| """ |
| audio_transcriber.py โ Background Audio โ SRT Transcription |
| Model: faster-whisper large-v3 (CPU, int8 quantization) |
| |
| Fixes applied: |
| - word_timestamps=True for accurate per-word timing |
| - VAD threshold lowered to 0.20 to avoid missing quiet speech |
| - no_speech_threshold=0.5 (less aggressive segment dropping) |
| - Hallucination filter: skip segments with bad logprob or high compression |
| - Long segment split at punctuation boundaries (max 80 chars) |
| """ |
|
|
| import os |
| import re |
| import logging |
| import tempfile |
| import threading |
|
|
| from job_store import update_job, save_result |
|
|
| logger = logging.getLogger(__name__) |
|
|
| 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", |
| } |
|
|
| |
| _HALLUCINATION_RE = re.compile( |
| r"^\s*(\[.*?\]|\(.*?\)|โช.*?โช|thank you[.!]?|thanks for watching[.!]?)\s*$", |
| re.IGNORECASE |
| ) |
| _MAX_CHARS = 80 |
|
|
|
|
| |
| |
| |
|
|
| 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}" |
|
|
|
|
| |
| |
| |
|
|
| def _is_hallucination(seg) -> bool: |
| """ |
| Return True if this segment should be dropped. |
| Whisper commonly hallucinates: |
| - [Music] [Applause] (brackets) |
| - Very low avg_logprob (AI is uncertain) |
| - Very high compression_ratio (repetitive text) |
| """ |
| text = seg.text.strip() |
|
|
| |
| if not text: |
| return True |
|
|
| |
| if _HALLUCINATION_RE.match(text): |
| return True |
|
|
| |
| if hasattr(seg, 'avg_logprob') and seg.avg_logprob < -1.0: |
| return True |
|
|
| |
| if hasattr(seg, 'compression_ratio') and seg.compression_ratio > 2.4: |
| return True |
|
|
| |
| if seg.end <= seg.start: |
| return True |
|
|
| return False |
|
|
|
|
| |
| |
| |
|
|
| def _split_segment(seg) -> list: |
| """ |
| If a segment is too long (>_MAX_CHARS), split it at sentence/phrase |
| boundaries using word-level timestamps. |
| |
| Returns list of (start, end, text) tuples. |
| If word timestamps are unavailable, returns the segment as-is. |
| """ |
| text = seg.text.strip() |
|
|
| |
| if len(text) <= _MAX_CHARS: |
| return [(seg.start, seg.end, text)] |
|
|
| |
| words = getattr(seg, 'words', None) |
| if not words: |
| return [(seg.start, seg.end, text)] |
|
|
| |
| result = [] |
| cur_words = [] |
| cur_start = words[0].start if words else seg.start |
|
|
| for word in words: |
| cur_words.append(word) |
| cur_text = "".join(w.word for w in cur_words).strip() |
|
|
| |
| at_boundary = cur_text and cur_text[-1] in ".!?,;:โฆโ" |
| too_long = len(cur_text) > _MAX_CHARS |
|
|
| if (at_boundary or too_long) and cur_words: |
| result.append((cur_start, word.end, cur_text)) |
| cur_words = [] |
| cur_start = word.end |
|
|
| |
| if cur_words: |
| cur_text = "".join(w.word for w in cur_words).strip() |
| if cur_text: |
| result.append((cur_start, seg.end, cur_text)) |
|
|
| return result if result else [(seg.start, seg.end, text)] |
|
|
|
|
| |
| |
| |
|
|
| def _segments_to_srt(segments: list) -> str: |
| """ |
| Convert filtered segments to SRT string. |
| - Drops hallucinations |
| - Splits long segments at word boundaries |
| - Ensures timestamp ordering (no backward jumps) |
| """ |
| lines = [] |
| idx = 1 |
| prev_end = 0.0 |
|
|
| for seg in segments: |
| |
| if _is_hallucination(seg): |
| logger.debug("Dropped hallucination: %s", seg.text[:50]) |
| continue |
|
|
| |
| sub_segs = _split_segment(seg) |
|
|
| for (start, end, text) in sub_segs: |
| text = text.strip() |
| if not text: |
| continue |
|
|
| |
| start = max(start, prev_end) |
| if end <= start: |
| end = start + 0.5 |
|
|
| lines += [str(idx), f"{_fmt(start)} --> {_fmt(end)}", text, ""] |
| idx += 1 |
| prev_end = end |
|
|
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| def _transcribe_worker(job_id: str, audio_bytes: bytes, |
| filename: str, language: str) -> None: |
| tmp_path = None |
| try: |
| from faster_whisper import WhisperModel |
|
|
| def cb(frac, msg): |
| update_job(job_id, progress=frac, status_msg=msg) |
|
|
| cb(0.0, "Model load แแฏแแบแแฑแแแบ (large-v3)โฆ") |
|
|
| suffix = os.path.splitext(filename)[1] or ".mp3" |
| fd, tmp_path = tempfile.mkstemp(suffix=suffix) |
| os.write(fd, audio_bytes) |
| os.close(fd) |
|
|
| model = WhisperModel( |
| MODEL, device="cpu", compute_type="int8", |
| download_root=MODEL_DIR, |
| ) |
| cb(0.05, "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, |
|
|
| |
| word_timestamps=True, |
|
|
| |
| vad_filter=True, |
| vad_parameters={ |
| "min_silence_duration_ms": 300, |
| "threshold": 0.20, |
| }, |
|
|
| |
| no_speech_threshold=0.5, |
| log_prob_threshold=-1.0, |
| compression_ratio_threshold=2.4, |
| ) |
|
|
| 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) |
| cb(pct, f"Transcribingโฆ {seg.end:.0f}s/{duration:.0f}s [{len(segments)} segs]") |
|
|
| 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") |
|
|
| |
| entry_count = srt_text.count("\n\n") + (1 if srt_text.strip() else 0) |
| update_job(job_id, status_msg=f"แแผแฎแธแ
แฎแธแแแบ โ {entry_count} subtitles ยท {detected}") |
| save_result(job_id, srt_bytes) |
|
|
| except ImportError: |
| update_job(job_id, status="failed", |
| error="faster-whisper แแแแทแบแแแฑแธแแซ โ requirements.txt แ
แ
แบแแซ", |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| def run_transcription_bg(job_id: str, audio_bytes: bytes, |
| filename: str, language: str = "auto") -> threading.Thread: |
| """Spawn background transcription thread. Survives browser disconnect.""" |
| 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, |
| name=f"whisper-{job_id}", |
| ) |
| t.start() |
| return t |
|
|