@gotcos/glasses-server 6.14.0 → 6.15.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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { spawn, execFileSync } from 'node:child_process'
10
10
  import type { ChildProcess } from 'node:child_process'
11
- import { writeFileSync, unlinkSync, existsSync } from 'node:fs'
11
+ import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { homedir } from 'node:os'
14
14
  import crypto from 'node:crypto'
@@ -42,9 +42,8 @@ export interface WhisperSegment {
42
42
  words?: WhisperWord[]
43
43
  }
44
44
 
45
- interface WhisperVerboseResponse {
46
- text: string
47
- segments?: WhisperSegment[]
45
+ interface WhisperJsonResponse {
46
+ text?: unknown
48
47
  }
49
48
 
50
49
  // Resolve whisper.cpp binaries across Homebrew prefixes (Apple Silicon
@@ -144,6 +143,7 @@ let cliAvailable = false
144
143
  let serverAvailable = false
145
144
  let serverProcess: ReturnType<typeof spawn> | null = null
146
145
  const ownedServerChildren = new Set<ChildProcess>()
146
+ const ownedHqChildren = new Set<ReturnType<typeof spawn>>()
147
147
 
148
148
  // Circuit breaker: track consecutive server failures to detect hung process
149
149
  let serverConsecutiveFailures = 0
@@ -389,7 +389,9 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
389
389
  console.log('[whisper-local] Starting whisper-server...')
390
390
 
391
391
  const child = spawn(WHISPER_SERVER, serverArgs, {
392
- stdio: ['ignore', 'pipe', 'pipe'],
392
+ // whisper-server writes per-inference diagnostics. Unread pipes eventually
393
+ // fill and block the daemon, so the supervisor must not leave them buffered.
394
+ stdio: 'ignore',
393
395
  detached: false, // Dies with parent
394
396
  })
395
397
  serverProcess = child
@@ -455,6 +457,10 @@ export function stopWhisperServer(): void {
455
457
  serverAvailable = false
456
458
  console.log('[whisper-local] whisper-server stopped')
457
459
  }
460
+ for (const proc of ownedHqChildren) {
461
+ try { proc.kill('SIGKILL') } catch { /* already exited */ }
462
+ }
463
+ ownedHqChildren.clear()
458
464
  }
459
465
 
460
466
  export function isWhisperLocalAvailable(): boolean {
@@ -516,6 +522,54 @@ async function reconcileWhisperServerHealth(): Promise<boolean> {
516
522
  return serverHealthProbe
517
523
  }
518
524
 
525
+ /**
526
+ * Parse whisper-cli `-ojf` JSON into plain text + timed words.
527
+ *
528
+ * Batch/save path only. Never uses whisper-server `verbose_json` (the live
529
+ * VAD-empty crash vector). Special tokens like `[_BEG_]` / `<|...|>` are dropped.
530
+ */
531
+ export function parseWhisperCliFullJson(raw: string): { text: string; words: WhisperWord[] } {
532
+ const data = JSON.parse(raw) as {
533
+ transcription?: Array<{
534
+ text?: unknown
535
+ tokens?: Array<{
536
+ text?: unknown
537
+ p?: unknown
538
+ offsets?: { from?: unknown; to?: unknown }
539
+ }>
540
+ }>
541
+ }
542
+ const segments = Array.isArray(data.transcription) ? data.transcription : []
543
+ const texts: string[] = []
544
+ const words: WhisperWord[] = []
545
+
546
+ for (const segment of segments) {
547
+ if (typeof segment.text === 'string' && segment.text.trim()) {
548
+ texts.push(segment.text.trim())
549
+ }
550
+ for (const token of Array.isArray(segment.tokens) ? segment.tokens : []) {
551
+ const tokenText = typeof token.text === 'string' ? token.text.trim() : ''
552
+ if (!tokenText) continue
553
+ if (tokenText.startsWith('[') && tokenText.endsWith(']')) continue
554
+ if (tokenText.startsWith('<|') && tokenText.endsWith('|>')) continue
555
+ const fromMs = typeof token.offsets?.from === 'number' ? token.offsets.from : Number(token.offsets?.from)
556
+ const toMs = typeof token.offsets?.to === 'number' ? token.offsets.to : Number(token.offsets?.to)
557
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) continue
558
+ words.push({
559
+ word: tokenText,
560
+ start: fromMs / 1000,
561
+ end: toMs / 1000,
562
+ probability: typeof token.p === 'number' && Number.isFinite(token.p) ? token.p : 0,
563
+ })
564
+ }
565
+ }
566
+
567
+ return {
568
+ text: texts.join(' ').replace(/\s+/g, ' ').trim(),
569
+ words,
570
+ }
571
+ }
572
+
519
573
  /**
520
574
  * High-quality transcription for batch/post-meeting use.
521
575
  * Uses the full Whisper large-v3 (32-layer) decoder + Silero VAD + beam search.
@@ -523,7 +577,11 @@ async function reconcileWhisperServerHealth(): Promise<boolean> {
523
577
  * Falls back to turbo weights if large-v3 not on disk or COS_BATCH_LARGE_V3=0.
524
578
  * Falls back to transcribeLocal if whisper-cli unavailable entirely.
525
579
  */
