Agent ran out of API credits before committing. Files staged + committed by orchestrator. Coverage:
- tests/cpp/ml_golden_vectors.cpp: fixed-seed regression tests for MLP determinism
- tests/cpp/engine_impulse.cpp: white-noise impulse responses with binary baseline
- tests/cpp/parity_check.cpp + parity_wasm.mjs + parity_diff.mjs: native vs WASM bit-equivalence
- scripts/build-cpp-tests.sh, lint-cpp.sh, parity-check.sh, run-all-tests.sh
- playground/tests/e2e/{ml-engine,modes,persistence,ui-interactions}.spec.ts (+helpers)
- .github/workflows/ci.yml
(meml-x06)
337 lines
12 KiB
C++
337 lines
12 KiB
C++
// tests/cpp/engine_impulse.cpp — impulse-response baselines for every audio
|
|
// engine.
|
|
//
|
|
// What we're checking
|
|
// -------------------
|
|
// For each engine in the build, drive it with a SHORT, FIXED stimulus
|
|
// (single-sample impulse at index 0) and capture a SHORT, FIXED response
|
|
// length (256 samples). Compare the L+R energy curve and a handful of sample-
|
|
// position checks against a baseline captured on a known-good build.
|
|
//
|
|
// We DON'T assert bit-perfect match — DSP code is bit-fragile under different
|
|
// compilers and optimisation levels. We assert:
|
|
// 1. Output is finite (no NaN/Inf).
|
|
// 2. Output is bounded — engines don't blow up to >10 amplitude on a unit
|
|
// impulse.
|
|
// 3. Energy in a fixed window matches the baseline within a generous
|
|
// tolerance (1e-3 absolute for energy, 1e-4 sample-wise).
|
|
//
|
|
// The baseline is captured ONCE, written next to this file as
|
|
// `engine_impulse_baseline.bin`, then read back in subsequent runs. Set
|
|
// NISPS_REGEN_BASELINE=1 to overwrite. Set NISPS_BASELINE_PATH=/some/path
|
|
// to override the file location (useful for CI artifact upload).
|
|
//
|
|
// Per-engine setup
|
|
// ----------------
|
|
// Engines satisfying nisps::AudioEngine are:
|
|
// PAFSynth, ChannelStrip, XIASRI, VerbFX, MEMLCelium, BreakOr, Elysiamorf,
|
|
// Analysis, NoOp.
|
|
//
|
|
// PAFSynth is a generator (ignores input) — we still drive it with the
|
|
// impulse stimulus and trust it to produce its idle output. BreakOr and
|
|
// Elysiamorf are sequencers that emit on their own clock; we just care that
|
|
// they don't crash. The impulse test is a smoke test, not a frequency-domain
|
|
// validation.
|
|
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "test_helpers.hpp"
|
|
|
|
#include "../../nisps/engines/analysis.hpp"
|
|
#include "../../nisps/engines/base.hpp"
|
|
#include "../../nisps/engines/breakor.hpp"
|
|
#include "../../nisps/engines/channel_strip.hpp"
|
|
#include "../../nisps/engines/elysiamorf.hpp"
|
|
#include "../../nisps/engines/memlcelium.hpp"
|
|
#include "../../nisps/engines/paf_synth.hpp"
|
|
#include "../../nisps/engines/verb_fx.hpp"
|
|
#include "../../nisps/engines/xiasri.hpp"
|
|
|
|
namespace {
|
|
|
|
constexpr std::size_t kFrames = 256u;
|
|
constexpr float kSampleRate = 48000.0f;
|
|
constexpr float kImpulseAmp = 0.5f;
|
|
constexpr float kSampleTol = 1.0e-4f;
|
|
constexpr float kEnergyTol = 1.0e-3f;
|
|
constexpr float kBoundAbs = 10.0f; // any engine exceeding this is broken
|
|
|
|
struct ImpulseResult {
|
|
std::array<float, kFrames> left{};
|
|
std::array<float, kFrames> right{};
|
|
float energy = 0.f;
|
|
};
|
|
|
|
// Drive engine with an impulse at sample 0 (kImpulseAmp on both channels) and
|
|
// silence after. Returns kFrames samples on each channel plus total energy.
|
|
template <typename E>
|
|
ImpulseResult run_impulse(E& e) {
|
|
e.setup(kSampleRate);
|
|
// Default params at midpoint — engines often have a tame default at 0.5.
|
|
if constexpr (E::param_count() > 0u) {
|
|
std::array<float, E::param_count()> p{};
|
|
for (auto& v : p) v = 0.5f;
|
|
e.set_params(std::span<const float>(p.data(), p.size()));
|
|
}
|
|
|
|
ImpulseResult out;
|
|
for (std::size_t i = 0; i < kFrames; ++i) {
|
|
nisps::stereosample_t in{0.f, 0.f};
|
|
if (i == 0) { in.L = kImpulseAmp; in.R = kImpulseAmp; }
|
|
const auto y = e.process(in);
|
|
out.left[i] = y.L;
|
|
out.right[i] = y.R;
|
|
out.energy += y.L * y.L + y.R * y.R;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Guard rails common to every engine.
|
|
void assert_finite_and_bounded(const ImpulseResult& r, const char* engine) {
|
|
bool finite = true;
|
|
bool bounded = true;
|
|
for (std::size_t i = 0; i < kFrames; ++i) {
|
|
if (!std::isfinite(r.left[i]) || !std::isfinite(r.right[i])) finite = false;
|
|
if (std::fabs(r.left[i]) > kBoundAbs) bounded = false;
|
|
if (std::fabs(r.right[i]) > kBoundAbs) bounded = false;
|
|
}
|
|
if (!finite) std::fprintf(stderr, " engine %s produced non-finite samples\n", engine);
|
|
if (!bounded) std::fprintf(stderr, " engine %s produced samples outside ±%.1f\n",
|
|
engine, kBoundAbs);
|
|
NISPS_EXPECT(finite);
|
|
NISPS_EXPECT(bounded);
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// Baseline file format
|
|
// -----------------------------------------------------------------
|
|
//
|
|
// uint32 magic = 'NIPB' // Nisps Impulse Baseline
|
|
// uint32 version = 1
|
|
// uint32 n_engines
|
|
// for each engine:
|
|
// uint32 name_len
|
|
// char[] name (no NUL)
|
|
// uint32 frames // = kFrames
|
|
// float energy
|
|
// float[] left (frames)
|
|
// float[] right (frames)
|
|
|
|
constexpr std::uint32_t kMagic = 0x4250494eu; // 'NIPB' little-endian = N I P B
|
|
constexpr std::uint32_t kVersion = 1u;
|
|
|
|
struct BaselineEntry {
|
|
std::string name;
|
|
ImpulseResult result;
|
|
};
|
|
|
|
bool regen_baseline_mode() {
|
|
const char* env = std::getenv("NISPS_REGEN_BASELINE");
|
|
return env && env[0] == '1';
|
|
}
|
|
|
|
std::string baseline_path() {
|
|
const char* env = std::getenv("NISPS_BASELINE_PATH");
|
|
if (env && env[0]) return env;
|
|
// Default: next to this source file. CMake puts the binary in nisps/build,
|
|
// so we look up to two levels for tests/cpp/.
|
|
return "tests/cpp/engine_impulse_baseline.bin";
|
|
}
|
|
|
|
bool write_baseline(const std::string& path,
|
|
const std::vector<BaselineEntry>& entries) {
|
|
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
|
if (!f.good()) return false;
|
|
auto write_u32 = [&](std::uint32_t v) { f.write(reinterpret_cast<const char*>(&v), 4); };
|
|
auto write_f32 = [&](float v) { f.write(reinterpret_cast<const char*>(&v), 4); };
|
|
write_u32(kMagic);
|
|
write_u32(kVersion);
|
|
write_u32(static_cast<std::uint32_t>(entries.size()));
|
|
for (const auto& e : entries) {
|
|
write_u32(static_cast<std::uint32_t>(e.name.size()));
|
|
f.write(e.name.data(), static_cast<std::streamsize>(e.name.size()));
|
|
write_u32(static_cast<std::uint32_t>(kFrames));
|
|
write_f32(e.result.energy);
|
|
for (float v : e.result.left) write_f32(v);
|
|
for (float v : e.result.right) write_f32(v);
|
|
}
|
|
return f.good();
|
|
}
|
|
|
|
bool read_baseline(const std::string& path, std::vector<BaselineEntry>& out) {
|
|
std::ifstream f(path, std::ios::binary);
|
|
if (!f.good()) return false;
|
|
auto read_u32 = [&]() -> std::uint32_t {
|
|
std::uint32_t v = 0u;
|
|
f.read(reinterpret_cast<char*>(&v), 4);
|
|
return v;
|
|
};
|
|
auto read_f32 = [&]() -> float {
|
|
float v = 0.f;
|
|
f.read(reinterpret_cast<char*>(&v), 4);
|
|
return v;
|
|
};
|
|
if (read_u32() != kMagic) return false;
|
|
if (read_u32() != kVersion) return false;
|
|
const std::uint32_t n = read_u32();
|
|
out.clear();
|
|
out.reserve(n);
|
|
for (std::uint32_t i = 0; i < n; ++i) {
|
|
BaselineEntry entry;
|
|
const std::uint32_t name_len = read_u32();
|
|
entry.name.resize(name_len);
|
|
f.read(entry.name.data(), name_len);
|
|
const std::uint32_t frames = read_u32();
|
|
if (frames != kFrames) return false;
|
|
entry.result.energy = read_f32();
|
|
for (auto& v : entry.result.left) v = read_f32();
|
|
for (auto& v : entry.result.right) v = read_f32();
|
|
out.push_back(std::move(entry));
|
|
}
|
|
return f.good() || f.eof();
|
|
}
|
|
|
|
void compare_against_baseline(const char* engine,
|
|
const ImpulseResult& got,
|
|
const std::vector<BaselineEntry>& baseline) {
|
|
for (const auto& e : baseline) {
|
|
if (e.name != engine) continue;
|
|
bool ok = true;
|
|
const float energy_delta = std::fabs(got.energy - e.result.energy);
|
|
if (energy_delta > kEnergyTol) {
|
|
std::fprintf(stderr,
|
|
" %s: energy drift %.6f vs baseline %.6f (delta=%.3e tol=%.3e)\n",
|
|
engine, got.energy, e.result.energy, energy_delta, kEnergyTol);
|
|
ok = false;
|
|
}
|
|
std::size_t bad_samples = 0;
|
|
for (std::size_t i = 0; i < kFrames; ++i) {
|
|
if (std::fabs(got.left[i] - e.result.left[i]) > kSampleTol ||
|
|
std::fabs(got.right[i] - e.result.right[i]) > kSampleTol) {
|
|
++bad_samples;
|
|
}
|
|
}
|
|
if (bad_samples > 0u) {
|
|
std::fprintf(stderr,
|
|
" %s: %zu/%zu samples differ by >%.3e\n",
|
|
engine, bad_samples, kFrames, kSampleTol);
|
|
ok = false;
|
|
}
|
|
NISPS_EXPECT(ok);
|
|
return;
|
|
}
|
|
std::fprintf(stderr,
|
|
" %s: not in baseline file (run with NISPS_REGEN_BASELINE=1)\n",
|
|
engine);
|
|
NISPS_EXPECT(false);
|
|
}
|
|
|
|
// We accumulate every engine's result here so we can write the whole baseline
|
|
// file at the end of the run. Yes, a singleton — but it's test-local and
|
|
// test_main.cpp is the only consumer.
|
|
std::vector<BaselineEntry>& collected() {
|
|
static std::vector<BaselineEntry> v;
|
|
return v;
|
|
}
|
|
|
|
std::vector<BaselineEntry>& cached_baseline() {
|
|
static std::vector<BaselineEntry> v;
|
|
static bool loaded = false;
|
|
if (!loaded) {
|
|
(void)read_baseline(baseline_path(), v);
|
|
loaded = true;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// Run + verify common path. Stages:
|
|
// 1. Run impulse, capture result.
|
|
// 2. Always check finite + bounded.
|
|
// 3. If regen mode: append to collected() to be written later.
|
|
// Else: compare against cached_baseline().
|
|
template <typename E>
|
|
void run_and_check(const char* engine_name) {
|
|
E e;
|
|
auto got = run_impulse(e);
|
|
assert_finite_and_bounded(got, engine_name);
|
|
|
|
if (regen_baseline_mode()) {
|
|
collected().push_back({engine_name, got});
|
|
return;
|
|
}
|
|
|
|
const auto& baseline = cached_baseline();
|
|
if (baseline.empty()) {
|
|
std::fprintf(stderr,
|
|
" %s: no baseline available at %s — run with NISPS_REGEN_BASELINE=1 to create.\n",
|
|
engine_name, baseline_path().c_str());
|
|
// Don't fail outright — record the result so the test binary can be
|
|
// used to bootstrap the baseline.
|
|
collected().push_back({engine_name, got});
|
|
return;
|
|
}
|
|
|
|
compare_against_baseline(engine_name, got, baseline);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
NISPS_TEST(engine_impulse_no_op) {
|
|
run_and_check<nisps::NoOpEngine>("thru");
|
|
}
|
|
NISPS_TEST(engine_impulse_paf_synth) {
|
|
run_and_check<nisps::PAFSynthEngine>("paf_synth");
|
|
}
|
|
NISPS_TEST(engine_impulse_channel_strip) {
|
|
run_and_check<nisps::ChannelStripEngine>("channel_strip");
|
|
}
|
|
NISPS_TEST(engine_impulse_xiasri) {
|
|
run_and_check<nisps::XIASRIEngine>("xiasri");
|
|
}
|
|
NISPS_TEST(engine_impulse_verb_fx) {
|
|
run_and_check<nisps::VerbFXEngine>("verb_fx");
|
|
}
|
|
NISPS_TEST(engine_impulse_memlcelium) {
|
|
run_and_check<nisps::MEMLCeliumEngine>("memlcelium");
|
|
}
|
|
NISPS_TEST(engine_impulse_breakor) {
|
|
run_and_check<nisps::BreakOrEngine>("breakor");
|
|
}
|
|
NISPS_TEST(engine_impulse_elysiamorf) {
|
|
run_and_check<nisps::ElysiamorfEngine>("elysiamorf");
|
|
}
|
|
NISPS_TEST(engine_impulse_analysis) {
|
|
run_and_check<nisps::AnalysisEngine>("analysis");
|
|
}
|
|
|
|
// Final test: if we're in regen mode (or had no baseline to start), persist
|
|
// the collected results so the user can `mv` them into the canonical path.
|
|
NISPS_TEST(engine_impulse_baseline_writeback) {
|
|
if (collected().empty()) return; // pure-pass run, no need to write
|
|
if (!regen_baseline_mode()) {
|
|
// No baseline existed; leave a hint file but DON'T write the canonical
|
|
// path automatically. We don't want a missing baseline to silently
|
|
// self-heal.
|
|
const std::string hint = baseline_path() + ".pending";
|
|
if (write_baseline(hint, collected())) {
|
|
std::printf(" [info] wrote pending baseline to %s — review and rename to %s\n",
|
|
hint.c_str(), baseline_path().c_str());
|
|
}
|
|
return;
|
|
}
|
|
if (!write_baseline(baseline_path(), collected())) {
|
|
std::fprintf(stderr, " failed to write baseline to %s\n", baseline_path().c_str());
|
|
NISPS_EXPECT(false);
|
|
} else {
|
|
std::printf(" [regen] wrote %zu engines to %s\n",
|
|
collected().size(), baseline_path().c_str());
|
|
}
|
|
}
|