audio2sub — 音频转字幕工具

约 3 分钟阅读 阅读

基于 OpenAI Whisper 的命令行工具,将音频文件批量转写为 VTT / SRT 格式字幕。


环境要求

依赖说明
Python≥ 3.8
PyTorchWhisper 的运行时依赖,自动安装
openai-whisper语音识别引擎
ffmpeg音频解码,系统级安装

安装步骤

1. 安装 ffmpeg

  • macOS:
brew install ffmpeg
  • Ubuntu / Debian:
sudo apt update && sudo apt install ffmpeg

2. 安装 openai-whisper

pip install openai-whisper

该命令会自动拉取 torch 等依赖。首次运行时 Whisper 模型文件会下载到 ~/.cache/whisper/

⚠️ macOS 环境注意事项

使用系统 Python 或 miniconda 安装 whisper:

# miniconda(推荐,已预装 torch)
/opt/miniconda/bin/pip install openai-whisper

# 或系统 Python
/usr/bin/python3 -m pip install openai-whisper

脚本文件

编写文件:audio2sub.py

#!/usr/bin/env python3
"""
audio2sub.py - 使用 OpenAI Whisper 将音频文件转写为字幕文件(VTT / SRT)

用法:
    python audio2sub.py <audio_path> [options]

参数:
    audio_path          音频文件或目录路径(必填)
                        若为目录,递归遍历其中所有音频文件并逐个生成字幕
    --model MODEL       Whisper 模型名称,默认 base
                        可选: tiny, base, small, medium, large, large-v2, large-v3
    --language LANG     音频语言,默认 en(英语)
                        例: zh(中文), ja(日语), auto(自动检测)
    --format FMT        字幕格式,vtt 或 srt(默认: vtt)
    --device DEVICE     计算设备,cpu / mps / auto(默认: auto,macOS Apple Silicon 优先 MPS)
    --recursive / --no-recursive
                        递归扫描子目录(默认: 递归)
    --skip-existing     跳过已有对应字幕文件的音频(避免重复转写)
    --output OUTPUT     输出文件路径(仅单文件模式有效;目录模式忽略此项)

示例:
    python audio2sub.py "Unit 1.mp3"
    python audio2sub.py "Unit 1.mp3" --model small --format srt
    python audio2sub.py "Unit 1.mp3" --model base --language zh
    python audio2sub.py "Unit 1.mp3" --output /tmp/Unit1.vtt
    python audio2sub.py ./SeniorHighSchool/                     # 递归遍历
    python audio2sub.py ./SeniorHighSchool/ --no-recursive      # 仅顶层
    python audio2sub.py ./SeniorHighSchool/ --skip-existing     # 跳过已有字幕
    python audio2sub.py ./words/ --model small --format srt --language auto
    python audio2sub.py "Unit 1.mp3" --device mps               # 强制使用 GPU
    python audio2sub.py "Unit 1.mp3" --device cpu               # 强制使用 CPU
"""

import argparse
import os
import sys

AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".m4a", ".ogg", ".wma", ".aac", ".opus"}


def resolve_device(device: str) -> str:
    """解析计算设备,auto 模式下优先使用 MPS (Apple Silicon GPU)"""
    import torch

    if device == "auto":
        if torch.backends.mps.is_available() and torch.backends.mps.is_built():
            return "mps"
        elif torch.cuda.is_available():
            return "cuda"
        else:
            return "cpu"
    return device


def format_timestamp_vtt(seconds: float) -> str:
    """将秒数转换为 VTT 时间戳格式 HH:MM:SS.mmm"""
    ms = int((seconds % 1) * 1000)
    s = int(seconds) % 60
    m = int(seconds) // 60 % 60
    h = int(seconds) // 3600
    return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"


def format_timestamp_srt(seconds: float) -> str:
    """将秒数转换为 SRT 时间戳格式 HH:MM:SS,mmm(逗号分隔符)"""
    ms = int((seconds % 1) * 1000)
    s = int(seconds) % 60
    m = int(seconds) // 60 % 60
    h = int(seconds) // 3600
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"


