Skip to the content.

Synthio Tutorial: A. Under the Hood

The rest of this tutorial shows you how to make sounds. This appendix collects the implementation details that will eventually surprise you: the numbers that explain why something is quieter than you expected, why a fast LFO stops getting faster, or why a note you pressed never arrived. Each one is a thing that changes what you’d write in code.

You don’t need any of this to make music with synthio. Come back when something doesn’t behave.

Gain and levels

A single note tops out at half scale

One synthio.Note at amplitude=1.0, with a full-scale +/-32767 waveform and an envelope at full level, peaks at about +/-16383 – half of the 16-bit range, or about 6 dB down.

This is deliberate headroom so that voices can sum without immediately clipping, but it catches people out when comparing against sample playback. The same waveform played through audiocore.RawSample gets no such treatment, so it comes out roughly twice as loud.

Two practical consequences:

The output limiter

Once the summed output passes +/-28000 (about 85% of full scale), a limiter starts reducing gain so that all voices together still fit in 16 bits. Below that threshold, samples pass through untouched.

A single note never gets near it. Two full-amplitude notes sum to about 32765 and get squashed back to roughly 28026 on RP2040. So stacking notes is not linear in loudness – the second note adds much less than the first, and the tenth adds almost nothing.

Sample format

synthio output is always signed 16-bit, and channel_count must be 1 or 2. Those aren’t configurable, whatever your audio output device is doing downstream.

Timing and update rates

LFOs and envelopes both update every 256 samples

This is the single most useful number in this appendix. synthio computes audio in blocks of 256 samples, and both LFO and Envelope values are recomputed once per block, not once per sample. Modulation is a staircase, not a smooth curve.

sample_rate block period modulation update rate fastest useful LFO
11025 (default) 23.2 ms 43 Hz ~21 Hz
22050 11.6 ms 86 Hz ~43 Hz
44100 5.8 ms 172 Hz ~86 Hz
48000 5.3 ms 188 Hz ~94 Hz

The last column is the practical ceiling: an LFO needs at least two updates per cycle to be a waveform rather than noise, so past about half the update rate you get aliased garbage instead of a faster wobble. If you want audio-rate modulation, that’s what ring_frequency is for, not LFO.

Envelope steps are blocky, and changes wait for a zero crossing

Envelope gain is held constant across each 256-sample block. When it does step to its next value, synthio waits for a zero crossing in the audio to make the step inaudible, falling back to the block boundary if no crossing turns up.

note.amplitude and note.panning go through the same machinery, so a change you make in Python takes up to one block to be heard.

Note that the zero-crossing trick works least well exactly where you’d want it most: on very low notes, whose period is longer than one block, there often isn’t a crossing to find, so you can still get a faint click on a big amplitude jump.

Envelope times round up to whole blocks

Because the envelope only advances once per block, the times you ask for get quantized. attack_time=0.1 at 48 kHz actually runs about 101.3 ms, and the shortest non-zero segment you can get is one block.

The error is relative, so it grows with the time you ask for: roughly 6% off at 10 seconds, and 20% at 30 seconds. Long ambient swells are approximate. Short ones are fine.

Don’t share one LFO between the synth and the mixer

An LFO object is advanced by whichever part of the audio graph reads it, once per that part’s own block. So if you attach the same LFO to both a Note parameter and an audiomixer.Mixer voice level, it gets advanced twice per buffer and runs about 1.5x faster than its rate says.

tremolo = synthio.LFO(rate=2)
note = synthio.Note(440, amplitude=tremolo)
mixer.voice[0].level = tremolo   # now tremolo runs fast, in both places

Make a second LFO instead. Two objects with the same rate stay at the rate you asked for.

An LFO used only on Note parameters, or only in synth.blocks, keeps correct time – including when the synth is routed through a Mixer, which is what every example in this tutorial does.

Limits and edge cases

Polyphony is set at compile time

The maximum number of simultaneous voices is baked into the build, not something you pass to Synthesizer:

Port Max voices
RP2040 / RP2350 24
ESP32 family 12
SAMD5x 12
nRF52 12
i.MX RT 12

Read it at runtime from Synthesizer.max_polyphony rather than hardcoding a number, since it varies by port and has changed before.

Too many notes fails silently

