Files
voice_test/web/index.html
T

214 lines
9.5 KiB
HTML
Raw 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="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>实时 ASR 客服机器人测试台</title>
<style>
:root { --bg:#0f1117; --panel:#1a1d27; --line:#2a2e3d; --fg:#e6e8f0; --dim:#8b90a5; --acc:#4f7cff; --ok:#3fbf6f; --warn:#e0a23c; --err:#e05c5c; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--fg); font:14px/1.6 "PingFang SC","Microsoft YaHei",system-ui,sans-serif; min-height:100vh; display:flex; flex-direction:column; }
header { padding:14px 22px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
header h1 { font-size:16px; font-weight:600; }
header .sel { display:flex; align-items:center; gap:8px; margin-left:auto; }
select, button { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px 14px; font-size:14px; cursor:pointer; }
select { min-width:280px; }
button:hover:not(:disabled) { border-color:var(--acc); }
button:disabled { opacity:.4; cursor:not-allowed; }
#mic { border-color:transparent; color:#fff; font-weight:600; min-width:96px; }
#mic.on { background:var(--err); animation:pulse 1.2s infinite; }
#mic.off { background:var(--acc); }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(224,92,92,.5)} 50%{box-shadow:0 0 0 10px rgba(224,92,92,0)} }
main { flex:1; display:grid; grid-template-columns:1fr 1fr; gap:14px; padding:14px 22px; max-width:1200px; width:100%; margin:0 auto; }
@media (max-width:860px){ main{grid-template-columns:1fr;} }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; display:flex; flex-direction:column; min-height:320px; }
.panel h2 { font-size:13px; color:var(--dim); padding:10px 14px; border-bottom:1px solid var(--line); font-weight:500; display:flex; justify-content:space-between; }
.panel h2 .badge { font-size:11px; padding:1px 8px; border-radius:6px; background:#262b3b; }
.log { flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:10px; }
.msg { padding:8px 12px; border-radius:10px; max-width:88%; white-space:pre-wrap; word-break:break-all; }
.msg .who { font-size:11px; color:var(--dim); margin-bottom:2px; }
.msg.user { align-self:flex-end; background:#25315e; border-top-right-radius:3px; }
.msg.bot { align-self:flex-start; background:#22283a; border-top-left-radius:3px; }
.msg.partial { opacity:.6; font-style:italic; }
.msg.err { align-self:center; background:#3b1f24; color:var(--err); font-size:12px; }
.msg.sys { align-self:center; color:var(--dim); font-size:12px; }
#status { padding:8px 22px; color:var(--dim); font-size:12px; border-top:1px solid var(--line); display:flex; gap:16px; }
#status b { color:var(--fg); font-weight:500; }
#vu { display:flex; align-items:center; gap:5px; }
#vu i { width:4px; background:var(--ok); border-radius:2px; height:4px; transition:height .08s; }
.footer-tip { padding:4px 22px 10px; color:var(--dim); font-size:11px; }
</style>
</head>
<body>
<header>
<h1>🎧 实时 ASR 客服机器人测试台</h1>
<div class="sel">
<select id="model"></select>
<button id="mic" class="off">🎤 开始</button>
</div>
</header>
<main>
<div class="panel">
<h2>识别(ASR) <span class="badge" id="asr-badge">idle</span></h2>
<div class="log" id="asr-log"></div>
</div>
<div class="panel">
<h2>客服回答(LLM → TTS) <span class="badge" id="tts-badge">idle</span></h2>
<div class="log" id="chat-log"></div>
</div>
</main>
<div id="status">
<span>状态: <b id="st">未连接</b></span>
<span>延迟: <b id="lat">-</b></span>
<span id="vu"><i></i><i></i><i></i><i></i><i></i></span>
</div>
<div class="footer-tip">浏览器采集 PCM16/16k 单声道,实时上行;每句判定结束后自动由客服 LLM 流式回答并 TTS 播报。Chrome 要求 localhost 或 HTTPS 才能使用麦克风。</div>
<script>
"use strict";
const $ = id => document.getElementById(id);
const log = (el, html, cls) => {
const d = document.createElement("div");
d.className = "msg " + cls; d.innerHTML = html;
el.appendChild(d); el.scrollTop = el.scrollHeight;
return d;
};
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let ws = null, audioCtx = null, proc = null, stream = null;
let partialEl = null, replyEl = null, replyText = "";
let lastVoiceAt = 0, finalAt = 0;
// ---------- 模型下拉 ----------
fetch("/api/models").then(r => r.json()).then(models => {
const sel = $("model");
for (const m of models) {
const o = document.createElement("option");
o.value = m.id;
o.textContent = m.label + (m.ready === false ? "(未配置凭证)" : "");
if (m.ready === false) o.disabled = true;
sel.appendChild(o);
}
});
// ---------- WebSocket ----------
function connectWS() {
ws = new WebSocket(`ws://${location.host}/agent`);
ws.binaryType = "arraybuffer";
ws.onopen = () => $("st").textContent = "已连接";
ws.onclose = () => { $("st").textContent = "连接断开"; stopMic(); };
ws.onmessage = ev => {
if (ev.data instanceof ArrayBuffer) { playPcm(new Int16Array(ev.data)); return; }
const m = JSON.parse(ev.data);
switch (m.type) {
case "partial":
if (partialEl) partialEl.remove();
partialEl = log($("asr-log"), esc(m.text), "user partial");
break;
case "final": {
if (partialEl) { partialEl.remove(); partialEl = null; }
log($("asr-log"), `<div class="who">你</div>${esc(m.text)}`, "user");
finalAt = performance.now();
$("lat").textContent = (finalAt - lastVoiceAt).toFixed(0) + " ms";
break;
}
case "reply-delta":
if (!replyEl) { replyText = ""; replyEl = log($("chat-log"), "", "bot"); }
replyText += m.text;
replyEl.innerHTML = `<div class="who">客服</div>${esc(replyText)}`;
$("chat-log").scrollTop = 1e9;
$("tts-badge").textContent = "answering";
break;
case "reply-done":
if (!replyEl) replyEl = log($("chat-log"), `<div class="who">客服</div>${esc(m.text)}`, "bot");
replyEl = null; replyText = "";
break;
case "tts-end":
$("tts-badge").textContent = "idle";
break;
case "asr-stopped":
$("asr-badge").textContent = "idle";
break;
case "error":
log($("chat-log"), `⚠ ${esc(m.error)}`, "err");
break;
}
};
}
// ---------- 麦克风 → PCM16 ----------
async function startMic() {
stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount:1, echoCancellation:true, noiseSuppression:true } });
audioCtx = new AudioContext({ sampleRate: 16000 });
const src = audioCtx.createMediaStreamSource(stream);
await audioCtx.audioWorklet.addModule(URL.createObjectURL(new Blob([`
class P extends AudioWorkletProcessor {
process(input) {
const ch = input[0][0];
if (ch) {
const pcm = new Int16Array(ch.length);
let sum = 0;
for (let i = 0; i < ch.length; i++) { const s = Math.max(-1, Math.min(1, ch[i])); sum += s*s; pcm[i] = s < 0 ? s*0x8000 : s*0x7fff; }
this.port.postMessage({ pcm: pcm.buffer, rms: Math.sqrt(sum/ch.length) }, [pcm.buffer]);
}
return true;
}
}
registerProcessor("pcm16", P);
`], { type: "application/javascript" })));
proc = new AudioWorkletNode(audioCtx, "pcm16");
src.connect(proc);
proc.port.onmessage = e => {
if (ws && ws.readyState === 1) {
lastVoiceAt = performance.now();
ws.send(e.data.pcm);
}
const rms = e.data.rms, bars = $("vu").children;
for (let i = 0; i < bars.length; i++) bars[i].style.height = Math.max(4, Math.min(36, rms * 400 * (i+1) / 2)) + "px";
};
}
function stopMic() {
if (proc) { proc.disconnect(); proc = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
const mic = $("mic");
mic.className = "off"; mic.textContent = "🎤 开始";
}
// ---------- TTS 播放(PCM16/16k) ----------
let playCtx = null, playHead = 0;
function playPcm(pcm) {
if (!playCtx || playCtx.state === "closed") { playCtx = new AudioContext({ sampleRate: 16000 }); playHead = 0; }
playCtx.resume();
const buf = playCtx.createBuffer(1, pcm.length, 16000);
buf.getChannelData(0).set(Float32Array.from(pcm, v => v / 32768));
const src = playCtx.createBufferSource();
src.buffer = buf; src.connect(playCtx.destination);
const now = playCtx.currentTime;
if (playHead < now + 0.05) playHead = now + 0.05; // 游标滞后(播完已久/首块)则贴到当前时刻
src.start(playHead); // 否则排队接在上一块尾部
playHead += buf.duration;
}
// ---------- 按钮 ----------
$("mic").onclick = async () => {
const mic = $("mic");
if (mic.classList.contains("on")) { // 停止
ws && ws.send(JSON.stringify({ type: "stop" }));
stopMic();
return;
}
if (!ws || ws.readyState !== 1) connectWS();
await new Promise(r => { const c = () => { if (ws.readyState === 1) r(); else setTimeout(c, 50); }; c(); });
try { await startMic(); } catch (e) { log($("chat-log"), `⚠ 麦克风失败: ${esc(e.message)}`, "err"); return; }
ws.send(JSON.stringify({ type: "start", asr: $("model").value }));
mic.className = "on"; mic.textContent = "⏹ 停止";
$("asr-badge").textContent = "listening";
log($("asr-log"), "", "sys").remove();
log($("chat-log"), `— 会话开始,模型: ${$("model").value} —`, "sys");
};
</script>
</body>
</html>