from __future__ import annotations from dataclasses import dataclass import numpy as np from app.config import Settings from app.errors import OpenAICompatibleError from app.services.supertonic_engine import SynthesisResult try: from kittentts import KittenTTS except ImportError: # pragma: no cover KittenTTS = None @dataclass(frozen=True) class KittenVoiceSpec: alias: str canonical_name: str provider_voice_id: str source: str = "builtin" style_path: str = "" class KittenEngine: public_model_ids = [ "kitten-tts-micro-0.8", "kitten-tts-micro", "kitten-tts", "kitten", ] primary_model_id = "kitten-tts-micro-0.8" sample_rate = 24000 voice_aliases = { "bella": {"canonical_name": "Bella", "provider_voice_id": "expr-voice-2-f"}, "jasper": {"canonical_name": "Jasper", "provider_voice_id": "expr-voice-2-m"}, "luna": {"canonical_name": "Luna", "provider_voice_id": "expr-voice-3-f"}, "bruno": {"canonical_name": "Bruno", "provider_voice_id": "expr-voice-3-m"}, "rosie": {"canonical_name": "Rosie", "provider_voice_id": "expr-voice-4-f"}, "hugo": {"canonical_name": "Hugo", "provider_voice_id": "expr-voice-4-m"}, "kiki": {"canonical_name": "Kiki", "provider_voice_id": "expr-voice-5-f"}, "leo": {"canonical_name": "Leo", "provider_voice_id": "expr-voice-5-m"}, } def __init__(self, tts: object | None, model_name: str) -> None: self.tts = tts self.model_name = model_name @classmethod def from_settings(cls, settings: Settings) -> "KittenEngine": if KittenTTS is None: return cls(tts=None, model_name=settings.kitten_model_name) try: tts = KittenTTS( model_name=settings.kitten_model_name, cache_dir=settings.kitten_cache_dir, ) return cls(tts=tts, model_name=settings.kitten_model_name) except Exception as exc: # pragma: no cover raise OpenAICompatibleError( status_code=500, message=f"Failed to initialize Kitten TTS engine: {exc}", error_type="server_error", code="engine_init_failed", ) from exc def list_voice_bindings(self) -> list[dict[str, str]]: return [ { "alias": voice, "canonical_name": spec["canonical_name"], "provider_voice_id": spec["provider_voice_id"], "source": "builtin", "style_path": "", "model": self.primary_model_id, } for voice, spec in self.voice_aliases.items() ] def supports_voice(self, voice: str) -> bool: return voice.strip().lower() in self.voice_aliases def synthesize( self, *, text: str, voice: str, speed: float, quality: str, model_name: str, lang: str, ) -> SynthesisResult: del quality, model_name, lang if self.tts is None: raise OpenAICompatibleError( status_code=500, message="Kitten TTS engine is not available.", error_type="server_error", code="engine_unavailable", ) normalized_voice = voice.strip().lower() if normalized_voice not in self.voice_aliases: raise OpenAICompatibleError( status_code=400, message=f"Unsupported Kitten voice '{voice}'.", param="voice", code="unsupported_voice", ) if speed <= 0: raise OpenAICompatibleError( status_code=400, message="Kitten speed must be greater than 0.", param="speed", code="invalid_speed", ) try: provider_voice_id = self.voice_aliases[normalized_voice][ "provider_voice_id" ] audio = self.tts.generate( text, voice=provider_voice_id, speed=speed, clean_text=False ) except Exception as exc: raise OpenAICompatibleError( status_code=500, message=f"Speech synthesis failed: {exc}", error_type="server_error", code="synthesis_failed", ) from exc return SynthesisResult(waveform=np.asarray(audio), sample_rate=self.sample_rate)