@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.49",
4
- "description": "Local privacy gateway \u2014 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.",
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.49';
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
- // The right whisper quantization depends on the ONNX RUNTIME, not the OS:
31
- // native onnxruntime-node (the npm gateway) loads the small, fast `q8`
32
- // (_quantized) exports best size + speed.
33
- // • onnxruntime-web WASM (the standalone binary — SAME wasm on macOS/Windows/
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
+ }