Files
2026-10-29 10:03:30 +01:00

405 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LFM Voice Demo</title>
<style>
:root {
--bg: #f6f7f9; --panel: #fff; --fg: #1b1f24; --muted: #6b7280; --line: #e3e6ea;
--accent: #2f6fed; --rec: #e5484d; --ok: #2e9e5b; --warn: #c27c0e; --mono-bg: #f1f3f5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115; --panel: #171a20; --fg: #e6e8eb; --muted: #8b93a1; --line: #2a2f38;
--accent: #5b8cff; --rec: #ff6369; --ok: #4cc38a; --warn: #f1a10d; --mono-bg: #11141a;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, sans-serif; }
header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; }
header h1 { font-size: 16px; margin: 0; }
.pill { font-size: 12px; padding: 2px 8px; border-radius: 99px; background: var(--mono-bg); color: var(--muted); border: 1px solid var(--line); }
.pill.ok { color: var(--ok); } .pill.bad { color: var(--rec); } .pill.busy { color: var(--warn); }
main { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 16px; padding: 16px; max-width: 1500px; margin: 0 auto; }
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; min-width: 0; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0 0 10px; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
label { font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 2px; }
input, select, textarea, button { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }
input[type=number] { width: 80px; }
button { cursor: pointer; background: var(--panel); }
button:hover { border-color: var(--accent); }
button:disabled { opacity: .5; cursor: default; }
#talk { width: 100%; padding: 18px; font-size: 16px; font-weight: 600; background: var(--accent); color: #fff; border: none; user-select: none; touch-action: none; }
#talk.rec { background: var(--rec); }
#meter { height: 6px; background: var(--mono-bg); border-radius: 3px; overflow: hidden; margin: 8px 0; }
#meter div { height: 100%; width: 0; background: var(--ok); transition: width 60ms linear; }
#chat { display: flex; flex-direction: column; gap: 8px; max-height: 45vh; overflow-y: auto; padding-right: 4px; }
.msg { padding: 8px 10px; border-radius: 8px; max-width: 90%; white-space: pre-wrap; word-wrap: break-word; }
.msg.user { align-self: flex-end; background: var(--accent); color: #fff; }
.msg.bot { align-self: flex-start; background: var(--mono-bg); border: 1px solid var(--line); }
.msg .meta { font-size: 11px; opacity: .75; margin-top: 4px; }
.msg audio { display: block; margin-top: 6px; height: 32px; max-width: 100%; }
.mono, pre { font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { background: var(--mono-bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; margin: 0; overflow: auto; white-space: pre-wrap; word-break: break-all; }
#stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 6px; margin-bottom: 12px; }
.stat { background: var(--mono-bg); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }
.stat b { display: block; font: 600 15px ui-monospace, Menlo, monospace; }
.stat span { font-size: 11px; color: var(--muted); }
#tokens { max-height: 120px; }
#tokens .sp { color: var(--warn); }
#timeline { width: 100%; height: 60px; display: block; background: var(--mono-bg); border: 1px solid var(--line); border-radius: 6px; margin-bottom: 12px; }
#log { max-height: 220px; }
details { margin-top: 10px; } summary { cursor: pointer; color: var(--muted); font-size: 12px; margin-bottom: 6px; }
.hint { font-size: 12px; color: var(--muted); }
#toolHint { margin-bottom: 8px; }
.msg.tool { align-self: flex-start; background: transparent; border: 1px dashed var(--warn); font: 12px/1.4 ui-monospace, Menlo, monospace; }
.msg.tool .res { color: var(--muted); margin-top: 4px; }
#toolsLog .call { color: var(--warn); }
</style>
</head>
<body>
<header>
<h1>LFM3-Audio-2B voice demo</h1>
<span id="conn" class="pill bad">disconnected</span>
<span id="state" class="pill">idle</span>
<span id="info" class="pill mono">–</span>
</header>
<main>
<section>
<h2>Conversation</h2>
<div class="row">
<label>Mode
<select id="mode">
<option value="chat">Voice chat (interleaved)</option>
<option value="tools">Voice + tools</option>
<option value="asr">ASR (transcribe)</option>
</select>
</label>
<label>Max tokens <input id="max_new_tokens" type="number" value="1024" min="16" max="4096"></label>
<label>Audio temp <input id="audio_temperature" type="number" value="1.0" step="0.1" min="0" max="2"></label>
<label>Audio top‑k <input id="audio_top_k" type="number" value="128" min="1" max="2048"></label>
<label>History <select id="keep_history"><option value="1">keep</option><option value="0">off</option></select></label>
<label title="Tools mode: make the final answer spoken even when the model would reply in plain text">Force speech <select id="force_speech"><option value="1">on</option><option value="0">off</option></select></label>
</div>
<div class="row">
<label style="flex:1">System prompt override (blank = mode default)
<input id="system" placeholder="Respond with interleaved text and audio.">
</label>
</div>
<button id="talk">Hold to talk (or hold Space)</button>
<div id="meter"><div></div></div>
<div class="row">
<button id="cancel">Cancel</button>
<button id="reset">Reset history</button>
<button id="clear">Clear view</button>
<span class="hint">Audio is streamed while you hold; generation starts on release.</span>
</div>
<div class="row">
<input id="ttsText" style="flex:1" placeholder="Type a message (Enter sends) or text for TTS…">
<button id="sendText">Send</button>
<select id="voice"><option>female</option><option>male</option></select>
<button id="tts">Speak (TTS)</button>
</div>
<div id="toolHint" class="hint" hidden>Tools: <span class="mono">get_current_time</span>, <span class="mono">get_weather</span> (simulated),
<span class="mono">calculate</span>, <span class="mono">set_theme_color</span>. Try “What's the weather in Paris?”,
“What's 17 times 23?” or “Make the screen green.”</div>
<div id="chat"></div>
</section>
<section>
<h2>Debug — last turn</h2>
<div id="stats"></div>
<canvas id="timeline" width="800" height="60"></canvas>
<h2>Token stream <span class="hint">(special tokens highlighted)</span></h2>
<pre id="tokens"></pre>
<h2 style="margin-top:12px">Tool calls</h2>
<pre id="toolsLog">–</pre>
<details><summary>Prompt sent to model (last round)</summary><pre id="prompt"></pre></details>
<details><summary>Server info</summary><pre id="serverInfo"></pre></details>
<h2 style="margin-top:12px">Event log</h2>
<pre id="log"></pre>
</section>
</main>
<script>
const $ = (id) => document.getElementById(id);
const OUT_RATE = 24000;
let ws, audioCtx, micStream, micNode, micSource, recording = false;
let playHead = 0, turn = null;
// ---------- logging ----------
const t0 = performance.now();
function log(msg, cls) {
const line = `[${((performance.now() - t0) / 1000).toFixed(2).padStart(7)}] ${msg}\n`;
const el = $("log");
el.textContent += line;
if (el.textContent.length > 40000) el.textContent = el.textContent.slice(-30000);
el.scrollTop = el.scrollHeight;
}
function setState(s, cls = "") { $("state").textContent = s; $("state").className = "pill " + cls; }
// ---------- websocket ----------
function connect() {
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
ws.binaryType = "arraybuffer";
ws.onopen = () => { $("conn").textContent = "connected"; $("conn").className = "pill ok"; sendSettings(); };
ws.onclose = () => { $("conn").textContent = "disconnected"; $("conn").className = "pill bad"; log("socket closed, retrying in 2s"); setTimeout(connect, 2000); };
ws.onmessage = (ev) => typeof ev.data === "string" ? onJson(JSON.parse(ev.data)) : onPcm(ev.data);
}
const send = (o) => ws && ws.readyState === 1 && ws.send(typeof o === "string" || o instanceof ArrayBuffer ? o : JSON.stringify(o));
function sendSettings() {
send({ type: "settings", mode: $("mode").value, max_new_tokens: +$("max_new_tokens").value,
audio_temperature: +$("audio_temperature").value, audio_top_k: +$("audio_top_k").value,
system: $("system").value, voice: $("voice").value, keep_history: $("keep_history").value === "1",
force_speech: $("force_speech").value === "1" });
}
$("mode").addEventListener("change", () => { $("toolHint").hidden = $("mode").value !== "tools"; });
for (const id of ["mode", "max_new_tokens", "audio_temperature", "audio_top_k", "system", "voice", "keep_history", "force_speech"])
$(id).addEventListener("change", sendSettings);
// ---------- turn state ----------
function newTurn(kind) {
turn = { kind, start: performance.now(), events: [], pcm: [], bot: addMsg("bot", ""), firstAudioAt: null, played: 0 };
$("tokens").innerHTML = ""; $("prompt").textContent = ""; $("toolsLog").textContent = "–";
renderStats({});
setState("generating…", "busy");
}
function mark(name) { if (turn) turn.events.push({ name, t: performance.now() - turn.start }); drawTimeline(); }
function onJson(m) {
switch (m.type) {
case "ready":
$("info").textContent = `${m.device} · ${m.dtype}`;
$("serverInfo").textContent = JSON.stringify(m, null, 2);
log(`model ready: ${m.model} on ${m.device}/${m.dtype}, loaded in ${m.load_s}s`);
break;
case "log": log("server: " + m.msg); break;
case "user_audio":
log(`user audio ${m.user_audio_s}s rms=${m.user_rms} peak=${m.user_peak}`);
if (m.user_audio_s >= 0.3) newTurn("voice");
break;
case "prompt":
$("prompt").textContent = m.text;
log(`prompt (round ${m.round}): ${m.tokens} tokens @ ${m.t_ms} ms`);
if (m.round > 0) { const sep = document.createElement("span"); sep.className = "sp"; sep.textContent = `\n── round ${m.round} ──\n`; $("tokens").appendChild(sep); }
mark(m.round ? `round ${m.round}` : "prompt");
break;
case "tool_call": {
log(`tool call: ${m.name}(${JSON.stringify(m.arguments)}) @ ${m.t_ms} ms`);
mark("tool call");
const card = addMsg("tool", `🔧 ${m.name}(${JSON.stringify(m.arguments)})`);
if (turn) { turn.toolCards = turn.toolCards || []; turn.toolCards.push(card); $("chat").appendChild(turn.bot); }
const tl = $("toolsLog"); if (tl.textContent === "–") tl.textContent = "";
tl.textContent += `round ${m.round} @ ${m.t_ms} ms → ${m.name}(${JSON.stringify(m.arguments)})\n`;
break;
}
case "tool_result": {
log(`tool result: ${m.name} → ${JSON.stringify(m.result)} (${m.ms} ms)`);
const card = turn && turn.toolCards && turn.toolCards.shift();
if (card) { const r = document.createElement("div"); r.className = "res"; r.textContent = "↳ " + JSON.stringify(m.result); card.appendChild(r); }
$("toolsLog").textContent += ` ↳ ${JSON.stringify(m.result)} (${m.ms} ms)\n`;
break;
}
case "ui":
if (m.action === "accent") { document.documentElement.style.setProperty("--accent", m.value); log(`UI: accent → ${m.value}`); }
break;
case "token": {
if (!turn) newTurn("?");
if (!turn.firstTok) { turn.firstTok = true; mark("1st text"); log(`first text token @ ${m.t_ms} ms (server)`); }
const span = document.createElement("span");
span.textContent = m.raw; span.title = `id ${m.id} @ ${m.t_ms} ms`;
if (m.special) span.className = "sp";
$("tokens").appendChild(span); $("tokens").scrollTop = $("tokens").scrollHeight;
if (m.delta) { turn.bot.querySelector(".body").textContent += m.delta; $("chat").scrollTop = $("chat").scrollHeight; }
break;
}
case "audio_chunk":
if (!turn) newTurn("?");
if (turn.chunks === undefined) { turn.chunks = 0; mark("1st audio"); log(`first audio chunk @ ${m.t_ms} ms (server), codes[0]=${JSON.stringify(m.codes)}`); }
turn.chunks++;
turn.lastChunk = m;
break;
case "done": {
const s = m.stats;
mark("done");
renderStats(s);
log(`done: ${JSON.stringify(s)}`);
setState(s.cancelled ? "cancelled" : "idle", s.cancelled ? "bad" : "");
if (turn) {
const meta = document.createElement("div"); meta.className = "meta";
meta.textContent = `${s.mode} · ${s.gen_ms} ms · ${s.audio_out_s}s audio${s.rtf ? " · RTF " + s.rtf : ""}`;
turn.bot.appendChild(meta);
if (turn.pcm.length) attachAudio(turn.bot, turn.pcm, OUT_RATE);
if (!turn.bot.querySelector(".body").textContent && s.mode === "tts") turn.bot.querySelector(".body").textContent = "(speech)";
}
turn = null;
break;
}
case "error": log("ERROR " + m.msg); setState("error", "bad"); if (turn) turn.bot.querySelector(".body").textContent += `\n⚠ ${m.msg}`; turn = null; break;
}
}
// ---------- playback ----------
function ensureCtx() {
if (!audioCtx) audioCtx = new AudioContext();
if (audioCtx.state === "suspended") audioCtx.resume();
return audioCtx;
}
function onPcm(buf) {
const pcm = new Float32Array(buf);
if (!turn) return;
turn.pcm.push(pcm);
const ctx = ensureCtx();
const b = ctx.createBuffer(1, pcm.length, OUT_RATE);
b.copyToChannel(pcm, 0);
const src = ctx.createBufferSource();
src.buffer = b; src.connect(ctx.destination);
const now = ctx.currentTime;
if (playHead < now + 0.02) {
if (turn.played > 0) { turn.underruns = (turn.underruns || 0) + 1; log(`playback underrun #${turn.underruns} (gap ${((now - playHead) * 1000).toFixed(0)} ms)`); }
playHead = now + 0.05; // small jitter buffer
}
if (!turn.played) { mark("play"); log(`playback starts, client latency ${(performance.now() - turn.start).toFixed(0)} ms since release`); }
src.start(playHead);
playHead += b.duration;
turn.played += pcm.length;
}
function attachAudio(el, chunks, rate) {
const n = chunks.reduce((a, c) => a + c.length, 0);
const all = new Float32Array(n); let o = 0;
for (const c of chunks) { all.set(c, o); o += c.length; }
const a = document.createElement("audio"); a.controls = true; a.src = URL.createObjectURL(wav(all, rate));
el.appendChild(a);
}
function wav(f32, rate) {
const buf = new ArrayBuffer(44 + f32.length * 2), v = new DataView(buf);
const w = (o, s) => [...s].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
w(0, "RIFF"); v.setUint32(4, 36 + f32.length * 2, true); w(8, "WAVEfmt "); v.setUint32(16, 16, true);
v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true); w(36, "data"); v.setUint32(40, f32.length * 2, true);
for (let i = 0; i < f32.length; i++) v.setInt16(44 + i * 2, Math.max(-1, Math.min(1, f32[i])) * 0x7fff, true);
return new Blob([buf], { type: "audio/wav" });
}
// ---------- mic capture ----------
const WORKLET = `
class Cap extends AudioWorkletProcessor {
constructor() { super(); this.buf = []; this.n = 0; }
process(inputs) {
const ch = inputs[0][0];
if (ch) { this.buf.push(new Float32Array(ch)); this.n += ch.length;
if (this.n >= 2048) { const out = new Float32Array(this.n); let o = 0;
for (const b of this.buf) { out.set(b, o); o += b.length; }
this.port.postMessage(out, [out.buffer]); this.buf = []; this.n = 0; } }
return true;
}
}
registerProcessor("cap", Cap);`;
async function initMic() {
const ctx = ensureCtx();
if (micNode) return;
micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 } });
await ctx.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: "application/javascript" })));
micSource = ctx.createMediaStreamSource(micStream);
micNode = new AudioWorkletNode(ctx, "cap");
micNode.port.onmessage = (e) => {
const d = e.data;
let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i];
$("meter").firstChild.style.width = Math.min(100, Math.sqrt(s / d.length) * 400) + "%";
if (recording) { send(d.buffer); recBuf.push(d); }
};
micSource.connect(micNode);
log(`mic ready @ ${ctx.sampleRate} Hz`);
}
let recBuf = [], recStart = 0;
async function startRec() {
if (recording) return;
try { await initMic(); } catch (e) { log("mic error: " + e); return; }
recording = true; recBuf = []; recStart = performance.now();
send({ type: "start", rate: audioCtx.sampleRate });
$("talk").classList.add("rec"); $("talk").textContent = "Listening… release to send";
setState("recording", "bad");
}
function stopRec() {
if (!recording) return;
recording = false;
send({ type: "stop" });
$("talk").classList.remove("rec"); $("talk").textContent = "Hold to talk (or hold Space)";
$("meter").firstChild.style.width = "0";
const dur = (performance.now() - recStart) / 1000;
if (dur >= 0.3) {
const m = addMsg("user", $("mode").value === "asr" ? "🎙 (transcribe)" : "🎙");
attachAudio(m, recBuf, audioCtx.sampleRate);
}
setState("waiting…", "busy");
}
const talk = $("talk");
talk.addEventListener("pointerdown", (e) => { e.preventDefault(); talk.setPointerCapture(e.pointerId); startRec(); });
talk.addEventListener("pointerup", stopRec);
talk.addEventListener("pointercancel", stopRec);
window.addEventListener("keydown", (e) => { if (e.code === "Space" && !e.repeat && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) { e.preventDefault(); startRec(); } });
window.addEventListener("keyup", (e) => { if (e.code === "Space" && recording) { e.preventDefault(); stopRec(); } });
// ---------- other controls ----------
$("cancel").onclick = () => send({ type: "cancel" });
$("reset").onclick = () => { send({ type: "reset" }); addMsg("bot", "— history cleared —"); };
$("clear").onclick = () => { $("chat").innerHTML = ""; $("log").textContent = ""; };
$("tts").onclick = () => {
const text = $("ttsText").value.trim(); if (!text) return;
ensureCtx(); addMsg("user", "🔊 " + text); newTurn("tts");
send({ type: "tts", text });
};
$("sendText").onclick = () => {
const text = $("ttsText").value.trim(); if (!text) return;
ensureCtx(); addMsg("user", text); newTurn("text"); $("ttsText").value = "";
send({ type: "text", text });
};
$("ttsText").addEventListener("keydown", (e) => { if (e.key === "Enter") $("sendText").click(); });
function addMsg(who, text) {
const d = document.createElement("div"); d.className = "msg " + who;
const b = document.createElement("div"); b.className = "body"; b.textContent = text; d.appendChild(b);
$("chat").appendChild(d); $("chat").scrollTop = $("chat").scrollHeight;
return d;
}
// ---------- debug rendering ----------
const STAT_LABELS = [
["user_audio_s", "input audio (s)"], ["prompt_tokens", "prompt tokens"], ["prep_ms", "prep (ms)"],
["first_text_ms", "1st text (ms)"], ["first_frame_ms", "1st frame (ms)"], ["first_audio_ms", "1st audio chunk (ms)"],
["gen_ms", "generate (ms)"], ["rounds", "rounds (tools)"], ["speech_restarts", "speech restarts"], ["text_tokens", "text tokens"], ["audio_frames", "audio frames"],
["audio_out_s", "audio out (s)"], ["rtf", "RTF (gen/audio)"], ["text_tok_per_s", "text tok/s"],
["frames_per_s", "frames/s (12.5=RT)"], ["codec_decode_ms", "codec decode (ms)"], ["max_decode_backlog", "decode backlog (frames)"],
];
function renderStats(s) {
$("stats").innerHTML = STAT_LABELS.map(([k, l]) => `<div class="stat"><b>${s[k] ?? "–"}</b><span>${l}</span></div>`).join("")
+ (turn && turn.underruns ? `<div class="stat"><b>${turn.underruns}</b><span>playback underruns</span></div>` : "");
}
function drawTimeline() {
const c = $("timeline"), g = c.getContext("2d");
c.width = c.clientWidth * devicePixelRatio; c.height = 60 * devicePixelRatio;
g.scale(devicePixelRatio, devicePixelRatio);
const W = c.clientWidth, css = getComputedStyle(document.documentElement);
g.clearRect(0, 0, W, 60);
if (!turn || !turn.events.length) return;
const max = Math.max(1000, ...turn.events.map((e) => e.t)) * 1.1;
g.fillStyle = css.getPropertyValue("--muted"); g.font = "11px system-ui";
g.fillText(`client timeline (ms since release) — 0…${max.toFixed(0)}`, 6, 13);
turn.events.forEach((e, i) => {
const x = 6 + (e.t / max) * (W - 12);
g.fillStyle = css.getPropertyValue("--accent"); g.fillRect(x, 20, 2, 34);
g.fillStyle = css.getPropertyValue("--fg");
g.fillText(`${e.name} ${e.t.toFixed(0)}`, Math.min(x + 4, W - 90), 32 + (i % 2) * 14);
});
}
renderStats({});
connect();
</script>
</body>
</html>