audioecho.FeedbackDelay: a cross-fed stereo tail reaches exact zero (#170) - #173
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #170: in stereo with a cross-feed strictly between 0 and 1, a
FeedbackDelaytail 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/127held 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 * crossedround 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:
own + crossed * (other - own). That re-rounds every stereo sample with a cross-feed: 145 of 310 audible renders moved.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
daca00fand from this branch; the three print the same lines, before and after.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, theaudiodsp_fp_contract.hbuild), the cross-feed sum fused asfma(own, direct, other*crossed)(fmaA) orfma(other, crossed, own*direct)(fmaB), and the whole node with the header suppressed and-mfma -ffp-contract=fast(gcc). Held renders, main / this branch:The 117 desktop cells are the
seprow's 234 renders; underfmaAthe 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.pyare 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.pygains 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.pygains the cross-fed floor (two cells, 8 kHz, 1 ms), soverify_dsppins 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 exactlyfeedback_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.pypasses, and its--faultfails as it should.Downstream, not changed here
audiocomponents, exported with
git archiveand run read-only against this branch:effects/phase5at9ae23f9: 1 894 tests,OK (skipped=3). Nothing goes red.effects/phase5-analogdelayatd9ced37: 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 insidetail_samples), and the half that goes red is the plantedRawSpread, 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 theset_macroroute): 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.