PurePolyglot / src /input_agent.py
github-actions[bot]
Automated deployment to Hugging Face
a2b450c
Raw
History Blame Contribute Delete
19.6 kB
import whisper
import os
import torch
import warnings
import gc # Garbage Collector for memory management
import tempfile
import re
import time
from pydub import AudioSegment # 🟢 NEW: Add pydub
# Suppress FP16 warnings on CPU
warnings.filterwarnings("ignore", message="FP16 is not supported on CPU; using FP32 instead")
class AgentInput:
TURBO_MODEL_ID = "openai/whisper-large-v3-turbo"
DISTIL_MODEL_ID = "distil-whisper/distil-large-v3"
MMS_MODEL_ID = "facebook/mms-1b-all"
QWEN_ASR_MODEL_ID = "Qwen/Qwen3-ASR-1.7B"
def __init__(self, device="cpu"):
print(f"👂 Agent 1 (Input) Online: Preparing Whisper on {device}...")
self.device = device
self.loaded_models = {}
self.loaded_pipelines = {}
self.turbo_disabled = False
self.turbo_slow_threshold_s = float(os.environ.get("ASR_TURBO_SLOW_THRESHOLD_S", "18"))
# USE 'tiny' FOR CLOUD TO PREVENT EXIT CODE 137 (OOM)
# Use 'base' only if you are running on a machine with 16GB+ RAM
self.model_name = "tiny"
try:
# Load the model and immediately collect garbage to free RAM
self.model = whisper.load_model(self.model_name, device=device)
self.loaded_models[self.model_name] = self.model
gc.collect()
print(f"✅ Whisper '{self.model_name}' model loaded. RAM optimized.")
except Exception as e:
print(f"⚠️ Load failed: {e}. Attempting emergency load...")
# Emergency fallback to tiny if not already tried
self.model = whisper.load_model("tiny", device=device)
self.loaded_models["tiny"] = self.model
self.model_name = "tiny"
def _audio_duration_s(self, audio_path):
try:
return float(AudioSegment.from_file(audio_path).duration_seconds)
except Exception:
return None
def _hint_text(self, language=None, dialect_hint=""):
return f"{language or ''} {dialect_hint or ''}".strip().lower()
def _is_zero_gpu_available(self):
return torch.cuda.is_available()
def _is_underrepresented_language(self, language=None, dialect_hint=""):
hint = self._hint_text(language, dialect_hint)
common_tokens = {
"english", " en", "en ", "korean", " ko", "ko ", "arabic", " ar", "ar ",
"chinese", "mandarin", "cantonese", " zh", "zh ", "french", " fr", "fr ",
"spanish", " es", "es ", "german", "deutsch", " de", "de ", "italian",
" it", "it ", "portuguese", " pt", "pt ", "hindi", " hi", "hi ",
"japanese", " ja", "ja ", "russian", " ru", "ru "
}
if any(token in f" {hint} " for token in common_tokens):
return False
return bool(hint)
def _expected_script(self, language=None, dialect_hint=""):
lang = str(language or "").strip().lower()
padded_lang = f" {lang} "
if lang in {"en", "eng", "fr", "fra", "es", "spa", "de", "deu", "it", "ita", "pt", "por", "pcm", "tl", "tgl", "fil"}:
return "latin"
if lang in {"ko", "kor"}:
return "hangul"
if lang in {"ar", "ara", "ur", "urd", "fa", "fas", "prs", "ps", "pus"}:
return "arabic"
if lang in {"zh", "cmn", "yue", "zho"}:
return "cjk"
if lang in {"ja", "jpn"}:
return "japanese"
if lang in {"hi", "hin", "mr", "mar", "ne", "nep"}:
return "devanagari"
if lang in {"th", "tha"}:
return "thai"
hint = self._hint_text(language, dialect_hint)
padded_hint = f" {hint} "
if any(token in padded_hint for token in ["korean", "hangul", "satoori", "jeju", "gyeongsang", "chungcheong", "jeolla", "busan", " ko "]):
return "hangul"
if any(token in padded_hint for token in ["arabic", "urdu", "persian", "farsi", "dari", "pashto", " ar "]):
return "arabic"
if any(token in padded_hint for token in ["chinese", "mandarin", "cantonese", "yue", " zh "]):
return "cjk"
if any(token in padded_hint for token in ["japanese", " ja "]):
return "japanese"
if any(token in padded_hint for token in ["hindi", "marathi", "nepali", " hi "]):
return "devanagari"
if "thai" in padded_hint:
return "thai"
if any(token in padded_hint for token in ["english", "french", "spanish", "german", "italian", "portuguese", "tagalog", "filipino", "pidgin", "patois", " en ", " fr ", " es ", " tl "]):
return "latin"
if any(token in padded_lang for token in [" en ", " eng ", " fr ", " fra ", " es ", " spa "]):
return "latin"
return None
def _looks_wrong_script(self, text, language=None, dialect_hint=""):
text = str(text or "").strip()
if not text:
return True
script = self._expected_script(language, dialect_hint)
if not script:
return False
patterns = {
"hangul": r"[\uac00-\ud7a3]",
"arabic": r"[\u0600-\u06ff]",
"cjk": r"[\u3400-\u9fff]",
"japanese": r"[\u3040-\u30ff\u3400-\u9fff]",
"devanagari": r"[\u0900-\u097f]",
"thai": r"[\u0e00-\u0e7f]",
"latin": r"[A-Za-z]",
}
expected_hits = len(re.findall(patterns[script], text))
if script == "latin":
other_hits = len(re.findall(r"[\u0600-\u06ff\u0900-\u097f\u0e00-\u0e7f\u3040-\u30ff\u3400-\u9fff\uac00-\ud7a3]", text))
return expected_hits == 0 or other_hits > expected_hits
return expected_hits == 0
def _mms_language_code(self, language=None, dialect_hint=""):
hint = self._hint_text(language, dialect_hint)
mappings = [
(["nigerian pidgin", "pidgin"], "pcm"),
(["tagalog", "filipino", " tl ", " tgl ", " fil "], "tgl"),
(["english", " en "], "eng"),
(["korean", " ko "], "kor"),
(["arabic", " ar "], "ara"),
(["mandarin", "chinese", " zh "], "cmn"),
(["cantonese", "yue"], "yue"),
(["french", " fr "], "fra"),
(["spanish", " es "], "spa"),
(["german", "deutsch", " de "], "deu"),
(["italian", " it "], "ita"),
(["portuguese", " pt "], "por"),
(["hindi", " hi "], "hin"),
(["japanese", " ja "], "jpn"),
(["russian", " ru "], "rus"),
(["thai"], "tha"),
(["vietnamese"], "vie"),
(["swahili"], "swh"),
(["yoruba"], "yor"),
(["igbo"], "ibo"),
(["hausa"], "hau"),
]
padded = f" {hint} "
for tokens, code in mappings:
if any(token in padded for token in tokens):
return code
return "eng"
def _normalize_manual_route(self, model_choice):
choice = str(model_choice or "auto").strip().lower()
aliases = {
"tiny": {"engine": "whisper", "model": "tiny", "label": "Whisper tiny (Edge fastest)"},
"whisper tiny": {"engine": "whisper", "model": "tiny", "label": "Whisper tiny (Edge fastest)"},
"base": {"engine": "whisper", "model": "base", "label": "Whisper base (Edge balanced)"},
"whisper base": {"engine": "whisper", "model": "base", "label": "Whisper base (Edge balanced)"},
"small": {"engine": "whisper", "model": "small", "label": "Whisper small (Fallback)"},
"whisper-small": {"engine": "whisper", "model": "small", "label": "Whisper small (Fallback)"},
"whisper small": {"engine": "whisper", "model": "small", "label": "Whisper small (Fallback)"},
"turbo": {"engine": "hf-whisper", "model": self.TURBO_MODEL_ID, "label": "Whisper large-v3 turbo (HF Pro)"},
"large-v3-turbo": {"engine": "hf-whisper", "model": self.TURBO_MODEL_ID, "label": "Whisper large-v3 turbo (HF Pro)"},
"whisper-large-v3-turbo": {"engine": "hf-whisper", "model": self.TURBO_MODEL_ID, "label": "Whisper large-v3 turbo (HF Pro)"},
self.TURBO_MODEL_ID: {"engine": "hf-whisper", "model": self.TURBO_MODEL_ID, "label": "Whisper large-v3 turbo (HF Pro)"},
"distil": {"engine": "hf-whisper", "model": self.DISTIL_MODEL_ID, "label": "Distil-Whisper (English fast)"},
"distil-large-v3": {"engine": "hf-whisper", "model": self.DISTIL_MODEL_ID, "label": "Distil-Whisper (English fast)"},
self.DISTIL_MODEL_ID: {"engine": "hf-whisper", "model": self.DISTIL_MODEL_ID, "label": "Distil-Whisper (English fast)"},
"qwen": {"engine": "hf-whisper", "model": self.QWEN_ASR_MODEL_ID, "label": "Qwen3-ASR 1.7B (Multilingual)"},
"qwen3-asr": {"engine": "hf-whisper", "model": self.QWEN_ASR_MODEL_ID, "label": "Qwen3-ASR 1.7B (Multilingual)"},
"qwen/qwen3-asr-1.7b": {"engine": "hf-whisper", "model": self.QWEN_ASR_MODEL_ID, "label": "Qwen3-ASR 1.7B (Multilingual)"},
self.QWEN_ASR_MODEL_ID.lower(): {"engine": "hf-whisper", "model": self.QWEN_ASR_MODEL_ID, "label": "Qwen3-ASR 1.7B (Multilingual)"},
"mms": {"engine": "mms", "model": self.MMS_MODEL_ID, "label": "MMS 1B (Low-resource)"},
self.MMS_MODEL_ID: {"engine": "mms", "model": self.MMS_MODEL_ID, "label": "MMS 1B (Low-resource)"},
}
return aliases.get(choice)
def _auto_routes(self, language=None, dialect_hint="", audio_path=None):
base = {"engine": "whisper", "model": "base", "label": "Whisper base (Edge balanced)"}
tiny = {"engine": "whisper", "model": "tiny", "label": "Whisper tiny (Edge fastest)"}
small = {"engine": "whisper", "model": "small", "label": "Whisper small (Fallback)"}
mms = {"engine": "mms", "model": self.MMS_MODEL_ID, "label": "MMS 1B (Low-resource)"}
turbo = {"engine": "hf-whisper", "model": self.TURBO_MODEL_ID, "label": "Whisper large-v3 turbo (HF Pro)"}
duration_s = self._audio_duration_s(audio_path) if audio_path else None
if self._is_zero_gpu_available() and not self.turbo_disabled and not (duration_s and duration_s > 18):
return [turbo, base, tiny]
if self._expected_script(language, dialect_hint) == "latin":
return [base, tiny]
if self._is_underrepresented_language(language, dialect_hint):
return [base, mms, small]
return [small]
def resolve_model_name(self, model_choice="auto", language=None, dialect_hint="", audio_path=None):
manual = self._normalize_manual_route(model_choice)
if manual:
return manual["model"]
return self._auto_routes(language=language, dialect_hint=dialect_hint, audio_path=audio_path)[0]["model"]
def _get_model(self, model_name):
model_name = model_name if model_name in {"tiny", "base", "small"} else "small"
if model_name in self.loaded_models:
self.model_name = model_name
self.model = self.loaded_models[model_name]
return self.model
try:
print(f"🔄 Loading Whisper '{model_name}' on demand...")
self.loaded_models[model_name] = whisper.load_model(model_name, device=self.device)
self.model_name = model_name
self.model = self.loaded_models[model_name]
gc.collect()
return self.model
except Exception as e:
print(f"⚠️ Whisper '{model_name}' unavailable ({e}); falling back to tiny.")
self.model_name = "tiny"
self.model = self.loaded_models.get("tiny") or whisper.load_model("tiny", device=self.device)
self.loaded_models["tiny"] = self.model
gc.collect()
return self.model
def _get_hf_pipeline(self, model_id):
if model_id in self.loaded_pipelines:
return self.loaded_pipelines[model_id]
try:
from transformers import pipeline
device_index = 0 if torch.cuda.is_available() else -1
kwargs = {"model": model_id, "device": device_index}
if torch.cuda.is_available():
kwargs["torch_dtype"] = torch.float16
print(f"🔄 Loading ASR pipeline '{model_id}'...")
self.loaded_pipelines[model_id] = pipeline("automatic-speech-recognition", **kwargs)
gc.collect()
return self.loaded_pipelines[model_id]
except Exception as e:
raise RuntimeError(f"ASR pipeline '{model_id}' unavailable: {e}") from e
def _get_mms_pipeline(self, language=None, dialect_hint=""):
lang_code = self._mms_language_code(language, dialect_hint)
cache_key = f"{self.MMS_MODEL_ID}:{lang_code}"
if cache_key in self.loaded_pipelines:
return self.loaded_pipelines[cache_key]
try:
from transformers import AutoProcessor, Wav2Vec2ForCTC, pipeline
device_index = 0 if torch.cuda.is_available() else -1
processor = AutoProcessor.from_pretrained(self.MMS_MODEL_ID)
model = Wav2Vec2ForCTC.from_pretrained(self.MMS_MODEL_ID)
try:
processor.tokenizer.set_target_lang(lang_code)
model.load_adapter(lang_code)
except Exception as e:
print(f"⚠️ MMS adapter '{lang_code}' unavailable ({e}); trying English adapter.")
lang_code = "eng"
processor.tokenizer.set_target_lang(lang_code)
model.load_adapter(lang_code)
cache_key = f"{self.MMS_MODEL_ID}:{lang_code}"
if torch.cuda.is_available():
model = model.to("cuda")
self.loaded_pipelines[cache_key] = pipeline(
"automatic-speech-recognition",
model=model,
tokenizer=processor.tokenizer,
feature_extractor=processor.feature_extractor,
device=device_index,
)
gc.collect()
return self.loaded_pipelines[cache_key]
except Exception as e:
raise RuntimeError(f"MMS ASR unavailable: {e}") from e
def _transcribe_with_route(self, route, clean_path, language=None, dialect_hint=""):
start = time.perf_counter()
if route["engine"] == "whisper":
model = self._get_model(route["model"])
result = model.transcribe(clean_path, language=language, fp16=False)
elif route["engine"] == "mms":
pipe = self._get_mms_pipeline(language, dialect_hint)
result = pipe(clean_path)
else:
pipe = self._get_hf_pipeline(route["model"])
generate_kwargs = {"task": "transcribe"}
if language:
generate_kwargs["language"] = language
result = pipe(clean_path, generate_kwargs=generate_kwargs)
elapsed_s = time.perf_counter() - start
if isinstance(result, dict):
text = str(result.get("text", "")).strip()
else:
text = str(result or "").strip()
if route["model"] == self.TURBO_MODEL_ID and elapsed_s > self.turbo_slow_threshold_s:
self.turbo_disabled = True
print(f"⚠️ Turbo route was slow ({elapsed_s:.2f}s); future Auto calls will use Whisper small.")
return text, elapsed_s
# 🟢 NEW HELPER: Sanitizes corrupted browser audio
def _sanitize_audio(self, audio_path):
try:
# Try to load it regardless of format
audio = AudioSegment.from_file(audio_path)
# Export it to a clean, standard WAV in a temp file
temp_path = os.path.join(tempfile.gettempdir(), f"clean_audio_{os.path.basename(audio_path)}.wav")
audio.export(temp_path, format="wav")
return temp_path
except Exception as e:
print(f"⚠️ Audio Sanitization Warning: {e}")
return audio_path # Fallback to original if pydub fails
def _should_sanitize_audio(self, sanitize_audio=True):
if isinstance(sanitize_audio, str):
return sanitize_audio.strip().lower() not in {"off", "false", "0", "no", "raw", "none"}
return bool(sanitize_audio)
def transcribe(self, audio_path, language=None, model_choice="auto", dialect_hint="", sanitize_audio=True):
if not audio_path:
return [{"text": "", "speaker": "SYSTEM"}]
try:
clean_path = self._sanitize_audio(audio_path) if self._should_sanitize_audio(sanitize_audio) else audio_path
manual_route = self._normalize_manual_route(model_choice)
routes = [manual_route] if manual_route else self._auto_routes(language=language, dialect_hint=dialect_hint, audio_path=clean_path)
if manual_route and manual_route["engine"] != "whisper":
routes.append({"engine": "whisper", "model": "small", "label": "Whisper small (Fallback)"})
transcription_text = ""
used_label = "Unknown"
underrepresented = self._is_underrepresented_language(language, dialect_hint)
tried_mms_retry = False
for route in routes:
try:
text, elapsed_s = self._transcribe_with_route(route, clean_path, language=language, dialect_hint=dialect_hint)
used_label = route["label"]
print(f"🎙️ Speech model route: requested={model_choice or 'auto'} resolved={used_label} time={elapsed_s:.2f}s")
transcription_text = text
if not manual_route and route["model"] == self.TURBO_MODEL_ID and elapsed_s > self.turbo_slow_threshold_s:
transcription_text = ""
continue
needs_mms_retry = (
not manual_route
and route["engine"] != "mms"
and not tried_mms_retry
and (underrepresented or self._looks_wrong_script(text, language, dialect_hint))
)
if needs_mms_retry:
tried_mms_retry = True
mms_route = {"engine": "mms", "model": self.MMS_MODEL_ID, "label": "MMS 1B (Low-resource)"}
print("🔁 ASR output needs low-resource validation; retrying with MMS 1B.")
mms_text, mms_elapsed = self._transcribe_with_route(mms_route, clean_path, language=language, dialect_hint=dialect_hint)
if mms_text and not self._looks_wrong_script(mms_text, language, dialect_hint):
transcription_text = mms_text
used_label = mms_route["label"]
print(f"🎙️ Speech model route: retry resolved={used_label} time={mms_elapsed:.2f}s")
if transcription_text:
break
except Exception as route_error:
print(f"⚠️ ASR route '{route['label']}' failed: {route_error}")
continue
gc.collect() # Force memory release
# Clean up temp file
if clean_path != audio_path and os.path.exists(clean_path):
os.remove(clean_path)
return [{"text": transcription_text, "speaker": "Speaker 1", "model": used_label}]
except Exception as e:
print(f"❌ Transcription Error: {e}")
return [{"text": "", "speaker": "ERROR"}]