@chatpanel/gateway 0.6.55 → 0.6.56
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/pocket-tts-engine.js +378 -0
- package/src/sentencepiece.js +184 -0
- package/src/server.js +54 -5
- package/src/tts-engine.js +83 -3
- package/src/tts-models.js +26 -1
- package/src/tts-voices.js +50 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.56",
|
|
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": {
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Kyutai Pocket TTS — the engine that can actually speak in YOUR voice.
|
|
2
|
+
//
|
|
3
|
+
// Kokoro's voices are fixed style banks and SpeechT5 borrows a voice print from a
|
|
4
|
+
// space it was not trained on, so neither reproduces a specific person. Pocket TTS
|
|
5
|
+
// is built for it: its Mimi encoder turns a few seconds of audio into a voice
|
|
6
|
+
// conditioning that seeds generation directly, which is why the same sample gives
|
|
7
|
+
// back the same speaker rather than a distant relative of them.
|
|
8
|
+
//
|
|
9
|
+
// Ported from the reference browser implementation in the `KevinAHM/pocket-tts-web`
|
|
10
|
+
// Hugging Face Space (Apache-2.0), adapted from a Web Worker to in-process Node:
|
|
11
|
+
// fetch → fs, postMessage → return values, and the CDN onnxruntime → the one this
|
|
12
|
+
// gateway already ships (parakeet-engine.js's getOrt() pattern, so the same code
|
|
13
|
+
// runs on native ORT and on the binary's WASM build). Model weights are
|
|
14
|
+
// CC-BY-4.0 from `KevinAHM/pocket-tts-onnx`.
|
|
15
|
+
//
|
|
16
|
+
// Five graphs, and the shape of a turn:
|
|
17
|
+
// text_conditioner text tokens → text embeddings
|
|
18
|
+
// mimi_encoder voice sample → voice conditioning (this is the cloning)
|
|
19
|
+
// flow_lm_main autoregressive step → conditioning + an end-of-speech logit
|
|
20
|
+
// flow_lm_flow flow-matching denoise of one latent frame
|
|
21
|
+
// mimi_decoder latent frames → audio
|
|
22
|
+
// flow_lm and mimi are STATEFUL: each call returns the state the next one needs,
|
|
23
|
+
// described by a manifest in bundle.json rather than hardcoded here.
|
|
24
|
+
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, statSync, createWriteStream, renameSync } from 'node:fs';
|
|
27
|
+
import { Readable } from 'node:stream';
|
|
28
|
+
import { modelRoot } from './ner-engine.js';
|
|
29
|
+
import { SentencePieceUnigram } from './sentencepiece.js';
|
|
30
|
+
|
|
31
|
+
export const POCKET_REPO = 'KevinAHM/pocket-tts-onnx';
|
|
32
|
+
export const DEFAULT_BUNDLE = 'english_2026-04';
|
|
33
|
+
export const SAMPLE_RATE = 24000;
|
|
34
|
+
|
|
35
|
+
// Generation constants, carried over from the reference implementation.
|
|
36
|
+
const MAX_FRAMES = 500; // hard ceiling per chunk (~40s at 12.5 fps)
|
|
37
|
+
const LSD_STEPS = 1; // flow-matching steps per frame
|
|
38
|
+
const TEMPERATURE = 0.7;
|
|
39
|
+
const EOS_LOGIT_THRESHOLD = -4.0;
|
|
40
|
+
const FIRST_CHUNK_FRAMES = 3; // decode early so audio starts sooner
|
|
41
|
+
const NORMAL_CHUNK_FRAMES = 12;
|
|
42
|
+
// The reference resets both states per text chunk; keeping them would let one
|
|
43
|
+
// sentence's trailing state colour the next one's opening.
|
|
44
|
+
const RESET_STATE_EACH_CHUNK = true;
|
|
45
|
+
|
|
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
|
+
const FILES = (q = '_int8') => [
|
|
51
|
+
'bundle.json', 'tokenizer.model', 'bos_before_voice.npy',
|
|
52
|
+
`text_conditioner${q}.onnx`, `mimi_encoder${q}.onnx`, `mimi_decoder${q}.onnx`,
|
|
53
|
+
`flow_lm_main${q}.onnx`, `flow_lm_flow${q}.onnx`,
|
|
54
|
+
];
|
|
55
|
+
|
|
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
|
+
|
|
71
|
+
export function bundleDir(bundle = DEFAULT_BUNDLE) {
|
|
72
|
+
return join(modelRoot(), 'pocket-tts', bundle);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function bundleOnDisk(bundle = DEFAULT_BUNDLE, quant = '_int8') {
|
|
76
|
+
const dir = bundleDir(bundle);
|
|
77
|
+
try {
|
|
78
|
+
return FILES(quant).every((f) => { const p = join(dir, f); return existsSync(p) && statSync(p).size > 0; });
|
|
79
|
+
} catch { return false; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function downloadFile(bundle, file, dir, { onProgress, log } = {}) {
|
|
83
|
+
const url = `https://huggingface.co/${POCKET_REPO}/resolve/main/onnx/${bundle}/${file}`;
|
|
84
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
85
|
+
if (!res.ok || !res.body) throw new Error(`fetch ${file} → HTTP ${res.status}`);
|
|
86
|
+
const total = Number(res.headers.get('content-length')) || 0;
|
|
87
|
+
const tmp = join(dir, `${file}.part`);
|
|
88
|
+
const out = createWriteStream(tmp);
|
|
89
|
+
let got = 0, lastPct = -1;
|
|
90
|
+
const src = Readable.fromWeb(res.body);
|
|
91
|
+
src.on('data', (c) => {
|
|
92
|
+
got += c.length;
|
|
93
|
+
if (total) { const pct = Math.round((got / total) * 100); if (pct !== lastPct) { lastPct = pct; onProgress?.({ file, pct }); } }
|
|
94
|
+
});
|
|
95
|
+
await new Promise((resolve, reject) => { src.pipe(out); out.on('finish', resolve); out.on('error', reject); src.on('error', reject); });
|
|
96
|
+
// .part then rename, so an interrupted fetch never reads as installed.
|
|
97
|
+
renameSync(tmp, join(dir, file));
|
|
98
|
+
log?.(`[pocket-tts] fetched ${file}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function ensureBundle(bundle = DEFAULT_BUNDLE, quant = '_int8', { onProgress, log } = {}) {
|
|
102
|
+
const dir = bundleDir(bundle);
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
for (const file of FILES(quant)) {
|
|
105
|
+
const dest = join(dir, file);
|
|
106
|
+
if (existsSync(dest) && statSync(dest).size > 0) continue;
|
|
107
|
+
log?.(`[pocket-tts] downloading ${file}…`);
|
|
108
|
+
await downloadFile(bundle, file, dir, { onProgress, log });
|
|
109
|
+
}
|
|
110
|
+
return dir;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── .npy (float32) ──────────────────────────────────────────────────────────────
|
|
114
|
+
export function parseNpyFloat32(buf) {
|
|
115
|
+
const magic = [0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59];
|
|
116
|
+
for (let i = 0; i < magic.length; i++) if (buf[i] !== magic[i]) throw new Error('not an NPY file');
|
|
117
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
118
|
+
const major = view.getUint8(6);
|
|
119
|
+
const headerLen = major === 1 ? view.getUint16(8, true) : view.getUint32(8, true);
|
|
120
|
+
const headerOffset = major === 1 ? 10 : 12;
|
|
121
|
+
const header = new TextDecoder().decode(buf.subarray(headerOffset, headerOffset + headerLen));
|
|
122
|
+
const m = /\(\s*([0-9,\s]*)\)/.exec(header);
|
|
123
|
+
if (!m) throw new Error('could not parse NPY shape');
|
|
124
|
+
const shape = m[1].split(',').map((x) => x.trim()).filter(Boolean).map((x) => parseInt(x, 10));
|
|
125
|
+
const start = headerOffset + headerLen;
|
|
126
|
+
const data = new Float32Array((buf.byteLength - start) / 4);
|
|
127
|
+
for (let i = 0; i < data.length; i++) data[i] = view.getFloat32(start + i * 4, true);
|
|
128
|
+
return { data, shape };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── state manifests ─────────────────────────────────────────────────────────────
|
|
132
|
+
// Both stateful graphs describe their own state in bundle.json: which inputs to
|
|
133
|
+
// seed, with what shape and fill, and which outputs feed them next turn. Reading
|
|
134
|
+
// it beats hardcoding 18 + 56 tensor names that differ per language bundle.
|
|
135
|
+
function filledArray(shape, dtype, fill) {
|
|
136
|
+
const size = shape.reduce((a, b) => a * b, 1);
|
|
137
|
+
if (dtype === 'int64') return new BigInt64Array(size);
|
|
138
|
+
if (dtype === 'bool') return new Uint8Array(size);
|
|
139
|
+
const d = new Float32Array(size);
|
|
140
|
+
if (fill === 'nan') d.fill(NaN);
|
|
141
|
+
else if (fill === 'ones') d.fill(1);
|
|
142
|
+
return d;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function initState(ort, manifest) {
|
|
146
|
+
const state = {};
|
|
147
|
+
for (const e of manifest) state[e.input_name] = new ort.Tensor(e.dtype, filledArray(e.shape, e.dtype, e.fill), e.shape);
|
|
148
|
+
return state;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function advanceState(state, result, manifest) {
|
|
152
|
+
for (const e of manifest) state[e.input_name] = result[e.output_name];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class PocketTTS {
|
|
156
|
+
constructor() {
|
|
157
|
+
this.ready = false;
|
|
158
|
+
this.bundle = null;
|
|
159
|
+
this.meta = null;
|
|
160
|
+
this.tok = null;
|
|
161
|
+
this.bos = null;
|
|
162
|
+
this.sessions = null;
|
|
163
|
+
this.st = null; // precomputed flow-matching s/t pairs
|
|
164
|
+
this.latentDim = 32;
|
|
165
|
+
this.condDim = 1024;
|
|
166
|
+
this.samplesPerFrame = 1920;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async load(bundle = DEFAULT_BUNDLE, { quant = '_int8', onProgress, log = () => {} } = {}) {
|
|
170
|
+
const dir = await ensureBundle(bundle, quant, { onProgress, log });
|
|
171
|
+
const ort = await getOrt();
|
|
172
|
+
this.meta = JSON.parse(readFileSync(join(dir, 'bundle.json'), 'utf8'));
|
|
173
|
+
this.tok = new SentencePieceUnigram(new Uint8Array(readFileSync(join(dir, 'tokenizer.model'))));
|
|
174
|
+
this.bos = this.meta.insert_bos_before_voice ? parseNpyFloat32(readFileSync(join(dir, 'bos_before_voice.npy'))) : null;
|
|
175
|
+
this.latentDim = Number(this.meta.latent_dim) || 32;
|
|
176
|
+
this.condDim = Number(this.meta.conditioning_dim) || 1024;
|
|
177
|
+
this.samplesPerFrame = Math.round(SAMPLE_RATE / (Number(this.meta.frame_rate) || 12.5));
|
|
178
|
+
|
|
179
|
+
const opts = { executionProviders: ['cpu'], graphOptimizationLevel: 'all', logSeverityLevel: 3 };
|
|
180
|
+
const [textConditioner, mimiEncoder, mimiDecoder, flowMain, flowFlow] = await Promise.all([
|
|
181
|
+
ort.InferenceSession.create(join(dir, `text_conditioner${quant}.onnx`), opts),
|
|
182
|
+
ort.InferenceSession.create(join(dir, `mimi_encoder${quant}.onnx`), opts),
|
|
183
|
+
ort.InferenceSession.create(join(dir, `mimi_decoder${quant}.onnx`), opts),
|
|
184
|
+
ort.InferenceSession.create(join(dir, `flow_lm_main${quant}.onnx`), opts),
|
|
185
|
+
ort.InferenceSession.create(join(dir, `flow_lm_flow${quant}.onnx`), opts),
|
|
186
|
+
]);
|
|
187
|
+
this.sessions = { ort, textConditioner, mimiEncoder, mimiDecoder, flowMain, flowFlow };
|
|
188
|
+
|
|
189
|
+
// Flow matching walks s → t in fixed steps; the tensors never change, so they
|
|
190
|
+
// are built once rather than per frame (this runs MAX_FRAMES times a chunk).
|
|
191
|
+
this.st = [];
|
|
192
|
+
const dt = 1 / LSD_STEPS;
|
|
193
|
+
for (let i = 0; i < LSD_STEPS; i++) {
|
|
194
|
+
const s = i / LSD_STEPS;
|
|
195
|
+
this.st.push({
|
|
196
|
+
s: new ort.Tensor('float32', new Float32Array([s]), [1, 1]),
|
|
197
|
+
t: new ort.Tensor('float32', new Float32Array([s + dt]), [1, 1]),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
this.bundle = bundle;
|
|
201
|
+
this.ready = true;
|
|
202
|
+
log(`[pocket-tts] ready — ${bundle} (${quant.replace('_', '') || 'fp32'}, ${SAMPLE_RATE} Hz)`);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* THE CLONING STEP. A few seconds of 24 kHz mono audio → the voice conditioning
|
|
208
|
+
* that seeds generation. Returns { data, shape } to be stored as the voice print.
|
|
209
|
+
*/
|
|
210
|
+
async encodeVoice(audio) {
|
|
211
|
+
if (!this.ready) throw new Error('pocket-tts not loaded');
|
|
212
|
+
const { ort, mimiEncoder } = this.sessions;
|
|
213
|
+
const pcm = audio instanceof Float32Array ? audio : Float32Array.from(audio);
|
|
214
|
+
const out = await mimiEncoder.run({ audio: new ort.Tensor('float32', pcm, [1, 1, pcm.length]) });
|
|
215
|
+
const emb = out[mimiEncoder.outputNames[0]];
|
|
216
|
+
let dims = emb.dims.slice();
|
|
217
|
+
while (dims.length > 3 && dims[0] === 1) dims = dims.slice(1);
|
|
218
|
+
if (dims.length < 3) dims = [1, dims[0], dims[1]];
|
|
219
|
+
return { data: Float32Array.from(emb.data), shape: dims };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The voice conditioning is fed to flow_lm_main in the TEXT-embedding slot,
|
|
223
|
+
// preceded by the bundle's BOS frames — the model is told "this is how the
|
|
224
|
+
// speaker sounds" before it is told what to say.
|
|
225
|
+
#voiceTensor(voice) {
|
|
226
|
+
const { ort } = this.sessions;
|
|
227
|
+
let data = voice.data instanceof Float32Array ? voice.data : Float32Array.from(voice.data);
|
|
228
|
+
let dims = voice.shape.slice();
|
|
229
|
+
if (this.meta.insert_bos_before_voice && this.bos) {
|
|
230
|
+
const combined = new Float32Array(this.bos.data.length + data.length);
|
|
231
|
+
combined.set(this.bos.data, 0);
|
|
232
|
+
combined.set(data, this.bos.data.length);
|
|
233
|
+
data = combined;
|
|
234
|
+
dims = [1, dims[1] + this.bos.shape[1], dims[2]];
|
|
235
|
+
}
|
|
236
|
+
return new ort.Tensor('float32', data, dims);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async #voiceState(voice) {
|
|
240
|
+
const { ort, flowMain } = this.sessions;
|
|
241
|
+
const state = initState(ort, this.meta.flow_lm_state_manifest);
|
|
242
|
+
const result = await flowMain.run({
|
|
243
|
+
sequence: new ort.Tensor('float32', new Float32Array(0), [1, 0, this.latentDim]),
|
|
244
|
+
text_embeddings: this.#voiceTensor(voice),
|
|
245
|
+
...state,
|
|
246
|
+
});
|
|
247
|
+
advanceState(state, result, this.meta.flow_lm_state_manifest);
|
|
248
|
+
return state;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Normalization the model expects: one line, capitalized, terminated.
|
|
252
|
+
#prepare(text) {
|
|
253
|
+
let s = String(text).replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
254
|
+
if (!s) return { text: '', framesAfterEos: 1 };
|
|
255
|
+
if (this.meta.remove_semicolons) s = s.replace(/;/g, ',');
|
|
256
|
+
const words = s.split(/\s+/).filter(Boolean).length;
|
|
257
|
+
let framesAfterEos = words <= 4 ? 3 : 1;
|
|
258
|
+
if (this.meta.model_recommended_frames_after_eos != null) framesAfterEos = Number(this.meta.model_recommended_frames_after_eos);
|
|
259
|
+
if (!/[A-ZÀ-Þ]/.test(s[0])) s = s[0].toUpperCase() + s.slice(1);
|
|
260
|
+
if (/[0-9A-Za-zÀ-ÿ]/.test(s[s.length - 1])) s += '.';
|
|
261
|
+
if (this.meta.pad_with_spaces_for_short_inputs && words < 5) s = ` ${s}`;
|
|
262
|
+
return { text: s, framesAfterEos };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// One forward pass per FRAME, so a long paragraph in a single chunk is a long
|
|
266
|
+
// time before any audio. Split on sentences, then on the model's token ceiling.
|
|
267
|
+
#chunks(text) {
|
|
268
|
+
const maxTokens = Number(this.meta.max_token_per_chunk) || 50;
|
|
269
|
+
const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text];
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const raw of sentences) {
|
|
272
|
+
const s = raw.trim();
|
|
273
|
+
if (!s) continue;
|
|
274
|
+
const ids = this.tok.encodeIds(s);
|
|
275
|
+
if (ids.length <= maxTokens) { out.push(s); continue; }
|
|
276
|
+
for (let i = 0; i < ids.length; i += maxTokens) {
|
|
277
|
+
const part = this.tok.decodeIds(ids.slice(i, i + maxTokens)).trim();
|
|
278
|
+
if (part) out.push(part);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return out.length ? out : [text];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Synthesize. `voice` is what encodeVoice() returned. `onAudio(pcm)` receives
|
|
286
|
+
* each decoded piece as it lands, so a caller can stream; the whole waveform is
|
|
287
|
+
* returned as well.
|
|
288
|
+
*/
|
|
289
|
+
async synth(text, { voice, onAudio = null } = {}) {
|
|
290
|
+
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');
|
|
292
|
+
const { ort, textConditioner, flowMain, flowFlow, mimiDecoder } = this.sessions;
|
|
293
|
+
const prepared = this.#prepare(text);
|
|
294
|
+
if (!prepared.text) return new Float32Array(0);
|
|
295
|
+
const chunks = this.#chunks(prepared.text);
|
|
296
|
+
const baseFlow = await this.#voiceState(voice);
|
|
297
|
+
|
|
298
|
+
const emptySeq = new ort.Tensor('float32', new Float32Array(0), [1, 0, this.latentDim]);
|
|
299
|
+
const emptyText = new ort.Tensor('float32', new Float32Array(0), [1, 0, this.condDim]);
|
|
300
|
+
let mimiState = initState(ort, this.meta.mimi_state_manifest);
|
|
301
|
+
let flowState = { ...baseFlow };
|
|
302
|
+
const pieces = [];
|
|
303
|
+
let first = true;
|
|
304
|
+
|
|
305
|
+
for (let c = 0; c < chunks.length; c++) {
|
|
306
|
+
if (RESET_STATE_EACH_CHUNK && c > 0) {
|
|
307
|
+
flowState = { ...baseFlow };
|
|
308
|
+
mimiState = initState(ort, this.meta.mimi_state_manifest);
|
|
309
|
+
}
|
|
310
|
+
const ids = this.tok.encodeIds(chunks[c]);
|
|
311
|
+
const tokens = new ort.Tensor('int64', BigInt64Array.from(ids, (t) => BigInt(t)), [1, ids.length]);
|
|
312
|
+
let textEmb = (await textConditioner.run({ token_ids: tokens }))[textConditioner.outputNames[0]];
|
|
313
|
+
if (textEmb.dims.length === 2) textEmb = new ort.Tensor('float32', Float32Array.from(textEmb.data), [1, textEmb.dims[0], textEmb.dims[1]]);
|
|
314
|
+
|
|
315
|
+
// Prime the LM with the text, then generate frames from silence.
|
|
316
|
+
advanceState(flowState, await flowMain.run({ sequence: emptySeq, text_embeddings: textEmb, ...flowState }), this.meta.flow_lm_state_manifest);
|
|
317
|
+
|
|
318
|
+
const latents = [];
|
|
319
|
+
let decoded = 0;
|
|
320
|
+
let cur = new ort.Tensor('float32', new Float32Array(this.latentDim).fill(NaN), [1, 1, this.latentDim]);
|
|
321
|
+
let eosAt = null;
|
|
322
|
+
|
|
323
|
+
for (let step = 0; step < MAX_FRAMES; step++) {
|
|
324
|
+
const ar = await flowMain.run({ sequence: cur, text_embeddings: emptyText, ...flowState });
|
|
325
|
+
const conditioning = ar.conditioning;
|
|
326
|
+
if (ar.eos_logit.data[0] > EOS_LOGIT_THRESHOLD && eosAt == null) eosAt = step;
|
|
327
|
+
const stop = eosAt != null && step >= eosAt + prepared.framesAfterEos;
|
|
328
|
+
|
|
329
|
+
// Start from Gaussian noise, then walk it along the learned flow field.
|
|
330
|
+
const std = Math.sqrt(TEMPERATURE);
|
|
331
|
+
const latent = new Float32Array(this.latentDim);
|
|
332
|
+
for (let i = 0; i < this.latentDim; i++) {
|
|
333
|
+
let u = 0, v = 0;
|
|
334
|
+
while (u === 0) u = Math.random();
|
|
335
|
+
while (v === 0) v = Math.random();
|
|
336
|
+
latent[i] = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) * std;
|
|
337
|
+
}
|
|
338
|
+
const dt = 1 / LSD_STEPS;
|
|
339
|
+
for (let k = 0; k < LSD_STEPS; k++) {
|
|
340
|
+
const f = await flowFlow.run({ c: conditioning, s: this.st[k].s, t: this.st[k].t, x: new ort.Tensor('float32', latent, [1, this.latentDim]) });
|
|
341
|
+
const dir = f.flow_dir.data;
|
|
342
|
+
for (let i = 0; i < this.latentDim; i++) latent[i] += dir[i] * dt;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
latents.push(Float32Array.from(latent));
|
|
346
|
+
cur = new ort.Tensor('float32', latent, [1, 1, this.latentDim]);
|
|
347
|
+
advanceState(flowState, ar, this.meta.flow_lm_state_manifest);
|
|
348
|
+
|
|
349
|
+
// Decode in small batches — the first deliberately tiny so sound starts
|
|
350
|
+
// early, the rest larger because the decoder is cheaper in bulk.
|
|
351
|
+
const pending = latents.length - decoded;
|
|
352
|
+
let take = 0;
|
|
353
|
+
if (stop) take = pending;
|
|
354
|
+
else if (first && pending >= FIRST_CHUNK_FRAMES) take = FIRST_CHUNK_FRAMES;
|
|
355
|
+
else if (pending >= NORMAL_CHUNK_FRAMES) take = NORMAL_CHUNK_FRAMES;
|
|
356
|
+
|
|
357
|
+
if (take > 0) {
|
|
358
|
+
const buf = new Float32Array(take * this.latentDim);
|
|
359
|
+
for (let f = 0; f < take; f++) buf.set(latents[decoded + f], f * this.latentDim);
|
|
360
|
+
const dec = await mimiDecoder.run({ latent: new ort.Tensor('float32', buf, [1, take, this.latentDim]), ...mimiState });
|
|
361
|
+
advanceState(mimiState, dec, this.meta.mimi_state_manifest);
|
|
362
|
+
decoded += take;
|
|
363
|
+
first = false;
|
|
364
|
+
const pcm = Float32Array.from(dec[mimiDecoder.outputNames[0]].data);
|
|
365
|
+
pieces.push(pcm);
|
|
366
|
+
onAudio?.(pcm);
|
|
367
|
+
}
|
|
368
|
+
if (stop) break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const total = pieces.reduce((n, p) => n + p.length, 0);
|
|
373
|
+
const out = new Float32Array(total);
|
|
374
|
+
let at = 0;
|
|
375
|
+
for (const p of pieces) { out.set(p, at); at += p.length; }
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// A minimal SentencePiece (Unigram) tokenizer — parse the .model protobuf and
|
|
2
|
+
// encode with Viterbi.
|
|
3
|
+
//
|
|
4
|
+
// Why not a library: the reference implementation for pocket-tts ships a 4 MB
|
|
5
|
+
// WASM-bundled sentencepiece build aimed at browsers. This gateway is
|
|
6
|
+
// zero-runtime-dependency by design and also compiles into a Bun single-file
|
|
7
|
+
// binary, so a 4 MB WASM blob for what is fundamentally a shortest-path search
|
|
8
|
+
// over a 4,000-entry vocabulary is the wrong trade.
|
|
9
|
+
//
|
|
10
|
+
// Scope is deliberately narrow: UNIGRAM models with byte fallback, which is what
|
|
11
|
+
// pocket-tts uses (4,000 pieces, each with a log-probability score, plus the 256
|
|
12
|
+
// <0xNN> byte pieces). A BPE model would need a different algorithm and is
|
|
13
|
+
// rejected at load rather than silently mis-tokenized.
|
|
14
|
+
|
|
15
|
+
const UNK = 0, NORMAL_TYPE = 1, CONTROL = 3, BYTE = 6;
|
|
16
|
+
// SentencePiece represents a space as U+2581 LOWER ONE EIGHTH BLOCK.
|
|
17
|
+
const SPACE = '▁';
|
|
18
|
+
|
|
19
|
+
function readVarint(buf, i) {
|
|
20
|
+
let result = 0, shift = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const b = buf[i++];
|
|
23
|
+
result += (b & 0x7f) * 2 ** shift;
|
|
24
|
+
if ((b & 0x80) === 0) return [result, i];
|
|
25
|
+
shift += 7;
|
|
26
|
+
if (shift > 49) throw new Error('varint too long');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ModelProto { repeated SentencePiece pieces = 1; ... }
|
|
31
|
+
// SentencePiece { string piece = 1; float score = 2; Type type = 3; }
|
|
32
|
+
function parseModelProto(buf) {
|
|
33
|
+
const pieces = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < buf.length) {
|
|
36
|
+
let key;
|
|
37
|
+
[key, i] = readVarint(buf, i);
|
|
38
|
+
const field = key >> 3, wire = key & 7;
|
|
39
|
+
if (wire === 2) {
|
|
40
|
+
let len;
|
|
41
|
+
[len, i] = readVarint(buf, i);
|
|
42
|
+
const chunk = buf.subarray(i, i + len);
|
|
43
|
+
i += len;
|
|
44
|
+
if (field === 1) pieces.push(parsePiece(chunk));
|
|
45
|
+
} else if (wire === 0) {
|
|
46
|
+
[, i] = readVarint(buf, i);
|
|
47
|
+
} else if (wire === 5) {
|
|
48
|
+
i += 4;
|
|
49
|
+
} else if (wire === 1) {
|
|
50
|
+
i += 8;
|
|
51
|
+
} else {
|
|
52
|
+
throw new Error(`unsupported protobuf wire type ${wire}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return pieces;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parsePiece(buf) {
|
|
59
|
+
let i = 0, piece = '', score = 0, type = NORMAL_TYPE;
|
|
60
|
+
while (i < buf.length) {
|
|
61
|
+
let key;
|
|
62
|
+
[key, i] = readVarint(buf, i);
|
|
63
|
+
const field = key >> 3, wire = key & 7;
|
|
64
|
+
if (wire === 2) {
|
|
65
|
+
let len;
|
|
66
|
+
[len, i] = readVarint(buf, i);
|
|
67
|
+
const val = buf.subarray(i, i + len);
|
|
68
|
+
i += len;
|
|
69
|
+
if (field === 1) piece = new TextDecoder().decode(val);
|
|
70
|
+
} else if (wire === 5) {
|
|
71
|
+
if (field === 2) score = new DataView(buf.buffer, buf.byteOffset + i, 4).getFloat32(0, true);
|
|
72
|
+
i += 4;
|
|
73
|
+
} else if (wire === 0) {
|
|
74
|
+
let v;
|
|
75
|
+
[v, i] = readVarint(buf, i);
|
|
76
|
+
if (field === 3) type = v;
|
|
77
|
+
} else {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { piece, score, type };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class SentencePieceUnigram {
|
|
85
|
+
constructor(modelBytes) {
|
|
86
|
+
const pieces = parseModelProto(modelBytes);
|
|
87
|
+
if (!pieces.length) throw new Error('sentencepiece model contains no pieces');
|
|
88
|
+
// A unigram model scores every piece; a BPE model does not. Rejecting here
|
|
89
|
+
// beats producing plausible-looking but wrong token ids.
|
|
90
|
+
if (!pieces.some((p) => p.score !== 0)) {
|
|
91
|
+
throw new Error('this looks like a BPE sentencepiece model — only unigram is supported');
|
|
92
|
+
}
|
|
93
|
+
this.pieces = pieces;
|
|
94
|
+
this.vocab = new Map();
|
|
95
|
+
this.byteId = new Array(256).fill(-1);
|
|
96
|
+
this.unkId = 0;
|
|
97
|
+
for (let id = 0; id < pieces.length; id++) {
|
|
98
|
+
const { piece, type } = pieces[id];
|
|
99
|
+
if (type === UNK) this.unkId = id;
|
|
100
|
+
if (type === BYTE) {
|
|
101
|
+
const m = /^<0x([0-9A-Fa-f]{2})>$/.exec(piece);
|
|
102
|
+
if (m) this.byteId[parseInt(m[1], 16)] = id;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Control pieces (<s>, </s>, <pad>) are addressable by id but must never be
|
|
106
|
+
// produced by encoding text — they are inserted by the caller if wanted.
|
|
107
|
+
if (type === CONTROL) continue;
|
|
108
|
+
if (!this.vocab.has(piece)) this.vocab.set(piece, id);
|
|
109
|
+
}
|
|
110
|
+
this.maxPieceLen = Math.max(...[...this.vocab.keys()].map((p) => p.length), 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get vocabSize() { return this.pieces.length; }
|
|
114
|
+
|
|
115
|
+
/** Text → token ids. Viterbi over piece scores, byte fallback for the rest. */
|
|
116
|
+
encodeIds(text) {
|
|
117
|
+
const norm = SPACE + String(text ?? '').normalize('NFKC').replace(/ /g, SPACE);
|
|
118
|
+
const n = norm.length;
|
|
119
|
+
// best[i] = { score, from, id } for the best segmentation of norm[0..i)
|
|
120
|
+
const best = new Array(n + 1).fill(null);
|
|
121
|
+
best[0] = { score: 0, from: -1, id: -1, bytes: null };
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < n; i++) {
|
|
124
|
+
if (!best[i]) continue;
|
|
125
|
+
let matched = false; // kept for readability of the fallback comment below
|
|
126
|
+
const limit = Math.min(n, i + this.maxPieceLen);
|
|
127
|
+
for (let j = i + 1; j <= limit; j++) {
|
|
128
|
+
const id = this.vocab.get(norm.slice(i, j));
|
|
129
|
+
if (id === undefined) continue;
|
|
130
|
+
matched = true;
|
|
131
|
+
const score = best[i].score + this.pieces[id].score;
|
|
132
|
+
if (!best[j] || score > best[j].score) best[j] = { score, from: i, id, bytes: null };
|
|
133
|
+
}
|
|
134
|
+
// Byte fallback is ALWAYS offered as an alternative, not only when nothing
|
|
135
|
+
// matched: a character can be in the vocabulary and still be the wrong split
|
|
136
|
+
// for the sentence around it. The per-byte penalty is far worse than any real
|
|
137
|
+
// piece's score, so Viterbi picks it only when it genuinely has to.
|
|
138
|
+
void matched;
|
|
139
|
+
{
|
|
140
|
+
const ch = String.fromCodePoint(norm.codePointAt(i));
|
|
141
|
+
const j = i + ch.length;
|
|
142
|
+
const bytes = new TextEncoder().encode(ch);
|
|
143
|
+
if (bytes.every((b) => this.byteId[b] >= 0)) {
|
|
144
|
+
const score = best[i].score + bytes.length * -10;
|
|
145
|
+
if (!best[j] || score > best[j].score) best[j] = { score, from: i, id: -1, bytes };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!best[n]) return [this.unkId];
|
|
151
|
+
const out = [];
|
|
152
|
+
for (let i = n; i > 0;) {
|
|
153
|
+
const node = best[i];
|
|
154
|
+
if (node.bytes) for (let k = node.bytes.length - 1; k >= 0; k--) out.push(this.byteId[node.bytes[k]]);
|
|
155
|
+
else out.push(node.id);
|
|
156
|
+
i = node.from;
|
|
157
|
+
}
|
|
158
|
+
return out.reverse();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Token ids → text. Byte pieces are reassembled before decoding as UTF-8. */
|
|
162
|
+
decodeIds(ids) {
|
|
163
|
+
const parts = [];
|
|
164
|
+
let pending = [];
|
|
165
|
+
const flush = () => {
|
|
166
|
+
if (!pending.length) return;
|
|
167
|
+
parts.push(new TextDecoder().decode(Uint8Array.from(pending)));
|
|
168
|
+
pending = [];
|
|
169
|
+
};
|
|
170
|
+
for (const id of ids) {
|
|
171
|
+
const p = this.pieces[id];
|
|
172
|
+
if (!p) continue;
|
|
173
|
+
if (p.type === BYTE) {
|
|
174
|
+
const m = /^<0x([0-9A-Fa-f]{2})>$/.exec(p.piece);
|
|
175
|
+
if (m) { pending.push(parseInt(m[1], 16)); continue; }
|
|
176
|
+
}
|
|
177
|
+
flush();
|
|
178
|
+
if (p.type === CONTROL || p.type === UNK) continue;
|
|
179
|
+
parts.push(p.piece);
|
|
180
|
+
}
|
|
181
|
+
flush();
|
|
182
|
+
return parts.join('').replace(new RegExp(SPACE, 'g'), ' ').replace(/^ /, '');
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/server.js
CHANGED
|
@@ -53,7 +53,7 @@ import * as openai from './openai.js';
|
|
|
53
53
|
import * as responses from './responses.js';
|
|
54
54
|
import * as anthropic from './anthropic.js';
|
|
55
55
|
|
|
56
|
-
export const VERSION = '0.6.
|
|
56
|
+
export const VERSION = '0.6.56';
|
|
57
57
|
|
|
58
58
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
59
59
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -462,6 +462,23 @@ export function joinUpstream(base, pathname, search = '') {
|
|
|
462
462
|
// short enough that a stuck download does not hold a request open forever.
|
|
463
463
|
const EMBEDDER_WAIT_MS = 90_000;
|
|
464
464
|
|
|
465
|
+
// Linear resample. Good enough for a voice-print sample — the encoder cares about
|
|
466
|
+
// timbre, not the last decibel of fidelity — and it avoids a dependency for one
|
|
467
|
+
// rate conversion.
|
|
468
|
+
function resample(input, from, to) {
|
|
469
|
+
if (from === to) return input;
|
|
470
|
+
const ratio = from / to;
|
|
471
|
+
const out = new Float32Array(Math.floor(input.length / ratio));
|
|
472
|
+
for (let i = 0; i < out.length; i++) {
|
|
473
|
+
const pos = i * ratio;
|
|
474
|
+
const a = Math.floor(pos);
|
|
475
|
+
const b = Math.min(input.length - 1, a + 1);
|
|
476
|
+
const f = pos - a;
|
|
477
|
+
out[i] = input[a] * (1 - f) + input[b] * f;
|
|
478
|
+
}
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
|
|
465
482
|
const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/config', '/logs', '/status', '/admin'];
|
|
466
483
|
|
|
467
484
|
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
|
|
@@ -1110,9 +1127,25 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1110
1127
|
});
|
|
1111
1128
|
}
|
|
1112
1129
|
}
|
|
1113
|
-
const
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1130
|
+
const audio = Float32Array.from(pcm);
|
|
1131
|
+
const vec = await diarizeEngine.embed(audio);
|
|
1132
|
+
|
|
1133
|
+
// A voice print is engine-specific and the SAMPLE is about to be thrown
|
|
1134
|
+
// away, so anything this voice might later need must be derived now.
|
|
1135
|
+
// Pocket TTS is the engine that actually reproduces a speaker, so its
|
|
1136
|
+
// conditioning is computed whenever its bundle is present — failing that
|
|
1137
|
+
// is not fatal, it just means this voice works only with SpeechT5.
|
|
1138
|
+
let pocket = null;
|
|
1139
|
+
try {
|
|
1140
|
+
const pt = await ttsEngine.pocketForEncoding({ allowDownload: cfg.tts?.allowDownload !== false, log: (m) => console.log(m) });
|
|
1141
|
+
// The recorder sends 16 kHz; Mimi wants 24 kHz.
|
|
1142
|
+
if (pt) pocket = await pt.encodeVoice(resample(audio, 16000, 24000));
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
console.log(`[tts] pocket conditioning unavailable for this voice (${e.message})`);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const saved = ttsVoices.saveVoice({ name, vec, pocket });
|
|
1148
|
+
console.log(`[tts] saved custom voice "${saved.name}" (${saved.kinds.join(' + ')}, sample discarded)`);
|
|
1116
1149
|
return sendJson(res, 201, { ...saved, usable: ttsEngine.supportsCustomVoices() });
|
|
1117
1150
|
} catch (e) {
|
|
1118
1151
|
return sendJson(res, 400, { error: { message: e.message, type: 'save_failed' } });
|
|
@@ -1225,7 +1258,23 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1225
1258
|
},
|
|
1226
1259
|
});
|
|
1227
1260
|
}
|
|
1228
|
-
|
|
1261
|
+
// Which print to hand over depends on the engine: Pocket TTS takes its
|
|
1262
|
+
// Mimi conditioning, SpeechT5 the 512-d x-vector. A voice saved before
|
|
1263
|
+
// the pocket bundle existed has only the latter.
|
|
1264
|
+
if (ttsEngine.isPocket()) {
|
|
1265
|
+
const pk = ttsVoices.getPocketVoice(customId);
|
|
1266
|
+
if (!pk) {
|
|
1267
|
+
return sendJson(res, 409, {
|
|
1268
|
+
error: {
|
|
1269
|
+
message: 'this voice was saved without a Pocket TTS conditioning — record it again with Pocket TTS selected',
|
|
1270
|
+
type: 'voice_kind_missing',
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
speakerEmbedding = pk;
|
|
1275
|
+
} else {
|
|
1276
|
+
speakerEmbedding = rec.vec;
|
|
1277
|
+
}
|
|
1229
1278
|
useVoice = `custom:${customId}`;
|
|
1230
1279
|
} else if (customId) {
|
|
1231
1280
|
// Explicitly asked for a recorded voice this model cannot use — say so.
|
package/src/tts-engine.js
CHANGED
|
@@ -24,8 +24,9 @@ import { ensureLib, modelRoot } from './ner-engine.js';
|
|
|
24
24
|
import { runtimeDtype, runtimeName, DTYPE_SUFFIX } from './model-runtime.js';
|
|
25
25
|
import {
|
|
26
26
|
DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, STYLE_DIM, MAX_PHONEME_TOKENS,
|
|
27
|
-
ttsModelDtype, isKnownTtsModel, isValidVoiceId, voiceLang,
|
|
27
|
+
ttsModelDtype, isKnownTtsModel, isValidVoiceId, voiceLang, ttsModelEngine, ttsModel,
|
|
28
28
|
} from './tts-models.js';
|
|
29
|
+
import { bundleOnDisk as pocketBundleOnDisk } from './pocket-tts-engine.js';
|
|
29
30
|
|
|
30
31
|
// Kokoro's rate. Kept as a named export because it is the default and several
|
|
31
32
|
// callers want a number before anything is loaded — but it is NOT universal: a
|
|
@@ -43,6 +44,8 @@ let _progress = null;
|
|
|
43
44
|
let _initPromise = null;
|
|
44
45
|
let _arch = null; // 'style-tts2' (Kokoro) | 'vits' (MMS) | 'speecht5' (custom voices)
|
|
45
46
|
let _vocoder = null; // speecht5 only — mel → waveform
|
|
47
|
+
let _pocket = null; // pocket-tts only — its own PocketTTS instance
|
|
48
|
+
let _encoder = null; // a PocketTTS kept ONLY to encode voices, never to speak
|
|
46
49
|
let _rate = SAMPLE_RATE; // the ACTIVE model's output rate
|
|
47
50
|
const _voices = new Map(); // voice id → Float32Array style bank
|
|
48
51
|
|
|
@@ -51,12 +54,38 @@ const _voices = new Map(); // voice id → Float32Array style bank
|
|
|
51
54
|
// forward pass with a shape error nobody can act on.
|
|
52
55
|
export const SUPPORTED_ARCH = { style_text_to_speech_2: 'style-tts2', vits: 'vits', speecht5: 'speecht5' };
|
|
53
56
|
|
|
57
|
+
// Pocket TTS is not a transformers.js model — it is five raw ONNX graphs with a
|
|
58
|
+
// hand-written generation loop, exactly like parakeet on the STT side — so it is
|
|
59
|
+
// dispatched by catalog id rather than by a transformers config.model_type.
|
|
60
|
+
export const POCKET_ARCH = 'pocket-tts';
|
|
61
|
+
|
|
54
62
|
// SpeechT5 is the only architecture here that takes a SPEAKER EMBEDDING, which is
|
|
55
63
|
// what makes a custom voice possible at all: Kokoro's voices are fixed style banks
|
|
56
64
|
// and VITS is single-speaker, so neither can be pointed at a person. It needs a
|
|
57
65
|
// separate vocoder (mel → waveform), hence the extra model id.
|
|
58
66
|
export const SPEECHT5_VOCODER = 'Xenova/speecht5_hifigan';
|
|
59
|
-
|
|
67
|
+
// Both engines that can be pointed at a person. Pocket TTS is the one built for
|
|
68
|
+
// it; SpeechT5 is kept because it is small and already downloaded for anyone who
|
|
69
|
+
// tried it, but it borrows a voice rather than reproducing one.
|
|
70
|
+
export function supportsCustomVoices() { return _arch === 'speecht5' || _arch === POCKET_ARCH; }
|
|
71
|
+
export function isPocket() { return _arch === POCKET_ARCH; }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A PocketTTS instance purely for ENCODING a voice, without disturbing whatever
|
|
75
|
+
* model is currently speaking. Saving a voice has to derive its conditioning while
|
|
76
|
+
* the sample still exists, and that must not silently switch the active engine out
|
|
77
|
+
* from under a conversation in progress.
|
|
78
|
+
*/
|
|
79
|
+
export async function pocketForEncoding({ allowDownload = true, log = () => {} } = {}) {
|
|
80
|
+
if (_pocket) return _pocket;
|
|
81
|
+
const { PocketTTS, bundleOnDisk, DEFAULT_BUNDLE } = await import('./pocket-tts-engine.js');
|
|
82
|
+
if (!bundleOnDisk(DEFAULT_BUNDLE) && !allowDownload) return null;
|
|
83
|
+
if (_encoder) return _encoder;
|
|
84
|
+
const pt = new PocketTTS();
|
|
85
|
+
await pt.load(DEFAULT_BUNDLE, { log });
|
|
86
|
+
_encoder = pt;
|
|
87
|
+
return pt;
|
|
88
|
+
}
|
|
60
89
|
|
|
61
90
|
export function arch() { return _arch; }
|
|
62
91
|
export function sampleRate() { return _rate; }
|
|
@@ -79,6 +108,11 @@ export function modelDir(modelId) {
|
|
|
79
108
|
// Present = the EXACT ONNX file this runtime will load, plus the tokenizer. Both
|
|
80
109
|
// non-empty: a truncated download must not read as installed.
|
|
81
110
|
export function modelOnDisk(modelId = _model || DEFAULT_TTS_MODEL, dtype = ttsModelDtype(modelId) || runtimeDtype()) {
|
|
111
|
+
// Pocket TTS keeps a bundle of five graphs under its own directory, not a single
|
|
112
|
+
// transformers-style onnx/ folder. bundleOnDisk is a pure fs predicate — the
|
|
113
|
+
// heavy onnxruntime import inside that module is dynamic — so importing it
|
|
114
|
+
// statically costs nothing.
|
|
115
|
+
if (ttsModelEngine(modelId) === POCKET_ARCH) return pocketBundleOnDisk(ttsModel(modelId)?.bundle);
|
|
82
116
|
const dir = modelDir(modelId);
|
|
83
117
|
const suffix = DTYPE_SUFFIX[dtype] ?? '';
|
|
84
118
|
const need = [join(dir, 'onnx', `model${suffix}.onnx`), join(dir, 'tokenizer.json')];
|
|
@@ -104,6 +138,10 @@ export function init(cfg = {}) {
|
|
|
104
138
|
}
|
|
105
139
|
|
|
106
140
|
async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype: dtypeOverride = null } = {}) {
|
|
141
|
+
// Pocket TTS has its own loader (raw onnxruntime, its own bundle layout), so it
|
|
142
|
+
// is routed before any transformers.js machinery is touched.
|
|
143
|
+
if (ttsModelEngine(modelId) === POCKET_ARCH) return loadPocket(modelId, { log, allowDownload });
|
|
144
|
+
|
|
107
145
|
const prevNet = _net, prevModel = _model;
|
|
108
146
|
let lib;
|
|
109
147
|
try {
|
|
@@ -190,6 +228,36 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype:
|
|
|
190
228
|
}
|
|
191
229
|
}
|
|
192
230
|
|
|
231
|
+
async function loadPocket(modelId, { log = () => {}, allowDownload = true } = {}) {
|
|
232
|
+
const prevArch = _arch, prevPocket = _pocket, prevModel = _model;
|
|
233
|
+
const { PocketTTS, bundleOnDisk, DEFAULT_BUNDLE, SAMPLE_RATE: PR } = await import('./pocket-tts-engine.js');
|
|
234
|
+
const bundle = ttsModel(modelId)?.bundle || DEFAULT_BUNDLE;
|
|
235
|
+
if (!bundleOnDisk(bundle) && !allowDownload) {
|
|
236
|
+
_state = 'error'; _err = 'model not on disk and downloads disabled';
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
_state = bundleOnDisk(bundle) ? 'loading' : 'downloading';
|
|
240
|
+
if (_state === 'downloading') _progress = { model: modelId, file: null, pct: 0 };
|
|
241
|
+
try {
|
|
242
|
+
const pt = new PocketTTS();
|
|
243
|
+
await pt.load(bundle, {
|
|
244
|
+
log,
|
|
245
|
+
onProgress: ({ file, pct }) => { _progress = { model: modelId, file, pct }; },
|
|
246
|
+
});
|
|
247
|
+
_pocket = pt; _net = pt; _tok = null; _vocoder = null;
|
|
248
|
+
_model = modelId; _arch = POCKET_ARCH; _dtype = 'int8'; _rate = PR;
|
|
249
|
+
_state = 'ready'; _err = null; _progress = null;
|
|
250
|
+
return true;
|
|
251
|
+
} catch (e) {
|
|
252
|
+
// A failed switch keeps whatever was working, same as every other engine here.
|
|
253
|
+
_pocket = prevPocket; _arch = prevArch; _model = prevModel;
|
|
254
|
+
_state = prevPocket || _net ? 'ready' : 'error';
|
|
255
|
+
_err = e.message; _progress = null;
|
|
256
|
+
log(`[pocket-tts] load failed (${e.message})`);
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
193
261
|
export async function setModel(modelId, { onLog = () => {}, allowDownload = true, dtype = 'auto' } = {}) {
|
|
194
262
|
const want = dtype && dtype !== 'auto' ? dtype : (ttsModelDtype(modelId) || runtimeDtype());
|
|
195
263
|
if (modelId === _model && isReady() && _dtype === want) return true;
|
|
@@ -288,6 +356,15 @@ export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1, s
|
|
|
288
356
|
return out.waveform.data;
|
|
289
357
|
}
|
|
290
358
|
|
|
359
|
+
// Pocket TTS runs its own generation loop and chunking, so a whole utterance is
|
|
360
|
+
// handed over at once rather than being pre-split here.
|
|
361
|
+
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 });
|
|
366
|
+
}
|
|
367
|
+
|
|
291
368
|
// SpeechT5: conditioned by a 512-d speaker embedding, which is the whole point —
|
|
292
369
|
// it is the one architecture here that can be pointed at a person's voice.
|
|
293
370
|
// Without an embedding there is no voice to speak in, so this refuses rather
|
|
@@ -324,6 +401,9 @@ export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1, s
|
|
|
324
401
|
|
|
325
402
|
/** Synthesize arbitrary-length text, chunk by chunk. `onChunk` sees each as it lands. */
|
|
326
403
|
export async function synth(text, { voice = DEFAULT_TTS_VOICE, speed = 1, speakerEmbedding = null, onChunk = null } = {}) {
|
|
404
|
+
// Pocket TTS splits internally against its own token ceiling, so splitting again
|
|
405
|
+
// here would cut sentences twice and reset its state mid-thought.
|
|
406
|
+
if (_arch === POCKET_ARCH) return synthChunk(text, { voice, speed, speakerEmbedding });
|
|
327
407
|
const chunks = splitSentences(text);
|
|
328
408
|
const out = [];
|
|
329
409
|
for (const c of chunks) {
|
|
@@ -366,6 +446,6 @@ export function toWav(pcm, sampleRate = SAMPLE_RATE) {
|
|
|
366
446
|
|
|
367
447
|
export function _reset() {
|
|
368
448
|
_state = 'off'; _model = null; _net = null; _tok = null; _dtype = null;
|
|
369
|
-
_err = null; _progress = null; _initPromise = null; _arch = null; _rate = SAMPLE_RATE; _vocoder = null;
|
|
449
|
+
_err = null; _progress = null; _initPromise = null; _arch = null; _rate = SAMPLE_RATE; _vocoder = null; _pocket = null; _encoder = null;
|
|
370
450
|
_voices.clear();
|
|
371
451
|
}
|
package/src/tts-models.js
CHANGED
|
@@ -55,6 +55,26 @@ export const TTS_MODEL_CATALOG = [
|
|
|
55
55
|
voices: true,
|
|
56
56
|
note: 'The Mandarin-tuned Kokoro. Same engine and voice mechanism as v1.0.',
|
|
57
57
|
},
|
|
58
|
+
{
|
|
59
|
+
// The one model here BUILT for cloning. Its Mimi encoder turns a sample into a
|
|
60
|
+
// conditioning the model generates from directly, rather than borrowing a
|
|
61
|
+
// voice print from a space it was not trained on — which is why it reproduces
|
|
62
|
+
// a speaker where SpeechT5 merely produces a consistent stranger. Also the
|
|
63
|
+
// fastest: measured 8-9x realtime at int8, against Kokoro's 2.1x.
|
|
64
|
+
id: 'kyutai/pocket-tts',
|
|
65
|
+
label: 'Pocket TTS — clone your voice',
|
|
66
|
+
lang: 'English',
|
|
67
|
+
tier: 'accurate',
|
|
68
|
+
arch: 'pocket-tts',
|
|
69
|
+
bundle: 'english_2026-04',
|
|
70
|
+
approxMB: 146,
|
|
71
|
+
ramMB: 700,
|
|
72
|
+
sampleRate: 24000,
|
|
73
|
+
voices: false,
|
|
74
|
+
customVoices: true,
|
|
75
|
+
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.',
|
|
77
|
+
},
|
|
58
78
|
{
|
|
59
79
|
id: 'Xenova/speecht5_tts',
|
|
60
80
|
label: 'SpeechT5 — your own voice',
|
|
@@ -74,7 +94,7 @@ export const TTS_MODEL_CATALOG = [
|
|
|
74
94
|
sampleRate: 16000,
|
|
75
95
|
voices: false, // no built-in voices…
|
|
76
96
|
customVoices: true, // …but it is the ONE model here that can use yours
|
|
77
|
-
note: '
|
|
97
|
+
note: 'Speaks using a voice you record — but it will NOT sound like you. It borrows pitch and timbre in a general way and produces a consistent voice of its own. Kokoro sounds better if you do not need a personal voice.',
|
|
78
98
|
},
|
|
79
99
|
{
|
|
80
100
|
// One MMS entry so the second architecture is DISCOVERABLE from the list rather
|
|
@@ -137,6 +157,11 @@ export function ttsModelEngine(id) {
|
|
|
137
157
|
return ttsModel(id)?.arch || 'style-tts2';
|
|
138
158
|
}
|
|
139
159
|
|
|
160
|
+
// The bundle a pocket-tts catalog entry uses (its language pack).
|
|
161
|
+
export function ttsModelBundle(id) {
|
|
162
|
+
return ttsModel(id)?.bundle || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
140
165
|
// Does this catalog entry have selectable voices? Kokoro picks one from a style
|
|
141
166
|
// bank; VITS/MMS is single-speaker. Unknown (a searched model) resolves at load.
|
|
142
167
|
export function ttsModelHasVoices(id) {
|
package/src/tts-voices.js
CHANGED
|
@@ -25,6 +25,17 @@ import { randomUUID } from 'node:crypto';
|
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
|
|
27
27
|
export const EMBED_DIM = 512;
|
|
28
|
+
|
|
29
|
+
// A voice print is engine-specific: SpeechT5 wants a 512-d wavlm x-vector, Pocket
|
|
30
|
+
// TTS wants its Mimi encoder's [1, N, 1024] conditioning. They are NOT
|
|
31
|
+
// interchangeable, and the sample is discarded after saving, so whatever a voice
|
|
32
|
+
// might later need has to be computed WHILE the audio is still in hand.
|
|
33
|
+
//
|
|
34
|
+
// The pocket conditioning is ~100k floats — small on disk, absurd inside a JSON
|
|
35
|
+
// document that a settings page lists — so it lives beside the record as raw
|
|
36
|
+
// Float32 and only its shape is stored in the JSON.
|
|
37
|
+
export const KIND_SPEECHT5 = 'wavlm-512';
|
|
38
|
+
export const KIND_POCKET = 'pocket-mimi';
|
|
28
39
|
const MAX_VOICES = 20;
|
|
29
40
|
const MAX_NAME = 60;
|
|
30
41
|
|
|
@@ -62,22 +73,50 @@ export function listVoices() {
|
|
|
62
73
|
// The vector is deliberately NOT returned by the listing: the UI needs a
|
|
63
74
|
// name and an id, and 512 floats of someone's voice print have no business
|
|
64
75
|
// in a settings page's JSON.
|
|
65
|
-
if (v?.id && v?.name)
|
|
76
|
+
if (v?.id && v?.name) {
|
|
77
|
+
out.push({
|
|
78
|
+
id: v.id, name: v.name, createdAt: v.createdAt || 0, dim: v.vec?.length || 0,
|
|
79
|
+
kinds: v.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
66
82
|
} catch { /* a corrupt file must not break the list */ }
|
|
67
83
|
}
|
|
68
84
|
return out.sort((a, b) => b.createdAt - a.createdAt);
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
function pocketFileFor(id) {
|
|
88
|
+
return join(voicesDir(), `${id}.pocket.bin`);
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
export function getVoice(id) {
|
|
72
92
|
if (!isValidVoiceRef(id)) return null;
|
|
73
93
|
try {
|
|
74
94
|
const v = JSON.parse(readFileSync(fileFor(id), 'utf8'));
|
|
75
|
-
|
|
95
|
+
if (!Array.isArray(v?.vec) || v.vec.length !== EMBED_DIM) return null;
|
|
96
|
+
return v;
|
|
97
|
+
} catch { return null; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The Pocket TTS conditioning for a voice, or null if it has none saved. */
|
|
101
|
+
export function getPocketVoice(id) {
|
|
102
|
+
if (!isValidVoiceRef(id)) return null;
|
|
103
|
+
const meta = getVoice(id);
|
|
104
|
+
if (!meta?.pocketShape) return null;
|
|
105
|
+
try {
|
|
106
|
+
const buf = readFileSync(pocketFileFor(id));
|
|
107
|
+
return { data: new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4)), shape: meta.pocketShape };
|
|
76
108
|
} catch { return null; }
|
|
77
109
|
}
|
|
78
110
|
|
|
111
|
+
/** Which engines can speak as this voice. Drives what the UI may offer. */
|
|
112
|
+
export function voiceKinds(id) {
|
|
113
|
+
const v = getVoice(id);
|
|
114
|
+
if (!v) return [];
|
|
115
|
+
return v.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5];
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
/** Persist an embedding under a user-chosen name. Returns the stored record. */
|
|
80
|
-
export function saveVoice({ name, vec }) {
|
|
119
|
+
export function saveVoice({ name, vec, pocket = null }) {
|
|
81
120
|
const clean = String(name || '').trim().slice(0, MAX_NAME);
|
|
82
121
|
if (!clean) throw new Error('a name is required');
|
|
83
122
|
if (!vec || vec.length !== EMBED_DIM) throw new Error(`expected a ${EMBED_DIM}-value embedding, got ${vec?.length || 0}`);
|
|
@@ -85,8 +124,13 @@ export function saveVoice({ name, vec }) {
|
|
|
85
124
|
const dir = voicesDir();
|
|
86
125
|
mkdirSync(dir, { recursive: true });
|
|
87
126
|
const rec = { id: randomUUID(), name: clean, createdAt: Date.now(), vec: Array.from(vec, (x) => Number(x) || 0) };
|
|
127
|
+
if (pocket?.data?.length && Array.isArray(pocket.shape)) {
|
|
128
|
+
rec.pocketShape = pocket.shape;
|
|
129
|
+
const f32 = pocket.data instanceof Float32Array ? pocket.data : Float32Array.from(pocket.data);
|
|
130
|
+
writeFileSync(pocketFileFor(rec.id), Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength));
|
|
131
|
+
}
|
|
88
132
|
writeFileSync(fileFor(rec.id), JSON.stringify(rec));
|
|
89
|
-
return { id: rec.id, name: rec.name, createdAt: rec.createdAt, dim: rec.vec.length };
|
|
133
|
+
return { id: rec.id, name: rec.name, createdAt: rec.createdAt, dim: rec.vec.length, kinds: rec.pocketShape ? [KIND_SPEECHT5, KIND_POCKET] : [KIND_SPEECHT5] };
|
|
90
134
|
}
|
|
91
135
|
|
|
92
136
|
/** Remove one permanently. Returns whether there was anything to remove. */
|
|
@@ -95,5 +139,7 @@ export function deleteVoice(id) {
|
|
|
95
139
|
const p = fileFor(id);
|
|
96
140
|
if (!existsSync(p)) return false;
|
|
97
141
|
rmSync(p, { force: true });
|
|
142
|
+
// Both halves, or the conditioning outlives the record that named it.
|
|
143
|
+
rmSync(pocketFileFor(id), { force: true });
|
|
98
144
|
return true;
|
|
99
145
|
}
|