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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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