def build_subtitle_content(segments: list, fmt: str) -> str:
    """根据格式构建字幕内容"""
    if fmt == "srt":
        lines = []
        for i, seg in enumerate(segments, 1):
            start = format_timestamp_srt(seg["start"])
            end = format_timestamp_srt(seg["end"])
            text = seg["text"].strip()
            lines.append(str(i))
            lines.append(f"{start} --> {end}")
            lines.append(text)
            lines.append("")
    else:  # vtt
        lines = ["WEBVTT", ""]
        for i, seg in enumerate(segments, 1):
            start = format_timestamp_vtt(seg["start"])
            end = format_timestamp_vtt(seg["end"])
            text = seg["text"].strip()
            lines.append(str(i))
            lines.append(f"{start} --> {end}")
            lines.append(text)
            lines.append("")
    return "\n".join(lines)


def get_output_path(audio_path: str, fmt: str, output_arg: str = None) -> str:
    """计算输出文件路径"""
    if output_arg:
        return os.path.abspath(output_arg)
    audio_dir = os.path.dirname(audio_path)
    audio_basename = os.path.splitext(os.path.basename(audio_path))[0]
    ext = ".vtt" if fmt == "vtt" else ".srt"
    return os.path.join(audio_dir, audio_basename + ext)


def collect_audio_files(path: str, recursive: bool = True) -> list:
    """收集目录下的音频文件(按路径排序),支持递归子目录"""
    files = []
    if recursive:
        for root, _dirs, entries in os.walk(path):
            for entry in entries:
                if os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS:
                    files.append(os.path.join(root, entry))
    else:
        for entry in os.listdir(path):
            full = os.path.join(path, entry)
            if os.path.isfile(full) and os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS:
                files.append(full)
    files.sort(key=lambda f: f.lower())
    return files


def transcribe_one(model, audio_path: str, language: str, fmt: str, output_path: str,
                   skip_existing: bool = False) -> bool:
    """转写单个音频文件并保存字幕,返回是否成功"""
    basename = os.path.basename(audio_path)

    # 跳过已有字幕
    if skip_existing and os.path.isfile(output_path):
        print(f"  ⊘ {basename} → 字幕已存在,跳过")
        return True

    try:
        lang_arg = None if language == "auto" else language
        print(f"  转写中: {basename} (language={language}) ...")
        result = model.transcribe(audio_path, language=lang_arg, task="transcribe", verbose=False)

        detected_lang = result.get("language", "unknown")
        segments = result["segments"]
        print(f"  检测语言: {detected_lang},共 {len(segments)} 个片段")

        content = build_subtitle_content(segments, fmt)
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(content)

        size_kb = os.path.getsize(output_path) / 1024
        fmt_label = fmt.upper()
        print(f"  ✓ {basename} → {os.path.basename(output_path)}  ({size_kb:.1f} KB, {len(segments)} 条字幕)")
        return True
    except Exception as e:
        print(f"  ✗ {basename} 转写失败: {e}", file=sys.stderr)
        return False


def run(input_path: str, model_name: str, language: str, fmt: str,
        output_arg: str = None, recursive: bool = True, skip_existing: bool = False,
        device: str = "auto") -> None:
    try:
        import whisper
    except ImportError:
        print("错误: 未找到 openai-whisper,请先安装:pip install openai-whisper", file=sys.stderr)
        sys.exit(1)

    input_path = os.path.abspath(input_path)

    # 收集待处理的音频文件列表
    if os.path.isdir(input_path):
        audio_files = collect_audio_files(input_path, recursive=recursive)
        if not audio_files:
            print(f"目录下未找到音频文件: {input_path}", file=sys.stderr)
            sys.exit(1)
        mode = "dir"
    elif os.path.isfile(input_path):
        audio_files = [input_path]
        mode = "single"
    else:
        print(f"错误: 路径不存在: {input_path}", file=sys.stderr)
        sys.exit(1)

    # 加载模型(统一加载一次,避免重复加载)
    resolved_device = resolve_device(device)
    print(f"加载模型: {model_name} (device={resolved_device}) ...")
    model = whisper.load_model(model_name, device=resolved_device)

    total = len(audio_files)
    success = 0
    fail = 0

    for idx, audio_path in enumerate(audio_files, 1):
        if total > 1:
            rel_path = os.path.relpath(audio_path, input_path) if os.path.isdir(input_path) else os.path.basename(audio_path)
            print(f"\n[{idx}/{total}] {rel_path}")

        out = get_output_path(audio_path, fmt, output_arg if mode == "single" else None)
        ok = transcribe_one(model, audio_path, language, fmt, out, skip_existing=skip_existing)
        if ok:
            success += 1
        else:
            fail += 1

    # 汇总
    if total > 1:
        print(f"\n{'='*40}")
        print(f"完成!成功 {success} 个,失败 {fail} 个,共 {total} 个文件")


