Skip to content

Commit 94b1c17

Browse files
authored
Merge fix/audioif-pin-cebb7ca: the effects catch up with the audioif floor (#66)
Catch the effects up with the audioif floor at cebb7ca
2 parents a3eaab6 + 56e8848 commit 94b1c17

16 files changed

Lines changed: 348 additions & 138 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ there, and are recorded in its changelog.
8181
nearest tom for every tom number a kit does not have. Each machine now
8282
sounds exactly the notes in its `NOTE_MAP`, and nothing else.
8383

84+
### Fixed
85+
86+
- **The effects catch up with the audioif floor at cebb7ca** (#66). Moving
87+
`AUDIOIF_PIN` there for `acoustickit` brought five deliberate audioif
88+
changes, and 27 tests went red. `AutoPan`'s Centre was mirrored, because
89+
CircuitPython 10.3.0 reversed synthio's panning sign; its notes are paired
90+
the other way now. A fresh mixer voice or synthio note now starts at level 0
91+
and waits for a zero crossing, so the first block of `AutoPan`, `DeEsser`
92+
and `MultibandCompressor` came out silent and then clicked in; each opens
93+
its gates on one block of silence before it takes its source
94+
(`_component.open_level_gates`). `Saturation`'s shelves moved to
95+
`audiobiquad`, because `synthio.Biquad` is CircuitPython's Q15 arithmetic
96+
again (audioif#77) and tape had lost its head bump. `BandPass` T1 now
97+
holds in 55 of 56 cells, both stops included (audioif#64). The rest were
98+
tests holding audioif's old behaviour: released nodes raise `ValueError`,
99+
`Splitter` releases, `Dynamics.reset()` clears its key filters, and there
100+
are 55 instruments.
101+
84102
### Removed
85103

86104
- **`Rotary` is removed.** The Leslie-style rotating-speaker effect is no longer in `audioeffects`; nothing replaces it yet.

lib/audioeffects/README.md

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ marginal share of one block on each board at construction defaults.
125125
| `DynamicEQ` | audioif (`audiobiquad`, `audiodynamics`, `audioroute`) | 8 | 0 samples | 14.5 % P4 / 26.0 % S3 of a block at patch 0 against the palette-derived 17 % / 29 % (listed remainder; `Splitter` taps=3 is a named gap). T2/T5 miss above ~5 ms attack; T4/T6 out-of-band miss below Q 2 | none - the exactly complementary split: one bell that does nothing until the sound *in that band* crosses a threshold, and a measured **wire** when it is idle |
126126
| `LowPass` | audioif (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget, and no `" - lean"` patch is possible: every section runs whatever its `mix` is, so no macro position is cheaper than any other | none - the two-pole analog prototype `H(s) = 1/(s² + 2Rs + 1)`: one knob slides the curve, one decides how loud the corner stands, −3 dB at Resonance 0.707 and +24 dB at 16. **Exact except at the bottom of the Frequency knob**: at f₀ 20 Hz with Q 16 at 24 dB/oct the corner stands 0.44 dB low and the curve stops being one shape below 25 Hz (float32 coefficients near z = 1), and "rings for Q periods" is a 12 dB/oct statement below about 0.17·F_s - at 24 dB/oct the ring runs 1.36× the Resonance number at Q 2 up to 1.71× at Q 16. The roll-off, −12.03 dB/oct on the warped axis with unity DC, holds everywhere |
127127
| `HighPass` | audioif (`audiobiquad`) | 5 | 0 samples | 5.7 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget, and no patch on this surface is cheaper - patch 0 is already one live section and two wires | none - RBJ's two-pole low-cut, with an exact transmission zero at DC: a held offset decays to zero rather than to the 71 LSB the ported node parks on at a 10 Hz corner. Two bounds, measured: the two numbers on the panel are exact only above about 40 Hz at 48 kHz - at a 10 Hz corner the peak is 0.6 dB shy of `Resonance` (Q 8, 24 dB/oct) and the curve is 0.21 dB off its own shape, both the node's `float` coefficients rather than the cut - and at 24 dB/oct the corner **rings 1.85x longer** than the knob says, which is the price of reading the same gain at both slopes. The cut, the 12/24 dB/oct skirt and the exact zero at DC hold everywhere |
128-
| `BandPass` | audioif (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. **Above 100 Hz.** Below it, at Q >= 4, the peak loses up to 0.50 dB and the loss changes sign with the level: the kernel's float32 recursion, not the form (audioif#64). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB |
128+
| `BandPass` | audioif (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audioif#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB |
129129
| `Notch` | audioif (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator |
130130
| `LadderFilter` | audioif (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit |
131131
| `CombFilter` | audioif (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. Above Feedback 0.5 the parked ring's period is the nearest whole number of samples to F_s/Frequency, not the fractional delay the comb was asked for: +17.4 cents at 1760 Hz / Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — about 70 cents — near 4 kHz. The first-repeat tap still lands within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, the tail reaches exact zero. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series |
@@ -266,33 +266,34 @@ macros move their children's controls.
266266

267267
## A note on how low a filter can go
268268

269-
Anywhere in the band, is the short answer - but it is worth knowing that
270-
this was not always true, because the failure was silent and you may still
271-
meet it on a stock CircuitPython board (below).
272-
273-
Every biquad in the engine used to keep its coefficients as Q15 integers,
274-
which is the right trade on a microcontroller and costs low frequencies.
275-
Below about 300 Hz they quantized into something that was no longer the
276-
filter you asked for: a `LowPass` at 100 Hz returned **silence**, a
277-
`HighPass` at 30 Hz returned **+21 dB of noise**, and a low shelf at 80 Hz
278-
lifted the whole band by 13.4 dB instead of its 1.5. A second, unrelated
279-
shortcut in the same file - one polynomial fitted to sine and cosine over
280-
[0, π/2], which is only 12 kHz at 48 kHz - broke the *top* of the band too,
281-
badly enough that a `HighPass` at 22 kHz passed its entire stopband.
282-
283-
Both are fixed. Coefficients now get as many fractional bits as each
284-
individual filter has room for, the recursion accumulates in 64 bits and
285-
keeps its feedback below the sample grid, and the trigonometry is a proper
286-
series. Measured against the closed-form response, every mode lands within
287-
**0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read +6.01 dB or
288-
better on a +6 dB request - the claim `GraphicEQ` used to carry, and read on
289-
`ParametricEQ` since both were rebuilt onto `audiobiquad`. The pre-rebuild
290-
`MultibandCompressor`'s three bands recombined flat to 0.23 dB from 30 Hz to
291-
8 kHz on those Q15 biquads; the rebuilt class is on `audiobiquad`'s float
292-
sections instead, for the tail rather than the shape, and sums to 0.12 dB
293-
from 30 Hz to 20 kHz. [audioif's `docs/upstream-diff.md`](https://github.com/PyDevices/audioif/blob/main/docs/upstream-diff.md),
294-
"The biquads were Q15, so they could not go low", has the arithmetic, the
295-
before-and-after table, and what it cost in instructions on an M0.
269+
Anywhere in the band on `audiobiquad`, which is what the rebuilt filters and
270+
EQs here are built on. On `synthio.Biquad` - and so `audiofilters.Filter` and
271+
a `Note.filter` chain - the low end is CircuitPython's, on every target, and
272+
it is worth knowing why.
273+
274+
CircuitPython keeps a biquad's coefficients as Q15 integers, which is the
275+
right trade on a microcontroller and costs low frequencies. Below about
276+
300 Hz they quantize into something that is no longer the filter you asked
277+
for: a `LowPass` at 100 Hz returned **silence**, a `HighPass` at 30 Hz
278+
returned **+21 dB of noise**, and a low shelf at 80 Hz lifted the whole band
279+
by 13.4 dB instead of its 1.5. A low-pass fed a DC burst can also park at a
280+
fixed point for ever - half of full scale from a 40 Hz low-pass.
281+
282+
audioif widened that arithmetic once for every biquad. Since audioif#77 the
283+
split is by ownership: `synthio.Biquad` is a node CircuitPython also has, so
284+
it runs CircuitPython's arithmetic everywhere (at the current floor that
285+
80 Hz shelf reads -7.65 dB), and the widened kernel belongs to
286+
`audiobiquad`, which is audioif's own. There, coefficients get as many
287+
fractional bits as each filter has room for, the recursion accumulates in
288+
64 bits and keeps its feedback below the sample grid, and the trigonometry
289+
is a proper series. Measured against the closed-form response, every mode
290+
lands within **0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read
291+
+6.01 dB or better on a +6 dB request, read on `ParametricEQ`. The rebuilt
292+
`MultibandCompressor` sums to 0.12 dB from 30 Hz to 20 kHz on
293+
`audiobiquad`'s float sections. `Saturation` builds its shelves there too,
294+
and falls back to `synthio.Biquad` only where `audiobiquad` is missing.
295+
[audioif's `docs/upstream-diff.md`](https://github.com/PyDevices/audioif/blob/main/docs/upstream-diff.md),
296+
"The biquads were Q15, so they could not go low", has the arithmetic.
296297

297298
Nothing refuses a low frequency and nothing ever did, because a
298299
`LadderFilter` sweeping down through 40 Hz is a legitimate thing to do.

lib/audioeffects/_component.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,38 @@ def macro_of(span, value):
188188
return int(round(macro_position(span, value) * 127))
189189

190190

191+
# --------------------------------------------------------------------------
192+
# Level gates
193+
194+
195+
def open_level_gates(node, voices, silence):
196+
"""Render one block of silence through `node` so every voice starts at
197+
its level instead of at zero. Returns whether it could.
198+
199+
Since CircuitPython 10.3.0 a mixer voice or a synthio note does not take
200+
a level, amplitude or pan change until its signal is at zero or changes
201+
sign, and a fresh one starts at level 0 (audioif 4ec5718). So a class's
202+
first block is gated: silent for material that does not cross zero in
203+
it - a gain table, a ramp, an impulse at frame 0 - and then the level
204+
steps in at the block boundary, which is a click. A zero sample opens
205+
the gate at once, so each voice in `voices` plays `silence` (a looped
206+
all-zero sample the node accepts) for one pull; the caller then hands
207+
the voices their real sources. Set the levels first: a level moved
208+
after this waits for a zero crossing, as it should.
209+
210+
`audiocore.get_buffer` is compiled out of CircuitPython's default board
211+
builds (CIRCUITPY_AUDIOCORE_DEBUG), so there this does nothing and the
212+
first block keeps upstream's behaviour.
213+
"""
214+
pull = getattr(audiocore, "get_buffer", None)
215+
if pull is None:
216+
return False
217+
for voice in voices:
218+
voice.play(silence, loop=True)
219+
pull(node)
220+
return True
221+
222+
191223
# --------------------------------------------------------------------------
192224
# Metadata
193225

lib/audioeffects/autopan.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -314,13 +314,30 @@ def _modulate_synth(self, rate_hz, wave_l, wave_r):
314314
if synth is None:
315315
synth = synthio.Synthesizer(
316316
sample_rate=self._sample_rate, channel_count=2)
317-
# CPython synthio pans +1 to the left column and -1 to the right
318-
# (scaled panning in synthio.py). Native builds match that pair.
317+
# Since CircuitPython 10.3.0, panning > 0 attenuates the LEFT
318+
# channel, so -1 lands a note on the left column alone and +1 on
319+
# the right, on every target (audioif 4ec5718). The pre-10.3.0
320+
# pairing was the reverse and put each gain on the wrong side.
321+
#
322+
# A fresh note renders at level 0 until its output crosses zero
323+
# (the same 10.3.0 change), and a gain table never does, so left
324+
# alone the first block is silent and the gain then steps in -
325+
# a click on whatever is already playing. The notes start on a
326+
# silent waveform for one block, which opens the gate at its
327+
# first sample (see `_component.open_level_gates`), and take
328+
# their tables after it.
329+
silent = array("h", bytes(2 * len(wave_l)))
319330
note_l = synthio.Note(
320-
frequency=rate_hz, waveform=wave_l, panning=1.0)
331+
frequency=rate_hz, waveform=silent, panning=-1.0)
321332
note_r = synthio.Note(
322-
frequency=rate_hz, waveform=wave_r, panning=-1.0)
333+
frequency=rate_hz, waveform=silent, panning=1.0)
323334
synth.press((note_l, note_r))
335+
import audiocore
336+
pull = getattr(audiocore, "get_buffer", None)
337+
if pull is not None:
338+
pull(synth)
339+
note_l.waveform = wave_l
340+
note_r.waveform = wave_r
324341
self._synth = synth
325342
self._note_l = note_l
326343
self._note_r = note_r

lib/audioeffects/bandpass.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,17 @@
5252
belong to RBJ's prototype, which this class tracks to nine thousandths of a
5353
decibel wherever it was checked.
5454
55-
**The build's one - the 0 dB peak is not held at a low centre with a narrow
56-
width.** T1 says the gain at f0 is 0.00 dB +- 0.05 at every width. Measured
57-
2026-09-07 over the whole `Frequency` x `Width` grid at four probe levels, it
58-
holds **at every width for f0 >= 100 Hz, and at every centre for Q <= 2**, and
59-
it does not hold in eleven of fifty-six cells below that - all of them f0
60-
<= 63 Hz with Q >= 4, worst **-0.50 dB at f0 20 Hz with Q 32**, which is the
61-
`Frequency` knob's bottom stop against the `Width` knob's top. Two knob turns
62-
from patch 5 `Sub Window`. What it sounds like: a sub-bass resonance up to
63-
half a decibel quieter than the same knob setting an octave higher, and the
64-
error changes sign with the signal level (+0.09 dB at -3 dBFS, -0.48 at -12,
65-
+0.44 at -20), so it is not a trim anyone can dial out.
55+
**The build's one - the 0 dB peak is not held at one low, narrow cell.** T1
56+
says the gain at f0 is 0.00 dB +- 0.05 at every width. Measured 2026-09-17 at
57+
the audioif cebb7ca floor over the whole `Frequency` x `Width` grid at four
58+
probe levels, it holds in fifty-five of fifty-six cells, both knob stops
59+
included, and misses in one: **-0.091 dB at f0 31.5 Hz with Q 32**. What it
60+
sounds like: nothing anyone will hear - a tenth of a decibel on the narrowest
61+
sub-bass band the knobs reach.
62+
63+
It used to be eleven cells, all f0 <= 63 Hz with Q >= 4, worst -0.50 dB at
64+
the `Frequency` knob's bottom stop against the `Width` knob's top, with an
65+
error that changed sign with the signal level. The history of why follows.
6666
6767
The cause is not this class and not the prototype. RBJ's closed form at that
6868
cell is `+0.00000 dB`, and `audiobiquad`'s own five coefficients, read off the
@@ -73,8 +73,9 @@
7373
coefficients cancel to seven parts in a million, so the increment single
7474
precision has to carry is 2e-5 of the numbers being differenced. Filed as
7575
audioif#64 with the fix (a transposed direct form II costs nothing at run
76-
time); this class is parked on it as audiocomponents#39. The measurement, the
77-
map and the four-way decomposition are
76+
time), and the fix landed; the one cell left is what it did not reach
77+
(audiocomponents#66). The measurement, the map and the four-way
78+
decomposition are
7879
`workspace docs/effects-internal/probes/phase2_probes/bandpass_lowcorner.py`.
7980
8081
**The prototype's four**, each measured against RBJ's closed form at the
@@ -130,10 +131,10 @@ class never reads `self._transport()`.
130131

131132

132133
class BandPass(_component.Component):
133-
"""A resonant band-pass: 0 dB at the centre at every width above 100 Hz,
134-
exact zeros at DC and Nyquist, +-6 dB/octave skirts (+-12 with `Slope`
135-
on). Below 100 Hz at Q >= 4 the peak loses up to half a decibel - the
136-
kernel's float32 recursion, audioif#64, module docstring."""
134+
"""A resonant band-pass: 0 dB at the centre at every width, exact zeros
135+
at DC and Nyquist, +-6 dB/octave skirts (+-12 with `Slope` on). The one
136+
exception is a tenth of a decibel at f0 31.5 Hz with Q 32 - module
137+
docstring."""
137138

138139
NAME = 'BandPass'
139140
DISPLAY_NAME = 'Band Pass'

lib/audioeffects/deesser.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,6 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
280280
band_only = bool(hf_only) or bool(listen)
281281
pre.voice[0].level = 0.0 if band_only else 1.0
282282
pre.voice[1].level = 1.0 if band_only else 0.0
283-
pre.voice[0].play(raw.tap(0))
284-
pre.voice[1].play(band.tap(0))
285283

286284
duck = audiodynamics.Dynamics(
287285
audiodynamics.DYN_LIMIT,
@@ -300,11 +298,10 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
300298
# comparison needs the whole signal, so the key cannot be the band.
301299
duck.key(top.tap(0))
302300

301+
# Voice 0 plays the dry, broadband; 1 the low half, HF-only; 2 the
302+
# dry high half, HF-only; 3 the ducked stream, both modes. They are
303+
# handed their sources at the end of construction - see there.
303304
out = audiomixer.Mixer(voice_count=4, **self._pcm(1024))
304-
out.voice[0].play(raw.tap(1)) # the dry, broadband
305-
out.voice[1].play(lows[1]) # the low half, HF-only
306-
out.voice[2].play(band.tap(1)) # the dry high half, HF-only
307-
out.voice[3].play(duck) # the ducked stream, both modes
308305

309306
# The class does NOT end in a mixer, and that is not decoration.
310307
# On CircuitPython `audiomixer.Mixer.reset_buffer` *stops* every
@@ -388,6 +385,21 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
388385
1.0 if hf_only else 0.0, release_ms, attack_ms,
389386
1.0 if listen else 0.0), patch)
390387

388+
# The voices get their sources last, after the macros have set every
389+
# level and corner. Since CircuitPython 10.3.0 a fresh mixer voice
390+
# starts at level 0 and takes its level only when its signal crosses
391+
# zero, so a Range 0 bypass rendered its first 256 frames silent on
392+
# a ramp, and an impulse at frame 0 never came through at all. One
393+
# block of silence opens every gate at the level just set
394+
# (`_component.open_level_gates`); then each voice takes its source,
395+
# `pre` before `out` because `out`'s voice 3 pulls through `pre`.
396+
_component.open_level_gates(pre, [pre.voice[0], pre.voice[1]],
397+
self._silence)
398+
_component.open_level_gates(
399+
out, [out.voice[index] for index in range(4)], self._silence)
400+
for mixer, index, sample in self._voices:
401+
mixer.voice[index].play(sample)
402+
391403
# -- reset ---------------------------------------------------------
392404

393405
def _reset_chain(self):

0 commit comments

Comments
 (0)