fix: wwitched the preroll metronome scheduling off window.setTimeout and onto Tone’s audio-context clock
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user