@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,358 @@
1
+ // Pure geometry between Nemotron's 10 ms speaker probabilities and transcript
2
+ // words. No I/O, no clock, no model: everything here is a function of its input
3
+ // so the live path, the final pass and the replay share one implementation.
4
+ //
5
+ // Coordinates: an Activity is a [frames x 8] matrix whose frame 0 is time 0 of
6
+ // the audio it was computed on (a chunk for the live path, the meeting timeline
7
+ // for the final pass). Times are seconds in that same coordinate system.
8
+
9
+ import type { WhisperWord } from './whisper-local.js'
10
+ import { NEMOTRON_CHANNELS, NEMOTRON_FRAME_SEC } from './nemotron-cli.js'
11
+ import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
12
+
13
+ export const ACTIVE_THRESHOLD = 0.5
14
+ const FPS = Math.round(1 / NEMOTRON_FRAME_SEC)
15
+ const SAMPLES_PER_FRAME = Math.round(16_000 * NEMOTRON_FRAME_SEC)
16
+ /** A channel shorter than this inside the span is noise, not a speaker. */
17
+ export const MIN_TRACK_SEC = 0.5
18
+ /** The voiceprint needs about this much of one voice to name it (the same floor as a whole chunk). */
19
+ export const NAME_TRACK_MIN_SEC = VOICEPRINT_MIN_AUDIO_SEC
20
+
21
+ export interface Activity {
22
+ frames: number
23
+ probs: Float32Array
24
+ }
25
+
26
+ export interface TokenTime {
27
+ start: number
28
+ end: number
29
+ }
30
+
31
+ export interface Track {
32
+ channel: number
33
+ /** Seconds the channel is active. */
34
+ activeSec: number
35
+ /** Seconds the channel is the only active channel. */
36
+ exclusiveSec: number
37
+ }
38
+
39
+ export function isActive(a: Activity, frame: number, channel: number): boolean {
40
+ return a.probs[frame * NEMOTRON_CHANNELS + channel] >= ACTIVE_THRESHOLD
41
+ }
42
+
43
+ export function frameRange(a: Activity, startSec: number, endSec: number): [number, number] {
44
+ const f0 = Math.max(0, Math.min(a.frames, Math.round(startSec * FPS)))
45
+ const f1 = Math.max(f0, Math.min(a.frames, Math.round(endSec * FPS)))
46
+ return [f0, f1]
47
+ }
48
+
49
+ /** Channels holding at least minTrackSec inside [f0, f1), most active first. */
50
+ export function tracksIn(a: Activity, f0: number, f1: number, minTrackSec = MIN_TRACK_SEC): Track[] {
51
+ const active = new Array<number>(NEMOTRON_CHANNELS).fill(0)
52
+ const exclusive = new Array<number>(NEMOTRON_CHANNELS).fill(0)
53
+ for (let f = Math.max(0, f0); f < Math.min(a.frames, f1); f++) {
54
+ let n = 0
55
+ let last = -1
56
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) {
57
+ if (isActive(a, f, c)) { active[c]++; n++; last = c }
58
+ }
59
+ if (n === 1) exclusive[last]++
60
+ }
61
+ const tracks: Track[] = []
62
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) {
63
+ if (active[c] / FPS >= minTrackSec) tracks.push({ channel: c, activeSec: active[c] / FPS, exclusiveSec: exclusive[c] / FPS })
64
+ }
65
+ return tracks.sort((x, y) => y.activeSec - x.activeSec || x.channel - y.channel)
66
+ }
67
+
68
+ /** Frames in [f0, f1) where any channel is active. */
69
+ export function speechFrames(a: Activity, f0: number, f1: number): number[] {
70
+ const out: number[] = []
71
+ for (let f = Math.max(0, f0); f < Math.min(a.frames, f1); f++) {
72
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) {
73
+ if (isActive(a, f, c)) { out.push(f); break }
74
+ }
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * The channel that holds a span: the largest summed probability over frames
81
+ * where the channel is active. Widened by padSec when nothing is active inside
82
+ * the span (a word at the edge of a turn). Null when no channel is active nearby.
83
+ */
84
+ export function channelForSpan(
85
+ a: Activity,
86
+ startSec: number,
87
+ endSec: number,
88
+ allowed?: ReadonlySet<number>,
89
+ padSec = 0.3,
90
+ ): number | null {
91
+ const pick = (from: number, to: number): number | null => {
92
+ const sums = new Array<number>(NEMOTRON_CHANNELS).fill(0)
93
+ for (let f = Math.max(0, from); f < Math.min(a.frames, to); f++) {
94
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) {
95
+ if (allowed && !allowed.has(c)) continue
96
+ const p = a.probs[f * NEMOTRON_CHANNELS + c]
97
+ if (p >= ACTIVE_THRESHOLD) sums[c] += p
98
+ }
99
+ }
100
+ let best: number | null = null
101
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) {
102
+ if (sums[c] > 0 && (best === null || sums[c] > sums[best])) best = c
103
+ }
104
+ return best
105
+ }
106
+ let [f0, f1] = frameRange(a, startSec, endSec)
107
+ if (f1 <= f0) f1 = Math.min(a.frames, f0 + 1)
108
+ const inside = pick(f0, f1)
109
+ if (inside !== null) return inside
110
+ const pad = Math.round(padSec * FPS)
111
+ return pick(f0 - pad, f1 + pad)
112
+ }
113
+
114
+ const norm = (value: string): string => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
115
+
116
+ /** Text split so that joining with one space gives the text back exactly. */
117
+ export function splitTokens(text: string): string[] {
118
+ return text.split(' ')
119
+ }
120
+
121
+ /** True when the token carries a spoken word (not empty, not bare punctuation). */
122
+ export function isWordToken(token: string): boolean {
123
+ return norm(token).length > 0
124
+ }
125
+
126
+ /**
127
+ * Times for each text token from ASR word timings, matched character by
128
+ * character after normalising both sides. Whisper tokens can be sub-words
129
+ * ("I", "'m") and corrections can rewrite words, so an exact 1:1 zip is wrong.
130
+ * Returns null when fewer than 80% of word tokens find a match.
131
+ */
132
+ export function tokenTimesFromWords(tokens: string[], words: WhisperWord[], offsetSec = 0): TokenTime[] | null {
133
+ const chars: Array<{ ch: string; t0: number; t1: number }> = []
134
+ for (const word of words) {
135
+ const w = norm(word.word ?? '')
136
+ if (!w || !Number.isFinite(word.start) || !Number.isFinite(word.end)) continue
137
+ const span = Math.max(0, word.end - word.start)
138
+ for (let k = 0; k < w.length; k++) {
139
+ chars.push({ ch: w[k], t0: offsetSec + word.start + span * (k / w.length), t1: offsetSec + word.start + span * ((k + 1) / w.length) })
140
+ }
141
+ }
142
+ const out: Array<TokenTime | null> = []
143
+ let pointer = 0
144
+ let considered = 0
145
+ let matched = 0
146
+ const SKIP = 12
147
+ for (const token of tokens) {
148
+ const t = norm(token)
149
+ if (!t) { out.push(null); continue }
150
+ considered++
151
+ let found = -1
152
+ for (let s = pointer; s <= Math.min(pointer + SKIP, chars.length - t.length); s++) {
153
+ let ok = true
154
+ for (let k = 0; k < t.length; k++) if (chars[s + k].ch !== t[k]) { ok = false; break }
155
+ if (ok) { found = s; break }
156
+ }
157
+ if (found < 0) { out.push(null); continue }
158
+ out.push({ start: chars[found].t0, end: chars[found + t.length - 1].t1 })
159
+ pointer = found + t.length
160
+ matched++
161
+ }
162
+ if (considered === 0 || matched / considered < 0.8) return null
163
+ return fillTimes(out)
164
+ }
165
+
166
+ /** Interpolate missing token times from their neighbours. */
167
+ function fillTimes(times: Array<TokenTime | null>): TokenTime[] {
168
+ const out = times.slice()
169
+ for (let i = 0; i < out.length; i++) {
170
+ if (out[i]) continue
171
+ let prev = i - 1
172
+ while (prev >= 0 && !out[prev]) prev--
173
+ let next = i + 1
174
+ while (next < out.length && !times[next]) next++
175
+ const at = prev >= 0 ? out[prev]!.end : next < out.length ? times[next]!.start : 0
176
+ const to = next < out.length ? times[next]!.start : at
177
+ out[i] = { start: at, end: Math.max(at, to) }
178
+ }
179
+ return out as TokenTime[]
180
+ }
181
+
182
+ /**
183
+ * Times for each token when the ASR gave none: the text is laid over the frames
184
+ * where Nemotron hears speech, in proportion to each token's length. Whisper's
185
+ * live server path returns compact JSON without word clocks (its verbose JSON
186
+ * can crash the native server), so this is the live path's normal case.
187
+ */
188
+ export function estimateTokenTimes(tokens: string[], a: Activity, startSec: number, endSec: number): TokenTime[] | null {
189
+ const [f0, f1] = frameRange(a, startSec, endSec)
190
+ const speech = speechFrames(a, f0, f1)
191
+ if (speech.length === 0) return null
192
+ const weights = tokens.map(token => norm(token).length)
193
+ const total = weights.reduce((sum, w) => sum + w, 0)
194
+ const n = speech.length
195
+ if (total === 0) return tokens.map(() => ({ start: speech[0] / FPS, end: (speech[n - 1] + 1) / FPS }))
196
+ const out: TokenTime[] = []
197
+ let cum = 0
198
+ for (const w of weights) {
199
+ const s = (cum / total) * n
200
+ const e = ((cum + w) / total) * n
201
+ cum += w
202
+ const i0 = Math.min(n - 1, Math.floor(s))
203
+ const i1 = Math.min(n - 1, Math.max(i0, Math.ceil(e) - 1))
204
+ out.push({ start: speech[i0] / FPS, end: (speech[i1] + 1) / FPS })
205
+ }
206
+ return out
207
+ }
208
+
209
+ /** Fill unlabelled tokens from the token before (or after, at the start). */
210
+ export function fillLabels<T>(labels: Array<T | null>, fallback: T): T[] {
211
+ const firstKnown = labels.find(label => label !== null)
212
+ let carry: T = firstKnown ?? fallback
213
+ return labels.map(label => {
214
+ if (label !== null) carry = label
215
+ return carry
216
+ })
217
+ }
218
+
219
+ const PUNCT_END = /[.?!,;:]["')\]]?$/
220
+
221
+ /**
222
+ * Estimated timings put a turn change mid-phrase. Move each boundary to the
223
+ * nearest token that ends a phrase, at most maxShift tokens away, never across
224
+ * another boundary.
225
+ */
226
+ export function snapToPunctuation<T>(tokens: string[], labels: T[], maxShift = 2): T[] {
227
+ const out = labels.slice()
228
+ const boundaries: number[] = []
229
+ for (let i = 1; i < out.length; i++) if (out[i] !== out[i - 1]) boundaries.push(i)
230
+ for (let bi = 0; bi < boundaries.length; bi++) {
231
+ const b = boundaries[bi]
232
+ if (PUNCT_END.test(tokens[b - 1] ?? '')) continue
233
+ const lo = bi > 0 ? boundaries[bi - 1] + 1 : 1
234
+ const hi = bi + 1 < boundaries.length ? boundaries[bi + 1] - 1 : out.length - 1
235
+ let target = -1
236
+ for (let d = 1; d <= maxShift && target < 0; d++) {
237
+ for (const cand of [b - d, b + d]) {
238
+ if (cand < lo || cand > hi) continue
239
+ if (PUNCT_END.test(tokens[cand - 1] ?? '')) { target = cand; break }
240
+ }
241
+ }
242
+ if (target < 0) continue
243
+ const before = out[b - 1]
244
+ const after = out[b]
245
+ if (target < b) for (let i = target; i < b; i++) out[i] = after
246
+ else for (let i = b; i < target; i++) out[i] = before
247
+ boundaries[bi] = target
248
+ }
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * A run of one word, shorter than minSec and flanked by the same label on both
254
+ * sides, is a flicker, not a turn. Absorb it.
255
+ */
256
+ export function absorbFlickers<T>(tokens: string[], labels: T[], times: TokenTime[], minSec = 0.4): T[] {
257
+ const out = labels.slice()
258
+ let i = 0
259
+ while (i < out.length) {
260
+ let j = i
261
+ while (j + 1 < out.length && out[j + 1] === out[i]) j++
262
+ const words = tokens.slice(i, j + 1).filter(isWordToken).length
263
+ const span = (times[j]?.end ?? 0) - (times[i]?.start ?? 0)
264
+ if (i > 0 && j + 1 < out.length && out[i - 1] === out[j + 1] && words <= 1 && span < minSec) {
265
+ for (let k = i; k <= j; k++) out[k] = out[i - 1]
266
+ }
267
+ i = j + 1
268
+ }
269
+ return out
270
+ }
271
+
272
+ export interface LabelRun<T> {
273
+ label: T
274
+ from: number
275
+ /** Exclusive. */
276
+ to: number
277
+ }
278
+
279
+ export function runsOf<T>(labels: T[]): Array<LabelRun<T>> {
280
+ const runs: Array<LabelRun<T>> = []
281
+ for (let i = 0; i < labels.length; i++) {
282
+ const last = runs.at(-1)
283
+ if (last && last.label === labels[i]) last.to = i + 1
284
+ else runs.push({ label: labels[i], from: i, to: i + 1 })
285
+ }
286
+ return runs
287
+ }
288
+
289
+ export interface Pcm16 {
290
+ pcm: Buffer
291
+ sampleRate: number
292
+ }
293
+
294
+ /** 16-bit mono PCM from a WAV, finding the data chunk like speaker-embeddings does. */
295
+ export function readPcm16Mono(wav: Buffer): Pcm16 | null {
296
+ if (wav.length < 44 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') return null
297
+ let offset = 12
298
+ let channels = 0
299
+ let bits = 0
300
+ let sampleRate = 0
301
+ while (offset + 8 <= wav.length) {
302
+ const id = wav.toString('ascii', offset, offset + 4)
303
+ const size = wav.readUInt32LE(offset + 4)
304
+ if (id === 'fmt ' && offset + 24 <= wav.length) {
305
+ channels = wav.readUInt16LE(offset + 10)
306
+ sampleRate = wav.readUInt32LE(offset + 12)
307
+ bits = wav.readUInt16LE(offset + 22)
308
+ }
309
+ if (id === 'data') {
310
+ if (channels !== 1 || bits !== 16) return null
311
+ const start = offset + 8
312
+ const end = Math.min(wav.length, start + size)
313
+ return { pcm: wav.subarray(start, end - ((end - start) % 2)), sampleRate }
314
+ }
315
+ offset += 8 + size + (size % 2)
316
+ }
317
+ return null
318
+ }
319
+
320
+ export function pcm16ToWav(pcm: Buffer, sampleRate = 16_000): Buffer {
321
+ return Buffer.concat([wavHeader(pcm.length, sampleRate), pcm])
322
+ }
323
+
324
+ /** The 44-byte header of a 16-bit mono PCM WAV holding pcmBytes of audio. */
325
+ export function wavHeader(pcmBytes: number, sampleRate = 16_000): Buffer {
326
+ const header = Buffer.alloc(44)
327
+ header.write('RIFF', 0)
328
+ header.writeUInt32LE(36 + pcmBytes, 4)
329
+ header.write('WAVE', 8)
330
+ header.write('fmt ', 12)
331
+ header.writeUInt32LE(16, 16)
332
+ header.writeUInt16LE(1, 20)
333
+ header.writeUInt16LE(1, 22)
334
+ header.writeUInt32LE(sampleRate, 24)
335
+ header.writeUInt32LE(sampleRate * 2, 28)
336
+ header.writeUInt16LE(2, 32)
337
+ header.writeUInt16LE(16, 34)
338
+ header.write('data', 36)
339
+ header.writeUInt32LE(pcmBytes, 40)
340
+ return header
341
+ }
342
+
343
+ /** The audio where only this channel speaks, as a WAV, for the voiceprint. */
344
+ export function exclusiveTrackWav(a: Activity, channel: number, audio: Pcm16): Buffer | null {
345
+ if (audio.sampleRate !== 16_000) return null
346
+ const parts: Buffer[] = []
347
+ for (let f = 0; f < a.frames; f++) {
348
+ if (!isActive(a, f, channel)) continue
349
+ let others = false
350
+ for (let c = 0; c < NEMOTRON_CHANNELS; c++) if (c !== channel && isActive(a, f, c)) { others = true; break }
351
+ if (others) continue
352
+ const from = f * SAMPLES_PER_FRAME * 2
353
+ const to = Math.min(audio.pcm.length, from + SAMPLES_PER_FRAME * 2)
354
+ if (from >= to) break
355
+ parts.push(audio.pcm.subarray(from, to))
356
+ }
357
+ return parts.length ? pcm16ToWav(Buffer.concat(parts)) : null
358
+ }
@@ -372,6 +372,9 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
372
372
  export function identifySpeaker(
373
373
  wavBuffer: Buffer,
374
374
  expectedSpeakers?: string[],
375
+ /** 6.61.0: `event` tags the calibration rows, e.g. 'nemotron-track' for one
376
+ * Nemotron track's audio, so they are never read as whole-chunk checks. */
377
+ options: { event?: string } = {},
375
378
  ): { speaker: string; similarity: number; embedding?: Float32Array } | null {
376
379
  if (!extractor || !manager) return null
377
380
 
@@ -385,7 +388,7 @@ export function identifySpeaker(
385
388
  const isOwner = manager.verify({ name: owner, v: embedding, threshold: VERIFY_THRESHOLD })
386
389
  if (isOwner) {
387
390
  const similarity = computeCosineSimilarity(embedding, owner)
388
- logCalibration(owner, similarity, true)
391
+ logCalibration(owner, similarity, true, options.event)
389
392
  return { speaker: owner, similarity, embedding }
390
393
  }
391
394
  }
@@ -398,7 +401,7 @@ export function identifySpeaker(
398
401
  const matches = manager.verify({ name, v: embedding, threshold: SEARCH_THRESHOLD })
399
402
  if (matches) {
400
403
  const similarity = computeCosineSimilarity(embedding, name)
401
- logCalibration(name, similarity, true)
404
+ logCalibration(name, similarity, true, options.event)
402
405
  return { speaker: name, similarity, embedding }
403
406
  }
404
407
  }
@@ -408,14 +411,14 @@ export function identifySpeaker(
408
411
  const found = manager.search({ v: embedding, threshold: SEARCH_THRESHOLD })
409
412
  if (found && found.length > 0) {
410
413
  const similarity = computeCosineSimilarity(embedding, found)
411
- logCalibration(found, similarity, true)
414
+ logCalibration(found, similarity, true, options.event)
412
415
  return { speaker: found, similarity, embedding }
413
416
  }
414
417
 
415
418
  // No match — external speaker. The embedding still goes back: an
416
419
  // unidentified voice's vector is the most valuable thing to retain, because
417
420
  // naming it later is exactly the correction that has no other evidence.
418
- logCalibration('Ext', 0, false)
421
+ logCalibration('Ext', 0, false, options.event)
419
422
  return { speaker: 'Ext', similarity: 0, embedding }
420
423
  } catch (err: unknown) {
421
424
  console.error('[speaker] Identification error:', errMsg(err))
@@ -0,0 +1,4 @@
1
+ // The least audio the voiceprint names a voice from. One source for the
2
+ // whole-chunk check in transcribe-stream and the per-track check in Nemotron
3
+ // naming, so the two can never drift apart.
4
+ export const VOICEPRINT_MIN_AUDIO_SEC = 2.0
@@ -10,6 +10,8 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
10
10
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
11
11
  import { isSileroAvailable } from '../lib/vad-silero.js'
12
12
  import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
13
+ import { nemotronLive } from '../lib/nemotron-live.js'
14
+ import { finalPassSnapshot } from '../lib/nemotron-final.js'
13
15
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
14
16
  import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
15
17
  import { correctionStoreStats } from '../lib/meeting-corrections.js'
@@ -336,6 +338,14 @@ healthRouter.get('/health', async (_req, res) => {
336
338
  // Asserted, not merely reported: `speaker_id` above says what the state IS,
337
339
  // this says whether that state is acceptable.
338
340
  speakerId: speakerReadinessState,
341
+ // 6.61.0: what is labelling chunks RIGHT NOW. `active` reads `nemotron` only
342
+ // while the model is warm, the breaker is closed and most of the last five
343
+ // chunks really were labelled by Nemotron; otherwise `warming` or `embedding`
344
+ // with the reason. Plus the per-chunk outcome counts since start and the last
345
+ // save-time final pass, as codes and counts only: this route is public and
346
+ // must never carry a speaker name, a path or raw error text. A Nemotron
347
+ // fallback is the designed voiceprint path, so it never degrades `status`.
348
+ diarizer: { ...nemotronLive.snapshot(), final: finalPassSnapshot() },
339
349
  }
340
350
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
341
351
  const meeting_summary = {
@@ -160,6 +160,10 @@ import {
160
160
  type MeetingFinalizationJob,
161
161
  } from '../lib/meeting-finalization-jobs.js'
162
162
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
163
+ import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
164
+
165
+ /** 6.61.0: the save-time Nemotron pass. Injectable so tests never spawn the CLI. */
166
+ export type FinalDiarize = (options: Pick<FinalPassOptions, 'meetingPath' | 'sidecarPath' | 'audioDir'>) => Promise<FinalPassOutcome>
163
167
 
164
168
  function cosOpsPipelineConfigured(): boolean {
165
169
  // Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
@@ -200,6 +204,7 @@ export interface MeetingRouteDependencies {
200
204
  scheduleBackground?: (task: Promise<void>) => void
201
205
  emit?: typeof emitDisplay
202
206
  finalizationJobs?: MeetingFinalizationJobStore
207
+ finalDiarize?: FinalDiarize
203
208
  }
204
209
 
205
210
  const activeFinalizationJobs = new Set<string>()
@@ -210,6 +215,7 @@ interface FinalizationRuntime {
210
215
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
211
216
  scheduleBackground: (task: Promise<void>) => void
212
217
  sessions?: MeetingSessionSource
218
+ finalDiarize?: FinalDiarize
213
219
  }
214
220
 
215
221
  function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
@@ -311,6 +317,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
311
317
  sidecarPath: current.sidecarPath,
312
318
  sessionId: current.sessionId,
313
319
  runBatch: runtime.runBatch,
320
+ finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
314
321
  })
315
322
  } else {
316
323
  console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
@@ -469,6 +476,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
469
476
  const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
470
477
  const emit = deps.emit ?? emitDisplay
471
478
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
479
+ const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
472
480
  const router = Router()
473
481
  const savingSessions = new Set<string>()
474
482
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
@@ -553,6 +561,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
553
561
  runBatch,
554
562
  scheduleBackground,
555
563
  sessions,
564
+ finalDiarize,
556
565
  })
557
566
  res.set('Cache-Control', 'private, no-store')
558
567
  res.json(publicSaveResponse(alreadySaved, true))
@@ -717,6 +726,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
717
726
  runBatch,
718
727
  scheduleBackground,
719
728
  sessions,
729
+ finalDiarize,
720
730
  })
721
731
  }
722
732
  } catch (error) {
@@ -797,6 +807,25 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
797
807
  }
