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:
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
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';
|
import { parseMidi } from '../utils/midiParser.js';
|
||||||
|
|
||||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
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 }) {
|
export default function PianoRollWidget({ moduleId }) {
|
||||||
const mod = state.modules.find(m => m.id === moduleId);
|
const mod = state.modules.find(m => m.id === moduleId);
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const partRef = useRef(null);
|
|
||||||
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
||||||
const drawingRef = useRef(null);
|
const drawingRef = useRef(null);
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
@@ -234,38 +233,40 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||||
}, [draw]);
|
}, [draw]);
|
||||||
|
|
||||||
// Playback — uses independent Tone.Clock so multiple pianorolls/sequencers
|
// Subscribe to global master clock for playback
|
||||||
// don't interfere with each other via the global Transport
|
const bpmRef = useRef(bpm);
|
||||||
|
const loopRef = useRef(loop);
|
||||||
|
const totalBeatsRef = useRef(totalBeats);
|
||||||
|
bpmRef.current = bpm;
|
||||||
|
loopRef.current = loop;
|
||||||
|
totalBeatsRef.current = totalBeats;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isRunning) {
|
if (!state.isRunning) {
|
||||||
if (partRef.current) {
|
unsubscribeTick(`pr-${moduleId}`);
|
||||||
try { partRef.current.stop(); } catch {}
|
|
||||||
try { partRef.current.dispose(); } catch {}
|
|
||||||
partRef.current = null;
|
|
||||||
}
|
|
||||||
playPosRef.current = -1;
|
playPosRef.current = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sixteenthRate = (bpm * 4) / 60;
|
|
||||||
const startTime = Tone.now();
|
|
||||||
let currentNote = null;
|
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 rawPos = elapsed * sixteenthRate * 0.25; // in beats
|
||||||
const pos = loop ? rawPos % totalBeats : rawPos;
|
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
|
||||||
|
|
||||||
// Quantize to sixteenth resolution for consistent note detection
|
|
||||||
const quantPos = Math.floor(pos * 4) / 4;
|
const quantPos = Math.floor(pos * 4) / 4;
|
||||||
|
|
||||||
// Detect loop wrap
|
// Only process on quantized position change
|
||||||
const looped = lastPos >= 0 && quantPos < lastPos;
|
if (quantPos === lastQuantPos) return;
|
||||||
lastPos = quantPos;
|
const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
|
||||||
|
lastQuantPos = quantPos;
|
||||||
|
|
||||||
if (!loop && rawPos >= totalBeats) {
|
if (!currentLoop && rawPos >= currentTotalBeats) {
|
||||||
if (currentNote) {
|
if (currentNote) {
|
||||||
setSequencerSignals(moduleId, 0, false);
|
setSequencerSignals(moduleId, 0, false);
|
||||||
currentNote = null;
|
currentNote = null;
|
||||||
@@ -295,19 +296,12 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
currentNote = null;
|
currentNote = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, sixteenthRate * 2); // 2x rate for tighter detection
|
});
|
||||||
|
|
||||||
clock.start();
|
|
||||||
partRef.current = clock;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (partRef.current) {
|
unsubscribeTick(`pr-${moduleId}`);
|
||||||
try { partRef.current.stop(); } catch {}
|
|
||||||
try { partRef.current.dispose(); } catch {}
|
|
||||||
partRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [state.isRunning, moduleId, bpm, bars, loop]);
|
}, [state.isRunning, moduleId]);
|
||||||
|
|
||||||
// Mouse interaction for drawing/erasing notes
|
// Mouse interaction for drawing/erasing notes
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
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'];
|
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 mod = state.modules.find(m => m.id === moduleId);
|
||||||
const currentStepRef = useRef(-1);
|
const currentStepRef = useRef(-1);
|
||||||
const [visualStep, setVisualStep] = useState(-1);
|
const [visualStep, setVisualStep] = useState(-1);
|
||||||
const clockRef = useRef(null);
|
|
||||||
const stepsRef = useRef(null);
|
const stepsRef = useRef(null);
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
|
|
||||||
@@ -57,30 +56,30 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
};
|
};
|
||||||
}, [state.isRunning]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!state.isRunning) {
|
if (!state.isRunning) {
|
||||||
if (clockRef.current) {
|
unsubscribeTick(`seq-${moduleId}`);
|
||||||
try { clockRef.current.stop(); } catch {}
|
|
||||||
try { clockRef.current.dispose(); } catch {}
|
|
||||||
clockRef.current = null;
|
|
||||||
}
|
|
||||||
currentStepRef.current = -1;
|
currentStepRef.current = -1;
|
||||||
setVisualStep(-1);
|
setVisualStep(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sixteenthRate = (bpm * 4) / 60; // Hz
|
|
||||||
const stepDuration = 1 / sixteenthRate; // seconds per step
|
|
||||||
const startTime = Tone.now();
|
|
||||||
let lastStepIdx = -1;
|
let lastStepIdx = -1;
|
||||||
|
let noteOffTimeout = null;
|
||||||
|
|
||||||
const clock = new Tone.Clock((time) => {
|
subscribeTick(`seq-${moduleId}`, (time, elapsed) => {
|
||||||
// Derive step from audio clock time — no counter drift
|
const currentBpm = bpmRef.current;
|
||||||
const elapsed = time - startTime;
|
const currentNumSteps = numStepsRef.current;
|
||||||
const stepIdx = Math.floor(elapsed * sixteenthRate) % numSteps;
|
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;
|
if (stepIdx === lastStepIdx) return;
|
||||||
lastStepIdx = stepIdx;
|
lastStepIdx = stepIdx;
|
||||||
|
|
||||||
@@ -91,26 +90,18 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
|
|
||||||
if (s.gate) {
|
if (s.gate) {
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
||||||
// Note-off via audio-thread timeout (not main-thread setTimeout)
|
|
||||||
Tone.getContext().setTimeout(() => {
|
Tone.getContext().setTimeout(() => {
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||||
}, stepDuration * 0.8);
|
}, stepDuration * 0.8);
|
||||||
} else {
|
} else {
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||||
}
|
}
|
||||||
}, sixteenthRate * 2); // Run clock at 2x rate for tighter step detection
|
});
|
||||||
|
|
||||||
clock.start();
|
|
||||||
clockRef.current = clock;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (clockRef.current) {
|
unsubscribeTick(`seq-${moduleId}`);
|
||||||
try { clockRef.current.stop(); } catch {}
|
|
||||||
try { clockRef.current.dispose(); } catch {}
|
|
||||||
clockRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [state.isRunning, moduleId, numSteps, bpm]);
|
}, [state.isRunning, moduleId]);
|
||||||
|
|
||||||
const toggleGate = (idx) => {
|
const toggleGate = (idx) => {
|
||||||
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
|
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
|
||||||
|
|||||||
@@ -12,6 +12,48 @@ const audioNodes = {};
|
|||||||
// Active keyboard state
|
// Active keyboard state
|
||||||
const keyboardState = { frequency: 440, gate: false };
|
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 ====================
|
// ==================== Node creation ====================
|
||||||
|
|
||||||
function createNode(mod) {
|
function createNode(mod) {
|
||||||
@@ -434,18 +476,21 @@ export function triggerKeyboard(moduleId, freq, gate) {
|
|||||||
export async function startAudio() {
|
export async function startAudio() {
|
||||||
await Tone.start();
|
await Tone.start();
|
||||||
state.isRunning = true;
|
state.isRunning = true;
|
||||||
|
startMasterClock();
|
||||||
|
|
||||||
// Rebuild entire audio graph
|
// Rebuild entire audio graph
|
||||||
rebuildGraph();
|
rebuildGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAudio() {
|
export function stopAudio() {
|
||||||
// Stop and reset Transport so pianoroll/sequencer Parts don't get stranded
|
stopMasterClock();
|
||||||
|
|
||||||
|
// Stop and reset Transport
|
||||||
try {
|
try {
|
||||||
Tone.getTransport().stop();
|
Tone.getTransport().stop();
|
||||||
Tone.getTransport().cancel(); // Remove all scheduled events
|
Tone.getTransport().cancel();
|
||||||
Tone.getTransport().position = 0;
|
Tone.getTransport().position = 0;
|
||||||
} catch (e) { /* ignore if Transport not started */ }
|
} catch (e) {}
|
||||||
|
|
||||||
// Destroy all nodes
|
// Destroy all nodes
|
||||||
for (const id of Object.keys(audioNodes)) {
|
for (const id of Object.keys(audioNodes)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user