Files
stack/apps/api/src/speech/providers/piper-tts.provider.spec.ts
Jason Woltje 6c465566f6
All checks were successful
ci/woodpecker/push/api Pipeline was successful
feat(#395): implement Piper TTS provider via OpenedAI Speech
Add fallback-tier TTS provider using Piper via OpenedAI Speech for
ultra-lightweight CPU-only synthesis. Maps 6 standard OpenAI voice
names (alloy, echo, fable, onyx, nova, shimmer) to Piper voices.
Update factory to use the new PiperTtsProvider class, replacing the
inline stub. Includes 37 unit tests covering provider identity,
voice mapping, and voice listing.

Fixes #395

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:39:20 -06:00

267 lines
7.7 KiB
TypeScript

/**
* 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);
});
});