Skip to content

Commit 5d3f206

Browse files
committed
feat: example stream mode for nodejs addon
1 parent 44fa2f6 commit 5d3f206

File tree

8 files changed

+607
-0
lines changed

8 files changed

+607
-0
lines changed

examples/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ if (EMSCRIPTEN)
100100
add_subdirectory(bench.wasm)
101101
elseif(CMAKE_JS_VERSION)
102102
add_subdirectory(addon.node)
103+
add_subdirectory(stream.node)
103104
else()
104105
add_subdirectory(cli)
105106
add_subdirectory(bench)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
cmake_minimum_required(VERSION 3.13)
2+
project(stream_addon)
3+
4+
set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/../../cmake ${CMAKE_MODULE_PATH})
5+
6+
set(CMAKE_CXX_STANDARD 11)
7+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
8+
9+
add_definitions(-DNAPI_VERSION=4)
10+
include_directories(${CMAKE_JS_INC})
11+
12+
set(TARGET stream.node)
13+
14+
add_library(${TARGET} SHARED
15+
addon.cpp
16+
whisper-stream.cpp
17+
)
18+
19+
set_target_properties(${TARGET} PROPERTIES
20+
PREFIX ""
21+
SUFFIX ".node"
22+
)
23+
include(DefaultTargetOptions)
24+
25+
execute_process(COMMAND node -p "require('node-addon-api').include"
26+
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
27+
OUTPUT_VARIABLE NODE_ADD_ON_API_DIR
28+
OUTPUT_STRIP_TRAILING_WHITESPACE)
29+
string(REPLACE "\"" "" NODE_ADD_ON_API_DIR ${NODE_ADD_ON_API_DIR})
30+
string(REPLACE "\\\\" "/" NODE_ADD_ON_API_DIR ${NODE_ADD_ON_API_DIR})
31+
target_include_directories(${TARGET} PRIVATE ${NODE_ADD_ON_API_DIR})
32+
33+
target_link_libraries(${TARGET}
34+
whisper
35+
common
36+
${CMAKE_THREAD_LIBS_INIT}
37+
)
38+
39+
if(MSVC AND CMAKE_NODEJS_DEF AND CMAKE_JS_NODEJS_TARGET)
40+
target_link_libraries(${TARGET} ${CMAKE_JS_NODEJS_TARGET})
41+
elseif(CMAKE_JS_LIB)
42+
execute_process(COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_JS_LIB} ${CMAKE_NODEJS_LIB_TARGET})
43+
target_link_libraries(${TARGET} ${CMAKE_NODEJS_LIB_TARGET})
44+
endif()

