Skip to content

Commit 0c7905b

Browse files
authored
Add recoverable listener session state
Add persisted listener session state for local STT sessions, keep chunk retry metadata available internally, and remove the debug chunk review UI from the Transcript tab.
1 parent 56ac831 commit 0c7905b

5 files changed

Lines changed: 243 additions & 5 deletions

File tree

apps/desktop/main.cjs

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const DEFAULT_AUDIO_DEVICE_ID = "default";
5555
const LOCAL_STT_STREAM_CHUNK_SECONDS = 5;
5656
const STT_CHUNK_LEDGER_FILE_NAME = "stt-chunks.jsonl";
5757
const TRANSCRIPT_RUNTIME_METADATA_FIELDS = ["sessionID", "chunkID", "eventType", "retryState"];
58+
const RECOVERABLE_LISTENER_SESSION_STATES = new Set(["starting", "recording", "stopping", "finalizing", "recovering"]);
5859

5960
const LOCAL_STT_MODELS = [
6061
{
@@ -143,6 +144,8 @@ const STT_MODEL_DOWNLOAD_CANCELLED_MESSAGE = "Model download cancelled.";
143144
let menuBarIconPulseTimer;
144145
let menuBarIconPulseBright = true;
145146
let currentMenuBarIconSignature = "";
147+
let activeListenerSessionNoteId = null;
148+
const listenerSessionWriteQueues = new Map();
146149
let menuBarState = {
147150
captureState: "idle",
148151
detail: "",
@@ -1314,6 +1317,138 @@ function normalizeArtifactBundle(bundle) {
13141317
};
13151318
}
13161319

1320+
function normalizeListenerSessionState(value) {
1321+
switch (value) {
1322+
case "starting":
1323+
case "recording":
1324+
case "stopping":
1325+
case "recovering":
1326+
case "failed":
1327+
case "completed":
1328+
return value;
1329+
case "capturing":
1330+
case "degraded":
1331+
return "recording";
1332+
case "finalizing":
1333+
return "stopping";
1334+
default:
1335+
return null;
1336+
}
1337+
}
1338+
1339+
function listenerSessionFromMetadata(metadata, noteId) {
1340+
const raw = metadata && typeof metadata.listenerSession === "object" ? metadata.listenerSession : null;
1341+
if (!raw) {
1342+
return null;
1343+
}
1344+
const state = normalizeListenerSessionState(raw.state);
1345+
if (!state) {
1346+
return null;
1347+
}
1348+
const recoveredState = activeListenerSessionNoteId === noteId || !RECOVERABLE_LISTENER_SESSION_STATES.has(state)
1349+
? state
1350+
: "recovering";
1351+
return {
1352+
state: recoveredState,
1353+
startedAt: normalizeDate(raw.startedAt)?.toISOString() || null,
1354+
updatedAt: normalizeDate(raw.updatedAt)?.toISOString() || null,
1355+
endedAt: normalizeDate(raw.endedAt)?.toISOString() || null,
1356+
detail: optionalNonEmptyString(raw.detail),
1357+
error: optionalNonEmptyString(raw.error),
1358+
};
1359+
}
1360+
1361+
async function writeListenerSessionState(noteId, patch) {
1362+
if (!noteId) {
1363+
return;
1364+
}
1365+
const directoryPath = resolveBundleDirectory(noteId);
1366+
const paths = bundlePaths(directoryPath);
1367+
const metadata = await readJSONIfExists(paths.metadataPath) || {};
1368+
const currentSession = metadata.listenerSession && typeof metadata.listenerSession === "object"
1369+
? metadata.listenerSession
1370+
: {};
1371+
const nextState = normalizeListenerSessionState(patch.state) || normalizeListenerSessionState(currentSession.state) || "starting";
1372+
const now = new Date().toISOString();
1373+
const nextSession = {
1374+
...currentSession,
1375+
state: nextState,
1376+
startedAt: currentSession.startedAt || metadata.startedAt || now,
1377+
updatedAt: now,
1378+
};
1379+
if (patch.detail !== undefined) {
1380+
nextSession.detail = optionalNonEmptyString(patch.detail);
1381+
}
1382+
if (patch.error !== undefined) {
1383+
nextSession.error = optionalNonEmptyString(patch.error);
1384+
}
1385+
if (nextState === "completed" || nextState === "failed") {
1386+
nextSession.endedAt = currentSession.endedAt || metadata.endedAt || now;
1387+
} else {
1388+
delete nextSession.endedAt;
1389+
}
1390+
metadata.listenerSession = nextSession;
1391+
await writeJSONFileAtomic(paths.metadataPath, metadata);
1392+
await syncNotesDatabaseFromFiles([noteId]);
1393+
}
1394+
1395+
function enqueueListenerSessionStateWrite(noteId, patch, context) {
1396+
if (!noteId) {
1397+
return Promise.resolve();
1398+
}
1399+
const previousWrite = listenerSessionWriteQueues.get(noteId) || Promise.resolve();
1400+
const write = previousWrite
1401+
.catch(() => {})
1402+
.then(() => writeListenerSessionState(noteId, patch));
1403+
listenerSessionWriteQueues.set(noteId, write);
1404+
write
1405+
.catch((error) => {
1406+
console.error(`Failed to persist listener session ${context}:`, error);
1407+
})
1408+
.finally(() => {
1409+
if (listenerSessionWriteQueues.get(noteId) === write) {
1410+
listenerSessionWriteQueues.delete(noteId);
1411+
}
1412+
});
1413+
return write;
1414+
}
1415+
1416+
function noteIdFromCaptureEvent(event) {
1417+
return optionalNonEmptyString(event?.bundleId) || optionalNonEmptyString(event?.artifactBundle?.id) || activeListenerSessionNoteId;
1418+
}
1419+
1420+
function persistCaptureEventSessionState(event) {
1421+
const noteId = noteIdFromCaptureEvent(event);
1422+
if (!noteId) {
1423+
return;
1424+
}
1425+
if (event.kind === "artifactBundlePrepared") {
1426+
activeListenerSessionNoteId = noteId;
1427+
enqueueListenerSessionStateWrite(noteId, { state: "starting", detail: "Preparing local capture." }, "start");
1428+
return;
1429+
}
1430+
if (event.kind === "stateChanged") {
1431+
const state = normalizeListenerSessionState(event.state);
1432+
if (!state) {
1433+
return;
1434+
}
1435+
if (state === "recording") {
1436+
activeListenerSessionNoteId = noteId;
1437+
}
1438+
enqueueListenerSessionStateWrite(noteId, { state, detail: event.detail }, "state");
1439+
return;
1440+
}
1441+
if (event.kind === "failed") {
1442+
enqueueListenerSessionStateWrite(noteId, { state: "failed", error: event.errorMessage }, "failure");
1443+
activeListenerSessionNoteId = null;
1444+
return;
1445+
}
1446+
if (event.kind === "finished") {
1447+
enqueueListenerSessionStateWrite(noteId, { state: "completed" }, "completion");
1448+
activeListenerSessionNoteId = null;
1449+
}
1450+
}
1451+
13171452
function resolveBundleDirectory(id) {
13181453
if (typeof id !== "string" || id.trim().length === 0) {
13191454
throw new Error("A note id is required.");
@@ -1679,6 +1814,7 @@ async function loadBundleSnapshot(directoryName) {
16791814
transcriptPath: paths.transcriptPath,
16801815
metadataPath: paths.metadataPath,
16811816
recordingPath: paths.recordingPath,
1817+
listenerSession: listenerSessionFromMetadata(metadata, directoryName),
16821818
};
16831819

16841820
return {
@@ -1691,6 +1827,7 @@ async function loadBundleSnapshot(directoryName) {
16911827
}
16921828

16931829
function noteSummaryFromRow(row) {
1830+
const metadata = parseMetadataJSON(row.metadata_json);
16941831
return {
16951832
id: row.id,
16961833
title: row.title,
@@ -1707,6 +1844,7 @@ function noteSummaryFromRow(row) {
17071844
transcriptPath: row.transcript_path,
17081845
metadataPath: row.metadata_path,
17091846
recordingPath: row.recording_path,
1847+
listenerSession: listenerSessionFromMetadata(metadata, row.id),
17101848
};
17111849
}
17121850

@@ -2291,6 +2429,8 @@ async function readNote(id) {
22912429
}
22922430
return normalizedSegment;
22932431
});
2432+
const directoryPath = resolveBundleDirectory(row.id);
2433+
const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME))));
22942434
const speakerLabels = database.prepare(`
22952435
SELECT
22962436
speaker_id,
@@ -2310,11 +2450,12 @@ async function readNote(id) {
23102450

23112451
return {
23122452
...noteSummaryFromRow(row),
2313-
directoryPath: resolveBundleDirectory(row.id),
2453+
directoryPath,
23142454
markdown: row.markdown,
23152455
metadata: parseMetadataJSON(row.metadata_json),
23162456
recordingURL: recordingURLForNote(row.id, row.recording_path),
23172457
transcriptSegments,
2458+
sttChunks,
23182459
speakerLabels,
23192460
transcriptText: transcriptSegments.map((segment) => segment.text).join("\n\n"),
23202461
};
@@ -2542,6 +2683,8 @@ function parseSTTChunkLedgerJSONL(raw) {
25422683
sampleCount: Number.isFinite(Number(record.sampleCount)) ? Number(record.sampleCount) : 0,
25432684
segmentCount: Number.isFinite(Number(record.segmentCount)) ? Number(record.segmentCount) : null,
25442685
error: typeof record.error === "string" ? record.error : null,
2686+
retryState: optionalNonEmptyString(record.retryState),
2687+
recordedAt: normalizeDate(record.recordedAt)?.toISOString() || null,
25452688
});
25462689
} catch {
25472690
// Ignore malformed ledger rows; the transcript retry path can fall back to whole-track retry.
@@ -2550,6 +2693,19 @@ function parseSTTChunkLedgerJSONL(raw) {
25502693
return records;
25512694
}
25522695

2696+
function latestSTTChunks(records) {
2697+
const latestByChunk = new Map();
2698+
for (const record of records) {
2699+
latestByChunk.set(record.chunkID, record);
2700+
}
2701+
return [...latestByChunk.values()].sort((a, b) => {
2702+
if (a.startTimeSeconds === b.startTimeSeconds) {
2703+
return a.chunkID.localeCompare(b.chunkID);
2704+
}
2705+
return a.startTimeSeconds - b.startTimeSeconds;
2706+
});
2707+
}
2708+
25532709
function latestFailedSTTChunks(records) {
25542710
return latestRetryableSTTChunks(records, new Set()).filter((record) => record.status === "failed");
25552711
}
@@ -3218,6 +3374,7 @@ function applyCaptureEventToMenuBar(event) {
32183374

32193375
function broadcastCaptureEvent(event) {
32203376
const normalized = normalizeNativeEvent(event);
3377+
persistCaptureEventSessionState(normalized);
32213378
applyCaptureEventToMenuBar(normalized);
32223379
for (const window of BrowserWindow.getAllWindows()) {
32233380
if (!window.isDestroyed()) {
@@ -4004,9 +4161,21 @@ async function handleStartCapture(title, bundleId) {
40044161
setMenuBarCaptureState("starting", "", "");
40054162
try {
40064163
const payload = await startNativeCapture(title, bundleId);
4164+
if (payload.artifactBundle?.id) {
4165+
activeListenerSessionNoteId = payload.artifactBundle.id;
4166+
enqueueListenerSessionStateWrite(payload.artifactBundle.id, { state: "recording", detail: "" }, "recording");
4167+
}
40074168
setMenuBarCaptureState("capturing", "", "");
40084169
return payload;
40094170
} catch (error) {
4171+
if (activeListenerSessionNoteId) {
4172+
await enqueueListenerSessionStateWrite(
4173+
activeListenerSessionNoteId,
4174+
{ state: "failed", error: messageForUnknownError(error) },
4175+
"start failure",
4176+
).catch(() => {});
4177+
activeListenerSessionNoteId = null;
4178+
}
40104179
setMenuBarCaptureState("failed", "", messageForUnknownError(error));
40114180
throw error;
40124181
}
@@ -4015,10 +4184,26 @@ async function handleStartCapture(title, bundleId) {
40154184
async function handleStopCapture() {
40164185
setMenuBarCaptureState("stopping", "Finalizing transcript...", "");
40174186
try {
4187+
const stoppingNoteId = activeListenerSessionNoteId;
4188+
if (stoppingNoteId) {
4189+
enqueueListenerSessionStateWrite(stoppingNoteId, { state: "stopping", detail: "Finalizing transcript." }, "stopping");
4190+
}
40184191
const payload = await nativeCapture.request("stopSession");
4192+
if (stoppingNoteId) {
4193+
await enqueueListenerSessionStateWrite(stoppingNoteId, { state: "completed", detail: "" }, "completion").catch(() => {});
4194+
activeListenerSessionNoteId = null;
4195+
}
40194196
setMenuBarCaptureState("completed", "", "");
40204197
return payload;
40214198
} catch (error) {
4199+
if (activeListenerSessionNoteId) {
4200+
await enqueueListenerSessionStateWrite(
4201+
activeListenerSessionNoteId,
4202+
{ state: "failed", error: messageForUnknownError(error) },
4203+
"stop failure",
4204+
).catch(() => {});
4205+
activeListenerSessionNoteId = null;
4206+
}
40224207
setMenuBarCaptureState("failed", "", messageForUnknownError(error));
40234208
throw error;
40244209
}
@@ -4353,6 +4538,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
43534538
mergeSummarySettingsPatch,
43544539
normalizeCustomSummaryTemplate,
43554540
normalizeCustomSummaryTemplates,
4541+
normalizeListenerSessionState,
43564542
normalizeSummarySettings,
43574543
nativeHelperWorkingDirectory,
43584544
normalizeTranscriptSegments,
@@ -4376,6 +4562,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
43764562
summaryTemplatesForSettings,
43774563
serializeTranscriptJSONL,
43784564
latestFailedSTTChunks,
4565+
latestSTTChunks,
43794566
latestRetryableSTTChunks,
43804567
stripLeadingBlankLineSeparatorsMain,
43814568
stripMarkdownFrontmatterMain,

apps/desktop/main/native-launch.node-test.cjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ process.env.MIRROR_NOTE_TEST_EXPORTS = "1";
44

55
const {
66
latestFailedSTTChunks,
7+
latestSTTChunks,
78
latestRetryableSTTChunks,
89
nativeHelperWorkingDirectory,
10+
normalizeListenerSessionState,
911
normalizeTranscriptSegments,
1012
parseSTTChunkLedgerJSONL,
1113
parseTranscriptJSONL,
1214
serializeTranscriptJSONL,
1315
} = require("../main.cjs");
1416

1517
assert.equal(nativeHelperWorkingDirectory(), process.cwd());
18+
assert.equal(normalizeListenerSessionState("capturing"), "recording");
19+
assert.equal(normalizeListenerSessionState("finalizing"), "stopping");
20+
assert.equal(normalizeListenerSessionState("completed"), "completed");
21+
assert.equal(normalizeListenerSessionState("unknown"), null);
1622

1723
const runtimeSegment = {
1824
id: "seg-1",
@@ -60,6 +66,8 @@ const ledgerRecords = parseSTTChunkLedgerJSONL([
6066
endTimeSeconds: 4,
6167
sampleCount: 16000,
6268
segmentCount: 1,
69+
retryState: "completed",
70+
recordedAt: "2026-05-04T00:00:00.000Z",
6371
}),
6472
].join("\n"));
6573
assert.deepEqual(latestFailedSTTChunks(ledgerRecords).map((record) => record.chunkID), [
@@ -69,6 +77,12 @@ assert.deepEqual(
6977
latestRetryableSTTChunks(ledgerRecords, new Set(["session-a:system:1"])).map((record) => record.chunkID),
7078
["session-a:system:1"],
7179
);
80+
assert.deepEqual(latestSTTChunks(ledgerRecords).map((record) => `${record.chunkID}:${record.status}`), [
81+
"session-a:microphone:0:failed",
82+
"session-a:system:1:completed",
83+
]);
84+
assert.equal(latestSTTChunks(ledgerRecords)[1].retryState, "completed");
85+
assert.equal(latestSTTChunks(ledgerRecords)[1].recordedAt, "2026-05-04T00:00:00.000Z");
7286

7387
const interruptedLedgerRecords = parseSTTChunkLedgerJSONL([
7488
JSON.stringify({

apps/desktop/preload.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ contextBridge.exposeInMainWorld("mirrorNote", {
88
testSummaryProvider: () => ipcRenderer.invoke("summary:test-provider"),
99
listSummaryModels: (provider) => ipcRenderer.invoke("summary:list-models", provider),
1010
saveTranscript: (id, segments) => ipcRenderer.invoke("transcript:save", id, segments),
11-
retryTranscript: (id) => ipcRenderer.invoke("transcript:retry", id),
11+
retryTranscript: (id, options) => ipcRenderer.invoke("transcript:retry", id, options),
1212
uploadTranscriptAsset: (id) => ipcRenderer.invoke("transcript:upload-asset", id),
1313
uploadRecordingAsset: (id) => ipcRenderer.invoke("recording:upload-asset", id),
1414
uploadMarkdownNote: (id) => ipcRenderer.invoke("notes:upload-markdown", id),

apps/desktop/src/App.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,11 @@ export function App() {
749749
if (typeof segment.speakerLabel === "string" && segment.speakerLabel.trim()) {
750750
normalized.speakerLabel = segment.speakerLabel.trim();
751751
}
752+
for (const field of ["sessionID", "chunkID", "eventType", "retryState"] as const) {
753+
if (typeof segment[field] === "string" && segment[field].trim()) {
754+
normalized[field] = segment[field].trim();
755+
}
756+
}
752757
if (!normalized.text) {
753758
return;
754759
}
@@ -1037,7 +1042,7 @@ export function App() {
10371042
}
10381043
}
10391044

1040-
async function retryTranscript() {
1045+
async function retryTranscript(chunkIDs?: string[]) {
10411046
const note = selectedNoteRef.current;
10421047
if (!note || retryingTranscriptId === note.id) {
10431048
return;
@@ -1049,7 +1054,7 @@ export function App() {
10491054
setSaveState("saving");
10501055
setStatusMessage(t("status.retryingTranscript"));
10511056
try {
1052-
const nextNote = await api.retryTranscript(note.id);
1057+
const nextNote = await api.retryTranscript(note.id, Array.isArray(chunkIDs) && chunkIDs.length > 0 ? { chunkIDs } : undefined);
10531058
setSelectedNote(nextNote);
10541059
selectedNoteRef.current = nextNote;
10551060
replaceEditorMarkdown(nextNote.markdown || "");

0 commit comments

Comments
 (0)