@chatpanel/gateway 0.6.17 → 0.6.19

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.17",
3
+ "version": "0.6.19",
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
@@ -96,6 +96,16 @@ const DEFAULTS = {
96
96
  enableFullTier: true,
97
97
  },
98
98
 
99
+ // Local speech-to-text (dictation) — whisper via the same in-process ONNX engine
100
+ // and model dir as NER (stt-engine.js). No autostart on purpose: the model
101
+ // downloads on FIRST dictation, never on gateway boot (first-run load time).
102
+ // Multilingual default so the spoken language is auto-detected per segment.
103
+ stt: {
104
+ enabled: true,
105
+ model: 'onnx-community/whisper-base',
106
+ allowDownload: true,
107
+ },
108
+
99
109
  // Log one line per request (method, tokens redacted) without any raw values.
100
110
  logRequests: true,
101
111
 
@@ -22,7 +22,7 @@ export function persistConfig(cfg, path = configPath()) {
22
22
  // restart drops them and every model falls back to the default OpenAI upstream.
23
23
  destinations: cfg.destinations,
24
24
  bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
25
- ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
25
+ ner: cfg.ner, stt: cfg.stt, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
26
26
  pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
27
27
  };
28
28
  writeFileSync(path, JSON.stringify(out, null, 2));
@@ -45,6 +45,7 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
45
45
  dictionary: Array.isArray(cfg.redaction?.dictionary) ? cfg.redaction.dictionary : [],
46
46
  },
47
47
  ner: cfg.ner,
48
+ stt: cfg.stt,
48
49
  allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
49
50
  // free = lifetime trial usage ({ used, cap, remaining }) — read-only; the cap
50
51
  // is fixed and the count is server-authoritative (never settable from the UI).
@@ -109,6 +110,13 @@ export function applyConfigPatch(cfg, patch = {}) {
109
110
  // its `used` count is server-authoritative — neither is editable here, so a
110
111
  // client can't raise the cap or reset its own trial.
111
112
  }
113
+ // Local dictation toggle. The MODEL is switched via POST /stt/models (like the
114
+ // NER manager), not patched here.
115
+ if (patch.stt && typeof patch.stt === 'object') {
116
+ cfg.stt = cfg.stt || { enabled: true, model: 'onnx-community/whisper-base', allowDownload: true };
117
+ if ('enabled' in patch.stt) cfg.stt.enabled = !!patch.stt.enabled;
118
+ if ('allowDownload' in patch.stt) cfg.stt.allowDownload = !!patch.stt.allowDownload;
119
+ }
112
120
  if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
113
121
  if (['off', 'types', 'values'].includes(patch.logDetail)) cfg.logDetail = patch.logDetail;
