@gotcos/glasses-server 6.1.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.
Files changed (51) hide show
  1. package/.cos-profile.example.json +7 -0
  2. package/.env.example +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/LICENSE +21 -0
  5. package/README.md +78 -0
  6. package/bin/cli.cjs +203 -0
  7. package/package.json +53 -0
  8. package/server/env.ts +26 -0
  9. package/server/index.ts +211 -0
  10. package/server/lib/archive-budget.ts +65 -0
  11. package/server/lib/archive.ts +414 -0
  12. package/server/lib/atomic-fs.ts +50 -0
  13. package/server/lib/audio-enhance.ts +87 -0
  14. package/server/lib/claude-bridge.ts +682 -0
  15. package/server/lib/claude-circuit.ts +52 -0
  16. package/server/lib/claude-run-ledger.ts +279 -0
  17. package/server/lib/codex-bridge.ts +476 -0
  18. package/server/lib/codex-engine-sessions.ts +140 -0
  19. package/server/lib/codex-run-ledger.ts +298 -0
  20. package/server/lib/context-builder.ts +210 -0
  21. package/server/lib/conversation.ts +587 -0
  22. package/server/lib/data-dir.ts +20 -0
  23. package/server/lib/display-bus.ts +21 -0
  24. package/server/lib/display-format.ts +23 -0
  25. package/server/lib/fuzzy-correct.ts +286 -0
  26. package/server/lib/hallucination-filter.ts +469 -0
  27. package/server/lib/local-day.ts +13 -0
  28. package/server/lib/model-router.ts +38 -0
  29. package/server/lib/openai-key.ts +155 -0
  30. package/server/lib/openai-whisper-budget.ts +170 -0
  31. package/server/lib/profile.ts +94 -0
  32. package/server/lib/python-bridge.ts +84 -0
  33. package/server/lib/response-cache.ts +138 -0
  34. package/server/lib/session-cache-writer.ts +266 -0
  35. package/server/lib/session-log.ts +162 -0
  36. package/server/lib/speaker-embeddings.ts +578 -0
  37. package/server/lib/telegram-notify.ts +85 -0
  38. package/server/lib/token-audit.ts +50 -0
  39. package/server/lib/transcribe-audio.ts +187 -0
  40. package/server/lib/utils.ts +5 -0
  41. package/server/lib/vad-silero.ts +179 -0
  42. package/server/lib/whisper-local.ts +697 -0
  43. package/server/routes/diag.ts +115 -0
  44. package/server/routes/display.ts +65 -0
  45. package/server/routes/health.ts +128 -0
  46. package/server/routes/openai-compat.ts +446 -0
  47. package/server/routes/openai-key.ts +121 -0
  48. package/server/routes/query.ts +121 -0
  49. package/server/routes/transcribe-stream.ts +1090 -0
  50. package/server/routes/transcribe.ts +55 -0
  51. package/shared/model-preference.ts +81 -0
