Skip to content

Commit 4597a2a

Browse files
justinchubyCopilot
andauthored
Add Fun-ASR-Nano and SenseVoiceSmall support (#236)
This pull request introduces support for the SenseVoiceSmall speech recognition model, including a new ASR example script and shared audio preprocessing utilities. It also expands the model registry and configuration to accommodate Fun-ASR and SenseVoiceSmall models, and exposes new SANM encoder components. The most important changes are grouped below: **New ASR Example and Utilities:** * Added `examples/sensevoice_small.py`, a complete CLI script for running ONNX-based speech recognition with SenseVoiceSmall, including CTC decoding, language control, and chunked inference. * Introduced `examples/asr_utils.py` with reusable audio preprocessing functions: audio loading, log-mel fbank computation, LFR stacking, CMVN loading, and full frontend pipeline. **Model Registry and Configuration Updates:** * Registered `FunASRForConditionalGeneration` and `SenseVoiceSmallModel` in the model registry, with proper task types and default HuggingFace model IDs. [[1]](diffhunk://#diff-8d5c42d97d876ab3b86d0698ef084e04c65db26af0cea79f0d9dd002cf2ebf69R97) [[2]](diffhunk://#diff-8d5c42d97d876ab3b86d0698ef084e04c65db26af0cea79f0d9dd002cf2ebf69R121) [[3]](diffhunk://#diff-8d5c42d97d876ab3b86d0698ef084e04c65db26af0cea79f0d9dd002cf2ebf69R512-R522) [[4]](diffhunk://#diff-8d5c42d97d876ab3b86d0698ef084e04c65db26af0cea79f0d9dd002cf2ebf69R857-R858) * Updated the registry to assign the "fun_asr" and "sensevoice_small" models to the "qwen" organization for fallback detection. * Extended `AudioConfig` with additional fields for Fun-ASR/SenseVoice encoder configuration (e.g., `tp_num_blocks`, `adaptor_proj_dim`). **Component Exposure:** * Exported SANM encoder components (`SANMAttention`, `SANMEncoderLayer`, `SANMFFN`) in `mobius.components.__init__` and imported them from the internal module. [[1]](diffhunk://#diff-8eacab4d5682bac795dd69c12af6cfb6e6ac9198de7bf37ed06fc748a4682d0cR87-R89) [[2]](diffhunk://#diff-8eacab4d5682bac795dd69c12af6cfb6e6ac9198de7bf37ed06fc748a4682d0cR247-R251) --------- Signed-off-by: Justin Chu <justinchu@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d39b13b commit 4597a2a

25 files changed

Lines changed: 3624 additions & 0 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,6 @@ cache_dir/**
224224
dashboard_preview.html
225225
docs/feature-flags.md
226226
.copilot/
227+
*.mp3
228+
*.m4a
229+
*.wav

examples/asr_utils.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Shared audio preprocessing utilities for ASR examples.
5+
6+
Provides the LFR fbank frontend pipeline shared by Fun-ASR-Nano and
7+
SenseVoiceSmall: audio loading, mel spectrogram, LFR stacking, and CMVN.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import numpy as np
13+
14+
SAMPLE_RATE = 16000
15+
LFR_M = 7 # LFR stack factor
16+
LFR_N = 6 # LFR stride
17+
N_MELS = 80
18+
19+
20+
def load_audio_file(path: str, sample_rate: int = SAMPLE_RATE) -> np.ndarray:
21+
"""Load audio file and resample to target sample rate (mono, float32)."""
22+
import torchaudio
23+
24+
waveform, sr = torchaudio.load(path)
25+
if waveform.shape[0] > 1:
26+
waveform = waveform.mean(dim=0, keepdim=True)
27+
if sr != sample_rate:
28+
waveform = torchaudio.functional.resample(waveform, sr, sample_rate)
29+
return waveform.squeeze(0).numpy().astype(np.float32)
30+
31+
32+
def compute_fbank(
33+
audio: np.ndarray,
34+
*,
35+
sample_rate: int = SAMPLE_RATE,
36+
n_mels: int = N_MELS,
37+
frame_length: float = 25.0,
38+
frame_shift: float = 10.0,
39+
) -> np.ndarray:
40+
"""Compute log-mel filterbank features using torchaudio (Kaldi-compatible).
41+
42+
Args:
43+
audio: 1-D waveform array (float32, mono).
44+
sample_rate: Audio sample rate in Hz.
45+
n_mels: Number of mel filter bank bins.
46+
frame_length: Frame length in ms.
47+
frame_shift: Frame shift (hop) in ms.
48+
49+
Returns:
50+
``(T, n_mels)`` fbank feature matrix.
51+
"""
52+
import torch
53+
import torchaudio
54+
55+
wav = torch.from_numpy(audio).float().unsqueeze(0)
56+
fbank = torchaudio.compliance.kaldi.fbank(
57+
wav,
58+
num_mel_bins=n_mels,
59+
sample_frequency=sample_rate,
60+
window_type="hamming",
61+
frame_length=frame_length,
62+
frame_shift=frame_shift,
63+
dither=0.0,
64+
)
65+
return fbank.numpy()
66+
67+
68+
def apply_lfr(fbank: np.ndarray, lfr_m: int = LFR_M, lfr_n: int = LFR_N) -> np.ndarray:
69+
"""Apply Low Frame Rate stacking and subsampling.
70+
71+
Stacks ``lfr_m`` consecutive frames and subsamples every ``lfr_n`` frames,
72+
producing features of dimension ``lfr_m * n_mels`` (typically 7*80 = 560).
73+
74+
Left-pads by ``(lfr_m - 1) // 2`` frames (FunASR convention) so that the
75+
first output frame is centered on the first input frame.
76+
77+
Returns array of shape ``(T_out, lfr_m * n_mels)``.
78+
"""
79+
# Left-pad by (lfr_m - 1) // 2 frames (FunASR convention)
80+
left_pad = (lfr_m - 1) // 2 # = 3 for lfr_m=7
81+
fbank = np.pad(fbank, ((left_pad, 0), (0, 0)), mode="edge")
82+
83+
num_frames = fbank.shape[0]
84+
pad_len = (lfr_n - (num_frames % lfr_n)) % lfr_n
85+
if pad_len > 0:
86+
fbank = np.pad(fbank, ((0, pad_len), (0, 0)), mode="edge")
87+
t_padded = fbank.shape[0]
88+
89+
lfr_frames = []
90+
for i in range(0, t_padded, lfr_n):
91+
end = min(i + lfr_m, t_padded)
92+
chunk = fbank[i:end]
93+
if chunk.shape[0] < lfr_m:
94+
chunk = np.pad(chunk, ((0, lfr_m - chunk.shape[0]), (0, 0)), mode="edge")
95+
lfr_frames.append(chunk.flatten())
96+
return np.array(lfr_frames)
97+
98+
99+
def load_cmvn(cmvn_path: str) -> tuple[np.ndarray, np.ndarray]:
100+
"""Load CMVN stats from Kaldi am.mvn file.
101+
102+
Returns (means, vars) arrays of shape ``(560,)`` each.
103+
CMVN is applied as: ``features = (features + means) * vars``
104+
"""
105+
with open(cmvn_path) as f:
106+
lines = f.readlines()
107+
means = None
108+
variances = None
109+
for i, line in enumerate(lines):
110+
parts = line.split()
111+
if parts[0] == "<AddShift>":
112+
next_parts = lines[i + 1].split()
113+
if next_parts[0] == "<LearnRateCoef>":
114+
means = np.array(next_parts[3:-1], dtype=np.float32)
115+
elif parts[0] == "<Rescale>":
116+
next_parts = lines[i + 1].split()
117+
if next_parts[0] == "<LearnRateCoef>":
118+
variances = np.array(next_parts[3:-1], dtype=np.float32)
119+
if means is None or variances is None:
120+
raise ValueError(f"Failed to parse CMVN from {cmvn_path}")
121+
return means, variances
122+
123+
124+
def preprocess_audio(
125+
audio: np.ndarray,
126+
*,
127+
sample_rate: int = SAMPLE_RATE,
128+
n_mels: int = N_MELS,
129+
cmvn: tuple[np.ndarray, np.ndarray] | None = None,
130+
) -> np.ndarray:
131+
"""Full frontend: fbank → LFR → CMVN → ``(1, T, 560)``.
132+
133+
Args:
134+
audio: 1-D waveform (float32, mono, at ``sample_rate``).
135+
sample_rate: Audio sample rate.
136+
n_mels: Mel bins.
137+
cmvn: Optional ``(means, vars)`` from :func:`load_cmvn`.
138+
139+
Returns:
140+
``(1, T_lfr, lfr_m * n_mels)`` feature tensor.
141+
"""
142+
fbank = compute_fbank(audio, sample_rate=sample_rate, n_mels=n_mels)
143+
lfr = apply_lfr(fbank)
144+
if cmvn is not None:
145+
means, variances = cmvn
146+
lfr = (lfr + means) * variances
147+
return lfr[np.newaxis, :, :]

0 commit comments

Comments
 (0)