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
189 changes: 188 additions & 1 deletion apps/desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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]);
}
Comment thread
qyinm marked this conversation as resolved.

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.");
Expand Down Expand Up @@ -1679,6 +1814,7 @@ async function loadBundleSnapshot(directoryName) {
transcriptPath: paths.transcriptPath,
metadataPath: paths.metadataPath,
recordingPath: paths.recordingPath,
listenerSession: listenerSessionFromMetadata(metadata, directoryName),
};

return {
Expand All @@ -1691,6 +1827,7 @@ async function loadBundleSnapshot(directoryName) {
}

function noteSummaryFromRow(row) {
const metadata = parseMetadataJSON(row.metadata_json);
return {
id: row.id,
title: row.title,
Expand All @@ -1707,6 +1844,7 @@ function noteSummaryFromRow(row) {
transcriptPath: row.transcript_path,
metadataPath: row.metadata_path,
recordingPath: row.recording_path,
listenerSession: listenerSessionFromMetadata(metadata, row.id),
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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"),
};
Expand Down Expand Up @@ -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.
Expand All @@ -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");
}
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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");
}
Comment thread
qyinm marked this conversation as resolved.
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;
}
Expand Down Expand Up @@ -4353,6 +4538,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
mergeSummarySettingsPatch,
normalizeCustomSummaryTemplate,
normalizeCustomSummaryTemplates,
normalizeListenerSessionState,
normalizeSummarySettings,
nativeHelperWorkingDirectory,
normalizeTranscriptSegments,
Expand All @@ -4376,6 +4562,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
summaryTemplatesForSettings,
serializeTranscriptJSONL,
latestFailedSTTChunks,
latestSTTChunks,
latestRetryableSTTChunks,
stripLeadingBlankLineSeparatorsMain,
stripMarkdownFrontmatterMain,
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/main/native-launch.node-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ process.env.MIRROR_NOTE_TEST_EXPORTS = "1";

const {
latestFailedSTTChunks,
latestSTTChunks,
latestRetryableSTTChunks,
nativeHelperWorkingDirectory,
normalizeListenerSessionState,
normalizeTranscriptSegments,
parseSTTChunkLedgerJSONL,
parseTranscriptJSONL,
serializeTranscriptJSONL,
} = 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",
Expand Down Expand Up @@ -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), [
Expand All @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 || "");
Expand Down
Loading