798
808
  }
799
809
 
810
+ /**
811
+ * 6.61.0 (QA W2): refuse a correction while the meeting is finalizing.
812
+ *
813
+ * The Nemotron final pass rewrites the sidecar and the transcript inside the
814
+ * finalization job, after a CLI run of tens of seconds. A relabel or a
815
+ * deattribution landing in that window would race it (the pass also re-reads
816
+ * both files and stands down, but the edit and the pass should not interleave
817
+ * at all). Same rule held naming uses: while a finalization job exists for the
818
+ * session the answer is 409, and a retry after it finishes works.
819
+ */
820
+ function finalizingRefusal(sessionId: string): { status: number; body: Record<string, unknown> } | null {
821
+ const running = [...activeFinalizationJobs].some(key => key.endsWith(`:${sessionId}`))
822
+ if (!running && !finalizationJobs.get(sessionId)) return null
823
+ return {
824
+ status: 409,
825
+ body: { error: 'Meeting is finalizing; retry after it finishes', reason: 'meeting_finalizing', retryable: true },
826
+ }
827
+ }
828
+
800
829
  // ── Speaker review (6.21.12) ──────────────────────────────────────────
801
830
  // Backs COS Control's naming panel. Read-only: it reports what a saved
802
831
  // meeting's sidecar already contains and never writes. Naming, merging, and
