Spaces:
Sleeping
Sleeping
| """ | |
| Shared helpers for the async servers: Silero VAD factory (one template, | |
| deep-copied per WebSocket connection), text post-filters (hallucination / | |
| context-echo), and client-language resolution. | |
| The vLLM engine itself lives in async_streaming.py (a single shared | |
| vllm.AsyncLLM). This module deliberately holds NO model/engine state. | |
| """ | |
| import os | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass | |
| import copy | |
| import logging | |
| import re | |
| import threading | |
| import numpy as np | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S", | |
| ) | |
| log = logging.getLogger("qwen3-asr") | |
| SAMPLE_RATE = 16000 | |
| VAD_THRESHOLD = float(os.getenv("VAD_THRESHOLD", "0.7")) | |
| VAD_MIN_SILENCE_MS = int(os.getenv("VAD_MIN_SILENCE_MS", "800")) | |
| VAD_SPEECH_PAD_MS = int(os.getenv("VAD_SPEECH_PAD_MS", "300")) | |
| HALLUCINATION_PHRASES = { | |
| "transcript", "transcription", "thank you", "thanks for watching", | |
| "you", "bye", "goodbye", "the end", "subtitle", "subtitles", | |
| } | |
| # ββ Text post-filters βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _is_hallucination(text: str) -> bool: | |
| return text.lower().strip().rstrip(".!?,;:") in HALLUCINATION_PHRASES | |
| def _normalize_ws(s: str) -> str: | |
| return re.sub(r"\s+", " ", s or "").strip().lower() | |
| def _is_context_echo(text: str, context: str) -> bool: | |
| """ | |
| True when the model regurgitated the context prompt instead of | |
| transcribing β a common failure on very short or near-silent | |
| utterances. Matches when the normalized output is a leading slice of | |
| the context (partial echo) or begins with the whole context (full echo). | |
| The 15-char floor avoids false positives on short real speech. | |
| """ | |
| if not text or not context: | |
| return False | |
| nt, nc = _normalize_ws(text), _normalize_ws(context) | |
| if len(nt) < 15: | |
| return False | |
| # Full echo, or output is the leading slice of the context (or vice-versa). | |
| if nc.startswith(nt) or nt.startswith(nc): | |
| return True | |
| # Mid-context echo: a long verbatim run of the context anywhere in the | |
| # output. High floor (40 chars) so short real overlaps with the term lists | |
| # (e.g. "fever, cough, headache") don't false-trigger. | |
| if len(nt) >= 40 and (nt in nc or nc in nt): | |
| return True | |
| return False | |
| # ββ Language resolution βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Mirrors qwen_asr.inference.utils.SUPPORTED_LANGUAGES (v0.0.6). A language the | |
| # model can't handle makes the engine raise, which would otherwise kill the | |
| # WebSocket connection mid-stream ("connected but no audio processed"). | |
| SUPPORTED_LANGUAGES = { | |
| "Chinese", "English", "Cantonese", "Arabic", "German", "French", "Spanish", | |
| "Portuguese", "Indonesian", "Italian", "Korean", "Russian", "Thai", | |
| "Vietnamese", "Japanese", "Turkish", "Hindi", "Malay", "Dutch", "Swedish", | |
| "Danish", "Finnish", "Polish", "Czech", "Filipino", "Persian", "Greek", | |
| "Romanian", "Hungarian", "Macedonian", | |
| } | |
| # Client-supplied language (ISO code or full name, any case) -> canonical model | |
| # language. NOTE: Qwen3-ASR has NO "Urdu" β Urdu requests are routed to Hindi so | |
| # the model emits Devanagari, which the Roman-Urdu transliterator then converts. | |
| _LANG_ALIASES = { | |
| "en": "English", "english": "English", | |
| "ms": "Malay", "malay": "Malay", | |
| "zh": "Chinese", "chinese": "Chinese", | |
| "ja": "Japanese", "japanese": "Japanese", | |
| "ko": "Korean", "korean": "Korean", | |
| "hi": "Hindi", "hindi": "Hindi", | |
| "ur": "Hindi", "urdu": "Hindi", | |
| "ar": "Arabic", "arabic": "Arabic", | |
| "id": "Indonesian", "indonesian": "Indonesian", | |
| "th": "Thai", "thai": "Thai", | |
| "fa": "Persian", "persian": "Persian", | |
| } | |
| def resolve_language(raw): | |
| """ | |
| Map a client-supplied language to a canonical Qwen3-ASR language. | |
| Accepts ISO codes ("hi"), full names ("hindi"), any case. Returns the | |
| canonical name if supported, else None (caller keeps its own default | |
| rather than forcing an unsupported value that would crash the stream). | |
| """ | |
| if not raw: | |
| return None | |
| key = str(raw).strip().lower() | |
| name = _LANG_ALIASES.get(key) | |
| if name is None: # try the raw value as a canonical name | |
| cand = key[:1].upper() + key[1:] | |
| name = cand if cand in SUPPORTED_LANGUAGES else None | |
| return name if name in SUPPORTED_LANGUAGES else None | |
| # ββ Silero VAD (one template; deep-copied per WebSocket connection) ββββββββββββ | |
| _vad_model_template = None | |
| _vad_utils = None | |
| _vad_lock = threading.Lock() | |
| def _ensure_vad_loaded(): | |
| global _vad_model_template, _vad_utils | |
| if _vad_model_template is None: | |
| with _vad_lock: | |
| if _vad_model_template is None: | |
| import torch | |
| _vad_model_template, _vad_utils = torch.hub.load( | |
| "snakers4/silero-vad", "silero_vad", | |
| trust_repo=True, verbose=False, | |
| ) | |
| log.info("Silero VAD model loaded") | |
| def create_vad(threshold=VAD_THRESHOLD, min_silence_ms=VAD_MIN_SILENCE_MS, | |
| speech_pad_ms=VAD_SPEECH_PAD_MS): | |
| try: | |
| _ensure_vad_loaded() | |
| model_copy = copy.deepcopy(_vad_model_template) | |
| VADIterator = _vad_utils[3] | |
| return VADIterator( | |
| model_copy, | |
| threshold=threshold, | |
| sampling_rate=SAMPLE_RATE, | |
| min_silence_duration_ms=min_silence_ms, | |
| speech_pad_ms=speech_pad_ms, | |
| ) | |
| except Exception as e: | |
| log.warning(f"Silero VAD failed ({e}), using RMS fallback") | |
| return RMSVad(threshold=0.01, silence_frames=int(min_silence_ms / 20)) | |
| def is_vad_ready() -> bool: | |
| return _vad_model_template is not None | |
| class RMSVad: | |
| """Energy-based VAD fallback used when Silero fails to load.""" | |
| def __init__(self, threshold=0.01, silence_frames=30, speech_frames=3): | |
| self.threshold = threshold | |
| self.silence_frames = silence_frames | |
| self.speech_frames = speech_frames | |
| self.is_speaking = False | |
| self._silent_count = 0 | |
| self._speech_count = 0 | |
| def __call__(self, audio_chunk): | |
| import torch | |
| if isinstance(audio_chunk, np.ndarray): | |
| audio_chunk = torch.from_numpy(audio_chunk) | |
| rms = float(torch.sqrt(torch.mean(audio_chunk.float() ** 2))) | |
| if rms > self.threshold: | |
| self._speech_count += 1 | |
| self._silent_count = 0 | |
| if not self.is_speaking and self._speech_count >= self.speech_frames: | |
| self.is_speaking = True | |
| return {"start": 0} | |
| else: | |
| self._silent_count += 1 | |
| self._speech_count = 0 | |
| if self.is_speaking and self._silent_count >= self.silence_frames: | |
| self.is_speaking = False | |
| return {"end": 0} | |
| return None | |
| def reset_states(self): | |
| self.is_speaking = False | |
| self._silent_count = 0 | |
| self._speech_count = 0 | |