From 7d3a19ec35c56fc170911fbd8cefd262511a9ea6 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:01:56 +0100 Subject: [PATCH] fix: use integer tick counter to eliminate floating-point beat drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/PianoRollWidget.jsx | 11 ++++++----- src/components/SequencerWidget.jsx | 10 ++++++---- src/engine/audioEngine.js | 28 +++++++++++----------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 46c9916..31ca2fe 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, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; +import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } 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']; @@ -251,17 +251,18 @@ export default function PianoRollWidget({ moduleId }) { let currentNote = null; let lastQuantPos = -1; - subscribeTick(`pr-${moduleId}`, (time, elapsed) => { + subscribeTick(`pr-${moduleId}`, (time, ticks) => { const currentBpm = bpmRef.current; const currentLoop = loopRef.current; const currentTotalBeats = totalBeatsRef.current; - const sixteenthRate = (currentBpm * 4) / 60; - const rawPos = elapsed * sixteenthRate * 0.25; // in beats + // Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks + // Position in sixteenths: ticks / (ticksPerSixteenth) + const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm; + const rawPos = ticks / ticksPerBeat; // in beats const pos = currentLoop ? rawPos % currentTotalBeats : rawPos; const quantPos = Math.floor(pos * 4) / 4; - // Only process on quantized position change if (quantPos === lastQuantPos) return; const looped = lastQuantPos >= 0 && quantPos < lastQuantPos; lastQuantPos = quantPos; diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index e725737..3bf0c10 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, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; +import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; @@ -73,11 +73,13 @@ export default function SequencerWidget({ moduleId }) { let lastStepIdx = -1; let lastGateOn = false; - subscribeTick(`seq-${moduleId}`, (time, elapsed) => { + subscribeTick(`seq-${moduleId}`, (time, ticks) => { const currentBpm = bpmRef.current; const currentNumSteps = numStepsRef.current; - const sixteenthRate = (currentBpm * 4) / 60; - const stepIdx = Math.floor(elapsed * sixteenthRate) % currentNumSteps; + // ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond + // sixteenthsPerSecond = bpm * 4 / 60 + const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4); + const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps; if (stepIdx === lastStepIdx) return; lastStepIdx = stepIdx; diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 8e90623..0239e25 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -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();