-
Notifications
You must be signed in to change notification settings - Fork 12
Variable Grain Envelope
The first thing we'll be adding to the grain class will be a variable envelope. The features of the envelope are described in more detail in the concept chapter. Since developing the plugin I wanted to keep the envelopes functionality from the the SuperCollider prototype I adapted the envelope function from.
To describe the envelope we must declare a few extra members of Grain
and initialize them in the constructor:
[...]
float envAttack, envAttackRecip;
float envRelease, envReleaseRecip;
float lengthRecip;
Grain(int onset, int length, int startPos, float center, float sustain) : onset(onset), length(length), startPosition(startPos),
envAttack((1 - sustain) * center), envAttackRecip(1/envAttack),
envRelease(sustain + envAttack), envReleaseRecip(1/(1-envRelease)),
lengthRecip(1/(float)length)
{
}
Grain()
{
onset = 0;
length = 100;
startPosition = 0;
envAttack = 0.3;
envAttackRecip = 1/envAttack;
envRelease = 0.6;
envReleaseRecip = 1/(1-envRelease);
lengthRecip = 1/(float)length;
}
[...]
The envelope will be described by two parameters: the center parameter and the sustain parameter. The sustain parameter will set what portion of the total envelope will take the value 1. The center parameter sets the ratio between the attack and release times. This configuration allows the user to easily morph between different envelope shapes useful for granular synthesis without having to worry about too many parameters. We see that we calculate the values for attack and release in the classes constructor initializer list. We are also calculating the reciprocal (1/x
) value for the length of the grain and the attack and release values. We do this because division is a relatively expensive operation for the cpu requiring several steps whereas multiplication is much cheaper. Since we are calculating the envelope for each sample and the values don't change once the grain is created we calculate the divisions in the constructor once and save ourselves the time in the actual process function.
Since we changed the constructor (adding more arguments) we must also fix the call to it from run
in the PluginAudioProcessor
:
grainStack.add( Grain(time + onset, length, wrap(startPosition, 0, numSamples), 0.5, 0.3) );
We will now define a function float envelope(time)
with which we will calculate the envelope:
float envelope (int time)
{
float gain;
float envPos;
envPos = (time - onset) * lengthRecip; // [1]
if(envPos <= envAttack){ // [2]
float aPos = envPos * envAttackRecip;
gain = aPos;
} else if( envPos < envRelease){
gain = 1.0;
} else if( envPos >= envRelease ){
float rPos = (envPos - envRelease) * envReleaseRecip;
gain = rPos * (-1) + 1;
}
return gain;
}
- We define the
envPos
variable that will go from 0 to 1. - We divide the envelope into segments. Checking if
envPos
is in a certain range. We then calculate a local position, that will go from 0 to 1 in that segment. For the release we invert that value to go from 1 to 0. For the sustain the value is just set as 1.
Afterwards we will add a call to envelope
to the process
function:
void process (AudioSampleBuffer& currentBlock, AudioSampleBuffer& fileBuffer, int numChannels, int blockNumSamples, int fileNumSamples, int time)
{
for(int channel=0; channel<numChannels; ++channel){
const float gain = envelope(time);
float* channelData = currentBlock.getWritePointer(channel);
const float* fileData = fileBuffer.getReadPointer(channel%fileBuffer.getNumChannels());
const int position = (time - onset) + startPosition;
channelData[time % blockNumSamples] += fileData[position % fileNumSamples] * gain;
}
Now it's time to test if everything is working correctly. Since we are still hardcoding the values for the grains in the run
function we can set them so debugging is easier. While testing and trying out different things here I chose a fairly long value for the duration (1000 milliseconds or more) and a very short value for the length of the grain (50 samples). I then posted values for envPos
, aPos
, rPos
and gain
to the console checking if all of them were in the right ranges and behaved like I expected them to.
Right now the envelope has just linear segments. We want to also add a curve argument that curves the envelope segments up and down. We first need to add another member to our grain class float envCurve
. After we added the new parameter to the constructor and edited the run
function we will adapt our envelope function:
float envelope (int time)
{
float gain;
float envPos;
envPos = (time - onset) * lengthRecip;
if(envPos <= envAttack){
if(std::abs(envCurve) > 0.001){
float aPos;
aPos = envPos * envAttackRecip;
double denom = 1.0f - exp(envCurve);
double numer = 1.0f - exp(aPos * envCurve);
gain = (numer/denom);
} else {
float aPos;
aPos = envPos * envAttackRecip;
gain = aPos;
}
} else if( envPos < envRelease){
gain = 1.0;
} else if( envPos >= envRelease ){
if(std::abs(envCurve) > 0.001){
float rPos;
rPos = (envPos - envRelease) * envReleaseRecip;
double denom = 1.0f - exp(-envCurve);
double numer = 1.0f - exp(rPos * -envCurve);
gain = (numer/denom) * (-1) + 1;
} else {
float rPos;
rPos = (envPos - envRelease) * envReleaseRecip;
gain = rPos * (-1) + 1;
}
}
return gain;
}
This code looks much more complicated then the previous version. But it's really not much more complicated just a bit verbose. In addition to checking in which envelope segment we are, we also check if the absolute (std::abs()
) value of envCurve
is great enough that it makes sense to spend the extra computing power on calculating the curve. If the value is too small the resulting envelope would be linear so we just calculate that. If the value is indeed large enough we calculate the curved segment.