diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 2870f96..47dc392 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -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 diff --git a/docs/how-it-works/upstream-diff.md b/docs/how-it-works/upstream-diff.md index 96e3ced..97d1567 100644 --- a/docs/how-it-works/upstream-diff.md +++ b/docs/how-it-works/upstream-diff.md @@ -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 — @@ -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. diff --git a/docs/how-it-works/upstream-sync.md b/docs/how-it-works/upstream-sync.md index 7a66bec..5ef3e6a 100644 --- a/docs/how-it-works/upstream-sync.md +++ b/docs/how-it-works/upstream-sync.md @@ -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 diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 2cdd167..9d2ed3a 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -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; diff --git a/src/cpython/audiofilters.py b/src/cpython/audiofilters.py index 0bec41d..d90e56d 100644 --- a/src/cpython/audiofilters.py +++ b/src/cpython/audiofilters.py @@ -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) @@ -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): @@ -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 @@ -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: @@ -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) @@ -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] diff --git a/src/shared/audiodsp_biquad.c b/src/shared/audiodsp_biquad.c index a088c3a..8b1cc79 100644 --- a/src/shared/audiodsp_biquad.c +++ b/src/shared/audiodsp_biquad.c @@ -184,65 +184,105 @@ void audiodsp_biquad_process(const audiodsp_biquad_coefficients_t *c, state->y[0] = y0; state->y[1] = y1; } -// --- CircuitPython's own Q15 biquad --------------------------------------- +// --- CircuitPython's own biquad -------------------------------------------- // // See audiodsp_biquad.h for why this exists beside the widened kernel above: // audiodsp#77, 2026-09-09. Upstream nodes run this so they render // CircuitPython's bytes; `audiobiquad`, which is ours, runs the widened one. // -// It tracks CircuitPython **main**, not the 10.3.0 tag, in two places -- both -// bugs upstream has already merged fixes for (PRs from this project), neither -// of them yet in a release: +// It is shared-module/synthio/Biquad.c as of CircuitPython main ae47a3f15d, +// which is ahead of the pinned 11.0.0-alpha.2 in two commits from +// adafruit/circuitpython#11679 (taken in upstream's words, ahead of the pin; +// docs/how-it-works/upstream-sync.md): // -// * PEAKING_EQ's `b2` is `1 - alpha * A`. 10.3.0 has the plus, which makes a -// +6 dB bell at 1 kHz / Q 1 come out roughly +21 dB at DC. Fixed by -// 8fabdbbfb1 on main. -// * `audiodsp_biquad_reset()` clears all four state words. 10.3.0 clears only -// `x`, so a reset filter carries on recursing on the previous audio. Fixed -// by 8a3deace5c on main. +// * 24546b7d1d: fast_sincos() reflects the angle into [0, pi/4] and +// evaluates a minimax polynomial there, where it used to extrapolate one +// fit over [0, pi/2] all the way to Nyquist. +// * 0a7df85004: the coefficients have as many fractional bits as the +// largest of them leaves room for (up to 30), the feedback state keeps +// BIQUAD_STATE_SHIFT bits below the sample, the sum of products is an +// int64_t, and the result rounds to nearest. The old Q15 sum rounded every +// negative result one count high, so a 200 Hz shelf at A = 1.0 settled +// 371 counts off zero in silence (audiodsp#310). // -// So this is CircuitPython's arithmetic as CircuitPython now intends it. Two -// named departures from the pinned 10.3.0 oracle follow from that, and both -// expire when upstream cuts a release containing those commits. -// -// What is NOT corrected here, deliberately, is the Q15 coefficient format and -// `fast_sincos`. That is `biquad-band-edges`, the one report still open -// upstream, and it is the whole reason the widened kernel above exists. +// Both are departures from the pinned oracle until the pin moves to a release +// that contains them. +#define CP_BIQUAD_MAX_SHIFT (30) +#define CP_BIQUAD_STATE_SHIFT (12) +#define CP_BIQUAD_STATE_MAX (32767 * (1 << CP_BIQUAD_STATE_SHIFT)) +#define CP_BIQUAD_STATE_MIN (-32768 * (1 << CP_BIQUAD_STATE_SHIFT)) -// CircuitPython's fast_sincos: one 5th-order fit to both sine and cosine over -// [0, pi/2], evaluated wherever W0 lands -- no reflection, so above pi/2 (12 -// kHz at 48 kHz) it is extrapolating. Reproduced as written, extrapolation -// included; audiodsp_sincos_reflect() is the corrected one and is not this. typedef struct { double s, c; } cp_sincos_t; +// Sine and cosine of theta, evaluated together. theta is the filter's angle +// 2 * pi * f / sample_rate, which is in [0, pi] up to Nyquist. +// +// Reflect into [0, pi / 2] (sin(pi - t) = sin(t), cos(pi - t) = -cos(t)), then +// into [0, pi / 4] by swapping the two (sin(pi / 2 - t) = cos(t)). On that +// interval a four-term polynomial for each, fitted to minimise the worst-case +// error, is accurate to the last bit or two of a float. static void cp_fast_sincos(double theta, cp_sincos_t *result) { - double x = (theta * (4 / AUDIODSP_PI)) - 1; - double x2 = x * x, x3 = x2 * x, x4 = x2 * x2, x5 = x2 * x3; - double c0 = 0.70708592, - c1x = -0.55535724 * x, - c2x2 = -0.21798592 * x2, - c3x3 = 0.05707685 * x3, - c4x4 = 0.0109 * x4, - c5x5 = -0.00171961 * x5; - double evens = c4x4 + c2x2 + c0, odds = c5x5 + c3x3 + c1x; - result->c = evens + odds; - result->s = evens - odds; + bool negate_cos = theta > (AUDIODSP_PI / 2); + double t = negate_cos ? (AUDIODSP_PI - theta) : theta; + bool swap = t > (AUDIODSP_PI / 4); + if (swap) { + t = (AUDIODSP_PI / 2) - t; + } + double t2 = t * t; + double s = t * (1 + t2 * (-0.16666651 + t2 * (0.0083319786 + t2 * -0.00019495635))); + double c = 1 + t2 * (-0.5 + t2 * (0.041666647 + t2 * (-0.0013887368 + t2 * 0.000024438452))); + double sin_theta = swap ? c : s; + double cos_theta = swap ? s : c; + result->s = sin_theta; + result->c = negate_cos ? -cos_theta : cos_theta; } -static int32_t cp_scale(double value) { - return (int32_t)round(ldexp(value, AUDIODSP_BIQUAD_CP_SHIFT)); +static int32_t cp_scale(double arg, int shift) { + double scaled = round(ldexp(arg, shift)); + // cp_choose_shift() leaves room for this; the clamp is so that a + // rounding tick at the very top of the range cannot wrap the sign. + if (scaled >= 2147483648.0) { + return INT32_MAX; + } + if (scaled <= -2147483648.0) { + return INT32_MIN; + } + return (int32_t)scaled; +} + +// The most fractional bits that all five coefficients can share and still fit +// in an int32_t, up to CP_BIQUAD_MAX_SHIFT. A plain low-pass tops out just +// under 2 (that is a1) and gets 30; a shelf with 24 dB of gain reaches almost +// 32 and gets 26. +static int cp_choose_shift(const double *coefficients, size_t count) { + double largest = 1; + for (size_t i = 0; i < count; i++) { + double magnitude = fabs(coefficients[i]); + if (magnitude > largest) { + largest = magnitude; + } + } + int exponent; + frexp(largest, &exponent); // largest < 2 ** exponent + int shift = 31 - exponent; + if (shift > CP_BIQUAD_MAX_SHIFT) { + shift = CP_BIQUAD_MAX_SHIFT; + } + // The rounding term in the filter needs at least one bit. + return shift < 1 ? 1 : shift; } void audiodsp_biquad_cp_init(audiodsp_biquad_cp_coefficients_t *coefficients) { coefficients->a1 = coefficients->a2 = 0; coefficients->b1 = coefficients->b2 = 0; - coefficients->b0 = 1 << AUDIODSP_BIQUAD_CP_SHIFT; + coefficients->shift = AUDIODSP_BIQUAD_CP_DEFAULT_SHIFT; + coefficients->b0 = 1 << AUDIODSP_BIQUAD_CP_DEFAULT_SHIFT; } double audiodsp_biquad_cp_w0(double frequency, uint32_t sample_rate) { // A multiply by a reciprocal, matching synthio_global_W_scale exactly. - // `frequency * (2 * PI) / sample_rate` differs in the last bits, and Q15 - // rounding turns that into different coefficients. + // `frequency * (2 * PI) / sample_rate` differs in the last bits, and + // rounding the coefficients turns that into different ones. double reciprocal = 1.0 / sample_rate; return frequency * ((2 * AUDIODSP_PI) * reciprocal); } @@ -265,7 +305,6 @@ void audiodsp_biquad_cp_configure(audiodsp_biquad_cp_coefficients_t *coefficient b0 = 1; b1 = -2 * sc.c; b2 = 1; } } else if (mode == 4) { - // `1 - alpha * A`: CircuitPython main, not 10.3.0. See the note above. b0 = 1 + alpha * A; b1 = -2 * sc.c; b2 = 1 - alpha * A; a0 = 1 + alpha / A; a1 = -2 * sc.c; a2 = 1 - alpha / A; } else { @@ -286,36 +325,62 @@ void audiodsp_biquad_cp_configure(audiodsp_biquad_cp_coefficients_t *coefficient a2 = (A + 1) - (A - 1) * sc.c - 2 * root * alpha; } } - double reciprocal = 1 / a0; - coefficients->a1 = cp_scale(a1 * reciprocal); - coefficients->a2 = cp_scale(a2 * reciprocal); - coefficients->b0 = cp_scale(b0 * reciprocal); - coefficients->b1 = cp_scale(b1 * reciprocal); - coefficients->b2 = cp_scale(b2 * reciprocal); + double recip_a0 = 1 / a0; + + a1 *= recip_a0; + a2 *= recip_a0; + b0 *= recip_a0; + b1 *= recip_a0; + b2 *= recip_a0; + + double values[] = {a1, a2, b0, b1, b2}; + int shift = cp_choose_shift(values, sizeof(values) / sizeof(values[0])); + coefficients->shift = shift; + coefficients->a1 = cp_scale(a1, shift); + coefficients->a2 = cp_scale(a2, shift); + coefficients->b0 = cp_scale(b0, shift); + coefficients->b1 = cp_scale(b1, shift); + coefficients->b2 = cp_scale(b2, shift); } -// The accumulator is int32_t and the result saturates to int16 each step, so -// `y` here is in SAMPLE units -- not the widened kernel's -// AUDIODSP_BIQUAD_STATE_SHIFT format. The two must not share a live state. -static int32_t cp_filter_sample(int32_t input, int32_t a1, int32_t a2, - int32_t b0, int32_t b1, int32_t b2, +// Returns the new feedback state: the output with CP_BIQUAD_STATE_SHIFT bits +// below the sample. Near 0 Hz a biquad turns each unit of error in its +// feedback into thousands of units of output, so the feedback must not be +// rounded to whole samples. The coefficients can have up to 30 fractional +// bits, so the sum of products needs 64 bits. +static inline int32_t cp_filter_sample(int32_t input, int32_t a1, int32_t a2, + int32_t b0, int32_t b1, int32_t b2, int shift, int32_t x0, int32_t x1, int32_t y0, int32_t y1) { - return audiodsp_sat16(b0 * input + b1 * x0 + b2 * x1 - a1 * y0 - a2 * y1 - + (1 << (AUDIODSP_BIQUAD_CP_SHIFT - 1)), AUDIODSP_BIQUAD_CP_SHIFT); + int64_t acc = ((int64_t)b0 * input + (int64_t)b1 * x0 + (int64_t)b2 * x1) * (1 << CP_BIQUAD_STATE_SHIFT) + - (int64_t)a1 * y0 - (int64_t)a2 * y1; + // Add half a step, then shift, to round to nearest. + acc = (acc + ((int64_t)1 << (shift - 1))) >> shift; + if (acc > CP_BIQUAD_STATE_MAX) { + return CP_BIQUAD_STATE_MAX; + } + if (acc < CP_BIQUAD_STATE_MIN) { + return CP_BIQUAD_STATE_MIN; + } + return (int32_t)acc; +} + +// The state is already within the range of a sample, so rounding is all it needs. +static inline int32_t cp_state_to_sample(int32_t y) { + return (y + (1 << (CP_BIQUAD_STATE_SHIFT - 1))) >> CP_BIQUAD_STATE_SHIFT; } int32_t audiodsp_biquad_cp_sample( const audiodsp_biquad_cp_coefficients_t *coefficients, audiodsp_biquad_state_t *state, int32_t input) { - int32_t output = cp_filter_sample(input, + int32_t y = cp_filter_sample(input, coefficients->a1, coefficients->a2, coefficients->b0, - coefficients->b1, coefficients->b2, + coefficients->b1, coefficients->b2, coefficients->shift, state->x[0], state->x[1], state->y[0], state->y[1]); state->x[1] = state->x[0]; state->x[0] = input; state->y[1] = state->y[0]; - state->y[0] = output; - return output; + state->y[0] = y; + return cp_state_to_sample(y); } void audiodsp_biquad_cp_process( @@ -323,15 +388,16 @@ void audiodsp_biquad_cp_process( audiodsp_biquad_state_t *state, int32_t *buffer, size_t sample_count) { int32_t a1 = coefficients->a1, a2 = coefficients->a2; int32_t b0 = coefficients->b0, b1 = coefficients->b1, b2 = coefficients->b2; + int shift = coefficients->shift; int32_t x0 = state->x[0], x1 = state->x[1]; int32_t y0 = state->y[0], y1 = state->y[1]; for (size_t n = sample_count; n; --n, ++buffer) { int32_t input = *buffer; - int32_t output = cp_filter_sample(input, a1, a2, b0, b1, b2, + int32_t y = cp_filter_sample(input, a1, a2, b0, b1, b2, shift, x0, x1, y0, y1); x1 = x0; x0 = input; - y1 = y0; y0 = output; - *buffer = output; + y1 = y0; y0 = y; + *buffer = cp_state_to_sample(y); } state->x[0] = x0; state->x[1] = x1; state->y[0] = y0; state->y[1] = y1; diff --git a/src/shared/audiodsp_biquad.h b/src/shared/audiodsp_biquad.h index 1aac493..ba1a8b7 100644 --- a/src/shared/audiodsp_biquad.h +++ b/src/shared/audiodsp_biquad.h @@ -33,24 +33,27 @@ void audiodsp_biquad_reset(audiodsp_biquad_state_t *state); void audiodsp_biquad_process(const audiodsp_biquad_coefficients_t *coefficients, audiodsp_biquad_state_t *state, int32_t *buffer, size_t sample_count); -// --- CircuitPython's own Q15 biquad --------------------------------------- +// --- CircuitPython's own biquad -------------------------------------------- // // The kernel above is a deliberate improvement on this one and is used only by // `audiobiquad`, which is ours (audiodsp#77, 2026-09-09). Anything // CircuitPython also has -- `synthio.Biquad`, and so `audiofilters.Filter` and // a `Note.filter` chain -- runs THIS, so it renders CircuitPython's bytes. // -// Arithmetic-identical to shared-module/synthio/Biquad.c at 10.3.0, expression -// order included: `fast_sincos`'s 5th-order fit, the Quake reciprocal square -// root for the shelves, Q15 coefficients rounded with `round(ldexp(v, 15))`, -// and an int32_t accumulator saturated to int16 each step. Its known costs are -// upstream's and are not corrected here -- that is the point of it. They are -// measured in docs/how-it-works/upstream-diff.md, "The biquads were Q15, so they could not -// go low". -#define AUDIODSP_BIQUAD_CP_SHIFT 15 +// Arithmetic-identical to shared-module/synthio/Biquad.c at CircuitPython main +// ae47a3f15d, expression order included: the reflected `fast_sincos`, the +// Quake reciprocal square root for the shelves, coefficients with a per-filter +// number of fractional bits rounded with `round(ldexp(v, shift))`, and an +// int64_t sum into a feedback state with 12 bits below the sample. Since that +// change it differs from audiobiquad's kernel in its sine and cosine and in how +// W0 is derived (audiodsp_biquad_cp_w0() below), both upstream's and kept so. See docs/how-it-works/upstream-diff.md, "The biquads were +// Q15, so they could not go low". +#define AUDIODSP_BIQUAD_CP_DEFAULT_SHIFT 15 typedef struct { int32_t a1, a2, b0, b1, b2; + // Fractional bits in the five coefficients above, chosen per filter. + int32_t shift; } audiodsp_biquad_cp_coefficients_t; // A pass-through, for a filter that reaches the DSP before its first tick. diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index e1f812b..495d093 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -148,8 +148,10 @@ typedef struct { double vactrol; // The cutoff the coefficients were made for; 0 before the first. double cutoff; - // synthio.Biquad's Q15 low-pass coefficients and its filter state. + // synthio.Biquad's low-pass coefficients, their fractional bits, and its + // filter state (`y` with LPG_STATE_SHIFT bits below the sample). int32_t a1, a2, b0, b1, b2; + int32_t shift; int32_t x0, x1, y0, y1; } graph_lpg_t; @@ -1634,65 +1636,103 @@ static double graph_log2d(double v) { #define LPG_LOG2_400 8.64385618977472469574 #define LPG_Q 0.7071 #define LPG_PI 3.14159265358979323846 -#define LPG_Q15 32768.0 - -// round(ldexp(v, 15)): half away from zero, as C's round() does. -static int32_t lpg_q15(double v) { - const double r = v * LPG_Q15; - int32_t t = (int32_t)r; +#define LPG_MAX_SHIFT 30 +#define LPG_STATE_SHIFT 12 +#define LPG_STATE_MAX (32767 * (1 << LPG_STATE_SHIFT)) +#define LPG_STATE_MIN (-32768 * (1 << LPG_STATE_SHIFT)) + +// round(ldexp(v, shift)): half away from zero, as C's round() does, clamped +// to an int32_t as synthio's biquad_scale_arg_float clamps it. +static int32_t lpg_scale(double v, int shift) { + const double r = v * (double)((int64_t)1 << shift); + int64_t t = (int64_t)r; const double frac = r - (double)t; if (frac >= 0.5) { ++t; } else if (frac <= -0.5) { --t; } - return t; + return t > INT32_MAX ? INT32_MAX : (t < INT32_MIN ? INT32_MIN : (int32_t)t); +} + +static double lpg_fabs(double v) { + return v < 0 ? -v : v; +} + +// synthio's biquad_choose_shift: the most fractional bits the largest +// coefficient leaves room for in an int32_t, up to 30. frexp's exponent is +// counted by doubling. +static int lpg_choose_shift(const double *values, int count) { + double largest = 1; + for (int i = 0; i < count; i++) { + if (lpg_fabs(values[i]) > largest) { + largest = lpg_fabs(values[i]); + } + } + int exponent = 0; + for (double power = 1; largest >= power; power *= 2) { + exponent++; + } + int shift = 31 - exponent; + if (shift > LPG_MAX_SHIFT) { + shift = LPG_MAX_SHIFT; + } + return shift < 1 ? 1 : shift; } // synthio.Biquad's LOW_PASS coefficients at Q 0.7071, as -// audiodsp_biquad_cp_configure makes them: CircuitPython's fast_sincos, its -// W0 scale and Q15. A copy here, without libm, because the CircuitPython -// build does not link audiodsp_biquad.c. +// audiodsp_biquad_cp_configure makes them: CircuitPython's reflected +// fast_sincos, its W0 scale and its per-filter shift. A copy here, without +// libm, because the CircuitPython build does not link audiodsp_biquad.c. static void lpg_configure(graph_lpg_t *d, double cutoff, uint32_t sample_rate) { const double reciprocal_sr = 1.0 / sample_rate; const double w0 = cutoff * ((2 * LPG_PI) * reciprocal_sr); - const double x = (w0 * (4 / LPG_PI)) - 1; - const double x2 = x * x, x3 = x2 * x, x4 = x2 * x2, x5 = x2 * x3; - const double c0 = 0.70708592, - c1x = -0.55535724 * x, - c2x2 = -0.21798592 * x2, - c3x3 = 0.05707685 * x3, - c4x4 = 0.0109 * x4, - c5x5 = -0.00171961 * x5; - const double evens = c4x4 + c2x2 + c0, odds = c5x5 + c3x3 + c1x; - const double c = evens + odds; - const double s = evens - odds; + const bool negate_cos = w0 > (LPG_PI / 2); + double t = negate_cos ? (LPG_PI - w0) : w0; + const bool swap = t > (LPG_PI / 4); + if (swap) { + t = (LPG_PI / 2) - t; + } + const double t2 = t * t; + const double ps = t * (1 + t2 * (-0.16666651 + t2 * (0.0083319786 + t2 * -0.00019495635))); + const double pc = 1 + t2 * (-0.5 + t2 * (0.041666647 + t2 * (-0.0013887368 + t2 * 0.000024438452))); + const double s = swap ? pc : ps; + const double c = negate_cos ? -(swap ? ps : pc) : (swap ? ps : pc); const double alpha = s / (2 * LPG_Q); - const double a0 = 1 + alpha, a1 = -2 * c, a2 = 1 - alpha; - const double b0 = (1 - c) * .5, b1 = 1 - c; + const double a0 = 1 + alpha; const double reciprocal = 1 / a0; - d->a1 = lpg_q15(a1 * reciprocal); - d->a2 = lpg_q15(a2 * reciprocal); - d->b0 = lpg_q15(b0 * reciprocal); - d->b1 = lpg_q15(b1 * reciprocal); - d->b2 = lpg_q15(b0 * reciprocal); + const double a1 = -2 * c * reciprocal; + const double a2 = (1 - alpha) * reciprocal; + const double b0 = (1 - c) * .5 * reciprocal; + const double b1 = (1 - c) * reciprocal; + const double values[] = {a1, a2, b0, b1, b0}; + const int shift = lpg_choose_shift(values, 5); + d->shift = shift; + d->a1 = lpg_scale(a1, shift); + d->a2 = lpg_scale(a2, shift); + d->b0 = lpg_scale(b0, shift); + d->b1 = lpg_scale(b1, shift); + d->b2 = lpg_scale(b0, shift); d->cutoff = cutoff; } -// audiodsp_sat16(value, 15): the shift rounds toward zero for a negative. -static inline int32_t lpg_shift15(int32_t value) { - if (value < 0) { - value += 0x7FFF; - } - value >>= 15; - return value > 32767 ? 32767 : (value < -32768 ? -32768 : value); +// synthio's biquad_filter_sample: the int64_t sum, rounded to nearest, kept +// with LPG_STATE_SHIFT bits below the sample and clamped to the sample range. +static inline int32_t lpg_filter(const graph_lpg_t *d, int32_t input, + int32_t x0, int32_t x1, int32_t y0, int32_t y1) { + int64_t acc = ((int64_t)d->b0 * input + (int64_t)d->b1 * x0 + + (int64_t)d->b2 * x1) * (1 << LPG_STATE_SHIFT) + - (int64_t)d->a1 * y0 - (int64_t)d->a2 * y1; + acc = (acc + ((int64_t)1 << (d->shift - 1))) >> d->shift; + return acc > LPG_STATE_MAX ? LPG_STATE_MAX + : (acc < LPG_STATE_MIN ? LPG_STATE_MIN : (int32_t)acc); } // LowPassGateCore.render. The vactrol steps once a block toward the manual // level plus the control's and 1.5 times the strike's first samples: up in // about 2 ms, down with the decay. VCA scales the input by it; VCF runs the // input through synthio.Biquad's low-pass at 30 * 400^v Hz inside -// audiofilters.Filter, Q15 and the soft knee past +-28000 included; BOTH does +// audiofilters.Filter, its fixed point and the soft knee past +-28000 included; BOTH does // that and scales by v^1.3. The level multiplies last. static void run_lpg(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, int16_t *out, uint32_t frames) { @@ -1758,13 +1798,12 @@ static void run_lpg(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, int32_t x0 = d->x0, x1 = d->x1, y0 = d->y0, y1 = d->y1; for (uint32_t i = 0; i < frames; ++i) { const int32_t sample = in != NULL ? in[i] : 0; - const int32_t y = lpg_shift15(d->b0 * sample + d->b1 * x0 + d->b2 * x1 - - d->a1 * y0 - d->a2 * y1 + (1 << 14)); + const int32_t y = lpg_filter(d, sample, x0, x1, y0, y1); x1 = x0; x0 = sample; y1 = y0; y0 = y; - int32_t w = y; + int32_t w = (y + (1 << (LPG_STATE_SHIFT - 1))) >> LPG_STATE_SHIFT; if (w < -28000) { w = (((w + 28000) * knee) >> 16) - 28000; } else if (w > 28000) { diff --git a/src/synthio/Biquad.c b/src/synthio/Biquad.c index 51a293d..02fe711 100644 --- a/src/synthio/Biquad.c +++ b/src/synthio/Biquad.c @@ -89,7 +89,7 @@ void common_hal_synthio_biquad_tick(mp_obj_t self_in) { return; } - // CircuitPython's own Q15 arithmetic, from the shared kernel so all three + // CircuitPython's own arithmetic, from the shared kernel so all three // targets run one copy (audiodsp#77). `synthio.Biquad` is a node // CircuitPython also has, so it renders CircuitPython's bytes; the widened // kernel beside it in audiodsp_biquad.c is used only by `audiobiquad`, diff --git a/src/synthio/Biquad.h b/src/synthio/Biquad.h index fa86079..b29be16 100644 --- a/src/synthio/Biquad.h +++ b/src/synthio/Biquad.h @@ -28,7 +28,7 @@ struct synthio_biquad { synthio_filter_mode mode; synthio_block_slot_t f0, Q, A; mp_float_t cached_W0, cached_Q, cached_A; - // CircuitPython's Q15 coefficients, and nothing else. audiodsp#77, + // CircuitPython's coefficients, and nothing else. audiodsp#77, // 2026-09-09: a node CircuitPython also has renders CircuitPython's bytes, // so there is one coefficient set here rather than two. The widened // fixed-point kernel this file used to carry alongside is now reached only diff --git a/tests/parity/golden/biquad_component.json b/tests/parity/golden/biquad_component.json index 6f38a4f..b979925 100644 --- a/tests/parity/golden/biquad_component.json +++ b/tests/parity/golden/biquad_component.json @@ -1,3 +1,3 @@ { - "cpython_stdout_sha256": "d9950b1628a8b5daaa1b26bd8805112dade076b9e5653f0e26fd495b59f9d3d7" + "cpython_stdout_sha256": "ba714db8258234503040abe5607e8eed8fbdc6d08892d36df7a489e958aea048" } diff --git a/tests/parity/golden/synthtools_acceptance.json b/tests/parity/golden/synthtools_acceptance.json index 62e85fc..cb362ec 100644 --- a/tests/parity/golden/synthtools_acceptance.json +++ b/tests/parity/golden/synthtools_acceptance.json @@ -10,15 +10,15 @@ "mixer playing False", "done" ], - "circuitpython_stdout_note": "CircuitPython 11.0.0-alpha.2's own answer, at the same ceiling this port ships (64). ALL EIGHT LINES are identical to `stdout` and to desktop MicroPython. Until 2026-09-09 three differed, and this note blamed the deliberate biquad deviations for all three; that was wrong on every count. Each had its own cause, and all four are fixed: (1) `lead` -- the biquad deviation was applied on ONE TARGET ONLY (audiodsp#77); synthio.Biquad now runs CircuitPython's Q15 on every target. (2) `bend` -- the zero-crossing loudness gate read the wrong voice slot on the CPython twin, whose note list compacts where CircuitPython's channel array does not; each note now carries the channel it was admitted on. (3) part of the SAW waveform -- the test-only ulab bridge rounded where real ulab truncates (tests/support/ulab). (4) `bass` -- the twin rendered voices already at envelope level 0, which the native builds skip. Harmless until the gate existed, because pending loudness 0 contributes silence; with the gate the voice kept sounding at its previous loudness for a whole block (audiodsp#78). Pinned by tests/test_cpython_silenced_voice.py, whose three cases all fail with the skip removed. Re-captured for audiodsp's resync with 11.0.0-alpha.2: upstream's mixer now scales a voice in integers, `(x * level) >> 15`, so `lead`, `bend` and `bass` moved, and the oracle, this port and desktop MicroPython agree on all eight lines again.", + "circuitpython_stdout_note": "CircuitPython 11.0.0-alpha.2's own answer, at the same ceiling this port ships (64). Since the biquad resync with CircuitPython main ae47a3f15d (adafruit/circuitpython#11679, audiodsp#310), `lead`, `bend` and `bass` differ from this answer: synthio.Biquad now has upstream's widened coefficients and 12-bit-fraction state, which no CircuitPython release carries yet, and those three lines run a resonant low-pass. The other five are identical. Re-capture this answer when the oracle pin moves to a release with #11679; until then the history below is about alpha.2. Before that, all eight lines were identical to `stdout` and to desktop MicroPython. Until 2026-09-09 three differed, and this note blamed the deliberate biquad deviations for all three; that was wrong on every count. Each had its own cause, and all four are fixed: (1) `lead` -- the biquad deviation was applied on ONE TARGET ONLY (audiodsp#77); synthio.Biquad now runs CircuitPython's Q15 on every target. (2) `bend` -- the zero-crossing loudness gate read the wrong voice slot on the CPython twin, whose note list compacts where CircuitPython's channel array does not; each note now carries the channel it was admitted on. (3) part of the SAW waveform -- the test-only ulab bridge rounded where real ulab truncates (tests/support/ulab). (4) `bass` -- the twin rendered voices already at envelope level 0, which the native builds skip. Harmless until the gate existed, because pending loudness 0 contributes silence; with the gate the voice kept sounding at its previous loudness for a whole block (audiodsp#78). Pinned by tests/test_cpython_silenced_voice.py, whose three cases all fail with the skip removed. Re-captured for audiodsp's resync with 11.0.0-alpha.2: upstream's mixer now scales a voice in integers, `(x * level) >> 15`, so `lead`, `bend` and `bass` moved, and the oracle, this port and desktop MicroPython agree on all eight lines again.", "fixture": "synthtools_acceptance.py stdout", "microcontroller_port": "parent workspace's built micropython", - "sha256": "757d944acffef29d9d57899916beef3b45aa05b802444c1eb29c98b03d0748be", + "sha256": "2aa95aeb211572afbb31a44f637979daef0ee97df4c254b1c7bbe0a8dd84ff30", "stdout": [ - "lead nbytes 20480 checksum 2466056 order 562618965", - "bend nbytes 9216 checksum 1153252 order 581011999", + "lead nbytes 20480 checksum 2464869 order 1222297248", + "bend nbytes 9216 checksum 1153116 order 1979562909", "json roundtrip filt_f 800.0 wave ASAW detune 1.004", - "bass nbytes 16384 checksum 2017069 order 1783014070", + "bass nbytes 16384 checksum 2070795 order 684190952", "echo delay_ms 230.76923076923077", "tail nbytes 2048 checksum 0 order 3523624389", "mixer playing False", diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index 8774935..634c61c 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -161,9 +161,9 @@ ("biquad_component_probe.py", "audiofilters", {"circuitpython": "this probe also covers Note.filter cascades, which " "are this port's extension and which CircuitPython " - "refuses. Everything else in it matches 11.0.0-alpha.1 " - "byte for byte, PEAKING_EQ and the filter reset " - "included. See docs/how-it-works/upstream-diff.md"}, + "refuses, and its biquads are CircuitPython main's " + "(ae47a3f15d), ahead of the pinned 11.0.0-alpha.2. " + "See docs/how-it-works/upstream-sync.md"}, None), # audiomixer is CircuitPython's, so like biquad_component_probe.py it is # held to the interpreters agreeing rather than to a capture. The twin @@ -233,7 +233,14 @@ "deliberate departures recorded in " "docs/how-it-works/upstream-diff.md"}, None), - ("echo_filter_probe.py", "audiodelays", {}, None), + ("echo_filter_probe.py", "audiodelays", + {"circuitpython": "synthio.Biquad is CircuitPython main's (ae47a3f15d, " + "adafruit/circuitpython#11679), ahead of the pinned " + "11.0.0-alpha.2 oracle; it matched alpha.2 byte for " + "byte before that change. Drop this skip when the pin " + "moves to a release with #11679. See " + "docs/how-it-works/upstream-sync.md"}, + None), # A delay line shorter than one audio buffer. CircuitPython 10.3.0 raised # it to the buffer's length after clamping it to the allocation, so the # line ran past its memory; upstream fixed the order in 6dddbda87 @@ -296,7 +303,14 @@ None), ("delay_time_probe.py", "audioecho", {}, None), ("feedback_delay_frames_probe.py", "audioecho", {}, None), - ("freeverb_filter_probe.py", "audiofreeverb", {}, None), + ("freeverb_filter_probe.py", "audiofreeverb", + {"circuitpython": "synthio.Biquad is CircuitPython main's (ae47a3f15d, " + "adafruit/circuitpython#11679), ahead of the pinned " + "11.0.0-alpha.2 oracle; it matched alpha.2 byte for " + "byte before that change. Drop this skip when the pin " + "moves to a release with #11679. See " + "docs/how-it-works/upstream-sync.md"}, + None), # A host reset mid-stream clears each node's own state and keeps the # source frames it has already taken, as CircuitPython's effects do # (audiodsp#181). The probe asserts it per node, and the PCM it prints diff --git a/tests/test_cpython_audiobiquad.py b/tests/test_cpython_audiobiquad.py index 416c4d1..22d0918 100644 --- a/tests/test_cpython_audiobiquad.py +++ b/tests/test_cpython_audiobiquad.py @@ -218,36 +218,28 @@ def test_the_all_pass_reaches_exact_zero_at_every_feedback(self): self._tail(node), "feedback %g never reached zero" % (feedback,)) - def test_the_frozen_kernel_has_fixed_points_this_one_does_not(self): - """audiodsp#23, reproduced at the kernel rather than cited. - - Not every trajectory lands on one -- which is why the defect went - unnoticed -- but a plain DC burst into a low-pass does, and the state - it lands on reproduces itself for as long as anyone cares to look. - - **The numbers here were 1 and 4 until 2026-09-09, and that was this - test measuring the wrong kernel.** `BiquadState.process_s16` ran - audiodsp's widened fixed point at the time, which does park but only on - a negligible value; audiodsp#77 pointed it at CircuitPython's actual Q15 - arithmetic, which is what the name always claimed. The defect is far - larger than 1 LSB: - - 40 Hz parks at 16143 100 Hz parks at 2631 - 400 Hz parks at 160 1000 Hz parks at 22 - - 16143 is half of full scale, held forever, from a filter asked for a - 40 Hz low-pass. Measured identically on the CPython extension, desktop - MicroPython and CircuitPython 10.3.0 (2026-09-09), so it is a fact - about CircuitPython's kernel and not about one binding. `audiobiquad`, - whose float state is the point of it, reaches exact zero at every one of - these -- that is B4 above. - - This is the measurement `docs/upstream-reports/biquad-band-edges.md` - exists to carry, and it is the cost of holding `synthio.Biquad` to - CircuitPython's bytes. + def test_the_frozen_kernel_parks_by_counts_not_thousands(self): + """audiodsp#23 and audiodsp#310, reproduced at the kernel. + + A plain DC burst into a low-pass, then silence. CircuitPython's Q15 + kernel landed on a state that reproduced itself for as long as anyone + cared to look, and it was large: + + 40 Hz parked at 16143 100 Hz parked at 2631 + 400 Hz parked at 160 1000 Hz parked at 22 + + Measured identically on the CPython extension, desktop MicroPython and + CircuitPython 10.3.0 (2026-09-09), so it was a fact about the kernel. + CircuitPython main (ae47a3f15d, adafruit/circuitpython#11679) widened + the coefficients and kept 12 bits of the feedback below the sample, and + this port took it: 400 Hz and 1000 Hz now reach exact zero, and 100 Hz + and 40 Hz sit 1 and 4 counts off it. `audiobiquad`, whose float state + is the point of it, still reaches exact zero at every one of these -- + that is B4 above. The numbers are pinned so a change to the kernel + has to say what it did to them. """ parked = [] - for frequency, expected in ((100.0, 2631), (40.0, 16143)): + for frequency, expected in ((100.0, 1), (40.0, 4)): state = _audiodsp.BiquadState() state.process_s16(array("h", [30000] * 256).tobytes(), 0, frequency, 0.7071067811865475, 1.0, 48000, 1.0, diff --git a/tests/test_cpython_audiograph_nodes.py b/tests/test_cpython_audiograph_nodes.py index 6daca45..945ad34 100644 --- a/tests/test_cpython_audiograph_nodes.py +++ b/tests/test_cpython_audiograph_nodes.py @@ -860,7 +860,10 @@ def compare(self, settings, frames=128, amplitude=6000): rig.set(ag.OVERDRIVE_TONE, tone) rig.set(ag.OVERDRIVE_LEVEL, level) got += rig.run(block) - want += ref.render(block, drive, tone, level) + # The node holds tone as the wire stores it, in float32. The + # shelf's coefficients carry up to 30 fractional bits, so a tone + # float32 can't hold exactly moves one, and the output by a count. + want += ref.render(block, drive, f32(tone), level) return got, want def test_matches_overdrivecore(self):