Repository navigation
Description
runtime/python/websocket/funasr_wss_server.py passes the hotwords field of the client's config message to the offline model's generate(hotword=...) unchanged (funasr_wss_server.py:504-507):
if "hotwords" in messagejson:
hotword_data = messagejson["hotwords"]
websocket.status_dict_asr["hotword"] = hotword_data
websocket.status_dict_asr_online["hotword"] = hotword_dataThe websocket protocol (runtime/docs/websocket_protocol.md) sends hotwords as a JSON object string that maps each hotword to a weight, such as {"阿里巴巴":20,"通义实验室":30}. The clients in the repo send that format: the HTML5 client (getHotwords in runtime/html5/static/main.js), runtime/java/FunasrWsClient.java, the Android client, the C++ clients (funasr-wss-client.cpp, funasr-wss-client-2pass.cpp) and funasr_wss_client.py in microphone mode (json.dumps(fst_dict)).
The server's offline model is paraformer-zh (SeACo-Paraformer). Its generate_hotwords_list reads a hotword string as space-separated words (funasr/models/seaco_paraformer/model.py:688; Contextual Paraformer does the same at funasr/models/contextual_paraformer/model.py:653). So the JSON text becomes one or more hotwords that map to <unk>, and the client's hotwords never reach the model as words. #2471 and #2514 reported that client hotwords have no effect with this server; both were closed without a fix.
The C++ server parses the JSON object, passes its words joined with spaces to its NN hotword model, and uses a string that is not JSON as it is (runtime/websocket/bin/websocket-server.cpp:314-355, websocket-server-2pass.cpp:469-510).
Reproduction on main (0e52c3c), no model files. It loads the real server module with only AutoModel replaced (the server builds its models when it is imported) and sends one offline request per case: the config message, 0.1 s of audio and the end-of-input message. The hotword string that reaches the offline model then goes through SeACo-Paraformer's own generate_hotwords_list, with a toy vocabulary and a seg_dict that splits Chinese words into characters (the setup of tests/test_paraformer_hotword_file.py). Run it from the root of a FunASR checkout, with funasr installed from that checkout:
import asyncio
import contextlib
import importlib.util
import io
import json
import os
import sys
import tempfile
import types
import funasr
import torch
from funasr.models.seaco_paraformer.model import SeacoParaformer
# Load the real server; only AutoModel (called when the module is imported) is replaced.
funasr.AutoModel = lambda **kwargs: types.SimpleNamespace(**kwargs)
sys.argv = ["funasr_wss_server.py"]
spec = importlib.util.spec_from_file_location(
"funasr_wss_server", "runtime/python/websocket/funasr_wss_server.py"
)
server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(server)
received = []
server.model_asr = types.SimpleNamespace(
generate=lambda input, **kwargs: received.append(kwargs["hotword"]) or [{"text": ""}]
)
server.model_vad = types.SimpleNamespace(generate=lambda input, **kwargs: [{"value": []}])
server.model_sv = types.SimpleNamespace(
generate=lambda input, **kwargs: [{"spk_embedding": torch.zeros(1, 192)}]
)
class Client:
"""One offline request: the config message, 0.1 s of audio, end of input."""
def __init__(self, hotwords):
config = {"mode": "offline", "chunk_size": [5, 10, 5], "chunk_interval": 10,
"wav_name": "demo", "is_speaking": True, "hotwords": hotwords}
self.messages = iter([json.dumps(config, ensure_ascii=False), b"\x01\x00" * 1600,
json.dumps({"is_speaking": False, "is_end": True})])
self.path = None
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.messages)
except StopIteration:
raise StopAsyncIteration
async def send(self, message):
pass
async def close(self):
pass
# SeACo-Paraformer's own hotword parser, with a toy vocabulary and a character seg_dict.
tokens = ["<blank>", "<s>", "</s>", "<unk>"] + list("阿里巴通义实验室")
model_dir = tempfile.mkdtemp()
with open(os.path.join(model_dir, "seg_dict"), "w", encoding="utf-8") as f:
f.write("".join(f"{t} {t}\n" for t in tokens[4:]))
tokenizer = types.SimpleNamespace(tokens2ids=lambda ws: [tokens.index(w) if w in tokens else 3 for w in ws])
frontend = types.SimpleNamespace(cmvn_file=os.path.join(model_dir, "am.mvn"))
for hotwords in [
'{"阿里巴巴":20,"通义实验室":30}', # HTML5, Java, Android and C++ clients
json.dumps({"阿里巴巴": 20, "通义实验室": 30}, ensure_ascii=False), # funasr_wss_client.py
"阿里巴巴 通义实验室", # what AutoModel expects
]:
received.clear()
with contextlib.redirect_stdout(io.StringIO()): # the server prints every message
asyncio.run(server.ws_serve(Client(hotwords)))
ids = SeacoParaformer.generate_hotwords_list(
types.SimpleNamespace(sos=1), received[0], tokenizer=tokenizer, frontend=frontend
)
print(f"client sends {hotwords!r}")
print(f" model.generate(hotword={received[0]!r})")
print(f" hotword ids: {ids}")
server.EXECUTOR.shutdown()Output:
client sends '{"阿里巴巴":20,"通义实验室":30}'
model.generate(hotword='{"阿里巴巴":20,"通义实验室":30}')
hotword ids: [[3], [1]]
client sends '{"阿里巴巴": 20, "通义实验室": 30}'
model.generate(hotword='{"阿里巴巴": 20, "通义实验室": 30}')
hotword ids: [[3], [3], [3], [3], [1]]
client sends '阿里巴巴 通义实验室'
model.generate(hotword='阿里巴巴 通义实验室')
hotword ids: [[4, 5, 6, 6], [7, 8, 9, 10, 11], [1]]
<unk> is id 3, and <s>, which generate_hotwords_list appends, is id 1. The JSON text becomes one <unk> hotword, or four with the spacing of json.dumps.
Expected: the model gets '阿里巴巴 通义实验室' for the first two cases too.
Environment: Windows 11, Python 3.11.17, torch 2.10.0+cpu, numpy 2.4.6, scipy 1.17.1, websockets 17.4, sources from main.
Activity