@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,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
|
+
}
|
|
@@ -42,6 +42,7 @@ const require = createRequire(import.meta.url)
|
|
|
42
42
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
43
43
|
|
|
44
44
|
import { DATA_DIR } from './data-dir.js'
|
|
45
|
+
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
45
46
|
|
|
46
47
|
export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
|
|
47
48
|
|
|
@@ -86,7 +87,7 @@ const PROFILES_PATH = resolve(DATA_DIR, 'voice-profiles.json')
|
|
|
86
87
|
const CALIBRATION_LOG = resolve(DATA_DIR, 'speaker-calibration.jsonl')
|
|
87
88
|
|
|
88
89
|
// Thresholds
|
|
89
|
-
const VERIFY_THRESHOLD =
|
|
90
|
+
const VERIFY_THRESHOLD = OWNER_VERIFY_SIMILARITY
|
|
90
91
|
const SEARCH_THRESHOLD = 0.55
|
|
91
92
|
/** The bar `autoEnroll` enrols at. From 6.45.4 also the bar at which a held
|
|
92
93
|
* group is offered as a person with one click ("high"): the identifier would
|
|
@@ -372,6 +373,9 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
372
373
|
export function identifySpeaker(
|
|
373
374
|
wavBuffer: Buffer,
|
|
374
375
|
expectedSpeakers?: string[],
|
|
376
|
+
/** 6.61.0: `event` tags the calibration rows, e.g. 'nemotron-track' for one
|
|
377
|
+
* Nemotron track's audio, so they are never read as whole-chunk checks. */
|
|
378
|
+
options: { event?: string } = {},
|
|
375
379
|
): { speaker: string; similarity: number; embedding?: Float32Array } | null {
|
|
376
380
|
if (!extractor || !manager) return null
|
|
377
381
|
|
|
@@ -385,7 +389,7 @@ export function identifySpeaker(
|
|
|
385
389
|
const isOwner = manager.verify({ name: owner, v: embedding, threshold: VERIFY_THRESHOLD })
|
|
386
390
|
if (isOwner) {
|
|
387
391
|
const similarity = computeCosineSimilarity(embedding, owner)
|
|
388
|
-
logCalibration(owner, similarity, true)
|
|
392
|
+
logCalibration(owner, similarity, true, options.event)
|
|
389
393
|
return { speaker: owner, similarity, embedding }
|
|
390
394
|
}
|
|
391
395
|
}
|
|
@@ -398,7 +402,7 @@ export function identifySpeaker(
|
|
|
398
402
|
const matches = manager.verify({ name, v: embedding, threshold: SEARCH_THRESHOLD })
|
|
399
403
|
if (matches) {
|
|
400
404
|
const similarity = computeCosineSimilarity(embedding, name)
|
|
401
|
-
logCalibration(name, similarity, true)
|
|
405
|
+
logCalibration(name, similarity, true, options.event)
|
|
402
406
|
return { speaker: name, similarity, embedding }
|
|
403
407
|
}
|
|
404
408
|
}
|
|
@@ -408,14 +412,14 @@ export function identifySpeaker(
|
|
|
408
412
|
const found = manager.search({ v: embedding, threshold: SEARCH_THRESHOLD })
|
|
409
413
|
if (found && found.length > 0) {
|
|
410
414
|
const similarity = computeCosineSimilarity(embedding, found)
|
|
411
|
-
logCalibration(found, similarity, true)
|
|
415
|
+
logCalibration(found, similarity, true, options.event)
|
|
412
416
|
return { speaker: found, similarity, embedding }
|
|
413
417
|
}
|
|
414
418
|
|
|
415
419
|
// No match — external speaker. The embedding still goes back: an
|
|
416
420
|
// unidentified voice's vector is the most valuable thing to retain, because
|
|
417
421
|
// naming it later is exactly the correction that has no other evidence.
|
|
418
|
-
logCalibration('Ext', 0, false)
|
|
422
|
+
logCalibration('Ext', 0, false, options.event)
|
|
419
423
|
return { speaker: 'Ext', similarity: 0, embedding }
|
|
420
424
|
} catch (err: unknown) {
|
|
421
425
|
console.error('[speaker] Identification error:', errMsg(err))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Voiceprint floors shared by every path that names a voice, so they can never
|
|
2
|
+
// drift apart.
|
|
3
|
+
|
|
4
|
+
/** The least audio the voiceprint names a voice from: the whole-chunk check in
|
|
5
|
+
* transcribe-stream and the per-track check in Nemotron naming. */
|
|
6
|
+
export const VOICEPRINT_MIN_AUDIO_SEC = 2.0
|
|
7
|
+
|
|
8
|
+
/** The similarity at which the voiceprint verifies the WEARER (speaker-embeddings'
|
|
9
|
+
* owner verify). A full search accepts any profile from 0.55, so a voice can come
|
|
10
|
+
* back as the wearer between 0.55 and 0.65 without ever passing owner verify;
|
|
11
|
+
* 6.61.1 refuses that for a split track (it named other people as the wearer). */
|
|
12
|
+
export const OWNER_VERIFY_SIMILARITY = 0.65
|
package/server/routes/health.ts
CHANGED
|
@@ -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 = {
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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 {
|