@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.
@@ -0,0 +1,431 @@
1
+ // Speech-to-text settings (6.72.0): which engine previews live speech, which
2
+ // engine writes the saved meeting (HQ), which engine gives HQ a second opinion,
3
+ // whether the resolver may use it, the ASR input front end and the quiet gate.
4
+ //
5
+ // Stored in <data>/stt-settings.json (atomic). Read on every use with an mtime
6
+ // check, so a change applies without a restart: the preview, front end and
7
+ // quiet gate from the next live chunk; HQ, second opinion and resolver from the
8
+ // next saved meeting (an HQ job snapshots the settings when it starts and never
9
+ // switches engines mid-job).
10
+ //
11
+ // Defaults and the evidence behind them: docs/stt-672-evaluation.md.
12
+
13
+ import { readFileSync, statSync } from 'node:fs'
14
+ import { atomicWriteFileSync } from './atomic-fs.js'
15
+ import { dataPath } from './data-dir.js'
16
+
17
+ export const PREVIEW_IDS = ['turbo', 'large-v3', 'parakeet', 'nemotron-560'] as const
18
+ export const HQ_IDS = ['large-v3', 'large-v3-legacy', 'parakeet-ultra'] as const
19
+ export const SECOND_OPINION_IDS = ['off', 'parakeet-ultra', 'large-v3'] as const
20
+ export const FRONT_END_IDS = ['highpass', 'full'] as const
21
+ export const QUIET_GATE_IDS = ['silero', 'rms'] as const
22
+
23
+ export type PreviewId = typeof PREVIEW_IDS[number]
24
+ export type HqId = typeof HQ_IDS[number]
25
+ export type SecondOpinionId = typeof SECOND_OPINION_IDS[number]
26
+ export type FrontEndId = typeof FRONT_END_IDS[number]
27
+ export type QuietGateId = typeof QUIET_GATE_IDS[number]
28
+
29
+ export interface SttSettings {
30
+ preview: PreviewId
31
+ hq: HqId
32
+ secondOpinion: SecondOpinionId
33
+ resolver: boolean
34
+ frontEnd: FrontEndId
35
+ quietGate: QuietGateId
36
+ }
37
+
38
+ /** Items 1 to 3 on. Second opinion off and the resolver off: on the shipped
39
+ * path (production 30 s windows, docs/stt-672-evaluation.md) neither
40
+ * resolver configuration passed the adoption rule (Large-v3 + Ultra -1.6% WER,
41
+ * CI -4.7 to +1.1), so Large-v3 alone writes the meeting. Parakeet Ultra stays
42
+ * installable for the preview and as the HQ primary. */
43
+ export const DEFAULT_STT_SETTINGS: Readonly<SttSettings> = Object.freeze({
44
+ preview: 'turbo',
45
+ hq: 'large-v3',
46
+ secondOpinion: 'off',
47
+ resolver: false,
48
+ frontEnd: 'highpass',
49
+ quietGate: 'silero',
50
+ })
51
+
52
+ export const STT_SETTINGS_FILE = 'stt-settings.json'
53
+ const settingsPath = () => dataPath(STT_SETTINGS_FILE)
54
+
55
+ function pick<T extends string>(ids: readonly T[], value: unknown, fallback: T): T {
56
+ return typeof value === 'string' && (ids as readonly string[]).includes(value) ? value as T : fallback
57
+ }
58
+
59
+ /** Unknown or damaged stored values fall back to the default, key by key. */
60
+ export function normalizeStoredSettings(raw: unknown): SttSettings {
61
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
62
+ return {
63
+ preview: pick(PREVIEW_IDS, o.preview, DEFAULT_STT_SETTINGS.preview),
64
+ hq: pick(HQ_IDS, o.hq, DEFAULT_STT_SETTINGS.hq),
65
+ secondOpinion: pick(SECOND_OPINION_IDS, o.secondOpinion, DEFAULT_STT_SETTINGS.secondOpinion),
66
+ resolver: typeof o.resolver === 'boolean' ? o.resolver : DEFAULT_STT_SETTINGS.resolver,
67
+ frontEnd: pick(FRONT_END_IDS, o.frontEnd, DEFAULT_STT_SETTINGS.frontEnd),
68
+ quietGate: pick(QUIET_GATE_IDS, o.quietGate, DEFAULT_STT_SETTINGS.quietGate),
69
+ }
70
+ }
71
+
72
+ let cache: { mtimeMs: number; size: number; value: SttSettings } | null = null
73
+ let warnedCorrupt = false
74
+
75
+ /** Stored settings (no capability check). Cheap enough for every live chunk. */
76
+ export function readSttSettings(): SttSettings {
77
+ const path = settingsPath()
78
+ let stat: { mtimeMs: number; size: number } | null = null
79
+ try { const s = statSync(path); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { stat = null }
80
+ if (!stat) { cache = null; return { ...DEFAULT_STT_SETTINGS } }
81
+ if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return { ...cache.value }
82
+ let value: SttSettings
83
+ try { value = normalizeStoredSettings(JSON.parse(readFileSync(path, 'utf8'))) } catch (error) {
84
+ // Unreadable settings run the defaults; say so once, not on every chunk.
85
+ if (!warnedCorrupt) { warnedCorrupt = true; console.warn(`[stt] ${STT_SETTINGS_FILE} is unreadable (${error instanceof Error ? error.message : error}); using the defaults until it is rewritten`) }
86
+ value = { ...DEFAULT_STT_SETTINGS }
87
+ }
88
+ cache = { ...stat, value }
89
+ return { ...value }
90
+ }
91
+
92
+ export function writeSttSettings(next: SttSettings): void {
93
+ atomicWriteFileSync(settingsPath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 })
94
+ cache = null
95
+ }
96
+
97
+ export function __resetSttSettingsCacheForTests(): void { cache = null; warnedCorrupt = false }
98
+
99
+ // ── Last HQ job (for GET: what actually happened, not only what was chosen) ──
100
+
101
+ export interface LastSttJob {
102
+ at: string
103
+ meeting: string
104
+ outcome: 'batch' | 'rejected' | 'failed'
105
+ summary: unknown
106
+ }
107
+ const LAST_JOB_FILE = 'stt-last-job.json'
108
+
109
+ export function recordLastSttJob(job: LastSttJob): void {
110
+ try { atomicWriteFileSync(dataPath(LAST_JOB_FILE), JSON.stringify(job, null, 2) + '\n', { mode: 0o600 }) } catch { /* telemetry only */ }
111
+ }
112
+
113
+ export function readLastSttJob(): LastSttJob | null {
114
+ try { return JSON.parse(readFileSync(dataPath(LAST_JOB_FILE), 'utf8')) as LastSttJob } catch { return null }
115
+ }
116
+
117
+ // ── Capabilities ──────────────────────────────────────────────────────────────
118
+
119
+ export interface SttCapabilities {
120
+ turboModel: boolean
121
+ /** The live commit worker runs Large-v3 (the Max tier). */
122
+ liveLargeV3: boolean
123
+ /** whisper-cli plus Large-v3 weights for meeting HQ. */
124
+ hqLargeV3: boolean
125
+ parakeetInstalled: boolean
126
+ parakeetPreviewWarm: boolean
127
+ parakeetPreviewWarming: boolean
128
+ /** The warm-up ran and failed (distinct from still warming). */
129
+ parakeetPreviewWarmFailed?: boolean
130
+ /** Repeated preview misses opened the preview breaker: Turbo until it closes. */
131
+ parakeetPreviewBreakerOpen?: boolean
132
+ previewDisabledByEnv: boolean
133
+ /** 6.73.0: COS_DICTATION_FOLLOWS_STT=0 keeps every dictation decode on its 6.72.0 Whisper engine. */
134
+ dictationDisabledByEnv?: boolean
135
+ }
136
+
137
+ export interface SttOption {
138
+ id: string
139
+ label: string
140
+ available: boolean
141
+ experimental: boolean
142
+ note: string
143
+ }
144
+
145
+ /** Every preview `model` string this server can return (contract-tested;
146
+ * a subset of what phone 6.10.639 accepts). */
147
+ export const PREVIEW_MODEL_STRINGS = ['large-v3-turbo', 'parakeet-ultra'] as const
148
+ /** The request parameter a client uses to say which preview models it accepts. */
149
+ export const PREVIEW_MODELS_PARAM = 'previewModels'
150
+ export const PREVIEW_REQUIRES_APP = '6.10.639'
151
+
152
+ export function previewOptions(c: SttCapabilities): SttOption[] {
153
+ const parakeetNote = !c.parakeetInstalled
154
+ ? 'Install Parakeet on this Mac first.'
155
+ : c.parakeetPreviewBreakerOpen
156
+ ? 'Parakeet missed several previews in a row; Whisper Turbo previews for a few minutes, then Parakeet is tried again.'
157
+ : c.parakeetPreviewWarm
158
+ ? 'Parakeet Ultra on the Neural Engine, about 0.3 s per chunk. Experimental: compare it live yourself. Meeting previews need COS Glasses 6.10.639 (older phones keep Whisper Turbo there). Dictation previews and Fast mode use it on every phone.'
159
+ : c.parakeetPreviewWarmFailed
160
+ ? 'Parakeet could not start; Whisper Turbo previews. It is tried again every 2 minutes.'
161
+ : c.parakeetPreviewWarming
162
+ ? 'Warming up (the first load takes 15 to 25 s). Whisper Turbo previews until it is ready.'
163
+ : 'Warms up when selected and no recording is live. Whisper Turbo previews until it is ready.'
164
+ return [
165
+ { id: 'turbo', label: 'Whisper Turbo', available: c.turboModel && !c.previewDisabledByEnv, experimental: false,
166
+ note: c.previewDisabledByEnv ? 'Meeting preview is turned off on this server (COS_WHISPER_MEETING_PREVIEW=0).' : c.turboModel ? 'Fast provisional text. Saved text never comes from the preview.' : 'Turbo weights are missing on this Mac.' },
167
+ { id: 'large-v3', label: 'Whisper Large-v3', available: false, experimental: false,
168
+ note: 'Not offered: a Large-v3 preview would share the live Large-v3 worker that writes the saved text.' },
169
+ { id: 'parakeet', label: 'Parakeet Ultra', available: c.parakeetInstalled && !c.previewDisabledByEnv, experimental: true, note: parakeetNote },
170
+ { id: 'nemotron-560', label: 'Nemotron streaming 560 ms', available: false, experimental: true,
171
+ note: 'Not wired in this server: FluidAudio 0.17.4 runs Nemotron only as a whole-file batch, so it cannot stream a live preview yet.' },
172
+ ]
173
+ }
174
+
175
+ export function hqOptions(c: SttCapabilities): SttOption[] {
176
+ const lv3 = c.hqLargeV3 ? '' : 'Needs whisper-cli and the Large-v3 weights. '
177
+ return [
178
+ { id: 'large-v3', label: 'Whisper Large-v3', available: c.hqLargeV3, experimental: false, note: `${lv3}Word timestamps on, mapped through Whisper's own speech segments.`.trim() },
179
+ { id: 'large-v3-legacy', label: 'Whisper Large-v3 (legacy decoder)', available: c.hqLargeV3, experimental: false, note: `${lv3}The pre-6.72 decoder without timestamps. Kept for rollback.`.trim() },
180
+ { id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: true,
181
+ note: c.parakeetInstalled ? 'Lower silver WER than Large-v3, weaker on names; see the evaluation.' : 'Install Parakeet on this Mac first.' },
182
+ ]
183
+ }
184
+
185
+ export function secondOpinionOptions(c: SttCapabilities): SttOption[] {
186
+ return [
187
+ { id: 'off', label: 'Off', available: true, experimental: false, note: 'One engine only.' },
188
+ { id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: false,
189
+ note: c.parakeetInstalled ? 'Hears every saved meeting again on the Neural Engine, about 0.6 s per 30 s of audio, after the meeting is saved. Disagreements are saved with the meeting.' : 'Install Parakeet on this Mac first.' },
190
+ { id: 'large-v3', label: 'Whisper Large-v3', available: c.hqLargeV3, experimental: false, note: 'For a Parakeet HQ model. Adds a full Large-v3 pass to every saved meeting: about 0.2x the audio length on Metal, about 1x on a busy CPU.' },
191
+ ]
192
+ }
193
+
194
+ const FRONT_END_OPTIONS: SttOption[] = [
195
+ { id: 'highpass', label: 'Highpass only', available: true, experimental: false, note: '80 Hz highpass. Does not lift faint background speech over the speech detector.' },
196
+ { id: 'full', label: 'Full chain', available: true, experimental: false, note: 'Highpass, noise reduction and loudness normalization (before 6.72).' },
197
+ ]
198
+ const QUIET_GATE_OPTIONS: SttOption[] = [
199
+ { id: 'silero', label: 'Speech detector', available: true, experimental: false, note: 'A chunk with under 0.5 s of detected speech is treated as quiet.' },
200
+ { id: 'rms', label: 'Volume threshold', available: true, experimental: false, note: 'RMS below 150 (before 6.72). Rarely fires on G2 audio.' },
201
+ ]
202
+
203
+ /** The engine family a choice decodes with; a second opinion must differ from HQ. */
204
+ export function engineFamily(id: HqId | SecondOpinionId): 'whisper' | 'parakeet' | 'off' {
205
+ return id === 'off' ? 'off' : id.startsWith('parakeet') ? 'parakeet' : 'whisper'
206
+ }
207
+
208
+ export class SttSettingsError extends Error {
209
+ constructor(message: string) { super(message); this.name = 'SttSettingsError' }
210
+ }
211
+
212
+ const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate'] as const
213
+
214
+ /**
215
+ * Apply a partial PUT. Throws SttSettingsError with a plain sentence for an
216
+ * unknown key, an unknown id, an unavailable choice or a conflict.
217
+ * Changing HQ onto the second opinion's engine moves the second opinion to the
218
+ * other engine (unless the same request sets it), so one tap never fails.
219
+ */
220
+ export function applySettingsPatch(current: SttSettings, body: unknown, c: SttCapabilities): SttSettings {
221
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new SttSettingsError('Send a JSON object, for example {"hq":"large-v3"}.')
222
+ const patch = body as Record<string, unknown>
223
+ const unknown = Object.keys(patch).filter(k => !(KEYS as readonly string[]).includes(k))
224
+ if (unknown.length) throw new SttSettingsError(`Unknown setting: ${unknown.join(', ')}.`)
225
+ const next = { ...current }
226
+ const choose = <T extends string>(key: string, ids: readonly T[], options: SttOption[], label: string): T | undefined => {
227
+ if (!(key in patch)) return undefined
228
+ const value = patch[key]
229
+ if (typeof value !== 'string' || !(ids as readonly string[]).includes(value)) {
230
+ throw new SttSettingsError(`${label} must be one of: ${ids.join(', ')}.`)
231
+ }
232
+ const option = options.find(o => o.id === value)
233
+ if (option && !option.available) throw new SttSettingsError(`${option.label} is not available: ${option.note}`)
234
+ return value as T
235
+ }
236
+ const preview = choose('preview', PREVIEW_IDS, previewOptions(c), 'Preview model')
237
+ const hq = choose('hq', HQ_IDS, hqOptions(c), 'HQ model')
238
+ const second = choose('secondOpinion', SECOND_OPINION_IDS, secondOpinionOptions(c), 'Second opinion')
239
+ const frontEnd = choose('frontEnd', FRONT_END_IDS, FRONT_END_OPTIONS, 'Front end')
240
+ const quietGate = choose('quietGate', QUIET_GATE_IDS, QUIET_GATE_OPTIONS, 'Quiet gate')
241
+ if ('resolver' in patch && typeof patch.resolver !== 'boolean') throw new SttSettingsError('resolver must be true or false.')
242
+ if (preview) next.preview = preview
243
+ if (hq) next.hq = hq
244
+ if (second) next.secondOpinion = second
245
+ if (frontEnd) next.frontEnd = frontEnd
246
+ if (quietGate) next.quietGate = quietGate
247
+ if (typeof patch.resolver === 'boolean') next.resolver = patch.resolver
248
+ if (engineFamily(next.hq) === engineFamily(next.secondOpinion)) {
249
+ if (second) throw new SttSettingsError('The second opinion must be a different engine from the HQ model.')
250
+ const other: SecondOpinionId = engineFamily(next.hq) === 'parakeet' ? 'large-v3' : 'parakeet-ultra'
251
+ const otherOk = secondOpinionOptions(c).find(o => o.id === other)?.available
252
+ next.secondOpinion = otherOk ? other : 'off'
253
+ }
254
+ return next
255
+ }
256
+
257
+ // ── Effective choices ─────────────────────────────────────────────────────────
258
+
259
+ export interface EffectiveStt {
260
+ settings: SttSettings
261
+ preview: { engine: 'turbo' | 'large-v3' | 'parakeet'; reason: string | null }
262
+ hq: { engine: HqId; reason: string | null }
263
+ secondOpinion: { engine: SecondOpinionId; reason: string | null }
264
+ resolver: boolean
265
+ frontEnd: FrontEndId
266
+ quietGate: QuietGateId
267
+ }
268
+
269
+ export const REASON_TEXT: Record<string, string> = {
270
+ parakeet_not_installed: 'Parakeet is not installed on this Mac.',
271
+ parakeet_warming: 'Parakeet is warming up.',
272
+ parakeet_warm_failed: 'Parakeet could not start; using Turbo.',
273
+ parakeet_breaker_open: 'Parakeet missed several previews; using Turbo for a few minutes.',
274
+ large_v3_preview_not_offered: 'A Large-v3 preview is not offered; using Turbo.',
275
+ nemotron_not_wired: 'Nemotron preview is not available in this server; using Turbo.',
276
+ large_v3_unavailable: 'Large-v3 is not available on this Mac.',
277
+ same_engine_as_hq: 'Same engine as the HQ model.',
278
+ no_second_opinion: 'No second opinion is running, so nothing to refine with.',
279
+ }
280
+
281
+ /** What will actually run, given what is installed. A stored choice that is no
282
+ * longer possible falls back (preview to Turbo, HQ to Large-v3, second opinion
283
+ * to off) and says why with a code. */
284
+ export function effectiveStt(settings: SttSettings, c: SttCapabilities): EffectiveStt {
285
+ let preview: EffectiveStt['preview'] = { engine: 'turbo', reason: null }
286
+ if (settings.preview === 'large-v3') preview = { engine: 'turbo', reason: 'large_v3_preview_not_offered' }
287
+ else if (settings.preview === 'parakeet') {
288
+ preview = !c.parakeetInstalled ? { engine: 'turbo', reason: 'parakeet_not_installed' }
289
+ : c.parakeetPreviewBreakerOpen ? { engine: 'turbo', reason: 'parakeet_breaker_open' }
290
+ : c.parakeetPreviewWarm ? { engine: 'parakeet', reason: null }
291
+ : c.parakeetPreviewWarmFailed ? { engine: 'turbo', reason: 'parakeet_warm_failed' }
292
+ : { engine: 'turbo', reason: 'parakeet_warming' }
293
+ } else if (settings.preview === 'nemotron-560') preview = { engine: 'turbo', reason: 'nemotron_not_wired' }
294
+ let hq: EffectiveStt['hq'] = { engine: settings.hq, reason: null }
295
+ if (settings.hq === 'parakeet-ultra' && !c.parakeetInstalled) hq = { engine: 'large-v3', reason: 'parakeet_not_installed' }
296
+ let second: EffectiveStt['secondOpinion'] = { engine: settings.secondOpinion, reason: null }
297
+ if (engineFamily(second.engine) === 'parakeet' && !c.parakeetInstalled) second = { engine: 'off', reason: 'parakeet_not_installed' }
298
+ if (second.engine === 'large-v3' && !c.hqLargeV3) second = { engine: 'off', reason: 'large_v3_unavailable' }
299
+ if (second.engine !== 'off' && engineFamily(second.engine) === engineFamily(hq.engine)) second = { engine: 'off', reason: 'same_engine_as_hq' }
300
+ return {
301
+ settings,
302
+ preview,
303
+ hq,
304
+ secondOpinion: second,
305
+ resolver: settings.resolver && second.engine !== 'off',
306
+ frontEnd: settings.frontEnd,
307
+ quietGate: settings.quietGate,
308
+ }
309
+ }
310
+
311
+ // ── Dictation (6.73.0) ────────────────────────────────────────────────────────
312
+ //
313
+ // Prompting and dictation follow the same choices (docs/dictation-stt-contract.md):
314
+ // the live preview and Fast mode follow the Preview model, the final text follows
315
+ // the HQ model. Second opinion and resolver never run on dictation.
316
+
317
+ export type DictationSlot = 'preview' | 'fast' | 'final'
318
+ export type DictationEngine = 'whisper' | 'parakeet-ultra'
319
+
320
+ export interface DictationPlan {
321
+ slot: DictationSlot
322
+ engine: DictationEngine
323
+ /** Why the selection could not run, as a REASON_TEXT code; null when it runs as chosen. */
324
+ reason: string | null
325
+ /** What a Parakeet decode hears. Whisper dictation always decodes raw audio. */
326
+ frontEnd: FrontEndId
327
+ /** The decode path this follows: "preview:" or "fast:" plus turbo or parakeet, or
328
+ * "final:" plus large-v3 or parakeet-ultra. A cached decode is reused only under the same key. */
329
+ key: string
330
+ }
331
+
332
+ /** Which slot a decode is in: Fast mode follows the Preview model, otherwise the HQ model. */
333
+ export function dictationSlotForMode(mode: 'hq' | 'fast'): DictationSlot {
334
+ return mode === 'fast' ? 'fast' : 'final'
335
+ }
336
+
337
+ /** The 6.72.0 key every pre-6.73 cached decode was made under. */
338
+ export function legacyDictationKey(mode: 'hq' | 'fast'): string {
339
+ return mode === 'fast' ? `fast:${DEFAULT_STT_SETTINGS.preview}` : `final:${DEFAULT_STT_SETTINGS.hq}`
340
+ }
341
+
342
+ /** Pure: what a dictation decode in this slot will use, given the settings and this Mac. */
343
+ export function dictationPlan(slot: DictationSlot, settings: SttSettings, c: SttCapabilities): DictationPlan {
344
+ if (c.dictationDisabledByEnv) {
345
+ return { slot, engine: 'whisper', reason: 'dictation_disabled_by_env', frontEnd: settings.frontEnd, key: slot === 'final' ? 'final:large-v3' : `${slot}:turbo` }
346
+ }
347
+ const wantsParakeet = slot === 'final' ? settings.hq === 'parakeet-ultra' : settings.preview === 'parakeet'
348
+ // The key names the dictation decode path, not the raw id: every Whisper choice in a
349
+ // slot is the same 6.72.0 decode for a prompt, so switching among them re-decodes nothing.
350
+ const key = slot === 'final' ? (wantsParakeet ? 'final:parakeet-ultra' : 'final:large-v3') : `${slot}:${wantsParakeet ? 'parakeet' : 'turbo'}`
351
+ const base = { slot, frontEnd: settings.frontEnd, key }
352
+ if (!wantsParakeet) {
353
+ // large-v3 and nemotron-560 previews are not offered; dictation keeps its Whisper engine.
354
+ const reason = slot !== 'final' && settings.preview === 'large-v3' ? 'large_v3_preview_not_offered'
355
+ : slot !== 'final' && settings.preview === 'nemotron-560' ? 'nemotron_not_wired' : null
356
+ return { ...base, engine: 'whisper', reason }
357
+ }
358
+ const reason = !c.parakeetInstalled ? 'parakeet_not_installed'
359
+ : c.parakeetPreviewBreakerOpen ? 'parakeet_breaker_open'
360
+ : c.parakeetPreviewWarm ? null
361
+ : c.parakeetPreviewWarmFailed ? 'parakeet_warm_failed'
362
+ : 'parakeet_warming'
363
+ return { ...base, engine: reason ? 'whisper' : 'parakeet-ultra', reason }
364
+ }
365
+
366
+ export const DICTATION_REASON_TEXT: Record<string, string> = {
367
+ parakeet_busy: 'Parakeet was busy with another decode; Whisper decoded this one.',
368
+ parakeet_failed: 'Parakeet failed on this audio; Whisper decoded it instead.',
369
+ audio_not_pcm_wav: 'This audio is not 16 kHz WAV, which Parakeet needs; Whisper decoded it.',
370
+ dictation_disabled_by_env: 'Dictation is held on Whisper on this server (COS_DICTATION_FOLLOWS_STT=0).',
371
+ }
372
+
373
+ function dictationSlotView(plan: DictationPlan, follows: 'preview' | 'hq') {
374
+ const code = plan.reason
375
+ return { engine: plan.engine, follows, reason: code ? (REASON_TEXT[code] ?? DICTATION_REASON_TEXT[code] ?? code) : null, reasonCode: code }
376
+ }
377
+
378
+ export function dictationView(settings: SttSettings, c: SttCapabilities) {
379
+ const preview = dictationPlan('preview', settings, c)
380
+ const fast = dictationPlan('fast', settings, c)
381
+ const final = dictationPlan('final', settings, c)
382
+ const anyParakeet = settings.preview === 'parakeet' || settings.hq === 'parakeet-ultra'
383
+ const name = (p: DictationPlan) => p.engine === 'parakeet-ultra' ? 'Parakeet Ultra' : 'Whisper'
384
+ const note = `Dictation follows these settings: the live preview and Fast mode use the Preview model (now ${name(preview)}), the final text uses the HQ model (now ${name(final)}). `
385
+ + 'Dictation never runs the second opinion or the resolver, and the quiet gate never drops a spoken prompt.'
386
+ return {
387
+ preview: dictationSlotView(preview, 'preview'),
388
+ fast: dictationSlotView(fast, 'preview'),
389
+ final: dictationSlotView(final, 'hq'),
390
+ frontEnd: anyParakeet ? settings.frontEnd : 'raw' as const,
391
+ quietGate: 'none' as const,
392
+ secondOpinion: 'off' as const,
393
+ note,
394
+ }
395
+ }
396
+
397
+ export interface ParakeetStatus {
398
+ installed: boolean
399
+ installable: boolean
400
+ version: string
401
+ bytes: number
402
+ progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; source?: unknown }
403
+ warm: { preview: string; error?: string; at?: string }
404
+ preview?: { breaker: { open: boolean; retryAt: string | null; misses: number }; counters: Record<string, number> }
405
+ note: string
406
+ }
407
+
408
+ function slotReason(code: string | null): { reason: string | null; reasonCode: string | null } {
409
+ return { reason: code ? (REASON_TEXT[code] ?? code) : null, reasonCode: code }
410
+ }
411
+
412
+ export function settingsView(settings: SttSettings, c: SttCapabilities, parakeet: ParakeetStatus, lastJob: LastSttJob | null = null) {
413
+ const eff = effectiveStt(settings, c)
414
+ const resolverCode = settings.resolver && !eff.resolver ? 'no_second_opinion' : null
415
+ return {
416
+ preview: {
417
+ selected: settings.preview, effective: eff.preview.engine, ...slotReason(eff.preview.reason), options: previewOptions(c),
418
+ models: [...PREVIEW_MODEL_STRINGS], clientParam: PREVIEW_MODELS_PARAM, requiresApp: PREVIEW_REQUIRES_APP,
419
+ note: `Only phones that list a preview model in ${PREVIEW_MODELS_PARAM} get it (COS Glasses ${PREVIEW_REQUIRES_APP} and later); every other client gets Whisper Turbo.`,
420
+ },
421
+ hq: { selected: settings.hq, effective: eff.hq.engine, ...slotReason(eff.hq.reason), options: hqOptions(c) },
422
+ secondOpinion: { selected: settings.secondOpinion, effective: eff.secondOpinion.engine, ...slotReason(eff.secondOpinion.reason), options: secondOpinionOptions(c) },
423
+ resolver: { enabled: settings.resolver, effective: eff.resolver, ...slotReason(resolverCode) },
424
+ frontEnd: { selected: settings.frontEnd, options: FRONT_END_OPTIONS },
425
+ quietGate: { selected: settings.quietGate, options: QUIET_GATE_OPTIONS },
426
+ parakeet,
427
+ lastJob,
428
+ dictation: dictationView(settings, c),
429
+ appliesTo: { preview: 'next live chunk', hq: 'next saved meeting', dictation: 'next dictation chunk' },
430
+ }
431
+ }
@@ -22,6 +22,8 @@ import {
22
22
  } from './hallucination-filter.js'
