voice_audio_multi/tests/unittests/test_earshot_vad.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

510 lines
24 KiB
C++

#include "engine/framework/audio/chunking.h"
#include "engine/framework/audio/conversion.h"
#include "engine/framework/debug/trace.h"
#include "engine/framework/io/json.h"
#include "engine/framework/runtime/registry.h"
#include "engine/community_models/earshot_vad/runtime.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <random>
#include <stdexcept>
#include <string>
#include <system_error>
#include <vector>
#ifndef ENGINE_REPO_ROOT
#define ENGINE_REPO_ROOT "."
#endif
#ifndef ENGINE_TEST_ASSET_ROOT
#define ENGINE_TEST_ASSET_ROOT "tests/unittests/assets"
#endif
namespace {
namespace ev = engine::community_models::earshot_vad;
namespace rt = engine::runtime;
std::filesystem::path repo_path(const std::string & relative) {
return std::filesystem::path(ENGINE_REPO_ROOT) / relative;
}
std::filesystem::path model_dir() {
return repo_path("assets/framework/models/earshot_vad");
}
void require(bool condition, const std::string & message) {
if (!condition) {
throw std::runtime_error(message);
}
}
std::shared_ptr<const ev::EarshotWeights> weights() {
return ev::load_earshot_weights_cached(ev::resolve_earshot_assets(model_dir()).checkpoint_path);
}
std::vector<ev::EarshotIsa> supported_isas() {
std::vector<ev::EarshotIsa> out;
for (const auto isa : {ev::EarshotIsa::Baseline, ev::EarshotIsa::Avx2, ev::EarshotIsa::Avx512}) {
try {
out.push_back(ev::resolve_earshot_isa(isa));
} catch (const std::runtime_error &) {
}
}
return out;
}
std::vector<float> load_wav(const std::string & relative) {
return engine::audio::read_wav_f32_as_mono_linear_resampled(repo_path(relative), ev::kSampleRate);
}
struct LoadedSession {
std::unique_ptr<rt::ILoadedVoiceModel> model;
std::unique_ptr<rt::IVoiceTaskSession> session;
};
LoadedSession open_session(rt::RunMode mode) {
LoadedSession out;
out.model = rt::make_default_registry().load(model_dir());
require(out.model->metadata().family == "earshot_vad", "registry did not pick earshot_vad");
out.session = out.model->create_task_session({rt::VoiceTaskKind::Vad, mode}, {});
rt::SessionPreparationRequest prepare;
out.session->prepare(prepare);
return out;
}
std::vector<rt::SpeechSegment> run_offline(const rt::AudioBuffer & audio) {
auto loaded = open_session(rt::RunMode::Offline);
auto & offline = dynamic_cast<rt::IOfflineVoiceTaskSession &>(*loaded.session);
rt::TaskRequest request;
request.audio_input = audio;
return offline.run(request).speech_segments;
}
std::vector<rt::SpeechSegment> run_streaming(
const std::vector<float> & audio, size_t chunk, size_t * end_events, size_t * start_events = nullptr) {
auto loaded = open_session(rt::RunMode::Streaming);
auto & streaming = dynamic_cast<rt::IStreamingVoiceTaskSession &>(*loaded.session);
streaming.start_stream({});
size_t ends = 0;
size_t starts = 0;
for (size_t offset = 0; offset < audio.size(); offset += chunk) {
rt::AudioChunk piece;
piece.sample_rate = ev::kSampleRate;
piece.channels = 1;
piece.start_sample = static_cast<int64_t>(offset);
piece.samples.assign(audio.begin() + offset, audio.begin() + std::min(audio.size(), offset + chunk));
for (const auto & event : streaming.process_audio_chunk(piece).voice_activity) {
if (event.kind == rt::VoiceActivityEvent::Kind::SpeechStart) {
++starts;
}
if (event.kind == rt::VoiceActivityEvent::Kind::SpeechEnd) {
require(event.segment.has_value(), "SpeechEnd without segment");
++ends;
}
}
}
if (end_events != nullptr) {
*end_events = ends;
}
if (start_events != nullptr) {
*start_events = starts;
}
return streaming.finish_stream().speech_segments;
}
void test_upstream_parity() {
const auto reference = engine::io::json::parse_jsonc_file(
std::filesystem::path(ENGINE_TEST_ASSET_ROOT) / "earshot_vad" / "upstream_reference.json");
const auto w = weights();
for (const auto & file : reference.find("files")->as_array()) {
const auto wav = file.find("wav")->as_string();
const auto audio = load_wav(wav);
require(static_cast<int64_t>(audio.size()) == file.find("num_samples")->as_i64(), wav + ": sample count");
const auto & expected = file.find("scores")->as_array();
const auto scores = ev::score_earshot_frames(w, audio.data(), static_cast<int64_t>(audio.size()), 1);
require(scores.size() == expected.size(), wav + ": frame count");
double max_diff = 0.0;
for (size_t i = 0; i < scores.size(); ++i) {
max_diff = std::max(max_diff, std::abs(static_cast<double>(scores[i]) - expected[i].as_number()));
}
// FFT order, ln/exp and mel sum order differ from upstream; observed max ~2e-5.
require(max_diff <= 5e-4, wav + ": max score diff " + std::to_string(max_diff));
// Threads and the frame-by-frame detector run the same kernels: identical bits.
const auto threaded = ev::score_earshot_frames(w, audio.data(), static_cast<int64_t>(audio.size()), 4);
require(threaded == scores, wav + ": threaded scoring differs from one thread");
ev::EarshotDetector detector(w);
for (size_t f = 0; f < scores.size(); ++f) {
const float s = detector.predict(audio.data() + static_cast<int64_t>(f) * ev::kFrameSize);
require(s == scores[f], wav + ": detector differs from batched scoring");
}
for (const auto isa : supported_isas()) {
const auto other = ev::score_earshot_frames(w, audio.data(), static_cast<int64_t>(audio.size()), 1, isa);
require(other == scores, wav + ": " + ev::earshot_kernel_name(isa) + " scores differ");
}
ev::EarshotSegmenterConfig upstream;
upstream.keep_trailing_segment = false;
const auto segments = ev::segment_earshot_scores(scores, upstream);
const auto & expected_segments = file.find("segments")->as_array();
require(segments.size() == expected_segments.size(), wav + ": segment count");
for (size_t i = 0; i < segments.size(); ++i) {
// Upstream segments.rs skips its frame counter increment on every frame that
// returns a segment, so segment i is reported i frames early there.
const auto lag = static_cast<int64_t>(i) * ev::kFrameSize;
const auto & pair = expected_segments[i].as_array();
require(segments[i].start_frame * ev::kFrameSize == pair[0].as_i64() + lag &&
segments[i].end_frame * ev::kFrameSize == pair[1].as_i64() + lag,
wav + ": segment " + std::to_string(i) + " differs from upstream");
}
}
}
void test_trailing_segment_kept() {
// This clip ends mid-speech; upstream drops that segment, audio.cpp keeps it.
const auto audio = load_wav("assets/asr_validation/librispeech/librispeech_test_clean_6930-75918-0000.wav");
const auto segments = run_offline({ev::kSampleRate, 1, audio});
require(segments.size() == 1, "expected one trailing speech segment");
require(segments[0].span.end_sample == static_cast<int64_t>(audio.size()), "trailing segment must reach the end");
require(segments[0].span.start_sample > 0 && segments[0].confidence > 0.5f, "trailing segment looks wrong");
}
void test_streaming_matches_offline() {
const auto audio = load_wav("tests/parakeet_tdt/assets/2086-149220-0033.wav");
const auto offline = run_offline({ev::kSampleRate, 1, audio});
require(offline.size() >= 2, "expected several segments");
for (const size_t chunk : {size_t{1}, size_t{100}, size_t{256}, size_t{4000}}) {
size_t ends = 0;
const auto streamed = run_streaming(audio, chunk, &ends);
require(streamed.size() == offline.size(), "streaming segment count, chunk " + std::to_string(chunk));
for (size_t i = 0; i < streamed.size(); ++i) {
require(streamed[i].span.start_sample == offline[i].span.start_sample &&
streamed[i].span.end_sample == offline[i].span.end_sample,
"streaming segment differs, chunk " + std::to_string(chunk));
}
require(ends + 1 >= streamed.size(), "missing SpeechEnd events");
}
// Streaming takes mono only (the driver counts interleaved samples).
{
auto loaded = open_session(rt::RunMode::Streaming);
auto & streaming = dynamic_cast<rt::IStreamingVoiceTaskSession &>(*loaded.session);
streaming.start_stream({});
bool threw = false;
try {
(void)streaming.process_audio_chunk({ev::kSampleRate, 2, 0, std::vector<float>(512, 0.0f)});
} catch (const std::runtime_error &) {
threw = true;
}
require(threw, "stereo streaming chunk accepted");
}
// Every segment in the final result was announced by a SpeechStart event, including a
// trailing segment closed by finalize() right at the minimum duration.
for (int64_t frames = 30; frames <= 50; ++frames) {
const std::vector<float> cut(audio.begin(), audio.begin() + frames * ev::kFrameSize);
size_t starts = 0;
const auto streamed = run_streaming(cut, 256, nullptr, &starts);
require(starts == streamed.size(),
"segment without SpeechStart event at " + std::to_string(frames) + " frames");
}
// A second run on a fresh stream must be identical.
require(run_streaming(audio, 333, nullptr).size() == offline.size(), "stream restart is not deterministic");
}
void test_silence_and_noise() {
std::vector<float> silence(ev::kSampleRate * 5, 0.0f);
require(run_offline({ev::kSampleRate, 1, silence}).empty(), "silence produced segments");
std::mt19937 rng(7);
std::normal_distribution<float> noise(0.0f, 0.01f);
std::vector<float> hiss(ev::kSampleRate * 5);
for (auto & sample : hiss) {
sample = noise(rng);
}
require(run_offline({ev::kSampleRate, 1, hiss}).empty(), "white noise produced segments");
require(run_offline({ev::kSampleRate, 1, std::vector<float>(100, 0.0f)}).empty(), "short input produced segments");
}
void test_stereo_resampled_input() {
const auto audio = load_wav("tests/parakeet_tdt/assets/2086-149220-0033.wav");
const auto reference = run_offline({ev::kSampleRate, 1, audio});
// 48 kHz interleaved stereo via linear upsampling.
const auto upsampled = engine::audio::convert_interleaved_audio_to_mono_linear_resampled(audio, 16000, 1, 48000);
std::vector<float> stereo(upsampled.size() * 2);
for (size_t i = 0; i < upsampled.size(); ++i) {
stereo[2 * i] = upsampled[i];
stereo[2 * i + 1] = upsampled[i];
}
const auto segments = run_offline({48000, 2, stereo});
require(segments.size() == reference.size(), "48 kHz stereo segment count");
for (size_t i = 0; i < segments.size(); ++i) {
const auto tolerance = 2 * ev::kFrameSize * 3;
require(std::abs(segments[i].span.start_sample - reference[i].span.start_sample * 3) <= tolerance &&
std::abs(segments[i].span.end_sample - reference[i].span.end_sample * 3) <= tolerance,
"48 kHz stereo spans are not mapped back to input samples");
}
}
void test_options() {
const auto config = ev::earshot_config_from_options({
{"threshold", "0.7"},
{"neg_threshold", "0.3"},
{"speech_pad_ms", "0"},
{"min_speech_duration_ms", "250"},
{"min_silence_duration_ms", "100"},
{"earshot_vad.keep_trailing_segment", "false"},
});
require(config.threshold == 0.7f && config.neg_threshold == 0.3f, "threshold options");
require(config.preroll_frames == 0 && config.min_duration_frames == 16 && config.end_delay_frames == 7, "ms options");
require(!config.keep_trailing_segment, "keep_trailing_segment option");
bool threw = false;
try {
(void)ev::earshot_config_from_options({{"threshold", "1.5"}});
} catch (const std::runtime_error &) {
threw = true;
}
require(threw, "out-of-range threshold accepted");
// Extreme settings must still give well-formed, ordered spans.
const auto audio = load_wav("tests/parakeet_tdt/assets/2086-149220-0033.wav");
const auto w = weights();
const auto scores = ev::score_earshot_frames(w, audio.data(), static_cast<int64_t>(audio.size()), 1);
const auto loose = ev::earshot_config_from_options({{"speech_pad_ms", "0"}, {"min_speech_duration_ms", "0"},
{"start_delay_ms", "0"}, {"min_silence_duration_ms", "0"}});
const auto segments = ev::segment_earshot_scores(scores, loose);
require(!segments.empty(), "loose options produced no segments");
for (const auto & segment : segments) {
require(segment.end_frame > segment.start_frame, "empty segment with loose options");
require(segment.end_frame <= static_cast<int64_t>(scores.size()), "segment past the end");
}
const auto defaults = ev::earshot_config_from_options({});
require(defaults.preroll_frames == 5 && defaults.min_duration_frames == 6 && defaults.start_delay_frames == 2 &&
defaults.end_delay_frames == 4,
"defaults must match upstream SegmenterOptions");
}
void test_long_input_blocks() {
// Offline scoring runs in 4096-frame blocks. Two full blocks plus a short
// tail (scored without threads) must match the frame-by-frame detector,
// which has no blocks, for every thread count.
const auto speech = load_wav("tests/parakeet_tdt/assets/2086-149220-0033.wav");
const int64_t frames = 2 * 4096 + 100;
std::vector<float> audio(static_cast<size_t>(frames * ev::kFrameSize) + 77);
std::mt19937 rng(3);
std::normal_distribution<float> noise(0.0f, 0.003f);
for (size_t i = 0; i < audio.size(); ++i) {
audio[i] = speech[i % speech.size()] + noise(rng);
}
const auto w = weights();
for (const auto isa : supported_isas()) {
const std::string name = ev::earshot_kernel_name(isa);
ev::EarshotDetector detector(w, isa);
std::vector<float> expected(static_cast<size_t>(frames));
for (int64_t f = 0; f < frames; ++f) {
expected[static_cast<size_t>(f)] = detector.predict(audio.data() + f * ev::kFrameSize);
}
for (const int threads : {1, 3, 4, 8}) {
const auto scores =
ev::score_earshot_frames(w, audio.data(), static_cast<int64_t>(audio.size()), threads, isa);
require(scores == expected,
name + ": long input differs from the detector, threads " + std::to_string(threads));
}
}
}
void test_kernel_identity() {
// Every kernel set, compiler and platform must give these exact bits.
const auto audio = load_wav("assets/asr_validation/librispeech/librispeech_test_clean_6930-75918-0001.wav");
const auto w = weights();
for (const auto isa : supported_isas()) {
// Scores are identical by design, so check the table name to see which kernels ran.
const std::string name = ev::earshot_isa_name(isa);
require(ev::earshot_kernel_name(isa) == name, name + " runs the " + ev::earshot_kernel_name(isa) + " kernels");
const auto scores = ev::score_earshot_frames(w, audio.data(), static_cast<int64_t>(audio.size()), 4, isa);
uint64_t hash = 1469598103934665603ull; // FNV-1a over the score bits
for (const float score : scores) {
uint32_t bits;
std::memcpy(&bits, &score, sizeof(bits));
for (int b = 0; b < 4; ++b) {
hash = (hash ^ ((bits >> (8 * b)) & 0xff)) * 1099511628211ull;
}
}
char hex[17];
std::snprintf(hex, sizeof(hex), "%016llx", static_cast<unsigned long long>(hash));
require(scores.size() == 889 && std::string(hex) == "9f3e04a6ceab8012",
name + " scores hash " + hex + " over " + std::to_string(scores.size()) + " frames");
ev::EarshotDetector detector(w, isa);
require(detector.kernel_name() == name, name + " detector runs " + detector.kernel_name());
for (size_t f = 0; f < scores.size(); ++f) {
require(detector.predict(audio.data() + static_cast<int64_t>(f) * ev::kFrameSize) == scores[f],
name + ": detector differs from batched scoring");
}
}
}
void test_exec_plan_isa() {
const auto plan = [](const std::string & json) {
return ev::earshot_isa_from_options({{"earshot_vad.exec_plan", json}});
};
const auto throws = [&](const std::string & json) {
try {
(void)plan(json);
} catch (const std::runtime_error &) {
return true;
}
return false;
};
const auto best = ev::resolve_earshot_isa(ev::EarshotIsa::Auto);
require(best != ev::EarshotIsa::Auto, "auto must resolve to a kernel set");
require(ev::earshot_isa_from_options({}) == best, "default isa must be auto");
require(plan(R"({"detector":{"impl":"native","backend":"cpu","params":{"isa":"auto"}}})") == best, "isa auto");
require(plan(R"({"detector":{"params":{"isa":"baseline"}}})") == ev::EarshotIsa::Baseline, "isa baseline");
for (const auto isa : {ev::EarshotIsa::Avx2, ev::EarshotIsa::Avx512}) {
const std::string json = std::string(R"({"detector":{"params":{"isa":")") + ev::earshot_isa_name(isa) + "\"}}}";
const auto supported = supported_isas();
if (std::find(supported.begin(), supported.end(), isa) != supported.end()) {
require(plan(json) == isa, std::string("isa ") + ev::earshot_isa_name(isa));
} else {
require(throws(json), std::string("unsupported isa accepted: ") + ev::earshot_isa_name(isa));
}
}
require(throws(R"({"detector":{"params":{"isa":"sse4"}}})"), "unknown isa accepted");
require(throws(R"({"detector":{"params":{"isa":2}}})"), "non-string isa accepted");
require(throws(R"({"detector":{"params":{"threads":2}}})"), "unknown param accepted");
require(throws(R"({"detector":{"impl":"neon"}})"), "unknown impl accepted");
require(throws(R"({"detector":{"backend":"cuda"}})"), "non-cpu backend accepted");
require(throws(R"({"vocoder":{}})"), "unknown component accepted");
// Each session runs the kernel set it asked for. Scores are the same for every
// set, so check the kernel name the session logs ("earshot_vad.kernel <name>").
const auto model = rt::make_default_registry().load(model_dir());
// A per-process file name, so concurrent test runs don't share the log.
const auto log_path = std::filesystem::temp_directory_path() /
("earshot_vad_test_kernel_" + std::to_string(std::random_device{}()) + "_" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".log");
// Turn logging off and remove the file even when a check throws.
struct LogGuard {
std::filesystem::path path;
~LogGuard() {
engine::debug::reset_logging();
std::error_code ignored;
std::filesystem::remove(path, ignored);
}
} guard{log_path};
const auto speech = load_wav("tests/parakeet_tdt/assets/2086-149220-0033.wav");
std::vector<rt::SpeechSegment> reference;
for (const auto isa : supported_isas()) {
const std::string name = ev::earshot_isa_name(isa);
rt::SessionOptions options;
options.options["earshot_vad.exec_plan"] = "{\"detector\":{\"params\":{\"isa\":\"" + name + "\"}}}";
std::filesystem::remove(log_path);
engine::debug::configure_logging({true, log_path.string()});
auto offline = model->create_task_session({rt::VoiceTaskKind::Vad, rt::RunMode::Offline}, options);
offline->prepare({});
rt::TaskRequest request;
request.audio_input = rt::AudioBuffer{ev::kSampleRate, 1, speech};
const auto segments = dynamic_cast<rt::IOfflineVoiceTaskSession &>(*offline).run(request).speech_segments;
auto streaming = model->create_task_session({rt::VoiceTaskKind::Vad, rt::RunMode::Streaming}, options);
streaming->prepare({});
dynamic_cast<rt::IStreamingVoiceTaskSession &>(*streaming).start_stream({});
engine::debug::reset_logging();
std::vector<std::string> logged;
std::ifstream log(log_path);
for (std::string line; std::getline(log, line);) {
const auto at = line.find("earshot_vad.kernel ");
if (at != std::string::npos) {
std::string value = line.substr(at + 19);
while (!value.empty() && (value.back() == '\r' || value.back() == ' ')) {
value.pop_back();
}
logged.push_back(value);
}
}
log.close();
std::filesystem::remove(log_path);
// One line from offline scoring, one from start_stream.
require(logged.size() == 2 && logged[0] == name && logged[1] == name,
name + " session did not run the " + name + " kernels");
require(!segments.empty(), name + " session found no speech");
if (reference.empty()) {
reference = segments;
continue;
}
require(segments.size() == reference.size(), name + " session segment count differs");
for (size_t i = 0; i < segments.size(); ++i) {
require(segments[i].span.start_sample == reference[i].span.start_sample &&
segments[i].span.end_sample == reference[i].span.end_sample &&
segments[i].confidence == reference[i].confidence,
name + " session segments differ");
}
}
rt::SessionOptions bad;
bad.options["earshot_vad.exec_plan"] = R"({"detector":{"params":{"isa":"sse4"}}})";
bool threw = false;
try {
(void)model->create_task_session({rt::VoiceTaskKind::Vad, rt::RunMode::Offline}, bad);
} catch (const std::runtime_error &) {
threw = true;
}
require(threw, "session accepted an invalid isa");
}
void test_loader_detection() {
const auto registry = rt::make_default_registry();
rt::ModelLoadRequest silero;
silero.model_path = repo_path("assets/framework/models/silero_vad");
require(registry.load(silero)->metadata().family == "silero_vad", "silero dir must still load as silero_vad");
rt::ModelLoadRequest by_alias;
by_alias.model_path = model_dir();
by_alias.family_hint = "earshot";
require(registry.load(by_alias)->metadata().family == "earshot_vad", "earshot alias");
}
void test_vad_chunk_planner() {
// Hosts that load their VAD through the registry (e.g. gigaam_asr) feed it to the chunk planner.
auto model = rt::make_default_registry().load(model_dir() / ev::kWeightFile);
require(model->metadata().family == "earshot_vad", "registry load of the earshot weight file");
auto session = model->create_task_session({rt::VoiceTaskKind::Vad, rt::RunMode::Offline}, {});
auto & offline = dynamic_cast<rt::IOfflineVoiceTaskSession &>(*session);
const auto audio = load_wav("tests/parakeet_tdt/assets/2086-149220-0033.wav");
const auto chunks = engine::audio::plan_vad_audio_chunks(
{ev::kSampleRate, 1, audio}, offline, engine::audio::VadAudioChunkOptions{30 * ev::kSampleRate, 8000, 4000});
require(!chunks.empty(), "VAD chunk planner produced no chunks");
}
} // namespace
int main() {
std::cout << "earshot_vad kernels (auto): " << ev::earshot_isa_name(ev::resolve_earshot_isa(ev::EarshotIsa::Auto))
<< "\n";
const std::vector<std::pair<const char *, void (*)()>> tests = {
{"upstream_parity", test_upstream_parity},
{"trailing_segment_kept", test_trailing_segment_kept},
{"streaming_matches_offline", test_streaming_matches_offline},
{"silence_and_noise", test_silence_and_noise},
{"stereo_resampled_input", test_stereo_resampled_input},
{"options", test_options},
{"long_input_blocks", test_long_input_blocks},
{"kernel_identity", test_kernel_identity},
{"exec_plan_isa", test_exec_plan_isa},
{"loader_detection", test_loader_detection},
{"vad_chunk_planner", test_vad_chunk_planner},
};
int failures = 0;
for (const auto & [name, test] : tests) {
try {
test();
std::cout << "[PASS] " << name << "\n";
} catch (const std::exception & error) {
std::cerr << "[FAIL] " << name << ": " << error.what() << "\n";
++failures;
}
}
return failures == 0 ? 0 : 1;
}