Skip to content

Export SenseVoice ASR models to Ascend NPU 910B - #2707

Merged
csukuangfj merged 4 commits into
k2-fsa:masterfrom
csukuangfj:export-sense-voice-ascend
Oct 21, 2025
Merged

csukuangfj merged 4 commits into
k2-fsa:masterfrom
csukuangfj:export-sense-voice-ascend

Conversation

@csukuangfj

@csukuangfj csukuangfj commented Oct 21, 2025 •

Copy link
Copy Markdown
Collaborator

You can find the exported models at
https://github.com/k2-fsa/sherpa-onnx/releases/tag/asr-models

Screenshot 2025-10-21 at 18 24 20

To test them, just download, unzip and run the test_om.py extracted from them.

Summary by CodeRabbit

  • New Features
    • Added support for exporting SenseVoice models to Ascend NPU accelerator format through automated deployment workflows.
    • Enables model validation and real-time inference testing on Ascend NPU hardware to verify correctness.
    • Provides streamlined model conversion pipeline with automatic artifact packaging, versioning, and release capabilities.

@coderabbitai

coderabbitai Bot commented Oct 21, 2025 •

Copy link
Copy Markdown

Walkthrough

Adds a GitHub Actions workflow for automated SenseVoice model export to Ascend NPU, along with supporting Python scripts for ONNX export, model wrapping, and OM format inference testing. Supports multiple frameworks and OS configurations via matrix builds.

Changes

Cohort / File(s) Change Summary
CI/CD Workflow
.github/workflows/export-sense-voice-to-ascend-npu.yaml
New GitHub Actions workflow automating SenseVoice model export to Ascend NPU. Triggers on branch pushes and dispatch. Runs matrix builds across OS and Python versions for FunASR and WSYue-ASR frameworks. Includes steps for environment validation, dependency installation, model download, ONNX export, OM conversion via atc, artifact packaging, and conditional GitHub Releases.
Model Export
scripts/sense-voice/ascend-npu/export_onnx.py
New Python script for exporting SenseVoice PyTorch models to ONNX format. Loads SentencePiece tokenizer, generates tokens.txt, instantiates SenseVoiceSmall model with CMVN, wraps model via ModelWrapper class, and exports to model.onnx with specified input/output names and dynamic axes.
OM Model Testing
scripts/sense-voice/ascend-npu/test_om.py
New Python script for inference testing against Ascend OM models. Includes OmModel wrapper for AI model sessions, audio loading and resampling utilities, Kaldi Fbank feature extraction with windowing, and inference pipeline that processes audio to logits, deduplicates tokens, and maps token IDs to text.
Model Reference
scripts/sense-voice/ascend-npu/torch_model.py
New file reference to relative path ../rknn/torch_model.py (file content not provided in diff).

Sequence Diagram(s)

sequenceDiagram
    participant GHA as GitHub Actions
    participant Checkout
    participant Env as Environment Setup
    participant Deps as Dependencies
    participant Model as SenseVoice Model
    participant Export as ONNX Export
    participant ATC as atc Converter
    participant Package as Packaging
    participant Release as GitHub Releases

    GHA->>Checkout: Trigger on push/dispatch
    Checkout->>Env: Setup Python & Toolchain
    Env->>Deps: Install numpy, onnx, torch, libs
    Deps->>Model: Download pre-trained model
    Model->>Export: Load & wrap model (export_onnx.py)
    Export->>Export: Generate ONNX with CMVN
    Export->>ATC: Export model.onnx
    ATC->>ATC: Convert ONNX → Ascend OM
    ATC->>Package: Create OM model artifact
    Package->>Package: Compress to tar.bz2
    alt Repository owner match
        Package->>Release: Upload to GitHub Releases
    end
    Release->>GHA: Workflow complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The PR introduces heterogeneous changes across CI/CD workflow and multiple Python scripts with moderate logic density (model wrapping, ONNX export, Kaldi feature extraction, inference pipeline). The workflow includes complex matrix builds and conditional logic; the scripts introduce new model handling and audio processing logic. Changes are primarily new code rather than refactoring, and scope spans several files with distinct purposes.

