@gotcos/glasses-server 6.60.0 → 6.61.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,256 @@
1
+ // One run of `fluidaudiocli nemotron3-diarize`: a WAV in, raw 10 ms speaker
2
+ // probabilities out.
3
+ //
4
+ // The probabilities, not the RTTM, are the output that matters. The canary found
5
+ // that the RTTM drops turns shorter than 0.2 s, so every consumer scores the raw
6
+ // [frames x 8] float32 matrix at 0.5, exactly as the canary did.
7
+ //
8
+ // No model call, no network: `--models` is always passed (see diarizer-backend.ts),
9
+ // and nothing here is an LLM. Transcription must never call `claude -p`.
10
+
11
+ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
12
+ import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
13
+ import { setPriority, tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import { NEMOTRON_VARIANT } from './diarizer-backend.js'
16
+
17
+ export const NEMOTRON_CHANNELS = 8
18
+ export const NEMOTRON_FRAME_SEC = 0.01
19
+ /** Every temp directory this feature makes starts with this, so a sweep can find them. */
20
+ export const NEMOTRON_TMP_PREFIX = 'cos-nemotron-'
21
+
22
+ /**
23
+ * The only variables the CLI is given (QA 6.61.0, Ghost Hunter N8). The server's
24
+ * environment holds the API token, provider keys and the OpenAI key; a model
25
+ * runner needs none of them. HOME is where CoreML keeps its compiled-model cache
26
+ * and FluidAudio its models; TMPDIR is where CoreML compiles. Verified on
27
+ * 2026-10-02: the real CLI runs warm from the local models with only these.
28
+ */
29
+ export const NEMOTRON_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', '__CF_USER_TEXT_ENCODING'] as const
30
+
31
+ export function nemotronChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
32
+ const out: NodeJS.ProcessEnv = {}
33
+ for (const key of NEMOTRON_ENV_KEYS) if (typeof env[key] === 'string') out[key] = env[key]
34
+ return out
35
+ }
36
+
37
+ // ── Children and temp directories in flight ───────────────────────────────────
38
+ // A server exit in the middle of a run must not leave the CLI running or a temp
39
+ // directory behind: the final pass's directory holds the whole meeting's audio.
40
+ const liveChildren = new Set<ChildProcess>()
41
+ const liveDirs = new Set<string>()
42
+
43
+ export function trackNemotronDir(dir: string): void { installExitHook(); liveDirs.add(dir) }
44
+ export function untrackNemotronDir(dir: string): void { liveDirs.delete(dir) }
45
+
46
+ /** Kill every CLI child and delete every temp directory in flight. Synchronous, so it works in an exit handler. */
47
+ export function killNemotronChildren(): { killed: number; removed: number } {
48
+ let killed = 0
49
+ for (const child of liveChildren) {
50
+ try { if (child.exitCode == null && child.signalCode == null) { child.kill('SIGKILL'); killed++ } } catch { /* already gone */ }
51
+ }
52
+ liveChildren.clear()
53
+ let removed = 0
54
+ for (const dir of liveDirs) {
55
+ try { rmSync(dir, { recursive: true, force: true }); removed++ } catch { /* best effort */ }
56
+ }
57
+ liveDirs.clear()
58
+ return { killed, removed }
59
+ }
60
+
61
+ let exitHookInstalled = false
62
+ function installExitHook(): void {
63
+ if (exitHookInstalled) return
64
+ exitHookInstalled = true
65
+ // `exit` runs for process.exit (gracefulShutdown ends with it) and a normal end.
66
+ process.once('exit', () => { killNemotronChildren() })
67
+ }
68
+
69
+ /**
70
+ * Server start: remove what a previous process left behind. Temp directories
71
+ * under this prefix older than minAgeMs, and CLI processes that were ours (their
72
+ * arguments name one of these directories) and are now orphaned (parent 1).
73
+ */
74
+ export function sweepStaleNemotron(options: { tmpRoot?: string; minAgeMs?: number; now?: number; killOrphans?: boolean } = {}): { dirs: number; processes: number } {
75
+ const root = options.tmpRoot ?? tmpdir()
76
+ const now = options.now ?? Date.now()
77
+ const minAge = options.minAgeMs ?? 60_000
78
+ let dirs = 0
79
+ try {
80
+ for (const name of readdirSync(root)) {
81
+ if (!name.startsWith(NEMOTRON_TMP_PREFIX)) continue
82
+ const path = join(root, name)
83
+ try {
84
+ const stat = statSync(path)
85
+ if (!stat.isDirectory() || now - stat.mtimeMs < minAge) continue
86
+ rmSync(path, { recursive: true, force: true })
87
+ dirs++
88
+ } catch { /* raced or unreadable */ }
89
+ }
90
+ } catch { /* no tmp root */ }
91
+ let processes = 0
92
+ if (options.killOrphans !== false) {
93
+ try {
94
+ const listing = execFileSync('/bin/ps', ['-Ao', 'pid=,ppid=,command='], { encoding: 'utf8', timeout: 2_000, maxBuffer: 4 * 1024 * 1024 })
95
+ for (const line of listing.split('\n')) {
96
+ const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
97
+ if (!match || match[2] !== '1') continue
98
+ const command = match[3]
99
+ if (!command.includes('nemotron3-diarize') || !command.includes(`/${NEMOTRON_TMP_PREFIX}`)) continue
100
+ try { process.kill(Number(match[1]), 'SIGKILL'); processes++ } catch { /* gone */ }
101
+ }
102
+ } catch { /* ps unavailable: the directories are still swept */ }
103
+ }
104
+ return { dirs, processes }
105
+ }
106
+
107
+ export interface NemotronPreds {
108
+ /** Number of 10 ms frames. */
109
+ frames: number
110
+ /** Row-major [frames x 8] probabilities, as the CLI's --dump-preds writes them. */
111
+ probs: Float32Array
112
+ }
113
+
114
+ export interface NemotronRun extends NemotronPreds {
115
+ /** "Model loaded in X s". Above a second or two means the Neural Engine compiled. */
116
+ loadSec: number | null
117
+ /** Audio seconds the CLI says it processed. */
118
+ audioSec: number | null
119
+ wallMs: number
120
+ }
121
+
122
+ export type NemotronRunFailure = 'timeout' | 'cli_failed' | 'bad_output' | 'spawn_failed'
123
+
124
+ export class NemotronRunError extends Error {
125
+ constructor(readonly reason: NemotronRunFailure, message: string) {
126
+ super(message)
127
+ this.name = 'NemotronRunError'
128
+ }
129
+ }
130
+
131
+ type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess
132
+
133
+ export interface RunNemotronOptions {
134
+ cli: string
135
+ modelsDir: string
136
+ wavPath: string
137
+ timeoutMs: number
138
+ /** `ane` keeps the GPU free for Whisper. Its probabilities matched `all` at the
139
+ * 0.5 threshold frame for frame on real chunks (2026-10-02 probe). */
140
+ computeUnits?: 'ane' | 'all'
141
+ /** Lower CPU priority than the server, so Whisper keeps priority. */
142
+ niceness?: number
143
+ spawnImpl?: SpawnLike
144
+ tmpRoot?: string
145
+ }
146
+
147
+ const STDOUT_CAP = 64 * 1024
148
+ const PROCESSED_LINE = /Processed ([0-9.]+)s in ([0-9.]+)s \(RTFx [0-9.]+x\), (\d+) x 10ms frames/
149
+ const LOADED_LINE = /Model loaded in ([0-9.]+)s/
150
+
151
+ export function nemotronArgs(wavPath: string, modelsDir: string, predsPath: string, computeUnits: 'ane' | 'all'): string[] {
152
+ return [
153
+ 'nemotron3-diarize', wavPath,
154
+ '--models', modelsDir,
155
+ '--variant', NEMOTRON_VARIANT,
156
+ '--compute-units', computeUnits,
157
+ '--threshold', '0.5',
158
+ '--dump-preds', predsPath,
159
+ ]
160
+ }
161
+
162
+ /** Parse the CLI's stdout plus the preds file into a checked matrix. */
163
+ export function parseNemotronOutput(stdout: string, preds: Buffer): Omit<NemotronRun, 'wallMs'> {
164
+ const processed = PROCESSED_LINE.exec(stdout)
165
+ if (!processed) throw new NemotronRunError('bad_output', 'no "Processed ... frames" line')
166
+ const frames = Number(processed[3])
167
+ if (!Number.isInteger(frames) || frames < 0) throw new NemotronRunError('bad_output', 'frame count unreadable')
168
+ const expected = frames * NEMOTRON_CHANNELS * 4
169
+ if (preds.length !== expected) {
170
+ throw new NemotronRunError('bad_output', `preds ${preds.length} bytes, expected ${frames}x${NEMOTRON_CHANNELS}x4 = ${expected}`)
171
+ }
172
+ // Copy into an aligned buffer: a Buffer slice may sit at an odd byte offset.
173
+ const probs = new Float32Array(frames * NEMOTRON_CHANNELS)
174
+ for (let i = 0; i < probs.length; i++) probs[i] = preds.readFloatLE(i * 4)
175
+ const loaded = LOADED_LINE.exec(stdout)
176
+ return {
177
+ frames,
178
+ probs,
179
+ loadSec: loaded ? Number(loaded[1]) : null,
180
+ audioSec: Number(processed[1]),
181
+ }
182
+ }
183
+
184
+ export function runNemotronCli(options: RunNemotronOptions): Promise<NemotronRun> {
185
+ const started = Date.now()
186
+ const spawnImpl: SpawnLike = options.spawnImpl ?? (nodeSpawn as unknown as SpawnLike)
187
+ let work: string
188
+ try {
189
+ work = mkdtempSync(join(options.tmpRoot ?? tmpdir(), NEMOTRON_TMP_PREFIX))
190
+ try { chmodSync(work, 0o700) } catch { /* mkdtemp is already 0700 */ }
191
+ } catch (error) {
192
+ return Promise.reject(new NemotronRunError('spawn_failed', `temp dir: ${error instanceof Error ? error.message : String(error)}`))
193
+ }
194
+ installExitHook()
195
+ trackNemotronDir(work)
196
+ const predsPath = join(work, 'preds.f32')
197
+ const cleanup = () => {
198
+ untrackNemotronDir(work)
199
+ try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ }
200
+ }
201
+
202
+ return new Promise<NemotronRun>((resolve, reject) => {
203
+ let settled = false
204
+ const finish = (error: NemotronRunError | null, value?: NemotronRun) => {
205
+ if (settled) return
206
+ settled = true
207
+ clearTimeout(timer)
208
+ cleanup()
209
+ if (error) reject(error)
210
+ else resolve(value!)
211
+ }
212
+ let child: ChildProcess
213
+ try {
214
+ child = spawnImpl(options.cli, nemotronArgs(options.wavPath, options.modelsDir, predsPath, options.computeUnits ?? 'ane'), {
215
+ stdio: ['ignore', 'pipe', 'pipe'],
216
+ env: nemotronChildEnv(),
217
+ })
218
+ } catch (error) {
219
+ cleanup()
220
+ reject(new NemotronRunError('spawn_failed', error instanceof Error ? error.message : String(error)))
221
+ return
222
+ }
223
+ liveChildren.add(child)
224
+ child.once('exit', () => liveChildren.delete(child))
225
+ child.once('close', () => liveChildren.delete(child))
226
+ if (child.pid && options.niceness) {
227
+ try { setPriority(child.pid, options.niceness) } catch { /* the run still works at normal priority */ }
228
+ }
229
+ let stdout = ''
230
+ let stderr = ''
231
+ child.stdout?.on('data', (data: Buffer) => { if (stdout.length < STDOUT_CAP) stdout += data.toString() })
232
+ child.stderr?.on('data', (data: Buffer) => { if (stderr.length < STDOUT_CAP) stderr += data.toString() })
233
+ const timer = setTimeout(() => {
234
+ // SIGKILL, not SIGTERM: a CoreML compile does not stop for TERM, and a
235
+ // budget that waits on it is no budget.
236
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
237
+ finish(new NemotronRunError('timeout', `no result within ${options.timeoutMs} ms`))
238
+ }, options.timeoutMs)
239
+ timer.unref?.()
240
+ child.on('error', error => { liveChildren.delete(child); finish(new NemotronRunError('spawn_failed', error.message)) })
241
+ child.on('close', (code, signal) => {
242
+ if (settled) return
243
+ if (code !== 0) {
244
+ const tail = `${stdout}\n${stderr}`.trim().split('\n').slice(-3).join(' | ').slice(0, 300)
245
+ finish(new NemotronRunError('cli_failed', `exit ${code ?? signal}: ${tail}`))
246
+ return
247
+ }
248
+ try {
249
+ const parsed = parseNemotronOutput(stdout, readFileSync(predsPath))
250
+ finish(null, { ...parsed, wallMs: Date.now() - started })
251
+ } catch (error) {
252
+ finish(error instanceof NemotronRunError ? error : new NemotronRunError('bad_output', error instanceof Error ? error.message : String(error)))
253
+ }
254
+ })
255
+ })
256
+ }