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
101 changes: 96 additions & 5 deletions apps/desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const {
const APP_DISPLAY_NAME = "MirrorNote";
const ELECTRON_SETTINGS_FILE_NAME = "electron-settings.json";
const NOTES_DATABASE_FILE_NAME = "notes.sqlite";
const NOTES_DATABASE_SCHEMA_VERSION = 3;
const NOTES_DATABASE_SCHEMA_VERSION = 4;
const MENU_BAR_ICON_SIZE = 18;
const MENU_BAR_ICON_IDLE_FILE_NAME = "menu-bar-icon-source.png";
const MENU_BAR_ICON_RECORDING_BRIGHT_FILE_NAME = "menu-bar-icon-recording-bright.png";
Expand Down Expand Up @@ -1358,6 +1358,77 @@ function listenerSessionFromMetadata(metadata, noteId) {
};
}

function normalizeAppSessionStatus(input = {}) {
const listenerSession = input.listenerSession && typeof input.listenerSession === "object"
? input.listenerSession
: null;
const sttChunks = Array.isArray(input.sttChunks) ? input.sttChunks : [];
const latestChunks = sttChunks.some((chunk) => optionalNonEmptyString(chunk?.chunkID))
? latestSTTChunks(sttChunks)
: sttChunks;
const runningChunkCount = latestChunks.filter((chunk) => chunk.status === "queued" || chunk.status === "running").length;
const failedChunkCount = latestChunks.filter((chunk) => chunk.status === "failed").length;
const retryableChunkCount = latestChunks.filter((chunk) => isRetryableSTTChunkStatus(chunk.status)).length;
const base = {
detail: null,
retryableChunkCount,
failedChunkCount,
runningChunkCount,
};
const listenerState = normalizeListenerSessionState(listenerSession?.state);
const listenerError = optionalNonEmptyString(listenerSession?.error);
const listenerDetail = listenerError || optionalNonEmptyString(listenerSession?.detail);

if (listenerState === "failed") {
return { ...base, state: "failed", detail: listenerDetail };
}
if (listenerState === "recovering") {
return { ...base, state: "recoverable", detail: listenerDetail || "Session needs recovery." };
}
if (listenerState === "starting" || listenerState === "recording") {
return {
...base,
state: listenerError ? "degraded" : "recording",
detail: listenerDetail,
};
}
if (listenerState === "stopping") {
return { ...base, state: "finalizing", detail: listenerDetail };
}
if (runningChunkCount > 0) {
return { ...base, state: "transcribing", detail: "Transcription is running." };
}
if (failedChunkCount > 0) {
return {
...base,
state: "recoverable",
detail: `${failedChunkCount} STT ${failedChunkCount === 1 ? "chunk needs" : "chunks need"} retry.`,
};
}
if (listenerState === "completed" || latestChunks.some((chunk) => chunk.status === "completed")) {
return { ...base, state: "completed", detail: listenerDetail };
}
return null;
}

function normalizeStoredAppSessionStatus(value) {
const raw = typeof value === "string" ? parseMetadataJSON(value) : value;
if (!raw || typeof raw !== "object") {
return null;
}
const state = optionalNonEmptyString(raw.state);
if (!["recording", "degraded", "finalizing", "transcribing", "failed", "completed", "recoverable"].includes(state)) {
return null;
}
return {
state,
detail: optionalNonEmptyString(raw.detail),
retryableChunkCount: Number.isFinite(Number(raw.retryableChunkCount)) ? Number(raw.retryableChunkCount) : 0,
failedChunkCount: Number.isFinite(Number(raw.failedChunkCount)) ? Number(raw.failedChunkCount) : 0,
runningChunkCount: Number.isFinite(Number(raw.runningChunkCount)) ? Number(raw.runningChunkCount) : 0,
};
}

