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:
- Expect to make the level up later, at your
audiomixer.Mixervoice level or in your amplifier. There is nosynthiosetting that reaches full scale with a single note. - If you mix
synthioandRawSamplein one project, scale your sample data down by half so the two sit at the same level.
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_startandwaveform_loop_endare ignored unless that sameNotealso has its ownwaveformset. Setting loop points on aNotethat relies on theSynthesizer’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