Ubuntu 20下用Python将视频/音频转文字的脚本,主要使用 whisper(OpenAI开源)或 faster-whisper(更快更省资源)。

纯 CPU 方案(无显卡)

使用 int8 量化,CPU也能流畅运行

python3 transcribe.py audio.mp3 --model base --device cpu --compute int8

模型目录结构要求

./faster-whisper-base/ 目录下需要包含这些文件:

./faster-whisper-base/
├── model.bin          (模型权重,约145MB for base)
├── config.json        (模型配置)
├── tokenizer.json     (分词器)
└── vocabulary.txt     (词表)

faster-whisper 支持的模型及其特点:

模型 参数量 磁盘大小 速度 准确率 适用场景 显存需求
tiny 39M ~75MB 最快 一般 实时字幕、快速草稿、资源极有限 ~1GB
tiny.en 39M ~75MB 最快 一般(英文优化) 仅英文实时场景 ~1GB
base 74M ~150MB 很快 够用 日常使用、播客、会议记录 ~1GB
base.en 74M ~150MB 很快 够用(英文优化) 仅英文日常场景 ~1GB
small 244M ~480MB 较好 平衡选择,推荐大多数场景 ~2GB
small.en 244M ~480MB 较好(英文优化) 仅英文高质量场景 ~2GB
medium 769M ~1.5GB 中等 对准确率要求高 ~5GB
medium.en 769M ~1.5GB 中等 好(英文优化) 仅英文高精度场景 ~5GB
large-v1 1.5B ~3GB 很好 专业转录、多语言 ~10GB
large-v2 1.5B ~3GB 较慢 很好 推荐的大模型版本 ~10GB
large-v3 1.5B ~3GB 较慢 最好 最新版,多语言最强 ~10GB
large-v3-turbo 1.5B ~3GB 接近large-v3 速度与准确率的最佳平衡 ~6GB

关键说明

.en 后缀模型

  • 仅针对英语优化,其他语言效果差
  • 如果你只处理中文内容,不要用 .en 模型

速度对比(参考值,CPU int8)

模型 1小时音频处理时间
tiny ~5分钟
base ~10分钟
small ~20分钟
medium ~1小时
large-v3 ~2-3小时

选择建议

你的场景 推荐模型
快速测试/草稿 tiny
日常中文视频/播客 basesmall
会议记录(要准确) smallmedium
专业字幕/翻译 mediumlarge-v3
多语言混合内容 large-v3
追求速度+准确率平衡 large-v3-turbo

你的 CPU 场景建议

# 日常够用,速度快
python3 transcribe.py audio.mp3 --model base --device cpu --compute int8

# 想要更好效果(推荐)
python3 transcribe.py audio.mp3 --model small --device cpu --compute int8

# 如果内存够(8G+),追求准确
python3 transcribe.py audio.mp3 --model medium --device cpu --compute int8

large-v3 在纯 CPU 上会很慢,除非内容特别重要,否则不建议。

如何使用HF-Mirror🌟

本站域名 hf-mirror.com,用于镜像 huggingface.co 域名。作为一个公益项目,致力于帮助国内AI开发者快速、稳定的下载模型、数据集。捐赠支持请看网页左下角,感谢支持!

更多详细用法请看《这篇教程》

方法一:网页下载

在本站搜索,并在模型主页的Files and Version中下载文件。

方法二:huggingface-cli

huggingface-cli 是 Hugging Face 官方提供的命令行工具,自带完善的下载功能。使用方法如下:

1. 安装依赖

pip install -U huggingface_hubCopy

2. 设置环境变量
Linux

export HF_ENDPOINT=https://hf-mirror.comCopy

Windows Powershell

$env:HF_ENDPOINT = "https://hf-mirror.com"Copy

建议将上面这一行写入 ~/.bashrc
3.1 下载模型

huggingface-cli download --resume-download gpt2 --local-dir gpt2Copy

3.2 下载数据集

huggingface-cli download --repo-type dataset --resume-download wikitext --local-dir wikitextCopy

可以添加 --local-dir-use-symlinks False 参数禁用文件软链接,这样下载路径下所见即所得,详细解释请见上面提到的教程。