examples/stream.node/addon.cpp

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#include "addon.h" // Your header file for WhisperStreamWrapper
2+
#include "whisper-stream.h" // Your header file for the WhisperStream class
3+
4+
// NOTE: The N-API wrapper handles errors by throwing JS exceptions, so this macro is not needed.
5+
// #define CHECK_STATUS(env, status, msg) ...
6+
7+
// --- Implementation of the Wrapper ---
8+
9+
Napi::Object WhisperStreamWrapper::Init(Napi::Env env, Napi::Object exports) {
10+
Napi::Function func = DefineClass(env, "WhisperStream", {
11+
InstanceMethod("startModel", &WhisperStreamWrapper::startModel),
12+
InstanceMethod("processChunk", &WhisperStreamWrapper::ProcessChunk),
13+
InstanceMethod("freeModel", &WhisperStreamWrapper::freeModel),
14+
});
15+
16+
exports.Set("WhisperStream", func);
17+
return exports;
18+
}
19+
20+
WhisperStreamWrapper::WhisperStreamWrapper(const Napi::CallbackInfo& info) : Napi::ObjectWrap<WhisperStreamWrapper>(info) {
21+
}
22+
23+
Napi::Value WhisperStreamWrapper::startModel(const Napi::CallbackInfo& info) {
24+
Napi::Env env = info.Env();
25+
26+
if (info.Length() < 1 || !info[0].IsObject()) {
27+
Napi::TypeError::New(env, "Expected a configuration object").ThrowAsJavaScriptException();
28+
return env.Null();
29+
}
30+
31+
Napi::Object js_params = info[0].As<Napi::Object>();
32+
StreamParams params;
33+
34+
if (js_params.Has("modelPath")) {
35+
params.model = js_params.Get("modelPath").As<Napi::String>();
36+
} else {
37+
Napi::TypeError::New(env, "Missing required parameter 'model'").ThrowAsJavaScriptException();
38+
return env.Null();
39+
}
40+
41+
if (js_params.Has("language")) params.language = js_params.Get("language").As<Napi::String>();
42+
if (js_params.Has("nThreads")) params.n_threads = js_params.Get("nThreads").As<Napi::Number>();
43+
if (js_params.Has("stepMs")) params.step_ms = js_params.Get("stepMs").As<Napi::Number>();
44+
if (js_params.Has("lengthMs")) params.length_ms = js_params.Get("lengthMs").As<Napi::Number>();
45+
if (js_params.Has("keepMs")) params.keep_ms = js_params.Get("keepMs").As<Napi::Number>();
46+
if (js_params.Has("maxTokens")) params.max_tokens = js_params.Get("maxTokens").As<Napi::Number>();
47+
if (js_params.Has("audioCtx")) params.audio_ctx = js_params.Get("audioCtx").As<Napi::Number>();
48+
if (js_params.Has("vadThold")) params.vad_thold = js_params.Get("vadThold").As<Napi::Number>();
49+
if (js_params.Has("beamSize")) params.beam_size = js_params.Get("beamSize").As<Napi::Number>();
50+
if (js_params.Has("freqThold")) params.freq_thold = js_params.Get("freqThold").As<Napi::Number>();
51+
if (js_params.Has("translate")) params.translate = js_params.Get("translate").As<Napi::Boolean>();
52+
if (js_params.Has("noFallback")) params.no_fallback = js_params.Get("noFallback").As<Napi::Boolean>();
53+
if (js_params.Has("printSpecial")) params.print_special = js_params.Get("printSpecial").As<Napi::Boolean>();
54+
if (js_params.Has("noContext")) params.no_context = js_params.Get("noContext").As<Napi::Boolean>();
55+
if (js_params.Has("noTimestamps")) params.no_timestamps = js_params.Get("noTimestamps").As<Napi::Boolean>();
56+
if (js_params.Has("tinydiarize")) params.tinydiarize = js_params.Get("tinydiarize").As<Napi::Boolean>();
57+
if (js_params.Has("saveAudio")) params.save_audio = js_params.Get("saveAudio").As<Napi::Boolean>();
58+
if (js_params.Has("useGpu")) params.use_gpu = js_params.Get("useGpu").As<Napi::Boolean>();
59+
if (js_params.Has("flashAttn")) params.flash_attn = js_params.Get("flashAttn").As<Napi::Boolean>();
60+
61+
if (this->whisperStream_) {
62+
delete this->whisperStream_;
63+
}
64+
65+
try {
66+
this->whisperStream_ = new WhisperStream(params);
67+
this->whisperStream_->init();
68+
} catch (const std::runtime_error& e) {
69+
Napi::Error::New(env, e.what()).ThrowAsJavaScriptException();
70+
return env.Null();
71+
}
72+
73+
return env.Undefined();
74+
}
75+
76+
Napi::Value WhisperStreamWrapper::ProcessChunk(const Napi::CallbackInfo& info) {
77+
Napi::Env env = info.Env();
78+
79+
if (!this->whisperStream_) {
80+
Napi::Error::New(env, "Model not started. Call startModel() first.").ThrowAsJavaScriptException();
81+
return env.Null();
82+
}
83+
84+
if (info.Length() < 1 || !info[0].IsTypedArray() || info[0].As<Napi::TypedArray>().TypedArrayType() != napi_float32_array) {
85+
Napi::TypeError::New(env, "Argument must be a Float32Array").ThrowAsJavaScriptException();
86+
return env.Null();
87+
}
88+
89+
Napi::Float32Array pcmf32_array = info[0].As<Napi::Float32Array>();
90+
std::vector<float> pcmf32_new(pcmf32_array.Data(), pcmf32_array.Data() + pcmf32_array.ElementLength());
91+
92+
TranscriptionResult result = this->whisperStream_->process(pcmf32_new);
93+
94+
Napi::Object resultObj = Napi::Object::New(env);
95+
resultObj.Set("text", Napi::String::New(env, result.text));
96+
resultObj.Set("isFinal", Napi::Boolean::New(env, result.final));
97+
98+
return resultObj;
99+
}
100+
101+
Napi::Value WhisperStreamWrapper::freeModel(const Napi::CallbackInfo& info) {
102+
Napi::Env env = info.Env();
103+
if (this->whisperStream_) {
104+
delete this->whisperStream_;
105+
this->whisperStream_ = nullptr;
106+
}
107+
return env.Undefined();
108+
}
109+
110+
Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
111+
return WhisperStreamWrapper::Init(env, exports);
112+
}
113+
114+
NODE_API_MODULE(whisper, InitAll)

