Pyannote与Whisper实战:搭建带说话人分离的语音转文字系统

发布时间:2026/10/7 8:42:57
Pyannote与Whisper实战:搭建带说话人分离的语音转文字系统 很多人第一次看到“Pyannote Whisper 实现带说话人分离的语音转文字”这类方案第一反应都是觉得门槛很高一边是音频AI模型一边是语音识别大模型光听名字就劝退。但实际动手做下来这套链路并没有想象中复杂真正的时间消耗其实集中在环境配置和模型下载上核心代码反而是最短的那部分。这篇文章就基于我实际跑通的完整流程把从零到一搭建这套系统的每一步拆开讲清楚包括Pyannote说话人分离的授权坑、Whisper的本地部署方式、针对中文识别后处理的一些调整以及最终如何把两条模型产出的结果拼装成一段带发言角色的完整文字稿。1. 先把目标拆清楚这套系统最终交付的是什么开始动手之前我建议你先想明白一个问题你究竟需要“带说话人分离的语音转文字”做到什么程度因为Pyannote负责的部分和Whisper负责的部分其实是两条相对独立的流水线最后需要靠胶水代码把它们的结果粘起来。把预期先讲清楚后面选方案、写代码才不会跑偏。1.1 核心流水线的分工逻辑这套方案的整体架构是一条典型的“先分离、后转写”串联链路Pyannote确切地说是pyannote.audio中的SpeakerDiarizationpipeline负责从一段音频里认出“谁在什么时候说话”输出的是时间段、说话人标签SPEAKER_00、SPEAKER_01这类时间戳信息。WhisperOpenAI 开源的语音识别模型负责把连续的音频切分成短句并转成文字输出的是带时间戳的文本片段segment。胶水代码把 Whisper 识别出的每一个文本片段的时间区间去跟 Pyannote 划分出的说话人时间段做对齐命中哪个说话人的区间就给这段文字打上对应的说话人名字。所以最终交付的形态是一段结构化的文字记录类似这样SPEAKER_00 [00:00:00.000 - 00:00:05.320]大家好今天我们来聊一下项目排期的问题。 SPEAKER_01 [00:00:05.400 - 00:00:12.680]好的我先把目前的开发进度同步一下。 SPEAKER_00 [00:00:12.760 - 00:00:20.040]可以你直接说重点就行。这套逻辑听起来很顺但实际拼接时有两个非常容易踩坑的细节一是两份时间戳的精度基准是否一致二是 Whisper 的 segment 可能正好横跨两个说话人的分割边界。这两点我在后面代码部分会专门做处理。1.2 为什么选用 Pyannote 而不是其他说话人分离方案市面上做说话人分离的工具有不少比如阿里开源的FunASR里也带了说话人分离能力SpeechBrain也提供了类似模型。我最终选择 Pyannote主要看重三点模型能力领先pyannote/speaker-diarization-3.1在常见的会议、电话、采访场景下分离准确率表现非常稳定尤其是在中文对话场景下只要音频质量不是特别差基本能正确区分出两个或多个人。调用方式足够“傻瓜”官方封装成了pipeline输入音频路径直接输出带时间戳的说话人片段不需要自己写 VAD语音活动检测和聚类逻辑。开源生态成熟它在 Hugging Face 上有完整的模型权重和文档代码层面的改动成本低。当然Pyannote 也有它的弱点比如首次使用需要在 Hugging Face 上申请模型访问权限且推理时需要联网下载模型权重。不过这两个问题都可以提前一次性解决后面我会详细讲。2. 环境准备和模型授权这里才是“5分钟”里真正的大头标题说“5分钟搞定”实际上如果从零开始配环境、下载模型通常要不止5分钟。但要是按“核心代码 5 分钟跑通”的标准来要求前面这些准备工作必须提前做好。这里我把完整的准备链路写出来照着做一次之后就能复用。2.1 创建 Python 环境和安装依赖我的建议是使用 Python 3.10 或 3.11这两个版本对 PyTorch 和 Whisper 的兼容性最好。我个人在 3.9 和 3.12 上都踩过微妙的问题3.9 是某些新版依赖不再支持3.12 是部分编译包找不到预编译 wheel最后还是老老实实回到 3.10。# 建议用 conda 或 venv 创建独立环境避免污染全局 Python python -m venv /path/to/venv/whisper_diarization source /path/to/venv/whisper_diarization/bin/activate # 升级打包工具 pip install --upgrade pip setuptools wheel # 安装 PyTorch带 CUDA 版本。如果只有 CPU把下面这行换掉 pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118 # 安装 pyannote.audio pip install pyannote.audio # 安装 Whisper / faster-whisper pip install faster-whisper注意我装的是faster-whisper而不是原版openai-whisper。为什么两个原因faster-whisper基于 CTranslate2 实现推理速度比原版快 4 倍以上显存占用也更低在 CPU 上跑 small、medium 这类模型体验差距非常明显。它的时间戳输出格式和原版接近后续做对齐处理几乎不需要改代码。如果你还是想用原版openai-whisper命令也简单直接pip install openai-whisper就行但后续代码里WhisperModel的导入路径要相应调整。2.2 申请 Pyannote 模型访问权限最容易卡住的环节Pyannote 的说话人分离模型不是“下载即用”它托管在 Hugging Face 上并且需要你同意模型卡片上的使用条款才会给你开通下载权限。这一步很多人会漏掉结果一跑代码就在网络请求阶段报 401 或者 gated 错误。需要在 Hugging Face 上做两件事注册账号并生成 Access Token登录后在 Settings → Access Tokens 里新建一个 token权限至少要有read。接受模型使用协议打开https://huggingface.co/pyannote/speaker-diarization-3.1打开https://huggingface.co/pyannote/segmentation-3.1在两个模型页面里分别点击 “Agree and access repository”。很多教程只提了第一个模型链接但实际上两个都要点同意因为说话人分离 pipeline 内部依赖 segmentation 模型。我之前第一次跑的时候只授权了主模型结果卡在下载 segmentation 权重那一步报错日志里的401一度让我以为是自己 token 配错了排查了半天才发现是第二个模型没点授权。然后把 token 配置到环境变量里export HF_TOKENhf_xxxxxxx代码里也可以直接读这个环境变量import os from pyannote.audio import Pipeline pipeline Pipeline.from_pretrained( pyannote/speaker-diarization-3.1, use_auth_tokenos.environ.get(HF_TOKEN), )2.3 提前把模型权重下载到本地避免每次运行都卡下载Hugging Face 的模型权重文件普遍不小Pyannote 的 diarization 模型加 segmentation 模型合计约 300MB 左右如果网络不稳定每次运行时才下载既浪费时间也容易中断。我的做法是提前用huggingface-cli把它们下到本地缓存目录里后续直接离线加载。pip install -U huggingface_hub[cli] huggingface-cli download pyannote/speaker-diarization-3.1 --local-dir ./models/pyannote/speaker-diarization-3.1 huggingface-cli download pyannote/segmentation-3.1 --local-dir ./models/pyannote/segmentation-3.1这样在执行Pipeline.from_pretrained时如果检测到本地已经存在快照就不会再触发在线下载。Whisper 模型也一样faster-whisper支持传入本地目录或提前下载模型。如果你希望完全离线使用可以用ct2的方式把原始模型转成 CTranslate2 格式或者直接从 Hugging Face 下载对应量化的faster-whisper模型放到本地目录。这里推荐后者省事。2.4 音频预处理减少噪声和静音段的干扰Pyannote 的分离效果受噪声影响挺明显的。尤其是电话录音、会议录音这类场景如果有大量环境音分词器和聚类的结果会出现说话人片段频繁跳变的情况。我养成的一个习惯是在把音频扔给 Pyannote 之前先用ffmpeg做一次轻量预处理ffmpeg -i input_audio.mp3 -ac 1 -ar 16000 -vn processed_audio.wav-ac 1强制转成单声道因为 Pyannote 对立体声的处理优势不大反而会增加计算量。-ar 16000Whisper 官方训练数据是 16kHz 采样音频重采样成 16kHz 后识别精度最稳定。-vn如果原文件是视频去掉视频流。如果音频里静音段太多可以再用silero-vad之类的工具做静音压缩把长静音去掉。这一步不是必须的但对提升 Pyannote 的分离效果有帮助。3. 核心实现说话人分离与语音转文字的对接细节主体代码其实不长难在几处对接细节的处理。我直接把完整代码贴出来然后逐段解释关键逻辑。3.1 完整代码可直接复制使用import os import numpy as np from pyannote.audio import Pipeline from faster_whisper import WhisperModel # 1. 初始化两个模型 def init_models(): # Pyannote 说话人分离 pipeline diarization_pipeline Pipeline.from_pretrained( pyannote/speaker-diarization-3.1, use_auth_tokenos.environ.get(HF_TOKEN), ) # Whisper 语音识别模型这里用 large-v3 的 int8 量化版 # 如果显存不够可以换成 small 或 medium asr_model WhisperModel( large-v3, devicecuda if os.environ.get(USE_CUDA, 1) 1 else cpu, compute_typeint8, # CPU 下更推荐 int8GPU 下可以用 float16 ) return diarization_pipeline, asr_model # 2. 说话人分离得到时间段和说话人标签 def perform_diarization(diarization_pipeline, audio_path): diarization_result diarization_pipeline(audio_path) speaker_segments [] # 每个元素: (start, end, speaker_label) for turn, _, speaker in diarization_result.itertracks(yield_labelTrue): speaker_segments.append({ start: turn.start, end: turn.end, speaker: speaker, }) return speaker_segments # 3. Whisper 转写得到文字片段和时间戳 def transcribe_with_whisper(asr_model, audio_path): segments, info asr_model.transcribe( audio_path, beam_size5, languagezh, # 中文场景固定语言如果不固定这里可以设为 None vad_filterTrue, # 用内置 VAD 过滤静音段能让时间戳更准 ) text_segments [] for seg in segments: text_segments.append({ start: seg.start, end: seg.end, text: seg.text.strip(), }) return text_segments # 4. 对齐把文字片段分配给说话人 def align_segments_to_speakers(speaker_segments, text_segments): # 思路对于每一条 Whisper 识别的文本片段 # 计算它与哪些说话人时间段重叠取重叠比例最大的那个说话人。 results [] for text_seg in text_segments: text_start text_seg[start] text_end text_seg[end] text_duration text_end - text_start best_speaker UNKNOWN best_overlap 0.0 for spk_seg in speaker_segments: overlap_start max(text_start, spk_seg[start]) overlap_end min(text_end, spk_seg[end]) if overlap_end overlap_start: continue overlap_duration overlap_end - overlap_start overlap_ratio overlap_duration / text_duration if overlap_ratio best_overlap: best_overlap overlap_ratio best_speaker spk_seg[speaker] results.append({ start: text_start, end: text_end, speaker: best_speaker, text: text_seg[text], }) return results def format_timestamp(seconds: float) - str: hours int(seconds // 3600) minutes int((seconds % 3600) // 60) secs int(seconds % 60) millis int((seconds - int(seconds)) * 1000) return f[{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}] def main(audio_path, output_pathNone): print([1/4] 初始化模型...) diarization_pipeline, asr_model init_models() print([2/4] 正在执行说话人分离...) speaker_segments perform_diarization(diarization_pipeline, audio_path) print(f 识别到 {len(set(s[speaker] for s in speaker_segments))} 个说话人) print([3/4] 正在执行语音转写...) text_segments transcribe_with_whisper(asr_model, audio_path) print([4/4] 正在对齐说话人与文本...) final_results align_segments_to_speakers(speaker_segments, text_segments) output_lines [] for res in final_results: line f{res[speaker]} {format_timestamp(res[start])} - {format_timestamp(res[end])}{res[text]} print(line) output_lines.append(line) if output_path: with open(output_path, w, encodingutf-8) as f: f.write(\n.join(output_lines)) print(f结果已保存至: {output_path}) if __name__ __main__: main(processed_audio.wav, output_pathresult.txt)3.2 关键代码解读为什么对齐逻辑这么写上面这段代码里最值得解释的是align_segments_to_speakers这个函数。Whisper 返回的每个 segment 是“一段识别出来的话”它的时间范围可能落在 Pyannote 划分的某个说话人时间段内部也可能跨越边界。我采用“重叠比例最大化”的策略计算该文本片段和每个说话人片段的交集占文本片段总时长的比例取最大比例对应的说话人。举例来说一条文本片段是[00:00:10.000 - 00:00:15.000]总共 5 秒。说话人 A 在这个区间内覆盖了前 4 秒说话人 B 覆盖了后 1 秒那么这个片段大概率是 A 说的因为 A 的占比是 80%。这种启发式方法在大多数场景下都足够准确而且代码简单不需要引入复杂的对齐算法。但要注意一个边界情况如果一段话正好横跨两人交接处比如 A 说了个开头B 顺势接了后半句Whisper 却把它们识别成了同一段。这时候无论分配哪个说话人都不完美。我在实际使用中会通过调整min_speakers和max_speakers参数来控制 Pyannote 的分段粒度尽可能减少这种跨说话人的长文本片段出现。后面会专门讲这个参数调整。3.3 关于文本后处理中文场景的一些小优化Whisper 在中文上识别效果不错但输出里有时会带一些格式问题比如数字、英文混排导致的空格问题。标点符号偶尔缺失。语气词“嗯”“啊”识别成文字后显得很啰嗦。我通常在生成最终文本前会做一个轻量清洗函数import re def clean_text(text: str) - str: # 去掉多余空格 text re.sub(r\s, , text) # 去掉常见的冗余语气词按需调整 text re.sub(r^(嗯|啊|呃|那个|就是), , text) return text.strip(。、 )这个处理放在transcribe_with_whisper的判断逻辑里对seg.text做一次清洗再保存。注意不要过度清理比如“那个”在正式对话里可能是有语义的我这里的正则只是去掉“出现在句子开头”的语气词后续你完全可以根据自己业务场景调整规则。4. 实测效果与参数调优心得代码写完只是开始真正的考验是怎么把效果调到能用的水平。我拿一段约 8 分钟、两人中文对话的会议录音做了测试下面把原始参数下的输出效果和调优后的结果对比一下。4.1 默认参数下的表现默认情况下Pyannote 的SpeakerDiarizationpipeline 会自动估计说话人数量。首次不加任何参数跑出来的结果说话人数识别为 3 个但实际上只有 2 人在对话。有一处明显问题两个人的语音交叉重叠时被切分成了第三个临时说话人。Whisper 识别文字本身很准但时间戳片段较长个别片段横跨了说话人交接点导致说话人归属出错。这个现象说明默认参数不适合对“说话人数已知”的场景。解决办法是给 pipeline 传入说话人上下限diarization_result diarization_pipeline( audio_path, min_speakers2, max_speakers2, )加了这两个参数后Pyannote 会把聚类结果强制约束在 2 个人内上述问题大幅缓解。但这里我要提醒一句min_speakers和max_speakers是“强约束”如果音频里实际有 3 个人但你设成了 2模型会把其中两个人强行合并导致文字张冠李戴。所以这个参数只适合你事先知道说话人上限的场景。如果不知道可以先跑一遍默认版本看识别出几个人再回头调整。4.2 Whisper 模型大小与速度的权衡faster-whisper提供了从tiny到large-v3多个尺寸的模型很多人一上来就上large-v3但会发现在 CPU 上跑个 1 小时音频慢得离谱。我实测的一组大致数据CPUIntel i7-1270016GB 内存int8 量化模型参数量CPU 相对速度中文识别效果tiny39M约 30x 实时较差很多专有名词错base74M约 20x 实时能听出大意细节不行small244M约 10x 实时日常对话尚可medium769M约 4x 实时明显变好专有名词偶有错large-v31550M约 1.5x 实时整体最稳但速度最慢如果机器没有独立显卡我建议日常使用medium如果追求效果且能接受等待large-v3值得等。连续说“5分钟搞定”的场景通常指配置好之后处理一段几分钟的音频确实只需要几分钟。GPU 环境下compute_type可以调整。NVIDIA 显卡建议用float16速度和显存占用都比int8更有优势CPU 上只能用int8或int8_float32否则内存占用会直线上升。4.3 一个容易被忽略的问题多说话人重叠时的文字归属真实对话里抢话、打断、重叠很常见。Whisper 在重叠语音上的识别效果并不好Pyannote 也会把重叠段归属到某个说话人头上。目前没有任何开源模型能完美解决重叠语音的分离和识别所以遇到这种情况我的建议是在最终输出里给重叠段打上“重叠”或“同时说话”的标记而不是强行归属某个人。如果业务要求非常严格考虑用录音硬件的多轨道方案每人一个麦克风从源头上避免重叠问题。这属于一个比较深的话题对大多数人来说现有的“快速方案”已经足够应付会议记录、采访整理这类场景了。5. 为项目封装成服务批量处理与调用接口如果只是自己偶尔转写几段音频跑上面的脚本就够了。但很多场景下这套能力是要给别人用的或者要批量处理大量音频那么建议封装成一个小服务或命令行工具。5.1 批量处理音频目录最简单的方式是遍历目录里的音频文件逐个调用主流程import glob import os audio_dir ./audios output_dir ./outputs os.makedirs(output_dir, exist_okTrue) for audio_path in glob.glob(os.path.join(audio_dir, *.wav)): basename os.path.splitext(os.path.basename(audio_path))[0] output_path os.path.join(output_dir, f{basename}.txt) main(audio_path, output_pathoutput_path)如果音频量很大需要注意两个模型的加载开销。最理想的做法是把两个模型的初始化放在循环外面只加载一次然后在循环里反复调用。上面main函数里每次都调用init_models()的方式适合单文件流程批量场景记得把初始化挪出去。5.2 提供 HTTP 接口用FastAPI包一个上传音频、返回转写结果的接口也很简单from fastapi import FastAPI, UploadFile import tempfile, os app FastAPI() app.post(/transcribe) async def transcribe(file: UploadFile): suffix os.path.splitext(file.filename)[-1] with tempfile.NamedTemporaryFile(suffixsuffix, deleteFalse) as tmp: tmp.write(await file.read()) tmp_path tmp.name # 这里可以并行初始化模型并通过 lru_cache 或全局变量复用 result main(tmp_path) os.unlink(tmp_path) return {result: result}需要考虑的是接口形态下模型的加载时机。最好在服务启动时预先加载模型而不是等到第一个请求才加载否则第一次请求的耗时会非常长。5.3 定时任务与长音频分段超过 30 分钟的音频直接丢给 Pyannote 和 Whisper 处理内存和显存压力都很大。我通常的做法是先把长音频按 10~15 分钟切成多个片段逐个处理后再拼接结果。切分工具可以用ffmpeg或者pydubfrom pydub import AudioSegment audio AudioSegment.from_wav(long_audio.wav) chunk_length_ms 15 * 60 * 1000 for i, chunk in enumerate(audio[::chunk_length_ms]): chunk.export(fchunk_{i}.wav, formatwav)拼接结果时注意给每个片段的时间戳加上偏移量这样最终输出的时间还是以原始长音频为准。6. 部署和工程化的时候一定要记住这几个坑这部分算是我整理的“防坑指南”。前面每节零散提了一点这里集中汇总都是实用主义的经验。6.1 音频格式和编码问题不同来源的音频文件编码五花八门有些.mp3的码率低到离谱Whisper 对低码率音频的识别效果会明显下降。我的原则是所有进入流水线的音频必须统一转成 16kHz 单声道 WAV。ffmpeg一条命令就解决千万别偷懒。而且 Pyannote 内部对采样率有自己的处理但如果原始音频是 44.1kHzPyannote 会做重采样这个过程中如果样本点对齐不好可能会在时间戳上引入几十毫秒的误差。虽然几十毫秒对说话人归属的影响不大但统一成 16kHz 后多个模型的输入基准一致对齐逻辑更可靠。6.2 时间戳偏移所有变量必须在同一时间基准上在“先分离、后转写”的架构里两个模型的输入都应该是同一个音频文件。如果中间你做了静音压缩、降速或者裁剪两份时间戳就对不上了。实战中我踩过的坑音频文件开头有 5 秒静音我用 VAD 过滤后再同时喂给 Pyannote 和 Whisper结果两个模型对“静音段”的行为不一样导致最终说话人和文字完全错位。最好的做法是先统一处理好最终用于分析的音频版本保存下来再同时把它作为两个模型的输入。要裁剪就都裁剪要过滤就都过滤保证时间基准一致。6.3 Pyannote 的文档说要 GPU但 CPU 也能跑Pyannote 官方推荐 GPU 运行但 CPU 上跑短音频也没问题。我测试过一段 5 分钟的音频CPU 推理大约十几秒完全可接受。所以如果没有 GPU不用被官方文档吓退先把流程跑通再说。只是要注意CPU 模式下 Pyannote 的音频加载逻辑如果有并发调用可能会吃满内存。大批量任务建议限速或者用单线程串行。6.4 关于use_auth_token的替代方案新版huggingface_hub里Pipeline.from_pretrained的use_auth_token参数可能会提示废弃推荐使用token参数。不同版本的 pyannote.audio 对这个参数的支持不太一样如果你在跑的时候遇到unexpected keyword argument的报错就把use_auth_token...改成token...或者在环境变量里配好HF_TOKEN后直接不传这个参数。6.5 并发调用同一个 pipeline 实例的安全性问题Pyannote 的 pipeline 对象并不是完全线程安全的。如果在 FastAPI 服务里用全局 pipeline 处理并发请求偶尔会出现结果混乱。稳妥的做法是为每个请求创建独立的 pipeline 实例但实例化很慢所以实际工程里会用一个连接池或者干脆用进程锁限制并发。另一个思路是把模型服务和业务服务分开部署比如用独立进程跑模型通过消息队列接收音频路径返回结果路径。这样即使模型服务崩了业务服务也能正常运行。7. 后续还可以怎么扩展到这里一套“Pyannote Whisper 带说话人分离的语音转文字”的基础能力已经完整落地了。根据我个人的使用经验后续还有几个非常值得扩展的方向结合大模型做会议摘要把转写结果发给 LLM让它按“待办事项”“决策结论”“风险点”等维度生成结构化会议纪要。自定义说话人名字映射如果知道说话人身份可以在对齐结果后把人名替换进去比如SPEAKER_00替换成“张三”。实时流式识别Pyannote 和 Whisper 都能做流式版本但工程复杂度会明显上升适合对“实时性”有硬性要求的场景。我强烈建议你把代码里align_segments_to_speakers的逻辑再打磨一下比如引入“文本语义相似度”作为辅助判断依据利用每句话的上下文来判断说话人归属能进一步降低跨说话人片段的错配概率。不过这属于进阶优化了基础版本先用重叠比例法已经完全够入门和大多数业务场景使用了。如果你也正在折腾类似的语音转写链路欢迎在实际运行后对照这篇文章逐项检查。模型选型也好时间戳对齐也好每一步坐下来其实都有它的“原理”与“取舍”。把基础打扎实了后面无论换模型还是换场景都不会再被环境问题劝退。