audioverb.Tank: two fixes (#168, #169) - #172
Merged
Merged
Conversation
At tone_db 0 the output loop skipped the tilt's one-pole altogether, so its state froze at whatever it held when Tone reached 0, and moving Tone off 0 later played that state: 1 382 LSB out of exact silence at 48 kHz stereo, 707 mono, on all three targets. The pole now runs whenever it has a coefficient; the tilt is still applied only when tone_db is non-zero, so a node held at 0 renders exactly what it did, and Tone comes back exactly as it would from 2^-24 dB. Rejected: following the signal while out (s = v). Silent out of silence, but wrong for the first millisecond of wet when Tone returns while playing (2 402 LSB at 48 kHz stereo). Holding the state at 0 is wrong the same way. Tests T1 and T2 in test_cpython_audioverb.py (ToneStateTest): 10 subtests fail on main, 6 with the pole following the signal, 6 with it held at 0.
…lace (#169) The line and tap tables size the allocation, so set() refused them and a class that changed a reverb's size or character had to build a new node. The node pulls its source a buffer at a time and plays 256 frames a block, so it can hold the unplayed rest of a source buffer between blocks, and those frames went with the old node: at mix 0 a 1024- or 2048-frame source came out 512 frames ahead after a move 1536 frames in, and a RawSample handed whole replayed from its start, on all three targets. set() now takes the constructor's delays and taps. The new tables are checked on a copy (audiodsp_tank_recut), the network is allocated, or cleared when it is the same size, and starts exactly as a newly built node's does; the source and the frames it holds stay. Options in the same call apply after the re-cut. sample_rate, channel_count and max_predelay_ms stay fixed. No new name. Rejected: reading the held frames out of the old node for a new one (a new public method); the old node leaving them where a new node on the same source would find them (hidden shared state, wrong once anything pulls the source in between). reset_buffer still drops the held frames, as the family does; clear() keeps them, as it always has. Tests R1-R4 in test_cpython_audioverb.py (RecutTest): 11 fail on main. Planted: the held frames dropped anyway, 11; a same-size re-cut that does not clear, 3; filters and modulation phase carried over, 7; the modulation ceiling from the old lines, 2; a bad option checked after the re-cut, 1. tests/parity/tank_state_probe.py pins both fixes across the interpreters.
scaled() multiplied frame counts by a float and truncated, and a single-precision MicroPython truncated some of them a frame differently from CPython (380 x 1.4 is 531.99... in double), so the float-precision unix-usermod job saw the two disagree at the longer re-cut. The node was not the difference. Now x tenths // 10; CPython, a double and a float MicroPython and CircuitPython agree line for line.
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 #168, fixes #169: two defects in
audioverb.Tank, one commit each.What you'd hear
#168, Tone back from centre. Before: set Tone to exactly 0, stop playing, let the tail die, then move Tone again, and the reverb ticks out of silence: 1 382 LSB at 48 kHz stereo, 707 mono, 2 803 at 22.05 kHz stereo. Moved while playing, the first millisecond or so of wet came out wrong (up to 2 995 LSB). After: silence stays silent, and Tone comes back from what is playing, exactly as it would from 2^-24 dB. A Tone knob that isn't moving sounds exactly as before.
#169, changing size or character while playing. Before: a class could only change the line lengths by building a new node, and the source frames the old node had pulled but not played went with it. At
mix=0the dry skipped: 512 frames (about 10 ms at 48 kHz) of a 1024- or 2048-frame source, and a RawSample handed whole started again from its beginning. After:tank.set(delays=..., taps=...)re-cuts the node in place and nothing skips. The tail still starts over, as it did with a new node: the new network starts empty. This needs the class to callset()instead of rebuilding; until it does, a class that rebuilds loses the same frames it always did.The changes
src/shared/audiodsp_tank.c:620, all three targets). The tilt's one-pole runs whenever it has a coefficient; the tilt is applied only whiletone_dbis non-zero, as before. At 0 the two gains are exactly 1, ands + (v - s)is not alwaysvin float, so the output at 0 still skips the tilt, and a node held at 0 renders the bytes it always did. One multiply-add per channel per sample while Tone is at 0.Rejected: following the signal while out (
s = v, a store, as audioecho.FeedbackDelay: damping_hz set to 0 freezes the loop low-pass, and switching it back in plays the stale state #158 did forFeedbackDelay's low-pass). Out of silence it is silent too, but a tilt holds a low band, not the last sample: back to +12 dB while noise plays it is still 2 402 LSB off at 48 kHz stereo (main: 2 995). Holding the state at 0 is wrong the same way (1 914). Also rejected: applying the flat tilt at 0 so the pole runs "naturally"; it moves 88 of 990 held-still renders by float rounding.audiodsp_tank_recut,src/shared/audiodsp_tank.c:203;set()insrc/audioverb/Tank.c, the CircuitPython binding, andsrc/cpython/audioverb.pyover a privateTankState.recut).set()accepts the constructor'sdelaysandtaps. The new tables are checked on a copy of the config, so a refusal changes nothing; unknown options in the same call are refused before anything moves. The network is then allocated (outside the pump lock; only the swap is inside it) or, at the same size, cleared in place under the lock asclear()does, and starts exactly as a newly built node's: lines, filters, predelay, modulation phase. The source and the frames the node holds stay. Options in the same call apply after the re-cut, andconfig_finishruns after it, so the modulation ceiling is the new lines'.sample_rate,channel_countandmax_predelay_msstay fixed.No new name:
delaysandtapsare the constructor's keywords. It does widen whatset()accepts, and the docstrings' "none of the three can change afterwards" becomes "max_predelay_mscannot". If that counts as public surface, this commit comes out cleanly and audioverb.Tank: tone_db set to 0 freezes the tilt's one-pole, and moving Tone off 0 plays the stale state #168 stands alone.Rejected: a way to read the held frames out of the old node for a new one (a new public method, handing a pointer into the source's memory across objects); the old node leaving its frames where a new node on the same source would find them (hidden state shared between objects, wrong once anything pulls the source in between). Not changed:
reset_bufferstill drops the held frames, as every node in the family does, andclear()still keeps them, so a class whose reset should not skip can registertank.clearas its reset.Where the frames were lost, measured rather than assumed: it is not the deinit (dropping the old node without deinit loses exactly as many), nor destruction; the frames live only in the node's
pendingpointer, and nothing could carry them to a new node.audiocore.reset_buffer(tank)drops them too;tank.clear()keeps them.Measured
At the node alone, on desktop CPython, MicroPython 1.29 and CircuitPython 10.3, each built from this branch; the three agree line for line, before and after (
tank_fix_repro.py).mix=0, move 1 536 frames in, 1024- or 2048-frame source(A rebuild still loses the frames on this branch, as it must; the cure is the re-cut.)
What moves.
tank_fix_moved.py, main against this branch, 1 038 renders of 3 s of a plucked phrase: 48, 44.1, 22.05 and 8 kHz; stereo and mono; Tone held at 0, ±0.5, ±6, ±12 and ±2^-24 dB; ten option sets (bare, drive, width 0.3 and 1.7, predelay, mix 0, 1 and 2, filters out); a re-cut network at construction;set(),clear()andreset_buffermid-stream. All 990 held-still renders are byte-identical. All 48 that move Tone through 0 and back differ, which is the fix. #169 moves nothing main can render: main refuses the keywords.Tests
tests/test_cpython_audioverb.py, each shown failing on main and with planted wrong cures (tank_fix_plants.py):tank_fix_moved.pycatches it, 88 static renders moved.mix=0the output is the source across a re-cut, on a 1024-frame source and a RawSample; after a re-cut the node renders byte for byte what a node built on the new tables renders from the same source frame (longer, shorter, same size; stereo and mono; options in the same call; a modulation depth past the new lines' ceiling); a refused re-cut changes nothing; taps alone start the network empty. Main: 11 fail. Planted: the held frames dropped anyway, 11; a same-size re-cut that does not clear, 3; filters and modulation phase carried over, 7; the modulation ceiling from the old lines, 2; a bad option checked after the re-cut, 1.test_set_refuses_what_construction_fixednow names the three keywords that stay fixed.Gates
pytest tests: 358 passed, 1 skipped (351 passed, 2 skipped on main; the skip difference is a revision check that skips in an exported tree). Each commit passes its own suite (353 at the first). flake8 clean.verify_dsp.py, three-way with MicroPython and CircuitPython built from this branch: 55 comparisons, 0 failures. A new probe,tank_state_probe.py, pins both fixes (Tone through 0, out of silence and while playing; re-cuts longer, shorter, same size and taps alone; themix=0wire across a re-cut). With the v0.6.3rc1 MicroPython it fails, as it should: main refusesset(delays=...).tank_state_probe.pycut its test networks in integers: the first CI run's float-precision unix build truncated one frame count differently from CPython (380 * 1.4), in the probe, not the node. A float-precision MicroPython built locally from this branch now agrees line for line. CI: 43 of 43.verify_acceptance,verify_effects,verify_streaming,verify_biquad,verify_mixdown_knee: all match their committed goldens.deinit_surface_probe.pypasses and its--faultfails.Goldens and probe outputs
No committed golden moves, and nothing is re-captured. All 36 existing probes (the 28
verify_dspruns and the 8 others) are byte-identical to main,tank_probe.pyincluded: it never moves Tone through 0 or re-cuts.tank_state_probe.pyis new.The soundtrack render reference cannot move from this change: nothing in mpvst imports
audioverb, and the Reverb that audiocomponents serves (audioeffects.reverb) is built onaudiofreeverb.Freeverb, not the Tank. Not re-captured.Cost
The C loop alone, main and this branch linked into one benchmark and timed alternately, pinned to one core, minimum of 40 000 single-block timings, x86-64, µs per 256-frame block at 48 kHz:
Everything is inside the run-to-run noise, about 2 % on this loaded box; the one real addition is a multiply-add per channel per sample while Tone is at 0. Desktop numbers; the boards were not measured.
RAM. The node's struct and its allocation are unchanged. A re-cut to a different size allocates the new network before freeing the old, so for a moment both exist: the same peak as today's rebuild, which also holds the old node's lines until the collector runs. A same-size re-cut allocates nothing.
Downstream, not changed here
audiocomponents, exported and run read-only against each build. On the Reverb branch (
effects/phase5-reverb,80d2b70), every test file that reaches the Tank or builds every rebuilt class (168 tests; the full discover run did not finish inside 50 minutes on this loaded box):OKagainst main, and one failure with this branch. It pinned the defect:test_cpython_effects_reverb.Tier1.test_tone_detent_as_exact_zero_is_red(audioverb.Tank: tone_db set to 0 freezes the tilt's one-pole, and moving Tone off 0 plays the stale state #168): the plantedToneDetentZero, which handstone_db=0at the detent, no longer plays over 100 LSB when Tone leaves it out of silence; it plays 0. The class's 2^-24 dB workaround (TONE_TRACK_DB) is no longer needed.The two
Rebuildstests (test_a_rebuild_skips_what_the_old_tank_held,test_a_rebuild_keeps_the_wire_on_a_256_frame_source) stay green: the class still rebuilds, and a rebuild still loses what the old node held. They go red when the class re-cuts withset(delays=..., taps=...)instead, which is the effects run's change to make, along withreset()registeringtank.clearif a reset should not skip either.On
effects/phase5(9ae23f9) no class builds a Tank and no test namesaudioverb;test_measure_effect_cost(its tool has a Tank row) passes.Not in this PR
reset_bufferstill drops the held frames, as every node in the family does.