refactor(microsoft): simplify Edge TTS output staging

This commit is contained in:
Peter Steinberger
2026-07-13 21:03:51 -07:00
parent 63c7c21bdf
commit c853064a42
2 changed files with 40 additions and 93 deletions
+17 -31
View File
@@ -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<void>,
onConstruct?: () => void,
) {
return {
EdgeTTS: class {
constructor() {
onConstruct?.();
}
ttsPromise(text: string, filePath: string) {
return ttsPromise(text, filePath);
}
},
};
function createEdgeTTSClient(ttsPromise: (text: string, filePath: string) => Promise<void>) {
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"]);
+23 -62
View File
@@ -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<typeof import("node-edge-tts").EdgeTTS>
) => Pick<import("node-edge-tts").EdgeTTS, "ttsPromise">;
};
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<import("node-edge-tts").EdgeTTS, "ttsPromise">;
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<void> {
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<void>;
}): Promise<number> {
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;
}