@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,1090 @@
1
+ // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
2
+ // Uses local Whisper (50ms) with OpenAI API fallback.
3
+ // Streams speaker-labeled transcript chunks for live meeting capture.
4
+
5
+ import { Router } from 'express'
6
+ import { createHash } from 'node:crypto'
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
8
+ import { writeFile } from 'node:fs/promises'
9
+ import { resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { getVocabulary, getOwnerName } from '../lib/profile.js'
12
+ import { getOpenAIKey } from '../lib/openai-key.js'
13
+
14
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
15
+ import { emitDisplay } from '../lib/display-bus.js'
16
+ import { errMsg } from '../lib/utils.js'
17
+ import { transcribeLocal, isWhisperLocalAvailable, type WhisperWord } from '../lib/whisper-local.js'
18
+ import { enhanceAudio } from '../lib/audio-enhance.js'
19
+ import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
20
+ import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
21
+ import {
22
+ assertOpenAIWhisperBudget,
23
+ recordOpenAIWhisperUsage,
24
+ estimateAudioSeconds,
25
+ OpenAIWhisperBudgetExhaustedError,
26
+ } from '../lib/openai-whisper-budget.js'
27
+ import {
28
+ stripInlineHallucinations as sharedStripInlineHallucinations,
29
+ isFullHallucination as sharedIsFullHallucination,
30
+ clearSessionHallucinationState,
31
+ streamSilenceDropReason,
32
+ } from '../lib/hallucination-filter.js'
33
+
34
+ // Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
35
+ // brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
36
+ // any-URL-only / repeated-thank-you -> dropped only when isQuiet (rms<150), so a real
37
+ // softly-spoken utterance or a clearly-dictated third-party URL is never dropped.
38
+ // Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
39
+ const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
40
+ const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
41
+
42
+ // Audio persistence: save G2-mic chunks for speakers who need more training data
43
+ import { dataPath } from '../lib/data-dir.js'
44
+ const AUDIO_SAVE_DIR = dataPath('training-audio')
45
+ const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
46
+
47
+ // Unrecognized speaker audio: save Ext chunks for retroactive enrollment
48
+ const EXT_AUDIO_DIR = dataPath('ext-audio')
49
+ if (!existsSync(EXT_AUDIO_DIR)) mkdirSync(EXT_AUDIO_DIR, { recursive: true })
50
+ const EXT_AUDIO_TTL_MS = 72 * 60 * 60 * 1000 // 72-hour retention
51
+ const MAX_EXT_CHUNKS_PER_SESSION = 40 // cap per session to avoid runaway storage
52
+ const extAudioCounts = new Map<string, number>() // sessionId → chunk count
53
+
54
+ // ── Hallucination filter — delegated to shared lib (server/lib/hallucination-filter.ts) ──
55
+ // Local wrappers preserve existing call-site signatures; shared lib is the single source
56
+ // of truth, also used by /api/transcribe (one-shot message query path).
57
+ function stripInlineHallucinations(text: string, sessionId: string): string {
58
+ return sharedStripInlineHallucinations(text, sessionId)
59
+ }
60
+ function isServerHallucination(text: string): boolean {
61
+ return sharedIsFullHallucination(text)
62
+ }
63
+
64
+ // Legacy constants kept empty — the block below used to define them but they now live
65
+ // in hallucination-filter.ts. Explicitly deleted (not undefined) so any stray references
66
+ // in older code fail loudly at compile time.
67
+ // (removed: KNOWN_HALLUCINATIONS, INLINE_HALLUCINATION_THRESHOLD, KNOWN_INLINE_HALLUCINATIONS,
68
+ // inlineNameFrequency, inlineBlocklist, INLINE_NAME_PATTERN, SOUND_DESCRIPTORS,
69
+ // SOUND_DESCRIPTOR_PATTERN, ASTERISK_CAPTION, WHOLE_CHUNK_ASTERISK, FILLER_WORDS)
70
+ const _unused_hallucination_constants_placeholder = 0 as const
71
+
72
+
73
+ // Session audio persistence: save all WAV chunks for batch re-transcription at save time
74
+ const SESSION_AUDIO_DIR = dataPath('session-audio')
75
+ if (!existsSync(SESSION_AUDIO_DIR)) mkdirSync(SESSION_AUDIO_DIR, { recursive: true })
76
+ const PENDING_BATCH_DIR = dataPath('pending-batch')
77
+ if (!existsSync(PENDING_BATCH_DIR)) mkdirSync(PENDING_BATCH_DIR, { recursive: true })
78
+ const MAX_SESSION_AUDIO_BYTES = 500 * 1024 * 1024 // 500MB cap per session (~2hr meeting ≈ 260MB)
79
+ const MAX_CANDIDATE_WAV_BASE64_CHARS = 8 * 1024 * 1024 // stay below server/index.ts 10mb JSON parser cap
80
+ const MAX_CANDIDATE_TEXT_CHARS = 8000
81
+ const MAX_CANDIDATE_WORDS = 1200
82
+ const sessionAudioBytes = new Map<string, number>() // track per-session byte count
83
+ const sessionAudioWrites = new Map<string, Set<Promise<void>>>()
84
+
85
+ // In-memory training audio counts — lazy-initialized from disk on first access per speaker
86
+ const trainingAudioCounts = new Map<string, number>()
87
+ function getTrainingCount(speakerDir: string): number {
88
+ let count = trainingAudioCounts.get(speakerDir)
89
+ if (count === undefined) {
90
+ try {
91
+ if (existsSync(speakerDir)) {
92
+ count = readdirSync(speakerDir).filter((f: string) => f.endsWith('.wav')).length
93
+ } else {
94
+ count = 0
95
+ }
96
+ } catch {
97
+ count = 0
98
+ }
99
+ trainingAudioCounts.set(speakerDir, count)
100
+ }
101
+ return count
102
+ }
103
+
104
+ function sha256Hex(buffer: Buffer): string {
105
+ return createHash('sha256').update(buffer).digest('hex')
106
+ }
107
+
108
+ function candidateKey(record: Pick<ProviderCandidateRecord, 'provider' | 'chunkIndex' | 'audioSha256'>): string {
109
+ return `${record.chunkIndex}:${record.provider}:${record.audioSha256}`
110
+ }
111
+
112
+ function trackSessionAudioWrite(sessionId: string, writeJob: Promise<void>): Promise<void> {
113
+ let writes = sessionAudioWrites.get(sessionId)
114
+ if (!writes) {
115
+ writes = new Set()
116
+ sessionAudioWrites.set(sessionId, writes)
117
+ }
118
+ writes.add(writeJob)
119
+ writeJob.finally(() => {
120
+ writes?.delete(writeJob)
121
+ if (writes && writes.size === 0) sessionAudioWrites.delete(sessionId)
122
+ }).catch(() => {})
123
+ return writeJob
124
+ }
125
+
126
+ export async function drainSessionAudioWrites(sessionId: string): Promise<void> {
127
+ const writes = sessionAudioWrites.get(sessionId)
128
+ if (!writes || writes.size === 0) return
129
+ const settled = await Promise.allSettled([...writes])
130
+ const rejected = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected')
131
+ if (rejected) throw rejected.reason
132
+ }
133
+
134
+ export const transcribeStreamRouter = Router()
135
+
136
+ // Session accumulator: sessionId -> { chunks, startTime, title }
137
+ export interface TranscriptChunk {
138
+ text: string
139
+ speaker: string
140
+ elapsed: number // ms since session start
141
+ similarity: number // speaker identification confidence (0-1)
142
+ words?: WhisperWord[] // word-level timestamps from DTW alignment
143
+ asrProvider?: 'server-whisper' | 'iphone-whisperkit-beta'
144
+ backend?: string
145
+ model?: string
146
+ mode?: string
147
+ fallbackReason?: string
148
+ latencyMs?: number
149
+ audioSha256?: string
150
+ canonical?: boolean
151
+ }
152
+
153
+ export interface ProviderCandidateRecord {
154
+ provider: 'iphone-whisperkit-beta'
155
+ chunkIndex: number
156
+ elapsed: number
157
+ audioSha256: string
158
+ text: string
159
+ words?: WhisperWord[]
160
+ latencyMs?: number
161
+ model?: string
162
+ mode?: string
163
+ receivedAt: number
164
+ accepted?: boolean
165
+ fallbackReason?: string
166
+ }
167
+
168
+ interface TranscriptSession {
169
+ chunks: TranscriptChunk[]
170
+ startTime: number
171
+ title: string
172
+ providerCandidates?: Record<string, ProviderCandidateRecord>
173
+ }
174
+
175
+ const sessions = new Map<string, TranscriptSession>()
176
+ const CLOSED_SESSION_TTL_MS = 4 * 60 * 60 * 1000
177
+ const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
178
+
179
+ // Incremental chunk persistence — survive server restarts
180
+ const CHUNK_PERSIST_DIR = dataPath('active-sessions')
181
+ if (!existsSync(CHUNK_PERSIST_DIR)) mkdirSync(CHUNK_PERSIST_DIR, { recursive: true })
182
+
183
+ function readClosedSessions(): Record<string, number> {
184
+ if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
185
+ try {
186
+ const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
187
+ if (!parsed || typeof parsed !== 'object') return {}
188
+ return Object.fromEntries(
189
+ Object.entries(parsed as Record<string, unknown>)
190
+ .filter(([id, ts]) => /^[A-Za-z0-9:_-]{3,96}$/.test(id) && typeof ts === 'number'),
191
+ ) as Record<string, number>
192
+ } catch {
193
+ try {
194
+ renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
195
+ } catch {}
196
+ return {}
197
+ }
198
+ }
199
+
200
+ function persistClosedSessions(): void {
201
+ const now = Date.now()
202
+ const merged = readClosedSessions()
203
+ for (const id of deletedSessions) merged[id] = now
204
+ for (const [id, closedAt] of Object.entries(merged)) {
205
+ if (now - closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
206
+ }
207
+ try {
208
+ const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
209
+ writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8')
210
+ renameSync(tmp, CLOSED_SESSIONS_FILE)
211
+ } catch { /* best-effort tombstones */ }
212
+ }
213
+
214
+ function recoverClosedSessions(): void {
215
+ const now = Date.now()
216
+ const closed = readClosedSessions()
217
+ let dirty = false
218
+ for (const [id, closedAt] of Object.entries(closed)) {
219
+ if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
220
+ deletedSessions.add(id)
221
+ } else {
222
+ delete closed[id]
223
+ dirty = true
224
+ }
225
+ }
226
+ if (dirty) {
227
+ try {
228
+ const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
229
+ writeFileSync(tmp, JSON.stringify(closed, null, 2), 'utf-8')
230
+ renameSync(tmp, CLOSED_SESSIONS_FILE)
231
+ } catch {}
232
+ }
233
+ }
234
+
235
+ /** Persist a session's chunks to disk (called after each new chunk) */
236
+ function persistSession(sessionId: string): void {
237
+ try {
238
+ const session = sessions.get(sessionId)
239
+ if (!session) return
240
+ const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
241
+ const data = JSON.stringify({
242
+ sessionId,
243
+ startTime: session.startTime,
244
+ title: session.title,
245
+ chunks: session.chunks.filter(c => c && c.text),
246
+ providerCandidates: session.providerCandidates ?? {},
247
+ })
248
+ writeFileSync(filePath, data, 'utf-8')
249
+ } catch { /* non-critical — don't break transcription for persistence */ }
250
+ }
251
+
252
+ /** Recover sessions from disk on server restart */
253
+ function recoverSessions(): void {
254
+ try {
255
+ if (!existsSync(CHUNK_PERSIST_DIR)) return
256
+ const files = readdirSync(CHUNK_PERSIST_DIR) as string[]
257
+ const recoveredIds = new Set<string>()
258
+ for (const file of files) {
259
+ if (!file.endsWith('.json')) continue
260
+ try {
261
+ const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
262
+ if (data.sessionId && data.chunks && data.chunks.length > 0) {
263
+ // Only recover sessions less than 4 hours old
264
+ if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
265
+ const session: TranscriptSession = {
266
+ chunks: data.chunks,
267
+ startTime: data.startTime,
268
+ title: data.title || '',
269
+ providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
270
+ ? data.providerCandidates
271
+ : {},
272
+ }
273
+ // Retroactively strip inline hallucinations from recovered chunks.
274
+ // Two-pass: (1) scan all chunks to build frequency/blocklist,
275
+ // (2) strip from all chunks using the final blocklist.
276
+ // Single-pass would miss non-seeded names in early chunks.
277
+ for (const chunk of session.chunks) {
278
+ if (chunk?.text) stripInlineHallucinations(chunk.text, data.sessionId) // pass 1: build blocklist
279
+ }
280
+ let cleaned = 0
281
+ for (const chunk of session.chunks) {
282
+ if (chunk?.text) {
283
+ const stripped = stripInlineHallucinations(chunk.text, data.sessionId)
284
+ if (stripped !== chunk.text) {
285
+ chunk.text = stripped
286
+ cleaned++
287
+ }
288
+ }
289
+ }
290
+ sessions.set(data.sessionId, session)
291
+ recoveredIds.add(data.sessionId)
292
+ if (cleaned > 0) {
293
+ console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
294
+ persistSession(data.sessionId) // re-persist cleaned data to disk
295
+ }
296
+ console.log(`[session-recovery] Recovered ${data.chunks.length} chunks for ${data.sessionId}`)
297
+ } else {
298
+ // Stale — clean up
299
+ unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
300
+ }
301
+ }
302
+ } catch { /* skip corrupt files */ }
303
+ }
304
+ // Remove orphaned session-audio dirs with no matching recovered session
305
+ try {
306
+ if (existsSync(SESSION_AUDIO_DIR)) {
307
+ for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
308
+ if (!recoveredIds.has(dir)) {
309
+ rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
310
+ console.log(`[session-recovery] Cleaned orphaned session-audio: ${dir}`)
311
+ }
312
+ }
313
+ }
314
+ } catch {}
315
+ } catch { /* non-critical */ }
316
+ }
317
+
318
+ // Track recently-deleted session IDs so the /diag/client endpoint can return 410 Gone
319
+ // to zombie clients still heartbeating into non-existent sessions (spam mitigation).
320
+ // Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
321
+ const deletedSessions = new Set<string>()
322
+ const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
323
+ export function isSessionDeleted(sessionId: string): boolean {
324
+ return deletedSessions.has(sessionId)
325
+ }
326
+
327
+ // Recover any sessions from prior server instance.
328
+ recoverClosedSessions()
329
+ recoverSessions()
330
+
331
+ // Auto-cleanup sessions older than 4 hours
332
+ setInterval(() => {
333
+ const cutoff = Date.now() - 4 * 60 * 60 * 1000
334
+ for (const [id, session] of sessions) {
335
+ if (session.startTime < cutoff) {
336
+ sessions.delete(id)
337
+ sessionAudioBytes.delete(id)
338
+ sessionAudioWrites.delete(id)
339
+ clearSessionHallucinationState(id)
340
+ // Clean up persisted file too
341
+ try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
342
+ // Clean up session audio
343
+ try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
344
+ }
345
+ }
346
+ // Purge orphaned session-audio dirs (no matching active session)
347
+ try {
348
+ for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
349
+ if (!sessions.has(dir)) {
350
+ rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
351
+ }
352
+ }
353
+ } catch {}
354
+ // Purge stale pending-batch dirs older than 2 hours after move (batch should complete in minutes).
355
+ // Age measured by _batch_pending.marker mtime (set by moveSessionAudioToPending), NOT the first
356
+ // chunk's mtime — chunk files keep their original write-time across the atomic rename, so for
357
+ // meetings > 1 hour, chunk mtimes would always look stale. Fallback: dir mtime for older marker-less dirs.
358
+ try {
359
+ for (const dir of readdirSync(PENDING_BATCH_DIR)) {
360
+ const dirPath = resolve(PENDING_BATCH_DIR, dir)
361
+ try {
362
+ const files = readdirSync(dirPath)
363
+ if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
364
+ // Prefer the marker file mtime over first chunk mtime
365
+ const markerPath = resolve(dirPath, '_batch_pending.marker')
366
+ let ageSource: number
367
+ if (existsSync(markerPath)) {
368
+ ageSource = statSync(markerPath).mtimeMs
369
+ } else {
370
+ // Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
371
+ ageSource = statSync(dirPath).ctimeMs
372
+ }
373
+ if (Date.now() - ageSource > 2 * 60 * 60 * 1000) {
374
+ rmSync(dirPath, { recursive: true, force: true })
375
+ console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
376
+ }
377
+ } catch {}
378
+ }
379
+ } catch {}
380
+
381
+ // Purge ext-audio dirs older than 72 hours
382
+ try {
383
+ if (existsSync(EXT_AUDIO_DIR)) {
384
+ for (const dir of readdirSync(EXT_AUDIO_DIR)) {
385
+ const dirPath = resolve(EXT_AUDIO_DIR, dir)
386
+ try {
387
+ const files = readdirSync(dirPath)
388
+ if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
389
+ const { mtimeMs } = statSync(resolve(dirPath, files[0]))
390
+ if (Date.now() - mtimeMs > EXT_AUDIO_TTL_MS) {
391
+ rmSync(dirPath, { recursive: true, force: true })
392
+ extAudioCounts.delete(dir)
393
+ console.log(`[ext-audio] Purged expired ext-audio: ${dir} (>72h)`)
394
+ }
395
+ } catch {}
396
+ }
397
+ }
398
+ } catch {}
399
+ persistClosedSessions()
400
+ }, 60_000)
401
+
402
+ /** Get or create a transcript session */
403
+ export function getSession(sessionId: string): TranscriptSession {
404
+ let session = sessions.get(sessionId)
405
+ if (!session) {
406
+ session = { chunks: [], startTime: Date.now(), title: '', providerCandidates: {} }
407
+ sessions.set(sessionId, session)
408
+ }
409
+ if (!session.providerCandidates) session.providerCandidates = {}
410
+ return session
411
+ }
412
+
413
+ /** Get full accumulated transcript for a session (with speaker labels) */
414
+ export function getSessionTranscript(sessionId: string): string | null {
415
+ const session = sessions.get(sessionId)
416
+ if (!session) return null
417
+ return session.chunks
418
+ .map(c => c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
419
+ .join('\n')
420
+ }
421
+
422
+ /** Get structured chunks with timing + speaker confidence (for blended meeting pipeline) */
423
+ export function getSessionChunks(sessionId: string): TranscriptChunk[] | null {
424
+ const session = sessions.get(sessionId)
425
+ if (!session) return null
426
+ return session.chunks.filter(c => c && c.text)
427
+ }
428
+
429
+ /** Get session start time */
430
+ export function getSessionStartTime(sessionId: string): number | null {
431
+ return sessions.get(sessionId)?.startTime ?? null
432
+ }
433
+
434
+ /** Move session audio to pending-batch before deletion (batch re-transcription needs it) */
435
+ export function moveSessionAudioToPending(sessionId: string): string | null {
436
+ const srcDir = resolve(SESSION_AUDIO_DIR, sessionId)
437
+ if (!existsSync(srcDir)) return null
438
+ const destDir = resolve(PENDING_BATCH_DIR, sessionId)
439
+ try {
440
+ // Rename is atomic on same filesystem
441
+ renameSync(srcDir, destDir)
442
+ // Write marker file with move timestamp. The cleanup interval checks THIS file's
443
+ // mtime for TTL, not individual chunk files (whose mtimes date from original write).
444
+ // Without this marker, meetings > 1 hour have first chunks older than the 1-hour
445
+ // TTL, causing the interval to purge pending-batch before batch re-transcription
446
+ // can run. Observed 2026-04-13: 103-min meeting lost audio to this race condition.
447
+ try { writeFileSync(resolve(destDir, '_batch_pending.marker'), String(Date.now()), 'utf-8') } catch {}
448
+ return destDir
449
+ } catch (err: unknown) {
450
+ console.warn(`[session-audio] Failed to move to pending-batch: ${errMsg(err)}`)
451
+ return null
452
+ }
453
+ }
454
+
455
+ export function getSessionProviderCandidates(sessionId: string): Record<string, ProviderCandidateRecord> {
456
+ return sessions.get(sessionId)?.providerCandidates ?? {}
457
+ }
458
+
459
+ /** Delete session after save */
460
+ export function deleteSession(sessionId: string): void {
461
+ sessions.delete(sessionId)
462
+ sessionAudioBytes.delete(sessionId)
463
+ // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
464
+ clearSessionHallucinationState(sessionId)
465
+ // Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
466
+ deletedSessions.add(sessionId)
467
+ if (deletedSessions.size > DELETED_SESSION_CAP) {
468
+ // Trim oldest entries to prevent unbounded growth
469
+ const arr = Array.from(deletedSessions)
470
+ for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
471
+ }
472
+ persistClosedSessions()
473
+ // Clean up persisted file
474
+ try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
475
+ // Clean up session audio if it wasn't moved to pending-batch
476
+ const audioDir = resolve(SESSION_AUDIO_DIR, sessionId)
477
+ try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
478
+ }
479
+
480
+ function isIosAsrCandidateEnabled(): boolean {
481
+ return process.env.COS_IOS_ASR_CANDIDATES === '1'
482
+ }
483
+
484
+ function makeHttpError(status: number, message: string, reason?: string): Error & { status?: number; reason?: string } {
485
+ const err = new Error(message) as Error & { status?: number; reason?: string }
486
+ err.status = status
487
+ err.reason = reason
488
+ return err
489
+ }
490
+
491
+ function validateSessionId(sessionId: string): void {
492
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
493
+ throw makeHttpError(400, 'invalid sessionId', 'invalid_session_id')
494
+ }
495
+ }
496
+
497
+ function validateChunkIndex(chunkIndex: number): void {
498
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex > 9999) {
499
+ throw makeHttpError(400, 'invalid chunkIndex', 'invalid_chunk_index')
500
+ }
501
+ }
502
+
503
+ function normalizeCandidateText(value: unknown): string {
504
+ const text = String(value ?? '')
505
+ if (text.length > MAX_CANDIDATE_TEXT_CHARS) {
506
+ throw makeHttpError(413, 'candidate text too large', 'candidate_text_too_large')
507
+ }
508
+ return text
509
+ }
510
+
511
+ function normalizeCandidateWords(value: unknown): WhisperWord[] | undefined {
512
+ if (!Array.isArray(value)) return undefined
513
+ const normalized: WhisperWord[] = []
514
+ for (const item of value.slice(0, MAX_CANDIDATE_WORDS)) {
515
+ if (!item || typeof item !== 'object') continue
516
+ const raw = item as Record<string, unknown>
517
+ const word = typeof raw.word === 'string' ? raw.word.trim().slice(0, 80) : ''
518
+ const start = typeof raw.start === 'number' && Number.isFinite(raw.start) ? raw.start : undefined
519
+ const end = typeof raw.end === 'number' && Number.isFinite(raw.end) ? raw.end : undefined
520
+ const probability = typeof raw.probability === 'number' && Number.isFinite(raw.probability)
521
+ ? raw.probability
522
+ : 0
523
+ if (!word || start == null || end == null) continue
524
+ normalized.push({ word, start, end, probability })
525
+ }
526
+ return normalized.length > 0 ? normalized : undefined
527
+ }
528
+
529
+ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
530
+ const buffers: Buffer[] = []
531
+ for await (const chunk of req) {
532
+ buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
533
+ }
534
+ return Buffer.concat(buffers)
535
+ }
536
+
537
+ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
538
+ const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
539
+ if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true })
540
+ const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
541
+ const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
542
+ const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
543
+ const nextBytes = currentBytes - existingSize + audioBuffer.length
544
+ if (nextBytes > MAX_SESSION_AUDIO_BYTES && !existsSync(chunkPath)) {
545
+ if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
546
+ return
547
+ }
548
+ const writeJob = writeFile(chunkPath, audioBuffer)
549
+ await trackSessionAudioWrite(sessionId, writeJob)
550
+ sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
551
+ }
552
+
553
+ function recentWhisperContext(sessionId: string, session: TranscriptSession): string {
554
+ const recentText = session.chunks
555
+ .filter(c => {
556
+ if (!c?.text) return false
557
+ const words = c.text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').split(/\s+/).filter((w: string) => w)
558
+ if (words.length < 2) return false
559
+ const unique = new Set(words)
560
+ if (unique.size === 1) return false
561
+ const wordCounts = new Map<string, number>()
562
+ for (const w of words) wordCounts.set(w, (wordCounts.get(w) ?? 0) + 1)
563
+ const maxCount = Math.max(...wordCounts.values())
564
+ if (maxCount / words.length > 0.7) return false
565
+ return true
566
+ })
567
+ .slice(-5)
568
+ .map(c => c.text)
569
+ .join(' ')
570
+ const cleanedContext = stripInlineHallucinations(recentText, sessionId)
571
+ return cleanedContext.length > 250 ? cleanedContext.slice(-250) : cleanedContext
572
+ }
573
+
574
+ function isCrossChunkRepeat(session: TranscriptSession, text: string): boolean {
575
+ const norm = text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').trim()
576
+ if (norm.length === 0 || norm.split(/\s+/).length > 6) return false
577
+ const recent = session.chunks.slice(-3).filter(c => c?.text)
578
+ const repeats = recent.filter(c =>
579
+ c.text.toLowerCase().replace(/[.!?,;:'"()\-\n]/g, '').trim() === norm
580
+ ).length
581
+ return repeats >= 2
582
+ }
583
+
584
+ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession, rawText: string, isQuiet = false): { text: string; fallbackReason?: string } {
585
+ let trimmedText = rawText?.trim() || ''
586
+ if (trimmedText) {
587
+ try { trimmedText = stripInlineHallucinations(trimmedText, sessionId) } catch { /* keep raw text */ }
588
+ }
589
+ // Silence-hallucination drops. brand-URL-only fires regardless of isQuiet (brand URLs
590
+ // are vocab-seeded and never a real standalone utterance); generic-URL-only and
591
+ // repeated-thank-you fire only when isQuiet so real soft speech / dictated URLs survive.
592
+ // Dropped chunks return '' and never enter session.chunks, so they don't pollute
593
+ // context priming or cross-chunk-repeat history. See streamSilenceDropReason contract.
594
+ if (trimmedText) {
595
+ const dropReason = streamSilenceDropReason(trimmedText, isQuiet)
596
+ const flagAllows = dropReason === 'thankyou_silence' ? THANKYOU_FILTER : STRIP_BRAND_URLS
597
+ if (dropReason && flagAllows) {
598
+ console.log(`[hallucination] Dropped (${dropReason}, q=${isQuiet ? 1 : 0}): "${trimmedText.slice(0, 60)}"`)
599
+ return { text: '', fallbackReason: dropReason }
600
+ }
601
+ }
602
+ if (trimmedText && isServerHallucination(trimmedText)) {
603
+ return { text: '', fallbackReason: 'hallucination' }
604
+ }
605
+ if (trimmedText && isCrossChunkRepeat(session, trimmedText)) {
606
+ return { text: '', fallbackReason: 'cross_chunk_repeat' }
607
+ }
608
+ return { text: trimmedText }
609
+ }
610
+
611
+ function storeProviderCandidate(
612
+ session: TranscriptSession,
613
+ record: ProviderCandidateRecord,
614
+ ): string {
615
+ session.providerCandidates ??= {}
616
+ const key = candidateKey(record)
617
+ session.providerCandidates[key] = record
618
+ return key
619
+ }
620
+
621
+ function canonicalChunkResponse(
622
+ existing: TranscriptChunk,
623
+ sessionId: string,
624
+ chunkIndex: number,
625
+ ): { text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string } {
626
+ return {
627
+ text: existing.text,
628
+ speaker: existing.speaker,
629
+ chunkIndex,
630
+ elapsed: existing.elapsed,
631
+ sessionId,
632
+ backend: existing.backend,
633
+ asrProvider: existing.asrProvider,
634
+ fallbackReason: existing.fallbackReason,
635
+ }
636
+ }
637
+
638
+ async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
639
+ if (isWhisperLocalAvailable()) {
640
+ try {
641
+ const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
642
+ return { text: result.text, words: result.words, backend: `local-${result.backend}` }
643
+ } catch (err: unknown) {
644
+ console.warn(`[transcribe-stream] Local Whisper failed, falling back to cloud: ${errMsg(err)}`)
645
+ const text = await transcribeViaCloud(whisperAudio)
646
+ return { text, words: undefined, backend: 'cloud' }
647
+ }
648
+ }
649
+ const text = await transcribeViaCloud(whisperAudio)
650
+ return { text, words: undefined, backend: 'cloud' }
651
+ }
652
+
653
+ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
654
+ const expectedSpeakers = undefined
655
+ const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
656
+ if (!isEmbeddingAvailable() || audioDurationSec < 2.0) return { speaker: clientSpeaker, similarity: 0 }
657
+
658
+ const tEmb = performance.now()
659
+ const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers)
660
+ console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
661
+ if (!embeddingResult) return { speaker: clientSpeaker, similarity: 0 }
662
+
663
+ let speaker = embeddingResult.speaker
664
+ if (speaker !== clientSpeaker) {
665
+ console.log(`[speaker] Embedding: ${speaker} vs Amplitude: ${clientSpeaker} (sim: ${embeddingResult.similarity.toFixed(2)})`)
666
+ }
667
+
668
+ if (embeddingResult.similarity >= 0.72 && speaker !== 'Ext') {
669
+ const enrollResult = autoEnroll(speaker, audioBuffer, embeddingResult.similarity, sessionId)
670
+ if (enrollResult.enrolled) {
671
+ console.log(`[speaker] Auto-enrolled ${speaker} from G2 mic (sim: ${embeddingResult.similarity.toFixed(3)})`)
672
+ }
673
+ }
674
+
675
+ if (speaker !== 'Ext' && embeddingResult.similarity > 0.50) {
676
+ const embCount = getEmbeddingCount(speaker)
677
+ if (embCount < 20) {
678
+ try {
679
+ const speakerDir = resolve(AUDIO_SAVE_DIR, speaker.replace(/\s+/g, '_'))
680
+ if (!existsSync(speakerDir)) mkdirSync(speakerDir, { recursive: true })
681
+ const existing = getTrainingCount(speakerDir)
682
+ if (existing < MAX_SAVED_CHUNKS_PER_SPEAKER) {
683
+ const filename = `${sessionId}_chunk${chunkIndex}_sim${embeddingResult.similarity.toFixed(2)}.wav`
684
+ const savePath = resolve(speakerDir, filename)
685
+ writeFile(savePath, audioBuffer).catch(err =>
686
+ console.warn(`[training-audio] Async save failed for ${speaker}: ${err.message}`)
687
+ )
688
+ trainingAudioCounts.set(speakerDir, existing + 1)
689
+ console.log(`[training-audio] Saved ${speaker} chunk (sim=${embeddingResult.similarity.toFixed(2)}, ${audioBuffer.length}b, total=${existing + 1})`)
690
+ }
691
+ } catch (audioSaveErr: unknown) {
692
+ console.warn(`[training-audio] Save failed for ${speaker}: ${errMsg(audioSaveErr)}`)
693
+ }
694
+ } else if (chunkIndex % 20 === 0) {
695
+ console.log(`[training-audio] ${speaker} at ${embCount} embeddings (>= 15), skipping save`)
696
+ }
697
+ }
698
+
699
+ if (speaker === 'Ext' && audioBuffer.length >= 16000) {
700
+ const extSessionDir = resolve(EXT_AUDIO_DIR, sessionId)
701
+ if (!existsSync(extSessionDir)) mkdirSync(extSessionDir, { recursive: true })
702
+ const extCount = extAudioCounts.get(sessionId) ?? 0
703
+ if (extCount < MAX_EXT_CHUNKS_PER_SESSION) {
704
+ const filename = `ext_chunk${chunkIndex}_${Date.now()}.wav`
705
+ writeFile(resolve(extSessionDir, filename), audioBuffer).catch(err =>
706
+ console.warn(`[ext-audio] Save failed: ${err.message}`)
707
+ )
708
+ extAudioCounts.set(sessionId, extCount + 1)
709
+ if (extCount === 0 || (extCount + 1) % 10 === 0) {
710
+ console.log(`[ext-audio] Saved Ext chunk for session ${sessionId} (total=${extCount + 1})`)
711
+ }
712
+ }
713
+ }
714
+
715
+ return { speaker, similarity: embeddingResult.similarity }
716
+ }
717
+
718
+ interface StreamCandidateInput {
719
+ provider: 'iphone-whisperkit-beta'
720
+ text: string
721
+ words?: WhisperWord[]
722
+ latencyMs?: number
723
+ model?: string
724
+ mode?: string
725
+ }
726
+
727
+ async function processStreamChunk(opts: {
728
+ sessionId: string
729
+ chunkIndex: number
730
+ clientSpeaker: string
731
+ audioBuffer: Buffer
732
+ candidate?: StreamCandidateInput
733
+ clientElapsed?: number
734
+ }): Promise<{ text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string }> {
735
+ const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
736
+ const tReq = performance.now()
737
+ validateSessionId(sessionId)
738
+ validateChunkIndex(chunkIndex)
739
+ if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
740
+ if (audioBuffer.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
741
+
742
+ const audioSha256 = sha256Hex(audioBuffer)
743
+ const session = getSession(sessionId)
744
+ const alreadyCanonical = session.chunks[chunkIndex]
745
+
746
+ let candidateRecordKey: string | undefined
747
+ if (candidate) {
748
+ const record: ProviderCandidateRecord = {
749
+ provider: candidate.provider,
750
+ chunkIndex,
751
+ elapsed: Number.isFinite(opts.clientElapsed) ? Number(opts.clientElapsed) : 0,
752
+ audioSha256,
753
+ text: candidate.text ?? '',
754
+ words: candidate.words,
755
+ latencyMs: candidate.latencyMs,
756
+ model: candidate.model,
757
+ mode: candidate.mode,
758
+ receivedAt: Date.now(),
759
+ }
760
+ candidateRecordKey = storeProviderCandidate(session, record)
761
+ }
762
+
763
+ // Do not let late duplicate/replayed candidates replace canonical raw audio.
764
+ // Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
765
+ if (alreadyCanonical?.canonical) {
766
+ if (candidate && candidateRecordKey) {
767
+ session.providerCandidates![candidateRecordKey].accepted =
768
+ alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
769
+ session.providerCandidates![candidateRecordKey].fallbackReason =
770
+ session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
771
+ persistSession(sessionId)
772
+ }
773
+ return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
774
+ }
775
+
776
+ await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
777
+
778
+ if (candidate) {
779
+ persistSession(sessionId)
780
+ }
781
+
782
+ const pcmData = audioBuffer.subarray(44)
783
+ let sumSq = 0
784
+ const nSamples = Math.floor(pcmData.length / 2)
785
+ for (let i = 0; i < nSamples; i++) {
786
+ const s = pcmData.readInt16LE(i * 2)
787
+ sumSq += s * s
788
+ }
789
+ const rms = Math.sqrt(sumSq / Math.max(1, nSamples))
790
+ const isQuiet = rms < 150
791
+ const whisperAudio = isQuiet ? audioBuffer : await enhanceAudio(audioBuffer)
792
+ const whisperContext = recentWhisperContext(sessionId, session)
793
+ console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
794
+
795
+ const speakerPromise = Promise.resolve(identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker))
796
+
797
+ let rawText = ''
798
+ let words: WhisperWord[] | undefined
799
+ let backend = 'candidate'
800
+ let asrProvider: 'server-whisper' | 'iphone-whisperkit-beta' = 'server-whisper'
801
+ let model: string | undefined
802
+ let mode: string | undefined
803
+ let latencyMs: number | undefined
804
+ let fallbackReason: string | undefined
805
+
806
+ if (candidate) {
807
+ rawText = candidate.text ?? ''
808
+ words = candidate.words
809
+ asrProvider = 'iphone-whisperkit-beta'
810
+ model = candidate.model
811
+ mode = candidate.mode
812
+ latencyMs = candidate.latencyMs
813
+ } else {
814
+ const t0 = performance.now()
815
+ const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
816
+ console.log(`[perf] whisper total: ${(performance.now() - t0).toFixed(1)}ms`)
817
+ rawText = result.text
818
+ words = result.words
819
+ backend = result.backend
820
+ }
821
+
822
+ let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
823
+ if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
824
+ fallbackReason = sanitized.fallbackReason || 'empty_candidate'
825
+ const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
826
+ rawText = result.text
827
+ words = result.words
828
+ backend = result.backend
829
+ asrProvider = 'server-whisper'
830
+ sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
831
+ }
832
+
833
+ const { speaker, similarity } = await speakerPromise
834
+ const elapsed = Date.now() - session.startTime
835
+ const trimmedText = sanitized.text
836
+
837
+ if (!trimmedText) {
838
+ console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
839
+ if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
840
+ session.providerCandidates[candidateRecordKey].accepted = false
841
+ session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
842
+ persistSession(sessionId)
843
+ }
844
+ return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
845
+ }
846
+
847
+ const chunk: TranscriptChunk = {
848
+ text: trimmedText,
849
+ speaker,
850
+ elapsed,
851
+ similarity,
852
+ words,
853
+ asrProvider,
854
+ backend,
855
+ model,
856
+ mode,
857
+ fallbackReason,
858
+ latencyMs,
859
+ audioSha256,
860
+ canonical: true,
861
+ }
862
+ const finalExisting = session.chunks[chunkIndex]
863
+ if (finalExisting?.canonical) {
864
+ if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
865
+ session.providerCandidates[candidateRecordKey].accepted =
866
+ finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
867
+ session.providerCandidates[candidateRecordKey].fallbackReason =
868
+ session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
869
+ persistSession(sessionId)
870
+ }
871
+ return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
872
+ }
873
+ session.chunks[chunkIndex] = chunk
874
+ if (candidate && candidateRecordKey) {
875
+ if (session.providerCandidates?.[candidateRecordKey]) {
876
+ session.providerCandidates[candidateRecordKey].accepted = asrProvider === 'iphone-whisperkit-beta'
877
+ session.providerCandidates[candidateRecordKey].fallbackReason = fallbackReason
878
+ }
879
+ }
880
+ const tPersist = performance.now()
881
+ persistSession(sessionId)
882
+ console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
883
+
884
+ emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
885
+
886
+ console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
887
+ return { text: trimmedText, speaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason }
888
+ }
889
+
890
+ function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
891
+ if (err instanceof OpenAIWhisperBudgetExhaustedError) {
892
+ console.error(`[transcribe-stream] ${err.message}`)
893
+ return res.status(503).json({
894
+ error: err.message,
895
+ reason: 'openai_whisper_budget_exhausted',
896
+ spent_today_usd: err.spentTodayUsd,
897
+ cap_usd: err.capUsd,
898
+ })
899
+ }
900
+ const status = typeof (err as any)?.status === 'number' ? (err as any).status : 500
901
+ return res.status(status).json({ error: errMsg(err), reason: (err as any)?.reason })
902
+ }
903
+
904
+ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
905
+ try {
906
+ const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
907
+ const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
908
+ const clientSpeaker = (req.query.speaker as string) || 'Unknown'
909
+ const audioBuffer = await readRawBody(req)
910
+ res.json(await processStreamChunk({ sessionId, chunkIndex, clientSpeaker, audioBuffer }))
911
+ } catch (err: unknown) {
912
+ sendStreamError(res, err)
913
+ }
914
+ })
915
+
916
+ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (req, res) => {
917
+ try {
918
+ if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
919
+ const body = req.body ?? {}
920
+ const sessionId = String(body.sessionId ?? '')
921
+ validateSessionId(sessionId)
922
+ if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
923
+ const session = getSession(sessionId)
924
+ const startTime = typeof body.startTime === 'number' && Number.isFinite(body.startTime)
925
+ ? Number(body.startTime)
926
+ : undefined
927
+ if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
928
+ if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
929
+ persistSession(sessionId)
930
+ res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
931
+ } catch (err: unknown) {
932
+ sendStreamError(res, err)
933
+ }
934
+ })
935
+
936
+ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/chunks', async (req, res) => {
937
+ try {
938
+ if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
939
+ const sessionId = String(req.params.sessionId ?? '')
940
+ validateSessionId(sessionId)
941
+ if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
942
+ const body = req.body ?? {}
943
+ const chunkIndex = Number(body.chunkIndex)
944
+ validateChunkIndex(chunkIndex)
945
+ if (chunkIndex > 0 && !sessions.has(sessionId)) {
946
+ throw makeHttpError(404, 'offline session not started', 'session_not_found')
947
+ }
948
+ if (body.provider !== 'iphone-whisperkit-beta') throw makeHttpError(400, 'invalid provider', 'invalid_provider')
949
+ const wavBase64 = String(body.wavBase64 ?? '')
950
+ if (!wavBase64 || wavBase64.length > MAX_CANDIDATE_WAV_BASE64_CHARS) throw makeHttpError(400, 'invalid audio payload', 'invalid_audio')
951
+ const audioBuffer = Buffer.from(wavBase64, 'base64')
952
+ const audioSha256 = sha256Hex(audioBuffer)
953
+ if (String(body.audioSha256 ?? '') !== audioSha256) throw makeHttpError(400, 'audio hash mismatch', 'audio_hash_mismatch')
954
+ const candidate = body.candidate ?? {}
955
+ const result = await processStreamChunk({
956
+ sessionId,
957
+ chunkIndex,
958
+ clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
959
+ audioBuffer,
960
+ clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
961
+ candidate: {
962
+ provider: 'iphone-whisperkit-beta',
963
+ text: normalizeCandidateText(candidate.text),
964
+ words: normalizeCandidateWords(candidate.words),
965
+ latencyMs: typeof candidate.latencyMs === 'number' ? candidate.latencyMs : undefined,
966
+ model: typeof candidate.model === 'string' ? candidate.model : undefined,
967
+ mode: typeof candidate.mode === 'string' ? candidate.mode : undefined,
968
+ },
969
+ })
970
+ res.json({ ...result, offlineReplay: true })
971
+ } catch (err: unknown) {
972
+ sendStreamError(res, err)
973
+ }
974
+ })
975
+
976
+ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/finalize', async (req, res) => {
977
+ try {
978
+ if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
979
+ const sessionId = String(req.params.sessionId ?? '')
980
+ validateSessionId(sessionId)
981
+ if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
982
+ const chunks = getSessionChunks(sessionId)
983
+ if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
984
+ await drainSessionAudioWrites(sessionId)
985
+ const transcript = getSessionTranscript(sessionId) ?? ''
986
+ res.json({
987
+ sessionId,
988
+ chunks: chunks.length,
989
+ transcriptChars: transcript.length,
990
+ readyToSave: true,
991
+ })
992
+ } catch (err: unknown) {
993
+ sendStreamError(res, err)
994
+ }
995
+ })
996
+
997
+ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) => {
998
+ try {
999
+ if (!isIosAsrCandidateEnabled()) throw makeHttpError(403, 'iPhone ASR candidates disabled', 'iphone_asr_disabled')
1000
+ const body = req.body ?? {}
1001
+ const sessionId = String(body.sessionId ?? '')
1002
+ const chunkIndex = Number(body.chunkIndex)
1003
+ validateSessionId(sessionId)
1004
+ validateChunkIndex(chunkIndex)
1005
+ if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
1006
+ if (chunkIndex > 0 && !sessions.has(sessionId)) {
1007
+ throw makeHttpError(404, 'session not found', 'session_not_found')
1008
+ }
1009
+ if (body.provider !== 'iphone-whisperkit-beta') throw makeHttpError(400, 'invalid provider', 'invalid_provider')
1010
+ const wavBase64 = String(body.wavBase64 ?? '')
1011
+ if (!wavBase64 || wavBase64.length > MAX_CANDIDATE_WAV_BASE64_CHARS) throw makeHttpError(400, 'invalid audio payload', 'invalid_audio')
1012
+ const audioBuffer = Buffer.from(wavBase64, 'base64')
1013
+ const audioSha256 = sha256Hex(audioBuffer)
1014
+ if (String(body.audioSha256 ?? '') !== audioSha256) throw makeHttpError(400, 'audio hash mismatch', 'audio_hash_mismatch')
1015
+ const candidate = body.candidate ?? {}
1016
+ const result = await processStreamChunk({
1017
+ sessionId,
1018
+ chunkIndex,
1019
+ clientSpeaker: String(body.clientSpeaker ?? 'Unknown'),
1020
+ audioBuffer,
1021
+ clientElapsed: typeof body.elapsed === 'number' ? body.elapsed : Number(body.elapsed ?? 0),
1022
+ candidate: {
1023
+ provider: 'iphone-whisperkit-beta',
1024
+ text: normalizeCandidateText(candidate.text),
1025
+ words: normalizeCandidateWords(candidate.words),
1026
+ latencyMs: typeof candidate.latencyMs === 'number' ? candidate.latencyMs : undefined,
1027
+ model: typeof candidate.model === 'string' ? candidate.model : undefined,
1028
+ mode: typeof candidate.mode === 'string' ? candidate.mode : undefined,
1029
+ },
1030
+ })
1031
+ res.json(result)
1032
+ } catch (err: unknown) {
1033
+ sendStreamError(res, err)
1034
+ }
1035
+ })
1036
+
1037
+ /** Fallback: transcribe via OpenAI Whisper API.
1038
+ * Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
1039
+ * A hung whisper-server + long meeting is the exact scenario this guards against —
1040
+ * chunks stay empty on budget-exceeded instead of silently billing per chunk. */
1041
+ async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
1042
+ assertOpenAIWhisperBudget()
1043
+
1044
+ const key = getOpenAIKey()
1045
+ const audioSeconds = estimateAudioSeconds(audioBuffer)
1046
+
1047
+ const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
1048
+ const filename = isWav ? 'recording.wav' : 'recording.webm'
1049
+ const mimeType = isWav ? 'audio/wav' : 'audio/webm'
1050
+
1051
+ const boundary = '----COS' + Date.now()
1052
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`
1053
+ const modelPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1`
1054
+ const languagePart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nen`
1055
+ const vocab = getVocabulary()
1056
+ const cloudPrompt = vocab.length > 0
1057
+ ? [getOwnerName(), ...vocab].join(', ')
1058
+ : `${getOwnerName()}, COS Glasses, Even G2`
1059
+ const promptPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="prompt"\r\n\r\n${cloudPrompt}`
1060
+ const footer = `\r\n--${boundary}--\r\n`
1061
+
1062
+ const body = Buffer.concat([
1063
+ Buffer.from(header),
1064
+ audioBuffer,
1065
+ Buffer.from(modelPart),
1066
+ Buffer.from(languagePart),
1067
+ Buffer.from(promptPart),
1068
+ Buffer.from(footer),
1069
+ ])
1070
+
1071
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
1072
+ method: 'POST',
1073
+ headers: {
1074
+ 'Authorization': `Bearer ${key}`,
1075
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1076
+ },
1077
+ body,
1078
+ })
1079
+
1080
+ if (!response.ok) {
1081
+ const errText = await response.text()
1082
+ // Don't bill a failed call — API returned non-2xx, no transcription produced.
1083
+ throw new Error(`Whisper API: ${errText.slice(0, 200)}`)
1084
+ }
1085
+
1086
+ const result = await response.json() as { text: string }
1087
+ // Success — count the audio we sent against today's budget.
1088
+ recordOpenAIWhisperUsage(audioSeconds)
1089
+ return result.text?.trim() || ''
1090
+ }