def main():
    parser = argparse.ArgumentParser(
        description="将音频文件转写为字幕文件(基于 OpenAI Whisper,支持 VTT / SRT)",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__.split("用法:")[1] if "用法:" in __doc__ else ""
    )
    parser.add_argument("audio_path", help="音频文件或目录路径")
    parser.add_argument(
        "--model", "-m",
        default="base",
        choices=["tiny", "base", "small", "medium", "large", "large-v2", "large-v3"],
        help="Whisper 模型(默认: base)"
    )
    parser.add_argument(
        "--language", "-l",
        default="en",
        help="音频语言代码,如 en、zh、ja,或 auto 自动检测(默认: en)"
    )
    parser.add_argument(
        "--format", "-f",
        default="vtt",
        choices=["vtt", "srt"],
        help="字幕格式(默认: vtt)"
    )
    parser.add_argument(
        "--output", "-o",
        default=None,
        help="输出文件路径(仅单文件模式有效;目录模式忽略此项)"
    )
    parser.add_argument(
        "--device", "-d",
        default="auto",
        choices=["auto", "cpu", "mps", "cuda"],
        help="计算设备(默认: auto,macOS Apple Silicon 优先 MPS GPU 加速)"
    )
    parser.add_argument(
        "--recursive", "-r",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="递归扫描子目录(默认: 递归,使用 --no-recursive 关闭)"
    )
    parser.add_argument(
        "--skip-existing", "-s",
        action="store_true",
        default=False,
        help="跳过已有对应字幕文件的音频"
    )

    args = parser.parse_args()
    run(args.audio_path, args.model, args.language, args.format, args.output,
        recursive=args.recursive, skip_existing=args.skip_existing, device=args.device)


if __name__ == "__main__":
    main()

使用方法

基本语法

python audio2sub.py <audio_path> [options]

参数一览

参数缩写默认值说明
audio_path必填音频文件或目录路径
--model-mbaseWhisper 模型(见下表)
--language-len语言代码,auto 为自动检测
--format-fvtt字幕格式:vttsrt
--device-dauto计算设备:auto / cpu / mps / cuda
--recursive / --no-recursive-r递归目录模式是否扫描子目录
--skip-existing-s关闭跳过已有字幕的音频
--output-o同目录同名输出路径(仅单文件模式)

模型选择

模型参数量英语模型大小多语言模型大小相对速度
tiny39M~39 MB~48 MB★★★★★
base74M~74 MB~142 MB★★★★
small244M~244 MB~466 MB★★★
medium769M~769 MB~1.5 GB★★
large1550M~2.9 GB
large-v21550M~2.9 GB
large-v31550M~2.9 GB

英语单词音频推荐 base,速度和精度均衡;中文语音推荐 small 及以上。

支持的音频格式

.mp3 .wav .flac .m4a .ogg .wma .aac .opus

GPU 加速

脚本支持通过 --device 参数选择计算设备:

设备说明
auto自动选择(默认),优先 MPS > CUDA > CPU
mpsmacOS Apple Silicon GPU(Metal Performance Shaders)
cudaNVIDIA GPU
cpu纯 CPU 计算

macOS Apple Silicon 用户无需额外配置,默认 auto 即可自动启用 MPS GPU 加速:

# 默认 auto,Apple Silicon 自动用 MPS
python audio2sub.py "Unit 1.mp3"

# 显式指定 MPS
python audio2sub.py "Unit 1.mp3" --device mps

# 强制使用 CPU
python audio2sub.py "Unit 1.mp3" --device cpu

前置条件: PyTorch ≥ 1.12 且 macOS ≥ 12.3。可通过以下命令检查:

python3 -c "import torch; print('MPS available:', torch.backends.mps.is_available())"

实测性能对比(base 模型,同一 14MB MP3 文件,Apple M 系列):

设备转写耗时说明
MPS (GPU)~19sGPU 加速,无 FP16 警告
CPU~13sCPU 多核,但有 FP16 回退警告

注:base 模型较小时 CPU 多核并行可能更快;small 及以上模型 GPU 优势明显。


示例

单文件

# 最简用法(默认 base 模型、英语、VTT 格式)
python audio2sub.py "Unit 1.mp3"

# 指定模型和格式
python audio2sub.py "Unit 1.mp3" --model small --format srt

# 中文音频
python audio2sub.py "对话.mp3" --language zh

# 自动检测语言
python audio2sub.py "audio.mp3" --language auto

# 指定输出路径
python audio2sub.py "Unit 1.mp3" -o /tmp/Unit1.vtt

目录批量

# 递归遍历子目录(默认行为)
python audio2sub.py ./SeniorHighSchool/

# 仅扫描顶层目录
python audio2sub.py ./SeniorHighSchool/ --no-recursive

# 跳过已有字幕(断点续传)
python audio2sub.py ./SeniorHighSchool/ --skip-existing

# 完整参数
python audio2sub.py ./SeniorHighSchool/ -m small -f srt -l auto -s

输出示例

加载模型: base ...

[1/295] Compulsory1/texts/Unit 1 Listening and speaking 2.mp3
  转写中: Unit 1 Listening and speaking 2.mp3 (language=en) ...
  检测语言: en,共 174 个片段
  ✓ Unit 1 Listening and speaking 2.mp3 → Unit 1 Listening and speaking 2.vtt  (7.8 KB, 174 条字幕)

[2/295] Compulsory1/texts/Unit 1 Listening and speaking 3.mp3
  转写中: Unit 1 Listening and speaking 3.mp3 (language=en) ...
  ...

========================================
完成!成功 293 个,失败 2 个,共 295 个文件

输出格式说明

VTT(WebVTT)

WEBVTT

1
00:00:00.000 --> 00:00:04.000
Unit 1, precise.

2
00:00:04.000 --> 00:00:06.000
Precise.

SRT(SubRip)

1
00:00:00,000 --> 00:00:04,000
Unit 1, precise.

2
00:00:04,000 --> 00:00:06,000
Precise.

两者差异:VTT 有 WEBVTT 头部,毫秒分隔符用 .;SRT 无头部,毫秒分隔符用 ,


文件输出规则

  • 单文件模式:默认输出到音频同目录,文件名相同 + 格式扩展名(如 Unit 1.mp3Unit 1.vtt
  • 目录模式:每个音频文件在自身所在目录生成对应字幕文件,保持目录结构不变
  • 使用 --output 仅在单文件模式下生效,目录模式忽略此项

常见问题

Q: 运行时报 FP16 is not supported on CPU 警告?

正常,CPU 模式下自动回退到 FP32,不影响结果。使用 --device mps--device auto(默认)可避免此警告并启用 GPU 加速。

Q: 如何确认 GPU 是否生效?

运行时观察输出中的设备信息:

加载模型: base (device=mps) ...    ← GPU 生效
加载模型: base (device=cpu) ...    ← 未使用 GPU

Q: MPS 模式报错怎么办?

部分操作在 MPS 上可能存在兼容性问题,回退到 CPU 即可:

python audio2sub.py "Unit 1.mp3" --device cpu

Q: 如何提升识别精度?

  1. 升级模型:--model small--model medium
  2. 指定正确语言:--language en(明确语言比 auto 检测更稳)
  3. 确保音频质量:低底噪、清晰发音效果更好

Q: 大批量转写中断了怎么办?

使用 --skip-existing 重新运行,已有字幕的音频会自动跳过:

python audio2sub.py ./SeniorHighSchool/ -s

运行环境信息

本工具在以下环境验证通过:

项目
系统macOS ARM64 (Apple Silicon)
Python3.10.9 (miniconda)
PyTorch2.11.0
openai-whisper20250625
ffmpegbrew 安装

相关文章