mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
5f958cf8e6
* fix(tts): report actual audio output formats Detect local CLI audio containers before conversion and derive ElevenLabs delivery metadata from the effective output format. This prevents MP3 or PCM bytes from being persisted or delivered under incompatible file and voice metadata. * fix(tts-local-cli): distinguish Ogg Opus audio Treat Ogg as a source container unless its first packet is OpusHead, so Vorbis and M4A inputs are transcoded to the requested target instead of being mislabeled as native voice audio. * fix(tts-local-cli): validate MP3 frame headers Require valid MPEG version, layer, bitrate, and sample-rate fields before treating sync bytes as MP3, preventing reserved headers from bypassing conversion under MP3 metadata. * fix(tts-local-cli): validate tagged MP3 output Require structurally valid ID3v2 metadata followed by a strict MPEG frame, and stop trusting recognizable file extensions when their bytes do not match. Unknown or malformed output now fails closed instead of bypassing conversion. * fix(tts-local-cli): accept free-format MPEG audio Treat bitrate index zero as valid free-format MPEG audio and lock the ID3v2.4 footer offset contract with focused regression coverage. * fix(tts-local-cli): normalize all Ogg output Treat Ogg only as a source container and always transcode it to the requested target, so malformed or ambiguous Ogg bytes can never bypass conversion as native voice audio.
133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
// TTS local CLI tests cover the canonical process-wrapper contract.
|
|
import { writeFileSync } from "node:fs";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { runCommandBufferedMock } = vi.hoisted(() => ({ runCommandBufferedMock: vi.fn() }));
|
|
|
|
vi.mock("openclaw/plugin-sdk/process-runtime", () => ({
|
|
runCommandBuffered: runCommandBufferedMock,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
|
runFfmpeg: vi.fn(),
|
|
}));
|
|
|
|
import { buildCliSpeechProvider } from "./speech-provider.js";
|
|
|
|
const TEST_CFG = {} as OpenClawConfig;
|
|
const MIB = 1024 * 1024;
|
|
const WAV_AUDIO = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WAVEaudio")]);
|
|
|
|
function commandResult(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
code: 0,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit",
|
|
stdout: WAV_AUDIO,
|
|
stderr: Buffer.alloc(0),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function synthesize(args = ["--voice", "test"]) {
|
|
return await buildCliSpeechProvider().synthesize({
|
|
text: "hello",
|
|
cfg: TEST_CFG,
|
|
providerConfig: {
|
|
command: "/fake/tts",
|
|
args,
|
|
outputFormat: "wav",
|
|
timeoutMs: 2_500,
|
|
},
|
|
providerOverrides: {},
|
|
timeoutMs: 2_500,
|
|
target: "audio-file",
|
|
});
|
|
}
|
|
|
|
describe("CLI TTS process wrapper", () => {
|
|
beforeEach(() => {
|
|
runCommandBufferedMock.mockReset();
|
|
runCommandBufferedMock.mockResolvedValue(commandResult());
|
|
});
|
|
|
|
it("uses Execa input, timeout, escalation, and asymmetric byte caps", async () => {
|
|
await expect(synthesize()).resolves.toMatchObject({ audioBuffer: WAV_AUDIO });
|
|
|
|
expect(runCommandBufferedMock).toHaveBeenCalledWith(
|
|
["/fake/tts", "--voice", "test"],
|
|
expect.objectContaining({
|
|
input: "hello",
|
|
maxOutputBytes: { stdout: 50 * MIB, stderr: MIB },
|
|
timeoutMs: 2_500,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("maps timeout and output-limit failures", async () => {
|
|
runCommandBufferedMock.mockResolvedValueOnce(
|
|
commandResult({ code: null, termination: "timeout" }),
|
|
);
|
|
await expect(synthesize()).rejects.toThrow("CLI TTS timed out after 2500ms");
|
|
|
|
runCommandBufferedMock.mockResolvedValueOnce(
|
|
commandResult({
|
|
code: 0,
|
|
termination: "output-limit",
|
|
outputLimitStream: "stderr",
|
|
}),
|
|
);
|
|
await expect(synthesize()).rejects.toThrow(`CLI TTS stderr exceeded ${MIB} bytes`);
|
|
});
|
|
|
|
it("keeps exit diagnostics", async () => {
|
|
runCommandBufferedMock.mockResolvedValueOnce(
|
|
commandResult({ code: 2, stderr: Buffer.from("bad voice") }),
|
|
);
|
|
|
|
await expect(synthesize()).rejects.toThrow("CLI TTS exit 2: bad voice");
|
|
});
|
|
|
|
it("rejects errored stdout but keeps a generated audio file authoritative", async () => {
|
|
const streamError = new Error("stdout stream failed");
|
|
runCommandBufferedMock.mockResolvedValueOnce(
|
|
commandResult({
|
|
code: null,
|
|
error: streamError,
|
|
errorStream: "stdout",
|
|
stdout: Buffer.from("partial"),
|
|
termination: "error",
|
|
}),
|
|
);
|
|
await expect(synthesize()).rejects.toThrow("CLI TTS failed: stdout stream failed");
|
|
|
|
runCommandBufferedMock.mockImplementationOnce(async (argv: string[]) => {
|
|
writeFileSync(argv[1]!, WAV_AUDIO);
|
|
return commandResult({
|
|
code: 0,
|
|
error: streamError,
|
|
stdout: Buffer.from("partial"),
|
|
termination: "error",
|
|
});
|
|
});
|
|
await expect(synthesize(["{{OutputPath}}"])).resolves.toMatchObject({
|
|
audioBuffer: WAV_AUDIO,
|
|
});
|
|
|
|
runCommandBufferedMock.mockResolvedValueOnce(
|
|
commandResult({
|
|
code: 0,
|
|
error: new Error("stderr stream failed"),
|
|
errorStream: "stderr",
|
|
stdout: WAV_AUDIO,
|
|
termination: "error",
|
|
}),
|
|
);
|
|
await expect(synthesize()).resolves.toMatchObject({
|
|
audioBuffer: WAV_AUDIO,
|
|
});
|
|
});
|
|
});
|