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
7 changes: 5 additions & 2 deletions .github/workflows/clean-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ jobs:
# fails if either grows, stops diverging, or anything else
# diverges at all. An accepted difference nobody has measured is
# indistinguishable from a new defect.
# Re-measured with the widened biquad (#310): its coefficients
# carry up to 30 bits, so #101's mp_float_t W0 now reaches more of
# them than Q15 did, and 4 lines became 35 and 6.
known-divergent: >-
--known-divergent biquad_component_probe.py:4
--known-divergent echo_filter_probe.py:4
--known-divergent biquad_component_probe.py:35
--known-divergent echo_filter_probe.py:6
name: unix-usermod (${{ matrix.float-impl.name }}-precision)
steps:
- uses: actions/checkout@v7
Expand Down
18 changes: 15 additions & 3 deletions docs/how-it-works/upstream-diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -2213,6 +2213,17 @@ the flat-EQ passthrough, and the Nyquist refusal.

## The biquads were Q15, so they could not go low — now `audiobiquad` only (audiodsp#77)

> **SINCE audiodsp#310 (2026-10-30), `synthio.Biquad` no longer parks.** It runs
> CircuitPython main's arithmetic at ae47a3f15d (adafruit/circuitpython#11679):
> coefficients with a per-filter shift of up to 30 bits and state that keeps 12
> bits below the sample, rounded to nearest. A DC burst into a low-pass now
> settles at exact zero at 400 Hz and 1000 Hz, and 1 and 4 counts off it at
> 100 Hz and 40 Hz, where the Q15 kernel held 2631 and 16143. A 200 Hz
> `LOW_SHELF` at A=1.0 behind `audiofilters.Filter` settles at 0, not +371. No
> CircuitPython release carries this yet; [upstream-sync.md](upstream-sync.md#ahead-of-the-pin-the-widened-biquad-audiodsp310)
> says what that means for the oracle. The tables below are the Q15 kernel's,
> kept as the record.

> **SCOPE CHANGED 2026-09-09 (the maintainer's call on audiodsp#77).** This deviation now
> applies to **`audiobiquad`, which is ours, and to nothing CircuitPython has.**
> `synthio.Biquad` — and so `audiofilters.Filter` and a `Note.filter` chain —
Expand Down Expand Up @@ -2289,9 +2300,10 @@ the flat-EQ passthrough, and the Nyquist refusal.
from `audiobiquad` there is one to a few LSB, and it is the node CircuitPython
has, so it is the one a ported sketch will already be using.

**Below about 100 Hz, use `audiobiquad`.** It reaches exact zero on every one of
the cases above, where `synthio.Biquad` settles on a constant it cannot be
talked out of. The same applies to `audiobiquad.AllPass` against
**Below about 100 Hz, prefer `audiobiquad`.** Its float state reaches exact
zero on every one of the cases above. `synthio.Biquad` used to settle on a
constant it could not be talked out of; since audiodsp#310 it gets within a few
counts of zero, which is inaudible but not exact. The same applies to `audiobiquad.AllPass` against
`audiofilters.Phaser`, whose feedback is clamped to `0.1..0.9` so a true null is
not reachable.

Expand Down
37 changes: 37 additions & 0 deletions docs/how-it-works/upstream-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,43 @@ to turn up.
7. Update the record below: what was taken, what was held back, and any
difference that a fix retired or added.

## Ahead of the pin: the widened biquad (audiodsp#310)

`synthio.Biquad` runs CircuitPython main's arithmetic at ae47a3f15d
(adafruit/circuitpython#11679), which no release carries yet. A biquad on a
low shelf or a low-frequency filter used to settle at a DC offset in silence
(+371 for a 200 Hz `LOW_SHELF` at A=1.0 behind `audiofilters.Filter` at
36 kHz), and the fix couldn't wait for a release. It came in as two commits,
both taken in upstream's words:

- **24546b7d1d**: `fast_sincos` reflects the angle into the first quadrant
before its polynomials, so cosine near pi is no longer off.
- **0a7df85004**: each filter's coefficients carry a shift of up to 30 bits,
chosen from the largest one, and the state keeps 12 fractional bits in a
64-bit sum that rounds to nearest. The old Q15 coefficients and integer
state are gone.

It is in [`src/shared/audiodsp_biquad.c`](../../src/shared/audiodsp_biquad.c), in the LPG node's copy in
[`src/shared/audiodsp_graph.c`](../../src/shared/audiodsp_graph.c), and in the Python filter chain in
[`src/cpython/audiofilters.py`](../../src/cpython/audiofilters.py). A harness built from upstream's own `Biquad.c`
at ae47a3f15d agrees with this port on 20,000 random filters and 40,960,000
samples, with no difference. Upstream's four biquad tests
(`synthio_biquad_dc_offset`, `_low_frequency`, `_high_frequency`, `_peaking`)
print upstream's expected output on MicroPython and on CPython.

Until the pin moves to a release with #11679, the alpha.2 oracle disagrees
wherever a `synthio.Biquad` is heard. `verify_dsp.py` skips the oracle for
`echo_filter_probe`, `freeverb_filter_probe` and `biquad_component_probe`, and
`synthtools_acceptance`'s `circuitpython_stdout` differs on `lead`, `bend` and
`bass`. Two stored digests were re-captured from this port, not the oracle:
`biquad_component` and `synthtools_acceptance`. **When the pin moves, drop
those three skips and re-capture both from the oracle.**

Held for that resync: the `synthio.MidiTrack` commits between alpha.2 and
ae47a3f15d (679e9700ab, ff05e5b620, aad79fe5eb, 6dfa7e5f20, a54b951de6) and
the new `audiotap` module (e9b2fd49f5, f889146e81), which needs the decision
step 2 asks for.

## The last resync: 11.0.0-alpha.2

The pin moved from 11.0.0-alpha.1 to 11.0.0-alpha.2 (d53b2277c). Between the
Expand Down
2 changes: 1 addition & 1 deletion src/cpython/_audiodsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ static PyObject *biquad_state_process(audiodsp_biquad_state_object_t *self,
input.len);
PyBuffer_Release(&input);
if (result == NULL) return NULL;
// CircuitPython's Q15 kernel, not the widened one beside it: this backs
// CircuitPython's kernel, not audiobiquad's beside it: this backs
// synthio.Biquad and audiofilters.Filter, which CircuitPython also has, so
// they render CircuitPython's bytes. audiodsp#77, 2026-09-09.
audiodsp_biquad_cp_coefficients_t coefficients;
Expand Down
81 changes: 53 additions & 28 deletions src/cpython/audiofilters.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,12 @@ def _process(self, data):
return bytes(result)


# CircuitPython 10.3.0's audiofilters_process_filter_chain: Q15 coefficients
# from the same fast_sincos / Q_rsqrt tick as shared-module/synthio/Biquad.c,
# then synthio_sat16 of the five-product sum. audiodsp_biquad is a different
# arithmetic (wider shift) and is not this helper.
# CircuitPython's synthio_biquad_filter_samples, as audiodsp_biquad_cp_* in
# src/shared/audiodsp_biquad.c has it (CircuitPython main ae47a3f15d): the
# coefficients from the same fast_sincos / Q_rsqrt tick as
# shared-module/synthio/Biquad.c with a per-filter shift, and the feedback
# state kept with _STATE_SHIFT bits below the sample. audiobiquad's sine and
# cosine are different and are not this helper.
_PAIR_SCALE = 0xfffffff // (32768 * 2 - 28000)


Expand Down Expand Up @@ -326,14 +328,39 @@ def _q_rsqrt(number):


def _fast_sincos(theta):
x = (theta * (4.0 / math.pi)) - 1.0
x2 = x * x
x3 = x2 * x
x4 = x2 * x2
x5 = x2 * x3
evens = 0.0109 * x4 + -0.21798592 * x2 + 0.70708592
odds = -0.00171961 * x5 + 0.05707685 * x3 + -0.55535724 * x
return evens - odds, evens + odds
# Reflect into [0, pi / 2], then into [0, pi / 4] by swapping sine and
# cosine, and evaluate upstream's minimax polynomial for each there.
negate_cos = theta > (math.pi / 2)
t = (math.pi - theta) if negate_cos else theta
swap = t > (math.pi / 4)
if swap:
t = (math.pi / 2) - t
t2 = t * t
s = t * (1 + t2 * (-0.16666651 + t2 * (0.0083319786 + t2 * -0.00019495635)))
c = 1 + t2 * (-0.5 + t2 * (0.041666647 + t2 * (-0.0013887368 + t2 * 0.000024438452)))
sine = c if swap else s
cosine = s if swap else c
return sine, (-cosine if negate_cos else cosine)


_MAX_SHIFT = 30
_STATE_SHIFT = 12
_STATE_MAX = 32767 << _STATE_SHIFT
_STATE_MIN = -32768 << _STATE_SHIFT


def _choose_shift(values):
largest = 1.0
for value in values:
if abs(value) > largest:
largest = abs(value)
shift = 31 - math.frexp(largest)[1]
return max(1, min(shift, _MAX_SHIFT))


def _scale(value, shift):
scaled = _c_round(math.ldexp(value, shift))
return max(-2147483648, min(scaled, 2147483647))


def _c_round(value):
Expand Down Expand Up @@ -361,7 +388,7 @@ def _cp_biquad_coeffs(biquad, w_scale):
else:
b0, b1, b2 = 1.0, -2.0 * cosine, 1.0
elif mode == 4:
b0, b1, b2 = 1.0 + alpha * gain, -2.0 * cosine, 1.0 + alpha * gain
b0, b1, b2 = 1.0 + alpha * gain, -2.0 * cosine, 1.0 - alpha * gain
a0, a1, a2 = 1.0 + alpha / gain, -2.0 * cosine, 1.0 - alpha / gain
else:
root = gain * _q_rsqrt(gain) if gain else 0.0
Expand All @@ -380,22 +407,20 @@ def _cp_biquad_coeffs(biquad, w_scale):
a1 = 2 * ((gain - 1) - (gain + 1) * cosine)
a2 = (gain + 1) - (gain - 1) * cosine - 2 * root * alpha
reciprocal = 1.0 / a0
return (
_c_round(math.ldexp(a1 * reciprocal, 15)),
_c_round(math.ldexp(a2 * reciprocal, 15)),
_c_round(math.ldexp(b0 * reciprocal, 15)),
_c_round(math.ldexp(b1 * reciprocal, 15)),
_c_round(math.ldexp(b2 * reciprocal, 15)),
)
values = (a1 * reciprocal, a2 * reciprocal, b0 * reciprocal,
b1 * reciprocal, b2 * reciprocal)
shift = _choose_shift(values)
return tuple(_scale(value, shift) for value in values) + (shift,)


def _cp_biquad_sample(coeffs, state, word):
a1, a2, b0, b1, b2 = coeffs
a1, a2, b0, b1, b2, shift = coeffs
x0, x1, y0, y1 = state
output = _sat16(
b0 * word + b1 * x0 + b2 * x1 - a1 * y0 - a2 * y1 + (1 << 14), 15)
state[1], state[0], state[3], state[2] = x0, word, y0, output
return output
acc = ((b0 * word + b1 * x0 + b2 * x1) << _STATE_SHIFT) - a1 * y0 - a2 * y1
y = (acc + (1 << (shift - 1))) >> shift
y = max(_STATE_MIN, min(y, _STATE_MAX))
state[1], state[0], state[3], state[2] = x0, word, y0, y
return (y + (1 << (_STATE_SHIFT - 1))) >> _STATE_SHIFT


class _FilterChain:
Expand All @@ -420,7 +445,7 @@ def __init__(self, filter_in, channel_count):
self.objs = items
self.channel_count = channel_count
self.states = [[0, 0, 0, 0] for _ in range(len(items) * channel_count)]
self.coeffs = [(0, 0, 0, 0, 0) for _ in items]
self.coeffs = [(0, 0, 0, 0, 0, 15) for _ in items]

def __bool__(self):
return bool(self.objs)
Expand All @@ -431,8 +456,8 @@ def reset(self):

def tick(self, sample_rate):
# A multiply by a reciprocal, matching synthio_global_W_scale exactly.
# `(2 * pi) / sample_rate` differs in the last bits, and Q15 rounding
# turns that into different coefficients. See audiodsp_biquad_cp_w0().
# `(2 * pi) / sample_rate` differs in the last bits, and rounding the
# coefficients turns that into different ones. See audiodsp_biquad_cp_w0().
w_scale = (2.0 * math.pi) * (1.0 / sample_rate)
self.coeffs = [_cp_biquad_coeffs(biquad, w_scale)
for biquad in self.objs]
Expand Down
Loading
Loading