@chatpanel/gateway 0.6.51 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.51",
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/server.js CHANGED
@@ -52,7 +52,7 @@ import * as openai from './openai.js';
52
52
  import * as responses from './responses.js';
53
53
  import * as anthropic from './anthropic.js';
54
54
 
55
- export const VERSION = '0.6.51';
55
+ export const VERSION = '0.6.52';
56
56
 
57
57
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
58
58
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -981,8 +981,13 @@ export function createGateway(cfg = loadConfig()) {
981
981
  progress: ttsEngine.progress(),
982
982
  available,
983
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) })),
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) })),
986
991
  dtype: cfg.tts?.dtype || 'auto',
987
992
  loadedDtype: ttsEngine.health().dtype,
988
993
  runtime: ttsEngine.health().runtime,
@@ -1069,12 +1074,15 @@ export function createGateway(cfg = loadConfig()) {
1069
1074
  });
1070
1075
  if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
1071
1076
  const pcm = await ttsEngine.synth(text, { voice, speed });
1072
- const out = fmt === 'pcm' ? Buffer.from(new Float32Array(pcm).buffer) : ttsEngine.toWav(pcm);
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);
1073
1081
  res.writeHead(200, {
1074
1082
  'Content-Type': fmt === 'pcm' ? 'application/octet-stream' : 'audio/wav',
1075
1083
  'Content-Length': String(out.length),
1076
1084
  'Cache-Control': 'no-store',
1077
- 'X-Tts-Sample-Rate': String(ttsEngine.SAMPLE_RATE),
1085
+ 'X-Tts-Sample-Rate': String(rate),
1078
1086
  });
1079
1087
  return res.end(out);
1080
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
- export const SAMPLE_RATE = 24000; // Kokoro's output rate fixed by the model
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
- 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
- }),
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; _voices.clear();
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)?.engine || 'style-tts2';
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) {