Split FunASR frontend and backend services
parent
525d8060f5
commit
78bd71ff89
72
.env.example
72
.env.example
|
|
@ -1,50 +1,28 @@
|
||||||
# 选择已经下载到 demo/models 目录中的 ASR 模型;必须与实际模型目录保持一致。
|
# Local model assets; the backend never downloads models at startup.
|
||||||
# 默认使用轻量的 0.6B 模型。如下载的是 1.7B,请改为 1.7b,不能混用。
|
MODEL_DIR=models
|
||||||
QWEN3_ASR_MODEL=0.6b
|
# Set this if CAM++ is not at a model_manifest.json path.
|
||||||
# 本地模型根目录。下载脚本、VLLM 启动脚本和辅助服务都会从这里查找模型。
|
# CAM_MODEL_PATH=iic/speech_campplus_sv_zh-cn_16k-common
|
||||||
MODEL_DIR=D:/github-project/ASR/Qwen-Asr/demo/models
|
|
||||||
|
|
||||||
# VLLM 宿主机服务绑定地址;0.0.0.0 表示允许服务器网卡接收外部请求。
|
# FunASR realtime engine
|
||||||
VLLM_HOST=0.0.0.0
|
FUNASR_ASR_MODEL=paraformer-zh-streaming
|
||||||
# VLLM 服务端口;启动器、健康检查和前端连接地址必须使用同一个端口。
|
FUNASR_VAD_MODEL=fsmn-vad
|
||||||
VLLM_PORT=9950
|
FUNASR_DEVICE=cuda:0
|
||||||
# VLLM 可执行文件名称;新版环境通常为 vllm,启动器会自动执行 vllm serve。
|
FUNASR_VAD_DEVICE=cpu
|
||||||
VLLM_EXECUTABLE=vllm
|
FUNASR_CHUNK_SIZE=0,10,5
|
||||||
# 启动成功提示中显示的地址,只影响日志和使用说明,不改变实际监听地址。
|
FUNASR_ENCODER_LOOK_BACK=4
|
||||||
VLLM_DISPLAY_HOST=127.0.0.1
|
FUNASR_DECODER_LOOK_BACK=1
|
||||||
# 启动轮询使用的地址;如果 VLLM 部署在本机,通常保持 127.0.0.1 即可。
|
FUNASR_VAD_CHUNK_MS=200
|
||||||
VLLM_PROBE_HOST=127.0.0.1
|
FUNASR_MAX_SEGMENT_SEC=30
|
||||||
# VLLM 启动阶段最多检查多少次健康状态,超过次数仍未就绪则启动失败。
|
|
||||||
VLLM_STARTUP_CHECK_LOOPS=60
|
|
||||||
# 两次健康检查之间的等待秒数;模型加载较慢时可以适当增大检查次数。
|
|
||||||
VLLM_STARTUP_CHECK_INTERVAL_SECONDS=2
|
|
||||||
# VLLM 使用的显存比例。显存还要留给 VAD、聚类和声纹辅助模型时,建议预留余量。
|
|
||||||
VLLM_GPU_MEMORY_UTILIZATION=0.3
|
|
||||||
# VLLM 的最大上下文长度;数值越大通常占用越多显存,请结合显卡容量调整。
|
|
||||||
VLLM_MAX_MODEL_LEN=16384
|
|
||||||
# VLLM 同时处理的最大序列数;实时单路验证可保持默认值,多路并发时再调大。
|
|
||||||
VLLM_MAX_NUM_SEQS=16
|
|
||||||
# 张量并行 GPU 数量;单卡部署为 1,多卡部署时填写参与并行的 GPU 数量。
|
|
||||||
VLLM_TENSOR_PARALLEL_SIZE=1
|
|
||||||
# 是否启用 eager 模式。true 通常更容易启动和排查,false 可能获得更高性能。
|
|
||||||
VLLM_ENFORCE_EAGER=true
|
|
||||||
|
|
||||||
# GB10 需要使用 CUDA 13 工具链中的 ptxas;serve.py 会自动加载该配置并传给 VLLM。
|
# CAM++ is required and started by the backend launcher.
|
||||||
TRITON_PTXAS_PATH=/usr/local/cuda/bin/ptxas
|
|
||||||
|
|
||||||
# 可选:为外部客户端设置稳定的公开模型名称。留空时默认使用完整 ModelScope ID。
|
|
||||||
# VLLM_SERVED_MODEL_NAME=Qwen/Qwen3-ASR-0.6B
|
|
||||||
|
|
||||||
# 辅助模型服务地址;它独立加载 VAD、CAM++ 聚类和声纹模型,WebSocket
|
|
||||||
# 服务只通过 HTTP 调用,不会把这些 GPU 模型重复加载到 WebSocket 进程。
|
|
||||||
AUXILIARY_SERVICE_URL=http://127.0.0.1:8010
|
AUXILIARY_SERVICE_URL=http://127.0.0.1:8010
|
||||||
# WebSocket 调用已独立部署的 vLLM;跨服务器时改成模型服务器的实际地址。
|
|
||||||
MODEL_SERVICE_URL=http://127.0.0.1:9950/v1
|
# Standalone frontend and browser-facing backend origin.
|
||||||
# 辅助服务监听的 GPU;单卡服务器保持 cuda:0,多卡时可改成指定卡号。
|
FRONTEND_HOST=127.0.0.1
|
||||||
AUXILIARY_DEVICE=cuda:0
|
FRONTEND_PORT=8080
|
||||||
# 仅保留兼容旧配置;VAD/CAM++ 核心模型缺失时始终拒绝启动,不会静默降级。
|
FRONTEND_ORIGIN=http://127.0.0.1:8080
|
||||||
# 可选 diarization/aligner 缺失不会阻断实时服务。
|
BACKEND_PUBLIC_URL=http://127.0.0.1:8082
|
||||||
AUXILIARY_ALLOW_MISSING=false
|
|
||||||
# 启动时强制加载 VAD + CAM++ speaker_verification;完整 diarization 首次调用时按需加载。
|
WEB_HOST=0.0.0.0
|
||||||
# 如确实需要启动时额外预加载,可追加:vad,speaker_verification,diarization
|
WEB_PORT=8082
|
||||||
AUXILIARY_PRELOAD_KINDS=vad,speaker_verification
|
WEB_DISPLAY_HOST=127.0.0.1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Local model assets; the backend never downloads models at startup.
|
||||||
|
MODEL_DIR=models
|
||||||
|
# Set this if CAM++ is not at a model_manifest.json path.
|
||||||
|
# CAM_MODEL_PATH=iic/speech_campplus_sv_zh-cn_16k-common
|
||||||
|
|
||||||
|
# FunASR streaming ASR model. The backend launcher requires it to exist locally.
|
||||||
|
FUNASR_ASR_MODEL=paraformer-zh-streaming
|
||||||
|
FUNASR_VAD_MODEL=fsmn-vad
|
||||||
|
|
||||||
|
# ASR and VAD may use different devices to limit peak GPU memory.
|
||||||
|
FUNASR_DEVICE=cuda:0
|
||||||
|
FUNASR_VAD_DEVICE=cpu
|
||||||
|
|
||||||
|
# [left context, current chunk, right lookahead]; 10 * 60ms = 600ms.
|
||||||
|
FUNASR_CHUNK_SIZE=0,10,5
|
||||||
|
FUNASR_ENCODER_LOOK_BACK=4
|
||||||
|
FUNASR_DECODER_LOOK_BACK=1
|
||||||
|
FUNASR_VAD_CHUNK_MS=200
|
||||||
|
FUNASR_MAX_SEGMENT_SEC=30
|
||||||
|
|
||||||
|
# CAM++ is required. The backend launcher starts this service itself.
|
||||||
|
AUXILIARY_SERVICE_URL=http://127.0.0.1:8010
|
||||||
|
|
||||||
|
# Standalone frontend and browser-facing backend origin.
|
||||||
|
FRONTEND_HOST=127.0.0.1
|
||||||
|
FRONTEND_PORT=8080
|
||||||
|
FRONTEND_ORIGIN=http://127.0.0.1:8080
|
||||||
|
BACKEND_PUBLIC_URL=http://127.0.0.1:8082
|
||||||
|
WEB_HOST=0.0.0.0
|
||||||
|
WEB_PORT=8082
|
||||||
|
WEB_DISPLAY_HOST=127.0.0.1
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# 选择已经下载到 demo/models 目录中的 ASR 模型;必须与实际模型目录保持一致。
|
||||||
|
# 默认使用轻量的 0.6B 模型。如下载的是 1.7B,请改为 1.7b,不能混用。
|
||||||
|
QWEN3_ASR_MODEL=0.6b
|
||||||
|
# 本地模型根目录。下载脚本、VLLM 启动脚本和辅助服务都会从这里查找模型。
|
||||||
|
MODEL_DIR=D:/github-project/ASR/Qwen-Asr/demo/models
|
||||||
|
|
||||||
|
# VLLM 宿主机服务绑定地址;0.0.0.0 表示允许服务器网卡接收外部请求。
|
||||||
|
VLLM_HOST=0.0.0.0
|
||||||
|
# VLLM 服务端口;启动器、健康检查和前端连接地址必须使用同一个端口。
|
||||||
|
VLLM_PORT=9950
|
||||||
|
# VLLM 可执行文件名称;新版环境通常为 vllm,启动器会自动执行 vllm serve。
|
||||||
|
VLLM_EXECUTABLE=vllm
|
||||||
|
# 启动成功提示中显示的地址,只影响日志和使用说明,不改变实际监听地址。
|
||||||
|
VLLM_DISPLAY_HOST=127.0.0.1
|
||||||
|
# 启动轮询使用的地址;如果 VLLM 部署在本机,通常保持 127.0.0.1 即可。
|
||||||
|
VLLM_PROBE_HOST=127.0.0.1
|
||||||
|
# VLLM 启动阶段最多检查多少次健康状态,超过次数仍未就绪则启动失败。
|
||||||
|
VLLM_STARTUP_CHECK_LOOPS=60
|
||||||
|
# 两次健康检查之间的等待秒数;模型加载较慢时可以适当增大检查次数。
|
||||||
|
VLLM_STARTUP_CHECK_INTERVAL_SECONDS=2
|
||||||
|
# VLLM 使用的显存比例。显存还要留给 VAD、聚类和声纹辅助模型时,建议预留余量。
|
||||||
|
VLLM_GPU_MEMORY_UTILIZATION=0.3
|
||||||
|
# VLLM 的最大上下文长度;数值越大通常占用越多显存,请结合显卡容量调整。
|
||||||
|
VLLM_MAX_MODEL_LEN=16384
|
||||||
|
# VLLM 同时处理的最大序列数;实时单路验证可保持默认值,多路并发时再调大。
|
||||||
|
VLLM_MAX_NUM_SEQS=16
|
||||||
|
# 张量并行 GPU 数量;单卡部署为 1,多卡部署时填写参与并行的 GPU 数量。
|
||||||
|
VLLM_TENSOR_PARALLEL_SIZE=1
|
||||||
|
# 是否启用 eager 模式。true 通常更容易启动和排查,false 可能获得更高性能。
|
||||||
|
VLLM_ENFORCE_EAGER=true
|
||||||
|
|
||||||
|
# GB10 需要使用 CUDA 13 工具链中的 ptxas;serve.py 会自动加载该配置并传给 VLLM。
|
||||||
|
TRITON_PTXAS_PATH=/usr/local/cuda/bin/ptxas
|
||||||
|
|
||||||
|
# 可选:为外部客户端设置稳定的公开模型名称。留空时默认使用完整 ModelScope ID。
|
||||||
|
# VLLM_SERVED_MODEL_NAME=Qwen/Qwen3-ASR-0.6B
|
||||||
|
|
||||||
|
# 辅助模型服务地址;它独立加载 VAD、CAM++ 聚类和声纹模型,WebSocket
|
||||||
|
# 服务只通过 HTTP 调用,不会把这些 GPU 模型重复加载到 WebSocket 进程。
|
||||||
|
AUXILIARY_SERVICE_URL=http://127.0.0.1:8010
|
||||||
|
# WebSocket 调用已独立部署的 vLLM;跨服务器时改成模型服务器的实际地址。
|
||||||
|
MODEL_SERVICE_URL=http://127.0.0.1:9950/v1
|
||||||
|
# 辅助服务监听的 GPU;单卡服务器保持 cuda:0,多卡时可改成指定卡号。
|
||||||
|
AUXILIARY_DEVICE=cuda:0
|
||||||
|
# 仅保留兼容旧配置;VAD/CAM++ 核心模型缺失时始终拒绝启动,不会静默降级。
|
||||||
|
# 可选 diarization/aligner 缺失不会阻断实时服务。
|
||||||
|
AUXILIARY_ALLOW_MISSING=false
|
||||||
|
# 启动时强制加载 VAD + CAM++ speaker_verification;完整 diarization 首次调用时按需加载。
|
||||||
|
# 如确实需要启动时额外预加载,可追加:vad,speaker_verification,diarization
|
||||||
|
AUXILIARY_PRELOAD_KINDS=vad,speaker_verification
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# FunASR realtime browser demo
|
||||||
|
|
||||||
|
The frontend and backend start separately. The backend launcher loads three
|
||||||
|
required local assets: streaming ASR, FSMN VAD, and CAM++ speaker verification.
|
||||||
|
It starts the CAM++ model service and the WebSocket service and stops both
|
||||||
|
together. No model is downloaded by the launcher.
|
||||||
|
|
||||||
|
## Model directories
|
||||||
|
|
||||||
|
Put the assets under models/ or set MODEL_DIR in .env. The default
|
||||||
|
FunASR names resolve to these local directories:
|
||||||
|
|
||||||
|
- models/iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online
|
||||||
|
- models/iic/speech_fsmn_vad_zh-cn-16k-common-pytorch (or the damo/ variant)
|
||||||
|
- models/iic/speech_campplus_sv_zh-cn_16k-common (or the damo/ variant)
|
||||||
|
|
||||||
|
If your directories have different names, set FUNASR_ASR_MODEL and
|
||||||
|
FUNASR_VAD_MODEL to their paths. The CAM++ directories follow
|
||||||
|
model_manifest.json; set CAM_MODEL_PATH for another location. Backend startup reports every checked path when a
|
||||||
|
required asset is missing.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
First install a torch/torchaudio build suitable for the host CPU or CUDA,
|
||||||
|
then install the project dependencies:
|
||||||
|
|
||||||
|
~~~powershell
|
||||||
|
cd D:\github-project\ASR\Asr-demo
|
||||||
|
python -m pip install -r requirements-funasr.txt
|
||||||
|
python -m pip install -r requirements-auxiliary.txt
|
||||||
|
if (-not (Test-Path .env)) { Copy-Item .env.funasr.example .env }
|
||||||
|
~~~
|
||||||
|
|
||||||
|
In one terminal start the backend:
|
||||||
|
|
||||||
|
~~~powershell
|
||||||
|
python scripts\run_backend.py
|
||||||
|
~~~
|
||||||
|
|
||||||
|
In another terminal start the frontend:
|
||||||
|
|
||||||
|
~~~powershell
|
||||||
|
python scripts\run_frontend.py
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Open http://127.0.0.1:8080/. The backend WebSocket listens on port 8082 and
|
||||||
|
the CAM++ service on port 8010. BACKEND_PUBLIC_URL must be reachable from
|
||||||
|
the browser. Set FRONTEND_ORIGIN to the exact frontend origin if it differs
|
||||||
|
from the default.
|
||||||
|
|
||||||
|
The browser sends start, PCM16 frames, and stop/eof over WebSocket. Speaker
|
||||||
|
labels are required. Short or unusable speech may still receive an unknown
|
||||||
|
speaker label, but missing CAM++ prevents backend startup.
|
||||||
160
README.md
160
README.md
|
|
@ -1,151 +1,25 @@
|
||||||
# Qwen3-ASR VLLM 独立部署项目
|
# FunASR realtime ASR demo
|
||||||
|
|
||||||
本目录是后续实时 ASR 功能验证使用的独立模型服务项目。
|
This branch runs streaming ASR, VAD, and CAM++ from local model assets.
|
||||||
|
The browser UI starts as a separate service.
|
||||||
|
|
||||||
它不导入、不启动、也不调用仓库根目录下原项目的 `app/` 代码。模型下载、VLLM 启动、配置和服务验证都在本目录内完成。后续验证 demo 只需要调用这里提供的 VLLM OpenAI 兼容接口。
|
## Start
|
||||||
|
|
||||||
## 当前下载范围
|
Install a torch/torchaudio build for the host, then install
|
||||||
|
requirements-funasr.txt and requirements-auxiliary.txt. Copy
|
||||||
|
.env.funasr.example to .env if no .env exists, and check MODEL_DIR.
|
||||||
|
|
||||||
默认下载一个 ASR 模型和独立辅助模型运行服务所需的全部模型资产:
|
Start the backend (CAM++ model service plus WebSocket) in one terminal:
|
||||||
|
|
||||||
```text
|
~~~powershell
|
||||||
Qwen/Qwen3-ASR-0.6B
|
python scripts\run_backend.py
|
||||||
```
|
~~~
|
||||||
|
|
||||||
ASR 如需使用大模型,可显式选择 1.7B:
|
Start the frontend in another terminal:
|
||||||
|
|
||||||
```text
|
~~~powershell
|
||||||
Qwen/Qwen3-ASR-1.7B
|
python scripts\run_frontend.py
|
||||||
```
|
~~~
|
||||||
|
|
||||||
辅助资产包括 VAD、CAM++ 分离、配置声纹、实时声纹、CAM++ Transformer 和 Qwen3 ForcedAligner。它们不会由 `qwen-asr-serve` 启动,而是由独立 Python 辅助模型服务加载。
|
Open http://127.0.0.1:8080/. Model directory layout and address settings
|
||||||
|
are described in FUNASR_README.md.
|
||||||
不会下载另一个未选择的 ASR 模型;辅助模型资产会随默认部署包下载,供独立 Python 运行服务预加载。
|
|
||||||
|
|
||||||
## 1. 下载模型
|
|
||||||
|
|
||||||
模型直接下载到宿主机的 `demo/models`。ASR 由 VLLM 启动,辅助模型由独立 Python 运行服务启动。
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\github-project\ASR\Qwen-Asr\demo
|
|
||||||
python -m venv .venv
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
pip install -r requirements-download.txt
|
|
||||||
python scripts\download_models.py
|
|
||||||
```
|
|
||||||
|
|
||||||
上述命令会下载默认 `0.6B` ASR 以及全部辅助模型,并在下载完成后把 CAM++ 配置中的依赖模型 ID 改为 `demo/models` 下的本地路径,保证辅助服务可以离线启动。只下载 ASR 时使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts\download_models.py --skip-auxiliary
|
|
||||||
```
|
|
||||||
|
|
||||||
只下载辅助模型时使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts\download_models.py --auxiliary-only
|
|
||||||
```
|
|
||||||
|
|
||||||
选择 1.7B:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts\download_models.py --model 1.7b
|
|
||||||
```
|
|
||||||
|
|
||||||
检查模型是否完整但不下载:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts\download_models.py --check-only
|
|
||||||
```
|
|
||||||
|
|
||||||
ModelScope 下载也可以通过环境变量调整缓存目录:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:MODELSCOPE_CACHE = 'D:\modelscope-cache'
|
|
||||||
python scripts\download_models.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 宿主机启动 VLLM 服务
|
|
||||||
|
|
||||||
需要宿主机具备与 VLLM 兼容的 Python、CUDA 和 NVIDIA 驱动环境。安装部署依赖:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pip install -r requirements-deploy.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
先复制并按服务器实际路径修改 `.env`,启动器会自动读取该文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
默认启动 `Qwen/Qwen3-ASR-0.6B`,监听地址为 `0.0.0.0:9950`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/serve.py
|
|
||||||
```
|
|
||||||
|
|
||||||
模型和启动检查循环可以通过命令行或环境变量传入;端口统一在 `scripts/serve.py` 的 `SERVER_PORT` 变量中维护:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QWEN3_ASR_MODEL=0.6b VLLM_STARTUP_CHECK_LOOPS=120 \
|
|
||||||
VLLM_STARTUP_CHECK_INTERVAL_SECONDS=2 python scripts/serve.py
|
|
||||||
```
|
|
||||||
|
|
||||||
如果使用 `1.7B`,下载和启动必须指定同一个模型:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python scripts\download_models.py --model 1.7b
|
|
||||||
python -m scripts.serve --model 1.7b
|
|
||||||
```
|
|
||||||
|
|
||||||
启动器会按 `VLLM_STARTUP_CHECK_LOOPS` 次数轮询 `/health`,每次间隔由 `VLLM_STARTUP_CHECK_INTERVAL_SECONDS` 指定。服务端口、健康检查端口和就绪提示统一使用 `scripts/serve.py` 中的 `SERVER_PORT`。
|
|
||||||
|
|
||||||
服务启动后可检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "http://${VLLM_DISPLAY_HOST:-127.0.0.1}:9950/health"
|
|
||||||
curl "http://${VLLM_DISPLAY_HOST:-127.0.0.1}:9950/v1/models"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 调用转写接口
|
|
||||||
|
|
||||||
VLLM 服务提供 OpenAI 兼容的音频转写接口:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "http://${VLLM_DISPLAY_HOST:-127.0.0.1}:9950/v1/audio/transcriptions" \
|
|
||||||
-H "Authorization: Bearer EMPTY" \
|
|
||||||
-F "file=@./audio/sample.wav" \
|
|
||||||
-F "model=Qwen/Qwen3-ASR-0.6B"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 启动辅助模型服务
|
|
||||||
|
|
||||||
另开一个终端,在同一台服务器启动 VAD、CAM++ 和声纹模型运行服务:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\github-project\ASR\Qwen-Asr\demo
|
|
||||||
pip install -r requirements-auxiliary.txt
|
|
||||||
python scripts\auxiliary_server.py
|
|
||||||
```
|
|
||||||
|
|
||||||
辅助服务默认监听 `0.0.0.0:8010`。实时链路启动时严格加载 VAD 和 CAM++ `speaker_verification` 声纹模型,用于每个 turn 的特征提取与在线聚类;完整 CAM++ 分离、Transformer 和 ForcedAligner 不阻断核心服务,完整分离模型会在调用 `/v1/diarization` 时按需加载。检查状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8010/health
|
|
||||||
```
|
|
||||||
|
|
||||||
WebSocket demo 默认连接 `9950` 的 ASR VLLM,辅助服务使用 `8010`。`/health` 的 `ready` 要求 `vad_ready` 与 `speaker_embedding_ready` 同时为 true;完整 diarization 资产缺失不会影响实时 `/v1/speaker/resolve`。
|
|
||||||
|
|
||||||
实时链路中的职责是:WebSocket 用 RMS 帧门控快速检测停顿;辅助服务用 FunASR VAD 提供 `/v1/vad`,并加载 CAM++ `speaker_verification` 提取 turn embedding,再由服务端在线聚类。`speech_campplus_speaker-diarization_common` 是完整音频分离接口的额外 pipeline,不是实时 turn 聚类的唯一入口。
|
|
||||||
|
|
||||||
也可以使用多模态 Chat Completions 接口,后续实时验证项目将以此服务边界为准。
|
|
||||||
|
|
||||||
## 项目边界
|
|
||||||
|
|
||||||
- `scripts/download_models.py`:下载选定 ASR 和全部辅助模型资产。
|
|
||||||
- `scripts/serve.py`:读取 `.env`,解析模型选择、宿主机参数并启动新版 `vllm serve`。
|
|
||||||
- `requirements-deploy.txt`:安装宿主机部署所需的官方 Qwen3-ASR VLLM 依赖。
|
|
||||||
- `tests/`:只验证本项目自己的模型清单和选择逻辑,不依赖原项目。
|
|
||||||
|
|
||||||
模型服务就绪后,新的实时 ASR demo 放在同级 `demo` 项目中继续开发,但不得通过 Python import 或 HTTP/WebSocket 调用原项目服务。
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Qwen3-ASR VLLM 独立部署项目
|
||||||
|
|
||||||
|
本目录是后续实时 ASR 功能验证使用的独立模型服务项目。
|
||||||
|
|
||||||
|
它不导入、不启动、也不调用仓库根目录下原项目的 `app/` 代码。模型下载、VLLM 启动、配置和服务验证都在本目录内完成。后续验证 demo 只需要调用这里提供的 VLLM OpenAI 兼容接口。
|
||||||
|
|
||||||
|
## 当前下载范围
|
||||||
|
|
||||||
|
默认下载一个 ASR 模型和独立辅助模型运行服务所需的全部模型资产:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Qwen/Qwen3-ASR-0.6B
|
||||||
|
```
|
||||||
|
|
||||||
|
ASR 如需使用大模型,可显式选择 1.7B:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Qwen/Qwen3-ASR-1.7B
|
||||||
|
```
|
||||||
|
|
||||||
|
辅助资产包括 VAD、CAM++ 分离、配置声纹、实时声纹、CAM++ Transformer 和 Qwen3 ForcedAligner。它们不会由 `qwen-asr-serve` 启动,而是由独立 Python 辅助模型服务加载。
|
||||||
|
|
||||||
|
不会下载另一个未选择的 ASR 模型;辅助模型资产会随默认部署包下载,供独立 Python 运行服务预加载。
|
||||||
|
|
||||||
|
## 1. 下载模型
|
||||||
|
|
||||||
|
模型直接下载到宿主机的 `demo/models`。ASR 由 VLLM 启动,辅助模型由独立 Python 运行服务启动。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\github-project\ASR\Qwen-Asr\demo
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements-download.txt
|
||||||
|
python scripts\download_models.py
|
||||||
|
```
|
||||||
|
|
||||||
|
上述命令会下载默认 `0.6B` ASR 以及全部辅助模型,并在下载完成后把 CAM++ 配置中的依赖模型 ID 改为 `demo/models` 下的本地路径,保证辅助服务可以离线启动。只下载 ASR 时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts\download_models.py --skip-auxiliary
|
||||||
|
```
|
||||||
|
|
||||||
|
只下载辅助模型时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts\download_models.py --auxiliary-only
|
||||||
|
```
|
||||||
|
|
||||||
|
选择 1.7B:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts\download_models.py --model 1.7b
|
||||||
|
```
|
||||||
|
|
||||||
|
检查模型是否完整但不下载:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts\download_models.py --check-only
|
||||||
|
```
|
||||||
|
|
||||||
|
ModelScope 下载也可以通过环境变量调整缓存目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:MODELSCOPE_CACHE = 'D:\modelscope-cache'
|
||||||
|
python scripts\download_models.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 宿主机启动 VLLM 服务
|
||||||
|
|
||||||
|
需要宿主机具备与 VLLM 兼容的 Python、CUDA 和 NVIDIA 驱动环境。安装部署依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -r requirements-deploy.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
先复制并按服务器实际路径修改 `.env`,启动器会自动读取该文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
默认启动 `Qwen/Qwen3-ASR-0.6B`,监听地址为 `0.0.0.0:9950`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/serve.py
|
||||||
|
```
|
||||||
|
|
||||||
|
模型和启动检查循环可以通过命令行或环境变量传入;端口统一在 `scripts/serve.py` 的 `SERVER_PORT` 变量中维护:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN3_ASR_MODEL=0.6b VLLM_STARTUP_CHECK_LOOPS=120 \
|
||||||
|
VLLM_STARTUP_CHECK_INTERVAL_SECONDS=2 python scripts/serve.py
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用 `1.7B`,下载和启动必须指定同一个模型:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts\download_models.py --model 1.7b
|
||||||
|
python -m scripts.serve --model 1.7b
|
||||||
|
```
|
||||||
|
|
||||||
|
启动器会按 `VLLM_STARTUP_CHECK_LOOPS` 次数轮询 `/health`,每次间隔由 `VLLM_STARTUP_CHECK_INTERVAL_SECONDS` 指定。服务端口、健康检查端口和就绪提示统一使用 `scripts/serve.py` 中的 `SERVER_PORT`。
|
||||||
|
|
||||||
|
服务启动后可检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://${VLLM_DISPLAY_HOST:-127.0.0.1}:9950/health"
|
||||||
|
curl "http://${VLLM_DISPLAY_HOST:-127.0.0.1}:9950/v1/models"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 调用转写接口
|
||||||
|
|
||||||
|
VLLM 服务提供 OpenAI 兼容的音频转写接口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://${VLLM_DISPLAY_HOST:-127.0.0.1}:9950/v1/audio/transcriptions" \
|
||||||
|
-H "Authorization: Bearer EMPTY" \
|
||||||
|
-F "file=@./audio/sample.wav" \
|
||||||
|
-F "model=Qwen/Qwen3-ASR-0.6B"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 启动辅助模型服务
|
||||||
|
|
||||||
|
另开一个终端,在同一台服务器启动 VAD、CAM++ 和声纹模型运行服务:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\github-project\ASR\Qwen-Asr\demo
|
||||||
|
pip install -r requirements-auxiliary.txt
|
||||||
|
python scripts\auxiliary_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
辅助服务默认监听 `0.0.0.0:8010`。实时链路启动时严格加载 VAD 和 CAM++ `speaker_verification` 声纹模型,用于每个 turn 的特征提取与在线聚类;完整 CAM++ 分离、Transformer 和 ForcedAligner 不阻断核心服务,完整分离模型会在调用 `/v1/diarization` 时按需加载。检查状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8010/health
|
||||||
|
```
|
||||||
|
|
||||||
|
WebSocket demo 默认连接 `9950` 的 ASR VLLM,辅助服务使用 `8010`。`/health` 的 `ready` 要求 `vad_ready` 与 `speaker_embedding_ready` 同时为 true;完整 diarization 资产缺失不会影响实时 `/v1/speaker/resolve`。
|
||||||
|
|
||||||
|
实时链路中的职责是:WebSocket 用 RMS 帧门控快速检测停顿;辅助服务用 FunASR VAD 提供 `/v1/vad`,并加载 CAM++ `speaker_verification` 提取 turn embedding,再由服务端在线聚类。`speech_campplus_speaker-diarization_common` 是完整音频分离接口的额外 pipeline,不是实时 turn 聚类的唯一入口。
|
||||||
|
|
||||||
|
也可以使用多模态 Chat Completions 接口,后续实时验证项目将以此服务边界为准。
|
||||||
|
|
||||||
|
## 项目边界
|
||||||
|
|
||||||
|
- `scripts/download_models.py`:下载选定 ASR 和全部辅助模型资产。
|
||||||
|
- `scripts/serve.py`:读取 `.env`,解析模型选择、宿主机参数并启动新版 `vllm serve`。
|
||||||
|
- `requirements-deploy.txt`:安装宿主机部署所需的官方 Qwen3-ASR VLLM 依赖。
|
||||||
|
- `tests/`:只验证本项目自己的模型清单和选择逻辑,不依赖原项目。
|
||||||
|
|
||||||
|
模型服务就绪后,新的实时 ASR demo 放在同级 `demo` 项目中继续开发,但不得通过 Python import 或 HTTP/WebSocket 调用原项目服务。
|
||||||
|
|
@ -3,18 +3,22 @@ requires = ["setuptools>=68", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "qwen3-asr-vllm-deployment"
|
name = "funasr-realtime-asr-demo"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Standalone Qwen3-ASR model downloader and VLLM deployment"
|
description = "FunASR streaming ASR browser demo"
|
||||||
requires-python = ">=3.10,<3.14"
|
requires-python = ">=3.10,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"modelscope==1.34.0",
|
"aiohttp==3.11.11",
|
||||||
"qwen-asr[vllm]==0.0.6",
|
"python-dotenv>=1.0",
|
||||||
|
"funasr==1.4.16",
|
||||||
|
"modelscope[framework]==1.34.0",
|
||||||
|
"soundfile==0.13.1",
|
||||||
|
"librosa==0.11.0",
|
||||||
|
"numpy>=1.24",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
qwen3-asr-download = "scripts.download_models:main"
|
funasr-realtime-demo = "scripts.run_funasr_demo:main"
|
||||||
qwen3-asr-serve = "scripts.serve:main"
|
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["scripts"]
|
packages = ["scripts", "realtime_websocket"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "qwen3-asr-vllm-deployment"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Standalone Qwen3-ASR model downloader and VLLM deployment"
|
||||||
|
requires-python = ">=3.10,<3.14"
|
||||||
|
dependencies = [
|
||||||
|
"modelscope==1.34.0",
|
||||||
|
"qwen-asr[vllm]==0.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
qwen3-asr-download = "scripts.download_models:main"
|
||||||
|
qwen3-asr-serve = "scripts.serve:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["scripts"]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""FunASR realtime browser demo package."""
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
"""FunASR realtime engine migrated into the demo project.
|
||||||
|
|
||||||
|
This module keeps the browser-facing project independent from the original
|
||||||
|
Qwen/VLLM service. It follows FunASR's streaming cache lifecycle:
|
||||||
|
one cache per ASR session and is_final=True only for the last chunk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FunASRServiceConfig:
|
||||||
|
"""Configuration for the in-process FunASR models."""
|
||||||
|
|
||||||
|
model: str = "paraformer-zh-streaming"
|
||||||
|
vad_model: str = "fsmn-vad"
|
||||||
|
device: str = "cuda:0"
|
||||||
|
vad_device: str = "cpu"
|
||||||
|
sample_rate: int = 16000
|
||||||
|
vad_chunk_ms: int = 200
|
||||||
|
chunk_size: tuple[int, int, int] = (0, 10, 5)
|
||||||
|
encoder_chunk_look_back: int = 4
|
||||||
|
decoder_chunk_look_back: int = 1
|
||||||
|
max_segment_sec: float = 30.0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "FunASRServiceConfig":
|
||||||
|
"""Read model selection from environment without changing WS fields."""
|
||||||
|
chunk_text = os.getenv("FUNASR_CHUNK_SIZE", "0,10,5")
|
||||||
|
try:
|
||||||
|
values = tuple(int(value.strip()) for value in chunk_text.split(","))
|
||||||
|
chunk_size = values if len(values) == 3 else cls.chunk_size
|
||||||
|
except ValueError:
|
||||||
|
chunk_size = cls.chunk_size
|
||||||
|
return cls(
|
||||||
|
model=os.getenv("FUNASR_ASR_MODEL", cls.model),
|
||||||
|
vad_model=os.getenv("FUNASR_VAD_MODEL", cls.vad_model),
|
||||||
|
device=os.getenv("FUNASR_DEVICE", cls.device),
|
||||||
|
vad_device=os.getenv("FUNASR_VAD_DEVICE", cls.vad_device),
|
||||||
|
vad_chunk_ms=max(50, int(os.getenv("FUNASR_VAD_CHUNK_MS", str(cls.vad_chunk_ms)))),
|
||||||
|
chunk_size=chunk_size,
|
||||||
|
encoder_chunk_look_back=max(
|
||||||
|
0, int(os.getenv("FUNASR_ENCODER_LOOK_BACK", str(cls.encoder_chunk_look_back)))
|
||||||
|
),
|
||||||
|
decoder_chunk_look_back=max(
|
||||||
|
0, int(os.getenv("FUNASR_DECODER_LOOK_BACK", str(cls.decoder_chunk_look_back)))
|
||||||
|
),
|
||||||
|
max_segment_sec=max(
|
||||||
|
2.0, float(os.getenv("FUNASR_MAX_SEGMENT_SEC", str(cls.max_segment_sec)))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FunASRSegment:
|
||||||
|
"""A partial or final event returned by one browser session."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
start_time_ms: float
|
||||||
|
end_time_ms: float
|
||||||
|
audio: bytes
|
||||||
|
voiced_ms: float
|
||||||
|
is_final: bool
|
||||||
|
sentence_id: int
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _result_text(result: Any) -> str:
|
||||||
|
"""Extract text from the list/dict result shapes used by FunASR."""
|
||||||
|
if isinstance(result, list):
|
||||||
|
return _result_text(result[0]) if result else ""
|
||||||
|
if isinstance(result, dict):
|
||||||
|
value = result.get("text")
|
||||||
|
if value is None:
|
||||||
|
value = result.get("value")
|
||||||
|
return str(value or "").strip()
|
||||||
|
return str(result or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _vad_events(result: Any) -> list[tuple[float, float]]:
|
||||||
|
"""Normalize FunASR streaming VAD output to start/end milliseconds."""
|
||||||
|
if isinstance(result, list):
|
||||||
|
result = result[0] if result else {}
|
||||||
|
if isinstance(result, dict):
|
||||||
|
result = result.get("value") or result.get("segments") or []
|
||||||
|
if not isinstance(result, list):
|
||||||
|
return []
|
||||||
|
events: list[tuple[float, float]] = []
|
||||||
|
for item in result:
|
||||||
|
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
events.append((float(item[0]), float(item[1])))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
class FunASRModelService:
|
||||||
|
"""Load FunASR once and create isolated streaming state per browser."""
|
||||||
|
|
||||||
|
native_partial_supported = True
|
||||||
|
|
||||||
|
def __init__(self, config: FunASRServiceConfig | None = None) -> None:
|
||||||
|
self.config = config or FunASRServiceConfig.from_env()
|
||||||
|
self.asr_model: Any | None = None
|
||||||
|
self.vad_model: Any | None = None
|
||||||
|
# Model objects are shared; each session owns its own cache. Serializing
|
||||||
|
# calls makes the first validation version predictable on one GPU.
|
||||||
|
self.inference_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Load streaming ASR and VAD outside the event loop."""
|
||||||
|
try:
|
||||||
|
from funasr import AutoModel
|
||||||
|
except ImportError as exc: # pragma: no cover - deployment-only branch
|
||||||
|
raise RuntimeError(
|
||||||
|
"FunASR is not installed; run pip install -r requirements-deploy.txt"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def load_models() -> tuple[Any, Any]:
|
||||||
|
common = {"disable_pbar": True, "disable_log": True}
|
||||||
|
asr = AutoModel(model=self.config.model, device=self.config.device, **common)
|
||||||
|
vad = AutoModel(model=self.config.vad_model, device=self.config.vad_device, **common)
|
||||||
|
return asr, vad
|
||||||
|
|
||||||
|
self.asr_model, self.vad_model = await asyncio.to_thread(load_models)
|
||||||
|
LOGGER.info(
|
||||||
|
"FunASR ready: model=%s vad=%s device=%s vad_device=%s",
|
||||||
|
self.config.model,
|
||||||
|
self.config.vad_model,
|
||||||
|
self.config.device,
|
||||||
|
self.config.vad_device,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Release references so CUDA memory can be reclaimed on shutdown."""
|
||||||
|
self.asr_model = None
|
||||||
|
self.vad_model = None
|
||||||
|
|
||||||
|
def create_session(self) -> "FunASRRealtimeSession":
|
||||||
|
"""Create a session with isolated VAD and ASR caches."""
|
||||||
|
if self.asr_model is None or self.vad_model is None:
|
||||||
|
raise RuntimeError("FunASR model service is not started")
|
||||||
|
return FunASRRealtimeSession(self)
|
||||||
|
|
||||||
|
async def generate_asr(self, audio: Any, status: dict[str, Any]) -> str:
|
||||||
|
"""Run one blocking ASR chunk while preserving its mutable cache."""
|
||||||
|
if self.asr_model is None:
|
||||||
|
raise RuntimeError("FunASR ASR model is not loaded")
|
||||||
|
|
||||||
|
def generate() -> str:
|
||||||
|
return _result_text(self.asr_model.generate(input=audio, **status))
|
||||||
|
|
||||||
|
async with self.inference_lock:
|
||||||
|
return await asyncio.to_thread(generate)
|
||||||
|
|
||||||
|
async def generate_vad(
|
||||||
|
self,
|
||||||
|
audio: Any,
|
||||||
|
status: dict[str, Any],
|
||||||
|
chunk_ms: int,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Run one streaming VAD chunk and normalize endpoint events."""
|
||||||
|
if self.vad_model is None:
|
||||||
|
raise RuntimeError("FunASR VAD model is not loaded")
|
||||||
|
|
||||||
|
def generate() -> list[tuple[float, float]]:
|
||||||
|
result = self.vad_model.generate(input=audio, chunk_size=chunk_ms, **status)
|
||||||
|
return _vad_events(result)
|
||||||
|
|
||||||
|
async with self.inference_lock:
|
||||||
|
return await asyncio.to_thread(generate)
|
||||||
|
|
||||||
|
|
||||||
|
class _StreamingASRTurn:
|
||||||
|
"""One utterance using FunASR's ordered chunk/cache lifecycle."""
|
||||||
|
|
||||||
|
def __init__(self, service: FunASRModelService) -> None:
|
||||||
|
self.service = service
|
||||||
|
self.cache: dict[str, Any] = {}
|
||||||
|
self.pending = bytearray()
|
||||||
|
self.cumulative_text = ""
|
||||||
|
self.last_chunk_text = ""
|
||||||
|
self.pending_outputs: list[str] = []
|
||||||
|
self.chunk_samples = max(1, service.config.chunk_size[1] * 960)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_chunk_text(current: str, chunk: str, previous_chunk: str) -> str:
|
||||||
|
"""Merge chunk text while tolerating wrappers returning cumulative text."""
|
||||||
|
if not chunk or chunk == previous_chunk:
|
||||||
|
return current
|
||||||
|
if current and chunk.startswith(current):
|
||||||
|
return chunk
|
||||||
|
return current + chunk
|
||||||
|
|
||||||
|
async def append(self, pcm_bytes: bytes) -> list[str]:
|
||||||
|
"""Decode complete chunks but hold one chunk for the final flush."""
|
||||||
|
if not pcm_bytes:
|
||||||
|
return []
|
||||||
|
self.pending.extend(pcm_bytes)
|
||||||
|
chunk_bytes = self.chunk_samples * 2
|
||||||
|
# Hold one full chunk so the real last chunk receives is_final=True.
|
||||||
|
while len(self.pending) >= chunk_bytes * 2:
|
||||||
|
chunk = bytes(self.pending[:chunk_bytes])
|
||||||
|
del self.pending[:chunk_bytes]
|
||||||
|
text = await self.service.generate_asr(
|
||||||
|
chunk,
|
||||||
|
{
|
||||||
|
"cache": self.cache,
|
||||||
|
"is_final": False,
|
||||||
|
"chunk_size": self.service.config.chunk_size,
|
||||||
|
"encoder_chunk_look_back": self.service.config.encoder_chunk_look_back,
|
||||||
|
"decoder_chunk_look_back": self.service.config.decoder_chunk_look_back,
|
||||||
|
"batch_size": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.cumulative_text = self._merge_chunk_text(
|
||||||
|
self.cumulative_text, text, self.last_chunk_text
|
||||||
|
)
|
||||||
|
self.last_chunk_text = text
|
||||||
|
if text:
|
||||||
|
self.pending_outputs.append(self.cumulative_text)
|
||||||
|
outputs = self.pending_outputs
|
||||||
|
self.pending_outputs = []
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
async def finish(self) -> str:
|
||||||
|
"""Flush the last buffered chunk and return cumulative text."""
|
||||||
|
if self.pending:
|
||||||
|
chunk = bytes(self.pending)
|
||||||
|
self.pending.clear()
|
||||||
|
text = await self.service.generate_asr(
|
||||||
|
chunk,
|
||||||
|
{
|
||||||
|
"cache": self.cache,
|
||||||
|
"is_final": True,
|
||||||
|
"chunk_size": self.service.config.chunk_size,
|
||||||
|
"encoder_chunk_look_back": self.service.config.encoder_chunk_look_back,
|
||||||
|
"decoder_chunk_look_back": self.service.config.decoder_chunk_look_back,
|
||||||
|
"batch_size": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.cumulative_text = self._merge_chunk_text(
|
||||||
|
self.cumulative_text, text, self.last_chunk_text
|
||||||
|
)
|
||||||
|
self.last_chunk_text = text
|
||||||
|
return self.cumulative_text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class FunASRRealtimeSession:
|
||||||
|
"""FunASR VAD + streaming ASR session used by one browser WebSocket."""
|
||||||
|
|
||||||
|
def __init__(self, service: FunASRModelService) -> None:
|
||||||
|
self.service = service
|
||||||
|
self.sample_rate = service.config.sample_rate
|
||||||
|
self.vad_chunk_bytes = service.config.vad_chunk_ms * self.sample_rate * 2 // 1000
|
||||||
|
self.vad_buffer = bytearray()
|
||||||
|
self.vad_cache: dict[str, Any] = {}
|
||||||
|
self.pre_roll = bytearray()
|
||||||
|
self.pre_roll_max_bytes = 300 * self.sample_rate * 2 // 1000
|
||||||
|
self.total_samples = 0
|
||||||
|
self.speech_started = False
|
||||||
|
self.segment_id = 0
|
||||||
|
self.segment_start_ms = 0.0
|
||||||
|
self.segment_audio = bytearray()
|
||||||
|
self.segment_voiced_ms = 0.0
|
||||||
|
self.asr_turn: _StreamingASRTurn | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_float32(pcm_bytes: bytes) -> Any:
|
||||||
|
"""Convert browser PCM16 to the float waveform expected by FunASR."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
return np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
def _start_segment(self, start_ms: float) -> None:
|
||||||
|
"""Open a turn and retain a short pre-roll for initial phonemes."""
|
||||||
|
self.speech_started = True
|
||||||
|
self.segment_start_ms = max(
|
||||||
|
0.0,
|
||||||
|
start_ms - len(self.pre_roll) / (self.sample_rate * 2) * 1000,
|
||||||
|
)
|
||||||
|
self.segment_audio = bytearray(self.pre_roll)
|
||||||
|
self.segment_voiced_ms = 0.0
|
||||||
|
self.asr_turn = _StreamingASRTurn(self.service)
|
||||||
|
self.pre_roll.clear()
|
||||||
|
|
||||||
|
async def _feed_vad_chunk(self, chunk: bytes) -> list[FunASRSegment]:
|
||||||
|
"""Feed VAD, then stream this audio into the active ASR turn."""
|
||||||
|
self.total_samples += len(chunk) // 2
|
||||||
|
events = await self.service.generate_vad(
|
||||||
|
self._to_float32(chunk),
|
||||||
|
{"cache": self.vad_cache, "is_final": False},
|
||||||
|
self.service.config.vad_chunk_ms,
|
||||||
|
)
|
||||||
|
starts = [start for start, _ in events if start >= 0]
|
||||||
|
ends = [end for _, end in events if end >= 0]
|
||||||
|
results: list[FunASRSegment] = []
|
||||||
|
if not self.speech_started and starts:
|
||||||
|
self._start_segment(starts[0])
|
||||||
|
|
||||||
|
if self.speech_started:
|
||||||
|
self.segment_audio.extend(chunk)
|
||||||
|
self.segment_voiced_ms += len(chunk) / (self.sample_rate * 2) * 1000
|
||||||
|
if self.asr_turn is not None:
|
||||||
|
for text in await self.asr_turn.append(chunk):
|
||||||
|
results.append(self._partial(text))
|
||||||
|
if self._segment_duration_ms() >= self.service.config.max_segment_sec * 1000:
|
||||||
|
results.append(await self._finish_segment("max_duration"))
|
||||||
|
else:
|
||||||
|
self.pre_roll.extend(chunk)
|
||||||
|
del self.pre_roll[:-self.pre_roll_max_bytes]
|
||||||
|
|
||||||
|
if ends and self.speech_started:
|
||||||
|
results.append(await self._finish_segment("vad_end"))
|
||||||
|
return [event for event in results if event.text or event.is_final]
|
||||||
|
|
||||||
|
def _segment_duration_ms(self) -> float:
|
||||||
|
return len(self.segment_audio) / (self.sample_rate * 2) * 1000
|
||||||
|
|
||||||
|
def _partial(self, text: str) -> FunASRSegment:
|
||||||
|
"""Create a partial event using the cumulative FunASR text."""
|
||||||
|
return FunASRSegment(
|
||||||
|
text=text,
|
||||||
|
start_time_ms=self.segment_start_ms,
|
||||||
|
end_time_ms=self.segment_start_ms + self._segment_duration_ms(),
|
||||||
|
audio=b"",
|
||||||
|
voiced_ms=self.segment_voiced_ms,
|
||||||
|
is_final=False,
|
||||||
|
sentence_id=self.segment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _finish_segment(self, reason: str) -> FunASRSegment:
|
||||||
|
"""Flush the ASR cache, then release completed segment audio."""
|
||||||
|
text = await self.asr_turn.finish() if self.asr_turn is not None else ""
|
||||||
|
result = FunASRSegment(
|
||||||
|
text=text,
|
||||||
|
start_time_ms=self.segment_start_ms,
|
||||||
|
end_time_ms=self.segment_start_ms + self._segment_duration_ms(),
|
||||||
|
audio=bytes(self.segment_audio),
|
||||||
|
voiced_ms=self.segment_voiced_ms,
|
||||||
|
is_final=True,
|
||||||
|
sentence_id=self.segment_id,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
self.segment_id += 1
|
||||||
|
self.segment_audio.clear()
|
||||||
|
self.asr_turn = None
|
||||||
|
self.speech_started = False
|
||||||
|
self.segment_voiced_ms = 0.0
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def feed(self, pcm_bytes: bytes) -> list[FunASRSegment]:
|
||||||
|
"""Consume PCM16 and return FunASR partial/final events."""
|
||||||
|
if len(pcm_bytes) % 2:
|
||||||
|
raise ValueError("PCM16 音频必须包含完整的双字节采样")
|
||||||
|
self.vad_buffer.extend(pcm_bytes)
|
||||||
|
results: list[FunASRSegment] = []
|
||||||
|
while len(self.vad_buffer) >= self.vad_chunk_bytes:
|
||||||
|
chunk = bytes(self.vad_buffer[:self.vad_chunk_bytes])
|
||||||
|
del self.vad_buffer[:self.vad_chunk_bytes]
|
||||||
|
results.extend(await self._feed_vad_chunk(chunk))
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def finish(self) -> list[FunASRSegment]:
|
||||||
|
"""Flush buffered audio and discard all stream caches."""
|
||||||
|
results: list[FunASRSegment] = []
|
||||||
|
if self.vad_buffer:
|
||||||
|
chunk = bytes(self.vad_buffer)
|
||||||
|
self.vad_buffer.clear()
|
||||||
|
if not self.speech_started and chunk:
|
||||||
|
self._start_segment(self.total_samples / self.sample_rate * 1000)
|
||||||
|
self.total_samples += len(chunk) // 2
|
||||||
|
if self.speech_started:
|
||||||
|
self.segment_audio.extend(chunk)
|
||||||
|
self.segment_voiced_ms += len(chunk) / (self.sample_rate * 2) * 1000
|
||||||
|
if self.asr_turn is not None:
|
||||||
|
for text in await self.asr_turn.append(chunk):
|
||||||
|
results.append(self._partial(text))
|
||||||
|
if self.speech_started:
|
||||||
|
results.append(await self._finish_segment("eof"))
|
||||||
|
self.vad_cache = {}
|
||||||
|
self.pre_roll.clear()
|
||||||
|
return [event for event in results if event.text or event.is_final]
|
||||||
|
|
@ -0,0 +1,642 @@
|
||||||
|
"""Browser-facing WebSocket adapter for the migrated FunASR engine.
|
||||||
|
|
||||||
|
The frontend contract remains the existing start/binary/stop protocol. All
|
||||||
|
speech activity detection and streaming ASR state now come from FunASR; this
|
||||||
|
module only translates engine events into the existing sentence snapshots.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from aiohttp import WSMsgType, web
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .auxiliary_service import AuxiliaryModelService, AuxiliaryServiceConfig
|
||||||
|
from .funasr_engine import FunASRModelService, FunASRSegment, FunASRServiceConfig
|
||||||
|
from .speaker_assembler import SegmentAssembler
|
||||||
|
except ImportError:
|
||||||
|
from auxiliary_service import AuxiliaryModelService, AuxiliaryServiceConfig
|
||||||
|
from funasr_engine import FunASRModelService, FunASRSegment, FunASRServiceConfig
|
||||||
|
from speaker_assembler import SegmentAssembler
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
load_dotenv(PROJECT_ROOT / ".env")
|
||||||
|
|
||||||
|
WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0")
|
||||||
|
WEB_PORT = int(os.getenv("WEB_PORT", "8082"))
|
||||||
|
WEB_DISPLAY_HOST = os.getenv("WEB_DISPLAY_HOST", "127.0.0.1")
|
||||||
|
LOCAL_ENGINE_URL = "local://funasr"
|
||||||
|
PARTIAL_BYTES_PER_SECOND = 16000 * 2
|
||||||
|
MIN_SPEAKER_VOICE_MS = 800
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EndOfStream:
|
||||||
|
"""Queue marker that cannot be confused with an audio frame."""
|
||||||
|
|
||||||
|
|
||||||
|
EOF = EndOfStream()
|
||||||
|
MODEL_SERVICE_KEY = web.AppKey("model_service", FunASRModelService)
|
||||||
|
AUXILIARY_SERVICE_KEY = web.AppKey("auxiliary_service", AuxiliaryModelService)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SpeakerJob:
|
||||||
|
"""Completed FunASR turn waiting for optional CAM++ speaker matching."""
|
||||||
|
|
||||||
|
sentence_id: int
|
||||||
|
audio: bytes
|
||||||
|
start_time_ms: float
|
||||||
|
end_time_ms: float
|
||||||
|
voiced_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionMetrics:
|
||||||
|
"""Small runtime snapshot shown in the existing frontend."""
|
||||||
|
|
||||||
|
started_at: float
|
||||||
|
audio_bytes: int = 0
|
||||||
|
input_chunks: int = 0
|
||||||
|
partial_count: int = 0
|
||||||
|
partial_revisions: int = 0
|
||||||
|
first_partial_ms: float | None = None
|
||||||
|
final_ms: float | None = None
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
"""Return JSON-safe metrics relative to session start."""
|
||||||
|
return {
|
||||||
|
"audio_bytes": self.audio_bytes,
|
||||||
|
"input_chunks": self.input_chunks,
|
||||||
|
"partial_count": self.partial_count,
|
||||||
|
"partial_revisions": self.partial_revisions,
|
||||||
|
"first_partial_ms": self.first_partial_ms,
|
||||||
|
"final_ms": self.final_ms,
|
||||||
|
"elapsed_ms": round((time.perf_counter() - self.started_at) * 1000, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IncrementalWavDecoder:
|
||||||
|
"""Strip a streamed RIFF header before forwarding PCM16 to FunASR."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.buffer = bytearray()
|
||||||
|
self.payload_started = False
|
||||||
|
self.riff_read = False
|
||||||
|
self.format_valid = False
|
||||||
|
self.data_remaining: int | None = None
|
||||||
|
|
||||||
|
def feed(self, chunk: bytes) -> bytes:
|
||||||
|
"""Parse complete RIFF chunks without buffering the whole recording."""
|
||||||
|
if self.payload_started:
|
||||||
|
if self.data_remaining is None:
|
||||||
|
return chunk
|
||||||
|
payload = chunk[: self.data_remaining]
|
||||||
|
self.data_remaining -= len(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
self.buffer.extend(chunk)
|
||||||
|
if not self.riff_read:
|
||||||
|
if len(self.buffer) < 12:
|
||||||
|
return b""
|
||||||
|
if self.buffer[:4] != b"RIFF" or self.buffer[8:12] != b"WAVE":
|
||||||
|
raise ValueError("文件不是有效的 RIFF/WAV 音频")
|
||||||
|
del self.buffer[:12]
|
||||||
|
self.riff_read = True
|
||||||
|
|
||||||
|
while len(self.buffer) >= 8:
|
||||||
|
kind = bytes(self.buffer[:4])
|
||||||
|
size = int.from_bytes(self.buffer[4:8], "little")
|
||||||
|
if size > 1024 * 1024:
|
||||||
|
raise ValueError("WAV 元数据头过大,请转换为标准 PCM WAV")
|
||||||
|
chunk_size = 8 + size + (size % 2)
|
||||||
|
if len(self.buffer) < chunk_size:
|
||||||
|
return b""
|
||||||
|
body = self.buffer[8 : 8 + size]
|
||||||
|
if kind == b"fmt ":
|
||||||
|
if size < 16:
|
||||||
|
raise ValueError("WAV fmt 区块不完整")
|
||||||
|
fields = (
|
||||||
|
int.from_bytes(body[0:2], "little"),
|
||||||
|
int.from_bytes(body[2:4], "little"),
|
||||||
|
int.from_bytes(body[4:8], "little"),
|
||||||
|
int.from_bytes(body[14:16], "little"),
|
||||||
|
)
|
||||||
|
if fields != (1, 1, 16000, 16):
|
||||||
|
raise ValueError("WAV 必须为 16kHz、单声道、PCM16")
|
||||||
|
self.format_valid = True
|
||||||
|
if kind == b"data":
|
||||||
|
if not self.format_valid or size % 2:
|
||||||
|
raise ValueError("WAV 必须为 16kHz、单声道、PCM16")
|
||||||
|
self.payload_started = True
|
||||||
|
self.data_remaining = size
|
||||||
|
del self.buffer[:8]
|
||||||
|
payload = bytes(self.buffer[:size])
|
||||||
|
del self.buffer[: min(size, len(self.buffer))]
|
||||||
|
self.data_remaining -= len(payload)
|
||||||
|
return payload
|
||||||
|
del self.buffer[:chunk_size]
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
"""Reject a truncated or header-only WAV before final inference."""
|
||||||
|
if not self.payload_started:
|
||||||
|
raise ValueError("WAV 文件不完整,未收到 data 音频区块")
|
||||||
|
if self.data_remaining not in (None, 0):
|
||||||
|
raise ValueError("WAV 文件不完整,未收到全部音频数据")
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeSession:
|
||||||
|
"""Translate FunASR events into the existing sentence/display protocol."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ws: web.WebSocketResponse,
|
||||||
|
model_service: FunASRModelService,
|
||||||
|
auxiliary_service: AuxiliaryModelService | None,
|
||||||
|
start: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
self.ws = ws
|
||||||
|
self.model_service = model_service
|
||||||
|
self.auxiliary_service = auxiliary_service
|
||||||
|
self.start = start
|
||||||
|
self.engine = model_service.create_session()
|
||||||
|
self.session_id = uuid4().hex
|
||||||
|
self.send_lock = asyncio.Lock()
|
||||||
|
self.audio_queue: asyncio.Queue[bytes | EndOfStream] = asyncio.Queue(maxsize=128)
|
||||||
|
self.speaker_queue: asyncio.Queue[SpeakerJob | EndOfStream] = asyncio.Queue(maxsize=64)
|
||||||
|
self.assembler = SegmentAssembler()
|
||||||
|
self.metrics = SessionMetrics(time.perf_counter())
|
||||||
|
self.source = str(start.get("source") or "mic")
|
||||||
|
self.file_name = str(start.get("file_name") or "audio.pcm")
|
||||||
|
self.wav_decoder = (
|
||||||
|
IncrementalWavDecoder()
|
||||||
|
if self.source == "file" and Path(self.file_name).suffix.lower() == ".wav"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self.merge_adjacent = self._parse_flag(start.get("display_merge"), True)
|
||||||
|
# Speaker labels are required for every browser session.
|
||||||
|
self.speaker_enabled = True
|
||||||
|
self.speaker_warning_sent = False
|
||||||
|
self.input_stopped = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_flag(value: Any, default: bool) -> bool:
|
||||||
|
"""Accept booleans, 0/1 and string flags from old frontend clients."""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() not in {"", "0", "false", "no", "off"}
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
async def emit(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Send ordered JSON while the browser connection is still alive."""
|
||||||
|
async with self.send_lock:
|
||||||
|
if not self.ws.closed:
|
||||||
|
await self.ws.send_json(payload)
|
||||||
|
|
||||||
|
async def emit_state(self, sentence: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Send both the legacy sentence event and the full display snapshot."""
|
||||||
|
state = {
|
||||||
|
"type": "display_state",
|
||||||
|
"raw_segments": self.assembler.raw_snapshot(),
|
||||||
|
"display_blocks": self.assembler.display_blocks(self.merge_adjacent),
|
||||||
|
"metrics": self.metrics.snapshot(),
|
||||||
|
}
|
||||||
|
if sentence is not None:
|
||||||
|
await self.emit(
|
||||||
|
{
|
||||||
|
"type": "sentences",
|
||||||
|
"sentences": [sentence],
|
||||||
|
"metrics": self.metrics.snapshot(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await self.emit(state)
|
||||||
|
|
||||||
|
async def warn_speaker(self, message: str) -> None:
|
||||||
|
"""Expose speaker-service failures without interrupting ASR."""
|
||||||
|
if self.speaker_warning_sent:
|
||||||
|
return
|
||||||
|
await self.emit(
|
||||||
|
{
|
||||||
|
"type": "speaker_warning",
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"speaker_service_url": getattr(
|
||||||
|
getattr(self.auxiliary_service, "config", None), "base_url", None
|
||||||
|
),
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.speaker_warning_sent = True
|
||||||
|
|
||||||
|
async def _emit_engine_segment(self, segment: FunASRSegment) -> None:
|
||||||
|
"""Map one FunASR partial/final event to a stable sentence ID."""
|
||||||
|
if not segment.text:
|
||||||
|
if segment.is_final and self.assembler.segments.pop(segment.sentence_id, None) is not None:
|
||||||
|
await self.emit_state()
|
||||||
|
return
|
||||||
|
|
||||||
|
sentence_type = 1 if segment.is_final else 0
|
||||||
|
sentence = self.assembler.apply_sentence(
|
||||||
|
{
|
||||||
|
"sentence_id": segment.sentence_id,
|
||||||
|
"sentence": segment.text,
|
||||||
|
"sentence_type": sentence_type,
|
||||||
|
"start_time": segment.start_time_ms,
|
||||||
|
"end_time": segment.end_time_ms,
|
||||||
|
"speaker_id": -1,
|
||||||
|
"speaker_name": "",
|
||||||
|
"speaker_evidence": "pending",
|
||||||
|
"speaker_confidence": 0.0,
|
||||||
|
"speaker_strategy": "funasr_pending",
|
||||||
|
"commit_reason": segment.reason,
|
||||||
|
"speaker_status": (
|
||||||
|
"queued" if segment.is_final else "waiting_final"
|
||||||
|
)
|
||||||
|
if self.speaker_enabled
|
||||||
|
else "disabled",
|
||||||
|
"speaker_reason": (
|
||||||
|
"等待 CAM++ 声纹处理"
|
||||||
|
if segment.is_final
|
||||||
|
else "FunASR 流式结果,等待片段结束"
|
||||||
|
)
|
||||||
|
if self.speaker_enabled
|
||||||
|
else "说话人分离已关闭",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if sentence_type == 0:
|
||||||
|
self.metrics.partial_count += 1
|
||||||
|
if sentence["revision_count"] > 0:
|
||||||
|
self.metrics.partial_revisions += 1
|
||||||
|
if self.metrics.first_partial_ms is None:
|
||||||
|
self.metrics.first_partial_ms = round(
|
||||||
|
(time.perf_counter() - self.metrics.started_at) * 1000, 1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.metrics.final_ms = round(
|
||||||
|
(time.perf_counter() - self.metrics.started_at) * 1000, 1
|
||||||
|
)
|
||||||
|
await self.emit_state(sentence)
|
||||||
|
|
||||||
|
if segment.is_final and self.speaker_enabled:
|
||||||
|
await self.speaker_queue.put(
|
||||||
|
SpeakerJob(
|
||||||
|
sentence_id=segment.sentence_id,
|
||||||
|
audio=segment.audio,
|
||||||
|
start_time_ms=segment.start_time_ms,
|
||||||
|
end_time_ms=segment.end_time_ms,
|
||||||
|
voiced_ms=segment.voiced_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_speaker(self, job: SpeakerJob) -> None:
|
||||||
|
"""Keep the existing optional CAM++ display integration."""
|
||||||
|
if not self.speaker_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def update_status(status: str, reason: str) -> None:
|
||||||
|
updated = self.assembler.apply_speaker_update(
|
||||||
|
{
|
||||||
|
"sentence_id": job.sentence_id,
|
||||||
|
"speaker_id": -1,
|
||||||
|
"speaker_evidence": "pending",
|
||||||
|
"speaker_confidence": 0.0,
|
||||||
|
"speaker_status": status,
|
||||||
|
"speaker_reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await self.emit_state(updated)
|
||||||
|
|
||||||
|
if job.voiced_ms < MIN_SPEAKER_VOICE_MS:
|
||||||
|
await update_status(
|
||||||
|
"insufficient_audio",
|
||||||
|
f"有效语音不足 {MIN_SPEAKER_VOICE_MS}ms,不继承上一位说话人",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.auxiliary_service is None:
|
||||||
|
await update_status("service_unavailable", "未配置说话人辅助模型服务")
|
||||||
|
await self.warn_speaker("未配置辅助模型服务,无法执行说话人分离")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update_status("processing", "正在提取 CAM++ 声纹并匹配说话人")
|
||||||
|
try:
|
||||||
|
speaker = await self.auxiliary_service.resolve_speaker(
|
||||||
|
job.audio,
|
||||||
|
self.session_id,
|
||||||
|
job.start_time_ms,
|
||||||
|
job.end_time_ms,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.exception("speaker resolve failed: session=%s sentence=%s", self.session_id, job.sentence_id)
|
||||||
|
await update_status("service_error", str(exc))
|
||||||
|
await self.warn_speaker(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not speaker:
|
||||||
|
await update_status("no_embedding", "辅助服务未返回可用声纹结果")
|
||||||
|
return
|
||||||
|
update = dict(speaker)
|
||||||
|
update["sentence_id"] = job.sentence_id
|
||||||
|
update["speaker_name"] = str(update.get("speaker_name") or "")
|
||||||
|
updated = self.assembler.apply_speaker_update(update)
|
||||||
|
if updated is not None:
|
||||||
|
await self.emit_state(updated)
|
||||||
|
|
||||||
|
async def process_speakers(self) -> None:
|
||||||
|
"""Process completed turns in order so speaker clusters stay stable."""
|
||||||
|
while True:
|
||||||
|
item = await self.speaker_queue.get()
|
||||||
|
if isinstance(item, EndOfStream):
|
||||||
|
return
|
||||||
|
await self._resolve_speaker(item)
|
||||||
|
|
||||||
|
async def _feed(self, chunk: bytes) -> None:
|
||||||
|
"""Forward raw PCM to FunASR and publish all returned events."""
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
self.metrics.audio_bytes += len(chunk)
|
||||||
|
for segment in await self.engine.feed(chunk):
|
||||||
|
await self._emit_engine_segment(segment)
|
||||||
|
|
||||||
|
async def process_audio(self) -> None:
|
||||||
|
"""Consume audio until EOF, with no local RMS/VLLM segmentation path."""
|
||||||
|
while True:
|
||||||
|
item = await self.audio_queue.get()
|
||||||
|
if isinstance(item, EndOfStream):
|
||||||
|
break
|
||||||
|
self.metrics.input_chunks += 1
|
||||||
|
chunk = self.wav_decoder.feed(item) if self.wav_decoder is not None else item
|
||||||
|
await self._feed(chunk)
|
||||||
|
|
||||||
|
if self.wav_decoder is not None:
|
||||||
|
self.wav_decoder.finish()
|
||||||
|
for segment in await self.engine.finish():
|
||||||
|
await self._emit_engine_segment(segment)
|
||||||
|
await self.emit({"type": "metrics", "metrics": self.metrics.snapshot()})
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Drop per-session FunASR caches and temporary state."""
|
||||||
|
self.engine = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
async def index_handler(_: web.Request) -> web.FileResponse:
|
||||||
|
"""Serve the browser page without stale-cache surprises."""
|
||||||
|
return web.FileResponse(
|
||||||
|
Path(__file__).parent / "static" / "index.html",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def config_handler(request: web.Request) -> web.Response:
|
||||||
|
"""Expose local FunASR settings using the old response field names."""
|
||||||
|
model_service: FunASRModelService = request.app[MODEL_SERVICE_KEY]
|
||||||
|
auxiliary = request.app.get(AUXILIARY_SERVICE_KEY)
|
||||||
|
response = web.json_response(
|
||||||
|
{
|
||||||
|
"model_service_url": LOCAL_ENGINE_URL,
|
||||||
|
"model": model_service.config.model,
|
||||||
|
"engine": "funasr",
|
||||||
|
"speaker_service_url": getattr(
|
||||||
|
getattr(auxiliary, "config", None), "base_url", None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# The standalone frontend reads this API from its own origin.
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = os.getenv(
|
||||||
|
"FRONTEND_ORIGIN", "http://127.0.0.1:8080"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
|
||||||
|
"""Keep the old browser protocol while using FunASR internally."""
|
||||||
|
ws = web.WebSocketResponse(max_msg_size=64 * 1024 * 1024)
|
||||||
|
await ws.prepare(request)
|
||||||
|
model_service: FunASRModelService = request.app[MODEL_SERVICE_KEY]
|
||||||
|
auxiliary_service = request.app.get(AUXILIARY_SERVICE_KEY)
|
||||||
|
processing: asyncio.Task[None] | None = None
|
||||||
|
speaker_processing: asyncio.Task[None] | None = None
|
||||||
|
session: RealtimeSession | None = None
|
||||||
|
input_finished = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
first = await ws.receive()
|
||||||
|
if first.type != WSMsgType.TEXT:
|
||||||
|
await ws.send_json({"type": "error", "message": "first message must be JSON start"})
|
||||||
|
return ws
|
||||||
|
try:
|
||||||
|
start = json.loads(first.data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await ws.send_json({"type": "error", "message": "invalid start JSON"})
|
||||||
|
return ws
|
||||||
|
if not isinstance(start, dict) or start.get("type") != "start":
|
||||||
|
await ws.send_json({"type": "error", "message": "first message must have type=start"})
|
||||||
|
return ws
|
||||||
|
|
||||||
|
source = str(start.get("source") or "mic")
|
||||||
|
suffix = Path(str(start.get("file_name") or "")).suffix.lower()
|
||||||
|
if source == "file" and suffix not in {".pcm", ".wav"}:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"message": "实时流式测试的文件模式只支持 PCM 或 WAV,请改用麦克风、PCM 或 WAV",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
session = RealtimeSession(ws, model_service, auxiliary_service, start)
|
||||||
|
speaker_health: dict[str, Any] | None = None
|
||||||
|
speaker_health_error: str | None = None
|
||||||
|
if session.speaker_enabled and auxiliary_service is not None:
|
||||||
|
try:
|
||||||
|
speaker_health = await asyncio.wait_for(auxiliary_service.health(), timeout=5)
|
||||||
|
if speaker_health.get("speaker_embedding_ready", speaker_health.get("ready")) is False:
|
||||||
|
speaker_health_error = "辅助模型服务未就绪,请检查 /health 返回的 models 状态"
|
||||||
|
except Exception as exc:
|
||||||
|
speaker_health_error = f"说话人辅助服务不可用:{exc}"
|
||||||
|
|
||||||
|
await session.emit(
|
||||||
|
{
|
||||||
|
"type": "start",
|
||||||
|
"model_service_url": LOCAL_ENGINE_URL,
|
||||||
|
"model": model_service.config.model,
|
||||||
|
"engine": "funasr",
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"enable_native_partial_stream": True,
|
||||||
|
"native_partial_supported": True,
|
||||||
|
"partial_mode": "funasr_streaming_cache",
|
||||||
|
"speaker_diarization_enabled": session.speaker_enabled,
|
||||||
|
"speaker_service_url": getattr(
|
||||||
|
getattr(auxiliary_service, "config", None), "base_url", None
|
||||||
|
),
|
||||||
|
"speaker_service_health": speaker_health,
|
||||||
|
"speaker_gap_enabled": False,
|
||||||
|
"sentence_strategy": start.get("sentence_strategy", 0),
|
||||||
|
"display_state_supported": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if speaker_health_error:
|
||||||
|
await session.warn_speaker(speaker_health_error)
|
||||||
|
|
||||||
|
processing = asyncio.create_task(session.process_audio())
|
||||||
|
if session.speaker_enabled:
|
||||||
|
speaker_processing = asyncio.create_task(session.process_speakers())
|
||||||
|
|
||||||
|
async def receive_or_raise() -> Any:
|
||||||
|
"""Wake up immediately when the engine worker fails."""
|
||||||
|
receive_task = asyncio.create_task(ws.receive())
|
||||||
|
workers = [task for task in (processing, speaker_processing) if task is not None]
|
||||||
|
done, _ = await asyncio.wait(
|
||||||
|
[receive_task, *workers],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
if receive_task in done:
|
||||||
|
return await receive_task
|
||||||
|
receive_task.cancel()
|
||||||
|
await asyncio.gather(receive_task, return_exceptions=True)
|
||||||
|
for worker in workers:
|
||||||
|
if worker in done:
|
||||||
|
await worker
|
||||||
|
raise RuntimeError("FunASR 实时处理任务意外结束")
|
||||||
|
|
||||||
|
while not ws.closed:
|
||||||
|
message = await receive_or_raise()
|
||||||
|
if message.type == WSMsgType.BINARY:
|
||||||
|
await session.audio_queue.put(bytes(message.data))
|
||||||
|
continue
|
||||||
|
if message.type == WSMsgType.TEXT:
|
||||||
|
try:
|
||||||
|
control = json.loads(message.data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if not isinstance(control, dict):
|
||||||
|
continue
|
||||||
|
if control.get("type") in {"eof", "stop"}:
|
||||||
|
input_finished = True
|
||||||
|
session.input_stopped = control.get("type") == "stop"
|
||||||
|
await session.emit({"type": "draining", "message": "FunASR 正在完成最终识别"})
|
||||||
|
await session.audio_queue.put(EOF)
|
||||||
|
break
|
||||||
|
if control.get("type") == "abort":
|
||||||
|
return ws
|
||||||
|
if message.type in {WSMsgType.ERROR, WSMsgType.CLOSE, WSMsgType.CLOSED}:
|
||||||
|
return ws
|
||||||
|
|
||||||
|
if input_finished:
|
||||||
|
await processing
|
||||||
|
if speaker_processing is not None:
|
||||||
|
await session.speaker_queue.put(EOF)
|
||||||
|
await speaker_processing
|
||||||
|
await session.emit_state()
|
||||||
|
await session.emit(
|
||||||
|
{
|
||||||
|
"type": "end",
|
||||||
|
"metrics": session.metrics.snapshot(),
|
||||||
|
"sentences": session.assembler.raw_snapshot(),
|
||||||
|
"display_blocks": session.assembler.display_blocks(session.merge_adjacent),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.exception("FunASR WebSocket session failed")
|
||||||
|
if not ws.closed:
|
||||||
|
await ws.send_json({"type": "error", "message": str(exc)})
|
||||||
|
finally:
|
||||||
|
for task in (processing, speaker_processing):
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(
|
||||||
|
*(task for task in (processing, speaker_processing) if task is not None),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
if session is not None:
|
||||||
|
reset = getattr(session.auxiliary_service, "reset_speaker_session", None)
|
||||||
|
await session.close()
|
||||||
|
if reset is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(reset(session.session_id), timeout=5)
|
||||||
|
except Exception:
|
||||||
|
LOGGER.warning("speaker session cleanup failed: %s", session.session_id, exc_info=True)
|
||||||
|
if not ws.closed:
|
||||||
|
await ws.close()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
async def start_app(
|
||||||
|
model: str | None = None,
|
||||||
|
device: str | None = None,
|
||||||
|
) -> web.Application:
|
||||||
|
"""Create the FunASR-backed frontend application."""
|
||||||
|
config = FunASRServiceConfig.from_env()
|
||||||
|
if model:
|
||||||
|
config = replace(config, model=model)
|
||||||
|
if device:
|
||||||
|
config = replace(config, device=device)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app[MODEL_SERVICE_KEY] = FunASRModelService(config)
|
||||||
|
app[AUXILIARY_SERVICE_KEY] = AuxiliaryModelService(
|
||||||
|
AuxiliaryServiceConfig(
|
||||||
|
base_url=os.getenv("AUXILIARY_SERVICE_URL", "http://127.0.0.1:8010")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lifecycle(application: web.Application):
|
||||||
|
await application[MODEL_SERVICE_KEY].start()
|
||||||
|
await application[AUXILIARY_SERVICE_KEY].start()
|
||||||
|
try:
|
||||||
|
health = await application[AUXILIARY_SERVICE_KEY].health()
|
||||||
|
if not health.get("speaker_embedding_ready"):
|
||||||
|
raise RuntimeError("CAM++ speaker service is not ready")
|
||||||
|
except Exception:
|
||||||
|
await application[AUXILIARY_SERVICE_KEY].close()
|
||||||
|
await application[MODEL_SERVICE_KEY].close()
|
||||||
|
raise
|
||||||
|
yield
|
||||||
|
await application[AUXILIARY_SERVICE_KEY].close()
|
||||||
|
await application[MODEL_SERVICE_KEY].close()
|
||||||
|
|
||||||
|
app.cleanup_ctx.append(lifecycle)
|
||||||
|
app.router.add_get("/", index_handler)
|
||||||
|
app.router.add_get("/api/config", config_handler)
|
||||||
|
app.router.add_static("/static/", Path(__file__).parent / "static")
|
||||||
|
app.router.add_get("/ws", websocket_handler)
|
||||||
|
app.router.add_static("/", Path(__file__).parent / "static", show_index=False)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Start the FunASR-backed browser demo."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--model", default=os.getenv("FUNASR_ASR_MODEL"))
|
||||||
|
parser.add_argument("--device", default=os.getenv("FUNASR_DEVICE"))
|
||||||
|
parser.add_argument("--no-browser", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
if not args.no_browser:
|
||||||
|
webbrowser.open(f"http://{WEB_DISPLAY_HOST}:{WEB_PORT}/")
|
||||||
|
print(f"FunASR demo: http://{WEB_DISPLAY_HOST}:{WEB_PORT}/", flush=True)
|
||||||
|
print(f"FunASR model: {args.model or FunASRServiceConfig.from_env().model}", flush=True)
|
||||||
|
web.run_app(
|
||||||
|
start_app(model=args.model, device=args.device),
|
||||||
|
host=WEB_HOST,
|
||||||
|
port=WEB_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,2 +1,6 @@
|
||||||
aiohttp==3.11.11
|
aiohttp==3.11.11
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
funasr==1.4.16
|
||||||
|
modelscope[framework]==1.34.0
|
||||||
|
soundfile==0.13.1
|
||||||
|
librosa==0.11.0
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ const elEngineModel = document.getElementById('engineModel');
|
||||||
const elModelServiceUrl = document.getElementById('modelServiceUrl');
|
const elModelServiceUrl = document.getElementById('modelServiceUrl');
|
||||||
const elSpeakerStatus = document.getElementById('speakerStatus');
|
const elSpeakerStatus = document.getElementById('speakerStatus');
|
||||||
const elDisplayMerge = document.getElementById('displayMerge');
|
const elDisplayMerge = document.getElementById('displayMerge');
|
||||||
const elSpeakerDiarization = document.getElementById('speakerDiarization');
|
|
||||||
const elDiarizationLabel = document.getElementById('diarizationLabel');
|
|
||||||
const elSentenceStrategy = document.getElementById('sentenceStrategy');
|
const elSentenceStrategy = document.getElementById('sentenceStrategy');
|
||||||
const elBtnStart = document.getElementById('btnStart');
|
const elBtnStart = document.getElementById('btnStart');
|
||||||
const elBtnStop = document.getElementById('btnStop');
|
const elBtnStop = document.getElementById('btnStop');
|
||||||
|
|
@ -450,7 +448,7 @@ async function startRecognition() {
|
||||||
sending = true;
|
sending = true;
|
||||||
|
|
||||||
const currentSession = ++sessionId;
|
const currentSession = ++sessionId;
|
||||||
const useSpeaker = elSpeakerDiarization.checked;
|
const useSpeaker = true; // Speaker labels are mandatory for this deployment.
|
||||||
let receivedTerminal = false;
|
let receivedTerminal = false;
|
||||||
|
|
||||||
// 构造 WebSocket 首条 start 消息。
|
// 构造 WebSocket 首条 start 消息。
|
||||||
|
|
@ -475,8 +473,10 @@ async function startRecognition() {
|
||||||
speed_factor: speedFactor
|
speed_factor: speedFactor
|
||||||
};
|
};
|
||||||
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const backendUrl = (window.ASR_BACKEND_URL || location.origin).replace(/\/$/, '');
|
||||||
ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
const websocketUrl = new URL(backendUrl + '/ws');
|
||||||
|
websocketUrl.protocol = backendUrl.startsWith('https:') ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(websocketUrl);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
|
@ -725,7 +725,9 @@ function stopMicCapture() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 展示实际部署端点及模型,避免沿用旧 SDK 的无效引擎配置。
|
// 展示实际部署端点及模型,避免沿用旧 SDK 的无效引擎配置。
|
||||||
fetch('/api/config').then(response => response.json()).then(config => {
|
// The static frontend reads API and WS addresses from runtime-config.js.
|
||||||
|
const backendUrl = (window.ASR_BACKEND_URL || location.origin).replace(/\/$/, '');
|
||||||
|
fetch(backendUrl + '/api/config').then(response => response.json()).then(config => {
|
||||||
if (!ws) {
|
if (!ws) {
|
||||||
elEngineModel.value = config.model;
|
elEngineModel.value = config.model;
|
||||||
elModelServiceUrl.value = config.model_service_url;
|
elModelServiceUrl.value = config.model_service_url;
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@
|
||||||
<div class="form-stack">
|
<div class="form-stack">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="engineModel">引擎模型</label>
|
<label for="engineModel">引擎模型</label>
|
||||||
<input type="text" id="engineModel" value="Qwen/Qwen3-ASR-0.6B">
|
<input type="text" id="engineModel" value="paraformer-zh-streaming">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="modelServiceUrl">vLLM 服务地址</label>
|
<label for="modelServiceUrl">FunASR 本地引擎</label>
|
||||||
<input type="text" id="modelServiceUrl" value="http://127.0.0.1:9950/v1">
|
<input type="text" id="modelServiceUrl" value="local://funasr" readonly>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="sentenceStrategy">分句策略</label>
|
<label for="sentenceStrategy">分句策略</label>
|
||||||
|
|
@ -39,9 +39,9 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>话者分离</label>
|
<label>话者分离</label>
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
<input type="checkbox" id="speakerDiarization" checked>
|
<input type="checkbox" id="speakerDiarization" checked disabled>
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
<span class="toggle-label" id="diarizationLabel">开启</span>
|
<span class="toggle-label" id="diarizationLabel">必需</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -114,6 +114,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 版本号变更用于刷新浏览器缓存,确保加载“未知说话人”标签逻辑。 -->
|
<!-- 版本号变更用于刷新浏览器缓存,确保加载“未知说话人”标签逻辑。 -->
|
||||||
<script src="app.js?v=213"></script>
|
<script src="runtime-config.js"></script>
|
||||||
|
<script src="app.js?v=214"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Unit tests for the migrated FunASR streaming lifecycle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
try:
|
||||||
|
from realtime_websocket.funasr_engine import (
|
||||||
|
FunASRRealtimeSession,
|
||||||
|
FunASRServiceConfig,
|
||||||
|
_result_text,
|
||||||
|
_vad_events,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from funasr_engine import (
|
||||||
|
FunASRRealtimeSession,
|
||||||
|
FunASRServiceConfig,
|
||||||
|
_result_text,
|
||||||
|
_vad_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.config = FunASRServiceConfig(
|
||||||
|
device="cpu",
|
||||||
|
vad_device="cpu",
|
||||||
|
vad_chunk_ms=1000,
|
||||||
|
chunk_size=(0, 2, 1),
|
||||||
|
)
|
||||||
|
self.vad_calls = 0
|
||||||
|
self.asr_calls: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
async def generate_vad(self, audio, status, chunk_ms):
|
||||||
|
self.vad_calls += 1
|
||||||
|
return [(0, -1)] if self.vad_calls == 1 else [(-1, 2000)]
|
||||||
|
|
||||||
|
async def generate_asr(self, audio, status):
|
||||||
|
self.asr_calls.append(status)
|
||||||
|
return "第一块" if len(self.asr_calls) == 1 else "第二块"
|
||||||
|
|
||||||
|
|
||||||
|
class FunASREngineTests(unittest.TestCase):
|
||||||
|
def test_result_normalization(self) -> None:
|
||||||
|
self.assertEqual(_result_text([{"text": "你好"}]), "你好")
|
||||||
|
self.assertEqual(_result_text({"value": "片段"}), "片段")
|
||||||
|
self.assertEqual(
|
||||||
|
_vad_events([{"value": [[0, -1], [-1, 800]]}]),
|
||||||
|
[(0.0, -1.0), (-1.0, 800.0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stream_has_independent_cache_and_final_flush(self) -> None:
|
||||||
|
async def exercise() -> None:
|
||||||
|
service = FakeService()
|
||||||
|
with patch.object(FunASRRealtimeSession, "_to_float32", staticmethod(lambda value: value)):
|
||||||
|
first = FunASRRealtimeSession(service)
|
||||||
|
second = FunASRRealtimeSession(service)
|
||||||
|
first_events = await first.feed(b"\\x01\\x00" * 16000)
|
||||||
|
second_events = await second.feed(b"\\x01\\x00" * 16000)
|
||||||
|
first_events += await first.feed(b"\\x01\\x00" * 16000)
|
||||||
|
first_events += await first.finish()
|
||||||
|
self.assertTrue(any(not event.is_final for event in first_events))
|
||||||
|
self.assertTrue(any(event.is_final for event in first_events))
|
||||||
|
self.assertEqual(first.segment_id, 1)
|
||||||
|
self.assertEqual(second.segment_id, 0)
|
||||||
|
self.assertTrue(any(status["is_final"] is True for status in service.asr_calls))
|
||||||
|
self.assertGreaterEqual(len(service.asr_calls), 2)
|
||||||
|
|
||||||
|
asyncio.run(exercise())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""WebSocket contract tests for the FunASR browser adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import AioHTTPTestCase
|
||||||
|
|
||||||
|
from realtime_websocket.funasr_engine import FunASRSegment
|
||||||
|
from realtime_websocket.funasr_server import (
|
||||||
|
AUXILIARY_SERVICE_KEY,
|
||||||
|
MODEL_SERVICE_KEY,
|
||||||
|
config_handler,
|
||||||
|
websocket_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFunASRSession:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent = False
|
||||||
|
|
||||||
|
async def feed(self, audio: bytes):
|
||||||
|
if self.sent:
|
||||||
|
return []
|
||||||
|
self.sent = True
|
||||||
|
return [
|
||||||
|
FunASRSegment(
|
||||||
|
text="实时片段",
|
||||||
|
start_time_ms=0,
|
||||||
|
end_time_ms=len(audio) / 32,
|
||||||
|
audio=b"",
|
||||||
|
voiced_ms=len(audio) / 32,
|
||||||
|
is_final=False,
|
||||||
|
sentence_id=0,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def finish(self):
|
||||||
|
return [
|
||||||
|
FunASRSegment(
|
||||||
|
text="最终片段",
|
||||||
|
start_time_ms=0,
|
||||||
|
end_time_ms=1000,
|
||||||
|
audio=b"\x01\x00" * 8000,
|
||||||
|
voiced_ms=1000,
|
||||||
|
is_final=True,
|
||||||
|
sentence_id=0,
|
||||||
|
reason="eof",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFunASRService:
|
||||||
|
config = SimpleNamespace(model="fake-funasr")
|
||||||
|
|
||||||
|
def create_session(self):
|
||||||
|
return FakeFunASRSession()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAuxiliaryService:
|
||||||
|
config = SimpleNamespace(base_url="http://fake-speaker")
|
||||||
|
|
||||||
|
async def health(self):
|
||||||
|
return {"ready": True, "speaker_embedding_ready": True}
|
||||||
|
|
||||||
|
async def reset_speaker_session(self, session_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FunASRWebSocketTests(AioHTTPTestCase):
|
||||||
|
def get_app(self):
|
||||||
|
app = web.Application()
|
||||||
|
app[MODEL_SERVICE_KEY] = FakeFunASRService()
|
||||||
|
app[AUXILIARY_SERVICE_KEY] = FakeAuxiliaryService()
|
||||||
|
app.router.add_get("/api/config", config_handler)
|
||||||
|
app.router.add_get("/ws", websocket_handler)
|
||||||
|
return app
|
||||||
|
|
||||||
|
async def test_frontend_contract_uses_funasr_streaming_mode(self):
|
||||||
|
ws = await self.client.ws_connect("/ws")
|
||||||
|
await ws.send_json({"type": "start", "speaker_diarization": 0})
|
||||||
|
start = await ws.receive_json()
|
||||||
|
self.assertEqual(start["type"], "start")
|
||||||
|
self.assertEqual(start["engine"], "funasr")
|
||||||
|
self.assertEqual(start["partial_mode"], "funasr_streaming_cache")
|
||||||
|
|
||||||
|
await ws.send_bytes(b"\x01\x00" * 16000)
|
||||||
|
await ws.send_json({"type": "eof"})
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while True:
|
||||||
|
message = await ws.receive_json()
|
||||||
|
messages.append(message)
|
||||||
|
if message["type"] == "end":
|
||||||
|
break
|
||||||
|
|
||||||
|
sentence_events = [
|
||||||
|
item for item in messages
|
||||||
|
if item["type"] == "sentences" and item["sentences"]
|
||||||
|
]
|
||||||
|
self.assertTrue(any(item["sentences"][0]["sentence_type"] == 0 for item in sentence_events))
|
||||||
|
self.assertEqual(sentence_events[-1]["sentences"][0]["sentence"], "最终片段")
|
||||||
|
self.assertEqual(messages[-1]["type"], "end")
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -4,7 +4,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from speaker_assembler import SegmentAssembler
|
try:
|
||||||
|
from realtime_websocket.speaker_assembler import SegmentAssembler
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from speaker_assembler import SegmentAssembler
|
||||||
|
|
||||||
|
|
||||||
class SegmentAssemblerTests(unittest.TestCase):
|
class SegmentAssemblerTests(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
aiohttp==3.11.11
|
aiohttp==3.11.11
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
funasr==1.3.1
|
funasr==1.4.16
|
||||||
modelscope[framework]==1.34.0
|
modelscope[framework]==1.34.0
|
||||||
soundfile==0.13.1
|
soundfile==0.13.1
|
||||||
librosa==0.11.0
|
librosa==0.11.0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Install a torch/torchaudio build matching the host CUDA or CPU environment first.
|
||||||
|
aiohttp==3.11.11
|
||||||
|
python-dotenv>=1.0
|
||||||
|
funasr==1.4.16
|
||||||
|
modelscope[framework]==1.34.0
|
||||||
|
soundfile==0.13.1
|
||||||
|
librosa==0.11.0
|
||||||
|
numpy>=1.24
|
||||||
|
|
@ -32,10 +32,9 @@ MODELS_DIR = Path(os.getenv("MODEL_DIR", str(PROJECT_ROOT / "models"))).resolve(
|
||||||
AUXILIARY_DEVICE = os.getenv("AUXILIARY_DEVICE", "cuda:0")
|
AUXILIARY_DEVICE = os.getenv("AUXILIARY_DEVICE", "cuda:0")
|
||||||
ONLINE_SPEAKER_MATCH_THRESHOLD = 0.68
|
ONLINE_SPEAKER_MATCH_THRESHOLD = 0.68
|
||||||
MIN_ONLINE_SPEAKER_AUDIO_MS = 800
|
MIN_ONLINE_SPEAKER_AUDIO_MS = 800
|
||||||
# 实时 WebSocket 必须使用 VAD + CAM++ 声纹模型进行在线聚类。完整的
|
# Realtime VAD is loaded by the WebSocket process. This service loads CAM++.
|
||||||
# speech_campplus_speaker-diarization_common 是整段离线 diarization 接口,
|
# Its standalone VAD HTTP endpoint remains available on demand.
|
||||||
# 与实时每个 turn 的 CAM++ embedding 不是同一加载路径;它仍可按需加载。
|
DEFAULT_PRELOAD_KINDS = {"speaker_verification"}
|
||||||
DEFAULT_PRELOAD_KINDS = {"vad", "speaker_verification"}
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_finite_float(value: object) -> float | None:
|
def _coerce_finite_float(value: object) -> float | None:
|
||||||
|
|
@ -102,8 +101,7 @@ class AuxiliaryRuntime:
|
||||||
raw = os.getenv("AUXILIARY_PRELOAD_KINDS", "")
|
raw = os.getenv("AUXILIARY_PRELOAD_KINDS", "")
|
||||||
if not raw.strip():
|
if not raw.strip():
|
||||||
return set(DEFAULT_PRELOAD_KINDS)
|
return set(DEFAULT_PRELOAD_KINDS)
|
||||||
# 无论环境变量如何设置,VAD 和 CAM++ speaker_verification 都是核心
|
# CAM++ is required; optional model kinds can be added for other endpoints.
|
||||||
# 依赖;额外类型只会增加预加载项,不能绕过核心模型校验。
|
|
||||||
return DEFAULT_PRELOAD_KINDS | {item.strip() for item in raw.split(",") if item.strip()}
|
return DEFAULT_PRELOAD_KINDS | {item.strip() for item in raw.split(",") if item.strip()}
|
||||||
|
|
||||||
def _load_asset(self, model_id: str, config: dict[str, Any], path: Path) -> Any | None:
|
def _load_asset(self, model_id: str, config: dict[str, Any], path: Path) -> Any | None:
|
||||||
|
|
@ -136,9 +134,12 @@ class AuxiliaryRuntime:
|
||||||
preload_kinds = self._preload_kinds()
|
preload_kinds = self._preload_kinds()
|
||||||
loaded_kinds: set[str] = set()
|
loaded_kinds: set[str] = set()
|
||||||
for model_id, config in self.assets.items():
|
for model_id, config in self.assets.items():
|
||||||
path = model_directory(model_id, self.manifest, MODELS_DIR)
|
|
||||||
record: dict[str, Any] = {"path": str(path), "asset_ready": _asset_ready(path, config)}
|
|
||||||
kind = str(config.get("kind") or "")
|
kind = str(config.get("kind") or "")
|
||||||
|
path = model_directory(model_id, self.manifest, MODELS_DIR)
|
||||||
|
if kind == "speaker_verification" and os.getenv("CAM_MODEL_PATH"):
|
||||||
|
# The backend launcher passes the selected local CAM++ directory.
|
||||||
|
path = Path(os.environ["CAM_MODEL_PATH"]).resolve()
|
||||||
|
record: dict[str, Any] = {"path": str(path), "asset_ready": _asset_ready(path, config)}
|
||||||
if kind not in preload_kinds:
|
if kind not in preload_kinds:
|
||||||
record["state"] = "optional_not_preloaded" if record["asset_ready"] else "optional_missing"
|
record["state"] = "optional_not_preloaded" if record["asset_ready"] else "optional_missing"
|
||||||
record["preload"] = False
|
record["preload"] = False
|
||||||
|
|
@ -166,7 +167,7 @@ class AuxiliaryRuntime:
|
||||||
else:
|
else:
|
||||||
record["state"] = "load_error"
|
record["state"] = "load_error"
|
||||||
record["error"] = "model loader returned no model"
|
record["error"] = "model loader returned no model"
|
||||||
if kind in {"vad", "speaker_verification"}:
|
if kind == "speaker_verification":
|
||||||
failures.append(model_id)
|
failures.append(model_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
record["state"] = "load_error"
|
record["state"] = "load_error"
|
||||||
|
|
@ -182,15 +183,11 @@ class AuxiliaryRuntime:
|
||||||
+ (f", error={record['error']}" if record.get("error") else ""),
|
+ (f", error={record['error']}" if record.get("error") else ""),
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
# VAD 和 CAM++ speaker_verification 都是核心依赖,缺失/加载异常时
|
# The WebSocket service owns the required VAD; this process requires CAM++.
|
||||||
# 立即失败并列出路径与底层错误,避免页面一直显示“未确认”。
|
|
||||||
required_failures = [
|
required_failures = [
|
||||||
model_id for model_id in failures
|
model_id for model_id in failures
|
||||||
if self.assets[model_id].get("kind") == "vad"
|
if self.assets[model_id].get("kind") == "speaker_verification"
|
||||||
or (
|
|
||||||
self.assets[model_id].get("kind") == "speaker_verification"
|
|
||||||
and self._speaker_embedding_model_id() is None
|
and self._speaker_embedding_model_id() is None
|
||||||
)
|
|
||||||
]
|
]
|
||||||
if required_failures:
|
if required_failures:
|
||||||
details = "; ".join(
|
details = "; ".join(
|
||||||
|
|
@ -200,8 +197,7 @@ class AuxiliaryRuntime:
|
||||||
)
|
)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Auxiliary core model is missing or failed to load: " + details
|
"Auxiliary core model is missing or failed to load: " + details
|
||||||
+ ". Run `python scripts/download_models.py --auxiliary-only` "
|
+ ". Set MODEL_DIR or CAM_MODEL_PATH to the local CAM++ asset."
|
||||||
"or set MODEL_DIR to the directory containing the downloaded assets."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_model(self, kind: str) -> Any:
|
def _find_model(self, kind: str) -> Any:
|
||||||
|
|
@ -243,7 +239,11 @@ class AuxiliaryRuntime:
|
||||||
|
|
||||||
async def vad(self, audio_path: str) -> Any:
|
async def vad(self, audio_path: str) -> Any:
|
||||||
"""使用临时音频文件执行一次串行化的 VAD 推理。"""
|
"""使用临时音频文件执行一次串行化的 VAD 推理。"""
|
||||||
|
# Realtime VAD lives in the WebSocket process; this HTTP endpoint loads on demand.
|
||||||
|
try:
|
||||||
model = self._find_model("vad")
|
model = self._find_model("vad")
|
||||||
|
except RuntimeError:
|
||||||
|
model = self._load_optional_kind("vad")
|
||||||
async with self.inference_lock:
|
async with self.inference_lock:
|
||||||
return await asyncio.to_thread(model.generate, input=audio_path, cache={})
|
return await asyncio.to_thread(model.generate, input=audio_path, cache={})
|
||||||
|
|
||||||
|
|
@ -433,9 +433,8 @@ async def health_handler(request: web.Request) -> web.Response:
|
||||||
if config.get("kind") == "vad" and model_id in runtime.models),
|
if config.get("kind") == "vad" and model_id in runtime.models),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
# ready 表示实时链路的两个核心模型都可用;完整 diarization 是否
|
# Readiness here reports the CAM++ model used by the realtime backend.
|
||||||
# 预加载不影响这里的结果。
|
ready = speaker_model_id is not None
|
||||||
ready = vad_model_id is not None and speaker_model_id is not None
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"ready": ready,
|
"ready": ready,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Start the local CAM++ model service and FunASR WebSocket backend together."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from scripts.model_manifest import auxiliary_models, load_manifest, model_directory
|
||||||
|
|
||||||
|
load_dotenv(PROJECT_ROOT / ".env")
|
||||||
|
|
||||||
|
# FunASR's published short names resolve to these ModelScope local directories.
|
||||||
|
LOCAL_MODEL_NAMES = {
|
||||||
|
"paraformer-zh-streaming": (
|
||||||
|
"iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online",
|
||||||
|
),
|
||||||
|
"fsmn-vad": (
|
||||||
|
"iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
|
||||||
|
"damo/speech_fsmn_vad_zh-cn-16k-common-pytorch",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def local_model(requested: str, models_dir: Path) -> Path:
|
||||||
|
"""Resolve a model ID or path beneath MODEL_DIR without a network fallback."""
|
||||||
|
name = requested.strip()
|
||||||
|
direct = Path(name)
|
||||||
|
candidates = [direct] if direct.is_absolute() else [
|
||||||
|
PROJECT_ROOT / direct, models_dir / direct,
|
||||||
|
*(models_dir / alias for alias in LOCAL_MODEL_NAMES.get(name, ())),
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.is_dir() and (candidate / "configuration.json").is_file():
|
||||||
|
return candidate.resolve()
|
||||||
|
checked = ", ".join(str(path) for path in candidates)
|
||||||
|
raise FileNotFoundError(f"Local model '{name}' was not found; checked: {checked}")
|
||||||
|
|
||||||
|
|
||||||
|
def local_cam_model(models_dir: Path) -> Path:
|
||||||
|
"""Require one complete CAM++ speaker verification asset from the manifest."""
|
||||||
|
manifest = load_manifest()
|
||||||
|
override = os.getenv("CAM_MODEL_PATH", "").strip()
|
||||||
|
if override:
|
||||||
|
path = Path(override)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = models_dir / path
|
||||||
|
path = path.resolve()
|
||||||
|
configs = [
|
||||||
|
config for config in auxiliary_models(manifest).values()
|
||||||
|
if config.get("kind") == "speaker_verification"
|
||||||
|
]
|
||||||
|
if path.is_dir() and configs and all(
|
||||||
|
(path / relative).is_file() for relative in configs[0].get("required_files", [])
|
||||||
|
):
|
||||||
|
return path
|
||||||
|
raise FileNotFoundError(f"CAM++ model is missing or incomplete: {path}")
|
||||||
|
checked = []
|
||||||
|
for model_id, config in auxiliary_models(manifest).items():
|
||||||
|
if config.get("kind") != "speaker_verification":
|
||||||
|
continue
|
||||||
|
path = model_directory(model_id, manifest, models_dir)
|
||||||
|
required = [path / relative for relative in config.get("required_files", [])]
|
||||||
|
if path.is_dir() and all(item.is_file() for item in required):
|
||||||
|
return path.resolve()
|
||||||
|
checked.append(str(path))
|
||||||
|
raise FileNotFoundError("CAM++ speaker model is missing; checked: " + ", ".join(checked))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_health(url: str, process: subprocess.Popen, key: str, seconds: int = 300) -> None:
|
||||||
|
"""Wait until a child has loaded its models, failing when it exits early."""
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
code = process.poll()
|
||||||
|
if code is not None:
|
||||||
|
raise RuntimeError(f"Service exited before it became ready ({url}, exit={code})")
|
||||||
|
try:
|
||||||
|
with urlopen(url, timeout=2) as response:
|
||||||
|
data = json.load(response)
|
||||||
|
if data.get(key):
|
||||||
|
return
|
||||||
|
except (OSError, ValueError, URLError):
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise TimeoutError(f"Service did not become ready within {seconds}s: {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_child(process: subprocess.Popen | None) -> None:
|
||||||
|
"""Stop a supervised model or WebSocket process on shutdown."""
|
||||||
|
if process is None or process.poll() is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Require ASR, VAD, and CAM++ before exposing the backend WebSocket."""
|
||||||
|
models_dir = Path(os.getenv("MODEL_DIR", "models"))
|
||||||
|
if not models_dir.is_absolute():
|
||||||
|
models_dir = PROJECT_ROOT / models_dir
|
||||||
|
models_dir = models_dir.resolve()
|
||||||
|
asr = local_model(os.getenv("FUNASR_ASR_MODEL", "paraformer-zh-streaming"), models_dir)
|
||||||
|
vad = local_model(os.getenv("FUNASR_VAD_MODEL", "fsmn-vad"), models_dir)
|
||||||
|
cam = local_cam_model(models_dir)
|
||||||
|
print(f"Local models: ASR={asr}; VAD={vad}; CAM++={cam}", flush=True)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update({
|
||||||
|
"MODEL_DIR": str(models_dir),
|
||||||
|
"FUNASR_ASR_MODEL": str(asr),
|
||||||
|
"FUNASR_VAD_MODEL": str(vad),
|
||||||
|
"CAM_MODEL_PATH": str(cam),
|
||||||
|
"AUXILIARY_SERVICE_URL": "http://127.0.0.1:8010",
|
||||||
|
})
|
||||||
|
auxiliary = None
|
||||||
|
websocket = None
|
||||||
|
try:
|
||||||
|
auxiliary = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "scripts.auxiliary_server"],
|
||||||
|
cwd=PROJECT_ROOT, env=env,
|
||||||
|
)
|
||||||
|
wait_for_health("http://127.0.0.1:8010/health", auxiliary, "speaker_embedding_ready")
|
||||||
|
websocket = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "scripts.run_funasr_demo", "--no-browser"],
|
||||||
|
cwd=PROJECT_ROOT, env=env,
|
||||||
|
)
|
||||||
|
web_port = int(env.get("WEB_PORT", "8082"))
|
||||||
|
wait_for_health(
|
||||||
|
f"http://127.0.0.1:{web_port}/api/config", websocket, "engine"
|
||||||
|
)
|
||||||
|
print(f"Backend ready: ws://127.0.0.1:{web_port}/ws", flush=True)
|
||||||
|
while True:
|
||||||
|
for label, process in (("CAM++", auxiliary), ("WebSocket", websocket)):
|
||||||
|
code = process.poll()
|
||||||
|
if code is not None:
|
||||||
|
raise RuntimeError(f"{label} service exited (exit={code})")
|
||||||
|
time.sleep(0.5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
stop_child(websocket)
|
||||||
|
stop_child(auxiliary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Serve the browser UI independently of the FunASR backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
STATIC_ROOT = PROJECT_ROOT / "realtime_websocket" / "static"
|
||||||
|
load_dotenv(PROJECT_ROOT / ".env")
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendHandler(SimpleHTTPRequestHandler):
|
||||||
|
"""Serve static assets and a browser-visible backend URL at runtime."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(STATIC_ROOT), **kwargs)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path.split("?", 1)[0] != "/runtime-config.js":
|
||||||
|
return super().do_GET()
|
||||||
|
backend_url = os.getenv("BACKEND_PUBLIC_URL", "http://127.0.0.1:8082").rstrip("/")
|
||||||
|
parsed = urlsplit(backend_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.path:
|
||||||
|
self.send_error(500, "BACKEND_PUBLIC_URL must be an HTTP origin")
|
||||||
|
return
|
||||||
|
# JSON escaping also yields a valid JavaScript string literal.
|
||||||
|
body = ("window.ASR_BACKEND_URL = " + json.dumps(backend_url) + ";\n").encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.getenv("FRONTEND_HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("FRONTEND_PORT", "8080"))
|
||||||
|
server = ThreadingHTTPServer((host, port), FrontendHandler)
|
||||||
|
print(f"Frontend: http://{host}:{port}/", flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Start the FunASR-backed browser demo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from realtime_websocket.funasr_server import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue