会议室噪声太大怎么办?WebRTC APM + Sortformer 接入AI会议助手的音频链路实践

很多人在优化会议语音识别时,第一反应都是换 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再把文本重新压到精确时间轴上。

这样做的目的并不是让技术栈变复杂,而是让每个模块只解决自己最擅长的问题。


一、为什么会议音频不能直接丢给ASR?

先看一个典型会议室。

桌面上有一个全向麦克风,电视或者会议大屏正在播放远端参会人的声音,同时还有空调、电脑风扇和键盘声。

麦克风实际采集到的并不是:

发言人声音

而更接近:

实际输入
=
近端发言
+
远端扬声器回声
+
空调噪声
+
键盘声
+
房间混响
+
其他参会人声音

这时候直接:

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先处理“听不清”的问题

WebRTC APM 最值得用于会议场景的三个模块分别是:

AEC
Acoustic Echo Cancellation

NS
Noise Suppression

AGC
Automatic Gain Control

1. AEC:先解决“自己听见自己”

假设远端人员通过会议室电视讲话:

远端声音
   ↓
会议室扬声器
   ↓
空气传播
   ↓
会议麦克风

如果没有 AEC,麦克风会再次采集扬声器播放的声音。

最后 ASR 可能收到:

远端原始语音
+
延迟几十毫秒后的远端回声

严重时甚至会出现重复文字。

AEC 的核心思想是同时拿到:

Render Stream:扬声器准备播放的声音

Capture Stream:麦克风实际录到的声音

通过参考信号估计哪些声音属于扬声器回放,再从麦克风输入中消除。

WebRTC APM 的典型处理顺序也是:

ProcessReverseStream(render)
        ↓
提供扬声器参考

ProcessStream(capture)
        ↓
处理麦克风信号

官方接口文档明确要求在通话链路中分别处理 render 和 capture 音频。

这也带来一个经常被忽略的问题:

只有一份已经录好的麦克风 WAV,并不能凭空获得完整 AEC 效果。

AEC需要远端播放参考。

如果会议设备在录音时没有保存扬声器输出流,后期只有混合录音,那么更现实的处理方式是做降噪和增强,而不是把它描述成完整的回声消除。


三、WebRTC APM怎么配置?

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参数配置正确,但参考信号错了,效果一样可能很差。


四、APM处理完以后,为什么还需要Silero VAD?

有了 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 负担会明显更可控。


五、这里有一个很重要的坑:不要把VAD结果直接拼成新音频

很多代码会这样做:

原音频

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
点击文字回听
字幕时间轴

都非常重要。


六、第三层:Qwen3-ASR只负责把声音变成文本

前端信号处理完成以后,再进入 ASR。

Qwen3-ASR 官方目前提供 0.6B 和 1.7B 两种开放模型,同时支持语言识别和多语言语音转写;官方工具包同时提供 Transformers 和 vLLM 推理路径。

边缘部署可以先从 0.6B 开始:

pip install -U qwen-asr

Python:

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?

七、为什么这次说话人模块换成NeMo Sortformer?

传统 speaker diarization 很多采用级联方案:

VAD
 ↓
Speaker Embedding
 ↓
Clustering
 ↓
Speaker 0 / Speaker 1

Sortformer 走的是另一条路线。

NVIDIA NeMo 将它定义为端到端 speaker diarization 模型:模型直接从输入音频预测说话人标签和活动区间,而不是必须经过“embedding + clustering”的完整级联流程。NeMo 当前同时提供离线和在线 Sortformer。

对于会议链路来说,这个特点很有意思:

会议录音
   ↓
Sortformer
   ↓
谁在什么时候说话

不需要先得到 ASR 结果才能执行。

因此可以把:

Qwen3-ASR

和:

Sortformer

理解成两个并行任务。

             ┌─ Qwen3-ASR ── 文本
会议音频 ────┤
             └─ Sortformer ─ Speaker时间轴

