Polish GUI, add pattern retrig option, and rework voice DSP

This commit is contained in:
Armin 2026-08-06 15:51:56 +02:00
commit 4732240cc7
7 changed files with 546 additions and 129 deletions

View file

@ -9,6 +9,14 @@ namespace monostep
class SynthVoice
{
public:
enum class FilterType
{
lp12 = 0, hp12, bp12, notch12,
lp24, hp24, bp24, notch24,
lp48, hp48, bp48, notch48, // 48 dB/octave = "2 x 24"
numFilterTypes
};
struct Params
{
Waveform waveA = Waveform::saw;
@ -21,15 +29,22 @@ public:
float osc2Phase = 0.0f;
float cutoff = 7000.0f;
float resonance = 0.15f;
FilterType filterType = FilterType::lp12;
float attack = 0.005f;
float decay = 0.3f;
float sustain = 0.7f;
float release = 0.4f;
float fAttack = 0.005f;
float fDecay = 0.3f;
float fSustain = 0.7f;
float fRelease = 0.4f;
float fAmount = 1.0f; // filter-env cutoff modulation in octaves
float glide = 0.12f;
float master = 0.9f;
float drive = 0.0f;
float ringMod = 0.0f;
float detuneRatio = 1.0f;
float detuneRatio = 1.0f; // OSC2 (inverted detune)
float detuneRatioA = 1.0f; // OSC1 (direct detune)
float mix = 0.5f;
};
@ -42,7 +57,8 @@ public:
void prepare (double sr)
{
sampleRate = (float) sr;
accentSmoothCoef = 1.0f - std::exp (-1.0f / (0.003f * sampleRate));
accentAttackCoef = 1.0f - std::exp (-1.0f / (0.003f * sampleRate));
accentReleaseCoef = 1.0f - std::exp (-1.0f / (0.050f * sampleRate));
reset();
}
@ -51,9 +67,15 @@ public:
phaseA = phaseB = 0.0f;
currentFreq = targetFreq = 440.0f;
smoothing = 0.0f;
filterLow = filterBand = 0.0f;
for (int i = 0; i < 4; ++i)
{
filterLow[i] = 0.0f;
filterBand[i] = 0.0f;
}
env = 0.0f;
envStage = Stage::idle;
fenv = 0.0f;
fenvStage = Stage::idle;
gate = false;
accentLevel = 1.0f;
accentSmooth = 1.0f;
@ -92,6 +114,9 @@ public:
if (envStage != Stage::idle && envStage != Stage::release)
envStage = Stage::release;
if (fenvStage != Stage::idle && fenvStage != Stage::release)
fenvStage = Stage::release;
}
}
@ -109,6 +134,37 @@ private:
void retriggerEnvelope()
{
envStage = Stage::attack;
fenvStage = Stage::attack;
}
void updateFilterEnvelope()
{
const float sr = sampleRate;
switch (fenvStage)
{
case Stage::attack:
fenv += 1.0f / (params.fAttack * sr);
if (fenv >= 1.0f) { fenv = 1.0f; fenvStage = Stage::decay; }
break;
case Stage::decay:
fenv -= (1.0f - params.fSustain) / (params.fDecay * sr);
if (fenv <= params.fSustain) { fenv = params.fSustain; fenvStage = Stage::sustain; }
break;
case Stage::sustain:
fenv = params.fSustain;
break;
case Stage::release:
fenv -= 1.0f / (params.fRelease * sr);
if (fenv <= 0.0f) { fenv = 0.0f; fenvStage = Stage::idle; }
break;
case Stage::idle:
break;
}
}
void updateEnvelope()
@ -155,27 +211,83 @@ private:
}
}
float processFilter (float input)
float processFilter (float input, float cutoff)
{
const float f = 2.0f * std::sin (juce::MathConstants<float>::pi * params.cutoff / sampleRate);
const float q = 1.0f / (1.0f + params.resonance * 9.0f);
const int numStages = numFilterStages();
const float f = 2.0f * std::sin (juce::MathConstants<float>::pi * cutoff / sampleRate);
// Distribute resonance across the pole stages (each stage gets res/stages) so the
// composite resonance matches the 12 dB case instead of stacking into self-oscillation
// near Nyquist. The 12 dB case (one stage) is unchanged.
const float q = 1.0f / (1.0f + params.resonance * 9.0f / (float) numStages);
filterLow += f * filterBand;
const float high = input - filterLow - q * filterBand;
filterBand += f * high;
// Always cascade the LP response (low -> low): this keeps the integrator chain
// unconditionally stable for every pole count. The requested response (LP/HP/BP/Notch)
// is then tapped from the final stage, which preserves the 12/24/48 slope while
// avoiding the ringing/unstability of cascading raw high/band signals.
float sig = input;
float low = 0.0f, high = 0.0f, band = 0.0f;
return filterLow;
for (int s = 0; s < numStages; ++s)
{
low = filterLow[s] + f * filterBand[s]; // new low (old band)
high = sig - low - q * filterBand[s]; // new high (new low, old band)
band = filterBand[s] + f * high; // new band
// Safety clamp: prevents the resonant state from diverging into NaN when a
// high pole count is driven at max resonance near Nyquist. Normal signals
// (accents peak ~[1 + res*4]) never reach this bound.
band = juce::jlimit (-16.0f, 16.0f, band);
low = juce::jlimit (-16.0f, 16.0f, low);
filterLow[s] = low;
filterBand[s] = band;
sig = low; // cascade LP response
}
return filterTap (low, high, band); // tap from last stage
}
int numFilterStages() const
{
switch (params.filterType)
{
case FilterType::lp12: case FilterType::hp12:
case FilterType::bp12: case FilterType::notch12: return 1;
case FilterType::lp24: case FilterType::hp24:
case FilterType::bp24: case FilterType::notch24: return 2;
case FilterType::lp48: case FilterType::hp48:
case FilterType::bp48: case FilterType::notch48: return 4;
default: return 1;
}
return 1;
}
float filterTap (float low, float high, float band) const
{
switch (params.filterType)
{
case FilterType::lp12: case FilterType::lp24: case FilterType::lp48: return low;
case FilterType::hp12: case FilterType::hp24: case FilterType::hp48: return high;
case FilterType::bp12: case FilterType::bp24: case FilterType::bp48: return band;
case FilterType::notch12: case FilterType::notch24: case FilterType::notch48: return low + high;
default: return low;
}
return low;
}
float renderSample()
{
updateEnvelope();
updateFilterEnvelope();
updateGlide();
// Smooth the accent gain so it ramps in/out instead of clicking.
accentSmooth += (accentLevel - accentSmooth) * accentSmoothCoef;
// Accent gains fast attack / slow release, so the boost fades out smoothly
// at the tail of an accented note instead of cutting off hard-edged.
accentSmooth += (accentLevel - accentSmooth)
* (accentLevel > accentSmooth ? accentAttackCoef : accentReleaseCoef);
const float incA = currentFreq / sampleRate;
const float incA = (currentFreq * params.detuneRatioA) / sampleRate;
const float incB = (currentFreq * params.detuneRatio) / sampleRate;
const float a = renderWave (params.waveA, phaseA, incA);
@ -190,7 +302,9 @@ private:
out = (1.0f - params.ringMod) * out + params.ringMod * (a * b);
const float preFilter = out;
out = processFilter (out);
const float envCutoff = juce::jlimit (20.0f, 20000.0f,
params.cutoff * std::pow (2.0f, params.fAmount * fenv));
out = processFilter (out, envCutoff);
// Accented steps also get a touch of the unfiltered signal so they cut
// through the mix (presence), not just a volume bump.
@ -215,15 +329,18 @@ private:
float targetFreq = 440.0f;
float smoothing = 0.0f;
float filterLow = 0.0f;
float filterBand = 0.0f;
float filterLow[4] = {}; // SVF stage states (up to 4 poles for 48 dB)
float filterBand[4] = {};
float env = 0.0f;
Stage envStage = Stage::idle;
float fenv = 0.0f;
Stage fenvStage = Stage::idle;
bool gate = false;
float accentLevel = 1.0f;
float accentSmooth = 1.0f;
float accentSmoothCoef = 0.01f;
float accentAttackCoef = 0.0f; // fast charge when an accent starts
float accentReleaseCoef = 0.01f; // slow release -> boost fades out at note end
};
} // namespace monostep

View file

@ -103,7 +103,6 @@ inline float renderWave (Waveform w, float phase, float inc)
case Waveform::super:
{
// A small detuned stack of band-limited saws -> fat, chorus-like lead.
float sum = 0.0f;
const float detunes[3] = { -0.01f, 0.0f, 0.01f };
@ -118,8 +117,6 @@ inline float renderWave (Waveform w, float phase, float inc)
case Waveform::sah:
{
// Sample & hold: a stepped random level per fraction of the cycle.
// Deterministic (no shared state) so each oscillator is independent.
const int steps = 16;
const float f = frac (phase);
int s = static_cast<int> (std::floor (f * (float) steps));
@ -135,7 +132,6 @@ inline float renderWave (Waveform w, float phase, float inc)
case Waveform::formant:
{
// Two formant-like bands via slow AM sidebands (fixed ratios).
const float w = phase * 6.283185307179586f;
const float env = 0.5f + 0.3f * std::cos (w * 0.5f) + 0.2f * std::cos (w * 0.25f);
return std::sin (w) * env;
@ -143,7 +139,6 @@ inline float renderWave (Waveform w, float phase, float inc)
case Waveform::pwm:
{
// Fixed 20 % pulse, band-limited at both edges.
const float width = 0.2f;
const float f = frac (phase);
float v = (f < width) ? 1.0f : -1.0f;