@@ -55,6 +55,7 @@ const DEFAULT_AUDIO_DEVICE_ID = "default";
5555const LOCAL_STT_STREAM_CHUNK_SECONDS = 5 ;
5656const STT_CHUNK_LEDGER_FILE_NAME = "stt-chunks.jsonl" ;
5757const TRANSCRIPT_RUNTIME_METADATA_FIELDS = [ "sessionID" , "chunkID" , "eventType" , "retryState" ] ;
58+ const RECOVERABLE_LISTENER_SESSION_STATES = new Set ( [ "starting" , "recording" , "stopping" , "finalizing" , "recovering" ] ) ;
5859
5960const LOCAL_STT_MODELS = [
6061 {
@@ -143,6 +144,8 @@ const STT_MODEL_DOWNLOAD_CANCELLED_MESSAGE = "Model download cancelled.";
143144let menuBarIconPulseTimer ;
144145let menuBarIconPulseBright = true ;
145146let currentMenuBarIconSignature = "" ;
147+ let activeListenerSessionNoteId = null ;
148+ const listenerSessionWriteQueues = new Map ( ) ;
146149let 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+
13171452function 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
16931829function 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+
25532709function latestFailedSTTChunks ( records ) {
25542710 return latestRetryableSTTChunks ( records , new Set ( ) ) . filter ( ( record ) => record . status === "failed" ) ;
25552711}
@@ -3218,6 +3374,7 @@ function applyCaptureEventToMenuBar(event) {
32183374
32193375function 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) {
40154184async 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,
0 commit comments