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

155 lines
5.5 KiB
Python

from __future__ import annotations
import asyncio
import audioop
import io
import json
import os
import wave
from realtime_voice_service.providers.base import BaseSTT
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 _pcm16le_to_wav_bytes(
pcm_bytes: bytes,
*,
sample_rate_hz: int,
channels: int = 1,
sample_width_bytes: int = 2,
) -> bytes:
handle = io.BytesIO()
with wave.open(handle, "wb") as wav_file:
wav_file.setnchannels(channels)
wav_file.setsampwidth(sample_width_bytes)
wav_file.setframerate(sample_rate_hz)
wav_file.writeframes(pcm_bytes)
return handle.getvalue()
def _resample_pcm16le(
pcm_bytes: bytes,
*,
input_rate_hz: int,
output_rate_hz: int,
) -> bytes:
if not pcm_bytes or input_rate_hz == output_rate_hz:
return pcm_bytes
converted, _ = audioop.ratecv(
pcm_bytes,
2,
1,
input_rate_hz,
output_rate_hz,
None,
)
return converted
class ElevenLabsSTT(BaseSTT):
def __init__(
self,
*,
api_key: str | None = None,
api_base: str | None = None,
model_id: str | None = None,
input_sample_rate_hz: int = 8000,
target_sample_rate_hz: int = 16000,
timeout_seconds: float | None = None,
language_code: str | None = None,
) -> 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._model_id = str(model_id or os.getenv("ELEVENLABS_STT_MODEL_ID", "scribe_v2")).strip() or "scribe_v2"
self._input_sample_rate_hz = max(int(input_sample_rate_hz), 1)
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._language_code = str(language_code or os.getenv("ELEVENLABS_STT_LANGUAGE_CODE", "")).strip() or None
async def transcribe(self, audio_bytes: bytes) -> str:
if not audio_bytes:
return ""
if not self._api_key:
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs STT")
pcm_bytes, sample_rate_hz = self._extract_pcm(audio_bytes)
if sample_rate_hz != self._target_sample_rate_hz:
pcm_bytes = _resample_pcm16le(
pcm_bytes,
input_rate_hz=sample_rate_hz,
output_rate_hz=self._target_sample_rate_hz,
)
sample_rate_hz = self._target_sample_rate_hz
wav_bytes = _pcm16le_to_wav_bytes(pcm_bytes, sample_rate_hz=sample_rate_hz)
try:
import aiohttp
except Exception as exc: # noqa: BLE001
raise RuntimeError("The `aiohttp` package is required for ElevenLabs STT") from exc
form = aiohttp.FormData()
form.add_field("model_id", self._model_id)
form.add_field("timestamps_granularity", "none")
form.add_field("diarize", "false")
if self._language_code:
form.add_field("language_code", self._language_code)
form.add_field(
"file",
wav_bytes,
filename="turn.wav",
content_type="audio/wav",
)
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/speech-to-text",
headers={"xi-api-key": self._api_key},
data=form,
) as response:
payload_text = await response.text()
if response.status >= 400:
raise RuntimeError(
f"ElevenLabs STT returned HTTP {response.status}: {payload_text[:300]}"
)
except (TimeoutError, asyncio.TimeoutError) as exc:
raise RuntimeError("ElevenLabs STT request timed out") from exc
except aiohttp.ClientError as exc:
raise RuntimeError("ElevenLabs STT request failed") from exc
try:
payload = json.loads(payload_text)
except json.JSONDecodeError as exc:
raise RuntimeError("ElevenLabs STT returned invalid JSON") from exc
return str(payload.get("text") or payload.get("transcript") or "").strip()
def _extract_pcm(self, audio_bytes: bytes) -> tuple[bytes, int]:
try:
with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file:
pcm_bytes = wav_file.readframes(wav_file.getnframes())
sample_width = wav_file.getsampwidth()
channels = wav_file.getnchannels()
sample_rate_hz = int(wav_file.getframerate() or self._input_sample_rate_hz)
if sample_width != 2:
return audio_bytes, self._input_sample_rate_hz
if channels == 2:
pcm_bytes = audioop.tomono(pcm_bytes, sample_width, 0.5, 0.5)
return pcm_bytes, sample_rate_hz
except (wave.Error, EOFError):
return audio_bytes, self._input_sample_rate_hz