214 lines
9.5 KiB
HTML
214 lines
9.5 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|