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