This commit is contained in:
Lin Xiaodong 2026-08-15 16:41:41 -04:00 committed by GitHub
commit 566edc4ba3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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
```
### 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
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)
- `print_progress`: Print progress info (default: false)
- `progress_callback`: Progress callback function
- `signal`: `AbortSignal` used to cancel the transcription (see above section)
- VAD parameters (see above section)

View File

@ -4,6 +4,8 @@
#include "whisper.h"
#include <atomic>
#include <memory>
#include <string>
#include <thread>
#include <vector>
@ -149,8 +151,9 @@ struct whisper_result {
class ProgressWorker : public Napi::AsyncWorker {
public:
ProgressWorker(Napi::Function& callback, whisper_params params, Napi::Function progress_callback, Napi::Env env)
: Napi::AsyncWorker(callback), params(params), env(env) {
ProgressWorker(Napi::Function& callback, whisper_params params, Napi::Function progress_callback, Napi::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
if (!progress_callback.IsEmpty()) {
tsfn = Napi::ThreadSafeFunction::New(
@ -185,6 +188,7 @@ class ProgressWorker : public Napi::AsyncWorker {
}
Napi::Object returnObj = Napi::Object::New(Env());
returnObj.Set("cancelled", Napi::Boolean::New(Env(), is_aborted->load()));
if (!result.language.empty()) {
returnObj.Set("language", Napi::String::New(Env(), result.language));
}
@ -217,6 +221,7 @@ class ProgressWorker : public Napi::AsyncWorker {
whisper_result result;
Napi::Env env;
Napi::ThreadSafeFunction tsfn;
std::shared_ptr<std::atomic<bool>> is_aborted;
// Custom run function with progress callback support
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;
// 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
wparams.vad = params.vad;
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.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");
whisper_free(ctx);
return 10;
}
}
@ -538,9 +563,29 @@ Napi::Value whisper(const Napi::CallbackInfo& info) {
params.vad_speech_pad_ms = vad_speech_pad_ms;
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>();
// 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();
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);
});