feat: added pitch bend automation (linear interpolation)
This commit is contained in:
@@ -31,6 +31,8 @@ const mockCore = {
|
||||
getStatus: () => 'Ready',
|
||||
getPlayheadPosition: () => 0,
|
||||
getIsPlaying: () => false,
|
||||
startPlaying: vi.fn().mockResolvedValue(undefined),
|
||||
stopPlaying: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock('../core/KGCore', () => ({
|
||||
@@ -51,6 +53,10 @@ vi.mock('../core/config/ConfigManager', () => ({
|
||||
describe('projectStore piano roll state', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockCore.startPlaying.mockReset();
|
||||
mockCore.startPlaying.mockResolvedValue(undefined);
|
||||
mockCore.stopPlaying.mockReset();
|
||||
mockCore.stopPlaying.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('clears hybrid state when opening a MIDI region', async () => {
|
||||
@@ -75,4 +81,76 @@ describe('projectStore piano roll state', () => {
|
||||
expect(state.activeRegionId).toBe('midi-b');
|
||||
expect(state.hybridAudioRegionId).toBeNull();
|
||||
});
|
||||
|
||||
it('tracks playback preparation around startPlaying success', async () => {
|
||||
let resolveStart: (() => void) | null = null;
|
||||
mockCore.startPlaying.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}));
|
||||
|
||||
const { useProjectStore } = await import('./projectStore');
|
||||
|
||||
const startPromise = useProjectStore.getState().startPlaying();
|
||||
expect(useProjectStore.getState().isPreparingPlayback).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.();
|
||||
await startPromise;
|
||||
});
|
||||
|
||||
const state = useProjectStore.getState();
|
||||
expect(state.isPreparingPlayback).toBe(false);
|
||||
expect(state.isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it('clears playback preparation if startPlaying fails', async () => {
|
||||
mockCore.startPlaying.mockRejectedValueOnce(new Error('prepare failed'));
|
||||
|
||||
const { useProjectStore } = await import('./projectStore');
|
||||
|
||||
await expect(useProjectStore.getState().startPlaying()).rejects.toThrow('prepare failed');
|
||||
expect(useProjectStore.getState().isPreparingPlayback).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores repeated startPlaying calls while already preparing', async () => {
|
||||
let resolveStart: (() => void) | null = null;
|
||||
mockCore.startPlaying.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}));
|
||||
|
||||
const { useProjectStore } = await import('./projectStore');
|
||||
|
||||
const firstStart = useProjectStore.getState().startPlaying();
|
||||
const secondStart = useProjectStore.getState().startPlaying();
|
||||
|
||||
expect(mockCore.startPlaying).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.();
|
||||
await Promise.all([firstStart, secondStart]);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears playback preparation when stopTransport is called during prepare', async () => {
|
||||
let resolveStart: (() => void) | null = null;
|
||||
mockCore.startPlaying.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}));
|
||||
|
||||
const { useProjectStore } = await import('./projectStore');
|
||||
|
||||
const startPromise = useProjectStore.getState().startPlaying();
|
||||
expect(useProjectStore.getState().isPreparingPlayback).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await useProjectStore.getState().stopTransport();
|
||||
});
|
||||
|
||||
expect(useProjectStore.getState().isPreparingPlayback).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart?.();
|
||||
await startPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+40
-10
@@ -68,6 +68,7 @@ interface ProjectState {
|
||||
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
|
||||
playheadPosition: number; // in beats
|
||||
isPlaying: boolean;
|
||||
isPreparingPlayback: boolean;
|
||||
autoScrollEnabled: boolean;
|
||||
currentTime: string; // formatted time string
|
||||
|
||||
@@ -341,6 +342,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
loopingRange: currentProject.getLoopingRange(),
|
||||
playheadPosition: KGCore.instance().getPlayheadPosition(),
|
||||
isPlaying: KGCore.instance().getIsPlaying(),
|
||||
isPreparingPlayback: false,
|
||||
autoScrollEnabled: true,
|
||||
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
|
||||
|
||||
@@ -854,13 +856,26 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
},
|
||||
|
||||
startPlaying: async () => {
|
||||
await KGCore.instance().startPlaying();
|
||||
set({ isPlaying: true, autoScrollEnabled: true });
|
||||
if (get().isPreparingPlayback) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isPreparingPlayback: true });
|
||||
try {
|
||||
await KGCore.instance().startPlaying();
|
||||
set({ isPlaying: true, autoScrollEnabled: true });
|
||||
} finally {
|
||||
set({ isPreparingPlayback: false });
|
||||
}
|
||||
},
|
||||
|
||||
stopPlaying: async () => {
|
||||
await KGCore.instance().stopPlaying();
|
||||
set({ isPlaying: false });
|
||||
try {
|
||||
await KGCore.instance().stopPlaying();
|
||||
set({ isPlaying: false });
|
||||
} finally {
|
||||
set({ isPreparingPlayback: false });
|
||||
}
|
||||
},
|
||||
|
||||
stopTransport: async () => {
|
||||
@@ -868,7 +883,11 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
await get().stopRecording();
|
||||
return;
|
||||
}
|
||||
await get().stopPlaying();
|
||||
try {
|
||||
await get().stopPlaying();
|
||||
} finally {
|
||||
set({ isPreparingPlayback: false });
|
||||
}
|
||||
},
|
||||
|
||||
startRecording: async () => {
|
||||
@@ -944,10 +963,15 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
: playheadPosition - timeSignature.numerator;
|
||||
|
||||
setPlayheadPosition(recordingStartBeat);
|
||||
await KGCore.instance().startPlaying({
|
||||
preserveLoopPreroll: projectLooping,
|
||||
});
|
||||
set({ isPlaying: true, autoScrollEnabled: true });
|
||||
set({ isPreparingPlayback: true });
|
||||
try {
|
||||
await KGCore.instance().startPlaying({
|
||||
preserveLoopPreroll: projectLooping,
|
||||
});
|
||||
set({ isPlaying: true, autoScrollEnabled: true });
|
||||
} finally {
|
||||
set({ isPreparingPlayback: false });
|
||||
}
|
||||
},
|
||||
|
||||
stopRecording: async () => {
|
||||
@@ -1002,7 +1026,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
|
||||
await stopPlaying();
|
||||
setPlayheadPosition(recordingOriginalPlayhead);
|
||||
set({ isRecording: false, recordingNotes: [], recordingPitchBends: [], recordingTargetRegionId: null });
|
||||
set({
|
||||
isRecording: false,
|
||||
isPreparingPlayback: false,
|
||||
recordingNotes: [],
|
||||
recordingPitchBends: [],
|
||||
recordingTargetRegionId: null
|
||||
});
|
||||
_lastRecordedPitchBendValue = null;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user