@chatpanel/gateway 0.6.49 → 0.6.51
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 +4 -3
- package/src/config.js +24 -0
- package/src/model-runtime.js +37 -0
- package/src/server.js +125 -1
- package/src/stt-engine.js +5 -30
- package/src/tts-engine.js +300 -0
- package/src/tts-models.js +138 -0
- package/src/tts-remote.js +109 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Local privacy gateway
|
|
3
|
+
"version": "0.6.51",
|
|
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": {
|
|
7
7
|
"chatpanel-gateway": "bin/chatpanel-gateway.js"
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@chatpanel/pii": "^0.4.0",
|
|
31
31
|
"@huggingface/transformers": "^4.2.0",
|
|
32
|
-
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
32
|
+
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
|
33
|
+
"phonemizer": "^1.2.1"
|
|
33
34
|
},
|
|
34
35
|
"homepage": "https://chatpanel.net",
|
|
35
36
|
"repository": {
|
package/src/config.js
CHANGED
|
@@ -106,6 +106,30 @@ const DEFAULTS = {
|
|
|
106
106
|
diarize: true,
|
|
107
107
|
},
|
|
108
108
|
|
|
109
|
+
// Local text-to-speech (read-aloud / voice out) — Kokoro via the same in-process
|
|
110
|
+
// ONNX engine and model dir as NER/STT (tts-engine.js). No autostart, same reason
|
|
111
|
+
// as stt: the model downloads on FIRST synthesis, never on gateway boot.
|
|
112
|
+
tts: {
|
|
113
|
+
enabled: true,
|
|
114
|
+
// 'local' = Kokoro in this process, nothing leaves the machine (the default).
|
|
115
|
+
// 'openai' | 'elevenlabs' = a remote voice. Auth is passed through from the
|
|
116
|
+
// client as everywhere else here; no key is stored. See src/tts-remote.js for
|
|
117
|
+
// why remote synthesis redacts by default and what that costs.
|
|
118
|
+
provider: 'local',
|
|
119
|
+
model: 'onnx-community/Kokoro-82M-v1.0-ONNX',
|
|
120
|
+
voice: 'af_heart',
|
|
121
|
+
allowDownload: true,
|
|
122
|
+
remote: {
|
|
123
|
+
baseUrl: '', // '' = the provider's own default endpoint
|
|
124
|
+
model: '',
|
|
125
|
+
voice: '',
|
|
126
|
+
// Redact before the text leaves this machine. Audio cannot be un-redacted,
|
|
127
|
+
// so this genuinely means the voice says "PERSON_1" — set false only if you
|
|
128
|
+
// accept sending real values to the vendor.
|
|
129
|
+
redact: true,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
109
133
|
// Log one line per request (method, tokens redacted) without any raw values.
|
|
110
134
|
logRequests: true,
|
|
111
135
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Which ONNX runtime is active, and what precision it can actually load.
|
|
2
|
+
//
|
|
3
|
+
// This is a fact about the RUNTIME, not about any one speech stage, so it lives in
|
|
4
|
+
// its own module rather than inside whichever engine needed it first. The voice
|
|
5
|
+
// pipeline's rule is that a stage never imports another stage (docs/voice-pipeline.md
|
|
6
|
+
// "Composable pipeline"), and stt-engine and tts-engine both have to answer this —
|
|
7
|
+
// so neither can own it.
|
|
8
|
+
//
|
|
9
|
+
// • native onnxruntime-node (the npm gateway) loads the small, fast `q8`
|
|
10
|
+
// (_quantized) exports — best size + speed.
|
|
11
|
+
// • onnxruntime-web WASM (the standalone binary — SAME wasm on macOS/Windows/
|
|
12
|
+
// Linux, so this is inherently cross-platform) CANNOT load the block-quantized
|
|
13
|
+
// exports (q8/int8/uint8 → MatMulNBits "missing scale"; fp16 → graph error);
|
|
14
|
+
// of the loadable ones only `fp32` is fast enough for real-time (q4/bnb4 are
|
|
15
|
+
// ~8× slower). Verified empirically against the bundled ORT-web build.
|
|
16
|
+
//
|
|
17
|
+
// The binary entry sets __CHATPANEL_WASM_PATHS__, so that global tells us which
|
|
18
|
+
// runtime we're on. A model may override the choice via `dtype` in its catalog.
|
|
19
|
+
export function runtimeDtype() {
|
|
20
|
+
return globalThis.__CHATPANEL_WASM_PATHS__ ? 'fp32' : 'q8';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 'native' (npm, fast quantized) | 'wasm' (binary, slow fp32). The extension
|
|
24
|
+
// surfaces this so users on the slow WASM build know the native gateway is faster.
|
|
25
|
+
export function runtimeName() {
|
|
26
|
+
return globalThis.__CHATPANEL_WASM_PATHS__ ? 'wasm' : 'native';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// transformers.js dtype → the ONNX filename suffix it loads. A presence check must
|
|
30
|
+
// target the EXACT file the current runtime will fetch — otherwise a q8 install
|
|
31
|
+
// (native) looks "present" to the WASM runtime, which actually needs the fp32 file,
|
|
32
|
+
// and the offline load fails. Checking the real target makes a runtime switch
|
|
33
|
+
// re-download rather than fail.
|
|
34
|
+
export const DTYPE_SUFFIX = {
|
|
35
|
+
fp32: '', q8: '_quantized', int8: '_int8', uint8: '_uint8',
|
|
36
|
+
fp16: '_fp16', q4: '_q4', bnb4: '_bnb4', q4f16: '_q4f16',
|
|
37
|
+
};
|
package/src/server.js
CHANGED
|
@@ -39,6 +39,9 @@ import * as sttEngine from './stt-engine.js';
|
|
|
39
39
|
import * as diarizeEngine from './diarize-engine.js';
|
|
40
40
|
import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
|
|
41
41
|
import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
|
|
42
|
+
import * as ttsEngine from './tts-engine.js';
|
|
43
|
+
import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS } from './tts-models.js';
|
|
44
|
+
import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
|
|
42
45
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
43
46
|
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
|
|
44
47
|
import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
|
|
@@ -49,7 +52,7 @@ import * as openai from './openai.js';
|
|
|
49
52
|
import * as responses from './responses.js';
|
|
50
53
|
import * as anthropic from './anthropic.js';
|
|
51
54
|
|
|
52
|
-
export const VERSION = '0.6.
|
|
55
|
+
export const VERSION = '0.6.51';
|
|
53
56
|
|
|
54
57
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
55
58
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -583,11 +586,15 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
583
586
|
// to auto-detect local dictation. `enabled` reflects config; the model only
|
|
584
587
|
// downloads on first use, so state may be 'off' while still available.
|
|
585
588
|
const stt = sttEngine.health();
|
|
589
|
+
const tts = ttsEngine.health();
|
|
586
590
|
return sendJson(res, 200, {
|
|
587
591
|
ok: true, version: VERSION, backend: cfg.backend, tier: cfg.redaction.tier,
|
|
588
592
|
// `runtime` = 'native' (npm, fast quantized) | 'wasm' (binary, slow fp32) —
|
|
589
593
|
// the extension uses it to advise the far-faster native gateway.
|
|
590
594
|
stt: { enabled: cfg.stt?.enabled !== false, state: stt.state, ready: stt.ok, model: stt.model || cfg.stt?.model || DEFAULT_STT_MODEL, runtime: stt.runtime, dtype: stt.dtype },
|
|
595
|
+
// `tts` is ADDITIVE the same way: an older extension ignores it, a newer
|
|
596
|
+
// one uses it to offer local read-aloud instead of browser speech.
|
|
597
|
+
tts: { enabled: cfg.tts?.enabled !== false, state: tts.state, ready: tts.ok, model: tts.model || cfg.tts?.model || DEFAULT_TTS_MODEL, voice: cfg.tts?.voice || DEFAULT_TTS_VOICE, runtime: tts.runtime, dtype: tts.dtype },
|
|
591
598
|
});
|
|
592
599
|
}
|
|
593
600
|
|
|
@@ -958,6 +965,123 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
958
965
|
return sendJson(res, 202, { accepted: true, active: diarizeEngine.DIARIZE_MODEL, state: diarizeEngine.state(), progress: diarizeEngine.progress() });
|
|
959
966
|
}
|
|
960
967
|
}
|
|
968
|
+
// --- Local text-to-speech (voice out). Kokoro runs IN-PROCESS (tts-engine.js),
|
|
969
|
+
// so audio is synthesized on this machine and never leaves it. Phase 4 of
|
|
970
|
+
// docs/voice-pipeline.md. Model manager first, then synthesis.
|
|
971
|
+
if (pathname === '/tts/models') {
|
|
972
|
+
if (req.method === 'GET') {
|
|
973
|
+
const active = ttsEngine.health().model || cfg.tts?.model || DEFAULT_TTS_MODEL;
|
|
974
|
+
const available = /** @type {any[]} */ (TTS_MODEL_CATALOG.map((m) => ({ ...m, installed: ttsEngine.modelOnDisk(m.id) })));
|
|
975
|
+
if (active && !available.some((m) => m.id === active)) {
|
|
976
|
+
available.push({ id: active, label: active, lang: '—', tier: 'custom', custom: true, installed: ttsEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
|
|
977
|
+
}
|
|
978
|
+
return sendJson(res, 200, {
|
|
979
|
+
active,
|
|
980
|
+
state: ttsEngine.state(),
|
|
981
|
+
progress: ttsEngine.progress(),
|
|
982
|
+
available,
|
|
983
|
+
voice: cfg.tts?.voice || DEFAULT_TTS_VOICE,
|
|
984
|
+
// Each voice is a separate ~500 KB style bank, so `installed` is per-voice.
|
|
985
|
+
voices: TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
|
|
986
|
+
dtype: cfg.tts?.dtype || 'auto',
|
|
987
|
+
loadedDtype: ttsEngine.health().dtype,
|
|
988
|
+
runtime: ttsEngine.health().runtime,
|
|
989
|
+
dtypes: TTS_DTYPES,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
if (req.method === 'POST') {
|
|
993
|
+
let body = null;
|
|
994
|
+
try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
|
|
995
|
+
const id = body && typeof body.id === 'string' ? body.id.trim() : null;
|
|
996
|
+
if (id && !(isKnownTtsModel(id) || isValidCustomTtsId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
|
|
997
|
+
// A voice id becomes a filename, so it is checked against the catalog AND
|
|
998
|
+
// its shape before it is ever persisted.
|
|
999
|
+
const voice = body && typeof body.voice === 'string' ? body.voice.trim() : null;
|
|
1000
|
+
if (voice && !(isKnownVoice(voice) && isValidVoiceId(voice))) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1001
|
+
const dtype = body && typeof body.dtype === 'string' && isValidTtsDtype(body.dtype) ? body.dtype : undefined;
|
|
1002
|
+
if (!cfg.tts) cfg.tts = { enabled: true, model: DEFAULT_TTS_MODEL, voice: DEFAULT_TTS_VOICE, allowDownload: true };
|
|
1003
|
+
if (id) cfg.tts.model = id;
|
|
1004
|
+
if (voice) cfg.tts.voice = voice;
|
|
1005
|
+
if (dtype) cfg.tts.dtype = dtype === 'auto' ? null : dtype;
|
|
1006
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
1007
|
+
if (id) ttsEngine.setModel(id, { onLog: (m) => console.log(m), dtype: dtype || cfg.tts.dtype || 'auto' });
|
|
1008
|
+
return sendJson(res, 202, { accepted: true, active: cfg.tts.model, voice: cfg.tts.voice, dtype: cfg.tts.dtype || 'auto', state: ttsEngine.state(), progress: ttsEngine.progress() });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// POST /tts — { text, voice?, speed? } → audio/wav — and its OpenAI-compatible
|
|
1013
|
+
// twin POST /v1/audio/speech ({ input, voice, speed, response_format }), so any
|
|
1014
|
+
// OpenAI client or tunnel can drive local speech with no ChatPanel-specific code.
|
|
1015
|
+
// Both go through ONE handler: two routes must never drift into two behaviours.
|
|
1016
|
+
//
|
|
1017
|
+
// Deliberately NOT redacted. Every other stage in the voice pipeline redacts at
|
|
1018
|
+
// the model-send chokepoint because the text is about to leave the machine;
|
|
1019
|
+
// synthesis is local, so there is nothing to protect it from — and reading
|
|
1020
|
+
// "[[PERSON_1]]" aloud to the person who wrote it is a bug, not privacy.
|
|
1021
|
+
if ((pathname === '/tts' || pathname === '/v1/audio/speech') && req.method === 'POST') {
|
|
1022
|
+
if (cfg.tts?.enabled === false) return sendJson(res, 503, { error: { message: 'tts is disabled in gateway config', type: 'tts_disabled' } });
|
|
1023
|
+
let body = null;
|
|
1024
|
+
try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
|
|
1025
|
+
// `input` is OpenAI's field name, `text` is ours — accept either on both routes.
|
|
1026
|
+
const text = body && typeof (body.input ?? body.text) === 'string' ? String(body.input ?? body.text).trim() : '';
|
|
1027
|
+
if (!text) return sendJson(res, 400, { error: { message: 'text is required', type: 'bad_request' } });
|
|
1028
|
+
if (text.length > MAX_TTS_CHARS) return sendJson(res, 413, { error: { message: `text too long (max ${MAX_TTS_CHARS} chars)`, type: 'too_long' } });
|
|
1029
|
+
const speed = Number.isFinite(body.speed) ? Math.min(2, Math.max(0.5, body.speed)) : 1;
|
|
1030
|
+
const dest = ttsDestination(cfg);
|
|
1031
|
+
// A remote destination has its own voice namespace (an ElevenLabs voice id is
|
|
1032
|
+
// not a Kokoro one), so the local catalog check would reject every valid id.
|
|
1033
|
+
const rawVoice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : null;
|
|
1034
|
+
const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
|
|
1035
|
+
const voiceOk = dest ? isValidRemoteVoice(voice) : (isKnownVoice(voice) && isValidVoiceId(voice));
|
|
1036
|
+
if (!voiceOk) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1037
|
+
// We synthesize WAV only. Say so rather than returning WAV bytes under an mp3
|
|
1038
|
+
// content-type — a client that trusts the header would play noise.
|
|
1039
|
+
const fmt = body && typeof body.response_format === 'string' ? body.response_format.toLowerCase() : 'wav';
|
|
1040
|
+
if (fmt !== 'wav' && fmt !== 'pcm') return sendJson(res, 400, { error: { message: `unsupported response_format "${fmt}" — this gateway synthesizes wav`, type: 'bad_format' } });
|
|
1041
|
+
try {
|
|
1042
|
+
// Remote destination: the caller's own auth goes upstream, the text is
|
|
1043
|
+
// redacted first unless this destination explicitly opted out, and the
|
|
1044
|
+
// vendor's own audio format is passed straight through rather than
|
|
1045
|
+
// re-wrapped — we did not synthesize it and must not claim its container.
|
|
1046
|
+
if (dest) {
|
|
1047
|
+
const { audio, contentType, redacted } = await synthesizeRemote({
|
|
1048
|
+
dest, text, voice, speed,
|
|
1049
|
+
auth: req.headers.authorization || req.headers['xi-api-key'] || '',
|
|
1050
|
+
redaction: cfg.redaction,
|
|
1051
|
+
isPro: await resolvePro(cfg.pro?.entitlementToken),
|
|
1052
|
+
});
|
|
1053
|
+
res.writeHead(200, {
|
|
1054
|
+
'Content-Type': contentType,
|
|
1055
|
+
'Content-Length': String(audio.length),
|
|
1056
|
+
'Cache-Control': 'no-store',
|
|
1057
|
+
// Say what actually left. A caller that asked for privacy can verify it,
|
|
1058
|
+
// and one that turned it off can see that it is off.
|
|
1059
|
+
'X-Tts-Provider': dest.kind,
|
|
1060
|
+
'X-Tts-Redacted': String(redacted),
|
|
1061
|
+
});
|
|
1062
|
+
return res.end(audio);
|
|
1063
|
+
}
|
|
1064
|
+
const ok = await ttsEngine.ready({
|
|
1065
|
+
onLog: (m) => console.log(m),
|
|
1066
|
+
allowDownload: cfg.tts?.allowDownload !== false,
|
|
1067
|
+
model: cfg.tts?.model || DEFAULT_TTS_MODEL,
|
|
1068
|
+
dtype: cfg.tts?.dtype || 'auto',
|
|
1069
|
+
});
|
|
1070
|
+
if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
|
|
1071
|
+
const pcm = await ttsEngine.synth(text, { voice, speed });
|
|
1072
|
+
const out = fmt === 'pcm' ? Buffer.from(new Float32Array(pcm).buffer) : ttsEngine.toWav(pcm);
|
|
1073
|
+
res.writeHead(200, {
|
|
1074
|
+
'Content-Type': fmt === 'pcm' ? 'application/octet-stream' : 'audio/wav',
|
|
1075
|
+
'Content-Length': String(out.length),
|
|
1076
|
+
'Cache-Control': 'no-store',
|
|
1077
|
+
'X-Tts-Sample-Rate': String(ttsEngine.SAMPLE_RATE),
|
|
1078
|
+
});
|
|
1079
|
+
return res.end(out);
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
return sendJson(res, 500, { error: { message: e.message, type: 'tts_failed' } });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
961
1085
|
if (pathname === '/stt/sessions' && req.method === 'POST') {
|
|
962
1086
|
if (cfg.stt?.enabled === false) return sendJson(res, 403, { error: { message: 'STT disabled in gateway config', type: 'stt_disabled' } });
|
|
963
1087
|
let body = null;
|
package/src/stt-engine.js
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { existsSync, readdirSync } from 'node:fs';
|
|
21
21
|
import { randomUUID } from 'node:crypto';
|
|
22
22
|
import { ensureLib, modelRoot } from './ner-engine.js';
|
|
23
|
+
import { runtimeDtype, runtimeName, DTYPE_SUFFIX } from './model-runtime.js';
|
|
23
24
|
import { verifyModelWeights } from './model-integrity.js';
|
|
24
25
|
import { DEFAULT_STT_MODEL, isEnglishOnly, sttModelDtype, isKnownSttModel, sttModelEngine } from './stt-models.js';
|
|
25
26
|
import * as parakeet from './parakeet-engine.js';
|
|
@@ -27,27 +28,10 @@ import * as diarize from './diarize-engine.js';
|
|
|
27
28
|
|
|
28
29
|
export const SAMPLE_RATE = 16000; // fixed wire contract: 16 kHz mono Float32 PCM
|
|
29
30
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
// Linux, so this is inherently cross-platform) CANNOT load the block-quantized
|
|
35
|
-
// exports (q8/int8/uint8 → MatMulNBits "missing scale"; fp16 → graph error);
|
|
36
|
-
// of the loadable ones only `fp32` is fast enough for real-time (q4/bnb4 are
|
|
37
|
-
// ~8× slower). Verified empirically against the bundled ORT-web build.
|
|
38
|
-
// The binary entry sets __CHATPANEL_WASM_PATHS__, so that global tells us which
|
|
39
|
-
// runtime we're on. A model may override via `dtype` in the STT catalog.
|
|
40
|
-
export function runtimeDtype() {
|
|
41
|
-
return globalThis.__CHATPANEL_WASM_PATHS__ ? 'fp32' : 'q8';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Which ONNX runtime is active: the standalone binary uses onnxruntime-web WASM
|
|
45
|
-
// (fp32-only, single-thread — ~10× slower); the npm package uses onnxruntime-node
|
|
46
|
-
// (native, quantized q8). The extension surfaces this so users on the slow WASM
|
|
47
|
-
// build know the native gateway is far faster.
|
|
48
|
-
export function runtimeName() {
|
|
49
|
-
return globalThis.__CHATPANEL_WASM_PATHS__ ? 'wasm' : 'native';
|
|
50
|
-
}
|
|
31
|
+
// runtimeDtype / runtimeName moved to model-runtime.js — the WASM binary's fp32-only
|
|
32
|
+
// constraint is a property of the runtime, and tts-engine needs the same answer.
|
|
33
|
+
// Re-exported here so this module's public API is unchanged for existing callers.
|
|
34
|
+
export { runtimeDtype, runtimeName };
|
|
51
35
|
|
|
52
36
|
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
53
37
|
let _model = null; // active model id
|
|
@@ -57,15 +41,6 @@ let _initPromise = null; // single-flight init
|
|
|
57
41
|
let _progress = null; // { model, file, pct } while downloading, else null
|
|
58
42
|
let _dtype = null; // the quantization actually loaded (fp32 on WASM, q8 native)
|
|
59
43
|
|
|
60
|
-
// transformers.js dtype → the ONNX filename suffix it loads. Presence must check
|
|
61
|
-
// the EXACT file the current runtime will fetch — otherwise a q8 install (native)
|
|
62
|
-
// looks "present" to the WASM runtime, which actually needs the fp32 file, and the
|
|
63
|
-
// offline load fails. Checking the real target makes a runtime switch re-download.
|
|
64
|
-
const DTYPE_SUFFIX = {
|
|
65
|
-
fp32: '', q8: '_quantized', int8: '_int8', uint8: '_uint8',
|
|
66
|
-
fp16: '_fp16', q4: '_q4', bnb4: '_bnb4', q4f16: '_q4f16',
|
|
67
|
-
};
|
|
68
|
-
|
|
69
44
|
export function modelOnDisk(modelId = _model || DEFAULT_STT_MODEL, dtype = sttModelDtype(modelId) || runtimeDtype()) {
|
|
70
45
|
// Transducer models (parakeet) have a different file layout + engine — delegate.
|
|
71
46
|
if (sttModelEngine(modelId) === 'parakeet-tdt') return parakeet.parakeetOnDisk(modelId, parakeet.parakeetDtype(dtype));
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Local text-to-speech — Kokoro (StyleTTS2-family) via the same in-process ONNX
|
|
2
|
+
// engine, model root and download plumbing as NER and STT. Phase 4 of
|
|
3
|
+
// docs/voice-pipeline.md: "voice out".
|
|
4
|
+
//
|
|
5
|
+
// Why this drives the model directly instead of using `kokoro-js`: that package
|
|
6
|
+
// depends on @huggingface/transformers ^3.5.1 and the gateway pins ^4.2.0, so
|
|
7
|
+
// adding it would nest a SECOND transformers install — a second ONNX runtime in a
|
|
8
|
+
// binary whose build script (scripts/build.mjs) exists specifically to control
|
|
9
|
+
// which runtime ships. transformers 4.2 has StyleTextToSpeech2Model built in, so
|
|
10
|
+
// the model loads on the pinned version and the only thing kokoro-js was really
|
|
11
|
+
// providing — grapheme→phoneme — comes from `phonemizer` (zero dependencies, a
|
|
12
|
+
// pure-JS espeak-ng, so it survives Bun --compile).
|
|
13
|
+
//
|
|
14
|
+
// Privacy: synthesis is entirely local. Nothing is sent anywhere, which is why the
|
|
15
|
+
// route speaks RESTORED text rather than redacted text — see the /tts handler.
|
|
16
|
+
//
|
|
17
|
+
// This module owns ONE concern: text in, PCM out. Streaming, routing and redaction
|
|
18
|
+
// live at the server/route layer, because a pipeline stage never imports another
|
|
19
|
+
// stage.
|
|
20
|
+
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
|
23
|
+
import { ensureLib, modelRoot } from './ner-engine.js';
|
|
24
|
+
import { runtimeDtype, runtimeName, DTYPE_SUFFIX } from './model-runtime.js';
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, STYLE_DIM, MAX_PHONEME_TOKENS,
|
|
27
|
+
ttsModelDtype, isKnownTtsModel, isValidVoiceId, voiceLang,
|
|
28
|
+
} from './tts-models.js';
|
|
29
|
+
|
|
30
|
+
export const SAMPLE_RATE = 24000; // Kokoro's output rate — fixed by the model
|
|
31
|
+
|
|
32
|
+
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
33
|
+
let _model = null;
|
|
34
|
+
let _net = null; // StyleTextToSpeech2Model
|
|
35
|
+
let _tok = null; // phoneme tokenizer
|
|
36
|
+
let _dtype = null;
|
|
37
|
+
let _err = null;
|
|
38
|
+
let _progress = null;
|
|
39
|
+
let _initPromise = null;
|
|
40
|
+
const _voices = new Map(); // voice id → Float32Array style bank
|
|
41
|
+
|
|
42
|
+
export function state() { return _state; }
|
|
43
|
+
export function isReady() { return _state === 'ready' && !!_net; }
|
|
44
|
+
export function progress() { return _progress; }
|
|
45
|
+
|
|
46
|
+
export function health() {
|
|
47
|
+
return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, runtime: runtimeName(), error: _err };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function modelDir(modelId) {
|
|
51
|
+
return join(modelRoot(), ...String(modelId).split('/'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Present = the EXACT ONNX file this runtime will load, plus the tokenizer. Both
|
|
55
|
+
// non-empty: a truncated download must not read as installed.
|
|
56
|
+
export function modelOnDisk(modelId = _model || DEFAULT_TTS_MODEL, dtype = ttsModelDtype(modelId) || runtimeDtype()) {
|
|
57
|
+
const dir = modelDir(modelId);
|
|
58
|
+
const suffix = DTYPE_SUFFIX[dtype] ?? '';
|
|
59
|
+
const need = [join(dir, 'onnx', `model${suffix}.onnx`), join(dir, 'tokenizer.json')];
|
|
60
|
+
try {
|
|
61
|
+
return need.every((p) => existsSync(p) && statSync(p).size > 0);
|
|
62
|
+
} catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A voice is a separate ~500 KB style bank (voices/<id>.bin), not part of the model
|
|
66
|
+
// download, so it is fetched and checked on its own.
|
|
67
|
+
export function voiceOnDisk(voice, modelId = _model || DEFAULT_TTS_MODEL) {
|
|
68
|
+
if (!isValidVoiceId(voice)) return false;
|
|
69
|
+
const p = join(modelDir(modelId), 'voices', `${voice}.bin`);
|
|
70
|
+
try { return existsSync(p) && statSync(p).size > 0; } catch { return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function init(cfg = {}) {
|
|
74
|
+
const tts = cfg.tts || {};
|
|
75
|
+
if (tts.enabled === false) { _state = 'off'; return; }
|
|
76
|
+
_model = tts.model || DEFAULT_TTS_MODEL;
|
|
77
|
+
// No autostart, same as STT: the model downloads on FIRST synthesis, never on
|
|
78
|
+
// gateway boot — a 90-330 MB fetch must not be a side effect of starting up.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype: dtypeOverride = null } = {}) {
|
|
82
|
+
const prevNet = _net, prevModel = _model;
|
|
83
|
+
let lib;
|
|
84
|
+
try {
|
|
85
|
+
lib = await ensureLib();
|
|
86
|
+
} catch (e) {
|
|
87
|
+
_state = 'error'; _err = `engine load failed: ${e.message}`;
|
|
88
|
+
log(`[tts] transformers.js not available (${e.message}) — read-aloud falls back to browser speech`);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const chosen = dtypeOverride && dtypeOverride !== 'auto' ? dtypeOverride : null;
|
|
93
|
+
const dtype = chosen || ttsModelDtype(modelId) || runtimeDtype();
|
|
94
|
+
const haveLocal = modelOnDisk(modelId, dtype);
|
|
95
|
+
lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
|
|
96
|
+
if (!haveLocal && !allowDownload) {
|
|
97
|
+
_state = 'error'; _err = 'model not on disk and downloads disabled';
|
|
98
|
+
log(`[tts] model ${modelId} not installed and downloads disabled`);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ensureLib points remoteHost at the private dl.chatpanel.net mirror, which
|
|
103
|
+
// carries the NER and STT models but NOT the TTS ones yet (it 403s on them), so
|
|
104
|
+
// every TTS fetch goes to Hugging Face for now — curated and custom alike. Once
|
|
105
|
+
// the mirror carries them, narrow this back to `!isKnownTtsModel(modelId)` the
|
|
106
|
+
// way stt-engine does, and curated downloads move to the mirror with no other
|
|
107
|
+
// change. Voice banks (loadVoice) fetch from HF for the same reason.
|
|
108
|
+
const prevHost = lib.env.remoteHost;
|
|
109
|
+
const mirrored = false; // ← flip when dl.chatpanel.net mirrors the TTS models
|
|
110
|
+
if (!haveLocal && (!mirrored || !isKnownTtsModel(modelId))) {
|
|
111
|
+
try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_state = haveLocal ? 'loading' : 'downloading';
|
|
115
|
+
if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[tts] downloading voice model ${modelId} (one-time)…`); }
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// The extra classes ensureLib doesn't return. Module resolution is cached, so
|
|
119
|
+
// this is the SAME transformers instance ensureLib already configured (env,
|
|
120
|
+
// cacheDir, wasm paths) — importing it here keeps ner-engine free of TTS.
|
|
121
|
+
const tf = await import('@huggingface/transformers');
|
|
122
|
+
const [net, tok] = await Promise.all([
|
|
123
|
+
tf.StyleTextToSpeech2Model.from_pretrained(modelId, {
|
|
124
|
+
dtype,
|
|
125
|
+
progress_callback: (p) => {
|
|
126
|
+
if (p?.status === 'progress' && p.file) _progress = { model: modelId, file: p.file, pct: Math.round(p.progress || 0) };
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
tf.AutoTokenizer.from_pretrained(modelId),
|
|
130
|
+
]);
|
|
131
|
+
_net = net; _tok = tok; _model = modelId; _dtype = dtype;
|
|
132
|
+
_state = 'ready'; _err = null; _progress = null;
|
|
133
|
+
log(`[tts] ready — model ${modelId} @ ${dtype} (${runtimeName()}, offline) — local speech active`);
|
|
134
|
+
return true;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// A failed SWITCH keeps the previous working model, same as ner/stt.
|
|
137
|
+
_net = prevNet; _model = prevModel;
|
|
138
|
+
_state = prevNet ? 'ready' : 'error';
|
|
139
|
+
_err = e.message; _progress = null;
|
|
140
|
+
log(`[tts] model load failed (${e.message})`);
|
|
141
|
+
return false;
|
|
142
|
+
} finally {
|
|
143
|
+
try { lib.env.remoteHost = prevHost; } catch { /* optional */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function setModel(modelId, { onLog = () => {}, allowDownload = true, dtype = 'auto' } = {}) {
|
|
148
|
+
const want = dtype && dtype !== 'auto' ? dtype : (ttsModelDtype(modelId) || runtimeDtype());
|
|
149
|
+
if (modelId === _model && isReady() && _dtype === want) return true;
|
|
150
|
+
_initPromise = loadModel(modelId, { log: onLog, allowDownload, dtype });
|
|
151
|
+
return _initPromise;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function ready({ onLog = () => {}, allowDownload = true, model = null, dtype = 'auto' } = {}) {
|
|
155
|
+
if (isReady()) return true;
|
|
156
|
+
if (_initPromise) return _initPromise;
|
|
157
|
+
return setModel(model || _model || DEFAULT_TTS_MODEL, { onLog, allowDownload, dtype });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── voices ───────────────────────────────────────────────────────────────────────
|
|
161
|
+
// voices/<id>.bin is a flat Float32 bank of MAX+1 style vectors — one per possible
|
|
162
|
+
// token count — so the row is selected by the length of THIS utterance.
|
|
163
|
+
async function loadVoice(voice, { allowDownload = true, log = () => {} } = {}) {
|
|
164
|
+
if (!isValidVoiceId(voice)) throw new Error(`invalid voice id: ${voice}`);
|
|
165
|
+
const cached = _voices.get(voice);
|
|
166
|
+
if (cached) return cached;
|
|
167
|
+
const dir = join(modelDir(_model || DEFAULT_TTS_MODEL), 'voices');
|
|
168
|
+
const dest = join(dir, `${voice}.bin`);
|
|
169
|
+
if (!(existsSync(dest) && statSync(dest).size > 0)) {
|
|
170
|
+
if (!allowDownload) throw new Error(`voice ${voice} not on disk and downloads disabled`);
|
|
171
|
+
mkdirSync(dir, { recursive: true });
|
|
172
|
+
const url = `https://huggingface.co/${_model || DEFAULT_TTS_MODEL}/resolve/main/voices/${voice}.bin`;
|
|
173
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
174
|
+
if (!res.ok) throw new Error(`fetch voice ${voice} → HTTP ${res.status}`);
|
|
175
|
+
// .part then rename, so an interrupted fetch never looks complete.
|
|
176
|
+
const tmp = `${dest}.part`;
|
|
177
|
+
writeFileSync(tmp, Buffer.from(await res.arrayBuffer()));
|
|
178
|
+
renameSync(tmp, dest);
|
|
179
|
+
log(`[tts] fetched voice ${voice}`);
|
|
180
|
+
}
|
|
181
|
+
const buf = readFileSync(dest);
|
|
182
|
+
const bank = new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4));
|
|
183
|
+
_voices.set(voice, bank);
|
|
184
|
+
return bank;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── text → speech ────────────────────────────────────────────────────────────────
|
|
188
|
+
// Kokoro accepts at most MAX_PHONEME_TOKENS per forward pass and SILENTLY drops
|
|
189
|
+
// whatever does not fit — the failure is "it stopped reading halfway", with no
|
|
190
|
+
// error. So every returned part must be bounded, not merely usually bounded.
|
|
191
|
+
//
|
|
192
|
+
// Boundaries are tried in descending order of how natural the pause sounds:
|
|
193
|
+
// sentence → clause → word → (last resort) a hard slice. Falling through to the
|
|
194
|
+
// next one only happens when the previous left a part still over the limit, so
|
|
195
|
+
// ordinary prose splits where a listener expects a breath.
|
|
196
|
+
export function splitSentences(text, maxChars = 300) {
|
|
197
|
+
const bound = (s, seps) => {
|
|
198
|
+
if (s.length <= maxChars) return [s];
|
|
199
|
+
if (!seps.length) {
|
|
200
|
+
// A single unbroken run longer than the window (a URL, a base64 blob).
|
|
201
|
+
// Slicing it is ugly to listen to, but losing its tail is worse.
|
|
202
|
+
const out = [];
|
|
203
|
+
for (let i = 0; i < s.length; i += maxChars) out.push(s.slice(i, i + maxChars).trim());
|
|
204
|
+
return out.filter(Boolean);
|
|
205
|
+
}
|
|
206
|
+
const [sep, ...rest] = seps;
|
|
207
|
+
const out = [];
|
|
208
|
+
let buf = '';
|
|
209
|
+
for (const piece of s.split(sep)) {
|
|
210
|
+
const p = piece.trim();
|
|
211
|
+
if (!p) continue;
|
|
212
|
+
const cand = buf ? `${buf} ${p}` : p;
|
|
213
|
+
if (cand.length > maxChars && buf) { out.push(...bound(buf, rest)); buf = p; }
|
|
214
|
+
else buf = cand;
|
|
215
|
+
}
|
|
216
|
+
if (buf) out.push(...bound(buf, rest));
|
|
217
|
+
return out;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const parts = [];
|
|
221
|
+
for (const raw of String(text).split(/(?<=[.!?])\s+|\n{2,}/)) {
|
|
222
|
+
const s = raw.trim();
|
|
223
|
+
if (s) parts.push(...bound(s, [/(?<=[,;:])\s+/, /\s+/]));
|
|
224
|
+
}
|
|
225
|
+
return parts;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Synthesize ONE chunk. Returns 24 kHz mono Float32 PCM.
|
|
230
|
+
* @param {string} text @param {{voice?: string, speed?: number}} [opts]
|
|
231
|
+
*/
|
|
232
|
+
export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1 } = {}) {
|
|
233
|
+
if (!isReady()) throw new Error('tts model not ready');
|
|
234
|
+
const { phonemize } = await import('phonemizer');
|
|
235
|
+
const tf = await import('@huggingface/transformers');
|
|
236
|
+
|
|
237
|
+
// G2P follows the VOICE, not the request — an American voice reading British
|
|
238
|
+
// phonemes is audibly wrong.
|
|
239
|
+
const phonemes = (await phonemize(String(text), voiceLang(voice))).join(' ');
|
|
240
|
+
const { input_ids } = _tok(phonemes, { truncation: true });
|
|
241
|
+
|
|
242
|
+
const bank = await loadVoice(voice);
|
|
243
|
+
// One style row per token count; clamp so a long chunk still picks a valid row.
|
|
244
|
+
const n = Math.min(Math.max(input_ids.dims.at(-1) - 2, 0), Math.floor(bank.length / STYLE_DIM) - 1);
|
|
245
|
+
const style = bank.slice(n * STYLE_DIM, n * STYLE_DIM + STYLE_DIM);
|
|
246
|
+
|
|
247
|
+
const out = await _net({
|
|
248
|
+
input_ids,
|
|
249
|
+
style: new tf.Tensor('float32', style, [1, STYLE_DIM]),
|
|
250
|
+
speed: new tf.Tensor('float32', [speed], [1]),
|
|
251
|
+
});
|
|
252
|
+
return out.waveform.data;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Synthesize arbitrary-length text, chunk by chunk. `onChunk` sees each as it lands. */
|
|
256
|
+
export async function synth(text, { voice = DEFAULT_TTS_VOICE, speed = 1, onChunk = null } = {}) {
|
|
257
|
+
const chunks = splitSentences(text);
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const c of chunks) {
|
|
260
|
+
const pcm = await synthChunk(c, { voice, speed });
|
|
261
|
+
out.push(pcm);
|
|
262
|
+
onChunk?.(pcm);
|
|
263
|
+
}
|
|
264
|
+
if (out.length === 1) return out[0];
|
|
265
|
+
const total = out.reduce((n, a) => n + a.length, 0);
|
|
266
|
+
const merged = new Float32Array(total);
|
|
267
|
+
let at = 0;
|
|
268
|
+
for (const a of out) { merged.set(a, at); at += a.length; }
|
|
269
|
+
return merged;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── WAV ──────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// 16-bit PCM WAV: what every <audio> element and every OS player accepts without a
|
|
274
|
+
// codec. Float32 → int16 with clamping (a value outside [-1,1] wraps and clicks).
|
|
275
|
+
export function toWav(pcm, sampleRate = SAMPLE_RATE) {
|
|
276
|
+
const buf = Buffer.alloc(44 + pcm.length * 2);
|
|
277
|
+
buf.write('RIFF', 0);
|
|
278
|
+
buf.writeUInt32LE(36 + pcm.length * 2, 4);
|
|
279
|
+
buf.write('WAVE', 8);
|
|
280
|
+
buf.write('fmt ', 12);
|
|
281
|
+
buf.writeUInt32LE(16, 16); // PCM chunk size
|
|
282
|
+
buf.writeUInt16LE(1, 20); // format = PCM
|
|
283
|
+
buf.writeUInt16LE(1, 22); // channels = mono
|
|
284
|
+
buf.writeUInt32LE(sampleRate, 24);
|
|
285
|
+
buf.writeUInt32LE(sampleRate * 2, 28); // byte rate
|
|
286
|
+
buf.writeUInt16LE(2, 32); // block align
|
|
287
|
+
buf.writeUInt16LE(16, 34); // bits per sample
|
|
288
|
+
buf.write('data', 36);
|
|
289
|
+
buf.writeUInt32LE(pcm.length * 2, 40);
|
|
290
|
+
for (let i = 0; i < pcm.length; i++) {
|
|
291
|
+
const s = Math.max(-1, Math.min(1, pcm[i]));
|
|
292
|
+
buf.writeInt16LE(Math.round(s * 32767), 44 + i * 2);
|
|
293
|
+
}
|
|
294
|
+
return buf;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function _reset() {
|
|
298
|
+
_state = 'off'; _model = null; _net = null; _tok = null; _dtype = null;
|
|
299
|
+
_err = null; _progress = null; _initPromise = null; _voices.clear();
|
|
300
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Catalog of text-to-speech models the gateway can run — Phase 4 of the voice
|
|
2
|
+
// pipeline (docs/voice-pipeline.md), the "voice out" counterpart to stt-models.js.
|
|
3
|
+
// Same engine family (ONNX via transformers.js), same model root, same download
|
|
4
|
+
// plumbing, same picker shape — so the Gateway tab renders it like the STT list.
|
|
5
|
+
//
|
|
6
|
+
// Why Kokoro and not the piper-class voices the design doc originally named: piper
|
|
7
|
+
// phonemizes through `piper-phonemize`, a NATIVE espeak-ng binding, and the
|
|
8
|
+
// standalone binary is a Bun --compile artifact that cannot carry native addons
|
|
9
|
+
// (it is why scripts/build.mjs stubs onnxruntime-node and sharp). Kokoro's G2P is
|
|
10
|
+
// the pure-JS `phonemizer` package — zero dependencies — so it is the one that
|
|
11
|
+
// actually runs on BOTH delivery channels. It is also Apache-2.0, 82M params, and
|
|
12
|
+
// ships fp32 + quantized ONNX exports, which is exactly the dual-runtime split
|
|
13
|
+
// model-runtime.js already encodes.
|
|
14
|
+
//
|
|
15
|
+
// Adding a model: it must expose `onnx/model{suffix}.onnx` and a tokenizer, and be
|
|
16
|
+
// driveable by a transformers.js class (Kokoro = StyleTextToSpeech2Model). Verify
|
|
17
|
+
// it loads on BOTH runtimes (native q8 + WASM fp32) before listing it.
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_TTS_MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
|
|
20
|
+
export const DEFAULT_TTS_VOICE = 'af_heart';
|
|
21
|
+
|
|
22
|
+
// Style-vector width in a voices/*.bin file, and the max token window a single
|
|
23
|
+
// forward pass accepts. Both are properties of the Kokoro export, and the engine
|
|
24
|
+
// needs them to slice the voice and to chunk long text.
|
|
25
|
+
export const STYLE_DIM = 256;
|
|
26
|
+
export const MAX_PHONEME_TOKENS = 510;
|
|
27
|
+
|
|
28
|
+
// Upper bound on one /tts request. Long text is CHUNKED rather than rejected, so
|
|
29
|
+
// this is not a model limit — it is a fairness limit: synthesis is single-engine
|
|
30
|
+
// and roughly realtime, so an unbounded body would hold it for minutes.
|
|
31
|
+
export const MAX_TTS_CHARS = 5000;
|
|
32
|
+
|
|
33
|
+
export const TTS_MODEL_CATALOG = [
|
|
34
|
+
{
|
|
35
|
+
id: 'onnx-community/Kokoro-82M-v1.0-ONNX',
|
|
36
|
+
label: 'Kokoro 82M (v1.0)',
|
|
37
|
+
lang: 'English (US/UK)',
|
|
38
|
+
tier: 'balanced',
|
|
39
|
+
arch: 'style-tts2',
|
|
40
|
+
approxMB: 330, // fp32 on WASM; ~90 on native q8
|
|
41
|
+
ramMB: 600,
|
|
42
|
+
sampleRate: 24000,
|
|
43
|
+
note: 'Apache-2.0, 82M params. Natural voices at ~5× realtime on CPU. The default.',
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Voice prefix → the language phonemizer must use for G2P. First letter = language,
|
|
48
|
+
// second = gender (f/m). Only the English families are listed: the other Kokoro
|
|
49
|
+
// voices (es/fr/hi/it/ja/pt/zh) need misaki-class G2P, which has no dependency-free
|
|
50
|
+
// JS port — shipping them against an English phonemizer would produce confident
|
|
51
|
+
// gibberish, which is worse than not offering them.
|
|
52
|
+
const VOICE_LANG = { a: 'en-us', b: 'en-gb' };
|
|
53
|
+
|
|
54
|
+
// `grade` is Kokoro's own published quality rating for the voice. Ordered best-first
|
|
55
|
+
// so the picker's default ordering is already the useful one.
|
|
56
|
+
export const TTS_VOICES = [
|
|
57
|
+
{ id: 'af_heart', label: 'Heart (US, female)', lang: 'en-us', gender: 'female', grade: 'A' },
|
|
58
|
+
{ id: 'af_bella', label: 'Bella (US, female)', lang: 'en-us', gender: 'female', grade: 'A-' },
|
|
59
|
+
{ id: 'bf_emma', label: 'Emma (UK, female)', lang: 'en-gb', gender: 'female', grade: 'B-' },
|
|
60
|
+
{ id: 'af_nicole', label: 'Nicole (US, female)', lang: 'en-us', gender: 'female', grade: 'B-', note: 'Recorded close-mic — best on headphones.' },
|
|
61
|
+
{ id: 'af_aoede', label: 'Aoede (US, female)', lang: 'en-us', gender: 'female', grade: 'C+' },
|
|
62
|
+
{ id: 'af_kore', label: 'Kore (US, female)', lang: 'en-us', gender: 'female', grade: 'C+' },
|
|
63
|
+
{ id: 'af_sarah', label: 'Sarah (US, female)', lang: 'en-us', gender: 'female', grade: 'C+' },
|
|
64
|
+
{ id: 'am_michael', label: 'Michael (US, male)', lang: 'en-us', gender: 'male', grade: 'C+' },
|
|
65
|
+
{ id: 'am_fenrir', label: 'Fenrir (US, male)', lang: 'en-us', gender: 'male', grade: 'C+' },
|
|
66
|
+
{ id: 'am_puck', label: 'Puck (US, male)', lang: 'en-us', gender: 'male', grade: 'C+' },
|
|
67
|
+
{ id: 'af_nova', label: 'Nova (US, female)', lang: 'en-us', gender: 'female', grade: 'C' },
|
|
68
|
+
{ id: 'af_alloy', label: 'Alloy (US, female)', lang: 'en-us', gender: 'female', grade: 'C' },
|
|
69
|
+
{ id: 'bf_isabella', label: 'Isabella (UK, female)', lang: 'en-gb', gender: 'female', grade: 'C' },
|
|
70
|
+
{ id: 'bm_george', label: 'George (UK, male)', lang: 'en-gb', gender: 'male', grade: 'C' },
|
|
71
|
+
{ id: 'bm_fable', label: 'Fable (UK, male)', lang: 'en-gb', gender: 'male', grade: 'C' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Which engine drives a model — the same dispatch seam stt-models.js has for
|
|
75
|
+
// 'whisper' (transformers.js pipeline) vs 'parakeet-tdt' (raw onnxruntime, its own
|
|
76
|
+
// decode loop). 'style-tts2' = Kokoro through transformers.js.
|
|
77
|
+
//
|
|
78
|
+
// The seam exists because the obvious second engine is already on the table:
|
|
79
|
+
// Kyutai's Pocket TTS (MIT code, CC-BY-4.0 weights, ~100M params, streams a first
|
|
80
|
+
// chunk in ~200 ms, six languages, and — unlike Kokoro — CLONES a voice from a
|
|
81
|
+
// sample). It is not listed here because it is not implemented: its ONNX form is a
|
|
82
|
+
// five-graph bundle (text_conditioner + flow_lm_main + flow_lm_flow + mimi
|
|
83
|
+
// encoder/decoder) needing a hand-written flow-matching decode loop, from a
|
|
84
|
+
// THIRD-PARTY re-export of a gated checkpoint. That is a parakeet-engine-sized
|
|
85
|
+
// piece of work, not a catalog entry — so the seam is here and the engine is not.
|
|
86
|
+
export function ttsModelEngine(id) {
|
|
87
|
+
return ttsModel(id)?.engine || 'style-tts2';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ttsModel(id) {
|
|
91
|
+
return TTS_MODEL_CATALOG.find((m) => m.id === id) || null;
|
|
92
|
+
}
|
|
93
|
+
export function isKnownTtsModel(id) {
|
|
94
|
+
return TTS_MODEL_CATALOG.some((m) => m.id === id);
|
|
95
|
+
}
|
|
96
|
+
export function ttsModelDtype(id) {
|
|
97
|
+
return ttsModel(id)?.dtype || null;
|
|
98
|
+
}
|
|
99
|
+
export function ttsVoice(id) {
|
|
100
|
+
return TTS_VOICES.find((v) => v.id === id) || null;
|
|
101
|
+
}
|
|
102
|
+
export function isKnownVoice(id) {
|
|
103
|
+
return TTS_VOICES.some((v) => v.id === id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A voice id becomes a FILENAME (voices/<id>.bin) and a phonemizer language lookup,
|
|
107
|
+
// so it is validated by shape as well as by catalog membership — belt and braces
|
|
108
|
+
// against a traversal reaching the fetch/join even if the catalog check is ever
|
|
109
|
+
// refactored away. Kokoro ids are strictly `<lang><gender>_<name>`.
|
|
110
|
+
export function isValidVoiceId(id) {
|
|
111
|
+
return /^[a-z]{2}_[a-z0-9]+$/.test(String(id || ''));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The phonemizer language for a voice — an American voice reading British phonemes
|
|
115
|
+
// (or vice versa) is audibly wrong, so G2P follows the voice, not the request.
|
|
116
|
+
export function voiceLang(id) {
|
|
117
|
+
return VOICE_LANG[String(id || '')[0]] || 'en-us';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Same strict `org/name` shape as isValidCustomSttId — no traversal.
|
|
121
|
+
export function isValidCustomTtsId(id) {
|
|
122
|
+
const s = String(id || '');
|
|
123
|
+
return /^[A-Za-z0-9][\w.-]*\/[\w.-]+$/.test(s) && !s.includes('..');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Selectable precisions. Same ids as the STT picker (they are transformers.js
|
|
127
|
+
// dtypes, not a whisper concept) but the notes are about synthesis speed.
|
|
128
|
+
export const TTS_DTYPES = [
|
|
129
|
+
{ id: 'auto', label: 'Auto (recommended)', note: 'q8 on the native gateway, fp32 on the WASM binary.' },
|
|
130
|
+
{ id: 'q8', label: 'q8 — fast, balanced', note: 'Int8. Best speed/quality trade-off (native default).' },
|
|
131
|
+
{ id: 'q4', label: 'q4 — smallest & fastest', note: '4-bit. Least memory; audibly rougher.' },
|
|
132
|
+
{ id: 'fp16', label: 'fp16 — more accurate', note: 'Half precision. Larger, a bit slower.' },
|
|
133
|
+
{ id: 'fp32', label: 'fp32 — best quality (slow)', note: 'Full precision. The only one that loads on the WASM binary.' },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
export function isValidTtsDtype(d) {
|
|
137
|
+
return TTS_DTYPES.some((x) => x.id === d);
|
|
138
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Remote text-to-speech destinations — ElevenLabs, OpenAI, or anything that speaks
|
|
2
|
+
// the OpenAI /audio/speech shape (Groq, a local server, an OpenAI-compatible proxy).
|
|
3
|
+
//
|
|
4
|
+
// TWO RULES, and they are the whole reason this is a separate module from
|
|
5
|
+
// tts-engine.js rather than a branch inside it.
|
|
6
|
+
//
|
|
7
|
+
// 1. NO KEY LIVES HERE. config.js says it outright — "the gateway forwards the
|
|
8
|
+
// CLIENT's own auth header upstream (it stores no provider keys)" — and a TTS
|
|
9
|
+
// destination is not the place to break that. The caller sends its own
|
|
10
|
+
// Authorization (or xi-api-key) and we pass it through. Config holds the ROUTE.
|
|
11
|
+
//
|
|
12
|
+
// 2. THE PRIVACY DECISION INVERTS. Local synthesis speaks restored text because the
|
|
13
|
+
// words never leave the machine; there is nothing to protect them from. The
|
|
14
|
+
// moment the destination is remote, the text IS leaving, to a vendor, and every
|
|
15
|
+
// other egress in this gateway is redacted first. So remote synthesis redacts by
|
|
16
|
+
// default.
|
|
17
|
+
//
|
|
18
|
+
// That trade is real and cannot be papered over: audio cannot be un-redacted the
|
|
19
|
+
// way a text completion can, so a redacted remote voice literally says
|
|
20
|
+
// "PERSON_1". The honest options are (a) placeholders spoken aloud, or (b) real
|
|
21
|
+
// PII sent to a vendor — and the user picks, per destination, with `redact`. The
|
|
22
|
+
// default is the safe one and the choice is logged via onEgress, so sending real
|
|
23
|
+
// names to ElevenLabs is something someone DID, not something that happened.
|
|
24
|
+
|
|
25
|
+
import { secureFetch } from './secure-fetch.js';
|
|
26
|
+
import { redactSegments, segment } from './redact.js';
|
|
27
|
+
|
|
28
|
+
export const TTS_PROVIDERS = ['local', 'openai', 'elevenlabs'];
|
|
29
|
+
|
|
30
|
+
export function isValidTtsProvider(p) {
|
|
31
|
+
return TTS_PROVIDERS.includes(String(p || ''));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The configured destination, or null when synthesis is local (the default). */
|
|
35
|
+
export function ttsDestination(cfg = {}) {
|
|
36
|
+
const t = cfg.tts || {};
|
|
37
|
+
const kind = t.provider || 'local';
|
|
38
|
+
if (kind === 'local' || !isValidTtsProvider(kind)) return null;
|
|
39
|
+
const r = t.remote || {};
|
|
40
|
+
return {
|
|
41
|
+
kind,
|
|
42
|
+
baseUrl: String(r.baseUrl || (kind === 'elevenlabs' ? 'https://api.elevenlabs.io/v1' : 'https://api.openai.com/v1')).replace(/\/+$/, ''),
|
|
43
|
+
model: r.model || (kind === 'elevenlabs' ? 'eleven_multilingual_v2' : 'tts-1'),
|
|
44
|
+
voice: r.voice || (kind === 'elevenlabs' ? '21m00Tcm4TlvDq8ikWAM' : 'alloy'),
|
|
45
|
+
// Undefined means "not set", which must read as ON — an absent flag is never
|
|
46
|
+
// permission to send someone's name to a vendor.
|
|
47
|
+
redact: r.redact !== false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A remote voice id lands in a URL PATH for ElevenLabs, so it is shape-checked
|
|
52
|
+
// rather than trusted: no slashes, no traversal, no query smuggling.
|
|
53
|
+
export function isValidRemoteVoice(v) {
|
|
54
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(String(v || ''));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Synthesize remotely. Returns { audio: Buffer, contentType, redacted } — `redacted`
|
|
59
|
+
* is the count of values replaced, so the caller can report what actually left.
|
|
60
|
+
*
|
|
61
|
+
* @param {{dest: any, text: string, voice?: string, speed?: number, auth?: string,
|
|
62
|
+
* redaction?: any, isPro?: boolean, onEgress?: Function, fetchImpl?: Function}} opts
|
|
63
|
+
*/
|
|
64
|
+
export async function synthesizeRemote({ dest, text, voice, speed = 1, auth, redaction, isPro = true, onEgress = null, fetchImpl = null } = {}) {
|
|
65
|
+
if (!dest) throw new Error('no remote TTS destination configured');
|
|
66
|
+
const pick = voice || dest.voice;
|
|
67
|
+
if (!isValidRemoteVoice(pick)) throw new Error(`invalid remote voice id: ${pick}`);
|
|
68
|
+
|
|
69
|
+
// Redact BEFORE anything is built, so there is no path where the raw string
|
|
70
|
+
// reaches a request body by accident.
|
|
71
|
+
let out = String(text);
|
|
72
|
+
let redacted = 0;
|
|
73
|
+
if (dest.redact && redaction) {
|
|
74
|
+
const box = { text: out };
|
|
75
|
+
const r = await redactSegments([segment(() => box.text, (v) => { box.text = v; })], redaction, { isPro, onEgress });
|
|
76
|
+
out = box.text;
|
|
77
|
+
redacted = r?.count || 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fetcher = fetchImpl || secureFetch;
|
|
81
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
82
|
+
let url, body;
|
|
83
|
+
if (dest.kind === 'elevenlabs') {
|
|
84
|
+
url = `${dest.baseUrl}/text-to-speech/${encodeURIComponent(pick)}`;
|
|
85
|
+
body = { text: out, model_id: dest.model };
|
|
86
|
+
// ElevenLabs uses its own header. Accept either form from the caller so a
|
|
87
|
+
// generic OpenAI client can drive it too.
|
|
88
|
+
const key = stripBearer(auth);
|
|
89
|
+
if (key) headers['xi-api-key'] = key;
|
|
90
|
+
} else {
|
|
91
|
+
url = `${dest.baseUrl}/audio/speech`;
|
|
92
|
+
body = { model: dest.model, input: out, voice: pick, response_format: 'wav', speed };
|
|
93
|
+
if (auth) headers.Authorization = auth.startsWith('Bearer ') ? auth : `Bearer ${auth}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const res = await fetcher(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
let detail = `HTTP ${res.status}`;
|
|
99
|
+
try { detail = (await res.text()).slice(0, 300) || detail; } catch { /* no body */ }
|
|
100
|
+
throw new Error(`remote tts failed: ${detail}`);
|
|
101
|
+
}
|
|
102
|
+
const audio = Buffer.from(await res.arrayBuffer());
|
|
103
|
+
return { audio, contentType: res.headers.get('content-type') || 'audio/mpeg', redacted };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stripBearer(a) {
|
|
107
|
+
const s = String(a || '').trim();
|
|
108
|
+
return s.toLowerCase().startsWith('bearer ') ? s.slice(7).trim() : s;
|
|
109
|
+
}
|