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
41 changes: 29 additions & 12 deletions lib/audioeffects/digitaldelay.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@

**Latency, tail, portability.** Latency is zero samples: nothing looks
ahead. `tail_samples` is an upper bound on how long the output takes to
reach exact zero once your input stops, at every Feedback, with Repeat Tone
in or out. With Repeat Cut in circuit, as at patch 5, `tail_samples` is
`None`: the class gives no bound there. Pass a lower `max_time_ms` for a
reach exact zero once your input stops, at every Feedback and every
setting of Repeat Tone and Repeat Cut. With Repeat Cut in circuit, as at
patch 5, the bound is twice as many repeats as with it out, a margin
measured rather than derived. Pass a lower `max_time_ms` for a
shorter line: Time then stops at that ceiling, and `get_macro(0)` shows
where it stopped. A constructor Time of 0 is the bottom of its span, and a
Repeat Tone or Repeat Cut of 0 is that filter out. `reset()` empties the
Expand Down Expand Up @@ -116,6 +117,21 @@
#: never itself on the widened edge.
STALL_FUZZ = 2.0 ** -22

#: With Repeat Cut in circuit `tail_samples` counts this many times
#: `laps_to_zero`'s laps. The high-pass 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 no count of its own is derived here. The factor is
#: measured. On full-scale noise, at every Feedback from 0.40 to 0.99 in
#: steps of 0.01, Repeat Cut from 20.3 to 400 Hz, 48 and 22.05 kHz, ten
#: noises each, and again on random full-scale signs and sparse clicks,
#: mono and stereo, Repeat Tone out and at 800 Hz, and on `PingPongDelay`
#: at both ends of Spread, the longest tail took 1.35 times the count with
#: the filter out (`PingPongDelay`, Feedback 0.5, Spread 1, Cut 20.3 Hz),
#: and at Feedback 0.99 1.06 (721 laps against 685). Twice the count
#: leaves nearly half again over the worst.
CUT_LAP_FACTOR = 2

#: Division's sixteen note values, in quarter-note beats, rising: 1/32,
#: 1/16T, 1/32., 1/16, 1/8T, 1/16., 1/8, 1/4T, 1/8., 1/4, 1/2T, 1/4., 1/2,
#: 1/1T, 1/2., 1/1.
Expand Down Expand Up @@ -608,7 +624,7 @@ def _refresh(self):
@property
def tail_samples(self):
"""Frames until the output is exactly zero once the input stops, as
an upper bound, or `None` where no bound is derived.
an upper bound.

`laps_to_zero(f)` laps, each at most one frame longer than the
longest delay the read head may be at (the read interpolates
Expand All @@ -617,12 +633,13 @@ def tail_samples(self):
a Glide-0 move or a reset lands the head, because the class cannot
see how far the walk has got.

Finite at every Feedback with Repeat Cut out. With Repeat Tone in,
each lap is `memory` frames longer, the time the low-pass takes to
forget the lap before, and near the Feedback values where the node
once held a small value for ever the count takes one more lap
(`laps_to_zero`). `None` with Repeat Cut in circuit: this class
derives no bound there.
Finite at every Feedback. With Repeat Tone in, each lap is
`memory` frames longer, the time the low-pass takes to forget the
lap before, and near the Feedback values where the node once held a
small value for ever the count takes one more lap (`laps_to_zero`).
With Repeat Cut in, the laps are counted `CUT_LAP_FACTOR` times:
the high-pass can lift a lap's peak above the last one's, so that
margin is measured, not derived (`CUT_LAP_FACTOR`).
"""
self._check_live()
return self._tail_bound()
Expand All @@ -631,10 +648,10 @@ def _tail_bound(self):
"""`tail_samples` without the liveness check: a plain method, so a
subclass can reach it on MicroPython, whose `property` has no
`fget` and whose `super()` hands back the property itself."""
if self._macros[CUT_I] > 0.0:
return None
memory, excess = self._tone_excess()
laps = laps_to_zero(self._feedback, excess)
if self._macros[CUT_I] > 0.0:
laps *= CUT_LAP_FACTOR
return int(laps * (self._reach + 1 + memory))

