@gotcos/glasses-server 6.71.14 → 6.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,665 @@
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
+ lane: 'preview' | 'batch' | 'warmup'
388
+ timeoutMs: number
389
+ signal?: AbortSignal
390
+ spawnImpl?: SpawnLike
391
+ /** Tests only: a different sandbox-exec path. There is no environment override. */
392
+ sandboxExec?: string
393
+ }
394
+
395
+ const liveChildren = new Set<ChildProcess>()
396
+ const liveDirs = new Set<string>()
397
+ let exitHookInstalled = false
398
+ function installExitHook(): void {
399
+ if (exitHookInstalled) return
400
+ exitHookInstalled = true
401
+ process.once('exit', () => { killParakeetChildren() })
402
+ }
403
+
404
+ export function killParakeetChildren(): { killed: number; removed: number } {
405
+ let killed = 0, removed = 0
406
+ for (const child of liveChildren) {
407
+ try { if (child.exitCode == null && child.signalCode == null) { child.kill('SIGKILL'); killed++ } } catch { /* gone */ }
408
+ }
409
+ liveChildren.clear()
410
+ for (const dir of liveDirs) { try { rmSync(dir, { recursive: true, force: true }); removed++ } catch { /* best effort */ } }
411
+ liveDirs.clear()
412
+ return { killed, removed }
413
+ }
414
+
415
+ /** Server start: temp directories a previous process left behind. */
416
+ export function sweepStaleParakeetTmp(tmpRoot = tmpdir(), minAgeMs = 60_000, now = Date.now()): number {
417
+ let removed = 0
418
+ try {
419
+ for (const name of readdirSync(tmpRoot)) {
420
+ if (!name.startsWith(PARAKEET_TMP_PREFIX)) continue
421
+ const path = join(tmpRoot, name)
422
+ try { if (now - statSync(path).mtimeMs >= minAgeMs) { rmSync(path, { recursive: true, force: true }); removed++ } } catch { /* raced */ }
423
+ }
424
+ } catch { /* no tmp root */ }
425
+ return removed
426
+ }
427
+
428
+ /**
429
+ * Sandbox profile: no network at all, and writes only to this run's temp
430
+ * directory and the private CoreML cache HOME (plus /dev streams). Reads stay
431
+ * allowed (the CLI loads system frameworks); that is a documented limit, not a
432
+ * guarantee about what it could read. Paths are realpath'd: the sandbox matches
433
+ * resolved paths, so a /tmp or symlinked data dir would otherwise never match.
434
+ */
435
+ export function parakeetSandboxProfile(workDir: string, homeDir: string, userTempDir: string | null = darwinUserTempDir()): string {
436
+ const q = (path: string) => { let real = path; try { real = realpathSync(path) } catch { /* not created yet */ } return real.replace(/\\/g, '\\\\').replace(/"/g, '\\"') }
437
+ // FluidAudio buffers audio over about 15 s in NSTemporaryDirectory (the per-user
438
+ // /var/folders/../T), not in TMPDIR; measured 2026-10-13, a 36 s HQ window failed without it.
439
+ const temp = userTempDir ? ` (subpath "${q(userTempDir)}")` : ''
440
+ return `(version 1)(allow default)(deny network*)(deny file-write*)`
441
+ + `(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"))`
442
+ }
443
+
444
+ let userTempCache: string | null | undefined
445
+ /** The per-user temp directory macOS gives NSTemporaryDirectory (confstr DARWIN_USER_TEMP_DIR). */
446
+ export function darwinUserTempDir(): string | null {
447
+ if (userTempCache !== undefined) return userTempCache
448
+ try {
449
+ const out = execFileSync('/usr/bin/getconf', ['DARWIN_USER_TEMP_DIR'], { encoding: 'utf8', timeout: 2_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
450
+ userTempCache = out.startsWith('/') ? out : null
451
+ } catch { userTempCache = null }
452
+ return userTempCache
453
+ }
454
+
455
+ /** The only variables the CLI sees. The server's tokens and keys never reach it. */
456
+ export function parakeetChildEnv(home: string, work: string, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
457
+ const out: NodeJS.ProcessEnv = { HOME: home, CFFIXED_USER_HOME: home, TMPDIR: work }
458
+ for (const key of ['PATH', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE']) if (typeof env[key] === 'string') out[key] = env[key]
459
+ return out
460
+ }
461
+
462
+ export function parseParakeetJson(raw: string): { text: string; words: HypWord[]; processingSec: number | null } {
463
+ const data = JSON.parse(raw) as { text?: unknown; wordTimings?: unknown; processingTimeSeconds?: unknown }
464
+ if (typeof data?.text !== 'string') throw new ParakeetError('bad_output', 'Parakeet JSON has no text')
465
+ const timings = Array.isArray(data.wordTimings) ? data.wordTimings : []
466
+ const words: HypWord[] = []
467
+ for (const item of timings as Array<Record<string, unknown>>) {
468
+ const word = typeof item?.word === 'string' ? item.word.trim() : ''
469
+ const start = Number(item?.startTime), end = Number(item?.endTime)
470
+ if (!word) continue
471
+ 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')
472
+ const p = Number(item?.confidence)
473
+ words.push({ word, start, end: Math.max(start, end), ...(Number.isFinite(p) ? { probability: p } : {}) })
474
+ }
475
+ const processingSec = Number(data.processingTimeSeconds)
476
+ return { text: data.text.trim().replace(/\s+/g, ' '), words, processingSec: Number.isFinite(processingSec) ? processingSec : null }
477
+ }
478
+
479
+ /** FluidAudio 0.17.4 fails a clip under about 0.3 s ("invalidAudioData", exit 0, no
480
+ * JSON); a meeting's last chunk is often 0.1 to 0.2 s. Pad with silence to this length. */
481
+ export const MIN_PARAKEET_AUDIO_SEC = 1.0
482
+
483
+ /** Zero-pad a 16 kHz mono PCM16 WAV shorter than minSec. Anything else is returned as is. */
484
+ export function padShortWav(wav: Buffer, minSec = MIN_PARAKEET_AUDIO_SEC): Buffer {
485
+ if (wav.length < 12 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') return wav
486
+ let offset = 12, fmtOk = false
487
+ while (offset + 8 <= wav.length) {
488
+ const id = wav.toString('ascii', offset, offset + 4)
489
+ const size = wav.readUInt32LE(offset + 4)
490
+ const body = offset + 8
491
+ if (id === 'fmt ' && body + 16 <= wav.length) {
492
+ fmtOk = wav.readUInt16LE(body) === 1 && wav.readUInt16LE(body + 2) === 1 && wav.readUInt32LE(body + 4) === 16_000 && wav.readUInt16LE(body + 14) === 16
493
+ }
494
+ if (id === 'data') {
495
+ if (!fmtOk) return wav
496
+ const pcm = wav.subarray(body, Math.min(wav.length, body + size))
497
+ const want = Math.round(minSec * 16_000) * 2
498
+ if (pcm.length >= want) return wav
499
+ const padded = Buffer.concat([pcm, Buffer.alloc(want - pcm.length)])
500
+ const header = Buffer.alloc(44)
501
+ header.write('RIFF', 0); header.writeUInt32LE(36 + padded.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
502
+ header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(16_000, 24)
503
+ header.writeUInt32LE(32_000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(padded.length, 40)
504
+ return Buffer.concat([header, padded])
505
+ }
506
+ offset = body + size + (size % 2)
507
+ }
508
+ return wav
509
+ }
510
+
511
+ let previewBusy = false
512
+ let batchTail: Promise<unknown> = Promise.resolve()
513
+
514
+ export function transcribeParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
515
+ if (options.lane === 'preview') {
516
+ if (previewBusy) return Promise.reject(new ParakeetError('busy', 'a preview decode is already running'))
517
+ previewBusy = true
518
+ return runParakeet(wav, options).finally(() => { previewBusy = false })
519
+ }
520
+ if (options.lane === 'batch') {
521
+ const job = batchTail.then(() => runParakeet(wav, options))
522
+ batchTail = job.catch(() => undefined)
523
+ return job
524
+ }
525
+ return runParakeet(wav, options)
526
+ }
527
+
528
+ async function runParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
529
+ // The installed CLI's full hash, once per process (and again if it changes on disk).
530
+ if (!options.spawnImpl && !(await isPinnedCli(parakeetCliPath()))) {
531
+ throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches its pinned SHA-256')
532
+ }
533
+ return runParakeetChecked(wav, options)
534
+ }
535
+
536
+ function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
537
+ const started = Date.now()
538
+ if (!parakeetInstalled().installed) return Promise.reject(new ParakeetError('not_installed', 'Parakeet is not installed'))
539
+ const sandbox = options.sandboxExec ?? SANDBOX_EXEC
540
+ if (!existsSync(sandbox)) return Promise.reject(new ParakeetError('sandbox_unavailable', 'sandbox-exec is missing, so Parakeet cannot be proven offline and does not run'))
541
+ if (options.signal?.aborted) return Promise.reject(new ParakeetError('aborted', 'aborted before start'))
542
+ const model = pinned(options.model)
543
+ let work: string
544
+ try {
545
+ work = mkdtempSync(join(tmpdir(), PARAKEET_TMP_PREFIX))
546
+ chmodSync(work, 0o700)
547
+ mkdirSync(parakeetHome(), { recursive: true, mode: 0o700 })
548
+ } catch (error) {
549
+ return Promise.reject(new ParakeetError('spawn_failed', `temp dir: ${error instanceof Error ? error.message : String(error)}`))
550
+ }
551
+ installExitHook()
552
+ liveDirs.add(work)
553
+ const input = join(work, 'in.wav'), output = join(work, 'out.json')
554
+ writeFileSync(input, padShortWav(wav), { mode: 0o600 })
555
+ const cleanup = () => { liveDirs.delete(work); try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ } }
556
+ const spawnImpl: SpawnLike = options.spawnImpl ?? (nodeSpawn as unknown as SpawnLike)
557
+ const args = ['-p', parakeetSandboxProfile(work, parakeetHome()), parakeetCliPath(), 'transcribe', input,
558
+ '--local-model-dir', parakeetModelDir(options.model), '--model-version', model.modelVersion, '--output-json', output]
559
+
560
+ return new Promise<ParakeetResult>((resolvePromise, reject) => {
561
+ let settled = false
562
+ let timer: ReturnType<typeof setTimeout> | null = null
563
+ const finish = (error: ParakeetError | null, value?: ParakeetResult) => {
564
+ if (settled) return
565
+ settled = true
566
+ if (timer) clearTimeout(timer)
567
+ options.signal?.removeEventListener('abort', onAbort)
568
+ cleanup()
569
+ if (error) reject(error)
570
+ else resolvePromise(value!)
571
+ }
572
+ let child: ChildProcess
573
+ try {
574
+ child = spawnImpl(sandbox, args, { stdio: ['ignore', 'pipe', 'pipe'], env: parakeetChildEnv(parakeetHome(), work) })
575
+ } catch (error) {
576
+ cleanup()
577
+ reject(new ParakeetError('spawn_failed', error instanceof Error ? error.message : String(error)))
578
+ return
579
+ }
580
+ const onAbort = () => { try { child.kill('SIGKILL') } catch { /* gone */ } finish(new ParakeetError('aborted', 'aborted')) }
581
+ options.signal?.addEventListener('abort', onAbort, { once: true })
582
+ liveChildren.add(child)
583
+ child.once('close', () => liveChildren.delete(child))
584
+ if (child.pid && options.lane !== 'preview') {
585
+ try { setPriority(child.pid, 10) } catch { /* runs at normal priority */ }
586
+ }
587
+ let stderr = ''
588
+ child.stdout?.on('data', () => { /* the JSON file is the output */ })
589
+ child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 32_768) stderr += d.toString() })
590
+ timer = setTimeout(() => {
591
+ // SIGKILL: a CoreML compile does not stop for TERM.
592
+ try { child.kill('SIGKILL') } catch { /* gone */ }
593
+ finish(new ParakeetError('timeout', `no result within ${options.timeoutMs} ms`))
594
+ }, options.timeoutMs)
595
+ timer.unref?.()
596
+ child.on('error', error => finish(new ParakeetError('spawn_failed', error.message)))
597
+ child.on('close', (code, signal) => {
598
+ if (settled) return
599
+ if (code !== 0) { finish(new ParakeetError('cli_failed', `exit ${code ?? signal}: ${stderr.trim().split('\n').slice(-2).join(' | ').slice(0, 240)}`)); return }
600
+ if (/Unknown option|not supported|transcription failed/i.test(stderr)) { finish(new ParakeetError('cli_failed', 'the CLI rejected an option')); return }
601
+ try {
602
+ const parsed = parseParakeetJson(readFileSync(output, 'utf8'))
603
+ finish(null, { model: options.model, ...parsed, wallMs: Date.now() - started })
604
+ } catch (error) {
605
+ finish(error instanceof ParakeetError ? error : new ParakeetError('bad_output', error instanceof Error ? error.message : String(error)))
606
+ }
607
+ })
608
+ })
609
+ }
610
+
611
+ // ── Warm state ────────────────────────────────────────────────────────────────
612
+
613
+ export type WarmState = 'cold' | 'warming' | 'warm' | 'failed'
614
+ interface WarmRecord { state: WarmState; at: number; firstMs?: number; warmMs?: number; error?: string }
615
+ const warm = new Map<ParakeetModelId, WarmRecord>()
616
+ const warming = new Map<ParakeetModelId, Promise<boolean>>()
617
+ /** A warm decode of the 8 s warm-up clip must come back inside this for the preview to use the model. */
618
+ export const WARM_DECODE_BUDGET_MS = 1_000
619
+ export const FIRST_LOAD_TIMEOUT_MS = 180_000
620
+
621
+ export function parakeetWarmState(model: ParakeetModelId): WarmRecord {
622
+ return warm.get(model) ?? { state: 'cold', at: 0 }
623
+ }
624
+
625
+ /** 8 s of very quiet noise: the length of a typical preview phrase, so the
626
+ * warm decode's time says something about a real preview. */
627
+ export const WARMUP_SECONDS = 8
628
+ export function warmupWav(): Buffer {
629
+ const samples = WARMUP_SECONDS * 16_000
630
+ const pcm = Buffer.alloc(samples * 2)
631
+ let seed = 7
632
+ for (let i = 0; i < samples; i++) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; pcm.writeInt16LE((seed % 41) - 20, i * 2) }
633
+ const header = Buffer.alloc(44)
634
+ header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
635
+ header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(16_000, 24)
636
+ header.writeUInt32LE(32_000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40)
637
+ return Buffer.concat([header, pcm])
638
+ }
639
+
640
+ /** Compile (first run) then time a warm run. Warm only when the second run fits the budget. */
641
+ export function warmParakeet(model: ParakeetModelId, spawnImpl?: SpawnLike): Promise<boolean> {
642
+ const running = warming.get(model)
643
+ if (running) return running
644
+ const job = (async () => {
645
+ warm.set(model, { state: 'warming', at: Date.now() })
646
+ try {
647
+ const first = await transcribeParakeet(warmupWav(), { model, lane: 'warmup', timeoutMs: FIRST_LOAD_TIMEOUT_MS, spawnImpl })
648
+ const second = await transcribeParakeet(warmupWav(), { model, lane: 'warmup', timeoutMs: 10_000, spawnImpl })
649
+ const ok = second.wallMs <= WARM_DECODE_BUDGET_MS
650
+ warm.set(model, { state: ok ? 'warm' : 'failed', at: Date.now(), firstMs: first.wallMs, warmMs: second.wallMs, ...(ok ? {} : { error: `warm decode took ${second.wallMs} ms` }) })
651
+ return ok
652
+ } catch (error) {
653
+ warm.set(model, { state: 'failed', at: Date.now(), error: error instanceof Error ? error.message.slice(0, 200) : String(error) })
654
+ return false
655
+ }
656
+ })().finally(() => warming.delete(model))
657
+ warming.set(model, job)
658
+ return job
659
+ }
660
+
661
+ export function __resetParakeetForTests(): void {
662
+ warm.clear(); warming.clear(); previewBusy = false; batchTail = Promise.resolve(); installing = null
663
+ progress = { state: 'idle', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null }
664
+ installedCache = null
665
+ }