Skip to content

funasr_wss_server.py: hotwords in the websocket protocol's JSON format reach the model as <unk> #3879

Description

@tanishqbaweja

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_data

The 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions