告别手动转写!我用 FFmpeg + ASR API 搭建了自动音频转写流水线

0 阅读14分钟

告别手动转写!我用 FFmpeg + ASR API 搭建了自动音频转写流水线


image.png

先看效果

from transcribe import TranscribePipeline

pipeline = TranscribePipeline(audio_dir="./recordings", output_dir="./transcripts")
pipeline.run()
# 输入:200个访谈录音 → 输出:200份带时间戳的逐字稿

三行代码,一杯咖啡的时间,几百个音频文件全部转写完毕。

如果你曾经手撸过音频转写的脚本,大概率踩过这些坑:格式混乱、切片出错、API限流、编码乱码……每个坑都能让你多熬两个晚上。这篇文章,我把这些坑全踩了一遍,然后逐个填上,给你一条跑通全链路的方案。 blob:mp.weixin.qq.com/b077989a-74…


痛点:音频转写为什么这么烦

想象一下这个场景:周一早上,产品甩过来200个用户访谈录音,"这周出逐字稿"。你打开文件夹一看——WAV、MP3、FLAC、OGG、M4A,格式比需求还多变。光把格式统一就得耗半天,更别提那些动辄一两个小时的文件,手动切片费时还容易切断句子。

你可能想过用云厂商的全托管方案,一问价格,转写100小时音频够请两个月实习生了。转身去看开源工具,FFmpeg做格式转换、Whisper做识别、再加个VAD做静音检测……拼拼凑凑能跑,但每个环节的衔接和异常处理全靠自己。

还有后处理——标点怎么恢复?说话人怎么区分?这些"最后一公里"的问题,几乎没有文章讲清楚。

这篇文章要做的:从FFmpeg预处理到ASR API调用到结果后处理,给出一条完整链路。代码可复现,踩坑有答案。读完你就能动手搭自己的转写流水线。


方案全景:一条管道,三个环节

先看整体架构,一句话说清链路:

[原始音频][FFmpeg预处理][ASR API调用][结果后处理][结构化输出]
                 ↓                    ↓
           格式统一/切片         并发/限流/重试

三个环节,环环相扣,但各自独立。你可以把FFmpeg换成SoX,把Whisper换成阿里云ASR,把后处理换成自己的NLP管道——每个组件可替换,这才是工程方案该有的样子。

为什么这么选

  • FFmpeg,而不是SoX或AudioLab:生态最全,几乎能解码任何音频格式;命令行原生友好,天然适配自动化脚本;跨平台,Linux/macOS/Windows通吃。做音频预处理,它就是事实标准。
  • API而非本地模型:聚焦落地效率。Whisper本地部署对GPU有要求,很多团队的开发机扛不住;API按量付费,初期成本低,部署门槛几乎为零。文末我会简要提一下本地部署方案,给数据敏感或者量大想省成本的读者一个出口。
  • ASR API选型:Whisper API、阿里云ASR、腾讯云ASR、讯飞ASR——各有适用场景,后面细讲。这里先不展开,因为选型的关键不在"哪个最好",而在"哪个最适合你的场景"。

设计原则

这条流水线遵循三个原则:管道化(数据在环节间单向流动,逻辑清晰)、可中断(断点续传,随时可以停下来再继续)、可扩展(每个环节独立,替换组件不需要改其他环节)。

好,全景看完,接下来逐个拆解。


核心拆解①:FFmpeg预处理——转写质量的基础

很多人上来就调API,结果识别率一塌糊涂,然后怪模型不行。其实大部分时候,问题出在音频本身。FFmpeg预处理不是可选步骤,它是转写质量的基础。

格式统一:消除第一道门槛

ASR模型的训练数据几乎都是WAV 16kHz单声道16bit PCM。你喂一个44.1kHz的立体声MP3进去,它要么报错,要么硬着头皮给你一个质量打折的结果。所以第一步,统一格式

ffmpeg -i input.mp3 -ar 16000 -ac 1 -sample_fmt s16 output.wav

参数解读:-ar 16000 采样率降到16kHz(语音识别不需要更高),-ac 1 单声道(人声不需要立体声),-sample_fmt s16 16bit PCM(标准格式)。一条命令搞定。

但现实情况是,你面对的不是单个文件,而是一整个目录。批量处理脚本长这样:

import subprocess
from pathlib import Path

def convert_to_wav(input_path: Path, output_path: Path):
    """将任意音频格式转为WAV 16kHz mono 16bit"""
    cmd = [
        "ffmpeg", "-i", str(input_path),
        "-ar", "16000", "-ac", "1", "-sample_fmt", "s16",
        "-y",  # 覆盖已有文件
        str(output_path)
    ]
    subprocess.run(cmd, check=True, capture_output=True)