方法三:使用 hfd

hfd 是本站开发的 huggingface 专用下载工具,基于成熟工具 aria2,可以做到稳定高速下载不断线。

1. 下载hfd

wget https://hf-mirror.com/hfd/hfd.sh
chmod a+x hfd.shCopy

2. 设置环境变量
Linux

export HF_ENDPOINT=https://hf-mirror.comCopy

Windows Powershell

$env:HF_ENDPOINT = "https://hf-mirror.com"Copy

3.1 下载模型

./hfd.sh gpt2Copy

3.2 下载数据集

./hfd.sh wikitext --datasetCopy

方法四:使用环境变量(非侵入式)

非侵入式,能解决大部分情况。huggingface 工具链会获取HF_ENDPOINT环境变量来确定下载文件所用的网址,所以可以使用通过设置变量来解决。

HF_ENDPOINT=https://hf-mirror.com python your_script.pyCopy

不过有些数据集有内置的下载脚本,那就需要手动改一下脚本内的地址来实现了。

常见问题

Q: 有些项目需要登录,如何下载?

A:部分 Gated Repo 需登录申请许可。为保障账号安全,本站不支持登录,需先前往 Hugging Face 官网登录、申请许可,在官网这里获取 Access Token 后回镜像站用命令行下载。
部分工具下载 Gated Repo 的方法:

huggingface-cli: 添加--token参数

huggingface-cli download --token hf_*** --resume-download meta-llama/Llama-2-7b-hf --local-dir Llama-2-7b-hfCopy

hfd: 添加--hf_username``--hf_token参数

hfd meta-llama/Llama-2-7b --hf_username YOUR_HF_USERNAME --hf_token hf_***Copy

其余如from_pretrainedwgetcurl如何设置认证 token,详见上面第一段提到的教程。

