From e82c3cbfc2b37e486bdfd41418164cdf9575a295 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 26 May 2026 19:40:37 -0700 Subject: [PATCH] fix: changing BPM in the middle of the song causes empty spaces between notes during playback/bounce --- .../audio-interface/KGAudioInterface.test.ts | 101 ++++++++++++++++-- src/core/audio-interface/KGAudioInterface.ts | 76 +++++++++---- .../audio-interface/KGOfflineRenderer.test.ts | 47 +++++++- src/core/audio-interface/KGOfflineRenderer.ts | 36 ++++--- 4 files changed, 214 insertions(+), 46 deletions(-) diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index f002d31..1ff6875 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -1,6 +1,8 @@ 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 { GlobalTrackType } from '../global-track'; +import { KGTempoRegion } from '../region/KGTempoRegion'; vi.mock('tone', async () => { const { ToneMock } = await import('../../test/mocks/tone'); @@ -41,6 +43,22 @@ function createMockAudioBus() { }; } +function setTempoRegions(project: ReturnType, 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', () => { beforeEach(() => { vi.useFakeTimers(); @@ -53,6 +71,7 @@ describe('KGAudioInterface preroll playback', () => { MockTransport.clear.mockClear(); MockTransport.schedule.mockClear(); MockTransport.bpm.value = 120; + ;(MockTransport as typeof MockTransport & { seconds?: number }).seconds = 0; const project = createMockProject({ bpm: 120, @@ -129,7 +148,7 @@ describe('KGAudioInterface preroll playback', () => { const audio = KGAudioInterface.instance(); 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', () => { @@ -147,9 +166,9 @@ describe('KGAudioInterface preroll playback', () => { ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 0); - const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time); - expect(scheduledTimes).toContain(0.25); + const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time)); 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', () => { @@ -167,8 +186,8 @@ describe('KGAudioInterface preroll playback', () => { ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 0); - const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time); - expect(scheduledTimes).toEqual([0.25]); + const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time)); + expect(scheduledTimes).toEqual([0.5]); }); 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 }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 5); - const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time); - expect(scheduledTimes).toContain(2); + const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => transportTimeToBeats(time)); + 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 }).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 }).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); }); }); diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index be3e55c..dffca44 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -76,6 +76,8 @@ export class KGAudioInterface { private delayedTransportStartSeconds: number = 0; private virtualPrerollStartBeat: number | null = null; private virtualPrerollStartAudioTime: number | null = null; + private playbackOriginBeat: number = 0; + private playbackOriginSeconds: number = 0; // Master volume control private masterGain: Tone.Gain | null = null; @@ -471,21 +473,24 @@ export class KGAudioInterface { scheduleStartBeat = startBar * beatsPerBar; 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 if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) { 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 { Tone.Transport.loop = false; console.log("Loop mode disabled"); + this.setPlaybackOrigin(project, startPosition); } this.scheduleTempoChanges(project, Math.max(startPosition, scheduleStartBeat), scheduleEndBeat); @@ -641,7 +646,7 @@ export class KGAudioInterface { if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) { audioBus.scheduleLiveMidiPitchBend(midiPitchBendToNormalized(value), time); } - }, this.beatsToToneTime(beat)); + }, this.projectBeatToTransportTime(beat)); this.scheduledEvents.add(eventId); }); @@ -669,7 +674,7 @@ export class KGAudioInterface { if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) { audioBus.scheduleLiveMidiExpression(value / 127, time); } - }, this.beatsToToneTime(beat)); + }, this.projectBeatToTransportTime(beat)); this.scheduledEvents.add(eventId); }); @@ -697,13 +702,13 @@ export class KGAudioInterface { if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) { audioBus.setLiveMidiSustain(value >= 64, time); } - }, this.beatsToToneTime(beat)); + }, this.projectBeatToTransportTime(beat)); this.scheduledEvents.add(eventId); }); trackNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => { - const noteStartTime = this.beatsToToneTime(absoluteStartBeat); + const noteStartTime = this.projectBeatToTransportTime(absoluteStartBeat); const noteDurationSeconds = beatRangeToSeconds(project, absoluteStartBeat, absoluteEndBeat); const velocity = note.getVelocity() / 127; const noteName = pitchToNoteNameString(note.getPitch()); @@ -781,7 +786,7 @@ export class KGAudioInterface { return; } - const regionStartTime = this.beatsToToneTime(safeResumeBeat); + const regionStartTime = this.projectBeatToTransportTime(safeResumeBeat); const eventId = Tone.Transport.schedule((time) => { const hasSoloedTracks = this.hasSoloedTracks(); @@ -819,7 +824,7 @@ export class KGAudioInterface { effectiveDurationSeconds = Math.min(effectiveDurationSeconds, maxDurationSeconds); } - const regionStartTime = this.beatsToToneTime(regionStartBeat); + const regionStartTime = this.projectBeatToTransportTime(regionStartBeat); console.log( `Scheduling audio region "${region.getName()}" at beat ${regionStartBeat}, clipOffset: ${clipStartOffsetSeconds}s, duration: ${effectiveDurationSeconds}s` @@ -885,6 +890,7 @@ export class KGAudioInterface { try { this.clearDelayedTransportStart(); Tone.Transport.stop(); + this.resetPlaybackOrigin(); this.metronome.stop(); // Release all currently playing notes @@ -1156,7 +1162,7 @@ export class KGAudioInterface { try { // Convert beats to Tone.js time format const safePosition = Math.max(0, position); - const toneTime = this.beatsToToneTime(safePosition); + const toneTime = this.projectBeatToTransportTime(safePosition); Tone.Transport.position = toneTime; console.log(`Set transport position to ${position} beats (${toneTime})`); } catch (error) { @@ -1179,10 +1185,13 @@ export class KGAudioInterface { const transportSeconds = Number(Tone.Transport.seconds); 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) { console.error('Error getting transport position:', error); return 0; @@ -1455,7 +1464,7 @@ export class KGAudioInterface { playerBus.setAutomationVolume(value); } this.updateAllEffectiveVolumes(); - }, this.beatsToToneTime(beat)); + }, this.projectBeatToTransportTime(beat)); this.scheduledEvents.add(eventId); }); @@ -1472,7 +1481,7 @@ export class KGAudioInterface { if (playerBus) { playerBus.scheduleAutomationPan(value, time); } - }, this.beatsToToneTime(beat)); + }, this.projectBeatToTransportTime(beat)); this.scheduledEvents.add(eventId); }); } @@ -1496,7 +1505,7 @@ export class KGAudioInterface { const eventId = Tone.Transport.schedule(() => { Tone.Transport.bpm.value = region.getBpm(); - }, this.beatsToToneTime(regionStartBeat)); + }, this.projectBeatToTransportTime(regionStartBeat)); this.scheduledEvents.add(eventId); }); } @@ -1559,6 +1568,33 @@ export class KGAudioInterface { 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 */ diff --git a/src/core/audio-interface/KGOfflineRenderer.test.ts b/src/core/audio-interface/KGOfflineRenderer.test.ts index 358c880..0c540ad 100644 --- a/src/core/audio-interface/KGOfflineRenderer.test.ts +++ b/src/core/audio-interface/KGOfflineRenderer.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data'; import { bakeMidiAutomationPointsInWindow } from '../../util/midiAutomationUtil'; +import { GlobalTrackType } from '../global-track'; +import { KGTempoRegion } from '../region/KGTempoRegion'; const { offlineMock, configGetMock } = vi.hoisted(() => ({ offlineMock: vi.fn(), @@ -32,6 +34,15 @@ vi.mock('../config/ConfigManager', () => ({ import { KGOfflineRenderer, applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer'; +function setTempoRegions(project: ReturnType, 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. * In the jsdom test environment, AudioBuffer is not available, @@ -215,8 +226,8 @@ describe('offline pitch bend automation', () => { source, 1, [{ beat: 1, value: 0 }], + createMockProject({ bpm: 120 }), 0, - 0.5 ); 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[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[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', () => { diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts index 17c9848..b725cfc 100644 --- a/src/core/audio-interface/KGOfflineRenderer.ts +++ b/src/core/audio-interface/KGOfflineRenderer.ts @@ -96,7 +96,6 @@ export class KGOfflineRenderer { // Calculate render duration in seconds const bpm = getEffectiveBpmAtBeat(project, 0); - const secondsPerBeat = 60 / bpm; const timeSignature = project.getTimeSignature(); const beatsPerBar = timeSignature.numerator; @@ -304,7 +303,7 @@ export class KGOfflineRenderer { context.transport.schedule((time) => { context.transport.bpm.setValueAtTime(region.getBpm(), time); - }, beatToSeconds(project, regionStartBeat) - beatToSeconds(project, renderStartBeat)); + }, beatsToOfflineTransportTime(regionStartBeat, renderStartBeat)); }); // ---- Create MIDI track samplers ---- @@ -343,9 +342,9 @@ export class KGOfflineRenderer { trackInfo.volumeAutomation, trackInfo.panAutomation, trackInfo.volume, + project, renderStartBeat, renderEndBeat, - secondsPerBeat, interpolationIntervalMs, bpm ); @@ -381,7 +380,6 @@ export class KGOfflineRenderer { // Skip notes outside render range if (note.startBeat >= renderEndBeat || note.endBeat <= renderStartBeat) continue; - const noteStartTime = beatToSeconds(project, note.startBeat) - beatToSeconds(project, renderStartBeat); const sustainedEndBeat = resolveSustainExtendedEndBeat( trackInfo.controllerEventsByType[64], note.endBeat, @@ -412,19 +410,19 @@ export class KGOfflineRenderer { source, basePlaybackRate, bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat), + project, renderStartBeat, - secondsPerBeat ); applyOfflineExpressionAutomation( gainNode, bakedExpressionEvents.filter(point => point.beat > note.startBeat && point.beat < sustainedEndBeat), + project, renderStartBeat, - secondsPerBeat ); context.transport.schedule((time) => { source.start(time, 0, noteDuration, velocity); - }, noteStartTime); + }, beatsToOfflineTransportTime(note.startBeat, renderStartBeat)); } } } catch (error) { @@ -449,9 +447,9 @@ export class KGOfflineRenderer { trackInfo.volumeAutomation, trackInfo.panAutomation, trackInfo.volume, + project, renderStartBeat, renderEndBeat, - secondsPerBeat, interpolationIntervalMs, bpm ); @@ -470,7 +468,7 @@ export class KGOfflineRenderer { 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. // Schedule callbacks fire during rendering after Tone.js restores the @@ -660,11 +658,11 @@ export function applyOfflinePitchBendAutomation( source: Tone.ToneBufferSource, basePlaybackRate: number, bakedPitchBends: BakedMidiAutomationPoint[], + project: KGProject, renderStartBeat: number, - secondsPerBeat: number ): void { bakedPitchBends.forEach(point => { - const automationTime = (point.beat - renderStartBeat) * secondsPerBeat; + const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat); setOfflinePlaybackRate( source, KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, midiPitchBendToNormalized(point.value)), @@ -676,11 +674,11 @@ export function applyOfflinePitchBendAutomation( export function applyOfflineExpressionAutomation( gainNode: Tone.Gain, bakedExpressionEvents: BakedMidiAutomationPoint[], + project: KGProject, renderStartBeat: number, - secondsPerBeat: number ): void { 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); }); } @@ -691,9 +689,9 @@ function applyOfflineTrackAutomation( volumeAutomation: MidiAutomationPoint[], panAutomation: MidiAutomationPoint[], baseVolume: number, + project: KGProject, renderStartBeat: number, renderEndBeat: number, - secondsPerBeat: number, interpolationIntervalMs: number, bpm: number ): void { @@ -717,7 +715,7 @@ function applyOfflineTrackAutomation( return; } - const automationTime = (point.beat - renderStartBeat) * secondsPerBeat; + const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat); setOfflineGainValue(gainNode, getOfflineTrackGain(point.value, false), automationTime); }); } else { @@ -744,7 +742,7 @@ function applyOfflineTrackAutomation( return; } - const automationTime = (point.beat - renderStartBeat) * secondsPerBeat; + const automationTime = beatToSeconds(project, point.beat) - beatToSeconds(project, renderStartBeat); setOfflinePanValue(pannerNode, point.value, automationTime); }); } 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 { const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; return isSilent ? -Infinity : volumeDb;