114
122
  if (patch.tools && typeof patch.tools === 'object') {
package/src/mcp.js CHANGED
@@ -28,7 +28,7 @@ function baseUrl() {
28
28
  const TOOLS = [
29
29
  {
30
30
  name: 'search_history',
31
- description: 'Full-text search the user\'s ChatPanel history (past chats and meeting transcripts) by keyword relevance. Use this to recall what was discussed when the current context does not already contain it.',
31
+ description: 'Full-text search the user\'s ChatPanel history past chats, meeting transcripts, and notes — by keyword relevance. Use this to recall what was discussed or written when the current context does not already contain it.',
32
32
  inputSchema: {
33
33
  type: 'object',
34
34
  properties: {
@@ -40,10 +40,10 @@ const TOOLS = [
40
40
  },
41
41
  {
42
42
  name: 'get_record',
43
- description: 'Fetch one full history record (its complete text) by id, e.g. chat:<id> or meeting:<id> returned by search_history.',
43
+ description: 'Fetch one full history record (its complete text) by id, e.g. chat:<id>, meeting:<id>, or note:<id> returned by search_history.',
44
44
  inputSchema: {
45
45
  type: 'object',
46
- properties: { id: { type: 'string', description: 'Record id such as chat:abc or meeting:imp_123.' } },
46
+ properties: { id: { type: 'string', description: 'Record id such as chat:abc, meeting:imp_123, or note:xyz.' } },
47
47
  required: ['id'],
48
48
  },
49
49
  },
package/src/ner-engine.js CHANGED
@@ -143,7 +143,9 @@ export async function fetchAdapter(_url, opts = {}) {
143
143
  export function progress() { return _progress; }
144
144
 
145
145
  // Import transformers ONCE and configure its env (model dir + WASM in a binary).
146
- async function ensureLib() {
146
+ // Exported so sibling engines (stt-engine.js) share the same configured lib —
147
+ // one env, one cache dir, one WASM setup; never a second copy of this logic.
148
+ export async function ensureLib() {
147
149
  if (_lib) return _lib;
148
150
  const root = modelRoot();
149
151
  try { mkdirSync(root, { recursive: true }); } catch { /* best effort */ }
@@ -159,7 +161,7 @@ async function ensureLib() {
159
161
  try { Object.defineProperty(process, 'release', { value: { ...process.release, name: 'bun' }, configurable: true }); } catch { /* ignore */ }
160
162
  }
161
163
 
162
- const { env, pipeline } = await import('@huggingface/transformers');
164
+ const { env, pipeline, Tensor } = await import('@huggingface/transformers');
163
165
  env.cacheDir = root; // where remote downloads are cached
164
166
  env.localModelPath = root; // where local loads resolve — same dir, we control it
165
167
  try { env.remoteHost = MODEL_HOST; } catch { /* optional */ }
@@ -169,7 +171,7 @@ async function ensureLib() {
169
171
  // failure when ORT-web runs outside a browser.
170
172
  try { env.backends.onnx.wasm.proxy = false; env.backends.onnx.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
171
173
  }
172
- _lib = { env, pipeline };
174
+ _lib = { env, pipeline, Tensor };
173
175
  return _lib;
174
176
  }
175
177
 
package/src/server.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { createServer } from 'node:http';
21
21
  import { loadConfig } from './config.js';
22
22
  import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
23
- import { redactSegments } from './redact.js';
23
+ import { redactSegments, segment } from './redact.js';
24
24
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
25
25
  import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote } from '@chatpanel/pii';
26
26
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
@@ -32,7 +32,9 @@ import { saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-s
32
32
  import { createHistoryStore } from './sqlite-store.js';
33
33
  import { ingestBackup } from './backup-ingest.js';
34
34
  import * as nerEngine from './ner-engine.js';
35
+ import * as sttEngine from './stt-engine.js';
35
36
  import { MODEL_CATALOG, isKnownModel } from './models.js';
37
+ import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL } from './stt-models.js';
36
38
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
37
39
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
38
40
  import { resolveDestination, aggregateModelsAsync } from './router.js';
@@ -40,7 +42,7 @@ import * as openai from './openai.js';
40
42
  import * as responses from './responses.js';
41
43
  import * as anthropic from './anthropic.js';
42
44
 
43
- export const VERSION = '0.6.17';
45
+ export const VERSION = '0.6.19';
44
46
 
45
47
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
46
48
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -490,7 +492,14 @@ export function createGateway(cfg = loadConfig()) {
490
492
  if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
491
493
 
492
494
  if (req.method === 'GET' && pathname === '/health') {
493
- return sendJson(res, 200, { ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier });
495
+ // `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
496
+ // to auto-detect local dictation. `enabled` reflects config; the model only
497
+ // downloads on first use, so state may be 'off' while still available.
498
+ const stt = sttEngine.health();
499
+ return sendJson(res, 200, {
500
+ ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
501
+ stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL },
502
+ });
494
503
  }
495
504
 
496
505
  // --- Config API (the extension's "Gateway" tab is a client of these) ---
@@ -646,6 +655,118 @@ export function createGateway(cfg = loadConfig()) {
646
655
  return sendJson(res, 202, { accepted: true, active: id, state: nerEngine.state(), progress: nerEngine.progress() });
647
656
  }
648
657
  }
658
+ // --- Local speech-to-text (dictation). Whisper runs IN-PROCESS (stt-engine.js,
659
+ // same ONNX engine + model dir as NER); audio arrives as 16 kHz mono Float32 PCM
660
+ // chunks over loopback and ONLY TEXT ever leaves this process. Wire contract
661
+ // (additive; see docs in the hub repo):
662
+ // POST /stt/sessions {lang?} → 201 { id } (ensures the model)
663
+ // POST /stt/sessions/:id/audio binary Float32 PCM chunk → { ok }
664
+ // GET /stt/sessions/:id/events SSE: progress | interim | final | error | end
665
+ // DELETE /stt/sessions/:id flush tail → { ok }
666
+ // GET/POST /stt/models catalog + progress / switch (mirrors /ner/models)
667
+ if (pathname === '/stt/models') {
668
+ if (req.method === 'GET') {
669
+ const active = sttEngine.health().model || cfg.stt?.model || DEFAULT_STT_MODEL;
670
+ const available = /** @type {any[]} */ (STT_MODEL_CATALOG.map((m) => ({ ...m, installed: sttEngine.modelOnDisk(m.id) })));
671
+ // Surface an active CUSTOM (non-catalog) model so the UI can show it too.
672
+ if (active && !available.some((m) => m.id === active)) {
673
+ available.push({ id: active, label: active, lang: '—', tier: 'custom', custom: true, installed: sttEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
674
+ }
675
+ return sendJson(res, 200, {
676
+ active,
677
+ state: sttEngine.state(),
678
+ progress: sttEngine.progress(),
679
+ available,
680
+ });
681
+ }
682
+ if (req.method === 'POST') {
683
+ let body = null;
684
+ try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
685
+ const id = body && typeof body.id === 'string' ? body.id.trim() : null;
686
+ // Curated catalog OR a strictly-validated custom whisper id (Advanced).
687
+ if (!id || !(isKnownSttModel(id) || isValidCustomSttId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
688
+ if (cfg.stt) cfg.stt.model = id; else cfg.stt = { enabled: true, model: id, allowDownload: true };
689
+ try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
690
+ sttEngine.setModel(id, { onLog: (m) => console.log(m) });
691
+ return sendJson(res, 202, { accepted: true, active: id, state: sttEngine.state(), progress: sttEngine.progress() });
692
+ }
693
+ }
694
+ if (pathname === '/stt/sessions' && req.method === 'POST') {
695
+ if (cfg.stt?.enabled === false) return sendJson(res, 403, { error: { message: 'STT disabled in gateway config', type: 'stt_disabled' } });
696
+ let body = null;
697
+ try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8') || '{}'); } catch { body = null; }
698
+ // Kick the model load on first use (single-flight; downloads once). The
699
+ // client follows progress on the session's SSE stream.
700
+ if (!sttEngine.isReady()) {
701
+ sttEngine.init({ model: cfg.stt?.model || DEFAULT_STT_MODEL, allowDownload: cfg.stt?.allowDownload !== false, onLog: (m) => console.log(m) });
702
+ }
703
+ try {
704
+ // `redact: true` chains the OPTIONAL redaction hop onto finals (STT → NER,
705
+ // same composable model as everything else: any stage, with or without).
706
+ const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true });
707
+ return sendJson(res, 201, { id, state: sttEngine.state() });
708
+ } catch (e) {
709
+ return sendJson(res, e.code === 'too_many_sessions' ? 429 : 500, { error: { message: e.message, type: e.code || 'stt_error' } });
710
+ }
711
+ }
712
+ {
713
+ const m = pathname.match(/^\/stt\/sessions\/([0-9a-f-]{36})(\/audio|\/events)?$/);
714
+ if (m) {
715
+ const sid = m[1];
716
+ if (m[2] === '/audio' && req.method === 'POST') {
717
+ try {
718
+ const raw = await readBody(req, cfg.maxBodyBytes);
719
+ sttEngine.pushAudio(sid, sttEngine.toFloat32(raw));
720
+ return sendJson(res, 200, { ok: true, state: sttEngine.state() });
721
+ } catch (e) {
722
+ return sendJson(res, e.code === 'no_session' ? 404 : 400, { error: { message: e.message, type: e.code || 'stt_error' } });
723
+ }
724
+ }
725
+ if (m[2] === '/events' && req.method === 'GET') {
726
+ const sess = sttEngine.getSession(sid);
727
+ if (!sess) return sendJson(res, 404, { error: { message: 'no such session', type: 'no_session' } });
728
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
729
+ const send = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
730
+ // Optional STT → NER hop: redact FINALS through the same shared guard as
731
+ // chat traffic (one implementation, composed — never a second redactor).
732
+ // Interims stay raw (transient, loopback-only). NOTE: the vault is
733
+ // discarded, so these placeholders are permanent — that's the point.
734
+ const sttIsPro = sess.redact ? await resolvePro(cfg.pro?.entitlementToken) : true;
735
+ const maybeRedact = async (ev) => {
736
+ if (ev.type !== 'final' || !sess.redact) return ev;
737
+ try {
738
+ let t = ev.text;
739
+ await redactSegments([segment(() => t, (v) => { t = v; })], cfg.redaction, { isPro: sttIsPro });
740
+ return { ...ev, text: t };
741
+ } catch { return ev; } // fail-open: raw text is still local-only
742
+ };
743
+ // While the model is loading/downloading, stream progress so the UI can
744
+ // show "downloading 43%" instead of dead air on first-ever dictation.
745
+ send({ type: 'state', state: sttEngine.state() });
746
+ const progressTimer = setInterval(() => {
747
+ const st = sttEngine.state();
748
+ if (st === 'downloading' || st === 'loading') send({ type: 'progress', state: st, ...(sttEngine.progress() || {}) });
749
+ else if (st === 'error') { send({ type: 'error', code: 'model_failed', message: sttEngine.health().error || 'model failed to load', fatal: true }); clearInterval(progressTimer); }
750
+ else { send({ type: 'state', state: st }); clearInterval(progressTimer); }
751
+ }, 500);
752
+ progressTimer.unref?.();
753
+ // Redaction is async — chain events so finals can't overtake interims.
754
+ let evChain = Promise.resolve();
755
+ const unsub = sttEngine.subscribe(sid, (ev) => {
756
+ evChain = evChain.then(async () => {
757
+ send(await maybeRedact(ev));
758
+ if (ev.type === 'end') { clearInterval(progressTimer); res.end(); }
759
+ }).catch(() => {});
760
+ });
761
+ req.on('close', () => { clearInterval(progressTimer); unsub?.(); });
762
+ return;
763
+ }
764
+ if (!m[2] && req.method === 'DELETE') {
765
+ await sttEngine.endSession(sid);
766
+ return sendJson(res, 200, { ok: true });
767
+ }
768
+ }
769
+ }
649
770
  if (pathname === '/logs' && req.method === 'GET') {
650
771
  return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only, unless logDetail enriches each entry
651
772
  }
@@ -0,0 +1,368 @@
1
+ // In-process speech-to-text — whisper via transformers.js, zero native deps.
2
+ //
3
+ // Same design as ner-engine.js (and deliberately the same file shape): an ONNX
4
+ // model run IN-PROCESS, downloaded once on first use into ~/.chatpanel/models,
5
+ // fully offline afterwards — audio NEVER leaves the machine. This is what makes
6
+ // dictation private: the extension streams mic PCM over loopback, we transcribe
7
+ // locally, and only text goes back.
8
+ //
9
+ // Two layers:
10
+ // engine — load/switch the whisper pipeline (mirrors ner-engine verbatim)
11
+ // sessions — rolling-buffer streaming decode: interim results every ~1.2s,
12
+ // finals on trailing silence or when a segment grows too long.
13
+ // Whisper isn't natively streaming, so we re-decode the open
14
+ // segment and commit it when the speaker pauses.
15
+ //
16
+ // Fail-open by design: if the model can't load, sessions emit one error event
17
+ // and dictation falls back to the browser engine client-side.
18
+
19
+ import { join } from 'node:path';
20
+ import { existsSync, readdirSync } from 'node:fs';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { ensureLib, modelRoot } from './ner-engine.js';
23
+ import { DEFAULT_STT_MODEL, isEnglishOnly, sttModelDtype, isKnownSttModel } from './stt-models.js';
24
+
25
+ export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
26
+
27
+ // The right whisper quantization depends on the ONNX RUNTIME, not the OS:
28
+ // • native onnxruntime-node (the npm gateway) loads the small, fast `q8`
29
+ // (_quantized) exports — best size + speed.
30
+ // • onnxruntime-web WASM (the standalone binary — SAME wasm on macOS/Windows/
31
+ // Linux, so this is inherently cross-platform) CANNOT load the block-quantized
32
+ // exports (q8/int8/uint8 → MatMulNBits "missing scale"; fp16 → graph error);
33
+ // of the loadable ones only `fp32` is fast enough for real-time (q4/bnb4 are
34
+ // ~8× slower). Verified empirically against the bundled ORT-web build.
35
+ // The binary entry sets __CHATPANEL_WASM_PATHS__, so that global tells us which
36
+ // runtime we're on. A model may override via `dtype` in the STT catalog.
37
+ export function runtimeDtype() {
38
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? 'fp32' : 'q8';
39
+ }
40
+
41
+ let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
42
+ let _model = null; // active model id
43
+ let _pipe = null; // the loaded automatic-speech-recognition pipeline
44
+ let _err = null; // last error message (for /health)
45
+ let _initPromise = null; // single-flight init
46
+ let _progress = null; // { model, file, pct } while downloading, else null
47
+
48
+ // transformers.js dtype → the ONNX filename suffix it loads. Presence must check
49
+ // the EXACT file the current runtime will fetch — otherwise a q8 install (native)
50
+ // looks "present" to the WASM runtime, which actually needs the fp32 file, and the
51
+ // offline load fails. Checking the real target makes a runtime switch re-download.
52
+ const DTYPE_SUFFIX = {
53
+ fp32: '', q8: '_quantized', int8: '_int8', uint8: '_uint8',
54
+ fp16: '_fp16', q4: '_q4', bnb4: '_bnb4', q4f16: '_q4f16',
55
+ };
56
+
57
+ export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL, dtype = sttModelDtype(modelId) || runtimeDtype()) {
58
+ const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
59
+ if (!existsSync(dir)) return false;
60
+ const suffix = DTYPE_SUFFIX[dtype] ?? '';
61
+ try {
62
+ const files = readdirSync(dir);
63
+ return files.includes(`encoder_model${suffix}.onnx`)
64
+ && files.some((f) => f === `decoder_model_merged${suffix}.onnx` || f === `decoder_model${suffix}.onnx`);
65
+ } catch { return false; }
66
+ }
67
+
68
+ export function state() { return _state; }
69
+ export function isReady() { return _state === 'ready' && !!_pipe; }
70
+ export function progress() { return _progress; }
71
+
72
+ export function health() {
73
+ return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, error: _err };
74
+ }
75
+
76
+ // (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
77
+ // and a failed SWITCH keeps the previous working pipeline.
78
+ /** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean }} [opts] */
79
+ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
80
+ const prevPipe = _pipe;
81
+ const prevModel = _model;
82
+ let lib;
83
+ try {
84
+ lib = await ensureLib();
85
+ } catch (e) {
86
+ _state = 'error'; _err = `engine load failed: ${e.message}`;
87
+ log(`[stt] transformers.js not available (${e.message}) — dictation falls back to the browser engine`);
88
+ return false;
89
+ }
90
+
91
+ const haveLocal = modelOnDisk(modelId);
92
+ lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
93
+ if (!haveLocal && !allowDownload) {
94
+ _state = 'error'; _err = 'model not on disk and downloads disabled';
95
+ log(`[stt] model ${modelId} not installed and downloads disabled`);
96
+ return false;
97
+ }
98
+
99
+ // Curated catalog models come from the private dl.chatpanel.net mirror (ensureLib
100
+ // already set that as remoteHost). A CUSTOM ("Advanced") id isn't mirrored, so
101
+ // fetch it from Hugging Face directly — only for this load, then restore.
102
+ const prevHost = lib.env.remoteHost;
103
+ const isCustom = !isKnownSttModel(modelId);
104
+ if (!haveLocal && isCustom) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ } }
105
+
106
+ _state = haveLocal ? 'loading' : 'downloading';
107
+ if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time${isCustom ? ', from Hugging Face' : ''})…`); }
108
+
109
+ try {
110
+ // Per-model override wins (catalog `dtype`), else the runtime-appropriate default.
111
+ const dtype = sttModelDtype(modelId) || runtimeDtype();
112
+ const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
113
+ dtype,
114
+ progress_callback: (p) => {
115
+ if (!p) return;
116
+ const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
117
+ if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
118
+ _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
119
+ } else if (p.status === 'done' && p.file) {
120
+ log(`[stt] fetched ${p.file}`);
121
+ }
122
+ },
123
+ });
124
+ _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
125
+ if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
126
+ log(`[stt] ready — model ${modelId} (in-process, offline) — local dictation active`);
127
+ return true;
128
+ } catch (e) {
129
+ _err = e.message; _progress = null;
130
+ if (prevPipe) { _pipe = prevPipe; _model = prevModel; _state = 'ready'; }
131
+ else { _state = 'error'; }
132
+ log(`[stt] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ''}`);
133
+ return false;
134
+ } finally {
135
+ try { lib.env.remoteHost = prevHost; } catch { /* optional */ } // restore the mirror for NER + catalog loads
136
+ }
137
+ }
138
+
139
+ // Load the configured model once, on FIRST USE (never at gateway startup — the
140
+ // download is deferred until someone actually dictates). Single-flight.
141
+ export function init(cfg = {}) {
142
+ if (_initPromise) return _initPromise;
143
+ const log = typeof cfg.onLog === 'function' ? cfg.onLog : () => {};
144
+ _model = cfg.model || DEFAULT_STT_MODEL;
145
+ _state = 'loading';
146
+ _initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false });
147
+ return _initPromise;
148
+ }
149
+
150
+ export async function setModel(modelId, opts = {}) {
151
+ const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
152
+ if (!modelId) return false;
153
+ if (modelId === _model && isReady()) return true;
154
+ return loadModel(modelId, { log, allowDownload: opts.allowDownload !== false });
155
+ }
156
+
157
+ // ── Streaming sessions ──────────────────────────────────────────────────────────
158
+
159
+ const MAX_SESSIONS = 4;
160
+ const IDLE_MS = 60_000; // no audio for a minute → session expires
161
+ const DECODE_GAP_MS = 1200; // min spacing between decodes of one session
162
+ const MAX_SEGMENT_S = 12; // force-finalize an open segment past this
163
+ const SILENCE_FINAL_MS = 700; // trailing quiet that commits a segment
164
+ const SILENCE_RMS = 0.008; // "quiet" threshold on Float32 PCM
165
+ const MAX_BUFFER_S = 30; // hard cap on the open segment (whisper ctx)
166
+
167
+ const _sessions = new Map();
168
+ let _decodeChain = Promise.resolve(); // whisper is effectively single-threaded —
169
+ // serialize decodes across ALL sessions
170
+
171
+ export function sessionCount() { return _sessions.size; }
172
+
173
+ /** @param {{ lang?: string, redact?: boolean }} [opts] */
174
+ export function createSession({ lang, redact = false } = {}) {
175
+ if (_sessions.size >= MAX_SESSIONS) {
176
+ const e = /** @type {Error & { code?: string }} */ (new Error('too many concurrent dictation sessions'));
177
+ e.code = 'too_many_sessions'; throw e;
178
+ }
179
+ const s = {
180
+ id: randomUUID(),
181
+ lang: typeof lang === 'string' && lang ? lang.slice(0, 12) : null,
182
+ langTried: false, // language auto-detect runs once per session (multilingual models)
183
+ // Opaque to this engine: the server applies the redaction hop to finals when
184
+ // set. Pipeline stages stay independent — STT never imports NER.
185
+ redact: !!redact,
186
+ chunks: [], // Float32Array pieces of the OPEN (unfinalized) segment
187
+ samples: 0,
188
+ listeners: new Set(), // (event) => void — the SSE writers
189
+ lastDecodeAt: 0,
190
+ decodeTimer: null,
191
+ lastInterim: '',
192
+ closed: false,
193
+ idleTimer: null,
194
+ };
195
+ _sessions.set(s.id, s);
196
+ touch(s);
197
+ return { id: s.id };
198
+ }
199
+
200
+ export function getSession(id) { return _sessions.get(String(id || '')) || null; }
201
+
202
+ export function subscribe(id, fn) {
203
+ const s = getSession(id);
204
+ if (!s) return null;
205
+ s.listeners.add(fn);
206
+ return () => s.listeners.delete(fn);
207
+ }
208
+
209
+ function emit(s, ev) {
210
+ for (const fn of s.listeners) { try { fn(ev); } catch { /* listener's problem */ } }
211
+ }
212
+
213
+ function touch(s) {
214
+ if (s.idleTimer) clearTimeout(s.idleTimer);
215
+ s.idleTimer = setTimeout(() => endSession(s.id).catch(() => {}), IDLE_MS);
216
+ s.idleTimer.unref?.();
217
+ }
218
+
219
+ // Accept a chunk of 16 kHz mono Float32 PCM and schedule a decode.
220
+ export function pushAudio(id, float32) {
221
+ const s = getSession(id);
222
+ if (!s || s.closed) {
223
+ const e = /** @type {Error & { code?: string }} */ (new Error('no such session'));
224
+ e.code = 'no_session'; throw e;
225
+ }
226
+ if (!(float32 instanceof Float32Array) || !float32.length) return;
227
+ s.chunks.push(float32);
228
+ s.samples += float32.length;
229
+ touch(s);
230
+ scheduleDecode(s);
231
+ }
232
+
233
+ // HTTP bodies arrive as Buffers whose byteOffset may not be 4-aligned — copy.
234
+ export function toFloat32(buf) {
235
+ const bytes = buf.length - (buf.length % 4);
236
+ const out = new Float32Array(bytes / 4);
237
+ for (let i = 0; i < out.length; i++) out[i] = buf.readFloatLE(i * 4);
238
+ return out;
239
+ }
240
+
241
+ function scheduleDecode(s) {
242
+ if (s.closed || s.decodeTimer) return;
243
+ const wait = Math.max(0, s.lastDecodeAt + DECODE_GAP_MS - Date.now());
244
+ s.decodeTimer = setTimeout(() => {
245
+ s.decodeTimer = null;
246
+ _decodeChain = _decodeChain.then(() => decodeSession(s).catch(() => {}));
247
+ }, wait);
248
+ s.decodeTimer.unref?.();
249
+ }
250
+
251
+ function concatBuffer(s) {
252
+ const out = new Float32Array(s.samples);
253
+ let o = 0;
254
+ for (const c of s.chunks) { out.set(c, o); o += c.length; }
255
+ return out;
256
+ }
257
+
258
+ function rms(audio, from = 0, to = audio.length) {
259
+ let sum = 0;
260
+ const n = Math.max(1, to - from);
261
+ for (let i = from; i < to; i++) sum += audio[i] * audio[i];
262
+ return Math.sqrt(sum / n);
263
+ }
264
+
265
+ // Whisper's native language-ID, which transformers.js doesn't implement (its
266
+ // pipeline just defaults to English): one decoder step from <|startoftranscript|>,
267
+ // argmax over the 99 language tokens. Run ONCE per session on the first voiced
268
+ // segment, then pinned — so "speak any language, it just works" without a setting.
269
+ async function detectLanguage(audio) {
270
+ try {
271
+ const lib = await ensureLib();
272
+ const gc = _pipe?.model?.generation_config;
273
+ if (!gc?.lang_to_id || !gc.decoder_start_token_id || !lib.Tensor) return null; // english-only model (or exotic export)
274
+ const clip = audio.length > SAMPLE_RATE * 8 ? audio.subarray(0, SAMPLE_RATE * 8) : audio;
275
+ const feats = await _pipe.processor(clip);
276
+ const decoder_input_ids = new lib.Tensor('int64', new BigInt64Array([BigInt(gc.decoder_start_token_id)]), [1, 1]);
277
+ const out = await _pipe.model({ ...feats, decoder_input_ids });
278
+ let best = null, bestVal = -Infinity;
279
+ for (const [tok, id] of Object.entries(gc.lang_to_id)) {
280
+ const v = Number(out.logits.data[id]);
281
+ if (v > bestVal) { bestVal = v; best = tok; }
282
+ }
283
+ return best ? best.slice(2, -2) : null; // '<|fr|>' → 'fr'
284
+ } catch { return null; } // fail-open: whisper's English default still transcribes
285
+ }
286
+
287
+ async function decodeSession(s, { flush = false } = {}) {
288
+ if (s.closed && !flush) return;
289
+ if (!isReady() || !s.samples) return;
290
+ s.lastDecodeAt = Date.now();
291
+
292
+ const audio = concatBuffer(s);
293
+ const tail = Math.round((SILENCE_FINAL_MS / 1000) * SAMPLE_RATE);
294
+ const trailingQuiet = audio.length > tail && rms(audio, audio.length - tail) < SILENCE_RMS;
295
+
296
+ // Nothing but room tone? Don't decode (whisper hallucinates on silence) and
297
+ // don't let the buffer grow unbounded — keep only the last second.
298
+ if (rms(audio) < SILENCE_RMS) {
299
+ if (audio.length > SAMPLE_RATE * 5) {
300
+ s.chunks = [audio.subarray(audio.length - SAMPLE_RATE)];
301
+ s.samples = SAMPLE_RATE;
302
+ }
303
+ return;
304
+ }
305
+
306
+ // Auto-detect the spoken language on the session's first voiced audio (≥1s),
307
+ // then pin it. An explicit client `lang` wins; `.en` models skip all of this.
308
+ if (!s.lang && !s.langTried && !isEnglishOnly(_model) && audio.length >= SAMPLE_RATE) {
309
+ s.langTried = true;
310
+ const detected = await detectLanguage(audio);
311
+ if (detected) { s.lang = detected; emit(s, { type: 'language', lang: detected }); }
312
+ }
313
+
314
+ let text = '';
315
+ try {
316
+ const opts = isEnglishOnly(_model) ? {} : { language: s.lang || undefined, task: 'transcribe' };
317
+ const out = await _pipe(audio, opts);
318
+ text = String(out?.text || '').trim();
319
+ } catch (e) {
320
+ emit(s, { type: 'error', code: 'decode_failed', message: e.message, fatal: false });
321
+ return;
322
+ }
323
+ if (!text) return;
324
+
325
+ const tooLong = audio.length >= MAX_SEGMENT_S * SAMPLE_RATE;
326
+ const overflow = audio.length >= MAX_BUFFER_S * SAMPLE_RATE;
327
+ if (flush || trailingQuiet || tooLong || overflow) {
328
+ // Commit: the open segment becomes a final; the buffer restarts empty.
329
+ s.chunks = []; s.samples = 0; s.lastInterim = '';
330
+ emit(s, { type: 'final', text });
331
+ } else if (text !== s.lastInterim) {
332
+ s.lastInterim = text;
333
+ emit(s, { type: 'interim', text });
334
+ }
335
+ // More audio may have arrived while decoding — the next push reschedules.
336
+ }
337
+
338
+ // Stop a session: flush any remaining speech as a last final, notify, clean up.
339
+ export async function endSession(id) {
340
+ const s = getSession(id);
341
+ if (!s || s.closed) return false;
342
+ s.closed = true;
343
+ if (s.idleTimer) clearTimeout(s.idleTimer);
344
+ if (s.decodeTimer) { clearTimeout(s.decodeTimer); s.decodeTimer = null; }
345
+ try {
346
+ await (_decodeChain = _decodeChain.then(() => decodeSession(s, { flush: true }).catch(() => {})));
347
+ } finally {
348
+ emit(s, { type: 'end' });
349
+ s.listeners.clear();
350
+ _sessions.delete(s.id);
351
+ }
352
+ return true;
353
+ }
354
+
355
+ // Test hook: reset module state.
356
+ export function _reset() {
357
+ for (const s of _sessions.values()) {
358
+ if (s.idleTimer) clearTimeout(s.idleTimer);
359
+ if (s.decodeTimer) clearTimeout(s.decodeTimer);
360
+ }
361
+ _sessions.clear();
362
+ _state = 'off'; _model = null; _pipe = null; _err = null; _initPromise = null; _progress = null;
363
+ }
364
+
365
+ // Test hook: inject a fake pipeline (so session logic is testable without a model).
366
+ export function _setPipeForTest(pipe, model = 'test/fake') {
367
+ _pipe = pipe; _model = model; _state = pipe ? 'ready' : 'off';
368
+ }
@@ -0,0 +1,85 @@
1
+ // Catalog of speech-to-text (whisper) models the gateway can run, surfaced in the
2
+ // extension's Gateway settings and dictation UI so users pick by their machine's
3
+ // resources. All are ONNX (transformers.js) automatic-speech-recognition models —
4
+ // same engine, download plumbing, and model root as the NER catalog (models.js).
5
+ //
6
+ // `tier` groups them for the picker (light / balanced / accurate / max). `ramMB`
7
+ // is a rough working-set hint. `approxMB` is the DOWNLOAD size for the dtype the
8
+ // current runtime will actually fetch — it differs by runtime (the WASM binary
9
+ // downloads fp32; the native npm build downloads q8), so it's a range, and the
10
+ // gateway reports the exact bytes as they stream.
11
+ //
12
+ // Adding a model: any onnx-community/* (or Xenova/*) whisper export works. Verify
13
+ // it loads on BOTH runtimes (native q8 + WASM fp32) before listing it — the WASM
14
+ // runtime can't load block-quantized (q8/int8) or fp16 exports (see stt-engine
15
+ // runtimeDtype). `.en` models are English-only and reject language/task options.
16
+
17
+ export const DEFAULT_STT_MODEL = 'onnx-community/whisper-base';
18
+
19
+ export const STT_MODEL_CATALOG = [
20
+ {
21
+ id: 'onnx-community/whisper-tiny.en',
22
+ label: 'Tiny (English)',
23
+ lang: 'English',
24
+ tier: 'light',
25
+ approxMB: 150, // fp32 on WASM; ~40 on native q8
26
+ ramMB: 400,
27
+ note: 'Fastest, lightest. English only — great for quick dictation on any machine.',
28
+ },
29
+ {
30
+ id: 'onnx-community/whisper-base',
31
+ label: 'Base (multilingual)',
32
+ lang: '99 languages (auto-detected)',
33
+ tier: 'balanced',
34
+ approxMB: 300, // fp32 on WASM; ~80 on native q8
35
+ ramMB: 700,
36
+ note: 'Default. Detects the spoken language automatically; good accuracy at real-time speed.',
37
+ },
38
+ {
39
+ id: 'onnx-community/whisper-small',
40
+ label: 'Small (multilingual)',
41
+ lang: '99 languages (auto-detected)',
42
+ tier: 'accurate',
43
+ approxMB: 950, // fp32 on WASM; ~250 on native q8
44
+ ramMB: 1600,
45
+ note: 'Noticeably more accurate; needs a faster CPU to keep up in real time.',
46
+ },
47
+ {
48
+ id: 'onnx-community/whisper-large-v3-turbo',
49
+ label: 'Large v3 Turbo (multilingual)',
50
+ lang: '99 languages (auto-detected)',
51
+ tier: 'max',
52
+ approxMB: 1600, // native q8 recommended; heavy on WASM
53
+ ramMB: 3200,
54
+ note: 'Best accuracy. For powerful machines; use the native (npm) gateway for speed.',
55
+ },
56
+ ];
57
+
58
+ export function isKnownSttModel(id) {
59
+ return STT_MODEL_CATALOG.some((m) => m.id === id);
60
+ }
61
+
62
+ export function sttModel(id) {
63
+ return STT_MODEL_CATALOG.find((m) => m.id === id) || null;
64
+ }
65
+
66
+ // English-only exports reject `language`/`task` generation options.
67
+ export function isEnglishOnly(id) {
68
+ return /\.en$/.test(String(id || ''));
69
+ }
70
+
71
+ // A model may pin an explicit dtype (overriding the runtime default). None do by
72
+ // default — the engine's runtimeDtype() picks q8 (native) / fp32 (WASM) — but
73
+ // this is the seam for a model that needs a specific quantization.
74
+ export function sttModelDtype(id) {
75
+ return sttModel(id)?.dtype || null;
76
+ }
77
+
78
+ // Accept a user-supplied ("Advanced") whisper model id. STRICT: `org/name` shape,
79
+ // must be a whisper export, no path traversal. Curated catalog ids come from the
80
+ // private dl.chatpanel.net mirror; custom ids are fetched from Hugging Face
81
+ // directly (the engine points remoteHost there only for the custom load).
82
+ export function isValidCustomSttId(id) {
83
+ const s = String(id || '');
84
+ return /^[A-Za-z0-9][\w.-]*\/[\w.-]+$/.test(s) && /whisper/i.test(s) && !s.includes('..');
85
+ }