211 lines
7.2 KiB
TypeScript
211 lines
7.2 KiB
TypeScript
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<Uint8Array>;
|
|
|
|
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<EdgeTtsPayload> {
|
|
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<Uint8Array> {
|
|
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<string, RateBucket>();
|
|
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<typeof setTimeout> | undefined;
|
|
try {
|
|
const audio = await Promise.race([
|
|
synthesisTask,
|
|
new Promise<never>((_, 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);
|
|
}
|
|
};
|
|
}
|