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 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;

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 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 (
<div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* 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 (
<g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/>
{/* Note bar */}
{s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1}
@@ -136,17 +151,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9}
/>
)}
{/* Inactive marker */}
{!s.gate && (
<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} />
)}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)}
</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}
fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)}
@@ -162,11 +174,10 @@ export default function SequencerWidget({ moduleId }) {
</g>
);
})}
{/* Playhead line */}
{currentStep >= 0 && (
{visualStep >= 0 && (
<line
x1={currentStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H}
x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/>
)}

View File

@@ -363,26 +363,42 @@ 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') {
// Set connected oscillator frequencies directly
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
const oscEntry = audioNodes[conn.to.moduleId];
const oscMod = state.modules.find(m => m.id === conn.to.moduleId);
if (oscEntry?.node && oscMod?.type === 'oscillator') {
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') {
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();
@@ -390,7 +406,6 @@ export function setSequencerSignals(moduleId, freq, gate) {
}
}
}
}
export function triggerKeyboard(moduleId, freq, gate) {
const entry = audioNodes[moduleId];
@@ -398,20 +413,16 @@ 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') {
// Set connected oscillator frequencies directly
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
const oscEntry = audioNodes[conn.to.moduleId];
const oscMod = state.modules.find(m => m.id === conn.to.moduleId);
if (oscEntry?.node && oscMod?.type === 'oscillator') {
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') {
// 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();
@@ -419,7 +430,6 @@ export function triggerKeyboard(moduleId, freq, gate) {
}
}
}
}
export async function startAudio() {
await Tone.start();