☰
FastWhisper+Pyannote:ASR与说话人分离离线转写实战
2026/10/7 3:25:56 网站建设 项目流程

前阵子帮一个做会议纪要的朋友收拾烂摊子,他们的产品要在一小时的多人会议录音里产出逐句文字,还得标清楚每一句是谁说的。最开始他们用的是某个在线转写接口,单次时长限制、按分钟计费、还得把录音传到别人的服务器上,成本和合规两头都难受,后来索性改成自己本地跑。折腾了两周,最后的方案就是FastWhisper 做 ASR,Pyannote 做说话者识别(Speaker Diarization),两条链路各干各的,中间用时间轴把它们缝起来。整套东西跑在一张 12G 显存的消费级显卡上,一小时的会议录音十几分钟出结果,全程离线,说话人编号也能对得上。这篇就按我当时的实施顺序,把选型理由、代码实现、参数调优和踩过的坑完整写一遍,代码都能直接抄,适合有一定 Python 基础、想自己搭一套多人语音转写流水线的同学。顺便说一句,很多人搜"ASR 的 AT 指令""最小的 ASR 模型",那多半是在玩串口语音模块那条路子,跟本文这套软件流水线不是一个赛道,后面我会顺带对照一下两种方案的取舍。

1. 先把需求拆干净:ASR 和说话者识别是两条独立链路

1.1 转写文字和分辨"谁在说"本来就是两件事

刚接触这块的人容易有个误区,觉得"语音转文字"是一个整体能力,找个模型一把梭就完事。实际上这是两个完全独立的子任务,输入都是音频,输出却完全不同。ASR(Automatic Speech Recognition)的任务是把声学信号映射成文字序列,它关心的是"这句话的字面内容是什么";而说话者识别(更准确地叫说话人日志,Speaker Diarization)的任务是回答"这段话是第几个人说的",它根本不需要理解内容,只关心声纹特征和聚类结果。

为什么这个区分这么重要?因为它们的失败模式完全不一样。ASR 出错表现为错字、漏字、幻觉句子;说话者识别出错表现为两个人被合成一个编号,或者一个人被切成三个。你在调优的时候,如果不知道错误来自哪条链路,就会像我当时一样,先去调 Whisper 的 beam_size,折腾半天发现文字明明是对的,乱的是编号。

还有一类需求叫"说话人确认"(Speaker Verification),比如声纹解锁,那是 1:1 比对的判定任务,和多人场景下的聚类任务又是两码事。我们这里要做的是"N 个人混在一起,谁说了哪句",属于聚类问题,说话人数量事先可能都不知道。

1.2 为什么选 Whisper 系而不是自研或商用接口

Whisper 是 OpenAI 开源的多语种 ASR 模型,最大的好处是中文识别开箱即用,不用自己标注数据训模型。我实测过几套方案,从零训练一个中文 ASR,哪怕只做到勉强可用,没有几百小时标注数据和几周调参下不来,对个人项目来说完全不现实。

但直接跑官方的openai-whisper有两个明显的痛点:一是推理慢,官方实现里 Python 层的开销和注意力计算没做太多优化;二是显存占用高,large 模型在消费级卡上跑起来紧张。FasterWhisper 是基于 CTranslate2 的重新实现,把模型转成 CTranslate2 格式,做了算子融合、量化支持和批处理优化,同样的模型同样精度下速度能有数倍提升,显存也降下来了。更关键的是它原生支持word_timestamps=True,能给出词级时间戳,这正是后面做说话人对齐的基础。

1.3 Pyannote 在这套方案里的定位

Pyannote 是一个专门做说话人日志的开源工具链,其中pyannote/speaker-diarization-3.1是目前业界用得比较多的预训练流水线。它的内部大致分三步走:先用一个分割模型(segmentation)在时间轴上找出"有人在说话"的片段,然后用声纹嵌入模型(embedding)把每个片段压成一个向量,最后用聚类算法把向量分组,同一组的片段归为同一个说话人。

