feat(nisps/engines): port firmware audio engines to AudioEngine concept (meml-1v6)
Concept-based, no virtual dispatch, per-engine voice spaces as inline
methods. Each engine satisfies nisps::AudioEngine via static_assert.
- NoOpEngine: silent passthrough; used for sequencer-only modes and
for the SoundAnalysisMIDI mode's audio path.
- PAFSynthEngine (33 params, 7 voice spaces): 4-voice PAF synth with
detune cascade, ring-mod, sine-shaper, ADSR, feedback delay. note_on/
note_off interface for MIDI keyboard.
- ChannelStripEngine (24 params, 6 voice spaces): stereo console strip
(pre-gain/HPF/LPF/2x peak/low-shelf/high-shelf/comp/post-gain). Voice
spaces: WannabeNeve66, SSL4K, SSL9K, MaleVox, FemaleVox, Neve80
(stepped-frequency).
- XIASRIEngine (24 params, "Direct" voice space): pitch-shift + 6 allpass
+ 2 comb + 4 delays. Direct NN→param mapping per firmware semantics.
- VerbFXEngine (47 params, 12 voice spaces): 8-band SVF filterbank +
3-lane dynamic delay + 8-lpcomb/4-allpass Freeverb-style tail with
cross-fades. All 12 voice spaces ported from voicespaces/VerbFX/*.hpp.
- MEMLCeliumEngine (56 params): 2-track ratio sequencer + dual-voice
PAF synth (7+7+22+20 layout). Sequencer triggers V0/V1 ADSR.
- BreakOrEngine (56 params): 8-track ratio sequencer; emits NoteOn/
NoteOff/Clock events via pop_events(span). process() returns silence.
- ElysiamorfEngine (40 params): 8-track FM-pair sequencer; emits CC
events on CCs {1,2,3,4,5,9,11,12}. Silent audio path.
- AnalysisEngine (0 params, 6 features): port of XiasriAnalysis (pitch
via zero-crossing, aperiodicity via MAD, log-domain energy + attack
derivative + brightness ratio). Inputs to ML on SoundAnalysisMIDI mode.
All param_count() values match schemas/modes/*.json output_size.
4074 LOC total. CMake adds nisps_dsp_engine_tests target with 38
passing tests under -Wall -Wextra -Werror -Wpedantic.
2026-04-29 15:09:12 +02:00
|
|
|
// nisps/engines/xiasri.hpp — Reverb + delays + pitch-shift FX engine.
|
|
|
|
|
//
|
refactor(nisps): delete dead engine/mode mass
Phase 1 group 3 (L3, L9, L4, L5, L6, ST1).
- L3: nisps/modes/voice_space.hpp — entirely dead, no includes anywhere.
- L4: deleted SawOsc and SquareOsc. KEPT SineOsc — a verifier caught that the
original reviewer's grep missed its live consumer (the firmware selftest);
re-confirmed here before touching the file.
- L9: removed the no-op VoiceSpace enum/table/setter boilerplate from the five
engines with no real voice spaces; kept it on PAFSynth, VerbFX and
ChannelStrip, which have real ones. Every engine member was checked against
nisps/wasm/bindings.cpp, firmware/ and tests/ for callers first.
- L5: ModeBase::input_dirty_ was write-only state — deleted the flag rather
than making it gate inference, which would have been a behaviour change.
- L6: VerbFXEngine's delay_to_verb_ (computed 12x per block, never read),
enable_reverb_, enable_delay_to_reverb_ and the unused set_enable_* setters.
- ST1: rewrote the four engine header comment blocks that described
implementations which do not exist.
L7 (MEMLCeliumEngine's inert feedback path) is deliberately NOT done — tracing
git history showed feedbackGain went 0.1f (live) -> "0; //0.1f" (explicitly
muted, value preserved) -> dropped entirely in the port. That is a muted
feature, not dead weight, and deleting it would silently lose it. Left intact
pending an operator decision; see the phase report.
Gates: run-all-tests.sh ALL GREEN.
2026-07-21 12:48:50 +02:00
|
|
|
// Mirrors firmware XIASRIAudioApp. Direct NN→param mapping — the firmware
|
|
|
|
|
// ignored its voice-space slot and read smoothParams[] inline, so this
|
|
|
|
|
// engine has no VoiceSpace machinery at all: the 24 NN outputs feed the
|
|
|
|
|
// smoother directly.
|
feat(nisps/engines): port firmware audio engines to AudioEngine concept (meml-1v6)
Concept-based, no virtual dispatch, per-engine voice spaces as inline
methods. Each engine satisfies nisps::AudioEngine via static_assert.
- NoOpEngine: silent passthrough; used for sequencer-only modes and
for the SoundAnalysisMIDI mode's audio path.
- PAFSynthEngine (33 params, 7 voice spaces): 4-voice PAF synth with
detune cascade, ring-mod, sine-shaper, ADSR, feedback delay. note_on/
note_off interface for MIDI keyboard.
- ChannelStripEngine (24 params, 6 voice spaces): stereo console strip
(pre-gain/HPF/LPF/2x peak/low-shelf/high-shelf/comp/post-gain). Voice
spaces: WannabeNeve66, SSL4K, SSL9K, MaleVox, FemaleVox, Neve80
(stepped-frequency).
- XIASRIEngine (24 params, "Direct" voice space): pitch-shift + 6 allpass
+ 2 comb + 4 delays. Direct NN→param mapping per firmware semantics.
- VerbFXEngine (47 params, 12 voice spaces): 8-band SVF filterbank +
3-lane dynamic delay + 8-lpcomb/4-allpass Freeverb-style tail with
cross-fades. All 12 voice spaces ported from voicespaces/VerbFX/*.hpp.
- MEMLCeliumEngine (56 params): 2-track ratio sequencer + dual-voice
PAF synth (7+7+22+20 layout). Sequencer triggers V0/V1 ADSR.
- BreakOrEngine (56 params): 8-track ratio sequencer; emits NoteOn/
NoteOff/Clock events via pop_events(span). process() returns silence.
- ElysiamorfEngine (40 params): 8-track FM-pair sequencer; emits CC
events on CCs {1,2,3,4,5,9,11,12}. Silent audio path.
- AnalysisEngine (0 params, 6 features): port of XiasriAnalysis (pitch
via zero-crossing, aperiodicity via MAD, log-domain energy + attack
derivative + brightness ratio). Inputs to ML on SoundAnalysisMIDI mode.
All param_count() values match schemas/modes/*.json output_size.
4074 LOC total. CMake adds nisps_dsp_engine_tests target with 38
passing tests under -Wall -Wextra -Werror -Wpedantic.
2026-04-29 15:09:12 +02:00
|
|
|
//
|
|
|
|
|
// 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"; }
|
|
|
|
|
|
|
|
|
|
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
|