/** * ChatterboxTTSProvider Unit Tests * * Tests the premium-tier TTS provider with voice cloning and * emotion exaggeration support for Chatterbox. * * Issue #394 */ import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"; import { ChatterboxTTSProvider } from "./chatterbox-tts.provider"; import type { ChatterboxSynthesizeOptions, AudioFormat } from "../interfaces/speech-types"; // ========================================== // Mock OpenAI SDK // ========================================== const mockCreate = vi.fn(); vi.mock("openai", () => { class MockOpenAI { audio = { speech: { create: mockCreate, }, }; } return { default: MockOpenAI }; }); // ========================================== // Test helpers // ========================================== /** * Create a mock Response-like object that mimics OpenAI SDK's audio.speech.create() return. */ function createMockAudioResponse(audioData: Uint8Array): { arrayBuffer: Mock } { return { arrayBuffer: vi.fn().mockResolvedValue(audioData.buffer), }; } describe("ChatterboxTTSProvider", () => { let provider: ChatterboxTTSProvider; const testBaseURL = "http://chatterbox-tts:8881/v1"; beforeEach(() => { vi.clearAllMocks(); provider = new ChatterboxTTSProvider(testBaseURL); }); // ========================================== // Provider identity // ========================================== describe("provider identity", () => { it("should have name 'chatterbox'", () => { expect(provider.name).toBe("chatterbox"); }); it("should have tier 'premium'", () => { expect(provider.tier).toBe("premium"); }); }); // ========================================== // Constructor // ========================================== describe("constructor", () => { it("should create an instance with the provided baseURL", () => { expect(provider).toBeDefined(); }); it("should use 'default' as the default voice", async () => { const audioBytes = new Uint8Array([0x01, 0x02]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const result = await provider.synthesize("Hello"); expect(result.voice).toBe("default"); }); it("should use 'wav' as the default format", async () => { const audioBytes = new Uint8Array([0x01, 0x02]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const result = await provider.synthesize("Hello"); expect(result.format).toBe("wav"); }); }); // ========================================== // synthesize() — basic (no Chatterbox-specific options) // ========================================== describe("synthesize (basic)", () => { it("should synthesize text and return a SynthesisResult", async () => { const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x04, 0x00]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const result = await provider.synthesize("Hello, world!"); expect(result).toBeDefined(); expect(result.audio).toBeInstanceOf(Buffer); expect(result.audio.length).toBe(audioBytes.length); expect(result.format).toBe("wav"); expect(result.voice).toBe("default"); expect(result.tier).toBe("premium"); }); it("should pass correct base parameters to OpenAI SDK when no extra options", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); await provider.synthesize("Test text"); expect(mockCreate).toHaveBeenCalledWith({ model: "tts-1", input: "Test text", voice: "default", response_format: "wav", speed: 1.0, }); }); it("should use custom voice from options", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { voice: "cloned_voice_1" }; const result = await provider.synthesize("Hello", options); expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ voice: "cloned_voice_1" })); expect(result.voice).toBe("cloned_voice_1"); }); it("should use custom format from options", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { format: "mp3" as AudioFormat }; const result = await provider.synthesize("Hello", options); expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ response_format: "mp3" })); expect(result.format).toBe("mp3"); }); it("should throw on synthesis failure", async () => { mockCreate.mockRejectedValue(new Error("GPU out of memory")); await expect(provider.synthesize("Hello")).rejects.toThrow( "TTS synthesis failed for chatterbox: GPU out of memory" ); }); }); // ========================================== // synthesize() — voice cloning (referenceAudio) // ========================================== describe("synthesize (voice cloning)", () => { it("should pass referenceAudio as base64 in extra body params", async () => { const audioBytes = new Uint8Array([0x01, 0x02]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const referenceAudio = Buffer.from("fake-audio-data-for-cloning"); const options: ChatterboxSynthesizeOptions = { referenceAudio, }; await provider.synthesize("Clone my voice", options); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ input: "Clone my voice", reference_audio: referenceAudio.toString("base64"), }) ); }); it("should not include reference_audio when referenceAudio is not provided", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); await provider.synthesize("No cloning"); const callArgs = mockCreate.mock.calls[0][0] as Record; expect(callArgs).not.toHaveProperty("reference_audio"); }); }); // ========================================== // synthesize() — emotion exaggeration // ========================================== describe("synthesize (emotion exaggeration)", () => { it("should pass emotionExaggeration as exaggeration in extra body params", async () => { const audioBytes = new Uint8Array([0x01, 0x02]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { emotionExaggeration: 0.7, }; await provider.synthesize("Very emotional text", options); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ exaggeration: 0.7, }) ); }); it("should not include exaggeration when emotionExaggeration is not provided", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); await provider.synthesize("Neutral text"); const callArgs = mockCreate.mock.calls[0][0] as Record; expect(callArgs).not.toHaveProperty("exaggeration"); }); it("should accept emotionExaggeration of 0.0", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { emotionExaggeration: 0.0, }; await provider.synthesize("Minimal emotion", options); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ exaggeration: 0.0, }) ); }); it("should accept emotionExaggeration of 1.0", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { emotionExaggeration: 1.0, }; await provider.synthesize("Maximum emotion", options); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ exaggeration: 1.0, }) ); }); it("should clamp emotionExaggeration above 1.0 to 1.0", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { emotionExaggeration: 1.5, }; await provider.synthesize("Over the top", options); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ exaggeration: 1.0, }) ); }); it("should clamp emotionExaggeration below 0.0 to 0.0", async () => { const audioBytes = new Uint8Array([0x01]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const options: ChatterboxSynthesizeOptions = { emotionExaggeration: -0.5, }; await provider.synthesize("Negative emotion", options); expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ exaggeration: 0.0, }) ); }); }); // ========================================== // synthesize() — combined options // ========================================== describe("synthesize (combined options)", () => { it("should handle referenceAudio and emotionExaggeration together", async () => { const audioBytes = new Uint8Array([0x01, 0x02, 0x03]); mockCreate.mockResolvedValue(createMockAudioResponse(audioBytes)); const referenceAudio = Buffer.from("reference-audio-sample"); const options: ChatterboxSynthesizeOptions = { voice: "custom_voice", format: "mp3", speed: 0.9, referenceAudio, emotionExaggeration: 0.6, }; const result = await provider.synthesize("Full options test", options); expect(mockCreate).toHaveBeenCalledWith({ model: "tts-1", input: "Full options test", voice: "custom_voice", response_format: "mp3", speed: 0.9, reference_audio: referenceAudio.toString("base64"), exaggeration: 0.6, }); expect(result.audio).toBeInstanceOf(Buffer); expect(result.voice).toBe("custom_voice"); expect(result.format).toBe("mp3"); expect(result.tier).toBe("premium"); }); }); // ========================================== // isHealthy() — graceful degradation // ========================================== describe("isHealthy (graceful degradation)", () => { it("should return true when the Chatterbox server is reachable", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, }); vi.stubGlobal("fetch", mockFetch); const healthy = await provider.isHealthy(); expect(healthy).toBe(true); vi.unstubAllGlobals(); }); it("should return false when GPU is unavailable (server unreachable)", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); vi.stubGlobal("fetch", mockFetch); const healthy = await provider.isHealthy(); expect(healthy).toBe(false); vi.unstubAllGlobals(); }); it("should return false when the server returns 503 (GPU overloaded)", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503, }); vi.stubGlobal("fetch", mockFetch); const healthy = await provider.isHealthy(); expect(healthy).toBe(false); vi.unstubAllGlobals(); }); it("should return false on timeout (slow GPU response)", async () => { const mockFetch = vi .fn() .mockRejectedValue(new Error("AbortError: The operation was aborted")); vi.stubGlobal("fetch", mockFetch); const healthy = await provider.isHealthy(); expect(healthy).toBe(false); vi.unstubAllGlobals(); }); }); // ========================================== // listVoices() // ========================================== describe("listVoices", () => { it("should return the default voice in the premium tier", async () => { const voices = await provider.listVoices(); expect(voices).toBeInstanceOf(Array); expect(voices.length).toBeGreaterThan(0); const defaultVoice = voices.find((v) => v.isDefault === true); expect(defaultVoice).toBeDefined(); expect(defaultVoice?.id).toBe("default"); expect(defaultVoice?.tier).toBe("premium"); }); it("should set tier to 'premium' on all voices", async () => { const voices = await provider.listVoices(); for (const voice of voices) { expect(voice.tier).toBe("premium"); } }); }); // ========================================== // supportedLanguages // ========================================== describe("supportedLanguages", () => { it("should expose a list of supported languages for cross-language transfer", () => { const languages = provider.supportedLanguages; expect(languages).toBeInstanceOf(Array); expect(languages.length).toBe(23); expect(languages).toContain("en"); expect(languages).toContain("fr"); expect(languages).toContain("de"); expect(languages).toContain("es"); expect(languages).toContain("ja"); expect(languages).toContain("zh"); }); }); });