fix: pre-roll metronome ticks are missing
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,6 +42,10 @@ export class KGAudioInterface {
|
|||||||
private isPlaying: boolean = false;
|
private isPlaying: boolean = false;
|
||||||
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 delayedTransportStartMs: number = 0;
|
||||||
|
private virtualPrerollStartBeat: number | null = null;
|
||||||
|
private virtualPrerollStartTimeMs: number | null = null;
|
||||||
|
|
||||||
// Master volume control
|
// Master volume control
|
||||||
private masterGain: Tone.Gain | null = null;
|
private masterGain: Tone.Gain | null = null;
|
||||||
@@ -390,6 +394,7 @@ export class KGAudioInterface {
|
|||||||
public preparePlayback(project: KGProject, startPosition: number): void {
|
public preparePlayback(project: KGProject, startPosition: number): void {
|
||||||
// Clear any existing scheduled events
|
// Clear any existing scheduled events
|
||||||
this.clearScheduledEvents();
|
this.clearScheduledEvents();
|
||||||
|
this.clearDelayedTransportStart();
|
||||||
|
|
||||||
console.log("Preparing playback");
|
console.log("Preparing playback");
|
||||||
|
|
||||||
@@ -440,8 +445,18 @@ export class KGAudioInterface {
|
|||||||
console.log("Loop mode disabled");
|
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)
|
// Set transport position (convert beats to Tone.js format)
|
||||||
this.setTransportPosition(startPosition);
|
this.setTransportPosition(Math.max(0, startPosition));
|
||||||
|
|
||||||
// Start metronome if enabled
|
// Start metronome if enabled
|
||||||
if (this.isMetronomeEnabled) {
|
if (this.isMetronomeEnabled) {
|
||||||
@@ -646,8 +661,19 @@ export class KGAudioInterface {
|
|||||||
if (!this.isAudioContextStarted) {
|
if (!this.isAudioContextStarted) {
|
||||||
throw new Error('Audio context not started');
|
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;
|
this.isPlaying = true;
|
||||||
|
|
||||||
console.log('Audio playback started');
|
console.log('Audio playback started');
|
||||||
@@ -662,6 +688,7 @@ export class KGAudioInterface {
|
|||||||
*/
|
*/
|
||||||
public stopPlayback(): void {
|
public stopPlayback(): void {
|
||||||
try {
|
try {
|
||||||
|
this.clearDelayedTransportStart();
|
||||||
Tone.Transport.stop();
|
Tone.Transport.stop();
|
||||||
this.metronome.stop();
|
this.metronome.stop();
|
||||||
|
|
||||||
@@ -789,7 +816,8 @@ export class KGAudioInterface {
|
|||||||
public setTransportPosition(position: number): void {
|
public setTransportPosition(position: number): void {
|
||||||
try {
|
try {
|
||||||
// Convert beats to Tone.js time format
|
// 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;
|
Tone.Transport.position = toneTime;
|
||||||
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -802,6 +830,14 @@ export class KGAudioInterface {
|
|||||||
*/
|
*/
|
||||||
public getTransportPosition(): number {
|
public getTransportPosition(): number {
|
||||||
try {
|
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;
|
const position = Tone.Transport.position;
|
||||||
return this.toneTimeToBeats(position);
|
return this.toneTimeToBeats(position);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -973,6 +1009,17 @@ export class KGAudioInterface {
|
|||||||
return this.captureStream;
|
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 =====
|
// ===== PRIVATE UTILITY METHODS =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
|||||||
export class KGMetronome {
|
export class KGMetronome {
|
||||||
private loop: Tone.Loop | null = null;
|
private loop: Tone.Loop | null = null;
|
||||||
private sampler: Tone.Sampler | null = null;
|
private sampler: Tone.Sampler | null = null;
|
||||||
|
private prerollTimeoutIds: number[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
|
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
|
||||||
@@ -34,6 +35,8 @@ export class KGMetronome {
|
|||||||
|
|
||||||
const ppq = Tone.Transport.PPQ;
|
const ppq = Tone.Transport.PPQ;
|
||||||
|
|
||||||
|
this.schedulePrerollClicks(startPositionBeats, beatsPerBar, playbackDelay);
|
||||||
|
|
||||||
this.loop = new Tone.Loop((time) => {
|
this.loop = new Tone.Loop((time) => {
|
||||||
if (this.sampler?.loaded) {
|
if (this.sampler?.loaded) {
|
||||||
// Derive bar position from the exact Transport tick count at the
|
// 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 and dispose the loop only — sampler is kept alive for reuse. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.prerollTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId));
|
||||||
|
this.prerollTimeoutIds = [];
|
||||||
|
|
||||||
if (this.loop) {
|
if (this.loop) {
|
||||||
this.loop.dispose();
|
this.loop.dispose();
|
||||||
this.loop = null;
|
this.loop = null;
|
||||||
@@ -65,4 +71,24 @@ export class KGMetronome {
|
|||||||
this.sampler = null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -25,7 +25,7 @@ export const MockTransport = {
|
|||||||
start: vi.fn(),
|
start: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
position: '0:0:0',
|
position: 0,
|
||||||
bpm: {
|
bpm: {
|
||||||
value: 120,
|
value: 120,
|
||||||
rampTo: vi.fn()
|
rampTo: vi.fn()
|
||||||
@@ -34,10 +34,23 @@ export const MockTransport = {
|
|||||||
state: 'stopped',
|
state: 'stopped',
|
||||||
scheduleOnce: vi.fn(),
|
scheduleOnce: vi.fn(),
|
||||||
scheduleRepeat: vi.fn(),
|
scheduleRepeat: vi.fn(),
|
||||||
|
schedule: vi.fn().mockReturnValue(1),
|
||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
clear: 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
|
// Mock Destination
|
||||||
export const MockDestination = {
|
export const MockDestination = {
|
||||||
volume: {
|
volume: {
|
||||||
@@ -81,6 +94,7 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
|
|||||||
// Complete Tone.js mock
|
// Complete Tone.js mock
|
||||||
export const ToneMock = {
|
export const ToneMock = {
|
||||||
Sampler: MockSampler,
|
Sampler: MockSampler,
|
||||||
|
Loop: MockLoop,
|
||||||
Transport: MockTransport,
|
Transport: MockTransport,
|
||||||
Destination: MockDestination,
|
Destination: MockDestination,
|
||||||
ToneAudioBuffer: MockToneAudioBuffer,
|
ToneAudioBuffer: MockToneAudioBuffer,
|
||||||
@@ -106,10 +120,11 @@ export const ToneMock = {
|
|||||||
Frequency: vi.fn().mockImplementation((freq) => ({
|
Frequency: vi.fn().mockImplementation((freq) => ({
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup the global mock
|
// Setup the global mock
|
||||||
export const setupToneMocks = () => {
|
export const setupToneMocks = () => {
|
||||||
vi.doMock('tone', () => ToneMock)
|
vi.doMock('tone', () => ToneMock)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user