@gotcos/glasses-server 6.73.14 → 6.75.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/CHANGELOG.md +33 -0
- package/managed-runtime-contract.json +7 -0
- package/package.json +1 -1
- package/server/index.ts +2 -0
- package/server/lib/diarizer-backend.ts +24 -2
- package/server/lib/dictation-benchmark.ts +197 -0
- package/server/lib/dictation-stt.ts +61 -2
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/jev.ts +12 -2
- package/server/lib/live-cues-engine.ts +3 -0
- package/server/lib/live-cues-v2-prompts.ts +41 -0
- package/server/lib/live-cues-v2.ts +639 -0
- package/server/lib/parakeet-cli-trust.ts +262 -0
- package/server/lib/parakeet.ts +156 -29
- package/server/lib/stt-runtime.ts +43 -11
- package/server/lib/stt-settings.ts +88 -3
- package/server/lib/transcribe-audio.ts +83 -11
- package/server/lib/work-chat.ts +28 -4
- package/server/routes/display.ts +2 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +48 -3
- package/server/routes/stt-settings.ts +55 -4
- package/server/routes/transcribe-stream.ts +2 -1
- package/server/routes/transcribe.ts +65 -15
- package/server/routes/work-chat.ts +4 -2
- package/shared/live-cues-v2.ts +77 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// 6.74.0: trust for a fluidaudiocli that COS Control ships signed (its Contents/Helpers).
|
|
2
|
+
//
|
|
3
|
+
// FluidAudio publishes no binary for v0.17.4, so COS Control builds it from the pinned
|
|
4
|
+
// source tag and signs it with the COS Developer ID. That build cannot match the SHA-256
|
|
5
|
+
// pin (parakeet-manifest.ts) because the signature is part of the bytes, so it is trusted
|
|
6
|
+
// on three proofs instead, all required:
|
|
7
|
+
//
|
|
8
|
+
// 1. `codesign --verify --strict` passes, and passes again against the requirement
|
|
9
|
+
// that the leaf certificate chains to Apple and names the COS team (a self-signed
|
|
10
|
+
// certificate can claim any team name; `anchor apple generic` rules that out).
|
|
11
|
+
// 2. The TeamIdentifier codesign reports is COS_TEAM_ID.
|
|
12
|
+
// 3. The codesign Identifier, and the CFBundleIdentifier of the Info.plist embedded in
|
|
13
|
+
// the binary, are both CLI_IDENTIFIER.
|
|
14
|
+
// 4. That embedded Info.plist is bound by the signature (`Info.plist entries=`) and its
|
|
15
|
+
// CFBundleShortVersionString is 0.17.4. The upstream 0.17.4 CLI has no version
|
|
16
|
+
// command, so the binary is never run to ask.
|
|
17
|
+
//
|
|
18
|
+
// The hand-placed SHA-256 pinned copy (parakeet.ts isPinnedCli) is unchanged.
|
|
19
|
+
|
|
20
|
+
import { execFile } from 'node:child_process'
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { constants as fsConstants, createReadStream, fstatSync, openSync, readSync, closeSync, statSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { basename, join } from 'node:path'
|
|
25
|
+
import { pipeline } from 'node:stream/promises'
|
|
26
|
+
import { Transform } from 'node:stream'
|
|
27
|
+
import { PARAKEET_CLI } from './parakeet-manifest.js'
|
|
28
|
+
|
|
29
|
+
/** The COS Developer ID team (read from /Applications/COS Control.app with codesign -dv, 2026-10-16). */
|
|
30
|
+
export const COS_TEAM_ID = 'NV3X46LLCR'
|
|
31
|
+
export const CODESIGN = '/usr/bin/codesign'
|
|
32
|
+
export const TRUST_REQUIREMENT = `=anchor apple generic and certificate leaf[subject.OU] = "${COS_TEAM_ID}"`
|
|
33
|
+
/** The codesign identifier and embedded CFBundleIdentifier of COS Control's build (agreed with Control). */
|
|
34
|
+
export const CLI_IDENTIFIER = 'com.gotcos.control.fluidaudiocli'
|
|
35
|
+
|
|
36
|
+
export type CliTrustReason = 'missing' | 'unsigned' | 'signature_invalid' | 'wrong_team' | 'wrong_identifier' | 'wrong_version'
|
|
37
|
+
|
|
38
|
+
export type CliTrust =
|
|
39
|
+
| { trusted: true; trust: 'developer-id'; path: string; teamId: string; version: string; versionSource: 'info-plist'; sha256: string; size: number }
|
|
40
|
+
| { trusted: false; path: string; reason: CliTrustReason; detail: string }
|
|
41
|
+
|
|
42
|
+
export interface CliTrustDeps {
|
|
43
|
+
/** codesign -dv: what it prints (stderr), and its exit code. */
|
|
44
|
+
display: (path: string) => Promise<{ code: number; output: string }>
|
|
45
|
+
/** codesign --verify --strict [-R requirement]. */
|
|
46
|
+
verify: (path: string, requirement?: string) => Promise<{ code: number; output: string }>
|
|
47
|
+
/** The Info.plist embedded in the Mach-O, or null. */
|
|
48
|
+
embeddedPlist: (path: string) => string | null
|
|
49
|
+
sha256: (path: string) => Promise<string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function run(command: string, args: string[], timeoutMs: number): Promise<{ code: number; output: string }> {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
execFile(command, args, { timeout: timeoutMs, maxBuffer: 256 * 1024, env: { PATH: '/usr/bin:/bin' } }, (error, stdout, stderr) => {
|
|
55
|
+
const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 1) : 0
|
|
56
|
+
resolve({ code, output: `${stdout ?? ''}${stderr ?? ''}` })
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sha256File(path: string): Promise<string> {
|
|
62
|
+
const hash = createHash('sha256')
|
|
63
|
+
await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
|
|
64
|
+
return hash.digest('hex')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const defaultCliTrustDeps: CliTrustDeps = {
|
|
68
|
+
display: path => run(CODESIGN, ['-dv', path], 10_000),
|
|
69
|
+
verify: (path, requirement) => run(CODESIGN, ['--verify', '--strict', ...(requirement ? ['-R', requirement] : []), path], 15_000),
|
|
70
|
+
embeddedPlist: path => readEmbeddedInfoPlist(path),
|
|
71
|
+
sha256: sha256File,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Mach-O __TEXT,__info_plist ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const MH_MAGIC_64 = 0xfeedfacf
|
|
77
|
+
const FAT_MAGIC = 0xcafebabe
|
|
78
|
+
const LC_SEGMENT_64 = 0x19
|
|
79
|
+
const CPU_TYPE_ARM64 = 0x0100000c
|
|
80
|
+
|
|
81
|
+
function readAt(fd: number, offset: number, length: number): Buffer {
|
|
82
|
+
const buf = Buffer.alloc(length)
|
|
83
|
+
const n = readSync(fd, buf, 0, length, offset)
|
|
84
|
+
return buf.subarray(0, n)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cstr(buf: Buffer, start: number): string {
|
|
88
|
+
const raw = buf.subarray(start, start + 16)
|
|
89
|
+
const end = raw.indexOf(0)
|
|
90
|
+
return raw.subarray(0, end < 0 ? 16 : end).toString('ascii')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The __TEXT,__info_plist section of a thin 64-bit or fat Mach-O (arm64 slice), or null. */
|
|
94
|
+
export function readEmbeddedInfoPlist(path: string): string | null {
|
|
95
|
+
let fd: number
|
|
96
|
+
// Non-blocking and no-follow: the path can come from a client, and a FIFO would
|
|
97
|
+
// block this synchronous open, and with it the whole server (hazard-invariants.test.ts).
|
|
98
|
+
try { fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW) } catch { return null }
|
|
99
|
+
try { if (!fstatSync(fd).isFile()) { closeSync(fd); return null } } catch { closeSync(fd); return null }
|
|
100
|
+
try {
|
|
101
|
+
let base = 0
|
|
102
|
+
const head = readAt(fd, 0, 8)
|
|
103
|
+
if (head.length < 8) return null
|
|
104
|
+
if (head.readUInt32BE(0) === FAT_MAGIC) {
|
|
105
|
+
const count = head.readUInt32BE(4)
|
|
106
|
+
if (count > 16) return null
|
|
107
|
+
const arches = readAt(fd, 8, count * 20)
|
|
108
|
+
base = -1
|
|
109
|
+
for (let i = 0; i < count; i++) {
|
|
110
|
+
if (arches.readUInt32BE(i * 20) === CPU_TYPE_ARM64) { base = arches.readUInt32BE(i * 20 + 8); break }
|
|
111
|
+
}
|
|
112
|
+
if (base < 0) return null
|
|
113
|
+
}
|
|
114
|
+
const header = readAt(fd, base, 32)
|
|
115
|
+
if (header.length < 32 || header.readUInt32LE(0) !== MH_MAGIC_64) return null
|
|
116
|
+
const ncmds = header.readUInt32LE(16)
|
|
117
|
+
const sizeofcmds = header.readUInt32LE(20)
|
|
118
|
+
if (sizeofcmds > 4 * 1024 * 1024) return null
|
|
119
|
+
const cmds = readAt(fd, base + 32, sizeofcmds)
|
|
120
|
+
let off = 0
|
|
121
|
+
for (let i = 0; i < ncmds && off + 8 <= cmds.length; i++) {
|
|
122
|
+
const cmd = cmds.readUInt32LE(off)
|
|
123
|
+
const cmdsize = cmds.readUInt32LE(off + 4)
|
|
124
|
+
if (cmdsize < 8) return null
|
|
125
|
+
if (cmd === LC_SEGMENT_64 && cstr(cmds, off + 8) === '__TEXT') {
|
|
126
|
+
const nsects = cmds.readUInt32LE(off + 64)
|
|
127
|
+
for (let s = 0; s < nsects; s++) {
|
|
128
|
+
const sec = off + 72 + s * 80
|
|
129
|
+
if (sec + 80 > cmds.length) return null
|
|
130
|
+
if (cstr(cmds, sec) !== '__info_plist') continue
|
|
131
|
+
const size = Number(cmds.readBigUInt64LE(sec + 40))
|
|
132
|
+
const offset = cmds.readUInt32LE(sec + 48)
|
|
133
|
+
if (size <= 0 || size > 64 * 1024) return null
|
|
134
|
+
return readAt(fd, base + offset, size).toString('utf8').replace(/\0+$/, '')
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
off += cmdsize
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
} catch { return null } finally { closeSync(fd) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function plistString(xml: string, key: string): string | null {
|
|
144
|
+
const m = new RegExp(`<key>${key}</key>\\s*<string>([^<]{1,128})</string>`).exec(xml)
|
|
145
|
+
return m ? m[1].trim() : null
|
|
146
|
+
}
|
|
147
|
+
/** CFBundleShortVersionString from an XML plist. */
|
|
148
|
+
export function plistVersion(xml: string): string | null { return plistString(xml, 'CFBundleShortVersionString') }
|
|
149
|
+
export function plistIdentifier(xml: string): string | null { return plistString(xml, 'CFBundleIdentifier') }
|
|
150
|
+
|
|
151
|
+
/** 0.17.4 as a whole token, so 0.17.40 or 10.17.4 never pass. */
|
|
152
|
+
export function versionMatches(text: string, want: string = PARAKEET_CLI.version): boolean {
|
|
153
|
+
const escaped = want.replace(/\./g, '\\.')
|
|
154
|
+
return new RegExp(`(^|[^0-9.])v?${escaped}(?![0-9]|\\.[0-9])`).test(text)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function parseCodesignDisplay(output: string): { teamId: string | null; identifier: string | null; adhoc: boolean; unsigned: boolean; plistBound: boolean } {
|
|
158
|
+
const team = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null
|
|
159
|
+
return {
|
|
160
|
+
teamId: team && team !== 'not set' ? team : null,
|
|
161
|
+
identifier: /^Identifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null,
|
|
162
|
+
adhoc: /^Signature=adhoc$/m.test(output) || /flags=0x[0-9a-f]*\([^)]*adhoc/.test(output),
|
|
163
|
+
unsigned: /code object is not signed at all/i.test(output),
|
|
164
|
+
plistBound: /^Info\.plist entries=\d+/m.test(output),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Every proof, in order; the first that fails names the reason. Never throws. */
|
|
169
|
+
export async function checkDeveloperIdCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
|
|
170
|
+
const no = (reason: CliTrustReason, detail: string): CliTrust => ({ trusted: false, path, reason, detail })
|
|
171
|
+
let size: number
|
|
172
|
+
try { const st = statSync(path); if (!st.isFile()) return no('missing', 'not a file'); size = st.size } catch { return no('missing', 'not found') }
|
|
173
|
+
try {
|
|
174
|
+
const shown = await deps.display(path)
|
|
175
|
+
const d = parseCodesignDisplay(shown.output)
|
|
176
|
+
if (d.unsigned || shown.code !== 0) return no('unsigned', 'the speech tool is not signed')
|
|
177
|
+
if (d.adhoc || !d.teamId) return no('unsigned', 'the speech tool has no Developer ID signature')
|
|
178
|
+
const strict = await deps.verify(path)
|
|
179
|
+
if (strict.code !== 0) return no('signature_invalid', strict.output.trim().split('\n').slice(-1)[0]?.slice(0, 200) || 'the signature does not verify')
|
|
180
|
+
if (d.teamId !== COS_TEAM_ID) return no('wrong_team', `signed by team ${d.teamId}`)
|
|
181
|
+
const anchored = await deps.verify(path, TRUST_REQUIREMENT)
|
|
182
|
+
if (anchored.code !== 0) return no('wrong_team', 'the signature does not chain to Apple for the COS team')
|
|
183
|
+
if (d.identifier !== CLI_IDENTIFIER) return no('wrong_identifier', `signed as ${d.identifier ?? 'no identifier'}`)
|
|
184
|
+
// Only a plist the signature binds counts; the binary is never run to ask its version.
|
|
185
|
+
const plist = d.plistBound ? deps.embeddedPlist(path) : null
|
|
186
|
+
if (!plist) return no('wrong_version', 'no signed Info.plist in the speech tool')
|
|
187
|
+
const bundleId = plistIdentifier(plist)
|
|
188
|
+
if (bundleId !== CLI_IDENTIFIER) return no('wrong_identifier', `its Info.plist names ${bundleId ?? 'no identifier'}`)
|
|
189
|
+
const named = plistVersion(plist)
|
|
190
|
+
if (!named || !versionMatches(named) || named.replace(/^v/, '') !== PARAKEET_CLI.version) return no('wrong_version', `version ${named ?? 'unknown'}, not FluidAudio ${PARAKEET_CLI.version}`)
|
|
191
|
+
const sha256 = await deps.sha256(path)
|
|
192
|
+
// The bytes hashed must be the bytes checked.
|
|
193
|
+
if (statSync(path).size !== size) return no('signature_invalid', 'the file changed while it was checked')
|
|
194
|
+
return { trusted: true, trust: 'developer-id', path, teamId: COS_TEAM_ID, version: PARAKEET_CLI.version, versionSource: 'info-plist', sha256, size }
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return no('signature_invalid', error instanceof Error ? error.message.slice(0, 200) : String(error))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Where COS Control's copy is, and a per-process cache for GET ──────────────
|
|
201
|
+
|
|
202
|
+
const HELPER_REL = join('COS Control.app', 'Contents', 'Helpers', 'fluidaudiocli')
|
|
203
|
+
|
|
204
|
+
/** COS Control in /Applications or ~/Applications, plus any path Control registered. */
|
|
205
|
+
export function controlCliCandidates(home: string = homedir()): string[] {
|
|
206
|
+
return [...offered, join(applicationsRoot, HELPER_REL), join(home, 'Applications', HELPER_REL)]
|
|
207
|
+
}
|
|
208
|
+
let applicationsRoot = '/Applications'
|
|
209
|
+
/** Tests only: keep the real /Applications out of a test's view. */
|
|
210
|
+
export function __setApplicationsRootForTests(root: string | null): void { applicationsRoot = root ?? '/Applications' }
|
|
211
|
+
|
|
212
|
+
const offered: string[] = []
|
|
213
|
+
/** An absolute path whose file name is fluidaudiocli. Anything else is never checked or run. */
|
|
214
|
+
export function isCliPathShape(path: unknown): path is string {
|
|
215
|
+
return typeof path === 'string' && path.startsWith('/') && path.length <= 1024 && !path.includes('\0') && basename(path) === 'fluidaudiocli'
|
|
216
|
+
}
|
|
217
|
+
/** Control tells the server where its bundled CLI is (POST /api/stt-settings/parakeet/cli). */
|
|
218
|
+
export function offerControlCli(path: string): void {
|
|
219
|
+
if (!isCliPathShape(path)) return
|
|
220
|
+
if (!offered.includes(path)) offered.unshift(path)
|
|
221
|
+
if (offered.length > 4) offered.length = 4
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const cache = new Map<string, CliTrust>()
|
|
225
|
+
const pending = new Map<string, Promise<CliTrust>>()
|
|
226
|
+
function cacheKey(path: string): string | null {
|
|
227
|
+
try { const st = statSync(path); return st.isFile() ? `${path}\0${st.size}\0${st.mtimeMs}\0${st.ino}` : null } catch { return null }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Check (once per path, size, mtime and inode) and remember. */
|
|
231
|
+
export function trustCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
|
|
232
|
+
const key = cacheKey(path)
|
|
233
|
+
if (!key) return Promise.resolve({ trusted: false, path, reason: 'missing', detail: 'not found' })
|
|
234
|
+
const known = cache.get(key)
|
|
235
|
+
if (known) return Promise.resolve(known)
|
|
236
|
+
const running = pending.get(key)
|
|
237
|
+
if (running) return running
|
|
238
|
+
const job = checkDeveloperIdCli(path, deps).then(r => { cache.set(key, r); return r }).finally(() => pending.delete(key))
|
|
239
|
+
pending.set(key, job)
|
|
240
|
+
return job
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Synchronous, for GET: the known result for this file, null while unchecked (a check starts). */
|
|
244
|
+
export function knownCliTrust(path: string): CliTrust | null {
|
|
245
|
+
const key = cacheKey(path)
|
|
246
|
+
if (!key) return { trusted: false, path, reason: 'missing', detail: 'not found' }
|
|
247
|
+
const known = cache.get(key)
|
|
248
|
+
if (known) return known
|
|
249
|
+
void trustCli(path)
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The first trusted candidate, or null. */
|
|
254
|
+
export async function findTrustedCli(candidates: string[] = controlCliCandidates(), deps?: CliTrustDeps): Promise<Extract<CliTrust, { trusted: true }> | null> {
|
|
255
|
+
for (const c of candidates) {
|
|
256
|
+
const r = await trustCli(c, deps)
|
|
257
|
+
if (r.trusted) return r
|
|
258
|
+
}
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function __resetCliTrustForTests(): void { cache.clear(); pending.clear(); offered.length = 0; applicationsRoot = '/Applications' }
|
package/server/lib/parakeet.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions
|
|
|
20
20
|
import { createHash, randomBytes } from 'node:crypto'
|
|
21
21
|
import {
|
|
22
22
|
chmodSync, copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync,
|
|
23
|
-
readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync,
|
|
23
|
+
readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, writeFileSync,
|
|
24
24
|
} from 'node:fs'
|
|
25
25
|
import { homedir, setPriority, tmpdir } from 'node:os'
|
|
26
26
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -29,6 +29,7 @@ import { Readable, Transform } from 'node:stream'
|
|
|
29
29
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
30
30
|
import { dataPath } from './data-dir.js'
|
|
31
31
|
import { PARAKEET_CLI, PARAKEET_MODELS, type PinnedModel } from './parakeet-manifest.js'
|
|
32
|
+
import { checkDeveloperIdCli, controlCliCandidates, COS_TEAM_ID, findTrustedCli, isCliPathShape, type CliTrustDeps, type CliTrustReason } from './parakeet-cli-trust.js'
|
|
32
33
|
import type { HypWord } from './stt-resolver.js'
|
|
33
34
|
|
|
34
35
|
export type ParakeetModelId = 'parakeet-ultra'
|
|
@@ -57,18 +58,23 @@ function pinned(id: ParakeetModelId): PinnedModel {
|
|
|
57
58
|
// ── Install state ─────────────────────────────────────────────────────────────
|
|
58
59
|
|
|
59
60
|
export type InstallPhase = 'idle' | 'downloading' | 'verifying' | 'installed' | 'failed'
|
|
61
|
+
/** 6.74.0: why an install failed, for a client that picks the action from a code. */
|
|
62
|
+
export type InstallErrorCode = 'cancelled' | 'insufficient_disk' | 'sha_mismatch' | 'download_failed' | 'stalled' | 'cli_untrusted' | 'cli_missing' | 'failed'
|
|
60
63
|
export interface InstallProgress {
|
|
61
64
|
state: InstallPhase
|
|
62
65
|
bytesDone: number
|
|
63
66
|
bytesTotal: number
|
|
64
67
|
error: string | null
|
|
68
|
+
errorCode?: InstallErrorCode | null
|
|
65
69
|
/** Where the bytes came from: files copied from a verified local copy, and files downloaded. */
|
|
66
70
|
source?: { filesLocal: number; filesDownloaded: number; localSources: string[] }
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
export interface InstallReceipt {
|
|
70
74
|
schema: 1
|
|
71
|
-
|
|
75
|
+
/** trust (6.74.0): 'sha256' (absent before 6.74.0) is the pinned upstream bytes;
|
|
76
|
+
* 'developer-id' is COS Control's signed build, its own hash and size recorded at install. */
|
|
77
|
+
cli: { version: string; sha256: string; size?: number; trust?: 'sha256' | 'developer-id'; teamId?: string }
|
|
72
78
|
models: Array<{ id: string; revision: string; bytes: number }>
|
|
73
79
|
bytes: number
|
|
74
80
|
installedAt: string
|
|
@@ -94,7 +100,7 @@ export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes
|
|
|
94
100
|
try {
|
|
95
101
|
const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt
|
|
96
102
|
const sizeOk = (path: string, size: number) => { try { return statSync(path).size === size } catch { return false } }
|
|
97
|
-
const cliOk = receipt?.schema === 1 && receipt.cli
|
|
103
|
+
const cliOk = receipt?.schema === 1 && receiptCliOk(receipt.cli, sizeOk)
|
|
98
104
|
const modelsOk = PARAKEET_MODELS.every(m =>
|
|
99
105
|
receipt.models?.some(r => r.id === m.id && r.revision === m.revision)
|
|
100
106
|
&& m.files.every(f => sizeOk(join(parakeetModelDir(m.id as ParakeetModelId), f.path), f.size)))
|
|
@@ -104,6 +110,26 @@ export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes
|
|
|
104
110
|
return value
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/** The receipt's CLI is one this server trusts, and the installed file has its size. */
|
|
114
|
+
function receiptCliOk(cli: InstallReceipt['cli'] | undefined, sizeOk: (path: string, size: number) => boolean): boolean {
|
|
115
|
+
if (!cli || cli.version !== PARAKEET_CLI.version) return false
|
|
116
|
+
if (cli.trust === 'developer-id') {
|
|
117
|
+
return cli.teamId === COS_TEAM_ID && typeof cli.sha256 === 'string' && /^[0-9a-f]{64}$/.test(cli.sha256)
|
|
118
|
+
&& typeof cli.size === 'number' && sizeOk(parakeetCliPath(), cli.size)
|
|
119
|
+
}
|
|
120
|
+
return cli.sha256 === PARAKEET_CLI.sha256 && sizeOk(parakeetCliPath(), PARAKEET_CLI.size)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readReceipt(): InstallReceipt | null {
|
|
124
|
+
try { return JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt } catch { return null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 6.74.0: how the installed CLI was trusted, for GET. */
|
|
128
|
+
export function installedCliTrust(): 'sha256' | 'developer-id' | null {
|
|
129
|
+
const cli = readReceipt()?.cli
|
|
130
|
+
return cli ? cli.trust ?? 'sha256' : null
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
export function resetParakeetInstallCache(): void { installedCache = null; cliHashCache.clear() }
|
|
108
134
|
|
|
109
135
|
async function sha256File(path: string): Promise<string> {
|
|
@@ -185,7 +211,32 @@ function modelDirCandidates(model: PinnedModel, sources: string[]): string[] {
|
|
|
185
211
|
}
|
|
186
212
|
|
|
187
213
|
export class ParakeetInstallError extends Error {
|
|
188
|
-
constructor(message: string) { super(message); this.name = 'ParakeetInstallError' }
|
|
214
|
+
constructor(message: string, readonly code: InstallErrorCode = 'failed') { super(message); this.name = 'ParakeetInstallError' }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 6.74.0: an install needs this much free space where it installs (654 MB of model,
|
|
218
|
+
* the CLI, a staging copy of each file before the swap, and headroom). */
|
|
219
|
+
export const INSTALL_MIN_FREE_BYTES = 1_500_000_000
|
|
220
|
+
|
|
221
|
+
/** Free bytes on the volume holding `dir` (its nearest existing ancestor), or null when unknown. */
|
|
222
|
+
export function freeBytesAt(dir: string): number | null {
|
|
223
|
+
let probe = resolve(dir)
|
|
224
|
+
for (let i = 0; i < 64; i++) {
|
|
225
|
+
if (existsSync(probe)) {
|
|
226
|
+
try { const st = statfsSync(probe); return Number(st.bavail) * Number(st.bsize) } catch { return null }
|
|
227
|
+
}
|
|
228
|
+
const up = dirname(probe)
|
|
229
|
+
if (up === probe) return null
|
|
230
|
+
probe = up
|
|
231
|
+
}
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The disk precheck the install route and the installer share. */
|
|
236
|
+
export function parakeetDiskCheck(freeBytes: (dir: string) => number | null = freeBytesAt, required = INSTALL_MIN_FREE_BYTES): { ok: boolean; freeBytes: number | null; requiredBytes: number } {
|
|
237
|
+
const free = freeBytes(parakeetRoot())
|
|
238
|
+
// Unknown free space does not block: the per-file writes still fail plainly on a full disk.
|
|
239
|
+
return { ok: free === null || free >= required, freeBytes: free, requiredBytes: required }
|
|
189
240
|
}
|
|
190
241
|
|
|
191
242
|
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; body: ReadableStream<Uint8Array> | null }>
|
|
@@ -201,6 +252,12 @@ export interface InstallOptions {
|
|
|
201
252
|
cliCandidates?: string[]
|
|
202
253
|
fetchImpl?: FetchLike
|
|
203
254
|
idleTimeoutMs?: number
|
|
255
|
+
/** 6.74.0, tests: codesign and version probes for a Developer ID CLI. */
|
|
256
|
+
trustDeps?: CliTrustDeps
|
|
257
|
+
/** 6.74.0, tests: where COS Control's signed CLI may be (defaults to /Applications and ~/Applications). */
|
|
258
|
+
controlCliCandidates?: string[]
|
|
259
|
+
/** 6.74.0, tests: free space probe. */
|
|
260
|
+
freeBytes?: (dir: string) => number | null
|
|
204
261
|
}
|
|
205
262
|
|
|
206
263
|
/** Start the install, or join the one already running. */
|
|
@@ -216,27 +273,53 @@ export function parakeetInstallRunning(): boolean { return installing !== null }
|
|
|
216
273
|
/** Stop a running install; nothing partial is kept. */
|
|
217
274
|
export function cancelParakeetInstall(): boolean {
|
|
218
275
|
if (!installAbort) return false
|
|
219
|
-
installAbort.abort(new ParakeetInstallError('The install was cancelled.'))
|
|
276
|
+
installAbort.abort(new ParakeetInstallError('The install was cancelled.', 'cancelled'))
|
|
220
277
|
return true
|
|
221
278
|
}
|
|
222
279
|
|
|
223
280
|
async function runInstall(options: InstallOptions, signal: AbortSignal): Promise<void> {
|
|
224
281
|
const root = parakeetRoot()
|
|
225
282
|
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 })
|
|
283
|
+
progress = { state: 'verifying', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null, errorCode: null, source: { filesLocal: 0, filesDownloaded: 0, localSources: [] } }
|
|
230
284
|
const usedSources = new Set<string>()
|
|
285
|
+
let staging: string | null = null
|
|
231
286
|
try {
|
|
232
|
-
//
|
|
287
|
+
// 0. Disk precheck (6.74.0), before anything is written.
|
|
288
|
+
const disk = parakeetDiskCheck(options.freeBytes)
|
|
289
|
+
if (!disk.ok) {
|
|
290
|
+
throw new ParakeetInstallError(`Parakeet needs ${formatGb(disk.requiredBytes)} free on this Mac; ${formatGb(disk.freeBytes ?? 0)} is free. Free some space, then install again. Whisper keeps working.`, 'insufficient_disk')
|
|
291
|
+
}
|
|
292
|
+
mkdirSync(root, { recursive: true, mode: 0o700 })
|
|
293
|
+
staging = join(root, `.staging-${randomBytes(6).toString('hex')}`)
|
|
294
|
+
mkdirSync(join(staging, 'bin'), { recursive: true, mode: 0o700 })
|
|
295
|
+
|
|
296
|
+
// 1. The CLI. FluidAudio publishes no binary: a SHA-256 pinned local copy, or
|
|
297
|
+
// (6.74.0) the build COS Control ships signed with the COS Developer ID.
|
|
233
298
|
const cliTarget = join(staging, 'bin', 'fluidaudiocli')
|
|
234
299
|
const cli = await findPinnedCli([...(options.cliCandidates ?? []), ...(options.localSources ?? []).map(src => fileSize(src) !== null ? src : join(src, 'fluidaudiocli')), ...defaultCliCandidates()])
|
|
235
|
-
|
|
236
|
-
|
|
300
|
+
let receiptCli: InstallReceipt['cli']
|
|
301
|
+
if (cli) {
|
|
302
|
+
copyFileSync(cli, cliTarget)
|
|
303
|
+
if (await sha256File(cliTarget) !== PARAKEET_CLI.sha256) throw new ParakeetInstallError('The FluidAudio CLI changed while it was being copied. Try again.')
|
|
304
|
+
receiptCli = { version: PARAKEET_CLI.version, sha256: PARAKEET_CLI.sha256, size: PARAKEET_CLI.size, trust: 'sha256' }
|
|
305
|
+
} else {
|
|
306
|
+
const signedCandidates = [...new Set([...(options.cliCandidates ?? []).filter(isCliPathShape), ...(options.controlCliCandidates ?? controlCliCandidates())])]
|
|
307
|
+
const signed = await findTrustedCli(signedCandidates, options.trustDeps)
|
|
308
|
+
if (!signed) {
|
|
309
|
+
const seen = signedCandidates.find(path => fileSize(path) !== null)
|
|
310
|
+
if (seen) {
|
|
311
|
+
const why = await checkDeveloperIdCli(seen, options.trustDeps)
|
|
312
|
+
const reason: CliTrustReason = why.trusted ? 'signature_invalid' : why.reason
|
|
313
|
+
throw new ParakeetInstallError(`The speech tool in COS Control did not pass its check (${reason}). Reinstall COS Control, then install again.`, 'cli_untrusted')
|
|
314
|
+
}
|
|
315
|
+
throw new ParakeetInstallError(`The speech tool Parakeet needs is not on this Mac. Update COS Control, which includes it, then install again.`, 'cli_missing')
|
|
316
|
+
}
|
|
317
|
+
copyFileSync(signed.path, cliTarget)
|
|
318
|
+
// Prove the copy, not only the source: the installed bytes must be signed and unchanged.
|
|
319
|
+
const copy = await checkDeveloperIdCli(cliTarget, options.trustDeps)
|
|
320
|
+
if (!copy.trusted || copy.sha256 !== signed.sha256) throw new ParakeetInstallError('The speech tool changed while it was being copied. Try again.', 'cli_untrusted')
|
|
321
|
+
receiptCli = { version: PARAKEET_CLI.version, sha256: copy.sha256, size: copy.size, trust: 'developer-id', teamId: COS_TEAM_ID }
|
|
237
322
|
}
|
|
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
323
|
chmodSync(cliTarget, 0o755)
|
|
241
324
|
progress.bytesDone += PARAKEET_CLI.size
|
|
242
325
|
|
|
@@ -246,7 +329,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
|
|
|
246
329
|
for (const model of PARAKEET_MODELS) {
|
|
247
330
|
const candidates = modelDirCandidates(model, localSources)
|
|
248
331
|
for (const file of model.files) {
|
|
249
|
-
if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.')
|
|
332
|
+
if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.', 'cancelled')
|
|
250
333
|
const target = join(staging, 'models', model.id, file.path)
|
|
251
334
|
mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
|
|
252
335
|
let placed = false
|
|
@@ -268,12 +351,12 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
|
|
|
268
351
|
const url = `https://huggingface.co/${model.repo}/resolve/${model.revision}/${file.path.split('/').map(encodeURIComponent).join('/')}`
|
|
269
352
|
// One controller per file: user cancel, or no bytes for idleMs.
|
|
270
353
|
const ctl = new AbortController()
|
|
271
|
-
const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.'))
|
|
354
|
+
const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.', 'cancelled'))
|
|
272
355
|
signal.addEventListener('abort', onCancel, { once: true })
|
|
273
356
|
let idle: ReturnType<typeof setTimeout> | null = null
|
|
274
357
|
const arm = () => {
|
|
275
358
|
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
|
|
359
|
+
idle = setTimeout(() => ctl.abort(new ParakeetInstallError(`The download stalled (no data for ${Math.round(idleMs / 1000)} s). Nothing was installed; try again.`, 'stalled')), idleMs)
|
|
277
360
|
idle.unref?.()
|
|
278
361
|
}
|
|
279
362
|
arm()
|
|
@@ -281,18 +364,20 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
|
|
|
281
364
|
let written = 0
|
|
282
365
|
try {
|
|
283
366
|
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}
|
|
367
|
+
if (!res.ok || !res.body) throw new ParakeetInstallError(`Download failed (${res.status}) for ${model.id} ${file.path}.`, 'download_failed')
|
|
285
368
|
const counter = new Transform({ transform(chunk, _e, cb) {
|
|
286
369
|
arm()
|
|
287
370
|
written += chunk.length
|
|
288
|
-
if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size
|
|
371
|
+
if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size.`, 'sha_mismatch')); return }
|
|
289
372
|
progress.bytesDone += chunk.length
|
|
290
373
|
cb(null, chunk)
|
|
291
374
|
} })
|
|
292
375
|
await pipeline(Readable.fromWeb(res.body as any), counter, createWriteStream(partial, { mode: 0o600 }), { signal: ctl.signal })
|
|
293
376
|
} catch (error) {
|
|
294
377
|
const why = ctl.signal.reason
|
|
295
|
-
|
|
378
|
+
if (why instanceof ParakeetInstallError) throw why
|
|
379
|
+
if (error instanceof ParakeetInstallError) throw error
|
|
380
|
+
throw new ParakeetInstallError(`Download failed for ${model.id} ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'download_failed')
|
|
296
381
|
} finally {
|
|
297
382
|
if (idle) clearTimeout(idle)
|
|
298
383
|
signal.removeEventListener('abort', onCancel)
|
|
@@ -303,7 +388,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
|
|
|
303
388
|
}
|
|
304
389
|
progress.state = 'verifying'
|
|
305
390
|
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
|
|
391
|
+
throw new ParakeetInstallError(`${model.id} ${file.path} does not match its pinned SHA-256. Nothing was installed.`, 'sha_mismatch')
|
|
307
392
|
}
|
|
308
393
|
progress.bytesDone += file.size
|
|
309
394
|
}
|
|
@@ -312,7 +397,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
|
|
|
312
397
|
|
|
313
398
|
const receipt: InstallReceipt = {
|
|
314
399
|
schema: 1,
|
|
315
|
-
cli:
|
|
400
|
+
cli: receiptCli,
|
|
316
401
|
models: PARAKEET_MODELS.map(m => ({ id: m.id, revision: m.revision, bytes: m.files.reduce((n, f) => n + f.size, 0) })),
|
|
317
402
|
bytes: TOTAL_BYTES,
|
|
318
403
|
installedAt: new Date().toISOString(),
|
|
@@ -324,20 +409,23 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
|
|
|
324
409
|
const current = currentDir()
|
|
325
410
|
const old = existsSync(current) ? join(root, `.old-${Date.now()}-${randomBytes(3).toString('hex')}`) : null
|
|
326
411
|
if (old) renameSync(current, old)
|
|
327
|
-
renameSync(staging
|
|
412
|
+
renameSync(staging!, current)
|
|
328
413
|
if (old) rmSync(old, { recursive: true, force: true })
|
|
329
414
|
mkdirSync(parakeetHome(), { recursive: true, mode: 0o700 })
|
|
330
415
|
resetParakeetInstallCache()
|
|
331
416
|
for (const id of warm.keys()) warm.delete(id)
|
|
332
|
-
progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null }
|
|
417
|
+
progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null, errorCode: null }
|
|
333
418
|
} catch (error) {
|
|
334
|
-
rmSync(staging, { recursive: true, force: true })
|
|
419
|
+
if (staging) rmSync(staging, { recursive: true, force: true })
|
|
335
420
|
const message = error instanceof ParakeetInstallError ? error.message : `Install failed: ${error instanceof Error ? error.message : String(error)}`
|
|
336
|
-
|
|
337
|
-
|
|
421
|
+
const code: InstallErrorCode = error instanceof ParakeetInstallError ? error.code : 'failed'
|
|
422
|
+
progress = { ...progress, state: 'failed', bytesTotal: TOTAL_BYTES, error: message, errorCode: code, source: progress.source ? { ...progress.source, localSources: [...usedSources] } : undefined }
|
|
423
|
+
throw new ParakeetInstallError(message, code)
|
|
338
424
|
}
|
|
339
425
|
}
|
|
340
426
|
|
|
427
|
+
function formatGb(bytes: number): string { return `${(bytes / 1e9).toFixed(1)} GB` }
|
|
428
|
+
|
|
341
429
|
/**
|
|
342
430
|
* Boot: put back an install a crash left mid-swap (no `current`, an `.old-*`
|
|
343
431
|
* present), then remove staging and old directories.
|
|
@@ -392,6 +480,8 @@ export interface ParakeetRunOptions {
|
|
|
392
480
|
spawnImpl?: SpawnLike
|
|
393
481
|
/** Tests only: a different sandbox-exec path. There is no environment override. */
|
|
394
482
|
sandboxExec?: string
|
|
483
|
+
/** 6.74.0 benchmark: called with the child's pid once it is spawned (peak memory sampling). */
|
|
484
|
+
onSpawn?: (pid: number) => void
|
|
395
485
|
}
|
|
396
486
|
|
|
397
487
|
const liveChildren = new Set<ChildProcess>()
|
|
@@ -537,12 +627,48 @@ export function transcribeParakeet(wav: Buffer, options: ParakeetRunOptions): Pr
|
|
|
537
627
|
|
|
538
628
|
async function runParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
|
|
539
629
|
// The installed CLI's full hash, once per process (and again if it changes on disk).
|
|
540
|
-
if (!options.spawnImpl && !(await
|
|
541
|
-
throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches
|
|
630
|
+
if (!options.spawnImpl && !(await installedCliIntact())) {
|
|
631
|
+
throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches the hash recorded when it was installed')
|
|
542
632
|
}
|
|
543
633
|
return runParakeetChecked(wav, options)
|
|
544
634
|
}
|
|
545
635
|
|
|
636
|
+
/**
|
|
637
|
+
* 6.74.0, for the diarizer's CLI lookup (synchronous, read on health): the GUI-installed
|
|
638
|
+
* CLI is trusted only when the install is complete and its bytes are known to match the
|
|
639
|
+
* receipt (the pinned hash, or the Developer ID build's recorded hash). null while that
|
|
640
|
+
* hash has not been checked in this process; a check starts in the background.
|
|
641
|
+
*/
|
|
642
|
+
export function installedCliTrustedNow(): boolean | null {
|
|
643
|
+
if (!parakeetInstalled().installed) return false
|
|
644
|
+
const path = parakeetCliPath()
|
|
645
|
+
const st = fileStat(path)
|
|
646
|
+
if (!st) return false
|
|
647
|
+
const cli = readReceipt()?.cli
|
|
648
|
+
const key = cli?.trust === 'developer-id'
|
|
649
|
+
? `${path}\0${st.size}\0${st.mtimeMs}\0${cli.sha256}`
|
|
650
|
+
: `${path}\0${st.size}\0${st.mtimeMs}`
|
|
651
|
+
const known = cliHashCache.get(key)
|
|
652
|
+
if (known !== undefined) return known
|
|
653
|
+
void installedCliIntact(path)
|
|
654
|
+
return null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The installed CLI is byte for byte what the install verified: the pinned hash, or
|
|
658
|
+
* (6.74.0) the hash of the Developer ID build recorded in the receipt. */
|
|
659
|
+
export async function installedCliIntact(path: string = parakeetCliPath()): Promise<boolean> {
|
|
660
|
+
const cli = readReceipt()?.cli
|
|
661
|
+
if (cli?.trust !== 'developer-id') return isPinnedCli(path)
|
|
662
|
+
const st = fileStat(path)
|
|
663
|
+
if (!st || st.size !== cli.size || cli.teamId !== COS_TEAM_ID) return false
|
|
664
|
+
const key = `${path}\0${st.size}\0${st.mtimeMs}\0${cli.sha256}`
|
|
665
|
+
const known = cliHashCache.get(key)
|
|
666
|
+
if (known !== undefined) return known
|
|
667
|
+
const ok = await sha256File(path).then(h => h === cli.sha256, () => false)
|
|
668
|
+
cliHashCache.set(key, ok)
|
|
669
|
+
return ok
|
|
670
|
+
}
|
|
671
|
+
|
|
546
672
|
function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
|
|
547
673
|
const started = Date.now()
|
|
548
674
|
if (!parakeetInstalled().installed) return Promise.reject(new ParakeetError('not_installed', 'Parakeet is not installed'))
|
|
@@ -591,6 +717,7 @@ function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<P
|
|
|
591
717
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
592
718
|
liveChildren.add(child)
|
|
593
719
|
child.once('close', () => liveChildren.delete(child))
|
|
720
|
+
if (child.pid && options.onSpawn) { try { options.onSpawn(child.pid) } catch { /* sampling only */ } }
|
|
594
721
|
if (child.pid && options.lane !== 'preview' && options.lane !== 'interactive') {
|
|
595
722
|
try { setPriority(child.pid, 10) } catch { /* runs at normal priority */ }
|
|
596
723
|
}
|