删除多余文档

main
Bifang 2026-09-29 09:57:24 +08:00
parent df81b0d209
commit e4282a747c
37 changed files with 102 additions and 7072 deletions

View File

@ -62,7 +62,7 @@
# DEVICE:具体运行设备,常见值有 auto、cpu、cuda:0。 # DEVICE:具体运行设备,常见值有 auto、cpu、cuda:0。
# DEVICE=auto # DEVICE=auto
# QWEN3_ASR_MODEL:手动指定离线识别模型,留空则自动挑选。 # QWEN3_ASR_MODEL:手动指定离线识别模型,留空则自动挑选。
# QWEN3_ASR_MODEL= QWEN3_ASR_MODEL=qwen3-asr-0.6b
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# 模型下载 / 缓存行为。 # 模型下载 / 缓存行为。

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
*.so *.so
crg-mcp-plugin
# Model files (downloaded) # Model files (downloaded)
*.pt *.pt

View File

@ -574,11 +574,10 @@ Automatic long audio segmentation:
| `qwen3-asr-0.6b` | Qwen3-ASR 0.6B | Lightweight multilingual ASR; CUDA uses vLLM, CPU/macOS uses Rust backend | Offline/Realtime | | `qwen3-asr-0.6b` | Qwen3-ASR 0.6B | Lightweight multilingual ASR; CUDA uses vLLM, CPU/macOS uses Rust backend | Offline/Realtime |
**Runtime selection:** **Runtime selection:**
- **VRAM >= 32GB**: Select `qwen3-asr-1.7b` - **Default**: Use `qwen3-asr-0.6b` for both offline and realtime ASR.
- **VRAM < 32GB**: Select `qwen3-asr-0.6b`
- **No CUDA**: Select the vendored Rust-backed `qwen3-asr-0.6b` - **No CUDA**: Select the vendored Rust-backed `qwen3-asr-0.6b`
- **macOS / Apple Silicon**: Always default to `qwen3-asr-0.6b`, regardless of memory size - **macOS / Apple Silicon**: Always default to `qwen3-asr-0.6b`, regardless of memory size
- **Environment override**: Set `QWEN3_ASR_MODEL=qwen3-asr-1.7b` or `QWEN3_ASR_MODEL=qwen3-asr-0.6b` to bypass automatic selection - **Environment override**: Set `QWEN3_ASR_MODEL=qwen3-asr-1.7b` to use 1.7B instead of the shared 0.6B default.
At startup the service checks the current runtime model plan and downloads missing models from ModelScope by default. At startup the service checks the current runtime model plan and downloads missing models from ModelScope by default.
@ -594,7 +593,7 @@ Recommended public settings:
| `ASR_BATCH_SIZE` | `4` | ASR batch size for long-audio segment processing | | `ASR_BATCH_SIZE` | `4` | ASR batch size for long-audio segment processing |
| `MAX_SEGMENT_SEC` | `60` | Max audio segment duration (seconds) | | `MAX_SEGMENT_SEC` | `60` | Max audio segment duration (seconds) |
| `ASR_ENABLE_NEARFIELD_FILTER` | `true` | Enable far-field sound filtering | | `ASR_ENABLE_NEARFIELD_FILTER` | `true` | Enable far-field sound filtering |
| `QWEN3_ASR_MODEL` | auto | Force `qwen3-asr-1.7b` or `qwen3-asr-0.6b` instead of VRAM-based selection | | `QWEN3_ASR_MODEL` | `qwen3-asr-0.6b` | Select the shared offline/realtime model; set 1.7B to override |
| `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | Upper bound for vLLM GPU memory reservation; lower it on shared GPUs, raise it when KV cache is too small | | `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | Upper bound for vLLM GPU memory reservation; lower it on shared GPUs, raise it when KV cache is too small |
| `QWEN_VLLM_ENFORCE_EAGER` | `true` | Force vLLM eager execution for compatibility; set `false` to allow CUDA Graph optimization on supported NVIDIA deployments | | `QWEN_VLLM_ENFORCE_EAGER` | `true` | Force vLLM eager execution for compatibility; set `false` to allow CUDA Graph optimization on supported NVIDIA deployments |

View File

