添加热词解析

main
Bifang 2026-09-28 14:33:50 +08:00
parent 71f8d572c0
commit f4747f3539
10 changed files with 164 additions and 57 deletions

View File

@ -6,6 +6,9 @@ MODEL_DIR=models
# FunASR 流式 ASR 与 VAD 模型。 # FunASR 流式 ASR 与 VAD 模型。
FUNASR_ASR_MODEL=paraformer-zh-streaming FUNASR_ASR_MODEL=paraformer-zh-streaming
FUNASR_VAD_MODEL=fsmn-vad 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_DEVICE=cuda:0
FUNASR_VAD_DEVICE=cpu FUNASR_VAD_DEVICE=cpu
# FunASR 原生 WebSocket 服务使用的 CPU 工作线程数。 # FunASR 原生 WebSocket 服务使用的 CPU 工作线程数。
@ -17,7 +20,7 @@ FUNASR_VAD_PARAGRAPH_MAX_END_SILENCE_MS=5000
# 提高此阈值会过滤更多环境噪声,也可能漏掉较轻的语音。 # 提高此阈值会过滤更多环境噪声,也可能漏掉较轻的语音。
FUNASR_VAD_SPEECH_NOISE_THRES=0.6 FUNASR_VAD_SPEECH_NOISE_THRES=0.6
# FunASR 原生 WebSocket 流式识别参数。 # FunASR Runtime WebSocket 流式与热词最终识别参数。
FUNASR_CHUNK_SIZE=0,10,5 FUNASR_CHUNK_SIZE=0,10,5
FUNASR_CHUNK_INTERVAL=10 FUNASR_CHUNK_INTERVAL=10
FUNASR_ENCODER_LOOK_BACK=4 FUNASR_ENCODER_LOOK_BACK=4

View File

