@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.
- package/.env.example +11 -0
- package/CHANGELOG.md +39 -0
- package/README.md +29 -7
- package/bin/cli.cjs +17 -4
- package/package.json +3 -3
- package/server/index.ts +3 -0
- package/server/lib/meeting-batch-transcribe.ts +1 -1
- package/server/lib/tts-cache.ts +28 -2
- package/server/lib/tts-engine.ts +230 -0
- package/server/lib/tts-local.ts +375 -0
- package/server/lib/tts-pronounce.ts +53 -0
- package/server/lib/whisper-local.ts +129 -28
- package/server/routes/health.ts +8 -2
- package/server/routes/tts.ts +574 -196
- package/server/tts-sidecar/bootstrap.sh +33 -0
- package/server/tts-sidecar/requirements.txt +9 -0
- package/server/tts-sidecar/server.py +235 -0
|
@@ -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
|
|
46
|
-
text
|
|
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
|
-
|
|
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(
|
|
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',
|
|
610
|
+
'-t', isolateBatchFromLiveMetal ? '8' : '16',
|
|
548
611
|
'-l', 'en',
|
|
549
|
-
'-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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(
|
|
600
|
-
|
|
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
|
-
*
|
|
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', '
|
|
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
|
|
654
|
-
|
|
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
|
/**
|
package/server/routes/health.ts
CHANGED
|
@@ -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,
|