This commit is contained in:
Magzhan Zhumabayev
2026-05-02 01:07:38 +05:00
parent 8a83a4d575
commit cde9eb6f07
3 changed files with 206 additions and 34 deletions
+3 -1
View File
@@ -43,7 +43,9 @@ AUDIO_DUMP_DIR=debug_audio
ELEVENLABS_API_KEY=
ELEVENLABS_API_BASE=https://api.elevenlabs.io
ELEVENLABS_TTS_VOICE_ID=
ELEVENLABS_TTS_MODEL_ID=eleven_flash_v2_5
# Use eleven_v3 for Kazakh/Russian. Switch back to v2 anytime with eleven_flash_v2_5 or eleven_multilingual_v2.
ELEVENLABS_TTS_MODEL_ID=eleven_v3
ELEVENLABS_TTS_TRANSPORT=auto
ELEVENLABS_TTS_LANGUAGE_CODE=ru
ELEVENLABS_TTS_OUTPUT_FORMAT=pcm_16000
ELEVENLABS_TTS_SPEED=1.2
+3 -2
View File
@@ -127,7 +127,7 @@ class RealtimeVoiceService:
LOGGER.info(
"realtime voice service config: sample_rate=%s audiosocket=%s:%s http=%s:%s "
"llm_provider=%s llm_model=%s tools_enabled=%s serper_configured=%s stt_provider=%s stt_model=%s stt_realtime_model=%s "
"tts_voice_id=%s tts_model=%s tts_format=%s tts_speed=%s",
"tts_voice_id=%s tts_model=%s tts_transport=%s tts_format=%s tts_speed=%s",
self._sample_rate_hz,
_audiosocket_host(),
_audiosocket_port(),
@@ -141,7 +141,8 @@ class RealtimeVoiceService:
os.getenv("ELEVENLABS_STT_MODEL_ID", "scribe_v2"),
os.getenv("ELEVENLABS_STT_REALTIME_MODEL_ID", "scribe_v2_realtime"),
os.getenv("ELEVENLABS_TTS_VOICE_ID", ""),
os.getenv("ELEVENLABS_TTS_MODEL_ID", "eleven_turbo_v2_5"),
os.getenv("ELEVENLABS_TTS_MODEL_ID", "eleven_v3"),
os.getenv("ELEVENLABS_TTS_TRANSPORT", "auto"),
f"pcm_{self._sample_rate_hz}",
os.getenv("ELEVENLABS_TTS_SPEED", "1.0"),
)
+200 -31
View File
@@ -82,6 +82,22 @@ def _preview_text(text: str, *, limit: int = 120) -> str:
return f"{normalized[:limit]}..."
def _tts_transport_mode() -> str:
raw = str(os.getenv("ELEVENLABS_TTS_TRANSPORT", "auto")).strip().lower().replace("-", "_")
aliases = {
"auto": "auto",
"ws": "websocket",
"websocket": "websocket",
"http": "http_stream",
"stream": "http_stream",
"http_stream": "http_stream",
}
if raw in aliases:
return aliases[raw]
LOGGER.warning("invalid ELEVENLABS_TTS_TRANSPORT=%r; using auto", raw)
return "auto"
class ElevenLabsTTS(BaseTTS):
def __init__(
self,
@@ -103,18 +119,16 @@ class ElevenLabsTTS(BaseTTS):
self._ws_base = _ws_base(self._api_base)
self._voice_id = str(voice_id or os.getenv("ELEVENLABS_TTS_VOICE_ID", "")).strip()
self._requested_model_id = (
str(model_id or os.getenv("ELEVENLABS_TTS_MODEL_ID", "eleven_multilingual_v2")).strip()
or "eleven_multilingual_v2"
)
self._websocket_fallback_model_id = (
str(os.getenv("ELEVENLABS_TTS_WS_FALLBACK_MODEL_ID", "eleven_multilingual_v2")).strip()
or "eleven_multilingual_v2"
)
self._model_id = self._resolve_websocket_model_id(
requested_model_id=self._requested_model_id,
fallback_model_id=self._websocket_fallback_model_id,
str(model_id or os.getenv("ELEVENLABS_TTS_MODEL_ID", "eleven_v3")).strip()
or "eleven_v3"
)
self._model_id = self._requested_model_id
self._language_code = str(language_code or os.getenv("ELEVENLABS_TTS_LANGUAGE_CODE", "ru")).strip() or None
self._transport_mode = _tts_transport_mode()
self._apply_text_normalization = (
str(os.getenv("ELEVENLABS_TTS_APPLY_TEXT_NORMALIZATION", "auto")).strip().lower()
or "auto"
)
requested_output_format = (
str(output_format or os.getenv("ELEVENLABS_TTS_OUTPUT_FORMAT", "pcm_16000")).strip().lower()
or "pcm_16000"
@@ -151,17 +165,18 @@ class ElevenLabsTTS(BaseTTS):
"use_speaker_boost": self._read_bool_env("ELEVENLABS_TTS_USE_SPEAKER_BOOST", True),
}
LOGGER.info(
"ElevenLabs TTS config: voice_id=%s requested_model=%s websocket_model=%s "
"ElevenLabs TTS config: voice_id=%s model=%s transport=%s "
"provider_output_format=%s provider_sample_rate=%s target_sample_rate=%s "
"language=%s auto_mode=%s chunk_schedule=%s voice_settings=%s",
"language=%s auto_mode=%s text_normalization=%s chunk_schedule=%s voice_settings=%s",
self._voice_id,
self._requested_model_id,
self._model_id,
self._resolved_transport_mode(),
self._output_format,
self._provider_sample_rate_hz,
self._target_sample_rate_hz,
self._language_code,
self._auto_mode,
self._apply_text_normalization,
self._chunk_length_schedule,
self._voice_settings,
)
@@ -177,14 +192,34 @@ class ElevenLabsTTS(BaseTTS):
if not self._voice_id:
raise RuntimeError("ELEVENLABS_TTS_VOICE_ID is required for ElevenLabs TTS")
effective_language_code = (str(language_code).strip() if language_code else "") or self._language_code
if self._resolved_transport_mode() == "http_stream":
async for audio_chunk in self._synthesize_http_stream(
text_stream,
language_code=effective_language_code,
):
yield audio_chunk
return
async for audio_chunk in self._synthesize_websocket_stream(
text_stream,
language_code=effective_language_code,
):
yield audio_chunk
async def _synthesize_websocket_stream(
self,
text_stream: AsyncIterable[str],
*,
language_code: str | None = None,
) -> AsyncGenerator[bytes, None]:
try:
from websockets.exceptions import WebSocketException
from websockets.legacy.client import connect as websocket_connect
except Exception as exc: # noqa: BLE001
raise RuntimeError("The `websockets` package is required for ElevenLabs TTS WebSocket streaming") from exc
effective_language_code = (str(language_code).strip() if language_code else "") or self._language_code
websocket_url = self._build_websocket_url(language_code=effective_language_code)
websocket_url = self._build_websocket_url(language_code=language_code)
started_monotonic = time.perf_counter()
audio_chunk_count = 0
audio_byte_count = 0
@@ -199,7 +234,7 @@ class ElevenLabsTTS(BaseTTS):
self._output_format,
self._provider_sample_rate_hz,
self._target_sample_rate_hz,
effective_language_code,
language_code,
self._auto_mode,
)
try:
@@ -296,6 +331,128 @@ class ElevenLabsTTS(BaseTTS):
except OSError as exc:
raise RuntimeError("ElevenLabs TTS WebSocket connection failed") from exc
async def _synthesize_http_stream(
self,
text_stream: AsyncIterable[str],
*,
language_code: str | None = None,
) -> AsyncGenerator[bytes, None]:
try:
import aiohttp
except Exception as exc: # noqa: BLE001
raise RuntimeError("The `aiohttp` package is required for ElevenLabs TTS HTTP streaming") from exc
timeout = aiohttp.ClientTimeout(total=self._timeout_seconds, sock_read=self._timeout_seconds)
connector = aiohttp.TCPConnector(limit=16, ttl_dns_cache=300)
url = self._build_http_stream_url()
headers = {
"xi-api-key": self._api_key,
"Content-Type": "application/json",
}
request_count = 0
audio_chunk_count = 0
audio_byte_count = 0
yielded_byte_count = 0
resample_state = None
pcm_remainder = b""
stream_started_monotonic = time.perf_counter()
LOGGER.info(
"ElevenLabs TTS HTTP streaming: voice_id=%s model=%s output_format=%s "
"provider_sample_rate=%s target_sample_rate=%s language=%s",
self._voice_id,
self._model_id,
self._output_format,
self._provider_sample_rate_hz,
self._target_sample_rate_hz,
language_code,
)
try:
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
async for raw_chunk in text_stream:
text = str(raw_chunk).strip()
if not text:
continue
request_count += 1
request_started_monotonic = time.perf_counter()
request_provider_bytes = 0
request_yielded_bytes = 0
payload = self._http_stream_payload(text=text, language_code=language_code)
LOGGER.info(
"ElevenLabs TTS HTTP request start: voice_id=%s model=%s index=%s chars=%s preview=%r",
self._voice_id,
self._model_id,
request_count,
len(text),
_preview_text(text),
)
async with session.post(url, headers=headers, json=payload) as response:
if response.status >= 400:
payload_text = await response.text()
raise RuntimeError(
f"ElevenLabs TTS HTTP {response.status}: {payload_text[:300]}"
)
async for audio_chunk in response.content.iter_chunked(4096):
if not audio_chunk:
continue
audio_chunk_count += 1
audio_byte_count += len(audio_chunk)
request_provider_bytes += len(audio_chunk)
audio_chunk, resample_state, pcm_remainder = self._normalize_pcm_chunk(
audio_chunk,
resample_state=resample_state,
pcm_remainder=pcm_remainder,
)
yielded_byte_count += len(audio_chunk)
request_yielded_bytes += len(audio_chunk)
if audio_chunk_count == 1:
LOGGER.info(
"ElevenLabs TTS first audio: voice_id=%s model=%s transport=http_stream "
"ttfa_ms=%s provider_bytes=%s yielded_bytes=%s",
self._voice_id,
self._model_id,
int((time.perf_counter() - stream_started_monotonic) * 1000.0),
audio_byte_count,
len(audio_chunk),
)
elif audio_chunk_count % 10 == 0:
LOGGER.info(
"ElevenLabs TTS audio summary: voice_id=%s chunks=%s "
"provider_bytes=%s yielded_bytes=%s",
self._voice_id,
audio_chunk_count,
audio_byte_count,
yielded_byte_count,
)
if audio_chunk:
yield audio_chunk
LOGGER.info(
"ElevenLabs TTS HTTP request completed: voice_id=%s model=%s index=%s "
"provider_bytes=%s yielded_bytes=%s total_ms=%s",
self._voice_id,
self._model_id,
request_count,
request_provider_bytes,
request_yielded_bytes,
int((time.perf_counter() - request_started_monotonic) * 1000.0),
)
except asyncio.TimeoutError as exc:
raise RuntimeError("ElevenLabs TTS HTTP stream timed out") from exc
except aiohttp.ClientError as exc:
raise RuntimeError("ElevenLabs TTS HTTP stream failed") from exc
LOGGER.info(
"ElevenLabs TTS HTTP stream completed: voice_id=%s model=%s requests=%s chunks=%s "
"provider_bytes=%s yielded_bytes=%s total_ms=%s",
self._voice_id,
self._model_id,
request_count,
audio_chunk_count,
audio_byte_count,
yielded_byte_count,
int((time.perf_counter() - stream_started_monotonic) * 1000.0),
)
def _build_websocket_url(self, *, language_code: str | None = None) -> str:
query = {
"model_id": self._model_id,
@@ -303,7 +460,7 @@ class ElevenLabsTTS(BaseTTS):
"inactivity_timeout": self._inactivity_timeout_seconds,
"auto_mode": str(self._auto_mode).lower(),
"sync_alignment": "false",
"apply_text_normalization": "auto",
"apply_text_normalization": self._apply_text_normalization,
}
effective_language = (str(language_code).strip() if language_code else "") or self._language_code
if effective_language:
@@ -311,6 +468,32 @@ class ElevenLabsTTS(BaseTTS):
encoded_voice_id = quote(self._voice_id, safe="")
return f"{self._ws_base}/v1/text-to-speech/{encoded_voice_id}/stream-input?{urlencode(query)}"
def _build_http_stream_url(self) -> str:
query = {
"output_format": self._output_format,
}
encoded_voice_id = quote(self._voice_id, safe="")
return f"{self._api_base}/v1/text-to-speech/{encoded_voice_id}/stream?{urlencode(query)}"
def _http_stream_payload(self, *, text: str, language_code: str | None = None) -> dict[str, object]:
payload: dict[str, object] = {
"text": text,
"model_id": self._model_id,
"voice_settings": self._voice_settings,
"apply_text_normalization": self._apply_text_normalization,
}
effective_language = (str(language_code).strip() if language_code else "") or self._language_code
if effective_language:
payload["language_code"] = effective_language
return payload
def _resolved_transport_mode(self) -> str:
if self._transport_mode != "auto":
return self._transport_mode
if self._model_id == "eleven_v3":
return "http_stream"
return "websocket"
def _initial_payload(self) -> dict[str, object]:
payload: dict[str, object] = {
"text": " ",
@@ -373,20 +556,6 @@ class ElevenLabsTTS(BaseTTS):
return text
return f"{text} "
@staticmethod
def _resolve_websocket_model_id(*, requested_model_id: str, fallback_model_id: str) -> str:
normalized_requested = requested_model_id.strip() or "eleven_multilingual_v2"
if normalized_requested not in {"eleven_v3", "eleven_ttv_v3"}:
return normalized_requested
normalized_fallback = fallback_model_id.strip() or "eleven_multilingual_v2"
LOGGER.warning(
"ElevenLabs WebSocket TTS does not support model_id=%s; falling back to model_id=%s",
normalized_requested,
normalized_fallback,
)
return normalized_fallback
@staticmethod
def _resolve_provider_output_format(*, requested_output_format: str, target_sample_rate_hz: int) -> str:
normalized = requested_output_format.strip().lower() or "pcm_16000"