@@ -0,0 +1,697 @@
1
+ // Local Whisper transcription via whisper-server (persistent) or whisper-cli (fallback)
2
+ // Eliminates 1-3s OpenAI API round-trip by running inference on M3 Ultra locally.
3
+ //
4
+ // Strategy (fastest → slowest):
5
+ // 1. whisper-server (persistent daemon, model in RAM) → ~50-100ms
6
+ // 2. whisper-cli (spawned per request, model loaded from disk) → ~500-700ms
7
+ // 3. OpenAI API (cloud, handled by transcribe.ts) → ~1000-3000ms
8
+
9
+ import { spawn, execSync } from 'node:child_process'
10
+ import { writeFileSync, unlinkSync, existsSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+ import crypto from 'node:crypto'
14
+ import { getVocabulary, getOwnerName } from './profile.js'
15
+ import { stripBrandUrls } from './hallucination-filter.js'
16
+
17
+ // Prompt hardening flags (transcription quality, 2026-05-29):
18
+ // COS_PROMPT_V2 — drop the trailing '.' on the vocab prompt and join prompt+context
19
+ // with a space (not '. ') to reduce the caption-training nudge that turns brand
20
+ // proper-nouns into "www.X.com" on low-confidence/silent audio. DEFAULT OFF
21
+ // (opt-in) — brand WER is unmeasurable without asr-bakeoff fixtures, so flip on
22
+ // and A/B before trusting it. Rollback: unset / COS_PROMPT_V2=0.
23
+ // COS_WHISPER_STRIP_BRAND_URLS — strip brand-vocab URLs from the prior-transcript
24
+ // prompt CONTEXT so an already-emitted hallucination can't self-reinforce via the
25
+ // context feedback loop. DEFAULT ON (context priming only — never mutates output).
26
+ const PROMPT_V2 = process.env.COS_PROMPT_V2 === '1'
27
+ const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
28
+
29
+ // Word-level timestamp types (from whisper.cpp DTW alignment)
30
+ export interface WhisperWord {
31
+ word: string
32
+ start: number // seconds
33
+ end: number // seconds
34
+ probability: number
35
+ }
36
+
37
+ export interface WhisperSegment {
38
+ text: string
39
+ start: number
40
+ end: number
41
+ words?: WhisperWord[]
42
+ }
43
+
44
+ interface WhisperVerboseResponse {
45
+ text: string
46
+ segments?: WhisperSegment[]
47
+ }
48
+
49
+ // Resolve whisper.cpp binaries across Homebrew prefixes (Apple Silicon
50
+ // /opt/homebrew, Intel /usr/local). Downstream code existsSync-guards these
51
+ // before use, so a missing binary degrades to CLI/cloud rather than crashing.
52
+ function resolveWhisperBin(name: string): string {
53
+ for (const prefix of ['/opt/homebrew/bin', '/usr/local/bin']) {
54
+ if (existsSync(`${prefix}/${name}`)) return `${prefix}/${name}`
55
+ }
56
+ return `/opt/homebrew/bin/${name}`
57
+ }
58
+ const WHISPER_CLI = resolveWhisperBin('whisper-cli')
59
+ const WHISPER_SERVER = resolveWhisperBin('whisper-server')
60
+ const MODEL_PATH = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-large-v3-turbo.bin')
61
+
62
+ // Post-meeting batch transcription uses the full 32-layer Whisper large-v3
63
+ // instead of turbo's 4-layer decoder. Bake-off on 2026-04-16 showed the full
64
+ // decoder captures +43% more speech content with zero known-hallucinations on
65
+ // a real 23.6 min G2 recording. See wk16_2026/asr-bakeoff/report.md.
66
+ //
67
+ // Streaming path stays on turbo (whisper-server + VAD) for latency. Only the
68
+ // post-meeting HQ re-transcription uses large-v3 — runs fire-and-forget after
69
+ // meeting save, so the ~4x wall-time cost is invisible to the user.
70
+ //
71
+ // DISABLE: set COS_BATCH_LARGE_V3=0 to revert HQ path to turbo. Missing-weights
72
+ // case is defensive: if ggml-large-v3.bin isn't on disk we log a warning and
73
+ // fall back to turbo automatically — no broken batch runs.
74
+ const BATCH_MODEL_LARGE_V3 = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-large-v3.bin')
75
+ const BATCH_MODEL_TURBO = MODEL_PATH
76
+ const BATCH_LARGE_V3_ENABLED = process.env.COS_BATCH_LARGE_V3 !== '0'
77
+
78
+ // Silero VAD (ggml) — whisper-server --vad strips silence/noise windows BEFORE the
79
+ // decoder runs, eliminating the #1 turbo hallucination trigger (empty-audio chunks
80
+ // generating "*sad music*", "thanks for watching", etc.). See openai/whisper#2281
81
+ // and whisper.cpp PR 2524. Model downloaded to ~/.local/share/whisper-models/ from
82
+ // huggingface.co/ggml-org/whisper-vad.
83
+ //
84
+ // DISABLE: set COS_WHISPER_VAD=0 to revert to the pre-2026-04-16 behaviour. If VAD
85
+ // regresses (e.g. trims quiet speakers on Zoom-through-laptop-mic), that env var
86
+ // lets the user fall back fast without a redeploy.
87
+ const VAD_MODEL_PATH = join(process.env.HOME ?? homedir(), '.local/share/whisper-models/ggml-silero-v5.1.2.bin')
88
+ const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
89
+
90
+ /** Pick the batch model path. Prefer large-v3 when enabled + on disk; fall
91
+ * back to turbo otherwise. Logged once per process so we know which decoder
92
+ * actually ran when reviewing a meeting later. */
93
+ let _batchModelResolved: string | null = null
94
+ function resolveBatchModel(): string {
95
+ if (_batchModelResolved) return _batchModelResolved
96
+ if (BATCH_LARGE_V3_ENABLED && existsSync(BATCH_MODEL_LARGE_V3)) {
97
+ console.log(`[whisper-local] batch HQ model: ggml-large-v3.bin (full 32-layer decoder)`)
98
+ _batchModelResolved = BATCH_MODEL_LARGE_V3
99
+ } else if (BATCH_LARGE_V3_ENABLED) {
100
+ console.warn(
101
+ `[whisper-local] COS_BATCH_LARGE_V3=1 but weights missing at ${BATCH_MODEL_LARGE_V3}. ` +
102
+ `Batch HQ falling back to turbo. Download: curl -L -o "${BATCH_MODEL_LARGE_V3}" ` +
103
+ `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin`,
104
+ )
105
+ _batchModelResolved = BATCH_MODEL_TURBO
106
+ } else {
107
+ console.log('[whisper-local] batch HQ model: ggml-large-v3-turbo.bin (COS_BATCH_LARGE_V3=0)')
108
+ _batchModelResolved = BATCH_MODEL_TURBO
109
+ }
110
+ return _batchModelResolved
111
+ }
112
+
113
+ // Domain vocabulary prompt — biases Whisper decoder toward proper nouns it would otherwise garble.
114
+ // People names are the biggest win (an unusual surname otherwise transcribed as a common soundalike).
115
+ // Loaded from .cos-profile.json vocabulary array, with generic fallback.
116
+ function buildWhisperPrompt(): string {
117
+ const vocab = getVocabulary()
118
+ const ownerName = getOwnerName()
119
+
120
+ if (vocab.length > 0) {
121
+ // User-configured vocabulary — names, products, acronyms from profile.
122
+ // V2 drops the trailing '.' (sentence-boundary token nudges ".com" completions).
123
+ return [ownerName, ...vocab].join(', ') + (PROMPT_V2 ? '' : '.')
124
+ }
125
+
126
+ // Generic fallback — no personal names, just product/format hints
127
+ return `${ownerName}. COS Glasses. Even G2.`
128
+ }
129
+
130
+ // Lazy-cache the prompt (profile is read once and cached in profile.ts)
131
+ let _whisperPrompt: string | null = null
132
+ function getWhisperPrompt(): string {
133
+ if (!_whisperPrompt) _whisperPrompt = buildWhisperPrompt()
134
+ return _whisperPrompt
135
+ }
136
+
137
+ // whisper-server runs on this port (started at server boot or by LaunchAgent)
138
+ const WHISPER_SERVER_PORT = 8178
139
+ const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
140
+
141
+ // Track which backends are available
142
+ let cliAvailable = false
143
+ let serverAvailable = false
144
+ let serverProcess: ReturnType<typeof spawn> | null = null
145
+
146
+ // Circuit breaker: track consecutive server failures to detect hung process
147
+ let serverConsecutiveFailures = 0
148
+ const SERVER_FAILURE_THRESHOLD = 3 // After 3 consecutive failures, auto-restart
149
+ let serverRestarting = false // Prevents concurrent restart attempts
150
+
151
+ // Check CLI availability at import time
152
+ try {
153
+ cliAvailable = existsSync(WHISPER_CLI) && existsSync(MODEL_PATH)
154
+ if (cliAvailable) {
155
+ console.log(`[whisper-local] whisper-cli available at ${WHISPER_CLI}`)
156
+ }
157
+ } catch {
158
+ // ignore
159
+ }
160
+
161
+ /**
162
+ * Start whisper-server as a child process (model stays loaded in RAM).
163
+ * Called from index.ts at server boot. Non-blocking.
164
+ */
165
+ export async function startWhisperServer(): Promise<void> {
166
+ if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
167
+ console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
168
+ return
169
+ }
170
+
171
+ // Check if already running
172
+ try {
173
+ const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
174
+ if (res.ok) {
175
+ serverAvailable = true
176
+ console.log('[whisper-local] whisper-server already running on port', WHISPER_SERVER_PORT)
177
+ return
178
+ }
179
+ } catch {
180
+ // Not running — kill any zombie processes before starting fresh
181
+ try {
182
+ execSync('pkill -9 -f "whisper-server"', { stdio: 'ignore' })
183
+ console.log('[whisper-local] Killed stale whisper-server processes')
184
+ } catch { /* none running */ }
185
+
186
+ // Wait for port to actually clear (up to 5s)
187
+ for (let i = 0; i < 10; i++) {
188
+ try {
189
+ execSync('lsof -i :8178 -t', { stdio: 'ignore' })
190
+ await new Promise(r => setTimeout(r, 500))
191
+ } catch {
192
+ break // Port clear
193
+ }
194
+ }
195
+ }
196
+
197
+ // Assemble startup args. VAD only attaches if the ggml model is actually on
198
+ // disk — missing-file is logged, not fatal (server still boots without VAD).
199
+ const serverArgs = [
200
+ '-m', MODEL_PATH,
201
+ '-t', '16', // M3 Ultra has 24P+8E cores — 16 threads for short audio chunks
202
+ '-l', 'en',
203
+ '-fa', // Flash attention — faster self-attention on Apple Silicon
204
+ '--no-speech-thold', '0.7', // Reject silence more aggressively (default 0.6)
205
+ // DTW removed: 'large-v3-turbo' not a valid preset, crashes server (exit 3)
206
+ // Also incompatible with -fa (flash attention). Revisit word timestamps separately.
207
+ '--host', '127.0.0.1',
208
+ '--port', String(WHISPER_SERVER_PORT),
209
+ ]
210
+
211
+ if (VAD_ENABLED && existsSync(VAD_MODEL_PATH)) {
212
+ // Silero VAD pre-filters silent/non-speech windows. Defaults are sane for
213
+ // meeting audio: threshold 0.5, min-speech 250ms, min-silence 100ms. Revisit
214
+ // if Phase 0 measurement shows VAD clipping real speech from quiet speakers.
215
+ serverArgs.push('--vad', '--vad-model', VAD_MODEL_PATH)
216
+ console.log(`[whisper-local] VAD enabled — using ${VAD_MODEL_PATH}`)
217
+ } else if (VAD_ENABLED) {
218
+ console.warn(
219
+ `[whisper-local] VAD requested (COS_WHISPER_VAD != 0) but model missing at ${VAD_MODEL_PATH}. ` +
220
+ `Download: curl -L -o "${VAD_MODEL_PATH}" https://huggingface.co/ggml-org/whisper-vad/resolve/main/ggml-silero-v5.1.2.bin`,
221
+ )
222
+ } else {
223
+ console.log('[whisper-local] VAD disabled via COS_WHISPER_VAD=0')
224
+ }
225
+
226
+ console.log('[whisper-local] Starting whisper-server...')
227
+
228
+ serverProcess = spawn(WHISPER_SERVER, serverArgs, {
229
+ stdio: ['ignore', 'pipe', 'pipe'],
230
+ detached: false, // Dies with parent
231
+ })
232
+
233
+ // Wait for server to be ready — poll /health every 2s (large models take ~20s to load)
234
+ return new Promise<void>((resolve) => {
235
+ const maxWaitMs = 45_000
236
+ const pollIntervalMs = 2_000
237
+ const startTime = Date.now()
238
+
239
+ const pollTimer = setInterval(async () => {
240
+ try {
241
+ const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
242
+ if (res.ok) {
243
+ clearInterval(pollTimer)
244
+ serverAvailable = true
245
+ const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
246
+ console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
247
+ resolve()
248
+ }
249
+ } catch {
250
+ // Not ready yet — keep polling
251
+ if (Date.now() - startTime > maxWaitMs) {
252
+ clearInterval(pollTimer)
253
+ console.warn(`[whisper-local] whisper-server startup timeout (${maxWaitMs / 1000}s) — using CLI fallback`)
254
+ resolve()
255
+ }
256
+ }
257
+ }, pollIntervalMs)
258
+
259
+ serverProcess!.on('error', (err) => {
260
+ clearInterval(pollTimer)
261
+ console.error('[whisper-local] whisper-server failed to start:', err.message)
262
+ resolve()
263
+ })
264
+
265
+ serverProcess!.on('close', (code) => {
266
+ serverAvailable = false
267
+ serverProcess = null
268
+ if (code !== null && code !== 0) {
269
+ console.warn(`[whisper-local] whisper-server exited with code ${code}`)
270
+ }
271
+ })
272
+ })
273
+ }
274
+
275
+ /**
276
+ * Gracefully stop whisper-server (called on process exit).
277
+ */
278
+ export function stopWhisperServer(): void {
279
+ if (serverProcess) {
280
+ serverProcess.kill('SIGTERM')
281
+ serverProcess = null
282
+ serverAvailable = false
283
+ console.log('[whisper-local] whisper-server stopped')
284
+ }
285
+ }
286
+
287
+ export function isWhisperLocalAvailable(): boolean {
288
+ // Only advertise server for real-time callers. CLI is reserved for batch
289
+ // (transcribeHighQuality) because cold-loading the model from disk takes ~11s,
290
+ // which is worse than OpenAI cloud (1-3s). When server is down, callers should
291
+ // go straight to cloud without the overhead of entering transcribeLocal → throw.
292
+ return serverAvailable
293
+ }
294
+
295
+ export function getWhisperBackend(): 'server' | 'cli' | 'none' {
296
+ if (serverAvailable) return 'server'
297
+ if (cliAvailable) return 'cli'
298
+ return 'none'
299
+ }
300
+
301
+ /** Detailed health status for diagnostics (/api/health) */
302
+ export function getWhisperHealth(): {
303
+ server: boolean
304
+ cli: boolean
305
+ consecutiveFailures: number
306
+ restarting: boolean
307
+ circuitOpen: boolean
308
+ } {
309
+ return {
310
+ server: serverAvailable,
311
+ cli: cliAvailable,
312
+ consecutiveFailures: serverConsecutiveFailures,
313
+ restarting: serverRestarting,
314
+ circuitOpen: serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD,
315
+ }
316
+ }
317
+
318
+ /**
319
+ * High-quality transcription for batch/post-meeting use.
320
+ * Uses the full Whisper large-v3 (32-layer) decoder + Silero VAD + beam search.
321
+ * Per 2026-04-16 bake-off: +43% speech capture vs turbo on real G2 audio.
322
+ * Falls back to turbo weights if large-v3 not on disk or COS_BATCH_LARGE_V3=0.
323
+ * Falls back to transcribeLocal if whisper-cli unavailable entirely.
324
+ */
325
+ export async function transcribeHighQuality(audioBuffer: Buffer, context?: string): Promise<{ text: string; words?: WhisperWord[] }> {
326
+ if (!cliAvailable) {
327
+ // Fall back to server (no beam search available via HTTP API)
328
+ return transcribeLocal(audioBuffer, context)
329
+ }
330
+
331
+ const start = Date.now()
332
+ const id = crypto.randomUUID().slice(0, 8)
333
+ const tmpWav = join('/tmp', `cos-whisper-hq-${id}.wav`)
334
+
335
+ const modelPath = resolveBatchModel()
336
+ const useLargeV3 = modelPath === BATCH_MODEL_LARGE_V3
337
+ const useVad = VAD_ENABLED && existsSync(VAD_MODEL_PATH)
338
+
339
+ try {
340
+ writeFileSync(tmpWav, audioBuffer)
341
+
342
+ const text = await new Promise<string>((resolve, reject) => {
343
+ const args = [
344
+ '-m', modelPath,
345
+ '-f', tmpWav,
346
+ '-t', '16', // Use more threads for batch (no real-time pressure)
347
+ '-l', 'en',
348
+ '-fa', // Flash attention — Metal win, same flag streaming uses
349
+ '-bs', '5', // Beam search width 5 (default disabled)
350
+ '-bo', '5', // Best-of-5 candidates (default 2)
351
+ '--no-timestamps',
352
+ '-np',
353
+ '--prompt', buildPrompt(context),
354
+ ]
355
+ if (useVad) {
356
+ // Same VAD model the streaming path uses. Strips silence windows
357
+ // before the decoder sees them — prevents the silence-hallucination
358
+ // failure mode even with large-v3's more permissive decoder.
359
+ args.push('--vad', '--vad-model', VAD_MODEL_PATH)
360
+ }
361
+ const proc = spawn(WHISPER_CLI, args, {
362
+ stdio: ['ignore', 'pipe', 'pipe'],
363
+ })
364
+
365
+ let stdout = ''
366
+ let stderr = ''
367
+ proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
368
+ proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
369
+
370
+ // Timeout scaled to decoder complexity: large-v3 is ~4x slower than
371
+ // turbo per bake-off (0.084x vs 0.024x RTF). 30-40s segments × 4x
372
+ // multiplier × beam-search overhead = ~240s safety ceiling for HQ.
373
+ // Turbo retains the old 60s ceiling.
374
+ const timeoutMs = useLargeV3 ? 240_000 : 60_000
375
+ const timeout = setTimeout(() => {
376
+ proc.kill('SIGTERM')
377
+ reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
378
+ }, timeoutMs)
379
+
380
+ proc.on('close', (code) => {
381
+ clearTimeout(timeout)
382
+ if (code !== 0) {
383
+ reject(new Error(`whisper-cli HQ exit ${code}: ${stderr.trim().slice(0, 200)}`))
384
+ return
385
+ }
386
+ resolve(stdout.trim())
387
+ })
388
+
389
+ proc.on('error', (err) => {
390
+ clearTimeout(timeout)
391
+ reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
392
+ })
393
+ })
394
+
395
+ const corrected = applyCorrections(text)
396
+ const elapsed = Date.now() - start
397
+ const modelTag = useLargeV3 ? 'large-v3' : 'turbo'
398
+ console.log(`[whisper-hq] Batch transcribed in ${elapsed}ms (${modelTag}${useVad ? '+vad' : ''}): "${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`)
399
+ return { text: corrected }
400
+ } finally {
401
+ try { unlinkSync(tmpWav) } catch { /* cleanup */ }
402
+ }
403
+ }
404
+
405
+ /** Build prompt with optional previous transcript context for continuity */
406
+ function buildPrompt(context?: string, isQuiet?: boolean): string {
407
+ // During quiet/silence audio, skip vocabulary prompt to reduce decoder bias
408
+ if (isQuiet) return ''
409
+ if (!context || context.length < 10) return getWhisperPrompt()
410
+ // Belt-and-suspenders: strip caption-training-artifact tokens from context
411
+ // before feeding to whisper-server. Primary filter is in transcribe-stream.ts;
412
+ // this catches anything that slipped through (e.g., batch re-transcription path).
413
+ let sanitized = context
414
+ .replace(/[*\[(♪][^*\])♪\n]{1,40}[*\])♪]/g, '') // *music*, [applause], (laughter), ♪ ♪
415
+ .replace(/\s{2,}/g, ' ')
416
+ .trim()
417
+ // Break the self-reinforcing loop: a brand-URL hallucination that landed in a
418
+ // prior chunk would otherwise feed back through this context and re-seed the
419
+ // next decode. Strip brand URLs from the priming context (output unaffected).
420
+ if (STRIP_BRAND_URLS) sanitized = stripBrandUrls(sanitized)
421
+ // Previous transcript helps Whisper maintain proper noun consistency,
422
+ // continue sentences across chunk boundaries, and reduce hallucinations
423
+ return PROMPT_V2 ? `${getWhisperPrompt()} ${sanitized}` : `${getWhisperPrompt()}. ${sanitized}`
424
+ }
425
+
426
+ /**
427
+ * Transcribe via whisper-server (persistent daemon, ~50-100ms).
428
+ * Returns text + optional word-level timestamps from DTW alignment.
429
+ */
430
+ async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; words?: WhisperWord[] }> {
431
+ const formData = new FormData()
432
+ // Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
433
+ const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' })
434
+ formData.append('file', blob, 'recording.wav')
435
+ formData.append('response_format', 'verbose_json') // includes segments[].words[] with DTW
436
+ formData.append('prompt', buildPrompt(context, isQuiet))
437
+ // Anti-hallucination handled by client-side filter + context filtering.
438
+ // Whisper-level entropy/logprob thresholds were too aggressive — silently dropped
439
+ // legitimate speech from quiet sources (laptop speakers through G2 mic).
440
+ formData.append('suppress_non_speech', 'true') // Suppress special/non-speech tokens (benign)
441
+
442
+ const response = await fetch(`${WHISPER_SERVER_URL}/inference`, {
443
+ method: 'POST',
444
+ body: formData,
445
+ signal: AbortSignal.timeout(10_000),
446
+ })
447
+
448
+ if (!response.ok) {
449
+ throw new Error(`whisper-server ${response.status}: ${await response.text()}`)
450
+ }
451
+
452
+ const result = await response.json() as WhisperVerboseResponse
453
+ const text = result.text?.trim() || ''
454
+
455
+ // Extract word-level timestamps from DTW-aligned segments (defensive — may be absent)
456
+ let words: WhisperWord[] | undefined
457
+ if (result.segments && result.segments.length > 0) {
458
+ const extracted = result.segments.flatMap(s => {
459
+ if (!s.words || !Array.isArray(s.words)) return []
460
+ return s.words.filter(w => typeof w.start === 'number' && typeof w.end === 'number')
461
+ })
462
+ if (extracted.length > 0) words = extracted
463
+ }
464
+
465
+ return { text, words }
466
+ }
467
+
468
+ /**
469
+ * Transcribe via whisper-cli (spawned per request, ~500-700ms).
470
+ */
471
+ async function transcribeViaCLI(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<string> {
472
+ const id = crypto.randomUUID().slice(0, 8)
473
+ const tmpWav = join('/tmp', `cos-whisper-${id}.wav`)
474
+
475
+ try {
476
+ writeFileSync(tmpWav, audioBuffer)
477
+
478
+ return await new Promise<string>((resolve, reject) => {
479
+ const proc = spawn(WHISPER_CLI, [
480
+ '-m', MODEL_PATH,
481
+ '-f', tmpWav,
482
+ '-t', '12',
483
+ '-l', 'en',
484
+ '-fa',
485
+ '--no-timestamps',
486
+ '-np',
487
+ '--prompt', buildPrompt(context, isQuiet),
488
+ ], {
489
+ stdio: ['ignore', 'pipe', 'pipe'],
490
+ })
491
+
492
+ let stdout = ''
493
+ let stderr = ''
494
+
495
+ proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
496
+ proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
497
+
498
+ const timeout = setTimeout(() => {
499
+ proc.kill('SIGTERM')
500
+ reject(new Error('whisper-cli timeout (20s)'))
501
+ }, 20_000)
502
+
503
+ proc.on('close', (code) => {
504
+ clearTimeout(timeout)
505
+ if (code !== 0) {
506
+ reject(new Error(`whisper-cli exit ${code}: ${stderr.trim().slice(0, 200)}`))
507
+ return
508
+ }
509
+ const cleaned = stdout.trim()
510
+ if (!cleaned) {
511
+ reject(new Error('whisper-cli returned empty output'))
512
+ return
513
+ }
514
+ resolve(cleaned)
515
+ })
516
+
517
+ proc.on('error', (err) => {
518
+ clearTimeout(timeout)
519
+ reject(new Error(`whisper-cli spawn error: ${err.message}`))
520
+ })
521
+ })
522
+ } finally {
523
+ try { unlinkSync(tmpWav) } catch { /* ignore */ }
524
+ }
525
+ }
526
+
527
+ // Post-processing correction dictionary — deterministic fixes for names Whisper garbles.
528
+ // Prompt biasing is probabilistic; regex replacement is guaranteed.
529
+ // User-specific corrections loaded from .cos-profile.json "whisper_corrections" field.
530
+ import { loadProfileField } from './profile.js'
531
+
532
+ function buildCorrections(): Array<[RegExp, string]> {
533
+ const corrections: Array<[RegExp, string]> = []
534
+
535
+ // Load user-configured corrections from profile
536
+ // Format: { "whisper_corrections": { "Soundalike": "YourName", ... } }
537
+ try {
538
+ const raw = loadProfileField('whisper_corrections', '')
539
+ if (raw) {
540
+ const map = JSON.parse(raw) as Record<string, string>
541
+ for (const [pattern, replacement] of Object.entries(map)) {
542
+ corrections.push([new RegExp(`\\b${pattern}\\b`, 'gi'), replacement])
543
+ }
544
+ }
545
+ } catch { /* invalid JSON — skip */ }
546
+
547
+ return corrections
548
+ }
549
+
550
+ let _corrections: Array<[RegExp, string]> | null = null
551
+ function getCorrections(): Array<[RegExp, string]> {
552
+ if (!_corrections) _corrections = buildCorrections()
553
+ return _corrections
554
+ }
555
+
556
+ // Exported so non-live surfaces (outbound dictation finalize) can apply the
557
+ // same deterministic name corrections. On the live path this still runs inside
558
+ // transcribeLocal; exporting it does not change live behavior.
559
+ export function applyCorrections(text: string): string {
560
+ let result = text
561
+ for (const [pattern, replacement] of getCorrections()) {
562
+ result = result.replace(pattern, replacement)
563
+ }
564
+ return result
565
+ }
566
+
567
+ /** Null the lazily-built decoder snapshots so the next decode + correction
568
+ * reload from a freshly-read profile. Call AFTER clearProfileCache() (profile.ts)
569
+ * on any glossary/profile write — clearProfileCache busts the root JSON cache,
570
+ * this busts the two derived snapshots that would otherwise re-serve stale
571
+ * vocabulary/corrections until a server restart. */
572
+ export function resetDecoderCaches(): void {
573
+ _whisperPrompt = null
574
+ _corrections = null
575
+ }
576
+
577
+ /**
578
+ * Transcribe audio locally — tries whisper-server first, then OpenAI cloud (via caller).
579
+ *
580
+ * Fallback strategy for REAL-TIME streaming:
581
+ * server (50ms) → throw → caller falls to OpenAI cloud (1-3s)
582
+ *
583
+ * We intentionally do NOT fall to whisper-cli for real-time because when the server
584
+ * is unhealthy, CLI cold-loads the 1.5GB model from disk = ~11s per chunk (worse than cloud).
585
+ * CLI is reserved for batch/HQ transcription where latency doesn't matter.
586
+ *
587
+ * Circuit breaker: after SERVER_FAILURE_THRESHOLD consecutive server failures,
588
+ * auto-restart the server process in the background and throw immediately so the
589
+ * caller can use cloud while the server recovers (~20s model load).
590
+ */
591
+ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
592
+ const start = Date.now()
593
+
594
+ // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
595
+ if (serverAvailable) {
596
+ try {
597
+ const result = await transcribeViaServer(audioBuffer, context, isQuiet)
598
+ const text = applyCorrections(result.text)
599
+ const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
600
+ const elapsed = Date.now() - start
601
+ // Reset circuit breaker on success
602
+ if (serverConsecutiveFailures > 0) {
603
+ console.log(`[whisper-local] Server recovered after ${serverConsecutiveFailures} consecutive failure(s)`)
604
+ serverConsecutiveFailures = 0
605
+ }
606
+ console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words): "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`)
607
+ return { text, backend: 'server', words }
608
+ } catch (err: any) {
609
+ serverConsecutiveFailures++
610
+ const isTimeout = err.message.includes('timeout') || err.message.includes('aborted')
611
+ const isDead = err.message.includes('ECONNREFUSED') || err.message.includes('fetch failed')
612
+
613
+ if (isDead) {
614
+ serverAvailable = false
615
+ console.error(`[whisper-local] Server DEAD (ECONNREFUSED) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
616
+ } else if (isTimeout) {
617
+ // Server process exists but is hung — mark unavailable so we stop trying
618
+ serverAvailable = false
619
+ console.error(`[whisper-local] Server HUNG (timeout) — marked unavailable. Consecutive failures: ${serverConsecutiveFailures}`)
620
+ }
621
+
622
+ // Circuit breaker: auto-restart after threshold
623
+ if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
624
+ console.error(`[whisper-local] ⚠ CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures. Auto-restarting server...`)
625
+ // Non-blocking restart in background
626
+ restartWhisperServer()
627
+ } else if (serverConsecutiveFailures < SERVER_FAILURE_THRESHOLD) {
628
+ console.warn(`[whisper-local] Server failed (${serverConsecutiveFailures}/${SERVER_FAILURE_THRESHOLD} before restart): ${err.message}`)
629
+ }
630
+
631
+ // Throw to let caller fall to OpenAI cloud (1-3s) — much faster than CLI cold-start (11s)
632
+ throw new Error(`whisper-server unavailable: ${err.message}`)
633
+ }
634
+ }
635
+
636
+ // Server not available — still count toward circuit breaker so auto-restart can fire.
637
+ // Without this, the counter stalls after the first failure marks serverAvailable=false
638
+ // and subsequent calls never increment, so restart never triggers.
639
+ serverConsecutiveFailures++
640
+ if (serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD && !serverRestarting) {
641
+ console.error(`[whisper-local] CIRCUIT BREAKER OPEN — ${serverConsecutiveFailures} consecutive failures (server unavailable). Auto-restarting...`)
642
+ restartWhisperServer()
643
+ }
644
+
645
+ // Throw so caller uses cloud fallback — CLI is intentionally skipped for real-time
646
+ throw new Error('whisper-server unavailable — use cloud fallback')
647
+ }
648
+
649
+ /**
650
+ * Auto-restart whisper-server after circuit breaker triggers.
651
+ * Non-blocking — runs in background while callers use cloud fallback.
652
+ */
653
+ async function restartWhisperServer(): Promise<void> {
654
+ if (serverRestarting) return
655
+ serverRestarting = true
656
+
657
+ try {
658
+ // Kill any existing server process
659
+ if (serverProcess) {
660
+ try { serverProcess.kill('SIGKILL') } catch {}
661
+ serverProcess = null
662
+ }
663
+ // Also kill any zombie processes
664
+ try {
665
+ execSync('pkill -9 -f "whisper-server"', { stdio: 'ignore' })
666
+ } catch { /* none running */ }
667
+
668
+ // Wait for port to clear
669
+ for (let i = 0; i < 6; i++) {
670
+ try {
671
+ execSync('lsof -i :8178 -t', { stdio: 'ignore' })
672
+ await new Promise(r => setTimeout(r, 500))
673
+ } catch {
674
+ break
675
+ }
676
+ }
677
+
678
+ console.log('[whisper-local] Restarting whisper-server (model load ~20s)...')
679
+ await startWhisperServer()
680
+
681
+ if (serverAvailable) {
682
+ serverConsecutiveFailures = 0
683
+ console.log('[whisper-local] Server restarted successfully — circuit breaker CLOSED')
684
+ } else {
685
+ // Reset counter so the next N failures can trigger another restart attempt
686
+ // Without this, the counter stays >= threshold but serverRestarting is false,
687
+ // so every subsequent call would re-trigger restart in a tight loop
688
+ serverConsecutiveFailures = 0
689
+ console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Using cloud fallback.')
690
+ }
691
+ } catch (err: any) {
692
+ serverConsecutiveFailures = 0 // Same reset — allow future retry cycle
693
+ console.error(`[whisper-local] Server restart error: ${err.message} — will retry after next 3 failures`)
694
+ } finally {
695
+ serverRestarting = false
696
+ }
697
+ }