fix: eliminate audio timing jitter and rhythm drift

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) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 17:35:41 +01:00
parent 1cf39f9b13
commit b91b35f23d
3 changed files with 88 additions and 72 deletions

View File

@@ -91,10 +91,10 @@ export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null); const canvasRef = useRef(null);
const partRef = useRef(null); const partRef = useRef(null);
const [playPos, setPlayPos] = useState(-1);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null); const drawingRef = useRef(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const playPosRef = useRef(-1);
const midiInputRef = useRef(null); const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
@@ -196,8 +196,9 @@ export default function PianoRollWidget({ moduleId }) {
} }
// Playhead // Playhead
if (playPos >= 0 && playPos < totalBeats) { const currentPlayPos = playPosRef.current;
const px = KEY_W + playPos * beatW; if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
const px = KEY_W + currentPlayPos * beatW;
ctx.strokeStyle = '#ff6644'; ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
@@ -221,7 +222,7 @@ export default function PianoRollWidget({ moduleId }) {
ctx.fillStyle = 'rgba(0,229,255,0.3)'; ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H); ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
} }
}, [totalBeats, beatW, playPos, rollW]); }, [totalBeats, beatW, rollW]);
// Animation loop // Animation loop
useEffect(() => { useEffect(() => {
@@ -242,7 +243,7 @@ export default function PianoRollWidget({ moduleId }) {
try { partRef.current.dispose(); } catch {} try { partRef.current.dispose(); } catch {}
partRef.current = null; partRef.current = null;
} }
setPlayPos(-1); playPosRef.current = -1;
return; return;
} }
@@ -252,46 +253,40 @@ export default function PianoRollWidget({ moduleId }) {
let currentNote = null; // track currently sounding note for on/off transitions let currentNote = null; // track currently sounding note for on/off transitions
const clock = new Tone.Clock(() => { 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 pos = loop ? rawPos % totalBeats : rawPos;
const prevRawPos = (tickCount - 1) * 0.25; const prevRawPos = (tickCount - 1) * 0.25;
const prevPos = loop ? prevRawPos % totalBeats : prevRawPos; const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
tickCount++; tickCount++;
// Detect loop wrap (position jumped backwards)
const looped = tickCount > 1 && pos < prevPos; const looped = tickCount > 1 && pos < prevPos;
// Stop at end if not looping
if (!loop && rawPos >= totalBeats) { if (!loop && rawPos >= totalBeats) {
if (currentNote) { if (currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
setPlayPos(-1); playPosRef.current = -1;
return; 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) { if (looped && currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;
} }
// Find the note active at this position
const allNotes = notesRef.current; const allNotes = notesRef.current;
const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration); const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration);
if (activeNote) { if (activeNote) {
// New note or different note → trigger
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true); setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
currentNote = activeNote; currentNote = activeNote;
} }
// Same note sustaining → do nothing
} else { } else {
// No note at this position → gate off
if (currentNote) { if (currentNote) {
setSequencerSignals(moduleId, 0, false); setSequencerSignals(moduleId, 0, false);
currentNote = null; currentNote = null;

View File

@@ -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 midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
// Default notes: C minor pentatonic pattern
const DEFAULT_STEPS = [ const DEFAULT_STEPS = [
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { 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 }, { 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 }) { export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === 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 clockRef = useRef(null);
const stepsRef = 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'); const numSteps = parseInt(mod?.params?.steps || '16');
if (mod) { if (mod) {
if (!mod.params._steps) { if (!mod.params._steps) {
@@ -30,12 +31,10 @@ export default function SequencerWidget({ moduleId }) {
while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
mod.params._steps = initial; mod.params._steps = initial;
} else if (mod.params._steps.length < numSteps) { } else if (mod.params._steps.length < numSteps) {
// Grow: pad with empty steps
while (mod.params._steps.length < numSteps) { while (mod.params._steps.length < numSteps) {
mod.params._steps.push({ midi: 60, gate: false }); mod.params._steps.push({ midi: 60, gate: false });
} }
} else if (mod.params._steps.length > numSteps) { } else if (mod.params._steps.length > numSteps) {
// Shrink: truncate
mod.params._steps = mod.params._steps.slice(0, numSteps); mod.params._steps = mod.params._steps.slice(0, numSteps);
} }
} }
@@ -44,8 +43,21 @@ export default function SequencerWidget({ moduleId }) {
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
// Start/stop sequencer when audio engine runs — uses independent Tone.Clock // Visual update loop — decoupled from audio, uses RAF
// so multiple sequencers don't interfere with each other via the global Transport 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(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (clockRef.current) { if (clockRef.current) {
@@ -53,13 +65,15 @@ export default function SequencerWidget({ moduleId }) {
try { clockRef.current.dispose(); } catch {} try { clockRef.current.dispose(); } catch {}
clockRef.current = null; clockRef.current = null;
} }
setCurrentStep(-1); currentStepRef.current = -1;
setVisualStep(-1);
return; return;
} }
// Independent clock at 16th-note rate const sixteenthRate = (bpm * 4) / 60;
const sixteenthRate = (bpm * 4) / 60; // Hz const stepDuration = 1 / sixteenthRate;
let step = 0; let step = 0;
let noteOffId = null;
const clock = new Tone.Clock((time) => { const clock = new Tone.Clock((time) => {
const stepIdx = step % numSteps; const stepIdx = step % numSteps;
@@ -67,15 +81,19 @@ export default function SequencerWidget({ moduleId }) {
const s = stepsRef.current[stepIdx]; const s = stepsRef.current[stepIdx];
if (!s) return; if (!s) return;
setCurrentStep(stepIdx); // Update ref (not state!) — visual follows via RAF
currentStepRef.current = stepIdx;
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
// Schedule note-off at 80% of step duration // Schedule note-off using Tone.Draw or Tone.context
const stepDuration = 1 / sixteenthRate; // Use the audio clock's time for precise scheduling
setTimeout(() => { if (noteOffId !== null) {
try { Tone.getTransport().clear(noteOffId); } catch {}
}
noteOffId = Tone.getTransport().scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, stepDuration * 0.8 * 1000); }, time + stepDuration * 0.8);
} else { } else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
} }
@@ -115,20 +133,17 @@ export default function SequencerWidget({ moduleId }) {
return ( return (
<div style={{ width: W + 4, overflow: 'hidden' }}> <div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}> <svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* Steps */}
{steps.slice(0, numSteps).map((s, i) => { {steps.slice(0, numSteps).map((s, i) => {
const x = i * CELL_W; const x = i * CELL_W;
const isActive = i === currentStep; const isActive = i === visualStep;
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
return ( return (
<g key={i}> <g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H} <rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'} rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5} stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/> />
{/* Note bar */}
{s.gate && ( {s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight} <rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1} rx={1}
@@ -136,17 +151,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9} opacity={0.9}
/> />
)} )}
{/* Inactive marker */}
{!s.gate && ( {!s.gate && (
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3} <line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
stroke="#333" strokeWidth={1.5} /> stroke="#333" strokeWidth={1.5} />
)} )}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle" <text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace"> fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)} {noteLabel(s.midi)}
</text> </text>
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3} <rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }} fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)} onClick={() => changeNote(i, 1)}
@@ -162,11 +174,10 @@ export default function SequencerWidget({ moduleId }) {
</g> </g>
); );
})} })}
{/* Playhead line */} {visualStep >= 0 && (
{currentStep >= 0 && (
<line <line
x1={currentStep * CELL_W + CELL_W / 2} y1={0} x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H} x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4} stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/> />
)} )}

