fix: should cut a held looped recording note at the loop end when note off arrives after wrap

This commit is contained in:
Xiaohan-Tian
2026-05-04 17:11:29 -07:00
parent 769d70ccb4
commit c733c39814
2 changed files with 73 additions and 4 deletions
+26 -4
View File
@@ -202,6 +202,29 @@ interface ProjectState {
let _recordingActiveNotes: Map<number, number> = new Map(); // pitch → region-relative startBeat let _recordingActiveNotes: Map<number, number> = new Map(); // pitch → region-relative startBeat
let _recordingRegionStartBeat: number = 0; 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 // Create the store
export const useProjectStore = create<ProjectState>((set, get) => { export const useProjectStore = create<ProjectState>((set, get) => {
const currentProject = KGCore.instance().getCurrentProject(); const currentProject = KGCore.instance().getCurrentProject();
@@ -869,8 +892,9 @@ export const useProjectStore = create<ProjectState>((set, get) => {
const startBeat = _recordingActiveNotes.get(pitch); const startBeat = _recordingActiveNotes.get(pitch);
if (startBeat !== undefined) { if (startBeat !== undefined) {
_recordingActiveNotes.delete(pitch); _recordingActiveNotes.delete(pitch);
const finalizedEndBeat = finalizeRecordedNote(startBeat, endBeat);
set(state => ({ set(state => ({
recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat }], recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat: finalizedEndBeat }],
})); }));
} }
} }
@@ -902,7 +926,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat; const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
_recordingActiveNotes.forEach((startBeat, pitch) => { _recordingActiveNotes.forEach((startBeat, pitch) => {
finalNotes.push({ pitch, startBeat, endBeat: endBeatForHeld }); finalNotes.push({ pitch, startBeat, endBeat: finalizeRecordedNote(startBeat, endBeatForHeld) });
}); });
_recordingActiveNotes.clear(); _recordingActiveNotes.clear();
@@ -1249,5 +1273,3 @@ export const useProjectStore = create<ProjectState>((set, get) => {
@@ -366,6 +366,53 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null); expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null);
expect(testRegion.getNotes()).toHaveLength(1); 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', () => { describe('Selection State Synchronization', () => {