Add updateCoefficients() that short-circuits when cutoff, resonance, and type are unchanged. Cache ladderG and formantCoefficients so processSample() avoids redundant exp()/tan() calls per sample. Extract static getCutoffHz() from Filter::process() so FilterBank can compute the keytracked cutoff once per parallel branch instead of per sample. Simplify formant() and screamer() signatures to use cached state.
300 lines
9.1 KiB
C++
300 lines
9.1 KiB
C++
#include "Filter.h"
|
|
|
|
namespace serum
|
|
{
|
|
|
|
namespace
|
|
{
|
|
constexpr double kPi = juce::MathConstants<double>::pi;
|
|
|
|
inline float clampF (float v, float lo, float hi) noexcept
|
|
{
|
|
return v < lo ? lo : (v > hi ? hi : v);
|
|
}
|
|
|
|
inline double clampD (double v, double lo, double hi) noexcept
|
|
{
|
|
return v < lo ? lo : (v > hi ? hi : v);
|
|
}
|
|
}
|
|
|
|
void Filter::prepare (double sampleRate, int maxBlockSize)
|
|
{
|
|
sr = sampleRate;
|
|
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
|
|
combWrite = 0;
|
|
combDamp = 0.0;
|
|
reset();
|
|
(void) maxBlockSize;
|
|
}
|
|
|
|
void Filter::reset()
|
|
{
|
|
lastCutoffHz = lastRes = -1.0f;
|
|
lastType = -1;
|
|
ladderG = 0.0;
|
|
ic1eq = ic2eq = 0.0;
|
|
lastG = lastK = 0.0;
|
|
a1 = a2 = a3 = 0.0;
|
|
stage.fill (0.0);
|
|
for (auto& s : formantState) s.fill (0.0);
|
|
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
|
combWrite = 0;
|
|
combDamp = 0.0;
|
|
}
|
|
|
|
void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
|
{
|
|
if (cutoffHz == lastCutoffHz && res == lastRes && type == lastType)
|
|
return;
|
|
lastCutoffHz = cutoffHz;
|
|
lastRes = res;
|
|
lastType = type;
|
|
|
|
// Ladder stages are cascaded one-poles, which need an exponential coefficient
|
|
// (always in (0,1]) for unconditional stability. The TPT SVF (formant/screamer)
|
|
// uses cached tan()-based coefficients instead.
|
|
switch ((FilterModel) type)
|
|
{
|
|
case FilterModel::LadderLP:
|
|
case FilterModel::LadderHP:
|
|
case FilterModel::LadderBP:
|
|
case FilterModel::Diode:
|
|
{
|
|
const double fc = clampD (cutoffHz, 20.0, sr * 0.45);
|
|
ladderG = 1.0 - std::exp (-2.0 * kPi * fc / sr);
|
|
break;
|
|
}
|
|
case FilterModel::Formant:
|
|
{
|
|
// morph (0..1) sweeps the three bandpass centres to produce vowel-like spectra.
|
|
const double m = clampD (maps::hzToCutoff (cutoffHz), 0.0, 1.0);
|
|
const double base[3] = { 400.0, 1200.0, 2600.0 };
|
|
const double k = clampD (2.0 * (1.0 - (double) res), 0.05, 2.0);
|
|
for (int i = 0; i < 3; ++i)
|
|
{
|
|
const double fc = base[i] * (0.7 + 1.6 * m) * (i == 2 ? 0.9 : 1.0);
|
|
const double g = std::tan (kPi * clampD (fc, 30.0, sr * 0.45) / sr);
|
|
auto& c = formantCoefficients[(size_t) i];
|
|
c[0] = 1.0 / (1.0 + g * (g + k));
|
|
c[1] = g * c[0];
|
|
c[2] = g * c[1];
|
|
}
|
|
break;
|
|
}
|
|
case FilterModel::Screamer:
|
|
{
|
|
const double g = std::tan (kPi * clampD (cutoffHz, 30.0, sr * 0.45) / sr);
|
|
const double k = clampD (2.0 * (1.0 - (double) res), 0.05, 2.0);
|
|
updateSvf (g, k);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void Filter::updateSvf (double g, double k) noexcept
|
|
{
|
|
if (g == lastG && k == lastK)
|
|
return;
|
|
lastG = g;
|
|
lastK = k;
|
|
a1 = 1.0 / (1.0 + g * (g + k));
|
|
a2 = g * a1;
|
|
a3 = g * a2;
|
|
}
|
|
|
|
double Filter::svfLow (double in, double g, double k) noexcept
|
|
{
|
|
updateSvf (g, k);
|
|
const double v3 = in - ic2eq;
|
|
const double v1 = a1 * ic1eq + a2 * v3;
|
|
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
|
ic1eq = 2.0 * v1 - ic1eq;
|
|
ic2eq = 2.0 * v2 - ic2eq;
|
|
return v2;
|
|
}
|
|
|
|
double Filter::svfBand (double in, double g, double k) noexcept
|
|
{
|
|
updateSvf (g, k);
|
|
const double v3 = in - ic2eq;
|
|
const double v1 = a1 * ic1eq + a2 * v3;
|
|
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
|
ic1eq = 2.0 * v1 - ic1eq;
|
|
ic2eq = 2.0 * v2 - ic2eq;
|
|
return v1;
|
|
}
|
|
|
|
double Filter::svfHigh (double in, double g, double k) noexcept
|
|
{
|
|
updateSvf (g, k);
|
|
const double v3 = in - ic2eq;
|
|
const double v1 = a1 * ic1eq + a2 * v3;
|
|
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
|
ic1eq = 2.0 * v1 - ic1eq;
|
|
ic2eq = 2.0 * v2 - ic2eq;
|
|
return in - k * v1 - v2;
|
|
}
|
|
|
|
double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept
|
|
{
|
|
stages = juce::jlimit (1, 4, stages);
|
|
res = clampD (res, 0.0, 0.97);
|
|
|
|
double x;
|
|
if (diode)
|
|
{
|
|
// Diode ladder: softer, asymmetric feedback and diode clipping.
|
|
x = in - 3.0 * res * (stage[(size_t) (stages - 1)] - in * 0.5);
|
|
x = x / (1.0 + std::abs (x)); // diode curve
|
|
x = x * (1.0 + drive * 3.0);
|
|
}
|
|
else
|
|
{
|
|
x = in - 4.0 * res * stage[(size_t) (stages - 1)];
|
|
x = std::tanh (x * (1.0 + drive * 6.0)); // Moog-ish input saturation
|
|
}
|
|
|
|
for (int i = 0; i < stages; ++i)
|
|
{
|
|
stage[(size_t) i] += g * (x - stage[(size_t) i]);
|
|
x = stage[(size_t) i];
|
|
}
|
|
|
|
x = clampD (x, -8.0, 8.0);
|
|
return x;
|
|
}
|
|
|
|
double Filter::comb (double in, double freqHz, double res, double drive) noexcept
|
|
{
|
|
const int len = (int) combLine.size();
|
|
if (len < 4)
|
|
return in;
|
|
|
|
const double freq = clampD (freqHz, 20.0, sr * 0.45);
|
|
double delay = sr / freq;
|
|
delay = clampD (delay, 2.0, (double) len - 2.0);
|
|
|
|
int readPos = combWrite - (int) delay;
|
|
if (readPos < 0) readPos += len;
|
|
const int readPos2 = (readPos + 1) % len;
|
|
const float frac = (float) (delay - std::floor (delay));
|
|
|
|
const float y0 = combLine[(size_t) readPos];
|
|
const float y1 = combLine[(size_t) readPos2];
|
|
float y = y0 + (y1 - y0) * frac;
|
|
|
|
// Damping lowpass in the feedback path (drive controls damping).
|
|
const double dampCoef = 1.0 - clampD (drive, 0.0, 0.99);
|
|
combDamp = y * (1.0 - dampCoef) + combDamp * dampCoef;
|
|
|
|
const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f);
|
|
combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback;
|
|
combWrite = (combWrite + 1) % len;
|
|
|
|
return (double) y;
|
|
}
|
|
|
|
double Filter::formant (double in) noexcept
|
|
{
|
|
double out = 0.0;
|
|
const double gains[3] = { 1.0, 0.8, 0.5 };
|
|
for (int i = 0; i < 3; ++i)
|
|
{
|
|
const auto& c = formantCoefficients[(size_t) i];
|
|
const double a1 = c[0];
|
|
const double a2 = c[1];
|
|
const double a3 = c[2];
|
|
const double v3 = in - formantState[(size_t) i][1];
|
|
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3;
|
|
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3;
|
|
formantState[(size_t) i][0] = 2.0 * v1 - formantState[(size_t) i][0];
|
|
formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1];
|
|
out += v1 * gains[i];
|
|
}
|
|
return clampD (out * 0.5, -8.0, 8.0);
|
|
}
|
|
|
|
double Filter::screamer (double in, double drive) noexcept
|
|
{
|
|
const double band = svfBand (in, lastG, lastK);
|
|
const double driven = std::tanh (band * (1.0 + drive * 12.0));
|
|
return driven * (1.0 - drive * 0.4);
|
|
}
|
|
|
|
float Filter::processSample (float in, float cutoffHz, float res, float drive, int type, int slope) noexcept
|
|
{
|
|
res = clampF (res, 0.0f, 0.98f);
|
|
drive = clampF (drive, 0.0f, 1.0f);
|
|
|
|
updateCoefficients (cutoffHz, res, type);
|
|
const double g = ladderG;
|
|
double out = (double) in;
|
|
|
|
switch ((FilterModel) type)
|
|
{
|
|
case FilterModel::LadderLP:
|
|
{
|
|
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
|
|
out = ladder (in, g, res, drive, stages, false);
|
|
break;
|
|
}
|
|
case FilterModel::LadderHP:
|
|
{
|
|
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
|
|
out = (double) in - ladder (in, g, res, drive, stages, false);
|
|
break;
|
|
}
|
|
case FilterModel::LadderBP:
|
|
{
|
|
int stages = (slope == 0) ? 2 : (slope == 1) ? 2 : 4;
|
|
out = ladder (in, g, res, drive, stages, false);
|
|
out = stage[0] - stage[(size_t) (stages - 1)];
|
|
break;
|
|
}
|
|
case FilterModel::Diode:
|
|
{
|
|
const int stages = (slope == 0) ? 1 : (slope == 1) ? 2 : 4;
|
|
out = ladder (in, g, res, drive, stages, true);
|
|
break;
|
|
}
|
|
case FilterModel::Comb:
|
|
out = comb (in, cutoffHz, res, drive);
|
|
break;
|
|
case FilterModel::Formant:
|
|
out = formant (in);
|
|
break;
|
|
case FilterModel::Screamer:
|
|
out = screamer (in, drive);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return (float) clampD (out, -8.0, 8.0);
|
|
}
|
|
|
|
float Filter::getCutoffHz (float cutoffNorm, float keytrack, float noteHz) noexcept
|
|
{
|
|
// Keytrack shifts the cutoff with note pitch.
|
|
const float noteNumber = (noteHz > 0.0f) ? (69.0f + 12.0f * std::log2f (noteHz / 440.0f)) : 60.0f;
|
|
const float baseHz = maps::cutoffToHz (cutoffNorm);
|
|
const float keyFactor = std::pow (2.0f, keytrack * (noteNumber - 60.0f) / 12.0f);
|
|
return clampF (baseHz * keyFactor, 20.0f, 18000.0f);
|
|
}
|
|
|
|
void Filter::process (float* samples, int numSamples, float cutoffNorm, float res,
|
|
float drive, float keytrack, float noteHz, int type, int slope) noexcept
|
|
{
|
|
if (samples == nullptr || numSamples <= 0)
|
|
return;
|
|
|
|
const float cutoffHz = getCutoffHz (cutoffNorm, keytrack, noteHz);
|
|
for (int i = 0; i < numSamples; ++i)
|
|
samples[i] = processSample (samples[i], cutoffHz, res, drive, type, slope);
|
|
}
|
|
|
|
} // namespace serum
|