diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index 64b15af..44b468e 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -26,6 +26,7 @@ import { KGAudioInterface } from './KGAudioInterface' describe('KGAudioInterface preroll playback', () => { beforeEach(() => { vi.useFakeTimers() + vi.setSystemTime(0) vi.clearAllMocks() MockTransport.position = 0 diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index ae97c2d..845d0cc 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -43,9 +43,9 @@ export class KGAudioInterface { private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME; private scheduledEvents: Set = new Set(); // Tone event IDs private delayedTransportStartTimeoutId: number | null = null; - private delayedTransportStartMs: number = 0; + private delayedTransportStartSeconds: number = 0; private virtualPrerollStartBeat: number | null = null; - private virtualPrerollStartTimeMs: number | null = null; + private virtualPrerollStartAudioTime: number | null = null; // Master volume control private masterGain: Tone.Gain | null = null; @@ -446,13 +446,13 @@ export class KGAudioInterface { } if (startPosition < 0) { - this.delayedTransportStartMs = Math.abs(startPosition) * secondsPerBeat * 1000; + this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat; this.virtualPrerollStartBeat = startPosition; - this.virtualPrerollStartTimeMs = null; + this.virtualPrerollStartAudioTime = null; } else { - this.delayedTransportStartMs = 0; + this.delayedTransportStartSeconds = 0; this.virtualPrerollStartBeat = null; - this.virtualPrerollStartTimeMs = null; + this.virtualPrerollStartAudioTime = null; } // Set transport position (convert beats to Tone.js format) @@ -662,14 +662,14 @@ export class KGAudioInterface { throw new Error('Audio context not started'); } - if (this.delayedTransportStartMs > 0 && this.virtualPrerollStartBeat !== null) { - this.virtualPrerollStartTimeMs = performance.now(); - this.delayedTransportStartTimeoutId = window.setTimeout(() => { + if (this.delayedTransportStartSeconds > 0 && this.virtualPrerollStartBeat !== null) { + this.virtualPrerollStartAudioTime = Tone.now(); + this.delayedTransportStartTimeoutId = Tone.getContext().setTimeout(() => { this.delayedTransportStartTimeoutId = null; this.virtualPrerollStartBeat = null; - this.virtualPrerollStartTimeMs = null; + this.virtualPrerollStartAudioTime = null; Tone.Transport.start(); - }, this.delayedTransportStartMs); + }, this.delayedTransportStartSeconds); } else { Tone.Transport.start(); } @@ -830,10 +830,10 @@ export class KGAudioInterface { */ public getTransportPosition(): number { try { - if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartTimeMs !== null) { + if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartAudioTime !== null) { const project = KGCore.instance().getCurrentProject(); 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; return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats); } @@ -1011,13 +1011,13 @@ export class KGAudioInterface { private clearDelayedTransportStart(): void { if (this.delayedTransportStartTimeoutId !== null) { - window.clearTimeout(this.delayedTransportStartTimeoutId); + Tone.getContext().clearTimeout(this.delayedTransportStartTimeoutId); this.delayedTransportStartTimeoutId = null; } - this.delayedTransportStartMs = 0; + this.delayedTransportStartSeconds = 0; this.virtualPrerollStartBeat = null; - this.virtualPrerollStartTimeMs = null; + this.virtualPrerollStartAudioTime = null; } // ===== PRIVATE UTILITY METHODS ===== diff --git a/src/core/audio-interface/KGMetronome.test.ts b/src/core/audio-interface/KGMetronome.test.ts index 4369c9e..ebf1f43 100644 --- a/src/core/audio-interface/KGMetronome.test.ts +++ b/src/core/audio-interface/KGMetronome.test.ts @@ -1,5 +1,5 @@ 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 () => { const { ToneMock: toneMock } = await import('../../test/mocks/tone') @@ -11,10 +11,10 @@ import { KGMetronome } from './KGMetronome' describe('KGMetronome', () => { beforeEach(() => { vi.useFakeTimers() + vi.setSystemTime(0) vi.clearAllMocks() MockTransport.bpm.value = 120 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', () => { @@ -28,10 +28,14 @@ describe('KGMetronome', () => { metronome.start(-4, 4, 0.2) 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) - 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 expect(transportLoop).toBeDefined() diff --git a/src/core/audio-interface/KGMetronome.ts b/src/core/audio-interface/KGMetronome.ts index 2bce458..eac151d 100644 --- a/src/core/audio-interface/KGMetronome.ts +++ b/src/core/audio-interface/KGMetronome.ts @@ -55,7 +55,8 @@ export class KGMetronome { /** Stop and dispose the loop only — sampler is kept alive for reuse. */ stop(): void { - this.prerollTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); + const context = Tone.getContext(); + this.prerollTimeoutIds.forEach(timeoutId => context.clearTimeout(timeoutId)); this.prerollTimeoutIds = []; if (this.loop) { @@ -78,16 +79,17 @@ export class KGMetronome { } const secondsPerBeat = 60 / Tone.Transport.bpm.value; + const context = Tone.getContext(); const firstBeat = Math.ceil(startPositionBeats); 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 timeoutId = window.setTimeout(() => { + const timeoutId = context.setTimeout(() => { if (this.sampler?.loaded) { this.sampler.triggerAttackRelease(note, '16n', Tone.now()); } - }, waitMs); + }, waitSeconds); this.prerollTimeoutIds.push(timeoutId); } } diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts index 38bca09..53a7e27 100644 --- a/src/test/mocks/tone.ts +++ b/src/test/mocks/tone.ts @@ -107,7 +107,14 @@ export const ToneMock = { state: 'running', resume: 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 @@ -121,7 +128,7 @@ export const ToneMock = { toFrequency: 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