@chatpanel/gateway 0.6.50 → 0.6.52
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 +14 -0
- package/src/server.js +43 -7
- package/src/tts-engine.js +54 -12
- package/src/tts-models.js +37 -1
- package/src/tts-remote.js +109 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.52",
|
|
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
|
@@ -111,9 +111,23 @@ const DEFAULTS = {
|
|
|
111
111
|
// as stt: the model downloads on FIRST synthesis, never on gateway boot.
|
|
112
112
|
tts: {
|
|
113
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',
|
|
114
119
|
model: 'onnx-community/Kokoro-82M-v1.0-ONNX',
|
|
115
120
|
voice: 'af_heart',
|
|
116
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
|
+
},
|
|
117
131
|
},
|
|
118
132
|
|
|
119
133
|
// Log one line per request (method, tokens redacted) without any raw values.
|
package/src/server.js
CHANGED
|
@@ -41,6 +41,7 @@ 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
42
|
import * as ttsEngine from './tts-engine.js';
|
|
43
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';
|
|
44
45
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
45
46
|
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
|
|
46
47
|
import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
|
|
@@ -51,7 +52,7 @@ import * as openai from './openai.js';
|
|
|
51
52
|
import * as responses from './responses.js';
|
|
52
53
|
import * as anthropic from './anthropic.js';
|
|
53
54
|
|
|
54
|
-
export const VERSION = '0.6.
|
|
55
|
+
export const VERSION = '0.6.52';
|
|
55
56
|
|
|
56
57
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
57
58
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -980,8 +981,13 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
980
981
|
progress: ttsEngine.progress(),
|
|
981
982
|
available,
|
|
982
983
|
voice: cfg.tts?.voice || DEFAULT_TTS_VOICE,
|
|
983
|
-
//
|
|
984
|
-
|
|
984
|
+
// Architecture decides whether voices mean anything: Kokoro picks one from
|
|
985
|
+
// a style bank, VITS/MMS is single-speaker. An empty list tells the UI to
|
|
986
|
+
// hide the picker rather than offer choices that cannot take effect.
|
|
987
|
+
arch: ttsEngine.arch(),
|
|
988
|
+
supportsVoices: ttsEngine.supportsVoices(),
|
|
989
|
+
sampleRate: ttsEngine.sampleRate(),
|
|
990
|
+
voices: ttsEngine.arch() === 'vits' ? [] : TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
|
|
985
991
|
dtype: cfg.tts?.dtype || 'auto',
|
|
986
992
|
loadedDtype: ttsEngine.health().dtype,
|
|
987
993
|
runtime: ttsEngine.health().runtime,
|
|
@@ -1025,14 +1031,41 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1025
1031
|
const text = body && typeof (body.input ?? body.text) === 'string' ? String(body.input ?? body.text).trim() : '';
|
|
1026
1032
|
if (!text) return sendJson(res, 400, { error: { message: 'text is required', type: 'bad_request' } });
|
|
1027
1033
|
if (text.length > MAX_TTS_CHARS) return sendJson(res, 413, { error: { message: `text too long (max ${MAX_TTS_CHARS} chars)`, type: 'too_long' } });
|
|
1028
|
-
const voice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : (cfg.tts?.voice || DEFAULT_TTS_VOICE);
|
|
1029
|
-
if (!(isKnownVoice(voice) && isValidVoiceId(voice))) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1030
1034
|
const speed = Number.isFinite(body.speed) ? Math.min(2, Math.max(0.5, body.speed)) : 1;
|
|
1035
|
+
const dest = ttsDestination(cfg);
|
|
1036
|
+
// A remote destination has its own voice namespace (an ElevenLabs voice id is
|
|
1037
|
+
// not a Kokoro one), so the local catalog check would reject every valid id.
|
|
1038
|
+
const rawVoice = body && typeof body.voice === 'string' && body.voice.trim() ? body.voice.trim() : null;
|
|
1039
|
+
const voice = rawVoice || (dest ? dest.voice : (cfg.tts?.voice || DEFAULT_TTS_VOICE));
|
|
1040
|
+
const voiceOk = dest ? isValidRemoteVoice(voice) : (isKnownVoice(voice) && isValidVoiceId(voice));
|
|
1041
|
+
if (!voiceOk) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
|
|
1031
1042
|
// We synthesize WAV only. Say so rather than returning WAV bytes under an mp3
|
|
1032
1043
|
// content-type — a client that trusts the header would play noise.
|
|
1033
1044
|
const fmt = body && typeof body.response_format === 'string' ? body.response_format.toLowerCase() : 'wav';
|
|
1034
1045
|
if (fmt !== 'wav' && fmt !== 'pcm') return sendJson(res, 400, { error: { message: `unsupported response_format "${fmt}" — this gateway synthesizes wav`, type: 'bad_format' } });
|
|
1035
1046
|
try {
|
|
1047
|
+
// Remote destination: the caller's own auth goes upstream, the text is
|
|
1048
|
+
// redacted first unless this destination explicitly opted out, and the
|
|
1049
|
+
// vendor's own audio format is passed straight through rather than
|
|
1050
|
+
// re-wrapped — we did not synthesize it and must not claim its container.
|
|
1051
|
+
if (dest) {
|
|
1052
|
+
const { audio, contentType, redacted } = await synthesizeRemote({
|
|
1053
|
+
dest, text, voice, speed,
|
|
1054
|
+
auth: req.headers.authorization || req.headers['xi-api-key'] || '',
|
|
1055
|
+
redaction: cfg.redaction,
|
|
1056
|
+
isPro: await resolvePro(cfg.pro?.entitlementToken),
|
|
1057
|
+
});
|
|
1058
|
+
res.writeHead(200, {
|
|
1059
|
+
'Content-Type': contentType,
|
|
1060
|
+
'Content-Length': String(audio.length),
|
|
1061
|
+
'Cache-Control': 'no-store',
|
|
1062
|
+
// Say what actually left. A caller that asked for privacy can verify it,
|
|
1063
|
+
// and one that turned it off can see that it is off.
|
|
1064
|
+
'X-Tts-Provider': dest.kind,
|
|
1065
|
+
'X-Tts-Redacted': String(redacted),
|
|
1066
|
+
});
|
|
1067
|
+
return res.end(audio);
|
|
1068
|
+
}
|
|
1036
1069
|
const ok = await ttsEngine.ready({
|
|
1037
1070
|
onLog: (m) => console.log(m),
|
|
1038
1071
|
allowDownload: cfg.tts?.allowDownload !== false,
|
|
@@ -1041,12 +1074,15 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1041
1074
|
});
|
|
1042
1075
|
if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
|
|
1043
1076
|
const pcm = await ttsEngine.synth(text, { voice, speed });
|
|
1044
|
-
|
|
1077
|
+
// The ACTIVE model's rate, not the constant: a VITS/MMS model emits 16 kHz
|
|
1078
|
+
// and writing it into a 24 kHz header plays it fast and chipmunked.
|
|
1079
|
+
const rate = ttsEngine.sampleRate();
|
|
1080
|
+
const out = fmt === 'pcm' ? Buffer.from(new Float32Array(pcm).buffer) : ttsEngine.toWav(pcm, rate);
|
|
1045
1081
|
res.writeHead(200, {
|
|
1046
1082
|
'Content-Type': fmt === 'pcm' ? 'application/octet-stream' : 'audio/wav',
|
|
1047
1083
|
'Content-Length': String(out.length),
|
|
1048
1084
|
'Cache-Control': 'no-store',
|
|
1049
|
-
'X-Tts-Sample-Rate': String(
|
|
1085
|
+
'X-Tts-Sample-Rate': String(rate),
|
|
1050
1086
|
});
|
|
1051
1087
|
return res.end(out);
|
|
1052
1088
|
} catch (e) {
|
package/src/tts-engine.js
CHANGED
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
ttsModelDtype, isKnownTtsModel, isValidVoiceId, voiceLang,
|
|
28
28
|
} from './tts-models.js';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Kokoro's rate. Kept as a named export because it is the default and several
|
|
31
|
+
// callers want a number before anything is loaded — but it is NOT universal: a
|
|
32
|
+
// VITS/MMS model outputs 16 kHz, and writing its samples into a 24 kHz WAV header
|
|
33
|
+
// plays it back fast and chipmunked. Use sampleRate() once a model is active.
|
|
34
|
+
export const SAMPLE_RATE = 24000;
|
|
31
35
|
|
|
32
36
|
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
33
37
|
let _model = null;
|
|
@@ -37,14 +41,27 @@ let _dtype = null;
|
|
|
37
41
|
let _err = null;
|
|
38
42
|
let _progress = null;
|
|
39
43
|
let _initPromise = null;
|
|
44
|
+
let _arch = null; // 'style-tts2' (Kokoro) | 'vits' (MMS et al)
|
|
45
|
+
let _rate = SAMPLE_RATE; // the ACTIVE model's output rate
|
|
40
46
|
const _voices = new Map(); // voice id → Float32Array style bank
|
|
41
47
|
|
|
48
|
+
// The architectures this engine can actually drive. Anything else is refused at
|
|
49
|
+
// load with a message naming what it is, rather than failing later inside a
|
|
50
|
+
// forward pass with a shape error nobody can act on.
|
|
51
|
+
export const SUPPORTED_ARCH = { style_text_to_speech_2: 'style-tts2', vits: 'vits' };
|
|
52
|
+
|
|
53
|
+
export function arch() { return _arch; }
|
|
54
|
+
export function sampleRate() { return _rate; }
|
|
55
|
+
// Kokoro picks a voice from a style bank; VITS is single-speaker and has none, so
|
|
56
|
+
// the UI must not offer a voice list that cannot do anything.
|
|
57
|
+
export function supportsVoices() { return _arch === 'style-tts2'; }
|
|
58
|
+
|
|
42
59
|
export function state() { return _state; }
|
|
43
60
|
export function isReady() { return _state === 'ready' && !!_net; }
|
|
44
61
|
export function progress() { return _progress; }
|
|
45
62
|
|
|
46
63
|
export function health() {
|
|
47
|
-
return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, runtime: runtimeName(), error: _err };
|
|
64
|
+
return { configured: _state !== 'off', ok: isReady(), state: _state, model: _model, dtype: _dtype, runtime: runtimeName(), error: _err, arch: _arch, sampleRate: _rate, voices: supportsVoices() };
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export function modelDir(modelId) {
|
|
@@ -119,18 +136,32 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype:
|
|
|
119
136
|
// this is the SAME transformers instance ensureLib already configured (env,
|
|
120
137
|
// cacheDir, wasm paths) — importing it here keeps ner-engine free of TTS.
|
|
121
138
|
const tf = await import('@huggingface/transformers');
|
|
139
|
+
|
|
140
|
+
// Which architecture is this? Read the config BEFORE choosing a class, so an
|
|
141
|
+
// unsupported model is refused by name instead of exploding inside a forward
|
|
142
|
+
// pass with a tensor-shape error.
|
|
143
|
+
let modelType = '';
|
|
144
|
+
try {
|
|
145
|
+
const conf = await tf.AutoConfig.from_pretrained(modelId);
|
|
146
|
+
modelType = String(conf?.model_type || '').toLowerCase();
|
|
147
|
+
} catch { /* no config we can read — fall through to the Kokoro default */ }
|
|
148
|
+
const kind = SUPPORTED_ARCH[modelType] || (modelType ? null : 'style-tts2');
|
|
149
|
+
if (!kind) throw new Error(`unsupported TTS architecture "${modelType}" — this engine drives Kokoro (style_text_to_speech_2) and VITS/MMS`);
|
|
150
|
+
|
|
151
|
+
const onProgress = (p) => {
|
|
152
|
+
if (p?.status === 'progress' && p.file) _progress = { model: modelId, file: p.file, pct: Math.round(p.progress || 0) };
|
|
153
|
+
};
|
|
154
|
+
const Klass = kind === 'vits' ? tf.VitsModel : tf.StyleTextToSpeech2Model;
|
|
122
155
|
const [net, tok] = await Promise.all([
|
|
123
|
-
|
|
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
|
-
}),
|
|
156
|
+
Klass.from_pretrained(modelId, { dtype, progress_callback: onProgress }),
|
|
129
157
|
tf.AutoTokenizer.from_pretrained(modelId),
|
|
130
158
|
]);
|
|
131
|
-
_net = net; _tok = tok; _model = modelId; _dtype = dtype;
|
|
159
|
+
_net = net; _tok = tok; _model = modelId; _dtype = dtype; _arch = kind;
|
|
160
|
+
// VITS/MMS emit 16 kHz; Kokoro 24 kHz. Take it from the model's own config
|
|
161
|
+
// where it says so, because guessing wrong plays the voice at the wrong pitch.
|
|
162
|
+
_rate = Number(net?.config?.sampling_rate) || (kind === 'vits' ? 16000 : SAMPLE_RATE);
|
|
132
163
|
_state = 'ready'; _err = null; _progress = null;
|
|
133
|
-
log(`[tts] ready — model ${modelId} @ ${dtype} (${runtimeName()}, offline) — local speech active`);
|
|
164
|
+
log(`[tts] ready — model ${modelId} @ ${dtype} (${kind}, ${_rate} Hz, ${runtimeName()}, offline) — local speech active`);
|
|
134
165
|
return true;
|
|
135
166
|
} catch (e) {
|
|
136
167
|
// A failed SWITCH keeps the previous working model, same as ner/stt.
|
|
@@ -231,9 +262,19 @@ export function splitSentences(text, maxChars = 300) {
|
|
|
231
262
|
*/
|
|
232
263
|
export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1 } = {}) {
|
|
233
264
|
if (!isReady()) throw new Error('tts model not ready');
|
|
234
|
-
const { phonemize } = await import('phonemizer');
|
|
235
265
|
const tf = await import('@huggingface/transformers');
|
|
236
266
|
|
|
267
|
+
// VITS/MMS: single-speaker, tokenizes GRAPHEMES directly — no phonemizer, no
|
|
268
|
+
// style bank, no speed input. One language per model, which is the trade for
|
|
269
|
+
// ~40 MB and a thousand of them.
|
|
270
|
+
if (_arch === 'vits') {
|
|
271
|
+
const inputs = _tok(String(text));
|
|
272
|
+
const out = await _net(inputs);
|
|
273
|
+
return out.waveform.data;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { phonemize } = await import('phonemizer');
|
|
277
|
+
|
|
237
278
|
// G2P follows the VOICE, not the request — an American voice reading British
|
|
238
279
|
// phonemes is audibly wrong.
|
|
239
280
|
const phonemes = (await phonemize(String(text), voiceLang(voice))).join(' ');
|
|
@@ -296,5 +337,6 @@ export function toWav(pcm, sampleRate = SAMPLE_RATE) {
|
|
|
296
337
|
|
|
297
338
|
export function _reset() {
|
|
298
339
|
_state = 'off'; _model = null; _net = null; _tok = null; _dtype = null;
|
|
299
|
-
_err = null; _progress = null; _initPromise = null;
|
|
340
|
+
_err = null; _progress = null; _initPromise = null; _arch = null; _rate = SAMPLE_RATE;
|
|
341
|
+
_voices.clear();
|
|
300
342
|
}
|
package/src/tts-models.js
CHANGED
|
@@ -40,8 +40,37 @@ export const TTS_MODEL_CATALOG = [
|
|
|
40
40
|
approxMB: 330, // fp32 on WASM; ~90 on native q8
|
|
41
41
|
ramMB: 600,
|
|
42
42
|
sampleRate: 24000,
|
|
43
|
+
voices: true,
|
|
43
44
|
note: 'Apache-2.0, 82M params. Natural voices at ~5× realtime on CPU. The default.',
|
|
44
45
|
},
|
|
46
|
+
{
|
|
47
|
+
id: 'onnx-community/Kokoro-82M-v1.1-zh-ONNX',
|
|
48
|
+
label: 'Kokoro 82M (v1.1, Chinese)',
|
|
49
|
+
lang: 'Chinese + English',
|
|
50
|
+
tier: 'balanced',
|
|
51
|
+
arch: 'style-tts2',
|
|
52
|
+
approxMB: 330,
|
|
53
|
+
ramMB: 600,
|
|
54
|
+
sampleRate: 24000,
|
|
55
|
+
voices: true,
|
|
56
|
+
note: 'The Mandarin-tuned Kokoro. Same engine and voice mechanism as v1.0.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
// One MMS entry so the second architecture is DISCOVERABLE from the list rather
|
|
60
|
+
// than only findable by search. The rest of the family (~1000 languages) is
|
|
61
|
+
// exactly what the search box is for — listing them all here would be a menu,
|
|
62
|
+
// not a catalog.
|
|
63
|
+
id: 'Xenova/mms-tts-hin',
|
|
64
|
+
label: 'MMS TTS — Hindi',
|
|
65
|
+
lang: 'Hindi (हिन्दी)',
|
|
66
|
+
tier: 'light',
|
|
67
|
+
arch: 'vits',
|
|
68
|
+
approxMB: 40,
|
|
69
|
+
ramMB: 200,
|
|
70
|
+
sampleRate: 16000,
|
|
71
|
+
voices: false,
|
|
72
|
+
note: 'Tiny single-speaker VITS. Meta\u2019s MMS covers ~1000 languages — search "mms-tts" for yours.',
|
|
73
|
+
},
|
|
45
74
|
];
|
|
46
75
|
|
|
47
76
|
// Voice prefix → the language phonemizer must use for G2P. First letter = language,
|
|
@@ -84,7 +113,14 @@ export const TTS_VOICES = [
|
|
|
84
113
|
// THIRD-PARTY re-export of a gated checkpoint. That is a parakeet-engine-sized
|
|
85
114
|
// piece of work, not a catalog entry — so the seam is here and the engine is not.
|
|
86
115
|
export function ttsModelEngine(id) {
|
|
87
|
-
return ttsModel(id)?.
|
|
116
|
+
return ttsModel(id)?.arch || 'style-tts2';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Does this catalog entry have selectable voices? Kokoro picks one from a style
|
|
120
|
+
// bank; VITS/MMS is single-speaker. Unknown (a searched model) resolves at load.
|
|
121
|
+
export function ttsModelHasVoices(id) {
|
|
122
|
+
const m = ttsModel(id);
|
|
123
|
+
return m ? m.voices !== false : true;
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
export function ttsModel(id) {
|
|
@@ -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
|
+
}
|