@gotcos/glasses-server 6.71.13 → 6.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,7 +37,6 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url))
37
37
  import { emitDisplay } from '../lib/display-bus.js'
38
38
  import { errMsg } from '../lib/utils.js'
39
39
  import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
40
- import { enhanceAudio } from '../lib/audio-enhance.js'
41
40
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
42
41
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
43
42
  import { nemotronLive, resolveLiveLabels, resplitSegments, type ChunkSpeakerSegment } from '../lib/nemotron-live.js'
@@ -56,7 +55,9 @@ import {
56
55
  streamSilenceDropReason,
57
56
  isVocabEchoOnly,
58
57
  } from '../lib/hallucination-filter.js'
59
- import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
58
+ import { transcribeMeetingPreview } from '../lib/stt-runtime.js'
59
+ import { decideQuiet, asrFrontEnd, silenceDropsAllowed } from '../lib/stt-live.js'
60
+ import { readSttSettings } from '../lib/stt-settings.js'
60
61
  import { dataPath } from '../lib/data-dir.js'
61
62
  import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
62
63
  import {
@@ -77,8 +78,16 @@ import {
77
78
  listUnsavedCaptures,
78
79
  purgeExpiredQuarantine,
79
80
  quarantineSessionAudio,
81
+ readCaptureDisposition,
80
82
  sweepOrphanedSessionAudio,
81
83
  } from '../lib/unsaved-audio-quarantine.js'
84
+ import {
85
+ STALE_ACTIVE_SESSION_DIR_NAME,
86
+ moveActiveSessionFileAside,
87
+ purgeExpiredStaleActiveSessionFiles,
88
+ sweepStaleActiveSessionFiles,
89
+ type StaleActiveSessionMove,
90
+ } from '../lib/stale-active-session-files.js'
82
91
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
83
92
  import { saveTrainingAudioSample } from '../lib/training-audio-save.js'
84
93
  import {
@@ -92,6 +101,7 @@ import {
92
101
  acquireMaintenanceWork,
93
102
  maintenanceAdmissionsOpen,
94
103
  maintenanceLifecycle,
104
+ onMaintenanceAdmissionsOpen,
95
105
  type MaintenanceWorkLease,
96
106
  } from '../lib/maintenance-lifecycle.js'
97
107
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
@@ -139,8 +149,12 @@ function ensurePrivateDirectory(path: string): void {
139
149
 
140
150
  // Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
141
151
  // brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
142
- // any-URL-only / repeated-thank-you -> dropped only when isQuiet (rms<150), so a real
143
- // softly-spoken utterance or a clearly-dictated third-party URL is never dropped.
152
+ // any-URL-only / repeated-thank-you / quiet vocab echo -> dropped only when the chunk
153
+ // is RMS-quiet (rms<150), so a real softly-spoken utterance or a clearly-dictated
154
+ // third-party URL is never dropped. 6.72.0 keeps these TEXT drops on the RMS test on
155
+ // purpose: the Silero quiet gate (under 0.5 s of speech) also fires on a chunk holding
156
+ // one short real name, and dropping that as a "vocab echo" would delete real speech.
157
+ // The Silero gate decides only the prompt and the front end.
144
158
  // Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
145
159
  const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
146
160
  const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
@@ -542,6 +556,8 @@ export function getTranscriptionSessionLiveness(now = Date.now()): Transcription
542
556
  // Incremental chunk persistence — survive server restarts
543
557
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
544
558
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)
559
+ /** 6.71.14: where an active-session file the server no longer owns is moved, never deleted in place. */
560
+ const STALE_ACTIVE_SESSION_DIR = dataPath(STALE_ACTIVE_SESSION_DIR_NAME)
545
561
 
546
562
  function readClosedSessions(): Record<string, ClosedTranscriptSession> {
547
563
  if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
@@ -691,6 +707,71 @@ function persistSessionBestEffort(sessionId: string): void {
691
707
  try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
692
708
  }
693
709
 
710
+ /**
711
+ * 6.71.14: one pass of the stale active-session sweep, against the real session
712
+ * map. Exported so a test drives the exact wiring the minute interval runs.
713
+ */
714
+ export function sweepStaleActiveSessionFilesNow(now: number): StaleActiveSessionMove[] {
715
+ const moves = sweepStaleActiveSessionFiles({
716
+ dir: CHUNK_PERSIST_DIR,
717
+ targetDir: STALE_ACTIVE_SESSION_DIR,
718
+ isLive: id => sessions.has(id),
719
+ now,
720
+ idleRetentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
721
+ })
722
+ for (const move of moves) {
723
+ if (move.target) {
724
+ staleMoveFailuresLogged.delete(move.sessionId)
725
+ console.warn(
726
+ `[cleanup] Moved stale active-session file aside: ${move.sessionId} `
727
+ + `(${move.reason}, idle ${Math.round((move.idleMs ?? 0) / 60_000)}m, not live) \u2192 ${move.target}`,
728
+ )
729
+ } else {
730
+ logStaleMoveFailure(move.sessionId, `[cleanup] Could not move stale active-session file aside for ${move.sessionId} (${move.reason}); it stays in active-sessions`)
731
+ }
732
+ }
733
+ const purged = purgeExpiredStaleActiveSessionFiles(STALE_ACTIVE_SESSION_DIR, now)
734
+ if (purged > 0) console.log(`[cleanup] Purged ${purged} stale active-session file(s) past retention`)
735
+ return moves
736
+ }
737
+
738
+ /** A failed move repeats every minute; say so once per session id, not every tick. */
739
+ const staleMoveFailuresLogged = new Set<string>()
740
+ function logStaleMoveFailure(sessionId: string, message: string): void {
741
+ if (staleMoveFailuresLogged.has(sessionId)) return
742
+ staleMoveFailuresLogged.add(sessionId)
743
+ if (staleMoveFailuresLogged.size > 500) staleMoveFailuresLogged.delete(staleMoveFailuresLogged.values().next().value as string)
744
+ console.error(message)
745
+ }
746
+
747
+ /**
748
+ * 6.71.14: is this active-session file the leftover of a capture that is already
749
+ * decoded (recovered or receipted no_speech), rather than a session that RESUMED
750
+ * under the same id after that capture was quarantined?
751
+ *
752
+ * A late chunk is accepted (the phone deletes its copy on a 410), so a session can
753
+ * restart under an id whose older quarantine dir is already receipted. Its file is
754
+ * then newer than the quarantine and its own audio sits in session-audio/<id>.
755
+ * Only a file that is no newer than the quarantine AND has no chunk audio of its
756
+ * own is the leftover; anything else is adopted as 6.71.13 would, so a meeting is
757
+ * never split across a restart.
758
+ */
759
+ function isTerminalCaptureFile(sessionId: string, recordedLastActivityAt: unknown, filePath: string): boolean {
760
+ const disposition = readCaptureDisposition(sessionId)
761
+ if (!disposition || (disposition.kind !== 'no_speech' && disposition.kind !== 'recovered')) return false
762
+ const terminalAtIso = disposition.quarantinedAt ?? disposition.receiptedAt
763
+ const terminalAt = terminalAtIso ? Date.parse(terminalAtIso) : NaN
764
+ if (!Number.isFinite(terminalAt)) return false
765
+ let fileActivity = typeof recordedLastActivityAt === 'number' && Number.isFinite(recordedLastActivityAt)
766
+ ? recordedLastActivityAt
767
+ : NaN
768
+ if (!Number.isFinite(fileActivity)) {
769
+ try { fileActivity = statSync(filePath).mtimeMs } catch { return false }
770
+ }
771
+ if (terminalAt < fileActivity) return false
772
+ return countChunkWavs(resolve(SESSION_AUDIO_DIR, sessionId)) === 0
773
+ }
774
+
694
775
  /** Recover sessions from disk on server restart */
695
776
  function recoverSessions(): void {
696
777
  try {
@@ -701,6 +782,27 @@ function recoverSessions(): void {
701
782
  if (!file.endsWith('.json')) continue
702
783
  try {
703
784
  const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
785
+ // 6.71.14: recovery can now run after boot (deferred behind a closed
786
+ // maintenance gate). A session already in memory owns its file; the disk
787
+ // copy must never overwrite it.
788
+ if (typeof data.sessionId === 'string' && sessions.has(data.sessionId)) continue
789
+ // A capture whose quarantined audio was already decoded to a terminal
790
+ // outcome (a saved recovery or a no_speech receipt) is done. Adopting its
791
+ // file would bring back a live session for it; tombstoning would delete the
792
+ // file. Move it aside instead. Plain `quarantined` (never decoded) is NOT
793
+ // done: a crash between a chunk's first WAV write and its first
794
+ // active-session persist quarantines the audio, and the phone's retry then
795
+ // starts the session again. That file is adopted exactly as before.
796
+ if (typeof data.sessionId === 'string' && isTerminalCaptureFile(data.sessionId, data.lastActivityAt, resolve(CHUNK_PERSIST_DIR, file))) {
797
+ const terminal = readCaptureDisposition(data.sessionId)!
798
+ const target = moveActiveSessionFileAside(
799
+ resolve(CHUNK_PERSIST_DIR, file), data.sessionId, STALE_ACTIVE_SESSION_DIR, 'terminal_capture', Date.now(),
800
+ { lastActivityAt: typeof data.lastActivityAt === 'number' ? data.lastActivityAt : null },
801
+ )
802
+ if (target) console.warn(`[session-recovery] ${data.sessionId} was already ${terminal.kind}; moved its active-session file aside: ${target}`)
803
+ else logStaleMoveFailure(data.sessionId, `[session-recovery] Could not move the active-session file aside for ${data.sessionId} (already ${terminal.kind}); left in place, not adopted`)
804
+ continue
805
+ }
704
806
  // Reconstruct the (sparse) chunk array. New format keeps original
705
807
  // indices via chunksIndexed; legacy format stored a dense `chunks`.
706
808
  const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
@@ -859,7 +961,9 @@ function recoverSessions(): void {
859
961
  try {
860
962
  if (existsSync(SESSION_AUDIO_DIR)) {
861
963
  const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
862
- isLive: id => recoveredIds.has(id),
964
+ // `|| sessions.has`: deferred recovery (6.71.14) can run while sessions
965
+ // are live in memory; their audio is not orphaned.
966
+ isLive: id => recoveredIds.has(id) || sessions.has(id),
863
967
  hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
864
968
  reason: 'boot_sweep_unsaved',
865
969
  })
@@ -896,9 +1000,40 @@ export function isSessionDeleted(sessionId: string): boolean {
896
1000
  // A committed cross-boot maintenance operation owns durable state until the
897
1001
  // trusted controller adopts the candidate. Boot recovery must not mutate or
898
1002
  // promote sessions while that gate is closed.
899
- if (maintenanceAdmissionsOpen()) {
1003
+ //
1004
+ // 6.71.14: and once that gate opens, recovery MUST still run. Until 6.71.13 this was
1005
+ // a module-scope `if` evaluated once at import: a boot behind a closed gate (every
1006
+ // managed update) skipped it for the life of the process. Persisted sessions were
1007
+ // never adopted or tombstoned, and the closed-session tombstones were never
1008
+ // restored. On 2026-10-12 that left `active-sessions/<id>.json` for a receipted
1009
+ // capture on disk for 17 hours, reported as live by COS Control and `missing` by
1010
+ // the status route.
1011
+ let durableSessionRecoveryRan = false
1012
+ function runDurableSessionRecovery(trigger: 'boot' | 'admissions_open'): void {
1013
+ if (durableSessionRecoveryRan) return
1014
+ durableSessionRecoveryRan = true
900
1015
  recoverClosedSessions()
901
1016
  recoverSessions()
1017
+ if (trigger === 'admissions_open') {
1018
+ console.log(`[session-recovery] Ran deferred durable recovery after the maintenance gate opened (${sessions.size} live session(s))`)
1019
+ }
1020
+ }
1021
+ if (maintenanceAdmissionsOpen()) {
1022
+ runDurableSessionRecovery('boot')
1023
+ } else {
1024
+ // Delivered on every later open; the flag above makes every delivery after the
1025
+ // first a no-op, so disk is never re-read over a live session map.
1026
+ onMaintenanceAdmissionsOpen(() => runDurableSessionRecovery('admissions_open'))
1027
+ }
1028
+
1029
+ /**
1030
+ * 6.71.14: has durable session recovery run in this process yet? Until it has, the
1031
+ * in-memory map does not yet hold the persisted sessions, so "not in memory" is not
1032
+ * evidence of anything. The status route answers 503 `recovery_pending` instead of
1033
+ * `missing` while this is false.
1034
+ */
1035
+ export function durableSessionRecoveryHasRun(): boolean {
1036
+ return durableSessionRecoveryRan
902
1037
  }
903
1038
 
904
1039
  /**
@@ -1011,6 +1146,10 @@ function promoteStrandedCapture(sessionId: string, lastActivityAt: number): void
1011
1146
  // Auto-cleanup sessions idle for the advertised retention horizon.
1012
1147
  setInterval(() => {
1013
1148
  if (!maintenanceAdmissionsOpen()) return
1149
+ // 6.71.14: the sweeps below judge a session dead by its absence from the
1150
+ // in-memory map. Before recovery has adopted the persisted sessions that map is
1151
+ // empty by construction, so sweeping then would quarantine a live capture.
1152
+ if (!durableSessionRecoveryRan) return
1014
1153
  // A capture whose phone went away must still become a meeting. This loop used
1015
1154
  // to close every idle session as 'expired', which quarantined its audio and
1016
1155
  // produced NO meeting — recoverable for 72h, but only by whoever thought to
@@ -1053,6 +1192,15 @@ setInterval(() => {
1053
1192
  // log, and nothing to reason about, because any throw in this block vanished.
1054
1193
  console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
1055
1194
  }
1195
+ // 6.71.14: its own try, for the same reason as auto-recovery below. An
1196
+ // active-session file whose session is not live and has been idle past the
1197
+ // retention horizon is moved aside; COS Control reads that directory directly
1198
+ // and would otherwise show it as a live recording indefinitely.
1199
+ try {
1200
+ sweepStaleActiveSessionFilesNow(Date.now())
1201
+ } catch (error) {
1202
+ console.error(`[cleanup] Stale active-session sweep failed: ${error instanceof Error ? error.message : error}`)
1203
+ }
1056
1204
  // DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
1057
1205
  // purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
1058
1206
  // silently never ran — which is exactly what happened in production on 6.23.1
@@ -2076,16 +2224,16 @@ async function processStreamChunk(opts: {
2076
2224
  resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
2077
2225
  )
2078
2226
 
2079
- const pcmData = audioBuffer.subarray(44)
2080
- let sumSq = 0
2081
- const nSamples = Math.floor(pcmData.length / 2)
2082
- for (let i = 0; i < nSamples; i++) {
2083
- const s = pcmData.readInt16LE(i * 2)
2084
- sumSq += s * s
2085
- }
2086
- const rms = Math.sqrt(sumSq / Math.max(1, nSamples))
2087
- const isQuiet = rms < 150
2088
- const whisperAudio = isQuiet ? audioBuffer : await enhanceAudio(audioBuffer)
2227
+ // 6.72.0: the quiet gate and the ASR front end follow the STT settings, read
2228
+ // per chunk so a change applies from the next chunk. Defaults: Silero speech
2229
+ // seconds (RMS 150 fired on 1% of G2 chunks) and highpass only (the full
2230
+ // chain lifted faint background speech over the VAD). Playback audio is
2231
+ // separate and unchanged.
2232
+ const stt = readSttSettings()
2233
+ const quiet = decideQuiet(audioBuffer, stt.quietGate)
2234
+ const rms = quiet.rms
2235
+ const isQuiet = quiet.isQuiet
2236
+ const whisperAudio = isQuiet ? audioBuffer : await asrFrontEnd(audioBuffer, stt.frontEnd)
2089
2237
  const whisperContext = recentWhisperContext(sessionId, session)
2090
2238
  console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
2091
2239
 
@@ -2122,7 +2270,9 @@ async function processStreamChunk(opts: {
2122
2270
  // uncorrected for those sources. Idempotent for the local path.
2123
2271
  rawText = applyCorrections(rawText)
2124
2272
 
2125
- let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
2273
+ // Text drops stay on the pre-6.72 RMS test (see the contract above).
2274
+ const dropQuiet = silenceDropsAllowed(quiet)
2275
+ let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, dropQuiet)
2126
2276
  if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
2127
2277
  fallbackReason = sanitized.fallbackReason || 'empty_candidate'
2128
2278
  const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
@@ -2130,7 +2280,7 @@ async function processStreamChunk(opts: {
2130
2280
  words = result.words
2131
2281
  backend = result.backend
2132
2282
  asrProvider = 'server-whisper'
2133
- sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
2283
+ sanitized = sanitizeStreamTranscript(sessionId, session, rawText, dropQuiet)
2134
2284
  }
2135
2285
 
2136
2286
  const voiceprint = await speakerPromise
@@ -2250,7 +2400,7 @@ async function processStreamChunk(opts: {
2250
2400
  // throws on by default. All gates live inside the feed.
2251
2401
  void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2252
2402
 
2253
- console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
2403
+ console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
2254
2404
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
2255
2405
  }
2256
2406
 
@@ -2277,13 +2427,15 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
2277
2427
  }
2278
2428
 
2279
2429
  /**
2280
- * Default-off meeting preview canary.
2430
+ * Meeting preview (on unless COS_WHISPER_MEETING_PREVIEW=0).
2281
2431
  *
2282
2432
  * This route deliberately owns no meeting session, recovery ledger, speaker
2283
2433
  * attribution, or persistence. It accepts a pinned copy of the still-open
2284
- * phrase and returns provisional Turbo text. Any contention or failure is a
2285
- * 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
2286
- * durable and speaker-attributed result.
2434
+ * phrase and returns provisional text from the preview engine the STT settings
2435
+ * choose (6.72.0: Whisper Turbo, or Parakeet Ultra when selected, warm, and the
2436
+ * client lists `parakeet-ultra` in `previewModels`; every other client gets
2437
+ * Turbo). Any contention or failure is a 204 cosmetic miss; the canonical
2438
+ * /transcribe-stream upload remains the only durable and speaker-attributed result.
2287
2439
  */
2288
2440
  transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2289
2441
  try {
@@ -2316,7 +2468,10 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2316
2468
 
2317
2469
  meetingPreviewBusy = true
2318
2470
  try {
2319
- const result = await transcribeWhisperMeetingPreview(audio)
2471
+ // 6.72.0: the models this client accepts. Absent (every phone before 6.10.639): Turbo only.
2472
+ const rawModels = typeof req.query.previewModels === 'string' ? req.query.previewModels.slice(0, 200) : ''
2473
+ const accepted = rawModels.split(',').map(m => m.trim()).filter(Boolean).slice(0, 8)
2474
+ const result = await transcribeMeetingPreview(audio, accepted)
2320
2475
  if (!result) return void res.status(204).send()
2321
2476
  const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
2322
2477
  if (!cleaned || sharedIsFullHallucination(cleaned)) {