def batch_convert(input_dir: str, output_dir: str):
    """批量转换目录下所有音频文件"""
    audio_exts = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".wma"}
    input_path = Path(input_dir)
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)

    for f in input_path.iterdir():
        if f.suffix.lower() in audio_exts:
            out = output_path / f"{f.stem}.wav"
            convert_to_wav(f, out)
            print(f"✓ {f.name}{out.name}")

batch_convert("./raw_audio", "./wav_output")

脚本做了三件事:遍历目录、过滤音频文件、逐个转换。.stem保留原文件名,-y覆盖已有文件避免重复处理。

音频切片:长文件的分割策略

大部分ASR API对单次请求的音频时长都有限制(Whisper文件接口25MB,阿里云长音频最长5小时但实时接口更短)。超过限制?要么报错,要么截断——截断的结果就是丢失内容。

方案A:按时长切割。 简单粗暴,适合内容连续的会议录音:

# 每5分钟切一段
ffmpeg -i long_meeting.wav -f segment -segment_time 300 -c copy segment_%03d.wav

-segment_time 300即300秒(5分钟),-c copy直接拷贝流不重编码,速度快。缺点:可能在句子中间切断,转写结果出现不完整的句子。

方案B:按静音检测切割。 更精细,适合有对话节奏的场景:

import subprocess, re

