GetDunne Wiki

Notes from the desk of Shane Dunne, software development consultant

User Tools

Site Tools


the_synthesizer

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Both sides previous revision Previous revision
Next revision
Previous revision
the_synthesizer [2017/08/30 21:03]
shane [SynthVoice]
the_synthesizer [2017/08/30 23:49] (current)
shane [SynthOscillator]
Line 112: Line 112:
   * //setup()// uses //SynthVoice// member variables, such as //pParams// which points to the current //SynthParameters// struct, for all the details of how to set up the new note.   * //setup()// uses //SynthVoice// member variables, such as //pParams// which points to the current //SynthParameters// struct, for all the details of how to set up the new note.
  
-//stopNote()// is a little bit complicated, because of the +//stopNote()// is a little bit complicated, because of the Boolean //allowTailOff// parameter. //allowTailOff// will normally be //true//, to indicate that the note should continue sounding, but begin its release phase, because the MIDI key which was down is now up. //allowTailOff// will be //false//, however, in the event of a MIDI "panic" ("all notes off") situation, in which case notes should stop sounding immediately and not "tail off"
 +<code cpp> 
 +void SynthVoice::stopNote(float velocity, bool allowTailOff) 
 +
 +    ignoreUnused(velocity); 
 + 
 +    if (allowTailOff & !tailOff) 
 +    { 
 +        tailOff = true; 
 +        ampEG.release(); 
 +    } 
 +    else 
 +    { 
 +        clearCurrentNote(); 
 +    } 
 +
 +</code> 
 +The //tailOff// member variable is used to ensure that the "tail-off" (release) operation happens only once. //SynthesiserVoice::clearCurrentNote()// tells the controlling //Synthesiser// instance that the voice is no longer active; //renderNextBlock()// will no longer be called until the voice is reassigned. 
 + 
 +The only other interesting aspect of the //SynthVoice// class are its //osc1Level// and //osc2Level// member variables, which are defined as //LinearSmoothedValue<float>//. This is due to two rather tricky aspects of //juce::Synthesiser//'s voice-assignment algorithm: 
 +  - Whenever a new MIDI note is played, if there is already an active voice sounding that pitch (based on the MIDI note-number), //juce::Synthesiser// will //not assign a new voice//. Instead it will simply call //startNote()// on the existing active voice, telling it to pop out of its release/tail-off phase and begin again at the attack phase. 
 +  - The active voice must not only begin again, it must begin again //with a new level//, based on the new MIDI note-velocity. 
 + 
 +If you are not careful, the result of playing a note first loudly and then very softly will be an audible click as the sounding note's amplitude suddenly drops from the old louder level to the new softer one. Use of //LinearSmoothedValue// objects ensures that this volume change will get stretched out over a short interval (VanillaJuce uses 100 milliseconds. (See the calls to //osc1Level.reset()// and //osc2Level.reset()// in //setup()//.) 
 ===== SynthOscillator ===== ===== SynthOscillator =====
 +VanillaJuce's oscillator class is designed for coding simplicity, not CPU-efficiency or sound quality. Here's the whole thing:
 +<code cpp>
 +class SynthOscillator
 +{
 +private:
 +    SynthOscillatorWaveform waveForm;
 +    double phase;            // [0.0, 1.0]
 +    double phaseDelta;        // cycles per sample (fraction)
 +
 +public:
 +    SynthOscillator();
 +    
 +    void setWaveform(SynthOscillatorWaveform wf) { waveForm = wf; }
 +    void setFrequency(double cyclesPerSample);
 +
 +    float getSample ();
 +};
 +
 +SynthOscillator::SynthOscillator()
 +    : waveForm(kSawtooth)
 +    , phase(0)
 +    , phaseDelta(0)
 +{
 +}
 +
 +void SynthOscillator::setFrequency(double cyclesPerSample)
 +{
 +    phaseDelta = cyclesPerSample;
 +}
 +
 +float SynthOscillator::getSample()
 +{
 +    float sample = 0.0f;
 +    switch (waveForm)
 +    {
 +    case kSine:
 +        sample = (float)(std::sin(phase * 2.0 * double_Pi));
 +        break;
 +    case kSquare:
 +        sample = (phase <= 0.5) ? 1.0f : -1.0f;
 +        break;
 +    case kTriangle:
 +        sample = (float)(2.0 * (0.5 - std::fabs(phase - 0.5)) - 1.0);
 +        break;
 +    case kSawtooth:
 +        sample = (float)(2.0 * phase - 1.0);
 +        break;
 +    }
 +
 +    phase += phaseDelta;
 +    while (phase > 1.0) phase -= 1.0;
 +
 +    return sample;
 +}
 +</code>
 +Every time //getSample()// is called, the //phase// member variable, which is a number in the range 0.0 to 1.0, is used in a simple math expression, to generate a sample of the appropriate waveform---sine, square, triangle, or sawtooth. Then //phase// is advanced by adding a small fraction //phaseDelta//, with wraparound so it remains in the range 0.0 to 1.0. As you can see in the //SynthVoice::setup()// code above, //phaseDelta// is computed by dividing the desired note frequency in Hz (cycles per second) by the plugin host's current sampling frequency (samples per second), yielding a //samples per cycle// value (aka normalized frequency).
 +
 +This simplistic code is acceptable for low-frequency oscillators (LFOs), but it's not good enough for audio-frequency oscillators, because mathematical functions which define the waveforms are not //band-limited// (with the exception of the sine waveform, which is in fact perfectly band-limited). As a result, higher-frequency harmonics will be "aliased" to completely different audio frequencies when you play higher notes.
 +
 +VanillaJuce is essentially an early iteration of a project which was eventually renamed [[sarah|SARAH]] (//Synthèse à Rapide Analyse Harmonique//, or "synthesis with fast harmonic analysis"), which I plan to publish soon.
  
 ===== SynthEnvelopeGenerator ===== ===== SynthEnvelopeGenerator =====
 +The //SynthEnvelopeGenerator// class implements a simple "ADSR" envelope function with a linear Attack and Decay ramps, a constant Sustain level, and a linear Release ramp. //juce::LinearSmoothedValue// is used to facilitate generating the linear ramps. To understand the code, it will be helpful to understand that the ADSR envelope always begins and ends at the value 0.0:
 +  * The Attack phase always ramps from 0.0 up to 1.0.
 +  * The Sustain level is some fraction in the range 0.0 to 1.0.
 +  * The Release phase ramps from the Sustain level back to 0.0.
 +
 +Here is the class declaration for //SynthEnvelopeGenerator//:
 +<code cpp>
 +typedef enum
 +{
 +    kIdle,
 +    kAttack,
 +    kDecay,
 +    kSustain,
 +    kRelease
 +} EG_Segment;
 +
 +class SynthEnvelopeGenerator
 +{
 +private:
 +    double sampleRateHz;
 +    LinearSmoothedValue<double> interpolator;
 +    EG_Segment segment;
 +
 +public:
 +    double attackSeconds, decaySeconds, releaseSeconds;
 +    double sustainLevel;    // [0.0, 1.0]
 +
 +public:
 +    SynthEnvelopeGenerator();
 +
 +    void start(double _sampleRateHz);    // called for note-on
 +    void release();                        // called for note-off
 +    bool isRunning() { return segment != kIdle; }
 +    float getSample ();
 +};
 +</code>
 +//juce::LinearSmoothedValue// is a template class, which in this case is instantiated with a base type of //double//, to define the member variable //interpolator//.
 +It has several member functions: the following four of which are used in //SynthEnvelopeGenerator//:
 +  * //setValue()// is used to set the "target value" that the interpolator will be ramping up (or down) to.
 +  * //reset()// takes a sampling rate in Hz and a ramp time in seconds, and prepares the interpolator.
 +  * //isSmoothing()// returns //true// if the interpolator's current value has not yet reached the target value, //false// if it has.
 +  * //getNextValue()// advances the interpolator by one step (one sample time), and returns the new current value.
 +
 +Unfortunately, the //juce::LinearSmoothedValue// class does //not// provide a function to set the interpolator's current value, so we have to resort to calling //setValue()// to set the target value, followed immediately by a call to //reset()//, which happens to set the current value to the target value (I only know this because I peeked at the //juce::LinearSmoothedValue// source code), followed by a second call to //setValue()// to set the new target value. You'll see this pattern more than once in the //SynthEnvelopeGenerator// code:
 +<code cpp>
 +SynthEnvelopeGenerator::SynthEnvelopeGenerator()
 +    : sampleRateHz(44100)
 +    , attackSeconds(0.01)
 +    , decaySeconds(0.1)
 +    , releaseSeconds(0.5)
 +    , sustainLevel(0.5)
 +    , segment(kIdle)
 +{
 +    interpolator.setValue(0.0);
 +    interpolator.reset(sampleRateHz, 0.0);
 +}
 +
 +void SynthEnvelopeGenerator::start (double _sampleRateHz)
 +{
 +    sampleRateHz = _sampleRateHz;
 +
 +    if (segment == kIdle)
 +    {
 +        // start new attack segment from zero
 +        interpolator.setValue(0.0);
 +        interpolator.reset(sampleRateHz, attackSeconds);
 +    }
 +    else
 +    {
 +        // note is still playing but has been retriggered or stolen
 +        // start new attack from where we are
 +        double currentValue = interpolator.getNextValue();
 +        interpolator.setValue(currentValue);
 +        interpolator.reset(sampleRateHz, attackSeconds * (1.0 - currentValue));
 +    }
 +
 +    segment = kAttack;
 +    interpolator.setValue(1.0);
 +}
 +
 +void SynthEnvelopeGenerator::release()
 +{
 +    segment = kRelease;
 +    interpolator.setValue(interpolator.getNextValue());
 +    interpolator.reset(sampleRateHz, releaseSeconds);
 +    interpolator.setValue(0.0);
 +}
 +
 +float SynthEnvelopeGenerator::getSample()
 +{
 +    if (segment == kSustain) return float(sustainLevel);
 +
 +    if (interpolator.isSmoothing()) return float(interpolator.getNextValue());
 +
 +    if (segment == kAttack)    // end of attack segment
 +    {
 +        if (decaySeconds > 0.0)
 +        {
 +            // there is a decay segment
 +            segment = kDecay;
 +            interpolator.reset(sampleRateHz, decaySeconds);
 +            interpolator.setValue(sustainLevel);
 +            return 1.0;
 +        }
 +        else
 +        {
 +            // no decay segment; go straight to sustain
 +            segment = kSustain;
 +            return float(sustainLevel);
 +        }
 +    }
 +    else if (segment == kDecay)    // end of decay segment
 +    {
 +        segment = kSustain;
 +        return float(sustainLevel);
 +    }
 +    else if (segment == kRelease)    // end of release
 +    {
 +        segment = kIdle;
 +    }
 +
 +    // after end of release segment
 +    return 0.0f;
 +}
 +</code>
 +
 +Remember where I talked about how //juce::Synthesizer// will re-trigger a voice back to its attack phase if the same MIDI note goes on, then off, then on again? That requires an even uglier version of the //setValue//, //reset//, //setValue// sequence of function calls, where the argument to the initial //setValue()// call is obtained by calling //getNextValue()//, to ensure that the new ramp begins exactly where the one being truncated leaves off, to avoid another type of "click" transient.
  
 ===== Summary: What happens when you play a note ===== ===== Summary: What happens when you play a note =====
 +When the VanillaJuce plugin is compiled and instantiated in a DAW, and the user presses down a note on a MIDI keyboard (or the same sequence of MIDI-events occurs during the playback of a recorded MIDI sequence), the following things happen:
 +  - The //juce::Synthesiser::noteOn()// member function is called, with ''this'' pointing to the one and only //Synth// object (member variable //synth// of //VanillaJuceAudioProcessor//)
 +  - The //juce::Synthesiser// code assigns (or re-assigns) one of the sixteen available //SynthVoice// objects to play the note, and calls that object's //startNote()// function.
 +  - The //SynthVoice::startNote()// code (see above) sets up its two oscillators and envelope-generator according to the current program/patch parameters, and starts the note sounding by calling the envelope-generator's //start()// function.
 +  - Because the //SynthVoice// instance is now "active", calls made from the plugin host to //juce::Synthesiser::renderNextBlock()// are passed on to //SynthVoice::renderNextBlock()// (see above), which generates the required number of samples, //adding them in// to the supplied //juce::AudioSampleBuffer// so that all active voices are effectively summed (mixed with equal intensity) to the output.
 +  - With each successive sample, the envelope generator is advanced through the ADSR shape.
 +
 +When the MIDI note-off event occurs in the MIDI input sequence:
 +  - //juce::Synthesiser::noteOff()// is called.
 +  - The //juce::Synthesiser// code uses the MIDI note-number to determine which of the currently-active //SynthVoice// is playing the note, and calls that object's //stopNote()// function, with the //allowTailOff// argument set to //true//.
 +  - The //SynthVoice::stopNote()// code (see above) forces the ADSR envelope generator to go straight to the start of its Release phase.
 +  - Eventually, the test of //ampEG.isRunning()// in //SynthVoice::renderNextBlock()// returns //false//, because the envelope generator has reached the end of the Release ramp, and //juce::SynthesiserVoice::clearCurrentNote()// gets called; this causes the voice to become inactive, suppressing further calls to //SynthVoice::renderNextBlock()// for that voice.
 +
 +There are two special voice-assignment scenarios you should be aware of. The first one, //note reassignment// was already discussed above. If a MIDI note-on event occurs while there is already an active voice sounding the same MIDI note-number, //juce::Synthesiser::noteOn()// will simply call //startNote()// again on the active //SynthVoice// instance. Care must then be taken to ensure that there is not much of an audible "click" as the note goes back to its Attack phase.
 +
 +The second special case concerns what happens when the synthesizer runs out of voices. That case is actually almost identical to the first one; the only difference is how the //juce::Synthesiser// code selects which active voice to reassign---a process called //note-stealing//. Have a look at the //juce::Synthesiser// source code to learn exactly how its note-stealing algorithm works, and be aware that this is just one of several possible ways to do it. If you wanted a different note-stealing algorithm, you would simply have to override more of the //juce::Synthesiser// member functions.
  
the_synthesizer.1504127030.txt.gz · Last modified: 2017/08/30 21:03 by shane