Skip to content

Commit 5133663

Browse files
authored
feat: add realtime postprocess hotwords (#3317)
Co-authored-by: LauraGPT <lauragpt@users.noreply.github.com>
1 parent 622df23 commit 5133663

4 files changed

Lines changed: 225 additions & 0 deletions

File tree

examples/industrial_data_pretraining/fun_asr_nano/docs/realtime_demo.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ render(committed + preview);
5959

6060
`HOTWORDS:Tool,客製化,季會``--hotword-file` 是模型解码阶段的热词偏置,不是确定性文本替换。热词过多、太短、中英文混杂,或音频静音/低音量/长句不停顿时,模型可能把相近声音偏向热词。排查时建议先完全关闭热词跑同一段音频;如果需求是把固定误识别改成正确专名,优先在识别完成后做后处理,而不是把大量词放进模型 hotwords。
6161

62+
实时服务也支持最终文本级后处理,适合“固定错词 -> 正确专名”的确定性纠正,不会参与模型解码,也不会在静音时把声音偏成热词。启动时可以传文件:
63+
64+
```bash
65+
cat > postprocess_hotwords.txt <<'EOF'
66+
哈囉=>客製化
67+
哈罗=>客製化
68+
EOF
69+
70+
funasr-realtime-server --postprocess-hotword-file postprocess_hotwords.txt
71+
```
72+
73+
也可以在同一条 WebSocket 连接中发送:
74+
75+
```text
76+
POSTPROCESS_HOTWORDS:哈囉=>客製化,哈罗=>客製化
77+
```
78+
79+
如果你确实希望模型在解码阶段更偏向某些专名,再使用 `HOTWORDS:` / `--hotword-file`;否则优先使用 `POSTPROCESS_HOTWORDS:` / `--postprocess-hotword-file`
80+
6281
## 客户端
6382

6483
```bash

examples/industrial_data_pretraining/fun_asr_nano/serve_realtime_ws.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
#!/usr/bin/env python3
22
"""Compatibility entry point for the packaged realtime WebSocket server."""
33

4+
import sys
5+
from pathlib import Path
6+
7+
8+
repo_root = Path(__file__).resolve().parents[3]
9+
if (repo_root / "funasr").is_dir() and str(repo_root) not in sys.path:
10+
sys.path.insert(0, str(repo_root))
11+
412
from funasr.bin.realtime_ws import cli_main
513

614

funasr/bin/realtime_ws.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
import regex
2424
import websockets
2525

26+
from funasr.utils.postprocess_hotwords import (
27+
apply_postprocess_hotwords_to_results,
28+
parse_postprocess_hotwords,
29+
)
30+
2631
warnings.filterwarnings('ignore')
2732
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
2833
logger = logging.getLogger(__name__)
@@ -73,6 +78,36 @@ def _clean_asr_text(text):
7378
return text.strip()
7479

7580

81+
def _postprocess_result_text(text, asr_kwargs):
82+
if not text:
83+
return text
84+
cfg = {
85+
key: asr_kwargs[key]
86+
for key in (
87+
"postprocess_hotwords",
88+
"postprocess_hotword_file",
89+
"postprocess_hotword_threshold",
90+
"postprocess_hotword_fuzzy",
91+
"return_postprocess_hotword_matches",
92+
)
93+
if key in asr_kwargs
94+
}
95+
if not cfg:
96+
return text
97+
results = apply_postprocess_hotwords_to_results([{"text": text}], cfg)
98+
return results[0].get("text", text)
99+
100+
101+
def _parse_postprocess_hotwords_command(payload):
102+
explicit, fuzzy_targets = parse_postprocess_hotwords(
103+
payload.replace(",", "\n")
104+
)
105+
if fuzzy_targets:
106+
for target in fuzzy_targets:
107+
explicit[target] = target
108+
return explicit
109+
110+
76111
from funasr.models.fsmn_vad_streaming.dynamic_vad import DynamicStreamingVAD
77112

78113

@@ -587,6 +622,7 @@ def _decode_segment(self, seg):
587622
)
588623
text = results[0]["text"] if results else ""
589624
text = _clean_asr_text(text)
625+
text = _postprocess_result_text(text, self.asr_kwargs)
590626
self.prev_seg_text = text
591627
return text
592628
except Exception as e:
@@ -675,6 +711,13 @@ def load_models(args):
675711
_asr_kwargs["language"] = args.language
676712
logger.info(f"Language: {args.language}")
677713

714+
postprocess_file = getattr(args, "postprocess_hotword_file", "")
715+
if postprocess_file:
716+
_asr_kwargs["postprocess_hotword_file"] = postprocess_file
717+
_asr_kwargs["postprocess_hotword_fuzzy"] = False
718+
_asr_kwargs["return_postprocess_hotword_matches"] = True
719+
logger.info(f"Loaded postprocess hotwords from '{postprocess_file}'")
720+
678721
if getattr(args, "endpoint_mode", "server") == "server":
679722
logger.info("Loading VAD: fsmn-vad (streaming)")
680723
_vad_model = AutoModel(
@@ -770,6 +813,18 @@ async def handle_client(websocket, args):
770813
session.asr_kwargs["hotwords"] = hotwords
771814
await websocket.send(json.dumps({"event": "hotwords_set", "hotwords": hotwords}))
772815
logger.info(f"Hotwords set: {len(hotwords)} words")
816+
elif cmd.upper().startswith("POSTPROCESS_HOTWORDS:"):
817+
payload = cmd.split(":", 1)[1]
818+
hotwords = _parse_postprocess_hotwords_command(payload)
819+
session.asr_kwargs = dict(session.asr_kwargs)
820+
session.asr_kwargs["postprocess_hotwords"] = hotwords
821+
session.asr_kwargs["postprocess_hotword_fuzzy"] = False
822+
session.asr_kwargs["return_postprocess_hotword_matches"] = True
823+
await websocket.send(json.dumps({
824+
"event": "postprocess_hotwords_set",
825+
"postprocess_hotwords": hotwords,
826+
}))
827+
logger.info(f"Postprocess hotwords set: {len(hotwords)} pairs")
773828
elif cmd.upper().startswith("LANGUAGE:"):
774829
lang = cmd[9:].strip()
775830
session.asr_kwargs = dict(session.asr_kwargs)
@@ -904,6 +959,17 @@ def build_arg_parser():
904959
parser.add_argument("--enable-spk", action="store_true", help="Enable streaming speaker diarization.")
905960
parser.add_argument("--spk-model", type=str, default="iic/speech_eres2netv2_sv_zh-cn_16k-common")
906961
parser.add_argument("--hotword-file", type=str, default="热词列表")
962+
parser.add_argument(
963+
"--postprocess-hotword-file",
964+
type=str,
965+
default="",
966+
help=(
967+
"Deterministic text-level hotword corrections applied to final "
968+
"sentences after decoding. Lines should be 'wrong=>right' pairs. "
969+
"Unlike --hotword-file, this does not bias "
970+
"model decoding or hallucinate words during silence."
971+
),
972+
)
907973
parser.add_argument("--language", type=str, default=None, help="Language hint (e.g. 中文, English, 日本語)")
908974
parser.add_argument(
909975
"--dtype",

tests/test_realtime_ws_service.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,11 @@ class DummyEngine:
250250
def __init__(self):
251251
self._engine = types.SimpleNamespace(tokenizer=DummyTokenizer())
252252
self.input_lengths = []
253+
self.generate_kwargs = []
253254

254255
def generate(self, inputs, **kwargs):
255256
self.input_lengths.extend(len(audio) for audio in inputs)
257+
self.generate_kwargs.append(kwargs)
256258
return [{"text": "hello"}]
257259

258260

@@ -318,6 +320,21 @@ def test_client_endpoint_mode_emits_partial_at_first_decode_threshold():
318320
assert session.vllm_engine.input_lengths == [int(0.48 * session.sample_rate)]
319321

320322

323+
def test_postprocess_hotwords_correct_final_text_without_model_hotword_bias():
324+
module = load_service_module()
325+
session = make_client_endpoint_session(module)
326+
session.asr_kwargs = {
327+
"postprocess_hotwords": {"hello": "Tool"},
328+
"return_postprocess_hotword_matches": True,
329+
}
330+
331+
session.add_audio(np.zeros(int(0.4 * session.sample_rate), dtype=np.int16).tobytes())
332+
result = session.commit()
333+
334+
assert result["sentences"] == [{"text": "Tool", "start": 0, "end": 400}]
335+
assert session.vllm_engine.generate_kwargs[-1].get("hotwords") is None
336+
337+
321338
def test_client_commits_short_utterances_once_with_monotonic_timestamps():
322339
module = load_service_module()
323340
session = make_client_endpoint_session(module)
@@ -447,6 +464,121 @@ async def send(self, message):
447464
assert received_audio == [b"first", b"second"]
448465

449466

467+
def test_handler_accepts_postprocess_hotwords_without_model_hotword_bias(monkeypatch):
468+
module = load_service_module()
469+
session_kwargs = []
470+
471+
class ProtocolSession:
472+
def __init__(self, vllm_engine, asr_kwargs, *args, **kwargs):
473+
self.is_active = False
474+
self.asr_kwargs = asr_kwargs
475+
session_kwargs.append(dict(asr_kwargs))
476+
477+
def reset(self):
478+
pass
479+
480+
def commit(self):
481+
return {"is_final": True, "asr_kwargs": dict(self.asr_kwargs)}
482+
483+
class FakeWebSocket:
484+
remote_address = ("127.0.0.1", 12345)
485+
486+
def __init__(self):
487+
self.messages = iter(
488+
[
489+
"START",
490+
"POSTPROCESS_HOTWORDS:hello=>Tool,哈囉=>客製化",
491+
"COMMIT",
492+
]
493+
)
494+
self.sent = []
495+
496+
def __aiter__(self):
497+
return self
498+
499+
async def __anext__(self):
500+
try:
501+
return next(self.messages)
502+
except StopIteration as error:
503+
raise StopAsyncIteration from error
504+
505+
async def send(self, message):
506+
self.sent.append(json.loads(message))
507+
508+
monkeypatch.setattr(module, "load_models", lambda args: (object(), {}, None, None))
509+
monkeypatch.setattr(module, "ClientEndpointVAD", lambda: object(), raising=False)
510+
monkeypatch.setattr(module, "create_speaker_tracker", lambda model, args: None)
511+
monkeypatch.setattr(module, "RealtimeASRSession", ProtocolSession)
512+
websocket = FakeWebSocket()
513+
args = types.SimpleNamespace(
514+
decode_interval=0.48,
515+
partial_window_sec=15.0,
516+
endpoint_mode="client",
517+
log_session_stats_interval=0.0,
518+
)
519+
520+
asyncio.run(module.handle_client(websocket, args))
521+
522+
assert session_kwargs == [{}]
523+
assert websocket.sent[1] == {
524+
"event": "postprocess_hotwords_set",
525+
"postprocess_hotwords": {"hello": "Tool", "哈囉": "客製化"},
526+
}
527+
final = [message for message in websocket.sent if message.get("is_final")][0]
528+
assert final["asr_kwargs"] == {
529+
"postprocess_hotwords": {"hello": "Tool", "哈囉": "客製化"},
530+
"postprocess_hotword_fuzzy": False,
531+
"return_postprocess_hotword_matches": True,
532+
}
533+
assert "hotwords" not in final["asr_kwargs"]
534+
535+
536+
def test_load_models_reads_postprocess_hotword_file(monkeypatch, tmp_path):
537+
module = load_service_module()
538+
module._vllm_engine = None
539+
module._asr_kwargs = None
540+
module._vad_model = None
541+
module._spk_model = None
542+
hotword_file = tmp_path / "postprocess_hotwords.txt"
543+
hotword_file.write_text("hello=>Tool\n哈囉=>客製化\n", encoding="utf-8")
544+
auto_model_calls = []
545+
546+
import funasr
547+
548+
monkeypatch.setattr(
549+
funasr,
550+
"AutoModel",
551+
lambda model, **kwargs: auto_model_calls.append(model) or object(),
552+
)
553+
vllm_stub = types.ModuleType("funasr.auto.auto_model_vllm")
554+
vllm_stub.AutoModelVLLM = lambda **kwargs: object()
555+
monkeypatch.setitem(sys.modules, "funasr.auto.auto_model_vllm", vllm_stub)
556+
557+
args = types.SimpleNamespace(
558+
model="FunAudioLLM/Fun-ASR-Nano-2512",
559+
hub="ms",
560+
device="cpu",
561+
dtype="fp32",
562+
tensor_parallel_size=1,
563+
gpu_memory_utilization=0.8,
564+
max_model_len=2048,
565+
hotword_file="",
566+
postprocess_hotword_file=str(hotword_file),
567+
language=None,
568+
enable_spk=False,
569+
endpoint_mode="client",
570+
)
571+
572+
_, asr_kwargs, _, _ = module.load_models(args)
573+
574+
assert asr_kwargs == {
575+
"postprocess_hotword_file": str(hotword_file),
576+
"postprocess_hotword_fuzzy": False,
577+
"return_postprocess_hotword_matches": True,
578+
}
579+
assert auto_model_calls == []
580+
581+
450582
def test_two_hour_session_keeps_audio_bounded_and_duration_absolute():
451583
module = load_service_module()
452584
sample_rate = 10

0 commit comments

Comments
 (0)