101 lines
3.9 KiB
Python
101 lines
3.9 KiB
Python
"""音视频转写: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
|