Polyphonic Synth

How the polyphonic synth is put together
Figure 1. The same skeleton as the monophonic synth, with an allocator and an array of voices added inside the q::audio_stream.

Overview

example/poly_synth/poly_synth.cpp takes the voice the Square Synth just built, an oscillator through an ADSR-swept resonant low-pass and amplifier, and keeps sixteen of them, so chords and overlapping notes ring together. The only change inside the voice is its oscillator, a bandwidth-limited sawtooth rather than a square.

  • The colors are the ones the Square Synth uses.

  • allocate() gives a note a voice, free if there is one and the oldest otherwise.

  • The array holds sixteen of them.

  • Each frame sums the voices that are active, applies a headroom gain, and soft-clips the result.

Polyphony needs no special library support. A voice is just an instance of the same fine-grained building blocks the monophonic synth uses, so the synth simply keeps a fixed pool of voices and a small allocator. Two questions are new, and they are the whole of this page: which voice a note goes to, and what happens to the one it displaces.

The Voice

A voice bundles the per-note state: a phase_iterator, an adsr_envelope_gen, and a State Variable Filter. It is the monophonic synth in miniature.

void on(q::frequency freq, float velocity)
{
   _velocity = velocity;
   _phase.set(freq, _sps);
   _env.attack();                 // retriggers cleanly even on a stolen voice
}

void off() { _env.release(); }

bool active() const { return !_env.in_idle_phase(); }

float operator()()
{
   auto env = _env() * _velocity;
   _filter.cutoff(q::frequency{60.0 + (env * 6000.0)}, _sps);
   return _filter(q::saw(_phase++)) * env;
}

The only new idea versus the monophonic synth is that there are many voices, each carrying its own oscillator, envelope, and filter state. A voice is "free" when its envelope has returned to idle (in_idle_phase()).

The Voice Pool

The synth holds a fixed pool (16 voices). A note-on takes a free voice, or, if all are busy, steals the oldest:

voice& allocate(std::uint8_t key)
{
   auto free = std::ranges::find_if(
      _voices, [](voice const& v) { return !v.active(); });
   voice& v = (free != _voices.end())
      ? *free
      : *std::ranges::min_element(_voices, {}, &voice::_order);
   v._key = key;
   v._order = ++_order;
   return v;
}

Stealing a still-sounding voice re-triggers it. Because the envelope’s attack ramps from the voice’s current level (rather than restarting from zero), the steal is click-free, the same property that keeps fast arpeggios clean on a single voice. A note-off releases every voice holding that key.

Voices allocated, released and stolen over time
Figure 2. Three notes take three voices, and a fourth arrives with none free.

Read the figure left to right: each note takes a voice, and the dashed stretch after it is the release, still sounding. When B arrives, every voice is still active, the released one included, so the oldest goes and its release is cut short. A voice counts as busy until its envelope reaches idle, which is later than the note-off that released it. That is why a chord played over a fading chord can run a pool out of voices, and why a generous pool costs little: a voice that is idle does nothing.

The Mix

Each frame sums the active voices, applies a little headroom gain, and soft-clips the result with a Clip:

auto mix = 0.0f;
for (auto& v : _voices)
   if (v.active())
      mix += v();
left[frame] = right[frame] = _clip(mix * master_gain);

master_gain (0.3) leaves headroom for several simultaneous voices; the soft clip catches the peaks of dense chords gracefully rather than hard-clipping.

That is a synthesizer: voices, allocation, and a mix. What it does not yet have is a sound worth naming, which is where the Virtual Analog Synth goes next: a second oscillator, a filter with character, and a patch that writes the whole thing down.

Running

cmake -B build
cmake --build build --target example_poly_synth
build/example/poly_synth/example_poly_synth

Pick a MIDI input and an audio output at the prompts (see List Devices), then play chords. Hold a sustain and add notes; play more than sixteen at once to hear the oldest voices stolen. Ctrl-C to quit.

Components Used

Component Role

Saw Wave Oscillator

Band-limited sawtooth, one per voice: q::saw(phase)

State Variable Filter

Per-voice resonant low-pass, swept by the envelope

Envelope Generator

adsr_envelope_gen: each voice’s contour; attack() retriggers a stolen voice

phase_iterator

Each voice’s pitch, set per note-on

Clip

cubic_clip: soft-clips the summed mix

MIDI Processor

Note on/off dispatch to voice allocation

MIDI Stream

The incoming MIDI byte stream

Audio Device, Audio Stream

Chosen output device, processing loop