addon.node : support cancelling transcription via AbortSignal
This commit is contained in:
parent
080bbbe852
commit
c8a67c5f1d
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 ¶ms, whisper_result & result) {
|
int run_with_progress(whisper_params ¶ms, 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue