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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user