examples/stream.node/addon.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#pragma once
2+
3+
#include <napi.h>
4+
#include "whisper-stream.h"
5+
6+
class WhisperStreamWrapper : public Napi::ObjectWrap<WhisperStreamWrapper> {
7+
public:
8+
static Napi::Object Init(Napi::Env env, Napi::Object exports);
9+
WhisperStreamWrapper(const Napi::CallbackInfo& info);
10+
11+
private:
12+
Napi::Value startModel(const Napi::CallbackInfo& info);
13+
Napi::Value ProcessChunk(const Napi::CallbackInfo& info);
14+
Napi::Value freeModel(const Napi::CallbackInfo& info);
15+
16+
WhisperStream* whisperStream_ = nullptr;
17+
};

examples/stream.node/index.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const path = require('path');
2+
const os = require('os');
3+
const portAudio = require('naudiodon2');
4+
5+
const addonPath = path.join(__dirname, '..', '..', 'build', 'Release', 'stream.node');
6+
7+
const { WhisperStream } = require(addonPath);
8+
9+
const modelPath = path.join(__dirname, '..', '..', 'models', 'ggml-base.en.bin');
10+
const SAMPLE_RATE = 16000;
11+
12+
// --- Main Application ---
13+
async function main() {
14+
const whisper = new WhisperStream();
15+
let pendingText = ''; // Buffer for the current unconfirmed text
16+
17+
console.log('Loading model...');
18+
whisper.startModel({
19+
modelPath: modelPath,
20+
language: 'en',
21+
nThreads: 4,
22+
stepMs: 3000,
23+
lengthMs: 10000,
24+
keepMs: 200,
25+
useGpu: true,
26+
});
27+
console.log('Model loaded.');
28+
29+
const ai = new portAudio.AudioIO({
30+
inOptions: {
31+
channelCount: 1,
32+
sampleFormat: portAudio.SampleFormatFloat32,
33+
sampleRate: SAMPLE_RATE,
34+
deviceId: -1,
35+
closeOnError: true,
36+
}
37+
});
38+
39+
ai.on('data', (chunk) => {
40+
const floatCount = chunk.length / Float32Array.BYTES_PER_ELEMENT;
41+
const float32 = new Float32Array(chunk.buffer, chunk.byteOffset, floatCount);
42+
43+
try {
44+
const result = whisper.processChunk(float32);
45+
if (!result || !result.text) return;
46+
47+
const { text, isFinal } = result;
48+
49+
if (isFinal) {
50+
process.stdout.write(`\r${text}\n`);
51+
pendingText = ''; // Reset for the next utterance
52+
} else {
53+
pendingText = text;
54+
// '\r' moves cursor to the start, '\x1B[K' clears the rest of the line.
55+
process.stdout.write(`\r${pendingText}\x1B[K`);
56+
}
57+
} catch (err) {
58+
console.error('Error during processing:', err);
59+
}
60+
});
61+
62+
ai.on('error', (err) => console.error('Audio input error:', err));
63+
64+
ai.start();
65+
console.log('Recording from microphone. Speak now.');
66+
process.stdout.write('> ');
67+
68+
const shutdown = () => {
69+
console.log('\nShutting down...');
70+
ai.quit(() => {
71+
whisper.freeModel();
72+
process.exit(0);
73+
});
74+
};
75+
76+
process.on('SIGINT', shutdown);
77+
process.on('SIGTERM', shutdown);
78+
}
79+
80+
main().catch((err) => {
81+
console.error('An unexpected error occurred:', err);
82+
process.exit(1);
83+
});

examples/stream.node/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "stream.node",
3+
"version": "1.0.0",
4+
"main": "index.js",
5+
"scripts": {
6+
"start": "node index_naudiodon.js",
7+
"test": "echo \"Error: no test specified\" && exit 1"
8+
},
9+
"dependencies": {
10+
"naudiodon2": "^2.3.6"
11+
},
12+
"devDependencies": {
13+
"cmake-js": "^7.3.1",
14+
"node-addon-api": "^5.1.0"
15+
}
16+
}

0 commit comments

Comments
 (0)