@chatpanel/gateway 0.6.56 → 0.6.58

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.56",
3
+ "version": "0.6.58",
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
@@ -119,8 +119,13 @@ export const DEFAULTS = {
119
119
  // client as everywhere else here; no key is stored. See src/tts-remote.js for
120
120
  // why remote synthesis redacts by default and what that costs.
121
121
  provider: 'local',
122
- model: 'onnx-community/Kokoro-82M-v1.0-ONNX',
123
- voice: 'af_heart',
122
+ // Empty = decide at runtime. Pocket TTS is the better default (fastest here,
123
+ // and the only engine that can speak as you) but needs the native onnxruntime,
124
+ // which the standalone binary does not carry — so hardcoding either one is
125
+ // wrong for half the installs. resolveDefaultModel() picks per runtime, and an
126
+ // explicit choice here always wins.
127
+ model: '',
128
+ voice: '',
124
129
  allowDownload: true,
125
130
  remote: {
126
131
  baseUrl: '', // '' = the provider's own default endpoint
package/src/ort.js ADDED
@@ -0,0 +1,52 @@
1
+ // One place to get an onnxruntime, for the engines that drive raw ONNX graphs:
2
+ // parakeet (STT) and pocket-tts (TTS). Transformers.js brings its own; these do
3
+ // not, and they were each resolving it separately.
4
+ //
5
+ // KNOWN LIMITATION — raw ORT does not work inside the standalone binary.
6
+ // The Bun build embeds the ORT runtime as a virtual path (.wasm) and a blob: URL
7
+ // (.mjs), and onnxruntime-web reaches for both with fetch(), which serves neither.
8
+ // Handing it the wasm BYTES, pointing wasmPaths at an extracted directory, and
9
+ // importing the wasm-only bundle were all tried; each moves the failure without
10
+ // removing it. Transformers.js works there because it configures its own bundled
11
+ // copy through its own env.
12
+ //
13
+ // This is NOT new — parakeet has had it since it shipped; it simply failed as an
14
+ // opaque "no available backend found" instead of saying so. Models that need this
15
+ // runtime are now marked `requiresNative` in their catalogs and are not offered on
16
+ // the binary, so the npm gateway is the answer rather than a broken download.
17
+
18
+ let _promise = null;
19
+
20
+ /** Is a raw-ONNX engine usable in this process? False inside the binary. */
21
+ export function rawOrtAvailable() {
22
+ return !globalThis.__CHATPANEL_WASM_PATHS__;
23
+ }
24
+
25
+ export function ortRuntimeName() {
26
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? 'wasm' : 'native';
27
+ }
28
+
29
+ // 'wasm' in the binary, 'cpu' on the native build. The wrong one makes ORT fall
30
+ // through its provider list and report a confusing "no available backend".
31
+ export function ortProviders() {
32
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? ['wasm'] : ['cpu'];
33
+ }
34
+
35
+ export function getOrt() {
36
+ if (_promise) return _promise;
37
+ _promise = (async () => {
38
+ const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
39
+ if (!wasmPaths) {
40
+ const mod = await import('onnxruntime-node');
41
+ return mod.InferenceSession ? mod : (mod.default || mod);
42
+ }
43
+
44
+ // Say what is actually wrong. ORT's own message describes a failed fetch and
45
+ // sends people looking for a corrupt download.
46
+ throw new Error(
47
+ 'this model needs the native onnxruntime, which the standalone binary does not carry — '
48
+ + 'install the npm gateway instead (npm i -g @chatpanel/gateway), or pick a model that runs on the bundled runtime',
49
+ );
50
+ })();
51
+ return _promise;
52
+ }
@@ -26,6 +26,7 @@ import { join } from 'node:path';
26
26
  import { existsSync, mkdirSync, readFileSync, createWriteStream, renameSync, statSync } from 'node:fs';
27
27
  import { Readable } from 'node:stream';
28
28
  import { modelRoot } from './ner-engine.js';
29
+ import { getOrt, ortProviders } from './ort.js';
29
30
 
30
31
  // transformers.js reports these `model_type`s for the transducer exports. Any of them
31
32
  // means "not a whisper pipeline model — route here instead".
@@ -72,20 +73,6 @@ export function parakeetOnDisk(modelId, dtype = PARAKEET_DEFAULT_DTYPE) {
72
73
  // The npm gateway uses native onnxruntime-node (fast). The standalone binary embeds the
73
74
  // onnxruntime-web WASM runtime and hands us its paths via __CHATPANEL_WASM_PATHS__ (the
74
75
  // same global ner-engine keys off) — configure ORT-web from it. Memoized once.
75
- let _ortPromise = null;
76
- function getOrt() {
77
- if (_ortPromise) return _ortPromise;
78
- _ortPromise = (async () => {
79
- const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
80
- const mod = await import(wasmPaths ? 'onnxruntime-web' : 'onnxruntime-node');
81
- const ort = mod.InferenceSession ? mod : (mod.default || mod);
82
- if (wasmPaths) {
83
- try { ort.env.wasm.numThreads = 1; ort.env.wasm.proxy = false; ort.env.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
84
- }
85
- return ort;
86
- })();
87
- return _ortPromise;
88
- }
89
76
 
90
77
  // ── download (only when a model isn't already on disk) ───────────────────────────────
91
78
  // Custom/BYO STT ids aren't on the dl.chatpanel.net mirror, so — like stt-engine's
@@ -166,7 +153,7 @@ export async function loadRecognizer({ modelId, dtype = PARAKEET_DEFAULT_DTYPE,
166
153
 
167
154
  const ort = await getOrt();
168
155
  const s = dt === 'fp32' ? '' : '.int8';
169
- const opts = { executionProviders: ['cpu'], graphOptimizationLevel: 'all', logSeverityLevel: 3 };
156
+ const opts = { executionProviders: ortProviders(), graphOptimizationLevel: 'all', logSeverityLevel: 3 };
170
157
  const [prep, encoder, decoder] = await Promise.all([
171
158
  ort.InferenceSession.create(join(dir, 'nemo128.onnx'), opts),
172
159
  ort.InferenceSession.create(join(dir, `encoder-model${s}.onnx`), opts),
@@ -27,8 +27,12 @@ import { existsSync, mkdirSync, readFileSync, statSync, createWriteStream, renam
27
27
  import { Readable } from 'node:stream';
28
28
  import { modelRoot } from './ner-engine.js';
29
29
  import { SentencePieceUnigram } from './sentencepiece.js';
30
+ import { getOrt, ortProviders } from './ort.js';
30
31
 
31
32
  export const POCKET_REPO = 'KevinAHM/pocket-tts-onnx';
33
+ // The built-in speakers live in the demo Space, not the weights repo — the weights
34
+ // repo 404s on voices.bin. Same author, same licence, different HF namespace.
35
+ export const POCKET_VOICES_SPACE = 'KevinAHM/pocket-tts-web';
32
36
  export const DEFAULT_BUNDLE = 'english_2026-04';
33
37
  export const SAMPLE_RATE = 24000;
34
38
 
@@ -43,30 +47,16 @@ const NORMAL_CHUNK_FRAMES = 12;
43
47
  // sentence's trailing state colour the next one's opening.
44
48
  const RESET_STATE_EACH_CHUNK = true;
45
49
 
46
- // voices.bin (the reference implementation's PREDEFINED speakers) is deliberately
47
- // absent: it lives only in the demo Space, not the weights repo, and this engine
48
- // exists to speak in a voice the user recorded. Kokoro already covers "pick a
49
- // stock voice", and far better.
50
50
  const FILES = (q = '_int8') => [
51
51
  'bundle.json', 'tokenizer.model', 'bos_before_voice.npy',
52
52
  `text_conditioner${q}.onnx`, `mimi_encoder${q}.onnx`, `mimi_decoder${q}.onnx`,
53
53
  `flow_lm_main${q}.onnx`, `flow_lm_flow${q}.onnx`,
54
54
  ];
55
+ // voices.bin is a separate ~52 MB download for the eight built-in speakers, and it
56
+ // is OPTIONAL: cloning works without it, so a failed or skipped fetch costs the
57
+ // stock voices and nothing else.
58
+ const VOICES_FILE = 'voices.bin';
55
59
 
56
- let _ortPromise = null;
57
- function getOrt() {
58
- if (_ortPromise) return _ortPromise;
59
- _ortPromise = (async () => {
60
- const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
61
- const mod = await import(wasmPaths ? 'onnxruntime-web' : 'onnxruntime-node');
62
- const ort = mod.InferenceSession ? mod : (mod.default || mod);
63
- if (wasmPaths) {
64
- try { ort.env.wasm.numThreads = 1; ort.env.wasm.proxy = false; ort.env.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
65
- }
66
- return ort;
67
- })();
68
- return _ortPromise;
69
- }
70
60
 
71
61
  export function bundleDir(bundle = DEFAULT_BUNDLE) {
72
62
  return join(modelRoot(), 'pocket-tts', bundle);
@@ -80,7 +70,9 @@ export function bundleOnDisk(bundle = DEFAULT_BUNDLE, quant = '_int8') {
80
70
  }
81
71
 
82
72
  async function downloadFile(bundle, file, dir, { onProgress, log } = {}) {
83
- const url = `https://huggingface.co/${POCKET_REPO}/resolve/main/onnx/${bundle}/${file}`;
73
+ const url = file === VOICES_FILE
74
+ ? `https://huggingface.co/spaces/${POCKET_VOICES_SPACE}/resolve/main/onnx/${bundle}/${file}`
75
+ : `https://huggingface.co/${POCKET_REPO}/resolve/main/onnx/${bundle}/${file}`;
84
76
  const res = await fetch(url, { redirect: 'follow' });
85
77
  if (!res.ok || !res.body) throw new Error(`fetch ${file} → HTTP ${res.status}`);
86
78
  const total = Number(res.headers.get('content-length')) || 0;
@@ -110,6 +102,21 @@ export async function ensureBundle(bundle = DEFAULT_BUNDLE, quant = '_int8', { o
110
102
  return dir;
111
103
  }
112
104
 
105
+ /** Fetch the built-in speakers. Optional — failure leaves cloning fully working. */
106
+ export async function ensureVoicesBin(bundle = DEFAULT_BUNDLE, { onProgress, log } = {}) {
107
+ const dir = bundleDir(bundle);
108
+ const dest = join(dir, VOICES_FILE);
109
+ if (existsSync(dest) && statSync(dest).size > 1024) return dest;
110
+ mkdirSync(dir, { recursive: true });
111
+ log?.(`[pocket-tts] downloading ${VOICES_FILE} (built-in voices, ~52 MB)…`);
112
+ await downloadFile(bundle, VOICES_FILE, dir, { onProgress, log });
113
+ return dest;
114
+ }
115
+
116
+ export function voicesBinOnDisk(bundle = DEFAULT_BUNDLE) {
117
+ try { const p = join(bundleDir(bundle), VOICES_FILE); return existsSync(p) && statSync(p).size > 1024; } catch { return false; }
118
+ }
119
+
113
120
  // ── .npy (float32) ──────────────────────────────────────────────────────────────
114
121
  export function parseNpyFloat32(buf) {
115
122
  const magic = [0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59];
@@ -152,6 +159,102 @@ function advanceState(state, result, manifest) {
152
159
  for (const e of manifest) state[e.input_name] = result[e.output_name];
153
160
  }
154
161
 
162
+
163
+ // ── built-in speakers ───────────────────────────────────────────────────────────
164
+ // voices.bin (PTVB1) is a flat table of per-voice tensor STATES — the model's
165
+ // internal state after it has been conditioned on that speaker — rather than
166
+ // audio or embeddings. Loading one is therefore not "encode a voice" but "restore
167
+ // the state a voice produces", which is why it takes a different path from cloning.
168
+ export function parseVoicesBin(buf) {
169
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
170
+ const view = new DataView(ab);
171
+ let off = 0;
172
+ const magic = new TextDecoder().decode(new Uint8Array(ab, 0, 5));
173
+ off += 5;
174
+ if (magic !== 'PTVB1') throw new Error('not a voices.bin (bad header)');
175
+ const voices = {};
176
+ const count = view.getUint32(off, true); off += 4;
177
+ for (let v = 0; v < count; v++) {
178
+ const nameLen = view.getUint16(off, true); off += 2;
179
+ const name = new TextDecoder().decode(new Uint8Array(ab, off, nameLen)); off += nameLen;
180
+ const tensorCount = view.getUint16(off, true); off += 2;
181
+ const tensors = {};
182
+ for (let t = 0; t < tensorCount; t++) {
183
+ const keyLen = view.getUint16(off, true); off += 2;
184
+ const key = new TextDecoder().decode(new Uint8Array(ab, off, keyLen)); off += keyLen;
185
+ const dtypeCode = view.getUint8(off); off += 1;
186
+ const rank = view.getUint8(off); off += 1;
187
+ const shape = [];
188
+ for (let d = 0; d < rank; d++) { shape.push(view.getUint32(off, true)); off += 4; }
189
+ const byteLength = view.getUint32(off, true); off += 4;
190
+ const slice = ab.slice(off, off + byteLength);
191
+ const data = dtypeCode === 0 ? new Float32Array(slice)
192
+ : dtypeCode === 1 ? new BigInt64Array(slice)
193
+ : dtypeCode === 2 ? new Uint8Array(slice)
194
+ : (() => { throw new Error(`unsupported voices.bin dtype ${dtypeCode}`); })();
195
+ off += byteLength;
196
+ tensors[key] = { data, shape, dtype: dtypeCode === 0 ? 'float32' : dtypeCode === 1 ? 'int64' : 'bool' };
197
+ }
198
+ voices[name] = tensors;
199
+ }
200
+ return voices;
201
+ }
202
+
203
+ // A saved state is keyed "module/tensor"; the manifest addresses the same tensors
204
+ // by module + key, so the flat table is regrouped before it can be matched.
205
+ function groupByModule(record) {
206
+ const grouped = {};
207
+ for (const [k, v] of Object.entries(record)) {
208
+ const i = k.indexOf('/');
209
+ if (i === -1) continue;
210
+ (grouped[k.slice(0, i)] ||= {})[k.slice(i + 1)] = v;
211
+ }
212
+ return grouped;
213
+ }
214
+
215
+ // Some modules record their position under a different name (or not at all), so
216
+ // the manifest's `step` is derived from whichever the module actually kept.
217
+ function deriveStep(moduleState) {
218
+ if (moduleState.step) return { data: BigInt64Array.from([BigInt(moduleState.step.data[0])]), shape: [1], dtype: 'int64' };
219
+ if (moduleState.offset && !moduleState.end_offset) return { data: BigInt64Array.from([BigInt(moduleState.offset.data[0])]), shape: [1], dtype: 'int64' };
220
+ if (moduleState.current_end) return { data: BigInt64Array.from([BigInt(moduleState.current_end.shape[0])]), shape: [1], dtype: 'int64' };
221
+ return { data: BigInt64Array.from([0n]), shape: [1], dtype: 'int64' };
222
+ }
223
+
224
+ // A saved tensor may not match the manifest's shape exactly (a shorter cache, say).
225
+ // Copy what overlaps into a correctly-shaped, correctly-filled target rather than
226
+ // handing ORT a tensor of the wrong rank.
227
+ function adaptTensor(source, entry) {
228
+ const target = filledArray(entry.shape, entry.dtype, entry.fill);
229
+ const targetSize = entry.shape.reduce((a, b) => a * b, 1);
230
+ const Ctor = entry.dtype === 'int64' ? BigInt64Array : entry.dtype === 'bool' ? Uint8Array : Float32Array;
231
+ const sameRank = source.shape.length === entry.shape.length;
232
+ if ((sameRank && source.shape.every((d, i) => d === entry.shape[i])) || source.data.length === targetSize) {
233
+ return new Ctor(source.data);
234
+ }
235
+ if (!sameRank) return target;
236
+ const strides = [];
237
+ let stride = 1;
238
+ for (let i = source.shape.length - 1; i >= 0; i--) { strides[i] = stride; stride *= source.shape[i]; }
239
+ const idx = new Array(source.shape.length).fill(0);
240
+ const max = source.shape.map((d, i) => Math.min(d, entry.shape[i]));
241
+ if (max.some((m) => m === 0)) return target;
242
+ for (;;) {
243
+ let si = 0;
244
+ for (let i = 0; i < idx.length; i++) si += idx[i] * strides[i];
245
+ let ti = 0, ts = 1;
246
+ for (let i = entry.shape.length - 1; i >= 0; i--) { ti += idx[i] * ts; ts *= entry.shape[i]; }
247
+ target[ti] = source.data[si];
248
+ let dim = idx.length - 1;
249
+ for (; dim >= 0; dim--) {
250
+ if (++idx[dim] < max[dim]) break;
251
+ idx[dim] = 0;
252
+ if (dim === 0) return target;
253
+ }
254
+ if (dim < 0) return target;
255
+ }
256
+ }
257
+
155
258
  export class PocketTTS {
156
259
  constructor() {
157
260
  this.ready = false;
@@ -164,8 +267,12 @@ export class PocketTTS {
164
267
  this.latentDim = 32;
165
268
  this.condDim = 1024;
166
269
  this.samplesPerFrame = 1920;
270
+ this.builtin = null; // name → saved tensor state, from voices.bin
167
271
  }
168
272
 
273
+ /** The built-in speaker names available, or [] if voices.bin was not fetched. */
274
+ builtinVoices() { return this.builtin ? Object.keys(this.builtin).sort() : []; }
275
+
169
276
  async load(bundle = DEFAULT_BUNDLE, { quant = '_int8', onProgress, log = () => {} } = {}) {
170
277
  const dir = await ensureBundle(bundle, quant, { onProgress, log });
171
278
  const ort = await getOrt();
@@ -176,7 +283,7 @@ export class PocketTTS {
176
283
  this.condDim = Number(this.meta.conditioning_dim) || 1024;
177
284
  this.samplesPerFrame = Math.round(SAMPLE_RATE / (Number(this.meta.frame_rate) || 12.5));
178
285
 
179
- const opts = { executionProviders: ['cpu'], graphOptimizationLevel: 'all', logSeverityLevel: 3 };
286
+ const opts = { executionProviders: ortProviders(), graphOptimizationLevel: 'all', logSeverityLevel: 3 };
180
287
  const [textConditioner, mimiEncoder, mimiDecoder, flowMain, flowFlow] = await Promise.all([
181
288
  ort.InferenceSession.create(join(dir, `text_conditioner${quant}.onnx`), opts),
182
289
  ort.InferenceSession.create(join(dir, `mimi_encoder${quant}.onnx`), opts),
@@ -197,6 +304,12 @@ export class PocketTTS {
197
304
  t: new ort.Tensor('float32', new Float32Array([s + dt]), [1, 1]),
198
305
  });
199
306
  }
307
+ // Built-in speakers are optional: a missing or unreadable voices.bin costs the
308
+ // stock voices and leaves cloning — the reason this engine exists — untouched.
309
+ try {
310
+ if (voicesBinOnDisk(bundle)) this.builtin = parseVoicesBin(readFileSync(join(dir, 'voices.bin')));
311
+ } catch (e) { log(`[pocket-tts] built-in voices unavailable (${e.message})`); }
312
+
200
313
  this.bundle = bundle;
201
314
  this.ready = true;
202
315
  log(`[pocket-tts] ready — ${bundle} (${quant.replace('_', '') || 'fp32'}, ${SAMPLE_RATE} Hz)`);
@@ -236,6 +349,22 @@ export class PocketTTS {
236
349
  return new ort.Tensor('float32', data, dims);
237
350
  }
238
351
 
352
+ // Restore the flow-LM state a built-in speaker was saved with.
353
+ #builtinState(name) {
354
+ const record = this.builtin?.[name];
355
+ if (!record) throw new Error(`unknown built-in voice: ${name}`);
356
+ const { ort } = this.sessions;
357
+ const grouped = groupByModule(record);
358
+ const state = initState(ort, this.meta.flow_lm_state_manifest);
359
+ for (const e of this.meta.flow_lm_state_manifest) {
360
+ const moduleState = grouped[e.module] || {};
361
+ const source = moduleState[e.key] || (e.key === 'step' ? deriveStep(moduleState) : null);
362
+ if (!source) continue;
363
+ state[e.input_name] = new ort.Tensor(e.dtype, adaptTensor(source, e), e.shape);
364
+ }
365
+ return state;
366
+ }
367
+
239
368
  async #voiceState(voice) {
240
369
  const { ort, flowMain } = this.sessions;
241
370
  const state = initState(ort, this.meta.flow_lm_state_manifest);
@@ -288,12 +417,17 @@ export class PocketTTS {
288
417
  */
289
418
  async synth(text, { voice, onAudio = null } = {}) {
290
419
  if (!this.ready) throw new Error('pocket-tts not loaded');
291
- if (!voice?.data?.length) throw new Error('pocket-tts needs a voice — record one');
420
+ // Either a cloned voice ({data, shape} from encodeVoice) or a built-in name.
421
+ if (typeof voice === 'string') {
422
+ if (!this.builtin?.[voice]) throw new Error(`unknown built-in voice: ${voice}`);
423
+ } else if (!voice?.data?.length) {
424
+ throw new Error('pocket-tts needs a voice — record one, or pick a built-in');
425
+ }
292
426
  const { ort, textConditioner, flowMain, flowFlow, mimiDecoder } = this.sessions;
293
427
  const prepared = this.#prepare(text);
294
428
  if (!prepared.text) return new Float32Array(0);
295
429
  const chunks = this.#chunks(prepared.text);
296
- const baseFlow = await this.#voiceState(voice);
430
+ const baseFlow = typeof voice === 'string' ? this.#builtinState(voice) : await this.#voiceState(voice);
297
431
 
298
432
  const emptySeq = new ort.Tensor('float32', new Float32Array(0), [1, 0, this.latentDim]);
299
433
  const emptyText = new ort.Tensor('float32', new Float32Array(0), [1, 0, this.condDim]);
package/src/server.js CHANGED
@@ -40,8 +40,9 @@ 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
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, ttsModelHasCustomVoices } from './tts-models.js';
43
+ import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS, ttsModelHasCustomVoices, ttsModelRequiresNative, resolveDefaultModel, POCKET_VOICES, DEFAULT_POCKET_VOICE, isPocketVoice, ttsModelEngine as ttsModelEngineOf } from './tts-models.js';
44
44
  import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
45
+ import { rawOrtAvailable } from './ort.js';
45
46
  import * as ttsVoices from './tts-voices.js';
46
47
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
47
48
  import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
@@ -53,7 +54,7 @@ import * as openai from './openai.js';
53
54
  import * as responses from './responses.js';
54
55
  import * as anthropic from './anthropic.js';
55
56
 
56
- export const VERSION = '0.6.56';
57
+ export const VERSION = '0.6.58';
57
58
 
58
59
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
59
60
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -998,8 +999,17 @@ export function createGateway(cfg = loadConfig()) {
998
999
  // docs/voice-pipeline.md. Model manager first, then synthesis.
999
1000
  if (pathname === '/tts/models') {
1000
1001
  if (req.method === 'GET') {
1001
- const active = ttsEngine.health().model || cfg.tts?.model || DEFAULT_TTS_MODEL;
1002
- const available = /** @type {any[]} */ (TTS_MODEL_CATALOG.map((m) => ({ ...m, installed: ttsEngine.modelOnDisk(m.id) })));
1002
+ const active = ttsEngine.health().model || cfg.tts?.model || resolveDefaultModel(rawOrtAvailable());
1003
+ // A model needing the native runtime is still LISTED on the binary, with the
1004
+ // reason — hiding it makes "why can't I clone my voice?" unanswerable.
1005
+ const nativeOk = rawOrtAvailable();
1006
+ const available = /** @type {any[]} */ (TTS_MODEL_CATALOG.map((m) => ({
1007
+ ...m,
1008
+ installed: ttsEngine.modelOnDisk(m.id),
1009
+ unavailable: m.requiresNative && !nativeOk
1010
+ ? 'needs the npm gateway — the standalone binary cannot load this engine'
1011
+ : undefined,
1012
+ })));
1003
1013
  if (active && !available.some((m) => m.id === active)) {
1004
1014
  available.push({ id: active, label: active, lang: '—', tier: 'custom', custom: true, installed: ttsEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
1005
1015
  }
@@ -1008,7 +1018,9 @@ export function createGateway(cfg = loadConfig()) {
1008
1018
  state: ttsEngine.state(),
1009
1019
  progress: ttsEngine.progress(),
1010
1020
  available,
1011
- voice: cfg.tts?.voice || DEFAULT_TTS_VOICE,
1021
+ // The default voice belongs to the model's own namespace: Kokoro's
1022
+ // af_heart means nothing to Pocket, and vice versa.
1023
+ voice: cfg.tts?.voice || (ttsModelEngineOf(active) === 'pocket-tts' ? DEFAULT_POCKET_VOICE : DEFAULT_TTS_VOICE),
1012
1024
  // Architecture decides whether voices mean anything: Kokoro picks one from
1013
1025
  // a style bank, VITS/MMS is single-speaker. An empty list tells the UI to
1014
1026
  // hide the picker rather than offer choices that cannot take effect.
@@ -1019,9 +1031,14 @@ export function createGateway(cfg = loadConfig()) {
1019
1031
  // Built-in voices belong to Kokoro alone. VITS is single-speaker and
1020
1032
  // SpeechT5 speaks only in a RECORDED voice, so offering Kokoro's list
1021
1033
  // for either would be offering choices that cannot take effect.
1022
- voices: ttsEngine.arch() && ttsEngine.arch() !== 'style-tts2'
1023
- ? []
1024
- : TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
1034
+ voices: (ttsEngine.isPocket() || (!ttsEngine.arch() && ttsModelEngineOf(active) === 'pocket-tts'))
1035
+ // Pocket ships eight speakers in an optional 52 MB file; report what is
1036
+ // actually loaded rather than the catalog's aspiration.
1037
+ ? (ttsEngine.builtinVoices().length ? ttsEngine.builtinVoices() : POCKET_VOICES)
1038
+ .map((n) => ({ id: n, label: n[0].toUpperCase() + n.slice(1), lang: 'en', installed: ttsEngine.builtinVoices().includes(n) }))
1039
+ : ttsEngine.arch() && ttsEngine.arch() !== 'style-tts2'
1040
+ ? []
1041
+ : TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
1025
1042
  dtype: cfg.tts?.dtype || 'auto',
1026
1043
  loadedDtype: ttsEngine.health().dtype,
1027
1044
  runtime: ttsEngine.health().runtime,
@@ -1040,7 +1057,7 @@ export function createGateway(cfg = loadConfig()) {
1040
1057
  const voice = body && typeof body.voice === 'string' ? body.voice.trim() : null;
1041
1058
  if (voice) {
1042
1059
  const cid = ttsVoices.parseCustomVoice(voice);
1043
- const okVoice = cid ? !!ttsVoices.getVoice(cid) : (isKnownVoice(voice) && isValidVoiceId(voice));
1060
+ const okVoice = cid ? !!ttsVoices.getVoice(cid) : (isPocketVoice(voice) || (isKnownVoice(voice) && isValidVoiceId(voice)));
1044
1061
  if (!okVoice) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1045
1062
  }
1046
1063
  const dtype = body && typeof body.dtype === 'string' && isValidTtsDtype(body.dtype) ? body.dtype : undefined;
@@ -1066,6 +1083,11 @@ export function createGateway(cfg = loadConfig()) {
1066
1083
  } else if (!wantsCustom && isCustom) {
1067
1084
  cfg.tts.voice = DEFAULT_TTS_VOICE;
1068
1085
  }
1086
+ // Kokoro and Pocket name their speakers differently; carrying one over
1087
+ // leaves a voice the new model has never heard of.
1088
+ const toPocket = ttsModelEngineOf(id) === 'pocket-tts';
1089
+ if (toPocket && !ttsVoices.parseCustomVoice(cfg.tts.voice || '') && !isPocketVoice(cfg.tts.voice)) cfg.tts.voice = DEFAULT_POCKET_VOICE;
1090
+ if (!toPocket && isPocketVoice(cfg.tts.voice)) cfg.tts.voice = DEFAULT_TTS_VOICE;
1069
1091
  }
1070
1092
  if (dtype) cfg.tts.dtype = dtype === 'auto' ? null : dtype;
1071
1093
  try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
@@ -1095,7 +1117,26 @@ export function createGateway(cfg = loadConfig()) {
1095
1117
  try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
1096
1118
  const name = body && typeof body.name === 'string' ? body.name.trim() : '';
1097
1119
  const pcm = body && Array.isArray(body.pcm) ? body.pcm : null;
1098
- if (!name) return sendJson(res, 400, { error: { message: 'a name is required', type: 'bad_request' } });
1120
+ // An `id` means UPDATE an existing voice rather than create one. The id is
1121
+ // preserved either way, because `custom:<id>` is what the config and every
1122
+ // client hold — a rename or a re-record must not orphan those.
1123
+ const editId = body && typeof body.id === 'string' ? body.id.trim() : '';
1124
+ if (editId) {
1125
+ if (!ttsVoices.getVoice(editId)) return sendJson(res, 404, { error: { message: 'no such saved voice', type: 'bad_voice' } });
1126
+ // Rename only — no new audio, so the prints are left exactly as they are.
1127
+ if (!pcm) {
1128
+ if (!name) return sendJson(res, 400, { error: { message: 'a name is required', type: 'bad_request' } });
1129
+ try {
1130
+ return sendJson(res, 200, { ...ttsVoices.renameVoice(editId, name), usable: ttsEngine.supportsCustomVoices() });
1131
+ } catch (e) { return sendJson(res, 400, { error: { message: e.message, type: 'rename_failed' } }); }
1132
+ }
1133
+ // Re-record: same rules as a fresh take, then swap the prints in place.
1134
+ if (pcm.length < 16000) {
1135
+ return sendJson(res, 400, { error: { message: 'need at least 1 second of 16 kHz mono audio', type: 'sample_too_short' } });
1136
+ }
1137
+ } else if (!name) {
1138
+ return sendJson(res, 400, { error: { message: 'a name is required', type: 'bad_request' } });
1139
+ }
1099
1140
  if (!pcm || pcm.length < 16000) {
1100
1141
  // Under a second of audio produces an embedding dominated by whatever
1101
1142
  // noise happened to be in it, and the resulting voice is arbitrary.
@@ -1144,9 +1185,13 @@ export function createGateway(cfg = loadConfig()) {
1144
1185
  console.log(`[tts] pocket conditioning unavailable for this voice (${e.message})`);
1145
1186
  }
1146
1187
 
1147
- const saved = ttsVoices.saveVoice({ name, vec, pocket });
1148
- console.log(`[tts] saved custom voice "${saved.name}" (${saved.kinds.join(' + ')}, sample discarded)`);
1149
- return sendJson(res, 201, { ...saved, usable: ttsEngine.supportsCustomVoices() });
1188
+ const saved = editId
1189
+ ? ttsVoices.replaceVoice(editId, { vec, pocket })
1190
+ : ttsVoices.saveVoice({ name, vec, pocket });
1191
+ // A rename may ride along with a re-record, so apply it after the swap.
1192
+ const final = editId && name && name !== saved.name ? ttsVoices.renameVoice(editId, name) : saved;
1193
+ console.log(`[tts] ${editId ? 're-recorded' : 'saved'} custom voice "${final.name}" (${(saved.kinds || []).join(' + ')}, sample discarded)`);
1194
+ return sendJson(res, editId ? 200 : 201, { ...saved, ...final, usable: ttsEngine.supportsCustomVoices() });
1150
1195
  } catch (e) {
1151
1196
  return sendJson(res, 400, { error: { message: e.message, type: 'save_failed' } });
1152
1197
  }
@@ -1226,7 +1271,7 @@ export function createGateway(cfg = loadConfig()) {
1226
1271
  const ok = await ttsEngine.ready({
1227
1272
  onLog: (m) => console.log(m),
1228
1273
  allowDownload: cfg.tts?.allowDownload !== false,
1229
- model: cfg.tts?.model || DEFAULT_TTS_MODEL,
1274
+ model: cfg.tts?.model || resolveDefaultModel(rawOrtAvailable()),
1230
1275
  dtype: cfg.tts?.dtype || 'auto',
1231
1276
  });
1232
1277
  if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
@@ -1239,7 +1284,13 @@ export function createGateway(cfg = loadConfig()) {
1239
1284
  let speakerEmbedding = null;
1240
1285
  let customId = ttsVoices.parseCustomVoice(voice);
1241
1286
 
1242
- if (ttsEngine.supportsCustomVoices()) {
1287
+ // A Pocket built-in speaker is a NAME, not an embedding, so it has to be
1288
+ // recognised before the custom-voice path — which would otherwise demand a
1289
+ // recording for a model that ships eight voices of its own.
1290
+ if (ttsEngine.isPocket() && isPocketVoice(rawVoice || useVoice)) {
1291
+ useVoice = rawVoice || useVoice;
1292
+ customId = null;
1293
+ } else if (ttsEngine.supportsCustomVoices()) {
1243
1294
  // This model speaks ONLY in a recorded voice. If the configured one names
1244
1295
  // a built-in (switching model does not rewrite `voice`) or points at a
1245
1296
  // voice since deleted, fall back to the most recent saved one — the
@@ -1249,8 +1300,13 @@ export function createGateway(cfg = loadConfig()) {
1249
1300
  if (!rec && !rawVoice) {
1250
1301
  const saved = ttsVoices.listVoices();
1251
1302
  if (saved.length) { customId = saved[0].id; rec = ttsVoices.getVoice(customId); }
1303
+ // Pocket can fall back to a built-in speaker; SpeechT5 has none, so for
1304
+ // that one "no saved voice" really is the end of the road.
1305
+ else if (ttsEngine.isPocket()) { useVoice = DEFAULT_POCKET_VOICE; customId = null; }
1252
1306
  }
1253
- if (!rec) {
1307
+ if (!rec && !customId && ttsEngine.isPocket()) {
1308
+ // resolved to a built-in above — nothing more to look up
1309
+ } else if (!rec) {
1254
1310
  return sendJson(res, customId ? 404 : 400, {
1255
1311
  error: {
1256
1312
  message: customId ? 'no such saved voice' : 'this model speaks in a voice you record — add one in Settings → Text-to-speech',
@@ -1287,7 +1343,11 @@ export function createGateway(cfg = loadConfig()) {
1287
1343
  }
1288
1344
  customId = null;
1289
1345
  useVoice = DEFAULT_TTS_VOICE;
1290
- } else if (ttsEngine.supportsVoices() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
1346
+ } else if (ttsEngine.isPocket() && !isPocketVoice(useVoice)) {
1347
+ // Pocket has its own speaker namespace; a Kokoro voice name here means the
1348
+ // config was carried over from another model, so fall back to its default.
1349
+ useVoice = DEFAULT_POCKET_VOICE;
1350
+ } else if (ttsEngine.supportsVoices() && !ttsEngine.isPocket() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
1291
1351
  return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1292
1352
  }
1293
1353
 
package/src/tts-engine.js CHANGED
@@ -69,6 +69,8 @@ export const SPEECHT5_VOCODER = 'Xenova/speecht5_hifigan';
69
69
  // tried it, but it borrows a voice rather than reproducing one.
70
70
  export function supportsCustomVoices() { return _arch === 'speecht5' || _arch === POCKET_ARCH; }
71
71
  export function isPocket() { return _arch === POCKET_ARCH; }
72
+ /** The built-in speaker names the loaded Pocket model offers, if any. */
73
+ export function builtinVoices() { return _pocket?.builtinVoices?.() || []; }
72
74
 
73
75
  /**
74
76
  * A PocketTTS instance purely for ENCODING a voice, without disturbing whatever
@@ -359,10 +361,10 @@ export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1, s
359
361
  // Pocket TTS runs its own generation loop and chunking, so a whole utterance is
360
362
  // handed over at once rather than being pre-split here.
361
363
  if (_arch === POCKET_ARCH) {
362
- if (!speakerEmbedding?.data?.length) {
363
- throw new Error('this model needs a saved voice — record one in Settings → Text-to-speech');
364
- }
365
- return _pocket.synth(String(text), { voice: speakerEmbedding });
364
+ // Either a cloned voice (an embedding) or one of its built-in speakers (a name).
365
+ const v = speakerEmbedding?.data?.length ? speakerEmbedding : voice;
366
+ if (!v) throw new Error('this model needs a voice — pick a built-in one or record your own');
367
+ return _pocket.synth(String(text), { voice: v });
366
368
  }
367
369
 
368
370
  // SpeechT5: conditioned by a 512-d speaker embedding, which is the whole point —
package/src/tts-models.js CHANGED
@@ -16,9 +16,30 @@
16
16
  // driveable by a transformers.js class (Kokoro = StyleTextToSpeech2Model). Verify
17
17
  // it loads on BOTH runtimes (native q8 + WASM fp32) before listing it.
18
18
 
19
- export const DEFAULT_TTS_MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
19
+ // Pocket TTS is the default where it can run: it is the fastest engine here
20
+ // (7-9x realtime against Kokoro's 2.1x) and the only one that can speak as the
21
+ // user. It needs the native onnxruntime, though, which the standalone binary does
22
+ // not carry — so the binary falls back to Kokoro rather than offering a model it
23
+ // cannot load. See src/ort.js for why.
24
+ export const DEFAULT_TTS_MODEL_NATIVE = 'kyutai/pocket-tts';
25
+ export const DEFAULT_TTS_MODEL_WASM = 'onnx-community/Kokoro-82M-v1.0-ONNX';
26
+ export const DEFAULT_TTS_MODEL = DEFAULT_TTS_MODEL_WASM; // safe default; resolveDefaultModel() picks properly
20
27
  export const DEFAULT_TTS_VOICE = 'af_heart';
21
28
 
29
+ // The eight speakers shipped in voices.bin. Names only — the data is a ~52 MB
30
+ // optional download, and cloning works without it.
31
+ export const POCKET_VOICES = ['alba', 'azelma', 'cosette', 'eponine', 'fantine', 'javert', 'jean', 'marius'];
32
+ export const DEFAULT_POCKET_VOICE = 'alba';
33
+
34
+ export function isPocketVoice(v) {
35
+ return POCKET_VOICES.includes(String(v || ''));
36
+ }
37
+
38
+ /** The model to use when nothing is configured, given what this runtime can load. */
39
+ export function resolveDefaultModel(rawOrtAvailable = true) {
40
+ return rawOrtAvailable ? DEFAULT_TTS_MODEL_NATIVE : DEFAULT_TTS_MODEL_WASM;
41
+ }
42
+
22
43
  // Style-vector width in a voices/*.bin file, and the max token window a single
23
44
  // forward pass accepts. Both are properties of the Kokoro export, and the engine
24
45
  // needs them to slice the voice and to chunk long text.
@@ -70,10 +91,12 @@ export const TTS_MODEL_CATALOG = [
70
91
  approxMB: 146,
71
92
  ramMB: 700,
72
93
  sampleRate: 24000,
73
- voices: false,
74
- customVoices: true,
94
+ voices: true, // eight built-in speakers (optional 52 MB voices.bin)
95
+ customVoices: true, // …and it can speak as YOU
75
96
  recommended: true,
76
- note: 'Kyutai Pocket TTS (MIT code, CC-BY-4.0 weights). Records a few seconds and speaks as you. Needs a saved voice; 146 MB one-time download.',
97
+ // Raw onnxruntime, which the standalone binary cannot provide.
98
+ requiresNative: true,
99
+ note: 'Kyutai Pocket TTS (MIT code, CC-BY-4.0 weights). Eight built-in voices, and it can clone yours. Fastest model here. 146 MB, plus 52 MB if you want the built-in voices. Needs the npm gateway.',
77
100
  },
78
101
  {
79
102
  id: 'Xenova/speecht5_tts',
@@ -175,6 +198,11 @@ export function ttsModelHasCustomVoices(id) {
175
198
  return ttsModel(id)?.customVoices === true;
176
199
  }
177
200
 
201
+ /** Does this model need the native onnxruntime (i.e. not usable in the binary)? */
202
+ export function ttsModelRequiresNative(id) {
203
+ return ttsModel(id)?.requiresNative === true;
204
+ }
205
+
178
206
  export function ttsModel(id) {
179
207
  return TTS_MODEL_CATALOG.find((m) => m.id === id) || null;
180
208
  }
package/src/tts-voices.js CHANGED
@@ -75,7 +75,7 @@ export function listVoices() {
75
75
  // in a settings page's JSON.
76
76
  if (v?.id && v?.name) {
77
77
  out.push({
78
- id: v.id, name: v.name, createdAt: v.createdAt || 0, dim: v.vec?.length || 0,
78
+ id: v.id, name: v.name, createdAt: v.createdAt || 0, updatedAt: v.updatedAt || 0, dim: v.vec?.length || 0,
79
79
  kinds: v.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5],
80
80
  });
81
81
  }
@@ -133,6 +133,53 @@ export function saveVoice({ name, vec, pocket = null }) {
133
133
  return { id: rec.id, name: rec.name, createdAt: rec.createdAt, dim: rec.vec.length, kinds: rec.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5] };
134
134
  }
135
135
 
136
+ /**
137
+ * Rename in place. The id is untouched on purpose: `custom:<id>` is what the
138
+ * gateway config and every client store, so a rename must not invalidate them.
139
+ */
140
+ export function renameVoice(id, name) {
141
+ const rec = getVoice(id);
142
+ if (!rec) return null;
143
+ const clean = String(name || '').trim().slice(0, MAX_NAME);
144
+ if (!clean) throw new Error('a name is required');
145
+ rec.name = clean;
146
+ rec.updatedAt = Date.now();
147
+ writeFileSync(fileFor(id), JSON.stringify(rec));
148
+ return { id, name: rec.name, createdAt: rec.createdAt || 0, updatedAt: rec.updatedAt, dim: rec.vec?.length || 0 };
149
+ }
150
+
151
+ /**
152
+ * Re-record: swap in prints derived from a NEW sample, keeping the id and name.
153
+ *
154
+ * Keeping the id is the whole point. A first take is often poor — too quiet, a
155
+ * cough, the wrong room — and the fix should be "record that again", not "delete
156
+ * it, record a new one, and go re-select it everywhere". Anything already pointing
157
+ * at this voice keeps working and simply sounds different.
158
+ */
159
+ export function replaceVoice(id, { vec, pocket = null }) {
160
+ const rec = getVoice(id);
161
+ if (!rec) return null;
162
+ if (!vec || vec.length !== EMBED_DIM) throw new Error(`expected a ${EMBED_DIM}-value embedding, got ${vec?.length || 0}`);
163
+ rec.vec = Array.from(vec, (x) => Number(x) || 0);
164
+ rec.updatedAt = Date.now();
165
+ if (pocket?.data?.length && Array.isArray(pocket.shape)) {
166
+ rec.pocketShape = pocket.shape;
167
+ const f32 = pocket.data instanceof Float32Array ? pocket.data : Float32Array.from(pocket.data);
168
+ writeFileSync(pocketFileFor(id), Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength));
169
+ } else if (rec.pocketShape) {
170
+ // The new take produced no Pocket conditioning (its bundle is missing), so the
171
+ // OLD one must go — leaving it would pair a stale voice with a fresh print and
172
+ // the voice would change depending on which engine spoke.
173
+ delete rec.pocketShape;
174
+ rmSync(pocketFileFor(id), { force: true });
175
+ }
176
+ writeFileSync(fileFor(id), JSON.stringify(rec));
177
+ return {
178
+ id, name: rec.name, createdAt: rec.createdAt || 0, updatedAt: rec.updatedAt,
179
+ dim: rec.vec.length, kinds: rec.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5],
180
+ };
181
+ }
182
+
136
183
  /** Remove one permanently. Returns whether there was anything to remove. */
137
184
  export function deleteVoice(id) {
138
185
  if (!isValidVoiceRef(id)) return false;