fix: changing BPM in the middle of the song causes empty spaces between notes during playback/bounce
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data';
|
import { createMockMidiNote, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data';
|
||||||
import { MockTransport } from '../../test/mocks/tone';
|
import { MockTransport } from '../../test/mocks/tone';
|
||||||
|
import { GlobalTrackType } from '../global-track';
|
||||||
|
import { KGTempoRegion } from '../region/KGTempoRegion';
|
||||||
|
|
||||||
vi.mock('tone', async () => {
|
vi.mock('tone', async () => {
|
||||||
const { ToneMock } = await import('../../test/mocks/tone');
|
const { ToneMock } = await import('../../test/mocks/tone');
|
||||||
@@ -41,6 +43,22 @@ function createMockAudioBus() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTempoRegions(project: ReturnType<typeof createMockProject>, regions: KGTempoRegion[]): void {
|
||||||
|
const tempoTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing in test setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoTrack.setRegions(regions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportTimeToBeats(time: unknown): number {
|
||||||
|
if (typeof time === 'string' && time.endsWith('i')) {
|
||||||
|
return Number.parseFloat(time.slice(0, -1)) / MockTransport.PPQ;
|
||||||
|
}
|
||||||
|
return Number(time);
|
||||||
|
}
|
||||||
|
|
||||||
describe('KGAudioInterface preroll playback', () => {
|
describe('KGAudioInterface preroll playback', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -53,6 +71,7 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
MockTransport.clear.mockClear();
|
MockTransport.clear.mockClear();
|
||||||
MockTransport.schedule.mockClear();
|
MockTransport.schedule.mockClear();
|
||||||
MockTransport.bpm.value = 120;
|
MockTransport.bpm.value = 120;
|
||||||
|
;(MockTransport as typeof MockTransport & { seconds?: number }).seconds = 0;
|
||||||
|
|
||||||
const project = createMockProject({
|
const project = createMockProject({
|
||||||
bpm: 120,
|
bpm: 120,
|
||||||
@@ -129,7 +148,7 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
const audio = KGAudioInterface.instance();
|
const audio = KGAudioInterface.instance();
|
||||||
audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true });
|
audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true });
|
||||||
|
|
||||||
expect(MockTransport.position).toBe(6);
|
expect(MockTransport.position).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('schedules baked pitch bend points instead of authored step changes only', () => {
|
it('schedules baked pitch bend points instead of authored step changes only', () => {
|
||||||
@@ -147,9 +166,9 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 0);
|
audio.preparePlayback(project, 0);
|
||||||
|
|
||||||
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time);
|
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time));
|
||||||
expect(scheduledTimes).toContain(0.25);
|
|
||||||
expect(scheduledTimes).toContain(0.5);
|
expect(scheduledTimes).toContain(0.5);
|
||||||
|
expect(scheduledTimes).toContain(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips redundant scheduled pitch bend events when interpolated values round to the same MIDI value', () => {
|
it('skips redundant scheduled pitch bend events when interpolated values round to the same MIDI value', () => {
|
||||||
@@ -167,8 +186,8 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 0);
|
audio.preparePlayback(project, 0);
|
||||||
|
|
||||||
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time);
|
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time));
|
||||||
expect(scheduledTimes).toEqual([0.25]);
|
expect(scheduledTimes).toEqual([0.5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes the initial interpolated bend for non-zero playback starts', () => {
|
it('computes the initial interpolated bend for non-zero playback starts', () => {
|
||||||
@@ -207,8 +226,74 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 5);
|
audio.preparePlayback(project, 5);
|
||||||
|
|
||||||
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time);
|
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time));
|
||||||
expect(scheduledTimes).toContain(2);
|
expect(scheduledTimes).toContain(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules post-tempo-change notes using playback-local transport time', () => {
|
||||||
|
const notes = [
|
||||||
|
createMockMidiNote({ id: 'note-1', startBeat: 4, endBeat: 5 }),
|
||||||
|
createMockMidiNote({ id: 'note-2', startBeat: 5, endBeat: 6 }),
|
||||||
|
];
|
||||||
|
const region = createMockMidiRegion({ notes, length: 8 });
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [track] });
|
||||||
|
setTempoRegions(project, [
|
||||||
|
new KGTempoRegion('tempo-a', 'tempo-track', 0, 120, 0, 1, 4),
|
||||||
|
new KGTempoRegion('tempo-b', 'tempo-track', 0, 60, 1, 31, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const audio = KGAudioInterface.instance();
|
||||||
|
const audioBus = createMockAudioBus();
|
||||||
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
|
|
||||||
|
audio.preparePlayback(project, 0);
|
||||||
|
|
||||||
|
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time));
|
||||||
|
expect(scheduledTimes.filter(time => time === 4)).toHaveLength(2);
|
||||||
|
expect(scheduledTimes).toContain(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps post-tempo-change note spacing correct for nonzero playback starts', () => {
|
||||||
|
const notes = [
|
||||||
|
createMockMidiNote({ id: 'note-1', startBeat: 4, endBeat: 5 }),
|
||||||
|
createMockMidiNote({ id: 'note-2', startBeat: 5, endBeat: 6 }),
|
||||||
|
];
|
||||||
|
const region = createMockMidiRegion({ notes, length: 8 });
|
||||||
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [track] });
|
||||||
|
setTempoRegions(project, [
|
||||||
|
new KGTempoRegion('tempo-a', 'tempo-track', 0, 120, 0, 1, 4),
|
||||||
|
new KGTempoRegion('tempo-b', 'tempo-track', 0, 60, 1, 31, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const audio = KGAudioInterface.instance();
|
||||||
|
const audioBus = createMockAudioBus();
|
||||||
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
|
|
||||||
|
audio.preparePlayback(project, 2);
|
||||||
|
|
||||||
|
const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time));
|
||||||
|
expect(scheduledTimes.filter(time => time === 2)).toHaveLength(2);
|
||||||
|
expect(scheduledTimes).toContain(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps transport seconds back to project beats across a tempo change', () => {
|
||||||
|
const project = createMockProject({ bpm: 120, tracks: [] });
|
||||||
|
setTempoRegions(project, [
|
||||||
|
new KGTempoRegion('tempo-a', 'tempo-track', 0, 120, 0, 1, 4),
|
||||||
|
new KGTempoRegion('tempo-b', 'tempo-track', 0, 60, 1, 31, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(KGCore.instance).mockReturnValue({
|
||||||
|
getCurrentProject: () => project,
|
||||||
|
} as unknown as KGCore);
|
||||||
|
|
||||||
|
const audio = KGAudioInterface.instance();
|
||||||
|
audio.preparePlayback(project, 2);
|
||||||
|
;(MockTransport as typeof MockTransport & { seconds?: number }).seconds = 2;
|
||||||
|
|
||||||
|
expect(audio.getTransportPosition()).toBeCloseTo(5, 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export class KGAudioInterface {
|
|||||||
private delayedTransportStartSeconds: number = 0;
|
private delayedTransportStartSeconds: number = 0;
|
||||||
private virtualPrerollStartBeat: number | null = null;
|
private virtualPrerollStartBeat: number | null = null;
|
||||||
private virtualPrerollStartAudioTime: number | null = null;
|
private virtualPrerollStartAudioTime: number | null = null;
|
||||||
|
private playbackOriginBeat: number = 0;
|
||||||
|
private playbackOriginSeconds: number = 0;
|
||||||
|
|
||||||
// Master volume control
|
// Master volume control
|
||||||
private masterGain: Tone.Gain | null = null;
|
private masterGain: Tone.Gain | null = null;
|
||||||
@@ -471,21 +473,24 @@ export class KGAudioInterface {
|
|||||||
scheduleStartBeat = startBar * beatsPerBar;
|
scheduleStartBeat = startBar * beatsPerBar;
|
||||||
scheduleEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
scheduleEndBeat = (endBar + 1) * beatsPerBar; // +1 because endBar is inclusive
|
||||||
|
|
||||||
// Configure Tone.Transport loop boundaries
|
|
||||||
const loopStartTime = this.beatsToToneTime(scheduleStartBeat);
|
|
||||||
const loopEndTime = this.beatsToToneTime(scheduleEndBeat);
|
|
||||||
Tone.Transport.setLoopPoints(loopStartTime, loopEndTime);
|
|
||||||
Tone.Transport.loop = true;
|
|
||||||
|
|
||||||
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
|
|
||||||
|
|
||||||
// Adjust start position to loop start if before loop range
|
// Adjust start position to loop start if before loop range
|
||||||
if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
|
if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
|
||||||
startPosition = scheduleStartBeat;
|
startPosition = scheduleStartBeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setPlaybackOrigin(project, startPosition >= scheduleStartBeat ? scheduleStartBeat : startPosition);
|
||||||
|
|
||||||
|
// Configure Tone.Transport loop boundaries
|
||||||
|
const loopStartTime = this.projectBeatToTransportTime(scheduleStartBeat);
|
||||||
|
const loopEndTime = this.projectBeatToTransportTime(scheduleEndBeat);
|
||||||
|
Tone.Transport.setLoopPoints(loopStartTime, loopEndTime);
|
||||||
|
Tone.Transport.loop = true;
|
||||||
|
|
||||||
|
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
|
||||||
} else {
|
} else {
|
||||||
Tone.Transport.loop = false;
|
Tone.Transport.loop = false;
|
||||||
console.log("Loop mode disabled");
|
console.log("Loop mode disabled");
|
||||||
|
this.setPlaybackOrigin(project, startPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleTempoChanges(project, Math.max(startPosition, scheduleStartBeat), scheduleEndBeat);
|
this.scheduleTempoChanges(project, Math.max(startPosition, scheduleStartBeat), scheduleEndBeat);
|
||||||
@@ -641,7 +646,7 @@ export class KGAudioInterface {
|
|||||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
audioBus.scheduleLiveMidiPitchBend(midiPitchBendToNormalized(value), time);
|
audioBus.scheduleLiveMidiPitchBend(midiPitchBendToNormalized(value), time);
|
||||||
}
|
}
|
||||||
}, this.beatsToToneTime(beat));
|
}, this.projectBeatToTransportTime(beat));
|
||||||
|
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
@@ -669,7 +674,7 @@ export class KGAudioInterface {
|
|||||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
audioBus.scheduleLiveMidiExpression(value / 127, time);
|
audioBus.scheduleLiveMidiExpression(value / 127, time);
|
||||||
}
|
}
|
||||||
}, this.beatsToToneTime(beat));
|
}, this.projectBeatToTransportTime(beat));
|
||||||
|
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
@@ -697,13 +702,13 @@ export class KGAudioInterface {
|
|||||||
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
|
||||||
audioBus.setLiveMidiSustain(value >= 64, time);
|
audioBus.setLiveMidiSustain(value >= 64, time);
|
||||||
}
|
}
|
||||||
}, this.beatsToToneTime(beat));
|
}, this.projectBeatToTransportTime(beat));
|
||||||
|
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
|
|
||||||
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
|
||||||
const noteStartTime = this.beatsToToneTime(absoluteStartBeat);
|
const noteStartTime = this.projectBeatToTransportTime(absoluteStartBeat);
|
||||||
const noteDurationSeconds = beatRangeToSeconds(project, absoluteStartBeat, absoluteEndBeat);
|
const noteDurationSeconds = beatRangeToSeconds(project, absoluteStartBeat, absoluteEndBeat);
|
||||||
const velocity = note.getVelocity() / 127;
|
const velocity = note.getVelocity() / 127;
|
||||||
const noteName = pitchToNoteNameString(note.getPitch());
|
const noteName = pitchToNoteNameString(note.getPitch());
|
||||||
@@ -781,7 +786,7 @@ export class KGAudioInterface {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const regionStartTime = this.beatsToToneTime(safeResumeBeat);
|
const regionStartTime = this.projectBeatToTransportTime(safeResumeBeat);
|
||||||
|
|
||||||
const eventId = Tone.Transport.schedule((time) => {
|
const eventId = Tone.Transport.schedule((time) => {
|
||||||
const hasSoloedTracks = this.hasSoloedTracks();
|
const hasSoloedTracks = this.hasSoloedTracks();
|
||||||
@@ -819,7 +824,7 @@ export class KGAudioInterface {
|
|||||||
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
|
effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const regionStartTime = this.beatsToToneTime(regionStartBeat);
|
const regionStartTime = this.projectBeatToTransportTime(regionStartBeat);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, clipOffset: ${clipStartOffsetSeconds}s, duration: ${effectiveDurationSeconds}s`
|
`Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, clipOffset: ${clipStartOffsetSeconds}s, duration: ${effectiveDurationSeconds}s`
|
||||||
@@ -885,6 +890,7 @@ export class KGAudioInterface {
|
|||||||
try {
|
try {
|
||||||
this.clearDelayedTransportStart();
|
this.clearDelayedTransportStart();
|
||||||
Tone.Transport.stop();
|
Tone.Transport.stop();
|
||||||
|
this.resetPlaybackOrigin();
|
||||||
this.metronome.stop();
|
this.metronome.stop();
|
||||||
|
|
||||||
// Release all currently playing notes
|
// Release all currently playing notes
|
||||||
@@ -1156,7 +1162,7 @@ export class KGAudioInterface {
|
|||||||
try {
|
try {
|
||||||
// Convert beats to Tone.js time format
|
// Convert beats to Tone.js time format
|
||||||
const safePosition = Math.max(0, position);
|
const safePosition = Math.max(0, position);
|
||||||
const toneTime = this.beatsToToneTime(safePosition);
|
const toneTime = this.projectBeatToTransportTime(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) {
|
||||||
@@ -1179,10 +1185,13 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
const transportSeconds = Number(Tone.Transport.seconds);
|
const transportSeconds = Number(Tone.Transport.seconds);
|
||||||
if (Number.isFinite(transportSeconds)) {
|
if (Number.isFinite(transportSeconds)) {
|
||||||
return secondsToBeat(KGCore.instance().getCurrentProject(), transportSeconds);
|
return secondsToBeat(
|
||||||
|
KGCore.instance().getCurrentProject(),
|
||||||
|
this.playbackOriginSeconds + Math.max(0, transportSeconds)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toneTimeToBeats(Tone.Transport.position);
|
return this.transportTimeToProjectBeat(KGCore.instance().getCurrentProject(), Tone.Transport.position);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting transport position:', error);
|
console.error('Error getting transport position:', error);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1455,7 +1464,7 @@ export class KGAudioInterface {
|
|||||||
playerBus.setAutomationVolume(value);
|
playerBus.setAutomationVolume(value);
|
||||||
}
|
}
|
||||||
this.updateAllEffectiveVolumes();
|
this.updateAllEffectiveVolumes();
|
||||||
}, this.beatsToToneTime(beat));
|
}, this.projectBeatToTransportTime(beat));
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1472,7 +1481,7 @@ export class KGAudioInterface {
|
|||||||
if (playerBus) {
|
if (playerBus) {
|
||||||
playerBus.scheduleAutomationPan(value, time);
|
playerBus.scheduleAutomationPan(value, time);
|
||||||
}
|
}
|
||||||
}, this.beatsToToneTime(beat));
|
}, this.projectBeatToTransportTime(beat));
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1496,7 +1505,7 @@ export class KGAudioInterface {
|
|||||||
|
|
||||||
const eventId = Tone.Transport.schedule(() => {
|
const eventId = Tone.Transport.schedule(() => {
|
||||||
Tone.Transport.bpm.value = region.getBpm();
|
Tone.Transport.bpm.value = region.getBpm();
|
||||||
}, this.beatsToToneTime(regionStartBeat));
|
}, this.projectBeatToTransportTime(regionStartBeat));
|
||||||
this.scheduledEvents.add(eventId);
|
this.scheduledEvents.add(eventId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1559,6 +1568,33 @@ export class KGAudioInterface {
|
|||||||
return beatToSeconds(KGCore.instance().getCurrentProject(), beats) as Tone.Unit.Time;
|
return beatToSeconds(KGCore.instance().getCurrentProject(), beats) as Tone.Unit.Time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setPlaybackOrigin(project: KGProject, beat: number): void {
|
||||||
|
this.playbackOriginBeat = Math.max(0, beat);
|
||||||
|
this.playbackOriginSeconds = beatToSeconds(project, this.playbackOriginBeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPlaybackOrigin(): void {
|
||||||
|
this.playbackOriginBeat = 0;
|
||||||
|
this.playbackOriginSeconds = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectBeatToTransportTime(beat: number): Tone.Unit.Time {
|
||||||
|
const clampedBeat = Math.max(0, beat);
|
||||||
|
const transportBeats = Math.max(0, clampedBeat - this.playbackOriginBeat);
|
||||||
|
const transportTicks = Math.round(transportBeats * Tone.Transport.PPQ);
|
||||||
|
return transportTicks === 0 ? 0 : `${transportTicks}i` as Tone.Unit.Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
private transportTimeToProjectBeat(project: KGProject, toneTime: Tone.Unit.Time): number {
|
||||||
|
if (typeof toneTime === 'string' && toneTime.endsWith('i')) {
|
||||||
|
const ticks = Number.parseFloat(toneTime.slice(0, -1));
|
||||||
|
return this.playbackOriginBeat + (Number.isFinite(ticks) ? ticks / Tone.Transport.PPQ : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericTime = typeof toneTime === 'number' ? toneTime : Tone.Time(toneTime).toSeconds();
|
||||||
|
return this.playbackOriginBeat + Math.max(0, numericTime);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Tone.js time format to beats
|
* Convert Tone.js time format to beats
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data';
|
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data';
|
||||||
import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil';
|
import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil';
|
||||||
|
import { GlobalTrackType } from '../global-track';
|
||||||
|
import { KGTempoRegion } from '../region/KGTempoRegion';
|
||||||
|
|
||||||
const { offlineMock, configGetMock } = vi.hoisted(() => ({
|
const { offlineMock, configGetMock } = vi.hoisted(() => ({
|
||||||
offlineMock: vi.fn(),
|
offlineMock: vi.fn(),
|
||||||
@@ -32,6 +34,15 @@ vi.mock('../config/ConfigManager', () => ({
|
|||||||
|
|
||||||
import { KGOfflineRenderer, applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
import { KGOfflineRenderer, applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer';
|
||||||
|
|
||||||
|
function setTempoRegions(project: ReturnType<typeof createMockProject>, regions: KGTempoRegion[]): void {
|
||||||
|
const tempoTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Tempo);
|
||||||
|
if (!tempoTrack) {
|
||||||
|
throw new Error('Tempo track missing in test setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
tempoTrack.setRegions(regions);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a minimal AudioBuffer-like object for testing.
|
* Create a minimal AudioBuffer-like object for testing.
|
||||||
* In the jsdom test environment, AudioBuffer is not available,
|
* In the jsdom test environment, AudioBuffer is not available,
|
||||||
@@ -215,8 +226,8 @@ describe('offline pitch bend automation', () => {
|
|||||||
source,
|
source,
|
||||||
1,
|
1,
|
||||||
[{ beat: 1, value: 0 }],
|
[{ beat: 1, value: 0 }],
|
||||||
|
createMockProject({ bpm: 120 }),
|
||||||
0,
|
0,
|
||||||
0.5
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
@@ -252,11 +263,43 @@ describe('offline pitch bend automation', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
applyOfflinePitchBendAutomation(source, 1, baked.filter(point => point.beat > 0), 0, 0.5);
|
applyOfflinePitchBendAutomation(source, 1, baked.filter(point => point.beat > 0), createMockProject({ bpm: 120 }), 0);
|
||||||
|
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(calls[0][1]).toBe(0.26);
|
expect(calls[0][1]).toBe(0.26);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses tempo-aware automation timing after a BPM change', () => {
|
||||||
|
const source = {
|
||||||
|
playbackRate: {
|
||||||
|
value: 1,
|
||||||
|
setValueAtTime: (..._args: unknown[]) => undefined,
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof applyOfflinePitchBendAutomation>[0];
|
||||||
|
|
||||||
|
const calls: Array<[number, number]> = [];
|
||||||
|
source.playbackRate.setValueAtTime = ((value: number, time: number) => {
|
||||||
|
calls.push([value, time]);
|
||||||
|
return source.playbackRate as never;
|
||||||
|
}) as typeof source.playbackRate.setValueAtTime;
|
||||||
|
|
||||||
|
const project = createMockProject({ bpm: 120 });
|
||||||
|
setTempoRegions(project, [
|
||||||
|
new KGTempoRegion('tempo-a', 'tempo-track', 0, 120, 0, 1, 4),
|
||||||
|
new KGTempoRegion('tempo-b', 'tempo-track', 0, 60, 1, 31, 4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
applyOfflinePitchBendAutomation(
|
||||||
|
source,
|
||||||
|
1,
|
||||||
|
[{ beat: 5, value: 0 }],
|
||||||
|
project,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0][1]).toBe(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderToBuffer bounce range', () => {
|
describe('renderToBuffer bounce range', () => {
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
// Calculate render duration in seconds
|
// Calculate render duration in seconds
|
||||||
const bpm = getEffectiveBpmAtBeat(project, 0);
|
const bpm = getEffectiveBpmAtBeat(project, 0);
|
||||||
const secondsPerBeat = 60 / bpm;
|
|
||||||
const timeSignature = project.getTimeSignature();
|
const timeSignature = project.getTimeSignature();
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
|
||||||
@@ -304,7 +303,7 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
context.transport.schedule((time) => {
|
context.transport.schedule((time) => {
|
||||||
context.transport.bpm.setValueAtTime(region.getBpm(), time);
|
context.transport.bpm.setValueAtTime(region.getBpm(), time);
|
||||||
}, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
|
}, beatsToOfflineTransportTime(regionStartBeat, renderStartBeat));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Create MIDI track samplers ----
|
// ---- Create MIDI track samplers ----
|
||||||
@@ -343,9 +342,9 @@ export class KGOfflineRenderer {
|
|||||||
trackInfo.volumeAutomation,
|
trackInfo.volumeAutomation,
|
||||||
trackInfo.panAutomation,
|
trackInfo.panAutomation,
|
||||||
trackInfo.volume,
|
trackInfo.volume,
|
||||||
|
project,
|
||||||
renderStartBeat,
|
renderStartBeat,
|
||||||
renderEndBeat,
|
renderEndBeat,
|
||||||
secondsPerBeat,
|
|
||||||
interpolationIntervalMs,
|
interpolationIntervalMs,
|
||||||
bpm
|
bpm
|
||||||
);
|
);
|
||||||
@@ -381,7 +380,6 @@ export class KGOfflineRenderer {
|
|||||||
// Skip notes outside render range
|
// Skip notes outside render range
|
||||||
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
|
if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue;
|
||||||
|
|
||||||
const noteStartTime = beatToSeconds(project, note.startBeat) - beatToSeconds(project, renderStartBeat);
|
|
||||||
const sustainedEndBeat = resolveSustainExtendedEndBeat(
|
const sustainedEndBeat = resolveSustainExtendedEndBeat(
|
||||||
trackInfo.controllerEventsByType[64],
|
trackInfo.controllerEventsByType[64],
|
||||||
note.endBeat,
|
note.endBeat,
|
||||||
@@ -412,19 +410,19 @@ export class KGOfflineRenderer {
|
|||||||
source,
|
source,
|
||||||
basePlaybackRate,
|
basePlaybackRate,
|
||||||
bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
||||||
|
project,
|
||||||
renderStartBeat,
|
renderStartBeat,
|
||||||
secondsPerBeat
|
|
||||||
);
|
);
|
||||||
applyOfflineExpressionAutomation(
|
applyOfflineExpressionAutomation(
|
||||||
gainNode,
|
gainNode,
|
||||||
bakedExpressionEvents.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
bakedExpressionEvents.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat),
|
||||||
|
project,
|
||||||
renderStartBeat,
|
renderStartBeat,
|
||||||
secondsPerBeat
|
|
||||||
);
|
);
|
||||||
|
|
||||||
context.transport.schedule((time) => {
|
context.transport.schedule((time) => {
|
||||||
source.start(time, 0, noteDuration, velocity);
|
source.start(time, 0, noteDuration, velocity);
|
||||||
}, noteStartTime);
|
}, beatsToOfflineTransportTime(note.startBeat, renderStartBeat));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -449,9 +447,9 @@ export class KGOfflineRenderer {
|
|||||||
trackInfo.volumeAutomation,
|
trackInfo.volumeAutomation,
|
||||||
trackInfo.panAutomation,
|
trackInfo.panAutomation,
|
||||||
trackInfo.volume,
|
trackInfo.volume,
|
||||||
|
project,
|
||||||
renderStartBeat,
|
renderStartBeat,
|
||||||
renderEndBeat,
|
renderEndBeat,
|
||||||
secondsPerBeat,
|
|
||||||
interpolationIntervalMs,
|
interpolationIntervalMs,
|
||||||
bpm
|
bpm
|
||||||
);
|
);
|
||||||
@@ -470,7 +468,7 @@ export class KGOfflineRenderer {
|
|||||||
|
|
||||||
if (effectiveDurationSeconds <= 0) continue;
|
if (effectiveDurationSeconds <= 0) continue;
|
||||||
|
|
||||||
const regionStartTime = Math.max(0, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat));
|
const regionStartTime = beatsToOfflineTransportTime(regionStartBeat, renderStartBeat);
|
||||||
|
|
||||||
// Create buffer source NOW while the offline context is still active.
|
// Create buffer source NOW while the offline context is still active.
|
||||||
// Schedule callbacks fire during rendering after Tone.js restores the
|
// Schedule callbacks fire during rendering after Tone.js restores the
|
||||||
@@ -660,11 +658,11 @@ export function applyOfflinePitchBendAutomation(
|
|||||||
source: Tone.ToneBufferSource,
|
source: Tone.ToneBufferSource,
|
||||||
basePlaybackRate: number,
|
basePlaybackRate: number,
|
||||||
bakedPitchBends: BakedMidiAutomationPoint[],
|
bakedPitchBends: BakedMidiAutomationPoint[],
|
||||||
|
project: KGProject,
|
||||||
renderStartBeat: number,
|
renderStartBeat: number,
|
||||||
secondsPerBeat: number
|
|
||||||
): void {
|
): void {
|
||||||
bakedPitchBends.forEach(point => {
|
bakedPitchBends.forEach(point => {
|
||||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat);
|
||||||
setOfflinePlaybackRate(
|
setOfflinePlaybackRate(
|
||||||
source,
|
source,
|
||||||
KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, midiPitchBendToNormalized(point.value)),
|
KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, midiPitchBendToNormalized(point.value)),
|
||||||
@@ -676,11 +674,11 @@ export function applyOfflinePitchBendAutomation(
|
|||||||
export function applyOfflineExpressionAutomation(
|
export function applyOfflineExpressionAutomation(
|
||||||
gainNode: Tone.Gain,
|
gainNode: Tone.Gain,
|
||||||
bakedExpressionEvents: BakedMidiAutomationPoint[],
|
bakedExpressionEvents: BakedMidiAutomationPoint[],
|
||||||
|
project: KGProject,
|
||||||
renderStartBeat: number,
|
renderStartBeat: number,
|
||||||
secondsPerBeat: number
|
|
||||||
): void {
|
): void {
|
||||||
bakedExpressionEvents.forEach(point => {
|
bakedExpressionEvents.forEach(point => {
|
||||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat);
|
||||||
setOfflineGainValue(gainNode, clampMidiControllerValue(point.value) / 127, automationTime);
|
setOfflineGainValue(gainNode, clampMidiControllerValue(point.value) / 127, automationTime);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -691,9 +689,9 @@ function applyOfflineTrackAutomation(
|
|||||||
volumeAutomation: MidiAutomationPoint[],
|
volumeAutomation: MidiAutomationPoint[],
|
||||||
panAutomation: MidiAutomationPoint[],
|
panAutomation: MidiAutomationPoint[],
|
||||||
baseVolume: number,
|
baseVolume: number,
|
||||||
|
project: KGProject,
|
||||||
renderStartBeat: number,
|
renderStartBeat: number,
|
||||||
renderEndBeat: number,
|
renderEndBeat: number,
|
||||||
secondsPerBeat: number,
|
|
||||||
interpolationIntervalMs: number,
|
interpolationIntervalMs: number,
|
||||||
bpm: number
|
bpm: number
|
||||||
): void {
|
): void {
|
||||||
@@ -717,7 +715,7 @@ function applyOfflineTrackAutomation(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat);
|
||||||
setOfflineGainValue(gainNode, getOfflineTrackGain(point.value, false), automationTime);
|
setOfflineGainValue(gainNode, getOfflineTrackGain(point.value, false), automationTime);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -744,7 +742,7 @@ function applyOfflineTrackAutomation(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const automationTime = (point.beat - renderStartBeat) * secondsPerBeat;
|
const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat);
|
||||||
setOfflinePanValue(pannerNode, point.value, automationTime);
|
setOfflinePanValue(pannerNode, point.value, automationTime);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -752,6 +750,12 @@ function applyOfflineTrackAutomation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function beatsToOfflineTransportTime(beat: number, renderStartBeat: number): Tone.Unit.Time {
|
||||||
|
const transportBeats = Math.max(0, beat - renderStartBeat);
|
||||||
|
const transportTicks = Math.round(transportBeats * Tone.Transport.PPQ);
|
||||||
|
return transportTicks === 0 ? 0 : `${transportTicks}i` as Tone.Unit.Time;
|
||||||
|
}
|
||||||
|
|
||||||
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number {
|
||||||
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
return isSilent ? -Infinity : volumeDb;
|
return isSilent ? -Infinity : volumeDb;
|
||||||
|
|||||||
Reference in New Issue
Block a user