"""faster-whisper 기반 전체 대본 전사 (세그먼트 + 단어 타임스탬프). ★ 핵심 원칙(참고 영상 08:11): 전체 대본을 먼저 뽑아 맥락을 잡는다. → 이 대본이 NG 탐지 · 자막 · 숏폼 하이라이트 선택의 공통 재료. 함정: - ASR(numba)은 동시 호출 시 segfault → 호출부(pipeline)에서 asyncio.Lock 직렬화. - 캐시는 content hash 기준(mtime 아님) → 같은 영상 재업로드 시 hit. """ from __future__ import annotations import hashlib import json import os from dataclasses import asdict, dataclass from typing import List, Optional CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".cache") os.makedirs(CACHE_DIR, exist_ok=True) # 모델 인스턴스 재사용(로딩 비쌈). (model_size, compute_type) → WhisperModel _MODEL_CACHE: dict = {} @dataclass class Word: start: float end: float text: str @dataclass class Seg: start: float end: float text: str words: List[Word] @dataclass class Transcript: language: str text: str # 전체 대본 (한 덩어리) segments: List[Seg] def to_json(self) -> dict: return { "language": self.language, "text": self.text, "segments": [ {"start": s.start, "end": s.end, "text": s.text, "words": [asdict(w) for w in s.words]} for s in self.segments ], } @staticmethod def from_json(d: dict) -> "Transcript": return Transcript( language=d["language"], text=d["text"], segments=[ Seg(s["start"], s["end"], s["text"], [Word(**w) for w in s["words"]]) for s in d["segments"] ], ) def _file_hash(path: str) -> str: h = hashlib.sha1() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest()[:12] def _get_model(model_size: str, compute_type: str): key = (model_size, compute_type) if key not in _MODEL_CACHE: from faster_whisper import WhisperModel # 지연 import (무거움) _MODEL_CACHE[key] = WhisperModel(model_size, device="cpu", compute_type=compute_type) return _MODEL_CACHE[key] def transcribe( video_path: str, *, model_size: str = "medium", language: Optional[str] = "ko", compute_type: str = "int8", use_cache: bool = True, vad_filter: bool = True, ) -> Transcript: """영상 전사 → Transcript. content-hash 캐시 적용. vad_filter=True: 무음 제거(환각↓) 하지만 짧은 외침을 놓칠 수 있음. vad_filter=False: 모든 발화 포착(자막 커버리지↑) — 컷이 오디오 기준일 때 자막용으로 적합. 동기 함수. 호출부에서 ASR_LOCK 으로 직렬화하고 thread 로 돌릴 것. """ h = _file_hash(video_path) tag = ("vad" if vad_filter else "novad") + "2" # 2 = 환각억제(no_repeat) 설정 cache_path = os.path.join(CACHE_DIR, f"asr_{model_size}_{tag}_{h}.json") if use_cache and os.path.exists(cache_path): with open(cache_path, encoding="utf-8") as f: return Transcript.from_json(json.load(f)) model = _get_model(model_size, compute_type) seg_iter, info = model.transcribe( video_path, language=language, word_timestamps=True, vad_filter=vad_filter, vad_parameters={"min_silence_duration_ms": 400} if vad_filter else None, condition_on_previous_text=False, # 세그먼트 간 환각 드리프트 억제 no_repeat_ngram_size=3, # 긴 어구 환각 루프(외국어 등) 차단. 단어 반복은 허용 ) segments: List[Seg] = [] for s in seg_iter: # 제너레이터 → 여기서 실제 추론 진행 words = [Word(w.start, w.end, w.word) for w in (s.words or [])] segments.append(Seg(s.start, s.end, s.text.strip(), words)) full_text = " ".join(s.text for s in segments).strip() tr = Transcript(language=info.language, text=full_text, segments=segments) if use_cache: with open(cache_path, "w", encoding="utf-8") as f: json.dump(tr.to_json(), f, ensure_ascii=False, indent=1) return tr