@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.
- package/.env.example +8 -0
- package/CHANGELOG.md +66 -0
- package/README.md +142 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/managed-runtime-contract.json +6 -1
- package/package.json +4 -1
- package/server/index.ts +34 -2
- package/server/lib/claude-hooks-installer.ts +120 -17
- package/server/lib/client-liveness.ts +92 -4
- 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 +979 -0
- package/server/lib/nemotron-live.ts +693 -0
- package/server/lib/nemotron-segments.ts +358 -0
- package/server/lib/permission-broker.ts +560 -31
- package/server/lib/session-hooks-runtime.ts +23 -0
- package/server/lib/speaker-embeddings.ts +7 -4
- package/server/lib/voiceprint-floor.ts +4 -0
- package/server/routes/health.ts +10 -0
- package/server/routes/meeting.ts +55 -2
- package/server/routes/permission-broker.ts +37 -3
- package/server/routes/transcribe-stream.ts +68 -5
|
@@ -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
|
+
}
|