选它而不是其他方案,主要是三个考虑:预训练模型在公开数据集上的 DER(Diarization Error Rate,说话人日志错误率)通常落在 15% 到 20% 这个量级,具体数字以官方模型卡为准,对会议场景够用了;它支持用min_speakers/max_speakers约束聚类,这个开关对结果影响巨大;还有就是它的输出是标准的pyannote.core.Annotation对象,能直接按时间区间遍历,方便和 Whisper 的输出做对齐。

至于嵌入式那边搜"AT 指令"的朋友,你们面对的是另一套东西:多数离线语音模块只做关键词唤醒或固定命令词识别,通过串口发 AT 指令配置,优点是功耗低、无需联网、响应快,缺点是只能认有限几条命令,不能做通用转写。如果你要做的是"录音转文字加区分说话人",那就别在这条路上耗了。

2. 环境准备:先把音频和依赖理顺再写代码

2.1 音频预处理是绕不过去的第一步

我踩过的第一个坑就是直接拿 mp3 喂给 Pyannote,结果报解码错误。Pyannote 内部用 torchaudio 加载音频,虽然理论上支持 ffmpeg 后端,但版本组合一乱就容易出问题。最稳妥的做法是在流水线外面用 ffmpeg 统一转成 16kHz 单声道 WAV,这一步做完,后面所有环节的采样率假设都统一了。

ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le -vn output.wav

参数的含义我解释一下:-ac 1强制单声道,因为 Whisper 和 Pyannote 都在单声道上训练,双声道不但浪费算力,还可能因为左右声道微小的时间差影响特征;-ar 16000统一到 16kHz,这是 Whisper 的原生采样率,重采样交给成熟的 ffmpeg 做比交给推理库做更可控;-vn是丢掉视频流,有时候录屏文件里带画面,不丢会报错。

注意:如果你的原始录音是 44.1kHz 或 48kHz 的,务必先重采样。我见过一次没重采样直接跑,Whisper 内部做了重采样,但 Pyannote 用的是原始采样率读入的时长,两边时间轴差了 3 倍,对齐结果全乱。

2.2 安装依赖和版本组合

我用的环境是 Python 3.10 + PyTorch 2.x + CUDA 12.x,这套组合比较稳。安装命令:

pip install faster-whisper pip install pyannote.audio pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121

这里有个容易翻车的地方:pyannote.audio会带一个自己依赖的 torch 版本,如果你的 torch 是单独装的高版本 CUDA 版,pip 有可能把它降级掉。装完之后一定要验证:

python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

如果打印出来是 False,说明 CUDA 版本不匹配,得回去检查显卡驱动和 torch 的对应关系。这一步不解决,后面所有模型都会默默跑在 CPU 上,速度差十倍以上。

2.3 模型授权和下载:Pyannote 有个隐藏门槛

Pyannote 的预训练模型不是下载即用,需要先在模型页面接受使用条款,再去 Hugging Face 申请一个 Access Token。我第一次跑的时候直接报 401,以为是网络问题,查了半天才发现是没接受条款。这个环节很多人第一次都会卡住,步骤是:注册账号,打开pyannote/speaker-diarization-3.1和它依赖的两个子模型页面,分别点同意,然后在设置里生成一个 read 权限的 token。

代码里传递 token 的写法在不同版本间有变化:

# pyannote.audio 3.x 的写法 # pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", use_auth_token=HF_TOKEN) # 较新版本的写法 from pyannote.audio import Pipeline pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", token=HF_TOKEN)

如果你用use_auth_token报参数错误,就换成token,反过来也一样。这是纯 API 变更,不是你的问题。

2.4 Whisper 模型怎么选:从 tiny 到 large-v3 的取舍

FasterWhisper 支持的模型规格和官方一致:tiny、base、small、medium、large-v3,还有社区蒸馏版 distil 系列。搜"最小的 ASR 模型"的朋友多半是在意体积,我把常见规格的实际情况列一下,体积是 CTranslate2 格式的大致数值,具体以你下载的仓库为准:

