@gotcos/glasses-server 6.73.14 → 6.74.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 +21 -0
- package/package.json +1 -1
- 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/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/stt-settings.ts +55 -4
- package/server/routes/transcribe.ts +65 -15
- package/server/routes/work-chat.ts +4 -2
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
|
}
|
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
import { transcribeWhisperMeetingPreview } from './whisper-preview.js'
|
|
12
12
|
import { asrFrontEnd } from './stt-live.js'
|
|
13
13
|
import {
|
|
14
|
-
getInstallProgress, parakeetInstalled, parakeetInstallRunning, parakeetWarmState, pinnedCliKnown,
|
|
14
|
+
getInstallProgress, installedCliTrust, parakeetInstalled, parakeetInstallRunning, parakeetWarmState, pinnedCliKnown,
|
|
15
15
|
PARAKEET_VERSION_LABEL, transcribeParakeet, warmParakeet, type ParakeetModelId,
|
|
16
16
|
} from './parakeet.js'
|
|
17
|
+
import { controlCliCandidates, knownCliTrust, type CliTrust } from './parakeet-cli-trust.js'
|
|
17
18
|
import {
|
|
18
19
|
effectiveStt, readSttSettings, type EffectiveStt, type FrontEndId, type ParakeetStatus,
|
|
19
20
|
type SttCapabilities,
|
|
@@ -36,6 +37,8 @@ export const PREVIEW_BREAKER_MAX_MS = 10 * 60_000
|
|
|
36
37
|
let recordingLiveProbe: () => boolean = () => false
|
|
37
38
|
export function setRecordingLiveProbe(probe: () => boolean): void { recordingLiveProbe = probe }
|
|
38
39
|
function recordingLive(): boolean { try { return recordingLiveProbe() } catch { return true } }
|
|
40
|
+
/** 6.74.0: the benchmark route refuses while a recording is live. Fails closed. */
|
|
41
|
+
export function isRecordingLive(): boolean { return recordingLive() }
|
|
39
42
|
|
|
40
43
|
// ── Preview breaker and counters ─────────────────────────────────────────────
|
|
41
44
|
const previewStats = { parakeet: 0, parakeetMisses: 0, turboFallbacks: 0, turbo: 0, nonTurboWithheld: 0, empty: 0 }
|
|
@@ -93,32 +96,60 @@ export function currentEffectiveStt(): EffectiveStt {
|
|
|
93
96
|
return effectiveStt(readSttSettings(), currentSttCapabilities())
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/** 6.74.0: which CLI an install would use. A SHA-256 pinned hand copy, else COS Control's
|
|
100
|
+
* signed build. true or false once known, null while a check runs (GET never blocks). */
|
|
101
|
+
export function installCliKnown(): { known: boolean | null; trust: 'sha256' | 'developer-id' | null; untrusted: CliTrust | null } {
|
|
102
|
+
const pinned = pinnedCliKnown()
|
|
103
|
+
if (pinned === true) return { known: true, trust: 'sha256', untrusted: null }
|
|
104
|
+
let unknown = pinned === null
|
|
105
|
+
let untrusted: CliTrust | null = null
|
|
106
|
+
for (const path of controlCliCandidates()) {
|
|
107
|
+
const t = knownCliTrust(path)
|
|
108
|
+
if (t === null) { unknown = true; continue }
|
|
109
|
+
if (t.trusted) return { known: true, trust: 'developer-id', untrusted: null }
|
|
110
|
+
if (t.reason !== 'missing' && !untrusted) untrusted = t
|
|
111
|
+
}
|
|
112
|
+
return { known: unknown ? null : false, trust: null, untrusted }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const UNTRUSTED_NOTE: Record<string, string> = {
|
|
116
|
+
unsigned: 'is not signed',
|
|
117
|
+
signature_invalid: 'has a signature that does not verify',
|
|
118
|
+
wrong_team: 'is not signed by COS',
|
|
119
|
+
wrong_version: 'is not the FluidAudio 0.17.4 build Parakeet needs',
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
export function parakeetStatus(live: boolean): ParakeetStatus {
|
|
97
123
|
const installed = parakeetInstalled()
|
|
98
124
|
const progress = getInstallProgress()
|
|
99
125
|
const running = parakeetInstallRunning()
|
|
100
126
|
const sandbox = existsSync('/usr/bin/sandbox-exec')
|
|
101
127
|
const appleSilicon = process.platform === 'darwin' && process.arch === 'arm64'
|
|
102
|
-
// A button that can never succeed is never offered:
|
|
103
|
-
|
|
104
|
-
const
|
|
128
|
+
// A button that can never succeed is never offered: a trusted CLI must be on this Mac,
|
|
129
|
+
// a SHA-256 pinned copy or (6.74.0) the signed one COS Control ships.
|
|
130
|
+
const cli = appleSilicon ? installCliKnown() : { known: false, trust: null, untrusted: null }
|
|
131
|
+
const installable = !installed.installed && !running && sandbox && appleSilicon && cli.known === true
|
|
105
132
|
const warm = parakeetWarmState(PREVIEW_PARAKEET_MODEL)
|
|
133
|
+
const untrustedReason = cli.untrusted && !cli.untrusted.trusted ? cli.untrusted.reason : null
|
|
106
134
|
const note = installed.installed
|
|
107
135
|
? 'Installed. Runs offline on the Neural Engine.'
|
|
108
136
|
: running
|
|
109
|
-
? 'Installing.'
|
|
137
|
+
? 'Installing. Dictation uses Whisper until it is ready.'
|
|
110
138
|
: !sandbox || !appleSilicon
|
|
111
139
|
? 'Parakeet needs an Apple silicon Mac.'
|
|
112
|
-
: cli === false
|
|
113
|
-
?
|
|
114
|
-
|
|
115
|
-
|
|
140
|
+
: cli.known === false
|
|
141
|
+
? untrustedReason
|
|
142
|
+
? `The speech tool in COS Control ${UNTRUSTED_NOTE[untrustedReason] ?? 'did not pass its check'}. Reinstall COS Control, then install Parakeet.`
|
|
143
|
+
: 'Parakeet needs the speech tool that comes with COS Control. Update COS Control, then install Parakeet here.'
|
|
144
|
+
: cli.known === null
|
|
145
|
+
? 'Checking this Mac for the speech tool.'
|
|
116
146
|
: live
|
|
117
147
|
? 'Install after the live recording is saved.'
|
|
118
|
-
: '
|
|
148
|
+
: 'Install Parakeet (654 MB). It downloads the pinned model, checks every file and runs offline. Dictation uses Whisper until it is ready.'
|
|
119
149
|
return {
|
|
120
150
|
installed: installed.installed,
|
|
121
151
|
installable,
|
|
152
|
+
cli: { trust: installed.installed ? installedCliTrust() : cli.trust, reason: untrustedReason },
|
|
122
153
|
version: PARAKEET_VERSION_LABEL,
|
|
123
154
|
bytes: installed.installed ? installed.bytes : progress.bytesDone,
|
|
124
155
|
progress: { ...progress, state: installed.installed && !running ? 'installed' : progress.state },
|
|
@@ -139,7 +170,8 @@ export const WARM_RETRY_MS = 120_000
|
|
|
139
170
|
export function ensurePreviewWarm(now = Date.now()): 'started' | 'skipped' | 'deferred' {
|
|
140
171
|
const settings = readSttSettings()
|
|
141
172
|
// 6.73.0: a Parakeet HQ model also warms, because dictation final text uses it only warm.
|
|
142
|
-
|
|
173
|
+
// 6.74.0: so does Mac dictation on auto or parakeet-ultra (the default is auto).
|
|
174
|
+
const wanted = settings.preview === 'parakeet' || settings.hq === 'parakeet-ultra' || settings.dictation !== 'whisper'
|
|
143
175
|
if (!wanted || !parakeetInstalled().installed) return 'skipped'
|
|
144
176
|
const w = parakeetWarmState(PREVIEW_PARAKEET_MODEL)
|
|
145
177
|
if (w.state === 'warm' || w.state === 'warming') return 'skipped'
|
|
@@ -19,12 +19,15 @@ export const HQ_IDS = ['large-v3', 'large-v3-legacy', 'parakeet-ultra'] as const
|
|
|
19
19
|
export const SECOND_OPINION_IDS = ['off', 'parakeet-ultra', 'large-v3'] as const
|
|
20
20
|
export const FRONT_END_IDS = ['highpass', 'full'] as const
|
|
21
21
|
export const QUIET_GATE_IDS = ['silero', 'rms'] as const
|
|
22
|
+
/** 6.74.0: the engine for Mac dictation (COS Control). It does not follow the Preview model. */
|
|
23
|
+
export const DICTATION_IDS = ['auto', 'parakeet-ultra', 'whisper'] as const
|
|
22
24
|
|
|
23
25
|
export type PreviewId = typeof PREVIEW_IDS[number]
|
|
24
26
|
export type HqId = typeof HQ_IDS[number]
|
|
25
27
|
export type SecondOpinionId = typeof SECOND_OPINION_IDS[number]
|
|
26
28
|
export type FrontEndId = typeof FRONT_END_IDS[number]
|
|
27
29
|
export type QuietGateId = typeof QUIET_GATE_IDS[number]
|
|
30
|
+
export type DesktopDictationId = typeof DICTATION_IDS[number]
|
|
28
31
|
|
|
29
32
|
export interface SttSettings {
|
|
30
33
|
preview: PreviewId
|
|
@@ -33,6 +36,8 @@ export interface SttSettings {
|
|
|
33
36
|
resolver: boolean
|
|
34
37
|
frontEnd: FrontEndId
|
|
35
38
|
quietGate: QuietGateId
|
|
39
|
+
/** 6.74.0: Mac dictation engine. auto = Parakeet Ultra when installed and warm, else local Whisper. */
|
|
40
|
+
dictation: DesktopDictationId
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** Items 1 to 3 on. Second opinion off and the resolver off: on the shipped
|
|
@@ -47,6 +52,9 @@ export const DEFAULT_STT_SETTINGS: Readonly<SttSettings> = Object.freeze({
|
|
|
47
52
|
resolver: false,
|
|
48
53
|
frontEnd: 'highpass',
|
|
49
54
|
quietGate: 'silero',
|
|
55
|
+
// Every install before 6.74.0 has no key and reads as auto, including Macs that
|
|
56
|
+
// chose Parakeet: auto keeps Parakeet wherever it is installed and warm.
|
|
57
|
+
dictation: 'auto',
|
|
50
58
|
})
|
|
51
59
|
|
|
52
60
|
export const STT_SETTINGS_FILE = 'stt-settings.json'
|
|
@@ -66,6 +74,7 @@ export function normalizeStoredSettings(raw: unknown): SttSettings {
|
|
|
66
74
|
resolver: typeof o.resolver === 'boolean' ? o.resolver : DEFAULT_STT_SETTINGS.resolver,
|
|
67
75
|
frontEnd: pick(FRONT_END_IDS, o.frontEnd, DEFAULT_STT_SETTINGS.frontEnd),
|
|
68
76
|
quietGate: pick(QUIET_GATE_IDS, o.quietGate, DEFAULT_STT_SETTINGS.quietGate),
|
|
77
|
+
dictation: pick(DICTATION_IDS, o.dictation, DEFAULT_STT_SETTINGS.dictation),
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -209,7 +218,7 @@ export class SttSettingsError extends Error {
|
|
|
209
218
|
constructor(message: string) { super(message); this.name = 'SttSettingsError' }
|
|
210
219
|
}
|
|
211
220
|
|
|
212
|
-
const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate'] as const
|
|
221
|
+
const KEYS = ['preview', 'hq', 'secondOpinion', 'resolver', 'frontEnd', 'quietGate', 'dictation'] as const
|
|
213
222
|
|
|
214
223
|
/**
|
|
215
224
|
* Apply a partial PUT. Throws SttSettingsError with a plain sentence for an
|
|
@@ -238,12 +247,14 @@ export function applySettingsPatch(current: SttSettings, body: unknown, c: SttCa
|
|
|
238
247
|
const second = choose('secondOpinion', SECOND_OPINION_IDS, secondOpinionOptions(c), 'Second opinion')
|
|
239
248
|
const frontEnd = choose('frontEnd', FRONT_END_IDS, FRONT_END_OPTIONS, 'Front end')
|
|
240
249
|
const quietGate = choose('quietGate', QUIET_GATE_IDS, QUIET_GATE_OPTIONS, 'Quiet gate')
|
|
250
|
+
const dictation = choose('dictation', DICTATION_IDS, desktopDictationOptions(c), 'Dictation engine')
|
|
241
251
|
if ('resolver' in patch && typeof patch.resolver !== 'boolean') throw new SttSettingsError('resolver must be true or false.')
|
|
242
252
|
if (preview) next.preview = preview
|
|
243
253
|
if (hq) next.hq = hq
|
|
244
254
|
if (second) next.secondOpinion = second
|
|
245
255
|
if (frontEnd) next.frontEnd = frontEnd
|
|
246
256
|
if (quietGate) next.quietGate = quietGate
|
|
257
|
+
if (dictation) next.dictation = dictation
|
|
247
258
|
if (typeof patch.resolver === 'boolean') next.resolver = patch.resolver
|
|
248
259
|
if (engineFamily(next.hq) === engineFamily(next.secondOpinion)) {
|
|
249
260
|
if (second) throw new SttSettingsError('The second opinion must be a different engine from the HQ model.')
|
|
@@ -370,6 +381,75 @@ export const DICTATION_REASON_TEXT: Record<string, string> = {
|
|
|
370
381
|
dictation_disabled_by_env: 'Dictation is held on Whisper on this server (COS_DICTATION_FOLLOWS_STT=0).',
|
|
371
382
|
}
|
|
372
383
|
|
|
384
|
+
// ── Mac dictation (6.74.0) ────────────────────────────────────────────────────
|
|
385
|
+
//
|
|
386
|
+
// COS Control's dictation has its own setting and never follows the Preview model.
|
|
387
|
+
// auto: Parakeet Ultra when installed, warm and its breaker closed, otherwise the
|
|
388
|
+
// local Whisper live worker for that decode (and a warm-up starts). Never the cloud.
|
|
389
|
+
// parakeet-ultra: Parakeet only, as 6.73.x did; when it cannot run the request fails.
|
|
390
|
+
// whisper: local Whisper only.
|
|
391
|
+
|
|
392
|
+
/** Whisper weights for the live worker are on this Mac. */
|
|
393
|
+
export function desktopWhisperPresent(c: SttCapabilities): boolean {
|
|
394
|
+
return c.turboModel || c.liveLargeV3
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function desktopDictationOptions(c: SttCapabilities): SttOption[] {
|
|
398
|
+
const whisper = desktopWhisperPresent(c)
|
|
399
|
+
return [
|
|
400
|
+
{ id: 'auto', label: 'Automatic', available: true, experimental: false,
|
|
401
|
+
note: 'Parakeet Ultra when it is installed and ready, otherwise Whisper on this Mac. Audio never leaves this Mac.' },
|
|
402
|
+
{ id: 'parakeet-ultra', label: 'Parakeet Ultra', available: c.parakeetInstalled, experimental: false,
|
|
403
|
+
note: c.parakeetInstalled ? 'Parakeet only. While it warms up or is busy, dictation fails instead of using Whisper.' : 'Install Parakeet on this Mac first.' },
|
|
404
|
+
{ id: 'whisper', label: 'Whisper', available: whisper, experimental: false,
|
|
405
|
+
note: whisper ? 'Whisper on this Mac only, with the names in your COS dictionary.' : 'Whisper weights are missing on this Mac.' },
|
|
406
|
+
]
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export interface DesktopDictationPlan extends DictationPlan {
|
|
410
|
+
setting: DesktopDictationId
|
|
411
|
+
/** Whisper may decode this request: auto and whisper, never parakeet-ultra. */
|
|
412
|
+
whisperAllowed: boolean
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Pure: what a Mac dictation decode uses. The engine is chosen once, here. */
|
|
416
|
+
export function desktopDictationPlan(slot: DictationSlot, settings: SttSettings, c: SttCapabilities): DesktopDictationPlan {
|
|
417
|
+
const setting = settings.dictation
|
|
418
|
+
const base = { slot, frontEnd: settings.frontEnd, setting, whisperAllowed: setting !== 'parakeet-ultra' }
|
|
419
|
+
const whisperKey = slot === 'final' ? 'final:large-v3' : `${slot}:turbo`
|
|
420
|
+
if (setting === 'whisper') return { ...base, engine: 'whisper', reason: null, key: whisperKey }
|
|
421
|
+
const reason = c.dictationDisabledByEnv ? 'dictation_disabled_by_env'
|
|
422
|
+
: !c.parakeetInstalled ? 'parakeet_not_installed'
|
|
423
|
+
: c.parakeetPreviewBreakerOpen ? 'parakeet_breaker_open'
|
|
424
|
+
: c.parakeetPreviewWarm ? null
|
|
425
|
+
: c.parakeetPreviewWarmFailed ? 'parakeet_warm_failed'
|
|
426
|
+
: 'parakeet_warming'
|
|
427
|
+
if (reason) return { ...base, engine: 'whisper', reason, key: whisperKey }
|
|
428
|
+
return { ...base, engine: 'parakeet-ultra', reason: null, key: slot === 'final' ? 'final:parakeet-ultra' : `${slot}:parakeet` }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const reasonText = (code: string) => REASON_TEXT[code] ?? DICTATION_REASON_TEXT[code] ?? code
|
|
432
|
+
|
|
433
|
+
export function desktopDictationView(settings: SttSettings, c: SttCapabilities) {
|
|
434
|
+
const plan = desktopDictationPlan('fast', settings, c)
|
|
435
|
+
const effective: DictationEngine | 'none' = plan.engine === 'parakeet-ultra' ? 'parakeet-ultra'
|
|
436
|
+
: plan.whisperAllowed && desktopWhisperPresent(c) ? 'whisper' : 'none'
|
|
437
|
+
const code = plan.reason
|
|
438
|
+
const note = effective === 'parakeet-ultra' ? 'Mac dictation uses Parakeet Ultra on this Mac.'
|
|
439
|
+
: effective === 'whisper'
|
|
440
|
+
? settings.dictation === 'auto' && code ? `Mac dictation uses Whisper on this Mac for now. ${reasonText(code)}` : 'Mac dictation uses Whisper on this Mac.'
|
|
441
|
+
: settings.dictation === 'parakeet-ultra' ? `Mac dictation waits for Parakeet.${code ? ` ${reasonText(code)}` : ''}`
|
|
442
|
+
: 'No dictation engine is ready on this Mac. Install Parakeet, or the Whisper weights.'
|
|
443
|
+
return {
|
|
444
|
+
selected: settings.dictation,
|
|
445
|
+
effective,
|
|
446
|
+
reason: code ? reasonText(code) : null,
|
|
447
|
+
reasonCode: code,
|
|
448
|
+
options: desktopDictationOptions(c),
|
|
449
|
+
note,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
373
453
|
function dictationSlotView(plan: DictationPlan, follows: 'preview' | 'hq') {
|
|
374
454
|
const code = plan.reason
|
|
375
455
|
return { engine: plan.engine, follows, reason: code ? (REASON_TEXT[code] ?? DICTATION_REASON_TEXT[code] ?? code) : null, reasonCode: code }
|
|
@@ -381,7 +461,8 @@ export function dictationView(settings: SttSettings, c: SttCapabilities) {
|
|
|
381
461
|
const final = dictationPlan('final', settings, c)
|
|
382
462
|
const anyParakeet = settings.preview === 'parakeet' || settings.hq === 'parakeet-ultra'
|
|
383
463
|
const name = (p: DictationPlan) => p.engine === 'parakeet-ultra' ? 'Parakeet Ultra' : 'Whisper'
|
|
384
|
-
const note = `
|
|
464
|
+
const note = `Glasses and phone dictation follow these settings: the live preview and Fast mode use the Preview model (now ${name(preview)}), the final text uses the HQ model (now ${name(final)}). `
|
|
465
|
+
+ 'Mac dictation has its own engine setting. '
|
|
385
466
|
+ 'Dictation never runs the second opinion or the resolver, and the quiet gate never drops a spoken prompt.'
|
|
386
467
|
return {
|
|
387
468
|
preview: dictationSlotView(preview, 'preview'),
|
|
@@ -391,6 +472,8 @@ export function dictationView(settings: SttSettings, c: SttCapabilities) {
|
|
|
391
472
|
quietGate: 'none' as const,
|
|
392
473
|
secondOpinion: 'off' as const,
|
|
393
474
|
note,
|
|
475
|
+
/** 6.74.0: COS Control's Mac dictation (POST /api/transcribe?requiredEngine=local). */
|
|
476
|
+
desktop: desktopDictationView(settings, c),
|
|
394
477
|
}
|
|
395
478
|
}
|
|
396
479
|
|
|
@@ -399,7 +482,9 @@ export interface ParakeetStatus {
|
|
|
399
482
|
installable: boolean
|
|
400
483
|
version: string
|
|
401
484
|
bytes: number
|
|
402
|
-
progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; source?: unknown }
|
|
485
|
+
progress: { state: string; bytesDone: number; bytesTotal: number; error: string | null; errorCode?: string | null; source?: unknown }
|
|
486
|
+
/** 6.74.0: how the CLI is (or would be) trusted, and why COS Control's copy was refused. */
|
|
487
|
+
cli?: { trust: 'sha256' | 'developer-id' | null; reason: string | null }
|
|
403
488
|
warm: { preview: string; error?: string; at?: string }
|
|
404
489
|
preview?: { breaker: { open: boolean; retryAt: string | null; misses: number }; counters: Record<string, number> }
|
|
405
490
|
note: string
|