Possibly related PRs

  • k2-fsa/sherpa-onnx#2592: Implements analogous SenseVoice export pipeline for RKNN target with similar export_onnx.py and model wrapping patterns
  • k2-fsa/sherpa-onnx#2692: Adds CI for exporting ASR models to vendor NPUs and shares nested "state_dict" checkpoint handling logic

Suggested labels

size:L

Poem

🐰 Hop-hop, the voice models now take flight,
To Ascend NPU in OM format bright,
ONNX wrapping, features extracted with care,
From PyTorch to inference, through CI workflows fair!
Let the workflows run and the models converge—
A whisker-perfect merge! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "Export SenseVoice ASR models to Ascend NPU 910B" directly and clearly describes the primary objective of the changeset. The pull request adds a new GitHub Actions workflow to automate the export process, introduces ONNX export and model testing scripts, and delivers exported SenseVoice models for Ascend NPU 910B. The title is concise, specific, and avoids vague terminology, making it easy for a teammate to understand the main change when scanning pull request history.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Oct 21, 2025
@csukuangfj
csukuangfj merged commit ece3183 into k2-fsa:master Oct 21, 2025
1 check passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (10)
.github/workflows/export-sense-voice-to-ascend-npu.yaml (2)

55-61: CANN env sourcing duplicated; consider a small helper step

You source set_env.sh and override LD_LIBRARY_PATH twice. Extract into a single reusable step or composite action to reduce drift.

Also applies to: 99-110, 163-174


78-87: Add SHA256 checksum verification to hardened supply chain security

The review comment is accurate: all URLs use /resolve/main/, which points to the repository branch HEAD and can change when the repo is updated. HuggingFace files have SHA256 checksums available via the metadata API, making verification implementable.

Affected sections:

  • Lines 78–80: FunASR model downloads (am.mvn, model.pt, chn_jpn_yue_eng_ko_spectok.bpe.model)
  • Lines 82–86, 90–91: FunASR test files and metadata
  • Lines 141–149: WSYue-ASR model downloads (sensevoice_small_yue/model.pt, am.mvn, chn_jpn_yue_eng_ko_spectok.bpe.model)
  • Lines 150–153: WSYue-ASR test file loop
  • Lines 156–158: WSYue-ASR metadata downloads

Recommended approach: retrieve checksums from HuggingFace Hub API or document them in a separate manifest, then add inline sha256sum -c verification for each critical file (especially model files).

scripts/sense-voice/ascend-npu/export_onnx.py (3)

37-49: ModelWrapper is defined but unused; simplify or export the wrapper intentionally

You build ModelWrapper returning (part1, part2) but export model.m with a single logits output. This leaves dead code and can confuse readers. Either export the wrapper (two outputs) or drop the wrapper and export the base model. Given test_om consumes a single logits tensor, prefer removing the wrapper.

Apply this diff to simplify:

-class ModelWrapper(torch.nn.Module):
-    def __init__(self, m):
-        super().__init__()
-        self.m = m
-
-    def forward(self, x, prompt):
-        logits = self.m(x[None], prompt)[0]
-        part1 = logits[:4]
-        part2 = logits[4:]
-        part1 = part1.reshape(4, 25055)
-        part2 = part2.reshape(x.size(0), 25055)
-        return part1, part2
+# Model exports a single 'logits' tensor; wrapper not needed.
 
@@
-    model = ModelWrapper(model)
-    model.eval()
-
     x = torch.randn(1, 93, 560, dtype=torch.float32)
