addon.node : support cancelling transcription via AbortSignal

This commit is contained in:
buxuku 2026-06-12 22:08:57 +08:00
parent 080bbbe852
commit c8a67c5f1d
3 changed files with 198 additions and 4 deletions

View File

@ -44,6 +44,39 @@ Run the VAD example with performance comparison:
node vad-example.js node vad-example.js
``` ```
### Cancellation Usage
Run the cancellation example (cancels an in-flight transcription via `AbortSignal`):
```shell
node cancel-example.js
```
## Cancelling a transcription
An in-flight transcription can be cancelled by passing an `AbortSignal` as the `signal` parameter:
```javascript
const ac = new AbortController();
const promise = whisperAsync({
// ... other params ...
signal: ac.signal,
});
// cancel at any time
ac.abort();
const result = await promise;
// result.cancelled === true
// result.transcription contains the segments transcribed before cancellation
```
Cancellation is checked before each encoder run and before each ggml graph
computation, so it usually takes effect within a fraction of a second.
The promise resolves normally (it does not reject): `result.cancelled` is `true`
and `result.transcription` contains the segments completed before the abort.
## Voice Activity Detection (VAD) Support ## Voice Activity Detection (VAD) Support
VAD can significantly improve transcription performance by only processing speech segments, which is especially beneficial for audio files with long periods of silence. VAD can significantly improve transcription performance by only processing speech segments, which is especially beneficial for audio files with long periods of silence.
@ -112,4 +145,5 @@ Both traditional whisper.cpp parameters and new VAD parameters are supported:
- `comma_in_time`: Use comma in timestamps (default: true) - `comma_in_time`: Use comma in timestamps (default: true)
- `print_progress`: Print progress info (default: false) - `print_progress`: Print progress info (default: false)
- `progress_callback`: Progress callback function - `progress_callback`: Progress callback function
- `signal`: `AbortSignal` used to cancel the transcription (see above section)
- VAD parameters (see above section) - VAD parameters (see above section)

View File