最后再做融合。


八、Sortformer最小推理代码

安装 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和Sortformer怎么合并?

假设 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。


十一、Qwen3-ForcedAligner解决什么问题?

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.76

Qwen 官方示例也是通过:

Qwen3ForcedAligner.from_pretrained(...)
model.align(...)

完成文本与音频对齐。


十二、为什么已经有Sortformer还需要ForcedAligner?

因为两者解决的问题并不一样。

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 峰值资源竞争。


十七、这条链路最容易踩的四个坑

1. AEC没有Render参考流

只有麦克风录音,却声称开启了 AEC。

这种情况下真正能工作的主要还是:

NS
AGC
其他语音增强

完整 AEC 应该有扬声器参考信号。


2. VAD删除静音以后破坏原时间轴

不要只保存处理后的:

segment.wav

一定同时保存:

original_start
original_end

否则后面的 diarization 和原音回听很难重新对齐。


3. Sortformer的Speaker 0不等于某个真实姓名

Sortformer解决的是:

speaker_0
speaker_1
speaker_2

而不是:

张主任
李工
王经理

真正做实名身份仍然需要额外的身份绑定机制。

不要把 diarization 和 speaker identification 混为一谈。


4. ForcedAligner不是第二套ASR

Forced Aligner需要:

音频
+
已经存在的文本

输入。

它的工作不是重新判断“说了什么”,而是判断:

这些文字究竟落在音频什么位置。

所以正常顺序应该是:

ASR
 ↓
获得文本
 ↓
Forced Alignment
 ↓
获得精确时间

而不是反过来。


十八、真正值得测试的不是一个“准确率”

这条链路上线前,最好按模块分别验收。

模块 建议指标
APM 回声残留、噪声抑制、人声失真
VAD 漏检、误检、语音边界
ASR CER/WER、专业词、方言
Sortformer DER、Speaker混淆、重叠语音
ForcedAligner 时间戳偏差
LLM 决策遗漏、待办错误、虚构内容
整链路 RTF、失败率、GPU峰值、长会议稳定性

特别是会议系统,不建议只测:

安静环境
+
一个人
+
普通话
+
5分钟

至少应该加入:

空调噪声

远场麦克风

会议大屏外放

中英文专业词

两人抢话

四人会议

一小时以上长会议

最终测试的是:

整套会议链路在真实环境里是否稳定。

而不是某一个模型在标准测试集上的单项分数。


十九、总结:提升会议识别效果,第一步未必是换ASR

重新看整条路线:

WebRTC APM
↓
让声音更干净

Silero VAD
↓
判断哪里真正有人讲话

Qwen3-ASR
↓
识别说了什么

NeMo Sortformer
↓
判断谁在什么时候说

Qwen3-ForcedAligner
↓
把文字重新压到精确时间轴

本地LLM
↓
提取摘要、决策和待办

会议系统
↓
负责回听、归档、检索和业务管理

其中任何一个模块都无法单独构成 AI 会议助手。

但每一层把自己的问题解决好以后,后面的模型反而会更容易工作。

WebRTC APM减少前端回声和环境噪声,Silero VAD避免大量无效音频进入推理,Qwen3-ASR专注转写,Sortformer专注多人发言关系,ForcedAligner负责精确时间轴,本地大模型最后再处理结构化信息。

对于熙瑾会悟这样的会议处理链路来说,这种模块化方式的意义也正在这里:底层模型可以不断更新,但会议、纪要、待办、回听和归档这些业务结构不需要随着某一个模型的变化而重新设计。

所以会议室识别效果不好时,与其第一时间问:

“是不是该换一个更大的ASR?”

不如先检查:

进入ASR之前的声音,到底处理好了吗?

很多时候,真正影响最终会议质量的,恰恰是模型之前那几层。

posted @ 2026-08-24 12:06  yellowzh  阅读(1)  评论(0)    收藏  举报