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.
This commit is contained in:
parent
429da1d1e4
commit
4964037da0
5 changed files with 401 additions and 0 deletions
|
|
@ -103,4 +103,27 @@ if(NOT EMSCRIPTEN)
|
|||
endif()
|
||||
|
||||
add_test(NAME nisps_dsp_engine_tests COMMAND nisps_dsp_engine_tests)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Mode tests (stream 4). Cover concept satisfaction, voice space
|
||||
# dispatch, control-event pumping, and end-to-end inference→audio.
|
||||
# ---------------------------------------------------------------------
|
||||
add_executable(nisps_modes_tests
|
||||
${NISPS_TEST_DIR}/test_main.cpp
|
||||
${NISPS_TEST_DIR}/test_mode_concepts.cpp
|
||||
${NISPS_TEST_DIR}/test_mode_paf_synth.cpp
|
||||
${NISPS_TEST_DIR}/test_mode_voice_space.cpp
|
||||
${NISPS_TEST_DIR}/test_mode_breakor_events.cpp
|
||||
)
|
||||
target_link_libraries(nisps_modes_tests PRIVATE nisps_core)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
|
||||
target_compile_options(nisps_modes_tests PRIVATE
|
||||
-Wall -Wextra -Werror -Wpedantic
|
||||
)
|
||||
elseif(MSVC)
|
||||
target_compile_options(nisps_modes_tests PRIVATE /W4 /WX)
|
||||
endif()
|
||||
|
||||
add_test(NAME nisps_modes_tests COMMAND nisps_modes_tests)
|
||||
endif()
|
||||
|
|
|
|||
112
tests/cpp/test_mode_breakor_events.cpp
Normal file
112
tests/cpp/test_mode_breakor_events.cpp
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// tests/cpp/test_mode_breakor_events.cpp — verify the sequencer modes pump
|
||||
// engine-side events through ModeBase's ControlEvent ring buffer.
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "../../nisps/modes/breakor.hpp"
|
||||
#include "../../nisps/modes/elysiamorf.hpp"
|
||||
#include "../../nisps/modes/sound_analysis_midi.hpp"
|
||||
|
||||
using nisps::ControlEvent;
|
||||
|
||||
NISPS_TEST(breakor_pump_emits_clock_events) {
|
||||
nisps::modes::BreakOrMode m;
|
||||
m.setup(48000.f);
|
||||
m.set_input(0, 0.4f);
|
||||
m.set_input(1, 0.6f);
|
||||
m.tick_control();
|
||||
m.set_playing(true);
|
||||
// Run enough samples for at least one MIDI clock tick (1/24th of a beat
|
||||
// at 90 BPM ≈ 27.8 ms ≈ 1334 samples at 48k).
|
||||
for (int n = 0; n < 2000; ++n) {
|
||||
m.process({0.f, 0.f});
|
||||
}
|
||||
m.pump_engine_events();
|
||||
|
||||
std::array<ControlEvent, 32u> buf;
|
||||
const std::size_t got = m.pop_control_events(std::span<ControlEvent>(buf));
|
||||
NISPS_EXPECT(got > 0u);
|
||||
|
||||
bool saw_clock = false;
|
||||
for (std::size_t i = 0u; i < got; ++i) {
|
||||
if (buf[i].kind == ControlEvent::Kind::Clock) saw_clock = true;
|
||||
}
|
||||
NISPS_EXPECT(saw_clock);
|
||||
}
|
||||
|
||||
NISPS_TEST(breakor_set_playing_false_drains_notes) {
|
||||
nisps::modes::BreakOrMode m;
|
||||
m.setup(48000.f);
|
||||
m.tick_control();
|
||||
m.set_playing(true);
|
||||
for (int n = 0; n < 5000; ++n) m.process({0.f, 0.f});
|
||||
m.set_playing(false);
|
||||
m.pump_engine_events();
|
||||
// After stop, we should see at least one Clock or NoteOff (stop drains
|
||||
// any in-flight track NoteOffs). Just verify the ring isn't empty.
|
||||
std::array<ControlEvent, 64u> buf;
|
||||
const std::size_t got = m.pop_control_events(std::span<ControlEvent>(buf));
|
||||
NISPS_EXPECT(got > 0u);
|
||||
}
|
||||
|
||||
NISPS_TEST(elysiamorf_pump_emits_cc_events) {
|
||||
nisps::modes::ElysiamorfMode m;
|
||||
m.setup(48000.f);
|
||||
m.set_input(0, 0.5f);
|
||||
m.tick_control();
|
||||
m.set_playing(true);
|
||||
for (int n = 0; n < 4000; ++n) m.process({0.f, 0.f});
|
||||
m.pump_engine_events();
|
||||
|
||||
std::array<ControlEvent, 64u> buf;
|
||||
const std::size_t got = m.pop_control_events(std::span<ControlEvent>(buf));
|
||||
NISPS_EXPECT(got > 0u);
|
||||
|
||||
bool saw_cc = false;
|
||||
for (std::size_t i = 0u; i < got; ++i) {
|
||||
if (buf[i].kind == ControlEvent::Kind::ControlChange) saw_cc = true;
|
||||
}
|
||||
NISPS_EXPECT(saw_cc);
|
||||
}
|
||||
|
||||
NISPS_TEST(sound_analysis_midi_routes_outputs_to_cc_events) {
|
||||
nisps::modes::SoundAnalysisMIDIMode m;
|
||||
m.setup(48000.f);
|
||||
// Drive a noisy input to populate analyser features.
|
||||
for (int n = 0; n < 2000; ++n) {
|
||||
const float s = static_cast<float>((n * 37) % 257) / 257.f - 0.5f;
|
||||
m.analyse({s, s});
|
||||
}
|
||||
m.set_input(6u, 0.7f);
|
||||
m.set_input(7u, 0.2f);
|
||||
m.tick_control();
|
||||
|
||||
std::array<ControlEvent, 16u> buf;
|
||||
const std::size_t got = m.pop_control_events(std::span<ControlEvent>(buf));
|
||||
NISPS_EXPECT(got == 8u);
|
||||
for (std::size_t i = 0u; i < got; ++i) {
|
||||
NISPS_EXPECT(buf[i].kind == ControlEvent::Kind::ControlChange);
|
||||
NISPS_EXPECT(buf[i].data1 < 8u); // CC numbers 0..7
|
||||
NISPS_EXPECT(buf[i].data2 <= 127u);
|
||||
}
|
||||
|
||||
// The mode's audio path is a NoOp (silence-out).
|
||||
const auto y = m.process({0.5f, -0.5f});
|
||||
NISPS_EXPECT(y.L == 0.f);
|
||||
NISPS_EXPECT(y.R == 0.f);
|
||||
}
|
||||
|
||||
NISPS_TEST(control_event_ring_buffer_overflow_drops) {
|
||||
nisps::modes::SoundAnalysisMIDIMode m;
|
||||
m.setup(48000.f);
|
||||
// Push more events than the ring can hold (kModeEventBufferSize = 64).
|
||||
// Each tick_control produces 8 CCs → ≥ 9 ticks fills the ring.
|
||||
for (int t = 0; t < 12; ++t) {
|
||||
m.tick_control();
|
||||
}
|
||||
std::array<ControlEvent, 128u> buf;
|
||||
const std::size_t got = m.pop_control_events(std::span<ControlEvent>(buf));
|
||||
NISPS_EXPECT(got <= 64u); // ring buffer caps total capacity
|
||||
NISPS_EXPECT(got > 0u);
|
||||
}
|
||||
94
tests/cpp/test_mode_concepts.cpp
Normal file
94
tests/cpp/test_mode_concepts.cpp
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// tests/cpp/test_mode_concepts.cpp — compile-time check that every
|
||||
// concrete mode in nisps/modes/ satisfies the `nisps::Mode` concept.
|
||||
//
|
||||
// Failure here = compilation failure; if this TU compiles, the assertions
|
||||
// have already passed. The runtime test bodies just sanity-check schema
|
||||
// metadata (mode_id, input_channel_count, schema sizes match expectations).
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/modes/breakor.hpp"
|
||||
#include "../../nisps/modes/channel_strip.hpp"
|
||||
#include "../../nisps/modes/elysiamorf.hpp"
|
||||
#include "../../nisps/modes/memlcelium.hpp"
|
||||
#include "../../nisps/modes/paf_synth.hpp"
|
||||
#include "../../nisps/modes/sound_analysis_midi.hpp"
|
||||
#include "../../nisps/modes/verb_fx.hpp"
|
||||
#include "../../nisps/modes/xiasri.hpp"
|
||||
|
||||
using namespace nisps;
|
||||
|
||||
// ---- Static concept checks (redundant with header-side static_asserts but
|
||||
// makes the test surface explicit) ----
|
||||
static_assert(Mode<modes::PAFSynthMode>);
|
||||
static_assert(Mode<modes::ChannelStripMode>);
|
||||
static_assert(Mode<modes::XIASRIMode>);
|
||||
static_assert(Mode<modes::VerbFXMode>);
|
||||
static_assert(Mode<modes::MEMLCeliumMode>);
|
||||
static_assert(Mode<modes::BreakOrMode>);
|
||||
static_assert(Mode<modes::ElysiamorfMode>);
|
||||
static_assert(Mode<modes::SoundAnalysisMIDIMode>);
|
||||
|
||||
// ---- Per-mode metadata ----
|
||||
NISPS_TEST(mode_paf_synth_metadata) {
|
||||
const auto& s = modes::PAFSynthMode::param_schema();
|
||||
NISPS_EXPECT(modes::PAFSynthMode::mode_id() == "paf_synth");
|
||||
NISPS_EXPECT(modes::PAFSynthMode::input_channel_count() == 4u);
|
||||
NISPS_EXPECT(s.input_size == 4u);
|
||||
NISPS_EXPECT(s.output_size == 33u);
|
||||
NISPS_EXPECT(s.engine_id == "paf_synth");
|
||||
NISPS_EXPECT(s.params.size() == 33u);
|
||||
NISPS_EXPECT(s.voice_spaces.size() == 7u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_channel_strip_metadata) {
|
||||
const auto& s = modes::ChannelStripMode::param_schema();
|
||||
NISPS_EXPECT(modes::ChannelStripMode::mode_id() == "channel_strip");
|
||||
NISPS_EXPECT(modes::ChannelStripMode::input_channel_count() == 4u);
|
||||
NISPS_EXPECT(s.output_size == 24u);
|
||||
NISPS_EXPECT(s.voice_spaces.size() == 6u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_xiasri_metadata) {
|
||||
const auto& s = modes::XIASRIMode::param_schema();
|
||||
NISPS_EXPECT(modes::XIASRIMode::mode_id() == "xiasri");
|
||||
NISPS_EXPECT(s.output_size == 24u);
|
||||
NISPS_EXPECT(s.voice_spaces.size() == 1u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_verb_fx_metadata) {
|
||||
const auto& s = modes::VerbFXMode::param_schema();
|
||||
NISPS_EXPECT(modes::VerbFXMode::mode_id() == "verb_fx");
|
||||
NISPS_EXPECT(s.output_size == 47u);
|
||||
NISPS_EXPECT(s.voice_spaces.size() == 12u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_memlcelium_metadata) {
|
||||
const auto& s = modes::MEMLCeliumMode::param_schema();
|
||||
NISPS_EXPECT(modes::MEMLCeliumMode::mode_id() == "memlcelium");
|
||||
NISPS_EXPECT(s.output_size == 56u);
|
||||
NISPS_EXPECT(s.engine_id == "memlcelium");
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_breakor_metadata) {
|
||||
const auto& s = modes::BreakOrMode::param_schema();
|
||||
NISPS_EXPECT(modes::BreakOrMode::mode_id() == "breakor");
|
||||
NISPS_EXPECT(s.output_size == 56u);
|
||||
NISPS_EXPECT(s.engine_id == "breakor");
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_elysiamorf_metadata) {
|
||||
const auto& s = modes::ElysiamorfMode::param_schema();
|
||||
NISPS_EXPECT(modes::ElysiamorfMode::mode_id() == "elysiamorf");
|
||||
NISPS_EXPECT(s.output_size == 40u);
|
||||
NISPS_EXPECT(s.engine_id == "elysiamorf");
|
||||
}
|
||||
|
||||
NISPS_TEST(mode_sound_analysis_midi_metadata) {
|
||||
const auto& s = modes::SoundAnalysisMIDIMode::param_schema();
|
||||
NISPS_EXPECT(modes::SoundAnalysisMIDIMode::mode_id() == "sound_analysis_midi");
|
||||
NISPS_EXPECT(modes::SoundAnalysisMIDIMode::input_channel_count() == 10u);
|
||||
NISPS_EXPECT(s.input_size == 10u);
|
||||
NISPS_EXPECT(s.output_size == 8u);
|
||||
NISPS_EXPECT(s.engine_id == "thru");
|
||||
}
|
||||
101
tests/cpp/test_mode_paf_synth.cpp
Normal file
101
tests/cpp/test_mode_paf_synth.cpp
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// 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);
|
||||
}
|
||||
71
tests/cpp/test_mode_voice_space.cpp
Normal file
71
tests/cpp/test_mode_voice_space.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// tests/cpp/test_mode_voice_space.cpp — verify that switching voice spaces
|
||||
// changes the engine's parameter mapping. We don't compare audio output
|
||||
// directly (different voice spaces converge to similar timbres for some
|
||||
// param vectors); instead we verify the engine stores the new voice-space
|
||||
// index and that subsequent process() doesn't blow up.
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "../../nisps/modes/channel_strip.hpp"
|
||||
#include "../../nisps/modes/paf_synth.hpp"
|
||||
#include "../../nisps/modes/verb_fx.hpp"
|
||||
|
||||
using nisps::modes::ChannelStripMode;
|
||||
using nisps::modes::PAFSynthMode;
|
||||
using nisps::modes::VerbFXMode;
|
||||
|
||||
NISPS_TEST(voice_space_paf_synth_round_trip) {
|
||||
PAFSynthMode m;
|
||||
m.setup(48000.f);
|
||||
|
||||
for (std::size_t i = 0u; i < nisps::PAFSynthEngine::kVoiceSpaceCount; ++i) {
|
||||
m.set_voice_space(i);
|
||||
NISPS_EXPECT(m.voice_space_index() == i);
|
||||
// Tick control + process; no NaN / no crash.
|
||||
m.set_input(0, 0.5f);
|
||||
m.tick_control();
|
||||
for (int n = 0; n < 64; ++n) {
|
||||
const auto y = m.process({0.f, 0.f});
|
||||
NISPS_EXPECT(std::isfinite(y.L));
|
||||
NISPS_EXPECT(std::isfinite(y.R));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(voice_space_channel_strip_dispatch) {
|
||||
ChannelStripMode m;
|
||||
m.setup(48000.f);
|
||||
for (std::size_t i = 0u; i < nisps::ChannelStripEngine::kVoiceSpaceCount; ++i) {
|
||||
m.set_voice_space(i);
|
||||
NISPS_EXPECT(m.engine().voice_space() ==
|
||||
static_cast<nisps::ChannelStripEngine::VoiceSpace>(i));
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(voice_space_verb_fx_all_dispatch) {
|
||||
VerbFXMode m;
|
||||
m.setup(48000.f);
|
||||
for (std::size_t i = 0u; i < nisps::VerbFXEngine::kVoiceSpaceCount; ++i) {
|
||||
m.set_voice_space(i);
|
||||
m.set_input(0, 0.4f);
|
||||
m.set_input(1, 0.6f);
|
||||
m.tick_control();
|
||||
// 64 samples — enough to surface a NaN if one's coming.
|
||||
for (int n = 0; n < 64; ++n) {
|
||||
const auto y = m.process({0.1f, -0.1f});
|
||||
NISPS_EXPECT(std::isfinite(y.L));
|
||||
NISPS_EXPECT(std::isfinite(y.R));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(voice_space_out_of_range_ignored) {
|
||||
PAFSynthMode m;
|
||||
m.setup(48000.f);
|
||||
m.set_voice_space(2u);
|
||||
NISPS_EXPECT(m.voice_space_index() == 2u);
|
||||
m.set_voice_space(999u); // out of range — should be a no-op
|
||||
NISPS_EXPECT(m.voice_space_index() == 2u);
|
||||
}
|
||||
Loading…
Reference in a new issue