"""Transcribe the first audio stream of one local video with faster-whisper."""
|
|
from __future__ import annotations
|
|
import argparse
|
import json
|
import math
|
import os
|
import shutil
|
import subprocess
|
import sys
|
import tempfile
|
from dataclasses import dataclass
|
from pathlib import Path
|
from typing import Any, Callable, Iterable, Sequence
|
|
|
MODEL_NAME = "large-v3"
|
DEVICE = "cuda"
|
COMPUTE_TYPE = "float16"
|
SHORT_AUDIO_LIMIT_SECONDS = 1200.0
|
CHUNK_CORE_SECONDS = 1200.0
|
CHUNK_OVERLAP_SECONDS = 5.0
|
|
|
class MediaTranscriptionError(RuntimeError):
|
"""A user-facing error that should stop the command without a traceback."""
|
|
|
@dataclass(frozen=True)
|
class OutputPaths:
|
directory: Path
|
audio: Path
|
text: Path
|
srt: Path
|
json: Path
|
|
@property
|
def files(self) -> tuple[Path, Path, Path, Path]:
|
return (self.audio, self.text, self.srt, self.json)
|
|
|
@dataclass(frozen=True)
|
class RuntimeInfo:
|
cuda_device_count: int
|
cuda_compute_types: tuple[str, ...]
|
|
|
@dataclass(frozen=True)
|
class TranscriptSegment:
|
start: float
|
end: float
|
text: str
|
|
|
@dataclass(frozen=True)
|
class TranscriptResult:
|
language: str | None
|
language_probability: float | None
|
segments: tuple[TranscriptSegment, ...]
|
|
|
@dataclass(frozen=True)
|
class AudioChunk:
|
index: int
|
total: int
|
core_start: float
|
core_end: float
|
audio_start: float
|
audio_end: float
|
|
@property
|
def audio_duration(self) -> float:
|
return self.audio_end - self.audio_start
|
|
|
ProcessRunner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
|
ModelLoader = Callable[[], tuple[Any, RuntimeInfo]]
|
|
|
def _run_process(command: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
return subprocess.run(
|
list(command),
|
check=False,
|
capture_output=True,
|
text=True,
|
encoding="utf-8",
|
errors="replace",
|
)
|
|
|
def _error_detail(result: subprocess.CompletedProcess[str]) -> str:
|
detail = (result.stderr or result.stdout or "未返回错误详情").strip()
|
return detail[-1200:]
|
|
|
def _require_executable(name: str) -> str:
|
executable = shutil.which(name)
|
if executable is None:
|
raise MediaTranscriptionError(
|
f"未找到 {name},请先安装并确保它位于当前 PATH。"
|
)
|
return executable
|
|
|
def _resolve_video(video_path: str | Path) -> Path:
|
video = Path(video_path).expanduser().resolve()
|
if not video.exists():
|
raise MediaTranscriptionError(f"视频文件不存在:{video}")
|
if not video.is_file():
|
raise MediaTranscriptionError(f"输入路径不是文件:{video}")
|
return video
|
|
|
def _build_output_paths(video: Path, output_dir: str | Path | None) -> OutputPaths:
|
if output_dir is None:
|
directory = video.with_name(f"{video.stem}.transcript")
|
else:
|
directory = Path(output_dir).expanduser().resolve()
|
|
stem = video.stem
|
return OutputPaths(
|
directory=directory,
|
audio=directory / f"{stem}.audio.flac",
|
text=directory / f"{stem}.txt",
|
srt=directory / f"{stem}.srt",
|
json=directory / f"{stem}.json",
|
)
|
|
|
def _ensure_output_available(outputs: OutputPaths) -> None:
|
if outputs.directory.exists() and not outputs.directory.is_dir():
|
raise MediaTranscriptionError(
|
f"输出路径已存在且不是目录:{outputs.directory}"
|
)
|
|
conflicts = [path for path in outputs.files if path.exists()]
|
if conflicts:
|
formatted = "\n".join(f"- {path}" for path in conflicts)
|
raise MediaTranscriptionError(
|
"输出文件已存在;默认不会覆盖。请改用新的输出目录或先人工处理已有结果:\n"
|
f"{formatted}"
|
)
|
|
|
def _probe_first_audio_stream(
|
video: Path,
|
ffprobe: str,
|
runner: ProcessRunner,
|
) -> None:
|
command = [
|
ffprobe,
|
"-v",
|
"error",
|
"-select_streams",
|
"a:0",
|
"-show_entries",
|
"stream=index",
|
"-of",
|
"csv=p=0",
|
str(video),
|
]
|
result = runner(command)
|
if result.returncode != 0:
|
raise MediaTranscriptionError(
|
f"FFprobe 无法检查视频音轨:{_error_detail(result)}"
|
)
|
if not result.stdout.strip():
|
raise MediaTranscriptionError(f"视频不包含可用音轨:{video}")
|
|
|
def _extract_first_audio_stream(
|
video: Path,
|
audio_path: Path,
|
ffmpeg: str,
|
runner: ProcessRunner,
|
) -> None:
|
command = [
|
ffmpeg,
|
"-hide_banner",
|
"-loglevel",
|
"error",
|
"-nostdin",
|
"-n",
|
"-i",
|
str(video),
|
"-map",
|
"0:a:0",
|
"-vn",
|
"-ac",
|
"1",
|
"-ar",
|
"16000",
|
"-c:a",
|
"flac",
|
str(audio_path),
|
]
|
result = runner(command)
|
if result.returncode != 0:
|
raise MediaTranscriptionError(
|
f"FFmpeg 提取第一条音轨失败:{_error_detail(result)}"
|
)
|
if not audio_path.is_file() or audio_path.stat().st_size == 0:
|
raise MediaTranscriptionError("FFmpeg 未生成有效的 FLAC 音频文件。")
|
|
|
def _probe_audio_duration(
|
audio_path: Path,
|
ffprobe: str,
|
runner: ProcessRunner,
|
) -> float:
|
command = [
|
ffprobe,
|
"-v",
|
"error",
|
"-show_entries",
|
"format=duration",
|
"-of",
|
"default=noprint_wrappers=1:nokey=1",
|
str(audio_path),
|
]
|
result = runner(command)
|
if result.returncode != 0:
|
raise MediaTranscriptionError(
|
f"FFprobe 无法读取 FLAC 时长:{_error_detail(result)}"
|
)
|
try:
|
duration = float(result.stdout.strip())
|
except ValueError as exc:
|
raise MediaTranscriptionError(
|
f"FFprobe 返回了无效的 FLAC 时长:{result.stdout.strip() or '空'}"
|
) from exc
|
if not math.isfinite(duration) or duration <= 0:
|
raise MediaTranscriptionError(f"FLAC 时长必须为有限正数,实际为:{duration}")
|
return duration
|
|
|
def _plan_audio_chunks(duration: float) -> tuple[AudioChunk, ...]:
|
if not math.isfinite(duration) or duration <= SHORT_AUDIO_LIMIT_SECONDS:
|
return ()
|
|
total = int(math.ceil(duration / CHUNK_CORE_SECONDS))
|
chunks: list[AudioChunk] = []
|
for offset in range(total):
|
core_start = offset * CHUNK_CORE_SECONDS
|
core_end = min(duration, core_start + CHUNK_CORE_SECONDS)
|
chunks.append(
|
AudioChunk(
|
index=offset + 1,
|
total=total,
|
core_start=core_start,
|
core_end=core_end,
|
audio_start=max(0.0, core_start - CHUNK_OVERLAP_SECONDS),
|
audio_end=min(duration, core_end + CHUNK_OVERLAP_SECONDS),
|
)
|
)
|
return tuple(chunks)
|
|
|
def _extract_audio_chunk(
|
full_audio: Path,
|
chunk_path: Path,
|
chunk: AudioChunk,
|
ffmpeg: str,
|
runner: ProcessRunner,
|
) -> None:
|
if chunk_path.exists():
|
raise MediaTranscriptionError(f"临时音频块已存在,拒绝覆盖:{chunk_path}")
|
command = [
|
ffmpeg,
|
"-hide_banner",
|
"-loglevel",
|
"error",
|
"-nostdin",
|
"-n",
|
"-ss",
|
f"{chunk.audio_start:.6f}",
|
"-i",
|
str(full_audio),
|
"-t",
|
f"{chunk.audio_duration:.6f}",
|
"-ac",
|
"1",
|
"-ar",
|
"16000",
|
"-c:a",
|
"flac",
|
str(chunk_path),
|
]
|
result = runner(command)
|
if result.returncode != 0:
|
raise MediaTranscriptionError(
|
f"FFmpeg 提取转写分块 {chunk.index}/{chunk.total} 失败:"
|
f"{_error_detail(result)}"
|
)
|
if not chunk_path.is_file() or chunk_path.stat().st_size == 0:
|
raise MediaTranscriptionError(
|
f"FFmpeg 未生成有效的转写分块 {chunk.index}/{chunk.total}。"
|
)
|
|
|
def _load_cuda_model() -> tuple[Any, RuntimeInfo]:
|
try:
|
import ctranslate2
|
from faster_whisper import WhisperModel
|
except ImportError as exc:
|
raise MediaTranscriptionError(
|
"缺少 faster-whisper/CTranslate2,请在项目隔离 Conda 环境中运行。"
|
) from exc
|
|
try:
|
device_count = int(ctranslate2.get_cuda_device_count())
|
compute_types = tuple(
|
sorted(str(item) for item in ctranslate2.get_supported_compute_types(DEVICE))
|
)
|
except Exception as exc:
|
raise MediaTranscriptionError(
|
f"CUDA GPU 自检失败;不会降级到 CPU。原始错误:{exc}"
|
) from exc
|
|
if device_count < 1:
|
raise MediaTranscriptionError("未检测到可用 CUDA GPU;不会降级到 CPU。")
|
if COMPUTE_TYPE not in compute_types:
|
raise MediaTranscriptionError(
|
"当前 CUDA GPU 不支持 float16;不会降级到 CPU。"
|
f"检测到的计算类型:{', '.join(compute_types) or '无'}"
|
)
|
|
try:
|
model = WhisperModel(
|
MODEL_NAME,
|
device=DEVICE,
|
compute_type=COMPUTE_TYPE,
|
)
|
except Exception as exc:
|
raise MediaTranscriptionError(
|
"large-v3 模型加载失败;请检查模型缓存/网络以及 CUDA 12、cuBLAS、"
|
f"cuDNN 9。不会降级到 CPU。原始错误:{exc}"
|
) from exc
|
|
return model, RuntimeInfo(device_count, compute_types)
|
|
|
def _collect_transcript(
|
model: Any,
|
audio_path: Path,
|
language: str | None,
|
) -> TranscriptResult:
|
options: dict[str, Any] = {"vad_filter": True}
|
if language:
|
options["language"] = language
|
|
try:
|
segment_iterator, info = model.transcribe(str(audio_path), **options)
|
segments = tuple(_normalize_segments(segment_iterator))
|
except Exception as exc:
|
raise MediaTranscriptionError(
|
"GPU 语音转写失败;请检查 CUDA/cuDNN/显存和输入音频。"
|
f"不会降级到 CPU。原始错误:{exc}"
|
) from exc
|
|
detected_language = getattr(info, "language", None)
|
probability = getattr(info, "language_probability", None)
|
if probability is not None:
|
probability = float(probability)
|
return TranscriptResult(detected_language, probability, segments)
|
|
|
def _normalize_segments(segments: Iterable[Any]) -> Iterable[TranscriptSegment]:
|
for segment in segments:
|
text = str(segment.text).strip()
|
if not text:
|
continue
|
start = max(0.0, float(segment.start))
|
end = max(start, float(segment.end))
|
yield TranscriptSegment(start=start, end=end, text=text)
|
|
|
def _project_chunk_segments(
|
segments: Iterable[TranscriptSegment],
|
chunk: AudioChunk,
|
audio_duration: float,
|
) -> tuple[TranscriptSegment, ...]:
|
projected: list[TranscriptSegment] = []
|
for segment in segments:
|
start = min(audio_duration, max(0.0, chunk.audio_start + segment.start))
|
end = min(audio_duration, max(start, chunk.audio_start + segment.end))
|
midpoint = (start + end) / 2.0
|
belongs = chunk.core_start <= midpoint < chunk.core_end
|
if chunk.index == chunk.total:
|
belongs = chunk.core_start <= midpoint <= chunk.core_end
|
if belongs:
|
projected.append(TranscriptSegment(start=start, end=end, text=segment.text))
|
return tuple(projected)
|
|
|
def _collect_chunked_transcript(
|
model: Any,
|
full_audio: Path,
|
audio_duration: float,
|
language: str | None,
|
ffmpeg: str,
|
runner: ProcessRunner,
|
) -> TranscriptResult:
|
chunks = _plan_audio_chunks(audio_duration)
|
if not chunks:
|
raise MediaTranscriptionError("内部错误:短音频不应进入分块转写路径。")
|
|
chunk_path = full_audio.parent / ".transcribe-chunk.flac"
|
active_language = language
|
result_language = language
|
language_probability: float | None = None
|
projected: list[TranscriptSegment] = []
|
|
for chunk in chunks:
|
print(f"正在转写分块 {chunk.index}/{chunk.total}", flush=True)
|
try:
|
_extract_audio_chunk(full_audio, chunk_path, chunk, ffmpeg, runner)
|
chunk_result = _collect_transcript(model, chunk_path, active_language)
|
if chunk.index == 1:
|
result_language = chunk_result.language or language
|
language_probability = chunk_result.language_probability
|
if language is None:
|
if not chunk_result.language:
|
raise MediaTranscriptionError(
|
"首个音频块未返回检测语言,无法为后续块固定语言。"
|
)
|
active_language = chunk_result.language
|
projected.extend(
|
_project_chunk_segments(chunk_result.segments, chunk, audio_duration)
|
)
|
finally:
|
chunk_path.unlink(missing_ok=True)
|
|
projected.sort(key=lambda item: (item.start, item.end, item.text))
|
return TranscriptResult(
|
language=result_language,
|
language_probability=language_probability,
|
segments=tuple(projected),
|
)
|
|
|
def _format_txt_timestamp(seconds: float) -> str:
|
total_seconds = max(0, int(seconds))
|
hours, remainder = divmod(total_seconds, 3600)
|
minutes, secs = divmod(remainder, 60)
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
|
def _format_srt_timestamp(seconds: float) -> str:
|
total_milliseconds = max(0, int(round(seconds * 1000)))
|
hours, remainder = divmod(total_milliseconds, 3_600_000)
|
minutes, remainder = divmod(remainder, 60_000)
|
secs, milliseconds = divmod(remainder, 1000)
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
|
|
|
def _write_transcript_files(
|
video: Path,
|
staged: OutputPaths,
|
result: TranscriptResult,
|
) -> None:
|
text_content = "\n\n".join(
|
f"[{_format_txt_timestamp(segment.start)}] {segment.text}"
|
for segment in result.segments
|
)
|
if text_content:
|
text_content += "\n"
|
staged.text.write_text(text_content, encoding="utf-8")
|
|
srt_blocks = []
|
for index, segment in enumerate(result.segments, start=1):
|
srt_blocks.append(
|
"\n".join(
|
[
|
str(index),
|
f"{_format_srt_timestamp(segment.start)} --> "
|
f"{_format_srt_timestamp(segment.end)}",
|
segment.text,
|
]
|
)
|
)
|
srt_content = "\n\n".join(srt_blocks)
|
if srt_content:
|
srt_content += "\n"
|
staged.srt.write_text(srt_content, encoding="utf-8")
|
|
payload = {
|
"source": video.name,
|
"audio": staged.audio.name,
|
"language": result.language,
|
"language_probability": result.language_probability,
|
"model": MODEL_NAME,
|
"device": DEVICE,
|
"compute_type": COMPUTE_TYPE,
|
"vad_filter": True,
|
"segments": [
|
{
|
"start": segment.start,
|
"end": segment.end,
|
"text": segment.text,
|
}
|
for segment in result.segments
|
],
|
}
|
staged.json.write_text(
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
encoding="utf-8",
|
)
|
|
|
def _staged_paths(staging_dir: Path, final: OutputPaths) -> OutputPaths:
|
return OutputPaths(
|
directory=staging_dir,
|
audio=staging_dir / final.audio.name,
|
text=staging_dir / final.text.name,
|
srt=staging_dir / final.srt.name,
|
json=staging_dir / final.json.name,
|
)
|
|
|
def _commit_outputs(staged: OutputPaths, final: OutputPaths) -> None:
|
_ensure_output_available(final)
|
created_directory = False
|
moved: list[Path] = []
|
try:
|
if not final.directory.exists():
|
final.directory.mkdir()
|
created_directory = True
|
for source, destination in zip(staged.files, final.files, strict=True):
|
try:
|
source.replace(destination)
|
except BaseException:
|
if destination.exists() and not source.exists():
|
moved.append(destination)
|
raise
|
else:
|
moved.append(destination)
|
except BaseException as exc:
|
rollback_failures: list[str] = []
|
for path in reversed(moved):
|
try:
|
path.unlink(missing_ok=True)
|
except BaseException as cleanup_exc:
|
rollback_failures.append(f"{path}: {cleanup_exc}")
|
if created_directory:
|
try:
|
final.directory.rmdir()
|
except BaseException as cleanup_exc:
|
rollback_failures.append(f"{final.directory}: {cleanup_exc}")
|
|
if not isinstance(exc, Exception):
|
if rollback_failures:
|
try:
|
setattr(
|
exc,
|
"_media_commit_rollback_failures",
|
tuple(rollback_failures),
|
)
|
except Exception:
|
pass
|
raise
|
|
rollback_detail = ""
|
if rollback_failures:
|
rollback_detail = ";回滚不完整:" + ";".join(rollback_failures)
|
raise MediaTranscriptionError(
|
f"提交输出文件失败,已回滚已写结果:{exc}{rollback_detail}"
|
) from exc
|
|
|
def transcribe_video(
|
video_path: str | Path,
|
output_dir: str | Path | None = None,
|
language: str | None = None,
|
*,
|
runner: ProcessRunner | None = None,
|
model_loader: ModelLoader | None = None,
|
) -> tuple[OutputPaths, RuntimeInfo]:
|
runner = runner or _run_process
|
model_loader = model_loader or _load_cuda_model
|
language = language.strip().lower() if language else None
|
|
video = _resolve_video(video_path)
|
outputs = _build_output_paths(video, output_dir)
|
_ensure_output_available(outputs)
|
ffprobe = _require_executable("ffprobe")
|
ffmpeg = _require_executable("ffmpeg")
|
_probe_first_audio_stream(video, ffprobe, runner)
|
|
outputs.directory.parent.mkdir(parents=True, exist_ok=True)
|
staging_dir = Path(
|
tempfile.mkdtemp(
|
prefix=f".{video.stem}.transcribe-",
|
dir=str(outputs.directory.parent),
|
)
|
)
|
staged = _staged_paths(staging_dir, outputs)
|
|
try:
|
print("正在提取第一条音轨(16 kHz、单声道 FLAC)……")
|
_extract_first_audio_stream(video, staged.audio, ffmpeg, runner)
|
audio_duration = _probe_audio_duration(staged.audio, ffprobe, runner)
|
print("正在加载 large-v3(CUDA、float16)……")
|
model, runtime = model_loader()
|
print("正在使用 GPU 和 VAD 转写……")
|
if audio_duration <= SHORT_AUDIO_LIMIT_SECONDS:
|
transcript = _collect_transcript(model, staged.audio, language)
|
else:
|
transcript = _collect_chunked_transcript(
|
model,
|
staged.audio,
|
audio_duration,
|
language,
|
ffmpeg,
|
runner,
|
)
|
_write_transcript_files(video, staged, transcript)
|
_commit_outputs(staged, outputs)
|
finally:
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
return outputs, runtime
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
parser = argparse.ArgumentParser(
|
description="用 FFmpeg 和 faster-whisper GPU 转写单个本地视频。"
|
)
|
parser.add_argument("video", help="本地视频文件路径")
|
parser.add_argument("--output", help="可选输出目录;默认在视频旁创建 <文件名>.transcript")
|
parser.add_argument("--language", help="可选语言代码,例如 zh;默认自动检测")
|
return parser
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
args = _build_parser().parse_args(argv)
|
try:
|
outputs, runtime = transcribe_video(args.video, args.output, args.language)
|
except MediaTranscriptionError as exc:
|
print(f"错误:{exc}", file=sys.stderr)
|
return 1
|
except KeyboardInterrupt as exc:
|
rollback_failures = getattr(exc, "_media_commit_rollback_failures", ())
|
if rollback_failures:
|
detail = "\n".join(f"- {item}" for item in rollback_failures)
|
print(
|
"错误:用户中断;正式输出回滚不完整,请人工检查以下路径:\n"
|
f"{detail}",
|
file=sys.stderr,
|
)
|
else:
|
print("错误:用户中断,未提交输出文件。", file=sys.stderr)
|
return 130
|
|
print(f"完成:{outputs.directory}")
|
print(
|
"GPU 自检:"
|
f"CUDA 设备数={runtime.cuda_device_count},"
|
f"计算类型={','.join(runtime.cuda_compute_types)}"
|
)
|
return 0
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|