import type { IncomingMessage, ServerResponse } from "node:http"; import { Communicate } from "edge-tts-ts"; const EDGE_TTS_PATH = "/api/tts/edge"; const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"; const MAX_REQUEST_BYTES = 8 * 1024; const MAX_TEXT_LENGTH = 520; const MAX_AUDIO_BYTES = 5 * 1024 * 1024; const DEFAULT_MAX_CONCURRENT = 8; const DEFAULT_RATE_LIMIT = 60; const DEFAULT_RATE_WINDOW_MS = 60_000; const DEFAULT_TIMEOUT_MS = 30_000; interface EdgeTtsPayload { text?: unknown; voice?: unknown; } interface RateBucket { count: number; startedAt: number; } export type EdgeTtsSynthesizer = (text: string, voice: string) => Promise; export type EdgeTtsMiddlewareOptions = { defaultVoice?: string; maxConcurrent?: number; maxRequestsPerWindow?: number; now?: () => number; rateWindowMs?: number; synthesize?: EdgeTtsSynthesizer; timeoutMs?: number; trustProxy?: boolean; }; function sendJson(response: ServerResponse, status: number, message: string) { response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify({ error: message })); } async function readJson(request: IncomingMessage): Promise { const chunks: Buffer[] = []; let byteLength = 0; for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); byteLength += buffer.byteLength; if (byteLength > MAX_REQUEST_BYTES) { throw new RangeError("请求内容过大"); } chunks.push(buffer); } const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new SyntaxError("请求内容必须是 JSON 对象"); } return payload as EdgeTtsPayload; } function isLoopback(address: string | undefined) { return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; } function getClientKey(request: IncomingMessage, trustProxy: boolean) { const remoteAddress = request.socket.remoteAddress; if (trustProxy && isLoopback(remoteAddress)) { const forwardedFor = request.headers["x-forwarded-for"]; const firstAddress = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(",", 1)[0]; if (firstAddress?.trim()) return firstAddress.trim(); } return remoteAddress || "unknown"; } export async function synthesizeEdgeSpeech(text: string, voice: string): Promise { const communicate = new Communicate(text, { voice }); const chunks: Uint8Array[] = []; let byteLength = 0; for await (const chunk of communicate.stream()) { if (chunk.type !== "audio") continue; chunks.push(chunk.data); byteLength += chunk.data.byteLength; } if (!byteLength) throw new Error("语音服务返回了空音频"); const audio = new Uint8Array(byteLength); let offset = 0; for (const chunk of chunks) { audio.set(chunk, offset); offset += chunk.byteLength; } return audio; } export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) { const synthesize = options.synthesize ?? synthesizeEdgeSpeech; const defaultVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE; const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; const maxRequestsPerWindow = options.maxRequestsPerWindow ?? DEFAULT_RATE_LIMIT; const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const trustProxy = options.trustProxy ?? false; const now = options.now ?? Date.now; const rateBuckets = new Map(); let activeRequests = 0; return async (request: IncomingMessage, response: ServerResponse, next?: () => void) => { if (request.url?.split("?", 1)[0] !== EDGE_TTS_PATH) { next?.(); return; } if (request.method !== "POST") { response.setHeader("Allow", "POST"); sendJson(response, 405, "仅支持 POST 请求"); return; } if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { sendJson(response, 415, "请求必须使用 application/json"); return; } const currentTime = now(); const clientKey = getClientKey(request, trustProxy); const existingBucket = rateBuckets.get(clientKey); const bucket = !existingBucket || currentTime - existingBucket.startedAt >= rateWindowMs ? { count: 0, startedAt: currentTime } : existingBucket; bucket.count += 1; rateBuckets.set(clientKey, bucket); if (rateBuckets.size > 1_024) { for (const [key, value] of rateBuckets) { if (currentTime - value.startedAt >= rateWindowMs) rateBuckets.delete(key); } } if (bucket.count > maxRequestsPerWindow) { response.setHeader("Retry-After", String(Math.max(1, Math.ceil(rateWindowMs / 1_000)))); sendJson(response, 429, "语音请求过于频繁,请稍后重试"); return; } let payload: EdgeTtsPayload; try { payload = await readJson(request); } catch (error) { sendJson(response, error instanceof RangeError ? 413 : 400, "请求内容不是有效 JSON"); return; } if (Object.prototype.hasOwnProperty.call(payload, "voice")) { sendJson(response, 400, "语音角色只能由服务端配置"); return; } const text = typeof payload.text === "string" ? payload.text.trim() : ""; if (!text) { sendJson(response, 400, "缺少待朗读文本"); return; } if (text.length > MAX_TEXT_LENGTH) { sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH} 字`); return; } if (activeRequests >= maxConcurrent) { response.setHeader("Retry-After", "1"); sendJson(response, 429, "语音服务繁忙,请稍后重试"); return; } activeRequests += 1; const synthesisTask = Promise.resolve().then(() => synthesize(text, defaultVoice)); void synthesisTask.finally(() => { activeRequests -= 1; }).catch(() => undefined); let timeout: ReturnType | undefined; try { const audio = await Promise.race([ synthesisTask, new Promise((_, reject) => { timeout = setTimeout(() => reject(new DOMException("语音服务响应超时", "TimeoutError")), timeoutMs); }) ]); if (!audio.byteLength) { sendJson(response, 502, "语音服务返回了空音频"); return; } if (audio.byteLength > MAX_AUDIO_BYTES) { sendJson(response, 502, "语音服务返回的音频过大"); return; } response.statusCode = 200; response.setHeader("Content-Type", "audio/mpeg"); response.setHeader("Cache-Control", "no-store"); response.end(Buffer.from(audio)); } catch (error) { if (error instanceof DOMException && error.name === "TimeoutError") { sendJson(response, 504, "语音服务响应超时"); return; } console.error("[Agent TTS] 语音生成失败", error); sendJson(response, 502, "语音生成失败"); } finally { if (timeout) clearTimeout(timeout); } }; }