@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,256 @@
|
|
|
1
|
+
// One run of `fluidaudiocli nemotron3-diarize`: a WAV in, raw 10 ms speaker
|
|
2
|
+
// probabilities out.
|
|
3
|
+
//
|
|
4
|
+
// The probabilities, not the RTTM, are the output that matters. The canary found
|
|
5
|
+
// that the RTTM drops turns shorter than 0.2 s, so every consumer scores the raw
|
|
6
|
+
// [frames x 8] float32 matrix at 0.5, exactly as the canary did.
|
|
7
|
+
//
|
|
8
|
+
// No model call, no network: `--models` is always passed (see diarizer-backend.ts),
|
|
9
|
+
// and nothing here is an LLM. Transcription must never call `claude -p`.
|
|
10
|
+
|
|
11
|
+
import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
|
|
12
|
+
import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
13
|
+
import { setPriority, tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { NEMOTRON_VARIANT } from './diarizer-backend.js'
|
|
16
|
+
|
|
17
|
+
export const NEMOTRON_CHANNELS = 8
|
|
18
|
+
export const NEMOTRON_FRAME_SEC = 0.01
|
|
19
|
+
/** Every temp directory this feature makes starts with this, so a sweep can find them. */
|
|
20
|
+
export const NEMOTRON_TMP_PREFIX = 'cos-nemotron-'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The only variables the CLI is given (QA 6.61.0, Ghost Hunter N8). The server's
|
|
24
|
+
* environment holds the API token, provider keys and the OpenAI key; a model
|
|
25
|
+
* runner needs none of them. HOME is where CoreML keeps its compiled-model cache
|
|
26
|
+
* and FluidAudio its models; TMPDIR is where CoreML compiles. Verified on
|
|
27
|
+
* 2026-10-02: the real CLI runs warm from the local models with only these.
|
|
28
|
+
*/
|
|
29
|
+
export const NEMOTRON_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', '__CF_USER_TEXT_ENCODING'] as const
|
|
30
|
+
|
|
31
|
+
export function nemotronChildEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
32
|
+
const out: NodeJS.ProcessEnv = {}
|
|
33
|
+
for (const key of NEMOTRON_ENV_KEYS) if (typeof env[key] === 'string') out[key] = env[key]
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Children and temp directories in flight ───────────────────────────────────
|
|
38
|
+
// A server exit in the middle of a run must not leave the CLI running or a temp
|
|
39
|
+
// directory behind: the final pass's directory holds the whole meeting's audio.
|
|
40
|
+
const liveChildren = new Set<ChildProcess>()
|
|
41
|
+
const liveDirs = new Set<string>()
|
|
42
|
+
|
|
43
|
+
export function trackNemotronDir(dir: string): void { installExitHook(); liveDirs.add(dir) }
|
|
44
|
+
export function untrackNemotronDir(dir: string): void { liveDirs.delete(dir) }
|
|
45
|
+
|
|
46
|
+
/** Kill every CLI child and delete every temp directory in flight. Synchronous, so it works in an exit handler. */
|
|
47
|
+
export function killNemotronChildren(): { killed: number; removed: number } {
|
|
48
|
+
let killed = 0
|
|
49
|
+
for (const child of liveChildren) {
|
|
50
|
+
try { if (child.exitCode == null && child.signalCode == null) { child.kill('SIGKILL'); killed++ } } catch { /* already gone */ }
|
|
51
|
+
}
|
|
52
|
+
liveChildren.clear()
|
|
53
|
+
let removed = 0
|
|
54
|
+
for (const dir of liveDirs) {
|
|
55
|
+
try { rmSync(dir, { recursive: true, force: true }); removed++ } catch { /* best effort */ }
|
|
56
|
+
}
|
|
57
|
+
liveDirs.clear()
|
|
58
|
+
return { killed, removed }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let exitHookInstalled = false
|
|
62
|
+
function installExitHook(): void {
|
|
63
|
+
if (exitHookInstalled) return
|
|
64
|
+
exitHookInstalled = true
|
|
65
|
+
// `exit` runs for process.exit (gracefulShutdown ends with it) and a normal end.
|
|
66
|
+
process.once('exit', () => { killNemotronChildren() })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Server start: remove what a previous process left behind. Temp directories
|
|
71
|
+
* under this prefix older than minAgeMs, and CLI processes that were ours (their
|
|
72
|
+
* arguments name one of these directories) and are now orphaned (parent 1).
|
|
73
|
+
*/
|
|
74
|
+
export function sweepStaleNemotron(options: { tmpRoot?: string; minAgeMs?: number; now?: number; killOrphans?: boolean } = {}): { dirs: number; processes: number } {
|
|
75
|
+
const root = options.tmpRoot ?? tmpdir()
|
|
76
|
+
const now = options.now ?? Date.now()
|
|
77
|
+
const minAge = options.minAgeMs ?? 60_000
|
|
78
|
+
let dirs = 0
|
|
79
|
+
try {
|
|
80
|
+
for (const name of readdirSync(root)) {
|
|
81
|
+
if (!name.startsWith(NEMOTRON_TMP_PREFIX)) continue
|
|
82
|
+
const path = join(root, name)
|
|
83
|
+
try {
|
|
84
|
+
const stat = statSync(path)
|
|
85
|
+
if (!stat.isDirectory() || now - stat.mtimeMs < minAge) continue
|
|
86
|
+
rmSync(path, { recursive: true, force: true })
|
|
87
|
+
dirs++
|
|
88
|
+
} catch { /* raced or unreadable */ }
|
|
89
|
+
}
|
|
90
|
+
} catch { /* no tmp root */ }
|
|
91
|
+
let processes = 0
|
|
92
|
+
if (options.killOrphans !== false) {
|
|
93
|
+
try {
|
|
94
|
+
const listing = execFileSync('/bin/ps', ['-Ao', 'pid=,ppid=,command='], { encoding: 'utf8', timeout: 2_000, maxBuffer: 4 * 1024 * 1024 })
|
|
95
|
+
for (const line of listing.split('\n')) {
|
|
96
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
97
|
+
if (!match || match[2] !== '1') continue
|
|
98
|
+
const command = match[3]
|
|
99
|
+
if (!command.includes('nemotron3-diarize') || !command.includes(`/${NEMOTRON_TMP_PREFIX}`)) continue
|
|
100
|
+
try { process.kill(Number(match[1]), 'SIGKILL'); processes++ } catch { /* gone */ }
|
|
101
|
+
}
|
|
102
|
+
} catch { /* ps unavailable: the directories are still swept */ }
|
|
103
|
+
}
|
|
104
|
+
return { dirs, processes }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface NemotronPreds {
|
|
108
|
+
/** Number of 10 ms frames. */
|
|
109
|
+
frames: number
|
|
110
|
+
/** Row-major [frames x 8] probabilities, as the CLI's --dump-preds writes them. */
|
|
111
|
+
probs: Float32Array
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface NemotronRun extends NemotronPreds {
|
|
115
|
+
/** "Model loaded in X s". Above a second or two means the Neural Engine compiled. */
|
|
116
|
+
loadSec: number | null
|
|
117
|
+
/** Audio seconds the CLI says it processed. */
|
|
118
|
+
audioSec: number | null
|
|
119
|
+
wallMs: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type NemotronRunFailure = 'timeout' | 'cli_failed' | 'bad_output' | 'spawn_failed'
|
|
123
|
+
|
|
124
|
+
export class NemotronRunError extends Error {
|
|
125
|
+
constructor(readonly reason: NemotronRunFailure, message: string) {
|
|
126
|
+
super(message)
|
|
127
|
+
this.name = 'NemotronRunError'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess
|
|
132
|
+
|
|
133
|
+
export interface RunNemotronOptions {
|
|
134
|
+
cli: string
|
|
135
|
+
modelsDir: string
|
|
136
|
+
wavPath: string
|
|
137
|
+
timeoutMs: number
|
|
138
|
+
/** `ane` keeps the GPU free for Whisper. Its probabilities matched `all` at the
|
|
139
|
+
* 0.5 threshold frame for frame on real chunks (2026-10-02 probe). */
|
|
140
|
+
computeUnits?: 'ane' | 'all'
|
|
141
|
+
/** Lower CPU priority than the server, so Whisper keeps priority. */
|
|
142
|
+
niceness?: number
|
|
143
|
+
spawnImpl?: SpawnLike
|
|
144
|
+
tmpRoot?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const STDOUT_CAP = 64 * 1024
|
|
148
|
+
const PROCESSED_LINE = /Processed ([0-9.]+)s in ([0-9.]+)s \(RTFx [0-9.]+x\), (\d+) x 10ms frames/
|
|
149
|
+
const LOADED_LINE = /Model loaded in ([0-9.]+)s/
|
|
150
|
+
|
|
151
|
+
export function nemotronArgs(wavPath: string, modelsDir: string, predsPath: string, computeUnits: 'ane' | 'all'): string[] {
|
|
152
|
+
return [
|
|
153
|
+
'nemotron3-diarize', wavPath,
|
|
154
|
+
'--models', modelsDir,
|
|
155
|
+
'--variant', NEMOTRON_VARIANT,
|
|
156
|
+
'--compute-units', computeUnits,
|
|
157
|
+
'--threshold', '0.5',
|
|
158
|
+
'--dump-preds', predsPath,
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse the CLI's stdout plus the preds file into a checked matrix. */
|
|
163
|
+
export function parseNemotronOutput(stdout: string, preds: Buffer): Omit<NemotronRun, 'wallMs'> {
|
|
164
|
+
const processed = PROCESSED_LINE.exec(stdout)
|
|
165
|
+
if (!processed) throw new NemotronRunError('bad_output', 'no "Processed ... frames" line')
|
|
166
|
+
const frames = Number(processed[3])
|
|
167
|
+
if (!Number.isInteger(frames) || frames < 0) throw new NemotronRunError('bad_output', 'frame count unreadable')
|
|
168
|
+
const expected = frames * NEMOTRON_CHANNELS * 4
|
|
169
|
+
if (preds.length !== expected) {
|
|
170
|
+
throw new NemotronRunError('bad_output', `preds ${preds.length} bytes, expected ${frames}x${NEMOTRON_CHANNELS}x4 = ${expected}`)
|
|
171
|
+
}
|
|
172
|
+
// Copy into an aligned buffer: a Buffer slice may sit at an odd byte offset.
|
|
173
|
+
const probs = new Float32Array(frames * NEMOTRON_CHANNELS)
|
|
174
|
+
for (let i = 0; i < probs.length; i++) probs[i] = preds.readFloatLE(i * 4)
|
|
175
|
+
const loaded = LOADED_LINE.exec(stdout)
|
|
176
|
+
return {
|
|
177
|
+
frames,
|
|
178
|
+
probs,
|
|
179
|
+
loadSec: loaded ? Number(loaded[1]) : null,
|
|
180
|
+
audioSec: Number(processed[1]),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function runNemotronCli(options: RunNemotronOptions): Promise<NemotronRun> {
|
|
185
|
+
const started = Date.now()
|
|
186
|
+
const spawnImpl: SpawnLike = options.spawnImpl ?? (nodeSpawn as unknown as SpawnLike)
|
|
187
|
+
let work: string
|
|
188
|
+
try {
|
|
189
|
+
work = mkdtempSync(join(options.tmpRoot ?? tmpdir(), NEMOTRON_TMP_PREFIX))
|
|
190
|
+
try { chmodSync(work, 0o700) } catch { /* mkdtemp is already 0700 */ }
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return Promise.reject(new NemotronRunError('spawn_failed', `temp dir: ${error instanceof Error ? error.message : String(error)}`))
|
|
193
|
+
}
|
|
194
|
+
installExitHook()
|
|
195
|
+
trackNemotronDir(work)
|
|
196
|
+
const predsPath = join(work, 'preds.f32')
|
|
197
|
+
const cleanup = () => {
|
|
198
|
+
untrackNemotronDir(work)
|
|
199
|
+
try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return new Promise<NemotronRun>((resolve, reject) => {
|
|
203
|
+
let settled = false
|
|
204
|
+
const finish = (error: NemotronRunError | null, value?: NemotronRun) => {
|
|
205
|
+
if (settled) return
|
|
206
|
+
settled = true
|
|
207
|
+
clearTimeout(timer)
|
|
208
|
+
cleanup()
|
|
209
|
+
if (error) reject(error)
|
|
210
|
+
else resolve(value!)
|
|
211
|
+
}
|
|
212
|
+
let child: ChildProcess
|
|
213
|
+
try {
|
|
214
|
+
child = spawnImpl(options.cli, nemotronArgs(options.wavPath, options.modelsDir, predsPath, options.computeUnits ?? 'ane'), {
|
|
215
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
216
|
+
env: nemotronChildEnv(),
|
|
217
|
+
})
|
|
218
|
+
} catch (error) {
|
|
219
|
+
cleanup()
|
|
220
|
+
reject(new NemotronRunError('spawn_failed', error instanceof Error ? error.message : String(error)))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
liveChildren.add(child)
|
|
224
|
+
child.once('exit', () => liveChildren.delete(child))
|
|
225
|
+
child.once('close', () => liveChildren.delete(child))
|
|
226
|
+
if (child.pid && options.niceness) {
|
|
227
|
+
try { setPriority(child.pid, options.niceness) } catch { /* the run still works at normal priority */ }
|
|
228
|
+
}
|
|
229
|
+
let stdout = ''
|
|
230
|
+
let stderr = ''
|
|
231
|
+
child.stdout?.on('data', (data: Buffer) => { if (stdout.length < STDOUT_CAP) stdout += data.toString() })
|
|
232
|
+
child.stderr?.on('data', (data: Buffer) => { if (stderr.length < STDOUT_CAP) stderr += data.toString() })
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
// SIGKILL, not SIGTERM: a CoreML compile does not stop for TERM, and a
|
|
235
|
+
// budget that waits on it is no budget.
|
|
236
|
+
try { child.kill('SIGKILL') } catch { /* already gone */ }
|
|
237
|
+
finish(new NemotronRunError('timeout', `no result within ${options.timeoutMs} ms`))
|
|
238
|
+
}, options.timeoutMs)
|
|
239
|
+
timer.unref?.()
|
|
240
|
+
child.on('error', error => { liveChildren.delete(child); finish(new NemotronRunError('spawn_failed', error.message)) })
|
|
241
|
+
child.on('close', (code, signal) => {
|
|
242
|
+
if (settled) return
|
|
243
|
+
if (code !== 0) {
|
|
244
|
+
const tail = `${stdout}\n${stderr}`.trim().split('\n').slice(-3).join(' | ').slice(0, 300)
|
|
245
|
+
finish(new NemotronRunError('cli_failed', `exit ${code ?? signal}: ${tail}`))
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const parsed = parseNemotronOutput(stdout, readFileSync(predsPath))
|
|
250
|
+
finish(null, { ...parsed, wallMs: Date.now() - started })
|
|
251
|
+
} catch (error) {
|
|
252
|
+
finish(error instanceof NemotronRunError ? error : new NemotronRunError('bad_output', error instanceof Error ? error.message : String(error)))
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
}
|