memlnaut-nisps/nisps/engines/memlcelium.hpp
w1n5t0n 8d0d47b992 feat(nisps/engines): port firmware audio engines to AudioEngine concept (meml-1v6)
Concept-based, no virtual dispatch, per-engine voice spaces as inline
methods. Each engine satisfies nisps::AudioEngine via static_assert.

- NoOpEngine: silent passthrough; used for sequencer-only modes and
  for the SoundAnalysisMIDI mode's audio path.
- PAFSynthEngine (33 params, 7 voice spaces): 4-voice PAF synth with
  detune cascade, ring-mod, sine-shaper, ADSR, feedback delay. note_on/
  note_off interface for MIDI keyboard.
- ChannelStripEngine (24 params, 6 voice spaces): stereo console strip
  (pre-gain/HPF/LPF/2x peak/low-shelf/high-shelf/comp/post-gain). Voice
  spaces: WannabeNeve66, SSL4K, SSL9K, MaleVox, FemaleVox, Neve80
  (stepped-frequency).
- XIASRIEngine (24 params, "Direct" voice space): pitch-shift + 6 allpass
  + 2 comb + 4 delays. Direct NN→param mapping per firmware semantics.
- VerbFXEngine (47 params, 12 voice spaces): 8-band SVF filterbank +
  3-lane dynamic delay + 8-lpcomb/4-allpass Freeverb-style tail with
  cross-fades. All 12 voice spaces ported from voicespaces/VerbFX/*.hpp.
- MEMLCeliumEngine (56 params): 2-track ratio sequencer + dual-voice
  PAF synth (7+7+22+20 layout). Sequencer triggers V0/V1 ADSR.
- BreakOrEngine (56 params): 8-track ratio sequencer; emits NoteOn/
  NoteOff/Clock events via pop_events(span). process() returns silence.
- ElysiamorfEngine (40 params): 8-track FM-pair sequencer; emits CC
  events on CCs {1,2,3,4,5,9,11,12}. Silent audio path.
- AnalysisEngine (0 params, 6 features): port of XiasriAnalysis (pitch
  via zero-crossing, aperiodicity via MAD, log-domain energy + attack
  derivative + brightness ratio). Inputs to ML on SoundAnalysisMIDI mode.

All param_count() values match schemas/modes/*.json output_size.
4074 LOC total. CMake adds nisps_dsp_engine_tests target with 38
passing tests under -Wall -Wextra -Werror -Wpedantic.
2026-04-29 16:09:12 +03:00

321 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 <array>
#include <cmath>
#include <cstddef>
#include <span>
#include <string_view>
#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<std::string_view, kVoiceSpaceCount> 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<const float> 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<float>(static_cast<int>(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<int>(params[base + 3] * 3.999999f) & 3];
seqs_[s].phase_off = static_cast<float>(static_cast<int>(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<float>(static_cast<int>(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<float>(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<float>(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<float, 3> ratios{1.f, 1.f, 1.f};
std::array<float, 2> 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 <std::size_t N>
static bool ratio_seq(float phasor, float ratio_sum,
const std::array<float, N>& ratios,
float pulse_width) noexcept {
float offset_phase = phasor;
if (offset_phase >= 1.f) offset_phase -= 1.f;
const float phase_adj = ratio_sum * offset_phase;
float accum = 0.f, last = 0.f;
for (std::size_t i = 0u; i < N; ++i) {
accum += ratios[i];
if (phase_adj <= accum) {
const float beat_phase = (phase_adj - last) / (accum - last);
return beat_phase <= pulse_width;
}
last = accum;
}
return false;
}
static bool ratio_seq_3(float p, float s, const std::array<float, 3>& r, float pw) noexcept {
return ratio_seq<3>(p, s, r, pw);
}
static bool ratio_seq_2(float p, float s, const std::array<float, 2>& r, float pw) noexcept {
return ratio_seq<2>(p, s, r, pw);
}
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<SeqState, kNSequences> 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>, "MEMLCeliumEngine must satisfy AudioEngine");
} // namespace nisps