@ -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++ 辅助服务,为已完成的语音段分配稳定的说话人标签。 - CAM++ 辅助服务,为已完成的语音段分配稳定的说话人标签。
- 浏览器协议桥接服务,将腾讯演示页面原有的消息格式转换为 FunASR WebSocket 协议。VAD 切分由 FunASR 处理,桥接层不会再运行另一套 ASR/VAD 切分流程。 - 浏览器协议桥接服务,将腾讯演示页面原有的消息格式转换为 FunASR WebSocket 协议。VAD 切分由 FunASR 处理,桥接层不会再运行另一套 ASR/VAD 切分流程。
@ -13,10 +13,11 @@
将模型放入 `models/`,或在 `.env` 中设置 `MODEL_DIR`。默认模型目录如下: 将模型放入 `models/`,或在 `.env` 中设置 `MODEL_DIR`。默认模型目录如下:
- `models/iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online` - `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/damo/speech_fsmn_vad_zh-cn-16k-common-pytorch`
- `models/iic/speech_campplus_sv_zh-cn_16k-common`,也可使用清单中配置的 `damo/` 模型目录 - `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}`。 默认前端端口为 `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++ 仍可能将说话人标为未知。 实时文件输入支持原始 PCM16 或 16 kHz 单声道 PCM WAV。每个完成的语音段都会计算说话人标签;语音过短或接近静音时,CAM++ 仍可能将说话人标为未知。

View File

@ -12,7 +12,7 @@ if (-not (Test-Path .env)) { Copy-Item .env.example .env }
python scripts/download_models.py --funasr-runtime 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 原生 WebSocket 服务、CAM++ 服务和浏览器协议桥接服务。前端提供腾讯演示页面,并将 `/ws` 和 `/api/stop` 请求转发给后端。模型路径、设备、VAD 静音时长、说话人聚类参数和端口均可在 `.env` 中配置。
模型目录和运行细节见 [FunASR 使用说明](FUNASR_README.md)。 模型目录和运行细节见 [FunASR 使用说明](FUNASR_README.md)。
测试热词时,在前端左侧“识别配置”的热词输入框中输入多个词,用逗号、顿号或分号分隔,然后开始识别。服务保留 FunASR 流式首遍预览,并在每段语音结束后用 Contextual Paraformer 做 2-pass 最终解码;最终识别模型与流式模型默认优先使用 `cuda:0`。热词在当前会话启动时生效,识别过程中修改输入不会影响当前会话,需停止后重新开始。运行 `python scripts/download_models.py` 会同时下载流式模型、Contextual 热词模型和配置的辅助模型。

View File

@ -15,6 +15,7 @@ import ssl
import os import os
import wave import wave
import functools import functools
import re
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from scipy.spatial.distance import cosine from scipy.spatial.distance import cosine
@ -45,6 +46,18 @@ def _positive_env_int(name: str, default: int) -> int:
return value 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 按静音时长动态调整阈值的机制,便于测试。 # 显式设置该值后,将关闭 FunASR 按静音时长动态调整阈值的机制,便于测试。
VAD_MAX_END_SILENCE_MS = _positive_env_int("FUNASR_VAD_MAX_END_SILENCE_MS", 800) VAD_MAX_END_SILENCE_MS = _positive_env_int("FUNASR_VAD_MAX_END_SILENCE_MS", 800)
VAD_PARAGRAPH_MAX_END_SILENCE_MS = _positive_env_int( 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_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_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_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("--concurrent_sv", type=int, default=1, help="Max concurrent speaker verification generate() calls.")
parser.add_argument( parser.add_argument(
@ -576,10 +590,15 @@ async def ws_serve(websocket, path=None):
] ]
if "hotwords" in messagejson: if "hotwords" in messagejson:
hotword_data = messagejson["hotwords"] hotword_data = normalize_contextual_hotwords(messagejson["hotwords"])
websocket.status_dict_asr["hotword"] = hotword_data if hotword_data and model_asr is None:
websocket.status_dict_asr_online["hotword"] = hotword_data record_error("热词解码需要启用 FunASR Contextual Paraformer 最终识别模型")
print(f"热词已更新: {hotword_data}") 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: if "mode" in messagejson:
requested_mode = messagejson["mode"] requested_mode = messagejson["mode"]

View File

@ -7,6 +7,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -50,6 +51,21 @@ AUXILIARY_KEY = web.AppKey("auxiliary_service", AuxiliaryModelService)
SESSION_REGISTRY_KEY = web.AppKey("sessions", dict) 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: class IncrementalWavDecoder:
"""读取流式 PCM WAV 文件头,并逐段返回 16 kHz 单声道 PCM 负载。""" """读取流式 PCM WAV 文件头,并逐段返回 16 kHz 单声道 PCM 负载。"""
@ -353,11 +369,19 @@ class BrowserSession:
self.align_turn_audio_to_vad(message) self.align_turn_audio_to_vad(message)
continue continue
text = str(message.get("text") or "") 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: if text:
# FunASR 在线模式会为每个分块发送新解码出的文本。 if offline_final:
self.turn_text += text self.turn_text = text
else:
# 在线模式逐块发送新增文本;2-pass 在线结果只用于临时预览。
self.turn_text += text
if message.get("is_final"): 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) audio = bytes(self.turn_audio)
start_ms = self.turn_start_ms start_ms = self.turn_start_ms
end_ms = start_ms + len(audio) / PCM_BYTES_PER_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": "voice_id", "voice_id": voice_id})
await session.emit({"type": "start"}) await session.emit({"type": "start"})
# 这是 FunASR 原生 WSS 消息格式;之后每 60 毫秒发送一帧 PCM。 # FunASR 的 Contextual Paraformer 在 2-pass 离线定稿阶段应用会话热词。
async with websocket_connect( async with websocket_connect(
NATIVE_WS_URL, NATIVE_WS_URL,
subprotocols=["binary"], subprotocols=["binary"],
@ -558,26 +582,27 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
close_timeout=3, close_timeout=3,
max_size=None, max_size=None,
) as native_ws: ) as native_ws:
await native_ws.send( native_start = {
json.dumps( "mode": "online",
{ "chunk_size": list(CHUNK_SIZE),
"mode": "online", "chunk_interval": CHUNK_INTERVAL,
"chunk_size": list(CHUNK_SIZE), "encoder_chunk_look_back": int(
"chunk_interval": CHUNK_INTERVAL, os.getenv("FUNASR_ENCODER_LOOK_BACK", "4")
"encoder_chunk_look_back": int( ),
os.getenv("FUNASR_ENCODER_LOOK_BACK", "4") "decoder_chunk_look_back": int(
), os.getenv("FUNASR_DECODER_LOOK_BACK", "1")
"decoder_chunk_look_back": int( ),
os.getenv("FUNASR_DECODER_LOOK_BACK", "1") "sentence_strategy": session.sentence_strategy,
), "audio_fs": SAMPLE_RATE,
"sentence_strategy": session.sentence_strategy, "wav_name": voice_id,
"audio_fs": SAMPLE_RATE, "is_speaking": True,
"wav_name": voice_id, }
"is_speaking": True, # Contextual 模型仅在 2-pass 最终解码阶段使用热词;普通在线预览仍走流式模型。
}, hotwords = normalize_hotwords(start.get("hotwords"))
ensure_ascii=False, 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)) native_reader = asyncio.create_task(session.read_native(native_ws))
speaker_worker = asyncio.create_task(session.resolve_speakers()) speaker_worker = asyncio.create_task(session.resolve_speakers())

View File

@ -147,6 +147,11 @@ def main() -> None:
asr = local_model( asr = local_model(
os.getenv("FUNASR_ASR_MODEL", "paraformer-zh-streaming"), models_dir, "asr" 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") vad = local_model(os.getenv("FUNASR_VAD_MODEL", "fsmn-vad"), models_dir, "vad")
cam = local_cam_model(models_dir) cam = local_cam_model(models_dir)
@ -160,7 +165,11 @@ def main() -> None:
ncpu = os.getenv("FUNASR_NCPU", str(os.cpu_count() or 4)) ncpu = os.getenv("FUNASR_NCPU", str(os.cpu_count() or 4))
web_port = int(os.getenv("WEB_PORT", "8082")) 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( print(
f"Runtime: ASR device={device}; VAD device={vad_device}; native WS={native_url}", f"Runtime: ASR device={device}; VAD device={vad_device}; native WS={native_url}",
flush=True, flush=True,
@ -178,6 +187,7 @@ def main() -> None:
"AUXILIARY_PRELOAD_KINDS": ",".join(sorted(preload_kinds)), "AUXILIARY_PRELOAD_KINDS": ",".join(sorted(preload_kinds)),
"MODEL_DIR": str(models_dir), "MODEL_DIR": str(models_dir),
"FUNASR_ASR_MODEL": str(asr), "FUNASR_ASR_MODEL": str(asr),
"FUNASR_HOTWORD_ASR_MODEL": str(hotword_asr),
"FUNASR_VAD_MODEL": str(vad), "FUNASR_VAD_MODEL": str(vad),
"FUNASR_DEVICE": device, "FUNASR_DEVICE": device,
"FUNASR_VAD_DEVICE": vad_device, "FUNASR_VAD_DEVICE": vad_device,
@ -191,13 +201,7 @@ def main() -> None:
native = None native = None
websocket = None websocket = None
try: try:
auxiliary = subprocess.Popen( # 先加载 ASR 主模型,避免说话人辅助模型抢占显存导致识别模型无法启动。
[sys.executable, "-m", "backend.auxiliary_server"],
cwd=PROJECT_ROOT,
env=env,
)
wait_for_health(f"{aux_url}/health", auxiliary, "speaker_embedding_ready")
native_args = [ native_args = [
sys.executable, sys.executable,
str(PROJECT_ROOT / "backend" / "realtime_websocket" / "funasr_native_wss.py"), str(PROJECT_ROOT / "backend" / "realtime_websocket" / "funasr_native_wss.py"),
@ -206,7 +210,9 @@ def main() -> None:
"--port", "--port",
str(native_port), str(native_port),
"--asr_model", "--asr_model",
"", str(hotword_asr),
"--asr_model_revision",
os.getenv("FUNASR_HOTWORD_ASR_MODEL_REVISION", "v2.0.4"),
"--asr_model_online", "--asr_model_online",
str(asr), str(asr),
"--vad_model", "--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 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) 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( websocket = subprocess.Popen(
[sys.executable, "-m", "backend.run_funasr_demo", "--no-browser"], [sys.executable, "-m", "backend.run_funasr_demo", "--no-browser"],
cwd=PROJECT_ROOT, 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) print(f"FunASR browser backend ready: ws://127.0.0.1:{web_port}/ws", flush=True)
while True: while True:
for label, process in ( for label, process in (
("CAM++", auxiliary),
("FunASR native WS", native), ("FunASR native WS", native),
("CAM++", auxiliary),
("browser WS adapter", websocket), ("browser WS adapter", websocket),
): ):
code = process.poll() code = process.poll()

View File

@ -1,6 +1,7 @@
// ===== 页面元素 ===== // ===== 页面元素 =====
const elEngineModel = document.getElementById('engineModel'); const elEngineModel = document.getElementById('engineModel');
const elSpeakerDiarization = document.getElementById('speakerDiarization'); const elSpeakerDiarization = document.getElementById('speakerDiarization');
const elHotwords = document.getElementById('hotwords');
const elDiarizationLabel = document.getElementById('diarizationLabel'); 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');
@ -89,6 +90,12 @@ const DEFAULT_SPEED = {
const MAX_SPEED = 3.0; const MAX_SPEED = 3.0;
const UNSUPPORTED_STREAMING_EXTENSIONS = new Set(['m4a']); 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']; const SPEAKER_COLORS = ['#4a7dff', '#52c41a', '#faad14', '#ff4d4f', '#9254de', '#13c2c2'];
let sentenceMap = {}; let sentenceMap = {};
@ -470,6 +477,7 @@ async function startRecognition() {
elBtnStart.disabled = true; elBtnStart.disabled = true;
elBtnStop.disabled = false; elBtnStop.disabled = false;
elHotwords.disabled = true;
sending = true; sending = true;
const currentSession = ++sessionId; const currentSession = ++sessionId;
@ -489,6 +497,7 @@ async function startRecognition() {
engine_model_type: elEngineModel.value, engine_model_type: elEngineModel.value,
speaker_diarization: useSpeaker ? 1 : 0, speaker_diarization: useSpeaker ? 1 : 0,
sentence_strategy: parseInt(elSentenceStrategy.value), sentence_strategy: parseInt(elSentenceStrategy.value),
hotwords: normalizeHotwords(elHotwords.value),
source: inputMode, source: inputMode,
voice_format: voiceFormat, voice_format: voiceFormat,
file_name: fileName, file_name: fileName,
@ -620,6 +629,7 @@ function resetControls() {
elBtnStart.disabled = false; elBtnStart.disabled = false;
} }
elBtnStop.disabled = true; elBtnStop.disabled = true;
elHotwords.disabled = false;
} }
// ===== 发送音频文件 ===== // ===== 发送音频文件 =====

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>语音识别体验 Demo</title> <title>语音识别体验 Demo</title>
<link rel="stylesheet" href="style.css?v=210"> <link rel="stylesheet" href="style.css?v=212">
</head> </head>
<body> <body>
<div class="layout"> <div class="layout">
@ -39,6 +39,10 @@
</label> </label>
</div> </div>
</div> </div>
<div class="form-group">
<label for="hotwords">热词 <span class="hint">逗号分隔,用于最终结果修正</span></label>
<input type="text" id="hotwords" placeholder="例如:FunASR,实时语音,说话人分离" autocomplete="off">
</div>
</div> </div>
<div class="section-divider"></div> <div class="section-divider"></div>
@ -106,6 +110,6 @@
</main> </main>
</div> </div>
<script src="app.js?v=210"></script> <script src="app.js?v=212"></script>
</body> </body>
</html> </html>

View File

@ -1,10 +1,11 @@
{ {
"default_model": "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online", "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": { "models": {
"iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online": { "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online": {
"alias": "paraformer-zh-streaming", "alias": "paraformer-zh-streaming",
"directory": "iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online", "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": [ "required_files": [
"configuration.json", "configuration.json",
"config.yaml" "config.yaml"
@ -18,6 +19,22 @@
"*.safetensors" "*.safetensors"
], ],
"min_total_size_bytes": 1000000 "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": { "auxiliary_models": {
@ -25,7 +42,7 @@
"alias": "vad", "alias": "vad",
"directory": "damo/speech_fsmn_vad_zh-cn-16k-common-pytorch", "directory": "damo/speech_fsmn_vad_zh-cn-16k-common-pytorch",
"kind": "vad", "kind": "vad",
"description": "FunASR FSMN VAD", "description": "FSMN 语音活动检测模型,用于检测语音起止并切分语音片段",
"revision": "v2.0.2", "revision": "v2.0.2",
"required_files": [ "required_files": [
"configuration.json", "configuration.json",
@ -39,7 +56,7 @@
"alias": "ct-punc", "alias": "ct-punc",
"directory": "iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch", "directory": "iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
"kind": "punctuation", "kind": "punctuation",
"description": "Optional FunASR CT-Transformer punctuation restoration", "description": "CT-Transformer 标点恢复模型,用于为识别文本补充标点符号(可选)",
"required_files": [ "required_files": [
"configuration.json", "configuration.json",
"config.yaml" "config.yaml"
@ -55,7 +72,7 @@
"alias": "diarization", "alias": "diarization",
"directory": "iic/speech_campplus_speaker-diarization_common", "directory": "iic/speech_campplus_speaker-diarization_common",
"kind": "diarization", "kind": "diarization",
"description": "CAM++ speaker diarization", "description": "CAM++ 音视频说话人分离扩展包,包含音频检测及人脸关联所需组件",
"required_files": [ "required_files": [
"configuration.json", "configuration.json",
"config.yaml", "config.yaml",
@ -70,7 +87,7 @@
"alias": "speaker", "alias": "speaker",
"directory": "iic/speech_campplus_sv_zh-cn_16k-common", "directory": "iic/speech_campplus_sv_zh-cn_16k-common",
"kind": "speaker_verification", "kind": "speaker_verification",
"description": "Configured CAM++ speaker verification", "description": "CAM++ 声纹特征模型,用于提取说话人向量并进行会话内说话人匹配",
"revision": "v2.0.2", "revision": "v2.0.2",
"required_files": [ "required_files": [
"configuration.json", "configuration.json",
@ -83,7 +100,7 @@
"alias": "realtime-speaker", "alias": "realtime-speaker",
"directory": "iic/speech_eres2netv2_sv_zh-cn_16k-common", "directory": "iic/speech_eres2netv2_sv_zh-cn_16k-common",
"kind": "realtime_speaker_verification", "kind": "realtime_speaker_verification",
"description": "Realtime speaker verification", "description": "ERes2NetV2 实时声纹验证模型,用于提取和比对说话人特征",
"required_files": [ "required_files": [
"configuration.json" "configuration.json"
], ],
@ -96,7 +113,7 @@
"alias": "speaker-damo", "alias": "speaker-damo",
"directory": "damo/speech_campplus_sv_zh-cn_16k-common", "directory": "damo/speech_campplus_sv_zh-cn_16k-common",
"kind": "speaker_verification", "kind": "speaker_verification",
"description": "CAM++ speaker verification dependency", "description": "CAM++ 声纹验证模型资源,供相关说话人识别配置使用",
"required_files": [ "required_files": [
"configuration.json", "configuration.json",
"config.yaml", "config.yaml",
@ -108,7 +125,7 @@
"alias": "speaker-transformer", "alias": "speaker-transformer",
"directory": "damo/speech_campplus-transformer_scl_zh-cn_16k-common", "directory": "damo/speech_campplus-transformer_scl_zh-cn_16k-common",
"kind": "speaker_transformer", "kind": "speaker_transformer",
"description": "CAM++ Transformer dependency", "description": "CAM++ 说话人聚类的 Transformer/SCL 辅助权重,需与声纹模型配套使用",
"required_files": [ "required_files": [
"configuration.json", "configuration.json",
"campplus_cn_encoder.pt", "campplus_cn_encoder.pt",

View File

@ -184,7 +184,7 @@ def main() -> int:
auxiliary_group.add_argument( auxiliary_group.add_argument(
"--funasr-runtime", "--funasr-runtime",
action="store_true", 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() args = parser.parse_args()
@ -195,10 +195,17 @@ def main() -> int:
models_dir = models_dir.resolve() models_dir = models_dir.resolve()
cache_dir = args.cache_dir.resolve() if args.cache_dir else None cache_dir = args.cache_dir.resolve() if args.cache_dir else None
selected_assets: list[tuple[str, dict[str, object]]] = [] selected_assets: list[tuple[str, dict[str, object]]] = []
hotword_model_id = str(manifest.get("hotword_model") or "").strip()
if args.funasr_runtime: if args.funasr_runtime:
# 标点模型在运行时可选,但下载后可获得完整的本地输出。 # 标点模型在运行时可选,但下载后可获得完整的本地输出。
asr_id = resolve_model_id(args.model, manifest) 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) assets = auxiliary_models(manifest)
vad_id = next( vad_id = next(
model_id for model_id, config in assets.items() model_id for model_id, config in assets.items()
@ -220,7 +227,13 @@ def main() -> int:
else: else:
if not args.auxiliary_only: if not args.auxiliary_only:
model_id = resolve_model_id(args.model, manifest) 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: if not args.skip_auxiliary:
selected_assets.extend(auxiliary_models(manifest).items()) selected_assets.extend(auxiliary_models(manifest).items())