Qwen-ASR-VLM-async-test / shared_model.py
aspirant312's picture
Fix context-echo leak: check raw (pre-romanize) text + catch mid-context runs
78c717b
Raw
History Blame Contribute Delete
7.4 kB
"""
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