From b91b35f23d123dbcf4c20feaf8644a559eb82a61 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:35:41 +0100 Subject: [PATCH] fix: eliminate audio timing jitter and rhythm drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes fixed: - Sequencer: replaced setTimeout note-off with Tone.Transport.scheduleOnce for sample-accurate timing instead of main-thread-dependent setTimeout - Sequencer + PianoRoll: decoupled visual updates from audio callbacks. Audio clock only writes to refs, RAF loop reads refs for visual step indicator. No more React setState inside Tone.Clock callbacks. - audioEngine: added connection lookup cache (Map) to replace O(n²) array iterations in setSequencerSignals/triggerKeyboard. Cache rebuilds lazily only when connections change. These changes eliminate the feedback loop where: audio callback → setState → React render → main thread blocks → setTimeout delayed → note-off late → drift compounds Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/PianoRollWidget.jsx | 25 +++++------ src/components/SequencerWidget.jsx | 63 +++++++++++++++----------- src/engine/audioEngine.js | 72 +++++++++++++++++------------- 3 files changed, 88 insertions(+), 72 deletions(-) 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 (
- {/* Steps */} {steps.slice(0, numSteps).map((s, i) => { const x = i * CELL_W; - const isActive = i === currentStep; + const isActive = i === visualStep; const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); return ( - {/* Background */} - {/* Note bar */} {s.gate && ( )} - {/* Inactive marker */} {!s.gate && ( )} - {/* Note name */} {noteLabel(s.midi)} - {/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */} changeNote(i, 1)} @@ -162,11 +174,10 @@ export default function SequencerWidget({ moduleId }) { ); })} - {/* Playhead line */} - {currentStep >= 0 && ( + {visualStep >= 0 && ( )} diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 0cf9f4d..b4d9262 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -363,31 +363,46 @@ export function updateParam(moduleId, paramName, value) { } } +// Cache connection lookups for hot-path audio scheduling +// Rebuilt lazily when connections change +let _connCacheVersion = -1; +const _connByModulePort = new Map(); // "moduleId-portName" → [connections] + +function getConnectionsFrom(moduleId, portName) { + // Rebuild cache if connections changed + const version = state.connections.length + state.connections.reduce((s, c) => s + c.id, 0); + if (version !== _connCacheVersion) { + _connByModulePort.clear(); + for (const conn of state.connections) { + const key = `${conn.from.moduleId}-${conn.from.port}`; + if (!_connByModulePort.has(key)) _connByModulePort.set(key, []); + _connByModulePort.get(key).push(conn); + } + _connCacheVersion = version; + } + return _connByModulePort.get(`${moduleId}-${portName}`) || []; +} + export function setSequencerSignals(moduleId, freq, gate) { const entry = audioNodes[moduleId]; if (!entry) return; if (entry._freqSig) entry._freqSig.value = freq; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; - // Directly set connected oscillator frequencies (bypasses the modulation Gain) - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { - const oscEntry = audioNodes[conn.to.moduleId]; - const oscMod = state.modules.find(m => m.id === conn.to.moduleId); - if (oscEntry?.node && oscMod?.type === 'oscillator') { - oscEntry.node.frequency.value = freq; - } + // Set connected oscillator frequencies directly + for (const conn of getConnectionsFrom(moduleId, 'freq')) { + const oscEntry = audioNodes[conn.to.moduleId]; + if (oscEntry?.node?.frequency) { + oscEntry.node.frequency.value = freq; } } // Trigger connected envelopes - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { - const envEntry = audioNodes[conn.to.moduleId]; - if (envEntry && envEntry.node instanceof Tone.Envelope) { - if (gate) envEntry.node.triggerAttack(); - else envEntry.node.triggerRelease(); - } + for (const conn of getConnectionsFrom(moduleId, 'gate')) { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gate) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); } } } @@ -398,25 +413,20 @@ export function triggerKeyboard(moduleId, freq, gate) { if (entry._freqSig) entry._freqSig.value = freq; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; - // Directly set connected oscillator frequencies (bypasses the modulation Gain) - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { - const oscEntry = audioNodes[conn.to.moduleId]; - const oscMod = state.modules.find(m => m.id === conn.to.moduleId); - if (oscEntry?.node && oscMod?.type === 'oscillator') { - oscEntry.node.frequency.value = freq; - } + // Set connected oscillator frequencies directly + for (const conn of getConnectionsFrom(moduleId, 'freq')) { + const oscEntry = audioNodes[conn.to.moduleId]; + if (oscEntry?.node?.frequency) { + oscEntry.node.frequency.value = freq; } } - // Also trigger any connected envelopes - for (const conn of state.connections) { - if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { - const envEntry = audioNodes[conn.to.moduleId]; - if (envEntry && envEntry.node instanceof Tone.Envelope) { - if (gate) envEntry.node.triggerAttack(); - else envEntry.node.triggerRelease(); - } + // Trigger connected envelopes + for (const conn of getConnectionsFrom(moduleId, 'gate')) { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gate) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); } } }