@gotcos/glasses-server 6.20.1 → 6.21.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/.env.example +7 -0
- package/CHANGELOG.md +33 -0
- package/README.md +19 -5
- package/bin/cli.cjs +41 -12
- package/package.json +1 -1
- package/server/index.ts +1 -1
- package/server/lib/provider-proof.ts +44 -15
- package/server/lib/whisper-local.ts +120 -17
- package/server/lib/whisper-preview.ts +177 -36
package/.env.example
CHANGED
|
@@ -83,6 +83,13 @@ BIND_HOST=0.0.0.0
|
|
|
83
83
|
# COS_HQ_SPECULATIVE_WARM=0 # disable background HQ warm
|
|
84
84
|
# COS_BATCH_LARGE_V3=0 # explicitly use turbo instead of full HQ
|
|
85
85
|
# COS_HQ_BEAM_INTERACTIVE=2 # interactive only; meetings stay at beam 5
|
|
86
|
+
# Control owns the machine-wide tier. Balanced is recommended: Small.en is a
|
|
87
|
+
# cosmetic prompt preview only, while Turbo commits authoritative live text and
|
|
88
|
+
# Large-v3 polishes saved work. Max reuses the resident Large-v3 worker for
|
|
89
|
+
# preview + commit; it is opt-in for powerful Macs and never starts a third
|
|
90
|
+
# Whisper worker.
|
|
91
|
+
# COS_WHISPER_TRANSCRIPTION_TIER=balanced # balanced | max
|
|
92
|
+
# COS_WHISPER_COMMIT_MODEL=turbo # turbo | large-v3 (advanced override)
|
|
86
93
|
# Adaptive prompt transcription keeps three quality lanes separate:
|
|
87
94
|
# Small.en provisional preview -> Large-v3-Turbo committed live text -> Large-v3 HQ polish.
|
|
88
95
|
# Unset preserves the pre-6.20 Turbo behavior. `auto` uses Small.en when it has
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
## 6.21.1
|
|
2
|
+
|
|
3
|
+
- Make the transactional Claude readiness proof explicitly use Haiku instead
|
|
4
|
+
of inheriting a user's heavyweight default model. The no-tool proof now has
|
|
5
|
+
a 45-second bound, while normal COS queries keep their selected models.
|
|
6
|
+
- Preserve timeout and cancellation reasons across the provider-process close
|
|
7
|
+
race. A timed-out proof now reports `provider proof timed out` instead of the
|
|
8
|
+
misleading `provider process exited before launch`.
|
|
9
|
+
|
|
10
|
+
## 6.21.0
|
|
11
|
+
|
|
12
|
+
Transcription quality is now a machine-owned, observable two-tier policy.
|
|
13
|
+
|
|
14
|
+
- **Balanced and Max tiers.** Balanced keeps Small.en as a cosmetic prompt
|
|
15
|
+
preview, Large-v3-Turbo as the authoritative live commit model, and Large-v3
|
|
16
|
+
for saved-work polish. Max reuses the resident Large-v3 worker for preview
|
|
17
|
+
and commit; it never starts a third Whisper process.
|
|
18
|
+
- **Safe Large-v3 fallback.** A requested Max tier degrades visibly to Turbo
|
|
19
|
+
when the Large-v3 weights are unavailable. Immutable Turbo and Large-v3
|
|
20
|
+
paths remain independently addressable for fallback and HQ work; health also
|
|
21
|
+
warns when Max is active but its immutable Turbo recovery weights are absent.
|
|
22
|
+
- **Preview isolation.** Small.en previews no longer receive decoder-bias
|
|
23
|
+
vocabulary and still cannot write recovery state or replace committed text.
|
|
24
|
+
Startup reaps only an exact stale Small.en/8177 worker before spawning its
|
|
25
|
+
owned child; an unrelated listener is never contacted with audio or killed.
|
|
26
|
+
- **Truthful health.** The existing
|
|
27
|
+
`capabilities.transcription.live` block now reports requested/effective tier,
|
|
28
|
+
requested/effective commit model, downgrade reason, and preview prompt policy
|
|
29
|
+
without exposing local file paths.
|
|
30
|
+
- **Guided provisioning.** `--setup-transcription --transcription-tier
|
|
31
|
+
balanced|max` provisions only the models required by the selected tier plus
|
|
32
|
+
the immutable Turbo fallback and Large-v3 HQ model.
|
|
33
|
+
|
|
1
34
|
## 6.20.1
|
|
2
35
|
|
|
3
36
|
Adaptive setup now survives the slow or interrupted downloads that exposed the
|
package/README.md
CHANGED
|
@@ -236,20 +236,34 @@ Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
|
|
|
236
236
|
OFF** requests HQ, and **Fast mode ON** requests turbo. The Mac performs all
|
|
237
237
|
decoding; the phone does not run Whisper.
|
|
238
238
|
|
|
239
|
-
For the recommended
|
|
239
|
+
For the recommended **Balanced** setup, run:
|
|
240
240
|
|
|
241
241
|
```bash
|
|
242
|
-
npx --yes @gotcos/glasses-server@latest --setup-transcription
|
|
242
|
+
npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
-
That keeps three jobs separate: Small.en supplies provisional words on
|
|
246
|
-
lens, Large-v3-Turbo commits the authoritative live transcript, and
|
|
247
|
-
polishes saved prompts and meetings. Small.en never writes the
|
|
245
|
+
That keeps three jobs separate: Small.en supplies provisional prompt words on
|
|
246
|
+
the lens, Large-v3-Turbo commits the authoritative live transcript, and
|
|
247
|
+
Large-v3 polishes saved prompts and meetings. Small.en never writes the
|
|
248
|
+
recovery ledger and receives no decoder-bias prompt.
|
|
248
249
|
If its sidecar is missing or unhealthy, preview falls back to Turbo without
|
|
249
250
|
changing final quality. Set `COS_WHISPER_PREVIEW_MODEL=turbo` to keep one live
|
|
250
251
|
model, or `off` to disable provisional peeks. Existing installs that only
|
|
251
252
|
update the server remain on Turbo until Guided Setup opts them into Small.en.
|
|
252
253
|
|
|
254
|
+
**Max** is an opt-in tier for powerful Macs:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier max
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Max reuses the existing Large-v3 worker for preview and authoritative commit;
|
|
261
|
+
saved work still receives Large-v3 HQ polish. It does not start a third model
|
|
262
|
+
process. If Large-v3 is missing, health reports the downgrade and the server
|
|
263
|
+
falls back to Turbo rather than making transcription unavailable. COS Control
|
|
264
|
+
is the supported owner of the machine-wide tier; the per-lane environment
|
|
265
|
+
variables remain advanced overrides.
|
|
266
|
+
|
|
253
267
|
The first server start downloads the real-time turbo model. True HQ additionally
|
|
254
268
|
requires the full `ggml-large-v3.bin` model (about 3.1 GB):
|
|
255
269
|
|
package/bin/cli.cjs
CHANGED
|
@@ -26,6 +26,20 @@ const PKG_ROOT = resolve(__dirname, '..')
|
|
|
26
26
|
const CONFIG_DIR = join(homedir(), '.cos-glasses')
|
|
27
27
|
const PREPARE_ONLY = process.argv.includes('--prepare-only')
|
|
28
28
|
const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
|
|
29
|
+
function optionValue(name) {
|
|
30
|
+
const index = process.argv.indexOf(name)
|
|
31
|
+
return index >= 0 ? process.argv[index + 1] : undefined
|
|
32
|
+
}
|
|
33
|
+
const TRANSCRIPTION_TIER_RAW = optionValue('--transcription-tier')
|
|
34
|
+
const TRANSCRIPTION_TIER = TRANSCRIPTION_TIER_RAW?.trim().toLowerCase() || 'balanced'
|
|
35
|
+
if (process.argv.includes('--transcription-tier') && !TRANSCRIPTION_TIER_RAW) {
|
|
36
|
+
console.error('Missing value for --transcription-tier. Use balanced or max.')
|
|
37
|
+
process.exit(64)
|
|
38
|
+
}
|
|
39
|
+
if (TRANSCRIPTION_TIER_RAW && !['balanced', 'max'].includes(TRANSCRIPTION_TIER)) {
|
|
40
|
+
console.error(`Invalid --transcription-tier "${TRANSCRIPTION_TIER_RAW}". Use balanced or max.`)
|
|
41
|
+
process.exit(64)
|
|
42
|
+
}
|
|
29
43
|
|
|
30
44
|
// Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
|
|
31
45
|
// with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
|
|
@@ -46,6 +60,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
46
60
|
console.log(' Usage:')
|
|
47
61
|
console.log(' npx --yes @gotcos/glasses-server@latest')
|
|
48
62
|
console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription')
|
|
63
|
+
console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced|max')
|
|
49
64
|
console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
|
|
50
65
|
console.log('')
|
|
51
66
|
console.log(' Requirements:')
|
|
@@ -355,9 +370,18 @@ function upsertEnvValue(file, key, value) {
|
|
|
355
370
|
}
|
|
356
371
|
|
|
357
372
|
if (SETUP_TRANSCRIPTION) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
373
|
+
const previewModel = TRANSCRIPTION_TIER === 'max' ? 'turbo' : 'small.en'
|
|
374
|
+
const commitModel = TRANSCRIPTION_TIER === 'max' ? 'large-v3' : 'turbo'
|
|
375
|
+
upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
|
|
376
|
+
upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
|
|
377
|
+
upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
|
|
378
|
+
process.env.COS_WHISPER_TRANSCRIPTION_TIER = TRANSCRIPTION_TIER
|
|
379
|
+
process.env.COS_WHISPER_PREVIEW_MODEL = previewModel
|
|
380
|
+
process.env.COS_WHISPER_COMMIT_MODEL = commitModel
|
|
381
|
+
const laneSummary = TRANSCRIPTION_TIER === 'max'
|
|
382
|
+
? 'Large-v3 preview + commit · Large-v3 HQ'
|
|
383
|
+
: 'Small.en preview · Turbo commit · Large-v3 HQ'
|
|
384
|
+
console.log(green(' ✓') + ` ${TRANSCRIPTION_TIER === 'max' ? 'Max' : 'Balanced'} transcription selected ` + dim(`— ${laneSummary}`))
|
|
361
385
|
}
|
|
362
386
|
// Persistent profile (identity + transcription vocabulary)
|
|
363
387
|
const PROFILE_FILE = join(CONFIG_DIR, '.cos-profile.json')
|
|
@@ -446,7 +470,7 @@ const transcriptionSetupFailures = []
|
|
|
446
470
|
|
|
447
471
|
if (SETUP_TRANSCRIPTION && (!whisperCliPath || !whisperServerPath)) {
|
|
448
472
|
console.log('')
|
|
449
|
-
console.log(red(' ✗
|
|
473
|
+
console.log(red(' ✗ Transcription setup needs whisper.cpp'))
|
|
450
474
|
console.log(' Install it first: ' + bold('brew install whisper-cpp'))
|
|
451
475
|
console.log(' Then rerun this setup command. No model download was started.')
|
|
452
476
|
console.log('')
|
|
@@ -457,7 +481,9 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
|
|
|
457
481
|
mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
|
|
458
482
|
const targets = [
|
|
459
483
|
[WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
|
|
460
|
-
|
|
484
|
+
...(TRANSCRIPTION_TIER === 'balanced'
|
|
485
|
+
? [[WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES]]
|
|
486
|
+
: []),
|
|
461
487
|
[WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
|
|
462
488
|
]
|
|
463
489
|
const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
|
|
@@ -472,7 +498,7 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
|
|
|
472
498
|
const requiredGB = ((missingBytes + safetyMarginBytes) / 1_000_000_000).toFixed(1)
|
|
473
499
|
const availableGB = (availableBytes / 1_000_000_000).toFixed(1)
|
|
474
500
|
console.log('')
|
|
475
|
-
console.log(red(' ✗ Not enough free disk space for
|
|
501
|
+
console.log(red(' ✗ Not enough free disk space for transcription setup'))
|
|
476
502
|
console.log(` Need about ${requiredGB} GB; ${availableGB} GB is available after reusable partial downloads.`)
|
|
477
503
|
console.log(' Free space, then rerun this setup command. Existing models were not removed.')
|
|
478
504
|
console.log('')
|
|
@@ -550,10 +576,13 @@ if (whisperCliPath && wantsSmallPreview) {
|
|
|
550
576
|
|
|
551
577
|
if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
552
578
|
if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
|
|
553
|
-
|
|
579
|
+
const largeRole = TRANSCRIPTION_TIER === 'max'
|
|
580
|
+
? 'live preview + commit and saved-meeting polish'
|
|
581
|
+
: 'saved meetings use full polish'
|
|
582
|
+
console.log(green(' ✓') + ' Large-v3 model ready ' + dim(`— ${largeRole}`))
|
|
554
583
|
} else {
|
|
555
584
|
if (existsSync(WHISPER_LARGE_MODEL_PATH)) { try { unlinkSync(WHISPER_LARGE_MODEL_PATH) } catch {} }
|
|
556
|
-
console.log(yellow(' ⚠') + '
|
|
585
|
+
console.log(yellow(' ⚠') + ' Large-v3 model missing')
|
|
557
586
|
console.log(' ' + dim('Downloading ggml-large-v3 (~3.1 GB).'))
|
|
558
587
|
if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
|
|
559
588
|
console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — HQ safely reports unavailable and live Turbo continues')
|
|
@@ -566,12 +595,12 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
|
566
595
|
expectedBytes: WHISPER_LARGE_MODEL_EXPECTED_BYTES,
|
|
567
596
|
timeoutMs: 7_200_000,
|
|
568
597
|
})
|
|
569
|
-
console.log(green(' ✓') + ' Large-v3
|
|
598
|
+
console.log(green(' ✓') + ' Large-v3 model downloaded')
|
|
570
599
|
} catch (err) {
|
|
571
600
|
console.log(red(' ✗') + ' Large-v3 download failed ' + dim('— live Turbo remains available'))
|
|
572
601
|
console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
|
|
573
602
|
console.log(' ' + dim('Partial download retained; rerun setup to resume it.'))
|
|
574
|
-
transcriptionSetupFailures.push('Large-v3 HQ model')
|
|
603
|
+
transcriptionSetupFailures.push(TRANSCRIPTION_TIER === 'max' ? 'Large-v3 Max/HQ model' : 'Large-v3 HQ model')
|
|
575
604
|
}
|
|
576
605
|
}
|
|
577
606
|
}
|
|
@@ -580,13 +609,13 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
|
580
609
|
if (PREPARE_ONLY && SETUP_TRANSCRIPTION) {
|
|
581
610
|
console.log('')
|
|
582
611
|
if (transcriptionSetupFailures.length > 0) {
|
|
583
|
-
console.log(red(' ✗
|
|
612
|
+
console.log(red(' ✗ Transcription setup incomplete'))
|
|
584
613
|
console.log(' Missing: ' + transcriptionSetupFailures.join(', '))
|
|
585
614
|
console.log(' Rerun this command; retained downloads resume from the last byte received.')
|
|
586
615
|
console.log('')
|
|
587
616
|
process.exit(1)
|
|
588
617
|
}
|
|
589
|
-
console.log(green(' ✓
|
|
618
|
+
console.log(green(' ✓ Transcription setup complete'))
|
|
590
619
|
console.log(' Return to COS Control and Restart, install, or update the managed server.')
|
|
591
620
|
console.log('')
|
|
592
621
|
process.exit(0)
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -298,7 +298,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
298
298
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
299
299
|
stopCodexModelCatalogRefresh()
|
|
300
300
|
stopWhisperServer()
|
|
301
|
-
stopWhisperPreviewServer()
|
|
301
|
+
await stopWhisperPreviewServer()
|
|
302
302
|
stopLocalTtsServer()
|
|
303
303
|
clearTimeout(forceExit)
|
|
304
304
|
process.exit(0)
|
|
@@ -17,7 +17,7 @@ const PROOF_PROMPT = `This is an automated local readiness check. Do not use too
|
|
|
17
17
|
const successCache = new Map<ProofProvider, ProviderProofResult>()
|
|
18
18
|
const inFlight = new Map<ProofProvider, Promise<ProviderProofResult>>()
|
|
19
19
|
|
|
20
|
-
interface ProcessResult {
|
|
20
|
+
export interface ProcessResult {
|
|
21
21
|
code: number | null
|
|
22
22
|
stdout: string
|
|
23
23
|
stderr: string
|
|
@@ -25,7 +25,9 @@ interface ProcessResult {
|
|
|
25
25
|
aborted: boolean
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
type TerminationReason = 'timeout' | 'abort' | null
|
|
29
|
+
|
|
30
|
+
export function runBounded(
|
|
29
31
|
command: string,
|
|
30
32
|
args: string[],
|
|
31
33
|
input: string,
|
|
@@ -48,6 +50,11 @@ function runBounded(
|
|
|
48
50
|
let stdout = ''
|
|
49
51
|
let stderr = ''
|
|
50
52
|
let settled = false
|
|
53
|
+
// The child `close` event normally wins the race against the async
|
|
54
|
+
// terminateProviderProcess() result. Record WHY termination began before
|
|
55
|
+
// sending a signal so that close cannot misreport a timeout as
|
|
56
|
+
// "exited before launch" (Control 0.3.5 field failure, 2026-08-03).
|
|
57
|
+
let terminationReason: TerminationReason = null
|
|
51
58
|
const cap = (value: string) => value.slice(-256_000)
|
|
52
59
|
child.stdout.on('data', chunk => { stdout = cap(stdout + chunk.toString()) })
|
|
53
60
|
child.stderr.on('data', chunk => { stderr = cap(stderr + chunk.toString()) })
|
|
@@ -58,27 +65,57 @@ function runBounded(
|
|
|
58
65
|
signal?.removeEventListener('abort', abort)
|
|
59
66
|
resolvePromise(result)
|
|
60
67
|
}
|
|
68
|
+
const terminatedResult = (code: number | null): ProcessResult => ({
|
|
69
|
+
code,
|
|
70
|
+
stdout,
|
|
71
|
+
stderr,
|
|
72
|
+
timedOut: terminationReason === 'timeout',
|
|
73
|
+
aborted: terminationReason === 'abort',
|
|
74
|
+
})
|
|
61
75
|
const timer = setTimeout(() => {
|
|
76
|
+
if (settled || terminationReason) return
|
|
77
|
+
terminationReason = 'timeout'
|
|
62
78
|
void terminateProviderProcess(child, { termGraceMs: 50 }).then(result => {
|
|
63
|
-
if (result.closed) finish(
|
|
79
|
+
if (result.closed) finish(terminatedResult(result.code))
|
|
64
80
|
else console.error('[provider-proof] timed-out provider did not close after SIGKILL; retaining request ownership')
|
|
65
81
|
})
|
|
66
82
|
}, timeoutMs)
|
|
67
83
|
timer.unref?.()
|
|
68
84
|
const abort = () => {
|
|
85
|
+
if (settled || terminationReason) return
|
|
86
|
+
terminationReason = 'abort'
|
|
69
87
|
void terminateProviderProcess(child).then(result => {
|
|
70
|
-
if (result.closed) finish(
|
|
88
|
+
if (result.closed) finish(terminatedResult(result.code))
|
|
71
89
|
else console.error('[provider-proof] canceled provider did not close after SIGKILL; retaining request ownership')
|
|
72
90
|
})
|
|
73
91
|
}
|
|
74
|
-
child.once('error', err =>
|
|
75
|
-
|
|
92
|
+
child.once('error', err => {
|
|
93
|
+
stderr = cap(stderr + err.message)
|
|
94
|
+
finish(terminatedResult(null))
|
|
95
|
+
})
|
|
96
|
+
child.once('close', code => finish(terminatedResult(code)))
|
|
76
97
|
signal?.addEventListener('abort', abort, { once: true })
|
|
77
98
|
child.stdin.on('error', () => { /* close/error is authoritative */ })
|
|
78
99
|
child.stdin.end(input)
|
|
79
100
|
})
|
|
80
101
|
}
|
|
81
102
|
|
|
103
|
+
export const CLAUDE_PROOF_MODEL = 'haiku'
|
|
104
|
+
export const CLAUDE_PROOF_TIMEOUT_MS = 45_000
|
|
105
|
+
|
|
106
|
+
export function claudeProofArgs(): string[] {
|
|
107
|
+
return [
|
|
108
|
+
'-p',
|
|
109
|
+
'--model', CLAUDE_PROOF_MODEL,
|
|
110
|
+
'--output-format', 'json',
|
|
111
|
+
'--permission-mode', 'dontAsk',
|
|
112
|
+
'--tools', '',
|
|
113
|
+
'--allowedTools', '',
|
|
114
|
+
'--system-prompt', PROOF_PROMPT,
|
|
115
|
+
PROOF_PROMPT,
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
|
|
82
119
|
export function claudeProofText(stdout: string): string {
|
|
83
120
|
try {
|
|
84
121
|
const parsed = JSON.parse(stdout) as { result?: unknown }
|
|
@@ -120,15 +157,7 @@ function safeProofError(result: ProcessResult): string {
|
|
|
120
157
|
async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
|
|
121
158
|
const started = Date.now()
|
|
122
159
|
const result = provider === 'claude'
|
|
123
|
-
? await runBounded('claude',
|
|
124
|
-
'-p',
|
|
125
|
-
'--output-format', 'json',
|
|
126
|
-
'--permission-mode', 'dontAsk',
|
|
127
|
-
'--tools', '',
|
|
128
|
-
'--allowedTools', '',
|
|
129
|
-
'--system-prompt', PROOF_PROMPT,
|
|
130
|
-
PROOF_PROMPT,
|
|
131
|
-
], '', 120_000, signal)
|
|
160
|
+
? await runBounded('claude', claudeProofArgs(), '', CLAUDE_PROOF_TIMEOUT_MS, signal)
|
|
132
161
|
: await runBounded('codex', [
|
|
133
162
|
'exec',
|
|
134
163
|
'--sandbox', 'read-only',
|
|
@@ -107,22 +107,76 @@ function resolveWhisperBin(name: string): string {
|
|
|
107
107
|
}
|
|
108
108
|
const WHISPER_CLI = resolveWhisperBin('whisper-cli')
|
|
109
109
|
const WHISPER_SERVER = resolveWhisperBin('whisper-server')
|
|
110
|
-
const
|
|
110
|
+
const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
|
|
111
|
+
export const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
|
|
112
|
+
export const WHISPER_LARGE_V3_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3.bin')
|
|
113
|
+
|
|
114
|
+
export type WhisperCommitRequest = 'turbo' | 'large-v3'
|
|
115
|
+
export type WhisperCommitModel = 'large-v3-turbo' | 'large-v3'
|
|
116
|
+
export type WhisperTranscriptionTier = 'balanced' | 'max'
|
|
117
|
+
|
|
118
|
+
export interface WhisperCommitCapability {
|
|
119
|
+
requestedTier: WhisperTranscriptionTier
|
|
120
|
+
effectiveTier: WhisperTranscriptionTier
|
|
121
|
+
requestedModel: WhisperCommitRequest
|
|
122
|
+
effectiveModel: WhisperCommitModel
|
|
123
|
+
ready: boolean
|
|
124
|
+
configured: boolean
|
|
125
|
+
degraded: boolean
|
|
126
|
+
reason: 'large_v3_model_missing' | 'turbo_model_missing' | null
|
|
127
|
+
promptPolicy: 'full-vocabulary'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function normalizeWhisperTranscriptionTier(raw?: string): WhisperTranscriptionTier {
|
|
131
|
+
return raw?.trim().toLowerCase() === 'max' ? 'max' : 'balanced'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function normalizeWhisperCommitRequest(raw?: string): WhisperCommitRequest {
|
|
135
|
+
const value = raw?.trim().toLowerCase()
|
|
136
|
+
if (value === 'large-v3' || value === 'large_v3' || value === 'ggml-large-v3.bin' || value === 'max') {
|
|
137
|
+
return 'large-v3'
|
|
138
|
+
}
|
|
139
|
+
return 'turbo'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function requestedTranscriptionTier(): WhisperTranscriptionTier {
|
|
143
|
+
return normalizeWhisperTranscriptionTier(process.env.COS_WHISPER_TRANSCRIPTION_TIER)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function requestedCommitModel(): WhisperCommitRequest {
|
|
147
|
+
const explicit = process.env.COS_WHISPER_COMMIT_MODEL
|
|
148
|
+
return explicit
|
|
149
|
+
? normalizeWhisperCommitRequest(explicit)
|
|
150
|
+
: requestedTranscriptionTier() === 'max' ? 'large-v3' : 'turbo'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const COMMIT_REQUEST = requestedCommitModel()
|
|
154
|
+
const REQUESTED_TRANSCRIPTION_TIER = requestedTranscriptionTier()
|
|
155
|
+
const COMMIT_MODEL_PATH = COMMIT_REQUEST === 'large-v3' && existsSync(WHISPER_LARGE_V3_MODEL_PATH)
|
|
156
|
+
? WHISPER_LARGE_V3_MODEL_PATH
|
|
157
|
+
: WHISPER_TURBO_MODEL_PATH
|
|
158
|
+
const COMMIT_MODEL: WhisperCommitModel = COMMIT_MODEL_PATH === WHISPER_LARGE_V3_MODEL_PATH
|
|
159
|
+
? 'large-v3'
|
|
160
|
+
: 'large-v3-turbo'
|
|
161
|
+
|
|
162
|
+
if (COMMIT_REQUEST === 'large-v3' && COMMIT_MODEL !== 'large-v3') {
|
|
163
|
+
console.warn('[whisper-local] Max requested but Large-v3 weights are missing; committed transcription is using Turbo.')
|
|
164
|
+
}
|
|
111
165
|
|
|
112
166
|
// Post-meeting batch transcription uses the full 32-layer Whisper large-v3
|
|
113
167
|
// instead of turbo's 4-layer decoder. Bake-off on 2026-04-16 showed the full
|
|
114
168
|
// decoder captures +43% more speech content with zero known-hallucinations on
|
|
115
169
|
// a real 23.6 min G2 recording. See wk16_2026/asr-bakeoff/report.md.
|
|
116
170
|
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
171
|
+
// Balanced live commit stays on Turbo. Max may opt the persistent live worker
|
|
172
|
+
// into Large-v3 on measured hardware. Post-meeting HQ remains a separate batch
|
|
173
|
+
// policy and never inherits the live commit selection.
|
|
120
174
|
//
|
|
121
175
|
// DISABLE: set COS_BATCH_LARGE_V3=0 to revert HQ path to turbo. Missing-weights
|
|
122
176
|
// case is defensive: if ggml-large-v3.bin isn't on disk we log a warning and
|
|
123
177
|
// fall back to turbo automatically — no broken batch runs.
|
|
124
|
-
const BATCH_MODEL_LARGE_V3 =
|
|
125
|
-
const BATCH_MODEL_TURBO =
|
|
178
|
+
const BATCH_MODEL_LARGE_V3 = WHISPER_LARGE_V3_MODEL_PATH
|
|
179
|
+
const BATCH_MODEL_TURBO = WHISPER_TURBO_MODEL_PATH
|
|
126
180
|
const BATCH_LARGE_V3_ENABLED = process.env.COS_BATCH_LARGE_V3 !== '0'
|
|
127
181
|
|
|
128
182
|
// Silero VAD (ggml) — whisper-server --vad strips silence/noise windows BEFORE the
|
|
@@ -286,7 +340,9 @@ function isCosWhisperServerCommand(command: string): boolean {
|
|
|
286
340
|
const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
|
|
287
341
|
const executable = basename(executablePath) === 'whisper-server'
|
|
288
342
|
const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
|
|
289
|
-
|
|
343
|
+
const supportedModel = [WHISPER_TURBO_MODEL_PATH, WHISPER_LARGE_V3_MODEL_PATH]
|
|
344
|
+
.some(modelPath => command.includes(modelPath))
|
|
345
|
+
return executable && configuredPort && supportedModel
|
|
290
346
|
}
|
|
291
347
|
|
|
292
348
|
function collectDescendants(processes: ProcessEntry[], roots: Iterable<number>): Set<number> {
|
|
@@ -399,7 +455,9 @@ async function proveWhisperPortClear(): Promise<void> {
|
|
|
399
455
|
|
|
400
456
|
// Check CLI availability at import time
|
|
401
457
|
try {
|
|
402
|
-
|
|
458
|
+
// CLI fallback/HQ semantics are always anchored to the immutable Turbo
|
|
459
|
+
// weights. A Max live selection must never silently rewrite the HQ fallback.
|
|
460
|
+
cliAvailable = existsSync(WHISPER_CLI) && existsSync(WHISPER_TURBO_MODEL_PATH)
|
|
403
461
|
if (cliAvailable) {
|
|
404
462
|
console.log(`[whisper-local] whisper-cli available at ${WHISPER_CLI}`)
|
|
405
463
|
}
|
|
@@ -441,7 +499,7 @@ export async function startWhisperServer(): Promise<void> {
|
|
|
441
499
|
}
|
|
442
500
|
|
|
443
501
|
async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
|
|
444
|
-
if (!existsSync(WHISPER_SERVER) || !existsSync(
|
|
502
|
+
if (!existsSync(WHISPER_SERVER) || !existsSync(COMMIT_MODEL_PATH)) {
|
|
445
503
|
serverStartupState = 'unavailable'
|
|
446
504
|
serverLastError = 'whisper-server or model not found'
|
|
447
505
|
console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
|
|
@@ -461,8 +519,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
|
|
|
461
519
|
// Assemble startup args. VAD only attaches if the ggml model is actually on
|
|
462
520
|
// disk — missing-file is logged, not fatal (server still boots without VAD).
|
|
463
521
|
const serverArgs = [
|
|
464
|
-
'-m',
|
|
465
|
-
'-t', '16', //
|
|
522
|
+
'-m', COMMIT_MODEL_PATH,
|
|
523
|
+
'-t', '16', // Metal runs the encoder; measured 4/8/16-thread results favor keeping 16 across Apple silicon.
|
|
466
524
|
'-l', 'en',
|
|
467
525
|
'-fa', // Flash attention — faster self-attention on Apple Silicon
|
|
468
526
|
'--no-speech-thold', '0.7', // Reject silence more aggressively (default 0.6)
|
|
@@ -599,7 +657,7 @@ export function getWhisperHealth(): {
|
|
|
599
657
|
} {
|
|
600
658
|
return {
|
|
601
659
|
server: serverAvailable,
|
|
602
|
-
serverConfigured: existsSync(WHISPER_SERVER) && existsSync(
|
|
660
|
+
serverConfigured: existsSync(WHISPER_SERVER) && existsSync(COMMIT_MODEL_PATH),
|
|
603
661
|
cli: cliAvailable,
|
|
604
662
|
consecutiveFailures: serverConsecutiveFailures,
|
|
605
663
|
restarting: serverRestarting || serverStarting,
|
|
@@ -610,6 +668,38 @@ export function getWhisperHealth(): {
|
|
|
610
668
|
}
|
|
611
669
|
}
|
|
612
670
|
|
|
671
|
+
/** Path-free truth for Control and companion capability rendering. The
|
|
672
|
+
* requested preset may degrade to Turbo when Large-v3 weights are absent, but
|
|
673
|
+
* the process never reports Max as effective unless the resident worker is
|
|
674
|
+
* actually using Large-v3. */
|
|
675
|
+
export function getWhisperCommitCapability(): WhisperCommitCapability {
|
|
676
|
+
const turboPresent = existsSync(WHISPER_TURBO_MODEL_PATH)
|
|
677
|
+
const largePresent = existsSync(WHISPER_LARGE_V3_MODEL_PATH)
|
|
678
|
+
const configured = existsSync(WHISPER_SERVER) && existsSync(COMMIT_MODEL_PATH)
|
|
679
|
+
const requestedTier: WhisperTranscriptionTier = COMMIT_REQUEST === 'large-v3'
|
|
680
|
+
? 'max'
|
|
681
|
+
: REQUESTED_TRANSCRIPTION_TIER
|
|
682
|
+
const effectiveTier: WhisperTranscriptionTier = COMMIT_MODEL === 'large-v3' ? 'max' : 'balanced'
|
|
683
|
+
const reason = COMMIT_REQUEST === 'large-v3' && !largePresent
|
|
684
|
+
? 'large_v3_model_missing' as const
|
|
685
|
+
// Turbo is the immutable recovery model even when Max is active. Missing
|
|
686
|
+
// fallback weights are therefore degraded health, not a clean Max state.
|
|
687
|
+
: !turboPresent
|
|
688
|
+
? 'turbo_model_missing' as const
|
|
689
|
+
: null
|
|
690
|
+
return {
|
|
691
|
+
requestedTier,
|
|
692
|
+
effectiveTier,
|
|
693
|
+
requestedModel: COMMIT_REQUEST,
|
|
694
|
+
effectiveModel: COMMIT_MODEL,
|
|
695
|
+
ready: serverAvailable,
|
|
696
|
+
configured,
|
|
697
|
+
degraded: requestedTier !== effectiveTier || reason !== null,
|
|
698
|
+
reason,
|
|
699
|
+
promptPolicy: 'full-vocabulary',
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
613
703
|
/** Public, path-free truth about whether an HQ request can actually run the
|
|
614
704
|
* full large-v3 decoder. Keep this separate from generic Whisper liveness: the
|
|
615
705
|
* persistent turbo server may be healthy while HQ weights or the CLI are not. */
|
|
@@ -931,13 +1021,23 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
|
|
|
931
1021
|
* which can receive a null C string after VAD returns no speech and crash the
|
|
932
1022
|
* native server before an HTTP response exists.
|
|
933
1023
|
*/
|
|
934
|
-
async function transcribeViaServer(
|
|
1024
|
+
async function transcribeViaServer(
|
|
1025
|
+
audioBuffer: Buffer,
|
|
1026
|
+
context?: string,
|
|
1027
|
+
isQuiet?: boolean,
|
|
1028
|
+
promptPolicy: 'full-vocabulary' | 'none' = 'full-vocabulary',
|
|
1029
|
+
): Promise<{ text: string; words?: WhisperWord[] }> {
|
|
935
1030
|
const formData = new FormData()
|
|
936
1031
|
// Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
|
|
937
1032
|
const blob = new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' })
|
|
938
1033
|
formData.append('file', blob, 'recording.wav')
|
|
939
1034
|
formData.append('response_format', 'json')
|
|
940
|
-
|
|
1035
|
+
// Cosmetic preview decodes must not receive decoder bias. Omitting the field
|
|
1036
|
+
// (instead of sending an empty string) keeps the wire contract unambiguous
|
|
1037
|
+
// and lets diagnostics truthfully report promptPolicy: "none".
|
|
1038
|
+
if (promptPolicy === 'full-vocabulary') {
|
|
1039
|
+
formData.append('prompt', buildPrompt(context, isQuiet))
|
|
1040
|
+
}
|
|
941
1041
|
// Anti-hallucination handled by client-side filter + context filtering.
|
|
942
1042
|
// Whisper-level entropy/logprob thresholds were too aggressive — silently dropped
|
|
943
1043
|
// legitimate speech from quiet sources (laptop speakers through G2 mic).
|
|
@@ -972,7 +1072,7 @@ async function transcribeViaCLI(audioBuffer: Buffer, context?: string, isQuiet?:
|
|
|
972
1072
|
|
|
973
1073
|
return await new Promise<string>((resolve, reject) => {
|
|
974
1074
|
const proc = spawn(WHISPER_CLI, [
|
|
975
|
-
'-m',
|
|
1075
|
+
'-m', WHISPER_TURBO_MODEL_PATH,
|
|
976
1076
|
'-f', tmpWav,
|
|
977
1077
|
'-t', '12',
|
|
978
1078
|
'-l', 'en',
|
|
@@ -1080,7 +1180,10 @@ export async function transcribeLocal(
|
|
|
1080
1180
|
audioBuffer: Buffer,
|
|
1081
1181
|
context?: string,
|
|
1082
1182
|
isQuiet?: boolean,
|
|
1083
|
-
opts?: {
|
|
1183
|
+
opts?: {
|
|
1184
|
+
affectsCircuit?: boolean
|
|
1185
|
+
promptPolicy?: 'full-vocabulary' | 'none'
|
|
1186
|
+
},
|
|
1084
1187
|
): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
|
|
1085
1188
|
const start = Date.now()
|
|
1086
1189
|
const affectsCircuit = opts?.affectsCircuit !== false
|
|
@@ -1096,7 +1199,7 @@ export async function transcribeLocal(
|
|
|
1096
1199
|
// Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
|
|
1097
1200
|
if (serverAvailable) {
|
|
1098
1201
|
try {
|
|
1099
|
-
const result = await transcribeViaServer(audioBuffer, context, isQuiet)
|
|
1202
|
+
const result = await transcribeViaServer(audioBuffer, context, isQuiet, opts?.promptPolicy)
|
|
1100
1203
|
const text = applyCorrections(result.text)
|
|
1101
1204
|
const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
|
|
1102
1205
|
const elapsed = Date.now() - start
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
// Adaptive provisional transcription
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// large-v3 -> HQ save/polish (unchanged)
|
|
1
|
+
// Adaptive provisional transcription:
|
|
2
|
+
// Balanced -> isolated Small.en cosmetic preview + Turbo live commit
|
|
3
|
+
// Max -> resident Large-v3 worker reused for preview + live commit
|
|
4
|
+
// polish -> Large-v3 save pass (unchanged)
|
|
6
5
|
|
|
7
|
-
import { spawn } from 'node:child_process'
|
|
6
|
+
import { execFile, spawn } from 'node:child_process'
|
|
8
7
|
import type { ChildProcess } from 'node:child_process'
|
|
9
8
|
import { existsSync } from 'node:fs'
|
|
10
9
|
import { homedir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
-
import {
|
|
13
|
-
|
|
10
|
+
import { basename, join } from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
applyCorrections,
|
|
13
|
+
getWhisperCommitCapability,
|
|
14
|
+
getWhisperHealth,
|
|
15
|
+
transcribeLocal,
|
|
16
|
+
type WhisperCommitModel,
|
|
17
|
+
type WhisperTranscriptionTier,
|
|
18
|
+
} from './whisper-local.js'
|
|
14
19
|
|
|
15
20
|
export type WhisperPreviewRequest = 'auto' | 'small.en' | 'turbo' | 'off'
|
|
16
|
-
export type WhisperPreviewModel = 'small.en' |
|
|
21
|
+
export type WhisperPreviewModel = 'small.en' | WhisperCommitModel | null
|
|
17
22
|
export type WhisperPreviewReason =
|
|
18
23
|
| 'disabled'
|
|
19
24
|
| 'small_model_missing'
|
|
@@ -22,6 +27,8 @@ export type WhisperPreviewReason =
|
|
|
22
27
|
| 'preview_start_failed'
|
|
23
28
|
| 'preview_sidecar_unavailable'
|
|
24
29
|
| 'turbo_unavailable'
|
|
30
|
+
| 'large_v3_model_missing'
|
|
31
|
+
| 'turbo_model_missing'
|
|
25
32
|
| null
|
|
26
33
|
|
|
27
34
|
export interface WhisperPreviewCapability {
|
|
@@ -31,7 +38,14 @@ export interface WhisperPreviewCapability {
|
|
|
31
38
|
backend: 'whisper-preview-server' | 'whisper-server' | null
|
|
32
39
|
degraded: boolean
|
|
33
40
|
reason: WhisperPreviewReason
|
|
34
|
-
|
|
41
|
+
previewDegraded: boolean
|
|
42
|
+
commitDegraded: boolean
|
|
43
|
+
commitReason: 'large_v3_model_missing' | 'turbo_model_missing' | null
|
|
44
|
+
committedModel: WhisperCommitModel
|
|
45
|
+
requestedCommitModel: 'turbo' | 'large-v3'
|
|
46
|
+
requestedTier: WhisperTranscriptionTier
|
|
47
|
+
effectiveTier: WhisperTranscriptionTier
|
|
48
|
+
promptPolicy: 'none'
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
|
|
@@ -42,6 +56,8 @@ const WHISPER_SERVER = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whis
|
|
|
42
56
|
.find(existsSync) ?? '/opt/homebrew/bin/whisper-server'
|
|
43
57
|
const PREVIEW_PORT = 8177
|
|
44
58
|
const PREVIEW_URL = `http://127.0.0.1:${PREVIEW_PORT}`
|
|
59
|
+
const PROCESS_PROBE_TIMEOUT_MS = 2_000
|
|
60
|
+
const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
|
|
45
61
|
|
|
46
62
|
let previewProcess: ChildProcess | null = null
|
|
47
63
|
let previewAvailable = false
|
|
@@ -49,6 +65,79 @@ let previewStarting = false
|
|
|
49
65
|
let previewFailure: WhisperPreviewReason = null
|
|
50
66
|
let warnedInvalidChoice = false
|
|
51
67
|
|
|
68
|
+
interface ProcessEntry {
|
|
69
|
+
pid: number
|
|
70
|
+
ppid: number
|
|
71
|
+
command: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runProcessProbe(file: string, args: string[]): Promise<string> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
execFile(file, args, {
|
|
77
|
+
encoding: 'utf8', timeout: PROCESS_PROBE_TIMEOUT_MS,
|
|
78
|
+
maxBuffer: 1024 * 1024, killSignal: 'SIGKILL',
|
|
79
|
+
}, (error, stdout) => error ? reject(error) : resolve(String(stdout)))
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function previewListeningPids(): Promise<number[]> {
|
|
84
|
+
try {
|
|
85
|
+
const output = await runProcessProbe(LSOF_BIN, ['-nP', `-iTCP:${PREVIEW_PORT}`, '-sTCP:LISTEN', '-t'])
|
|
86
|
+
return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
|
87
|
+
} catch (error: any) {
|
|
88
|
+
if (error?.code === 1 || error?.status === 1) return []
|
|
89
|
+
throw new Error(`unable to inspect preview port ${PREVIEW_PORT}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function listProcesses(): Promise<ProcessEntry[]> {
|
|
94
|
+
const output = await runProcessProbe('/bin/ps', ['-axww', '-o', 'pid=,ppid=,command='])
|
|
95
|
+
return output.split('\n').flatMap(line => {
|
|
96
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
|
|
97
|
+
return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }] : []
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isCosPreviewCommand(command: string): boolean {
|
|
102
|
+
const firstToken = command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/)
|
|
103
|
+
const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
|
|
104
|
+
return basename(executablePath) === 'whisper-server'
|
|
105
|
+
&& new RegExp(`(?:^|\\s)--port(?:=|\\s+)${PREVIEW_PORT}(?:\\s|$)`).test(command)
|
|
106
|
+
&& command.includes(WHISPER_SMALL_EN_MODEL_PATH)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Reap only a listener proven to be our exact Small.en/8177 command. An
|
|
110
|
+
* unrelated local service is never contacted with audio or terminated. */
|
|
111
|
+
async function reclaimPreviewPort(): Promise<'clear' | 'reaped' | 'foreign'> {
|
|
112
|
+
const listeners = await previewListeningPids()
|
|
113
|
+
if (listeners.length === 0) return 'clear'
|
|
114
|
+
const processes = await listProcesses()
|
|
115
|
+
const byPid = new Map(processes.map(entry => [entry.pid, entry]))
|
|
116
|
+
if (!listeners.every(pid => isCosPreviewCommand(byPid.get(pid)?.command ?? ''))) return 'foreign'
|
|
117
|
+
|
|
118
|
+
const targets = new Set(listeners)
|
|
119
|
+
let changed = true
|
|
120
|
+
while (changed) {
|
|
121
|
+
changed = false
|
|
122
|
+
for (const entry of processes) {
|
|
123
|
+
if (!targets.has(entry.pid) && targets.has(entry.ppid)) {
|
|
124
|
+
targets.add(entry.pid)
|
|
125
|
+
changed = true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const pid of [...targets].reverse()) {
|
|
130
|
+
try { process.kill(pid, 'SIGKILL') } catch (error: any) {
|
|
131
|
+
if (error?.code !== 'ESRCH') throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
135
|
+
if ((await previewListeningPids()).length === 0) return 'reaped'
|
|
136
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`verified Small.en worker still owns port ${PREVIEW_PORT}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
52
141
|
export function normalizeWhisperPreviewRequest(raw?: string): WhisperPreviewRequest {
|
|
53
142
|
const value = raw?.trim().toLowerCase()
|
|
54
143
|
// Backward compatibility is deliberate: simply updating the server keeps
|
|
@@ -64,6 +153,11 @@ export function normalizeWhisperPreviewRequest(raw?: string): WhisperPreviewRequ
|
|
|
64
153
|
function requestedPreviewModel(): WhisperPreviewRequest {
|
|
65
154
|
const raw = process.env.COS_WHISPER_PREVIEW_MODEL
|
|
66
155
|
?? process.env.COS_WHISPER_REALTIME_MODEL // migration alias for early private installs
|
|
156
|
+
if (!raw && process.env.COS_WHISPER_TRANSCRIPTION_TIER) {
|
|
157
|
+
return process.env.COS_WHISPER_TRANSCRIPTION_TIER.trim().toLowerCase() === 'max'
|
|
158
|
+
? 'turbo'
|
|
159
|
+
: 'small.en'
|
|
160
|
+
}
|
|
67
161
|
const normalized = normalizeWhisperPreviewRequest(raw)
|
|
68
162
|
if (raw && normalized === 'auto' && !['auto', 'adaptive'].includes(raw.trim().toLowerCase()) && !warnedInvalidChoice) {
|
|
69
163
|
warnedInvalidChoice = true
|
|
@@ -74,17 +168,24 @@ function requestedPreviewModel(): WhisperPreviewRequest {
|
|
|
74
168
|
|
|
75
169
|
function selectedPreviewModel(requested = requestedPreviewModel()): WhisperPreviewModel {
|
|
76
170
|
if (requested === 'off') return null
|
|
77
|
-
|
|
78
|
-
if (requested === '
|
|
79
|
-
return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' :
|
|
171
|
+
const primary = getWhisperCommitCapability().effectiveModel
|
|
172
|
+
if (requested === 'turbo') return primary
|
|
173
|
+
if (requested === 'small.en') return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : primary
|
|
174
|
+
return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : primary
|
|
80
175
|
}
|
|
81
176
|
|
|
82
177
|
export function getWhisperPreviewCapability(): WhisperPreviewCapability {
|
|
178
|
+
const commit = getWhisperCommitCapability()
|
|
83
179
|
const requested = requestedPreviewModel()
|
|
84
180
|
if (requested === 'off') {
|
|
85
181
|
return {
|
|
86
182
|
requested, effectiveModel: null, ready: false, backend: null,
|
|
87
|
-
degraded:
|
|
183
|
+
degraded: commit.degraded, reason: commit.reason ?? 'disabled',
|
|
184
|
+
previewDegraded: false, commitDegraded: commit.degraded, commitReason: commit.reason,
|
|
185
|
+
committedModel: commit.effectiveModel,
|
|
186
|
+
requestedCommitModel: commit.requestedModel,
|
|
187
|
+
requestedTier: commit.requestedTier, effectiveTier: commit.effectiveTier,
|
|
188
|
+
promptPolicy: 'none',
|
|
88
189
|
}
|
|
89
190
|
}
|
|
90
191
|
|
|
@@ -94,25 +195,39 @@ export function getWhisperPreviewCapability(): WhisperPreviewCapability {
|
|
|
94
195
|
if (selected === 'small.en' && previewAvailable) {
|
|
95
196
|
return {
|
|
96
197
|
requested, effectiveModel: 'small.en', ready: true,
|
|
97
|
-
backend: 'whisper-preview-server', degraded:
|
|
98
|
-
|
|
198
|
+
backend: 'whisper-preview-server', degraded: commit.degraded, reason: commit.reason,
|
|
199
|
+
previewDegraded: false, commitDegraded: commit.degraded, commitReason: commit.reason,
|
|
200
|
+
committedModel: commit.effectiveModel,
|
|
201
|
+
requestedCommitModel: commit.requestedModel,
|
|
202
|
+
requestedTier: commit.requestedTier, effectiveTier: commit.effectiveTier,
|
|
203
|
+
promptPolicy: 'none',
|
|
99
204
|
}
|
|
100
205
|
}
|
|
101
206
|
|
|
102
207
|
const smallWasExpected = requested === 'small.en' || (requested === 'auto' && smallPresent)
|
|
103
|
-
const
|
|
208
|
+
const previewDegraded = smallWasExpected
|
|
209
|
+
const reason: WhisperPreviewReason = commit.reason
|
|
210
|
+
? commit.reason
|
|
211
|
+
: requested === 'small.en' && !smallPresent
|
|
104
212
|
? 'small_model_missing'
|
|
105
213
|
: smallWasExpected
|
|
106
214
|
? (previewFailure ?? (previewStarting ? null : 'preview_sidecar_unavailable'))
|
|
107
215
|
: turboReady ? null : 'turbo_unavailable'
|
|
108
216
|
return {
|
|
109
217
|
requested,
|
|
110
|
-
effectiveModel: turboReady ?
|
|
218
|
+
effectiveModel: turboReady ? commit.effectiveModel : selected,
|
|
111
219
|
ready: turboReady,
|
|
112
220
|
backend: turboReady ? 'whisper-server' : null,
|
|
113
|
-
degraded:
|
|
221
|
+
degraded: previewDegraded || commit.degraded,
|
|
114
222
|
reason,
|
|
115
|
-
|
|
223
|
+
previewDegraded,
|
|
224
|
+
commitDegraded: commit.degraded,
|
|
225
|
+
commitReason: commit.reason,
|
|
226
|
+
committedModel: commit.effectiveModel,
|
|
227
|
+
requestedCommitModel: commit.requestedModel,
|
|
228
|
+
requestedTier: commit.requestedTier,
|
|
229
|
+
effectiveTier: commit.effectiveTier,
|
|
230
|
+
promptPolicy: 'none',
|
|
116
231
|
}
|
|
117
232
|
}
|
|
118
233
|
|
|
@@ -138,12 +253,19 @@ export async function startWhisperPreviewServer(): Promise<void> {
|
|
|
138
253
|
previewFailure = null
|
|
139
254
|
try {
|
|
140
255
|
try {
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
256
|
+
const portState = await reclaimPreviewPort()
|
|
257
|
+
if (portState === 'foreign') {
|
|
143
258
|
previewFailure = 'preview_port_busy'
|
|
144
259
|
return
|
|
145
260
|
}
|
|
146
|
-
|
|
261
|
+
if (portState === 'reaped') {
|
|
262
|
+
console.log('[whisper-preview] reaped a stale Small.en worker before restart')
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
previewFailure = 'preview_start_failed'
|
|
266
|
+
console.warn(`[whisper-preview] port preflight failed: ${error instanceof Error ? error.message : error}`)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
147
269
|
|
|
148
270
|
const args = [
|
|
149
271
|
'-m', WHISPER_SMALL_EN_MODEL_PATH,
|
|
@@ -192,28 +314,44 @@ export async function startWhisperPreviewServer(): Promise<void> {
|
|
|
192
314
|
}
|
|
193
315
|
}
|
|
194
316
|
|
|
195
|
-
|
|
317
|
+
function waitForPreviewClose(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
|
318
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
|
319
|
+
return new Promise(resolve => {
|
|
320
|
+
let settled = false
|
|
321
|
+
const finish = (closed: boolean) => {
|
|
322
|
+
if (settled) return
|
|
323
|
+
settled = true
|
|
324
|
+
clearTimeout(timeout)
|
|
325
|
+
child.off('close', onClose)
|
|
326
|
+
resolve(closed)
|
|
327
|
+
}
|
|
328
|
+
const onClose = () => finish(true)
|
|
329
|
+
const timeout = setTimeout(() => finish(false), timeoutMs)
|
|
330
|
+
child.once('close', onClose)
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function stopWhisperPreviewServer(): Promise<void> {
|
|
196
335
|
const child = previewProcess
|
|
197
336
|
previewProcess = null
|
|
198
337
|
previewAvailable = false
|
|
199
338
|
previewStarting = false
|
|
200
339
|
if (child) {
|
|
201
340
|
try { child.kill('SIGTERM') } catch { /* already exited */ }
|
|
341
|
+
if (!await waitForPreviewClose(child, 2_000)) {
|
|
342
|
+
try { child.kill('SIGKILL') } catch { /* already exited */ }
|
|
343
|
+
await waitForPreviewClose(child, 1_000)
|
|
344
|
+
}
|
|
202
345
|
}
|
|
203
346
|
}
|
|
204
347
|
|
|
205
|
-
function previewPrompt(): string {
|
|
206
|
-
const vocabulary = getVocabulary()
|
|
207
|
-
return vocabulary.length > 0
|
|
208
|
-
? [getOwnerName(), ...vocabulary].join(', ')
|
|
209
|
-
: `${getOwnerName()}. COS Glasses. Even G2.`
|
|
210
|
-
}
|
|
211
|
-
|
|
212
348
|
async function transcribeViaPreviewServer(audioBuffer: Buffer): Promise<string> {
|
|
213
349
|
const formData = new FormData()
|
|
214
350
|
formData.append('file', new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' }), 'recording.wav')
|
|
215
351
|
formData.append('response_format', 'json')
|
|
216
|
-
|
|
352
|
+
// Preview text is cosmetic and the same audio is authoritatively decoded by
|
|
353
|
+
// the commit lane. Small.en is disproportionately suggestible on short
|
|
354
|
+
// windows, so never bias this provisional decode with profile vocabulary.
|
|
217
355
|
formData.append('suppress_non_speech', 'true')
|
|
218
356
|
const response = await endpointReady('/inference', { method: 'POST', body: formData }, 5_000)
|
|
219
357
|
if (!response.ok) throw new Error(`preview server ${response.status}`)
|
|
@@ -226,7 +364,7 @@ async function transcribeViaPreviewServer(audioBuffer: Buffer): Promise<string>
|
|
|
226
364
|
* non-circuit Turbo decode and can never write committed transcript state. */
|
|
227
365
|
export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
|
|
228
366
|
text: string
|
|
229
|
-
model: 'small.en' |
|
|
367
|
+
model: 'small.en' | WhisperCommitModel
|
|
230
368
|
backend: 'whisper-preview-server' | 'whisper-server'
|
|
231
369
|
}> {
|
|
232
370
|
if (previewAvailable) {
|
|
@@ -238,6 +376,9 @@ export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
|
|
|
238
376
|
console.warn(`[whisper-preview] small.en preview failed; falling back to Turbo: ${error instanceof Error ? error.message : error}`)
|
|
239
377
|
}
|
|
240
378
|
}
|
|
241
|
-
const result = await transcribeLocal(audioBuffer, undefined, undefined, {
|
|
242
|
-
|
|
379
|
+
const result = await transcribeLocal(audioBuffer, undefined, undefined, {
|
|
380
|
+
affectsCircuit: false,
|
|
381
|
+
promptPolicy: 'none',
|
|
382
|
+
})
|
|
383
|
+
return { text: result.text, model: getWhisperCommitCapability().effectiveModel, backend: 'whisper-server' }
|
|
243
384
|
}
|