fix: should cut a held looped recording note at the loop end when note off arrives after wrap
This commit is contained in:
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user