Shahah / audio_transcriber.py
Kai72828's picture
Upload 11 files
223945e verified
Raw
History Blame
12.2 kB
"""
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 patterns Whisper commonly generates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_HALLUCINATION_RE = re.compile(
r"^\s*(\[.*?\]|\(.*?\)|โ™ช.*?โ™ช|thank you[.!]?|thanks for watching[.!]?)\s*$",
re.IGNORECASE
)
_MAX_CHARS = 80 # max chars per subtitle line before splitting
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Timestamp 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}"
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Segment filtering โ€” remove hallucinations
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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()
# Empty
if not text:
return True
# Known hallucination patterns
if _HALLUCINATION_RE.match(text):
return True
# Low confidence โ€” model very uncertain about this segment
if hasattr(seg, 'avg_logprob') and seg.avg_logprob < -1.0:
return True
# High repetition โ€” model is stuck in a loop
if hasattr(seg, 'compression_ratio') and seg.compression_ratio > 2.4:
return True
# Timestamp sanity โ€” end must be after start
if seg.end <= seg.start:
return True
return False
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Long segment splitting
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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()
# Short enough โ€” no split needed
if len(text) <= _MAX_CHARS:
return [(seg.start, seg.end, text)]
# Try word-level split
words = getattr(seg, 'words', None)
if not words:
return [(seg.start, seg.end, text)]
# Build sub-segments from word timestamps
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()
# Split at punctuation or when too long
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
# Remainder
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)]
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Segments โ†’ SRT
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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:
# Drop bad segments
if _is_hallucination(seg):
logger.debug("Dropped hallucination: %s", seg.text[:50])
continue
# Split if too long
sub_segs = _split_segment(seg)
for (start, end, text) in sub_segs:
text = text.strip()
if not text:
continue
# Timestamp sanity: don't go backwards
start = max(start, prev_end)
if end <= start:
end = start + 0.5 # give at least 0.5s
lines += [str(idx), f"{_fmt(start)} --> {_fmt(end)}", text, ""]
idx += 1
prev_end = end
return "\n".join(lines)
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Background transcription worker
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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, # reduce hallucination loops
# โ”€โ”€ Timestamp accuracy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
word_timestamps=True, # per-word timing for accurate SRT
# โ”€โ”€ VAD โ€” catch more speech, miss less โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
vad_filter=True,
vad_parameters={
"min_silence_duration_ms": 300, # was 500 โ†’ catch short pauses
"threshold": 0.20, # was 0.35 โ†’ less aggressive
},
# โ”€โ”€ Segment quality control โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
no_speech_threshold=0.5, # was default 0.6 โ†’ keep more
log_prob_threshold=-1.0, # drop very uncertain segments
compression_ratio_threshold=2.4, # drop repetitive segments
)
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")
# Count actual subtitle entries
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
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Public API
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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