@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 +13 -0
- package/README.md +8 -6
- package/bin/cli.cjs +1 -1
- package/package.json +1 -1
- package/server/lib/whisper-local.ts +25 -2
- package/server/lib/whisper-metal-gate.ts +53 -0
- package/server/lib/whisper-preview.ts +94 -42
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
is
|
|
265
|
-
|
|
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
|
|
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
|
@@ -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.
|
|
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(
|
|
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 ->
|
|
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
|
-
&&
|
|
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
|
|
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
|
|
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')
|
|
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
|
|
195
|
-
|
|
208
|
+
const primaryReady = getWhisperHealth().server
|
|
209
|
+
const sidecarExpected = needsPreviewSidecar(selected)
|
|
210
|
+
if (sidecarExpected && previewAvailable && previewWorkerModel === selected) {
|
|
196
211
|
return {
|
|
197
|
-
requested, effectiveModel:
|
|
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
|
|
208
|
-
|
|
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
|
-
:
|
|
212
|
-
|
|
213
|
-
|
|
230
|
+
: selectedModelMissing
|
|
231
|
+
? selectedModelMissing
|
|
232
|
+
: sidecarExpected
|
|
214
233
|
? (previewFailure ?? (previewStarting ? null : 'preview_sidecar_unavailable'))
|
|
215
|
-
:
|
|
234
|
+
: primaryReady ? null : 'turbo_unavailable'
|
|
216
235
|
return {
|
|
217
236
|
requested,
|
|
218
|
-
effectiveModel:
|
|
219
|
-
ready:
|
|
220
|
-
backend:
|
|
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
|
-
|
|
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
|
|
239
|
-
* Turbo
|
|
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
|
-
|
|
243
|
-
if (!
|
|
244
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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.
|
|
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
|
|
364
|
-
* non-circuit
|
|
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 {
|
|
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]
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
}
|