Add bounded CPU VAD diagnostic with real model contract tests

This commit is contained in:
karl committed 2026-10-13 13:39:04 +02:00
1 parent 37bb6a6e0e
commit bc47e14971
6 files changed
+205 -2

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
cmake_minimum_required(VERSION 3.21)
project(searchi_vad_diagnostic LANGUAGES C CXX)
if(NOT WHISPER_SOURCE)
message(FATAL_ERROR "Provide the pinned WHISPER_SOURCE checkout")
endif()
find_package(Git REQUIRED)
execute_process(COMMAND "${GIT_EXECUTABLE}" -C "${WHISPER_SOURCE}" rev-parse HEAD
OUTPUT_VARIABLE whisper_revision OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE revision_status)
if(NOT revision_status EQUAL 0 OR NOT whisper_revision STREQUAL "d1be6fde11ac6e0407606b4e42fe72d34add8037")
message(FATAL_ERROR "Diagnostic requires the frozen whisper.cpp revision")
endif()
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(GGML_BACKEND_DL OFF CACHE BOOL "" FORCE)
foreach(provider CUDA VULKAN METAL SYCL HIP BLAS)
set(GGML_${provider} OFF CACHE BOOL "" FORCE)
endforeach()
set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(WHISPER_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory("${WHISPER_SOURCE}" whisper)
add_executable(searchi-vad-diagnostic diagnostic.cpp)
target_compile_features(searchi-vad-diagnostic PRIVATE cxx_std_17)
target_link_libraries(searchi-vad-diagnostic PRIVATE whisper)
+31
View File
@@ -0,0 +1,31 @@
# CPU speech-activity diagnostic
Research tooling, separate from searchi's normal GPU-capable runtime. No model
download, transcription, source indexing or product default is changed.
Use the frozen source and model hashes in [the protocol](../../docs/SPEECH-VAD.md).
```sh
cmake -S tools/speech-vad -B .searchi/speech/build-vad-diagnostic -DWHISPER_SOURCE=/absolute/path/to/pinned/whisper.cpp
cmake --build .searchi/speech/build-vad-diagnostic --config Release --parallel 1
```
Run `searchi-vad-diagnostic MODEL PCM_F32LE`, retaining stdout and stderr
separately. Independently verify the model SHA-256 before running. Input must
be verified mono 16-kHz little-endian float32 PCM, at most ten minutes, with
finite normalized samples. CPU and one inference thread are fixed. Native error
logs invalidate detection even when upstream returns success. Probability
count must match the pinned 512-sample inference framing.
Output JSON retains every probability and raw centisecond interval, source
sample count and load/detection/segmentation milliseconds. Interval padding is
already applied. Preserve this raw output; a subsequent harness must explicitly
intersect intervals with actual decoded source samples and add the verified
source origin exactly once. Do not concatenate disjoint intervals without a
piecewise source map. Zero intervals are a valid no-speech decision, distinct
from nonzero exit status or missing JSON.
The wrapper fixes the preregistered segmentation parameters: threshold 0.5,
minimum speech 250 ms, silence 100 ms, maximum speech FLT_MAX, padding 30 ms,
overlap 0.1 seconds. It does not establish model accuracy or a production
speech gate. Compare the complete frozen speech/music/sound pool before judging
quality; preserve original Google audio retrieval regardless of transcription.
+81
View File
@@ -0,0 +1,81 @@
// Research only. Input: verified mono 16-kHz little-endian float32 PCM.
#include "whisper.h"
#include <atomic>
#include <chrono>
#include <cmath>
#include <cfloat>
#include <cstdio>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <vector>
static std::atomic<bool> failed{false};
static void log_message(enum ggml_log_level level, const char * text, void *) {
if (level == GGML_LOG_LEVEL_ERROR) failed.store(true);
std::fputs(text, stderr);
}
using Clock = std::chrono::steady_clock;
static double elapsed(Clock::time_point start) {
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
}
int main(int argc, char ** argv) {
if (argc != 3) { std::cerr << "Expected MODEL PCM_F32LE\n"; return 2; }
const unsigned short endian = 1;
if (*reinterpret_cast<const unsigned char *>(&endian) != 1 || sizeof(float) != 4) return 2;
std::ifstream input(argv[2], std::ios::binary | std::ios::ate);
if (!input) return 2;
const auto bytes = input.tellg();
// At most ten minutes. Never allocate according to an unchecked file size.
if (bytes <= 0 || bytes > 16000LL * 600 * 4 || bytes % 4 != 0) return 2;
std::vector<float> samples(static_cast<size_t>(bytes) / 4);
input.seekg(0);
if (!input.read(reinterpret_cast<char *>(samples.data()), bytes)) return 2;
for (float sample : samples) if (!std::isfinite(sample) || std::abs(sample) > 1.0f) return 2;
whisper_log_set(log_message, nullptr);
auto context_params = whisper_vad_default_context_params();
context_params.n_threads = 1;
context_params.use_gpu = false;
auto started = Clock::now();
auto * context = whisper_vad_init_from_file_with_params(argv[1], context_params);
const double load_ms = elapsed(started);
if (!context || failed.load()) { if (context) whisper_vad_free(context); return 3; }
started = Clock::now();
const bool detected = whisper_vad_detect_speech(context, samples.data(), static_cast<int>(samples.size()));
const double detect_ms = elapsed(started);
const int count = whisper_vad_n_probs(context);
const float * probs = whisper_vad_probs(context);
bool valid = detected && !failed.load() && probs && count == static_cast<int>((samples.size() + 511) / 512);
for (int i = 0; valid && i < count; ++i) valid = std::isfinite(probs[i]) && probs[i] >= 0 && probs[i] <= 1;
if (!valid) { whisper_vad_free(context); return 3; }
started = Clock::now();
const whisper_vad_params params{0.5f, 250, 100, FLT_MAX, 30, 0.1f};
auto * segments = whisper_vad_segments_from_probs(context, params);
const double segment_ms = elapsed(started);
if (!segments || failed.load()) { if (segments) whisper_vad_free_segments(segments); whisper_vad_free(context); return 3; }
float previous_end = 0;
for (int i = 0; i < whisper_vad_segments_n_segments(segments); ++i) {
const float start = whisper_vad_segments_get_segment_t0(segments, i);
const float end = whisper_vad_segments_get_segment_t1(segments, i);
if (!std::isfinite(start) || !std::isfinite(end) || start < previous_end || end <= start) {
whisper_vad_free_segments(segments); whisper_vad_free(context); return 3;
}
previous_end = end;
}
// Emit only after all native operations succeed. Raw centiseconds retained;
// the caller maps intervals to verified source bounds and absolute origin.
std::cout << std::setprecision(9) << "{\"schema\":1,\"sample_rate\":16000,\"samples\":" << samples.size()
<< ",\"threads\":1,\"gpu\":false,\"load_ms\":" << load_ms
<< ",\"detect_ms\":" << detect_ms << ",\"segment_ms\":" << segment_ms << ",\"probabilities\":[";
for (int i = 0; i < count; ++i) { if (i) std::cout << ','; std::cout << probs[i]; }
std::cout << "],\"intervals_cs\":[";
for (int i = 0; i < whisper_vad_segments_n_segments(segments); ++i) {
if (i) std::cout << ',';
std::cout << '[' << whisper_vad_segments_get_segment_t0(segments, i) << ','
<< whisper_vad_segments_get_segment_t1(segments, i) << ']';
}
std::cout << "]}\n";
whisper_vad_free_segments(segments);
whisper_vad_free(context);
return std::cout.good() ? 0 : 4;
}
+53
View File
@@ -0,0 +1,53 @@
"""Actual CPU model contract checks; explicit asset paths required."""
import hashlib
import json
import os
from pathlib import Path
import struct
import subprocess
import tempfile
import unittest
class NativeContract(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.worker = Path(os.environ["SEARCHI_VAD_DIAGNOSTIC"])
cls.model = Path(os.environ["SEARCHI_VAD_MODEL"])
if hashlib.sha256(cls.model.read_bytes()).hexdigest() != "2aa269b785eeb53a82983a20501ddf7c1d9c48e33ab63a41391ac6c9f7fb6987":
raise ValueError("Expected frozen Silero v6.2.0 export")
def run_pcm(self, data):
with tempfile.TemporaryDirectory(prefix="searchi-vad-contract-") as scratch:
pcm = Path(scratch) / "samples.f32"
pcm.write_bytes(data)
return subprocess.run([str(self.worker), str(self.model), str(pcm)],
capture_output=True, timeout=30, check=False)
def test_real_model_silence_tail(self):
result = self.run_pcm(b"\0" * (16001 * 4))
self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", errors="replace"))
report = json.loads(result.stdout)
self.assertEqual(report["samples"], 16001)
self.assertEqual(len(report["probabilities"]), 32)
self.assertEqual(report["intervals_cs"], [])
self.assertEqual(report["threads"], 1)
self.assertIs(report["gpu"], False)
for key in ("load_ms", "detect_ms", "segment_ms"):
self.assertGreaterEqual(report[key], 0)
def test_invalid_pcm(self):
for data in (b"", b"x", struct.pack("<f", float("nan")), struct.pack("<f", 2)):
with self.subTest(data=data):
result = self.run_pcm(data)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, b"")
def test_unknown_arguments_fail(self):
result = subprocess.run([str(self.worker), "--unknown"], capture_output=True, timeout=5)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, b"")
if __name__ == "__main__":
unittest.main()