CoolFace
Apppublic

DGXAI/driftcall

sourceHugging Faceapache-2.0updated 5mo agoView on Hugging Face
0likes
step_09_audio.py945 linesDownload Raw Back to cells
1"""Cell 09 — Audio pipeline (Kokoro-82M TTS + faster-whisper-small ASR).2 3Implements docs/modules/audio.md: TTS and ASR engines exposed at the env4boundary. Training never imports this module (docs/modules/audio.md §6.3).5Heavy deps (``kokoro``, ``faster_whisper``, ``torchaudio``, ``soundfile``)6are loaded lazily inside ``_load_*`` helpers so this cell imports cleanly7in environments where those optional packages are absent, and so tests can8monkeypatch the loaders to return fakes without ever touching the network.9"""10 11from __future__ import annotations12 13import hashlib14import io15import logging16import math17import struct18import threading19import time20import unicodedata21import wave22from collections.abc import Callable23from dataclasses import dataclass24from datetime import datetime, timedelta, timezone25from typing import Any, Literal, cast26 27import numpy as np28from cachetools import LRUCache29 30logger = logging.getLogger(__name__)31 32 33# ---------------------------------------------------------------------------34# Public literal types (audio.md §2.1, §2.2)35# ---------------------------------------------------------------------------36 37LanguageCode = Literal["hi", "ta", "kn", "en", "hinglish"]38VoicePack = Literal[39    "hi_female_1",40    "hi_male_1",41    "ta_female_1",42    "kn_male_1",43    "en_indian_female_1",44]45 46_LANGUAGE_CODES: frozenset[str] = frozenset({"hi", "ta", "kn", "en", "hinglish"})47_VOICE_PACKS_SET: frozenset[str] = frozenset(48    {49        "hi_female_1",50        "hi_male_1",51        "ta_female_1",52        "kn_male_1",53        "en_indian_female_1",54    }55)56 57 58# ---------------------------------------------------------------------------59# Errors (audio.md §2.3)60# ---------------------------------------------------------------------------61 62 63class AudioError(Exception):64    """Base class for all audio-module errors."""65 66 67class ModelLoadError(AudioError):68    """Raised when Kokoro or faster-whisper cannot be instantiated."""69 70 71class UnsupportedLanguageError(AudioError):72    """Raised when a non-registered language code is passed to synthesize()."""73 74 75class UnsupportedVoicePackError(AudioError):76    """Raised when a voice pack is not in VOICE_PACKS[lang].allowed."""77 78 79class AudioDecodeError(AudioError):80    """Raised when transcribe() cannot decode the input bytes."""81 82 83class AudioTooLongError(AudioError):84    """Raised when transcribe() receives audio longer than max_duration_s in strict mode."""85 86 87class TTSOutOfMemoryError(AudioError):88    """Raised when TTS synthesis exhausts memory mid-call."""89 90 91# ---------------------------------------------------------------------------92# Data records (audio.md §2.1, §2.2, §2.2a, §4.1, §4.2)93# ---------------------------------------------------------------------------94 95 96@dataclass(frozen=True)97class VoicePackMapping:98    """Per-language default + allowed voice packs. audio.md §4.3."""99 100    language: LanguageCode101    default: VoicePack102    allowed: tuple[VoicePack, ...]103 104 105VOICE_PACKS: dict[LanguageCode, VoicePackMapping] = {106    "hi": VoicePackMapping(107        language="hi",108        default="hi_female_1",109        allowed=("hi_female_1", "hi_male_1"),110    ),111    "ta": VoicePackMapping(112        language="ta",113        default="ta_female_1",114        allowed=("ta_female_1",),115    ),116    "kn": VoicePackMapping(117        language="kn",118        default="kn_male_1",119        allowed=("kn_male_1",),120    ),121    "en": VoicePackMapping(122        language="en",123        default="en_indian_female_1",124        allowed=("en_indian_female_1",),125    ),126    "hinglish": VoicePackMapping(127        language="hinglish",128        default="en_indian_female_1",129        allowed=("en_indian_female_1", "hi_female_1"),130    ),131}132 133 134@dataclass(frozen=True)135class TranscriptResult:136    """ASR output surfaced to the env observation builder. audio.md §4.1."""137 138    text: str139    language_detected: LanguageCode | Literal["unknown"]140    confidence: float141    duration_s: float142 143 144@dataclass(frozen=True)145class AudioTrace:146    """Per-call diagnostic record emitted via the configured trace sink.147 148    audio.md §2.2a, §3.8.149    """150 151    op: Literal["synthesize", "transcribe"]152    input_hash: str153    language: str154    duration_s: float155    latency_ms: int156    confidence: float | None157    cache_hit: bool158    degraded: bool159    ts_ist: str160 161 162TraceSink = Callable[[AudioTrace], None]163 164 165# ---------------------------------------------------------------------------166# Lazy dep loaders — patched by tests to inject fakes.167# ---------------------------------------------------------------------------168 169 170def _load_kokoro() -> Any:171    """Return the ``kokoro`` module. Patched in tests."""172 173    import kokoro174 175    return kokoro176 177 178def _load_faster_whisper() -> Any:179    """Return the ``faster_whisper`` module. Patched in tests."""180 181    import faster_whisper182 183    return faster_whisper184 185 186def _load_torchaudio_functional() -> Any:187    """Return ``torchaudio.functional``. Patched in tests."""188 189    import torchaudio.functional as F190 191    return F192 193 194def _load_torchaudio() -> Any:195    """Return the top-level ``torchaudio`` module. Patched in tests."""196 197    import torchaudio198 199    return torchaudio200 201 202def _load_soundfile() -> Any:203    """Return the ``soundfile`` module. Patched in tests."""204 205    import soundfile206 207    return soundfile208 209 210def _load_torch() -> Any:211    """Return the ``torch`` module. Patched in tests."""212 213    import torch214 215    return torch216 217 218# ---------------------------------------------------------------------------219# Helpers220# ---------------------------------------------------------------------------221 222 223_IST_TZ = timezone(timedelta(hours=5, minutes=30))224 225 226def _ts_ist_now() -> str:227    return datetime.now(tz=_IST_TZ).isoformat(timespec="milliseconds")228 229 230def _input_hash(payload: bytes) -> str:231    return hashlib.blake2b(payload, digest_size=16).hexdigest()232 233 234def _logprob_to_confidence(avg_logprob: float) -> float:235    """Map faster-whisper ``avg_logprob`` into [0, 1] per audio.md §3.5."""236 237    clamped = max(-1.5, min(0.0, float(avg_logprob)))238    return round(math.exp(clamped), 3)239 240 241def _riff_header_sample_rate(audio_bytes: bytes) -> int | None:242    """Return the sample-rate field from a RIFF header, or None if not RIFF."""243 244    if len(audio_bytes) < 28:245        return None246    if audio_bytes[0:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":247        return None248    return int(struct.unpack_from("<I", audio_bytes, 24)[0])249 250 251def _pcm16_silence_wav(duration_s: float, sample_rate_hz: int = 16000) -> bytes:252    """Build a 16-bit mono PCM WAV of pure silence for warmup / fallback."""253 254    n_samples = max(1, int(duration_s * sample_rate_hz))255    buf = io.BytesIO()256    with wave.open(buf, "wb") as w:257        w.setnchannels(1)258        w.setsampwidth(2)259        w.setframerate(sample_rate_hz)260        w.writeframes(b"\x00\x00" * n_samples)261    return buf.getvalue()262 263 264def _np_to_wav_bytes(pcm: np.ndarray, sample_rate_hz: int) -> bytes:265    """Encode a float32 mono numpy array as 16-bit PCM RIFF WAV bytes.266 267    Used when torchaudio is unavailable or mocked — the fallback path268    produces the same byte-level contract (RIFF header + 16 kHz mono 16-bit).269    """270 271    if pcm.dtype != np.int16:272        clipped = np.clip(pcm.astype(np.float32), -1.0, 1.0)273        pcm_i16 = (clipped * 32767.0).astype(np.int16)274    else:275        pcm_i16 = pcm276    buf = io.BytesIO()277    with wave.open(buf, "wb") as w:278        w.setnchannels(1)279        w.setsampwidth(2)280        w.setframerate(sample_rate_hz)281        w.writeframes(pcm_i16.tobytes())282    return buf.getvalue()283 284 285# ---------------------------------------------------------------------------286# TTS287# ---------------------------------------------------------------------------288 289 290_TTS_CACHE_MAX_BYTES: int = 64 * 1024 * 1024291_TTS_CACHE_MAX_ENTRIES: int = 256292 293 294def _available_voice_packs(kokoro_module: Any) -> set[str]:295    """Probe the installed Kokoro bundle for shipped voice-pack names.296 297    Looks for ``AVAILABLE_VOICES``, ``list_voices()``, or ``VOICES``. A fresh298    install typically exposes at least one of these. If none is present we299    fall back to the full canonical set (best-effort; runtime per-call300    fallback in ``_resolve_voice_pack`` still protects against missing packs).301    """302 303    candidates: set[str] = set()304    for attr in ("AVAILABLE_VOICES", "VOICES"):305        value = getattr(kokoro_module, attr, None)306        if isinstance(value, (list, tuple, set, frozenset)):307            candidates.update(str(v) for v in value)308    list_voices = getattr(kokoro_module, "list_voices", None)309    if callable(list_voices):310        try:311            value = list_voices()312            if isinstance(value, (list, tuple, set, frozenset)):313                candidates.update(str(v) for v in value)314        except Exception:  # pragma: no cover — defensive315            pass316    if not candidates:317        return set(_VOICE_PACKS_SET)318    return candidates319 320 321_FALLBACK_CHAIN: dict[str, str] = {322    "ta_female_1": "hi_female_1",323    "kn_male_1": "hi_female_1",324    "hi_male_1": "hi_female_1",325    "hi_female_1": "en_indian_female_1",326}327 328 329class TTSEngine:330    """Kokoro-82M wrapper. Constructed via ``get_tts_engine()``.331 332    One instance per process. All heavy deps are imported lazily.333    """334 335    def __init__(336        self,337        *,338        model_id: str = "hexgrad/Kokoro-82M",339        trace_sink: TraceSink | None = None,340    ) -> None:341        self._model_id = model_id342        self._trace_sink = trace_sink343        self._lock = threading.Lock()344        self._cache: LRUCache[tuple[Any, ...], bytes] = LRUCache(345            maxsize=_TTS_CACHE_MAX_BYTES, getsizeof=len346        )347        self._numpy_cache: LRUCache[tuple[Any, ...], np.ndarray] = LRUCache(348            maxsize=_TTS_CACHE_MAX_BYTES, getsizeof=lambda a: int(a.nbytes)349        )350        self._fallback_used: dict[str, str] = {}351        try:352            kokoro = _load_kokoro()353        except Exception as exc:  # network / disk / import failure354            raise ModelLoadError(f"failed to load kokoro: {exc}") from exc355        self._kokoro = kokoro356        try:357            pipeline_cls = getattr(kokoro, "KPipeline", None)358            if pipeline_cls is None:359                raise AttributeError("kokoro.KPipeline missing")360            self._pipeline = pipeline_cls(model_id=model_id)361        except Exception as exc:362            raise ModelLoadError(f"failed to construct KPipeline: {exc}") from exc363        self._available_packs = _available_voice_packs(kokoro)364        self._verify_critical_packs()365 366    def _verify_critical_packs(self) -> None:367        if (368            "en_indian_female_1" not in self._available_packs369            and "hi_female_1" not in self._available_packs370        ):371            raise ModelLoadError("no usable voice pack for hi or en")372 373    def _resolve_voice_pack(self, requested: VoicePack) -> tuple[VoicePack, bool, str | None]:374        """Walk the fallback chain until an available pack is found.375 376        Returns ``(resolved_pack, degraded, fallback_from)``.377        """378 379        current = requested380        original = requested381        degraded = False382        fallback_from: str | None = None383        visited: set[str] = set()384        while current not in self._available_packs:385            if current in visited:386                break387            visited.add(current)388            successor = _FALLBACK_CHAIN.get(current)389            if successor is None:390                raise ModelLoadError(391                    f"no usable voice pack; chain exhausted from {original!r}"392                )393            fallback_from = original394            current = cast("VoicePack", successor)395            degraded = True396        if degraded:397            self._fallback_used[original] = current398        return current, degraded, fallback_from399 400    def _emit_trace(self, trace: AudioTrace) -> None:401        if self._trace_sink is None:402            return403        try:404            self._trace_sink(trace)405        except Exception:  # telemetry must never break production406            logger.debug("trace sink raised; swallowed", exc_info=True)407 408    def _render_pcm(self, text: str, voice_pack: VoicePack, seed: int) -> np.ndarray:409        """Invoke Kokoro inside a forked RNG context and return 24 kHz float32 PCM."""410 411        torch = _load_torch()412        with torch.random.fork_rng(devices=[]):413            torch.manual_seed(seed)414            try:415                result = self._pipeline(text, voice=voice_pack)416            except MemoryError as exc:417                raise TTSOutOfMemoryError(f"TTS OOM: {exc}") from exc418            except RuntimeError as exc:419                msg = str(exc).lower()420                if "out of memory" in msg or "alloc" in msg:421                    raise TTSOutOfMemoryError(f"TTS OOM: {exc}") from exc422                raise423        return _coerce_to_float32_mono(result)424 425    def _resample_to_16k(self, pcm_24k: np.ndarray) -> np.ndarray:426        """Downsample 24 kHz → 16 kHz via torchaudio.functional.resample."""427 428        try:429            F = _load_torchaudio_functional()430        except Exception as exc:  # pragma: no cover — hard runtime failure431            raise ModelLoadError(f"torchaudio.functional missing: {exc}") from exc432        torch = _load_torch()433        tensor = torch.from_numpy(pcm_24k.astype(np.float32)).unsqueeze(0)434        resampled = F.resample(435            tensor, orig_freq=24000, new_freq=16000, lowpass_filter_width=64436        )437        out = resampled.squeeze(0).cpu().numpy().astype(np.float32)438        return cast("np.ndarray", out)439 440    def _encode_wav(self, pcm_16k: np.ndarray, sample_rate_hz: int) -> bytes:441        """Encode the 16 kHz float32 PCM into 16-bit mono RIFF WAV bytes."""442 443        try:444            torchaudio = _load_torchaudio()445            torch = _load_torch()446            tensor = torch.from_numpy(pcm_16k.astype(np.float32)).unsqueeze(0)447            buf = io.BytesIO()448            torchaudio.save(449                buf,450                tensor,451                sample_rate=sample_rate_hz,452                bits_per_sample=16,453                format="wav",454                encoding="PCM_S",455            )456            return buf.getvalue()457        except Exception:458            # Fall back to stdlib wave encoder so the byte contract still holds459            # even when torchaudio is unavailable.460            return _np_to_wav_bytes(pcm_16k, sample_rate_hz)461 462    def synthesize(463        self,464        text: str,465        language_code: LanguageCode,466        voice_pack: VoicePack | None = None,467        *,468        seed: int = 0,469        sample_rate_hz: int = 16000,470    ) -> bytes:471        """Return 16-bit PCM mono WAV bytes. audio.md §2.1, §4.4."""472 473        if sample_rate_hz != 16000:474            raise UnsupportedLanguageError(475                f"sample_rate_hz={sample_rate_hz} unsupported; only 16000 allowed in v1"476            )477        if language_code not in _LANGUAGE_CODES:478            raise UnsupportedLanguageError(f"language_code={language_code!r} unsupported")479        mapping = VOICE_PACKS[language_code]480        if voice_pack is None:481            voice_pack = mapping.default482        if voice_pack not in mapping.allowed:483            raise UnsupportedVoicePackError(484                f"voice_pack={voice_pack!r} not allowed for language={language_code!r}"485            )486        text_hash = _input_hash(text.encode("utf-8"))487        cache_key = (text_hash, voice_pack, seed, sample_rate_hz, "bytes")488        start = time.perf_counter()489        with self._lock:490            cached = self._cache.get(cache_key)491        if cached is not None:492            latency_ms = int((time.perf_counter() - start) * 1000)493            duration_s = _wav_duration_s(cached)494            self._emit_trace(495                AudioTrace(496                    op="synthesize",497                    input_hash=text_hash,498                    language=language_code,499                    duration_s=duration_s,500                    latency_ms=latency_ms,501                    confidence=None,502                    cache_hit=True,503                    degraded=False,504                    ts_ist=_ts_ist_now(),505                )506            )507            return cached508        resolved_pack, degraded, _ = self._resolve_voice_pack(voice_pack)509        pcm_24k = self._render_pcm(text, resolved_pack, seed)510        pcm_16k = self._resample_to_16k(pcm_24k)511        wav_bytes = self._encode_wav(pcm_16k, sample_rate_hz)512        with self._lock:513            self._cache[cache_key] = wav_bytes514        latency_ms = int((time.perf_counter() - start) * 1000)515        duration_s = _wav_duration_s(wav_bytes)516        self._emit_trace(517            AudioTrace(518                op="synthesize",519                input_hash=text_hash,520                language=language_code,521                duration_s=duration_s,522                latency_ms=latency_ms,523                confidence=None,524                cache_hit=False,525                degraded=degraded,526                ts_ist=_ts_ist_now(),527            )528        )529        return wav_bytes530 531    def synthesize_to_gradio(532        self,533        text: str,534        language_hint: LanguageCode,535        voice_pack: VoicePack | None = None,536        *,537        seed: int = 0,538    ) -> tuple[int, np.ndarray]:539        """Return ``(sample_rate, float32 mono ndarray)``. audio.md §2.1."""540 541        if language_hint not in _LANGUAGE_CODES:542            raise UnsupportedLanguageError(f"language_hint={language_hint!r} unsupported")543        mapping = VOICE_PACKS[language_hint]544        if voice_pack is None:545            voice_pack = mapping.default546        if voice_pack not in mapping.allowed:547            raise UnsupportedVoicePackError(548                f"voice_pack={voice_pack!r} not allowed for language={language_hint!r}"549            )550        text_hash = _input_hash(text.encode("utf-8"))551        sample_rate_hz = 16000552        cache_key = (text_hash, voice_pack, seed, sample_rate_hz, "numpy")553        start = time.perf_counter()554        with self._lock:555            cached = self._numpy_cache.get(cache_key)556        if cached is not None:557            self._emit_trace(558                AudioTrace(559                    op="synthesize",560                    input_hash=text_hash,561                    language=language_hint,562                    duration_s=float(len(cached)) / sample_rate_hz,563                    latency_ms=int((time.perf_counter() - start) * 1000),564                    confidence=None,565                    cache_hit=True,566                    degraded=False,567                    ts_ist=_ts_ist_now(),568                )569            )570            return sample_rate_hz, cached.copy()571        resolved_pack, degraded, _ = self._resolve_voice_pack(voice_pack)572        pcm_24k = self._render_pcm(text, resolved_pack, seed)573        pcm_16k = self._resample_to_16k(pcm_24k)574        with self._lock:575            self._numpy_cache[cache_key] = pcm_16k576        self._emit_trace(577            AudioTrace(578                op="synthesize",579                input_hash=text_hash,580                language=language_hint,581                duration_s=float(len(pcm_16k)) / sample_rate_hz,582                latency_ms=int((time.perf_counter() - start) * 1000),583                confidence=None,584                cache_hit=False,585                degraded=degraded,586                ts_ist=_ts_ist_now(),587            )588        )589        return sample_rate_hz, pcm_16k.copy()590 591    def warmup(self) -> None:592        """Probe each voice pack; log WARN on missing Indic packs. audio.md §4.3.1."""593 594        for lang, mapping in VOICE_PACKS.items():595            for pack in mapping.allowed:596                if pack not in self._available_packs:597                    logger.warning(598                        "voice pack %r missing from bundle (language=%s); will fall back at synth time",599                        pack,600                        lang,601                    )602        try:603            self.synthesize("warmup", "en")604        except Exception:  # pragma: no cover — warmup best-effort605            logger.debug("warmup synthesize failed; continuing", exc_info=True)606 607 608def _coerce_to_float32_mono(result: Any) -> np.ndarray:609    """Turn whatever Kokoro returned into a 1-D float32 numpy array."""610 611    torch = _load_torch()612    if hasattr(result, "cpu") and hasattr(result, "numpy"):613        arr = result.detach().cpu().numpy()614    elif isinstance(result, tuple):615        audio_like = result[0]616        if hasattr(audio_like, "cpu") and hasattr(audio_like, "numpy"):617            arr = audio_like.detach().cpu().numpy()618        else:619            arr = np.asarray(audio_like)620    elif isinstance(result, np.ndarray):621        arr = result622    else:623        try:624            tensor = torch.as_tensor(result)625            arr = tensor.detach().cpu().numpy()626        except Exception as exc:  # pragma: no cover — defensive627            raise TTSOutOfMemoryError(f"unexpected Kokoro return type: {type(result)!r}: {exc}") from exc628    arr = np.asarray(arr, dtype=np.float32).reshape(-1)629    return arr630 631 632def _wav_duration_s(wav_bytes: bytes) -> float:633    """Return the duration in seconds for a RIFF WAV payload (best-effort)."""634 635    try:636        with wave.open(io.BytesIO(wav_bytes), "rb") as w:637            frames = w.getnframes()638            rate = w.getframerate()639            if rate <= 0:640                return 0.0641            return round(frames / rate, 3)642    except Exception:643        return 0.0644 645 646# ---------------------------------------------------------------------------647# ASR648# ---------------------------------------------------------------------------649 650 651def _map_language(code: str | None) -> LanguageCode | Literal["unknown"]:652    if code in _LANGUAGE_CODES:653        return cast("LanguageCode", code)654    return "unknown"655 656 657def _nfc(text: str) -> str:658    return unicodedata.normalize("NFC", text).strip()659 660 661class ASREngine:662    """faster-whisper-small wrapper. Constructed via ``get_asr_engine()``.663 664    audio.md §2.2. Heavy deps loaded lazily.665    """666 667    def __init__(668        self,669        *,670        model_id: str = "Systran/faster-whisper-small",671        compute_type: Literal["int8", "int8_float16"] = "int8",672        trace_sink: TraceSink | None = None,673    ) -> None:674        self._model_id = model_id675        self._compute_type = compute_type676        self._trace_sink = trace_sink677        self._lock = threading.Lock()678        try:679            fw = _load_faster_whisper()680        except Exception as exc:681            raise ModelLoadError(f"failed to load faster_whisper: {exc}") from exc682        model_cls = getattr(fw, "WhisperModel", None)683        if model_cls is None:684            raise ModelLoadError("faster_whisper.WhisperModel missing")685        try:686            self._model = model_cls(model_id, compute_type=compute_type, device="cpu")687        except Exception as exc:688            raise ModelLoadError(f"failed to construct WhisperModel: {exc}") from exc689 690    def _emit_trace(self, trace: AudioTrace) -> None:691        if self._trace_sink is None:692            return693        try:694            self._trace_sink(trace)695        except Exception:696            logger.debug("trace sink raised; swallowed", exc_info=True)697 698    def transcribe(699        self,700        audio_bytes: bytes,701        language_hint: LanguageCode | None,702        *,703        beam_size: int = 1,704        vad_filter: bool = True,705        max_duration_s: float = 30.0,706    ) -> TranscriptResult:707        """Decode WAV/PCM bytes. audio.md §2.2, §3.5, §4.4."""708 709        start = time.perf_counter()710        pcm, clip_duration = self._decode_input(audio_bytes)711        if clip_duration > max_duration_s:712            pcm = pcm[: int(max_duration_s * 16000)]713            clip_duration = max_duration_s714        language_for_whisper: str | None715        if language_hint == "hinglish":716            language_for_whisper = "hi"717        elif language_hint is None:718            language_for_whisper = None719        else:720            language_for_whisper = language_hint721        segments, info = self._run_whisper(722            pcm,723            language=language_for_whisper,724            beam_size=beam_size,725            vad_filter=vad_filter,726        )727        segments_list = list(segments)728        detected_code = _map_language(getattr(info, "language", None))729        vad_dropped_all = getattr(info, "vad_dropped_all_segments", None)730        if vad_dropped_all is None:731            vad_dropped_all = len(segments_list) == 0 and vad_filter732        combined_text = _nfc("".join(getattr(s, "text", "") for s in segments_list))733        duration_s = round(min(float(clip_duration), float(max_duration_s)), 3)734        degraded = False735        if combined_text == "":736            confidence = 0.0737            if vad_dropped_all:738                detected: LanguageCode | Literal["unknown"] = "unknown"739            else:740                detected = detected_code741                degraded = True742        else:743            confidence = _duration_weighted_confidence(segments_list)744            detected = _infer_hinglish(detected_code, combined_text, language_hint)745        result = TranscriptResult(746            text=combined_text,747            language_detected=detected,748            confidence=confidence,749            duration_s=duration_s,750        )751        latency_ms = int((time.perf_counter() - start) * 1000)752        self._emit_trace(753            AudioTrace(754                op="transcribe",755                input_hash=_input_hash(audio_bytes),756                language=language_hint or "unknown",757                duration_s=duration_s,758                latency_ms=latency_ms,759                confidence=confidence,760                cache_hit=False,761                degraded=degraded,762                ts_ist=_ts_ist_now(),763            )764        )765        return result766 767    def _decode_input(self, audio_bytes: bytes) -> tuple[np.ndarray, float]:768        """Return (float32 mono @ 16 kHz, duration_s); raise AudioDecodeError on mismatch."""769 770        if len(audio_bytes) >= 3 and audio_bytes[:3] == b"ID3":771            raise AudioDecodeError("MP3 / ID3-tagged inputs are not supported (no ffmpeg in image)")772        rate = _riff_header_sample_rate(audio_bytes)773        if rate is not None:774            if rate != 16000:775                raise AudioDecodeError("input must be 16 kHz mono; caller must pre-resample")776            try:777                sf = _load_soundfile()778                data, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32", always_2d=False)779            except Exception as exc:780                raise AudioDecodeError(f"soundfile failed to decode RIFF WAV: {exc}") from exc781            if sr != 16000:782                raise AudioDecodeError("input must be 16 kHz mono; caller must pre-resample")783            arr = np.asarray(data, dtype=np.float32).reshape(-1)784            duration = float(len(arr)) / 16000.0785            return arr, duration786        # Raw float32 PCM path (demo mic input). 16 kHz assumed. We only accept787        # payloads that look like plausible audio — ≥ 0.25 s of float32 samples788        # (4000 × 4 = 16000 bytes) whose magnitudes fit inside the normalized789        # [-1, 1] range that Gradio emits. Short / out-of-range payloads are790        # rejected so arbitrary random bytes do not slip through.791        min_raw_pcm_bytes = 4000 * 4792        if len(audio_bytes) >= min_raw_pcm_bytes and len(audio_bytes) % 4 == 0:793            pcm = np.frombuffer(audio_bytes, dtype=np.float32).copy()794            if pcm.size and np.all(np.isfinite(pcm)) and np.max(np.abs(pcm)) <= 2.0:795                duration = float(pcm.size) / 16000.0796                return pcm, duration797        raise AudioDecodeError("input is not a valid 16 kHz RIFF WAV or float32 PCM payload")798 799    def _run_whisper(800        self,801        pcm: np.ndarray,802        *,803        language: str | None,804        beam_size: int,805        vad_filter: bool,806    ) -> tuple[Any, Any]:807        try:808            segments, info = self._model.transcribe(809                pcm,810                language=language,811                beam_size=beam_size,812                vad_filter=vad_filter,813            )814        except Exception as exc:815            raise AudioDecodeError(f"whisper decode failed: {exc}") from exc816        return segments, info817 818    def warmup(self) -> None:819        """Run one transcribe() on 0.5 s of silence to force load. audio.md §2.2."""820 821        silence = _pcm16_silence_wav(0.5)822        try:823            self.transcribe(silence, "en")824        except Exception:  # pragma: no cover — warmup best-effort825            logger.debug("warmup transcribe failed; continuing", exc_info=True)826 827 828def _duration_weighted_confidence(segments: list[Any]) -> float:829    if not segments:830        return 0.0831    total_dur = 0.0832    weighted = 0.0833    for seg in segments:834        start = float(getattr(seg, "start", 0.0) or 0.0)835        end = float(getattr(seg, "end", 0.0) or 0.0)836        dur = max(0.0, end - start)837        avg_logprob = float(getattr(seg, "avg_logprob", 0.0) or 0.0)838        confidence = _logprob_to_confidence(avg_logprob)839        if dur == 0.0:840            total_dur += 1.0841            weighted += confidence842        else:843            total_dur += dur844            weighted += confidence * dur845    if total_dur == 0.0:846        return 0.0847    return round(weighted / total_dur, 3)848 849 850def _infer_hinglish(851    detected: LanguageCode | Literal["unknown"],852    text: str,853    hint: LanguageCode | None,854) -> LanguageCode | Literal["unknown"]:855    """Downgrade ``hi`` to ``hinglish`` when the decoded text is code-mixed.856 857    Heuristic per audio.md §3.6: ≥ 2 ASCII words intermixed with Devanagari.858    """859 860    if hint != "hinglish":861        return detected862    if detected != "hi":863        return detected864    ascii_words = [tok for tok in text.split() if tok.isascii() and tok.isalpha()]865    has_devanagari = any("ऀ" <= ch <= "ॿ" for ch in text)866    if len(ascii_words) >= 2 and has_devanagari:867        return "hinglish"868    return detected869 870 871# ---------------------------------------------------------------------------872# Singletons873# ---------------------------------------------------------------------------874 875 876_tts_engine: TTSEngine | None = None877_asr_engine: ASREngine | None = None878_tts_lock = threading.Lock()879_asr_lock = threading.Lock()880 881 882def get_tts_engine(883    *, trace_sink: TraceSink | None = None, model_id: str = "hexgrad/Kokoro-82M"884) -> TTSEngine:885    """Return the process-wide TTSEngine singleton. audio.md §3.2, §3.8."""886 887    global _tts_engine888    with _tts_lock:889        if _tts_engine is None:890            _tts_engine = TTSEngine(model_id=model_id, trace_sink=trace_sink)891        elif trace_sink is not None and trace_sink is not _tts_engine._trace_sink:892            logger.warning("get_tts_engine: different sink passed after construction; ignoring")893        return _tts_engine894 895 896def get_asr_engine(897    *,898    trace_sink: TraceSink | None = None,899    model_id: str = "Systran/faster-whisper-small",900    compute_type: Literal["int8", "int8_float16"] = "int8",901) -> ASREngine:902    """Return the process-wide ASREngine singleton. audio.md §3.2, §3.8."""903 904    global _asr_engine905    with _asr_lock:906        if _asr_engine is None:907            _asr_engine = ASREngine(908                model_id=model_id, compute_type=compute_type, trace_sink=trace_sink909            )910        elif trace_sink is not None and trace_sink is not _asr_engine._trace_sink:911            logger.warning("get_asr_engine: different sink passed after construction; ignoring")912        return _asr_engine913 914 915def _reset_singletons_for_tests() -> None:916    """Tear down singletons. Tests only. audio.md §3.2 "Unload. Never." exemption."""917 918    global _tts_engine, _asr_engine919    with _tts_lock:920        _tts_engine = None921    with _asr_lock:922        _asr_engine = None923 924 925__all__ = [926    "AudioDecodeError",927    "AudioError",928    "AudioTooLongError",929    "AudioTrace",930    "ASREngine",931    "LanguageCode",932    "ModelLoadError",933    "TTSEngine",934    "TTSOutOfMemoryError",935    "TranscriptResult",936    "TraceSink",937    "UnsupportedLanguageError",938    "UnsupportedVoicePackError",939    "VOICE_PACKS",940    "VoicePack",941    "VoicePackMapping",942    "get_asr_engine",943    "get_tts_engine",944]945