From c733c398140c60fee913630ed35253fb3676aa0c Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Mon, 4 May 2026 17:11:29 -0700 Subject: [PATCH] fix: should cut a held looped recording note at the loop end when note off arrives after wrap --- src/stores/projectStore.ts | 30 ++++++++++-- .../project-store-sync.integration.test.ts | 47 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 342815d..72470d2 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -202,6 +202,29 @@ interface ProjectState { let _recordingActiveNotes: Map = new Map(); // pitch → region-relative startBeat let _recordingRegionStartBeat: number = 0; +function getRecordingLoopEndBeatRelative(): number | null { + const project = KGCore.instance().getCurrentProject(); + if (!project.getIsLooping()) { + return null; + } + + const [startBar, endBarOriginal] = project.getLoopingRange(); + const beatsPerBar = project.getTimeSignature().numerator; + const endBar = (startBar === 0 && endBarOriginal === 0) ? project.getMaxBars() : endBarOriginal; + const loopEndBeatAbsolute = (endBar + 1) * beatsPerBar; + + return loopEndBeatAbsolute - _recordingRegionStartBeat; +} + +function finalizeRecordedNote(startBeat: number, candidateEndBeat: number): number { + const loopEndBeatRelative = getRecordingLoopEndBeatRelative(); + if (loopEndBeatRelative !== null && candidateEndBeat < startBeat) { + return loopEndBeatRelative; + } + + return candidateEndBeat; +} + // Create the store export const useProjectStore = create((set, get) => { const currentProject = KGCore.instance().getCurrentProject(); @@ -869,8 +892,9 @@ export const useProjectStore = create((set, get) => { const startBeat = _recordingActiveNotes.get(pitch); if (startBeat !== undefined) { _recordingActiveNotes.delete(pitch); + const finalizedEndBeat = finalizeRecordedNote(startBeat, endBeat); set(state => ({ - recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat }], + recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat: finalizedEndBeat }], })); } } @@ -902,7 +926,7 @@ export const useProjectStore = create((set, get) => { const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat; _recordingActiveNotes.forEach((startBeat, pitch) => { - finalNotes.push({ pitch, startBeat, endBeat: endBeatForHeld }); + finalNotes.push({ pitch, startBeat, endBeat: finalizeRecordedNote(startBeat, endBeatForHeld) }); }); _recordingActiveNotes.clear(); @@ -1249,5 +1273,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 dcdbf28..1bd18c7 100644 --- a/src/test/integration/store/project-store-sync.integration.test.ts +++ b/src/test/integration/store/project-store-sync.integration.test.ts @@ -366,6 +366,53 @@ describe('Project Store Synchronization Integration Tests', () => { expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null); expect(testRegion.getNotes()).toHaveLength(1); }); + + it('should cut a held looped recording note at the loop end when note off arrives after wrap', 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(); + vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined); + vi.spyOn(mockAudioInterface, 'getTransportPosition') + .mockReturnValueOnce(31.4) + .mockReturnValueOnce(16.9); + const setRecordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks'); + const { setActiveRegionId, setPlayheadPosition, startRecording, stopTransport } = useProjectStore.getState(); + + act(() => { + setActiveRegionId(testRegion.getId()); + setPlayheadPosition(18); + }); + + await act(async () => { + await startRecording(); + }); + + const noteOn = setRecordingCallbacksSpy.mock.calls.at(-1)?.[0]; + const noteOff = setRecordingCallbacksSpy.mock.calls.at(-1)?.[1]; + + act(() => { + noteOn?.(60); + noteOff?.(60); + }); + + await act(async () => { + await stopTransport(); + }); + + const notes = testRegion.getNotes(); + expect(notes).toHaveLength(1); + expect(notes[0].getStartBeat()).toBeCloseTo(15); + expect(notes[0].getEndBeat()).toBeCloseTo(16); + }); }); describe('Selection State Synchronization', () => {