diff --git a/extensions/microsoft/tts.test.ts b/extensions/microsoft/tts.test.ts index 74438a5a0d4f..c8b01754b919 100644 --- a/extensions/microsoft/tts.test.ts +++ b/extensions/microsoft/tts.test.ts @@ -6,21 +6,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; let edgeTTS: typeof import("./tts.js").edgeTTS; -function createEdgeTTSDeps( - ttsPromise: (text: string, filePath: string) => Promise, - onConstruct?: () => void, -) { - return { - EdgeTTS: class { - constructor() { - onConstruct?.(); - } - - ttsPromise(text: string, filePath: string) { - return ttsPromise(text, filePath); - } - }, - }; +function createEdgeTTSClient(ttsPromise: (text: string, filePath: string) => Promise) { + return { ttsPromise }; } const baseEdgeConfig = { @@ -44,13 +31,12 @@ describe("edgeTTS empty audio validation", () => { } }); - it("rejects blank text before constructing Edge TTS", async () => { + it("rejects blank text before calling Edge TTS", async () => { tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-")); const outputPath = path.join(tempDir, "voice.mp3"); - const onConstruct = vi.fn(); - const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => { + const ttsPromise = vi.fn(async (_text: string, filePath: string) => { writeFileSync(filePath, Buffer.from([0xff])); - }, onConstruct); + }); await expect( edgeTTS( @@ -60,10 +46,10 @@ describe("edgeTTS empty audio validation", () => { config: baseEdgeConfig, timeoutMs: 10000, }, - deps, + createEdgeTTSClient(ttsPromise), ), ).rejects.toThrow("Microsoft TTS text cannot be empty"); - expect(onConstruct).not.toHaveBeenCalled(); + expect(ttsPromise).not.toHaveBeenCalled(); }); it("throws after one retry when the output file stays empty", async () => { @@ -71,7 +57,7 @@ describe("edgeTTS empty audio validation", () => { const outputPath = path.join(tempDir, "voice.mp3"); const calls: string[] = []; - const deps = createEdgeTTSDeps(async (text: string, filePath: string) => { + const tts = createEdgeTTSClient(async (text: string, filePath: string) => { calls.push(text); writeFileSync(filePath, ""); }); @@ -84,7 +70,7 @@ describe("edgeTTS empty audio validation", () => { config: baseEdgeConfig, timeoutMs: 10000, }, - deps, + tts, ), ).rejects.toThrow("Edge TTS produced empty audio file after retry"); expect(calls).toEqual(["Hello", "Hello"]); @@ -95,7 +81,7 @@ describe("edgeTTS empty audio validation", () => { const outputPath = path.join(tempDir, "voice.mp3"); let stagedPath = ""; - const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => { + const tts = createEdgeTTSClient(async (_text: string, filePath: string) => { stagedPath = filePath; writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); }); @@ -108,7 +94,7 @@ describe("edgeTTS empty audio validation", () => { config: baseEdgeConfig, timeoutMs: 10000, }, - deps, + tts, ), ).resolves.toBeUndefined(); expect(stagedPath).not.toBe(outputPath); @@ -123,7 +109,7 @@ describe("edgeTTS empty audio validation", () => { const outputPath = path.join(tempDir, "voice.mp3"); const calls: string[] = []; - const deps = createEdgeTTSDeps(async (text: string, filePath: string) => { + const tts = createEdgeTTSClient(async (text: string, filePath: string) => { calls.push(text); writeFileSync(filePath, calls.length === 1 ? "" : Buffer.from([0xff, 0xfb, 0x90, 0x00])); }); @@ -136,7 +122,7 @@ describe("edgeTTS empty audio validation", () => { config: baseEdgeConfig, timeoutMs: 10000, }, - deps, + tts, ), ).resolves.toBeUndefined(); expect(calls).toEqual(["Hello", "Hello"]); @@ -147,7 +133,7 @@ describe("edgeTTS empty audio validation", () => { const outputPath = path.join(tempDir, "voice.mp3"); const calls: string[] = []; - const deps = createEdgeTTSDeps(async (text: string, filePath: string) => { + const tts = createEdgeTTSClient(async (text: string, filePath: string) => { calls.push(text); if (calls.length === 2) { writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); @@ -162,7 +148,7 @@ describe("edgeTTS empty audio validation", () => { config: baseEdgeConfig, timeoutMs: 10000, }, - deps, + tts, ), ).resolves.toBeUndefined(); expect(calls).toEqual(["Hello", "Hello"]); @@ -173,7 +159,7 @@ describe("edgeTTS empty audio validation", () => { const outputPath = path.join(tempDir, "voice.mp3"); const calls: string[] = []; - const deps = createEdgeTTSDeps(async (text: string) => { + const tts = createEdgeTTSClient(async (text: string) => { calls.push(text); throw new Error("upstream timeout"); }); @@ -186,7 +172,7 @@ describe("edgeTTS empty audio validation", () => { config: baseEdgeConfig, timeoutMs: 10000, }, - deps, + tts, ), ).rejects.toThrow("upstream timeout"); expect(calls).toEqual(["Hello"]); diff --git a/extensions/microsoft/tts.ts b/extensions/microsoft/tts.ts index 37304ed0b018..e925626ece31 100644 --- a/extensions/microsoft/tts.ts +++ b/extensions/microsoft/tts.ts @@ -5,31 +5,7 @@ import path from "node:path"; import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -type EdgeTTSDeps = { - EdgeTTS: new ( - ...args: ConstructorParameters - ) => Pick; -}; - -function isMissingOutputFileError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === "ENOENT" - ); -} - -function readOutputSize(outputPath: string): number { - try { - return statSync(outputPath).size; - } catch (error) { - if (isMissingOutputFileError(error)) { - return 0; - } - throw error; - } -} +type EdgeTTSClient = Pick; export function inferEdgeExtension(outputFormat: string): string { const normalized = normalizeLowercaseStringOrEmpty(outputFormat); @@ -65,31 +41,37 @@ export async function edgeTTS( }; timeoutMs: number; }, - deps?: EdgeTTSDeps, + ttsOverride?: EdgeTTSClient, ): Promise { const { text, outputPath, config, timeoutMs } = params; if (text.trim().length === 0) { throw new Error("Microsoft TTS text cannot be empty"); } - const EdgeTTSClass = deps?.EdgeTTS ?? (await import("node-edge-tts")).EdgeTTS; - const tts = new EdgeTTSClass({ - voice: config.voice, - lang: config.lang, - outputFormat: config.outputFormat, - saveSubtitles: config.saveSubtitles, - proxy: config.proxy, - rate: config.rate, - pitch: config.pitch, - volume: config.volume, - timeout: config.timeoutMs ?? timeoutMs, - }); + const tts = + ttsOverride ?? + new (await import("node-edge-tts")).EdgeTTS({ + voice: config.voice, + lang: config.lang, + outputFormat: config.outputFormat, + saveSubtitles: config.saveSubtitles, + proxy: config.proxy, + rate: config.rate, + pitch: config.pitch, + volume: config.volume, + timeout: config.timeoutMs ?? timeoutMs, + }); + await mkdir(path.dirname(outputPath), { recursive: true }); for (let attempt = 0; attempt < 2; attempt += 1) { - const outputSize = await writeEdgeTtsOutput({ - outputPath, - ttsPromise: async (tempPath) => { + let outputSize = 0; + await writeExternalFileWithinRoot({ + rootDir: path.dirname(outputPath), + path: path.basename(outputPath), + write: async (tempPath) => { + writeFileSync(tempPath, ""); await tts.ttsPromise(text, tempPath); + outputSize = statSync(tempPath).size; }, }); if (outputSize > 0) { @@ -98,24 +80,3 @@ export async function edgeTTS( } throw new Error("Edge TTS produced empty audio file after retry"); } - -async function writeEdgeTtsOutput(params: { - outputPath: string; - ttsPromise: (tempPath: string) => Promise; -}): Promise { - const rootDir = path.dirname(params.outputPath); - await mkdir(rootDir, { recursive: true }); - let outputSize = 0; - await writeExternalFileWithinRoot({ - rootDir, - path: path.basename(params.outputPath), - write: async (tempPath) => { - await params.ttsPromise(tempPath); - outputSize = readOutputSize(tempPath); - if (outputSize === 0) { - writeFileSync(tempPath, ""); - } - }, - }); - return outputSize; -}