first tests
This commit is contained in:
1 parent
50ef195c35
commit
203df3b7d6
12 files changed
+4228
No files matched your search
@@ -0,0 +1,404 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user