Files
realtime_voice_service/providers/tts.py
T
2026-04-23 01:22:40 +05:00

184 lines
7.0 KiB
Python

from __future__ import annotations
import asyncio
import audioop
import os
from collections.abc import AsyncGenerator
from realtime_voice_service.providers.base import BaseTTS
def _api_base() -> str:
return (os.getenv("ELEVENLABS_API_BASE", "https://api.elevenlabs.io").strip() or "https://api.elevenlabs.io").rstrip("/")
def _timeout_seconds() -> float:
raw = os.getenv("ELEVENLABS_TIMEOUT_SECONDS")
if raw is None:
return 30.0
try:
return max(float(raw.strip()), 1.0)
except ValueError:
return 30.0
def _parse_output_format(output_format: str) -> tuple[str, int]:
normalized = str(output_format or "").strip().lower()
if normalized == "ulaw_8000":
return "ulaw", 8000
if normalized == "alaw_8000":
return "alaw", 8000
if not normalized.startswith("pcm_"):
raise ValueError("ElevenLabsTTS expects `pcm_*`, `ulaw_8000`, or `alaw_8000` output formats")
suffix = normalized.split("_", 1)[1]
try:
return "pcm16le", int(suffix)
except ValueError as exc:
raise ValueError(f"Unsupported ElevenLabs output format: {output_format}") from exc
class _PCM16StreamAdapter:
def __init__(self, *, input_codec: str, input_rate_hz: int, output_rate_hz: int) -> None:
self._input_codec = input_codec
self._input_rate_hz = input_rate_hz
self._output_rate_hz = output_rate_hz
self._carry = b""
self._state = None
def process(self, chunk: bytes) -> bytes:
if not chunk:
return b""
data = self._carry + chunk
sample_width_bytes = 2 if self._input_codec == "pcm16le" else 1
usable_length = len(data) - (len(data) % sample_width_bytes)
self._carry = data[usable_length:]
if usable_length <= 0:
return b""
pcm16 = self._decode_to_pcm16(data[:usable_length])
if self._input_rate_hz == self._output_rate_hz:
return pcm16
converted, self._state = audioop.ratecv(
pcm16,
2,
1,
self._input_rate_hz,
self._output_rate_hz,
self._state,
)
return converted
def flush(self) -> bytes:
if not self._carry:
return b""
sample_width_bytes = 2 if self._input_codec == "pcm16le" else 1
padded = self._carry + (b"\x00" * ((sample_width_bytes - len(self._carry)) % sample_width_bytes))
self._carry = b""
pcm16 = self._decode_to_pcm16(padded)
if self._input_rate_hz == self._output_rate_hz:
return pcm16
converted, self._state = audioop.ratecv(
pcm16,
2,
1,
self._input_rate_hz,
self._output_rate_hz,
self._state,
)
return converted
def _decode_to_pcm16(self, chunk: bytes) -> bytes:
if self._input_codec == "pcm16le":
return chunk
if self._input_codec == "ulaw":
return audioop.ulaw2lin(chunk, 2)
if self._input_codec == "alaw":
return audioop.alaw2lin(chunk, 2)
raise ValueError(f"Unsupported input codec: {self._input_codec}")
class ElevenLabsTTS(BaseTTS):
def __init__(
self,
*,
api_key: str | None = None,
api_base: str | None = None,
voice_id: str | None = None,
model_id: str | None = None,
language_code: str | None = None,
output_format: str | None = None,
target_sample_rate_hz: int = 8000,
timeout_seconds: float | None = None,
stream_chunk_bytes: int = 4096,
) -> None:
self._api_key = str(api_key if api_key is not None else os.getenv("ELEVENLABS_API_KEY", "")).strip()
self._api_base = str(api_base or _api_base()).strip().rstrip("/")
self._voice_id = str(voice_id or os.getenv("ELEVENLABS_TTS_VOICE_ID", "")).strip()
self._model_id = (
str(model_id or os.getenv("ELEVENLABS_TTS_MODEL_ID", "eleven_flash_v2_5")).strip()
or "eleven_flash_v2_5"
)
self._language_code = str(language_code or os.getenv("ELEVENLABS_TTS_LANGUAGE_CODE", "ru")).strip() or None
self._output_format = str(output_format or os.getenv("ELEVENLABS_TTS_OUTPUT_FORMAT", "pcm_16000")).strip().lower() or "pcm_16000"
self._source_codec, self._source_sample_rate_hz = _parse_output_format(self._output_format)
self._target_sample_rate_hz = max(int(target_sample_rate_hz), 1)
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 1.0)
self._stream_chunk_bytes = max(int(stream_chunk_bytes), 256)
async def synthesize_stream(self, text: str) -> AsyncGenerator[bytes, None]:
if not text.strip():
return
if not self._api_key:
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs TTS")
if not self._voice_id:
raise RuntimeError("ELEVENLABS_TTS_VOICE_ID is required for ElevenLabs TTS")
try:
import aiohttp
except Exception as exc: # noqa: BLE001
raise RuntimeError("The `aiohttp` package is required for ElevenLabs TTS") from exc
payload = {
"text": text,
"model_id": self._model_id,
"apply_text_normalization": "auto",
}
if self._language_code:
payload["language_code"] = self._language_code
adapter = _PCM16StreamAdapter(
input_codec=self._source_codec,
input_rate_hz=self._source_sample_rate_hz,
output_rate_hz=self._target_sample_rate_hz,
)
timeout = aiohttp.ClientTimeout(total=self._timeout_seconds)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
f"{self._api_base}/v1/text-to-speech/{self._voice_id}/stream",
params={"output_format": self._output_format},
headers={
"xi-api-key": self._api_key,
"Content-Type": "application/json",
},
json=payload,
) as response:
if response.status >= 400:
error_text = await response.text()
raise RuntimeError(
f"ElevenLabs TTS returned HTTP {response.status}: {error_text[:300]}"
)
async for chunk in response.content.iter_chunked(self._stream_chunk_bytes):
if not chunk:
continue
converted = adapter.process(chunk)
if converted:
yield converted
except (TimeoutError, asyncio.TimeoutError) as exc:
raise RuntimeError("ElevenLabs TTS request timed out") from exc
except aiohttp.ClientError as exc:
raise RuntimeError("ElevenLabs TTS request failed") from exc
tail = adapter.flush()
if tail:
yield tail