refactor(engines): extract the shared sequencer machinery

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.
This commit is contained in:
monkey-w1n5t0n 2026-07-21 14:02:23 +02:00
parent f5b571412f
commit 96737a3d42
8 changed files with 401 additions and 136 deletions

View file

@ -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

View file

@ -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 <array>
#include <cstddef>
#include <span>
#include <type_traits>
#include "perf.hpp"
namespace nisps {
template <typename T, std::size_t N>
class EventQueue {
static_assert(N > 0u, "EventQueue capacity must be > 0");
static_assert(std::is_trivially_copyable_v<T>,
"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<T> 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<T, N> buf_{};
std::size_t read_ = 0u;
std::size_t write_ = 0u;
std::size_t count_ = 0u;
};
} // namespace nisps

45
nisps/dsp/ratio_seq.hpp Normal file
View file

@ -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<N>` 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 <array>
#include <cstddef>
namespace nisps {
template <std::size_t N>
inline bool ratio_seq(float phasor, float ratio_sum,
const std::array<float, N>& 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

94
nisps/dsp/seq_clock.hpp Normal file
View file

@ -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 <cstddef>
#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<float>(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

View file

@ -20,8 +20,11 @@
#include <string_view>
#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<std::uint8_t>(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<Event> 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<float>(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 <std::size_t N>
static bool ratio_seq(float phasor, float ratio_sum,
const std::array<float, N>& 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<float, 3>& r, float pw) noexcept {
return ratio_seq<3>(p, s, r, pw);
}
static bool ratio_seq_2(float p, float s, const std::array<float, 2>& 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<Track, kNSequences> 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<Event, kEventBufferSize> events_{};
std::size_t event_read_ = 0u;
std::size_t event_write_ = 0u;
std::size_t event_count_ = 0u;
SeqClock clock_{kSequencingSampleDiv};
EventQueue<Event, kEventBufferSize> events_;
};
static_assert(AudioEngine<BreakOrEngine>, "BreakOrEngine must satisfy AudioEngine");

View file

@ -18,9 +18,11 @@
#include <string_view>
#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<std::uint8_t>(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<Event> 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<float>(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<Track, kNSequences> 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<Event, kEventBufferSize> events_{};
std::size_t event_read_ = 0u;
std::size_t event_write_ = 0u;
std::size_t event_count_ = 0u;
SeqClock clock_{kSequencingSampleDiv};
EventQueue<Event, kEventBufferSize> events_;
};
static_assert(AudioEngine<ElysiamorfEngine>, "ElysiamorfEngine must satisfy AudioEngine");

View file

@ -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 <std::size_t N>
static bool ratio_seq(float phasor, float ratio_sum,
const std::array<float, N>& 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<float, 3>& 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<float, 2>& 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;

View file

@ -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 <array>
#include <cstdint>
#include "test_helpers.hpp"
#include "../../nisps/core/event_queue.hpp"
#include "../../nisps/dsp/ratio_seq.hpp"
#include "../../nisps/dsp/seq_clock.hpp"
// ---------------------------------------------------------------------------
// ratio_seq<N>
// ---------------------------------------------------------------------------
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<float, 3> 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<float, 2> 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<float, 2> 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<int, 8> q;
NISPS_EXPECT(q.empty());
q.push(1);
q.push(2);
q.push(3);
NISPS_EXPECT(q.size() == 3u);
std::array<int, 8> buf{};
const std::size_t n = q.pop(std::span<int>(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<int, 4> 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<int, 8> buf{};
const std::size_t n = q.pop(std::span<int>(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<int, 8> q;
for (int i = 0; i < 5; ++i) q.push(i * 10);
std::array<int, 2> small{};
const std::size_t n = q.pop(std::span<int>(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<int, 4> q;
int v = 0;
std::array<int, 1> one{};
for (int i = 0; i < 100; ++i) {
q.push(i);
NISPS_EXPECT(q.pop(std::span<int>(one)) == 1u);
v = one[0];
NISPS_EXPECT(v == i);
}
NISPS_EXPECT(q.empty());
}