fix: audio track undo buffer rehydration

This commit is contained in:
Xiaohan-Tian
2026-06-07 13:06:34 -07:00
parent a76a8abb94
commit 94befcdd95
3 changed files with 228 additions and 23 deletions
+140
View File
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { KGTrack } from '../core/track/KGTrack';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { createDefaultGlobalTracks } from '../core/global-track';
const pianoRollStateMocks = vi.hoisted(() => ({
@@ -9,6 +11,25 @@ const pianoRollStateMocks = vi.hoisted(() => ({
setPianoRollZoom: vi.fn(),
}));
const audioStorageMocks = vi.hoisted(() => ({
loadAudioFile: vi.fn(),
}));
const toneMocks = vi.hoisted(() => {
const decodeAudioData = vi.fn();
const toneBufferSet = vi.fn();
class MockToneAudioBuffer {
public set = toneBufferSet;
}
return {
decodeAudioData,
toneBufferSet,
ToneAudioBuffer: MockToneAudioBuffer,
};
});
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
let mockIsMetronomeEnabled = false;
let mockShowGlobalTracks = false;
@@ -44,6 +65,9 @@ const mockAudioInterface = {
removeTrackSynth: vi.fn(),
removeTrackAudioPlayerBus: vi.fn(),
createTrackAudioPlayerBus: vi.fn().mockResolvedValue(undefined),
hasTrackAudioPlayerBus: vi.fn().mockReturnValue(true),
hasAudioBufferForTrack: vi.fn().mockReturnValue(false),
getAudioBuffer: vi.fn(),
loadAudioBufferForTrack: vi.fn(),
createTrackSynth: vi.fn(),
setTrackVolume: vi.fn(),
@@ -96,6 +120,21 @@ vi.mock('../core/audio-interface/KGAudioInterface', () => ({
},
}));
vi.mock('../core/io/KGAudioFileStorage', () => ({
KGAudioFileStorage: {
loadAudioFile: audioStorageMocks.loadAudioFile,
},
}));
vi.mock('tone', () => ({
getContext: () => ({
rawContext: {
decodeAudioData: toneMocks.decodeAudioData,
},
}),
ToneAudioBuffer: toneMocks.ToneAudioBuffer,
}));
vi.mock('../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => ({
@@ -139,7 +178,18 @@ describe('projectStore piano roll state', () => {
mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
mockAudioInterface.getTransportPosition.mockReset();
mockAudioInterface.getTransportPosition.mockReturnValue(8);
mockAudioInterface.hasTrackAudioPlayerBus.mockReset();
mockAudioInterface.hasTrackAudioPlayerBus.mockReturnValue(true);
mockAudioInterface.hasAudioBufferForTrack.mockReset();
mockAudioInterface.hasAudioBufferForTrack.mockReturnValue(false);
mockAudioInterface.getAudioBuffer.mockReset();
mockAudioInterface.createTrackAudioPlayerBus.mockReset();
mockAudioInterface.createTrackAudioPlayerBus.mockResolvedValue(undefined);
mockAudioInterface.loadAudioBufferForTrack.mockReset();
mockAudioInterface.setMetronomeEnabled.mockReset();
audioStorageMocks.loadAudioFile.mockReset();
toneMocks.decodeAudioData.mockReset();
toneMocks.toneBufferSet.mockReset();
mockIsMetronomeEnabled = false;
mockShowGlobalTracks = false;
mockProject.setIsMetronomeEnabled.mockClear();
@@ -376,6 +426,96 @@ describe('projectStore piano roll state', () => {
expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2);
});
it('rehydrates missing audio buffers during refreshProjectState', async () => {
const audioTrack = new KGAudioTrack('Audio 1', 1);
audioTrack.setTrackIndex(0);
audioTrack.setRegions([
new KGAudioRegion('audio-region-1', '1', 0, 'clip.wav', 0, 4, 'audio-file-1.wav', 'clip.wav', 2.5),
]);
mockTracks = [audioTrack];
const decodedBuffer = { duration: 2.5 } as AudioBuffer;
audioStorageMocks.loadAudioFile.mockResolvedValue(new ArrayBuffer(16));
toneMocks.decodeAudioData.mockResolvedValue(decodedBuffer);
const { useProjectStore } = await import('./projectStore');
await act(async () => {
useProjectStore.getState().refreshProjectState();
await Promise.resolve();
await Promise.resolve();
});
expect(audioStorageMocks.loadAudioFile).toHaveBeenCalledWith('Test Project', 'audio-file-1.wav');
expect(toneMocks.decodeAudioData).toHaveBeenCalled();
expect(mockAudioInterface.loadAudioBufferForTrack).toHaveBeenCalledWith(
'1',
'audio-file-1.wav',
expect.any(toneMocks.ToneAudioBuffer),
);
});
it('reloads a restored audio track buffer on undo', async () => {
const restoredTrack = new KGAudioTrack('Audio 1', 1);
restoredTrack.setTrackIndex(0);
const restoredRegion = new KGAudioRegion(
'audio-region-1',
'1',
0,
'clip.wav',
0,
4,
'audio-file-1.wav',
'clip.wav',
2.5,
);
restoredTrack.setRegions([restoredRegion]);
mockTracks = [];
mockCore.undo.mockImplementationOnce(() => {
mockTracks = [restoredTrack];
return true;
});
const decodedBuffer = { duration: 2.5 } as AudioBuffer;
audioStorageMocks.loadAudioFile.mockResolvedValue(new ArrayBuffer(16));
toneMocks.decodeAudioData.mockResolvedValue(decodedBuffer);
const { useProjectStore } = await import('./projectStore');
const initialWaveformVersion = useProjectStore.getState().audioWaveformRedrawVersion;
await act(async () => {
useProjectStore.getState().undo();
await Promise.resolve();
await Promise.resolve();
});
const state = useProjectStore.getState();
expect(state.tracks).toHaveLength(1);
const restoredTrackState = state.tracks[0] as KGAudioTrack;
expect((restoredTrackState.getRegions()[0] as KGAudioRegion).getAudioFileId()).toBe('audio-file-1.wav');
expect(mockAudioInterface.loadAudioBufferForTrack).toHaveBeenCalledWith(
'1',
'audio-file-1.wav',
expect.any(toneMocks.ToneAudioBuffer),
);
expect(state.audioWaveformRedrawVersion).toBeGreaterThan(initialWaveformVersion);
});
it('does not attempt audio buffer hydration for MIDI-only undo', async () => {
mockTracks = [new KGMidiTrack('Track 1', 1, 'acoustic_grand_piano')];
const { useProjectStore } = await import('./projectStore');
await act(async () => {
useProjectStore.getState().undo();
await Promise.resolve();
});
expect(audioStorageMocks.loadAudioFile).not.toHaveBeenCalled();
expect(mockAudioInterface.loadAudioBufferForTrack).not.toHaveBeenCalled();
});
it('restores Chat after closing Settings when Chat was active on entry', async () => {
const { useProjectStore } = await import('./projectStore');