Skip to content

audiooscillator: a band-limited oscillator with audio-rate sync, FM and PWM; Ladder at any block size - #286

Merged
bdbarnett merged 2 commits into
mainfrom
phase0-osc-ladder
Oct 20, 2026
Merged

bdbarnett merged 2 commits into
mainfrom
phase0-osc-ladder

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

Two nodes for synthesizers built from audio-rate parts.

audiooscillator.Oscillator is the oscillator synthio doesn't have: saw, pulse, triangle and sine from one phase, with pitch, linear FM, pulse width and hard sync read from audio streams every sample.

osc = audiooscillator.Oscillator(frequency=220.0, waveform=audiooscillator.SAW, fm_depth=300.0)
osc.modulate(sync=audiooscillator.Oscillator(frequency=110.0, waveform=audiooscillator.PULSE), fm=lfo)
audio_out.play(osc)

waveform is any sum of SAW, PULSE, TRIANGLE and SINE, and a two-channel node puts waveform_right on the right channel from the same phase. Steps are band-limited with PolyBLEP and the triangle's corners with PolyBLAMP. A sync edge is placed between the two samples it fell between and band-limited like any other step, which costs one sample of latency. It builds for MicroPython, the CPython extension and CircuitPython (circuitpython.mk), and takes buffer_size like the effects.

audioladder.Ladder(buffer_size=...) sets the block size in bytes, as the CircuitPython effects do, from one frame up to the default 256. A host running 64-frame blocks gets the filter with no latency and a cutoff that can move every 64 frames. The filter is per sample, so the audio doesn't change: ladder_probe.py renders byte-identical to main on MicroPython and CPython, and the new ladder_frames_probe.py shows 64-, 1- and 200-frame renders equal to the default one.

Evidence

  • Aliasing, a 4567 Hz note at 48 kHz, energy outside the harmonics below 10 kHz relative to the harmonics: saw -50.7 dB (naive -13.5), pulse -50.6 dB (naive -15.2), triangle -69.5 dB (naive -36.3). An additive band-limited saw reads -93.6 dB, which is the int16 floor. The tests hold saw and pulse to -45 dB and the triangle to -60 dB, and check that the naive versions fail those bars.
  • Sync: a saw synced to a pulse Oscillator matches the analytic synced saw to within 0.003 away from its edges (the same comparison without sync is off by more than 0.1).
  • FM, PWM and pitch are read every sample: a saw's per-sample slope tracks a 750 Hz FM input to 2e-4, and duty cycle and period follow a step that lands mid-block.
  • Without its inputs, the oscillator matches a Python PolyBLEP saw and pulse sample for sample, to float precision.
  • Planted faults, each restored afterwards: sync detection disabled (sync tests fail), the BLEP kernel made a no-op (band-limiting and sync tests fail), buffer_size ignored in the CPython Ladder (block-size tests fail) and in the MicroPython binding (ladder_frames_probe.py reports all six renders unequal).
  • verify_dsp.py: 58 comparisons, 0 failures, CPython against a MicroPython unix build of this branch. The deinit-surface and host-reset probes cover the new node on both.

Ladder takes buffer_size in bytes, as the CircuitPython effects do, from one
frame up to the default 256. Each pull then hands out that many frames and
takes exactly one block from a source of the same size, so a host running
small blocks gets the filter with no latency and a cutoff that can move once
a block. The filter is per sample, so the audio is the default render cut
smaller; renders at the default size are byte-identical to before.
…ch at audio rate

Oscillator renders saw, pulse, triangle and sine from one phase, any sum of
them per channel, with a two-channel node putting a second waveform on the
right from the same phase. Its pitch (exponential), linear FM, pulse width
and hard sync are audio streams read every sample, so one oscillator can sync
to another or be modulated by it inside a block. Steps are PolyBLEP and the
triangle's corners PolyBLAMP; a sync edge is placed between the samples it
fell between and band-limited like any other step, at the cost of one sample
of latency. Built for MicroPython, the CPython extension and CircuitPython,
with buffer_size like Ladder's.

verify_dsp gains its probe and the ladder's block-size probe, and the deinit
and host-reset probes cover the new node.
@bdbarnett
bdbarnett merged commit d7794ad into main Oct 20, 2026
32 checks passed
@bdbarnett
bdbarnett deleted the phase0-osc-ladder branch October 20, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant