告别手动转写!我用 FFmpeg + ASR API 搭建了自动音频转写流水线
先看效果
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")
这段代码做了五件事:
- 并发控制:
ThreadPoolExecutor限制最多3个并发请求,避免触发API并发限制。你可以根据API文档调整这个数字。 - 指数退避:遇到
RateLimitError,等待时间按1→2→4秒递增。暴力重试只会让限流更严。 - 错误分类:网络问题重试、限流退避、格式错误直接跳过——不同错误不同策略。
- 断点续传:已完成的文件记录在
transcribe_progress.json里,程序中断后重启会自动跳过。这一步是批量处理的保命符。 - 进度可视化:
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不提供标点,或者提供的标点效果不好,可以用专门的标点恢复模型补上,比如funasr的ct-punc模型,轻量且效果不错。
说话人分离:从"说了什么"到"谁说的"
基础转写只能告诉你"说了什么",但会议场景你还需要知道"谁在什么时间说了什么"——这就是说话人分离(Speaker Diarization)。
实现思路:
- 用ASR拿到带时间戳的转写结果
- 用说话人聚类模型(如pyannote.audio、speechbrain)对音频做分段+聚类
- 将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 | 技术落地实践系列