405 lines
21 KiB
HTML
405 lines
21 KiB
HTML
<!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>
|