/** * PiperTtsProvider Unit Tests * * Tests the Piper TTS provider via OpenedAI Speech (fallback tier). * Validates provider identity, OpenAI voice name mapping, voice listing, * and ultra-lightweight CPU-only design characteristics. * * Issue #395 */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PiperTtsProvider, PIPER_VOICE_MAP, PIPER_SUPPORTED_FORMATS, OPENAI_STANDARD_VOICES, } from "./piper-tts.provider"; import type { VoiceInfo } from "../interfaces/speech-types"; // ========================================== // Mock OpenAI SDK // ========================================== vi.mock("openai", () => { class MockOpenAI { audio = { speech: { create: vi.fn(), }, }; } return { default: MockOpenAI }; }); // ========================================== // Provider identity // ========================================== describe("PiperTtsProvider", () => { const testBaseURL = "http://openedai-speech:8000/v1"; let provider: PiperTtsProvider; beforeEach(() => { provider = new PiperTtsProvider(testBaseURL); }); describe("provider identity", () => { it("should have name 'piper'", () => { expect(provider.name).toBe("piper"); }); it("should have tier 'fallback'", () => { expect(provider.tier).toBe("fallback"); }); }); // ========================================== // Constructor // ========================================== describe("constructor", () => { it("should use 'alloy' as default voice", () => { const newProvider = new PiperTtsProvider(testBaseURL); expect(newProvider).toBeDefined(); }); it("should accept a custom default voice", () => { const customProvider = new PiperTtsProvider(testBaseURL, "nova"); expect(customProvider).toBeDefined(); }); it("should accept a custom default format", () => { const customProvider = new PiperTtsProvider(testBaseURL, "alloy", "wav"); expect(customProvider).toBeDefined(); }); }); // ========================================== // listVoices() // ========================================== describe("listVoices", () => { let voices: VoiceInfo[]; beforeEach(async () => { voices = await provider.listVoices(); }); it("should return an array of VoiceInfo objects", () => { expect(voices).toBeInstanceOf(Array); expect(voices.length).toBeGreaterThan(0); }); it("should return exactly 6 voices (OpenAI standard set)", () => { expect(voices.length).toBe(6); }); it("should set tier to 'fallback' on all voices", () => { for (const voice of voices) { expect(voice.tier).toBe("fallback"); } }); it("should have exactly one default voice", () => { const defaults = voices.filter((v) => v.isDefault === true); expect(defaults.length).toBe(1); }); it("should mark 'alloy' as the default voice", () => { const defaultVoice = voices.find((v) => v.isDefault === true); expect(defaultVoice).toBeDefined(); expect(defaultVoice?.id).toBe("alloy"); }); it("should have an id and name for every voice", () => { for (const voice of voices) { expect(voice.id).toBeTruthy(); expect(voice.name).toBeTruthy(); } }); it("should set language on every voice", () => { for (const voice of voices) { expect(voice.language).toBeTruthy(); } }); // ========================================== // All 6 OpenAI standard voices present // ========================================== describe("OpenAI standard voices", () => { const standardVoiceIds = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]; it.each(standardVoiceIds)("should include voice '%s'", (voiceId) => { const voice = voices.find((v) => v.id === voiceId); expect(voice).toBeDefined(); }); }); // ========================================== // Voice metadata // ========================================== describe("voice metadata", () => { it("should include gender info in voice names", () => { const alloy = voices.find((v) => v.id === "alloy"); expect(alloy?.name).toMatch(/Female|Male/); }); it("should map alloy to a female voice", () => { const alloy = voices.find((v) => v.id === "alloy"); expect(alloy?.name).toContain("Female"); }); it("should map echo to a male voice", () => { const echo = voices.find((v) => v.id === "echo"); expect(echo?.name).toContain("Male"); }); it("should map fable to a British voice", () => { const fable = voices.find((v) => v.id === "fable"); expect(fable?.language).toBe("en-GB"); }); it("should map onyx to a male voice", () => { const onyx = voices.find((v) => v.id === "onyx"); expect(onyx?.name).toContain("Male"); }); it("should map nova to a female voice", () => { const nova = voices.find((v) => v.id === "nova"); expect(nova?.name).toContain("Female"); }); it("should map shimmer to a female voice", () => { const shimmer = voices.find((v) => v.id === "shimmer"); expect(shimmer?.name).toContain("Female"); }); }); }); }); // ========================================== // PIPER_VOICE_MAP // ========================================== describe("PIPER_VOICE_MAP", () => { it("should contain all 6 OpenAI standard voice names", () => { const expectedKeys = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]; for (const key of expectedKeys) { expect(PIPER_VOICE_MAP).toHaveProperty(key); } }); it("should map each voice to a Piper voice ID", () => { for (const entry of Object.values(PIPER_VOICE_MAP)) { expect(entry.piperVoice).toBeTruthy(); expect(typeof entry.piperVoice).toBe("string"); } }); it("should have gender for each voice entry", () => { for (const entry of Object.values(PIPER_VOICE_MAP)) { expect(entry.gender).toMatch(/^(female|male)$/); } }); it("should have a language for each voice entry", () => { for (const entry of Object.values(PIPER_VOICE_MAP)) { expect(entry.language).toBeTruthy(); } }); it("should have a description for each voice entry", () => { for (const entry of Object.values(PIPER_VOICE_MAP)) { expect(entry.description).toBeTruthy(); } }); }); // ========================================== // OPENAI_STANDARD_VOICES // ========================================== describe("OPENAI_STANDARD_VOICES", () => { it("should be an array of 6 voice IDs", () => { expect(Array.isArray(OPENAI_STANDARD_VOICES)).toBe(true); expect(OPENAI_STANDARD_VOICES.length).toBe(6); }); it("should contain all standard OpenAI voice names", () => { expect(OPENAI_STANDARD_VOICES).toContain("alloy"); expect(OPENAI_STANDARD_VOICES).toContain("echo"); expect(OPENAI_STANDARD_VOICES).toContain("fable"); expect(OPENAI_STANDARD_VOICES).toContain("onyx"); expect(OPENAI_STANDARD_VOICES).toContain("nova"); expect(OPENAI_STANDARD_VOICES).toContain("shimmer"); }); }); // ========================================== // PIPER_SUPPORTED_FORMATS // ========================================== describe("PIPER_SUPPORTED_FORMATS", () => { it("should include mp3", () => { expect(PIPER_SUPPORTED_FORMATS).toContain("mp3"); }); it("should include wav", () => { expect(PIPER_SUPPORTED_FORMATS).toContain("wav"); }); it("should include opus", () => { expect(PIPER_SUPPORTED_FORMATS).toContain("opus"); }); it("should include flac", () => { expect(PIPER_SUPPORTED_FORMATS).toContain("flac"); }); it("should be a readonly array", () => { expect(Array.isArray(PIPER_SUPPORTED_FORMATS)).toBe(true); }); });