Skip to content

audioecho.FeedbackDelay: a cross-fed stereo tail reaches exact zero (#170) - #173

Merged
bdbarnett merged 1 commit into
mainfrom
fix/feedback-delay-crossfeed
Sep 29, 2026
Merged

bdbarnett merged 1 commit into
mainfrom
fix/feedback-delay-crossfeed

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

Fixes #170: in stereo with a cross-feed strictly between 0 and 1, a FeedbackDelay tail could hold a few LSB of DC for ever. One commit.

What you'd hear

Before: a delay set to certain typed values, a cross-feed (Spread) strictly between 0 and 1 and a feedback one or two float32 steps under 1 - 0.5 / k, never went silent. Its repeats came down to k LSB, 9 to 50, and stayed there as DC for ever, and any class built on it reported a tail length that was wrong. feedback=0.9899999, cross_feed=39/127 held 50 LSB. Knob grids didn't land on these cells; typed values did. After: every such tail ends in exact silence. At every other setting nothing you can hear changes (the measurements below).

The change

src/shared/audiodsp_feedback_delay.c, recirculated() (:373), called with both lanes at :623. All three targets get it.

With both lanes of a stereo line at the same whole k, each lane sends own * direct + other * crossed round the loop. That is k exactly in real arithmetic, but in float32 the two rounded products could sum to one ulp above k, and at those feedbacks the stall test then saw a gap a hair over 0.5, rounded, and wrote k back on every lap. A gap more than 2^-14 past 0.5 still rounds straight away, one compare as before. Within 2^-14 of the edge, the node now works out what the rounded write would be and keeps it only if it is smaller than the larger of the two lanes; otherwise it truncates. That is the property #154's argument needs (no write as large as the largest value on the line), checked directly. So it holds however the sum was rounded: each product separately as the desktop does, or fused into a multiply-add in either order. And it can't touch a write that was already smaller than the larger lane.

The edge only has to cover how far the sum can overshoot the lanes: a few float32 steps of a value no larger than 51 (the test only bites where |sent| <= 0.5 / (1 - 0.99)). The measured excess is at most 1.19e-7 of the larger lane, under the 2.5 x 2^-24 bound. An edge of 2^-18 already fails under a fused stall test and 2^-16 passes, so 2^-14 is sixteen times the narrowest edge that failed.

Rejected, all measured:

No public API change on any binding. No golden moves; nothing is re-captured.

Measured

On desktop CPython (the extension), desktop MicroPython 1.29 and CircuitPython 10.3, each built from daca00f and from this branch; the three print the same lines, before and after.

Probe (node alone) main this branch
The 117 cells (cross_feed s/127, 48 kHz, 20 ms, DC 2k + 2 then 300 laps of silence): held at the end 117 of 117, 9 to 50 LSB 0
Controls at the same cells: cross_feed 0, mono 0 held 0 held

Stall search, on the node's own C linked into a harness (no Python between blocks), stereo, a DC of 2k + 2 for four laps and then silence until the line is empty or 3 x level + 200 lines have passed. Four builds of each tree: as shipped (sep, nothing fused, the audiodsp_fp_contract.h build), the cross-feed sum fused as fma(own, direct, other*crossed) (fmaA) or fma(other, crossed, own*direct) (fmaB), and the whole node with the header suppressed and -mfma -ffp-contract=fast (gcc). Held renders, main / this branch:

Set renders sep fmaA fmaB gcc
cross_feed s/127 x feedback within 4 steps of every 1 - 0.5/k (k = 1..50), +-DC, 8 kHz 5 ms 112 392 234 / 0 58 / 0 458 / 0 458 / 0
the same with the damping in (800 Hz and 3 kHz at 8 kHz) 112 392 234 / 0 58 / 0 458 / 0 458 / 0
the knob grids (s/127 x g/127 x 0.99), three levels, +-DC 96 768 0 / 0 0 / 0 0 / 0 0 / 0
cross_feed j/4096 (AnalogDelay's grid) 1 826 370 0 / 0 0 / 0 0 / 0 0 / 0
typed: n/100, n/1000, 2 000 seven-digit decimals, +-DC, 1 ms 2 763 416 6 682 / 0 1 812 / 0 12 684 / 0 12 684 / 0
cross_feed j/16383 (all 16 382) x 2 steps either side, 1 ms 4 062 736 17 512 / 0 not run not run not run

The 117 desktop cells are the sep row's 234 renders; under fmaA the set is 29 cells, 13 of them shared with the desktop's, as the class audit found. Today nothing fuses here: with the header, the ESP32-S3 and ESP32-P4 toolchains (esp-14.2.0) emit 0 fused instructions for this file at -O2 and -Os, 16 to 18 without it. The fused rows are for a build where the pragma doesn't take.

The arithmetic, exhaustively (sep, fmaA, fmaB, and the stall test both as written and fused). For equal whole lanes k = 1 ... 64, every float32 cross_feed in (0, 1), 1 065 353 215 values, lands the sum within one float32 step of k. For each of the 178 landings, every float32 feedback in [0.25, 0.99] (16 609 445 values; below 0.25 no write can reach k): writes as large as k on main, 68 / 30 / 68 with the test as written and 124 / 68 / 124 with it fused; on this branch, 0 in every column. Then 200 million random cells, unequal whole lanes in [-64, 64] and non-whole lanes, feedbacks near the knife edges: 0 on this branch. One side finding: with the stall test itself fused, main also hands back a lone lane (30 cells) and 238 random unequal-lane cells. No build fuses it today; this branch closes it too.

What moves at audible levels. #161's matrix, a 4 s plucked phrase at up to 23 000 LSB through a 340 ms delay rendered to 12 s, 166 cases: all 166 byte-identical. A cross-feed set of 120 (cross_feed 0.05, 0.3, 0.5, 39/127, 0.9, with and without damping, feedbacks 0.5, 0.75, 0.9, 0.99 and the typed 0.9899999 and 0.9444443583, 48 and 22.05 kHz): 118 byte-identical. A mono-source set of 24 (the same phrase on both channels, so the lanes are equal at every level): 23 byte-identical. The three that move are all the reported cell, 39/127 at 0.9899999. There main was handing small values back at zero crossings where the lanes meet: 30 to 214 samples, at most 3 to 10 LSB, -116 to -131 dBFS rms.

Every tail still reaches exact zero, on the same frame. #154's 420-case matrix as #161 rebuilt it: all 420 end on exactly the frame they ended on before. A cross-feed set of 380 (the five cross-feeds above, with and without damping, the matrix's feedbacks plus the knife edges for k = 2, 5, 9, 50 and the two float32 steps under each, 48 and 22.05 kHz): 376 unchanged. The 2 that held for ever on main (39/127 at the two k = 50 feedbacks, 48 kHz) now end at 4.88 s, and the same cell at 22.05 kHz ends 127 ms sooner (2 cases). #157's damped floor cases in feedback_delay_state_probe.py are byte-identical.

Cost. The C loop alone, main and this branch linked into one benchmark and timed alternately on one core, best of 40 000 single-block timings, microseconds per 256-frame stereo block. At -O2 (the ESP32 builds' level) every configuration is within -1.1 to +0.3 % over two runs: plain, cross-feed 0.5, damping with cross-feed, tape with cross-feed, mono, and a floor-level input where the edge test is taken most often. At -Os it reads about 1 % slower (four runs, -1.6 to +3.2 %). On the boards the file's text grows by 94 bytes at -O2 on both the S3 and the P4 (74 and 90 at -Os). These are desktop numbers; no board was measured.

Tests

tests/test_cpython_audioecho.py gains trait E14: the 117 cells at both DC signs on a 1 ms line at 8 kHz, five cells as they were found at 48 kHz 20 ms (the typed 0.9899999 with 39/127 among them), the controls (cross_feed 0 and 1, mono), and a check that the cell search still finds its 117. Main: 239 failures. Planted: the check off by one (keeping a write equal to the larger lane), 239; the edge at one ulp (2^-24), 239; at 2^-20, 204; the check made against the sum rather than the lanes, 239. An edge of 2^-18 passes E14 on the desktop and fails only under fused arithmetic; the exhaustive probe is where that shows (17 misses).

feedback_delay_state_probe.py gains the cross-fed floor (two cells, 8 kHz, 1 ms), so verify_dsp pins that the three targets render the new arithmetic identically.

Gates

  • pytest tests: 355 passed, 2 skipped (351 on main, which fails E14's 239 subtests). flake8 clean.
  • verify_dsp.py, three-way with MicroPython and CircuitPython built from this branch: 53 comparisons, 0 failures. With main's MicroPython it fails exactly feedback_delay_state_probe.py, first at the cross-fed lines, and agrees on the rest.
  • verify_acceptance, verify_effects, verify_streaming, verify_biquad, verify_mixdown_knee: all match their committed goldens. deinit_surface_probe.py passes, and its --fault fails as it should.

Downstream, not changed here

audiocomponents, exported with git archive and run read-only against this branch:

  • effects/phase5 at 9ae23f9: 1 894 tests, OK (skipped=3). Nothing goes red.
  • effects/phase5-analogdelay at d9ced37: 1 833 tests, 1 failure, and it pinned the defect. test_cpython_effects_analogdelay.Tier1Fast.test_the_cross_feed_stall_cells_reach_zero: the class's own half still passes (its tail ends inside tail_samples), and the half that goes red is the planted RawSpread, the class with Spread handed as set, which the test expects to hold k LSB for ever. It now ends: 0 where it asserted 9 at (0.9444443583, 1/127). Green on main.

AnalogDelay's 1/4096 Spread grid is no longer needed on this node. Measured on the class with Spread handed as set (audiocomponents ba7948b), over the 245 cells that hold on main under any of the three sum roundings, at both DC signs, 48 kHz, 20 ms: main holds 234 / 58 / 458 / 234 renders (separate / fmaA / fmaB / GCC-contracted); this branch holds 0 under all four. Through the class audit's own instrument (xfeed, the 117 cells plus their span over 48, 44.1 and 22.05 kHz, 20 / 300 / 600 ms, both characters, both DC signs and the set_macro route): 117 held and 117 in the span on main, 0 and 0 on this branch. The grid is harmless and holds 0 on either node; taking it out is the class's call.

…170)

With both lanes of a stereo line landed on the same whole k and cross_feed
strictly between 0 and 1, the float32 sum own * direct + other * crossed
could come out an ulp above k. One or two float32 steps under
f = 1 - 0.5 / k the stall test then saw a gap a hair over 0.5, rounded, and
wrote k back on every lap: 117 cells on the 7-bit cross_feed grid held 9 to
50 LSB for ever, identically on CPython, MicroPython and CircuitPython.

Within 2^-14 of the stall edge, recirculated() now works out the write and
keeps the rounding only if it is smaller than the larger of the two lanes;
past the edge it rounds straight away, one compare as before. That is the
property the tail argument needs, checked directly, so it holds however the
sum is rounded (separately, or fused either way round), and it cannot touch
a write that was already smaller than the larger lane.

Rejected: the same 2^-14 as a plain margin on the stall test. It ends every
tail too, but it moves the edge for every sample that crosses it, and loud
passages cross it at every zero crossing: 90 of 286 audible renders moved by
a 1 LSB flip carried round the loop. Also rejected: making equal lanes exact
by writing the mix as own + crossed * (other - own), or clamping the sum
between the lanes, both of which re-round every stereo sample with a
cross-feed.

Trait E14: the 117 cells at both DC signs, five as they were found at
48 kHz, and the controls. feedback_delay_state_probe.py gains the cross-fed
floor for verify_dsp.

Fixes #170.
@bdbarnett
bdbarnett merged commit 9f7338b into main Sep 29, 2026
43 checks passed
@bdbarnett
bdbarnett deleted the fix/feedback-delay-crossfeed branch September 29, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant