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:
parent
f5b571412f
commit
96737a3d42
8 changed files with 401 additions and 136 deletions
|
|
@ -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
|
||||
|
|
|
|||
73
nisps/core/event_queue.hpp
Normal file
73
nisps/core/event_queue.hpp
Normal 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
45
nisps/dsp/ratio_seq.hpp
Normal 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
94
nisps/dsp/seq_clock.hpp
Normal 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
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
155
tests/cpp/test_dsp_seq_shared.cpp
Normal file
155
tests/cpp/test_dsp_seq_shared.cpp
Normal 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());
|
||||
}
|
||||
Loading…
Reference in a new issue