@gotcos/glasses-server 6.71.14 → 6.73.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 +16 -6
- package/CHANGELOG.md +32 -0
- package/README.md +188 -3
- package/docs/stt-672-evaluation.md +113 -0
- package/package.json +4 -1
- package/server/index.ts +18 -1
- package/server/lib/batch-transcript-quality.ts +46 -0
- package/server/lib/dictation-stt.ts +117 -0
- package/server/lib/meeting-batch-persistence.ts +23 -1
- package/server/lib/meeting-batch-transcribe.ts +408 -45
- package/server/lib/parakeet-manifest.ts +41 -0
- package/server/lib/parakeet.ts +675 -0
- package/server/lib/prompt-draft-store.ts +5 -0
- package/server/lib/stt-live.ts +60 -0
- package/server/lib/stt-resolver.ts +327 -0
- package/server/lib/stt-runtime.ts +233 -0
- package/server/lib/stt-settings.ts +431 -0
- package/server/lib/transcribe-audio.ts +53 -4
- package/server/lib/vad-silero.ts +52 -0
- package/server/lib/whisper-batch-timing.ts +13 -3
- package/server/lib/whisper-local.ts +58 -17
- package/server/routes/meeting.ts +9 -3
- package/server/routes/prompt-drafts.ts +56 -17
- package/server/routes/stt-settings.ts +85 -0
- package/server/routes/transcribe-stream.ts +34 -22
- package/server/routes/transcribe.ts +6 -2
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
// Parakeet speech recognition through FluidAudio's CLI on the Neural Engine
|
|
2
|
+
// (6.72.0): the HQ engine choice, the HQ second opinion and the experimental
|
|
3
|
+
// live preview.
|
|
4
|
+
//
|
|
5
|
+
// OFFLINE BY CONSTRUCTION. Every run passes `--local-model-dir` (a FluidAudio
|
|
6
|
+
// path that never downloads) AND runs under `sandbox-exec` with network denied
|
|
7
|
+
// and the installed models read-only. No sandbox, no run. Only the installer
|
|
8
|
+
// touches the network, and it accepts a byte only when its SHA-256 matches the
|
|
9
|
+
// pinned manifest (parakeet-manifest.ts).
|
|
10
|
+
//
|
|
11
|
+
// No model call is an LLM. Transcription must never call `claude -p`.
|
|
12
|
+
//
|
|
13
|
+
// fluidaudiocli 0.17.4 has no server mode, so each decode is one short process.
|
|
14
|
+
// What stays warm is the compiled CoreML bundle under the private HOME below:
|
|
15
|
+
// measured 2026-10-13 on an M3 Ultra, a 6 s chunk takes about 0.28 s end to end
|
|
16
|
+
// once compiled, against 12 to 17 s for the first, compiling run. Callers that
|
|
17
|
+
// need a warm model (the live preview) check parakeetWarmState first.
|
|
18
|
+
|
|
19
|
+
import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
|
|
20
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
21
|
+
import {
|
|
22
|
+
chmodSync, copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync,
|
|
23
|
+
readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync,
|
|
24
|
+
} from 'node:fs'
|
|
25
|
+
import { homedir, setPriority, tmpdir } from 'node:os'
|
|
26
|
+
import { dirname, join, resolve } from 'node:path'
|
|
27
|
+
import { pipeline } from 'node:stream/promises'
|
|
28
|
+
import { Readable, Transform } from 'node:stream'
|
|
29
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
30
|
+
import { dataPath } from './data-dir.js'
|
|
31
|
+
import { PARAKEET_CLI, PARAKEET_MODELS, type PinnedModel } from './parakeet-manifest.js'
|
|
32
|
+
import type { HypWord } from './stt-resolver.js'
|
|
33
|
+
|
|
34
|
+
export type ParakeetModelId = 'parakeet-ultra'
|
|
35
|
+
export const PARAKEET_TMP_PREFIX = 'cos-parakeet-'
|
|
36
|
+
export const SANDBOX_EXEC = '/usr/bin/sandbox-exec'
|
|
37
|
+
/** At most 40 characters (Control truncates there). */
|
|
38
|
+
export const PARAKEET_VERSION_LABEL = `Ultra ${PARAKEET_MODELS[0].revision.slice(0, 8)}, CLI ${PARAKEET_CLI.version}`
|
|
39
|
+
|
|
40
|
+
export function parakeetRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
41
|
+
const override = env.COS_PARAKEET_DIR?.trim()
|
|
42
|
+
return override ? resolve(override) : dataPath('stt', 'parakeet')
|
|
43
|
+
}
|
|
44
|
+
const currentDir = () => join(parakeetRoot(), 'current')
|
|
45
|
+
export const parakeetCliPath = () => join(currentDir(), 'bin', 'fluidaudiocli')
|
|
46
|
+
export const parakeetModelDir = (id: ParakeetModelId) => join(currentDir(), 'models', id)
|
|
47
|
+
const receiptPath = () => join(currentDir(), 'install.json')
|
|
48
|
+
/** CoreML keeps its compiled bundles here. Survives reinstalls, so a reinstall stays warm. */
|
|
49
|
+
export const parakeetHome = () => join(parakeetRoot(), 'home')
|
|
50
|
+
|
|
51
|
+
function pinned(id: ParakeetModelId): PinnedModel {
|
|
52
|
+
const model = PARAKEET_MODELS.find(m => m.id === id)
|
|
53
|
+
if (!model) throw new Error(`unknown Parakeet model ${id}`)
|
|
54
|
+
return model
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Install state ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export type InstallPhase = 'idle' | 'downloading' | 'verifying' | 'installed' | 'failed'
|
|
60
|
+
export interface InstallProgress {
|
|
61
|
+
state: InstallPhase
|
|
62
|
+
bytesDone: number
|
|
63
|
+
bytesTotal: number
|
|
64
|
+
error: string | null
|
|
65
|
+
/** Where the bytes came from: files copied from a verified local copy, and files downloaded. */
|
|
66
|
+
source?: { filesLocal: number; filesDownloaded: number; localSources: string[] }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface InstallReceipt {
|
|
70
|
+
schema: 1
|
|
71
|
+
cli: { version: string; sha256: string }
|
|
72
|
+
models: Array<{ id: string; revision: string; bytes: number }>
|
|
73
|
+
bytes: number
|
|
74
|
+
installedAt: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const TOTAL_BYTES = PARAKEET_CLI.size + PARAKEET_MODELS.reduce((n, m) => n + m.files.reduce((k, f) => k + f.size, 0), 0)
|
|
78
|
+
let progress: InstallProgress = { state: 'idle', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null }
|
|
79
|
+
let installing: Promise<void> | null = null
|
|
80
|
+
let installAbort: AbortController | null = null
|
|
81
|
+
let installedCache: { at: number; value: { installed: boolean; bytes: number } } | null = null
|
|
82
|
+
|
|
83
|
+
export function getInstallProgress(): InstallProgress {
|
|
84
|
+
return { ...progress, ...(progress.source ? { source: { ...progress.source, localSources: [...progress.source.localSources] } } : {}) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Cheap: the receipt plus a size check of every pinned file. The full hash
|
|
89
|
+
* check happens at install, and the CLI's hash once per process (cliVerified).
|
|
90
|
+
*/
|
|
91
|
+
export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes: number } {
|
|
92
|
+
if (installedCache && now - installedCache.at < 5_000) return installedCache.value
|
|
93
|
+
let value = { installed: false, bytes: 0 }
|
|
94
|
+
try {
|
|
95
|
+
const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt
|
|
96
|
+
const sizeOk = (path: string, size: number) => { try { return statSync(path).size === size } catch { return false } }
|
|
97
|
+
const cliOk = receipt?.schema === 1 && receipt.cli?.sha256 === PARAKEET_CLI.sha256 && sizeOk(parakeetCliPath(), PARAKEET_CLI.size)
|
|
98
|
+
const modelsOk = PARAKEET_MODELS.every(m =>
|
|
99
|
+
receipt.models?.some(r => r.id === m.id && r.revision === m.revision)
|
|
100
|
+
&& m.files.every(f => sizeOk(join(parakeetModelDir(m.id as ParakeetModelId), f.path), f.size)))
|
|
101
|
+
if (cliOk && modelsOk) value = { installed: true, bytes: receipt.bytes }
|
|
102
|
+
} catch { /* not installed */ }
|
|
103
|
+
installedCache = { at: now, value }
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resetParakeetInstallCache(): void { installedCache = null; cliHashCache.clear() }
|
|
108
|
+
|
|
109
|
+
async function sha256File(path: string): Promise<string> {
|
|
110
|
+
const hash = createHash('sha256')
|
|
111
|
+
await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
|
|
112
|
+
return hash.digest('hex')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fileStat(path: string): { size: number; mtimeMs: number } | null {
|
|
116
|
+
try { const st = statSync(path); return st.isFile() ? { size: st.size, mtimeMs: st.mtimeMs } : null } catch { return null }
|
|
117
|
+
}
|
|
118
|
+
function fileSize(path: string): number | null { return fileStat(path)?.size ?? null }
|
|
119
|
+
|
|
120
|
+
/** A pinned CLI hash, computed once per (path, size, mtime) for the life of the process. */
|
|
121
|
+
const cliHashCache = new Map<string, boolean>()
|
|
122
|
+
async function isPinnedCli(path: string): Promise<boolean> {
|
|
123
|
+
const st = fileStat(path)
|
|
124
|
+
if (!st || st.size !== PARAKEET_CLI.size) return false
|
|
125
|
+
const key = `${path}\0${st.size}\0${st.mtimeMs}`
|
|
126
|
+
const known = cliHashCache.get(key)
|
|
127
|
+
if (known !== undefined) return known
|
|
128
|
+
const ok = await sha256File(path).then(h => h === PARAKEET_CLI.sha256, () => false)
|
|
129
|
+
cliHashCache.set(key, ok)
|
|
130
|
+
return ok
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Synchronous view of the CLI check for GET: true or false once checked, null while unknown. */
|
|
134
|
+
function knownPinnedCli(path: string): boolean | null {
|
|
135
|
+
const st = fileStat(path)
|
|
136
|
+
if (!st || st.size !== PARAKEET_CLI.size) return false
|
|
137
|
+
return cliHashCache.get(`${path}\0${st.size}\0${st.mtimeMs}`) ?? null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where an already-verified local copy of the models might be. Every candidate
|
|
142
|
+
* must still match the pinned hash. `<data>/stt/parakeet-sources` is the
|
|
143
|
+
* documented drop-in: link or copy a folder holding `parakeet-ultra/`
|
|
144
|
+
* (or FluidAudio's own repo folder name) there, and an install
|
|
145
|
+
* started with an empty body uses it instead of downloading.
|
|
146
|
+
*/
|
|
147
|
+
export function defaultLocalSources(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string[] {
|
|
148
|
+
const extra = (env.COS_PARAKEET_LOCAL_SOURCES ?? '').split(':').map(s => s.trim()).filter(Boolean)
|
|
149
|
+
return [...extra, join(parakeetRoot(env), '..', 'parakeet-sources'), join(home, 'Library', 'Application Support', 'FluidAudio', 'Models')]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function defaultCliCandidates(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string[] {
|
|
153
|
+
const out: string[] = []
|
|
154
|
+
if (env.COS_NEMOTRON_CLI) out.push(env.COS_NEMOTRON_CLI)
|
|
155
|
+
out.push(join(home, '.cos-glasses', 'bin', 'fluidaudiocli'))
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The first candidate CLI that is the pinned build, or null. */
|
|
160
|
+
export async function findPinnedCli(candidates: string[] = defaultCliCandidates()): Promise<string | null> {
|
|
161
|
+
for (const c of candidates) if (await isPinnedCli(c)) return c
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** For GET (no I/O beyond stat): is a pinned CLI known to be on this Mac? null until checked. */
|
|
166
|
+
export function pinnedCliKnown(candidates: string[] = defaultCliCandidates()): boolean | null {
|
|
167
|
+
let unknown = false
|
|
168
|
+
for (const c of candidates) {
|
|
169
|
+
const k = knownPinnedCli(c)
|
|
170
|
+
if (k === true) return true
|
|
171
|
+
if (k === null) unknown = true
|
|
172
|
+
}
|
|
173
|
+
if (unknown) void findPinnedCli(candidates)
|
|
174
|
+
return unknown ? null : false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function modelDirCandidates(model: PinnedModel, sources: string[]): string[] {
|
|
178
|
+
const repoName = model.repo.split('/').pop()!
|
|
179
|
+
const out: string[] = []
|
|
180
|
+
for (const src of sources) {
|
|
181
|
+
for (const name of [model.id, repoName]) out.push(join(src, name))
|
|
182
|
+
out.push(join(src, 'models', model.id))
|
|
183
|
+
}
|
|
184
|
+
return [...new Set(out)]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export class ParakeetInstallError extends Error {
|
|
188
|
+
constructor(message: string) { super(message); this.name = 'ParakeetInstallError' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; body: ReadableStream<Uint8Array> | null }>
|
|
192
|
+
|
|
193
|
+
/** A download that delivers no bytes for this long is abandoned. */
|
|
194
|
+
export const INSTALL_IDLE_TIMEOUT_MS = 60_000
|
|
195
|
+
|
|
196
|
+
export interface InstallOptions {
|
|
197
|
+
/** Extra directories or files to look in before downloading. */
|
|
198
|
+
localSources?: string[]
|
|
199
|
+
/** Fail (plainly) instead of downloading a file the local sources lack. */
|
|
200
|
+
requireLocal?: boolean
|
|
201
|
+
cliCandidates?: string[]
|
|
202
|
+
fetchImpl?: FetchLike
|
|
203
|
+
idleTimeoutMs?: number
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Start the install, or join the one already running. */
|
|
207
|
+
export function installParakeet(options: InstallOptions = {}): Promise<void> {
|
|
208
|
+
if (installing) return installing
|
|
209
|
+
installAbort = new AbortController()
|
|
210
|
+
installing = runInstall(options, installAbort.signal).finally(() => { installing = null; installAbort = null })
|
|
211
|
+
return installing
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function parakeetInstallRunning(): boolean { return installing !== null }
|
|
215
|
+
|
|
216
|
+
/** Stop a running install; nothing partial is kept. */
|
|
217
|
+
export function cancelParakeetInstall(): boolean {
|
|
218
|
+
if (!installAbort) return false
|
|
219
|
+
installAbort.abort(new ParakeetInstallError('The install was cancelled.'))
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runInstall(options: InstallOptions, signal: AbortSignal): Promise<void> {
|
|
224
|
+
const root = parakeetRoot()
|
|
225
|
+
const localSources = [...(options.localSources ?? []), ...defaultLocalSources()]
|
|
226
|
+
progress = { state: 'verifying', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null, source: { filesLocal: 0, filesDownloaded: 0, localSources: [] } }
|
|
227
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
228
|
+
const staging = join(root, `.staging-${randomBytes(6).toString('hex')}`)
|
|
229
|
+
mkdirSync(join(staging, 'bin'), { recursive: true, mode: 0o700 })
|
|
230
|
+
const usedSources = new Set<string>()
|
|
231
|
+
try {
|
|
232
|
+
// 1. The CLI. FluidAudio publishes no binary, so a pinned local copy is the only source.
|
|
233
|
+
const cliTarget = join(staging, 'bin', 'fluidaudiocli')
|
|
234
|
+
const cli = await findPinnedCli([...(options.cliCandidates ?? []), ...(options.localSources ?? []).map(src => fileSize(src) !== null ? src : join(src, 'fluidaudiocli')), ...defaultCliCandidates()])
|
|
235
|
+
if (!cli) {
|
|
236
|
+
throw new ParakeetInstallError(`FluidAudio CLI ${PARAKEET_CLI.version} is not on this Mac. It is the same tool speaker separation uses; put it at ~/.cos-glasses/bin/fluidaudiocli, then install again.`)
|
|
237
|
+
}
|
|
238
|
+
copyFileSync(cli, cliTarget)
|
|
239
|
+
if (await sha256File(cliTarget) !== PARAKEET_CLI.sha256) throw new ParakeetInstallError('The FluidAudio CLI changed while it was being copied. Try again.')
|
|
240
|
+
chmodSync(cliTarget, 0o755)
|
|
241
|
+
progress.bytesDone += PARAKEET_CLI.size
|
|
242
|
+
|
|
243
|
+
// 2. The models: a matching local copy first, else the pinned Hugging Face revision.
|
|
244
|
+
const fetchImpl: FetchLike = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike)
|
|
245
|
+
const idleMs = options.idleTimeoutMs ?? INSTALL_IDLE_TIMEOUT_MS
|
|
246
|
+
for (const model of PARAKEET_MODELS) {
|
|
247
|
+
const candidates = modelDirCandidates(model, localSources)
|
|
248
|
+
for (const file of model.files) {
|
|
249
|
+
if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.')
|
|
250
|
+
const target = join(staging, 'models', model.id, file.path)
|
|
251
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
|
|
252
|
+
let placed = false
|
|
253
|
+
for (const dir of candidates) {
|
|
254
|
+
const src = join(dir, file.path)
|
|
255
|
+
if (fileSize(src) !== file.size) continue
|
|
256
|
+
if (await sha256File(src) !== file.sha256) continue
|
|
257
|
+
copyFileSync(src, target)
|
|
258
|
+
placed = true
|
|
259
|
+
usedSources.add(dir)
|
|
260
|
+
progress.source!.filesLocal++
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
if (!placed && options.requireLocal) {
|
|
264
|
+
throw new ParakeetInstallError(`No copy of ${model.id} ${file.path} with the pinned SHA-256 was found in ${(options.localSources ?? []).join(', ') || 'the local sources'}. Check the path, or install with allowDownload to fetch it.`)
|
|
265
|
+
}
|
|
266
|
+
if (!placed) {
|
|
267
|
+
progress.state = 'downloading'
|
|
268
|
+
const url = `https://huggingface.co/${model.repo}/resolve/${model.revision}/${file.path.split('/').map(encodeURIComponent).join('/')}`
|
|
269
|
+
// One controller per file: user cancel, or no bytes for idleMs.
|
|
270
|
+
const ctl = new AbortController()
|
|
271
|
+
const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.'))
|
|
272
|
+
signal.addEventListener('abort', onCancel, { once: true })
|
|
273
|
+
let idle: ReturnType<typeof setTimeout> | null = null
|
|
274
|
+
const arm = () => {
|
|
275
|
+
if (idle) clearTimeout(idle)
|
|
276
|
+
idle = setTimeout(() => ctl.abort(new ParakeetInstallError(`The download stalled (no data for ${Math.round(idleMs / 1000)} s). Nothing was installed; try again.`)), idleMs)
|
|
277
|
+
idle.unref?.()
|
|
278
|
+
}
|
|
279
|
+
arm()
|
|
280
|
+
const partial = `${target}.partial`
|
|
281
|
+
let written = 0
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetchImpl(url, { signal: ctl.signal })
|
|
284
|
+
if (!res.ok || !res.body) throw new ParakeetInstallError(`Download failed (${res.status}) for ${model.id} ${file.path}.`)
|
|
285
|
+
const counter = new Transform({ transform(chunk, _e, cb) {
|
|
286
|
+
arm()
|
|
287
|
+
written += chunk.length
|
|
288
|
+
if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size.`)); return }
|
|
289
|
+
progress.bytesDone += chunk.length
|
|
290
|
+
cb(null, chunk)
|
|
291
|
+
} })
|
|
292
|
+
await pipeline(Readable.fromWeb(res.body as any), counter, createWriteStream(partial, { mode: 0o600 }), { signal: ctl.signal })
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const why = ctl.signal.reason
|
|
295
|
+
throw why instanceof ParakeetInstallError ? why : error
|
|
296
|
+
} finally {
|
|
297
|
+
if (idle) clearTimeout(idle)
|
|
298
|
+
signal.removeEventListener('abort', onCancel)
|
|
299
|
+
progress.bytesDone -= written // re-counted once below, after verification
|
|
300
|
+
}
|
|
301
|
+
renameSync(partial, target)
|
|
302
|
+
progress.source!.filesDownloaded++
|
|
303
|
+
}
|
|
304
|
+
progress.state = 'verifying'
|
|
305
|
+
if (fileSize(target) !== file.size || await sha256File(target) !== file.sha256) {
|
|
306
|
+
throw new ParakeetInstallError(`${model.id} ${file.path} does not match its pinned SHA-256. Nothing was installed.`)
|
|
307
|
+
}
|
|
308
|
+
progress.bytesDone += file.size
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
progress.source!.localSources = [...usedSources]
|
|
312
|
+
|
|
313
|
+
const receipt: InstallReceipt = {
|
|
314
|
+
schema: 1,
|
|
315
|
+
cli: { version: PARAKEET_CLI.version, sha256: PARAKEET_CLI.sha256 },
|
|
316
|
+
models: PARAKEET_MODELS.map(m => ({ id: m.id, revision: m.revision, bytes: m.files.reduce((n, f) => n + f.size, 0) })),
|
|
317
|
+
bytes: TOTAL_BYTES,
|
|
318
|
+
installedAt: new Date().toISOString(),
|
|
319
|
+
}
|
|
320
|
+
durableAtomicWriteFileSync(join(staging, 'install.json'), JSON.stringify(receipt, null, 2), { mode: 0o600 })
|
|
321
|
+
|
|
322
|
+
// 3. Swap. A crash between the two renames leaves only `.old-*`; boot
|
|
323
|
+
// (sweepParakeetStaging) puts it back as current.
|
|
324
|
+
const current = currentDir()
|
|
325
|
+
const old = existsSync(current) ? join(root, `.old-${Date.now()}-${randomBytes(3).toString('hex')}`) : null
|
|
326
|
+
if (old) renameSync(current, old)
|
|
327
|
+
renameSync(staging, current)
|
|
328
|
+
if (old) rmSync(old, { recursive: true, force: true })
|
|
329
|
+
mkdirSync(parakeetHome(), { recursive: true, mode: 0o700 })
|
|
330
|
+
resetParakeetInstallCache()
|
|
331
|
+
for (const id of warm.keys()) warm.delete(id)
|
|
332
|
+
progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null }
|
|
333
|
+
} catch (error) {
|
|
334
|
+
rmSync(staging, { recursive: true, force: true })
|
|
335
|
+
const message = error instanceof ParakeetInstallError ? error.message : `Install failed: ${error instanceof Error ? error.message : String(error)}`
|
|
336
|
+
progress = { ...progress, state: 'failed', bytesTotal: TOTAL_BYTES, error: message, source: progress.source ? { ...progress.source, localSources: [...usedSources] } : undefined }
|
|
337
|
+
throw new ParakeetInstallError(message)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Boot: put back an install a crash left mid-swap (no `current`, an `.old-*`
|
|
343
|
+
* present), then remove staging and old directories.
|
|
344
|
+
*/
|
|
345
|
+
export function sweepParakeetStaging(): { removed: number; restored: boolean } {
|
|
346
|
+
let removed = 0, restored = false
|
|
347
|
+
try {
|
|
348
|
+
const root = parakeetRoot()
|
|
349
|
+
const names = readdirSync(root)
|
|
350
|
+
const olds = names.filter(n => n.startsWith('.old-')).sort()
|
|
351
|
+
if (!existsSync(currentDir()) && olds.length) {
|
|
352
|
+
renameSync(join(root, olds.at(-1)!), currentDir())
|
|
353
|
+
restored = true
|
|
354
|
+
olds.pop()
|
|
355
|
+
}
|
|
356
|
+
for (const name of names) {
|
|
357
|
+
if (!name.startsWith('.staging-') && !(name.startsWith('.old-') && olds.includes(name))) continue
|
|
358
|
+
rmSync(join(root, name), { recursive: true, force: true })
|
|
359
|
+
removed++
|
|
360
|
+
}
|
|
361
|
+
} catch { /* nothing installed yet */ }
|
|
362
|
+
if (restored) resetParakeetInstallCache()
|
|
363
|
+
return { removed, restored }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Inference ─────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
export type ParakeetFailure = 'not_installed' | 'sandbox_unavailable' | 'busy' | 'timeout' | 'cli_failed' | 'bad_output' | 'spawn_failed' | 'aborted'
|
|
369
|
+
export class ParakeetError extends Error {
|
|
370
|
+
constructor(readonly reason: ParakeetFailure, message: string) { super(message); this.name = 'ParakeetError' }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export interface ParakeetResult {
|
|
374
|
+
model: ParakeetModelId
|
|
375
|
+
text: string
|
|
376
|
+
/** Spoken words, seconds from the start of the audio. */
|
|
377
|
+
words: HypWord[]
|
|
378
|
+
processingSec: number | null
|
|
379
|
+
wallMs: number
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess
|
|
383
|
+
|
|
384
|
+
export interface ParakeetRunOptions {
|
|
385
|
+
model: ParakeetModelId
|
|
386
|
+
/** preview: one at a time, a busy lane is a miss. batch: queued, lower CPU priority.
|
|
387
|
+
* interactive (6.73.0, dictation final and Fast mode): normal priority, never queued
|
|
388
|
+
* behind a meeting batch, at most INTERACTIVE_LANE_MAX at once; beyond that it is busy. */
|
|
389
|
+
lane: 'preview' | 'batch' | 'warmup' | 'interactive'
|
|
390
|
+
timeoutMs: number
|
|
391
|
+
signal?: AbortSignal
|
|
392
|
+
spawnImpl?: SpawnLike
|
|
393
|
+
/** Tests only: a different sandbox-exec path. There is no environment override. */
|
|
394
|
+
sandboxExec?: string
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const liveChildren = new Set<ChildProcess>()
|
|
398
|
+
const liveDirs = new Set<string>()
|
|
399
|
+
let exitHookInstalled = false
|
|
400
|
+
function installExitHook(): void {
|
|
401
|
+
if (exitHookInstalled) return
|
|
402
|
+
exitHookInstalled = true
|
|
403
|
+
process.once('exit', () => { killParakeetChildren() })
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function killParakeetChildren(): { killed: number; removed: number } {
|
|
407
|
+
let killed = 0, removed = 0
|
|
408
|
+
for (const child of liveChildren) {
|
|
409
|
+
try { if (child.exitCode == null && child.signalCode == null) { child.kill('SIGKILL'); killed++ } } catch { /* gone */ }
|
|
410
|
+
}
|
|
411
|
+
liveChildren.clear()
|
|
412
|
+
for (const dir of liveDirs) { try { rmSync(dir, { recursive: true, force: true }); removed++ } catch { /* best effort */ } }
|
|
413
|
+
liveDirs.clear()
|
|
414
|
+
return { killed, removed }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Server start: temp directories a previous process left behind. */
|
|
418
|
+
export function sweepStaleParakeetTmp(tmpRoot = tmpdir(), minAgeMs = 60_000, now = Date.now()): number {
|
|
419
|
+
let removed = 0
|
|
420
|
+
try {
|
|
421
|
+
for (const name of readdirSync(tmpRoot)) {
|
|
422
|
+
if (!name.startsWith(PARAKEET_TMP_PREFIX)) continue
|
|
423
|
+
const path = join(tmpRoot, name)
|
|
424
|
+
try { if (now - statSync(path).mtimeMs >= minAgeMs) { rmSync(path, { recursive: true, force: true }); removed++ } } catch { /* raced */ }
|
|
425
|
+
}
|
|
426
|
+
} catch { /* no tmp root */ }
|
|
427
|
+
return removed
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Sandbox profile: no network at all, and writes only to this run's temp
|
|
432
|
+
* directory and the private CoreML cache HOME (plus /dev streams). Reads stay
|
|
433
|
+
* allowed (the CLI loads system frameworks); that is a documented limit, not a
|
|
434
|
+
* guarantee about what it could read. Paths are realpath'd: the sandbox matches
|
|
435
|
+
* resolved paths, so a /tmp or symlinked data dir would otherwise never match.
|
|
436
|
+
*/
|
|
437
|
+
export function parakeetSandboxProfile(workDir: string, homeDir: string, userTempDir: string | null = darwinUserTempDir()): string {
|
|
438
|
+
const q = (path: string) => { let real = path; try { real = realpathSync(path) } catch { /* not created yet */ } return real.replace(/\\/g, '\\\\').replace(/"/g, '\\"') }
|
|
439
|
+
// FluidAudio buffers audio over about 15 s in NSTemporaryDirectory (the per-user
|
|
440
|
+
// /var/folders/../T), not in TMPDIR; measured 2026-10-13, a 36 s HQ window failed without it.
|
|
441
|
+
const temp = userTempDir ? ` (subpath "${q(userTempDir)}")` : ''
|
|
442
|
+
return `(version 1)(allow default)(deny network*)(deny file-write*)`
|
|
443
|
+
+ `(allow file-write* (subpath "${q(workDir)}") (subpath "${q(homeDir)}")${temp} (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (regex #"^/dev/fd/") (regex #"^/dev/tty"))`
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let userTempCache: string | null | undefined
|
|
447
|
+
/** The per-user temp directory macOS gives NSTemporaryDirectory (confstr DARWIN_USER_TEMP_DIR). */
|
|
448
|
+
export function darwinUserTempDir(): string | null {
|
|
449
|
+
if (userTempCache !== undefined) return userTempCache
|
|
450
|
+
try {
|
|
451
|
+
const out = execFileSync('/usr/bin/getconf', ['DARWIN_USER_TEMP_DIR'], { encoding: 'utf8', timeout: 2_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
452
|
+
userTempCache = out.startsWith('/') ? out : null
|
|
453
|
+
} catch { userTempCache = null }
|
|
454
|
+
return userTempCache
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The only variables the CLI sees. The server's tokens and keys never reach it. */
|
|
458
|
+
export function parakeetChildEnv(home: string, work: string, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
459
|
+
const out: NodeJS.ProcessEnv = { HOME: home, CFFIXED_USER_HOME: home, TMPDIR: work }
|
|
460
|
+
for (const key of ['PATH', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE']) if (typeof env[key] === 'string') out[key] = env[key]
|
|
461
|
+
return out
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function parseParakeetJson(raw: string): { text: string; words: HypWord[]; processingSec: number | null } {
|
|
465
|
+
const data = JSON.parse(raw) as { text?: unknown; wordTimings?: unknown; processingTimeSeconds?: unknown }
|
|
466
|
+
if (typeof data?.text !== 'string') throw new ParakeetError('bad_output', 'Parakeet JSON has no text')
|
|
467
|
+
const timings = Array.isArray(data.wordTimings) ? data.wordTimings : []
|
|
468
|
+
const words: HypWord[] = []
|
|
469
|
+
for (const item of timings as Array<Record<string, unknown>>) {
|
|
470
|
+
const word = typeof item?.word === 'string' ? item.word.trim() : ''
|
|
471
|
+
const start = Number(item?.startTime), end = Number(item?.endTime)
|
|
472
|
+
if (!word) continue
|
|
473
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < 0) throw new ParakeetError('bad_output', 'Parakeet word clock is not a finite, non-negative number')
|
|
474
|
+
const p = Number(item?.confidence)
|
|
475
|
+
words.push({ word, start, end: Math.max(start, end), ...(Number.isFinite(p) ? { probability: p } : {}) })
|
|
476
|
+
}
|
|
477
|
+
const processingSec = Number(data.processingTimeSeconds)
|
|
478
|
+
return { text: data.text.trim().replace(/\s+/g, ' '), words, processingSec: Number.isFinite(processingSec) ? processingSec : null }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** FluidAudio 0.17.4 fails a clip under about 0.3 s ("invalidAudioData", exit 0, no
|
|
482
|
+
* JSON); a meeting's last chunk is often 0.1 to 0.2 s. Pad with silence to this length. */
|
|
483
|
+
export const MIN_PARAKEET_AUDIO_SEC = 1.0
|
|
484
|
+
|
|
485
|
+
/** Zero-pad a 16 kHz mono PCM16 WAV shorter than minSec. Anything else is returned as is. */
|
|
486
|
+
export function padShortWav(wav: Buffer, minSec = MIN_PARAKEET_AUDIO_SEC): Buffer {
|
|
487
|
+
if (wav.length < 12 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') return wav
|
|
488
|
+
let offset = 12, fmtOk = false
|
|
489
|
+
while (offset + 8 <= wav.length) {
|
|
490
|
+
const id = wav.toString('ascii', offset, offset + 4)
|
|
491
|
+
const size = wav.readUInt32LE(offset + 4)
|
|
492
|
+
const body = offset + 8
|
|
493
|
+
if (id === 'fmt ' && body + 16 <= wav.length) {
|
|
494
|
+
fmtOk = wav.readUInt16LE(body) === 1 && wav.readUInt16LE(body + 2) === 1 && wav.readUInt32LE(body + 4) === 16_000 && wav.readUInt16LE(body + 14) === 16
|
|
495
|
+
}
|
|
496
|
+
if (id === 'data') {
|
|
497
|
+
if (!fmtOk) return wav
|
|
498
|
+
const pcm = wav.subarray(body, Math.min(wav.length, body + size))
|
|
499
|
+
const want = Math.round(minSec * 16_000) * 2
|
|
500
|
+
if (pcm.length >= want) return wav
|
|
501
|
+
const padded = Buffer.concat([pcm, Buffer.alloc(want - pcm.length)])
|
|
502
|
+
const header = Buffer.alloc(44)
|
|
503
|
+
header.write('RIFF', 0); header.writeUInt32LE(36 + padded.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
|
|
504
|
+
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(16_000, 24)
|
|
505
|
+
header.writeUInt32LE(32_000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(padded.length, 40)
|
|
506
|
+
return Buffer.concat([header, padded])
|
|
507
|
+
}
|
|
508
|
+
offset = body + size + (size % 2)
|
|
509
|
+
}
|
|
510
|
+
return wav
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let previewBusy = false
|
|
514
|
+
let batchTail: Promise<unknown> = Promise.resolve()
|
|
515
|
+
/** A prompt's Fast warm and HQ warm can overlap for one chunk; a third waits for Whisper. */
|
|
516
|
+
export const INTERACTIVE_LANE_MAX = 2
|
|
517
|
+
let interactiveRunning = 0
|
|
518
|
+
|
|
519
|
+
export function transcribeParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
|
|
520
|
+
if (options.lane === 'preview') {
|
|
521
|
+
if (previewBusy) return Promise.reject(new ParakeetError('busy', 'a preview decode is already running'))
|
|
522
|
+
previewBusy = true
|
|
523
|
+
return runParakeet(wav, options).finally(() => { previewBusy = false })
|
|
524
|
+
}
|
|
525
|
+
if (options.lane === 'interactive') {
|
|
526
|
+
if (interactiveRunning >= INTERACTIVE_LANE_MAX) return Promise.reject(new ParakeetError('busy', 'the interactive lane is full'))
|
|
527
|
+
interactiveRunning++
|
|
528
|
+
return runParakeet(wav, options).finally(() => { interactiveRunning-- })
|
|
529
|
+
}
|
|
530
|
+
if (options.lane === 'batch') {
|
|
531
|
+
const job = batchTail.then(() => runParakeet(wav, options))
|
|
532
|
+
batchTail = job.catch(() => undefined)
|
|
533
|
+
return job
|
|
534
|
+
}
|
|
535
|
+
return runParakeet(wav, options)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function runParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
|
|
539
|
+
// The installed CLI's full hash, once per process (and again if it changes on disk).
|
|
540
|
+
if (!options.spawnImpl && !(await isPinnedCli(parakeetCliPath()))) {
|
|
541
|
+
throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches its pinned SHA-256')
|
|
542
|
+
}
|
|
543
|
+
return runParakeetChecked(wav, options)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
|
|
547
|
+
const started = Date.now()
|
|
548
|
+
if (!parakeetInstalled().installed) return Promise.reject(new ParakeetError('not_installed', 'Parakeet is not installed'))
|
|
549
|
+
const sandbox = options.sandboxExec ?? SANDBOX_EXEC
|
|
550
|
+
if (!existsSync(sandbox)) return Promise.reject(new ParakeetError('sandbox_unavailable', 'sandbox-exec is missing, so Parakeet cannot be proven offline and does not run'))
|
|
551
|
+
if (options.signal?.aborted) return Promise.reject(new ParakeetError('aborted', 'aborted before start'))
|
|
552
|
+
const model = pinned(options.model)
|
|
553
|
+
let work: string
|
|
554
|
+
try {
|
|
555
|
+
work = mkdtempSync(join(tmpdir(), PARAKEET_TMP_PREFIX))
|
|
556
|
+
chmodSync(work, 0o700)
|
|
557
|
+
mkdirSync(parakeetHome(), { recursive: true, mode: 0o700 })
|
|
558
|
+
} catch (error) {
|
|
559
|
+
return Promise.reject(new ParakeetError('spawn_failed', `temp dir: ${error instanceof Error ? error.message : String(error)}`))
|
|
560
|
+
}
|
|
561
|
+
installExitHook()
|
|
562
|
+
liveDirs.add(work)
|
|
563
|
+
const input = join(work, 'in.wav'), output = join(work, 'out.json')
|
|
564
|
+
writeFileSync(input, padShortWav(wav), { mode: 0o600 })
|
|
565
|
+
const cleanup = () => { liveDirs.delete(work); try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ } }
|
|
566
|
+
const spawnImpl: SpawnLike = options.spawnImpl ?? (nodeSpawn as unknown as SpawnLike)
|
|
567
|
+
const args = ['-p', parakeetSandboxProfile(work, parakeetHome()), parakeetCliPath(), 'transcribe', input,
|
|
568
|
+
'--local-model-dir', parakeetModelDir(options.model), '--model-version', model.modelVersion, '--output-json', output]
|
|
569
|
+
|
|
570
|
+
return new Promise<ParakeetResult>((resolvePromise, reject) => {
|
|
571
|
+
let settled = false
|
|
572
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
573
|
+
const finish = (error: ParakeetError | null, value?: ParakeetResult) => {
|
|
574
|
+
if (settled) return
|
|
575
|
+
settled = true
|
|
576
|
+
if (timer) clearTimeout(timer)
|
|
577
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
578
|
+
cleanup()
|
|
579
|
+
if (error) reject(error)
|
|
580
|
+
else resolvePromise(value!)
|
|
581
|
+
}
|
|
582
|
+
let child: ChildProcess
|
|
583
|
+
try {
|
|
584
|
+
child = spawnImpl(sandbox, args, { stdio: ['ignore', 'pipe', 'pipe'], env: parakeetChildEnv(parakeetHome(), work) })
|
|
585
|
+
} catch (error) {
|
|
586
|
+
cleanup()
|
|
587
|
+
reject(new ParakeetError('spawn_failed', error instanceof Error ? error.message : String(error)))
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
const onAbort = () => { try { child.kill('SIGKILL') } catch { /* gone */ } finish(new ParakeetError('aborted', 'aborted')) }
|
|
591
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
592
|
+
liveChildren.add(child)
|
|
593
|
+
child.once('close', () => liveChildren.delete(child))
|
|
594
|
+
if (child.pid && options.lane !== 'preview' && options.lane !== 'interactive') {
|
|
595
|
+
try { setPriority(child.pid, 10) } catch { /* runs at normal priority */ }
|
|
596
|
+
}
|
|
597
|
+
let stderr = ''
|
|
598
|
+
child.stdout?.on('data', () => { /* the JSON file is the output */ })
|
|
599
|
+
child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 32_768) stderr += d.toString() })
|
|
600
|
+
timer = setTimeout(() => {
|
|
601
|
+
// SIGKILL: a CoreML compile does not stop for TERM.
|
|
602
|
+
try { child.kill('SIGKILL') } catch { /* gone */ }
|
|
603
|
+
finish(new ParakeetError('timeout', `no result within ${options.timeoutMs} ms`))
|
|
604
|
+
}, options.timeoutMs)
|
|
605
|
+
timer.unref?.()
|
|
606
|
+
child.on('error', error => finish(new ParakeetError('spawn_failed', error.message)))
|
|
607
|
+
child.on('close', (code, signal) => {
|
|
608
|
+
if (settled) return
|
|
609
|
+
if (code !== 0) { finish(new ParakeetError('cli_failed', `exit ${code ?? signal}: ${stderr.trim().split('\n').slice(-2).join(' | ').slice(0, 240)}`)); return }
|
|
610
|
+
if (/Unknown option|not supported|transcription failed/i.test(stderr)) { finish(new ParakeetError('cli_failed', 'the CLI rejected an option')); return }
|
|
611
|
+
try {
|
|
612
|
+
const parsed = parseParakeetJson(readFileSync(output, 'utf8'))
|
|
613
|
+
finish(null, { model: options.model, ...parsed, wallMs: Date.now() - started })
|
|
614
|
+
} catch (error) {
|
|
615
|
+
finish(error instanceof ParakeetError ? error : new ParakeetError('bad_output', error instanceof Error ? error.message : String(error)))
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── Warm state ────────────────────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
export type WarmState = 'cold' | 'warming' | 'warm' | 'failed'
|
|
624
|
+
interface WarmRecord { state: WarmState; at: number; firstMs?: number; warmMs?: number; error?: string }
|
|
625
|
+
const warm = new Map<ParakeetModelId, WarmRecord>()
|
|
626
|
+
const warming = new Map<ParakeetModelId, Promise<boolean>>()
|
|
627
|
+
/** A warm decode of the 8 s warm-up clip must come back inside this for the preview to use the model. */
|
|
628
|
+
export const WARM_DECODE_BUDGET_MS = 1_000
|
|
629
|
+
export const FIRST_LOAD_TIMEOUT_MS = 180_000
|
|
630
|
+
|
|
631
|
+
export function parakeetWarmState(model: ParakeetModelId): WarmRecord {
|
|
632
|
+
return warm.get(model) ?? { state: 'cold', at: 0 }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** 8 s of very quiet noise: the length of a typical preview phrase, so the
|
|
636
|
+
* warm decode's time says something about a real preview. */
|
|
637
|
+
export const WARMUP_SECONDS = 8
|
|
638
|
+
export function warmupWav(): Buffer {
|
|
639
|
+
const samples = WARMUP_SECONDS * 16_000
|
|
640
|
+
const pcm = Buffer.alloc(samples * 2)
|
|
641
|
+
let seed = 7
|
|
642
|
+
for (let i = 0; i < samples; i++) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; pcm.writeInt16LE((seed % 41) - 20, i * 2) }
|
|
643
|
+
const header = Buffer.alloc(44)
|
|
644
|
+
header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
|
|
645
|
+
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(16_000, 24)
|
|
646
|
+
header.writeUInt32LE(32_000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40)
|
|
647
|
+
return Buffer.concat([header, pcm])
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Compile (first run) then time a warm run. Warm only when the second run fits the budget. */
|
|
651
|
+
export function warmParakeet(model: ParakeetModelId, spawnImpl?: SpawnLike): Promise<boolean> {
|
|
652
|
+
const running = warming.get(model)
|
|
653
|
+
if (running) return running
|
|
654
|
+
const job = (async () => {
|
|
655
|
+
warm.set(model, { state: 'warming', at: Date.now() })
|
|
656
|
+
try {
|
|
657
|
+
const first = await transcribeParakeet(warmupWav(), { model, lane: 'warmup', timeoutMs: FIRST_LOAD_TIMEOUT_MS, spawnImpl })
|
|
658
|
+
const second = await transcribeParakeet(warmupWav(), { model, lane: 'warmup', timeoutMs: 10_000, spawnImpl })
|
|
659
|
+
const ok = second.wallMs <= WARM_DECODE_BUDGET_MS
|
|
660
|
+
warm.set(model, { state: ok ? 'warm' : 'failed', at: Date.now(), firstMs: first.wallMs, warmMs: second.wallMs, ...(ok ? {} : { error: `warm decode took ${second.wallMs} ms` }) })
|
|
661
|
+
return ok
|
|
662
|
+
} catch (error) {
|
|
663
|
+
warm.set(model, { state: 'failed', at: Date.now(), error: error instanceof Error ? error.message.slice(0, 200) : String(error) })
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
})().finally(() => warming.delete(model))
|
|
667
|
+
warming.set(model, job)
|
|
668
|
+
return job
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export function __resetParakeetForTests(): void {
|
|
672
|
+
warm.clear(); warming.clear(); previewBusy = false; interactiveRunning = 0; batchTail = Promise.resolve(); installing = null
|
|
673
|
+
progress = { state: 'idle', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null }
|
|
674
|
+
installedCache = null
|
|
675
|
+
}
|