@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.
- package/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- 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
|
+
}
|