Polyphonic Synth

A cartoon octopus playing a polyphonic synthesizer

Overview

example/poly_synth/poly_synth.cpp is a sibling of the Square Synth: the same per-note voice (an oscillator through an ADSR-swept resonant low-pass and amplifier), but played many at once, so chords and overlapping notes ring together. Here the voice’s oscillator is a bandwidth-limited sawtooth rather than a square, sixteen of them ready to form a small choir.

Polyphonic synth voice flow
Figure 1. A note is allocated to a free voice (or steals the oldest); the active voices are summed, gained, and soft-clipped.

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.

The Voice

A voice bundles the per-note state: a phase_iterator, an adsr_envelope_gen (Envelope Generator), 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.

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.

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


Previous: Square Synth | Next: Grain Freeze