@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.
@@ -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
@@ -40,8 +40,9 @@ import * as diarizeEngine from './diarize-engine.js';
40
40
  import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
41
41
  import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
42
42
  import * as ttsEngine from './tts-engine.js';
43
- import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS, ttsModelHasCustomVoices } from './tts-models.js';
43
+ import { TTS_MODEL_CATALOG, TTS_VOICES, isKnownTtsModel, isValidCustomTtsId, isKnownVoice, isValidVoiceId, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, TTS_DTYPES, isValidTtsDtype, MAX_TTS_CHARS, ttsModelHasCustomVoices, ttsModelRequiresNative, resolveDefaultModel, POCKET_VOICES, DEFAULT_POCKET_VOICE, isPocketVoice, ttsModelEngine as ttsModelEngineOf } from './tts-models.js';
44
44
  import { ttsDestination, synthesizeRemote, isValidRemoteVoice } from './tts-remote.js';
45
+ import { rawOrtAvailable } from './ort.js';
45
46
  import * as ttsVoices from './tts-voices.js';
46
47
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
47
48
  import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
@@ -53,7 +54,7 @@ import * as openai from './openai.js';
53
54
  import * as responses from './responses.js';
54
55
  import * as anthropic from './anthropic.js';
55
56
 
56
- export const VERSION = '0.6.55';
57
+ export const VERSION = '0.6.57';
57
58
 