@@ -1191,7 +1220,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1191
1220
  // A merged G2 session is NOT refused here: it is a real capture with real
1192
1221
  // audio, and relabelling it is how its profile gets better. Only an imported
1193
1222
  // or derived IDENTITY is refused.
1194
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1223
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1195
1224
  if (evidenceRefusal) {
1196
1225
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1197
1226
  return
@@ -1525,7 +1554,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1525
1554
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1526
1555
  return
1527
1556
  }
1528
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1557
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1529
1558
  if (evidenceRefusal) {
1530
1559
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1531
1560
  return
@@ -2194,6 +2223,7 @@ async function finalizeBatch(options: {
2194
2223
  sidecarPath: string
2195
2224
  sessionId?: string
2196
2225
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
2226
+ finalDiarize?: FinalDiarize
2197
2227
  }): Promise<void> {
2198
2228
  const result = await options.runBatch(
2199
2229
  options.audioDir,
@@ -2236,6 +2266,29 @@ async function finalizeBatch(options: {
2236
2266
  )
2237
2267
  }
2238
2268
 
2269
+ // 6.61.0: the Nemotron final pass relabels the transcript just persisted,
2270
+ // while the raw chunk audio still exists and BEFORE this job reaches
2271
+ // ops_pending, so COS sync (sync_meetings.py --g2-import-file, run by the
2272
+ // handoff after this function returns) only ever imports the relabelled
2273
+ // meeting. It never throws and writes nothing unless every word survives;
2274
+ // on any failure the meeting stays exactly as saved above.
2275
+ // Skipped when the batch text landed but its sidecar did not: the two would
2276
+ // disagree about which transcript is canonical. Counted and logged like any
2277
+ // other skip, so health shows it.
2278
+ if (options.finalDiarize && transcriptApplied && !metadataPersisted) {
2279
+ recordFinalPassSkip('metadata_not_persisted', 'the batch transcript was applied but its sidecar was not written')
2280
+ } else if (options.finalDiarize) {
2281
+ try {
2282
+ await options.finalDiarize({
2283
+ meetingPath: options.meetingPath,
2284
+ sidecarPath: options.sidecarPath,
2285
+ audioDir: options.audioDir,
2286
+ })
2287
+ } catch (error) {
2288
+ console.warn(`[diarizer] final pass threw; the saved transcript is unchanged: ${error instanceof Error ? error.message : String(error)}`)
2289
+ }
2290
+ }
2291
+
2239
2292
  if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
2240
2293
  rmSync(options.audioDir, { recursive: true, force: true })
2241
2294
  } else {