transcribe.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视频/音频转文字脚本 (Ubuntu 20)
使用 faster-whisper,支持 GPU/CPU
"""

import os
import sys
import argparse
import subprocess
import json
from pathlib import Path
from datetime import timedelta

# 尝试导入 faster-whisper,失败则提示安装
try:
    from faster_whisper import WhisperModel
except ImportError:
    print("错误:未安装 faster-whisper")
    print("请运行: pip install faster-whisper")
    sys.exit(1)


def format_timestamp(seconds: float) -> str:
    """将秒数转换为 SRT 时间格式 HH:MM:SS,mmm"""
    td = timedelta(seconds=seconds)
    total_seconds = int(td.total_seconds())
    hours = total_seconds // 3600
    minutes = (total_seconds % 3600) // 60
    secs = total_seconds % 60
    millis = int((seconds - int(seconds)) * 1000)
    return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"


def extract_audio(video_path: str, output_audio: str = None) -> str:
    """
    从视频文件中提取音频(使用 ffmpeg)
    """
    if output_audio is None:
        output_audio = video_path.rsplit('.', 1)[0] + '.wav'

    # 检查是否已有音频文件
    if os.path.exists(output_audio):
        print(f"音频文件已存在: {output_audio}")
        return output_audio

    print(f"正在从视频提取音频: {video_path}")

    cmd = [
        'ffmpeg', '-y', '-i', video_path,
        '-vn',                    # 不处理视频
        '-acodec', 'pcm_s16le',   # 16位PCM编码
        '-ar', '16000',           # 采样率16kHz(whisper推荐)
        '-ac', '1',               # 单声道
        output_audio
    ]

    try:
        result = subprocess.run(
            cmd, 
            capture_output=True, 
            text=True,
            check=True
        )
        print(f"音频提取完成: {output_audio}")
        return output_audio

    except subprocess.CalledProcessError as e:
        print(f"ffmpeg 提取音频失败: {e.stderr}")
        sys.exit(1)
    except FileNotFoundError:
        print("错误:未找到 ffmpeg,请先安装: sudo apt install ffmpeg")
        sys.exit(1)


def transcribe(
    audio_path: str,
    model_size: str = "base",
    device: str = "auto",
    compute_type: str = "default",
    language: str = "zh",
    output_format: str = "txt",
    output_path: str = None,
    vad_filter: bool = True
) -> list:
    """
    使用 faster-whisper 进行语音识别

    参数:
        audio_path: 音频文件路径
        model_size: 模型大小 (tiny/base/small/medium/large/large-v2/large-v3)
        device: 计算设备 (cuda/cpu/auto)
        compute_type: 计算精度 (float16/int8/int8_float16/float32)
        language: 语言代码 (zh/en/ja/...)
        output_format: 输出格式 (txt/srt/json)
        output_path: 输出文件路径
        vad_filter: 是否启用语音活动检测(过滤静音)
    """

    # 自动选择设备
    if device == "auto":
        import torch
        device = "cuda" if torch.cuda.is_available() else "cpu"
        print(f"自动选择设备: {device}")

    # 根据设备调整计算类型
    if compute_type == "default":
        compute_type = "float16" if device == "cuda" else "int8"

    print(f"加载模型: {model_size} (设备: {device}, 精度: {compute_type})")

    # 加载模型(优先使用 ./faster-whisper-base 目录下的本地模型)
    model_dir = os.environ.get("WHISPER_MODEL_DIR", "./faster-whisper-base")
    print(f"模型目录: {os.path.abspath(model_dir)}")

    # 加载模型(优先使用 ./faster-whisper-base 目录下的本地模型)
    if os.path.exists(model_dir):
        print("使用本地模型...")
        # 直接传模型目录路径给第一个参数
        model = WhisperModel(model_dir, device=device, compute_type=compute_type)
    else:
        print("本地模型不存在,尝试下载...")
        model = WhisperModel(model_size, device=device, compute_type=compute_type)

    print(f"开始识别: {audio_path}")
    print(f"语言: {language}, VAD过滤: {vad_filter}")

    # 执行识别
    segments, info = model.transcribe(
        audio_path,
        language=language,
        vad_filter=vad_filter,
        vad_parameters=dict(min_silence_duration_ms=500),
        condition_on_previous_text=True
    )

    print(f"检测到语言: {info.language} (概率: {info.language_probability:.2f})")
    print(f"预计时长: {info.duration:.2f} 秒")

    # 收集结果
    results = []
    for i, segment in enumerate(segments, 1):
        result = {
            "id": i,
            "start": segment.start,
            "end": segment.end,
            "text": segment.text.strip(),
            "start_formatted": format_timestamp(segment.start),
            "end_formatted": format_timestamp(segment.end),
        }
        results.append(result)

        # 实时打印
        print(f"[{result['start_formatted']} --> {result['end_formatted']}] {result['text']}")

    # 保存结果
    if output_path is None:
        output_path = audio_path.rsplit('.', 1)[0] + f'.{output_format}'

    save_results(results, output_path, output_format)
    print(f"\n结果已保存: {output_path}")

    return results


def save_results(results: list, output_path: str, format_type: str):
    """保存识别结果到文件"""

    if format_type == "txt":
        # 纯文本格式
        with open(output_path, 'w', encoding='utf-8') as f:
            for r in results:
                f.write(r["text"] + "\n")

    elif format_type == "srt":
        # SRT 字幕格式
        with open(output_path, 'w', encoding='utf-8') as f:
            for r in results:
                f.write(f"{r['id']}\n")
                f.write(f"{r['start_formatted']} --> {r['end_formatted']}\n")
                f.write(f"{r['text']}\n\n")

    elif format_type == "json":
        # JSON 格式
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(results, f, ensure_ascii=False, indent=2)

    elif format_type == "vtt":
        # WebVTT 格式
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write("WEBVTT\n\n")
            for r in results:
                # VTT 使用点号分隔毫秒
                start = r['start_formatted'].replace(',', '.')
                end = r['end_formatted'].replace(',', '.')
                f.write(f"{start} --> {end}\n")
                f.write(f"{r['text']}\n\n")


def batch_process(
    input_paths: list,
    model_size: str = "base",
    **kwargs
):
    """批量处理多个文件"""
    for path in input_paths:
        print(f"\n{'='*50}")
        print(f"处理文件: {path}")
        print(f"{'='*50}")

        ext = Path(path).suffix.lower()

        # 如果是视频,先提取音频
        if ext in ['.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv', '.webm']:
            audio_path = extract_audio(path)
        else:
            audio_path = path

        # 转文字
        transcribe(audio_path, model_size=model_size, **kwargs)


def main():
    parser = argparse.ArgumentParser(
        description='视频/音频转文字工具 (基于 faster-whisper)',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 识别单个音频文件
  python3 transcribe.py audio.mp3

  # 识别视频并生成字幕
  python3 transcribe.py video.mp4 -f srt -l zh

  # 使用大模型提高准确率(需要更多显存)
  python3 transcribe.py audio.mp3 --model large-v3 --device cuda

  # 批量处理
  python3 transcribe.py *.mp3 --model small

  # 输出JSON(带时间戳)
  python3 transcribe.py audio.mp3 -f json

模型大小说明:
  tiny    - 39M  参数, 最快, 准确率一般
  base    - 74M  参数, 较快, 日常够用
  small   - 244M 参数, 平衡选择
  medium  - 769M 参数, 较准, 较慢
  large-v3 - 1.5B 参数, 最准, 最慢, 需8G+显存
        """
    )

    parser.add_argument('input', nargs='+', help='输入文件路径(支持视频/音频)')
    parser.add_argument('-m', '--model', default='base', 
                       choices=['tiny', 'base', 'small', 'medium', 'large', 'large-v2', 'large-v3'],
                       help='模型大小 (默认: base)')
    parser.add_argument('-d', '--device', default='auto',
                       choices=['cuda', 'cpu', 'auto'],
                       help='计算设备 (默认: auto)')
    parser.add_argument('-c', '--compute', default='default',
                       choices=['float16', 'float32', 'int8', 'int8_float16', 'default'],
                       help='计算精度 (默认: 自动)')
    parser.add_argument('-l', '--language', default='zh',
                       help='语言代码,如 zh, en, ja (默认: zh)')
    parser.add_argument('-f', '--format', default='txt',
                       choices=['txt', 'srt', 'json', 'vtt'],
                       help='输出格式 (默认: txt)')
    parser.add_argument('-o', '--output', default=None,
                       help='输出文件路径 (默认: 同输入文件名)')
    parser.add_argument('--no-vad', action='store_true',
                       help='禁用语音活动检测')

    args = parser.parse_args()

    # 批量处理
    batch_process(
        args.input,
        model_size=args.model,
        device=args.device,
        compute_type=args.compute,
        language=args.language,
        output_format=args.format,
        output_path=args.output,
        vad_filter=not args.no_vad
    )


