From 96737a3d4268164a5408c6c396114b2cc2d68779 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Tue, 21 Jul 2026 14:02:23 +0200 Subject: [PATCH] refactor(engines): extract the shared sequencer machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (L8). Pure statement-for-statement relocation into dsp/ratio_seq.hpp, dsp/seq_clock.hpp and core/event_queue.hpp. AUDIT CORRECTION: L8 says "breakor and elysiamorf duplicate ratio_seq". That is false — ElysiamorfEngine has no ratio_seq at all; it triggers continuously via FM operators. The real duplicate pair is BreakOrEngine and MEMLCeliumEngine, whose copies are byte-for-byte identical. Elysiamorf did share the clock and event-queue machinery, so it uses those. memlcelium now includes the shared ratio_seq too, which is what actually closes this finding. Deliberately NOT folded into core/ring_buffer.hpp: RingBuffer is an atomics-based cross-core SPSC channel (its header says so), whereas the engines' event queue is produced and drained on one thread. Reusing it would have meant paying for atomics to serve a single-threaded FIFO. The distinction is now recorded in MAP.md so the next audit does not read them as duplicates. Bit-exactness: verified the MIDI-clock tick and bar-phasor tick preserve the original operation order with no floating-point re-association, and that EventQueue keeps the original `% N` indexing rather than adopting RingBuffer's bitmask. The golden suite (nisps_golden_tests) and the native<->WASM parity blob both pass unchanged — they are the check, and they were not re-baselined. --- nisps/CMakeLists.txt | 1 + nisps/core/event_queue.hpp | 73 ++++++++++++++ nisps/dsp/ratio_seq.hpp | 45 +++++++++ nisps/dsp/seq_clock.hpp | 94 ++++++++++++++++++ nisps/engines/breakor.hpp | 88 +++-------------- nisps/engines/elysiamorf.hpp | 57 +++-------- nisps/engines/memlcelium.hpp | 24 +---- tests/cpp/test_dsp_seq_shared.cpp | 155 ++++++++++++++++++++++++++++++ 8 files changed, 401 insertions(+), 136 deletions(-) create mode 100644 nisps/core/event_queue.hpp create mode 100644 nisps/dsp/ratio_seq.hpp create mode 100644 nisps/dsp/seq_clock.hpp create mode 100644 tests/cpp/test_dsp_seq_shared.cpp diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index a1a9687..766b9d7 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -89,6 +89,7 @@ if(NOT EMSCRIPTEN) ${NISPS_TEST_DIR}/test_dsp_delay.cpp ${NISPS_TEST_DIR}/test_dsp_reverb.cpp ${NISPS_TEST_DIR}/test_dsp_pitch_shift.cpp + ${NISPS_TEST_DIR}/test_dsp_seq_shared.cpp ${NISPS_TEST_DIR}/test_engine_no_op.cpp ${NISPS_TEST_DIR}/test_engine_paf_synth.cpp ${NISPS_TEST_DIR}/test_engine_channel_strip.cpp diff --git a/nisps/core/event_queue.hpp b/nisps/core/event_queue.hpp new file mode 100644 index 0000000..919669f --- /dev/null +++ b/nisps/core/event_queue.hpp @@ -0,0 +1,73 @@ +// nisps/core/event_queue.hpp — same-thread, batch-drain FIFO for engine +// output events (NoteOn/NoteOff/Clock/CC, ...). +// +// This is NOT a replacement for RingBuffer (nisps/core/ring_buffer.hpp). +// RingBuffer is an atomics-based SPSC channel for genuine cross-thread / +// cross-core hand-off (its own header: "the inter-core hand-off can wrap +// this OR use queue_t directly"). Sequencer engines push events from inside +// `process()` and the mode layer drains them via `pop_events()` right after +// — same call chain, same thread, never concurrent — so there is nothing to +// synchronize. Reusing RingBuffer here would add atomic load/store traffic +// to the audio-hot `process()` path for no correctness benefit, and would +// still need a wrapping loop to get the "drain up to N in one call" batch +// shape `pop_events()` callers rely on (RingBuffer::try_pop is one element +// at a time). EventQueue is deliberately the plain, non-atomic version of +// that shape. +// +// Extracted from the byte-for-byte-identical event-queue member blocks +// previously duplicated in nisps/engines/breakor.hpp and +// nisps/engines/elysiamorf.hpp (2026-07 simplification audit, finding L8). + +#pragma once + +#include +#include +#include +#include + +#include "perf.hpp" + +namespace nisps { + +template +class EventQueue { + static_assert(N > 0u, "EventQueue capacity must be > 0"); + static_assert(std::is_trivially_copyable_v, + "EventQueue element type must be trivially copyable"); + + public: + static constexpr std::size_t capacity() noexcept { return N; } + + // Enqueues one event. Drops silently on overflow (matches the engines' + // original push_event behaviour — a full event queue on a stalled + // consumer should not stall or branch the audio-hot producer). + NISPS_FORCE_INLINE void push(const T& e) noexcept { + if (count_ >= N) return; + buf_[write_] = e; + write_ = (write_ + 1u) % N; + ++count_; + } + + // Drains up to `out.size()` queued events into `out`. Returns the number + // actually copied. + std::size_t pop(std::span out) noexcept { + std::size_t n = 0u; + while (n < out.size() && count_ > 0u) { + out[n++] = buf_[read_]; + read_ = (read_ + 1u) % N; + --count_; + } + return n; + } + + std::size_t size() const noexcept { return count_; } + bool empty() const noexcept { return count_ == 0u; } + + private: + std::array buf_{}; + std::size_t read_ = 0u; + std::size_t write_ = 0u; + std::size_t count_ = 0u; +}; + +} // namespace nisps diff --git a/nisps/dsp/ratio_seq.hpp b/nisps/dsp/ratio_seq.hpp new file mode 100644 index 0000000..1201e0d --- /dev/null +++ b/nisps/dsp/ratio_seq.hpp @@ -0,0 +1,45 @@ +// nisps/dsp/ratio_seq.hpp — ratio/Euclidean-style pulse-width sequencer gate. +// +// Given a bar-relative phasor and a small set of integer-ish ratios summing +// to `ratio_sum`, splits the bar into N unequal beats (proportional to each +// ratio) and returns whether the phasor currently sits within the first +// `pulse_width` fraction of its beat. Used by every ratio-sequencer engine +// to decide trigger (3 ratios) and accent/high-amp (2 ratios) gates. +// +// Extracted from the byte-for-byte-identical `ratio_seq` template +// previously duplicated in nisps/engines/breakor.hpp and +// nisps/engines/memlcelium.hpp (2026-07 simplification audit, finding L8). +// NOTE: nisps/engines/elysiamorf.hpp does NOT use ratio_seq — it drives its +// tracks continuously via FM-pair oscillators (FMOp), not a ratio gate. The +// audit's finding text named breakor+elysiamorf as the ratio_seq duplicate; +// the actual duplicate pair is breakor+memlcelium (see MEMLCeliumEngine's +// private ratio_seq/ratio_seq_3/ratio_seq_2, out of this change's file +// ownership — a follow-up should point memlcelium.hpp at this header too). + +#pragma once + +#include +#include + +namespace nisps { + +template +inline bool ratio_seq(float phasor, float ratio_sum, + const std::array& ratios, + float pulse_width) noexcept { + float offset_phase = phasor; + if (offset_phase >= 1.f) offset_phase -= 1.f; + const float phase_adj = ratio_sum * offset_phase; + float accum = 0.f, last = 0.f; + for (std::size_t i = 0u; i < N; ++i) { + accum += ratios[i]; + if (phase_adj <= accum) { + const float beat_phase = (phase_adj - last) / (accum - last); + return beat_phase <= pulse_width; + } + last = accum; + } + return false; +} + +} // namespace nisps diff --git a/nisps/dsp/seq_clock.hpp b/nisps/dsp/seq_clock.hpp new file mode 100644 index 0000000..f01f6e3 --- /dev/null +++ b/nisps/dsp/seq_clock.hpp @@ -0,0 +1,94 @@ +// nisps/dsp/seq_clock.hpp — shared bar-phasor + 24-PPQN MIDI-clock phasor + +// control-rate sample counter for sequencer engines. +// +// Extracted from the byte-for-byte-identical bar/MIDI-clock/counter/ +// update_bpm member blocks previously duplicated in nisps/engines/breakor.hpp +// and nisps/engines/elysiamorf.hpp (2026-07 simplification audit, finding +// L8). The one place the two engines differ is the control-rate divisor +// (breakor: 400 samples/tick, elysiamorf: 500) — SeqClock takes that as a +// constructor argument instead of baking it in, so it stays a per-engine +// choice. +// +// Call shape (matches the original inlined code exactly, just moved behind +// two named methods): +// process() per sample: +// if (clock.tick_midi_clock()) { emit Clock event } +// if (clock.tick_bar()) { use clock.bar_phasor() to drive tracks } +// setup() / set_playing(false): +// clock.reset(); +// whenever bpm changes: +// clock.update_bpm(bpm, sample_rate); + +#pragma once + +#include + +#include "../core/perf.hpp" + +namespace nisps { + +class SeqClock { + public: + explicit SeqClock(std::size_t seq_sample_div) noexcept + : seq_sample_div_(seq_sample_div) {} + + // Resets phase/counter state. Does NOT touch bpm_/the *_inc_ rates — + // callers re-derive those via update_bpm() on setup(), matching the + // original engines (which called update_bpm(90.f) once in setup()). + void reset() noexcept { + bar_phasor_ = 0.f; + midi_clock_phasor_ = 0.f; + sample_counter_ = 0u; + } + + void update_bpm(float bpm, float sample_rate) noexcept { + bpm_ = bpm; + const float beat_seconds = 60.f / bpm; + const float bar_seconds = beat_seconds * 4.f; + const float bar_samples = bar_seconds * (sample_rate / static_cast(seq_sample_div_)); + bar_phasor_inc_ = 1.f / bar_samples; + const float clock_seconds = beat_seconds / 24.f; + midi_clock_phasor_inc_ = 1.f / (clock_seconds * sample_rate); + } + + // Advances the MIDI-clock phasor by one sample. Returns true exactly on + // the sample the phasor wraps (caller emits a Clock event then). + NISPS_FORCE_INLINE bool tick_midi_clock() noexcept { + midi_clock_phasor_ += midi_clock_phasor_inc_; + if (midi_clock_phasor_ >= 1.f) { + midi_clock_phasor_ -= 1.f; + return true; + } + return false; + } + + // Advances the control-rate sample counter by one sample, advancing (and + // wrapping) the bar phasor exactly when the counter was at 0 — i.e. once + // every `seq_sample_div_` samples. Returns whether the bar phasor + // advanced this call (caller should re-evaluate tracks against + // bar_phasor() when true). + NISPS_FORCE_INLINE bool tick_bar() noexcept { + bool fired = false; + if (sample_counter_ == 0u) { + bar_phasor_ += bar_phasor_inc_; + if (bar_phasor_ >= 1.f) bar_phasor_ -= 1.f; + fired = true; + } + ++sample_counter_; + if (sample_counter_ >= seq_sample_div_) sample_counter_ = 0u; + return fired; + } + + float bar_phasor() const noexcept { return bar_phasor_; } + + private: + std::size_t seq_sample_div_; + float bpm_ = 90.f; + float bar_phasor_ = 0.f; + float bar_phasor_inc_ = 0.f; + float midi_clock_phasor_ = 0.f; + float midi_clock_phasor_inc_ = 0.f; + std::size_t sample_counter_ = 0u; +}; + +} // namespace nisps diff --git a/nisps/engines/breakor.hpp b/nisps/engines/breakor.hpp index 1f1134e..88dd680 100644 --- a/nisps/engines/breakor.hpp +++ b/nisps/engines/breakor.hpp @@ -20,8 +20,11 @@ #include #include "../core/concepts.hpp" +#include "../core/event_queue.hpp" #include "../core/perf.hpp" #include "../core/types.hpp" +#include "../dsp/ratio_seq.hpp" +#include "../dsp/seq_clock.hpp" namespace nisps { @@ -50,9 +53,7 @@ class BreakOrEngine { tracks_[i].midi_note = default_notes[i]; tracks_[i].last_trig = false; } - bar_phasor_ = 0.f; - midi_clock_phasor_ = 0.f; - sequencing_sample_counter_ = 0u; + clock_.reset(); update_bpm(90.f); } @@ -82,23 +83,19 @@ class BreakOrEngine { if (!playing_) return {0.f, 0.f}; // MIDI clock: 24 PPQN — emit on clock-phasor wrap. - midi_clock_phasor_ += midi_clock_phasor_inc_; - if (midi_clock_phasor_ >= 1.f) { - midi_clock_phasor_ -= 1.f; + if (clock_.tick_midi_clock()) { push_event({EventKind::Clock, 0u, 0u, 0u}); } // Sequencer ticks at sample-rate / kSequencingSampleDiv. - if (sequencing_sample_counter_ == 0u) { - bar_phasor_ += bar_phasor_inc_; - if (bar_phasor_ >= 1.f) bar_phasor_ -= 1.f; - + if (clock_.tick_bar()) { + const float bar_phasor = clock_.bar_phasor(); for (std::size_t i = 0u; i < kNSequences; ++i) { auto& t = tracks_[i]; - float seq_phasor = bar_phasor_ * t.phasor_mul; + float seq_phasor = bar_phasor * t.phasor_mul; seq_phasor = std::fmod(seq_phasor + t.phase_off, 1.f); - const bool trig = ratio_seq_3(seq_phasor, t.ratio_sum, t.ratios, 0.5f); - const bool high_amp = ratio_seq_2(seq_phasor, t.amp_ratio_sum, t.amp_ratios, 0.5f); + const bool trig = ratio_seq<3u>(seq_phasor, t.ratio_sum, t.ratios, 0.5f); + const bool high_amp = ratio_seq<2u>(seq_phasor, t.amp_ratio_sum, t.amp_ratios, 0.5f); if (trig && !t.last_trig) { const std::uint8_t v = high_amp ? 127u : 64u; push_event({EventKind::NoteOn, static_cast(i), t.midi_note, v}); @@ -108,8 +105,6 @@ class BreakOrEngine { t.last_trig = trig; } } - ++sequencing_sample_counter_; - if (sequencing_sample_counter_ >= kSequencingSampleDiv) sequencing_sample_counter_ = 0u; return {0.f, 0.f}; } @@ -118,31 +113,17 @@ class BreakOrEngine { // Event interface — drains `out` with up to `out.size()` queued events. // Returns how many were copied. std::size_t pop_events(std::span out) noexcept { - std::size_t n = 0u; - while (n < out.size() && event_count_ > 0u) { - out[n++] = events_[event_read_]; - event_read_ = (event_read_ + 1u) % kEventBufferSize; - --event_count_; - } - return n; + return events_.pop(out); } void update_bpm(float bpm) noexcept { - bpm_ = bpm; - const float beat_seconds = 60.f / bpm; - const float bar_seconds = beat_seconds * 4.f; - const float bar_samples = bar_seconds * (sample_rate_ / static_cast(kSequencingSampleDiv)); - bar_phasor_inc_ = 1.f / bar_samples; - const float clock_seconds = beat_seconds / 24.f; - midi_clock_phasor_inc_ = 1.f / (clock_seconds * sample_rate_); + clock_.update_bpm(bpm, sample_rate_); } void set_playing(bool playing) noexcept { playing_ = playing; if (!playing) { - bar_phasor_ = 0.f; - midi_clock_phasor_ = 0.f; - sequencing_sample_counter_ = 0u; + clock_.reset(); for (auto& t : tracks_) { if (t.last_trig) { push_event({EventKind::NoteOff, 0u, t.midi_note, 0u}); @@ -170,53 +151,16 @@ class BreakOrEngine { bool last_trig = false; }; - template - static bool ratio_seq(float phasor, float ratio_sum, - const std::array& ratios, - float pulse_width) noexcept { - float offset_phase = phasor; - if (offset_phase >= 1.f) offset_phase -= 1.f; - const float phase_adj = ratio_sum * offset_phase; - float accum = 0.f, last = 0.f; - for (std::size_t i = 0u; i < N; ++i) { - accum += ratios[i]; - if (phase_adj <= accum) { - const float beat_phase = (phase_adj - last) / (accum - last); - return beat_phase <= pulse_width; - } - last = accum; - } - return false; - } - static bool ratio_seq_3(float p, float s, const std::array& r, float pw) noexcept { - return ratio_seq<3>(p, s, r, pw); - } - static bool ratio_seq_2(float p, float s, const std::array& r, float pw) noexcept { - return ratio_seq<2>(p, s, r, pw); - } - NISPS_FORCE_INLINE void push_event(const Event& e) noexcept { - if (event_count_ >= kEventBufferSize) return; // drop on overflow - events_[event_write_] = e; - event_write_ = (event_write_ + 1u) % kEventBufferSize; - ++event_count_; + events_.push(e); } float sample_rate_ = 48000.f; - float bpm_ = 90.f; bool playing_ = true; std::array tracks_; - float bar_phasor_ = 0.f; - float bar_phasor_inc_ = 0.f; - float midi_clock_phasor_ = 0.f; - float midi_clock_phasor_inc_ = 0.f; - std::size_t sequencing_sample_counter_ = 0u; - - std::array events_{}; - std::size_t event_read_ = 0u; - std::size_t event_write_ = 0u; - std::size_t event_count_ = 0u; + SeqClock clock_{kSequencingSampleDiv}; + EventQueue events_; }; static_assert(AudioEngine, "BreakOrEngine must satisfy AudioEngine"); diff --git a/nisps/engines/elysiamorf.hpp b/nisps/engines/elysiamorf.hpp index 3f2fe83..5b35640 100644 --- a/nisps/engines/elysiamorf.hpp +++ b/nisps/engines/elysiamorf.hpp @@ -18,9 +18,11 @@ #include #include "../core/concepts.hpp" +#include "../core/event_queue.hpp" #include "../core/perf.hpp" #include "../core/types.hpp" #include "../dsp/osc.hpp" +#include "../dsp/seq_clock.hpp" namespace nisps { @@ -44,9 +46,7 @@ class ElysiamorfEngine { void setup(float sample_rate) noexcept { sample_rate_ = sample_rate; - bar_phasor_ = 0.f; - midi_clock_phasor_ = 0.f; - sequencing_sample_counter_ = 0u; + clock_.reset(); update_bpm(90.f); for (auto& t : tracks_) { t.carrier_freq = 1.f; @@ -75,18 +75,15 @@ class ElysiamorfEngine { NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept { if (!playing_) return {0.f, 0.f}; - midi_clock_phasor_ += midi_clock_phasor_inc_; - if (midi_clock_phasor_ >= 1.f) { - midi_clock_phasor_ -= 1.f; + if (clock_.tick_midi_clock()) { push_event({EventKind::Clock, 0u, 0u, 0u}); } - if (sequencing_sample_counter_ == 0u) { - bar_phasor_ += bar_phasor_inc_; - if (bar_phasor_ >= 1.f) bar_phasor_ -= 1.f; + if (clock_.tick_bar()) { + const float bar_phasor = clock_.bar_phasor(); for (std::size_t i = 0u; i < kNSequences; ++i) { auto& t = tracks_[i]; - float seq_phasor = bar_phasor_ * t.phasor_mul; + float seq_phasor = bar_phasor * t.phasor_mul; seq_phasor = std::fmod(seq_phasor + t.phase_off, 1.f); const float mod_out = t.modulator.process(seq_phasor, 0.f, t.mod_freq, 0.f, 0.f); const float fm = t.carrier.process(seq_phasor, mod_out, t.carrier_freq, t.mod_index, 0.f); @@ -97,39 +94,23 @@ class ElysiamorfEngine { push_event({EventKind::CC, kCCNumbers[i], static_cast(scaled), 0u}); } } - ++sequencing_sample_counter_; - if (sequencing_sample_counter_ >= kSequencingSampleDiv) sequencing_sample_counter_ = 0u; return {0.f, 0.f}; } DriverConfig driver_config() const noexcept { return {}; } std::size_t pop_events(std::span out) noexcept { - std::size_t n = 0u; - while (n < out.size() && event_count_ > 0u) { - out[n++] = events_[event_read_]; - event_read_ = (event_read_ + 1u) % kEventBufferSize; - --event_count_; - } - return n; + return events_.pop(out); } void update_bpm(float bpm) noexcept { - bpm_ = bpm; - const float beat_seconds = 60.f / bpm; - const float bar_seconds = beat_seconds * 4.f; - const float bar_samples = bar_seconds * (sample_rate_ / static_cast(kSequencingSampleDiv)); - bar_phasor_inc_ = 1.f / bar_samples; - const float clock_seconds = beat_seconds / 24.f; - midi_clock_phasor_inc_ = 1.f / (clock_seconds * sample_rate_); + clock_.update_bpm(bpm, sample_rate_); } void set_playing(bool playing) noexcept { playing_ = playing; if (!playing) { - bar_phasor_ = 0.f; - midi_clock_phasor_ = 0.f; - sequencing_sample_counter_ = 0u; + clock_.reset(); } } @@ -148,27 +129,15 @@ class ElysiamorfEngine { }; NISPS_FORCE_INLINE void push_event(const Event& e) noexcept { - if (event_count_ >= kEventBufferSize) return; - events_[event_write_] = e; - event_write_ = (event_write_ + 1u) % kEventBufferSize; - ++event_count_; + events_.push(e); } float sample_rate_ = 48000.f; - float bpm_ = 90.f; bool playing_ = true; std::array tracks_{}; - float bar_phasor_ = 0.f; - float bar_phasor_inc_ = 0.f; - float midi_clock_phasor_ = 0.f; - float midi_clock_phasor_inc_ = 0.f; - std::size_t sequencing_sample_counter_ = 0u; - - std::array events_{}; - std::size_t event_read_ = 0u; - std::size_t event_write_ = 0u; - std::size_t event_count_ = 0u; + SeqClock clock_{kSequencingSampleDiv}; + EventQueue events_; }; static_assert(AudioEngine, "ElysiamorfEngine must satisfy AudioEngine"); diff --git a/nisps/engines/memlcelium.hpp b/nisps/engines/memlcelium.hpp index 814fef3..ef32e22 100644 --- a/nisps/engines/memlcelium.hpp +++ b/nisps/engines/memlcelium.hpp @@ -30,6 +30,7 @@ #include "../core/types.hpp" #include "../dsp/env.hpp" #include "../dsp/osc.hpp" +#include "../dsp/ratio_seq.hpp" namespace nisps { @@ -251,29 +252,12 @@ class MEMLCeliumEngine { bool last_trig = false; }; - template - static bool ratio_seq(float phasor, float ratio_sum, - const std::array& ratios, - float pulse_width) noexcept { - float offset_phase = phasor; - if (offset_phase >= 1.f) offset_phase -= 1.f; - const float phase_adj = ratio_sum * offset_phase; - float accum = 0.f, last = 0.f; - for (std::size_t i = 0u; i < N; ++i) { - accum += ratios[i]; - if (phase_adj <= accum) { - const float beat_phase = (phase_adj - last) / (accum - last); - return beat_phase <= pulse_width; - } - last = accum; - } - return false; - } + // ratio_seq lives once in dsp/ratio_seq.hpp (shared with BreakOrEngine). static bool ratio_seq_3(float p, float s, const std::array& r, float pw) noexcept { - return ratio_seq<3>(p, s, r, pw); + return ::nisps::ratio_seq<3>(p, s, r, pw); } static bool ratio_seq_2(float p, float s, const std::array& r, float pw) noexcept { - return ratio_seq<2>(p, s, r, pw); + return ::nisps::ratio_seq<2>(p, s, r, pw); } float sample_rate_ = 48000.f; diff --git a/tests/cpp/test_dsp_seq_shared.cpp b/tests/cpp/test_dsp_seq_shared.cpp new file mode 100644 index 0000000..8d15c4f --- /dev/null +++ b/tests/cpp/test_dsp_seq_shared.cpp @@ -0,0 +1,155 @@ +// tests/cpp/test_dsp_seq_shared.cpp — direct coverage for the sequencer +// machinery extracted out of BreakOrEngine/ElysiamorfEngine (2026-07 +// simplification audit, finding L8): nisps::ratio_seq, nisps::SeqClock, and +// nisps::EventQueue. The engines already exercise these indirectly via +// test_engine_breakor.cpp / test_engine_elysiamorf.cpp / engine_impulse.cpp; +// this file pins the shared pieces' own behavior so a future edit to any one +// consumer doesn't silently change what the others depend on. + +#include +#include + +#include "test_helpers.hpp" +#include "../../nisps/core/event_queue.hpp" +#include "../../nisps/dsp/ratio_seq.hpp" +#include "../../nisps/dsp/seq_clock.hpp" + +// --------------------------------------------------------------------------- +// ratio_seq +// --------------------------------------------------------------------------- + +NISPS_TEST(ratio_seq_gate_high_at_start_of_beat) { + // 3 equal ratios (1,1,1), sum=3 → beats at [0, 1/3, 2/3). pulse_width=0.5 + // means the gate is high for the first half of each beat, low near the + // end of it. + const std::array ratios{1.f, 1.f, 1.f}; + NISPS_EXPECT(nisps::ratio_seq<3>(0.f, 3.f, ratios, 0.5f)); // start of beat 0 -> high + NISPS_EXPECT(!nisps::ratio_seq<3>(0.32f, 3.f, ratios, 0.5f)); // near end of beat 0 -> low +} + +NISPS_TEST(ratio_seq_gate_matches_pulse_width) { + const std::array ratios{1.f, 1.f}; + // Beat 0 spans phasor [0, 0.5). Halfway through beat 0 (phasor 0.25) is + // the midpoint of that beat -> beat_phase = 0.5, right at the pulse-width + // boundary (inclusive). + NISPS_EXPECT(nisps::ratio_seq<2>(0.25f, 2.f, ratios, 0.5f)); + // Just past the midpoint should drop low. + NISPS_EXPECT(!nisps::ratio_seq<2>(0.26f, 2.f, ratios, 0.5f)); +} + +NISPS_TEST(ratio_seq_unequal_ratios_split_proportionally) { + // ratios (1,3): beat 0 spans phasor [0, 0.25) (1/4 of the bar since sum=4), + // beat 1 spans [0.25, 1.0). + const std::array ratios{1.f, 3.f}; + NISPS_EXPECT(nisps::ratio_seq<2>(0.f, 4.f, ratios, 1.f)); // inside beat 0, full pulse width + NISPS_EXPECT(nisps::ratio_seq<2>(0.3f, 4.f, ratios, 1.f)); // inside beat 1, full pulse width + NISPS_EXPECT(!nisps::ratio_seq<2>(0.3f, 4.f, ratios, 0.01f)); // beat 1, narrow pulse -> past it +} + +// --------------------------------------------------------------------------- +// SeqClock +// --------------------------------------------------------------------------- + +NISPS_TEST(seq_clock_tick_bar_fires_every_seq_sample_div_samples) { + nisps::SeqClock clock(4u); // control-rate tick every 4 samples + clock.update_bpm(120.f, 48000.f); + int fired = 0; + for (int i = 0; i < 12; ++i) { + if (clock.tick_bar()) ++fired; + } + NISPS_EXPECT(fired == 3); // samples 0, 4, 8 +} + +NISPS_TEST(seq_clock_bar_phasor_advances_only_on_fired_ticks) { + nisps::SeqClock clock(4u); + clock.update_bpm(120.f, 48000.f); + const float p0 = clock.bar_phasor(); + NISPS_EXPECT(p0 == 0.f); + NISPS_EXPECT(clock.tick_bar()); // sample 0 -> fires, advances + const float p1 = clock.bar_phasor(); + NISPS_EXPECT(p1 > p0); + NISPS_EXPECT(!clock.tick_bar()); // sample 1 -> no fire + NISPS_EXPECT(!clock.tick_bar()); // sample 2 -> no fire + NISPS_EXPECT(!clock.tick_bar()); // sample 3 -> no fire + NISPS_EXPECT(clock.bar_phasor() == p1); // unchanged while not firing +} + +NISPS_TEST(seq_clock_midi_clock_wraps_and_reports_true_on_wrap) { + nisps::SeqClock clock(400u); + // 120 bpm -> beat = 0.5s, 24 PPQN clock tick every 0.5/24 s ≈ 20.83ms. + // At 48kHz that's ~1000 samples/tick; drive enough samples to see at + // least one wrap without asserting an exact count (that's the engines' + // job in engine_impulse.cpp / test_engine_breakor.cpp). + clock.update_bpm(120.f, 48000.f); + int wraps = 0; + for (int i = 0; i < 2000; ++i) { + if (clock.tick_midi_clock()) ++wraps; + } + NISPS_EXPECT(wraps >= 1); +} + +NISPS_TEST(seq_clock_reset_zeroes_phase_and_counter) { + nisps::SeqClock clock(4u); + clock.update_bpm(120.f, 48000.f); + for (int i = 0; i < 10; ++i) clock.tick_bar(); + for (int i = 0; i < 10; ++i) clock.tick_midi_clock(); + clock.reset(); + NISPS_EXPECT(clock.bar_phasor() == 0.f); + // After reset, the very next tick_bar() should fire again (counter==0). + NISPS_EXPECT(clock.tick_bar()); +} + +// --------------------------------------------------------------------------- +// EventQueue +// --------------------------------------------------------------------------- + +NISPS_TEST(event_queue_fifo_and_batch_pop) { + nisps::EventQueue q; + NISPS_EXPECT(q.empty()); + q.push(1); + q.push(2); + q.push(3); + NISPS_EXPECT(q.size() == 3u); + std::array buf{}; + const std::size_t n = q.pop(std::span(buf)); + NISPS_EXPECT(n == 3u); + NISPS_EXPECT(buf[0] == 1); + NISPS_EXPECT(buf[1] == 2); + NISPS_EXPECT(buf[2] == 3); + NISPS_EXPECT(q.empty()); +} + +NISPS_TEST(event_queue_drops_on_overflow) { + nisps::EventQueue q; + for (int i = 0; i < 4; ++i) q.push(i); + q.push(99); // dropped — already at capacity + NISPS_EXPECT(q.size() == 4u); + std::array buf{}; + const std::size_t n = q.pop(std::span(buf)); + NISPS_EXPECT(n == 4u); + NISPS_EXPECT(buf[3] == 3); // the dropped 99 never made it in +} + +NISPS_TEST(event_queue_partial_pop_leaves_remainder) { + nisps::EventQueue q; + for (int i = 0; i < 5; ++i) q.push(i * 10); + std::array small{}; + const std::size_t n = q.pop(std::span(small)); + NISPS_EXPECT(n == 2u); + NISPS_EXPECT(small[0] == 0); + NISPS_EXPECT(small[1] == 10); + NISPS_EXPECT(q.size() == 3u); +} + +NISPS_TEST(event_queue_wraparound) { + nisps::EventQueue q; + int v = 0; + std::array one{}; + for (int i = 0; i < 100; ++i) { + q.push(i); + NISPS_EXPECT(q.pop(std::span(one)) == 1u); + v = one[0]; + NISPS_EXPECT(v == i); + } + NISPS_EXPECT(q.empty()); +}