Files
agent-call/services/asr-web/web/app.js
T
2026-09-11 17:47:03 +08:00

177 lines
5.9 KiB
JavaScript

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();
}
});