"""Local speech-to-text support for short player utterances.""" from __future__ import annotations from pathlib import Path from typing import Any import wave MODEL_ID = "nvidia/nemotron-speech-streaming-en-0.6b" MAX_RECORDING_SECONDS = 10 MAX_RECORDING_DRIFT_SECONDS = 0.75 MIN_RECORDING_SECONDS = 0.15 _ASR_MODEL: Any | None = None class SpeechTranscriptionError(RuntimeError): """Raised when local speech transcription cannot produce usable text.""" def _load_asr_model() -> Any: global _ASR_MODEL if _ASR_MODEL is None: try: import nemo.collections.asr as nemo_asr except Exception as exc: raise SpeechTranscriptionError( "Speech recognition is not installed. Install NVIDIA NeMo ASR locally." ) from exc try: _ASR_MODEL = nemo_asr.models.ASRModel.from_pretrained(model_name=MODEL_ID) except Exception as exc: raise SpeechTranscriptionError( f"Could not load local speech model {MODEL_ID}." ) from exc return _ASR_MODEL def _duration_seconds(audio_path: Path) -> float | None: try: with wave.open(str(audio_path), "rb") as fh: rate = fh.getframerate() if rate <= 0: return None return fh.getnframes() / float(rate) except (wave.Error, EOFError): return None def _validate_audio_path(audio_path: str | Path) -> Path: path = Path(audio_path) if not path.exists() or not path.is_file(): raise SpeechTranscriptionError("No audio file was captured.") duration = _duration_seconds(path) if duration is None: raise SpeechTranscriptionError("No speech detected in the recording.") if duration < MIN_RECORDING_SECONDS: raise SpeechTranscriptionError("No speech detected in the recording.") if duration > MAX_RECORDING_SECONDS + MAX_RECORDING_DRIFT_SECONDS: raise SpeechTranscriptionError( "Recording is too long. Try again with a shorter line." ) return path def _first_transcript(result: Any) -> str: if result is None: return "" if isinstance(result, str): return result if hasattr(result, "text"): return str(result.text) if isinstance(result, dict): for key in ("text", "transcript"): if key in result: return str(result[key]) if isinstance(result, (list, tuple)): if not result: return "" return _first_transcript(result[0]) return str(result) def _normalize_transcript(result: Any) -> str: transcript = " ".join(_first_transcript(result).split()) if not transcript: raise SpeechTranscriptionError("No speech detected in the recording.") return transcript def transcribe_audio(audio_path: str | Path, *, model: Any | None = None) -> str: path = _validate_audio_path(audio_path) asr_model = model if model is not None else _load_asr_model() try: result = asr_model.transcribe([str(path)], batch_size=1) except SpeechTranscriptionError: raise except Exception as exc: raise SpeechTranscriptionError("Speech transcription failed.") from exc return _normalize_transcript(result)