Skip to content

Commit c737df5

Browse files
committed
feat(stt): add cross-platform Silero VAD pre-pass via whisper.cpp
Add Silero VAD (v6.2.0) pre-pass hooked into the vendored whisper.cpp helper via whisper_vad_segments_from_samples, fully cross-platform with no new dependencies or external runtimes. - electron/native/whisper-stt/src/main.cpp: - Add --vad-model flag and OPENSCREEN_VAD_MODEL env var support - Initialize whisper_vad_context with auto GPU detection and CPU fallback - Pass wparams.vad and wparams.vad_model_path to whisper_full - Add POST /vad endpoint returning speech boundaries via whisper_vad_segments_from_samples - Clean up VAD context via whisper_vad_free on exit - electron/stt/modelManager.ts: - Add silero-vad model descriptor (ggml-silero-v6.2.0.bin, ~885 KB) - Map model cache paths and check model presence - electron/stt/whisperServer.ts: - Pass --vad-model to helper process args - Add detectVadSegments method calling POST /vad - electron/stt/index.ts: - Download silero-vad alongside whisper in prepare() - Pass vadModelPath on startup and chunk retry - Add detectSpeech method and register stt:vad IPC handler - electron/stt/*.test.ts: - Add unit tests for descriptor, paths, caching, --vad-model argument, and /vad client Refs #626 Follows feedback on #631
1 parent 5d4e0b8 commit c737df5

7 files changed

Lines changed: 350 additions & 13 deletions

File tree

‎electron/native/whisper-stt/src/main.cpp‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
#include <cstdint>
4545
#include <cstdlib>
4646
#include <cstring>
47+
#include <filesystem>
4748
#include <fstream>
4849
#include <iostream>
4950
#include <mutex>
@@ -222,6 +223,7 @@ struct Word {
222223

223224
int main(int argc, char** argv) {
224225
std::string model_path;
226+
std::string vad_model_path;
225227
std::string host = "127.0.0.1";
226228
bool host_from_flag = false;
227229
bool force_cpu = false;
@@ -231,6 +233,7 @@ int main(int argc, char** argv) {
231233
for (int i = 1; i < argc; ++i) {
232234
const std::string a = argv[i];
233235
if (a == "--model" && i + 1 < argc) model_path = argv[++i];
236+
else if (a == "--vad-model" && i + 1 < argc) vad_model_path = argv[++i];
234237
else if (a == "--host" && i + 1 < argc) { host = argv[++i]; host_from_flag = true; }
235238
else if (a == "--port" && i + 1 < argc) port = std::atoi(argv[++i]);
236239
else if (a == "--threads" && i + 1 < argc) threads = std::atoi(argv[++i]);
@@ -241,6 +244,10 @@ int main(int argc, char** argv) {
241244
if (model_path.empty()) {
242245
if (const char* p = std::getenv("OPENSCREEN_WHISPER_MODEL")) model_path = p;
243246
}
247+
if (vad_model_path.empty()) {
248+
if (const char* p = std::getenv("OPENSCREEN_VAD_MODEL")) vad_model_path = p;
249+
else if (const char* p = std::getenv("OPENSCREEN_WHISPER_VAD_MODEL")) vad_model_path = p;
250+
}
244251
if (port == 0) {
245252
if (const char* p = std::getenv("OPENSCREEN_WHISPER_PORT")) port = std::atoi(p);
246253
}
@@ -264,7 +271,9 @@ int main(int argc, char** argv) {
264271
"OPENSCREEN_WHISPER_MODEL is required" << std::endl;
265272
return 2;
266273
}
267-
log("boot: model=" + model_path + " host=" + host +
274+
log("boot: model=" + model_path +
275+
(!vad_model_path.empty() ? (" vad_model=" + vad_model_path) : "") +
276+
" host=" + host +
268277
" port=" + (port > 0 ? std::to_string(port) : "(any)") +
269278
" threads=" + std::to_string(threads));
270279

@@ -293,6 +302,25 @@ int main(int argc, char** argv) {
293302
const std::string active_backend = cparams.use_gpu ? detect_active_backend() : "whispercpp-cpu";
294303
log("model loaded; backend=" + active_backend);
295304

305+
// ---- Init VAD context (Silero VAD v6.2.0) ----
306+
struct whisper_vad_context* vctx = nullptr;
307+
if (!vad_model_path.empty()) {
308+
struct whisper_vad_context_params vad_ctx_params = whisper_vad_default_context_params();
309+
vad_ctx_params.n_threads = threads;
310+
vad_ctx_params.use_gpu = !force_cpu;
311+
vctx = whisper_vad_init_from_file_with_params(vad_model_path.c_str(), vad_ctx_params);
312+
if (!vctx && vad_ctx_params.use_gpu) {
313+
log("GPU VAD initialization failed; retrying with CPU inference");
314+
vad_ctx_params.use_gpu = false;
315+
vctx = whisper_vad_init_from_file_with_params(vad_model_path.c_str(), vad_ctx_params);
316+
}
317+
if (vctx) {
318+
log("VAD model loaded; path=" + vad_model_path);
319+
} else {
320+
log("WARNING: failed to load VAD model from " + vad_model_path);
321+
}
322+
}
323+
296324
// ---- HTTP server ----
297325
httplib::Server svr;
298326
std::mutex infer_mu; // whisper contexts are not thread-safe
@@ -366,6 +394,11 @@ int main(int argc, char** argv) {
366394
wparams.print_realtime = false;
367395
wparams.print_timestamps = false;
368396
wparams.n_threads = threads;
397+
if (vctx && !vad_model_path.empty()) {
398+
wparams.vad = true;
399+
wparams.vad_model_path = vad_model_path.c_str();
400+
wparams.vad_params = whisper_vad_default_params();
401+
}
369402

370403
const auto t0 = std::chrono::steady_clock::now();
371404
const int rc = whisper_full(ctx, wparams, pcm.data(), static_cast<int>(pcm.size()));
@@ -540,12 +573,80 @@ int main(int argc, char** argv) {
540573
res.set_content(reply.dump(), "application/json");
541574
});
542575

576+
// POST /vad — multipart form with `file` (WAV: 16 kHz mono PCM16).
577+
// Runs Silero VAD segmentation and returns speech intervals [start, end] in seconds.
578+
svr.Post("/vad", [&](const httplib::Request& req, httplib::Response& res) {
579+
if (!vctx) {
580+
res.status = 400;
581+
res.set_content(R"({"error":"VAD model was not loaded on server startup"})", "application/json");
582+
return;
583+
}
584+
auto it = req.files.find("file");
585+
if (it == req.files.end()) {
586+
res.status = 400;
587+
res.set_content(R"({"error":"missing 'file' form field"})", "application/json");
588+
return;
589+
}
590+
const auto& file_entry = it->second;
591+
592+
static std::atomic<uint64_t> tmp_vad_counter{0};
593+
const auto tmp_vad_id = tmp_vad_counter.fetch_add(1, std::memory_order_relaxed);
594+
const auto tmp_vad_ts = std::chrono::high_resolution_clock::now().time_since_epoch().count();
595+
const std::string tmp_wav = (std::filesystem::temp_directory_path() /
596+
("openscreen-vad-" + std::to_string(tmp_vad_ts) +
597+
"-" + std::to_string(tmp_vad_id) + ".wav")).string();
598+
{
599+
std::ofstream out(tmp_wav, std::ios::binary);
600+
out.write(file_entry.content.data(),
601+
static_cast<std::streamsize>(file_entry.content.size()));
602+
}
603+
std::vector<float> pcm;
604+
int sample_rate = 0, channels = 0;
605+
const bool ok = read_wav_pcm16(tmp_wav, pcm, sample_rate, channels);
606+
std::error_code ec;
607+
std::filesystem::remove(tmp_wav, ec);
608+
if (!ok) {
609+
res.status = 400;
610+
res.set_content(R"({"error":"failed to parse WAV"})", "application/json");
611+
return;
612+
}
613+
if (sample_rate != 16000 || channels != 1) {
614+
res.status = 400;
615+
res.set_content(
616+
R"({"error":"expected 16 kHz mono PCM16 WAV"})",
617+
"application/json");
618+
return;
619+
}
620+
621+
const std::lock_guard<std::mutex> lk(infer_mu);
622+
struct whisper_vad_params vad_params = whisper_vad_default_params();
623+
struct whisper_vad_segments* vad_segments =
624+
whisper_vad_segments_from_samples(vctx, vad_params, pcm.data(), static_cast<int>(pcm.size()));
625+
626+
nlohmann::json reply;
627+
reply["segments"] = nlohmann::json::array();
628+
if (vad_segments) {
629+
const int n_segs = whisper_vad_segments_n_segments(vad_segments);
630+
for (int i = 0; i < n_segs; ++i) {
631+
const float t0 = whisper_vad_segments_get_segment_t0(vad_segments, i);
632+
const float t1 = whisper_vad_segments_get_segment_t1(vad_segments, i);
633+
reply["segments"].push_back({
634+
{"start", static_cast<double>(t0)},
635+
{"end", static_cast<double>(t1)}
636+
});
637+
}
638+
whisper_vad_free_segments(vad_segments);
639+
}
640+
res.set_content(reply.dump(), "application/json");
641+
});
642+
543643
// ---- bind + listen ----
544644
int bound_port = port;
545645
if (bound_port == 0) {
546646
bound_port = svr.bind_to_any_port(host);
547647
} else if (!svr.bind_to_port(host, bound_port)) {
548648
std::cerr << "FATAL: bind_to_port(" << host << ":" << bound_port << ") failed" << std::endl;
649+
if (vctx) whisper_vad_free(vctx);
549650
whisper_free(ctx);
550651
return 4;
551652
}
@@ -554,6 +655,7 @@ int main(int argc, char** argv) {
554655
if (rc != 0) {
555656
std::cerr << "FATAL: listen_after_bind failed" << std::endl;
556657
}
658+
if (vctx) whisper_vad_free(vctx);
557659
whisper_free(ctx);
558660
return rc;
559661
}

‎electron/stt/index.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type {
99
SttTiming,
1010
SttTranscribeRequest,
1111
SttTranscribeResponse,
12+
SttVadResponse,
13+
SttVadSegment,
1214
SttWordSegment,
1315
} from "./transcriptionContract";
1416
import { WhisperServerManager } from "./whisperServer";
@@ -97,6 +99,7 @@ export class SttManager {
9799
private initPromise: Promise<void> | null = null;
98100
/** Kept from `prepare()` so a chunk retry can respawn a helper that died mid-run. */
99101
private modelPath: string | null = null;
102+
private vadModelPath: string | null = null;
100103
/**
101104
* Bumped by `cancel()`. The chunk loop compares it against the value it
102105
* captured on entry, so a cancel that lands after a new run started cannot
@@ -194,8 +197,12 @@ export class SttManager {
194197

195198
const paths = modelPaths(modelsDir);
196199
this.modelPath = paths.whisper;
200+
this.vadModelPath = paths["silero-vad"];
197201
try {
198-
await this.server.start({ modelPath: paths.whisper });
202+
await this.server.start({
203+
modelPath: paths.whisper,
204+
vadModelPath: paths["silero-vad"],
205+
});
199206
} catch (error) {
200207
if (this.shuttingDown) throw cancelledError();
201208
throw error;
@@ -236,7 +243,9 @@ export class SttManager {
236243
if (this.shuttingDown) throw cancelledError();
237244
if (attempt === CHUNK_ATTEMPTS) break;
238245
if (this.modelPath) {
239-
await this.server.start({ modelPath: this.modelPath }).catch(() => undefined);
246+
await this.server
247+
.start({ modelPath: this.modelPath, vadModelPath: this.vadModelPath })
248+
.catch(() => undefined);
240249
}
241250
if (this.shuttingDown) throw cancelledError();
242251
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
@@ -433,6 +442,15 @@ export class SttManager {
433442
this.cancelEpoch++;
434443
await this.server.shutdown();
435444
}
445+
446+
/**
447+
* Run Voice Activity Detection (Silero VAD) to detect speech segments in samples.
448+
*/
449+
async detectSpeech(samples: Float32Array): Promise<SttVadSegment[]> {
450+
if (this.shuttingDown) throw cancelledError();
451+
await this.init();
452+
return this.server.detectVadSegments({ samples });
453+
}
436454
}
437455

