feat(modulation): add envelopes, LFOs and modulation matrix
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user