#pragma once #include #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& 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; } 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() { 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::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() { updateEnvelope(); updateFilterEnvelope(); updateGlide(); // 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 * 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; 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. if (accentSmooth > 1.0f) out += (accentSmooth - 1.0f) * 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