/** * Kokoro-FastAPI TTS Provider * * Default-tier TTS provider backed by Kokoro-FastAPI. * CPU-based, always available, Apache 2.0 license. * * Features: * - 53 built-in voices across 8 languages * - Speed control: 0.25x to 4.0x * - Output formats: mp3, wav, opus, flac * - Voice metadata derived from ID prefix (language, gender, accent) * * Voice ID format: {prefix}_{name} * - First character: language/accent code (a=American, b=British, etc.) * - Second character: gender code (f=Female, m=Male) * * Issue #393 */ import { BaseTTSProvider } from "./base-tts.provider"; import type { SpeechTier, VoiceInfo, AudioFormat } from "../interfaces/speech-types"; // ========================================== // Constants // ========================================== /** Audio formats supported by Kokoro-FastAPI */ export const KOKORO_SUPPORTED_FORMATS: readonly AudioFormat[] = [ "mp3", "wav", "opus", "flac", ] as const; /** Speed range supported by Kokoro-FastAPI */ export const KOKORO_SPEED_RANGE = { min: 0.25, max: 4.0, } as const; /** Default voice for Kokoro */ const KOKORO_DEFAULT_VOICE = "af_heart"; /** Default audio format for Kokoro */ const KOKORO_DEFAULT_FORMAT: AudioFormat = "mp3"; // ========================================== // Voice prefix mapping // ========================================== /** * Mapping of voice ID prefix (first two characters) to language/accent/gender metadata. * * Kokoro voice IDs follow the pattern: {lang}{gender}_{name} * - lang: a=American, b=British, e=Spanish, f=French, h=Hindi, j=Japanese, p=Portuguese, z=Chinese * - gender: f=Female, m=Male */ const VOICE_PREFIX_MAP: Record = { af: { language: "en-US", gender: "female", accent: "American" }, am: { language: "en-US", gender: "male", accent: "American" }, bf: { language: "en-GB", gender: "female", accent: "British" }, bm: { language: "en-GB", gender: "male", accent: "British" }, ef: { language: "es", gender: "female", accent: "Spanish" }, em: { language: "es", gender: "male", accent: "Spanish" }, ff: { language: "fr", gender: "female", accent: "French" }, fm: { language: "fr", gender: "male", accent: "French" }, hf: { language: "hi", gender: "female", accent: "Hindi" }, hm: { language: "hi", gender: "male", accent: "Hindi" }, jf: { language: "ja", gender: "female", accent: "Japanese" }, jm: { language: "ja", gender: "male", accent: "Japanese" }, pf: { language: "pt-BR", gender: "female", accent: "Portuguese" }, pm: { language: "pt-BR", gender: "male", accent: "Portuguese" }, zf: { language: "zh", gender: "female", accent: "Chinese" }, zm: { language: "zh", gender: "male", accent: "Chinese" }, }; // ========================================== // Voice catalog // ========================================== /** Raw voice catalog entry */ interface KokoroVoiceEntry { /** Voice ID (e.g. "af_heart") */ id: string; /** Human-readable label (e.g. "Heart") */ label: string; } /** * Complete catalog of Kokoro built-in voices. * * Organized by language/accent prefix: * - af_: American English Female * - am_: American English Male * - bf_: British English Female * - bm_: British English Male * - ef_: Spanish Female * - em_: Spanish Male * - ff_: French Female * - hf_: Hindi Female * - jf_: Japanese Female * - jm_: Japanese Male * - pf_: Portuguese Female * - zf_: Chinese Female * - zm_: Chinese Male */ export const KOKORO_VOICES: readonly KokoroVoiceEntry[] = [ // American English Female (af_) { id: "af_heart", label: "Heart" }, { id: "af_alloy", label: "Alloy" }, { id: "af_aoede", label: "Aoede" }, { id: "af_bella", label: "Bella" }, { id: "af_jessica", label: "Jessica" }, { id: "af_kore", label: "Kore" }, { id: "af_nicole", label: "Nicole" }, { id: "af_nova", label: "Nova" }, { id: "af_river", label: "River" }, { id: "af_sarah", label: "Sarah" }, { id: "af_sky", label: "Sky" }, // American English Male (am_) { id: "am_adam", label: "Adam" }, { id: "am_echo", label: "Echo" }, { id: "am_eric", label: "Eric" }, { id: "am_fenrir", label: "Fenrir" }, { id: "am_liam", label: "Liam" }, { id: "am_michael", label: "Michael" }, { id: "am_onyx", label: "Onyx" }, { id: "am_puck", label: "Puck" }, { id: "am_santa", label: "Santa" }, // British English Female (bf_) { id: "bf_alice", label: "Alice" }, { id: "bf_emma", label: "Emma" }, { id: "bf_isabella", label: "Isabella" }, { id: "bf_lily", label: "Lily" }, // British English Male (bm_) { id: "bm_daniel", label: "Daniel" }, { id: "bm_fable", label: "Fable" }, { id: "bm_george", label: "George" }, { id: "bm_lewis", label: "Lewis" }, { id: "bm_oscar", label: "Oscar" }, // Spanish Female (ef_) { id: "ef_dora", label: "Dora" }, { id: "ef_elena", label: "Elena" }, { id: "ef_maria", label: "Maria" }, // Spanish Male (em_) { id: "em_alex", label: "Alex" }, { id: "em_carlos", label: "Carlos" }, { id: "em_santa", label: "Santa" }, // French Female (ff_) { id: "ff_camille", label: "Camille" }, { id: "ff_siwis", label: "Siwis" }, // Hindi Female (hf_) { id: "hf_alpha", label: "Alpha" }, { id: "hf_beta", label: "Beta" }, // Japanese Female (jf_) { id: "jf_alpha", label: "Alpha" }, { id: "jf_gongitsune", label: "Gongitsune" }, { id: "jf_nezumi", label: "Nezumi" }, { id: "jf_tebukuro", label: "Tebukuro" }, // Japanese Male (jm_) { id: "jm_kumo", label: "Kumo" }, // Portuguese Female (pf_) { id: "pf_dora", label: "Dora" }, // Chinese Female (zf_) { id: "zf_xiaobei", label: "Xiaobei" }, { id: "zf_xiaoni", label: "Xiaoni" }, { id: "zf_xiaoxiao", label: "Xiaoxiao" }, { id: "zf_xiaoyi", label: "Xiaoyi" }, // Chinese Male (zm_) { id: "zm_yunjian", label: "Yunjian" }, { id: "zm_yunxi", label: "Yunxi" }, { id: "zm_yunxia", label: "Yunxia" }, { id: "zm_yunyang", label: "Yunyang" }, ] as const; // ========================================== // Prefix parser // ========================================== /** Parsed voice prefix metadata */ export interface VoicePrefixMetadata { /** BCP 47 language code (e.g. "en-US", "en-GB", "ja") */ language: string; /** Gender: "female", "male", or "unknown" */ gender: string; /** Human-readable accent label (e.g. "American", "British") */ accent: string; } /** * Parse a Kokoro voice ID to extract language, gender, and accent metadata. * * Voice IDs follow the pattern: {lang}{gender}_{name} * The first two characters encode language/accent and gender. * * @param voiceId - Kokoro voice ID (e.g. "af_heart") * @returns Parsed metadata with language, gender, and accent */ export function parseVoicePrefix(voiceId: string): VoicePrefixMetadata { const prefix = voiceId.substring(0, 2); const mapping = VOICE_PREFIX_MAP[prefix]; if (mapping) { return { language: mapping.language, gender: mapping.gender, accent: mapping.accent, }; } return { language: "unknown", gender: "unknown", accent: "Unknown", }; } // ========================================== // Provider class // ========================================== /** * Kokoro-FastAPI TTS provider (default tier). * * CPU-based text-to-speech engine with 53 built-in voices across 8 languages. * Uses the OpenAI-compatible API exposed by Kokoro-FastAPI. * * @example * ```typescript * const kokoro = new KokoroTtsProvider("http://kokoro-tts:8880/v1"); * const voices = await kokoro.listVoices(); * const result = await kokoro.synthesize("Hello!", { voice: "af_heart" }); * ``` */ export class KokoroTtsProvider extends BaseTTSProvider { readonly name = "kokoro"; readonly tier: SpeechTier = "default"; /** * Create a new Kokoro TTS provider. * * @param baseURL - Base URL for the Kokoro-FastAPI endpoint (e.g. "http://kokoro-tts:8880/v1") * @param defaultVoice - Default voice ID (defaults to "af_heart") * @param defaultFormat - Default audio format (defaults to "mp3") */ constructor( baseURL: string, defaultVoice: string = KOKORO_DEFAULT_VOICE, defaultFormat: AudioFormat = KOKORO_DEFAULT_FORMAT ) { super(baseURL, defaultVoice, defaultFormat); } /** * List all available Kokoro voices with metadata. * * Returns the full catalog of 53 built-in voices with language, gender, * and accent information derived from voice ID prefixes. * * @returns Array of VoiceInfo objects for all Kokoro voices */ override listVoices(): Promise { const voices: VoiceInfo[] = KOKORO_VOICES.map((entry) => { const metadata = parseVoicePrefix(entry.id); const genderLabel = metadata.gender === "female" ? "Female" : "Male"; return { id: entry.id, name: `${entry.label} (${metadata.accent} ${genderLabel})`, language: metadata.language, tier: this.tier, isDefault: entry.id === this.defaultVoice, }; }); return Promise.resolve(voices); } }