@chatpanel/gateway 0.6.17 → 0.6.18
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 +10 -0
- package/src/configstore.js +9 -1
- package/src/mcp.js +3 -3
- package/src/ner-engine.js +5 -3
- package/src/server.js +118 -3
- package/src/stt-engine.js +332 -0
- package/src/stt-models.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18",
|
|
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
|
|
package/src/configstore.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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, 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.
|
|
45
|
+
export const VERSION = '0.6.18';
|
|
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
|
-
|
|
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,112 @@ 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 available = STT_MODEL_CATALOG.map((m) => ({ ...m, installed: sttEngine.modelOnDisk(m.id) }));
|
|
670
|
+
return sendJson(res, 200, {
|
|
671
|
+
active: sttEngine.health().model || cfg.stt?.model || DEFAULT_STT_MODEL,
|
|
672
|
+
state: sttEngine.state(),
|
|
673
|
+
progress: sttEngine.progress(),
|
|
674
|
+
available,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
if (req.method === 'POST') {
|
|
678
|
+
let body = null;
|
|
679
|
+
try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
|
|
680
|
+
const id = body && typeof body.id === 'string' ? body.id : null;
|
|
681
|
+
if (!id || !isKnownSttModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
|
|
682
|
+
if (cfg.stt) cfg.stt.model = id; else cfg.stt = { enabled: true, model: id, allowDownload: true };
|
|
683
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
684
|
+
sttEngine.setModel(id, { onLog: (m) => console.log(m) });
|
|
685
|
+
return sendJson(res, 202, { accepted: true, active: id, state: sttEngine.state(), progress: sttEngine.progress() });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (pathname === '/stt/sessions' && req.method === 'POST') {
|
|
689
|
+
if (cfg.stt?.enabled === false) return sendJson(res, 403, { error: { message: 'STT disabled in gateway config', type: 'stt_disabled' } });
|
|
690
|
+
let body = null;
|
|
691
|
+
try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8') || '{}'); } catch { body = null; }
|
|
692
|
+
// Kick the model load on first use (single-flight; downloads once). The
|
|
693
|
+
// client follows progress on the session's SSE stream.
|
|
694
|
+
if (!sttEngine.isReady()) {
|
|
695
|
+
sttEngine.init({ model: cfg.stt?.model || DEFAULT_STT_MODEL, allowDownload: cfg.stt?.allowDownload !== false, onLog: (m) => console.log(m) });
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
// `redact: true` chains the OPTIONAL redaction hop onto finals (STT → NER,
|
|
699
|
+
// same composable model as everything else: any stage, with or without).
|
|
700
|
+
const { id } = sttEngine.createSession({ lang: body?.lang, redact: body?.redact === true });
|
|
701
|
+
return sendJson(res, 201, { id, state: sttEngine.state() });
|
|
702
|
+
} catch (e) {
|
|
703
|
+
return sendJson(res, e.code === 'too_many_sessions' ? 429 : 500, { error: { message: e.message, type: e.code || 'stt_error' } });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
{
|
|
707
|
+
const m = pathname.match(/^\/stt\/sessions\/([0-9a-f-]{36})(\/audio|\/events)?$/);
|
|
708
|
+
if (m) {
|
|
709
|
+
const sid = m[1];
|
|
710
|
+
if (m[2] === '/audio' && req.method === 'POST') {
|
|
711
|
+
try {
|
|
712
|
+
const raw = await readBody(req, cfg.maxBodyBytes);
|
|
713
|
+
sttEngine.pushAudio(sid, sttEngine.toFloat32(raw));
|
|
714
|
+
return sendJson(res, 200, { ok: true, state: sttEngine.state() });
|
|
715
|
+
} catch (e) {
|
|
716
|
+
return sendJson(res, e.code === 'no_session' ? 404 : 400, { error: { message: e.message, type: e.code || 'stt_error' } });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (m[2] === '/events' && req.method === 'GET') {
|
|
720
|
+
const sess = sttEngine.getSession(sid);
|
|
721
|
+
if (!sess) return sendJson(res, 404, { error: { message: 'no such session', type: 'no_session' } });
|
|
722
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
723
|
+
const send = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
724
|
+
// Optional STT → NER hop: redact FINALS through the same shared guard as
|
|
725
|
+
// chat traffic (one implementation, composed — never a second redactor).
|
|
726
|
+
// Interims stay raw (transient, loopback-only). NOTE: the vault is
|
|
727
|
+
// discarded, so these placeholders are permanent — that's the point.
|
|
728
|
+
const sttIsPro = sess.redact ? await resolvePro(cfg.pro?.entitlementToken) : true;
|
|
729
|
+
const maybeRedact = async (ev) => {
|
|
730
|
+
if (ev.type !== 'final' || !sess.redact) return ev;
|
|
731
|
+
try {
|
|
732
|
+
let t = ev.text;
|
|
733
|
+
await redactSegments([segment(() => t, (v) => { t = v; })], cfg.redaction, { isPro: sttIsPro });
|
|
734
|
+
return { ...ev, text: t };
|
|
735
|
+
} catch { return ev; } // fail-open: raw text is still local-only
|
|
736
|
+
};
|
|
737
|
+
// While the model is loading/downloading, stream progress so the UI can
|
|
738
|
+
// show "downloading 43%" instead of dead air on first-ever dictation.
|
|
739
|
+
send({ type: 'state', state: sttEngine.state() });
|
|
740
|
+
const progressTimer = setInterval(() => {
|
|
741
|
+
const st = sttEngine.state();
|
|
742
|
+
if (st === 'downloading' || st === 'loading') send({ type: 'progress', state: st, ...(sttEngine.progress() || {}) });
|
|
743
|
+
else if (st === 'error') { send({ type: 'error', code: 'model_failed', message: sttEngine.health().error || 'model failed to load', fatal: true }); clearInterval(progressTimer); }
|
|
744
|
+
else { send({ type: 'state', state: st }); clearInterval(progressTimer); }
|
|
745
|
+
}, 500);
|
|
746
|
+
progressTimer.unref?.();
|
|
747
|
+
// Redaction is async — chain events so finals can't overtake interims.
|
|
748
|
+
let evChain = Promise.resolve();
|
|
749
|
+
const unsub = sttEngine.subscribe(sid, (ev) => {
|
|
750
|
+
evChain = evChain.then(async () => {
|
|
751
|
+
send(await maybeRedact(ev));
|
|
752
|
+
if (ev.type === 'end') { clearInterval(progressTimer); res.end(); }
|
|
753
|
+
}).catch(() => {});
|
|
754
|
+
});
|
|
755
|
+
req.on('close', () => { clearInterval(progressTimer); unsub?.(); });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (!m[2] && req.method === 'DELETE') {
|
|
759
|
+
await sttEngine.endSession(sid);
|
|
760
|
+
return sendJson(res, 200, { ok: true });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
649
764
|
if (pathname === '/logs' && req.method === 'GET') {
|
|
650
765
|
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only, unless logDetail enriches each entry
|
|
651
766
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
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 } from './stt-models.js';
|
|
24
|
+
|
|
25
|
+
export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
|
|
26
|
+
|
|
27
|
+
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
28
|
+
let _model = null; // active model id
|
|
29
|
+
let _pipe = null; // the loaded automatic-speech-recognition pipeline
|
|
30
|
+
let _err = null; // last error message (for /health)
|
|
31
|
+
let _initPromise = null; // single-flight init
|
|
32
|
+
let _progress = null; // { model, file, pct } while downloading, else null
|
|
33
|
+
|
|
34
|
+
// Whisper exports ship encoder+decoder ONNX files (not the single model.onnx the
|
|
35
|
+
// NER check looks for), so presence = "any encoder_model*.onnx on disk".
|
|
36
|
+
export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL) {
|
|
37
|
+
const dir = join(modelRoot(), ...modelId.split('/'), 'onnx');
|
|
38
|
+
if (!existsSync(dir)) return false;
|
|
39
|
+
try { return readdirSync(dir).some((f) => /^encoder_model.*\.onnx$/.test(f)); }
|
|
40
|
+
catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function state() { return _state; }
|
|
44
|
+
export function isReady() { return _state === 'ready' && !!_pipe; }
|
|
45
|
+
export function progress() { return _progress; }
|
|
46
|
+
|
|
47
|
+
export function health() {
|
|
48
|
+
return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, error: _err };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// (Re)load a model into _pipe. Same contract as ner-engine.loadModel: fail-open,
|
|
52
|
+
// and a failed SWITCH keeps the previous working pipeline.
|
|
53
|
+
/** @param {string} modelId @param {{ log?: (m: string) => void, allowDownload?: boolean }} [opts] */
|
|
54
|
+
async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
|
|
55
|
+
const prevPipe = _pipe;
|
|
56
|
+
const prevModel = _model;
|
|
57
|
+
let lib;
|
|
58
|
+
try {
|
|
59
|
+
lib = await ensureLib();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
_state = 'error'; _err = `engine load failed: ${e.message}`;
|
|
62
|
+
log(`[stt] transformers.js not available (${e.message}) — dictation falls back to the browser engine`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const haveLocal = modelOnDisk(modelId);
|
|
67
|
+
lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
|
|
68
|
+
if (!haveLocal && !allowDownload) {
|
|
69
|
+
_state = 'error'; _err = 'model not on disk and downloads disabled';
|
|
70
|
+
log(`[stt] model ${modelId} not installed and downloads disabled`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_state = haveLocal ? 'loading' : 'downloading';
|
|
75
|
+
if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[stt] downloading model ${modelId} (one-time)…`); }
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const pipe = await lib.pipeline('automatic-speech-recognition', modelId, {
|
|
79
|
+
dtype: 'q8',
|
|
80
|
+
progress_callback: (p) => {
|
|
81
|
+
if (!p) return;
|
|
82
|
+
const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
|
|
83
|
+
if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
|
|
84
|
+
_progress = { model: modelId, file: p.file || _progress?.file || null, pct };
|
|
85
|
+
} else if (p.status === 'done' && p.file) {
|
|
86
|
+
log(`[stt] fetched ${p.file}`);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
_pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
|
|
91
|
+
if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
|
|
92
|
+
log(`[stt] ready — model ${modelId} (in-process, offline) — local dictation active`);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
_err = e.message; _progress = null;
|
|
96
|
+
if (prevPipe) { _pipe = prevPipe; _model = prevModel; _state = 'ready'; }
|
|
97
|
+
else { _state = 'error'; }
|
|
98
|
+
log(`[stt] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ''}`);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Load the configured model once, on FIRST USE (never at gateway startup — the
|
|
104
|
+
// download is deferred until someone actually dictates). Single-flight.
|
|
105
|
+
export function init(cfg = {}) {
|
|
106
|
+
if (_initPromise) return _initPromise;
|
|
107
|
+
const log = typeof cfg.onLog === 'function' ? cfg.onLog : () => {};
|
|
108
|
+
_model = cfg.model || DEFAULT_STT_MODEL;
|
|
109
|
+
_state = 'loading';
|
|
110
|
+
_initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false });
|
|
111
|
+
return _initPromise;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function setModel(modelId, opts = {}) {
|
|
115
|
+
const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
|
|
116
|
+
if (!modelId) return false;
|
|
117
|
+
if (modelId === _model && isReady()) return true;
|
|
118
|
+
return loadModel(modelId, { log, allowDownload: opts.allowDownload !== false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Streaming sessions ──────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
const MAX_SESSIONS = 4;
|
|
124
|
+
const IDLE_MS = 60_000; // no audio for a minute → session expires
|
|
125
|
+
const DECODE_GAP_MS = 1200; // min spacing between decodes of one session
|
|
126
|
+
const MAX_SEGMENT_S = 12; // force-finalize an open segment past this
|
|
127
|
+
const SILENCE_FINAL_MS = 700; // trailing quiet that commits a segment
|
|
128
|
+
const SILENCE_RMS = 0.008; // "quiet" threshold on Float32 PCM
|
|
129
|
+
const MAX_BUFFER_S = 30; // hard cap on the open segment (whisper ctx)
|
|
130
|
+
|
|
131
|
+
const _sessions = new Map();
|
|
132
|
+
let _decodeChain = Promise.resolve(); // whisper is effectively single-threaded —
|
|
133
|
+
// serialize decodes across ALL sessions
|
|
134
|
+
|
|
135
|
+
export function sessionCount() { return _sessions.size; }
|
|
136
|
+
|
|
137
|
+
/** @param {{ lang?: string, redact?: boolean }} [opts] */
|
|
138
|
+
export function createSession({ lang, redact = false } = {}) {
|
|
139
|
+
if (_sessions.size >= MAX_SESSIONS) {
|
|
140
|
+
const e = /** @type {Error & { code?: string }} */ (new Error('too many concurrent dictation sessions'));
|
|
141
|
+
e.code = 'too_many_sessions'; throw e;
|
|
142
|
+
}
|
|
143
|
+
const s = {
|
|
144
|
+
id: randomUUID(),
|
|
145
|
+
lang: typeof lang === 'string' && lang ? lang.slice(0, 12) : null,
|
|
146
|
+
langTried: false, // language auto-detect runs once per session (multilingual models)
|
|
147
|
+
// Opaque to this engine: the server applies the redaction hop to finals when
|
|
148
|
+
// set. Pipeline stages stay independent — STT never imports NER.
|
|
149
|
+
redact: !!redact,
|
|
150
|
+
chunks: [], // Float32Array pieces of the OPEN (unfinalized) segment
|
|
151
|
+
samples: 0,
|
|
152
|
+
listeners: new Set(), // (event) => void — the SSE writers
|
|
153
|
+
lastDecodeAt: 0,
|
|
154
|
+
decodeTimer: null,
|
|
155
|
+
lastInterim: '',
|
|
156
|
+
closed: false,
|
|
157
|
+
idleTimer: null,
|
|
158
|
+
};
|
|
159
|
+
_sessions.set(s.id, s);
|
|
160
|
+
touch(s);
|
|
161
|
+
return { id: s.id };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getSession(id) { return _sessions.get(String(id || '')) || null; }
|
|
165
|
+
|
|
166
|
+
export function subscribe(id, fn) {
|
|
167
|
+
const s = getSession(id);
|
|
168
|
+
if (!s) return null;
|
|
169
|
+
s.listeners.add(fn);
|
|
170
|
+
return () => s.listeners.delete(fn);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function emit(s, ev) {
|
|
174
|
+
for (const fn of s.listeners) { try { fn(ev); } catch { /* listener's problem */ } }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function touch(s) {
|
|
178
|
+
if (s.idleTimer) clearTimeout(s.idleTimer);
|
|
179
|
+
s.idleTimer = setTimeout(() => endSession(s.id).catch(() => {}), IDLE_MS);
|
|
180
|
+
s.idleTimer.unref?.();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Accept a chunk of 16 kHz mono Float32 PCM and schedule a decode.
|
|
184
|
+
export function pushAudio(id, float32) {
|
|
185
|
+
const s = getSession(id);
|
|
186
|
+
if (!s || s.closed) {
|
|
187
|
+
const e = /** @type {Error & { code?: string }} */ (new Error('no such session'));
|
|
188
|
+
e.code = 'no_session'; throw e;
|
|
189
|
+
}
|
|
190
|
+
if (!(float32 instanceof Float32Array) || !float32.length) return;
|
|
191
|
+
s.chunks.push(float32);
|
|
192
|
+
s.samples += float32.length;
|
|
193
|
+
touch(s);
|
|
194
|
+
scheduleDecode(s);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// HTTP bodies arrive as Buffers whose byteOffset may not be 4-aligned — copy.
|
|
198
|
+
export function toFloat32(buf) {
|
|
199
|
+
const bytes = buf.length - (buf.length % 4);
|
|
200
|
+
const out = new Float32Array(bytes / 4);
|
|
201
|
+
for (let i = 0; i < out.length; i++) out[i] = buf.readFloatLE(i * 4);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function scheduleDecode(s) {
|
|
206
|
+
if (s.closed || s.decodeTimer) return;
|
|
207
|
+
const wait = Math.max(0, s.lastDecodeAt + DECODE_GAP_MS - Date.now());
|
|
208
|
+
s.decodeTimer = setTimeout(() => {
|
|
209
|
+
s.decodeTimer = null;
|
|
210
|
+
_decodeChain = _decodeChain.then(() => decodeSession(s).catch(() => {}));
|
|
211
|
+
}, wait);
|
|
212
|
+
s.decodeTimer.unref?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function concatBuffer(s) {
|
|
216
|
+
const out = new Float32Array(s.samples);
|
|
217
|
+
let o = 0;
|
|
218
|
+
for (const c of s.chunks) { out.set(c, o); o += c.length; }
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function rms(audio, from = 0, to = audio.length) {
|
|
223
|
+
let sum = 0;
|
|
224
|
+
const n = Math.max(1, to - from);
|
|
225
|
+
for (let i = from; i < to; i++) sum += audio[i] * audio[i];
|
|
226
|
+
return Math.sqrt(sum / n);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Whisper's native language-ID, which transformers.js doesn't implement (its
|
|
230
|
+
// pipeline just defaults to English): one decoder step from <|startoftranscript|>,
|
|
231
|
+
// argmax over the 99 language tokens. Run ONCE per session on the first voiced
|
|
232
|
+
// segment, then pinned — so "speak any language, it just works" without a setting.
|
|
233
|
+
async function detectLanguage(audio) {
|
|
234
|
+
try {
|
|
235
|
+
const lib = await ensureLib();
|
|
236
|
+
const gc = _pipe?.model?.generation_config;
|
|
237
|
+
if (!gc?.lang_to_id || !gc.decoder_start_token_id || !lib.Tensor) return null; // english-only model (or exotic export)
|
|
238
|
+
const clip = audio.length > SAMPLE_RATE * 8 ? audio.subarray(0, SAMPLE_RATE * 8) : audio;
|
|
239
|
+
const feats = await _pipe.processor(clip);
|
|
240
|
+
const decoder_input_ids = new lib.Tensor('int64', new BigInt64Array([BigInt(gc.decoder_start_token_id)]), [1, 1]);
|
|
241
|
+
const out = await _pipe.model({ ...feats, decoder_input_ids });
|
|
242
|
+
let best = null, bestVal = -Infinity;
|
|
243
|
+
for (const [tok, id] of Object.entries(gc.lang_to_id)) {
|
|
244
|
+
const v = Number(out.logits.data[id]);
|
|
245
|
+
if (v > bestVal) { bestVal = v; best = tok; }
|
|
246
|
+
}
|
|
247
|
+
return best ? best.slice(2, -2) : null; // '<|fr|>' → 'fr'
|
|
248
|
+
} catch { return null; } // fail-open: whisper's English default still transcribes
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function decodeSession(s, { flush = false } = {}) {
|
|
252
|
+
if (s.closed && !flush) return;
|
|
253
|
+
if (!isReady() || !s.samples) return;
|
|
254
|
+
s.lastDecodeAt = Date.now();
|
|
255
|
+
|
|
256
|
+
const audio = concatBuffer(s);
|
|
257
|
+
const tail = Math.round((SILENCE_FINAL_MS / 1000) * SAMPLE_RATE);
|
|
258
|
+
const trailingQuiet = audio.length > tail && rms(audio, audio.length - tail) < SILENCE_RMS;
|
|
259
|
+
|
|
260
|
+
// Nothing but room tone? Don't decode (whisper hallucinates on silence) and
|
|
261
|
+
// don't let the buffer grow unbounded — keep only the last second.
|
|
262
|
+
if (rms(audio) < SILENCE_RMS) {
|
|
263
|
+
if (audio.length > SAMPLE_RATE * 5) {
|
|
264
|
+
s.chunks = [audio.subarray(audio.length - SAMPLE_RATE)];
|
|
265
|
+
s.samples = SAMPLE_RATE;
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Auto-detect the spoken language on the session's first voiced audio (≥1s),
|
|
271
|
+
// then pin it. An explicit client `lang` wins; `.en` models skip all of this.
|
|
272
|
+
if (!s.lang && !s.langTried && !isEnglishOnly(_model) && audio.length >= SAMPLE_RATE) {
|
|
273
|
+
s.langTried = true;
|
|
274
|
+
const detected = await detectLanguage(audio);
|
|
275
|
+
if (detected) { s.lang = detected; emit(s, { type: 'language', lang: detected }); }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let text = '';
|
|
279
|
+
try {
|
|
280
|
+
const opts = isEnglishOnly(_model) ? {} : { language: s.lang || undefined, task: 'transcribe' };
|
|
281
|
+
const out = await _pipe(audio, opts);
|
|
282
|
+
text = String(out?.text || '').trim();
|
|
283
|
+
} catch (e) {
|
|
284
|
+
emit(s, { type: 'error', code: 'decode_failed', message: e.message, fatal: false });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!text) return;
|
|
288
|
+
|
|
289
|
+
const tooLong = audio.length >= MAX_SEGMENT_S * SAMPLE_RATE;
|
|
290
|
+
const overflow = audio.length >= MAX_BUFFER_S * SAMPLE_RATE;
|
|
291
|
+
if (flush || trailingQuiet || tooLong || overflow) {
|
|
292
|
+
// Commit: the open segment becomes a final; the buffer restarts empty.
|
|
293
|
+
s.chunks = []; s.samples = 0; s.lastInterim = '';
|
|
294
|
+
emit(s, { type: 'final', text });
|
|
295
|
+
} else if (text !== s.lastInterim) {
|
|
296
|
+
s.lastInterim = text;
|
|
297
|
+
emit(s, { type: 'interim', text });
|
|
298
|
+
}
|
|
299
|
+
// More audio may have arrived while decoding — the next push reschedules.
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Stop a session: flush any remaining speech as a last final, notify, clean up.
|
|
303
|
+
export async function endSession(id) {
|
|
304
|
+
const s = getSession(id);
|
|
305
|
+
if (!s || s.closed) return false;
|
|
306
|
+
s.closed = true;
|
|
307
|
+
if (s.idleTimer) clearTimeout(s.idleTimer);
|
|
308
|
+
if (s.decodeTimer) { clearTimeout(s.decodeTimer); s.decodeTimer = null; }
|
|
309
|
+
try {
|
|
310
|
+
await (_decodeChain = _decodeChain.then(() => decodeSession(s, { flush: true }).catch(() => {})));
|
|
311
|
+
} finally {
|
|
312
|
+
emit(s, { type: 'end' });
|
|
313
|
+
s.listeners.clear();
|
|
314
|
+
_sessions.delete(s.id);
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Test hook: reset module state.
|
|
320
|
+
export function _reset() {
|
|
321
|
+
for (const s of _sessions.values()) {
|
|
322
|
+
if (s.idleTimer) clearTimeout(s.idleTimer);
|
|
323
|
+
if (s.decodeTimer) clearTimeout(s.decodeTimer);
|
|
324
|
+
}
|
|
325
|
+
_sessions.clear();
|
|
326
|
+
_state = 'off'; _model = null; _pipe = null; _err = null; _initPromise = null; _progress = null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Test hook: inject a fake pipeline (so session logic is testable without a model).
|
|
330
|
+
export function _setPipeForTest(pipe, model = 'test/fake') {
|
|
331
|
+
_pipe = pipe; _model = model; _state = pipe ? 'ready' : 'off';
|
|
332
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Catalog of speech-to-text (whisper) models the gateway can run, surfaced in the
|
|
2
|
+
// extension's Gateway settings and dictation UI. All are ONNX (transformers.js)
|
|
3
|
+
// automatic-speech-recognition models — same engine, download plumbing, and model
|
|
4
|
+
// root as the NER catalog (models.js). Sizes are the on-disk q8 footprint, approx.
|
|
5
|
+
//
|
|
6
|
+
// Adding a model: any onnx-community/* (or Xenova/*) whisper export works. Verify
|
|
7
|
+
// it loads + transcribes before listing it here. `.en` models are English-only and
|
|
8
|
+
// reject language/task options — sttDecodeOptions() below handles that split.
|
|
9
|
+
|
|
10
|
+
// Default is MULTILINGUAL on purpose: whisper auto-detects the spoken language
|
|
11
|
+
// per segment when no `language` is pinned — dictation must "just work" in any
|
|
12
|
+
// language without a setting. `.en` models are the speed opt-in, not the default.
|
|
13
|
+
export const DEFAULT_STT_MODEL = 'onnx-community/whisper-base';
|
|
14
|
+
|
|
15
|
+
export const STT_MODEL_CATALOG = [
|
|
16
|
+
{
|
|
17
|
+
id: 'onnx-community/whisper-base',
|
|
18
|
+
label: 'Multilingual — balanced',
|
|
19
|
+
lang: '99 languages (auto-detected)',
|
|
20
|
+
approxMB: 105,
|
|
21
|
+
note: 'Default. Detects the spoken language automatically; good accuracy.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'onnx-community/whisper-tiny.en',
|
|
25
|
+
label: 'English — fastest',
|
|
26
|
+
lang: 'English',
|
|
27
|
+
approxMB: 60,
|
|
28
|
+
note: 'Real-time on modest hardware; English only.',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'onnx-community/whisper-small',
|
|
32
|
+
label: 'Multilingual — accurate',
|
|
33
|
+
lang: '99 languages (auto-detected)',
|
|
34
|
+
approxMB: 330,
|
|
35
|
+
note: 'Best accuracy; needs a faster machine for real-time use.',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function isKnownSttModel(id) {
|
|
40
|
+
return STT_MODEL_CATALOG.some((m) => m.id === id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// English-only exports reject `language`/`task` generation options.
|
|
44
|
+
export function isEnglishOnly(id) {
|
|
45
|
+
return /\.en$/.test(String(id || ''));
|
|
46
|
+
}
|