@tablefolk/voice 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +33 -2
- package/breeze-web/LICENSE-Breeze-TTS-2.cpp.txt +202 -0
- package/breeze-web/LICENSE-ggml.txt +21 -0
- package/breeze-web/README.md +11 -0
- package/breeze-web/breeze.mjs +2 -0
- package/breeze-web/breeze.wasm +0 -0
- package/breeze-web-worker.js +177 -0
- package/index.js +161 -0
- package/package.json +30 -4
- package/pcm-worklet.js +143 -0
- package/pocket-worker.js +1021 -0
- package/pocket18-worker.js +334 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Pocket TTS, the small English model (english_18m_refl_2026-10: 18.2M
|
|
2
|
+
// parameters, Kyutai, CC-BY-4.0), ONNX export by maxime/pocket-tts-18m-onnx.
|
|
3
|
+
// A second engine beside inference-worker.js (the 100M model), with the same
|
|
4
|
+
// messages, so pocket-tts-manager.js can run either:
|
|
5
|
+
// in: load, set_audio_port (a port to the PCM worklet), set_voice,
|
|
6
|
+
// encode_voice {voiceId, audio}, delete_voice, generate {text, voice}, stop
|
|
7
|
+
// out: status, runtime_info, voices_loaded, bundle_loaded, loaded,
|
|
8
|
+
// voice_set, voice_encoded, generation_started, audio_chunk(_meta),
|
|
9
|
+
// generation_metrics, stream_ended, error
|
|
10
|
+
//
|
|
11
|
+
// About 36 MB (int8 flow LM, the rest fp32) plus one preset voice's state
|
|
12
|
+
// (~4 MB, fetched alone with a Range request from voices.bin). The voice
|
|
13
|
+
// encoder (39 MB) loads only when a custom voice first speaks. Files are kept
|
|
14
|
+
// in Cache Storage. Runs on the CPU (WASM): the GPU belongs to the chat model.
|
|
15
|
+
//
|
|
16
|
+
// The format (bundle.json): the flow LM keeps a KV cache per layer
|
|
17
|
+
// (state_cache_<i>, [2, 1, L, 8, 64], L dynamic: the voice prompt, the text
|
|
18
|
+
// and the frames to generate) and a state_offset; the sampler head is
|
|
19
|
+
// "drifting" (one call maps noise to the next latent); the Mimi decoder
|
|
20
|
+
// keeps its own states, zeroed at the start of an utterance.
|
|
21
|
+
|
|
22
|
+
// Cache keys keep the Hugging Face URLs (copies cached from there stay valid);
|
|
23
|
+
// downloads come from our R2 mirror (same bytes), Hugging Face as the fallback.
|
|
24
|
+
const BASE = "https://huggingface.co/maxime/pocket-tts-18m-onnx/resolve/main/english_18m_refl_2026-10/";
|
|
25
|
+
const MIRROR = "https://geebr-models.mindroot.io/pocket-tts/18m/english_18m_refl_2026-10/";
|
|
26
|
+
let useMirror = true;
|
|
27
|
+
const ORT_VERSION = "1.31.1";
|
|
28
|
+
const SENTENCEPIECE_URL = "https://huggingface.co/spaces/KevinAHM/pocket-tts-web/resolve/main/sentencepiece.js";
|
|
29
|
+
const SENTENCEPIECE_MIRROR = "https://geebr-models.mindroot.io/pocket-tts/web/sentencepiece.js";
|
|
30
|
+
const CACHE = "tablefolk-tts-pocket18";
|
|
31
|
+
const MAX_TOKENS_PER_CHUNK = 50, FIRST_DECODE = 4, DECODE = 6, CACHE_STEP = 64;
|
|
32
|
+
|
|
33
|
+
let ort = null, bundle = null, sp = null, bos = null, audioPort = null;
|
|
34
|
+
let textSession = null, mainSession = null, flowSession = null, decoderSession = null, encoderSession = null;
|
|
35
|
+
let ready = false, generating = false, currentVoice = "alba";
|
|
36
|
+
const presetStates = new Map(); // name -> voice state {tensors: {name: {dtype, shape, data}}}
|
|
37
|
+
const customAudio = new Map(); // id -> Float32Array (24 kHz), encoded on first use
|
|
38
|
+
const customStates = new Map(); // id -> voice state
|
|
39
|
+
|
|
40
|
+
const status = (text, state = "loading") => postMessage({ type: "status", status: text, state });
|
|
41
|
+
|
|
42
|
+
// ---- files (Cache Storage, so a second visit downloads nothing) ----
|
|
43
|
+
async function bytes(name, { range = null, key = BASE + name } = {}) {
|
|
44
|
+
let cache = null;
|
|
45
|
+
try { cache = await caches.open(CACHE); const hit = await cache.match(key); if (hit) return await hit.arrayBuffer(); } catch {}
|
|
46
|
+
const init = range ? { headers: { Range: `bytes=${range[0]}-${range[1] - 1}` } } : {};
|
|
47
|
+
const good = r => r.ok && (!range || r.status === 206);
|
|
48
|
+
let res = null;
|
|
49
|
+
if (useMirror) {
|
|
50
|
+
try { res = await fetch(MIRROR + name, init); } catch {}
|
|
51
|
+
if (!res || !good(res)) { console.warn("[pocket18] mirror unavailable, using Hugging Face"); useMirror = false; res = null; }
|
|
52
|
+
}
|
|
53
|
+
res ||= await fetch(BASE + name, init);
|
|
54
|
+
if (!good(res)) throw new Error(`${name}: HTTP ${res.status}`);
|
|
55
|
+
const buf = await res.arrayBuffer();
|
|
56
|
+
try { await cache?.put(key, new Response(buf.slice(0))); } catch {}
|
|
57
|
+
return buf;
|
|
58
|
+
}
|
|
59
|
+
async function session(name, opts = {}) {
|
|
60
|
+
return ort.InferenceSession.create(new Uint8Array(await bytes(name)), { executionProviders: ["wasm"], graphOptimizationLevel: "all", ...opts });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- tensors ----
|
|
64
|
+
function zeros(dtype, shape) {
|
|
65
|
+
const n = shape.reduce((a, b) => a * b, 1);
|
|
66
|
+
return new ort.Tensor(dtype, dtype === "int64" ? new BigInt64Array(n) : dtype === "bool" ? new Uint8Array(n) : new Float32Array(n), shape);
|
|
67
|
+
}
|
|
68
|
+
const dynAxis = spec => { const a = Object.keys(spec.dynamic || {}); return a.length === 1 ? +a[0] : -1; };
|
|
69
|
+
function freshStates(specs, dynamicSize = 0) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const s of specs) out[s.name] = zeros(s.dtype, s.shape.map(d => d === -1 ? dynamicSize : d));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
// A voice state's caches, copied into zeroed caches `capacity` long (empty
|
|
75
|
+
// slots must be zeros: the graph masks the tail).
|
|
76
|
+
function flowStateFrom(voice, capacity) {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const spec of bundle.graphs.flow_lm_main.states) {
|
|
79
|
+
const t = voice.tensors[spec.name]; if (!t) throw new Error(`voice state has no ${spec.name}`);
|
|
80
|
+
const axis = dynAxis(spec);
|
|
81
|
+
if (axis < 0) { out[spec.name] = new ort.Tensor(spec.dtype, t.data.slice(), t.shape); continue; }
|
|
82
|
+
const shape = t.shape.slice(); shape[axis] = capacity;
|
|
83
|
+
const outer = t.shape.slice(0, axis).reduce((a, b) => a * b, 1), inner = t.shape.slice(axis + 1).reduce((a, b) => a * b, 1);
|
|
84
|
+
const from = t.shape[axis] * inner, to = capacity * inner, data = new Float32Array(outer * to);
|
|
85
|
+
for (let i = 0; i < outer; i++) data.set(t.data.subarray(i * from, (i + 1) * from), i * to);
|
|
86
|
+
out[spec.name] = new ort.Tensor(spec.dtype, data, shape);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function takeStates(state, result, specs) { for (const s of specs) state[s.name] = result[s.output]; }
|
|
91
|
+
|
|
92
|
+
// ---- voices ----
|
|
93
|
+
// PTVB1 records: name, then tensors (path, dtype 0 f32 / 1 i64, shape, bytes).
|
|
94
|
+
function parseVoice(buf, offset = 0) {
|
|
95
|
+
const v = new DataView(buf), td = new TextDecoder(); let o = offset;
|
|
96
|
+
const u16 = () => { const x = v.getUint16(o, true); o += 2; return x; }, u32 = () => { const x = v.getUint32(o, true); o += 4; return x; };
|
|
97
|
+
const str = n => { const s = td.decode(new Uint8Array(buf, o, n)); o += n; return s; };
|
|
98
|
+
const name = str(u16()), count = u16(), tensors = {};
|
|
99
|
+
for (let i = 0; i < count; i++) {
|
|
100
|
+
const path = str(u16()), code = v.getUint8(o++), rank = v.getUint8(o++), shape = [];
|
|
101
|
+
for (let r = 0; r < rank; r++) shape.push(u32());
|
|
102
|
+
const len = u32(), raw = buf.slice(o, o + len); o += len;
|
|
103
|
+
tensors[path] = { dtype: code === 1 ? "int64" : "float32", shape, data: code === 1 ? new BigInt64Array(raw) : new Float32Array(raw) };
|
|
104
|
+
}
|
|
105
|
+
return { name, tensors, end: o };
|
|
106
|
+
}
|
|
107
|
+
async function presetVoice(name) {
|
|
108
|
+
if (presetStates.has(name)) return presetStates.get(name);
|
|
109
|
+
const range = bundle.voices?.[name]; if (!range) throw new Error(`no voice ${name}`);
|
|
110
|
+
status(`Loading voice (${name})...`);
|
|
111
|
+
let voice;
|
|
112
|
+
try { voice = parseVoice(await bytes("voices.bin", { range: [range.start, range.end], key: BASE + "voices.bin?voice=" + name })); }
|
|
113
|
+
catch (e) {
|
|
114
|
+
// no Range support: the whole file (34 MB), then this voice from it
|
|
115
|
+
console.warn("[tts18] range fetch failed, reading all of voices.bin", e);
|
|
116
|
+
voice = parseVoice(await bytes("voices.bin"), range.start);
|
|
117
|
+
}
|
|
118
|
+
presetStates.set(name, voice);
|
|
119
|
+
return voice;
|
|
120
|
+
}
|
|
121
|
+
function parseNpy(buf) {
|
|
122
|
+
const v = new DataView(buf), major = v.getUint8(6), hl = major === 1 ? v.getUint16(8, true) : v.getUint32(8, true), ho = major === 1 ? 10 : 12;
|
|
123
|
+
const head = new TextDecoder().decode(new Uint8Array(buf, ho, hl)), shape = head.match(/\(\s*([0-9,\s]+)\)/)[1].split(",").map(s => s.trim()).filter(Boolean).map(Number);
|
|
124
|
+
return { data: new Float32Array(buf.slice(ho + hl)), shape };
|
|
125
|
+
}
|
|
126
|
+
// A custom voice: encode the sample, then run it through the flow LM as the
|
|
127
|
+
// prompt and keep the resulting state (as preset voices ship it).
|
|
128
|
+
async function customVoice(id) {
|
|
129
|
+
if (customStates.has(id)) return customStates.get(id);
|
|
130
|
+
const audio = customAudio.get(id); if (!audio) throw new Error("No custom voice loaded.");
|
|
131
|
+
status("Preparing custom voice...");
|
|
132
|
+
encoderSession ||= await session("mimi_encoder.onnx");
|
|
133
|
+
if (bundle.insert_bos_before_voice && !bos) bos = parseNpy(await bytes("bos_before_voice.npy"));
|
|
134
|
+
const n = audio.length - audio.length % bundle.frame_size || bundle.frame_size, pcm = new Float32Array(n); pcm.set(audio.subarray(0, n));
|
|
135
|
+
const enc = (await encoderSession.run({ audio: new ort.Tensor("float32", pcm, [1, 1, n]) })).conditioning;
|
|
136
|
+
let data = enc.data, frames = enc.dims[1];
|
|
137
|
+
if (bos) { const c = new Float32Array(bos.data.length + data.length); c.set(bos.data); c.set(data, bos.data.length); data = c; frames += bos.shape[1]; }
|
|
138
|
+
const specs = bundle.graphs.flow_lm_main.states, state = freshStates(specs, frames);
|
|
139
|
+
const r = await mainSession.run({ sequence: new ort.Tensor("float32", new Float32Array(0), [1, 0, bundle.dims.latent]), text_embeddings: new ort.Tensor("float32", data, [1, frames, bundle.dims.text_embedding]), ...state });
|
|
140
|
+
const tensors = {};
|
|
141
|
+
for (const s of specs) { const t = r[s.output]; tensors[s.name] = { dtype: t.type, shape: [...t.dims], data: t.data.slice() }; }
|
|
142
|
+
const voice = { name: id, tensors };
|
|
143
|
+
customStates.set(id, voice);
|
|
144
|
+
// the page keeps it (IndexedDB), so the next visit skips the encoder and the prefill
|
|
145
|
+
postMessage({ type: "voice_state", id, tensors });
|
|
146
|
+
return voice;
|
|
147
|
+
}
|
|
148
|
+
const voiceFor = name => name.startsWith("custom:") ? customVoice(name.slice(7)) : presetVoice(name);
|
|
149
|
+
|
|
150
|
+
// ---- text ----
|
|
151
|
+
const ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"];
|
|
152
|
+
const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
|
153
|
+
function words(n) {
|
|
154
|
+
if (n < 20) return ONES[n];
|
|
155
|
+
if (n < 100) return TENS[Math.floor(n / 10)] + (n % 10 ? "-" + ONES[n % 10] : "");
|
|
156
|
+
if (n < 1000) return ONES[Math.floor(n / 100)] + " hundred" + (n % 100 ? " and " + words(n % 100) : "");
|
|
157
|
+
if (n < 1e6) return words(Math.floor(n / 1000)) + " thousand" + (n % 1000 ? " " + words(n % 1000) : "");
|
|
158
|
+
return String(n);
|
|
159
|
+
}
|
|
160
|
+
function prepare(text) {
|
|
161
|
+
let t = String(text || "").normalize("NFKD").replace(/[̀-ͯ]/g, "")
|
|
162
|
+
.replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/[—–]/g, ", ").replace(/…/g, "...")
|
|
163
|
+
.replace(/\*+|_+|#+/g, "").replace(/\s+/g, " ").trim();
|
|
164
|
+
t = t.replace(/(\d+)\s*gp\b/gi, "$1 gold").replace(/\b\d{1,6}\b/g, m => words(+m));
|
|
165
|
+
if (!t) return "";
|
|
166
|
+
if (/[A-Za-z0-9]$/.test(t)) t += ".";
|
|
167
|
+
return t[0].toUpperCase() + t.slice(1);
|
|
168
|
+
}
|
|
169
|
+
// One context per utterance; long ones split at sentence ends near the limit.
|
|
170
|
+
function chunks(text) {
|
|
171
|
+
const ids = sp.encodeIds(text);
|
|
172
|
+
if (ids.length <= MAX_TOKENS_PER_CHUNK) return [text];
|
|
173
|
+
const out = []; let cur = "";
|
|
174
|
+
for (const s of text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text]) {
|
|
175
|
+
const next = (cur + " " + s).trim();
|
|
176
|
+
if (cur && sp.encodeIds(next).length > MAX_TOKENS_PER_CHUNK) { out.push(cur); cur = s.trim(); } else cur = next;
|
|
177
|
+
}
|
|
178
|
+
if (cur) out.push(cur);
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---- generation ----
|
|
183
|
+
function gauss(n, std) {
|
|
184
|
+
const d = new Float32Array(n);
|
|
185
|
+
for (let i = 0; i < n; i++) { let u = 0, v = 0; while (!u) u = Math.random(); while (!v) v = Math.random(); d[i] = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) * std; }
|
|
186
|
+
return d;
|
|
187
|
+
}
|
|
188
|
+
const maxFrames = tokens => Math.ceil((tokens / bundle.max_gen.tokens_per_second + bundle.max_gen.padding_seconds) * bundle.frame_rate);
|
|
189
|
+
const tick = () => new Promise(r => setTimeout(r, 0));
|
|
190
|
+
|
|
191
|
+
function sendAudio(pcm, metrics) {
|
|
192
|
+
if (audioPort) { audioPort.postMessage({ type: "audio", data: pcm }, [pcm.buffer]); postMessage({ type: "audio_chunk_meta", metrics }); }
|
|
193
|
+
else postMessage({ type: "audio_chunk", data: pcm, metrics }, [pcm.buffer]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function generate(text, voiceName) {
|
|
197
|
+
generating = true;
|
|
198
|
+
status("Generating...", "running");
|
|
199
|
+
audioPort?.postMessage({ type: "reset" });
|
|
200
|
+
postMessage({ type: "generation_started", data: { time: performance.now(), sourceSampleRate: bundle.sample_rate } });
|
|
201
|
+
const t0 = performance.now(); let firstAudioAt = null, frames = 0, work = 0;
|
|
202
|
+
try {
|
|
203
|
+
const prepared = prepare(text); if (!prepared) throw new Error("No text to generate");
|
|
204
|
+
const voice = await voiceFor(voiceName);
|
|
205
|
+
const parts = chunks(prepared), ids = parts.map(p => sp.encodeIds(p));
|
|
206
|
+
const voiceFrames = Number(voice.tensors.state_offset.data[0]);
|
|
207
|
+
const capacity = Math.ceil((voiceFrames + Math.max(...ids.map(i => i.length + maxFrames(i.length)))) / CACHE_STEP) * CACHE_STEP;
|
|
208
|
+
const flowSpecs = bundle.graphs.flow_lm_main.states, decSpecs = bundle.graphs.mimi_decoder.states, D = bundle.dims.latent;
|
|
209
|
+
const emptySeq = new ort.Tensor("float32", new Float32Array(0), [1, 0, D]);
|
|
210
|
+
const emptyText = new ort.Tensor("float32", new Float32Array(0), [1, 0, bundle.dims.text_embedding]);
|
|
211
|
+
// the decoder runs on across text chunks (smoother joins)
|
|
212
|
+
const dec = freshStates(decSpecs);
|
|
213
|
+
let first = true;
|
|
214
|
+
for (let c = 0; c < parts.length && generating; c++) {
|
|
215
|
+
const flow = flowStateFrom(voice, capacity);
|
|
216
|
+
const tok = new ort.Tensor("int64", BigInt64Array.from(ids[c].map(BigInt)), [1, ids[c].length]);
|
|
217
|
+
let emb = (await textSession.run({ token_ids: tok })).embeddings;
|
|
218
|
+
if (emb.dims.length === 2) emb = new ort.Tensor("float32", emb.data, [1, ...emb.dims]);
|
|
219
|
+
takeStates(flow, await mainSession.run({ sequence: emptySeq, text_embeddings: emb, ...flow }), flowSpecs);
|
|
220
|
+
const n = parts[c].split(/\s+/).length, rule = bundle.eos.frames_after_eos, after = n <= rule.max_short_words ? rule.short : rule.long;
|
|
221
|
+
let seq = new ort.Tensor("float32", new Float32Array(D).fill(NaN), [1, 1, D]), eos = null;
|
|
222
|
+
const latents = []; let decoded = 0;
|
|
223
|
+
const decode = async (count, last) => {
|
|
224
|
+
const lat = new Float32Array(count * D);
|
|
225
|
+
for (let i = 0; i < count; i++) lat.set(latents[decoded + i], i * D);
|
|
226
|
+
const s0 = performance.now();
|
|
227
|
+
const r = await decoderSession.run({ latent: new ort.Tensor("float32", lat, [1, count, D]), ...dec });
|
|
228
|
+
takeStates(dec, r, decSpecs);
|
|
229
|
+
const ms = performance.now() - s0; work += ms; decoded += count;
|
|
230
|
+
const pcm = new Float32Array(r.audio.data);
|
|
231
|
+
firstAudioAt ??= performance.now();
|
|
232
|
+
sendAudio(pcm, { chunkDuration: pcm.length / bundle.sample_rate, genTimeSec: ms / 1000, isFirst: first, isLast: last });
|
|
233
|
+
first = false;
|
|
234
|
+
};
|
|
235
|
+
const limit = maxFrames(ids[c].length);
|
|
236
|
+
for (let step = 0; step < limit && generating; step++) {
|
|
237
|
+
if (step && step % 4 === 0) await tick();
|
|
238
|
+
const s0 = performance.now();
|
|
239
|
+
const r = await mainSession.run({ sequence: seq, text_embeddings: emptyText, ...flow });
|
|
240
|
+
// EOS is ignored for the first frames, then `after` more are kept
|
|
241
|
+
if (eos === null && step >= bundle.eos.min_frames && r.eos_logit.data[0] > bundle.eos.threshold) eos = step;
|
|
242
|
+
if (eos !== null && step >= eos + after) break;
|
|
243
|
+
const x = new ort.Tensor("float32", gauss(D, Math.sqrt(bundle.temperature)), [1, D]);
|
|
244
|
+
const latent = new Float32Array((await flowSession.run({ c: r.conditioning, x })).latent.data);
|
|
245
|
+
takeStates(flow, r, flowSpecs);
|
|
246
|
+
latents.push(latent); frames++;
|
|
247
|
+
seq = new ort.Tensor("float32", latent, [1, 1, D]);
|
|
248
|
+
work += performance.now() - s0;
|
|
249
|
+
if (latents.length - decoded >= (first ? FIRST_DECODE : DECODE)) await decode(first ? FIRST_DECODE : DECODE, false);
|
|
250
|
+
}
|
|
251
|
+
if (generating && decoded < latents.length) await decode(latents.length - decoded, c === parts.length - 1);
|
|
252
|
+
}
|
|
253
|
+
const audioSec = frames * bundle.frame_size / bundle.sample_rate, rtfx = work ? audioSec / (work / 1000) : 0;
|
|
254
|
+
postMessage({ type: "generation_metrics", metrics: { rtfx, genTime: work / 1000, totalTime: (performance.now() - t0) / 1000, audioDuration: audioSec, firstAudioMs: firstAudioAt == null ? null : firstAudioAt - t0 } });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error("[tts18] generation", err);
|
|
257
|
+
postMessage({ type: "error", error: String(err?.message || err) });
|
|
258
|
+
} finally {
|
|
259
|
+
if (generating) { audioPort?.postMessage({ type: "stream-ended" }); postMessage({ type: "stream_ended" }); status("Finished", "idle"); }
|
|
260
|
+
generating = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- loading ----
|
|
265
|
+
async function load() {
|
|
266
|
+
status("Loading ONNX Runtime...");
|
|
267
|
+
const m = await import(`https://cdn.jsdelivr.net/npm/onnxruntime-web@${ORT_VERSION}/dist/ort.min.mjs`);
|
|
268
|
+
ort = m.default || m;
|
|
269
|
+
ort.env.wasm.wasmPaths = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${ORT_VERSION}/dist/`;
|
|
270
|
+
ort.env.wasm.simd = true;
|
|
271
|
+
ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(navigator.hardwareConcurrency || 2, 2) : 1;
|
|
272
|
+
postMessage({ type: "runtime_info", data: { backend: "WASM CPU (18M)", isolated: self.crossOriginIsolated, threads: ort.env.wasm.numThreads, hardwareConcurrency: navigator.hardwareConcurrency || 0 } });
|
|
273
|
+
status("Loading the small Pocket-TTS model...");
|
|
274
|
+
bundle = JSON.parse(new TextDecoder().decode(await bytes("bundle.json")));
|
|
275
|
+
if (bundle.flow?.type !== "drifting") throw new Error("unexpected flow head: " + bundle.flow?.type);
|
|
276
|
+
[textSession, mainSession, flowSession, decoderSession] = await Promise.all([
|
|
277
|
+
session("text_conditioner.onnx"), session("flow_lm_main_int8.onnx"), session("flow_lm_flow.onnx"), session("mimi_decoder.onnx"),
|
|
278
|
+
]);
|
|
279
|
+
status("Loading tokenizer...");
|
|
280
|
+
if (!self.__sp) {
|
|
281
|
+
let r = useMirror ? await fetch(SENTENCEPIECE_MIRROR).catch(() => null) : null;
|
|
282
|
+
if (!r?.ok) r = await fetch(SENTENCEPIECE_URL);
|
|
283
|
+
const js = await r.text();
|
|
284
|
+
self.__sp = await import(URL.createObjectURL(new Blob([js], { type: "application/javascript" })));
|
|
285
|
+
}
|
|
286
|
+
sp = new self.__sp.SentencePieceProcessor();
|
|
287
|
+
await sp.loadFromB64StringModel(btoa(String.fromCharCode(...new Uint8Array(await bytes("tokenizer.model")))));
|
|
288
|
+
await presetVoice(currentVoice);
|
|
289
|
+
ready = true;
|
|
290
|
+
const voices = bundle.predefined_voices || Object.keys(bundle.voices || {});
|
|
291
|
+
postMessage({ type: "voices_loaded", voices, defaultVoice: currentVoice, language: "english" });
|
|
292
|
+
postMessage({ type: "bundle_loaded", language: "english", sampleRate: bundle.sample_rate, initialLoad: true });
|
|
293
|
+
status("Ready", "idle");
|
|
294
|
+
postMessage({ type: "model_status", status: "ready", text: "Ready (english, 18M)" });
|
|
295
|
+
postMessage({ type: "loaded" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
self.onmessage = async e => {
|
|
299
|
+
const { type, data } = e.data || {};
|
|
300
|
+
try {
|
|
301
|
+
// the R2 mirror only where the page allows it (@tablefolk/models mirrorAllowed: tablefolk.world)
|
|
302
|
+
if (type === "load") { if (data?.mirror === false) useMirror = false; return await load(); }
|
|
303
|
+
if (type === "set_audio_port") { audioPort = e.ports?.[0] || null; return; }
|
|
304
|
+
if (type === "stop") { generating = false; status("Stopped", "idle"); return; }
|
|
305
|
+
if (type === "set_language") { postMessage({ type: "voices_loaded", voices: bundle?.predefined_voices || [], defaultVoice: currentVoice, language: "english" }); return; }
|
|
306
|
+
if (!ready) return postMessage({ type: "error", error: "Models are not loaded yet." });
|
|
307
|
+
if (type === "encode_voice") {
|
|
308
|
+
// kept as audio; encoded when it first speaks (the encoder is 39 MB)
|
|
309
|
+
const id = data.voiceId || "custom";
|
|
310
|
+
customAudio.set(id, new Float32Array(data.audio)); customStates.delete(id);
|
|
311
|
+
postMessage({ type: "voice_encoded", voiceName: "custom:" + id });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (type === "load_voice_bin") {
|
|
315
|
+
// a precomputed voice record (tools/build-voice-states.mjs, voices.bin's format)
|
|
316
|
+
const v = parseVoice(data.buf); customAudio.set(data.voiceId, new Float32Array(1)); customStates.set(data.voiceId, { name: data.voiceId, tensors: v.tensors });
|
|
317
|
+
postMessage({ type: "voice_state", id: data.voiceId, tensors: v.tensors });
|
|
318
|
+
postMessage({ type: "voice_encoded", voiceName: "custom:" + data.voiceId });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (type === "load_voice_state") {
|
|
322
|
+
// a voice state computed on an earlier visit
|
|
323
|
+
customAudio.set(data.voiceId, new Float32Array(1)); customStates.set(data.voiceId, { name: data.voiceId, tensors: data.tensors });
|
|
324
|
+
postMessage({ type: "voice_encoded", voiceName: "custom:" + data.voiceId });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (type === "delete_voice") { customAudio.delete(data.voiceId); customStates.delete(data.voiceId); return; }
|
|
328
|
+
if (type === "set_voice") { await voiceFor(data.voiceName); currentVoice = data.voiceName; postMessage({ type: "voice_set", voiceName: currentVoice }); status("Ready", "idle"); return; }
|
|
329
|
+
if (type === "generate") { if (!generating) await generate(data.text, data.voice || currentVoice); }
|
|
330
|
+
} catch (err) {
|
|
331
|
+
console.error("[tts18]", err);
|
|
332
|
+
postMessage({ type: "error", error: String(err?.message || err) });
|
|
333
|
+
}
|
|
334
|
+
};
|