diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 6ebdffd..6864a7b 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -10,6 +10,10 @@ import { KGRegion } from './region/KGRegion'; import { generateUniqueId } from '../util/miscUtil'; import { KGCommand, KGCommandHistory } from './commands'; +interface PlaybackStartOptions { + preserveLoopPreroll?: boolean; +} + /** * KGCore - Main application class for the DAW * Implements the singleton pattern for global access @@ -231,7 +235,7 @@ export class KGCore { return this.isPlaying; } - public async preparePlay(): Promise { + public async preparePlay(options?: PlaybackStartOptions): Promise { try { const audioInterface = KGAudioInterface.instance(); @@ -239,7 +243,9 @@ export class KGCore { await audioInterface.startAudioContext(); // Prepare playback with current project and playhead position - audioInterface.preparePlayback(this.currentProject, this.playheadPosition); + audioInterface.preparePlayback(this.currentProject, this.playheadPosition, { + allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false, + }); // Sync BPM and transport settings audioInterface.setBpm(this.currentProject.getBpm()); @@ -301,7 +307,7 @@ export class KGCore { } // High-level playback control methods - public async startPlaying(): Promise { + public async startPlaying(options?: PlaybackStartOptions): Promise { // Handle loop mode initialization if (this.currentProject.getIsLooping()) { const [startBar, endBar] = this.currentProject.getLoopingRange(); @@ -319,11 +325,13 @@ export class KGCore { const updatedRange = this.currentProject.getLoopingRange(); const beatsPerBar = this.currentProject.getTimeSignature().numerator; const loopStartBeats = updatedRange[0] * beatsPerBar; - this.setPlayheadPosition(loopStartBeats); + if (!options?.preserveLoopPreroll) { + this.setPlayheadPosition(loopStartBeats); + } } // Prepare playback first - await this.preparePlay(); + await this.preparePlay(options); // Start playing (non-blocking) this.play(); // Don't await this @@ -645,4 +653,4 @@ export class KGCore { public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } { return this.commandHistory.getHistoryStats(); } -} \ No newline at end of file +} diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index 44b468e..068f923 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -97,4 +97,19 @@ describe('KGAudioInterface preroll playback', () => { expect(MockTransport.stop).toHaveBeenCalledTimes(1) expect(audio.getTransportPosition()).toBe(0) }) + + it('allows a first-pass start before the loop start when explicitly requested', () => { + const project = createMockProject({ + bpm: 120, + timeSignature: { numerator: 4, denominator: 4 }, + tracks: [], + }) + project.setIsLooping(true) + project.setLoopingRange([4, 7]) + + const audio = KGAudioInterface.instance() + audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true }) + + expect(MockTransport.position).toBe(6) + }) }) diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index 845d0cc..f556cba 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -12,6 +12,10 @@ import { KGCore } from '../KGCore'; import { ConfigManager } from '../config/ConfigManager'; import { KGMetronome } from './KGMetronome'; +interface PreparePlaybackOptions { + allowStartBeforeLoopStart?: boolean; +} + /** * KGAudioInterface - Audio engine interface for the DAW * Implements the singleton pattern for global audio management @@ -391,7 +395,7 @@ export class KGAudioInterface { /** * Prepare playback by scheduling all MIDI events */ - public preparePlayback(project: KGProject, startPosition: number): void { + public preparePlayback(project: KGProject, startPosition: number, options?: PreparePlaybackOptions): void { // Clear any existing scheduled events this.clearScheduledEvents(); this.clearDelayedTransportStart(); @@ -437,7 +441,7 @@ export class KGAudioInterface { console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`); // Adjust start position to loop start if before loop range - if (startPosition < scheduleStartBeat) { + if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) { startPosition = scheduleStartBeat; } } else { diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 65ba16d..b97461d 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -797,7 +797,7 @@ export const useProjectStore = create((set, get) => { }, startRecording: async () => { - const { activeRegionId, timeSignature, playheadPosition, startPlaying, setPlayheadPosition } = get(); + const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get(); const project = KGCore.instance().getCurrentProject(); let targetRegion: KGMidiRegion | null = null; @@ -842,8 +842,18 @@ export const useProjectStore = create((set, get) => { } ); - setPlayheadPosition(playheadPosition - timeSignature.numerator); - await startPlaying(); + const projectLooping = project.getIsLooping(); + const [loopStartBar] = project.getLoopingRange(); + const loopStartBeat = loopStartBar * timeSignature.numerator; + const recordingStartBeat = projectLooping + ? loopStartBeat - timeSignature.numerator + : playheadPosition - timeSignature.numerator; + + setPlayheadPosition(recordingStartBeat); + await KGCore.instance().startPlaying({ + preserveLoopPreroll: projectLooping, + }); + set({ isPlaying: true, autoScrollEnabled: true }); }, stopRecording: async () => { @@ -1196,4 +1206,3 @@ export const useProjectStore = create((set, get) => { - diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts index 425e836..ac3113c 100644 --- a/src/test/integration/store/project-store-sync.integration.test.ts +++ b/src/test/integration/store/project-store-sync.integration.test.ts @@ -11,6 +11,7 @@ import { KGProject } from '../../../core/KGProject' import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack' import { KGMidiRegion } from '../../../core/region/KGMidiRegion' import { KGMidiNote } from '../../../core/midi/KGMidiNote' +import { KGMidiInput } from '../../../core/midi-input/KGMidiInput' // Import store import { useProjectStore } from '../../../stores/projectStore' @@ -279,6 +280,41 @@ describe('Project Store Synchronization Integration Tests', () => { // Verify audio interface was called expect(mockAudioInterface.stopPlayback).toHaveBeenCalled() }) + + it('should start looped recording one bar before the loop start on the first pass', async () => { + const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano') + const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16) + testTrack.addRegion(testRegion) + testProject.setTracks([testTrack]) + testProject.setIsLooping(true) + testProject.setLoopingRange([4, 7]) + + await act(async () => { + await useProjectStore.getState().loadProject(testProject) + }) + + const core = KGCore.instance() + const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined) + const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks') + const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState() + + act(() => { + setActiveRegionId(testRegion.getId()) + setPlayheadPosition(18) + }) + + await act(async () => { + await startRecording() + }) + + const storeState = useProjectStore.getState() + expect(storeState.playheadPosition).toBe(12) + expect(storeState.recordingOriginalPlayhead).toBe(18) + expect(storeState.isRecording).toBe(true) + expect(storeState.isPlaying).toBe(true) + expect(recordingCallbacksSpy).toHaveBeenCalled() + expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true }) + }) }) describe('Selection State Synchronization', () => { @@ -443,4 +479,4 @@ describe('Project Store Synchronization Integration Tests', () => { expect(maxBarsCSS.trim()).toBe('48') }) }) -}) \ No newline at end of file +})