@gotcos/glasses-server 6.60.0 → 6.61.1

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,736 @@
1
+ // Live, per chunk: Nemotron separates the voices, the voiceprint names them.
2
+ //
3
+ // THE FALLBACK-EXPERIENCE HARD RULE. A live chunk must never stall or be lost
4
+ // because of this file. Every path that is not a clean Nemotron result returns a
5
+ // reason, and the caller keeps today's voiceprint label for that chunk:
6
+ // - the model is warmed at server start in the background; until it is warm
7
+ // chunks report `warming` and use the voiceprint;
8
+ // - each run has a hard budget (SIGKILL at the deadline) and at most
9
+ // MAX_CONCURRENT runs exist at once; a chunk that finds the lane full is
10
+ // `busy`, never queued;
11
+ // - a missing CLI, missing models, a crash or unreadable output all fall back.
12
+ // Whisper keeps priority: the run starts in parallel with Whisper, on the Neural
13
+ // Engine (`--compute-units ane`, so the GPU stays Whisper's) at a lower CPU
14
+ // priority, and the chunk waits for it only up to its budget.
15
+ //
16
+ // No LLM anywhere. Transcription must never call `claude -p`.
17
+
18
+ import { createHash } from 'node:crypto'
19
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { homedir, tmpdir } from 'node:os'
21
+ import { join } from 'node:path'
22
+ import {
23
+ NEMOTRON_VARIANT,
24
+ activeDiarizer,
25
+ nemotronCliPath,
26
+ prepareNemotronModelsDir,
27
+ requestedDiarizer,
28
+ } from './diarizer-backend.js'
29
+ import {
30
+ NEMOTRON_TMP_PREFIX,
31
+ NemotronRunError,
32
+ runNemotronCli,
33
+ sweepStaleNemotron,
34
+ trackNemotronDir,
35
+ untrackNemotronDir,
36
+ type NemotronRun,
37
+ type RunNemotronOptions,
38
+ } from './nemotron-cli.js'
39
+ import {
40
+ NAME_TRACK_MIN_SEC,
41
+ absorbFlickers,
42
+ channelForSpan,
43
+ estimateTokenTimes,
44
+ exclusiveTrackWav,
45
+ fillLabels,
46
+ isWordToken,
47
+ pcm16ToWav,
48
+ readPcm16Mono,
49
+ runsOf,
50
+ snapToPunctuation,
51
+ splitTokens,
52
+ tokenTimesFromWords,
53
+ tracksIn,
54
+ type Activity,
55
+ type TokenTime,
56
+ } from './nemotron-segments.js'
57
+ import type { WhisperWord } from './whisper-local.js'
58
+ import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
59
+
60
+ /** The chunk response contract shared with COS Glasses 6.9.567. Keep this shape. */
61
+ export interface ChunkSpeakerSegment {
62
+ speaker: string
63
+ text: string
64
+ startSec?: number
65
+ endSec?: number
66
+ similarity?: number
67
+ }
68
+
69
+ export interface VoiceprintResult {
70
+ speaker: string
71
+ similarity: number
72
+ }
73
+
74
+ export interface LiveNamingInput {
75
+ activity: Activity
76
+ text: string
77
+ words?: WhisperWord[]
78
+ /** The chunk's WAV, the same bytes Nemotron read. */
79
+ audio: Buffer
80
+ /** Today's whole-chunk voiceprint result. */
81
+ chunkVoiceprint: VoiceprintResult
82
+ /** The client's own label (amplitude / Even Hub), e.g. the owner or Ext. Never names a split track. */
83
+ clientSpeaker: string
84
+ /** The wearer's label (`owner_speaker_label`), for the owner guard. */
85
+ owner?: string | null
86
+ identify: (wav: Buffer) => VoiceprintResult | null
87
+ }
88
+
89
+ export type LiveNamingResult =
90
+ | {
91
+ ok: true
92
+ speaker: string
93
+ similarity: number
94
+ segments: ChunkSpeakerSegment[]
95
+ tracks: number
96
+ timing: 'words' | 'estimated'
97
+ identified: number
98
+ /** Would-be wearer names refused in this chunk, by reason. */
99
+ ownerGuard: { client_label: number; weak_owner: number }
100
+ }
101
+ | { ok: false; reason: 'no_text' | 'no_speech' }
102
+
103
+ const round = (value: number, places = 2): number => Math.round(value * 10 ** places) / 10 ** places
104
+
105
+ function isRealLabel(label: string): boolean {
106
+ const value = label.trim()
107
+ return value.length > 0 && value !== 'Unknown' && value !== 'Ext'
108
+ }
109
+
110
+ /**
111
+ * Split one chunk's text into named segments. Pure apart from `identify`.
112
+ *
113
+ * Naming rules (2026-10-02 brief, 6.61.1 owner guard):
114
+ * - one track: it is the whole chunk, so it takes the chunk's voiceprint result,
115
+ * exactly as 6.60.0 labelled the chunk (including the client's label when no
116
+ * voiceprint could run);
117
+ * - two or more: each track with at least NAME_TRACK_MIN_SEC of its own audio is
118
+ * named by the voiceprint on that audio; a shorter dominant track takes the
119
+ * chunk's voiceprint; any other short track is Ext;
120
+ * - in a split chunk the WEARER is named only at owner-verify confidence
121
+ * (OWNER_VERIFY_SIMILARITY). A full search can return the wearer from 0.55,
122
+ * and the client's label is the glasses' default wearer label, not identity
123
+ * evidence: on 2026-10-02 both named other people (Jeremy, Kyle) as the
124
+ * wearer. Either becomes Ext and is counted (`ownerGuard`).
125
+ * A name is never invented.
126
+ * The segments' texts joined with one space equal `text` exactly.
127
+ */
128
+ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
129
+ const { activity, text } = input
130
+ if (!text.trim()) return { ok: false, reason: 'no_text' }
131
+ const tracks = tracksIn(activity, 0, activity.frames)
132
+ if (tracks.length === 0) return { ok: false, reason: 'no_speech' }
133
+ const tokens = splitTokens(text)
134
+ const allowed = new Set(tracks.map(track => track.channel))
135
+ const dominant = tracks[0].channel
136
+
137
+ let timing: 'words' | 'estimated' = 'estimated'
138
+ let times: TokenTime[] | null = input.words?.length ? tokenTimesFromWords(tokens, input.words) : null
139
+ if (times) timing = 'words'
140
+ else times = estimateTokenTimes(tokens, activity, 0, activity.frames / 100)
141
+ if (!times) return { ok: false, reason: 'no_speech' }
142
+
143
+ const raw = tokens.map((token, i) => isWordToken(token)
144
+ ? channelForSpan(activity, times![i].start, times![i].end, allowed)
145
+ : null)
146
+ let labels = fillLabels(raw, dominant)
147
+ if (timing === 'estimated') labels = snapToPunctuation(tokens, labels)
148
+ labels = absorbFlickers(tokens, labels, times)
149
+ const used = new Set(labels)
150
+
151
+ const names = new Map<number, VoiceprintResult>()
152
+ const ownerGuard = { client_label: 0, weak_owner: 0 }
153
+ let identified = 0
154
+ const pcm = readPcm16Mono(input.audio)
155
+ const ordered = tracks.filter(track => used.has(track.channel))
156
+ if (!ordered.some(track => track.channel === dominant)) ordered.unshift(tracks[0])
157
+ if (ordered.length === 1) {
158
+ names.set(ordered[0].channel, input.chunkVoiceprint)
159
+ } else {
160
+ const owner = input.owner?.trim() || null
161
+ const ownerSafe = (named: VoiceprintResult): VoiceprintResult => {
162
+ if (!owner || named.speaker !== owner || named.similarity >= OWNER_VERIFY_SIMILARITY) return named
163
+ ownerGuard[named.similarity > 0 ? 'weak_owner' : 'client_label']++
164
+ return { speaker: 'Ext', similarity: 0 }
165
+ }
166
+ for (const track of ordered) {
167
+ if (track.exclusiveSec >= NAME_TRACK_MIN_SEC && pcm) {
168
+ const wav = exclusiveTrackWav(activity, track.channel, pcm)
169
+ const named = wav ? input.identify(wav) : null
170
+ if (named) { names.set(track.channel, ownerSafe(named)); identified++; continue }
171
+ }
172
+ if (track.channel === dominant) { names.set(track.channel, ownerSafe(input.chunkVoiceprint)); continue }
173
+ // The client's label is the glasses' default wearer label, not evidence of
174
+ // who this voice is (6.61.0 named Jeremy Sokolic as MU through it).
175
+ const dominantName = names.get(dominant)?.speaker ?? input.chunkVoiceprint.speaker
176
+ const client = input.clientSpeaker.trim()
177
+ if (isRealLabel(client) && client !== dominantName) ownerGuard.client_label++
178
+ names.set(track.channel, { speaker: 'Ext', similarity: 0 })
179
+ }
180
+ }
181
+
182
+ const segments: ChunkSpeakerSegment[] = []
183
+ const nameOf = (channel: number): VoiceprintResult => names.get(channel) ?? input.chunkVoiceprint
184
+ for (const run of runsOf(labels)) {
185
+ const named = nameOf(run.label)
186
+ const piece: ChunkSpeakerSegment = {
187
+ speaker: named.speaker,
188
+ text: tokens.slice(run.from, run.to).join(' '),
189
+ startSec: round(times[run.from].start),
190
+ endSec: round(times[run.to - 1].end),
191
+ similarity: round(named.similarity, 3),
192
+ }
193
+ const last = segments.at(-1)
194
+ // Estimated neighbours can share a 10 ms frame; turns never overlap in time order.
195
+ if (last && (piece.startSec ?? 0) < (last.endSec ?? 0)) piece.startSec = last.endSec
196
+ if ((piece.endSec ?? 0) < (piece.startSec ?? 0)) piece.endSec = piece.startSec
197
+ if (last && last.speaker === piece.speaker) {
198
+ last.text = `${last.text} ${piece.text}`
199
+ last.endSec = Math.max(last.endSec ?? 0, piece.endSec ?? 0)
200
+ last.similarity = Math.max(last.similarity ?? 0, piece.similarity ?? 0)
201
+ } else {
202
+ segments.push(piece)
203
+ }
204
+ }
205
+
206
+ // Dominant NAME by voiced time among the tracks that carry the text: two
207
+ // channels can carry one person, and a voice with no words here is not who
208
+ // said this chunk.
209
+ const voiced = new Map<string, number>()
210
+ for (const track of tracks) {
211
+ if (!names.has(track.channel) || !used.has(track.channel)) continue
212
+ const name = names.get(track.channel)!.speaker
213
+ voiced.set(name, (voiced.get(name) ?? 0) + track.activeSec)
214
+ }
215
+ let speaker = nameOf(dominant).speaker
216
+ let best = -1
217
+ for (const [name, sec] of voiced) if (sec > best) { best = sec; speaker = name }
218
+ const similarity = [...names.values()].find(named => named.speaker === speaker)?.similarity ?? 0
219
+ return { ok: true, speaker, similarity: round(similarity, 3), segments, tracks: tracks.length, timing, identified, ownerGuard }
220
+ }
221
+
222
+ /**
223
+ * Fit stored turns to a text that lost some words (session recovery strips inline
224
+ * hallucinations from `text` after the fact). Each surviving word keeps the turn
225
+ * it was in; a turn left empty goes; neighbours with one name merge. Returns
226
+ * undefined when the words cannot be matched in order, and the caller then drops
227
+ * the turns: joined turns must always equal the text.
228
+ */
229
+ export function resplitSegments(segments: ChunkSpeakerSegment[], text: string): ChunkSpeakerSegment[] | undefined {
230
+ const old: Array<{ token: string; seg: number }> = []
231
+ segments.forEach((segment, seg) => { for (const token of splitTokens(String(segment.text ?? ''))) old.push({ token, seg }) })
232
+ const fresh = splitTokens(text)
233
+ const owner: number[] = []
234
+ let pointer = 0
235
+ for (const token of fresh) {
236
+ let found = -1
237
+ for (let k = pointer; k < Math.min(old.length, pointer + 40); k++) if (old[k].token === token) { found = k; break }
238
+ if (found < 0) return undefined
239
+ owner.push(old[found].seg)
240
+ pointer = found + 1
241
+ }
242
+ const out: ChunkSpeakerSegment[] = []
243
+ fresh.forEach((token, i) => {
244
+ const from = segments[owner[i]]
245
+ const last = out.at(-1)
246
+ if (last && last.speaker === from.speaker) {
247
+ last.text = `${last.text} ${token}`
248
+ if (typeof from.endSec === 'number') last.endSec = Math.max(last.endSec ?? 0, from.endSec)
249
+ if (typeof from.similarity === 'number') last.similarity = Math.max(last.similarity ?? 0, from.similarity)
250
+ } else {
251
+ out.push({ ...from, text: token })
252
+ }
253
+ })
254
+ return out.map(segment => segment.text).join(' ') === text ? out : undefined
255
+ }
256
+
257
+ export interface LiveLabelInput {
258
+ /** The run started before Whisper; awaited here, bounded by its own budget. */
259
+ diarize: Promise<LiveDiarizeOutcome>
260
+ /** performance.now() when the run was started. */
261
+ startedAt: number
262
+ text: string
263
+ words?: WhisperWord[]
264
+ audio: Buffer
265
+ voiceprint: VoiceprintResult
266
+ clientSpeaker: string
267
+ /** The wearer's label, for the owner guard on split chunks. */
268
+ owner?: string | null
269
+ identify: (wav: Buffer) => VoiceprintResult | null
270
+ clock?: () => number
271
+ }
272
+
273
+ export interface LiveLabels {
274
+ speaker: string
275
+ similarity: number
276
+ segments?: ChunkSpeakerSegment[]
277
+ diarizer?: {
278
+ engine: 'nemotron' | 'voiceprint'
279
+ fallback?: string
280
+ voiceprintSpeaker?: string
281
+ voiceprintSimilarity?: number
282
+ tracks?: number
283
+ timing?: 'words' | 'estimated'
284
+ ms?: number
285
+ /** 6.61.1: would-be wearer names refused in this chunk (client_label, weak_owner). */
286
+ ownerGuard?: string[]
287
+ }
288
+ /** What NemotronLiveRuntime.record should log; absent when the voiceprint was chosen by config. */
289
+ record?:
290
+ | { outcome: 'nemotron'; ms: number; segments: number; tracks: number; timing: string; identified: number; ownerGuard?: { client_label: number; weak_owner: number } }
291
+ | { outcome: 'voiceprint'; reason: string; ms: number | null; detail?: string }
292
+ }
293
+
294
+ /**
295
+ * The chunk's final labels. Never throws: any failure returns today's voiceprint
296
+ * label with the reason, so the chunk is answered and stored exactly as before.
297
+ */
298
+ export async function resolveLiveLabels(input: LiveLabelInput): Promise<LiveLabels> {
299
+ const clock = input.clock ?? (() => performance.now())
300
+ const voiceprintOnly = (reason: string, ms: number | null, detail?: string): LiveLabels => ({
301
+ speaker: input.voiceprint.speaker,
302
+ similarity: input.voiceprint.similarity,
303
+ diarizer: { engine: 'voiceprint', fallback: reason },
304
+ record: { outcome: 'voiceprint', reason, ms, ...(detail ? { detail } : {}) },
305
+ })
306
+ let outcome: LiveDiarizeOutcome
307
+ try {
308
+ outcome = await input.diarize
309
+ } catch (error) {
310
+ return voiceprintOnly('error', null, error instanceof Error ? error.message : String(error))
311
+ }
312
+ // Nemotron's own run time, not how long the chunk waited: the run started
313
+ // before Whisper, so most of it is hidden behind Whisper.
314
+ const waited = Math.round(clock() - input.startedAt)
315
+ if (!outcome.ok) {
316
+ // The voiceprint by choice, or an install with no CLI or models: today's label,
317
+ // no per-chunk stamp and no per-chunk log (the warm-up logged it once).
318
+ if (outcome.reason === 'embedding_requested' || STATIC_REASONS.has(outcome.reason)) {
319
+ return { speaker: input.voiceprint.speaker, similarity: input.voiceprint.similarity }
320
+ }
321
+ const ran = outcome.reason === 'timeout' || outcome.reason === 'cli_failed' || outcome.reason === 'bad_output' || outcome.reason === 'spawn_failed' || outcome.reason === 'error'
322
+ return voiceprintOnly(outcome.reason, ran ? (outcome.ms ?? waited) : null, outcome.detail)
323
+ }
324
+ const ms = Math.round(outcome.run.wallMs ?? waited)
325
+ try {
326
+ const named = nameChunkSegments({
327
+ activity: outcome.run,
328
+ text: input.text,
329
+ words: input.words,
330
+ audio: input.audio,
331
+ chunkVoiceprint: input.voiceprint,
332
+ clientSpeaker: input.clientSpeaker,
333
+ owner: input.owner,
334
+ identify: input.identify,
335
+ })
336
+ if (!named.ok) return voiceprintOnly(named.reason, ms)
337
+ const changed = named.speaker !== input.voiceprint.speaker
338
+ const guarded = [
339
+ ...Array(named.ownerGuard.client_label).fill('client_label'),
340
+ ...Array(named.ownerGuard.weak_owner).fill('weak_owner'),
341
+ ] as string[]
342
+ return {
343
+ speaker: named.speaker,
344
+ similarity: named.similarity,
345
+ segments: named.segments,
346
+ diarizer: {
347
+ engine: 'nemotron',
348
+ tracks: named.tracks,
349
+ timing: named.timing,
350
+ ms,
351
+ ...(changed ? { voiceprintSpeaker: input.voiceprint.speaker, voiceprintSimilarity: input.voiceprint.similarity } : {}),
352
+ ...(guarded.length ? { ownerGuard: guarded } : {}),
353
+ },
354
+ record: {
355
+ outcome: 'nemotron', ms, segments: named.segments.length, tracks: named.tracks, timing: named.timing, identified: named.identified,
356
+ ...(guarded.length ? { ownerGuard: named.ownerGuard } : {}),
357
+ },
358
+ }
359
+ } catch (error) {
360
+ return voiceprintOnly('error', ms, error instanceof Error ? error.message : String(error))
361
+ }
362
+ }
363
+
364
+ // ── Runtime: warm-up, lane, budget, honest counts ─────────────────────────────
365
+
366
+ export type LiveFallbackReason =
367
+ | 'cli_missing'
368
+ | 'models_missing'
369
+ | 'warming'
370
+ | 'warmup_failed'
371
+ | 'cooling'
372
+ | 'busy'
373
+ | 'timeout'
374
+ | 'cli_failed'
375
+ | 'bad_output'
376
+ | 'spawn_failed'
377
+ | 'no_speech'
378
+ | 'error'
379
+
380
+ export type LiveDiarizeOutcome =
381
+ | { ok: true; run: NemotronRun }
382
+ | { ok: false; reason: LiveFallbackReason | 'embedding_requested'; detail?: string; ms?: number }
383
+
384
+ /** A run was attempted and failed. Consecutive ones open the breaker. */
385
+ const RUN_FAILURES: ReadonlySet<string> = new Set(['timeout', 'cli_failed', 'bad_output', 'spawn_failed', 'error'])
386
+ /** Install-level reasons: logged once by the warm-up, never stamped on a chunk. */
387
+ export const STATIC_REASONS: ReadonlySet<string> = new Set(['cli_missing', 'models_missing'])
388
+ /** State-level reasons: counted per chunk, logged once per change of state. */
389
+ const STATE_REASONS: ReadonlySet<string> = new Set(['warming', 'warmup_failed', 'cooling', 'cli_missing', 'models_missing'])
390
+
391
+ export type LiveState = 'off' | 'idle' | 'warming' | 'ready' | 'cooling' | 'failed'
392
+
393
+ export interface NemotronLiveDeps {
394
+ env?: () => NodeJS.ProcessEnv
395
+ home?: () => string
396
+ run?: (options: RunNemotronOptions) => Promise<NemotronRun>
397
+ log?: (line: string) => void
398
+ now?: () => number
399
+ sleep?: (ms: number) => Promise<void>
400
+ tmpRoot?: () => string
401
+ maxConcurrent?: number
402
+ budgetMs?: number
403
+ warmTimeoutMs?: number
404
+ warmRetryDelaysMs?: number[]
405
+ /** Hash of the CLI binary, for the startup identity line. */
406
+ cliIdentity?: (path: string) => { sha256: string; known: boolean } | null
407
+ }
408
+
409
+ export const LIVE_BUDGET_MS = 1500
410
+ export const LIVE_MAX_CONCURRENT = 2
411
+ export const WARM_TIMEOUT_MS = 180_000
412
+ export const WARM_RETRY_DELAYS_MS = [5_000, 30_000]
413
+ /** Consecutive failed runs (a budget kill, a crash) that open the breaker. */
414
+ export const BREAKER_FAILURES = 3
415
+ /**
416
+ * How long Nemotron rests after the breaker opens or a warm-up gives up, by
417
+ * consecutive trip: 2, 4, 8, 16, 32 minutes, then 60 at most. While it rests no
418
+ * CLI is spawned and every chunk takes the voiceprint at once (no 1.5 s wait);
419
+ * then one fresh warm-up decides. A clean chunk resets the trips.
420
+ */
421
+ export const BREAKER_BACKOFF_MS = [120_000, 240_000, 480_000, 960_000, 1_920_000, 3_600_000]
422
+ /** Install-level checks (a CLI or models that appear later) are retried on this interval, without spawning. */
423
+ export const STATIC_RECHECK_MS = 120_000
424
+ /** The recent chunk outcomes `active` is read from. */
425
+ export const RECENT_WINDOW = 5
426
+ /** fluidaudiocli built from FluidAudio v0.17.4 (the 2026-10-01 canary's build). */
427
+ export const KNOWN_CLI_SHA256 = '4714154623b59c40404563828cb0fbeb4da6878e90f582ad776c1c2cd061d35a'
428
+
429
+ export interface LiveSnapshot {
430
+ requested: 'nemotron' | 'embedding'
431
+ /**
432
+ * What is labelling chunks right now. `nemotron` only while the model is warm,
433
+ * the breaker is closed and at least half the recent chunks really were
434
+ * labelled by Nemotron; otherwise `warming` or `embedding` with the reason.
435
+ */
436
+ active: 'nemotron' | 'warming' | 'embedding'
437
+ fallback: string | null
438
+ /** The last RECENT_WINDOW chunk outcomes: who actually labelled them. */
439
+ recent: { window: number; nemotron: number; voiceprint: number }
440
+ breaker: { open: boolean; trips: number; consecutiveFailures: number; retryAt: string | null }
441
+ variant: string
442
+ computeUnits: 'ane'
443
+ budgetMs: number
444
+ maxConcurrent: number
445
+ inFlight: number
446
+ warm: { state: LiveState; ms: number | null; loadSec: number | null; error: string | null }
447
+ /** Whether the CLI binary is the v0.17.4 build the offline guarantee was verified on. */
448
+ cliKnown: boolean | null
449
+ chunks: { nemotron: number; voiceprint: number; reasons: Record<string, number> }
450
+ /** 6.61.1: would-be wearer names refused on split chunks since start. */
451
+ ownerGuard: { client_label: number; weak_owner: number }
452
+ last: { at: string; outcome: 'nemotron' | 'voiceprint'; reason: string | null; ms: number | null } | null
453
+ }
454
+
455
+ export function cliIdentity(path: string): { sha256: string; known: boolean } | null {
456
+ try {
457
+ const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex')
458
+ return { sha256, known: sha256 === KNOWN_CLI_SHA256 }
459
+ } catch {
460
+ return null
461
+ }
462
+ }
463
+
464
+ export class NemotronLiveRuntime {
465
+ private state: LiveState = 'idle'
466
+ private inFlight = 0
467
+ private consecutiveFailures = 0
468
+ private trips = 0
469
+ private retryAt = 0
470
+ private warming: Promise<void> | null = null
471
+ private warmMs: number | null = null
472
+ private warmLoadSec: number | null = null
473
+ private warmError: string | null = null
474
+ private cli: string | null = null
475
+ private modelsDir: string | null = null
476
+ private identity: { path: string; sha256: string; known: boolean } | null = null
477
+ private counts = { nemotron: 0, voiceprint: 0, reasons: {} as Record<string, number> }
478
+ private ownerGuard = { client_label: 0, weak_owner: 0 }
479
+ private recent: Array<{ outcome: 'nemotron' | 'voiceprint'; reason: string | null }> = []
480
+ private last: LiveSnapshot['last'] = null
481
+ private lastStateLog: string | null = null
482
+
483
+ constructor(private readonly deps: NemotronLiveDeps = {}) {}
484
+
485
+ private env(): NodeJS.ProcessEnv { return this.deps.env?.() ?? process.env }
486
+ private home(): string { return this.deps.home?.() ?? homedir() }
487
+ private now(): number { return this.deps.now?.() ?? Date.now() }
488
+ private log(line: string): void { (this.deps.log ?? console.log)(line) }
489
+ private budget(): number { return this.deps.budgetMs ?? LIVE_BUDGET_MS }
490
+ private maxConcurrent(): number { return this.deps.maxConcurrent ?? LIVE_MAX_CONCURRENT }
491
+ private backoff(): number { return BREAKER_BACKOFF_MS[Math.min(this.trips, BREAKER_BACKOFF_MS.length - 1)] }
492
+
493
+ /** Start (or restart) the background warm-up. Never throws, never rejects, never blocks a chunk. */
494
+ startWarmup(): Promise<void> {
495
+ if (requestedDiarizer(this.env()) === 'embedding') {
496
+ if (this.state !== 'off') this.log('[diarizer] tracks=voiceprint names=voiceprint (COS_DIARIZER selects the voiceprint)')
497
+ this.state = 'off'
498
+ return Promise.resolve()
499
+ }
500
+ if (this.warming) return this.warming
501
+ this.state = 'warming'
502
+ this.warming = this.warm()
503
+ .catch(error => {
504
+ // Unreachable by design (warm catches per attempt); a warm-up must still never be left `warming`.
505
+ this.state = 'failed'
506
+ this.warmError = 'error'
507
+ this.retryAt = this.now() + this.backoff()
508
+ this.log(`[diarizer] Nemotron warm-up error: ${error instanceof Error ? error.message : String(error)}`)
509
+ })
510
+ .finally(() => { this.warming = null })
511
+ return this.warming
512
+ }
513
+
514
+ private async warm(): Promise<void> {
515
+ const delays = this.deps.warmRetryDelaysMs ?? WARM_RETRY_DELAYS_MS
516
+ const attempts = delays.length + 1
517
+ for (let attempt = 1; attempt <= attempts; attempt++) {
518
+ const choice = activeDiarizer(this.env(), this.home())
519
+ this.cli = nemotronCliPath(this.env(), this.home())
520
+ this.modelsDir = choice.fallback ? null : prepareNemotronModelsDir(this.env(), this.home())
521
+ if (!this.cli || !this.modelsDir) {
522
+ // Static: retrying cannot install a binary or download a model. Checked
523
+ // again every STATIC_RECHECK_MS without spawning; logged once.
524
+ const reason = choice.fallback ?? 'models_missing'
525
+ this.state = 'failed'
526
+ if (this.warmError !== reason) this.log(`[diarizer] tracks=voiceprint names=voiceprint fallback=${reason} (Nemotron requested)`)
527
+ this.warmError = reason
528
+ this.retryAt = this.now() + STATIC_RECHECK_MS
529
+ return
530
+ }
531
+ if (!this.identity || this.identity.path !== this.cli) {
532
+ const id = (this.deps.cliIdentity ?? cliIdentity)(this.cli)
533
+ if (id) {
534
+ this.identity = { path: this.cli, ...id }
535
+ this.log(`[diarizer] fluidaudiocli sha256 ${id.sha256} ${id.known ? '(FluidAudio v0.17.4, the verified build)' : '(an unverified build: offline use rests on --models; check its download behaviour)'}`)
536
+ }
537
+ }
538
+ const started = this.now()
539
+ let work: string | null = null
540
+ try {
541
+ // Inside the try (QA 6.61.0): a temp dir that cannot be made is a failed
542
+ // attempt with a reason, never a warm-up stuck on `warming`.
543
+ work = mkdtempSync(join(this.deps.tmpRoot?.() ?? tmpdir(), `${NEMOTRON_TMP_PREFIX}warm-`))
544
+ trackNemotronDir(work)
545
+ const wav = join(work, 'warm.wav')
546
+ // One second of silence: enough to load and compile, nothing to diarize.
547
+ writeFileSync(wav, pcm16ToWav(Buffer.alloc(32_000)), { mode: 0o600 })
548
+ const run = await (this.deps.run ?? runNemotronCli)({
549
+ cli: this.cli,
550
+ modelsDir: this.modelsDir,
551
+ wavPath: wav,
552
+ timeoutMs: this.deps.warmTimeoutMs ?? WARM_TIMEOUT_MS,
553
+ computeUnits: 'ane',
554
+ niceness: 10,
555
+ tmpRoot: this.deps.tmpRoot?.(),
556
+ })
557
+ this.state = 'ready'
558
+ this.warmMs = this.now() - started
559
+ this.warmLoadSec = run.loadSec
560
+ this.warmError = null
561
+ this.consecutiveFailures = 0
562
+ this.lastStateLog = null
563
+ this.log(`[diarizer] tracks=nemotron names=voiceprint warm in ${this.warmMs} ms (model load ${run.loadSec ?? '?'} s, ${NEMOTRON_VARIANT}, ane)`)
564
+ return
565
+ } catch (error) {
566
+ this.warmError = error instanceof NemotronRunError ? error.reason : 'temp_dir'
567
+ this.log(`[diarizer] Nemotron warm-up attempt ${attempt}/${attempts} failed (${this.warmError}): ${error instanceof Error ? error.message : String(error)}`)
568
+ } finally {
569
+ if (work) {
570
+ untrackNemotronDir(work)
571
+ try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ }
572
+ }
573
+ }
574
+ if (attempt < attempts) await (this.deps.sleep ?? (ms => new Promise(r => setTimeout(r, ms).unref?.())))(delays[attempt - 1])
575
+ }
576
+ const wait = this.backoff()
577
+ this.trips++
578
+ this.state = 'failed'
579
+ this.retryAt = this.now() + wait
580
+ this.log(`[diarizer] tracks=voiceprint names=voiceprint fallback=warmup_failed (${this.warmError}); next warm-up in ${Math.round(wait / 60_000)} min`)
581
+ }
582
+
583
+ /** Why Nemotron cannot take this chunk, or null when it can. */
584
+ private gate(): LiveFallbackReason | 'embedding_requested' | null {
585
+ if (requestedDiarizer(this.env()) === 'embedding') return 'embedding_requested'
586
+ if (this.state === 'idle' || this.state === 'off') { void this.startWarmup(); return 'warming' }
587
+ if (this.state === 'warming') return 'warming'
588
+ if (this.state === 'cooling' || this.state === 'failed') {
589
+ const reason: LiveFallbackReason = this.state === 'cooling'
590
+ ? 'cooling'
591
+ : this.warmError === 'cli_missing' || this.warmError === 'models_missing' ? this.warmError : 'warmup_failed'
592
+ if (this.now() < this.retryAt) return reason
593
+ // The rest is over: one fresh warm-up decides.
594
+ void this.startWarmup()
595
+ return STATIC_REASONS.has(reason) ? reason : 'warming'
596
+ }
597
+ if (this.inFlight >= this.maxConcurrent()) return 'busy'
598
+ return null
599
+ }
600
+
601
+ /** Run Nemotron on one chunk WAV within the live budget. Never throws. */
602
+ async diarize(wavPath: string): Promise<LiveDiarizeOutcome> {
603
+ const blocked = this.gate()
604
+ if (blocked) return { ok: false, reason: blocked }
605
+ this.inFlight++
606
+ const started = this.now()
607
+ try {
608
+ const run = await (this.deps.run ?? runNemotronCli)({
609
+ cli: this.cli!,
610
+ modelsDir: this.modelsDir!,
611
+ wavPath,
612
+ timeoutMs: this.budget(),
613
+ computeUnits: 'ane',
614
+ niceness: 10,
615
+ tmpRoot: this.deps.tmpRoot?.(),
616
+ })
617
+ this.consecutiveFailures = 0
618
+ this.trips = 0
619
+ return { ok: true, run }
620
+ } catch (error) {
621
+ const reason: LiveFallbackReason = error instanceof NemotronRunError ? error.reason : 'error'
622
+ if (RUN_FAILURES.has(reason)) {
623
+ this.consecutiveFailures++
624
+ if (this.consecutiveFailures >= BREAKER_FAILURES && this.state === 'ready') {
625
+ // The breaker (QA 6.61.0): stop spawning. Every chunk takes the voiceprint
626
+ // at once instead of waiting out a budget it will not meet.
627
+ const wait = this.backoff()
628
+ this.trips++
629
+ this.state = 'cooling'
630
+ this.retryAt = this.now() + wait
631
+ this.warmError = reason
632
+ this.log(`[diarizer] breaker open: ${this.consecutiveFailures} Nemotron runs failed in a row (last: ${reason}); voiceprint labels for ${Math.round(wait / 60_000)} min, then a fresh warm-up`)
633
+ this.consecutiveFailures = 0
634
+ }
635
+ }
636
+ return { ok: false, reason, detail: error instanceof Error ? error.message : String(error), ms: this.now() - started }
637
+ } finally {
638
+ this.inFlight--
639
+ }
640
+ }
641
+
642
+ /** Record one chunk's outcome. Run failures are logged per chunk; state reasons once per change. */
643
+ record(chunk: { sessionId: string; chunkIndex: number }, outcome:
644
+ | { outcome: 'nemotron'; ms: number; segments: number; tracks: number; timing: string; identified: number; ownerGuard?: { client_label: number; weak_owner: number } }
645
+ | { outcome: 'voiceprint'; reason: string; ms: number | null; detail?: string },
646
+ ): void {
647
+ const at = new Date(this.now()).toISOString()
648
+ if (outcome.outcome === 'nemotron') {
649
+ this.counts.nemotron++
650
+ this.last = { at, outcome: 'nemotron', reason: null, ms: outcome.ms }
651
+ this.pushRecent('nemotron', null)
652
+ this.lastStateLog = null
653
+ this.log(`[diarizer] chunk #${chunk.chunkIndex} nemotron ${outcome.ms} ms tracks=${outcome.tracks} segments=${outcome.segments} timing=${outcome.timing} named=${outcome.identified}`)
654
+ const guard = outcome.ownerGuard
655
+ if (guard && guard.client_label + guard.weak_owner > 0) {
656
+ this.ownerGuard.client_label += guard.client_label
657
+ this.ownerGuard.weak_owner += guard.weak_owner
658
+ this.log(`[diarizer] owner-guard chunk #${chunk.chunkIndex} refused=${guard.client_label + guard.weak_owner} client_label=${guard.client_label} weak_owner=${guard.weak_owner}`)
659
+ }
660
+ return
661
+ }
662
+ this.counts.voiceprint++
663
+ this.counts.reasons[outcome.reason] = (this.counts.reasons[outcome.reason] ?? 0) + 1
664
+ this.last = { at, outcome: 'voiceprint', reason: outcome.reason, ms: outcome.ms }
665
+ // Nemotron ran and heard nobody: not a failure of Nemotron.
666
+ if (outcome.reason !== 'no_speech') this.pushRecent('voiceprint', outcome.reason)
667
+ if (STATE_REASONS.has(outcome.reason)) {
668
+ if (this.lastStateLog === outcome.reason) return
669
+ this.lastStateLog = outcome.reason
670
+ this.log(`[diarizer] chunk #${chunk.chunkIndex} fallback=${outcome.reason} -> voiceprint (and every chunk after it until the state changes; counted in health)`)
671
+ return
672
+ }
673
+ this.log(`[diarizer] chunk #${chunk.chunkIndex} fallback=${outcome.reason}${outcome.ms !== null ? ` after ${outcome.ms} ms` : ''} -> voiceprint${outcome.detail ? ` (${outcome.detail.slice(0, 160)})` : ''}`)
674
+ }
675
+
676
+ private pushRecent(outcome: 'nemotron' | 'voiceprint', reason: string | null): void {
677
+ this.recent.push({ outcome, reason })
678
+ if (this.recent.length > RECENT_WINDOW) this.recent.shift()
679
+ }
680
+
681
+ snapshot(): LiveSnapshot {
682
+ const choice = activeDiarizer(this.env(), this.home())
683
+ const requested = choice.requested
684
+ const recentNemotron = this.recent.filter(r => r.outcome === 'nemotron').length
685
+ const recentVoiceprint = this.recent.length - recentNemotron
686
+ let active: LiveSnapshot['active']
687
+ let fallback: string | null = null
688
+ if (requested === 'embedding') active = 'embedding'
689
+ else if (choice.fallback) { active = 'embedding'; fallback = choice.fallback }
690
+ else if (this.state === 'warming' || this.state === 'idle') { active = 'warming'; fallback = 'warming' }
691
+ else if (this.state === 'cooling') { active = 'embedding'; fallback = 'cooling' }
692
+ else if (this.state === 'failed' || this.state === 'off') {
693
+ active = 'embedding'
694
+ fallback = this.warmError === 'cli_missing' || this.warmError === 'models_missing' ? this.warmError : 'warmup_failed'
695
+ } else if (this.recent.length > 0 && recentVoiceprint * 2 > this.recent.length) {
696
+ // Warm, but most recent chunks fell back: say so, and why.
697
+ active = 'embedding'
698
+ const tally = new Map<string, number>()
699
+ for (const r of this.recent) if (r.reason) tally.set(r.reason, (tally.get(r.reason) ?? 0) + 1)
700
+ fallback = [...tally.entries()].sort((x, y) => y[1] - x[1])[0]?.[0] ?? 'error'
701
+ } else active = 'nemotron'
702
+ return {
703
+ requested,
704
+ active,
705
+ fallback,
706
+ recent: { window: RECENT_WINDOW, nemotron: recentNemotron, voiceprint: recentVoiceprint },
707
+ breaker: {
708
+ open: this.state === 'cooling',
709
+ trips: this.trips,
710
+ consecutiveFailures: this.consecutiveFailures,
711
+ retryAt: (this.state === 'cooling' || this.state === 'failed') && this.retryAt ? new Date(this.retryAt).toISOString() : null,
712
+ },
713
+ variant: NEMOTRON_VARIANT,
714
+ computeUnits: 'ane',
715
+ budgetMs: this.budget(),
716
+ maxConcurrent: this.maxConcurrent(),
717
+ inFlight: this.inFlight,
718
+ warm: { state: this.state, ms: this.warmMs, loadSec: this.warmLoadSec, error: this.warmError },
719
+ cliKnown: this.identity ? this.identity.known : null,
720
+ chunks: { nemotron: this.counts.nemotron, voiceprint: this.counts.voiceprint, reasons: { ...this.counts.reasons } },
721
+ ownerGuard: { ...this.ownerGuard },
722
+ last: this.last,
723
+ }
724
+ }
725
+ }
726
+
727
+ export const nemotronLive = new NemotronLiveRuntime()
728
+
729
+ /** Server start: sweep what a previous process left, then warm the model in the background. */
730
+ export function startNemotronWarmup(): void {
731
+ try {
732
+ const swept = sweepStaleNemotron()
733
+ if (swept.dirs || swept.processes) console.log(`[diarizer] startup sweep: removed ${swept.dirs} stale temp dir(s), killed ${swept.processes} orphaned CLI run(s)`)
734
+ } catch { /* the sweep is housekeeping, never a reason not to start */ }
735
+ void nemotronLive.startWarmup()
736
+ }