diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index 2f4677f..7260634 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -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() diff --git a/tests/cpp/test_mode_breakor_events.cpp b/tests/cpp/test_mode_breakor_events.cpp new file mode 100644 index 0000000..0a0d8f7 --- /dev/null +++ b/tests/cpp/test_mode_breakor_events.cpp @@ -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 + +#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 buf; + const std::size_t got = m.pop_control_events(std::span(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 buf; + const std::size_t got = m.pop_control_events(std::span(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 buf; + const std::size_t got = m.pop_control_events(std::span(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((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 buf; + const std::size_t got = m.pop_control_events(std::span(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 buf; + const std::size_t got = m.pop_control_events(std::span(buf)); + NISPS_EXPECT(got <= 64u); // ring buffer caps total capacity + NISPS_EXPECT(got > 0u); +} diff --git a/tests/cpp/test_mode_concepts.cpp b/tests/cpp/test_mode_concepts.cpp new file mode 100644 index 0000000..9e55d6b --- /dev/null +++ b/tests/cpp/test_mode_concepts.cpp @@ -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); +static_assert(Mode); +static_assert(Mode); +static_assert(Mode); +static_assert(Mode); +static_assert(Mode); +static_assert(Mode); +static_assert(Mode); + +// ---- 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"); +} diff --git a/tests/cpp/test_mode_paf_synth.cpp b/tests/cpp/test_mode_paf_synth.cpp new file mode 100644 index 0000000..737ba2f --- /dev/null +++ b/tests/cpp/test_mode_paf_synth.cpp @@ -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 +#include + +#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 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); +} diff --git a/tests/cpp/test_mode_voice_space.cpp b/tests/cpp/test_mode_voice_space.cpp new file mode 100644 index 0000000..6d097ab --- /dev/null +++ b/tests/cpp/test_mode_voice_space.cpp @@ -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 +#include + +#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(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); +}