if __name__ == "__main__":
    main()

文档转声音

推荐

场景 推荐方案
快速测试、简单需求 edge-tts
要离线、不联网 pyttsx3
高质量、自然语音 ChatTTS(有GPU)或 edge-tts

最推荐 edge-tts,免费、中文效果好、直接出 MP3:

pip install edge-tts

edge-tts --voice zh-CN-XiaoxiaoNeural --text "你好世界" --write-media output.mp3

方案1:pyttsx3(离线,最简单)

pip install pyttsx3
import pyttsx3

engine = pyttsx3.init()
engine.setProperty('rate', 150)      # 语速
engine.setProperty('volume', 0.9)    # 音量

# 保存为 MP3(实际输出的是 wav,需要 ffmpeg 转换)
engine.save_to_file("你好,这是测试语音", "output.wav")
engine.runAndWait()

# 用 ffmpeg 转 MP3
import subprocess
subprocess.run(['ffmpeg', '-y', '-i', 'output.wav', '-b:a', '192k', 'output.mp3'])

缺点:声音机械,中文支持一般。


方案2:edge-tts(免费在线,微软 Edge 语音,推荐)

pip install edge-tts
import asyncio
import edge_tts

async def text_to_speech(text, output_file, voice="zh-CN-XiaoxiaoNeural"):
    communicate = edge_tts.Communicate(text, voice)
    await communicate.save(output_file)

# 使用
asyncio.run(text_to_speech("你好,这是微软 Edge 语音合成的测试", "output.mp3"))

优点:

  • 免费,调用微软 Edge 在线 TTS
  • 中文语音自然(晓晓、云希等)
  • 直接输出 MP3

可用语音列表:

edge-tts --list-voices | grep zh-CN

常用中文语音:

语音名称 特点
zh-CN-XiaoxiaoNeural 晓晓,女声,温柔
zh-CN-YunxiNeural 云希,男声,年轻
zh-CN-YunjianNeural 云健,男声,新闻播报
zh-CN-XiaoyiNeural 小艺,女声,活泼

方案3:ChatTTS(开源,最自然,需 GPU)

bash复制

pip install ChatTTS
import ChatTTS
import torch
import torchaudio

chat = ChatTTS.Chat()
chat.load(compile=False)  # CPU 用 False

texts = ["你好,这是 ChatTTS 生成的自然语音。"]
wavs = chat.infer(texts)

torchaudio.save("output.mp3", torch.from_numpy(wavs[0]), 24000)

优点:中文语音非常自然,带情感。 缺点:需要 4G+ 显存,第一次下载模型较大。


方案4:gTTS(Google TTS,简单)

pip install gtts
from gtts import gTTS

tts = gTTS("你好,这是 Google 语音合成", lang='zh')
tts.save("output.mp3")

缺点:需要翻墙,中文语音一般。

txt2mp3.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
txt 文档转 MP3 语音脚本
使用 edge-tts(微软 Edge 在线语音合成)

依赖安装:
    pip install edge-tts

用法:
    python3 txt2mp3.py input.txt
    python3 txt2mp3.py input.txt -o output.mp3
    python3 txt2mp3.py input.txt -v zh-CN-YunxiNeural -r 1.2
# 基本用法
python3 txt2mp3.py article.txt

# 指定输出文件名
python3 txt2mp3.py article.txt -o book.mp3

# 换男声(云希)
python3 txt2mp3.py article.txt -v yunxi

# 加快语速
python3 txt2mp3.py article.txt -v xiaoxiao -r +20%

# 减慢语速 + 增大音量
python3 txt2mp3.py article.txt -r -10% -vol +30%

# 查看可用语音列表
python3 txt2mp3.py --list-voices
"""

import os
import sys
import argparse
import asyncio
import edge_tts


# 常用语音列表
VOICES = {
    # === 中文 ===
    "xiaoxiao": "zh-CN-XiaoxiaoNeural",      # 女声,温柔
    "xiaoyi": "zh-CN-XiaoyiNeural",          # 女声,活泼
    "yunxi": "zh-CN-YunxiNeural",            # 男声,年轻
    "yunjian": "zh-CN-YunjianNeural",        # 男声,新闻播报
    "xiaochen": "zh-CN-XiaochenNeural",      # 女声,成熟
    "xiaohan": "zh-CN-XiaohanNeural",        # 女声,柔和
    "xiaomeng": "zh-CN-XiaomengNeural",      # 女声,甜美
    "xiaomo": "zh-CN-XiaomoNeural",          # 男声,磁性
    "xiaorui": "zh-CN-XiaoruiNeural",        # 男声,稳重
    "xiaoxuan": "zh-CN-XiaoxuanNeural",      # 女声,知性
    "yunyang": "zh-CN-YunyangNeural",        # 男声,新闻
    "yunxia": "zh-CN-YunxiaNeural",          # 男声,可爱
    # === 英语-美国 ===
    "jenny": "en-US-JennyNeural",            # 女声,标准美音
    "guy": "en-US-GuyNeural",                # 男声,标准美音
    "aria": "en-US-AriaNeural",              # 女声,自然
    "ana": "en-US-AnaNeural",                # 女声,温柔
    "christopher": "en-US-ChristopherNeural", # 男声,稳重
    "eric": "en-US-EricNeural",              # 男声,年轻
    "michelle": "en-US-MichelleNeural",      # 女声,知性
    "roger": "en-US-RogerNeural",            # 男声,成熟
    "steffan": "en-US-SteffanNeural",        # 男声,阳光
    # === 英语-英国 ===
    "sonia": "en-GB-SoniaNeural",            # 女声,英音
    "ryan": "en-GB-RyanNeural",              # 男声,英音
    "libby": "en-GB-LibbyNeural",            # 女声,英音
    "thomas": "en-GB-ThomasNeural",          # 男声,英音
    "maisie": "en-GB-MaisieNeural",          # 女声,年轻英音
    # === 英语-其他 ===
    "natasha": "en-AU-NatashaNeural",        # 女声,澳音
    "william": "en-AU-WilliamNeural",        # 男声,澳音
    "clara": "en-CA-ClaraNeural",            # 女声,加音
    "liam": "en-CA-LiamNeural",              # 男声,加音
}


def get_voice(voice_key: str) -> str:
    """获取完整 voice ID"""
    voice_key = voice_key.lower()
    if voice_key in VOICES:
        return VOICES[voice_key]
    # 如果用户直接传了完整 ID,直接返回
    if "Neural" in voice_key:
        return voice_key
    # 默认
    return VOICES["xiaoxiao"]


def split_text(text: str, max_chars: int = 3000) -> list:
    """
    将长文本按段落分割,每段不超过 max_chars 字符
    edge-tts 单次有长度限制,建议分段处理
    """
    paragraphs = text.split("\n")
    chunks = []
    current_chunk = ""

    for para in paragraphs:
        para = para.strip()
        if not para:
            continue

        # 如果当前段落加上已有内容超过限制,先保存当前块
        if len(current_chunk) + len(para) > max_chars and current_chunk:
            chunks.append(current_chunk.strip())
            current_chunk = para
        else:
            current_chunk += "\n" + para if current_chunk else para

    if current_chunk:
        chunks.append(current_chunk.strip())

    return chunks if chunks else [text]


async def text_to_speech(text: str, output_file: str, voice: str, rate: str = "+0%", volume: str = "+0%"):
    """将文字转换为语音并保存"""
    communicate = edge_tts.Communicate(text, voice, rate=rate, volume=volume)
    await communicate.save(output_file)


async def convert_file(input_path: str, output_path: str, voice: str, rate: str, volume: str):
    """转换单个文件"""
    # 读取 txt 文件
    print(f"读取文件: {input_path}")
    with open(input_path, "r", encoding="utf-8") as f:
        text = f.read()

    if not text.strip():
        print("错误: 文件内容为空")
        return False

    total_chars = len(text)
    print(f"总字数: {total_chars}")
    print(f"语音: {voice}")
    print(f"语速: {rate}")

    # 分段处理
    chunks = split_text(text)
    print(f"分 {len(chunks)} 段处理...")

    if len(chunks) == 1:
        # 单段直接转换
        print("转换中...")
        await text_to_speech(chunks[0], output_path, voice, rate, volume)
    else:
        # 多段分别转换后合并
        temp_files = []
        for i, chunk in enumerate(chunks, 1):
            temp_file = f"{output_path}.part{i}.mp3"
            temp_files.append(temp_file)
            print(f"  处理第 {i}/{len(chunks)} 段 ({len(chunk)} 字)...")
            await text_to_speech(chunk, temp_file, voice, rate, volume)

        # 合并音频文件(使用 ffmpeg)
        print("合并音频片段...")
        merge_audio_files(temp_files, output_path)

        # 清理临时文件
        for f in temp_files:
            os.remove(f)

    print(f"\n✅ 完成: {output_path}")
    file_size = os.path.getsize(output_path) / 1024
    print(f"文件大小: {file_size:.1f} KB")
    return True


def merge_audio_files(input_files: list, output_file: str):
    """使用 ffmpeg 合并多个 MP3 文件"""
    # 创建 concat list 文件
    list_file = output_file + ".list.txt"
    with open(list_file, "w", encoding="utf-8") as f:
        for path in input_files:
            # ffmpeg concat 需要转义单引号
            escaped = path.replace("\'", "\'\\\'\'")
            f.write(f"file \'{escaped}\'\n")

    cmd = [
        "ffmpeg", "-y", "-f", "concat", "-safe", "0",
        "-i", list_file,
        "-acodec", "copy",
        output_file
    ]

    import subprocess
    result = subprocess.run(cmd, capture_output=True, text=True)
    os.remove(list_file)

    if result.returncode != 0:
        print(f"ffmpeg 合并警告: {result.stderr}")
        # 备用方案:重新编码合并
        filter_str = ""
        inputs = []
        for i, path in enumerate(input_files):
            inputs.extend(["-i", path])
            filter_str += f"[{i}:a:0]"
        filter_str += f"concat=n={len(input_files)}:v=0:a=1[outa]"

        cmd2 = ["ffmpeg", "-y"] + inputs + ["-filter_complex", filter_str, "-map", "[outa]", "-b:a", "192k", output_file]
        subprocess.run(cmd2, check=True)


def list_voices():
    """列出可用语音"""
    print("常用语音列表:")
    print("=" * 60)

    print("\n【中文】")
    print("-" * 60)
    for key, voice_id in VOICES.items():
        if voice_id.startswith("zh-CN"):
            gender = "男" if "Yun" in voice_id else "女"
            print(f"  {key:12s} -> {voice_id} ({gender})")

    print("\n【英语-美国】")
    print("-" * 60)
    for key, voice_id in VOICES.items():
        if voice_id.startswith("en-US"):
            gender = "男" if "Guy" in voice_id or "Christopher" in voice_id or "Eric" in voice_id or "Roger" in voice_id or "Steffan" in voice_id else "女"
            print(f"  {key:12s} -> {voice_id} ({gender})")

    print("\n【英语-英国】")
    print("-" * 60)
    for key, voice_id in VOICES.items():
        if voice_id.startswith("en-GB"):
            gender = "男" if "Ryan" in voice_id or "Thomas" in voice_id else "女"
            print(f"  {key:12s} -> {voice_id} ({gender})")

    print("\n【英语-其他】")
    print("-" * 60)
    for key, voice_id in VOICES.items():
        if voice_id.startswith("en-") and not voice_id.startswith("en-US") and not voice_id.startswith("en-GB"):
            gender = "男" if "William" in voice_id or "Liam" in voice_id else "女"
            print(f"  {key:12s} -> {voice_id} ({gender})")

    print("=" * 60)
    print("\n查看全部语音:")
    print("  edge-tts --list-voices | grep en-US   # 美式英语")
    print("  edge-tts --list-voices | grep en-GB   # 英式英语")
    print("  edge-tts --list-voices | grep zh-CN   # 中文")


def main():
    parser = argparse.ArgumentParser(
        description="txt 文档转 MP3 语音",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  python3 txt2mp3.py article.txt
  python3 txt2mp3.py article.txt -o book.mp3
  python3 txt2mp3.py article.txt -v yunxi -r +10%
  python3 txt2mp3.py article.txt -v xiaoxiao -r -10% -vol +20%

语速 rate:
  +0%   默认速度
  +50%  快50%
  -20%  慢20%

音量 volume:
  +0%   默认音量
  +50%  大50%
  -20%  小20%
        """
    )

    parser.add_argument("input", nargs="?", help="输入 txt 文件路径")
    parser.add_argument("-o", "--output", default=None, help="输出 MP3 文件路径 (默认: 同输入文件名)")
    parser.add_argument("-v", "--voice", default="xiaoxiao", help="语音名称 (默认: xiaoxiao)")
    parser.add_argument("-r", "--rate", default="+0%", help="语速调整,如 +10%, -20% (默认: +0%)")
    parser.add_argument("-vol", "--volume", default="+0%", help="音量调整,如 +20%, -10% (默认: +0%)")
    parser.add_argument("--list-voices", action="store_true", help="列出可用语音")

    args = parser.parse_args()

    if args.list_voices:
        list_voices()
        return

    if not args.input:
        parser.print_help()
        return

    input_path = args.input
    if not os.path.exists(input_path):
        print(f"错误: 文件不存在: {input_path}")
        sys.exit(1)

    # 确定输出路径
    if args.output:
        output_path = args.output
    else:
        output_path = os.path.splitext(input_path)[0] + ".mp3"

    # 获取语音 ID
    voice = get_voice(args.voice)

    # 执行转换
    try:
        asyncio.run(convert_file(input_path, output_path, voice, args.rate, args.volume))
    except KeyboardInterrupt:
        print("\n用户取消")
    except Exception as e:
        print(f"\n错误: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()