Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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