When you press a note, synthio takes a free voice if there is one, otherwise it steals the quietest voice that is already releasing.

Watch out: If every voice is actively held down, press() does nothing at all. It raises no exception, returns no error, and the note will not sound later when a voice frees up – it is simply dropped.

If you’re building anything that can be handed more notes than you have voices – a MIDI synth, an arpeggiator, a sustain pedal – do your own voice tracking and note-stealing in Python rather than relying on synthio to sort it out.

Pressing the same Note twice retriggers it

Pressing a Note object that is already sounding re-enters the attack phase on the voice it already owns. It does not allocate a second voice, so you can’t stack a Note on itself.

Two distinct Note objects at the same frequency do stack normally. That’s how you build unison and detuned-oscillator sounds:

# two voices, slightly detuned
n1 = synthio.Note(440)
n2 = synthio.Note(442)
synth.press((n1, n2))

Above Nyquist, notes go silent

A note whose frequency exceeds half the sample rate is skipped entirely. It still presses, still holds, still releases, still occupies a voice – it just makes no sound, and you get no error to tell you why. At the default 11025 Hz sample rate, that means anything above 5512 Hz.

The ring oscillator is checked separately, so an over-Nyquist ring_frequency silences only the ring modulation while the note itself keeps playing.

The default sample rate is low, and it sets three ceilings

synthio.Synthesizer() defaults to sample_rate=11025 and channel_count=1. That default is worth overriding, because the sample rate sets three separate limits at once:

sample_rate highest note filter ceiling
11025 (default) 5512 Hz 2756 Hz
22050 11025 Hz 5512 Hz
44100 22050 Hz 11025 Hz
48000 24000 Hz 12000 Hz

plus the modulation update rate from the table further up. The filter ceiling is explained next.

Gotchas

Pitch resolution depends on waveform length

Frequency is held as a fixed-point step through your waveform table, so a shorter table gives coarser pitch. The default 2-sample square wave is the worst case by a wide margin:

Waveform length Pitch step at 48 kHz Error at middle C
2 (the default square) 0.366 Hz ~2.4 cents
256 0.0029 Hz ~0.02 cents
16384 (the maximum) 0.00004 Hz negligible

2.4 cents is small but audible as slight mistuning in chords. If you care about precise tuning, supply a real wavetable – even when what you want is a square wave, a 256-sample square tunes far better than the default 2-sample one.

Waveforms must be signed 16-bit (array.array('h') or np.int16), at most synthio.waveform_max_length (16384) samples, and are interpreted as exactly one cycle.

Keep filter frequency below a quarter of the sample rate

synthio.Biquad computes its coefficients with a fast trigonometric approximation that’s only valid up to sample_rate / 4. Above that the approximation diverges and the filter stops doing anything meaningful. At the default sample rate that ceiling is only 2756 Hz.

Nothing validates this, so a filter sweep that runs off the top end just produces nonsense rather than an error. Also avoid Q=0, which produces non-finite coefficients and is likewise unvalidated.

Waveform loop points need the Note to own its waveform

Watch out: waveform_loop_start and waveform_loop_end are ignored unless that same Note also has its own waveform set. Setting loop points on a Note that relies on the Synthesizer’s default waveform does nothing, silently.

# loop points ignored -- this Note has no waveform of its own
n = synthio.Note(440, waveform_loop_end=100)

# loop points respected
n = synthio.Note(440, waveform=my_wave, waveform_loop_end=100)

Math divide-by-zero returns zero

synthio.MathOperation guards against division by zero, but not the way the API docs say. MUL_DIV is documented as returning 1.0 when its divisor is zero, and DIV_ADD as returning c. Both actually return 0.0.

If you’re relying on that fallback value for anything, test it rather than trusting the documentation.

LFO value is a snapshot

Reading lfo.value from your own code is cheap and side-effect free – it returns the value cached at the last block, and does not advance the LFO or disturb the audio.

The flip side is that it only changes once per block, and it won’t reflect a parameter you just set until the synth has ticked again:

lfo = synthio.LFO(rate=1, offset=0.5)
lfo.offset = 0.9
print(lfo.value)   # still reflects offset=0.5 until the next block

Relatedly, an LFO that isn’t attached to any Note parameter doesn’t run at all until you add it to the synth:

synth.blocks.append(my_lfo)   # required for a free-running LFO