@chatpanel/gateway 0.6.55 → 0.6.57

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.57",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -119,8 +119,13 @@ export const DEFAULTS = {
119
119
  // client as everywhere else here; no key is stored. See src/tts-remote.js for
120
120
  // why remote synthesis redacts by default and what that costs.
121
121
  provider: 'local',
122
- model: 'onnx-community/Kokoro-82M-v1.0-ONNX',
123
- voice: 'af_heart',
122
+ // Empty = decide at runtime. Pocket TTS is the better default (fastest here,
123
+ // and the only engine that can speak as you) but needs the native onnxruntime,
124
+ // which the standalone binary does not carry — so hardcoding either one is
125
+ // wrong for half the installs. resolveDefaultModel() picks per runtime, and an
126
+ // explicit choice here always wins.
127
+ model: '',
128
+ voice: '',
124
129
  allowDownload: true,
125
130
  remote: {
126
131
  baseUrl: '', // '' = the provider's own default endpoint
package/src/ort.js ADDED
@@ -0,0 +1,51 @@
1
+ // One place to get an onnxruntime, for the engines that drive raw ONNX graphs:
2
+ // parakeet (STT) and pocket-tts (TTS). Transformers.js brings its own; these do
3
+ // not, and they were each resolving it separately.
4
+ //
5
+ // KNOWN LIMITATION — raw ORT does not work inside the standalone binary.
6
+ // The Bun build embeds the ORT runtime as a virtual path (.wasm) and a blob: URL
7
+ // (.mjs), and onnxruntime-web reaches for both with fetch(), which serves neither.
8
+ // Handing it the wasm BYTES, pointing wasmPaths at an extracted directory, and
9
+ // importing the wasm-only bundle were all tried; each moves the failure without
10
+ // removing it. Transformers.js works there because it configures its own bundled
11
+ // copy through its own env.
12
+ //
13
+ // This is NOT new — parakeet has had it since it shipped; it simply failed as an
14
+ // opaque "no available backend found" instead of saying so. Models that need this
15
+ // runtime are now marked `requiresNative` in their catalogs and are not offered on
16
+ // the binary, so the npm gateway is the answer rather than a broken download.
17
+
18
+ let _promise = null;
19
+
20
+ /** Is a raw-ONNX engine usable in this process? False inside the binary. */
21
+ export function rawOrtAvailable() {
22
+ return !globalThis.__CHATPANEL_WASM_PATHS__;
23
+ }
24
+
25
+ export function ortRuntimeName() {
26
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? 'wasm' : 'native';
27
+ }
28
+
29
+ // 'wasm' in the binary, 'cpu' on the native build. The wrong one makes ORT fall
30
+ // through its provider list and report a confusing "no available backend".
31
+ export function ortProviders() {
32
+ return globalThis.__CHATPANEL_WASM_PATHS__ ? ['wasm'] : ['cpu'];
33
+ }
34
+
35
+ export function getOrt() {
36
+ if (_promise) return _promise;
37
+ _promise = (async () => {
38
+ const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
39
+ if (!wasmPaths) {
40
+ const mod = await import('onnxruntime-node');
41
+ return mod.InferenceSession ? mod : (mod.default || mod);
42
+ }
43
+ // Say what is actually wrong. The alternative is ORT's own message, which
44
+ // describes a failed fetch and sends people looking for a corrupt download.
45
+ throw new Error(
46
+ 'this model needs the native onnxruntime, which the standalone binary does not carry — '
47
+ + 'install the npm gateway instead (npm i -g @chatpanel/gateway), or pick a model that runs on the bundled runtime',
48
+ );
49
+ })();
50
+ return _promise;
51
+ }
@@ -26,6 +26,7 @@ import { join } from 'node:path';
26
26
  import { existsSync, mkdirSync, readFileSync, createWriteStream, renameSync, statSync } from 'node:fs';
27
27
  import { Readable } from 'node:stream';
28
28
  import { modelRoot } from './ner-engine.js';
29
+ import { getOrt, ortProviders } from './ort.js';
29
30
 
30
31
  // transformers.js reports these `model_type`s for the transducer exports. Any of them
31
32
  // means "not a whisper pipeline model — route here instead".
@@ -72,20 +73,6 @@ export function parakeetOnDisk(modelId, dtype = PARAKEET_DEFAULT_DTYPE) {
72
73
  // The npm gateway uses native onnxruntime-node (fast). The standalone binary embeds the
73
74
  // onnxruntime-web WASM runtime and hands us its paths via __CHATPANEL_WASM_PATHS__ (the
74
75
  // same global ner-engine keys off) — configure ORT-web from it. Memoized once.
75
- let _ortPromise = null;
76
- function getOrt() {
77
- if (_ortPromise) return _ortPromise;
78
- _ortPromise = (async () => {
79
- const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
80
- const mod = await import(wasmPaths ? 'onnxruntime-web' : 'onnxruntime-node');
81
- const ort = mod.InferenceSession ? mod : (mod.default || mod);
82
- if (wasmPaths) {
83
- try { ort.env.wasm.numThreads = 1; ort.env.wasm.proxy = false; ort.env.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
84
- }
85
- return ort;
86
- })();
87
- return _ortPromise;
88
- }
89
76
 
90
77
  // ── download (only when a model isn't already on disk) ───────────────────────────────
91
78
  // Custom/BYO STT ids aren't on the dl.chatpanel.net mirror, so — like stt-engine's
@@ -166,7 +153,7 @@ export async function loadRecognizer({ modelId, dtype = PARAKEET_DEFAULT_DTYPE,
166
153
 
167
154
  const ort = await getOrt();
168
155
  const s = dt === 'fp32' ? '' : '.int8';
169
- const opts = { executionProviders: ['cpu'], graphOptimizationLevel: 'all', logSeverityLevel: 3 };
156
+ const opts = { executionProviders: ortProviders(), graphOptimizationLevel: 'all', logSeverityLevel: 3 };
170
157
  const [prep, encoder, decoder] = await Promise.all([
171
158
  ort.InferenceSession.create(join(dir, 'nemo128.onnx'), opts),
172
159
  ort.InferenceSession.create(join(dir, `encoder-model${s}.onnx`), opts),
@@ -0,0 +1,512 @@
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
+ import { getOrt, ortProviders } from './ort.js';
31
+
32
+ export const POCKET_REPO = 'KevinAHM/pocket-tts-onnx';
33
+ // The built-in speakers live in the demo Space, not the weights repo — the weights
34
+ // repo 404s on voices.bin. Same author, same licence, different HF namespace.
35
+ export const POCKET_VOICES_SPACE = 'KevinAHM/pocket-tts-web';
36
+ export const DEFAULT_BUNDLE = 'english_2026-04';
37
+ export const SAMPLE_RATE = 24000;
38
+
39
+ // Generation constants, carried over from the reference implementation.
40
+ const MAX_FRAMES = 500; // hard ceiling per chunk (~40s at 12.5 fps)
41
+ const LSD_STEPS = 1; // flow-matching steps per frame
42
+ const TEMPERATURE = 0.7;
43
+ const EOS_LOGIT_THRESHOLD = -4.0;
44
+ const FIRST_CHUNK_FRAMES = 3; // decode early so audio starts sooner
45
+ const NORMAL_CHUNK_FRAMES = 12;
46
+ // The reference resets both states per text chunk; keeping them would let one
47
+ // sentence's trailing state colour the next one's opening.
48
+ const RESET_STATE_EACH_CHUNK = true;
49
+
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
+ // voices.bin is a separate ~52 MB download for the eight built-in speakers, and it
56
+ // is OPTIONAL: cloning works without it, so a failed or skipped fetch costs the
57
+ // stock voices and nothing else.
58
+ const VOICES_FILE = 'voices.bin';
59
+
60
+
61
+ export function bundleDir(bundle = DEFAULT_BUNDLE) {
62
+ return join(modelRoot(), 'pocket-tts', bundle);
63
+ }
64
+
65
+ export function bundleOnDisk(bundle = DEFAULT_BUNDLE, quant = '_int8') {
66
+ const dir = bundleDir(bundle);
67
+ try {
68
+ return FILES(quant).every((f) => { const p = join(dir, f); return existsSync(p) && statSync(p).size > 0; });
69
+ } catch { return false; }
70
+ }
71
+
72
+ async function downloadFile(bundle, file, dir, { onProgress, log } = {}) {
73
+ const url = file === VOICES_FILE
74
+ ? `https://huggingface.co/spaces/${POCKET_VOICES_SPACE}/resolve/main/onnx/${bundle}/${file}`
75
+ : `https://huggingface.co/${POCKET_REPO}/resolve/main/onnx/${bundle}/${file}`;
76
+ const res = await fetch(url, { redirect: 'follow' });
77
+ if (!res.ok || !res.body) throw new Error(`fetch ${file} → HTTP ${res.status}`);
78
+ const total = Number(res.headers.get('content-length')) || 0;
79
+ const tmp = join(dir, `${file}.part`);
80
+ const out = createWriteStream(tmp);
81
+ let got = 0, lastPct = -1;
82
+ const src = Readable.fromWeb(res.body);
83
+ src.on('data', (c) => {
84
+ got += c.length;
85
+ if (total) { const pct = Math.round((got / total) * 100); if (pct !== lastPct) { lastPct = pct; onProgress?.({ file, pct }); } }
86
+ });
87
+ await new Promise((resolve, reject) => { src.pipe(out); out.on('finish', resolve); out.on('error', reject); src.on('error', reject); });
88
+ // .part then rename, so an interrupted fetch never reads as installed.
89
+ renameSync(tmp, join(dir, file));
90
+ log?.(`[pocket-tts] fetched ${file}`);
91
+ }
92
+
93
+ export async function ensureBundle(bundle = DEFAULT_BUNDLE, quant = '_int8', { onProgress, log } = {}) {
94
+ const dir = bundleDir(bundle);
95
+ mkdirSync(dir, { recursive: true });
96
+ for (const file of FILES(quant)) {
97
+ const dest = join(dir, file);
98
+ if (existsSync(dest) && statSync(dest).size > 0) continue;
99
+ log?.(`[pocket-tts] downloading ${file}…`);
100
+ await downloadFile(bundle, file, dir, { onProgress, log });
101
+ }
102
+ return dir;
103
+ }
104
+
105
+ /** Fetch the built-in speakers. Optional — failure leaves cloning fully working. */
106
+ export async function ensureVoicesBin(bundle = DEFAULT_BUNDLE, { onProgress, log } = {}) {
107
+ const dir = bundleDir(bundle);
108
+ const dest = join(dir, VOICES_FILE);
109
+ if (existsSync(dest) && statSync(dest).size > 1024) return dest;
110
+ mkdirSync(dir, { recursive: true });
111
+ log?.(`[pocket-tts] downloading ${VOICES_FILE} (built-in voices, ~52 MB)…`);
112
+ await downloadFile(bundle, VOICES_FILE, dir, { onProgress, log });
113
+ return dest;
114
+ }
115
+
116
+ export function voicesBinOnDisk(bundle = DEFAULT_BUNDLE) {
117
+ try { const p = join(bundleDir(bundle), VOICES_FILE); return existsSync(p) && statSync(p).size > 1024; } catch { return false; }
118
+ }
119
+
120
+ // ── .npy (float32) ──────────────────────────────────────────────────────────────
121
+ export function parseNpyFloat32(buf) {
122
+ const magic = [0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59];
123
+ for (let i = 0; i < magic.length; i++) if (buf[i] !== magic[i]) throw new Error('not an NPY file');
124
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
125
+ const major = view.getUint8(6);
126
+ const headerLen = major === 1 ? view.getUint16(8, true) : view.getUint32(8, true);
127
+ const headerOffset = major === 1 ? 10 : 12;
128
+ const header = new TextDecoder().decode(buf.subarray(headerOffset, headerOffset + headerLen));
129
+ const m = /\(\s*([0-9,\s]*)\)/.exec(header);
130
+ if (!m) throw new Error('could not parse NPY shape');
131
+ const shape = m[1].split(',').map((x) => x.trim()).filter(Boolean).map((x) => parseInt(x, 10));
132
+ const start = headerOffset + headerLen;
133
+ const data = new Float32Array((buf.byteLength - start) / 4);
134
+ for (let i = 0; i < data.length; i++) data[i] = view.getFloat32(start + i * 4, true);
135
+ return { data, shape };
136
+ }
137
+
138
+ // ── state manifests ─────────────────────────────────────────────────────────────
139
+ // Both stateful graphs describe their own state in bundle.json: which inputs to
140
+ // seed, with what shape and fill, and which outputs feed them next turn. Reading
141
+ // it beats hardcoding 18 + 56 tensor names that differ per language bundle.
142
+ function filledArray(shape, dtype, fill) {
143
+ const size = shape.reduce((a, b) => a * b, 1);
144
+ if (dtype === 'int64') return new BigInt64Array(size);
145
+ if (dtype === 'bool') return new Uint8Array(size);
146
+ const d = new Float32Array(size);
147
+ if (fill === 'nan') d.fill(NaN);
148
+ else if (fill === 'ones') d.fill(1);
149
+ return d;
150
+ }
151
+
152
+ function initState(ort, manifest) {
153
+ const state = {};
154
+ for (const e of manifest) state[e.input_name] = new ort.Tensor(e.dtype, filledArray(e.shape, e.dtype, e.fill), e.shape);
155
+ return state;
156
+ }
157
+
158
+ function advanceState(state, result, manifest) {
159
+ for (const e of manifest) state[e.input_name] = result[e.output_name];
160
+ }
161
+
162
+
163
+ // ── built-in speakers ───────────────────────────────────────────────────────────
164
+ // voices.bin (PTVB1) is a flat table of per-voice tensor STATES — the model's
165
+ // internal state after it has been conditioned on that speaker — rather than
166
+ // audio or embeddings. Loading one is therefore not "encode a voice" but "restore
167
+ // the state a voice produces", which is why it takes a different path from cloning.
168
+ export function parseVoicesBin(buf) {
169
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
170
+ const view = new DataView(ab);
171
+ let off = 0;
172
+ const magic = new TextDecoder().decode(new Uint8Array(ab, 0, 5));
173
+ off += 5;
174
+ if (magic !== 'PTVB1') throw new Error('not a voices.bin (bad header)');
175
+ const voices = {};
176
+ const count = view.getUint32(off, true); off += 4;
177
+ for (let v = 0; v < count; v++) {
178
+ const nameLen = view.getUint16(off, true); off += 2;
179
+ const name = new TextDecoder().decode(new Uint8Array(ab, off, nameLen)); off += nameLen;
180
+ const tensorCount = view.getUint16(off, true); off += 2;
181
+ const tensors = {};
182
+ for (let t = 0; t < tensorCount; t++) {
183
+ const keyLen = view.getUint16(off, true); off += 2;
184
+ const key = new TextDecoder().decode(new Uint8Array(ab, off, keyLen)); off += keyLen;
185
+ const dtypeCode = view.getUint8(off); off += 1;
186
+ const rank = view.getUint8(off); off += 1;
187
+ const shape = [];
188
+ for (let d = 0; d < rank; d++) { shape.push(view.getUint32(off, true)); off += 4; }
189
+ const byteLength = view.getUint32(off, true); off += 4;
190
+ const slice = ab.slice(off, off + byteLength);
191
+ const data = dtypeCode === 0 ? new Float32Array(slice)
192
+ : dtypeCode === 1 ? new BigInt64Array(slice)
193
+ : dtypeCode === 2 ? new Uint8Array(slice)
194
+ : (() => { throw new Error(`unsupported voices.bin dtype ${dtypeCode}`); })();
195
+ off += byteLength;
196
+ tensors[key] = { data, shape, dtype: dtypeCode === 0 ? 'float32' : dtypeCode === 1 ? 'int64' : 'bool' };
197
+ }
198
+ voices[name] = tensors;
199
+ }
200
+ return voices;
201
+ }
202
+
203
+ // A saved state is keyed "module/tensor"; the manifest addresses the same tensors
204
+ // by module + key, so the flat table is regrouped before it can be matched.
205
+ function groupByModule(record) {
206
+ const grouped = {};
207
+ for (const [k, v] of Object.entries(record)) {
208
+ const i = k.indexOf('/');
209
+ if (i === -1) continue;
210
+ (grouped[k.slice(0, i)] ||= {})[k.slice(i + 1)] = v;
211
+ }
212
+ return grouped;
213
+ }
214
+
215
+ // Some modules record their position under a different name (or not at all), so
216
+ // the manifest's `step` is derived from whichever the module actually kept.
217
+ function deriveStep(moduleState) {
218
+ if (moduleState.step) return { data: BigInt64Array.from([BigInt(moduleState.step.data[0])]), shape: [1], dtype: 'int64' };
219
+ if (moduleState.offset && !moduleState.end_offset) return { data: BigInt64Array.from([BigInt(moduleState.offset.data[0])]), shape: [1], dtype: 'int64' };
220
+ if (moduleState.current_end) return { data: BigInt64Array.from([BigInt(moduleState.current_end.shape[0])]), shape: [1], dtype: 'int64' };
221
+ return { data: BigInt64Array.from([0n]), shape: [1], dtype: 'int64' };
222
+ }
223
+
224
+ // A saved tensor may not match the manifest's shape exactly (a shorter cache, say).
225
+ // Copy what overlaps into a correctly-shaped, correctly-filled target rather than
226
+ // handing ORT a tensor of the wrong rank.
227
+ function adaptTensor(source, entry) {
228
+ const target = filledArray(entry.shape, entry.dtype, entry.fill);
229
+ const targetSize = entry.shape.reduce((a, b) => a * b, 1);
230
+ const Ctor = entry.dtype === 'int64' ? BigInt64Array : entry.dtype === 'bool' ? Uint8Array : Float32Array;
231
+ const sameRank = source.shape.length === entry.shape.length;
232
+ if ((sameRank && source.shape.every((d, i) => d === entry.shape[i])) || source.data.length === targetSize) {
233
+ return new Ctor(source.data);
234
+ }
235
+ if (!sameRank) return target;
236
+ const strides = [];
237
+ let stride = 1;
238
+ for (let i = source.shape.length - 1; i >= 0; i--) { strides[i] = stride; stride *= source.shape[i]; }
239
+ const idx = new Array(source.shape.length).fill(0);
240
+ const max = source.shape.map((d, i) => Math.min(d, entry.shape[i]));
241
+ if (max.some((m) => m === 0)) return target;
242
+ for (;;) {
243
+ let si = 0;
244
+ for (let i = 0; i < idx.length; i++) si += idx[i] * strides[i];
245
+ let ti = 0, ts = 1;
246
+ for (let i = entry.shape.length - 1; i >= 0; i--) { ti += idx[i] * ts; ts *= entry.shape[i]; }
247
+ target[ti] = source.data[si];
248
+ let dim = idx.length - 1;
249
+ for (; dim >= 0; dim--) {
250
+ if (++idx[dim] < max[dim]) break;
251
+ idx[dim] = 0;
252
+ if (dim === 0) return target;
253
+ }
254
+ if (dim < 0) return target;
255
+ }
256
+ }
257
+
258
+ export class PocketTTS {
259
+ constructor() {
260
+ this.ready = false;
261
+ this.bundle = null;
262
+ this.meta = null;
263
+ this.tok = null;
264
+ this.bos = null;
265
+ this.sessions = null;
266
+ this.st = null; // precomputed flow-matching s/t pairs
267
+ this.latentDim = 32;
268
+ this.condDim = 1024;
269
+ this.samplesPerFrame = 1920;
270
+ this.builtin = null; // name → saved tensor state, from voices.bin
271
+ }
272
+
273
+ /** The built-in speaker names available, or [] if voices.bin was not fetched. */
274
+ builtinVoices() { return this.builtin ? Object.keys(this.builtin).sort() : []; }
275
+
276
+ async load(bundle = DEFAULT_BUNDLE, { quant = '_int8', onProgress, log = () => {} } = {}) {
277
+ const dir = await ensureBundle(bundle, quant, { onProgress, log });
278
+ const ort = await getOrt();
279
+ this.meta = JSON.parse(readFileSync(join(dir, 'bundle.json'), 'utf8'));
280
+ this.tok = new SentencePieceUnigram(new Uint8Array(readFileSync(join(dir, 'tokenizer.model'))));
281
+ this.bos = this.meta.insert_bos_before_voice ? parseNpyFloat32(readFileSync(join(dir, 'bos_before_voice.npy'))) : null;
282
+ this.latentDim = Number(this.meta.latent_dim) || 32;
283
+ this.condDim = Number(this.meta.conditioning_dim) || 1024;
284
+ this.samplesPerFrame = Math.round(SAMPLE_RATE / (Number(this.meta.frame_rate) || 12.5));
285
+
286
+ const opts = { executionProviders: ortProviders(), graphOptimizationLevel: 'all', logSeverityLevel: 3 };
287
+ const [textConditioner, mimiEncoder, mimiDecoder, flowMain, flowFlow] = await Promise.all([
288
+ ort.InferenceSession.create(join(dir, `text_conditioner${quant}.onnx`), opts),
289
+ ort.InferenceSession.create(join(dir, `mimi_encoder${quant}.onnx`), opts),
290
+ ort.InferenceSession.create(join(dir, `mimi_decoder${quant}.onnx`), opts),
291
+ ort.InferenceSession.create(join(dir, `flow_lm_main${quant}.onnx`), opts),
292
+ ort.InferenceSession.create(join(dir, `flow_lm_flow${quant}.onnx`), opts),
293
+ ]);
294
+ this.sessions = { ort, textConditioner, mimiEncoder, mimiDecoder, flowMain, flowFlow };
295
+
296
+ // Flow matching walks s → t in fixed steps; the tensors never change, so they
297
+ // are built once rather than per frame (this runs MAX_FRAMES times a chunk).
298
+ this.st = [];
299
+ const dt = 1 / LSD_STEPS;
300
+ for (let i = 0; i < LSD_STEPS; i++) {
301
+ const s = i / LSD_STEPS;
302
+ this.st.push({
303
+ s: new ort.Tensor('float32', new Float32Array([s]), [1, 1]),
304
+ t: new ort.Tensor('float32', new Float32Array([s + dt]), [1, 1]),
305
+ });
306
+ }
307
+ // Built-in speakers are optional: a missing or unreadable voices.bin costs the
308
+ // stock voices and leaves cloning — the reason this engine exists — untouched.
309
+ try {
310
+ if (voicesBinOnDisk(bundle)) this.builtin = parseVoicesBin(readFileSync(join(dir, 'voices.bin')));
311
+ } catch (e) { log(`[pocket-tts] built-in voices unavailable (${e.message})`); }
312
+
313
+ this.bundle = bundle;
314
+ this.ready = true;
315
+ log(`[pocket-tts] ready — ${bundle} (${quant.replace('_', '') || 'fp32'}, ${SAMPLE_RATE} Hz)`);
316
+ return true;
317
+ }
318
+
319
+ /**
320
+ * THE CLONING STEP. A few seconds of 24 kHz mono audio → the voice conditioning
321
+ * that seeds generation. Returns { data, shape } to be stored as the voice print.
322
+ */
323
+ async encodeVoice(audio) {
324
+ if (!this.ready) throw new Error('pocket-tts not loaded');
325
+ const { ort, mimiEncoder } = this.sessions;
326
+ const pcm = audio instanceof Float32Array ? audio : Float32Array.from(audio);
327
+ const out = await mimiEncoder.run({ audio: new ort.Tensor('float32', pcm, [1, 1, pcm.length]) });
328
+ const emb = out[mimiEncoder.outputNames[0]];
329
+ let dims = emb.dims.slice();
330
+ while (dims.length > 3 && dims[0] === 1) dims = dims.slice(1);
331
+ if (dims.length < 3) dims = [1, dims[0], dims[1]];
332
+ return { data: Float32Array.from(emb.data), shape: dims };
333
+ }
334
+
335
+ // The voice conditioning is fed to flow_lm_main in the TEXT-embedding slot,
336
+ // preceded by the bundle's BOS frames — the model is told "this is how the
337
+ // speaker sounds" before it is told what to say.
338
+ #voiceTensor(voice) {
339
+ const { ort } = this.sessions;
340
+ let data = voice.data instanceof Float32Array ? voice.data : Float32Array.from(voice.data);
341
+ let dims = voice.shape.slice();
342
+ if (this.meta.insert_bos_before_voice && this.bos) {
343
+ const combined = new Float32Array(this.bos.data.length + data.length);
344
+ combined.set(this.bos.data, 0);
345
+ combined.set(data, this.bos.data.length);
346
+ data = combined;
347
+ dims = [1, dims[1] + this.bos.shape[1], dims[2]];
348
+ }
349
+ return new ort.Tensor('float32', data, dims);
350
+ }
351
+
352
+ // Restore the flow-LM state a built-in speaker was saved with.
353
+ #builtinState(name) {
354
+ const record = this.builtin?.[name];
355
+ if (!record) throw new Error(`unknown built-in voice: ${name}`);
356
+ const { ort } = this.sessions;
357
+ const grouped = groupByModule(record);
358
+ const state = initState(ort, this.meta.flow_lm_state_manifest);
359
+ for (const e of this.meta.flow_lm_state_manifest) {
360
+ const moduleState = grouped[e.module] || {};
361
+ const source = moduleState[e.key] || (e.key === 'step' ? deriveStep(moduleState) : null);
362
+ if (!source) continue;
363
+ state[e.input_name] = new ort.Tensor(e.dtype, adaptTensor(source, e), e.shape);
364
+ }
365
+ return state;
366
+ }
367
+
368
+ async #voiceState(voice) {
369
+ const { ort, flowMain } = this.sessions;
370
+ const state = initState(ort, this.meta.flow_lm_state_manifest);
371
+ const result = await flowMain.run({
372
+ sequence: new ort.Tensor('float32', new Float32Array(0), [1, 0, this.latentDim]),
373
+ text_embeddings: this.#voiceTensor(voice),
374
+ ...state,
375
+ });
376
+ advanceState(state, result, this.meta.flow_lm_state_manifest);
377
+ return state;
378
+ }
379
+
380
+ // Normalization the model expects: one line, capitalized, terminated.
381
+ #prepare(text) {
382
+ let s = String(text).replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
383
+ if (!s) return { text: '', framesAfterEos: 1 };
384
+ if (this.meta.remove_semicolons) s = s.replace(/;/g, ',');
385
+ const words = s.split(/\s+/).filter(Boolean).length;
386
+ let framesAfterEos = words <= 4 ? 3 : 1;
387
+ if (this.meta.model_recommended_frames_after_eos != null) framesAfterEos = Number(this.meta.model_recommended_frames_after_eos);
388
+ if (!/[A-ZÀ-Þ]/.test(s[0])) s = s[0].toUpperCase() + s.slice(1);
389
+ if (/[0-9A-Za-zÀ-ÿ]/.test(s[s.length - 1])) s += '.';
390
+ if (this.meta.pad_with_spaces_for_short_inputs && words < 5) s = ` ${s}`;
391
+ return { text: s, framesAfterEos };
392
+ }
393
+
394
+ // One forward pass per FRAME, so a long paragraph in a single chunk is a long
395
+ // time before any audio. Split on sentences, then on the model's token ceiling.
396
+ #chunks(text) {
397
+ const maxTokens = Number(this.meta.max_token_per_chunk) || 50;
398
+ const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text];
399
+ const out = [];
400
+ for (const raw of sentences) {
401
+ const s = raw.trim();
402
+ if (!s) continue;
403
+ const ids = this.tok.encodeIds(s);
404
+ if (ids.length <= maxTokens) { out.push(s); continue; }
405
+ for (let i = 0; i < ids.length; i += maxTokens) {
406
+ const part = this.tok.decodeIds(ids.slice(i, i + maxTokens)).trim();
407
+ if (part) out.push(part);
408
+ }
409
+ }
410
+ return out.length ? out : [text];
411
+ }
412
+
413
+ /**
414
+ * Synthesize. `voice` is what encodeVoice() returned. `onAudio(pcm)` receives
415
+ * each decoded piece as it lands, so a caller can stream; the whole waveform is
416
+ * returned as well.
417
+ */
418
+ async synth(text, { voice, onAudio = null } = {}) {
419
+ if (!this.ready) throw new Error('pocket-tts not loaded');
420
+ // Either a cloned voice ({data, shape} from encodeVoice) or a built-in name.
421
+ if (typeof voice === 'string') {
422
+ if (!this.builtin?.[voice]) throw new Error(`unknown built-in voice: ${voice}`);
423
+ } else if (!voice?.data?.length) {
424
+ throw new Error('pocket-tts needs a voice — record one, or pick a built-in');
425
+ }
426
+ const { ort, textConditioner, flowMain, flowFlow, mimiDecoder } = this.sessions;
427
+ const prepared = this.#prepare(text);
428
+ if (!prepared.text) return new Float32Array(0);
429
+ const chunks = this.#chunks(prepared.text);
430
+ const baseFlow = typeof voice === 'string' ? this.#builtinState(voice) : await this.#voiceState(voice);
431
+
432
+ const emptySeq = new ort.Tensor('float32', new Float32Array(0), [1, 0, this.latentDim]);
433
+ const emptyText = new ort.Tensor('float32', new Float32Array(0), [1, 0, this.condDim]);
434
+ let mimiState = initState(ort, this.meta.mimi_state_manifest);
435
+ let flowState = { ...baseFlow };
436
+ const pieces = [];
437
+ let first = true;
438
+
439
+ for (let c = 0; c < chunks.length; c++) {
440
+ if (RESET_STATE_EACH_CHUNK && c > 0) {
441
+ flowState = { ...baseFlow };
442
+ mimiState = initState(ort, this.meta.mimi_state_manifest);
443
+ }
444
+ const ids = this.tok.encodeIds(chunks[c]);
445
+ const tokens = new ort.Tensor('int64', BigInt64Array.from(ids, (t) => BigInt(t)), [1, ids.length]);
446
+ let textEmb = (await textConditioner.run({ token_ids: tokens }))[textConditioner.outputNames[0]];
447
+ if (textEmb.dims.length === 2) textEmb = new ort.Tensor('float32', Float32Array.from(textEmb.data), [1, textEmb.dims[0], textEmb.dims[1]]);
448
+
449
+ // Prime the LM with the text, then generate frames from silence.
450
+ advanceState(flowState, await flowMain.run({ sequence: emptySeq, text_embeddings: textEmb, ...flowState }), this.meta.flow_lm_state_manifest);
451
+
452
+ const latents = [];
453
+ let decoded = 0;
454
+ let cur = new ort.Tensor('float32', new Float32Array(this.latentDim).fill(NaN), [1, 1, this.latentDim]);
455
+ let eosAt = null;
456
+
457
+ for (let step = 0; step < MAX_FRAMES; step++) {
458
+ const ar = await flowMain.run({ sequence: cur, text_embeddings: emptyText, ...flowState });
459
+ const conditioning = ar.conditioning;
460
+ if (ar.eos_logit.data[0] > EOS_LOGIT_THRESHOLD && eosAt == null) eosAt = step;
461
+ const stop = eosAt != null && step >= eosAt + prepared.framesAfterEos;
462
+
463
+ // Start from Gaussian noise, then walk it along the learned flow field.
464
+ const std = Math.sqrt(TEMPERATURE);
465
+ const latent = new Float32Array(this.latentDim);
466
+ for (let i = 0; i < this.latentDim; i++) {
467
+ let u = 0, v = 0;
468
+ while (u === 0) u = Math.random();
469
+ while (v === 0) v = Math.random();
470
+ latent[i] = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) * std;
471
+ }
472
+ const dt = 1 / LSD_STEPS;
473
+ for (let k = 0; k < LSD_STEPS; k++) {
474
+ 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]) });
475
+ const dir = f.flow_dir.data;
476
+ for (let i = 0; i < this.latentDim; i++) latent[i] += dir[i] * dt;
477
+ }
478
+
479
+ latents.push(Float32Array.from(latent));
480
+ cur = new ort.Tensor('float32', latent, [1, 1, this.latentDim]);
481
+ advanceState(flowState, ar, this.meta.flow_lm_state_manifest);
482
+
483
+ // Decode in small batches — the first deliberately tiny so sound starts
484
+ // early, the rest larger because the decoder is cheaper in bulk.
485
+ const pending = latents.length - decoded;
486
+ let take = 0;
487
+ if (stop) take = pending;
488
+ else if (first && pending >= FIRST_CHUNK_FRAMES) take = FIRST_CHUNK_FRAMES;
489
+ else if (pending >= NORMAL_CHUNK_FRAMES) take = NORMAL_CHUNK_FRAMES;
490
+
491
+ if (take > 0) {
492
+ const buf = new Float32Array(take * this.latentDim);
493
+ for (let f = 0; f < take; f++) buf.set(latents[decoded + f], f * this.latentDim);
494
+ const dec = await mimiDecoder.run({ latent: new ort.Tensor('float32', buf, [1, take, this.latentDim]), ...mimiState });
495
+ advanceState(mimiState, dec, this.meta.mimi_state_manifest);
496
+ decoded += take;
497
+ first = false;
498
+ const pcm = Float32Array.from(dec[mimiDecoder.outputNames[0]].data);
499
+ pieces.push(pcm);
500
+ onAudio?.(pcm);
501
+ }
502
+ if (stop) break;
503
+ }
504
+ }
505
+
506
+ const total = pieces.reduce((n, p) => n + p.length, 0);
507
+ const out = new Float32Array(total);
508
+ let at = 0;
509
+ for (const p of pieces) { out.set(p, at); at += p.length; }
510
+ return out;
511
+ }
512
+ }