Spaces:
Running
Running
File size: 4,290 Bytes
63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e 63158fe 89dc30e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | // core/voice-out.js — Q's beautiful voice: Kokoro-82M neural TTS, on-device and serverless. The text is
// synthesized ENTIRELY in the browser; only the model WEIGHTS stream from HuggingFace on first use, then
// cache offline. kokoro.js imports "@huggingface/transformers" + "phonemizer" as bare specifiers; the page's
// import map points them at the vendored copies (no CDN, no server).
import { env } from "@huggingface/transformers";
import { KokoroTTS } from "../vendor/kokoro/kokoro.js";
let _tts = null, _loading = null, _ctx = null, _cur = null, _queue = [], _draining = false;
export function ready() { return !!_tts; }
export function engine() { return _tts ? "kokoro-wasm" : null; } // the LIVE neural engine (null = not loaded → caller uses the OS voice)
// Load Kokoro once. Runtime is vendored; only the model streams from HF.
export async function loadVoice(onProgress) {
if (_tts) return _tts;
if (_loading) return _loading;
_loading = (async () => {
env.allowRemoteModels = true; // weights stream from HuggingFace…
env.allowLocalModels = false; // …not from disk
try {
const wasm = new URL("../vendor/kokoro/transformers/", import.meta.url).href; // vendored ORT wasm, no CDN
// RELIABILITY (the "robotic on Brave" fix): the vendored ORT wasm is the THREADED build, which needs
// SharedArrayBuffer / cross-origin isolation. Brave Shields (and some setups) disable that → the threaded
// path fails → Q silently drops to the robotic OS voice. So when isolation is ABSENT, run ORT single-thread
// on the main thread (no worker, no SAB) — slower to synthesize, but it's still the NEURAL Kokoro voice.
const isolated = (typeof self !== "undefined" && self.crossOriginIsolated) && (typeof SharedArrayBuffer !== "undefined");
if (env.backends && env.backends.onnx && env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = wasm;
env.backends.onnx.wasm.numThreads = isolated ? Math.min(4, (navigator.hardwareConcurrency || 2)) : 1;
env.backends.onnx.wasm.proxy = isolated; // worker only when isolated; no-SAB → main thread (max compatibility)
}
} catch {}
// WASM + q8: warm, natural, ~86 MB (cached after first use), and avoids the ORT-WebGPU TTS kernel issue.
_tts = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", { dtype: "q8", device: "wasm", progress_callback: onProgress });
return _tts;
})().catch((e) => { _loading = null; throw e; });
return _loading;
}
function ctx() { if (!_ctx) _ctx = new (window.AudioContext || window.webkitAudioContext)(); return _ctx; }
// GAPLESS QUEUE: synthesize each clause and play them back-to-back on ONE AudioContext — no gaps, no overlap.
// This is what makes clause-STREAMING smooth: index.html enqueues each sentence the moment it's generated, so
// Q starts talking almost immediately while the rest of the reply is still being written + synthesized.
async function _play(text) {
const tts = await loadVoice();
const out = await tts.generate(String(text), { voice: "af_heart" });
const c = ctx(); if (c.state === "suspended") { try { await c.resume(); } catch {} }
const buf = c.createBuffer(1, out.audio.length, out.sampling_rate || 24000); buf.getChannelData(0).set(out.audio);
const s = c.createBufferSource(); s.buffer = buf; s.connect(c.destination); _cur = s;
await new Promise((res) => { s.onended = () => { if (_cur === s) _cur = null; res(); }; s.start(); });
}
async function _drain() {
if (_draining) return; _draining = true;
try { while (_queue.length) { const t = _queue.shift(); try { await _play(t); } catch {} } } finally { _draining = false; }
}
// enqueue(text) — add one clause to the voice queue; it plays as soon as it's synthesized.
export function enqueue(text) { const t = String(text || "").trim(); if (!t) return; _queue.push(t); _drain(); }
export function speak(text) { enqueue(text); } // one-shot = a queue of one (greeting / demo)
export function speaking() { return _draining || _queue.length > 0 || !!_cur; }
// Barge-in / mute: clear the queue and cut playback immediately.
export function stop() { _queue.length = 0; try { if (_cur) { _cur.onended = null; _cur.stop(); _cur = null; } } catch {} }
|