@@
-    torch.onnx.export(
-        model.m,
+    torch.onnx.export(
+        model,
         (x, prompt),
         filename,
         opset_version=opset_version,
         input_names=["x", "prompt"],
-        output_names=["logits"],
+        output_names=["logits"],
         dynamic_axes={
             "x": {0: "N", 1: "T"},
             "logits": {0: "N", 1: "T_4"},
         },
     )

Also applies to: 83-95


59-67: State dict normalization is good; add strict=False guard for resilience

Third‑party checkpoints sometimes omit buffers or have prefix drift. Using strict=False avoids brittle failures while still logging mismatches.

-    model.load_state_dict(state_dict)
+    missing, unexpected = model.load_state_dict(state_dict, strict=False)
+    if missing or unexpected:
+        print("load_state_dict warnings:", {"missing": missing, "unexpected": unexpected})

51-56: SentencePiece load error path

If the .bpe.model fails to download, sp.load returns False without raising. Add an assert to fail fast.

-    sp.load("./chn_jpn_yue_eng_ko_spectok.bpe.model")
+    ok = sp.load("./chn_jpn_yue_eng_ko_spectok.bpe.model")
+    assert ok, "Failed to load SentencePiece model"
scripts/sense-voice/ascend-npu/test_om.py (5)

111-116: Minor cleanups: remove unused constants; fix typo (ja) and comments

Several language_* constants and without_itn are unused; drop or group into a dict. Also language_ya should be language_ja. Ensure commented lines start with # .

Apply this diff:

-    language_auto = 0
-    language_zh = 3
-    language_en = 4
-    language_yue = 7
-    language_ya = 11
-    language_ko = 12
-    language_nospeech = 13
+    language_auto, language_zh, language_en = 0, 3, 4
+    language_yue, language_ja, language_ko, language_nospeech = 7, 11, 12, 13
@@
-    with_itn = 14
-    without_itn = 15
+    with_itn, without_itn = 14, 15  # keep if you plan to use later; otherwise remove
@@
-    # language = np.array([language], dtype=np.int32)
-    # text_norm = np.array([text_norm], dtype=np.int32)
+    # language = np.array([language], dtype=np.int32)
+    # text_norm = np.array([text_norm], dtype=np.int32)

Or simply remove the unused variables entirely. As per static analysis hints.

Also applies to: 132-137


81-84: Fbank scaling: avoid Python list conversion in hot path

accept_waveform can take a NumPy array; converting to list adds overhead. Prefer np.asarray(samples * 32768, dtype=np.float32).

-    online_fbank.accept_waveform(sample_rate, (samples * 32768).tolist())
+    online_fbank.accept_waveform(sample_rate, np.asarray(samples * 32768, dtype=np.float32))

14-26: Device/config introspection is useful; add a short check that model.om exists

Prints are helpful; add an existence check to fail fast with a clear message if users forget to run atc.

     def __init__(self):
-        self.model = InferSession(device_id=0, model_path="./model.om", debug=False)
+        import os
+        assert os.path.exists("./model.om"), "model.om not found; run atc conversion first"
+        self.model = InferSession(device_id=0, model_path="./model.om", debug=False)

101-110: Optional: add CLI args for wav path and device id

Hardcoded zh.wav and device 0 are fine for the README, but a simple argparse improves reusability.

I can send a small argparse patch if you want.


158-163: Token mapping assumes contiguous IDs; handle explicit ids from file

load_tokens ignores the numeric id in tokens.txt and uses line index. Safer to parse both fields and use the file’s id column.

-    tokens = load_tokens("./tokens.txt")
-    text = "".join([tokens[i] for i in ids])
+    tokens = {}
+    with open("./tokens.txt", encoding="utf-8") as f:
+        for line in f:
+            piece, tok_id = line.rstrip("\n").rsplit(" ", 1)
+            tokens[int(tok_id)] = piece
+    text = "".join([tokens.get(i, "") for i in ids])
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 194795e and f9e767e.

📒 Files selected for processing (4)
  • .github/workflows/export-sense-voice-to-ascend-npu.yaml (1 hunks)
  • scripts/sense-voice/ascend-npu/export_onnx.py (1 hunks)
  • scripts/sense-voice/ascend-npu/test_om.py (1 hunks)
  • scripts/sense-voice/ascend-npu/torch_model.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
scripts/sense-voice/ascend-npu/export_onnx.py (1)
scripts/sense-voice/ascend-npu/torch_model.py (8)
  • SenseVoiceSmall (541-602)
  • forward (41-46)
  • forward (68-70)
  • forward (201-220)
  • forward (250-329)
  • forward (336-344)
  • forward (452-478)
  • forward (592-602)
scripts/sense-voice/ascend-npu/test_om.py (3)
sherpa-onnx/csrc/ten-vad-model.cc (1)
  • frame_opts (237-255)
scripts/sense-voice/ascend-npu/export_onnx.py (1)
  • main (52-96)
scripts/sense-voice/ascend-npu/torch_model.py (1)
  • argmax (527-538)
🪛 Flake8 (7.3.0)
scripts/sense-voice/ascend-npu/test_om.py

[error] 4-4: 'argparse' imported but unused

(F401)


[error] 10-10: 'torch' imported but unused

(F401)


[error] 118-118: local variable 'language_zh' is assigned to but never used

(F841)


[error] 119-119: local variable 'language_en' is assigned to but never used

(F841)


[error] 120-120: local variable 'language_yue' is assigned to but never used

(F841)


[error] 121-121: local variable 'language_ya' is assigned to but never used

(F841)


[error] 122-122: local variable 'language_ko' is assigned to but never used

(F841)


[error] 123-123: local variable 'language_nospeech' is assigned to but never used

(F841)


[error] 128-128: local variable 'without_itn' is assigned to but never used

(F841)


[error] 142-142: block comment should start with '# '

(E265)

🪛 Ruff (0.14.1)
scripts/sense-voice/ascend-npu/test_om.py

118-118: Local variable language_zh is assigned to but never used

Remove assignment to unused variable language_zh

(F841)


119-119: Local variable language_en is assigned to but never used

Remove assignment to unused variable language_en

(F841)


120-120: Local variable language_yue is assigned to but never used

Remove assignment to unused variable language_yue

(F841)


121-121: Local variable language_ya is assigned to but never used

Remove assignment to unused variable language_ya

(F841)


122-122: Local variable language_ko is assigned to but never used

Remove assignment to unused variable language_ko

(F841)


123-123: Local variable language_nospeech is assigned to but never used

Remove assignment to unused variable language_nospeech

(F841)


128-128: Local variable without_itn is assigned to but never used

Remove assignment to unused variable without_itn

(F841)

🔇 Additional comments (3)
.github/workflows/export-sense-voice-to-ascend-npu.yaml (2)

198-207: Release token scope and tag reuse

Releasing to k2-fsa with tag asr-models overwrites artifacts on every run. That’s intended? If not, append date/model name to the tag or use release_name with run-id. Ensure ${{ secrets.UPLOAD_GH_SHERPA_ONNX_TOKEN }} is repo‑scoped with contents:write.

Also applies to: 209-216


33-37: Fix PyTorch wheel source URL to ensure install succeeds on CI

The PyTorch wheel URL -f https://download.pytorch.org/whl/torch needs to use the stable page. Change line 67 to:

                  torch==2.0.0+cpu -f https://download.pytorch.org/whl/torch_stable.html \

Also add a comment explaining the numpy<2 pin to document the version constraint reasoning.

(Verification confirmed the torch_stable.html URL resolves successfully.)

Likely an incorrect or invalid review comment.

scripts/sense-voice/ascend-npu/torch_model.py (1)

1-1: Symlink is valid and working on Linux, but Windows compatibility concern is legitimate

Verification confirms the symlink is correct:

  • Target ../rknn/torch_model.py properly resolves to ./scripts/sense-voice/rknn/torch_model.py
  • Target file exists and git tracking is valid (mode 120000)
  • Works correctly on Linux CI

The concern about Windows users with core.symlinks=false is valid—they would see a 1-line text file instead of the actual module, causing import failures. The recommendation to either provide a real module during packaging or document the symlink requirement is sound for cross-platform support.

Comment on lines +39 to +45
return self.model.infer([x, prompt], mode="static", custom_sizes=10000000)[0][0]
logits_part1, logits_part2 = self.model.infer(
[x, prompt], mode="dymshape", custom_sizes=10000000
)
logits = np.concatenate([logits_part1, logits_part2], axis=0)
return logits

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unreachable code and stale two‑output merge path

Early returns make the merge path dead. Remove the static‑mode branch and the (part1, part2) concat logic to avoid confusion.

Apply this diff:

-        return self.model.infer([x, prompt], mode="static", custom_sizes=10000000)[0][0]
-        logits_part1, logits_part2 = self.model.infer(
-            [x, prompt], mode="dymshape", custom_sizes=10000000
-        )
-        logits = np.concatenate([logits_part1, logits_part2], axis=0)
-        return logits
+        # Single-output logits path only

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In scripts/sense-voice/ascend-npu/test_om.py around lines 39 to 45, the early
return using mode="static" makes the subsequent dynamic-shape two-output path
dead and leaves stale concat logic; remove the static-mode return and the
logits_part1/logits_part2 capture and concatenation, and instead call
self.model.infer([x, prompt], mode="dymshape", custom_sizes=10000000) once and
return its single output (or the appropriate element of the returned structure)
so only the dynamic-shape path remains.

Comment on lines +85 to +96
features = np.stack(
[online_fbank.get_frame(i) for i in range(online_fbank.num_frames_ready)]
)
assert features.data.contiguous is True
assert features.dtype == np.float32, features.dtype

T = (features.shape[0] - window_size) // window_shift + 1
features = np.lib.stride_tricks.as_strided(
features,
shape=(T, features.shape[1] * window_size),
strides=((window_shift * features.shape[1]) * 4, 4),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incorrect NumPy contiguity check; potential negative T for short audio

  • features.data.contiguous is not a NumPy API and will raise. Use features.flags.c_contiguous.
  • Guard T from going negative when audio is shorter than window_size.

Apply this diff:

-    assert features.data.contiguous is True
-    assert features.dtype == np.float32, features.dtype
+    assert features.flags.c_contiguous, "features must be C-contiguous"
+    assert features.dtype == np.float32, features.dtype
@@
-    T = (features.shape[0] - window_size) // window_shift + 1
+    num_frames = features.shape[0]
+    if num_frames < window_size:
+        return np.zeros((0, features.shape[1] * window_size), dtype=np.float32)
+    T = (num_frames - window_size) // window_shift + 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
features = np.stack(
[online_fbank.get_frame(i) for i in range(online_fbank.num_frames_ready)]
)
assert features.data.contiguous is True
assert features.dtype == np.float32, features.dtype
T = (features.shape[0] - window_size) // window_shift + 1
features = np.lib.stride_tricks.as_strided(
features,
shape=(T, features.shape[1] * window_size),
strides=((window_shift * features.shape[1]) * 4, 4),
)
features = np.stack(
[online_fbank.get_frame(i) for i in range(online_fbank.num_frames_ready)]
)
assert features.flags.c_contiguous, "features must be C-contiguous"
assert features.dtype == np.float32, features.dtype
num_frames = features.shape[0]
if num_frames < window_size:
return np.zeros((0, features.shape[1] * window_size), dtype=np.float32)
T = (num_frames - window_size) // window_shift + 1
features = np.lib.stride_tricks.as_strided(
features,
shape=(T, features.shape[1] * window_size),
strides=((window_shift * features.shape[1]) * 4, 4),
)

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

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant