diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts new file mode 100644 index 0000000..64b15af --- /dev/null +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockProject } from '../../test/utils/mock-data' +import { MockTransport } from '../../test/mocks/tone' + +vi.mock('tone', async () => { + const { ToneMock } = await import('../../test/mocks/tone') + return ToneMock +}) + +vi.mock('../KGCore', () => ({ + KGCore: { + instance: vi.fn() + } +})) + +vi.mock('../config/ConfigManager', () => ({ + ConfigManager: { + instance: vi.fn() + } +})) + +import { KGCore } from '../KGCore' +import { ConfigManager } from '../config/ConfigManager' +import { KGAudioInterface } from './KGAudioInterface' + +describe('KGAudioInterface preroll playback', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + + MockTransport.position = 0 + MockTransport.start.mockClear() + MockTransport.stop.mockClear() + MockTransport.clear.mockClear() + MockTransport.schedule.mockClear() + MockTransport.bpm.value = 120 + + const project = createMockProject({ + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + tracks: [], + }) + + vi.mocked(KGCore.instance).mockReturnValue({ + getCurrentProject: () => project, + } as unknown as KGCore) + + vi.mocked(ConfigManager.instance).mockReturnValue({ + get: (key: string) => { + if (key === 'audio.playback_delay') return 0.2 + if (key === 'audio.lookahead_time') return 0.05 + return null + }, + } as unknown as ConfigManager) + + ;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null + }) + + it('uses virtual negative beats until the delayed transport start reaches beat 0', () => { + const project = KGCore.instance().getCurrentProject() + const audio = KGAudioInterface.instance() + ;(audio as unknown as { isInitialized: boolean }).isInitialized = true + ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true + + const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start') + audio.setMetronomeEnabled(true) + + audio.preparePlayback(project, -2) + audio.startPlayback() + + expect(MockTransport.position).toBe(0) + expect(MockTransport.start).not.toHaveBeenCalled() + expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2) + expect(audio.getTransportPosition()).toBeCloseTo(-2, 2) + + vi.advanceTimersByTime(500) + expect(audio.getTransportPosition()).toBeCloseTo(-1, 1) + + vi.advanceTimersByTime(500) + expect(MockTransport.start).toHaveBeenCalledTimes(1) + expect(audio.getTransportPosition()).toBe(0) + }) + + it('cancels the delayed transport start when playback stops during preroll', () => { + const project = KGCore.instance().getCurrentProject() + const audio = KGAudioInterface.instance() + ;(audio as unknown as { isInitialized: boolean }).isInitialized = true + ;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true + + audio.preparePlayback(project, -2) + audio.startPlayback() + audio.stopPlayback() + vi.runAllTimers() + + expect(MockTransport.start).not.toHaveBeenCalled() + expect(MockTransport.stop).toHaveBeenCalledTimes(1) + expect(audio.getTransportPosition()).toBe(0) + }) +}) diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index 1c8f600..ae97c2d 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -42,6 +42,10 @@ export class KGAudioInterface { private isPlaying: boolean = false; 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 virtualPrerollStartBeat: number | null = null; + private virtualPrerollStartTimeMs: number | null = null; // Master volume control private masterGain: Tone.Gain | null = null; @@ -390,6 +394,7 @@ export class KGAudioInterface { public preparePlayback(project: KGProject, startPosition: number): void { // Clear any existing scheduled events this.clearScheduledEvents(); + this.clearDelayedTransportStart(); console.log("Preparing playback"); @@ -440,8 +445,18 @@ export class KGAudioInterface { console.log("Loop mode disabled"); } + if (startPosition < 0) { + this.delayedTransportStartMs = Math.abs(startPosition) * secondsPerBeat * 1000; + this.virtualPrerollStartBeat = startPosition; + this.virtualPrerollStartTimeMs = null; + } else { + this.delayedTransportStartMs = 0; + this.virtualPrerollStartBeat = null; + this.virtualPrerollStartTimeMs = null; + } + // Set transport position (convert beats to Tone.js format) - this.setTransportPosition(startPosition); + this.setTransportPosition(Math.max(0, startPosition)); // Start metronome if enabled if (this.isMetronomeEnabled) { @@ -646,8 +661,19 @@ export class KGAudioInterface { if (!this.isAudioContextStarted) { throw new Error('Audio context not started'); } - - Tone.Transport.start(); + + if (this.delayedTransportStartMs > 0 && this.virtualPrerollStartBeat !== null) { + this.virtualPrerollStartTimeMs = performance.now(); + this.delayedTransportStartTimeoutId = window.setTimeout(() => { + this.delayedTransportStartTimeoutId = null; + this.virtualPrerollStartBeat = null; + this.virtualPrerollStartTimeMs = null; + Tone.Transport.start(); + }, this.delayedTransportStartMs); + } else { + Tone.Transport.start(); + } + this.isPlaying = true; console.log('Audio playback started'); @@ -662,6 +688,7 @@ export class KGAudioInterface { */ public stopPlayback(): void { try { + this.clearDelayedTransportStart(); Tone.Transport.stop(); this.metronome.stop(); @@ -789,7 +816,8 @@ export class KGAudioInterface { public setTransportPosition(position: number): void { try { // Convert beats to Tone.js time format - const toneTime = this.beatsToToneTime(position); + const safePosition = Math.max(0, position); + const toneTime = this.beatsToToneTime(safePosition); Tone.Transport.position = toneTime; console.log(`Set transport position to ${position} beats (${toneTime})`); } catch (error) { @@ -802,6 +830,14 @@ export class KGAudioInterface { */ public getTransportPosition(): number { try { + if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartTimeMs !== null) { + const project = KGCore.instance().getCurrentProject(); + const secondsPerBeat = 60 / project.getBpm(); + const elapsedSeconds = (performance.now() - this.virtualPrerollStartTimeMs) / 1000; + const elapsedBeats = elapsedSeconds / secondsPerBeat; + return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats); + } + const position = Tone.Transport.position; return this.toneTimeToBeats(position); } catch (error) { @@ -973,6 +1009,17 @@ export class KGAudioInterface { return this.captureStream; } + private clearDelayedTransportStart(): void { + if (this.delayedTransportStartTimeoutId !== null) { + window.clearTimeout(this.delayedTransportStartTimeoutId); + this.delayedTransportStartTimeoutId = null; + } + + this.delayedTransportStartMs = 0; + this.virtualPrerollStartBeat = null; + this.virtualPrerollStartTimeMs = null; + } + // ===== PRIVATE UTILITY METHODS ===== /** diff --git a/src/core/audio-interface/KGMetronome.test.ts b/src/core/audio-interface/KGMetronome.test.ts new file mode 100644 index 0000000..4369c9e --- /dev/null +++ b/src/core/audio-interface/KGMetronome.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MockLoop, MockTransport, ToneMock } from '../../test/mocks/tone' + +vi.mock('tone', async () => { + const { ToneMock: toneMock } = await import('../../test/mocks/tone') + return toneMock +}) + +import { KGMetronome } from './KGMetronome' + +describe('KGMetronome', () => { + beforeEach(() => { + vi.useFakeTimers() + 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', () => { + const metronome = new KGMetronome() + const triggerAttackRelease = vi.fn() + ;(metronome as unknown as { sampler: unknown }).sampler = { + loaded: true, + triggerAttackRelease, + } + + metronome.start(-4, 4, 0.2) + + vi.advanceTimersByTime(200) + expect(triggerAttackRelease).toHaveBeenNthCalledWith(1, 'C5', '16n', 42) + + vi.advanceTimersByTime(1000) + expect(triggerAttackRelease).toHaveBeenNthCalledWith(2, 'C4', '16n', 42) + + const transportLoop = MockLoop.mock.results[0]?.value + expect(transportLoop).toBeDefined() + + transportLoop.callback(0) + expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2) + }) + + it('cancels pending preroll clicks when stopped', () => { + const metronome = new KGMetronome() + const triggerAttackRelease = vi.fn() + ;(metronome as unknown as { sampler: unknown }).sampler = { + loaded: true, + triggerAttackRelease, + } + + metronome.start(-2, 4, 0.2) + metronome.stop() + vi.runAllTimers() + + expect(triggerAttackRelease).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/audio-interface/KGMetronome.ts b/src/core/audio-interface/KGMetronome.ts index f8025af..2bce458 100644 --- a/src/core/audio-interface/KGMetronome.ts +++ b/src/core/audio-interface/KGMetronome.ts @@ -8,6 +8,7 @@ import { KGToneSamplerFactory } from './KGToneSamplerFactory'; export class KGMetronome { private loop: Tone.Loop | null = null; private sampler: Tone.Sampler | null = null; + private prerollTimeoutIds: number[] = []; /** * Load the woodblock sampler. Called once from KGAudioInterface.initialize() — @@ -34,6 +35,8 @@ export class KGMetronome { const ppq = Tone.Transport.PPQ; + this.schedulePrerollClicks(startPositionBeats, beatsPerBar, playbackDelay); + this.loop = new Tone.Loop((time) => { if (this.sampler?.loaded) { // Derive bar position from the exact Transport tick count at the @@ -52,6 +55,9 @@ export class KGMetronome { /** Stop and dispose the loop only — sampler is kept alive for reuse. */ stop(): void { + this.prerollTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); + this.prerollTimeoutIds = []; + if (this.loop) { this.loop.dispose(); this.loop = null; @@ -65,4 +71,24 @@ export class KGMetronome { this.sampler = null; } } + + private schedulePrerollClicks(startPositionBeats: number, beatsPerBar: number, playbackDelay: number): void { + if (startPositionBeats >= 0 || !this.sampler?.loaded) { + return; + } + + const secondsPerBeat = 60 / Tone.Transport.bpm.value; + const firstBeat = Math.ceil(startPositionBeats); + + for (let beat = firstBeat; beat < 0; beat += 1) { + const waitMs = Math.max(0, ((beat - startPositionBeats) * secondsPerBeat + playbackDelay) * 1000); + const note = beat % beatsPerBar === 0 ? 'C5' : 'C4'; + const timeoutId = window.setTimeout(() => { + if (this.sampler?.loaded) { + this.sampler.triggerAttackRelease(note, '16n', Tone.now()); + } + }, waitMs); + this.prerollTimeoutIds.push(timeoutId); + } + } } diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts index b0f3fb4..38bca09 100644 --- a/src/test/mocks/tone.ts +++ b/src/test/mocks/tone.ts @@ -25,7 +25,7 @@ export const MockTransport = { start: vi.fn(), stop: vi.fn(), pause: vi.fn(), - position: '0:0:0', + position: 0, bpm: { value: 120, rampTo: vi.fn() @@ -34,10 +34,23 @@ export const MockTransport = { state: 'stopped', scheduleOnce: vi.fn(), scheduleRepeat: vi.fn(), + schedule: vi.fn().mockReturnValue(1), cancel: vi.fn(), clear: vi.fn() + , + setLoopPoints: vi.fn(), + loop: false, + PPQ: 192, + getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192) } +export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({ + callback, + interval, + start: vi.fn(), + dispose: vi.fn() +})) + // Mock Destination export const MockDestination = { volume: { @@ -81,6 +94,7 @@ export const MockMeter = vi.fn().mockImplementation(() => ({ // Complete Tone.js mock export const ToneMock = { Sampler: MockSampler, + Loop: MockLoop, Transport: MockTransport, Destination: MockDestination, ToneAudioBuffer: MockToneAudioBuffer, @@ -106,10 +120,11 @@ export const ToneMock = { Frequency: vi.fn().mockImplementation((freq) => ({ toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440), valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440) - })) + })), + now: vi.fn().mockReturnValue(0) } // Setup the global mock export const setupToneMocks = () => { vi.doMock('tone', () => ToneMock) -} \ No newline at end of file +}