GetDunne Wiki

Notes from the desk of Shane Dunne, software development consultant

User Tools

Site Tools


the_synthesizer

This is an old revision of the document!


The VanillaJuce synthesizer

The synthesizer-related classes Synth, SynthSound, SynthParameters have already been covered on the overview page. This leaves only SynthOscillator, SynthEnvelopeGenerator, and SynthVoice. SynthVoice is by far the most important, so let's start with that. I'm not going to pore over every line of code; instead my goal is to point out what's important to help you understand the code as you read it for yourself.

SynthVoice

Because our synthesizer class Synth inherits from juce::Synthesiser, it inherits a whole pre-built mechanism for dynamically assigning SynthVoice objects to MIDI notes as they are played. We don't have to write any of that tricky code at all. (In fact, the main reason I wrote VanillaJuce at all was because Obxd, the only complete, uncomplicated JUCE-based synthesizer example I was able to find, was not based on juce::Synthesiser.)

The key juce::SynthesiserVoice member functions that SynthVoice overrides are:

    void startNote(int midiNoteNumber, float velocity, SynthesiserSound* sound, int currentPitchWheelPosition) override;
    void stopNote(float velocity, bool allowTailOff) override;
    void pitchWheelMoved(int newValue) override;
    void controllerMoved(int controllerNumber, int newValue) override;
 
    void renderNextBlock(AudioSampleBuffer& outputBuffer, int startSample, int numSamples) override;

The juce::Synthesiser::renderNextBlock() function calls each active voice's renderNextBlock() function once for every “block” (buffer) of output audio. Our implementation is this:

void SynthVoice::renderNextBlock(AudioSampleBuffer& outputBuffer, int startSample, int numSamples)
{
    while (--numSamples >= 0)
    {
        if (!ampEG.isRunning())
        {
            clearCurrentNote();
            break;
        }
        float aeg = ampEG.getSample();
        float osc = osc1.getSample() * osc1Level.getNextValue() + osc2.getSample() * osc2Level.getNextValue();
        float sample = aeg * osc;
        outputBuffer.addSample(0, startSample, sample);
        outputBuffer.addSample(1, startSample, sample);
        ++startSample;
    }
}

Don't worry about all the details yet. At this point, just note that the outer while loop iterates over all the samples in outputBuffer, and at each step, a new sample value sample is computed for this voice, and added in to whatever may already be there in outputBuffer, using its addSample() function. In this way, all of the active voices (sounding notes) are effectively summed together into the output buffer.

startNote() gets called each time a voice is assigned to play a new MIDI note:

void SynthVoice::startNote(int midiNoteNumber, float velocity, SynthesiserSound* sound, int currentPitchWheelPosition)
{
    ignoreUnused(midiNoteNumber);    // accessible as SynthesiserVoice::getCurrentlyPlayingNote();
    tailOff = false;
    noteVelocity = velocity;
 
    pParams = dynamic_cast<SynthSound*>(sound)->pParams;
    double sampleRateHz = getSampleRate();
    setPitchBend(currentPitchWheelPosition);
 
    setup(false);
    ampEG.start(sampleRateHz);
}

The function arguments contain all the details to specify which note is to be played, at what key velocity, based on which SynthSound (we dynamic_cast the incoming SynthesiserSound* pointer to SynthSound*), and also tells where the MIDI controller's pitch-wheel is positioned (it might not be in the middle).

Most of the work of setting up the new note is delegated to the setup() function, and startNote() finishes by telling the ampEG (amplifier envelope generator) to start the attack-phase of the note. We'll get to setup() in a moment, but for now, note that it is also called from soundParameterChanged() (when any parameter is changed via the GUI) and pitchWheelMoved():

void SynthVoice::soundParameterChanged()
{
    if (pParams == 0) return;
    setup(false);
}
 
void SynthVoice::pitchWheelMoved(int newValue)
{
    setPitchBend(newValue);
    setup(true);
}

pitchWheelMoved() delegates the real work to setPitchBend(), which transforms the 14-bit unsigned MIDI pitch-bend value newValue to a signed float value in the range -1.0 to +1.0, and then calls setup() with its Boolean parameter set to true. Here is setup():

void SynthVoice::setup (bool pitchBendOnly)
{
    double sampleRateHz = getSampleRate();
    int midiNote = getCurrentlyPlayingNote();
 
    float masterLevel = float(noteVelocity * pParams->masterLevel);
    double pbCents = pitchBendCents();
 
    double cyclesPerSecond = noteHz(midiNote + pParams->osc1PitchOffsetSemitones, pParams->osc1DetuneOffsetCents + pbCents);
    double cyclesPerSample = cyclesPerSecond / sampleRateHz;
    osc1.setFrequency(cyclesPerSample);
    if (!pitchBendOnly)
    {
        osc1.setWaveform(pParams->osc1Waveform);
        osc1Level.reset(sampleRateHz, ampEG.isRunning() ? 0.1 : 0.0);
        osc1Level.setValue(float(pParams->oscBlend * masterLevel));
    }
 
    cyclesPerSecond = noteHz(midiNote + pParams->osc2PitchOffsetSemitones, pParams->osc2DetuneOffsetCents + pbCents);
    cyclesPerSample = cyclesPerSecond / sampleRateHz;
    osc2.setFrequency(cyclesPerSample);
    if (!pitchBendOnly)
    {
        osc2.setWaveform(pParams->osc2Waveform);
        osc2Level.reset(sampleRateHz, ampEG.isRunning() ? 0.1 : 0.0);
        osc2Level.setValue(float((1.0 - pParams->oscBlend) * masterLevel));
    }
 
    if (!pitchBendOnly)
    {
        ampEG.attackSeconds = pParams->ampEgAttackTimeSeconds;
        ampEG.decaySeconds = pParams->ampEgDecayTimeSeconds;
        ampEG.sustainLevel = pParams->ampEgSustainLevel;
        ampEG.releaseSeconds = pParams->ampEgReleaseTimeSeconds;
    }
}

Don't worry about the details; they'll become clear as you study the code for yourself, and especially when we look at the SynthOscillator and SynthEnvelopeGenerator classes. For now the important points to note are:

  • The pitchBendOnly parameter, which will only be true when setup() is called in response to a pitch-wheel change, is used to decide whether to perform a complete note setup (false) or only change certain things (true).
  • 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

SynthOscillator

SynthEnvelopeGenerator

Summary: What happens when you play a note

the_synthesizer.1504126947.txt.gz · Last modified: 2017/08/30 21:02 by shane