Files

225 lines
8.2 KiB
Python

from __future__ import annotations
import asyncio
import io
import logging
import os
import time
import wave
from collections.abc import Sequence
from typing import Any
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:
return 30.0
try:
return max(float(raw.strip()), 1.0)
except ValueError:
return 30.0
def _max_retries() -> int:
raw = os.getenv("OPENAI_STT_MAX_RETRIES")
if raw is None:
return 2
try:
return max(int(raw.strip()), 0)
except ValueError:
return 2
def _preview_text(text: str, *, limit: int = 120) -> str:
normalized = " ".join(str(text or "").split())
if len(normalized) <= limit:
return normalized
return f"{normalized[:limit]}..."
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()
class OpenAISTT(BaseSTT):
name = "openai"
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
base_url: str | None = None,
input_sample_rate_hz: int = 16000,
prompt: str | None = None,
language: str | None = None,
timeout_seconds: float | None = None,
max_retries: int | None = None,
) -> None:
self._api_key = str(api_key if api_key is not None else os.getenv("OPENAI_API_KEY", "")).strip()
self._model = str(model or os.getenv("OPENAI_STT_MODEL", "whisper-1")).strip() or "whisper-1"
self._base_url = str(
base_url if base_url is not None else (os.getenv("OPENAI_STT_BASE_URL") or os.getenv("OPENAI_BASE_URL") or "")
).strip() or None
self._input_sample_rate_hz = max(int(input_sample_rate_hz), 1)
self._prompt = str(prompt if prompt is not None else os.getenv("STT_PROMPT", "")).strip()
self._ru_prompt = str(os.getenv("STT_PROMPT_RU", "")).strip()
self._kk_prompt = str(os.getenv("STT_PROMPT_KZ", "") or os.getenv("STT_PROMPT_KK", "")).strip()
self._language = str(language if language is not None else os.getenv("OPENAI_STT_LANGUAGE", "")).strip() or None
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 1.0)
self._max_retries = max(int(max_retries if max_retries is not None else _max_retries()), 0)
self._client: Any | None = None
self._openai_module: Any | None = None
LOGGER.info(
"OpenAI STT config: model=%s input_sample_rate=%s language=%s prompt_configured=%s "
"ru_prompt_configured=%s kk_prompt_configured=%s base_url=%s timeout=%s",
self._model,
self._input_sample_rate_hz,
self._language,
bool(self._prompt),
bool(self._ru_prompt),
bool(self._kk_prompt),
self._base_url or "default",
self._timeout_seconds,
)
async def transcribe(
self,
audio_bytes: bytes,
*,
keyterms: Sequence[str] | None = None,
language_code: str | None = None,
force_batch: bool = False,
) -> str:
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
effective_prompt = self._prompt_for_language(effective_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"
started_monotonic = time.perf_counter()
LOGGER.info(
"OpenAI STT request start: model=%s pcm_bytes=%s wav_bytes=%s sample_rate=%s language=%s prompt=%r",
self._model,
len(audio_bytes),
len(wav_bytes),
self._input_sample_rate_hz,
effective_language,
_preview_text(effective_prompt),
)
request: dict[str, object] = {
"file": audio_file,
"model": self._model,
"response_format": "json",
}
if effective_prompt:
request["prompt"] = effective_prompt
if effective_language:
request["language"] = effective_language
try:
response = await self._get_client().audio.transcriptions.create(**request)
except (TimeoutError, asyncio.TimeoutError) as exc:
raise RuntimeError("OpenAI STT request timed out") from exc
except Exception as exc: # noqa: BLE001
openai_module = self._openai_module
if openai_module is not None and isinstance(exc, getattr(openai_module, "APITimeoutError", ())):
raise RuntimeError("OpenAI STT request timed out") from exc
if openai_module is not None and isinstance(exc, getattr(openai_module, "RateLimitError", ())):
raise RuntimeError("OpenAI STT rate limit exceeded") from exc
if openai_module is not None and isinstance(exc, getattr(openai_module, "APIStatusError", ())):
status_code = getattr(exc, "status_code", "unknown")
raise RuntimeError(f"OpenAI STT returned HTTP {status_code}") from exc
if openai_module is not None and isinstance(exc, getattr(openai_module, "APIConnectionError", ())):
raise RuntimeError("OpenAI STT connection failed") from exc
raise RuntimeError("OpenAI STT request failed") from exc
transcript_value = getattr(response, "text", None)
if transcript_value is None and isinstance(response, dict):
transcript_value = response.get("text")
transcript = str(transcript_value or "").strip()
LOGGER.info(
"OpenAI STT transcript result: latency_ms=%s chars=%s transcript=%r",
int((time.perf_counter() - started_monotonic) * 1000.0),
len(transcript),
_preview_text(transcript),
)
return transcript
def _get_client(self):
if self._client is not None:
return self._client
try:
import httpx
import openai
from openai import AsyncOpenAI
except Exception as exc: # noqa: BLE001
raise RuntimeError("The `openai` package is required for OpenAI STT") from exc
timeout = httpx.Timeout(
self._timeout_seconds,
connect=min(self._timeout_seconds, 5.0),
write=min(self._timeout_seconds, 15.0),
read=self._timeout_seconds,
)
client_kwargs: dict[str, object] = {
"api_key": self._api_key,
"timeout": timeout,
"max_retries": self._max_retries,
}
if self._base_url:
client_kwargs["base_url"] = self._base_url
self._openai_module = openai
self._client = AsyncOpenAI(**client_kwargs)
return self._client
def _prompt_for_language(self, language_code: str | None) -> str:
normalized = _normalize_openai_language_code(language_code)
if normalized == "ru":
return self._ru_prompt or self._prompt
if normalized == "kk":
return self._kk_prompt or self._prompt
return self._prompt
async def close(self) -> None:
client = self._client
self._client = None
if client is not None:
await client.close()