memlnaut-nisps/tests/cpp/test_mode_paf_synth.cpp
w1n5t0n 4964037da0 test(nisps/modes): host C++ tests for stream 4 mode layer (meml-beb)
Adds `nisps_modes_tests` executable to nisps/CMakeLists.txt with four TUs:

  - `test_mode_concepts.cpp`: 8 `static_assert(Mode<...>)` (concept
    satisfaction), plus runtime metadata sanity for each mode (mode_id,
    input_channel_count, schema sizes match engine param_count).

  - `test_mode_paf_synth.cpp`: end-to-end exercise — setup, set_input,
    tick_control, process audio. Verifies idle process is finite, output
    bounds [0,1] hold, note_on triggers nonzero audio, input clamping,
    and engine/ml accessors round-trip.

  - `test_mode_voice_space.cpp`: voice-space round trip for PAFSynth,
    ChannelStrip and VerbFX (all dispatched modes). Confirms
    out-of-range index is silently ignored.

  - `test_mode_breakor_events.cpp`: sequencer event pumping. BreakOr
    emits Clock + NoteOn/Off, Elysiamorf emits CC, SoundAnalysisMIDI
    converts 8 ML outputs → 8 ControlEvents (CC 0..7) per tick, ring
    buffer overflow drops cleanly.

Total: 22 new tests (110 across nisps/), all passing under -Werror
-Wpedantic. Build remains clean.
2026-04-29 16:27:03 +03:00

101 lines
2.8 KiB
C++

// tests/cpp/test_mode_paf_synth.cpp — exercise the PAFSynthMode end-to-end:
// setup → set_input → tick_control (ML inference + voice space mapping) →
// process audio → verify output is bounded and non-trivial after note_on.
#include <array>
#include <cmath>
#include "test_helpers.hpp"
#include "../../nisps/modes/paf_synth.hpp"
using nisps::modes::PAFSynthMode;
NISPS_TEST(paf_synth_mode_setup_and_idle_process) {
PAFSynthMode m;
m.setup(48000.f);
// After setup the engine should already have its default params set;
// process() is safe to call and must produce finite samples.
for (int n = 0; n < 256; ++n) {
const auto y = m.process({0.f, 0.f});
NISPS_EXPECT(std::isfinite(y.L));
NISPS_EXPECT(std::isfinite(y.R));
}
}
NISPS_TEST(paf_synth_mode_tick_control_changes_outputs) {
PAFSynthMode m;
m.setup(48000.f);
// Capture an outputs snapshot, then change inputs and tick again.
std::array<float, 33> before{};
{
const auto outs = m.ml().outputs();
for (std::size_t i = 0u; i < outs.size(); ++i) before[i] = outs[i];
}
m.set_input(0, 0.0f);
m.set_input(1, 1.0f);
m.set_input(2, 0.25f);
m.set_input(3, 0.75f);
m.tick_control();
bool any_changed = false;
{
const auto outs = m.ml().outputs();
for (std::size_t i = 0u; i < outs.size(); ++i) {
if (std::fabs(outs[i] - before[i]) > 1e-6f) { any_changed = true; break; }
}
}
NISPS_EXPECT(any_changed);
// All outputs of the sigmoid layer remain in [0, 1].
for (float v : m.ml().outputs()) {
NISPS_EXPECT(v >= 0.f && v <= 1.f);
}
}
NISPS_TEST(paf_synth_mode_note_produces_audio) {
PAFSynthMode m;
m.setup(48000.f);
m.set_input(0, 0.5f);
m.set_input(1, 0.5f);
m.tick_control();
m.note_on(60u, 100u);
float energy = 0.f;
for (int n = 0; n < 4800; ++n) {
const auto y = m.process({0.f, 0.f});
NISPS_EXPECT(std::isfinite(y.L));
// Tanh saturation guarantees |y| < 2.
NISPS_EXPECT(std::fabs(y.L) < 2.f);
energy += y.L * y.L;
}
NISPS_EXPECT(energy > 0.f);
m.note_off(60u);
}
NISPS_TEST(paf_synth_mode_input_clamping) {
PAFSynthMode m;
m.setup(48000.f);
m.set_input(0, -10.f);
m.set_input(1, 10.f);
NISPS_EXPECT(m.input_channels()[0] == 0.f);
NISPS_EXPECT(m.input_channels()[1] == 1.f);
// Out-of-range index is silently dropped.
m.set_input(99u, 0.5f);
}
NISPS_TEST(paf_synth_mode_engine_and_ml_accessors) {
PAFSynthMode m;
m.setup(48000.f);
auto& e = m.engine();
auto& ml = m.ml();
NISPS_EXPECT(e.engine_id() == "paf_synth");
// MLP::process() must be callable directly via the accessor.
ml.set_input(0u, 0.3f);
ml.process();
NISPS_EXPECT(ml.outputs().size() == 33u);
}