@ -4,6 +4,8 @@
#include "whisper.h" #include "whisper.h"
#include <atomic>
#include <memory>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
@ -149,8 +151,9 @@ struct whisper_result {
class ProgressWorker : public Napi::AsyncWorker { class ProgressWorker : public Napi::AsyncWorker {
public: public:
ProgressWorker(Napi::Function& callback, whisper_params params, Napi::Function progress_callback, Napi::Env env) ProgressWorker(Napi::Function& callback, whisper_params params, Napi::Function progress_callback, Napi::Env env,
: Napi::AsyncWorker(callback), params(params), env(env) { std::shared_ptr<std::atomic<bool>> is_aborted)
: Napi::AsyncWorker(callback), params(params), env(env), is_aborted(std::move(is_aborted)) {
// Create thread-safe function // Create thread-safe function
if (!progress_callback.IsEmpty()) { if (!progress_callback.IsEmpty()) {
tsfn = Napi::ThreadSafeFunction::New( tsfn = Napi::ThreadSafeFunction::New(
@ -185,6 +188,7 @@ class ProgressWorker : public Napi::AsyncWorker {
} }
Napi::Object returnObj = Napi::Object::New(Env()); Napi::Object returnObj = Napi::Object::New(Env());
returnObj.Set("cancelled", Napi::Boolean::New(Env(), is_aborted->load()));
if (!result.language.empty()) { if (!result.language.empty()) {
returnObj.Set("language", Napi::String::New(Env(), result.language)); returnObj.Set("language", Napi::String::New(Env(), result.language));
} }
@ -217,6 +221,7 @@ class ProgressWorker : public Napi::AsyncWorker {
whisper_result result; whisper_result result;
Napi::Env env; Napi::Env env;
Napi::ThreadSafeFunction tsfn; Napi::ThreadSafeFunction tsfn;
std::shared_ptr<std::atomic<bool>> is_aborted;
// Custom run function with progress callback support // Custom run function with progress callback support
int run_with_progress(whisper_params &params, whisper_result & result) { int run_with_progress(whisper_params &params, whisper_result & result) {
@ -344,6 +349,18 @@ class ProgressWorker : public Napi::AsyncWorker {
}; };
wparams.progress_callback_user_data = this; wparams.progress_callback_user_data = this;
// Cancellation support: checked before each encoder run (coarse)
// and before each ggml graph computation (fine)
wparams.encoder_begin_callback = [](struct whisper_context * /*ctx*/, struct whisper_state * /*state*/, void * user_data) {
return !static_cast<std::atomic<bool>*>(user_data)->load();
};
wparams.encoder_begin_callback_user_data = is_aborted.get();
wparams.abort_callback = [](void * user_data) {
return static_cast<std::atomic<bool>*>(user_data)->load();
};
wparams.abort_callback_user_data = is_aborted.get();
// Set VAD parameters // Set VAD parameters
wparams.vad = params.vad; wparams.vad = params.vad;
wparams.vad_model_path = params.vad_model.c_str(); wparams.vad_model_path = params.vad_model.c_str();
@ -355,8 +372,16 @@ class ProgressWorker : public Napi::AsyncWorker {
wparams.vad_params.speech_pad_ms = params.vad_speech_pad_ms; wparams.vad_params.speech_pad_ms = params.vad_speech_pad_ms;
wparams.vad_params.samples_overlap = params.vad_samples_overlap; wparams.vad_params.samples_overlap = params.vad_samples_overlap;
if (whisper_full_parallel(ctx, wparams, pcmf32.data(), pcmf32.size(), params.n_processors) != 0) { const int ret = whisper_full_parallel(ctx, wparams, pcmf32.data(), pcmf32.size(), params.n_processors);
if (is_aborted->load()) {
// cancelled - keep the segments transcribed so far
break;
}
if (ret != 0) {
fprintf(stderr, "failed to process audio\n"); fprintf(stderr, "failed to process audio\n");
whisper_free(ctx);
return 10; return 10;
} }
} }
@ -538,9 +563,29 @@ Napi::Value whisper(const Napi::CallbackInfo& info) {
params.vad_speech_pad_ms = vad_speech_pad_ms; params.vad_speech_pad_ms = vad_speech_pad_ms;
params.vad_samples_overlap = vad_samples_overlap; params.vad_samples_overlap = vad_samples_overlap;
// Cancellation support: an AbortSignal can be passed via params.signal.
// Its "abort" event sets a shared flag which is polled by the whisper.cpp
// abort callbacks on the worker thread.
auto is_aborted = std::make_shared<std::atomic<bool>>(false);
if (whisper_params.Has("signal") && whisper_params.Get("signal").IsObject()) {
Napi::Object signal = whisper_params.Get("signal").As<Napi::Object>();
if (signal.Get("aborted").ToBoolean().Value()) {
is_aborted->store(true);
} else if (signal.Has("addEventListener") && signal.Get("addEventListener").IsFunction()) {
Napi::Function add_listener = signal.Get("addEventListener").As<Napi::Function>();
Napi::Function on_abort = Napi::Function::New(env, [is_aborted](const Napi::CallbackInfo &) {
is_aborted->store(true);
});
Napi::Object options = Napi::Object::New(env);
options.Set("once", Napi::Boolean::New(env, true));
add_listener.Call(signal, { Napi::String::New(env, "abort"), on_abort, options });
}
}
Napi::Function callback = info[1].As<Napi::Function>(); Napi::Function callback = info[1].As<Napi::Function>();
// Create a new Worker class with progress callback support // Create a new Worker class with progress callback support
ProgressWorker* worker = new ProgressWorker(callback, params, progress_callback, env); ProgressWorker* worker = new ProgressWorker(callback, params, progress_callback, env, is_aborted);
worker->Queue(); worker->Queue();
return env.Undefined(); return env.Undefined();
} }

View File

@ -0,0 +1,115 @@
// Demonstrates cancelling an in-flight transcription via AbortSignal (params.signal).
//
// Usage: node cancel-example.js [--model=path/to/model.bin]
const path = require("path");
const os = require("os");
const { promisify } = require("util");
const isWindows = os.platform() === "win32";
const buildPath = isWindows ? "../../build/bin/Release/addon.node" : "../../build/Release/addon.node";
const { whisper } = require(path.join(__dirname, buildPath));
const whisperAsync = promisify(whisper);
const modelArg = process.argv.find((a) => a.startsWith("--model="));
const model = modelArg
? modelArg.slice("--model=".length)
: path.join(__dirname, "../../models/ggml-base.en.bin");
// Long synthetic audio (tone + noise) so the transcription runs long enough
// to be cancelled mid-flight.
function syntheticAudio(seconds) {
const n = 16000 * seconds;
const pcm = new Float32Array(n);
for (let i = 0; i < n; i++) {
pcm[i] = 0.05 * Math.sin((2 * Math.PI * 440 * i) / 16000) + (Math.random() - 0.5) * 0.02;
}
return pcm;
}
const baseParams = {
language: "en",
model,
use_gpu: true,
no_prints: true,
no_timestamps: false,
comma_in_time: false,
};
async function cancelMidFlight() {
console.log("--- test 1: cancel mid-transcription ---");
const ac = new AbortController();
const progressSeen = [];
const t0 = Date.now();
const promise = whisperAsync({
...baseParams,
fname_inp: "",
pcmf32: syntheticAudio(600),
signal: ac.signal,
progress_callback: (p) => {
progressSeen.push(p);
console.log(`progress: ${p}%`);
if (!ac.signal.aborted) {
console.log(">>> calling abort()");
ac.abort();
}
},
});
const result = await promise;
const elapsed = Date.now() - t0;
console.log(`cancelled = ${result.cancelled}, segments = ${result.transcription.length}, elapsed = ${elapsed} ms`);
if (result.cancelled !== true) throw new Error("FAIL: expected cancelled === true");
if (progressSeen.includes(100)) throw new Error("FAIL: transcription ran to completion, was not cancelled");
console.log("PASS\n");
}
async function preAbortedSignal() {
console.log("--- test 2: already-aborted signal ---");
const ac = new AbortController();
ac.abort();
const t0 = Date.now();
const result = await whisperAsync({
...baseParams,
fname_inp: "",
pcmf32: syntheticAudio(600),
signal: ac.signal,
});
const elapsed = Date.now() - t0;
console.log(`cancelled = ${result.cancelled}, segments = ${result.transcription.length}, elapsed = ${elapsed} ms`);
if (result.cancelled !== true) throw new Error("FAIL: expected cancelled === true");
if (result.transcription.length !== 0) throw new Error("FAIL: expected no segments");
console.log("PASS\n");
}
async function normalRun() {
console.log("--- test 3: normal run without signal (regression) ---");
const t0 = Date.now();
const result = await whisperAsync({
...baseParams,
fname_inp: path.join(__dirname, "../../samples/jfk.wav"),
});
const elapsed = Date.now() - t0;
const text = result.transcription.map((s) => s[2]).join(" ");
console.log(`cancelled = ${result.cancelled}, segments = ${result.transcription.length}, elapsed = ${elapsed} ms`);
console.log(`text: ${text.trim()}`);
if (result.cancelled !== false) throw new Error("FAIL: expected cancelled === false");
if (!text.toLowerCase().includes("ask not")) throw new Error("FAIL: unexpected transcription");
console.log("PASS\n");
}
(async () => {
await cancelMidFlight();
await preAbortedSignal();
await normalRun();
console.log("ALL TESTS PASSED");
})().catch((err) => {
console.error(err);
process.exit(1);
});