def detect_silence(input_file: str, noise_db: str = "-30dB", min_duration: float = 2.0):
    """检测音频中的静音段,返回静音起止时间列表"""
    cmd = [
        "ffmpeg", "-i", input_file,
        "-af", f"silencedetect=noise={noise_db}:d={min_duration}",
        "-f", "null", "-"
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    # 解析输出:silencedetect: silence_start: 12.5 | silence_end: 14.2
    starts = [float(m.group(1)) for m in re.finditer(r"silence_start:\s*([\d.]+)", result.stderr)]
    ends = [float(m.group(1)) for m in re.finditer(r"silence_end:\s*([\d.]+)", result.stderr)]
    return list(zip(starts, ends))

silences = detect_silence("meeting.wav")
print(f"检测到 {len(silences)} 段静音")
# 输出示例:检测到 23 段静音

silencedetect滤波器的原理:检测音量低于阈值(-30dB)且持续超过指定时长(2秒)的片段。这些静音段就是天然的切割点。拿到切割点后,用-ss-t参数逐段提取即可。

两种方案怎么选? 会议录音、讲座等连续内容→方案A更快;播客、访谈等有对话节奏的→方案B更精准,切断句子的概率更小。

其他预处理技巧

音量归一化。 有些录音音量极低(手机远场录制),直接送识别率感人。FFmpeg的loudnorm滤波器能统一响度:

ffmpeg -i quiet.wav -af loudnorm normalized.wav

这一步对低音量录音的识别率提升非常明显,别跳过。

简单降噪。 如果录音环境嘈杂但不算极端,用highpass+lowpass切掉语音频段之外的噪音:

ffmpeg -i noisy.wav -af "highpass=200,lowpass=3000" denoised.wav

200Hz以下和3000Hz以上对中文语音识别贡献极小,切掉反而能减少干扰。如果噪音真的很严重,建议上RNNoise做深度降噪,但那是另一个话题了。

信道分离。 双声道录音,左右声道各是一个说话人?直接分离:

ffmpeg -i stereo.wav -map_channel 0.0.0 left.wav -map_channel 0.0.1 right.wav

两个声道各自独立转写,再按时间戳合并,比混声转写后做说话人分离简单得多。


blob:mp.weixin.qq.com/b077989a-74…

核心拆解②:语音识别API对接——从调通到跑稳

调通一个API调用,10分钟的事。但让它在生产环境稳定跑,才是真正的工程活。

API横向对比与选型

先说结论,再给数据:

维度Whisper API阿里云ASR腾讯云ASR讯飞ASR
语言支持99种中英粤中英粤中英
长音频支持文件接口25MB录音文件识别(最长5h)录音文件识别(最长5h)长语音转写(最长5h)
定价模式$0.006/分钟按时长阶梯按时长阶梯按次数/时长
中文效果优秀优秀优秀优秀
标点恢复
说话人分离✓(部分接口)✓(部分接口)✓(部分接口)
适合场景多语言/国际化国内企业级国内企业级教育/嵌入式

选型建议:国内业务为主→阿里云或腾讯云,中文效果好且合规无忧;有多语言需求→Whisper API,99种语言不是开玩笑的;性价比→看你的调用量级,量大谈商务折扣,量小都差不多。

下面以Whisper API为例演示调用方式。国内API的调用逻辑类似,区别主要在鉴权和接口命名。

基本调用

import openai

client = openai.OpenAI()  # 默认读取 OPENAI_API_KEY 环境变量

def transcribe_file(audio_path: str) -> dict:
    """调用Whisper API转写单个音频文件"""
    with open(audio_path, "rb") as f:
        result = client.audio.transcriptions.create(
            model="whisper-1",
            file=f,
            response_format="verbose_json",  # 返回分段详情
            timestamp_granularities=["segment"]
        )
    return {
        "text": result.text,
        "language": result.language,
        "segments": [
            {"start": s.start, "end": s.end, "text": s.text}
            for s in result.segments
        ]
    }

verbose_json格式返回带时间戳的分段结果,比纯文本有用得多。segments数组里每段包含起止时间和对应文本,后续做时间轴对齐、说话人标注都靠它。

文件上传vs流式接口:Whisper API目前只有文件上传接口,单文件限制25MB(约90分钟16kHz WAV)。如果音频更长,就在预处理阶段切片后再逐段调用。阿里云和腾讯云的长音频接口支持异步转写,上传后轮询结果,适合超长文件。

工程化:让API调用跑稳

单个文件转写跑通了,接下来面对的是批量场景:几百个文件,API有限流,网络会抖动,程序可能被中断。不处理这些问题,你的脚本跑一半挂了,从头再来——浪费额度又浪费时间。

并发控制+限流应对

import openai, json, time
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from tqdm import tqdm

class TranscribeWorker:
    def __init__(self, max_workers: int = 3, max_retries: int = 3):
        self.client = openai.OpenAI()
        self.max_workers = max_workers
        self.max_retries = max_retries
        self.progress_file = Path("./transcribe_progress.json")
        self.progress = self._load_progress()

    def _load_progress(self) -> dict:
        """加载断点续传记录"""
        if self.progress_file.exists():
            return json.loads(self.progress_file.read_text())
        return {}

    def _save_progress(self):
        """保存进度"""
        self.progress_file.write_text(json.dumps(self.progress, ensure_ascii=False, indent=2))

    def transcribe_with_retry(self, audio_path: str) -> dict:
        """带指数退避的转写调用"""
        for attempt in range(self.max_retries):
            try:
                with open(audio_path, "rb") as f:
                    result = self.client.audio.transcriptions.create(
                        model="whisper-1", file=f,
                        response_format="verbose_json",
                        timestamp_granularities=["segment"]
                    )
                return {
                    "text": result.text,
                    "language": result.language,
                    "segments": [
                        {"start": s.start, "end": s.end, "text": s.text}
                        for s in result.segments
                    ]
                }
            except openai.RateLimitError:
                wait = 2 ** attempt  # 指数退避:1s, 2s, 4s
                print(f"  ⚠ 限流,等待 {wait}s 后重试...")
                time.sleep(wait)
            except openai.APIConnectionError:
                time.sleep(5)  # 网络问题固定等5秒
            except openai.BadRequestError as e:
                print(f"  ✗ 格式不支持: {e}")
                return None  # 格式问题不重试
        print(f"  ✗ 重试{self.max_retries}次仍失败: {audio_path}")
        return None

    def process_file(self, audio_path: Path, output_dir: Path):
        """处理单个文件(含断点续传检查)"""
        key = audio_path.name
        if key in self.progress:
            return  # 已处理,跳过

        result = self.transcribe_with_retry(str(audio_path))
        if result:
            out_path = output_dir / f"{audio_path.stem}.json"
            out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2))
            self.progress[key] = "done"
            self._save_progress()

    def run(self, audio_dir: str, output_dir: str):
        """批量转写主入口"""
        audio_exts = {".wav", ".mp3", ".flac", ".m4a"}
        files = [f for f in Path(audio_dir).iterdir() if f.suffix.lower() in audio_exts]
        output_path = Path(output_dir)
        output_path.mkdir(parents=True, exist_ok=True)

        print(f"共 {len(files)} 个文件,已完成 {len(self.progress)} 个,剩余 {len(files) - len(self.progress)} 个")

        with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
            futures = {
                pool.submit(self.process_file, f, output_path): f
                for f in files
            }
            for future in tqdm(as_completed(futures), total=len(futures), desc="转写进度"):
                future.result()  # 触发异常(如有)

worker = TranscribeWorker(max_workers=3)
worker.run("./wav_output", "./transcripts")

