web-index/doubao-asr-demo/server/index.js

229 lines
8.2 KiB
JavaScript
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.

import express from "express";
import fetch, { Headers } from "node-fetch";
import cors from "cors";
import dotenv from "dotenv";
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
dotenv.config();
const apiApp = express();
const port = Number.parseInt(process.env.PORT ?? "5174", 10);
const submitUrl = process.env.DOU_BAO_SUBMIT_URL ?? "https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit";
const resultUrl = process.env.DOU_BAO_RESULT_URL ?? "https://openspeech.bytedance.com/api/v3/auc/bigmodel/query";
const shouldServeStatic = true;
apiApp.use(cors());
apiApp.use(express.json({ limit: "100mb" }));
// 基础访问日志,便于定位请求是否到达后端
apiApp.use((req, _res, next) => { console.log(`[incoming] ${req.method} ${req.path}`); next(); });
// 同端口托管静态页面
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const staticRoot = path.resolve(__dirname, "..");
apiApp.use(express.static(staticRoot));
apiApp.get(["/", "/index.html"], (req, res) => {
res.sendFile(path.resolve(staticRoot, "index.html"));
});
function assertApiKeyConfigured() {
if (!process.env.DOU_BAO_API_KEY) {
throw new Error("服务器未配置 DOU_BAO_API_KEY无法向豆包接口鉴权");
}
}
function createUpstreamHeaders(overrideRequestId) {
assertApiKeyConfigured();
const headers = new Headers();
const requestId = overrideRequestId || crypto.randomUUID();
headers.set("Content-Type", "application/json");
headers.set("x-api-key", process.env.DOU_BAO_API_KEY);
headers.set("X-Api-Resource-Id", process.env.DOU_BAO_RESOURCE_ID ?? "volc.bigasr.auc");
headers.set("X-Api-Request-Id", requestId);
headers.set("X-Api-Sequence", "-1");
return { headers, requestId };
}
function summarizeSubmitPayload(payload) {
if (!payload) {
return {};
}
const audioDataLength = typeof payload?.audio?.data === "string" ? payload.audio.data.length : 0;
return {
hasAudioData: audioDataLength > 0,
audioDataLength,
audioFormat: payload?.audio?.format,
audioCodec: payload?.audio?.codec,
sampleRate: payload?.audio?.rate,
bits: payload?.audio?.bits,
channel: payload?.audio?.channel,
hasAudioUrl: typeof payload?.audio?.url === "string",
modelName: payload?.request?.model_name,
uid: payload?.user?.uid
};
}
function pickTranscript(obj) {
const segments = obj?.data?.result?.segments || obj?.result?.segments;
if (Array.isArray(segments) && segments.length > 0) {
const texts = segments
.map(s => (typeof s?.text === 'string' && s.text.trim()) || (typeof s?.transcript === 'string' && s.transcript.trim()) || '')
.filter(Boolean);
if (texts.length) return texts.join('\n');
}
const candidates = [
obj?.data?.result?.text,
obj?.result?.text,
obj?.result?.output?.choices?.[0]?.text,
obj?.result?.output?.transcript,
obj?.result?.transcript,
obj?.data?.output?.choices?.[0]?.text,
obj?.data?.transcript,
obj?.text,
obj?.transcript
];
return candidates.find(v => typeof v === 'string' && v.trim().length > 0) || null;
}
async function forwardJson({ url, payload, logLabel, overrideRequestId }) {
const { headers, requestId } = createUpstreamHeaders(overrideRequestId);
const payloadSummary = summarizeSubmitPayload(payload);
console.log(`[${logLabel}] 转发至 ${url}`, payloadSummary, { request: payload?.request });
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const rawText = await response.text();
let parsed;
try {
parsed = JSON.parse(rawText);
} catch (error) {
parsed = { message: "解析豆包响应失败", raw: rawText };
}
// 尝试兼容更多返回字段,并附带原始响应便于前端调试
const normalized = {
...parsed,
__upstream: {
status: response.status,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
rawText,
requestId
},
result: parsed.result ?? parsed.data ?? parsed ?? {},
data: parsed.data ?? parsed.result ?? parsed ?? {}
};
// 若上游未返回任务ID使用本次 requestId 作为回退,确保前端能继续轮询
if (!normalized.result.job_key && !normalized.data.job_key) {
normalized.result.job_key = requestId;
normalized.data.job_key = requestId;
}
// 不在服务端强制注入 transcript避免前端“抢先以纯文本结束轮询”
// const transcript = pickTranscript(normalized) || null;
// if (transcript) normalized.transcript = transcript;
console.log(`[${logLabel}] 响应 ${response.status}`, normalized);
return { status: response.status, body: normalized };
}
apiApp.post("/api/asr/submit", async (req, res) => {
try {
if (!req.body || Object.keys(req.body).length === 0) {
return res.status(400).json({ message: "请求体为空,无法提交任务" });
}
const result = await forwardJson({ url: submitUrl, payload: req.body, logLabel: "submit" });
return res.status(result.status).json(result.body);
} catch (error) {
console.error("[submit]", error);
const message = error instanceof Error ? error.message : String(error);
return res.status(500).json({ message: "提交任务失败", error: message });
}
});
apiApp.post("/api/asr/result", async (req, res) => {
try {
if (!req.body || !req.body.job_key) {
return res.status(400).json({ message: "缺少 job_key无法查询任务" });
}
const jobKey = String(req.body.job_key || req.body.taskId || req.body.task_id || "");
if (!jobKey) {
return res.status(400).json({ message: "缺少 job_key无法查询任务" });
}
// 按官方示例query 使用 X-Api-Request-Id 作为查询键body 可为空
const result = await forwardJson({ url: resultUrl, payload: {}, logLabel: "result", overrideRequestId: jobKey });
return res.status(result.status).json(result.body);
} catch (error) {
console.error("[result]", error);
const message = error instanceof Error ? error.message : String(error);
return res.status(500).json({ message: "查询任务失败", error: message });
}
});
apiApp.get("/health", (req, res) => {
res.json({
status: "ok",
submitUrl,
resultUrl,
hasApiKey: Boolean(process.env.DOU_BAO_API_KEY)
});
});
const apiServer = apiApp.listen(port, "0.0.0.0", () => {
console.log(`豆包 ASR 代理服务已启动,端口 ${port}`);
});
// Keep-Alive定期自我心跳支持工作时段窗口控制
const KEEP_ALIVE_MS = Number.parseInt(process.env.KEEP_ALIVE_MS ?? "300000", 10); // 默认5分钟
const KEEP_ALIVE_WINDOW = process.env.KEEP_ALIVE_WINDOW || "08:00-22:00"; // 形如 "08:00-22:00"
const KEEP_ALIVE_TZ = process.env.KEEP_ALIVE_TZ || process.env.TZ || "Asia/Shanghai";
function withinKeepAliveWindow(now = new Date()){
try{
// 获取目标时区的小时与分钟
const parts = new Intl.DateTimeFormat('en-GB', { timeZone: KEEP_ALIVE_TZ, hour12: false, hour: '2-digit', minute: '2-digit' })
.formatToParts(now);
const hh = Number(parts.find(p=>p.type==='hour')?.value || '0');
const mm = Number(parts.find(p=>p.type==='minute')?.value || '0');
const cur = hh * 60 + mm;
const [startStr, endStr] = KEEP_ALIVE_WINDOW.split('-');
const [sh, sm] = (startStr||'08:00').split(':').map(n=>Number(n)||0);
const [eh, em] = (endStr||'22:00').split(':').map(n=>Number(n)||0);
const start = sh * 60 + sm;
const end = eh * 60 + em;
if (start === end) return true; // 全时段
if (start < end) return cur >= start && cur < end; // 正常区间
// 跨午夜区间
return cur >= start || cur < end;
}catch(_){ return true; }
}
if (KEEP_ALIVE_MS > 0) {
setInterval(async () => {
if (!withinKeepAliveWindow()) return;
try {
const resp = await fetch(`http://127.0.0.1:${port}/health`);
process.stdout.write(".");
await resp.text().catch(()=>{});
} catch (e) {
console.log("[keep-alive] ping 失败", e?.message || e);
}
}, KEEP_ALIVE_MS);
}
// 已在同端口托管静态页面,无需单独 staticApp
process.on("SIGINT", () => {
apiServer.close(() => {
console.log("代理服务已关闭");
process.exit(0);
});
});