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:
Jose Luis
2026-03-21 18:01:56 +01:00
parent 8bdb953b52
commit 7d3a19ec35
3 changed files with 23 additions and 26 deletions

View File

@@ -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, subscribeTick, unsubscribeTick } from '../engine/audioEngine.js'; import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } 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'];
@@ -251,17 +251,18 @@ export default function PianoRollWidget({ moduleId }) {
let currentNote = null; let currentNote = null;
let lastQuantPos = -1; let lastQuantPos = -1;
subscribeTick(`pr-${moduleId}`, (time, elapsed) => { subscribeTick(`pr-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current; const currentBpm = bpmRef.current;
const currentLoop = loopRef.current; const currentLoop = loopRef.current;
const currentTotalBeats = totalBeatsRef.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 pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
const quantPos = Math.floor(pos * 4) / 4; const quantPos = Math.floor(pos * 4) / 4;
// Only process on quantized position change
if (quantPos === lastQuantPos) return; if (quantPos === lastQuantPos) return;
const looped = lastQuantPos >= 0 && quantPos < lastQuantPos; const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
lastQuantPos = quantPos; lastQuantPos = quantPos;

View File

@@ -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, 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']; 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 lastStepIdx = -1;
let lastGateOn = false; let lastGateOn = false;
subscribeTick(`seq-${moduleId}`, (time, elapsed) => { subscribeTick(`seq-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current; const currentBpm = bpmRef.current;
const currentNumSteps = numStepsRef.current; const currentNumSteps = numStepsRef.current;
const sixteenthRate = (currentBpm * 4) / 60; // ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond
const stepIdx = Math.floor(elapsed * sixteenthRate) % currentNumSteps; // sixteenthsPerSecond = bpm * 4 / 60
const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4);
const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps;
if (stepIdx === lastStepIdx) return; if (stepIdx === lastStepIdx) return;
lastStepIdx = stepIdx; lastStepIdx = stepIdx;

View File

@@ -13,16 +13,16 @@ const audioNodes = {};
const keyboardState = { frequency: 440, gate: false }; const keyboardState = { frequency: 440, gate: false };
// ==================== Global Master Clock ==================== // ==================== Global Master Clock ====================
// Single high-resolution clock (960 ticks/sec ≈ 1ms precision). // Single clock with integer tick counter. All sequencers/piano rolls
// All sequencers/piano rolls derive their timing from this. // derive their step positions from this shared tick count.
const MASTER_TICK_RATE = 240; // Hz — enough for 300 BPM sixteenths (20 Hz) with 12x headroom // 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 _masterClock = null;
let _masterTime = 0; // audio-context seconds at clock start let _masterTicks = 0;
const _tickListeners = new Map(); // id → callback(audioTime, elapsed) const _tickListeners = new Map(); // id → callback(audioTime, ticks)
export function getMasterTime() { export function getMasterTicks() {
if (!_masterClock) return 0; return _masterTicks;
return Tone.now() - _masterTime;
} }
export function subscribeTick(id, callback) { export function subscribeTick(id, callback) {
@@ -35,17 +35,11 @@ export function unsubscribeTick(id) {
function startMasterClock() { function startMasterClock() {
if (_masterClock) return; if (_masterClock) return;
_masterTime = 0; // Will be set from first tick _masterTicks = 0;
let _started = false;
_masterClock = new Tone.Clock((time) => { _masterClock = new Tone.Clock((time) => {
// Capture start time from the FIRST callback — guarantees same clock source _masterTicks++;
if (!_started) {
_masterTime = time;
_started = true;
}
const elapsed = time - _masterTime;
for (const cb of _tickListeners.values()) { for (const cb of _tickListeners.values()) {
cb(time, elapsed); cb(time, _masterTicks);
} }
}, MASTER_TICK_RATE); }, MASTER_TICK_RATE);
_masterClock.start(); _masterClock.start();