feat: global master clock for drift-free multi-sequencer timing

Replace independent Tone.Clock per sequencer/pianoroll with a single
shared master clock running at 960 Hz in audioEngine.

Architecture:
- Master clock starts/stops with audio engine (startAudio/stopAudio)
- Widgets subscribe via subscribeTick(id, callback) receiving
  (audioTime, elapsed) on every tick
- Each widget derives its own step/position from elapsed time and
  its own BPM, so different BPMs stay perfectly in sync
- BPM/steps/bars changes are read from refs (no clock restart needed)

Benefits:
- All timing derived from one clock source = zero relative drift
- No clock recreation on param changes = no glitches
- 960 Hz tick rate ≈ 1ms precision (plenty for musical timing)
- Sequencer at 80 BPM and 160 BPM maintain perfect 1:2 ratio forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 17:45:10 +01:00
parent 9dba156961
commit 1f941d7e39
3 changed files with 92 additions and 62 deletions

View File

@@ -12,6 +12,48 @@ const audioNodes = {};
// Active keyboard state
const keyboardState = { frequency: 440, gate: false };
// ==================== Global Master Clock ====================
// Single high-resolution clock (960 ticks/sec ≈ 1ms precision).
// All sequencers/piano rolls derive their timing from this.
const MASTER_TICK_RATE = 960; // Hz
let _masterClock = null;
let _masterTime = 0; // audio-context seconds at clock start
const _tickListeners = new Map(); // id → callback(audioTime, elapsed)
export function getMasterTime() {
if (!_masterClock) return 0;
return Tone.now() - _masterTime;
}
export function subscribeTick(id, callback) {
_tickListeners.set(id, callback);
}
export function unsubscribeTick(id) {
_tickListeners.delete(id);
}
function startMasterClock() {
if (_masterClock) return;
_masterTime = Tone.now();
_masterClock = new Tone.Clock((time) => {
const elapsed = time - _masterTime;
for (const cb of _tickListeners.values()) {
cb(time, elapsed);
}
}, MASTER_TICK_RATE);
_masterClock.start();
}
function stopMasterClock() {
if (_masterClock) {
try { _masterClock.stop(); } catch {}
try { _masterClock.dispose(); } catch {}
_masterClock = null;
}
_tickListeners.clear();
}
// ==================== Node creation ====================
function createNode(mod) {
@@ -434,18 +476,21 @@ export function triggerKeyboard(moduleId, freq, gate) {
export async function startAudio() {
await Tone.start();
state.isRunning = true;
startMasterClock();
// Rebuild entire audio graph
rebuildGraph();
}
export function stopAudio() {
// Stop and reset Transport so pianoroll/sequencer Parts don't get stranded
stopMasterClock();
// Stop and reset Transport
try {
Tone.getTransport().stop();
Tone.getTransport().cancel(); // Remove all scheduled events
Tone.getTransport().cancel();
Tone.getTransport().position = 0;
} catch (e) { /* ignore if Transport not started */ }
} catch (e) {}
// Destroy all nodes
for (const id of Object.keys(audioNodes)) {