memlnaut-nisps/nisps/engines/channel_strip.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

354 lines
16 KiB
C++
Raw Permalink 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/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 <array>
#include <cmath>
#include <cstddef>
#include <span>
#include <string_view>
#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<std::size_t>(VoiceSpace::Count);
static constexpr std::array<std::string_view, kVoiceSpaceCount> 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<const float> params) noexcept {
if (params.size() < kNParams) return;
std::array<float, kNParams> 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<std::size_t>(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>, "ChannelStripEngine must satisfy AudioEngine");
} // namespace nisps