@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 +1 -1
- package/src/config.js +7 -2
- package/src/ort.js +51 -0
- package/src/parakeet-engine.js +2 -15
- package/src/pocket-tts-engine.js +512 -0
- package/src/sentencepiece.js +184 -0
- package/src/server.js +103 -17
- package/src/tts-engine.js +85 -3
- package/src/tts-models.js +55 -2
- package/src/tts-voices.js +50 -4
|
@@ -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.
|
|
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 ||
|
|
985
|
-
|
|
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
|
-
|
|
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.
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
|
1114
|
-
const
|
|
1115
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
}
|