Skip to content

Commit 47b2f11

Browse files
committed
fix(capture): clean up stale audio submissions
1 parent b53e89a commit 47b2f11

7 files changed

Lines changed: 255 additions & 16 deletions

File tree

apps/desktop/src/components/QuickAddModal.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,8 @@ describe('QuickAddModal', () => {
13051305

13061306
expect(addTask).not.toHaveBeenCalled();
13071307
expect(screen.getByPlaceholderText('Add Task')).toHaveValue('Second capture');
1308+
expect(fsMocks.remove).toHaveBeenCalledWith('/data/audio-a.wav');
1309+
expect(fsMocks.remove).toHaveBeenCalledTimes(1);
13081310
});
13091311

13101312
it('cancels an active audio recorder when the modal owner unmounts', async () => {

apps/desktop/src/components/QuickAddModal.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,6 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
649649
const audioSession = await queueRecordingDeviceOperation(async () => {
650650
if (!isStartCurrent()) return null;
651651
const acquired = await startAudioCapture({
652-
defaultName: () => `mindwtr-audio-${safeFormatDate(new Date(), 'yyyyMMdd-HHmmss')}.wav`,
653652
isCurrent: isStartCurrent,
654653
});
655654
if (!isStartCurrent()) {
@@ -702,6 +701,8 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
702701
const isSubmissionCurrent = () => (
703702
captureSurfaceSession === null || submissionCoordinatorRef.current.isCurrent(captureSurfaceSession)
704703
);
704+
let stoppedCapturePath: string | null = null;
705+
let stoppedCaptureAdopted = false;
705706
setRecordingBusy(true);
706707
setIsRecording(false);
707708
const audioSession = captureSessionRef.current;
@@ -715,6 +716,7 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
715716
}
716717

717718
const capture = await queueRecordingDeviceOperation(() => audioSession.stop());
719+
stoppedCapturePath = capture.path;
718720
if (!isSubmissionCurrent()) return;
719721
const fileName = capture.name;
720722
const absolutePath = capture.path;
@@ -753,6 +755,7 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
753755
}
754756
if (!isSubmissionCurrent()) return;
755757
const addTaskResult = await addTask(displayTitle, props);
758+
if (addTaskResult.success && addTaskResult.id) stoppedCaptureAdopted = true;
756759
if (!isSubmissionCurrent()) return;
757760
if (addTaskResult.success && standaloneWindow) {
758761
await flushPendingSave().catch((error) => reportError('Failed to save quick add task', error));
@@ -827,6 +830,14 @@ export function QuickAddModal({ standaloneWindow = false }: QuickAddModalProps)
827830
const message = error instanceof Error ? error.message : String(error);
828831
setRecordingError(`${t('quickAdd.audioErrorBody')} (${message})`);
829832
} finally {
833+
if (stoppedCapturePath && !stoppedCaptureAdopted && !isSubmissionCurrent()) {
834+
await remove(stoppedCapturePath).catch((error) => {
835+
void logWarn('Stale audio cleanup failed', {
836+
scope: 'audio',
837+
extra: { error: error instanceof Error ? error.message : String(error) },
838+
});
839+
});
840+
}
830841
if (submissionSession === null) {
831842
if (captureSurfaceSession === null || submissionCoordinatorRef.current.isCurrent(captureSurfaceSession)) {
832843
setRecordingBusy(false);

apps/desktop/src/lib/audio-capture.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ beforeEach(() => {
5656
});
5757

5858
afterEach(() => {
59+
vi.useRealTimers();
5960
delete (window as any).__TAURI_INTERNALS__;
6061
delete (navigator as any).mediaDevices;
6162
delete (window as any).AudioContext;
@@ -107,6 +108,23 @@ describe('startAudioCapture — native backend', () => {
107108
});
108109

109110
describe('startAudioCapture — fallback', () => {
111+
it('uses distinct default paths for captures stopped in the same second', async () => {
112+
vi.useFakeTimers();
113+
vi.setSystemTime(new Date('2026-08-31T12:00:00.000Z'));
114+
const firstGraph = installFakeWebAudio();
115+
const firstSession = await startAudioCapture();
116+
pushSamples(firstGraph.processor, new Float32Array([0.1]));
117+
const firstCapture = await firstSession.stop();
118+
119+
const secondGraph = installFakeWebAudio();
120+
const secondSession = await startAudioCapture();
121+
pushSamples(secondGraph.processor, new Float32Array([0.1]));
122+
const secondCapture = await secondSession.stop();
123+
124+
expect(firstCapture.path).not.toBe(secondCapture.path);
125+
expect(firstCapture.name).not.toBe(secondCapture.name);
126+
});
127+
110128
it('falls back to web capture when the native recorder refuses to start', async () => {
111129
// This is the defect the shared module fixes: the task editor used to
112130
// hard-fail here instead of recording through the page.

apps/desktop/src/lib/audio-capture.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { join } from '@tauri-apps/api/path';
22
import { mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs';
3+
import { generateUUID } from '@mindwtr/core';
34

45
import { logWarn } from './app-log';
56
import { appendAudioChunkWithLimit, getMaxAudioSamples } from './audio-capture-buffer';
@@ -243,7 +244,7 @@ export async function startAudioCapture(
243244
options: { defaultName?: () => string; isCurrent?: () => boolean } = {},
244245
): Promise<AudioCaptureSession> {
245246
const timestampedName = options.defaultName
246-
?? (() => `mindwtr-audio-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '')}.wav`);
247+
?? (() => `mindwtr-audio-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '')}-${generateUUID()}.wav`);
247248
const isCurrent = options.isCurrent ?? (() => true);
248249

249250
if (!isCurrent()) throw new Error('Audio capture start was cancelled');

apps/mobile/components/use-quick-capture-audio.test.tsx

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ const fileMocks = vi.hoisted(() => ({
6969
delete: vi.fn(),
7070
}));
7171

72+
const coreMocks = vi.hoisted(() => ({
73+
generateUUID: vi.fn(),
74+
}));
75+
7276
const appLogMock = vi.hoisted(() => ({
7377
logInfo: vi.fn(),
7478
}));
@@ -146,7 +150,7 @@ vi.mock('@mindwtr/core', async (importOriginal) => {
146150
// Only the id and clock are pinned, so attachment names stay deterministic;
147151
// `buildTaskUpdatesFromSpeechResult` runs for real.
148152
return mockCore(importOriginal, () => ({}), {
149-
generateUUID: () => 'attachment-1',
153+
generateUUID: coreMocks.generateUUID,
150154
safeFormatDate: (_value: Date | string, format: string) => {
151155
if (format === 'yyyyMMdd-HHmmss') return '20260629-090027';
152156
if (format === 'Pp') return '06/29/2026, 9:00 AM';
@@ -269,6 +273,8 @@ describe('useQuickCaptureAudio', () => {
269273
latest = null;
270274
submissionCoordinator = new CaptureSessionCoordinator();
271275
activeSubmissionSession = submissionCoordinator.beginSession();
276+
let uuidSequence = 0;
277+
coreMocks.generateUUID.mockImplementation(() => `capture-${uuidSequence += 1}`);
272278
storeMocks.state.areas = [];
273279
storeMocks.state.projects = [];
274280
storeMocks.state.settings = settings;
@@ -280,9 +286,9 @@ describe('useQuickCaptureAudio', () => {
280286
audioMocks.release.mockReturnValue(undefined);
281287
audioMocks.stop.mockResolvedValue(undefined);
282288
attachmentMocks.getAttachmentsDir.mockResolvedValue('file:///document/attachments/');
283-
attachmentMocks.persistAttachmentLocally.mockImplementation(async (attachment: { uri: string }) => ({
289+
attachmentMocks.persistAttachmentLocally.mockImplementation(async (attachment: { id: string; uri: string }) => ({
284290
...attachment,
285-
uri: 'file:///document/attachments/attachment-1.wav',
291+
uri: `file:///document/attachments/${attachment.id}.wav`,
286292
}));
287293
buildTaskProps.mockImplementation(async (fallbackTitle: string, extraProps?: Record<string, unknown>) => ({
288294
title: fallbackTitle,
@@ -339,6 +345,7 @@ describe('useQuickCaptureAudio', () => {
339345
// so this line asserted behaviour the app does not have.
340346
expect(storeMocks.updateTask).toHaveBeenCalledWith('task-1', { title: 'Buy milk' });
341347
expect(handleClose).toHaveBeenCalledOnce();
348+
expect(fileMocks.delete).not.toHaveBeenCalled();
342349
});
343350

344351
it('shows a notice and never starts the recorder when speech-to-text is unconfigured', async () => {
@@ -694,6 +701,12 @@ describe('useQuickCaptureAudio', () => {
694701
expect(stopAHandler).toHaveBeenCalledTimes(1);
695702
expect(speechMocks.startWhisperRealtimeCapture).toHaveBeenCalledTimes(2);
696703
expect(audioMocks.setAudioModeAsync).toHaveBeenCalledTimes(2);
704+
const firstOutputPath = String(speechMocks.startWhisperRealtimeCapture.mock.calls[0]?.[0]);
705+
const secondOutputPath = String(speechMocks.startWhisperRealtimeCapture.mock.calls[1]?.[0]);
706+
expect(firstOutputPath).not.toBe(secondOutputPath);
707+
expect(fileMocks.delete).toHaveBeenCalledWith(`file://${firstOutputPath}`);
708+
expect(fileMocks.delete).not.toHaveBeenCalledWith(`file://${secondOutputPath}`);
709+
expect(fileMocks.delete).toHaveBeenCalledTimes(1);
697710
expect(latest?.recording).toEqual(expect.objectContaining({ kind: 'whisper', stop: stopBHandler }));
698711
expect(latest?.recordingBusy).toBe(false);
699712
});
@@ -737,5 +750,143 @@ describe('useQuickCaptureAudio', () => {
737750
expect(handleClose).not.toHaveBeenCalled();
738751
expect(submissionCoordinator.isSubmitting(reopenedSession)).toBe(true);
739752
expect(latest?.recordingBusy).toBe(false);
753+
expect(fileMocks.delete).toHaveBeenCalledWith(
754+
'file:///document/audio-captures/mindwtr-audio-20260629-090027-capture-1.wav',
755+
);
756+
expect(fileMocks.delete).toHaveBeenCalledWith(
757+
'file:///document/attachments/capture-2.wav',
758+
);
759+
expect(fileMocks.delete).toHaveBeenCalledTimes(2);
760+
});
761+
762+
it('keeps reopened capture settings when stopped audio A resolves its model late', async () => {
763+
speechMocks.startWhisperRealtimeCapture.mockRejectedValueOnce(new Error('use Expo fallback'));
764+
let tree!: ReturnType<typeof create>;
765+
await act(async () => {
766+
tree = create(<Harness submissionKey={1} />);
767+
await flushPromises();
768+
});
769+
await act(async () => {
770+
await latest?.startRecording();
771+
await flushPromises();
772+
});
773+
774+
const modelResolution = deferred<{
775+
exists: boolean;
776+
path: string;
777+
uri: string;
778+
size: number;
779+
}>();
780+
speechMocks.ensureWhisperModelPathForConfigAsync.mockReturnValueOnce(modelResolution.promise);
781+
updateSpeechSettings.mockClear();
782+
let stopRun!: Promise<void>;
783+
await act(async () => {
784+
stopRun = latest!.stopRecording({ saveTask: true });
785+
await flushPromises();
786+
});
787+
788+
const audioSession = activeSubmissionSession!;
789+
submissionCoordinator.invalidateSession(audioSession);
790+
activeSubmissionSession = submissionCoordinator.beginSession();
791+
await act(async () => {
792+
tree.update(<Harness submissionKey={2} />);
793+
modelResolution.resolve({
794+
exists: true,
795+
path: '/document/whisper-models/reopened-b.bin',
796+
uri: 'file:///document/whisper-models/reopened-b.bin',
797+
size: 77704715,
798+
});
799+
await stopRun;
800+
await flushPromises();
801+
});
802+
803+
expect(updateSpeechSettings).not.toHaveBeenCalled();
804+
expect(addTask).not.toHaveBeenCalled();
805+
expect(fileMocks.delete).toHaveBeenCalledWith('file:///recording.m4a');
806+
expect(fileMocks.delete).toHaveBeenCalledTimes(1);
807+
});
808+
809+
it('keeps a newer speech model selected while the same audio save is resolving', async () => {
810+
speechMocks.startWhisperRealtimeCapture.mockRejectedValueOnce(new Error('use Expo fallback'));
811+
await act(async () => {
812+
create(<Harness />);
813+
await flushPromises();
814+
});
815+
await act(async () => {
816+
await latest?.startRecording();
817+
await flushPromises();
818+
});
819+
820+
const modelResolution = deferred<{
821+
exists: boolean;
822+
path: string;
823+
uri: string;
824+
size: number;
825+
}>();
826+
speechMocks.ensureWhisperModelPathForConfigAsync.mockReturnValueOnce(modelResolution.promise);
827+
updateSpeechSettings.mockClear();
828+
let stopRun!: Promise<void>;
829+
await act(async () => {
830+
stopRun = latest!.stopRecording({ saveTask: true });
831+
await flushPromises();
832+
});
833+
834+
storeMocks.state.settings = {
835+
...settings,
836+
ai: {
837+
speechToText: {
838+
...settings.ai.speechToText,
839+
model: 'whisper-base',
840+
offlineModelPath: 'file:///document/whisper-models/newer-b.bin',
841+
},
842+
},
843+
};
844+
await act(async () => {
845+
modelResolution.resolve({
846+
exists: true,
847+
path: '/document/whisper-models/obsolete-a.bin',
848+
uri: 'file:///document/whisper-models/obsolete-a.bin',
849+
size: 77704715,
850+
});
851+
await stopRun;
852+
await flushPromises();
853+
});
854+
855+
expect(updateSpeechSettings).not.toHaveBeenCalled();
856+
expect(addTask).toHaveBeenCalledTimes(1);
857+
});
858+
859+
it('does not persist a model path from a canceled Whisper preload', async () => {
860+
const modelResolution = deferred<{
861+
exists: boolean;
862+
path: string;
863+
uri: string;
864+
size: number;
865+
}>();
866+
speechMocks.ensureWhisperModelPathForConfigAsync.mockReturnValueOnce(modelResolution.promise);
867+
let tree!: ReturnType<typeof create>;
868+
await act(async () => {
869+
tree = create(<Harness />);
870+
await flushPromises();
871+
});
872+
updateSpeechSettings.mockClear();
873+
874+
await act(async () => {
875+
tree.unmount();
876+
await flushPromises();
877+
});
878+
await act(async () => {
879+
modelResolution.resolve({
880+
exists: true,
881+
path: '/document/whisper-models/obsolete-a.bin',
882+
uri: 'file:///document/whisper-models/obsolete-a.bin',
883+
size: 77704715,
884+
});
885+
await modelResolution.promise;
886+
await flushPromises();
887+
});
888+
889+
expect(updateSpeechSettings).not.toHaveBeenCalled();
890+
expect(speechMocks.preloadWhisperContext).not.toHaveBeenCalled();
740891
});
741892
});

0 commit comments

Comments
 (0)