@gotcos/glasses-server 6.60.0 → 6.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,979 @@
1
+ // The final transcript: one Nemotron pass over the whole meeting at save time.
2
+ //
3
+ // Live chunks are diarized one at a time, so a chunk's Nemotron channels mean
4
+ // nothing in the next chunk. Run once over the meeting, the channels are
5
+ // consistent for the whole call, and the canary's method maps each to a name:
6
+ // 1. place every chunk WAV at its real `elapsed` on one timeline (join
7
+ // chunkEntries[].chunkIndex to chunk_NNNN.wav, check audioSha256, trim only
8
+ // the tail that would run into the next chunk);
9
+ // 2. score the raw 10 ms probabilities at 0.5, 8 channels;
10
+ // 3. a channel takes a name only from chunks the voiceprint is sure of
11
+ // (similarity >= 0.65, a real name, one-sided by Even Hub, or by Nemotron
12
+ // itself when the chunk has no Even Hub histogram), with at least
13
+ // FINAL_MIN_SUPPORT such chunks and FINAL_PURITY of them agreeing. A channel
14
+ // two people share stays unmapped.
15
+ // Every word then takes its channel's name, or keeps its per-chunk name (the
16
+ // live segment it sat in, else its existing label) where the channel is unmapped.
17
+ //
18
+ // SAFETY. Only labels change. The saved transcript's words are relabelled in
19
+ // place (never regenerated and re-cleaned), and nothing is written unless the
20
+ // result holds exactly the same words, in the same order. The original
21
+ // labels stay recoverable: every changed batch word carries `originalSpeaker`,
22
+ // every changed chunk carries `originalSpeaker`, and the sidecar keeps the whole
23
+ // pre-relabel transcript under `diarization.originalTranscript`. Any failure
24
+ // leaves the meeting exactly as it was saved. A human correction always wins:
25
+ // a sidecar with correctionRevision > 0 is never relabelled.
26
+ //
27
+ // No LLM anywhere. Transcription must never call `claude -p`.
28
+
29
+ import { createHash } from 'node:crypto'
30
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'
31
+ import { homedir, tmpdir } from 'node:os'
32
+ import { readFile, writeFile } from 'node:fs/promises'
33
+ import { join } from 'node:path'
34
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
35
+ import { selectBatchTranscriptForPersistence } from './batch-transcript-quality.js'
36
+ import {
37
+ NEMOTRON_BUNDLE,
38
+ NEMOTRON_VARIANT,
39
+ activeDiarizer,
40
+ nemotronCliPath,
41
+ prepareNemotronModelsDir,
42
+ } from './diarizer-backend.js'
43
+ import { CONFIDENT_SIMILARITY, isUnattributed } from './meeting-speaker-review.js'
44
+ import {
45
+ NEMOTRON_TMP_PREFIX,
46
+ NemotronRunError,
47
+ runNemotronCli,
48
+ trackNemotronDir,
49
+ untrackNemotronDir,
50
+ type NemotronPreds,
51
+ type RunNemotronOptions,
52
+ } from './nemotron-cli.js'
53
+ import type { ChunkSpeakerSegment } from './nemotron-live.js'
54
+ import {
55
+ channelForSpan,
56
+ estimateTokenTimes,
57
+ frameRange,
58
+ isActive,
59
+ isWordToken,
60
+ readPcm16Mono,
61
+ splitTokens,
62
+ tokenTimesFromWords,
63
+ tracksIn,
64
+ type Activity,
65
+ type TokenTime,
66
+ wavHeader,
67
+ } from './nemotron-segments.js'
68
+ import { NEMOTRON_CHANNELS } from './nemotron-cli.js'
69
+ import type { WhisperWord } from './whisper-local.js'
70
+
71
+ /** A name is evidence only from a chunk the voiceprint was sure of: the review panel's own floor. */
72
+ export const FINAL_MIN_SIMILARITY = CONFIDENT_SIMILARITY
73
+ export const FINAL_ONE_SIDED = 0.8
74
+ export const FINAL_MIN_SUPPORT = 3
75
+ export const FINAL_PURITY = 0.8
76
+ /** The canary's eligibility for a meeting-wide map: at most this many identities... */
77
+ export const FINAL_MAX_IDENTITIES = 4
78
+ /** ...and fewer channels in use than Nemotron has, or two people already share one. */
79
+ export const FINAL_MAX_CHANNELS = NEMOTRON_CHANNELS
80
+ /** A timeline longer than this many times the audio it holds (plus slack) is a bad clock, not a meeting. */
81
+ export const FINAL_MAX_SPAN_RATIO = 1.5
82
+ export const FINAL_SPAN_SLACK_SEC = 600
83
+ const SR = 16_000
84
+ const WAV_HEADER = 44
85
+
86
+ interface EvenRole { frames?: number; self?: number; other?: number }
87
+
88
+ export interface SidecarChunk {
89
+ text: string
90
+ speaker: string
91
+ elapsed: number
92
+ similarity: number
93
+ words?: WhisperWord[]
94
+ segments?: ChunkSpeakerSegment[]
95
+ evenHubSpeakerRole?: EvenRole
96
+ audioSha256?: string
97
+ originalSpeaker?: string
98
+ finalSegments?: ChunkSpeakerSegment[]
99
+ [key: string]: unknown
100
+ }
101
+
102
+ export interface SidecarEntry { chunkIndex: number; chunk: SidecarChunk }
103
+
104
+ export interface SpeakerWord {
105
+ word: string
106
+ start: number
107
+ end: number
108
+ speaker: string
109
+ similarity: number
110
+ originalSpeaker?: string
111
+ [key: string]: unknown
112
+ }
113
+
114
+ export interface SidecarBatchSegment {
115
+ startChunkIdx: number
116
+ endChunkIdx: number
117
+ startElapsed: number
118
+ endElapsed: number
119
+ text: string
120
+ words?: WhisperWord[]
121
+ speakerWords: SpeakerWord[]
122
+ }
123
+
124
+ export interface TimelineWindow {
125
+ chunkIndex: number
126
+ /** Seconds on the meeting timeline. */
127
+ start: number
128
+ end: number
129
+ chunk: SidecarChunk
130
+ }
131
+
132
+ export interface MeetingTimeline {
133
+ /** A complete 16 kHz mono WAV. */
134
+ wav: Buffer
135
+ durationSec: number
136
+ windows: TimelineWindow[]
137
+ byIndex: Map<number, TimelineWindow>
138
+ /** PCM samples each chunk file holds as concatenateWavChunks reads it (bytes after a 44-byte header). */
139
+ fileSamples: Map<number, number>
140
+ excluded: { missing: number; hashMismatch: number; unreadable: number }
141
+ /** Seconds of chunk audio placed on the timeline. */
142
+ audioSec: number
143
+ /** True when the span is far beyond the audio: no WAV was built. */
144
+ tooLong: boolean
145
+ }
146
+
147
+ const chunkFile = (audioDir: string, index: number): string => join(audioDir, `chunk_${String(index).padStart(4, '0')}.wav`)
148
+
149
+ /** lstat, so a symlink or a FIFO in the audio directory is never read. */
150
+ function isRegularFile(path: string): boolean {
151
+ try { return lstatSync(path).isFile() } catch { return false }
152
+ }
153
+
154
+ /** Samples concatenateWavChunks reads from a chunk file: everything after a 44-byte header, if it is RIFF. */
155
+ function concatSamples(path: string): number | null {
156
+ try {
157
+ const stat = statSync(path)
158
+ if (!stat.isFile() || stat.size <= WAV_HEADER) return null
159
+ const size = stat.size
160
+ // Non-blocking and no symlinks: a FIFO planted in its place must never hang the server.
161
+ const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW)
162
+ try {
163
+ const head = Buffer.alloc(4)
164
+ readSync(fd, head, 0, 4, 0)
165
+ return head.toString('ascii') === 'RIFF' ? Math.floor((size - WAV_HEADER) / 2) : null
166
+ } finally { closeSync(fd) }
167
+ } catch {
168
+ return null
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The canary's timeline: each WAV at its real elapsed, only the overlapping tail
174
+ * trimmed. Two passes so that only the timeline itself is held in memory (a
175
+ * 90-minute meeting is about 170 MB of 16-bit audio): the first checks every
176
+ * chunk and keeps its place, the second copies its audio in.
177
+ */
178
+ /** Give the event loop back every this many chunks while the timeline is built. */
179
+ const TIMELINE_YIELD_EVERY = 25
180
+ const yieldToLoop = (): Promise<void> => new Promise(resolve => setImmediate(resolve))
181
+
182
+ /**
183
+ * Async and chunked (QA 6.61.0, N7): reads are non-blocking and the loop is given
184
+ * back every TIMELINE_YIELD_EVERY chunks, so a long meeting's timeline does not
185
+ * freeze live chunk uploads while it is built.
186
+ *
187
+ * Returns `tooLong` instead of a timeline when the span the clocks claim is far
188
+ * beyond the audio the chunks hold (QA W5: one chunk stamped three hours late
189
+ * made a 346 MB timeline out of 12 s of audio). Nothing is allocated then.
190
+ */
191
+ export async function buildMeetingTimeline(entries: SidecarEntry[], audioDir: string): Promise<MeetingTimeline> {
192
+ const excluded = { missing: 0, hashMismatch: 0, unreadable: 0 }
193
+ const fileSamples = new Map<number, number>()
194
+ const indices = entries.map(entry => entry.chunkIndex)
195
+ const maxIndex = indices.length ? Math.max(...indices) : -1
196
+ for (let index = 0; index <= maxIndex; index++) {
197
+ const samples = concatSamples(chunkFile(audioDir, index))
198
+ if (samples !== null) fileSamples.set(index, samples)
199
+ }
200
+ const placed: Array<{ entry: SidecarEntry; at: number; samples: number; dataOffset: number }> = []
201
+ let seen = 0
202
+ for (const entry of entries) {
203
+ if (++seen % TIMELINE_YIELD_EVERY === 0) await yieldToLoop()
204
+ const path = chunkFile(audioDir, entry.chunkIndex)
205
+ if (!existsSync(path)) { excluded.missing++; continue }
206
+ if (!isRegularFile(path)) { excluded.unreadable++; continue }
207
+ let wav: Buffer
208
+ try { wav = await readFile(path) } catch { excluded.unreadable++; continue }
209
+ const expected = entry.chunk.audioSha256
210
+ if (expected && createHash('sha256').update(wav).digest('hex') !== expected) { excluded.hashMismatch++; continue }
211
+ const pcm = readPcm16Mono(wav)
212
+ if (!pcm || pcm.sampleRate !== SR || !Number.isFinite(entry.chunk.elapsed)) { excluded.unreadable++; continue }
213
+ placed.push({ entry, at: Math.max(0, Math.round((entry.chunk.elapsed / 1000) * SR)), samples: pcm.pcm.length / 2, dataOffset: pcm.pcm.byteOffset - wav.byteOffset })
214
+ }
215
+ placed.sort((x, y) => x.at - y.at || x.entry.chunkIndex - y.entry.chunkIndex)
216
+ let total = 0
217
+ let audio = 0
218
+ for (const item of placed) { total = Math.max(total, item.at + item.samples); audio += item.samples }
219
+ total += SR
220
+ const empty = { wav: Buffer.alloc(0), windows: [], byIndex: new Map<number, TimelineWindow>(), fileSamples, excluded }
221
+ if (total / SR > (audio / SR) * FINAL_MAX_SPAN_RATIO + FINAL_SPAN_SLACK_SEC) {
222
+ return { ...empty, durationSec: total / SR, audioSec: audio / SR, tooLong: true }
223
+ }
224
+ const out = Buffer.alloc(WAV_HEADER + total * 2)
225
+ wavHeader(total * 2, SR).copy(out, 0)
226
+ const windows: TimelineWindow[] = []
227
+ for (let i = 0; i < placed.length; i++) {
228
+ if ((i + 1) % TIMELINE_YIELD_EVERY === 0) await yieldToLoop()
229
+ const item = placed[i]
230
+ const next = i + 1 < placed.length ? placed[i + 1].at : item.at + item.samples
231
+ const room = Math.max(0, next - item.at)
232
+ const use = Math.min(item.samples, room)
233
+ let copied = 0
234
+ try {
235
+ if (!isRegularFile(chunkFile(audioDir, item.entry.chunkIndex))) throw new Error('not a regular file')
236
+ const wav = await readFile(chunkFile(audioDir, item.entry.chunkIndex))
237
+ copied = wav.copy(out, WAV_HEADER + item.at * 2, item.dataOffset, Math.min(wav.length, item.dataOffset + use * 2)) / 2
238
+ } catch { /* vanished between passes: its window stays silent */ }
239
+ windows.push({ chunkIndex: item.entry.chunkIndex, start: item.at / SR, end: (item.at + Math.floor(copied)) / SR, chunk: item.entry.chunk })
240
+ }
241
+ return {
242
+ wav: out,
243
+ durationSec: total / SR,
244
+ audioSec: audio / SR,
245
+ tooLong: false,
246
+ windows,
247
+ byIndex: new Map(windows.map(window => [window.chunkIndex, window])),
248
+ fileSamples,
249
+ excluded,
250
+ }
251
+ }
252
+
253
+ /** Too full for one meeting-wide map: more than four identities, or every channel in use. */
254
+ export function isCrowded(identities: number, channelsUsed: number): boolean {
255
+ return identities > FINAL_MAX_IDENTITIES || channelsUsed >= FINAL_MAX_CHANNELS
256
+ }
257
+
258
+ /** Names with at least three chunks the voiceprint is sure of: the canary's identity count. */
259
+ export function crowdIdentities(windows: TimelineWindow[]): number {
260
+ const counts = new Map<string, number>()
261
+ for (const window of windows) {
262
+ const chunk = window.chunk
263
+ if (isName(chunk.speaker) && Number(chunk.similarity) >= FINAL_MIN_SIMILARITY) counts.set(chunk.speaker, (counts.get(chunk.speaker) ?? 0) + 1)
264
+ }
265
+ return [...counts.values()].filter(count => count >= FINAL_MIN_SUPPORT).length
266
+ }
267
+
268
+ function isName(label: unknown): label is string {
269
+ return typeof label === 'string' && !isUnattributed(label.trim())
270
+ }
271
+
272
+ export interface ChannelMapping {
273
+ map: Map<number, string>
274
+ evidence: Array<{ channel: number; name: string | null; support: number; purity: number; top: string | null }>
275
+ eligible: number
276
+ }
277
+
278
+ /** The canary's dominant-channel map, made many-to-one with a purity floor. */
279
+ export function mapChannelsToNames(windows: TimelineWindow[], a: Activity, options: {
280
+ minSimilarity?: number
281
+ oneSided?: number
282
+ minSupport?: number
283
+ purity?: number
284
+ } = {}): ChannelMapping {
285
+ const minSimilarity = options.minSimilarity ?? FINAL_MIN_SIMILARITY
286
+ const oneSided = options.oneSided ?? FINAL_ONE_SIDED
287
+ const minSupport = options.minSupport ?? FINAL_MIN_SUPPORT
288
+ const purity = options.purity ?? FINAL_PURITY
289
+ const tallies = new Map<number, Map<string, number>>()
290
+ let eligible = 0
291
+ for (const window of windows) {
292
+ const chunk = window.chunk
293
+ if (!isName(chunk.speaker) || !(Number(chunk.similarity) >= minSimilarity)) continue
294
+ const [f0, f1] = frameRange(a, window.start, window.end)
295
+ const counts = new Array<number>(NEMOTRON_CHANNELS).fill(0)
296
+ let speech = 0
297
+ for (let f = f0; f < f1; f++) {
298
+ let any = false
299
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) if (isActive(a, f, c)) { counts[c]++; any = true }
300
+ if (any) speech++
301
+ }
302
+ if (speech === 0) continue
303
+ let dom = 0
304
+ for (let c = 1; c < NEMOTRON_CHANNELS; c++) if (counts[c] > counts[dom]) dom = c
305
+ const role = chunk.evenHubSpeakerRole
306
+ const frames = Number(role?.frames ?? 0)
307
+ const sided = frames > 0
308
+ ? Math.max(Number(role?.self ?? 0), Number(role?.other ?? 0)) / frames >= oneSided
309
+ : counts[dom] / speech >= oneSided
310
+ if (!sided) continue
311
+ eligible++
312
+ const tally = tallies.get(dom) ?? new Map<string, number>()
313
+ tally.set(chunk.speaker, (tally.get(chunk.speaker) ?? 0) + 1)
314
+ tallies.set(dom, tally)
315
+ }
316
+ const map = new Map<number, string>()
317
+ const evidence: ChannelMapping['evidence'] = []
318
+ for (const [channel, tally] of [...tallies.entries()].sort((x, y) => x[0] - y[0])) {
319
+ let top: string | null = null
320
+ let topCount = 0
321
+ let support = 0
322
+ for (const [name, count] of tally) {
323
+ support += count
324
+ if (count > topCount || (count === topCount && top !== null && name < top)) { top = name; topCount = count }
325
+ }
326
+ const share = support ? topCount / support : 0
327
+ const accepted = top !== null && support >= minSupport && share >= purity
328
+ if (accepted) map.set(channel, top!)
329
+ evidence.push({ channel, name: accepted ? top : null, support, purity: Math.round(share * 1000) / 1000, top })
330
+ }
331
+ return { map, evidence, eligible }
332
+ }
333
+
334
+ /** The live segment that held chunk-relative time t, else null. */
335
+ function liveSpeakerAt(chunk: SidecarChunk, t: number): string | null {
336
+ const segments = chunk.segments
337
+ if (!Array.isArray(segments) || segments.length === 0) return null
338
+ let best: ChunkSpeakerSegment | null = null
339
+ let distance = Number.POSITIVE_INFINITY
340
+ for (const segment of segments) {
341
+ const s = Number(segment.startSec)
342
+ const e = Number(segment.endSec)
343
+ if (!Number.isFinite(s) || !Number.isFinite(e)) continue
344
+ const d = t < s ? s - t : t > e ? t - e : 0
345
+ if (d < distance) { distance = d; best = segment }
346
+ }
347
+ return best && typeof best.speaker === 'string' ? best.speaker : null
348
+ }
349
+
350
+ export interface RelabelStats {
351
+ total: number
352
+ /** Words whose label changed. */
353
+ relabeled: number
354
+ /** Words labelled through a mapped channel. */
355
+ viaChannel: number
356
+ /** Words whose channel was unmapped or silent, kept on their per-chunk name. */
357
+ kept: number
358
+ }
359
+
360
+ /** Word times in a batch segment are relative to its concatenated chunk files. Put them on the timeline. */
361
+ export function batchWordToTimeline(segment: SidecarBatchSegment, t: number, timeline: MeetingTimeline): { window: TimelineWindow; inChunk: number; at: number } | null {
362
+ let offset = 0
363
+ let lastWindow: { window: TimelineWindow; inChunk: number; at: number } | null = null
364
+ for (let index = segment.startChunkIdx; index <= segment.endChunkIdx; index++) {
365
+ const samples = timeline.fileSamples.get(index)
366
+ if (samples === undefined) continue
367
+ const length = samples / SR
368
+ const window = timeline.byIndex.get(index)
369
+ if (t < offset + length || index === segment.endChunkIdx) {
370
+ if (!window) return null
371
+ const inChunk = Math.max(0, t - offset)
372
+ const span = Math.max(0, window.end - window.start - 0.005)
373
+ return { window, inChunk, at: window.start + Math.min(inChunk, span) }
374
+ }
375
+ if (window) lastWindow = { window, inChunk: length, at: window.end - 0.005 }
376
+ offset += length
377
+ }
378
+ return lastWindow
379
+ }
380
+
381
+ export function relabelBatchSegments(
382
+ segments: SidecarBatchSegment[],
383
+ timeline: MeetingTimeline,
384
+ a: Activity,
385
+ map: Map<number, string>,
386
+ ): { segments: SidecarBatchSegment[]; stats: RelabelStats } {
387
+ const stats: RelabelStats = { total: 0, relabeled: 0, viaChannel: 0, kept: 0 }
388
+ const out = segments.map(segment => ({
389
+ ...segment,
390
+ speakerWords: (segment.speakerWords ?? []).map(word => {
391
+ stats.total++
392
+ const original = typeof word.originalSpeaker === 'string' ? word.originalSpeaker : word.speaker
393
+ const start = batchWordToTimeline(segment, Number(word.start), timeline)
394
+ const end = batchWordToTimeline(segment, Number(word.end), timeline)
395
+ let name: string | null = null
396
+ if (start) {
397
+ const channel = channelForSpan(a, start.at, Math.max(start.at, end?.window === start.window ? end.at : start.at + 0.01))
398
+ if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
399
+ }
400
+ if (name === null) {
401
+ stats.kept++
402
+ name = (start && liveSpeakerAt(start.window.chunk, start.inChunk)) ?? original
403
+ }
404
+ if (name !== word.speaker) stats.relabeled++
405
+ const next: SpeakerWord = { ...word, speaker: name }
406
+ if (name !== original) next.originalSpeaker = original
407
+ else delete next.originalSpeaker
408
+ return next
409
+ }),
410
+ }))
411
+ return { segments: out, stats }
412
+ }
413
+
414
+ /** Token times for a streaming chunk, chunk-relative seconds. */
415
+ function streamingTokenTimes(chunk: SidecarChunk, tokens: string[], window: TimelineWindow, a: Activity): TokenTime[] | null {
416
+ if (Array.isArray(chunk.words) && chunk.words.length) {
417
+ const fromWords = tokenTimesFromWords(tokens, chunk.words)
418
+ if (fromWords) return fromWords
419
+ }
420
+ const shift = (times: TokenTime[] | null): TokenTime[] | null => times && times.map(time => ({ start: time.start - window.start, end: time.end - window.start }))
421
+ const segments = Array.isArray(chunk.segments) ? chunk.segments : []
422
+ const counts = segments.map(segment => splitTokens(String(segment.text ?? '')).length)
423
+ if (segments.length && counts.reduce((sum, n) => sum + n, 0) === tokens.length && segments.map(s => s.text).join(' ') === chunk.text) {
424
+ const out: TokenTime[] = []
425
+ let from = 0
426
+ for (let i = 0; i < segments.length; i++) {
427
+ const piece = tokens.slice(from, from + counts[i])
428
+ const s = window.start + Number(segments[i].startSec ?? 0)
429
+ const e = window.start + Number(segments[i].endSec ?? (window.end - window.start))
430
+ const times = shift(estimateTokenTimes(piece, a, s, Math.max(s + 0.01, e)))
431
+ ?? piece.map(() => ({ start: s - window.start, end: e - window.start }))
432
+ out.push(...times)
433
+ from += counts[i]
434
+ }
435
+ return out
436
+ }
437
+ return shift(estimateTokenTimes(tokens, a, window.start, window.end))
438
+ }
439
+
440
+ export function relabelStreamingChunks(
441
+ timeline: MeetingTimeline,
442
+ a: Activity,
443
+ map: Map<number, string>,
444
+ ): { finals: Map<number, { speaker: string; segments: ChunkSpeakerSegment[] }>; stats: RelabelStats } {
445
+ const stats: RelabelStats = { total: 0, relabeled: 0, viaChannel: 0, kept: 0 }
446
+ const finals = new Map<number, { speaker: string; segments: ChunkSpeakerSegment[] }>()
447
+ for (const window of timeline.windows) {
448
+ const chunk = window.chunk
449
+ if (typeof chunk.text !== 'string' || !chunk.text.trim()) continue
450
+ const tokens = splitTokens(chunk.text)
451
+ const times = streamingTokenTimes(chunk, tokens, window, a)
452
+ const base = typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker
453
+ const labels: string[] = []
454
+ let carry: string | null = null
455
+ for (let i = 0; i < tokens.length; i++) {
456
+ if (!isWordToken(tokens[i]) || !times) { labels.push(carry ?? liveSpeakerAt(chunk, 0) ?? base); continue }
457
+ stats.total++
458
+ const span = times[i]
459
+ const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
460
+ let name: string
461
+ if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
462
+ else { name = liveSpeakerAt(chunk, span.start) ?? base; stats.kept++ }
463
+ labels.push(name)
464
+ carry = name
465
+ }
466
+ const segments: ChunkSpeakerSegment[] = []
467
+ for (let i = 0; i < tokens.length; i++) {
468
+ const last = segments.at(-1)
469
+ if (last && last.speaker === labels[i]) { last.text += ` ${tokens[i]}`; if (times) last.endSec = Math.round(times[i].end * 100) / 100; continue }
470
+ segments.push({
471
+ speaker: labels[i],
472
+ text: tokens[i],
473
+ ...(times ? { startSec: Math.round(times[i].start * 100) / 100, endSec: Math.round(times[i].end * 100) / 100 } : {}),
474
+ })
475
+ }
476
+ const wordsBy = new Map<string, number>()
477
+ for (const segment of segments) wordsBy.set(segment.speaker, (wordsBy.get(segment.speaker) ?? 0) + splitTokens(segment.text).filter(isWordToken).length)
478
+ let speaker = chunk.speaker
479
+ let most = -1
480
+ for (const [name, count] of wordsBy) if (count > most) { most = count; speaker = name }
481
+ const liveLabels = Array.isArray(chunk.segments) && chunk.segments.length
482
+ ? chunk.segments.flatMap(segment => splitTokens(String(segment.text ?? '')).map(() => segment.speaker))
483
+ : tokens.map(() => chunk.speaker)
484
+ for (let i = 0; i < tokens.length; i++) if (isWordToken(tokens[i]) && labels[i] !== (liveLabels[i] ?? chunk.speaker)) stats.relabeled++
485
+ finals.set(window.chunkIndex, { speaker, segments })
486
+ }
487
+ return { finals, stats }
488
+ }
489
+
490
+ const LABEL_PREFIX = /^\[[^\]\n]+\]:\s*/
491
+
492
+ /** The words of a transcript with speaker labels removed. Gap markers stay words. */
493
+ export function transcriptWords(transcript: string): string[] {
494
+ return transcript.split('\n').flatMap(line => line.replace(LABEL_PREFIX, '').split(/\s+/).filter(Boolean))
495
+ }
496
+
497
+ export function sameWords(before: string, after: string): boolean {
498
+ const x = transcriptWords(before)
499
+ const y = transcriptWords(after)
500
+ return x.length === y.length && x.every((word, i) => word === y[i])
501
+ }
502
+
503
+ /** Re-render a streaming transcript: each `[X]: text` line that is exactly one chunk's text becomes that chunk's final turns. */
504
+ export function relabelStreamingTranscript(
505
+ transcript: string,
506
+ chunksInOrder: Array<{ chunkIndex: number; text: string }>,
507
+ finals: Map<number, { speaker: string; segments: ChunkSpeakerSegment[] }>,
508
+ ): string {
509
+ const out: string[] = []
510
+ let pointer = 0
511
+ for (const line of transcript.split('\n')) {
512
+ const match = /^\[([^\]\n]+)\]: ([\s\S]*)$/.exec(line)
513
+ if (!match) { out.push(line); continue }
514
+ let found = -1
515
+ for (let k = pointer; k < Math.min(chunksInOrder.length, pointer + 8); k++) {
516
+ if (chunksInOrder[k].text === match[2]) { found = k; break }
517
+ }
518
+ if (found < 0) { out.push(line); continue }
519
+ pointer = found + 1
520
+ const final = finals.get(chunksInOrder[found].chunkIndex)
521
+ if (!final) { out.push(line); continue }
522
+ for (const segment of final.segments) out.push(`[${segment.speaker}]: ${segment.text}`)
523
+ }
524
+ return out.join('\n')
525
+ }
526
+
527
+ /**
528
+ * Relabel a transcript that is already on disk, word by word, without touching a
529
+ * word. The saved batch transcript is the speaker-words grouping AFTER cleaning
530
+ * (URL-only lines dropped, negative rules applied), so regenerating it from the
531
+ * relabelled words and cleaning again can change which words survive when the
532
+ * groups change. Instead each transcript word is matched, in order, to the next
533
+ * relabelled word with the same text (a small window, then a three-word anchor
534
+ * to resynchronise past anything cleaning removed). A word with no match keeps
535
+ * the turn it is in. Gap markers and other unlabelled lines are kept as they are.
536
+ */
537
+ export function relabelTranscriptWords(transcript: string, sequence: Array<{ word: string; speaker: string }>): string {
538
+ const WINDOW = 12
539
+ const ANCHOR = 3
540
+ const RESYNC = 600
541
+ const lines = transcript.split('\n')
542
+ const parsed = lines.map(line => {
543
+ const match = /^\[([^\]\n]+)\]:\s*([\s\S]*)$/.exec(line)
544
+ return match ? { speaker: match[1], words: match[2].split(/\s+/).filter(Boolean) } : null
545
+ })
546
+ const flat: string[] = parsed.flatMap(line => line?.words ?? [])
547
+ const out: string[] = []
548
+ let pointer = 0
549
+ let flatIndex = 0
550
+ for (let li = 0; li < lines.length; li++) {
551
+ const line = parsed[li]
552
+ if (!line) { out.push(lines[li]); continue }
553
+ // Line breaks are kept (QA 6.61.0 W4): a line splits where its words change
554
+ // speaker and is never merged with its neighbour, so a pass that changes no
555
+ // label reproduces the transcript byte for byte.
556
+ const turns: Array<{ speaker: string; words: string[] }> = []
557
+ for (const word of line.words) {
558
+ let speaker: string | null = null
559
+ for (let k = pointer; k < Math.min(sequence.length, pointer + WINDOW); k++) {
560
+ if (sequence[k].word === word) { speaker = sequence[k].speaker; pointer = k + 1; break }
561
+ }
562
+ if (speaker === null) {
563
+ const anchor = flat.slice(flatIndex, flatIndex + ANCHOR)
564
+ if (anchor.length === ANCHOR) {
565
+ for (let k = pointer; k <= Math.min(sequence.length - ANCHOR, pointer + RESYNC); k++) {
566
+ if (anchor.every((w, a) => sequence[k + a].word === w)) { speaker = sequence[k].speaker; pointer = k + 1; break }
567
+ }
568
+ }
569
+ }
570
+ if (speaker === null) speaker = turns.at(-1)?.speaker ?? line.speaker
571
+ const turn = turns.at(-1)
572
+ if (turn && turn.speaker === speaker) turn.words.push(word)
573
+ else turns.push({ speaker, words: [word] })
574
+ flatIndex++
575
+ }
576
+ if (turns.length === 1 && turns[0].speaker === line.speaker) { out.push(lines[li]); continue }
577
+ for (const turn of turns) out.push(`[${turn.speaker}]: ${turn.words.join(' ')}`)
578
+ }
579
+ return out.join('\n')
580
+ }
581
+
582
+ const TRANSCRIPT_SECTION = /## Transcript\n\n([\s\S]*)$/
583
+
584
+ export function readTranscriptSection(markdown: string): string | null {
585
+ const match = TRANSCRIPT_SECTION.exec(markdown)
586
+ return match ? match[1].replace(/\n+$/, '') : null
587
+ }
588
+
589
+ /** Replace the transcript section and nothing else (the quality row stays as saved). */
590
+ export function replaceTranscriptSection(markdown: string, transcript: string): string | null {
591
+ if (!TRANSCRIPT_SECTION.test(markdown)) return null
592
+ return markdown.replace(TRANSCRIPT_SECTION, () => `## Transcript\n\n${transcript}\n`)
593
+ }
594
+
595
+ const sha = (text: string): string => createHash('sha256').update(text).digest('hex')
596
+
597
+ export type FinalPassStatus = 'applied' | 'skipped' | 'failed'
598
+
599
+ /**
600
+ * Every reason a final pass can give, a fixed set. `/api/health` is public (no
601
+ * token) and the server listens on the LAN, so health may carry only these codes
602
+ * and counts: never a speaker name, a path, a meeting title or a raw error
603
+ * message (QA 6.61.0 blocker). Anything else is reported as `error`, and the raw
604
+ * text goes to server.log only.
605
+ */
606
+ export const FINAL_REASON_CODES = [
607
+ 'embedding_requested', 'cli_missing', 'models_missing', 'meeting_missing', 'no_audio', 'human_corrected',
608
+ 'no_transcript', 'already_applied', 'no_chunks', 'nemotron_timeout', 'nemotron_cli_failed',
609
+ 'nemotron_bad_output', 'nemotron_spawn_failed', 'nemotron_error', 'no_speaker_words', 'chunk_map_ambiguous',
610
+ 'word_mismatch', 'no_change', 'changed_during_pass', 'markdown_write', 'sidecar_restore_failed', 'too_long',
611
+ 'metadata_not_persisted', 'error',
612
+ ] as const
613
+ export type FinalReasonCode = typeof FINAL_REASON_CODES[number]
614
+ const REASON_SET: ReadonlySet<string> = new Set(FINAL_REASON_CODES)
615
+ export const finalReasonCode = (reason: string | undefined): FinalReasonCode | null =>
616
+ reason === undefined ? null : REASON_SET.has(reason) ? reason as FinalReasonCode : 'error'
617
+
618
+ export interface FinalPassOutcome {
619
+ status: FinalPassStatus
620
+ reason?: string
621
+ /** Raw failure text for server.log. Never published. */
622
+ detail?: string
623
+ mode?: 'batch' | 'streaming'
624
+ /** True when the room was too full for a meeting-wide map (only per-chunk turns applied). */
625
+ crowded?: boolean
626
+ identities?: number
627
+ ms?: number
628
+ audioSec?: number
629
+ channels?: number
630
+ mapped?: Record<string, string>
631
+ words?: RelabelStats
632
+ /** Chunks left off the timeline: no file, a hash that disagrees, unreadable. */
633
+ excluded?: { missing: number; hashMismatch: number; unreadable: number }
634
+ /** Distinct speakers in the transcript before and after. */
635
+ speakersBefore?: number
636
+ speakersAfter?: number
637
+ }
638
+
639
+ export interface FinalPassOptions {
640
+ meetingPath: string
641
+ sidecarPath: string
642
+ audioDir: string
643
+ env?: NodeJS.ProcessEnv
644
+ home?: string
645
+ run?: (options: RunNemotronOptions) => Promise<NemotronPreds & { loadSec?: number | null }>
646
+ tmpRoot?: string
647
+ log?: (line: string) => void
648
+ now?: () => number
649
+ /** Seam: how files are written (durable atomic by default). */
650
+ write?: (path: string, data: string) => void
651
+ /** Seam: the batch relabeller (word-aligned in place by default). */
652
+ relabelWords?: (transcript: string, sequence: Array<{ word: string; speaker: string }>) => string
653
+ }
654
+
655
+ /** 20x realtime is the floor (the canary ran 155x), plus two minutes for a cold Neural Engine compile. */
656
+ export function finalPassTimeoutMs(durationSec: number): number {
657
+ return 120_000 + Math.round((durationSec * 1000) / 20)
658
+ }
659
+
660
+ /** What `/api/health` may say about the last final pass: codes and counts only. */
661
+ export interface PublicFinalOutcome {
662
+ status: FinalPassStatus
663
+ reasonCode: FinalReasonCode | null
664
+ mode: 'batch' | 'streaming' | null
665
+ crowded: boolean | null
666
+ channels: number | null
667
+ mappedChannels: number | null
668
+ wordsRelabeled: number | null
669
+ wordsTotal: number | null
670
+ excludedChunks: number | null
671
+ ms: number | null
672
+ at: string
673
+ }
674
+
675
+ let finalLane: Promise<unknown> = Promise.resolve()
676
+ let lastFinal: PublicFinalOutcome | null = null
677
+ const finalCounts: Record<string, number> = {}
678
+
679
+ export function publicFinalOutcome(outcome: FinalPassOutcome, at: string): PublicFinalOutcome {
680
+ return {
681
+ status: outcome.status,
682
+ reasonCode: finalReasonCode(outcome.reason),
683
+ mode: outcome.mode ?? null,
684
+ crowded: typeof outcome.crowded === 'boolean' ? outcome.crowded : null,
685
+ channels: typeof outcome.channels === 'number' ? outcome.channels : null,
686
+ mappedChannels: outcome.mapped ? Object.keys(outcome.mapped).length : null,
687
+ wordsRelabeled: outcome.words?.relabeled ?? null,
688
+ wordsTotal: outcome.words?.total ?? null,
689
+ excludedChunks: outcome.excluded ? outcome.excluded.missing + outcome.excluded.hashMismatch + outcome.excluded.unreadable : null,
690
+ ms: typeof outcome.ms === 'number' ? outcome.ms : null,
691
+ at,
692
+ }
693
+ }
694
+
695
+ export function finalPassSnapshot(): { last: PublicFinalOutcome | null; counts: Record<string, number> } {
696
+ return { last: lastFinal ? { ...lastFinal } : null, counts: { ...finalCounts } }
697
+ }
698
+
699
+ /** One meeting's final pass, serialised with every other final pass. Never throws. */
700
+ export function runNemotronFinalPass(options: FinalPassOptions): Promise<FinalPassOutcome> {
701
+ const job = finalLane.then(() => finalPassNow(options)).catch((error): FinalPassOutcome => ({
702
+ status: 'failed',
703
+ reason: 'error',
704
+ detail: error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300),
705
+ }))
706
+ finalLane = job.then(() => undefined, () => undefined)
707
+ return job.then(outcome => {
708
+ const published = publicFinalOutcome(outcome, new Date((options.now ?? Date.now)()).toISOString())
709
+ const key = outcome.status === 'applied' ? 'applied' : `${outcome.status}:${published.reasonCode ?? 'error'}`
710
+ finalCounts[key] = (finalCounts[key] ?? 0) + 1
711
+ lastFinal = published
712
+ const log = options.log ?? console.log
713
+ if (outcome.status === 'applied') {
714
+ log(`[diarizer] final pass applied (${outcome.mode}${outcome.crowded ? `, crowded: ${outcome.identities} identities, per-chunk turns only` : ''}) in ${outcome.ms} ms: ${outcome.channels} channels, mapped ${JSON.stringify(outcome.mapped)}, ${outcome.words?.relabeled}/${outcome.words?.total} words relabelled, speakers ${outcome.speakersBefore} -> ${outcome.speakersAfter}${excludedNote(outcome)}`)
715
+ } else if (outcome.reason === 'sidecar_restore_failed') {
716
+ log(`[diarizer] final pass FAILED and could not restore the sidecar: ${outcome.detail}`)
717
+ } else {
718
+ log(`[diarizer] final pass ${outcome.status}: ${outcome.reason}${outcome.detail ? ` (${outcome.detail})` : ''}${excludedNote(outcome)}; the saved transcript is unchanged`)
719
+ }
720
+ return outcome
721
+ })
722
+ }
723
+
724
+ function excludedNote(outcome: FinalPassOutcome): string {
725
+ const e = outcome.excluded
726
+ if (!e || e.missing + e.hashMismatch + e.unreadable === 0) return ''
727
+ return `; chunks left off the timeline: ${e.missing} missing, ${e.hashMismatch} hash mismatch, ${e.unreadable} unreadable`
728
+ }
729
+
730
+ /** A pass the caller decided not to run (the save path's metadata guard): counted and logged like any other. */
731
+ export function recordFinalPassSkip(reason: FinalReasonCode, detail: string, options: { log?: (line: string) => void; now?: () => number } = {}): void {
732
+ const outcome: FinalPassOutcome = { status: 'skipped', reason, detail }
733
+ const published = publicFinalOutcome(outcome, new Date((options.now ?? Date.now)()).toISOString())
734
+ const key = `skipped:${published.reasonCode ?? 'error'}`
735
+ finalCounts[key] = (finalCounts[key] ?? 0) + 1
736
+ lastFinal = published
737
+ ;(options.log ?? console.log)(`[diarizer] final pass skipped: ${reason} (${detail}); the saved transcript is unchanged`)
738
+ }
739
+
740
+ function distinctSpeakers(transcript: string): number {
741
+ const names = new Set<string>()
742
+ for (const line of transcript.split('\n')) {
743
+ const match = /^\[([^\]\n]+)\]:/.exec(line)
744
+ if (match) names.add(match[1])
745
+ }
746
+ return names.size
747
+ }
748
+
749
+ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome> {
750
+ const started = (options.now ?? Date.now)()
751
+ const env = options.env ?? process.env
752
+ const home = options.home ?? homedir()
753
+ const choice = activeDiarizer(env, home)
754
+ if (choice.requested === 'embedding') return { status: 'skipped', reason: 'embedding_requested' }
755
+ if (choice.fallback) return { status: 'skipped', reason: choice.fallback }
756
+ if (!existsSync(options.sidecarPath) || !existsSync(options.meetingPath)) return { status: 'skipped', reason: 'meeting_missing' }
757
+ if (!existsSync(options.audioDir)) return { status: 'skipped', reason: 'no_audio' }
758
+
759
+ const sidecarText = readFileSync(options.sidecarPath, 'utf8')
760
+ const sidecar = JSON.parse(sidecarText) as Record<string, any>
761
+ if (Number(sidecar.correctionRevision ?? 0) > 0) return { status: 'skipped', reason: 'human_corrected' }
762
+ const markdown = readFileSync(options.meetingPath, 'utf8')
763
+ const transcript = readTranscriptSection(markdown)
764
+ if (transcript === null) return { status: 'skipped', reason: 'no_transcript' }
765
+ if (sidecar.diarization?.status === 'applied' && sidecar.diarization?.outputSha256 === sha(transcript)) {
766
+ return { status: 'skipped', reason: 'already_applied' }
767
+ }
768
+ const entries = (Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries : []) as SidecarEntry[]
769
+ if (!entries.length) return { status: 'skipped', reason: 'no_chunks' }
770
+
771
+ const timeline = await buildMeetingTimeline(entries, options.audioDir)
772
+ const excluded = timeline.excluded
773
+ if (timeline.tooLong) {
774
+ return { status: 'skipped', reason: 'too_long', excluded, detail: `timeline ${Math.round(timeline.durationSec)} s for ${Math.round(timeline.audioSec)} s of audio` }
775
+ }
776
+ if (!timeline.windows.length) return { status: 'skipped', reason: 'no_audio', excluded }
777
+
778
+ const cli = nemotronCliPath(env, home)
779
+ const modelsDir = prepareNemotronModelsDir(env, home)
780
+ if (!cli || !modelsDir) return { status: 'skipped', reason: !cli ? 'cli_missing' : 'models_missing' }
781
+
782
+ // Tracked, so a server exit mid-run kills the CLI and deletes this directory
783
+ // (it holds the whole meeting's audio); a sweep at the next start catches a crash.
784
+ const work = mkdtempSync(join(options.tmpRoot ?? tmpdir(), `${NEMOTRON_TMP_PREFIX}final-`))
785
+ trackNemotronDir(work)
786
+ let run: NemotronPreds
787
+ try {
788
+ try { chmodSync(work, 0o700) } catch { /* mkdtemp is already 0700 */ }
789
+ const wavPath = join(work, 'meeting.wav')
790
+ await writeFile(wavPath, timeline.wav, { mode: 0o600 })
791
+ try {
792
+ run = await (options.run ?? runNemotronCli)({
793
+ cli,
794
+ modelsDir,
795
+ wavPath,
796
+ timeoutMs: finalPassTimeoutMs(timeline.durationSec),
797
+ computeUnits: 'ane',
798
+ niceness: 10,
799
+ tmpRoot: options.tmpRoot,
800
+ })
801
+ } catch (error) {
802
+ return {
803
+ status: 'failed',
804
+ reason: error instanceof NemotronRunError ? `nemotron_${error.reason}` : 'nemotron_error',
805
+ detail: (error instanceof Error ? error.message : String(error)).slice(0, 300),
806
+ excluded,
807
+ }
808
+ }
809
+ } finally {
810
+ untrackNemotronDir(work)
811
+ try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ }
812
+ }
813
+
814
+ const activity: Activity = run
815
+ // A channel is in use once it holds a track's worth of speech (QA 6.61.0 N2:
816
+ // one 10 ms blip on a third channel made a two-person call "crowded").
817
+ const channelsUsed = new Set(tracksIn(activity, 0, activity.frames).map(track => track.channel))
818
+ // The canary's rule for a meeting-wide map (OUTCOME_2026-10-01, retro plan 4):
819
+ // four or fewer identities and fewer than eight channels. A crowded room
820
+ // (Wed 07:31 filled all eight with five names) gets no channel map; its words
821
+ // keep their per-chunk names, which are the live Nemotron turns where those exist.
822
+ const identities = crowdIdentities(timeline.windows)
823
+ const crowded = isCrowded(identities, channelsUsed.size)
824
+ const mapping = crowded
825
+ ? { ...mapChannelsToNames(timeline.windows, activity), map: new Map<number, string>() }
826
+ : mapChannelsToNames(timeline.windows, activity)
827
+
828
+ let mode: 'batch' | 'streaming'
829
+ let next: string
830
+ let stats: RelabelStats
831
+ const nextSidecar: Record<string, any> = JSON.parse(sidecarText)
832
+ if (sidecar.batchApplied === true) {
833
+ mode = 'batch'
834
+ const segments = (Array.isArray(sidecar.batchSegments) ? sidecar.batchSegments : []) as SidecarBatchSegment[]
835
+ if (!segments.length) return { status: 'skipped', reason: 'no_speaker_words' }
836
+ const relabeled = relabelBatchSegments(segments, timeline, activity, mapping.map)
837
+ stats = relabeled.stats
838
+ // finalizeBatch wrote labelled turns only when every segment had speaker words.
839
+ const selected = selectBatchTranscriptForPersistence(
840
+ String(sidecar.batchTranscript ?? ''),
841
+ segments.map(segment => ({ text: segment.text, speakerWords: segment.speakerWords })),
842
+ )
843
+ if (selected.source !== 'speaker-words') return { status: 'skipped', reason: 'no_speaker_words' }
844
+ const sequence = relabeled.segments.flatMap(segment => segment.speakerWords
845
+ .map(word => ({ word: String(word.word ?? '').trim(), speaker: word.speaker || 'Ext' }))
846
+ .filter(word => word.word))
847
+ next = (options.relabelWords ?? relabelTranscriptWords)(transcript, sequence)
848
+ nextSidecar.batchSegments = relabeled.segments
849
+ // finalizeBatch records the exact canonical text here; keep that true.
850
+ if (typeof sidecar.batchTranscript === 'string') nextSidecar.batchTranscript = next
851
+ } else {
852
+ mode = 'streaming'
853
+ const relabeled = relabelStreamingChunks(timeline, activity, mapping.map)
854
+ stats = relabeled.stats
855
+ const textEntries = entries.filter(entry => typeof entry.chunk?.text === 'string' && entry.chunk.text.trim())
856
+ next = relabelStreamingTranscript(transcript, textEntries.map(entry => ({ chunkIndex: entry.chunkIndex, text: entry.chunk.text })), relabeled.finals)
857
+ const chunks = Array.isArray(nextSidecar.chunks) ? nextSidecar.chunks as SidecarChunk[] : []
858
+ const nextEntries = (Array.isArray(nextSidecar.chunkEntries) ? nextSidecar.chunkEntries : []) as SidecarEntry[]
859
+ const textNext = nextEntries.filter(entry => typeof entry.chunk?.text === 'string' && entry.chunk.text.trim())
860
+ const aligned = textNext.length === chunks.length && textNext.every((entry, i) => entry.chunk.text === chunks[i]?.text)
861
+ if (!aligned) return { status: 'skipped', reason: 'chunk_map_ambiguous' }
862
+ textNext.forEach((entry, i) => {
863
+ const final = relabeled.finals.get(entry.chunkIndex)
864
+ if (!final) return
865
+ for (const chunk of [entry.chunk, chunks[i]]) {
866
+ const original = typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker
867
+ chunk.finalSegments = final.segments
868
+ chunk.speaker = final.speaker
869
+ if (final.speaker !== original) chunk.originalSpeaker = original
870
+ else delete chunk.originalSpeaker
871
+ }
872
+ })
873
+ }
874
+
875
+ // Both relabelers keep every word by construction (the batch one matches the
876
+ // saved words, the streaming one splits a line only into its own chunk text).
877
+ // This check is the last line of defence should either ever change.
878
+ if (!sameWords(transcript, next)) return { status: 'skipped', reason: 'word_mismatch', mode, excluded }
879
+ // Both relabellers keep line breaks, so an unchanged transcript means no label
880
+ // changed: nothing is written and no revision moves.
881
+ if (next === transcript) return { status: 'skipped', reason: 'no_change', mode, words: stats, excluded, crowded, identities, channels: channelsUsed.size }
882
+
883
+ const speakers = new Set<string>(Array.isArray(sidecar.speakers) ? sidecar.speakers : [])
884
+ for (const line of next.split('\n')) {
885
+ const match = /^\[([^\]\n]+)\]:/.exec(line)
886
+ if (match && isName(match[1])) speakers.add(match[1])
887
+ }
888
+ const ms = (options.now ?? Date.now)() - started
889
+ const mapped = Object.fromEntries([...mapping.map.entries()].map(([channel, name]) => [String(channel), name]))
890
+ nextSidecar.speakers = [...speakers]
891
+ nextSidecar.lifecycleRevision = Number(sidecar.lifecycleRevision ?? 0) + 1
892
+ nextSidecar.diarization = {
893
+ engine: 'nemotron3',
894
+ variant: NEMOTRON_VARIANT,
895
+ bundle: NEMOTRON_BUNDLE,
896
+ computeUnits: 'ane',
897
+ status: 'applied',
898
+ mode,
899
+ appliedAt: new Date((options.now ?? Date.now)()).toISOString(),
900
+ ms,
901
+ audioSec: Math.round(timeline.durationSec * 10) / 10,
902
+ frames: run.frames,
903
+ channelsUsed: channelsUsed.size,
904
+ channelMap: mapped,
905
+ mapping: {
906
+ crowded,
907
+ identities,
908
+ eligibleChunks: mapping.eligible,
909
+ minSimilarity: FINAL_MIN_SIMILARITY,
910
+ oneSided: FINAL_ONE_SIDED,
911
+ minSupport: FINAL_MIN_SUPPORT,
912
+ purity: FINAL_PURITY,
913
+ evidence: mapping.evidence,
914
+ },
915
+ words: stats,
916
+ excludedChunks: timeline.excluded,
917
+ originalTranscript: transcript,
918
+ outputSha256: sha(next),
919
+ }
920
+ const nextMarkdown = replaceTranscriptSection(markdown, next)
921
+ if (nextMarkdown === null) return { status: 'skipped', reason: 'no_transcript' }
922
+
923
+ // The CLI ran for tens of seconds. A human relabel or deattribution, or a
924
+ // summary, may have landed meanwhile; writing from the copy read before the
925
+ // run would erase it. Re-read both files: any change and this pass is stale.
926
+ // Everything from here to the writes is synchronous, so no request handled by
927
+ // this process can land in between.
928
+ let sidecarNow: string
929
+ let markdownNow: string
930
+ try {
931
+ sidecarNow = readFileSync(options.sidecarPath, 'utf8')
932
+ markdownNow = readFileSync(options.meetingPath, 'utf8')
933
+ } catch (error) {
934
+ return { status: 'skipped', reason: 'changed_during_pass', detail: (error instanceof Error ? error.message : String(error)).slice(0, 300) }
935
+ }
936
+ if (sidecarNow !== sidecarText || markdownNow !== markdown) {
937
+ return { status: 'skipped', reason: 'changed_during_pass', detail: sidecarNow !== sidecarText ? 'sidecar changed' : 'markdown changed' }
938
+ }
939
+
940
+ // Sidecar first, markdown second, as the store does: the markdown is the
941
+ // visible commit. If the markdown write fails, put the sidecar back, and say
942
+ // so loudly if even that fails (the two files then disagree).
943
+ const write = options.write ?? ((path: string, data: string) => durableAtomicWriteFileSync(path, data, { mode: 0o600 }))
944
+ try {
945
+ write(options.sidecarPath, JSON.stringify(nextSidecar, null, 2))
946
+ } catch (error) {
947
+ return { status: 'failed', reason: 'markdown_write', detail: `sidecar write: ${(error instanceof Error ? error.message : String(error)).slice(0, 280)}`, excluded }
948
+ }
949
+ try {
950
+ write(options.meetingPath, nextMarkdown)
951
+ } catch (error) {
952
+ const why = (error instanceof Error ? error.message : String(error)).slice(0, 200)
953
+ try {
954
+ write(options.sidecarPath, sidecarText)
955
+ } catch (restoreError) {
956
+ return {
957
+ status: 'failed',
958
+ reason: 'sidecar_restore_failed',
959
+ detail: `markdown write: ${why}; sidecar restore: ${(restoreError instanceof Error ? restoreError.message : String(restoreError)).slice(0, 200)}; the sidecar holds the relabel and the markdown does not`,
960
+ excluded,
961
+ }
962
+ }
963
+ return { status: 'failed', reason: 'markdown_write', detail: `${why}; sidecar restored`, excluded }
964
+ }
965
+ return {
966
+ status: 'applied',
967
+ mode,
968
+ crowded,
969
+ identities,
970
+ ms,
971
+ audioSec: Math.round(timeline.durationSec),
972
+ channels: channelsUsed.size,
973
+ mapped,
974
+ words: stats,
975
+ excluded,
976
+ speakersBefore: distinctSpeakers(transcript),
977
+ speakersAfter: distinctSpeakers(next),
978
+ }
979
+ }