@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.
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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);