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_delay.cpp
|
||||||
${NISPS_TEST_DIR}/test_dsp_reverb.cpp
|
${NISPS_TEST_DIR}/test_dsp_reverb.cpp
|
||||||
${NISPS_TEST_DIR}/test_dsp_pitch_shift.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_no_op.cpp
|
||||||
${NISPS_TEST_DIR}/test_engine_paf_synth.cpp
|
${NISPS_TEST_DIR}/test_engine_paf_synth.cpp
|
||||||
${NISPS_TEST_DIR}/test_engine_channel_strip.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 <string_view>
|
||||||
|
|
||||||
#include "../core/concepts.hpp"
|
#include "../core/concepts.hpp"
|
||||||
|
#include "../core/event_queue.hpp"
|
||||||
#include "../core/perf.hpp"
|
#include "../core/perf.hpp"
|
||||||
#include "../core/types.hpp"
|
#include "../core/types.hpp"
|
||||||
|
#include "../dsp/ratio_seq.hpp"
|
||||||
|
#include "../dsp/seq_clock.hpp"
|
||||||
|
|
||||||
namespace nisps {
|
namespace nisps {
|
||||||
|
|
||||||
|
|
@ -50,9 +53,7 @@ class BreakOrEngine {
|
||||||
tracks_[i].midi_note = default_notes[i];
|
tracks_[i].midi_note = default_notes[i];
|
||||||
tracks_[i].last_trig = false;
|
tracks_[i].last_trig = false;
|
||||||
}
|
}
|
||||||
bar_phasor_ = 0.f;
|
clock_.reset();
|
||||||
midi_clock_phasor_ = 0.f;
|
|
||||||
sequencing_sample_counter_ = 0u;
|
|
||||||
update_bpm(90.f);
|
update_bpm(90.f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,23 +83,19 @@ class BreakOrEngine {
|
||||||
if (!playing_) return {0.f, 0.f};
|
if (!playing_) return {0.f, 0.f};
|
||||||
|
|
||||||
// MIDI clock: 24 PPQN — emit on clock-phasor wrap.
|
// MIDI clock: 24 PPQN — emit on clock-phasor wrap.
|
||||||
midi_clock_phasor_ += midi_clock_phasor_inc_;
|
if (clock_.tick_midi_clock()) {
|
||||||
if (midi_clock_phasor_ >= 1.f) {
|
|
||||||
midi_clock_phasor_ -= 1.f;
|
|
||||||
push_event({EventKind::Clock, 0u, 0u, 0u});
|
push_event({EventKind::Clock, 0u, 0u, 0u});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequencer ticks at sample-rate / kSequencingSampleDiv.
|
// Sequencer ticks at sample-rate / kSequencingSampleDiv.
|
||||||
if (sequencing_sample_counter_ == 0u) {
|
if (clock_.tick_bar()) {
|
||||||
bar_phasor_ += bar_phasor_inc_;
|
const float bar_phasor = clock_.bar_phasor();
|
||||||
if (bar_phasor_ >= 1.f) bar_phasor_ -= 1.f;
|
|
||||||
|
|
||||||
for (std::size_t i = 0u; i < kNSequences; ++i) {
|
for (std::size_t i = 0u; i < kNSequences; ++i) {
|
||||||
auto& t = tracks_[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);
|
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 trig = ratio_seq<3u>(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 high_amp = ratio_seq<2u>(seq_phasor, t.amp_ratio_sum, t.amp_ratios, 0.5f);
|
||||||
if (trig && !t.last_trig) {
|
if (trig && !t.last_trig) {
|
||||||
const std::uint8_t v = high_amp ? 127u : 64u;
|
const std::uint8_t v = high_amp ? 127u : 64u;
|
||||||
push_event({EventKind::NoteOn, static_cast<std::uint8_t>(i), t.midi_note, v});
|
push_event({EventKind::NoteOn, static_cast<std::uint8_t>(i), t.midi_note, v});
|
||||||
|
|
@ -108,8 +105,6 @@ class BreakOrEngine {
|
||||||
t.last_trig = trig;
|
t.last_trig = trig;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
++sequencing_sample_counter_;
|
|
||||||
if (sequencing_sample_counter_ >= kSequencingSampleDiv) sequencing_sample_counter_ = 0u;
|
|
||||||
return {0.f, 0.f};
|
return {0.f, 0.f};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,31 +113,17 @@ class BreakOrEngine {
|
||||||
// Event interface — drains `out` with up to `out.size()` queued events.
|
// Event interface — drains `out` with up to `out.size()` queued events.
|
||||||
// Returns how many were copied.
|
// Returns how many were copied.
|
||||||
std::size_t pop_events(std::span<Event> out) noexcept {
|
std::size_t pop_events(std::span<Event> out) noexcept {
|
||||||
std::size_t n = 0u;
|
return events_.pop(out);
|
||||||
while (n < out.size() && event_count_ > 0u) {
|
|
||||||
out[n++] = events_[event_read_];
|
|
||||||
event_read_ = (event_read_ + 1u) % kEventBufferSize;
|
|
||||||
--event_count_;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void update_bpm(float bpm) noexcept {
|
void update_bpm(float bpm) noexcept {
|
||||||
bpm_ = bpm;
|
clock_.update_bpm(bpm, sample_rate_);
|
||||||
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_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void set_playing(bool playing) noexcept {
|
void set_playing(bool playing) noexcept {
|
||||||
playing_ = playing;
|
playing_ = playing;
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
bar_phasor_ = 0.f;
|
clock_.reset();
|
||||||
midi_clock_phasor_ = 0.f;
|
|
||||||
sequencing_sample_counter_ = 0u;
|
|
||||||
for (auto& t : tracks_) {
|
for (auto& t : tracks_) {
|
||||||
if (t.last_trig) {
|
if (t.last_trig) {
|
||||||
push_event({EventKind::NoteOff, 0u, t.midi_note, 0u});
|
push_event({EventKind::NoteOff, 0u, t.midi_note, 0u});
|
||||||
|
|
@ -170,53 +151,16 @@ class BreakOrEngine {
|
||||||
bool last_trig = false;
|
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 {
|
NISPS_FORCE_INLINE void push_event(const Event& e) noexcept {
|
||||||
if (event_count_ >= kEventBufferSize) return; // drop on overflow
|
events_.push(e);
|
||||||
events_[event_write_] = e;
|
|
||||||
event_write_ = (event_write_ + 1u) % kEventBufferSize;
|
|
||||||
++event_count_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
float sample_rate_ = 48000.f;
|
float sample_rate_ = 48000.f;
|
||||||
float bpm_ = 90.f;
|
|
||||||
bool playing_ = true;
|
bool playing_ = true;
|
||||||
|
|
||||||
std::array<Track, kNSequences> tracks_;
|
std::array<Track, kNSequences> tracks_;
|
||||||
float bar_phasor_ = 0.f;
|
SeqClock clock_{kSequencingSampleDiv};
|
||||||
float bar_phasor_inc_ = 0.f;
|
EventQueue<Event, kEventBufferSize> events_;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static_assert(AudioEngine<BreakOrEngine>, "BreakOrEngine must satisfy AudioEngine");
|
static_assert(AudioEngine<BreakOrEngine>, "BreakOrEngine must satisfy AudioEngine");
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
#include "../core/concepts.hpp"
|
#include "../core/concepts.hpp"
|
||||||
|
#include "../core/event_queue.hpp"
|
||||||
#include "../core/perf.hpp"
|
#include "../core/perf.hpp"
|
||||||
#include "../core/types.hpp"
|
#include "../core/types.hpp"
|
||||||
#include "../dsp/osc.hpp"
|
#include "../dsp/osc.hpp"
|
||||||
|
#include "../dsp/seq_clock.hpp"
|
||||||
|
|
||||||
namespace nisps {
|
namespace nisps {
|
||||||
|
|
||||||
|
|
@ -44,9 +46,7 @@ class ElysiamorfEngine {
|
||||||
|
|
||||||
void setup(float sample_rate) noexcept {
|
void setup(float sample_rate) noexcept {
|
||||||
sample_rate_ = sample_rate;
|
sample_rate_ = sample_rate;
|
||||||
bar_phasor_ = 0.f;
|
clock_.reset();
|
||||||
midi_clock_phasor_ = 0.f;
|
|
||||||
sequencing_sample_counter_ = 0u;
|
|
||||||
update_bpm(90.f);
|
update_bpm(90.f);
|
||||||
for (auto& t : tracks_) {
|
for (auto& t : tracks_) {
|
||||||
t.carrier_freq = 1.f;
|
t.carrier_freq = 1.f;
|
||||||
|
|
@ -75,18 +75,15 @@ class ElysiamorfEngine {
|
||||||
NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept {
|
NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept {
|
||||||
if (!playing_) return {0.f, 0.f};
|
if (!playing_) return {0.f, 0.f};
|
||||||
|
|
||||||
midi_clock_phasor_ += midi_clock_phasor_inc_;
|
if (clock_.tick_midi_clock()) {
|
||||||
if (midi_clock_phasor_ >= 1.f) {
|
|
||||||
midi_clock_phasor_ -= 1.f;
|
|
||||||
push_event({EventKind::Clock, 0u, 0u, 0u});
|
push_event({EventKind::Clock, 0u, 0u, 0u});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sequencing_sample_counter_ == 0u) {
|
if (clock_.tick_bar()) {
|
||||||
bar_phasor_ += bar_phasor_inc_;
|
const float bar_phasor = clock_.bar_phasor();
|
||||||
if (bar_phasor_ >= 1.f) bar_phasor_ -= 1.f;
|
|
||||||
for (std::size_t i = 0u; i < kNSequences; ++i) {
|
for (std::size_t i = 0u; i < kNSequences; ++i) {
|
||||||
auto& t = tracks_[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);
|
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 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);
|
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});
|
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};
|
return {0.f, 0.f};
|
||||||
}
|
}
|
||||||
|
|
||||||
DriverConfig driver_config() const noexcept { return {}; }
|
DriverConfig driver_config() const noexcept { return {}; }
|
||||||
|
|
||||||
std::size_t pop_events(std::span<Event> out) noexcept {
|
std::size_t pop_events(std::span<Event> out) noexcept {
|
||||||
std::size_t n = 0u;
|
return events_.pop(out);
|
||||||
while (n < out.size() && event_count_ > 0u) {
|
|
||||||
out[n++] = events_[event_read_];
|
|
||||||
event_read_ = (event_read_ + 1u) % kEventBufferSize;
|
|
||||||
--event_count_;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void update_bpm(float bpm) noexcept {
|
void update_bpm(float bpm) noexcept {
|
||||||
bpm_ = bpm;
|
clock_.update_bpm(bpm, sample_rate_);
|
||||||
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_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void set_playing(bool playing) noexcept {
|
void set_playing(bool playing) noexcept {
|
||||||
playing_ = playing;
|
playing_ = playing;
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
bar_phasor_ = 0.f;
|
clock_.reset();
|
||||||
midi_clock_phasor_ = 0.f;
|
|
||||||
sequencing_sample_counter_ = 0u;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,27 +129,15 @@ class ElysiamorfEngine {
|
||||||
};
|
};
|
||||||
|
|
||||||
NISPS_FORCE_INLINE void push_event(const Event& e) noexcept {
|
NISPS_FORCE_INLINE void push_event(const Event& e) noexcept {
|
||||||
if (event_count_ >= kEventBufferSize) return;
|
events_.push(e);
|
||||||
events_[event_write_] = e;
|
|
||||||
event_write_ = (event_write_ + 1u) % kEventBufferSize;
|
|
||||||
++event_count_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
float sample_rate_ = 48000.f;
|
float sample_rate_ = 48000.f;
|
||||||
float bpm_ = 90.f;
|
|
||||||
bool playing_ = true;
|
bool playing_ = true;
|
||||||
|
|
||||||
std::array<Track, kNSequences> tracks_{};
|
std::array<Track, kNSequences> tracks_{};
|
||||||
float bar_phasor_ = 0.f;
|
SeqClock clock_{kSequencingSampleDiv};
|
||||||
float bar_phasor_inc_ = 0.f;
|
EventQueue<Event, kEventBufferSize> events_;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static_assert(AudioEngine<ElysiamorfEngine>, "ElysiamorfEngine must satisfy AudioEngine");
|
static_assert(AudioEngine<ElysiamorfEngine>, "ElysiamorfEngine must satisfy AudioEngine");
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
#include "../core/types.hpp"
|
#include "../core/types.hpp"
|
||||||
#include "../dsp/env.hpp"
|
#include "../dsp/env.hpp"
|
||||||
#include "../dsp/osc.hpp"
|
#include "../dsp/osc.hpp"
|
||||||
|
#include "../dsp/ratio_seq.hpp"
|
||||||
|
|
||||||
namespace nisps {
|
namespace nisps {
|
||||||
|
|
||||||
|
|
@ -251,29 +252,12 @@ class MEMLCeliumEngine {
|
||||||
bool last_trig = false;
|
bool last_trig = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
template <std::size_t N>
|
// ratio_seq lives once in dsp/ratio_seq.hpp (shared with BreakOrEngine).
|
||||||
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 {
|
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 {
|
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;
|
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