Files
voice_test/web/index.html
T

393 lines
18 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; }
html, body { height:100%; overflow:hidden; }
body { background:var(--bg); color:var(--fg); font:14px/1.6 "PingFang SC","Microsoft YaHei",system-ui,sans-serif; 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, input { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px 12px; font-size:14px; }
select, button { cursor:pointer; }
select { min-width:280px; }
.config { padding:10px 22px; border-bottom:1px solid var(--line); background:#141720; max-height:45vh; overflow-y:auto; flex:none; }
.config summary { cursor:pointer; color:var(--dim); user-select:none; }
.config-grid { display:grid; grid-template-columns:repeat(5,minmax(130px,1fr)); gap:10px 14px; margin-top:10px; }
.config-grid label { display:flex; flex-direction:column; gap:4px; color:var(--dim); font-size:12px; }
.config-grid label.wide { grid-column:span 2; }
.config-grid input[type="range"] { padding:0; accent-color:var(--acc); }
.config-grid select { min-width:0; width:100%; }
.range-title { display:flex; justify-content:space-between; }
.hint { color:var(--dim); font-size:11px; margin-top:6px; }
.recorder { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-top:10px; padding-top:10px; border-top:1px solid var(--line); }
#record.on { background:var(--err); color:#fff; }
#recording-audio { height:34px; max-width:320px; }
#recording-download { color:#8fb0ff; text-decoration:none; }
@media (max-width:900px){ .config-grid{grid-template-columns:1fr 1fr;} .config-grid label.wide{grid-column:span 2;} }
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; min-height:0; overflow:hidden; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:minmax(0,1fr); gap:14px; padding:14px 22px; max-width:1200px; width:100%; margin:0 auto; }
@media (max-width:860px){ main{grid-template-columns:1fr; grid-template-rows:repeat(2,minmax(0,1fr));} }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
.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; min-height:0; overflow-y:auto; overscroll-behavior:contain; 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>
<details class="config" open>
<summary>TTS 声音配置</summary>
<div class="config-grid">
<label>TTS 模型<select id="tts-model"></select></label>
<label>预置音色<select id="tts-system-voice"></select></label>
<label class="wide">复刻 / 设计音色<select id="tts-cloned-voice"><option value="">请选择复刻音色</option></select></label>
<label><span class="range-title">语速 <b id="rate-value">1.0×</b></span><input id="tts-rate" type="range" min="0.5" max="2" step="0.1" value="1"></label>
<label><span class="range-title">语调 <b id="pitch-value">1.0×</b></span><input id="tts-pitch" type="range" min="0.5" max="2" step="0.1" value="1"></label>
<label><span class="range-title">音量 <b id="volume-value">50</b></span><input id="tts-volume" type="range" min="0" max="100" step="1" value="50"></label>
<label class="wide">声音表达 / 模仿描述<input id="tts-instruction" maxlength="100" placeholder="如:温柔沉稳、略带微笑、语速自然"></label>
</div>
<div class="hint">v3.5 仅支持复刻/设计音色;复刻音色从当前百炼账号自动读取。<span id="voice-status"></span></div>
<div class="recorder">
<button id="record">⏺ 录制复刻样本</button>
<b id="record-time">00:00</b>
<audio id="recording-audio" controls hidden></audio>
<a id="recording-download" download="voice-sample.wav" hidden>下载 WAV</a>
<span class="hint">建议安静环境录制 5~20 秒连续清晰人声,最长 30 秒。</span>
</div>
</details>
<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 流式回答并按当前音色配置播报。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);
}
});
let systemVoices = {};
fetch("/api/config").then(r => r.json()).then(({tts, ttsModels, systemVoices: voices, clonedVoices, voiceError}) => {
systemVoices = voices;
fillSelect($("tts-model"), ttsModels);
$("tts-model").value = tts.model;
fillSelect($("tts-cloned-voice"), clonedVoices, "请选择复刻/设计音色");
const isSystem = renderSystemVoices(tts.voice);
if (!isSystem) $("tts-cloned-voice").value = tts.voice;
$("tts-instruction").value = tts.instruction;
$("tts-rate").value = tts.rate;
$("tts-pitch").value = tts.pitch;
$("tts-volume").value = tts.volume;
if (voiceError) $("voice-status").textContent = `(读取失败:${voiceError}`;
updateRangeLabels();
});
function fillSelect(select, options, placeholder = "") {
select.replaceChildren();
if (placeholder) select.add(new Option(placeholder, ""));
for (const o of options || []) select.add(new Option(o.label, o.id));
}
function renderSystemVoices(selected = "") {
const options = systemVoices[$("tts-model").value] || [];
fillSelect($("tts-system-voice"), options, options.length ? "请选择预置音色" : "该模型无预置音色");
if (options.some(v => v.id === selected)) {
$("tts-system-voice").value = selected;
return true;
}
return false;
}
$("tts-model").onchange = () => renderSystemVoices();
$("tts-system-voice").onchange = () => { if ($("tts-system-voice").value) $("tts-cloned-voice").value = ""; };
$("tts-cloned-voice").onchange = () => { if ($("tts-cloned-voice").value) $("tts-system-voice").value = ""; };
function updateRangeLabels() {
$("rate-value").textContent = Number($("tts-rate").value).toFixed(1) + "×";
$("pitch-value").textContent = Number($("tts-pitch").value).toFixed(1) + "×";
$("volume-value").textContent = $("tts-volume").value;
}
["tts-rate", "tts-pitch", "tts-volume"].forEach(id => $(id).oninput = updateRangeLabels);
function ttsConfig() {
return {
model: $("tts-model").value,
voice: $("tts-cloned-voice").value || $("tts-system-voice").value,
instruction: $("tts-instruction").value,
rate: Number($("tts-rate").value),
pitch: Number($("tts-pitch").value),
volume: Number($("tts-volume").value)
};
}
// ---------- 复刻样本录制(单声道 PCM WAV ----------
let recordStream = null, recordCtx = null, recordNode = null, recordChunks = [], recordStarted = 0, recordTimer = null, recordURL = "";
$("record").onclick = async () => {
if (recordStream) { stopRecording(); return; }
if ($("mic").classList.contains("on")) {
log($("chat-log"), "⚠ 请先停止 ASR 会话", "err");
return;
}
try {
recordStream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:false, noiseSuppression:false}});
recordCtx = new AudioContext();
const source = recordCtx.createMediaStreamSource(recordStream);
recordNode = recordCtx.createScriptProcessor(4096, 1, 1);
recordNode.onaudioprocess = e => recordChunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
const silent = recordCtx.createGain(); silent.gain.value = 0;
source.connect(recordNode); recordNode.connect(silent); silent.connect(recordCtx.destination);
recordStarted = Date.now();
$("record").className = "on"; $("record").textContent = "⏹ 停止录制";
recordTimer = setInterval(() => {
const seconds = Math.floor((Date.now() - recordStarted) / 1000);
$("record-time").textContent = `00:${String(seconds).padStart(2, "0")}`;
if (seconds >= 30) stopRecording();
}, 250);
} catch (e) {
recordStream = null;
log($("chat-log"), `⚠ 录音失败: ${esc(e.message)}`, "err");
}
};
function stopRecording() {
clearInterval(recordTimer);
const sampleRate = recordCtx.sampleRate;
recordNode.disconnect();
recordStream.getTracks().forEach(t => t.stop());
recordCtx.close();
recordStream = recordCtx = recordNode = null;
$("record").className = ""; $("record").textContent = "⏺ 重新录制";
if (recordURL) URL.revokeObjectURL(recordURL);
recordURL = URL.createObjectURL(wavBlob(recordChunks, sampleRate));
recordChunks = [];
$("recording-audio").src = recordURL; $("recording-audio").hidden = false;
$("recording-download").href = recordURL; $("recording-download").hidden = false;
}
function wavBlob(chunks, sampleRate) {
const samples = chunks.reduce((n, c) => n + c.length, 0);
const buffer = new ArrayBuffer(44 + samples * 2), view = new DataView(buffer);
const text = (offset, value) => [...value].forEach((c, i) => view.setUint8(offset + i, c.charCodeAt(0)));
text(0, "RIFF"); view.setUint32(4, 36 + samples * 2, true); text(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true); view.setUint16(34, 16, true); text(36, "data"); view.setUint32(40, samples * 2, true);
let offset = 44;
for (const chunk of chunks) for (const value of chunk) {
const s = Math.max(-1, Math.min(1, value));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); offset += 2;
}
return new Blob([buffer], {type:"audio/wav"});
}
// ---------- WebSocket ----------
function connectWS() {
ws = new WebSocket(`ws://${location.host}/agent`);
ws.binaryType = "arraybuffer";
ws.onopen = () => $("st").textContent = "已连接";
ws.onclose = () => { $("st").textContent = "连接断开"; stopMic(); stopPlayback(); };
ws.onmessage = ev => {
if (ev.data instanceof ArrayBuffer) { if (playEnabled) 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":
playEnabled = true;
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 "interrupt":
stopPlayback();
replyEl = null; replyText = "";
$("tts-badge").textContent = "interrupted";
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, playEnabled = false;
const playSources = new Set();
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();
playSources.add(src);
src.onended = () => playSources.delete(src);
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;
}
function stopPlayback() {
playEnabled = false;
for (const src of playSources) {
try { src.stop(); } catch (_) {}
}
playSources.clear();
playHead = playCtx ? playCtx.currentTime : 0;
}
// ---------- 按钮 ----------
$("mic").onclick = async () => {
const mic = $("mic");
if (mic.classList.contains("on")) { // 停止
ws && ws.send(JSON.stringify({ type: "stop" }));
stopPlayback();
stopMic();
return;
}
if (recordStream) {
log($("chat-log"), "⚠ 请先停止样本录制", "err");
return;
}
if (!ttsConfig().voice) {
log($("chat-log"), "⚠ 请先选择预置音色或复刻音色", "err");
$("tts-cloned-voice").focus();
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, tts: ttsConfig() }));
mic.className = "on"; mic.textContent = "⏹ 停止";
$("asr-badge").textContent = "listening";
log($("asr-log"), "", "sys").remove();
log($("chat-log"), `— 会话开始,模型: ${$("model").value} —`, "sys");
};
</script>
</body>
</html>