def _tone_excess(self):
Expand Down
20 changes: 12 additions & 8 deletions lib/audioeffects/pingpongdelay.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

# DigitalDelay's arithmetic, reused rather than copied: the same node rounds
# the same way in both classes; the same for SlapbackDelay's `tone_excess`.
from .digitaldelay import (DIVISION_BEATS, laps_to_zero, nominal_cut_hz,
whole_frames)
from .digitaldelay import (CUT_LAP_FACTOR, DIVISION_BEATS, laps_to_zero,
nominal_cut_hz, whole_frames)
from .slapbackdelay import tone_excess

try:
Expand Down Expand Up @@ -144,8 +144,9 @@ class PingPongDelay(_component.Component):
in: there is no latency. `tail_samples` is an upper bound on how many
frames the output takes to reach exact zero, counted from when your
input stops or from when you read it if that is later, for the settings
as they stand when you read it. With Repeat Cut in circuit
`tail_samples` is `None`: the class gives no bound there. Pass a lower
as they stand when you read it. With Repeat Cut in circuit the bound
is twice as many repeats as with it out, a margin measured rather than
derived. Pass a lower
`max_time_ms` for a shorter line: Time then stops at that ceiling, and
`get_macro(0)` shows where it stopped. A constructor value outside a
knob's span clamps to the nearer stop, a `tone_hz` or `cut_hz` of 0 or
Expand Down Expand Up @@ -449,24 +450,27 @@ def _refresh(self):
@property
def tail_samples(self):
"""Frames until the output is exactly zero once the input stops, as
an upper bound, or `None` with Repeat Cut in circuit.
an upper bound.

`laps_to_zero(f, excess)` laps, each at most one frame longer than
the longest delay the read head may be at (the read interpolates
towards the next older frame), plus the Tone low-pass's memory.
While a walk falls, that is the Time it is walking from, until a
reset lands the head. The cross-feed hands each lane a convex mix
of the two lanes' loop values (`audiodsp_feedback_delay.c:581-589`),
so the per-lap argument holds at every Spread."""
so the per-lap argument holds at every Spread. With Repeat Cut in,
the laps are counted `CUT_LAP_FACTOR` times, as `DigitalDelay`
counts them: the high-pass can lift a lap's peak above the last
one's, and that margin is measured, not derived."""
self._check_live()
return self._tail_bound()

def _tail_bound(self):
"""`tail_samples` without the liveness check: a plain method, so a
subclass can reach it on MicroPython, whose `property` has no
`fget`."""
if self._cut > 0.0:
return None
memory, excess = tone_excess(self._damping, self._sample_rate)
laps = laps_to_zero(self._feedback, excess)
if self._cut > 0.0:
laps *= CUT_LAP_FACTOR
return int(laps * (self._reach + 1 + memory))
101 changes: 83 additions & 18 deletions tests/test_cpython_effects_digitaldelay.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class of the node's walk, and the edge test goes red at the old edge. A
VENDOR = "PyDevices"

RATE = 48000
#: Repeat Cut's planted cell: Feedback 0.5, Cut 20.3 Hz, noise seed 0, where
#: the tail (10 436 frames) outlives the count without `CUT_LAP_FACTOR`
#: (9 616).
PLANT_FEEDBACK, PLANT_SEED = 0.5, 0
BLOCK = 256
TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \
range(8)
Expand Down Expand Up @@ -497,6 +501,20 @@ def tail_samples(self):
return declared - (self._reach + 1)


class UndoubledCutDelay(DigitalDelay):
"""Repeat Cut's bound without `CUT_LAP_FACTOR`: the laps counted as if
the high-pass were out. The high-pass lifts some laps' peaks above the
last one's, so noise outlives it."""

NAME = 'DigitalDelay'

@property
def tail_samples(self):
memory, excess = self._tone_excess()
return int(dd.laps_to_zero(self._feedback, excess)
* (self._reach + 1 + memory))