438456
let singleton: SttManager | null = null;
@@ -491,6 +509,13 @@ export function registerSttIpc(ipcMain: IpcMain): void {
491509
}
492510
},
493511
);
512+
ipcMain.handle(
513+
"stt:vad",
514+
async (_event, req: { samples: Float32Array }): Promise<SttVadResponse> => {
515+
const segments = await manager.detectSpeech(req.samples);
516+
return { segments };
517+
},
518+
);
494519
ipcMain.handle("stt:cancel", () => {
495520
manager.cancel();
496521
});

‎electron/stt/modelManager.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,39 @@ describe("modelManager", () => {
3030
}
3131
});
3232

33+
it("exposes the silero-vad model descriptor with a single GGML file", () => {
34+
expect(STT_MODELS["silero-vad"].cacheDir).toBe("whisper-ggml");
35+
expect(STT_MODELS["silero-vad"].repoId).toBe("ggml-org/whisper-vad");
36+
expect(STT_MODELS["silero-vad"].files.length).toBe(1);
37+
expect(STT_MODELS["silero-vad"].files[0].name).toBe("ggml-silero-v6.2.0.bin");
38+
expect(STT_MODELS["silero-vad"].files[0].expectedSha256).toBe(
39+
"2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987",
40+
);
41+
for (const f of STT_MODELS["silero-vad"].files) {
42+
expect(f.approximateBytes).toBeGreaterThan(0);
43+
expect(f.url).toContain("huggingface.co");
44+
expect(f.url).toMatch(/\/resolve\/[0-9a-f]{40}\//);
45+
}
46+
});
47+
3348
it("modelPaths places the GGML file under the cache directory", () => {
3449
const paths = modelPaths(dir);
3550
expect(paths.whisper).toBe(path.join(dir, "whisper-ggml", "ggml-small-q8_0.bin"));
51+
expect(paths["silero-vad"]).toBe(path.join(dir, "whisper-ggml", "ggml-silero-v6.2.0.bin"));
3652
});
3753

3854
it("areModelsPresent returns false when the model file is missing", async () => {
3955
expect(await areModelsPresent(dir)).toBe(false);
4056
});
4157

42-
it("areModelsPresent returns true once the GGML file is present", async () => {
58+
it("areModelsPresent returns true once the GGML files are present", async () => {
4359
const paths = modelPaths(dir);
4460
await mkdir(path.dirname(paths.whisper), { recursive: true });
4561
expect(await areModelsPresent(dir)).toBe(false);
4662
await writeFile(paths.whisper, "dummy-ggml");
63+
expect(await areModelsPresent(dir, ["whisper"])).toBe(true);
64+
expect(await areModelsPresent(dir)).toBe(false);
65+
await writeFile(paths["silero-vad"], "dummy-vad");
4766
expect(await areModelsPresent(dir)).toBe(true);
4867
});
4968

‎electron/stt/modelManager.ts‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { pipeline } from "node:stream/promises";
2424
* separate VAD model is required. See `technical-documentation/architecture/transcription-and-captions.md`.
2525
*/
2626

27-
export type SttModelId = "whisper";
27+
export type SttModelId = "whisper" | "silero-vad";
2828

2929
export interface SttModelFile {
3030
/** Relative path within the model directory (e.g. "ggml-small-q8_0.bin"). */
@@ -62,6 +62,10 @@ const MODEL_FILE = "ggml-small-q8_0.bin";
6262
// LFS oid for MODEL_FILE is exactly the digest below.
6363
const MODEL_REVISION = "5359861c739e955e79d9a303bcbc70fb988958b1";
6464

65+
const VAD_REPO = "ggml-org/whisper-vad";
66+
const VAD_FILE = "ggml-silero-v6.2.0.bin";
67+
const VAD_REVISION = "9ffd54a1e1ee413ddf265af9913beaf518d1639b";
68+
6569
export const STT_MODELS: Record<SttModelId, SttModelDescriptor> = {
6670
whisper: {
6771
cacheDir: "whisper-ggml",
@@ -75,22 +79,43 @@ export const STT_MODELS: Record<SttModelId, SttModelDescriptor> = {
7579
},
7680
],
7781
},
82+
"silero-vad": {
83+
cacheDir: "whisper-ggml",
84+
repoId: VAD_REPO,
85+
files: [
86+
{
87+
name: VAD_FILE,
88+
url: `${MODEL_BASE}/${VAD_REPO}/resolve/${VAD_REVISION}/${VAD_FILE}`,
89+
expectedSha256: "2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987",
90+
approximateBytes: 885_098,
91+
},
92+
],
93+
},
7894
};
7995

8096
export function modelPaths(baseDir: string): Record<SttModelId, string> {
8197
return {
8298
whisper: path.join(baseDir, STT_MODELS.whisper.cacheDir, MODEL_FILE),
99+
"silero-vad": path.join(baseDir, STT_MODELS["silero-vad"].cacheDir, VAD_FILE),
83100
};
84101
}
85102

86103
/**
87-
* True when the GGML model file exists and is non-empty.
104+
* True when the GGML model files exist and are non-empty.
88105
*/
89-
export async function areModelsPresent(baseDir: string): Promise<boolean> {
106+
export async function areModelsPresent(
107+
baseDir: string,
108+
only: SttModelId[] = ["whisper", "silero-vad"],
109+
): Promise<boolean> {
90110
const paths = modelPaths(baseDir);
91111
try {
92-
const s = await stat(paths.whisper);
93-
return s.isFile() && s.size > 0;
112+
const results = await Promise.all(
113+
only.map(async (id) => {
114+
const s = await stat(paths[id]);
115+
return s.isFile() && s.size > 0;
116+
}),
117+
);
118+
return results.every(Boolean);
94119
} catch {
95120
return false;
96121
}
@@ -217,7 +242,7 @@ async function ensureFile(
217242

218243
export interface EnsureModelsOptions {
219244
baseDir: string;
220-
/** Models to ensure; defaults to all (currently just `whisper`). */
245+
/** Models to ensure; defaults to all (`whisper`, `silero-vad`). */
221246
only?: SttModelId[];
222247
onProgress?: (event: {
223248
id: SttModelId;
@@ -228,9 +253,9 @@ export interface EnsureModelsOptions {
228253
fetcher?: typeof fetch;
229254
}
230255

231-
/** Ensure the GGML model file is present locally; downloads with progress + retry. */
256+
/** Ensure the GGML model files are present locally; downloads with progress + retry. */
232257
export async function ensureModels(opts: EnsureModelsOptions): Promise<void> {
233-
const targets = (opts.only ?? (["whisper"] as SttModelId[])).map((id) => ({
258+
const targets = (opts.only ?? (["whisper", "silero-vad"] as SttModelId[])).map((id) => ({
234259
id,
235260
descriptor: STT_MODELS[id],
236261
filePath: modelPaths(opts.baseDir)[id],

‎electron/stt/transcriptionContract.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export interface SttStatusEvent {
7171
/** Total bytes for the in-flight download. */
7272
totalBytes?: number;
7373
/** Which model is downloading. */
74-
model?: "whisper";
74+
model?: "whisper" | "silero-vad";
7575
/**
7676
* Seconds of audio transcribed so far, and the total for this request. Only
7777
* when `phase === "transcribe"`. Progress is reported per CHUNK (see
@@ -158,3 +158,14 @@ export interface SttTranscribeResponse {
158158

159159
/** IPC success envelope; thrown errors cross as a rejection. */
160160
export type SttTranscribeResult = SttTranscribeResponse;
161+
162+
/** A detected speech segment with timestamps in seconds. */
163+
export interface SttVadSegment {
164+
startSec: number;
165+
endSec: number;
166+
}
167+
168+
/** Response from the VAD speech detection pass. */
169+
export interface SttVadResponse {
170+
segments: SttVadSegment[];
171+
}

0 commit comments

Comments
 (0)