@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
package/pocket-worker.js
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
// Pocket TTS ONNX Web Worker
|
|
2
|
+
console.log("Pocket TTS Worker Starting...");
|
|
3
|
+
self.postMessage({ type: "status", status: "Worker Thread Started", state: "idle" });
|
|
4
|
+
|
|
5
|
+
let ort = null;
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LANGUAGE = "english_2026-04";
|
|
8
|
+
const LANGUAGE_BUNDLES = ["english_2026-04", "german", "italian", "portuguese", "spanish"];
|
|
9
|
+
const MODEL_STEMS = {
|
|
10
|
+
mimi_encoder: "mimi_encoder_int8.onnx",
|
|
11
|
+
text_conditioner: "text_conditioner_int8.onnx",
|
|
12
|
+
flow_lm_main: "flow_lm_main_int8.onnx",
|
|
13
|
+
flow_lm_flow: "flow_lm_flow_int8.onnx",
|
|
14
|
+
mimi_decoder: "mimi_decoder_int8.onnx",
|
|
15
|
+
};
|
|
16
|
+
const DEBUG_LOGS = false;
|
|
17
|
+
const CHUNK_GAP_SEC = 0.0; // original Space used 0.25s; remove intentional sentence gaps
|
|
18
|
+
const MAX_FRAMES = 500;
|
|
19
|
+
const LSD_STEPS = 1;
|
|
20
|
+
const RESET_FLOW_STATE_EACH_CHUNK = true;
|
|
21
|
+
const RESET_MIMI_STATE_EACH_CHUNK = false; // keep decoder state across text chunks for smoother joins
|
|
22
|
+
|
|
23
|
+
let currentLanguage = DEFAULT_LANGUAGE;
|
|
24
|
+
let currentBundleDir = null;
|
|
25
|
+
let bundleMetadata = null;
|
|
26
|
+
let tokenizerProcessor = null;
|
|
27
|
+
let tokenizerModelB64 = null;
|
|
28
|
+
let bosBeforeVoice = null;
|
|
29
|
+
|
|
30
|
+
let mimiEncoderSession = null;
|
|
31
|
+
let textConditionerSession = null;
|
|
32
|
+
let flowLmMainSession = null;
|
|
33
|
+
let flowLmFlowSession = null;
|
|
34
|
+
let mimiDecoderSession = null;
|
|
35
|
+
|
|
36
|
+
let currentSampleRate = 24000;
|
|
37
|
+
let currentSamplesPerFrame = 1920;
|
|
38
|
+
let currentLatentDim = 32;
|
|
39
|
+
let currentConditioningDim = 1024;
|
|
40
|
+
let currentMaxTokenPerChunk = 50;
|
|
41
|
+
|
|
42
|
+
let predefinedVoiceRecords = {};
|
|
43
|
+
let customVoiceEmbedding = null;
|
|
44
|
+
let customVoiceEmbeddings = new Map();
|
|
45
|
+
let audioPort = null; // direct MessagePort to AudioWorklet (bypasses main thread)
|
|
46
|
+
let currentVoiceName = null;
|
|
47
|
+
let voiceConditioningCache = new Map();
|
|
48
|
+
|
|
49
|
+
let stTensors = [];
|
|
50
|
+
let isGenerating = false;
|
|
51
|
+
let isReady = false;
|
|
52
|
+
|
|
53
|
+
// The model files: our R2 mirror (same bytes as the Space's), the Space as the fallback.
|
|
54
|
+
const HF_REPO_BASE = "https://huggingface.co/spaces/KevinAHM/pocket-tts-web/resolve/main";
|
|
55
|
+
const MIRROR_BASE = "https://geebr-models.mindroot.io/pocket-tts/web";
|
|
56
|
+
let repoBase = MIRROR_BASE;
|
|
57
|
+
|
|
58
|
+
// fetch from the mirror; on failure switch to the fallback for good
|
|
59
|
+
async function fetchModelFile(path) {
|
|
60
|
+
if (repoBase === MIRROR_BASE) {
|
|
61
|
+
try { const r = await fetch(`${MIRROR_BASE}/${path}`); if (r.ok) return r; } catch {}
|
|
62
|
+
console.warn("[pocket-tts] mirror unavailable, using Hugging Face");
|
|
63
|
+
repoBase = HF_REPO_BASE;
|
|
64
|
+
}
|
|
65
|
+
return fetch(`${HF_REPO_BASE}/${path}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bundleDir(language) {
|
|
69
|
+
return `${repoBase}/onnx/${language}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function bundlePath(language, filename) {
|
|
73
|
+
return `${bundleDir(language)}/${filename}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function debugLog(...args) {
|
|
77
|
+
if (DEBUG_LOGS) {
|
|
78
|
+
console.log(...args);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeFilledArray(shape, dtype, fill) {
|
|
83
|
+
const size = shape.reduce((a, b) => a * b, 1);
|
|
84
|
+
let data;
|
|
85
|
+
|
|
86
|
+
if (dtype === "int64") {
|
|
87
|
+
data = new BigInt64Array(size);
|
|
88
|
+
} else if (dtype === "bool") {
|
|
89
|
+
data = new Uint8Array(size);
|
|
90
|
+
} else {
|
|
91
|
+
data = new Float32Array(size);
|
|
92
|
+
if (fill === "nan") {
|
|
93
|
+
data.fill(NaN);
|
|
94
|
+
} else if (fill === "ones") {
|
|
95
|
+
data.fill(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createTensor(dtype, data, dims) {
|
|
103
|
+
return new ort.Tensor(dtype, data, dims);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function initStateFromManifest(manifest) {
|
|
107
|
+
const state = {};
|
|
108
|
+
for (const entry of manifest) {
|
|
109
|
+
state[entry.input_name] = createTensor(
|
|
110
|
+
entry.dtype,
|
|
111
|
+
makeFilledArray(entry.shape, entry.dtype, entry.fill),
|
|
112
|
+
entry.shape
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cloneState(state) {
|
|
119
|
+
return { ...state };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function updateStateFromManifestOutputs(state, result, manifest) {
|
|
123
|
+
for (const entry of manifest) {
|
|
124
|
+
state[entry.input_name] = result[entry.output_name];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function groupVoiceRecordByModule(record) {
|
|
129
|
+
const grouped = {};
|
|
130
|
+
for (const [key, value] of Object.entries(record)) {
|
|
131
|
+
const slash = key.indexOf("/");
|
|
132
|
+
if (slash === -1) continue;
|
|
133
|
+
const moduleName = key.slice(0, slash);
|
|
134
|
+
const tensorKey = key.slice(slash + 1);
|
|
135
|
+
if (!grouped[moduleName]) {
|
|
136
|
+
grouped[moduleName] = {};
|
|
137
|
+
}
|
|
138
|
+
grouped[moduleName][tensorKey] = value;
|
|
139
|
+
}
|
|
140
|
+
return grouped;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function adaptTypedArray(source, entry) {
|
|
144
|
+
const targetShape = entry.shape;
|
|
145
|
+
const targetSize = targetShape.reduce((a, b) => a * b, 1);
|
|
146
|
+
const target = makeFilledArray(targetShape, entry.dtype, entry.fill);
|
|
147
|
+
|
|
148
|
+
if (source.shape.length === targetShape.length) {
|
|
149
|
+
const exactShape = source.shape.every((dim, idx) => dim === targetShape[idx]);
|
|
150
|
+
if (exactShape) {
|
|
151
|
+
if (entry.dtype === "int64") {
|
|
152
|
+
return new BigInt64Array(source.data);
|
|
153
|
+
}
|
|
154
|
+
if (entry.dtype === "bool") {
|
|
155
|
+
return new Uint8Array(source.data);
|
|
156
|
+
}
|
|
157
|
+
return new Float32Array(source.data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (source.data.length === targetSize) {
|
|
162
|
+
if (entry.dtype === "int64") {
|
|
163
|
+
return new BigInt64Array(source.data);
|
|
164
|
+
}
|
|
165
|
+
if (entry.dtype === "bool") {
|
|
166
|
+
return new Uint8Array(source.data);
|
|
167
|
+
}
|
|
168
|
+
return new Float32Array(source.data);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (source.shape.length !== targetShape.length) {
|
|
172
|
+
return target;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const strides = [];
|
|
176
|
+
let stride = 1;
|
|
177
|
+
for (let i = source.shape.length - 1; i >= 0; i--) {
|
|
178
|
+
strides[i] = stride;
|
|
179
|
+
stride *= source.shape[i];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const indices = new Array(source.shape.length).fill(0);
|
|
183
|
+
const maxIndices = source.shape.map((dim, idx) => Math.min(dim, targetShape[idx]));
|
|
184
|
+
|
|
185
|
+
function targetIndex(coords) {
|
|
186
|
+
let idx = 0;
|
|
187
|
+
let tStride = 1;
|
|
188
|
+
for (let i = targetShape.length - 1; i >= 0; i--) {
|
|
189
|
+
idx += coords[i] * tStride;
|
|
190
|
+
tStride *= targetShape[i];
|
|
191
|
+
}
|
|
192
|
+
return idx;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let done = false;
|
|
196
|
+
while (!done) {
|
|
197
|
+
let sourceIdx = 0;
|
|
198
|
+
for (let i = 0; i < indices.length; i++) {
|
|
199
|
+
sourceIdx += indices[i] * strides[i];
|
|
200
|
+
}
|
|
201
|
+
target[targetIndex(indices)] = source.data[sourceIdx];
|
|
202
|
+
|
|
203
|
+
for (let dim = indices.length - 1; dim >= 0; dim--) {
|
|
204
|
+
indices[dim] += 1;
|
|
205
|
+
if (indices[dim] < maxIndices[dim]) {
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
indices[dim] = 0;
|
|
209
|
+
if (dim === 0) {
|
|
210
|
+
done = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return target;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function deriveStep(moduleState) {
|
|
219
|
+
if (moduleState.step) {
|
|
220
|
+
return { data: BigInt64Array.from([BigInt(moduleState.step.data[0])]), shape: [1], dtype: "int64" };
|
|
221
|
+
}
|
|
222
|
+
if (moduleState.offset && !moduleState.end_offset) {
|
|
223
|
+
return { data: BigInt64Array.from([BigInt(moduleState.offset.data[0])]), shape: [1], dtype: "int64" };
|
|
224
|
+
}
|
|
225
|
+
if (moduleState.current_end) {
|
|
226
|
+
return { data: BigInt64Array.from([BigInt(moduleState.current_end.shape[0])]), shape: [1], dtype: "int64" };
|
|
227
|
+
}
|
|
228
|
+
return { data: BigInt64Array.from([0n]), shape: [1], dtype: "int64" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function stateFromVoiceRecord(record) {
|
|
232
|
+
const grouped = groupVoiceRecordByModule(record);
|
|
233
|
+
const state = initStateFromManifest(bundleMetadata.flow_lm_state_manifest);
|
|
234
|
+
|
|
235
|
+
for (const entry of bundleMetadata.flow_lm_state_manifest) {
|
|
236
|
+
const moduleState = grouped[entry.module] || {};
|
|
237
|
+
let source = moduleState[entry.key];
|
|
238
|
+
if (!source && entry.key === "step") {
|
|
239
|
+
source = deriveStep(moduleState);
|
|
240
|
+
}
|
|
241
|
+
if (!source) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const data = adaptTypedArray(source, entry);
|
|
246
|
+
state[entry.input_name] = createTensor(entry.dtype, data, entry.shape);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return state;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function prepareVoiceEmbeddingData(voiceEmb) {
|
|
253
|
+
let data = voiceEmb.data;
|
|
254
|
+
let dims = voiceEmb.shape.slice();
|
|
255
|
+
|
|
256
|
+
if (bundleMetadata.insert_bos_before_voice && bosBeforeVoice) {
|
|
257
|
+
const bosData = bosBeforeVoice.data;
|
|
258
|
+
const combined = new Float32Array(bosData.length + data.length);
|
|
259
|
+
combined.set(bosData, 0);
|
|
260
|
+
combined.set(data, bosData.length);
|
|
261
|
+
data = combined;
|
|
262
|
+
dims = [1, dims[1] + bosBeforeVoice.shape[1], dims[2]];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return createTensor("float32", data, dims);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function buildVoiceConditionedState(voiceEmb) {
|
|
269
|
+
const flowLmState = initStateFromManifest(bundleMetadata.flow_lm_state_manifest);
|
|
270
|
+
const emptySeq = createTensor("float32", new Float32Array(0), [1, 0, currentLatentDim]);
|
|
271
|
+
const voiceTensor = prepareVoiceEmbeddingData(voiceEmb);
|
|
272
|
+
|
|
273
|
+
const result = await flowLmMainSession.run({
|
|
274
|
+
sequence: emptySeq,
|
|
275
|
+
text_embeddings: voiceTensor,
|
|
276
|
+
...flowLmState,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
updateStateFromManifestOutputs(flowLmState, result, bundleMetadata.flow_lm_state_manifest);
|
|
280
|
+
return flowLmState;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function ensurePredefinedVoiceCached(voiceName, options = {}) {
|
|
284
|
+
const { force = false, statusText = "Preparing voice..." } = options;
|
|
285
|
+
if (!predefinedVoiceRecords[voiceName]) {
|
|
286
|
+
throw new Error(`Unknown built-in voice: ${voiceName}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!force && voiceConditioningCache.has(voiceName)) {
|
|
290
|
+
return voiceConditioningCache.get(voiceName);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
postMessage({ type: "status", status: statusText, state: "loading" });
|
|
294
|
+
const conditioned = stateFromVoiceRecord(predefinedVoiceRecords[voiceName]);
|
|
295
|
+
voiceConditioningCache.set(voiceName, conditioned);
|
|
296
|
+
return conditioned;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function ensureCustomVoiceCached(options = {}) {
|
|
300
|
+
const voiceKey = options.voiceKey || "custom";
|
|
301
|
+
customVoiceEmbedding = customVoiceEmbeddings.get(voiceKey.replace(/^custom:/, "")) || customVoiceEmbedding;
|
|
302
|
+
const { force = false, statusText = "Preparing custom voice..." } = options;
|
|
303
|
+
if (!customVoiceEmbedding) {
|
|
304
|
+
throw new Error("No custom voice loaded.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!force && voiceConditioningCache.has(voiceKey)) {
|
|
308
|
+
return voiceConditioningCache.get(voiceKey);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
postMessage({ type: "status", status: statusText, state: "loading" });
|
|
312
|
+
const conditioned = await buildVoiceConditionedState(customVoiceEmbedding);
|
|
313
|
+
voiceConditioningCache.set(voiceKey, conditioned);
|
|
314
|
+
return conditioned;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parseNpyFloat32(buffer) {
|
|
318
|
+
const view = new DataView(buffer);
|
|
319
|
+
const magic = new Uint8Array(buffer, 0, 6);
|
|
320
|
+
const expected = [0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59];
|
|
321
|
+
for (let i = 0; i < expected.length; i++) {
|
|
322
|
+
if (magic[i] !== expected[i]) {
|
|
323
|
+
throw new Error("Invalid NPY file");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const major = view.getUint8(6);
|
|
328
|
+
const headerLen = major === 1 ? view.getUint16(8, true) : view.getUint32(8, true);
|
|
329
|
+
const headerOffset = major === 1 ? 10 : 12;
|
|
330
|
+
const headerText = new TextDecoder().decode(new Uint8Array(buffer, headerOffset, headerLen));
|
|
331
|
+
const shapeMatch = headerText.match(/\(\s*([0-9,\s]+)\)/);
|
|
332
|
+
if (!shapeMatch) {
|
|
333
|
+
throw new Error("Could not parse NPY shape");
|
|
334
|
+
}
|
|
335
|
+
const shape = shapeMatch[1]
|
|
336
|
+
.split(",")
|
|
337
|
+
.map((part) => part.trim())
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
.map((part) => Number.parseInt(part, 10));
|
|
340
|
+
const dataOffset = headerOffset + headerLen;
|
|
341
|
+
const data = new Float32Array(buffer, dataOffset);
|
|
342
|
+
return { data: new Float32Array(data), shape };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseVoiceStatesBin(buffer) {
|
|
346
|
+
const view = new DataView(buffer);
|
|
347
|
+
let offset = 0;
|
|
348
|
+
const magic = new TextDecoder().decode(new Uint8Array(buffer, offset, 5));
|
|
349
|
+
offset += 5;
|
|
350
|
+
if (magic !== "PTVB1") {
|
|
351
|
+
throw new Error("Invalid voices.bin header");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const voices = {};
|
|
355
|
+
const voiceCount = view.getUint32(offset, true);
|
|
356
|
+
offset += 4;
|
|
357
|
+
|
|
358
|
+
for (let voiceIndex = 0; voiceIndex < voiceCount; voiceIndex++) {
|
|
359
|
+
const nameLen = view.getUint16(offset, true);
|
|
360
|
+
offset += 2;
|
|
361
|
+
const name = new TextDecoder().decode(new Uint8Array(buffer, offset, nameLen));
|
|
362
|
+
offset += nameLen;
|
|
363
|
+
|
|
364
|
+
const tensorCount = view.getUint16(offset, true);
|
|
365
|
+
offset += 2;
|
|
366
|
+
const tensors = {};
|
|
367
|
+
|
|
368
|
+
for (let tensorIndex = 0; tensorIndex < tensorCount; tensorIndex++) {
|
|
369
|
+
const keyLen = view.getUint16(offset, true);
|
|
370
|
+
offset += 2;
|
|
371
|
+
const key = new TextDecoder().decode(new Uint8Array(buffer, offset, keyLen));
|
|
372
|
+
offset += keyLen;
|
|
373
|
+
|
|
374
|
+
const dtypeCode = view.getUint8(offset);
|
|
375
|
+
offset += 1;
|
|
376
|
+
const rank = view.getUint8(offset);
|
|
377
|
+
offset += 1;
|
|
378
|
+
|
|
379
|
+
const shape = [];
|
|
380
|
+
for (let dimIndex = 0; dimIndex < rank; dimIndex++) {
|
|
381
|
+
shape.push(view.getUint32(offset, true));
|
|
382
|
+
offset += 4;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const byteLength = view.getUint32(offset, true);
|
|
386
|
+
offset += 4;
|
|
387
|
+
|
|
388
|
+
let data;
|
|
389
|
+
if (dtypeCode === 0) {
|
|
390
|
+
data = new Float32Array(buffer.slice(offset, offset + byteLength));
|
|
391
|
+
} else if (dtypeCode === 1) {
|
|
392
|
+
data = new BigInt64Array(buffer.slice(offset, offset + byteLength));
|
|
393
|
+
} else if (dtypeCode === 2) {
|
|
394
|
+
data = new Uint8Array(buffer.slice(offset, offset + byteLength));
|
|
395
|
+
} else {
|
|
396
|
+
throw new Error(`Unsupported voices.bin dtype code: ${dtypeCode}`);
|
|
397
|
+
}
|
|
398
|
+
offset += byteLength;
|
|
399
|
+
|
|
400
|
+
tensors[key] = {
|
|
401
|
+
data,
|
|
402
|
+
shape,
|
|
403
|
+
dtype: dtypeCode === 0 ? "float32" : dtypeCode === 1 ? "int64" : "bool",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
voices[name] = tensors;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return voices;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function encodeVoiceAudio(audioData) {
|
|
414
|
+
const input = createTensor("float32", audioData, [1, 1, audioData.length]);
|
|
415
|
+
const outputs = await mimiEncoderSession.run({ audio: input });
|
|
416
|
+
const embeddings = outputs[mimiEncoderSession.outputNames[0]];
|
|
417
|
+
|
|
418
|
+
let dims = embeddings.dims.slice();
|
|
419
|
+
let data = new Float32Array(embeddings.data);
|
|
420
|
+
while (dims.length > 3) {
|
|
421
|
+
if (dims[0] !== 1) break;
|
|
422
|
+
dims = dims.slice(1);
|
|
423
|
+
}
|
|
424
|
+
if (dims.length < 3) {
|
|
425
|
+
dims = [1, dims[0], dims[1]];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { data, shape: dims };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function prepareTextPrompt(text) {
|
|
432
|
+
let prompt = text.trim();
|
|
433
|
+
if (!prompt) {
|
|
434
|
+
return { text: "", framesAfterEos: 1 };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
prompt = prompt.replace(/\r/g, " ").replace(/\n/g, " ").replace(/\s+/g, " ");
|
|
438
|
+
if (bundleMetadata.remove_semicolons) {
|
|
439
|
+
prompt = prompt.replace(/;/g, ",");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const wordCount = prompt.split(/\s+/).filter(Boolean).length;
|
|
443
|
+
let framesAfterEos = wordCount <= 4 ? 3 : 1;
|
|
444
|
+
if (bundleMetadata.model_recommended_frames_after_eos != null) {
|
|
445
|
+
framesAfterEos = Number(bundleMetadata.model_recommended_frames_after_eos);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (prompt && !/[A-ZÀ-Þ]/.test(prompt[0])) {
|
|
449
|
+
prompt = prompt[0].toUpperCase() + prompt.slice(1);
|
|
450
|
+
}
|
|
451
|
+
if (prompt && /[0-9A-Za-zÀ-ÿ]/.test(prompt[prompt.length - 1])) {
|
|
452
|
+
prompt += ".";
|
|
453
|
+
}
|
|
454
|
+
if (bundleMetadata.pad_with_spaces_for_short_inputs && wordCount < 5) {
|
|
455
|
+
prompt = " " + prompt;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { text: prompt, framesAfterEos };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function splitTokenIdsIntoChunks(tokenIds, maxTokens) {
|
|
462
|
+
const chunks = [];
|
|
463
|
+
for (let i = 0; i < tokenIds.length; i += maxTokens) {
|
|
464
|
+
const chunkText = tokenizerProcessor.decodeIds(tokenIds.slice(i, i + maxTokens)).trim();
|
|
465
|
+
if (chunkText) {
|
|
466
|
+
chunks.push(chunkText);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return chunks;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function splitIntoBestSentences(text) {
|
|
473
|
+
const prepared = prepareTextPrompt(text);
|
|
474
|
+
if (!prepared.text) {
|
|
475
|
+
return { chunks: [], framesAfterEos: prepared.framesAfterEos };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Keep ordinary speech as one synthesis context. Split only when the model's
|
|
479
|
+
// hard token limit requires it; punctuation never creates a generation seam.
|
|
480
|
+
const tokenIds = tokenizerProcessor.encodeIds(prepared.text);
|
|
481
|
+
const chunks = tokenIds.length > currentMaxTokenPerChunk
|
|
482
|
+
? splitTokenIdsIntoChunks(tokenIds, currentMaxTokenPerChunk)
|
|
483
|
+
: [prepared.text];
|
|
484
|
+
return { chunks, framesAfterEos: prepared.framesAfterEos };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function precomputeFlowBuffers() {
|
|
488
|
+
stTensors = [];
|
|
489
|
+
const dt = 1.0 / LSD_STEPS;
|
|
490
|
+
for (let step = 0; step < LSD_STEPS; step++) {
|
|
491
|
+
const s = step / LSD_STEPS;
|
|
492
|
+
const t = s + dt;
|
|
493
|
+
stTensors.push({
|
|
494
|
+
s: createTensor("float32", new Float32Array([s]), [1, 1]),
|
|
495
|
+
t: createTensor("float32", new Float32Array([t]), [1, 1]),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
async function importSentencePiece() {
|
|
502
|
+
if (self.__sentencePieceModule) return self.__sentencePieceModule;
|
|
503
|
+
postMessage({ type: "status", status: "Loading SentencePiece...", state: "loading" });
|
|
504
|
+
const res = await fetchModelFile("sentencepiece.js");
|
|
505
|
+
if (!res.ok) throw new Error(`Failed to fetch sentencepiece.js: ${res.status}`);
|
|
506
|
+
const js = await res.text();
|
|
507
|
+
const blobUrl = URL.createObjectURL(new Blob([js], { type: "application/javascript" }));
|
|
508
|
+
self.__sentencePieceModule = await import(blobUrl);
|
|
509
|
+
return self.__sentencePieceModule;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function loadOrt() {
|
|
513
|
+
if (ort) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
postMessage({ type: "status", status: "Loading ONNX Runtime...", state: "loading" });
|
|
518
|
+
const version = "1.31.1";
|
|
519
|
+
const cdnBase = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${version}/dist/`;
|
|
520
|
+
const ortModule = await import(`https://cdn.jsdelivr.net/npm/onnxruntime-web@${version}/dist/ort.min.mjs`);
|
|
521
|
+
ort = ortModule.default || ortModule;
|
|
522
|
+
ort.env.wasm.wasmPaths = cdnBase;
|
|
523
|
+
ort.env.wasm.simd = true;
|
|
524
|
+
// Kyutai's own README for this model (kyutai-labs/pocket-tts) documents
|
|
525
|
+
// ~6x real-time on a MacBook Air M4 using only 2 CPU cores - it's a small
|
|
526
|
+
// (~100M param), batch-size-1 model that was never designed to scale
|
|
527
|
+
// across many threads. The min(hardwareConcurrency,8) this used to be
|
|
528
|
+
// came from generic onnxruntime-web demo boilerplate (present verbatim in
|
|
529
|
+
// both the community browser port this app is built on and the
|
|
530
|
+
// pocket_tts_min/ reference copy in this repo), not from any tuning for
|
|
531
|
+
// this specific model. Measured RTFx in this app was only ~1.03-1.08x
|
|
532
|
+
// (barely faster than real-time, vs. Kyutai's ~6x target) while pegging
|
|
533
|
+
// 7-8 of 12 CPU threads - a classic oversubscription signature for a
|
|
534
|
+
// small, tightly-coupled model: more threads fighting over cache/
|
|
535
|
+
// scheduler time than there's real parallel work to hand them. Matching
|
|
536
|
+
// Kyutai's own number as an experiment; revisit if RTFx doesn't improve.
|
|
537
|
+
const threads = self.crossOriginIsolated
|
|
538
|
+
? Math.min(navigator.hardwareConcurrency || 4, 2)
|
|
539
|
+
: 1;
|
|
540
|
+
ort.env.wasm.numThreads = threads;
|
|
541
|
+
postMessage({ type: "runtime_info", data: { backend: "WASM CPU", isolated: self.crossOriginIsolated, threads, hardwareConcurrency: navigator.hardwareConcurrency || 0 } });
|
|
542
|
+
precomputeFlowBuffers();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function releaseSession(session) {
|
|
546
|
+
if (!session) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (typeof session.release === "function") {
|
|
550
|
+
await session.release();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function loadBundle(language, { initialLoad = false } = {}) {
|
|
555
|
+
if (!LANGUAGE_BUNDLES.includes(language)) {
|
|
556
|
+
throw new Error(`Unsupported language bundle: ${language}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await loadOrt();
|
|
560
|
+
|
|
561
|
+
postMessage({ type: "status", status: `Loading ${language} bundle...`, state: "loading" });
|
|
562
|
+
currentLanguage = language;
|
|
563
|
+
const metadataResponse = await fetchModelFile(`onnx/${language}/bundle.json`);
|
|
564
|
+
currentBundleDir = bundleDir(language);
|
|
565
|
+
if (!metadataResponse.ok) {
|
|
566
|
+
throw new Error(`Failed to load bundle metadata for ${language}`);
|
|
567
|
+
}
|
|
568
|
+
bundleMetadata = await metadataResponse.json();
|
|
569
|
+
|
|
570
|
+
currentSampleRate = Number(bundleMetadata.sample_rate);
|
|
571
|
+
currentSamplesPerFrame = Number(bundleMetadata.samples_per_frame);
|
|
572
|
+
currentLatentDim = Number(bundleMetadata.latent_dim);
|
|
573
|
+
currentConditioningDim = Number(bundleMetadata.conditioning_dim);
|
|
574
|
+
currentMaxTokenPerChunk = Number(bundleMetadata.max_token_per_chunk || 50);
|
|
575
|
+
isReady = false;
|
|
576
|
+
|
|
577
|
+
await Promise.all([
|
|
578
|
+
releaseSession(mimiEncoderSession),
|
|
579
|
+
releaseSession(textConditionerSession),
|
|
580
|
+
releaseSession(flowLmMainSession),
|
|
581
|
+
releaseSession(flowLmFlowSession),
|
|
582
|
+
releaseSession(mimiDecoderSession),
|
|
583
|
+
]);
|
|
584
|
+
|
|
585
|
+
const sessionOptions = {
|
|
586
|
+
executionProviders: ["wasm"],
|
|
587
|
+
graphOptimizationLevel: "all",
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const [encoderRes, textCondRes, flowMainRes, flowFlowRes, decoderRes] = await Promise.all([
|
|
591
|
+
ort.InferenceSession.create(bundlePath(language, MODEL_STEMS.mimi_encoder), sessionOptions),
|
|
592
|
+
ort.InferenceSession.create(bundlePath(language, MODEL_STEMS.text_conditioner), sessionOptions),
|
|
593
|
+
ort.InferenceSession.create(bundlePath(language, MODEL_STEMS.flow_lm_main), sessionOptions),
|
|
594
|
+
ort.InferenceSession.create(bundlePath(language, MODEL_STEMS.flow_lm_flow), sessionOptions),
|
|
595
|
+
ort.InferenceSession.create(bundlePath(language, MODEL_STEMS.mimi_decoder), sessionOptions),
|
|
596
|
+
]);
|
|
597
|
+
|
|
598
|
+
mimiEncoderSession = encoderRes;
|
|
599
|
+
textConditionerSession = textCondRes;
|
|
600
|
+
flowLmMainSession = flowMainRes;
|
|
601
|
+
flowLmFlowSession = flowFlowRes;
|
|
602
|
+
mimiDecoderSession = decoderRes;
|
|
603
|
+
|
|
604
|
+
const tokenizerResponse = await fetch(bundlePath(language, bundleMetadata.tokenizer_file));
|
|
605
|
+
if (!tokenizerResponse.ok) {
|
|
606
|
+
throw new Error(`Failed to load tokenizer for ${language}`);
|
|
607
|
+
}
|
|
608
|
+
const tokenizerBuffer = await tokenizerResponse.arrayBuffer();
|
|
609
|
+
tokenizerModelB64 = btoa(String.fromCharCode(...new Uint8Array(tokenizerBuffer)));
|
|
610
|
+
const spModule = await importSentencePiece();
|
|
611
|
+
tokenizerProcessor = new spModule.SentencePieceProcessor();
|
|
612
|
+
await tokenizerProcessor.loadFromB64StringModel(tokenizerModelB64);
|
|
613
|
+
|
|
614
|
+
bosBeforeVoice = null;
|
|
615
|
+
if (bundleMetadata.bos_before_voice_file) {
|
|
616
|
+
const bosResponse = await fetch(bundlePath(language, bundleMetadata.bos_before_voice_file));
|
|
617
|
+
if (bosResponse.ok) {
|
|
618
|
+
bosBeforeVoice = parseNpyFloat32(await bosResponse.arrayBuffer());
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
predefinedVoiceRecords = {};
|
|
623
|
+
const voicesResponse = await fetch(bundlePath(language, "voices.bin"));
|
|
624
|
+
if (voicesResponse.ok) {
|
|
625
|
+
predefinedVoiceRecords = parseVoiceStatesBin(await voicesResponse.arrayBuffer());
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
voiceConditioningCache = new Map();
|
|
629
|
+
let defaultVoice = bundleMetadata.predefined_voices?.includes("alba") ? "alba" : null;
|
|
630
|
+
if (!defaultVoice) {
|
|
631
|
+
defaultVoice = Object.keys(predefinedVoiceRecords)[0] || null;
|
|
632
|
+
}
|
|
633
|
+
currentVoiceName = defaultVoice;
|
|
634
|
+
|
|
635
|
+
if (defaultVoice) {
|
|
636
|
+
await ensurePredefinedVoiceCached(defaultVoice, {
|
|
637
|
+
force: true,
|
|
638
|
+
statusText: `Preparing voice (${defaultVoice})...`,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (customVoiceEmbedding) {
|
|
643
|
+
voiceConditioningCache.delete("custom");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
isReady = true;
|
|
647
|
+
|
|
648
|
+
postMessage({
|
|
649
|
+
type: "voices_loaded",
|
|
650
|
+
voices: bundleMetadata.predefined_voices || Object.keys(predefinedVoiceRecords),
|
|
651
|
+
defaultVoice,
|
|
652
|
+
language,
|
|
653
|
+
});
|
|
654
|
+
postMessage({
|
|
655
|
+
type: "bundle_loaded",
|
|
656
|
+
language,
|
|
657
|
+
sampleRate: currentSampleRate,
|
|
658
|
+
initialLoad,
|
|
659
|
+
});
|
|
660
|
+
postMessage({ type: "status", status: "Ready", state: "idle" });
|
|
661
|
+
postMessage({ type: "model_status", status: "ready", text: `Ready (${language})` });
|
|
662
|
+
if (initialLoad) {
|
|
663
|
+
postMessage({ type: "loaded" });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
self.onmessage = async (e) => {
|
|
668
|
+
const { type, data } = e.data;
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
if (type === "load") {
|
|
672
|
+
// the R2 mirror only where the page allows it (@tablefolk/models mirrorAllowed: tablefolk.world)
|
|
673
|
+
if (data?.mirror === false) repoBase = HF_REPO_BASE;
|
|
674
|
+
await loadBundle(data?.language || DEFAULT_LANGUAGE, { initialLoad: true });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (type === "set_audio_port") {
|
|
679
|
+
if (e.ports && e.ports.length) {
|
|
680
|
+
audioPort = e.ports[0];
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (type === "set_language") {
|
|
686
|
+
if (isGenerating) {
|
|
687
|
+
postMessage({ type: "error", error: "Cannot switch language while generation is running." });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
await loadBundle(data.language, { initialLoad: false });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (type === "stop") {
|
|
695
|
+
isGenerating = false;
|
|
696
|
+
postMessage({ type: "status", status: "Stopped", state: "idle" });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (!isReady) {
|
|
701
|
+
postMessage({ type: "error", error: "Models are not loaded yet." });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (type === "encode_voice") {
|
|
706
|
+
if (isGenerating) {
|
|
707
|
+
postMessage({ type: "error", error: "Cannot encode a voice while generation is running." });
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
customVoiceEmbedding = await encodeVoiceAudio(data.audio);
|
|
711
|
+
const customId = data.voiceId || "custom";
|
|
712
|
+
customVoiceEmbeddings.set(customId, customVoiceEmbedding);
|
|
713
|
+
currentVoiceName = `custom:${customId}`;
|
|
714
|
+
await ensureCustomVoiceCached({ force: true, voiceKey: currentVoiceName, statusText: "Preparing custom voice..." });
|
|
715
|
+
postMessage({ type: "voice_encoded", voiceName: currentVoiceName });
|
|
716
|
+
postMessage({ type: "status", status: "Ready", state: "idle" });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (type === "delete_voice") {
|
|
721
|
+
customVoiceEmbeddings.delete(data.voiceId);
|
|
722
|
+
voiceConditioningCache.delete(`custom:${data.voiceId}`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (type === "set_voice") {
|
|
727
|
+
if (isGenerating) {
|
|
728
|
+
postMessage({ type: "error", error: "Cannot switch voice while generation is running." });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (data.voiceName.startsWith("custom:")) {
|
|
732
|
+
await ensureCustomVoiceCached({ voiceKey: data.voiceName, statusText: "Preparing custom voice..." });
|
|
733
|
+
currentVoiceName = data.voiceName;
|
|
734
|
+
} else {
|
|
735
|
+
await ensurePredefinedVoiceCached(data.voiceName, {
|
|
736
|
+
statusText: `Preparing voice (${data.voiceName})...`,
|
|
737
|
+
});
|
|
738
|
+
currentVoiceName = data.voiceName;
|
|
739
|
+
}
|
|
740
|
+
postMessage({ type: "voice_set", voiceName: currentVoiceName });
|
|
741
|
+
postMessage({ type: "status", status: "Ready", state: "idle" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (type === "generate") {
|
|
746
|
+
if (isGenerating) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
await startGeneration(data.text, data.voice || currentVoiceName);
|
|
750
|
+
}
|
|
751
|
+
} catch (err) {
|
|
752
|
+
console.error("Worker error:", err);
|
|
753
|
+
postMessage({ type: "error", error: err.toString() });
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
async function startGeneration(text, voiceName) {
|
|
758
|
+
isGenerating = true;
|
|
759
|
+
postMessage({ type: "status", status: "Generating...", state: "running" });
|
|
760
|
+
if (audioPort) audioPort.postMessage({ type: "reset" });
|
|
761
|
+
postMessage({ type: "generation_started", data: { time: performance.now(), sourceSampleRate: currentSampleRate } });
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
const { chunks, framesAfterEos } = splitIntoBestSentences(text);
|
|
765
|
+
if (!chunks.length) {
|
|
766
|
+
throw new Error("No text to generate");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (voiceName.startsWith("custom:")) {
|
|
770
|
+
await ensureCustomVoiceCached({ voiceKey: voiceName, statusText: "Preparing custom voice..." });
|
|
771
|
+
} else {
|
|
772
|
+
await ensurePredefinedVoiceCached(voiceName, {
|
|
773
|
+
statusText: `Preparing voice (${voiceName})...`,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
currentVoiceName = voiceName;
|
|
777
|
+
|
|
778
|
+
await runGenerationPipeline(voiceName, chunks, framesAfterEos);
|
|
779
|
+
} catch (err) {
|
|
780
|
+
console.error("Generation error:", err);
|
|
781
|
+
postMessage({ type: "error", error: err.toString() });
|
|
782
|
+
} finally {
|
|
783
|
+
if (isGenerating) {
|
|
784
|
+
if (audioPort) audioPort.postMessage({ type: "stream-ended" });
|
|
785
|
+
postMessage({ type: "stream_ended" });
|
|
786
|
+
postMessage({ type: "status", status: "Finished", state: "idle" });
|
|
787
|
+
}
|
|
788
|
+
isGenerating = false;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function runGenerationPipeline(voiceName, chunks, framesAfterEos) {
|
|
793
|
+
let mimiState = initStateFromManifest(bundleMetadata.mimi_state_manifest);
|
|
794
|
+
const emptySeq = createTensor("float32", new Float32Array(0), [1, 0, currentLatentDim]);
|
|
795
|
+
const emptyTextEmb = createTensor("float32", new Float32Array(0), [1, 0, currentConditioningDim]);
|
|
796
|
+
const baseFlowState = voiceConditioningCache.get(voiceName);
|
|
797
|
+
if (!baseFlowState) {
|
|
798
|
+
throw new Error(`Voice conditioning cache missing for '${voiceName}'.`);
|
|
799
|
+
}
|
|
800
|
+
let flowLmState = cloneState(baseFlowState);
|
|
801
|
+
|
|
802
|
+
const firstChunkFrames = 5; // 400 ms startup batch for additional underrun protection
|
|
803
|
+
const normalChunkFrames = 6; // smaller batches arrive sooner, preventing underrun when CPU is shared
|
|
804
|
+
const allGeneratedLatents = [];
|
|
805
|
+
let isFirstAudioChunk = true;
|
|
806
|
+
let firstAudioAt = null;
|
|
807
|
+
let totalFlowLmTime = 0;
|
|
808
|
+
let totalDecodeTime = 0;
|
|
809
|
+
const generationStart = performance.now();
|
|
810
|
+
|
|
811
|
+
for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
|
|
812
|
+
if (!isGenerating) break;
|
|
813
|
+
|
|
814
|
+
if (RESET_FLOW_STATE_EACH_CHUNK && chunkIdx > 0) {
|
|
815
|
+
flowLmState = cloneState(baseFlowState);
|
|
816
|
+
}
|
|
817
|
+
if (RESET_MIMI_STATE_EACH_CHUNK && chunkIdx > 0) {
|
|
818
|
+
mimiState = initStateFromManifest(bundleMetadata.mimi_state_manifest);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const chunkText = chunks[chunkIdx];
|
|
822
|
+
let isFirstAudioChunkOfTextChunk = true;
|
|
823
|
+
const tokenIds = tokenizerProcessor.encodeIds(chunkText);
|
|
824
|
+
const textInput = createTensor(
|
|
825
|
+
"int64",
|
|
826
|
+
BigInt64Array.from(tokenIds.map((token) => BigInt(token))),
|
|
827
|
+
[1, tokenIds.length]
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
let textEmb = (await textConditionerSession.run({ token_ids: textInput }))[textConditionerSession.outputNames[0]];
|
|
831
|
+
if (textEmb.dims.length === 2) {
|
|
832
|
+
textEmb = createTensor("float32", new Float32Array(textEmb.data), [1, textEmb.dims[0], textEmb.dims[1]]);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const condResult = await flowLmMainSession.run({
|
|
836
|
+
sequence: emptySeq,
|
|
837
|
+
text_embeddings: textEmb,
|
|
838
|
+
...flowLmState,
|
|
839
|
+
});
|
|
840
|
+
updateStateFromManifestOutputs(flowLmState, condResult, bundleMetadata.flow_lm_state_manifest);
|
|
841
|
+
|
|
842
|
+
const chunkLatents = [];
|
|
843
|
+
let chunkDecodedFrames = 0;
|
|
844
|
+
let currentLatent = createTensor("float32", new Float32Array(currentLatentDim).fill(NaN), [1, 1, currentLatentDim]);
|
|
845
|
+
let eosStep = null;
|
|
846
|
+
let chunkEnded = false;
|
|
847
|
+
let chunkGenTimeMs = 0;
|
|
848
|
+
|
|
849
|
+
for (let step = 0; step < MAX_FRAMES; step++) {
|
|
850
|
+
if (!isGenerating) break;
|
|
851
|
+
if (step > 0 && step % 4 === 0) {
|
|
852
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const stepStart = performance.now();
|
|
856
|
+
const arResult = await flowLmMainSession.run({
|
|
857
|
+
sequence: currentLatent,
|
|
858
|
+
text_embeddings: emptyTextEmb,
|
|
859
|
+
...flowLmState,
|
|
860
|
+
});
|
|
861
|
+
const stepElapsed = performance.now() - stepStart;
|
|
862
|
+
chunkGenTimeMs += stepElapsed;
|
|
863
|
+
totalFlowLmTime += stepElapsed;
|
|
864
|
+
|
|
865
|
+
const conditioning = arResult.conditioning;
|
|
866
|
+
const eosLogit = arResult.eos_logit.data[0];
|
|
867
|
+
const isEos = eosLogit > -4.0;
|
|
868
|
+
if (isEos && eosStep == null) {
|
|
869
|
+
eosStep = step;
|
|
870
|
+
}
|
|
871
|
+
const shouldStop = eosStep != null && step >= eosStep + framesAfterEos;
|
|
872
|
+
|
|
873
|
+
const temperature = 0.7;
|
|
874
|
+
const std = Math.sqrt(temperature);
|
|
875
|
+
const latentData = new Float32Array(currentLatentDim);
|
|
876
|
+
for (let i = 0; i < currentLatentDim; i++) {
|
|
877
|
+
let u = 0;
|
|
878
|
+
let v = 0;
|
|
879
|
+
while (u === 0) u = Math.random();
|
|
880
|
+
while (v === 0) v = Math.random();
|
|
881
|
+
latentData[i] = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v) * std;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const dt = 1.0 / LSD_STEPS;
|
|
885
|
+
for (let lsdIndex = 0; lsdIndex < LSD_STEPS; lsdIndex++) {
|
|
886
|
+
const flowResult = await flowLmFlowSession.run({
|
|
887
|
+
c: conditioning,
|
|
888
|
+
s: stTensors[lsdIndex].s,
|
|
889
|
+
t: stTensors[lsdIndex].t,
|
|
890
|
+
x: createTensor("float32", latentData, [1, currentLatentDim]),
|
|
891
|
+
});
|
|
892
|
+
const flowDir = flowResult.flow_dir.data;
|
|
893
|
+
for (let i = 0; i < currentLatentDim; i++) {
|
|
894
|
+
latentData[i] += flowDir[i] * dt;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
chunkLatents.push(new Float32Array(latentData));
|
|
899
|
+
allGeneratedLatents.push(new Float32Array(latentData));
|
|
900
|
+
currentLatent = createTensor("float32", latentData, [1, 1, currentLatentDim]);
|
|
901
|
+
updateStateFromManifestOutputs(flowLmState, arResult, bundleMetadata.flow_lm_state_manifest);
|
|
902
|
+
|
|
903
|
+
const pending = chunkLatents.length - chunkDecodedFrames;
|
|
904
|
+
let decodeSize = 0;
|
|
905
|
+
if (shouldStop) {
|
|
906
|
+
decodeSize = pending;
|
|
907
|
+
} else if (isFirstAudioChunk && pending >= firstChunkFrames) {
|
|
908
|
+
decodeSize = firstChunkFrames;
|
|
909
|
+
} else if (pending >= normalChunkFrames) {
|
|
910
|
+
decodeSize = normalChunkFrames;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (decodeSize > 0) {
|
|
914
|
+
const decodeLatents = new Float32Array(decodeSize * currentLatentDim);
|
|
915
|
+
for (let frame = 0; frame < decodeSize; frame++) {
|
|
916
|
+
decodeLatents.set(chunkLatents[chunkDecodedFrames + frame], frame * currentLatentDim);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const decoderStart = performance.now();
|
|
920
|
+
const decodeResult = await mimiDecoderSession.run({
|
|
921
|
+
latent: createTensor("float32", decodeLatents, [1, decodeSize, currentLatentDim]),
|
|
922
|
+
...mimiState,
|
|
923
|
+
});
|
|
924
|
+
const decoderElapsed = performance.now() - decoderStart;
|
|
925
|
+
chunkGenTimeMs += decoderElapsed;
|
|
926
|
+
totalDecodeTime += decoderElapsed;
|
|
927
|
+
|
|
928
|
+
for (const entry of bundleMetadata.mimi_state_manifest) {
|
|
929
|
+
mimiState[entry.input_name] = decodeResult[entry.output_name];
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
chunkDecodedFrames += decodeSize;
|
|
933
|
+
const audioFloat32 = new Float32Array(decodeResult[mimiDecoderSession.outputNames[0]].data);
|
|
934
|
+
const isLastChunk = shouldStop && chunkIdx === chunks.length - 1;
|
|
935
|
+
if (firstAudioAt == null) firstAudioAt = performance.now();
|
|
936
|
+
|
|
937
|
+
// Post audio directly to the AudioWorklet via the MessagePort,
|
|
938
|
+
// bypassing the main thread entirely. Fall back to main-thread
|
|
939
|
+
// postMessage if the direct port is not set up yet.
|
|
940
|
+
if (audioPort) {
|
|
941
|
+
audioPort.postMessage({
|
|
942
|
+
type: "audio",
|
|
943
|
+
data: audioFloat32,
|
|
944
|
+
}, [audioFloat32.buffer]);
|
|
945
|
+
// Lightweight metadata only (no audio data) for telemetry.
|
|
946
|
+
postMessage({
|
|
947
|
+
type: "audio_chunk_meta",
|
|
948
|
+
metrics: {
|
|
949
|
+
bbTime: 0,
|
|
950
|
+
decTime: 0,
|
|
951
|
+
chunkDuration: audioFloat32.length / currentSampleRate,
|
|
952
|
+
genTimeSec: chunkGenTimeMs / 1000,
|
|
953
|
+
chunkRtfx: chunkGenTimeMs > 0 ? (audioFloat32.length / currentSampleRate) / (chunkGenTimeMs / 1000) : 0,
|
|
954
|
+
isFirst: isFirstAudioChunk,
|
|
955
|
+
isLast: isLastChunk,
|
|
956
|
+
chunkStart: isFirstAudioChunkOfTextChunk,
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
} else {
|
|
960
|
+
// Fallback: send audio through main thread (legacy path).
|
|
961
|
+
postMessage({
|
|
962
|
+
type: "audio_chunk",
|
|
963
|
+
data: audioFloat32,
|
|
964
|
+
metrics: {
|
|
965
|
+
bbTime: 0,
|
|
966
|
+
decTime: 0,
|
|
967
|
+
chunkDuration: audioFloat32.length / currentSampleRate,
|
|
968
|
+
genTimeSec: chunkGenTimeMs / 1000,
|
|
969
|
+
chunkRtfx: chunkGenTimeMs > 0 ? (audioFloat32.length / currentSampleRate) / (chunkGenTimeMs / 1000) : 0,
|
|
970
|
+
isFirst: isFirstAudioChunk,
|
|
971
|
+
isLast: isLastChunk,
|
|
972
|
+
chunkStart: isFirstAudioChunkOfTextChunk,
|
|
973
|
+
},
|
|
974
|
+
}, [audioFloat32.buffer]);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
isFirstAudioChunk = false;
|
|
978
|
+
isFirstAudioChunkOfTextChunk = false;
|
|
979
|
+
chunkGenTimeMs = 0;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (shouldStop) {
|
|
983
|
+
chunkEnded = true;
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (CHUNK_GAP_SEC > 0 && chunkEnded && isGenerating && chunkIdx < chunks.length - 1) {
|
|
989
|
+
const gapSamples = Math.floor(CHUNK_GAP_SEC * currentSampleRate);
|
|
990
|
+
if (gapSamples > 0) {
|
|
991
|
+
const silence = new Float32Array(gapSamples);
|
|
992
|
+
postMessage({
|
|
993
|
+
type: "audio_chunk",
|
|
994
|
+
data: silence,
|
|
995
|
+
metrics: {
|
|
996
|
+
bbTime: 0,
|
|
997
|
+
decTime: 0,
|
|
998
|
+
chunkDuration: gapSamples / currentSampleRate,
|
|
999
|
+
isFirst: false,
|
|
1000
|
+
isLast: false,
|
|
1001
|
+
isSilence: true,
|
|
1002
|
+
},
|
|
1003
|
+
}, [silence.buffer]);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const totalTime = (performance.now() - generationStart) / 1000;
|
|
1009
|
+
const audioSeconds = allGeneratedLatents.length * currentSamplesPerFrame / currentSampleRate;
|
|
1010
|
+
const genTime = (totalFlowLmTime + totalDecodeTime) / 1000;
|
|
1011
|
+
const rtfx = genTime > 0 ? audioSeconds / genTime : 0;
|
|
1012
|
+
postMessage({ type: "generation_metrics", metrics: { rtfx, genTime, totalTime, audioDuration: audioSeconds, firstAudioMs: firstAudioAt == null ? null : firstAudioAt - generationStart } });
|
|
1013
|
+
|
|
1014
|
+
debugLog(`Generation complete for ${voiceName} in ${totalTime.toFixed(2)}s (RTFx ${rtfx.toFixed(2)}x)`);
|
|
1015
|
+
postMessage({
|
|
1016
|
+
type: "status",
|
|
1017
|
+
status: `Finished (RTFx: ${rtfx.toFixed(2)}x)`,
|
|
1018
|
+
state: "idle",
|
|
1019
|
+
metrics: { rtfx, genTime, totalTime, audioDuration: audioSeconds },
|
|
1020
|
+
});
|
|
1021
|
+
}
|