// Voice worker for the AAC reference app: one on-device neural voice // (Moonshine TTS running Kokoro-82M or Piper VITS), text in, PCM out. // // Nothing leaves the device. The worker never fetches: the voice files come // from the model store (Cache API "aac-models-v1", every file SHA-256 // checked when it was stored), and the runtime (moonshine.mjs and // moonshine.wasm) is handed over by src/bindings/neural_tts.js, which also // read it from the model store. So the voice keeps working offline. // // neural_tts.js starts this file from a Blob URL, and hands moonshine.mjs // over as a Blob URL too (Emscripten's `mainScriptUrlOrBlob`): workers made // from Blob URLs inherit the page's cross-origin isolation, which the // runtime's threads (SharedArrayBuffer) need, whatever headers or service // worker served the files. // // Messages in (each with an `id`, answered once, in order): // {type: "init", mjs, wasm} mjs: Blob URL; wasm: ArrayBuffer // {type: "load", key, lang, files: [{name, url}], options: [[k, v]...]} // {type: "say", text} -> {pcm: ArrayBuffer (Float32, mono), sampleRate} // {type: "split", lang, text} -> {parts: [string]} (the runtime's own sentences) // {type: "unload"} // Replies: {id, ok: true, ...} or {id, ok: false, error}. // // Vendored into vendor/moonshine-wasm--/ by // third_party/moonshine-wasm/vendor.sh. Its bytes are pinned in // crates/aac-web/src/neural_voice.rs (RUNTIME_FILES): change this file only // together with a new MOONSHINE_VENDOR_REV and its new size and hash there. const CACHE_NAME = "aac-models-v1"; let mod = null; // the Emscripten module let tts = null; // the current TextToSpeech let key = ""; // which voice (and speed) `tts` speaks with // ONNX Runtime warns about the CPU vendor browsers hide; it means nothing here. const QUIET = /Unknown CPU vendor/; function describe(err) { if (typeof err === "number" && mod && typeof mod.getExceptionMessage === "function") { try { const m = mod.getExceptionMessage(err); return Array.isArray(m) ? m.filter(Boolean).join(": ") : String(m); } catch (_) { /* fall through */ } } return String(err && err.message ? err.message : err); } function release() { if (!tts) return; try { tts.close?.(); } catch (_) { /* already closed */ } try { tts.delete?.(); } catch (_) { /* already deleted */ } tts = null; key = ""; } async function init(msg) { if (mod) return {}; const { default: createModule } = await import(msg.mjs); mod = await createModule({ wasmBinary: msg.wasm, // Threads start from the same Blob, so they share our isolation. mainScriptUrlOrBlob: msg.mjs, // import.meta.url is a blob: URL here, which cannot resolve relative // names; wasmBinary means the wasm is never looked up anyway. locateFile: (path) => path, print: () => {}, printErr: (text) => { if (!QUIET.test(String(text))) console.warn("[AAC voice]", text); }, }); if (typeof mod.TextToSpeech !== "function") { throw new Error("this runtime was built without text to speech"); } return {}; } async function load(msg) { if (!mod) throw new Error("runtime not started"); if (tts && key === msg.key) return {}; const cache = await caches.open(CACHE_NAME); const names = []; const buffers = []; for (const f of msg.files) { const res = await cache.match(f.url); if (!res) throw new Error(`voice file missing from this device: ${f.name}`); names.push(f.name); buffers.push(new Uint8Array(await res.arrayBuffer())); } // One voice at a time: free the old one (hundreds of MB) before the new. release(); const optionNames = msg.options.map(([k]) => String(k)); const optionValues = msg.options.map(([, v]) => String(v)); tts = new mod.TextToSpeech(msg.lang, names, buffers, optionNames, optionValues); key = msg.key; return {}; } // The runtime's own sentence splitter, the one say() uses: it keeps // decimals, thousands separators and abbreviations whole ("1.5 mg", // "1.000 Euro", "Dr. Smith"). Moonshine 0.1.5 answers with a JSON array. function split(msg) { if (!mod) throw new Error("runtime not started"); const text = String(msg.text); if (typeof mod.ttsSplitUtterances !== "function") return { parts: [text] }; const raw = mod.ttsSplitUtterances(String(msg.lang), text); const parts = typeof raw === "string" ? JSON.parse(raw) : Array.from(raw); return { parts: Array.isArray(parts) ? parts.map(String) : [text] }; } function say(msg) { if (!tts) throw new Error("no voice loaded"); const result = tts.say(String(msg.text)); // Copy out of the wasm heap into a buffer we can hand over. const pcm = new Float32Array(result.audio).buffer; return { pcm, sampleRate: result.sampleRate, transfer: [pcm] }; } // Messages are handled strictly one after another. let queue = Promise.resolve(); self.onmessage = (event) => { const msg = event.data || {}; queue = queue.then(async () => { try { let out; switch (msg.type) { case "init": out = await init(msg); break; case "load": out = await load(msg); break; case "say": out = say(msg); break; case "split": out = split(msg); break; case "unload": release(); out = {}; break; default: throw new Error(`unknown message: ${msg.type}`); } const transfer = out.transfer || []; delete out.transfer; self.postMessage({ id: msg.id, ok: true, ...out }, transfer); } catch (err) { self.postMessage({ id: msg.id, ok: false, error: describe(err) }); } }); };