23 KiB
实时 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 最重要的状态机:
- 二进制数据进入
audio_queue,再拆成 640 bytes 的 PCM 帧,即 20ms。 - 每帧用 RMS 阈值
450判断有声/静音;这是 WebSocket 层的轻量门控,不是辅助服务的整段 VAD pipeline。 - 未进入说话状态时,保留最近 6400 bytes(约 200ms)
pre_roll。 - 第一帧有声时,将 pre-roll 加到新
segment_audio,设置segment_start_ms。 - 进入说话状态后,所有帧追加到当前 turn;有声帧累计
voiced_ms,静音帧累计silence_ms。 sentence_strategy=0默认约 800ms 静音提交;sentence_strategy=1使用约 1400ms 静音提交。- 达到
partial_interval_ms(默认 1200ms)且尚未达到静音阈值时,调用一次 vLLM partial。 - 达到
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_idsentence_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 入队顺序串行调用辅助服务:
voiced_ms < 800ms:不提取声纹,写入insufficient_audio,保持speaker_id=-1。- 辅助服务缺失或异常:写入
service_unavailable/service_error,ASR 继续输出。 - 辅助服务返回 embedding/聚类结果:回写同一
sentence_id。 SegmentAssembler.apply_speaker_update()只接受可信身份:speaker_evidence必须是fresh或confirmed,置信度至少0.6,并拒绝short_attach、embedding_attach。- 不可信结果被归一化为
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"}。服务端会:
- 对仍 active 的
segment_audio_buffer做reason=final的 final 提交; - 立即对现有
speaker_records做最终 recluster,并发送可能的 speaker 更新; - 汇总
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 的可能路径
- 同一个 turn:两人换话之间没有达到 800ms(或段落模式 1400ms)静音,RMS VAD 不切段。此时 vLLM 收到的是混合 turn,前端只有一个
sentence_id,不是气泡合并问题。 - 声纹误归属:A 的 final 先是 pending,随后辅助服务把 A 误匹配到 B 的 cluster。下一次
display_state中,A、B 两个相邻片段拥有同一可信身份,display_blocks()会把两者拼成一个 block。 - 异步回写改变了合并条件:A 的 speaker 结果可能在 B 的 final 之后才到达。服务端按时间排序重建快照,所以视觉上是 A 的文字“后来进入”B 的气泡;实际是 A 的旧片段身份被补齐后触发了相邻合并。
- 旧客户端渲染路径:当前页面在
display_state_supported=true时忽略sentences,但旧页面若逐条 appendsentences,可能把同一sentence_id的 final/speaker 更新当成新气泡,或把 pending 文本追加到上一气泡。必须确认浏览器加载的app.js版本和服务端返回的display_state。 - 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 原项目的可能路径
- 静音边界不足:默认 800ms 静音才提交;换话前的短停顿会让 A 尾部和 B 开头留在同一个
segment_audio_buffer。 - carry 音频污染:启用实时 VAD split 时,分割点之后的
carry_audio会成为下一个 turn 的开头。若 split 点落在 A 尾音或 B 起音中间,下一段声纹和 ASR 都会携带前一人尾部。 - 短句沿用上一身份:B 的新段小于 1.6s 且历史有命名 speaker 时,
short_attach会直接复用上一条;chunk 提取为空时的embedding_attach也可能复用上一条。这是代码中最直接的“上一人污染下一段”路径。 - 最近身份继承:较长的新段在匹配失败时可能触发
recent_named_inherit(至少 4.5s)或recent_inherit(至少 8s),因此不能只看最终speaker_id,还要记录speaker_strategy。 - 重聚类改写历史:实时重聚类和 stop 最终重聚类都可能改写已有 segment 的 speaker。客户端如果按到达顺序追加,而不是按
sentence_idupsert,就会看到旧气泡和新气泡互相覆盖或合并。 - 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_idupsert。
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.pyRealtimeSession.__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.pyapply_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.pyConnectionContext:音频、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幂等处理
- 对外协议示例;其中 partial/final/speaker 回写必须按
本轮只新增本文档,没有修改上述实现。后续修复应先用第 5 节的三层证据确定问题属于切段、声纹还是展示层,再决定改哪一层。