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