diff --git a/extensions/xai/tts.test.ts b/extensions/xai/tts.test.ts index 6f447b26c89..388fd72bc72 100644 --- a/extensions/xai/tts.test.ts +++ b/extensions/xai/tts.test.ts @@ -281,6 +281,28 @@ describe("xai tts", () => { await result.release(); }); + it("errors and releases the stream for malformed audio base64", async () => { + const resultPromise = xaiTTSStream({ + text: "hello", + apiKey: "dummy", + baseUrl: XAI_BASE_URL, + voiceId: "eve", + timeoutMs: 5_000, + }); + const ws = FakeWebSocket.instances.at(0); + ws?.emit("open"); + const result = await resultPromise; + const reader = result.audioStream.getReader(); + + ws?.emit("message", JSON.stringify({ type: "audio.delta", delta: "not-base64!" })); + + await expect(reader.read()).rejects.toThrow( + "xAI TTS stream returned malformed base64 audio data", + ); + expect(ws?.readyState).toBe(FakeWebSocket.CLOSED); + await result.release(); + }); + it("splits text above xAI's 15,000-character limit into ordered delta frames", async () => { const text = `${"a".repeat(15_000)}${"b".repeat(15_000)}c`; const resultPromise = xaiTTSStream({ diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index 5f43bbf12fd..9e40bd3c690 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -1,4 +1,5 @@ // Xai plugin module implements tts behavior. +import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime"; import { assertOkOrThrowProviderError, postJsonRequest, @@ -375,7 +376,12 @@ export async function xaiTTSStream(params: { if (!encoded) { return; } - const chunk = Buffer.from(encoded, "base64"); + const canonicalAudio = canonicalizeBase64(encoded); + if (!canonicalAudio) { + failStream(new Error("xAI TTS stream returned malformed base64 audio data")); + return; + } + const chunk = Buffer.from(canonicalAudio, "base64"); totalBytes += chunk.length; if (totalBytes > maxBytes) { errorStream?.(new Error(`xAI TTS audio stream exceeds ${maxBytes} bytes`));