test/docs/REALTIME_WEBSOCKET_PROCESSI...

23 KiB
Raw Blame History

实时 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. 先看整体差异

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
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 输入和输出

客户端首条消息是扁平结构:

{
  "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

服务端消息的实际顺序通常是:

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 元素:

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 需要同时保存的证据

对同一段测试音频,至少保存以下三层结果:

音频层:每个 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 节的三层证据确定问题属于切段、声纹还是展示层,再决定改哪一层。