Skip to content

Commit 753609d

Browse files
authored
Support per-stream hotwords in the JavaScript (node-addon) API for non-streaming ASR (#3723)
1 parent 2c912d2 commit 753609d

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

.github/scripts/test-nodejs-addon-npm.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ tar xvf sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2
170170
rm sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2
171171

172172
node ./test_asr_non_streaming_nemo_parakeet_tdt_v2.js
173+
node ./test_asr_non_streaming_nemo_parakeet_tdt_v2_hotwords.js
173174
rm -rf sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8
174175

175176
echo "----------non-streaming ASR dolphin CTC----------"

harmony-os/SherpaOnnxHar/sherpa_onnx/src/main/cpp/non-streaming-asr.cc

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -649,9 +649,9 @@ Napi::Value CreateOfflineRecognizerAsyncWrapper(
649649
static Napi::External<SherpaOnnxOfflineStream> CreateOfflineStreamWrapper(
650650
const Napi::CallbackInfo &info) {
651651
Napi::Env env = info.Env();
652-
if (info.Length() != 1) {
652+
if (info.Length() != 1 && info.Length() != 2) {
653653
std::ostringstream os;
654-
os << "Expect only 1 argument. Given: " << info.Length();
654+
os << "Expect only 1 or 2 arguments. Given: " << info.Length();
655655

656656
Napi::TypeError::New(env, os.str()).ThrowAsJavaScriptException();
657657

@@ -661,7 +661,7 @@ static Napi::External<SherpaOnnxOfflineStream> CreateOfflineStreamWrapper(
661661
if (!info[0].IsExternal()) {
662662
Napi::TypeError::New(
663663
env,
664-
"You should pass an offline recognizer pointer as the only argument")
664+
"You should pass an offline recognizer pointer as the first argument")
665665
.ThrowAsJavaScriptException();
666666

667667
return {};
@@ -670,8 +670,32 @@ static Napi::External<SherpaOnnxOfflineStream> CreateOfflineStreamWrapper(
670670
const SherpaOnnxOfflineRecognizer *recognizer =
671671
info[0].As<Napi::External<SherpaOnnxOfflineRecognizer>>().Data();
672672

673-
const SherpaOnnxOfflineStream *stream =
674-
SherpaOnnxCreateOfflineStream(recognizer);
673+
const SherpaOnnxOfflineStream *stream = nullptr;
674+
if (info.Length() == 2) {
675+
// Optional per-stream hotwords for contextual biasing. Note that only
676+
// transducer models decoded with modified_beam_search support hotwords.
677+
if (!info[1].IsString()) {
678+
Napi::TypeError::New(env, "Argument 2 should be a string.")
679+
.ThrowAsJavaScriptException();
680+
681+
return {};
682+
}
683+
684+
std::string hotwords = info[1].As<Napi::String>().Utf8Value();
685+
stream =
686+
SherpaOnnxCreateOfflineStreamWithHotwords(recognizer, hotwords.c_str());
687+
} else {
688+
stream = SherpaOnnxCreateOfflineStream(recognizer);
689+
}
690+
691+
if (!stream) {
692+
Napi::TypeError::New(env,
693+
"Failed to create offline stream. Please check if "
694+
"your model and decoding method support hotwords.")
695+
.ThrowAsJavaScriptException();
696+
697+
return {};
698+
}
675699

676700
return Napi::External<SherpaOnnxOfflineStream>::New(
677701
env, const_cast<SherpaOnnxOfflineStream *>(stream),

nodejs-addon-examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ The following tables list the examples in this folder.
146146
|[./test_asr_non_streaming_nemo_canary.js](./test_asr_non_streaming_nemo_canary.js)|Non-streaming speech recognition from a file using a [NeMo](https://github.com/NVIDIA/NeMo) [Canary](https://k2-fsa.github.io/sherpa/onnx/nemo/canary.html#sherpa-onnx-nemo-canary-180m-flash-en-es-de-fr-int8-english-spanish-german-french) model|
147147
|[./test_asr_non_streaming_zipformer_ctc.js](./test_asr_non_streaming_zipformer_ctc.js)|Non-streaming speech recognition from a file using a Zipformer CTC model with greedy search|
148148
|[./test_asr_non_streaming_nemo_parakeet_tdt_v2.js](./test_asr_non_streaming_nemo_parakeet_tdt_v2.js)|Non-streaming speech recognition from a file using a [NeMo](https://github.com/NVIDIA/NeMo) [parakeet-tdt-0.6b-v2](https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html#sherpa-onnx-nemo-parakeet-tdt-0-6b-v2-int8-english) model with greedy search|
149+
|[./test_asr_non_streaming_nemo_parakeet_tdt_v2_hotwords.js](./test_asr_non_streaming_nemo_parakeet_tdt_v2_hotwords.js)|Non-streaming speech recognition from a file using a [NeMo](https://github.com/NVIDIA/NeMo) [parakeet-tdt-0.6b-v2](https://k2-fsa.github.io/sherpa/onnx/pretrained_models/offline-transducer/nemo-transducer-models.html#sherpa-onnx-nemo-parakeet-tdt-0-6b-v2-int8-english) model with modified_beam_search and per-stream [hotwords](https://k2-fsa.github.io/sherpa/onnx/hotwords/index.html)|
149150
|[./test_asr_non_streaming_dolphin_ctc.js](./test_asr_non_streaming_dolphin_ctc.js)|Non-streaming speech recognition from a file using a [Dolphinhttps://github.com/DataoceanAI/Dolphin]) CTC model with greedy search|
150151
|[./test_asr_non_streaming_paraformer.js](./test_asr_non_streaming_paraformer.js)|Non-streaming speech recognition from a file using [Paraformer](https://github.com/alibaba-damo-academy/FunASR)|
151152
|[./test_asr_non_streaming_paraformer_itn.js](./test_asr_non_streaming_paraformer_itn.js)|Non-streaming speech recognition from a file using [Paraformer](https://github.com/alibaba-damo-academy/FunASR) with ITN|
@@ -443,6 +444,9 @@ tar xvf sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2
443444
rm sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2
444445

445446
node ./test_asr_non_streaming_nemo_parakeet_tdt_v2.js
447+
448+
# Per-stream hotwords (contextual biasing) with modified_beam_search
449+
node ./test_asr_non_streaming_nemo_parakeet_tdt_v2_hotwords.js
446450
```
447451

448452
### Non-streaming speech recognition with Zipformer CTC models
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 Xiaomi Corporation
2+
const fs = require('fs');
3+
const sherpa_onnx = require('sherpa-onnx-node');
4+
5+
// This example shows how to use per-stream hotwords (contextual biasing)
6+
// with a NeMo transducer model. Hotwords require
7+
// decodingMethod 'modified_beam_search'.
8+
//
9+
// The test wave contains the name "Phoebe", which the model transcribes
10+
// with the spelling "Phebe" by default. Passing the hotword "Phoebe" to
11+
// createStream() biases this stream towards the expected spelling.
12+
//
13+
// Please download test files from
14+
// https://github.com/k2-fsa/sherpa-onnx/releases/tag/asr-models
15+
16+
const modelDir = './sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8';
17+
18+
// To pass hotwords as normal words, the model config needs a bpe vocab.
19+
// This model's release does not ship bpe.vocab, but an equivalent one for
20+
// hotword encoding can be derived from tokens.txt (equal scores make the
21+
// encoder behave as longest-match).
22+
const bpeVocab = `${modelDir}/bpe.vocab`;
23+
if (!fs.existsSync(bpeVocab)) {
24+
const tokens = fs.readFileSync(`${modelDir}/tokens.txt`, 'utf8');
25+
const vocab = tokens.split('\n')
26+
.filter(line => line.trim() !== '')
27+
.map(line => `${line.split(' ')[0]}\t-1.0`)
28+
.join('\n');
29+
fs.writeFileSync(bpeVocab, vocab + '\n');
30+
}
31+
32+
const config = {
33+
'featConfig': {
34+
'sampleRate': 16000,
35+
'featureDim': 80,
36+
},
37+
'modelConfig': {
38+
'transducer': {
39+
'encoder':
40+
'./sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/encoder.int8.onnx',
41+
'decoder':
42+
'./sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/decoder.int8.onnx',
43+
'joiner': './sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/joiner.int8.onnx',
44+
},
45+
'tokens': './sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/tokens.txt',
46+
'numThreads': 2,
47+
'provider': 'cpu',
48+
'debug': 1,
49+
'modelType': 'nemo_transducer',
50+
'modelingUnit': 'bpe',
51+
'bpeVocab': bpeVocab,
52+
},
53+
'decodingMethod': 'modified_beam_search',
54+
'hotwordsScore': 2.0,
55+
};
56+
57+
const waveFilename =
58+
'./sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8/test_wavs/0.wav';
59+
60+
const recognizer = new sherpa_onnx.OfflineRecognizer(config);
61+
console.log('Started');
62+
const wave = sherpa_onnx.readWave(waveFilename);
63+
64+
function decode(hotwords) {
65+
const stream = recognizer.createStream(hotwords);
66+
stream.acceptWaveform({sampleRate: wave.sampleRate, samples: wave.samples});
67+
recognizer.decode(stream);
68+
return recognizer.getResult(stream).text;
69+
}
70+
71+
console.log('Without hotwords:', decode());
72+
73+
// Multiple phrases are separated by '/'; a per-phrase boosting score can be
74+
// appended, e.g. 'PHOEBE :3.0/DON QUIXOTE'.
75+
console.log('With hotwords :', decode('Phoebe'));

scripts/node-addon-api/lib/non-streaming-asr.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,24 @@ class OfflineRecognizer {
9292

9393
/**
9494
* Create a new OfflineStream bound to this recognizer.
95+
*
96+
* The optional hotwords argument enables contextual biasing for this
97+
* stream only. Hotwords are supported only by transducer models decoded
98+
* with decodingMethod 'modified_beam_search'. Separate multiple phrases
99+
* with '/', and optionally append a per-phrase boosting score, e.g.
100+
* 'PHOEBE :2.0/DON QUIXOTE'. When modelConfig.modelingUnit and
101+
* modelConfig.bpeVocab are set, phrases are given as normal words;
102+
* otherwise each phrase must be a sequence of tokens from tokens.txt
103+
* separated by spaces. See also
104+
* https://k2-fsa.github.io/sherpa/onnx/hotwords/index.html
105+
*
106+
* @param {string} [hotwords] Optional hotwords for this stream.
95107
* @returns {OfflineStream}
96108
*/
97-
createStream() {
98-
const handle = addon.createOfflineStream(this.handle);
109+
createStream(hotwords) {
110+
const handle = hotwords === undefined ?
111+
addon.createOfflineStream(this.handle) :
112+
addon.createOfflineStream(this.handle, hotwords);
99113
return new OfflineStream(handle);
100114
}
101115

0 commit comments

Comments
 (0)