View File

@@ -363,33 +363,48 @@ 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) { export function setSequencerSignals(moduleId, freq, gate) {
const entry = audioNodes[moduleId]; const entry = audioNodes[moduleId];
if (!entry) return; if (!entry) return;
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Directly set connected oscillator frequencies (bypasses the modulation Gain) // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') {
const oscEntry = audioNodes[conn.to.moduleId]; const oscEntry = audioNodes[conn.to.moduleId];
const oscMod = state.modules.find(m => m.id === conn.to.moduleId); if (oscEntry?.node?.frequency) {
if (oscEntry?.node && oscMod?.type === 'oscillator') {
oscEntry.node.frequency.value = freq; oscEntry.node.frequency.value = freq;
} }
} }
}
// Trigger connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
const envEntry = audioNodes[conn.to.moduleId]; const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack(); if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease(); else envEntry.node.triggerRelease();
} }
} }
}
} }
export function triggerKeyboard(moduleId, freq, gate) { export function triggerKeyboard(moduleId, freq, gate) {
@@ -398,27 +413,22 @@ export function triggerKeyboard(moduleId, freq, gate) {
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Directly set connected oscillator frequencies (bypasses the modulation Gain) // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') {
const oscEntry = audioNodes[conn.to.moduleId]; const oscEntry = audioNodes[conn.to.moduleId];
const oscMod = state.modules.find(m => m.id === conn.to.moduleId); if (oscEntry?.node?.frequency) {
if (oscEntry?.node && oscMod?.type === 'oscillator') {
oscEntry.node.frequency.value = freq; oscEntry.node.frequency.value = freq;
} }
} }
}
// Also trigger any connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
const envEntry = audioNodes[conn.to.moduleId]; const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack(); if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease(); else envEntry.node.triggerRelease();
} }
} }
}
} }
export async function startAudio() { export async function startAudio() {