This commit is contained in:
Magzhan Zhumabayev
2026-05-01 18:08:46 +05:00
parent 1f07c710e1
commit 5dda1e10a3
5 changed files with 169 additions and 29 deletions
+5 -1
View File
@@ -37,6 +37,8 @@ class BaseSTT(ABC):
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
raise NotImplementedError
@@ -88,8 +90,10 @@ class MockSTT(BaseSTT):
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
del keyterms
del keyterms, language_code, force_batch
await asyncio.sleep(self._latency_ms / 1000.0)
self._call_count += 1
if self._scripted_transcripts:
+79 -24
View File
@@ -52,6 +52,42 @@ def _normalize_keyterms(
return normalized
def _normalize_elevenlabs_language_code(language_code: str | None) -> str | None:
normalized = str(language_code or "").strip()
if not normalized:
return None
lowered = normalized.lower().replace("_", "-")
mapping = {
"kz": "kaz",
"kk": "kaz",
"kaz": "kaz",
"kk-kz": "kaz",
"kz-kz": "kaz",
"ru": "rus",
"rus": "rus",
"ru-ru": "rus",
}
return mapping.get(lowered, normalized)
def _normalize_yandex_language_code(language_code: str | None) -> str | None:
normalized = str(language_code or "").strip()
if not normalized:
return None
lowered = normalized.lower().replace("_", "-")
mapping = {
"ru": "ru-RU",
"rus": "ru-RU",
"ru-ru": "ru-RU",
"kk": "kk-KZ",
"kaz": "kk-KZ",
"kz": "kk-KZ",
"kk-kz": "kk-KZ",
"kz-kz": "kk-KZ",
}
return mapping.get(lowered, normalized)
def _api_base() -> str:
return (os.getenv("ELEVENLABS_API_BASE", "https://api.elevenlabs.io").strip() or "https://api.elevenlabs.io").rstrip("/")
@@ -107,18 +143,7 @@ def _yandex_language() -> str:
or os.getenv("AI_VOICE_ASR_YANDEX_LANGUAGE", "").strip()
or "ru-RU"
)
normalized = raw.lower().replace("_", "-")
mapping = {
"ru": "ru-RU",
"rus": "ru-RU",
"ru-ru": "ru-RU",
"kk": "kk-KZ",
"kaz": "kk-KZ",
"kz": "kk-KZ",
"kk-kz": "kk-KZ",
"kz-kz": "kk-KZ",
}
return mapping.get(normalized, raw)
return _normalize_yandex_language_code(raw) or "ru-RU"
def _yandex_topic() -> str:
@@ -562,21 +587,26 @@ class ElevenLabsSTT(BaseSTT):
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
if not audio_bytes:
return ""
if not self._api_key:
raise RuntimeError("ELEVENLABS_API_KEY is required for ElevenLabs STT")
effective_language_code = _normalize_elevenlabs_language_code(language_code) or self._language_code
pcm_bytes, sample_rate_hz = self._extract_pcm(audio_bytes)
LOGGER.info(
"ElevenLabs STT transcribe start: input_bytes=%s extracted_pcm_bytes=%s sample_rate=%s duration_ms=%s "
"use_realtime=%s keyterms=%s",
"use_realtime=%s force_batch=%s language=%s keyterms=%s",
len(audio_bytes),
len(pcm_bytes),
sample_rate_hz,
_pcm_duration_ms(pcm_bytes, sample_rate_hz=sample_rate_hz),
self._use_realtime,
force_batch,
effective_language_code,
len(keyterms) if keyterms else 0,
)
if sample_rate_hz != self._target_sample_rate_hz:
@@ -596,12 +626,13 @@ class ElevenLabsSTT(BaseSTT):
len(pcm_bytes),
)
if self._use_realtime:
if self._use_realtime and not force_batch:
try:
transcript = await self._transcribe_realtime(
pcm_bytes=pcm_bytes,
sample_rate_hz=sample_rate_hz,
keyterms=keyterms,
language_code=effective_language_code,
)
LOGGER.info(
"ElevenLabs STT realtime transcript result: chars=%s transcript=%r",
@@ -618,6 +649,7 @@ class ElevenLabsSTT(BaseSTT):
pcm_bytes=pcm_bytes,
sample_rate_hz=sample_rate_hz,
keyterms=keyterms,
language_code=effective_language_code,
)
LOGGER.info(
"ElevenLabs STT batch transcript result: chars=%s transcript=%r",
@@ -678,6 +710,7 @@ class ElevenLabsSTT(BaseSTT):
pcm_bytes: bytes,
sample_rate_hz: int,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
) -> str:
try:
from websockets.exceptions import WebSocketException
@@ -688,6 +721,7 @@ class ElevenLabsSTT(BaseSTT):
websocket_url = self._build_realtime_websocket_url(
sample_rate_hz=sample_rate_hz,
keyterms=keyterms,
language_code=language_code,
)
chunk_bytes = max(int(sample_rate_hz * self._realtime_chunk_duration_ms / 1000.0) * 2, 320)
started_monotonic = time.perf_counter()
@@ -790,6 +824,7 @@ class ElevenLabsSTT(BaseSTT):
pcm_bytes: bytes,
sample_rate_hz: int,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
) -> str:
# TODO: Architectural Bottleneck: Рассмотреть замену STT на Deepgram WebSocket API для достижения true-streaming latency.
try:
@@ -817,15 +852,16 @@ class ElevenLabsSTT(BaseSTT):
len(pcm_bytes),
len(wav_bytes),
_pcm_duration_ms(pcm_bytes, sample_rate_hz=sample_rate_hz),
self._language_code,
language_code or self._language_code,
len(normalized_keyterms),
)
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)
effective_language_code = language_code or self._language_code
if effective_language_code:
form.add_field("language_code", effective_language_code)
if normalized_keyterms:
form.add_field("keyterms", json.dumps(normalized_keyterms, ensure_ascii=False))
form.add_field(
@@ -927,6 +963,7 @@ class ElevenLabsSTT(BaseSTT):
*,
sample_rate_hz: int,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
) -> str:
query: list[tuple[str, str]] = [
("model_id", self._realtime_model_id),
@@ -934,8 +971,9 @@ class ElevenLabsSTT(BaseSTT):
("commit_strategy", "manual"),
("include_timestamps", "false"),
]
if self._language_code:
query.append(("language_code", self._language_code))
effective_language_code = language_code or self._language_code
if effective_language_code:
query.append(("language_code", effective_language_code))
for term in _normalize_keyterms(
keyterms,
max_count=_REALTIME_MAX_KEYTERMS,
@@ -1004,20 +1042,25 @@ class YandexSpeechKitSTT(BaseSTT):
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
del keyterms
del keyterms, force_batch
if not audio_bytes:
return ""
if not self._api_key and not self._iam_token:
raise RuntimeError("YANDEX_STT_API_KEY or YANDEX_STT_IAM_TOKEN is required for Yandex STT")
effective_language = _normalize_yandex_language_code(language_code) or self._language
pcm_bytes, sample_rate_hz = self._extract_pcm(audio_bytes)
LOGGER.info(
"Yandex SpeechKit STT transcribe start: input_bytes=%s extracted_pcm_bytes=%s sample_rate=%s duration_ms=%s",
"Yandex SpeechKit STT transcribe start: input_bytes=%s extracted_pcm_bytes=%s sample_rate=%s "
"duration_ms=%s language=%s",
len(audio_bytes),
len(pcm_bytes),
sample_rate_hz,
_pcm_duration_ms(pcm_bytes, sample_rate_hz=sample_rate_hz),
effective_language,
)
if sample_rate_hz != self._target_sample_rate_hz:
before_rate_hz = sample_rate_hz
@@ -1038,7 +1081,7 @@ class YandexSpeechKitSTT(BaseSTT):
session = await self._get_client_session()
params = {
"lang": self._language,
"lang": effective_language,
"topic": self._topic,
"format": "lpcm",
"sampleRateHertz": str(sample_rate_hz),
@@ -1145,16 +1188,28 @@ class FallbackSTT(BaseSTT):
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
try:
return await self._primary.transcribe(audio_bytes, keyterms=keyterms)
return await self._primary.transcribe(
audio_bytes,
keyterms=keyterms,
language_code=language_code,
force_batch=force_batch,
)
except Exception:
LOGGER.exception(
"Primary STT provider failed; falling back: primary=%s fallback=%s",
type(self._primary).__name__,
type(self._fallback).__name__,
)
return await self._fallback.transcribe(audio_bytes, keyterms=keyterms)
return await self._fallback.transcribe(
audio_bytes,
keyterms=keyterms,
language_code=language_code,
force_batch=force_batch,
)
async def close(self) -> None:
for provider in (self._primary, self._fallback):
+19 -4
View File
@@ -15,6 +15,18 @@ from realtime_voice_service.providers.base import BaseSTT
LOGGER = logging.getLogger("uvicorn.error")
def _normalize_openai_language_code(language_code: str | None) -> str | None:
normalized = str(language_code or "").strip()
if not normalized:
return None
lowered = normalized.lower()
if lowered in {"kz", "kk", "kaz", "kk-kz"}:
return "kk"
if lowered in {"ru", "rus", "ru-ru"}:
return "ru"
return normalized
def _timeout_seconds() -> float:
raw = os.getenv("OPENAI_STT_TIMEOUT_SECONDS") or os.getenv("OPENAI_TIMEOUT_SECONDS")
if raw is None:
@@ -100,13 +112,16 @@ class OpenAISTT(BaseSTT):
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
del keyterms
del keyterms, force_batch
if not audio_bytes:
return ""
if not self._api_key:
raise RuntimeError("OPENAI_API_KEY is required for OpenAI STT")
effective_language = _normalize_openai_language_code(language_code) or self._language
wav_bytes = _pcm16le_to_wav_bytes(audio_bytes, sample_rate_hz=self._input_sample_rate_hz)
audio_file = io.BytesIO(wav_bytes)
audio_file.name = "utterance.wav"
@@ -118,7 +133,7 @@ class OpenAISTT(BaseSTT):
len(audio_bytes),
len(wav_bytes),
self._input_sample_rate_hz,
self._language,
effective_language,
_preview_text(self._prompt),
)
@@ -129,8 +144,8 @@ class OpenAISTT(BaseSTT):
}
if self._prompt:
request["prompt"] = self._prompt
if self._language:
request["language"] = self._language
if effective_language:
request["language"] = effective_language
try:
response = await self._get_client().audio.transcriptions.create(**request)