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:22]
shane [SynthVoice]
the_synthesizer [2017/08/30 23:49] (current)
shane [SynthOscillator]
Line 138: Line 138:
  
 ===== 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.1504128173.txt.gz · Last modified: 2017/08/30 21:22 by shane