@gotcos/glasses-server 6.71.13 → 6.72.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.
@@ -0,0 +1,225 @@
1
+ // Where the STT settings meet the decoders (6.72.0): what this Mac can run,
2
+ // the live quiet gate and ASR front end, and the meeting preview dispatch.
3
+
4
+ import { existsSync } from 'node:fs'
5
+ import {
6
+ applyCorrections,
7
+ getHighQualityTranscriptionCapability,
8
+ getWhisperCommitCapability,
9
+ WHISPER_TURBO_MODEL_PATH,
10
+ } from './whisper-local.js'
11
+ import { transcribeWhisperMeetingPreview } from './whisper-preview.js'
12
+ import { asrFrontEnd } from './stt-live.js'
13
+ import {
14
+ getInstallProgress, parakeetInstalled, parakeetInstallRunning, parakeetWarmState, pinnedCliKnown,
15
+ PARAKEET_VERSION_LABEL, transcribeParakeet, warmParakeet, type ParakeetModelId,
16
+ } from './parakeet.js'
17
+ import {
18
+ effectiveStt, readSttSettings, type EffectiveStt, type FrontEndId, type ParakeetStatus,
19
+ type SttCapabilities,
20
+ } from './stt-settings.js'
21
+
22
+ /** The live preview's Parakeet model. Ultra over v2 on the live-lane evidence
23
+ * (docs/stt-672-evaluation.md): lower WER, and v2 put text on 3 of 40 clean
24
+ * non-speech units where Ultra put none. */
25
+ export const PREVIEW_PARAKEET_MODEL: ParakeetModelId = 'parakeet-ultra'
26
+ /** The phone gives a preview 1.8 s end to end. Parakeet gets this long... */
27
+ export const PARAKEET_PREVIEW_TIMEOUT_MS = 900
28
+ /** ...and a miss falls back to Turbo only while this much of the budget is left unused. */
29
+ export const PREVIEW_TURBO_FALLBACK_BEFORE_MS = 900
30
+ /** Consecutive Parakeet preview misses (not busy lanes) that open the breaker. */
31
+ export const PREVIEW_BREAKER_MISSES = 3
32
+ export const PREVIEW_BREAKER_BASE_MS = 60_000
33
+ export const PREVIEW_BREAKER_MAX_MS = 10 * 60_000
34
+
35
+ // ── Recording probe (registered by the server; avoids an import cycle) ───────
36
+ let recordingLiveProbe: () => boolean = () => false
37
+ export function setRecordingLiveProbe(probe: () => boolean): void { recordingLiveProbe = probe }
38
+ function recordingLive(): boolean { try { return recordingLiveProbe() } catch { return true } }
39
+
40
+ // ── Preview breaker and counters ─────────────────────────────────────────────
41
+ const previewStats = { parakeet: 0, parakeetMisses: 0, turboFallbacks: 0, turbo: 0, nonTurboWithheld: 0, empty: 0 }
42
+ let breaker = { misses: 0, openUntil: 0, backoffMs: PREVIEW_BREAKER_BASE_MS }
43
+
44
+ export function previewBreakerState(now = Date.now()): { open: boolean; retryAt: string | null; misses: number } {
45
+ const open = now < breaker.openUntil
46
+ return { open, retryAt: open ? new Date(breaker.openUntil).toISOString() : null, misses: breaker.misses }
47
+ }
48
+ export function previewCounters(): typeof previewStats { return { ...previewStats } }
49
+
50
+ function notePreviewMiss(now = Date.now()): void {
51
+ previewStats.parakeetMisses++
52
+ breaker.misses++
53
+ if (breaker.misses >= PREVIEW_BREAKER_MISSES) {
54
+ breaker.openUntil = now + breaker.backoffMs
55
+ console.warn(`[stt] Parakeet preview missed ${breaker.misses} in a row; Turbo previews for ${Math.round(breaker.backoffMs / 1000)} s`)
56
+ breaker.backoffMs = Math.min(breaker.backoffMs * 2, PREVIEW_BREAKER_MAX_MS)
57
+ breaker.misses = 0
58
+ }
59
+ }
60
+ function notePreviewHit(): void { breaker = { misses: 0, openUntil: 0, backoffMs: PREVIEW_BREAKER_BASE_MS } }
61
+
62
+ export function __resetPreviewForTests(): void {
63
+ for (const k of Object.keys(previewStats) as Array<keyof typeof previewStats>) previewStats[k] = 0
64
+ breaker = { misses: 0, openUntil: 0, backoffMs: PREVIEW_BREAKER_BASE_MS }
65
+ recordingLiveProbe = () => false
66
+ }
67
+
68
+ export function currentSttCapabilities(): SttCapabilities {
69
+ const commit = getWhisperCommitCapability()
70
+ const hq = getHighQualityTranscriptionCapability()
71
+ const installed = parakeetInstalled().installed
72
+ const warm = parakeetWarmState(PREVIEW_PARAKEET_MODEL).state
73
+ return {
74
+ turboModel: existsSync(WHISPER_TURBO_MODEL_PATH),
75
+ liveLargeV3: commit.effectiveModel === 'large-v3',
76
+ hqLargeV3: hq.hqAvailable && hq.model === 'large-v3',
77
+ parakeetInstalled: installed,
78
+ parakeetPreviewWarm: installed && warm === 'warm',
79
+ parakeetPreviewWarming: installed && warm === 'warming',
80
+ parakeetPreviewWarmFailed: installed && warm === 'failed',
81
+ parakeetPreviewBreakerOpen: installed && previewBreakerState().open,
82
+ previewDisabledByEnv: process.env.COS_WHISPER_MEETING_PREVIEW === '0',
83
+ }
84
+ }
85
+
86
+ export function currentEffectiveStt(): EffectiveStt {
87
+ return effectiveStt(readSttSettings(), currentSttCapabilities())
88
+ }
89
+
90
+ export function parakeetStatus(live: boolean): ParakeetStatus {
91
+ const installed = parakeetInstalled()
92
+ const progress = getInstallProgress()
93
+ const running = parakeetInstallRunning()
94
+ const sandbox = existsSync('/usr/bin/sandbox-exec')
95
+ const appleSilicon = process.platform === 'darwin' && process.arch === 'arm64'
96
+ // A button that can never succeed is never offered: the pinned CLI must be on this Mac.
97
+ const cli = appleSilicon ? pinnedCliKnown() : false
98
+ const installable = !installed.installed && !running && sandbox && appleSilicon && cli === true
99
+ const warm = parakeetWarmState(PREVIEW_PARAKEET_MODEL)
100
+ const note = installed.installed
101
+ ? 'Installed. Runs offline on the Neural Engine.'
102
+ : running
103
+ ? 'Installing.'
104
+ : !sandbox || !appleSilicon
105
+ ? 'Parakeet needs an Apple silicon Mac.'
106
+ : cli === false
107
+ ? 'Parakeet needs FluidAudio fluidaudiocli 0.17.4 at ~/.cos-glasses/bin (the speaker-separation tool); this Mac does not have that build.'
108
+ : cli === null
109
+ ? 'Checking this Mac for the FluidAudio CLI.'
110
+ : live
111
+ ? 'Install after the live recording is saved.'
112
+ : 'About 654 MB. Installs the pinned model and checks every file.'
113
+ return {
114
+ installed: installed.installed,
115
+ installable,
116
+ version: PARAKEET_VERSION_LABEL,
117
+ bytes: installed.installed ? installed.bytes : progress.bytesDone,
118
+ progress: { ...progress, state: installed.installed && !running ? 'installed' : progress.state },
119
+ warm: { preview: warm.state, ...(warm.error ? { error: warm.error } : {}), ...(warm.at ? { at: new Date(warm.at).toISOString() } : {}) },
120
+ preview: { breaker: previewBreakerState(), counters: previewCounters() },
121
+ note,
122
+ }
123
+ }
124
+
125
+ /** A failed warm-up is retried at most this often, never per chunk. */
126
+ export const WARM_RETRY_MS = 120_000
127
+
128
+ /**
129
+ * Start warming the preview model when it is selected; never awaited by a
130
+ * chunk. Deferred while a recording is live (the first CoreML compile is 12 to
131
+ * 25 s of Neural Engine work next to the live diarizer); a timer retries.
132
+ */
133
+ export function ensurePreviewWarm(now = Date.now()): 'started' | 'skipped' | 'deferred' {
134
+ const settings = readSttSettings()
135
+ if (settings.preview !== 'parakeet' || !parakeetInstalled().installed) return 'skipped'
136
+ const w = parakeetWarmState(PREVIEW_PARAKEET_MODEL)
137
+ if (w.state === 'warm' || w.state === 'warming') return 'skipped'
138
+ if (w.state === 'failed' && now - w.at < WARM_RETRY_MS) return 'skipped'
139
+ if (recordingLive()) return 'deferred'
140
+ void warmParakeet(PREVIEW_PARAKEET_MODEL).then(ok => {
141
+ console.log(`[stt] Parakeet preview ${ok ? 'warm' : 'not warm'}: ${JSON.stringify(parakeetWarmState(PREVIEW_PARAKEET_MODEL))}`)
142
+ })
143
+ return 'started'
144
+ }
145
+
146
+ let warmTimer: ReturnType<typeof setInterval> | null = null
147
+ /** Server start: one try now, then every minute (a no-op unless Parakeet preview is selected and cold). */
148
+ export function startPreviewWarmLoop(): void {
149
+ ensurePreviewWarm()
150
+ if (warmTimer) return
151
+ warmTimer = setInterval(() => { try { ensurePreviewWarm() } catch { /* next tick */ } }, 60_000)
152
+ warmTimer.unref?.()
153
+ }
154
+ export function stopPreviewWarmLoop(): void { if (warmTimer) clearInterval(warmTimer); warmTimer = null }
155
+
156
+ // ── Meeting preview ──────────────────────────────────────────────────────────
157
+
158
+ export interface PreviewResult {
159
+ text: string
160
+ model: string
161
+ backend: string
162
+ }
163
+
164
+ export interface PreviewDeps {
165
+ effective: () => EffectiveStt
166
+ turbo: (audio: Buffer) => Promise<{ text: string; model: string; backend: string } | null>
167
+ parakeet: (audio: Buffer) => Promise<{ text: string } | null>
168
+ frontEnd: (audio: Buffer, frontEnd: FrontEndId) => Promise<Buffer>
169
+ now?: () => number
170
+ }
171
+
172
+ export const defaultPreviewDeps: PreviewDeps = {
173
+ effective: () => currentEffectiveStt(),
174
+ turbo: audio => transcribeWhisperMeetingPreview(audio),
175
+ parakeet: async audio => {
176
+ const result = await transcribeParakeet(audio, { model: PREVIEW_PARAKEET_MODEL, lane: 'preview', timeoutMs: PARAKEET_PREVIEW_TIMEOUT_MS })
177
+ return { text: applyCorrections(result.text) }
178
+ },
179
+ frontEnd: (audio, fe) => asrFrontEnd(audio, fe),
180
+ }
181
+
182
+ /**
183
+ * Cosmetic preview for the still-open phrase. Never durable, never attributed.
184
+ *
185
+ * `acceptedModels` is what the client says it accepts (`previewModels`). A
186
+ * client that lists nothing (every phone before 6.10.639) gets Whisper Turbo
187
+ * whatever the setting, so a preview it would reject is never sent. A
188
+ * Parakeet miss falls back to Turbo only while the phone's 1.8 s budget allows
189
+ * (PREVIEW_TURBO_FALLBACK_BEFORE_MS); otherwise it is a quiet miss (204).
190
+ * The Parakeet preview hears the selected ASR front end (highpass by default);
191
+ * the Whisper previews keep raw audio, as before 6.72 (highpass measured
192
+ * slightly worse for Turbo).
193
+ */
194
+ export async function transcribeMeetingPreview(audio: Buffer, acceptedModels: readonly string[] = [], deps: PreviewDeps = defaultPreviewDeps): Promise<PreviewResult | null> {
195
+ const now = deps.now ?? (() => performance.now())
196
+ const started = now()
197
+ let eff: EffectiveStt | null = null
198
+ try { eff = deps.effective() } catch (error) {
199
+ console.warn(`[stt] preview settings unreadable, using Turbo: ${error instanceof Error ? error.message : error}`)
200
+ }
201
+ const turbo = async () => {
202
+ previewStats.turbo++
203
+ const t = await deps.turbo(audio)
204
+ return t ? { text: t.text, model: t.model, backend: t.backend } : null
205
+ }
206
+ if (!eff) return turbo()
207
+ if (eff.settings.preview === 'parakeet' && eff.preview.engine !== 'parakeet') ensurePreviewWarm()
208
+ if (eff.preview.engine === 'parakeet') {
209
+ if (!acceptedModels.includes(PREVIEW_PARAKEET_MODEL)) {
210
+ previewStats.nonTurboWithheld++
211
+ return turbo()
212
+ }
213
+ try {
214
+ const r = await deps.parakeet(await deps.frontEnd(audio, eff.frontEnd))
215
+ if (r) { notePreviewHit(); previewStats.parakeet++; return { text: r.text, model: PREVIEW_PARAKEET_MODEL, backend: 'fluidaudio-coreml' } }
216
+ } catch (error) {
217
+ const busy = (error as { reason?: string })?.reason === 'busy'
218
+ if (!busy) notePreviewMiss()
219
+ console.warn(`[stt] Parakeet preview missed${busy ? ' (busy)' : ''}: ${error instanceof Error ? error.message : error}`)
220
+ }
221
+ if (now() - started >= PREVIEW_TURBO_FALLBACK_BEFORE_MS) { previewStats.empty++; return null }
222
+ previewStats.turboFallbacks++
223
+ }
224
+ return turbo()
225
+ }
@@ -0,0 +1,342 @@
1
+ // Speech-to-text settings (6.72.0): which engine previews live speech, which
2
+ // engine writes the saved meeting (HQ), which engine gives HQ a second opinion,
3
+ // whether the resolver may use it, the ASR input front end and the quiet gate.
4
+ //
5
+ // Stored in <data>/stt-settings.json (atomic). Read on every use with an mtime
6
+ // check, so a change applies without a restart: the preview, front end and
7
+ // quiet gate from the next live chunk; HQ, second opinion and resolver from the
8
+ // next saved meeting (an HQ job snapshots the settings when it starts and never
9
+ // switches engines mid-job).
10
+ //
11
+ // Defaults and the evidence behind them: docs/stt-672-evaluation.md.
12
+
13
+ import { readFileSync, statSync } from 'node:fs'
14
+ import { atomicWriteFileSync } from './atomic-fs.js'
15
+ import { dataPath } from './data-dir.js'
16
+
17
+ export const PREVIEW_IDS = ['turbo', 'large-v3', 'parakeet', 'nemotron-560'] as const
18
+ export const HQ_IDS = ['large-v3', 'large-v3-legacy', 'parakeet-ultra'] as const
19
+ export const SECOND_OPINION_IDS = ['off', 'parakeet-ultra', 'large-v3'] as const
20
+ export const FRONT_END_IDS = ['highpass', 'full'] as const
21
+ export const QUIET_GATE_IDS = ['silero', 'rms'] as const
22
+
23
+ export type PreviewId = typeof PREVIEW_IDS[number]
24
+ export type HqId = typeof HQ_IDS[number]
25
+ export type SecondOpinionId = typeof SECOND_OPINION_IDS[number]
26
+ export type FrontEndId = typeof FRONT_END_IDS[number]
27
+ export type QuietGateId = typeof QUIET_GATE_IDS[number]
28
+
29
+ export interface SttSettings {
30
+ preview: PreviewId
31
+ hq: HqId
32
+ secondOpinion: SecondOpinionId
33
+ resolver: boolean
34
+ frontEnd: FrontEndId
35
+ quietGate: QuietGateId
36
+ }
37
+
38
+ /** Items 1 to 3 on. Second opinion off and the resolver off: on the shipped
39
+ * path (production 30 s windows, docs/stt-672-evaluation.md) neither
40
+ * resolver configuration passed the adoption rule (Large-v3 + Ultra -1.6% WER,
41
+ * CI -4.7 to +1.1), so Large-v3 alone writes the meeting. Parakeet Ultra stays
42
+ * installable for the preview and as the HQ primary. */
43
+ export const DEFAULT_STT_SETTINGS: Readonly<SttSettings> = Object.freeze({
44
+ preview: 'turbo',
45
+ hq: 'large-v3',
46
+ secondOpinion: 'off',
47
+ resolver: false,
48
+ frontEnd: 'highpass',
49
+ quietGate: 'silero',
50
+ })
51
+
52
+ export const STT_SETTINGS_FILE = 'stt-settings.json'
53
+ const settingsPath = () => dataPath(STT_SETTINGS_FILE)
54
+
55
+ function pick<T extends string>(ids: readonly T[], value: unknown, fallback: T): T {
56
+ return typeof value === 'string' && (ids as readonly string[]).includes(value) ? value as T : fallback
57
+ }
58
+
59
+ /** Unknown or damaged stored values fall back to the default, key by key. */
60
+ export function normalizeStoredSettings(raw: unknown): SttSettings {
61
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
62
+ return {
63
+ preview: pick(PREVIEW_IDS, o.preview, DEFAULT_STT_SETTINGS.preview),
64
+ hq: pick(HQ_IDS, o.hq, DEFAULT_STT_SETTINGS.hq),
65
+ secondOpinion: pick(SECOND_OPINION_IDS, o.secondOpinion, DEFAULT_STT_SETTINGS.secondOpinion),
66
+ resolver: typeof o.resolver === 'boolean' ? o.resolver : DEFAULT_STT_SETTINGS.resolver,
67
+ frontEnd: pick(FRONT_END_IDS, o.frontEnd, DEFAULT_STT_SETTINGS.frontEnd),
68
+ quietGate: pick(QUIET_GATE_IDS, o.quietGate, DEFAULT_STT_SETTINGS.quietGate),
69
+ }
70
+ }
71
+
72
+ let cache: { mtimeMs: number; size: number; value: SttSettings } | null = null
73
+ let warnedCorrupt = false
74
+
75
+ /** Stored settings (no capability check). Cheap enough for every live chunk. */
76
+ export function readSttSettings(): SttSettings {
77
+ const path = settingsPath()
78
+ let stat: { mtimeMs: number; size: number } | null = null
79
+ try { const s = statSync(path); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { stat = null }
80
+ if (!stat) { cache = null; return { ...DEFAULT_STT_SETTINGS } }
81
+ if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return { ...cache.value }
82
+ let value: SttSettings
83
+ try { value = normalizeStoredSettings(JSON.parse(readFileSync(path, 'utf8'))) } catch (error) {
84
+ // Unreadable settings run the defaults; say so once, not on every chunk.
85
+ if (!warnedCorrupt) { warnedCorrupt = true; console.warn(`[stt] ${STT_SETTINGS_FILE} is unreadable (${error instanceof Error ? error.message : error}); using the defaults until it is rewritten`) }
86
+ value = { ...DEFAULT_STT_SETTINGS }
87
+ }
88
+ cache = { ...stat, value }
89
+ return { ...value }
90
+ }
91
+
92
+ export function writeSttSettings(next: SttSettings): void {
93
+ atomicWriteFileSync(settingsPath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 })
94
+ cache = null
95
+ }
96
+
97
+ export function __resetSttSettingsCacheForTests(): void { cache = null; warnedCorrupt = false }
98
+
99
+ // ── Last HQ job (for GET: what actually happened, not only what was chosen) ──
100
+
101
+ export interface LastSttJob {
102
+ at: string
103
+ meeting: string
104
+ outcome: 'batch' | 'rejected' | 'failed'
105
+ summary: unknown
106
+ }
107
+ const LAST_JOB_FILE = 'stt-last-job.json'
108
+
109
+ export function recordLastSttJob(job: LastSttJob): void {
110
+ try { atomicWriteFileSync(dataPath(LAST_JOB_FILE), JSON.stringify(job, null, 2) + '\n', { mode: 0o600 }) } catch { /* telemetry only */ }
111
+ }
112
+
113
+ export function readLastSttJob(): LastSttJob | null {
114
+ try { return JSON.parse(readFileSync(dataPath(LAST_JOB_FILE), 'utf8')) as LastSttJob } catch { return null }
115
+ }
116
+
117
+ // ── Capabilities ──────────────────────────────────────────────────────────────
118
+
119
+ export interface SttCapabilities {
120
+ turboModel: boolean
121
+ /** The live commit worker runs Large-v3 (the Max tier). */
122
+ liveLargeV3: boolean
123
+ /** whisper-cli plus Large-v3 weights for meeting HQ. */
124
+ hqLargeV3: boolean
125
+ parakeetInstalled: boolean
126
+ parakeetPreviewWarm: boolean
127
+ parakeetPreviewWarming: boolean
128
+ /** The warm-up ran and failed (distinct from still warming). */
129
+ parakeetPreviewWarmFailed?: boolean
130
+ /** Repeated preview misses opened the preview breaker: Turbo until it closes. */
131
+ parakeetPreviewBreakerOpen?: boolean
132
+ previewDisabledByEnv: boolean
133
+ }
134
+
135
+ export interface SttOption {
136
+ id: string
137
+ label: string
138
+ available: boolean
139
+ experimental: boolean
140
+ note: string
141
+ }
142
+
143
+ /** Every preview `model` string this server can return (contract-tested;
144
+ * a subset of what phone 6.10.639 accepts). */
145
+ export const PREVIEW_MODEL_STRINGS = ['large-v3-turbo', 'parakeet-ultra'] as const
146
+ /** The request parameter a client uses to say which preview models it accepts. */
147
+ export const PREVIEW_MODELS_PARAM = 'previewModels'
148
+ export const PREVIEW_REQUIRES_APP = '6.10.639'
149
+
150
+ export function previewOptions(c: SttCapabilities): SttOption[] {
151
+ const parakeetNote = !c.parakeetInstalled
152
+ ? 'Install Parakeet on this Mac first.'
153
+ : c.parakeetPreviewBreakerOpen
154
+ ? 'Parakeet missed several previews in a row; Whisper Turbo previews for a few minutes, then Parakeet is tried again.'
155
+ : c.parakeetPreviewWarm
156
+ ? 'Parakeet Ultra on the Neural Engine, about 0.3 s per chunk. Experimental: compare it live yourself. Needs COS Glasses 6.10.639; older phones keep Whisper Turbo.'
157
+ : c.parakeetPreviewWarmFailed
158
+ ? 'Parakeet could not start; Whisper Turbo previews. It is tried again every 2 minutes.'
159
+ : c.parakeetPreviewWarming
160
+ ? 'Warming up (the first load takes 15 to 25 s). Whisper Turbo previews until it is ready.'
161
+ : 'Warms up when selected and no recording is live. Whisper Turbo previews until it is ready.'
162
+ return [
163
+ { id: 'turbo', label: 'Whisper Turbo', available: c.turboModel && !c.previewDisabledByEnv, experimental: false,
164
+ note: c.previewDisabledByEnv ? 'Meeting preview is turned off on this server (COS_WHISPER_MEETING_PREVIEW=0).' : c.turboModel ? 'Fast provisional text. Saved text never comes from the preview.' : 'Turbo weights are missing on this Mac.' },
165
+ { id: 'large-v3', label: 'Whisper Large-v3', available: false, experimental: false,
166
+ note: 'Not offered: a Large-v3 preview would share the live Large-v3 worker that writes the saved text.' },
167
+ { id: 'parakeet', label: 'Parakeet Ultra', available: c.parakeetInstalled && !c.previewDisabledByEnv, experimental: true, note: parakeetNote },
168
+ { id: 'nemotron-560', label: 'Nemotron streaming 560 ms', available: false, experimental: true,
169
+ note: 'Not wired in this server: FluidAudio 0.17.4 runs Nemotron only as a whole-file batch, so it cannot stream a live preview yet.' },
170
+ ]
171
+ }
172
+
173
+ export function hqOptions(c: SttCapabilities): SttOption[] {
174
+ const lv3 = c.hqLargeV3 ? '' : 'Needs whisper-cli and the Large-v3 weights. '
175
+ return [
176
+ { id: 'large-v3', label: 'Whisper Large-v3', available: c.hqLargeV3, experimental: false, note: `${lv3}Word timestamps on, mapped through Whisper's own speech segments.`.trim() },
177
+ { id: 'large-v3-legacy', label: 'Whisper Large-v3 (legacy decoder)', available: c.hqLargeV3, experimental: false, note: `${lv3}The pre-6.72 decoder without timestamps. Kept for rollback.`.trim() },
178
+ { id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: true,
179
+ note: c.parakeetInstalled ? 'Lower silver WER than Large-v3, weaker on names; see the evaluation.' : 'Install Parakeet on this Mac first.' },
180
+ ]
181
+ }
182
+
183
+ export function secondOpinionOptions(c: SttCapabilities): SttOption[] {
184
+ return [
185
+ { id: 'off', label: 'Off', available: true, experimental: false, note: 'One engine only.' },
186
+ { id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: false,
187
+ note: c.parakeetInstalled ? 'Hears every saved meeting again on the Neural Engine, about 0.6 s per 30 s of audio, after the meeting is saved. Disagreements are saved with the meeting.' : 'Install Parakeet on this Mac first.' },
188
+ { id: 'large-v3', label: 'Whisper Large-v3', available: c.hqLargeV3, experimental: false, note: 'For a Parakeet HQ model. Adds a full Large-v3 pass to every saved meeting: about 0.2x the audio length on Metal, about 1x on a busy CPU.' },
189
+ ]
190
+ }
191
+
192
+ const FRONT_END_OPTIONS: SttOption[] = [
193
+ { id: 'highpass', label: 'Highpass only', available: true, experimental: false, note: '80 Hz highpass. Does not lift faint background speech over the speech detector.' },
194
+ { id: 'full', label: 'Full chain', available: true, experimental: false, note: 'Highpass, noise reduction and loudness normalization (before 6.72).' },
195
+ ]
196
+ const QUIET_GATE_OPTIONS: SttOption[] = [
197
+ { id: 'silero', label: 'Speech detector', available: true, experimental: false, note: 'A chunk with under 0.5 s of detected speech is treated as quiet.' },
198
+ { id: 'rms', label: 'Volume threshold', available: true, experimental: false, note: 'RMS below 150 (before 6.72). Rarely fires on G2 audio.' },
199
+ ]
200
+
201
+ /** The engine family a choice decodes with; a second opinion must differ from HQ. */
202
+ export function engineFamily(id: HqId | SecondOpinionId): 'whisper' | 'parakeet' | 'off' {
203
+ return id === 'off' ? 'off' : id.startsWith('parakeet') ? 'parakeet' : 'whisper'
204
+ }
205
+
206
+ export class SttSettingsError extends Error {
207
+ constructor(message: string) { super(message); this.name = 'SttSettingsError' }
208
+ }
209
+
210
+ const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate'] as const
211
+
212
+ /**
213
+ * Apply a partial PUT. Throws SttSettingsError with a plain sentence for an
214
+ * unknown key, an unknown id, an unavailable choice or a conflict.
215
+ * Changing HQ onto the second opinion's engine moves the second opinion to the
216
+ * other engine (unless the same request sets it), so one tap never fails.
217
+ */
218
+ export function applySettingsPatch(current: SttSettings, body: unknown, c: SttCapabilities): SttSettings {
219
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new SttSettingsError('Send a JSON object, for example {"hq":"large-v3"}.')
220
+ const patch = body as Record<string, unknown>
221
+ const unknown = Object.keys(patch).filter(k => !(KEYS as readonly string[]).includes(k))
222
+ if (unknown.length) throw new SttSettingsError(`Unknown setting: ${unknown.join(', ')}.`)
223
+ const next = { ...current }
224
+ const choose = <T extends string>(key: string, ids: readonly T[], options: SttOption[], label: string): T | undefined => {
225
+ if (!(key in patch)) return undefined
226
+ const value = patch[key]
227
+ if (typeof value !== 'string' || !(ids as readonly string[]).includes(value)) {
228
+ throw new SttSettingsError(`${label} must be one of: ${ids.join(', ')}.`)
229
+ }
230
+ const option = options.find(o => o.id === value)
231
+ if (option && !option.available) throw new SttSettingsError(`${option.label} is not available: ${option.note}`)
232
+ return value as T
233
+ }
234
+ const preview = choose('preview', PREVIEW_IDS, previewOptions(c), 'Preview model')
235
+ const hq = choose('hq', HQ_IDS, hqOptions(c), 'HQ model')
236
+ const second = choose('secondOpinion', SECOND_OPINION_IDS, secondOpinionOptions(c), 'Second opinion')
237
+ const frontEnd = choose('frontEnd', FRONT_END_IDS, FRONT_END_OPTIONS, 'Front end')
238
+ const quietGate = choose('quietGate', QUIET_GATE_IDS, QUIET_GATE_OPTIONS, 'Quiet gate')
239
+ if ('resolver' in patch && typeof patch.resolver !== 'boolean') throw new SttSettingsError('resolver must be true or false.')
240
+ if (preview) next.preview = preview
241
+ if (hq) next.hq = hq
242
+ if (second) next.secondOpinion = second
243
+ if (frontEnd) next.frontEnd = frontEnd
244
+ if (quietGate) next.quietGate = quietGate
245
+ if (typeof patch.resolver === 'boolean') next.resolver = patch.resolver
246
+ if (engineFamily(next.hq) === engineFamily(next.secondOpinion)) {
247
+ if (second) throw new SttSettingsError('The second opinion must be a different engine from the HQ model.')
248
+ const other: SecondOpinionId = engineFamily(next.hq) === 'parakeet' ? 'large-v3' : 'parakeet-ultra'
249
+ const otherOk = secondOpinionOptions(c).find(o => o.id === other)?.available
250
+ next.secondOpinion = otherOk ? other : 'off'
251
+ }
252
+ return next
253
+ }
254
+
255
+ // ── Effective choices ─────────────────────────────────────────────────────────
256
+
257
+ export interface EffectiveStt {
258
+ settings: SttSettings
259
+ preview: { engine: 'turbo' | 'large-v3' | 'parakeet'; reason: string | null }
260
+ hq: { engine: HqId; reason: string | null }
261
+ secondOpinion: { engine: SecondOpinionId; reason: string | null }
262
+ resolver: boolean
263
+ frontEnd: FrontEndId
264
+ quietGate: QuietGateId
265
+ }
266
+
267
+ export const REASON_TEXT: Record<string, string> = {
268
+ parakeet_not_installed: 'Parakeet is not installed on this Mac.',
269
+ parakeet_warming: 'Parakeet is warming up.',
270
+ parakeet_warm_failed: 'Parakeet could not start; using Turbo.',
271
+ parakeet_breaker_open: 'Parakeet missed several previews; using Turbo for a few minutes.',
272
+ large_v3_preview_not_offered: 'A Large-v3 preview is not offered; using Turbo.',
273
+ nemotron_not_wired: 'Nemotron preview is not available in this server; using Turbo.',
274
+ large_v3_unavailable: 'Large-v3 is not available on this Mac.',
275
+ same_engine_as_hq: 'Same engine as the HQ model.',
276
+ no_second_opinion: 'No second opinion is running, so nothing to refine with.',
277
+ }
278
+
279
+ /** What will actually run, given what is installed. A stored choice that is no
280
+ * longer possible falls back (preview to Turbo, HQ to Large-v3, second opinion
281
+ * to off) and says why with a code. */
282
+ export function effectiveStt(settings: SttSettings, c: SttCapabilities): EffectiveStt {
283
+ let preview: EffectiveStt['preview'] = { engine: 'turbo', reason: null }
284
+ if (settings.preview === 'large-v3') preview = { engine: 'turbo', reason: 'large_v3_preview_not_offered' }
285
+ else if (settings.preview === 'parakeet') {
286
+ preview = !c.parakeetInstalled ? { engine: 'turbo', reason: 'parakeet_not_installed' }
287
+ : c.parakeetPreviewBreakerOpen ? { engine: 'turbo', reason: 'parakeet_breaker_open' }
288
+ : c.parakeetPreviewWarm ? { engine: 'parakeet', reason: null }
289
+ : c.parakeetPreviewWarmFailed ? { engine: 'turbo', reason: 'parakeet_warm_failed' }
290
+ : { engine: 'turbo', reason: 'parakeet_warming' }
291
+ } else if (settings.preview === 'nemotron-560') preview = { engine: 'turbo', reason: 'nemotron_not_wired' }
292
+ let hq: EffectiveStt['hq'] = { engine: settings.hq, reason: null }
293
+ if (settings.hq === 'parakeet-ultra' && !c.parakeetInstalled) hq = { engine: 'large-v3', reason: 'parakeet_not_installed' }
294
+ let second: EffectiveStt['secondOpinion'] = { engine: settings.secondOpinion, reason: null }
295
+ if (engineFamily(second.engine) === 'parakeet' && !c.parakeetInstalled) second = { engine: 'off', reason: 'parakeet_not_installed' }
296
+ if (second.engine === 'large-v3' && !c.hqLargeV3) second = { engine: 'off', reason: 'large_v3_unavailable' }
297
+ if (second.engine !== 'off' && engineFamily(second.engine) === engineFamily(hq.engine)) second = { engine: 'off', reason: 'same_engine_as_hq' }
298
+ return {
299
+ settings,
300
+ preview,
301
+ hq,
302
+ secondOpinion: second,
303
+ resolver: settings.resolver && second.engine !== 'off',
304
+ frontEnd: settings.frontEnd,
305
+ quietGate: settings.quietGate,
306
+ }
307
+ }
308
+
309
+ export interface ParakeetStatus {
310
+ installed: boolean
311
+ installable: boolean
312
+ version: string
313
+ bytes: number
314
+ progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; source?: unknown }
315
+ warm: { preview: string; error?: string; at?: string }
316
+ preview?: { breaker: { open: boolean; retryAt: string | null; misses: number }; counters: Record<string, number> }
317
+ note: string
318
+ }
319
+
320
+ function slotReason(code: string | null): { reason: string | null; reasonCode: string | null } {
321
+ return { reason: code ? (REASON_TEXT[code] ?? code) : null, reasonCode: code }
322
+ }
323
+
324
+ export function settingsView(settings: SttSettings, c: SttCapabilities, parakeet: ParakeetStatus, lastJob: LastSttJob | null = null) {
325
+ const eff = effectiveStt(settings, c)
326
+ const resolverCode = settings.resolver && !eff.resolver ? 'no_second_opinion' : null
327
+ return {
328
+ preview: {
329
+ selected: settings.preview, effective: eff.preview.engine, ...slotReason(eff.preview.reason), options: previewOptions(c),
330
+ models: [...PREVIEW_MODEL_STRINGS], clientParam: PREVIEW_MODELS_PARAM, requiresApp: PREVIEW_REQUIRES_APP,
331
+ note: `Only phones that list a preview model in ${PREVIEW_MODELS_PARAM} get it (COS Glasses ${PREVIEW_REQUIRES_APP} and later); every other client gets Whisper Turbo.`,
332
+ },
333
+ hq: { selected: settings.hq, effective: eff.hq.engine, ...slotReason(eff.hq.reason), options: hqOptions(c) },
334
+ secondOpinion: { selected: settings.secondOpinion, effective: eff.secondOpinion.engine, ...slotReason(eff.secondOpinion.reason), options: secondOpinionOptions(c) },
335
+ resolver: { enabled: settings.resolver, effective: eff.resolver, ...slotReason(resolverCode) },
336
+ frontEnd: { selected: settings.frontEnd, options: FRONT_END_OPTIONS },
337
+ quietGate: { selected: settings.quietGate, options: QUIET_GATE_OPTIONS },
338
+ parakeet,
339
+ lastJob,
340
+ appliesTo: { preview: 'next live chunk', hq: 'next saved meeting' },
341
+ }
342
+ }