@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.55",
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.55';
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 vec = await diarizeEngine.embed(Float32Array.from(pcm));
1114
- const saved = ttsVoices.saveVoice({ name, vec });
1115
- console.log(`[tts] saved custom voice "${saved.name}" (${saved.dim}-d, sample discarded)`);
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
- speakerEmbedding = rec.vec;
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
- export function supportsCustomVoices() { return _arch === 'speecht5'; }
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: 'The only model here that speaks in a voice you record. Rougher than Kokoro, and the match is approximate see the note under Your voices.',
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) out.push({ id: v.id, name: v.name, createdAt: v.createdAt || 0, dim: v.vec?.length || 0 });
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
- return Array.isArray(v?.vec) && v.vec.length === EMBED_DIM ? v : null;
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
  }