From ae06bf32fc720179c73d1e9f520f2f51423a8c7b Mon Sep 17 00:00:00 2001 From: Tanishq Date: Thu, 12 Nov 2026 18:05:17 +0530 Subject: [PATCH] fix(runtime): cut long hotwords to 10 characters and skip empty ones ContextualParaformer.proc_hotword in funasr_onnx and funasr_torch (SeacoParaformer inherits it) split the hotword string with split(" ") and padded each hotword into a row of 10 ids. A hotword longer than 10 characters did not fit, so the whole call raised "ValueError: could not broadcast input array from shape (12,) into shape (10,)". Repeated, leading or trailing spaces, and an empty hotword string, gave empty hotwords: rows of padding whose embedding was read at position -1 and passed to the model as extra hotwords. The exported embedding model takes exactly 10 ids per hotword (only num_hotwords is dynamic in ContextualEmbedderExport), and the C++ runtime (CompileHotwordEmbedding in paraformer.cpp and paraformer-torch.cpp) keeps the first max_hotword_len = 10 tokens of each hotword and skips empty ones. Do the same: split on whitespace, as generate_hotwords_list in funasr/models does, and keep the first 10 characters of each hotword. Extend tests/test_runtime_eval_native.py: for both classes in both packages, built from the hotword fixture graphs, check the hotword ids and embedding positions for a string with extra spaces and a 12-character hotword and for an empty string, and that both strings decode every file. --- .../libtorch/funasr_torch/paraformer_bin.py | 7 +++-- .../onnxruntime/funasr_onnx/paraformer_bin.py | 7 +++-- tests/test_runtime_eval_native.py | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/runtime/python/libtorch/funasr_torch/paraformer_bin.py b/runtime/python/libtorch/funasr_torch/paraformer_bin.py index dc9d2a596..836ffba8d 100644 --- a/runtime/python/libtorch/funasr_torch/paraformer_bin.py +++ b/runtime/python/libtorch/funasr_torch/paraformer_bin.py @@ -365,7 +365,10 @@ def __call__( return asr_res def proc_hotword(self, hotwords): - hotwords = hotwords.split(" ") + # Like the C++ runtime, skip empty hotwords and keep the first max_hotword_len + # characters of longer ones: the exported embedding model takes that many per hotword. + max_hotword_len = 10 + hotwords = [hotword[:max_hotword_len] for hotword in hotwords.split()] hotwords_length = [len(i) - 1 for i in hotwords] hotwords_length.append(0) hotwords_length = np.array(hotwords_length) @@ -385,7 +388,7 @@ def word_map(word): hotword_int = [word_map(i) for i in hotwords] hotword_int.append(np.array([1])) - hotwords = pad_list(hotword_int, pad_value=0, max_len=10) + hotwords = pad_list(hotword_int, pad_value=0, max_len=max_hotword_len) return torch.tensor(hotwords), hotwords_length def bb_infer( diff --git a/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py b/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py index 517252cd8..7a39c4667 100644 --- a/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py +++ b/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py @@ -407,7 +407,10 @@ def __call__( return asr_res def proc_hotword(self, hotwords): - hotwords = hotwords.split(" ") + # Like the C++ runtime, skip empty hotwords and keep the first max_hotword_len + # characters of longer ones: the exported embedding model takes that many per hotword. + max_hotword_len = 10 + hotwords = [hotword[:max_hotword_len] for hotword in hotwords.split()] hotwords_length = [len(i) - 1 for i in hotwords] hotwords_length.append(0) hotwords_length = np.array(hotwords_length) @@ -428,7 +431,7 @@ def word_map(word): hotword_int = [word_map(i) for i in hotwords] hotword_int.append(np.array([1])) - hotwords = pad_list(hotword_int, pad_value=0, max_len=10) + hotwords = pad_list(hotword_int, pad_value=0, max_len=max_hotword_len) return hotwords, hotwords_length diff --git a/tests/test_runtime_eval_native.py b/tests/test_runtime_eval_native.py index e0e615480..7bc1d33bd 100644 --- a/tests/test_runtime_eval_native.py +++ b/tests/test_runtime_eval_native.py @@ -310,3 +310,32 @@ def test_onnx_wrappers_read_non_wav_paths(native_fixture, tmp_path, monkeypatch, model = funasr_onnx.Paraformer(str(directory), device_id=DEVICE, intra_op_num_threads=1) assert model(str(audio)) == model(expected) assert {path.name: path.read_bytes() for path in tmp_path.iterdir()} == files + + +@pytest.mark.parametrize("backend", ["torch", "onnx"]) +@pytest.mark.parametrize("wrapper", ["ContextualParaformer", "SeacoParaformer"]) +def test_hotword_wrappers_skip_empty_and_cut_long_hotwords( + hotword_fixture, monkeypatch, backend, wrapper +): + directory, paths = hotword_fixture + location = "libtorch" if backend == "torch" else "onnxruntime" + monkeypatch.syspath_prepend(str(ROOT / "runtime/python" / location)) + module = importlib.import_module("funasr_" + backend + ".paraformer_bin") + model = getattr(module, wrapper)(str(directory), intra_op_num_threads=1) + # Like the C++ runtime: skip empty hotwords, and keep the first 10 characters of a longer + # one (the exported model_eb takes 10 per hotword). Ids: 1, 你 3, 好 4, 魔 5, 搭 6. + long_hotwords = " 魔搭 你好魔搭你好魔搭你好魔搭 " + matrix, positions = model.proc_hotword(long_hotwords) + assert matrix.tolist() == [ + [5, 6, 0, 0, 0, 0, 0, 0, 0, 0], + [3, 4, 5, 6, 3, 4, 5, 6, 3, 4], + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] + assert positions.tolist() == [1, 9, 0] + matrix, positions = model.proc_hotword("") + assert matrix.tolist() == [[1, 0, 0, 0, 0, 0, 0, 0, 0, 0]] + assert positions.tolist() == [0] + # Both decode every file; the 12-character hotword used to stop the call with a ValueError. + for hotwords in (long_hotwords, ""): + result = model(paths, hotwords) + assert [row["preds"][0] for row in result] == ["你" * 4, "你" * 11, "你" * 17]