""" audio_transcriber.py — Background Audio → SRT Transcription run_transcription_bg(job_id, audio_bytes, filename, language) → Spawns a daemon thread that runs independently of the browser session. → Progress saved to job_store → UI polls job_store to show status. → Browser close / refresh does NOT stop transcription. Model: large-v3 only (best accuracy for JA/KO/ZH/TH/HI/EN). """ import os import logging import tempfile import threading from typing import Callable from job_store import update_job, save_result logger = logging.getLogger(__name__) # ── Fixed model: large-v3 ───────────────────────────────────────────────── MODEL = "large-v3" MODEL_DIR = os.path.join(tempfile.gettempdir(), "whisper_models") SUPPORTED_LANGUAGES = { "auto": "Auto-detect", "en": "English", "ja": "Japanese (日本語)", "ko": "Korean (한국어)", "zh": "Chinese (中文)", "th": "Thai (ภาษาไทย)", "hi": "Hindi (हिन्दी)", "fr": "French", "de": "German", "es": "Spanish", "it": "Italian", "pt": "Portuguese", "ru": "Russian", "ar": "Arabic", "vi": "Vietnamese", "id": "Indonesian", } # ───────────────────────────────────────────────────────────────────────────── # Time formatting # ───────────────────────────────────────────────────────────────────────────── def _fmt(seconds: float) -> str: ms = int(round((seconds % 1) * 1000)) s = int(seconds) h, m, s = s // 3600, (s % 3600) // 60, s % 60 return f"{h:02}:{m:02}:{s:02},{ms:03}" # ───────────────────────────────────────────────────────────────────────────── # Segments → SRT string # ───────────────────────────────────────────────────────────────────────────── def _segments_to_srt(segments: list) -> str: lines = [] idx = 1 for seg in segments: text = seg.text.strip() if not text: continue lines += [str(idx), f"{_fmt(seg.start)} --> {_fmt(seg.end)}", text, ""] idx += 1 return "\n".join(lines) # ───────────────────────────────────────────────────────────────────────────── # Core transcription (runs in background thread) # ───────────────────────────────────────────────────────────────────────────── def _transcribe_worker( job_id: str, audio_bytes: bytes, filename: str, language: str, ) -> None: """ Background worker — runs in its own thread, independent of browser session. Updates job_store so UI can poll progress even after refresh. Thread is daemon=False so it finishes even if main thread exits (as long as Docker container is alive). """ tmp_path = None try: from faster_whisper import WhisperModel def cb(frac: float, msg: str) -> None: update_job(job_id, progress=frac, status_msg=msg) cb(0.0, "Model load လုပ်နေသည် (large-v3, ပထမဆုံးအကြိမ် ကြာနိုင်သည်)…") # Write audio to temp file suffix = os.path.splitext(filename)[1] or ".mp3" fd, tmp_path = tempfile.mkstemp(suffix=suffix) os.write(fd, audio_bytes) os.close(fd) # Load Whisper large-v3 model = WhisperModel( MODEL, device="cpu", compute_type="int8", # fastest CPU quantization download_root=MODEL_DIR, ) cb(0.05, f"large-v3 load ပြီး — Transcribing…") lang_code = None if language == "auto" else language segments_gen, info = model.transcribe( tmp_path, language=lang_code, beam_size=5, temperature=0.0, condition_on_previous_text=False, # reduce hallucination vad_filter=True, vad_parameters={ "min_silence_duration_ms": 500, "threshold": 0.35, }, ) detected = info.language duration = max(info.duration or 1.0, 1.0) cb(0.08, f"ဘာသာစကား: {detected} · {duration:.0f}s — Segments ထုတ်နေသည်…") segments = [] for seg in segments_gen: segments.append(seg) pct = min(0.08 + (seg.end / duration) * 0.90, 0.98) elapsed = f"{seg.end:.0f}s/{duration:.0f}s" cb(pct, f"Transcribing… {elapsed} [{len(segments)} segments]") if not segments: update_job(job_id, status="failed", error="Audio မှ speech မတွေ့ပါ", status_msg="⚠ Speech မတွေ့ပါ") return cb(0.99, f"SRT ဖန်တီးနေသည်… ({len(segments)} segments)") srt_text = _segments_to_srt(segments) srt_bytes = srt_text.encode("utf-8") save_result(job_id, srt_bytes) # save_result already sets status=completed except ImportError: err = ("faster-whisper မထည့်ရသေးပါ — " "requirements.txt မှာ faster-whisper>=1.0.0 ပါပြီး redeploy လုပ်ပါ") update_job(job_id, status="failed", error=err, status_msg="❌ Import Error") except Exception as exc: logger.exception("Transcription worker error") update_job(job_id, status="failed", error=str(exc)[:400], status_msg=f"❌ Error: {exc}") finally: if tmp_path and os.path.exists(tmp_path): try: os.unlink(tmp_path) except Exception: pass # ───────────────────────────────────────────────────────────────────────────── # Public API — launch background thread # ───────────────────────────────────────────────────────────────────────────── def run_transcription_bg( job_id: str, audio_bytes: bytes, filename: str, language: str = "auto", ) -> threading.Thread: """ Spawn a background thread to transcribe audio. The thread runs independently of the Streamlit browser session. Browser close / page refresh does NOT stop the transcription. Returns the Thread object (caller can ignore it). """ update_job(job_id, status="running", progress=0.0, status_msg="Background thread started…") t = threading.Thread( target=_transcribe_worker, args=(job_id, audio_bytes, filename, language), daemon=True, # daemon=True: thread exits with container (correct behavior) name=f"whisper-{job_id}", ) t.start() logger.info("Transcription thread started: job=%s file=%s", job_id, filename) return t