Files

101 lines
3.9 KiB
Python
Raw Permalink Normal View History

"""音视频转写:faster-whisper 本地模型 + ffmpeg 抽音轨。
输出与 .srt 同格式的「去时间戳、按语义间隙合并」文本,直接进入既有 RAG 分块管线。
"""
from __future__ import annotations
import os
import re
import subprocess
import threading
from pathlib import Path
from .parse import clean_text
MEDIA_SUFFIXES = {".mp4", ".mov", ".m4a", ".mp3", ".wav", ".flv", ".mkv", ".avi", ".webm", ".aac", ".ogg", ".flac"}
# 模型目录:优先环境变量,其次 modelscope 缓存里的 gpustack/faster-whisper-small
_MODEL_DIR_CANDIDATES = [
os.environ.get("WHISPER_MODEL_DIR", ""),
str(Path.home() / ".cache/modelscope/models/gpustack--faster-whisper-small/snapshots/master"),
]
_model = None
_model_lock = threading.Lock()
def _get_model():
global _model
if _model is not None:
return _model
with _model_lock:
if _model is not None:
return _model
from faster_whisper import WhisperModel
for cand in _MODEL_DIR_CANDIDATES:
if cand and Path(cand).exists():
_model = WhisperModel(cand, device="cpu", compute_type="int8", local_files_only=True)
return _model
_model = WhisperModel("small", device="cpu", compute_type="int8")
return _model
def is_media(path: Path) -> bool:
return path.suffix.lower() in MEDIA_SUFFIXES
def _probe_duration(path: Path) -> float:
try:
r = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", str(path)],
capture_output=True, text=True, timeout=30)
return float(r.stdout.strip() or 0)
except Exception:
return 0.0
def transcribe_media(path: Path, progress_cb=None) -> list[dict]:
"""音视频 → 语义段落列表 [{text, meta:{heading}}]。
超长音先压缩码率降体积;VAD 过滤静音;按标点+间隙合并成语义段。"""
src = Path(path)
work = src
# 大文件先转 16k mono wav(whisper 输入要求,同时把视频降为纯音轨)
if src.suffix.lower() not in (".wav",) or _probe_duration(src) > 0:
work = src.with_suffix(".wav.wav")
cmd = ["ffmpeg", "-y", "-i", str(src), "-vn", "-ar", "16000", "-ac", "1",
"-b:a", "64k", str(work)]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
if r.returncode != 0 or not work.exists() or work.stat().st_size == 0:
raise ValueError(f"ffmpeg 抽音轨失败: {r.stderr[-200:]}")
try:
model = _get_model()
segments, _info = model.transcribe(str(work), language="zh", vad_filter=True,
vad_parameters={"min_silence_duration_ms": 700})
lines: list[str] = []
for seg in segments:
t = (seg.text or "").strip()
if t:
lines.append(t)
if progress_cb and len(lines) % 20 == 0:
progress_cb(len(lines))
if not lines:
return []
# 合并成语义段:约 15 句或 1200 字一段(与既有 2200 字分块目标匹配)
merged, buf, size = [], [], 0
for t in lines:
buf.append(t)
size += len(t)
if size >= 1200 or t.endswith(("。", "!", "?", "!", "?")) and size >= 600:
merged.append(" ".join(buf))
buf, size = [], 0
if buf:
merged.append(" ".join(buf))
# 转写结果也经过同一套结构清洗:去掉字幕式序号/时间戳、不可见字符和句首独立口头填充词,
# 保留正文中的日期、金额、型号等数字。
return [{"text": clean_text(m), "meta": {"heading": src.stem}}
for m in merged if clean_text(m)]
finally:
if work != src:
try:
work.unlink(missing_ok=True)
except OSError:
pass