feat(ui): add UI controls for Time-Stretch Envelope and Spectral Panning#717
Conversation
- Plumbed `timeStretchEnvDepth` through `useAudioEngine.ts` down to the `SingingVoice` and `rubberband-processor.ts`. - Added grouped UI sliders for `timeStretchEnvDepth`, `spectralPanRate`, and `spectralPanDepth` to `NoteSelector.tsx`. - Mapped per-step properties through `ContextMenuNode.tsx`. - Added global Knobs for Spectral Pan Rate and Depth to `SamplerPanel.tsx`. - Updated test parameters to fix component count assertions. - Updated `agent_plan.md` to reflect completed tasks and added new roadmap ideas. Co-authored-by: ford442 <9397845+ford442@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
Warning Review limit reached
More reviews will be available in 38 minutes and 43 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more credits in the billing tab to continue. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
📝 WalkthroughWalkthroughMixed refactoring and cleanup across state management, parameter types, and audio infrastructure. Consolidates pattern state updates to functional setters, removes timeStretchEnvDepth parameter, unifies slideTime/portamento mappings, consolidates ExpressiveVoiceProcessor effects, regenerates WASM runtime glue, improves modal accessibility, and updates work planning. ChangesState management, parameter cleanup, and audio infrastructure refactoring
Sequence Diagram(s)(No sequence diagram generated: changes span multiple independent refactoring and cleanup areas rather than a coherent interactive flow.) Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- Fix a bug in `RbsImporter.ts` where `slideTime` was referenced instead of the unnormalized `rawSlideTime` when defining the Bass2Params parameter mappings, causing a ReferenceError during CI tests. Co-authored-by: ford442 <9397845+ford442@users.noreply.github.com>
- Resolved `slideTime` ReferenceError in RbsImporter by substituting `portamento` where `slideTime` was out of scope. - Fixed a duplicated `currentStepRef` object literal property in useAppState.tsx. - Added missing `length` property to Voice.test.ts SynthParams mock. - Addressed `Float32Array` type mismatch with `ArrayBufferLike` in `useLevelMeter.ts`. - Removed an unused `@ts-expect-error` in `RustOscillator.ts`. - Removed an errant comma causing unused-expression error in `patternRandomizer.test.ts`. Co-authored-by: ford442 <9397845+ford442@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@public/jc303-threaded.js`:
- Line 1: The expression for ENVIRONMENT_IS_PTHREAD accidentally runs into the
following IIFE, causing the string "em-pthread" to be invoked as a function; fix
by separating the boolean expression from the IIFE — change the sequence around
ENVIRONMENT_IS_PTHREAD and the immediate (function(){...})(); so
ENVIRONMENT_IS_PTHREAD is assigned the boolean (e.g. keep var
ENVIRONMENT_IS_PTHREAD=!!globalThis.WorkerGlobalScope&&globalThis.name=="em-pthread";)
and then run the wasmTable init IIFE as a standalone statement
(function(){...})(); so locate the ENVIRONMENT_IS_PTHREAD definition and the
following wasmTable initialization IIFE and insert the separator (semicolon) or
end the assignment before the IIFE.
In `@src/components/RbsImportModal.tsx`:
- Line 409: RbsImportModal currently doesn't trap or set focus when opened;
update the modal to use the existing useFocusTrap hook: add a ref (e.g.,
modalRef) to the dialog container (ensure it has tabIndex={-1} so it can receive
focus) and call useFocusTrap(modalRef, { initialFocus: closeButtonRef }) where
closeButtonRef points to the modal's close button or first interactive control;
ensure the close button/getInitial focus ref is created (useRef) and passed as
initialFocus, and confirm focus is restored on unmount/close by relying on the
hook's restore behavior or calling focus on the previously active element in the
modal close handler in RbsImportModal.
In `@src/hooks/useAppState.tsx`:
- Around line 1181-1185: The code nullifies subsequent steps using a hardcoded
256 bound which can overflow the actual lane length; update both loops that
check targetIndex < 256 to use the actual step-array length (e.g., targetIndex <
newBank.steps.length or the lane size constant) so nullification stays inside
the lane; locate the loops that reference newBank.steps, stepIndex and newLength
(the blocks around the current nullification and the similar block at the other
occurrence) and replace the 256 check with the dynamic length check.
In `@src/hooks/useAppState.tsx.orig`:
- Around line 1-1258: Remove the merge-conflict backup file
(useAppState.tsx.orig) from the PR and prevent future commits of .orig files:
delete the .orig file from the changeset (so only the correct useAppState.tsx
remains), add the pattern *.orig to .gitignore, stage the .gitignore update, and
commit/push the changes; verify the exported symbol/useAppState hook
implementation is coming from useAppState.tsx (not the .orig) before merging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6f40491b-3514-48bd-a604-bdc396a8c277
⛔ Files ignored due to path filters (10)
public/hyphon_native.wasmis excluded by!**/*.wasmpublic/jc303-single.wasmis excluded by!**/*.wasmpublic/jc303-threaded.wasmis excluded by!**/*.wasmpublic/jc303.wasmis excluded by!**/*.wasmsrc/wasm/audioExport.wasmis excluded by!**/*.wasmsrc/wasm/fft.wasmis excluded by!**/*.wasmsrc/wasm/jc303-single.wasmis excluded by!**/*.wasmsrc/wasm/oscillators.wasmis excluded by!**/*.wasmsrc/wasm/trackFreezer.wasmis excluded by!**/*.wasmsrc/wasm/xmExport.wasmis excluded by!**/*.wasm
📒 Files selected for processing (26)
.jules/bolt.mdagent_plan.mdplan.mdpublic/hyphon_native.jspublic/jc303-single-worklet.jspublic/jc303-single.jspublic/jc303-threaded-worklet.jspublic/jc303-threaded.jspublic/jc303.jspublic/jc303_worklet.jssrc/audio-worklets/expressive-voice-processor-worklet.tssrc/audio-worklets/open303-processor.tssrc/components/AISongModal.tsxsrc/components/RbsImportModal.tsxsrc/components/automation/AutomationLaneList.tsxsrc/constants/appDefaults.tssrc/engines/Open303Oscillator.tssrc/engines/__tests__/Voice.test.tssrc/engines/rubberband/ExpressiveVoiceProcessor.tssrc/hooks/useAppState.tsxsrc/hooks/useAppState.tsx.origsrc/hooks/useAudioEngine.tssrc/hooks/useLevelMeter.tssrc/importers/rbs/RbsImporter.tssrc/importers/rbs/types.tssrc/types.ts
💤 Files with no reviewable changes (8)
- src/components/automation/AutomationLaneList.tsx
- src/components/AISongModal.tsx
- src/engines/Open303Oscillator.ts
- src/constants/appDefaults.ts
- src/hooks/useAudioEngine.ts
- src/types.ts
- .jules/bolt.md
- src/audio-worklets/open303-processor.ts
| aria-hidden="true" | ||
| /> | ||
| <div role="dialog" tabIndex={-1} aria-modal="true" aria-labelledby="rbs-import-title" aria-describedby="rbs-import-desc" className="relative z-10 bg-[#0f1115] border border-amber-500/30 rounded-xl shadow-[0_0_60px_rgba(245,158,11,0.2)] w-full max-w-4xl max-h-[90vh] flex flex-col"> | ||
| <div role="dialog" aria-modal="true" aria-labelledby="rbs-import-title" aria-describedby="rbs-import-desc" className="relative z-10 bg-[#0f1115] border border-amber-500/30 rounded-xl shadow-[0_0_60px_rgba(245,158,11,0.2)] w-full max-w-4xl max-h-[90vh] flex flex-col"> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Search for focus management in modal components and utilities
echo "=== Checking for focus trap libraries ==="
rg -i "focus-trap|use-focus-trap|react-focus-lock" package.json package-lock.json
echo -e "\n=== Checking for focus management in modal components ==="
rg -nP -A5 "\.focus\(\)" src/components/*Modal.tsx
echo -e "\n=== Checking for useEffect with focus in modals ==="
rg -nP -A3 "useEffect.*focus" src/components/*Modal.tsx
echo -e "\n=== Checking for modal wrapper or provider with focus management ==="
rg -nP -C3 "class.*Modal|function.*Modal" src/components/ src/providers/ --type ts --type tsx 2>/dev/null || rg -nP -C3 "class.*Modal|function.*Modal" src/components/ src/providers/ -g "*.ts" -g "*.tsx"
echo -e "\n=== Checking for aria-modal usage patterns ==="
rg -nP 'aria-modal.*true' src/components/ -g "*.tsx" -C2Repository: ford442/web_sequencer
Length of output: 11072
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== RbsImportModal.tsx: focus/refs/useEffect ==="
rg -n "RbsImportModal|tabIndex=\{-1\}|ref=|useEffect\(|\.focus\(" src/components/RbsImportModal.tsx -S
echo -e "\n=== Any .focus() calls in other modal files (broader) ==="
rg -nP "\.focus\(\)" src/components -g "*Modal*.tsx" -S
echo -e "\n=== Search entire src for focus-trap utilities ==="
rg -n -i "focus-trap|use-focus-trap|react-focus-lock|focusTrap|trapFocus|focus management" src -S
echo -e "\n=== Search for dialogRef/guideRef ref focus usage (broader) ==="
rg -nP "(\w*Ref\.current|\w*Ref)\.current\?\.focus\(" src/components -S
rg -nP "\.current\?\.focus\(" src/components -SRepository: ford442/web_sequencer
Length of output: 1739
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/components/RbsImportModal.tsx"
echo "=== Context around useEffect blocks in RbsImportModal.tsx ==="
nl -ba "$FILE" | sed -n '70,140p'
echo -e "\n=== Find refs in RbsImportModal.tsx (dialog/fileInput etc.) ==="
rg -n "useRef\(|ref=" "$FILE" -S
echo -e "\n=== Context around file input ref and dialog container (around the tabIndex line) ==="
nl -ba "$FILE" | sed -n '380,460p'
nl -ba "$FILE" | sed -n '480,560p'
echo -e "\n=== Search within file for focus() / focusable container logic ==="
rg -nP "\.focus\(" "$FILE" -SRepository: ford442/web_sequencer
Length of output: 188
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/components/RbsImportModal.tsx"
echo "=== Context around useEffect blocks in RbsImportModal.tsx (lines ~70-140) ==="
awk 'NR>=70 && NR<=150 {print NR ":" $0}' "$FILE"
echo -e "\n=== Find refs in RbsImportModal.tsx ==="
rg -n "useRef\(|ref=" "$FILE" -S
echo -e "\n=== Context around tabIndex dialog line (lines ~390-440) ==="
awk 'NR>=380 && NR<=440 {print NR ":" $0}' "$FILE"
echo -e "\n=== Context around file input ref (lines ~500-540) ==="
awk 'NR>=500 && NR<=560 {print NR ":" $0}' "$FILE"
echo -e "\n=== Search within file for focus() calls ==="
rg -nP "\.focus\(" "$FILE" -S || trueRepository: ford442/web_sequencer
Length of output: 8804
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Find any tests for RbsImportModal / aria focus ==="
rg -n "RbsImportModal|rbs-import-title|rbs-import-desc|role=\"dialog\".*rbs" src/components -S
echo -e "\n=== Search for modal/focus management utilities in src ==="
rg -n -i "focus trap|focus-trap|focuslock|focusLock|FocusLock|useFocus|useFocusEffect|trapFocus|sentinel|TabIndex=-1|restoreFocus|returnFocus|initialFocus" src -S
echo -e "\n=== Search for common dialog libraries (HeadlessUI/Radix/React-Aria) ==="
rg -n "`@headlessui`|`@radix-ui`|react-aria|`@react-aria`|react-focus|focus-management|aria-dialog" package.json src -S || trueRepository: ford442/web_sequencer
Length of output: 4604
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/components/RbsImportModal.tsx"
echo "=== Does RbsImportModal use useFocusTrap? ==="
rg -n "useFocusTrap" "$FILE" -S || true
echo -e "\n=== RbsImportModal imports (top of file) ==="
awk 'NR>=1 && NR<=60 {print NR ":" $0}' "$FILE"
echo -e "\n=== Inspect useFocusTrap hook ==="
awk 'NR>=1 && NR<=120 {print NR ":" $0}' src/hooks/useFocusTrap.tsRepository: ford442/web_sequencer
Length of output: 6396
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# focus mgmt patterns in RbsImportModal
rg -n "autoFocus|initialFocus|restoreFocus|returnFocus|useFocusTrap|focusTrap|onClose\(|tabIndex=\{-1\}|ref=.*useFocusTrap" src/components/RbsImportModal.tsx -S
# see how it is opened
rg -n "RbsImportModal" src/components -S
# inspect BottomBar around state/open call
nl -ba src/components/BottomBar.tsx | sed -n '150,240p'Repository: ford442/web_sequencer
Length of output: 814
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/components/RbsImportModal.tsx"
echo "=== tabIndex usage in RbsImportModal.tsx ==="
rg -n "tabIndex" "$FILE" -S
echo -e "\n=== Any refs in RbsImportModal.tsx besides fileInputRef ==="
rg -n "useRef\(|ref=" "$FILE" -S
echo -e "\n=== Search for focusing behavior in RbsImportModal.tsx ==="
rg -n -P "\.focus\(|autoFocus|initialFocus|restoreFocus|returnFocus" "$FILE" -S || true
echo -e "\n=== Inspect BottomBar around the RbsImportModal open call (lines ~160-210) ==="
BFILE="src/components/BottomBar.tsx"
awk 'NR>=150 && NR<=230 {print NR ":" $0}' "$BFILE"Repository: ford442/web_sequencer
Length of output: 110
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Where RbsImportModal is rendered ==="
rg -n "<\s*RbsImportModal\b|RbsImportModal\s*\(" src -S
echo -e "\n=== Does the immediate parent (likely BottomBar) render a focus trap around it? ==="
BFILE="src/components/BottomBar.tsx"
echo "--- BottomBar.tsx: imports (top) ---"
awk 'NR>=1 && NR<=80 {print NR ":" $0}' "$BFILE"
echo -e "\n--- BottomBar.tsx: state + render where RbsImportModal is used (find isRbsImportModalOpen block) ---"
rg -n "isRbsImportModalOpen|RbsImportModal" "$BFILE"
awk 'NR>=150 && NR<=310 {print NR ":" $0}' "$BFILE"
echo -e "\n=== Confirm RbsImportModal.tsx has no focus trap usage ==="
rg -n "useFocusTrap|focusTrap|ref=.*dialog|dialogRef" src/components/RbsImportModal.tsx -S || true
rg -n "ref=" src/components/RbsImportModal.tsx -SRepository: ford442/web_sequencer
Length of output: 17071
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/App.tsx"
echo "=== App.tsx: imports (top) mentioning focus trap ==="
rg -n "useFocusTrap|focus trap|focusTrap" "$FILE" -S || true
echo -e "\n=== App.tsx: where RbsImportModal is rendered ==="
rg -n "RbsImportModal" "$FILE" -S
# show a small window around the render call
LINE=$(rg -n "RbsImportModal" "$FILE" | head -n1 | cut -d: -f1)
START=$((LINE-40))
END=$((LINE+40))
awk -v s=$START -v e=$END 'NR>=s && NR<=e {print NR ":" $0}' "$FILE"Repository: ford442/web_sequencer
Length of output: 3723
Fix focus management for RbsImportModal when it opens
RbsImportModal renders a proper dialog (role="dialog", aria-modal="true", aria-labelledby/aria-describedby) and handles Escape, but it does not appear to manage focus for keyboard users:
- The dialog container has no
tabIndexinsrc/components/RbsImportModal.tsx(line ~409) and the component has no focus calls. - The modal only registers a keydown handler for Escape (no Tab trapping).
- The codebase already has a
useFocusTraphook (src/hooks/useFocusTrap.ts) used by other modals (e.g.,VoiceEditor,NoteSelector,PatternSelector), butRbsImportModaldoesn’t use it.
Please update RbsImportModal to use useFocusTrap (and set an initial focus target such as the close button / first interactive control) to ensure focus moves into the modal, Tabs can’t leave it, and focus is restored on close.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/RbsImportModal.tsx` at line 409, RbsImportModal currently
doesn't trap or set focus when opened; update the modal to use the existing
useFocusTrap hook: add a ref (e.g., modalRef) to the dialog container (ensure it
has tabIndex={-1} so it can receive focus) and call useFocusTrap(modalRef, {
initialFocus: closeButtonRef }) where closeButtonRef points to the modal's close
button or first interactive control; ensure the close button/getInitial focus
ref is created (useRef) and passed as initialFocus, and confirm focus is
restored on unmount/close by relying on the hook's restore behavior or calling
focus on the previously active element in the modal close handler in
RbsImportModal.
| for (let i = 1; i < newLength; i++) { | ||
| const targetIndex = stepIndex + i; | ||
| if (targetIndex < 256) { | ||
| newPattern = updateSamplerStep(newPattern, bankIdx, targetIndex, () => null); | ||
| newBank.steps[targetIndex] = null; | ||
| } |
There was a problem hiding this comment.
Use step-array bounds instead of hardcoded 256 during note-length nullification.
Line 1183 and Line 1206 use targetIndex < 256; this can extend steps past the lane size and produce inconsistent pattern arrays.
Suggested fix
for (let i = 1; i < newLength; i++) {
const targetIndex = stepIndex + i;
- if (targetIndex < 256) {
+ if (targetIndex < newBank.steps.length) {
newBank.steps[targetIndex] = null;
}
}
@@
for (let i = 1; i < newLength; i++) {
const targetIndex = stepIndex + i;
- if (targetIndex < 256) {
+ if (targetIndex < newTrack.steps.length) {
newTrack.steps[targetIndex] = null;
}
}Also applies to: 1204-1208
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/useAppState.tsx` around lines 1181 - 1185, The code nullifies
subsequent steps using a hardcoded 256 bound which can overflow the actual lane
length; update both loops that check targetIndex < 256 to use the actual
step-array length (e.g., targetIndex < newBank.steps.length or the lane size
constant) so nullification stays inside the lane; locate the loops that
reference newBank.steps, stepIndex and newLength (the blocks around the current
nullification and the similar block at the other occurrence) and replace the 256
check with the dynamic length check.
| import { useCallback, useEffect, useRef, useState, useMemo } from 'react' | ||
| import { useAudioEngine } from './useAudioEngine' | ||
| import { usePyodideEngine } from './usePyodideEngine' | ||
| import { useScheduler } from './useScheduler' | ||
| import { useStepHandler } from './useStepHandler' | ||
| import { useGamepad } from './useGamepad' | ||
| import { useStableKnobConfig } from './useStableKnobConfig' | ||
| import { useSongStorage } from './useSongStorage' | ||
| import { useTTSPreloader } from './useTTSPreloader' | ||
| import { SupertonicService } from '../services/Supertonic' | ||
| import { loadingProgressStore } from '../stores/loadingProgressStore' | ||
| import { exportSongToXM } from '../utils/xmExport' | ||
| import { noteToMidi, midiToNote } from '../utils/musicTheory' | ||
| import type { ScaleDefinition } from '../utils/musicTheory' | ||
| import { copySteps, pasteSteps } from '../utils/clipboardUtils' | ||
| import type { MainSequencerHandle } from '../components/MainSequencer' | ||
| import type { AlignmentResult } from '../engines/rubberband/PhonemeAligner' | ||
| import { type HarmonizerConfig } from '../engines/Harmonizer' | ||
| import { WaveformSelector } from '../components/WaveformSelector' | ||
| import { SamplerPanel } from '../components/SamplerPanel' | ||
|
|
||
| import { | ||
| NUM_STEPS, | ||
| DEFAULT_TEMPO, | ||
| DEFAULT_SYNTH_PARAMS_A, | ||
| DEFAULT_SYNTH_PARAMS_B, | ||
| DEFAULT_BASS2_PARAMS, | ||
| DEFAULT_KICK_PARAMS, | ||
| DEFAULT_SNARE_PARAMS, | ||
| DEFAULT_CLOSED_HAT_PARAMS, | ||
| DEFAULT_OPEN_HAT_PARAMS, | ||
| } from '../constants' | ||
| import type { Pattern, SynthParams, KickParams, SnareParams, SamplerParams, SamplerBankParams, PartSequence, Note, Bass2Params, PhonemeData, ReverbType } from '../types' | ||
| import { | ||
| INITIAL_SAMPLER_PARAMS, UPDATED_INITIAL_PATTERN, | ||
| type TrackKey, type SongSnapshot, | ||
| getInitialTrackStorage, | ||
| } from '../constants/appDefaults' | ||
| import { | ||
| getBass2Controls, getSynthControls, getKickControls, getSnareControls, | ||
| getClosedHatControls, getOpenHatControls, getSamplerControls, | ||
| } from '../utils/knobConfigs' | ||
|
|
||
| export function useAppState() { | ||
| const { pyodide, isPyodideReady, pyodideStatus } = usePyodideEngine() | ||
| const [isVoiceEditorOpen, setIsVoiceEditorOpen] = useState(false); | ||
| const [isCloudLibraryOpen, setIsCloudLibraryOpen] = useState(false); | ||
| const [isAISongModalOpen, setIsAISongModalOpen] = useState(false); | ||
| const [isRbsImportModalOpen, setIsRbsImportModalOpen] = useState(false); | ||
| const [isLyricTrackVisible, setIsLyricTrackVisible] = useState(false); | ||
| const [isShortcutsHelpOpen, setIsShortcutsHelpOpen] = useState(false); | ||
| const [showGamepadDebug, setShowGamepadDebug] = useState(false); | ||
| const [isGenerating, setIsGenerating] = useState(false); | ||
| const [hasStarted, setHasStarted] = useState(false); | ||
| const [forceScriptProcessorFallback, setForceScriptProcessorFallback] = useState(() => { | ||
| return localStorage.getItem('forceScriptProcessorFallback') === 'true'; | ||
| }); | ||
|
|
||
| useGamepad(); | ||
|
|
||
| const [is3DMode, setIs3DMode] = useState(false); | ||
|
|
||
| const [toast, setToast] = useState<{ message: string, type: 'success' | 'error' | 'info' } | null>(null); | ||
| const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => { | ||
| setToast({ message, type }); | ||
| }, []); | ||
|
|
||
| const lastFreqRef = useRef<Record<string, number>>({ partA: 0, partB: 0 }); | ||
| const { audioEngine, isReady, initializeAudio, onParamChange } = useAudioEngine(pyodide) | ||
| const isEngineReady = isReady && (isPyodideReady || !!pyodideStatus) | ||
|
|
||
| useTTSPreloader() | ||
|
|
||
| const [viewMode, setViewMode] = useState<'notes' | 'automation'>('notes'); | ||
| const [automationParam, setAutomationParam] = useState('formantShift'); | ||
|
|
||
| const [melodicMode, setMelodicMode] = useState(false); | ||
|
|
||
| const [activeAlignment, setActiveAlignment] = useState<AlignmentResult | null>(null); | ||
|
|
||
| const lastSamplerMidiRef = useRef<Record<number, number>>({}); | ||
|
|
||
| const handleStart = async () => { | ||
| console.log("Initialization sequence started..."); | ||
| try { | ||
| setHasStarted(true); | ||
| await initializeAudio(); | ||
| setIsInitialized(true); | ||
| console.log("Audio Engine Initialized"); | ||
| loadingProgressStore.startStep('ttsEngine'); | ||
| SupertonicService.getInstance().init().then(() => { | ||
| loadingProgressStore.completeStep('ttsEngine'); | ||
| }).catch((e: unknown) => { | ||
| loadingProgressStore.failStep( | ||
| 'ttsEngine', | ||
| e instanceof Error ? e : new Error(String(e)), | ||
| true | ||
| ); | ||
| }); | ||
| } catch (e) { | ||
| console.error("Failed to start system:", e); | ||
| } | ||
| }; | ||
|
|
||
| const [pattern, setPattern] = useState<Pattern>(UPDATED_INITIAL_PATTERN) | ||
| const [tempo, setTempo] = useState<number>(DEFAULT_TEMPO) | ||
| const [isInitialized, setIsInitialized] = useState(false) | ||
| const [isPlaying, setIsPlaying] = useState(false) | ||
| const [isRecording, setIsRecording] = useState(false) | ||
| const [selectedTrack, setSelectedTrack] = useState<TrackKey>('partA') | ||
| const [ambianceUrl, setAmbianceUrl] = useState<string>('') | ||
| const [backgroundImage, setBackgroundImage] = useState<string>('') | ||
| const [masterVolume, setMasterVolume] = useState(0.8) | ||
| const [masterSaturation, setMasterSaturation] = useState(0) | ||
| const [globalPan, setGlobalPan] = useState(0) | ||
| const [reverbType, setReverbType] = useState<ReverbType>('plate') | ||
|
|
||
| const [isSongModeOpen, setIsSongModeOpen] = useState(false); | ||
| const [isSongModeActive, setIsSongModeActive] = useState(false); | ||
| const [songStructure, setSongStructure] = useState<({ [key in TrackKey]: number | null })[]>( | ||
| Array(16).fill(null).map(() => ({ | ||
| partA: null, partB: null, bass2: null, kick: null, snare: null, closedHat: null, openHat: null, sampler: null | ||
| })) | ||
| ); | ||
| const [currentSongMeasure, setCurrentSongMeasure] = useState(0); | ||
| const [contextMenu, setContextMenu] = useState<{ x: number, y: number, track: TrackKey, step: number } | null>(null); | ||
| const [isNoteDragging, setIsNoteDragging] = useState(false); | ||
| const noteDragRef = useRef<{ track: TrackKey; step: number; startY: number; startMidi: number; hasMoved: boolean; lastMidi: number; pendingSequence?: PartSequence | PartSequence[]; } | null>(null); | ||
|
|
||
| const [currentScale, setCurrentScale] = useState<ScaleDefinition | null>(null); | ||
|
|
||
| const sliceHighlightRef = useRef<((slice: number) => void) | null>(null); | ||
|
|
||
| const [selection, setSelection] = useState<{ trackKey: TrackKey; startStep: number; endStep: number; } | null>(null); | ||
| const [isSelecting, setIsSelecting] = useState(false); | ||
| const [clipboard, setClipboard] = useState<(Note | null)[] | null>(null); | ||
| const [isDrawing, setIsDrawing] = useState(false); | ||
| const [drawMode, setDrawMode] = useState<'add' | 'remove' | null>(null); | ||
|
|
||
| const [zoomLevel, setZoomLevel] = useState(1); | ||
|
|
||
| const handleSelectionStart = useCallback((trackKey: TrackKey, stepIndex: number) => { | ||
| setSelection({ trackKey, startStep: stepIndex, endStep: stepIndex }); | ||
| setIsSelecting(true); | ||
| }, []); | ||
|
|
||
| const handleSelectionEnter = useCallback((trackKey: TrackKey, stepIndex: number) => { | ||
| if (isSelecting && selection && selection.trackKey === trackKey) { | ||
| setSelection(prev => prev ? { ...prev, endStep: stepIndex } : null); | ||
| } | ||
| }, [isSelecting, selection]); | ||
|
|
||
| const handleSelectionEnd = useCallback(() => { setIsSelecting(false); }, []); | ||
|
|
||
| const [trackStorage, setTrackStorage] = useState<Record<TrackKey, (PartSequence | PartSequence[] | null)[]>>( | ||
| getInitialTrackStorage(UPDATED_INITIAL_PATTERN) | ||
| ); | ||
| const [activeTrackSlots, setActiveTrackSlots] = useState<Record<TrackKey, number>>({ | ||
| partA: 0, partB: 0, bass2: 0, kick: 0, snare: 0, closedHat: 0, openHat: 0, sampler: 0 | ||
| }); | ||
| const activeTrackSlotsRef = useRef(activeTrackSlots); | ||
| useEffect(() => { activeTrackSlotsRef.current = activeTrackSlots; }, [activeTrackSlots]); | ||
|
|
||
| const [songStorage, setSongStorage] = useState<(SongSnapshot | null)[]>([null, null, null, null]); | ||
| const [activeSongSlot, setActiveSongSlot] = useState<number | null>(null); | ||
|
|
||
| const [activeSamplerBank, setActiveSamplerBank] = useState(0); | ||
| const activeSamplerBankRef = useRef(activeSamplerBank); | ||
|
|
||
| useEffect(() => { | ||
| activeSamplerBankRef.current = activeSamplerBank; | ||
| if (audioEngine && audioEngine.getAlignment) { | ||
| setActiveAlignment(audioEngine.getAlignment(activeSamplerBank)); | ||
| } | ||
| }, [activeSamplerBank, audioEngine]); | ||
|
|
||
| const [sampleBuffers, setSampleBuffers] = useState<(AudioBuffer | null)[]>(new Array(8).fill(null)); | ||
| const loadedBanks = useMemo(() => sampleBuffers.map(b => !!b), [sampleBuffers]); | ||
|
|
||
| const multisampleReady = useMemo(() => | ||
| Array.from({ length: 8 }, (_, i) => audioEngine?.isMultisampleReady?.(i) ?? false), | ||
| [audioEngine, sampleBuffers] | ||
| ); | ||
| const multisampleProcessing = useMemo(() => | ||
| Array.from({ length: 8 }, (_, i) => { | ||
| const bank = audioEngine?.getMultisampleBank?.(i); | ||
| return bank?.isProcessing ?? false; | ||
| }), | ||
| [audioEngine, sampleBuffers] | ||
| ); | ||
| const [ttsPhrases, setTtsPhrases] = useState<string[]>(Array(8).fill("Hello World")); | ||
|
|
||
| const [synthA, setSynthA] = useState<SynthParams>(DEFAULT_SYNTH_PARAMS_A); | ||
| const synthARef = useRef<SynthParams>(DEFAULT_SYNTH_PARAMS_A); | ||
| const updateSynthA = useCallback((updates: Partial<SynthParams>) => { setSynthA(prev => { const n = { ...prev, ...updates }; synthARef.current = n; return n; }); }, []); | ||
|
|
||
| const [synthB, setSynthB] = useState<SynthParams>(DEFAULT_SYNTH_PARAMS_B); | ||
| const synthBRef = useRef<SynthParams>(DEFAULT_SYNTH_PARAMS_B); | ||
| const updateSynthB = useCallback((updates: Partial<SynthParams>) => { setSynthB(prev => { const n = { ...prev, ...updates }; synthBRef.current = n; return n; }); }, []); | ||
|
|
||
| const [bass2, setBass2] = useState<Bass2Params>(DEFAULT_BASS2_PARAMS); | ||
| const bass2Ref = useRef<Bass2Params>(DEFAULT_BASS2_PARAMS); | ||
| const updateBass2 = useCallback((updates: Partial<Bass2Params>) => { setBass2(prev => { const n = { ...prev, ...updates }; bass2Ref.current = n; return n; }); }, []); | ||
|
|
||
| const [kick, setKick] = useState<KickParams>(DEFAULT_KICK_PARAMS); | ||
| const kickRef = useRef(DEFAULT_KICK_PARAMS); | ||
| const updateKick = useCallback((u: Partial<KickParams>) => { setKick(prev => { const n = { ...prev, ...u }; kickRef.current = n; return n; }); }, []); | ||
|
|
||
| const [snare, setSnare] = useState<SnareParams>(DEFAULT_SNARE_PARAMS); | ||
| const snareRef = useRef(DEFAULT_SNARE_PARAMS); | ||
| const updateSnare = useCallback((u: Partial<SnareParams>) => { setSnare(prev => { const n = { ...prev, ...u }; snareRef.current = n; return n; }); }, []); | ||
|
|
||
| const [closedHat, setClosedHat] = useState(DEFAULT_CLOSED_HAT_PARAMS); | ||
| const closedHatRef = useRef(DEFAULT_CLOSED_HAT_PARAMS); | ||
| const updateClosedHat = useCallback((u: Partial<typeof DEFAULT_CLOSED_HAT_PARAMS>) => { setClosedHat(prev => { const n = { ...prev, ...u }; closedHatRef.current = n; return n; }); }, []); | ||
|
|
||
| const [openHat, setOpenHat] = useState(DEFAULT_OPEN_HAT_PARAMS); | ||
| const openHatRef = useRef(DEFAULT_OPEN_HAT_PARAMS); | ||
| const updateOpenHat = useCallback((u: Partial<typeof DEFAULT_OPEN_HAT_PARAMS>) => { setOpenHat(prev => { const n = { ...prev, ...u }; openHatRef.current = n; return n; }); }, []); | ||
|
|
||
| const [sampler, setSampler] = useState<SamplerParams>(INITIAL_SAMPLER_PARAMS); | ||
| const samplerRef = useRef(INITIAL_SAMPLER_PARAMS); | ||
| const updateSampler = useCallback((u: SamplerParams) => { setSampler(u); samplerRef.current = u; }, []); | ||
|
|
||
| const samplerVoiceParamsRef = useRef({ | ||
| rootNote: 60, | ||
| coarseTune: 0, | ||
| fineTune: 0, | ||
| formantShift: 0, | ||
| pitchAttack: 0, | ||
| pitchDecay: 0.5, | ||
| quality: 'good' as 'preview' | 'good' | 'better' | 'best', | ||
| stretchMode: 'Time' as 'Time' | 'Pitch' | 'Formant', | ||
| lockToSequencer: false | ||
| }); | ||
| const [samplerVoiceParams, setSamplerVoiceParams] = useState(samplerVoiceParamsRef.current); | ||
|
|
||
| const [harmonizerConfig, setHarmonizerConfig] = useState<HarmonizerConfig>({ | ||
| voiceCount: 2, | ||
| harmonyType: 'third', | ||
| detuneSpread: 15, | ||
| formantSpread: 3 | ||
| }); | ||
| const [isHarmonizeActive, setIsHarmonizeActive] = useState(false); | ||
|
|
||
| const handleHarmonizerConfigChange = useCallback((config: HarmonizerConfig, isActive: boolean) => { | ||
| setHarmonizerConfig(config); | ||
| setIsHarmonizeActive(isActive); | ||
| if (audioEngine?.setHarmonizerConfig) { | ||
| audioEngine.setHarmonizerConfig(config, isActive); | ||
| } | ||
| }, [audioEngine]); | ||
|
|
||
| const handleSamplerVoiceChange = useCallback((param: string, value: number | string | boolean) => { | ||
| const newParams = { ...samplerVoiceParamsRef.current, [param]: value }; | ||
| samplerVoiceParamsRef.current = newParams; | ||
| setSamplerVoiceParams(newParams); | ||
| if (audioEngine?.updateSamplerVoiceParams) { | ||
| audioEngine.updateSamplerVoiceParams(activeSamplerBankRef.current, param, value); | ||
| } | ||
| }, [audioEngine]); | ||
|
|
||
| const handleAutoMix = useCallback(() => { | ||
| updateSynthA({ pan: -0.3 }); | ||
| updateSynthB({ pan: 0.3 }); | ||
| updateBass2({ pan: 0 }); | ||
| updateKick({ pan: 0 }); | ||
| updateSnare({ pan: 0 }); | ||
| updateClosedHat({ pan: 0.15 }); | ||
| updateOpenHat({ pan: 0.25 }); | ||
| setSampler(prev => { | ||
| const next = [...prev]; | ||
| for (let i = 0; i < 8; i++) { | ||
| next[i] = { ...next[i], pan: (i % 2 === 0 ? -0.4 : 0.4) + (i * 0.05) }; | ||
| } | ||
| return next; | ||
| }); | ||
|
|
||
| updateSynthA({ volume: 0.65 }); | ||
| updateSynthB({ volume: 0.65 }); | ||
| updateBass2({ volume: 0.85 }); | ||
| updateKick({ volume: 0.95 }); | ||
| updateSnare({ volume: 0.85 }); | ||
| updateClosedHat({ volume: 0.6 }); | ||
| updateOpenHat({ volume: 0.65 }); | ||
| setSampler(prev => { | ||
| const next = [...prev]; | ||
| for (let i = 0; i < 8; i++) { | ||
| next[i] = { ...next[i], volume: 0.7 }; | ||
| } | ||
| return next; | ||
| }); | ||
|
|
||
| setMasterVolume(0.85); | ||
| if (audioEngine) { | ||
| audioEngine.setMasterVolume(0.85); | ||
| } | ||
|
|
||
| console.log("Auto-Mix Assistant applied deterministic mixing parameters."); | ||
| }, [updateSynthA, updateSynthB, updateBass2, updateKick, updateSnare, updateClosedHat, updateOpenHat, audioEngine]); | ||
|
|
||
| const tempoHoldIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); | ||
| const tempoRef = useRef(tempo); | ||
| useEffect(() => { tempoRef.current = tempo; }, [tempo]); | ||
|
|
||
| const adjustTempo = useCallback((direction: number) => { setTempo(t => Math.max(30, Math.min(300, t + direction))); }, []); | ||
| const tempoHoldTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const handleTempoHoldStart = useCallback((direction: number) => { | ||
| adjustTempo(direction); | ||
| tempoHoldTimeoutRef.current = setTimeout(() => { tempoHoldIntervalRef.current = setInterval(() => { adjustTempo(direction); }, 50); }, 300); | ||
| }, [adjustTempo]); | ||
| const handleTempoHoldEnd = useCallback(() => { | ||
| if (tempoHoldTimeoutRef.current) { clearTimeout(tempoHoldTimeoutRef.current); tempoHoldTimeoutRef.current = null; } | ||
| if (tempoHoldIntervalRef.current) { clearInterval(tempoHoldIntervalRef.current); tempoHoldIntervalRef.current = null; } | ||
| }, []); | ||
| const handleTempoKeyDown = useCallback((e: React.KeyboardEvent, direction: number) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); adjustTempo(direction); } }, [adjustTempo]); | ||
|
|
||
| const handlePanic = useCallback(() => { | ||
| if (!audioEngine || !audioEngine.stopAllNotes) return; | ||
| audioEngine.stopAllNotes(); | ||
| activeKeyboardNotesRef.current.clear(); | ||
| }, [audioEngine]); | ||
|
|
||
| const patternRef = useRef(pattern); | ||
| useEffect(() => { patternRef.current = pattern; }, [pattern]); | ||
| const songStructureRef = useRef(songStructure); | ||
| useEffect(() => { songStructureRef.current = songStructure; }, [songStructure]); | ||
| const isSongModeActiveRef = useRef(isSongModeActive); | ||
| useEffect(() => { isSongModeActiveRef.current = isSongModeActive; }, [isSongModeActive]); | ||
| const trackStorageRef = useRef(trackStorage); | ||
| useEffect(() => { trackStorageRef.current = trackStorage; }, [trackStorage]); | ||
| const songMeasureRef = useRef(0); | ||
| const isFirstStepRef = useRef(true); | ||
|
|
||
| const sequencerRef = useRef<MainSequencerHandle>(null); | ||
| const currentStepRef = useRef(-1); | ||
|
|
||
| const currentScaleRef = useRef(currentScale); | ||
| useEffect(() => { currentScaleRef.current = currentScale; }, [currentScale]); | ||
|
|
||
| const { onStep } = useStepHandler({ | ||
| audioEngine, | ||
| tempo, | ||
| onParamChange, | ||
| currentStepRef, | ||
| sequencerRef, | ||
| patternRef, | ||
| lastFreqRef, | ||
| lastSamplerMidiRef, | ||
| synthARef, | ||
| synthBRef, | ||
| bass2Ref, | ||
| kickRef, | ||
| snareRef, | ||
| closedHatRef, | ||
| openHatRef, | ||
| samplerRef, | ||
| samplerVoiceParamsRef, | ||
| activeSamplerBankRef, | ||
| sliceHighlightRef, | ||
| isSongModeActiveRef, | ||
| songStructureRef, | ||
| currentScaleRef, | ||
| songMeasureRef, | ||
| isFirstStepRef, | ||
| trackStorageRef, | ||
| setCurrentSongMeasure, | ||
| }) | ||
|
|
||
| const { isPlaying: schedPlaying, setIsPlaying: setSchedPlaying } = useScheduler(tempo, NUM_STEPS, onStep, isEngineReady) | ||
| useEffect(() => setIsPlaying(schedPlaying), [schedPlaying]) | ||
|
|
||
| useEffect(() => { | ||
| if (!schedPlaying) { | ||
| songMeasureRef.current = 0; | ||
| setCurrentSongMeasure(0); | ||
| isFirstStepRef.current = true; | ||
| if (sequencerRef.current) sequencerRef.current.setHighlight(-1); | ||
| currentStepRef.current = -1; | ||
| } | ||
| }, [schedPlaying]); | ||
|
|
||
| const handlePlayToggle = useCallback(async () => { | ||
| if (!isInitialized) { await initializeAudio(); setIsInitialized(true); } | ||
| setSchedPlaying(prev => !prev) | ||
| }, [isInitialized, initializeAudio, setSchedPlaying]); | ||
|
|
||
| useEffect(() => { | ||
| const handleGlobalKeyDown = (e: KeyboardEvent) => { | ||
| if (e.code === 'Space') { | ||
| const target = e.target as HTMLElement; | ||
| if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; | ||
| e.preventDefault(); | ||
| handlePlayToggle(); | ||
| } | ||
| }; | ||
| window.addEventListener('keydown', handleGlobalKeyDown); | ||
| return () => window.removeEventListener('keydown', handleGlobalKeyDown); | ||
| }, [handlePlayToggle]); | ||
|
|
||
| const handleMasterVolume = (e: React.ChangeEvent<HTMLInputElement>) => { const v = parseFloat(e.target.value); setMasterVolume(v); audioEngine?.setMasterVolume(v); }; | ||
| const handleMasterVolumeKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); setMasterVolume(0.8); audioEngine?.setMasterVolume(0.8); } }; | ||
| const handleMasterVolumeReset = () => { setMasterVolume(0.8); audioEngine?.setMasterVolume(0.8); }; | ||
|
|
||
| const handleMasterSaturation = (e: React.ChangeEvent<HTMLInputElement>) => { const v = parseFloat(e.target.value); setMasterSaturation(v); audioEngine?.setMasterSaturation(v); }; | ||
| const handleMasterSaturationKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); setMasterSaturation(0); audioEngine?.setMasterSaturation(0); } }; | ||
| const handleMasterSaturationReset = () => { setMasterSaturation(0); audioEngine?.setMasterSaturation(0); }; | ||
|
|
||
| const handleGlobalPan = (e: React.ChangeEvent<HTMLInputElement>) => { const p = parseFloat(e.target.value); const val = (p > -0.1 && p < 0.1) ? 0 : p; setGlobalPan(val); audioEngine?.setGlobalPan(val); }; | ||
| const handleGlobalPanKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); setGlobalPan(0); audioEngine?.setGlobalPan(0); } }; | ||
| const handleGlobalPanReset = () => { setGlobalPan(0); audioEngine?.setGlobalPan(0); }; | ||
| const handleReverbType = (e: React.ChangeEvent<HTMLSelectElement>) => { | ||
| const t = e.target.value as ReverbType; | ||
| setReverbType(t); | ||
| audioEngine?.setReverbType?.(t); | ||
| }; | ||
| const updateStorageForTrack = useCallback((track: TrackKey, sequence: PartSequence | PartSequence[]) => { setTrackStorage(prev => { const copy = { ...prev }; copy[track] = [...copy[track]]; copy[track][activeTrackSlotsRef.current[track]] = sequence; return copy; }); }, []); | ||
|
|
||
| const handleCopy = useCallback(() => { | ||
| if (!selection) return; | ||
| const copied = copySteps(patternRef.current, selection, activeSamplerBankRef.current); | ||
| if (copied) { | ||
| setClipboard(copied); | ||
| showToast("Copied to clipboard!", "success"); | ||
| } | ||
| }, [selection, showToast]); | ||
|
|
||
| const handlePaste = useCallback(() => { | ||
| if (!clipboard) return; | ||
| let targetTrack = selectedTrack; | ||
| let targetStep = 0; | ||
| if (selection) { | ||
| targetTrack = selection.trackKey; | ||
| targetStep = selection.startStep; | ||
| } else { | ||
| if (!window.confirm(`Paste from clipboard to start of ${targetTrack}?`)) return; | ||
| } | ||
| const newPattern = pasteSteps(patternRef.current, clipboard, targetTrack, targetStep, activeSamplerBankRef.current); | ||
| setPattern(newPattern); | ||
| let changedSequence; | ||
| if (targetTrack === 'sampler') { | ||
| changedSequence = newPattern.sampler; | ||
| } else { | ||
| changedSequence = newPattern[targetTrack]; | ||
| } | ||
| updateStorageForTrack(targetTrack, changedSequence); | ||
| showToast("Pasted from clipboard!", "success"); | ||
| }, [clipboard, selection, selectedTrack, showToast, updateStorageForTrack]); | ||
|
|
||
| const handleAutomationChange = useCallback((trackKey: TrackKey, step: number, value: number) => { | ||
| setPattern(prev => { | ||
| const nextPattern = { ...prev }; | ||
| if (trackKey === 'sampler') { | ||
| const bankIdx = activeSamplerBankRef.current; | ||
| const nextSampler = [...nextPattern.sampler]; | ||
| const nextBank = { ...nextSampler[bankIdx] }; | ||
| const nextAutomation = nextBank.automation ? { ...nextBank.automation } : {}; | ||
| const nextParamArray = nextAutomation[automationParam] | ||
| ? [...nextAutomation[automationParam]] | ||
| : Array(NUM_STEPS).fill(null); | ||
| nextParamArray[step] = value; | ||
| nextAutomation[automationParam] = nextParamArray; | ||
| nextBank.automation = nextAutomation; | ||
| nextSampler[bankIdx] = nextBank; | ||
| nextPattern.sampler = nextSampler; | ||
| updateStorageForTrack(trackKey, nextSampler); | ||
| } else { | ||
| const nextTrack = { ...nextPattern[trackKey] } as any; | ||
| const nextAutomation = nextTrack.automation ? { ...nextTrack.automation } : {}; | ||
| const nextParamArray = nextAutomation[automationParam] | ||
| ? [...nextAutomation[automationParam]] | ||
| : Array(NUM_STEPS).fill(null); | ||
| nextParamArray[step] = value; | ||
| nextAutomation[automationParam] = nextParamArray; | ||
| nextTrack.automation = nextAutomation; | ||
| nextPattern[trackKey] = nextTrack; | ||
| updateStorageForTrack(trackKey, nextTrack); | ||
| } | ||
| return nextPattern; | ||
| }); | ||
| }, [activeSamplerBank, automationParam, updateStorageForTrack]); | ||
|
|
||
| const handlePitchChange = useCallback((trackKey: TrackKey, step: number, pitch: number) => { | ||
| if (trackKey !== 'sampler') return; | ||
| const note = midiToNote(pitch); | ||
| setPattern(prev => { | ||
| const copy = { ...prev }; | ||
| const bankIdx = activeSamplerBankRef.current; | ||
| const newSampler = [...copy.sampler]; | ||
| const newBank = { ...newSampler[bankIdx] }; | ||
| newBank.steps = [...newBank.steps]; | ||
| if (newBank.steps[step]) { | ||
| newBank.steps[step] = { ...newBank.steps[step]!, note }; | ||
| } else { | ||
| newBank.steps[step] = { note, velocity: 1, length: 1 }; | ||
| } | ||
| newSampler[bankIdx] = newBank; | ||
| copy.sampler = newSampler; | ||
| updateStorageForTrack('sampler', newSampler); | ||
| return copy; | ||
| }); | ||
| }, [updateStorageForTrack]); | ||
|
|
||
| const handlePhonemeUpdate = useCallback(( | ||
| trackKey: TrackKey, | ||
| bankIndex: number, | ||
| step: number, | ||
| phonemes: PhonemeData[] | undefined | ||
| ) => { | ||
| if (trackKey !== 'sampler') return; | ||
| setPattern(prev => { | ||
| const newPattern = { ...prev }; | ||
| const newSampler = [...newPattern.sampler]; | ||
| const newBank = { ...newSampler[bankIndex] }; | ||
| newBank.steps = [...newBank.steps]; | ||
| if (newBank.steps[step]) { | ||
| newBank.steps[step] = { ...newBank.steps[step]!, phonemes }; | ||
| } else { | ||
| newBank.steps[step] = { note: 'C4', velocity: 1, length: 1, phonemes }; | ||
| } | ||
| newSampler[bankIndex] = newBank; | ||
| newPattern.sampler = newSampler; | ||
| updateStorageForTrack('sampler', newSampler); | ||
| return newPattern; | ||
| }); | ||
| }, [updateStorageForTrack]); | ||
|
|
||
| const handlePatternChange = useCallback((rowKey: keyof Pattern, i: number, _subIndex?: number | unknown, updates?: { length?: number, slide?: boolean, chord?: string[], sliceIndex?: number }) => { | ||
| const prev = patternRef.current; | ||
| const copy = { ...prev }; | ||
| let changedSequence; | ||
| if (rowKey === 'sampler') { | ||
| const bankIndex = activeSamplerBankRef.current; | ||
| const newSampler = [...prev.sampler]; | ||
| newSampler[bankIndex] = { ...newSampler[bankIndex], steps: [...newSampler[bankIndex].steps] }; | ||
| const steps = newSampler[bankIndex].steps; | ||
| const existing = steps[i]; | ||
| if (updates) { | ||
| if (existing) { | ||
| const newStep = { ...existing }; | ||
| if (updates.length !== undefined) newStep.length = updates.length; | ||
| if (updates.slide !== undefined) newStep.slide = updates.slide; | ||
| if (updates.chord !== undefined) newStep.chord = updates.chord; | ||
| if (updates.sliceIndex !== undefined) newStep.sliceIndex = updates.sliceIndex; | ||
| steps[i] = newStep; | ||
| if (updates.length !== undefined) { for (let k = 1; k < updates.length; k++) { const nextStepIdx = i + k; if (nextStepIdx < steps.length) { steps[nextStepIdx] = null; } } } | ||
| } | ||
| } else { if (existing) { steps[i] = null; } else { steps[i] = { note: 'C4', velocity: 1, length: 1, slide: false }; } } | ||
| copy.sampler = newSampler; | ||
| changedSequence = newSampler; | ||
| } else { | ||
| copy[rowKey] = { ...prev[rowKey], steps: [...prev[rowKey].steps] }; | ||
| const steps = copy[rowKey].steps; | ||
| const existing = steps[i]; | ||
| if (updates) { | ||
| if (existing) { | ||
| const newStep = { ...existing }; | ||
| if (updates.length !== undefined) newStep.length = updates.length; | ||
| if (updates.slide !== undefined) newStep.slide = updates.slide; | ||
| if (updates.chord !== undefined) newStep.chord = updates.chord; | ||
| steps[i] = newStep; | ||
| if (updates.length !== undefined) { for (let k = 1; k < updates.length; k++) { const nextStepIdx = i + k; if (nextStepIdx < steps.length) { steps[nextStepIdx] = null; } } } | ||
| } | ||
| } else { if (existing) { steps[i] = null; } else { const defaultNote = rowKey.startsWith('part') ? (rowKey === 'partA' ? 'C4' : 'C3') : 'C4'; steps[i] = { note: defaultNote, velocity: 1, length: 1, slide: false }; } } | ||
| changedSequence = copy[rowKey]; | ||
| } | ||
| setPattern(copy); | ||
| updateStorageForTrack(rowKey, changedSequence); | ||
| }, [updateStorageForTrack]); | ||
|
|
||
| const handleStepToggle = useCallback((rowKey: TrackKey, index: number, e: any) => { | ||
| if (e.altKey) { e.preventDefault(); let step = null; if (rowKey === 'sampler') { step = patternRef.current.sampler[activeSamplerBankRef.current].steps[index]; } else { step = patternRef.current[rowKey].steps[index]; } if (step) { handlePatternChange(rowKey, index, undefined, { slide: !step.slide }); } return; } | ||
| if (e.ctrlKey || e.metaKey) { e.preventDefault(); let step = null; if (rowKey === 'sampler') { step = patternRef.current.sampler[activeSamplerBankRef.current].steps[index]; } else { step = patternRef.current[rowKey].steps[index]; } if (step) { if (step.chord && step.chord.length > 0) { handlePatternChange(rowKey, index, undefined, { chord: [] }); } else { const root = noteToMidi(step.note); const chord = [midiToNote(root + 4), midiToNote(root + 7)]; handlePatternChange(rowKey, index, undefined, { chord }); } } return; } | ||
| const pattern = patternRef.current; | ||
| let step = null; | ||
| if (rowKey === 'sampler') { step = pattern.sampler[activeSamplerBankRef.current].steps[index]; } | ||
| else { step = pattern[rowKey].steps[index]; } | ||
| const isActive = !!step; | ||
| setIsDrawing(true); | ||
| setDrawMode(isActive ? 'remove' : 'add'); | ||
| handlePatternChange(rowKey, index, e); | ||
| }, [handlePatternChange, activeSamplerBank]); | ||
|
|
||
| const activeKeyboardNotesRef = useRef<Map<string, number>>(new Map()); | ||
| const handleKeyboardPlay = useCallback((note: string) => { | ||
| if (!audioEngine) return; | ||
| const time = audioEngine.context.currentTime; | ||
| if (selectedTrack === 'partA') { const maybe = audioEngine.noteOnSynth?.(synthARef.current, note, time, 'partA'); Promise.resolve(maybe).then((id) => { if (id) activeKeyboardNotesRef.current.set(note, id); }); } | ||
| else if (selectedTrack === 'partB') { const maybe = audioEngine.noteOnSynth?.(synthBRef.current, note, time, 'partB'); Promise.resolve(maybe).then((id) => { if (id) activeKeyboardNotesRef.current.set(note, id); }); } | ||
| else if (selectedTrack === 'bass2') { | ||
| const bass2Params: SynthParams = { | ||
| waveform: bass2Ref.current.waveform, | ||
| pitch: bass2Ref.current.pitch, | ||
| filterCutoff: bass2Ref.current.cutoff, | ||
| filterResonance: bass2Ref.current.resonance, | ||
| filterMode: bass2Ref.current.filterMode, | ||
| attack: 0.01, | ||
| decay: bass2Ref.current.decay, | ||
| sustain: 0, | ||
| release: 0.1, | ||
| length: 0.25, | ||
| volume: bass2Ref.current.volume, | ||
| delayTime: 0, | ||
| delayFeedback: 0, | ||
| delayMix: 0, | ||
| }; | ||
| const maybe = audioEngine.noteOnSynth?.(bass2Params, note, time, 'bass2'); | ||
| Promise.resolve(maybe).then((id) => { if (id) activeKeyboardNotesRef.current.set(note, id); }); | ||
| } | ||
| else if (selectedTrack === 'kick') audioEngine.playDrum('kick', { ...kickRef.current, pitch: 60 }, time); | ||
| else if (selectedTrack === 'snare') audioEngine.playDrum('snare', snareRef.current, time); | ||
| else if (selectedTrack === 'closedHat') audioEngine.playDrum('closedHat', closedHatRef.current, time); | ||
| else if (selectedTrack === 'openHat') audioEngine.playDrum('openHat', openHatRef.current, time); | ||
| else if (selectedTrack === 'sampler') { | ||
| const voiceParams = samplerVoiceParamsRef.current; | ||
| const bankParams = { | ||
| ...samplerRef.current[activeSamplerBank], | ||
| rootNote: voiceParams.rootNote, | ||
| coarseTune: voiceParams.coarseTune, | ||
| fineTune: voiceParams.fineTune, | ||
| formantShift: voiceParams.formantShift, | ||
| pitchAttack: voiceParams.pitchAttack, | ||
| pitchDecay: voiceParams.pitchDecay, | ||
| quality: voiceParams.quality, | ||
| stretchMode: voiceParams.stretchMode, | ||
| lockToSequencer: voiceParams.lockToSequencer | ||
| }; | ||
| const id = audioEngine.noteOnSampler?.(bankParams, note, time) ?? null; | ||
| if (id) activeKeyboardNotesRef.current.set(note, id); | ||
| } | ||
| const step = currentStepRef.current; | ||
| if (isRecording && isPlaying && step >= 0) { setPattern(prev => { const copy = JSON.parse(JSON.stringify(prev)) as Pattern; if (selectedTrack === 'sampler') { copy.sampler[activeSamplerBank].steps[step] = { note, velocity: 1, length: 1 }; updateStorageForTrack('sampler', copy.sampler); } else { copy[selectedTrack].steps[step] = { note, velocity: 1, length: 1 }; updateStorageForTrack(selectedTrack, copy[selectedTrack]); } return copy; }); } | ||
| }, [audioEngine, selectedTrack, isRecording, isPlaying, updateStorageForTrack, activeSamplerBank]); | ||
|
|
||
| const handleKeyboardStop = useCallback((note: string) => { if (!audioEngine) return; const id = activeKeyboardNotesRef.current.get(note); if (!id) return; if (selectedTrack === 'partA' || selectedTrack === 'partB' || selectedTrack === 'bass2') { audioEngine.noteOffSynth?.(id); } else if (selectedTrack === 'sampler') { audioEngine.noteOffSampler?.(id); } activeKeyboardNotesRef.current.delete(note); }, [audioEngine, selectedTrack]); | ||
| const handleRightMouseDown = useCallback((track: TrackKey, step: number, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); let stepData = null; if (track === 'sampler') { stepData = patternRef.current.sampler[activeSamplerBankRef.current].steps[step]; } else { stepData = patternRef.current[track].steps[step]; } if (!stepData) return; setIsNoteDragging(true); const startMidi = noteToMidi(stepData.note); noteDragRef.current = { track, step, startY: e.clientY, startMidi, hasMoved: false, lastMidi: startMidi }; document.body.style.cursor = 'ns-resize'; }, []); | ||
| const handleGlobalMouseMove = useCallback((e: MouseEvent) => { | ||
| if (!isNoteDragging || !noteDragRef.current) return; | ||
| const { track, step, startY, startMidi } = noteDragRef.current; | ||
| const dy = startY - e.clientY; | ||
| if (!noteDragRef.current.hasMoved && Math.abs(dy) > 5) { noteDragRef.current.hasMoved = true; } | ||
| if (noteDragRef.current.hasMoved) { | ||
| const semitoneChange = Math.round(dy / 10); | ||
| const newMidi = startMidi + semitoneChange; | ||
| const clampedMidi = Math.max(24, Math.min(108, newMidi)); | ||
| if (clampedMidi !== noteDragRef.current.lastMidi) { | ||
| noteDragRef.current.lastMidi = clampedMidi; | ||
| const newNote = midiToNote(clampedMidi); | ||
| setPattern(prev => { | ||
| const copy = { ...prev }; | ||
| if (track === 'sampler') { const bankIndex = activeSamplerBank; const newSampler = [...copy.sampler]; const newBank = { ...newSampler[bankIndex] }; newBank.steps = [...newBank.steps]; if (newBank.steps[step]) { newBank.steps[step] = { ...newBank.steps[step]!, note: newNote }; } newSampler[bankIndex] = newBank; copy.sampler = newSampler; if (noteDragRef.current) noteDragRef.current.pendingSequence = copy.sampler; } | ||
| else { const newTrack = { ...copy[track] }; newTrack.steps = [...newTrack.steps]; if (newTrack.steps[step]) { newTrack.steps[step] = { ...newTrack.steps[step]!, note: newNote }; } copy[track] = newTrack; if (noteDragRef.current) noteDragRef.current.pendingSequence = copy[track]; } | ||
| return copy; | ||
| }); | ||
| } | ||
| } | ||
| }, [isNoteDragging, activeSamplerBank]); | ||
| const handleGlobalMouseUp = useCallback((e: MouseEvent) => { | ||
| if (isDrawing) { setIsDrawing(false); setDrawMode(null); } | ||
| if (!isNoteDragging || !noteDragRef.current) return; | ||
| if (!noteDragRef.current.hasMoved) { const { track, step } = noteDragRef.current; setContextMenu({ x: e.clientX, y: e.clientY, track, step }); } | ||
| else if (noteDragRef.current.pendingSequence) { updateStorageForTrack(noteDragRef.current.track, noteDragRef.current.pendingSequence); } | ||
| setIsNoteDragging(false); noteDragRef.current = null; document.body.style.cursor = 'default'; | ||
| }, [isNoteDragging, updateStorageForTrack, isDrawing]); | ||
|
|
||
| useEffect(() => { | ||
| window.addEventListener('pointerup', handleGlobalMouseUp as any); | ||
| if (isNoteDragging) { window.addEventListener('mousemove', handleGlobalMouseMove); window.addEventListener('mouseup', handleGlobalMouseUp); } | ||
| return () => { | ||
| window.removeEventListener('pointerup', handleGlobalMouseUp as any); | ||
| window.removeEventListener('mousemove', handleGlobalMouseMove); window.removeEventListener('mouseup', handleGlobalMouseUp); | ||
| }; | ||
| }, [isNoteDragging, handleGlobalMouseMove, handleGlobalMouseUp]); | ||
|
|
||
| const handleDrawEnter = useCallback((trackKey: TrackKey, stepIndex: number) => { | ||
| if (!isDrawing || !drawMode) return; | ||
| const pattern = patternRef.current; | ||
| let step = null; | ||
| if (trackKey === 'sampler') { | ||
| step = pattern.sampler[activeSamplerBankRef.current].steps[stepIndex]; | ||
| } else { | ||
| step = pattern[trackKey].steps[stepIndex]; | ||
| } | ||
| const isActive = !!step; | ||
| if (drawMode === 'add' && !isActive) { | ||
| handlePatternChange(trackKey, stepIndex, undefined); | ||
| } else if (drawMode === 'remove' && isActive) { | ||
| handlePatternChange(trackKey, stepIndex, undefined); | ||
| } | ||
| }, [isDrawing, drawMode, handlePatternChange, activeSamplerBank]); | ||
|
|
||
| useEffect(() => { | ||
| const handleKeyDown = (e: KeyboardEvent) => { | ||
| if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; | ||
| if ((e.ctrlKey || e.metaKey) && e.key === 'c') { | ||
| e.preventDefault(); | ||
| handleCopy(); | ||
| } | ||
| if ((e.ctrlKey || e.metaKey) && e.key === 'v') { | ||
| e.preventDefault(); | ||
| handlePaste(); | ||
| } | ||
| if ((e.key === 'Delete' || e.key === 'Backspace') && selection) { | ||
| const { trackKey, startStep, endStep } = selection; | ||
| const low = Math.min(startStep, endStep); | ||
| const high = Math.max(startStep, endStep); | ||
| setPattern(prev => { | ||
| const copy = JSON.parse(JSON.stringify(prev)); | ||
| let changedSequence; | ||
| if (trackKey === 'sampler') { | ||
| const bankIdx = activeSamplerBankRef.current; | ||
| const bank = copy.sampler[bankIdx]; | ||
| for (let i = low; i <= high; i++) { bank.steps[i] = null; } | ||
| changedSequence = copy.sampler; | ||
| } else { | ||
| const track = copy[trackKey] as any; | ||
| for (let i = low; i <= high; i++) { track.steps[i] = null; } | ||
| changedSequence = track; | ||
| } | ||
| updateStorageForTrack(trackKey, changedSequence); | ||
| return copy; | ||
| }); | ||
| setSelection(null); | ||
| } | ||
| if (e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey) { | ||
| e.preventDefault(); | ||
| audioEngine?.triggerTapeStop?.(2.0); | ||
| } | ||
| }; | ||
| window.addEventListener('keydown', handleKeyDown); | ||
| window.addEventListener('mouseup', handleSelectionEnd); | ||
| return () => { | ||
| window.removeEventListener('keydown', handleKeyDown); | ||
| window.removeEventListener('mouseup', handleSelectionEnd); | ||
| }; | ||
| }, [selection, handleSelectionEnd, updateStorageForTrack, handleCopy, handlePaste]); | ||
|
|
||
| const handleNoteSelect = useCallback((note: string) => { | ||
| if (!contextMenu) return; | ||
| const prev = patternRef.current; | ||
| const copy = JSON.parse(JSON.stringify(prev)) as Pattern; | ||
| let changedSequence; | ||
| let trackKey: TrackKey; | ||
| if (contextMenu.track === 'sampler') { | ||
| trackKey = 'sampler'; | ||
| const stepData = copy.sampler[activeSamplerBank].steps[contextMenu.step]; | ||
| if (stepData) stepData.note = note; | ||
| changedSequence = copy.sampler; | ||
| } else { | ||
| trackKey = contextMenu.track; | ||
| const stepData = copy[trackKey].steps[contextMenu.step]; | ||
| if (stepData) stepData.note = note; | ||
| changedSequence = copy[trackKey]; | ||
| } | ||
| setPattern(copy); | ||
| updateStorageForTrack(trackKey, changedSequence); | ||
| setContextMenu(null); | ||
| }, [contextMenu, activeSamplerBank, updateStorageForTrack]); | ||
|
|
||
| const handleNoteLengthChange = useCallback((newLength: number) => { | ||
| if (!contextMenu) return; | ||
| const prev = patternRef.current; | ||
| const copy = JSON.parse(JSON.stringify(prev)) as Pattern; | ||
| const trackKey = contextMenu.track; | ||
| const stepIndex = contextMenu.step; | ||
| const isSampler = trackKey === 'sampler'; | ||
| let stepsArray; | ||
| if (isSampler) { stepsArray = copy.sampler[activeSamplerBank].steps; } else { stepsArray = (copy[trackKey] as any).steps; } | ||
| const stepData = stepsArray[stepIndex]; | ||
| if (stepData) { | ||
| stepData.length = newLength; | ||
| for (let i = 1; i < newLength; i++) { const nextStepIdx = stepIndex + i; if (nextStepIdx < stepsArray.length) { stepsArray[nextStepIdx] = null; } } | ||
| } | ||
| let changedSequence; | ||
| if (isSampler) { changedSequence = copy.sampler; } else { changedSequence = copy[trackKey]; } | ||
| setPattern(copy); | ||
| updateStorageForTrack(trackKey, changedSequence); | ||
| }, [contextMenu, activeSamplerBank, updateStorageForTrack]); | ||
|
|
||
| const handleNotePropertyChange = useCallback((key: 'timbre' | 'velocity' | 'probability' | 'microtiming' | 'reverse' | 'retrigger' | 'freeze' | 'formantShift' | 'filterCutoff' | 'filterResonance' | 'envMod' | 'formantLfoRate' | 'formantLfoDepth' | 'formantEnvAttack' | 'formantEnvDecay' | 'formantEnvAmount' | 'vibratoDepth' | 'drive' | 'characterMorph' | 'reverbSend' | 'reverbType' | 'delaySend' | 'freezeEnvDepth' | 'grainEnvDepth' | 'grainPitchQuantize' | 'choir', value: number | boolean | string) => { | ||
| if (!contextMenu) return; | ||
| const prev = patternRef.current; | ||
| const copy = JSON.parse(JSON.stringify(prev)) as Pattern; | ||
| const trackKey = contextMenu.track; | ||
| const stepIndex = contextMenu.step; | ||
| const isSampler = trackKey === 'sampler'; | ||
| let stepsArray; | ||
| if (isSampler) { | ||
| stepsArray = copy.sampler[activeSamplerBank].steps; | ||
| } else { | ||
| stepsArray = (copy[trackKey] as any).steps; | ||
| } | ||
| const stepData = stepsArray[stepIndex]; | ||
| if (stepData) { | ||
| if (key === 'reverse') { | ||
| if (typeof value === 'boolean') stepData.reverse = value; | ||
| } else { | ||
| if (typeof value === 'number') stepData[key] = value; | ||
| } | ||
| } | ||
| let changedSequence; | ||
| if (isSampler) { changedSequence = copy.sampler; } else { changedSequence = copy[trackKey]; } | ||
| setPattern(copy); | ||
| updateStorageForTrack(trackKey, changedSequence); | ||
| }, [contextMenu, activeSamplerBank, updateStorageForTrack]); | ||
|
|
||
| const handleClearPattern = useCallback(() => { | ||
| if (window.confirm("Clear current pattern?")) { | ||
| const emptyPattern: Pattern = { | ||
| partA: { steps: Array(32).fill(null) }, | ||
| partB: { steps: Array(32).fill(null) }, | ||
| bass2: { steps: Array(32).fill(null) }, | ||
| kick: { steps: Array(32).fill(null) }, | ||
| snare: { steps: Array(32).fill(null) }, | ||
| closedHat: { steps: Array(32).fill(null) }, | ||
| openHat: { steps: Array(32).fill(null) }, | ||
| sampler: Array.from({ length: 8 }, () => ({ steps: Array(32).fill(null) })), | ||
| }; | ||
| setPattern(emptyPattern); | ||
| setTrackStorage(prevStorage => { | ||
| const storageCopy = { ...prevStorage }; | ||
| (Object.keys(storageCopy) as TrackKey[]).forEach(key => { | ||
| storageCopy[key] = [...storageCopy[key]]; | ||
| storageCopy[key][activeTrackSlotsRef.current[key]] = emptyPattern[key]; | ||
| }); | ||
| return storageCopy; | ||
| }); | ||
| } | ||
| }, []); | ||
| const handleTrackSlotClick = useCallback((track: TrackKey, slotIndex: number) => { const currentTrackPattern = track === 'sampler' ? patternRef.current.sampler : patternRef.current[track]; const storedPattern = trackStorageRef.current[track][slotIndex]; if (storedPattern) { setPattern(prev => ({ ...prev, [track]: storedPattern })); setActiveTrackSlots(prev => ({ ...prev, [track]: slotIndex })); } else { setTrackStorage(prev => { const copy = { ...prev }; copy[track] = [...prev[track]]; copy[track][slotIndex] = currentTrackPattern; return copy; }); setActiveTrackSlots(prev => ({ ...prev, [track]: slotIndex })); } }, []); | ||
| const handleSelectRow = useCallback((k: any) => setSelectedTrack(k as TrackKey), []); | ||
| const handleEditLength = useCallback((k: TrackKey, i: number, len: number) => { handlePatternChange(k, i, undefined, { length: len }); }, [handlePatternChange]); | ||
| const handleSongModeToggle = useCallback(() => setIsSongModeOpen(prev => !prev), []); | ||
| const handleSongStructureUpdate = useCallback((idx: number, key: TrackKey, val: number | null) => { setSongStructure(prev => { const copy = [...prev]; copy[idx] = { ...copy[idx], [key]: val }; return copy; }); }, []); | ||
| const handleAddMeasure = useCallback(() => setSongStructure(prev => [...prev, { partA: null, partB: null, bass2: null, kick: null, snare: null, closedHat: null, openHat: null, sampler: null }]), []); | ||
| const handleExportXM = useCallback(() => { exportSongToXM(songStructureRef.current, trackStorageRef.current, { synthA: synthARef.current, synthB: synthBRef.current, kick: kickRef.current, snare: snareRef.current, closedHat: closedHatRef.current, openHat: openHatRef.current, sampler: samplerRef.current }, tempoRef.current, patternRef.current, { webGpuEngine: audioEngine?.webGpuEngine, wasmEngine: audioEngine?.wasmEngine, pyodide: pyodide }, sampleBuffers); }, [audioEngine, pyodide, sampleBuffers]); | ||
| const handleRemoveMeasure = useCallback(() => { const currentStructure = songStructure; if (currentStructure.length === 0) return; const last = currentStructure[currentStructure.length - 1]; const hasData = Object.values(last).some(v => v !== null); if (hasData) { if (!window.confirm("The last measure contains patterns. Are you sure you want to remove it?")) return; } setSongStructure(prev => prev.slice(0, -1)); }, [songStructure]); | ||
| const handleLoadSample = useCallback(async (name: string, buffer: AudioBuffer, onProgress?: (progress: number) => void) => { | ||
| if (!audioEngine) return; | ||
| await audioEngine.loadSampleToEngine(name, buffer, onProgress); | ||
| setSampleBuffers(prev => { const next = [...prev]; next[activeSamplerBank] = buffer; return next; }); | ||
| const bankName = `bank_${activeSamplerBank}`; | ||
| setSampler(prev => { const newParams = [...prev]; newParams[activeSamplerBank] = { ...newParams[activeSamplerBank], sampleName: bankName }; return newParams; }); | ||
| if (audioEngine.prepareVocal) { | ||
| const text = ttsPhrases[activeSamplerBank] || "Hello World"; | ||
| audioEngine.prepareVocal(activeSamplerBank, text).then(() => { | ||
| if (audioEngine.getAlignment) { | ||
| setActiveAlignment(audioEngine.getAlignment(activeSamplerBank)); | ||
| } | ||
| }); | ||
| } | ||
| }, [audioEngine, activeSamplerBank, ttsPhrases]); | ||
|
|
||
| const { | ||
| getSongData, getBankData, getPatternData, | ||
| exportSongToFile, importSongFromFile, | ||
| handleSaveSong, loadSong, loadCloudData, | ||
| handleAISongImport, handleRbsImport, | ||
| isImportingAISong, aiImportProgress, aiImportStage, aiImportError, | ||
| setIsImportingAISong, setAiImportStage, setAiImportProgress, | ||
| } = useSongStorage({ | ||
| patternRef, tempoRef, | ||
| synthARef, synthBRef, bass2Ref, kickRef, snareRef, closedHatRef, openHatRef, samplerRef, | ||
| trackStorageRef, activeTrackSlotsRef, songStructureRef, | ||
| ambianceUrl, backgroundImage, sampleBuffers, ttsPhrases, | ||
| songStorage, pattern, tempo, trackStorage, | ||
| setPattern, setTempo, setAmbianceUrl, setBackgroundImage, | ||
| setSynthA, setSynthB, setBass2, setKick, setSnare, setClosedHat, setOpenHat, setSampler, | ||
| setTrackStorage, setActiveTrackSlots, setSongStructure, setSampleBuffers, setTtsPhrases, | ||
| setSongStorage, setActiveSongSlot, | ||
| audioEngine, showToast, | ||
| setIsAISongModalOpen, setIsRbsImportModalOpen, | ||
| }); | ||
|
|
||
| const handleSynthChange = useCallback((isA: boolean, id: string, val: number) => { const updater = isA ? updateSynthA : updateSynthB; let realVal = val; if (id === 'pitch') realVal = Math.floor(val * 48 - 24); else if (id === 'filterCutoff') realVal = val * 8000; else if (id === 'filterResonance') realVal = val * 20; else if (id === 'filterMode') realVal = Math.round(val); else if (id === 'decay') realVal = val * 2; else if (id === 'release') realVal = val * 2; else if (id === 'length') realVal = val * 2; updater({ [id]: realVal }); }, [updateSynthA, updateSynthB]); | ||
| const handleBass2Change = useCallback((id: string, val: number) => { let realVal = val; if (id === 'waveform') realVal = val > 0.5 ? 1 : 0; else if (id === 'cutoff') realVal = val * 8000; else if (id === 'resonance') realVal = val * 20; else if (id === 'filterMode') realVal = Math.round(val); else if (id === 'decay') realVal = val * 2; else if (id === 'pitch') realVal = Math.floor(val * 48 - 24); updateBass2({ [id]: realVal }); }, [updateBass2]); | ||
| const handleKickChange = useCallback((id: string, val: number) => { let realVal = val; if (id === 'pitch') realVal = val * 130 + 20; updateKick({ [id]: realVal }); }, [updateKick]); | ||
| const handleSnareChange = useCallback((id: string, val: number) => { let realVal = val; if (id === 'tone') realVal = val * 300 + 100; else if (id === 'noise') realVal = val * 7000 + 1000; else if (id === 'decay') realVal = val * 0.5; updateSnare({ [id]: realVal }); }, [updateSnare]); | ||
| const handleClosedHatChange = useCallback((id: string, val: number) => updateClosedHat({ [id]: val }), [updateClosedHat]); | ||
| const handleOpenHatChange = useCallback((id: string, val: number) => updateOpenHat({ [id]: val }), [updateOpenHat]); | ||
| const handleSamplerChange = useCallback((id: string, val: number) => { | ||
| let realVal = val; if (id === 'playbackSpeed') realVal = val * 4.0; else if (id === 'filterCutoff') realVal = val * 20000; else if (id === 'filterResonance') realVal = val * 20; setSampler(prev => { | ||
| const next = [...prev]; const currentBank = next[activeSamplerBank]; | ||
| next[activeSamplerBank] = { ...currentBank, [id]: realVal }; return next; | ||
| }); | ||
| }, [activeSamplerBank]); | ||
|
|
||
| const handleSamplerParamChange = useCallback((bankIdx: number, key: string, val: any) => { | ||
| setSampler(prev => { | ||
| const next = [...prev]; | ||
| if (next[bankIdx]) { | ||
| next[bankIdx] = { ...next[bankIdx], [key as keyof SamplerBankParams]: val }; | ||
| } | ||
| samplerRef.current = next; | ||
| return next; | ||
| }); | ||
| }, []); | ||
|
|
||
| const handleTtsPhraseChange = useCallback((newPhrases: string[]) => { | ||
| setTtsPhrases(newPhrases); | ||
| if (audioEngine?.prepareVocal) { | ||
| const text = newPhrases[activeSamplerBank]; | ||
| audioEngine.prepareVocal(activeSamplerBank, text).then(() => { | ||
| if (audioEngine.getAlignment) { | ||
| setActiveAlignment(audioEngine.getAlignment(activeSamplerBank)); | ||
| } | ||
| }); | ||
| } | ||
| }, [audioEngine, activeSamplerBank]); | ||
|
|
||
| const handleGenerateTTS = useCallback(async (text: string) => { | ||
| if (!audioEngine) return; | ||
| setIsGenerating(true); | ||
| try { | ||
| const rawData = await SupertonicService.getInstance().generate(text); | ||
| const buffer = audioEngine.context.createBuffer(1, rawData.length, 44100); | ||
| buffer.getChannelData(0).set(rawData); | ||
| const bankName = `bank_${activeSamplerBankRef.current}`; | ||
| handleLoadSample(bankName, buffer); | ||
| showToast(`Generated: ${text.substring(0, 15)}...`, "success"); | ||
| } catch (e) { | ||
| console.error(e); | ||
| showToast("TTS Generation Failed", "error"); | ||
| throw e; | ||
| } finally { | ||
| setIsGenerating(false); | ||
| } | ||
| }, [audioEngine, handleLoadSample, showToast]); | ||
|
|
||
| const handleTextToDrums = useCallback(async (text: string) => { | ||
| try { | ||
| await handleGenerateTTS(text); | ||
| const alignment = audioEngine?.getAlignment?.(activeSamplerBankRef.current); | ||
| if (!alignment) return; | ||
| const newPattern = { ...patternRef.current }; | ||
| const newKick = { ...newPattern.kick, steps: [...newPattern.kick.steps] }; | ||
| const newSnare = { ...newPattern.snare, steps: [...newPattern.snare.steps] }; | ||
| const newCH = { ...newPattern.closedHat, steps: [...newPattern.closedHat.steps] }; | ||
| const newOH = { ...newPattern.openHat, steps: [...newPattern.openHat.steps] }; | ||
| newKick.steps = Array(32).fill(null); | ||
| newSnare.steps = Array(32).fill(null); | ||
| newCH.steps = Array(32).fill(null); | ||
| newOH.steps = Array(32).fill(null); | ||
| const stepTime = 60 / tempoRef.current / 4; | ||
| alignment.phonemes.forEach((p, idx) => { | ||
| const stepIdx = Math.round(p.start / stepTime); | ||
| if (stepIdx >= 0 && stepIdx < 32) { | ||
| const ph = p.phoneme.toUpperCase().replace(/[0-9]/g, ''); | ||
| const isVowel = ['AA','AE','AH','AO','AW','AY','EH','ER','EY','IH','IY','OW','OY','UH','UW'].includes(ph); | ||
| const isSnare = ['B','D','G','CH','JH','K','P','T'].includes(ph); | ||
| const isHat = ['F','S','SH','TH','V','Z','ZH'].includes(ph); | ||
| const isOpenHat = ['SH','ZH','S','F'].includes(ph) && p.end - p.start > 0.15; | ||
| if (isVowel) newKick.steps[stepIdx] = { note: 'C4', velocity: 1, length: 1 }; | ||
| else if (isSnare) newSnare.steps[stepIdx] = { note: 'C4', velocity: 1, length: 1 }; | ||
| else if (isOpenHat) newOH.steps[stepIdx] = { note: 'C4', velocity: 1, length: 1 }; | ||
| else if (isHat) newCH.steps[stepIdx] = { note: 'C4', velocity: 1, length: 1 }; | ||
| else newCH.steps[stepIdx] = { note: 'C4', velocity: 0.5, length: 1 }; | ||
| } | ||
| }); | ||
| newPattern.kick = newKick; | ||
| newPattern.snare = newSnare; | ||
| newPattern.closedHat = newCH; | ||
| newPattern.openHat = newOH; | ||
| setPattern(newPattern); | ||
| updateStorageForTrack('kick', newKick); | ||
| updateStorageForTrack('snare', newSnare); | ||
| updateStorageForTrack('closedHat', newCH); | ||
| updateStorageForTrack('openHat', newOH); | ||
| showToast("Drumkit generated from text!", "success"); | ||
| setIsLyricTrackVisible(false); | ||
| } catch(e) { | ||
| console.error(e); | ||
| showToast("Failed to generate drums.", "error"); | ||
| } | ||
| }, [handleGenerateTTS, audioEngine, updateStorageForTrack, showToast]); | ||
|
|
||
| const handleLyricApply = useCallback(async (text: string) => { | ||
| try { | ||
| await handleGenerateTTS(text); | ||
| const newPhrases = [...ttsPhrases]; | ||
| newPhrases[activeSamplerBankRef.current] = text; | ||
| setTtsPhrases(newPhrases); | ||
| const prev = patternRef.current; | ||
| const copy = JSON.parse(JSON.stringify(prev)) as Pattern; | ||
| const bankIdx = activeSamplerBankRef.current; | ||
| const bank = copy.sampler[bankIdx]; | ||
| let noteIndex = 0; | ||
| for (let i = 0; i < 32; i++) { | ||
| if (bank.steps[i] && bank.steps[i]!.velocity > 0) { | ||
| bank.steps[i]!.sliceIndex = noteIndex; | ||
| noteIndex++; | ||
| } | ||
| } | ||
| setPattern(copy); | ||
| updateStorageForTrack('sampler', copy.sampler); | ||
| setSampler(prevParams => { | ||
| const next = [...prevParams]; | ||
| if (next[bankIdx]) { | ||
| next[bankIdx] = { ...next[bankIdx], sliceMode: 'phoneme' }; | ||
| } | ||
| samplerRef.current = next; | ||
| return next; | ||
| }); | ||
| if (noteIndex > 0) { | ||
| showToast(`Mapped ${noteIndex} syllables across Sampler Bank ${bankIdx + 1}!`, "success"); | ||
| } else { | ||
| showToast("Generated TTS! (No notes found to map syllables to)", "success"); | ||
| } | ||
| setIsLyricTrackVisible(false); | ||
| } catch (e) { | ||
| console.error(e); | ||
| showToast("Failed to generate or map lyrics.", "error"); | ||
| } | ||
| }, [handleGenerateTTS, ttsPhrases, updateStorageForTrack, showToast]); | ||
|
|
||
| const onSynthAParamChange = useCallback((id: string, v: number) => handleSynthChange(true, id, v), [handleSynthChange]); | ||
| const onSynthBParamChange = useCallback((id: string, v: number) => handleSynthChange(false, id, v), [handleSynthChange]); | ||
| const onBass2ParamChange = useCallback((id: string, v: number) => handleBass2Change(id, v), [handleBass2Change]); | ||
|
|
||
| const synthAControls = useStableKnobConfig(getSynthControls, synthA); | ||
| const synthBControls = useStableKnobConfig(getSynthControls, synthB); | ||
| const bass2Controls = useStableKnobConfig(getBass2Controls, bass2); | ||
| const kickControls = useStableKnobConfig(getKickControls, kick); | ||
| const snareControls = useStableKnobConfig(getSnareControls, snare); | ||
| const closedHatControls = useStableKnobConfig(getClosedHatControls, closedHat); | ||
| const openHatControls = useStableKnobConfig(getOpenHatControls, openHat); | ||
| const samplerControls = useStableKnobConfig(getSamplerControls, sampler[activeSamplerBank]); | ||
|
|
||
| const synthAChild = useMemo(() => (<div className="absolute top-4 right-6 pointer-events-auto"><WaveformSelector selected={synthA.waveform} onChange={(w) => updateSynthA({ waveform: w })} accentColor="cyan" /></div>), [synthA.waveform, updateSynthA]); | ||
| const synthBChild = useMemo(() => (<div className="absolute top-4 right-6 pointer-events-auto"><WaveformSelector selected={synthB.waveform} onChange={(w) => updateSynthB({ waveform: w })} accentColor="pink" /></div>), [synthB.waveform, updateSynthB]); | ||
| const bass2Child = useMemo(() => ( | ||
| <div className="absolute top-4 right-6 pointer-events-auto"> | ||
| <div className="flex flex-col gap-2 p-2 rounded-lg bg-zinc-950/80 border border-pink-500/20"> | ||
| <span className="text-[8px] font-mono text-pink-400/60 uppercase tracking-wider text-center">Waveform</span> | ||
| <button | ||
| onClick={() => updateBass2({ waveform: '303-saw' })} | ||
| className={`px-4 py-1.5 text-[10px] font-bold rounded-md transition-all border ${ | ||
| bass2.waveform === '303-saw' | ||
| ? 'bg-gradient-to-b from-pink-500 to-pink-600 text-white border-pink-400 shadow-[0_0_12px_rgba(255,0,102,0.5),inset_0_1px_0_rgba(255,255,255,0.2)]' | ||
| : 'bg-gradient-to-b from-zinc-800 to-zinc-900 text-zinc-400 border-zinc-700 hover:text-zinc-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]' | ||
| }`} | ||
| > | ||
| SAW | ||
| </button> | ||
| <button | ||
| onClick={() => updateBass2({ waveform: '303-sqr' })} | ||
| className={`px-4 py-1.5 text-[10px] font-bold rounded-md transition-all border ${ | ||
| bass2.waveform === '303-sqr' | ||
| ? 'bg-gradient-to-b from-pink-500 to-pink-600 text-white border-pink-400 shadow-[0_0_12px_rgba(255,0,102,0.5),inset_0_1px_0_rgba(255,255,255,0.2)]' | ||
| : 'bg-gradient-to-b from-zinc-800 to-zinc-900 text-zinc-400 border-zinc-700 hover:text-zinc-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]' | ||
| }`} | ||
| > | ||
| SQR | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ), [bass2.waveform, updateBass2]); | ||
| const samplerChild = useMemo(() => (<div className="absolute top-2 left-[25%] w-[50%] max-h-[280px] h-auto pointer-events-auto z-10 bg-gray-900/90 rounded-lg border border-purple-500/30 backdrop-blur-sm overflow-hidden"><SamplerPanel params={sampler} onChange={(u) => updateSampler(u)} onParamChange={handleSamplerParamChange} onLoadSample={handleLoadSample} audioContext={audioEngine?.context!} audioEngine={audioEngine || undefined} activeBankIdx={activeSamplerBank} onBankChange={setActiveSamplerBank} onOpenEditor={() => setIsVoiceEditorOpen(true)} isVoiceEditorOpen={isVoiceEditorOpen} ttsPhrases={ttsPhrases} onTtsPhraseChange={handleTtsPhraseChange} onGenerateTTS={handleGenerateTTS} loadedBanks={loadedBanks} sampleBuffer={sampleBuffers[activeSamplerBank]} sliceHighlightRef={sliceHighlightRef} melodicMode={melodicMode} onMelodicModeChange={setMelodicMode} multisampleReady={multisampleReady} multisampleProcessing={multisampleProcessing} alignment={activeAlignment} onAlignmentChange={(newAlignment) => { audioEngine?.setAlignment?.(activeSamplerBank, newAlignment); setActiveAlignment(newAlignment); }} /></div>), [sampler, updateSampler, handleSamplerParamChange, audioEngine, setIsVoiceEditorOpen, isVoiceEditorOpen, activeSamplerBank, handleLoadSample, ttsPhrases, handleTtsPhraseChange, handleGenerateTTS, loadedBanks, sampleBuffers, melodicMode, multisampleReady, multisampleProcessing, activeAlignment, setActiveAlignment]); | ||
|
|
||
| return { | ||
| isVoiceEditorOpen, setIsVoiceEditorOpen, | ||
| isCloudLibraryOpen, setIsCloudLibraryOpen, | ||
| isAISongModalOpen, setIsAISongModalOpen, | ||
| isRbsImportModalOpen, setIsRbsImportModalOpen, | ||
| isLyricTrackVisible, setIsLyricTrackVisible, | ||
| isShortcutsHelpOpen, setIsShortcutsHelpOpen, | ||
| showGamepadDebug, setShowGamepadDebug, | ||
| isGenerating, setIsGenerating, | ||
| hasStarted, setHasStarted, | ||
| forceScriptProcessorFallback, setForceScriptProcessorFallback, | ||
| is3DMode, setIs3DMode, | ||
| toast, setToast, | ||
| showToast, | ||
| pyodide, isPyodideReady, pyodideStatus, | ||
| lastFreqRef, | ||
| audioEngine, isReady, initializeAudio, onParamChange, | ||
| isEngineReady, | ||
| pattern, setPattern, | ||
| tempo, setTempo, | ||
| isInitialized, setIsInitialized, | ||
| isPlaying, setIsPlaying, | ||
| isRecording, setIsRecording, | ||
| selectedTrack, setSelectedTrack, | ||
| ambianceUrl, setAmbianceUrl, | ||
| backgroundImage, setBackgroundImage, | ||
| masterVolume, setMasterVolume, | ||
| masterSaturation, setMasterSaturation, | ||
| globalPan, setGlobalPan, | ||
| reverbType, setReverbType, | ||
| isSongModeOpen, setIsSongModeOpen, | ||
| isSongModeActive, setIsSongModeActive, | ||
| songStructure, setSongStructure, | ||
| currentSongMeasure, setCurrentSongMeasure, | ||
| contextMenu, setContextMenu, | ||
| isNoteDragging, setIsNoteDragging, | ||
| viewMode, setViewMode, | ||
| automationParam, setAutomationParam, | ||
| melodicMode, setMelodicMode, | ||
| activeAlignment, setActiveAlignment, | ||
| lastSamplerMidiRef, | ||
| currentScale, setCurrentScale, | ||
| sliceHighlightRef, | ||
| selection, setSelection, | ||
| isSelecting, setIsSelecting, | ||
| clipboard, setClipboard, | ||
| isDrawing, setIsDrawing, | ||
| drawMode, setDrawMode, | ||
| zoomLevel, setZoomLevel, | ||
| trackStorage, setTrackStorage, | ||
| activeTrackSlots, setActiveTrackSlots, | ||
| activeTrackSlotsRef, | ||
| songStorage, setSongStorage, | ||
| activeSongSlot, setActiveSongSlot, | ||
| activeSamplerBank, setActiveSamplerBank, | ||
| activeSamplerBankRef, | ||
| sampleBuffers, setSampleBuffers, | ||
| loadedBanks, | ||
| multisampleReady, | ||
| multisampleProcessing, | ||
| ttsPhrases, setTtsPhrases, | ||
| synthA, setSynthA, | ||
| synthARef, | ||
| updateSynthA, | ||
| synthB, setSynthB, | ||
| synthBRef, | ||
| updateSynthB, | ||
| bass2, setBass2, | ||
| bass2Ref, | ||
| updateBass2, | ||
| kick, setKick, | ||
| kickRef, | ||
| updateKick, | ||
| snare, setSnare, | ||
| snareRef, | ||
| updateSnare, | ||
| closedHat, setClosedHat, | ||
| closedHatRef, | ||
| updateClosedHat, | ||
| openHat, setOpenHat, | ||
| openHatRef, | ||
| updateOpenHat, | ||
| sampler, setSampler, | ||
| samplerRef, | ||
| updateSampler, | ||
| samplerVoiceParamsRef, | ||
| samplerVoiceParams, setSamplerVoiceParams, | ||
| harmonizerConfig, setHarmonizerConfig, | ||
| isHarmonizeActive, setIsHarmonizeActive, | ||
| handleStart, | ||
| handleAutoMix, | ||
| handlePanic, | ||
| handlePlayToggle, | ||
| adjustTempo, | ||
| handleTempoHoldStart, | ||
| handleTempoHoldEnd, | ||
| handleTempoKeyDown, | ||
| handleMasterVolume, | ||
| handleMasterVolumeKeyDown, | ||
| handleMasterVolumeReset, | ||
| handleMasterSaturation, | ||
| handleMasterSaturationKeyDown, | ||
| handleMasterSaturationReset, | ||
| handleGlobalPan, | ||
| handleGlobalPanKeyDown, | ||
| handleGlobalPanReset, | ||
| handleReverbType, | ||
| updateStorageForTrack, | ||
| handleCopy, | ||
| handlePaste, | ||
| handleAutomationChange, | ||
| handlePitchChange, | ||
| handlePhonemeUpdate, | ||
| handlePatternChange, | ||
| handleStepToggle, | ||
| handleKeyboardPlay, | ||
| handleKeyboardStop, | ||
| handleRightMouseDown, | ||
| handleGlobalMouseMove, | ||
| handleGlobalMouseUp, | ||
| handleDrawEnter, | ||
| handleSelectionStart, | ||
| handleSelectionEnter, | ||
| handleSelectionEnd, | ||
| handleNoteSelect, | ||
| handleNoteLengthChange, | ||
| handleNotePropertyChange, | ||
| handleClearPattern, | ||
| handleTrackSlotClick, | ||
| handleSelectRow, | ||
| handleEditLength, | ||
| handleSongModeToggle, | ||
| handleSongStructureUpdate, | ||
| handleAddMeasure, | ||
| handleExportXM, | ||
| handleRemoveMeasure, | ||
| handleLoadSample, | ||
| handleSynthChange, | ||
| handleBass2Change, | ||
| handleKickChange, | ||
| handleSnareChange, | ||
| handleClosedHatChange, | ||
| handleOpenHatChange, | ||
| handleSamplerChange, | ||
| handleSamplerParamChange, | ||
| handleTtsPhraseChange, | ||
| handleGenerateTTS, | ||
| handleTextToDrums, | ||
| handleLyricApply, | ||
| onSynthAParamChange, | ||
| onSynthBParamChange, | ||
| onBass2ParamChange, | ||
| handleHarmonizerConfigChange, | ||
| handleSamplerVoiceChange, | ||
| synthAControls, | ||
| synthBControls, | ||
| bass2Controls, | ||
| kickControls, | ||
| snareControls, | ||
| closedHatControls, | ||
| openHatControls, | ||
| samplerControls, | ||
| synthAChild, | ||
| synthBChild, | ||
| bass2Child, | ||
| samplerChild, | ||
| getSongData, | ||
| getBankData, | ||
| getPatternData, | ||
| exportSongToFile, | ||
| importSongFromFile, | ||
| handleSaveSong, | ||
| loadSong, | ||
| loadCloudData, | ||
| handleAISongImport, | ||
| handleRbsImport, | ||
| isImportingAISong, | ||
| aiImportProgress, | ||
| aiImportStage, | ||
| aiImportError, | ||
| setIsImportingAISong, | ||
| setAiImportStage, | ||
| setAiImportProgress, | ||
| patternRef, | ||
| songStructureRef, | ||
| isSongModeActiveRef, | ||
| currentScaleRef, | ||
| trackStorageRef, | ||
| songMeasureRef, | ||
| isFirstStepRef, | ||
| sequencerRef, | ||
| currentStepRef, | ||
| tempoRef, | ||
| tempoHoldIntervalRef, | ||
| tempoHoldTimeoutRef, | ||
| activeKeyboardNotesRef, | ||
| noteDragRef, | ||
| } | ||
| } |
There was a problem hiding this comment.
Remove merge conflict backup file from the repository.
The .orig extension indicates this is a backup file automatically generated by merge tools (e.g., git merge, git stash) during conflict resolution. These development artifacts should not be committed to the repository.
Recommended actions:
- Delete this file from the PR
- Add
*.origto.gitignoreto prevent future accidental commits
git rm src/hooks/useAppState.tsx.orig
echo "*.orig" >> .gitignore🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/hooks/useAppState.tsx.orig` around lines 1 - 1258, Remove the
merge-conflict backup file (useAppState.tsx.orig) from the PR and prevent future
commits of .orig files: delete the .orig file from the changeset (so only the
correct useAppState.tsx remains), add the pattern *.orig to .gitignore, stage
the .gitignore update, and commit/push the changes; verify the exported
symbol/useAppState hook implementation is coming from useAppState.tsx (not the
.orig) before merging.
|
@copilot resolve the merge conflicts in this pull request |
I've successfully resolved all merge conflicts in this pull request. Here's what was done: Conflicts Resolved:
Build Artifacts Cleanup:
The merge commit |
Implemented UI controls for the 'Time-Stretch Envelope' and 'Spectral Panning' features and connected them to the underlying audio engine. Added comprehensive per-step overrides in NoteSelector.tsx (styled consistently with the app's advanced parameter groups) and global track adjustments in SamplerPanel.tsx. Test suites updated and fully passing.
PR created automatically by Jules for task 13127396875629083664 started by @ford442
Summary by CodeRabbit
Bug Fixes
Chores