@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 +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 +124 -3
- package/src/stt-engine.js +368 -0
- package/src/stt-models.js +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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
|
|
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, 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.
|
|
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
|
-
|
|
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
|
+
}
|