Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ there, and are recorded in its changelog.
nearest tom for every tom number a kit does not have. Each machine now
sounds exactly the notes in its `NOTE_MAP`, and nothing else.

### Fixed

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

### Removed

- **`Rotary` is removed.** The Leslie-style rotating-speaker effect is no longer in `audioeffects`; nothing replaces it yet.
Expand Down
57 changes: 29 additions & 28 deletions lib/audioeffects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ marginal share of one block on each board at construction defaults.
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
Expand Down Expand Up @@ -266,33 +266,34 @@ macros move their children's controls.

## A note on how low a filter can go

Anywhere in the band, is the short answer - but it is worth knowing that
this was not always true, because the failure was silent and you may still
meet it on a stock CircuitPython board (below).

Every biquad in the engine used to keep its coefficients as Q15 integers,
which is the right trade on a microcontroller and costs low frequencies.
Below about 300 Hz they quantized into something that was no longer the
filter you asked for: a `LowPass` at 100 Hz returned **silence**, a
`HighPass` at 30 Hz returned **+21 dB of noise**, and a low shelf at 80 Hz
lifted the whole band by 13.4 dB instead of its 1.5. A second, unrelated
shortcut in the same file - one polynomial fitted to sine and cosine over
[0, π/2], which is only 12 kHz at 48 kHz - broke the *top* of the band too,
badly enough that a `HighPass` at 22 kHz passed its entire stopband.

Both are fixed. Coefficients now get as many fractional bits as each
individual filter has room for, the recursion accumulates in 64 bits and
keeps its feedback below the sample grid, and the trigonometry is a proper
series. Measured against the closed-form response, every mode lands within
**0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read +6.01 dB or
better on a +6 dB request - the claim `GraphicEQ` used to carry, and read on
`ParametricEQ` since both were rebuilt onto `audiobiquad`. The pre-rebuild
`MultibandCompressor`'s three bands recombined flat to 0.23 dB from 30 Hz to
8 kHz on those Q15 biquads; the rebuilt class is on `audiobiquad`'s float
sections instead, for the tail rather than the shape, and sums to 0.12 dB
from 30 Hz to 20 kHz. [audioif's `docs/upstream-diff.md`](https://github.com/PyDevices/audioif/blob/main/docs/upstream-diff.md),
"The biquads were Q15, so they could not go low", has the arithmetic, the
before-and-after table, and what it cost in instructions on an M0.
Anywhere in the band on `audiobiquad`, which is what the rebuilt filters and
EQs here are built on. On `synthio.Biquad` - and so `audiofilters.Filter` and
a `Note.filter` chain - the low end is CircuitPython's, on every target, and
it is worth knowing why.

CircuitPython keeps a biquad's coefficients as Q15 integers, which is the
right trade on a microcontroller and costs low frequencies. Below about
300 Hz they quantize into something that is no longer the filter you asked
for: a `LowPass` at 100 Hz returned **silence**, a `HighPass` at 30 Hz
returned **+21 dB of noise**, and a low shelf at 80 Hz lifted the whole band
by 13.4 dB instead of its 1.5. A low-pass fed a DC burst can also park at a
fixed point for ever - half of full scale from a 40 Hz low-pass.

audioif widened that arithmetic once for every biquad. Since audioif#77 the
split is by ownership: `synthio.Biquad` is a node CircuitPython also has, so
it runs CircuitPython's arithmetic everywhere (at the current floor that
80 Hz shelf reads -7.65 dB), and the widened kernel belongs to
`audiobiquad`, which is audioif's own. There, coefficients get as many
fractional bits as each filter has room for, the recursion accumulates in
64 bits and keeps its feedback below the sample grid, and the trigonometry
is a proper series. Measured against the closed-form response, every mode
lands within **0.03 dB from 50 Hz to 22 kHz**. Ten octave bands all read
+6.01 dB or better on a +6 dB request, read on `ParametricEQ`. The rebuilt
`MultibandCompressor` sums to 0.12 dB from 30 Hz to 20 kHz on
`audiobiquad`'s float sections. `Saturation` builds its shelves there too,
and falls back to `synthio.Biquad` only where `audiobiquad` is missing.
[audioif's `docs/upstream-diff.md`](https://github.com/PyDevices/audioif/blob/main/docs/upstream-diff.md),
"The biquads were Q15, so they could not go low", has the arithmetic.

Nothing refuses a low frequency and nothing ever did, because a
`LadderFilter` sweeping down through 40 Hz is a legitimate thing to do.
Expand Down
32 changes: 32 additions & 0 deletions lib/audioeffects/_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,38 @@ def macro_of(span, value):
return int(round(macro_position(span, value) * 127))


# --------------------------------------------------------------------------
# Level gates


def open_level_gates(node, voices, silence):
"""Render one block of silence through `node` so every voice starts at
its level instead of at zero. Returns whether it could.

Since CircuitPython 10.3.0 a mixer voice or a synthio note does not take
a level, amplitude or pan change until its signal is at zero or changes
sign, and a fresh one starts at level 0 (audioif 4ec5718). So a class's
first block is gated: silent for material that does not cross zero in
it - a gain table, a ramp, an impulse at frame 0 - and then the level
steps in at the block boundary, which is a click. A zero sample opens
the gate at once, so each voice in `voices` plays `silence` (a looped
all-zero sample the node accepts) for one pull; the caller then hands
the voices their real sources. Set the levels first: a level moved
after this waits for a zero crossing, as it should.

`audiocore.get_buffer` is compiled out of CircuitPython's default board
builds (CIRCUITPY_AUDIOCORE_DEBUG), so there this does nothing and the
first block keeps upstream's behaviour.
"""
pull = getattr(audiocore, "get_buffer", None)
if pull is None:
return False
for voice in voices:
voice.play(silence, loop=True)
pull(node)
return True


# --------------------------------------------------------------------------
# Metadata

Expand Down
25 changes: 21 additions & 4 deletions lib/audioeffects/autopan.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,30 @@ def _modulate_synth(self, rate_hz, wave_l, wave_r):
if synth is None:
synth = synthio.Synthesizer(
sample_rate=self._sample_rate, channel_count=2)
# CPython synthio pans +1 to the left column and -1 to the right
# (scaled panning in synthio.py). Native builds match that pair.
# Since CircuitPython 10.3.0, panning > 0 attenuates the LEFT
# channel, so -1 lands a note on the left column alone and +1 on
# the right, on every target (audioif 4ec5718). The pre-10.3.0
# pairing was the reverse and put each gain on the wrong side.
#
# A fresh note renders at level 0 until its output crosses zero
# (the same 10.3.0 change), and a gain table never does, so left
# alone the first block is silent and the gain then steps in -
# a click on whatever is already playing. The notes start on a
# silent waveform for one block, which opens the gate at its
# first sample (see `_component.open_level_gates`), and take
# their tables after it.
silent = array("h", bytes(2 * len(wave_l)))
note_l = synthio.Note(
frequency=rate_hz, waveform=wave_l, panning=1.0)
frequency=rate_hz, waveform=silent, panning=-1.0)
note_r = synthio.Note(
frequency=rate_hz, waveform=wave_r, panning=-1.0)
frequency=rate_hz, waveform=silent, panning=1.0)
synth.press((note_l, note_r))
import audiocore
pull = getattr(audiocore, "get_buffer", None)
if pull is not None:
pull(synth)
note_l.waveform = wave_l
note_r.waveform = wave_r
self._synth = synth
self._note_l = note_l
self._note_r = note_r
Expand Down
35 changes: 18 additions & 17 deletions lib/audioeffects/bandpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,17 @@
belong to RBJ's prototype, which this class tracks to nine thousandths of a
decibel wherever it was checked.

