diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index 72d0028..bee8d89 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -55,6 +55,7 @@ const DEFAULT_AUDIO_DEVICE_ID = "default"; const LOCAL_STT_STREAM_CHUNK_SECONDS = 5; const STT_CHUNK_LEDGER_FILE_NAME = "stt-chunks.jsonl"; const TRANSCRIPT_RUNTIME_METADATA_FIELDS = ["sessionID", "chunkID", "eventType", "retryState"]; +const RECOVERABLE_LISTENER_SESSION_STATES = new Set(["starting", "recording", "stopping", "finalizing", "recovering"]); const LOCAL_STT_MODELS = [ { @@ -143,6 +144,8 @@ const STT_MODEL_DOWNLOAD_CANCELLED_MESSAGE = "Model download cancelled."; let menuBarIconPulseTimer; let menuBarIconPulseBright = true; let currentMenuBarIconSignature = ""; +let activeListenerSessionNoteId = null; +const listenerSessionWriteQueues = new Map(); let menuBarState = { captureState: "idle", detail: "", @@ -1314,6 +1317,138 @@ function normalizeArtifactBundle(bundle) { }; } +function normalizeListenerSessionState(value) { + switch (value) { + case "starting": + case "recording": + case "stopping": + case "recovering": + case "failed": + case "completed": + return value; + case "capturing": + case "degraded": + return "recording"; + case "finalizing": + return "stopping"; + default: + return null; + } +} + +function listenerSessionFromMetadata(metadata, noteId) { + const raw = metadata && typeof metadata.listenerSession === "object" ? metadata.listenerSession : null; + if (!raw) { + return null; + } + const state = normalizeListenerSessionState(raw.state); + if (!state) { + return null; + } + const recoveredState = activeListenerSessionNoteId === noteId || !RECOVERABLE_LISTENER_SESSION_STATES.has(state) + ? state + : "recovering"; + return { + state: recoveredState, + startedAt: normalizeDate(raw.startedAt)?.toISOString() || null, + updatedAt: normalizeDate(raw.updatedAt)?.toISOString() || null, + endedAt: normalizeDate(raw.endedAt)?.toISOString() || null, + detail: optionalNonEmptyString(raw.detail), + error: optionalNonEmptyString(raw.error), + }; +} + +async function writeListenerSessionState(noteId, patch) { + if (!noteId) { + return; + } + const directoryPath = resolveBundleDirectory(noteId); + const paths = bundlePaths(directoryPath); + const metadata = await readJSONIfExists(paths.metadataPath) || {}; + const currentSession = metadata.listenerSession && typeof metadata.listenerSession === "object" + ? metadata.listenerSession + : {}; + const nextState = normalizeListenerSessionState(patch.state) || normalizeListenerSessionState(currentSession.state) || "starting"; + const now = new Date().toISOString(); + const nextSession = { + ...currentSession, + state: nextState, + startedAt: currentSession.startedAt || metadata.startedAt || now, + updatedAt: now, + }; + if (patch.detail !== undefined) { + nextSession.detail = optionalNonEmptyString(patch.detail); + } + if (patch.error !== undefined) { + nextSession.error = optionalNonEmptyString(patch.error); + } + if (nextState === "completed" || nextState === "failed") { + nextSession.endedAt = currentSession.endedAt || metadata.endedAt || now; + } else { + delete nextSession.endedAt; + } + metadata.listenerSession = nextSession; + await writeJSONFileAtomic(paths.metadataPath, metadata); + await syncNotesDatabaseFromFiles([noteId]); +} + +function enqueueListenerSessionStateWrite(noteId, patch, context) { + if (!noteId) { + return Promise.resolve(); + } + const previousWrite = listenerSessionWriteQueues.get(noteId) || Promise.resolve(); + const write = previousWrite + .catch(() => {}) + .then(() => writeListenerSessionState(noteId, patch)); + listenerSessionWriteQueues.set(noteId, write); + write + .catch((error) => { + console.error(`Failed to persist listener session ${context}:`, error); + }) + .finally(() => { + if (listenerSessionWriteQueues.get(noteId) === write) { + listenerSessionWriteQueues.delete(noteId); + } + }); + return write; +} + +function noteIdFromCaptureEvent(event) { + return optionalNonEmptyString(event?.bundleId) || optionalNonEmptyString(event?.artifactBundle?.id) || activeListenerSessionNoteId; +} + +function persistCaptureEventSessionState(event) { + const noteId = noteIdFromCaptureEvent(event); + if (!noteId) { + return; + } + if (event.kind === "artifactBundlePrepared") { + activeListenerSessionNoteId = noteId; + enqueueListenerSessionStateWrite(noteId, { state: "starting", detail: "Preparing local capture." }, "start"); + return; + } + if (event.kind === "stateChanged") { + const state = normalizeListenerSessionState(event.state); + if (!state) { + return; + } + if (state === "recording") { + activeListenerSessionNoteId = noteId; + } + enqueueListenerSessionStateWrite(noteId, { state, detail: event.detail }, "state"); + return; + } + if (event.kind === "failed") { + enqueueListenerSessionStateWrite(noteId, { state: "failed", error: event.errorMessage }, "failure"); + activeListenerSessionNoteId = null; + return; + } + if (event.kind === "finished") { + enqueueListenerSessionStateWrite(noteId, { state: "completed" }, "completion"); + activeListenerSessionNoteId = null; + } +} + function resolveBundleDirectory(id) { if (typeof id !== "string" || id.trim().length === 0) { throw new Error("A note id is required."); @@ -1679,6 +1814,7 @@ async function loadBundleSnapshot(directoryName) { transcriptPath: paths.transcriptPath, metadataPath: paths.metadataPath, recordingPath: paths.recordingPath, + listenerSession: listenerSessionFromMetadata(metadata, directoryName), }; return { @@ -1691,6 +1827,7 @@ async function loadBundleSnapshot(directoryName) { } function noteSummaryFromRow(row) { + const metadata = parseMetadataJSON(row.metadata_json); return { id: row.id, title: row.title, @@ -1707,6 +1844,7 @@ function noteSummaryFromRow(row) { transcriptPath: row.transcript_path, metadataPath: row.metadata_path, recordingPath: row.recording_path, + listenerSession: listenerSessionFromMetadata(metadata, row.id), }; } @@ -2291,6 +2429,8 @@ async function readNote(id) { } return normalizedSegment; }); + const directoryPath = resolveBundleDirectory(row.id); + const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME)))); const speakerLabels = database.prepare(` SELECT speaker_id, @@ -2310,11 +2450,12 @@ async function readNote(id) { return { ...noteSummaryFromRow(row), - directoryPath: resolveBundleDirectory(row.id), + directoryPath, markdown: row.markdown, metadata: parseMetadataJSON(row.metadata_json), recordingURL: recordingURLForNote(row.id, row.recording_path), transcriptSegments, + sttChunks, speakerLabels, transcriptText: transcriptSegments.map((segment) => segment.text).join("\n\n"), }; @@ -2542,6 +2683,8 @@ function parseSTTChunkLedgerJSONL(raw) { sampleCount: Number.isFinite(Number(record.sampleCount)) ? Number(record.sampleCount) : 0, segmentCount: Number.isFinite(Number(record.segmentCount)) ? Number(record.segmentCount) : null, error: typeof record.error === "string" ? record.error : null, + retryState: optionalNonEmptyString(record.retryState), + recordedAt: normalizeDate(record.recordedAt)?.toISOString() || null, }); } catch { // Ignore malformed ledger rows; the transcript retry path can fall back to whole-track retry. @@ -2550,6 +2693,19 @@ function parseSTTChunkLedgerJSONL(raw) { return records; } +function latestSTTChunks(records) { + const latestByChunk = new Map(); + for (const record of records) { + latestByChunk.set(record.chunkID, record); + } + return [...latestByChunk.values()].sort((a, b) => { + if (a.startTimeSeconds === b.startTimeSeconds) { + return a.chunkID.localeCompare(b.chunkID); + } + return a.startTimeSeconds - b.startTimeSeconds; + }); +} + function latestFailedSTTChunks(records) { return latestRetryableSTTChunks(records, new Set()).filter((record) => record.status === "failed"); } @@ -3218,6 +3374,7 @@ function applyCaptureEventToMenuBar(event) { function broadcastCaptureEvent(event) { const normalized = normalizeNativeEvent(event); + persistCaptureEventSessionState(normalized); applyCaptureEventToMenuBar(normalized); for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { @@ -4004,9 +4161,21 @@ async function handleStartCapture(title, bundleId) { setMenuBarCaptureState("starting", "", ""); try { const payload = await startNativeCapture(title, bundleId); + if (payload.artifactBundle?.id) { + activeListenerSessionNoteId = payload.artifactBundle.id; + enqueueListenerSessionStateWrite(payload.artifactBundle.id, { state: "recording", detail: "" }, "recording"); + } setMenuBarCaptureState("capturing", "", ""); return payload; } catch (error) { + if (activeListenerSessionNoteId) { + await enqueueListenerSessionStateWrite( + activeListenerSessionNoteId, + { state: "failed", error: messageForUnknownError(error) }, + "start failure", + ).catch(() => {}); + activeListenerSessionNoteId = null; + } setMenuBarCaptureState("failed", "", messageForUnknownError(error)); throw error; } @@ -4015,10 +4184,26 @@ async function handleStartCapture(title, bundleId) { async function handleStopCapture() { setMenuBarCaptureState("stopping", "Finalizing transcript...", ""); try { + const stoppingNoteId = activeListenerSessionNoteId; + if (stoppingNoteId) { + enqueueListenerSessionStateWrite(stoppingNoteId, { state: "stopping", detail: "Finalizing transcript." }, "stopping"); + } const payload = await nativeCapture.request("stopSession"); + if (stoppingNoteId) { + await enqueueListenerSessionStateWrite(stoppingNoteId, { state: "completed", detail: "" }, "completion").catch(() => {}); + activeListenerSessionNoteId = null; + } setMenuBarCaptureState("completed", "", ""); return payload; } catch (error) { + if (activeListenerSessionNoteId) { + await enqueueListenerSessionStateWrite( + activeListenerSessionNoteId, + { state: "failed", error: messageForUnknownError(error) }, + "stop failure", + ).catch(() => {}); + activeListenerSessionNoteId = null; + } setMenuBarCaptureState("failed", "", messageForUnknownError(error)); throw error; } @@ -4353,6 +4538,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { mergeSummarySettingsPatch, normalizeCustomSummaryTemplate, normalizeCustomSummaryTemplates, + normalizeListenerSessionState, normalizeSummarySettings, nativeHelperWorkingDirectory, normalizeTranscriptSegments, @@ -4376,6 +4562,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { summaryTemplatesForSettings, serializeTranscriptJSONL, latestFailedSTTChunks, + latestSTTChunks, latestRetryableSTTChunks, stripLeadingBlankLineSeparatorsMain, stripMarkdownFrontmatterMain, diff --git a/apps/desktop/main/native-launch.node-test.cjs b/apps/desktop/main/native-launch.node-test.cjs index 9deabe9..88cff35 100644 --- a/apps/desktop/main/native-launch.node-test.cjs +++ b/apps/desktop/main/native-launch.node-test.cjs @@ -4,8 +4,10 @@ process.env.MIRROR_NOTE_TEST_EXPORTS = "1"; const { latestFailedSTTChunks, + latestSTTChunks, latestRetryableSTTChunks, nativeHelperWorkingDirectory, + normalizeListenerSessionState, normalizeTranscriptSegments, parseSTTChunkLedgerJSONL, parseTranscriptJSONL, @@ -13,6 +15,10 @@ const { } = require("../main.cjs"); assert.equal(nativeHelperWorkingDirectory(), process.cwd()); +assert.equal(normalizeListenerSessionState("capturing"), "recording"); +assert.equal(normalizeListenerSessionState("finalizing"), "stopping"); +assert.equal(normalizeListenerSessionState("completed"), "completed"); +assert.equal(normalizeListenerSessionState("unknown"), null); const runtimeSegment = { id: "seg-1", @@ -60,6 +66,8 @@ const ledgerRecords = parseSTTChunkLedgerJSONL([ endTimeSeconds: 4, sampleCount: 16000, segmentCount: 1, + retryState: "completed", + recordedAt: "2026-05-04T00:00:00.000Z", }), ].join("\n")); assert.deepEqual(latestFailedSTTChunks(ledgerRecords).map((record) => record.chunkID), [ @@ -69,6 +77,12 @@ assert.deepEqual( latestRetryableSTTChunks(ledgerRecords, new Set(["session-a:system:1"])).map((record) => record.chunkID), ["session-a:system:1"], ); +assert.deepEqual(latestSTTChunks(ledgerRecords).map((record) => `${record.chunkID}:${record.status}`), [ + "session-a:microphone:0:failed", + "session-a:system:1:completed", +]); +assert.equal(latestSTTChunks(ledgerRecords)[1].retryState, "completed"); +assert.equal(latestSTTChunks(ledgerRecords)[1].recordedAt, "2026-05-04T00:00:00.000Z"); const interruptedLedgerRecords = parseSTTChunkLedgerJSONL([ JSON.stringify({ diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index bf8d59d..6e84b8b 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -8,7 +8,7 @@ contextBridge.exposeInMainWorld("mirrorNote", { testSummaryProvider: () => ipcRenderer.invoke("summary:test-provider"), listSummaryModels: (provider) => ipcRenderer.invoke("summary:list-models", provider), saveTranscript: (id, segments) => ipcRenderer.invoke("transcript:save", id, segments), - retryTranscript: (id) => ipcRenderer.invoke("transcript:retry", id), + retryTranscript: (id, options) => ipcRenderer.invoke("transcript:retry", id, options), uploadTranscriptAsset: (id) => ipcRenderer.invoke("transcript:upload-asset", id), uploadRecordingAsset: (id) => ipcRenderer.invoke("recording:upload-asset", id), uploadMarkdownNote: (id) => ipcRenderer.invoke("notes:upload-markdown", id), diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 522a102..e4c88ac 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -749,6 +749,11 @@ export function App() { if (typeof segment.speakerLabel === "string" && segment.speakerLabel.trim()) { normalized.speakerLabel = segment.speakerLabel.trim(); } + for (const field of ["sessionID", "chunkID", "eventType", "retryState"] as const) { + if (typeof segment[field] === "string" && segment[field].trim()) { + normalized[field] = segment[field].trim(); + } + } if (!normalized.text) { return; } @@ -1037,7 +1042,7 @@ export function App() { } } - async function retryTranscript() { + async function retryTranscript(chunkIDs?: string[]) { const note = selectedNoteRef.current; if (!note || retryingTranscriptId === note.id) { return; @@ -1049,7 +1054,7 @@ export function App() { setSaveState("saving"); setStatusMessage(t("status.retryingTranscript")); try { - const nextNote = await api.retryTranscript(note.id); + const nextNote = await api.retryTranscript(note.id, Array.isArray(chunkIDs) && chunkIDs.length > 0 ? { chunkIDs } : undefined); setSelectedNote(nextNote); selectedNoteRef.current = nextNote; replaceEditorMarkdown(nextNote.markdown || ""); diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 8147c70..0ad0040 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -7,6 +7,8 @@ export type CaptureState = "idle" | "starting" | "capturing" | "degraded" | "sto export type EditorCommand = "paragraph" | "heading1" | "heading2" | "bullet" | "checklist" | "divider"; export type NoteActivity = "recording" | "finalizing" | "failed" | null; export type TranscriptSource = "microphone" | "system"; +export type ListenerSessionState = "starting" | "recording" | "stopping" | "recovering" | "failed" | "completed"; +export type STTChunkStatus = "queued" | "running" | "completed" | "failed"; export type SpeakerLabelingMode = "local" | "disabled"; export type AppLanguage = "en" | "ko"; export type SummaryProvider = "openai" | "ollama" | "lmstudio"; @@ -77,6 +79,7 @@ export interface NoteSummary { transcriptPath: string; metadataPath: string; recordingPath: string; + listenerSession: ListenerSession | null; } export interface TranscriptSegment { @@ -88,6 +91,34 @@ export interface TranscriptSegment { source?: TranscriptSource; speakerId?: string; speakerLabel?: string; + sessionID?: string; + chunkID?: string; + eventType?: string; + retryState?: string; +} + +export interface ListenerSession { + state: ListenerSessionState; + startedAt: string | null; + updatedAt: string | null; + endedAt: string | null; + detail: string | null; + error: string | null; +} + +export interface STTChunk { + sessionID: string | null; + chunkID: string; + source: TranscriptSource; + chunkIndex: number; + status: STTChunkStatus; + startTimeSeconds: number; + endTimeSeconds: number; + sampleCount: number; + segmentCount: number | null; + error: string | null; + retryState: string | null; + recordedAt: string | null; } export interface SpeakerLabel { @@ -102,6 +133,7 @@ export interface NoteDetail extends NoteSummary { metadata: unknown; recordingURL: string | null; transcriptSegments: TranscriptSegment[]; + sttChunks: STTChunk[]; transcriptText: string; speakerLabels: SpeakerLabel[]; } @@ -196,7 +228,7 @@ export interface MirrorNoteAPI { testSummaryProvider: () => Promise<{ ok: boolean; message: string }>; listSummaryModels: (provider?: SummaryProvider) => Promise<{ models: SummaryModelOption[]; message?: string }>; saveTranscript: (id: string, segments: TranscriptSegment[]) => Promise; - retryTranscript: (id: string) => Promise; + retryTranscript: (id: string, options?: { chunkIDs?: string[] }) => Promise; uploadTranscriptAsset: (id: string) => Promise; uploadRecordingAsset: (id: string) => Promise; uploadMarkdownNote: (id: string) => Promise;