const $ = (id) => document.getElementById(id); let current = null; function status(text) { $("status").textContent = text; } function controls(busy) { $("start").disabled = busy || !$("model").value; $("load").disabled = busy; $("model").disabled = busy; $("finish").disabled = !busy; $("cancel").disabled = !busy; } async function stopMic(s) { s.stream?.getTracks().forEach((track) => track.stop()); s.stream = null; s.node?.disconnect(); s.node = null; if (s.context && s.context.state !== "closed") await s.context.close().catch(() => {}); } async function cleanup(s, message) { if (s.closed) return; s.closed = true; await stopMic(s); if (s.ws && s.ws.readyState < WebSocket.CLOSING) s.ws.close(); if (current === s) { current = null; controls(false); if (message) status(message); } } $("load").addEventListener("click", async () => { try { const token = $("token").value.trim(); const response = await fetch("/api/models", { headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) throw new Error( response.status === 401 ? "访问令牌无效" : "读取模型失败", ); const models = await response.json(); $("model").replaceChildren(); let available = 0; for (const model of models) { const option = document.createElement("option"); option.value = model.id; option.textContent = `${model.id}${model.enabled ? "" : "(服务端未配置凭据)"}`; option.disabled = !model.enabled; $("model").append(option); if (model.enabled) available++; } const enabled = [...$("model").options].find((o) => !o.disabled); $("model").value = enabled?.value || ""; controls(false); status( available ? "配置已读取,可开始测试" : "没有可用凭据;LLM/TTS 未启用", ); } catch (error) { status(error.message); } }); $("model").addEventListener("change", () => controls(false)); $("start").addEventListener("click", async () => { if (current) return; if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) { status( "麦克风需要 HTTPS 或 localhost。可使用 SSH 隧道,不要关闭浏览器安全设置。", ); return; } const token = $("token").value.trim(); if (!/^[A-Za-z0-9_-]{32,}$/.test(token)) { status("请输入有效的服务访问令牌"); return; } const s = { closed: false }; current = s; controls(true); status("正在请求麦克风…"); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, }, video: false, }); if (s.closed) { stream.getTracks().forEach((track) => track.stop()); return; } s.stream = stream; s.context = new AudioContext({ sampleRate: 16000 }); if (s.context.sampleRate !== 16000) throw new Error("浏览器无法提供 16 kHz 音频,已停止,避免发送错误采样率"); await s.context.audioWorklet.addModule("/pcm-worklet.js"); if (s.closed) return; const ws = new WebSocket( `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`, ["asr.v1", `auth.${token}`], ); s.ws = ws; ws.binaryType = "arraybuffer"; ws.onclose = () => { void cleanup(s, "连接已结束"); }; ws.onerror = () => { void cleanup(s, "WebSocket 连接失败,请检查访问令牌、Origin 和服务状态"); }; ws.onopen = () => { if (!s.closed) ws.send(JSON.stringify({ type: "start", model: $("model").value })); }; ws.onmessage = async (event) => { if (s.closed || typeof event.data !== "string") return; try { const data = JSON.parse(event.data); if (data.type === "asr-started") { s.node = new AudioWorkletNode(s.context, "pcm16"); s.node.port.onmessage = ({ data: pcm }) => { if (s.closed || ws.readyState !== WebSocket.OPEN) return; if (ws.bufferedAmount > 256 * 1024) { void cleanup(s, "网络积压过多,已停止采集"); return; } ws.send(pcm); }; s.context.createMediaStreamSource(stream).connect(s.node); s.node.connect(s.context.destination); await s.context.resume(); $("audio").textContent = `音频:${s.context.sampleRate} Hz / 单声道 / PCM16 / 每包 100ms`; status("正在识别(最长5分钟)"); } else if (data.type === "partial") { $("partial").textContent = data.text; } else if (data.type === "final") { const line = document.createElement("p"); line.textContent = data.text; $("finals").append(line); if ($("finals").children.length > 100) $("finals").firstElementChild.remove(); $("partial").textContent = "等待下一句…"; } else if (data.type === "error") { await cleanup(s, data.error || "上游识别错误"); } else if (data.type === "asr-stopped") { await cleanup(s, "本轮识别结束"); } } catch { await cleanup(s, "音频初始化或服务消息处理失败"); } }; } catch (error) { await cleanup(s, error.message); } }); $("finish").addEventListener("click", async () => { const s = current; if (!s) return; await stopMic(s); $("finish").disabled = true; if (s.ws?.readyState === WebSocket.OPEN) { s.ws.send(JSON.stringify({ type: "finish" })); status("已停止麦克风,等待最终结果…"); } else await cleanup(s, "已取消尚未就绪的连接"); }); $("cancel").addEventListener("click", () => { if (current) void cleanup(current, "已取消"); }); window.addEventListener("pagehide", () => { if (current) { current.stream?.getTracks().forEach((track) => track.stop()); current.ws?.close(); } });