class OneLapToneDelay(DigitalDelay):
"""Fix round 2's tail with Repeat Tone in: the lap count as if the
filter were out, plus one lap. The low-pass can hold a small value in
Expand Down Expand Up @@ -1316,13 +1334,15 @@ def shares(segment):


def tail_measure(cls, feedback, material, rate=RATE, channels=2,
time_ms=12.5, budget=None, **options):
time_ms=12.5, budget=None, seed=0, **options):
"""The frames from the input's end to the output's last non-zero frame,
against the `tail_samples` the class declares once the input has
stopped. `floor` is gate audit round 1's cell (997 Hz at 12 000 LSB for
2 048 frames, the material that held 1 LSB for ever at v0.6.1);
`fullscale` is four laps of +32 767, which leaves every line sample at
the rail and so meets the lap count exactly. Mix 2, wet only."""
the rail and so meets the lap count exactly; `noise` is four laps of
full-scale uniform noise from `seed`, the material that lets Repeat
Cut's high-pass lift a lap above the last. Mix 2, wet only."""
probe = DigitalDelay(silence_src(64, channels, rate), sample_rate=rate,
time_ms=time_ms, feedback=feedback, mix=2.0,
**options)
Expand All @@ -1333,6 +1353,10 @@ def tail_measure(cls, feedback, material, rate=RATE, channels=2,
values = [0] * 256 + sine_values(997.0, 2048, rate, 12000)
elif material == "dc2":
values = [2] * (rate // 2)
elif material == "noise":
noise = np.random.default_rng(seed).integers(-32767, 32768,
4 * lap)
values = [int(v) for v in noise]
else:
values = [32767] * (4 * lap)
input_end = len(values)
Expand Down Expand Up @@ -1633,7 +1657,7 @@ def test_tail_samples_follows_time_and_feedback(self):
self.assertEqual(dd.laps_to_zero(0.7, 1e-5), 29)
effect.set_macro(FEEDBACK_I, 0)
effect.set_macro(CUT_I, 1)
self.assertIsNone(effect.tail_samples)
self.assertEqual(effect.tail_samples, 2 * 601)
effect.reset()
self.assertEqual(effect.tail_samples, 11 * 16936)

Expand Down Expand Up @@ -2771,21 +2795,38 @@ def test_mix_2_is_the_repeats_alone(self):
self.assertEqual(float(np.max(np.abs(out[:4810]))), 0.0)
self.assertEqual(float(out[4810]), 30000.0)

def test_repeat_cut_in_has_no_tail_bound(self):
def test_repeat_cut_in_keeps_a_measured_bound(self):
# Repeat Cut's high-pass can take a lap's peak above the last one,
# so the per-lap count does not hold: `None` at patch 5 and at any
# Cut position above the out stop; an int with Cut out.
# so the per-lap count alone does not hold; with Cut in the class
# counts `CUT_LAP_FACTOR` (2) times the laps, at patch 5 and at any
# Cut position above the out stop.
effect = DigitalDelay(silence_src(512), sample_rate=RATE, patch=5)
self.assertGreater(effect.get_macro(CUT_I), 0.0)
self.assertIsNone(effect.tail_samples)
with_cut = effect.tail_samples
effect.set_macro(CUT_I, 0)
self.assertEqual(with_cut, dd.CUT_LAP_FACTOR * effect.tail_samples)
for position in (1, 64, 127):
effect.set_macro(CUT_I, position)
self.assertIsNone(effect.tail_samples, position)
effect.set_macro(CUT_I, 0)
self.assertIsInstance(effect.tail_samples, int)
for patch in (0, 1, 2, 3, 4):
effect.program_change(patch)
self.assertIsInstance(effect.tail_samples, int, patch)
self.assertEqual(effect.tail_samples, with_cut, position)
effect.deinit()
# The worst cells measured: Cut just above its out stop, full-scale
# noise, the Feedback where the high-pass adds most against the
# count (0.5) and the top (0.99), at both rates. Each reaches exact
# zero inside the bound.
for rate in (RATE, 22050):
for feedback in (0.5, 0.99):
for seed in range(3 if feedback < 0.9 else 1):
result = tail_measure(DigitalDelay, feedback, "noise",
rate=rate, seed=seed, cut_hz=20.3)
self.assertTrue(result["passed"],
(rate, feedback, seed, result))
self.assertEqual(result["held"], 0,
(rate, feedback, seed, result))
# Planted: the count without the factor, which noise outlives.
result = tail_measure(UndoubledCutDelay, PLANT_FEEDBACK, "noise",
seed=PLANT_SEED, cut_hz=20.3)
self.assertFalse(result["passed"], result)
self.assertGreater(result["tail"], result["declared"], result)

def test_patch_5_is_the_pedal_corners(self):
# Patch 5's Repeat Tone and Repeat Cut positions are the 7 kHz and
Expand Down Expand Up @@ -2996,16 +3037,16 @@ def test_clean_at_the_stated_input_level(self):
("Tier1Fast.test_click_delay_is_zero",
"TheSurface.test_macros_patches_tier_latency")),
("`tail_samples` is an upper bound on how long the output takes to "
"reach exact zero once your input stops, at every Feedback, with "
"Repeat Tone in or out.",
"reach exact zero once your input stops, at every Feedback and every "
"setting of Repeat Tone and Repeat Cut.",
("Tier1Fast.test_the_tail_reaches_exact_zero_inside_tail_samples",
"Tier1Fast.test_the_tail_is_bounded_at_every_feedback",
"Tier1Fast.test_repeat_tone_stall_cells_reach_zero_at_the_feedback_set",
"Tier1Fast.test_every_window_centre_reaches_zero",
"Tier1Fast.test_a_falling_walk_keeps_the_old_time_in_the_tail")),
("With Repeat Cut in circuit, as at patch 5, `tail_samples` is `None`: "
"the class gives no bound there.",
("TrialClaims.test_repeat_cut_in_has_no_tail_bound",)),
("With Repeat Cut in circuit, as at patch 5, the bound is twice as many "
"repeats as with it out, a margin measured rather than derived.",
("TrialClaims.test_repeat_cut_in_keeps_a_measured_bound",)),
("Pass a lower `max_time_ms` for a shorter line: Time then stops at "
"that ceiling, and `get_macro(0)` shows where it stopped.",
("T4TimeLaw.test_a_lowered_ceiling_clamps_visibly",)),
Expand Down Expand Up @@ -3114,5 +3155,29 @@ def test_the_tail_after_a_reset_is_a_fresh_instance_s(self):
fresh.deinit()


#: Not here, because they show only when a knob moves, on a reset or a
#: rejoin, or not at all on a fresh build: `HalfGlideMsDelay`,
#: `ZeroBpmDelay`, `JumpSeedGlideDelay`, `NearZeroSeedGlideDelay`,
#: `NanBpmDelay`, `FloorEraTailDelay`, `UndoubledCutDelay`,
#: `OneLapToneDelay`, `SteppedToneDelay`, `WindowNoneToneDelay`,
#: `TrackingToneDelay`, `LeakyTrackDelay`, `SlowSeedGlideDelay`,
#: `TargetOnlyTailDelay`, `PerMacroPatchDelay`; they change only a reported
#: tail: `SixtyDbTailDelay`, `LapShortTailDelay`; Tone at 0 renders it:
#: `FrozenToneDelay`.
KIT_PLANTS = {
"DryScaledDelay": DryScaledDelay,
"DryGainDelay": DryGainDelay,
"StaircaseDelay": (StaircaseDelay, 3),
"HalfFrameDelay": HalfFrameDelay,
"LinearMapDelay": LinearMapDelay,
"OpenTopToneDelay": OpenTopToneDelay,
"RawTopToneDelay": RawTopToneDelay,
"CornerShiftDelay": (CornerShiftDelay, 5),
"PostCutDelay": (PostCutDelay, 5),
"PostToneDelay": (PostToneDelay, 5),
"FrozenCutDelay": FrozenCutDelay,
}


if __name__ == "__main__":
unittest.main()
Loading
Loading