async function writeListenerSessionState(noteId, patch) {
if (!noteId) {
return;
Expand Down Expand Up @@ -1635,6 +1706,14 @@ function applyNotesDatabaseMigrations(database) {
`);
},
},
{
version: 4,
up: (db) => {
db.exec(`
ALTER TABLE notes ADD COLUMN session_status_json TEXT;
`);
},
},
];

for (const migration of migrations) {
Expand Down Expand Up @@ -1798,6 +1877,9 @@ async function loadBundleSnapshot(directoryName) {
? metadata.transcriptSegmentCount
: transcriptSegments.length;

const listenerSession = listenerSessionFromMetadata(metadata, directoryName);
const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME))));

const summary = {
id: directoryName,
title: displayTitleFromBundle(directoryName, metadata, markdown),
Expand All @@ -1814,7 +1896,8 @@ async function loadBundleSnapshot(directoryName) {
transcriptPath: paths.transcriptPath,
metadataPath: paths.metadataPath,
recordingPath: paths.recordingPath,
listenerSession: listenerSessionFromMetadata(metadata, directoryName),
listenerSession,
sessionStatus: normalizeAppSessionStatus({ listenerSession, sttChunks }),
};

return {
Expand All @@ -1828,6 +1911,7 @@ async function loadBundleSnapshot(directoryName) {

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

Expand Down Expand Up @@ -1884,9 +1969,10 @@ function upsertNoteSnapshot(snapshot) {
recording_path,
markdown,
metadata_json,
session_status_json,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
started_at = excluded.started_at,
Expand All @@ -1902,6 +1988,7 @@ function upsertNoteSnapshot(snapshot) {
recording_path = excluded.recording_path,
markdown = excluded.markdown,
metadata_json = excluded.metadata_json,
session_status_json = excluded.session_status_json,
updated_at = excluded.updated_at
`).run(
summary.id,
Expand All @@ -1919,6 +2006,7 @@ function upsertNoteSnapshot(snapshot) {
summary.recordingPath,
markdown,
serializeMetadata(metadata),
serializeMetadata(summary.sessionStatus),
createdAt,
now,
);
Expand Down Expand Up @@ -2431,6 +2519,7 @@ async function readNote(id) {
});
const directoryPath = resolveBundleDirectory(row.id);
const sttChunks = latestSTTChunks(parseSTTChunkLedgerJSONL(await readTextIfExists(path.join(directoryPath, STT_CHUNK_LEDGER_FILE_NAME))));
const summary = noteSummaryFromRow(row);
const speakerLabels = database.prepare(`
SELECT
speaker_id,
Expand All @@ -2449,7 +2538,8 @@ async function readNote(id) {
markNoteOpened(id);

return {
...noteSummaryFromRow(row),
...summary,
sessionStatus: normalizeAppSessionStatus({ listenerSession: summary.listenerSession, sttChunks }),
Comment thread
qyinm marked this conversation as resolved.
directoryPath,
markdown: row.markdown,
metadata: parseMetadataJSON(row.metadata_json),
Expand Down Expand Up @@ -4538,6 +4628,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
mergeSummarySettingsPatch,
normalizeCustomSummaryTemplate,
normalizeCustomSummaryTemplates,
normalizeAppSessionStatus,
normalizeListenerSessionState,
normalizeSummarySettings,
nativeHelperWorkingDirectory,
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/main/native-launch.node-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
latestSTTChunks,
latestRetryableSTTChunks,
nativeHelperWorkingDirectory,
normalizeAppSessionStatus,
normalizeListenerSessionState,
normalizeTranscriptSegments,
parseSTTChunkLedgerJSONL,
Expand All @@ -20,6 +21,55 @@ assert.equal(normalizeListenerSessionState("finalizing"), "stopping");
assert.equal(normalizeListenerSessionState("completed"), "completed");
assert.equal(normalizeListenerSessionState("unknown"), null);

assert.deepEqual(normalizeAppSessionStatus({
listenerSession: { state: "recording", error: null, detail: null },
sttChunks: [],
}), {
state: "recording",
detail: null,
retryableChunkCount: 0,
failedChunkCount: 0,
runningChunkCount: 0,
});
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "recording", error: "System audio unavailable.", detail: null },
sttChunks: [],
}).state, "degraded");
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "starting", error: null, detail: "Preparing local capture." },
sttChunks: [],
}).state, "recording");
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "stopping", error: null, detail: null },
sttChunks: [],
}).state, "finalizing");
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "failed", error: "Capture failed.", detail: null },
sttChunks: [],
}).state, "failed");
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "recovering", error: null, detail: null },
sttChunks: [],
}).state, "recoverable");
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "completed", error: null, detail: null },
sttChunks: [{ status: "running" }, { status: "completed" }],
}).state, "transcribing");
assert.deepEqual(normalizeAppSessionStatus({
listenerSession: { state: "completed", error: null, detail: null },
sttChunks: [{ status: "failed" }, { status: "completed" }],
}), {
state: "recoverable",
detail: "1 STT chunk needs retry.",
retryableChunkCount: 1,
failedChunkCount: 1,
runningChunkCount: 0,
});
assert.equal(normalizeAppSessionStatus({
listenerSession: { state: "completed", error: null, detail: null },
sttChunks: [{ status: "completed" }],
}).state, "completed");

const runtimeSegment = {
id: "seg-1",
sessionID: "session-a",
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type EditorCommand = "paragraph" | "heading1" | "heading2" | "bullet" | "
export type NoteActivity = "recording" | "finalizing" | "failed" | null;
export type TranscriptSource = "microphone" | "system";
export type ListenerSessionState = "starting" | "recording" | "stopping" | "recovering" | "failed" | "completed";
export type AppSessionState = "recording" | "degraded" | "finalizing" | "transcribing" | "failed" | "completed" | "recoverable";
export type STTChunkStatus = "queued" | "running" | "completed" | "failed";
export type SpeakerLabelingMode = "local" | "disabled";
export type AppLanguage = "en" | "ko";
Expand Down Expand Up @@ -80,6 +81,7 @@ export interface NoteSummary {
metadataPath: string;
recordingPath: string;
listenerSession: ListenerSession | null;
sessionStatus: AppSessionStatus | null;
}

export interface TranscriptSegment {
Expand All @@ -106,6 +108,14 @@ export interface ListenerSession {
error: string | null;
}

export interface AppSessionStatus {
state: AppSessionState;
detail: string | null;
retryableChunkCount: number;
failedChunkCount: number;
runningChunkCount: number;
}

export interface STTChunk {
sessionID: string | null;
chunkID: string;
Expand Down