From fc9d16b30258c4f930046ee3edc40f974bef2be2 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Tue, 8 Sep 2026 14:55:21 +0200 Subject: [PATCH] feat(dsp): add core synthesizer oscillators, wavetable and voice engine --- Source/Engine.cpp | 320 ++++++++++++++++++++++++++++++++++ Source/Engine.h | 67 +++++++ Source/NoiseOscillator.cpp | 36 ++++ Source/NoiseOscillator.h | 29 ++++ Source/Oscillator.cpp | 133 ++++++++++++++ Source/Oscillator.h | 73 ++++++++ Source/SubOscillator.cpp | 39 +++++ Source/SubOscillator.h | 29 ++++ Source/SynthVoice.cpp | 220 +++++++++++++++++++++++ Source/SynthVoice.h | 98 +++++++++++ Source/Wavetable.cpp | 348 +++++++++++++++++++++++++++++++++++++ Source/Wavetable.h | 81 +++++++++ 12 files changed, 1473 insertions(+) create mode 100644 Source/Engine.cpp create mode 100644 Source/Engine.h create mode 100644 Source/NoiseOscillator.cpp create mode 100644 Source/NoiseOscillator.h create mode 100644 Source/Oscillator.cpp create mode 100644 Source/Oscillator.h create mode 100644 Source/SubOscillator.cpp create mode 100644 Source/SubOscillator.h create mode 100644 Source/SynthVoice.cpp create mode 100644 Source/SynthVoice.h create mode 100644 Source/Wavetable.cpp create mode 100644 Source/Wavetable.h diff --git a/Source/Engine.cpp b/Source/Engine.cpp new file mode 100644 index 0000000..e5b7849 --- /dev/null +++ b/Source/Engine.cpp @@ -0,0 +1,320 @@ +#include "Engine.h" + +namespace serum +{ + +namespace +{ + inline float v (juce::AudioProcessorValueTreeState& apvts, const char* id) + { + if (auto* p = apvts.getRawParameterValue (id)) + return p->load(); + return 0.0f; + } + + inline int vic (juce::AudioProcessorValueTreeState& apvts, const char* id, int maxValue) + { + return juce::jlimit (0, maxValue, (int) std::llround (v (apvts, id) * maxValue)); + } + + inline float limit (float x) noexcept + { + const float ax = std::fabs (x); + if (ax < 0.8f) + return x; + const float over = ax - 0.8f; + const float clipped = 0.8f + std::tanh (over) * 0.2f; + return std::copysign (clipped, x); + } + + const char* kEnvAttack[kNumEnvelopes] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A }; + const char* kEnvDecay[kNumEnvelopes] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D }; + const char* kEnvSustain[kNumEnvelopes]= { ids::env1S, ids::env2S, ids::env3S, ids::env4S }; + const char* kEnvRelease[kNumEnvelopes]= { ids::env1R, ids::env2R, ids::env3R, ids::env4R }; + const char* kEnvCurve[kNumEnvelopes] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve }; + + const char* kLfoRate[kNumLfos] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate }; + const char* kLfoSync[kNumLfos] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync }; + const char* kLfoBeat[kNumLfos] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat }; + const char* kLfoShape[kNumLfos] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape }; + const char* kLfoPhase[kNumLfos] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase }; + const char* kLfoFade[kNumLfos] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade }; + const char* kLfoDelay[kNumLfos] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay }; + + const char* kFxType[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type, + ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type }; + const char* kFxMix[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix, + ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix }; + const char* kFxP1[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1, + ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 }; + const char* kFxP2[kNumFxSlots] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2, + ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 }; + const char* kFxP3[kNumFxSlots] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3, + ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 }; + const char* kFxP4[kNumFxSlots] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4, + ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 }; +} + +void Engine::prepare (double sampleRate, int maxBlockSize) +{ + sr = sampleRate; + blockSize = maxBlockSize; + + for (auto& voice : voices) + voice.prepare (sampleRate, maxBlockSize); + for (auto& lfo : lfos) + lfo.prepare (sampleRate); + fx.prepare (sampleRate, maxBlockSize); + mixBuffer.setSize (2, maxBlockSize, false, false, true); + reset(); +} + +void Engine::reset() +{ + for (auto& voice : voices) + voice.reset(); + for (auto& lfo : lfos) + lfo.reset(); + fx.reset(); + pitchBend = 0.0f; + modWheel = 0.0f; + mixBuffer.clear(); +} + +void Engine::setLfoShapeData (int index, const std::vector& data, int steps) +{ + index = juce::jlimit (0, kNumLfos - 1, index); + lfos[(size_t) index].setShapeData (data, steps); +} + +int Engine::getActiveVoiceCount() const +{ + int count = 0; + for (const auto& v : voices) + if (v.isActive()) + ++count; + return count; +} + +SynthVoice* Engine::findFreeVoice() +{ + for (auto& v : voices) + if (! v.isActive()) + return &v; + return nullptr; +} + +SynthVoice* Engine::stealVoice() +{ + // Prefer stealing an already-released voice, then the oldest active one. + SynthVoice* best = nullptr; + juce::uint64 bestId = std::numeric_limits::max(); + + for (auto& v : voices) + if (v.isActive() && v.isReleased() && v.getNoteId() < bestId) + { + best = &v; + bestId = v.getNoteId(); + } + if (best != nullptr) + return best; + + for (auto& v : voices) + if (v.isActive() && v.getNoteId() < bestId) + { + best = &v; + bestId = v.getNoteId(); + } + return best != nullptr ? best : &voices[0]; +} + +void Engine::noteOn (int noteNumber, float velocity01) +{ + SynthVoice* voice = findFreeVoice(); + if (voice == nullptr) + voice = stealVoice(); + + const double freq = juce::MidiMessage::getMidiNoteInHertz (noteNumber); + voice->noteOn (noteNumber, juce::jlimit (0.0f, 1.0f, velocity01), freq, (juce::uint32) (++noteCounter)); +} + +void Engine::noteOff (int noteNumber) +{ + for (auto& v : voices) + if (v.isActive() && v.getNote() == noteNumber && ! v.isReleased()) + v.noteOff(); +} + +void Engine::allNotesOff() +{ + for (auto& v : voices) + if (v.isActive()) + v.noteOff(); +} + +void Engine::readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& o) +{ + const juce::String p = prefix; + auto g = [&] (const char* suffix) { return v (apvts, (p + suffix).toRawUTF8()); }; + + o.enabled = g ("On") > 0.5f; + o.wave = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (g ("Wave") * (kNumWavetables - 1))); + o.wtPos = g ("WtPos"); + o.warp = (int) std::llround (g ("Warp") * 7.0f); + o.warpAmt = g ("WarpAmt"); + o.coarse = (int) std::llround (g ("Coarse") * 48.0f) - 24; + o.fine = (int) std::llround (g ("Fine") * 200.0f) - 100; + o.level = g ("Level"); + o.pan = g ("Pan") * 2.0f - 1.0f; + o.unison = 1 + (int) std::llround (g ("Unison") * 15.0f); + o.detune = g ("Detune"); + o.spread = g ("Spread"); + o.phase = g ("Phase"); + o.randPhase = g ("RandPh"); +} + +void Engine::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midi, + juce::AudioProcessorValueTreeState& apvts, + juce::AudioPlayHead* playhead) +{ + const int n = buffer.getNumSamples(); + const int numCh = buffer.getNumChannels(); + + // Tempo. + if (playhead != nullptr) + if (auto pos = playhead->getPosition()) + if (auto b = pos->getBpm()) + bpm = *b; + + // MIDI. + for (const auto meta : midi) + { + const auto m = meta.getMessage(); + if (m.isNoteOn() && m.getVelocity() > 0) + noteOn (m.getNoteNumber(), m.getFloatVelocity()); + else if (m.isNoteOff() || (m.isNoteOn() && m.getVelocity() == 0)) + noteOff (m.getNoteNumber()); + else if (m.isPitchWheel()) + pitchBend = (m.getPitchWheelValue() - 8192) / 8192.0f; + else if (m.isController()) + { + if (m.getControllerNumber() == 1) + modWheel = m.getControllerValue() / 127.0f; + else if (m.getControllerNumber() == 120 || m.getControllerNumber() == 123) + allNotesOff(); + } + else if (m.isAllNotesOff() || m.isAllSoundOff()) + allNotesOff(); + } + + // Prepare the voice mix buffer. + mixBuffer.setSize (2, n, false, false, true); + mixBuffer.clear(); + float* mixL = mixBuffer.getWritePointer (0); + float* mixR = mixBuffer.getWritePointer (1); + + // Advance LFOs and capture their values (control rate). + float lfoValues[kNumLfos]; + for (int i = 0; i < kNumLfos; ++i) + { + lfos[(size_t) i].setTempo (bpm); + lfos[(size_t) i].setParams (v (apvts, kLfoRate[i]), + v (apvts, kLfoSync[i]) > 0.5f, + v (apvts, kLfoBeat[i]), + vic (apvts, kLfoShape[i], 6), + v (apvts, kLfoPhase[i]), + v (apvts, kLfoFade[i]), + v (apvts, kLfoDelay[i])); + for (int s = 0; s < n; ++s) + lfos[(size_t) i].process(); + lfoValues[i] = lfos[(size_t) i].getValue(); + } + + const float macroValues[kNumMacros] = { v (apvts, ids::macro1), v (apvts, ids::macro2), + v (apvts, ids::macro3), v (apvts, ids::macro4) }; + + // Build the render context. + RenderContext ctx; + ctx.sampleRate = sr; + ctx.wavetables = &wavetables; + for (int i = 0; i < kNumLfos; ++i) ctx.lfoValues[i] = lfoValues[i]; + for (int i = 0; i < kNumMacros; ++i) ctx.macroValues[i] = macroValues[i]; + ctx.modWheel = modWheel; + ctx.pitchBend = pitchBend; + ctx.pitchBendRange = 2.0f; + ctx.matrix = &matrix; + ctx.macros = ¯os; + + readOscParams (apvts, "oscA", ctx.oscA); + readOscParams (apvts, "oscB", ctx.oscB); + + ctx.subOn = v (apvts, ids::subOn) > 0.5f; + ctx.subShape = vic (apvts, ids::subShape, 1); + ctx.subOct = vic (apvts, ids::subOct, 2) - 2; + ctx.subLevel = v (apvts, ids::subLevel); + + ctx.noiseOn = v (apvts, ids::noiseOn) > 0.5f; + ctx.noiseType = vic (apvts, ids::noiseType, 1); + ctx.noiseLevel = v (apvts, ids::noiseLevel); + + ctx.filters.f1On = v (apvts, ids::f1On) > 0.5f; + ctx.filters.f1Type = vic (apvts, ids::f1Type, 6); + ctx.filters.f1Cutoff = v (apvts, ids::f1Cutoff); + ctx.filters.f1Res = v (apvts, ids::f1Res); + ctx.filters.f1Drive = v (apvts, ids::f1Drive); + ctx.filters.f1Key = v (apvts, ids::f1Key); + ctx.filters.f1Slope = vic (apvts, ids::f1Slope, 2); + + ctx.filters.f2On = v (apvts, ids::f2On) > 0.5f; + ctx.filters.f2Type = vic (apvts, ids::f2Type, 6); + ctx.filters.f2Cutoff = v (apvts, ids::f2Cutoff); + ctx.filters.f2Res = v (apvts, ids::f2Res); + ctx.filters.f2Drive = v (apvts, ids::f2Drive); + ctx.filters.f2Key = v (apvts, ids::f2Key); + ctx.filters.f2Slope = vic (apvts, ids::f2Slope, 2); + + ctx.filters.route = vic (apvts, ids::fRoute, 2); + ctx.filters.mix = v (apvts, ids::fMix); + ctx.filters.out = v (apvts, ids::fOut) * 1.5f; + + for (int i = 0; i < kNumEnvelopes; ++i) + { + ctx.envAttack[i] = v (apvts, kEnvAttack[i]); + ctx.envDecay[i] = v (apvts, kEnvDecay[i]); + ctx.envSustain[i] = v (apvts, kEnvSustain[i]); + ctx.envRelease[i] = v (apvts, kEnvRelease[i]); + ctx.envCurve[i] = v (apvts, kEnvCurve[i]); + } + + // Render all active voices into the mix buffer. + for (auto& voice : voices) + if (voice.isActive()) + voice.render (mixL, mixR, n, ctx); + + // FX rack. + FxSlotParams slots[kNumFxSlots]; + for (int i = 0; i < kNumFxSlots; ++i) + { + slots[i].type = juce::jlimit (0, (int) FxType::Count - 1, (int) std::llround (v (apvts, kFxType[i]) * ((int) FxType::Count - 1))); + slots[i].mix = v (apvts, kFxMix[i]); + slots[i].p[0] = v (apvts, kFxP1[i]); + slots[i].p[1] = v (apvts, kFxP2[i]); + slots[i].p[2] = v (apvts, kFxP3[i]); + slots[i].p[3] = v (apvts, kFxP4[i]); + } + + fx.process (mixBuffer, slots, kNumFxSlots); + + // Master + soft limiting. + const float master = v (apvts, ids::master); + + for (int ch = 0; ch < numCh; ++ch) + { + float* dest = buffer.getWritePointer (ch); + const float* src = mixBuffer.getReadPointer (ch < 2 ? ch : 0); + for (int i = 0; i < n; ++i) + dest[i] = limit (src[i] * master); + } +} + +} // namespace serum diff --git a/Source/Engine.h b/Source/Engine.h new file mode 100644 index 0000000..322a6c7 --- /dev/null +++ b/Source/Engine.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include "Params.h" +#include "Wavetable.h" +#include "SynthVoice.h" +#include "LFO.h" +#include "FXProcessor.h" +#include "ModulationMatrix.h" +#include "MacroControls.h" + +namespace serum +{ + +// =========================================================================== +// The synthesis engine: a fixed pool of voices, four global LFOs, a wavetable +// library and the FX rack. Owns all per-block DSP and MIDI handling. +// =========================================================================== +class Engine +{ +public: + void prepare (double sampleRate, int blockSize); + void reset(); + + void processBlock (juce::AudioBuffer& buffer, + juce::MidiBuffer& midi, + juce::AudioProcessorValueTreeState& apvts, + juce::AudioPlayHead* playhead); + + // MIDI + void noteOn (int noteNumber, float velocity01); + void noteOff (int noteNumber); + void allNotesOff(); + + // Modulation / DSP accessors (read-only for the GUI). + ModulationMatrix& getMatrix() { return matrix; } + MacroControls& getMacros() { return macros; } + WavetableLibrary& getWavetables() { return wavetables; } + const std::array& getLfos() const { return lfos; } + void setLfoShapeData (int index, const std::vector& data, int steps); + + int getActiveVoiceCount() const; + +private: + std::array voices; + std::array lfos; + WavetableLibrary wavetables; + FXProcessor fx; + ModulationMatrix matrix; + MacroControls macros; + + double sr = 44100.0; + int blockSize = 512; + double bpm = 120.0; + float pitchBend = 0.0f; + float modWheel = 0.0f; + juce::uint64 noteCounter = 0; + + juce::AudioBuffer mixBuffer; + + SynthVoice* findFreeVoice(); + SynthVoice* stealVoice(); + + void readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& out); +}; + +} // namespace serum diff --git a/Source/NoiseOscillator.cpp b/Source/NoiseOscillator.cpp new file mode 100644 index 0000000..f4f5cfa --- /dev/null +++ b/Source/NoiseOscillator.cpp @@ -0,0 +1,36 @@ +#include "NoiseOscillator.h" + +namespace serum +{ + +void NoiseOscillator::reset() +{ + pinkB0 = pinkB1 = pinkB2 = 0.0f; +} + +void NoiseOscillator::noteOn (juce::uint32 seed) +{ + rng.setSeed (seed); +} + +void NoiseOscillator::processAdd (int type, float level, float& out) noexcept +{ + if (level <= 0.0f) + return; + + const float white = rng.nextFloat() * 2.0f - 1.0f; + + float s = white; + if (type == (int) NoiseType::Pink) + { + // Paul Kellet's economy pink-noise filter. + pinkB0 = 0.99765f * pinkB0 + white * 0.0990460f; + pinkB1 = 0.96300f * pinkB1 + white * 0.2965164f; + pinkB2 = 0.57000f * pinkB2 + white * 1.0526913f; + s = pinkB0 + pinkB1 + pinkB2 + white * 0.1848f; + } + + out += s * level; +} + +} // namespace serum diff --git a/Source/NoiseOscillator.h b/Source/NoiseOscillator.h new file mode 100644 index 0000000..affc5a9 --- /dev/null +++ b/Source/NoiseOscillator.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include "Params.h" + +namespace serum +{ + +// =========================================================================== +// Per-voice white/pink noise generator. +// =========================================================================== +class NoiseOscillator +{ +public: + void prepare (double sampleRate) { sr = sampleRate; reset(); } + void reset(); + + void noteOn (juce::uint32 seed); + + // Accumulate into out. + void processAdd (int type, float level, float& out) noexcept; + +private: + double sr = 44100.0; + juce::Random rng; + float pinkB0 = 0.0f, pinkB1 = 0.0f, pinkB2 = 0.0f; +}; + +} // namespace serum diff --git a/Source/Oscillator.cpp b/Source/Oscillator.cpp new file mode 100644 index 0000000..d82efde --- /dev/null +++ b/Source/Oscillator.cpp @@ -0,0 +1,133 @@ +#include "Oscillator.h" + +namespace serum +{ + +void Oscillator::reset() +{ + for (auto& v : voices) + { + v.phase = 0.0; + v.detuneRatio = 1.0; + v.pan = 0.0f; + v.level = 1.0f; + } + activeUnison = 1; +} + +void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed) +{ + jassert (freqHz > 0.0); + rng.setSeed (seed); + + const int uni = juce::jlimit (1, kMaxUnison, p.unison); + activeUnison = uni; + + const double basePhase = (double) p.phase * kTwoPi; + + for (int v = 0; v < uni; ++v) + { + // Even phase spacing prevents cancellation across unison voices. + double offset = (uni > 1) ? ((double) v / (double) uni) * kTwoPi : 0.0; + double random = rng.nextFloat() * (double) p.randPhase * kTwoPi; + voices[(size_t) v].phase = basePhase + offset + random; + + // Detune: linear spread in cents, 0..50 cents at full depth. + double detuneCents = 0.0; + if (uni > 1) + detuneCents = (double) p.detune * 50.0 * ((double) (v - (uni - 1) / 2.0) / (double) ((uni - 1) / 2.0)); + voices[(size_t) v].detuneRatio = std::pow (2.0, detuneCents / 1200.0); + + // Stereo spread. + float panPos = (uni > 1) ? ((float) v / (float) (uni - 1) - 0.5f) * 2.0f * p.spread : 0.0f; + voices[(size_t) v].pan = panPos; + + // Gain scaling with a centre emphasis for odd unison counts. + float lvl = 1.0f / std::sqrt ((float) uni); + if ((uni & 1) && v == uni / 2) + lvl *= 1.3f; + voices[(size_t) v].level = lvl; + } +} + +float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept +{ + const float amt = p.warpAmt; + switch ((WarpMode) p.warp) + { + case WarpMode::None: return phase; + case WarpMode::BendPlus: return phase + amt * 0.5f * std::sin (phase * (float) kTwoPi); + case WarpMode::BendMinus: return phase - amt * 0.5f * std::sin (phase * (float) kTwoPi); + case WarpMode::Sync: return std::fmod (phase * (1.0f + amt * 7.0f), 1.0f); + case WarpMode::Asym: + if (amt < 0.001f) return phase; + return std::pow (phase, 1.0f + amt * 3.0f); + case WarpMode::Mirror: + { + const float mirror = 2.0f * std::abs (phase - 0.5f); + return phase + (mirror - phase) * amt; + } + case WarpMode::PWM: return phase; + case WarpMode::Fold: return phase; + default: return phase; + } +} + +float Oscillator::warpSample (float sample, const OscParams& p) const noexcept +{ + const float amt = p.warpAmt; + switch ((WarpMode) p.warp) + { + case WarpMode::PWM: + { + const float threshold = (2.0f * amt - 1.0f) * 0.9f; + return std::tanh ((sample - threshold) * 4.0f); + } + case WarpMode::Fold: + return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f); + default: + return sample; + } +} + +void Oscillator::processAdd (const Wavetable& wt, const OscParams& p, double freqHz, + float& outL, float& outR) noexcept +{ + if (! p.enabled || p.level <= 0.0f || freqHz <= 0.0) + return; + + const int uni = juce::jlimit (1, kMaxUnison, p.unison); + const float framePos = p.wtPos * 255.0f; + const double phaseInc = kTwoPi * freqHz / sr; + + float accL = 0.0f; + float accR = 0.0f; + + for (int v = 0; v < uni; ++v) + { + auto& sv = voices[(size_t) v]; + + sv.phase += phaseInc * sv.detuneRatio; + sv.phase -= std::floor (sv.phase * (1.0 / kTwoPi)) * kTwoPi; + if (sv.phase >= kTwoPi) sv.phase -= kTwoPi; + if (sv.phase < 0.0) sv.phase += kTwoPi; + + float phase01 = (float) (sv.phase * (1.0 / kTwoPi)); + float sample = wt.readSafe (framePos, warpPhase (phase01, p)); + sample = warpSample (sample, p); + + // Constant-power pan. + const float panAngle = (sv.pan + 1.0f) * 0.5f * 1.5707963267948966f; + const float panL = std::cos (panAngle); + const float panR = std::sin (panAngle); + + const float gain = sv.level * p.level; + accL += sample * gain * panL; + accR += sample * gain * panR; + } + + outL += accL; + outR += accR; +} + +} // namespace serum diff --git a/Source/Oscillator.h b/Source/Oscillator.h new file mode 100644 index 0000000..8fc17a6 --- /dev/null +++ b/Source/Oscillator.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include "Params.h" +#include "Wavetable.h" + +namespace serum +{ + +// =========================================================================== +// Per-oscillator parameters (snapshot read from the APVTS each block). +// =========================================================================== +struct OscParams +{ + bool enabled = true; + int wave = 0; + float wtPos = 0.0f; + int warp = 0; + float warpAmt = 0.0f; + int coarse = 0; + int fine = 0; + float level = 1.0f; + float pan = 0.0f; + int unison = 1; + float detune = 0.0f; + float spread = 0.0f; + float phase = 0.0f; + float randPhase = 0.0f; +}; + +// =========================================================================== +// A polyphonic oscillator with wavetable morphing, warp modes and unison +// (1..16 sub-voices). Per-voice state (phase) lives here; params are read each +// block from the OscParams snapshot. +// =========================================================================== +class Oscillator +{ +public: + Oscillator() = default; + + void prepare (double sampleRate) { sr = sampleRate; reset(); } + void reset(); + + // (Re)configure unison sub-voices: phase offsets, detune, pan and gain. + void noteOn (double freqHz, const OscParams& p, juce::uint32 seed); + + // Accumulate this oscillator's contribution into outL/outR. + void processAdd (const Wavetable& wt, const OscParams& p, double freqHz, + float& outL, float& outR) noexcept; + + int getActiveUnison() const noexcept { return activeUnison; } + +private: + struct SubVoice + { + double phase = 0.0; + double detuneRatio = 1.0; + float pan = 0.0f; + float level = 1.0f; + }; + + std::array voices; + int activeUnison = 1; + double sr = 44100.0; + juce::Random rng; + + static constexpr double kTwoPi = 6.28318530717958647692; + + float warpPhase (float phase, const OscParams& p) const noexcept; + float warpSample (float sample, const OscParams& p) const noexcept; +}; + +} // namespace serum diff --git a/Source/SubOscillator.cpp b/Source/SubOscillator.cpp new file mode 100644 index 0000000..7cd78fd --- /dev/null +++ b/Source/SubOscillator.cpp @@ -0,0 +1,39 @@ +#include "SubOscillator.h" + +namespace serum +{ + +void SubOscillator::noteOn (double freqHz, int octaveShift) +{ + // octaveShift: -2, -1 or 0 (from the subOct param). + const double mult = (octaveShift == -2) ? 0.25 : (octaveShift == -1) ? 0.5 : 1.0; + (void) freqHz; + (void) mult; + phase = 0.0; +} + +void SubOscillator::processAdd (double freqHz, int shape, float level, float& out) noexcept +{ + if (level <= 0.0f || freqHz <= 0.0) + return; + + // The octave shift is baked into freqHz by the voice; here we just phase-accumulate. + const double inc = kTwoPi * freqHz / sr; + phase += inc; + phase -= std::floor (phase * (1.0 / kTwoPi)) * kTwoPi; + + float s = 0.0f; + if (shape == (int) SubShape::Triangle) + { + const float p = (float) (phase * (1.0 / kTwoPi)); + s = 1.0f - 4.0f * std::abs (p - 0.5f); + } + else + { + s = (float) std::sin (phase); + } + + out += s * level; +} + +} // namespace serum diff --git a/Source/SubOscillator.h b/Source/SubOscillator.h new file mode 100644 index 0000000..f2f038f --- /dev/null +++ b/Source/SubOscillator.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include "Params.h" + +namespace serum +{ + +// =========================================================================== +// Simple sub oscillator: sine or triangle, one/two octaves down or unison. +// =========================================================================== +class SubOscillator +{ +public: + void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; } + void reset() { phase = 0.0; } + + void noteOn (double freqHz, int octaveShift); + + // Accumulate into out (mono; the caller pans/levels it). + void processAdd (double freqHz, int shape, float level, float& out) noexcept; + +private: + double sr = 44100.0; + double phase = 0.0; + static constexpr double kTwoPi = 6.28318530717958647692; +}; + +} // namespace serum diff --git a/Source/SynthVoice.cpp b/Source/SynthVoice.cpp new file mode 100644 index 0000000..c491b21 --- /dev/null +++ b/Source/SynthVoice.cpp @@ -0,0 +1,220 @@ +#include "SynthVoice.h" + +namespace serum +{ + +namespace +{ + inline float clampF (float v, float lo, float hi) noexcept + { + return v < lo ? lo : (v > hi ? hi : v); + } +} + +void SynthVoice::prepare (double sampleRate, int maxBlockSize) +{ + oscA.prepare (sampleRate); + oscB.prepare (sampleRate); + sub.prepare (sampleRate); + noise.prepare (sampleRate); + filters.prepare (sampleRate, maxBlockSize); + for (auto& e : env) + e.prepare (sampleRate); + scratch.setSize (2, maxBlockSize, false, false, true); + reset(); +} + +void SynthVoice::reset() +{ + oscA.reset(); oscB.reset(); + sub.reset(); noise.reset(); + filters.reset(); + for (auto& e : env) + e.reset(); + note = -1; + velocity = 0.0f; + baseFreq = 0.0; + active = released = false; + lastUnisonA = lastUnisonB = 1; + noteRandom = 0.5f; + scratch.clear(); +} + +void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::uint32 noteSeed) +{ + note = noteNumber; + velocity = velocity01; + baseFreq = freqHz; + active = true; + released = false; + seed = noteSeed; + lastUnisonA = lastUnisonB = 0; // force unison reconfigure on first render + + juce::Random rng (noteSeed); + noteRandom = rng.nextFloat(); + + sub.noteOn (freqHz, -1); + noise.noteOn (noteSeed); + + for (auto& e : env) + e.noteOn(); +} + +void SynthVoice::noteOff() +{ + if (! active || released) + return; + released = true; + for (auto& e : env) + e.noteOff(); +} + +void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept +{ + if (! active || numSamples <= 0) + return; + + // 1. Refresh envelope parameters (cheap; also lets UI edits affect held notes). + for (int i = 0; i < kNumEnvelopes; ++i) + env[(size_t) i].setParams (maps::toSeconds (ctx.envAttack[i]), + maps::toSeconds (ctx.envDecay[i]), + ctx.envSustain[i], + maps::toSeconds (ctx.envRelease[i]), + ctx.envCurve[i]); + + // 2. Block-start envelope values (control-rate modulation sources). + float envStart[kNumEnvelopes]; + for (int i = 0; i < kNumEnvelopes; ++i) + envStart[i] = env[(size_t) i].getValue(); + + // 3. Per-voice modulation source values. + float src[kNumModSources]; + src[(int) ModSource::Lfo1] = ctx.lfoValues[0]; + src[(int) ModSource::Lfo2] = ctx.lfoValues[1]; + src[(int) ModSource::Lfo3] = ctx.lfoValues[2]; + src[(int) ModSource::Lfo4] = ctx.lfoValues[3]; + src[(int) ModSource::Env1] = envStart[0]; + src[(int) ModSource::Env2] = envStart[1]; + src[(int) ModSource::Env3] = envStart[2]; + src[(int) ModSource::Env4] = envStart[3]; + src[(int) ModSource::Velocity] = velocity; + src[(int) ModSource::Note] = clampF ((float) note / 127.0f, 0.0f, 1.0f); + src[(int) ModSource::ModWheel] = ctx.modWheel; + src[(int) ModSource::PitchBend]= ctx.pitchBend; + src[(int) ModSource::Macro1] = ctx.macroValues[0]; + src[(int) ModSource::Macro2] = ctx.macroValues[1]; + src[(int) ModSource::Macro3] = ctx.macroValues[2]; + src[(int) ModSource::Macro4] = ctx.macroValues[3]; + src[(int) ModSource::Random] = noteRandom; + + // 4. Accumulate modulation offsets (block rate). + std::array mod { { } }; + if (ctx.matrix != nullptr) + { + for (const auto& c : ctx.matrix->connections) + { + float v = src[(int) c.source]; + if (c.bipolar && ! isBipolarSource (c.source)) + v = v * 2.0f - 1.0f; + mod[(int) c.target] += v * c.depth; + } + } + if (ctx.macros != nullptr) + { + for (int m = 0; m < kNumMacros; ++m) + for (const auto& a : ctx.macros->assignments[(size_t) m]) + mod[(int) a.target] += ctx.macroValues[m] * a.depth; + } + + // 5. Modulated oscillator parameters. + OscParams a = ctx.oscA; + OscParams b = ctx.oscB; + a.level = clampF (a.level + mod[(int) ModTarget::OscALevel], 0.0f, 1.0f); + a.pan = clampF (a.pan + mod[(int) ModTarget::OscAPan], -1.0f, 1.0f); + a.wtPos = clampF (a.wtPos + mod[(int) ModTarget::OscAWtPos], 0.0f, 1.0f); + a.unison = (int) std::llround (clampF ((float) a.unison + mod[(int) ModTarget::OscAUnison] * 16.0f, 1.0f, 16.0f)); + a.detune = clampF (a.detune + mod[(int) ModTarget::OscADetune], 0.0f, 1.0f); + a.spread = clampF (a.spread + mod[(int) ModTarget::OscASpread], 0.0f, 1.0f); + a.warpAmt = clampF (a.warpAmt+ mod[(int) ModTarget::OscAWarpAmt], 0.0f, 1.0f); + + b.level = clampF (b.level + mod[(int) ModTarget::OscBLevel], 0.0f, 1.0f); + b.pan = clampF (b.pan + mod[(int) ModTarget::OscBPan], -1.0f, 1.0f); + b.wtPos = clampF (b.wtPos + mod[(int) ModTarget::OscBWtPos], 0.0f, 1.0f); + b.unison = (int) std::llround (clampF ((float) b.unison + mod[(int) ModTarget::OscBUnison] * 16.0f, 1.0f, 16.0f)); + b.detune = clampF (b.detune + mod[(int) ModTarget::OscBDetune], 0.0f, 1.0f); + b.spread = clampF (b.spread + mod[(int) ModTarget::OscBSpread], 0.0f, 1.0f); + b.warpAmt = clampF (b.warpAmt+ mod[(int) ModTarget::OscBWarpAmt], 0.0f, 1.0f); + + // 6. Frequency (pitch bend + mod matrix pitch + per-osc coarse/fine). + const double semis = ctx.pitchBend * ctx.pitchBendRange + mod[(int) ModTarget::Pitch] * 24.0; + const double bent = baseFreq * std::pow (2.0, semis / 12.0); + const double freqA = bent * std::pow (2.0, (double) a.coarse / 12.0 + (double) a.fine / 1200.0); + const double freqB = bent * std::pow (2.0, (double) b.coarse / 12.0 + (double) b.fine / 1200.0); + + // Reconfigure unison only when the integer count changes (avoids phase reset + // on every block when unison is LFO-modulated at control rate). + if (a.unison != lastUnisonA) { oscA.noteOn (freqA, a, seed); lastUnisonA = a.unison; } + if (b.unison != lastUnisonB) { oscB.noteOn (freqB, b, seed + 1); lastUnisonB = b.unison; } + + // 7. Modulated filter parameters. + FilterBankParams fb = ctx.filters; + fb.f1Cutoff = clampF (fb.f1Cutoff + mod[(int) ModTarget::Filter1Cutoff], 0.0f, 1.0f); + fb.f1Res = clampF (fb.f1Res + mod[(int) ModTarget::Filter1Res], 0.0f, 1.0f); + fb.f1Drive = clampF (fb.f1Drive + mod[(int) ModTarget::Filter1Drive], 0.0f, 1.0f); + fb.f2Cutoff = clampF (fb.f2Cutoff + mod[(int) ModTarget::Filter2Cutoff], 0.0f, 1.0f); + fb.f2Res = clampF (fb.f2Res + mod[(int) ModTarget::Filter2Res], 0.0f, 1.0f); + fb.f2Drive = clampF (fb.f2Drive + mod[(int) ModTarget::Filter2Drive], 0.0f, 1.0f); + fb.mix = clampF (fb.mix + mod[(int) ModTarget::FilterMix], 0.0f, 1.0f); + fb.out = clampF (fb.out + mod[(int) ModTarget::FilterOut], 0.0f, 1.5f); + + // 8. Amp modulation + velocity. + const float ampMod = 1.0f + mod[(int) ModTarget::Amp]; + const float velGain = velocity * velocity + 0.001f; + + // 9. Generate oscillators/sub/noise into scratch. + float* sL = scratch.getWritePointer (0); + float* sR = scratch.getWritePointer (1); + const Wavetable& wtA = ctx.wavetables->getTable (a.wave); + const Wavetable& wtB = ctx.wavetables->getTable (b.wave); + const double subMult = (ctx.subOct == -2) ? 0.25 : (ctx.subOct == -1) ? 0.5 : 1.0; + + for (int i = 0; i < numSamples; ++i) + { + float l = 0.0f, r = 0.0f; + oscA.processAdd (wtA, a, freqA, l, r); + oscB.processAdd (wtB, b, freqB, l, r); + + float mono = 0.0f; + if (ctx.subOn) + sub.processAdd (bent * subMult, ctx.subShape, ctx.subLevel, mono); + if (ctx.noiseOn) + noise.processAdd (ctx.noiseType, ctx.noiseLevel, mono); + l += mono; + r += mono; + + sL[i] = l; + sR[i] = r; + } + + // 10. Filter bank (control rate). + filters.process (sL, sR, numSamples, fb, (float) baseFreq); + + // 11. Amp envelope (per sample) and advance the remaining envelopes. + for (int i = 0; i < numSamples; ++i) + { + const float amp = env[0].process(); + env[1].process(); + env[2].process(); + env[3].process(); + + const float gain = amp * ampMod * velGain; + outL[i] += sL[i] * gain; + outR[i] += sR[i] * gain; + } + + // 12. Release completes when the amp envelope has fully decayed. + if (released && ! env[0].isActive()) + active = false; +} + +} // namespace serum diff --git a/Source/SynthVoice.h b/Source/SynthVoice.h new file mode 100644 index 0000000..9b9d919 --- /dev/null +++ b/Source/SynthVoice.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include "Params.h" +#include "Wavetable.h" +#include "Oscillator.h" +#include "SubOscillator.h" +#include "NoiseOscillator.h" +#include "FilterBank.h" +#include "Envelope.h" +#include "ModulationMatrix.h" +#include "MacroControls.h" + +namespace serum +{ + +// =========================================================================== +// Everything a voice needs to render one block. Global (non per-voice) values +// are provided by the engine; the voice adds its own per-voice modulation. +// =========================================================================== +struct RenderContext +{ + double sampleRate = 44100.0; + const WavetableLibrary* wavetables = nullptr; + + float lfoValues[kNumLfos] { 0.0f, 0.0f, 0.0f, 0.0f }; + float macroValues[kNumMacros] { 0.0f, 0.0f, 0.0f, 0.0f }; + float modWheel = 0.0f; + float pitchBend = 0.0f; + float pitchBendRange = 2.0f; + + const ModulationMatrix* matrix = nullptr; + const MacroControls* macros = nullptr; + + OscParams oscA; + OscParams oscB; + + bool subOn = false; + int subShape = 0; + int subOct = -1; + float subLevel = 0.0f; + + bool noiseOn = false; + int noiseType = 0; + float noiseLevel = 0.0f; + + FilterBankParams filters; + + float envAttack[4] { 0.01f, 0.01f, 0.01f, 0.01f }; + float envDecay[4] { 0.2f, 0.2f, 0.2f, 0.2f }; + float envSustain[4]{ 0.7f, 0.7f, 0.7f, 0.7f }; + float envRelease[4]{ 0.3f, 0.3f, 0.3f, 0.3f }; + float envCurve[4] { 0.5f, 0.5f, 0.5f, 0.5f }; +}; + +// =========================================================================== +// One synthesis voice: two oscillators + sub + noise -> filter bank -> amp +// envelope. Four envelopes provide per-voice modulation sources. +// =========================================================================== +class SynthVoice +{ +public: + void prepare (double sampleRate, int maxBlockSize); + void reset(); + + void noteOn (int noteNumber, float velocity, double freqHz, juce::uint32 seed); + void noteOff(); + + void render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept; + + bool isActive() const noexcept { return active; } + bool isReleased() const noexcept { return released; } + int getNote() const noexcept { return note; } + juce::uint64 getNoteId() const noexcept { return noteId; } + +private: + Oscillator oscA, oscB; + SubOscillator sub; + NoiseOscillator noise; + FilterBank filters; + std::array env; + + int note = -1; + float velocity = 0.0f; + double baseFreq = 0.0; + bool active = false; + bool released = false; + juce::uint64 noteId = 0; + + int lastUnisonA = 1, lastUnisonB = 1; + + juce::uint32 seed = 0; + float noteRandom = 0.5f; + + juce::AudioBuffer scratch; // 2 channels +}; + +} // namespace serum diff --git a/Source/Wavetable.cpp b/Source/Wavetable.cpp new file mode 100644 index 0000000..db4ae28 --- /dev/null +++ b/Source/Wavetable.cpp @@ -0,0 +1,348 @@ +#include "Wavetable.h" + +namespace serum +{ + +// --------------------------------------------------------------------------- +// Wavetable +// --------------------------------------------------------------------------- +void Wavetable::clear() +{ + frames.clear(); + name = {}; +} + +void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int numHarmonics) +{ + name = n; + frames.assign (kFrames, std::vector (kTableSize, 0.0f)); + + const float invN = 1.0f / (float) kTableSize; + constexpr double twoPi = 6.28318530717958647692; + + for (int f = 0; f < kFrames; ++f) + { + auto& frame = frames[(size_t) f]; + + // Precompute per-harmonic rotation step (cos/sin of the angular increment). + std::vector cosStep ((size_t) numHarmonics + 1, 0.0); + std::vector sinStep ((size_t) numHarmonics + 1, 0.0); + std::vector mag ((size_t) numHarmonics + 1, 0.0); + std::vector phase ((size_t) numHarmonics + 1, 0.0); + + double maxAmp = 1e-9; + for (int h = 1; h <= numHarmonics; ++h) + { + const float a = ampFn (f, h); + const double m = std::abs ((double) a); + mag[(size_t) h] = m; + phase[(size_t) h] = (a < 0.0f) ? twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base + const double ang = twoPi * (double) h * (double) invN; + cosStep[(size_t) h] = std::cos (ang); + sinStep[(size_t) h] = std::sin (ang); + maxAmp = std::max (maxAmp, m); + } + + double peak = 1e-9; + for (int h = 1; h <= numHarmonics; ++h) + { + if (mag[(size_t) h] < 1e-9) + continue; + + // Start the rotating phasor at the harmonic's phase offset. + double s = std::sin (phase[(size_t) h]); + double c = std::cos (phase[(size_t) h]); + const double cs = cosStep[(size_t) h]; + const double ss = sinStep[(size_t) h]; + const double m = mag[(size_t) h]; + + for (int i = 0; i < kTableSize; ++i) + { + frame[(size_t) i] += (float) (m * s); + const double s2 = s * cs + c * ss; + c = c * cs - s * ss; + s = s2; + } + } + + // Normalise each frame to unity peak so morphing keeps a stable level. + for (int i = 0; i < kTableSize; ++i) + peak = std::max (peak, (double) std::abs (frame[(size_t) i])); + + if (peak > 1e-6) + { + const float g = (float) (1.0 / peak); + for (int i = 0; i < kTableSize; ++i) + frame[(size_t) i] *= g; + } + } +} + +float Wavetable::read (float framePos, float phase) const noexcept +{ + if (frames.empty()) + return 0.0f; + + framePos = juce::jlimit (0.0f, (float) (kFrames - 1), framePos); + int f0 = (int) framePos; + float frac = framePos - (float) f0; + int f1 = juce::jmin (f0 + 1, kFrames - 1); + + float p = phase * (float) kTableSize; + int i0 = (int) p; + if (i0 < 0) i0 = 0; + float t = p - (float) i0; + int i1 = (i0 + 1) & (kTableSize - 1); + i0 &= (kTableSize - 1); + + const auto& a = frames[(size_t) f0]; + const auto& b = frames[(size_t) f1]; + const float s0 = lerp (a[(size_t) i0], a[(size_t) i1], t); + const float s1 = lerp (b[(size_t) i0], b[(size_t) i1], t); + return lerp (s0, s1, frac); +} + +void Wavetable::copyFrame (int frameIndex, float* dest, int numSamples) const +{ + if (frames.empty() || dest == nullptr) + return; + + frameIndex = juce::jlimit (0, kFrames - 1, frameIndex); + const auto& frame = frames[(size_t) frameIndex]; + const int n = juce::jmin (numSamples, kTableSize); + for (int i = 0; i < n; ++i) + dest[i] = frame[(size_t) i]; +} + +// --------------------------------------------------------------------------- +// Library +// --------------------------------------------------------------------------- +void WavetableLibrary::prebuild() +{ + for (int i = 0; i < kNumWavetables; ++i) + getTable (i); +} + +const Wavetable& WavetableLibrary::getTable (int index) const +{ + index = juce::jlimit (0, kNumWavetables - 1, index); + if (!built[(size_t) index]) + buildTable (index); + return tables[(size_t) index]; +} + +void WavetableLibrary::buildTable (int index) const +{ + switch (index) + { + case 0: tables[(size_t) index] = makeBasic(); break; + case 1: tables[(size_t) index] = makeSawPwm(); break; + case 2: tables[(size_t) index] = makeSquareSync(); break; + case 3: tables[(size_t) index] = makeTriangleFold(); break; + case 4: tables[(size_t) index] = makeVowel(); break; + case 5: tables[(size_t) index] = makeOrgan(); break; + case 6: tables[(size_t) index] = makeWarmSaw(); break; + case 7: tables[(size_t) index] = makeDigital(); break; + case 8: tables[(size_t) index] = makeGlass(); break; + case 9: tables[(size_t) index] = makeBass(); break; + default: tables[(size_t) index] = makeBasic(); break; + } + built[(size_t) index] = true; +} + +namespace +{ + inline float normT (int frame) { return (float) frame / 255.0f; } + constexpr float kPi = 3.14159265358979323846f; +} + +// sine -> saw morph +Wavetable WavetableLibrary::makeBasic() +{ + Wavetable wt; + wt.buildHarmonic ("Basic Shapes", [] (int f, int h) + { + const float t = normT (f); + const float sine = (h == 1) ? 1.0f : 0.0f; + const float saw = 1.0f / (float) h; + return sine + (saw - sine) * t; + }, 48); + return wt; +} + +// narrow pulse -> wide pulse +Wavetable WavetableLibrary::makeSawPwm() +{ + Wavetable wt; + wt.buildHarmonic ("Saw PWM", [] (int f, int h) + { + const float t = normT (f); + const float duty = 0.08f + 0.42f * t; // 8% -> 50% + const float a = std::sin (kPi * (float) h * duty); + return (2.0f / ((float) h * kPi)) * a; + }, 48); + return wt; +} + +// hard-sync style comb sweep +Wavetable WavetableLibrary::makeSquareSync() +{ + Wavetable wt; + wt.buildHarmonic ("Square Sync", [] (int f, int h) + { + const float t = normT (f); + const float ratio = 1.0f + 3.0f * t; + const float comb = 0.5f + 0.5f * std::cos (2.0f * kPi * (float) h * ratio); + return (1.0f / std::pow ((float) h, 0.8f)) * comb; + }, 48); + return wt; +} + +// triangle -> folded/saw-ish +Wavetable WavetableLibrary::makeTriangleFold() +{ + Wavetable wt; + wt.buildHarmonic ("Triangle Fold", [] (int f, int h) + { + const float t = normT (f); + // Triangle: odd harmonics with alternating sign, 1/h^2. + float tri = 0.0f; + if ((h & 1) == 1) + { + const int k = (h - 1) / 2; + const float sign = ((k & 1) == 0) ? 1.0f : -1.0f; + tri = sign * 8.0f / (kPi * kPi * (float) (h * h)); + } + const float saw = 1.0f / (float) h; + return tri + (saw - tri) * t; + }, 48); + return wt; +} + +// formant morph a -> e -> i +Wavetable WavetableLibrary::makeVowel() +{ + Wavetable wt; + wt.buildHarmonic ("Vowel", [] (int f, int h) + { + const float t = normT (f); + // Three formant sets (Hz) with f0 = 55 Hz. + const float f0 = 55.0f; + const float aa[3] = { 730.0f, 1090.0f, 2440.0f }; + const float ee[3] = { 530.0f, 1840.0f, 2480.0f }; + const float ii[3] = { 270.0f, 2290.0f, 3010.0f }; + + float from[3], to[3]; + if (t < 0.5f) + { + const float u = t * 2.0f; + for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; } + (void) u; + } + else + { + const float u = (t - 0.5f) * 2.0f; + for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; } + (void) u; + } + + float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f; + float sum = 0.0f; + const float sigma = 1.8f; // formant bandwidth in harmonic units + for (int k = 0; k < 3; ++k) + { + const float fc = from[k] + (to[k] - from[k]) * tt; + const float center = fc / f0; + const float d = ((float) h - center) / sigma; + sum += std::exp (-0.5f * d * d) * (k == 0 ? 1.0f : 0.6f); + } + return sum; + }, 48); + return wt; +} + +// drawbar organ morph +Wavetable WavetableLibrary::makeOrgan() +{ + Wavetable wt; + wt.buildHarmonic ("Organ", [] (int f, int h) + { + const float t = normT (f); + // two drawbar registrations (16', 8', 5 1/3', 4', 2 2/3', 2', ...) + const float regA[8] = { 0.0f, 1.0f, 0.0f, 0.35f, 0.0f, 0.2f, 0.0f, 0.1f }; + const float regB[8] = { 0.5f, 1.0f, 0.4f, 0.6f, 0.25f, 0.5f, 0.2f, 0.35f }; + if (h > 8) return 0.0f; + const float a = regA[h - 1]; + const float b = regB[h - 1]; + return a + (b - a) * t; + }, 8); + return wt; +} + +// warm lowpassed saw -> brighter +Wavetable WavetableLibrary::makeWarmSaw() +{ + Wavetable wt; + wt.buildHarmonic ("Warm Saw", [] (int f, int h) + { + const float t = normT (f); + const float cutoff = 10.0f + 34.0f * t; // harmonic rolloff centre + const float rolloff = std::exp (-((float) h / cutoff) * ((float) h / cutoff)); + const float body = 1.0f / std::pow ((float) h, 0.9f); + return body * (0.4f + 0.6f * rolloff); + }, 48); + return wt; +} + +// additive, brighter and slightly combed +Wavetable WavetableLibrary::makeDigital() +{ + Wavetable wt; + wt.buildHarmonic ("Digital", [] (int f, int h) + { + const float t = normT (f); + const float comb = 0.5f + 0.5f * std::cos ((float) h * 0.9f * (1.0f + t)); + return (1.0f / std::pow ((float) h, 0.6f)) * (0.5f + 0.5f * comb); + }, 48); + return wt; +} + +// inharmonic bell-like partial clusters +Wavetable WavetableLibrary::makeGlass() +{ + Wavetable wt; + wt.buildHarmonic ("Glass", [] (int f, int h) + { + const float t = normT (f); + const float c1 = 1.0f + t * 2.5f; + const float c2 = 3.6f + t * 3.4f; + const float c3 = 6.2f + t * 4.0f; + const float sigma = 0.9f; + float sum = 0.0f; + const float centers[3] = { c1, c2, c3 }; + const float gains[3] = { 1.0f, 0.7f, 0.4f }; + for (int k = 0; k < 3; ++k) + { + const float d = ((float) h - centers[k]) / sigma; + sum += gains[k] * std::exp (-0.5f * d * d); + } + return sum; + }, 48); + return wt; +} + +// sub-heavy -> fuller bass +Wavetable WavetableLibrary::makeBass() +{ + Wavetable wt; + wt.buildHarmonic ("Bass", [] (int f, int h) + { + const float t = normT (f); + const float sub = (h == 1) ? 1.0f : ((h == 2) ? 0.4f : ((h == 3) ? 0.12f : 0.0f)); + const float full = 1.0f / std::pow ((float) h, 1.1f); + return sub + (full - sub) * t; + }, 48); + return wt; +} + +} // namespace serum diff --git a/Source/Wavetable.h b/Source/Wavetable.h new file mode 100644 index 0000000..2a75838 --- /dev/null +++ b/Source/Wavetable.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include "Params.h" + +namespace serum +{ + +// =========================================================================== +// A single wavetable: 256 frames of 2048 samples each. Frames morph smoothly +// from a simple base shape to a complex one. Lookups interpolate bilinearly +// (across frames and within a frame). Tables are RAM-resident and generated +// lazily by the WavetableLibrary. +// =========================================================================== +class Wavetable +{ +public: + static constexpr int kFrames = 256; + static constexpr int kTableSize = 2048; + + Wavetable() = default; + + juce::String name; + std::vector> frames; // [frame][sample] + + bool isValid() const noexcept { return (int) frames.size() == kFrames; } + + void clear(); + + // Build a table from a per-frame harmonic amplitude function. + // ampFn(frameIndex, harmonicNumber) -> amplitude (harmonic 1 = fundamental) + using HarmonicAmpFn = std::function; + void buildHarmonic (const juce::String& name, HarmonicAmpFn ampFn, int numHarmonics = 64); + + // Read the table at a fractional frame position and a phase in [0,1). + float read (float framePos, float phase) const noexcept; + + // Read with phase wrapped and clamped frame position. + float readSafe (float framePos, float phase) const noexcept + { + phase -= std::floor (phase); + return read (juce::jlimit (0.0f, (float) kFrames - 1.001f, framePos), phase); + } + + // Copy raw samples of a frame into a destination buffer (for the GUI). + void copyFrame (int frameIndex, float* dest, int numSamples) const; + +private: + static float lerp (float a, float b, float t) noexcept { return a + (b - a) * t; } +}; + +// =========================================================================== +// Lazily-built library of factory wavetables. Indices map to kWavetableNames. +// =========================================================================== +class WavetableLibrary +{ +public: + const Wavetable& getTable (int index) const; + int size() const noexcept { return kNumWavetables; } + + // Force generation of all tables (used at prepare time). + void prebuild(); + +private: + mutable std::array tables; + mutable std::array built { { false } }; + + void buildTable (int index) const; + static Wavetable makeBasic(); + static Wavetable makeSawPwm(); + static Wavetable makeSquareSync(); + static Wavetable makeTriangleFold(); + static Wavetable makeVowel(); + static Wavetable makeOrgan(); + static Wavetable makeWarmSaw(); + static Wavetable makeDigital(); + static Wavetable makeGlass(); + static Wavetable makeBass(); +}; + +} // namespace serum