Files
stack/apps/api/src/speech/providers/kokoro-tts.provider.ts
T
jason.woltjeandClaude Opus 4.6 af9c5799af fix(#388): address PR review findings — fix WebSocket/REST bugs, improve error handling, fix types and comments
Critical fixes:
- Fix FormData field name mismatch (audio -> file) to match backend FileInterceptor
- Add /speech namespace to WebSocket connection URL
- Pass auth token in WebSocket handshake options
- Wrap audio.play() in try-catch for NotAllowedError and DOMException handling
- Replace bare catch block with named error parameter and descriptive message
- Add connect_error and disconnect event handlers to WebSocket
- Update JSDoc to accurately describe batch transcription (not real-time partial)

Important fixes:
- Emit transcription-error before disconnect in gateway auth failures
- Capture MediaRecorder error details and clean up media tracks on error
- Change TtsDefaultConfig.format type from string to AudioFormat
- Define canonical SPEECH_TIERS and AUDIO_FORMATS arrays as single source of truth
- Fix voice count from 54 to 53 in provider, AGENTS.md, and docs
- Fix inaccurate comments (Piper formats, tier prop, SpeachesProvider, TextValidationPipe)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 03:44:33 -06:00

279 lines
8.9 KiB
TypeScript

/**
* 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<string, { language: string; gender: string; accent: string }> = {
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<VoiceInfo[]> {
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);
}
}