mirror of
https://codeberg.org/armin/monostep.git
synced 2026-09-01 04:10:46 +02:00
- Add delay and reverb Dry/Wet parameters and knobs, wired into presets - Add Accent/Slide/Fine/Rate toggles to the Randomize panel with new per-aspect pattern randomizers - Soften accent: modest volume lift plus small cutoff and filter-attack modulation instead of the heavy 5x gain boost - Halve reverb comb/allpass delays so the wet speaks sooner - Regenerate a fresh build stamp on every build
370 lines
12 KiB
C++
370 lines
12 KiB
C++
#pragma once
|
|
|
|
#include <JuceHeader.h>
|
|
#include "WaveTables.h"
|
|
|
|
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;
|
|
Waveform waveB = Waveform::square;
|
|
float osc1Coarse = 0.0f;
|
|
float osc2Coarse = 0.0f;
|
|
float osc1Fine = 0.0f;
|
|
float osc2Fine = 0.0f;
|
|
float osc1Phase = 0.0f;
|
|
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; // OSC2 (inverted detune)
|
|
float detuneRatioA = 1.0f; // OSC1 (direct detune)
|
|
float mix = 0.5f;
|
|
};
|
|
|
|
void addPhase (float phaseOffset)
|
|
{
|
|
phaseA = frac (phaseA + phaseOffset);
|
|
phaseB = frac (phaseB + phaseOffset);
|
|
}
|
|
|
|
void prepare (double sr)
|
|
{
|
|
sampleRate = (float) sr;
|
|
accentAttackCoef = 1.0f - std::exp (-1.0f / (0.003f * sampleRate));
|
|
accentReleaseCoef = 1.0f - std::exp (-1.0f / (0.050f * sampleRate));
|
|
reset();
|
|
}
|
|
|
|
void reset()
|
|
{
|
|
phaseA = phaseB = 0.0f;
|
|
currentFreq = targetFreq = 440.0f;
|
|
smoothing = 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;
|
|
}
|
|
|
|
void setParams (const Params& p) { params = p; }
|
|
|
|
bool isActive() const { return gate; }
|
|
|
|
void setAccentLevel (float level) { accentLevel = level; }
|
|
|
|
void noteOn (float freqHz, bool legato)
|
|
{
|
|
targetFreq = freqHz;
|
|
|
|
if (gate && legato)
|
|
{
|
|
const float time = std::max (params.glide, 0.001f);
|
|
smoothing = 1.0f - std::exp (-1.0f / (time * sampleRate));
|
|
}
|
|
else
|
|
{
|
|
currentFreq = targetFreq;
|
|
smoothing = 0.0f;
|
|
retriggerEnvelope();
|
|
}
|
|
|
|
gate = true;
|
|
}
|
|
|
|
void noteOff()
|
|
{
|
|
if (gate)
|
|
{
|
|
gate = false;
|
|
|
|
if (envStage != Stage::idle && envStage != Stage::release)
|
|
envStage = Stage::release;
|
|
|
|
if (fenvStage != Stage::idle && fenvStage != Stage::release)
|
|
fenvStage = Stage::release;
|
|
}
|
|
}
|
|
|
|
void render (juce::AudioBuffer<float>& buffer, int numSamples)
|
|
{
|
|
auto* out = buffer.getWritePointer (0);
|
|
|
|
for (int i = 0; i < numSamples; ++i)
|
|
out[i] = renderSample();
|
|
}
|
|
|
|
private:
|
|
enum class Stage { idle, attack, decay, sustain, release };
|
|
|
|
void retriggerEnvelope()
|
|
{
|
|
envStage = Stage::attack;
|
|
fenvStage = Stage::attack;
|
|
}
|
|
|
|
// RC-style (exponential) filter envelope: the cutoff glides smoothly instead
|
|
// of stepping linearly, so fast filter sweeps sound analog rather than
|
|
// zippered. Each stage still reaches ~95% within the labelled time, so the
|
|
// knob values keep their musical meaning.
|
|
void updateFilterEnvelope()
|
|
{
|
|
const float sr = sampleRate;
|
|
|
|
switch (fenvStage)
|
|
{
|
|
case Stage::attack:
|
|
{
|
|
// Accented notes attack the filter ever so slightly faster.
|
|
const float acc = accentSmooth - 1.0f;
|
|
const float t = juce::jmax (0.0005f, params.fAttack / (1.0f + acc * 0.5f));
|
|
const float a = 1.0f - std::exp (-3.0f / (t * sr));
|
|
fenv += (1.0f - fenv) * a;
|
|
if (fenv >= 0.999f) { fenv = 1.0f; fenvStage = Stage::decay; }
|
|
break;
|
|
}
|
|
|
|
case Stage::decay:
|
|
{
|
|
const float t = juce::jmax (0.0005f, params.fDecay);
|
|
const float a = 1.0f - std::exp (-3.0f / (t * sr));
|
|
fenv += (params.fSustain - fenv) * a;
|
|
if (std::fabs (fenv - params.fSustain) < 0.001f) { fenv = params.fSustain; fenvStage = Stage::sustain; }
|
|
break;
|
|
}
|
|
|
|
case Stage::sustain:
|
|
fenv = params.fSustain;
|
|
break;
|
|
|
|
case Stage::release:
|
|
{
|
|
const float t = juce::jmax (0.0005f, params.fRelease);
|
|
const float a = 1.0f - std::exp (-3.0f / (t * sr));
|
|
fenv -= fenv * a;
|
|
if (fenv <= 0.001f) { fenv = 0.0f; fenvStage = Stage::idle; }
|
|
break;
|
|
}
|
|
|
|
case Stage::idle:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void updateEnvelope()
|
|
{
|
|
const float sr = sampleRate;
|
|
|
|
switch (envStage)
|
|
{
|
|
case Stage::attack:
|
|
env += 1.0f / (params.attack * sr);
|
|
if (env >= 1.0f) { env = 1.0f; envStage = Stage::decay; }
|
|
break;
|
|
|
|
case Stage::decay:
|
|
env -= (1.0f - params.sustain) / (params.decay * sr);
|
|
if (env <= params.sustain) { env = params.sustain; envStage = Stage::sustain; }
|
|
break;
|
|
|
|
case Stage::sustain:
|
|
env = params.sustain;
|
|
break;
|
|
|
|
case Stage::release:
|
|
env -= 1.0f / (params.release * sr);
|
|
if (env <= 0.0f) { env = 0.0f; envStage = Stage::idle; }
|
|
break;
|
|
|
|
case Stage::idle:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void updateGlide()
|
|
{
|
|
if (smoothing > 0.0f)
|
|
{
|
|
currentFreq += (targetFreq - currentFreq) * smoothing;
|
|
|
|
if (std::fabs (targetFreq - currentFreq) < 0.01f)
|
|
{
|
|
currentFreq = targetFreq;
|
|
smoothing = 0.0f;
|
|
}
|
|
}
|
|
}
|
|
|
|
float processFilter (float input, float cutoff)
|
|
{
|
|
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);
|
|
|
|
// 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;
|
|
|
|
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()
|
|
{
|
|
// 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);
|
|
|
|
// 0 for unaccented steps, rising with the accent knob on accented ones.
|
|
const float accBoost = accentSmooth - 1.0f;
|
|
|
|
updateEnvelope();
|
|
updateFilterEnvelope();
|
|
updateGlide();
|
|
|
|
const float incA = (currentFreq * params.detuneRatioA) / sampleRate;
|
|
const float incB = (currentFreq * params.detuneRatio) / sampleRate;
|
|
|
|
const float a = renderWave (params.waveA, phaseA, incA);
|
|
const float b = renderWave (params.waveB, phaseB, incB);
|
|
|
|
phaseA = frac (phaseA + incA);
|
|
phaseB = frac (phaseB + incB);
|
|
|
|
float out = (1.0f - params.mix) * a + params.mix * b;
|
|
|
|
if (params.ringMod > 0.0f)
|
|
out = (1.0f - params.ringMod) * out + params.ringMod * (a * b);
|
|
|
|
const float preFilter = out;
|
|
|
|
// Accented steps open the filter a tiny bit, so they sparkle without
|
|
// relying on a big volume jump.
|
|
const float envCutoff = juce::jlimit (20.0f, 20000.0f,
|
|
params.cutoff * std::pow (2.0f, params.fAmount * fenv + accBoost * 1.5f));
|
|
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.
|
|
if (accBoost > 0.0f)
|
|
out += accBoost * 0.2f * preFilter;
|
|
|
|
if (params.drive > 0.0f)
|
|
{
|
|
const float wet = std::tanh (out * (1.0f + params.drive * 9.0f));
|
|
out = (1.0f - params.drive) * out + params.drive * wet;
|
|
}
|
|
|
|
return out * env * params.master * accentSmooth;
|
|
}
|
|
|
|
float sampleRate = 44100.0f;
|
|
Params params;
|
|
|
|
float phaseA = 0.0f;
|
|
float phaseB = 0.0f;
|
|
float currentFreq = 440.0f;
|
|
float targetFreq = 440.0f;
|
|
float smoothing = 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 accentAttackCoef = 0.0f; // fast charge when an accent starts
|
|
float accentReleaseCoef = 0.01f; // slow release -> boost fades out at note end
|
|
};
|
|
|
|
} // namespace monostep
|