@gotcos/glasses-server 6.21.2 → 6.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 6.21.3
2
+
3
+ - Route Max-tier provisional dictation preview through the resident Turbo
4
+ preview sidecar while keeping authoritative live commit and saved-work
5
+ polish on Large-v3. This corrects the 6.21.0 behavior that made cosmetic
6
+ preview pay Large-v3 latency.
7
+ - Give canonical transcription strict GPU priority. A cosmetic preview is
8
+ dropped or aborted when canonical/HQ Metal work begins, and preview failures
9
+ remain outside the Whisper circuit breaker and all persistence paths.
10
+ - Report the effective Max lanes truthfully as Turbo preview, Large-v3 commit,
11
+ and Large-v3 polish. Balanced remains Small.en preview, Turbo commit, and
12
+ Large-v3 polish.
13
+
1
14
  ## 6.21.2
2
15
 
3
16
  - Cache Python, Claude, Codex, and Cursor process probes for 30 seconds so the
package/README.md CHANGED
@@ -257,12 +257,14 @@ update the server remain on Turbo until Guided Setup opts them into Small.en.
257
257
  npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier max
258
258
  ```
259
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.
260
+ Max keeps Turbo resident in the isolated preview sidecar for low-latency
261
+ provisional words, while Large-v3 remains authoritative for live commit and
262
+ saved-work polish. Canonical transcription has strict GPU priority: a cosmetic
263
+ preview is dropped or aborted instead of competing with a committed decode.
264
+ If Large-v3 is missing, health reports the downgrade and the server falls back
265
+ to Turbo rather than making transcription unavailable. COS Control is the
266
+ supported owner of the machine-wide tier; the per-lane environment variables
267
+ remain advanced overrides.
266
268
 
267
269
  The first server start downloads the real-time turbo model. True HQ additionally
268
270
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
package/bin/cli.cjs CHANGED
@@ -379,7 +379,7 @@ if (SETUP_TRANSCRIPTION) {
379
379
  process.env.COS_WHISPER_PREVIEW_MODEL = previewModel
380
380
  process.env.COS_WHISPER_COMMIT_MODEL = commitModel
381
381
  const laneSummary = TRANSCRIPTION_TIER === 'max'
382
- ? 'Large-v3 preview + commit · Large-v3 HQ'
382
+ ? 'Turbo preview · Large-v3 commit · Large-v3 HQ'
383
383
  : 'Small.en preview · Turbo commit · Large-v3 HQ'
384
384
  console.log(green(' ✓') + ` ${TRANSCRIPTION_TIER === 'max' ? 'Max' : 'Balanced'} transcription selected ` + dim(`— ${laneSummary}`))
385
385
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.2",
3
+ "version": "6.21.3",
4
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": {
@@ -16,9 +16,12 @@ import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js
16
16
  import { stripBrandUrls } from './hallucination-filter.js'
17
17
  import {
18
18
  batchHqMetalEnabled,
19
+ beginCanonicalMetal,
19
20
  chooseBatchDevice,
20
21
  MetalBatchPreemptedError,
22
+ MetalPreviewContendedError,
21
23
  registerMetalBatchChild,
24
+ tryAcquireMetalPreview,
22
25
  unregisterMetalBatchChild,
23
26
  } from './whisper-metal-gate.js'
24
27
 
@@ -1026,6 +1029,7 @@ async function transcribeViaServer(
1026
1029
  context?: string,
1027
1030
  isQuiet?: boolean,
1028
1031
  promptPolicy: 'full-vocabulary' | 'none' = 'full-vocabulary',
1032
+ signal?: AbortSignal,
1029
1033
  ): Promise<{ text: string; words?: WhisperWord[] }> {
1030
1034
  const formData = new FormData()
1031
1035
  // Convert Buffer to Uint8Array to satisfy Blob's BlobPart type constraint
@@ -1043,10 +1047,11 @@ async function transcribeViaServer(
1043
1047
  // legitimate speech from quiet sources (laptop speakers through G2 mic).
1044
1048
  formData.append('suppress_non_speech', 'true') // Suppress special/non-speech tokens (benign)
1045
1049
 
1050
+ const timeoutSignal = AbortSignal.timeout(10_000)
1046
1051
  const response = await fetch(`${WHISPER_SERVER_URL}/inference`, {
1047
1052
  method: 'POST',
1048
1053
  body: formData,
1049
- signal: AbortSignal.timeout(10_000),
1054
+ signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
1050
1055
  })
1051
1056
 
1052
1057
  if (!response.ok) {
@@ -1183,6 +1188,7 @@ export async function transcribeLocal(
1183
1188
  opts?: {
1184
1189
  affectsCircuit?: boolean
1185
1190
  promptPolicy?: 'full-vocabulary' | 'none'
1191
+ metalPriority?: 'canonical' | 'preview'
1186
1192
  },
1187
1193
  ): Promise<{ text: string; backend: 'server' | 'cli'; words?: WhisperWord[] }> {
1188
1194
  const start = Date.now()
@@ -1198,8 +1204,21 @@ export async function transcribeLocal(
1198
1204
 
1199
1205
  // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
1200
1206
  if (serverAvailable) {
1207
+ const previewLease = opts?.metalPriority === 'preview' ? tryAcquireMetalPreview() : null
1208
+ if (opts?.metalPriority === 'preview' && !previewLease) {
1209
+ throw new MetalPreviewContendedError()
1210
+ }
1211
+ const releaseCanonical = opts?.metalPriority === 'preview'
1212
+ ? null
1213
+ : beginCanonicalMetal('whisper_server')
1201
1214
  try {
1202
- const result = await transcribeViaServer(audioBuffer, context, isQuiet, opts?.promptPolicy)
1215
+ const result = await transcribeViaServer(
1216
+ audioBuffer,
1217
+ context,
1218
+ isQuiet,
1219
+ opts?.promptPolicy,
1220
+ previewLease?.signal,
1221
+ )
1203
1222
  const text = applyCorrections(result.text)
1204
1223
  const words = result.words?.map(w => ({ ...w, word: applyCorrections(w.word) }))
1205
1224
  const elapsed = Date.now() - start
@@ -1211,6 +1230,7 @@ export async function transcribeLocal(
1211
1230
  console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words): "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`)
