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]>
279 lines
8.9 KiB
TypeScript
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);
|
|
}
|
|
}
|