fix: wwitched the preroll metronome scheduling off window.setTimeout and onto Tone’s audio-context clock

This commit is contained in:
Xiaohan-Tian
2026-05-01 12:16:57 -07:00
parent a474065450
commit adb2345693
5 changed files with 40 additions and 26 deletions
@@ -26,6 +26,7 @@ import { KGAudioInterface } from './KGAudioInterface'
describe('KGAudioInterface preroll playback', () => { describe('KGAudioInterface preroll playback', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(0)
vi.clearAllMocks() vi.clearAllMocks()
MockTransport.position = 0 MockTransport.position = 0
+16 -16
View File
@@ -43,9 +43,9 @@ export class KGAudioInterface {
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME; private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
private scheduledEvents: Set<number> = new Set(); // Tone event IDs private scheduledEvents: Set<number> = new Set(); // Tone event IDs
private delayedTransportStartTimeoutId: number | null = null; private delayedTransportStartTimeoutId: number | null = null;
private delayedTransportStartMs: number = 0; private delayedTransportStartSeconds: number = 0;
private virtualPrerollStartBeat: number | null = null; private virtualPrerollStartBeat: number | null = null;
private virtualPrerollStartTimeMs: number | null = null; private virtualPrerollStartAudioTime: number | null = null;
// Master volume control // Master volume control
private masterGain: Tone.Gain | null = null; private masterGain: Tone.Gain | null = null;
@@ -446,13 +446,13 @@ export class KGAudioInterface {
} }
if (startPosition < 0) { if (startPosition < 0) {
this.delayedTransportStartMs = Math.abs(startPosition) * secondsPerBeat * 1000; this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
this.virtualPrerollStartBeat = startPosition; this.virtualPrerollStartBeat = startPosition;
this.virtualPrerollStartTimeMs = null; this.virtualPrerollStartAudioTime = null;
} else { } else {
this.delayedTransportStartMs = 0; this.delayedTransportStartSeconds = 0;
this.virtualPrerollStartBeat = null; this.virtualPrerollStartBeat = null;
this.virtualPrerollStartTimeMs = null; this.virtualPrerollStartAudioTime = null;
} }
// Set transport position (convert beats to Tone.js format) // Set transport position (convert beats to Tone.js format)
@@ -662,14 +662,14 @@ export class KGAudioInterface {
throw new Error('Audio context not started'); throw new Error('Audio context not started');
} }
if (this.delayedTransportStartMs > 0 && this.virtualPrerollStartBeat !== null) { if (this.delayedTransportStartSeconds > 0 && this.virtualPrerollStartBeat !== null) {
this.virtualPrerollStartTimeMs = performance.now(); this.virtualPrerollStartAudioTime = Tone.now();
this.delayedTransportStartTimeoutId = window.setTimeout(() => { this.delayedTransportStartTimeoutId = Tone.getContext().setTimeout(() => {
this.delayedTransportStartTimeoutId = null; this.delayedTransportStartTimeoutId = null;
this.virtualPrerollStartBeat = null; this.virtualPrerollStartBeat = null;
this.virtualPrerollStartTimeMs = null; this.virtualPrerollStartAudioTime = null;
Tone.Transport.start(); Tone.Transport.start();
}, this.delayedTransportStartMs); }, this.delayedTransportStartSeconds);
} else { } else {
Tone.Transport.start(); Tone.Transport.start();
} }
@@ -830,10 +830,10 @@ export class KGAudioInterface {
*/ */
public getTransportPosition(): number { public getTransportPosition(): number {
try { try {
if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartTimeMs !== null) { if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartAudioTime !== null) {
const project = KGCore.instance().getCurrentProject(); const project = KGCore.instance().getCurrentProject();
const secondsPerBeat = 60 / project.getBpm(); const secondsPerBeat = 60 / project.getBpm();
const elapsedSeconds = (performance.now() - this.virtualPrerollStartTimeMs) / 1000; const elapsedSeconds = Math.max(0, Tone.now() - this.virtualPrerollStartAudioTime);
const elapsedBeats = elapsedSeconds / secondsPerBeat; const elapsedBeats = elapsedSeconds / secondsPerBeat;
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats); return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
} }
@@ -1011,13 +1011,13 @@ export class KGAudioInterface {
private clearDelayedTransportStart(): void { private clearDelayedTransportStart(): void {
if (this.delayedTransportStartTimeoutId !== null) { if (this.delayedTransportStartTimeoutId !== null) {
window.clearTimeout(this.delayedTransportStartTimeoutId); Tone.getContext().clearTimeout(this.delayedTransportStartTimeoutId);
this.delayedTransportStartTimeoutId = null; this.delayedTransportStartTimeoutId = null;
} }
this.delayedTransportStartMs = 0; this.delayedTransportStartSeconds = 0;
this.virtualPrerollStartBeat = null; this.virtualPrerollStartBeat = null;
this.virtualPrerollStartTimeMs = null; this.virtualPrerollStartAudioTime = null;
} }
// ===== PRIVATE UTILITY METHODS ===== // ===== PRIVATE UTILITY METHODS =====
+8 -4
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MockLoop, MockTransport, ToneMock } from '../../test/mocks/tone' import { MockLoop, MockTransport } from '../../test/mocks/tone'
vi.mock('tone', async () => { vi.mock('tone', async () => {
const { ToneMock: toneMock } = await import('../../test/mocks/tone') const { ToneMock: toneMock } = await import('../../test/mocks/tone')
@@ -11,10 +11,10 @@ import { KGMetronome } from './KGMetronome'
describe('KGMetronome', () => { describe('KGMetronome', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(0)
vi.clearAllMocks() vi.clearAllMocks()
MockTransport.bpm.value = 120 MockTransport.bpm.value = 120
MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ) MockTransport.getTicksAtTime.mockImplementation((time: number) => time * MockTransport.PPQ)
vi.mocked(ToneMock.now).mockReturnValue(42)
}) })
it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => { it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => {
@@ -28,10 +28,14 @@ describe('KGMetronome', () => {
metronome.start(-4, 4, 0.2) metronome.start(-4, 4, 0.2)
vi.advanceTimersByTime(200) vi.advanceTimersByTime(200)
expect(triggerAttackRelease).toHaveBeenNthCalledWith(1, 'C5', '16n', 42) expect(triggerAttackRelease.mock.calls[0]?.[0]).toBe('C5')
expect(triggerAttackRelease.mock.calls[0]?.[1]).toBe('16n')
expect(triggerAttackRelease.mock.calls[0]?.[2]).toBeCloseTo(0.2, 5)
vi.advanceTimersByTime(1000) vi.advanceTimersByTime(1000)
expect(triggerAttackRelease).toHaveBeenNthCalledWith(2, 'C4', '16n', 42) expect(triggerAttackRelease.mock.calls[1]?.[0]).toBe('C4')
expect(triggerAttackRelease.mock.calls[1]?.[1]).toBe('16n')
expect(triggerAttackRelease.mock.calls[1]?.[2]).toBeCloseTo(0.7, 5)
const transportLoop = MockLoop.mock.results[0]?.value const transportLoop = MockLoop.mock.results[0]?.value
expect(transportLoop).toBeDefined() expect(transportLoop).toBeDefined()
+6 -4
View File
@@ -55,7 +55,8 @@ export class KGMetronome {
/** Stop and dispose the loop only — sampler is kept alive for reuse. */ /** Stop and dispose the loop only — sampler is kept alive for reuse. */
stop(): void { stop(): void {
this.prerollTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); const context = Tone.getContext();
this.prerollTimeoutIds.forEach(timeoutId => context.clearTimeout(timeoutId));
this.prerollTimeoutIds = []; this.prerollTimeoutIds = [];
if (this.loop) { if (this.loop) {
@@ -78,16 +79,17 @@ export class KGMetronome {
} }
const secondsPerBeat = 60 / Tone.Transport.bpm.value; const secondsPerBeat = 60 / Tone.Transport.bpm.value;
const context = Tone.getContext();
const firstBeat = Math.ceil(startPositionBeats); const firstBeat = Math.ceil(startPositionBeats);
for (let beat = firstBeat; beat < 0; beat += 1) { for (let beat = firstBeat; beat < 0; beat += 1) {
const waitMs = Math.max(0, ((beat - startPositionBeats) * secondsPerBeat + playbackDelay) * 1000); const waitSeconds = Math.max(0, (beat - startPositionBeats) * secondsPerBeat + playbackDelay);
const note = beat % beatsPerBar === 0 ? 'C5' : 'C4'; const note = beat % beatsPerBar === 0 ? 'C5' : 'C4';
const timeoutId = window.setTimeout(() => { const timeoutId = context.setTimeout(() => {
if (this.sampler?.loaded) { if (this.sampler?.loaded) {
this.sampler.triggerAttackRelease(note, '16n', Tone.now()); this.sampler.triggerAttackRelease(note, '16n', Tone.now());
} }
}, waitMs); }, waitSeconds);
this.prerollTimeoutIds.push(timeoutId); this.prerollTimeoutIds.push(timeoutId);
} }
} }
+9 -2
View File
@@ -107,7 +107,14 @@ export const ToneMock = {
state: 'running', state: 'running',
resume: vi.fn().mockResolvedValue(undefined), resume: vi.fn().mockResolvedValue(undefined),
suspend: vi.fn().mockResolvedValue(undefined), suspend: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined) close: vi.fn().mockResolvedValue(undefined),
lookAhead: 0.05,
setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => {
return window.setTimeout(fn, timeoutSeconds * 1000)
}),
clearTimeout: vi.fn().mockImplementation((id: number) => {
window.clearTimeout(id)
})
}), }),
// Time utilities // Time utilities
@@ -121,7 +128,7 @@ export const ToneMock = {
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440), toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440) valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
})), })),
now: vi.fn().mockReturnValue(0) now: vi.fn().mockImplementation(() => Date.now() / 1000)
} }
// Setup the global mock // Setup the global mock