@gotcos/glasses-server 6.20.0 → 6.21.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 +7 -0
- package/CHANGELOG.md +38 -0
- package/README.md +19 -5
- package/bin/cli.cjs +98 -34
- package/package.json +2 -2
- package/server/index.ts +1 -1
- 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,41 @@
|
|
|
1
|
+
## 6.21.0
|
|
2
|
+
|
|
3
|
+
Transcription quality is now a machine-owned, observable two-tier policy.
|
|
4
|
+
|
|
5
|
+
- **Balanced and Max tiers.** Balanced keeps Small.en as a cosmetic prompt
|
|
6
|
+
preview, Large-v3-Turbo as the authoritative live commit model, and Large-v3
|
|
7
|
+
for saved-work polish. Max reuses the resident Large-v3 worker for preview
|
|
8
|
+
and commit; it never starts a third Whisper process.
|
|
9
|
+
- **Safe Large-v3 fallback.** A requested Max tier degrades visibly to Turbo
|
|
10
|
+
when the Large-v3 weights are unavailable. Immutable Turbo and Large-v3
|
|
11
|
+
paths remain independently addressable for fallback and HQ work; health also
|
|
12
|
+
warns when Max is active but its immutable Turbo recovery weights are absent.
|
|
13
|
+
- **Preview isolation.** Small.en previews no longer receive decoder-bias
|
|
14
|
+
vocabulary and still cannot write recovery state or replace committed text.
|
|
15
|
+
Startup reaps only an exact stale Small.en/8177 worker before spawning its
|
|
16
|
+
owned child; an unrelated listener is never contacted with audio or killed.
|
|
17
|
+
- **Truthful health.** The existing
|
|
18
|
+
`capabilities.transcription.live` block now reports requested/effective tier,
|
|
19
|
+
requested/effective commit model, downgrade reason, and preview prompt policy
|
|
20
|
+
without exposing local file paths.
|
|
21
|
+
- **Guided provisioning.** `--setup-transcription --transcription-tier
|
|
22
|
+
balanced|max` provisions only the models required by the selected tier plus
|
|
23
|
+
the immutable Turbo fallback and Large-v3 HQ model.
|
|
24
|
+
|
|
25
|
+
## 6.20.1
|
|
26
|
+
|
|
27
|
+
Adaptive setup now survives the slow or interrupted downloads that exposed the
|
|
28
|
+
first public 6.20.0 install to a false-success state.
|
|
29
|
+
|
|
30
|
+
- **Resumable model downloads.** Whisper model downloads retain their `.partial`
|
|
31
|
+
files, resume with HTTP range requests, retry transient network errors eight
|
|
32
|
+
times, and use one-hour bounds for Small.en/Turbo plus a two-hour bound for
|
|
33
|
+
Large-v3. A rerun continues from the last byte instead of restarting at zero.
|
|
34
|
+
- **Truthful guided setup.** `--setup-transcription --prepare-only` exits nonzero
|
|
35
|
+
and names any missing lane instead of printing “setup complete” after a model
|
|
36
|
+
download failed. Existing Turbo transcription remains the safe runtime
|
|
37
|
+
fallback while setup is incomplete.
|
|
38
|
+
|
|
1
39
|
## 6.20.0
|
|
2
40
|
|
|
3
41
|
Adaptive transcription makes fast feedback additive instead of a quality
|
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')
|
|
@@ -393,9 +417,9 @@ const WHISPER_LARGE_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3.bin')
|
|
|
393
417
|
const WHISPER_LARGE_MODEL_PARTIAL = WHISPER_LARGE_MODEL_PATH + '.partial'
|
|
394
418
|
const WHISPER_LARGE_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin'
|
|
395
419
|
const WHISPER_LARGE_MODEL_MIN_BYTES = 2_800_000_000
|
|
396
|
-
const WHISPER_MODEL_EXPECTED_BYTES =
|
|
397
|
-
const WHISPER_SMALL_MODEL_EXPECTED_BYTES =
|
|
398
|
-
const WHISPER_LARGE_MODEL_EXPECTED_BYTES =
|
|
420
|
+
const WHISPER_MODEL_EXPECTED_BYTES = 1_624_555_275
|
|
421
|
+
const WHISPER_SMALL_MODEL_EXPECTED_BYTES = 487_614_201
|
|
422
|
+
const WHISPER_LARGE_MODEL_EXPECTED_BYTES = 3_095_033_483
|
|
399
423
|
function findWhisperCli() {
|
|
400
424
|
for (const p of WHISPER_KNOWN_PATHS) { if (existsSync(p)) return p }
|
|
401
425
|
try {
|
|
@@ -418,13 +442,35 @@ function isValidWhisperModel(p, minBytes = WHISPER_MODEL_MIN_BYTES) {
|
|
|
418
442
|
if (!existsSync(p)) return false
|
|
419
443
|
try { return statSync(p).size >= minBytes } catch { return false }
|
|
420
444
|
}
|
|
445
|
+
function downloadWhisperModel({ url, destination, partial, expectedBytes, timeoutMs }) {
|
|
446
|
+
mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
|
|
447
|
+
const partialBytes = existsSync(partial) ? statSync(partial).size : 0
|
|
448
|
+
execFileSync('curl', [
|
|
449
|
+
'-fL',
|
|
450
|
+
'--retry', '8',
|
|
451
|
+
'--retry-all-errors',
|
|
452
|
+
'--retry-delay', '2',
|
|
453
|
+
'--connect-timeout', '30',
|
|
454
|
+
'--continue-at', '-',
|
|
455
|
+
'--progress-bar',
|
|
456
|
+
url,
|
|
457
|
+
'-o', partial,
|
|
458
|
+
], { stdio: 'inherit', timeout: timeoutMs })
|
|
459
|
+
const stats = statSync(partial)
|
|
460
|
+
if (stats.size !== expectedBytes) {
|
|
461
|
+
throw new Error(`Downloaded file size mismatch: expected ${expectedBytes}, received ${stats.size} bytes`)
|
|
462
|
+
}
|
|
463
|
+
renameSync(partial, destination)
|
|
464
|
+
return { resumed: partialBytes > 0 }
|
|
465
|
+
}
|
|
421
466
|
const whisperCliPath = findWhisperCli()
|
|
422
467
|
const whisperServerPath = findWhisperServer()
|
|
423
468
|
const hasValidModel = isValidWhisperModel(WHISPER_MODEL_PATH)
|
|
469
|
+
const transcriptionSetupFailures = []
|
|
424
470
|
|
|
425
471
|
if (SETUP_TRANSCRIPTION && (!whisperCliPath || !whisperServerPath)) {
|
|
426
472
|
console.log('')
|
|
427
|
-
console.log(red(' ✗
|
|
473
|
+
console.log(red(' ✗ Transcription setup needs whisper.cpp'))
|
|
428
474
|
console.log(' Install it first: ' + bold('brew install whisper-cpp'))
|
|
429
475
|
console.log(' Then rerun this setup command. No model download was started.')
|
|
430
476
|
console.log('')
|
|
@@ -435,7 +481,9 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
|
|
|
435
481
|
mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
|
|
436
482
|
const targets = [
|
|
437
483
|
[WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
|
|
438
|
-
|
|
484
|
+
...(TRANSCRIPTION_TIER === 'balanced'
|
|
485
|
+
? [[WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES]]
|
|
486
|
+
: []),
|
|
439
487
|
[WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
|
|
440
488
|
]
|
|
441
489
|
const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
|
|
@@ -450,7 +498,7 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
|
|
|
450
498
|
const requiredGB = ((missingBytes + safetyMarginBytes) / 1_000_000_000).toFixed(1)
|
|
451
499
|
const availableGB = (availableBytes / 1_000_000_000).toFixed(1)
|
|
452
500
|
console.log('')
|
|
453
|
-
console.log(red(' ✗ Not enough free disk space for
|
|
501
|
+
console.log(red(' ✗ Not enough free disk space for transcription setup'))
|
|
454
502
|
console.log(` Need about ${requiredGB} GB; ${availableGB} GB is available after reusable partial downloads.`)
|
|
455
503
|
console.log(' Free space, then rerun this setup command. Existing models were not removed.')
|
|
456
504
|
console.log('')
|
|
@@ -462,7 +510,6 @@ if (whisperCliPath && hasValidModel) {
|
|
|
462
510
|
console.log(green(' ✓') + ' whisper.cpp + model ready ' + dim('— voice = local (FREE)'))
|
|
463
511
|
} else if (whisperCliPath && !hasValidModel) {
|
|
464
512
|
if (existsSync(WHISPER_MODEL_PATH)) { try { unlinkSync(WHISPER_MODEL_PATH) } catch {} }
|
|
465
|
-
if (existsSync(WHISPER_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {} }
|
|
466
513
|
console.log(yellow(' ⚠') + ' whisper.cpp installed but model missing')
|
|
467
514
|
console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB).'))
|
|
468
515
|
console.log(' ' + dim('Skip: SKIP_WHISPER_DOWNLOAD=1 npx --yes @gotcos/glasses-server@latest'))
|
|
@@ -470,17 +517,20 @@ if (whisperCliPath && hasValidModel) {
|
|
|
470
517
|
console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — local voice unavailable')
|
|
471
518
|
} else {
|
|
472
519
|
try {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
520
|
+
downloadWhisperModel({
|
|
521
|
+
url: WHISPER_MODEL_URL,
|
|
522
|
+
destination: WHISPER_MODEL_PATH,
|
|
523
|
+
partial: WHISPER_MODEL_PARTIAL,
|
|
524
|
+
expectedBytes: WHISPER_MODEL_EXPECTED_BYTES,
|
|
525
|
+
timeoutMs: 3_600_000,
|
|
526
|
+
})
|
|
478
527
|
localVoiceReady = true
|
|
479
528
|
console.log(green(' ✓') + ' Model downloaded ' + dim('— voice = local (FREE)'))
|
|
480
529
|
} catch (err) {
|
|
481
|
-
try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {}
|
|
482
530
|
console.log(red(' ✗') + ' Model download failed ' + dim('— local voice unavailable'))
|
|
483
531
|
console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
|
|
532
|
+
console.log(' ' + dim('Partial download retained; rerun setup to resume it.'))
|
|
533
|
+
if (SETUP_TRANSCRIPTION) transcriptionSetupFailures.push('Large-v3-Turbo commit model')
|
|
484
534
|
}
|
|
485
535
|
}
|
|
486
536
|
} else {
|
|
@@ -500,23 +550,25 @@ if (whisperCliPath && wantsSmallPreview) {
|
|
|
500
550
|
console.log(green(' ✓') + ' Small.en preview model ready ' + dim('— Turbo remains authoritative'))
|
|
501
551
|
} else {
|
|
502
552
|
if (existsSync(WHISPER_SMALL_MODEL_PATH)) { try { unlinkSync(WHISPER_SMALL_MODEL_PATH) } catch {} }
|
|
503
|
-
if (existsSync(WHISPER_SMALL_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_SMALL_MODEL_PARTIAL) } catch {} }
|
|
504
553
|
console.log(yellow(' ⚠') + ' Adaptive preview model missing')
|
|
505
554
|
console.log(' ' + dim('Downloading ggml-small.en (~466 MB).'))
|
|
506
555
|
if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
|
|
507
556
|
console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — previews use Turbo until Small.en is provisioned')
|
|
508
557
|
} else {
|
|
509
558
|
try {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
559
|
+
downloadWhisperModel({
|
|
560
|
+
url: WHISPER_SMALL_MODEL_URL,
|
|
561
|
+
destination: WHISPER_SMALL_MODEL_PATH,
|
|
562
|
+
partial: WHISPER_SMALL_MODEL_PARTIAL,
|
|
563
|
+
expectedBytes: WHISPER_SMALL_MODEL_EXPECTED_BYTES,
|
|
564
|
+
timeoutMs: 3_600_000,
|
|
565
|
+
})
|
|
515
566
|
console.log(green(' ✓') + ' Small.en preview model downloaded ' + dim('— Turbo commit unchanged'))
|
|
516
567
|
} catch (err) {
|
|
517
|
-
try { unlinkSync(WHISPER_SMALL_MODEL_PARTIAL) } catch {}
|
|
518
568
|
console.log(red(' ✗') + ' Small.en download failed ' + dim('— previews safely fall back to Turbo'))
|
|
519
569
|
console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
|
|
570
|
+
console.log(' ' + dim('Partial download retained; rerun setup to resume it.'))
|
|
571
|
+
transcriptionSetupFailures.push('Small.en preview model')
|
|
520
572
|
}
|
|
521
573
|
}
|
|
522
574
|
}
|
|
@@ -524,26 +576,31 @@ if (whisperCliPath && wantsSmallPreview) {
|
|
|
524
576
|
|
|
525
577
|
if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
526
578
|
if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
|
|
527
|
-
|
|
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}`))
|
|
528
583
|
} else {
|
|
529
584
|
if (existsSync(WHISPER_LARGE_MODEL_PATH)) { try { unlinkSync(WHISPER_LARGE_MODEL_PATH) } catch {} }
|
|
530
|
-
|
|
531
|
-
console.log(yellow(' ⚠') + ' HQ polish model missing')
|
|
585
|
+
console.log(yellow(' ⚠') + ' Large-v3 model missing')
|
|
532
586
|
console.log(' ' + dim('Downloading ggml-large-v3 (~3.1 GB).'))
|
|
533
587
|
if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
|
|
534
588
|
console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — HQ safely reports unavailable and live Turbo continues')
|
|
535
589
|
} else {
|
|
536
590
|
try {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
591
|
+
downloadWhisperModel({
|
|
592
|
+
url: WHISPER_LARGE_MODEL_URL,
|
|
593
|
+
destination: WHISPER_LARGE_MODEL_PATH,
|
|
594
|
+
partial: WHISPER_LARGE_MODEL_PARTIAL,
|
|
595
|
+
expectedBytes: WHISPER_LARGE_MODEL_EXPECTED_BYTES,
|
|
596
|
+
timeoutMs: 7_200_000,
|
|
597
|
+
})
|
|
598
|
+
console.log(green(' ✓') + ' Large-v3 model downloaded')
|
|
543
599
|
} catch (err) {
|
|
544
|
-
try { unlinkSync(WHISPER_LARGE_MODEL_PARTIAL) } catch {}
|
|
545
600
|
console.log(red(' ✗') + ' Large-v3 download failed ' + dim('— live Turbo remains available'))
|
|
546
601
|
console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
|
|
602
|
+
console.log(' ' + dim('Partial download retained; rerun setup to resume it.'))
|
|
603
|
+
transcriptionSetupFailures.push(TRANSCRIPTION_TIER === 'max' ? 'Large-v3 Max/HQ model' : 'Large-v3 HQ model')
|
|
547
604
|
}
|
|
548
605
|
}
|
|
549
606
|
}
|
|
@@ -551,7 +608,14 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
|
|
|
551
608
|
|
|
552
609
|
if (PREPARE_ONLY && SETUP_TRANSCRIPTION) {
|
|
553
610
|
console.log('')
|
|
554
|
-
|
|
611
|
+
if (transcriptionSetupFailures.length > 0) {
|
|
612
|
+
console.log(red(' ✗ Transcription setup incomplete'))
|
|
613
|
+
console.log(' Missing: ' + transcriptionSetupFailures.join(', '))
|
|
614
|
+
console.log(' Rerun this command; retained downloads resume from the last byte received.')
|
|
615
|
+
console.log('')
|
|
616
|
+
process.exit(1)
|
|
617
|
+
}
|
|
618
|
+
console.log(green(' ✓ Transcription setup complete'))
|
|
555
619
|
console.log(' Return to COS Control and Restart, install, or update the managed server.')
|
|
556
620
|
console.log('')
|
|
557
621
|
process.exit(0)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.21.0",
|
|
4
|
+
"description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
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)
|
|
@@ -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
|
}
|