feat(modulation): add envelopes, LFOs and modulation matrix

This commit is contained in:
2026-09-08 14:55:21 +02:00
parent fc9d16b302
commit 44960b9acb
6 changed files with 458 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
#include "Envelope.h"
namespace serum
{
void Envelope::reset()
{
stage = Stage::Idle;
value = 0.0f;
startValue = endValue = 0.0f;
elapsed = duration = 0.0;
}
void Envelope::noteOn()
{
stage = Stage::Attack;
elapsed = 0.0;
duration = attackSamples;
startValue = value; // retrigger from current level
endValue = 1.0f;
}
void Envelope::noteOff()
{
if (stage == Stage::Idle)
return;
stage = Stage::Release;
elapsed = 0.0;
duration = releaseSamples;
startValue = value;
}
void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, float releaseSec, float curve)
{
sustain = juce::jlimit (0.0f, 1.0f, sustainLevel);
curve = juce::jlimit (0.0f, 1.0f, curve);
attackSamples = std::max (1.0, (double) attackSec * sr);
decaySamples = std::max (1.0, (double) decaySec * sr);
releaseSamples = std::max (1.0, (double) releaseSec * sr);
// curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay
attackShape = 0.3 + curve * 2.7; // 0.3 .. 3.0
decayShape = 3.0 - curve * 2.7; // 3.0 .. 0.3
shape.attack = attackSec;
shape.decay = decaySec;
shape.sustain = sustain;
shape.release = releaseSec;
shape.curve = curve;
}
float Envelope::process() noexcept
{
switch (stage)
{
case Stage::Idle:
value = 0.0f;
return 0.0f;
case Stage::Attack:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = endValue;
stage = Stage::Decay;
elapsed = 0.0;
duration = decaySamples;
startValue = value;
endValue = sustain;
}
else
{
const double p = elapsed / duration;
value = startValue + (endValue - startValue) * (float) std::pow (p, attackShape);
}
return value;
case Stage::Decay:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = sustain;
stage = Stage::Sustain;
}
else
{
const double p = elapsed / duration;
value = endValue + (startValue - endValue) * (float) std::pow (1.0 - p, decayShape);
}
return value;
case Stage::Sustain:
value = sustain;
return value;
case Stage::Release:
elapsed += 1.0;
if (duration <= 1.0 || elapsed >= duration)
{
value = 0.0f;
stage = Stage::Idle;
}
else
{
const double p = elapsed / duration;
value = startValue * (float) std::pow (1.0 - p, decayShape);
}
return value;
}
return 0.0f;
}
} // namespace serum
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <JuceHeader.h>
namespace serum
{
// ===========================================================================
// ADSR envelope with curve tension. Durations are in seconds; the sustain
// level is 0..1. Retriggering (noteOn while active) starts from the current
// value for click-free legato behaviour.
// ===========================================================================
class Envelope
{
public:
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void noteOn();
void noteOff();
void setParams (float attackSec, float decaySec, float sustain, float releaseSec, float curve);
float process() noexcept; // advance one sample and return the value
float getValue() const noexcept { return value; }
bool isActive() const noexcept { return stage != Stage::Idle; }
// Snapshot for GUI rendering.
struct Shape { float attack = 0, decay = 0, sustain = 0, release = 0, curve = 0.5f; };
Shape getShape() const noexcept { return shape; }
private:
enum class Stage { Idle, Attack, Decay, Sustain, Release };
Stage stage = Stage::Idle;
double sr = 44100.0;
float value = 0.0f;
float startValue = 0.0f, endValue = 0.0f;
double elapsed = 0.0, duration = 0.0;
double attackShape = 1.0, decayShape = 1.0;
float sustain = 0.7f;
double attackSamples = 0.0, decaySamples = 0.0, releaseSamples = 0.0;
Shape shape;
};
} // namespace serum
+126
View File
@@ -0,0 +1,126 @@
#include "LFO.h"
namespace serum
{
void LFO::reset()
{
phase = 0.0;
value = 0.0f;
delayCounter = 0.0;
fadeCounter = 0.0;
fadeVal = 1.0f;
holdValue = 0.0f;
prevPhase = 0.0;
prevDelayParam = -1.0f;
prevFadeParam = -1.0f;
shapeBuffer.assign ((size_t) kShapePoints, 0.0f);
// default step sequence
for (int i = 0; i < kShapePoints; ++i)
shapeBuffer[(size_t) i] = (i % 2 == 0) ? 1.0f : -1.0f;
}
void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
float fade, float delay)
{
sync = s;
beat = b;
shape = juce::jlimit (0, (int) LfoShape::Count - 1, shp);
phase = juce::jlimit (0.0f, 1.0f, ph);
if (sync)
rateHz = maps::beatToMultiplier (beat) * (tempo / 60.0);
else
rateHz = maps::rateToHz (rateNorm);
delaySeconds = juce::jlimit (0.0f, 1.0f, delay) * 4.0;
fadeSeconds = juce::jlimit (0.0f, 1.0f, fade) * 8.0;
// Only restart the delay/fade timing when those knobs actually change,
// so repeated block-rate setParams calls don't keep resetting the LFO.
if (delay != prevDelayParam || fade != prevFadeParam)
{
delayCounter = delaySeconds * sr;
fadeCounter = 0.0;
fadeVal = (fadeSeconds <= 0.0) ? 1.0f : 0.0f;
prevDelayParam = delay;
prevFadeParam = fade;
}
}
void LFO::setShapeData (const std::vector<float>& data, int steps)
{
if (data.empty())
return;
shapeSteps = juce::jlimit (2, kShapePoints, steps);
shapeBuffer.assign (data.begin(), data.end());
shapeBuffer.resize ((size_t) kShapePoints, 0.0f);
}
float LFO::shapeValue() noexcept
{
const float p = (float) phase;
switch ((LfoShape) shape)
{
case LfoShape::Sine:
return std::sin (p * 6.28318530717958647692f);
case LfoShape::Triangle:
return 1.0f - 4.0f * std::abs (p - 0.5f);
case LfoShape::Saw:
return 2.0f * p - 1.0f;
case LfoShape::Square:
return (p < 0.5f) ? 1.0f : -1.0f;
case LfoShape::SampleHold:
{
if (phase < prevPhase)
holdValue = rng.nextFloat() * 2.0f - 1.0f;
return holdValue;
}
case LfoShape::StepSeq:
{
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
return shapeBuffer[(size_t) idx];
}
case LfoShape::Freehand:
{
const float pos = p * (float) (shapeSteps - 1);
const int i0 = (int) pos;
const int i1 = juce::jmin (i0 + 1, shapeSteps - 1);
const float frac = pos - (float) i0;
return shapeBuffer[(size_t) i0] * (1.0f - frac) + shapeBuffer[(size_t) i1] * frac;
}
default:
return 0.0f;
}
}
float LFO::process() noexcept
{
// Start delay.
if (delayCounter > 0.0)
{
delayCounter -= 1.0;
value = 0.0f;
return 0.0f;
}
// Fade-in ramp.
if (fadeVal < 1.0f)
{
fadeCounter += 1.0;
if (fadeSeconds > 0.0)
fadeVal = (float) juce::jlimit (0.0, 1.0, fadeCounter / (fadeSeconds * sr));
else
fadeVal = 1.0f;
}
prevPhase = phase;
phase += rateHz / sr;
phase -= std::floor (phase);
value = shapeValue() * fadeVal;
return value;
}
} // namespace serum
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// Free-running LFO with tempo sync, seven shapes (sine/tri/saw/square/S&H/
// step-sequencer/freehand), adjustable phase, fade-in and start delay.
// ===========================================================================
class LFO
{
public:
static constexpr int kShapePoints = 64;
void prepare (double sampleRate) { sr = sampleRate; reset(); }
void reset();
void setParams (float rateNorm, bool sync, float beat, int shape, float phase,
float fade, float delay);
void setTempo (double bpm) noexcept { tempo = bpm; }
void setShapeData (const std::vector<float>& data, int steps);
float process() noexcept; // advance and return current value
float getPhase() const noexcept { return (float) phase; }
float getValue() const noexcept { return value; }
const std::vector<float>& getShapeData() const noexcept { return shapeBuffer; }
int getShapeSteps() const noexcept { return shapeSteps; }
private:
double sr = 44100.0;
double tempo = 120.0;
double phase = 0.0;
double rateHz = 1.0;
float value = 0.0f;
int shape = 0;
bool sync = false;
float beat = 0.25f;
double delaySeconds = 0.0;
double fadeSeconds = 0.0;
double delayCounter = 0.0; // samples remaining before start
double fadeCounter = 0.0; // samples elapsed in fade
float fadeVal = 1.0f;
float prevDelayParam = -1.0f;
float prevFadeParam = -1.0f;
std::vector<float> shapeBuffer;
int shapeSteps = 16;
juce::Random rng;
float holdValue = 0.0f;
double prevPhase = 0.0;
float shapeValue() noexcept;
};
} // namespace serum
+62
View File
@@ -0,0 +1,62 @@
#include "ModulationMatrix.h"
namespace serum
{
bool ModulationMatrix::addConnection (ModSource source, ModTarget target, float depth, bool bipolar)
{
if (target == ModTarget::None || (int) connections.size() >= kMaxConnections)
return false;
connections.push_back ({ source, target, depth, bipolar });
return true;
}
void ModulationMatrix::removeConnection (int index)
{
if (index >= 0 && index < (int) connections.size())
connections.erase (connections.begin() + index);
}
void ModulationMatrix::removeAllWithTarget (ModTarget target)
{
connections.erase (std::remove_if (connections.begin(), connections.end(),
[target] (const ModConnection& c) { return c.target == target; }),
connections.end());
}
juce::ValueTree ModulationMatrix::toValueTree() const
{
juce::ValueTree tree ("MODMATRIX");
for (const auto& c : connections)
{
juce::ValueTree con ("CONNECTION");
con.setProperty ("source", modSourceToString (c.source), nullptr);
con.setProperty ("target", modTargetToString (c.target), nullptr);
con.setProperty ("depth", c.depth, nullptr);
con.setProperty ("bipolar", c.bipolar, nullptr);
tree.appendChild (con, nullptr);
}
return tree;
}
void ModulationMatrix::fromValueTree (const juce::ValueTree& tree)
{
connections.clear();
if (! tree.isValid())
return;
for (const auto& con : tree)
{
if (! con.hasType ("CONNECTION"))
continue;
ModConnection c;
c.source = modSourceFromString (con.getProperty ("source").toString());
c.target = modTargetFromString (con.getProperty ("target").toString());
c.depth = (float) con.getProperty ("depth", 0.0);
c.bipolar = (bool) con.getProperty ("bipolar", false);
if (c.target != ModTarget::None && (int) connections.size() < kMaxConnections)
connections.push_back (c);
}
}
} // namespace serum
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <JuceHeader.h>
#include "Params.h"
namespace serum
{
// ===========================================================================
// A single modulation connection: source -> target with a static depth and a
// bipolar flag. One level of modulation only (no modulation of modulation).
// ===========================================================================
struct ModConnection
{
ModSource source = ModSource::Lfo1;
ModTarget target = ModTarget::None;
float depth = 0.0f;
bool bipolar = false;
};
// ===========================================================================
// Ordered collection of modulation connections. Serialisable to/from a
// juce::ValueTree so that connections survive preset save/load.
// ===========================================================================
class ModulationMatrix
{
public:
static constexpr int kMaxConnections = 32;
std::vector<ModConnection> connections;
bool addConnection (ModSource source, ModTarget target, float depth, bool bipolar);
void removeConnection (int index);
void removeAllWithTarget (ModTarget target);
void clear() { connections.clear(); }
int size() const noexcept { return (int) connections.size(); }
juce::ValueTree toValueTree() const;
void fromValueTree (const juce::ValueTree& tree);
};
} // namespace serum