# 实时 ASR WebSocket 处理细节对照 本文只整理当前代码,不修改 WebSocket 或说话人算法。对照对象是: - 当前独立 Demo:`demo/realtime_asr_optimization_demo` - 原项目实时接口:`app/api/v1/websocket_asr.py`、`app/services/qwen3_websocket_asr.py`、`app/services/realtime_speaker_clusterer.py` 代码是本文的依据;旧的排查记录或早期说明如果与当前实现冲突,以代码为准。 ## 1. 先看整体差异 ```mermaid sequenceDiagram participant B as 浏览器 participant D as 当前 Demo /ws participant V as 独立 vLLM HTTP participant A as 独立辅助服务 B->>D: start(扁平字段) B->>D: PCM/WAV 二进制帧 D->>D: 20ms RMS 门控、turn 缓冲、静音切段 D->>V: 当前 turn 的累积 WAV(partial/final) V-->>D: 文本 D-->>B: sentences + display_state D->>A: final turn + session_id A-->>D: CAM++ embedding / 在线聚类标签 D-->>B: 同 sentence_id 的 speaker 更新 + 新 display_state B->>D: eof 或 stop D->>D: 等待音频队列和 speaker 队列清空 D-->>B: end ``` ```mermaid sequenceDiagram participant B as 浏览器 participant R as 原项目 FastAPI 路由 participant Q as Qwen3ASRService participant E as Qwen3ASREngine participant S as RealtimeSpeakerClusterer B->>R: /ws/v1/asr 或 /ws/v1/asr/qwen R->>Q: handle_connection B->>Q: start.payload(嵌套字段) Q-->>B: voice_id、start B->>Q: PCM/WAV 二进制帧 Q->>Q: 转采样、VAD、pre-roll、partial Q->>E: 原生流式或当前窗口重转写 E-->>Q: partial 文本 Q-->>B: sentence_type=0 Q->>E: 当前 turn 全量 final 重转写 E-->>Q: final 文本 Q-->>B: sentence_type=1、speaker_id=-1 Q->>S: speaker_job_queue(异步) S-->>Q: 聚类/注册库匹配 Q-->>B: 相同 sentence_id 的 speaker 回写 B->>Q: stop Q-->>B: end(stop 内部会再次 final/recluster) ``` 核心设计差别是:当前 Demo 把 ASR 和声纹都放在独立 HTTP 服务后面,WebSocket 只做编排;原项目把 Qwen ASR 引擎、VAD、在线声纹聚类放在同一个服务进程里,但 speaker 归属仍是 final 之后异步补回。 ## 2. 当前独立 Demo 的处理链路 ### 2.1 进程和启动关系 `server.py` 启动一个 aiohttp HTTP/WebSocket 进程,并在生命周期中创建两个 HTTP 客户端: | 组件 | 默认地址 | 职责 | | --- | --- | --- | | `VLLMTranscriptionService` | `http://127.0.0.1:9950/v1` | 只调用 `/audio/transcriptions`,partial 和 final 都是累积窗口 HTTP 请求 | | `AuxiliaryModelService` | `http://127.0.0.1:8010` | 调用 `/health`、`/v1/speaker/resolve`、`/v1/speaker/reset` | | `RealtimeSession` | `server.py:/ws` | 接收音频、VAD 切 turn、调用两个服务、维护展示状态 | 辅助服务在 `demo/scripts/auxiliary_server.py` 中预加载 VAD 与 CAM++ `speaker_verification`。每次 resolve 只上传一个已结束 turn,服务以 `session_id` 保存在线聚类中心;这不是把整段会议音频重新上传。 ### 2.2 WebSocket 输入和输出 客户端首条消息是扁平结构: ```json { "type": "start", "source": "mic", "model_service_url": "http://127.0.0.1:9950/v1", "model": "Qwen/Qwen3-ASR-0.6B", "speaker_diarization": 1, "sentence_strategy": 0, "partial_interval_ms": 1200, "max_segment_sec": 12, "display_merge": true } ``` `start` 成功后,客户端发送 16kHz、单声道、PCM16 二进制帧。文件模式只接收 `.pcm` 和 `.wav`;WAV 的 RIFF/fmt/data chunk 在 WebSocket 服务端增量剥离,并且要求 16kHz、单声道、PCM16。 控制消息: | 消息 | 行为 | | --- | --- | | `eof` | 输入生产者结束;服务端把 `EOF` 放入音频队列,完成尾部 turn 和 speaker 队列后发送 `end` | | `stop` | 与 `eof` 走同一排空流程,同时记录 `input_stopped`,用于 WAV 不完整时的校验差异 | | `abort` | 取消音频和 speaker worker,直接结束,不保证当前 turn 有 final | 服务端消息的实际顺序通常是: ```text start -> sentences(partial,可能多次) -> display_state(每次状态改变一份快照) -> sentences(final,speaker 尚未确认) -> display_state(pending) -> display_state(processing/confirmed 或失败原因) -> draining -> end ``` `sentences` 是兼容性事件;`display_state` 是当前页面的主要渲染数据。`display_state.revision` 单调递增,包含: - `raw_segments`:按 `start_time`、`sentence_id` 排序的原始片段 - `display_blocks`:按相邻且可信的说话人合并后的展示块 - `metrics`:音频字节数、输入帧数、partial 数量、partial 修订次数和耗时 ### 2.3 音频、VAD 和 turn 边界 `RealtimeSession.process_audio()` 的边界是本 Demo 最重要的状态机: 1. 二进制数据进入 `audio_queue`,再拆成 640 bytes 的 PCM 帧,即 20ms。 2. 每帧用 RMS 阈值 `450` 判断有声/静音;这是 WebSocket 层的轻量门控,不是辅助服务的整段 VAD pipeline。 3. 未进入说话状态时,保留最近 6400 bytes(约 200ms)`pre_roll`。 4. 第一帧有声时,将 pre-roll 加到新 `segment_audio`,设置 `segment_start_ms`。 5. 进入说话状态后,所有帧追加到当前 turn;有声帧累计 `voiced_ms`,静音帧累计 `silence_ms`。 6. `sentence_strategy=0` 默认约 800ms 静音提交;`sentence_strategy=1` 使用约 1400ms 静音提交。 7. 达到 `partial_interval_ms`(默认 1200ms)且尚未达到静音阈值时,调用一次 vLLM partial。 8. 达到 `max_segment_sec`(默认 12s)时按 `max_duration` 提交。 当前 Demo 不把切段尾部静音送给 ASR/声纹:提交前按 `silence_ms` 从 `segment_audio` 尾部删除。下一个 turn 没有原项目那样的 `carry_audio`,而是从后续有声帧重新开始;因此两段之间的静音会形成时间间隔,不会自动带入下一段。 ### 2.4 ASR 结果和同句覆盖 `_emit_transcription()` 始终使用当前 `segment_id`: - `sentence_type=0`:partial,写入/覆盖同一个 `sentence_id` - `sentence_type=1`:final,仍写入同一个 `sentence_id`,并加入 speaker 队列 `SegmentAssembler.apply_sentence()` 先按 `sentence_id` 找旧记录再覆盖;如果已经是 final,后来的 partial 不会回滚 final。final 没有文本时,会移除该片段,避免遗留一个永久 pending 的 partial。 ### 2.5 声纹异步链路 `_commit_segment()` 只负责把 `SpeakerJob` 放入 `speaker_queue`,不会等待 CAM++。`process_speakers()` 是单 worker,按 turn 入队顺序串行调用辅助服务: 1. `voiced_ms < 800ms`:不提取声纹,写入 `insufficient_audio`,保持 `speaker_id=-1`。 2. 辅助服务缺失或异常:写入 `service_unavailable`/`service_error`,ASR 继续输出。 3. 辅助服务返回 embedding/聚类结果:回写同一 `sentence_id`。 4. `SegmentAssembler.apply_speaker_update()` 只接受可信身份:`speaker_evidence` 必须是 `fresh` 或 `confirmed`,置信度至少 `0.6`,并拒绝 `short_attach`、`embedding_attach`。 5. 不可信结果被归一化为 `speaker_id=-1`、`speaker_name=""`,但保留状态和原因供诊断。 辅助服务的在线聚类是简单的 session 级中心匹配:首次 embedding 新建 `speaker_id`,之后与已有中心的余弦相似度达到阈值就更新中心并复用 ID。`/v1/speaker/reset` 在 WebSocket 结束时清理该 session。 ### 2.6 展示块如何合并 `SegmentAssembler.display_blocks(merge_adjacent=True)` 先按时间排序原始片段,然后遵循: - pending/unknown 片段始终以自己的 `sentence_id` 作为身份键,独立成块; - 只有相邻且可信的片段,且 `user_id`/`registry_speaker_id`/`speaker_id` 身份键相同,才合并; - 合并只拼接文本、扩大结束时间并追加 `segment_ids`,原始片段仍保留在 `raw_segments`。 当前页面收到 `display_state` 后会整块重建结果区,按 `block_id` 渲染;收到 `sentences` 时如果服务端声明支持 `display_state`,页面不会再次追加,避免同一片段重复显示。旧页面或绕过 `display_state` 的客户端不具备这个保护。 ## 3. 原项目 WebSocket 的处理链路 ### 3.1 路由和状态 `app/api/v1/websocket_asr.py` 当前实际路由: - `/ws/v1/asr` - `/ws/v1/asr/qwen` - `/ws/v1/asr/funasr` 已废弃,接受后发送 `FUNASR_REALTIME_REMOVED` 并以 1008 关闭 每个连接进入 `Qwen3ASRService.handle_connection()`。`ConnectionContext.state` 为 `READY -> STARTED -> STREAMING`;会话还可以通过 `session_id` 在 TTL 内断线恢复。恢复的是完整上下文,包括已确认片段、speaker history、时间线和待处理状态,不只是一个 WebSocket ID。 ### 3.2 start 参数 原项目的 `start` 使用 `payload` 嵌套对象。常用字段包括: | 类别 | 字段 | | --- | --- | | 音频 | `format`、`sample_rate`、`language`、`context`、`enable_inverse_text_normalization` | | partial | `min_partial_sec`、`partial_window_sec`、`partial_holdback_chars`、`unfixed_token_num`、`enable_native_partial_stream` | | 切段 | `silence_duration_ms`(默认 800)、`pre_roll_ms`(默认 240)、`max_sentence_count`(默认 8)、`enable_realtime_vad_split`、`max_segment_sec` | | speaker | `enable_speaker`(默认 true)、`match_speaker_registry`、`speaker_threshold` | | 稳定性 | `force_stable_segment_sec`、`force_stable_min_chars`、`soft_limit_sec`、`hard_limit_sec` | 服务端先发送 `voice_id`,再发送 `start`。`voice_id` 与会话 ID 通常相同;如果客户端传入固定 `payload.session_id`,断线重连时可以复用上下文。 ### 3.3 音频转换、pre-roll 和 partial `_convert_audio()` 接收 PCM/WAV,转为 float32;多声道下混为单声道,非 16kHz 用 scipy 重采样。每个二进制消息都在服务端转换后立即参与 VAD。 原项目的 `ConnectionContext` 同时维护: - `pre_roll_audio`:未开始说话前的前滚音频,默认约 240ms; - `segment_audio_buffer`:从当前 turn 开始到提交前的完整音频; - `stream_window_buffer`:最近窗口,用于 partial 或 native stream 失败时回退; - `realtime_stream_state`:只服务低延迟 partial,不决定 final; - `silence_samples`、`sentence_active`、`total_samples`:VAD 状态和当前 turn 长度。 有声输入到达时,`_start_turn()` 把 pre-roll 与当前音频拼接;后续由 `_append_turn_audio()` 追加。达到最短窗口后,服务端可走原生 Qwen partial,或对当前窗口/当前 turn 重转写;partial 会经过清理、去重、与上一段重叠裁剪后发送。 提交触发条件不只有静音: - 识别到足够完整的标点句,且时长/字数达到稳定门槛; - 句子数达到 `max_sentence_count`; - 静音样本达到 `silence_duration_ms`; - 达到硬时长限制;开启实时 VAD split 时会尝试找一个完成的分割点。 ### 3.4 final、carry 和时间线 `_commit_retranscribe_turn()` 对当前完整 turn 做一次 final 重转写,生成 `confirmed_segments` 元素: ```text index / text / language / reason duration_ms / start_ms / end_ms sentence_type=1 / speaker_id=-1 / speaker_pending=true ``` final 事件先发送,speaker 之后再补。默认 final 的 `start_ms` 来自 `ctx.timeline_cursor_ms`;提交后时间线前移到 `segment_end_ms`。 开启实时 VAD split 时,提交可能得到 `finalized_audio + carry_audio`:前半段定稿,后半段留在下一个逻辑 turn 中,且会重新初始化 stream 状态。这个 carry 是原项目与当前 Demo 的一个实质差异,也是跨说话人边界时必须重点观察的音频来源。 ### 3.5 原项目 speaker worker 和聚类 原项目 final 后把 job 放入 `ctx.speaker_job_queue`,由 `_speaker_worker_loop()` 串行消费。`_resolve_segment_speaker()` 调用 `RealtimeSpeakerClusterer.resolve_segment_speaker()`,再把结果写入指定 `segment_index`,通过相同索引发送一条新的 `sentences`。 `RealtimeSpeakerClusterer` 当前行为: - 1.6s 以下且上一条有命名身份:使用 `short_attach` 直接沿用上一条; - 正常 turn:按 1.5s 窗口、0.75s 步长提取 CAM++ embedding,匹配已有记录或新建 generic speaker; - 4s 以上若 chunk 明显混合:返回 `mixed_segment`,保持未知; - 开启注册库匹配且时长至少 2.4s:在独立注册 embedding 空间匹配实名; - timeline 平滑时,短于 0.7s 的范围会并给相邻说话人; - 实时记录达到至少 5 条且队列积压不超过 1 条时,可能对最近 12 条 pending 片段重新聚类; - `stop` 时还会对历史记录做一次最终 recluster。 此外,`Qwen3ASRService` 自身还有两类“最近说话人继承”:generic speaker 新 turn 时长至少 8s 才允许 `recent_inherit`,实名 speaker 至少 4.5s 才允许 `recent_named_inherit`。这些继承都发生在 embedding 结果之后,不能与 `short_attach` 混为一谈。 ### 3.6 stop 和 end 客户端只发送 `{"type":"stop"}`。服务端会: 1. 对仍 active 的 `segment_audio_buffer` 做 `reason=final` 的 final 提交; 2. 立即对现有 `speaker_records` 做最终 recluster,并发送可能的 speaker 更新; 3. 汇总 `confirmed_segments`,发送 `end(final=1)`。 这里与当前 Demo 不同:原项目的 `_stop()` 没有显式等待 `speaker_job_queue.join()`。如果 stop 到达时 speaker worker 仍在处理,最终 recluster/end 可能先于某个异步 speaker 回写;断开清理还会停止 worker。客户端必须把同 `sentence_id` 的后续 speaker 事件当作可迟到更新,而不能认为 `end` 之后绝不会再有归属变化。 ## 4. 两套消息契约对照 | 维度 | 当前独立 Demo | 原项目 | | --- | --- | --- | | WebSocket | aiohttp `/ws` | FastAPI `/ws/v1/asr`、`/qwen` | | start | 扁平字段 | `payload` 嵌套字段 | | ASR | 外部 vLLM HTTP 累积窗口 | 进程内 Qwen engine,原生 stream 或重转写回退 | | VAD | 20ms PCM RMS 门控 | float32 音频门控,支持实时 VAD split 辅助切分 | | pre-roll | 固定约 200ms | `pre_roll_ms` 默认约 240ms | | final 音频 | 删除提交尾部静音,不保留 carry | 可有 `carry_audio` 并带入后续 turn | | speaker | 外部 CAM++/在线中心服务,单 worker | 进程内 CAM++ chunk 聚类、注册库、重聚类,单 worker | | 未确认 speaker | `speaker_evidence=pending`,展示独立未知块 | `speaker_id=-1` 或 `speaker_pending=true`,客户端需自行暂存 | | 文本更新键 | `sentence_id` | `sentence_id` 对外,内部 `segment_index` | | 展示快照 | `display_state.revision`,服务端生成 `display_blocks` | 没有同等的服务端展示块协议,客户端按 sentence upsert | | end 屏障 | `EOF -> audio worker -> speaker EOF -> end` | `_stop()` final/recluster/end,不等待 speaker 队列清空 | ## 5. “上一人的最后一句进入下一人气泡”的定位框架 当前先不修改,定位时要把“片段本身错了”和“片段正确但展示合错了”分开。 ### 5.1 当前独立 Demo 的可能路径 1. **同一个 turn**:两人换话之间没有达到 800ms(或段落模式 1400ms)静音,RMS VAD 不切段。此时 vLLM 收到的是混合 turn,前端只有一个 `sentence_id`,不是气泡合并问题。 2. **声纹误归属**:A 的 final 先是 pending,随后辅助服务把 A 误匹配到 B 的 cluster。下一次 `display_state` 中,A、B 两个相邻片段拥有同一可信身份,`display_blocks()` 会把两者拼成一个 block。 3. **异步回写改变了合并条件**:A 的 speaker 结果可能在 B 的 final 之后才到达。服务端按时间排序重建快照,所以视觉上是 A 的文字“后来进入”B 的气泡;实际是 A 的旧片段身份被补齐后触发了相邻合并。 4. **旧客户端渲染路径**:当前页面在 `display_state_supported=true` 时忽略 `sentences`,但旧页面若逐条 append `sentences`,可能把同一 `sentence_id` 的 final/speaker 更新当成新气泡,或把 pending 文本追加到上一气泡。必须确认浏览器加载的 `app.js` 版本和服务端返回的 `display_state`。 5. **session 污染**:辅助服务按 `session_id` 保存聚类中心。若 reset 没有执行、多个连接错误复用同一个 session ID,上一场会话的 cluster 可能影响新会话;正常一次连接内 A/B 共用中心是设计行为,不是跨人合并的充分证据。 当前 Demo 的 worker 是串行的,`emit()` 有发送锁,因而“并发返回顺序打乱”不是首要嫌疑;首要证据应是 `raw_segments` 的 `sentence_id/start_time/speaker_id/speaker_strategy` 是否正确,以及 `display_blocks.segment_ids` 是否把两个片段合到一起。 ### 5.2 原项目的可能路径 1. **静音边界不足**:默认 800ms 静音才提交;换话前的短停顿会让 A 尾部和 B 开头留在同一个 `segment_audio_buffer`。 2. **carry 音频污染**:启用实时 VAD split 时,分割点之后的 `carry_audio` 会成为下一个 turn 的开头。若 split 点落在 A 尾音或 B 起音中间,下一段声纹和 ASR 都会携带前一人尾部。 3. **短句沿用上一身份**:B 的新段小于 1.6s 且历史有命名 speaker 时,`short_attach` 会直接复用上一条;chunk 提取为空时的 `embedding_attach` 也可能复用上一条。这是代码中最直接的“上一人污染下一段”路径。 4. **最近身份继承**:较长的新段在匹配失败时可能触发 `recent_named_inherit`(至少 4.5s)或 `recent_inherit`(至少 8s),因此不能只看最终 `speaker_id`,还要记录 `speaker_strategy`。 5. **重聚类改写历史**:实时重聚类和 stop 最终重聚类都可能改写已有 segment 的 speaker。客户端如果按到达顺序追加,而不是按 `sentence_id` upsert,就会看到旧气泡和新气泡互相覆盖或合并。 6. **stop 竞态**:原项目 stop 不等待 speaker job 队列清空;end 可能先发,随后连接清理还会取消 worker。最后一个人的 speaker 归属可能缺失、迟到或停留在旧标签,前端若把 end 当成不可变快照会放大问题。 ### 5.3 需要同时保存的证据 对同一段测试音频,至少保存以下三层结果: ```text 音频层:每个 turn 的 start/end、有效有声时长、是否包含 carry/pre-roll 识别层:sentence_id/index、sentence_type、文本、speaker_strategy、置信度 展示层:display_state.revision、raw_segments、display_blocks.segment_ids ``` 判定规则: - `raw_segments` 已经只有一个片段:先查 VAD/切段边界; - `raw_segments` 有 A、B 两段且 speaker ID 相同:查声纹误匹配/继承/重聚类; - `raw_segments` 的 ID 不同但 `display_blocks.segment_ids` 合并:查展示合并键; - `display_blocks` 正确但页面仍显示一只气泡:查浏览器脚本版本、是否绕过 `display_state`、是否按 `sentence_id` upsert。 ## 6. 后续细节优化的优先级(本轮不实施) ### P0:先证明边界和身份是否正确 - 记录每个 final 的实际音频起止、有效有声毫秒、pre-roll/carry 长度。 - 记录 speaker resolve 请求和返回的 `session_id`、策略、置信度、cluster ID。 - 前端临时展示 `block.segment_ids`,确认“合并”到底是两个片段还是一个片段。 - 用固定 A-静音-B 音频,比较 200ms、500ms、800ms、1400ms 停顿。 ### P1:降低错误身份传播 - 对 `short_attach`、`embedding_attach`、recent inherit 单独统计,不要只统计 speaker_id。 - 对跨边界的短 turn 保持 pending,等到有独立 embedding 或后续重聚类再确认。 - 明确实时重聚类和最终重聚类的可修改范围,客户端统一按 ID 幂等更新。 - 为 stop 增加“最后一个 speaker job 已完成”的可观察状态。 ### P2:改善展示稳定性 - 展示层只把可信且相邻的片段合并,保留 segment_ids 和 revision。 - 对 speaker 更新做局部重绘或整快照重绘,但不要把同一个 sentence 当成新消息追加。 - unknown/pending 使用独立块,不把诊断文本放在说话人名称中。 ## 7. 建议的验收用例 | 用例 | 观察点 | 通过标准 | | --- | --- | --- | | A 说 3s,停 1s,B 说 3s | 两套服务的 raw segment | 至少两个不同 sentence_id,时间不重叠 | | A 说 3s,停 300ms,B 说 3s | VAD 边界 | 明确记录为同段或分段,不能只看气泡颜色判断 | | A 说 3s,B 只说 0.8s | 短 turn speaker 策略 | 原项目应能观察 `short_attach`;Demo 应保持 pending 或独立结果 | | A/B 各说多段,speaker 服务延迟 2s | 异步回写 | 文本不重复,更新按 sentence_id 定位,顺序按时间恢复 | | speaker 服务不可用 | 降级 | ASR 仍有 final,speaker 为未知并有明确 reason | | stop 紧跟最后一帧 | 收尾屏障 | Demo 的 end 在 speaker 队列完成后发送;原项目记录可能迟到的 speaker 更新 | | 断线后同 session_id 重连 | 会话隔离 | 原项目按 TTL 恢复;Demo 新连接不会复用旧 speaker center | ## 8. 源码索引 ### 当前独立 Demo - `demo/realtime_asr_optimization_demo/server.py` - `RealtimeSession.__init__`:会话参数、队列和 VAD 状态 - `emit_state`:`raw_segments/display_blocks/revision` - `_emit_transcription`:partial/final 写入同一 `sentence_id` - `_commit_segment`、`process_audio`:VAD、切段和 speaker job 入队 - `_resolve_speaker`、`process_speakers`:异步声纹回写 - `websocket_handler`:start、二进制帧、eof/stop/abort、end - `demo/realtime_asr_optimization_demo/speaker_assembler.py` - `apply_sentence`、`apply_speaker_update`、`display_blocks` - `demo/realtime_asr_optimization_demo/model_service.py` - 独立 vLLM OpenAI-compatible HTTP 适配 - `demo/realtime_asr_optimization_demo/auxiliary_service.py` - `/health`、`/v1/speaker/resolve`、`/v1/speaker/reset` 客户端适配 - `demo/scripts/auxiliary_server.py` - VAD/CAM++ 预加载、embedding 提取、session 级在线聚类 ### 原项目 - `app/api/v1/websocket_asr.py` - `/ws/v1/asr`、`/ws/v1/asr/qwen`、废弃 `/funasr` - `app/services/qwen3_websocket_asr.py` - `ConnectionContext`:音频、partial、confirmed segments、speaker 队列 - `handle_connection`:WebSocket 状态机和消息协议 - `_commit_retranscribe_turn`:final、carry、timeline、speaker job - `_speaker_worker_loop`、`_resolve_and_emit_segment_speaker`:异步 speaker 回写 - `_inherit_recent_*`、`_maybe_recluster_recent_segments`:身份传播和重聚类 - `_stop`:最终提交、recluster、end - `app/services/realtime_speaker_clusterer.py` - chunk embedding、短段沿用、已有 speaker 匹配、混合段、timeline 平滑 - `docs/realtime_meeting_websocket.md` - 对外协议示例;其中 partial/final/speaker 回写必须按 `sentence_id` 幂等处理 本轮只新增本文档,没有修改上述实现。后续修复应先用第 5 节的三层证据确定问题属于切段、声纹还是展示层,再决定改哪一层。