by 雪隐_上班了 from juejin.cn/user/143341... 欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可联系授权。
专栏主旨 :用我那台 RTX 5060 Ti 16G + 64GB 内存 的"丐帮战车",做点不枉费电费的新奇事情。
上一章我们用 MOSS-Transcribe-Diarize 做了会议纪要,端到端一把梭,能跑通。但是------那个"但是"你们懂的------太慢了。36 分钟录音要等 31 分钟,慢到怀疑人生。而且 MOSS 和 gemma 在 16G 显存里谁也容不下谁,只能两班倒。
所以这一章,我换了一条路:用 Whisper Large V3 + pyannote.audio,各干各的,再按时间轴对齐。 效果怎么样?快了很多。有多快?往下看。
背景
我有一个基于 faster-whisper(Large V3)的本地字幕生成工具,能把视频/音频转成带时间戳的 SRT 字幕。最近想给它加一个「会议纪要」功能,而纪要的前提是回答一个问题:这句话是谁说的?
Whisper 本身只做语音转文字,输出的片段没有任何说话人信息。要区分说话人,就需要引入说话人分离(Speaker Diarization)技术。这篇文章记录我用 pyannote.audio 实现这一功能的过程,以及在 Windows 环境下踩到的几个坑------每一个坑都够写一集《今日说法》。
方案选型
主流的说话人分离方案有两个:
- pyannote.audio :业界事实标准,
speaker-diarization-3.1模型效果好,社区活跃。缺点是模型在 HuggingFace 上是受限(gated)的,需要申请授权------得跟 HF 签个"君子协定"。 - FunASR(阿里):从 ModelScope 下载,国内网络友好,中文场景效果不错。缺点是不用魔搭的人得重新学一套 API。
考虑到通用性和识别质量,我最终选了 pyannote.audio。如果你的网络访问 HuggingFace 不方便,FunASR 是更省事的选择。
整体 pipeline 设计如下:
markdown
音视频文件 → ffmpeg 提取 16kHz 单声道 wav
→ faster-whisper 转录(带时间戳的文本片段)
→ pyannote 说话人分离(带时间戳的说话人区间)
→ 按时间重叠对齐,给每个片段打上说话人标签
→ 相邻同说话人片段合并 → 带说话人的转写稿
关键思路:转录和分离是两个独立的模型,各干各的,最后靠时间轴对齐。就像两个侦探分别查案,最后对笔记------一个负责"说了什么",一个负责"谁说的"。这样不需要替换现有的 Whisper 流程,侵入最小。
核心实现
1. 加载分离管线(比下载模型还简单)
python
from pyannote.audio import Pipeline
pipeline = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
token=HF_TOKEN
)
pipeline.to(torch.device("cuda"))
支持传入 num_speakers 指定人数(知道参会人数时能明显提高准确度),不传则自动检测------但自动检测有时候会多送你一两个不存在的"说话人",比如猫叫、门响、空调声。 所以知道人数最好还是传一下。
2. 分离:得到说话人时间区间
python
diarization = pipeline(audio_input, num_speakers=num_speakers)
turns = []
for turn, _, speaker in diarization.itertracks(yield_label=True):
turns.append({
"start": turn.start,
"end": turn.end,
"speaker": speaker # 形如 SPEAKER_00 / SPEAKER_01
})
输出是一系列「谁、从第几秒、说到第几秒」的区间。像剧本一样干净:张三 0-5 秒,李四 5-10 秒,张三 10-15 秒------吵架的时候切换尤其快。
3. 对齐:把 Whisper 片段分给说话人
这是整个功能的核心算法。对每个 Whisper 片段,计算它与所有说话人区间的时间重叠长度,分配给重叠最大的那个说话人;完全没有重叠时(比如 Whisper 的时间戳略有偏差),退而求其次取时间上最近的:
python
for seg in segments:
best_speaker, best_overlap = None, 0.0
for turn in turns:
overlap = min(seg["end"], turn["end"]) - max(seg["start"], turn["start"])
if overlap > best_overlap:
best_overlap = overlap
best_speaker = turn["speaker"]
if best_speaker is None: # 无重叠时取最近者
seg_mid = (seg["start"] + seg["end"]) / 2
best_speaker = min(turns, key=lambda t: min(
abs(seg_mid - t["start"]), abs(seg_mid - t["end"])))["speaker"]
seg["speaker"] = friendly_name(best_speaker)
friendly_name 按说话人首次出现的顺序把 SPEAKER_00 重命名为「说话人1/2/3...」,可读性更好------不然开会纪要用 SPEAKER_00 来指代老板,总觉得哪里不对。
如果有 word 级别的时间戳(faster-whisper 支持 word_timestamps=True),可以做更精细的词级对齐,片段边界处串人的概率会更低------目前的片段级对齐对会议场景已经够用。毕竟开会不会有人每隔 0.5 秒就抢一次话,除非你们公司文化比较特别。
4. 合并:让转写稿像「对话」
Whisper 的片段很碎(一两秒一条),直接标注会满屏都是说话人切换,读起来像精神分裂患者的日记。把相邻、同说话人、间隔小于 2 秒的片段合并成发言块:
python
if seg["speaker"] == current["speaker"] and seg["start"] - current["end"] <= 2.0:
current["end"] = seg["end"]
current["text"] += " " + seg["text"]
最终格式化成这样的转写稿:
csharp
[00:00:02] 说话人1: there you go ... so we got kind of a weird one ...
[00:00:47] 说话人2: yeah the problem is we need more wine ...
[00:00:59] 说话人3: congratulations thank you ...
[00:01:19] 说话人4: you realize you really got me ...
这份转写稿既可以直接阅读,也可以喂给 LLM 生成会议纪要------顺便还能看出谁在会上话最多,谁一直在"嗯""对""好"。
踩坑记录(重点,敲黑板)
坑一:403 Gated Repo,而且不止一个
pyannote 的模型在 HuggingFace 上是受限的,光有 token 不够,还必须在网页上逐个点击"同意使用协议"。报错长这样:
bash
Cannot access gated repo for url https://huggingface.co/pyannote/speaker-diarization-3.1/...
Access to model pyannote/speaker-diarization-3.1 is restricted...
我在代码里对未配置 token 的情况给出了明确指引,但即便配了 token,还是连续撞了三次 403------因为需要的授权不止一个:
pyannote/segmentation-3.0pyannote/speaker-diarization-3.1pyannote/speaker-diarization-community-1← 最容易漏掉的一个
第三个是 pyannote.audio 4.x 内部依赖的模型,报错信息里才会暴露出来。三个页面都点完"同意"之后,世界终于清净了。
教训:gated 模型的依赖链可能藏得很深,授权时把报错里提到的每个仓库都点一遍。 就像填表格,你以为填完了,结果下一页还有三个。
坑二:Windows 上 torchcodec 解码失败
pyannote 4.x 用 torchcodec 解码音频,但在我的 Windows 环境(torch 2.8 + cu128)下 libtorchcodec 的 DLL 死活加载不了:
css
torchcodec is not installed correctly so built-in audio decoding will fail.
* use audio preloaded in-memory as a {'waveform': ..., 'sample_rate': ...} dictionary;
好在报错信息直接给了出路:不喂文件路径,改成喂内存中的波形。 用 soundfile 读音频,soundfile 不支持的格式(部分 m4a/wma)先用 ffmpeg 转一道:
python
import soundfile as sf
data, sample_rate = sf.read(audio_path, dtype='float32')
waveform = torch.from_numpy(data)
if waveform.ndim == 1:
waveform = waveform.unsqueeze(0) # (time,) -> (1, time)
else:
waveform = waveform.T # (time, channel) -> (channel, time)
diarization = pipeline({"waveform": waveform, "sample_rate": sample_rate})
绕开 torchcodec 之后世界清静了。这就像开车上不了高速,那就走国道,反正能到。 还顺带统一了输入格式------不管你是 mp3、m4a、flac、wma,ffmpeg 先归一化再读,pyannote 只管"吃"。
坑三:pyannote 4.x 返回值结构变了
speaker-diarization-3.1 在 pyannote.audio 3.x 里直接返回 Annotation 对象;而 4.x 返回的是一个 DiarizeOutput 数据类,真正的分离结果在它的 speaker_diarization 属性里。直接对 DiarizeOutput 调 itertracks 会报 AttributeError------就像打开快递盒,发现里面还有一个盒子,再打开才是你要的东西。
兼容两个版本的写法:
python
diarization = pipeline(audio_input)
# pyannote.audio >= 4.0 返回 DiarizeOutput,取其中的 Annotation
if hasattr(diarization, "speaker_diarization"):
diarization = diarization.speaker_diarization
顺带一提,4.x 里 Pipeline.from_pretrained 的认证参数也从 use_auth_token 改成了 token。API 变更是程序员永远躲不掉的债。
效果:终于快起来了
用一个 100 秒的英文短片实测:
- Whisper 转录:约 15 秒
- pyannote 分离:约 8 秒(GPU)
- 对齐 + 合并:不到 1 秒
- 总计:约 23 秒
对比上一章的 MOSS:36 分钟录音 → 31 分钟转写。速度差距大概是 80 倍 。Whisper + pyannote 组合拳在 100 秒音频上的总耗时不超过 30 秒,处理 1 小时会议录音大约需要 8-12 分钟------这才是正常人能接受的等待时间。
自动检测出 4 个说话人,发言块合并合理。对于真实的双人/多人会议录音,如果提前知道参会人数,传入 num_speakers 可以明显减少串人现象。
完整代码约 200 行,核心就是一个 SpeakerDiarizer 类:diarize()(分离)→ assign_speakers()(对齐)→ merge_consecutive()(合并)→ format_transcript()(格式化),四个静态/成员方法各司其职,也方便单独测试------对齐和合并逻辑不依赖模型,用模拟数据就能单测。
最关键的是:这个方案在 5060 Ti 16G 上跑得稳稳的,不会把显存榨干。 不像 MOSS 那样动不动就要 49.5G,还不给商量。
总结与可改进点
「转录 + 分离 + 时间轴对齐」是一条性价比很高的路线:两个成熟模型各司其职,中间只用几十行对齐代码粘合,就能让纯文本转写升级成带角色的会议记录。
对比上一章 MOSS 的端到端方案:
| 对比项 | MOSS 0.9B | Whisper + pyannote |
|---|---|---|
| 速度 | 慢(约 1:1 耗时) | 快(约 5-8 倍实时) |
| 显存占用 | 动态增长,长音频危险 | 稳定可控 |
| 灵活性 | 固定流程,难以拆分 | 模块独立,可替换 |
| 模型授权 | 开箱即用 | 需手动点 3 个授权页面 |
没有完美的方案,只有适合的方案。MOSS 胜在省事,Whisper+pyannote 胜在灵活和速度------成年人当然是两个都要,不同场景换着用。
如果继续打磨,几个方向:
- 词级对齐 :用
word_timestamps按词分配说话人,处理说话人快速交替的场景------比如辩论节目,两人互怼时每半秒换一次人。 - exclusive diarization :pyannote 4.x 还提供
exclusive_speaker_diarization(不重叠的分离结果),对重叠语音的处理更干净------适合那种大家同时说话的混乱会议。 - 说话人命名:分离只能给出「说话人1/2/3」,结合声纹库或简单的人工标注界面,可以替换成真实姓名------"老板说"比"说话人1说"有说服力多了。
如果你之前用 MOSS 跑会议纪要觉得太慢,试试这套组合拳。代码量不大,改造成本低,速度提升肉眼可见。
如果这篇文章让你成功避开了一个坑,或者省了一下午的调试时间,点赞、评论、转发都行。
谢谢大家 🙏
祝你们的模型一次加载成功,token 永不超时,说话人永远不串。