From f4747f3539b1f6be1f59b3db381d1ca7d7c863aa Mon Sep 17 00:00:00 2001 From: Bifang <915779419@qq.com> Date: Mon, 28 Sep 2026 14:33:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=83=AD=E8=AF=8D=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 +- FUNASR_README.md | 7 +- README.md | 4 +- .../realtime_websocket/funasr_native_wss.py | 29 ++++++-- backend/realtime_websocket/funasr_server.py | 73 +++++++++++++------ backend/run_backend.py | 33 ++++++--- frontend/static/app.js | 10 +++ frontend/static/index.html | 8 +- model_manifest.json | 33 +++++++-- scripts/download_models.py | 19 ++++- 10 files changed, 164 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index ef46590..39f57fb 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ MODEL_DIR=models # FunASR 流式 ASR 与 VAD 模型。 FUNASR_ASR_MODEL=paraformer-zh-streaming FUNASR_VAD_MODEL=fsmn-vad +# Contextual 模型在 2-pass 最终识别时应用本会话热词,和流式首遍共享 GPU。 +FUNASR_HOTWORD_ASR_MODEL=paraformer-zh-contextual +FUNASR_HOTWORD_ASR_MODEL_REVISION=v2.0.4 FUNASR_DEVICE=cuda:0 FUNASR_VAD_DEVICE=cpu # FunASR 原生 WebSocket 服务使用的 CPU 工作线程数。 @@ -17,7 +20,7 @@ FUNASR_VAD_PARAGRAPH_MAX_END_SILENCE_MS=5000 # 提高此阈值会过滤更多环境噪声,也可能漏掉较轻的语音。 FUNASR_VAD_SPEECH_NOISE_THRES=0.6 -# FunASR 原生 WebSocket 流式识别参数。 +# FunASR Runtime WebSocket 流式与热词最终识别参数。 FUNASR_CHUNK_SIZE=0,10,5 FUNASR_CHUNK_INTERVAL=10 FUNASR_ENCODER_LOOK_BACK=4 diff --git a/FUNASR_README.md b/FUNASR_README.md index fa8a247..ad52b09 100644 --- a/FUNASR_README.md +++ b/FUNASR_README.md @@ -2,7 +2,7 @@ 前端和后端分别启动。后端会托管以下服务: -- FunASR 原生在线 WebSocket 服务,使用本地流式 ASR 和 FSMN-VAD 模型。浏览器桥接层发送 `mode=online`、分块和回看参数,并在输入结束时发送 `is_speaking=false`,让引擎刷新最后的识别结果。 +- FunASR WebSocket 服务使用本地流式 ASR 和 FSMN-VAD。未填写热词时发送 `mode=online`;填写热词时发送 `mode=2pass`,流式模型负责实时预览,Contextual Paraformer 在每段语音结束后生成带热词偏置的最终结果。输入结束时发送 `is_speaking=false`,让引擎刷新剩余音频。 - CAM++ 辅助服务,为已完成的语音段分配稳定的说话人标签。 - 浏览器协议桥接服务,将腾讯演示页面原有的消息格式转换为 FunASR WebSocket 协议。VAD 切分由 FunASR 处理,桥接层不会再运行另一套 ASR/VAD 切分流程。 @@ -13,10 +13,11 @@ 将模型放入 `models/`,或在 `.env` 中设置 `MODEL_DIR`。默认模型目录如下: - `models/iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online` +- `models/iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404` - `models/damo/speech_fsmn_vad_zh-cn-16k-common-pytorch` - `models/iic/speech_campplus_sv_zh-cn_16k-common`,也可使用清单中配置的 `damo/` 模型目录 -若模型目录名称不同,可在 `.env` 中将 `FUNASR_ASR_MODEL` 和 `FUNASR_VAD_MODEL` 设为本地路径。CAM++ 默认按 `model_manifest.json` 查找;需要自定义目录时设置 `CAM_MODEL_PATH`。后端会在对外提供 WebSocket 服务前检查 ASR、VAD 和 CAM++ 三个必需模型。 +若模型目录名称不同,可在 `.env` 中将 `FUNASR_ASR_MODEL`、`FUNASR_HOTWORD_ASR_MODEL` 和 `FUNASR_VAD_MODEL` 设为本地路径。CAM++ 默认按 `model_manifest.json` 查找;需要自定义目录时设置 `CAM_MODEL_PATH`。后端会在开放 WebSocket 前检查流式 ASR、Contextual 热词 ASR、VAD 和 CAM++ 模型。 ## 安装与启动 @@ -37,6 +38,6 @@ python frontend\run_frontend.py 默认前端端口为 `8080`,浏览器后端端口为 `8082`,CAM++ HTTP 端口为 `8010`,仅本机可访问的 FunASR WebSocket 端口为 `10095`。需要调整时,在 `.env` 中同步修改 `FRONTEND_PORT`、`WEB_PORT`、`AUXILIARY_SERVICE_URL`、`FUNASR_NATIVE_WS_HOST` 和 `FUNASR_NATIVE_WS_PORT`。前端进程通过 `BACKEND_INTERNAL_URL` 访问后端;未设置时使用 `http://127.0.0.1:${WEB_PORT}`。 -`FUNASR_DEVICE` 和 `FUNASR_VAD_DEVICE` 分别控制 ASR 与 VAD 的运行设备。若 CAM++ 不与 ASR 共用 GPU,可将 `AUXILIARY_DEVICE` 设为 `cpu`。`FUNASR_CHUNK_SIZE` 和 `FUNASR_CHUNK_INTERVAL` 控制 FunASR 原生分块协议;默认值 `[0,10,5]` 和间隔 `10` 会按 600 毫秒一组发送当前音频块。 +`FUNASR_DEVICE` 控制流式 ASR 和 Contextual 最终解码模型的设备,默认使用 `cuda:0`;`FUNASR_VAD_DEVICE` 默认使用 CPU。启动器先加载 ASR,再启动 CAM++,优先为识别模型分配显存。若 CAM++ 不与 ASR 共用 GPU,可将 `AUXILIARY_DEVICE` 设为 `cpu`。热词以逗号、顿号或分号分隔,开始会话时传入;流式预览不应用热词,最终结果由 Contextual 模型解码。`FUNASR_CHUNK_SIZE` 和 `FUNASR_CHUNK_INTERVAL` 控制 FunASR 分块协议;默认值 `[0,10,5]` 和间隔 `10` 会按 600 毫秒一组发送当前音频块。 实时文件输入支持原始 PCM16 或 16 kHz 单声道 PCM WAV。每个完成的语音段都会计算说话人标签;语音过短或接近静音时,CAM++ 仍可能将说话人标为未知。 diff --git a/README.md b/README.md index 73f039b..bd71b15 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ if (-not (Test-Path .env)) { Copy-Item .env.example .env } python scripts/download_models.py --funasr-runtime ~~~ -`--funasr-runtime` 会下载流式 Paraformer、FSMN-VAD、CAM++ 和标点模型。若要下载 `model_manifest.json` 中列出的所有可选模型,请运行 `python scripts/download_models.py`。 +`--funasr-runtime` 会下载流式 Paraformer、Contextual 热词模型、FSMN-VAD、CAM++ 和标点模型。若要下载 `model_manifest.json` 中列出的全部模型资源,请运行 `python scripts/download_models.py`。 ## 启动 @@ -26,3 +26,5 @@ python frontend/run_frontend.py 后端负责启动 FunASR 原生 WebSocket 服务、CAM++ 服务和浏览器协议桥接服务。前端提供腾讯演示页面,并将 `/ws` 和 `/api/stop` 请求转发给后端。模型路径、设备、VAD 静音时长、说话人聚类参数和端口均可在 `.env` 中配置。 模型目录和运行细节见 [FunASR 使用说明](FUNASR_README.md)。 + +测试热词时,在前端左侧“识别配置”的热词输入框中输入多个词,用逗号、顿号或分号分隔,然后开始识别。服务保留 FunASR 流式首遍预览,并在每段语音结束后用 Contextual Paraformer 做 2-pass 最终解码;最终识别模型与流式模型默认优先使用 `cuda:0`。热词在当前会话启动时生效,识别过程中修改输入不会影响当前会话,需停止后重新开始。运行 `python scripts/download_models.py` 会同时下载流式模型、Contextual 热词模型和配置的辅助模型。 diff --git a/backend/realtime_websocket/funasr_native_wss.py b/backend/realtime_websocket/funasr_native_wss.py index 6837cd3..f9a1623 100644 --- a/backend/realtime_websocket/funasr_native_wss.py +++ b/backend/realtime_websocket/funasr_native_wss.py @@ -15,6 +15,7 @@ import ssl import os import wave import functools +import re from concurrent.futures import ThreadPoolExecutor from scipy.spatial.distance import cosine @@ -45,6 +46,18 @@ def _positive_env_int(name: str, default: int) -> int: return value +def normalize_contextual_hotwords(value): + """将会话热词拆成 Contextual Paraformer 接受的空格分隔文本。""" + if isinstance(value, (list, tuple)): + source = ",".join(str(item) for item in value if item is not None) + elif isinstance(value, str): + source = value + else: + return "" + terms = re.split(r"[,\uff0c\u3001;\uff1b\r\n]+", source) + return " ".join(dict.fromkeys(term.strip() for term in terms if term.strip())) + + # 显式设置该值后,将关闭 FunASR 按静音时长动态调整阈值的机制,便于测试。 VAD_MAX_END_SILENCE_MS = _positive_env_int("FUNASR_VAD_MAX_END_SILENCE_MS", 800) VAD_PARAGRAPH_MAX_END_SILENCE_MS = _positive_env_int( @@ -163,7 +176,8 @@ parser.add_argument( ) parser.add_argument("--concurrent_vad", type=int, default=4, help="Max concurrent VAD generate() calls.") parser.add_argument("--concurrent_asr_online", type=int, default=4, help="Max concurrent streaming ASR generate() calls.") -parser.add_argument("--concurrent_asr_offline", type=int, default=2, help="Max concurrent offline ASR generate() calls.") +# Contextual Paraformer 将本轮热词暂存在模型对象上,串行离线调用以隔离不同会话热词。 +parser.add_argument("--concurrent_asr_offline", type=int, default=1, help="Max concurrent offline ASR generate() calls.") parser.add_argument("--concurrent_punc", type=int, default=1, help="Max concurrent punctuation generate() calls.") parser.add_argument("--concurrent_sv", type=int, default=1, help="Max concurrent speaker verification generate() calls.") parser.add_argument( @@ -576,10 +590,15 @@ async def ws_serve(websocket, path=None): ] if "hotwords" in messagejson: - hotword_data = messagejson["hotwords"] - websocket.status_dict_asr["hotword"] = hotword_data - websocket.status_dict_asr_online["hotword"] = hotword_data - print(f"热词已更新: {hotword_data}") + hotword_data = normalize_contextual_hotwords(messagejson["hotwords"]) + if hotword_data and model_asr is None: + record_error("热词解码需要启用 FunASR Contextual Paraformer 最终识别模型") + if hotword_data: + # 普通 Paraformer 流式模型不具备上下文热词解码,热词只传给最终解码器。 + websocket.status_dict_asr["hotword"] = hotword_data + else: + websocket.status_dict_asr.pop("hotword", None) + print(f"会话热词已更新: {hotword_data}") if "mode" in messagejson: requested_mode = messagejson["mode"] diff --git a/backend/realtime_websocket/funasr_server.py b/backend/realtime_websocket/funasr_server.py index fd4852f..7e49ad5 100644 --- a/backend/realtime_websocket/funasr_server.py +++ b/backend/realtime_websocket/funasr_server.py @@ -7,6 +7,7 @@ import asyncio import json import logging import os +import re from dataclasses import dataclass from pathlib import Path from typing import Any @@ -50,6 +51,21 @@ AUXILIARY_KEY = web.AppKey("auxiliary_service", AuxiliaryModelService) SESSION_REGISTRY_KEY = web.AppKey("sessions", dict) +def normalize_hotwords(value: Any) -> str: + """去重并规范化浏览器输入,供 Contextual Paraformer 按空格解析词条。""" + if isinstance(value, list): + source = ",".join(str(item) for item in value if item is not None) + elif isinstance(value, str): + source = value + else: + return "" + + terms = re.split(r"[,\uff0c\u3001;\uff1b\r\n]+", source) + # 保持用户输入顺序,并去掉重复项,避免同一热词被重复传给解码端。 + unique_terms = list(dict.fromkeys(term.strip() for term in terms if term.strip())) + return " ".join(unique_terms) + + class IncrementalWavDecoder: """读取流式 PCM WAV 文件头,并逐段返回 16 kHz 单声道 PCM 负载。""" @@ -353,11 +369,19 @@ class BrowserSession: self.align_turn_audio_to_vad(message) continue text = str(message.get("text") or "") + native_mode = str(message.get("mode") or "") + # 2-pass 在线文本只是预览,离线 Contextual ASR 的文本才是热词定稿。 + offline_final = native_mode in {"2pass-offline", "offline"} if text: - # FunASR 在线模式会为每个分块发送新解码出的文本。 - self.turn_text += text + if offline_final: + self.turn_text = text + else: + # 在线模式逐块发送新增文本;2-pass 在线结果只用于临时预览。 + self.turn_text += text if message.get("is_final"): - final_text = self.turn_text.strip() + final_text = ( + text.strip() if offline_final else self.turn_text.strip() + ) audio = bytes(self.turn_audio) start_ms = self.turn_start_ms end_ms = start_ms + len(audio) / PCM_BYTES_PER_MS @@ -550,7 +574,7 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse: await session.emit({"type": "voice_id", "voice_id": voice_id}) await session.emit({"type": "start"}) - # 这是 FunASR 原生 WSS 消息格式;之后每 60 毫秒发送一帧 PCM。 + # FunASR 的 Contextual Paraformer 在 2-pass 离线定稿阶段应用会话热词。 async with websocket_connect( NATIVE_WS_URL, subprotocols=["binary"], @@ -558,26 +582,27 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse: close_timeout=3, max_size=None, ) as native_ws: - await native_ws.send( - json.dumps( - { - "mode": "online", - "chunk_size": list(CHUNK_SIZE), - "chunk_interval": CHUNK_INTERVAL, - "encoder_chunk_look_back": int( - os.getenv("FUNASR_ENCODER_LOOK_BACK", "4") - ), - "decoder_chunk_look_back": int( - os.getenv("FUNASR_DECODER_LOOK_BACK", "1") - ), - "sentence_strategy": session.sentence_strategy, - "audio_fs": SAMPLE_RATE, - "wav_name": voice_id, - "is_speaking": True, - }, - ensure_ascii=False, - ) - ) + native_start = { + "mode": "online", + "chunk_size": list(CHUNK_SIZE), + "chunk_interval": CHUNK_INTERVAL, + "encoder_chunk_look_back": int( + os.getenv("FUNASR_ENCODER_LOOK_BACK", "4") + ), + "decoder_chunk_look_back": int( + os.getenv("FUNASR_DECODER_LOOK_BACK", "1") + ), + "sentence_strategy": session.sentence_strategy, + "audio_fs": SAMPLE_RATE, + "wav_name": voice_id, + "is_speaking": True, + } + # Contextual 模型仅在 2-pass 最终解码阶段使用热词;普通在线预览仍走流式模型。 + hotwords = normalize_hotwords(start.get("hotwords")) + if hotwords: + native_start["mode"] = "2pass" + native_start["hotwords"] = hotwords + await native_ws.send(json.dumps(native_start, ensure_ascii=False)) native_reader = asyncio.create_task(session.read_native(native_ws)) speaker_worker = asyncio.create_task(session.resolve_speakers()) diff --git a/backend/run_backend.py b/backend/run_backend.py index 3eec1f8..3749c2d 100644 --- a/backend/run_backend.py +++ b/backend/run_backend.py @@ -147,6 +147,11 @@ def main() -> None: asr = local_model( os.getenv("FUNASR_ASR_MODEL", "paraformer-zh-streaming"), models_dir, "asr" ) + hotword_asr = local_model( + os.getenv("FUNASR_HOTWORD_ASR_MODEL", "paraformer-zh-contextual"), + models_dir, + "asr", + ) vad = local_model(os.getenv("FUNASR_VAD_MODEL", "fsmn-vad"), models_dir, "vad") cam = local_cam_model(models_dir) @@ -160,7 +165,11 @@ def main() -> None: ncpu = os.getenv("FUNASR_NCPU", str(os.cpu_count() or 4)) web_port = int(os.getenv("WEB_PORT", "8082")) - print(f"Local models: ASR={asr}; VAD={vad}; CAM++={cam}", flush=True) + print( + f"Local models: streaming ASR={asr}; hotword final ASR={hotword_asr}; " + f"VAD={vad}; CAM++={cam}", + flush=True, + ) print( f"Runtime: ASR device={device}; VAD device={vad_device}; native WS={native_url}", flush=True, @@ -178,6 +187,7 @@ def main() -> None: "AUXILIARY_PRELOAD_KINDS": ",".join(sorted(preload_kinds)), "MODEL_DIR": str(models_dir), "FUNASR_ASR_MODEL": str(asr), + "FUNASR_HOTWORD_ASR_MODEL": str(hotword_asr), "FUNASR_VAD_MODEL": str(vad), "FUNASR_DEVICE": device, "FUNASR_VAD_DEVICE": vad_device, @@ -191,13 +201,7 @@ def main() -> None: native = None websocket = None try: - auxiliary = subprocess.Popen( - [sys.executable, "-m", "backend.auxiliary_server"], - cwd=PROJECT_ROOT, - env=env, - ) - wait_for_health(f"{aux_url}/health", auxiliary, "speaker_embedding_ready") - + # 先加载 ASR 主模型,避免说话人辅助模型抢占显存导致识别模型无法启动。 native_args = [ sys.executable, str(PROJECT_ROOT / "backend" / "realtime_websocket" / "funasr_native_wss.py"), @@ -206,7 +210,9 @@ def main() -> None: "--port", str(native_port), "--asr_model", - "", + str(hotword_asr), + "--asr_model_revision", + os.getenv("FUNASR_HOTWORD_ASR_MODEL_REVISION", "v2.0.4"), "--asr_model_online", str(asr), "--vad_model", @@ -230,6 +236,13 @@ def main() -> None: probe_host = "127.0.0.1" if native_host in {"0.0.0.0", "::"} else native_host wait_for_tcp(probe_host, native_port, native) + auxiliary = subprocess.Popen( + [sys.executable, "-m", "backend.auxiliary_server"], + cwd=PROJECT_ROOT, + env=env, + ) + wait_for_health(f"{aux_url}/health", auxiliary, "speaker_embedding_ready") + websocket = subprocess.Popen( [sys.executable, "-m", "backend.run_funasr_demo", "--no-browser"], cwd=PROJECT_ROOT, @@ -241,8 +254,8 @@ def main() -> None: print(f"FunASR browser backend ready: ws://127.0.0.1:{web_port}/ws", flush=True) while True: for label, process in ( - ("CAM++", auxiliary), ("FunASR native WS", native), + ("CAM++", auxiliary), ("browser WS adapter", websocket), ): code = process.poll() diff --git a/frontend/static/app.js b/frontend/static/app.js index 2aacbee..e40d726 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -1,6 +1,7 @@ // ===== 页面元素 ===== const elEngineModel = document.getElementById('engineModel'); const elSpeakerDiarization = document.getElementById('speakerDiarization'); +const elHotwords = document.getElementById('hotwords'); const elDiarizationLabel = document.getElementById('diarizationLabel'); const elSentenceStrategy = document.getElementById('sentenceStrategy'); const elBtnStart = document.getElementById('btnStart'); @@ -89,6 +90,12 @@ const DEFAULT_SPEED = { const MAX_SPEED = 3.0; const UNSUPPORTED_STREAMING_EXTENSIONS = new Set(['m4a']); +// 统一前端热词分隔符并去重,后端再转换为 Contextual Paraformer 的输入格式。 +function normalizeHotwords(value) { + const terms = value.split(/[,,、;;\r\n]+/).map(term => term.trim()).filter(Boolean); + return [...new Set(terms)].join(','); +} + const SPEAKER_COLORS = ['#4a7dff', '#52c41a', '#faad14', '#ff4d4f', '#9254de', '#13c2c2']; let sentenceMap = {}; @@ -470,6 +477,7 @@ async function startRecognition() { elBtnStart.disabled = true; elBtnStop.disabled = false; + elHotwords.disabled = true; sending = true; const currentSession = ++sessionId; @@ -489,6 +497,7 @@ async function startRecognition() { engine_model_type: elEngineModel.value, speaker_diarization: useSpeaker ? 1 : 0, sentence_strategy: parseInt(elSentenceStrategy.value), + hotwords: normalizeHotwords(elHotwords.value), source: inputMode, voice_format: voiceFormat, file_name: fileName, @@ -620,6 +629,7 @@ function resetControls() { elBtnStart.disabled = false; } elBtnStop.disabled = true; + elHotwords.disabled = false; } // ===== 发送音频文件 ===== diff --git a/frontend/static/index.html b/frontend/static/index.html index c45ef19..d0e9413 100644 --- a/frontend/static/index.html +++ b/frontend/static/index.html @@ -4,7 +4,7 @@ 语音识别体验 Demo - +
@@ -39,6 +39,10 @@
+
+ + +
@@ -106,6 +110,6 @@ - + diff --git a/model_manifest.json b/model_manifest.json index 7e10850..7733e48 100644 --- a/model_manifest.json +++ b/model_manifest.json @@ -1,10 +1,11 @@ { "default_model": "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online", + "hotword_model": "iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404", "models": { "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online": { "alias": "paraformer-zh-streaming", "directory": "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online", - "description": "FunASR Paraformer streaming ASR used by the realtime backend", + "description": "FunASR 流式 Paraformer 语音识别模型,用于实时首遍识别并输出中间结果", "required_files": [ "configuration.json", "config.yaml" @@ -18,6 +19,22 @@ "*.safetensors" ], "min_total_size_bytes": 1000000 + }, + "iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404": { + "alias": "paraformer-zh-contextual", + "directory": "iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404", + "description": "FunASR Contextual Paraformer 热词模型,用于结合会话热词生成最终识别结果", + "revision": "v2.0.4", + "required_files": [ + "configuration.json", + "config.yaml" + ], + "any_files": [ + "*.pt", + "*.bin", + "*.safetensors" + ], + "min_total_size_bytes": 1000000 } }, "auxiliary_models": { @@ -25,7 +42,7 @@ "alias": "vad", "directory": "damo/speech_fsmn_vad_zh-cn-16k-common-pytorch", "kind": "vad", - "description": "FunASR FSMN VAD", + "description": "FSMN 语音活动检测模型,用于检测语音起止并切分语音片段", "revision": "v2.0.2", "required_files": [ "configuration.json", @@ -39,7 +56,7 @@ "alias": "ct-punc", "directory": "iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch", "kind": "punctuation", - "description": "Optional FunASR CT-Transformer punctuation restoration", + "description": "CT-Transformer 标点恢复模型,用于为识别文本补充标点符号(可选)", "required_files": [ "configuration.json", "config.yaml" @@ -55,7 +72,7 @@ "alias": "diarization", "directory": "iic/speech_campplus_speaker-diarization_common", "kind": "diarization", - "description": "CAM++ speaker diarization", + "description": "CAM++ 音视频说话人分离扩展包,包含音频检测及人脸关联所需组件", "required_files": [ "configuration.json", "config.yaml", @@ -70,7 +87,7 @@ "alias": "speaker", "directory": "iic/speech_campplus_sv_zh-cn_16k-common", "kind": "speaker_verification", - "description": "Configured CAM++ speaker verification", + "description": "CAM++ 声纹特征模型,用于提取说话人向量并进行会话内说话人匹配", "revision": "v2.0.2", "required_files": [ "configuration.json", @@ -83,7 +100,7 @@ "alias": "realtime-speaker", "directory": "iic/speech_eres2netv2_sv_zh-cn_16k-common", "kind": "realtime_speaker_verification", - "description": "Realtime speaker verification", + "description": "ERes2NetV2 实时声纹验证模型,用于提取和比对说话人特征", "required_files": [ "configuration.json" ], @@ -96,7 +113,7 @@ "alias": "speaker-damo", "directory": "damo/speech_campplus_sv_zh-cn_16k-common", "kind": "speaker_verification", - "description": "CAM++ speaker verification dependency", + "description": "CAM++ 声纹验证模型资源,供相关说话人识别配置使用", "required_files": [ "configuration.json", "config.yaml", @@ -108,7 +125,7 @@ "alias": "speaker-transformer", "directory": "damo/speech_campplus-transformer_scl_zh-cn_16k-common", "kind": "speaker_transformer", - "description": "CAM++ Transformer dependency", + "description": "CAM++ 说话人聚类的 Transformer/SCL 辅助权重,需与声纹模型配套使用", "required_files": [ "configuration.json", "campplus_cn_encoder.pt", diff --git a/scripts/download_models.py b/scripts/download_models.py index e5ee8a2..6d89800 100644 --- a/scripts/download_models.py +++ b/scripts/download_models.py @@ -184,7 +184,7 @@ def main() -> int: auxiliary_group.add_argument( "--funasr-runtime", action="store_true", - help="Download/check the configured ASR model, FSMN-VAD, CAM++, and punctuation", + help="下载或检查流式 ASR、Contextual 热词 ASR、FSMN-VAD、CAM++ 和标点模型", ) args = parser.parse_args() @@ -195,10 +195,17 @@ def main() -> int: models_dir = models_dir.resolve() cache_dir = args.cache_dir.resolve() if args.cache_dir else None selected_assets: list[tuple[str, dict[str, object]]] = [] + hotword_model_id = str(manifest.get("hotword_model") or "").strip() if args.funasr_runtime: # 标点模型在运行时可选,但下载后可获得完整的本地输出。 asr_id = resolve_model_id(args.model, manifest) - selected_assets.append((asr_id, manifest["models"][asr_id])) + model_ids = [asr_id] + if hotword_model_id: + model_ids.append(resolve_model_id(hotword_model_id, manifest)) + selected_assets.extend( + (model_id, manifest["models"][model_id]) + for model_id in dict.fromkeys(model_ids) + ) assets = auxiliary_models(manifest) vad_id = next( model_id for model_id, config in assets.items() @@ -220,7 +227,13 @@ def main() -> int: else: if not args.auxiliary_only: model_id = resolve_model_id(args.model, manifest) - selected_assets.append((model_id, manifest["models"][model_id])) + model_ids = [model_id] + if hotword_model_id: + model_ids.append(resolve_model_id(hotword_model_id, manifest)) + selected_assets.extend( + (selected_id, manifest["models"][selected_id]) + for selected_id in dict.fromkeys(model_ids) + ) if not args.skip_auxiliary: selected_assets.extend(auxiliary_models(manifest).items())