Port Cortex-M4 resonant filter to a pure vtable-free core/instrument/engine/filter module with 0.1-10 Q and log cutoff
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# src/core/instrument/engine/filter — the per-voice resonant filter
|
||||
|
||||
## Scope
|
||||
|
||||
The pure 2-pole resonant low/high-pass a sounding voice runs. No REAPER, no VST3, no
|
||||
allocation, no I/O. Four files, one responsibility each:
|
||||
|
||||
- `filter_params` — the control domain: `FilterMode`, normalized [0,1] knob position →
|
||||
cutoff Hz and Q, and the exact inverses.
|
||||
- `filter_coeffs` — the DSP domain: `BiquadCoeffs` and the RBJ coefficient computation
|
||||
from (mode, cutoff Hz, Q, sample rate).
|
||||
- `filter_saturate` — the high-pass feedback saturator (`tanhSaturate` /
|
||||
`feedbackSaturate`). Header-only inline; it sits on the per-sample path.
|
||||
- `voice_filter` — `FilterSettings` and `VoiceFilter`, the concrete per-voice type.
|
||||
`process()` is defined in the header.
|
||||
|
||||
## Invariants
|
||||
|
||||
### No vtable on the per-sample path
|
||||
|
||||
This is a **port, not a relocation**. The Cortex-M4 source was a virtual hierarchy
|
||||
(`FilterBase` → `Filter` → `Biquad` → `{BiquadHP, BiquadLP}`) whose base class routed the
|
||||
channel loop through pure-virtual `process_channel_frame` / `filter` / `update_feedback`
|
||||
so a `FilterDecorator` chain could wrap it. **None of that came across, and none of it may
|
||||
come back.** `VoiceFilter` is concrete: mode is a member branch inside an inlined
|
||||
`process()`, predicted perfectly because it cannot change within a note. There is no
|
||||
`IFilter`, no decorator seam, no virtual `tick()`, and no allocation in `process()` — root
|
||||
`CLAUDE.md`'s structural heuristic 3 names this class of dispatch blowout directly.
|
||||
|
||||
A non-type template parameter for the mode was considered and rejected: mode is a
|
||||
runtime-settable user parameter, so templating would only relocate the same branch to the
|
||||
call site and force the voice to hold two instances or switch over them.
|
||||
|
||||
### Two modes, and only two
|
||||
|
||||
2-pole high-pass and 2-pole low-pass. The source's `Biquad1PoleLP` is struck and was not
|
||||
ported. Further modes are deferred — **do not build a mode-extension framework** for them.
|
||||
|
||||
### The cutoff control is sample-rate-free; the clamp is not
|
||||
|
||||
`filterCutoffHzFromNorm` sweeps a fixed 20 Hz – 20 kHz (three exact decades, so norm 1/3
|
||||
is 200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the
|
||||
normalized knob position, so a rate-derived endpoint would make one preset sound different
|
||||
at 44.1k and 96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the
|
||||
bilinear transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `biquadCoeffs`
|
||||
where the rate is already a parameter. 20 kHz is under 0.48·sr at every supported rate, so
|
||||
the clamp never eats live knob travel; the source's hardcoded 23 kHz endpoint did exactly
|
||||
that at 44.1k.
|
||||
|
||||
`biquadCoeffs` with a non-positive sample rate returns pass-through coefficients. It does
|
||||
**not** fall back to 44100 — that would breach the standing no-hardcoded-sample-rates
|
||||
ruling.
|
||||
|
||||
### Q spans 0.1 → 10 with √2 at the center
|
||||
|
||||
Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at 0.707, no
|
||||
center anchor) was **rewritten, not ported**. The curve is quadratic in log Q through the
|
||||
three anchors rather than two spliced log segments — same anchors either way, but no slope
|
||||
kink at the center detent. The quadratic term is nonzero only because √2 is not the
|
||||
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it.
|
||||
|
||||
### The high-pass input feedback is load-bearing
|
||||
|
||||
`kHighPassFeedbackShare` (0.24) times the raw **normalized** resonance, not Q — Q reaches
|
||||
10 and scaling the feedback by it would push loop gain past unity. The high-pass numerator
|
||||
collapses toward zero as cutoff falls, taking the resonance with it; the saturated
|
||||
feedback restores the character down there. Ported behavior; the constant is the tuning
|
||||
knob if the feel needs adjusting. `audio_saturate` and `H()` from the source were unused
|
||||
by the biquads and were not ported.
|
||||
|
||||
### Denormal flushing
|
||||
|
||||
`process()` flushes the **y** history to exact zero below `kFilterDenormalFloor` (1e-30).
|
||||
Only the recursive half needs it: a denormal in `y` self-sustains and stalls the FPU for
|
||||
thousands of samples on a ringing-out voice, while the `x` history is an FIR tail that
|
||||
shifts out within two samples. `isSilent()` reports the flushed state and is the honest
|
||||
signal that a voice's filter can no longer contribute output.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The tan pre-warp is not a different filter.** By the half-angle identity
|
||||
`cos(w0) = (1-w²)/(1+w²)` and `sin(w0) = 2w/(1+w²)` with `w = tan(pi*fc/sr)`, these are
|
||||
the textbook RBJ cos/sin coefficients exactly — just computed in a form that stays
|
||||
conditioned at low cutoff where `cos(w0) → 1`. `tests/test_filter.cpp` asserts the
|
||||
equivalence against an independent derivation. Don't "simplify" it back to `std::cos`.
|
||||
- **`prepare()` deliberately does not clear history** — a live parameter move must glide,
|
||||
not click. Call `reset()` at note-on.
|
||||
- **`a1`/`a2` are stored for a subtracting difference equation** (`y = ... - a1*y1 -
|
||||
a2*y2`), so the transfer denominator is `1 + a1*z^-1 + a2*z^-2`. A sign convention slip
|
||||
here inverts the poles.
|
||||
- **No call site yet.** Wiring the filter into the voice path is a separate track; nothing
|
||||
in `sampler_core` references this module today.
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
namespace {
|
||||
|
||||
// M_PI is not standard C++ and is absent on MSVC without _USE_MATH_DEFINES.
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
double clampd(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
BiquadCoeffs biquadCoeffs(FilterMode mode, float cutoffHz, float q, double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return BiquadCoeffs{};
|
||||
|
||||
const double nyquistCeiling = kFilterNyquistFraction * sampleRate;
|
||||
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, nyquistCeiling);
|
||||
const double qq = clampd(q, kFilterQMin, kFilterQMax);
|
||||
|
||||
const double w = std::tan(kPi * fc / sampleRate);
|
||||
const double w2 = w * w;
|
||||
const double cosw = (1.0 - w2) / (1.0 + w2);
|
||||
const double sinw = 2.0 * w / (1.0 + w2);
|
||||
const double alpha = sinw / (2.0 * qq);
|
||||
const double norm = 1.0 / (1.0 + alpha);
|
||||
|
||||
// Both modes share the denominator; only the numerator's sign on cosw differs, and b1 is
|
||||
// always +/-2*b0 — folding that in keeps the two branches from drifting apart.
|
||||
const double b0 = (mode == FilterMode::HighPass ? (1.0 + cosw) : (1.0 - cosw)) * 0.5 * norm;
|
||||
const double b1 = (mode == FilterMode::HighPass ? -2.0 : 2.0) * b0;
|
||||
|
||||
BiquadCoeffs c;
|
||||
c.b0 = static_cast<float>(b0);
|
||||
c.b1 = static_cast<float>(b1);
|
||||
c.b2 = static_cast<float>(b0);
|
||||
c.a1 = static_cast<float>(-2.0 * cosw * norm);
|
||||
c.a2 = static_cast<float>((1.0 - alpha) * norm);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,31 @@
|
||||
// filter_coeffs.h — RBJ Audio EQ Cookbook Direct Form I biquad coefficients for the 2-pole
|
||||
// low/high-pass. Computed via the tan half-angle substitution w = tan(pi*fc/sr): by the
|
||||
// identity cos(w0) = (1-w^2)/(1+w^2), sin(w0) = 2w/(1+w^2) these ARE the textbook cos/sin
|
||||
// coefficients, in a form that stays conditioned at low cutoff where cos(w0) -> 1.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// Already normalized by a0. The denominator is 1 + a1*z^-1 + a2*z^-2, so the difference
|
||||
// equation SUBTRACTS the a terms: y = b0*x + b1*x1 + b2*x2 - a1*y1 - a2*y2.
|
||||
struct BiquadCoeffs {
|
||||
float b0 = 1.0f;
|
||||
float b1 = 0.0f;
|
||||
float b2 = 0.0f;
|
||||
float a1 = 0.0f;
|
||||
float a2 = 0.0f;
|
||||
};
|
||||
|
||||
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges
|
||||
// as fc approaches sr/2. Ported unchanged from the firmware, where it was already the ceiling.
|
||||
inline constexpr double kFilterNyquistFraction = 0.48;
|
||||
|
||||
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
|
||||
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields pass-through coefficients — the
|
||||
// no-hardcoded-sample-rates ruling means we refuse to invent a rate rather than assume 44.1k.
|
||||
BiquadCoeffs biquadCoeffs(FilterMode mode, float cutoffHz, float q, double sampleRate);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
namespace {
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
// log Q = A + B*n + C*n^2, solved from the three anchor points. C is nonzero precisely
|
||||
// because the center anchor sqrt(2) is not the geometric mean of the endpoints (which is 1);
|
||||
// were they equal the curve would degenerate to a plain log sweep and the inverse below
|
||||
// would divide by zero.
|
||||
struct QCurve {
|
||||
double a, b, c;
|
||||
};
|
||||
|
||||
QCurve qCurve() {
|
||||
const double lo = std::log(static_cast<double>(kFilterQMin));
|
||||
const double mid = std::log(static_cast<double>(kFilterQCenter));
|
||||
const double hi = std::log(static_cast<double>(kFilterQMax));
|
||||
return {lo, 4.0 * mid - 3.0 * lo - hi, 2.0 * lo + 2.0 * hi - 4.0 * mid};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
float filterCutoffHzFromNorm(float norm) {
|
||||
const double lo = std::log(static_cast<double>(kFilterCutoffMinHz));
|
||||
const double hi = std::log(static_cast<double>(kFilterCutoffMaxHz));
|
||||
return static_cast<float>(std::exp(lo + clamp01(norm) * (hi - lo)));
|
||||
}
|
||||
|
||||
float filterNormFromCutoffHz(float hz) {
|
||||
if (!(hz > 0.0f)) return 0.0f;
|
||||
const double lo = std::log(static_cast<double>(kFilterCutoffMinHz));
|
||||
const double hi = std::log(static_cast<double>(kFilterCutoffMaxHz));
|
||||
return static_cast<float>(clamp01((std::log(static_cast<double>(hz)) - lo) / (hi - lo)));
|
||||
}
|
||||
|
||||
float filterQFromNorm(float norm) {
|
||||
const QCurve k = qCurve();
|
||||
const double n = clamp01(norm);
|
||||
return static_cast<float>(std::exp(k.a + n * (k.b + k.c * n)));
|
||||
}
|
||||
|
||||
float filterNormFromQ(float q) {
|
||||
if (!(q > kFilterQMin)) return 0.0f;
|
||||
if (q >= kFilterQMax) return 1.0f;
|
||||
// Clamping first is load-bearing, not just tidy: the parabola peaks at log Q well below
|
||||
// an arbitrarily large q, so an unclamped out-of-range value has no real root at all.
|
||||
const QCurve k = qCurve();
|
||||
const double d = k.b * k.b - 4.0 * k.c * (k.a - std::log(static_cast<double>(q)));
|
||||
if (!(d >= 0.0)) return 0.0f;
|
||||
// Of the two roots only this one lies on the rising branch inside [0,1]; the parabola's
|
||||
// vertex sits well above 1 for the settled anchors.
|
||||
return static_cast<float>(clamp01((-k.b + std::sqrt(d)) / (2.0 * k.c)));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,39 @@
|
||||
// filter_params.h — control-domain mapping for the voice filter: normalized [0,1] knob
|
||||
// positions to cutoff Hz and Q, plus the two-mode enum. Deliberately sample-rate-free —
|
||||
// the Nyquist clamp is a property of the bilinear transform and lives in filter_coeffs,
|
||||
// so the persisted normalized cutoff means the same frequency at every project rate.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
enum class FilterMode { LowPass, HighPass };
|
||||
|
||||
// The audio band the cutoff control sweeps: three exact decades, so norm 1/3 is 200 Hz and
|
||||
// norm 2/3 is 2 kHz. NOT derived from the sample rate — a rate-dependent endpoint would make
|
||||
// one saved preset sound different at 44.1k and 96k, and at 44.1k the top of the travel would
|
||||
// be dead against the Nyquist clamp (the ported firmware's 23 kHz endpoint had exactly that
|
||||
// defect). 20 kHz sits under 0.48*sr at every rate we support, so the whole knob stays live.
|
||||
inline constexpr float kFilterCutoffMinHz = 20.0f;
|
||||
inline constexpr float kFilterCutoffMaxHz = 20000.0f;
|
||||
|
||||
// Q spans the full range with Butterworth (sqrt(2)) at the control's center detent.
|
||||
inline constexpr float kFilterQMin = 0.1f;
|
||||
inline constexpr float kFilterQMax = 10.0f;
|
||||
inline constexpr float kFilterQCenter = 1.41421356f;
|
||||
|
||||
// Out-of-range norm clamps to the endpoints.
|
||||
float filterCutoffHzFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterCutoffHzFromNorm over the band; out-of-band Hz clamps to 0 or 1.
|
||||
float filterNormFromCutoffHz(float hz);
|
||||
|
||||
// A single smooth curve — quadratic in log Q — through (0, kFilterQMin),
|
||||
// (0.5, kFilterQCenter), (1, kFilterQMax), rather than two spliced log segments. Same three
|
||||
// anchors either way, but the single curve has no slope kink at the center detent.
|
||||
float filterQFromNorm(float norm);
|
||||
|
||||
// Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
|
||||
float filterNormFromQ(float q);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,27 @@
|
||||
// filter_saturate.h — the high-pass feedback-path saturator, ported from the Cortex-M4
|
||||
// filter. Header-inline: it sits on the per-voice per-sample path, and a rational
|
||||
// approximation is here precisely to avoid a transcendental tanh() call there.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// Rational tanh approximation inside +/-threshold, continued past it with a gentle 0.1 slope
|
||||
// anchored at the threshold value so the curve stays continuous rather than hard-clipping.
|
||||
inline float tanhSaturate(float x, float threshold, float a, float b) {
|
||||
if (x > threshold) {
|
||||
const float satAtThreshold = threshold * a / (a + b + threshold * threshold);
|
||||
return satAtThreshold + (x - threshold) * 0.1f;
|
||||
}
|
||||
if (x < -threshold) {
|
||||
const float satAtThreshold = -threshold * a / (a + b + threshold * threshold);
|
||||
return satAtThreshold + (x + threshold) * 0.1f;
|
||||
}
|
||||
return x * a / (a + b + x * x);
|
||||
}
|
||||
|
||||
// TB-303-style hard feedback saturation. Tuned for the large excursions a resonant feedback
|
||||
// path produces, not for audio-level signals — do not reuse it as a general waveshaper.
|
||||
inline float feedbackSaturate(float x) { return tanhSaturate(x, 2.0f, 27.0f, 9.0f); }
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
|
||||
mode_ = settings.mode;
|
||||
const float cutoffHz = filterCutoffHzFromNorm(settings.cutoffNorm);
|
||||
coeffs_ = biquadCoeffs(settings.mode, cutoffHz, filterQFromNorm(settings.resonanceNorm),
|
||||
sampleRate);
|
||||
const float res = settings.resonanceNorm < 0.0f
|
||||
? 0.0f
|
||||
: (settings.resonanceNorm > 1.0f ? 1.0f : settings.resonanceNorm);
|
||||
fbAmount_ = res * kHighPassFeedbackShare;
|
||||
}
|
||||
|
||||
void VoiceFilter::reset() {
|
||||
for (State& s : state_) s = State{};
|
||||
}
|
||||
|
||||
bool VoiceFilter::isSilent() const {
|
||||
for (const State& s : state_) {
|
||||
if (s.x1 != 0.0f || s.x2 != 0.0f || s.y1 != 0.0f || s.y2 != 0.0f || s.fb != 0.0f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,113 @@
|
||||
// voice_filter.h — per-voice 2-pole resonant low/high-pass. Concrete type, no vtable: this
|
||||
// sits on the per-voice per-sample path, so process() is header-inline and mode is a member
|
||||
// branch. No allocation, no virtual dispatch, no I/O anywhere in process().
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||
#include "core/instrument/engine/filter/filter_params.h"
|
||||
#include "core/instrument/engine/filter/filter_saturate.h"
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// Normalized control positions, as the editor moves them and the persisted state carries them.
|
||||
struct FilterSettings {
|
||||
FilterMode mode = FilterMode::LowPass;
|
||||
float cutoffNorm = 1.0f;
|
||||
float resonanceNorm = 0.0f;
|
||||
};
|
||||
|
||||
// Share of the last output fed back into the high-pass input at full resonance. Driven by the
|
||||
// raw control position rather than by Q: Q reaches 10, and scaling the feedback by it would
|
||||
// push the loop gain past unity at the top of the range.
|
||||
inline constexpr float kHighPassFeedbackShare = 0.24f;
|
||||
|
||||
// Below this the recursion has decayed past -600 dB. Flushing keeps the history out of the
|
||||
// subnormal range, where a ringing-out voice would otherwise stall the FPU for thousands of
|
||||
// samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
|
||||
inline constexpr float kFilterDenormalFloor = 1e-30f;
|
||||
|
||||
class VoiceFilter {
|
||||
public:
|
||||
// The instrument's output bus is permanently stereo; one history line per channel.
|
||||
static constexpr int kMaxChannels = 2;
|
||||
|
||||
struct State {
|
||||
float x1 = 0.0f;
|
||||
float x2 = 0.0f;
|
||||
float y1 = 0.0f;
|
||||
float y2 = 0.0f;
|
||||
float fb = 0.0f; // last output; the high-pass input-feedback tap
|
||||
};
|
||||
|
||||
// Recomputes coefficients from the control positions. History is deliberately preserved so
|
||||
// a live parameter move glides instead of clicking; call reset() at note-on.
|
||||
void prepare(const FilterSettings& settings, double sampleRate);
|
||||
|
||||
void reset();
|
||||
|
||||
// Hot path. `channel` must be in [0, kMaxChannels).
|
||||
float process(int channel, float x) {
|
||||
assert(channel >= 0 && channel < kMaxChannels);
|
||||
State& s = state_[channel];
|
||||
|
||||
// The high-pass numerator collapses toward zero as cutoff falls, taking the resonance
|
||||
// with it; feeding a saturated share of the last output back into the input restores
|
||||
// the character the coefficients alone stop producing down there.
|
||||
const float in = (mode_ == FilterMode::HighPass)
|
||||
? x - fbAmount_ * feedbackSaturate(s.fb * 0.9f)
|
||||
: x;
|
||||
|
||||
const float y = coeffs_.b0 * in + coeffs_.b1 * s.x1 + coeffs_.b2 * s.x2
|
||||
- coeffs_.a1 * s.y1 - coeffs_.a2 * s.y2;
|
||||
|
||||
s.x2 = s.x1;
|
||||
s.x1 = in;
|
||||
s.y2 = s.y1;
|
||||
s.y1 = y;
|
||||
|
||||
// Snap the WHOLE state once the recursion as a whole has decayed past -600 dB.
|
||||
// Zeroing individual samples instead does not work: a resonator swings through zero
|
||||
// twice a cycle, so a per-sample flush injects a step in phase with the resonance,
|
||||
// which the resonance then amplifies — the filter limit-cycles at the floor forever
|
||||
// rather than going quiet. Testing y1 AND y2 tests the envelope, not one sample.
|
||||
if (s.y1 > -kFilterDenormalFloor && s.y1 < kFilterDenormalFloor &&
|
||||
s.y2 > -kFilterDenormalFloor && s.y2 < kFilterDenormalFloor) {
|
||||
s = State{};
|
||||
}
|
||||
s.fb = s.y1;
|
||||
return y;
|
||||
}
|
||||
|
||||
void processFrame(float* samples, int channelCount) {
|
||||
assert(channelCount >= 0 && channelCount <= kMaxChannels);
|
||||
for (int c = 0; c < channelCount; ++c) samples[c] = process(c, samples[c]);
|
||||
}
|
||||
|
||||
// True once every history line has flushed to exact zero — the voice's filter has stopped
|
||||
// ringing and cannot contribute further output.
|
||||
bool isSilent() const;
|
||||
|
||||
const State& state(int channel) const {
|
||||
assert(channel >= 0 && channel < kMaxChannels);
|
||||
return state_[channel];
|
||||
}
|
||||
const BiquadCoeffs& coeffs() const { return coeffs_; }
|
||||
|
||||
private:
|
||||
BiquadCoeffs coeffs_{};
|
||||
FilterMode mode_ = FilterMode::LowPass;
|
||||
float fbAmount_ = 0.0f;
|
||||
State state_[kMaxChannels]{};
|
||||
};
|
||||
|
||||
// The port's whole point, enforced by the compiler rather than by review: the source was a
|
||||
// virtual hierarchy dispatching per channel per sample, and this type must never grow one
|
||||
// back. Trivially copyable also means nothing here is heap-owned.
|
||||
static_assert(!std::is_polymorphic_v<VoiceFilter>, "no vtable on the per-sample path");
|
||||
static_assert(std::is_trivially_copyable_v<VoiceFilter>, "state is plain values, never owned");
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
Reference in New Issue
Block a user