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

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals } from '../engine/audioEngine.js';
import { setSequencerSignals, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js';
import { parseMidi } from '../utils/midiParser.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
@@ -90,7 +90,6 @@ const ROW_H = ROLL_H / NOTE_RANGE;
export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null);
const partRef = useRef(null);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null);
const rafRef = useRef(null);
@@ -234,38 +233,40 @@ export default function PianoRollWidget({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]);
// Playback — uses independent Tone.Clock so multiple pianorolls/sequencers
// don't interfere with each other via the global Transport
// Subscribe to global master clock for playback
const bpmRef = useRef(bpm);
const loopRef = useRef(loop);
const totalBeatsRef = useRef(totalBeats);
bpmRef.current = bpm;
loopRef.current = loop;
totalBeatsRef.current = totalBeats;
useEffect(() => {
if (!state.isRunning) {
if (partRef.current) {
try { partRef.current.stop(); } catch {}
try { partRef.current.dispose(); } catch {}
partRef.current = null;
}
unsubscribeTick(`pr-${moduleId}`);
playPosRef.current = -1;
return;
}
const sixteenthRate = (bpm * 4) / 60;
const startTime = Tone.now();
let currentNote = null;
let lastPos = -1;
let lastQuantPos = -1;
subscribeTick(`pr-${moduleId}`, (time, elapsed) => {
const currentBpm = bpmRef.current;
const currentLoop = loopRef.current;
const currentTotalBeats = totalBeatsRef.current;
const sixteenthRate = (currentBpm * 4) / 60;
const clock = new Tone.Clock((time) => {
// Derive position from audio clock — no counter drift
const elapsed = time - startTime;
const rawPos = elapsed * sixteenthRate * 0.25; // in beats
const pos = loop ? rawPos % totalBeats : rawPos;
// Quantize to sixteenth resolution for consistent note detection
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
const quantPos = Math.floor(pos * 4) / 4;
// Detect loop wrap
const looped = lastPos >= 0 && quantPos < lastPos;
lastPos = quantPos;
// Only process on quantized position change
if (quantPos === lastQuantPos) return;
const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
lastQuantPos = quantPos;
if (!loop && rawPos >= totalBeats) {
if (!currentLoop && rawPos >= currentTotalBeats) {
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
@@ -295,19 +296,12 @@ export default function PianoRollWidget({ moduleId }) {
currentNote = null;
}
}
}, sixteenthRate * 2); // 2x rate for tighter detection
clock.start();
partRef.current = clock;
});
return () => {
if (partRef.current) {
try { partRef.current.stop(); } catch {}
try { partRef.current.dispose(); } catch {}
partRef.current = null;
}
unsubscribeTick(`pr-${moduleId}`);
};
}, [state.isRunning, moduleId, bpm, bars, loop]);
}, [state.isRunning, moduleId]);
// Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => {

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals, getAudioNode } from '../engine/audioEngine.js';
import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
@@ -19,7 +19,6 @@ export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const currentStepRef = useRef(-1);
const [visualStep, setVisualStep] = useState(-1);
const clockRef = useRef(null);
const stepsRef = useRef(null);
const rafRef = useRef(null);
@@ -57,30 +56,30 @@ export default function SequencerWidget({ moduleId }) {
};
}, [state.isRunning]);
// Audio clock — ONLY does audio work, no React state updates
// Subscribe to global master clock — derive step from elapsed time
const bpmRef = useRef(bpm);
const numStepsRef = useRef(numSteps);
bpmRef.current = bpm;
numStepsRef.current = numSteps;
useEffect(() => {
if (!state.isRunning) {
if (clockRef.current) {
try { clockRef.current.stop(); } catch {}
try { clockRef.current.dispose(); } catch {}
clockRef.current = null;
}
unsubscribeTick(`seq-${moduleId}`);
currentStepRef.current = -1;
setVisualStep(-1);
return;
}
const sixteenthRate = (bpm * 4) / 60; // Hz
const stepDuration = 1 / sixteenthRate; // seconds per step
const startTime = Tone.now();
let lastStepIdx = -1;
let noteOffTimeout = null;
const clock = new Tone.Clock((time) => {
// Derive step from audio clock time — no counter drift
const elapsed = time - startTime;
const stepIdx = Math.floor(elapsed * sixteenthRate) % numSteps;
subscribeTick(`seq-${moduleId}`, (time, elapsed) => {
const currentBpm = bpmRef.current;
const currentNumSteps = numStepsRef.current;
const sixteenthRate = (currentBpm * 4) / 60;
const stepDuration = 1 / sixteenthRate;
const stepIdx = Math.floor(elapsed * sixteenthRate) % currentNumSteps;
// Only trigger on step change
if (stepIdx === lastStepIdx) return;
lastStepIdx = stepIdx;
@@ -91,26 +90,18 @@ export default function SequencerWidget({ moduleId }) {
if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
// Note-off via audio-thread timeout (not main-thread setTimeout)
Tone.getContext().setTimeout(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, stepDuration * 0.8);
} else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}
}, sixteenthRate * 2); // Run clock at 2x rate for tighter step detection
clock.start();
clockRef.current = clock;
});
return () => {
if (clockRef.current) {
try { clockRef.current.stop(); } catch {}
try { clockRef.current.dispose(); } catch {}
clockRef.current = null;
}
unsubscribeTick(`seq-${moduleId}`);
};
}, [state.isRunning, moduleId, numSteps, bpm]);
}, [state.isRunning, moduleId]);
const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };

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)) {