526
- export async function transcribeHighQuality(audioBuffer: Buffer, context?: string): Promise<{ text: string; words?: WhisperWord[] }> {
580
+ export async function transcribeHighQuality(
581
+ audioBuffer: Buffer,
582
+ context?: string,
583
+ opts: { priority?: 'interactive' | 'batch' } = {},
584
+ ): Promise<{ text: string; words?: WhisperWord[] }> {
527
585
  if (!cliAvailable) {
528
586
  // Fall back to server (no beam search available via HTTP API)
529
587
  return transcribeLocal(audioBuffer, context)
@@ -532,6 +590,10 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
532
590
  const start = Date.now()
533
591
  const id = crypto.randomUUID().slice(0, 8)
534
592
  const tmpWav = join('/tmp', `cos-whisper-hq-${id}.wav`)
593
+ const outBase = join('/tmp', `cos-whisper-hq-${id}`)
594
+ const jsonPath = `${outBase}.json`
595
+ // Word clocks only on post-meeting CPU polish. Live stays compact JSON.
596
+ const captureBatchWords = opts.priority === 'batch'
535
597
 
536
598
  const modelPath = resolveBatchModel()
537
599
  const useLargeV3 = modelPath === BATCH_MODEL_LARGE_V3
@@ -541,18 +603,22 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
541
603
  writeFileSync(tmpWav, audioBuffer)
542
604
 
543
605
  const text = await new Promise<string>((resolve, reject) => {
606
+ const isolateBatchFromLiveMetal = opts.priority === 'batch'
544
607
  const args = [
545
608
  '-m', modelPath,
546
609
  '-f', tmpWav,
547
- '-t', '16', // Use more threads for batch (no real-time pressure)
610
+ '-t', isolateBatchFromLiveMetal ? '8' : '16',
548
611
  '-l', 'en',
549
- '-fa', // Flash attention — Metal win, same flag streaming uses
612
+ ...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
550
613
  '-bs', '5', // Beam search width 5 (default disabled)
551
614
  '-bo', '5', // Best-of-5 candidates (default 2)
552
615
  '--no-timestamps',
553
616
  '-np',
554
617
  '--prompt', buildPrompt(context),
555
618
  ]
619
+ if (captureBatchWords) {
620
+ args.push('-ojf', '-of', outBase)
621
+ }
556
622
  if (useVad) {
557
623
  // Same VAD model the streaming path uses. Strips silence windows
558
624
  // before the decoder sees them — prevents the silence-hallucination
@@ -562,6 +628,7 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
562
628
  const proc = spawn(WHISPER_CLI, args, {
563
629
  stdio: ['ignore', 'pipe', 'pipe'],
564
630
  })
631
+ ownedHqChildren.add(proc)
565
632
 
566
633
  let stdout = ''
567
634
  let stderr = ''
@@ -573,13 +640,24 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
573
640
  // multiplier × beam-search overhead = ~240s safety ceiling for HQ.
574
641
  // Turbo retains the old 60s ceiling.
575
642
  const timeoutMs = useLargeV3 ? 240_000 : 60_000
643
+ let timedOut = false
644
+ let forceKill: ReturnType<typeof setTimeout> | null = null
576
645
  const timeout = setTimeout(() => {
577
- proc.kill('SIGTERM')
578
- reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
646
+ timedOut = true
647
+ try { proc.kill('SIGTERM') } catch { /* already exited */ }
648
+ forceKill = setTimeout(() => {
649
+ try { proc.kill('SIGKILL') } catch { /* already exited */ }
650
+ }, 2_000)
579
651
  }, timeoutMs)
580
652
 
581
653
  proc.on('close', (code) => {
654
+ ownedHqChildren.delete(proc)
582
655
  clearTimeout(timeout)
656
+ if (forceKill) clearTimeout(forceKill)
657
+ if (timedOut) {
658
+ reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
659
+ return
660
+ }
583
661
  if (code !== 0) {
584
662
  reject(new Error(`whisper-cli HQ exit ${code}: ${stderr.trim().slice(0, 200)}`))
585
663
  return
@@ -588,18 +666,47 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
588
666
  })
589
667
 
590
668
  proc.on('error', (err) => {
669
+ ownedHqChildren.delete(proc)
591
670
  clearTimeout(timeout)
671
+ if (forceKill) clearTimeout(forceKill)
592
672
  reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
593
673
  })
594
674
  })
595
675
 
596
- const corrected = applyCorrections(text)
676
+ let finalText = text
677
+ let words: WhisperWord[] | undefined
678
+ if (captureBatchWords) {
679
+ try {
680
+ if (existsSync(jsonPath)) {
681
+ const parsed = parseWhisperCliFullJson(readFileSync(jsonPath, 'utf8'))
682
+ if (parsed.text) finalText = parsed.text
683
+ words = parsed.words.length > 0
684
+ ? parsed.words.map(w => ({ ...w, word: applyCorrections(w.word) }))
685
+ : []
686
+ }
687
+ } catch (err) {
688
+ console.warn(
689
+ `[whisper-hq] Batch word JSON parse failed; keeping text-only polish: ` +
690
+ `${err instanceof Error ? err.message : String(err)}`,
691
+ )
692
+ }
693
+ }
694
+
695
+ const corrected = applyCorrections(finalText)
597
696
  const elapsed = Date.now() - start
598
697
  const modelTag = useLargeV3 ? 'large-v3' : 'turbo'
599
- console.log(`[whisper-hq] Batch transcribed in ${elapsed}ms (${modelTag}${useVad ? '+vad' : ''}): "${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`)
600
- return { text: corrected }
698
+ console.log(
699
+ `[whisper-hq] Batch transcribed in ${elapsed}ms ` +
700
+ `(${modelTag}${useVad ? '+vad' : ''}${captureBatchWords ? '+words' : ''}` +
701
+ `${words ? `, ${words.length} words` : ''}): ` +
702
+ `"${corrected.slice(0, 80)}${corrected.length > 80 ? '...' : ''}"`,
703
+ )
704
+ return words ? { text: corrected, words } : { text: corrected }
601
705
  } finally {
602
706
  try { unlinkSync(tmpWav) } catch { /* cleanup */ }
707
+ if (captureBatchWords) {
708
+ try { unlinkSync(jsonPath) } catch { /* cleanup */ }
709
+ }
603
710
  }
604
711
  }
605
712
 
@@ -626,14 +733,17 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
626
733
 
627
734
  /**
628
735
  * Transcribe via whisper-server (persistent daemon, ~50-100ms).
629
- * Returns text + optional word-level timestamps from DTW alignment.
736
+ *
737
+ * Compact JSON intentionally avoids whisper.cpp's verbose_json language field,
738
+ * which can receive a null C string after VAD returns no speech and crash the
739
+ * native server before an HTTP response exists.
630
740
  */
631
741
  async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; words?: WhisperWord[] }> {
632
742
  const formData = new FormData()
633
743
  // Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
634
744
  const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' })
635
745
  formData.append('file', blob, 'recording.wav')
636
- formData.append('response_format', 'verbose_json') // includes segments[].words[] with DTW
746
+ formData.append('response_format', 'json')
637
747
  formData.append('prompt', buildPrompt(context, isQuiet))
638
748
  // Anti-hallucination handled by client-side filter + context filtering.
639
749
  // Whisper-level entropy/logprob thresholds were too aggressive — silently dropped
@@ -650,20 +760,11 @@ async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuie
650
760
  throw new Error(`whisper-server ${response.status}: ${await response.text()}`)
651
761
  }
652
762
 
653
- const result = await response.json() as WhisperVerboseResponse
654
- const text = result.text?.trim() || ''
655
-
656
- // Extract word-level timestamps from DTW-aligned segments (defensive — may be absent)
657
- let words: WhisperWord[] | undefined
658
- if (result.segments && result.segments.length > 0) {
659
- const extracted = result.segments.flatMap(s => {
660
- if (!s.words || !Array.isArray(s.words)) return []
661
- return s.words.filter(w => typeof w.start === 'number' && typeof w.end === 'number')
662
- })
663
- if (extracted.length > 0) words = extracted
763
+ const result = await response.json() as WhisperJsonResponse
764
+ if (typeof result.text !== 'string') {
765
+ throw new Error('whisper-server returned invalid compact JSON: missing string text')
664
766
  }
665
-
666
- return { text, words }
767
+ return { text: result.text.trim() }
667
768
  }
668
769
 
669
770
  /**
@@ -24,6 +24,7 @@ import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
24
24
  import { managedRuntimeCapability, managedServerVersion } from '../lib/managed-runtime.js'
25
25
  import { getServerGenerationId } from '../lib/managed-runtime.js'
26
26
  import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
27
+ import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
27
28
 
28
29
  export const healthRouter = Router()
29
30
 
@@ -57,6 +58,7 @@ function durableQueryJobStatus() {
57
58
  }
58
59
 
59
60
  healthRouter.get('/health', async (_req, res) => {
61
+ await refreshLocalTtsHealth()
60
62
  const checks: Record<string, string | number | boolean> = {
61
63
  status: 'ok',
62
64
  mode: COS_MODE ? 'cos' : 'standalone',
@@ -150,10 +152,11 @@ healthRouter.get('/health', async (_req, res) => {
150
152
  const transcription = getTranscriptionPolicySnapshot()
151
153
  const recovery = managedRuntimeCapability()
152
154
  const maintenance = maintenanceLifecycle.snapshot()
155
+ const tts_local = getLocalTtsHealth()
153
156
  const features = {
154
157
  claude: claudeAvailable,
155
158
  codex: codexAvailable,
156
- voice: keyStatus.hasKey,
159
+ voice: keyStatus.hasKey || tts_local.ready,
157
160
  cos_pipeline: COS_MODE,
158
161
  whisper: isWhisperLocalAvailable(),
159
162
  promptRecovery: true,
@@ -167,15 +170,17 @@ healthRouter.get('/health', async (_req, res) => {
167
170
  transcriptionPolicy: transcription.mode,
168
171
  }
169
172
  const voice = {
173
+ available: keyStatus.hasKey || tts_local.ready,
170
174
  hasKey: keyStatus.hasKey,
171
175
  keySource: keyStatus.source,
176
+ localReady: tts_local.ready,
177
+ engine: tts_local.engine,
172
178
  }
173
179
 
174
180
  // Whisper-server health + cloud budget — exposed so glasses + dashboards can
175
181
  // see whether we're at risk of falling to cloud and how much budget remains.
176
182
  const whisper_health = getWhisperHealth()
177
183
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
178
-
179
184
  const codex_models = getCodexModelCatalogSnapshot()
180
185
  res.json({
181
186
  ...checks,
@@ -187,6 +192,7 @@ healthRouter.get('/health', async (_req, res) => {
187
192
  voice,
188
193
  whisper_health,
189
194
  openai_whisper_budget,
195
+ tts_local,
190
196
  codex_models,
191
197
  capabilities: {
192
198
  transcription,