177 lines
5.9 KiB
JavaScript
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();
|
|
}
|
|
});
|