Skip to content

audioladder: a cutoff input read every sample, for audio-rate filter FM - #293

Merged
bdbarnett merged 2 commits into
mainfrom
feat/ladder-cutoff-buffer
Oct 21, 2026
Merged

bdbarnett merged 2 commits into
mainfrom
feat/ladder-cutoff-buffer

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

Ladder.set(cutoff_hz=...) moves the cutoff once a block. That's fine for an envelope or a knob, but sweep the cutoff with an audio-rate signal that way and you get a staircase, which you hear as a buzz at the block rate.

This adds a cutoff audio input, following the way audiooscillator.Oscillator takes its pitch:

ladder = audioladder.Ladder(cutoff_hz=800.0, resonance=3.6, cutoff_octaves=2.0)
ladder.modulate(cutoff=lfo)   # or Ladder(..., cutoff=lfo)

The cutoff becomes cutoff_hz * 2 ** (v * cutoff_octaves) for an input at v of full scale. Only the first channel is read, and the result is clamped to the same band cutoff_hz is. cutoff_octaves defaults to 10, which is the oscillator's pitch scale. An input of zero, an input that has run dry, or no input at all gives you the configured filter bit for bit.

The per-sample coefficients use a deterministic exp2 (moved out of the oscillator into shared/audiodsp_exp2.h) and a sin/(sin + cos) series instead of libm's tanf, so every target renders them the same. A held input value is cached. The unmodulated path's output is unchanged: the ladder probe gives identical bytes before and after. Its speed is unchanged too, at 23.7-24.1 µs per stereo 256-frame block both ways. The modulated path costs about 15% more.

The MicroPython binding, the CircuitPython spike and CPython all get the same surface: modulate(), the cutoff keyword and cutoff_octaves.

Tests, in tests/test_cpython_audioladder_cutoff.py:

  • A zero input renders the same bytes as no input.
  • A constant input matches set() at the cutoff it names, within 1 LSB.
  • Audio-rate FM with a 1900 Hz carrier, a 100 Hz modulator and 64-frame blocks at 48 kHz. With the input, the 750 Hz block-rate spur is 190 dB under the carrier. Stepped once a block with set(), the control, it's 18 dB under.
  • A dry input counts as zero, a stereo input is read from its first channel, and modulate() refuses names and values that aren't inputs.

ladder_probe.py gains swept, held and stereo-input fixtures, so verify_dsp compares the new path across interpreters. Locally, MicroPython v1.29.0 unix and CPython agree on all 58 comparisons.

set(cutoff_hz=...) moves the cutoff once a block, so a cutoff swept by an
audio-rate signal is a staircase and buzzes at the block rate. Ladder now
takes a `cutoff` audio input, connected with modulate(cutoff=...) or in the
constructor, as audiooscillator.Oscillator takes its pitch: the cutoff is
cutoff_hz * 2 ** (v * cutoff_octaves) for an input at v of full scale, first
channel only, clamped to the band cutoff_hz is. cutoff_octaves defaults to
10, the oscillator's pitch scale.

The per-sample coefficients come from a deterministic exp2 (moved out of the
oscillator into shared/audiodsp_exp2.h) and sin/(sin + cos) series rather than
libm's tanf, so every target renders them alike; a held input is cached. An
input of zero, or none, is the configured filter bit for bit, and the
unmodulated loop is unchanged in output and speed.

All three bindings (MicroPython, the CircuitPython spike and CPython) get the
same surface. Tests: zero input matches no input exactly, a constant input
matches set() within 1 LSB, and FFT shows the 750 Hz block-rate spur of a
64-frame block 190 dB under the carrier with the input against 18 dB under
when stepped with set(). ladder_probe gains swept, held and stereo-input
fixtures for verify_dsp.
@bdbarnett
bdbarnett merged commit 398b5c1 into main Oct 21, 2026
31 checks passed
@bdbarnett
bdbarnett deleted the feat/ladder-cutoff-buffer branch October 21, 2026 10:23
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