fix: use integer tick counter to eliminate floating-point beat drift
Root cause: floor(elapsed * rateA) vs floor(elapsed * rateB) where rateB = 2*rateA doesn't maintain exact 2:1 ratio due to floating-point multiplication errors. This creates a beat/aliasing pattern where sequencers at 80 and 160 BPM periodically go in and out of phase. Fix: Master clock now uses an integer tick counter (_masterTicks++) instead of floating-point elapsed time. Sequencers derive steps via: stepIdx = floor(ticks / ticksPerStep) % numSteps where ticks is an integer — no floating-point accumulation possible. Also bumped master clock to 480 Hz for cleaner division at common BPMs: 80 BPM: 480*60/320 = 90 ticks/step (exact) 120 BPM: 480*60/480 = 60 ticks/step (exact) 160 BPM: 480*60/640 = 45 ticks/step (exact) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,16 +13,16 @@ const audioNodes = {};
|
||||
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 = 240; // Hz — enough for 300 BPM sixteenths (20 Hz) with 12x headroom
|
||||
// Single clock with integer tick counter. All sequencers/piano rolls
|
||||
// derive their step positions from this shared tick count.
|
||||
// Using integers avoids floating-point drift entirely.
|
||||
export const MASTER_TICK_RATE = 480; // Hz — must be high enough for fastest BPM
|
||||
let _masterClock = null;
|
||||
let _masterTime = 0; // audio-context seconds at clock start
|
||||
const _tickListeners = new Map(); // id → callback(audioTime, elapsed)
|
||||
let _masterTicks = 0;
|
||||
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
|
||||
|
||||
export function getMasterTime() {
|
||||
if (!_masterClock) return 0;
|
||||
return Tone.now() - _masterTime;
|
||||
export function getMasterTicks() {
|
||||
return _masterTicks;
|
||||
}
|
||||
|
||||
export function subscribeTick(id, callback) {
|
||||
@@ -35,17 +35,11 @@ export function unsubscribeTick(id) {
|
||||
|
||||
function startMasterClock() {
|
||||
if (_masterClock) return;
|
||||
_masterTime = 0; // Will be set from first tick
|
||||
let _started = false;
|
||||
_masterTicks = 0;
|
||||
_masterClock = new Tone.Clock((time) => {
|
||||
// Capture start time from the FIRST callback — guarantees same clock source
|
||||
if (!_started) {
|
||||
_masterTime = time;
|
||||
_started = true;
|
||||
}
|
||||
const elapsed = time - _masterTime;
|
||||
_masterTicks++;
|
||||
for (const cb of _tickListeners.values()) {
|
||||
cb(time, elapsed);
|
||||
cb(time, _masterTicks);
|
||||
}
|
||||
}, MASTER_TICK_RATE);
|
||||
_masterClock.start();
|
||||
|
||||
Reference in New Issue
Block a user