@gotcos/glasses-server 6.59.0 → 6.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }