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

@@ -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();
}
}
}