From 46c276f77fe036c42d0a5204021a4af40b3cfcab Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:19:46 -0500 Subject: [PATCH] AirSpace: count the repeats twice over against Loop Bass's high-pass Loop Bass is a high-pass in the loop, so a lap's peak can rise above the last one's and the tape outlived tail_samples' share for it: 1.49 times the count at Feedback 0.0039, 1.11 at 0.4999. The laps are now counted LOOP_BASS_LAP_FACTOR (2) times, as DigitalDelay does with Repeat Cut in. Render tests hold the tape and the whole rack to exact zero inside the bound at the worst cells, and go red on the single count. Fixes #235 --- lib/audioeffects/airspace.py | 46 ++++++++--- tests/test_cpython_effects_airspace.py | 108 ++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/lib/audioeffects/airspace.py b/lib/audioeffects/airspace.py index 6ca0cc48..e6f94f95 100644 --- a/lib/audioeffects/airspace.py +++ b/lib/audioeffects/airspace.py @@ -62,7 +62,7 @@ from . import _component -from .digitaldelay import laps_to_zero, whole_frames +from .digitaldelay import CUT_LAP_FACTOR, laps_to_zero, whole_frames from .tapedelay import tone_excess # The module, not the class, so this module holds one Component class. from . import reverb as _reverb @@ -101,6 +101,23 @@ FEEDBACK_MAX = 0.85 +#: `tail_samples` counts this many times `laps_to_zero`'s laps, as +#: `DigitalDelay` does with Repeat Cut in (`CUT_LAP_FACTOR`). Loop Bass is a +#: high-pass in the loop, always in circuit: it sends a lap's value less the +#: mean the loop has been carrying, which can be larger than any value on the +#: line, so the per-lap argument (each lap's peak below the last) does not +#: hold, and the factor is measured. On the tape's own output, at Feedback +#: just under each step of `laps_to_zero`'s count from 0.004 to 0.85 and in +#: steps of 0.01 from 0.40, Loop Bass 20 to 1000 Hz, Time 20 to 500 ms, 48 +#: and 22.05 kHz, on full-scale noise, random full-scale signs and clicks, +#: the longest tail took 1.49 times the count with each lap lengthened by +#: both one-poles' memory (Feedback 0.0039, Loop Bass 1 kHz, Loop Top +#: 16 kHz, Time 500 ms: three laps against two), 1.11 at Feedback 0.4999 +#: (17 laps against 15) and 1.02 at 0.5312; the more laps the count +#: holds, the less the high-pass adds. Mono, Drive and a lower Loop Top +#: measured no longer. Twice the count leaves a third again over the worst. +LOOP_BASS_LAP_FACTOR = CUT_LAP_FACTOR + (TIME_I, FEEDBACK_I, GLIDE_I, DRIVE_I, LOOP_TOP_I, LOOP_BASS_I, TOP_CUT_I, LOW_CUT_I, DECAY_I, DAMPING_I, MIX_I) = range(11) @@ -579,7 +596,8 @@ def _reset_chamber(self): def tail_samples(self): """Frames until the output is exactly zero once the input stops, as an upper bound: the tape's repeats, the Top Cut's ring and the - chamber's own tail, end to end.""" + chamber's own tail, end to end. The repeats are counted twice over + (`LOOP_BASS_LAP_FACTOR`), a margin measured rather than derived.""" self._check_live() return self._tail_bound() @@ -588,17 +606,23 @@ def _tail_bound(self): subclass can reach it on MicroPython, whose `property` has no `fget`. - `laps_to_zero` laps at the Feedback in force, each as long as the - longest delay the read head may be at plus one frame for the - interpolated read plus the memory of the loop's two one-poles; - then `_component.biquad_ring` of the Top Cut; then the chamber's - `tail_samples`. 0 at Mix 0, where the output is the input.""" + The repeats (`_repeats_bound`); then `_component.biquad_ring` of + the Top Cut; then the chamber's `tail_samples`. 0 at Mix 0, where + the output is the input.""" if self._value(MIX_I) <= 0.0: return 0 + ring = _component.biquad_ring(self._lp) or 0 + return int(self._repeats_bound() + ring + self._chamber.tail_samples) + + def _repeats_bound(self): + """Frames until the tape's own output is exactly zero once the input + stops: `LOOP_BASS_LAP_FACTOR` times `laps_to_zero`'s laps at the + Feedback in force, each as long as the longest delay the read head + may be at plus one frame for the interpolated read plus the memory + of the loop's two one-poles.""" rate = self._sample_rate top_memory, excess = tone_excess(self._handed["damping_hz"], rate) bass_memory, _bass = tone_excess(self._handed["cut_hz"], rate) - laps = laps_to_zero(self._handed["feedback"], excess) - repeats = laps * (self._reach + 1 + top_memory + bass_memory) - ring = _component.biquad_ring(self._lp) or 0 - return int(repeats + ring + self._chamber.tail_samples) + laps = LOOP_BASS_LAP_FACTOR * laps_to_zero(self._handed["feedback"], + excess) + return laps * (self._reach + 1 + top_memory + bass_memory) diff --git a/tests/test_cpython_effects_airspace.py b/tests/test_cpython_effects_airspace.py index b84dd7fe..05e0246b 100644 --- a/tests/test_cpython_effects_airspace.py +++ b/tests/test_cpython_effects_airspace.py @@ -28,6 +28,8 @@ import audiocore # noqa: E402 import airspace_plants as plants # noqa: E402 import audioeffects # noqa: E402 +from audioeffects.digitaldelay import laps_to_zero # noqa: E402 +from audioeffects.tapedelay import tone_excess # noqa: E402 VENDOR = "PyDevices" @@ -1288,6 +1290,109 @@ def _pull(self): self.assertNotEqual(got, want) +# -------------------------------------------------------------------------- +# The tail bound against Loop Bass (LOOP_BASS_LAP_FACTOR) + + +class TapeOut(AirSpace): + """A measuring tap, not a fault: the wet voice plays the tape itself, + past the Top Cut and the chamber, so at Mix 2 the output is the tape's + own repeats, which `_repeats_bound` bounds.""" + + def _wet_source(self): + return self._fd + + +class SingleLapCount(TapeOut): + """The repeats' bound before `LOOP_BASS_LAP_FACTOR`: `laps_to_zero`'s + laps once, as if Loop Bass's high-pass could not lift a lap's peak + above the last one's.""" + + def _repeats_bound(self): + rate = self._sample_rate + top_memory, excess = tone_excess(self._handed["damping_hz"], rate) + bass_memory, _bass = tone_excess(self._handed["cut_hz"], rate) + laps = laps_to_zero(self._handed["feedback"], excess) + return laps * (self._reach + 1 + top_memory + bass_memory) + + +def full_scale_signs(frames, channels, seed): + """+32 767 or -32 767 per sample, at random from `seed`.""" + state = (seed * 7919 + 17) & 0x7FFFFFFF + out = array("h", bytes(2 * frames * channels)) + for i in range(frames * channels): + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + out[i] = 32767 if state & 0x40000000 else -32767 + return out + + +def tape_tail(cls, rate, feedback, loop_bass_hz, time_ms=500.0, seed=0, + rack=False): + """(tail, bound): the frames from the input's end to the tape's last + non-zero frame, and the bound for it read once the input has stopped, + `_repeats_bound` on the tape alone or `tail_samples` on the whole rack + (`rack`). Four laps of full-scale random signs, Loop Top 16 kHz (the + least memory per lap), the chamber at its shortest Decay, Mix 2. The + render runs a lap past the bound, so a tail that outlives the bound is + seen.""" + channels = 2 + lap = law_frames(time_ms, rate) + frames = 4 * lap + data = full_scale_signs(frames, channels, seed) + effect = build(cls, data, rate, channels, time_ms=time_ms, + feedback=feedback, loop_bass_hz=loop_bass_hz, + loop_top_hz=16000.0, decay=0.3, mix=2.0) + bound = None + last = -1 + at = 0 + while bound is None or at < frames + bound + lap: + _, block = audiocore.get_buffer(effect.output) + samples = array("h", bytes(block)) + if any(samples): + i = len(samples) - 1 + while not samples[i]: + i -= 1 + last = at + i // channels + at += len(samples) // channels + if bound is None and at >= frames: + bound = effect.tail_samples if rack else effect._repeats_bound() + effect.deinit() + return last + 1 - frames, bound + + +class TheTailBound(unittest.TestCase): + """Loop Bass's high-pass can lift a lap's peak above the last one's, so + the repeats are counted `LOOP_BASS_LAP_FACTOR` times. The worst cells + measured sit just under a step of `laps_to_zero`'s count, at long + Times, where each lap's filter memory adds least.""" + + #: (rate, Feedback, Loop Bass Hz): the tape's longest tails against the + #: single count, 1.49 times it at Feedback 0.0039 (three laps against + #: two) and 1.11 at 0.4999 (17 against 15). + WORST = ((48000, 0.0039, 1000.0), (22050, 0.0039, 1000.0), + (48000, 0.4999, 300.0), (48000, 0.3291, 300.0)) + + def test_the_tape_reaches_exact_zero_inside_its_bound(self): + for rate, feedback, bass in self.WORST: + tail, bound = tape_tail(TapeOut, rate, feedback, bass) + self.assertGreater(tail, 0, (rate, feedback, bass)) + self.assertLessEqual(tail, bound, (rate, feedback, bass, tail)) + + def test_the_rack_reaches_exact_zero_inside_tail_samples(self): + for rate, feedback, bass in self.WORST[:3]: + tail, bound = tape_tail(AirSpace, rate, feedback, bass, + rack=True) + self.assertGreater(tail, 0, (rate, feedback, bass)) + self.assertLessEqual(tail, bound, (rate, feedback, bass, tail)) + + def test_red_on_the_single_count(self): + # Planted: the laps counted once. The tape outlives that bound at + # every worst cell. + for rate, feedback, bass in self.WORST: + tail, bound = tape_tail(SingleLapCount, rate, feedback, bass) + self.assertGreater(tail, bound, (rate, feedback, bass)) + + # -------------------------------------------------------------------------- # The docstring's claims, sentence by sentence (KIT.md, claims) @@ -1394,7 +1499,8 @@ class TestResetIsFresh(unittest.TestCase): def test_the_tail_after_a_reset_from_a_longer_time(self): # The reset emptied the tape with the outgoing patch's Time as the # head's reach, then patch 0's shorter Time kept it: from patch 2 - # the tail read 361 384 frames where a fresh instance reads 251 836. + # the tail read 361 384 frames where a fresh instance read 251 836 + # (both before the repeats were counted `LOOP_BASS_LAP_FACTOR` times). sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) import kit_fresh