test/docs/REALTIME_WEBSOCKET_PROCESSI...

383 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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