@ -1,310 +0,0 @@
# 实时 ASR 当前问题排查记录
更新时间:2026-09-07
排查范围:实时 ASR、原生流式 partial、说话人识别、声纹姓名匹配、WebSocket 输出和前端展示。
## 1. 当前结论
目前发现的问题分布在三个层次:
```text
识别层 原生 partial 默认关闭,非原生路径会反复重识别窗口
说话人层 短片段无条件继承上一位实名,并可能把复制的 embedding 写回记录池
输出层 内部物理切段直接作为前端展示单元,导致同一说话人被拆成多行
```
其中,“不同的人进入已经确定的说话人气泡”最明确的根因是说话人层的短段快路径:小于 1.6 秒的片段在提取新特征之前直接继承上一位已命名说话人。WebSocket 本身负责传递这些结果,但错误身份是在上游状态机中产生的。
“同一说话人被切成几行”主要是输出层问题。silence 和 max duration 可以结束一次内部识别片段,但不应直接决定前端展示换行。
## 2. 当前实时 WebSocket 链路
入口位于 [app/api/v1/websocket_asr.py](app/api/v1/websocket_asr.py)。`/ws/v1/asr` 和 `/ws/v1/asr/qwen` 最终都进入 [Qwen3ASRService.handle_connection](app/services/qwen3_websocket_asr.py#L2538)。
主流程如下:
```text
客户端 start
↓
初始化 ConnectionContext 和 streaming state
↓
接收二进制音频
↓
RMS/peak 判断是否有声音,维护 pre-roll 和当前 turn
↓
partial 解码并向前端发送 sentence_type=0
↓
silence / max_duration / sentence_limit / stop 触发内部提交
↓
最终重识别,创建 confirmed segment
↓
异步 speaker worker 处理说话人
↓
通过相同 sentence_id 发送 speaker update
↓
stop 时发送最终 sentences 和 end
```
图谱分析显示 `handle_connection` 是当前实时链路的关键汇聚点;说话人解析又连接到 `RealtimeSpeakerClusterer.resolve_segment_speaker`、声纹注册服务和历史记录重聚类。因此直接修改主服务的影响范围较大,本次验证优先放在独立 demo 中。
## 3. 问题一:内部切段直接变成前端展示行
### 3.1 当前切段触发器
| 触发器 | 当前条件 | 当前作用 | 对展示的实际影响 |
|---|---|---|---|
| `silence` | 连续静音默认 `800ms` | 主力结束当前 turn | 直接产生一个 confirmed segment |
| `max_duration` | 缓冲默认最多 `12s` | 防止单段无限增长 | 长讲话被硬切成多个 segment |
| `sentence_limit` | 段内达到默认 `8` 句 | 兜底限制 | 可能提前提交 |
| `complete_sentence` | 标点结尾、时长达到阈值且字数足够 | 长独讲兜底 | 正常短句通常到不了该条件 |
| `final` | 客户端发送 `stop` | 提交最后一段 | 输出最终结果 |
相关逻辑位于 [qwen3_websocket_asr.py](app/services/qwen3_websocket_asr.py#L2538)、[_should_commit_complete_sentence](app/services/qwen3_websocket_asr.py#L1100) 和 [_commit_retranscribe_turn](app/services/qwen3_websocket_asr.py#L2333)。
### 3.2 当前输出方式
每次内部提交都会:
1. 创建一条 `confirmed_segments` 记录;
2. 使用该记录的 `index` 作为 `sentence_id`;
3. 立即发送 `sentence_type=1` 的 `sentences` 事件;
4. 把所有记录用换行连接成 `full_text`。
代码中 `full_text` 使用 `"\\n".join(...)`,位置在 [_commit_retranscribe_turn](app/services/qwen3_websocket_asr.py#L2464) 和 [_stop](app/services/qwen3_websocket_asr.py#L2916)。因此这里的 `sentence_id` 实际上是“物理切段编号”,不一定是语义完整句子编号。
项目协议文档明确区分了 `sentence_type=0` 的 partial 和 `sentence_type=1` 的 final,但没有把“内部 segment”和“前端 display block”分开,见 [realtime_meeting_websocket.md](docs/realtime_meeting_websocket.md#L162)。
前端可以根据相同 `sentence_id` 更新原记录,但不会把不同 `sentence_id` 且说话人相同的记录合并。因此停顿、12 秒硬切和前端换行目前形成了直接关系。
另外,图谱显示 [_should_force_stable_segment](app/services/qwen3_websocket_asr.py#L2051) 当前没有调用方,它不能实际改变实时切段;`complete_sentence` 主要是长段兜底。
## 4. 问题二:原生流式 partial 与输出合并不是同一层
当前配置中 `REALTIME_STREAM_CHUNK_SEC` 默认约为 `1.2s`,`REALTIME_PARTIAL_WINDOW_SEC` 默认约为 `8s`,原生 partial 开关默认关闭,相关默认值在 [app/core/config.py](app/core/config.py#L103)。
非原生路径会维护窗口并调用 `_transcribe_audio_text` 重识别;原生路径则通过:
```text
create_stream → push_stream/feed_stream → finish_stream
```
对应 [qwen3_engine.py](app/services/asr/qwen3_engine.py#L570) 和 [qwen3_websocket_asr.py](app/services/qwen3_websocket_asr.py#L1162)。
原生 partial 影响的是:
- partial 首次出现的延迟;
- 每次增量识别的计算量;
- 流式状态维护方式;
- 文本回滚和未固定 token 的处理。
它不应决定:
- 是否因为停顿产生前端新行;
- 哪些物理 segment 合并成一个说话人气泡;
- 是否把某个 speaker 姓名写入展示结果。
所以两个优化需要同时做,但必须保持两个独立状态:ASR streaming state 和 display aggregation state。
## 5. 问题三:短音频无条件继承实名
### 5.1 已确认的代码路径
在 [realtime_speaker_clusterer.py](app/services/realtime_speaker_clusterer.py#L27) 中,`_FAST_ATTACH_MAX_SEC = 1.6`。
`resolve_segment_speaker` 的入口逻辑是:
```text
duration_sec < 1.6s
且 speaker_records 非空
且上一条记录存在实名身份
↓
直接复制上一条记录的 speaker_id / speaker_name / user_id / registry_speaker_id
speaker_confidence = 0.0
speaker_strategy = "short_attach"
```
该分支在 [realtime_speaker_clusterer.py](app/services/realtime_speaker_clusterer.py#L505),早于 `extract_chunk_embeddings`。因此短段没有经过新的声纹特征验证。
### 5.2 为什么实名会直接通过稳定性判断
[_is_stable_speaker_info](app/services/qwen3_websocket_asr.py#L613) 首先检查 `registry_speaker_id` 或 `user_id`。短段继承时这两个字段被完整复制,所以即使 `speaker_confidence=0.0`,仍会被判断为 stable。
随后 [_resolve_segment_speaker](app/services/qwen3_websocket_asr.py#L2156) 会把该身份写入最终 segment,并由 [_emit_speaker_update](app/services/qwen3_websocket_asr.py#L498) 通过相同 `sentence_id` 推送给客户端。
这解释了为什么匿名 `SpeakerNN` 的继承可能被拦截,而带 `user_id` 或注册声纹 ID 的实名继承可以直接进入前端。
### 5.3 为什么短段是高风险场景
“嗯”“对”“好的”“可以”等短插话通常不到 1.6 秒,恰好是多人会议中最容易发生换人的场景。当前快路径把上一位实名当作新片段身份,产生的结果就是:新说话人的短句进入上一位实名气泡。
## 6. 问题四:特征提取失败分支与实际路径
代码中确实存在 `embedding_attach` 分支:特征提取后如果 `current_chunks` 为空,则尝试继承上一位实名,位置在 [realtime_speaker_clusterer.py](app/services/realtime_speaker_clusterer.py#L521)。
但当前 `extract_chunk_embeddings` 在没有 chunk 时会回退为整段单 chunk,因此正常情况下很难返回空列表;如果模型真正抛异常,异常会向上传递,最终由 [_resolve_and_emit_segment_speaker](app/services/qwen3_websocket_asr.py#L2220) 捕获,当前片段保持 pending。
准确结论是:
- 模型抛异常:当前片段通常保持 `speaker_id=-1`,不会走继承;
- chunk 为空:代码意图是继承实名,但该分支近乎不可达;
- 模型不抛异常但产生垃圾 embedding:仍需单独检查零向量、NaN 和相似度边界;
- 当前最确定、最直接的错误来源是 `<1.6s` 的 `short_attach`。
## 7. 问题五:继承 embedding 造成污染链
短段继承返回时还会复制上一条记录的 `_embedding`、`_chunk_embeddings` 和 `_chunks`。下游 [_record_segment_speaker](app/services/qwen3_websocket_asr.py#L644) 只要发现 `_embedding` 非空,就会把它作为正常 speaker record 保存。
污染链如下:
```text
上一位实名离场
↓
新人的短段直接复制实名和 embedding
↓
复制结果成为新的 last_record
↓
后续短段继续继承这条记录
↓
历史匹配池重复出现同一个 embedding
↓
相似度、聚类中心和重聚类结果被污染
```
影响包括:
1. 继承链持续延长,错误实名被不断续写;
2. 历史 embedding 被重复计数,匹配置信度可能虚高;
3. `cluster_records_with_ranges` 会把复制的 chunk 作为真实样本参与聚类;
4. 后续重聚类可能把错误身份回写到更多 segment。
## 8. 问题六:speaker update 是异步的
实时最终段先写入 `confirmed_segments`,speaker worker 再异步处理,处理完成后通过相同 `sentence_id` 发送更新,相关位置是 [_speaker_worker_loop](app/services/qwen3_websocket_asr.py#L442) 和 [_emit_speaker_update](app/services/qwen3_websocket_asr.py#L498)。
当前协议允许首次 final 没有姓名,后续再补姓名,这一点在 [realtime_meeting_websocket.md](docs/realtime_meeting_websocket.md#L240) 有说明。
这里有两个风险:
- 前端如果把每次事件当成追加消息,会出现重复行;
- 上游异步继承结果如果带着错误实名,前端的幂等更新会把错误身份稳定显示出来。
因此上层 WebSocket 需要维护 segment 状态表,按 `sentence_id` 覆盖更新,然后根据完整状态重新生成 display block 快照。
## 9. 需要同时实施的两层优化
### 9.1 识别层:原生流式 partial
目标是让同一轮语音持续使用一个 streaming state,通过增量音频推进识别,减少窗口重复重识别。
demo 应透传并记录:
- `enable_native_partial_stream`;
- partial 产生时间;
- 每次 partial 的文本长度和修订次数;
- 首次 partial 延迟;
- final 延迟;
- 原服务返回的 chunk 或 segment 时间范围。
### 9.2 说话人与输出层
目标是把“身份确认”和“展示合并”分开:
1. `<1.6s` 片段不继承上一位实名;
2. 没有新鲜声纹证据时保持 pending;
3. 特征提取异常时保持 pending;
4. 不复制上一条记录的 embedding;
5. 只有达到确认阈值的独立特征才能更新身份缓存;
6. 同一 `sentence_id` 的更新覆盖原片段;
7. 相邻且身份可信度一致的物理 segment 才合并为 display block;
8. A→B→A 保留时间顺序,不把非相邻发言重新拼接到一起;
9. 前端展示使用 display block,入库和诊断仍保留 raw segment。
推荐的状态关系是:
```text
raw segment
├─ ASR text state:partial / final
├─ speaker evidence:pending / fresh / confirmed
├─ speaker identity:cluster / registry / user
└─ display block:按时间和可信身份重新生成
```
## 10. 服务器接口边界
已验证服务器 `10.100.53.199:8000` 可访问,原实时 WebSocket 地址为:
```text
ws://10.100.53.199:8000/ws/v1/asr/qwen
```
当前公开接口包括:
- `/ws/v1/asr/qwen`:原实时 WebSocket,已经包含主服务内部的 ASR、切段和 speaker 逻辑;
- `/v1/audio/transcriptions`:整段音频转写;
- `/api/v1/speakers/identify`:通过文件来源识别注册说话人;
- `/api/v1/speakers`:声纹注册和人员管理。
当前公开接口没有返回实时聚类所需的原始 embedding,也没有把 [Qwen3ASREngine](app/services/asr/qwen3_engine.py#L570) 的 `create_stream`、`feed_stream`、`finish_stream` 暴露为独立远程模型 RPC。
所以存在一个边界:
- 上层 demo WebSocket 可以独立重写事件顺序、切段提交策略、pending 保护和展示合并;
- 如果要在 demo 中完整重建原项目的声纹聚类,服务器还需要提供 embedding 接口或模型 RPC;
- 只依赖当前 `/ws/v1/asr/qwen` 返回字段,无法重新计算已被原服务错误归类的长段身份。
## 11. 当前独立 demo 状态
独立验证项目位于 [realtime_asr_optimization_demo](realtime_asr_optimization_demo)。
当前结构:
```text
浏览器
↓ ws://127.0.0.1:8082/ws
demo 上层 WebSocket
↓ model_service.py 远程模型服务适配层
↓ ws://10.100.53.199:8000/ws/v1/asr/qwen
已部署模型服务
```
当前 demo 已具备:
- 原生 partial 参数透传;
- 同一 `sentence_id` 覆盖更新;
- raw event 与 display block 分离;
- 同一说话人的相邻片段合并;
- `<1.6s` 实名结果降级为 pending;
- 已命名身份不接受只有弱 cluster id 的片段加入;
- embedding 字段不进入 demo 状态池。
- 模型服务与 WebSocket 解耦;demo 不启动或加载模型;
- 模型服务地址可通过页面或 `--model-service-url` 配置。
对应实现见 [server.py](realtime_asr_optimization_demo/server.py)、[model_service.py](realtime_asr_optimization_demo/model_service.py)、[speaker_assembler.py](realtime_asr_optimization_demo/speaker_assembler.py) 和 [static/app.js](realtime_asr_optimization_demo/static/app.js)。
这个 demo 当前主要验证上层协议、状态覆盖和展示保护。后续把模型服务部署到新服务器时,只需替换模型服务地址;若新模型服务协议不同,则替换 `model_service.py`,不改变 WebSocket 编排层。若要验证“demo 自己完成声纹特征提取、聚类和按需姓名匹配”,仍需服务器提供远程 embedding/model RPC。
## 12. 建议验证用例
| 用例 | 关注结果 |
|---|---|
| 单人连续讲话,中间停顿 800ms 以上 | 前端仍属于同一个 display block |
| A 讲话后,B 说“嗯/好的” | B 的短段保持 pending,不进入 A 的实名块 |
| A→B→A | 保持三个时间顺序块 |
| 同一 `sentence_id` 先 partial 后 final | 文本覆盖,不重复追加 |
| final 先返回,speaker update 后返回 | 原 block 更新并重新归并 |
| 声纹模型异常 | 片段保持 pending,不继承上一位实名 |
| 连续多个短插话 | 不复制上一条 embedding,不形成继承链 |
| 关闭 native partial | 只影响识别延迟和资源,不改变展示合并规则 |
| 关闭 display merge | 可以看到原始物理 segment,用于对照 |
## 13. 当前未确认项
以下问题不能仅靠现有公开 WebSocket 字段确认:
- 原服务是否存在未写入 OpenAPI 的内部 embedding/model RPC;
- 实际使用的 realtime speaker 模型是否会输出零向量或 NaN;
- 不同真实说话人被分配相同稳定 cluster id 的比例;
- speaker worker 完成时间与 `end` 事件之间是否存在竞态;
- 远程 Docker 是否还映射了独立的模型后端端口。
这些项目需要通过服务器日志、embedding 接口或带原始音频的端到端录音继续验证。

View File

@ -346,7 +346,7 @@ def _get_openai_model_description() -> str:
**兼容性说明:** **兼容性说明:**
- 支持 OpenAI SDK 和第三方客户端调用 - 支持 OpenAI SDK 和第三方客户端调用
- 当前默认模型根据显存自动选择;也可通过 `QWEN3_ASR_MODEL` 覆盖 - 离线与实时 ASR 默认共用 Qwen3-ASR 0.6B;可通过 `QWEN3_ASR_MODEL` 切换到 1.7B
""" """

View File

@ -200,7 +200,7 @@ class ASRHealthCheckResponse(HealthCheckResponse):
"device": "cuda:0", "device": "cuda:0",
"version": "1.0.0", "version": "1.0.0",
"message": "ASR service is running normally", "message": "ASR service is running normally",
"loaded_models": ["qwen3-asr-1.7b"], "loaded_models": ["qwen3-asr-0.6b"],
"memory_usage": { "memory_usage": {
"gpu_memory_used": "2.1GB", "gpu_memory_used": "2.1GB",
"gpu_memory_total": "8.0GB", "gpu_memory_total": "8.0GB",
@ -242,16 +242,16 @@ class ASRDeclaredEntryInfo(BaseModel):
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"id": "qwen3-asr-1.7b", "id": "qwen3-asr-0.6b",
"kind": "model", "kind": "model",
"name": "Qwen3-ASR-1.7B", "name": "Qwen3-ASR-0.6B",
"engine": "qwen3", "engine": "qwen3",
"description": "多语言离线语音识别模型", "description": "多语言离线语音识别模型",
"languages": ["zh", "en"], "languages": ["zh", "en"],
"default": True, "default": True,
"supports_realtime": True, "supports_realtime": True,
"offline_model": { "offline_model": {
"path": "Qwen/Qwen3-ASR-1.7B", "path": "Qwen/Qwen3-ASR-0.6B",
"exists": True, "exists": True,
}, },
"realtime_model": None, "realtime_model": None,
@ -270,9 +270,9 @@ class ASRRuntimeInfo(BaseModel):
model_config = { model_config = {
"json_schema_extra": { "json_schema_extra": {
"example": { "example": {
"loaded_model_ids": ["qwen3-asr-1.7b"], "loaded_model_ids": ["qwen3-asr-0.6b"],
"loaded_count": 1, "loaded_count": 1,
"default_offline_model_id": "qwen3-asr-1.7b", "default_offline_model_id": "qwen3-asr-0.6b",
} }
} }
} }
@ -290,16 +290,16 @@ class ASRModelsResponse(BaseModel):
"example": { "example": {
"declared_entries": [ "declared_entries": [
{ {
"id": "qwen3-asr-1.7b", "id": "qwen3-asr-0.6b",
"kind": "model", "kind": "model",
"name": "Qwen3-ASR-1.7B", "name": "Qwen3-ASR-0.6B",
"engine": "qwen3", "engine": "qwen3",
"description": "多语言离线语音识别模型", "description": "多语言离线语音识别模型",
"languages": ["zh", "en"], "languages": ["zh", "en"],
"default": True, "default": True,
"supports_realtime": True, "supports_realtime": True,
"offline_model": { "offline_model": {
"path": "Qwen/Qwen3-ASR-1.7B", "path": "Qwen/Qwen3-ASR-0.6B",
"exists": True, "exists": True,
}, },
"realtime_model": None, "realtime_model": None,
@ -307,9 +307,9 @@ class ASRModelsResponse(BaseModel):
], ],
"declared_count": 2, "declared_count": 2,
"runtime": { "runtime": {
"loaded_model_ids": ["qwen3-asr-1.7b"], "loaded_model_ids": ["qwen3-asr-0.6b"],
"loaded_count": 1, "loaded_count": 1,
"default_offline_model_id": "qwen3-asr-1.7b", "default_offline_model_id": "qwen3-asr-0.6b",
}, },
} }
} }

View File

@ -46,10 +46,10 @@ def get_qwen_model_override() -> Optional[str]:
return normalized return normalized
def detect_qwen_model_by_vram(all_model_ids: Optional[list[str]] = None) -> Optional[str]: def select_qwen_model(all_model_ids: Optional[list[str]] = None) -> Optional[str]:
"""Pick the active Qwen model for the current machine.""" """选择离线与实时共用的 Qwen ASR 模型。"""
from app.core.accelerator import get_accelerator_info from app.core.accelerator import get_accelerator_info
from app.core.device import detect_device, get_vram_gb from app.core.device import detect_device
from app.services.asr.qwenasr_rust import is_qwenasr_rust_available from app.services.asr.qwenasr_rust import is_qwenasr_rust_available
model_ids = all_model_ids or load_supported_model_ids() model_ids = all_model_ids or load_supported_model_ids()
@ -67,19 +67,16 @@ def detect_qwen_model_by_vram(all_model_ids: Optional[list[str]] = None) -> Opti
if resolved_device == "cpu" or not accelerator.is_gpu: if resolved_device == "cpu" or not accelerator.is_gpu:
return "qwen3-asr-0.6b" if is_qwenasr_rust_available() and "qwen3-asr-0.6b" in model_ids else None return "qwen3-asr-0.6b" if is_qwenasr_rust_available() and "qwen3-asr-0.6b" in model_ids else None
vram = get_vram_gb() # 离线与实时默认共用轻量模型;需要 1.7B 时通过 QWEN3_ASR_MODEL 显式指定。
preferred = "qwen3-asr-1.7b" if vram >= 32 else "qwen3-asr-0.6b" if "qwen3-asr-0.6b" in model_ids:
if preferred in model_ids: return "qwen3-asr-0.6b"
return preferred return "qwen3-asr-1.7b" if "qwen3-asr-1.7b" in model_ids else None
fallback = "qwen3-asr-0.6b" if preferred == "qwen3-asr-1.7b" else "qwen3-asr-1.7b"
return fallback if fallback in model_ids else None
def get_active_qwen_model(all_model_ids: Optional[list[str]] = None) -> str: def get_active_qwen_model(all_model_ids: Optional[list[str]] = None) -> str:
"""Return the required Qwen model for the current machine.""" """Return the required Qwen model for the current machine."""
model_ids = all_model_ids or load_supported_model_ids() model_ids = all_model_ids or load_supported_model_ids()
qwen_model = detect_qwen_model_by_vram(model_ids) qwen_model = select_qwen_model(model_ids)
if not qwen_model: if not qwen_model:
override_model = get_qwen_model_override() override_model = get_qwen_model_override()
if override_model: if override_model:

View File

@ -22,7 +22,7 @@
"th", "th",
"vi" "vi"
], ],
"default": true, "default": false,
"supports_realtime": true, "supports_realtime": true,
"models": { "models": {
"offline": "Qwen/Qwen3-ASR-1.7B" "offline": "Qwen/Qwen3-ASR-1.7B"
@ -55,7 +55,7 @@
"th", "th",
"vi" "vi"
], ],
"default": false, "default": true,
"supports_realtime": true, "supports_realtime": true,
"models": { "models": {
"offline": "Qwen/Qwen3-ASR-0.6B" "offline": "Qwen/Qwen3-ASR-0.6B"

View File

@ -137,6 +137,7 @@ class Qwen3StreamingState:
max_new_tokens: int = 32 max_new_tokens: int = 32
language: Optional[str] = None language: Optional[str] = None
chunk_count: int = 0 chunk_count: int = 0
# 引擎统一向 WebSocket 层提供当前整句文本快照,屏蔽 vLLM 快照与 Rust 增量的差异。
last_text: str = "" last_text: str = ""
last_language: str = "" last_language: str = ""
@ -598,7 +599,12 @@ class Qwen3ASREngine(BaseASREngine):
last_language=language or "", last_language=language or "",
) )
if self._backend == "vllm": if self._backend == "vllm":
streaming_state = self.model.init_streaming_state(context=context, language=language, **kwargs) # 流式 partial 与 final 共用离线转写相同的热词提示格式。
streaming_state = self.model.init_streaming_state(
context=self._build_hotword_prompt_context(context),
language=language,
**kwargs,
)
return Qwen3StreamingState( return Qwen3StreamingState(
internal_state=streaming_state, internal_state=streaming_state,
chunk_size_sec=float(kwargs.get("chunk_size_sec", 1.2)), chunk_size_sec=float(kwargs.get("chunk_size_sec", 1.2)),
@ -633,7 +639,9 @@ class Qwen3ASREngine(BaseASREngine):
language=state.language, language=state.language,
) )
state.chunk_count += 1 state.chunk_count += 1
state.last_text = text # Rust 流式接口返回新增文本片段;引擎对上层统一提供整句快照。
if text:
state.last_text += text
state.last_language = state.language or "" state.last_language = state.language or ""
return state return state
@ -659,7 +667,9 @@ class Qwen3ASREngine(BaseASREngine):
max_new_tokens=state.max_new_tokens, max_new_tokens=state.max_new_tokens,
language=state.language, language=state.language,
) )
state.last_text = text # 收尾接口同样只返回本次刷出的尾部增量,接到既有快照后再交给 WebSocket。
if text:
state.last_text += text
state.last_language = state.language or "" state.last_language = state.language or ""
return state return state

View File

@ -1168,8 +1168,8 @@ class Qwen3ASRService:
configured = ctx.params.get("enable_native_partial_stream") configured = ctx.params.get("enable_native_partial_stream")
if configured is not None: if configured is not None:
return bool(configured) return bool(configured)
# 默认使用整段周期重转写;仅在客户端明确要求时启用底层 native partial stream。 # 默认使用 Qwen3 原生流式推理;显式传 false 时仍保留旧式 partial 兼容路径。
return False return True
async def _init_realtime_stream_state( async def _init_realtime_stream_state(
self, self,
@ -1268,10 +1268,10 @@ class Qwen3ASRService:
getattr(ctx.realtime_stream_state, "last_language", ""), getattr(ctx.realtime_stream_state, "last_language", ""),
ctx, ctx,
) )
if stream_text.strip(): # 流状态有效但当前还没有解码文本时,等待下一个流式块,避免反复整段重转写。
return stream_text, stream_language return stream_text, stream_language
# 流式文本为空时回退到当前音频窗口重转写。 # 流式初始化失败或客户端显式关闭原生流式时,再回退到窗口转写。
partial_audio = np.asarray( partial_audio = np.asarray(
ctx.stream_window_buffer if ctx.stream_window_buffer.size > 0 else ctx.segment_audio_buffer, ctx.stream_window_buffer if ctx.stream_window_buffer.size > 0 else ctx.segment_audio_buffer,
dtype=np.float32, dtype=np.float32,
@ -1628,17 +1628,42 @@ class Qwen3ASRService:
ctx.segment_observed_language = language ctx.segment_observed_language = language
return observed return observed
def _replace_segment_observed_text(
self,
ctx: ConnectionContext,
text: str,
language: str,
) -> str:
"""用 Qwen 流式接口当前返回的整句快照更新 partial,允许修订句尾。"""
candidate = self._sanitize_candidate_text(text)
if not candidate:
return ctx.segment_observed_text
if self._is_unstable_expansion(ctx.segment_observed_text, candidate):
return ctx.segment_observed_text
ctx.segment_observed_text = candidate
if language:
ctx.segment_observed_language = language
return candidate
def _update_best_partial( def _update_best_partial(
self, self,
ctx: ConnectionContext, ctx: ConnectionContext,
text: str, text: str,
language: str, language: str,
*,
replace_snapshot: bool = False,
) -> None: ) -> None:
candidate = self._sanitize_candidate_text(text) candidate = self._sanitize_candidate_text(text)
if not candidate: if not candidate:
return return
if self._is_unstable_expansion(ctx.best_partial_text, candidate): if self._is_unstable_expansion(ctx.best_partial_text, candidate):
return return
if replace_snapshot:
# Qwen partial 是整句快照;修订句尾时应淘汰旧快照,避免旧文本被 final 选回。
ctx.best_partial_text = candidate
ctx.best_partial_language = language or ctx.best_partial_language
return
chosen = self._prefer_segment_text(ctx.best_partial_text, candidate) chosen = self._prefer_segment_text(ctx.best_partial_text, candidate)
if chosen != ctx.best_partial_text: if chosen != ctx.best_partial_text:
ctx.best_partial_text = chosen ctx.best_partial_text = chosen
@ -2356,18 +2381,27 @@ class Qwen3ASRService:
elif reason in {"max_duration", "long_speech"} and self._enable_realtime_vad_split(ctx): elif reason in {"max_duration", "long_speech"} and self._enable_realtime_vad_split(ctx):
finalized_audio, carry_audio = self._split_max_duration_audio(current_audio) finalized_audio, carry_audio = self._split_max_duration_audio(current_audio)
has_native_stream_snapshot = (
carry_audio.size == 0
and self._should_use_native_partial_stream(ctx, engine)
and ctx.realtime_stream_state is not None
)
if carry_audio.size == 0: if carry_audio.size == 0:
stream_text, stream_language = await self._finish_realtime_stream_text(ctx, engine) stream_text, stream_language = await self._finish_realtime_stream_text(ctx, engine)
if stream_text.strip(): if stream_text.strip():
observed_text = self._update_segment_observed_text( update_observed_text = (
ctx, self._replace_segment_observed_text
stream_text, if has_native_stream_snapshot
stream_language or self._infer_text_language(stream_text), else self._update_segment_observed_text
)
observed_text = update_observed_text(
ctx, stream_text, stream_language or self._infer_text_language(stream_text)
) )
self._update_best_partial( self._update_best_partial(
ctx, ctx,
observed_text, observed_text,
stream_language or self._infer_text_language(observed_text), stream_language or self._infer_text_language(observed_text),
replace_snapshot=has_native_stream_snapshot,
) )
else: else:
ctx.realtime_stream_state = None ctx.realtime_stream_state = None
@ -2629,7 +2663,7 @@ class Qwen3ASRService:
), ),
"enable_native_partial_stream": payload.get( "enable_native_partial_stream": payload.get(
"enable_native_partial_stream", "enable_native_partial_stream",
False, True,
), ),
# 与离线会议接口保持一致:前端优先传 enable_speaker / match_speaker_registry。 # 与离线会议接口保持一致:前端优先传 enable_speaker / match_speaker_registry。
"enable_speaker": payload.get("enable_speaker", True), "enable_speaker": payload.get("enable_speaker", True),
@ -2736,6 +2770,10 @@ class Qwen3ASRService:
continue continue
if self._should_decode_turn_partial(ctx): if self._should_decode_turn_partial(ctx):
has_native_stream_snapshot = (
self._should_use_native_partial_stream(ctx, engine)
and ctx.realtime_stream_state is not None
)
current, current_language = await self._decode_turn_partial_text( current, current_language = await self._decode_turn_partial_text(
engine, engine,
ctx, ctx,
@ -2744,11 +2782,12 @@ class Qwen3ASRService:
current = self._sanitize_candidate_text(current) current = self._sanitize_candidate_text(current)
if current and not self._is_degenerate_repetition(current): if current and not self._is_degenerate_repetition(current):
current_language = current_language or self._infer_text_language(current) current_language = current_language or self._infer_text_language(current)
observed = self._update_segment_observed_text( update_observed_text = (
ctx, self._replace_segment_observed_text
current, if has_native_stream_snapshot
current_language, else self._update_segment_observed_text
) )
observed = update_observed_text(ctx, current, current_language)
visible_observed = self._trim_previous_segment_overlap(ctx, observed) visible_observed = self._trim_previous_segment_overlap(ctx, observed)
partial_display = self._clip_partial_text(visible_observed, ctx) partial_display = self._clip_partial_text(visible_observed, ctx)
if ( if (
@ -2762,7 +2801,12 @@ class Qwen3ASRService:
ctx.last_partial_text = visible_observed.strip() ctx.last_partial_text = visible_observed.strip()
ctx.last_partial_display_text = partial_display.strip() ctx.last_partial_display_text = partial_display.strip()
ctx.last_partial_language = current_language ctx.last_partial_language = current_language
self._update_best_partial(ctx, visible_observed, current_language) self._update_best_partial(
ctx,
visible_observed,
current_language,
replace_snapshot=has_native_stream_snapshot,
)
sentence_payload = self._build_tencent_sentence( sentence_payload = self._build_tencent_sentence(
{ {

View File

@ -1,68 +0,0 @@
# crg-mcp — code-review-graph MCP 接入插件
把已部署在本机的 `D:\github-project\code-review-graph` MCP 服务接入 PI-Desktop,
让它的工具以原生 agent 工具的形式出现。**未修改该项目任何文件。**
## 接入原理
PI-Desktop 的 MCP 客户端由插件宿主承载:宿主读取插件 `manifest.json` 里的
`contributes.mcpServers`,自行拉起进程、完成 MCP 握手,并把上游每个工具发布为
`plugin_<插件id>_<server id>_<工具名>`。因此这里只需声明,无需自己写 JSON-RPC。
## 文件
| 文件 | 作用 |
| --- | --- |
| `manifest.json` | 声明 stdio MCP 服务 + `mcp.server.local` 权限 |
| `crg.cmd` | 启动包装脚本(修环境后 exec `python -m code_review_graph serve`) |
| `main.js` | 空加载器,客户端生命周期归宿主管理 |
| `dist/crg-mcp-1.0.0.piplug` | 可安装包 |
## 两个必须保留的环境修正
宿主的 `mcpProcessEnv()` 只向子进程传递
`PATH / SystemRoot / windir / TEMP / TMP / LANG`(加插件声明的 env),所以:
1. **`PYTHONPATH` 必须注入。** 项目的 editable 安装记录
`.venv\Lib\site-packages\_editable_impl_code_review_graph.pth` 指向
`D:\github_project\code-review-graph`(下划线),而项目实际位于
`D:\github-project\code-review-graph`(连字符),因此直接
`import code_review_graph` 会 `ModuleNotFoundError`。
同理 `.venv\Scripts\code-review-graph.exe` 也不能用
(`error: uv trampoline failed to canonicalize script path`)——包装脚本绕过了
这两点,改用 `python -m code_review_graph`。
2. **`USERPROFILE` / `HOMEDRIVE` / `HOMEPATH` 必须补齐。**
`code_review_graph/constants.py` 在 import 期调用 `Path.home()`,
精简环境下会抛 `RuntimeError: Could not determine home directory.`
## 暴露的工具(10 个)
默认通过 `CRG_TOOLS` 只开放审查相关的 10 个工具(上游共 30 个,全开会显著占上下文):
`build_or_update_graph_tool`、`run_postprocess_tool`、`get_minimal_context_tool`、
`get_review_context_tool`、`get_impact_radius_tool`、`query_graph_tool`、
`semantic_search_nodes_tool`、`detect_changes_tool`、`list_graph_stats_tool`、
`get_affected_flows_tool`
工具名前缀为 `plugin_crg_mcp_crg_`,例如 `plugin_crg_mcp_crg_query_graph_tool`。
### 调整暴露范围 / 目标仓库
编辑 `crg.cmd`:
- 改 `CRG_TOOLS=...`:改工具白名单(置空并删除该行 = 暴露全部 30 个)。
- 改 `CRG_REPO=...`:改被分析的仓库根目录(默认指向 code-review-graph 自身,
即已建好图的那个库)。
改完重启 PI-Desktop 生效。
## 为何用 stdio 而不是 HTTP
`serve --http`(127.0.0.1:5555/mcp)实测可用,但宿主只负责 spawn,不会托管一个
常驻服务;HTTP 需要外部进程守护,进程一掉工具就全空。stdio 由宿主拉起并在每次
调用时自动重连握手,更稳。
## 校验方式
装好后对 agent 说“用图谱统计一下仓库规模”,应命中
`plugin_crg_mcp_crg_list_graph_stats_tool` 并返回节点/边数量。

View File

@ -1,57 +0,0 @@
@echo off
chcp 65001 >nul
setlocal
rem ---------------------------------------------------------------------------
rem code-review-graph MCP launcher for PI-Desktop.
rem
rem The MCP host spawns this with a minimal environment (PATH, SystemRoot,
rem windir, TEMP, TMP, LANG plus the manifest's env block) and cwd = plugin
rem directory, with stdin/stdout used for JSON-RPC. Never write to stdout.
rem ---------------------------------------------------------------------------
if defined CRG_HOME goto have_home
set "CRG_HOME=D:\github-project\code-review-graph"
:have_home
rem code_review_graph/constants.py calls Path.home() at import time; without a
rem user profile the server dies with "Could not determine home directory.".
if defined USERPROFILE goto have_profile
set "USERPROFILE=C:\Users\%USERNAME%"
:have_profile
if defined HOMEDRIVE goto have_hd
set "HOMEDRIVE=C:"
:have_hd
if defined HOMEPATH goto have_hp
set "HOMEPATH=\Users\%USERNAME%"
:have_hp
if defined APPDATA set "APPDATA=%USERPROFILE%\AppData\Roaming"
if defined LOCALAPPDATA set "LOCALAPPDATA=%USERPROFILE%\AppData\Local"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
rem The editable install's .pth points at D:\github_project\... (underscore)
rem while the checkout lives at D:\github-project\... (hyphen), so the package
rem is only importable with the checkout explicitly on sys.path.
set "PYTHONPATH=%CRG_HOME%"
rem The venv's code-review-graph.exe is a broken uv trampoline
rem ("failed to canonicalize script path"), so prefer a working interpreter.
set "CRG_PY=%CRG_HOME%\.venv\Scripts\python.exe"
if exist "%CRG_PY%" goto py_ready
set "CRG_PY=python"
:py_ready
rem Upstream ships 30 tools; keep the surface small unless overridden.
if defined CRG_TOOLS goto tools_ready
set "CRG_TOOLS=build_or_update_graph_tool,run_postprocess_tool,get_minimal_context_tool,get_review_context_tool,get_impact_radius_tool,query_graph_tool,semantic_search_nodes_tool,detect_changes_tool,list_graph_stats_tool,get_affected_flows_tool"
:tools_ready
if defined CRG_REPO goto repo_ready
set "CRG_REPO=%CRG_HOME%"
:repo_ready
"%CRG_PY%" -m code_review_graph serve --repo "%CRG_REPO%"
endlocal

Binary file not shown.

View File

@ -1,18 +0,0 @@
/**
* crg-mcp — thin loader for the code-review-graph MCP bridge.
*
* All of the wiring lives in manifest.json under `contributes.mcpServers`:
* PI-Desktop spawns `crg.cmd` (stdio MCP), performs the handshake, and
* publishes every upstream tool as `plugin_crg_mcp_crg_<tool>`. Nothing has
* to be registered from here — the host owns the client, the retries, and the
* tool lifecycle. This module only keeps the plugin loadable and offers a
* place for future local helpers.
*/
async function onLoad() {
// The MCP client is owned by the host; no tool registration needed.
}
async function onUnload() {}
module.exports = { onLoad, onUnload };

View File

@ -1,33 +0,0 @@
{
"schemaVersion": 1,
"id": "crg-mcp",
"name": "Code Review Graph MCP",
"version": "1.0.0",
"description": "Bridges the locally deployed code-review-graph MCP server (D:\\github-project\\code-review-graph) into the agent as native tools.",
"main": "main.js",
"contributes": {
"mcpServers": [
{
"id": "crg",
"label": "Code Review Graph",
"transport": "stdio",
"command": "crg.cmd",
"env": {
"PYTHONPATH": "D:\\github-project\\code-review-graph",
"USERPROFILE": "C:\\Users\\admin",
"HOMEDRIVE": "C:",
"HOMEPATH": "\\Users\\admin"
}
}
]
},
"permissions": [
"mcp.server.local"
],
"engines": {
"piDesktop": ">=0.1.0"
},
"activationEvents": [
"onStartup"
]
}

View File

@ -1,96 +0,0 @@
<#
Self-check for the crg-mcp plugin.
Drives crg.cmd exactly the way the PI-Desktop MCP host does — `cmd /c crg.cmd`
with piped stdio and cwd = this folder — then reports the MCP handshake, the
tool list, and one real tool call. Run it from PowerShell:
powershell -NoProfile -ExecutionPolicy Bypass -File .\selfcheck.ps1
#>
$ErrorActionPreference = 'Stop'
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Mirror the host's minimal environment plus the manifest's env block.
foreach ($k in 'PATH', 'SystemRoot', 'TEMP', 'TMP') {
if (-not (Test-Path "Env:$k")) { Write-Warning "missing $k in ambient env" }
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'cmd.exe'
$psi.Arguments = '/c crg.cmd'
$psi.WorkingDirectory = $dir
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$proc = [System.Diagnostics.Process]::Start($psi)
function Send($obj) {
$proc.StandardInput.WriteLine(($obj | ConvertTo-Json -Compress -Depth 8))
$proc.StandardInput.Flush()
}
function ReadLine([int]$waitSeconds = 25) {
$task = $proc.StandardOutput.ReadLineAsync()
if ($task.Wait([TimeSpan]::FromSeconds($waitSeconds))) { return $task.Result }
return $null
}
Send @{
jsonrpc = '2.0'; id = 1; method = 'initialize'
params = @{
protocolVersion = '2025-06-18'
capabilities = @{}
clientInfo = @{ name = 'crg-selfcheck'; version = '1' }
}
}
$initLine = ReadLine 40
if (-not $initLine) {
Write-Host 'HANDSHAKE FAILED: no stdout from crg.cmd' -ForegroundColor Red
Write-Host '--- stderr ---'
Write-Host $proc.StandardError.ReadToEnd()
try { $proc.Kill() } catch { }
exit 1
}
$init = $initLine | ConvertFrom-Json
Write-Host ("HANDSHAKE OK server={0} {1}" -f $init.result.serverInfo.name, $init.result.serverInfo.version) -ForegroundColor Green
Send @{ jsonrpc = '2.0'; method = 'notifications/initialized'; params = @{} }
Send @{ jsonrpc = '2.0'; id = 2; method = 'tools/list'; params = @{} }
$toolsLine = ReadLine
if (-not $toolsLine) {
Write-Host 'tools/list returned nothing' -ForegroundColor Red
try { $proc.Kill() } catch { }
exit 1
}
$tools = ($toolsLine | ConvertFrom-Json).result.tools
Write-Host ("TOOLS: {0}" -f $tools.Count) -ForegroundColor Green
foreach ($t in $tools) { Write-Host (" plugin_crg_mcp_crg_{0}" -f $t.name) }
Send @{
jsonrpc = '2.0'; id = 3; method = 'tools/call'
params = @{ name = 'list_graph_stats_tool'; arguments = @{} }
}
$callLine = ReadLine 40
if ($callLine) {
$call = $callLine | ConvertFrom-Json
if ($call.result) {
$text = $call.result.content[0].text
Write-Host 'TOOL CALL OK' -ForegroundColor Green
Write-Host (' ' + ($text -split "`n")[0..3] -join ' | ')
}
else {
Write-Host ("TOOL CALL ERROR: {0}" -f ($call | ConvertTo-Json -Compress -Depth 6)) -ForegroundColor Red
}
}
else {
Write-Host 'TOOL CALL: no response' -ForegroundColor Red
}
try { $proc.Kill() } catch { }

View File

@ -526,11 +526,10 @@ curl -X POST "http://localhost:8000/stream/v1/asr?enable_speaker_diarization=tru
| `qwen3-asr-0.6b` | Qwen3-ASR 0.6B | 轻量版多语言 ASR;CUDA 使用 vLLM,CPU/macOS 使用 Rust backend | 离线/实时 | | `qwen3-asr-0.6b` | Qwen3-ASR 0.6B | 轻量版多语言 ASR;CUDA 使用 vLLM,CPU/macOS 使用 Rust backend | 离线/实时 |
**运行时选择:** **运行时选择:**
- **显存 >= 32GB**: 选择 `qwen3-asr-1.7b` - **默认选择**: 离线和实时 ASR 统一使用 `qwen3-asr-0.6b`。
- **显存 < 32GB**: 选择 `qwen3-asr-0.6b`
- **无 CUDA**: 选择基于 vendored Rust 的 `qwen3-asr-0.6b` - **无 CUDA**: 选择基于 vendored Rust 的 `qwen3-asr-0.6b`
- **macOS / Apple Silicon**: 无论内存大小多少,默认都选择 `qwen3-asr-0.6b` - **macOS / Apple Silicon**: 无论内存大小多少,默认都选择 `qwen3-asr-0.6b`
- **环境变量覆盖**: 设置 `QWEN3_ASR_MODEL=qwen3-asr-1.7b` 或 `QWEN3_ASR_MODEL=qwen3-asr-0.6b` 可跳过自动选择 - **环境变量覆盖**: 设置 `QWEN3_ASR_MODEL=qwen3-asr-1.7b` 可切换到 1.7B;默认使用 0.6B
启动时会先检测当前运行计划所需模型;如果本地缓存缺失,会自动从 ModelScope 下载。离线部署请提前准备模型缓存。 启动时会先检测当前运行计划所需模型;如果本地缓存缺失,会自动从 ModelScope 下载。离线部署请提前准备模型缓存。
@ -546,7 +545,7 @@ curl -X POST "http://localhost:8000/stream/v1/asr?enable_speaker_diarization=tru
| `ASR_BATCH_SIZE` | `4` | 长音频分段后的 ASR 批处理大小 | | `ASR_BATCH_SIZE` | `4` | 长音频分段后的 ASR 批处理大小 |
| `MAX_SEGMENT_SEC` | `60` | 音频分段最大时长(秒) | | `MAX_SEGMENT_SEC` | `60` | 音频分段最大时长(秒) |
| `ASR_ENABLE_NEARFIELD_FILTER` | `true` | 启用远场声音过滤 | | `ASR_ENABLE_NEARFIELD_FILTER` | `true` | 启用远场声音过滤 |
| `QWEN3_ASR_MODEL` | 自动选择 | 强制选择 `qwen3-asr-1.7b` 或 `qwen3-asr-0.6b` | | `QWEN3_ASR_MODEL` | `qwen3-asr-0.6b` | 离线与实时共用模型;设为 1.7B 可覆盖默认值 |
| `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | vLLM 可保留的 GPU 显存上限;共享显卡时可调低,KV cache 不足时可适当调高 | | `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | vLLM 可保留的 GPU 显存上限;共享显卡时可调低,KV cache 不足时可适当调高 |
| `QWEN_VLLM_ENFORCE_EAGER` | `true` | 强制 vLLM eager 执行以提高兼容性;NVIDIA 性能测试可设为 `false` 允许 CUDA Graph 优化 | | `QWEN_VLLM_ENFORCE_EAGER` | `true` | 强制 vLLM eager 执行以提高兼容性;NVIDIA 性能测试可设为 `false` 允许 CUDA Graph 优化 |

View File

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

View File

@ -1,66 +0,0 @@
# 熵减执行清单
本文档记录本轮已执行的熵减工作。目标是移除不可达路径、兼容占位、隐藏 fallback、重复请求流水线和无用依赖。
## 范围
- 主范围:`app/`、根运行配置、公开运行文档。
- 不处理:`vendor/qwenasr` 内部实现、仅 benchmark 使用且不阻塞主链路的代码。
- 原则:开发中项目不保留废弃接口、旧字段、兼容层或 fallback 逻辑。
## P0
- [x] 移除 Qwen3 `transformers` 后端残留路径。
- 删除后端选择里的 `"transformers"` fallback。
- 删除仅服务该路径的 batch/segment 转换死代码。
- 不支持的设备显式失败。
- [x] 启动预加载改为 fail-fast。
- 模型完整性检查或预加载失败时停止 worker。
- 不再静默降级到首次请求加载。
- [x] 移除被忽略的离线模型兼容参数。
- 删除 REST `model_id` 兼容处理。
- 删除 OpenAI transcription `model` 兼容处理。
- 运行时模型选择统一由部署计划和 `QWEN3_ASR_MODEL` 控制。
## P1
- [x] 抽出共享离线转写服务。
- API 层只处理协议输入和响应格式。
- 音频准备、`OfflineASRRequest`、runtime 调用和清理边界集中到服务层。
- [x] 合并音频字节处理逻辑。
- `process_from_request` 和 `process_upload_file` 共享私有 byte 处理 helper。
## P2
- [x] 将 `.tsscale` sidecar 隐式耦合改为显式结构化元数据。
- [x] 拆分 WebSocket 路由和 Qwen3 协议服务。
- Qwen3 websocket 状态机移出 API route。
- route 模块仅保留端点注册和 service delegation。
- [x] 删除首轮发现的无用 helper。
- [x] 审计并移除无用直接依赖。
- 根环境和 CPU 环境移除直接依赖 `pydub`、`httpx`。
- 保留 ModelScope/FunASR 动态 runtime 依赖。
- [x] 继续压缩阿里协议 WebSocket service。
- 删除大段注释、未使用状态、未使用参数和死函数。
- 合并重复响应构造。
- 删除重复音频转换。
## 验证
- [x] `uv run python -m py_compile $(find app -name '*.py' -not -path '*/__pycache__/*') start.py`
- [x] `uvx pyright`
- [x] 变更模块 import smoke check。
- [x] 手工 API smoke plan 已记录:
- `/stream/v1/asr`
- `/v1/audio/transcriptions`
- `/ws/v1/asr/funasr`
- `/ws/v1/asr/qwen`
## 手工 Smoke Plan
启动服务并准备模型后执行:
1. `POST /stream/v1/asr`,使用小 WAV request body,确认返回 `result`、`segments`、`duration`、`processing_time`。
2. `POST /v1/audio/transcriptions`,使用 multipart `file` 和 `response_format=verbose_json`,确认返回 OpenAI 风格 `text` 和 `segments`。
3. 连接 `/ws/v1/asr/funasr`,发送阿里兼容 start/audio/stop 消息,确认 sentence 事件仍正常返回。
4. 连接 `/ws/v1/asr/qwen`,发送 start/audio/stop 消息,确认 partial/final 事件仍正常返回。

View File

@ -1,457 +0,0 @@
# x86 Rust Align Optimization Plan
## 当前结论
- 当前工作区的 vendored Rust backend 已经收敛到更接近 upstream `huanglizhuo/QwenASR` 的 Linux/x86_64 路径:
- `release`
- `RUSTFLAGS="-C target-cpu=native"`
- `BLAS/OpenBLAS`
- x86_64 默认 `BF16` decode
- 保留的有意偏离只有两类:
- `SharedQwenModel` / shared model cache
- 中性的 `ffi` feature(`macos-ffi` 仅作为兼容别名保留)
## upstream 参考
- upstream repo: `https://github.com/huanglizhuo/QwenASR`
- inspected commit: `4e85a19b05f034e106a345d279c68f50df718ab8`
## 本机环境
- CPU: `Intel Core i5-13600KF`
- visible CPUs: `14`
- memory: user reported `G.SKILL DDR5-6400`
## 已验证 benchmark
音频:
- `/opt/qwen3-asr/temp/test_assets/podcast_demo_2min_16k.wav`
- duration: `120s`
### decode 路径对照(runtime concurrency = 4)
`INT8 decode`
- total: `173.42s`
- asr: `54.69s`
- align: `118.74s`
- rtf: `1.4452`
来源:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_int8.json`
`BF16 decode`
- total: `125.87s`
- asr: `46.40s`
- align: `79.47s`
- rtf: `1.0489`
来源:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_bf16.json`
结论:
- 在当前这台 x86_64 机器上,`BF16 decode` 明显优于 `INT8 decode`
- 因此 x86_64 默认 decode 路径应保持 `BF16`
### 收敛后的默认路径 benchmark
来源:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_default_after_converge.json`
结果:
- `runtime concurrency = 4`
- total: `131.77s`
- asr: `51.54s`
- align: `80.23s`
- rtf: `1.0981`
- `runtime concurrency = 14`
- total: `128.25s`
- asr: `57.12s`
- align: `71.13s`
- rtf: `1.0688`
结论:
- 当前主瓶颈仍然在 `align`
- `align_sec` 明显大于或接近 `asr_sec`
- `runtime concurrency` 的最优值并不稳定,说明问题不在单纯线程数,而在具体阶段的访问模式和 kernel 行为
## 为什么不继续默认走 INT8
- upstream README 对 Linux/x86_64 的主路径描述是 `BLAS + AVX2/FMA`
- 当前本机实测中,`INT8 decode` 明显慢于 `BF16 decode`
- 说明这台机器上的主瓶颈不只是权重带宽,更多是:
- x86_64 上 INT8 kernel 的有效带宽利用率
- cache / 数据布局
- 实现成熟度差异
## 当前仍需保留的偏离
### 1. Shared model cache
文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/context.rs`
目的:
- 多 runtime / 多 worker 场景下复用只读模型权重
- 避免每个 runtime 重复 mmap / 持有整套权重
### 2. ffi feature
文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/Cargo.toml`
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/lib.rs`
- `/opt/qwen3-asr/Dockerfile.cpu`
目的:
- 让 Linux CPU 集成不再依赖命名不准确的 `macos-ffi`
- 同时保留兼容别名,避免已有脚本立即失效
## align 热点拆解计划
### Phase 1: 阶段级 profiling
状态:已完成
目标:
- 先确认 `align` 的主耗时究竟在哪一段
位置:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/align.rs`
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/decoder.rs`
需要拆出的阶段:
- `mel_spectrogram`
- `encoder.forward`
- `input_embeds build`
- `decoder_prefill_logits`
- `timestamp argmax extract`
- `fix_timestamps`
验收:
- 2 分钟样本上输出稳定的阶段级耗时表
实际结果:
- 产物:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_align_profile.json`
- `/opt/qwen3-asr/temp/test_logs/qwen_rust_runtime_concurrency_2min_align_profile.stderr`
- 结论:
- `align` 的主热点明确落在 `decoder_prefill_logits`
- `final rms_norm` 和 `lm_head projection` 不是主矛盾
### Phase 2: decoder_prefill_logits 内部分解
状态:已完成
如果 `decoder_prefill_logits` 是主热点,则继续拆分:
- `decoder_prefill`
- final `rms_norm`
- `lm_head projection`
目的:
- 判断到底是 decoder prefill 慢,还是最后分类头 projection 慢
实际结果:
- 产物:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_align_breakdown.json`
- `/opt/qwen3-asr/temp/test_logs/qwen_rust_runtime_concurrency_2min_align_breakdown.stderr`
- 结论:
- 真正的大头是 `decoder_prefill`
- 长段(`seq_len=1801`)时,`attention_ms` 占 `decoder_prefill` 的绝大部分
- 关键样本:
- `decoder_prefill total_ms=79280.78`
- `attention_ms=73769.91`
- `qkv_ms=1336.33`
- `gate_up_ms=1866.68`
- `down_proj_ms=955.94`
### Phase 2.5: 失败尝试记录
状态:已完成并回退
尝试:
- 针对 x86_64 预先物化 prefill 用 F32 权重,避免每次 `align` 反复做 `BF16 -> F32`
结果:
- 长段 `decoder_prefill` 没有稳定收益,反而出现回归
- 这条路径已经回退,不保留在主线代码里
结论:
- 当前瓶颈不是简单的 BF16 权重转换
- 更直接的问题是 multi-token causal attention 的算法路径
### Phase 3: 对热点段做针对性优化
状态:第一轮已完成
根据 profiling 结果,按优先级选一个方向:
1. 如果热点在 `input_embeds build`
- 复用固定 prefix/suffix embeddings
- 减少逐 token 小块 copy
- 降低每段 align 的重复构造开销
2. 如果热点在 `decoder_prefill`
- 检查 `BF16 matvec / attention / swiglu` 的实际热点
- 优化并行粒度或数据布局
3. 如果热点在 `lm_head projection`
- 优先优化 `BF16` classify head 路径
- 避免无价值的整块 materialize
- 但只有在 profiling 证明是主热点后才动
4. 如果热点在后处理
- 精简 `fix_timestamps`
- 减少 `Vec/String` 分配
### Phase 3 实施结果
本轮实际落地的是:
- 文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/kernels/mod.rs`
- 改动:
- 对 BLAS multi-token causal attention 增加长序列专用 batched 路径
- 仅在 `seq_q >= 256` 时启用
- 从“每个 head、每一行 2 次小 GEMM”改为:
- 每个 head 1 次 `Q @ K^T`
- 行级 causal softmax
- 每个 head 1 次 `softmax @ V`
### Phase 3 回归结果
来源:
- 优化后 benchmark:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_attention_opt.json`
- 优化后 profiling:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_attention_opt_profile.json`
- `/opt/qwen3-asr/temp/test_logs/qwen_rust_runtime_concurrency_2min_attention_opt_profile.stderr`
关键对比:
- 之前默认路径(2 分钟样本,基线文件):
- `total=128.25s`
- `asr=57.12s`
- `align=71.13s`
- 优化后:
- `total=76.46s`
- `asr=45.32s`
- `align=31.14s`
- `rtf=0.6372`
attention 热点变化:
- 长段 `seq_len=1801`
- 优化前:
- `decoder_prefill total_ms=79280.78`
- `attention_ms=73769.91`
- 优化后:
- `decoder_prefill total_ms=16625.88`
- `attention_ms=9981.88`
结论:
- 当前这台 i5 上,`align` 的主矛盾已经从“attention 明显失控”收敛到了“attention 仍是第一热点,但已降到可接受量级”
- 这一轮优化是有效的,应该保留
## Phase 4: FFN 路径继续收敛
状态:已完成一轮,并保留有效部分
本轮动作:
- 文件:
- `/opt/qwen3-asr/vendor/qwenasr/crates/qwen-asr/src/decoder.rs`
- 改动:
- 仅对 x86_64 `BF16 prefill` 的 FFN 路径物化共享 F32 权重
- 范围只包括:
- `gate_up_fused`
- `down_weight`
- `QKV` 和 `O-proj` 暂不纳入
回归数据:
- 不带 profiling:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_ffn_opt.json`
- `total=58.61s`
- `asr=29.50s`
- `align=29.11s`
- 带 profiling:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_ffn_opt_profile.json`
- `total=62.07s`
- `asr=32.15s`
- `align=29.92s`
与上一轮 attention-only profiling 对比:
- attention-only:
- `total=64.58s`
- `align=30.86s`
- FFN-opt:
- `total=62.07s`
- `align=29.92s`
长段热点对比(`seq_len=1801`):
- FFN-opt 之前:
- `attention_ms=9981.88`
- `gate_up_ms=2080.10`
- `down_proj_ms=1183.64`
- FFN-opt 之后:
- `attention_ms=9660.15`
- `gate_up_ms=2210.39`
- `down_proj_ms=1166.96`
结论:
- FFN 这轮不是“大收益”,但 `align_sec` 仍然有小幅下降
- 收益不像 attention 优化那样压倒性,更像是小幅收敛
- 当前可以保留,但不值得继续在同一方向上扩大复杂度
## Phase 5: QKV / O-proj 试验与回退
状态:已完成并回退
尝试:
- 在 prefill 中进一步物化 `QKV` 和 `O-proj` 的共享 F32 权重
回归数据:
- 试验版本:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_qkv_opt.json`
- `total=63.12s`
- `align=30.07s`
结论:
- 相比 FFN-opt 版本,没有形成净收益
- 因此这条路径已回退,不保留在主线代码里
## Phase 6: attention 继续细化的两次试验
状态:已完成并回退
### 试验 A:query-block batched attention
尝试:
- 将长序列 batched causal attention 从“整段一次性 `QK^T` / `SV`”改成按 query block 分块执行
结果:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_attention_block_opt.json`
- `total=79.18s`
- `align=33.40s`
结论:
- 这条路径在当前 i5 + OpenBLAS 组合下没有收益
- 增加 GEMM 次数带来的额外调度开销,超过了小块缓存收益
- 已回退
### 试验 B:提高 batched 切换阈值到 512
尝试:
- 只改 `BATCHED_CAUSAL_ATTENTION_THRESHOLD`
- 让中等长度序列继续走 row-wise 路径,只把更长的序列交给 batched 路径
结果:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_attention_threshold512.json`
- `total=74.49s`
- `align=30.42s`
结论:
- 相比当前主线最优版本也没有收益
- 说明当前阈值 `256` 不是主要问题
- 已恢复回 `256`
## Phase 7: K/V block + online softmax 试验
状态:已完成并回退
尝试:
- 仅替换长序列 batched attention 路径
- 改为按 `K/V` block 流式累积的 online softmax
- 短序列和单 token 路径完全不动
目标:
- 不再一次性物化整块 `scores`
- 降低大矩阵内存压力
- 观察是否能进一步压低长段 `attention_ms`
结果:
- `/opt/qwen3-asr/temp/test_assets/qwen_rust_runtime_concurrency_2min_after_kvblock_online_softmax.json`
- `total=74.11s`
- `align=30.33s`
结论:
- 当前实现下没有优于主线最优版本
- 在这台机器上,额外的 block 循环和 online softmax 合并开销,超过了减少大 `scores` 矩阵带来的收益
- 已回退
## 当前判断
- 当前最值钱、且已验证有效的优化仍然是:
- 长序列 batched causal attention
- FFN selective F32 物化
- 继续扩大到 `QKV/O-proj` 这一步暂时不划算
- query-block 化和 batched 阈值调优目前也不划算
- `K/V block + online softmax` 在当前实现形态下也不划算
- 后续判断应优先看:
- `align_sec`
- `decoder_prefill` profiling
- 尤其是长段 `seq_len` 下的热点变化
补充:
- `asr_sec` 在多次回归中波动明显大于 `align_sec`
- 因此后续评估优化效果时,不应只盯总耗时,应优先以 `align` profiling 为准
## 暂不做的事
- 不再把 x86_64 默认路径改回 `INT8 decode`
- 不继续做没有 profiling 支撑的 `align` 结构性改写
- 不围绕 `runtime concurrency` 数量盲调
## 下一步执行顺序
1. 保留当前 batched causal attention 路径,继续观察不同长段下的稳定性
2. 保留 FFN selective F32 物化,继续观察其稳定收益
3. 如需继续优化,优先看:
- 更激进的 `attention` 算法级改动,例如按 `K/V` block 的 online softmax
- 再其次才是 `qkv_ms + gate_up_ms + down_proj_ms`
4. 如果后续继续深挖,再考虑:
- batched attention 的 block 化,降低大 score matrix 的瞬时内存
- `decoder_prefill` 内的投影层进一步收敛
5. 保持同一份 2 分钟样本持续回归,避免再次把回归误当成优化

View File

@ -1,699 +0,0 @@
# Qwen3-ASR 部署指南
快速部署 Qwen3-ASR 语音识别服务,支持 CPU/macOS、NVIDIA GPU、沐曦 GPU、天数 GPU 与摩尔线程 GPU 运行形态。
如果你正在继续验证本轮 CUDA 官方 vLLM 迁移,请同时参考:
- [PENDING_CUDA_VLLM_HANDOFF.md](./TODO/PENDING_CUDA_VLLM_HANDOFF.md)
依赖安装现在改成根目录默认 NVIDIA GPU,CPU、沐曦、天数与摩尔线程为单独特化环境:
| 模式 | 命令 | 说明 |
|------|------|------|
| NVIDIA GPU | `uv sync` 或 `./scripts/sync_gpu_env.sh` | Linux/NVIDIA 运行时,默认锁定 CUDA 13.0/cu130 `torch 2.11.0` / `torchaudio 2.11.0` / `torchvision 0.26.0` + `vllm 0.20.0` |
| 沐曦 GPU | `./scripts/sync_metax_env.sh` | 同步公共依赖;可选 GPU 栈安装默认从沐曦 MACA PyPI 源按 `--no-deps` 安装 |
| 天数 GPU | `./scripts/sync_iluvatar_env.sh` | 同步公共依赖;GPU 栈使用天数官方 vLLM 镜像 |
| 摩尔线程 GPU | `./scripts/sync_mthreads_env.sh` | 同步公共依赖;GPU 栈使用摩尔线程官方 MUSA vLLM 镜像 |
| CPU | `./scripts/sync_cpu_env.sh` | Linux/CPU 运行时 |
| 自动 | `./scripts/sync_accel_env.sh` | 根据 `mx-smi` / `ixsmi` / `mthreads-gmi` / `nvidia-smi` 自动选择沐曦、天数、摩尔线程、NVIDIA 或 CPU 环境 |
## 快速部署
### NVIDIA GPU 版本部署(推荐)
适用于生产环境,提供更快的推理速度:
**前置要求:**
- NVIDIA GPU(默认镜像面向 CUDA 13.0+;CUDA 12.6 / 13.0 可通过构建参数覆盖)
- 已安装 NVIDIA Container Toolkit
- 显存 12GB+(推荐 16GB+ 以支持 Qwen3-ASR 1.7B)
```bash
# 使用 docker run(带模型挂载)
docker run -d --name qwen3-asr \
--gpus all \
-p 17003:8000 \
-v /opt/dep/asr/models:/app/models \
-v /opt/dep/asr/data:/app/data \
-e ACCELERATOR=nvidia \
-e DEVICE=auto \
-e QWEN_GPU_MEMORY_UTILIZATION=0.3 \
-e QWEN_VLLM_ENFORCE_EAGER=true \
unis/qwen3-asr:gpu-latest
# 或使用 docker-compose(推荐)
docker-compose up -d
```
### 沐曦 GPU 版本部署
适用于已安装沐曦驱动与容器运行栈的机器:
```bash
docker run -d --name qwen3-asr-metax \
--privileged \
--network=host \
--pid=host \
--ipc=host \
-v /dev:/dev \
-v /opt/mxdriver:/opt/mxdriver:ro \
-v /opt/dep/asr/models:/app/models \
-v /opt/dep/asr/data:/app/data \
-e ACCELERATOR=metax \
-e PORT=17003 \
-e METAX_VISIBLE_DEVICES=0 \
unis/qwen3-asr:metax-latest
# 或使用 docker-compose-metax.yml
docker compose -f docker-compose-metax.yml up -d
```
构建沐曦镜像时,`Dockerfile.metax` 会基于沐曦官方 vLLM 镜像融合本项目代码与通用依赖:
```bash
./scripts/package_vendor_gpu_image.sh \
--vendor metax \
--base-image <沐曦官方vLLM镜像名> \
-v n260-3.7.0.38
```
沐曦等国产 GPU 的生产推荐路径是“厂商官方 vLLM 镜像 + 本项目代码/通用依赖”。不要在项目 Dockerfile 中重新 `pip install vllm`,避免解析到 PyPI/NVIDIA CUDA 依赖。
沐曦 GPU 的完整编译、模型准备和离线交付流程见 [沐曦 GPU 国产化离线部署指南](./metax_offline_deployment.md)。
### 天数 GPU 版本部署
适用于已安装天数驱动与容器运行栈的机器。按天数官方镜像运行建议,本 compose 使用 host network、host pid/ipc、privileged、`/dev`、`/usr/src`、`/lib/modules` 等挂载,并额外挂载本项目模型与数据目录:
```bash
docker pull registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
./scripts/package_vendor_gpu_image.sh \
--vendor iluvatar \
--base-image registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5 \
docker compose -f docker-compose-iluvatar.yml up -d
```
天数 GPU 的完整编译、模型准备和离线交付流程见 [天数 GPU 国产化离线部署指南](./iluvatar_offline_deployment.md)。
### 摩尔线程 GPU 版本部署
适用于已安装摩尔线程驱动与 MUSA 容器运行栈的机器:
```bash
docker pull registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
./scripts/package_vendor_gpu_image.sh \
--vendor mthreads \
--base-image registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519 \
-v s4000_4.3.5_d0519
ASR_IMAGE=unis/qwen3-asr:mthreads-s4000_4.3.5_d0519 \
docker compose -f docker-compose-mthreads.yml up -d
```
摩尔线程 GPU 的完整编译、模型准备和离线交付流程见 [摩尔线程 GPU 国产化离线部署指南](./mthreads_offline_deployment.md)。
默认推荐将宿主机目录统一挂载到 `/opt/dep/asr` 下,目录结构如下:
```text
/opt/dep/asr/models/
Qwen/
iic/
damo/
```
如果你希望挂载到自定义目录,可统一设置:
```bash
export MODEL_STORAGE_DIR=/data/qwen3-asr-models
export DATA_STORAGE_DIR=/data/qwen3-asr-data
mkdir -p "$MODEL_STORAGE_DIR" "$DATA_STORAGE_DIR"
docker-compose up -d
```
### 多 GPU 拓扑模式
项目现在支持统一的多 GPU 拓扑开关:
- `ASR_DEPLOY_TOPOLOGY=isolated`
- 默认模式
- 每张卡启动 1 个 backend 实例
- 容器内使用 Nginx 负载均衡到多个实例
- `ASR_DEPLOY_TOPOLOGY=sharded`
- 单个 backend 进程占用多张卡
- 由 vLLM 在进程内部做多卡分片
- `ASR_DEPLOY_TOPOLOGY=auto`
- 优先尝试 `sharded`
- 如果当前平台、可见设备或 shard 数不满足条件,则自动回退到 `isolated`
### NVIDIA 多 GPU 自动并行部署(推荐)
适用于并发量较高场景。该方案通过容器 entrypoint 自动完成:
- 根据 `ASR_VISIBLE_DEVICES` 拉起多个 ASR 实例(每张卡 1 个实例)
- 容器内自动生成 Nginx upstream 并负载均衡到各实例
- 对外仍只暴露一个服务端口(默认 `8000`)
你不需要手工维护多个 `docker-compose` 服务块或手工维护 nginx upstream。
```bash
# 4 卡示例:GPU0,1,2,3 各启动 1 个实例
ASR_VISIBLE_DEVICES=0,1,2,3 docker-compose up -d
```
常用组合:
- 单卡(保持默认):`ASR_VISIBLE_DEVICES=0`
- 双卡:`ASR_VISIBLE_DEVICES=0,1`
- 四卡:`ASR_VISIBLE_DEVICES=0,1,2,3`
强制单实例多卡分片:
```bash
ASR_DEPLOY_TOPOLOGY=sharded \
ASR_VISIBLE_DEVICES=0,1 \
docker-compose up -d
```
自动选择模式:
```bash
ASR_DEPLOY_TOPOLOGY=auto \
ASR_VISIBLE_DEVICES=0,1 \
docker-compose up -d
```
**服务访问地址:**
- API 服务: `http://localhost:17003`
- API 文档: `http://localhost:17003/docs`
### 沐曦多 GPU 自动并行部署
```bash
ASR_VISIBLE_DEVICES=0,1 docker compose -f docker-compose-metax.yml up -d
```
### 摩尔线程多 GPU 自动并行部署
```bash
ASR_VISIBLE_DEVICES=0,1 docker compose -f docker-compose-mthreads.yml up -d
```
### CPU 版本部署
适用于开发测试或无 GPU 环境:
```bash
docker run -d --name qwen3-asr \
-p 17003:8000 \
-v /opt/dep/asr/models:/app/models \
-v /opt/dep/asr/data:/app/data \
-e DEVICE=cpu \
unis/qwen3-asr:cpu-latest
```
**注意:** CPU 版本不使用 GPU/vLLM 路径。
当前 CPU 镜像已集成 QwenASR Rust backend,会自动选择 `qwen3-asr-0.6b`。
CPU 镜像默认使用可分发的 `x86-64-v2` Rust 构建目标,避免把构建机的 native CPU 指令带入通用镜像。
如果你确认构建机与部署机 CPU 指令集一致,可在自建镜像时设置 `QWENASR_RUST_TARGET_CPU=native` 换取更激进优化。
当前 Rust backend 的 x86 kernel 需要 `avx2` 与 `fma`,不满足时启动会给出明确错误。镜像默认限制
`OPENBLAS_NUM_THREADS=1` / `OMP_NUM_THREADS=1` / `GOTO_NUM_THREADS=1`,以减少多 runtime 并发时的线程争抢。
CUDA vLLM 与 CPU Rust 路径下,`word_timestamps=true` 会自动调用 forced aligner 返回字词级时间戳。
### 离线交付目录导出
如果你需要把镜像交付到不能联网的机器,推荐直接生成一个完整的离线交付目录。该脚本使用普通 `docker build` + `docker save`,不依赖 `buildx`:
```bash
# 生成 GPU 离线交付目录
./export_offline_bundle.sh --type gpu
# 或生成 CPU 离线交付目录
./export_offline_bundle.sh --type cpu
# 或生成沐曦 GPU 离线交付目录
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
--skip-models
./export_offline_bundle.sh --type iluvatar --iluvatar-base registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
./export_offline_bundle.sh --type mthreads --mthreads-base registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
# 或一次同时生成 GPU + CPU 离线交付目录
./export_offline_bundle.sh --type all
```
生成后的目录形如:
```text
build-file/
20260520_153000-all/
qwen3-asr-cpu-20260520_153000-amd64.tar.gz
qwen3-asr-gpu-20260520_153000-amd64.tar.gz
docker-compose.yml
docker-compose-cpu.yml
docker-compose-metax.yml
.env.example
init_host_dirs.sh
README.md
DEPLOYMENT.md
```
其中会自动包含:
- 对应类型的一份或两份镜像压缩包
- 对应类型的 compose 文件;天数包只包含 `docker-compose-iluvatar.yml`
- 摩尔线程包只包含 `docker-compose-mthreads.yml`
- `.env` 模板
- 宿主机目录初始化脚本
- 离线部署说明文档
模型文件建议使用 `./scripts/download-models.sh --models-dir /opt/dep/asr/models` 单独准备;该脚本增量补齐缺失模型,不删除已有目录,也不依赖 uv。
当前运行时 / 设备默认值以主 README 为准:
- `README.md`
- `docs/README_zh.md`
设计背景与实现思路可参考:
- 当前 Qwen3 后端:`NVIDIA/沐曦 GPU -> vLLM`、`CPU/macOS -> vendored QwenASR Rust`
- 引用项目 [QwenASR](https://github.com/huanglizhuo/QwenASR)
### macOS / Apple Silicon 本地部署
适用于 M1/M2/M3/M4 机器上的本地 Qwen3-ASR 推理。当前 macOS 已统一走 vendored QwenASR Rust CPU backend。
```bash
./scripts/sync_cpu_env.sh
source .venv/bin/activate
python start.py
```
### 验证部署
```bash
# 健康检查
curl http://localhost:17003/stream/v1/asr/health
# 查看可用模型
curl http://localhost:17003/stream/v1/asr/models
# 测试语音识别(阿里云协议)
curl -X POST "http://localhost:17003/stream/v1/asr" \
-H "Content-Type: application/octet-stream" \
--data-binary @test.wav
# 测试 OpenAI 兼容接口
curl -X POST "http://localhost:17003/v1/audio/transcriptions" \
-H "Authorization: Bearer any" \
-F "file=@test.wav" \
-F "model=qwen3-asr-1.7b"
```
## 从源码构建镜像
### 使用构建脚本
项目提供了一个更薄的 `scripts/build_docker.sh` 包装层,用于统一 `docker buildx` 参数:
```bash
# 构建所有版本(CPU + GPU)
./scripts/build_docker.sh
# 仅构建 GPU 版本
./scripts/build_docker.sh -t gpu
# 构建指定版本并推送
./scripts/build_docker.sh -t all -v 1.0.1 -p
# 查看帮助
./scripts/build_docker.sh -h
```
**构建脚本参数:**
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `-a, --arch` | 目标架构: `amd64`, `arm64`, `multi` | `amd64` |
| `-t, --type` | 构建类型: `cpu`, `gpu`, `all` | `all` |
| `-v, --version` | 版本标签 | `latest` |
| `-p, --push` | 构建后推送到 Docker Hub | 否 |
| `-e, --export` | 导出单架构镜像为 tar.gz | 否 |
| `-o, --output` | 导出目录 | `.` |
| `-r, --registry` | 镜像仓库 | `unis` |
| `-n, --no-cache` | 禁用 Docker 构建缓存 | 否 |
### 手动构建
```bash
# 构建 CPU 版本
docker build -t qwen3-asr:cpu-latest -f Dockerfile.cpu .
# 构建绑定当前机器指令集的 CPU 版本(仅适合同构部署)
docker build -t qwen3-asr:cpu-native -f Dockerfile.cpu \
--build-arg QWENASR_RUST_TARGET_CPU=native \
.
# 构建默认 GPU 版本(CUDA 13.0 / PyTorch cu130)
docker build -t qwen3-asr:gpu-cu130 -f Dockerfile.gpu .
# 构建 CUDA 12.6 版本
docker build -t qwen3-asr:gpu-cu126 -f Dockerfile.gpu \
--build-arg PYTORCH_BASE_IMAGE=pytorch/pytorch:2.11.0-cuda12.6-cudnn9-runtime \
--build-arg PYTORCH_CUDA_INDEX=https://download.pytorch.org/whl/cu126 \
--build-arg CUDA_NVCC_PACKAGE=cuda-nvcc-12-6 \
--build-arg TORCH_CUDA_ARCH_LIST="8.0;8.6;8.9" \
.
# 构建 CUDA 13.0 版本
docker build -t qwen3-asr:gpu-cu130 -f Dockerfile.gpu \
--build-arg PYTORCH_BASE_IMAGE=pytorch/pytorch:2.11.0-cuda13.0-cudnn9-runtime \
--build-arg PYTORCH_CUDA_INDEX=https://download.pytorch.org/whl/cu130 \
--build-arg CUDA_NVCC_PACKAGE=cuda-nvcc-13-0 \
--build-arg TORCH_CUDA_ARCH_LIST="12.0+PTX" \
.
```
`Dockerfile.cpu` 可覆盖的 CPU 构建参数:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `QWENASR_RUST_TARGET_CPU` | `x86-64-v2` | amd64 Rust backend 编译目标;可设为 `native` 构建绑定当前 CPU 的镜像 |
`Dockerfile.gpu` 可覆盖的 GPU 构建参数:
| 参数 | 默认值 | 用途 |
|------|--------|------|
| `PYTORCH_BASE_IMAGE` | `pytorch/pytorch:2.11.0-cuda13.0-cudnn9-runtime` | 选择 PyTorch/CUDA 基础镜像 |
| `PYTORCH_CUDA_INDEX` | `https://download.pytorch.org/whl/cu130` | 选择 PyTorch wheel CUDA 后端 |
| `CUDA_NVCC_PACKAGE` | `cuda-nvcc-13-0` | 安装匹配的 nvcc,用于 vLLM/FlashInfer JIT |
| `TORCH_CUDA_ARCH_LIST` | `12.0+PTX` | 指定 JIT 编译目标架构 |
| `VLLM_PACKAGE` | `vllm==0.20.0` | 覆盖 vLLM 包版本或来源 |
### 模型说明
服务支持以下 ASR 模型:
| 模型 | 说明 | 适用场景 |
|------|------|----------|
| Qwen3-ASR-1.7B ⭐ | 多语言 ASR(52种语言+方言,字级时间戳) | CUDA |
| Qwen3-ASR-0.6B | 轻量版多语言 ASR | CUDA / CPU Rust / macOS |
**运行时模型选择:**
系统根据机器资源自动选择合适的 Qwen3-ASR 模型:
- **显存 >= 32GB**: 自动加载 `qwen3-asr-1.7b`
- **显存 < 32GB**: 自动加载 `qwen3-asr-0.6b`
- **无 CUDA**: 自动加载基于 vendored Rust 的 `qwen3-asr-0.6b`
- **macOS / Apple Silicon**: 无论内存大小多少,默认都加载 `qwen3-asr-0.6b`
- **环境变量覆盖**: 设置 `QWEN3_ASR_MODEL=qwen3-asr-1.7b` 或 `QWEN3_ASR_MODEL=qwen3-asr-0.6b` 可硬覆盖自动选择
### 模型下载
启动时会先检测当前运行计划所需模型;如果本地缓存缺失,会自动从 ModelScope 下载。离线部署请提前准备模型缓存。
手动准备方式:
```bash
# 增量补齐离线部署所需模型,不删除已有文件
./scripts/download-models.sh --models-dir /opt/dep/asr/models
# 如果本机缺少 Python 依赖,也可以使用已构建镜像下载
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5 \
./scripts/download-models.sh --mode docker --models-dir /opt/dep/asr/models
```
离线部署时,推荐目录结构:
```text
/opt/dep/asr/models/
Qwen/
iic/
damo/
```
然后保持与 compose 文件一致的挂载:
```yaml
volumes:
- ${MODEL_STORAGE_DIR:-/opt/dep/asr/models}:/app/models
- ${DATA_STORAGE_DIR:-/opt/dep/asr/data}:/app/data
```
## 环境变量配置
### 基础配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `HOST` | `0.0.0.0` | 服务绑定地址 |
| `PORT` | `8000` | 服务端口 |
| `DEBUG` | `false` | 调试模式(启用后可访问 /docs) |
| `LOG_LEVEL` | `INFO` | 日志级别:DEBUG, INFO, WARNING, ERROR |
| `WORKERS` | `1` | 工作进程数(多进程会复制模型,显存成倍增加) |
| `MAX_AUDIO_SIZE` | `2048` | 最大音频文件大小(MB,支持单位如 2GB) |
| `API_KEY` | - | 服务端统一鉴权密钥 |
### 设备配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `DEVICE` | `auto` | 设备选择:`auto`, `cpu`, `cuda:0` |
| `ASR_VISIBLE_DEVICES` | `0` | 统一可见 GPU 设备配置,程序会按当前 accelerator 自动映射到底层变量 |
| `ASR_DEPLOY_TOPOLOGY` | `isolated` | 部署拓扑:`isolated`, `sharded`, `auto` |
### 内置 Nginx 与限流配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `NGINX_RATE_LIMIT_RPS` | `0` | 全局每秒请求上限,`0` 表示关闭 |
| `NGINX_RATE_LIMIT_BURST` | `0` | 全局突发请求数,`0` 时自动取 `NGINX_RATE_LIMIT_RPS` |
### ASR 模型配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ASR_ENABLE_REALTIME_PUNC` | `true` | 是否启用实时标点模型 |
### 性能优化配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ASR_BATCH_SIZE` | `4` | 长音频分段后的 ASR 批处理大小 |
| `INFERENCE_THREAD_POOL_SIZE` | 自动 | 推理线程池大小;默认按 CPU 核数自动设置 |
| `MAX_SEGMENT_SEC` | `60` | 音频分段最大时长(秒) |
| `QWEN_GPU_MEMORY_UTILIZATION` | `0.9` | vLLM 可保留的 GPU 显存上限,KV cache 不足时可适当调高 |
| `QWEN_VLLM_ENFORCE_EAGER` | `true` | 强制 vLLM eager 执行以提高兼容性;NVIDIA 性能测试可设为 `false` 允许 CUDA Graph 优化 |
| `WS_MAX_BUFFER_SIZE` | `160000` | WebSocket 音频缓冲区大小(样本数) |
| `QWEN_RUST_CPU_WORKERS` | `4` | CPU Rust backend worker 数;Rust ASR / forced align 默认按该数量并行 |
| `QWEN_RUST_ASR_CONCURRENCY` | `0` | Rust ASR 阶段批内并行度;`0` 表示跟随 `QWEN_RUST_CPU_WORKERS` |
| `QWEN_RUST_ALIGN_CONCURRENCY` | `0` | Rust forced align 阶段批内并行度;`0` 表示跟随 `QWEN_RUST_CPU_WORKERS` |
| `QWENASR_LIBRARY_PATH` | 自动探测 | 覆盖 vendored Rust 动态库路径 |
### 远场过滤配置
流式 ASR 远场声音过滤功能,自动过滤远场声音和环境音:
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ASR_ENABLE_NEARFIELD_FILTER` | `true` | 启用远场声音过滤 |
| `ASR_NEARFIELD_RMS_THRESHOLD` | `0.01` | RMS 能量阈值 |
| `LOG_LEVEL=DEBUG` | - | 需要观察过滤细节时打开调试日志 |
调优建议:
- `ASR_NEARFIELD_RMS_THRESHOLD=0.01` 是当前默认值,也是推荐起点
- 嘈杂环境可以适当调高,增强背景语音过滤
- 安静环境如果出现小声说话漏识别,可以适当调低
- 需要观察过滤行为时,可临时设置 `LOG_LEVEL=DEBUG`
### 鉴权配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `API_KEY` | - | 服务端统一鉴权密钥;同时兼容 `Authorization: Bearer` 和 `X-NLS-Token` |
**使用示例:**
```bash
# 使用 Token
curl -H "X-NLS-Token: your_token" http://localhost:8000/stream/v1/asr/health
# 使用 Bearer Token(OpenAI 兼容)
curl -H "Authorization: Bearer your_token" http://localhost:8000/v1/models
```
### 日志配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `LOG_LEVEL` | `INFO` | 日志级别:`DEBUG`, `INFO`, `WARNING` |
| `LOG_FILE` | `data/logs/qwen3-asr.log` | 日志文件路径 |
| `LOG_MAX_BYTES` | `20971520` | 单个日志文件最大大小(20MB) |
| `LOG_BACKUP_COUNT` | `50` | 日志备份文件数量 |
## Docker Compose 配置
### 基础配置(GPU)
```yaml
services:
qwen3-asr:
image: unis/qwen3-asr:gpu-latest
container_name: qwen3-asr
ports:
- "17003:8000"
volumes:
- /opt/dep/asr/models:/app/models
- /opt/dep/asr/data:/app/data
environment:
- DEBUG=false
- LOG_LEVEL=INFO
- DEVICE=auto
- QWEN_GPU_MEMORY_UTILIZATION=0.3
- QWEN_VLLM_ENFORCE_EAGER=true
- ASR_BATCH_SIZE=4
- WORKERS=1
- INFERENCE_THREAD_POOL_SIZE=4
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
### CPU 版本配置
```yaml
services:
qwen3-asr:
image: unis/qwen3-asr:cpu-latest
container_name: qwen3-asr
ports:
- "17003:8000"
volumes:
- /opt/dep/asr/models:/app/models
- /opt/dep/asr/data:/app/data
environment:
- DEBUG=false
- LOG_LEVEL=INFO
- DEVICE=cpu
- WORKERS=1
- INFERENCE_THREAD_POOL_SIZE=1
restart: unless-stopped
```
### 生产环境配置(内置 Nginx,推荐)
```yaml
services:
qwen3-asr:
image: unis/qwen3-asr:gpu-latest
container_name: qwen3-asr
ports:
- "17003:8000"
volumes:
- /opt/dep/asr/models:/app/models
- /opt/dep/asr/data:/app/data
environment:
- DEBUG=false
- LOG_LEVEL=INFO
- DEVICE=auto
- CUDA_VISIBLE_DEVICES=0,1
- QWEN_GPU_MEMORY_UTILIZATION=0.3
- QWEN_VLLM_ENFORCE_EAGER=true
- NGINX_RATE_LIMIT_RPS=20
- NGINX_RATE_LIMIT_BURST=40
- WORKERS=1
- INFERENCE_THREAD_POOL_SIZE=4
- ASR_BATCH_SIZE=4
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
## 服务监控
### 健康检查
```bash
curl http://localhost:17003/stream/v1/asr/health
```
### 日志监控
```bash
# 实时查看日志
docker logs -f qwen3-asr
# 查看错误日志
docker logs qwen3-asr 2>&1 | grep -i error
```
### 资源监控
```bash
# 容器资源使用
docker stats qwen3-asr
# GPU 使用情况
docker exec -it qwen3-asr nvidia-smi
```
## 资源需求
### 最小配置(CPU 版本)
- CPU: 4 核
- 内存: 8GB
- 磁盘: 10GB
### 推荐配置(GPU 版本)
- CPU: 8 核
- 内存: 16GB
- GPU: NVIDIA GPU (12GB+ 显存,含说话人分离模型)
- 磁盘: 25GB
## 故障排除
### 常见问题
| 问题 | 症状 | 解决方案 |
|------|------|----------|
| GPU 内存不足 | CUDA OOM 错误 | 设置 `DEVICE=cpu` 或使用更大显存的 GPU |
| 模型加载失败 / 缓慢 | 本地模型缓存缺失 | 先运行 `./scripts/download-models.sh --models-dir /opt/dep/asr/models` 预准备模型 |
| 端口被占用 | 端口冲突错误 | 修改端口映射:`"8080:8000"` |
| 说话人分离失败 | CAM++ 模型错误 | 检查模型是否完整下载,显存是否充足 |
### 调试模式
```bash
# 启用调试模式
docker run -e DEBUG=true -e LOG_LEVEL=DEBUG ...
# 进入容器调试
docker exec -it qwen3-asr /bin/bash
```
## 更新服务
```bash
# 拉取最新镜像(GPU 版本)
docker pull unis/qwen3-asr:gpu-latest
# 拉取最新镜像(CPU 版本)
docker pull unis/qwen3-asr:cpu-latest
# 重启服务
docker-compose down && docker-compose up -d
```

View File

@ -1,242 +0,0 @@
# 天数 GPU 国产化离线部署指南
本文档用于天数/Iluvatar GPU 环境的编译、模型准备、离线包导出与目标机部署。
## 推荐基础镜像
天数默认推荐使用以下官方 vLLM 镜像作为基础镜像:
```bash
registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
```
该镜像负责提供天数 IX 运行时、PyTorch、vLLM 与相关内核。本项目的 `Dockerfile.iluvatar` 只叠加通用 Python 依赖和业务代码,不在 Dockerfile 内重新安装 vLLM,也不使用 uv 虚拟环境。
## 目录约定
目标机推荐统一使用以下宿主机目录:
```text
/opt/dep/asr/
models/
data/
logs/
temp/
tasks/
```
容器内默认挂载为:
```text
/app/models
/app/data
```
## 在线编译融合镜像
在可访问天数镜像仓库和 Python 包源的构建机上执行:
```bash
docker pull registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5
./scripts/package_vendor_gpu_image.sh \
--vendor iluvatar \
--base-image registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5
```
脚本会生成融合镜像:
```text
unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5
```
并导出镜像归档到:
```text
build-file/qwen3-asr-iluvatar-vllm0.17.0-4.4.0-v5-amd64.tar.gz
```
如果只需要本机镜像,不需要导出 tar 包,可增加 `--no-export`。
## 单独准备模型
模型下载建议独立于业务服务执行。`download-models.sh` 是增量下载脚本,不会删除已有模型目录,也不依赖 uv。
在项目根目录或离线包目录执行:
```bash
./scripts/download-models.sh --models-dir /opt/dep/asr/models
```
如果本机没有 Python 依赖,但已经有融合镜像,可以用镜像内环境下载:
```bash
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5 \
./scripts/download-models.sh \
--mode docker \
--models-dir /opt/dep/asr/models
```
模型目录最终应至少包含:
```text
/opt/dep/asr/models/
Qwen/
Qwen3-ASR-0.6B/
Qwen3-ForcedAligner-0.6B/
damo/
iic/
```
## 导出完整离线交付包
如果要交付给不能联网的目标机,推荐直接导出天数专用离线包:
```bash
./export_offline_bundle.sh \
--type iluvatar \
--iluvatar-base registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5
```
如果模型要单独准备,不希望离线包包含模型压缩包:
```bash
./export_offline_bundle.sh \
--type iluvatar \
--iluvatar-base registry.iluvatar.com.cn:10443/customer/sz/vllm0.17.0-4.4.0-x86:v5 \
-v vllm0.17.0-4.4.0-v5 \
--skip-models
```
天数离线包只会包含天数专用启动文件:
```text
docker-compose-iluvatar.yml
.env
.env.example
init_host_dirs.sh
download-models.sh
download_models_standalone.py
README.md
DEPLOYMENT.md
BUNDLE_INFO.txt
qwen3-asr-iluvatar-*-amd64.tar.gz
```
不会再要求使用通用 `docker-compose.yml`。
## 目标机离线部署
将整个离线包复制到目标机后,进入离线包目录:
```bash
chmod +x init_host_dirs.sh download-models.sh
./init_host_dirs.sh
```
导入镜像:
```bash
gunzip -c qwen3-asr-iluvatar-vllm0.17.0-4.4.0-v5-amd64.tar.gz | docker load
```
确认 `.env` 中的镜像名与导入镜像一致:
```env
ASR_IMAGE=unis/qwen3-asr:iluvatar-vllm0.17.0-4.4.0-v5
```
按需设置显卡与 vLLM 显存比例:
```env
ILUVATAR_VISIBLE_DEVICES=0
IX_VISIBLE_DEVICES=0
CUDA_VISIBLE_DEVICES=0
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
启动服务:
```bash
docker compose -f docker-compose-iluvatar.yml up -d
```
查看状态与日志:
```bash
docker compose -f docker-compose-iluvatar.yml ps
docker compose -f docker-compose-iluvatar.yml logs -f
```
服务默认监听 host 网络端口:
```text
http://<目标机IP>:17003
```
## 已导出的旧离线包处理
如果旧离线包里的镜像已经能识别 `Qwen3ASRForConditionalGeneration`,但启动时报 KV cache 不足,例如:
```text
Try increasing gpu_memory_utilization or decreasing max_model_len
```
不需要重新打镜像。只需要在旧离线包的 `docker-compose-iluvatar.yml` 的 `QWEN3_ASR_MODEL` 附近补充:
```yaml
QWEN_GPU_MEMORY_UTILIZATION: ${QWEN_GPU_MEMORY_UTILIZATION:-0.25}
QWEN_VLLM_ENFORCE_EAGER: ${QWEN_VLLM_ENFORCE_EAGER:-true}
```
然后重新创建容器:
```bash
docker compose -f docker-compose-iluvatar.yml down
docker compose -f docker-compose-iluvatar.yml up -d
```
只执行 `restart` 不会重新注入环境变量。
## 常见问题
### 为什么不用 uv?
天数 Docker 镜像内推荐直接使用系统 Python 环境。`Dockerfile.iluvatar` 使用:
```bash
python3 -m pip install --no-cache-dir -r environments/iluvatar/requirements.txt
```
不创建 uv 虚拟环境,也不在镜像内运行 `uv pip install`。
### 为什么不重新安装 vLLM?
国产 GPU 的 vLLM、PyTorch、内核和运行时通常需要严格匹配厂商镜像。项目层重新 `pip install vllm` 容易解析到 PyPI/NVIDIA CUDA 依赖,破坏天数官方镜像里的匹配关系。
### `QWEN_GPU_MEMORY_UTILIZATION` 为什么没生效?
变量必须进入容器才会生效。天数 compose 中需要有:
```yaml
QWEN_GPU_MEMORY_UTILIZATION: ${QWEN_GPU_MEMORY_UTILIZATION:-}
QWEN_VLLM_ENFORCE_EAGER: ${QWEN_VLLM_ENFORCE_EAGER:-true}
```
然后在 `.env` 设置:
```env
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
修改 `.env` 后必须 `down` 再 `up -d`。
### 日志中的本地模型 repo id warning 是否致命?
vLLM 可能会先尝试按远端 repo 方式读取 safetensors,遇到本地路径时打印 warning。如果后续出现 `Loading safetensors checkpoint shards` 并继续加载权重,通常不是致命错误。
真正需要处理的是最后的异常,例如模型架构不识别、KV cache 不足、模型文件缺失等。

View File

@ -1,234 +0,0 @@
# 沐曦 GPU 国产化离线部署指南
本文档用于沐曦/MetaX GPU 环境的编译、模型准备、离线包导出与目标机部署。
## 基础镜像原则
沐曦部署应使用沐曦官方或现场确认的 vLLM/MACA 基础镜像:
```bash
cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64
```
该镜像负责提供 MACA 运行时、PyTorch、vLLM 与相关内核。本项目的 `Dockerfile.metax` 只叠加通用 Python 依赖和业务代码,不在 Dockerfile 内重新安装 vLLM,也不使用 uv 虚拟环境。
## 目录约定
目标机推荐统一使用以下宿主机目录:
```text
/opt/dep/asr/
models/
data/
logs/
temp/
tasks/
```
容器内默认挂载为:
```text
/app/models
/app/data
```
## 在线编译融合镜像
先按沐曦官网复制的命令登录并拉取基础镜像。账号、密码和 token 不要写入项目文件、`.env` 或离线包。
官网命令通常形如:
```bash
docker login --username=<沐曦账号> --password=<沐曦token> cr.metax-tech.com && \
docker pull cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64
```
如果沐曦基础镜像是 tar 包,则改为先导入:
```bash
docker load -i metax-vllm-official.tar
docker images | grep -i -E 'metax|maca|vllm'
```
然后用完整官方镜像名构建融合镜像:
```bash
./scripts/package_vendor_gpu_image.sh \
--vendor metax \
--base-image cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
-v 0.17.0-maca.ai3.5.3.307
```
脚本会生成融合镜像:
```text
unis/qwen3-asr:metax-0.17.0-maca.ai3.5.3.307
```
并导出镜像归档到:
```text
build-file/qwen3-asr-metax-0.17.0-maca.ai3.5.3.307-amd64.tar.gz
```
## 单独准备模型
模型下载建议独立于业务服务执行。`download-models.sh` 是增量下载脚本,不会删除已有模型目录,也不依赖 uv。
在项目根目录或离线包目录执行:
```bash
./scripts/download-models.sh --models-dir /opt/dep/asr/models
```
如果本机没有 Python 依赖,但已经有融合镜像,可以用镜像内环境下载:
```bash
ASR_IMAGE=unis/qwen3-asr:metax-0.17.0-maca.ai3.5.3.307 \
./scripts/download-models.sh \
--mode docker \
--models-dir /opt/dep/asr/models
```
## 导出完整离线交付包
```bash
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
-v 0.17.0-maca.ai3.5.3.307
```
如果模型要单独准备,不希望离线包包含模型压缩包:
```bash
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
-v 0.17.0-maca.ai3.5.3.307 \
--skip-models
```
如果不指定 `-v`,脚本会使用当前时间戳作为版本号。
沐曦官方镜像内的 Python 默认使用 `/opt/conda/bin/python`。如果现场镜像路径不同,可额外指定:
```bash
./export_offline_bundle.sh \
--type metax \
--metax-base cr.metax-tech.com/public-ai-release/maca/vllm-metax:0.17.0-maca.ai3.5.3.307-torch2.8-py312-ubuntu22.04-amd64 \
--metax-python /path/to/python \
--skip-models
```
也可以先给官方长镜像名打一个本地短标签,再把短标签传给 `--metax-base`;这只是为了少复制长镜像名,不是必需步骤。
沐曦离线包只会包含沐曦专用启动文件:
```text
docker-compose-metax.yml
.env
.env.example
init_host_dirs.sh
download-models.sh
download_models_standalone.py
README.md
DEPLOYMENT.md
METAX_DEPLOYMENT.md
BUNDLE_INFO.txt
qwen3-asr-metax-*-amd64.tar.gz
```
不会要求使用通用 `docker-compose.yml`。
## 目标机离线部署
将整个离线包复制到目标机后,进入离线包目录:
```bash
chmod +x init_host_dirs.sh download-models.sh
./init_host_dirs.sh
```
导入镜像:
```bash
gunzip -c qwen3-asr-metax-0.17.0-maca.ai3.5.3.307-amd64.tar.gz | docker load
```
确认 `.env` 中的镜像名与导入镜像一致:
```env
ASR_IMAGE=unis/qwen3-asr:metax-0.17.0-maca.ai3.5.3.307
```
按需设置显卡与 vLLM 显存比例:
```env
METAX_VISIBLE_DEVICES=0
MACA_VISIBLE_DEVICES=0
MX_VISIBLE_DEVICES=0
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
启动服务:
```bash
docker compose -f docker-compose-metax.yml up -d
```
查看状态与日志:
```bash
docker compose -f docker-compose-metax.yml ps
docker compose -f docker-compose-metax.yml logs -f
```
服务默认端口:
```text
http://<目标机IP>:17003
```
## 沐曦与天数的差异
- 沐曦使用 `Dockerfile.metax` 和 `docker-compose-metax.yml`
- 天数使用 `Dockerfile.iluvatar` 和 `docker-compose-iluvatar.yml`
- 沐曦目标机需要可访问宿主 `/dev` 与 `/opt/mxdriver`
- 沐曦 compose 使用 host network、host pid/ipc、privileged,并挂载 `/dev` 与 `/opt/mxdriver`
- 天数 compose 同样使用 host network、host pid/ipc、privileged 和宿主机设备/驱动目录挂载
## 常见问题
### 为什么不用 uv?
沐曦 Docker 镜像内推荐直接使用系统 Python 环境。`Dockerfile.metax` 使用:
```bash
python3 -m pip install --no-cache-dir -r environments/metax/requirements.txt
```
不创建 uv 虚拟环境,也不在镜像内运行 `uv pip install`。
### 为什么不重新安装 vLLM?
国产 GPU 的 vLLM、PyTorch、内核和运行时通常需要严格匹配厂商镜像。项目层重新 `pip install vllm` 容易解析到 PyPI/NVIDIA CUDA 依赖,破坏沐曦官方镜像里的匹配关系。
### `QWEN_GPU_MEMORY_UTILIZATION` 为什么没生效?
变量必须进入容器才会生效。沐曦 compose 中需要有:
```yaml
QWEN_GPU_MEMORY_UTILIZATION: ${QWEN_GPU_MEMORY_UTILIZATION:-}
QWEN_VLLM_ENFORCE_EAGER: ${QWEN_VLLM_ENFORCE_EAGER:-true}
```
然后在 `.env` 设置:
```env
QWEN_GPU_MEMORY_UTILIZATION=0.25
QWEN_VLLM_ENFORCE_EAGER=true
```
修改 `.env` 后必须 `down` 再 `up -d`。

View File

@ -1,53 +0,0 @@
# 摩尔线程 GPU 国产化离线部署指南
推荐使用摩尔线程官方 MUSA vLLM 基础镜像:
```bash
registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
```
该镜像负责提供 MUSA 运行时、PyTorch、vLLM 与相关内核。本项目的 `Dockerfile.mthreads` 只叠加通用 Python 依赖和业务代码,不在 Dockerfile 内重新安装 vLLM,也不使用 uv 虚拟环境。
## 1. 拉取官方镜像
```bash
docker pull registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
```
## 2. 生成融合镜像
```bash
./scripts/package_vendor_gpu_image.sh \
--vendor mthreads \
--base-image registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519 \
-v s4000_4.3.5_d0519
```
## 3. 启动服务
```bash
ASR_IMAGE=unis/qwen3-asr:mthreads-s4000_4.3.5_d0519 \
docker compose -f docker-compose-mthreads.yml up -d
```
多卡示例:
```bash
MTHREADS_VISIBLE_DEVICES=0,1 docker compose -f docker-compose-mthreads.yml up -d
```
项目默认同时兼容 `MTHREADS_VISIBLE_DEVICES`、`MUSA_VISIBLE_DEVICES` 和 `CUDA_VISIBLE_DEVICES`。
## 4. 离线交付目录
```bash
./export_offline_bundle.sh \
--type mthreads \
--mthreads-base registry.mthreads.com/presale/devtech/vllm_musa:s4000_4.3.5_d0519
```
## 说明
- 容器内设备探测命令使用 `mthreads-gmi`
- `ACCELERATOR=mthreads` 会走 vLLM GPU 路径
- 不建议在项目层重新 `pip install vllm`,避免破坏厂商镜像内的 MUSA 依赖匹配关系

View File

@ -1,412 +0,0 @@
# 实时会议识别改造报告
## 1. 背景
当前 `Qwen-Asr` 的实时会议链路,已经暴露出 3 类系统性问题:
1. 长句在 `max_duration` 或静音点附近容易被截坏,出现残句、半句、尾词漂移。
2. `partial` 与 `final segment` 责任混杂,静音、串音、背景音、测试语种会直接污染最终结果。
3. 说话人识别仍以“句级单 embedding + 会话聚类”为主,面对插话、背景音、儿童声音、英语/泰语测试时容易抖动。
这些问题并不是单个阈值导致的,而是实时链路的职责分层不清晰。
## 2. 外部方案调研结论
本次调研覆盖了:
- vLLM / Qwen3-ASR 官方实时文档
- `diart`
- `pyannote.audio`
- NVIDIA NeMo `Streaming Sortformer`
- 在线 speaker diarization / label matching 论文
- streaming ASR partial stability / endpointing 论文
### 2.1 vLLM/Qwen3-ASR 的边界
vLLM 的 `Qwen3-ASR realtime` 更像“流式推理后端”,它负责:
- 累积音频
- 到固定块长后执行流式推理
- 提供 `flush/finish`
它不负责:
- 会议场景 endpointing
- speaker diarization
- partial 稳定化
- 说话人标签稳定跟踪
结论:
`vLLM 只能做实时 ASR 的第一层,不是完整会议链路的解决方案。`
### 2.2 开源实时 speaker diarization 的主流范式
#### A. `diart` 范式
特征:
- rolling buffer
- overlap-aware segmentation
- incremental clustering
- cannot-link constraints
- 低延迟滚动更新
优点:
- 工程上相对容易接入
- 很适合作为现有系统的 speaker 旁路
#### B. `Streaming Sortformer` 范式
特征:
- 真正 streaming diarization
- chunk-based processing
- speaker cache / AOSC
- 跨 chunk 标签稳定
优点:
- 更像现代工业方案
- 标签稳定性更强
缺点:
- 接入成本高于 `diart`
### 2.3 论文结论
#### 在线 diarization 的关键问题不是“分离”,而是“标签一致性”
主流论文强调:
- 在线 diarization 必须解决 label matching
- 否则 `Speaker01/02/03` 会在不同 chunk 间乱跳
#### streaming ASR 的关键问题不是“能不能出字”,而是“partial 稳定性”
论文结论:
- partial 会被不断修正
- partial 不应直接当 final 使用
- 需要单独设计稳定策略
#### endpointing 对长句质量至关重要
论文与开源实现都说明:
- 只靠静音阈值不够
- 最好使用 VAD/SAD 或模型辅助 endpointing
- `max_duration` 只能是兜底机制,不应成为主要切句方式
## 3. 当前项目存在的核心架构问题
### 3.1 `partial` 和 `final` 混线
当前链路中:
- `partial` 的输出直接影响最终句子定稿
- 静音前后、噪声和串音有机会直接污染最终句子
这会带来:
- 静音幻觉
- 末尾漂移
- 错误残句
### 3.2 断句主要靠“能量阈值 + 最长时长”
当前主逻辑仍然偏向:
- `self._has_voice(audio)` 做粗能量判定
- `silence_samples` 达阈值就截断
- `12s max_duration` 强行保底
这会导致:
- 长句被硬切
- 断句点不自然
- 下一段拿到的是残尾巴
### 3.3 speaker 仍然是“句级单 embedding”
当前 speaker 的主要依据是:
- 当前句或其裁剪后的音频
- 提一个 embedding
- 与会话内 slot 聚类
这对干净单人句子有效,但对会议场景不够:
- 一句里可能混多人
- 背景音会污染 embedding
- 不同 chunk 之间缺少真正的 diarization cache
### 3.4 registry match 介入过早
当前一旦句级聚类通过阈值,就可能直接映射实名。
这会带来:
- 错误聚类被直接升级为错实名
- 后续纠正空间变小
## 4. 推荐的目标架构
建议将实时会议链路拆成 4 层:
### Layer A: Streaming ASR Partial
职责:
- 面向 UI 输出中间文本
- 只作为“参考内容”
- 不入库
- 不参与 speaker
- 不做实名映射
要求:
- 可以允许回退、修正
- 需要稳定策略,但不要求和 final 完全一致
### Layer B: Endpointing / Final Segment Flush
职责:
- 决定“什么时候一句真正结束”
- 产出 final segment
推荐方式:
- `pending_buffer`
- 周期性 VAD/SAD 检查
- 只刷出“已完成”的语音片段
- 最后一段继续留 buffer,等待更多上下文
说明:
- `max_duration` 仍保留
- 但只作为异常保底
### Layer C: Online Speaker Tracking
职责:
- 产出稳定 `Speaker01/02/03/...`
- 不直接输出实名
推荐路线:
- 低成本版:`rolling window diarization + incremental clustering`
- 强化版:`streaming diarization + speaker cache`
必要能力:
- label matching
- speaker cache
- cluster centroid 更新
- recent speaker continuity
### Layer D: Speaker Registry Match
职责:
- 把稳定的 slot 映射成实名
原则:
- 先有稳定 `Speaker01/02/03`
- 再做 registry match
- 不要在每个短句上直接实名匹配
## 5. 对当前代码库的具体落地建议
### 5.1 保留 `Qwen3ASREngine` 作为流式 ASR 后端
文件:
- `app/services/asr/qwen3_engine.py`
建议:
- 继续让它只负责 `init_streaming_state / streaming_transcribe / finish_streaming_transcribe`
- 不再让它承担句边界和 speaker 逻辑
### 5.2 重构 `qwen3_websocket_asr.py`
文件:
- `app/services/qwen3_websocket_asr.py`
建议把它拆成以下内部组件:
1. `PartialStreamController`
- 负责把有声 chunk 喂给流式 ASR
- 维护 partial 状态
2. `RealtimeEndpointController`
- 维护 `pending_buffer`
- 周期性 VAD flush
- 产出 finalized audio span
3. `RealtimeSpeakerController`
- 对 finalized span 做 speaker tracking
- 维护 `Speaker01/02/...`
4. `RegistryMatchController`
- 在 slot 稳定后映射实名
### 5.3 speaker tracker 升级方向
当前文件:
- `app/services/realtime_speaker_tracker.py`
建议保留它,但升级为:
- slot centroid
- slot history
- recent speaker cache
- explicit label matching step
- “未知 slot” 与 “实名 slot” 分层
不建议继续只用:
- `assign(embedding)` 的一次性聚类结果
### 5.4 引入真正的 VAD completed-segment flush
当前项目最应该借鉴老项目的部分就是:
- `pending_buffer`
- `flush_completed_segments`
- 只把已完成语音刷成 final
建议新建模块,例如:
- `app/services/realtime_endpointing.py`
它负责:
- 累积 PCM
- 每隔固定步长跑 VAD
- 判断哪些片段已完成
- 返回 `finalized_audio` 与 `remaining_audio`
### 5.5 `max_duration` 的正确角色
建议保留,但只作为:
- buffer 过长
- 长时间不出句
- VAD 失效
时的保底。
不建议让它继续承担:
- 常规切句
- 主要 final 机制
## 6. 推荐改造路线
### 阶段 1:先把文本链路拆干净
目标:
- `partial` 只显示
- `final segment` 只由 endpointing 决定
工作项:
- 引入 `pending_buffer + VAD flush`
- 保留现有 websocket 接口字段不变
- `max_duration` 降级为兜底机制
### 阶段 2:speaker 从句级 embedding 升级为在线 tracking
目标:
- 稳定 `Speaker01/02/03`
工作项:
- 引入 rolling window diarization 或 speaker turn 检测
- 增加 label matching
- 增加 speaker cache
### 阶段 3:实名映射后移
目标:
- 先稳定 slot
- 再实名
工作项:
- registry match 不再逐句触发
- 改成基于 slot centroid 或稳定窗口触发
### 阶段 4:partial 稳定策略
目标:
- UI 不再频繁抖动
工作项:
- prefix commit
- suffix freeze
- partial stability score
## 7. 不建议继续做的事
以下方向收益很低,且会继续增加系统复杂度:
- 继续堆更多 `if suspicious`
- 继续调 `speaker_threshold`
- 继续靠 `max_duration` 修长句
- 继续用句级单 embedding 解决多人会议 speaker
- 继续让 `partial` 直接影响 final
## 8. 结论
当前项目最需要的不是继续补条件分支,而是把实时会议能力拆成明确的四层:
1. `streaming partial`
2. `endpointing/final flush`
3. `online diarization / speaker tracking`
4. `registry match`
其中:
- `vLLM` 属于第 1 层
- 不是第 2、3、4 层的替代品
如果后续继续在当前单文件链路上修修补补,复杂度会继续升高,稳定性仍然不可控。
如果按本报告做分层重构,问题会从“靠猜修 bug”变成“按职责逐层验证”。
## 9. 参考资料
- vLLM realtime Qwen3-ASR model docs
<https://docs.vllm.ai/en/v0.20.1/api/vllm/model_executor/models/qwen3_asr_realtime/>
- vLLM Qwen3-ASR recipe
<https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-ASR.html>
- diart
<https://github.com/juanmc2005/diart>
- pyannote.audio
<https://github.com/pyannote/pyannote-audio>
- NVIDIA NeMo speaker diarization docs
<https://docs.nvidia.com/nemo-framework/user-guide/latest/nemotoolkit/asr/speaker_diarization/models.html>
- NVIDIA Streaming Sortformer blog
<https://developer.nvidia.com/blog/identify-speakers-in-meetings-calls-and-voice-apps-in-real-time-with-nvidia-streaming-sortformer/>
- Low-Latency Online Speaker Diarization with Graph-Based Label Generation
<https://arxiv.org/abs/2111.13803>
- TURN-TO-DIARIZE: Online Speaker Diarization Constrained by Transformer Transducer Speaker Turn Detection
<https://resourcecenter.ieee.org/conferences/icassp-2022/spsicassp22vid1565>
- Analyzing the Quality and Stability of a Streaming End-to-End On-Device Speech Recognizer
<https://arxiv.org/abs/2006.01416>
- Improving endpoint detection in end-to-end streaming ASR for conversational speech
<https://arxiv.org/abs/2505.17070>

View File

@ -1,417 +0,0 @@
# 实时会议 WebSocket 调用说明
本文档说明当前项目实时会议识别接口的调用方式、入参与返回格式。
## 1. 接口地址
- 推荐地址:`/ws/v1/asr`
- 显式地址:`/ws/v1/asr/qwen`
说明:
- `/ws/v1/asr/funasr` 已废弃,不再使用。
- 当前返回结构按腾讯实时识别风格组织,核心消息类型为:`voice_id`、`start`、`sentences`、`end`、`error`。
## 2. 交互流程
客户端调用顺序:
1. 建立 WebSocket 连接
2. 发送 `start` 消息
3. 持续发送音频二进制数据
4. 接收服务端返回的 `voice_id`、`start`、`sentences`
5. 发送 `stop`
6. 接收最终 `end`
## 3. start 消息
客户端先发送文本消息:
```json
{
"type": "start",
"payload": {
"format": "pcm",
"sample_rate": 16000,
"session_id": "meeting-abc-123",
"language": null,
"context": "",
"enable_inverse_text_normalization": true,
"silence_duration_ms": 800,
"min_partial_sec": 0.3,
"pre_roll_ms": 240,
"max_sentence_count": 8,
"partial_holdback_chars": 2,
"unfixed_token_num": 3,
"enable_native_partial_stream": true,
"enable_speaker": true,
"match_speaker_registry": true,
"speaker_threshold": 0.6,
"enable_realtime_vad_split": true,
"enable_realtime_longform": false,
"force_stable_segment_sec": 6,
"force_stable_min_chars": 24,
"max_segment_sec": 12
}
}
```
## 4. start 参数说明
### 必填/常用参数
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `format` | string | `pcm` | 当前建议传 `pcm` |
| `sample_rate` | number | `16000` | 采样率 |
| `session_id` | string | 自动生成/可不传 | 会话标识;断线重连时传同一个值可在保活期内恢复同一会议会话 |
| `language` | string/null | `null` | 识别语言,留空表示自动判断 |
| `context` | string | `""` | 上下文提示,如会议主题、术语 |
| `enable_inverse_text_normalization` | boolean | `true` | 是否启用数字归一化 |
### partial / 切段相关
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `silence_duration_ms` | number | `800` | 静音多久触发收段 |
| `min_partial_sec` | number | `0.9`(后端默认) | 最短 partial 窗口 |
| `pre_roll_ms` | number | `240` | 句首预读毫秒数 |
| `max_sentence_count` | number | `8` | 一段内最多允许的句子数 |
| `partial_holdback_chars` | number | 后端默认 | partial 尾部保留字数,减少尾字抖动 |
| `unfixed_token_num` | number | `5`(后端默认) | native partial 模式下回滚 token 数 |
| `enable_native_partial_stream` | boolean | `false` | 是否启用底层原生流式 partial |
| `enable_realtime_vad_split` | boolean | `false` | 是否启用实时 VAD 拆段 |
| `enable_realtime_longform` | boolean | `false` | 是否启用长段重转写 |
| `force_stable_segment_sec` | number | 后端默认 | 超时后软提交的时间阈值 |
| `force_stable_min_chars` | number | 后端默认 | 软提交最少字数 |
| `max_segment_sec` | number | 后端默认 | 单段最长秒数 |
### speaker 相关
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `enable_speaker` | boolean | `true` | 是否开启说话人分离 |
| `match_speaker_registry` | boolean | `false` | 是否匹配已注册声纹库 |
| `speaker_threshold` | number/null | `null` | 本次声纹匹配阈值 |
说明:
- `enable_speaker=true` 时,服务端会先返回文本,再异步补充 speaker 归属。
- `match_speaker_registry=true` 时,若匹配到声纹库,会返回实名 speaker 信息。
## 5. 音频数据发送
`start` 成功后,客户端持续发送音频二进制帧。
当前推荐:
- 单声道 PCM
- 16k 采样率
- 小块持续发送
示例:
```js
ws.send(pcmChunkArrayBuffer);
```
## 6. stop 消息
识别结束后发送:
```json
{
"type": "stop"
}
```
## 7. 服务端返回消息
## 7.1 `voice_id`
连接启动后首先返回:
```json
{
"type": "voice_id",
"voice_id": "41ff1926",
"session_id": "meeting-abc-123"
}
```
## 7.2 `start`
服务端确认开始识别:
```json
{
"type": "start",
"session_id": "meeting-abc-123"
}
```
说明:
- 若客户端在 `start.payload.session_id` 中传入固定值,服务端会优先复用该值。
- 若客户端未传 `session_id`,服务端仍按当前连接生成 `voice_id`,并同步作为 `session_id` 返回。
- 在 `REALTIME_SESSION_RESUME_TTL_SEC` 保活时间内,客户端断线后使用相同 `session_id` 再次发送 `start`,服务端会恢复原会话上下文。
## 7.3 `sentences`
识别过程中会持续返回 `sentences`。
重要说明:
- `sentence_type=0` / `slice_type=1` 表示实时 partial,仅用于实时展示。
- `sentence_type=1` / `slice_type=2` 表示该段最终定稿,建议作为最终业务入库依据。
- 若开启 `enable_native_partial_stream=true`,partial 刷新会更快,但中间文本可能更活。
- 若希望少存数据,建议不要存 partial,只对 `sentence_type=1` 做落库或 upsert。
### partial 返回
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 1,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 0,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15600,
"sentence": "今天这个会议主要讨论预算。"
}
]
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `slice_type=1` | partial |
| `sentence_type=0` | partial 句子 |
| `speaker_id=-1` | 当前还未确认 speaker |
### 定稿返回
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "",
"user_id": null
}
]
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `slice_type=2` | 该段文本已定稿 |
| `sentence_type=1` | final 句子 |
| `speaker_name` | 项目扩展字段 |
| `user_id` | 项目扩展字段 |
### speaker 回写返回
如果 speaker 后续识别完成,服务端会再次返回相同 `sentence_id` 的 `sentences` 消息,只更新 speaker 归属:
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": 19,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "Alan Paine",
"user_id": "3"
}
]
}
```
说明:
- speaker 是异步补归属,不保证和文本首个 final 同时返回。
- 前端应使用 `sentence_id` 做幂等更新,而不是简单追加。
## 7.3.1 partial 与 final 的使用建议
推荐使用方式:
- 前端实时显示:使用 `sentence_type=0`
- 最终文本入库:使用 `sentence_type=1`
- 说话人归属更新:继续按相同 `sentence_id` 更新已有 final 记录
推荐入库字段:
- `sentence_id`
- `start_time`
- `end_time`
- `sentence`
- `speaker_id`
- `speaker_name`
- `user_id`
推荐入库策略:
1. 收到 `sentences` 后,仅处理 `sentence_type=1`
2. 使用 `sentence_id` 作为同一连接内的幂等更新键
3. 若后续相同 `sentence_id` 再次返回,通常表示 speaker 归属补写,直接更新原记录
4. 收到 `end` 后,可将本次会话的 final 结果视为最终完成结果
## 7.4 `end`
客户端发送 `stop` 后,服务端返回最终结束消息:
```json
{
"type": "end",
"code": 0,
"message": "",
"voice_id": "41ff1926",
"final": 1,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "完整识别文本"
},
"sentences": [
{
"sentence_id": 0,
"sentence_type": 1,
"speaker_id": 19,
"start_time": 0,
"end_time": 3200,
"sentence": "第一句",
"speaker_name": "Alan Paine",
"user_id": "3"
}
]
}
```
## 7.5 `error`
异常时返回:
```json
{
"type": "error",
"code": -1,
"message": "错误信息",
"voice_id": "41ff1926"
}
```
## 8. 腾讯风格字段兼容说明
当前实时会议返回格式按腾讯实时 speaker demo 风格组织,核心字段保持一致:
- `type`
- `code`
- `voice_id`
- `final`
- `result.slice_type`
- `result.index`
- `result.voice_text_str`
- `sentences[].sentence_id`
- `sentences[].sentence_type`
- `sentences[].speaker_id`
- `sentences[].start_time`
- `sentences[].end_time`
- `sentences[].sentence`
在此基础上,项目额外补充:
- `sentences[].speaker_name`
- `sentences[].user_id`
## 9. JavaScript 调用示例
```js
const ws = new WebSocket("ws://127.0.0.1:8000/ws/v1/asr");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "start",
payload: {
format: "pcm",
sample_rate: 16000,
language: null,
context: "预算评审会",
enable_inverse_text_normalization: true,
silence_duration_ms: 800,
min_partial_sec: 0.3,
pre_roll_ms: 240,
max_sentence_count: 8,
partial_holdback_chars: 2,
unfixed_token_num: 3,
enable_native_partial_stream: true,
enable_speaker: true,
match_speaker_registry: true,
speaker_threshold: 0.6,
enable_realtime_vad_split: true,
enable_realtime_longform: false,
force_stable_segment_sec: 6,
force_stable_min_chars: 24,
max_segment_sec: 12
}
}));
};
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
console.log("ws message:", payload);
};
function sendPcmChunk(arrayBuffer) {
ws.send(arrayBuffer);
}
function stopRecognition() {
ws.send(JSON.stringify({ type: "stop" }));
}
```
## 10. 对接注意事项
- 前端要按 `sentence_id` 更新句子,不要把 speaker 回写当成新句子追加。
- `speaker_id=-1` 代表 speaker 暂未确认,不代表识别失败。
- 默认启用 `enable_native_partial_stream`;partial 刷新更快,但中间文本可能会修订。
- 最终展示建议以 `sentence_type=1` 的句子为准。
- 若需要排查前端是否真的传了某个参数,建议把 `start payload` 直接打印到页面日志。

View File

@ -1,297 +0,0 @@
# 实时会议 WebSocket 对接文档
本文档用于客户侧接入实时会议识别服务,包含接口地址、消息格式、返回示例和重连约定。
## 1. 接口地址
- 推荐地址:`/ws/v1/asr`
- 显式地址:`/ws/v1/asr/qwen`
说明:
- 当前协议为 JSON 控制消息 + 音频二进制流。
- 返回结构按实时会议场景组织,核心消息类型为:`voice_id`、`start`、`sentences`、`end`、`error`。
## 2. 交互流程
客户端调用顺序:
1. 建立 WebSocket 连接
2. 发送 `start` 文本消息
3. 持续发送音频二进制数据
4. 持续接收服务端返回的 `voice_id`、`start`、`sentences`
5. 识别结束时发送 `stop`
6. 接收最终 `end`
## 3. start 请求
### 3.1 请求示例
```json
{
"type": "start",
"payload": {
"format": "pcm",
"sample_rate": 16000,
"session_id": "meeting-abc-123",
"language": null,
"context": "",
"enable_inverse_text_normalization": true,
"silence_duration_ms": 800,
"min_partial_sec": 0.3,
"pre_roll_ms": 240,
"max_sentence_count": 8,
"partial_holdback_chars": 2,
"unfixed_token_num": 3,
"enable_native_partial_stream": true,
"enable_speaker": true,
"match_speaker_registry": true,
"speaker_threshold": 0.6,
"enable_realtime_vad_split": true,
"enable_realtime_longform": false,
"force_stable_segment_sec": 6,
"force_stable_min_chars": 24,
"max_segment_sec": 12
}
}
```
### 3.2 主要参数说明
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `format` | string | `pcm` | 建议传 `pcm` |
| `sample_rate` | number | `16000` | 音频采样率 |
| `session_id` | string | 可不传 | 会话标识;断线重连时传同一个值可恢复同一会议会话 |
| `language` | string/null | `null` | 留空表示自动识别 |
| `context` | string | `""` | 业务上下文提示,如会议主题、术语 |
| `enable_inverse_text_normalization` | boolean | `true` | 是否启用数字归一化 |
| `silence_duration_ms` | number | `800` | 静音多久触发收段 |
| `min_partial_sec` | number | 后端默认 | 最短 partial 窗口 |
| `pre_roll_ms` | number | `240` | 句首预读时长 |
| `max_sentence_count` | number | `8` | 单段最多句数 |
| `partial_holdback_chars` | number | 后端默认 | partial 尾部保留字数,减少尾字抖动 |
| `unfixed_token_num` | number | 后端默认 | native partial 模式下回滚 token 数 |
| `enable_native_partial_stream` | boolean | `false` | 是否启用原生流式 partial |
| `enable_speaker` | boolean | `true` | 是否开启说话人分离 |
| `match_speaker_registry` | boolean | `false` | 是否匹配已注册声纹库 |
| `speaker_threshold` | number/null | `null` | 本次声纹匹配阈值 |
| `enable_realtime_vad_split` | boolean | `false` | 是否启用实时 VAD 拆段 |
| `enable_realtime_longform` | boolean | `false` | 是否启用长段重转写 |
| `force_stable_segment_sec` | number | 后端默认 | 超时后软提交阈值 |
| `force_stable_min_chars` | number | 后端默认 | 软提交最少字数 |
| `max_segment_sec` | number | 后端默认 | 单段最长秒数 |
说明:
- `enable_speaker=true` 时,服务端会先返回文本,再异步补充 speaker 归属。
- `match_speaker_registry=true` 时,若匹配到声纹库,会返回实名 speaker 信息。
## 4. 音频发送
`start` 成功后,客户端持续发送音频二进制帧。
推荐格式:
- 单声道 PCM
- 16k 采样率
- 小块连续发送
示例:
```js
ws.send(pcmChunkArrayBuffer);
```
## 5. stop 请求
```json
{
"type": "stop"
}
```
## 6. 服务端返回
## 6.1 `voice_id`
连接建立并收到 `start` 后,服务端先返回:
```json
{
"type": "voice_id",
"voice_id": "41ff1926",
"session_id": "meeting-abc-123"
}
```
说明:
- `voice_id` 为本次实时识别标识。
- `session_id` 为本次会话标识;若未传,服务端会返回自动生成值。
## 6.2 `start`
服务端确认开始识别:
```json
{
"type": "start",
"session_id": "meeting-abc-123"
}
```
## 6.3 `sentences`
识别过程中会持续返回 `sentences`。
重要说明:
- `sentence_type=0` / `slice_type=1` 表示实时 partial,仅用于实时展示。
- `sentence_type=1` / `slice_type=2` 表示该段最终定稿,建议作为最终入库依据。
- 说话人可能异步补写,不保证和首个 final 同时返回。
### partial 返回示例
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 1,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 0,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15600,
"sentence": "今天这个会议主要讨论预算。"
}
]
}
```
### final 返回示例
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": -1,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "",
"user_id": null
}
]
}
```
### speaker 回写示例
如果 speaker 后续识别完成,服务端会再次返回相同 `sentence_id` 的消息,只更新 speaker 信息:
```json
{
"type": "sentences",
"code": 0,
"voice_id": "41ff1926",
"final": 0,
"result": {
"slice_type": 2,
"index": 3,
"voice_text_str": "今天这个会议主要讨论预算。"
},
"sentences": [
{
"sentence_id": 3,
"sentence_type": 1,
"speaker_id": 19,
"start_time": 12000,
"end_time": 15880,
"sentence": "今天这个会议主要讨论预算。",
"speaker_name": "Alan Paine",
"user_id": "3"
}
]
}
```
接入建议:
- 客户端按 `sentence_id` 更新句子,不要把 speaker 回写当成新句子追加。
- `speaker_id=-1` 代表当前 speaker 暂未确认,不代表识别失败。
## 6.4 `end`
识别结束后返回:
```json
{
"type": "end",
"code": 0,
"message": "",
"voice_id": "41ff1926",
"session_id": "meeting-abc-123",
"final": 1,
"result": {
"slice_type": 2,
"index": 5,
"voice_text_str": "完整会议文本"
},
"sentences": []
}
```
## 6.5 `error`
异常时返回:
```json
{
"type": "error",
"code": "INVALID_STATE",
"message": "请先发送 start",
"voice_id": "41ff1926"
}
```
## 7. 断线重连
支持基于 `session_id` 的会话恢复。
约定如下:
1. 首次连接时,客户端可以自行生成 `session_id` 并放入 `start.payload.session_id`
2. 若客户端未传,服务端会返回自动生成的 `session_id`
3. 断线重连时,客户端使用同一个 `session_id` 再次发送 `start`
4. 若服务端会话仍在保活期内,则恢复同一会议上下文
建议:
- 客户端在会议生命周期内保持 `session_id` 稳定
- 网络抖动或页面刷新后,优先使用上一次会话的 `session_id` 重连
## 8. 客户端接入建议
- 实时展示可消费 `sentence_type=0` 的 partial
- 业务落库建议只以 `sentence_type=1` 的 final 为准
- 若后续收到相同 `sentence_id` 的 final 更新,应执行更新而不是新增
- 若需要最佳实时体验,建议开启 `enable_native_partial_stream=true`
- 若希望 speaker 识别更完整,建议开启 `enable_speaker=true`

View File

@ -1,95 +0,0 @@
# Qwen-Asr 国产化兼容适配汇报
## 一、适配目标
完成对 **沐曦(MetaX)** 和 **天数(Iluvatar)** 两款国产GPU的完整适配,实现语音识别服务在国产硬件平台的稳定运行。
---
## 二、适配历程与关键节点
| 时间 | 里程碑 | 关键动作 |
|------|--------|----------|
| 2026-06-02 | 架构设计 | 新增Accelerator抽象层,重构设备检测逻辑 |
| 2026-06-02 | 沐曦适配 | 新增沐曦GPU支持(MetaX MACA runtime) |
| 2026-06-02 | 天数适配 | 新增天数GPU支持(Iluvatar IX runtime) |
| 2026-06-03 | 部署完善 | 完善沐曦全流程部署支持,升级天数基础镜像 |
| 2026-06-04 | 问题修复 | 修复setuptools版本冲突,优化pip安装配置 |
---
## 三、遇到的主要"坑"与解决方案
### **坑1:设备检测适配问题**
- **问题**:不同厂商使用不同的设备查询命令(`mx-smi` vs `ixsmi`),输出格式差异大
- **解决方案**:
- 抽象统一的`AcceleratorAdapter`接口
- 支持多种SMI命令格式解析(JSON/表格/列表)
- 自动检测优先级:国产厂商SMI → NVIDIA → CPU
### **坑2:vLLM/PyTorch版本不兼容**
- **问题**:国产GPU的vLLM、PyTorch、内核和运行时需要严格匹配厂商官方版本
- **解决方案**:
- 采用"厂商官方vLLM镜像 + 项目代码叠加"策略
- 不在Dockerfile中重新`pip install vllm`,避免解析到NVIDIA CUDA依赖
- 提供专用环境配置文件(`environments/metax/`、`environments/iluvatar/`)
### **坑3:环境变量命名混乱**
- **问题**:各厂商使用不同的设备可见性变量名
- **解决方案**:
- 沐曦:`METAX_VISIBLE_DEVICES` / `MACA_VISIBLE_DEVICES` / `MX_VISIBLE_DEVICES`
- 天数:`ILUVATAR_VISIBLE_DEVICES` / `IX_VISIBLE_DEVICES` / `CUDA_VISIBLE_DEVICES`
- 代码层统一处理,支持多种变量名自动识别
### **坑4:显存管理问题**
- **问题**:国产GPU显存分配策略与NVIDIA不同,默认配置易导致KV cache不足
- **解决方案**:
- 引入`QWEN_GPU_MEMORY_UTILIZATION`配置项(默认0.25)
- 强制启用`QWEN_VLLM_ENFORCE_EAGER=true`提升兼容性
- 提供详细的显存配置指导文档
### **坑5:离线部署打包复杂**
- **问题**:目标机通常无法联网,需要完整的离线交付包
- **解决方案**:
- 开发`export_offline_bundle.sh`一键打包脚本
- 支持跳过模型打包(模型单独准备)
- 生成厂商专用的docker-compose配置
### **坑6:Python环境依赖冲突**
- **问题**:沐曦官方镜像使用特定Python路径和setuptools版本
- **解决方案**:
- 不使用uv虚拟环境,直接使用系统Python
- 固定setuptools版本为69.5.1
- 支持自定义Python路径配置
---
## 四、兼容性矩阵
| 平台 | 支持状态 | 基础镜像 | 设备命令 |
|------|----------|----------|----------|
| NVIDIA CUDA | ✅ 支持 | 自定义CUDA 13.0 | nvidia-smi |
| 沐曦 MetaX | ✅ 支持 | vLLM 0.17.0 + MACA AI3.5.3 | mx-smi |
| 天数 Iluvatar | ✅ 支持 | vLLM 0.17.0 + IX 4.4.0 | ixsmi |
| CPU (Rust) | ✅ 支持 | - | - |
---
## 五、交付成果
1. **代码层**:统一的加速器抽象层(`app/core/accelerator.py`)
2. **部署文档**:
- 《沐曦GPU国产化离线部署指南》
- 《天数GPU国产化离线部署指南》
3. **环境配置**:专用依赖配置文件(metax/iluvatar)
4. **打包脚本**:一键离线打包工具
5. **Docker镜像**:专用Dockerfile和docker-compose配置
---
## 六、关键经验总结
1. **厂商官方镜像优先**:不自行编译GPU运行时,直接使用厂商验证过的vLLM镜像
2. **统一抽象层**:通过Adapter模式屏蔽硬件差异,上层业务无感知
3. **配置化驱动**:设备可见性、显存比例等参数化配置,适应不同现场环境
4. **离线交付优先**:提前规划离线部署方案,避免现场网络限制问题

View File

@ -1,54 +0,0 @@
[project]
name = "qwen3-asr-cpu-env"
version = "1.0.0"
description = "CPU runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"setuptools>=70.0.0,<81",
"torch==2.3.1 ; sys_platform == 'linux' or sys_platform == 'darwin'",
"torchaudio==2.3.1 ; sys_platform == 'linux' or sys_platform == 'darwin'",
"transformers>=4.45,<4.50",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false
[tool.uv.sources]
torch = [
{ index = "pytorch-cpu", marker = "platform_system == 'Linux'" },
]
torchaudio = [
{ index = "pytorch-cpu", marker = "platform_system == 'Linux'" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +0,0 @@
[project]
name = "qwen3-asr-iluvatar-env"
version = "1.0.0"
description = "Iluvatar IX runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"asyncpg==0.31.0",
"pypinyin==0.55.0",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"setuptools>=70.0.0,<81",
"transformers>=4.56.0,<5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false

View File

@ -1,24 +0,0 @@
fastapi==0.128.0
fastapi-offline==1.7.6
uvicorn[standard]==0.40.0
pydantic==2.12.0
python-multipart==0.0.22
funasr==1.3.1
requests==2.32.5
modelscope[framework]==1.34.0
soundfile==0.13.1
librosa==0.11.0
websockets==16.0
addict==2.4.0
datasets==3.6.0
scipy==1.15.3
itntext==0.1.5
python-dotenv==1.2.1
asyncpg==0.31.0
pypinyin==0.55.0
huggingface_hub==0.34.0
hdbscan==0.8.41
loguru==0.7.2
rich>=13.9,<14
setuptools>=70.0.0,<81
transformers>=4.56.0,<5

View File

@ -1,45 +0,0 @@
[project]
name = "qwen3-asr-metax-env"
version = "1.0.0"
description = "MetaX MACA runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"asyncpg==0.31.0",
"pypinyin==0.55.0",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"transformers>=4.56.0,<5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false
[[tool.uv.index]]
name = "metax-maca"
url = "https://repos.metax-tech.com/r/maca-pypi/simple"
explicit = true

View File

@ -1,23 +0,0 @@
fastapi==0.128.0
fastapi-offline==1.7.6
uvicorn[standard]==0.40.0
pydantic==2.12.0
python-multipart==0.0.22
funasr==1.3.1
requests==2.32.5
modelscope[framework]==1.34.0
soundfile==0.13.1
librosa==0.11.0
websockets==16.0
addict==2.4.0
datasets==3.6.0
scipy==1.15.3
itntext==0.1.5
python-dotenv==1.2.1
asyncpg==0.31.0
pypinyin==0.55.0
huggingface_hub==0.34.0
hdbscan==0.8.41
loguru==0.7.2
rich>=13.9,<14
transformers>=4.56.0,<5

View File

@ -1,41 +0,0 @@
[project]
name = "qwen3-asr-mthreads-env"
version = "1.0.0"
description = "Moore Threads MUSA runtime environment for qwen3-asr"
requires-python = ">=3.10,<3.13"
dependencies = [
"fastapi==0.128.0",
"fastapi-offline==1.7.6",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.0",
"python-multipart==0.0.22",
"funasr==1.3.1",
"requests==2.32.5",
"modelscope[framework]==1.34.0",
"soundfile==0.13.1",
"librosa==0.11.0",
"websockets==16.0",
"addict==2.4.0",
"datasets==3.6.0",
"scipy==1.15.3",
"itntext==0.1.5",
"python-dotenv==1.2.1",
"asyncpg==0.31.0",
"pypinyin==0.55.0",
"huggingface_hub==0.34.0",
"hdbscan==0.8.41",
"loguru==0.7.2",
"rich>=13.9,<14",
"setuptools>=70.0.0,<81",
"transformers>=4.56.0,<5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = []
[tool.uv]
package = false

View File

@ -1,24 +0,0 @@
fastapi==0.128.0
fastapi-offline==1.7.6
uvicorn[standard]==0.40.0
pydantic==2.12.0
python-multipart==0.0.22
funasr==1.3.1
requests==2.32.5
modelscope[framework]==1.34.0
soundfile==0.13.1
librosa==0.11.0
websockets==16.0
addict==2.4.0
datasets==3.6.0
scipy==1.15.3
itntext==0.1.5
python-dotenv==1.2.1
asyncpg==0.31.0
pypinyin==0.55.0
huggingface_hub==0.34.0
hdbscan==0.8.41
loguru==0.7.2
rich>=13.9,<14
setuptools>=70.0.0,<81
transformers>=4.56.0,<5