Skip to content

audiograph: a clipping two-pole filter and an overdrive - #322

Merged
bdbarnett merged 4 commits into
mainfrom
graph/twopole-overdrive
Oct 27, 2026
Merged

bdbarnett merged 4 commits into
mainfrom
graph/twopole-overdrive

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Two new audiograph node kinds, added after SAMPLE_HOLD so the existing kinds keep their numbers. They work on CPython, MicroPython and CircuitPython, and no node file outside audiograph changes.

TWOPOLE is three filters behind one node with a type: the Sallen-Key low-pass and high-pass (diode-clipped peak feedback) and the CMOS state-variable filter in low-pass, band-pass and high-pass. These keep their sound in a clipper inside the loop, so a biquad can't stand in. Parameters are TWOPOLE_TYPE, TWOPOLE_CUTOFF_HZ, TWOPOLE_DEPTH, TWOPOLE_RESONANCE and TWOPOLE_DRIVE, with a second input for the cutoff CV. The cutoff is CUTOFF_HZ * 2^(cv / UNIT * depth * 10), held between 20 Hz and 0.45 of the sample rate. The CV is read at the block's first and last samples and the coefficient walks between them.

OVERDRIVE is audiofilters.Distortion's soft-clipped overdrive followed by a high shelf at 1.5 kHz (synthio.Biquad's arithmetic), with OVERDRIVE_DRIVE, OVERDRIVE_TONE and OVERDRIVE_LEVEL.

Both are held to the Python cores they stand in for (vrack's dsp/twopole.py and OverdriveCore):

node against result
CMOS low/band/high-pass CMOSFilterCore, CV still, stepping, running through a block, and open 0 counts over 184,320 samples
Sallen-Key low/high-pass SallenKeyLPFCore, SallenKeyHPFCore, same four CV cases within 1 count, 90 of 122,880 samples differ
overdrive the chain of audiofilters nodes OverdriveCore builds, knobs moving every block 0 counts

The Sallen-Key pair isn't at 0 because its tanh is a rational function in float that returns the same bits on every target, where the Python core's is libm's in double. The cutoff arithmetic does reproduce the Python core's float32 steps, because at 1e-7 the CMOS filter clips hard enough to turn a seventh-digit difference into 16 counts.

tests/parity/graph_nodes_probe.py now renders both, and verify_graph.py compares it across interpreters. CPython, MicroPython (double and single float) and CircuitPython unix agree on all 491,520 samples of that probe at 0 counts. Two new planted faults (a cutoff CV a block late, an overdrive tone a block late) each make it fail, and CI runs them first alongside the existing ones.

circuitpython.mk now also builds audiodsp_distortion.c, audiodsp_biquad.c and audiodsp_synth_dsp.c, which the overdrive calls.

One cost to know: while the cutoff CV moves within a block, the coefficient walks in double, one add and one divide a sample, so it matches the Python core exactly. On a board without a double FPU that is a few percent of one core per filter.

The VCA also takes VCA_CURVE: VCA_EXPONENTIAL is 60 dB over the CV, 10^(3·(cv/UNIT − 1)), the response a voice with an exponential VCA has. Linear stays the default.

Two node kinds, added after SAMPLE_HOLD so the existing kinds keep their
numbers, on CPython, MicroPython and CircuitPython alike. No node file
outside audiograph changed; circuitpython.mk now also builds the distortion,
biquad and synth-DSP kernels the overdrive calls.

TWOPOLE is the three filters of vrack's dsp/twopole.py behind one node with a
type: the Sallen-Key low-pass and high-pass (diode-clipped peak feedback) and
the CMOS state-variable filter in low-pass, band-pass and high-pass. The
cutoff CV is read at the block's first and last samples and the coefficient
walks between them, as those cores take it. The CMOS filter matches
CMOSFilterCore at 0 counts with the CV still, stepping, running through a
block, and not connected (184,320 samples). The Sallen-Key pair matches within
1 count, on 90 of 122,880 samples: their tanh is a rational function in float
that gives the same bits on every target, where vrack's is libm's in double.

OVERDRIVE is vrack's OverdriveCore: audiofilters.Distortion's soft-clipped
overdrive, then a high shelf in synthio.Biquad's own Q15 arithmetic, then
audiofilters.Filter's mix-down knee. It matches the chain of audiofilters
nodes at 0 counts, with the knobs moving every block.

tests/parity/graph_nodes_probe.py renders both, and verify_graph.py compares
it across interpreters: CPython, MicroPython (double and single float) and
CircuitPython unix agree on all 491,520 samples of that probe. Two new
planted faults, a cutoff CV a block late and an overdrive tone a block late,
each make it fail; CI runs them first.
VCA_CURVE = VCA_EXPONENTIAL makes the gain 60 dB over the CV,
10^(3 * (cv / UNIT - 1)), unity at UNIT, times VCA_GAIN. Linear stays
the default.
The overdrive node computes its gains with double-precision pow and log10,
and calls into audiodsp_distortion.c and audiodsp_biquad.c, which use pow,
round and ldexp. CircuitPython's RP2040 build has none of these to link,
so test_circuitpython_libm failed once those files joined circuitpython.mk.

The node now lives in audiodsp_graph_overdrive.c. CircuitPython builds
without it (AUDIODSP_GRAPH_HAS_OVERDRIVE=0) and without the two files.
There, audiodsp_graph_kind_built() says no, add() refuses the kind, and
the module has no OVERDRIVE constants. Every other build is unchanged.
@bdbarnett
bdbarnett marked this pull request as ready for review October 27, 2026 22:08
@bdbarnett
bdbarnett merged commit e29365b into main Oct 27, 2026
32 checks passed
@bdbarnett
bdbarnett deleted the graph/twopole-overdrive branch October 27, 2026 22:08
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