diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 9e22f36..f612d50 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -91,10 +91,10 @@ export default function PianoRollWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); const canvasRef = useRef(null); const partRef = useRef(null); - const [playPos, setPlayPos] = useState(-1); const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const drawingRef = useRef(null); const rafRef = useRef(null); + const playPosRef = useRef(-1); const midiInputRef = useRef(null); const bpm = mod?.params?.bpm ?? 140; @@ -196,8 +196,9 @@ export default function PianoRollWidget({ moduleId }) { } // Playhead - if (playPos >= 0 && playPos < totalBeats) { - const px = KEY_W + playPos * beatW; + const currentPlayPos = playPosRef.current; + if (currentPlayPos >= 0 && currentPlayPos < totalBeats) { + const px = KEY_W + currentPlayPos * beatW; ctx.strokeStyle = '#ff6644'; ctx.lineWidth = 2; ctx.beginPath(); @@ -221,7 +222,7 @@ export default function PianoRollWidget({ moduleId }) { ctx.fillStyle = 'rgba(0,229,255,0.3)'; ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H); } - }, [totalBeats, beatW, playPos, rollW]); + }, [totalBeats, beatW, rollW]); // Animation loop useEffect(() => { @@ -242,7 +243,7 @@ export default function PianoRollWidget({ moduleId }) { try { partRef.current.dispose(); } catch {} partRef.current = null; } - setPlayPos(-1); + playPosRef.current = -1; return; } @@ -252,46 +253,40 @@ export default function PianoRollWidget({ moduleId }) { let currentNote = null; // track currently sounding note for on/off transitions const clock = new Tone.Clock(() => { - const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats) + const rawPos = tickCount * 0.25; const pos = loop ? rawPos % totalBeats : rawPos; const prevRawPos = (tickCount - 1) * 0.25; const prevPos = loop ? prevRawPos % totalBeats : prevRawPos; tickCount++; - // Detect loop wrap (position jumped backwards) const looped = tickCount > 1 && pos < prevPos; - // Stop at end if not looping if (!loop && rawPos >= totalBeats) { if (currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; } - setPlayPos(-1); + playPosRef.current = -1; return; } - setPlayPos(pos); + // Update ref, not state — visual follows via RAF + playPosRef.current = pos; - // Force note-off on loop boundary for clean retrigger if (looped && currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; } - // Find the note active at this position const allNotes = notesRef.current; const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration); if (activeNote) { - // New note or different note → trigger if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { setSequencerSignals(moduleId, midiToFreq(activeNote.note), true); currentNote = activeNote; } - // Same note sustaining → do nothing } else { - // No note at this position → gate off if (currentNote) { setSequencerSignals(moduleId, 0, false); currentNote = null; diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 437a5b8..3a15bbf 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -8,7 +8,6 @@ const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } -// Default notes: C minor pentatonic pattern const DEFAULT_STEPS = [ { midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true }, @@ -18,11 +17,13 @@ const DEFAULT_STEPS = [ export default function SequencerWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); - const [currentStep, setCurrentStep] = useState(-1); + const currentStepRef = useRef(-1); + const [visualStep, setVisualStep] = useState(-1); const clockRef = useRef(null); const stepsRef = useRef(null); + const rafRef = useRef(null); - // Init steps data — also grow/shrink when numSteps changes + // Init steps data const numSteps = parseInt(mod?.params?.steps || '16'); if (mod) { if (!mod.params._steps) { @@ -30,12 +31,10 @@ export default function SequencerWidget({ moduleId }) { while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); mod.params._steps = initial; } else if (mod.params._steps.length < numSteps) { - // Grow: pad with empty steps while (mod.params._steps.length < numSteps) { mod.params._steps.push({ midi: 60, gate: false }); } } else if (mod.params._steps.length > numSteps) { - // Shrink: truncate mod.params._steps = mod.params._steps.slice(0, numSteps); } } @@ -44,8 +43,21 @@ export default function SequencerWidget({ moduleId }) { const bpm = mod?.params?.bpm ?? 140; - // Start/stop sequencer when audio engine runs — uses independent Tone.Clock - // so multiple sequencers don't interfere with each other via the global Transport + // Visual update loop — decoupled from audio, uses RAF + useEffect(() => { + const tick = () => { + setVisualStep(currentStepRef.current); + rafRef.current = requestAnimationFrame(tick); + }; + if (state.isRunning) { + rafRef.current = requestAnimationFrame(tick); + } + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [state.isRunning]); + + // Audio clock — ONLY does audio work, no React state updates useEffect(() => { if (!state.isRunning) { if (clockRef.current) { @@ -53,13 +65,15 @@ export default function SequencerWidget({ moduleId }) { try { clockRef.current.dispose(); } catch {} clockRef.current = null; } - setCurrentStep(-1); + currentStepRef.current = -1; + setVisualStep(-1); return; } - // Independent clock at 16th-note rate - const sixteenthRate = (bpm * 4) / 60; // Hz + const sixteenthRate = (bpm * 4) / 60; + const stepDuration = 1 / sixteenthRate; let step = 0; + let noteOffId = null; const clock = new Tone.Clock((time) => { const stepIdx = step % numSteps; @@ -67,15 +81,19 @@ export default function SequencerWidget({ moduleId }) { const s = stepsRef.current[stepIdx]; if (!s) return; - setCurrentStep(stepIdx); + // Update ref (not state!) — visual follows via RAF + currentStepRef.current = stepIdx; if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); - // Schedule note-off at 80% of step duration - const stepDuration = 1 / sixteenthRate; - setTimeout(() => { + // Schedule note-off using Tone.Draw or Tone.context + // Use the audio clock's time for precise scheduling + if (noteOffId !== null) { + try { Tone.getTransport().clear(noteOffId); } catch {} + } + noteOffId = Tone.getTransport().scheduleOnce(() => { setSequencerSignals(moduleId, midiToFreq(s.midi), false); - }, stepDuration * 0.8 * 1000); + }, time + stepDuration * 0.8); } else { setSequencerSignals(moduleId, midiToFreq(s.midi), false); } @@ -115,20 +133,17 @@ export default function SequencerWidget({ moduleId }) { return (