@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.
- package/.env.example +8 -0
- package/CHANGELOG.md +51 -0
- package/README.md +64 -0
- package/managed-runtime-contract.json +4 -1
- package/package.json +3 -1
- package/server/index.ts +11 -0
- package/server/lib/diarizer-backend.ts +163 -0
- package/server/lib/meeting-relabel-enrolment.ts +44 -7
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-cli.ts +256 -0
- package/server/lib/nemotron-final.ts +1005 -0
- package/server/lib/nemotron-live.ts +736 -0
- package/server/lib/nemotron-segments.ts +358 -0
- package/server/lib/speaker-embeddings.ts +9 -5
- package/server/lib/voiceprint-floor.ts +12 -0
- package/server/routes/health.ts +10 -0
- package/server/routes/meeting.ts +55 -2
- package/server/routes/transcribe-stream.ts +75 -6
|
@@ -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
|
+
}
|