From ae06bf32fc720179c73d1e9f520f2f51423a8c7b Mon Sep 17 00:00:00 2001 From: Tanishq Date: Thu, 12 Nov 2026 18:05:17 +0530 Subject: [PATCH 1/2] 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] From c9b5104ce646f81a1393d20e593e1dd35b8e66a6 Mon Sep 17 00:00:00 2001 From: zhifu gao Date: Fri, 13 Nov 2026 04:30:04 +0800 Subject: [PATCH 2/2] fix(runtime): integrate bounded hotwords on current main Retain the original #3882 author commit and carry forward the exact accepted 1.4.26 main tree. Only the original three-file hotword patch differs from main. Signed-off-by: LauraGPT --- README.md | 3 +- README_ja.md | 3 +- README_ko.md | 3 +- README_zh.md | 3 +- docs/repository_roles.md | 2 +- docs/repository_roles_zh.md | 2 +- funasr/utils/postprocess_utils.py | 11 ++- funasr/version.txt | 2 +- .../libtorch/funasr_torch/paraformer_bin.py | 4 +- .../funasr_torch/utils/postprocess_utils.py | 11 ++- .../onnxruntime/funasr_onnx/paraformer_bin.py | 8 +- .../funasr_onnx/utils/postprocess_utils.py | 11 ++- tests/test_release_version_contract.py | 15 ++- .../test_rich_transcription_event_segments.py | 4 +- tests/test_rich_transcription_postprocess.py | 21 +++++ tests/test_runtime_eval_native.py | 94 +++++++++++++++++++ .../product-site/content/legacy-manifest.json | 8 +- ...osted-deepgram-assemblyai-alternative.html | 3 +- ...hosted-openai-whisper-api-alternative.html | 3 +- ...osted-deepgram-assemblyai-alternative.html | 3 +- ...hosted-openai-whisper-api-alternative.html | 3 +- .../tests/test_blog_editorial_articles.py | 3 +- .../tests/test_http_blog_contracts.py | 17 +++- 23 files changed, 207 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 108711e43..53c94fa55 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,8 @@ recordings with the same evaluation scope. ## What's new - **MOSS-Transcribe-Diarize** brings long-form ASR, timestamps, and anonymous speaker labels to FunASR services, Docker, Kubernetes, vLLM/SGLang workflows, and FunClip. [Deploy MOSS ->](./docs/moss_transcribe_diarize.md) -- **FunASR 1.4.25** restores Python 3.12 boolean argument parsing without `distutils` and preserves normalized error messages; removes synthetic leading spaces from Latin sentence timestamps while preserving boundaries and raw text. [Fix #3863](https://github.com/modelscope/FunASR/pull/3863), [fix #3866](https://github.com/modelscope/FunASR/pull/3866). Install with `python -m pip install -U "funasr==1.4.25"`. [Release and verification scope ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) +- **FunASR 1.4.26** preserves word spaces between English/Korean VAD segments and returns plain text for HTTP `response_format=text`. [#3874](https://github.com/modelscope/FunASR/pull/3874), [#3883](https://github.com/modelscope/FunASR/pull/3883). `python -m pip install -U "funasr==1.4.26"`. [Release / 验证范围](https://github.com/modelscope/FunASR/releases/tag/v1.4.26) + **FunASR 1.4.25** restores Python 3.12 boolean argument parsing without `distutils` and preserves normalized error messages; removes synthetic leading spaces from Latin sentence timestamps while preserving boundaries and raw text. [Fix #3863](https://github.com/modelscope/FunASR/pull/3863), [fix #3866](https://github.com/modelscope/FunASR/pull/3866). Install with `python -m pip install -U "funasr==1.4.25"`. [Release and verification scope ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) **FunASR 1.4.24** ignores empty non-linguistic symbols to prevent tokenizer loops, fixes padded variance normalization, and prevents CIF crashes in mixed batches containing zero-fire samples. [Fix #3852](https://github.com/modelscope/FunASR/pull/3852), [fix #3853](https://github.com/modelscope/FunASR/pull/3853), [fix #3854](https://github.com/modelscope/FunASR/pull/3854). A checkpoint trained with both `norm_means=True` and `norm_vars=True` needs recognition-quality reevaluation because normalized features change; `norm_vars=False` remains the default. Install with `python -m pip install -U "funasr==1.4.24"`. [Release and verification scope ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.24) **FunASR 1.4.23** fixes the online frontend cursor for `lfr_m=lfr_n=1` and redundant empty final offline VAD chunks. With a supplied `token_list`, SentencePiece `ids2tokens` returns model pieces; use `tokens2text` for text. Without `token_list`, legacy text decoding is unchanged. [Fix #3849](https://github.com/modelscope/FunASR/pull/3849), [fix #3783](https://github.com/modelscope/FunASR/pull/3783). [Release and verification scope ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.23) **FunASR 1.4.22** honors per-call `merge_length_s` and evaluates dynamic VAD silence thresholds per frame using the current segment duration, preserving the fixed-threshold opt-out. [Fix #3841](https://github.com/modelscope/FunASR/pull/3841), [fix #3833](https://github.com/modelscope/FunASR/pull/3833). [Release and verification scope ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.22) diff --git a/README_ja.md b/README_ja.md index 64f634fe6..883faf2c0 100644 --- a/README_ja.md +++ b/README_ja.md @@ -112,7 +112,8 @@ CER/WER をそろえて比較してください。オフラインのスループ ## 最新情報 - **MOSS-Transcribe-Diarize** を FunASR service、Docker、Kubernetes、vLLM/SGLang workflow、FunClip に統合し、長時間 ASR、timestamp、匿名 speaker label を一度に処理できます。[MOSS をデプロイ ->](./docs/moss_transcribe_diarize.md) -- **FunASR 1.4.25** は Python 3.12 の真偽値引数を `distutils` なしで解析し、正規化されたエラーメッセージを維持します。ラテン文字で始まる文のタイムスタンプ出力から余分な先頭空白を除き、時刻境界と原文を保持します。[修正 #3863](https://github.com/modelscope/FunASR/pull/3863)、[修正 #3866](https://github.com/modelscope/FunASR/pull/3866)。`python -m pip install -U "funasr==1.4.25"` でインストールできます。[リリースと検証範囲 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) +- **FunASR 1.4.26** は英語・韓国語の VAD セグメント間の単語区切りを保持し、HTTP `response_format=text` でプレーンテキストを返します。 [#3874](https://github.com/modelscope/FunASR/pull/3874), [#3883](https://github.com/modelscope/FunASR/pull/3883). `python -m pip install -U "funasr==1.4.26"`. [Release / 验证范围](https://github.com/modelscope/FunASR/releases/tag/v1.4.26) + **FunASR 1.4.25** は Python 3.12 の真偽値引数を `distutils` なしで解析し、正規化されたエラーメッセージを維持します。ラテン文字で始まる文のタイムスタンプ出力から余分な先頭空白を除き、時刻境界と原文を保持します。[修正 #3863](https://github.com/modelscope/FunASR/pull/3863)、[修正 #3866](https://github.com/modelscope/FunASR/pull/3866)。`python -m pip install -U "funasr==1.4.25"` でインストールできます。[リリースと検証範囲 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) **FunASR 1.4.24** は空の非言語記号によるトークナイザーの無限ループ、パディングを含む分散正規化、発火回数ゼロのサンプルを含む混合バッチでの CIF クラッシュを修正します。[修正 #3852](https://github.com/modelscope/FunASR/pull/3852)、[修正 #3853](https://github.com/modelscope/FunASR/pull/3853)、[修正 #3854](https://github.com/modelscope/FunASR/pull/3854)。`norm_means=True` と `norm_vars=True` の両方で学習した checkpoint は正規化特徴が変わるため、認識品質の再評価が必要です。既定値は引き続き `norm_vars=False` です。`python -m pip install -U "funasr==1.4.24"` でインストールできます。[リリースと検証範囲 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.24) **FunASR 1.4.23** は `lfr_m=lfr_n=1` のオンライン特徴抽出のフレーム位置と、オフライン VAD の余分な空の最終チャンクを修正します。`token_list` 指定時の SentencePiece `ids2tokens` はモデルのピースを返し、テキストへの変換には `tokens2text` を使います。未指定時の従来のテキスト復号は維持します。[修正 #3849](https://github.com/modelscope/FunASR/pull/3849)、[修正 #3783](https://github.com/modelscope/FunASR/pull/3783)。[リリースと検証範囲 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.23) **FunASR 1.4.22** は呼び出しごとの `merge_length_s` を反映し、現在の発話区間の長さに基づいて動的 VAD 無音しきい値をフレーム単位で更新します。固定しきい値モードも維持します。[修正 #3841](https://github.com/modelscope/FunASR/pull/3841)、[修正 #3833](https://github.com/modelscope/FunASR/pull/3833)。[リリースと検証範囲 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.22) diff --git a/README_ko.md b/README_ko.md index caa493859..43e51b4da 100644 --- a/README_ko.md +++ b/README_ko.md @@ -111,7 +111,8 @@ checkpoint/revision, 오디오 집합, 하드웨어, 배치, 워밍업, 측정 ## 최신 소식 - **MOSS-Transcribe-Diarize**를 FunASR service, Docker, Kubernetes, vLLM/SGLang workflow, FunClip에 통합해 긴 오디오 ASR, timestamp, 익명 speaker label을 한 번에 처리합니다. [MOSS 배포 ->](./docs/moss_transcribe_diarize.md) -- **FunASR 1.4.25**는 Python 3.12에서 `distutils` 없이 불리언 인수를 처리하고 정규화된 오류 메시지를 유지합니다. 라틴 문자로 시작하는 문장의 타임스탬프 출력에서 불필요한 앞 공백을 제거하며 시간 경계와 원문은 보존합니다. [수정 #3863](https://github.com/modelscope/FunASR/pull/3863), [수정 #3866](https://github.com/modelscope/FunASR/pull/3866). `python -m pip install -U "funasr==1.4.25"`로 설치할 수 있습니다. [릴리스 및 검증 범위 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) +- **FunASR 1.4.26** 는 영어·한국어 VAD 구간 사이의 단어 공백을 보존하고 HTTP `response_format=text` 요청에 일반 텍스트를 반환합니다. [#3874](https://github.com/modelscope/FunASR/pull/3874), [#3883](https://github.com/modelscope/FunASR/pull/3883). `python -m pip install -U "funasr==1.4.26"`. [Release / 验证范围](https://github.com/modelscope/FunASR/releases/tag/v1.4.26) + **FunASR 1.4.25**는 Python 3.12에서 `distutils` 없이 불리언 인수를 처리하고 정규화된 오류 메시지를 유지합니다. 라틴 문자로 시작하는 문장의 타임스탬프 출력에서 불필요한 앞 공백을 제거하며 시간 경계와 원문은 보존합니다. [수정 #3863](https://github.com/modelscope/FunASR/pull/3863), [수정 #3866](https://github.com/modelscope/FunASR/pull/3866). `python -m pip install -U "funasr==1.4.25"`로 설치할 수 있습니다. [릴리스 및 검증 범위 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) **FunASR 1.4.24**는 빈 비언어 기호로 인한 토크나이저 무한 루프, 패딩이 있는 입력의 분산 정규화 오류, 발화 횟수가 0인 샘플이 섞인 배치의 CIF 충돌을 수정합니다. [수정 #3852](https://github.com/modelscope/FunASR/pull/3852), [수정 #3853](https://github.com/modelscope/FunASR/pull/3853), [수정 #3854](https://github.com/modelscope/FunASR/pull/3854). `norm_means=True`와 `norm_vars=True`를 모두 사용해 학습한 checkpoint는 정규화 특징이 달라지므로 인식 품질을 다시 평가해야 합니다. 기본값은 계속 `norm_vars=False`입니다. `python -m pip install -U "funasr==1.4.24"`로 설치할 수 있습니다. [릴리스 및 검증 범위 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.24) **FunASR 1.4.23**은 `lfr_m=lfr_n=1` 온라인 프런트엔드의 프레임 위치와 오프라인 VAD의 불필요한 빈 마지막 청크를 수정합니다. `token_list`를 지정하면 SentencePiece `ids2tokens`는 모델 토큰을 반환하며, 텍스트 변환에는 `tokens2text`를 사용합니다. 미지정 시 기존 텍스트 디코딩은 유지됩니다. [수정 #3849](https://github.com/modelscope/FunASR/pull/3849), [수정 #3783](https://github.com/modelscope/FunASR/pull/3783). [릴리스 및 검증 범위 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.23) **FunASR 1.4.22**는 호출별 `merge_length_s`를 반영하고 현재 발화 구간 길이에 따라 동적 VAD 무음 임계값을 프레임마다 갱신합니다. 고정 임계값 모드도 유지합니다. [수정 #3841](https://github.com/modelscope/FunASR/pull/3841), [수정 #3833](https://github.com/modelscope/FunASR/pull/3833). [릴리스 및 검증 범위 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.22) diff --git a/README_zh.md b/README_zh.md index ec13d2f34..efa77d7ca 100644 --- a/README_zh.md +++ b/README_zh.md @@ -157,7 +157,8 @@ checkpoint/revision、音频集、硬件、批量大小、预热、计时范围 ## 最新动态 - **MOSS-Transcribe-Diarize** 已接入 FunASR 服务、Docker、Kubernetes、vLLM/SGLang 工作流和 FunClip,一次完成长音频转写、时间戳与匿名说话人标注。[部署 MOSS ->](./docs/moss_transcribe_diarize_zh.md) -- **FunASR 1.4.25** 修复 Python 3.12 下不依赖 `distutils` 的布尔参数解析并保留归一化错误信息;移除拉丁文字句子时间戳中的多余前导空格,保留时间边界和原始文本。[修复 #3863](https://github.com/modelscope/FunASR/pull/3863)、[修复 #3866](https://github.com/modelscope/FunASR/pull/3866)。安装:`python -m pip install -U "funasr==1.4.25"`。[发布说明与验证范围 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) +- **FunASR 1.4.26** 保留英语、韩语 VAD 分段间的词间空格,并让 HTTP `response_format=text` 返回纯文本。 [#3874](https://github.com/modelscope/FunASR/pull/3874), [#3883](https://github.com/modelscope/FunASR/pull/3883). `python -m pip install -U "funasr==1.4.26"`. [Release / 验证范围](https://github.com/modelscope/FunASR/releases/tag/v1.4.26) + **FunASR 1.4.25** 修复 Python 3.12 下不依赖 `distutils` 的布尔参数解析并保留归一化错误信息;移除拉丁文字句子时间戳中的多余前导空格,保留时间边界和原始文本。[修复 #3863](https://github.com/modelscope/FunASR/pull/3863)、[修复 #3866](https://github.com/modelscope/FunASR/pull/3866)。安装:`python -m pip install -U "funasr==1.4.25"`。[发布说明与验证范围 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.25) **FunASR 1.4.24** 忽略空的非语言符号以避免分词死循环,修复带填充输入的方差归一化,并避免包含零触发样本的混合批次导致 CIF 崩溃。[修复 #3852](https://github.com/modelscope/FunASR/pull/3852)、[修复 #3853](https://github.com/modelscope/FunASR/pull/3853)、[修复 #3854](https://github.com/modelscope/FunASR/pull/3854)。同时启用 `norm_means=True` 和 `norm_vars=True` 训练的 checkpoint 会收到不同的归一化特征,需要重新评估识别质量;默认仍为 `norm_vars=False`。安装:`python -m pip install -U "funasr==1.4.24"`。[发布说明与验证范围 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.24) **FunASR 1.4.23** 修复 `lfr_m=lfr_n=1` 在线前端的帧游标和离线 VAD 多余的空尾块。提供 `token_list` 时,SentencePiece 的 `ids2tokens` 返回模型词元,请用 `tokens2text` 转为文本;未提供 `token_list` 时保持旧有文本解码行为。[修复 #3849](https://github.com/modelscope/FunASR/pull/3849)、[修复 #3783](https://github.com/modelscope/FunASR/pull/3783)。[发布说明与验证范围 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.23) **FunASR 1.4.22** 支持逐调用覆盖 `merge_length_s`,并根据当前语音段时长逐帧更新动态 VAD 静音阈值,同时保留固定阈值模式。[修复 #3841](https://github.com/modelscope/FunASR/pull/3841)、[修复 #3833](https://github.com/modelscope/FunASR/pull/3833)。[发布说明与验证范围 ->](https://github.com/modelscope/FunASR/releases/tag/v1.4.22) diff --git a/docs/repository_roles.md b/docs/repository_roles.md index bff0e53a9..30178f163 100644 --- a/docs/repository_roles.md +++ b/docs/repository_roles.md @@ -5,7 +5,7 @@ This document explains the responsibility boundaries, user entry points, and iss > **Directional roadmap, not a release promise.** > This document records shipped capabilities and active work, but does not commit to > future version numbers or dates. The current Python release is -> [`funasr==1.4.25`](https://github.com/modelscope/FunASR/releases/tag/v1.4.25). +> [`funasr==1.4.26`](https://github.com/modelscope/FunASR/releases/tag/v1.4.26). > Any future breaking release still requires a maintainer-approved milestone and > migration plan. diff --git a/docs/repository_roles_zh.md b/docs/repository_roles_zh.md index fd979aced..013f0669e 100644 --- a/docs/repository_roles_zh.md +++ b/docs/repository_roles_zh.md @@ -4,7 +4,7 @@ > **方向性路线图,不是版本承诺。** > 本文档记录已交付能力与正在推进的工作,但不承诺未来版本号或日期。当前 Python -> 版本是 [`funasr==1.4.25`](https://github.com/modelscope/FunASR/releases/tag/v1.4.25)。 +> 版本是 [`funasr==1.4.26`](https://github.com/modelscope/FunASR/releases/tag/v1.4.26)。 > 任何未来的 breaking release 仍需 maintainer 确认 milestone 与迁移方案。 --- diff --git a/funasr/utils/postprocess_utils.py b/funasr/utils/postprocess_utils.py index 6a3cd2336..cd99e1fbb 100644 --- a/funasr/utils/postprocess_utils.py +++ b/funasr/utils/postprocess_utils.py @@ -442,6 +442,11 @@ def format_str_v2(s): return s.strip() +def _is_spaced_char(ch): + """Latin and Hangul text separates words with spaces; CJK text does not.""" + return (ch.isascii() and not ch.isspace()) or "\uac00" <= ch <= "\ud7a3" + + def rich_transcription_postprocess(s): """Rich transcription postprocess. @@ -481,6 +486,10 @@ def get_event(s): cur_ent_event = get_event(s_list[i]) if get_emo(s_list[i]) != None and get_emo(s_list[i]) == get_emo(new_s): new_s = new_s[:-1] - new_s += s_list[i].strip().lstrip() + segment = s_list[i].strip() + # Each VAD segment is its own <|lang|> block; keep the word boundary between them. + if segment and _is_spaced_char(new_s[-1]) and _is_spaced_char(segment[0]): + new_s += " " + new_s += segment new_s = new_s.replace("The.", " ") return new_s.strip() diff --git a/funasr/version.txt b/funasr/version.txt index df3487bee..24d07aa70 100644 --- a/funasr/version.txt +++ b/funasr/version.txt @@ -1 +1 @@ -1.4.25 +1.4.26 diff --git a/runtime/python/libtorch/funasr_torch/paraformer_bin.py b/runtime/python/libtorch/funasr_torch/paraformer_bin.py index 836ffba8d..15bab5075 100644 --- a/runtime/python/libtorch/funasr_torch/paraformer_bin.py +++ b/runtime/python/libtorch/funasr_torch/paraformer_bin.py @@ -120,10 +120,10 @@ def __call__(self, wav_content: Union[str, np.ndarray, List[str]], **kwargs) -> pred = sentence_postprocess(pred) asr_res.append({"preds": pred}) else: - for pred, us_peaks_ in zip(preds, us_peaks): + for pred, us_peaks_, feat_len in zip(preds, us_peaks, feats_len): raw_tokens = pred timestamp, timestamp_raw = time_stamp_lfr6_onnx( - us_peaks_, copy.copy(raw_tokens) + us_peaks_[: feat_len * 3], copy.copy(raw_tokens) ) text_proc, timestamp_proc, _ = sentence_postprocess( raw_tokens, timestamp_raw diff --git a/runtime/python/libtorch/funasr_torch/utils/postprocess_utils.py b/runtime/python/libtorch/funasr_torch/utils/postprocess_utils.py index 34180841c..d5b7a6ffb 100644 --- a/runtime/python/libtorch/funasr_torch/utils/postprocess_utils.py +++ b/runtime/python/libtorch/funasr_torch/utils/postprocess_utils.py @@ -347,6 +347,11 @@ def format_str_v2(s): return s.strip() +def _is_spaced_char(ch): + """Latin and Hangul text separates words with spaces; CJK text does not.""" + return (ch.isascii() and not ch.isspace()) or "\uac00" <= ch <= "\ud7a3" + + def rich_transcription_postprocess(s): def get_emo(s): return s[-1] if s[-1] in emo_set else None @@ -371,6 +376,10 @@ def get_event(s): cur_ent_event = get_event(s_list[i]) if get_emo(s_list[i]) != None and get_emo(s_list[i]) == get_emo(new_s): new_s = new_s[:-1] - new_s += s_list[i].strip().lstrip() + segment = s_list[i].strip() + # Each VAD segment is its own <|lang|> block; keep the word boundary between them. + if segment and _is_spaced_char(new_s[-1]) and _is_spaced_char(segment[0]): + new_s += " " + new_s += segment new_s = new_s.replace("The.", " ") return new_s.strip() diff --git a/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py b/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py index 7a39c4667..fff3f301d 100644 --- a/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py +++ b/runtime/python/onnxruntime/funasr_onnx/paraformer_bin.py @@ -131,10 +131,10 @@ def __call__(self, wav_content: Union[str, np.ndarray, List[str]], **kwargs) -> pred = sentence_postprocess(pred) asr_res.append({"preds": pred}) else: - for pred, us_peaks_ in zip(preds, us_peaks): + for pred, us_peaks_, feat_len in zip(preds, us_peaks, feats_len): raw_tokens = pred timestamp, timestamp_raw = time_stamp_lfr6_onnx( - us_peaks_, copy.copy(raw_tokens) + us_peaks_[: feat_len * 3], copy.copy(raw_tokens) ) text_proc, timestamp_proc, _ = sentence_postprocess( raw_tokens, timestamp_raw @@ -384,10 +384,10 @@ def __call__( pred = sentence_postprocess(pred) asr_res.append({"preds": pred}) else: - for pred, us_peaks_ in zip(preds, us_peaks): + for pred, us_peaks_, feat_len in zip(preds, us_peaks, feats_len): raw_tokens = pred timestamp, timestamp_raw = time_stamp_lfr6_onnx( - us_peaks_, copy.copy(raw_tokens) + us_peaks_[: feat_len * 3], copy.copy(raw_tokens) ) text_proc, timestamp_proc, _ = sentence_postprocess( raw_tokens, timestamp_raw diff --git a/runtime/python/onnxruntime/funasr_onnx/utils/postprocess_utils.py b/runtime/python/onnxruntime/funasr_onnx/utils/postprocess_utils.py index 3a62afee6..82c334f85 100644 --- a/runtime/python/onnxruntime/funasr_onnx/utils/postprocess_utils.py +++ b/runtime/python/onnxruntime/funasr_onnx/utils/postprocess_utils.py @@ -401,6 +401,11 @@ def format_str_v2(s): return s.strip() +def _is_spaced_char(ch): + """Latin and Hangul text separates words with spaces; CJK text does not.""" + return (ch.isascii() and not ch.isspace()) or "\uac00" <= ch <= "\ud7a3" + + def rich_transcription_postprocess(s): def get_emo(s): return s[-1] if s[-1] in emo_set else None @@ -425,6 +430,10 @@ def get_event(s): cur_ent_event = get_event(s_list[i]) if get_emo(s_list[i]) != None and get_emo(s_list[i]) == get_emo(new_s): new_s = new_s[:-1] - new_s += s_list[i].strip().lstrip() + segment = s_list[i].strip() + # Each VAD segment is its own <|lang|> block; keep the word boundary between them. + if segment and _is_spaced_char(new_s[-1]) and _is_spaced_char(segment[0]): + new_s += " " + new_s += segment new_s = new_s.replace("The.", " ") return new_s.strip() diff --git a/tests/test_release_version_contract.py b/tests/test_release_version_contract.py index d2b076db2..b3bf95182 100644 --- a/tests/test_release_version_contract.py +++ b/tests/test_release_version_contract.py @@ -4,7 +4,7 @@ ROOT = Path(__file__).resolve().parents[1] -RELEASE_VERSION = "1.4.25" +RELEASE_VERSION = "1.4.26" README_FILES = ("README.md", "README_zh.md", "README_ja.md", "README_ko.md") REPOSITORY_ROLE_FILES = ("docs/repository_roles.md", "docs/repository_roles_zh.md") @pytest.mark.parametrize("name", README_FILES) @@ -23,7 +23,7 @@ def test_release_summary_covers_tokenizer_normalizer_and_cif(name): @pytest.mark.parametrize("name", README_FILES) def test_release_summary_covers_parser_and_timestamp_fixes(name): summaries = [line for line in (ROOT / name).read_text(encoding="utf-8").splitlines() - if f"**FunASR {RELEASE_VERSION}**" in line] + if "**FunASR 1.4.25**" in line] assert len(summaries) == 1 assert "Python 3.12" in summaries[0] assert "distutils" in summaries[0] @@ -31,6 +31,17 @@ def test_release_summary_covers_parser_and_timestamp_fixes(name): assert f"https://github.com/modelscope/FunASR/pull/{number}" in summaries[0] +@pytest.mark.parametrize("name", README_FILES) +def test_release_summary_covers_vad_spacing_and_plain_text(name): + summaries = [line for line in (ROOT / name).read_text(encoding="utf-8").splitlines() + if f"**FunASR {RELEASE_VERSION}**" in line] + assert len(summaries) == 1 + assert "VAD" in summaries[0] + assert "response_format=text" in summaries[0] + for number in (3874, 3883): + assert f"https://github.com/modelscope/FunASR/pull/{number}" in summaries[0] + + diff --git a/tests/test_rich_transcription_event_segments.py b/tests/test_rich_transcription_event_segments.py index ed756abfd..a565b5c3c 100644 --- a/tests/test_rich_transcription_event_segments.py +++ b/tests/test_rich_transcription_event_segments.py @@ -34,7 +34,9 @@ def test_repeated_event_only_segments_keep_following_text(postprocess, event, fo result = postprocess.rich_transcription_postprocess(text) - assert result == postprocess.event_dict[event] + "hello" + following_text + assert result == postprocess.event_dict[event] + " ".join( + filter(None, ["hello", following_text]) + ) @pytest.mark.parametrize("event", ["<|Cough|>", "<|Sneeze|>", "<|Applause|>"]) diff --git a/tests/test_rich_transcription_postprocess.py b/tests/test_rich_transcription_postprocess.py index 8e07de09e..2cfc708ad 100644 --- a/tests/test_rich_transcription_postprocess.py +++ b/tests/test_rich_transcription_postprocess.py @@ -32,3 +32,24 @@ def test_cough_and_sneeze_events_render_distinct_emojis(path, event, emoji): text = module.rich_transcription_postprocess(f"<|en|><|NEUTRAL|>{event}<|woitn|>hello") assert text == f"{emoji}hello" + + +@pytest.mark.parametrize("path", POSTPROCESS_UTILS, ids=lambda path: path.parts[-3]) +@pytest.mark.parametrize( + "first, second, expected", + [ + ("Hello world.", "How are you?", "Hello world. How are you?"), + ("so I went to the", "store", "so I went to the store"), + ("안녕하세요.", "감사합니다.", "안녕하세요. 감사합니다."), + ("你好。", "再见。", "你好。再见。"), + ("你好。", "Hello.", "你好。Hello."), + ], +) +def test_vad_segments_keep_a_space_between_spaced_scripts(path, first, second, expected): + module = _load(path) + tags = "<|NEUTRAL|><|Speech|><|withitn|>" + + # AutoModel joins the tagged text of each VAD segment with a single space. + text = module.rich_transcription_postprocess(f"<|en|>{tags}{first} <|en|>{tags}{second}") + + assert text == expected diff --git a/tests/test_runtime_eval_native.py b/tests/test_runtime_eval_native.py index 7bc1d33bd..97133b3af 100644 --- a/tests/test_runtime_eval_native.py +++ b/tests/test_runtime_eval_native.py @@ -8,6 +8,7 @@ import json import os from pathlib import Path +import shutil import subprocess import sys import wave @@ -275,6 +276,99 @@ def test_hotword_wrappers_decode_every_batch( assert model(paths, hotwords) == alone +@pytest.fixture(scope="module") +def timestamp_fixture(hotword_fixture, tmp_path_factory): + np = pytest.importorskip("numpy") + source, _ = hotword_fixture + directory = tmp_path_factory.mktemp("runtime-timestamp") + # The graphs only have to load: the timestamp test replaces the acoustic model's session. + for name, target in [ + ("tokens.json", "tokens.json"), + ("model_eb.onnx", "model_eb.onnx"), + ("model.onnx", "model.onnx"), + ("model_bb.torchscript", "model.torchscript"), + ]: + shutil.copy(source / name, directory / target) + (directory / "config.yaml").write_text( + json.dumps( + { + "frontend_conf": {"n_mels": 4, "lfr_m": 7, "lfr_n": 6, "dither": 0.0}, + "model_conf": {"predictor_bias": 1}, + } + ) + ) + (directory / "am.mvn").write_text( + " 28 28\n 1 [ " + "0 " * 28 + "]\n" + " 28 28\n 1 [ " + "1 " * 28 + "]\n" + ) + paths = [] + for seconds in (1, 3): + path = directory / f"tone {seconds}s.wav" + tone = np.sin(np.arange(16000 * seconds) * 2 * np.pi * 440 / 16000) * 3000 + with wave.open(str(path), "wb") as output: + output.setnchannels(1) + output.setsampwidth(2) + output.setframerate(16000) + output.writeframes(tone.astype(" + us_alphas = np.zeros((batch, 3 * feats.shape[1]), dtype=np.float32) + us_peaks = np.zeros_like(us_alphas) + for row, feat_len in enumerate(np.asarray(feats_len).tolist()): + frames = 3 * feat_len + us_alphas[row, :frames] = 3.0 / frames + us_peaks[row, np.linspace(5, frames - 6, 3).round().astype(int)] = 1.0 + return [am_scores, np.full(batch, 3, dtype=np.int32), us_alphas, us_peaks] + + +@pytest.mark.parametrize( + "backend, wrapper", + [ + ("onnx", "Paraformer"), + ("onnx", "ContextualParaformer"), + ("onnx", "SeacoParaformer"), + ("torch", "Paraformer"), + ], +) +def test_batched_timestamps_ignore_padding(timestamp_fixture, monkeypatch, backend, wrapper): + torch = pytest.importorskip("torch") + directory, paths = timestamp_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), batch_size=2, intra_op_num_threads=1) + if backend == "torch": + model.ort_infer = lambda feats, feats_len: tuple( + torch.from_numpy(output) for output in timestamp_outputs(feats, feats_len) + ) + elif wrapper == "Paraformer": + model.ort_infer = lambda inputs: timestamp_outputs(*inputs) + else: + model.ort_infer_bb = lambda inputs: timestamp_outputs(*inputs[:2]) + hotwords = () if wrapper == "Paraformer" else ("魔搭",) + alone = [model(path, *hotwords)[0] for path in paths] + # The 1 s tone ends at 1000 ms, the 3 s tone at 3000 ms. + assert [row["timestamp"] for row in alone] == [ + [[70, 470], [470, 945]], + [[70, 670], [1450, 2925]], + ] + # Batched with the 3 s tone, the 1 s tone must not take on the padded frames. + assert model(paths, *hotwords) == alone + + @pytest.mark.parametrize("extension", ["flac", "ogg", "mp3"]) def test_onnx_wrappers_read_non_wav_paths(native_fixture, tmp_path, monkeypatch, extension): np = pytest.importorskip("numpy") diff --git a/web-pages/product-site/content/legacy-manifest.json b/web-pages/product-site/content/legacy-manifest.json index ddfe0410d..20e7c98ac 100644 --- a/web-pages/product-site/content/legacy-manifest.json +++ b/web-pages/product-site/content/legacy-manifest.json @@ -37,9 +37,9 @@ "blog/lightweight-speech-recognition-cpu.html": "d6270066222ed5baef0df108e6a4155f4a87bb5482169f23f218794d371f3281", "blog/meeting-transcript-acceptance.html": "8173b3049ca707bdac46013748bc0ca7d9576ebf6f7ff6db6c940a833b12e4a9", "blog/punctuation-restoration-python.html": "063c5fe584bff780edc50f95214decbb3432326d4983334fcd8b38aabeb16128", - "blog/self-hosted-deepgram-assemblyai-alternative.html": "feb17aae8e0f3fe46914c8725eacc8fcdf7fe78bf22a291347d6107c24e3992e", + "blog/self-hosted-deepgram-assemblyai-alternative.html": "e7021f5a7f9bc0c40d88ff46ee53cd0d08cb5cbf17ae7aa8f9e8d85a15600c5f", "blog/self-hosted-google-aws-azure-speech-to-text.html": "8e4000fb9e2b33a48a3907d4bd0f55db57ba74121a89b5fa12b412c3c963f49a", - "blog/self-hosted-openai-whisper-api-alternative.html": "254184cd753f30e308329c301ae47e8f5f5dfb57c01d0abf3ce341034f2990f9", + "blog/self-hosted-openai-whisper-api-alternative.html": "f75f4b2fad03f6d1215347fbc98c71de1a59ff3c97cecc4aaf19bbb94e8eb777", "blog/sensevoice-deployment-guide.html": "61c34fb7626444a0f1b5c93013dd95c10c576333791eaca1ab819baf0a8cdad6", "blog/sensevoice-emotion-language-detection.html": "2451769a28af67788456aa02dad5201d218ac53fb84764cd7fbc32f991c953f8", "blog/speech-to-text-python-transcribe-audio.html": "87335710235dfdec9e01288f03a9a294c7bc3f730110e8d06f2b4c9d4906e284", @@ -80,9 +80,9 @@ "en/blog/lightweight-speech-recognition-cpu.html": "0e02c0e0853b12613d32c250f593c05c2052f18231207b63ee01d6e6600a86f7", "en/blog/meeting-transcript-acceptance.html": "ffc6eb2329267515bb367f21bb2d1c0523ff90d3f02b2e734ae41f78b02d0515", "en/blog/punctuation-restoration-python.html": "5853658aa8e936eda988fa087cba4a297de8044f979f6a9deef49206b8967a79", - "en/blog/self-hosted-deepgram-assemblyai-alternative.html": "4239a93246726c6f010a2fb20208211e050792a372e3ea356d1a93ac028a8a96", + "en/blog/self-hosted-deepgram-assemblyai-alternative.html": "49a5e6e8134c33f9951e3cc052571e8d8441acaa8522be233552f30b5f5410fa", "en/blog/self-hosted-google-aws-azure-speech-to-text.html": "4a8453451722b3ec8735224a586744ddfbfec9458cedd433f9fc908351027e9a", - "en/blog/self-hosted-openai-whisper-api-alternative.html": "14bdb877adfd113eeb04b215ffa015c29d8323859eeddd00d9e03d166dfa0473", + "en/blog/self-hosted-openai-whisper-api-alternative.html": "a95bde0e83ebea941144e5a4bd339ea866feee099f047d4b4fb01072c293cb70", "en/blog/sensevoice-deployment-guide.html": "8882912c6a84ec0a0e025d0eff8fcfa74bf76324bd4c9cae862c43c87c6e10b3", "en/blog/sensevoice-emotion-language-detection.html": "8f1432a4c07dab895dfda144d4fe1f64a487c72922febb6a379a15b7215e42a3", "en/blog/speech-to-text-python-transcribe-audio.html": "4fc57aef3c6177e28afa26fa6c41f67280b986e384b32a63a2f79ca67bcfe631", diff --git a/web-pages/product-site/legacy/blog/self-hosted-deepgram-assemblyai-alternative.html b/web-pages/product-site/legacy/blog/self-hosted-deepgram-assemblyai-alternative.html index 631379ae9..a92b9e038 100644 --- a/web-pages/product-site/legacy/blog/self-hosted-deepgram-assemblyai-alternative.html +++ b/web-pages/product-site/legacy/blog/self-hosted-deepgram-assemblyai-alternative.html @@ -5,7 +5,7 @@