这段代码做了五件事:

  1. 并发控制ThreadPoolExecutor限制最多3个并发请求,避免触发API并发限制。你可以根据API文档调整这个数字。
  2. 指数退避:遇到RateLimitError,等待时间按1→2→4秒递增。暴力重试只会让限流更严。
  3. 错误分类:网络问题重试、限流退避、格式错误直接跳过——不同错误不同策略。
  4. 断点续传:已完成的文件记录在transcribe_progress.json里,程序中断后重启会自动跳过。这一步是批量处理的保命符。
  5. 进度可视化tqdm进度条,几百个文件跑起来至少知道还剩多少。

落地经验与避坑指南

上面是"怎么跑起来"的部分。这部分是"跑起来之后你会发现什么问题"的部分。每一条都是真实踩坑,不是纸上谈兵。

编码与格式坑

问题现象解法
BOM头读取文件后API报"encoding error"Python打开文件用encoding='utf-8-sig',自动去掉BOM
采样率不一致识别结果出现大量乱码或重复文字预处理环节必须强制统一为16kHz,别偷懒
VBR可变码率MP3的VBR导致时长计算偏差,切片位置错乱先转WAV再切片,FFmpeg对VBR的时长估算不准
文件名含特殊字符中文/空格文件名在某些系统下报错路径用Path对象处理,避免字符串拼接

识别质量问题

长静音段被识别为重复语句。 这是最常见的"灵异现象"——一段5秒的沉默,模型硬是给你编出几句重复的话。原因是模型对静音段会产生"幻觉"(hallucination)。解法就是在预处理阶段用VAD切掉静音段,别给模型胡说的机会。

专有名词和行业术语。 "Kubernetes"被识别成"kubernetti","FFmpeg"变成"F F mpeg"——模型不背这个锅,它没见过你的领域词。解法有两条路:一是选支持热词/自定义词表的API(阿里云、讯飞都支持),二是在后处理阶段做术语替换——后者土但有效。

标点恢复。 部分API(包括Whisper)自带标点恢复,但质量参差不齐。如果API不提供标点,或者提供的标点效果不好,可以用专门的标点恢复模型补上,比如funasrct-punc模型,轻量且效果不错。

说话人分离:从"说了什么"到"谁说的"

基础转写只能告诉你"说了什么",但会议场景你还需要知道"谁在什么时间说了什么"——这就是说话人分离(Speaker Diarization)。

实现思路:

  1. 用ASR拿到带时间戳的转写结果
  2. 用说话人聚类模型(如pyannote.audio、speechbrain)对音频做分段+聚类
  3. 将ASR的时间戳与聚类结果按时间对齐

这个环节是"加分项",基础方案不必须,但能显著提升结果质量。阿里云ASR的部分接口已经内置了说话人分离能力,能省不少事。如果你用Whisper,就需要自己接pyannote了——具体实现超出本文范围,但核心思路就是"时间对齐"四个字。

成本与性能优化

  • 压缩后上传:部分API支持MP3输入,16kHz MP3比WAV小10倍,传输时间大幅缩短
  • 低精度模型:非正式场景(如会议纪要草稿)可以用whisper-1的小模型,速度更快成本更低
  • 缓存策略:对相同文件做哈希校验,不重复调用API。批量处理中文件重复是常有的事
  • 错峰调用:部分API在非高峰期更稳定、限流更宽松,深夜跑批量任务体验更好

总结:三步走完音频转写

回顾全链路:FFmpeg预处理 → ASR API调用 → 结果后处理,三步完成音频自动转写。

  • 预处理解决"垃圾进垃圾出"的问题——格式统一、静音切片、音量归一化
  • API调用解决"能不能跑稳"的问题——并发控制、限流退避、断点续传
  • 后处理解决"结果好不好用"的问题——标点恢复、说话人分离、术语修正

这个方案的适用边界:中小规模(日处理<1000小时)完全够用。如果日处理量超过这个级别,需要考虑消息队列(RabbitMQ/Kafka)+ 分布式Worker的架构升级,但核心环节不变。

本地部署替代方案:如果你对数据安全有硬性要求(音频不能出域),或者调用量大到API费用不可控,可以看两个方向——faster-whisper(Whisper的CTranslate2优化版,速度快4倍,显存占用减半)和Vosk(轻量离线ASR,支持树莓派级别的设备)。本地部署的预处理和后处理环节与本文完全一致,只是把API调用换成了本地推理。


如果这篇文章对你有帮助,点个「在看」👍,收藏起来下次用。 你在音频转写中遇到过什么坑?评论区聊聊,也许你的踩坑经验正是别人需要的答案。


本文作者:Zoe | 技术落地实践系列