Files
openclaw/extensions/elevenlabs/speech-provider.test.ts
Peter Steinberger 5f958cf8e6 fix(tts): avoid mislabeled local and ElevenLabs audio (#125628)
* 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.
2026-08-18 03:24:44 -07:00

398 lines
13 KiB
TypeScript

// Elevenlabs tests cover speech provider plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { isValidElevenLabsVoiceId } from "./shared.js";
import { buildElevenLabsSpeechProvider } from "./speech-provider.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("./config-api.js", () => ({
resolveElevenLabsApiKeyWithProfileFallback: () => null,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: async (params: {
url: string;
init?: RequestInit;
timeoutMs?: number;
}): Promise<{ response: Response; release: () => Promise<void> }> => {
fetchWithSsrFGuardMock(params);
return {
response: await globalThis.fetch(params.url, params.init),
release: vi.fn(async () => {}),
};
},
ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined,
}));
function parseRequestBody(init: RequestInit | undefined): Record<string, unknown> {
if (typeof init?.body !== "string") {
throw new Error("expected string request body");
}
const body: unknown = JSON.parse(init.body);
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error("expected ElevenLabs request body");
}
return body as Record<string, unknown>;
}
const OUTPUT_FORMAT_CASES = [
{ outputFormat: "pcm_44100", fileExtension: ".pcm", voiceCompatible: false },
{ outputFormat: "OPUS_48000_64", fileExtension: ".opus", voiceCompatible: true },
{ outputFormat: "mp3_44100_128", fileExtension: ".mp3", voiceCompatible: false },
{ outputFormat: "ulaw_8000", fileExtension: ".ulaw", voiceCompatible: false },
{ outputFormat: "alaw_8000", fileExtension: ".alaw", voiceCompatible: false },
{ outputFormat: "wav_44100", fileExtension: ".wav", voiceCompatible: false },
{ outputFormat: "future_123", fileExtension: ".bin", voiceCompatible: false },
] as const;
describe("elevenlabs speech provider", () => {
const originalFetch = globalThis.fetch;
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
});
afterEach(() => {
globalThis.fetch = originalFetch;
fetchWithSsrFGuardMock.mockClear();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("exposes the current ElevenLabs TTS model catalog", () => {
const provider = buildElevenLabsSpeechProvider();
expect(provider.models).toEqual([
"eleven_v3",
"eleven_multilingual_v2",
"eleven_flash_v2_5",
"eleven_flash_v2",
"eleven_turbo_v2_5",
"eleven_monolingual_v1",
]);
});
it("forwards the core-resolved voice-list timeout", async () => {
globalThis.fetch = vi.fn(async () => Response.json({ voices: [] })) as unknown as typeof fetch;
const provider = buildElevenLabsSpeechProvider();
await provider.listVoices?.({
providerConfig: { apiKey: "xi-test" },
timeoutMs: 30_000,
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 30_000 }),
);
});
it("rejects blank credentials across discovery and synthesis before requests", async () => {
vi.stubEnv("ELEVENLABS_API_KEY", " ");
vi.stubEnv("XI_API_KEY", " ");
const provider = buildElevenLabsSpeechProvider();
const providerConfig = { apiKey: " " };
expect(provider.isConfigured({ providerConfig, timeoutMs: 1_000 })).toBe(false);
await expect(
provider.listVoices?.({ apiKey: " ", providerConfig, timeoutMs: 1_000 }),
).rejects.toThrow("ElevenLabs API key missing");
const request = {
text: "hello",
cfg: {} as never,
providerConfig,
target: "audio-file" as const,
timeoutMs: 1_000,
};
await expect(provider.synthesize(request)).rejects.toThrow("ElevenLabs API key missing");
await expect(provider.streamSynthesize?.(request)).rejects.toThrow(
"ElevenLabs API key missing",
);
await expect(provider.synthesizeTelephony?.(request)).rejects.toThrow(
"ElevenLabs API key missing",
);
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
});
it("keeps non-equivalent deprecated ElevenLabs TTS model IDs", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body.model_id).toBe("eleven_monolingual_v1");
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
modelId: "eleven_monolingual_v1",
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("maps deprecated ElevenLabs TTS model IDs in overrides", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body.model_id).toBe("eleven_flash_v2_5");
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
modelId: "eleven_multilingual_v2",
},
providerOverrides: {
modelId: "eleven_turbo_v2_5",
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("validates ElevenLabs voice ID length and character rules", () => {
const cases = [
{ value: "pMsXgVXv3BLzUgSXRplE", expected: true },
{ value: "21m00Tcm4TlvDq8ikWAM", expected: true },
{ value: "VoiceAlias1234567890", expected: true },
{ value: "a1b2c3d4e5", expected: true },
{ value: "a".repeat(40), expected: true },
{ value: "", expected: false },
{ value: "abc", expected: false },
{ value: "123456789", expected: false },
{ value: "a".repeat(41), expected: false },
{ value: "a".repeat(100), expected: false },
{ value: "pMsXgVXv3BLz-gSXRplE", expected: false },
{ value: "pMsXgVXv3BLz_gSXRplE", expected: false },
{ value: "pMsXgVXv3BLz gSXRplE", expected: false },
{ value: "../../../etc/passwd", expected: false },
{ value: "voice?param=value", expected: false },
] as const;
for (const testCase of cases) {
expect(isValidElevenLabsVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
}
});
it("applies provider overrides to telephony synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
expect(url).toContain("/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM");
expect(url).toContain("output_format=pcm_22050");
const body = parseRequestBody(init);
expect(body).toEqual({
text: "hello",
model_id: "eleven_v3",
seed: 123,
apply_text_normalization: "on",
language_code: "en",
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: 1.2,
},
});
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const result = await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2",
},
providerOverrides: {
voiceId: "21m00Tcm4TlvDq8ikWAM",
modelId: "eleven_v3",
seed: 123,
applyTextNormalization: "on",
languageCode: "en",
voiceSettings: {
speed: 1.2,
},
},
timeoutMs: 1_000,
});
expect(result?.outputFormat).toBe("pcm_22050");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("drops out-of-range voice settings before synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body.voice_settings).toEqual({
stability: 0.5,
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: 1,
});
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
voiceSettings: {
stability: -1,
similarityBoost: 2,
style: Number.NaN,
speed: 3,
},
},
providerOverrides: {
voiceSettings: {
speed: 0.1,
},
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("drops malformed seed values before synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body).not.toHaveProperty("seed");
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
seed: 1.5,
},
providerOverrides: {
seed: Number.POSITIVE_INFINITY,
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("drops malformed latency tier overrides before synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (url: string) => {
expect(new URL(url).searchParams.has("optimize_streaming_latency")).toBe(false);
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesize?.({
text: "hello",
target: "audio-file",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
},
providerOverrides: {
latencyTier: 2.5,
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each(OUTPUT_FORMAT_CASES)(
"returns truthful $outputFormat metadata for a voice-note override",
async ({ outputFormat, fileExtension, voiceCompatible }) => {
const fetchMock = vi.fn(async (url: string) => {
expect(new URL(url).searchParams.get("output_format")).toBe(outputFormat);
return new Response(new Uint8Array([1, 2, 3]), {
headers: { "content-type": "audio/mpeg" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const result = await buildElevenLabsSpeechProvider().synthesize({
text: "hello",
target: "voice-note",
cfg: {} as never,
providerConfig: { apiKey: "xi-test" },
providerOverrides: { outputFormat },
timeoutMs: 1_000,
});
expect(result).toEqual({
audioBuffer: Buffer.from([1, 2, 3]),
outputFormat,
fileExtension,
voiceCompatible,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
},
);
it("returns truthful stream metadata for an output override and releases the stream once", async () => {
const cancel = vi.fn();
const fetchMock = vi.fn(async (url: string) => {
expect(new URL(url).searchParams.get("output_format")).toBe("pcm_44100");
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
},
cancel,
}),
{ headers: { "content-type": "audio/mpeg" } },
);
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const result = await buildElevenLabsSpeechProvider().streamSynthesize?.({
text: "hello",
target: "voice-note",
cfg: {} as never,
providerConfig: { apiKey: "xi-test" },
providerOverrides: { outputFormat: "pcm_44100" },
timeoutMs: 1_000,
});
if (!result) {
throw new Error("streamSynthesize is unavailable");
}
expect(result).toMatchObject({
outputFormat: "pcm_44100",
fileExtension: ".pcm",
voiceCompatible: false,
});
if (!result.release) {
throw new Error("stream release is unavailable");
}
expect(cancel).not.toHaveBeenCalled();
await result.release();
await result.release();
expect(cancel).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});