feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user