Natural envelope shapes for better synth plucks #338
Replies: 7 comments
-
Here's my code. class PluckEnvelope
{
void reset()
{
x = 0;
lengthInSecondsOffset = lengthModifier;
}
void setSampleRate(double v)
{
sampleRate = v;
update();
}
void setSeconds(double v)
{
lengthInSeconds = v;
update();
}
void setAttack(double v)
{
attack = v;
}
void setAttackRatio(double v)
{
attackRatio = v;
}
void setAttackMix(double v)
{
attackMix = v;
}
void setDecay(double v)
{
decay = v;
}
void setDecayRatio(double v)
{
decayRatio = v;
}
void setDecayMix(double v)
{
decayMix = v;
}
void setLengthInSeconds(double v)
{
lengthInSeconds = v;
update();
}
void setLengthModifier(double v)
{
lengthModifier = v;
}
void setLoopTime(double v)
{
loopSeconds = v;
}
double getSample()
{
d1 = decay;
d2 = decayRatio * decay;
dec = decayMix * exp(-x/d1) + (1-decayMix) * exp(-x/d2);
a1 = attack;
a2 = attackRatio * attack;
att = attackMix * exp(-x/a1) + (1-attackMix) * exp(-x/a2);
env = dec - att;
x += increment+lengthModifier;
if (x > loopSeconds)
{
x = 0;
lengthInSecondsOffset = lengthModifier;
}
lengthInSecondsOffset += lengthModifier;
update();
return env;
}
void update()
{
increment = (1.0/(lengthInSeconds + lengthInSecondsOffset)) / sampleRate;
}
// user variables
double attack;
double attackRatio;
double attackMix;
double decay;
double decayRatio;
double decayMix;
double lengthInSeconds;
double lengthInSecondsOffsetInc = 0;
double lengthModifier = 0;
double loopSeconds = 0;
// internal states
double d1, d2, dec, a1, a2, att;
double x = 0;
double env = 0;
double sampleRate = 44100;
double increment = .0001;
double lengthInSecondsOffset;
}; |
Beta Was this translation helpful? Give feedback.
-
It is perhaps best to think about this kind of envelope in a more general context as a weighted sum of simple exponential decays each of which can be implemented as a 1st order lowpass and the envelope would get triggered by an impulse input. If implemented that way, you shouldn't get clicks on retrigger unless you reset the lowpass state on (re)trigger events/impulses. If you don't reset but just feed another impulse into the filter, a rapid succession of such impulses/triggers would make the envelopes pile up on each other - which may or may not be desirable. I have an envelope generator under construction that works like that. It uses just two exponential decays which together form an attack/decay envelope (by my usual technique of subtracting a fast decay env from a slow decay env). It is here: and here are some experiments with that implementation: In one of these experiments, I'm firing a rapid succession of note-ons at the envelope and I'm experimenting with a couple of different ways for the retrigger behavior. Here are some plots: One way is to indeed reset the filter state on retrigger. This will produce the clicks that you are talking about. This behavior is shown in the bottommost, green curve. Another way is to just not reset the states leading to the piling up shown in the topmost purple curve. In between (red and blue) are some attempts to scale the trigger impulses in a particular way as a function of the current filter states to avoid the piling up. I'm trying to derive a formula for exact compensation but unfortunately, that leads to a complicated implicit equation which I don't yet know, how to solve. The goal of such a perfect compensation would be a curve that peaks at the same values as the green one but without the reset, i.e. without the drop to zero at the triggers. The red and blue attempts are some simple ad-hoc formulas. My goal is actually to turn this into into a full ADSR envelope and the way I'm thinking to achieve it is to pass not only single impulses into the filter on trigger events but also an added constant during the sustain and switching the decay-time to a different value on release. ...sooo, bottom line: I don't yet have a perfect solution for this, but if one of the behaviors shown in the plots looks useful to you, feel free to use them. |
Beta Was this translation helpful? Give feedback.
-
By the way, forming envelopes as weighted sums of exponential decays is something that I generally like a lot recently. I have created some nice pitch-envelopes for electronic bassdrums using a weighted sum of 3 decaying exponentials. What you have above is a weighted sum of 4 of them. The general technique leads to nice, smooth and "natural" envelope shapes. |
Beta Was this translation helpful? Give feedback.
-
Can't tell from the code how to set samplerate. Edit: After a bunch of copy pasting code to my prototyping environment (Plug'n'Script, which uses AngelScript which is close to c++) it doesn't work. There's so many hairy calculation and a few that depend on functions outside the class, easy for me to have gone wrong somewhere. class AttackDecayFilter
{
AttackDecayFilter()
{
reset();
updateCoeffs();
}
/** Sets the attack time in samples, i.e. the number of samples it takes to reach the peak. */
void setAttackSamples(double newAttack) { attackSamples = newAttack; coeffsDirty = true; }
/** Sets the decay time constant in samples, i.e. the number of samples, it takes to decay to
1/e for the more slowly decaying exponential. */
void setDecaySamples(double newDecay) { decaySamples = newDecay; coeffsDirty = true; }
//-----------------------------------------------------------------------------------------------
/** \name Inquiry */
/** Returns the gain of this filter at DC. This value can be useful to know when you want to
create an envelope with sustain - you may then feed the reciprocal of that value as constant
input into the filter. */
double getGainAtDC() const { return s*(cd-ca) / (1+ca*cd-cd-ca); }
/** Computes the reciprocal of the DC gain of this filter. This is the constant value, you want
to feed into the filter when you want to get a sustained output of 1. It's used for implementing
sustain in subclass rsAttackDecayEnvelope. */
double getReciprocalGainAtDC() const { return (1+ca*cd-cd-ca) / (s*(cd-ca)); }
//-----------------------------------------------------------------------------------------------
/** \name Processing */
double getSample(double v)
{
if(coeffsDirty)
updateCoeffs();
ya = v + ca * ya;
yd = v + cd * yd;
return s * (yd - ya);
}
/** Resets the internal state of both filters to zero. */
void reset()
{
ya = yd = 0;
}
/** Updates our filter coefficients according to user parameters. */
void updateCoeffs()
{
double tauAttack = 0;
double attackSamples2 = min(0.99 * decaySamples, attackSamples + 1);
// Why the 2nd +1? to avoid numerical problems when is goes down to zero? Then maybe
// using max would be better...or is there some offset of 1 sample that is being compesated?
expDiffScalerAndTau2(decaySamples, attackSamples2, tauAttack, s);
ca = exp(-1.0/tauAttack); // = exp(-alpha), pole radius
cd = exp(-1.0/decaySamples);
coeffsDirty = false;
}
// Data:
double ca, cd; // coefficients for attack and decay filters
double ya, yd; // state of attack and decay filter
double s;
bool coeffsDirty = true; // flag to indicate that coeffs need to be re-computed
//std::atomic<bool> coeffsDirty = true; // flag to indicate that coeffs need to be re-computed
double attackSamples = 20, decaySamples = 100; // sort of arbitrary
};
void expDiffScalerAndTau2(double tau1, double tp, double& tau2, double& scaler)
{
if(tp >= tau1)
{
tau2 = tau1;
scaler = 1.0;
return;
}
double a1 = 1/tau1;
double c = a1 * tp;
double k = findDecayScalerLess1(c);
double a2 = k*a1;
double hp = exp(-a1*tp) - exp(-a2*tp); // peak height
tau2 = 1/a2;
scaler = 1/hp;
}
double findDecayScalerLess1(double c)
{
if(c <= 0.0 || c >= 1.0)
{
return 1.0;
}
// precomputations:
double kp = 1/c; // location of the the peak of g(k)
double k = 1 + 2*(kp-1); // initial guess for the zero of g(k)
double eps = 2.22045e-16; // relative tolerance
int i = 0; // iteration counter
// Newton iteration:
double g, gp; // g(k), g'(k)
double kOld = 2*k; // ensure to enter the loop
while(abs(k-kOld) > k*eps && i < 1000) // whoa! 1000? that seems way too high for production code!
{
kOld = k;
g = log(k) + c*(1-k); // g(k)
gp = 1/k - c; // g'(k)
k = k - g/gp; // Newton step
i++; // count iteration
}
return k;
} |
Beta Was this translation helpful? Give feedback.
-
The class knows no samplerate. The time values are set up as number of samples. I tend to try to avoid sampleRate members in lower level DSP classes nowadays because they tend to be redundant data when you make higher level classes that may - say - contain an array of such lower level objects. Having the sampleRate repeated in every object is wasteful with memory and requires more effort on the client code side to keep them all in sync. Yes - the calculation of the fast decay time constant from the desired peak position is indeed hairy. It's an implicit equation. I wrote something about it here: http://rs-met.com/documents/dsp/AttackDecayEnvelope.pdf at the end ("Finding tau_a from t_p "). I'm currently doing this with Newton iteration. Btw.: In the pdf, I describe a serial implementation but in this code here, I'm using a parallel implementation implementing the scaled difference of two exponential decays directly. But this makes no difference with regard to this calculation.. |
Beta Was this translation helpful? Give feedback.
-
Why aren't you using APE instead of Plug'n'Script? Because of some missing features? I'm sure Plug'n'Script is great but with APE, you could directly use the C++ classes from my library without trying to rewrite them in Angelscript (which really is the killer feature for me - as I also said in the KVR thread). |
Beta Was this translation helpful? Give feedback.
-
Plug'n'Script feels faster to prototype with, also it's the preferred environment for someone I'm working with. I am working on a synth that will be called Hydrus, it brings together all ideas I had throughout the years, including a pluck envelope, my analog filter emulations, better supersaws, first-ever-in-a-synth hypersaw (which is ACTUALLY a new beast, people have called things hypersaw in the past but they are incorrect), etc. I'll have to consider using APE to test the pluck envelope. edit: you might've missed the demos I posted of the hypersaw in action, note how few saws I'm using. 6 sawtooths Supersaw-like: https://drive.google.com/file/d/1kxKdzs5vHMIKjnsD37MaqkuKJOV8Q4Fu/view?usp=sharing |
Beta Was this translation helpful? Give feedback.
-
Some ms-paint diagrams of desired envelope shapes: #206 (comment)
Robin's equation for a pluck envelope: #267 (comment)
I implemented the equation and added some feedback and now I am able to get very long decays after a short impulse.
The problem is that I don't know how to retrigger the envelope without having it restart completely causing clicks.
Beta Was this translation helpful? Give feedback.
All reactions