// Tts Local Cli tests cover speech provider plugin behavior. import { mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { SpeechProviderConfig, SpeechSynthesisRequest } from "openclaw/plugin-sdk/speech-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; type SpeechSynthesisTarget = SpeechSynthesisRequest["target"]; const runFfmpegMock = vi.hoisted(() => vi.fn<(args: string[]) => Promise>()); const debugLogMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ runFfmpeg: runFfmpegMock, })); vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ createSubsystemLogger: () => ({ debug: debugLogMock }), })); import { buildCliSpeechProvider } from "./speech-provider.js"; const TEST_CFG = {} as OpenClawConfig; const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024; function createCliFixture(): { dir: string; script: string } { const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-tts-test-")); const script = path.join(dir, "write-audio.mjs"); writeFileSync( script, ` import { writeFileSync } from "node:fs"; const outIndex = process.argv.indexOf("--out"); const outputPath = outIndex >= 0 ? process.argv[outIndex + 1] : ""; const textIndex = process.argv.indexOf("--text"); const textArg = textIndex >= 0 ? process.argv[textIndex + 1] : ""; const stdin = await new Promise((resolve) => { let data = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { data += chunk; }); process.stdin.on("end", () => resolve(data)); }); const payload = Buffer.from(JSON.stringify({ args: process.argv.slice(2), stdin, textArg })); if (outputPath) { writeFileSync(outputPath, payload); } else { process.stdout.write(payload); } `, ); return { dir, script }; } function baseProviderConfig( script: string, overrides: SpeechProviderConfig = {}, ): SpeechProviderConfig { return { command: process.execPath, args: [script], timeoutMs: 1000, ...overrides, }; } async function synthesize(params: { providerConfig: SpeechProviderConfig; text?: string; target?: SpeechSynthesisTarget; }) { return await buildCliSpeechProvider().synthesize({ text: params.text ?? "hello world", cfg: TEST_CFG, providerConfig: params.providerConfig, providerOverrides: {}, timeoutMs: 1000, target: params.target ?? "audio-file", }); } function parseAudioPayload(result: { audioBuffer: Buffer }) { return JSON.parse(result.audioBuffer.toString("utf8")) as { stdin?: string; textArg?: string; }; } function requireFfmpegArgs(index = 0) { const args = runFfmpegMock.mock.calls[index]?.[0]; if (!args) { throw new Error(`runFfmpeg call ${index} missing`); } return args; } function expectArgsContainSequence(args: string[], sequence: string[]) { const startIndex = args.findIndex((arg, index) => sequence.every((expected, offset) => args[index + offset] === expected), ); expect(startIndex).toBeGreaterThanOrEqual(0); } describe("buildCliSpeechProvider", () => { beforeEach(() => { runFfmpegMock.mockImplementation(async (args) => { const outputPath = args.at(-1); if (typeof outputPath !== "string") { throw new Error("missing ffmpeg output path"); } const stagedTarget = outputPath.endsWith(".part") ? outputPath.slice(0, -".part".length) : outputPath; const forcedFormatIndex = args.lastIndexOf("-f"); const forcedFormat = forcedFormatIndex >= 0 && typeof args[forcedFormatIndex + 1] === "string" ? args[forcedFormatIndex + 1] : undefined; const extension = forcedFormat === "s16le" ? ".pcm" : forcedFormat ? `.${forcedFormat}` : path.extname(stagedTarget); writeFileSync(outputPath, Buffer.from(`converted:${extension}`)); }); }); afterEach(() => { vi.clearAllMocks(); }); it("prefers canonical provider config over the cli alias", () => { const provider = buildCliSpeechProvider(); expect( provider.resolveConfig?.({ cfg: TEST_CFG, rawConfig: { providers: { cli: { command: "alias-command" }, "tts-local-cli": { command: "canonical-command" }, }, }, timeoutMs: 1000, }), ).toEqual({ command: "canonical-command" }); }); it("passes text through stdin when args omit the text template", async () => { const fixture = createCliFixture(); try { const result = await synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "mp3", }), text: "hello 😀 world", }); expect(result.outputFormat).toBe("mp3"); expect(result.fileExtension).toBe(".mp3"); expect(result.voiceCompatible).toBe(false); const audioPayload = parseAudioPayload(result); expect(audioPayload.stdin).toBe("hello world"); expect(audioPayload.textArg).toBe(""); expect(runFfmpegMock).not.toHaveBeenCalled(); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("uses template args and stdout output when no output file is produced", async () => { const fixture = createCliFixture(); try { const result = await synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--text", "{{Text}}"], outputFormat: "wav", }), text: "spoken words", }); expect(result.outputFormat).toBe("wav"); expect(result.fileExtension).toBe(".wav"); expect(result.voiceCompatible).toBe(false); const audioPayload = parseAudioPayload(result); expect(audioPayload.stdin).toBe(""); expect(audioPayload.textArg).toBe("spoken words"); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("converts non-opus output for voice-note targets", async () => { const fixture = createCliFixture(); try { const result = await synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "mp3", }), target: "voice-note", }); expect(result).toEqual({ audioBuffer: Buffer.from("converted:.opus"), outputFormat: "opus", fileExtension: ".ogg", voiceCompatible: true, }); expectArgsContainSequence(requireFfmpegArgs(), ["-c:a", "libopus", "-b:a", "64k"]); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("converts stdout WAV to the requested audio-file format", async () => { const fixture = createCliFixture(); try { const result = await synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--text", "{{Text}}"], outputFormat: "mp3", }), }); expect(result).toEqual({ audioBuffer: Buffer.from("converted:.mp3"), outputFormat: "mp3", fileExtension: ".mp3", voiceCompatible: false, }); expectArgsContainSequence(requireFfmpegArgs(), ["-c:a", "libmp3lame", "-b:a", "128k"]); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("converts CLI output to raw telephony PCM", async () => { const fixture = createCliFixture(); try { const result = await buildCliSpeechProvider().synthesizeTelephony?.({ text: "phone reply", cfg: TEST_CFG, providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "wav", }), timeoutMs: 1000, }); expect(result).toEqual({ audioBuffer: Buffer.from("converted:.pcm"), outputFormat: "pcm", sampleRate: 16000, }); expectArgsContainSequence(requireFfmpegArgs(), ["-ar", "16000", "-ac", "1", "-f", "s16le"]); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("rejects oversized CLI output files before reading them", async () => { const fixture = createCliFixture(); try { writeFileSync( fixture.script, ` import { truncateSync, writeFileSync } from "node:fs"; const outIndex = process.argv.indexOf("--out"); const outputPath = process.argv[outIndex + 1]; writeFileSync(outputPath, ""); truncateSync(outputPath, ${MAX_AUDIO_OUTPUT_BYTES + 1}); `, ); await expect( synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "wav", }), }), ).rejects.toThrow(`File exceeds ${MAX_AUDIO_OUTPUT_BYTES} bytes`); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it("rejects non-file CLI output artifacts", async () => { const fixture = createCliFixture(); try { writeFileSync( fixture.script, ` import { mkdirSync } from "node:fs"; const outIndex = process.argv.indexOf("--out"); mkdirSync(process.argv[outIndex + 1]); `, ); await expect( synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "wav", }), }), ).rejects.toThrow("path must be a regular file"); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); it.each(["voice-note", "telephony"] as const)( "rejects oversized ffmpeg output for %s synthesis", async (mode) => { const fixture = createCliFixture(); runFfmpegMock.mockImplementation(async (args) => { const outputPath = args.at(-1); if (typeof outputPath !== "string") { throw new Error("missing ffmpeg output path"); } writeFileSync(outputPath, ""); truncateSync(outputPath, MAX_AUDIO_OUTPUT_BYTES + 1); }); try { const providerConfig = baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "wav", }); const run = mode === "voice-note" ? synthesize({ providerConfig, target: "voice-note" }) : buildCliSpeechProvider().synthesizeTelephony?.({ text: "phone reply", cfg: TEST_CFG, providerConfig, providerOverrides: {}, timeoutMs: 1000, }); await expect(run).rejects.toThrow(`File exceeds ${MAX_AUDIO_OUTPUT_BYTES} bytes`); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }, ); it.each(["synthesize", "synthesizeTelephony"] as const)( "keeps %s debug previews free of lone surrogates", async (method) => { const text = `${"a".repeat(49)}😀tail`; const providerConfig = { command: "missing-openclaw-tts-test-command" }; const run = method === "synthesize" ? synthesize({ providerConfig, text }) : buildCliSpeechProvider().synthesizeTelephony?.({ text, cfg: TEST_CFG, providerConfig, timeoutMs: 1000, }); await expect(run).rejects.toThrow(); const preview = String(debugLogMock.mock.calls[0]?.[0]); expect(Buffer.from(preview).toString()).toBe(preview); }, ); it("can synthesize through a real local CLI fixture and ffmpeg", async () => { if (process.env.OPENCLAW_LIVE_TEST !== "1") { return; } const fixture = createCliFixture(); const rawFfmpeg = await vi.importActual( "openclaw/plugin-sdk/media-runtime", ); runFfmpegMock.mockImplementation(async (args) => { await rawFfmpeg.runFfmpeg(args); }); try { const wavPath = path.join(fixture.dir, "source.wav"); await rawFfmpeg.runFfmpeg([ "-y", "-f", "lavfi", "-i", "sine=frequency=660:duration=0.1", "-c:a", "pcm_s16le", wavPath, ]); writeFileSync( fixture.script, ` import { copyFileSync } from "node:fs"; const outIndex = process.argv.indexOf("--out"); copyFileSync(${JSON.stringify(wavPath)}, process.argv[outIndex + 1]); `, ); const result = await synthesize({ providerConfig: baseProviderConfig(fixture.script, { args: [fixture.script, "--out", "{{OutputPath}}"], outputFormat: "wav", }), target: "voice-note", }); expect(result.outputFormat).toBe("opus"); expect(result.fileExtension).toBe(".ogg"); expect(result.voiceCompatible).toBe(true); expect(result.audioBuffer.byteLength).toBeGreaterThan(0); expect(readFileSync(wavPath).byteLength).toBeGreaterThan(0); } finally { rmSync(fixture.dir, { recursive: true, force: true }); } }); });