Files
yawan-workbench/server/app/rag/media.py
T

101 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""音视频转写: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