@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,177 @@
|
|
|
1
|
+
// Breeze TTS 2 in the browser: Breeze-TTS-2.cpp (HoppouAI, Apache-2.0)
|
|
2
|
+
// compiled to WebAssembly with ggml's WebGPU backend (tools/breeze-web/), as
|
|
3
|
+
// a speech worker for pocket-tts-manager.js (the same messages as
|
|
4
|
+
// pocket18-worker.js):
|
|
5
|
+
// in: load, set_audio_port, encode_voice {voiceId, audio, text, cacheKey},
|
|
6
|
+
// generate {text, voice, instruction}, stop, delete_voice
|
|
7
|
+
// out: status, runtime_info, bundle_loaded, loaded, voice_encoded,
|
|
8
|
+
// generation_started, audio_chunk_meta, generation_metrics,
|
|
9
|
+
// stream_ended, error
|
|
10
|
+
//
|
|
11
|
+
// The model (BreezeBlue's Breeze TTS 2, research and non-commercial licence;
|
|
12
|
+
// HoppouAI's q4_k-dd2 GGUF, 2.46 GB) comes once from the R2 mirror
|
|
13
|
+
// (breeze-tts-2/: 64 MiB parts, four at a time over the four hostnames,
|
|
14
|
+
// each written at its offset) into this origin's private file system (OPFS),
|
|
15
|
+
// then is mounted read-only (WORKERFS): the loader reads it tensor by tensor
|
|
16
|
+
// onto the GPU, never all of it into WebAssembly memory.
|
|
17
|
+
// A library voice is encoded once from its sample and transcript; the codes
|
|
18
|
+
// are kept in IndexedDB.
|
|
19
|
+
const BASES = ['https://geebr-models.mindroot.io', 'https://geebr-models-2.mindroot.io', 'https://geebr-models-3.mindroot.io', 'https://geebr-models-4.mindroot.io'];
|
|
20
|
+
const MODEL = 'breeze-tts-2-q4_k-dd2.gguf', DIR = 'tablefolk-breeze';
|
|
21
|
+
// the same file on Hugging Face: pages other than tablefolk.world download from there
|
|
22
|
+
// (the load message's mirror: false), in 64 MiB ranges like the mirror's parts
|
|
23
|
+
const HF_URL = 'https://huggingface.co/HoppouAI/Breeze-TTS-2.cpp/resolve/main/' + MODEL;
|
|
24
|
+
let useMirror = true;
|
|
25
|
+
let M = null, port = null, stopPtr = 0, loading = null;
|
|
26
|
+
// views of the module's memory made fresh each time (they go stale when it grows)
|
|
27
|
+
const f32 = () => new Float32Array(M.wasmMemory.buffer), i32 = () => new Int32Array(M.wasmMemory.buffer);
|
|
28
|
+
const voices = new Map(); // voiceId -> { codes: Int32Array, frames, text }
|
|
29
|
+
const status = (text, state = 'loading') => postMessage({ type: 'status', status: text, state });
|
|
30
|
+
|
|
31
|
+
async function opfsModel(onProgress) {
|
|
32
|
+
const root = await navigator.storage.getDirectory(), dir = await root.getDirectoryHandle(DIR, { create: true });
|
|
33
|
+
const man = useMirror ? await (await fetch(`${BASES[0]}/breeze-tts-2/${MODEL}.parts.json`)).json() : await (async () => {
|
|
34
|
+
const r = await fetch(HF_URL, { headers: { Range: 'bytes=0-0' } }); await r.body?.cancel();
|
|
35
|
+
const size = Number(r.headers.get('Content-Range')?.split('/')[1]) || 0; if (!size) throw new Error('Breeze TTS 2: Hugging Face gave no size');
|
|
36
|
+
const partSize = 64 * 2 ** 20; return { size, partSize, parts: Array.from({ length: Math.ceil(size / partSize) }, (_, i) => i) };
|
|
37
|
+
})();
|
|
38
|
+
const fh = await dir.getFileHandle(MODEL, { create: true });
|
|
39
|
+
let file = await fh.getFile();
|
|
40
|
+
if (file.size === man.size && (await dir.getFileHandle(MODEL + '.ok').then(() => true, () => false))) return file;
|
|
41
|
+
try { await dir.removeEntry(MODEL + '.ok'); } catch {}
|
|
42
|
+
const h = await fh.createSyncAccessHandle();
|
|
43
|
+
try {
|
|
44
|
+
h.truncate(man.size);
|
|
45
|
+
let done = 0, next = 0;
|
|
46
|
+
const one = async (i) => {
|
|
47
|
+
for (let t = 0; ; t++) {
|
|
48
|
+
try {
|
|
49
|
+
const r = useMirror ? await fetch(`${BASES[i % 4]}/breeze-tts-2/${man.parts[i]}`)
|
|
50
|
+
: await fetch(HF_URL, { headers: { Range: `bytes=${i * man.partSize}-${Math.min((i + 1) * man.partSize, man.size) - 1}` } });
|
|
51
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
52
|
+
const buf = new Uint8Array(await r.arrayBuffer());
|
|
53
|
+
h.write(buf, { at: i * man.partSize }); done += buf.length; onProgress(done, man.size); return;
|
|
54
|
+
} catch (e) { if (t >= 3) throw new Error(`model part ${i}: ${e.message}`); await new Promise(r => setTimeout(r, 1500 * (t + 1))); }
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await Promise.all([0, 1, 2, 3].map(async () => { while (next < man.parts.length) await one(next++); }));
|
|
58
|
+
h.flush();
|
|
59
|
+
} finally { h.close(); }
|
|
60
|
+
await dir.getFileHandle(MODEL + '.ok', { create: true });
|
|
61
|
+
return fh.getFile();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// voice codes kept between visits
|
|
65
|
+
const db = () => new Promise((res, rej) => { const q = indexedDB.open('tablefolk-breeze-voices', 1); q.onupgradeneeded = () => q.result.createObjectStore('codes'); q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error); });
|
|
66
|
+
async function cacheGet(key) { try { const d = await db(); return await new Promise(r => { const q = d.transaction('codes').objectStore('codes').get(key); q.onsuccess = () => r(q.result || null); q.onerror = () => r(null); }); } catch { return null; } }
|
|
67
|
+
async function cachePut(key, v) { try { const d = await db(); d.transaction('codes', 'readwrite').objectStore('codes').put(v, key); } catch {} }
|
|
68
|
+
|
|
69
|
+
async function load(opts = {}) {
|
|
70
|
+
if (M) return;
|
|
71
|
+
if (opts.mirror === false) useMirror = false;
|
|
72
|
+
// what it needs, checked before the 2.5 GB download (browser-check.js has the same rules)
|
|
73
|
+
if (typeof WebAssembly.Suspending !== 'function') throw new Error('Breeze TTS 2 failed: this browser has no JSPI (WebAssembly.Suspending); it needs Chrome or Edge 137+, Safari 27+ or Firefox 153+');
|
|
74
|
+
if (!opts.allowCpu) {
|
|
75
|
+
const a = await navigator.gpu?.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null);
|
|
76
|
+
if (!a) throw new Error('Breeze TTS 2 failed: no WebGPU in this browser\'s workers');
|
|
77
|
+
if (!a.features.has('shader-f16')) throw new Error('Breeze TTS 2 failed: the graphics card offers no WebGPU shader-f16' + (/linux/i.test(navigator.userAgent) && a.info?.vendor === 'nvidia' ? ' (Chrome on Linux with NVIDIA: start it with --enable-dawn-features=vulkan_enable_f16_on_nvidia)' : ''));
|
|
78
|
+
}
|
|
79
|
+
status('Breeze TTS 2: checking the model…');
|
|
80
|
+
const t0 = performance.now();
|
|
81
|
+
const file = await opfsModel((n, total) => status(`Downloading Breeze TTS 2 ${Math.floor(100 * n / total)}% (${(n / 2 ** 30).toFixed(2)} / ${(total / 2 ** 30).toFixed(2)} GB, once)`));
|
|
82
|
+
status('Breeze TTS 2: loading onto the GPU…');
|
|
83
|
+
const create = (await import('./breeze-web/breeze.mjs')).default;
|
|
84
|
+
M = await create({ print: s => console.log('[breeze]', s), printErr: s => console.warn('[breeze]', s) });
|
|
85
|
+
M.FS.mkdir('/m'); M.FS.mount(M.WORKERFS || M.FS.filesystems.WORKERFS, { files: [file] }, '/m');
|
|
86
|
+
const rc = await M.ccall('tf_init', 'number', ['string'], ['/m/' + MODEL], { async: true });
|
|
87
|
+
if (rc !== 0) { const err = M.UTF8ToString(M._tf_error()); M = null; throw new Error('Breeze TTS 2 failed to load: ' + err); }
|
|
88
|
+
stopPtr = M._tf_stop_flag();
|
|
89
|
+
M.onAudio = pcm => onAudio(pcm);
|
|
90
|
+
const backend = M.UTF8ToString(M._tf_backend());
|
|
91
|
+
console.info('[breeze] devices:', M.UTF8ToString(M._tf_devices()), '| using', backend);
|
|
92
|
+
// ggml's WebGPU backend needs the shader-f16 feature: Chrome on Linux with
|
|
93
|
+
// NVIDIA's Vulkan driver hides it unless started with
|
|
94
|
+
// --enable-dawn-features=vulkan_enable_f16_on_nvidia. On the CPU a line takes minutes.
|
|
95
|
+
if (!/webgpu/i.test(backend) && !opts.allowCpu) { M = null; throw new Error('Breeze TTS 2 needs WebGPU shader-f16 in this browser (Chrome on Linux with NVIDIA: start it with --enable-dawn-features=vulkan_enable_f16_on_nvidia); without it, it runs only on the CPU (far too slow)'); }
|
|
96
|
+
postMessage({ type: 'runtime_info', data: { backend: 'Breeze TTS 2 in this browser (' + backend + ')' } });
|
|
97
|
+
postMessage({ type: 'bundle_loaded', sampleRate: M._tf_sample_rate() || 24000 });
|
|
98
|
+
postMessage({ type: 'loaded', loadMs: Math.round(performance.now() - t0) });
|
|
99
|
+
status('Breeze TTS 2 ready (' + backend + ')', 'ready');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function encode({ voiceId, audio, text, cacheKey }) {
|
|
103
|
+
if (!text) { voices.set(voiceId, null); return; } // no transcript: it can only design a voice
|
|
104
|
+
let v = cacheKey && await cacheGet(cacheKey);
|
|
105
|
+
if (!v) {
|
|
106
|
+
const a = audio instanceof Float32Array ? audio : Float32Array.from(audio || []);
|
|
107
|
+
const p = M._malloc(a.length * 4); f32().set(a, p >>> 2);
|
|
108
|
+
const frames = await M.ccall('tf_encode', 'number', ['number', 'number'], [p, a.length], { async: true });
|
|
109
|
+
M._free(p);
|
|
110
|
+
if (frames < 0) throw new Error('voice encode: ' + M.UTF8ToString(M._tf_error()));
|
|
111
|
+
const n = M._tf_codes_len(), ptr = M._tf_codes();
|
|
112
|
+
v = { codes: i32().slice(ptr >>> 2, (ptr >>> 2) + n), frames, text };
|
|
113
|
+
if (cacheKey) cachePut(cacheKey, v);
|
|
114
|
+
}
|
|
115
|
+
voices.set(voiceId, v);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let gen = null;
|
|
119
|
+
function onAudio(pcm) {
|
|
120
|
+
if (!gen) return;
|
|
121
|
+
if (gen.first == null) gen.first = performance.now() - gen.t0;
|
|
122
|
+
gen.samples += pcm.length;
|
|
123
|
+
// the warm-up: nothing is played, and a second of audio is enough
|
|
124
|
+
if (gen.silent) { if (gen.samples > 24000 && stopPtr) i32()[stopPtr >>> 2] = 1; return; }
|
|
125
|
+
const metrics = { chunkRtfx: gen.samples / 24000 / ((performance.now() - gen.t0) / 1000) };
|
|
126
|
+
if (port) { port.postMessage({ type: 'audio', data: pcm }, [pcm.buffer]); postMessage({ type: 'audio_chunk_meta', metrics }); }
|
|
127
|
+
else postMessage({ type: 'audio_chunk', data: pcm.buffer, metrics }, [pcm.buffer]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function generate({ text, voice, instruction, silent = false }) {
|
|
131
|
+
if (!String(text || '').trim()) { if (!silent) postMessage({ type: 'stream_ended' }); return; }
|
|
132
|
+
const v = voices.get(String(voice).replace(/^custom:/, '')) || null;
|
|
133
|
+
gen = { t0: performance.now(), samples: 0, first: null, silent };
|
|
134
|
+
if (!silent) { port?.postMessage({ type: 'reset' }); postMessage({ type: 'generation_started', data: { time: gen.t0, sourceSampleRate: 24000 } }); }
|
|
135
|
+
let cp = 0;
|
|
136
|
+
if (v) { cp = M._malloc(v.codes.length * 4); i32().set(v.codes, cp >>> 2); }
|
|
137
|
+
i32()[stopPtr >>> 2] = 0;
|
|
138
|
+
const rc = await M.ccall('tf_generate', 'number',
|
|
139
|
+
['string', 'string', 'string', 'number', 'number', 'number', 'number', 'number', 'number', 'number'],
|
|
140
|
+
[String(text), v ? '' : (instruction || 'A warm, natural speaking voice. Speak clearly and naturally.'), v ? v.text : '', cp, v ? v.codes.length : 0, v ? v.frames : 0, 7, 1, 4, 25],
|
|
141
|
+
{ async: true });
|
|
142
|
+
if (cp) M._free(cp);
|
|
143
|
+
const g = gen; gen = null;
|
|
144
|
+
if (rc === 1) throw new Error('Breeze: ' + M.UTF8ToString(M._tf_error()));
|
|
145
|
+
const secs = (performance.now() - g.t0) / 1000;
|
|
146
|
+
if (silent) { console.log(`[breeze] warm-up: first audio ${Math.round(g.first || 0)} ms, ${secs.toFixed(1)} s`); return; }
|
|
147
|
+
// rtfx: speed once audio has started (the wait before it is firstAudioMs). It sets how much is held before playing
|
|
148
|
+
// (holdFor), and counting the wait as slowness made every line wait seconds longer than it had to.
|
|
149
|
+
const after = Math.max(.001, secs - (g.first || 0) / 1000);
|
|
150
|
+
postMessage({ type: 'generation_metrics', metrics: { rtfx: g.samples / 24000 / after, overallRtfx: g.samples / 24000 / secs, genTime: secs, totalTime: secs, audioDuration: g.samples / 24000, firstAudioMs: g.first } });
|
|
151
|
+
if (rc === 0) { port?.postMessage({ type: 'stream-ended' }); postMessage({ type: 'stream_ended' }); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// one job at a time (the model has one cache); stop goes straight through
|
|
155
|
+
let chain = Promise.resolve();
|
|
156
|
+
self.onmessage = e => {
|
|
157
|
+
const { type, data = {} } = e.data || {};
|
|
158
|
+
if (type === 'set_audio_port') { port = e.ports?.[0] || null; return; }
|
|
159
|
+
if (type === 'stop') { if (M && stopPtr) i32()[stopPtr >>> 2] = 1; return; }
|
|
160
|
+
if (type === 'delete_voice') { voices.delete(data.voiceId); return; }
|
|
161
|
+
if (type === 'set_language') { postMessage({ type: 'voices_loaded', voices: [], language: 'english' }); return; }
|
|
162
|
+
chain = chain.then(async () => {
|
|
163
|
+
try {
|
|
164
|
+
if (type === 'load') return await (loading ||= load(data).catch(err => { loading = null; throw err; }));
|
|
165
|
+
if (type === 'encode_voice' || type === 'load_voice_state' || type === 'load_voice_bin') { if (type === 'encode_voice') await encode(data); postMessage({ type: 'voice_encoded', voiceId: data.voiceId }); return; }
|
|
166
|
+
if (type === 'generate') return await generate(data);
|
|
167
|
+
// a short line made and thrown away, so the first real one starts at full speed
|
|
168
|
+
if (type === 'warmup') { await generate({ ...data, silent: true }); postMessage({ type: 'warmed' }); return; }
|
|
169
|
+
if (type === 'debug_stages') { const p = await M.ccall('tf_stages', 'number', [], [], { async: true }); postMessage({ type: 'debug_stages', text: M.UTF8ToString(p) }); return; }
|
|
170
|
+
if (type === 'debug_matvec') { const p = await M.ccall('tf_matvec_check', 'number', ['number'], [data.repeats || 20], { async: true }); postMessage({ type: 'debug_matvec', text: M.UTF8ToString(p) }); return; }
|
|
171
|
+
// codes encoded natively ahead of time (assets/voices/breeze-codes): no encoder, and closer to native
|
|
172
|
+
if (type === 'voice_codes') { voices.set(data.voiceId, { codes: data.codes, frames: data.frames, text: data.text }); postMessage({ type: 'voice_encoded', voiceId: data.voiceId }); return; }
|
|
173
|
+
if (type === 'debug_trace') { const c = data.codes ? Int32Array.from(data.codes) : new Int32Array(1); const cp = M._malloc(c.length * 4); i32().set(c, cp >>> 2); const p = await M.ccall('tf_trace', 'number', ['number', 'number', 'number', 'string', 'number', 'number'], [cp, c.length, data.frames || 0, data.text || '', data.useRef ? 1 : 0, data.steps || 40], { async: true }); M._free(cp); postMessage({ type: 'debug_trace', text: M.UTF8ToString(p) }); return; }
|
|
174
|
+
if (type === 'debug_hash') { const p = await M.ccall('tf_hash_tensors', 'number', [], [], { async: true }); postMessage({ type: 'debug_hash', text: M.UTF8ToString(p) }); return; }
|
|
175
|
+
} catch (err) { console.error('[breeze]', err?.stack || err); postMessage({ type: 'error', error: String(err?.message || err) }); }
|
|
176
|
+
});
|
|
177
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @tablefolk/voice -- text to speech in the browser, nothing sent anywhere:
|
|
2
|
+
// pocket-18m Pocket-TTS small (Kyutai, CC-BY-4.0): ~40 MB, English, on the processor; phones
|
|
3
|
+
// pocket-100m Pocket-TTS (~120 MB, five languages), on the processor
|
|
4
|
+
// breeze-web Breeze TTS 2 (3B, WebGPU, a 2.5 GB download once): far better, needs a
|
|
5
|
+
// graphics card with ~3.5 GB free, shader-f16 and JSPI. Its weights are for
|
|
6
|
+
// research and non-commercial use only (BreezeBlue's licence).
|
|
7
|
+
// browser the browser's own speechSynthesis (no download)
|
|
8
|
+
//
|
|
9
|
+
// import { createVoice, chooseEngine } from '@tablefolk/voice';
|
|
10
|
+
// const v = await createVoice({ engine: 'auto', onStatus: s => console.log(s) });
|
|
11
|
+
// v.engine, v.why // 'pocket-18m', 'a phone: the small model'
|
|
12
|
+
// await v.speak('Well met, traveller.', { voice: 'alba' }) // resolves when it has been heard
|
|
13
|
+
// for await (const pcm of v.stream('Hello')) ... // Float32Array chunks at v.sampleRate (no playback)
|
|
14
|
+
// v.voices // the built-in voice names
|
|
15
|
+
// await v.cloneVoice('me', pcm24k, 'the words said in it') // Pocket: audio alone; Breeze: with its transcript
|
|
16
|
+
// v.stop(); v.unload(); v.level() // level: loudness now (for a talking mouth)
|
|
17
|
+
//
|
|
18
|
+
// 'auto': Breeze in the browser when the device has room for it beside what
|
|
19
|
+
// else it runs (pass freeGB, the graphics memory left) and allowBreeze is
|
|
20
|
+
// true (default; false for a commercial product); else Pocket-TTS small. The
|
|
21
|
+
// models download from Hugging Face, or Tablefolk's mirror on tablefolk.world.
|
|
22
|
+
import { createWorker, mirrorAllowed, note } from '@tablefolk/models';
|
|
23
|
+
import { detectHardware, usableGB } from '@tablefolk/device';
|
|
24
|
+
|
|
25
|
+
export const ENGINES = {
|
|
26
|
+
'pocket-18m': { label: 'Pocket-TTS small (18M, English)', worker: 'pocket18-worker.js', size: '~40 MB' },
|
|
27
|
+
'pocket-100m': { label: 'Pocket-TTS (100M, 5 languages)', worker: 'pocket-worker.js', size: '~120 MB', languages: true },
|
|
28
|
+
'breeze-web': { label: 'Breeze TTS 2 (3B, WebGPU; non-commercial licence)', worker: 'breeze-web-worker.js', size: '2.5 GB', needGB: 3.5, nonCommercial: true },
|
|
29
|
+
'browser': { label: 'the browser\'s speech', size: 'none' },
|
|
30
|
+
};
|
|
31
|
+
export const POCKET_VOICES = ['alba', 'azelma', 'cosette', 'eponine', 'fantine', 'javert', 'jean', 'marius'];
|
|
32
|
+
|
|
33
|
+
// The engine for this device, and why. freeGB: graphics memory left beside
|
|
34
|
+
// the page's other models (default: all of what @tablefolk/device finds usable).
|
|
35
|
+
export async function chooseEngine({ allowBreeze = true, freeGB = null, preferBreeze = true } = {}) {
|
|
36
|
+
const hw = await detectHardware().catch(() => null);
|
|
37
|
+
if (hw && (hw.cls === 'phone' || hw.cls === 'tablet')) return { engine: 'pocket-18m', why: 'a phone or tablet: the small model' };
|
|
38
|
+
if (!allowBreeze || !preferBreeze) return { engine: 'pocket-18m', why: allowBreeze ? 'the small model' : 'Breeze not allowed here (its licence is non-commercial)' };
|
|
39
|
+
const no = await breezeWhyNot(hw, freeGB);
|
|
40
|
+
return no ? { engine: 'pocket-18m', why: 'Breeze can\'t run here: ' + no } : { engine: 'breeze-web', why: 'the graphics card has room for Breeze' };
|
|
41
|
+
}
|
|
42
|
+
export async function breezeWhyNot(hw = null, freeGB = null) {
|
|
43
|
+
if (typeof WebAssembly.Suspending !== 'function') return 'no JSPI in this browser';
|
|
44
|
+
const a = await navigator.gpu?.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null);
|
|
45
|
+
if (!a) return 'no WebGPU';
|
|
46
|
+
if (!a.features.has('shader-f16')) return 'no shader-f16 on this graphics card';
|
|
47
|
+
hw ||= await detectHardware().catch(() => null);
|
|
48
|
+
const free = freeGB ?? usableGB(hw);
|
|
49
|
+
if (free == null) return 'the graphics memory is not known';
|
|
50
|
+
if (free < ENGINES['breeze-web'].needGB) return `${free} GB of graphics memory free; Breeze wants ${ENGINES['breeze-web'].needGB}`;
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function createVoice({ engine = 'auto', voice = null, language = null, onStatus = () => {}, audioContext = null, allowBreeze = true, freeGB = null } = {}) {
|
|
55
|
+
const pick = engine === 'auto' ? await chooseEngine({ allowBreeze, freeGB }) : { engine, why: 'asked for' };
|
|
56
|
+
const E = ENGINES[pick.engine]; if (!E) throw new Error('unknown voice engine ' + pick.engine);
|
|
57
|
+
note('voice', `${E.label}: ${pick.why}`);
|
|
58
|
+
if (pick.engine === 'browser') return browserVoice(pick);
|
|
59
|
+
|
|
60
|
+
const w = createWorker(new URL('./' + E.worker, import.meta.url));
|
|
61
|
+
let sampleRate = 24000, voices = [], defaultVoice = voice;
|
|
62
|
+
let sink = null; // the stream being read: { push(Float32Array), end(), fail(e) }
|
|
63
|
+
const waitLoad = new Promise((resolve, reject) => {
|
|
64
|
+
w.onmessage = e => {
|
|
65
|
+
const m = e.data || {};
|
|
66
|
+
if (m.type === 'status') onStatus(m.status);
|
|
67
|
+
else if (m.type === 'bundle_loaded') sampleRate = m.sampleRate || sampleRate;
|
|
68
|
+
else if (m.type === 'voices_loaded') { voices = m.voices || voices; defaultVoice ||= m.defaultVoice; }
|
|
69
|
+
else if (m.type === 'loaded') resolve();
|
|
70
|
+
else if (m.type === 'generation_started') sampleRate = m.data?.sourceSampleRate || sampleRate;
|
|
71
|
+
else if (m.type === 'audio_chunk') sink?.push(new Float32Array(m.data));
|
|
72
|
+
else if (m.type === 'stream_ended') sink?.end();
|
|
73
|
+
else if (m.type === 'voice_encoded') sink?.encoded?.(m.voiceName);
|
|
74
|
+
else if (m.type === 'error') { const err = new Error(m.error || 'voice error'); if (sink) sink.fail(err); else reject(err); }
|
|
75
|
+
};
|
|
76
|
+
w.onerror = e => reject(new Error(e.message || 'voice worker failed'));
|
|
77
|
+
});
|
|
78
|
+
w.postMessage({ type: 'load', data: { mirror: mirrorAllowed(), language: language || undefined, allowCpu: false } });
|
|
79
|
+
await waitLoad;
|
|
80
|
+
onStatus(`${E.label} ready`);
|
|
81
|
+
|
|
82
|
+
let queue = Promise.resolve();
|
|
83
|
+
// PCM pieces of one line (one line at a time: the worker generates one)
|
|
84
|
+
function stream(text, { voice: v = defaultVoice, instruction } = {}) {
|
|
85
|
+
const chunks = [], waiters = []; let done = false, error = null;
|
|
86
|
+
const wake = () => { while (waiters.length && (chunks.length || done || error)) waiters.shift()(); };
|
|
87
|
+
const run = () => new Promise(res => {
|
|
88
|
+
sink = { push: c => { chunks.push(c); wake(); }, end: () => { done = true; sink = null; wake(); res(); }, fail: e => { error = e; sink = null; wake(); res(); } };
|
|
89
|
+
w.postMessage({ type: 'generate', data: { text, voice: v, instruction } });
|
|
90
|
+
});
|
|
91
|
+
queue = queue.then(run, run);
|
|
92
|
+
return { [Symbol.asyncIterator]() { return this; },
|
|
93
|
+
async next() {
|
|
94
|
+
while (!chunks.length && !done && !error) await new Promise(r => waiters.push(r));
|
|
95
|
+
if (chunks.length) return { value: chunks.shift(), done: false };
|
|
96
|
+
if (error) throw error;
|
|
97
|
+
return { value: undefined, done: true };
|
|
98
|
+
} };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// playback: chunks scheduled back to back
|
|
102
|
+
let ctx = audioContext, gain = null, analyser = null, levelBuf = null, playing = new Set(), stopped = 0;
|
|
103
|
+
function audio() {
|
|
104
|
+
if (!ctx) ctx = new AudioContext();
|
|
105
|
+
if (!gain) { gain = ctx.createGain(); analyser = ctx.createAnalyser(); analyser.fftSize = 1024; levelBuf = new Float32Array(analyser.fftSize); gain.connect(analyser); analyser.connect(ctx.destination); }
|
|
106
|
+
return ctx;
|
|
107
|
+
}
|
|
108
|
+
async function speak(text, opts = {}) {
|
|
109
|
+
const c = audio(); if (c.state === 'suspended') await c.resume().catch(() => {});
|
|
110
|
+
const gen = stopped; let at = c.currentTime + 0.05, last = null;
|
|
111
|
+
for await (const pcm of stream(text, opts)) {
|
|
112
|
+
if (gen !== stopped) break;
|
|
113
|
+
const b = c.createBuffer(1, pcm.length, sampleRate); b.copyToChannel(pcm, 0);
|
|
114
|
+
const s = c.createBufferSource(); s.buffer = b; s.connect(gain);
|
|
115
|
+
at = Math.max(at, c.currentTime + 0.02); s.start(at); at += b.duration;
|
|
116
|
+
playing.add(s); s.onended = () => playing.delete(s); last = s;
|
|
117
|
+
}
|
|
118
|
+
if (last && gen === stopped) await new Promise(r => { last.addEventListener('ended', r, { once: true }); });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
engine: pick.engine, why: pick.why, label: E.label,
|
|
122
|
+
get voices() { return voices.map(v => typeof v === 'string' ? v : v.name || v.id).filter(Boolean); },
|
|
123
|
+
get sampleRate() { return sampleRate; },
|
|
124
|
+
speak, stream,
|
|
125
|
+
setVolume(v) { audio(); gain.gain.value = v; },
|
|
126
|
+
level() { if (!analyser) return 0; analyser.getFloatTimeDomainData(levelBuf); let s = 0; for (const v of levelBuf) s += v * v; return Math.sqrt(s / levelBuf.length); },
|
|
127
|
+
// a voice of your own: 24 kHz mono audio (a few seconds); Breeze also wants the words said in it
|
|
128
|
+
cloneVoice(id, audio24k, text = '') {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const prev = sink;
|
|
131
|
+
sink = { encoded: name => { sink = prev; resolve(name); }, fail: e => { sink = prev; reject(e); }, push() {}, end() {} };
|
|
132
|
+
const a = new Float32Array(audio24k);
|
|
133
|
+
w.postMessage({ type: 'encode_voice', data: { voiceId: id, audio: a, text, cacheKey: id } }, [a.buffer]);
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
stop() { stopped++; w.postMessage({ type: 'stop' }); for (const s of playing) { try { s.stop(); } catch {} } playing.clear(); },
|
|
137
|
+
unload() { this.stop(); w.terminate(); },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// the browser's speechSynthesis behind the same methods (no stream: it plays itself)
|
|
142
|
+
function browserVoice(pick) {
|
|
143
|
+
const S = globalThis.speechSynthesis;
|
|
144
|
+
if (!S) throw new Error('no speechSynthesis in this browser');
|
|
145
|
+
const list = () => S.getVoices();
|
|
146
|
+
return {
|
|
147
|
+
engine: 'browser', why: pick.why, label: ENGINES.browser.label, sampleRate: null,
|
|
148
|
+
get voices() { return list().map(v => v.name); },
|
|
149
|
+
speak(text, { voice } = {}) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const u = new SpeechSynthesisUtterance(text);
|
|
152
|
+
const v = voice && list().find(x => x.name === voice); if (v) u.voice = v;
|
|
153
|
+
u.onend = () => resolve(); u.onerror = e => e.error === 'interrupted' || e.error === 'canceled' ? resolve() : reject(new Error(e.error));
|
|
154
|
+
S.speak(u);
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
stream() { throw new Error('the browser\'s speech plays itself: use speak()'); },
|
|
158
|
+
level: () => 0, setVolume() {}, cloneVoice() { throw new Error('the browser\'s speech has no custom voices'); },
|
|
159
|
+
stop() { S.cancel(); }, unload() { S.cancel(); },
|
|
160
|
+
};
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tablefolk/voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Text to speech in the browser: Pocket-TTS (small, CPU) and Breeze TTS 2 (WebGPU), chosen for the device, or the browser's speechSynthesis.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./*": "./*"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/runvnc/tablefolk.git",
|
|
15
|
+
"directory": "libs/voice"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/runvnc/tablefolk/tree/main/libs/voice",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"tablefolk",
|
|
20
|
+
"browser",
|
|
21
|
+
"tts",
|
|
22
|
+
"speech",
|
|
23
|
+
"pocket-tts",
|
|
24
|
+
"breeze",
|
|
25
|
+
"webgpu"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@tablefolk/models": "^0.1.0",
|
|
30
|
+
"@tablefolk/device": "^0.1.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/pcm-worklet.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
class PocketPCMProcessor extends AudioWorkletProcessor {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
super();
|
|
4
|
+
const opts = options.processorOptions || {};
|
|
5
|
+
this.bufferSize = opts.bufferSize || sampleRate * 60;
|
|
6
|
+
this.minBufferSamples = opts.minBufferSamples || Math.floor(sampleRate * 0.35);
|
|
7
|
+
this.targetBufferSamples = opts.targetBufferSamples || this.minBufferSamples * 2;
|
|
8
|
+
// Separate, much shorter threshold for resuming after a mid-stream
|
|
9
|
+
// underrun. minBufferSamples is a cold-start cushion (nothing has played
|
|
10
|
+
// yet, so a bigger buffer is worth the wait); once already speaking, we
|
|
11
|
+
// just want playback to pick back up as soon as anything is there rather
|
|
12
|
+
// than re-waiting for the full cold-start amount again, which turned a
|
|
13
|
+
// brief gap into a much longer audible stutter.
|
|
14
|
+
this.resumeBufferSamples = opts.resumeBufferSamples || Math.floor(sampleRate * 0.02);
|
|
15
|
+
this.ring = new Float32Array(this.bufferSize);
|
|
16
|
+
this.readPos = 0;
|
|
17
|
+
this.writePos = 0;
|
|
18
|
+
this.buffered = 0;
|
|
19
|
+
this.started = false;
|
|
20
|
+
this.everStarted = false; // false only before the very first cold start of this stream
|
|
21
|
+
this.streamEnded = false;
|
|
22
|
+
this.underruns = 0;
|
|
23
|
+
this.samplesPlayed = 0;
|
|
24
|
+
this.reportCounter = 0;
|
|
25
|
+
this.drainedReported = false;
|
|
26
|
+
this.audioPort = null; // direct port from worker (bypasses main thread)
|
|
27
|
+
this.throttleCounter = 0;
|
|
28
|
+
// Keep-alive: instead of exact silence between lines, a faint noise
|
|
29
|
+
// (about -80 dB, far below hearing). Chrome closes its audio stream when
|
|
30
|
+
// the output is all zeros, PipeWire then suspends the speaker and the codec
|
|
31
|
+
// powers down, and waking them swallowed the first part of the next line
|
|
32
|
+
// (heard on a laptop's built-in speakers, 2026-11-07).
|
|
33
|
+
this.keepAlive = opts.keepAlive !== false;
|
|
34
|
+
this.seed = 22222;
|
|
35
|
+
|
|
36
|
+
this.port.onmessage = (e) => {
|
|
37
|
+
const m = e.data || {};
|
|
38
|
+
if (m.type === 'set_audio_port' && e.ports && e.ports.length) {
|
|
39
|
+
this.audioPort = e.ports[0];
|
|
40
|
+
this.audioPort.onmessage = (ev) => {
|
|
41
|
+
const d = ev.data || {};
|
|
42
|
+
if (d.type === 'audio') this.push(d.data);
|
|
43
|
+
else if (d.type === 'reset') this.reset();
|
|
44
|
+
else if (d.type === 'stream-ended') this.streamEnded = true;
|
|
45
|
+
else if (d.type === 'set-min-buffer') this.minBufferSamples = d.samples | 0;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (m.type === 'audio') this.push(m.data);
|
|
49
|
+
else if (m.type === 'reset') this.reset();
|
|
50
|
+
else if (m.type === 'stream-ended') this.streamEnded = true;
|
|
51
|
+
else if (m.type === 'set-min-buffer') this.minBufferSamples = m.samples | 0;
|
|
52
|
+
else if (m.type === 'keep-alive') this.keepAlive = !!m.on;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// the keep-alive floor: tiny white noise
|
|
57
|
+
floor() { if (!this.keepAlive) return 0; this.seed = (this.seed * 1103515245 + 12345) & 0x7fffffff; return (this.seed / 0x7fffffff - .5) * 2e-4; }
|
|
58
|
+
|
|
59
|
+
reset() {
|
|
60
|
+
this.readPos = 0;
|
|
61
|
+
this.writePos = 0;
|
|
62
|
+
this.buffered = 0;
|
|
63
|
+
this.started = false;
|
|
64
|
+
this.everStarted = false;
|
|
65
|
+
this.streamEnded = false;
|
|
66
|
+
this.underruns = 0;
|
|
67
|
+
this.samplesPlayed = 0;
|
|
68
|
+
this.drainedReported = false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
push(data) {
|
|
72
|
+
const src = data instanceof Float32Array ? data : new Float32Array(data);
|
|
73
|
+
for (let i = 0; i < src.length; i++) {
|
|
74
|
+
if (this.buffered >= this.bufferSize) {
|
|
75
|
+
this.readPos = (this.readPos + 1) % this.bufferSize;
|
|
76
|
+
this.buffered--;
|
|
77
|
+
}
|
|
78
|
+
this.ring[this.writePos] = src[i];
|
|
79
|
+
this.writePos = (this.writePos + 1) % this.bufferSize;
|
|
80
|
+
this.buffered++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process(inputs, outputs) {
|
|
85
|
+
const out = outputs[0][0];
|
|
86
|
+
if (!out) return true;
|
|
87
|
+
|
|
88
|
+
if (this.streamEnded && this.buffered === 0 && !this.drainedReported) {
|
|
89
|
+
this.drainedReported = true;
|
|
90
|
+
this.port.postMessage({ type: 'drained', samplesPlayed: this.samplesPlayed });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!this.started) {
|
|
94
|
+
const threshold = this.everStarted ? this.resumeBufferSamples : this.minBufferSamples;
|
|
95
|
+
if (this.buffered >= threshold || (this.streamEnded && this.buffered > 0)) {
|
|
96
|
+
this.port.postMessage({ type: 'playback-started', buffered: this.buffered, resumed: this.everStarted });
|
|
97
|
+
this.started = true;
|
|
98
|
+
this.everStarted = true;
|
|
99
|
+
} else {
|
|
100
|
+
for (let i = 0; i < out.length; i++) out[i] = this.floor();
|
|
101
|
+
this.report();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < out.length; i++) {
|
|
107
|
+
if (this.buffered > 0) {
|
|
108
|
+
out[i] = this.ring[this.readPos];
|
|
109
|
+
this.readPos = (this.readPos + 1) % this.bufferSize;
|
|
110
|
+
this.buffered--;
|
|
111
|
+
this.samplesPlayed++;
|
|
112
|
+
} else {
|
|
113
|
+
out[i] = this.floor();
|
|
114
|
+
if (!this.streamEnded) this.underruns++;
|
|
115
|
+
this.started = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.report();
|
|
120
|
+
|
|
121
|
+
// Send capacity back through the direct port if available, so the worker
|
|
122
|
+
// can throttle if needed without involving the main thread.
|
|
123
|
+
if (this.audioPort && this.buffered < this.targetBufferSamples) {
|
|
124
|
+
this.audioPort.postMessage({ type: 'capacity', buffered: this.buffered });
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
report() {
|
|
130
|
+
this.reportCounter++;
|
|
131
|
+
if (this.reportCounter % 64 === 0) {
|
|
132
|
+
this.port.postMessage({
|
|
133
|
+
type: 'stats',
|
|
134
|
+
buffered: this.buffered,
|
|
135
|
+
bufferedMs: Math.round(this.buffered / sampleRate * 1000),
|
|
136
|
+
underruns: this.underruns,
|
|
137
|
+
samplesPlayed: this.samplesPlayed,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registerProcessor('pocket-pcm-processor', PocketPCMProcessor);
|