**The build's one - the 0 dB peak is not held at a low centre with a narrow
width.** T1 says the gain at f0 is 0.00 dB +- 0.05 at every width. Measured
2026-09-07 over the whole `Frequency` x `Width` grid at four probe levels, it
holds **at every width for f0 >= 100 Hz, and at every centre for Q <= 2**, and
it does not hold in eleven of fifty-six cells below that - all of them f0
<= 63 Hz with Q >= 4, worst **-0.50 dB at f0 20 Hz with Q 32**, which is the
`Frequency` knob's bottom stop against the `Width` knob's top. Two knob turns
from patch 5 `Sub Window`. What it sounds like: a sub-bass resonance up to
half a decibel quieter than the same knob setting an octave higher, and the
error changes sign with the signal level (+0.09 dB at -3 dBFS, -0.48 at -12,
+0.44 at -20), so it is not a trim anyone can dial out.
**The build's one - the 0 dB peak is not held at one low, narrow cell.** T1
says the gain at f0 is 0.00 dB +- 0.05 at every width. Measured 2026-09-17 at
the audioif cebb7ca floor over the whole `Frequency` x `Width` grid at four
probe levels, it holds in fifty-five of fifty-six cells, both knob stops
included, and misses in one: **-0.091 dB at f0 31.5 Hz with Q 32**. What it
sounds like: nothing anyone will hear - a tenth of a decibel on the narrowest
sub-bass band the knobs reach.

It used to be eleven cells, all f0 <= 63 Hz with Q >= 4, worst -0.50 dB at
the `Frequency` knob's bottom stop against the `Width` knob's top, with an
error that changed sign with the signal level. The history of why follows.

The cause is not this class and not the prototype. RBJ's closed form at that
cell is `+0.00000 dB`, and `audiobiquad`'s own five coefficients, read off the
Expand All @@ -73,8 +73,9 @@
coefficients cancel to seven parts in a million, so the increment single
precision has to carry is 2e-5 of the numbers being differenced. Filed as
audioif#64 with the fix (a transposed direct form II costs nothing at run
time); this class is parked on it as audiocomponents#39. The measurement, the
map and the four-way decomposition are
time), and the fix landed; the one cell left is what it did not reach
(audiocomponents#66). The measurement, the map and the four-way
decomposition are
`workspace docs/effects-internal/probes/phase2_probes/bandpass_lowcorner.py`.

**The prototype's four**, each measured against RBJ's closed form at the
Expand Down Expand Up @@ -130,10 +131,10 @@ class never reads `self._transport()`.


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

NAME = 'BandPass'
DISPLAY_NAME = 'Band Pass'
Expand Down
24 changes: 18 additions & 6 deletions lib/audioeffects/deesser.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,6 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0,
band_only = bool(hf_only) or bool(listen)
pre.voice[0].level = 0.0 if band_only else 1.0
pre.voice[1].level = 1.0 if band_only else 0.0
pre.voice[0].play(raw.tap(0))
pre.voice[1].play(band.tap(0))

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

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

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

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

# -- reset ---------------------------------------------------------

def _reset_chain(self):
Expand Down
Loading