225 lines
8.2 KiB
Python
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()
|