from __future__ import annotations import audioop import base64 import logging import os import threading import time from dataclasses import dataclass, field from typing import Any, Protocol from fastapi import Depends, FastAPI, HTTPException from pydantic import BaseModel from services.shared.core import Role, new_id from services.shared.models import HealthResponse from services.shared.security import require_roles LOGGER = logging.getLogger("uvicorn.error") app = FastAPI(title="streaming-asr-sidecar-service", version="1.0.0") def _int_env(name: str, default: int) -> int: raw = os.getenv(name) if raw is None: return default try: return int(str(raw).strip()) except ValueError: return default def _float_env(name: str, default: float) -> float: raw = os.getenv(name) if raw is None: return default try: return float(str(raw).strip()) except ValueError: return default def _stream_idle_ttl_seconds() -> float: return max(_float_env("AI_VOICE_V2_STREAMING_ASR_IDLE_TTL_SECONDS", 45.0), 5.0) def _partial_min_audio_ms() -> int: return max(_int_env("AI_VOICE_V2_STREAMING_ASR_MIN_AUDIO_MS", 320), 120) def _partial_recompute_interval_ms() -> int: return max(_int_env("AI_VOICE_V2_STREAMING_ASR_PARTIAL_RECOMPUTE_INTERVAL_MS", 450), 80) def _partial_stability_hold_ms() -> int: return max(_int_env("AI_VOICE_V2_STREAMING_ASR_PARTIAL_STABILITY_HOLD_MS", 400), 120) def _partial_max_audio_ms() -> int: return max(_int_env("AI_VOICE_V2_STREAMING_ASR_PARTIAL_MAX_AUDIO_MS", 1800), 400) def _model_name() -> str: return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_MODEL", "base") or "base").strip() or "base" def _compute_type() -> str: return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE", "int8") or "int8").strip() or "int8" def _device() -> str: return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_DEVICE", "cpu") or "cpu").strip() or "cpu" def _cache_dir() -> str: return str(os.getenv("AI_VOICE_V2_STREAMING_ASR_CACHE_DIR", "/models/faster_whisper") or "/models/faster_whisper").strip() or "/models/faster_whisper" def _supported_languages() -> set[str]: raw = str(os.getenv("AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES", "ru") or "ru").strip() values = {item.strip().lower() for item in raw.split(",") if item.strip()} return values or {"ru"} def _target_sample_rate_hz() -> int: return max(_int_env("AI_VOICE_V2_STREAMING_ASR_TARGET_SAMPLE_RATE_HZ", 16000), 8000) def _normalize_language_hint(language_hint: str | None) -> str: raw = str(language_hint or "ru").strip().lower() or "ru" if raw in {"ru", "ru-ru"}: return "ru" if raw in {"kk", "kz", "kk-kk"}: return "kk" return raw def _pcm_duration_ms(pcm_bytes: bytes | bytearray, sample_rate_hz: int) -> int: if sample_rate_hz <= 0: return 0 sample_count = len(pcm_bytes) // 2 return int((sample_count / float(sample_rate_hz)) * 1000.0) 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 @dataclass(slots=True) class SidecarTranscript: text: str language: str | None = None confidence: float | None = None class TranscriptionEngine(Protocol): def transcribe_pcm( self, pcm_bytes: bytes, *, sample_rate_hz: int, language_hint: str | None = None, ) -> SidecarTranscript: ... class FasterWhisperEngine: def __init__( self, *, model_name: str, compute_type: str, device: str, cache_dir: str, target_sample_rate_hz: int, ) -> None: self._model_name = model_name self._compute_type = compute_type self._device = device self._cache_dir = cache_dir self._target_sample_rate_hz = target_sample_rate_hz self._model = None self._lock = threading.Lock() def _load_model(self): with self._lock: if self._model is not None: return self._model try: from faster_whisper import WhisperModel except Exception as exc: # noqa: BLE001 raise RuntimeError( "faster-whisper is not installed; install requirements before starting the streaming ASR sidecar" ) from exc self._model = WhisperModel( self._model_name, device=self._device, compute_type=self._compute_type, download_root=self._cache_dir, ) return self._model def transcribe_pcm( self, pcm_bytes: bytes, *, sample_rate_hz: int, language_hint: str | None = None, ) -> SidecarTranscript: if not pcm_bytes: return SidecarTranscript(text="", language=language_hint, confidence=None) try: import numpy as np except Exception as exc: # noqa: BLE001 raise RuntimeError("numpy is required for streaming ASR sidecar") from exc model = self._load_model() pcm_model_rate = _resample_pcm16le( pcm_bytes, input_rate_hz=sample_rate_hz, output_rate_hz=self._target_sample_rate_hz, ) waveform = np.frombuffer(pcm_model_rate, dtype=np.int16).astype(np.float32) / 32768.0 segments, info = model.transcribe( waveform, language=_normalize_language_hint(language_hint), beam_size=1, best_of=1, temperature=0.0, vad_filter=False, word_timestamps=False, condition_on_previous_text=False, without_timestamps=True, ) parts: list[str] = [] for segment in segments: text = str(getattr(segment, "text", "") or "").strip() if text: parts.append(text) text = " ".join(parts).strip() language = str(getattr(info, "language", "") or language_hint or "").strip() or language_hint confidence_raw = getattr(info, "language_probability", None) confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else None if confidence is not None: confidence = max(0.0, min(confidence, 1.0)) return SidecarTranscript(text=text, language=language, confidence=confidence) def _default_engine_factory() -> TranscriptionEngine: return FasterWhisperEngine( model_name=_model_name(), compute_type=_compute_type(), device=_device(), cache_dir=_cache_dir(), target_sample_rate_hz=_target_sample_rate_hz(), ) _ENGINE_FACTORY = _default_engine_factory _ENGINE_INSTANCE: TranscriptionEngine | None = None _ENGINE_LOCK = threading.Lock() def _get_engine() -> TranscriptionEngine: global _ENGINE_INSTANCE with _ENGINE_LOCK: if _ENGINE_INSTANCE is None: _ENGINE_INSTANCE = _ENGINE_FACTORY() return _ENGINE_INSTANCE def _warmup_engine() -> None: try: _get_engine() LOGGER.info("streaming_asr.engine_ready") except Exception as exc: # noqa: BLE001 LOGGER.warning("streaming_asr.engine_warmup_failed error=%s", str(exc)[:500]) @app.on_event("startup") def _startup_warmup_engine() -> None: threading.Thread(target=_warmup_engine, daemon=True).start() @dataclass(slots=True) class StreamState: stream_id: str session_id: str language_hint: str sample_rate_hz: int encoding: str pcm_buffer: bytearray = field(default_factory=bytearray) created_monotonic: float = field(default_factory=time.monotonic) last_activity_monotonic: float = field(default_factory=time.monotonic) buffer_version: int = 0 partial_text: str = "" partial_language: str | None = None partial_confidence: float | None = None partial_buffer_version: int = -1 partial_updated_monotonic: float = 0.0 partial_first_seen_monotonic: float = 0.0 partial_repeat_count: int = 0 final_transcript: SidecarTranscript | None = None def touch(self) -> None: self.last_activity_monotonic = time.monotonic() class StreamStore: def __init__(self, *, idle_ttl_seconds: float) -> None: self._idle_ttl_seconds = idle_ttl_seconds self._streams: dict[str, StreamState] = {} self._lock = threading.Lock() def cleanup_expired(self) -> list[str]: now = time.monotonic() removed: list[str] = [] with self._lock: for stream_id, stream in list(self._streams.items()): if now - stream.last_activity_monotonic < self._idle_ttl_seconds: continue removed.append(stream_id) self._streams.pop(stream_id, None) return removed def create(self, *, session_id: str, language_hint: str, sample_rate_hz: int, encoding: str) -> StreamState: stream = StreamState( stream_id=new_id("sasr"), session_id=session_id, language_hint=language_hint, sample_rate_hz=sample_rate_hz, encoding=encoding, ) with self._lock: self._streams[stream.stream_id] = stream return stream def get(self, stream_id: str) -> StreamState | None: with self._lock: stream = self._streams.get(stream_id) if stream is not None: stream.touch() return stream def append_pcm(self, stream_id: str, pcm_chunk: bytes) -> StreamState: with self._lock: stream = self._streams.get(stream_id) if stream is None: raise KeyError(stream_id) stream.pcm_buffer.extend(pcm_chunk) stream.buffer_version += 1 stream.touch() return stream def update_partial( self, stream_id: str, *, transcript: SidecarTranscript, buffer_version: int, ) -> StreamState | None: now = time.monotonic() with self._lock: stream = self._streams.get(stream_id) if stream is None: return None previous_text = stream.partial_text stream.touch() if transcript.text: if transcript.text == previous_text: stream.partial_repeat_count += 1 else: stream.partial_first_seen_monotonic = now stream.partial_repeat_count = 1 stream.partial_text = transcript.text stream.partial_language = transcript.language stream.partial_confidence = transcript.confidence stream.partial_updated_monotonic = now stream.partial_buffer_version = buffer_version return stream def set_final(self, stream_id: str, transcript: SidecarTranscript) -> StreamState | None: with self._lock: stream = self._streams.get(stream_id) if stream is None: return None stream.final_transcript = transcript stream.touch() return stream def delete(self, stream_id: str) -> bool: with self._lock: return self._streams.pop(stream_id, None) is not None _STREAMS = StreamStore(idle_ttl_seconds=_stream_idle_ttl_seconds()) class OpenStreamIn(BaseModel): session_id: str language_hint: str | None = None sample_rate_hz: int = 8000 encoding: str = "pcm_s16le" class OpenStreamOut(BaseModel): ok: bool = True stream_id: str language: str class PushChunkIn(BaseModel): pcm_b64: str sample_rate_hz: int = 8000 encoding: str = "pcm_s16le" class PushChunkOut(BaseModel): ok: bool = True stream_id: str received_bytes: int duration_ms: int class PartialOut(BaseModel): text: str language: str | None = None confidence: float | None = None is_final: bool = False is_stable: bool = False class FinalizeOut(BaseModel): text: str language: str | None = None confidence: float | None = None def _require_internal_actor(_: dict = Depends(require_roles(Role.ADMIN))) -> None: return None def _cleanup_expired_streams() -> None: removed = _STREAMS.cleanup_expired() if removed: LOGGER.info("streaming_asr.cleanup_expired removed=%s", len(removed)) def _current_partial_is_stable(stream: StreamState) -> bool: if not stream.partial_text: return False if stream.partial_repeat_count >= 2: return True return (time.monotonic() - stream.partial_first_seen_monotonic) >= (_partial_stability_hold_ms() / 1000.0) def _copy_stream_snapshot(stream: StreamState) -> tuple[bytes, int, int, str]: return ( bytes(stream.pcm_buffer), stream.buffer_version, stream.sample_rate_hz, stream.language_hint, ) def _trim_partial_snapshot(pcm_bytes: bytes, *, sample_rate_hz: int) -> bytes: if not pcm_bytes or sample_rate_hz <= 0: return pcm_bytes max_bytes = int((_partial_max_audio_ms() / 1000.0) * sample_rate_hz * 2) if max_bytes <= 0 or len(pcm_bytes) <= max_bytes: return pcm_bytes return pcm_bytes[-max_bytes:] def _transcribe_stream_snapshot(pcm_bytes: bytes, *, sample_rate_hz: int, language_hint: str) -> SidecarTranscript: return _get_engine().transcribe_pcm( pcm_bytes, sample_rate_hz=sample_rate_hz, language_hint=language_hint, ) def _current_partial_payload(stream: StreamState) -> PartialOut | None: if not stream.partial_text: return None return PartialOut( text=stream.partial_text, language=stream.partial_language, confidence=stream.partial_confidence, is_final=False, is_stable=_current_partial_is_stable(stream), ) def _language_or_error(language_hint: str | None) -> str: normalized = _normalize_language_hint(language_hint) if normalized not in _supported_languages(): raise HTTPException( status_code=400, detail=f"Unsupported streaming ASR language: {normalized}", ) return normalized def _validate_audio_params(sample_rate_hz: int, encoding: str) -> None: if int(sample_rate_hz) != 8000: raise HTTPException(status_code=400, detail="Only 8000 Hz streaming ASR input is supported") if str(encoding or "").strip().lower() != "pcm_s16le": raise HTTPException(status_code=400, detail="Only pcm_s16le encoding is supported") @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: _cleanup_expired_streams() with _ENGINE_LOCK: loaded = _ENGINE_INSTANCE is not None suffix = "loaded" if loaded else "cold" return HealthResponse(status="ok", service=f"streaming-asr-sidecar ({suffix})") @app.post("/internal/asr/streams", response_model=OpenStreamOut) def open_stream(payload: OpenStreamIn, _: None = Depends(_require_internal_actor)) -> OpenStreamOut: _cleanup_expired_streams() session_id = str(payload.session_id or "").strip() if not session_id: raise HTTPException(status_code=400, detail="session_id is required") language = _language_or_error(payload.language_hint) _validate_audio_params(payload.sample_rate_hz, payload.encoding) stream = _STREAMS.create( session_id=session_id, language_hint=language, sample_rate_hz=payload.sample_rate_hz, encoding=payload.encoding, ) return OpenStreamOut(stream_id=stream.stream_id, language=language) @app.post("/internal/asr/streams/{stream_id}/chunks", response_model=PushChunkOut) def push_chunk(stream_id: str, payload: PushChunkIn, _: None = Depends(_require_internal_actor)) -> PushChunkOut: _cleanup_expired_streams() _validate_audio_params(payload.sample_rate_hz, payload.encoding) try: pcm_bytes = base64.b64decode(str(payload.pcm_b64 or "").encode("ascii"), validate=True) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail="pcm_b64 must be valid base64") from exc try: stream = _STREAMS.append_pcm(stream_id, pcm_bytes) except KeyError as exc: raise HTTPException(status_code=404, detail="ASR stream not found") from exc return PushChunkOut( stream_id=stream_id, received_bytes=len(pcm_bytes), duration_ms=_pcm_duration_ms(stream.pcm_buffer, stream.sample_rate_hz), ) @app.get("/internal/asr/streams/{stream_id}/partial", response_model=PartialOut | dict[str, Any]) def poll_partial(stream_id: str, _: None = Depends(_require_internal_actor)) -> PartialOut | dict[str, Any]: _cleanup_expired_streams() stream = _STREAMS.get(stream_id) if stream is None: raise HTTPException(status_code=404, detail="ASR stream not found") if stream.final_transcript is not None: return PartialOut( text=stream.final_transcript.text, language=stream.final_transcript.language, confidence=stream.final_transcript.confidence, is_final=True, is_stable=True, ) if _pcm_duration_ms(stream.pcm_buffer, stream.sample_rate_hz) < _partial_min_audio_ms(): payload = _current_partial_payload(stream) return payload.model_dump() if payload else {} now = time.monotonic() if ( stream.partial_text and stream.partial_buffer_version == stream.buffer_version and (now - stream.partial_updated_monotonic) < (_partial_recompute_interval_ms() / 1000.0) ): payload = _current_partial_payload(stream) return payload.model_dump() if payload else {} pcm_bytes, buffer_version, sample_rate_hz, language_hint = _copy_stream_snapshot(stream) transcript = _transcribe_stream_snapshot( _trim_partial_snapshot(pcm_bytes, sample_rate_hz=sample_rate_hz), sample_rate_hz=sample_rate_hz, language_hint=language_hint, ) stream = _STREAMS.update_partial( stream_id, transcript=transcript, buffer_version=buffer_version, ) if stream is None: raise HTTPException(status_code=404, detail="ASR stream not found") payload = _current_partial_payload(stream) return payload.model_dump() if payload else {} @app.post("/internal/asr/streams/{stream_id}/finalize", response_model=FinalizeOut) def finalize_stream(stream_id: str, _: None = Depends(_require_internal_actor)) -> FinalizeOut: _cleanup_expired_streams() stream = _STREAMS.get(stream_id) if stream is None: raise HTTPException(status_code=404, detail="ASR stream not found") if stream.final_transcript is not None: return FinalizeOut( text=stream.final_transcript.text, language=stream.final_transcript.language, confidence=stream.final_transcript.confidence, ) pcm_bytes, _buffer_version, sample_rate_hz, language_hint = _copy_stream_snapshot(stream) transcript = _transcribe_stream_snapshot( pcm_bytes, sample_rate_hz=sample_rate_hz, language_hint=language_hint, ) _STREAMS.set_final(stream_id, transcript) return FinalizeOut( text=transcript.text, language=transcript.language, confidence=transcript.confidence, ) @app.delete("/internal/asr/streams/{stream_id}") def close_stream(stream_id: str, _: None = Depends(_require_internal_actor)) -> dict[str, Any]: _cleanup_expired_streams() if not _STREAMS.delete(stream_id): raise HTTPException(status_code=404, detail="ASR stream not found") return {"ok": True, "stream_id": stream_id}