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.
This commit is contained in:
parent
973455b158
commit
8d0d47b992
19 changed files with 2825 additions and 0 deletions
|
|
@ -71,4 +71,36 @@ if(NOT EMSCRIPTEN)
|
||||||
|
|
||||||
enable_testing()
|
enable_testing()
|
||||||
add_test(NAME nisps_core_tests COMMAND nisps_core_tests)
|
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()
|
endif()
|
||||||
|
|
|
||||||
265
nisps/engines/analysis.hpp
Normal file
265
nisps/engines/analysis.hpp
Normal file
|
|
@ -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 <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 {
|
||||||
|
|
||||||
|
// Fixed-size median filter — replaces memllib's std::vector-based version.
|
||||||
|
template <std::size_t N>
|
||||||
|
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<float, N> 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<float, N> buf_{};
|
||||||
|
std::size_t idx_ = 0u;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fixed-size circular buffer with [] access counted from oldest.
|
||||||
|
template <typename T, std::size_t N>
|
||||||
|
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<T, N> 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<std::string_view, 0u> 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<const float> /*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<float>(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<float, kZCBufSize> 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<float> 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<float, kZCBufSize>& v) noexcept {
|
||||||
|
float sum = 0.f;
|
||||||
|
for (auto x : v) sum += x;
|
||||||
|
const float mean = sum / static_cast<float>(kZCBufSize);
|
||||||
|
float acc = 0.f;
|
||||||
|
for (auto x : v) acc += std::fabs(x - mean);
|
||||||
|
return acc / static_cast<float>(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<kZCMedianSize> zc_median_;
|
||||||
|
CircularBuffer<float, kZCBufSize> 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>, "AnalysisEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
41
nisps/engines/base.hpp
Normal file
41
nisps/engines/base.hpp
Normal file
|
|
@ -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 <cstddef>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#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<const float> /*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>, "NoOpEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
229
nisps/engines/breakor.hpp
Normal file
229
nisps/engines/breakor.hpp
Normal file
|
|
@ -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 <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#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<std::string_view, 0u> 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<const float> 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<float>(static_cast<int>(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<int>(params[i++] * 3.999999f) & 3];
|
||||||
|
t.phase_off = static_cast<float>(static_cast<int>(params[i++] * 4.f)) * 0.25f;
|
||||||
|
sum = 0.f;
|
||||||
|
for (std::size_t r = 0u; r < 2u; ++r) {
|
||||||
|
t.amp_ratios[r] = static_cast<float>(static_cast<int>(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<std::uint8_t>(i), t.midi_note, v});
|
||||||
|
} else if (!trig && t.last_trig) {
|
||||||
|
push_event({EventKind::NoteOff, static_cast<std::uint8_t>(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<Event> out) noexcept {
|
||||||
|
std::size_t n = 0u;
|
||||||
|
while (n < out.size() && event_count_ > 0u) {
|
||||||
|
out[n++] = events_[event_read_];
|
||||||
|
event_read_ = (event_read_ + 1u) % kEventBufferSize;
|
||||||
|
--event_count_;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_bpm(float bpm) noexcept {
|
||||||
|
bpm_ = bpm;
|
||||||
|
const float beat_seconds = 60.f / bpm;
|
||||||
|
const float bar_seconds = beat_seconds * 4.f;
|
||||||
|
const float bar_samples = bar_seconds * (sample_rate_ / static_cast<float>(kSequencingSampleDiv));
|
||||||
|
bar_phasor_inc_ = 1.f / bar_samples;
|
||||||
|
const float clock_seconds = beat_seconds / 24.f;
|
||||||
|
midi_clock_phasor_inc_ = 1.f / (clock_seconds * sample_rate_);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
std::uint8_t midi_note = 36u;
|
||||||
|
bool last_trig = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <std::size_t N>
|
||||||
|
static bool ratio_seq(float phasor, float ratio_sum,
|
||||||
|
const std::array<float, N>& ratios,
|
||||||
|
float pulse_width) noexcept {
|
||||||
|
float offset_phase = phasor;
|
||||||
|
if (offset_phase >= 1.f) offset_phase -= 1.f;
|
||||||
|
const float phase_adj = ratio_sum * offset_phase;
|
||||||
|
float accum = 0.f, last = 0.f;
|
||||||
|
for (std::size_t i = 0u; i < N; ++i) {
|
||||||
|
accum += ratios[i];
|
||||||
|
if (phase_adj <= accum) {
|
||||||
|
const float beat_phase = (phase_adj - last) / (accum - last);
|
||||||
|
return beat_phase <= pulse_width;
|
||||||
|
}
|
||||||
|
last = accum;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static bool ratio_seq_3(float p, float s, const std::array<float, 3>& r, float pw) noexcept {
|
||||||
|
return ratio_seq<3>(p, s, r, pw);
|
||||||
|
}
|
||||||
|
static bool ratio_seq_2(float p, float s, const std::array<float, 2>& r, float pw) noexcept {
|
||||||
|
return ratio_seq<2>(p, s, r, pw);
|
||||||
|
}
|
||||||
|
|
||||||
|
NISPS_FORCE_INLINE void push_event(const Event& e) noexcept {
|
||||||
|
if (event_count_ >= kEventBufferSize) return; // drop on overflow
|
||||||
|
events_[event_write_] = e;
|
||||||
|
event_write_ = (event_write_ + 1u) % kEventBufferSize;
|
||||||
|
++event_count_;
|
||||||
|
}
|
||||||
|
|
||||||
|
float sample_rate_ = 48000.f;
|
||||||
|
float bpm_ = 90.f;
|
||||||
|
bool playing_ = true;
|
||||||
|
|
||||||
|
std::array<Track, kNSequences> tracks_;
|
||||||
|
float bar_phasor_ = 0.f;
|
||||||
|
float bar_phasor_inc_ = 0.f;
|
||||||
|
float midi_clock_phasor_ = 0.f;
|
||||||
|
float midi_clock_phasor_inc_ = 0.f;
|
||||||
|
std::size_t sequencing_sample_counter_ = 0u;
|
||||||
|
|
||||||
|
std::array<Event, kEventBufferSize> events_{};
|
||||||
|
std::size_t event_read_ = 0u;
|
||||||
|
std::size_t event_write_ = 0u;
|
||||||
|
std::size_t event_count_ = 0u;
|
||||||
|
};
|
||||||
|
|
||||||
|
static_assert(AudioEngine<BreakOrEngine>, "BreakOrEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
354
nisps/engines/channel_strip.hpp
Normal file
354
nisps/engines/channel_strip.hpp
Normal file
|
|
@ -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 <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
|
||||||
182
nisps/engines/elysiamorf.hpp
Normal file
182
nisps/engines/elysiamorf.hpp
Normal file
|
|
@ -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 <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#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<std::string_view, 0u> 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<const float> 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<int>(params[i++] * 3.999f) & 3];
|
||||||
|
t.phase_off = static_cast<float>(static_cast<int>(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<std::uint8_t>(scaled), 0u});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++sequencing_sample_counter_;
|
||||||
|
if (sequencing_sample_counter_ >= kSequencingSampleDiv) sequencing_sample_counter_ = 0u;
|
||||||
|
return {0.f, 0.f};
|
||||||
|
}
|
||||||
|
|
||||||
|
DriverConfig driver_config() const noexcept { return {}; }
|
||||||
|
|
||||||
|
std::size_t pop_events(std::span<Event> out) noexcept {
|
||||||
|
std::size_t n = 0u;
|
||||||
|
while (n < out.size() && event_count_ > 0u) {
|
||||||
|
out[n++] = events_[event_read_];
|
||||||
|
event_read_ = (event_read_ + 1u) % kEventBufferSize;
|
||||||
|
--event_count_;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_bpm(float bpm) noexcept {
|
||||||
|
bpm_ = bpm;
|
||||||
|
const float beat_seconds = 60.f / bpm;
|
||||||
|
const float bar_seconds = beat_seconds * 4.f;
|
||||||
|
const float bar_samples = bar_seconds * (sample_rate_ / static_cast<float>(kSequencingSampleDiv));
|
||||||
|
bar_phasor_inc_ = 1.f / bar_samples;
|
||||||
|
const float clock_seconds = beat_seconds / 24.f;
|
||||||
|
midi_clock_phasor_inc_ = 1.f / (clock_seconds * sample_rate_);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Track, kNSequences> tracks_{};
|
||||||
|
float bar_phasor_ = 0.f;
|
||||||
|
float bar_phasor_inc_ = 0.f;
|
||||||
|
float midi_clock_phasor_ = 0.f;
|
||||||
|
float midi_clock_phasor_inc_ = 0.f;
|
||||||
|
std::size_t sequencing_sample_counter_ = 0u;
|
||||||
|
|
||||||
|
std::array<Event, kEventBufferSize> events_{};
|
||||||
|
std::size_t event_read_ = 0u;
|
||||||
|
std::size_t event_write_ = 0u;
|
||||||
|
std::size_t event_count_ = 0u;
|
||||||
|
};
|
||||||
|
|
||||||
|
static_assert(AudioEngine<ElysiamorfEngine>, "ElysiamorfEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
321
nisps/engines/memlcelium.hpp
Normal file
321
nisps/engines/memlcelium.hpp
Normal file
|
|
@ -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 <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
|
||||||
407
nisps/engines/paf_synth.hpp
Normal file
407
nisps/engines/paf_synth.hpp
Normal file
|
|
@ -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 <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#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<std::size_t>(VoiceSpace::Count);
|
||||||
|
static constexpr std::array<std::string_view, kVoiceSpaceCount> 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<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::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<float>(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<float>(note) - 69.f) / 12.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// -------- voice space implementations --------
|
||||||
|
// VS1 — Rowantares
|
||||||
|
void apply_vs1(const std::array<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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<float, kNParams>& 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>, "PAFSynthEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
512
nisps/engines/verb_fx.hpp
Normal file
512
nisps/engines/verb_fx.hpp
Normal file
|
|
@ -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 <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#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<std::size_t>(VoiceSpace::Count);
|
||||||
|
static constexpr std::array<std::string_view, kVoiceSpaceCount> 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<const float> 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<kNParams> smoother_;
|
||||||
|
std::array<float, kNParams> nn_outputs_{};
|
||||||
|
std::array<float, kNParams> smooth_params_{};
|
||||||
|
|
||||||
|
AllPass<kSizeAllP0> allp0_;
|
||||||
|
AllPass<kSizeAllP1> allp1_;
|
||||||
|
AllPass<kSizeAllP2> allp2_;
|
||||||
|
AllPass<kSizeAllP3> allp3_;
|
||||||
|
|
||||||
|
LpComb<kSizeComb0> lpcomb0_;
|
||||||
|
LpComb<kSizeComb1> lpcomb1_;
|
||||||
|
LpComb<kSizeComb2> lpcomb2_;
|
||||||
|
LpComb<kSizeComb3> lpcomb3_;
|
||||||
|
LpComb<kSizeComb4> lpcomb4_;
|
||||||
|
LpComb<kSizeComb5> lpcomb5_;
|
||||||
|
LpComb<kSizeComb6> lpcomb6_;
|
||||||
|
LpComb<kSizeComb7> 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>, "VerbFXEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
160
nisps/engines/xiasri.hpp
Normal file
160
nisps/engines/xiasri.hpp
Normal file
|
|
@ -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 <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#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<std::string_view, kVoiceSpaceCount> 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<const float> 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<kNParams> smoother_;
|
||||||
|
std::array<float, kNParams> nn_outputs_{};
|
||||||
|
std::array<float, kNParams> smooth_params_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
static_assert(AudioEngine<XIASRIEngine>, "XIASRIEngine must satisfy AudioEngine");
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
37
tests/cpp/test_engine_analysis.cpp
Normal file
37
tests/cpp/test_engine_analysis.cpp
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// tests/cpp/test_engine_analysis.cpp — XiasriAnalysis port smoke test.
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/analysis.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(analysis_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::AnalysisEngine>);
|
||||||
|
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);
|
||||||
|
}
|
||||||
46
tests/cpp/test_engine_breakor.cpp
Normal file
46
tests/cpp/test_engine_breakor.cpp
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// tests/cpp/test_engine_breakor.cpp — sequencer-only engine.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/breakor.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(breakor_concept_and_silent_audio) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::BreakOrEngine>);
|
||||||
|
NISPS_EXPECT(nisps::BreakOrEngine::param_count() == 56u);
|
||||||
|
NISPS_EXPECT(nisps::BreakOrEngine::engine_id() == "breakor");
|
||||||
|
|
||||||
|
nisps::BreakOrEngine e;
|
||||||
|
e.setup(48000.f);
|
||||||
|
std::array<float, 56> 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<float, 56> 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<nisps::BreakOrEngine::Event, 64> 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);
|
||||||
|
}
|
||||||
39
tests/cpp/test_engine_channel_strip.cpp
Normal file
39
tests/cpp/test_engine_channel_strip.cpp
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// tests/cpp/test_engine_channel_strip.cpp — channel strip smoke test.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/channel_strip.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(channel_strip_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::ChannelStripEngine>);
|
||||||
|
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<float, 24> 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<nisps::ChannelStripEngine::VoiceSpace>(vs));
|
||||||
|
e.set_params(p);
|
||||||
|
for (int n = 0; n < 1000; ++n) {
|
||||||
|
const float x = std::sin(static_cast<float>(n) * 0.05f) * 0.3f;
|
||||||
|
const auto y = e.process({x, x});
|
||||||
|
NISPS_EXPECT(std::isfinite(y.L));
|
||||||
|
NISPS_EXPECT(std::isfinite(y.R));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/cpp/test_engine_elysiamorf.cpp
Normal file
33
tests/cpp/test_engine_elysiamorf.cpp
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// tests/cpp/test_engine_elysiamorf.cpp — sequencer-only FM-CC emitter.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/elysiamorf.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(elysiamorf_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::ElysiamorfEngine>);
|
||||||
|
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<float, 40> p;
|
||||||
|
for (auto& v : p) v = 0.5f;
|
||||||
|
e.set_params(p);
|
||||||
|
e.update_bpm(120.f);
|
||||||
|
e.set_playing(true);
|
||||||
|
|
||||||
|
std::array<nisps::ElysiamorfEngine::Event, 64> 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);
|
||||||
|
}
|
||||||
26
tests/cpp/test_engine_memlcelium.cpp
Normal file
26
tests/cpp/test_engine_memlcelium.cpp
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// tests/cpp/test_engine_memlcelium.cpp — MEMLCelium smoke test.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/memlcelium.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(memlcelium_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::MEMLCeliumEngine>);
|
||||||
|
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<float, 56> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tests/cpp/test_engine_no_op.cpp
Normal file
24
tests/cpp/test_engine_no_op.cpp
Normal file
|
|
@ -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::NoOpEngine>);
|
||||||
|
NISPS_EXPECT(true);
|
||||||
|
}
|
||||||
45
tests/cpp/test_engine_paf_synth.cpp
Normal file
45
tests/cpp/test_engine_paf_synth.cpp
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// tests/cpp/test_engine_paf_synth.cpp — PAFSynth engine smoke test.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/paf_synth.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(paf_synth_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::PAFSynthEngine>);
|
||||||
|
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<float, 33> 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<float, 33> 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<nisps::PAFSynthEngine::VoiceSpace>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/cpp/test_engine_verb_fx.cpp
Normal file
42
tests/cpp/test_engine_verb_fx.cpp
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// tests/cpp/test_engine_verb_fx.cpp — VerbFX smoke test.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/verb_fx.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(verb_fx_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::VerbFXEngine>);
|
||||||
|
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<float, 47> 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<float>(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<float, 47> 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<nisps::VerbFXEngine::VoiceSpace>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/cpp/test_engine_xiasri.cpp
Normal file
30
tests/cpp/test_engine_xiasri.cpp
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// tests/cpp/test_engine_xiasri.cpp — XIASRI smoke test.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include "test_helpers.hpp"
|
||||||
|
#include "../../nisps/engines/xiasri.hpp"
|
||||||
|
|
||||||
|
NISPS_TEST(xiasri_concept) {
|
||||||
|
static_assert(nisps::AudioEngine<nisps::XIASRIEngine>);
|
||||||
|
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<float, 24> 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue