"""ffmpeg silencedetect 기반 무음 감지 → 발화(speech) 구간 추출. 핵심: 무음을 직접 찾고, 그 여집합 = 발화 구간. 발화 구간 양 끝에 padding 을 주어 단어 시작/끝이 잘리지 않게 한다(점프컷 특유의 '말 잘림' 방지). """ from __future__ import annotations import re import subprocess from typing import List, Tuple Segment = Tuple[float, float] # (start_sec, end_sec) _SILENCE_START = re.compile(r"silence_start:\s*(-?[\d.]+)") _SILENCE_END = re.compile(r"silence_end:\s*(-?[\d.]+)") def _detect_silences(video_path: str, noise_db: float, min_silence: float) -> List[Segment]: """무음 구간 [(start, end), ...] (초) 반환.""" cmd = [ "ffmpeg", "-hide_banner", "-nostats", "-i", video_path, "-af", f"silencedetect=noise={noise_db}dB:d={min_silence}", "-f", "null", "-", ] # silencedetect 로그는 stderr 로 나온다. 단, Windows에서 부모 프로세스(uvicorn)의 # stderr 핸들이 리다이렉트된 경우 capture_output 의 stderr PIPE 가 None 으로 잡히는 # 케이스가 있어, stderr→stdout 병합 후 stdout 을 읽는다(검증된 경로). proc = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", ) log = proc.stdout or "" silences: List[Segment] = [] cur_start = None for line in log.splitlines(): m = _SILENCE_START.search(line) if m: cur_start = float(m.group(1)) continue m = _SILENCE_END.search(line) if m and cur_start is not None: silences.append((cur_start, float(m.group(1)))) cur_start = None return silences def detect_speech_segments( video_path: str, duration: float, *, noise_db: float = -30.0, min_silence: float = 0.4, pad: float = 0.08, min_speech: float = 0.2, ) -> List[Segment]: """무음의 여집합 = 발화 구간. padding/merge/최소길이 필터 적용. Args: duration: 전체 영상 길이(초). probe 로 미리 구한 값. noise_db: 이 dB 이하를 무음으로 간주. min_silence: 이 길이(초) 이상 지속돼야 무음으로 컷. pad: 발화 구간 양 끝 여유(초). 말 잘림 방지. min_speech: 이보다 짧은 발화 조각은 버림(노이즈성 컷 방지). """ silences = _detect_silences(video_path, noise_db, min_silence) # 무음의 여집합 = 발화 speech: List[Segment] = [] cursor = 0.0 for s_start, s_end in silences: if s_start > cursor: speech.append((cursor, s_start)) cursor = max(cursor, s_end) if cursor < duration: speech.append((cursor, duration)) # padding (양 끝 확장) + 클램프 padded = [(max(0.0, s - pad), min(duration, e + pad)) for s, e in speech] # padding 으로 겹친 구간 병합 merged: List[Segment] = [] for s, e in padded: if merged and s <= merged[-1][1]: merged[-1] = (merged[-1][0], max(merged[-1][1], e)) else: merged.append((s, e)) # 최소 발화 길이 필터 return [(s, e) for s, e in merged if (e - s) >= min_speech]