Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
105 lines
3.3 KiB
Python
105 lines
3.3 KiB
Python
"""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
|