Files
2026-03-21 18:57:10 +08:00

278 lines
6.8 KiB
JavaScript
Raw Permalink 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.

/**
* 小程序语音网关客户端:
* 1. 对齐 Web 端 /ws/asr 协议ready/result/round_end/error
* 2. 控制帧走 JSON音频分片走二进制帧。
*/
const READY_TIMEOUT_MS = 7000;
const CLOSE_TIMEOUT_MS = 2600;
function buildAsrWsUrl(rawGatewayUrl, token) {
const input = String(rawGatewayUrl || "").trim();
if (!input) {
throw new Error("网关地址为空");
}
let base = input;
if (base.startsWith("http://")) base = `ws://${base.slice(7)}`;
if (base.startsWith("https://")) base = `wss://${base.slice(8)}`;
if (!base.startsWith("ws://") && !base.startsWith("wss://")) {
base = `wss://${base}`;
}
base = base.replace(/\/+$/, "");
const safeToken = encodeURIComponent(String(token || ""));
return `${base}/ws/asr?token=${safeToken}`;
}
function createAsrGatewayClient(options) {
const config = options || {};
const endpoint = buildAsrWsUrl(config.gatewayUrl, config.gatewayToken);
let socketTask = null;
let ready = false;
let closed = false;
let stopCloseTimer = null;
let readyTimer = null;
let readyResolve = null;
let readyReject = null;
function clearReadyTimer() {
if (readyTimer) {
clearTimeout(readyTimer);
readyTimer = null;
}
}
function clearStopCloseTimer() {
if (stopCloseTimer) {
clearTimeout(stopCloseTimer);
stopCloseTimer = null;
}
}
function rejectPendingReady(error) {
if (typeof readyReject === "function") {
readyReject(error);
}
readyResolve = null;
readyReject = null;
}
function resolvePendingReady() {
if (typeof readyResolve === "function") {
readyResolve();
}
readyResolve = null;
readyReject = null;
}
function sendJson(frame) {
if (!socketTask || closed) return;
socketTask.send({ data: JSON.stringify(frame) });
}
function connect() {
if (socketTask && !closed) {
return Promise.resolve();
}
closed = false;
ready = false;
return new Promise((resolve, reject) => {
readyResolve = resolve;
readyReject = reject;
socketTask = wx.connectSocket({ url: endpoint, timeout: READY_TIMEOUT_MS });
clearReadyTimer();
readyTimer = setTimeout(() => {
if (ready) return;
close("ready_timeout");
rejectPendingReady(new Error("语音网关连接超时"));
}, READY_TIMEOUT_MS);
socketTask.onOpen(() => {
if (typeof config.onOpen === "function") {
config.onOpen();
}
});
socketTask.onMessage((event) => {
const raw = event && event.data;
if (typeof raw !== "string") {
return;
}
let frame = null;
try {
frame = JSON.parse(raw);
} catch {
return;
}
if (frame.type === "ready") {
ready = true;
clearReadyTimer();
resolvePendingReady();
if (typeof config.onReady === "function") {
config.onReady(frame.payload || {});
}
return;
}
if (frame.type === "result") {
if (typeof config.onResult === "function") {
config.onResult(frame.payload || {});
}
return;
}
if (frame.type === "round_end") {
clearStopCloseTimer();
if (typeof config.onRoundEnd === "function") {
config.onRoundEnd(frame.payload || {});
}
return;
}
if (frame.type === "pong") {
return;
}
if (frame.type === "error") {
const message = String((frame.payload && frame.payload.message) || "语音网关异常");
const error = new Error(message);
if (!ready) {
clearReadyTimer();
rejectPendingReady(error);
}
if (typeof config.onError === "function") {
config.onError(error);
}
return;
}
});
socketTask.onError((event) => {
const detail = event && event.errMsg ? String(event.errMsg) : "";
const error = new Error(detail ? `语音网关连接失败: ${detail}` : "语音网关连接失败");
if (!ready) {
clearReadyTimer();
rejectPendingReady(error);
}
if (typeof config.onError === "function") {
config.onError(error);
}
});
socketTask.onClose((event) => {
const wasReady = ready;
ready = false;
closed = true;
clearReadyTimer();
clearStopCloseTimer();
socketTask = null;
if (!wasReady) {
const code = event && typeof event.code === "number" ? event.code : "";
const reason = event && event.reason ? String(event.reason) : "";
rejectPendingReady(
new Error(`语音连接已关闭${code ? `(${code})` : ""}${reason ? `: ${reason}` : ""}`)
);
}
if (typeof config.onClose === "function") {
config.onClose(event || {});
}
});
});
}
function startRound(payload) {
if (!ready) {
throw new Error("语音网关未就绪");
}
sendJson({
type: "start",
payload:
payload && typeof payload === "object"
? payload
: {
audio: {
format: "pcm",
codec: "raw",
rate: 16000,
bits: 16,
channel: 1
},
request: {
model_name: "bigmodel",
enable_itn: true,
enable_punc: true,
result_type: "full"
}
}
});
}
function sendAudio(buffer) {
if (!ready || !socketTask || closed) {
return;
}
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength <= 0) {
return;
}
socketTask.send({ data: buffer });
}
function stopRound() {
if (!ready) return;
sendJson({ type: "stop" });
clearStopCloseTimer();
stopCloseTimer = setTimeout(() => {
close("round_timeout");
}, CLOSE_TIMEOUT_MS);
}
function cancelRound() {
if (!ready) {
close("cancel_before_ready");
return;
}
sendJson({ type: "cancel" });
close("cancel");
}
function ping() {
if (!ready) return;
sendJson({ type: "ping" });
}
function close(reason) {
clearReadyTimer();
clearStopCloseTimer();
closed = true;
ready = false;
if (!socketTask) return;
try {
socketTask.close({ code: 1000, reason: reason || "client_close" });
} catch (error) {
console.warn("[asrGateway.close]", error);
}
socketTask = null;
}
return {
connect,
startRound,
sendAudio,
stopRound,
cancelRound,
ping,
close,
isReady() {
return ready;
}
};
}
module.exports = {
READY_TIMEOUT_MS,
CLOSE_TIMEOUT_MS,
buildAsrWsUrl,
createAsrGatewayClient
};