@chatpanel/gateway 0.6.21 → 0.6.23
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/package.json +1 -1
- package/src/config.js +3 -0
- package/src/diarize-engine.js +140 -0
- package/src/ner-engine.js +2 -2
- package/src/server.js +20 -5
- package/src/stt-engine.js +30 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.js
CHANGED
|
@@ -104,6 +104,9 @@ const DEFAULTS = {
|
|
|
104
104
|
enabled: true,
|
|
105
105
|
model: 'onnx-community/whisper-base',
|
|
106
106
|
allowDownload: true,
|
|
107
|
+
// Speaker diarization ("who said what") is an OPT-IN per-session stage (its
|
|
108
|
+
// model loads only when a session asks). Set false to disable it gateway-wide.
|
|
109
|
+
diarize: true,
|
|
107
110
|
},
|
|
108
111
|
|
|
109
112
|
// Log one line per request (method, tokens redacted) without any raw values.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Speaker diarization ("who said what") — in-process, same engine + model dir as
|
|
2
|
+
// STT/NER. Two layers:
|
|
3
|
+
// engine — load Xenova/wavlm-base-plus-sv and embed a segment of audio into a
|
|
4
|
+
// 512-d x-vector (speaker fingerprint). Download-on-demand, fail-open.
|
|
5
|
+
// Diarizer — online clustering: keep a running centroid per speaker; a new
|
|
6
|
+
// segment joins the nearest centroid if cosine ≥ threshold, else it
|
|
7
|
+
// starts a new speaker. Stable "Speaker N" labels within a session.
|
|
8
|
+
//
|
|
9
|
+
// Honest limits: embeddings separate DIFFERENT speakers well but same-gender / very
|
|
10
|
+
// similar voices can merge (synthetic TTS especially). In meetings the mic channel
|
|
11
|
+
// is anchored as "You" and clustering only splits the remote channel — so a merge
|
|
12
|
+
// there is far less costly. Opt-in per session (diarize:true); off for dictation.
|
|
13
|
+
|
|
14
|
+
import { ensureLib, modelRoot } from './ner-engine.js';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
export const DIARIZE_MODEL = 'Xenova/wavlm-base-plus-sv';
|
|
19
|
+
|
|
20
|
+
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
21
|
+
let _model = null;
|
|
22
|
+
let _processor = null;
|
|
23
|
+
let _err = null;
|
|
24
|
+
let _initPromise = null;
|
|
25
|
+
let _progress = null;
|
|
26
|
+
|
|
27
|
+
// WASM can't load some quantized exports (see stt runtimeDtype); fp32 is the safe
|
|
28
|
+
// cross-runtime choice on the binary, q8 on native.
|
|
29
|
+
function runtimeDtype() {
|
|
30
|
+
return globalThis.__CHATPANEL_WASM_PATHS__ ? 'fp32' : 'q8';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function state() { return _state; }
|
|
34
|
+
export function isReady() { return _state === 'ready' && !!_model && !!_processor; }
|
|
35
|
+
export function progress() { return _progress; }
|
|
36
|
+
export function health() { return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, error: _err }; }
|
|
37
|
+
|
|
38
|
+
export function modelOnDisk(modelId = DIARIZE_MODEL, dtype = runtimeDtype()) {
|
|
39
|
+
const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
|
|
40
|
+
if (!existsSync(dir)) return false;
|
|
41
|
+
const suffix = dtype === 'fp32' ? '' : (dtype === 'q8' ? '_quantized' : `_${dtype}`);
|
|
42
|
+
try { return readdirSync(dir).some((f) => f === `model${suffix}.onnx`); }
|
|
43
|
+
catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function load({ log = () => {}, allowDownload = true } = {}) {
|
|
47
|
+
let lib;
|
|
48
|
+
try { lib = await ensureLib(); }
|
|
49
|
+
catch (e) { _state = 'error'; _err = e.message; log(`[diarize] engine unavailable (${e.message})`); return false; }
|
|
50
|
+
|
|
51
|
+
const haveLocal = modelOnDisk();
|
|
52
|
+
lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
|
|
53
|
+
if (!haveLocal && !allowDownload) { _state = 'error'; _err = 'model not on disk and downloads disabled'; return false; }
|
|
54
|
+
|
|
55
|
+
const prevHost = lib.env.remoteHost;
|
|
56
|
+
if (!haveLocal) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* wavlm isn't on the dl mirror */ } }
|
|
57
|
+
_state = haveLocal ? 'loading' : 'downloading';
|
|
58
|
+
if (!haveLocal) { _progress = { model: DIARIZE_MODEL, file: null, pct: 0 }; log(`[diarize] downloading ${DIARIZE_MODEL} (one-time)…`); }
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const { AutoModel, AutoProcessor } = lib;
|
|
62
|
+
const cb = (p) => {
|
|
63
|
+
if (!p) return;
|
|
64
|
+
const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
|
|
65
|
+
if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') _progress = { model: DIARIZE_MODEL, file: p.file || _progress?.file || null, pct };
|
|
66
|
+
};
|
|
67
|
+
const processor = await AutoProcessor.from_pretrained(DIARIZE_MODEL, { progress_callback: cb });
|
|
68
|
+
const model = await AutoModel.from_pretrained(DIARIZE_MODEL, { dtype: runtimeDtype(), progress_callback: cb });
|
|
69
|
+
_processor = processor; _model = model; _state = 'ready'; _err = null; _progress = null;
|
|
70
|
+
log(`[diarize] ready — ${DIARIZE_MODEL} @ ${runtimeDtype()}`);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
_err = e.message; _progress = null; _state = 'error';
|
|
74
|
+
log(`[diarize] load failed (${e.message}) — diarization off`);
|
|
75
|
+
return false;
|
|
76
|
+
} finally {
|
|
77
|
+
try { lib.env.remoteHost = prevHost; } catch { /* optional */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function init(cfg = {}) {
|
|
82
|
+
if (_initPromise) return _initPromise;
|
|
83
|
+
_state = 'loading';
|
|
84
|
+
_initPromise = load({ log: cfg.onLog, allowDownload: cfg.allowDownload !== false });
|
|
85
|
+
return _initPromise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Embed a mono 16 kHz Float32 segment → Float32Array x-vector, or null if not ready.
|
|
89
|
+
export async function embed(audio) {
|
|
90
|
+
if (!isReady() || !(audio instanceof Float32Array) || !audio.length) return null;
|
|
91
|
+
try {
|
|
92
|
+
const inputs = await _processor(audio);
|
|
93
|
+
const out = await _model(inputs);
|
|
94
|
+
const t = out.embeddings ?? out.logits ?? out.last_hidden_state;
|
|
95
|
+
return t?.data ? Float32Array.from(t.data) : null;
|
|
96
|
+
} catch { return null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function _reset() { _state = 'off'; _model = null; _processor = null; _err = null; _initPromise = null; _progress = null; }
|
|
100
|
+
export function _setForTest(model, processor) { _model = model; _processor = processor; _state = model ? 'ready' : 'off'; }
|
|
101
|
+
|
|
102
|
+
// ── Online speaker clustering ────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function cosine(a, b) {
|
|
105
|
+
let d = 0, na = 0, nb = 0;
|
|
106
|
+
for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
107
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
108
|
+
return denom ? d / denom : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A per-session speaker tracker. `threshold` = min cosine to be the SAME speaker;
|
|
112
|
+
// lower → fewer speakers (merges more), higher → more speakers (splits more).
|
|
113
|
+
export class Diarizer {
|
|
114
|
+
constructor({ threshold = 0.75, maxSpeakers = 8 } = {}) {
|
|
115
|
+
this.threshold = threshold;
|
|
116
|
+
this.maxSpeakers = maxSpeakers;
|
|
117
|
+
this.centroids = []; // { id, label, vec: Float32Array, n }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Assign an embedding to a speaker (nearest centroid ≥ threshold, else new).
|
|
121
|
+
// `pinnedLabel` forces a label (used for the mic channel = "You" in meetings).
|
|
122
|
+
assign(vec, { pinnedLabel = null } = {}) {
|
|
123
|
+
if (!vec) return null;
|
|
124
|
+
if (pinnedLabel) return this._merge(this._find(pinnedLabel) || this._create(pinnedLabel), vec);
|
|
125
|
+
let best = null, bestSim = -1;
|
|
126
|
+
for (const c of this.centroids) { const s = cosine(vec, c.vec); if (s > bestSim) { bestSim = s; best = c; } }
|
|
127
|
+
if (best && bestSim >= this.threshold) return this._merge(best, vec);
|
|
128
|
+
if (this.centroids.length >= this.maxSpeakers && best) return this._merge(best, vec); // cap: fold into nearest
|
|
129
|
+
return this._merge(this._create(`Speaker ${this.centroids.length + 1}`), vec);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_find(label) { return this.centroids.find((c) => c.label === label) || null; }
|
|
133
|
+
_create(label) { const c = { id: this.centroids.length + 1, label, vec: null, n: 0 }; this.centroids.push(c); return c; }
|
|
134
|
+
_merge(c, vec) {
|
|
135
|
+
if (!c.vec) { c.vec = Float32Array.from(vec); }
|
|
136
|
+
else { for (let i = 0; i < c.vec.length; i++) c.vec[i] = (c.vec[i] * c.n + vec[i]) / (c.n + 1); } // running mean centroid
|
|
137
|
+
c.n += 1;
|
|
138
|
+
return { id: c.id, label: c.label };
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/ner-engine.js
CHANGED
|
@@ -162,7 +162,7 @@ export async function ensureLib() {
|
|
|
162
162
|
try { Object.defineProperty(process, 'release', { value: { ...process.release, name: 'bun' }, configurable: true }); } catch { /* ignore */ }
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
const { env, pipeline, Tensor } = await import('@huggingface/transformers');
|
|
165
|
+
const { env, pipeline, Tensor, AutoModel, AutoProcessor } = await import('@huggingface/transformers');
|
|
166
166
|
env.cacheDir = root; // where remote downloads are cached
|
|
167
167
|
env.localModelPath = root; // where local loads resolve — same dir, we control it
|
|
168
168
|
try { env.remoteHost = MODEL_HOST; } catch { /* optional */ }
|
|
@@ -172,7 +172,7 @@ export async function ensureLib() {
|
|
|
172
172
|
// failure when ORT-web runs outside a browser.
|
|
173
173
|
try { env.backends.onnx.wasm.proxy = false; env.backends.onnx.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
|
|
174
174
|
}
|
|
175
|
-
_lib = { env, pipeline, Tensor };
|
|
175
|
+
_lib = { env, pipeline, Tensor, AutoModel, AutoProcessor };
|
|
176
176
|
return _lib;
|
|
177
177
|
}
|
|
178
178
|
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { createHistoryStore } from './sqlite-store.js';
|
|
|
33
33
|
import { ingestBackup } from './backup-ingest.js';
|
|
34
34
|
import * as nerEngine from './ner-engine.js';
|
|
35
35
|
import * as sttEngine from './stt-engine.js';
|
|
36
|
+
import * as diarizeEngine from './diarize-engine.js';
|
|
36
37
|
import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
|
|
37
38
|
import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL } from './stt-models.js';
|
|
38
39
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
@@ -42,7 +43,7 @@ import * as openai from './openai.js';
|
|
|
42
43
|
import * as responses from './responses.js';
|
|
43
44
|
import * as anthropic from './anthropic.js';
|
|
44
45
|
|
|
45
|
-
export const VERSION = '0.6.
|
|
46
|
+
export const VERSION = '0.6.23';
|
|
46
47
|
|
|
47
48
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
48
49
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -706,10 +707,18 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
706
707
|
if (!sttEngine.isReady()) {
|
|
707
708
|
sttEngine.init({ model: cfg.stt?.model || DEFAULT_STT_MODEL, allowDownload: cfg.stt?.allowDownload !== false, onLog: (m) => console.log(m) });
|
|
708
709
|
}
|
|
710
|
+
// Diarization is another OPTIONAL stage: load its model only when a session
|
|
711
|
+
// asks for it (never on the dictation path).
|
|
712
|
+
const wantDiarize = body?.diarize === true && cfg.stt?.diarize !== false;
|
|
713
|
+
if (wantDiarize && !diarizeEngine.isReady()) {
|
|
714
|
+
diarizeEngine.init({ allowDownload: cfg.stt?.allowDownload !== false, onLog: (m) => console.log(m) });
|
|
715
|
+
}
|
|
709
716
|
try {
|
|
710
717
|
// `redact: true` chains the OPTIONAL redaction hop onto finals (STT → NER,
|
|
711
718
|
// same composable model as everything else: any stage, with or without).
|
|
712
|
-
|
|
719
|
+
// `diarize: true` (+ optional `speakerLabel` to pin the mic channel to a
|
|
720
|
+
// name) attaches a speaker to each final.
|
|
721
|
+
const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true, diarize: wantDiarize, speakerLabel: body?.speakerLabel });
|
|
713
722
|
return sendJson(res, 201, { id, state: sttEngine.state() });
|
|
714
723
|
} catch (e) {
|
|
715
724
|
return sendJson(res, e.code === 'too_many_sessions' ? 429 : 500, { error: { message: e.message, type: e.code || 'stt_error' } });
|
|
@@ -751,9 +760,15 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
751
760
|
send({ type: 'state', state: sttEngine.state() });
|
|
752
761
|
const progressTimer = setInterval(() => {
|
|
753
762
|
const st = sttEngine.state();
|
|
754
|
-
if (st === 'downloading' || st === 'loading') send({ type: 'progress', state: st, ...(sttEngine.progress() || {}) });
|
|
755
|
-
|
|
756
|
-
|
|
763
|
+
if (st === 'downloading' || st === 'loading') { send({ type: 'progress', state: st, ...(sttEngine.progress() || {}) }); return; }
|
|
764
|
+
if (st === 'error') { send({ type: 'error', code: 'model_failed', message: sttEngine.health().error || 'model failed to load', fatal: true }); clearInterval(progressTimer); return; }
|
|
765
|
+
// STT ready — if diarization is on, surface ITS one-time download too, so
|
|
766
|
+
// "who said what" doesn't silently lag while the speaker model fetches.
|
|
767
|
+
if (sess.diarize) {
|
|
768
|
+
const ds = diarizeEngine.state();
|
|
769
|
+
if (ds === 'downloading' || ds === 'loading') { send({ type: 'diarize-progress', state: ds, ...(diarizeEngine.progress() || {}) }); return; }
|
|
770
|
+
}
|
|
771
|
+
send({ type: 'state', state: st }); clearInterval(progressTimer);
|
|
757
772
|
}, 500);
|
|
758
773
|
progressTimer.unref?.();
|
|
759
774
|
// Redaction is async — chain events so finals can't overtake interims.
|
package/src/stt-engine.js
CHANGED
|
@@ -21,6 +21,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
21
21
|
import { randomUUID } from 'node:crypto';
|
|
22
22
|
import { ensureLib, modelRoot } from './ner-engine.js';
|
|
23
23
|
import { DEFAULT_STT_MODEL, isEnglishOnly, sttModelDtype, isKnownSttModel } from './stt-models.js';
|
|
24
|
+
import * as diarize from './diarize-engine.js';
|
|
24
25
|
|
|
25
26
|
export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
|
|
26
27
|
|
|
@@ -171,8 +172,8 @@ let _decodeChain = Promise.resolve(); // whisper is effectively single-threaded
|
|
|
171
172
|
|
|
172
173
|
export function sessionCount() { return _sessions.size; }
|
|
173
174
|
|
|
174
|
-
/** @param {{ lang?: string, redact?: boolean }} [opts] */
|
|
175
|
-
export function createSession({ lang, redact = false } = {}) {
|
|
175
|
+
/** @param {{ lang?: string, redact?: boolean, diarize?: boolean, speakerLabel?: string }} [opts] */
|
|
176
|
+
export function createSession({ lang, redact = false, diarize: diarizeOpt = false, speakerLabel = null } = {}) {
|
|
176
177
|
if (_sessions.size >= MAX_SESSIONS) {
|
|
177
178
|
const e = /** @type {Error & { code?: string }} */ (new Error('too many concurrent dictation sessions'));
|
|
178
179
|
e.code = 'too_many_sessions'; throw e;
|
|
@@ -184,6 +185,12 @@ export function createSession({ lang, redact = false } = {}) {
|
|
|
184
185
|
// Opaque to this engine: the server applies the redaction hop to finals when
|
|
185
186
|
// set. Pipeline stages stay independent — STT never imports NER.
|
|
186
187
|
redact: !!redact,
|
|
188
|
+
// Diarization is opt-in. A per-session Diarizer clusters final segments into
|
|
189
|
+
// speakers; `speakerLabel` pins every final to one label (the mic channel =
|
|
190
|
+
// "You" in a meeting), so clustering only ever splits the other channels.
|
|
191
|
+
diarize: !!diarizeOpt,
|
|
192
|
+
speakerLabel: typeof speakerLabel === 'string' && speakerLabel ? speakerLabel.slice(0, 40) : null,
|
|
193
|
+
diarizer: diarizeOpt ? new diarize.Diarizer() : null,
|
|
187
194
|
chunks: [], // Float32Array pieces of the OPEN (unfinalized) segment
|
|
188
195
|
samples: 0,
|
|
189
196
|
listeners: new Set(), // (event) => void — the SSE writers
|
|
@@ -263,6 +270,17 @@ function rms(audio, from = 0, to = audio.length) {
|
|
|
263
270
|
return Math.sqrt(sum / n);
|
|
264
271
|
}
|
|
265
272
|
|
|
273
|
+
// Trim leading/trailing near-silence so a speaker embedding is computed on VOICE,
|
|
274
|
+
// not room tone — otherwise the silence dominates and distinct speakers' vectors
|
|
275
|
+
// converge (they'd all cluster as one). Window-scan at 20 ms granularity.
|
|
276
|
+
function voicedRegion(audio) {
|
|
277
|
+
const w = Math.round(0.02 * SAMPLE_RATE);
|
|
278
|
+
let start = 0, end = audio.length;
|
|
279
|
+
for (let i = 0; i + w <= audio.length; i += w) { if (rms(audio, i, i + w) >= SILENCE_RMS) { start = i; break; } }
|
|
280
|
+
for (let i = audio.length - w; i >= 0; i -= w) { if (rms(audio, i, i + w) >= SILENCE_RMS) { end = i + w; break; } }
|
|
281
|
+
return end > start ? audio.subarray(start, end) : audio;
|
|
282
|
+
}
|
|
283
|
+
|
|
266
284
|
// Whisper's native language-ID, which transformers.js doesn't implement (its
|
|
267
285
|
// pipeline just defaults to English): one decoder step from <|startoftranscript|>,
|
|
268
286
|
// argmax over the 99 language tokens. Run ONCE per session on the first voiced
|
|
@@ -328,7 +346,16 @@ async function decodeSession(s, { flush = false } = {}) {
|
|
|
328
346
|
if (flush || trailingQuiet || tooLong || overflow) {
|
|
329
347
|
// Commit: the open segment becomes a final; the buffer restarts empty.
|
|
330
348
|
s.chunks = []; s.samples = 0; s.lastInterim = '';
|
|
331
|
-
|
|
349
|
+
// Diarize this committed segment (opt-in): embed its audio, cluster → speaker.
|
|
350
|
+
// A pinned label (mic = "You") skips clustering. Best-effort; never blocks text.
|
|
351
|
+
let speaker = null;
|
|
352
|
+
if (s.diarizer) {
|
|
353
|
+
try {
|
|
354
|
+
const vec = s.speakerLabel ? null : await diarize.embed(voicedRegion(audio));
|
|
355
|
+
speaker = s.diarizer.assign(vec, { pinnedLabel: s.speakerLabel });
|
|
356
|
+
} catch { /* diarization is additive — a failure never drops the transcript */ }
|
|
357
|
+
}
|
|
358
|
+
emit(s, speaker ? { type: 'final', text, speaker } : { type: 'final', text });
|
|
332
359
|
} else if (text !== s.lastInterim) {
|
|
333
360
|
s.lastInterim = text;
|
|
334
361
|
emit(s, { type: 'interim', text });
|