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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user