diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index a40f531..2f4677f 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -71,4 +71,36 @@ if(NOT EMSCRIPTEN) enable_testing() add_test(NAME nisps_core_tests COMMAND nisps_core_tests) + + # --------------------------------------------------------------------- + # DSP + engines tests (stream 3). Built as a separate executable so + # failures here don't take core/ML tests with them. + # --------------------------------------------------------------------- + add_executable(nisps_dsp_engine_tests + ${NISPS_TEST_DIR}/test_main.cpp + ${NISPS_TEST_DIR}/test_dsp_biquad.cpp + ${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_engine_no_op.cpp + ${NISPS_TEST_DIR}/test_engine_paf_synth.cpp + ${NISPS_TEST_DIR}/test_engine_channel_strip.cpp + ${NISPS_TEST_DIR}/test_engine_xiasri.cpp + ${NISPS_TEST_DIR}/test_engine_verb_fx.cpp + ${NISPS_TEST_DIR}/test_engine_memlcelium.cpp + ${NISPS_TEST_DIR}/test_engine_breakor.cpp + ${NISPS_TEST_DIR}/test_engine_elysiamorf.cpp + ${NISPS_TEST_DIR}/test_engine_analysis.cpp + ) + target_link_libraries(nisps_dsp_engine_tests PRIVATE nisps_core) + + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(nisps_dsp_engine_tests PRIVATE + -Wall -Wextra -Werror -Wpedantic + ) + elseif(MSVC) + target_compile_options(nisps_dsp_engine_tests PRIVATE /W4 /WX) + endif() + + add_test(NAME nisps_dsp_engine_tests COMMAND nisps_dsp_engine_tests) endif() diff --git a/nisps/engines/analysis.hpp b/nisps/engines/analysis.hpp new file mode 100644 index 0000000..fbfc2dd --- /dev/null +++ b/nisps/engines/analysis.hpp @@ -0,0 +1,265 @@ +// nisps/engines/analysis.hpp — input-side audio analysis engine for the +// SoundAnalysisMIDI mode. Mirrors firmware XiasriAnalysis. +// +// On each sample, computes 6 features: +// pitch — fundamental frequency from zero-crossing detection +// aperiodicity — variance of period lengths (jitter) +// energy — log-domain envelope follower +// attack — derivative of energy (transient detector) +// brightness — high-band energy / total band energy ratio +// energy_crude — |x| (one-sample peak) +// +// All features clamped to [0, 1]. +// +// Despite producing analysis-side output, this still satisfies AudioEngine — +// `process()` returns silence. Modes pull the latest features via `features()`. +// The 8-output SoundAnalysisMIDI schema feeds these features (plus the 4 +// joystick channels) as ML INPUTS, then routes ML outputs to MIDI CC. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/biquad.hpp" +#include "../dsp/filter.hpp" + +namespace nisps { + +// Fixed-size median filter — replaces memllib's std::vector-based version. +template +class MedianFilter { + public: + static_assert(N > 0u && (N & 1u) == 1u, "MedianFilter size must be odd > 0"); + + void reset(float value = 0.f) noexcept { + for (auto& v : buf_) v = value; + idx_ = 0u; + } + + float process(float input) noexcept { + buf_[idx_] = input; + idx_ = (idx_ + 1u) % N; + std::array tmp = buf_; + // Selection sort up to median index — O(N²/2), N = 16 is fine. + constexpr std::size_t kCenter = N / 2u; + for (std::size_t i = 0u; i <= kCenter; ++i) { + std::size_t min_i = i; + for (std::size_t j = i + 1u; j < N; ++j) { + if (tmp[j] < tmp[min_i]) min_i = j; + } + if (min_i != i) { + const float t = tmp[i]; + tmp[i] = tmp[min_i]; + tmp[min_i] = t; + } + } + return tmp[kCenter]; + } + + private: + std::array buf_{}; + std::size_t idx_ = 0u; +}; + +// Fixed-size circular buffer with [] access counted from oldest. +template +class CircularBuffer { + public: + void push(T value) noexcept { + buf_[idx_] = value; + idx_ = (idx_ + 1u) % N; + } + T operator[](std::size_t i) const noexcept { + // i==0 ⇒ oldest, i==N-1 ⇒ newest. + return buf_[(idx_ + i) % N]; + } + static constexpr std::size_t size() noexcept { return N; } + + private: + std::array buf_{}; + std::size_t idx_ = 0u; +}; + +class AnalysisEngine { + public: + static constexpr std::size_t kNFeatures = 6u; + // The engine itself produces no audio; expose a no-op param surface. + static constexpr std::size_t param_count() noexcept { return 0u; } + static constexpr std::string_view engine_id() noexcept { return "analysis"; } + + enum class VoiceSpace : std::size_t { None = 0 }; + static constexpr std::size_t kVoiceSpaceCount = 0u; + static constexpr std::array kVoiceSpaceNames = {}; + void set_voice_space(VoiceSpace) noexcept {} + VoiceSpace voice_space() const noexcept { return VoiceSpace::None; } + + struct Features { + float pitch = 0.f; + float aperiodicity = 0.f; + float energy = 0.f; + float attack = 0.f; + float brightness = 0.f; + float energy_crude = 0.f; + }; + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + common_hpf_.setup(sample_rate); + zc_lpf_.setup(sample_rate); + br_lpf1_.setup(sample_rate); + br_hpf2_.setup(sample_rate); + br_lpf2_.setup(sample_rate); + common_hpf_.set(Biquad::Type::HighPass, 20.f, 0.707f, 0.f); + zc_lpf_.set( Biquad::Type::LowPass, 4000.f, 0.707f, 0.f); + br_lpf1_.set( Biquad::Type::LowPass, 1000.f, 0.707f, 0.f); + br_hpf2_.set( Biquad::Type::HighPass, 1000.f, 0.707f, 0.f); + br_lpf2_.set( Biquad::Type::LowPass, 4000.f, 0.707f, 0.f); + ef_follower_.setup(sample_rate, 10.f, 100.f); + br_low_follower_.setup(sample_rate, 10.f, 100.f); + br_high_follower_.setup(sample_rate, 10.f, 100.f); + zc_median_.reset(0.f); + elapsed_samples_ = 0u; + prev_zx_ = 0.f; + ef_deriv_y_ = 0.f; + } + + void set_params(std::span /*params*/) noexcept {} + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept { + const float input = x.L; // analyse left channel + const float pre = common_hpf_.play(input); + + // ---- Pitch via zero-crossing ---- + const float zc_y = zc_lpf_.play(pre); + const bool zx = (zc_y > 0.f && prev_zx_ <= 0.f); + prev_zx_ = zc_y; + if (zx) { + const float median = zc_median_.process(static_cast(elapsed_samples_)); + zc_buffer_.push(median); + elapsed_samples_ = 0u; + } + ++elapsed_samples_; + + const float zc_value = zc_buffer_[kZCBufSize - 1u]; + const float pitch_hz = (zc_value > 1.f) ? (sample_rate_ / zc_value) : 0.f; + const float norm_pitch = clamp01((pitch_hz - kPitchMin) * kInvPitchRange); + + // ---- Aperiodicity from MAD of period lengths ---- + std::array zc_copy; + for (std::size_t i = 0u; i < kZCBufSize; ++i) zc_copy[i] = zc_buffer_[i]; + const float mad = mean_abs_deviation(zc_copy); + const float median_period = zc_value; + const float rel_mad = mad / (median_period + 1.f); + const float norm_aperiodicity = std::min(1.f, rel_mad * (1.f / 0.3f)); + + // ---- Energy via envelope follower + log mapping ---- + const float ef_lin = ef_follower_.play(pre); + const float ef_log = log_envelope(ef_lin); + const float deriv = ef_log - ef_deriv_y_; + ef_deriv_y_ = ef_log; + const float attack = std::max(0.f, std::min(deriv * 10.f, 1.f)); + + // ---- Brightness ---- + const float low = br_lpf1_.play(pre); + float high = br_hpf2_.play(pre); + high = br_lpf2_.play(high); + const float low_env = br_low_follower_.play(low); + const float high_env = br_high_follower_.play(high); + const float total = low_env + high_env + 1e-8f; + const float brightness = std::min(high_env / total, 1.f); + + features_.pitch = norm_pitch; + features_.aperiodicity = norm_aperiodicity; + features_.energy = ef_log; + features_.attack = attack; + features_.brightness = brightness; + features_.energy_crude = std::fabs(input); + return {0.f, 0.f}; + } + + DriverConfig driver_config() const noexcept { + DriverConfig c; + c.mic_input = true; + c.mic_gain_db = 20u; + return c; + } + + // Exposed for modes — read the latest computed feature snapshot. + const Features& features() const noexcept { return features_; } + + // Pack into a 6-channel array, in same order as Features fields. + void copy_features(std::span out) const noexcept { + if (out.size() < kNFeatures) return; + out[0] = features_.pitch; + out[1] = features_.aperiodicity; + out[2] = features_.energy; + out[3] = features_.attack; + out[4] = features_.brightness; + out[5] = features_.energy_crude; + } + + private: + static constexpr std::size_t kZCMedianSize = 15u; // odd + static constexpr std::size_t kZCBufSize = 32u; + static constexpr float kPitchMin = 20.f; + static constexpr float kPitchMax = 800.f; + static constexpr float kInvPitchRange = 1.f / (kPitchMax - kPitchMin); + + static float clamp01(float x) noexcept { + if (x < 0.f) return 0.f; + if (x > 1.f) return 1.f; + return x; + } + + static float mean_abs_deviation(const std::array& v) noexcept { + float sum = 0.f; + for (auto x : v) sum += x; + const float mean = sum / static_cast(kZCBufSize); + float acc = 0.f; + for (auto x : v) acc += std::fabs(x - mean); + return acc / static_cast(kZCBufSize); + } + + static float log_envelope(float linear_env) noexcept { + // Map [0.001 (-60dB), 1.0 (0dB)] → [0, 1]. + static const float kMinEnv = 1e-3f; + static const float kLog2MinEnv = -3.f * 3.321928095f; // -3 * log2(10) + static const float kInvLogRange = 1.f / (0.f - kLog2MinEnv); + if (linear_env < kMinEnv) linear_env = kMinEnv; + const float lg = std::log2(linear_env); + const float y = (lg - kLog2MinEnv) * kInvLogRange; + return clamp01(y); + } + + float sample_rate_ = 48000.f; + + Biquad common_hpf_; + Biquad zc_lpf_; + Biquad br_lpf1_; + Biquad br_hpf2_; + Biquad br_lpf2_; + + EnvelopeFollower ef_follower_; + EnvelopeFollower br_low_follower_; + EnvelopeFollower br_high_follower_; + + MedianFilter zc_median_; + CircularBuffer zc_buffer_; + + std::size_t elapsed_samples_ = 0u; + float prev_zx_ = 0.f; + float ef_deriv_y_ = 0.f; + + Features features_; +}; + +static_assert(AudioEngine, "AnalysisEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/base.hpp b/nisps/engines/base.hpp new file mode 100644 index 0000000..8b0f777 --- /dev/null +++ b/nisps/engines/base.hpp @@ -0,0 +1,41 @@ +// nisps/engines/base.hpp — common building blocks for AudioEngine +// implementations. +// +// Includes: +// - NoOpEngine: a silent passthrough used by sequencer-only modes +// (BreakOr, Elysiamorf). Their actual behaviour (MIDI/I2C event emission) +// lives in the engine wrapper outside the audio path; `process()` returns +// zeros so the audio driver has something to consume. +// - Helper macros / static_asserts to verify each concrete engine satisfies +// `nisps::AudioEngine` at compile time. + +#pragma once + +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" + +namespace nisps { + +class NoOpEngine { + public: + static constexpr std::size_t param_count() noexcept { return 0u; } + static constexpr std::string_view engine_id() noexcept { return "noop"; } + + void setup(float /*sample_rate*/) noexcept {} + void set_params(std::span /*params*/) noexcept {} + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept { + return {0.f, 0.f}; + } + + DriverConfig driver_config() const noexcept { return {}; } +}; + +static_assert(AudioEngine, "NoOpEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/breakor.hpp b/nisps/engines/breakor.hpp new file mode 100644 index 0000000..64ed068 --- /dev/null +++ b/nisps/engines/breakor.hpp @@ -0,0 +1,229 @@ +// nisps/engines/breakor.hpp — 8-track ratio-sequencer. +// +// `process()` returns silence; the engine's job is to emit MIDI/I2C events on +// each tick. Wraps a NoOpEngine for the audio path. Stream 4/6 will hook +// `pop_events()` into the firmware's MIDI/I2C output. +// +// Param layout: 8 tracks × 7 ratio-seq params each = 56 params. +// per track: [ratio0, ratio1, ratio2, phasorMul, phaseOff, ampRatio0, ampRatio1] +// +// Default MIDI notes: {36,37,38,39,40,42,43,45} (kick/snare/toms/hats etc.). + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" + +namespace nisps { + +class BreakOrEngine { + public: + static constexpr std::size_t kNSequences = 8u; + static constexpr std::size_t kSeqParamsEach = 7u; + static constexpr std::size_t kNParams = kNSequences * kSeqParamsEach; // = 56 + static constexpr std::size_t kEventBufferSize = 64u; + + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "breakor"; } + + enum class VoiceSpace : std::size_t { None = 0, Count = 0 }; + static constexpr std::size_t kVoiceSpaceCount = 0u; + static constexpr std::array kVoiceSpaceNames = {}; + void set_voice_space(VoiceSpace) noexcept {} + VoiceSpace voice_space() const noexcept { return VoiceSpace::None; } + + enum class EventKind : std::uint8_t { NoteOn, NoteOff, Clock }; + struct Event { + EventKind kind; + std::uint8_t track; + std::uint8_t midi_note; + std::uint8_t velocity; + }; + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + static const std::uint8_t default_notes[kNSequences] = {36u,37u,38u,39u,40u,42u,43u,45u}; + for (std::size_t i = 0u; i < kNSequences; ++i) { + 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; + update_bpm(90.f); + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + std::size_t i = 0u; + for (auto& t : tracks_) { + float sum = 0.f; + for (std::size_t r = 0u; r < 3u; ++r) { + t.ratios[r] = static_cast(static_cast(params[i++] * 3.f)) + 1.f; + sum += t.ratios[r]; + } + t.ratio_sum = sum; + static const float muls[4] = {1.f, 2.f, 4.f, 8.f}; + t.phasor_mul = muls[static_cast(params[i++] * 3.999999f) & 3]; + t.phase_off = static_cast(static_cast(params[i++] * 4.f)) * 0.25f; + sum = 0.f; + for (std::size_t r = 0u; r < 2u; ++r) { + t.amp_ratios[r] = static_cast(static_cast(params[i++] * 3.f)) + 1.f; + sum += t.amp_ratios[r]; + } + t.amp_ratio_sum = sum; + } + } + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept { + 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; + 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; + + for (std::size_t i = 0u; i < kNSequences; ++i) { + auto& t = tracks_[i]; + 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); + 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}); + } else if (!trig && t.last_trig) { + push_event({EventKind::NoteOff, static_cast(i), t.midi_note, 0u}); + } + t.last_trig = trig; + } + } + ++sequencing_sample_counter_; + if (sequencing_sample_counter_ >= kSequencingSampleDiv) sequencing_sample_counter_ = 0u; + return {0.f, 0.f}; + } + + DriverConfig driver_config() const noexcept { return {}; } + + // 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; + } + + 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_); + } + + void set_playing(bool playing) noexcept { + playing_ = playing; + if (!playing) { + bar_phasor_ = 0.f; + midi_clock_phasor_ = 0.f; + sequencing_sample_counter_ = 0u; + for (auto& t : tracks_) { + if (t.last_trig) { + push_event({EventKind::NoteOff, 0u, t.midi_note, 0u}); + } + t.last_trig = false; + } + } + } + + void set_track_note(std::size_t track, std::uint8_t note) noexcept { + if (track < kNSequences) tracks_[track].midi_note = note; + } + + private: + static constexpr std::size_t kSequencingSampleDiv = 400u; + + struct Track { + std::array ratios{1.f, 1.f, 1.f}; + std::array amp_ratios{1.f, 1.f}; + float ratio_sum = 3.f; + float amp_ratio_sum = 2.f; + float phasor_mul = 1.f; + float phase_off = 0.f; + std::uint8_t midi_note = 36u; + 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_; + } + + 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; +}; + +static_assert(AudioEngine, "BreakOrEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/channel_strip.hpp b/nisps/engines/channel_strip.hpp new file mode 100644 index 0000000..6503f89 --- /dev/null +++ b/nisps/engines/channel_strip.hpp @@ -0,0 +1,354 @@ +// nisps/engines/channel_strip.hpp — stereo console-style channel strip. +// +// Mirrors firmware ChannelStripAudioApp. Per-channel signal flow: +// pre-gain → tanh → HPF → LPF → 2× peak EQ → low-shelf → high-shelf → +// compressor → tanh → post-gain. +// +// Voice spaces are inline lambdas (`apply_voice_space_*`) that translate a +// 24-element NN output vector into the named member fields. They mirror +// `voicespaces/ChannelStrip/basic.hpp` exactly. +// +// Compressor: a feed-forward design with envelope follower + log-domain +// threshold + linear-domain ratio + smoothed gain. This is simpler than +// maximilian's `maxiDynamicsLite` (which has lookahead, knee, and bidirectional +// companding); the audible result is close enough for the voice-space ranges. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/biquad.hpp" +#include "../dsp/filter.hpp" + +namespace nisps { + +class ChannelStripEngine { + public: + static constexpr std::size_t kNParams = 24u; + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "channel_strip"; } + + enum class VoiceSpace : std::size_t { + WannabeNeve66 = 0, + SSL4KGist = 1, + SSL9KInda = 2, + MaleVox = 3, + FemaleVox = 4, + Neve80 = 5, + Count = 6, + }; + + static constexpr std::size_t kVoiceSpaceCount = static_cast(VoiceSpace::Count); + static constexpr std::array kVoiceSpaceNames = { + "WannabeNeve66", "SSL 4K G-ist", "SSL 9K-inda", "MaleVox", "FemaleVox", "Neve 80"}; + + void set_voice_space(VoiceSpace vs) noexcept { voice_space_ = vs; } + VoiceSpace voice_space() const noexcept { return voice_space_; } + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + for (auto* f : {&in_hpf_l_, &in_lpf_l_, &in_hpf_r_, &in_lpf_r_}) f->setup(sample_rate); + peak0_l_.setup(sample_rate); + peak1_l_.setup(sample_rate); + ls_l_.setup(sample_rate); + hs_l_.setup(sample_rate); + peak0_r_.setup(sample_rate); + peak1_r_.setup(sample_rate); + ls_r_.setup(sample_rate); + hs_r_.setup(sample_rate); + comp_env_l_.setup(sample_rate, 10.f, 200.f); + comp_env_r_.setup(sample_rate, 10.f, 200.f); + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + std::array p; + for (std::size_t i = 0u; i < kNParams; ++i) p[i] = params[i]; + switch (voice_space_) { + case VoiceSpace::WannabeNeve66: apply_neve66(p); break; + case VoiceSpace::SSL4KGist: apply_ssl4k(p); break; + case VoiceSpace::SSL9KInda: apply_ssl9k(p); break; + case VoiceSpace::MaleVox: apply_male_vox(p); break; + case VoiceSpace::FemaleVox: apply_female_vox(p); break; + case VoiceSpace::Neve80: apply_neve80(p); break; + case VoiceSpace::Count: break; + } + // Apply EQ updates to all biquads. + peak0_l_.set(Biquad::Type::Peak, peak0_freq_, peak0_q_, peak0_gain_); + peak1_l_.set(Biquad::Type::Peak, peak1_freq_, peak1_q_, peak1_gain_); + ls_l_.set(Biquad::Type::LowShelf, low_shelf_freq_, low_shelf_q_, low_shelf_gain_); + hs_l_.set(Biquad::Type::HighShelf, high_shelf_freq_, high_shelf_q_, high_shelf_gain_); + peak0_r_.set(Biquad::Type::Peak, peak0_freq_, peak0_q_, peak0_gain_); + peak1_r_.set(Biquad::Type::Peak, peak1_freq_, peak1_q_, peak1_gain_); + ls_r_.set(Biquad::Type::LowShelf, low_shelf_freq_, low_shelf_q_, low_shelf_gain_); + hs_r_.set(Biquad::Type::HighShelf, high_shelf_freq_, high_shelf_q_, high_shelf_gain_); + comp_env_l_.set_attack(comp_attack_); + comp_env_l_.set_release(comp_release_); + comp_env_r_.set_attack(comp_attack_); + comp_env_r_.set_release(comp_release_); + } + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept { + if (bypass_all_) return x; + + float yl = x.L; + float yr = x.R; + + if (!bypass_pre_post_gain_) { + yl = std::tanh(yl * pre_gain_); + yr = std::tanh(yr * pre_gain_); + } + if (!bypass_in_filters_) { + yl = in_lpf_l_.lowpass(yl, in_lowpass_cutoff_, 1.f); + yl = in_hpf_l_.highpass(yl, in_highpass_cutoff_, 1.f); + yr = in_lpf_r_.lowpass(yr, in_lowpass_cutoff_, 1.f); + yr = in_hpf_r_.highpass(yr, in_highpass_cutoff_, 1.f); + } + if (!bypass_eq_) { + yl = peak0_l_.play(yl); + yl = peak1_l_.play(yl); + yl = ls_l_.play(yl); + yl = hs_l_.play(yl); + yr = peak0_r_.play(yr); + yr = peak1_r_.play(yr); + yr = ls_r_.play(yr); + // Right-channel highshelf intentionally skipped — matches firmware's + // commented-out `// y1 = highshelf1.play(y1)`. Sonically negligible + // for these voice spaces but preserved for parity. + } + if (!bypass_comp_) { + yl = compress(yl, comp_env_l_, comp_threshold_, comp_ratio_); + yr = compress(yr, comp_env_r_, comp_threshold_, comp_ratio_); + } + if (!bypass_pre_post_gain_) { + yl = std::tanh(yl * post_gain_); + yr = std::tanh(yr * post_gain_); + } + return {yl, yr}; + } + + DriverConfig driver_config() const noexcept { + DriverConfig c; + c.line_level = 6u; + c.output_volume = 0.9f; + return c; + } + + void set_bypass_all(bool b) noexcept { bypass_all_ = b; } + void set_bypass_eq(bool b) noexcept { bypass_eq_ = b; } + void set_bypass_comp(bool b) noexcept { bypass_comp_ = b; } + void set_bypass_pre_post_gain(bool b) noexcept { bypass_pre_post_gain_ = b; } + void set_bypass_in_filters(bool b) noexcept { bypass_in_filters_ = b; } + + private: + NISPS_HOT NISPS_FORCE_INLINE float compress(float x, EnvelopeFollower& env, + float threshold_db, float ratio) noexcept { + // Simple downward compressor: detect via envelope follower, convert to + // dB, apply ratio above threshold, return to linear. + const float env_lin = env.play(x) + 1e-7f; + const float env_db = 20.f * std::log10(env_lin); + float gain_db = 0.f; + if (env_db > threshold_db && ratio > 1.f) { + gain_db = -(env_db - threshold_db) * (1.f - 1.f / ratio); + } + const float gain_lin = std::pow(10.f, gain_db * 0.05f); + return x * gain_lin; + } + + void apply_neve66(const std::array& p) noexcept { + pre_gain_ = 0.5f + (p[0] * p[0] * 4.f); + in_lowpass_cutoff_ = 2000.f + (p[7] * p[7] * 18000.f); + in_highpass_cutoff_ = 30.f + (p[8] * p[8] * 270.f); + low_shelf_freq_ = 31.5f + (p[14] * p[14] * 313.5f); + low_shelf_q_ = 0.6f + (p[15] * 4.4f); + low_shelf_gain_ = -15.f + (p[16] * 30.f); + peak0_freq_ = 200.f + (p[1] * p[1] * 1800.f); + peak0_q_ = 0.6f + (p[5] * 4.4f); + peak0_gain_ = -18.f + (p[6] * 36.f); + peak1_freq_ = 800.f + (p[4] * p[4] * 7200.f); + peak1_q_ = 0.6f + (p[5] * 4.4f); + peak1_gain_ = -18.f + (p[6] * 36.f); + high_shelf_freq_ = 1600.f + (p[17] * p[17] * 14400.f); + high_shelf_q_ = 0.6f + (p[18] * 4.4f); + high_shelf_gain_ = -18.f + (p[19] * 36.f); + comp_threshold_ = 20.f + (p[10] * -40.f); + comp_ratio_ = 1.f + (p[11] * 19.f); + comp_attack_ = 0.002f + (p[12] * 10.f); + comp_release_ = 30.f + (p[13] * p[13] * 2970.f); + post_gain_ = 0.5f + (p[23] * p[23] * 4.f); + } + void apply_ssl4k(const std::array& p) noexcept { + pre_gain_ = 0.5f + (p[0] * p[0] * 4.f); + in_lowpass_cutoff_ = 3000.f + (p[7] * p[7] * 18000.f); + in_highpass_cutoff_ = 10.f + (p[8] * p[8] * 340.f); + low_shelf_freq_ = 30.f + (p[14] * p[14] * 420.f); + low_shelf_q_ = 0.6f + (p[15] * 4.4f); + low_shelf_gain_ = -18.f + (p[16] * 36.f); + peak0_freq_ = 200.f + (p[1] * p[1] * 2300.f); + peak0_q_ = 0.6f + (p[5] * 4.4f); + peak0_gain_ = -22.f + (p[6] * 44.f); + peak1_freq_ = 600.f + (p[4] * p[4] * 6400.f); + peak1_q_ = 0.6f + (p[5] * 4.4f); + peak1_gain_ = -22.f + (p[6] * 44.f); + high_shelf_freq_ = 1500.f + (p[17] * p[17] * 14500.f); + high_shelf_q_ = 0.6f + (p[18] * 4.4f); + high_shelf_gain_ = -20.f + (p[19] * 40.f); + comp_threshold_ = 10.f + (p[10] * -30.f); + comp_ratio_ = 1.f + (p[11] * p[11] * 20.f); + comp_attack_ = 0.08f + (p[12] * 3.f); + comp_release_ = 100.f + (p[13] * p[13] * 3900.f); + post_gain_ = 0.5f + (p[23] * p[23] * 4.f); + } + void apply_ssl9k(const std::array& p) noexcept { + pre_gain_ = 0.5f + (p[0] * p[0] * 4.f); + in_lowpass_cutoff_ = 3000.f + (p[7] * p[7] * 18000.f); + in_highpass_cutoff_ = 10.f + (p[8] * p[8] * 490.f); + low_shelf_freq_ = 40.f + (p[14] * p[14] * 560.f); + low_shelf_q_ = 0.6f + (p[15] * 4.4f); + low_shelf_gain_ = -20.f + (p[16] * 40.f); + peak0_freq_ = 200.f + (p[1] * p[1] * 1800.f); + peak0_q_ = 0.5f + (p[5] * 2.f); + peak0_gain_ = -20.f + (p[6] * 40.f); + peak1_freq_ = 600.f + (p[4] * p[4] * 6400.f); + peak1_q_ = 0.5f + (p[5] * 2.f); + peak1_gain_ = -20.f + (p[6] * 40.f); + high_shelf_freq_ = 1500.f + (p[17] * p[17] * 20500.f); + high_shelf_q_ = 0.6f + (p[18] * 4.4f); + high_shelf_gain_ = -20.f + (p[19] * 40.f); + comp_threshold_ = 10.f + (p[10] * -30.f); + comp_ratio_ = 1.f + (p[11] * p[11] * 20.f); + comp_attack_ = 0.08f + (p[12] * 3.f); + comp_release_ = 100.f + (p[13] * p[13] * 3900.f); + post_gain_ = 0.5f + (p[23] * p[23] * 4.f); + } + void apply_male_vox(const std::array& p) noexcept { + pre_gain_ = 0.5f + (p[0] * p[0] * 4.f); + in_lowpass_cutoff_ = 1000.f + (p[7] * p[7] * 19000.f); + in_highpass_cutoff_ = 10.f + (p[8] * p[8] * 1990.f); + comp_threshold_ = p[10] * -30.f; + comp_ratio_ = 2.f + (p[11] * 6.f); + comp_attack_ = 0.08f + (p[12] * 50.f); + comp_release_ = 50.f + (p[13] * 500.f); + low_shelf_freq_ = 60.f + (p[14] * p[14] * 240.f); + low_shelf_q_ = 0.6f + (p[15] * 4.4f); + low_shelf_gain_ = -18.f + (p[16] * 36.f); + peak0_freq_ = 60.f + (p[1] * p[1] * 440.f); + peak0_q_ = 0.6f + (p[5] * 4.4f); + peak0_gain_ = -18.f + (p[6] * 36.f); + peak1_freq_ = 300.f + (p[4] * p[4] * 7700.f); + peak1_q_ = 0.6f + (p[5] * 4.4f); + peak1_gain_ = -18.f + (p[6] * 36.f); + high_shelf_freq_ = 1000.f + (p[17] * p[17] * 7000.f); + high_shelf_q_ = 0.6f + (p[18] * 4.4f); + high_shelf_gain_ = -18.f + (p[19] * 36.f); + post_gain_ = 0.5f + (p[23] * p[23] * 4.f); + } + void apply_female_vox(const std::array& p) noexcept { + pre_gain_ = 0.5f + (p[0] * p[0] * 4.f); + in_lowpass_cutoff_ = 1000.f + (p[7] * p[7] * 19000.f); + in_highpass_cutoff_ = 10.f + (p[8] * p[8] * 1990.f); + comp_threshold_ = p[10] * -30.f; + comp_ratio_ = 2.f + (p[11] * 6.f); + comp_attack_ = 0.08f + (p[12] * 50.f); + comp_release_ = 50.f + (p[13] * 500.f); + low_shelf_freq_ = 120.f + (p[14] * p[14] * 180.f); + low_shelf_q_ = 0.6f + (p[15] * 4.4f); + low_shelf_gain_ = -18.f + (p[16] * 36.f); + peak0_freq_ = 120.f + (p[1] * p[1] * 380.f); + peak0_q_ = 0.6f + (p[5] * 4.4f); + peak0_gain_ = -18.f + (p[6] * 36.f); + peak1_freq_ = 300.f + (p[4] * p[4] * 9700.f); + peak1_q_ = 0.6f + (p[5] * 4.4f); + peak1_gain_ = -18.f + (p[6] * 36.f); + high_shelf_freq_ = 1000.f + (p[17] * p[17] * 9000.f); + high_shelf_q_ = 0.6f + (p[18] * 4.4f); + high_shelf_gain_ = -18.f + (p[19] * 36.f); + post_gain_ = 0.5f + (p[23] * p[23] * 4.f); + } + void apply_neve80(const std::array& p) noexcept { + // Stepped frequencies — Neve-style fixed values picked by parameter index. + static const float lo_pass_freqs[] = {190.f, 1200.f, 3900.f, 5600.f, 9200.f}; + static const float hi_pass_freqs[] = {27.f, 47.f, 92.f, 150.f, 270.f}; + static const float comp_ratios[] = {1.5f, 2.f, 3.f, 4.f, 6.f}; + static const float comp_releases[] = {400.f, 800.f, 1500.f}; + static const float low_shelf_freqs[] = {33.f, 56.f, 100.f, 190.f, 330.f}; + static const float low_peak_freqs[] = {220.f, 270.f, 330.f, 390.f, 470.f, 560.f, 690.f, 820.f, 1000.f, 1200.f}; + static const float high_peak_freqs[] = {1500.f, 1900.f, 2200.f, 2700.f, 3300.f, 3900.f, 4700.f, 5600.f, 6900.f, 8200.f}; + static const float high_shelf_freqs[] = {3300.f, 4700.f, 6900.f, 10000.f, 15000.f}; + + pre_gain_ = 0.5f + (p[0] * p[0] * 4.f); + in_lowpass_cutoff_ = lo_pass_freqs[idx_clamp(p[7] * 3.999999f, 5)]; + in_highpass_cutoff_ = hi_pass_freqs[idx_clamp(p[8] * 3.999999f, 5)]; + comp_threshold_ = p[10] * -30.f; + comp_ratio_ = comp_ratios[idx_clamp(p[11] * 3.999999f, 5)]; + comp_attack_ = p[12] > 0.5f ? 5.f : 1.f; + comp_release_ = comp_releases[idx_clamp(p[13] * 1.999999f, 3)]; + low_shelf_freq_ = low_shelf_freqs[idx_clamp(p[14] * 3.999999f, 5)]; + low_shelf_q_ = 0.6f + (p[15] * 4.4f); + low_shelf_gain_ = -18.f + (p[16] * 36.f); + peak0_freq_ = low_peak_freqs[idx_clamp(p[1] * 8.999999f, 10)]; + peak0_q_ = 0.6f + (p[5] * 4.4f); + peak0_gain_ = -18.f + (p[6] * 36.f); + peak1_freq_ = high_peak_freqs[idx_clamp(p[4] * 8.999999f, 10)]; + peak1_q_ = 0.6f + (p[5] * 4.4f); + peak1_gain_ = -18.f + (p[6] * 36.f); + high_shelf_freq_ = high_shelf_freqs[idx_clamp(p[17] * 3.999999f, 5)]; + high_shelf_q_ = 0.6f + (p[18] * 4.4f); + high_shelf_gain_ = -18.f + (p[19] * 36.f); + post_gain_ = 0.5f + (p[23] * p[23] * 4.f); + } + + static std::size_t idx_clamp(float x, std::size_t n) noexcept { + if (x < 0.f) return 0u; + const auto i = static_cast(x); + return i >= n ? n - 1u : i; + } + + float sample_rate_ = 48000.f; + + // Voice-space-driven engine state. + float pre_gain_ = 1.f; + float post_gain_ = 1.f; + float in_lowpass_cutoff_ = 200.f; + float in_highpass_cutoff_ = 2000.f; + float comp_threshold_ = 0.f; + float comp_ratio_ = 1.f; + float comp_attack_ = 10.f; + float comp_release_ = 50.f; + float peak0_freq_ = 100.f; + float peak0_q_ = 1.f; + float peak0_gain_ = 1.f; + float peak1_freq_ = 1000.f; + float peak1_q_ = 1.f; + float peak1_gain_ = 1.f; + float low_shelf_freq_ = 100.f; + float low_shelf_q_ = 1.f; + float low_shelf_gain_ = 0.f; + float high_shelf_freq_ = 8000.f; + float high_shelf_q_ = 1.f; + float high_shelf_gain_ = 0.f; + + bool bypass_all_ = false; + bool bypass_eq_ = false; + bool bypass_comp_ = false; + bool bypass_pre_post_gain_ = false; + bool bypass_in_filters_ = false; + + VoiceSpace voice_space_ = VoiceSpace::WannabeNeve66; + + ChamberlinSVF in_hpf_l_, in_lpf_l_, in_hpf_r_, in_lpf_r_; + Biquad peak0_l_, peak1_l_, ls_l_, hs_l_; + Biquad peak0_r_, peak1_r_, ls_r_, hs_r_; + EnvelopeFollower comp_env_l_, comp_env_r_; +}; + +static_assert(AudioEngine, "ChannelStripEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/elysiamorf.hpp b/nisps/engines/elysiamorf.hpp new file mode 100644 index 0000000..03f1518 --- /dev/null +++ b/nisps/engines/elysiamorf.hpp @@ -0,0 +1,182 @@ +// nisps/engines/elysiamorf.hpp — 8-track FM-pair sequencer emitting MIDI CC. +// +// `process()` returns silence; on each tick the engine evaluates 8 FM-pair +// generators and emits CC events scaled to MIDI 0..127. Voice 0 → CC1, voice +// 1 → CC2, etc. (firmware mapping: {1,2,3,4,5,9,11,12}). +// +// Per-track param layout (5 params each, 40 total): carrier_freq, mod_freq, +// mod_index, phasor_mul, phase_off. Firmware NPARAMS template defaults to 56 +// but only consumes 40 — `param_notes.md` flags this and we follow consumption. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/osc.hpp" + +namespace nisps { + +class ElysiamorfEngine { + public: + static constexpr std::size_t kNSequences = 8u; + static constexpr std::size_t kSeqParamsEach = 5u; + static constexpr std::size_t kNParams = kNSequences * kSeqParamsEach; // = 40 + static constexpr std::size_t kEventBufferSize = 64u; + + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "elysiamorf"; } + + enum class VoiceSpace : std::size_t { None = 0, Count = 0 }; + static constexpr std::size_t kVoiceSpaceCount = 0u; + static constexpr std::array kVoiceSpaceNames = {}; + void set_voice_space(VoiceSpace) noexcept {} + VoiceSpace voice_space() const noexcept { return VoiceSpace::None; } + + enum class EventKind : std::uint8_t { CC, Clock }; + struct Event { + EventKind kind; + std::uint8_t cc_number; + std::uint8_t cc_value; + std::uint8_t pad; + }; + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + bar_phasor_ = 0.f; + midi_clock_phasor_ = 0.f; + sequencing_sample_counter_ = 0u; + update_bpm(90.f); + for (auto& t : tracks_) { + t.carrier_freq = 1.f; + t.mod_freq = 2.f; + t.mod_index = 0.f; + t.phasor_mul = 1.f; + t.phase_off = 0.f; + t.carrier.reset(); + t.modulator.reset(); + } + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + std::size_t i = 0u; + for (auto& t : tracks_) { + t.carrier_freq = (0.25f + params[i++] * 0.75f) * 0.125f; + t.mod_freq = (0.25f + params[i++] * 0.75f) * 0.25f; + t.mod_index = params[i++] * 4.f; + static const float muls[4] = {1.f, 2.f, 3.f, 4.f}; + t.phasor_mul = muls[static_cast(params[i++] * 3.999f) & 3]; + t.phase_off = static_cast(static_cast(params[i++] * 4.f)) * 0.25f; + } + } + + 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; + 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; + for (std::size_t i = 0u; i < kNSequences; ++i) { + auto& t = tracks_[i]; + 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); + // Map [-1, 1] → [0, 127]. + float scaled = (fm + 1.f) * 0.5f * 127.f; + if (scaled < 0.f) scaled = 0.f; + if (scaled > 127.f) scaled = 127.f; + 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; + } + + 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_); + } + + void set_playing(bool playing) noexcept { + playing_ = playing; + if (!playing) { + bar_phasor_ = 0.f; + midi_clock_phasor_ = 0.f; + sequencing_sample_counter_ = 0u; + } + } + + private: + static constexpr std::size_t kSequencingSampleDiv = 500u; + static constexpr std::uint8_t kCCNumbers[kNSequences] = {1u, 2u, 3u, 4u, 5u, 9u, 11u, 12u}; + + struct Track { + float carrier_freq = 1.f; + float mod_freq = 2.f; + float mod_index = 0.f; + float phasor_mul = 1.f; + float phase_off = 0.f; + FMOp carrier; + FMOp modulator; + }; + + 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_; + } + + 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; +}; + +static_assert(AudioEngine, "ElysiamorfEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/memlcelium.hpp b/nisps/engines/memlcelium.hpp new file mode 100644 index 0000000..a49f172 --- /dev/null +++ b/nisps/engines/memlcelium.hpp @@ -0,0 +1,321 @@ +// nisps/engines/memlcelium.hpp — Dual-voice PAF synth driven by a 2-track +// ratio sequencer. Mirrors firmware MEMLCeliumAudioApp. +// +// Param layout (from `MEMLCeliumAudioApp::ProcessParams`): +// [0..13] — sequencer (2 sequences × 7 ratio-seq params) +// [14..55] — synthesis (V0 + V1, 42 params total) +// +// Voice 0 (3 PAF operators): base freq, 3× cf, 3× bw, vib, vfr, 3× shift, +// amp ADSR (attack/decay/sustain/release), pitch envelope, pitch emphasis, +// shape gain/asym/mix, ring-mod gain. (~22 params) +// +// Voice 1 (3 PAF operators): base freq, detune1/2, 3× cf, 3× bw, 3× shift, +// amp ADSR, pitch envelope, pitch emphasis. (~20 params) +// +// The sequencer fires note events that trigger V0/V1 envelopes. The actual +// internal "RatioSeq" tick uses the sequencer-side params; we expose +// `pop_events()` for stream 4/6 to consume MIDI/I2C if desired. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/env.hpp" +#include "../dsp/osc.hpp" + +namespace nisps { + +class MEMLCeliumEngine { + public: + static constexpr std::size_t kNParams = 56u; + static constexpr std::size_t kNSequences = 2u; + static constexpr std::size_t kSeqParamsEach = 7u; + + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "memlcelium"; } + + enum class VoiceSpace : std::size_t { Direct = 0, Count = 1 }; + static constexpr std::size_t kVoiceSpaceCount = 1u; + static constexpr std::array kVoiceSpaceNames = {"Direct"}; + void set_voice_space(VoiceSpace) noexcept {} + VoiceSpace voice_space() const noexcept { return VoiceSpace::Direct; } + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + for (auto* op : {&v0_paf0_, &v0_paf1_, &v0_paf2_, + &v1_paf0_, &v1_paf1_, &v1_paf2_}) { + op->init(); + op->setsr(sample_rate); + } + v0_amp_env_.setup(500.f, 500.f, 0.8f, 1000.f, sample_rate); + v0_pitch_env_.setup(10.f, 500.f, 0.f, 100.f, sample_rate); + v1_amp_env_.setup(500.f, 500.f, 0.8f, 1000.f, sample_rate); + v1_pitch_env_.setup(10.f, 500.f, 0.f, 100.f, sample_rate); + bar_phasor_ = 0.f; + bar_phasor_inc_ = 0.f; + update_bpm(120.f); + sequencing_sample_counter_ = 0u; + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + // ---- sequencer (params 0..13) ---- + for (std::size_t s = 0u; s < kNSequences; ++s) { + const std::size_t base = s * kSeqParamsEach; + float sum = 0.f; + for (std::size_t i = 0u; i < 3u; ++i) { + seqs_[s].ratios[i] = static_cast(static_cast(params[base + i] * 3.f)) + 1.f; + sum += seqs_[s].ratios[i]; + } + seqs_[s].ratio_sum = sum; + static const float muls[4] = {1.f, 2.f, 4.f, 8.f}; + seqs_[s].phasor_mul = muls[static_cast(params[base + 3] * 3.999999f) & 3]; + seqs_[s].phase_off = static_cast(static_cast(params[base + 4] * 4.f)) * 0.25f; + sum = 0.f; + for (std::size_t i = 0u; i < 2u; ++i) { + seqs_[s].amp_ratios[i] = static_cast(static_cast(params[base + 5 + i] * 3.f)) + 1.f; + sum += seqs_[s].amp_ratios[i]; + } + seqs_[s].amp_ratio_sum = sum; + } + // ---- synthesis (params 14..55) ---- + std::size_t i = 14u; + auto sq = [&]() { const float p = params[i++]; return p * p; }; + + base_freq_ = 60.f + (params[i++] * 10.f); + v0_paf0_cf_ = params[i++] * 2.f; + v0_paf1_cf_ = params[i++] * 2.f; + v0_paf2_cf_ = params[i++] * 2.f; + v0_paf0_bw_ = 10.f + (params[i++] * 100.f); + v0_paf1_bw_ = 10.f + (params[i++] * 100.f); + v0_paf2_bw_ = 10.f + (params[i++] * 100.f); + v0_paf_vib_ = sq() * 0.01f; + v0_paf_vfr_ = sq() * 15.f; + v0_paf0_shift_ = -100.f + (params[i++] * 200.f); + v0_paf1_shift_ = -100.f + (params[i++] * 200.f); + v0_paf2_shift_ = -100.f + (params[i++] * 200.f); + { + const float a = 0.01f + (params[i++] * 1.f); + const float d = 0.5f + sq() * 200.f; + const float s = 0.01f + (params[i++] * 0.5f); + const float r = 1.f + sq() * 800.f; + v0_amp_env_.setup(a, d, s, r, sample_rate_); + } + { + const float a = 0.01f + (params[i++] * 3.f); + const float d = 0.5f + sq() * 100.f; + v0_pitch_env_.setup(a, d, 0.f, 0.1f, sample_rate_); + } + v0_pitch_emph_ = params[i++] * 50.f; + v0_shape_gain_ = params[i++]; + v0_shape_asym_ = params[i++] * 0.5f; + v0_shape_mix_ = params[i++]; + rm_gain_ = params[i++]; + + v1_base_freq_ = 300.f + (params[i++] * 10.f); + v1_detune1_ = 1.f + (params[i++] * 1.f); + v1_detune2_ = 1.f + (params[i++] * 1.f); + v1_paf0_cf_ = params[i++] * 2.f; + v1_paf1_cf_ = params[i++] * 2.f; + v1_paf2_cf_ = params[i++] * 2.f; + v1_paf0_bw_ = 10.f + (params[i++] * 400.f); + v1_paf1_bw_ = 10.f + (params[i++] * 600.f); + v1_paf2_bw_ = 10.f + (params[i++] * 500.f); + v1_paf0_shift_ = -500.f + (params[i++] * 1000.f); + v1_paf1_shift_ = -300.f + (params[i++] * 600.f); + v1_paf2_shift_ = -100.f + (params[i++] * 200.f); + { + const float a = 0.01f + (params[i++] * 1.f); + const float d = 0.5f + sq() * 100.f; + const float s = 0.01f + (params[i++] * 0.3f); + const float r = 1.f + sq() * 200.f; + v1_amp_env_.setup(a, d, s, r, sample_rate_); + } + { + const float a = 0.01f + (params[i++] * 3.f); + const float d = 0.5f + sq() * 100.f; + v1_pitch_env_.setup(a, d, 0.f, 0.1f, sample_rate_); + } + v1_pitch_emph_ = params[i++] * 10.f; + } + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept { + // Sequencer tick — one decision per `kSequencingSampleDiv` audio + // samples to keep CPU bounded; matches firmware's `sequencingSampleDiv = 400`. + if (sequencing_sample_counter_ == 0u) { + bar_phasor_ += bar_phasor_inc_; + if (bar_phasor_ >= 1.f) bar_phasor_ -= 1.f; + for (std::size_t i = 0u; i < kNSequences; ++i) { + auto& s = seqs_[i]; + float seq_phasor = bar_phasor_ * s.phasor_mul; + seq_phasor = std::fmod(seq_phasor + s.phase_off, 1.f); + const bool trig = ratio_seq_3(seq_phasor, s.ratio_sum, s.ratios, 0.5f); + const bool high_amp = ratio_seq_2(seq_phasor, s.amp_ratio_sum, s.amp_ratios, 0.5f); + if (trig && !s.last_trig) { + const std::uint8_t velocity = high_amp ? 127u : 64u; + const float v = static_cast(velocity) / 127.f; + const float vsq = v * v; + if (i == 0u) { v0_amp_env_.trigger(vsq); v0_pitch_env_.trigger(1.f); } + else { v1_amp_env_.trigger(vsq); v1_pitch_env_.trigger(1.f); } + } else if (!trig && s.last_trig) { + if (i == 0u) { v0_amp_env_.release(); v0_pitch_env_.release(); } + else { v1_amp_env_.release(); v1_pitch_env_.release(); } + } + s.last_trig = trig; + } + } + ++sequencing_sample_counter_; + if (sequencing_sample_counter_ >= kSequencingSampleDiv) sequencing_sample_counter_ = 0u; + + // ----- Voice 0 ----- + const float v0_env = v0_amp_env_.play(); + const float v0_p = v0_pitch_env_.play() * v0_pitch_emph_; + const float fbsmooth = (fbzm1_ * fb_smooth_alpha_) + (feedback_ * (1.f - fb_smooth_alpha_)); + fbzm1_ = fbsmooth; + const float freq0 = base_freq_ * (1.f + fbsmooth) + (v0_p * base_freq_); + const float p0 = v0_paf0_.play(freq0, freq0 + (v0_paf0_cf_ * freq0), + v0_paf0_bw_, v0_paf_vib_, v0_paf_vfr_, v0_paf0_shift_, false); + const float freq1 = freq0 * 1.01f; + const float p1 = v0_paf1_.play(freq1, freq1 + (v0_paf1_cf_ * freq1), + v0_paf1_bw_, v0_paf_vib_, v0_paf_vfr_, v0_paf1_shift_, true); + const float freq2 = freq1 * 1.02f; + const float p2 = v0_paf2_.play(freq2, freq2 + (v0_paf2_cf_ * freq2), + v0_paf2_bw_, v0_paf_vib_, v0_paf_vfr_, v0_paf2_shift_, true); + float v0 = (p0 + p1 + p2) * v0_env; + + // ----- Voice 1 ----- + const float v1_env = v1_amp_env_.play(); + const float v1_p = v1_pitch_env_.play() * v1_pitch_emph_; + const float v1f0 = v1_base_freq_ + (v1_p * v1_base_freq_); + const float v1p0 = v1_paf0_.play(v1f0, v1f0 + (v1_paf0_cf_ * v1f0), + v1_paf0_bw_, 0.f, 0.f, v1_paf0_shift_, false); + const float v1f1 = v1f0 * v1_detune1_; + const float v1p1 = v1_paf1_.play(v1f1, v1f1 + (v1_paf1_cf_ * v1f1), + v1_paf1_bw_, 0.f, 0.f, v1_paf1_shift_, true); + const float v1f2 = v1f1 * v1_detune2_; + // Note: firmware has a bug where this line uses `freq2` (V0's freq), + // but we faithfully port it for sonic parity. + const float v1p2 = v1_paf2_.play(v1f2, v1f2 + (v1_paf2_cf_ * freq2), + v1_paf2_bw_, 0.f, 0.f, v1_paf2_shift_, true); + float v1 = v1p0 + v1p1 + v1p2; + const float rm = v1p0 * v1p1 * v1p2; + v1 = ((1.f - rm_gain_) * v1) + (rm * rm_gain_); + v1 = v1 * v1_env; + + // ----- Mix + sine shaper ----- + float mix = v0 + v1; + static const float kTwoPi = 6.28318530717958647692f; + float shape = std::sin(mix * kTwoPi); + shape = std::sin((shape * kTwoPi * v0_shape_gain_) + v0_shape_asym_); + mix = mix + (shape * v0_shape_mix_); + mix = std::tanh(mix); + return {mix, mix}; + } + + DriverConfig driver_config() const noexcept { + DriverConfig c; + c.output_volume = 0.9f; + return c; + } + + void update_bpm(float bpm) noexcept { + bpm_ = bpm; + const float beat_seconds = 60.f / bpm; + const float bar_seconds = beat_seconds * 4.f; // assume 4/4 + const float bar_samples = bar_seconds * (sample_rate_ / static_cast(kSequencingSampleDiv)); + bar_phasor_inc_ = 1.f / bar_samples; + } + + void set_playing(bool playing) noexcept { + if (!playing) { + bar_phasor_ = 0.f; + sequencing_sample_counter_ = 0u; + for (auto& s : seqs_) { s.last_trig = false; } + v0_amp_env_.release(); v0_pitch_env_.release(); + v1_amp_env_.release(); v1_pitch_env_.release(); + } + } + + private: + static constexpr std::size_t kSequencingSampleDiv = 400u; + + struct SeqState { + std::array ratios{1.f, 1.f, 1.f}; + std::array amp_ratios{1.f, 1.f}; + float ratio_sum = 3.f; + float amp_ratio_sum = 2.f; + float phasor_mul = 1.f; + float phase_off = 0.f; + 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); + } + + float sample_rate_ = 48000.f; + float bpm_ = 120.f; + + PAFOperator v0_paf0_, v0_paf1_, v0_paf2_; + PAFOperator v1_paf0_, v1_paf1_, v1_paf2_; + ADSR v0_amp_env_, v0_pitch_env_; + ADSR v1_amp_env_, v1_pitch_env_; + + std::array seqs_; + float bar_phasor_ = 0.f; + float bar_phasor_inc_ = 0.f; + std::size_t sequencing_sample_counter_ = 0u; + + // Synth state. + float base_freq_ = 60.f; + float v0_paf0_cf_ = 0.f, v0_paf1_cf_ = 0.f, v0_paf2_cf_ = 0.f; + float v0_paf0_bw_ = 50.f, v0_paf1_bw_ = 50.f, v0_paf2_bw_ = 50.f; + float v0_paf_vib_ = 0.f, v0_paf_vfr_ = 0.f; + float v0_paf0_shift_ = 0.f, v0_paf1_shift_ = 0.f, v0_paf2_shift_ = 0.f; + float v0_pitch_emph_ = 0.f; + float v0_shape_gain_ = 0.f, v0_shape_asym_ = 0.f, v0_shape_mix_ = 0.f; + float rm_gain_ = 0.f; + + float v1_base_freq_ = 300.f; + float v1_detune1_ = 1.f, v1_detune2_ = 1.f; + float v1_paf0_cf_ = 0.f, v1_paf1_cf_ = 0.f, v1_paf2_cf_ = 0.f; + float v1_paf0_bw_ = 50.f, v1_paf1_bw_ = 50.f, v1_paf2_bw_ = 50.f; + float v1_paf0_shift_ = 0.f, v1_paf1_shift_ = 0.f, v1_paf2_shift_ = 0.f; + float v1_pitch_emph_ = 0.f; + + float feedback_ = 0.f; + float fbzm1_ = 0.f; + float fb_smooth_alpha_ = 0.5f; +}; + +static_assert(AudioEngine, "MEMLCeliumEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/paf_synth.hpp b/nisps/engines/paf_synth.hpp new file mode 100644 index 0000000..4c0203d --- /dev/null +++ b/nisps/engines/paf_synth.hpp @@ -0,0 +1,407 @@ +// nisps/engines/paf_synth.hpp — 4-voice PAF (Phase-Aligned Formant) synth. +// +// Mirrors firmware PAFSynthAudioApp. Process pipeline: +// - 4 PAF operators with independent freq/cf/bw/vib/vfr/shift settings +// - cross-operator detune cascade +// - sum + ring-mod + sine-shaper +// - ADSR envelope on the carrier +// - tanh saturation +// - feedback delay line +// +// Note triggering is NOT done via set_params (firmware uses a separate MIDI +// queue). We expose `note_on(note, velocity)` / `note_off(note)` directly; +// modes call them from a non-RT context. +// +// 7 voice spaces (Ellipticacacia, Rowantares, Neemeda, Aquillow, Magnetarch, +// Elderstar, Ipeleiades) re-map the 33 NN outputs to engine state. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/delay.hpp" +#include "../dsp/env.hpp" +#include "../dsp/osc.hpp" + +namespace nisps { + +class PAFSynthEngine { + public: + static constexpr std::size_t kNParams = 33u; + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "paf_synth"; } + + enum class VoiceSpace : std::size_t { + Ellipticacacia = 0, // QuadDetune + Rowantares = 1, // VS1 + Neemeda = 2, // VS2 + Aquillow = 3, // Perc + Magnetarch = 4, // Single1 + Elderstar = 5, // QuadOct + Ipeleiades = 6, // QuadDist + Count = 7, + }; + + static constexpr std::size_t kVoiceSpaceCount = static_cast(VoiceSpace::Count); + static constexpr std::array kVoiceSpaceNames = { + "Ellipticacacia", "Rowantares", "Neemeda", "Aquillow", + "Magnetarch", "Elderstar", "Ipeleiades"}; + + void set_voice_space(VoiceSpace vs) noexcept { voice_space_ = vs; } + VoiceSpace voice_space() const noexcept { return voice_space_; } + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + for (auto* op : {&paf0_, &paf1_, &paf2_, &paf3_}) { + op->init(); + op->setsr(sample_rate); + } + env_.setup(500.f, 500.f, 0.8f, 1000.f, sample_rate); + delay_.clear(); + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + std::array p; + for (std::size_t i = 0u; i < kNParams; ++i) p[i] = params[i]; + switch (voice_space_) { + case VoiceSpace::Ellipticacacia: apply_quad_detune(p); break; + case VoiceSpace::Rowantares: apply_vs1(p); break; + case VoiceSpace::Neemeda: apply_vs2(p); break; + case VoiceSpace::Aquillow: apply_perc(p); break; + case VoiceSpace::Magnetarch: apply_single1(p); break; + case VoiceSpace::Elderstar: apply_quad_oct(p); break; + case VoiceSpace::Ipeleiades: apply_quad_dist(p); break; + case VoiceSpace::Count: break; + } + } + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t /*x*/) noexcept { + // Smooth feedback amount. + const float fbsmooth = (fbzm1_ * fb_smooth_alpha_) + (feedback_ * (1.f - fb_smooth_alpha_)); + fbzm1_ = fbsmooth; + + const float freq0 = base_freq_ * (1.f + fbsmooth); + const float p0 = paf0_.play(freq0, freq0 + (paf0_cf_ * freq0), + paf0_bw_, paf0_vib_, paf0_vfr_, paf0_shift_, false) * p0_gain_; + const float freq1 = freq0 * detune1_; + const float p1 = paf1_.play(freq1, freq1 + (paf1_cf_ * freq1), + paf1_bw_, paf1_vib_, paf1_vfr_, paf1_shift_, true) * p1_gain_; + const float freq2 = freq1 * detune2_; + const float p2 = paf2_.play(freq2, freq2 + (paf2_cf_ * freq2), + paf2_bw_, paf2_vib_, paf2_vfr_, paf2_shift_, true) * p2_gain_; + const float freq3 = freq2 * detune3_; + const float p3 = paf3_.play(freq3, freq3 + (paf3_cf_ * freq3), + paf3_bw_ * freq3, paf3_vib_, paf3_vfr_, paf3_shift_, true) * p3_gain_; + + float y = p0 + p1 + p2 + p3; + const float rm = p0 * p1 * p2 * p3; + y = y + (rm * rm_gain_); + + static const float kTwoPi = 6.28318530717958647692f; + float shape = std::sin(y * kTwoPi); + shape = std::sin(((shape * kTwoPi) * sine_shape_gain_) + sine_shape_asym_); + y = y + (shape * sine_shape_mix_); + + const float envval = env_.play(); + y = y * envval; + y = std::tanh(y); + + const float d1 = delay_.play(y, delay_max_, dl_fb_) * dl1_mix_; + y = y + d1; + feedback_ = y * feedback_gain_; + + return {y, y}; + } + + DriverConfig driver_config() const noexcept { + DriverConfig c; + c.output_volume = 0.9f; + return c; + } + + // Note interface — called from non-RT mode glue (MIDI keyboard / sequencer). + void note_on(std::uint8_t midi_note, std::uint8_t velocity) noexcept { + base_freq_ = mtof(midi_note); + const float v = static_cast(velocity) / 127.f; + const float vsq = v * v; + env_.trigger(vsq); + current_note_ = midi_note; + } + void note_off(std::uint8_t midi_note) noexcept { + if (midi_note == current_note_) env_.release(); + } + + static constexpr float mtof(std::uint8_t note) noexcept { + return 440.f * std::exp2((static_cast(note) - 69.f) / 12.f); + } + + private: + // -------- voice space implementations -------- + // VS1 — Rowantares + void apply_vs1(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = 1.f; p2_gain_ = 0.f; p3_gain_ = 0.f; + detune1_ = 2.f; detune2_ = 1.f; detune3_ = 1.f; + paf0_cf_ = p[2]; paf1_cf_ = p[3]; + paf0_bw_ = 10.f + (p[5] * 200.f); + paf1_bw_ = 10.f + (p[6] * 200.f); + paf0_vib_ = p[8] * p[8] * 0.05f; + paf1_vib_ = p[9] * p[9] * 0.05f; + paf0_vfr_ = p[11] * p[11] * 5.f; + paf1_vfr_ = p[12] * p[12] * 5.f; + paf0_shift_ = -50.f + (p[14] * 100.f); + paf1_shift_ = -50.f + (p[15] * 100.f); + dl1_mix_ = p[17] * p[17] * 0.8f; + dl_fb_ = p[19] * 0.9f; + env_.setup(1.f + p[30] * 200.f, + 1.f + p[20] * p[20] * 500.f, + 0.01f + (p[31] * 0.5f), + 1.f + p[32] * 500.f, + sample_rate_); + sine_shape_gain_ = p[26] * p[26]; + sine_shape_asym_ = p[27] * p[27] * 0.1f; + sine_shape_mix_ = p[28]; + rm_gain_ = p[29] * p[29]; + feedback_gain_ = 0.f; + delay_max_ = 1000u; + fb_smooth_alpha_ = 0.f; + } + // VS2 — Neemeda + void apply_vs2(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = 1.f; p2_gain_ = 1.f; p3_gain_ = 1.f; + detune1_ = 2.f; detune2_ = 0.5f; detune3_ = 1.f; + paf0_cf_ = p[2]; paf1_cf_ = p[3]; paf2_cf_ = p[4]; paf3_cf_ = p[21]; + paf0_bw_ = 10.f + p[5] * 400.f; + paf1_bw_ = 10.f + p[6] * 300.f; + paf2_bw_ = 10.f + p[7] * 200.f; + paf3_bw_ = 10.f + p[22] * 100.f; + paf0_vib_ = p[8] * p[8] * 0.1f; + paf1_vib_ = p[9] * p[9] * 0.05f; + paf2_vib_ = p[10] * p[10] * 0.05f; + paf3_vib_ = p[23] * p[23] * 0.05f; + paf0_vfr_ = p[11] * p[11] * 5.f; + paf1_vfr_ = p[12] * p[12] * 5.f; + paf2_vfr_ = p[13] * p[13] * 10.f; + paf3_vfr_ = p[24] * p[24] * 10.f; + paf0_shift_ = -50.f + p[14] * 200.f; + paf1_shift_ = -50.f + p[15] * 200.f; + paf2_shift_ = -50.f + p[16] * 300.f; + paf3_shift_ = -50.f + p[25] * 400.f; + dl1_mix_ = p[17] * p[17] * 0.3f; + dl_fb_ = p[19] * 0.95f; + env_.setup(1.f + p[30] * 200.f, 1.f + p[20] * p[20] * 500.f, + 0.01f + p[31] * 0.5f, 1.f + p[32] * 500.f, sample_rate_); + sine_shape_gain_ = p[26] * p[26]; + sine_shape_asym_ = p[27] * p[27] * 0.2f; + sine_shape_mix_ = p[28]; + rm_gain_ = p[29] * p[29]; + feedback_gain_ = 0.01f; + delay_max_ = 3000u; + fb_smooth_alpha_ = 0.94f; + } + // Perc — Aquillow + void apply_perc(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = 1.f; p2_gain_ = 1.f; p3_gain_ = 1.f; + detune1_ = 1.f; detune2_ = 1.1f; detune3_ = 1.2f; + paf0_cf_ = p[2] * 2.f; paf1_cf_ = p[3] * 2.f; + paf2_cf_ = p[4] * 2.f; paf3_cf_ = p[21] * 2.f; + paf0_bw_ = 10.f + p[5] * 400.f; + paf1_bw_ = 10.f + p[6] * 50.f; + paf2_bw_ = 10.f + p[7] * 50.f; + paf3_bw_ = 10.f + p[22] * 100.f; + paf0_vib_ = p[8] * p[8] * 0.01f; + paf1_vib_ = p[9] * p[9] * 0.01f; + paf2_vib_ = p[10] * p[10] * 0.01f; + paf3_vib_ = p[23] * p[23] * 0.01f; + paf0_vfr_ = p[11] * p[11] * 15.f; + paf1_vfr_ = p[12] * p[12] * 15.f; + paf2_vfr_ = p[13] * p[13] * 15.f; + paf3_vfr_ = p[24] * p[24] * 15.f; + paf0_shift_ = -500.f + p[14] * 500.f; + paf1_shift_ = -300.f + p[15] * 300.f; + paf2_shift_ = -300.f + p[16] * 300.f; + paf3_shift_ = -300.f + p[25] * 300.f; + dl1_mix_ = p[17] * p[17] * 0.5f; + dl_fb_ = p[19] * 0.95f; + env_.setup(0.2f + p[30] * 1.f, + 0.5f + p[20] * p[20] * 100.f, + 0.01f + p[31] * 0.1f, + 1.f + p[32] * p[32] * 300.f, + sample_rate_); + sine_shape_gain_ = p[26]; + sine_shape_asym_ = p[27] * 0.5f; + sine_shape_mix_ = p[28]; + rm_gain_ = p[29]; + feedback_gain_ = 0.1f; + delay_max_ = 178u; + fb_smooth_alpha_ = 0.5f; + } + // Single1 — Magnetarch + void apply_single1(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = 0.f; p2_gain_ = 0.f; p3_gain_ = 0.f; + static const float kTwoPi = 6.28318530717958647692f; + float p1v = p[0] + p[7] + p[8]; + p1v = ((std::sin(p1v * kTwoPi)) + 1.f) * 0.5f; + float p2v = p[9] + p[10] + p[11]; + p2v = ((std::sin(p2v * kTwoPi)) + 1.f) * 0.5f; + float p3v = p[12] + p[13] + p[14] + p[15]; + p3v = ((std::sin(p3v * kTwoPi)) + 1.f) * 0.5f; + paf0_cf_ = p1v * 2.f; + paf0_bw_ = 10.f + p2v * 700.f; + paf0_vib_ = 0.f; paf0_vfr_ = 0.f; + paf0_shift_ = -20.f + p3v * 40.f; + dl1_mix_ = 0.f; dl_fb_ = 0.f; + env_.setup(1.f + p[1] * 50.f, + 1.f + p[2] * 300.f, + p[3] * 0.7f, + 10.f + p[4] * 500.f, + sample_rate_); + sine_shape_gain_ = p[5] * p[5] * 0.2f; + sine_shape_asym_ = 0.f; + sine_shape_mix_ = p[6] * 0.3f; + rm_gain_ = 0.f; + feedback_gain_ = 0.f; + delay_max_ = 1000u; + fb_smooth_alpha_ = 0.f; + } + // QuadDetune — Ellipticacacia + void apply_quad_detune(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = 1.f; p2_gain_ = 1.f; p3_gain_ = 0.8f; + const float factor = 1.f + (p[17] * 0.2f); + detune1_ = 1.f * factor; + detune2_ = detune1_ * factor; + detune3_ = detune2_ * factor; + paf0_cf_ = p[0] * 1.f; paf1_cf_ = p[0] * 2.f; + paf2_cf_ = p[1] * 3.f; paf3_cf_ = p[1] * 5.f; + paf0_bw_ = 10.f + p[2] * 500.f; + paf1_bw_ = 10.f + p[3] * 500.f; + paf2_bw_ = 10.f + p[4] * 500.f; + paf3_bw_ = 10.f + p[5] * 2000.f; + paf0_vib_ = p[18] * p[18] * 0.05f; paf1_vib_ = paf0_vib_; + paf2_vib_ = 0.f; paf3_vib_ = 0.f; + paf0_vfr_ = p[19] * p[19] * 15.f; paf1_vfr_ = paf0_vfr_; + paf2_vfr_ = 0.f; paf3_vfr_ = 0.f; + paf0_shift_ = 0.f; paf1_shift_ = 0.f; paf2_shift_ = 0.f; + paf3_shift_ = -40.f + p[9] * 80.f; + dl1_mix_ = 0.f; dl_fb_ = 0.f; + env_.setup(1.f + p[10] * 20.f, 1.f + p[11] * 200.f, + p[12] * 0.4f, 10.f + p[13] * 300.f, sample_rate_); + sine_shape_gain_ = p[14] * p[14] * 0.2f; + sine_shape_asym_ = p[15] * 0.05f; + sine_shape_mix_ = p[16] * 0.3f; + rm_gain_ = 0.f; feedback_gain_ = 0.f; + delay_max_ = 1000u; fb_smooth_alpha_ = 0.f; + } + // QuadOct — Elderstar + void apply_quad_oct(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = p[24]; p2_gain_ = p[25]; p3_gain_ = p[26]; + const float factor = 1.f + (p[17] + p[27] * 0.2f); + detune1_ = (1.f * factor) * 0.5f; + detune2_ = (1.f * factor * factor) * 2.f; + detune3_ = (detune2_ * factor) * 2.f; + paf0_cf_ = p[0] * 1.f; paf1_cf_ = p[0] * 2.f; + paf2_cf_ = p[1] * 3.f; paf3_cf_ = p[1] * 5.f; + paf0_bw_ = 10.f + p[2] * 500.f; + paf1_bw_ = 10.f + p[3] * 500.f; + paf2_bw_ = 10.f + p[4] * 500.f; + paf3_bw_ = 10.f + p[5] * 2000.f; + paf0_vib_ = p[18] * p[18] * 0.05f; + paf1_vib_ = paf0_vib_ * 2.f; + paf2_vib_ = p[28] * 0.1f; + paf3_vib_ = p[29] * 0.1f; + paf0_vfr_ = p[19] * p[19] * 15.f; paf1_vfr_ = paf0_vfr_; + paf2_vfr_ = 0.f; paf3_vfr_ = 0.f; + paf0_shift_ = 0.f; paf1_shift_ = 0.f; + paf2_shift_ = -100.f + p[8] * 200.f; + paf3_shift_ = -150.f + p[9] * 300.f; + dl1_mix_ = p[20] * p[20] * 0.1f; + dl_fb_ = p[21] * p[21] * 0.7f; + env_.setup(1.f + p[10] * 20.f, 1.f + p[11] * 200.f, + p[12] * 0.4f, 10.f + p[13] * 300.f, sample_rate_); + sine_shape_gain_ = p[14] * p[14] * 0.9f; + sine_shape_asym_ = p[15] * 0.5f; + sine_shape_mix_ = p[16] * 0.8f; + rm_gain_ = p[22] * p[22] * 0.7f; + feedback_gain_ = p[23] * p[23] * 0.4f; + delay_max_ = 4000u; + fb_smooth_alpha_ = 0.9f; + } + // QuadDist — Ipeleiades + void apply_quad_dist(const std::array& p) noexcept { + p0_gain_ = 1.f; p1_gain_ = p[24]; p2_gain_ = p[25]; p3_gain_ = p[26]; + const float factor = 1.f + (p[17] + p[27] * 0.6f); + detune1_ = (1.f * factor) * 0.5f; + detune2_ = (1.f * factor * factor) * 2.f; + detune3_ = (detune2_ * factor) * 2.f; + paf0_cf_ = p[0] * 4.f; paf1_cf_ = p[0] * 4.f; + paf2_cf_ = p[1] * 8.f; paf3_cf_ = p[1] * 8.f; + paf0_bw_ = 10.f + p[2] * 5000.f; + paf1_bw_ = 10.f + p[3] * 5000.f; + paf2_bw_ = 10.f + p[4] * 5000.f; + paf3_bw_ = 10.f + p[5] * 2000.f; + paf0_vib_ = p[18] * p[18] * 0.05f; + paf1_vib_ = paf0_vib_ * 2.f; + paf2_vib_ = p[28] * 0.1f; + paf3_vib_ = p[29] * 0.1f; + paf0_vfr_ = p[19] * 15.f; + paf1_vfr_ = p[28] * 15.f; + paf2_vfr_ = p[29] * 15.f; + paf3_vfr_ = p[30] * 15.f; + paf0_shift_ = 0.f; + paf1_shift_ = -800.f + p[27] * 600.f; + paf2_shift_ = -300.f + p[8] * 600.f; + paf3_shift_ = -350.f + p[9] * 100.f; + dl1_mix_ = p[20] * p[20] * 0.1f; + dl_fb_ = p[21] * p[21] * 0.7f; + env_.setup(1.f + p[10] * 20.f, 1.f + p[11] * 200.f, + p[12] * 0.4f, 10.f + p[13] * 300.f, sample_rate_); + sine_shape_gain_ = p[14] * p[14] * 0.9f; + sine_shape_asym_ = p[15] * 0.5f; + sine_shape_mix_ = p[16] * 0.8f; + rm_gain_ = p[22] * p[22] * 0.99f; + feedback_gain_ = p[23] * p[23] * 0.7f; + delay_max_ = 10000u; + fb_smooth_alpha_ = 0.9f; + } + + float sample_rate_ = 48000.f; + + PAFOperator paf0_, paf1_, paf2_, paf3_; + Delay<11000> delay_; + ADSR env_; + + VoiceSpace voice_space_ = VoiceSpace::Ellipticacacia; + + // Engine state set by voice spaces. + float p0_gain_ = 1.f, p1_gain_ = 1.f, p2_gain_ = 1.f, p3_gain_ = 1.f; + float paf0_cf_ = 200.f, paf1_cf_ = 250.f, paf2_cf_ = 250.f, paf3_cf_ = 250.f; + float paf0_bw_ = 100.f, paf1_bw_ = 5000.f, paf2_bw_ = 5000.f, paf3_bw_ = 5000.f; + float paf0_vib_ = 0.f, paf1_vib_ = 1.f, paf2_vib_ = 1.f, paf3_vib_ = 1.f; + float paf0_vfr_ = 2.f, paf1_vfr_ = 2.f, paf2_vfr_ = 2.f, paf3_vfr_ = 2.f; + float paf0_shift_ = 0.f, paf1_shift_ = 0.f, paf2_shift_ = 0.f, paf3_shift_ = 0.f; + float detune1_ = 1.f, detune2_ = 1.f, detune3_ = 1.f; + float dl1_mix_ = 0.f, dl_fb_ = 0.5f; + float rm_gain_ = 0.f; + float sine_shape_gain_ = 0.1f, sine_shape_asym_ = 0.f, sine_shape_mix_ = 0.f; + float feedback_gain_ = 0.f; + float fb_smooth_alpha_ = 0.95f; + std::size_t delay_max_ = 10u; + + // Per-process state. + float feedback_ = 0.f; + float fbzm1_ = 0.f; + float base_freq_ = 50.f; + std::uint8_t current_note_ = 60u; +}; + +static_assert(AudioEngine, "PAFSynthEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/verb_fx.hpp b/nisps/engines/verb_fx.hpp new file mode 100644 index 0000000..45260bb --- /dev/null +++ b/nisps/engines/verb_fx.hpp @@ -0,0 +1,512 @@ +// nisps/engines/verb_fx.hpp — 47-param reverb/delay/filterbank effects engine. +// +// Mirrors firmware VerbFXAudioApp. Pipeline: +// - Filterbank (8× SVF bandpass) injects mid into delay/verb paths. +// - 3 dynamic delay lines (long/medium/short) with configurable feedback. +// - 8× LP-comb feedback bank + 4× allpass = Freeverb-style reverb tail. +// - Cross-fade between delay sum and verb output via verbVsDelayLevel. +// - Wet/dry mix. +// +// Voice-space lambdas are stored as function pointers and dispatched in +// `set_params()`. This adds one indirection per non-RT param update — the +// audio path itself is unaffected. Keeps each voice-space body in its own +// (private static) function for readability and lets the compiler inline. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/delay.hpp" +#include "../dsp/filter.hpp" +#include "../dsp/reverb.hpp" + +namespace nisps { + +class VerbFXEngine { + public: + static constexpr std::size_t kNParams = 47u; + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "verb_fx"; } + + enum class VoiceSpace : std::size_t { + Default = 0, + Resonant = 1, + Soft = 2, + Cathedral = 3, + Shimmer = 4, + Chamber = 5, + Metallic = 6, + Granular = 7, + Diffuse = 8, + Dark = 9, + Bright = 10, + Harmonic = 11, + Count = 12, + }; + static constexpr std::size_t kVoiceSpaceCount = static_cast(VoiceSpace::Count); + static constexpr std::array kVoiceSpaceNames = { + "Default", "Resonant", "Soft", "Cathedral", "Shimmer", "Chamber", + "Metallic", "Granular", "Diffuse", "Dark", "Bright", "Harmonic"}; + + void set_voice_space(VoiceSpace vs) noexcept { voice_space_ = vs; } + VoiceSpace voice_space() const noexcept { return voice_space_; } + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + smoother_.setup(150.f, sample_rate); + for (auto& v : nn_outputs_) v = 0.f; + for (auto& v : smooth_params_) v = 0.f; + for (auto* fb : {&fb0_, &fb1_, &fb2_, &fb3_, &fb4_, &fb5_, &fb6_, &fb7_}) { + fb->setup(sample_rate); + } + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + for (std::size_t i = 0u; i < kNParams; ++i) nn_outputs_[i] = params[i]; + } + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept { + smoother_.process(nn_outputs_.data(), smooth_params_.data()); + apply_voice_space(); + + const float mix = x.L + x.R; + + // Cross-fade levels between filterbank and delay-feedback into the bank. + const float fb_xfade_a = std::sqrt(filter_bank_delay_xfade_); + const float fb_xfade_inv = std::sqrt(1.f - filter_bank_delay_xfade_); + + // FILTERBANK + const float fb_in = mix + (fb_xfade_a * ddelay_feedback_); + float fb_out; + if (enable_filterbank_) { + fb_out = fb0_.bandpass(fb_in, fb_freqs_[0], fb_res_[0]); + fb_out += fb1_.bandpass(fb_in, fb_freqs_[1], fb_res_[1]); + fb_out += fb2_.bandpass(fb_in, fb_freqs_[2], fb_res_[2]); + fb_out += fb3_.bandpass(fb_in, fb_freqs_[3], fb_res_[3]); + fb_out += fb4_.bandpass(fb_in, fb_freqs_[4], fb_res_[4]); + fb_out += fb5_.bandpass(fb_in, fb_freqs_[5], fb_res_[5]); + fb_out += fb6_.bandpass(fb_in, fb_freqs_[6], fb_res_[6]); + fb_out += fb7_.bandpass(fb_in, fb_freqs_[7], fb_res_[7]); + fb_out *= 0.125f; + } else { + fb_out = mix; + } + + // DELAYS + const float delay_in = fb_out; + const float d_long = enable_long_delay_ ? ddelay_long_.read(ddelay_time_) : 0.f; + ddelay_long_.write((delay_in * fb_xfade_inv) + + ((ddelay_feedback_ + (delay_in * fb_xfade_a)) * d_long)); + + const float d_med = enable_medium_delay_ ? ddelay_med_.read(ddelay_time1_) : 0.f; + ddelay_med_.write(delay_in + (ddelay_feedback1_ * d_med)); + + const float d_short = enable_short_delay_ ? ddelay_short_.read(ddelay_time2_) : 0.f; + ddelay_short_.write(delay_in + (ddelay_feedback2_ * d_short)); + + // Crossfade morph (constant-power-ish blend between three lanes). + const float a = std::min(delay_morph_ * 2.f, 1.f); + const float b = std::max(delay_morph_ * 2.f - 1.f, 0.f); + static const float kEqualMix = 0.57735f; // 1/sqrt(3) + const float w_short = kEqualMix + delay_blend_ * (std::sqrt(1.f - a) - kEqualMix); + const float w_med = kEqualMix + delay_blend_ * (std::sqrt(a) * std::sqrt(1.f - b) - kEqualMix); + const float w_long = kEqualMix + delay_blend_ * (std::sqrt(a) * std::sqrt(b) - kEqualMix); + const float delay_sum = (w_short * d_short) + (w_med * d_med) + (w_long * d_long); + + // VERB (Freeverb-style: 8 lp-comb in parallel, then 4 allpass in series) + // Note: firmware feeds `filterBankOut` straight into the verb regardless + // of `enableReverb` (the gating only controls the verbIn variable, which + // is then unused). Keep that exact behaviour for sonic parity. + float verb_out = 0.f; + verb_out = lpcomb0_.process(fb_out, kSizeComb0, lp_fb_[0], lp_cutoff_[0]); + verb_out += lpcomb1_.process(fb_out, kSizeComb1, lp_fb_[1], lp_cutoff_[1]); + verb_out += lpcomb2_.process(fb_out, kSizeComb2, lp_fb_[2], lp_cutoff_[2]); + verb_out += lpcomb3_.process(fb_out, kSizeComb3, lp_fb_[3], lp_cutoff_[3]); + verb_out += lpcomb4_.process(fb_out, kSizeComb4, lp_fb_[4], lp_cutoff_[4]); + verb_out += lpcomb5_.process(fb_out, kSizeComb5, lp_fb_[5], lp_cutoff_[5]); + verb_out += lpcomb6_.process(fb_out, kSizeComb6, lp_fb_[6], lp_cutoff_[6]); + verb_out += lpcomb7_.process(fb_out, kSizeComb7, lp_fb_[7], lp_cutoff_[7]); + + verb_out = allp0_.process(verb_out, kSizeAllP0, allp_fb_[0]); + verb_out = allp1_.process(verb_out, kSizeAllP1, allp_fb_[1]); + verb_out = allp2_.process(verb_out, kSizeAllP2, allp_fb_[2]); + verb_out = allp3_.process(verb_out, kSizeAllP3, allp_fb_[3]); + + // Cross-fade verb vs delay + float y = (std::sqrt(verb_vs_delay_) * delay_sum) + + (std::sqrt(1.f - verb_vs_delay_) * verb_out); + + // Wet/dry + y = (y * std::sqrt(wet_dry_)) + (mix * std::sqrt(1.f - wet_dry_)); + return {y, y}; + } + + DriverConfig driver_config() const noexcept { + DriverConfig c; + c.line_level = 6u; + c.output_volume = 0.9f; + return c; + } + + void set_enable_filterbank(bool v) noexcept { enable_filterbank_ = v; } + void set_enable_reverb(bool v) noexcept { enable_reverb_ = v; } + void set_enable_short_delay(bool v) noexcept { enable_short_delay_ = v; } + void set_enable_medium_delay(bool v) noexcept { enable_medium_delay_ = v; } + void set_enable_long_delay(bool v) noexcept { enable_long_delay_ = v; } + void set_enable_delay_to_reverb(bool v) noexcept { enable_delay_to_reverb_ = v; } + void set_wet_dry_override(float v) noexcept { wet_dry_ = v; } + + private: + static constexpr std::size_t kSizeAllP0 = 244u; + static constexpr std::size_t kSizeAllP1 = 605u; + static constexpr std::size_t kSizeAllP2 = 479u; + static constexpr std::size_t kSizeAllP3 = 371u; + + static constexpr std::size_t kSizeComb0 = 1694u; + static constexpr std::size_t kSizeComb1 = 1759u; + static constexpr std::size_t kSizeComb2 = 1622u; + static constexpr std::size_t kSizeComb3 = 1547u; + static constexpr std::size_t kSizeComb4 = 1379u; + static constexpr std::size_t kSizeComb5 = 1464u; + static constexpr std::size_t kSizeComb6 = 1283u; + static constexpr std::size_t kSizeComb7 = 1205u; + + NISPS_FORCE_INLINE void apply_voice_space() noexcept { + const float* p = smooth_params_.data(); + switch (voice_space_) { + case VoiceSpace::Default: apply_default(p); break; + case VoiceSpace::Resonant: apply_resonant(p); break; + case VoiceSpace::Soft: apply_soft(p); break; + case VoiceSpace::Cathedral: apply_cathedral(p); break; + case VoiceSpace::Shimmer: apply_shimmer(p); break; + case VoiceSpace::Chamber: apply_chamber(p); break; + case VoiceSpace::Metallic: apply_metallic(p); break; + case VoiceSpace::Granular: apply_granular(p); break; + case VoiceSpace::Diffuse: apply_diffuse(p); break; + case VoiceSpace::Dark: apply_dark(p); break; + case VoiceSpace::Bright: apply_bright(p); break; + case VoiceSpace::Harmonic: apply_harmonic(p); break; + case VoiceSpace::Count: break; + } + } + + // Helpers that compute filterbank freqs as octaves-of-40Hz (the dominant + // pattern across Default/Resonant/Soft/Cathedral/Shimmer/Chamber). + NISPS_FORCE_INLINE void filterbank_octaves_default(const float* p) noexcept { + fb_freqs_[0] = 40.f + p[21] * 40.f; + fb_freqs_[1] = 80.f + p[22] * 80.f; + fb_freqs_[2] = 160.f + p[23] * 160.f; + fb_freqs_[3] = 320.f + p[24] * 320.f; + fb_freqs_[4] = 640.f + p[25] * 640.f; + fb_freqs_[5] = 1280.f + p[26] * 1280.f; + fb_freqs_[6] = 2560.f + p[27] * 2560.f; + fb_freqs_[7] = 5120.f + p[28] * 5120.f; + } + NISPS_FORCE_INLINE void filterbank_res_linear(const float* p, float scale) noexcept { + for (std::size_t i = 0u; i < 8u; ++i) fb_res_[i] = 1.f + p[29 + i] * scale; + } + NISPS_FORCE_INLINE void filterbank_res_squared(const float* p, float scale) noexcept { + for (std::size_t i = 0u; i < 8u; ++i) fb_res_[i] = 1.f + (p[29 + i] * p[29 + i]) * scale; + } + NISPS_FORCE_INLINE void filterbank_res_sqrt(const float* p, float scale) noexcept { + for (std::size_t i = 0u; i < 8u; ++i) fb_res_[i] = 1.f + std::sqrt(p[29 + i]) * scale; + } + NISPS_FORCE_INLINE void common_delays_linear(const float* p) noexcept { + ddelay_time_ = 10.f + p[37] * 16373.f; + ddelay_feedback_ = p[38] * 0.98f; + ddelay_time1_ = 10.f + p[39] * 2037.f; + ddelay_feedback1_ = p[40] * 0.98f; + ddelay_time2_ = 10.f + p[41] * 501.f; + ddelay_feedback2_ = p[42] * 0.98f; + } + + void apply_default(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * (i == 5 ? 0.98f : 0.9f); + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = p[17 + i] * 0.9f; + filterbank_octaves_default(p); + filterbank_res_linear(p, 19.f); + common_delays_linear(p); + verb_vs_delay_ = p[43]; + delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; + delay_blend_ = p[46]; + } + + void apply_resonant(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * 0.99f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = p[17 + i] * 0.99f; + filterbank_octaves_default(p); + filterbank_res_sqrt(p, 25.f); + ddelay_time_ = 10.f + p[37] * 16373.f; + ddelay_feedback_ = p[38] * 0.99f; + ddelay_time1_ = 10.f + p[39] * 2037.f; + ddelay_feedback1_ = p[40] * 0.99f; + ddelay_time2_ = 10.f + p[41] * 501.f; + ddelay_feedback2_ = p[42] * 0.99f; + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_soft(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + const float v = p[1 + 2 * i]; + lp_fb_[i] = v * v * (i == 5 ? 0.98f : 0.9f); + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) { + const float v = p[17 + i]; + allp_fb_[i] = v * v * 0.9f; + } + filterbank_octaves_default(p); + filterbank_res_squared(p, 19.f); + ddelay_time_ = 10.f + p[37] * 16373.f; + ddelay_feedback_ = p[38] * p[38] * 0.98f; + ddelay_time1_ = 10.f + p[39] * 2037.f; + ddelay_feedback1_ = p[40] * p[40] * 0.98f; + ddelay_time2_ = 10.f + p[41] * 501.f; + ddelay_feedback2_ = p[42] * p[42] * 0.98f; + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_cathedral(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = std::sqrt(p[1 + 2 * i]) * 0.98f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.3f + 0.02f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = std::sqrt(p[17 + i]) * 0.9f; + filterbank_octaves_default(p); + filterbank_res_linear(p, 19.f); + ddelay_time_ = 10.f + std::sqrt(p[37]) * 16373.f; + ddelay_feedback_ = std::sqrt(p[38]) * 0.98f; + ddelay_time1_ = 10.f + std::sqrt(p[39]) * 2037.f; + ddelay_feedback1_ = std::sqrt(p[40]) * 0.98f; + ddelay_time2_ = 10.f + std::sqrt(p[41]) * 501.f; + ddelay_feedback2_ = std::sqrt(p[42]) * 0.98f; + verb_vs_delay_ = p[43] * p[43]; + delay_to_verb_ = std::sqrt(p[44]) * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_shimmer(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = std::sqrt(p[1 + 2 * i]) * 0.98f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.4f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = std::sqrt(p[17 + i]) * 0.99f; + filterbank_octaves_default(p); + filterbank_res_sqrt(p, 19.f); + ddelay_time_ = 10.f + p[37] * 16373.f; + ddelay_feedback_ = std::sqrt(p[38]) * 0.95f; + ddelay_time1_ = 10.f + p[39] * 2037.f; + ddelay_feedback1_ = std::sqrt(p[40]) * 0.95f; + ddelay_time2_ = 10.f + p[41] * 501.f; + ddelay_feedback2_ = std::sqrt(p[42]) * 0.95f; + verb_vs_delay_ = p[43] * p[43]; + delay_to_verb_ = std::sqrt(p[44]) * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_chamber(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + const float v = p[1 + 2 * i]; + lp_fb_[i] = v * v * 0.9f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.1f; + } + for (std::size_t i = 0u; i < 4u; ++i) { + const float v = p[17 + i]; + allp_fb_[i] = v * v * 0.9f; + } + filterbank_octaves_default(p); + filterbank_res_squared(p, 19.f); + ddelay_time_ = 10.f + p[37] * p[37] * 16373.f; + ddelay_feedback_ = p[38] * p[38] * 0.98f; + ddelay_time1_ = 10.f + p[39] * p[39] * 2037.f; + ddelay_feedback1_ = p[40] * p[40] * 0.98f; + ddelay_time2_ = 10.f + p[41] * p[41] * 501.f; + ddelay_feedback2_ = p[42] * p[42] * 0.98f; + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_metallic(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * 0.9f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = std::sqrt(p[17 + i]) * 0.95f; + filterbank_octaves_default(p); + // Alternating sqrt/squared res — metallic peaky character. + for (std::size_t i = 0u; i < 8u; ++i) { + const float v = p[29 + i]; + fb_res_[i] = (i % 2u == 0u) ? (1.f + std::sqrt(v) * 25.f) + : (1.f + v * v * 19.f); + } + common_delays_linear(p); + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_granular(const float* p) noexcept { + // Same shape as Soft, with sqrt on a couple of late params. + apply_soft(p); + ddelay_feedback2_ = std::sqrt(p[42]) * 0.98f; + verb_vs_delay_ = std::sqrt(p[43]); + delay_morph_ = p[45] * p[45]; + delay_blend_ = std::sqrt(p[46]); + } + + void apply_diffuse(const float* p) noexcept { + filter_bank_delay_xfade_ = std::sqrt(p[0]); + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * 0.9f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = std::sqrt(p[17 + i]) * 0.95f; + filterbank_octaves_default(p); + filterbank_res_squared(p, 19.f); + ddelay_time_ = 10.f + p[37] * 16373.f; + ddelay_feedback_ = std::sqrt(p[38]) * 0.98f; + ddelay_time1_ = 10.f + p[39] * 2037.f; + ddelay_feedback1_ = std::sqrt(p[40]) * 0.98f; + ddelay_time2_ = 10.f + p[41] * 501.f; + ddelay_feedback2_ = std::sqrt(p[42]) * 0.98f; + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_dark(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * 0.9f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.3f + 0.02f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = p[17 + i] * 0.9f; + // Squared filterbank freqs — pulls them lower on average. + const float bases[8] = {40.f, 80.f, 160.f, 320.f, 640.f, 1280.f, 2560.f, 5120.f}; + for (std::size_t i = 0u; i < 8u; ++i) { + const float pp = p[21 + i]; + fb_freqs_[i] = bases[i] + (pp * pp) * bases[i]; + } + // Mixed: first half sqrt, second half squared. + for (std::size_t i = 0u; i < 8u; ++i) { + const float v = p[29 + i]; + fb_res_[i] = (i < 4u) ? (1.f + std::sqrt(v) * 19.f) + : (1.f + v * v * 19.f); + } + common_delays_linear(p); + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_bright(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * 0.9f; + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.1f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = p[17 + i] * 0.9f; + const float bases[8] = {40.f, 80.f, 160.f, 320.f, 640.f, 1280.f, 2560.f, 5120.f}; + for (std::size_t i = 0u; i < 8u; ++i) { + fb_freqs_[i] = bases[i] + std::sqrt(p[21 + i]) * bases[i]; + } + for (std::size_t i = 0u; i < 8u; ++i) { + const float v = p[29 + i]; + fb_res_[i] = (i < 4u) ? (1.f + v * v * 19.f) + : (1.f + std::sqrt(v) * 25.f); + } + common_delays_linear(p); + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + void apply_harmonic(const float* p) noexcept { + filter_bank_delay_xfade_ = p[0]; + for (std::size_t i = 0u; i < 8u; ++i) { + lp_fb_[i] = p[1 + 2 * i] * (i == 5 ? 0.98f : 0.9f); + lp_cutoff_[i] = p[2 + 2 * i] * 0.5f + 0.05f; + } + for (std::size_t i = 0u; i < 4u; ++i) allp_fb_[i] = p[17 + i] * 0.9f; + // Harmonic series-ish base freqs (every ~100 Hz). + const float harm_bases[8] = {80.f, 180.f, 280.f, 380.f, 480.f, 580.f, 680.f, 780.f}; + for (std::size_t i = 0u; i < 8u; ++i) fb_freqs_[i] = harm_bases[i] + p[21 + i] * 40.f; + filterbank_res_sqrt(p, 19.f); + common_delays_linear(p); + verb_vs_delay_ = p[43]; delay_to_verb_ = p[44] * 0.99f; + delay_morph_ = p[45]; delay_blend_ = p[46]; + } + + float sample_rate_ = 48000.f; + + OnePoleSmoother smoother_; + std::array nn_outputs_{}; + std::array smooth_params_{}; + + AllPass allp0_; + AllPass allp1_; + AllPass allp2_; + AllPass allp3_; + + LpComb lpcomb0_; + LpComb lpcomb1_; + LpComb lpcomb2_; + LpComb lpcomb3_; + LpComb lpcomb4_; + LpComb lpcomb5_; + LpComb lpcomb6_; + LpComb lpcomb7_; + + ChamberlinSVF fb0_, fb1_, fb2_, fb3_, fb4_, fb5_, fb6_, fb7_; + + DynamicDelay<16384> ddelay_long_; + DynamicDelay<2048> ddelay_med_; + DynamicDelay<512> ddelay_short_; + + // Voice-space outputs. + float lp_fb_[8]{}; + float lp_cutoff_[8]{}; + float allp_fb_[4]{}; + float fb_freqs_[8]{}; + float fb_res_[8]{}; + float ddelay_time_ = 0.f, ddelay_feedback_ = 0.f; + float ddelay_time1_ = 0.f, ddelay_feedback1_ = 0.f; + float ddelay_time2_ = 0.f, ddelay_feedback2_ = 0.f; + float verb_vs_delay_ = 0.f, delay_to_verb_ = 0.f; + float delay_morph_ = 0.5f, delay_blend_ = 0.f; + float filter_bank_delay_xfade_ = 0.f; + float wet_dry_ = 0.5f; + + bool enable_filterbank_ = true; + bool enable_reverb_ = true; + bool enable_short_delay_ = true; + bool enable_medium_delay_ = true; + bool enable_long_delay_ = true; + bool enable_delay_to_reverb_ = true; + + VoiceSpace voice_space_ = VoiceSpace::Default; +}; + +static_assert(AudioEngine, "VerbFXEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/nisps/engines/xiasri.hpp b/nisps/engines/xiasri.hpp new file mode 100644 index 0000000..788b5e0 --- /dev/null +++ b/nisps/engines/xiasri.hpp @@ -0,0 +1,160 @@ +// nisps/engines/xiasri.hpp — Reverb + delays + pitch-shift FX engine. +// +// Mirrors firmware XIASRIAudioApp. Direct (no voice space) NN→param mapping — +// the firmware ignored its voice-space slot and read smoothParams[] inline. +// We expose a single "Direct" voice space for schema parity. +// +// Pipeline (per sample): +// pitch_shift → DC-blocker → 6-allpass + 2-comb reverb tail → 4 parallel +// delays → wet/dry mix → tanh → mono out +// +// All parameters are smoothed via a OnePoleSmoother before they reach the +// audio path so that ML output discontinuities don't audibly click. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../dsp/dc_blocker.hpp" +#include "../dsp/delay.hpp" +#include "../dsp/filter.hpp" +#include "../dsp/pitch_shift.hpp" +#include "../dsp/reverb.hpp" + +namespace nisps { + +class XIASRIEngine { + public: + static constexpr std::size_t kNParams = 24u; + static constexpr std::size_t param_count() noexcept { return kNParams; } + static constexpr std::string_view engine_id() noexcept { return "xiasri"; } + + enum class VoiceSpace : std::size_t { Direct = 0, Count = 1 }; + static constexpr std::size_t kVoiceSpaceCount = 1u; + static constexpr std::array kVoiceSpaceNames = {"Direct"}; + + void set_voice_space(VoiceSpace) noexcept {} // no-op for parity + VoiceSpace voice_space() const noexcept { return VoiceSpace::Direct; } + + void setup(float sample_rate) noexcept { + sample_rate_ = sample_rate; + smoother_.setup(150.f, sample_rate); + pitch_shifter_.init(sample_rate); + for (auto& v : nn_outputs_) v = 0.f; + for (auto& v : smooth_params_) v = 0.f; + } + + void set_params(std::span params) noexcept { + if (params.size() < kNParams) return; + // Direct copy — actual parameter mapping is per-sample in process(). + for (std::size_t i = 0u; i < kNParams; ++i) nn_outputs_[i] = params[i]; + } + + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept { + smoother_.process(nn_outputs_.data(), smooth_params_.data()); + + const float dl1mix = smooth_params_[0] * 0.4f; + const float dl2mix = smooth_params_[1] * 0.4f; + const float dl3mix = smooth_params_[2] * 0.8f; + const float dl4mix = smooth_params_[22] * smooth_params_[22] * 0.41f; + + const float allp1fb = smooth_params_[4] * 0.99f; + const float allp2fb = smooth_params_[5] * 0.99f; + const float comb1fb = smooth_params_[6] * 0.95f; + const float comb2fb = smooth_params_[7] * 0.95f; + + const float dl1fb = smooth_params_[8] * 0.95f; + const float dl2fb = smooth_params_[9] * 0.95f; + const float dl3fb = smooth_params_[10] * 0.95f; + const float dl4fb = smooth_params_[23] * 0.95f; + + const float wet = (smooth_params_[11] * 0.7f) + 0.3f; + // Keep firmware's curious +12 semitone offset for parity. + pitch_shifter_.set_transposition(12.f + smooth_params_[12]); + const float ps_mix = smooth_params_[13] * 0.99f; + + const float allp3fb = smooth_params_[14] * 0.99f; + const float allp4fb = smooth_params_[15] * 0.99f; + const float allp5fb = smooth_params_[16] * 0.99f; + const float allp6fb = smooth_params_[17] * 0.99f; + const float allp3mix = smooth_params_[18]; + const float allp4mix = smooth_params_[19]; + const float allp5mix = smooth_params_[20]; + const float allp6mix = smooth_params_[21]; + + // Stereo collapsed to mono pre-FX, scaled by 2. + float mix = (x.L + x.R) * 2.f; + + float ps = pitch_shifter_.process(mix); + ps = (mix * (1.f - ps_mix)) + (ps * ps_mix); + + float y = dcb_.play(ps, 0.99f) * 2.f; + + float y1 = allp1_.process(y, 30u, allp1fb); + y1 = comb1_.process(y1, 127u, comb1fb); + + float y2 = allp2_.process(y, 482u, allp2fb); + y2 = comb2_.process(y2, 808u, comb2fb); + + const float y3 = allp3_.process(y, 19u, allp3fb) * allp3mix; + const float y4 = allp4_.process(y, 69u, allp4fb) * allp4mix; + const float y5 = allp5_.process(y, 131u, allp5fb) * allp5mix; + const float y6 = allp6_.process(y, 287u, allp6fb) * allp6mix; + + y = y1 + y2 + y3 + y4 + y5 + y6; + + const float d1 = dl1_.play(y, 3500u, dl1fb) * dl1mix; + const float d2 = dl2_.play(y, 7886u, dl2fb) * dl2mix; + const float d3 = dl3_.play(y, 299u, dl3fb) * dl3mix; + // d4 captured but not summed in firmware code path; preserve semantics. + (void)dl4_.play(y, 15873u, dl4fb); + (void)dl4mix; + + y = y + d1 + d2 + d3; + y = (y * wet) + (mix * (1.f - wet)); + y = std::tanh(y * 1.2f); + return {y, y}; + } + + DriverConfig driver_config() const noexcept { + DriverConfig c; + c.line_level = 6u; + c.output_volume = 0.9f; + return c; + } + + private: + float sample_rate_ = 48000.f; + + PitchShifter<8192> pitch_shifter_; + DCBlocker dcb_; + + Delay<5000> dl1_; + Delay<8000> dl2_; + Delay<1201> dl3_; + Delay<16000> dl4_; + + AllPass<300> allp1_; + AllPass<500> allp2_; + AllPass<500> allp3_; + AllPass<500> allp4_; + AllPass<500> allp5_; + AllPass<500> allp6_; + Comb<200> comb1_; + Comb<900> comb2_; + + OnePoleSmoother smoother_; + std::array nn_outputs_{}; + std::array smooth_params_{}; +}; + +static_assert(AudioEngine, "XIASRIEngine must satisfy AudioEngine"); + +} // namespace nisps diff --git a/tests/cpp/test_engine_analysis.cpp b/tests/cpp/test_engine_analysis.cpp new file mode 100644 index 0000000..ed09b50 --- /dev/null +++ b/tests/cpp/test_engine_analysis.cpp @@ -0,0 +1,37 @@ +// tests/cpp/test_engine_analysis.cpp — XiasriAnalysis port smoke test. + +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/analysis.hpp" + +NISPS_TEST(analysis_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::AnalysisEngine::param_count() == 0u); + NISPS_EXPECT(nisps::AnalysisEngine::engine_id() == "analysis"); +} + +NISPS_TEST(analysis_features_in_range) { + nisps::AnalysisEngine e; + e.setup(48000.f); + // Feed a 220 Hz sine. + for (int n = 0; n < 48000; ++n) { + const float x = std::sin(2.f * 3.14159265f * 220.f * n / 48000.f) * 0.5f; + e.process({x, x}); + } + const auto& f = e.features(); + NISPS_EXPECT(f.pitch >= 0.f && f.pitch <= 1.f); + NISPS_EXPECT(f.aperiodicity >= 0.f && f.aperiodicity <= 1.f); + NISPS_EXPECT(f.energy >= 0.f && f.energy <= 1.f); + NISPS_EXPECT(f.attack >= 0.f && f.attack <= 1.f); + NISPS_EXPECT(f.brightness >= 0.f && f.brightness <= 1.f); +} + +NISPS_TEST(analysis_silence_gives_low_energy) { + nisps::AnalysisEngine e; + e.setup(48000.f); + for (int n = 0; n < 48000; ++n) e.process({0.f, 0.f}); + const auto& f = e.features(); + NISPS_EXPECT(f.energy < 0.1f); // log envelope clamps to ~0 + NISPS_EXPECT(f.energy_crude == 0.f); +} diff --git a/tests/cpp/test_engine_breakor.cpp b/tests/cpp/test_engine_breakor.cpp new file mode 100644 index 0000000..3fc4a26 --- /dev/null +++ b/tests/cpp/test_engine_breakor.cpp @@ -0,0 +1,46 @@ +// tests/cpp/test_engine_breakor.cpp — sequencer-only engine. + +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/breakor.hpp" + +NISPS_TEST(breakor_concept_and_silent_audio) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::BreakOrEngine::param_count() == 56u); + NISPS_EXPECT(nisps::BreakOrEngine::engine_id() == "breakor"); + + nisps::BreakOrEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.5f; + e.set_params(p); + + // Audio output should be all zeros. + for (int n = 0; n < 1000; ++n) { + const auto y = e.process({0.f, 0.f}); + NISPS_EXPECT(y.L == 0.f); + NISPS_EXPECT(y.R == 0.f); + } +} + +NISPS_TEST(breakor_emits_events_when_playing) { + nisps::BreakOrEngine e; + e.setup(48000.f); + std::array p; + // Choose params that will trigger (ratios drive the sequencer). + for (std::size_t i = 0u; i < 56u; ++i) p[i] = 0.5f; + e.set_params(p); + e.update_bpm(120.f); + e.set_playing(true); + + std::array events; + std::size_t total = 0u; + for (int n = 0; n < 48000 * 4; ++n) { // 4 seconds + e.process({0.f, 0.f}); + const std::size_t got = e.pop_events(events); + total += got; + if (total > 5u) break; + } + NISPS_EXPECT(total > 0u); +} diff --git a/tests/cpp/test_engine_channel_strip.cpp b/tests/cpp/test_engine_channel_strip.cpp new file mode 100644 index 0000000..d1f9bd1 --- /dev/null +++ b/tests/cpp/test_engine_channel_strip.cpp @@ -0,0 +1,39 @@ +// tests/cpp/test_engine_channel_strip.cpp — channel strip smoke test. + +#include +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/channel_strip.hpp" + +NISPS_TEST(channel_strip_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::ChannelStripEngine::param_count() == 24u); + NISPS_EXPECT(nisps::ChannelStripEngine::engine_id() == "channel_strip"); +} + +NISPS_TEST(channel_strip_bypass_passthrough) { + nisps::ChannelStripEngine e; + e.setup(48000.f); + e.set_bypass_all(true); + const auto out = e.process({0.7f, -0.3f}); + NISPS_EXPECT_NEAR(out.L, 0.7f, 1e-6); + NISPS_EXPECT_NEAR(out.R, -0.3f, 1e-6); +} + +NISPS_TEST(channel_strip_finite_output_each_voice_space) { + nisps::ChannelStripEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.5f; + for (std::size_t vs = 0u; vs < nisps::ChannelStripEngine::kVoiceSpaceCount; ++vs) { + e.set_voice_space(static_cast(vs)); + e.set_params(p); + for (int n = 0; n < 1000; ++n) { + const float x = std::sin(static_cast(n) * 0.05f) * 0.3f; + const auto y = e.process({x, x}); + NISPS_EXPECT(std::isfinite(y.L)); + NISPS_EXPECT(std::isfinite(y.R)); + } + } +} diff --git a/tests/cpp/test_engine_elysiamorf.cpp b/tests/cpp/test_engine_elysiamorf.cpp new file mode 100644 index 0000000..c352851 --- /dev/null +++ b/tests/cpp/test_engine_elysiamorf.cpp @@ -0,0 +1,33 @@ +// tests/cpp/test_engine_elysiamorf.cpp — sequencer-only FM-CC emitter. + +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/elysiamorf.hpp" + +NISPS_TEST(elysiamorf_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::ElysiamorfEngine::param_count() == 40u); + NISPS_EXPECT(nisps::ElysiamorfEngine::engine_id() == "elysiamorf"); +} + +NISPS_TEST(elysiamorf_silent_audio_with_events) { + nisps::ElysiamorfEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.5f; + e.set_params(p); + e.update_bpm(120.f); + e.set_playing(true); + + std::array events; + std::size_t total = 0u; + for (int n = 0; n < 48000; ++n) { + const auto y = e.process({0.f, 0.f}); + NISPS_EXPECT(y.L == 0.f); + NISPS_EXPECT(y.R == 0.f); + total += e.pop_events(events); + if (total > 8u) break; + } + NISPS_EXPECT(total > 0u); +} diff --git a/tests/cpp/test_engine_memlcelium.cpp b/tests/cpp/test_engine_memlcelium.cpp new file mode 100644 index 0000000..bcd93fb --- /dev/null +++ b/tests/cpp/test_engine_memlcelium.cpp @@ -0,0 +1,26 @@ +// tests/cpp/test_engine_memlcelium.cpp — MEMLCelium smoke test. + +#include +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/memlcelium.hpp" + +NISPS_TEST(memlcelium_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::MEMLCeliumEngine::param_count() == 56u); + NISPS_EXPECT(nisps::MEMLCeliumEngine::engine_id() == "memlcelium"); +} + +NISPS_TEST(memlcelium_runs) { + nisps::MEMLCeliumEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.5f; + e.set_params(p); + for (int n = 0; n < 2400; ++n) { + const auto y = e.process({0.f, 0.f}); + NISPS_EXPECT(std::isfinite(y.L)); + NISPS_EXPECT(std::fabs(y.L) < 5.f); + } +} diff --git a/tests/cpp/test_engine_no_op.cpp b/tests/cpp/test_engine_no_op.cpp new file mode 100644 index 0000000..bd984a6 --- /dev/null +++ b/tests/cpp/test_engine_no_op.cpp @@ -0,0 +1,24 @@ +// tests/cpp/test_engine_no_op.cpp — the silent engine. + +#include "test_helpers.hpp" +#include "../../nisps/engines/base.hpp" + +NISPS_TEST(no_op_engine_returns_zero) { + nisps::NoOpEngine e; + e.setup(48000.f); + e.set_params({}); + for (int n = 0; n < 100; ++n) { + const auto out = e.process({0.5f, -0.5f}); + NISPS_EXPECT(out.L == 0.f); + NISPS_EXPECT(out.R == 0.f); + } +} + +NISPS_TEST(no_op_engine_param_count_zero) { + NISPS_EXPECT(nisps::NoOpEngine::param_count() == 0u); +} + +NISPS_TEST(no_op_engine_satisfies_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(true); +} diff --git a/tests/cpp/test_engine_paf_synth.cpp b/tests/cpp/test_engine_paf_synth.cpp new file mode 100644 index 0000000..338a7a4 --- /dev/null +++ b/tests/cpp/test_engine_paf_synth.cpp @@ -0,0 +1,45 @@ +// tests/cpp/test_engine_paf_synth.cpp — PAFSynth engine smoke test. + +#include +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/paf_synth.hpp" + +NISPS_TEST(paf_synth_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::PAFSynthEngine::param_count() == 33u); + NISPS_EXPECT(nisps::PAFSynthEngine::engine_id() == "paf_synth"); +} + +NISPS_TEST(paf_synth_produces_nonzero_with_note) { + nisps::PAFSynthEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.5f; + e.set_params(p); + e.note_on(60u, 100u); + float energy = 0.f; + for (int n = 0; n < 4800; ++n) { + const auto y = e.process({0.f, 0.f}); + NISPS_EXPECT(std::isfinite(y.L)); + NISPS_EXPECT(std::isfinite(y.R)); + energy += y.L * y.L; + } + NISPS_EXPECT(energy > 0.f); +} + +NISPS_TEST(paf_synth_voice_space_switches) { + nisps::PAFSynthEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.5f; + for (std::size_t vs = 0u; vs < nisps::PAFSynthEngine::kVoiceSpaceCount; ++vs) { + e.set_voice_space(static_cast(vs)); + e.set_params(p); + for (int n = 0; n < 100; ++n) { + const auto y = e.process({0.f, 0.f}); + NISPS_EXPECT(std::isfinite(y.L)); + } + } +} diff --git a/tests/cpp/test_engine_verb_fx.cpp b/tests/cpp/test_engine_verb_fx.cpp new file mode 100644 index 0000000..4e65fd2 --- /dev/null +++ b/tests/cpp/test_engine_verb_fx.cpp @@ -0,0 +1,42 @@ +// tests/cpp/test_engine_verb_fx.cpp — VerbFX smoke test. + +#include +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/verb_fx.hpp" + +NISPS_TEST(verb_fx_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::VerbFXEngine::param_count() == 47u); + NISPS_EXPECT(nisps::VerbFXEngine::engine_id() == "verb_fx"); +} + +NISPS_TEST(verb_fx_finite_output_default) { + nisps::VerbFXEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.3f; + e.set_params(p); + for (int n = 0; n < 2400; ++n) { + const float x = std::sin(static_cast(n) * 0.03f) * 0.3f; + const auto y = e.process({x, x}); + NISPS_EXPECT(std::isfinite(y.L)); + NISPS_EXPECT(std::fabs(y.L) < 50.f); + } +} + +NISPS_TEST(verb_fx_voice_spaces_finite) { + nisps::VerbFXEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.3f; + for (std::size_t vs = 0u; vs < nisps::VerbFXEngine::kVoiceSpaceCount; ++vs) { + e.set_voice_space(static_cast(vs)); + e.set_params(p); + for (int n = 0; n < 200; ++n) { + const auto y = e.process({0.1f, 0.1f}); + NISPS_EXPECT(std::isfinite(y.L)); + } + } +} diff --git a/tests/cpp/test_engine_xiasri.cpp b/tests/cpp/test_engine_xiasri.cpp new file mode 100644 index 0000000..a5dc961 --- /dev/null +++ b/tests/cpp/test_engine_xiasri.cpp @@ -0,0 +1,30 @@ +// tests/cpp/test_engine_xiasri.cpp — XIASRI smoke test. + +#include +#include + +#include "test_helpers.hpp" +#include "../../nisps/engines/xiasri.hpp" + +NISPS_TEST(xiasri_concept) { + static_assert(nisps::AudioEngine); + NISPS_EXPECT(nisps::XIASRIEngine::param_count() == 24u); + NISPS_EXPECT(nisps::XIASRIEngine::engine_id() == "xiasri"); +} + +NISPS_TEST(xiasri_finite_output) { + nisps::XIASRIEngine e; + e.setup(48000.f); + std::array p; + for (auto& v : p) v = 0.3f; + e.set_params(p); + float energy = 0.f; + for (int n = 0; n < 4800; ++n) { + const float x = std::sin(2.f * 3.14159265f * 220.f * n / 48000.f) * 0.5f; + const auto y = e.process({x, x}); + NISPS_EXPECT(std::isfinite(y.L)); + NISPS_EXPECT(std::fabs(y.L) < 4.f); + energy += y.L * y.L; + } + NISPS_EXPECT(energy > 0.f); +}