@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 +1 -1
- package/src/config.js +7 -2
- package/src/ort.js +52 -0
- package/src/parakeet-engine.js +2 -15
- package/src/pocket-tts-engine.js +156 -22
- package/src/server.js +77 -17
- package/src/tts-engine.js +6 -4
- package/src/tts-models.js +32 -4
- package/src/tts-voices.js +48 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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
|
-
|
|
123
|
-
|
|
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
|
+
}
|
package/src/parakeet-engine.js
CHANGED
|
@@ -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:
|
|
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),
|
package/src/pocket-tts-engine.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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 ||
|
|
1002
|
-
|
|
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
|
-
|
|
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.
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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 =
|
|
1148
|
-
|
|
1149
|
-
|
|
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 ||
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return _pocket.synth(String(text), { voice:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|