# 实时会议识别改造报告 ## 1. 背景 当前 `Qwen-Asr` 的实时会议链路,已经暴露出 3 类系统性问题: 1. 长句在 `max_duration` 或静音点附近容易被截坏,出现残句、半句、尾词漂移。 2. `partial` 与 `final segment` 责任混杂,静音、串音、背景音、测试语种会直接污染最终结果。 3. 说话人识别仍以“句级单 embedding + 会话聚类”为主,面对插话、背景音、儿童声音、英语/泰语测试时容易抖动。 这些问题并不是单个阈值导致的,而是实时链路的职责分层不清晰。 ## 2. 外部方案调研结论 本次调研覆盖了: - vLLM / Qwen3-ASR 官方实时文档 - `diart` - `pyannote.audio` - NVIDIA NeMo `Streaming Sortformer` - 在线 speaker diarization / label matching 论文 - streaming ASR partial stability / endpointing 论文 ### 2.1 vLLM/Qwen3-ASR 的边界 vLLM 的 `Qwen3-ASR realtime` 更像“流式推理后端”,它负责: - 累积音频 - 到固定块长后执行流式推理 - 提供 `flush/finish` 它不负责: - 会议场景 endpointing - speaker diarization - partial 稳定化 - 说话人标签稳定跟踪 结论: `vLLM 只能做实时 ASR 的第一层,不是完整会议链路的解决方案。` ### 2.2 开源实时 speaker diarization 的主流范式 #### A. `diart` 范式 特征: - rolling buffer - overlap-aware segmentation - incremental clustering - cannot-link constraints - 低延迟滚动更新 优点: - 工程上相对容易接入 - 很适合作为现有系统的 speaker 旁路 #### B. `Streaming Sortformer` 范式 特征: - 真正 streaming diarization - chunk-based processing - speaker cache / AOSC - 跨 chunk 标签稳定 优点: - 更像现代工业方案 - 标签稳定性更强 缺点: - 接入成本高于 `diart` ### 2.3 论文结论 #### 在线 diarization 的关键问题不是“分离”,而是“标签一致性” 主流论文强调: - 在线 diarization 必须解决 label matching - 否则 `Speaker01/02/03` 会在不同 chunk 间乱跳 #### streaming ASR 的关键问题不是“能不能出字”,而是“partial 稳定性” 论文结论: - partial 会被不断修正 - partial 不应直接当 final 使用 - 需要单独设计稳定策略 #### endpointing 对长句质量至关重要 论文与开源实现都说明: - 只靠静音阈值不够 - 最好使用 VAD/SAD 或模型辅助 endpointing - `max_duration` 只能是兜底机制,不应成为主要切句方式 ## 3. 当前项目存在的核心架构问题 ### 3.1 `partial` 和 `final` 混线 当前链路中: - `partial` 的输出直接影响最终句子定稿 - 静音前后、噪声和串音有机会直接污染最终句子 这会带来: - 静音幻觉 - 末尾漂移 - 错误残句 ### 3.2 断句主要靠“能量阈值 + 最长时长” 当前主逻辑仍然偏向: - `self._has_voice(audio)` 做粗能量判定 - `silence_samples` 达阈值就截断 - `12s max_duration` 强行保底 这会导致: - 长句被硬切 - 断句点不自然 - 下一段拿到的是残尾巴 ### 3.3 speaker 仍然是“句级单 embedding” 当前 speaker 的主要依据是: - 当前句或其裁剪后的音频 - 提一个 embedding - 与会话内 slot 聚类 这对干净单人句子有效,但对会议场景不够: - 一句里可能混多人 - 背景音会污染 embedding - 不同 chunk 之间缺少真正的 diarization cache ### 3.4 registry match 介入过早 当前一旦句级聚类通过阈值,就可能直接映射实名。 这会带来: - 错误聚类被直接升级为错实名 - 后续纠正空间变小 ## 4. 推荐的目标架构 建议将实时会议链路拆成 4 层: ### Layer A: Streaming ASR Partial 职责: - 面向 UI 输出中间文本 - 只作为“参考内容” - 不入库 - 不参与 speaker - 不做实名映射 要求: - 可以允许回退、修正 - 需要稳定策略,但不要求和 final 完全一致 ### Layer B: Endpointing / Final Segment Flush 职责: - 决定“什么时候一句真正结束” - 产出 final segment 推荐方式: - `pending_buffer` - 周期性 VAD/SAD 检查 - 只刷出“已完成”的语音片段 - 最后一段继续留 buffer,等待更多上下文 说明: - `max_duration` 仍保留 - 但只作为异常保底 ### Layer C: Online Speaker Tracking 职责: - 产出稳定 `Speaker01/02/03/...` - 不直接输出实名 推荐路线: - 低成本版:`rolling window diarization + incremental clustering` - 强化版:`streaming diarization + speaker cache` 必要能力: - label matching - speaker cache - cluster centroid 更新 - recent speaker continuity ### Layer D: Speaker Registry Match 职责: - 把稳定的 slot 映射成实名 原则: - 先有稳定 `Speaker01/02/03` - 再做 registry match - 不要在每个短句上直接实名匹配 ## 5. 对当前代码库的具体落地建议 ### 5.1 保留 `Qwen3ASREngine` 作为流式 ASR 后端 文件: - `app/services/asr/qwen3_engine.py` 建议: - 继续让它只负责 `init_streaming_state / streaming_transcribe / finish_streaming_transcribe` - 不再让它承担句边界和 speaker 逻辑 ### 5.2 重构 `qwen3_websocket_asr.py` 文件: - `app/services/qwen3_websocket_asr.py` 建议把它拆成以下内部组件: 1. `PartialStreamController` - 负责把有声 chunk 喂给流式 ASR - 维护 partial 状态 2. `RealtimeEndpointController` - 维护 `pending_buffer` - 周期性 VAD flush - 产出 finalized audio span 3. `RealtimeSpeakerController` - 对 finalized span 做 speaker tracking - 维护 `Speaker01/02/...` 4. `RegistryMatchController` - 在 slot 稳定后映射实名 ### 5.3 speaker tracker 升级方向 当前文件: - `app/services/realtime_speaker_tracker.py` 建议保留它,但升级为: - slot centroid - slot history - recent speaker cache - explicit label matching step - “未知 slot” 与 “实名 slot” 分层 不建议继续只用: - `assign(embedding)` 的一次性聚类结果 ### 5.4 引入真正的 VAD completed-segment flush 当前项目最应该借鉴老项目的部分就是: - `pending_buffer` - `flush_completed_segments` - 只把已完成语音刷成 final 建议新建模块,例如: - `app/services/realtime_endpointing.py` 它负责: - 累积 PCM - 每隔固定步长跑 VAD - 判断哪些片段已完成 - 返回 `finalized_audio` 与 `remaining_audio` ### 5.5 `max_duration` 的正确角色 建议保留,但只作为: - buffer 过长 - 长时间不出句 - VAD 失效 时的保底。 不建议让它继续承担: - 常规切句 - 主要 final 机制 ## 6. 推荐改造路线 ### 阶段 1:先把文本链路拆干净 目标: - `partial` 只显示 - `final segment` 只由 endpointing 决定 工作项: - 引入 `pending_buffer + VAD flush` - 保留现有 websocket 接口字段不变 - `max_duration` 降级为兜底机制 ### 阶段 2:speaker 从句级 embedding 升级为在线 tracking 目标: - 稳定 `Speaker01/02/03` 工作项: - 引入 rolling window diarization 或 speaker turn 检测 - 增加 label matching - 增加 speaker cache ### 阶段 3:实名映射后移 目标: - 先稳定 slot - 再实名 工作项: - registry match 不再逐句触发 - 改成基于 slot centroid 或稳定窗口触发 ### 阶段 4:partial 稳定策略 目标: - UI 不再频繁抖动 工作项: - prefix commit - suffix freeze - partial stability score ## 7. 不建议继续做的事 以下方向收益很低,且会继续增加系统复杂度: - 继续堆更多 `if suspicious` - 继续调 `speaker_threshold` - 继续靠 `max_duration` 修长句 - 继续用句级单 embedding 解决多人会议 speaker - 继续让 `partial` 直接影响 final ## 8. 结论 当前项目最需要的不是继续补条件分支,而是把实时会议能力拆成明确的四层: 1. `streaming partial` 2. `endpointing/final flush` 3. `online diarization / speaker tracking` 4. `registry match` 其中: - `vLLM` 属于第 1 层 - 不是第 2、3、4 层的替代品 如果后续继续在当前单文件链路上修修补补,复杂度会继续升高,稳定性仍然不可控。 如果按本报告做分层重构,问题会从“靠猜修 bug”变成“按职责逐层验证”。 ## 9. 参考资料 - vLLM realtime Qwen3-ASR model docs - vLLM Qwen3-ASR recipe - diart - pyannote.audio - NVIDIA NeMo speaker diarization docs - NVIDIA Streaming Sortformer blog - Low-Latency Online Speaker Diarization with Graph-Based Label Generation - TURN-TO-DIARIZE: Online Speaker Diarization Constrained by Transformer Transducer Speaker Turn Detection - Analyzing the Quality and Stability of a Streaming End-to-End On-Device Speech Recognizer - Improving endpoint detection in end-to-end streaming ASR for conversational speech