feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
+53
View File
@@ -7,6 +7,13 @@ import {
type EdgeTtsMiddlewareOptions,
type EdgeTtsSynthesizer
} from "./edge-tts-service";
import type { SpeechVoice } from "../src/shared/tts/voices";
const voices: SpeechVoice[] = [
{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao", locale: "zh-CN", gender: "Female" },
{ id: "zh-CN-YunyangNeural", name: "Yunyang", locale: "zh-CN", gender: "Male" },
{ id: "zh-CN-XiaoyiNeural", name: "Xiaoyi", locale: "zh-CN", gender: "Female" }
];
const servers: ReturnType<typeof createServer>[] = [];
@@ -39,6 +46,52 @@ function postSpeech(baseUrl: string, body: unknown, headers = true) {
}
describe("Edge TTS adapter", () => {
it("falls back to Mandarin when a non-Mandarin default is configured", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ defaultVoice: "en-US-AriaNeural", synthesize });
expect((await postSpeech(baseUrl, { text: "测试" })).status).toBe(200);
expect(synthesize).toHaveBeenCalledWith("测试", "zh-CN-XiaoxiaoNeural");
});
it("returns all voices and the default, sharing the catalog with selection validation", async () => {
const listVoices = vi.fn(async () => voices);
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices, synthesize, defaultVoice: voices[0].id });
const response = await fetch(`${baseUrl}/api/tts/edge/voices`);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ voices, defaultVoice: voices[0].id });
expect((await postSpeech(baseUrl, { text: "测试", voice: voices[1].id })).status).toBe(200);
expect(synthesize).toHaveBeenLastCalledWith("测试", voices[1].id);
expect((await postSpeech(baseUrl, { text: "test", voice: voices[2].id })).status).toBe(200);
expect(synthesize).toHaveBeenLastCalledWith("test", voices[2].id);
expect(listVoices).toHaveBeenCalledOnce();
});
it("rejects unknown and malformed voice choices before synthesis", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices: async () => voices, synthesize });
for (const voice of ["zh-CN-UnknownNeural", "en-US-AriaNeural", "zh-TW-HsiaoChenNeural", "zh-CN-liaoning-XiaobeiNeural", "<voice name='bad'>", null, 12, {}]) {
expect((await postSpeech(baseUrl, { text: "测试", voice })).status).toBe(400);
}
expect(synthesize).not.toHaveBeenCalled();
});
it("can use the default when the catalog is unavailable and retry a failed catalog load", async () => {
const listVoices = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(voices);
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices, synthesize });
expect((await fetch(`${baseUrl}/api/tts/edge/voices`)).status).toBe(503);
expect((await postSpeech(baseUrl, { text: "默认" })).status).toBe(200);
expect((await fetch(`${baseUrl}/api/tts/edge/voices`)).status).toBe(200);
expect(listVoices).toHaveBeenCalledTimes(2);
});
it("refuses a chosen voice when its availability cannot be verified", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([1]));
const baseUrl = await startServer({ listVoices: async () => { throw new Error("offline"); }, synthesize });
expect((await postSpeech(baseUrl, { text: "测试", voice: voices[1].id })).status).toBe(503);
expect(synthesize).not.toHaveBeenCalled();
});
it("returns synthesized MP3 audio with the server-owned voice", async () => {
const synthesize = vi.fn<EdgeTtsSynthesizer>(async () => Uint8Array.from([73, 68, 51]));
const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" });
+54 -17
View File
@@ -1,7 +1,10 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Communicate } from "edge-tts-ts";
import { EdgeTTS } from "node-edge-tts";
import { speechVoiceIdSchema, type SpeechVoice } from "../src/shared/tts/voices";
import { createEdgeTtsVoiceCatalog } from "./edge-tts-voices";
const EDGE_TTS_PATH = "/api/tts/edge";
const EDGE_TTS_VOICES_PATH = `${EDGE_TTS_PATH}/voices`;
const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural";
const MAX_REQUEST_BYTES = 8 * 1024;
const MAX_TEXT_LENGTH = 520;
@@ -32,6 +35,7 @@ export type EdgeTtsMiddlewareOptions = {
synthesize?: EdgeTtsSynthesizer;
timeoutMs?: number;
trustProxy?: boolean;
listVoices?: () => Promise<SpeechVoice[]>;
};
function sendJson(response: ServerResponse, status: number, message: string) {
@@ -76,14 +80,15 @@ function getClientKey(request: IncomingMessage, trustProxy: boolean) {
}
export async function synthesizeEdgeSpeech(text: string, voice: string): Promise<Uint8Array> {
const communicate = new Communicate(text, { voice });
const communicate = new EdgeTTS({ voice, timeout: DEFAULT_TIMEOUT_MS });
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;
for await (const chunk of communicate.streamAudio(text)) {
if (!Buffer.isBuffer(chunk)) continue;
chunks.push(chunk);
byteLength += chunk.byteLength;
if (byteLength > MAX_AUDIO_BYTES) throw new Error("语音服务返回的音频过大");
}
if (!byteLength) throw new Error("语音服务返回了空音频");
@@ -99,27 +104,32 @@ export async function synthesizeEdgeSpeech(text: string, voice: string): Promise
export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) {
const synthesize = options.synthesize ?? synthesizeEdgeSpeech;
const defaultVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE;
const configuredVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE;
const defaultVoice = speechVoiceIdSchema.safeParse(configuredVoice).success ? configuredVoice : 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 listVoices = createEdgeTtsVoiceCatalog(options.listVoices, 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) {
const path = request.url?.split("?", 1)[0];
const voicesRequest = path === EDGE_TTS_VOICES_PATH;
if (path !== EDGE_TTS_PATH && !voicesRequest) {
next?.();
return;
}
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
sendJson(response, 405, "仅支持 POST 请求");
const method = voicesRequest ? "GET" : "POST";
if (request.method !== method) {
response.setHeader("Allow", method);
sendJson(response, 405, `仅支持 ${method} 请求`);
return;
}
if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
if (!voicesRequest && !request.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
sendJson(response, 415, "请求必须使用 application/json");
return;
}
@@ -143,6 +153,19 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
return;
}
if (voicesRequest) {
try {
const voices = await listVoices();
response.statusCode = 200;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify({ voices, defaultVoice }));
} catch {
sendJson(response, 503, "音色列表暂时不可用,请稍后重试");
}
return;
}
let payload: EdgeTtsPayload;
try {
payload = await readJson(request);
@@ -151,10 +174,6 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
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, "缺少待朗读文本");
@@ -164,6 +183,24 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH} 字`);
return;
}
let voice = defaultVoice;
if (Object.prototype.hasOwnProperty.call(payload, "voice")) {
const selectedVoice = speechVoiceIdSchema.safeParse(payload.voice);
if (!selectedVoice.success) {
sendJson(response, 400, "请选择有效音色");
return;
}
try {
if (!(await listVoices()).some((item) => item.id === selectedVoice.data)) {
sendJson(response, 400, "所选音色不可用,请重新选择");
return;
}
voice = selectedVoice.data;
} catch {
sendJson(response, 503, "无法确认音色是否可用,请稍后重试");
return;
}
}
if (activeRequests >= maxConcurrent) {
response.setHeader("Retry-After", "1");
sendJson(response, 429, "语音服务繁忙,请稍后重试");
@@ -171,7 +208,7 @@ export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {})
}
activeRequests += 1;
const synthesisTask = Promise.resolve().then(() => synthesize(text, defaultVoice));
const synthesisTask = Promise.resolve().then(() => synthesize(text, voice));
void synthesisTask.finally(() => {
activeRequests -= 1;
}).catch(() => undefined);
+42
View File
@@ -0,0 +1,42 @@
import { EdgeTTS } from "node-edge-tts";
import { afterEach, expect, it, vi } from "vitest";
import { synthesizeEdgeSpeech } from "./edge-tts-service";
afterEach(() => vi.restoreAllMocks());
it("collects streamed MP3 bytes with the configured voice and a bounded timeout", async () => {
const stream = vi.spyOn(EdgeTTS.prototype, "streamAudio").mockImplementation(async function* (this: EdgeTTS) {
expect(this).toMatchObject({ voice: "zh-CN-XiaoxiaoNeural", timeout: 30_000 });
yield Buffer.from([73, 68]);
yield { part: "测试", start: 0, end: 1 };
yield Buffer.from([51]);
});
expect(await synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural"))
.toEqual(Uint8Array.from([73, 68, 51]));
expect(stream).toHaveBeenCalledWith("测试");
});
it("rejects empty audio and propagates upstream failures", async () => {
const stream = vi.spyOn(EdgeTTS.prototype, "streamAudio").mockImplementation(async function* () {});
await expect(synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural")).rejects.toThrow("空音频");
stream.mockImplementation(async function* () {
yield { part: "测试", start: 0, end: 1 };
throw new Error("upstream unavailable");
});
await expect(synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural"))
.rejects.toThrow("upstream unavailable");
});
it("stops consuming an oversized audio stream", async () => {
const closed = vi.fn();
vi.spyOn(EdgeTTS.prototype, "streamAudio").mockImplementation(async function* () {
try {
yield Buffer.alloc(5 * 1024 * 1024 + 1);
throw new Error("must stop before consuming more audio");
} finally {
closed();
}
});
await expect(synthesizeEdgeSpeech("测试", "zh-CN-XiaoxiaoNeural")).rejects.toThrow("音频过大");
expect(closed).toHaveBeenCalledOnce();
});
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, expect, it, vi } from "vitest";
import { createEdgeTtsVoiceCatalog, fetchEdgeTtsVoices } from "./edge-tts-voices";
afterEach(() => vi.unstubAllGlobals());
it("deduplicates simultaneous catalog loads and refreshes after one hour", async () => {
let now = 0;
const load = vi.fn(async () => [{ id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao", locale: "zh-CN" as const, gender: "Female" as const }]);
const catalog = createEdgeTtsVoiceCatalog(load, () => now);
const [first, second] = await Promise.all([catalog(), catalog()]);
expect(first).toBe(second);
expect(await catalog()).toBe(first);
expect(load).toHaveBeenCalledOnce();
now = 3_600_001;
await catalog();
expect(load).toHaveBeenCalledTimes(2);
});
it("requests v2 authentication and only exposes validated mainland Mandarin voices", async () => {
const upstream = [
{ ShortName: "zh-CN-XiaoxiaoNeural", FriendlyName: "Xiaoxiao", Locale: "zh-CN", Gender: "Female" },
{ ShortName: "en-US-AriaNeural", FriendlyName: "Aria", Locale: "en-US", Gender: "Female" },
{ ShortName: "zh-HK-HiuMaanNeural", FriendlyName: "HiuMaan", Locale: "zh-HK", Gender: "Female" },
{ ShortName: "zh-TW-HsiaoChenNeural", FriendlyName: "HsiaoChen", Locale: "zh-TW", Gender: "Female" },
{ ShortName: "zh-CN-liaoning-XiaobeiNeural", FriendlyName: "Xiaobei", Locale: "zh-CN-liaoning", Gender: "Female" }
];
const fetchMock = vi.fn(async () => new Response(JSON.stringify(upstream)));
vi.stubGlobal("fetch", fetchMock);
expect(await fetchEdgeTtsVoices()).toEqual([{ id: upstream[0].ShortName, name: "Xiaoxiao", locale: "zh-CN", gender: "Female" }]);
const [url] = fetchMock.mock.calls[0] as unknown as [URL];
expect(url.searchParams.get("Sec-MS-GEC-Version")).toMatch(/^2-/);
expect(url.searchParams.get("Sec-MS-GEC")).toMatch(/^[0-9A-F]{16}-[0-9A-F]{64}$/);
fetchMock.mockImplementation(async () => new Response(JSON.stringify([{ ...upstream[0], ShortName: "bad<xml>" }])));
await expect(fetchEdgeTtsVoices()).rejects.toThrow();
});
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
import { generateSecMsGecToken_V2, SEC_MS_GEC_VERSION_V2, TRUSTED_CLIENT_TOKEN } from "node-edge-tts/dist/drm";
import { speechVoiceSchema, type SpeechVoice } from "../src/shared/tts/voices";
const upstreamVoicesSchema = z.array(z.object({
ShortName: z.string().min(1).max(120),
FriendlyName: z.string().min(1).max(200),
Locale: z.string().min(2).max(40),
Gender: z.enum(["Female", "Male"])
})).min(1).max(2_000);
export async function fetchEdgeTtsVoices(): Promise<SpeechVoice[]> {
const url = new URL("https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list");
url.searchParams.set("trustedclienttoken", TRUSTED_CLIENT_TOKEN);
url.searchParams.set("Sec-MS-GEC", generateSecMsGecToken_V2());
url.searchParams.set("Sec-MS-GEC-Version", SEC_MS_GEC_VERSION_V2);
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`Edge TTS voice catalog returned ${response.status}`);
const voices = upstreamVoicesSchema.parse(await response.json()).filter((voice) => voice.Locale === "zh-CN");
if (!voices.length) throw new Error("Edge TTS returned no Mandarin voices");
return voices.map((voice) => speechVoiceSchema.parse({
id: voice.ShortName, name: voice.FriendlyName, locale: voice.Locale, gender: voice.Gender
}));
}
export function createEdgeTtsVoiceCatalog(
load: () => Promise<SpeechVoice[]> = fetchEdgeTtsVoices,
now: () => number = Date.now
) {
let cached: SpeechVoice[] | undefined;
let expiresAt = 0;
let pending: Promise<SpeechVoice[]> | undefined;
return () => {
if (cached && now() < expiresAt) return Promise.resolve(cached);
if (!pending) {
pending = Promise.resolve().then(load).then((voices) => {
cached = voices;
expiresAt = now() + 60 * 60 * 1_000;
return voices;
}).finally(() => { pending = undefined; });
}
return pending;
};
}