
很多人在优化会议语音识别时,第一反应都是换 ASR 模型。
识别不准,就从 Whisper 换到 Qwen3-ASR;方言效果不好,就继续找更大的模型;远场会议错字多,再尝试调 beam size、Prompt 或热词。
但实际做过会议系统后会发现,有些问题根本不应该交给 ASR 解决。
会议室里的空调声、投影仪风扇、扬声器回声、远近发言音量差异、多人交叉说话,都发生在语音真正进入 ASR 之前。输入信号已经出了问题,再强的识别模型也只能尽量补救。
因此这次我们没有继续从 ASR 层入手,而是重新整理了前端语音处理链路:
会议麦克风
↓
WebRTC APM
AEC / NS / AGC
↓
Silero VAD
↓
Qwen3-ASR
↓
NeMo Sortformer
↓
Qwen3-ForcedAligner
↓
本地大模型
↓
会议纪要 / 决策 / 待办 / 回听在测试链路中,这几个模型和处理模块保持相互独立:前端负责把声音处理干净,ASR负责识别文字,Sortformer负责判断谁在什么时候说话,ForcedAligner再把文本重新压到精确时间轴上。
这样做的目的并不是让技术栈变复杂,而是让每个模块只解决自己最擅长的问题。
先看一个典型会议室。
桌面上有一个全向麦克风,电视或者会议大屏正在播放远端参会人的声音,同时还有空调、电脑风扇和键盘声。
麦克风实际采集到的并不是:
发言人声音而更接近:
实际输入
=
近端发言
+
远端扬声器回声
+
空调噪声
+
键盘声
+
房间混响
+
其他参会人声音这时候直接:
Microphone
↓
Qwen3-ASR等于要求 ASR 同时完成:
回声消除
降噪
音量补偿
静音判断
说话人分离
语音识别显然不合理。
更合适的做法,是先建立一个前端 Audio Front-End。
模块 | 主要解决的问题 |
|---|---|
WebRTC AEC | 扬声器声音重新进入麦克风 |
WebRTC NS | 空调、风扇等稳定背景噪声 |
WebRTC AGC | 发言人远近造成音量差异 |
Silero VAD | 判断什么时候真正有人说话 |
Qwen3-ASR | 语音转文字 |
Sortformer | 判断谁在什么时候发言 |
ForcedAligner | 文本与音频精确对齐 |
本地LLM | 摘要、决策、待办等结构化整理 |
WebRTC 官方的 Audio Processing Module 本身就是为语音通信前处理设计的,包含 AEC、Noise Suppression 和 Automatic Gain Control,并且既可以放在 WebRTC 完整链路里,也可以独立使用。
WebRTC APM 最值得用于会议场景的三个模块分别是:
AEC
Acoustic Echo Cancellation
NS
Noise Suppression
AGC
Automatic Gain Control假设远端人员通过会议室电视讲话:
远端声音
↓
会议室扬声器
↓
空气传播
↓
会议麦克风如果没有 AEC,麦克风会再次采集扬声器播放的声音。
最后 ASR 可能收到:
远端原始语音
+
延迟几十毫秒后的远端回声严重时甚至会出现重复文字。
AEC 的核心思想是同时拿到:
Render Stream:扬声器准备播放的声音
Capture Stream:麦克风实际录到的声音通过参考信号估计哪些声音属于扬声器回放,再从麦克风输入中消除。
WebRTC APM 的典型处理顺序也是:
ProcessReverseStream(render)
↓
提供扬声器参考
ProcessStream(capture)
↓
处理麦克风信号官方接口文档明确要求在通话链路中分别处理 render 和 capture 音频。
这也带来一个经常被忽略的问题:
只有一份已经录好的麦克风 WAV,并不能凭空获得完整 AEC 效果。
AEC需要远端播放参考。
如果会议设备在录音时没有保存扬声器输出流,后期只有混合录音,那么更现实的处理方式是做降噪和增强,而不是把它描述成完整的回声消除。
APM 本身主要是 C++ 模块。
核心配置可以写成:
#include "api/audio/audio_processing.h"
webrtc::AudioProcessing::Config config;
// 回声消除
config.echo_canceller.enabled = true;
// 背景噪声抑制
config.noise_suppression.enabled = true;
config.noise_suppression.level =
webrtc::AudioProcessing::Config::
NoiseSuppression::kHigh;
// 自动增益
config.gain_controller2.enabled = true;
// 高频噪声和低频干扰场景可考虑打开
config.high_pass_filter.enabled = true;当前 WebRTC API 中,AEC、NS、AGC 等都可以通过 AudioProcessing::Config 配置,然后由 BuiltinAudioProcessingBuilder 创建 APM 实例。
简化后的结构:
webrtc::AudioProcessing::Config config;
config.echo_canceller.enabled = true;
config.noise_suppression.enabled = true;
config.gain_controller2.enabled = true;
auto apm =
webrtc::BuiltinAudioProcessingBuilder(config)
.Build(
webrtc::CreateEnvironment()
);真实音频循环则类似:
while (running) {
// 1. 获取即将送到扬声器的声音
ReadRenderFrame(render_frame);
// 2. AEC参考信号
apm->ProcessReverseStream(
render_frame
);
// 3. 获取麦克风音频
ReadCaptureFrame(capture_frame);
// 4. 如果设备链路可以得到延迟估计,
// 应同步告诉AEC
apm->set_stream_delay_ms(
estimated_delay_ms
);
// 5. 执行AEC / NS / AGC
apm->ProcessStream(
capture_frame
);
// 6. 后续交给VAD和ASR
PushToSpeechPipeline(
capture_frame
);
}这里真正需要调试的通常不是:
AEC开没开而是:
Render参考流是不是正确
Capture和Render时间是不是对齐
设备播放延迟是多少
是否发生重复重采样
AGC有没有把噪声一起抬高AEC参数配置正确,但参考信号错了,效果一样可能很差。
有了 WebRTC APM,并不意味着 VAD 可以删除。
因为两者解决的问题完全不同。
APM回答:
这段声音怎样变得更干净?
VAD回答:
这里到底有没有人在说话?
一场两小时会议,很可能存在大量:
等待人员进入
翻PPT
看资料
喝水
中场休息
设备调试
无人发言这些片段没有必要全部进入 ASR。
Silero VAD 可以直接输出有效语音区间。官方项目提供 PyTorch 和 ONNX 运行方式,目前支持 8kHz 和 16kHz 音频,并且模型本身很轻量。
安装:
pip install silero-vad最简单的调用:
from silero_vad import (
load_silero_vad,
read_audio,
get_speech_timestamps,
)
model = load_silero_vad()
wav = read_audio(
"/data/meeting_apm.wav"
)
speech_timestamps = (
get_speech_timestamps(
wav,
model,
return_seconds=True,
)
)
for item in speech_timestamps:
print(item)可能得到:
{'start': 1.24, 'end': 8.93}
{'start': 11.48, 'end': 19.72}
{'start': 24.10, 'end': 35.86}于是:
120分钟原始录音
↓
Silero VAD
↓
只保留真正存在语音的区间后面的 ASR 负担会明显更可控。
很多代码会这样做:
原音频
00:00-00:05 有人说话
00:05-00:20 静音
00:20-00:30 有人说话然后直接删除静音,拼成:
00:00-00:05 第一段
00:05-00:15 第二段ASR当然还能识别。
但原始时间轴已经被破坏了。
之后 Sortformer 可能告诉你:
Speaker 1:
20.4s - 28.7s而 ASR 文本却已经变成:
5.4s - 13.7s两者无法直接合并。
因此更合理的数据结构是:
{
"segment_id": 2,
"original_start": 20.0,
"original_end": 30.0,
"audio_path": "segment_0002.wav"
}识别完成后,把局部时间重新映射回全局时间:
def restore_global_time(
local_time: float,
segment_start: float,
):
return (
segment_start
+ local_time
)这一步对后面的:
说话人匹配
Forced Alignment
点击文字回听
字幕时间轴都非常重要。
前端信号处理完成以后,再进入 ASR。
Qwen3-ASR 官方目前提供 0.6B 和 1.7B 两种开放模型,同时支持语言识别和多语言语音转写;官方工具包同时提供 Transformers 和 vLLM 推理路径。
边缘部署可以先从 0.6B 开始:
pip install -U qwen-asrPython:
import torch
from qwen_asr import (
Qwen3ASRModel,
)
MODEL_PATH = (
"/data/models/"
"Qwen3-ASR-0.6B"
)
model = Qwen3ASRModel.from_pretrained(
MODEL_PATH,
dtype=torch.bfloat16,
device_map="cuda:0",
max_inference_batch_size=1,
max_new_tokens=1024,
)
results = model.transcribe(
audio="/data/meeting_apm.wav",
language="Chinese",
)
result = results[0]
print(result.language)
print(result.text)得到:
Chinese
今天主要确认一下本周接口联调和测试环境安排。
接口部分预计周五之前完成。
测试服务器的问题由运维继续处理。到这里,我们只解决了:
What was said?还没有解决:
Who said it?传统 speaker diarization 很多采用级联方案:
VAD
↓
Speaker Embedding
↓
Clustering
↓
Speaker 0 / Speaker 1Sortformer 走的是另一条路线。
NVIDIA NeMo 将它定义为端到端 speaker diarization 模型:模型直接从输入音频预测说话人标签和活动区间,而不是必须经过“embedding + clustering”的完整级联流程。NeMo 当前同时提供离线和在线 Sortformer。
对于会议链路来说,这个特点很有意思:
会议录音
↓
Sortformer
↓
谁在什么时候说话不需要先得到 ASR 结果才能执行。
因此可以把:
Qwen3-ASR和:
Sortformer理解成两个并行任务。
┌─ Qwen3-ASR ── 文本
会议音频 ────┤
└─ Sortformer ─ Speaker时间轴最后再做融合。
安装 NeMo 环境后,可以直接加载官方 checkpoint:
from nemo.collections.asr.models import (
SortformerEncLabelModel,
)
diar_model = (
SortformerEncLabelModel
.from_pretrained(
"nvidia/"
"diar_sortformer_4spk-v1"
)
)
diar_model.eval()
segments = diar_model.diarize(
audio="/data/meeting_apm.wav",
batch_size=1,
)
print(segments)NVIDIA 当前公开的 diar_sortformer_4spk-v1 是离线 Sortformer checkpoint,支持最多 4 个说话人;NeMo 另外提供 Streaming Sortformer 版本。
这一点非常值得注意。
如果实际会议可能有:
8人
10人
20人不能因为模型名字里写了 diarization,就默认这个具体 checkpoint 可以处理任意人数。
模型验收时必须把:
最大说话人数单独列为测试条件。
会议最麻烦的并不是:
A说完
B再说而是:
A:我觉得这个方案——
B:对,我补充一下——
A:先等我说完。这种 overlapping speech 对普通聚类式 speaker diarization 很有挑战。
Sortformer 本身就是端到端 diarization 路线,NVIDIA 发布的评估结果也包含 overlapping speech 场景;后续 Streaming Sortformer 还提供基于 speaker cache 的跨 chunk 说话人跟踪。
但这不代表:
多人同时讲话
=
一定100%区分正确真实会议仍然应该测试:
两人同时说话
三人快速轮换
远场会议
同一性别相似声线
远端+近端混合会议特别是多人会议里,diarization 的错误往往不会表现成乱码。
更常见的是:
文字是对的
发言人错了这对会议纪要影响反而很大。
假设 ASR 得到:
[
{
"text": "这个接口周五之前完成。",
"start": 12.4,
"end": 16.9
}
]Sortformer得到:
[
{
"speaker": "speaker_1",
"start": 12.1,
"end": 17.2
}
]最简单的方法就是计算时间重叠。
def overlap(
a_start,
a_end,
b_start,
b_end,
):
return max(
0,
min(a_end, b_end)
- max(a_start, b_start),
)
def match_speaker(
asr_segment,
diar_segments,
):
best_speaker = "unknown"
best_overlap = 0.0
for diar in diar_segments:
current = overlap(
asr_segment["start"],
asr_segment["end"],
diar["start"],
diar["end"],
)
if current > best_overlap:
best_overlap = current
best_speaker = (
diar["speaker"]
)
return best_speaker最终:
{
"speaker": "speaker_1",
"start": 12.4,
"end": 16.9,
"text": "这个接口周五之前完成。"
}但是这里还有一个问题:
ASR时间戳未必足够精细。
因此下一层才轮到 Forced Aligner。
ASR主要追求:
识别出正确文字Forced Alignment解决的是:
已经知道文字以后
这些字准确出现在音频什么位置?Qwen 官方发布的 Qwen3-ForcedAligner-0.6B 可以对文本和语音进行对齐,并返回词级或字符级时间戳,目前官方列出的对齐语言为 11 种。
直接调用:
import torch
from qwen_asr import (
Qwen3ForcedAligner,
)
aligner = (
Qwen3ForcedAligner
.from_pretrained(
"/data/models/"
"Qwen3-ForcedAligner-0.6B",
dtype=torch.bfloat16,
device_map="cuda:0",
)
)
results = aligner.align(
audio="/data/meeting_segment.wav",
text="这个接口周五之前完成。",
language="Chinese",
)
for item in results[0]:
print(
item.text,
item.start_time,
item.end_time,
)可能得到类似:
这个 12.42 12.73
接口 12.74 13.18
周五 13.46 13.82
之前 13.83 14.21
完成 14.22 14.76Qwen 官方示例也是通过:
Qwen3ForcedAligner.from_pretrained(...)
model.align(...)完成文本与音频对齐。
因为两者解决的问题并不一样。
Sortformer输出:
12.1 - 17.2
Speaker 1正在说话ForcedAligner输出:
12.42 - 12.73
“这个”
12.74 - 13.18
“接口”
13.46 - 13.82
“周五”把两边放在一起:
Speaker 1
12.1 ───────────────── 17.2
│
├─ 12.42 这个
├─ 12.74 接口
├─ 13.46 周五
└─ 14.22 完成于是可以得到更加稳定的:
{
"speaker": "speaker_1",
"text": "这个接口周五之前完成。",
"start": 12.42,
"end": 14.76
}以后用户点击纪要里的这句话:
这个接口周五之前完成播放器直接:
audio.currentTime = 12.42;
audio.play();就可以定位回原声。
这也是 Forced Alignment 在会议产品里比单纯“显示漂亮时间戳”更实际的价值。
经过:
APM
↓
VAD
↓
ASR
↓
Sortformer
↓
ForcedAligner以后,不应该只剩下一大段纯文本。
比较理想的结构是:
[
{
"speaker": "speaker_0",
"start": 3.26,
"end": 8.41,
"text": "今天先确认一下本周的项目计划。"
},
{
"speaker": "speaker_1",
"start": 12.42,
"end": 14.76,
"text": "这个接口周五之前完成。"
},
{
"speaker": "speaker_2",
"start": 18.20,
"end": 23.61,
"text": "测试服务器的问题我们继续处理。"
}
]然后再转成:
speaker_0:
今天先确认一下本周的项目计划。
speaker_1:
这个接口周五之前完成。
speaker_2:
测试服务器的问题我们继续处理。交给本地 LLM。
最简单的 Prompt:
请总结下面会议。当然可以运行。
但真正用于会议系统时,更推荐强制结构化输出。
SYSTEM_PROMPT = """
你是会议纪要整理模块。
根据输入的会议记录生成JSON。
要求:
1. 不允许编造原文没有的信息;
2. 区分讨论意见和最终决策;
3. 待办必须尽量保留原发言人;
4. 没有明确责任人则保持为空;
5. 没有明确时间不要自行补充;
6. 风险事项必须能在原文中找到依据。
返回:
{
"summary": "",
"topics": [],
"decisions": [],
"todos": [
{
"speaker": "",
"task": "",
"deadline": ""
}
],
"risks": []
}
"""通过本地 OpenAI 兼容接口调用:
from openai import OpenAI
client = OpenAI(
base_url=(
"http://127.0.0.1:1025/v1"
),
api_key="EMPTY",
)
response = (
client.chat.completions.create(
model="local-meeting-llm",
temperature=0.1,
messages=[
{
"role": "system",
"content": SYSTEM_PROMPT,
},
{
"role": "user",
"content": meeting_text,
},
],
)
)
minutes = (
response
.choices[0]
.message.content
)
print(minutes)最终可能得到:
{
"summary": "会议主要确认项目计划、接口开发及测试环境安排。",
"decisions": [
"本周继续推进项目测试准备"
],
"todos": [
{
"speaker": "speaker_1",
"task": "完成接口开发",
"deadline": "周五之前"
},
{
"speaker": "speaker_2",
"task": "继续处理测试服务器问题",
"deadline": ""
}
]
}这里 speaker 信息的意义就体现出来了。
如果 LLM 只收到:
接口周五之前完成。它只能知道:
有一个任务。收到:
speaker_1:
接口周五之前完成。才有机会继续保留:
谁承诺了这个任务。做到这里以后,底层已经有:
WebRTC APM
Silero VAD
Qwen3-ASR
Sortformer
ForcedAligner
Local LLM但业务系统不应该分别管理这些模型的细节。
更合理的是封装成统一任务:
def process_meeting(
meeting_id: str,
audio_path: str,
):
enhanced_audio = (
run_audio_frontend(
audio_path
)
)
vad_segments = (
run_vad(
enhanced_audio
)
)
transcript = (
run_asr(
enhanced_audio,
vad_segments,
)
)
speakers = (
run_sortformer(
enhanced_audio
)
)
aligned = (
run_forced_alignment(
enhanced_audio,
transcript,
)
)
merged = (
merge_speaker_and_text(
speakers,
aligned,
)
)
minutes = (
generate_minutes(
merged
)
)
return {
"meeting_id":
meeting_id,
"segments":
merged,
"minutes":
minutes,
}在熙瑾会悟会议助手的业务层中,真正需要管理的是:
会议ID
录音文件
处理状态
参会人员
结构化纪要
待办事项
原声回听
历史检索至于底层使用哪个 VAD、哪个 ASR、哪个 diarization 模型,可以留在模型服务层内部处理。
这样以后替换模型时,上层接口基本不用跟着重写。
Demo 很容易写成:
meeting.py
├── APM
├── Silero
├── Qwen3-ASR
├── Sortformer
├── ForcedAligner
└── LLM生产环境不建议这样做。
不同模块的资源需求完全不同。
可以拆成:
audio-frontend
WebRTC APM
CPU
vad-service
Silero VAD
CPU / ONNX
asr-service
Qwen3-ASR
GPU
diarization-service
Sortformer
GPU
aligner-service
Qwen3-ForcedAligner
GPU
llm-service
Local LLM
GPU然后业务层通过:
HTTP
gRPC
任务队列进行组合。
如果只有一张 GPU,也不一定要求所有模型同时常驻。
可以采用阶段式处理:
会议进行中
APM
↓
VAD
↓
ASR
会议结束后
Sortformer
↓
ForcedAligner
↓
LLM这样可以减少 GPU 峰值资源竞争。
只有麦克风录音,却声称开启了 AEC。
这种情况下真正能工作的主要还是:
NS
AGC
其他语音增强完整 AEC 应该有扬声器参考信号。
不要只保存处理后的:
segment.wav一定同时保存:
original_start
original_end否则后面的 diarization 和原音回听很难重新对齐。
Sortformer解决的是:
speaker_0
speaker_1
speaker_2而不是:
张主任
李工
王经理真正做实名身份仍然需要额外的身份绑定机制。
不要把 diarization 和 speaker identification 混为一谈。
Forced Aligner需要:
音频
+
已经存在的文本输入。
它的工作不是重新判断“说了什么”,而是判断:
这些文字究竟落在音频什么位置。所以正常顺序应该是:
ASR
↓
获得文本
↓
Forced Alignment
↓
获得精确时间而不是反过来。
这条链路上线前,最好按模块分别验收。
模块 | 建议指标 |
|---|---|
APM | 回声残留、噪声抑制、人声失真 |
VAD | 漏检、误检、语音边界 |
ASR | CER/WER、专业词、方言 |
Sortformer | DER、Speaker混淆、重叠语音 |
ForcedAligner | 时间戳偏差 |
LLM | 决策遗漏、待办错误、虚构内容 |
整链路 | RTF、失败率、GPU峰值、长会议稳定性 |
特别是会议系统,不建议只测:
安静环境
+
一个人
+
普通话
+
5分钟至少应该加入:
空调噪声
远场麦克风
会议大屏外放
中英文专业词
两人抢话
四人会议
一小时以上长会议最终测试的是:
整套会议链路在真实环境里是否稳定。
而不是某一个模型在标准测试集上的单项分数。
重新看整条路线:
WebRTC APM
↓
让声音更干净
Silero VAD
↓
判断哪里真正有人讲话
Qwen3-ASR
↓
识别说了什么
NeMo Sortformer
↓
判断谁在什么时候说
Qwen3-ForcedAligner
↓
把文字重新压到精确时间轴
本地LLM
↓
提取摘要、决策和待办
熙瑾会悟会议系统
↓
负责回听、归档、检索和业务管理其中任何一个模块都无法单独构成 AI 会议助手。
但每一层把自己的问题解决好以后,后面的模型反而会更容易工作。
WebRTC APM减少前端回声和环境噪声,Silero VAD避免大量无效音频进入推理,Qwen3-ASR专注转写,Sortformer专注多人发言关系,ForcedAligner负责精确时间轴,本地大模型最后再处理结构化信息。
对于会议处理链路来说,这种模块化方式的意义也正在这里:底层模型可以不断更新,但会议、纪要、待办、回听和归档这些业务结构不需要随着某一个模型的变化而重新设计。
所以会议室识别效果不好时,与其第一时间问:
“是不是该换一个更大的ASR?”
不如先检查:
进入ASR之前的声音,到底处理好了吗?
很多时候,真正影响最终会议质量的,恰恰是模型之前那几层。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。