模型规格参数量级float16 体积int8 体积中文效果适用场景
tiny约 39M约 75MB约 40MB明显吃力演示、极低资源设备
base约 74M约 145MB约 75MB能出字,错字多快速预览
small约 244M约 484MB约 245MB勉强可用边缘设备、准实时
medium约 769M约 1.5GB约 770MB较好显存有限时的折中
large-v3约 1550M约 3.1GB约 1.6GB最好追求准确率的离线批处理

我的建议很直接:追求准确率就上 large-v3,显存不够就用 int8 量化版本,别在 tiny 和 base 上浪费时间。中文的声调和同音字太多,小模型出来的结果错得离谱,后处理修错的成本比自己跑大模型高得多。如果你确实需要极小的模型,可以考虑 distil 系列的英文蒸馏版,它在英文上的速度和精度平衡做得不错,但中文支持没优势。

3. 核心实现:把两条链路拼成一条流水线

3.1 先跑说话人日志,拿到时间轴

整个流水线的执行顺序我建议是先做说话人日志,再做转写。原因是 Pyannote 的模型小、跑得快,如果它挂了或者授权不对,你能立刻发现,不用等 Whisper 跑完十分钟才发现白跑。另外一个原因是显存:两个模型不用同时驻留,跑完一个释放掉再跑下一个,12G 卡完全够用。

import torch from pyannote.audio import Pipeline def get_diarization_turns(audio_path, hf_token, num_speakers=None, min_speakers=None, max_speakers=None): pipeline = Pipeline.from_pretrained( "pyannote/speaker-diarization-3.1", token=hf_token, ) if torch.cuda.is_available(): pipeline.to(torch.device("cuda")) output = pipeline( audio_path, num_speakers=num_speakers, min_speakers=min_speakers, max_speakers=max_speakers, ) # 关键兼容点:新版 pyannote.audio 返回 DiarizeOutput,不是 Annotation annotation = getattr(output, "speaker_diarization", output) turns = [] for seg, _, speaker in annotation.itertracks(yield_label=True): turns.append({ "start": round(seg.start, 3), "end": round(seg.end, 3), "speaker": speaker, }) # 及时释放显存,给 Whisper 腾地方 del pipeline torch.cuda.empty_cache() return sorted(turns, key=lambda x: x["start"])

itertracks(yield_label=True)返回的是三元组:时间段、轨道号、说话人标签。轨道号我们不需要,说话人标签形如SPEAKER_00、SPEAKER_01,注意这个编号是每次运行都可能变的,它只是聚类结果的一个内部标识,不要拿它当固定 ID 用,跨文件对比时要重新做映射。

这里那个兼容点值得单独强调:新版 pyannote.audio 的 pipeline 返回的是一个DiarizeOutput数据类,里面同时包含speaker_diarization和exclusive_speaker_diarization两个字段,直接对它调用itertracks会报 AttributeError。用getattr兜一下,两个版本都能跑。这是我卡了半小时才查出来的问题,网上很多老教程还停留在旧返回值的写法上。

3.2 再做词级转写,拿到带时间戳的文字

下一步是转写。关键参数是word_timestamps=True,打开之后每个 segment 里会带一个words列表,每个词有自己的起止时间和置信度。