23
23
  import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
24
24
  import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from './transcription-policy.js'
25
+ import { currentDictationPlan, runParakeetDictation, type DictationParakeetOutcome } from './dictation-stt.js'
26
+ import { dictationSlotForMode, type DictationEngine, type DictationPlan, type DictationSlot } from './stt-settings.js'
25
27
 
26
28
  export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
27
29
 
@@ -39,6 +41,22 @@ export interface TranscribeAudioResult {
39
41
  degradationReason?: string
40
42
  elapsedMs: number
41
43
  audioBytes: number
44
+ /** 6.73.0: the engine that decoded this audio. */
45
+ engine: DictationEngine
46
+ /** Why the selected engine did not run (a reason code), when it did not. */
47
+ engineReason?: string
48
+ /** The setting this decode followed (see DictationPlan.key). */
49
+ dictationKey: string
50
+ }
51
+
52
+ /** Injected by tests; production reads the settings and runs Parakeet. */
53
+ export interface DictationSttDeps {
54
+ plan: (slot: DictationSlot) => DictationPlan
55
+ parakeet: (audio: Buffer, plan: DictationPlan) => Promise<DictationParakeetOutcome>
56
+ }
57
+ export const defaultDictationSttDeps: DictationSttDeps = {
58
+ plan: slot => currentDictationPlan(slot),
59
+ parakeet: (audio, plan) => runParakeetDictation(audio, plan, 'interactive'),
42
60
  }