58
59
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
59
60
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -462,6 +463,23 @@ export function joinUpstream(base, pathname, search = '') {
462
463
  // short enough that a stuck download does not hold a request open forever.
463
464
  const EMBEDDER_WAIT_MS = 90_000;
464
465
 
466
+ // Linear resample. Good enough for a voice-print sample — the encoder cares about
467
+ // timbre, not the last decibel of fidelity — and it avoids a dependency for one
468
+ // rate conversion.
469
+ function resample(input, from, to) {
470
+ if (from === to) return input;
471
+ const ratio = from / to;
472
+ const out = new Float32Array(Math.floor(input.length / ratio));
473
+ for (let i = 0; i < out.length; i++) {
474
+ const pos = i * ratio;
475
+ const a = Math.floor(pos);
476
+ const b = Math.min(input.length - 1, a + 1);
477
+ const f = pos - a;
478
+ out[i] = input[a] * (1 - f) + input[b] * f;
479
+ }
480
+ return out;
481
+ }
482
+
465
483
  const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/config', '/logs', '/status', '/admin'];
466
484
 
467
485
  async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
@@ -981,8 +999,17 @@ export function createGateway(cfg = loadConfig()) {
981
999
  // docs/voice-pipeline.md. Model manager first, then synthesis.
982
1000
  if (pathname === '/tts/models') {
983
1001
  if (req.method === 'GET') {
984
- const active = ttsEngine.health().model || cfg.tts?.model || DEFAULT_TTS_MODEL;
985
- const available = /** @type {any[]} */ (TTS_MODEL_CATALOG.map((m) => ({ ...m, installed: ttsEngine.modelOnDisk(m.id) })));
1002
+ const active = ttsEngine.health().model || cfg.tts?.model || resolveDefaultModel(rawOrtAvailable());
1003
+ // A model needing the native runtime is still LISTED on the binary, with the
1004
+ // reason — hiding it makes "why can't I clone my voice?" unanswerable.
1005
+ const nativeOk = rawOrtAvailable();
1006
+ const available = /** @type {any[]} */ (TTS_MODEL_CATALOG.map((m) => ({
1007
+ ...m,
1008
+ installed: ttsEngine.modelOnDisk(m.id),
1009
+ unavailable: m.requiresNative && !nativeOk
1010
+ ? 'needs the npm gateway — the standalone binary cannot load this engine'
1011
+ : undefined,
1012
+ })));
986
1013
  if (active && !available.some((m) => m.id === active)) {
987
1014
  available.push({ id: active, label: active, lang: '—', tier: 'custom', custom: true, installed: ttsEngine.modelOnDisk(active), note: 'Custom model (from Hugging Face).' });
988
1015
  }
@@ -991,7 +1018,9 @@ export function createGateway(cfg = loadConfig()) {
991
1018
  state: ttsEngine.state(),
992
1019
  progress: ttsEngine.progress(),
993
1020
  available,
994
- voice: cfg.tts?.voice || DEFAULT_TTS_VOICE,
1021
+ // The default voice belongs to the model's own namespace: Kokoro's
1022
+ // af_heart means nothing to Pocket, and vice versa.
1023
+ voice: cfg.tts?.voice || (ttsModelEngineOf(active) === 'pocket-tts' ? DEFAULT_POCKET_VOICE : DEFAULT_TTS_VOICE),
995
1024
  // Architecture decides whether voices mean anything: Kokoro picks one from
996
1025
  // a style bank, VITS/MMS is single-speaker. An empty list tells the UI to
997
1026
  // hide the picker rather than offer choices that cannot take effect.
@@ -1002,9 +1031,14 @@ export function createGateway(cfg = loadConfig()) {
1002
1031
  // Built-in voices belong to Kokoro alone. VITS is single-speaker and
1003
1032
  // SpeechT5 speaks only in a RECORDED voice, so offering Kokoro's list
1004
1033
  // for either would be offering choices that cannot take effect.
1005
- voices: ttsEngine.arch() && ttsEngine.arch() !== 'style-tts2'
1006
- ? []
1007
- : TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
1034
+ voices: (ttsEngine.isPocket() || (!ttsEngine.arch() && ttsModelEngineOf(active) === 'pocket-tts'))
1035
+ // Pocket ships eight speakers in an optional 52 MB file; report what is
1036
+ // actually loaded rather than the catalog's aspiration.
1037
+ ? (ttsEngine.builtinVoices().length ? ttsEngine.builtinVoices() : POCKET_VOICES)
1038
+ .map((n) => ({ id: n, label: n[0].toUpperCase() + n.slice(1), lang: 'en', installed: ttsEngine.builtinVoices().includes(n) }))
1039
+ : ttsEngine.arch() && ttsEngine.arch() !== 'style-tts2'
1040
+ ? []
1041
+ : TTS_VOICES.map((v) => ({ ...v, installed: ttsEngine.voiceOnDisk(v.id, active) })),
1008
1042
  dtype: cfg.tts?.dtype || 'auto',
1009
1043
  loadedDtype: ttsEngine.health().dtype,
1010
1044
  runtime: ttsEngine.health().runtime,
@@ -1023,7 +1057,7 @@ export function createGateway(cfg = loadConfig()) {
1023
1057
  const voice = body && typeof body.voice === 'string' ? body.voice.trim() : null;
1024
1058
  if (voice) {
1025
1059
  const cid = ttsVoices.parseCustomVoice(voice);
1026
- const okVoice = cid ? !!ttsVoices.getVoice(cid) : (isKnownVoice(voice) && isValidVoiceId(voice));
1060
+ const okVoice = cid ? !!ttsVoices.getVoice(cid) : (isPocketVoice(voice) || (isKnownVoice(voice) && isValidVoiceId(voice)));
1027
1061
  if (!okVoice) return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1028
1062
  }
1029
1063
  const dtype = body && typeof body.dtype === 'string' && isValidTtsDtype(body.dtype) ? body.dtype : undefined;
@@ -1049,6 +1083,11 @@ export function createGateway(cfg = loadConfig()) {
1049
1083
  } else if (!wantsCustom && isCustom) {
1050
1084
  cfg.tts.voice = DEFAULT_TTS_VOICE;
1051
1085
  }
1086
+ // Kokoro and Pocket name their speakers differently; carrying one over
1087
+ // leaves a voice the new model has never heard of.
1088
+ const toPocket = ttsModelEngineOf(id) === 'pocket-tts';
1089
+ if (toPocket && !ttsVoices.parseCustomVoice(cfg.tts.voice || '') && !isPocketVoice(cfg.tts.voice)) cfg.tts.voice = DEFAULT_POCKET_VOICE;
1090
+ if (!toPocket && isPocketVoice(cfg.tts.voice)) cfg.tts.voice = DEFAULT_TTS_VOICE;
1052
1091
  }
1053
1092
  if (dtype) cfg.tts.dtype = dtype === 'auto' ? null : dtype;
1054
1093
  try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
@@ -1110,9 +1149,25 @@ export function createGateway(cfg = loadConfig()) {
1110
1149
  });
1111
1150
  }
1112
1151
  }
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)`);
1152
+ const audio = Float32Array.from(pcm);
1153
+ const vec = await diarizeEngine.embed(audio);
1154
+
1155
+ // A voice print is engine-specific and the SAMPLE is about to be thrown
1156
+ // away, so anything this voice might later need must be derived now.
1157
+ // Pocket TTS is the engine that actually reproduces a speaker, so its
1158
+ // conditioning is computed whenever its bundle is present — failing that
1159
+ // is not fatal, it just means this voice works only with SpeechT5.
1160
+ let pocket = null;
1161
+ try {
1162
+ const pt = await ttsEngine.pocketForEncoding({ allowDownload: cfg.tts?.allowDownload !== false, log: (m) => console.log(m) });
1163
+ // The recorder sends 16 kHz; Mimi wants 24 kHz.
1164
+ if (pt) pocket = await pt.encodeVoice(resample(audio, 16000, 24000));
1165
+ } catch (e) {
1166
+ console.log(`[tts] pocket conditioning unavailable for this voice (${e.message})`);
1167
+ }
1168
+
1169
+ const saved = ttsVoices.saveVoice({ name, vec, pocket });
1170
+ console.log(`[tts] saved custom voice "${saved.name}" (${saved.kinds.join(' + ')}, sample discarded)`);
1116
1171
  return sendJson(res, 201, { ...saved, usable: ttsEngine.supportsCustomVoices() });
1117
1172
  } catch (e) {
1118
1173
  return sendJson(res, 400, { error: { message: e.message, type: 'save_failed' } });
@@ -1193,7 +1248,7 @@ export function createGateway(cfg = loadConfig()) {
1193
1248
  const ok = await ttsEngine.ready({
1194
1249
  onLog: (m) => console.log(m),
1195
1250
  allowDownload: cfg.tts?.allowDownload !== false,
1196
- model: cfg.tts?.model || DEFAULT_TTS_MODEL,
1251
+ model: cfg.tts?.model || resolveDefaultModel(rawOrtAvailable()),
1197
1252
  dtype: cfg.tts?.dtype || 'auto',
1198
1253
  });
1199
1254
  if (!ok) return sendJson(res, 503, { error: { message: ttsEngine.health().error || 'tts model not ready', type: 'tts_unavailable' } });
@@ -1206,7 +1261,13 @@ export function createGateway(cfg = loadConfig()) {
1206
1261
  let speakerEmbedding = null;
1207
1262
  let customId = ttsVoices.parseCustomVoice(voice);
1208
1263
 
1209
- if (ttsEngine.supportsCustomVoices()) {
1264
+ // A Pocket built-in speaker is a NAME, not an embedding, so it has to be
1265
+ // recognised before the custom-voice path — which would otherwise demand a
1266
+ // recording for a model that ships eight voices of its own.
1267
+ if (ttsEngine.isPocket() && isPocketVoice(rawVoice || useVoice)) {
1268
+ useVoice = rawVoice || useVoice;
1269
+ customId = null;
1270
+ } else if (ttsEngine.supportsCustomVoices()) {
1210
1271
  // This model speaks ONLY in a recorded voice. If the configured one names
1211
1272
  // a built-in (switching model does not rewrite `voice`) or points at a
1212
1273
  // voice since deleted, fall back to the most recent saved one — the
@@ -1216,8 +1277,13 @@ export function createGateway(cfg = loadConfig()) {
1216
1277
  if (!rec && !rawVoice) {
1217
1278
  const saved = ttsVoices.listVoices();
1218
1279
  if (saved.length) { customId = saved[0].id; rec = ttsVoices.getVoice(customId); }
1280
+ // Pocket can fall back to a built-in speaker; SpeechT5 has none, so for
1281
+ // that one "no saved voice" really is the end of the road.
1282
+ else if (ttsEngine.isPocket()) { useVoice = DEFAULT_POCKET_VOICE; customId = null; }
1219
1283
  }
1220
- if (!rec) {
1284
+ if (!rec && !customId && ttsEngine.isPocket()) {
1285
+ // resolved to a built-in above — nothing more to look up
1286
+ } else if (!rec) {
1221
1287
  return sendJson(res, customId ? 404 : 400, {
1222
1288
  error: {
1223
1289
  message: customId ? 'no such saved voice' : 'this model speaks in a voice you record — add one in Settings → Text-to-speech',
@@ -1225,7 +1291,23 @@ export function createGateway(cfg = loadConfig()) {
1225
1291
  },
1226
1292
  });
1227
1293
  }
1228
- speakerEmbedding = rec.vec;
1294
+ // Which print to hand over depends on the engine: Pocket TTS takes its
1295
+ // Mimi conditioning, SpeechT5 the 512-d x-vector. A voice saved before
1296
+ // the pocket bundle existed has only the latter.
1297
+ if (ttsEngine.isPocket()) {
1298
+ const pk = ttsVoices.getPocketVoice(customId);
1299
+ if (!pk) {
1300
+ return sendJson(res, 409, {
1301
+ error: {
1302
+ message: 'this voice was saved without a Pocket TTS conditioning — record it again with Pocket TTS selected',
1303
+ type: 'voice_kind_missing',
1304
+ },
1305
+ });
1306
+ }
1307
+ speakerEmbedding = pk;
1308
+ } else {
1309
+ speakerEmbedding = rec.vec;
1310
+ }
1229
1311
  useVoice = `custom:${customId}`;
1230
1312
  } else if (customId) {
1231
1313
  // Explicitly asked for a recorded voice this model cannot use — say so.
@@ -1238,7 +1320,11 @@ export function createGateway(cfg = loadConfig()) {
1238
1320
  }
1239
1321
  customId = null;
1240
1322
  useVoice = DEFAULT_TTS_VOICE;
1241
- } else if (ttsEngine.supportsVoices() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
1323
+ } else if (ttsEngine.isPocket() && !isPocketVoice(useVoice)) {
1324
+ // Pocket has its own speaker namespace; a Kokoro voice name here means the
1325
+ // config was carried over from another model, so fall back to its default.
1326
+ useVoice = DEFAULT_POCKET_VOICE;
1327
+ } else if (ttsEngine.supportsVoices() && !ttsEngine.isPocket() && !(isKnownVoice(useVoice) && isValidVoiceId(useVoice))) {
1242
1328
  return sendJson(res, 400, { error: { message: 'unknown or invalid voice', type: 'bad_voice' } });
1243
1329
  }
1244
1330
 
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,40 @@ 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
+ /** The built-in speaker names the loaded Pocket model offers, if any. */
73
+ export function builtinVoices() { return _pocket?.builtinVoices?.() || []; }
74
+
75
+ /**
76
+ * A PocketTTS instance purely for ENCODING a voice, without disturbing whatever
77
+ * model is currently speaking. Saving a voice has to derive its conditioning while
78
+ * the sample still exists, and that must not silently switch the active engine out
79
+ * from under a conversation in progress.
80
+ */
81
+ export async function pocketForEncoding({ allowDownload = true, log = () => {} } = {}) {
82
+ if (_pocket) return _pocket;
83
+ const { PocketTTS, bundleOnDisk, DEFAULT_BUNDLE } = await import('./pocket-tts-engine.js');
84
+ if (!bundleOnDisk(DEFAULT_BUNDLE) && !allowDownload) return null;
85
+ if (_encoder) return _encoder;
86
+ const pt = new PocketTTS();
87
+ await pt.load(DEFAULT_BUNDLE, { log });
88
+ _encoder = pt;
89
+ return pt;
90
+ }
60
91
 
61
92
  export function arch() { return _arch; }
62
93
  export function sampleRate() { return _rate; }
@@ -79,6 +110,11 @@ export function modelDir(modelId) {
79
110
  // Present = the EXACT ONNX file this runtime will load, plus the tokenizer. Both
80
111
  // non-empty: a truncated download must not read as installed.
81
112
  export function modelOnDisk(modelId = _model || DEFAULT_TTS_MODEL, dtype = ttsModelDtype(modelId) || runtimeDtype()) {
113
+ // Pocket TTS keeps a bundle of five graphs under its own directory, not a single
114
+ // transformers-style onnx/ folder. bundleOnDisk is a pure fs predicate — the
115
+ // heavy onnxruntime import inside that module is dynamic — so importing it
116
+ // statically costs nothing.
117
+ if (ttsModelEngine(modelId) === POCKET_ARCH) return pocketBundleOnDisk(ttsModel(modelId)?.bundle);
82
118
  const dir = modelDir(modelId);
83
119
  const suffix = DTYPE_SUFFIX[dtype] ?? '';
84
120
  const need = [join(dir, 'onnx', `model${suffix}.onnx`), join(dir, 'tokenizer.json')];
@@ -104,6 +140,10 @@ export function init(cfg = {}) {
104
140
  }
105
141
 
106
142
  async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype: dtypeOverride = null } = {}) {
143
+ // Pocket TTS has its own loader (raw onnxruntime, its own bundle layout), so it
144
+ // is routed before any transformers.js machinery is touched.
145
+ if (ttsModelEngine(modelId) === POCKET_ARCH) return loadPocket(modelId, { log, allowDownload });
146
+
107
147
  const prevNet = _net, prevModel = _model;
108
148
  let lib;
109
149
  try {
@@ -190,6 +230,36 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true, dtype:
190
230
  }
191
231
  }
192
232
 
233
+ async function loadPocket(modelId, { log = () => {}, allowDownload = true } = {}) {
234
+ const prevArch = _arch, prevPocket = _pocket, prevModel = _model;
235
+ const { PocketTTS, bundleOnDisk, DEFAULT_BUNDLE, SAMPLE_RATE: PR } = await import('./pocket-tts-engine.js');
236
+ const bundle = ttsModel(modelId)?.bundle || DEFAULT_BUNDLE;
237
+ if (!bundleOnDisk(bundle) && !allowDownload) {
238
+ _state = 'error'; _err = 'model not on disk and downloads disabled';
239
+ return false;
240
+ }
241
+ _state = bundleOnDisk(bundle) ? 'loading' : 'downloading';
242
+ if (_state === 'downloading') _progress = { model: modelId, file: null, pct: 0 };
243
+ try {
244
+ const pt = new PocketTTS();
245
+ await pt.load(bundle, {
246
+ log,
247
+ onProgress: ({ file, pct }) => { _progress = { model: modelId, file, pct }; },
248
+ });
249
+ _pocket = pt; _net = pt; _tok = null; _vocoder = null;
250
+ _model = modelId; _arch = POCKET_ARCH; _dtype = 'int8'; _rate = PR;
251
+ _state = 'ready'; _err = null; _progress = null;
252
+ return true;
253
+ } catch (e) {
254
+ // A failed switch keeps whatever was working, same as every other engine here.
255
+ _pocket = prevPocket; _arch = prevArch; _model = prevModel;
256
+ _state = prevPocket || _net ? 'ready' : 'error';
257
+ _err = e.message; _progress = null;
258
+ log(`[pocket-tts] load failed (${e.message})`);
259
+ return false;
260
+ }
261
+ }
262
+
193
263
  export async function setModel(modelId, { onLog = () => {}, allowDownload = true, dtype = 'auto' } = {}) {
194
264
  const want = dtype && dtype !== 'auto' ? dtype : (ttsModelDtype(modelId) || runtimeDtype());
195
265
  if (modelId === _model && isReady() && _dtype === want) return true;
@@ -288,6 +358,15 @@ export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1, s
288
358
  return out.waveform.data;
289
359
  }
290
360
 
361
+ // Pocket TTS runs its own generation loop and chunking, so a whole utterance is
362
+ // handed over at once rather than being pre-split here.
363
+ if (_arch === POCKET_ARCH) {
364
+ // Either a cloned voice (an embedding) or one of its built-in speakers (a name).
365
+ const v = speakerEmbedding?.data?.length ? speakerEmbedding : voice;
366
+ if (!v) throw new Error('this model needs a voice — pick a built-in one or record your own');
367
+ return _pocket.synth(String(text), { voice: v });
368
+ }
369
+
291
370
  // SpeechT5: conditioned by a 512-d speaker embedding, which is the whole point —
292
371
  // it is the one architecture here that can be pointed at a person's voice.
293
372
  // Without an embedding there is no voice to speak in, so this refuses rather
@@ -324,6 +403,9 @@ export async function synthChunk(text, { voice = DEFAULT_TTS_VOICE, speed = 1, s
324
403
 
325
404
  /** Synthesize arbitrary-length text, chunk by chunk. `onChunk` sees each as it lands. */
326
405
  export async function synth(text, { voice = DEFAULT_TTS_VOICE, speed = 1, speakerEmbedding = null, onChunk = null } = {}) {
406
+ // Pocket TTS splits internally against its own token ceiling, so splitting again
407
+ // here would cut sentences twice and reset its state mid-thought.
408
+ if (_arch === POCKET_ARCH) return synthChunk(text, { voice, speed, speakerEmbedding });
327
409
  const chunks = splitSentences(text);
328
410
  const out = [];
329
411
  for (const c of chunks) {
@@ -366,6 +448,6 @@ export function toWav(pcm, sampleRate = SAMPLE_RATE) {
366
448
 
367
449
  export function _reset() {
368
450
  _state = 'off'; _model = null; _net = null; _tok = null; _dtype = null;
369
- _err = null; _progress = null; _initPromise = null; _arch = null; _rate = SAMPLE_RATE; _vocoder = null;
451
+ _err = null; _progress = null; _initPromise = null; _arch = null; _rate = SAMPLE_RATE; _vocoder = null; _pocket = null; _encoder = null;
370
452
  _voices.clear();
371
453
  }
package/src/tts-models.js CHANGED
@@ -16,9 +16,30 @@
16
16
  // driveable by a transformers.js class (Kokoro = StyleTextToSpeech2Model). Verify
17
17
  // it loads on BOTH runtimes (native q8 + WASM fp32) before listing it.
18
18
 
19
- export const DEFAULT_TTS_MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
19
+ // Pocket TTS is the default where it can run: it is the fastest engine here
20
+ // (7-9x realtime against Kokoro's 2.1x) and the only one that can speak as the
21
+ // user. It needs the native onnxruntime, though, which the standalone binary does
22
+ // not carry — so the binary falls back to Kokoro rather than offering a model it
23
+ // cannot load. See src/ort.js for why.
24
+ export const DEFAULT_TTS_MODEL_NATIVE = 'kyutai/pocket-tts';
25
+ export const DEFAULT_TTS_MODEL_WASM = 'onnx-community/Kokoro-82M-v1.0-ONNX';
26
+ export const DEFAULT_TTS_MODEL = DEFAULT_TTS_MODEL_WASM; // safe default; resolveDefaultModel() picks properly
20
27
  export const DEFAULT_TTS_VOICE = 'af_heart';
21
28
 
29
+ // The eight speakers shipped in voices.bin. Names only — the data is a ~52 MB
30
+ // optional download, and cloning works without it.
31
+ export const POCKET_VOICES = ['alba', 'azelma', 'cosette', 'eponine', 'fantine', 'javert', 'jean', 'marius'];
32
+ export const DEFAULT_POCKET_VOICE = 'alba';
33
+
34
+ export function isPocketVoice(v) {
35
+ return POCKET_VOICES.includes(String(v || ''));
36
+ }
37
+
38
+ /** The model to use when nothing is configured, given what this runtime can load. */
39
+ export function resolveDefaultModel(rawOrtAvailable = true) {
40
+ return rawOrtAvailable ? DEFAULT_TTS_MODEL_NATIVE : DEFAULT_TTS_MODEL_WASM;
41
+ }
42
+
22
43
  // Style-vector width in a voices/*.bin file, and the max token window a single
23
44
  // forward pass accepts. Both are properties of the Kokoro export, and the engine
24
45
  // needs them to slice the voice and to chunk long text.
@@ -55,6 +76,28 @@ export const TTS_MODEL_CATALOG = [
55
76
  voices: true,
56
77
  note: 'The Mandarin-tuned Kokoro. Same engine and voice mechanism as v1.0.',
57
78
  },
79
+ {
80
+ // The one model here BUILT for cloning. Its Mimi encoder turns a sample into a
81
+ // conditioning the model generates from directly, rather than borrowing a
82
+ // voice print from a space it was not trained on — which is why it reproduces
83
+ // a speaker where SpeechT5 merely produces a consistent stranger. Also the
84
+ // fastest: measured 8-9x realtime at int8, against Kokoro's 2.1x.
85
+ id: 'kyutai/pocket-tts',
86
+ label: 'Pocket TTS — clone your voice',
87
+ lang: 'English',
88
+ tier: 'accurate',
89
+ arch: 'pocket-tts',
90
+ bundle: 'english_2026-04',
91
+ approxMB: 146,
92
+ ramMB: 700,
93
+ sampleRate: 24000,
94
+ voices: true, // eight built-in speakers (optional 52 MB voices.bin)
95
+ customVoices: true, // …and it can speak as YOU
96
+ recommended: true,
97
+ // Raw onnxruntime, which the standalone binary cannot provide.
98
+ requiresNative: true,
99
+ note: 'Kyutai Pocket TTS (MIT code, CC-BY-4.0 weights). Eight built-in voices, and it can clone yours. Fastest model here. 146 MB, plus 52 MB if you want the built-in voices. Needs the npm gateway.',
100
+ },
58
101
  {
59
102
  id: 'Xenova/speecht5_tts',
60
103
  label: 'SpeechT5 — your own voice',
@@ -74,7 +117,7 @@ export const TTS_MODEL_CATALOG = [
74
117
  sampleRate: 16000,
75
118
  voices: false, // no built-in voices…
76
119
  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.',
120
+ 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
121
  },
79
122
  {
80
123
  // One MMS entry so the second architecture is DISCOVERABLE from the list rather
@@ -137,6 +180,11 @@ export function ttsModelEngine(id) {
137
180
  return ttsModel(id)?.arch || 'style-tts2';
138
181
  }
139
182
 
183
+ // The bundle a pocket-tts catalog entry uses (its language pack).
184
+ export function ttsModelBundle(id) {
185
+ return ttsModel(id)?.bundle || null;
186
+ }
187
+
140
188
  // Does this catalog entry have selectable voices? Kokoro picks one from a style
141
189
  // bank; VITS/MMS is single-speaker. Unknown (a searched model) resolves at load.
142
190
  export function ttsModelHasVoices(id) {
@@ -150,6 +198,11 @@ export function ttsModelHasCustomVoices(id) {
150
198
  return ttsModel(id)?.customVoices === true;
151
199
  }
152
200
 
201
+ /** Does this model need the native onnxruntime (i.e. not usable in the binary)? */
202
+ export function ttsModelRequiresNative(id) {
203
+ return ttsModel(id)?.requiresNative === true;
204
+ }
205
+
153
206
  export function ttsModel(id) {
154
207
  return TTS_MODEL_CATALOG.find((m) => m.id === id) || null;
155
208
  }