Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions lib/audioeffects/airspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()

Expand All @@ -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)
108 changes: 107 additions & 1 deletion tests/test_cpython_effects_airspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Loading