from faster_whisper import WhisperModel def transcribe_words(audio_path, model_size="large-v3", language=None): model = WhisperModel(model_size, device="cuda", compute_type="float16") segments, info = model.transcribe( audio_path, language=language, # 已知语种就写死,避免短音频误判 beam_size=5, word_timestamps=True, vad_filter=True, vad_parameters=dict(min_silence_duration_ms=500), condition_on_previous_text=False, ) words = [] for seg in segments: for w in (seg.words or []): words.append({ "start": w.start, "end": w.end, "word": w.word, "prob": w.probability, }) del model torch.cuda.empty_cache() return words, info

segments是个生成器,所以model.transcribe调用本身几乎瞬间返回,真正耗时发生在你遍历它的时候。这个特性在做进度条的时候有用,但也容易让人误判"哦这么快",结果卡在 for 循环里。

language=None让模型自动检测语种,但对于只有几十秒的短音频,检测很容易翻车,把中文识别成日文的情况我遇到过不止一次。如果你明确知道音频语种,就写死language="zh",省得模型自己猜。

3.3 时间轴对齐:把词挂到说话人身上

这是整套方案最核心的一步。Whisper 给你一串带时间戳的词,Pyannote 给你一串带说话人标签的时间区间,现在要把词分配下去。我的做法是按重叠时长最多的那个区间来判定,而不是简单地看词的中点落在哪个区间里。

为什么用重叠时长?因为说话人切换的瞬间,Pyannote 的边界和 Whisper 的词边界几乎不可能严丝合缝。一个词跨越了两段,看中点可能判错,但按重叠时长加权,取占比大的那一边,鲁棒性明显更好。

def assign_speaker(word, turns): s, e = word["start"], word["end"] best_speaker, best_overlap = None, 0.0 for t in turns: overlap = min(e, t["end"]) - max(s, t["start"]) if overlap > best_overlap: best_overlap = overlap best_speaker = t["speaker"] if best_speaker is None: # 完全没重叠,说明落在一段静音或边界缝隙里,退回用中点判断 mid = (s + e) / 2 for t in turns: if t["start"] <= mid <= t["end"]: return t["speaker"] return best_speaker or "UNKNOWN"

接下来把这些词合并成一句一句的话。合并规则是:同一个说话人、且和上一句的间隔小于阈值,就并成一句。这个阈值我一般设 0.6 到 1.0 秒,太小会把一句话切得稀碎,太大则会把两个人的交替发言粘在一起。

def merge_into_utterances(words, turns, gap_threshold=0.8): utterances = [] for w in words: speaker = assign_speaker(w, turns) if (utterances and utterances[-1]["speaker"] == speaker and w["start"] - utterances[-1]["end"] < gap_threshold): utterances[-1]["text"] += w["word"] utterances[-1]["end"] = w["end"] utterances[-1]["probs"].append(w["prob"]) else: utterances.append({ "speaker": speaker, "start": w["start"], "end": w["end"], "text": w["word"], "probs": [w["prob"]], }) for u in utterances: u["text"] = u["text"].strip() u["avg_prob"] = round(sum(u.pop("probs")) / len(u["probs"]), 3) return utterances

有个细节要提醒:Whisper 输出的word字段对英文是带前导空格的(比如" hello"),直接拼接是对的;中文则是按 token 切分的,不一定一个字一个 token,所以中文的词级时间戳粒度是不均匀的,有时候一个 token 覆盖好几个字。这不影响拼接结果的正确性,但如果你要拿它做逐字高亮字幕,粒度会显得很怪。这种情况下我建议退回按 segment 级别做对齐,反而更整齐。

3.4 输出成 SRT 和结构化 JSON

对齐完就要落盘了。我一般同时输出两种格式:带说话人前缀的 SRT 给人看,结构化 JSON 给系统消费。

def format_timestamp(seconds, sep=","): h = int(seconds // 3600) m = int(seconds % 3600 // 60) s = int(seconds % 60) ms = int(round((seconds - int(seconds)) * 1000)) return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}" def to_srt(utterances, speaker_names=None): speaker_names = speaker_names or {} lines = [] for i, u in enumerate(utterances, start=1): name = speaker_names.get(u["speaker"], u["speaker"]) lines.append(str(i)) lines.append(f"{format_timestamp(u['start'])} --> {format_timestamp(u['end'])}") lines.append(f"[{name}] {u['text']}") lines.append("") return "\n".join(lines)

speaker_names这个映射表是给人用的,SPEAKER_00这种标签交付出去没人看得懂,我会先跑一遍,人工听一下开头几十秒确认哪个编号是谁,然后填进去替换。这一步目前没什么好办法自动做,除非接入声纹库做比对。

4. 参数调优:把错误率再往下压一压

4.1 说话人数量约束:性价比最高的一个开关

如果你知道这场会议有几个人,一定要把num_speakers传进去。这是所有参数里对结果影响最大的一个。不传的时候,Pyannote 会用一个基于阈值的聚类,自动决定分几类,结果就是人数经常判多或判少。我实测过同一段四人会议,不约束的时候分出了六个说话人,把num_speakers=4写死之后直接降到四个,DER 肉眼可见地改善。

如果人数不确定,可以退一步用范围约束:

turns = get_diarization_turns( "meeting.wav", hf_token=HF_TOKEN, min_speakers=2, max_speakers=6, )

这两个参数不能和num_speakers同时用,同时传会报错。我的经验是,能确定就写死,确定不了就给个合理的范围,哪怕范围宽一点也比完全不约束强。

另外一个技巧是用你已知的信息反推。比如电话客服录音必然是两个人,那你直接写num_speakers=2,比任何聚类调参都管用。

4.2 VAD 与断句参数:决定时间轴颗粒度

Whisper 的vad_filter=True是用一个内置的 VAD 模型先切掉静音再送进 ASR,这个开关主要作用是抑制幻觉。Whisper 在遇到长段静音或者噪音时,会凭空编出一些句子,中文场景下常见的是编出"谢谢观看""请点赞订阅"这种训练数据里的高频短句。打开 VAD 之后这类问题能减少一大半。

vad_parameters里最常调的是min_silence_duration_ms,默认值是 2000 毫秒,也就是静音超过两秒才断开。会议场景里我习惯调到 500 毫秒左右,因为人与人对话的停顿往往不到两秒,调大之后容易出现一段音频里塞进好几分钟的连续内容,时间轴就不准了。

Pyannote 那边也有对应的参数,可以通过instantiate覆盖:

pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", token=HF_TOKEN) pipeline.instantiate({ "segmentation": { "min_duration_off": 0.0, # 允许更短的静音间隔 }, "clustering": { "method": "centroid", "min_cluster_size": 12, }, })

min_duration_off调小会让切分更碎,但说话人切换的边界会更精确;调大会让片段更连续,但快速交替的对话可能被合并。这个需要拿你自己的数据试,没有万能值。

4.3 Whisper 解码参数与幻觉抑制

除了 VAD,还有几个解码参数值得动:

beam_size默认是 5,调大能略微提升准确率,但耗时线性增长。我一般在离线批处理时用 5,准实时场景降到 1 或 2。

condition_on_previous_text默认是 True,意思是把上一段的结果作为下一段的上下文提示。这个机制能提升连贯性,但一旦模型开始幻觉,错误会被不断放大,形成循环重复。我现在的默认做法是设成 False,牺牲一点连贯性换稳定。

temperature可以传一个列表做回退策略,默认就是[0.0, 0.2, 0.4, 0.6, 0.8, 1.0],当某段的压缩率或对数概率不达标时自动升温重试。这个机制大部分时候有用,但偶尔会引入不必要的随机性,追求可复现的时候我会固定成[0.0]。

还有一个很少人提但很有用的参数是initial_prompt。它可以给模型一段提示文本,引导输出风格。比如你的录音里全是专业术语,把术语表塞进initial_prompt能明显减少错字。但注意提示文本不能太长,太长会挤占上下文窗口,反而让模型开始重复。

4.4 显存和速度的实际估算

我把测试环境说清楚:单张 12G 显存的消费级显卡,音频是 16kHz 单声道 WAV,一小时长度。以下是我实测的大致量级,硬件不同差别会很大,仅供参考。

环节模型显存占用一小时音频耗时
说话人日志pyannote 3.1约 1.5-2GB约 1-2 分钟
转写large-v3 float16约 4-5GB约 8-15 分钟
转写medium float16约 2.5-3GB约 4-6 分钟
转写small int8约 1GB约 2-3 分钟

可以看到,说话人日志的耗时可忽略不计,瓶颈全在 Whisper 上。所以如果整体太慢,优先考虑降 Whisper 的规格或者上量化,而不是去动 Pyannote。

显存方面,两个模型我都做了顺序执行加显存释放,峰值占用出现在 Whisper 阶段。如果你要并发处理多个文件,记得控制并发数,large-v3 同时跑两个就可能 OOM。

5. 常见问题与排查实录

5.1 时间轴错位、句子被切碎

最常见的一类问题。症状是输出的句子时间戳明显偏移,或者一句话被切成了七八段。原因通常有三个:一是音频没做重采样,两边采样率假设不一致,这个前面说过了;二是 VAD 的min_silence_duration_ms太大,段落粘在一起导致时间戳累积漂移;三是合并阈值gap_threshold设得太小。

排查顺序我一般这样走:先单独打印 Pyannote 的输出,人工对照音频听几个时间点,确认它的时间轴是对的;再单独打印 Whisper 的词级时间戳,同样抽查几个点。两个都对,问题就在对齐逻辑上;如果其中一个不对,那就不是对齐的锅。

还有一个隐蔽的情况:音频里存在长时间的音乐或环境噪声,VAD 会把这些当成语音切出来送进 Whisper,模型在这些片段上输出的时间戳会非常离谱。解决办法是在预处理阶段做一次能量检测,把明显的非语音片段剔掉。

5.2 说话人编号串了、两个人合成一个

第二个高频问题。最直接的原因是没做人数约束,前面讲过了。但即使做了约束,还是可能出现串号,这时候通常是声纹本身太接近,比如两个人音色相似,或者录音质量差导致嵌入向量区分度不够。

可以尝试的补救手段有:把min_cluster_size调大,抑制小簇的产生;检查音频里是不是有重叠说话(两个人同时说),重叠语音是说话人日志的老大难,目前没有特别好的通用解法;再就是做后处理,统计每个说话人的总时长,如果某个编号只出现了两秒钟,大概率是误判,可以把它合并到相邻编号。

提示:不要指望编号在多次运行之间保持一致。Pyannote 的编号是聚类时顺序生成的,同一段音频跑两次,编号可能互换。如果你需要一个稳定的说话人 ID,得自己接一套声纹比对来做映射。

5.3 幻觉、重复、语气词刷屏

Whisper 的幻觉在中文场景下的典型表现是在静音段编出"字幕由某某提供""请不吝点赞"这类句子,或者把同一句话重复输出十几遍。我的处理是三重防护一起上:vad_filter=True先切静音,condition_on_previous_text=False切断错误传播,再加一层输出后处理检测重复。

重复检测的逻辑很简单,把连续 N 句完全相同的文本合并或者删掉:

def dedup_utterances(utterances, max_repeat=2): result = [] for u in utterances: recent = [x["text"] for x in result[-max_repeat:]] if len(recent) == max_repeat and all(t == u["text"] for t in recent): result[-1]["end"] = u["end"] # 只延长时间,不再追加文本 continue result.append(u) return result

语气词刷屏是另一回事,那是模型如实转写了"嗯""啊""那个",这个不算错误。要做纪要的话可以在后处理里删掉,但从转写准确度角度讲它是对的,别去调参数惩罚它。

5.4 常见问题速查表

把上面这些整理成一张表,方便对照排查:

现象最可能的原因优先尝试的解决方式
401 / 403 报错未接受 Pyannote 模型条款或 token 无效去模型页点同意,重新生成 read 权限 token
AttributeError: itertracks新版返回 DiarizeOutput 对象用 getattr 取 speaker_diarization 字段
mp3 加载失败torchaudio 后端缺失先用 ffmpeg 转成 16kHz 单声道 WAV
说话人数明显偏多未约束聚类数量传 num_speakers 或 min/max_speakers
时间戳整体偏移采样率未统一加重采样步骤,两端统一 16kHz
出现幻觉句子静音段被送进模型开 vad_filter,关 condition_on_previous_text
中文识别成日文语种自动检测误判transcribe 时写死 language="zh"
跑在 CPU 上很慢torch 的 CUDA 版本不匹配验证 torch.cuda.is_available(),重装对应版本
显存爆掉两个模型同时驻留顺序执行,跑完 del + empty_cache

6. 工程化落地上的几个经验

6.1 长音频分片和批处理

一小时的音频直接整段跑没问题,但如果你的输入是三四小时的会议或者多文件批量,就得考虑分片。我的分片策略是按静音点切,每片 10 到 15 分钟,片与片之间留 1 秒重叠。留重叠是因为切点可能正好落在一个词中间,重叠一小段能保证这个词至少在某一片里是完整的,后面去重的时候按时间戳把重复部分删掉就行。

分片还有个额外好处是能并行。多个进程各自跑一片,最后按时间顺序合并,总耗时能压到接近单片的水平。但注意别开太多并发,Whisper large-v3 一开多显存就顶不住了,一般并发数等于显存容量除以单实例占用再减一。

另外,Pyannote 的说话人日志建议在整段音频上跑,而不是分片跑。因为说话人聚类依赖全局的声纹分布,分片跑会导致同一个人的编号在每片里都不一样,合并的时候完全对不上。这是个很容易踩的坑,我第一版就是这么写的,结果合并出来的结果里同一个人有三四个编号。

6.2 想做到接近实时该怎么做

纯离线批处理这套流程没法做到实时,因为 Pyannote 的聚类需要看到足够长的上下文才能判断说话人数量。如果你要做成准实时的,思路是分两步:先用一个短窗口做在线聚类,给每个新片段分配一个临时的说话人标签,同时维护一个声纹库;随着音频推进不断更新声纹库,标签可能会在前期发生修正。这套东西实现复杂度比离线高一个量级,除非产品明确要求,我一般不建议一上来就做。

如果只是想把延迟压到几秒,可以用小模型加流式解码的方式。FasterWhisper 本身支持对音频块做增量转写,配合小规格模型,延迟能压到秒级,代价是准确率下降。这个取舍要看你的场景,纪要场景我强烈建议老老实实做离线。

6.3 效果到底怎么评估

调了半天参数,总得有个量化的东西来判断好坏。ASR 侧用WER(词错误率),把人工标注的文本和模型输出对比,用 jiwer 这类库直接算:

from jiwer import wer score = wer("这是参考文本", "这是模型输出文本")

中文算 WER 的时候通常按字算,不用分词,这样更接近实际观感。

说话人侧用DER(说话人日志错误率),它由三部分组成:漏检(该说话的地方没检测到)、误检(不该说话的地方检测成说话)、说话人混淆(检测到了但分错人)。用 pyannote 自带的评估工具:

from pyannote.metrics.diarization import DiarizationErrorRate from pyannote.core import Annotation, Segment metric = DiarizationErrorRate() der = metric(reference, hypothesis)

实际做的时候,我一般标个十分钟的测试集就够用了,标太多不现实。标完之后固定这套测试集,每次改参数都跑一遍,看 DER 有没有改善,避免凭感觉调参。

顺便说一个我自己的感受:DER 从 20% 降到 15%,主观上会觉得结果"靠谱多了",但从 15% 再往下降到 10%,对使用体验的改善就没那么明显了。所以如果资源有限,先把 ASR 的准确率做上去,性价比比死磕说话人日志高。

最后分享一个小技巧,也是我在实际项目里用得最多的一个:别急着上大模型,先用 small 加 int8 跑通整条流水线,把数据流和对齐逻辑全部验证正确,再换成 large-v3 重跑一遍。我第一版直接上 large-v3 调试,每次改一行代码要等十分钟才能看到结果,一天下来效率极低。换成小模型之后迭代速度提升十倍,逻辑确认无误了再上大模型,整个开发周期缩短了一大截。这个顺序上的小调整,省下来的时间比我调所有参数加起来都多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询