@gotcos/glasses-server 6.14.0 → 6.14.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 6.14.1
2
+
3
+ - Keep real-time `large-v3-turbo` stable on VAD-empty audio by using
4
+ whisper-server compact JSON instead of the nullable `verbose_json` language
5
+ path that can crash native whisper.cpp.
6
+ - Prevent long sessions from stalling on unread native output by discarding
7
+ whisper-server stdout and stderr under the existing owner-safe supervisor.
8
+ - Isolate meeting-save full `large-v3` polish from live Metal inference by
9
+ running batch HQ on CPU with eight threads. Interactive HQ retains GPU speed.
10
+ - Reap timed-out HQ children before the queue advances, with SIGKILL escalation
11
+ if SIGTERM does not exit within two seconds.
12
+
1
13
  ## 6.14.0
2
14
 
3
15
  - Add voice (TTS + speaker) and additive glasses routes to the public server: `tts`, `voice`, `glossary`, `handoffs`, `recovery`, `prompt-edit`, `bookmarks`. Brings server-side voice + companion utilities to public installs; COS-integration routes remain private.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.14.0",
4
- "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
3
+ "version": "6.14.1",
4
+ "description": "COS Glasses self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glasses-server": "bin/cli.cjs",
@@ -172,7 +172,7 @@ async function transcribeSegments(
172
172
  const combined = concatenateWavChunks(audioDir, segment.startChunkIdx, segment.endChunkIdx)
173
173
  const enhanced = await enhanceAudio(combined)
174
174
  const previousText = results.at(-1)?.text
175
- const result = await transcribeHighQuality(enhanced, previousText?.slice(-250))
175
+ const result = await transcribeHighQuality(enhanced, previousText?.slice(-250), { priority: 'batch' })
176
176
  const text = previousText ? stripOverlap(result.text, previousText) : result.text
177
177
  const words = result.words ?? []
178
178
  results.push({
@@ -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 {
@@ -523,7 +529,11 @@ async function reconcileWhisperServerHealth(): Promise<boolean> {
523
529
  * Falls back to turbo weights if large-v3 not on disk or COS_BATCH_LARGE_V3=0.
524
530
  * Falls back to transcribeLocal if whisper-cli unavailable entirely.
525
531
  */
526
- export async function transcribeHighQuality(audioBuffer: Buffer, context?: string): Promise<{ text: string; words?: WhisperWord[] }> {
532
+ export async function transcribeHighQuality(
533
+ audioBuffer: Buffer,
534
+ context?: string,
535
+ opts: { priority?: 'interactive' | 'batch' } = {},
536
+ ): Promise<{ text: string; words?: WhisperWord[] }> {
527
537
  if (!cliAvailable) {
528
538
  // Fall back to server (no beam search available via HTTP API)
529
539
  return transcribeLocal(audioBuffer, context)
@@ -541,12 +551,13 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
541
551
  writeFileSync(tmpWav, audioBuffer)
542
552
 
543
553
  const text = await new Promise<string>((resolve, reject) => {
554
+ const isolateBatchFromLiveMetal = opts.priority === 'batch'
544
555
  const args = [
545
556
  '-m', modelPath,
546
557
  '-f', tmpWav,
547
- '-t', '16', // Use more threads for batch (no real-time pressure)
558
+ '-t', isolateBatchFromLiveMetal ? '8' : '16',
548
559
  '-l', 'en',
549
- '-fa', // Flash attention — Metal win, same flag streaming uses
560
+ ...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
550
561
  '-bs', '5', // Beam search width 5 (default disabled)
551
562
  '-bo', '5', // Best-of-5 candidates (default 2)
552
563
  '--no-timestamps',
@@ -562,6 +573,7 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
562
573
  const proc = spawn(WHISPER_CLI, args, {
563
574
  stdio: ['ignore', 'pipe', 'pipe'],
564
575
  })
576
+ ownedHqChildren.add(proc)
565
577
 
566
578
  let stdout = ''
567
579
  let stderr = ''
@@ -573,13 +585,24 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
573
585
  // multiplier × beam-search overhead = ~240s safety ceiling for HQ.
574
586
  // Turbo retains the old 60s ceiling.
575
587
  const timeoutMs = useLargeV3 ? 240_000 : 60_000
588
+ let timedOut = false
589
+ let forceKill: ReturnType<typeof setTimeout> | null = null
576
590
  const timeout = setTimeout(() => {
577
- proc.kill('SIGTERM')
578
- reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
591
+ timedOut = true
592
+ try { proc.kill('SIGTERM') } catch { /* already exited */ }
593
+ forceKill = setTimeout(() => {
594
+ try { proc.kill('SIGKILL') } catch { /* already exited */ }
595
+ }, 2_000)
579
596
  }, timeoutMs)
580
597
 
581
598
  proc.on('close', (code) => {
599
+ ownedHqChildren.delete(proc)
582
600
  clearTimeout(timeout)
601
+ if (forceKill) clearTimeout(forceKill)
602
+ if (timedOut) {
603
+ reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
604
+ return
605
+ }
583
606
  if (code !== 0) {
584
607
  reject(new Error(`whisper-cli HQ exit ${code}: ${stderr.trim().slice(0, 200)}`))
585
608
  return
@@ -588,7 +611,9 @@ export async function transcribeHighQuality(audioBuffer: Buffer, context?: strin
588
611
  })
589
612
 
590
613
  proc.on('error', (err) => {
614
+ ownedHqChildren.delete(proc)
591
615
  clearTimeout(timeout)
616
+ if (forceKill) clearTimeout(forceKill)
592
617
  reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
593
618
  })
594
619
  })
@@ -626,14 +651,17 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
626
651
 
627
652
  /**
628
653
  * Transcribe via whisper-server (persistent daemon, ~50-100ms).
629
- * Returns text + optional word-level timestamps from DTW alignment.
654
+ *
655
+ * Compact JSON intentionally avoids whisper.cpp's verbose_json language field,
656
+ * which can receive a null C string after VAD returns no speech and crash the
657
+ * native server before an HTTP response exists.
630
658
  */
631
659
  async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuiet?: boolean): Promise<{ text: string; words?: WhisperWord[] }> {
632
660
  const formData = new FormData()
633
661
  // Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
634
662
  const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' })
635
663
  formData.append('file', blob, 'recording.wav')
636
- formData.append('response_format', 'verbose_json') // includes segments[].words[] with DTW
664
+ formData.append('response_format', 'json')
637
665
  formData.append('prompt', buildPrompt(context, isQuiet))
638
666
  // Anti-hallucination handled by client-side filter + context filtering.
639
667
  // Whisper-level entropy/logprob thresholds were too aggressive — silently dropped
@@ -650,20 +678,11 @@ async function transcribeViaServer(audioBuffer: Buffer, context?: string, isQuie
650
678
  throw new Error(`whisper-server ${response.status}: ${await response.text()}`)
651
679
  }
652
680
 
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
681
+ const result = await response.json() as WhisperJsonResponse
682
+ if (typeof result.text !== 'string') {
683
+ throw new Error('whisper-server returned invalid compact JSON: missing string text')
664
684
  }
665
-
666
- return { text, words }
685
+ return { text: result.text.trim() }
667
686
  }
668
687
 
669
688
  /**