File size: 13,727 Bytes
7248b12 2c4fa39 7248b12 2c4fa39 7248b12 76d53c7 7248b12 95a1864 7248b12 4b2c965 7248b12 8b954a7 7248b12 8b954a7 7248b12 d93e050 7248b12 8b954a7 7248b12 9b0349d 7248b12 adbfdcb 95a1864 c5622cf 95a1864 c5622cf 95a1864 c5622cf 95a1864 7248b12 8b954a7 2c4fa39 7248b12 2c4fa39 9b0349d 7248b12 4b2c965 7248b12 76d53c7 7248b12 8b954a7 7248b12 76d53c7 7248b12 2c4fa39 95a1864 7248b12 95a1864 7248b12 88503fd 7248b12 95a1864 7248b12 d93e050 88503fd d93e050 7248b12 95a1864 7248b12 88503fd 7248b12 88503fd 8b954a7 2c4fa39 7248b12 8b954a7 7248b12 | 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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | // @ts-check
/**
* LFM3-Omni on WebGPU, off the main thread so the avatar keeps its frame rate.
* One conversation lives here: the history, the prefix-cached session, and the
* context budget. Everything the main thread needs comes back as a stream of events.
*
* main -> worker:
* { kind: "load", model, system }
* { kind: "respond", id, audio } a user turn, 16 kHz Float32
* { kind: "respond", id, text } a user turn typed in the chat box
* { kind: "abort", id } barge-in: stop generating this turn
* { kind: "system", system } new persona / instructions
* { kind: "reset" } forget the conversation
*
* worker -> main:
* { kind: "progress", loaded?, total?, fraction?, status? }
* { kind: "ready", device, sampleRate } { kind: "failed", name, message }
* { kind: "text", id, text } { kind: "audio", id, samples }
* { kind: "finished", id, reason, usage, generated, error? }
*
* Voice: with Piper enabled (default, `?voice=` selects or disables with
* "model") replies generate as text only and each sentence is spoken by a
* native Spanish Piper voice, resampled into the model's audio rate.
*/
import {
Lfm3OmniForConditionalGeneration as Model,
ContextOverflowError,
DeviceLostError,
} from "../vendor/Lfm3OmniForConditionalGeneration.js";
const LOAD_ATTEMPTS = 4; // Chromium occasionally returns no adapter on a cold GPU process, and networks drop requests
const LOAD_RETRY_MS = 1500;
// Her replies stay in history as text only: her speech costs ~16 tokens/s and left
// no room for what was said, so a 44-turn chat now fits and she still recalls it.
// Past the budget half of it goes, well under the session's 8192 tokens.
const SEARCH = new URL(location.href).searchParams;
const HISTORY_BUDGET_TOKENS = Number(SEARCH.get("historyBudget")) || 6000;
const PRE_SPEECH_SAMPLES = 1200; // keep 50 ms before her first sound
/** @type {any} */ let model = null;
/** @type {any} */ let session = null;
let system = "";
/** @type {any[]} */ let history = [];
let idle = Promise.resolve();
/** @type {AbortController | null} */ let rereading = null;
/** @type {Map<number, AbortController>} */
const running = new Map();
const simulateRtf = Number(SEARCH.get("simulateRtf")) || 0;
const holdLoadMs = Number(SEARCH.get("holdLoadMs")) || 0; // testing: stretch out the load
const post = (msg, transfer = []) => self.postMessage(msg, { transfer });
// Piper TTS: native Spanish voice. `?voice=model` keeps the model's own voice; any
// other value is a Piper voice id (e.g. es_ES-sharvard-medium) + `?speaker=` (F=1).
let ttsSession = null;
let ttsWarm = Promise.resolve();
const ttsVoice = SEARCH.get("voice") ?? "es_ES-sharvard-medium";
const ttsSpeaker = Number(SEARCH.get("speaker")) || 1;
if (ttsVoice !== "model") {
post({ kind: "progress", status: "Preparando voz" });
ttsWarm = (async () => {
try {
const lib = await import("../vendor/piper/piper-tts-web.js");
ttsSession = await lib.TtsSession.create({ voiceId: ttsVoice, speakerId: ttsSpeaker, logger: (m) => console.log("tts:", m) });
post({ kind: "progress", status: "Voz lista" });
} catch (err) {
console.warn("voice load failed:", err);
}
})();
void ttsWarm;
}
/** Split off a sentence boundary at the front of `pendingText`. */
function takeSentence(buf) {
const m = buf.match(/[\s\S]*?[.!?…](\s|$)/);
return m ? m[0] : null;
}
function decodeWav(buf) {
const dv = new DataView(buf);
let off = 12, fmt = null, data = null;
while (off < buf.byteLength - 8) {
const id = String.fromCharCode(dv.getUint8(off), dv.getUint8(off + 1), dv.getUint8(off + 2), dv.getUint8(off + 3));
const size = dv.getUint32(off + 4, true);
if (id === "fmt ") fmt = { comp: dv.getUint16(off + 8, true), ch: dv.getUint16(off + 10, true), rate: dv.getUint32(off + 12, true), bits: dv.getUint16(off + 22, true) };
else if (id === "data") data = { off: off + 8, size };
off += 8 + size + (size % 2);
}
if (!fmt || !data) throw new Error("wav malformed");
let pcm;
if (fmt.bits === 16) {
const n = Math.floor(data.size / 2), out = new Float32Array(n);
for (let k = 0; k < n; k++) out[k] = dv.getInt16(data.off + k * 2, true) / 32768;
pcm = out;
} else if (fmt.bits === 32 && fmt.comp === 3) {
const n = Math.floor(data.size / 4), out = new Float32Array(n);
for (let k = 0; k < n; k++) out[k] = dv.getFloat32(data.off + k * 4, true);
pcm = out;
} else throw new Error(`unsupported wav bit depth ${fmt.bits}`);
return { pcm, rate: fmt.rate };
}
function trimSilence(x, rate, padSec) {
const th = 0.012;
let a = 0, b = x.length - 1;
while (a < x.length && Math.abs(x[a]) < th) a++;
while (b > a && Math.abs(x[b]) < th) b--;
const pad = Math.round(rate * padSec);
return x.slice(Math.max(0, a - pad), Math.min(x.length, b + 1 + pad));
}
function resample(x, rateIn, rateOut) {
if (!rateOut || rateIn === rateOut) return x;
const ratio = rateIn / rateOut, n = Math.max(1, Math.round(x.length / ratio)), out = new Float32Array(n);
for (let k = 0; k < n; k++) {
const p = k * ratio, j = Math.floor(p);
out[k] = x[j] + (x[Math.min(j + 1, x.length - 1)] - x[j]) * (p - j);
}
return out;
}
/** Synthesize one sentence with Piper and hand it to the main thread like model audio. */
async function speakAndPost(id, text) {
if (!text.trim()) return 0;
try {
if (!ttsSession) {
console.warn("no voice yet, staying silent for:", text);
return 0;
}
const { pcm, rate } = decodeWav(await (await ttsSession.predict(text)).arrayBuffer());
let samples = trimSilence(pcm, rate, 0.12);
samples = resample(samples, rate, model.audio_sampling_rate);
if (samples.length > 16) post({ kind: "audio", id, samples }, [samples.buffer]);
return samples.length;
} catch (err) {
console.warn("tts synthesis failed:", err);
return 0;
}
}
function messages() {
return [{ role: "system", content: system }, ...history];
}
/** Forget the oldest user turn and everything answering it. */
function dropOldestExchange() {
const next = history.findIndex((m, i) => i > 0 && m.role === "user");
if (next < 0) return false;
history.splice(0, next);
return true;
}
/** Past the budget half the history goes at once, so the next turn's one re-prefill buys many cheap turns. */
function compactHistory() {
const exchanges = history.filter((m) => m.role === "user").length;
for (let i = 0; i < Math.floor(exchanges / 2); i++) dropOldestExchange();
}
/** The reply as history keeps it: what she said, not the sound of it. */
function textOnly(message) {
if (!Array.isArray(message.content)) return message;
return { ...message, content: message.content.filter((c) => c.type === "text").map((c) => c.text).join("") };
}
/**
* Settle the cache while she's idle, re-reading a trimmed history and rewinding
* past her audio, so nobody waits for it on their next turn. Stops short of the last reply: the cache can only rewind to its end,
* and this prompt's end must still be a prefix of the next one.
*/
async function reread(signal) {
const stream = session.generate(messages().slice(0, -1), { generation_mode: "text", max_new_tokens: 1, signal });
for await (const _ of stream);
await stream.result;
}
async function load({ model: modelId, system: sys }) {
system = sys;
let lastErr;
for (let attempt = 1; attempt <= LOAD_ATTEMPTS; attempt++) {
try {
if (!navigator.gpu) {
throw Object.assign(new Error("This browser doesn't support WebGPU."), { name: "WebGPUUnavailable" });
}
post({ kind: "progress", status: "Checking your GPU" });
const check = await Model.checkAvailability(modelId);
if (!check.ok && !/No WebGPU adapter/i.test(check.reason ?? "")) {
throw Object.assign(new Error(check.reason), { name: "WebGPUUnavailable" });
}
model = await Model.from_pretrained(modelId, {
onProgress: (p) => {
if (p.kind === "bytes" && p.total > 0) post({ kind: "progress", loaded: p.loaded, total: p.total });
else if (p.status === "ready") post({ kind: "progress", fraction: 1 });
else post({ kind: "progress", status: p.message ?? p.status });
},
});
session = model.create_session();
post({ kind: "progress", status: "Getting ready" });
await session.prime(messages()).result;
if (holdLoadMs) await new Promise((r) => setTimeout(r, holdLoadMs));
post({ kind: "ready", device: model.deviceInfo(), sampleRate: model.audio_sampling_rate });
return;
} catch (err) {
lastErr = err;
console.warn(`model load attempt ${attempt} failed:`, err);
const adapterGlitch = /No WebGPU adapter/i.test(String(err?.message));
if (adapterGlitch && attempt === LOAD_ATTEMPTS) lastErr = Object.assign(new Error(err.message), { name: "WebGPUUnavailable" });
// A multi-gigabyte download over a real connection will sometimes drop a request. That's worth another go, after a growing pause; what's already cached isn't fetched again.
const networkGlitch = /failed to fetch|networkerror|network error|load failed|err_/i.test(String(err?.message));
if ((!adapterGlitch && !networkGlitch) || attempt === LOAD_ATTEMPTS) break;
await new Promise((r) => setTimeout(r, LOAD_RETRY_MS * 2 ** (attempt - 1)));
}
}
post({ kind: "failed", name: lastErr?.name ?? "Error", message: lastErr?.message ?? String(lastErr) });
}
/** One user turn: spoken (audio) or typed (text). */
async function respond({ id, audio, text }) {
if (!audio && !text) {
post({ kind: "finished", id, reason: "noop" });
return;
}
const controller = new AbortController();
running.set(id, controller);
const { signal } = controller;
rereading?.abort();
const prior = idle;
/** @type {() => void} */ let release = () => {};
idle = new Promise((r) => (release = r));
let reason = "stop";
let usage = null;
let generated = 0;
let error;
try {
await prior;
signal.throwIfAborted();
history.push(audio ? { role: "user", content: [{ type: "audio", audio }] } : { role: "user", content: text });
for (;;) {
const textMode = ttsVoice !== "model" && !audio;
const stream = session.generate(
messages(),
textMode ? { generation_mode: "text", signal } : { generation_mode: "interleaved", signal },
);
let emitted = false;
let pendingText = "";
const started = performance.now();
try {
for await (const ev of stream) {
if (ev.type === "text") {
emitted = true;
if (textMode) {
pendingText += ev.text;
for (let s; (s = takeSentence(pendingText)); ) {
pendingText = pendingText.slice(s.length);
const clean = s.trim();
if (clean) {
post({ kind: "text", id, text: clean });
generated += (await speakAndPost(id, clean)) ?? 0;
}
}
} else {
post({ kind: "text", id, text: ev.text });
}
} else if (ev.type === "audio") {
emitted = true;
let samples = ev.audio.slice();
if (generated === 0) {
// Her replies open with ~0.8 s of silence. Don't make anyone wait for it.
const start = samples.findIndex((v) => Math.abs(v) > 0.01);
if (start < 0) continue;
samples = samples.slice(Math.max(0, start - PRE_SPEECH_SAMPLES));
}
generated += samples.length;
if (simulateRtf) {
const due = started + (generated / model.audio_sampling_rate / simulateRtf) * 1000;
await new Promise((r) => setTimeout(r, Math.max(0, due - performance.now())));
}
post({ kind: "audio", id, samples }, [samples.buffer]);
}
}
if (textMode && pendingText.trim()) {
const clean = pendingText.trim();
post({ kind: "text", id, text: clean });
generated += (await speakAndPost(id, clean)) ?? 0;
pendingText = "";
}
} catch (err) {
if (!emitted && err instanceof ContextOverflowError && dropOldestExchange()) continue;
throw err;
}
const result = await stream.result;
usage = result.usage;
reason = result.finish_reason;
history.push(textOnly(result.message));
break;
}
} catch (err) {
if (err?.name === "AbortError" || signal.aborted) {
reason = "cancelled";
} else {
console.error(err);
reason = "error";
error = { name: err?.name, message: err?.message ?? String(err), fatal: err instanceof DeviceLostError };
}
} finally {
running.delete(id);
post({ kind: "finished", id, reason, usage, generated, error });
if (usage && !signal.aborted) {
if (session.count_tokens(messages()).prompt_tokens > HISTORY_BUDGET_TOKENS) compactHistory();
rereading = new AbortController();
await reread(rereading.signal).catch((err) => {
if (err?.name !== "AbortError" && !rereading?.signal.aborted) console.warn("re-read failed:", err);
});
rereading = null;
}
release();
}
}
self.onmessage = ({ data }) => {
switch (data.kind) {
case "load":
void load(data);
break;
case "respond":
void respond(data);
break;
case "abort":
running.get(data.id)?.abort();
break;
case "system":
system = data.system;
break;
case "reset":
idle = idle.then(() => {
history = [];
});
break;
}
};
|