feat: add edge tts service wrapper

This commit is contained in:
DIYgod 2026-04-10 10:49:44 +08:00
parent 45f8164e26
commit 5abc2a1e22
2 changed files with 176 additions and 0 deletions

48
src/lib/tts.ts Normal file
View File

@ -0,0 +1,48 @@
import { Communicate, listVoices, type Voice } from "edge-tts-universal/isomorphic";
export const DEFAULT_VOICE = "zh-CN-Xiaoxiao:DragonHDFlashLatestNeural";
export type TtsInput = {
text: string;
voice?: string;
};
export async function createAudioStream({
text,
voice,
}: TtsInput): Promise<ReadableStream<Uint8Array>> {
const iterator = new Communicate(text, {
voice: voice ?? DEFAULT_VOICE,
}).stream();
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
while (true) {
const { done, value } = await iterator.next();
if (done) {
controller.close();
return;
}
if (value.type === "audio" && value.data) {
controller.enqueue(value.data);
return;
}
}
} catch (error) {
controller.error(error);
}
},
async cancel() {
if (typeof iterator.return === "function") {
await iterator.return();
}
},
});
}
export async function getVoices(): Promise<Voice[]> {
return listVoices();
}

128
test/tts.spec.ts Normal file
View File

@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { communicateConstructor, streamFactory, listVoicesMock } = vi.hoisted(() => ({
communicateConstructor: vi.fn(),
streamFactory: vi.fn(),
listVoicesMock: vi.fn(),
}));
vi.mock("edge-tts-universal/isomorphic", () => {
class Communicate {
constructor(text: string, options: { voice?: string }) {
communicateConstructor(text, options);
}
stream() {
return streamFactory();
}
}
return {
Communicate,
listVoices: listVoicesMock,
};
});
import { DEFAULT_VOICE, createAudioStream, getVoices } from "../src/lib/tts";
function makeChunkStream(chunks: Array<Record<string, unknown>>) {
return (async function* () {
for (const chunk of chunks) {
yield chunk;
}
})();
}
async function readAll(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return merged;
}
describe("createAudioStream", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the default voice and forwards only audio chunks", async () => {
streamFactory.mockReturnValueOnce(
makeChunkStream([
{ type: "WordBoundary", text: "hello" },
{ type: "audio", data: new Uint8Array([1, 2]) },
{ type: "SentenceBoundary", text: "hello" },
{ type: "audio", data: new Uint8Array([3, 4]) },
])
);
const stream = await createAudioStream({ text: "hello" });
expect(communicateConstructor).toHaveBeenCalledWith("hello", {
voice: DEFAULT_VOICE,
});
const bytes = await readAll(stream);
expect(Array.from(bytes)).toEqual([1, 2, 3, 4]);
});
it("passes through an explicit voice", async () => {
streamFactory.mockReturnValueOnce(
makeChunkStream([{ type: "audio", data: new Uint8Array([9]) }])
);
const stream = await createAudioStream({
text: "hello",
voice: "en-US-EmmaMultilingualNeural",
});
await readAll(stream);
expect(communicateConstructor).toHaveBeenCalledWith("hello", {
voice: "en-US-EmmaMultilingualNeural",
});
});
});
describe("getVoices", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the vendor voice list", async () => {
listVoicesMock.mockResolvedValueOnce([
{
Name: "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)",
ShortName: "zh-CN-XiaoxiaoNeural",
Gender: "Female",
Locale: "zh-CN",
SuggestedCodec: "audio-24khz-48kbitrate-mono-mp3",
FriendlyName: "Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)",
Status: "GA",
VoiceTag: {
ContentCategories: ["General"],
VoicePersonalities: ["Friendly"],
},
},
]);
const voices = await getVoices();
expect(voices).toHaveLength(1);
expect(voices[0]?.ShortName).toBe("zh-CN-XiaoxiaoNeural");
});
});