diff --git a/lib/audioeffects/digitaldelay.py b/lib/audioeffects/digitaldelay.py index d55f8ac..3977861 100644 --- a/lib/audioeffects/digitaldelay.py +++ b/lib/audioeffects/digitaldelay.py @@ -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 @@ -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. @@ -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 @@ -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() @@ -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): diff --git a/lib/audioeffects/pingpongdelay.py b/lib/audioeffects/pingpongdelay.py index 15de2de..cd18356 100644 --- a/lib/audioeffects/pingpongdelay.py +++ b/lib/audioeffects/pingpongdelay.py @@ -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: @@ -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 @@ -449,7 +450,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` 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 @@ -457,7 +458,10 @@ def tail_samples(self): 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() @@ -465,8 +469,8 @@ 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)) diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index 218748f..bb3329c 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -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) @@ -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 @@ -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) @@ -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) @@ -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) @@ -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 @@ -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",)), @@ -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() diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index fc864a5..ef26a11 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -44,6 +44,9 @@ VENDOR = "PyDevices" RATE = 48000 +#: Repeat Cut's planted cell (Feedback, Spread, noise seed): the tail +#: (19 979 frames) outlives the count without `CUT_LAP_FACTOR` (15 376). +PP_PLANT = (0.5, 0.0, 0) BLOCK = 256 RATES = (48000, 44100, 22050) (TIME_I, FEEDBACK_I, MIX_I, SPREAD_I, SIDE_I, SYNC_I, DIVISION_I, TONE_I, @@ -295,10 +298,22 @@ def _clear(self): self._tone_was_in = False +class UndoubledCutPingPong(PingPongDelay): + """Repeat Cut's bound without `CUT_LAP_FACTOR`: the laps counted as if + the high-pass were out, which noise outlives.""" + + NAME = 'PingPongDelay' + + def _tail_bound(self): + memory, excess = pp.tone_excess(self._damping, self._sample_rate) + laps = pp.laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) + + class HeldCutPingPong(PingPongDelay): """The class before audiodsp v0.6.3rc1: once Repeat Cut has been in, its bottom stop stays in circuit at the 20 Hz corner, and - `tail_samples` is `None` while it lasts.""" + `tail_samples` keeps Repeat Cut's doubled count while it lasts.""" NAME = 'PingPongDelay' @@ -1082,14 +1097,15 @@ def test_the_filter_stops(self): # hand exactly 0 whatever came before, and the bound is finite. effect.set_macro(TONE_I, 127) self.assertEqual(handed(effect, "damping_hz"), 0.0) + out = effect.tail_samples effect.set_macro(CUT_I, 1) self.assertGreater(handed(effect, "cut_hz"), 0.0) - self.assertIsNone(effect.tail_samples) + self.assertEqual(effect.tail_samples, 2 * out) effect.set_macro(CUT_I, 0) self.assertEqual(handed(effect, "cut_hz"), 0.0) - self.assertIsNotNone(effect.tail_samples) + self.assertEqual(effect.tail_samples, out) # Planted: the retired cures (Tone tracking at 32 x the rate, - # Cut held in at the 20 Hz corner with no bound). + # Cut held in at the 20 Hz corner). for cls, name, want in ( (TrackingPingPong, "damping_hz", 32.0 * rate), (HeldCutPingPong, "cut_hz", @@ -1146,7 +1162,7 @@ def test_tail_samples_follows_time_feedback_and_tone(self): 685 * 48001) for patch in (3, 4, 6): self.assertEqual(spied(patch=patch).tail_samples, 190064) - self.assertIsNone(spied(cut_hz=40.0).tail_samples) + self.assertEqual(spied(cut_hz=40.0).tail_samples, 2 * 14 * 13441) # Cut in, then out: since audiodsp v0.6.3rc1 the out stop is out # (the node holds an out high-pass at zero, #159), so the bound is # the one Cut never in has. Planted: the retired held 20 Hz corner. @@ -1157,7 +1173,7 @@ def test_tail_samples_follows_time_feedback_and_tone(self): held = HeldCutPingPong(silence_src(64), sample_rate=RATE, cut_hz=40.0) held.set_macro(CUT_I, 0) - self.assertIsNone(held.tail_samples) + self.assertEqual(held.tail_samples, 2 * 14 * 13441) def test_the_feedback_is_handed_as_set(self): # Up to audiodsp v0.6.2 the class stepped the Feedback clear of the @@ -1822,8 +1838,8 @@ def test_the_tail_after_cut_in_then_out_is_inside_the_bound(self): # Since audiodsp v0.6.3rc1 the bound is finite once Cut is out, # whatever came before. Full-scale noise with Cut in (400 Hz and # MIDI 1), Cut out as it stops: the tail ends inside the bound read - # after the move. Planted: the retired held Cut (no bound) and the - # bound one lap short. + # after the move. Planted: the retired held Cut (the doubled count + # Cut in keeps) and the bound one lap short. class OneLapShort(PingPongDelay): NAME = 'PingPongDelay' @@ -1865,7 +1881,8 @@ def move(frame): self.assertLessEqual(tail, declared, (cut_midi, feedback, spread)) self.assertGreater(tail, declared // 2) - self.assertIsNone(cell(HeldCutPingPong, 127, 0.45, 1.0)[0]) + self.assertEqual(cell(HeldCutPingPong, 127, 0.45, 1.0)[0], + 2 * cell(PingPongDelay, 127, 0.45, 1.0)[0]) declared, tail = cell(OneLapShort, 127, 0.45, 1.0) self.assertGreater(tail, declared) @@ -1887,6 +1904,44 @@ def test_the_tail_reaches_exact_zero_inside_tail_samples(self): self.assertLessEqual(last, bound, options) self.assertGreater(last, bound // 2, options) + def test_repeat_cut_in_reaches_zero_inside_the_bound(self): + # With Repeat Cut in the class counts twice the laps + # (`CUT_LAP_FACTOR`), a margin measured, not derived. The worst + # cells: Cut just above its out stop, four laps of full-scale + # noise, Feedback 0.5 (where the high-pass adds most against the + # count) and 0.99, Spread at both ends, both rates. Planted: the + # count without the factor, which the noise outlives. + def cell(cls, rate, feedback, spread, seed): + options = dict(time_ms=20.0, feedback=feedback, spread=spread, + cut_hz=20.3, mix=2.0) + probe = PingPongDelay(silence_src(64, rate=rate), + sample_rate=rate, **options) + bound = PingPongDelay._tail_bound(probe) + probe.deinit() + fill = 4 * law_frames(20.0, rate) + x = np.zeros(fill + bound + fill) + x[:fill] = np.random.default_rng(seed).integers(-32767, 32768, + fill) + effect = cls(to_source(x, 2, rate)[0], sample_rate=rate, + **options) + declared = effect.tail_samples + y = pull(effect, len(x), 2) + effect.deinit() + nonzero = np.flatnonzero(y[fill:].any(axis=1)) + return declared, int(nonzero[-1]) + 1 if len(nonzero) else 0 + + for rate in (RATE, 22050): + for feedback in (0.5, 0.99): + for spread in (0.0, 1.0): + for seed in range(2 if feedback < 0.9 else 1): + declared, tail = cell(PingPongDelay, rate, feedback, + spread, seed) + self.assertLessEqual( + tail, declared, (rate, feedback, spread, seed)) + declared, tail = cell(UndoubledCutPingPong, RATE, PP_PLANT[0], + PP_PLANT[1], PP_PLANT[2]) + self.assertGreater(tail, declared, PP_PLANT) + def test_a_full_scale_fill_at_every_spread(self): T = law_frames(20.0, RATE) fill = 4 * T @@ -2541,10 +2596,11 @@ def test_clean_at_the_stated_input_level(self): "Tier1Fast.test_the_stall_cell_reaches_zero_at_the_feedback_set", "Tier1Fast.test_the_tail_after_cut_in_then_out_is_inside_the_bound", "Tier1Fast.test_a_falling_walk_keeps_the_old_time_in_the_tail")), - ("With Repeat Cut in circuit `tail_samples` is `None`: the class gives " - "no bound there.", + ("With Repeat Cut in circuit the bound is twice as many repeats as with " + "it out, a margin measured rather than derived.", ("TheSurface.test_tail_samples_follows_time_feedback_and_tone", - "TheSurface.test_the_filter_stops")), + "TheSurface.test_the_filter_stops", + "Tier1Fast.test_repeat_cut_in_reaches_zero_inside_the_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.", ("TheSurface.test_a_lowered_ceiling_clamps_visibly",)), @@ -2608,5 +2664,22 @@ def test_every_claim_is_in_the_docstring_and_tested(self): "latency.", ("Tier1Fast.test_nothing_here",)),))) +#: Not here, because they show only when a knob moves, on a reset or a +#: rejoin, or not at all on a fresh build: `NoSlewPingPong`, +#: `FrozenFilterPingPong`, `SteppedPingPong`, `TrackingPingPong`, +#: `UndoubledCutPingPong`, `HeldCutPingPong`; patch 0 renders it: +#: `MonoStereoSettingsPingPong`. +KIT_PLANTS = { + "LossyCrossPingPong": LossyCrossPingPong, + "LateReadPingPong": LateReadPingPong, + "ScaledFeedbackPingPong": ScaledFeedbackPingPong, + "PanlessPingPong": PanlessPingPong, + "DryGainPingPong": DryGainPingPong, + "MonoInPingPong": MonoInPingPong, + "SwapInPingPong": SwapInPingPong, + "HeldFloorPingPong": HeldFloorPingPong, +} + + if __name__ == "__main__": unittest.main()