1212
1231
  return { text, backend: 'server', words }
1213
1232
  } catch (err: any) {
1233
+ if (previewLease?.signal.aborted) throw new MetalPreviewContendedError()
1214
1234
  if (affectsCircuit) {
1215
1235
  serverConsecutiveFailures++
1216
1236
  const isTimeout = err.message.includes('timeout') || err.message.includes('aborted')
@@ -1239,6 +1259,9 @@ export async function transcribeLocal(
1239
1259
 
1240
1260
  // Throw to let caller fall to OpenAI cloud (1-3s) — much faster than CLI cold-start (11s)
1241
1261
  throw new Error(`whisper-server unavailable: ${err.message}`)
1262
+ } finally {
1263
+ previewLease?.release()
1264
+ releaseCanonical?.()
1242
1265
  }
1243
1266
  }
1244
1267
 
@@ -39,6 +39,7 @@ export type ContentionReason =
39
39
  | 'prompt_draft_warm'
40
40
  | 'prompt_draft_finalize'
41
41
  | 'metal_batch_in_flight'
42
+ | 'canonical_in_flight'
42
43
 
43
44
  export type DeviceReason =
44
45
  | 'force_cpu'
@@ -72,6 +73,8 @@ export function batchHqMetalEnabled(): boolean {
72
73
  type LiveActivityProbe = () => number | null
73
74
 
74
75
  let liveActivityProbe: LiveActivityProbe | null = null
76
+ let canonicalMetalRequests = 0
77
+ const previewMetalRequests = new Set<AbortController>()
75
78
 
76
79
  /** transcribe-stream calls this at module load. Returns the most recent
77
80
  * lastActivityAt across in-memory sessions, or null when there are none. */
@@ -83,6 +86,9 @@ export function registerLiveActivityProbe(probe: LiveActivityProbe): void {
83
86
  export function resetMetalGateForTests(): void {
84
87
  liveActivityProbe = null
85
88
  metalChildren.clear()
89
+ canonicalMetalRequests = 0
90
+ for (const controller of previewMetalRequests) controller.abort('test_reset')
91
+ previewMetalRequests.clear()
86
92
  }
87
93
 
88
94
  function recentSessionActivity(now: number): boolean {
@@ -115,6 +121,7 @@ function activeMetalFamilyWork(): ContentionReason | null {
115
121
 
116
122
  /** Is something live currently entitled to Metal? */
117
123
  export function isLiveMetalContended(now: number = Date.now()): { contended: boolean; reason: ContentionReason | null } {
124
+ if (canonicalMetalRequests > 0) return { contended: true, reason: 'canonical_in_flight' }
118
125
  const work = activeMetalFamilyWork()
119
126
  if (work) return { contended: true, reason: work }
120
127
  if (recentSessionActivity(now)) return { contended: true, reason: 'session_recent' }
@@ -122,6 +129,52 @@ export function isLiveMetalContended(now: number = Date.now()): { contended: boo
122
129
  return { contended: false, reason: null }
123
130
  }
124
131
 
132
+ export interface MetalPreviewLease {
133
+ signal: AbortSignal
134
+ release: () => void
135
+ }
136
+
137
+ /** Cosmetic preview receives Metal only while canonical work is idle. The
138
+ * registration is synchronous, so a canonical request that begins afterward
139
+ * can abort this request before starting its own inference. */
140
+ export function tryAcquireMetalPreview(): MetalPreviewLease | null {
141
+ // Recent session activity is not itself GPU work. Allow preview between
142
+ // canonical chunks, but never alongside an active canonical or HQ Metal job.
143
+ if (canonicalMetalRequests > 0 || metalChildren.size > 0) return null
144
+ const controller = new AbortController()
145
+ previewMetalRequests.add(controller)
146
+ let released = false
147
+ return {
148
+ signal: controller.signal,
149
+ release: () => {
150
+ if (released) return
151
+ released = true
152
+ previewMetalRequests.delete(controller)
153
+ },
154
+ }
155
+ }
156
+
157
+ /** Canonical live transcription always wins. Abort any cosmetic preview first,
158
+ * then hold a synchronous counter so no new preview can enter until release. */
159
+ export function beginCanonicalMetal(reason: string): () => void {
160
+ canonicalMetalRequests++
161
+ for (const controller of previewMetalRequests) controller.abort(reason)
162
+ let released = false
163
+ return () => {
164
+ if (released) return
165
+ released = true
166
+ canonicalMetalRequests = Math.max(0, canonicalMetalRequests - 1)
167
+ }
168
+ }
169
+
170
+ export class MetalPreviewContendedError extends Error {
171
+ readonly contended = true
172
+ constructor() {
173
+ super('Whisper preview yielded to canonical transcription')
174
+ this.name = 'MetalPreviewContendedError'
175
+ }
176
+ }
177
+
125
178
  /** Device for the NEXT batch segment. Re-evaluated per segment so a meeting
126
179
  * that starts mid-batch moves subsequent segments to CPU without a preempt. */
127
180
  export function chooseBatchDevice(now: number = Date.now()): BatchDeviceDecision {
@@ -1,6 +1,6 @@
1
1
  // Adaptive provisional transcription:
2
2
  // Balanced -> isolated Small.en cosmetic preview + Turbo live commit
3
- // Max -> resident Large-v3 worker reused for preview + live commit
3
+ // Max -> isolated Turbo cosmetic preview + Large-v3 live commit
4
4
  // polish -> Large-v3 save pass (unchanged)
5
5
 
6
6
  import { execFile, spawn } from 'node:child_process'
@@ -16,9 +16,11 @@ import {
16
16
  type WhisperCommitModel,
17
17
  type WhisperTranscriptionTier,
18
18
  } from './whisper-local.js'
19
+ import { MetalPreviewContendedError, tryAcquireMetalPreview } from './whisper-metal-gate.js'
19
20
 
20
21
  export type WhisperPreviewRequest = 'auto' | 'small.en' | 'turbo' | 'off'
21
22
  export type WhisperPreviewModel = 'small.en' | WhisperCommitModel | null
23
+ type WhisperPreviewSidecarModel = 'small.en' | 'large-v3-turbo'
22
24
  export type WhisperPreviewReason =
23
25
  | 'disabled'
24
26
  | 'small_model_missing'
@@ -50,6 +52,7 @@ export interface WhisperPreviewCapability {
50
52
 
51
53
  const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
52
54
  export const WHISPER_SMALL_EN_MODEL_PATH = join(MODEL_DIR, 'ggml-small.en.bin')
55
+ const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
53
56
  const VAD_MODEL_PATH = join(MODEL_DIR, 'ggml-silero-v5.1.2.bin')
54
57
  const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
55
58
  const WHISPER_SERVER = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whisper-server']
@@ -63,6 +66,7 @@ let previewProcess: ChildProcess | null = null
63
66
  let previewAvailable = false
64
67
  let previewStarting = false
65
68
  let previewFailure: WhisperPreviewReason = null
69
+ let previewWorkerModel: WhisperPreviewSidecarModel | null = null
66
70
  let warnedInvalidChoice = false
67
71
 
68
72
  interface ProcessEntry {
@@ -103,10 +107,11 @@ function isCosPreviewCommand(command: string): boolean {
103
107
  const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
104
108
  return basename(executablePath) === 'whisper-server'
105
109
  && new RegExp(`(?:^|\\s)--port(?:=|\\s+)${PREVIEW_PORT}(?:\\s|$)`).test(command)
106
- && command.includes(WHISPER_SMALL_EN_MODEL_PATH)
110
+ && [WHISPER_SMALL_EN_MODEL_PATH, WHISPER_TURBO_MODEL_PATH]
111
+ .some(modelPath => command.includes(modelPath))
107
112
  }
108
113
 
109
- /** Reap only a listener proven to be our exact Small.en/8177 command. An
114
+ /** Reap only a listener proven to be our exact COS preview/8177 command. An
110
115
  * unrelated local service is never contacted with audio or terminated. */
111
116
  async function reclaimPreviewPort(): Promise<'clear' | 'reaped' | 'foreign'> {
112
117
  const listeners = await previewListeningPids()
@@ -135,7 +140,7 @@ async function reclaimPreviewPort(): Promise<'clear' | 'reaped' | 'foreign'> {
135
140
  if ((await previewListeningPids()).length === 0) return 'reaped'
136
141
  await new Promise(resolve => setTimeout(resolve, 100))
137
142
  }
138
- throw new Error(`verified Small.en worker still owns port ${PREVIEW_PORT}`)
143
+ throw new Error(`verified COS preview worker still owns port ${PREVIEW_PORT}`)
139
144
  }
140
145
 
141
146
  export function normalizeWhisperPreviewRequest(raw?: string): WhisperPreviewRequest {
@@ -169,11 +174,21 @@ function requestedPreviewModel(): WhisperPreviewRequest {
169
174
  function selectedPreviewModel(requested = requestedPreviewModel()): WhisperPreviewModel {
170
175
  if (requested === 'off') return null
171
176
  const primary = getWhisperCommitCapability().effectiveModel
172
- if (requested === 'turbo') return primary
177
+ if (requested === 'turbo') {
178
+ return existsSync(WHISPER_TURBO_MODEL_PATH) ? 'large-v3-turbo' : primary
179
+ }
173
180
  if (requested === 'small.en') return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : primary
174
181
  return existsSync(WHISPER_SMALL_EN_MODEL_PATH) ? 'small.en' : primary
175
182
  }
176
183
 
184
+ function sidecarModelPath(model: WhisperPreviewSidecarModel): string {
185
+ return model === 'small.en' ? WHISPER_SMALL_EN_MODEL_PATH : WHISPER_TURBO_MODEL_PATH
186
+ }
187
+
188
+ function needsPreviewSidecar(model: WhisperPreviewModel): model is WhisperPreviewSidecarModel {
189
+ return model !== null && model !== getWhisperCommitCapability().effectiveModel
190
+ }
191
+
177
192
  export function getWhisperPreviewCapability(): WhisperPreviewCapability {
178
193
  const commit = getWhisperCommitCapability()
179
194
  const requested = requestedPreviewModel()
@@ -189,12 +204,12 @@ export function getWhisperPreviewCapability(): WhisperPreviewCapability {
189
204
  }
190
205
  }
191
206
 
192
- const smallPresent = existsSync(WHISPER_SMALL_EN_MODEL_PATH)
193
207
  const selected = selectedPreviewModel(requested)
194
- const turboReady = getWhisperHealth().server
195
- if (selected === 'small.en' && previewAvailable) {
208
+ const primaryReady = getWhisperHealth().server
209
+ const sidecarExpected = needsPreviewSidecar(selected)
210
+ if (sidecarExpected && previewAvailable && previewWorkerModel === selected) {
196
211
  return {
197
- requested, effectiveModel: 'small.en', ready: true,
212
+ requested, effectiveModel: selected, ready: true,
198
213
  backend: 'whisper-preview-server', degraded: commit.degraded, reason: commit.reason,
199
214
  previewDegraded: false, commitDegraded: commit.degraded, commitReason: commit.reason,
200
215
  committedModel: commit.effectiveModel,
@@ -204,20 +219,24 @@ export function getWhisperPreviewCapability(): WhisperPreviewCapability {
204
219
  }
205
220
  }
206
221
 
207
- const smallWasExpected = requested === 'small.en' || (requested === 'auto' && smallPresent)
208
- const previewDegraded = smallWasExpected
222
+ const selectedModelMissing = requested === 'small.en' && !existsSync(WHISPER_SMALL_EN_MODEL_PATH)
223
+ ? 'small_model_missing'
224
+ : requested === 'turbo' && !existsSync(WHISPER_TURBO_MODEL_PATH)
225
+ ? 'turbo_model_missing'
226
+ : null
227
+ const previewDegraded = sidecarExpected || selectedModelMissing !== null
209
228
  const reason: WhisperPreviewReason = commit.reason
210
229
  ? commit.reason
211
- : requested === 'small.en' && !smallPresent
212
- ? 'small_model_missing'
213
- : smallWasExpected
230
+ : selectedModelMissing
231
+ ? selectedModelMissing
232
+ : sidecarExpected
214
233
  ? (previewFailure ?? (previewStarting ? null : 'preview_sidecar_unavailable'))
215
- : turboReady ? null : 'turbo_unavailable'
234
+ : primaryReady ? null : 'turbo_unavailable'
216
235
  return {
217
236
  requested,
218
- effectiveModel: turboReady ? commit.effectiveModel : selected,
219
- ready: turboReady,
220
- backend: turboReady ? 'whisper-server' : null,
237
+ effectiveModel: primaryReady ? commit.effectiveModel : selected,
238
+ ready: primaryReady,
239
+ backend: primaryReady ? 'whisper-server' : null,
221
240
  degraded: previewDegraded || commit.degraded,
222
241
  reason,
223
242
  previewDegraded,
@@ -232,16 +251,21 @@ export function getWhisperPreviewCapability(): WhisperPreviewCapability {
232
251
  }
233
252
 
234
253
  async function endpointReady(path: '/health' | '/inference', init?: RequestInit, timeoutMs = 1_000): Promise<Response> {
235
- return fetch(`${PREVIEW_URL}${path}`, { ...init, signal: AbortSignal.timeout(timeoutMs) })
254
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
255
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
256
+ return fetch(`${PREVIEW_URL}${path}`, { ...init, signal })
236
257
  }
237
258
 
238
- /** Start the optional small.en preview worker. Failure is cosmetic: committed
239
- * Turbo and every recovery/finalization path stay untouched. */
259
+ /** Start the optional isolated preview worker. Balanced uses Small.en; Max
260
+ * uses Turbo while its canonical live worker remains Large-v3. Failure is
261
+ * cosmetic: every recovery/finalization path stays untouched. */
240
262
  export async function startWhisperPreviewServer(): Promise<void> {
241
263
  const requested = requestedPreviewModel()
242
- if (selectedPreviewModel(requested) !== 'small.en' || previewProcess || previewAvailable || previewStarting) return
243
- if (!existsSync(WHISPER_SMALL_EN_MODEL_PATH)) {
244
- previewFailure = 'small_model_missing'
264
+ const selected = selectedPreviewModel(requested)
265
+ if (!needsPreviewSidecar(selected) || previewProcess || previewAvailable || previewStarting) return
266
+ const modelPath = sidecarModelPath(selected)
267
+ if (!existsSync(modelPath)) {
268
+ previewFailure = selected === 'small.en' ? 'small_model_missing' : 'turbo_model_missing'
245
269
  return
246
270
  }
247
271
  if (!existsSync(WHISPER_SERVER)) {
@@ -259,7 +283,7 @@ export async function startWhisperPreviewServer(): Promise<void> {
259
283
  return
260
284
  }
261
285
  if (portState === 'reaped') {
262
- console.log('[whisper-preview] reaped a stale Small.en worker before restart')
286
+ console.log('[whisper-preview] reaped a stale COS preview worker before restart')
263
287
  }
264
288
  } catch (error) {
265
289
  previewFailure = 'preview_start_failed'
@@ -268,7 +292,7 @@ export async function startWhisperPreviewServer(): Promise<void> {
268
292
  }
269
293
 
270
294
  const args = [
271
- '-m', WHISPER_SMALL_EN_MODEL_PATH,
295
+ '-m', modelPath,
272
296
  '-t', '16',
273
297
  '-l', 'en',
274
298
  '-fa',
@@ -281,10 +305,12 @@ export async function startWhisperPreviewServer(): Promise<void> {
281
305
  }
282
306
  const child = spawn(WHISPER_SERVER, args, { stdio: 'ignore', detached: false })
283
307
  previewProcess = child
308
+ previewWorkerModel = selected
284
309
  child.once('close', code => {
285
310
  if (previewProcess !== child) return
286
311
  previewProcess = null
287
312
  previewAvailable = false
313
+ previewWorkerModel = null
288
314
  previewFailure = code === 0 ? 'preview_sidecar_unavailable' : 'preview_start_failed'
289
315
  })
290
316
  child.once('error', () => {
@@ -300,7 +326,8 @@ export async function startWhisperPreviewServer(): Promise<void> {
300
326
  if (response.ok) {
301
327
  previewAvailable = true
302
328
  previewFailure = null
303
- console.log('[whisper-preview] small.en ready for provisional text; committed text remains Turbo')
329
+ const committed = getWhisperCommitCapability().effectiveModel
330
+ console.log(`[whisper-preview] ${selected} ready for provisional text; committed text remains ${committed}`)
304
331
  return
305
332
  }
306
333
  } catch { /* model still loading */ }
@@ -308,6 +335,7 @@ export async function startWhisperPreviewServer(): Promise<void> {
308
335
  }
309
336
  try { child.kill('SIGKILL') } catch { /* already exited */ }
310
337
  if (previewProcess === child) previewProcess = null
338
+ previewWorkerModel = null
311
339
  previewFailure = 'preview_start_failed'
312
340
  } finally {
313
341
  previewStarting = false
@@ -336,6 +364,7 @@ export async function stopWhisperPreviewServer(): Promise<void> {
336
364
  previewProcess = null
337
365
  previewAvailable = false
338
366
  previewStarting = false
367
+ previewWorkerModel = null
339
368
  if (child) {
340
369
  try { child.kill('SIGTERM') } catch { /* already exited */ }
341
370
  if (!await waitForPreviewClose(child, 2_000)) {
@@ -345,40 +374,63 @@ export async function stopWhisperPreviewServer(): Promise<void> {
345
374
  }
346
375
  }
347
376
 
348
- async function transcribeViaPreviewServer(audioBuffer: Buffer): Promise<string> {
377
+ async function transcribeViaPreviewServer(audioBuffer: Buffer, signal: AbortSignal): Promise<string> {
349
378
  const formData = new FormData()
350
379
  formData.append('file', new Blob([new Uint8Array(audioBuffer)], { type: 'audio/wav' }), 'recording.wav')
351
380
  formData.append('response_format', 'json')
352
381
  // 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.
382
+ // the commit lane. Never bias this provisional decode with profile vocabulary.
355
383
  formData.append('suppress_non_speech', 'true')
356
- const response = await endpointReady('/inference', { method: 'POST', body: formData }, 5_000)
384
+ const response = await endpointReady('/inference', { method: 'POST', body: formData, signal }, 5_000)
357
385
  if (!response.ok) throw new Error(`preview server ${response.status}`)
358
386
  const result = await response.json() as { text?: unknown }
359
387
  if (typeof result.text !== 'string') throw new Error('preview server returned invalid text')
360
388
  return applyCorrections(result.text.trim())
361
389
  }
362
390
 
363
- /** Cosmetic preview only. A small-worker failure falls through to the existing
364
- * non-circuit Turbo decode and can never write committed transcript state. */
391
+ /** Cosmetic preview only. A sidecar failure falls through to the existing
392
+ * non-circuit canonical decode and can never write committed transcript state. */
365
393
  export async function transcribeWhisperPreview(audioBuffer: Buffer): Promise<{
366
394
  text: string
367
395
  model: 'small.en' | WhisperCommitModel
368
396
  backend: 'whisper-preview-server' | 'whisper-server'
369
- }> {
370
- if (previewAvailable) {
397
+ }> {
398
+ if (previewAvailable && previewWorkerModel) {
399
+ const workerModel = previewWorkerModel
400
+ const previewLease = tryAcquireMetalPreview()
401
+ if (!previewLease) {
402
+ return { text: '', model: workerModel, backend: 'whisper-preview-server' }
403
+ }
371
404
  try {
372
- return { text: await transcribeViaPreviewServer(audioBuffer), model: 'small.en', backend: 'whisper-preview-server' }
405
+ return {
406
+ text: await transcribeViaPreviewServer(audioBuffer, previewLease.signal),
407
+ model: workerModel,
408
+ backend: 'whisper-preview-server',
409
+ }
373
410
  } catch (error) {
411
+ if (previewLease.signal.aborted) {
412
+ return { text: '', model: workerModel, backend: 'whisper-preview-server' }
413
+ }
414
+ const failedModel = previewWorkerModel
374
415
  previewAvailable = false
416
+ previewWorkerModel = null
375
417
  previewFailure = 'preview_sidecar_unavailable'
376
- console.warn(`[whisper-preview] small.en preview failed; falling back to Turbo: ${error instanceof Error ? error.message : error}`)
418
+ console.warn(`[whisper-preview] ${failedModel} preview failed; falling back to canonical worker: ${error instanceof Error ? error.message : error}`)
419
+ } finally {
420
+ previewLease.release()
377
421
  }
378
422
  }
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' }
423
+ try {
424
+ const result = await transcribeLocal(audioBuffer, undefined, undefined, {
425
+ affectsCircuit: false,
426
+ promptPolicy: 'none',
427
+ metalPriority: 'preview',
428
+ })
429
+ return { text: result.text, model: getWhisperCommitCapability().effectiveModel, backend: 'whisper-server' }
430
+ } catch (error) {
431
+ if (error instanceof MetalPreviewContendedError) {
432
+ return { text: '', model: getWhisperCommitCapability().effectiveModel, backend: 'whisper-server' }
433
+ }
434
+ throw error
435
+ }
384
436
  }