@gotcos/glasses-server 6.71.14 → 6.73.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.
@@ -13,7 +13,8 @@ import { writeFileSync, unlinkSync, existsSync, readFileSync, statSync } from 'n
13
13
  import { basename, join } from 'node:path'
14
14
  import { homedir } from 'node:os'
15
15
  import crypto from 'node:crypto'
16
- import { batchTimestampCanaryEnabled, remapBatchWords } from './whisper-batch-timing.js'
16
+ import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
17
+ import { readSttSettings, type FrontEndId } from './stt-settings.js'
17
18
  import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
18
19
  import { stripBrandUrls } from './hallucination-filter.js'
19
20
  import {
@@ -70,21 +71,34 @@ export interface HighQualityTranscriptionCapability {
70
71
  export interface HighQualityTranscriptionResult {
71
72
  text: string
72
73
  words?: WhisperWord[]
74
+ /** Sub-word tokens merged into spoken words (6.72.0), NOT name-corrected.
75
+ * Clocks are only meaningful when the decode ran with timestamps. */
76
+ spokenWords?: WhisperWord[]
77
+ /** True when this decode ran with timestamps and the VAD remap. */
78
+ timestamps?: boolean
79
+ /** Where this decode's own VAD heard speech (seconds, input clock). Timestamp mode only. */
80
+ speechSpans?: Array<[number, number]>
73
81
  model: 'large-v3' | 'turbo'
74
82
  backend: 'whisper-cli' | 'whisper-server'
75
83
  actualQuality: 'hq' | 'fast'
76
84
  degradationReason?: HighQualityUnavailableReason
77
85
  }
78
86
 
79
- let highQualityCheckpointFingerprint: string | null = null
80
- let highQualityCheckpointTimingMode: boolean | null = null
87
+ const highQualityCheckpointFingerprints = new Map<string, string>()
81
88
 
82
89
  /** Stable, path-free cache identity for progressive meeting HQ checkpoints.
83
90
  * Model/configuration is immutable for the lifetime of one server process, so
84
- * cache this instead of stat'ing multi-GB model files on every health poll. */
85
- export function getHighQualityCheckpointFingerprint(): string {
86
- const timingMode = batchTimestampCanaryEnabled()
87
- if (highQualityCheckpointFingerprint && highQualityCheckpointTimingMode === timingMode) return highQualityCheckpointFingerprint
91
+ * cache this instead of stat'ing multi-GB model files on every health poll.
92
+ * 6.72.0: the timing mode and the ASR front end are part of the identity, so a
93
+ * checkpoint decoded under one setting is never reused under another. With
94
+ * timestamps off and the full front end the identity is the pre-6.72 one, byte
95
+ * for byte. */
96
+ export function getHighQualityCheckpointFingerprint(opts: { timestamps?: boolean; frontEnd?: FrontEndId } = {}): string {
97
+ const timingMode = opts.timestamps ?? batchTimestampsEnabled()
98
+ const frontEnd: FrontEndId = opts.frontEnd ?? readSttSettings().frontEnd
99
+ const cacheKey = `${timingMode ? 1 : 0}|${frontEnd}`
100
+ const cached = highQualityCheckpointFingerprints.get(cacheKey)
101
+ if (cached) return cached
88
102
  const identity = (path: string): Record<string, number | boolean> => {
89
103
  try {
90
104
  const stat = statSync(path)
@@ -93,9 +107,9 @@ export function getHighQualityCheckpointFingerprint(): string {
93
107
  return { present: false, size: 0, mtimeMs: 0 }
94
108
  }
95
109
  }
96
- highQualityCheckpointFingerprint = crypto.createHash('sha256').update(JSON.stringify({
110
+ const fingerprint = crypto.createHash('sha256').update(JSON.stringify({
97
111
  schema: 2,
98
- // Keep the existing legacy identity byte-for-byte when the canary is off.
112
+ // Keep the existing legacy identity byte-for-byte when timestamps are off.
99
113
  ...(timingMode ? { batchTiming: 'native-vad-timestamps-v1' } : {}),
100
114
  serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
101
115
  whisperCli: identity(WHISPER_CLI),
@@ -104,12 +118,12 @@ export function getHighQualityCheckpointFingerprint(): string {
104
118
  beam: 5,
105
119
  bestOf: 5,
106
120
  vadEnabled: hqCliVadEnabled('batch'),
107
- enhancement: 'audio-enhance-v1',
121
+ enhancement: frontEnd === 'full' ? 'audio-enhance-v1' : 'audio-enhance-highpass-v1',
108
122
  prompt: buildWhisperPrompt(),
109
123
  corrections: getWhisperCorrections(),
110
124
  })).digest('hex')
111
- highQualityCheckpointTimingMode = timingMode
112
- return highQualityCheckpointFingerprint
125
+ highQualityCheckpointFingerprints.set(cacheKey, fingerprint)
126
+ return fingerprint
113
127
  }
114
128
 
115
129
  export function classifyHighQualityTranscriptionCapability(input: {
@@ -882,7 +896,7 @@ async function reconcileWhisperServerHealth(): Promise<boolean> {
882
896
  * Batch/save path only. Never uses whisper-server `verbose_json` (the live
883
897
  * VAD-empty crash vector). Special tokens like `[_BEG_]` / `<|...|>` are dropped.
884
898
  */
885
- export function parseWhisperCliFullJson(raw: string): { text: string; words: WhisperWord[] } {
899
+ export function parseWhisperCliFullJson(raw: string): { text: string; words: WhisperWord[]; spokenWords: WhisperWord[] } {
886
900
  const data = JSON.parse(raw) as {
887
901
  transcription?: Array<{
888
902
  text?: unknown
@@ -896,6 +910,7 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
896
910
  const segments = Array.isArray(data.transcription) ? data.transcription : []
897
911
  const texts: string[] = []
898
912
  const words: WhisperWord[] = []
913
+ const spokenWords: WhisperWord[] = []
899
914
 
900
915
  for (const segment of segments) {
901
916
  if (typeof segment.text === 'string' && segment.text.trim()) {
@@ -909,18 +924,30 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
909
924
  const fromMs = typeof token.offsets?.from === 'number' ? token.offsets.from : Number(token.offsets?.from)
910
925
  const toMs = typeof token.offsets?.to === 'number' ? token.offsets.to : Number(token.offsets?.to)
911
926
  if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) continue
912
- words.push({
927
+ const word: WhisperWord = {
913
928
  word: tokenText,
914
929
  start: fromMs / 1000,
915
930
  end: toMs / 1000,
916
931
  probability: typeof token.p === 'number' && Number.isFinite(token.p) ? token.p : 0,
917
- })
932
+ }
933
+ words.push(word)
934
+ // A token without a leading space continues the previous word ("I" + "'m").
935
+ const rawText = typeof token.text === 'string' ? token.text : ''
936
+ const last = spokenWords.at(-1)
937
+ if (last && !/^\s/.test(rawText) && !/^[\s]*$/.test(rawText)) {
938
+ last.word += tokenText
939
+ last.end = Math.max(last.end, word.end)
940
+ last.probability = Math.min(last.probability, word.probability)
941
+ } else {
942
+ spokenWords.push({ ...word })
943
+ }
918
944
  }
919
945
  }
920
946
 
921
947
  return {
922
948
  text: texts.join(' ').replace(/\s+/g, ' ').trim(),
923
949
  words,
950
+ spokenWords,
924
951
  }
925
952
  }
926
953
 
@@ -949,6 +976,8 @@ export async function transcribeHighQuality(
949
976
  threads?: number
950
977
  backgroundCpu?: boolean
951
978
  signal?: AbortSignal
979
+ /** Meeting batch only: timestamps on/off for this decode (the job's snapshot). */
980
+ timestamps?: boolean
952
981
  } = {},
953
982
  ): Promise<HighQualityTranscriptionResult> {
954
983
  if (!cliAvailable) {
@@ -972,7 +1001,9 @@ export async function transcribeHighQuality(
972
1001
  // Word clocks on post-meeting CPU polish, and on any decode that asks for
973
1002
  // them (the prompt tail guard). Other live decodes stay compact JSON.
974
1003
  const captureWords = opts.priority === 'batch' || opts.words === true
975
- const timestampCanary = opts.priority === 'batch' && batchTimestampCanaryEnabled()
1004
+ // 6.72.0: on by default for meeting batch (HQ setting "large-v3"); the job
1005
+ // passes its own snapshot so a settings change never lands mid-meeting.
1006
+ const timestampCanary = opts.priority === 'batch' && (opts.timestamps ?? batchTimestampsEnabled())
976
1007
  let nativeDiagnostics = ''
977
1008
 
978
1009
  const modelPath = resolveBatchModel()
@@ -1144,6 +1175,8 @@ export async function transcribeHighQuality(
1144
1175
 
1145
1176
  let finalText = text
1146
1177
  let words: WhisperWord[] | undefined
1178
+ let spokenWords: WhisperWord[] | undefined
1179
+ let speechSpans: Array<[number, number]> | undefined
1147
1180
  if (captureWords) {
1148
1181
  try {
1149
1182
  if (existsSync(jsonPath)) {
@@ -1157,6 +1190,11 @@ export async function transcribeHighQuality(
1157
1190
  words = timed.length > 0
1158
1191
  ? timed.map(w => ({ ...w, word: applyCorrections(w.word) }))
1159
1192
  : []
1193
+ // Spoken words stay uncorrected: the resolver joins them and corrects the text once.
1194
+ spokenWords = timestampCanary ? remapBatchWords(parsed.spokenWords, nativeDiagnostics, useVad) : parsed.spokenWords
1195
+ if (timestampCanary && useVad && parsed.words.length) {
1196
+ speechSpans = parseNativeVadTimeline(nativeDiagnostics).map(s => [s.originalStart, s.originalEnd] as [number, number])
1197
+ }
1160
1198
  } else if (timestampCanary) {
1161
1199
  throw new Error('HQ timestamp candidate did not produce CLI JSON')
1162
1200
  }
@@ -1193,7 +1231,10 @@ export async function transcribeHighQuality(
1193
1231
  ? 'large_v3_model_missing' as const
1194
1232
  : 'hq_disabled' as const,
1195
1233
  }
1196
- return words ? { text: corrected, words, ...metadata } : { text: corrected, ...metadata }
1234
+ const timing = isBatch ? { timestamps: timestampCanary } : {}
1235
+ return words
1236
+ ? { text: corrected, words, ...(spokenWords ? { spokenWords } : {}), ...(speechSpans ? { speechSpans } : {}), ...timing, ...metadata }
1237
+ : { text: corrected, ...timing, ...metadata }
1197
1238
  } finally {
1198
1239
  try { unlinkSync(tmpWav) } catch { /* cleanup */ }
1199
1240
  if (captureWords) {
@@ -66,6 +66,7 @@ import {
66
66
  import {
67
67
  canDeletePendingBatchAudio,
68
68
  persistBatchDecisionSidecar,
69
+ persistSttSidecar,
69
70
  replaceMeetingTranscriptAtomic,
70
71
  } from '../lib/meeting-batch-persistence.js'
71
72
  import {
@@ -77,6 +78,8 @@ import {
77
78
  import {
78
79
  enqueueSerializedHqWork,
79
80
  runMeetingBatchPipeline,
81
+ recoveryHqJob,
82
+ summarizeStt,
80
83
  segmentTranscriptChunks,
81
84
  stopProgressiveHqSession,
82
85
  transcribeSegments,
@@ -2178,9 +2181,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2178
2181
  // decoder serialization — that lives in the queue tail. Chain onto it,
2179
2182
  // or two back-to-back recoveries plus a live post-meeting batch would
2180
2183
  // run parallel 16-thread large-v3 decoders on the user's Mac.
2181
- const results = await enqueueSerializedHqWork(
2182
- () => transcribeSegments(quarantineDir, segments, entries),
2183
- )
2184
+ const hqJob = recoveryHqJob(quarantineDir, segments, entries)
2185
+ const plan = hqJob.plan
2186
+ const results = await enqueueSerializedHqWork(hqJob.run)
2184
2187
  // Batch whisper over a dead session has no streaming baseline to
2185
2188
  // compare against (evaluateBatchQuality needs one), but the inline
2186
2189
  // hallucination filter needs none — run the same two-pass the boot
@@ -2240,6 +2243,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2240
2243
  })),
2241
2244
  transferIntegrity: null,
2242
2245
  })
2246
+ try { persistSttSidecar(saved.sidecarPath, results, summarizeStt(plan, results, segments.length)) } catch (error) {
2247
+ console.warn(`[meeting/orphans] ${sessionId}: STT hypotheses not saved: ${error instanceof Error ? error.message : error}`)
2248
+ }
2243
2249
  markRecovered(quarantineDir, saved.filename)
2244
2250
  console.log(
2245
2251
  `[meeting/orphans] Recovered ${sessionId} → ${saved.filename} `
@@ -40,6 +40,8 @@ import { applyCorrections } from '../lib/whisper-local.js'
40
40
  import { applyFuzzyCorrections } from '../lib/fuzzy-correct.js'
41
41
  import { getAllSpeakerNames } from '../lib/speaker-embeddings.js'
42
42
  import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
43
+ import { currentDictationPlan, runParakeetDictation } from '../lib/dictation-stt.js'
44
+ import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '../lib/stt-settings.js'
43
45
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
44
46
  import { getVocabulary } from '../lib/profile.js'
45
47
  import { createBreaker } from '../lib/claude-circuit.js'
@@ -240,14 +242,25 @@ async function sendDraftError(res: Response, draftId: string, err: any): Promise
240
242
  res.status(err.status ?? 500).json({ error: err.message })
241
243
  }
242
244
 
243
- function modeQualityKey(draftId: string, chunkIndex: number, mode: 'hq' | 'fast', hash: string): string {
244
- return `${draftId}:${chunkIndex}:${mode}:${hash}`
245
+ // 6.73.0: the setting key is part of every decode key, so a decode made under
246
+ // one STT selection is never shared with or reused for another.
247
+ function modeQualityKey(draftId: string, chunkIndex: number, mode: 'hq' | 'fast', hash: string, sttKey: string): string {
248
+ return `${draftId}:${chunkIndex}:${mode}:${hash}:${sttKey}`
245
249
  }
246
250
 
247
- async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffer, mode: 'hq' | 'fast', purpose: 'warm' | 'final'): Promise<string> {
251
+ /** The setting key a stored record was made under (pre-6.73 records: the 6.72.0 defaults). */
252
+ export function recordSttKey(record: PromptDraftTranscriptRecord): string {
253
+ return record.sttKey ?? legacyDictationKey(record.requestedMode)
254
+ }
255
+
256
+ function planFor(mode: 'hq' | 'fast'): DictationPlan {
257
+ return currentDictationPlan(dictationSlotForMode(mode))
258
+ }
259
+
260
+ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffer, mode: 'hq' | 'fast', purpose: 'warm' | 'final', plan: DictationPlan = planFor(mode)): Promise<string> {
248
261
  const hash = audioHash(audio)
249
- const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}`
250
- const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash)
262
+ const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}:${plan.key}`
263
+ const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash, plan.key)
251
264
 
252
265
  // HQ warm and HQ finalize must share one decode (plan step 8a).
253
266
  const existingShared = modeQualityJobs.get(sharedKey)
@@ -276,6 +289,9 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
276
289
  backend: warmMatches ? warm!.backend : 'shared-inflight',
277
290
  degraded: warmMatches ? warm!.degraded : false,
278
291
  ...(warmMatches && warm!.degradationReason ? { degradationReason: warm!.degradationReason } : {}),
292
+ sttKey: plan.key,
293
+ ...(warmMatches && warm!.engine ? { engine: warm!.engine } : {}),
294
+ ...(warmMatches && warm!.engineReason ? { engineReason: warm!.engineReason } : {}),
279
295
  }
280
296
  if (
281
297
  !cachedFinal
@@ -298,7 +314,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
298
314
  try {
299
315
  // Speculative warm is always local-only. Finalize may use automatic cloud fallback.
300
316
  const policy = purpose === 'warm' ? 'local-only' as const : 'automatic' as const
301
- const result = await transcribeAudioBuffer(audio, { mode, policy })
317
+ const result = await transcribeAudioBuffer(audio, { mode, policy, dictationPlan: plan })
302
318
  if (!isCurrentChunk(draftId, chunkIndex, audio)) return ''
303
319
  // 6.45.4 — the sentence Whisper invents after the speaker stops. Never trims
304
320
  // audio; drops a trailing sentence only on the junk lexicon or on silence
@@ -313,9 +329,11 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
313
329
  text, hash, requestedMode: result.requestedMode, actualQuality: result.actualQuality,
314
330
  backend: result.backend, degraded: result.degraded,
315
331
  ...(result.degradationReason ? { degradationReason: result.degradationReason } : {}),
332
+ sttKey: result.dictationKey, engine: result.engine,
333
+ ...(result.engineReason ? { engineReason: result.engineReason } : {}),
316
334
  }
317
335
  await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
318
- console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
336
+ console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend}${result.engineReason ? ` (${result.engineReason})` : ''} | ${purpose}/${mode} | ${text.length} chars`)
319
337
  return text
320
338
  } catch (err) {
321
339
  if (err instanceof NoSpeechDetectedError) {
@@ -324,7 +342,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
324
342
  // recognized failure shape instead of persisting either the artifact
325
343
  // or an empty gap. Other no-speech results retain existing behavior.
326
344
  if (mode === 'hq' && isCaptionCreditHallucination(err.rawText)) {
327
- const fastJob = modeQualityJobs.get(modeQualityKey(draftId, chunkIndex, 'fast', hash))
345
+ const fastJob = [...modeQualityJobs.entries()].find(([key]) => key.startsWith(`${draftId}:${chunkIndex}:fast:${hash}:`))?.[1]
328
346
  if (fastJob) {
329
347
  try { await fastJob } catch { /* normal no-speech path below */ }
330
348
  }
@@ -342,6 +360,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
342
360
  requestedMode: 'hq',
343
361
  degraded: true,
344
362
  degradationReason: 'hq_caption_credit_hallucination',
363
+ sttKey: plan.key,
345
364
  }, 'final')
346
365
  }
347
366
  console.warn(`[prompt-draft] dropped HQ caption-credit hallucination ${draftId}/${chunkIndex}; retained Fast text`)
@@ -349,7 +368,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
349
368
  }
350
369
  }
351
370
  await markPromptDraftChunkTranscript(draftId, chunkIndex, {
352
- text: '', hash, requestedMode: mode, actualQuality: mode, backend: 'no-speech', degraded: false,
371
+ text: '', hash, requestedMode: mode, actualQuality: mode, backend: 'no-speech', degraded: false, sttKey: plan.key,
353
372
  }, purpose)
354
373
  return ''
355
374
  }
@@ -364,12 +383,15 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
364
383
  return job
365
384
  }
366
385
 
367
- function isReusableTranscript(
386
+ export function isReusableTranscript(
368
387
  cached: PromptDraftTranscriptRecord | undefined,
369
388
  hash: string,
370
389
  mode: 'hq' | 'fast',
390
+ sttKey: string,
371
391
  ): boolean {
372
392
  if (!cached || cached.hash !== hash || cached.requestedMode !== mode) return false
393
+ // 6.73.0: a decode made under a different STT selection is decoded again.
394
+ if (recordSttKey(cached) !== sttKey) return false
373
395
  if (cached.backend.startsWith('legacy')) return false
374
396
  if (mode === 'fast') return cached.actualQuality === 'fast' || cached.actualQuality === 'cloud'
375
397
  // mode === 'hq'
@@ -385,12 +407,14 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
385
407
  if (!meta) throw Object.assign(new Error('draft not found'), { status: 404 })
386
408
  const texts: string[] = []
387
409
  const transcriptRecords: PromptDraftTranscriptRecord[] = []
410
+ // One STT snapshot for the whole finalize: a settings change mid-finalize never mixes selections.
411
+ const plan = planFor(mode)
388
412
  for (const chunk of readPromptDraftChunks(draftId)) {
389
413
  try {
390
414
  const hash = audioHash(chunk.audioBuffer)
391
415
  // Await in-flight HQ warm before deciding cache miss (plan step 8b belt).
392
416
  if (mode === 'hq') {
393
- const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash))
417
+ const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash, plan.key))
394
418
  if (inflight) {
395
419
  try { await inflight } catch { /* finalize may retry with automatic below */ }
396
420
  }
@@ -401,8 +425,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
401
425
  // When Settings HQ is available, never treat a degraded turbo warm as final
402
426
  // — that left finished chats on Fast text while REVIEW looked "HQ ready".
403
427
  // Legacy records stay excluded (reconstructed provenance).
404
- const reusable = isReusableTranscript(cached, hash, mode)
405
- const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
428
+ const reusable = isReusableTranscript(cached, hash, mode, plan.key)
429
+ const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final', plan)
406
430
  const text = sanitizeTranscript(draftId, raw, !reusable)
407
431
  if (text.trim()) {
408
432
  texts.push(text.trim())
@@ -433,6 +457,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
433
457
  const degraded = mode === 'hq' && (actualQuality !== 'hq' || transcriptRecords.some(record => record.degraded))
434
458
  const backends = [...new Set(transcriptRecords.map(record => record.backend))]
435
459
  const degradationReason = transcriptRecords.find(record => record.degradationReason)?.degradationReason
460
+ const engines = [...new Set(transcriptRecords.map(record => record.engine ?? 'whisper'))]
461
+ const engineReason = transcriptRecords.find(record => record.engineReason)?.engineReason
436
462
  return {
437
463
  draftId,
438
464
  text: finalText,
@@ -445,6 +471,9 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
445
471
  degraded,
446
472
  backend: backends.length === 1 ? backends[0] : 'mixed',
447
473
  ...(degradationReason ? { degradationReason } : {}),
474
+ // 6.73.0, additive.
475
+ engine: engines.length === 1 ? engines[0] : engines.length === 0 ? plan.engine : 'mixed',
476
+ ...(engineReason ? { engineReason } : {}),
448
477
  }
449
478
  }
450
479
 
@@ -513,15 +542,24 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/peek', async (req, res) => {
513
542
  peekTail = peekTail.then(async () => {
514
543
  lease.setPhase('active')
515
544
  try {
516
- const result = await transcribeWhisperPreview(audio)
545
+ // 6.73.0: the live preview follows the Preview model. Parakeet runs on the
546
+ // shared one-at-a-time preview lane; any miss is the 6.72.0 Whisper peek.
547
+ const plan = currentDictationPlan('preview')
548
+ let raw: string | null = null
549
+ let engine: 'whisper' | 'parakeet-ultra' = 'whisper'
550
+ if (plan.engine === 'parakeet-ultra') {
551
+ const outcome = await runParakeetDictation(audio, plan, 'preview')
552
+ if (outcome.ok) { raw = applyCorrections(outcome.text); engine = 'parakeet-ultra' }
553
+ }
554
+ if (raw === null) raw = (await transcribeWhisperPreview(audio)).text
517
555
  // 6.45.4: the same whole-sentence filler rule as the commit path, so an
518
556
  // invented closing line never paints on the lens only to vanish later.
519
- const text = sanitizeTranscript(draftId, guardPromptTail({ text: result.text, speech: SPEECH_UNKNOWN }).text, false)
557
+ const text = sanitizeTranscript(draftId, guardPromptTail({ text: raw, speech: SPEECH_UNKNOWN }).text, false)
520
558
  if (!text) return
521
559
  if (!loadPromptDraftMeta(draftId)) return
522
560
  emitDisplay({
523
561
  type: 'prompt_transcript',
524
- data: { draftId, chunkIndex, text, provisional: true, peekGen },
562
+ data: { draftId, chunkIndex, text, provisional: true, peekGen, engine },
525
563
  })
526
564
  } catch (err: any) {
527
565
  if (!(err instanceof NoSpeechDetectedError)) {
@@ -575,9 +613,10 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
575
613
  // transcript only after rechecking that this exact audio still owns the
576
614
  // chunk index; a retry/replacement must never paint stale words.
577
615
  if (!isCurrentChunk(req.params.draftId, chunkIndex, audio)) return
616
+ const warm = loadPromptDraftMeta(req.params.draftId)?.warmTranscripts?.[String(chunkIndex)]
578
617
  emitDisplay({
579
618
  type: 'prompt_transcript',
580
- data: { draftId: req.params.draftId, chunkIndex, text },
619
+ data: { draftId: req.params.draftId, chunkIndex, text, ...(warm?.requestedMode === 'fast' && warm.engine ? { engine: warm.engine } : {}) },
581
620
  })
582
621
  }).catch(err => {
583
622
  console.warn(`[prompt-draft] warm transcription failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
@@ -0,0 +1,85 @@
1
+ // Speech-to-text settings (6.72.0). Token-protected by the /api middleware.
2
+ //
3
+ // GET /api/stt-settings the settings, every option with availability, Parakeet status
4
+ // PUT /api/stt-settings partial body: {"hq":"parakeet-ultra"}, {"resolver":false}, ...
5
+ // POST /api/stt-settings/parakeet/install install the pinned Parakeet CLI and models (202; poll GET)
6
+ // body {} | {"localSource":"/abs/path"} | {"localSource":"...","allowDownload":true}
7
+ // DELETE /api/stt-settings/parakeet/install cancel a running install (nothing partial is kept)
8
+ //
9
+ // Clients find the route with GET: a 404 means an older server, so they hide the switcher.
10
+ // The contract is shared with COS Control and the phone (see README "Speech-to-text settings").
11
+
12
+ import { Router } from 'express'
13
+ import { errMsg } from '../lib/utils.js'
14
+ import { cancelParakeetInstall, installParakeet, parakeetInstalled, parakeetInstallRunning, type InstallOptions } from '../lib/parakeet.js'
15
+ import { currentSttCapabilities, ensurePreviewWarm, parakeetStatus } from '../lib/stt-runtime.js'
16
+ import {
17
+ applySettingsPatch, readLastSttJob, readSttSettings, settingsView, SttSettingsError, writeSttSettings,
18
+ type SttCapabilities,
19
+ } from '../lib/stt-settings.js'
20
+
21
+ export interface SttSettingsRouterDeps {
22
+ recordingLive: () => boolean
23
+ capabilities?: () => SttCapabilities
24
+ install?: (options: InstallOptions) => Promise<void>
25
+ onChanged?: () => void
26
+ }
27
+
28
+ export function createSttSettingsRouter(deps: SttSettingsRouterDeps): Router {
29
+ const router = Router()
30
+ const caps = deps.capabilities ?? currentSttCapabilities
31
+ const install = deps.install ?? installParakeet
32
+ const view = () => settingsView(readSttSettings(), caps(), parakeetStatus(deps.recordingLive()), readLastSttJob())
33
+
34
+ router.get('/stt-settings', (_req, res) => {
35
+ try { res.json(view()) } catch (err) { res.status(500).json({ error: errMsg(err) }) }
36
+ })
37
+
38
+ router.put('/stt-settings', (req, res) => {
39
+ try {
40
+ const next = applySettingsPatch(readSttSettings(), req.body, caps())
41
+ writeSttSettings(next)
42
+ ensurePreviewWarm()
43
+ deps.onChanged?.()
44
+ res.json(view())
45
+ } catch (err) {
46
+ if (err instanceof SttSettingsError) return void res.status(400).json({ error: err.message })
47
+ res.status(500).json({ error: errMsg(err) })
48
+ }
49
+ })
50
+
51
+ router.post('/stt-settings/parakeet/install', (req, res) => {
52
+ try {
53
+ if (deps.recordingLive()) return void res.status(409).json({ error: 'A recording is live. Install Parakeet after it is saved.' })
54
+ if (parakeetInstalled().installed && !parakeetInstallRunning()) return void res.status(200).json({ started: false, ...view() })
55
+ const body = (req.body ?? {}) as { localSource?: unknown; allowDownload?: unknown }
56
+ const raw = body.localSource
57
+ if (raw !== undefined && (typeof raw !== 'string' || !raw.startsWith('/') || raw.length > 1024)) {
58
+ return void res.status(400).json({ error: 'localSource must be an absolute path.' })
59
+ }
60
+ if (body.allowDownload !== undefined && typeof body.allowDownload !== 'boolean') return void res.status(400).json({ error: 'allowDownload must be true or false.' })
61
+ // Only bytes whose SHA-256 matches the pinned manifest are ever copied from it.
62
+ // A named localSource that lacks a file fails plainly unless allowDownload is true.
63
+ const options: InstallOptions = raw ? { localSources: [raw], requireLocal: body.allowDownload !== true } : {}
64
+ if (!parakeetInstallRunning()) {
65
+ void install(options).then(() => { ensurePreviewWarm(); deps.onChanged?.() }).catch(error => {
66
+ console.warn(`[stt] Parakeet install failed: ${errMsg(error)}`)
67
+ })
68
+ }
69
+ res.status(202).json({ started: true, ...view() })
70
+ } catch (err) {
71
+ res.status(500).json({ error: errMsg(err) })
72
+ }
73
+ })
74
+
75
+ router.delete('/stt-settings/parakeet/install', (_req, res) => {
76
+ try {
77
+ const cancelled = cancelParakeetInstall()
78
+ res.status(cancelled ? 202 : 200).json({ cancelled, ...view() })
79
+ } catch (err) {
80
+ res.status(500).json({ error: errMsg(err) })
81
+ }
82
+ })
83
+
84
+ return router
85
+ }
@@ -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 {
@@ -148,8 +149,12 @@ function ensurePrivateDirectory(path: string): void {
148
149
 
149
150
  // Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
150
151
  // brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
151
- // any-URL-only / repeated-thank-you -> dropped only when isQuiet (rms<150), so a real
152
- // 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.
153
158
  // Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
154
159
  const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
155
160
  const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
@@ -2219,16 +2224,16 @@ async function processStreamChunk(opts: {
2219
2224
  resolve(SESSION_AUDIO_DIR, sessionId, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`),
2220
2225
  )
2221
2226
 
2222
- const pcmData = audioBuffer.subarray(44)
2223
- let sumSq = 0
2224
- const nSamples = Math.floor(pcmData.length / 2)
2225
- for (let i = 0; i < nSamples; i++) {
2226
- const s = pcmData.readInt16LE(i * 2)
2227
- sumSq += s * s
2228
- }
2229
- const rms = Math.sqrt(sumSq / Math.max(1, nSamples))
2230
- const isQuiet = rms < 150
2231
- 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)
2232
2237
  const whisperContext = recentWhisperContext(sessionId, session)
2233
2238
  console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
2234
2239
 
@@ -2265,7 +2270,9 @@ async function processStreamChunk(opts: {
2265
2270
  // uncorrected for those sources. Idempotent for the local path.
2266
2271
  rawText = applyCorrections(rawText)
2267
2272
 
2268
- 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)
2269
2276
  if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
2270
2277
  fallbackReason = sanitized.fallbackReason || 'empty_candidate'
2271
2278
  const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
@@ -2273,7 +2280,7 @@ async function processStreamChunk(opts: {
2273
2280
  words = result.words
2274
2281
  backend = result.backend
2275
2282
  asrProvider = 'server-whisper'
2276
- sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
2283
+ sanitized = sanitizeStreamTranscript(sessionId, session, rawText, dropQuiet)
2277
2284
  }
2278
2285
 
2279
2286
  const voiceprint = await speakerPromise
@@ -2393,7 +2400,7 @@ async function processStreamChunk(opts: {
2393
2400
  // throws on by default. All gates live inside the feed.
2394
2401
  void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2395
2402
 
2396
- 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)}"`)
2397
2404
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
2398
2405
  }
2399
2406
 
@@ -2420,13 +2427,15 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
2420
2427
  }
2421
2428
 
2422
2429
  /**
2423
- * Default-off meeting preview canary.
2430
+ * Meeting preview (on unless COS_WHISPER_MEETING_PREVIEW=0).
2424
2431
  *
2425
2432
  * This route deliberately owns no meeting session, recovery ledger, speaker
2426
2433
  * attribution, or persistence. It accepts a pinned copy of the still-open
2427
- * phrase and returns provisional Turbo text. Any contention or failure is a
2428
- * 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
2429
- * 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.
2430
2439
  */
2431
2440
  transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2432
2441
  try {
@@ -2459,7 +2468,10 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2459
2468
 
2460
2469
  meetingPreviewBusy = true
2461
2470
  try {
2462
- 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)
2463
2475
  if (!result) return void res.status(204).send()
2464
2476
  const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
2465
2477
  if (!cleaned || sharedIsFullHallucination(cleaned)) {