Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions apps/desktop/src/stt/capture-lifecycle-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ test("loads a valid capture marker", async () => {
);
});

test("preserves automatic capture provenance and its pre-recording audio state", async () => {
const automaticMarker = {
...marker,
automatic: true,
preserveExistingAudio: false,
initialTitle: "Standup",
};
mocks.execute.mockResolvedValue([
{ value_json: JSON.stringify(automaticMarker) },
]);
await expect(loadCaptureLifecycleMarker("session-1")).resolves.toEqual(
automaticMarker,
);
});

test("loads the exact durable summary recovery mode", async () => {
const summaryMarker = {
...marker,
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/stt/capture-lifecycle-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export type CaptureLifecycleMarker = {
createdAt: string;
audioOffsetMs: number;
preserveExistingTranscript: boolean;
automatic?: boolean;
preserveExistingAudio?: boolean;
initialTitle?: string;
ownerUserId: string;
memo: string;
provider?: string;
Expand Down Expand Up @@ -154,6 +157,15 @@ function parseCaptureLifecycleMarker(
createdAt: parsed.createdAt,
audioOffsetMs: Math.max(0, parsed.audioOffsetMs),
preserveExistingTranscript: parsed.preserveExistingTranscript,
...(typeof parsed.automatic === "boolean"
? { automatic: parsed.automatic }
: {}),
...(typeof parsed.preserveExistingAudio === "boolean"
? { preserveExistingAudio: parsed.preserveExistingAudio }
: {}),
...(typeof parsed.initialTitle === "string"
? { initialTitle: parsed.initialTitle }
: {}),
ownerUserId: parsed.ownerUserId,
memo: parsed.memo,
...(parsed.phase === "capturing" || parsed.phase === "finalizing"
Expand Down
60 changes: 52 additions & 8 deletions apps/desktop/src/stt/capture-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync";
import { sonnerToast } from "@anlg/ui/components/ui/toast";

import { useListener } from "./contexts";
import { discardEmptyAutomaticCapture } from "./empty-automatic-capture";
import { cancelMeetingRecordingDisclosure } from "./meeting-disclosure";
import { persistTranscriptWrite } from "./persist-retry";
import { createTranscriptPersistenceWorker } from "./transcript-persistence-worker";
Expand Down Expand Up @@ -204,7 +205,24 @@ export function useCaptureLifecycle(sessionId: string) {
);

const createCaptureLifecycle = useCallback(
(recoveredMarker?: CaptureLifecycleMarker) => {
(
recoveredMarker?: CaptureLifecycleMarker,
startedAutomatically = false,
) => {
const automatic = recoveredMarker
? recoveredMarker.automatic === true
: startedAutomatically;
const initialTitle = recoveredMarker
? recoveredMarker.initialTitle
: session?.title;
const existingAudioPromise = recoveredMarker
? Promise.resolve(recoveredMarker.preserveExistingAudio ?? true)
: automatic
? fsSyncCommands
.audioExist(sessionId)
.then((result) => (result.status === "ok" ? result.data : true))
.catch(() => true)
: Promise.resolve(true);
const transcriptId = recoveredMarker?.transcriptId ?? id();
let transcriptCreated: boolean | null = recoveredMarker ? null : false;
let transcriptTouched = false;
Expand Down Expand Up @@ -401,6 +419,9 @@ export function useCaptureLifecycle(sessionId: string) {
createdAt,
audioOffsetMs: await existingAudioDurationPromise,
preserveExistingTranscript,
automatic,
preserveExistingAudio: await existingAudioPromise,
initialTitle,
ownerUserId,
memo: memoMd,
...(provider ? { provider } : {}),
Expand Down Expand Up @@ -478,6 +499,32 @@ export function useCaptureLifecycle(sessionId: string) {
};
cancelMeetingRecordingDisclosure(sessionId);
await stopMeetingChatTasks();
await transcriptPersistence.flush();
if (
details.audioPath &&
(await discardEmptyAutomaticCapture({
sessionId,
automatic,
preserveExistingAudio: await existingAudioPromise,
preserveExistingTranscript,
initialTitle,
transcriptTouched,
transcriptionComplete:
(!details.requestedLiveTranscription ||
details.liveTranscriptionActive) &&
!details.needsBatchRepair &&
!transcriptWriteError,
}))
) {
await clearCaptureLifecycleMarker(sessionId, transcriptId);
recoveryPending = false;
recoveryStateCleared = true;
return;
}
trackSessionCompletion(
details,
recoveredMarker ? "recovered_capture_stopped" : "capture_stopped",
);
if (details.audioPath) {
try {
await enqueueSessionAudioOperation(sessionId, () =>
Expand All @@ -487,7 +534,6 @@ export function useCaptureLifecycle(sessionId: string) {
console.error("[listener] failed to catalog recorded audio", error);
}
}
await transcriptPersistence.flush();
transcriptCreated ??= await transcriptExists(transcriptId);
const useLocalBatchForSpeakerDiarization =
shouldUseLocalBatchForSpeakerDiarization();
Expand Down Expand Up @@ -844,10 +890,6 @@ export function useCaptureLifecycle(sessionId: string) {
}
};
const onStopped: OnStoppedCallback = (_sessionId, details) => {
trackSessionCompletion(
details,
recoveredMarker ? "recovered_capture_stopped" : "capture_stopped",
);
recoveryPending = false;
markExpectedPostStopBatch(details);
return finalizeStopped(details, true);
Expand All @@ -867,7 +909,6 @@ export function useCaptureLifecycle(sessionId: string) {
}
};
const recoverStopped: OnStoppedCallback = (_sessionId, details) => {
trackSessionCompletion(details, "recovered_capture_stopped");
markExpectedPostStopBatch(details);
return finalizeStopped(details, false);
};
Expand All @@ -887,7 +928,10 @@ export function useCaptureLifecycle(sessionId: string) {
handlePersist,
onStopped,
recoverStopped,
ready: existingAudioDurationPromise.then(() => undefined),
ready: Promise.all([
existingAudioDurationPromise,
existingAudioPromise,
]).then(() => undefined),
persistMarker: async () => {
await persistTranscriptWrite(async () => {
await saveCaptureLifecycleMarker(await marker());
Expand Down
119 changes: 119 additions & 0 deletions apps/desktop/src/stt/empty-automatic-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { beforeEach, expect, it, vi } from "vitest";

import { discardEmptyAutomaticCapture } from "./empty-automatic-capture";

const mocks = vi.hoisted(() => ({
speech: vi.fn(),
remove: vi.fn(),
execute: vi.fn(),
empty: vi.fn(),
flush: vi.fn(),
}));
vi.mock("@anlg/plugin-fs-sync", () => ({
commands: {
audioHasSpeech: mocks.speech,
audioDelete: mocks.remove,
},
}));
vi.mock("~/db", () => ({ liveQueryClient: { execute: mocks.execute } }));
vi.mock("~/session/queries", () => ({ isSessionEmpty: mocks.empty }));
vi.mock("~/session-sharing/editor-activity", () => ({
flushCanonicalSessionEditorChanges: mocks.flush,
}));
vi.mock("~/session/audio-operations", () => ({
enqueueSessionAudioOperation: (_: string, operation: () => unknown) =>
operation(),
}));

const input = {
sessionId: "meeting",
automatic: true,
preserveExistingAudio: false,
preserveExistingTranscript: false,
initialTitle: "Standup",
transcriptTouched: false,
transcriptionComplete: true,
};

beforeEach(() => {
vi.resetAllMocks();
mocks.speech.mockResolvedValue({ status: "ok", data: false });
mocks.remove.mockResolvedValue({ status: "ok", data: true });
mocks.execute.mockResolvedValue([{ title: "Standup", has_attachments: 0 }]);
mocks.empty.mockResolvedValue(true);
mocks.flush.mockResolvedValue(undefined);
});

it.each([0, false])(
"discards silent automatic audio when the attachment flag is %j",
async (hasAttachments) => {
mocks.execute.mockResolvedValue([
{ title: "Standup", has_attachments: hasAttachments },
]);
expect(await discardEmptyAutomaticCapture(input)).toBe(true);
expect(mocks.remove).toHaveBeenCalledWith("meeting");
expect(mocks.execute).toHaveBeenCalledTimes(1);
expect(mocks.execute.mock.calls[0][0]).toMatch(/^SELECT /);
},
);

it.each([
{ automatic: false },
{ preserveExistingAudio: true },
{ preserveExistingTranscript: true },
{ transcriptTouched: true },
{ transcriptionComplete: false },
{ initialTitle: undefined },
])("keeps captures that cannot safely be discarded: %j", async (override) => {
expect(await discardEmptyAutomaticCapture({ ...input, ...override })).toBe(
false,
);
expect(mocks.speech).not.toHaveBeenCalled();
expect(mocks.remove).not.toHaveBeenCalled();
});

it.each([
{ status: "ok", data: true },
{ status: "error", error: "decoder failed" },
])("keeps speech and uncertain analysis: %j", async (result) => {
mocks.speech.mockResolvedValue(result);
expect(await discardEmptyAutomaticCapture(input)).toBe(false);
expect(mocks.remove).not.toHaveBeenCalled();
});

it("keeps recordings when the user adds notes", async () => {
mocks.empty.mockResolvedValue(false);
expect(await discardEmptyAutomaticCapture(input)).toBe(false);
expect(mocks.remove).not.toHaveBeenCalled();
});

it.each([1, true, null, undefined, "0"])(
"keeps audio when the attachment flag is present or uncertain: %j",
async (hasAttachments) => {
mocks.execute.mockResolvedValue([
{ title: "Standup", has_attachments: hasAttachments },
]);
expect(await discardEmptyAutomaticCapture(input)).toBe(false);
expect(mocks.remove).not.toHaveBeenCalled();
},
);

it("flushes edits made during audio analysis before deciding whether to discard", async () => {
mocks.speech.mockImplementation(async () => {
mocks.flush.mockImplementation(async () => {
mocks.empty.mockResolvedValue(false);
});
return { status: "ok", data: false };
});
expect(await discardEmptyAutomaticCapture(input)).toBe(false);
expect(mocks.remove).not.toHaveBeenCalled();
});

it.each([{ rows: [{ title: "Renamed by the user" }] }, { rows: [] }])(
"keeps renamed or deleted sessions",
async ({ rows }) => {
mocks.execute.mockResolvedValue(rows);
expect(await discardEmptyAutomaticCapture(input)).toBe(false);
expect(mocks.remove).not.toHaveBeenCalled();
},
);
72 changes: 72 additions & 0 deletions apps/desktop/src/stt/empty-automatic-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { commands as fsSyncCommands } from "@anlg/plugin-fs-sync";

import { liveQueryClient } from "~/db";
import { flushCanonicalSessionEditorChanges } from "~/session-sharing/editor-activity";
import { enqueueSessionAudioOperation } from "~/session/audio-operations";
import { isSessionEmpty } from "~/session/queries";

export async function discardEmptyAutomaticCapture({
sessionId,
automatic,
preserveExistingAudio,
preserveExistingTranscript,
initialTitle,
transcriptTouched,
transcriptionComplete,
}: {
sessionId: string;
automatic: boolean;
preserveExistingAudio: boolean;
preserveExistingTranscript: boolean;
initialTitle: string | undefined;
transcriptTouched: boolean;
transcriptionComplete: boolean;
}): Promise<boolean> {
if (
!automatic ||
preserveExistingAudio ||
preserveExistingTranscript ||
initialTitle === undefined ||
transcriptTouched ||
!transcriptionComplete
) {
return false;
}
try {
await flushCanonicalSessionEditorChanges(sessionId);
return await enqueueSessionAudioOperation(sessionId, async () => {
const speech = await fsSyncCommands.audioHasSpeech(sessionId);
if (speech.status !== "ok" || speech.data) return false;
await flushCanonicalSessionEditorChanges(sessionId);
const [session] = await liveQueryClient.execute<{
title: string;
has_attachments: boolean | number;
}>(
`SELECT title, EXISTS (
SELECT 1 FROM session_attachments
WHERE session_id = sessions.id AND deleted_at IS NULL
) AS has_attachments
FROM sessions WHERE id = ? AND deleted_at IS NULL`,
[sessionId],
);
if (
!session ||
session.title !== initialTitle ||
(session.has_attachments !== 0 && session.has_attachments !== false) ||
!(await isSessionEmpty(sessionId))
Comment thread
cursor[bot] marked this conversation as resolved.
) {
return false;
}
// Only remove this device's un-catalogued audio. The calendar note and
// anything another device has contributed stay in the shared database.
const result = await fsSyncCommands.audioDelete(sessionId);
return result.status === "ok" && result.data;
});
} catch (error) {
console.warn(
"[listener] keeping automatic capture after an inconclusive activity check",
error,
);
return false;
}
}
5 changes: 4 additions & 1 deletion apps/desktop/src/stt/scheduled-session-auto-start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ function PendingScheduledSessionAutoStart({
}

function ReadyScheduledSessionAutoStart({ sessionId }: { sessionId: string }) {
const { connectionReady, startListening } = useStartListeningState(sessionId);
const { connectionReady, startListening } = useStartListeningState(
sessionId,
{ automatic: true },
);
const attemptedRef = useRef(false);

useMountEffect(() => {
Expand Down
Loading
Loading