"""ElevenLabs TTS 语音合成适配器。""" import time import httpx from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, wait_exponential, ) from app.core.logging import get_logger from app.services.adapters.base import AdapterConfig, BaseAdapter from app.services.adapters.registry import AdapterRegistry logger = get_logger(__name__) ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1" DEFAULT_VOICE_ID = "21m00Tcm4TlvDq8ikWAM" # Rachel @AdapterRegistry.register("tts", "elevenlabs") class ElevenLabsTtsAdapter(BaseAdapter[bytes]): """ElevenLabs TTS 语音合成适配器,返回 MP3 bytes。""" adapter_type = "tts" adapter_name = "elevenlabs" def __init__(self, config: AdapterConfig): super().__init__(config) self.api_base = config.api_base or ELEVENLABS_API_BASE async def execute(self, text: str, **kwargs) -> bytes: """将文本转换为语音 MP3 bytes。""" start_time = time.time() logger.info("elevenlabs_tts_start", text_length=len(text)) voice_id = kwargs.get("voice_id") or DEFAULT_VOICE_ID model_id = kwargs.get("model") or self.config.model or "eleven_multilingual_v2" stability = kwargs.get("stability", 0.5) similarity_boost = kwargs.get("similarity_boost", 0.75) url = f"{self.api_base}/text-to-speech/{voice_id}" payload = { "text": text, "model_id": model_id, "voice_settings": { "stability": stability, "similarity_boost": similarity_boost, }, } audio_bytes = await self._call_api(url, payload) elapsed = time.time() - start_time logger.info( "elevenlabs_tts_success", elapsed_seconds=round(elapsed, 2), audio_size_bytes=len(audio_bytes), ) return audio_bytes async def health_check(self) -> bool: """检查 ElevenLabs API 是否可用。""" try: async with httpx.AsyncClient(timeout=10) as client: response = await client.get( f"{self.api_base}/voices", headers={"xi-api-key": self.config.api_key}, ) return response.status_code == 200 except Exception: return False @property def estimated_cost(self) -> float: """预估每千字符成本 (USD)。""" return 0.03 @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), reraise=True, ) async def _call_api(self, url: str, payload: dict) -> bytes: """调用 ElevenLabs API,带重试机制。""" timeout = self.config.timeout_ms / 1000 async with httpx.AsyncClient(timeout=timeout) as client: response = await client.post( url, json=payload, headers={ "xi-api-key": self.config.api_key, "Content-Type": "application/json", "Accept": "audio/mpeg", }, ) response.raise_for_status() return response.content