Skip to content

Commit 6762232

Browse files
authored
Add C++ runtime support for Moonshine v2 (#3232)
This PR adds C++ runtime support for Moonshine v2 models in sherpa-onnx. The implementation introduces a merged decoder architecture that differs from v1's separate cached/uncached decoder approach. Changes: - Added new model and decoder classes for Moonshine v2 with merged decoder support - Updated configuration to support both v1 (4 models) and v2 (2 models) architectures - Added Python script utilities for token generation and model testing
1 parent aca8652 commit 6762232

15 files changed

Lines changed: 1023 additions & 47 deletions

scripts/moonshine/v2/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Introduction
2+
3+
This folder contains scripts for moonshine v2 models that use
4+
- encoder_model.onnx
5+
- decoder_model_merged.onnx
6+
or
7+
- encoder_model.ort
8+
- decoder_model_merged.ort
9+
10+
Note that you need to use [./generate_tokens.py](./generate_tokens.py)
11+
to generate `tokens.txt` from `tokenizer.bin` for moonshine v2 models.
12+
13+
See also https://github.com/moonshine-ai/moonshine/pull/73
14+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Xiaomi Corp. (authors: Fangjun Kuang)
3+
4+
import base64
5+
from test import BinTokenizer
6+
7+
8+
def main():
9+
tokenizer = BinTokenizer("./tokenizer.bin")
10+
11+
with open("./tokens.txt", "w", encoding="utf-8") as f:
12+
for idx, token_bytes in enumerate(tokenizer.tokens):
13+
b64 = base64.b64encode(token_bytes).decode("ascii")
14+
f.write(f"{b64} {idx}\n")
15+
16+
print("Saved to ./tokens.txt")
17+
18+
19+
if __name__ == "__main__":
20+
main()

scripts/moonshine/v2/test.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Xiaomi Corp. (authors: Fangjun Kuang)
3+
4+
5+
import librosa
6+
import numpy as np
7+
import onnxruntime as ort
8+
9+
10+
class BinTokenizer:
11+
def __init__(self, path):
12+
self.tokens = self._load(path)
13+
14+
def _load(self, path):
15+
tokens = []
16+
with open(path, "rb") as f:
17+
data = f.read()
18+
19+
i = 0
20+
while i < len(data):
21+
first = data[i]
22+
i += 1
23+
24+
if first == 0:
25+
tokens.append(b"") # store as bytes
26+
continue
27+
28+
if first < 128:
29+
length = first
30+
else:
31+
second = data[i]
32+
i += 1
33+
length = (second * 128) + (first - 128)
34+
35+
token_bytes = data[i : i + length]
36+
i += length
37+
tokens.append(token_bytes) # store as bytes, do NOT decode here
38+
39+
return tokens
40+
41+
def decode(self, ids):
42+
# join bytes first, then decode as UTF-8
43+
byte_stream = b"".join(self.tokens[i] for i in ids if i < len(self.tokens))
44+
text = byte_stream.decode("utf-8", errors="replace")
45+
return text.replace("▁", " ").strip()
46+
47+
48+
class OnnxModel:
49+
def __init__(self, encoder, decoder):
50+
session_opts = ort.SessionOptions()
51+
session_opts.inter_op_num_threads = 1
52+
session_opts.intra_op_num_threads = 1
53+
54+
self.session_opts = session_opts
55+
56+
self.encoder = ort.InferenceSession(
57+
encoder,
58+
sess_options=self.session_opts,
59+
providers=["CPUExecutionProvider"],
60+
)
61+
62+
self.decoder = ort.InferenceSession(
63+
decoder,
64+
sess_options=self.session_opts,
65+
providers=["CPUExecutionProvider"],
66+
)
67+
68+
print(f"----{encoder} input----")
69+
for i in self.encoder.get_inputs():
70+
print(i)
71+
72+
print(f"----{encoder} output----")
73+
74+
for i in self.encoder.get_outputs():
75+
print(i)
76+
77+
print(f"----{decoder} input----")
78+
for i in self.decoder.get_inputs():
79+
print(i)
80+
81+
print(f"----{decoder} output----")
82+
83+
for i in self.decoder.get_outputs():
84+
print(i)
85+
86+
self.need_decoder_attention_mask = False
87+
88+
for n in self.decoder.get_inputs():
89+
if "key_values" in n.name and not hasattr(self, "num_head"):
90+
self.num_head = n.shape[1]
91+
self.head_dim = n.shape[3]
92+
93+
if "encoder_attention_mask" in n.name:
94+
self.need_decoder_attention_mask = True
95+
if self.need_decoder_attention_mask:
96+
# [ mask, ids, encoder_out, states, use_cache_branch]
97+
self.num_layers = (len(self.decoder.get_inputs()) - 4) // 4
98+
else:
99+
# [ ids, encoder_out, states, use_cache_branch]
100+
self.num_layers = (len(self.decoder.get_inputs()) - 3) // 4
101+
102+
self.bos = 1
103+
self.eos = 2
104+
105+
def get_decoder_init_states(self):
106+
states = []
107+
shape = [1, self.num_head, 0, self.head_dim]
108+
for i in range(self.num_layers):
109+
decoder_key = np.zeros(shape, dtype=np.float32)
110+
decoder_value = np.zeros(shape, dtype=np.float32)
111+
encoder_key = np.zeros(shape, dtype=np.float32)
112+
encoder_value = np.zeros(shape, dtype=np.float32)
113+
114+
states.append(decoder_key)
115+
states.append(decoder_value)
116+
states.append(encoder_key)
117+
states.append(encoder_value)
118+
119+
return states
120+
121+
def run_encoder(self, audio):
122+
audio = audio[None, :] # batch=1
123+
124+
if len(self.encoder.get_inputs()) > 1:
125+
mask = np.ones_like(audio, dtype=np.int64)
126+
127+
outputs = self.encoder.run(
128+
[
129+
self.encoder.get_outputs()[0].name,
130+
],
131+
{
132+
self.encoder.get_inputs()[0].name: audio,
133+
self.encoder.get_inputs()[1].name: mask,
134+
},
135+
)
136+
else:
137+
outputs = self.encoder.run(
138+
[
139+
self.encoder.get_outputs()[0].name,
140+
],
141+
{
142+
self.encoder.get_inputs()[0].name: audio,
143+
},
144+
)
145+
return outputs[0] # last_hidden_state
146+
147+
def run_decoder(self, token_id, encoder_out, states):
148+
inputs = dict()
149+
if self.need_decoder_attention_mask:
150+
mask = np.ones((1, encoder_out.shape[1]), dtype=np.int64)
151+
inputs[self.decoder.get_inputs()[0].name] = mask
152+
153+
inputs[self.decoder.get_inputs()[1].name] = np.array(
154+
[[token_id]], dtype=np.int64
155+
)
156+
inputs[self.decoder.get_inputs()[2].name] = encoder_out
157+
158+
for i in range(len(states)):
159+
inputs[self.decoder.get_inputs()[3 + i].name] = states[i]
160+
161+
inputs[self.decoder.get_inputs()[-1].name] = np.array(
162+
[token_id != self.bos], dtype=bool
163+
)
164+
else:
165+
inputs[self.decoder.get_inputs()[0].name] = np.array(
166+
[[token_id]], dtype=np.int64
167+
)
168+
inputs[self.decoder.get_inputs()[1].name] = encoder_out
169+
170+
for i in range(len(states)):
171+
inputs[self.decoder.get_inputs()[2 + i].name] = states[i]
172+
173+
inputs[self.decoder.get_inputs()[-1].name] = np.array(
174+
[token_id != self.bos], dtype=bool
175+
)
176+
177+
outputs = self.decoder.run(None, inputs)
178+
179+
logits = outputs[0]
180+
if token_id == self.bos:
181+
states = outputs[1:]
182+
else:
183+
for i in range(self.num_layers):
184+
states[4 * i + 0] = outputs[1 + 4 * i + 0]
185+
states[4 * i + 1] = outputs[1 + 4 * i + 1]
186+
187+
return logits, states
188+
189+
190+
def load_audio(filename):
191+
audio, sample_rate = librosa.load(filename, sr=16000)
192+
assert sample_rate == 16000, sample_rate
193+
assert len(audio.shape) == 1, audio.shape
194+
195+
return np.ascontiguousarray(audio[: 8 * 16000])
196+
197+
198+
def main():
199+
model = OnnxModel(
200+
encoder="./tiny/encoder_model.ort",
201+
decoder="./tiny/decoder_model_merged.ort",
202+
#
203+
# encoder="./tiny-zh/encoder_model.onnx",
204+
# decoder="./tiny-zh/decoder_model_merged.onnx",
205+
#
206+
# encoder="./base-zh/encoder_model.ort",
207+
# decoder="./base-zh/decoder_model_merged.ort",
208+
)
209+
samples = load_audio("./two_cities.wav")
210+
print("samples.shape", samples.shape)
211+
encoder_out = model.run_encoder(samples)
212+
print("encoder_out.shape", encoder_out.shape)
213+
states = model.get_decoder_init_states()
214+
tokens = []
215+
216+
max_len = int(len(samples) / 16000 * 15)
217+
218+
token_id = model.bos
219+
220+
for step in range(max_len):
221+
logits, states = model.run_decoder(token_id, encoder_out, states)
222+
token_id = int(np.argmax(logits[0, 0]))
223+
if token_id == model.eos:
224+
break
225+
tokens.append(token_id)
226+
print(tokens)
227+
228+
tokenizer = BinTokenizer("./base-zh/tokenizer.bin")
229+
text = tokenizer.decode(tokens)
230+
print("text", text)
231+
232+
233+
if __name__ == "__main__":
234+
main()

sherpa-onnx/csrc/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ set(sources
4646
offline-medasr-ctc-model.cc
4747
offline-model-config.cc
4848
offline-moonshine-greedy-search-decoder.cc
49+
offline-moonshine-v2-greedy-search-decoder.cc
4950
offline-moonshine-model-config.cc
51+
offline-moonshine-model-v2.cc
5052
offline-moonshine-model.cc
5153
offline-nemo-enc-dec-ctc-model-config.cc
5254
offline-nemo-enc-dec-ctc-model.cc

sherpa-onnx/csrc/offline-model-config.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ bool OfflineModelConfig::Validate() const {
147147
return sense_voice.Validate();
148148
}
149149

150-
if (!moonshine.preprocessor.empty()) {
150+
if (!moonshine.encoder.empty()) {
151151
return moonshine.Validate();
152152
}
153153

0 commit comments

Comments
 (0)