diff --git a/lib/audioeffects/airspace.py b/lib/audioeffects/airspace.py index 6ca0cc4..e6f94f9 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 b84dd7f..05e0246 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