43
61
 
44
62
  export type TranscriptionBackendPolicy = 'automatic' | 'local-only'
@@ -147,6 +165,9 @@ export async function transcribeAudioBuffer(
147
165
  policy?: TranscriptionBackendPolicy
148
166
  /** When false, turbo/server failures do not move the shared meeting circuit breaker. */
149
167
  affectsCircuit?: boolean
168
+ /** A plan snapshot taken by the caller (prompt finalize takes one for every chunk). */
169
+ dictationPlan?: DictationPlan
170
+ sttDeps?: DictationSttDeps
150
171
  } = {},
151
172
  ): Promise<TranscribeAudioResult> {
152
173
  const requestedMode = opts.mode ?? 'hq'
@@ -160,14 +181,39 @@ export async function transcribeAudioBuffer(
160
181
  console.log(`[transcribe] mode downgrade: hq → fast (audio ${audioSeconds.toFixed(1)}s > cap ${HQ_MAX_SECONDS}s)`)
161
182
  }
162
183
 
163
- let text: string
184
+ let text!: string
164
185
  let words: WhisperWord[] | undefined
165
- let backend: string
166
- let actualQuality: 'hq' | 'fast' | 'cloud'
186
+ let backend!: string
187
+ let actualQuality!: 'hq' | 'fast' | 'cloud'
167
188
  let degradationReason: string | undefined = effectiveMode !== requestedMode ? 'audio_too_long' : undefined
168
189
  const tStart = performance.now()
169
190
 
170
- if (effectiveMode === 'hq') {
191
+ // 6.73.0: Fast mode follows the Preview model, otherwise the HQ model. One plan
192
+ // per decode; a long clip downgraded to Fast is planned for the Fast slot.
193
+ const sttDeps = opts.sttDeps ?? defaultDictationSttDeps
194
+ const slot = dictationSlotForMode(effectiveMode)
195
+ const plan = opts.dictationPlan && opts.dictationPlan.slot === slot ? opts.dictationPlan : sttDeps.plan(slot)
196
+ let engine: DictationEngine = 'whisper'
197
+ let engineReason: string | null = plan.reason
198
+ let decoded = false
199
+ if (plan.engine === 'parakeet-ultra') {
200
+ const outcome = await sttDeps.parakeet(audioBuffer, plan)
201
+ if (outcome.ok) {
202
+ text = outcome.text
203
+ backend = 'parakeet-ultra'
204
+ actualQuality = effectiveMode
205
+ engine = 'parakeet-ultra'
206
+ engineReason = null
207
+ decoded = true
208
+ } else {
209
+ engineReason = outcome.reason
210
+ }
211
+ }
212
+
213
+ if (decoded) {
214
+ // Parakeet ran: nothing below. Its word clocks are not passed to the prompt tail
215
+ // guard, whose confidence rule is calibrated on Whisper token probabilities.
216
+ } else if (effectiveMode === 'hq') {
171
217
  try {
172
218
  // A0 (2026-07-30): ffmpeg enhance light (highpass=f=80) was measured dropping
173
219
  // leading speech on compose ("device just for your awareness"). Meeting batch
@@ -286,5 +332,8 @@ export async function transcribeAudioBuffer(
286
332
  ...(degradationReason ? { degradationReason } : {}),
287
333
  elapsedMs,
288
334
  audioBytes: audioBuffer.length,
335
+ engine,
336
+ ...(engineReason ? { engineReason } : {}),
337
+ dictationKey: plan.key,
289
338
  }
290
339
  }
@@ -27,6 +27,12 @@ const MIN_SAMPLES = 512
27
27
  let sherpaOnnx: any = null
28
28
  let available = false
29
29
  let vadInstance: any = null
30
+ // 6.72.0 quiet gate: its own detector, stricter than the trimming one. Chosen on
31
+ // bake-off canary 5 (docs/stt-672-evaluation.md): probability 0.8 with a 0.25 s
32
+ // minimum, and quiet below 0.5 s of speech, fired on 80.8% of Silero-negative
33
+ // chunks and 0.91% of speech chunks.
34
+ export const QUIET_GATE_VAD = { threshold: 0.8, minSilenceDuration: 0.3, minSpeechDuration: 0.25 } as const
35
+ let gateVad: any = null
30
36
 
31
37
  export interface TrimResult {
32
38
  trimmedWav: Buffer
@@ -60,6 +66,11 @@ export function initSileroVAD(): boolean {
60
66
  10, // bufferSizeInSeconds — chunks are max 6s
61
67
  )
62
68
  vadInstance.reset() // verify it works
69
+ gateVad = new sherpaOnnx.Vad(
70
+ { sileroVad: { model: MODEL_PATH, ...QUIET_GATE_VAD, windowSize: 512 }, sampleRate: SAMPLE_RATE, numThreads: 1, provider: 'cpu' },
71
+ 10,
72
+ )
73
+ gateVad.reset()
63
74
  available = true
64
75
  console.log('[silero-vad] Initialized: Silero VAD active (reused instance, bufferSize=10s)')
65
76
  return true
@@ -182,3 +193,44 @@ export function trimSilence(wavBuffer: Buffer): TrimResult {
182
193
  return fallback
183
194
  }
184
195
  }
196
+
197
+ /**
198
+ * Seconds of speech the quiet-gate Silero (QUIET_GATE_VAD) hears in a WAV (6.72.0).
199
+ *
200
+ * Feeds the detector one 512-sample window at a time and drains finished
201
+ * segments after each window. Handing sherpa-onnx the whole chunk in one
202
+ * acceptWaveform call (as trimSilence does) keeps only the last segment:
203
+ * measured 2026-10-13, a 6 s chunk of continuous speech read 0.32 s that way
204
+ * and 5.03 s windowed. Null when the detector is unavailable or the audio is
205
+ * too short to measure: a caller must never read null as silence.
206
+ */
207
+ export function measureSpeechSeconds(wavBuffer: Buffer): number | null {
208
+ if (!available || !gateVad) return null
209
+ try {
210
+ if (wavBuffer.length <= WAV_HEADER_SIZE + MIN_SAMPLES * BYTES_PER_SAMPLE) return null
211
+ const pcmBytes = wavBuffer.buffer.slice(wavBuffer.byteOffset + WAV_HEADER_SIZE, wavBuffer.byteOffset + wavBuffer.length - ((wavBuffer.length - WAV_HEADER_SIZE) % 2))
212
+ const int16 = new Int16Array(pcmBytes)
213
+ if (int16.length < MIN_SAMPLES) return null
214
+ const float32 = new Float32Array(int16.length)
215
+ for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768
216
+ gateVad.reset()
217
+ let speechSamples = 0
218
+ const drain = () => {
219
+ while (!gateVad.isEmpty()) {
220
+ speechSamples += gateVad.front().samples.length
221
+ gateVad.pop()
222
+ }
223
+ }
224
+ for (let offset = 0; offset + MIN_SAMPLES <= float32.length; offset += MIN_SAMPLES) {
225
+ gateVad.acceptWaveform(float32.subarray(offset, offset + MIN_SAMPLES))
226
+ drain()
227
+ }
228
+ gateVad.flush()
229
+ drain()
230
+ gateVad.reset()
231
+ return speechSamples / SAMPLE_RATE
232
+ } catch (err: any) {
233
+ console.error('[silero-vad] measureSpeechSeconds error:', err.message)
234
+ return null
235
+ }
236
+ }
@@ -1,11 +1,21 @@
1
1
  import type { WhisperWord } from './whisper-local.js'
2
+ import { readSttSettings } from './stt-settings.js'
2
3
 
3
- /** Canary-only until human-corrected meeting/fact and native contention gates pass.
4
+ /** Meeting batch HQ timestamps (6.72.0 default: on). The HQ setting decides:
5
+ * "large-v3" decodes with timestamps and remaps them through Whisper's own VAD
6
+ * table; "large-v3-legacy" keeps the pre-6.72 `--no-timestamps` decoder.
7
+ * COS_BATCH_HQ_TIMESTAMPS still overrides: "1" forces timestamps on, "0" off.
4
8
  * Live HTTP keeps compact JSON; interactive HQ and DTW are unchanged. */
5
- export function batchTimestampCanaryEnabled(): boolean {
6
- return process.env.COS_BATCH_HQ_TIMESTAMPS === '1'
9
+ export function batchTimestampsEnabled(hq?: string): boolean {
10
+ const flag = process.env.COS_BATCH_HQ_TIMESTAMPS
11
+ if (flag === '1') return true
12
+ if (flag === '0') return false
13
+ return (hq ?? readSttSettings().hq) !== 'large-v3-legacy'
7
14
  }
8
15
 
16
+ /** Pre-6.72 name, kept for callers and tests. */
17
+ export const batchTimestampCanaryEnabled = (): boolean => batchTimestampsEnabled()
18
+
9
19
  export interface VadTimeSegment {
10
20
  originalStart: number
11
21
  originalEnd: number