From 1f941d7e39341e9b5af30de1dc9f4913ce5ac141 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 17:45:10 +0100 Subject: [PATCH] feat: global master clock for drift-free multi-sequencer timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/PianoRollWidget.jsx | 58 ++++++++++++++---------------- src/components/SequencerWidget.jsx | 45 ++++++++++------------- src/engine/audioEngine.js | 51 ++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 62 deletions(-) diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 73474a5..46c9916 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -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) => { diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index d614391..39a2c77 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -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 }; diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index b4d9262..fcd950f 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -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)) {