184 lines
7.0 KiB
Python
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
|