Skip to content

audioverb.Tank: two fixes (#168, #169) - #172

Merged
bdbarnett merged 3 commits into
mainfrom
fix/tank-two
Sep 29, 2026
Merged

bdbarnett merged 3 commits into
mainfrom
fix/tank-two

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

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=0 the 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 call set() instead of rebuilding; until it does, a class that rebuilds loses the same frames it always did.

The changes

  1. audioverb.Tank: tone_db set to 0 freezes the tilt's one-pole, and moving Tone off 0 plays the stale state #168 (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 while tone_db is non-zero, as before. At 0 the two gains are exactly 1, and s + (v - s) is not always v in 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 for FeedbackDelay'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.
  2. audioverb.Tank: re-cutting the lines means a new node, and the old node's unplayed source frames are lost #169 (audiodsp_tank_recut, src/shared/audiodsp_tank.c:203; set() in src/audioverb/Tank.c, the CircuitPython binding, and src/cpython/audioverb.py over a private TankState.recut). set() accepts the constructor's delays and taps. 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 as clear() 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, and config_finish runs after it, so the modulation ceiling is the new lines'. sample_rate, channel_count and max_predelay_ms stay fixed.
    No new name: delays and taps are the constructor's keywords. It does widen what set() accepts, and the docstrings' "none of the three can change afterwards" becomes "max_predelay_ms cannot". 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_buffer still drops the held frames, as every node in the family does, and clear() still keeps them, so a class whose reset should not skip can register tank.clear as 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 pending pointer, 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).

Probe (node alone) main this branch
#168: Tone +12 → 0 → +12 out of exact silence, peak over 0.1 s, 48 kHz stereo / mono 1 382 / 707 LSB 0 / 0
#168: the same at 22.05 kHz stereo / mono 2 803 / 324 LSB 0 / 0
#168: Tone +12 → 0 → +3 out of silence, 48 kHz stereo 321 LSB 0
#168: back in while playing, frames differing from a 2^-24 dB node (largest), 48 kHz stereo 68 (2 995 LSB) 0
#169: mix=0, move 1 536 frames in, 1024- or 2048-frame source 512 frames ahead the source, byte for byte
#169: the same, a RawSample handed whole replays from its start the source, byte for byte
#169: a 256-frame source nothing lost nothing lost

(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() and reset_buffer mid-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):

  • ToneStateTest T1, T2 (audioverb.Tank: tone_db set to 0 freezes the tilt's one-pole, and moving Tone off 0 plays the stale state #168): Tone out, silence until the wet is exact zero, Tone back: all zero (stereo and mono, two moves). Tone out and back while playing: from the move on, byte for byte what a node handed 2^-24 dB renders. Main: 10 subtests fail. Planted: following the signal, 6; the state held at 0, 6. Applying the flat tilt at 0 passes the tests (nothing in a test is an independent reference for a node held at 0); tank_fix_moved.py catches it, 88 static renders moved.
  • RecutTest R1-R4 (audioverb.Tank: re-cutting the lines means a new node, and the old node's unplayed source frames are lost #169): at mix=0 the 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_fixed now 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; the mix=0 wire across a re-cut). With the v0.6.3rc1 MicroPython it fails, as it should: main refuses set(delays=...).
  • A third commit makes tank_state_probe.py cut 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.py passes and its --fault fails.

Goldens and probe outputs

No committed golden moves, and nothing is re-captured. All 36 existing probes (the 28 verify_dsp runs and the 8 others) are byte-identical to main, tank_probe.py included: it never moves Tone through 0 or re-cuts. tank_state_probe.py is 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 on audiofreeverb.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:

-O2 -Os
stereo, bare network, Tone 0 26.85 → 27.07 (+0.8 / +1.0 %) -1.1 / +0.1 %
stereo, filters and modulation, Tone 0 23.08 → 23.18 (+0.4 / +0.4 %) -0.6 / +1.0 %
stereo, filters and modulation, Tone +6 dB -0.1 / -0.3 % -0.1 / +0.5 %
mono, filters and modulation, Tone 0 +0.3 / +0.6 % -1.7 / +0.2 %
mono, filters and modulation, Tone +6 dB +0.5 / +2.4 % +0.2 / +0.1 %

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): OK against main, and one failure with this branch. It pinned the defect:

The two Rebuilds tests (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 with set(delays=..., taps=...) instead, which is the effects run's change to make, along with reset() registering tank.clear if a reset should not skip either.

On effects/phase5 (9ae23f9) no class builds a Tank and no test names audioverb; test_measure_effect_cost (its tool has a Tank row) passes.

Not in this PR

  • The Reverb class still rebuilds on a Character or Size move, so a player still hears the skip until the class adopts the re-cut.
  • reset_buffer still drops the held frames, as every node in the family does.
  • Board cost: no board was run.

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.
@bdbarnett
bdbarnett merged commit 160e3bb into main Sep 29, 2026
43 checks passed
@bdbarnett
bdbarnett deleted the fix/tank-two branch September 29, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant