From b04804c96dcfc9a4cb945f3d108a3fb8908d1205 Mon Sep 17 00:00:00 2001 From: BillVan1945 <307193874+BillVan1945@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:17:43 +0900 Subject: [PATCH] fix(runtime): handle blank and incomplete CMVN files --- .../libtorch/funasr_torch/utils/frontend.py | 14 ++- .../onnxruntime/funasr_onnx/utils/frontend.py | 12 +- tests/test_runtime_cmvn.py | 105 ++++++++++++++++++ 3 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 tests/test_runtime_cmvn.py diff --git a/runtime/python/libtorch/funasr_torch/utils/frontend.py b/runtime/python/libtorch/funasr_torch/utils/frontend.py index 743ee5e5f2..2772ad95d6 100644 --- a/runtime/python/libtorch/funasr_torch/utils/frontend.py +++ b/runtime/python/libtorch/funasr_torch/utils/frontend.py @@ -125,25 +125,29 @@ def load_cmvn( self, ) -> np.ndarray: with open(self.cmvn_file, "r", encoding="utf-8") as f: - lines = f.readlines() + lines = [line for line in f.readlines() if line.split()] means_list = [] vars_list = [] for i in range(len(lines)): line_item = lines[i].split() if line_item[0] == "": - line_item = lines[i + 1].split() - if line_item[0] == "": + line_item = lines[i + 1].split() if i + 1 < len(lines) else [] + if line_item and line_item[0] == "": add_shift_line = line_item[3 : (len(line_item) - 1)] means_list = list(add_shift_line) continue elif line_item[0] == "": - line_item = lines[i + 1].split() - if line_item[0] == "": + line_item = lines[i + 1].split() if i + 1 < len(lines) else [] + if line_item and line_item[0] == "": rescale_line = line_item[3 : (len(line_item) - 1)] vars_list = list(rescale_line) continue + if not means_list or not vars_list: + raise ValueError( + f"No / statistics found in cmvn file: {self.cmvn_file}" + ) means = np.array(means_list).astype(np.float64) vars = np.array(vars_list).astype(np.float64) cmvn = np.array([means, vars]) diff --git a/runtime/python/onnxruntime/funasr_onnx/utils/frontend.py b/runtime/python/onnxruntime/funasr_onnx/utils/frontend.py index f150ccbf8a..4218718b6c 100644 --- a/runtime/python/onnxruntime/funasr_onnx/utils/frontend.py +++ b/runtime/python/onnxruntime/funasr_onnx/utils/frontend.py @@ -142,24 +142,26 @@ def load_cmvn(cmvn_file: Union[str, Path]) -> np.ndarray: raise FileNotFoundError("cmvn file not exits") with open(cmvn_file, "r", encoding="utf-8") as f: - lines = f.readlines() + lines = [line for line in f.readlines() if line.split()] means_list = [] vars_list = [] for i in range(len(lines)): line_item = lines[i].split() if line_item[0] == "": - line_item = lines[i + 1].split() - if line_item[0] == "": + line_item = lines[i + 1].split() if i + 1 < len(lines) else [] + if line_item and line_item[0] == "": add_shift_line = line_item[3 : (len(line_item) - 1)] means_list = list(add_shift_line) continue elif line_item[0] == "": - line_item = lines[i + 1].split() - if line_item[0] == "": + line_item = lines[i + 1].split() if i + 1 < len(lines) else [] + if line_item and line_item[0] == "": rescale_line = line_item[3 : (len(line_item) - 1)] vars_list = list(rescale_line) continue + if not means_list or not vars_list: + raise ValueError(f"No / statistics found in cmvn file: {cmvn_file}") means = np.array(means_list).astype(np.float64) vars = np.array(vars_list).astype(np.float64) cmvn = np.array([means, vars]) diff --git a/tests/test_runtime_cmvn.py b/tests/test_runtime_cmvn.py new file mode 100644 index 0000000000..97889e2fc7 --- /dev/null +++ b/tests/test_runtime_cmvn.py @@ -0,0 +1,105 @@ +"""Exercise the standalone runtime frontends without model weights or engines.""" + +import importlib.util +from pathlib import Path + +import numpy as np +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +CMVN = " 2 2\n 1 [ 1 -2 ]\n 2 2\n 1 [ 0.5 2 ]\n" + + +@pytest.fixture(params=["onnxruntime/funasr_onnx", "libtorch/funasr_torch"]) +def frontend_class(request): + # Loading the frontend directly keeps these tests independent of the + # optional inference engines and exercises each package's real constructor. + path = ROOT / "runtime/python" / request.param / "utils/frontend.py" + spec = importlib.util.spec_from_file_location("runtime_cmvn_frontend", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.WavFrontend + + +def test_valid_cmvn_preserves_statistics_and_normalized_features(frontend_class, tmp_path): + path = tmp_path / "valid.mvn" + path.write_text(CMVN, encoding="utf-8") + frontend = frontend_class(cmvn_file=str(path)) + + assert frontend.cmvn.dtype == np.float64 + np.testing.assert_array_equal(frontend.cmvn, [[1, -2], [0.5, 2]]) + features = np.array([[0, 2], [-2, 4]], dtype=np.float32) + np.testing.assert_array_equal(frontend.apply_cmvn(features), [[0.5, 0], [-0.5, 4]]) + + +@pytest.mark.parametrize("placement", ["leading", "trailing", "between_sections", "after_tags"]) +def test_cmvn_accepts_blank_lines(frontend_class, tmp_path, placement): + blank = " \t\n\n" + if placement == "leading": + content = blank + CMVN + elif placement == "trailing": + content = CMVN + blank + elif placement == "between_sections": + content = CMVN.replace("", blank + "") + else: + content = CMVN.replace("", blank + "") + path = tmp_path / "blank lines.mvn" + path.write_text(content, encoding="utf-8") + + frontend = frontend_class(cmvn_file=str(path)) + + np.testing.assert_array_equal(frontend.cmvn, [[1, -2], [0.5, 2]]) + + +@pytest.mark.parametrize( + "content", + [ + "", + " \t\n", + "\n\n", + " 2 2\n", + " 2 2\n", + " 2 2\n 1 [ 1 -2 ]\n", + " 2 2\n 1 [ 0.5 2 ]\n", + CMVN.replace("\n", " "), + ], + ids=[ + "empty", + "blank", + "no_stats", + "truncated_shift", + "truncated_scale", + "shift_only", + "scale_only", + "compact", + ], +) +def test_incomplete_cmvn_fails_during_construction(frontend_class, tmp_path, content): + path = tmp_path / "incomplete.mvn" + path.write_text(content, encoding="utf-8") + + with pytest.raises(ValueError) as error: + frontend_class(cmvn_file=str(path)) + + assert str(path) in str(error.value) + + +def test_shipped_cmvn_is_unchanged_by_blank_lines(frontend_class, tmp_path): + shipped = ROOT / "runtime/triton_gpu/model_repo_sense_voice_small/feature_extractor/am.mvn" + original = frontend_class(cmvn_file=str(shipped), lfr_m=7, lfr_n=6, dither=0.0) + path = tmp_path / "shipped-with-blanks.mvn" + path.write_text("\n" + "\n \t\n".join(shipped.read_text().splitlines()) + "\n\n") + edited = frontend_class(cmvn_file=str(path), lfr_m=7, lfr_n=6, dither=0.0) + + assert original.cmvn.shape == (2, 560) + assert np.isfinite(original.cmvn).all() + np.testing.assert_array_equal(edited.cmvn, original.cmvn) + # Real filterbank + LFR + CMVN on a deterministic waveform, not a model stub. + waveform = np.sin(2 * np.pi * 440 * np.arange(1600) / 16000).astype(np.float32) * 0.1 + original_features, _ = original.fbank(waveform) + edited_features, _ = edited.fbank(waveform) + original_features, original_length = original.lfr_cmvn(original_features) + edited_features, edited_length = edited.lfr_cmvn(edited_features) + assert edited_length == original_length + np.testing.assert_array_equal(edited_features, original_features)