@chatpanel/gateway 0.6.21 → 0.6.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.21",
3
+ "version": "0.6.22",
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.21';
46
+ export const VERSION = '0.6.22';
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
- const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true });
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' } });
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
- emit(s, { type: 'final', text });
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 });