mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor(microsoft): simplify Edge TTS output staging
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user