From bc51b6b5df79bc7f90a4f0af128974523352ecf5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:36:36 -0500 Subject: [PATCH 01/88] The audiodsp floor moves to v0.6.1, on Brad's instruction At 4e58091 the unix MicroPython no longer links under the current micropython-pydevices overlay, so not one desktop gate could run at the pin this file named. Brad: "Move the pin and document that I told you to." The target is the v0.6.1 release (094dde5, tagged 2026-09-25, on TestPyPI, the MIP index and production PyPI); the only commit past it is #151, workflow only. What the move changes in the suite: audiodsp#129 made a mixer voice at level 1.0 a wire, so the four audiodsp#95 plants (Mix 0 through the dry voice at unity, in Compressor, DeEsser, DynamicEQ and MultibandCompressor) cannot fire on this floor and become controls that must pass. Compressor and MultibandCompressor gain the one-LSB dry-path plant the other two already had, so every WIRE row still has a fault it is shown catching. Two docstrings that said the pin was older than #137 now say when that stopped being true. Gates at the new pin, on the cloud desktop: unittest 1548 tests, OK (skipped=2); validate_api 55 instruments and 45 effects; validate_metadata valid; flake8 clean; the smoke 45 classes, 249 patches, 0 failures on CPython, MicroPython and CircuitPython; verify_dsp 49 comparisons, 0 failures, on all three. Pending both boards: the Phase 5 board test plan carries the proof as its first row. --- AUDIODSP_PIN | 45 +++++++++++++++++++++++- tests/test_cpython_effects_compressor.py | 29 +++++++++++++-- tests/test_cpython_effects_deesser.py | 28 ++++++++------- tests/test_cpython_effects_dynamiceq.py | 29 ++++++++------- tests/test_cpython_effects_multiband.py | 35 ++++++++++++++---- tests/test_scheduling_seam.py | 6 ++-- tests/test_sequencer.py | 5 +-- 7 files changed, 135 insertions(+), 42 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 85da741..136e2de 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -122,5 +122,48 @@ # `OK (skipped=2)`, validate_api 55 instruments and 45 effects, the smoke # green -- the same counts as at 02b1841. # +# 2026-09-27: 4e58091 -> v0.6.1 (094dde5), on Brad's instruction. The +# effects program's Phase 5 (Time) was un-parked in a cloud session, where +# the desktop interpreters are built from the audiodsp checkout beside the +# anchor -- and at 4e58091 the unix MicroPython no longer links under +# micropython-pydevices' current overlay: every usermod entry in the module +# table comes up undefined (the manifest names the modules the way audiodsp +# main has named them since #144), so not one desktop gate could run at the +# floor this file named. Told that, Brad: "Move the pin and document that I +# told you to." And, the same hour: v0.6.1 was tagged on 2026-09-25 and is +# on TestPyPI, on the MIP index and on production PyPI (uploaded by hand +# after the Trusted Publisher step failed that day); the only commit past it +# is #151, which moves the PyPI upload into audiodsp's own workflow, and no +# source file differs -- so the desktop pair built from main (dd84add) is +# the release's DSP byte for byte. What moved beneath the components between +# 4e58091 and v0.6.1, by `git log 4e58091..v0.6.1 -- src`: a mixer voice at +# level 1.0 is a wire (audiodsp#129); the shaper's oversample ceiling is 16 +# and a build-time knob (#131); a deinit() holds the pump lock over its whole +# body (#132); the pump's 64-bit status words are read under the 32-bit- +# atomics rule (#133); a file-backed source is refused at handover and the +# funnel refuses a loop entered at any node (#134, #135); a Tap read with +# nothing new says so (#136); a refused press is counted, so the instrument +# layer can see it (#137); a strike on a modal bank can carry a frame (#138); +# audiomodal's inner loop runs over the modes that can sound (#139); the +# pump's per-block path can be placed in IRAM (#142, #143); and ulab is named +# by the manifest once for every port (#144). Re-run here at the new pin, on +# the cloud desktop (Linux x86-64, Python 3.12, MicroPython v1.29.0 + +# overlay, CircuitPython 10.3.0 + the apply scripts): the unit suite `Ran +# 1548 tests ... OK (skipped=2)`, two more than the 1546 the same tree +# counted before this move's test edits, because #129 is the fix the four +# audiodsp#95 plants were waiting for (Mix 0 through +# the dry voice at unity, in Compressor, DeEsser, DynamicEQ and +# MultibandCompressor: it cannot fire on this floor, so each is a control +# now, and Compressor and MultibandCompressor gained the one-LSB dry-path +# plant the other two already had, so every WIRE row still has a fault it +# is shown catching); validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 249 patches, +# 0 failures on CPython, MicroPython and CircuitPython; verify_dsp 49 +# comparisons, 0 failures, on all three. Pending both boards: +# this move has not been proven on the P4 or the S3 -- there is no board in +# the cloud -- and the Phase 5 board test plan carries it as its first row. +# The pyproject floors stay at >=0.5.1; raising them is a release-time +# change and Phase 7's. +# # -- ref is what CI installs (git+https://...@) -4e5809195af58dd17f1705edcb9981e6fec3a312 4e5809195af58dd17f1705edcb9981e6fec3a312 +v0.6.1 094dde5203dadcbf30fe5f2b6a88c16e413799b8 diff --git a/tests/test_cpython_effects_compressor.py b/tests/test_cpython_effects_compressor.py index 8bbe0fa..0e58216 100644 --- a/tests/test_cpython_effects_compressor.py +++ b/tests/test_cpython_effects_compressor.py @@ -74,15 +74,22 @@ class MixZeroIsAWireTest(unittest.TestCase): 16384 on a full-scale ramp, all in the right channel, because a stereo voice at pan 0 gets 32767 on the left and 32768 on the right. Nothing peaking below -6.02 dBFS reaches the mechanism. + + audiodsp v0.6.1 (#129) made the voice at 1.0 exact, and the pin moved + there on 2026-09-27 (`AUDIODSP_PIN`). So the unity plant is a control + now, and the fault the row is shown catching is the kit spec's own: + the dry voice a hair under unity, 32767/32768. """ - def _rendered(self, cls=None, channels=2, frames=8192): + def _rendered(self, cls=None, channels=2, frames=8192, dry_level=None): values = probes.ramp_fs(frames, channels) source = audiocore.RawSample(values, sample_rate=RATE, channel_count=channels) effect = (cls or ev.Compressor).create(source, RATE, mix=0.0) try: self.assertEqual(effect.latency_samples, 0) + if dry_level is not None: + effect._mixer.voice[1].level = dry_level wet = probes.render(effect.output, frames, rate=RATE, channels=channels, block=256) finally: @@ -97,10 +104,28 @@ def test_mix_zero_is_a_wire_at_both_channel_counts(self): self.assertTrue(result["passed"], result["red"]) self.assertEqual(result["values"]["differing_samples"], 0) - def test_the_dry_voice_at_unity_is_the_fault_the_wire_catches(self): + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + The wiring this class shipped with before #95 renders byte-exact on + the floor the pin names. If this ever goes red, the floor has moved + back under the suite. + """ result = self._rendered(ThroughTheDryVoice) + self.assertTrue(result["passed"], result["red"]) + self.assertEqual(result["values"]["differing_samples"], 0) + + def test_the_wire_goes_red_on_a_one_lsb_dry_path(self): + """The fault the WIRE row is shown catching: the dry voice at + 32767/32768, inaudible, and the byte compare must still refuse it. + It has to put the mixer back in the path to be planted at all, + because Mix 0 no longer runs through one. + """ + result = self._rendered(ThroughTheDryVoice, + dry_level=32767.0 / 32768.0) self.assertFalse(result["passed"]) self.assertEqual(result["values"]["max_abs_difference_lsb"], 1) + self.assertGreater(result["values"]["differing_samples"], 0) def test_the_head_of_the_render_is_not_a_hole(self): """The second defect the ramp found: this class opened no level diff --git a/tests/test_cpython_effects_deesser.py b/tests/test_cpython_effects_deesser.py index 917d224..d91f569 100644 --- a/tests/test_cpython_effects_deesser.py +++ b/tests/test_cpython_effects_deesser.py @@ -1209,16 +1209,18 @@ def test_wire_goes_red_on_a_one_lsb_dry_path(self): effect.deinit() effect2.deinit() - def test_wire_goes_red_on_a_dry_voice_at_unity(self): - """audiodsp#95's fault, and the probe the row above cannot use. - - A mixer voice at level 1.0 is not unity - upstream's Q15 level is - `1.0 * 32768` and the kernel divides by 32767 - so the dry voice at - unity came out one LSB high at every sample from 32736 up. The - committed `ramp_fs` probe never puts a sample strictly inside that - window, so the fault was invisible to it and to this whole file - (see `fine_ramp_fs`). On a monotone full-scale ramp it is 7 of - 16384, all in the right channel, and the class is exact. + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + A mixer voice at level 1.0 was not unity - upstream's Q15 level was + `1.0 * 32768` and the kernel divided by 32767 - so the dry voice at + unity came out one LSB high at every sample from 32736 up, 7 of + 16384 on `fine_ramp_fs`, all in the right channel. audiodsp v0.6.1 + (#129) made the voice at 1.0 exact and the pin moved there on + 2026-09-27 (`AUDIODSP_PIN`). The row above keeps its fault, the + one-LSB voice; this is the floor's own control, on the probe the + old defect was visible to. If it goes red the floor has moved back + under the suite. """ rate = 48000 values = fine_ramp_fs(8192) @@ -1233,9 +1235,9 @@ def test_wire_goes_red_on_a_dry_voice_at_unity(self): cls=ThroughTheDryVoice, range_db=0.0) result = M.wire(render(effect2.output, 8192, rate), dry) effect2.deinit() - print("\n WIRE unity fault: %s" % result["values"]) - self.assertNotEqual(result["red"], []) - self.assertEqual(result["values"]["max_abs_difference_lsb"], 1) + print("\n WIRE unity control: %s" % result["values"]) + self.assertEqual(result["red"], [], result["values"]) + self.assertEqual(result["values"]["differing_samples"], 0) def test_tail_goes_red_on_a_held_dc_state(self): rate = 48000 diff --git a/tests/test_cpython_effects_dynamiceq.py b/tests/test_cpython_effects_dynamiceq.py index e9e3105..1701ca4 100644 --- a/tests/test_cpython_effects_dynamiceq.py +++ b/tests/test_cpython_effects_dynamiceq.py @@ -800,28 +800,27 @@ def test_wire_goes_red_on_a_one_lsb_dry_path(self): print("\n WIRE fault: first differing sample %r" % (first,)) self.assertIsNotNone(first) - def test_wire_goes_red_on_a_dry_voice_at_unity(self): - """audiodsp#95's fault, which is the wiring this class shipped with. - - A mixer voice at level 1.0 is not unity - upstream's Q15 level is - `1.0 * 32768` and the kernel divides by 32767 - so the dry tap at - unity came out one LSB high at every sample from 32736 up. Three of - 16384 on a full-scale ramp, all in the right channel, because a - stereo voice at pan 0 gets 32767 on the left and 32768 on the right. - Nothing below -6.02 dBFS can reach it, which is why the row is a - ramp and this fault was invisible to a -4.4 dBFS tone for a year. + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + A mixer voice at level 1.0 was not unity - upstream's Q15 level was + `1.0 * 32768` and the kernel divided by 32767 - so the wiring this + class shipped with came out one LSB high at every sample from + 32736 up: three of 16384 on a full-scale ramp, all in the right + channel. audiodsp v0.6.1 (#129) made the voice at 1.0 exact and the + pin moved there on 2026-09-27 (`AUDIODSP_PIN`). The row above still + has its fault, the one-LSB voice; this is the floor's own control, + on the ramp because nothing below -6.02 dBFS could reach the old + defect. If it goes red the floor has moved back under the suite. """ data = ramp_fs(8192) effect, _ = build(data, ThroughTheDryVoice, mix=0.0) out = render(effect.output, 8192) differ = [index for index in range(min(len(out), len(data))) if out[index] != data[index]] - print("\n WIRE unity fault: %d differing, first %r" + print("\n WIRE unity control: %d differing, first %r" % (len(differ), differ[0] if differ else None)) - self.assertTrue(differ) - self.assertTrue(all(abs(out[index] - data[index]) == 1 - for index in differ)) - self.assertTrue(all(abs(data[index]) >= 32736 for index in differ)) + self.assertEqual(differ, []) def test_tail_goes_red_on_a_held_dc_state(self): """TAIL's fault: the audiodsp#23 shape, a state that never arrives. diff --git a/tests/test_cpython_effects_multiband.py b/tests/test_cpython_effects_multiband.py index 1837358..50ca414 100644 --- a/tests/test_cpython_effects_multiband.py +++ b/tests/test_cpython_effects_multiband.py @@ -442,17 +442,38 @@ def test_mix_zero_is_a_wire_at_every_rate_and_channel_count(self): self.assertEqual( result["values"]["differing_samples"], 0) - def test_the_dry_voice_at_unity_is_the_fault_the_wire_catches(self): - """The planted fault, and it is the wiring this class used to have: - Mix 0 routed through the mixer's dry voice at level 1.0. - - Without this the row above is a measurement nobody has shown able to - fail - and the difference is one LSB on 15 samples of 32768, which - no summarising statistic would have found either. + def test_the_dry_voice_at_unity_is_a_wire_since_audiodsp_v0_6_1(self): + """audiodsp#95's plant, kept as a control now that it cannot fire. + + The wiring this class used to have - Mix 0 through the mixer's dry + voice at level 1.0 - came out one LSB high on 15 samples of 32768 + until audiodsp v0.6.1 (#129) made the voice at 1.0 exact. The pin + moved there on 2026-09-27 (`AUDIODSP_PIN`); if this goes red the + floor has moved back under the suite. + """ + values = probes.ramp_fs(16384) + effect = build(ThroughTheDryVoice, probe=values, mix=0.0) + try: + wet = render(effect, 16384) + finally: + effect.deinit() + dry = kit.Render(bytes(memoryview(values).cast("B")), RATE, 2) + result = kit.wire(wet, dry, latency_samples=0) + self.assertTrue(result["passed"], result["red"]) + self.assertEqual(result["values"]["differing_samples"], 0) + + def test_the_wire_goes_red_on_a_one_lsb_dry_path(self): + """The fault the WIRE row is shown catching: the dry voice at + 32767/32768, the kit spec's own plant. Without this the row above + is a measurement nobody has shown able to fail - and the + difference is one LSB, which no summarising statistic would find. + It has to put the mixer back in the path to be planted at all, + because Mix 0 no longer runs through one. """ values = probes.ramp_fs(16384) effect = build(ThroughTheDryVoice, probe=values, mix=0.0) try: + effect._mixer.voice[0].level = 32767.0 / 32768.0 wet = render(effect, 16384) finally: effect.deinit() diff --git a/tests/test_scheduling_seam.py b/tests/test_scheduling_seam.py index 4e1a158..a96d67f 100644 --- a/tests/test_scheduling_seam.py +++ b/tests/test_scheduling_seam.py @@ -326,8 +326,10 @@ class _Counting: """A synthesizer stand-in that can, or cannot, count refused presses. `synthio.Synthesizer.refused` arrived in audiodsp#137 and this - repository's `AUDIODSP_PIN` is older, so the engine CI runs against - cannot answer. Testing the reading against whatever engine happens to be + repository's `AUDIODSP_PIN` was older when this was written, so the + engine CI ran against could not answer (the pin passed #137 on + 2026-09-27; the reasoning holds either side of it). Testing the + reading against whatever engine happens to be installed would mean this file proves one thing today and the other thing after the pin moves, and says nothing either way -- so the subject is a stand-in with the property and one without it. diff --git a/tests/test_sequencer.py b/tests/test_sequencer.py index 01e51aa..46593bf 100644 --- a/tests/test_sequencer.py +++ b/tests/test_sequencer.py @@ -275,8 +275,9 @@ def test_start_again_picks_the_groove_up_where_it_was(self): class _Track: """A real instrument with the refusal count answered by hand. - `health()` has to carry a number the engine CI runs against cannot - produce -- `synthio.Synthesizer.refused` is newer than `AUDIODSP_PIN` -- + `health()` has to carry a number the engine CI ran against could not + produce when this was written -- `synthio.Synthesizer.refused` was newer + than `AUDIODSP_PIN` until the pin passed audiodsp#137 on 2026-09-27 -- so what is proved here is the plumbing and the separation, and the numbers themselves are measured on the real pump. From 51b4904fe945cb56d637d500c41faef78a02a3fd Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:18:03 -0500 Subject: [PATCH 02/88] DigitalDelay rebuilt against its frozen dossier (Phase 5 Station B, parked) One audioecho.FeedbackDelay with delay_slew from the Glide law, every static Time landed on a whole frame, both loop filters pre-warped to the corner they achieve with their out stops at exactly 0, Sync reading the transport, and a tail_samples property. Tests carry T1-T5 with a same-kind planted fault each, reachability over 142 positions and the null build red on every row. --- lib/audioeffects/rebuilt/digitaldelay.py | 380 ++++++++++ tests/test_cpython_effects_digitaldelay.py | 761 +++++++++++++++++++++ 2 files changed, 1141 insertions(+) create mode 100644 lib/audioeffects/rebuilt/digitaldelay.py create mode 100644 tests/test_cpython_effects_digitaldelay.py diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py new file mode 100644 index 0000000..07ddfce --- /dev/null +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -0,0 +1,380 @@ +"""`DigitalDelay` - a clean interpolated line with the Boss DD-2's control law. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/DigitalDelay.md`, whose trait +table was frozen at Station A before this file existed (anchor commit +51207b8, 2026-09-27). The old class in `delay.py` is consulted only for the +seven defects that dossier's section 7 names; it stays the class the +library serves until the auditor adopts this one. + +**What it sounds like.** Your dry signal passes untouched, and one clean +repeat follows it, fed back for more. Time (12.5-800 ms) is the DD-2's +D.TIME and its three MODE ranges folded into one knob. Feedback (0-0.99) +is F.BACK, Mix (0-2) is E.LEVEL: dry at unity up to 1, wet alone at 2, and +Mix 0 is a wire while the line keeps recording. Turn Time while it plays +and the repeats bend in pitch and settle, the way the pedal's single master +clock resamples its memory, instead of clicking. Glide sets how fast that +happens: the time a full-range Time move takes, 800 ms to 8 s. Repeat Tone +and Repeat Cut put the pedal's 7 kHz and 40 Hz corners into the loop as +knobs, so each repeat gets a little darker or thinner than the last. Sync +locks Time to Division of the host's beat. + +**The standout:** the Boss DD-2 Digital Delay (1983), light touch. You get +its control law and its dry/wet discipline as defaults, and its converter +colour as two knobs that default off. Patch 5 is the pedal's own corners. + +**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The stock +`audiodelays.Echo` limits its only output at +-28000, so its Mix 0 is not +a wire, and its one continuous-time mode lands 350 ms 33 samples late. On +a stock CircuitPython board this module imports cleanly and construction +raises `ImportError`. + +**Latency: zero samples, at every setting and every rate.** Nothing looks +ahead. The delay is the wet path, not latency on the dry path, and no +option adds any. + +**Mono.** A one-channel source gets the identical effect on its one +channel. The class never passes `input_pan`, which in mono would overwrite +the node's mono feed and halve the repeats. + +**RAM.** The line is `max_time_ms + 1` ms of two int16 lanes whatever the +channel count: 153 792 B at 48 kHz for the default 800 ms (141 296 B at +44.1 kHz, 70 648 B at 22.05 kHz), plus about 1.2 KB of node. Pass a lower +`max_time_ms` to spend less (300 ms costs 57 792 B); Time then stops at +that ceiling and `get_macro(0)` shows where it stopped. + +**Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on; no mixer. +Palette row FeedbackDelay +options (the nearest not-cheaper row; there is +no row for the slew alone), glue 0: **P4 <= 9 %, S3 <= 15 %** of a +5.333 ms stereo block. The board measurement is pending hardware. + +**What the default surrenders.** It is a clean line, so it does not darken +on its own: the DD-2's 7 kHz band limit and its compander are not in the +default sound (patch 5 and the Tone and Cut knobs put the corners back; the +compander is not modelled at all). Freeze (the pedal's HOLD) is not here: +the node's loop tops out at 0.99, so a held phrase would fade 0.087 dB a +lap, and a HOLD that fades is worse than none. **Glide 0 is an instant +knob, and its price is a click**: a 200 -> 150 ms jump steps 7712 LSB into +a tone whose own steepest step is 1565. At the default Glide (4 s for the +full range) a falling Time bends the repeats 311 cents up and a rising one +380 cents down while it moves, and a 200 -> 150 ms move takes 254 ms. At the +knob's fastest glide (grid 1, 814.6 ms) a falling move reads +1171 cents and +a rising one nearly stalls the read head. + +While Time moves, the line is read between samples, and the two-tap read +costs the top of the band sqrt(1 - 2 frac (1 - frac)(1 - cos 2 pi f / fs)) +per pass: 5.1 dB at 15 kHz at a half frame, 48 kHz. Every static Time is +landed on the nearest whole frame at the running rate, where the read is +lossless, so the repeats of a Time you have stopped turning do not darken. +Above Mix 1 the dry falls as 2 - Mix, by `audiodelays.Echo`'s convention. + +`capabilities = ("tempo_sync",)`: with Sync on, the class reads +`self._transport()` on every macro move and program change (not per block). +With no host transport, Time stays where the knob is. +""" + +VENDOR = "PyDevices" + +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The Time map, fixed on every instance (dossier T4): 12.5-800 ms, log. +TIME_MIN_MS = 12.5 +TIME_MAX_MS = 800.0 + +#: Glide is the time a full-range Time move takes; the node's `delay_slew` +#: (delay-seconds per second) is FULL_RANGE_MS / glide_ms (dossier section 6). +FULL_RANGE_MS = TIME_MAX_MS - TIME_MIN_MS + +#: At slew 1 a rising Time stands the read head still, and past it the line +#: plays backwards. A constructor Glide under 795.45 ms is pinned here; no +#: grid position gets there (grid 1 is slew 0.967). +SLEW_PIN = 0.99 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: The line's headroom over `max_time_ms`: the node clamps a delay at +#: `line_frames - 2`, so a line of exactly `max_time_ms` could not reach it. +LINE_HEADROOM_MS = 1.0 + +#: 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. +DIVISION_BEATS = (0.125, 1.0 / 6.0, 0.1875, 0.25, 1.0 / 3.0, 0.375, 0.5, + 2.0 / 3.0, 0.75, 1.0, 4.0 / 3.0, 1.5, 2.0, 8.0 / 3.0, 3.0, + 4.0) + +TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \ + range(8) + + +def nominal_cut_hz(corner_hz, sample_rate): + """`cut_hz` whose one-pole high-pass -3 dB is `corner_hz` at + `sample_rate`. 0 stays 0, which is the filter out of circuit. + + The node's high-pass is `y -= lp(y)` with the low-pass coefficient + a = 1 - exp(-2 pi hz / fs) (`audiodsp_feedback_delay.c:33-40`, + `:462-466`), so with b = 1 - a it is H = b (1 - z^-1) / (1 - b z^-1). + |H|^2 = 1/2 gives b^2 (3 - 4 cos w) + 2 b cos w - 1 = 0; the root in + (0, 1) is the pole, and `cut_hz = -fs ln b / 2 pi`. + """ + fs = float(sample_rate) + fc = float(corner_hz) + if fc <= 0.0: + return 0.0 + c = math.cos(2.0 * math.pi * fc / fs) + qa = 3.0 - 4.0 * c + qb = 2.0 * c + if abs(qa) < 1e-12: + roots = (1.0 / qb,) if qb != 0.0 else () + else: + disc = qb * qb + 4.0 * qa + if disc < 0.0: + disc = 0.0 + root = math.sqrt(disc) + roots = ((-qb + root) / (2.0 * qa), (-qb - root) / (2.0 * qa)) + pole = None + for candidate in roots: + if 0.0 < candidate < 1.0: + pole = candidate + if pole is None: + return fc + return -fs * math.log(pole) / (2.0 * math.pi) + + +def slew_of(glide_ms): + """The node's `delay_slew` for a Glide: 0 (the jump) at Glide 0, + otherwise 787.5 ms over `glide_ms`, pinned at 0.99.""" + glide_ms = float(glide_ms) + if glide_ms <= 0.0: + return 0.0 + slew = FULL_RANGE_MS / glide_ms + if slew > SLEW_PIN: + slew = SLEW_PIN + return slew + + +def whole_frames(time_ms, sample_rate): + """The nearest whole frame at the running rate (dossier section 6).""" + return int(math.floor(float(time_ms) * sample_rate / 1000.0 + 0.5)) + + +class DigitalDelay(_component.Component): + """A clean digital delay with the DD-2's control law: the dry path is a + wire, and turning Time pitch-bends the repeats instead of clicking. + audiodsp tier; zero latency. + + **What the default surrenders:** no band limit and no compander in the + default sound (patch 5 and the Tone and Cut knobs are the corners), no + HOLD, and Glide 0's instant knob clicks. At the default Glide a falling + Time bends the repeats +311 cents while it moves, a rising one -380. + """ + + NAME = 'DigitalDelay' + DISPLAY_NAME = 'Digital Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Glide", "Sync", "Division", + "Repeat Tone", "Repeat Cut") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "TOGGLE", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (800.0, 8000.0, "log"), # 3 Glide, ms; grid 0 = jump + (0.0, 1.0), # 4 Sync + (0.0, 15.0), # 5 Division index + (800.0, 16000.0, "log"), # 6 Repeat Tone, Hz; top = out + (20.0, 400.0, "log"), # 7 Repeat Cut, Hz; bottom = out + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Clean Repeats", (102, 45, 19, 89, 0, 51, 127, 0)), + 1: ("Eighth Notes", (102, 51, 19, 89, 127, 51, 127, 0)), + 2: ("Dotted Eighths", (102, 58, 19, 89, 127, 68, 127, 0)), + 3: ("Short Doubling", (45, 0, 32, 89, 0, 51, 127, 0)), + 4: ("Long Ambient", (125, 90, 16, 89, 0, 51, 127, 0)), + 5: ("Band Limited Repeats", (102, 77, 19, 89, 0, 51, 92, 29)), + } + + def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, + sync=False, division=6, tone_hz=16000.0, cut_hz=20.0, + max_time_ms=TIME_MAX_MS, patch=None): + max_time_ms = float(max_time_ms) + if max_time_ms > TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + self._frames = 1 + self._feedback = 0.0 + self._node_ms = 0.0 + #: A constructor Glide stays on the audio path until macro 3 moves: + #: the Glide span starts at 800 ms, so a faster constructor Glide + #: (down to the 0.99 pin) or an exact 0 has no knob position. + self._glide_exact = float(glide_ms) + self._seeding = True + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=self._clamp_ms(time_ms), + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + delay_slew=0.0) + # `clear()` empties the line and the loop filters and re-primes the + # read head, so a reset is silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._delay.clear) + self._delay.play(self._source) + self._output = self._delay + # The knob is seeded at its bottom (800 ms) for a faster or zero + # constructor Glide; `_glide_exact` carries the real value. + self._init_macros((time_ms, feedback, mix, max(800.0, glide_ms), + 1.0 if sync else 0.0, float(division), tone_hz, + cut_hz)) + self._seeding = False + if patch is not None: + self.program_change(patch) + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _time_map(self, position): + """Macro 0's position -> milliseconds; the same on every instance.""" + return _component.macro_value(self._MACRO_RANGES[TIME_I], position) + + def _clamp_ms(self, time_ms): + time_ms = float(time_ms) + if time_ms > self._max_time_ms: + return self._max_time_ms + if time_ms < TIME_MIN_MS: + return TIME_MIN_MS + return time_ms + + def _glide_ms(self): + if self._glide_exact is not None: + return self._glide_exact + if self._macros[GLIDE_I] <= 0.0: + return 0.0 + return self._value(GLIDE_I) + + def _tone_damping(self, position): + """Macro 6's position -> the node's `damping_hz`. The top stop is + exactly 0 (out of circuit) and is never pre-warped.""" + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _cut_hz(self, position): + """Macro 7's position -> the node's `cut_hz`. The bottom stop is + exactly 0 (out of circuit).""" + if position <= 0.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[CUT_I], position) + return nominal_cut_hz(self._hz(corner), self._sample_rate) + + def _transport_state(self): + transport = self._transport + state = transport() if callable(transport) else transport + return transport, state + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + bpm = float(state[2]) if state[2] else 120.0 + if bpm <= 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + def _node_time_ms(self, frames): + """What the node is handed for a whole-frame Time.""" + return frames * 1000.0 / self._sample_rate + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del position + if index == GLIDE_I and not self._seeding: + self._glide_exact = None + self._refresh() + + def _refresh(self): + span = self._MACRO_RANGES[TIME_I] + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + # Division quantises Time into the same map, and the clamp + # shows through get_macro(0) the same way (dossier T4). + self._macros[TIME_I] = _component.macro_position( + span, self._clamp_ms(synced)) + time_ms = self._time_map(self._macros[TIME_I]) + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + self._macros[TIME_I] = _component.macro_position(span, clamped) + self._frames = max(1, whole_frames(clamped, self._sample_rate)) + self._node_ms = self._node_time_ms(self._frames) + feedback = self._value(FEEDBACK_I) + if feedback > FEEDBACK_MAX: + feedback = FEEDBACK_MAX + if feedback < 0.0: + feedback = 0.0 + self._feedback = feedback + self._delay.set( + delay_slew=slew_of(self._glide_ms()), + delay_ms=self._node_ms, + feedback=feedback, + mix=self._value(MIX_I), + damping_hz=self._tone_damping(self._macros[TONE_I]), + cut_hz=self._cut_hz(self._macros[CUT_I])) + + @property + def tail_samples(self): + """The -60 dB lap count plus one lap, from the Time in whole frames + and the Feedback: `ceil(T (1 + 3 / -log10 f))`, and `T` at f = 0. A + loop filter only shortens the tail, so this is an upper bound.""" + self._check_live() + frames = self._frames + feedback = self._feedback + if feedback <= 0.0: + return int(frames) + laps = 1.0 + 3.0 * math.log(10.0) / -math.log(feedback) + return int(math.ceil(frames * laps)) diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py new file mode 100644 index 0000000..d23bb8a --- /dev/null +++ b/tests/test_cpython_effects_digitaldelay.py @@ -0,0 +1,761 @@ +"""`DigitalDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/DigitalDelay.md` +(frozen at anchor 51207b8); its Tier 2 rows are T1-T5. Each row here is the +measurement at a few of the row's cells and the same measurement shown red +on a planted fault of the same kind, at the constructor defaults. Every +fault is shown unreachable from every macro position and shipped patch, and +every row's measurement is shown red on the class built as a wire. The full +spans, the three interpreters and the rates live in the evidence pack, not +in this file. + +The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by +`rebuilt.module_class("DigitalDelay")`. +""" + +import math +import os +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import digitaldelay as dd # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +BLOCK = 256 +TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \ + range(8) + +DigitalDelay = rebuilt.module_class("DigitalDelay") + + +def midi_of_ms(time_ms): + """Time's MIDI position for `time_ms`, unquantised.""" + return 127.0 * math.log(time_ms / 12.5) / math.log(64.0) + + +def mapped_frames(position, rate): + """The dossier's T4 law, written out independently of the class: + 12.5 * 64^position ms, landed on the nearest whole frame.""" + time_ms = 12.5 * 64.0 ** position + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +# -------------------------------------------------------------------------- +# Planted faults, one per Tier 2 row, each of the row's own kind + + +class DryScaledDelay(DigitalDelay): + """T1: the dry path coloured - the output scaled by 32767/32768, one + LSB above -6 dBFS (`kit_faults.OneLsbScale`, WIRE's own fault).""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + DigitalDelay._build(self, *arguments, **options) + self._plant_dry_scale = True + self._output = kit_faults.OneLsbScale(self._delay) + + +class _Stepper: + """Pulls the owner's node, stepping its delay from Python first: the + finest a Python-driven Time can move is once per block.""" + + def __init__(self, owner): + self._owner = owner + node = owner._delay + self.sample_rate = node.sample_rate + self.channel_count = node.channel_count + self.bits_per_sample = 16 + self.samples_signed = True + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + audiocore.reset_buffer(self._owner._delay) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self._owner._step() + return audiocore.get_buffer(self._owner._delay, + single_channel_output, audio_channel) + + +class StaircaseDelay(DigitalDelay): + """T2: route (b), A8.8 - `delay_ms` stepped from Python once per + 256-frame block with the node's slew off, at the rate the Glide asks + for. No macro position reaches it: Glide grid 0 is one jump, and every + other position is the node's own per-frame walk.""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + self._current_ms = None + DigitalDelay._build(self, *arguments, **options) + self._output = _Stepper(self) + + def _refresh(self): + DigitalDelay._refresh(self) + if self._current_ms is None: + self._current_ms = self._node_ms + slew = dd.slew_of(self._glide_ms()) + self._step_per_block = slew * BLOCK * 1000.0 / self._sample_rate + self._delay.set(delay_slew=0.0, delay_ms=self._current_ms) + + def _step(self): + target = self._node_ms + step = self._step_per_block + current = self._current_ms + if current == target: + return + if step <= 0.0 or abs(target - current) <= step: + current = target + elif target > current: + current += step + else: + current -= step + self._current_ms = current + self._delay.set(delay_ms=current) + + +class HalfFrameDelay(DigitalDelay): + """T3: Time handed to the node half a frame off the whole frame, so the + read interpolator takes the top of the band down on every pass - the + per-pass loss the whole-frame law designs out.""" + + NAME = 'DigitalDelay' + + def _node_time_ms(self, frames): + return (frames + 0.5) * 1000.0 / self._sample_rate + + +class LinearMapDelay(DigitalDelay): + """T4: the Time map linear in the macro instead of log - the same knob + position means different milliseconds than the stated law.""" + + NAME = 'DigitalDelay' + + def _time_map(self, position): + return 12.5 + 787.5 * position + + +class OpenTopToneDelay(DigitalDelay): + """T5: Repeat Tone's top stop pre-warped to a large number instead of + exactly 0, so the default is not out of circuit.""" + + NAME = 'DigitalDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return nominal_damping_hz(self._hz(16000.0), self._sample_rate) + return DigitalDelay._tone_damping(self, position) + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def array_src(values, channels=2, rate=RATE): + data = array("h") + for value in values: + for _ in range(channels): + data.append(int(value)) + return probes.ArraySource(data, rate=rate, channels=channels, block=BLOCK) + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def sine_values(hz, frames, rate, level): + return [int(round(level * math.sin(2.0 * math.pi * hz * n / rate))) + for n in range(frames)] + + +def pull(effect, frames, channels=None, on_block=None): + """Interleaved int16 of `frames` frames; `on_block(frame)` runs before + each block is pulled.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], dtype=np.int16) + + +def left(interleaved, channels): + return interleaved[0::channels].astype(np.float64) + + +def inst_hz(x, rate): + x = np.asarray(x, dtype=np.float64) + n = len(x) + spec = np.fft.fft(x) + h = np.zeros(n) + h[0] = 1.0 + if n % 2 == 0: + h[n // 2] = 1.0 + h[1:n // 2] = 2.0 + else: + h[1:(n + 1) // 2] = 2.0 + phase = np.unwrap(np.angle(np.fft.ifft(spec * h))) + return np.diff(phase) * rate / (2.0 * math.pi) + + +def cents(ratio): + return 1200.0 * math.log(ratio) / math.log(2.0) + + +# -------------------------------------------------------------------------- +# The measurements, each returning {"passed": ...} + + +def t1_measure(cls, rate=RATE, channels=2, **options): + """WIRE over the first T - 1 frames on the full-scale ramp, and the + first repeat arriving in the 64 frames after, where Mix is above 0.""" + options.setdefault("glide_ms", 4000.0) + effect_probe = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + frames_t = effect_probe._frames + mix = effect_probe.macro(MIX_I) + effect_probe.deinit() + total = max(8192, frames_t + 512) + ramp = probes.ramp_fs(frames=total, channels=channels) + source = probes.ArraySource(ramp, rate=rate, channels=channels, + block=BLOCK) + effect = cls(source, sample_rate=rate, **options) + out = pull(effect, total, channels) + src = np.array(ramp, dtype=np.int16) + window = (frames_t - 1) * channels + differing = int(np.count_nonzero(out[:window] != src[:window])) + after = slice(frames_t * channels, (frames_t + 64) * channels) + arrived = int(np.count_nonzero(out[after] != src[after])) + passed = differing == 0 and (mix <= 0.0 or arrived > 0) + return {"passed": passed, "differing": differing, "arrived": arrived, + "frames": frames_t} + + +def t2_render(cls, rate, glide_ms, start_ms, target_ms, move_at=20480): + slew = dd.slew_of(glide_ms) + nominal = 0 + if target_ms is not None and slew > 0.0: + nominal = int(abs(target_ms - start_ms) / slew * rate / 1000.0) + frames = move_at + nominal + int(0.3 * rate) + source = array_src(sine_values(997.0, frames, rate, 12000), 2, rate) + effect = cls(source, sample_rate=rate, time_ms=start_ms, feedback=0.0, + mix=2.0, glide_ms=glide_ms) + + def move(frame): + if target_ms is not None and frame == move_at: + effect.set_macro(TIME_I, midi_of_ms(target_ms)) + + out = left(pull(effect, frames, 2, on_block=move), 2) + return out, nominal + + +def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, + target_ms=150.0, move_at=20480): + """The wet pitch against w_s (1 - dD) during the walk, the residual + 50-250 ms after it, and the no-step clause in three windows: the walk + (less a 128-frame margin, inside which the node's float32 walk ends; + A8.1 reads it ending up to 105 frames early) against the signed bar + |1 - dD| x the unramped maximum, the margin against the looser of the + two legitimate slopes, and the frames after against the unramped + maximum, each with 5 % for phase sampling.""" + slew = dd.slew_of(glide_ms) + y, nominal = t2_render(cls, rate, glide_ms, start_ms, target_ms, move_at) + base, _ = t2_render(cls, rate, glide_ms, start_ms, None, move_at) + d_base = float(np.abs(np.diff(base[move_at:])).max()) + falling = target_ms < start_ms + ratio = (1.0 + slew) if falling else (1.0 - slew) + end = move_at + nominal + walk = float(np.abs(np.diff(y[move_at - 1:end - 128 + 1])).max()) + gap = float(np.abs(np.diff(y[end - 128:end + 64 + 1])).max()) + after = float(np.abs(np.diff(y[end + 64:end + 64 + 2000])).max()) + hz = inst_hz(y, rate) + during = float(np.median(hz[move_at + 400:end - 400])) + residual = float(np.median(hz[end + int(0.05 * rate): + end + int(0.25 * rate)])) + pitch_error = cents(during / (997.0 * ratio)) + residual_error = cents(residual / 997.0) + passed = (abs(pitch_error) <= 10.0 and abs(residual_error) <= 1.0 + and walk <= 1.05 * ratio * d_base + and gap <= 1.05 * max(ratio, 1.0) * d_base + and after <= 1.05 * d_base) + return {"passed": passed, "pitch_cents": pitch_error, + "residual_cents": residual_error, "walk": walk, "gap": gap, + "after": after, "unramped": d_base, + "bar": 1.05 * ratio * d_base} + + +def t3_burst(rate): + n = int(rate * 50.0 / 1000.0) + rng = np.random.RandomState(12345) + return np.round(rng.uniform(-1.0, 1.0, n) * 8192).astype(np.int16) + + +def t3_shares(segment, rate): + spec = np.abs(np.fft.rfft(segment)) ** 2 + freqs = np.fft.rfftfreq(len(segment), 1.0 / rate) + total = spec[freqs >= 20.0].sum() + bands = ((100.0, 1000.0), (1000.0, 4000.0), (4000.0, rate / 2.0)) + if total <= 0.0: + return None + return [spec[(freqs >= lo) & (freqs < hi)].sum() / total + for lo, hi in bands] + + +def t3_measure(cls, rate=RATE, tone_hz=16000.0, **options): + """Burst then silence at Feedback 0.8, Mix 2: each repeat's band shares + against repeat 1's, through repeat 8. Returns the worst deviation per + band and repeat 8's 4 kHz-Nyquist share against repeat 1's.""" + options.setdefault("time_ms", 350.0) + burst = t3_burst(rate) + probe = cls(silence_src(64, 2, rate), sample_rate=rate, **options) + frames_t = probe._frames + probe.deinit() + total = 9 * frames_t + len(burst) + rate // 10 + values = np.zeros(total, dtype=np.int16) + values[:len(burst)] = burst + effect = cls(array_src(values.tolist(), 2, rate), sample_rate=rate, + feedback=0.8, mix=2.0, tone_hz=tone_hz, cut_hz=20.0, + glide_ms=4000.0, **options) + y = left(pull(effect, total, 2), 2) + rows = [] + for k in range(1, 9): + segment = y[k * frames_t:k * frames_t + len(burst)] + if float(np.sqrt(np.mean(segment ** 2))) < 1.0: + return {"passed": False, "why": "repeat %d is silent" % k} + shares = t3_shares(segment, rate) + if shares is None or min(shares) <= 0.0: + return {"passed": False, "why": "repeat %d has no band" % k} + rows.append(shares) + worst = [max(abs(10.0 * math.log10(row[i] / rows[0][i])) for row in rows) + for i in range(3)] + high8 = 10.0 * math.log10(rows[7][2] / rows[0][2]) + return {"passed": max(worst) <= 1.0, "worst": worst, "high8": high8, + "frames": frames_t} + + +def t4_delay(cls, rate=RATE, transport=None, midi=None, division=None, + sync=None, **options): + """The wet click's landing in frames: one full-scale sample at frame + 1000, Mix 2, Feedback 0, Glide 0 (an instant knob).""" + at = 1000 + frames = at + int(0.81 * rate) + 512 + values = [0] * frames + values[at] = 32767 + source = array_src(values, 2, rate) + if transport is None: + effect = cls(source, sample_rate=rate, feedback=0.0, mix=2.0, + glide_ms=0.0, **options) + else: + effect = cls.create(source, rate, transport=transport, feedback=0.0, + mix=2.0, glide_ms=0.0, **options) + if sync is not None: + effect.set_macro(SYNC_I, sync) + if division is not None: + effect.set_macro(DIVISION_I, division) + if midi is not None: + effect.set_macro(TIME_I, midi) + y = np.abs(left(pull(effect, frames, 2), 2)) + peak = int(np.argmax(y)) + lo, hi = max(0, peak - 2), min(len(y), peak + 3) + centroid = float(np.dot(np.arange(lo, hi), y[lo:hi]) / y[lo:hi].sum()) + return centroid - at, effect.get_macro(TIME_I) + + +def t4_measure(cls, rate=RATE, positions=None): + """Delay tracking against the stated law's whole frame, within one + sample; with no positions, at the constructor's own 350 ms.""" + if positions is None: + measured, _ = t4_delay(cls, rate, time_ms=350.0) + expected = int(math.floor(350.0 * rate / 1000.0 + 0.5)) + errors = [abs(measured - expected)] + else: + errors = [] + for k in positions: + measured, _ = t4_delay(cls, rate, midi=127.0 * k / 16.0) + errors.append(abs(measured - mapped_frames(k / 16.0, rate))) + return {"passed": max(errors) <= 1.0, "worst": max(errors)} + + +def t5_gain_db(cls, hz, rate=RATE, window_s=0.25, **options): + """One pass, wet only, Time 350.0 ms: the tone's magnitude against the + class's own filters-out render at the same Time and rate.""" + frames_t = int(math.floor(350.0 * rate / 1000.0 + 0.5)) + total = frames_t + int(window_s * rate) + int(0.05 * rate) + values = sine_values(hz, total, rate, 8192) + + def magnitude(**opts): + effect = cls(array_src(values, 2, rate), sample_rate=rate, + time_ms=350.0, feedback=0.0, mix=2.0, glide_ms=4000.0, + **opts) + y = left(pull(effect, total, 2), 2) + n = int(window_s * rate) + segment = y[-n:] + t = np.arange(n) / float(rate) + return abs(np.sum(segment * np.exp(-2j * math.pi * hz * t))) * 2.0 / n + + wet = magnitude(**options) + ref = magnitude() + return 20.0 * math.log10(max(wet, 1e-9) / max(ref, 1e-9)) + + +def t5_out_identical(cls, rate=RATE, channels=2): + """The class at both filter stops (its defaults) against a node given + neither filter option, on deterministic noise: byte for byte.""" + frames = 8192 + noise = probes.noise_det(frames=frames, channels=channels) + effect = cls(probes.ArraySource(noise, rate=rate, channels=channels, + block=BLOCK), + sample_rate=rate, time_ms=12.5, feedback=0.6, mix=2.0) + out = pull(effect, frames, channels) + reference = dd.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=801.0, + delay_ms=12.5, feedback=0.6, mix=2.0) + reference.play(probes.ArraySource(noise, rate=rate, channels=channels, + block=BLOCK)) + ref = array("h") + while len(ref) < frames * channels: + data = bytes(audiocore.get_buffer(reference)[1]) + if not data: + break + ref.extend(memoryview(data).cast("h")) + ref = np.array(ref[:frames * channels], dtype=np.int16) + return int(np.count_nonzero(out != ref)) + + +def t5_measure(cls, rate=RATE): + """Repeat Tone 7 kHz: -3 dB inside 6.3-7.7 kHz, no more than 3 dB down + at 5 kHz, more than 3 dB down at 10 kHz; Repeat Cut 40 Hz: -3 dB inside + 36-44 Hz; both out-of-circuit stops byte-identical to filters-out.""" + tone = {hz: t5_gain_db(cls, hz, rate, tone_hz=7000.0) + for hz in (5000, 6300, 7700, 10000)} + cut = {hz: t5_gain_db(cls, hz, rate, cut_hz=40.0) for hz in (36, 44)} + differing = t5_out_identical(cls, rate) + passed = (tone[6300] > -3.0 > tone[7700] and tone[5000] >= -3.0 + and tone[10000] < -3.0 and cut[36] < -3.0 < cut[44] + and differing == 0) + return {"passed": passed, "tone": tone, "cut": cut, + "differing": differing} + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = DigitalDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Feedback", "Mix", "Glide", "Sync", + "Division", "Repeat Tone", "Repeat Cut")) + self.assertEqual(len(cls.PATCHES), 6) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ("tempo_sync",)) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_parked_not_served(self): + import audioeffects + self.assertNotIn("DigitalDelay", rebuilt.ADOPTED) + self.assertIn("DigitalDelay", rebuilt.parked()) + self.assertIsNot(audioeffects.DigitalDelay, DigitalDelay) + + def test_patch_0_is_the_constructor_grid(self): + effect = DigitalDelay(silence_src(512), sample_rate=RATE) + grid = DigitalDelay.PATCHES[0][1] + for index, expected in enumerate(grid): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_tail_samples_follows_time_and_feedback(self): + effect = DigitalDelay(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.tail_samples, 127343) + effect.program_change(0) + self.assertEqual(effect.tail_samples, 128606) + effect.set_macro(FEEDBACK_I, 0) + self.assertEqual(effect.tail_samples, 16935) + effect.set_macro(TIME_I, 0) + self.assertEqual(effect.tail_samples, 600) + + def test_glide_law(self): + self.assertEqual(dd.slew_of(0.0), 0.0) + self.assertAlmostEqual(dd.slew_of(3937.5), 0.2) + self.assertAlmostEqual(dd.slew_of(4000.0), 0.196875) + self.assertEqual(dd.slew_of(500.0), 0.99) + effect = DigitalDelay(silence_src(512), sample_rate=RATE, + glide_ms=800.0) + self.assertAlmostEqual(dd.slew_of(effect._glide_ms()), 0.984375) + effect.set_macro(GLIDE_I, 1) + self.assertAlmostEqual(dd.slew_of(effect._glide_ms()), 0.96669, + places=5) + effect.set_macro(GLIDE_I, 0) + self.assertEqual(effect._glide_ms(), 0.0) + effect.set_macro(GLIDE_I, 127) + self.assertAlmostEqual(effect._glide_ms(), 8000.0) + + def test_filter_stops_are_exactly_zero(self): + effect = DigitalDelay(silence_src(512), sample_rate=RATE) + self.assertEqual(effect._tone_damping(1.0), 0.0) + self.assertEqual(effect._cut_hz(0.0), 0.0) + self.assertAlmostEqual(dd.nominal_cut_hz(40.0, 48000), 39.7916, + places=3) + self.assertAlmostEqual(dd.nominal_cut_hz(400.0, 44100), 378.3309, + places=3) + + +# -------------------------------------------------------------------------- +# Tier 2 rows + + +class T1DryIsAWire(unittest.TestCase): + def test_defaults_stereo_and_mono(self): + for channels in (2, 1): + result = t1_measure(DigitalDelay, channels=channels) + self.assertTrue(result["passed"], (channels, result)) + self.assertEqual(result["frames"], 16800) + + def test_the_hardest_cells(self): + for rate in (48000, 22050): + for mix in (0.9921, 1.0): + result = t1_measure(DigitalDelay, rate=rate, time_ms=12.5, + feedback=0.99, mix=mix, tone_hz=800.0, + cut_hz=400.0) + self.assertTrue(result["passed"], (rate, mix, result)) + + def test_a_coloured_dry_is_red(self): + result = t1_measure(DryScaledDelay) + self.assertFalse(result["passed"]) + self.assertGreater(result["differing"], 0) + + +class T2TimeResamples(unittest.TestCase): + def test_the_row_cell_falls_on_the_law(self): + result = t2_measure(DigitalDelay) + self.assertTrue(result["passed"], result) + self.assertLess(abs(result["pitch_cents"]), 10.0) + + def test_a_rising_move_at_the_default_glide(self): + result = t2_measure(DigitalDelay, glide_ms=4000.0, start_ms=150.0, + target_ms=200.0) + self.assertTrue(result["passed"], result) + + def test_the_block_staircase_is_red(self): + result = t2_measure(StaircaseDelay, glide_ms=4000.0) + self.assertFalse(result["passed"], result) + self.assertGreater(result["walk"], result["bar"]) + + +class T3NoDarkening(unittest.TestCase): + def test_the_constructor_time_at_48k_and_22k(self): + for rate, frames in ((48000, 16800), (22050, 7718)): + result = t3_measure(DigitalDelay, rate=rate) + self.assertTrue(result["passed"], (rate, result)) + self.assertEqual(result["frames"], frames) + + def test_the_control_darkens(self): + result = t3_measure(DigitalDelay, tone_hz=3000.0) + self.assertLessEqual(result["high8"], -20.0, result) + + def test_a_half_frame_read_is_red(self): + result = t3_measure(HalfFrameDelay) + self.assertFalse(result["passed"], result) + + +class T4TimeLaw(unittest.TestCase): + def test_the_map_at_the_stops_and_between(self): + result = t4_measure(DigitalDelay, positions=(0, 4, 8, 12, 16)) + self.assertTrue(result["passed"], result) + result = t4_measure(DigitalDelay, rate=22050, positions=(0, 16)) + self.assertTrue(result["passed"], result) + + def test_a_lowered_ceiling_clamps_visibly(self): + measured, reported = t4_delay(DigitalDelay, midi=127.0, + max_time_ms=300.0) + self.assertLessEqual(abs(measured - 14400), 1.0) + self.assertAlmostEqual(reported, 97.048, places=3) + measured, reported = t4_delay(DigitalDelay, midi=64.0, + max_time_ms=300.0) + self.assertLessEqual(abs(measured - mapped_frames(64 / 127.0, RATE)), + 1.0) + self.assertAlmostEqual(reported, 64.0, places=6) + + def test_sync_quantises_time_into_the_map(self): + def transport(): + return (True, 0.0, 120.0, 4, 4) + for division, frames, reported in ((0, 3000, None), + (51, 12000, None), + (127, 38400, 127.0)): + measured, got = t4_delay(DigitalDelay, transport=transport, + sync=127, division=division) + self.assertLessEqual(abs(measured - frames), 1.0, division) + if reported is not None: + self.assertAlmostEqual(got, reported, places=6) + + def test_no_host_leaves_time_on_the_knob(self): + measured, _ = t4_delay(DigitalDelay, sync=127, division=0, + time_ms=350.0) + self.assertLessEqual(abs(measured - 16800), 1.0) + + def test_a_linear_map_is_red(self): + self.assertTrue(t4_measure(DigitalDelay)["passed"]) + self.assertFalse(t4_measure(LinearMapDelay)["passed"]) + + +class T5BandLimit(unittest.TestCase): + def test_the_corners_at_48k(self): + result = t5_measure(DigitalDelay) + self.assertTrue(result["passed"], result) + + def test_the_in_circuit_stops(self): + self.assertAlmostEqual( + t5_gain_db(DigitalDelay, 800, tone_hz=800.0), -3.01, delta=0.2) + self.assertAlmostEqual( + t5_gain_db(DigitalDelay, 400, cut_hz=400.0), -3.01, delta=0.2) + + def test_an_open_top_stop_is_red(self): + self.assertGreater(t5_out_identical(OpenTopToneDelay), 0) + self.assertFalse(t5_measure(OpenTopToneDelay)["passed"]) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half + + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for rate in (48000, 44100, 22050): + for channels in (2, 1): + ramp = probes.ramp_fs(frames=8192, channels=channels) + source = probes.ArraySource(ramp, rate=rate, + channels=channels, block=BLOCK) + effect = DigitalDelay(source, sample_rate=rate, mix=0.0, + time_ms=12.5, feedback=0.99) + out = pull(effect, 8192, channels) + self.assertTrue(np.array_equal( + out, np.array(ramp, dtype=np.int16)), (rate, channels)) + + def test_silence_stays_silence(self): + effect = DigitalDelay(silence_src(RATE), sample_rate=RATE, + feedback=0.99, mix=2.0, tone_hz=800.0, + cut_hz=400.0) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE)))), 0) + + def test_reset_empties_the_line(self): + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * RATE + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=100.0, feedback=0.9, mix=2.0) + pull(effect, 2304) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE // 2)))), 0) + + def test_deinit_leaves_the_source(self): + source = array_src(sine_values(440.0, 1024, RATE, 8000)) + effect = DigitalDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + values = [0] * 2048 + values[10] = 30000 + effect = DigitalDelay(array_src(values), sample_rate=RATE) + out = left(pull(effect, 2048), 2) + self.assertEqual(int(np.argmax(np.abs(out))), 10) + self.assertEqual(effect.latency_samples, 0) + + def test_the_transport_is_read_only_with_sync_on(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = DigitalDelay.create(silence_src(512), RATE, + transport=transport) + self.assertEqual(reads, []) + effect.set_macro(SYNC_I, 127) + self.assertGreater(len(reads), 0) + + +# -------------------------------------------------------------------------- +# The two checks every planted fault and every row is held to + + +class FaultsAreUnreachable(unittest.TestCase): + CHECKED = 8 * 17 + 6 + + def _reach(self, faulted, reading): + return kit_faults.fault_reachability( + DigitalDelay, faulted, reading, + lambda cls: cls(silence_src(512), sample_rate=RATE)) + + def test_every_fault_is_off_the_surface(self): + for faulted, reading, expected in ( + (DryScaledDelay, + lambda e: getattr(e, "_plant_dry_scale", False), True), + (StaircaseDelay, + lambda e: round(getattr(e, "_step_per_block", 0.0), 6), + round(0.196875 * BLOCK * 1000.0 / RATE, 6)), + (HalfFrameDelay, + lambda e: round(abs(e._node_ms * e._sample_rate / 1000.0 + - round(e._node_ms * e._sample_rate + / 1000.0)), 6), 0.5), + (LinearMapDelay, lambda e: round(e._time_map(0.5), 6), + 406.25), + (OpenTopToneDelay, lambda e: round(e._tone_damping(1.0), 3), + round(nominal_damping_hz(16000.0, RATE), 3))): + with self.subTest(fault=faulted.__name__): + result = self._reach(faulted, reading) + self.assertEqual(result["target"], expected) + self.assertEqual(result["checked"], self.CHECKED) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in ( + ("T1", t1_measure), + ("T2", t2_measure), + ("T3", t3_measure), + ("T4", lambda cls: t4_measure(cls, positions=(0, 8, 16))), + ("T5", t5_measure)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + DigitalDelay, measure, label="DigitalDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() From 8e0a3513a975d67b4217176fff2eb8021c150739 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:42:27 -0500 Subject: [PATCH 03/88] DigitalDelay fix round: an honest tail, and patches load their own Time tail_samples now bounds the frames to exact zero rather than -60 dB: the node's rounded feedback write needs laps_to_zero(f) laps (12 at the default 0.35), each one frame longer than the longest delay the read head may be at, which during a falling Glide walk is the Time it walks from. From Feedback 0.5 up the node's rounding holds 1 LSB or more in the line forever, a floor bug in audiodsp v0.6.1, so tail_samples is None there, and None with Repeat Cut in circuit. program_change applies the whole patch before refreshing, so a Sync-off patch no longer keeps the outgoing patch's synced Time with a host transport, and reset() restores patch 0. Tests: the kit's TAIL at the defaults and patch 2, the floor bug's residual, the walk tail and the patch load, each red on its planted fault. --- lib/audioeffects/rebuilt/digitaldelay.py | 126 ++++++++++++++-- tests/test_cpython_effects_digitaldelay.py | 159 ++++++++++++++++++++- 2 files changed, 269 insertions(+), 16 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 07ddfce..eedcb51 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -68,6 +68,14 @@ lossless, so the repeats of a Time you have stopped turning do not darken. Above Mix 1 the dry falls as 2 - Mix, by `audiodelays.Echo`'s convention. +**Tail.** `tail_samples` is an upper bound on how long the repeats take to +reach exact zero after your input stops, and it is long: the loop has to +round its way down from full scale, 12 laps at the default Feedback. From +Feedback 0.5 up it is `None`, because there the node's feedback write +rounds a 1 LSB repeat back to itself and the line never empties (up to 50 +LSB at 0.99). That is a floor bug in the node, not in this class, and it is +why the rebuild is parked. With Repeat Cut in circuit it is `None` too. + `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). With no host transport, Time stays where the knob is. @@ -106,6 +114,15 @@ #: `line_frames - 2`, so a line of exactly `max_time_ms` could not reach it. LINE_HEADROOM_MS = 1.0 +#: At and above this Feedback the node's line never empties. Its feedback +#: write rounds half away from zero (`to_s16`, `audiodsp_feedback_delay.c:317`, +#: called at `:493`), so every |x| <= 0.5 / (1 - f) writes itself back, and +#: at f >= 0.5 that includes 1 LSB. `tail_samples` is `None` there. +FEEDBACK_UNBOUNDED = 0.5 + +#: The largest magnitude one line sample can hold (int16). +LINE_PEAK = 32768.0 + #: 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. @@ -168,6 +185,33 @@ def whole_frames(time_ms, sample_rate): return int(math.floor(float(time_ms) * sample_rate / 1000.0 + 0.5)) +def laps_to_zero(feedback): + """How many laps of the line can still hold a non-zero sample once the + input stops, or `None` if the line never empties. + + The node writes `round(fed + f * read)`, rounding half away from zero, + so after the input stops a lap's peak obeys x' <= f x + 0.5 from any + starting x <= 32768. That gives x_k <= f^k (32768 - c) + c with + c = 0.5 / (1 - f), and x_k < 1 means x_k is exactly 0. Below + f = 0.5, c < 1 and the count is finite; at 0.5 and above, 1 LSB writes + itself back forever (`FEEDBACK_UNBOUNDED`). + """ + feedback = max(0.0, float(feedback)) + if feedback >= FEEDBACK_UNBOUNDED: + return None + c = 0.5 / (1.0 - feedback) + laps = 0 + peak = LINE_PEAK + while peak >= 1.0: + laps += 1 + if laps > 1024: + # Only a Feedback within a float's width of 0.5 gets here, where + # a board's single-precision c can round up to 1. + return None + peak = feedback * (peak - c) + c + return laps + + class DigitalDelay(_component.Component): """A clean digital delay with the DD-2's control law: the dry path is a wire, and turning Time pitch-bends the repeats instead of clicking. @@ -235,6 +279,17 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, max_time_ms = TIME_MIN_MS self._max_time_ms = max_time_ms self._frames = 1 + #: The longest delay, in whole frames, the read head may still sit + #: at. A Glide walk starts from wherever the head is and the class + #: cannot see how far it has got, so after a falling move this keeps + #: the old Time until a jump (Glide 0) or a clear lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True inside `program_change`, which applies the macros one at a + #: time; the node is refreshed once, after the last. + self._deferred = False self._feedback = 0.0 self._node_ms = 0.0 #: A constructor Glide stays on the audio path until macro 3 moves: @@ -254,7 +309,7 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, delay_slew=0.0) # `clear()` empties the line and the loop filters and re-primes the # read head, so a reset is silent and snaps onto patch 0's Time. - self._own(self._delay, reset=self._delay.clear) + self._own(self._delay, reset=self._clear) self._delay.play(self._source) self._output = self._delay # The knob is seeded at its bottom (800 ms) for a faster or zero @@ -265,6 +320,11 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, self._seeding = False if patch is not None: self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True # -- the maps ------------------------------------------------------ @@ -335,7 +395,27 @@ def _apply_macro(self, index, position): del position if index == GLIDE_I and not self._seeding: self._glide_exact = None - self._refresh() + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh once. The base applies + the macros in index order, so a refresh per macro would read Time + against the outgoing patch's Sync and, with a host transport, keep + its synced Time instead of the new patch's own.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + self._fresh = False def _refresh(self): span = self._MACRO_RANGES[TIME_I] @@ -352,6 +432,13 @@ def _refresh(self): self._macros[TIME_I] = _component.macro_position(span, clamped) self._frames = max(1, whole_frames(clamped, self._sample_rate)) self._node_ms = self._node_time_ms(self._frames) + slew = slew_of(self._glide_ms()) + if self._fresh or slew <= 0.0: + # A fresh node snaps onto the target, and with the slew off the + # read head jumps there on the next frame. + self._reach = self._frames + elif self._frames > self._reach: + self._reach = self._frames feedback = self._value(FEEDBACK_I) if feedback > FEEDBACK_MAX: feedback = FEEDBACK_MAX @@ -359,7 +446,7 @@ def _refresh(self): feedback = 0.0 self._feedback = feedback self._delay.set( - delay_slew=slew_of(self._glide_ms()), + delay_slew=slew, delay_ms=self._node_ms, feedback=feedback, mix=self._value(MIX_I), @@ -368,13 +455,28 @@ def _refresh(self): @property def tail_samples(self): - """The -60 dB lap count plus one lap, from the Time in whole frames - and the Feedback: `ceil(T (1 + 3 / -log10 f))`, and `T` at f = 0. A - loop filter only shortens the tail, so this is an upper bound.""" + """Frames until the output is exactly zero once the input stops, as + an upper bound, or `None` where no bound holds. + + `laps_to_zero(f)` 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). While a Glide walk falls, that is + the Time it is walking from, not the target, and it stays so until + a Glide-0 move or a reset lands the head, because the class cannot + see how far the walk has got. Repeat Tone adds one lap for its + filter state to die away. + + `None` at Feedback 0.5 and above, where the node's rounding holds + 1 LSB or more in the line forever (`FEEDBACK_UNBOUNDED`), and with + Repeat Cut in circuit, whose high-pass can more than double a peak + in one pass and remembers across laps at its low corners. + """ self._check_live() - frames = self._frames - feedback = self._feedback - if feedback <= 0.0: - return int(frames) - laps = 1.0 + 3.0 * math.log(10.0) / -math.log(feedback) - return int(math.ceil(frames * laps)) + if self._macros[CUT_I] > 0.0: + return None + laps = laps_to_zero(self._feedback) + if laps is None: + return None + if self._macros[TONE_I] < 1.0: + laps += 1 + return int(laps * (self._reach + 1)) diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index d23bb8a..b5d1222 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -31,6 +31,7 @@ from audioeffects import rebuilt # noqa: E402 from audioeffects.chorus import nominal_damping_hz # noqa: E402 from audioeffects.rebuilt import digitaldelay as dd # noqa: E402 +from tools import effect_measurements as kit # noqa: E402 VENDOR = "PyDevices" @@ -161,6 +162,44 @@ def _tone_damping(self, position): return DigitalDelay._tone_damping(self, position) +# -------------------------------------------------------------------------- +# Planted faults for the Tier 1 checks the review round added + + +class SixtyDbTailDelay(DigitalDelay): + """The first build's tail: the -60 dB lap count plus one lap, which a + -6 dBFS burst outlives on its way down to exact zero.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + frames = self._frames + if self._feedback <= 0.0: + return int(frames) + laps = 1.0 + 3.0 * math.log(10.0) / -math.log(self._feedback) + return int(math.ceil(frames * laps)) + + +class TargetOnlyTailDelay(DigitalDelay): + """The tail from the target Time alone, while the read head is still + walking down from the old one.""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + self._reach = self._frames + + +class PerMacroPatchDelay(DigitalDelay): + """A patch applied one macro at a time with a refresh after each, so + Time is read against the outgoing patch's Sync.""" + + NAME = 'DigitalDelay' + program_change = _component.Component.program_change + + # -------------------------------------------------------------------------- # Sources and pulls @@ -495,14 +534,34 @@ def test_patch_0_is_the_constructor_grid(self): delta=0.6) def test_tail_samples_follows_time_and_feedback(self): + # laps_to_zero(f) laps of (the head's longest delay + 1) frames. + self.assertEqual(dd.laps_to_zero(0.0), 1) + self.assertEqual(dd.laps_to_zero(0.35), 12) + self.assertEqual(dd.laps_to_zero(64 / 127.0 * 0.99), 24) + self.assertIsNone(dd.laps_to_zero(0.5)) effect = DigitalDelay(silence_src(512), sample_rate=RATE) - self.assertEqual(effect.tail_samples, 127343) + self.assertEqual(effect.tail_samples, 12 * 16801) effect.program_change(0) - self.assertEqual(effect.tail_samples, 128606) + self.assertEqual(effect.tail_samples, 12 * 16936) effect.set_macro(FEEDBACK_I, 0) - self.assertEqual(effect.tail_samples, 16935) + self.assertEqual(effect.tail_samples, 16936) + # A falling move walks from the old Time, so the old Time stays the + # bound until a jump lands the head. effect.set_macro(TIME_I, 0) - self.assertEqual(effect.tail_samples, 600) + self.assertEqual(effect.tail_samples, 16936) + effect.set_macro(GLIDE_I, 0) + self.assertEqual(effect.tail_samples, 601) + effect.set_macro(FEEDBACK_I, 64) + self.assertEqual(effect.tail_samples, 24 * 601) + effect.set_macro(TONE_I, 100) + self.assertEqual(effect.tail_samples, 25 * 601) + effect.set_macro(FEEDBACK_I, 65) + self.assertIsNone(effect.tail_samples) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(CUT_I, 1) + self.assertIsNone(effect.tail_samples) + effect.reset() + self.assertEqual(effect.tail_samples, 12 * 16936) def test_glide_law(self): self.assertEqual(dd.slew_of(0.0), 0.0) @@ -667,6 +726,98 @@ def test_silence_stays_silence(self): cut_hz=400.0) self.assertEqual(int(np.max(np.abs(pull(effect, RATE)))), 0) + def _kit_tail(self, cls, seconds, **options): + data, on = probes.burst_silence(total_s=seconds, rate=RATE) + effect = cls(probes.ArraySource(data, rate=RATE, channels=2, + block=BLOCK), + sample_rate=RATE, **options) + declared = effect.tail_samples + render = probes.render(effect, int(seconds * RATE), rate=RATE, + channels=2) + result = kit.tail(render, burst_end_frame=on, + declared_tail_samples=declared, + settle_frames=effect._frames) + return declared, result["values"], result["red"] + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + # The kit's TAIL on its own -6 dBFS burst, at the defaults and at + # the Sync-on patch with the highest Feedback under 0.5. + for options, seconds in (({}, 5.0), ({"patch": 2}, 6.0)): + declared, values, red = self._kit_tail(DigitalDelay, seconds, + **options) + self.assertEqual(red, [], (options, values)) + self.assertLessEqual(values["tail_samples"], declared) + declared, values, red = self._kit_tail(SixtyDbTailDelay, 5.0) + self.assertNotEqual(red, [], values) + self.assertGreater(values["tail_samples"], declared) + + def test_the_floor_bug_is_reported_as_unbounded(self): + # audiodsp v0.6.1 rounds the feedback write half away from zero, so + # from Feedback 0.5 a 1 LSB repeat writes itself back forever. The + # class says None there. When the node is fixed the residual below + # goes to 0, and tail_samples can come back for the top half. + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * (3 * RATE) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.5, mix=2.0) + self.assertIsNone(effect.tail_samples) + out = pull(effect, len(values)) + self.assertGreater(int(np.max(np.abs(out[-600 * 2:]))), 0) + effect.set_macro(FEEDBACK_I, 64) + self.assertIsNotNone(effect.tail_samples) + + def _walk_tail(self, cls): + """800 ms of 997 Hz, then Time 800 -> 12.5 ms at the default Glide + on the tone's last block, Feedback 0, Mix 2.""" + tone = int(0.8 * RATE) // BLOCK * BLOCK + values = sine_values(997.0, tone, RATE, 12000) + [0] * RATE + effect = cls(array_src(values), sample_rate=RATE, time_ms=800.0, + feedback=0.0, mix=2.0) + seen = {} + + def move(frame): + if frame == tone: + effect.set_macro(TIME_I, 0) + seen["declared"] = effect.tail_samples + + out = pull(effect, len(values), on_block=move) + after = out[tone * 2:] + last = int(np.nonzero(after)[0][-1]) // 2 + 1 + return seen["declared"], last + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + declared, last = self._walk_tail(DigitalDelay) + self.assertGreater(last, 30000) + self.assertLessEqual(last, declared) + declared, last = self._walk_tail(TargetOnlyTailDelay) + self.assertGreater(last, declared) + + def _patch_times(self, cls): + def transport(): + return (True, 0.0, 120.0, 4, 4) + effect = cls.create(silence_src(512), RATE, transport=transport) + seen = [] + effect.program_change(1) + effect.program_change(0) + seen.append((effect._frames, round(effect.get_macro(TIME_I), 3))) + effect.program_change(1) + effect.program_change(4) + seen.append((effect._frames, round(effect.get_macro(TIME_I), 3))) + effect.program_change(2) + effect.reset() + seen.append((effect._frames, round(effect.get_macro(TIME_I), 3), + effect.patch_index)) + return seen + + def test_a_sync_off_patch_loads_its_own_time(self): + # 120 bpm: patch 1's synced Time is 12000 frames (1/8), patch 2's + # 18000 (1/8 dotted). + self.assertEqual(self._patch_times(DigitalDelay), + [(16935, 102.0), (35966, 125.0), + (16935, 102.0, 0)]) + faulted = self._patch_times(PerMacroPatchDelay) + self.assertEqual([row[0] for row in faulted], [12000, 12000, 18000]) + def test_reset_empties_the_line(self): values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) values += [0] * RATE From 5072ddf74f6e49a215ae0dff24f341bac3bc6654 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:54:15 -0500 Subject: [PATCH 04/88] DigitalDelay fix round 1: the audit's list, class side Gate audit round 1 parked the class. This round works its list on the class and its tests; the class stays parked on the node's floor bug, which needs an audiodsp release. - The docstring says the line is not guaranteed to empty from Feedback 0.5 up (it said never), states the input ceiling (-3 dBFS peak at the defaults, -4 over the patches, on noise_det; (1 - Mix) of full scale on any material with Repeat Cut out), and discloses Repeat Tone's flat top at 22.05 kHz (positions 111-126 on the clamp). - A constructor Glide at or under 800 ms seeds the knob just above grid 0, so a get_macro / set_macro round trip keeps the glide (it went to slew 0). - A host reporting 0 bpm leaves Time on the knob (it landed 120 bpm's 1/8). - T2 reads pitch by a local least-squares fit against the dossier's own law, 787.5 / glide_ms, never the class's slew_of; HalfGlideMsDelay is planted and reads +267 c. T1 gets DryGainDelay (+0.1 dB), red at -20 and -40 dBFS where OneLsbScale cannot show. T5 gets the Cut-compounding measurement with PostCutDelay, CornerShiftDelay moves into the tests, and RawTopToneDelay replaces the out-stop fault Tone 111-126 dialled at 22.05 kHz. - Every fault's reachability walk runs at 48, 44.1 and 22.05 kHz, reading what the node is handed or what the output does at each position. - InputCeiling, GlideRoundTrip, ZeroBpmHost and RepeatToneKnee, each beside a planted fault; GlideRoundTrip and ZeroBpmHost were run red on the round-1 class (500 ms round trip lost 100 % of its slew; 0 bpm landed 4 800 frames off the knob). - CHANGELOG: the class's line, under Unreleased. --- CHANGELOG.md | 18 + lib/audioeffects/rebuilt/digitaldelay.py | 71 +- tests/test_cpython_effects_digitaldelay.py | 751 +++++++++++++++++++-- 3 files changed, 757 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82433e4..8e15661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ here. The two packages version and release together, from this repository; Releases up to and including audiodsp's v0.1.1 shipped both packages from there, and are recorded in its changelog. +## Unreleased + +### Added + +- **`DigitalDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** a clean + interpolated line with the Boss DD-2's control law on one + `audioecho.FeedbackDelay`. Turning Time pitch-bends the repeats at a rate + Glide sets instead of clicking, every static Time lands on a whole frame, + Mix 0 is a wire while the line keeps recording, and Repeat Tone and Repeat + Cut put the pedal's 7 kHz and 40 Hz corners into the loop as knobs that + default out. It lives in `lib/audioeffects/rebuilt/digitaldelay.py`, and + `audioeffects.DigitalDelay` is still the old class. It is parked on a + floor bug in the node: from Feedback 0.5 up the feedback write can hold + 1 LSB (50 at 0.99) going round for ever, which needs an audiodsp release. + The docstring states the input ceiling (-3 dBFS peak at the defaults, -4 + over the shipped patches, on `noise_det`) and Repeat Tone's flat top at + 22.05 kHz. Board cost is unmeasured. + ## v0.3.2 (2026-09-25) No change to either package's code: both packages are 0.3.1 under a new diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index eedcb51..5c88d51 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -68,17 +68,46 @@ lossless, so the repeats of a Time you have stopped turning do not darken. Above Mix 1 the dry falls as 2 - Mix, by `audiodelays.Echo`'s convention. +**Repeat Tone at a low rate.** The knob's corners clamp below Nyquist at +the running rate, so where the rate is too low for the top of the span the +top of the knob goes flat. At 22.05 kHz positions 111-126 (labelled +10 970-15 627 Hz) all sit on the 10 804.5 Hz clamp and do the same thing, +and position 127 takes the filter out. At 44.1 and 48 kHz every position +moves. + +A constructor `glide_ms` faster than the knob's span (under 800 ms, down to +the 0.99 pin) stays on the audio path, and the knob reads back just above +its bottom stop, never at grid 0, the jump: handing `get_macro(3)` back to +`set_macro(3, ...)` keeps the glide. + +**Input ceiling.** The dry path sits at unity and the repeats add to it, so +a hot input can put the output on the int16 rail; there is no input gain +to turn down. Measured on the kit's `noise_det` at 48 kHz, the defaults are +clean up to -3 dBFS peak and the shipped patches up to -4 dBFS (patch 3, +Mix 0.5, rails first). On any material, with Repeat Cut out and Mix below +1, an input peaking at or below (1 - Mix) of full scale cannot reach the +rail at any Time or Feedback, because the line holds int16 and so the +repeats never exceed Mix x full scale: -3.1 dBFS at the default Mix 0.3, +-6.1 dBFS at patch 3. High Feedback does not keep building past that: at Feedback 0.99 +the line saturates, and the defaults' noise_det ceiling is -4 dBFS after +6 s and after 20 s alike. Repeat Cut's high-pass can overshoot a peak, so +with it in circuit leave more room. + **Tail.** `tail_samples` is an upper bound on how long the repeats take to reach exact zero after your input stops, and it is long: the loop has to round its way down from full scale, 12 laps at the default Feedback. From Feedback 0.5 up it is `None`, because there the node's feedback write -rounds a 1 LSB repeat back to itself and the line never empties (up to 50 -LSB at 0.99). That is a floor bug in the node, not in this class, and it is -why the rebuild is parked. With Repeat Cut in circuit it is `None` too. +rounds a 1 LSB repeat back to itself, so the line is not guaranteed to +empty. On some material it never does (a 997 Hz burst leaves 1 LSB going +round at Feedback 0.5 and 0.7, 50 LSB at 0.99); on other material it does +(a 1 kHz -6 dBFS burst reaches zero at 0.5 and 0.7). That is a floor bug in +the node, not in this class, and it is why the rebuild is parked. With +Repeat Cut in circuit it is `None` too. `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). -With no host transport, Time stays where the knob is. +With no host transport, or a host that reports no tempo (0 bpm), Time stays +where the knob is. """ VENDOR = "PyDevices" @@ -107,6 +136,11 @@ #: grid position gets there (grid 1 is slew 0.967). SLEW_PIN = 0.99 +#: The Glide knob's position for a constructor Glide at or under 800 ms: +#: just above grid 0, which is the jump. It reads back as MIDI 1.27e-7 and +#: stands for 800.0 ms (slew 0.984). +GLIDE_FLOOR = 1e-9 + #: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). FEEDBACK_MAX = 0.99 @@ -114,10 +148,11 @@ #: `line_frames - 2`, so a line of exactly `max_time_ms` could not reach it. LINE_HEADROOM_MS = 1.0 -#: At and above this Feedback the node's line never empties. Its feedback -#: write rounds half away from zero (`to_s16`, `audiodsp_feedback_delay.c:317`, -#: called at `:493`), so every |x| <= 0.5 / (1 - f) writes itself back, and -#: at f >= 0.5 that includes 1 LSB. `tail_samples` is `None` there. +#: At and above this Feedback the node's line is not guaranteed to empty. +#: Its feedback write rounds half away from zero (`to_s16`, +#: `audiodsp_feedback_delay.c:317`, called at `:493`), so every +#: |x| <= 0.5 / (1 - f) can write itself back, and at f >= 0.5 that +#: includes 1 LSB. `tail_samples` is `None` there. FEEDBACK_UNBOUNDED = 0.5 #: The largest magnitude one line sample can hold (int16). @@ -187,14 +222,15 @@ def whole_frames(time_ms, sample_rate): def laps_to_zero(feedback): """How many laps of the line can still hold a non-zero sample once the - input stops, or `None` if the line never empties. + input stops, or `None` if the line is not guaranteed to empty. The node writes `round(fed + f * read)`, rounding half away from zero, so after the input stops a lap's peak obeys x' <= f x + 0.5 from any starting x <= 32768. That gives x_k <= f^k (32768 - c) + c with c = 0.5 / (1 - f), and x_k < 1 means x_k is exactly 0. Below f = 0.5, c < 1 and the count is finite; at 0.5 and above, 1 LSB writes - itself back forever (`FEEDBACK_UNBOUNDED`). + itself back, and on some material it does so forever + (`FEEDBACK_UNBOUNDED`). """ feedback = max(0.0, float(feedback)) if feedback >= FEEDBACK_UNBOUNDED: @@ -312,11 +348,15 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, self._own(self._delay, reset=self._clear) self._delay.play(self._source) self._output = self._delay - # The knob is seeded at its bottom (800 ms) for a faster or zero - # constructor Glide; `_glide_exact` carries the real value. + # The knob is seeded at its bottom (800 ms) for a faster constructor + # Glide; `_glide_exact` carries the real value. Position 0 is the + # jump, so a Glide that is not 0 is seeded just above it, and a + # get_macro / set_macro round trip keeps it gliding. self._init_macros((time_ms, feedback, mix, max(800.0, glide_ms), 1.0 if sync else 0.0, float(division), tone_hz, cut_hz)) + if self._glide_exact > 0.0 and self._macros[GLIDE_I] <= 0.0: + self._macros[GLIDE_I] = GLIDE_FLOOR self._seeding = False if patch is not None: self.program_change(patch) @@ -378,7 +418,9 @@ def _synced_ms(self): transport, state = self._transport_state() if transport is _component.static_transport: return None - bpm = float(state[2]) if state[2] else 120.0 + # A host that reports no tempo (0 or None) leaves Time on the knob, + # as the static transport does; it is never read as 120 bpm. + bpm = float(state[2] or 0.0) if bpm <= 0.0: return None index = int(round(self._value(DIVISION_I))) @@ -467,7 +509,8 @@ def tail_samples(self): filter state to die away. `None` at Feedback 0.5 and above, where the node's rounding holds - 1 LSB or more in the line forever (`FEEDBACK_UNBOUNDED`), and with + 1 LSB or more in the line, forever on some material + (`FEEDBACK_UNBOUNDED`), and with Repeat Cut in circuit, whose high-pass can more than double a peak in one pass and remembers across laps at its low corners. """ diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index b5d1222..e0bfa28 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -11,6 +11,17 @@ The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by `rebuilt.module_class("DigitalDelay")`. + +Fix round 1 (2026-09-27, after gate audit round 1): T2's pitch is read by a +local least-squares fit against the dossier's own law (787.5 / glide_ms, +written out here, never the class's `slew_of`), with a planted wrong Glide +law; T1 has a dry fault that shows below -6 dBFS; T5's compounding clause +has a measurement and an out-of-loop fault, and its out-stop fault is one +no position dials at any rate; every fault's reachability walk runs at +48, 44.1 and 22.05 kHz with a reading of what the node is handed (or what +the output does) at the position walked. The input ceiling, the Glide +round trip, a 0 bpm host and Repeat Tone's clamp at 22.05 kHz each have a +test beside a planted fault. """ import math @@ -71,6 +82,19 @@ def _build(self, *arguments, **options): self._output = kit_faults.OneLsbScale(self._delay) +class DryGainDelay(DigitalDelay): + """T1 at every level the row names: the output +0.1 dB + (`kit_faults.HiddenGain`, LEVEL's own fault). `OneLsbScale` is the + identity below 16 384 LSB, so on the -20 and -40 dBFS materials only + this one can go red.""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + DigitalDelay._build(self, *arguments, **options) + self._output = kit_faults.HiddenGain(self._delay, 0.1) + + class _Stepper: """Pulls the owner's node, stepping its delay from Python first: the finest a Python-driven Time can move is once per block.""" @@ -129,6 +153,17 @@ def _step(self): self._delay.set(delay_ms=current) +class HalfGlideMsDelay(DigitalDelay): + """T2: the Glide law wrong - the knob's milliseconds halved, so the node + walks at twice the dossier's 787.5 / glide_ms. A measurement that took + its law from the class's own `slew_of` could not see it.""" + + NAME = 'DigitalDelay' + + def _glide_ms(self): + return 0.5 * DigitalDelay._glide_ms(self) + + class HalfFrameDelay(DigitalDelay): """T3: Time handed to the node half a frame off the whole frame, so the read interpolator takes the top of the band down on every pass - the @@ -151,8 +186,10 @@ def _time_map(self, position): class OpenTopToneDelay(DigitalDelay): - """T5: Repeat Tone's top stop pre-warped to a large number instead of - exactly 0, so the default is not out of circuit.""" + """Superseded at fix round 1, kept so the round-1 probes still import: + Repeat Tone's top stop pre-warped from the clamped 16 kHz. At 22.05 kHz + that is 6 183.7, exactly what Tone positions 111-126 hand the node, so + the surface dials it there. `RawTopToneDelay` replaces it.""" NAME = 'DigitalDelay' @@ -162,6 +199,125 @@ def _tone_damping(self, position): return DigitalDelay._tone_damping(self, position) +class RawTopToneDelay(DigitalDelay): + """T5's out stop: Repeat Tone's top stop hands the node the span's top + corner, clamped below Nyquist, as a raw `damping_hz` (16 000 / 16 000 / + 10 804.5 at 48 / 44.1 / 22.05 kHz) instead of exactly 0. Every in-circuit + position hands a pre-warped value, which is lower at every rate, so no + position reaches it.""" + + NAME = 'DigitalDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return self._hz(16000.0) + return DigitalDelay._tone_damping(self, position) + + +class CornerShiftDelay(DigitalDelay): + """T5's corner clauses: both filters pre-warped for a corner 15 % above + the label. Both filters are out at the defaults, so it is inert there by + design; it is read at the corner cells.""" + + NAME = 'DigitalDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return 0.0 + corner = 1.15 * _component.macro_value(self._MACRO_RANGES[TONE_I], + position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _cut_hz(self, position): + if position <= 0.0: + return 0.0 + corner = 1.15 * _component.macro_value(self._MACRO_RANGES[CUT_I], + position) + return dd.nominal_cut_hz(self._hz(corner), self._sample_rate) + + +class PostCutDelay(DigitalDelay): + """T5's compounding clause: Repeat Cut moved out of the loop onto the + output - the same one-pole, the same pre-warp, applied once. One pass + is identical to the class; the repeats do not compound.""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + self._post = 0.0 + self._hp = [0.0, 0.0] + DigitalDelay._build(self, *arguments, **options) + self._output = _PostHighPass(self) + + def _cut_hz(self, position): + self._post = DigitalDelay._cut_hz(self, position) + return 0.0 + + +class _PostHighPass: + def __init__(self, owner): + self._owner = owner + node = owner._delay + self.sample_rate = node.sample_rate + self.channel_count = node.channel_count + self.bits_per_sample = 16 + self.samples_signed = True + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + audiocore.reset_buffer(self._owner._delay) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + owner = self._owner + result, data = audiocore.get_buffer(owner._delay) + raw = bytes(data) + if not raw or owner._post <= 0.0: + return result, memoryview(raw) + a = 1.0 - math.exp(-2.0 * math.pi * owner._post / owner._sample_rate) + v = np.frombuffer(raw, dtype="= 0, np.trunc(out + 0.5), + np.trunc(out - 0.5)), -32768, 32767) + return result, memoryview(out.astype(" 0.0: - nominal = int(abs(target_ms - start_ms) / slew * rate / 1000.0) - frames = move_at + nominal + int(0.3 * rate) - source = array_src(sine_values(997.0, frames, rate, 12000), 2, rate) +def t1_quiet(cls, dbfs, rate=RATE, channels=2, **options): + """WIRE over the first T - 1 frames on `noise_det` at `dbfs` peak: the + row's quieter levels, where `OneLsbScale` cannot show.""" + options.setdefault("glide_ms", 4000.0) + effect_probe = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + frames_t = effect_probe._frames + effect_probe.deinit() + total = frames_t + 256 + data = probes.noise_det(frames=total, dbfs=dbfs, channels=channels) + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate, **options) + out = pull(effect, total, channels) + src = np.array(data, dtype=np.int16) + window = (frames_t - 1) * channels + differing = int(np.count_nonzero(out[:window] != src[:window])) + return {"passed": differing == 0, "differing": differing, + "peak": int(np.abs(src).max())} + + +def dossier_slew(glide_ms): + """The dossier's section 6 Glide law, written out here and never taken + from the class: 787.5 ms over `glide_ms`, pinned at 0.99, 0 the jump.""" + glide_ms = float(glide_ms) + if glide_ms <= 0.0: + return 0.0 + return min(0.99, 787.5 / glide_ms) + + +def glide_of_grid(grid): + """The Glide knob's label at a grid position: log 800-8000 ms, grid 0 + the jump (dossier section 6).""" + if grid <= 0: + return 0.0 + return 800.0 * 10.0 ** (grid / 127.0) + + +def ls_hz(segment, rate): + """The frequency whose sine, with a phase and a DC term, fits `segment` + best in least squares. It reads only the samples it is given - the + analytic signal's FFT reads the whole render, which is what failed on + the near-stall rising cells - and it takes no law: the search starts + at the segment's own spectral peak, walks in 10-cent steps until the + minimum is inside the bracket, then narrows to 1 cent and a golden + section.""" + seg = np.asarray(segment, dtype=np.float64) + n = len(seg) + t = np.arange(n) / float(rate) + ones = np.ones(n) + + def residual(cents_off, base): + f = base * 2.0 ** (cents_off / 1200.0) + w = 2.0 * math.pi * f * t + basis = np.stack([np.sin(w), np.cos(w), ones], axis=1) + coef = np.linalg.lstsq(basis, seg, rcond=None)[0] + return float(np.sum((seg - basis.dot(coef)) ** 2)) + + pad = 1 << int(math.ceil(math.log(max(16 * n, 1 << 16), 2))) + spec = np.abs(np.fft.rfft((seg - seg.mean()) * np.hanning(n), pad)) + spec[0] = 0.0 + base = max(float(np.argmax(spec)) * rate / pad, 5.0) + centre = 0.0 + for _ in range(40): + offsets = centre + np.arange(-300.0, 301.0, 10.0) + errors = [residual(c, base) for c in offsets] + k = int(np.argmin(errors)) + centre = float(offsets[k]) + if 0 < k < len(offsets) - 1: + break + offsets = centre + np.arange(-10.0, 10.5, 1.0) + centre = float(offsets[int(np.argmin([residual(c, base) + for c in offsets]))]) + lo, hi = centre - 1.0, centre + 1.0 + g = (math.sqrt(5.0) - 1.0) / 2.0 + a, b = hi - g * (hi - lo), lo + g * (hi - lo) + fa, fb = residual(a, base), residual(b, base) + for _ in range(30): + if fa < fb: + hi, b, fb = b, a, fa + a = hi - g * (hi - lo) + fa = residual(a, base) + else: + lo, a, fa = a, b, fb + b = lo + g * (hi - lo) + fb = residual(b, base) + return base * 2.0 ** ((lo + hi) / 2400.0) + + +def t2_render(cls, rate, values, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames): + source = array_src(values, 2, rate) effect = cls(source, sample_rate=rate, time_ms=start_ms, feedback=0.0, mix=2.0, glide_ms=glide_ms) + if glide_grid is not None: + effect.set_macro(GLIDE_I, glide_grid) def move(frame): if target_ms is not None and frame == move_at: effect.set_macro(TIME_I, midi_of_ms(target_ms)) - out = left(pull(effect, frames, 2, on_block=move), 2) - return out, nominal + return left(pull(effect, frames, 2, on_block=move), 2) def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, - target_ms=150.0, move_at=20480): - """The wet pitch against w_s (1 - dD) during the walk, the residual - 50-250 ms after it, and the no-step clause in three windows: the walk - (less a 128-frame margin, inside which the node's float32 walk ends; - A8.1 reads it ending up to 105 frames early) against the signed bar - |1 - dD| x the unramped maximum, the margin against the looser of the - two legitimate slopes, and the frames after against the unramped - maximum, each with 5 % for phase sampling.""" - slew = dd.slew_of(glide_ms) - y, nominal = t2_render(cls, rate, glide_ms, start_ms, target_ms, move_at) - base, _ = t2_render(cls, rate, glide_ms, start_ms, None, move_at) - d_base = float(np.abs(np.diff(base[move_at:])).max()) + target_ms=150.0, move_at=20480, glide_grid=None, level=12000): + """T2's three clauses at one cell, against the dossier's own law. + + The law is w_s (1 - dD) with dD = 787.5 / (the Glide asked) - the + constructor's `glide_ms`, or the knob's label at `glide_grid` - never + the class's own `slew_of`. The walk's end is read off a ramp render of + the same move (the node's float32 walk ends early, A8.1). Pitch: the + least-squares frequency of the walk less a margin of min(400, walk/10) + frames at each end; residual: the same fit 50-250 ms after the walk. + No-step: the largest first difference in the walk against + 1.05 |1 - dD| x the unramped maximum, a gap past the ramp's end against + the looser legitimate slope, and 2000 frames after against the unramped + maximum. `body` is the walk's largest step outside its last block, so + a staircase's one remainder jump at the end is reported apart.""" + asked = glide_ms if glide_grid is None else glide_of_grid(glide_grid) + slew = dossier_slew(asked) falling = target_ms < start_ms ratio = (1.0 + slew) if falling else (1.0 - slew) - end = move_at + nominal - walk = float(np.abs(np.diff(y[move_at - 1:end - 128 + 1])).max()) - gap = float(np.abs(np.diff(y[end - 128:end + 64 + 1])).max()) - after = float(np.abs(np.diff(y[end + 64:end + 64 + 2000])).max()) - hz = inst_hz(y, rate) - during = float(np.median(hz[move_at + 400:end - 400])) - residual = float(np.median(hz[end + int(0.05 * rate): - end + int(0.25 * rate)])) - pitch_error = cents(during / (997.0 * ratio)) - residual_error = cents(residual / 997.0) - passed = (abs(pitch_error) <= 10.0 and abs(residual_error) <= 1.0 - and walk <= 1.05 * ratio * d_base - and gap <= 1.05 * max(ratio, 1.0) * d_base - and after <= 1.05 * d_base) - return {"passed": passed, "pitch_cents": pitch_error, - "residual_cents": residual_error, "walk": walk, "gap": gap, - "after": after, "unramped": d_base, - "bar": 1.05 * ratio * d_base} + nominal = (int(abs(target_ms - start_ms) / slew * rate / 1000.0) + if slew > 0.0 else 0) + frames = move_at + nominal + int(0.35 * rate) + 4096 + sine = sine_values(997.0, frames, rate, level) + y = t2_render(cls, rate, sine, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames) + base = t2_render(cls, rate, sine, start_ms, None, glide_ms, glide_grid, + move_at, frames) + d_base = float(np.abs(np.diff(base[move_at:])).max()) + k = max(1, -(-frames // 60000)) + ramp = [i // k - 30000 for i in range(frames)] + r = t2_render(cls, rate, ramp, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames) + delay = np.arange(frames) - k * (r + 30000.0) + target_frames = int(math.floor(target_ms * rate / 1000.0 + 0.5)) + short = np.where(np.abs(delay[move_at:] - target_frames) > 2 * k + 2)[0] + end = move_at + (int(short.max()) + 1 if len(short) else 0) + walked = end - move_at + result = {"slew": slew, "ratio": ratio, "unramped": d_base, + "walk_frames": walked, "nominal": nominal, "law": "dossier", + "bar": 1.05 * ratio * d_base} + if slew <= 0.0 or walked < 64 or end + 4096 > len(y): + step = float(np.abs(np.diff(y[move_at - 1:move_at + 256])).max()) + result.update({"passed": False, "walk": step, "gap": step, + "after": step, "body": step, "end": None, + "bar_gap": result["bar"], "bar_after": result["bar"], + "pitch_cents": None, "residual_cents": None, + "why": "no walk read off the ramp"}) + return result + a = move_at + walked // 10 + b = end - walked // 10 + result["slew_measured"] = ( + abs(float(np.polyfit(np.arange(a, b), delay[a:b], 1)[0])) + if b - a > 10 else float("nan")) + slack = 2 * k + 2 + margin = int(math.ceil((slack + (slack - 2) // 2) / slew)) + 2 + walk = float(np.abs(np.diff(y[move_at - 1:end + 1])).max()) + body = float(np.abs(np.diff(y[move_at - 1:max(move_at, end - BLOCK) + + 1])).max()) + end_jump = float(np.abs(np.diff(y[max(move_at, end - BLOCK) - 1: + end + 1])).max()) + gap = float(np.abs(np.diff(y[end:end + margin + 1])).max()) + after = float(np.abs(np.diff(y[end + margin:end + margin + 2000])).max()) + edge = min(400, walked // 10) + window = walked - 2 * edge + pitch = cents(ls_hz(y[move_at + edge:end - edge], rate) + / (997.0 * ratio)) + settled = end + margin + residual = cents(ls_hz(y[settled + int(0.05 * rate): + settled + int(0.25 * rate)], rate) / 997.0) + bar_gap = 1.05 * max(ratio, 1.0) * d_base + bar_after = 1.05 * d_base + passed = (abs(pitch) <= 10.0 and abs(residual) <= 1.0 + and walk <= result["bar"] and gap <= bar_gap + and after <= bar_after) + result.update({"passed": passed, "pitch_cents": pitch, + "residual_cents": residual, "walk": walk, "body": body, + "end": end_jump, "gap": gap, "after": after, + "bar_gap": bar_gap, "bar_after": bar_after, + "window": window}) + return result def t3_burst(rate): @@ -465,10 +753,13 @@ def t5_out_identical(cls, rate=RATE, channels=2): effect = cls(probes.ArraySource(noise, rate=rate, channels=channels, block=BLOCK), sample_rate=rate, time_ms=12.5, feedback=0.6, mix=2.0) + node_ms = effect._node_ms out = pull(effect, frames, channels) + # The reference takes the class's whole-frame Time (12.5 ms is 551.25 + # frames at 44.1 kHz; the class lands it on 551, dossier section 6). reference = dd.audioecho.FeedbackDelay( sample_rate=rate, channel_count=channels, max_delay_ms=801.0, - delay_ms=12.5, feedback=0.6, mix=2.0) + delay_ms=node_ms, feedback=0.6, mix=2.0) reference.play(probes.ArraySource(noise, rate=rate, channels=channels, block=BLOCK)) ref = array("h") @@ -496,6 +787,218 @@ def t5_measure(cls, rate=RATE): "differing": differing} +def t5_compound(cls, rate=RATE, level=8192, settings=None, **options): + """T5's compounding clause: the T3 burst at Feedback 0.8, Mix 2, Time + 350.0 ms; repeat 8's share of each band against repeat 1's, in dB, for + 20-100 Hz (the Cut clause's band), 100 Hz-1 kHz, 1-4 kHz and + 4 kHz-Nyquist.""" + n = int(rate * 50.0 / 1000.0) + burst = np.round(np.random.RandomState(12345).uniform(-1.0, 1.0, n) + * level).astype(np.int16) + opts = {"time_ms": 350.0, "feedback": 0.8, "mix": 2.0, + "glide_ms": 4000.0} + opts.update(options) + frames_t = dd.whole_frames(350.0, rate) + total = 9 * frames_t + n + rate // 10 + values = np.zeros(total, dtype=np.int16) + values[:n] = burst + effect = cls(array_src(values.tolist(), 2, rate), sample_rate=rate, + **opts) + for index in sorted(settings or {}): + effect.set_macro(index, settings[index]) + y = left(pull(effect, total, 2), 2) + bands = ((20.0, 100.0), (100.0, 1000.0), (1000.0, 4000.0), + (4000.0, rate / 2.0 + 1.0)) + + def shares(segment): + spec = np.abs(np.fft.rfft(segment)) ** 2 + freqs = np.fft.rfftfreq(len(segment), 1.0 / rate) + total_energy = spec[freqs >= 20.0].sum() + return [spec[(freqs >= lo) & (freqs < hi)].sum() / total_energy + for lo, hi in bands] + + first = shares(y[frames_t:frames_t + n]) + eighth = shares(y[8 * frames_t:8 * frames_t + n]) + return {"last": [10.0 * math.log10(e / f) for e, f in zip(eighth, first)]} + + +def railed_samples(cls, dbfs, seconds=1.0, rate=RATE, **options): + """The input ceiling's measurement: `noise_det` at `dbfs` peak, 48 kHz + stereo; output samples on the int16 rail that are not on it in the + source.""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=dbfs, channels=2) + effect = cls(probes.ArraySource(data, rate=rate, channels=2, block=BLOCK), + sample_rate=rate, **options) + out = pull(effect, frames, 2).astype(np.int64) + src = np.array(data, dtype=np.int64) + rail = (out >= 32767) | (out <= -32768) + return int(np.count_nonzero(rail & ~((src >= 32767) | (src <= -32768)))) + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed, or what the output does, at the +# position walked. The twin's `FeedbackDelay.set` is watched while a walk +# runs, so a reading sees the options the class actually handed over. + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node: `_handed` (the latest value of each option) and + `_writes` (each call's options, in order).""" + + def __enter__(self): + node_class = dd.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._writes = [] + node._handed.update(options) + node._writes.append(dict(options)) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def copy_of(effect, source, **ctor): + """A fresh instance of the same class on `source`, at `effect`'s macro + positions.""" + other = type(effect)(source, sample_rate=effect._sample_rate, **ctor) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + return other + + +def read_dry_gain(effect): + """The dry path's gain before the first repeat: a copy at these + positions on a 256-frame full-scale ramp, least-squares out / in.""" + ramp = probes.ramp_fs(frames=256, channels=2) + other = copy_of(effect, probes.ArraySource( + ramp, rate=effect._sample_rate, channels=2, block=BLOCK)) + out = pull(other, 256, 2).astype(np.float64) + src = np.array(ramp, dtype=np.float64) + other.deinit() + return round(float(np.dot(out, src) / np.dot(src, src)), 5) + + +def read_python_steps(effect): + """How many times the node's `delay_ms` is written while eight blocks + are pulled after one Time move, on a copy at these positions: the class + writes it once, on the move; a Python-stepped Time writes it every + block.""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(16 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + time_now = other.get_macro(TIME_I) + mark = len(other._delay._writes) + other.set_macro(TIME_I, time_now + 32.0 if time_now < 64 else + time_now - 32.0) + pull(other, 8 * BLOCK, 2) + writes = sum(1 for w in other._delay._writes[mark:] if "delay_ms" in w) + other.deinit() + return writes + + +def read_landing_fraction(effect): + """The handed delay's distance from a whole frame, landed the node's way + (float32 `value * rate / 1000`, `audiodsp_feedback_delay.c:148`).""" + ms = np.float32(effect._delay._handed["delay_ms"]) + frames = float(ms * np.float32(effect._sample_rate) / np.float32(1000.0)) + return round(abs(frames - round(frames)), 2) + + +def read_map_error(effect): + """The handed delay's whole frames against the dossier's T4 law at the + knob's own position.""" + rate = effect._sample_rate + frames = int(math.floor(effect._delay._handed["delay_ms"] * rate / 1000.0 + + 0.5)) + return frames - mapped_frames(effect._macros[TIME_I], rate) + + +def read_damping(effect): + """The `damping_hz` handed to the node now.""" + return round(float(effect._delay._handed["damping_hz"]), 1) + + +def read_corner_ratios(effect): + """Each filter's handed coefficient against the pre-warp of its label + at the knob's position (1.0 at an out stop and wherever they agree).""" + rate = effect._sample_rate + handed = effect._delay._handed + position = effect._macros[TONE_I] + if position >= 1.0: + tone = 1.0 if handed["damping_hz"] == 0.0 else 0.0 + else: + label = 800.0 * 20.0 ** position + tone = handed["damping_hz"] / nominal_damping_hz(effect._hz(label), + rate) + position = effect._macros[CUT_I] + if position <= 0.0: + cut = 1.0 if handed["cut_hz"] == 0.0 else 0.0 + else: + label = 20.0 * 20.0 ** position + cut = handed["cut_hz"] / dd.nominal_cut_hz(effect._hz(label), rate) + return (round(tone, 3), round(cut, 3)) + + +def read_cut_placement(effect): + """(the `cut_hz` handed to the loop, the high-pass applied outside it).""" + return (round(float(effect._delay._handed["cut_hz"]), 3), + round(float(getattr(effect, "_post", 0.0)), 3)) + + +def read_slew_against_label(effect): + """The `delay_slew` handed to the node against the dossier's law at the + Glide the knob's label says (the constructor's exact value while the + knob has not moved).""" + handed = effect._delay._handed["delay_slew"] + if effect._glide_exact is not None: + asked = effect._glide_exact + else: + asked = glide_of_grid(127.0 * effect._macros[GLIDE_I]) + law = dossier_slew(asked) + if law == 0.0: + return 1.0 if handed == 0.0 else 0.0 + return round(handed / law, 4) + + +#: (name, fault, reading, constructor options for both builds). The corner +#: and compounding faults are built at their corner cells (Tone 7 kHz, Cut +#: 40 Hz), because both filters are out at the defaults. +REACH_WALKS = ( + ("DryScaledDelay", DryScaledDelay, read_dry_gain, {}), + ("DryGainDelay", DryGainDelay, read_dry_gain, {}), + ("StaircaseDelay", StaircaseDelay, read_python_steps, {}), + ("HalfGlideMsDelay", HalfGlideMsDelay, read_slew_against_label, {}), + ("HalfFrameDelay", HalfFrameDelay, read_landing_fraction, {}), + ("LinearMapDelay", LinearMapDelay, read_map_error, {}), + ("RawTopToneDelay", RawTopToneDelay, read_damping, {}), + ("CornerShiftDelay", CornerShiftDelay, read_corner_ratios, + {"tone_hz": 7000.0, "cut_hz": 40.0}), + ("PostCutDelay", PostCutDelay, read_cut_placement, {"cut_hz": 40.0}), +) + + +def reach(faulted, reading, rate, ctor): + """`kit_faults.fault_reachability` at `rate`, the node watched.""" + def build(cls): + return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(DigitalDelay, faulted, reading, + build) + + # -------------------------------------------------------------------------- # The surface @@ -612,6 +1115,17 @@ def test_a_coloured_dry_is_red(self): result = t1_measure(DryScaledDelay) self.assertFalse(result["passed"]) self.assertGreater(result["differing"], 0) + result = t1_measure(DryGainDelay) + self.assertFalse(result["passed"]) + + def test_the_quieter_levels_have_a_fault_leg(self): + # -20 and -40 dBFS: the clean class is a wire, OneLsbScale cannot + # show there (it is the identity below 16 384 LSB), and the +0.1 dB + # fault does. + for dbfs in (-20.0, -40.0): + self.assertTrue(t1_quiet(DigitalDelay, dbfs)["passed"], dbfs) + self.assertEqual(t1_quiet(DryScaledDelay, dbfs)["differing"], 0) + self.assertGreater(t1_quiet(DryGainDelay, dbfs)["differing"], 0) class T2TimeResamples(unittest.TestCase): @@ -625,10 +1139,27 @@ def test_a_rising_move_at_the_default_glide(self): target_ms=200.0) self.assertTrue(result["passed"], result) + def test_a_near_stall_rising_cell(self): + # Glide grid 2, 150 -> 200 ms: the law's ratio is 0.051 (a 50 Hz + # tone, 1.7 cycles in the window). The analytic-signal median read + # -15.00 c here; the least-squares fit reads the node. + result = t2_measure(DigitalDelay, glide_grid=2, start_ms=150.0, + target_ms=200.0) + self.assertTrue(result["passed"], result) + + def test_the_constructor_800_ms_cell(self): + result = t2_measure(DigitalDelay, glide_ms=800.0) + self.assertTrue(result["passed"], result) + + def test_a_wrong_glide_law_is_red(self): + result = t2_measure(HalfGlideMsDelay) + self.assertFalse(result["passed"], result) + self.assertGreater(result["pitch_cents"], 200.0) + def test_the_block_staircase_is_red(self): result = t2_measure(StaircaseDelay, glide_ms=4000.0) self.assertFalse(result["passed"], result) - self.assertGreater(result["walk"], result["bar"]) + self.assertGreater(result["body"], result["bar"]) class T3NoDarkening(unittest.TestCase): @@ -699,8 +1230,28 @@ def test_the_in_circuit_stops(self): t5_gain_db(DigitalDelay, 400, cut_hz=400.0), -3.01, delta=0.2) def test_an_open_top_stop_is_red(self): - self.assertGreater(t5_out_identical(OpenTopToneDelay), 0) - self.assertFalse(t5_measure(OpenTopToneDelay)["passed"]) + for rate in (48000, 44100, 22050): + self.assertEqual(t5_out_identical(DigitalDelay, rate), 0, rate) + self.assertGreater(t5_out_identical(RawTopToneDelay, rate), 0, + rate) + self.assertFalse(t5_measure(RawTopToneDelay)["passed"]) + + def test_the_corners_shifted_are_red(self): + # 15 % high puts the 7 kHz corner at 8 050 Hz, so 7 700 Hz is still + # less than 3 dB down, and the 40 Hz corner at 46 Hz. + result = t5_measure(CornerShiftDelay) + self.assertFalse(result["passed"], result) + self.assertGreater(result["tone"][7700], -3.0) + + def test_the_cut_compounds_in_the_loop(self): + # Repeat Cut at its 400 Hz stop, Feedback 0.8: repeat 8's 20-100 Hz + # share at least 6 dB under repeat 1's (the fix round's bar, dated + # in the dossier); moved out of the loop it does not compound. + for rate in (48000, 22050): + clean = t5_compound(DigitalDelay, rate, settings={CUT_I: 127}) + self.assertLessEqual(clean["last"][0], -6.0, (rate, clean)) + post = t5_compound(PostCutDelay, rate, settings={CUT_I: 127}) + self.assertGreater(post["last"][0], -1.0, (rate, post)) # -------------------------------------------------------------------------- @@ -857,37 +1408,99 @@ def transport(): self.assertGreater(len(reads), 0) +# -------------------------------------------------------------------------- +# Fix round 1: the audit's (o) items and the 0 bpm host + + +class InputCeiling(unittest.TestCase): + """The docstring's ceiling on `noise_det`, 48 kHz: the defaults clean at + -3 dBFS peak, patch 3 (the first shipped patch to rail) at -4; 1 dB + over each rails.""" + + def test_the_stated_ceiling_is_clean_and_one_db_over_is_not(self): + for options, ceiling in (({}, -3.0), ({"patch": 3}, -4.0)): + self.assertEqual(railed_samples(DigitalDelay, ceiling, + **options), 0, options) + self.assertGreater(railed_samples(DigitalDelay, ceiling + 1.0, + **options), 0, options) + + +class GlideRoundTrip(unittest.TestCase): + def _round_trip(self, cls, glide_ms): + effect = cls(silence_src(512), sample_rate=RATE, glide_ms=glide_ms) + before = dd.slew_of(effect._glide_ms()) + effect.set_macro(GLIDE_I, effect.get_macro(GLIDE_I)) + return before, dd.slew_of(effect._glide_ms()) + + def test_get_macro_hands_back_the_glide(self): + for glide_ms in (500.0, 800.0, 4000.0): + before, after = self._round_trip(DigitalDelay, glide_ms) + self.assertGreater(before, 0.0) + self.assertLessEqual(abs(after / before - 1.0), 0.02, glide_ms) + effect = DigitalDelay(silence_src(512), sample_rate=RATE, + glide_ms=0.0) + self.assertEqual(effect.get_macro(GLIDE_I), 0.0) + + def test_the_jump_seed_is_red(self): + before, after = self._round_trip(JumpSeedGlideDelay, 800.0) + self.assertGreater(before, 0.9) + self.assertEqual(after, 0.0) + + +class ZeroBpmHost(unittest.TestCase): + def test_no_tempo_leaves_time_on_the_knob(self): + for bpm in (0.0, None): + def transport(_bpm=bpm): + return (True, 0.0, _bpm, 4, 4) + measured, _ = t4_delay(DigitalDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + self.assertLessEqual(abs(measured - 16800), 1.0, bpm) + measured, _ = t4_delay(ZeroBpmDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + self.assertLessEqual(abs(measured - 12000), 1.0, bpm) + + +class RepeatToneKnee(unittest.TestCase): + """Repeat Tone's clamp: at 22.05 kHz positions 111-126 sit on the + 10 804.5 Hz ceiling and 127 is out; at 44.1 and 48 kHz every position + moves.""" + + def _dampings(self, rate): + effect = DigitalDelay(silence_src(64, 2, rate), sample_rate=rate) + return [effect._tone_damping(p / 127.0) for p in range(128)] + + def test_the_knee(self): + values = self._dampings(22050) + self.assertLess(values[110], values[111]) + self.assertEqual(len(set(values[111:127])), 1) + self.assertEqual(values[127], 0.0) + for rate in (48000, 44100): + values = self._dampings(rate) + self.assertTrue(all(b > a for a, b in zip(values[:126], + values[1:127])), rate) + self.assertEqual(values[127], 0.0) + + # -------------------------------------------------------------------------- # The two checks every planted fault and every row is held to class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, at 48, 44.1 and 22.05 kHz, reading + what the node is handed (or what the output does) at each position.""" + CHECKED = 8 * 17 + 6 - def _reach(self, faulted, reading): - return kit_faults.fault_reachability( - DigitalDelay, faulted, reading, - lambda cls: cls(silence_src(512), sample_rate=RATE)) - - def test_every_fault_is_off_the_surface(self): - for faulted, reading, expected in ( - (DryScaledDelay, - lambda e: getattr(e, "_plant_dry_scale", False), True), - (StaircaseDelay, - lambda e: round(getattr(e, "_step_per_block", 0.0), 6), - round(0.196875 * BLOCK * 1000.0 / RATE, 6)), - (HalfFrameDelay, - lambda e: round(abs(e._node_ms * e._sample_rate / 1000.0 - - round(e._node_ms * e._sample_rate - / 1000.0)), 6), 0.5), - (LinearMapDelay, lambda e: round(e._time_map(0.5), 6), - 406.25), - (OpenTopToneDelay, lambda e: round(e._tone_damping(1.0), 3), - round(nominal_damping_hz(16000.0, RATE), 3))): - with self.subTest(fault=faulted.__name__): - result = self._reach(faulted, reading) - self.assertEqual(result["target"], expected) - self.assertEqual(result["checked"], self.CHECKED) + def test_every_fault_is_off_the_surface_at_three_rates(self): + for rate in (48000, 44100, 22050): + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + + def test_the_old_out_stop_fault_is_dialled_at_22k(self): + with self.assertRaises(kit_faults.FaultReachable): + reach(OpenTopToneDelay, read_damping, 22050, {}) class NullBuildRed(unittest.TestCase): From 44fa09d3d0b637809651aa0739adb0884e372104 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:33:15 -0500 Subject: [PATCH 05/88] DigitalDelay fix round 2: the audit's list, class side Gate audit round 2 parked the class; this answers the items the class can. - Input ceiling: the docstring says -3.1 dBFS at the defaults (was -3), and the any-material bound is one LSB under floor(32767 (1 - Mix)), with the rail value reachable by rounding at the edge. InputCeiling renders 20 s and is red at -3.0 (4 railed samples); a DC test holds the bound. - Glide readback: a constructor Glide faster than grid 1 seeds the knob at grid 1 (GLIDE_FLOOR = 1/127), so a 7-bit round trip through macro_of keeps slew 0.966689. The new test failed on 5072ddf's class (500 ms landed slew 0) and is red on NearZeroSeedGlideDelay (the 1e-9 seed). - Tempo: NaN and infinite bpm leave Time on the knob. The new test failed on 5072ddf's class (NaN landed 600 frames) and is red on NanBpmDelay. - T5: PostToneDelay (the re-refuter's fault) is in the tests and in FaultsAreUnreachable at three rates. - T2: the docstring names what the row no longer claims (rising, strictly between Glide grid 1 and 2, and constructor Glides under 829.5 ms but 800), with a float32 model of the node's walk and rendered fractional positions as tests. Still parked on the node's floor bug (audiodsp to_s16), which needs an audiodsp release and a pin move. --- lib/audioeffects/rebuilt/digitaldelay.py | 75 ++++-- tests/test_cpython_effects_digitaldelay.py | 264 ++++++++++++++++++++- 2 files changed, 304 insertions(+), 35 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 5c88d51..4b74f53 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -75,23 +75,41 @@ and position 127 takes the filter out. At 44.1 and 48 kHz every position moves. -A constructor `glide_ms` faster than the knob's span (under 800 ms, down to -the 0.99 pin) stays on the audio path, and the knob reads back just above -its bottom stop, never at grid 0, the jump: handing `get_macro(3)` back to -`set_macro(3, ...)` keeps the glide. +A constructor `glide_ms` faster than the knob's fastest walk (under +814.6 ms, down to the 0.99 pin) stays on the audio path, and the knob +reads back at grid 1, the fastest walk it has, never at grid 0, the jump: +handing `get_macro(3)` back to `set_macro(3, ...)`, even rounded to a +7-bit MIDI value, keeps the glide (at grid 1's slew, 0.967). + +**Where the pitch claim stops.** The node walks the read head in single +precision, so the rate it plays is the Glide's rate rounded to the float +step of the delay. Near a stall that rounding is worth several cents: on +a rising Time at the Glide knob's fastest 1 %, strictly between grid 1 and +grid 2 (814.6-829.5 ms), the repeats can read up to 15 cents off the +glide law at 48 kHz and 11.5 at 44.1 kHz. That part of the knob is not +claimed on a rising move; grid 1, grid 2 and everything slower are. +Nor is a constructor Glide under 829.5 ms on a rising move, except +800 ms (slew 63/64, which single precision holds exactly): at the 0.99 +pin a rising Time can read 41 cents off. The rising move's pitch and no-step claims are measured on inputs from +-8.7 to -0.2 dBFS; quieter, a near-stall rising glide is a few LSB of +signal and int16 rounding decides the reading. **Input ceiling.** The dry path sits at unity and the repeats add to it, so a hot input can put the output on the int16 rail; there is no input gain -to turn down. Measured on the kit's `noise_det` at 48 kHz, the defaults are -clean up to -3 dBFS peak and the shipped patches up to -4 dBFS (patch 3, -Mix 0.5, rails first). On any material, with Repeat Cut out and Mix below -1, an input peaking at or below (1 - Mix) of full scale cannot reach the +to turn down. Measured on the kit's `noise_det` at 48 kHz over 20 s, the +defaults are clean up to -3.1 dBFS peak and the shipped patches up to +-4 dBFS (patch 3, Mix 0.5, rails first). At -3.0 dBFS the defaults put a +few samples on the rail over 4 s and more. On any material, with Repeat +Cut out and Mix below 1, an input peaking at or below one LSB under +(1 - Mix) of full scale, floor(32767 (1 - Mix)) - 1, cannot reach the rail at any Time or Feedback, because the line holds int16 and so the repeats never exceed Mix x full scale: -3.1 dBFS at the default Mix 0.3, --6.1 dBFS at patch 3. High Feedback does not keep building past that: at Feedback 0.99 -the line saturates, and the defaults' noise_det ceiling is -4 dBFS after -6 s and after 20 s alike. Repeat Cut's high-pass can overshoot a peak, so -with it in circuit leave more room. +-6.1 dBFS at patch 3. At exactly (1 - Mix) of full scale the sum can round +onto 32767, the rail value, though nothing is clipped. High Feedback does +not keep building past that: at Feedback 0.99 the line saturates, and the +defaults' noise_det ceiling is -4 dBFS after 6 s and after 20 s alike. +Repeat Cut's high-pass can overshoot a peak, so with it in circuit leave +more room. **Tail.** `tail_samples` is an upper bound on how long the repeats take to reach exact zero after your input stops, and it is long: the loop has to @@ -106,8 +124,9 @@ `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). -With no host transport, or a host that reports no tempo (0 bpm), Time stays -where the knob is. +With no host transport, or a host whose tempo is not a finite positive +number (0, negative, NaN, infinite or missing), Time stays where the knob +is. """ VENDOR = "PyDevices" @@ -136,10 +155,11 @@ #: grid position gets there (grid 1 is slew 0.967). SLEW_PIN = 0.99 -#: The Glide knob's position for a constructor Glide at or under 800 ms: -#: just above grid 0, which is the jump. It reads back as MIDI 1.27e-7 and -#: stands for 800.0 ms (slew 0.984). -GLIDE_FLOOR = 1e-9 +#: The Glide knob's position for a constructor Glide faster than grid 1: +#: grid 1 itself (814.6 ms, slew 0.967), the knob's fastest walk. Grid 0 is +#: the jump, and a position just above it reads back as a MIDI value that +#: rounds to 0 on any 7-bit path (fix round 2). +GLIDE_FLOOR = 1.0 / 127.0 #: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). FEEDBACK_MAX = 0.99 @@ -348,14 +368,15 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, self._own(self._delay, reset=self._clear) self._delay.play(self._source) self._output = self._delay - # The knob is seeded at its bottom (800 ms) for a faster constructor - # Glide; `_glide_exact` carries the real value. Position 0 is the - # jump, so a Glide that is not 0 is seeded just above it, and a - # get_macro / set_macro round trip keeps it gliding. + # The knob is seeded where the constructor's Glide is, or at grid 1 + # for a faster one; `_glide_exact` carries the real value. Position + # 0 is the jump, so a Glide that is not 0 is never seeded below grid + # 1, and a get_macro / set_macro round trip keeps it gliding even + # when a host rounds it to a MIDI value. self._init_macros((time_ms, feedback, mix, max(800.0, glide_ms), 1.0 if sync else 0.0, float(division), tone_hz, cut_hz)) - if self._glide_exact > 0.0 and self._macros[GLIDE_I] <= 0.0: + if self._glide_exact > 0.0 and self._macros[GLIDE_I] < GLIDE_FLOOR: self._macros[GLIDE_I] = GLIDE_FLOOR self._seeding = False if patch is not None: @@ -418,10 +439,12 @@ def _synced_ms(self): transport, state = self._transport_state() if transport is _component.static_transport: return None - # A host that reports no tempo (0 or None) leaves Time on the knob, - # as the static transport does; it is never read as 120 bpm. + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob, as the static + # transport does; it is never read as 120 bpm. `not bpm > 0` catches + # NaN, and `bpm * 0` is NaN for infinity. bpm = float(state[2] or 0.0) - if bpm <= 0.0: + if not bpm > 0.0 or bpm * 0.0 != 0.0: return None index = int(round(self._value(DIVISION_I))) index = min(len(DIVISION_BEATS) - 1, max(0, index)) diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index e0bfa28..c854bdf 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -22,6 +22,15 @@ the output does) at the position walked. The input ceiling, the Glide round trip, a 0 bpm host and Repeat Tone's clamp at 22.05 kHz each have a test beside a planted fault. + +Fix round 2 (2026-09-27, after gate audit round 2): the input ceiling is +rendered over 20 s at -3.1 dBFS and shown red at the old -3.0; the Glide +readback survives a 7-bit round trip through `_component.macro_of`, beside +the round-2 seeding; a NaN or infinite tempo leaves Time on the knob, +beside the round-2 guard; T5's Tone-compounding fault `PostToneDelay` is +here and in the reachability walks; and T2's part of the knob the row no +longer claims (rising, strictly between Glide grid 1 and 2) is tested by a +float32 model of the node's walk and by rendered fractional positions. """ import math @@ -290,6 +299,32 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): return result, memoryview(out.astype("= 0, np.trunc(out + 0.5), + np.trunc(out - 0.5)), -32768, 32767) + return result, memoryview(out.astype(" 0.0 + and self._glide_exact <= 800.0): + self._macros[GLIDE_I] = 1e-9 + + +class NanBpmDelay(DigitalDelay): + """The round-2 tempo guard, `bpm <= 0.0`, which a NaN passes and an + infinite tempo passes too: both drag Time to the bottom of the knob.""" + + NAME = 'DigitalDelay' + + def _synced_ms(self): + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + bpm = float(state[2] or 0.0) + if bpm <= 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(dd.DIVISION_BEATS) - 1, max(0, index)) + return dd.DIVISION_BEATS[index] * 60000.0 / bpm + + +class PostToneDelay(DigitalDelay): + """T5's compounding clause, Tone half: Repeat Tone's one-pole low-pass + moved out of the loop onto the output - the same pre-warped + coefficient, applied once. One pass is identical to the class; the + repeats do not compound, so T3's control stops darkening. (The + re-refuter's fault, `digitaldelay_rerefute1_t5.py`, moved here at fix + round 2.)""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + self._post_lp = 0.0 + self._lp = [0.0, 0.0] + DigitalDelay._build(self, *arguments, **options) + self._output = _PostLowPass(self) + + def _tone_damping(self, position): + self._post_lp = DigitalDelay._tone_damping(self, position) + return 0.0 + + # -------------------------------------------------------------------------- # Planted faults for the Tier 1 checks the review round added @@ -631,6 +719,50 @@ def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, return result +def f32_walk_cents(rate, grids, start_ms=150.0, target_ms=200.0): + """A float32 model of the node's slew walk (`current += slew`, + `audiodsp_feedback_delay.c:408` / `:413` at v0.6.1), from `start_ms` to + `target_ms` in whole frames, at the dossier's law for each Glide grid + position in `grids`. Returns, per position, the cents the walk plays + off the law, read over the walk's middle 80 % (gate audit round 2's + model, `digitaldelay_audit2_f32scan.py`).""" + grids = np.asarray(grids, dtype=np.float64) + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + law = np.array([dossier_slew(glide_of_grid(g)) for g in grids]) + step = law.astype(np.float32) + rising = b > a + tgt = np.float32(b) + start = np.full(len(grids), a, np.float32) + + def advance(c): + if rising: + return np.minimum((c + step).astype(np.float32), tgt) + return np.maximum((c - step).astype(np.float32), tgt) + + n = np.zeros(len(grids), np.int64) + c = start.copy() + done = np.zeros(len(grids), bool) + k = 0 + while not done.all(): + k += 1 + c = np.where(done, c, advance(c)) + newly = (~done) & (c == tgt) + n[newly] = k + done |= newly + ia, ib = n // 10, n - n // 10 + c = start.copy() + pa = np.zeros(len(grids)) + pb = np.zeros(len(grids)) + for k in range(1, int(n.max()) + 1): + c = advance(c) + pa[ia == k] = c[ia == k] + pb[ib == k] = c[ib == k] + eff = np.abs(pb - pa) / (ib - ia) + ratio = (1.0 - eff) / (1.0 - law) if rising else (1.0 + eff) / (1.0 + law) + return 1200.0 * np.log2(ratio) + + def t3_burst(rate): n = int(rate * 50.0 / 1000.0) rng = np.random.RandomState(12345) @@ -957,6 +1089,13 @@ def read_cut_placement(effect): round(float(getattr(effect, "_post", 0.0)), 3)) +def read_tone_placement(effect): + """(the `damping_hz` handed to the loop, the low-pass applied outside + it).""" + return (round(float(effect._delay._handed["damping_hz"]), 3), + round(float(getattr(effect, "_post_lp", 0.0)), 3)) + + def read_slew_against_label(effect): """The `delay_slew` handed to the node against the dossier's law at the Glide the knob's label says (the constructor's exact value while the @@ -986,6 +1125,8 @@ def read_slew_against_label(effect): ("CornerShiftDelay", CornerShiftDelay, read_corner_ratios, {"tone_hz": 7000.0, "cut_hz": 40.0}), ("PostCutDelay", PostCutDelay, read_cut_placement, {"cut_hz": 40.0}), + ("PostToneDelay", PostToneDelay, read_tone_placement, + {"tone_hz": 7000.0}), ) @@ -1161,6 +1302,34 @@ def test_the_block_staircase_is_red(self): self.assertFalse(result["passed"], result) self.assertGreater(result["body"], result["bar"]) + def test_the_unclaimed_band_is_where_the_float32_walk_says(self): + # Fix round 2 (dossier section 8.11): the rising move is not claimed + # strictly between Glide grid 1 and grid 2. The float32 model of the + # node's walk, over the whole knob in steps of 0.01, puts every + # position more than 10 c off the law inside that band at every + # rate, and does find some there at 48 kHz (so the scan can fail). + grids = np.arange(100, 12701) / 100.0 + for rate in (48000, 44100, 22050): + off = grids[np.abs(f32_walk_cents(rate, grids)) > 10.0] + self.assertTrue(np.all((off > 1.0) & (off < 2.0)), + (rate, off[(off <= 1.0) | (off >= 2.0)])) + if rate == 48000: + self.assertGreater(len(off), 0) + falling = f32_walk_cents(48000, grids, 200.0, 150.0) + self.assertLess(float(np.abs(falling).max()), 10.0) + + def test_inside_the_band_is_red_and_outside_it_holds(self): + # Grid 1.245 is the model's worst position at 48 kHz: rendered, it + # reads about -15 c. Fractional positions outside the band pass. + inside = t2_measure(DigitalDelay, glide_grid=1.245, start_ms=150.0, + target_ms=200.0) + self.assertLess(inside["pitch_cents"], -10.0, inside) + for rate, grid in ((48000, 2.05), (48000, 3.55), (44100, 1.95), + (22050, 1.245)): + result = t2_measure(DigitalDelay, rate=rate, glide_grid=grid, + start_ms=150.0, target_ms=200.0) + self.assertTrue(result["passed"], (rate, grid, result)) + class T3NoDarkening(unittest.TestCase): def test_the_constructor_time_at_48k_and_22k(self): @@ -1253,6 +1422,16 @@ def test_the_cut_compounds_in_the_loop(self): post = t5_compound(PostCutDelay, rate, settings={CUT_I: 127}) self.assertGreater(post["last"][0], -1.0, (rate, post)) + def test_the_tone_compounds_in_the_loop(self): + # T3's control is the Tone half of the compounding clause: Repeat + # Tone 3 kHz takes repeat 8's 4 kHz-Nyquist share at least 20 dB + # under repeat 1's. Moved out of the loop it does not compound. + for rate in (48000, 44100, 22050): + clean = t3_measure(DigitalDelay, rate=rate, tone_hz=3000.0) + self.assertLessEqual(clean["high8"], -20.0, (rate, clean)) + post = t3_measure(PostToneDelay, rate=rate, tone_hz=3000.0) + self.assertGreater(post["high8"], -1.0, (rate, post)) + # -------------------------------------------------------------------------- # Tier 1, the fast half @@ -1413,16 +1592,37 @@ def transport(): class InputCeiling(unittest.TestCase): - """The docstring's ceiling on `noise_det`, 48 kHz: the defaults clean at - -3 dBFS peak, patch 3 (the first shipped patch to rail) at -4; 1 dB - over each rails.""" + """The docstring's ceiling on `noise_det`, 48 kHz stereo, over 20 s: + the defaults clean at -3.1 dBFS peak, patch 3 (the first shipped patch + to rail) at -4. Fix round 2: 1 s could not see the defaults rail at the + old -3.0 dBFS (4 samples over 20 s, the first at frame 169 739), so the + render is 20 s and -3.0 is the red leg.""" + + SECONDS = 20.0 - def test_the_stated_ceiling_is_clean_and_one_db_over_is_not(self): - for options, ceiling in (({}, -3.0), ({"patch": 3}, -4.0)): + def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): + for options, ceiling, over in (({}, -3.1, -3.0), + ({"patch": 3}, -4.0, -3.0)): self.assertEqual(railed_samples(DigitalDelay, ceiling, - **options), 0, options) - self.assertGreater(railed_samples(DigitalDelay, ceiling + 1.0, - **options), 0, options) + self.SECONDS, **options), 0, + options) + self.assertGreater(railed_samples(DigitalDelay, over, + self.SECONDS, **options), 0, + options) + + def test_the_any_material_bound(self): + # A DC one LSB under floor(32767 (1 - Mix)) never reaches the rail + # at Feedback 0.99; at floor(32767 (1 - Mix)) itself the sum rounds + # onto 32767 (the docstring says so: nothing clips). + for mix in (0.5, 0.3): + edge = int(math.floor(32767 * (1.0 - mix))) + for level, railed in ((edge - 1, False), (edge, mix == 0.5)): + values = [level] * (RATE // 2) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.99, mix=mix) + out = pull(effect, len(values)) + self.assertEqual(bool(np.any(out >= 32767)), railed, + (mix, level)) class GlideRoundTrip(unittest.TestCase): @@ -1433,10 +1633,13 @@ def _round_trip(self, cls, glide_ms): return before, dd.slew_of(effect._glide_ms()) def test_get_macro_hands_back_the_glide(self): + # A constructor Glide faster than grid 1 reads back as grid 1, so a + # float trip lands the knob's fastest walk (0.966689; from 500 ms's + # pinned 0.99 that is -2.36 %, the most the knob can hold). for glide_ms in (500.0, 800.0, 4000.0): before, after = self._round_trip(DigitalDelay, glide_ms) self.assertGreater(before, 0.0) - self.assertLessEqual(abs(after / before - 1.0), 0.02, glide_ms) + self.assertLessEqual(abs(after / before - 1.0), 0.025, glide_ms) effect = DigitalDelay(silence_src(512), sample_rate=RATE, glide_ms=0.0) self.assertEqual(effect.get_macro(GLIDE_I), 0.0) @@ -1446,8 +1649,51 @@ def test_the_jump_seed_is_red(self): self.assertGreater(before, 0.9) self.assertEqual(after, 0.0) + def _seven_bit_trip(self, cls, glide_ms): + """The Glide knob stored as a 7-bit value, the way a patch author + or a MIDI host stores it (`_component.macro_of` of `macro(3)`), and + handed back.""" + effect = cls(silence_src(512), sample_rate=RATE, glide_ms=glide_ms) + before = dd.slew_of(effect._glide_ms()) + midi = _component.macro_of(cls._MACRO_RANGES[GLIDE_I], + effect.macro(GLIDE_I)) + effect.set_macro(GLIDE_I, midi) + return before, midi, dd.slew_of(effect._glide_ms()) + + def test_a_seven_bit_trip_keeps_the_glide(self): + grid_1 = dossier_slew(glide_of_grid(1)) + for glide_ms in (500.0, 800.0, 810.0, 4000.0): + before, midi, after = self._seven_bit_trip(DigitalDelay, + glide_ms) + self.assertGreater(after, 0.0, glide_ms) + if before > grid_1: + # Faster than the grid can hold: grid 1, the fastest walk. + self.assertEqual(midi, 1, glide_ms) + self.assertAlmostEqual(after, 0.966689, places=6) + else: + self.assertLessEqual(abs(after / before - 1.0), 0.02, + glide_ms) + for glide_ms in (500.0, 800.0): + _, midi, after = self._seven_bit_trip(NearZeroSeedGlideDelay, + glide_ms) + self.assertEqual((midi, after), (0, 0.0), glide_ms) + class ZeroBpmHost(unittest.TestCase): + def test_a_tempo_that_is_not_finite_leaves_time_on_the_knob(self): + # Fix round 2: NaN and infinity passed the round-2 guard + # (`bpm <= 0.0`) and landed Time at 12.5 ms (600 frames). + for bpm in (float("nan"), float("inf"), float("-inf")): + def transport(_bpm=bpm): + return (True, 0.0, _bpm, 4, 4) + measured, _ = t4_delay(DigitalDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + self.assertLessEqual(abs(measured - 16800), 1.0, bpm) + measured, _ = t4_delay(NanBpmDelay, transport=transport, + sync=127, division=51, time_ms=350.0) + if bpm > 0.0 or bpm != bpm: + self.assertLessEqual(abs(measured - 600), 1.0, bpm) + def test_no_tempo_leaves_time_on_the_knob(self): for bpm in (0.0, None): def transport(_bpm=bpm): From 1be87833f800528ea9a75b174f23fb516ab379ba Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:35:00 -0500 Subject: [PATCH 06/88] DigitalDelay fix round 2: T2's level span is -8.7 to -0.2 dBFS on both directions A falling move at -48.7 dBFS reads 1.001-1.004 of its no-step bar at Glide grids 91, 112 and 113 (48 kHz, digitaldelay_fix2.py level), so the docstring states the span for the whole row, not the rising move only. --- lib/audioeffects/rebuilt/digitaldelay.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 4b74f53..4e6cb94 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -90,9 +90,10 @@ claimed on a rising move; grid 1, grid 2 and everything slower are. Nor is a constructor Glide under 829.5 ms on a rising move, except 800 ms (slew 63/64, which single precision holds exactly): at the 0.99 -pin a rising Time can read 41 cents off. The rising move's pitch and no-step claims are measured on inputs from --8.7 to -0.2 dBFS; quieter, a near-stall rising glide is a few LSB of -signal and int16 rounding decides the reading. +pin a rising Time can read 41 cents off. The pitch and no-step claims +are measured on inputs from -8.7 to -0.2 dBFS. Quieter, int16 rounding +decides the reading: a near-stall rising glide is a few LSB of signal, +and even a falling move at -48.7 dBFS can step 0.4 % past its bar. **Input ceiling.** The dry path sits at unity and the repeats add to it, so a hot input can put the output on the int16 rail; there is no input gain From e7226208fe67ba4fcf1292da56674ac1bbdae8b9 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:16:36 -0500 Subject: [PATCH 07/88] The audiodsp floor moves to v0.6.2, on Brad's instruction AUDIODSP_PIN names v0.6.2 (1c89b03). Beneath the components: audiopump.Tap gains a C stream reader (#152, no DSP node), and FeedbackDelay's fed-back term steps toward zero where rounding would stall (#154), so a tail reaches exact zero at every feedback. Ten assertions pinned the old floor. DigitalDelay's floor test now asserts the tail ends inside the lap bound at Feedback 0.5 (the class is untouched). CombFilter's parked-residue and parked-ring classes become TheTailReachesExactZero and TheRingIsTheAskedPitchAndEnds: the ring plays the asked fractional delay within 1 cent and ends inside the lap bound; the two-sample-short fault still reads 150.6 cents out. The un-reset-line control resets as the burst ends, so a line left full reads the rail rather than a line that drained itself. Each rewritten check is red at v0.6.1. Flanger's F8 audio T60 at Color max reads 1.91 s against its 2.0 s bar (2.13 s at v0.6.1). The bar is not moved: its two tests stay red with a message saying why, for the Phase 5 gate audit. CombFilter's docstring and catalogue row now say the tail reaches zero; TAIL_SAMPLES is still declared None. --- AUDIODSP_PIN | 36 +++- lib/audioeffects/README.md | 2 +- lib/audioeffects/combfilter.py | 61 ++++--- tests/test_cpython_effects_combfilter.py | 185 +++++++++++++++------ tests/test_cpython_effects_digitaldelay.py | 48 +++++- tests/test_cpython_effects_flanger.py | 17 +- 6 files changed, 257 insertions(+), 92 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 136e2de..16b551c 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -165,5 +165,39 @@ # The pyproject floors stay at >=0.5.1; raising them is a release-time # change and Phase 7's. # +# 2026-09-27: v0.6.1 -> v0.6.2 (1c89b03), on Brad's instruction, the same +# day: "merge it and release and move the pin ... Work unattended and +# continue with effects after the release and pin move." v0.6.2 is on +# TestPyPI and production PyPI (the first upload through audiodsp's own +# Trusted Publisher). What moved beneath the components between v0.6.1 and +# v0.6.2, by `git log v0.6.1..v0.6.2 -- src`: audiopump.Tap gained a C +# stream reader (#152; no DSP node touched), and audioecho.FeedbackDelay's +# fed-back term is truncated toward zero exactly where round-to-nearest +# could hand a repeat back unchanged (#154, `recirculated()`, +# src/shared/audiodsp_feedback_delay.c:344, called at :529), so a tail +# reaches exact zero at every feedback. Below feedback 0.5 the output is +# byte-identical; above it a tail's last few LSB differ, 1-3 LSB and at most +# 6 at 0.99. That fix turned ten assertions red here, each pinning the old +# floor: DigitalDelay's floor test, CombFilter's parked-residue, parked-ring +# and un-reset-line tests, and Flanger's F8 audio T60 at Color max, which +# reads 1.91 s against its 2.0 s bar (2.13 s at v0.6.1: the floor was +# holding the ring up). The first eight are rewritten to the fixed +# behaviour, each shown red at v0.6.1; Flanger's two stay red for the gate +# audit, because the bar is not the pin move's to lower. Re-run here at the +# new pin on Brad's workstation (Linux x86-64, Python 3.12, MicroPython +# v1.29.0 + overlay and CircuitPython 10.3.0 + the apply scripts, both +# rebuilt from v0.6.2): the unit suite `Ran 1601 tests ... FAILED +# (failures=2, skipped=2)`, the two being Flanger's F8 rows above; +# validate_api 55 instruments and 45 effects; validate_metadata valid; +# flake8 clean; the smoke 45 classes, 249 patches, 0 failures on CPython, +# MicroPython and CircuitPython, line for line what v0.6.1 printed. A +# burst-and-two-seconds census of the whole catalogue (every class and +# parked rebuild, constructor default and every patch, 316 cells; the anchor's +# docs/effects-internal/probes/phase5_probes/pin062_catalogue_digest.py) +# moved 18 cells, the same 18 with the same digests on all three +# interpreters: CombFilter (5), the parked Flanger (6), TapeDelay (3), +# AnalogDelay (2), PingPongDelay (1) and the parked DigitalDelay (1). +# Pending both boards, as v0.6.1 was. +# # -- ref is what CI installs (git+https://...@) -v0.6.1 094dde5203dadcbf30fe5f2b6a88c16e413799b8 +v0.6.2 1c89b032abb9e625390d634165a561683df81d14 diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 722ebe5..c29077b 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -128,7 +128,7 @@ marginal share of one block on each board at construction defaults. | `BandPass` | audiodsp (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audiodsp#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | -| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. Above Feedback 0.5 the parked ring's period is the nearest whole number of samples to F_s/Frequency, not the fractional delay the comb was asked for: +17.4 cents at 1760 Hz / Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — about 70 cents — near 4 kHz. The first-repeat tap still lands within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, the tail reaches exact zero. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | +| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95 against a 10.7 s lap bound; `tail_samples` is still declared `None`. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | ### Time and space - `reverb.py`, `delay.py` | Class | Notes | |---|---| diff --git a/lib/audioeffects/combfilter.py b/lib/audioeffects/combfilter.py index 1bd5501..eda82d1 100644 --- a/lib/audioeffects/combfilter.py +++ b/lib/audioeffects/combfilter.py @@ -63,32 +63,28 @@ 20 Hz. That is the comb, not latency; it is the delay you asked for by tuning it. -**Above Feedback 0.5 the tail may never reach zero, and the tuning decides -whether it does.** The node's line is int16 and `to_s16` rounds, so -`to_s16(g*c) == c` for every `|c| <= 0.5/(1-g)`: the loop has fixed points, -and after the music stops it can park on one for as long as the graph runs. -Whether it parks depends on the *fractional part* of `sample_rate / -Frequency`. Measured on ten-second renders after a 0.2 s burst, at 48 kHz: -tuned to **1000 Hz, where 48 000/f is a whole 48 frames, it parks on 10 LSB -at Feedback 0.95** - `floor(0.5/(1-g))`, the bound, hit exactly - and on 8 -LSB at 440 Hz and 6 at 220; tuned to **438.3 Hz, half a sample off the -grid, it reaches exact zero at every Feedback this class offers**, because -the interpolator averages the last LSB with a zero neighbour and rounds it -away. Below Feedback 0.5 it always reaches zero. - -**Above Feedback 0.5 the parked ring's period is the nearest whole number -of samples to F_s/Frequency, not the fractional delay the comb was asked -for: +17.4 cents at 1760 Hz / Feedback 0.8 (27 samples at 48 kHz) and at -most a half-sample — about 70 cents — near 4 kHz. The first-repeat tap -still lands within 0.01 cents. Below Feedback 0.5, and at half-sample -tunings, the tail reaches exact zero.** Nothing on this palette removes -the parked case — `cut_hz` is a DC blocker and this is not DC; a Tone -low enough to drain 1760/0.8 misses the 1 kHz park and breaks the peak -law — so `TAIL_SAMPLES` is `None`, `reset()` clears it, and the -silence-in-silence-out invariant is demonstrated inside that bound and -disconfirmed outside it. This class is a tuned resonator: Frequency is a -pitch, and the first-repeat tap is the note it plays. The parked ring is -the leftover, not the note. +**The tail reaches exact zero at every Feedback and every tuning.** Up to +audiodsp v0.6.1 it could not above Feedback 0.5: the node's int16 line +rounded the feedback write to nearest, so every `|c| <= 0.5/(1-g)` was a +fixed point of the loop, and at a tuning whose read lands near a whole +sample the tail parked on a few LSB for as long as the graph ran (10 LSB at +1000 Hz / Feedback 0.95), ringing at the nearest whole-sample period +(+17.4 cents at 1760 Hz / Feedback 0.8). audiodsp v0.6.2 (#154) truncates +the fed-back term toward zero exactly where rounding would hand it back +unchanged, so the line empties: from full scale its loudest sample falls by +the geometric law until it is within `floor(0.5/(1-g))` LSB, then by at +least one LSB a lap. Measured at v0.6.2, 48 kHz, Tone off and Trim flat, +after an impulse: 1760 Hz / 0.8 is silent after 928 frames, 1000 Hz / 0.95 +after 7 488, and 20 Hz / 0.95, the slowest corner of the surface, after +374 400 (7.8 s), each inside that lap bound times the line (511 413 frames, +10.7 s, at 20 Hz / 0.95). While it rings, it rings at the fractional delay +it was asked for: 1760 Hz / 0.8 at 27.262 frames against the asked 27.273, +0.7 cents, through its tenth repeat, and the first-repeat tap lands within +0.01 cents. `TAIL_SAMPLES` stays `None` for now: a finite bound that depends +on the setting is derivable, but declaring one changes an adopted class's +surface, and that is the Phase 5 gate audit's call, not the pin move's. +This class is a tuned resonator: Frequency is a pitch, and the first-repeat +tap is the note it plays. **Two traits this class does not have.** The *negative* comb, whose peaks sit on the odd half-multiples and which sounds hollow rather than pitched, is @@ -172,13 +168,12 @@ class CombFilter(_component.Component): CAPABILITIES = () LATENCY_SAMPLES = 0 - #: Not finitely bounded. Above Feedback 0.5 the parked ring's period - #: is the nearest whole number of samples to F_s/Frequency, not the - #: fractional delay the comb was asked for: +17.4 cents at 1760 Hz / - #: Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — - #: about 70 cents — near 4 kHz. The first-repeat tap still lands - #: within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, - #: the tail reaches exact zero. + #: Declared unbounded, conservatively. Since audiodsp v0.6.2 (#154) the + #: tail reaches exact zero at every setting, inside + #: `laps * (ceil(F_s/Frequency) + 1)` frames (the module docstring has + #: the lap law and the numbers); up to v0.6.1 it could park for ever + #: above Feedback 0.5. Declaring the finite bound is the gate audit's + #: change to make. TAIL_SAMPLES = None MACRO_LABELS = ("Frequency", "Feedback", "Mix", "Tone", "Trim", "Glide") diff --git a/tests/test_cpython_effects_combfilter.py b/tests/test_cpython_effects_combfilter.py index 5b53397..79c21d1 100644 --- a/tests/test_cpython_effects_combfilter.py +++ b/tests/test_cpython_effects_combfilter.py @@ -240,6 +240,46 @@ def cents(measured, ideal): return 1200.0 * math.log(ideal / measured, 2.0) if measured else 1e9 +def laps_to_exact_zero(feedback, peak=32768.0): + """Laps of the line after which no sample can be non-zero, on audiodsp + v0.6.2 and later. + + Rounding to nearest bounds a lap's peak by x' <= g x + 0.5, so + x_k <= g^k (peak - c) + c with c = 0.5 / (1 - g); and since audiodsp#154 + no lap hands back a sample as large as the one it sent, so once the peak + is at most floor(c) it takes at most floor(c) more laps to reach 0. The + 1e-6 keeps a c that is a whole number in exact arithmetic (10 at 0.95) + from flooring one short in float, where the loop would never end. + """ + c = 0.5 / (1.0 - feedback) + stall = math.floor(c + 1e-6) + laps = 0 + while peak >= stall + 1: + peak = feedback * (peak - c) + c + laps += 1 + return laps + int(stall) + + +def zero_bound_frames(hz, feedback, rate=SAMPLE_RATE): + """The tail bound in frames: `laps_to_exact_zero` laps, each at most one + frame past the line (the read interpolates towards the next older + frame).""" + return laps_to_exact_zero(feedback) * (math.ceil(rate / hz) + 1) + + +def ring_period(values, asked, repeats=10): + """The ring's period, from the centroid of its `repeats`-th repeat, + found by walking repeat to repeat from the first one. A pulse keeps its + first moment through linear interpolation, so this reads the delay the + loop really has, fraction and all, while the ring is loud enough to + carry one.""" + first = centroid(values, asked) + position = first + for _ in range(repeats - 1): + position = centroid(values, position + first) + return position / repeats + + def bin_db(values, hz, rate=SAMPLE_RATE, skip=0): """One DFT bin, in dB relative to full scale of the probe's own units.""" real = imaginary = 0.0 @@ -553,6 +593,17 @@ class ResetEmptiesTheLine(unittest.TestCase): #: after `reset()` and reads exactly like a line that was never cleared. BURST_FROM = 12000 BURST_TO = 22000 + #: `reset()` lands the moment the burst stops, with the line at full + #: ring. Up to audiodsp v0.6.1 it could land anywhere after the burst, + #: because a line left alone parked on a few LSB for ever; since v0.6.2 + #: (audiodsp#154) a line left alone empties itself, inside 11 544 frames + #: at 440 Hz / Feedback 0.9 (`zero_bound_frames`), so a reset 10 000 + #: frames late found a line nearly drained by itself and the planted + #: fault below read 0 -- a control with no teeth. Here the 8192 frames + #: read after the reset hold the loudest part of the ring an un-reset + #: line still carries, from either end of the source (its first 12 000 + #: frames are silence, and so is everything after 22 000). + RESET_AT = BURST_TO def residue(self, cls): values = array.array("h") @@ -567,7 +618,7 @@ def residue(self, cls): channel_count=CHANNELS) effect = cls(source, frequency=440.0, feedback=0.9, mix=2.0, glide=0.0) - pull(effect.output, 32000) + pull(effect.output, self.RESET_AT) effect.reset() return max(abs(v) for v in pull(effect.output, 8192)), effect, source @@ -581,8 +632,10 @@ def test_reset_leaves_the_line_silent_and_the_source_rendering(self): self.assertGreater(max(abs(v) for v in pull(source, 48000)), 0) def test_a_line_left_full_is_red(self): + # Measured at v0.6.2: 32 768, the rail. A full line, not a parked + # LSB; the same reset 10 000 frames later read 0 here. residue, _effect, _source = self.residue(UnresetCombFilter) - self.assertGreater(residue, 0) + self.assertGreater(residue, 1000) def test_deinit_releases_the_nodes_and_leaves_the_source(self): source = tone(220.0, 8192) @@ -594,16 +647,23 @@ def test_deinit_releases_the_nodes_and_leaves_the_source(self): self.assertGreater(max(abs(v) for v in pull(source, 2048)), 0) -class TheTailIsBoundedRatherThanZero(unittest.TestCase): - """The one Tier 1 invariant this class does not always meet, held to - both ends of what it does. `to_s16` rounds, so every |c| <= 0.5/(1-g) is - a fixed point of the loop -- but whether the loop can sit on one is - decided by the *fractional part* of `sample_rate / Frequency`, not by the - feedback alone. 440 Hz is 109.09 frames: the read is nearly exact and a - lone LSB survives its round trip. 438.3 Hz is 109.51: the interpolator - averages it with a zero neighbour and rounds it away. Both are asserted, - because the first alone would read as "this class never settles" and the - second alone as "it always does".""" +class TheTailReachesExactZero(unittest.TestCase): + """Silence in, silence out, at every Feedback and every tuning, inside + a stated bound. + + Up to audiodsp v0.6.1 this class could not meet it above Feedback 0.5: + the node's feedback write rounded to nearest, so every + |c| <= 0.5/(1-g) was a fixed point of the loop, and at a tuning whose + read lands nearly on a whole sample (440 Hz, 109.09 frames) a few LSB + went round for ever -- this class was `TheTailIsBoundedRatherThanZero` + and held the residue under that bound. v0.6.2 (audiodsp#154) truncates + the fed-back term toward zero exactly where rounding would hand it back + unchanged, so the tail now reaches exact zero at whole-sample and + half-sample tunings alike, inside `zero_bound_frames` of the burst's + end. Measured at v0.6.2 on this burst at 440 Hz: the last non-zero + frame is 3 164 / 4 906 / 9 597 / 18 109 frames after the burst at + Feedback 0.7 / 0.8 / 0.9 / 0.95, against bounds of 3 774 / 5 772 / + 11 544 / 23 643.""" #: 48 000 / 440 = 109.09 frames -- 0.09 of a sample off the grid. PARKS_HZ = 440.0 @@ -630,13 +690,36 @@ def residue(self, feedback, seconds=4, tuned=None): def test_below_half_the_line_reaches_exact_zero(self): self.assertEqual(self.residue(0.45), 0) - def test_a_whole_sample_tuning_parks_inside_the_closed_form_bound(self): - for feedback in (0.7, 0.8, 0.9): + def last_sound(self, feedback, seconds=4, tuned=None): + """Frames from the burst's last frame to the last non-zero one.""" + values = array.array("h") + for frame in range(SAMPLE_RATE * seconds): + value = 0 + if 2048 <= frame < 6848: + value = int(20000 * math.sin(2.0 * math.pi * 440.0 * frame + / SAMPLE_RATE)) + for _ in range(CHANNELS): + values.append(value) + effect = combfilter.CombFilter( + audiocore.RawSample(values, sample_rate=SAMPLE_RATE, + channel_count=CHANNELS), + frequency=self.PARKS_HZ if tuned is None else tuned, + feedback=feedback, mix=2.0, glide=0.0) + y = pull(effect.output, SAMPLE_RATE * seconds) + last = max(i for i in range(len(y)) if y[i]) // CHANNELS + return last - 6847 + + def test_a_whole_sample_tuning_reaches_exact_zero_inside_the_bound(self): + for feedback in (0.7, 0.8, 0.9, 0.95): with self.subTest(feedback=feedback): - bound = math.floor(0.5 / (1.0 - feedback)) - measured = self.residue(feedback) + bound = zero_bound_frames(self.PARKS_HZ, feedback) + measured = self.last_sound(feedback) self.assertGreater(measured, 0) - self.assertLessEqual(measured, bound) + self.assertLessEqual(measured, bound, + "still sounding %d frames after the " + "burst, over the %d-frame bound" + % (measured, bound)) + self.assertEqual(self.residue(feedback), 0) def test_a_half_sample_tuning_still_reaches_exact_zero(self): # The other end, and the reason the docstring's number is a bound @@ -647,33 +730,35 @@ def test_a_half_sample_tuning_still_reaches_exact_zero(self): tuned=self.DRAINS_HZ), 0) -class TheParkedRingIsTheNearestSample(unittest.TestCase): - """The TAIL-pitch bound. Above Feedback 0.5 the parked ring's period - is the nearest whole number of samples to F_s/Frequency, not the - fractional delay the comb was asked for: +17.4 cents at 1760 Hz / - Feedback 0.8 (27 samples at 48 kHz) and at most a half-sample — - about 70 cents — near 4 kHz. The first-repeat tap still lands - within 0.01 cents. Below Feedback 0.5, and at half-sample tunings, - the tail reaches exact zero.""" - - #: Half a sample at 4 kHz / 48 kHz is 70.67 cents; the bound the - #: class states is "about 70 cents". - HALF_SAMPLE_CENTS = 70.0 - - def parked_period(self, cls, hz, feedback, rate=SAMPLE_RATE, seconds=4): +class TheRingIsTheAskedPitchAndEnds(unittest.TestCase): + """The TAIL-pitch trait. The ring plays the fractional delay the comb + was asked for, through its tenth repeat within 1 cent, and then reaches + exact zero inside `zero_bound_frames`. + + Up to audiodsp v0.6.1 this class was `TheParkedRingIsTheNearestSample`: + above Feedback 0.5 the tail parked on a ring whose period was the + nearest whole number of samples, +17.4 cents at 1760 Hz / Feedback 0.8, + for ever. v0.6.2 (audiodsp#154) empties the line, so there is no parked + ring left to measure; what the ear gets instead is the ring itself, and + that is what this class holds. Measured at v0.6.2: 1760 Hz / 0.8 rings + at 27.262 frames against the asked 27.273 (0.7 cents) and is silent + 928 frames after the impulse (bound 1 508); 1000 Hz / 0.8 rings at + exactly 48 and is silent after 2 016 (bound 2 548). The line two samples + short rings at 25.000, 150.6 cents sharp.""" + + #: The ring's period is held to this many cents of the asked delay. + RING_CENTS = 1.0 + + def ring(self, cls, hz, feedback, rate=SAMPLE_RATE, seconds=1): + """(period of the ring in frames, frames to its last non-zero + sample) for an impulse at frame 0.""" frames = rate * seconds effect = cls(impulse_at(0, frames + 512, rate), frequency=hz, feedback=feedback, mix=2.0, glide=0.0, sample_rate=rate) y = left(pull(effect.output, frames)) - edges = [i for i in range(1, len(y) - 2048, 1) - if y[i - 1] == 0 and y[i] != 0 and i >= frames - rate] - if len(edges) < 4: - edges = [i for i in range(max(1, frames - rate), len(y)) - if y[i - 1] == 0 and y[i] != 0] - self.assertGreater(len(edges), 3, "tail did not oscillate") - gaps = [edges[i] - edges[i - 1] for i in range(1, len(edges))] - return sum(gaps) / len(gaps) + last = max(i for i in range(len(y)) if y[i]) + return ring_period(y, rate / hz), last def delay_skew_frames(self, effect): """Asked delay minus the nearest whole sample. The clean class @@ -683,18 +768,18 @@ def delay_skew_frames(self, effect): return float(round(asked) - 2) - round(asked) return asked - round(asked) - def test_1760_at_feedback_08_parks_27_samples_17_cents_sharp(self): - period = self.parked_period(combfilter.CombFilter, 1760.0, 0.8) - self.assertAlmostEqual(period, 27.0, delta=0.05) + def test_1760_at_feedback_08_rings_on_the_asked_pitch_then_ends(self): + period, last = self.ring(combfilter.CombFilter, 1760.0, 0.8) error = 1200.0 * math.log((SAMPLE_RATE / period) / 1760.0, 2.0) - self.assertAlmostEqual(error, 17.4, delta=0.2) - self.assertLess(abs(error), self.HALF_SAMPLE_CENTS) + self.assertLess(abs(error), self.RING_CENTS, period) + self.assertLessEqual(last, zero_bound_frames(1760.0, 0.8)) - def test_1000_parks_on_the_asked_pitch(self): - period = self.parked_period(combfilter.CombFilter, 1000.0, 0.8) - self.assertAlmostEqual(period, 48.0, delta=0.05) + def test_1000_rings_on_the_asked_pitch_then_ends(self): + period, last = self.ring(combfilter.CombFilter, 1000.0, 0.8) + self.assertAlmostEqual(period, 48.0, delta=0.005) error = 1200.0 * math.log((SAMPLE_RATE / period) / 1000.0, 2.0) - self.assertAlmostEqual(error, 0.0, delta=0.2) + self.assertLess(abs(error), self.RING_CENTS, period) + self.assertLessEqual(last, zero_bound_frames(1000.0, 0.8)) def test_the_first_repeat_at_1760_is_still_the_asked_tap(self): ideal = SAMPLE_RATE / 1760.0 @@ -705,10 +790,10 @@ def test_the_first_repeat_at_1760_is_still_the_asked_tap(self): self.assertLess(abs(cents(centroid(y, ideal), ideal)), 0.01) def test_a_two_sample_shorter_line_is_outside_the_bound(self): - period = self.parked_period(TwoSampleShortCombFilter, 1760.0, 0.8) + period, _last = self.ring(TwoSampleShortCombFilter, 1760.0, 0.8) self.assertAlmostEqual(period, 25.0, delta=0.05) error = 1200.0 * math.log((SAMPLE_RATE / period) / 1760.0, 2.0) - self.assertGreater(abs(error), self.HALF_SAMPLE_CENTS) + self.assertGreater(abs(error), self.RING_CENTS) def test_the_surface_cannot_dial_the_two_sample_short(self): def build(cls): diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index c854bdf..8ce522f 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -505,6 +505,26 @@ def cents(ratio): return 1200.0 * math.log(ratio) / math.log(2.0) +def laps_to_exact_zero(feedback, peak=32768.0): + """Laps of the line after which no sample can be non-zero, on audiodsp + v0.6.2 and later, at any Feedback below the node's 1.0. + + Rounding to nearest still bounds a lap's peak by x' <= f x + 0.5, so + x_k <= f^k (peak - c) + c with c = 0.5 / (1 - f); and since audiodsp#154 + no lap can hand back a sample as large as the one it sent, so once the + peak is at most floor(c) it takes at most floor(c) more laps to reach 0. + The 1e-6 keeps a c that is a whole number in exact arithmetic (10 at + 0.95) from flooring one short in float, where the loop would never end. + """ + c = 0.5 / (1.0 - feedback) + stall = math.floor(c + 1e-6) + laps = 0 + while peak >= stall + 1: + peak = feedback * (peak - c) + c + laps += 1 + return laps + int(stall) + + # -------------------------------------------------------------------------- # The measurements, each returning {"passed": ...} @@ -1481,18 +1501,34 @@ def test_the_tail_reaches_exact_zero_inside_tail_samples(self): self.assertNotEqual(red, [], values) self.assertGreater(values["tail_samples"], declared) - def test_the_floor_bug_is_reported_as_unbounded(self): - # audiodsp v0.6.1 rounds the feedback write half away from zero, so - # from Feedback 0.5 a 1 LSB repeat writes itself back forever. The - # class says None there. When the node is fixed the residual below - # goes to 0, and tail_samples can come back for the top half. + def test_the_floor_is_gone_at_feedback_half(self): + # Up to audiodsp v0.6.1 the node rounded the feedback write to + # nearest, so from Feedback 0.5 a 1 LSB repeat wrote itself back + # forever: on this material 1 LSB was still going round after three + # seconds. v0.6.2 (audiodsp#154) truncates the fed-back term toward + # zero exactly where rounding would hand it back unchanged, so the + # largest sample on the line falls by at least 1 LSB a lap and, from + # full scale, by the geometric bound until then + # (`laps_to_exact_zero`). The tail therefore ends inside that many + # laps of at most one frame past the 600-frame Time (measured at + # v0.6.2: silent 8 748 frames after the input, bound 9 616). The class + # still says None here: its declaration is DigitalDelay's re-audit + # to change, not this pin move's. values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + input_end = len(values) values += [0] * (3 * RATE) effect = DigitalDelay(array_src(values), sample_rate=RATE, time_ms=12.5, feedback=0.5, mix=2.0) self.assertIsNone(effect.tail_samples) out = pull(effect, len(values)) - self.assertGreater(int(np.max(np.abs(out[-600 * 2:]))), 0) + bound = laps_to_exact_zero(0.5) * (600 + 1) + nonzero = np.nonzero(out)[0] + self.assertGreater(len(nonzero), 0) + last = int(nonzero[-1]) // 2 + self.assertLessEqual(last - input_end, bound, + "the tail ran %d frames past the input, " + "over the %d-frame bound" + % (last - input_end, bound)) effect.set_macro(FEEDBACK_I, 64) self.assertIsNotNone(effect.tail_samples) diff --git a/tests/test_cpython_effects_flanger.py b/tests/test_cpython_effects_flanger.py index babafbf..b5ec7d2 100644 --- a/tests/test_cpython_effects_flanger.py +++ b/tests/test_cpython_effects_flanger.py @@ -559,11 +559,22 @@ def _audio_t60(self, cls=Flanger, color=0.99, listen_s=3.0, hz=440.0): t60 = (start - burst) / float(rate) return t60 + #: Why the two tests below are red at audiodsp v0.6.2, said once. The + #: bar is the gate audit's to move or keep, not the pin move's. + F8_AT_V062 = ( + "F8's 2.0 s bar at Color max on the 440 Hz burst measures %.2f s at " + "audiodsp v0.6.2, against 2.13 s at v0.6.1. audiodsp#154 empties " + "the line where rounding used to hold a few LSB going round for " + "ever, and part of the old T60 was that floor holding the ring up " + "(the 200 Hz row reads 2.13 s now, 2.35 s at v0.6.1, and still " + "passes). The bar is left where it is and listed for the Phase 5 " + "gate audit.") + def test_audio_t60_at_color_max_is_at_least_two_seconds(self): """F8 audio: Color-max cut is 0.4 Hz so the ring clears 2 s.""" t60 = self._audio_t60() self.assertIsNotNone(t60, "never reached −60 dB") - self.assertGreaterEqual(t60, 2.0, t60) + self.assertGreaterEqual(t60, 2.0, self.F8_AT_V062 % t60) def test_audio_t60_at_200hz_is_at_least_two_seconds(self): t60 = self._audio_t60(hz=200.0) @@ -580,6 +591,10 @@ def measure(cls): t60 = self._audio_t60(cls, listen_s=2.5) passed = t60 is not None and t60 >= 2.0 return {"passed": passed, "red": [{"t60": t60}]} + clean = self._audio_t60(listen_s=2.5) + # null_build_red needs the clean class to pass first; say why not. + self.assertTrue(clean is not None and clean >= 2.0, + self.F8_AT_V062 % (clean or 0.0)) kit_faults.null_build_red(Flanger, measure, label="Flanger F8 audio") def test_default_audio_tail_is_not_two_seconds(self): From 0b967180e9576baef3651697431f14ba2c293c0d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:07:07 -0500 Subject: [PATCH 08/88] DigitalDelay re-audit fix round 1: the tail at audiodsp v0.6.2, T2's edge at grid 3 tail_samples is finite at every Feedback with the loop filters out. At v0.6.2 (audiodsp#154) a lap maps the line's peak x to at most min(x - 1, floor(f x + 0.5)), so laps_to_zero iterates that from full scale: 11 laps at the defaults (184 811 frames), 16 at 0.5, 685 at 0.99. Full scale meets the count exactly; 3 840 cells over the Feedback grid, six Tone positions and five materials read at most 0.9983 of it. With Repeat Tone in, each lap is the low-pass's forgetting time longer, and the class says None wherever a lap could hand a value back: the node holds 1 LSB at Feedback 0.5 and 5 at 0.9 (Tone 800 Hz, 2 LSB DC) because its float32 damping state rests a few ulps high and recirculated() rounds it back. That is a second node defect; the class stays parked on it. Fix round 2's one-extra-lap rule for Tone is wrong there and elsewhere. T2's rising 150 -> 200 ms move is claimed from Glide grid 3 (844.72 ms), placed by rendering every rounding class of the node's float32 walk at three rates: red classes reach grid 2.505 at 48 kHz, and the worst above grid 3 is 8.68 c. The docstring names the move and the longer rising moves it does not claim. A constructor Glide slower than 8 s is clamped to 8 s, so get_macro(3) reads back what plays. A constructor Time, Repeat Tone or Repeat Cut of 0 builds: 12.5 ms, and each filter out of circuit. The docstring's Feedback 0.99 ceiling is -3.1 dBFS, and patch 5's square-wave headroom is stated. Shown red: on e722620's class the new and changed tests fail 3 and error 4 of 57 (the tail at 0.5, the constructor zeros, the slow Glide, the kit TAIL at patch 4, the Tone stall); the edge test fails on grid 2.0342 with the edge set back to 2. --- lib/audioeffects/rebuilt/digitaldelay.py | 222 ++++++++----- tests/test_cpython_effects_digitaldelay.py | 343 +++++++++++++++++---- 2 files changed, 440 insertions(+), 125 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 4e6cb94..1eb2e34 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -79,21 +79,30 @@ 814.6 ms, down to the 0.99 pin) stays on the audio path, and the knob reads back at grid 1, the fastest walk it has, never at grid 0, the jump: handing `get_macro(3)` back to `set_macro(3, ...)`, even rounded to a -7-bit MIDI value, keeps the glide (at grid 1's slew, 0.967). +7-bit MIDI value, keeps the glide (at grid 1's slew, 0.967). One slower +than the knob's 8 s plays 8 s, and the knob reads 8 s. A constructor +Time of 0 is 12.5 ms, and a Repeat Tone or Repeat Cut of 0 is that +filter out of circuit, as it is on the node. **Where the pitch claim stops.** The node walks the read head in single precision, so the rate it plays is the Glide's rate rounded to the float -step of the delay. Near a stall that rounding is worth several cents: on -a rising Time at the Glide knob's fastest 1 %, strictly between grid 1 and -grid 2 (814.6-829.5 ms), the repeats can read up to 15 cents off the -glide law at 48 kHz and 11.5 at 44.1 kHz. That part of the knob is not -claimed on a rising move; grid 1, grid 2 and everything slower are. -Nor is a constructor Glide under 829.5 ms on a rising move, except -800 ms (slew 63/64, which single precision holds exactly): at the 0.99 -pin a rising Time can read 41 cents off. The pitch and no-step claims -are measured on inputs from -8.7 to -0.2 dBFS. Quieter, int16 rounding -decides the reading: a near-stall rising glide is a few LSB of signal, -and even a falling move at -48.7 dBFS can step 0.4 % past its bar. +step of the delay. Near a stall that rounding is worth several cents. +The claim is stated for a rising 150 -> 200 ms move: at the Glide knob's +fastest positions, strictly between grid 1 and grid 3 (814.6-844.7 ms), +the repeats can read up to 15.4 cents off the glide law at 48 kHz and +12.7 at 44.1 kHz, so that part of the knob is not claimed on a rising +move. Grid 1 itself and everything from grid 3 up are (worst 8.7 cents). +Nor is a constructor Glide under 844.7 ms claimed on a rising move, +except 800 ms (slew 63/64, which single precision holds exactly): at +the 0.99 pin a rising Time can read 41 cents off. A longer rising move +reaches delays where the float step is coarser, and at a fast Glide it +is not claimed: 350 -> 500 ms at grid 3 reads +15.8 cents at 48 kHz and ++15.3 at 44.1 kHz, and 400 -> 800 ms at grid 5 reads +10.7 at 48 kHz. +At the default Glide every move the dossier names is inside 10 cents. +The pitch and no-step claims are measured on inputs from -8.7 to +-0.2 dBFS. Quieter, int16 rounding decides the reading: a near-stall +rising glide is a few LSB of signal, and even a falling move at +-48.7 dBFS can step 0.4 % past its bar. **Input ceiling.** The dry path sits at unity and the repeats add to it, so a hot input can put the output on the int16 rail; there is no input gain @@ -108,20 +117,26 @@ -6.1 dBFS at patch 3. At exactly (1 - Mix) of full scale the sum can round onto 32767, the rail value, though nothing is clipped. High Feedback does not keep building past that: at Feedback 0.99 the line saturates, and the -defaults' noise_det ceiling is -4 dBFS after 6 s and after 20 s alike. -Repeat Cut's high-pass can overshoot a peak, so with it in circuit leave -more room. +defaults' noise_det ceiling is still -3.1 dBFS over 20 s (at -3.0 it +rails 2284 samples). Repeat Cut's high-pass can overshoot a peak, so with +it in circuit leave more room: patch 5 (Cut at 40 Hz) is clean on +noise_det at -4 dBFS, but a 40 Hz square wave there puts 54752 samples on +the rail over 20 s, and it is clean from -6 dBFS down. **Tail.** `tail_samples` is an upper bound on how long the repeats take to reach exact zero after your input stops, and it is long: the loop has to -round its way down from full scale, 12 laps at the default Feedback. From -Feedback 0.5 up it is `None`, because there the node's feedback write -rounds a 1 LSB repeat back to itself, so the line is not guaranteed to -empty. On some material it never does (a 997 Hz burst leaves 1 LSB going -round at Feedback 0.5 and 0.7, 50 LSB at 0.99); on other material it does -(a 1 kHz -6 dBFS burst reaches zero at 0.5 and 0.7). That is a floor bug in -the node, not in this class, and it is why the rebuild is parked. With -Repeat Cut in circuit it is `None` too. +round its way down from full scale, 11 laps at the default Feedback and +685 at 0.99 (nine minutes at Time 800 ms). Since audiodsp v0.6.2 the node +steps a repeat toward zero wherever rounding would hand it back +unchanged, so with both filters out the bound holds at every Feedback. +With Repeat Tone in circuit each lap is a little longer, and wherever +0.5 / (1 - Feedback) is within a hair of a whole number (0.5, 0.75, 0.83, +0.875, 0.9 and on up to the knob's top, 0.99) it is `None`: there the +node's loop low-pass can come to rest a hair above a small value and send +it round for ever. A 2 LSB DC at Feedback 0.5 with Repeat Tone at 800 Hz +leaves 1 LSB going round; at 0.9, 5 LSB. That is a floor bug in the node, +not in this class, and it keeps the rebuild parked. With Repeat Cut in +circuit it is `None` too: no bound is derived there. `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). @@ -151,6 +166,12 @@ #: (delay-seconds per second) is FULL_RANGE_MS / glide_ms (dossier section 6). FULL_RANGE_MS = TIME_MAX_MS - TIME_MIN_MS +#: The Glide knob's span, log, with grid 0 the jump. A constructor Glide +#: slower than the top is clamped to it, the way Time clamps at +#: `max_time_ms`, so `get_macro(3)` always names the Glide that plays. +GLIDE_MIN_MS = 800.0 +GLIDE_MAX_MS = 8000.0 + #: At slew 1 a rising Time stands the read head still, and past it the line #: plays backwards. A constructor Glide under 795.45 ms is pinned here; no #: grid position gets there (grid 1 is slew 0.967). @@ -169,15 +190,13 @@ #: `line_frames - 2`, so a line of exactly `max_time_ms` could not reach it. LINE_HEADROOM_MS = 1.0 -#: At and above this Feedback the node's line is not guaranteed to empty. -#: Its feedback write rounds half away from zero (`to_s16`, -#: `audiodsp_feedback_delay.c:317`, called at `:493`), so every -#: |x| <= 0.5 / (1 - f) can write itself back, and at f >= 0.5 that -#: includes 1 LSB. `tail_samples` is `None` there. -FEEDBACK_UNBOUNDED = 0.5 - #: The largest magnitude one line sample can hold (int16). -LINE_PEAK = 32768.0 +LINE_PEAK = 32768 + +#: `laps_to_zero` reckons with a Feedback this much larger, relatively, so +#: the node's single-precision feedback and product, and a board's +#: single-precision Python, can only make the bound longer, never shorter. +FEEDBACK_MARGIN = 2.0 ** -16 #: 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, @@ -196,7 +215,8 @@ def nominal_cut_hz(corner_hz, sample_rate): The node's high-pass is `y -= lp(y)` with the low-pass coefficient a = 1 - exp(-2 pi hz / fs) (`audiodsp_feedback_delay.c:33-40`, - `:462-466`), so with b = 1 - a it is H = b (1 - z^-1) / (1 - b z^-1). + `:498-501` at v0.6.2), so with b = 1 - a it is + H = b (1 - z^-1) / (1 - b z^-1). |H|^2 = 1/2 gives b^2 (3 - 4 cos w) + 2 b cos w - 1 = 0; the root in (0, 1) is the pole, and `cut_hz = -fs ln b / 2 pi`. """ @@ -241,31 +261,55 @@ def whole_frames(time_ms, sample_rate): return int(math.floor(float(time_ms) * sample_rate / 1000.0 + 0.5)) -def laps_to_zero(feedback): +def laps_to_zero(feedback, excess=0.0): """How many laps of the line can still hold a non-zero sample once the - input stops, or `None` if the line is not guaranteed to empty. - - The node writes `round(fed + f * read)`, rounding half away from zero, - so after the input stops a lap's peak obeys x' <= f x + 0.5 from any - starting x <= 32768. That gives x_k <= f^k (32768 - c) + c with - c = 0.5 / (1 - f), and x_k < 1 means x_k is exactly 0. Below - f = 0.5, c < 1 and the count is finite; at 0.5 and above, 1 LSB writes - itself back, and on some material it does so forever - (`FEEDBACK_UNBOUNDED`). + input stops, or `None` where a lap could hand a sample back unchanged. + + Once the input stops, the node writes `to_s16(recirculated(s, f))` for + each value s it sends round (`audiodsp_feedback_delay.c:529`). Since + audiodsp v0.6.2 (#154) `recirculated` (`:344`) rounds f s to nearest + where |s| - |f s| > 0.5 and truncates it toward zero otherwise. + + With Repeat Tone out (`excess` 0) s is a line sample, or a mix of two + at a fractional read, so |s| <= x, the line's peak, and the node + guarantees |write| < |s|: a lap maps x to at most + min(x - 1, floor(f x + 0.5)). Both halves only grow with x, so the peak + of one lap bounds every sample of the next, and iterating from full + scale counts the laps to exact zero. That is finite at every Feedback; + 0.99 takes 685 laps. + + With Repeat Tone in, s is the loop low-pass's state, which can sit + above the line's peak by up to `excess` times it (`DigitalDelay`'s + `_tone_excess`). Then a lap maps x to the node's write at + s = x (1 + excess), whichever branch it takes, and the guarantee is + gone: where that write is still x, the peak can stay there for ever. + The node does that (1 LSB at Feedback 0.5, 5 at 0.9, Tone 800 Hz, on + a DC input), so the count is `None` wherever the reckoning allows it. """ - feedback = max(0.0, float(feedback)) - if feedback >= FEEDBACK_UNBOUNDED: - return None - c = 0.5 / (1.0 - feedback) + feedback = float(feedback) + if feedback < 0.0: + feedback = 0.0 + if feedback > FEEDBACK_MAX: + feedback = FEEDBACK_MAX + up = feedback * (1.0 + FEEDBACK_MARGIN) + low = feedback * (1.0 - FEEDBACK_MARGIN) laps = 0 peak = LINE_PEAK - while peak >= 1.0: + while peak > 0: laps += 1 - if laps > 1024: - # Only a Feedback within a float's width of 0.5 gets here, where - # a board's single-precision c can round up to 1. - return None - peak = feedback * (peak - c) + c + if excess <= 0.0: + image = int(math.floor(up * peak + 0.5)) + if image >= peak: + image = peak - 1 + else: + sent = peak * (1.0 + excess) + if sent * (1.0 - low) > 0.5: + image = int(math.floor(up * sent + 0.5)) + else: + image = int(math.floor(up * sent)) + if image >= peak: + return None + peak = image return laps @@ -308,7 +352,7 @@ class DigitalDelay(_component.Component): (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms (0.0, FEEDBACK_MAX), # 1 Feedback (0.0, 2.0), # 2 Mix; dry at unity to 1 - (800.0, 8000.0, "log"), # 3 Glide, ms; grid 0 = jump + (GLIDE_MIN_MS, GLIDE_MAX_MS, "log"), # 3 Glide, ms; grid 0 = jump (0.0, 1.0), # 4 Sync (0.0, 15.0), # 5 Division index (800.0, 16000.0, "log"), # 6 Repeat Tone, Hz; top = out @@ -349,10 +393,26 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, self._deferred = False self._feedback = 0.0 self._node_ms = 0.0 + # 0 (or less) is how the node spells a filter out of circuit, so + # Repeat Tone and Repeat Cut at 0 are their out stops; Time at 0 + # is the bottom of its span. Each is a value a log knob cannot seed. + time_ms = float(time_ms) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + tone_hz = float(tone_hz) + if not tone_hz > 0.0: + tone_hz = self._MACRO_RANGES[TONE_I][1] + cut_hz = float(cut_hz) + if not cut_hz > 0.0: + cut_hz = self._MACRO_RANGES[CUT_I][0] #: A constructor Glide stays on the audio path until macro 3 moves: #: the Glide span starts at 800 ms, so a faster constructor Glide - #: (down to the 0.99 pin) or an exact 0 has no knob position. - self._glide_exact = float(glide_ms) + #: (down to the 0.99 pin) or an exact 0 has no knob position. A + #: slower one is clamped to the span's top, 8 s. + glide_ms = float(glide_ms) + if glide_ms > GLIDE_MAX_MS: + glide_ms = GLIDE_MAX_MS + self._glide_exact = glide_ms self._seeding = True self._delay = audioecho.FeedbackDelay( sample_rate=self._sample_rate, @@ -374,7 +434,8 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, # 0 is the jump, so a Glide that is not 0 is never seeded below grid # 1, and a get_macro / set_macro round trip keeps it gliding even # when a host rounds it to a MIDI value. - self._init_macros((time_ms, feedback, mix, max(800.0, glide_ms), + self._init_macros((time_ms, feedback, mix, + max(GLIDE_MIN_MS, glide_ms), 1.0 if sync else 0.0, float(division), tone_hz, cut_hz)) if self._glide_exact > 0.0 and self._macros[GLIDE_I] < GLIDE_FLOOR: @@ -522,28 +583,51 @@ 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 holds. + an upper bound, or `None` where no bound is derived. `laps_to_zero(f)` 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). While a Glide walk falls, that is the Time it is walking from, not the target, and it stays so until a Glide-0 move or a reset lands the head, because the class cannot - see how far the walk has got. Repeat Tone adds one lap for its - filter state to die away. - - `None` at Feedback 0.5 and above, where the node's rounding holds - 1 LSB or more in the line, forever on some material - (`FEEDBACK_UNBOUNDED`), and with - Repeat Cut in circuit, whose high-pass can more than double a peak - in one pass and remembers across laps at its low corners. + see how far the walk has got. + + Finite at every Feedback with both filters out. With Repeat Tone + in, each lap is `memory` frames longer, the time the low-pass takes + to forget the lap before, and the count is `None` near the Feedback + values where the node can hold a small value for ever + (`laps_to_zero`). `None` with Repeat Cut in circuit, whose + high-pass can more than double a peak in one pass, so the per-lap + argument does not hold; the node measures that case rather than + bounding it, and this class has no bound for it either. """ 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` and whose `super()` hands back the property itself.""" if self._macros[CUT_I] > 0.0: return None - laps = laps_to_zero(self._feedback) + memory, excess = self._tone_excess() + laps = laps_to_zero(self._feedback, excess) if laps is None: return None - if self._macros[TONE_I] < 1.0: - laps += 1 - return int(laps * (self._reach + 1)) + return int(laps * (self._reach + 1 + memory)) + + def _tone_excess(self): + """(frames, relative excess) for Repeat Tone's low-pass. After + `frames` frames whatever the state held before, full scale at + most, weighs under 2^-17 LSB, which is under 2^-17 of any non-zero + peak; and the single-precision state can rest up to 2^-24 / a of + the peak above it, a being the coefficient, because a step + a (v - y) under half an ulp rounds away. (0, 0.0) with Repeat Tone + out.""" + if self._macros[TONE_I] >= 1.0: + return 0, 0.0 + damping = self._tone_damping(self._macros[TONE_I]) + per_frame = 2.0 * math.pi * damping / self._sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index 8ce522f..a35d652 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -31,6 +31,17 @@ here and in the reachability walks; and T2's part of the knob the row no longer claims (rising, strictly between Glide grid 1 and 2) is tested by a float32 model of the node's walk and by rendered fractional positions. + +Re-audit fix round 1 (2026-09-27, after the pin moved to audiodsp v0.6.2 +and gate audit round 3): `tail_samples` is finite at every Feedback with +the loop filters out, and graded on the old floor cell and on full scale +beside the old `None` and a bound one lap short; with Repeat Tone in, the +Feedback values where the node holds a value for ever are shown, where the +class says `None`, beside fix round 2's one-extra-lap rule. T2's rising +edge moves from Glide grid 2 to grid 3, placed by rendering every rounding +class of the node's walk, and the edge test goes red at the old edge. A +constructor Glide slower than 8 s reads back as the 8 s it plays, and a +constructor Time, Repeat Tone or Repeat Cut of 0 builds. """ import math @@ -425,6 +436,67 @@ def tail_samples(self): return int(math.ceil(frames * laps)) +class FloorEraTailDelay(DigitalDelay): + """The declaration the class made up to audiodsp v0.6.1: `None` from + Feedback 0.5 up, where that node's rounding could hold 1 LSB in the + line for ever. From v0.6.2 a bound holds there, so `None` is a claim + the class no longer has any cause to make.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + if self._feedback >= 0.5: + return None + return DigitalDelay._tail_bound(self) + + +class LapShortTailDelay(DigitalDelay): + """The bound one lap short. Full-scale material lasts exactly the + lap count, so it outlives this.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + declared = DigitalDelay._tail_bound(self) + if declared is None: + return None + return declared - (self._reach + 1) + + +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 + the line for ever at some Feedback values, and it makes other tails + longer than one lap more.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + if self._macros[CUT_I] > 0.0: + return None + laps = dd.laps_to_zero(self._feedback) + if self._macros[TONE_I] < 1.0: + laps += 1 + return int(laps * (self._reach + 1)) + + +class SlowSeedGlideDelay(DigitalDelay): + """The seeding up to re-audit fix round 1: a constructor Glide slower + than 8 s stayed on the audio path while the knob read 8 s, so handing + `get_macro(3)` back sped the walk up (16 s: +100 %).""" + + NAME = 'DigitalDelay' + + def _build(self, *arguments, **options): + DigitalDelay._build(self, *arguments, **options) + glide_ms = float(options.get("glide_ms", 4000.0)) + if glide_ms > dd.GLIDE_MAX_MS and self._glide_exact is not None: + self._glide_exact = glide_ms + + class TargetOnlyTailDelay(DigitalDelay): """The tail from the target Time alone, while the read head is still walking down from the old one.""" @@ -583,6 +655,11 @@ def dossier_slew(glide_ms): return min(0.99, 787.5 / glide_ms) +#: Where T2's rising 150 -> 200 ms move is claimed from on the Glide knob +#: (dossier section 8.12): grid 3, 844.72 ms. Fix round 2 said grid 2. +RISING_EDGE_GRID = 3.0 + + def glide_of_grid(grid): """The Glide knob's label at a grid position: log 800-8000 ms, grid 0 the jump (dossier section 6).""" @@ -741,7 +818,7 @@ def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, def f32_walk_cents(rate, grids, start_ms=150.0, target_ms=200.0): """A float32 model of the node's slew walk (`current += slew`, - `audiodsp_feedback_delay.c:408` / `:413` at v0.6.1), from `start_ms` to + `audiodsp_feedback_delay.c:444` / `:449` at v0.6.2), from `start_ms` to `target_ms` in whole frames, at the dossier's law for each Glide grid position in `grids`. Returns, per position, the cents the walk plays off the law, read over the walk's middle 80 % (gate audit round 2's @@ -974,6 +1051,40 @@ def shares(segment): return {"last": [10.0 * math.log10(e / f) for e, f in zip(eighth, first)]} +def tail_measure(cls, feedback, material, rate=RATE, channels=2, + time_ms=12.5, **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.""" + probe = DigitalDelay(silence_src(64, channels, rate), sample_rate=rate, + time_ms=time_ms, feedback=feedback, mix=2.0, + **options) + lap = probe._frames + budget = probe.tail_samples or 4 * rate + probe.deinit() + if material == "floor": + values = [0] * 256 + sine_values(997.0, 2048, rate, 12000) + elif material == "dc2": + values = [2] * (rate // 2) + else: + values = [32767] * (4 * lap) + input_end = len(values) + values += [0] * (budget + 4 * lap) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + time_ms=time_ms, feedback=feedback, mix=2.0, **options) + declared = effect.tail_samples + out = pull(effect, len(values), channels) + nonzero = np.nonzero(out[input_end * channels:])[0] + tail = int(nonzero[-1]) // channels + 1 if len(nonzero) else 0 + passed = declared is not None and 0 < tail <= declared + held = int(np.abs(out[-4 * lap * channels:]).max()) + return {"passed": passed, "declared": declared, "tail": tail, + "lap": lap, "held": held} + + def railed_samples(cls, dbfs, seconds=1.0, rate=RATE, **options): """The input ceiling's measurement: `noise_det` at `dbfs` peak, 48 kHz stereo; output samples on the int16 rail that are not on it in the @@ -1198,15 +1309,18 @@ def test_patch_0_is_the_constructor_grid(self): delta=0.6) def test_tail_samples_follows_time_and_feedback(self): - # laps_to_zero(f) laps of (the head's longest delay + 1) frames. + # laps_to_zero(f) laps of (the head's longest delay + 1) frames, + # finite at every Feedback since audiodsp v0.6.2 (#154). self.assertEqual(dd.laps_to_zero(0.0), 1) - self.assertEqual(dd.laps_to_zero(0.35), 12) - self.assertEqual(dd.laps_to_zero(64 / 127.0 * 0.99), 24) - self.assertIsNone(dd.laps_to_zero(0.5)) + self.assertEqual(dd.laps_to_zero(0.35), 11) + self.assertEqual(dd.laps_to_zero(64 / 127.0 * 0.99), 15) + self.assertEqual(dd.laps_to_zero(0.5), 16) + self.assertEqual(dd.laps_to_zero(0.99), 685) + self.assertEqual(dd.laps_to_zero(1.5), 685) effect = DigitalDelay(silence_src(512), sample_rate=RATE) - self.assertEqual(effect.tail_samples, 12 * 16801) + self.assertEqual(effect.tail_samples, 11 * 16801) effect.program_change(0) - self.assertEqual(effect.tail_samples, 12 * 16936) + self.assertEqual(effect.tail_samples, 11 * 16936) effect.set_macro(FEEDBACK_I, 0) self.assertEqual(effect.tail_samples, 16936) # A falling move walks from the old Time, so the old Time stays the @@ -1216,16 +1330,27 @@ def test_tail_samples_follows_time_and_feedback(self): effect.set_macro(GLIDE_I, 0) self.assertEqual(effect.tail_samples, 601) effect.set_macro(FEEDBACK_I, 64) - self.assertEqual(effect.tail_samples, 24 * 601) + self.assertEqual(effect.tail_samples, 15 * 601) + # Repeat Tone in: each lap is the low-pass's forgetting time longer + # (22 frames at grid 100, 48 kHz), and at Feedback 0.99 the class + # has no bound, because a lap can hand a value back there. effect.set_macro(TONE_I, 100) - self.assertEqual(effect.tail_samples, 25 * 601) + self.assertEqual(effect._tone_excess()[0], 22) + self.assertEqual(effect.tail_samples, 15 * (601 + 22)) effect.set_macro(FEEDBACK_I, 65) + self.assertEqual(effect.tail_samples, 17 * (601 + 22)) + effect.set_macro(FEEDBACK_I, 127) self.assertIsNone(effect.tail_samples) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect.tail_samples, 685 * 601) + self.assertEqual(dd.laps_to_zero(0.99, 1e-5), None) + self.assertEqual(dd.laps_to_zero(0.5, 1e-5), None) + 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) effect.reset() - self.assertEqual(effect.tail_samples, 12 * 16936) + self.assertEqual(effect.tail_samples, 11 * 16936) def test_glide_law(self): self.assertEqual(dd.slew_of(0.0), 0.0) @@ -1323,32 +1448,53 @@ def test_the_block_staircase_is_red(self): self.assertGreater(result["body"], result["bar"]) def test_the_unclaimed_band_is_where_the_float32_walk_says(self): - # Fix round 2 (dossier section 8.11): the rising move is not claimed - # strictly between Glide grid 1 and grid 2. The float32 model of the - # node's walk, over the whole knob in steps of 0.01, puts every - # position more than 10 c off the law inside that band at every - # rate, and does find some there at 48 kHz (so the scan can fail). + # A cross-check, not where the edge came from: the float32 model of + # the node's walk, over the whole knob in steps of 0.01, puts every + # position more than 10 c off the law inside the unclaimed band at + # every rate, and does find some there at 48 kHz (so the scan can + # fail). The edge is placed by rendering every rounding class + # (`digitaldelay_reaudit1_t2edge.py`); the model reads 1-1.5 c + # kinder than the render near the stall, which is how fix round 2 + # put the edge at grid 2 and grid 2.0342 got past it. grids = np.arange(100, 12701) / 100.0 for rate in (48000, 44100, 22050): off = grids[np.abs(f32_walk_cents(rate, grids)) > 10.0] - self.assertTrue(np.all((off > 1.0) & (off < 2.0)), - (rate, off[(off <= 1.0) | (off >= 2.0)])) + self.assertTrue(np.all((off > 1.0) & (off < RISING_EDGE_GRID)), + (rate, off[(off <= 1.0) + | (off >= RISING_EDGE_GRID)])) if rate == 48000: self.assertGreater(len(off), 0) falling = f32_walk_cents(48000, grids, 200.0, 150.0) self.assertLess(float(np.abs(falling).max()), 10.0) def test_inside_the_band_is_red_and_outside_it_holds(self): - # Grid 1.245 is the model's worst position at 48 kHz: rendered, it - # reads about -15 c. Fractional positions outside the band pass. - inside = t2_measure(DigitalDelay, glide_grid=1.245, start_ms=150.0, - target_ms=200.0) - self.assertLess(inside["pitch_cents"], -10.0, inside) - for rate, grid in ((48000, 2.05), (48000, 3.55), (44100, 1.95), - (22050, 1.245)): + # Re-audit fix round 1 (dossier section 8.12): the rising 150 -> + # 200 ms move is claimed from Glide grid 3 up (and at grid 1 + # itself). Every rounding class of the node's float32 walk was + # rendered at three rates; the red ones reach grid 2.505 at + # 48 kHz and 1.512 at 44.1 kHz, none at 22.05 kHz. Inside: gate + # audit round 3's cell 2.0342, which fix round 2's edge claimed, + # and each rate's worst class. Outside: the edge, and the worst + # class above it at each rate (8.68 c at 48 kHz, just above 3). + # A cell passes exactly when the edge claims it, so an edge moved + # back to 2 claims 2.0342 and 2.49031 and goes red on them. + for rate, grid in ((48000, 2.0342), (48000, 2.49031), + (48000, 1.245), (44100, 1.0076), (48000, 3.0), + (48000, 3.00397), (48000, 3.01517), + (48000, 4.06536), (44100, 3.03684), + (22050, 3.00794)): result = t2_measure(DigitalDelay, rate=rate, glide_grid=grid, start_ms=150.0, target_ms=200.0) - self.assertTrue(result["passed"], (rate, grid, result)) + self.assertEqual(result["passed"], grid >= RISING_EDGE_GRID, + (rate, grid, result)) + # The constructor, the same way: 830.056 ms is red (fix round 2 + # claimed it, above its 829.5 ms), the edge's own 844.72 ms holds. + for glide_ms in (830.056, glide_of_grid(3.0)): + result = t2_measure(DigitalDelay, glide_ms=glide_ms, + start_ms=150.0, target_ms=200.0) + self.assertEqual(result["passed"], + glide_ms >= glide_of_grid(RISING_EDGE_GRID), + (glide_ms, result)) class T3NoDarkening(unittest.TestCase): @@ -1490,9 +1636,11 @@ def _kit_tail(self, cls, seconds, **options): return declared, result["values"], result["red"] def test_the_tail_reaches_exact_zero_inside_tail_samples(self): - # The kit's TAIL on its own -6 dBFS burst, at the defaults and at - # the Sync-on patch with the highest Feedback under 0.5. - for options, seconds in (({}, 5.0), ({"patch": 2}, 6.0)): + # The kit's TAIL on its own -6 dBFS burst, at the defaults, at the + # Sync-on patch with the highest Feedback under 0.5, and at patch 4 + # (Feedback 0.70), which held 1 LSB for ever at audiodsp v0.6.1. + for options, seconds in (({}, 5.0), ({"patch": 2}, 6.0), + ({"patch": 4}, 24.0)): declared, values, red = self._kit_tail(DigitalDelay, seconds, **options) self.assertEqual(red, [], (options, values)) @@ -1501,36 +1649,63 @@ def test_the_tail_reaches_exact_zero_inside_tail_samples(self): self.assertNotEqual(red, [], values) self.assertGreater(values["tail_samples"], declared) - def test_the_floor_is_gone_at_feedback_half(self): + def test_the_tail_is_bounded_at_every_feedback(self): # Up to audiodsp v0.6.1 the node rounded the feedback write to # nearest, so from Feedback 0.5 a 1 LSB repeat wrote itself back - # forever: on this material 1 LSB was still going round after three - # seconds. v0.6.2 (audiodsp#154) truncates the fed-back term toward - # zero exactly where rounding would hand it back unchanged, so the - # largest sample on the line falls by at least 1 LSB a lap and, from - # full scale, by the geometric bound until then - # (`laps_to_exact_zero`). The tail therefore ends inside that many - # laps of at most one frame past the 600-frame Time (measured at - # v0.6.2: silent 8 748 frames after the input, bound 9 616). The class - # still says None here: its declaration is DigitalDelay's re-audit - # to change, not this pin move's. - values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) - input_end = len(values) - values += [0] * (3 * RATE) - effect = DigitalDelay(array_src(values), sample_rate=RATE, - time_ms=12.5, feedback=0.5, mix=2.0) - self.assertIsNone(effect.tail_samples) - out = pull(effect, len(values)) - bound = laps_to_exact_zero(0.5) * (600 + 1) - nonzero = np.nonzero(out)[0] - self.assertGreater(len(nonzero), 0) - last = int(nonzero[-1]) // 2 - self.assertLessEqual(last - input_end, bound, - "the tail ran %d frames past the input, " - "over the %d-frame bound" - % (last - input_end, bound)) - effect.set_macro(FEEDBACK_I, 64) - self.assertIsNotNone(effect.tail_samples) + # for ever, and the class declared None there. v0.6.2 (#154) steps + # the fed-back term toward zero where rounding would stall, so the + # class declares `laps_to_zero(f)` laps at every Feedback, and the + # tail ends inside it: on the old floor cell, and on full scale, + # which meets the lap count exactly. + for feedback in (0.5, 0.7, 0.99): + for material in ("floor", "fullscale"): + result = tail_measure(DigitalDelay, feedback, material) + self.assertTrue(result["passed"], + (feedback, material, result)) + # Repeat Tone in, where a bound holds. + for feedback, tone_hz in ((0.35, 800.0), (0.7, 800.0), + (0.7, 3000.0)): + result = tail_measure(DigitalDelay, feedback, "fullscale", + tone_hz=tone_hz) + self.assertTrue(result["passed"], (feedback, tone_hz, result)) + # The pin mover's own geometric bound agrees at 0.5. + self.assertLessEqual(tail_measure(DigitalDelay, 0.5, "floor")["tail"], + laps_to_exact_zero(0.5) * (600 + 1)) + # The old declaration is red, and so is a bound one lap short on + # full scale (at 0.99 the margin makes the bound a lap loose, so + # one lap short still holds there). + self.assertFalse(tail_measure(FloorEraTailDelay, 0.5, + "floor")["passed"]) + for feedback in (0.35, 0.5, 0.7, 0.9): + result = tail_measure(LapShortTailDelay, feedback, "fullscale") + self.assertFalse(result["passed"], (feedback, result)) + result = tail_measure(SixtyDbTailDelay, 0.7, "fullscale") + self.assertFalse(result["passed"], result) + + def test_repeat_tone_can_hold_a_value_where_the_class_says_none(self): + # With Repeat Tone in, the node sends its damping state round, and + # that single-precision state can rest a few ulps above a small + # value v. Where 0.5 / (1 - f) is v, `recirculated` then rounds + # f x back up to v, and the line holds v for ever: 1 LSB at 0.5, + # 2 at 0.75, 5 at 0.9 on a 2 LSB DC, Tone 800 Hz, at audiodsp + # v0.6.2 (issue draft `audiodsp-feedback-delay-damping-holds-the- + # line.md`). The class says None there. Fix round 2's rule for + # Repeat Tone, one extra lap, declares a finite tail and is red. + for feedback, held in ((0.5, 1), (0.75, 2), (0.9, 5)): + result = tail_measure(DigitalDelay, feedback, "dc2", + tone_hz=800.0) + self.assertIsNone(result["declared"], feedback) + self.assertEqual(result["held"], held, (feedback, result)) + result = tail_measure(OneLapToneDelay, 0.9, "dc2", tone_hz=800.0) + self.assertIsNotNone(result["declared"]) + self.assertFalse(result["passed"], result) + # Where the class does declare a bound with Repeat Tone in, the + # low-pass still stretches the tail past one extra lap: Feedback + # grid 113, Tone at its 800 Hz stop, full scale. + feedback = 113 * 0.99 / 127.0 + for cls, passed in ((DigitalDelay, True), (OneLapToneDelay, False)): + result = tail_measure(cls, feedback, "fullscale", tone_hz=800.0) + self.assertEqual(result["passed"], passed, (cls, result)) def _walk_tail(self, cls): """800 ms of 997 Hz, then Time 800 -> 12.5 ms at the default Glide @@ -1714,6 +1889,62 @@ def test_a_seven_bit_trip_keeps_the_glide(self): glide_ms) self.assertEqual((midi, after), (0, 0.0), glide_ms) + def test_a_slow_constructor_glide_reads_back_as_itself(self): + # Re-audit fix round 1: a constructor Glide slower than the span's + # 8 s plays 8 s, so a float or a 7-bit trip through get_macro(3) + # lands within one grid step of it (the top step is 10^(1/127) - 1, + # 1.83 %). Up to fix round 2 it played the slower Glide while the + # knob read 8 s: 16 s came back +100 %, 80 s +900 %. + step = 10.0 ** (1.0 / 127.0) - 1.0 + for glide_ms in (8000.0, 12000.0, 16000.0, 80000.0): + before, after = self._round_trip(DigitalDelay, glide_ms) + self.assertAlmostEqual(before, 787.5 / 8000.0, places=12) + self.assertLessEqual(abs(after / before - 1.0), step, glide_ms) + before, midi, after = self._seven_bit_trip(DigitalDelay, + glide_ms) + self.assertEqual(midi, 127, glide_ms) + self.assertLessEqual(abs(after / before - 1.0), step, glide_ms) + for glide_ms, gain in ((16000.0, 1.0), (80000.0, 9.0)): + for trip in (self._round_trip, self._seven_bit_trip): + got = trip(SlowSeedGlideDelay, glide_ms) + before, after = got[0], got[-1] + self.assertAlmostEqual(after / before - 1.0, gain, + places=6, msg=(glide_ms, trip)) + + +class ConstructorZeros(unittest.TestCase): + """Re-audit fix round 1: `time_ms`, `tone_hz` and `cut_hz` at 0 built + nothing (`math domain error` from the log knob's seed). 0 is how the + node spells a filter out of circuit, so Repeat Tone and Repeat Cut at 0 + are their out stops, and Time at 0 is the bottom of its span, beside + the out-of-span values that already clamped.""" + + def _built(self, **options): + effect = DigitalDelay(silence_src(512), sample_rate=RATE, **options) + return (effect._frames, effect.get_macro(TIME_I), + effect.get_macro(TONE_I), effect.get_macro(CUT_I), + effect._tone_damping(effect._macros[TONE_I]), + effect._cut_hz(effect._macros[CUT_I])) + + def test_zero_builds_and_means_the_stop(self): + bottom_and_out = (600, 0.0, 127.0, 0.0, 0.0, 0.0) + for options in ({"time_ms": 0.0, "tone_hz": 0.0, "cut_hz": 0.0}, + {"time_ms": -5.0, "tone_hz": -1.0, "cut_hz": -1.0}, + {"time_ms": 5.0, "tone_hz": 30000.0, "cut_hz": 5.0}): + self.assertEqual(self._built(**options), bottom_and_out, + options) + + def test_zero_filters_render_as_filters_out(self): + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * 4096 + renders = [] + for options in ({}, {"tone_hz": 0.0, "cut_hz": 0.0}): + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.7, **options) + renders.append(pull(effect, len(values))) + self.assertTrue(np.array_equal(renders[0], renders[1])) + self.assertGreater(int(np.abs(renders[0]).max()), 0) + class ZeroBpmHost(unittest.TestCase): def test_a_tempo_that_is_not_finite_leaves_time_on_the_knob(self): From c949fb0b7ee6686195155ad3c38cdb517c79e45b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:31:02 -0500 Subject: [PATCH 09/88] DigitalDelay re-audit fix round 2: Repeat Tone steps clear of the node's stall, T2 read per binade With Repeat Tone in circuit the class hands the node the nearer edge of its own stall window (clear_of_stalls, at most 2.44e-5 of Feedback; get_macro(1) untouched), so tail_samples is finite wherever Repeat Cut is out, and every stall cell and window centre reaches exact zero inside it. Shown red beside StallWindowToneDelay (re-audit fix round 1's class: None, 1-5 LSB held). T2's pitch clause is read per binade of the node's float32 walk (t2_segments, on a fine ramp and the sine fit); grid 3.0079 is red there where the whole-walk fit passed, and the rising edge moves to Glide grid 4. Shown red with RISING_EDGE_GRID back at 3.0. A NaN glide_ms is the jump and a NaN max_time_ms is 800 ms; both tests red on 0b96718's class. --- lib/audioeffects/rebuilt/digitaldelay.py | 142 ++++++-- tests/test_cpython_effects_digitaldelay.py | 394 +++++++++++++++++---- 2 files changed, 444 insertions(+), 92 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 1eb2e34..fa69dee 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -80,25 +80,34 @@ reads back at grid 1, the fastest walk it has, never at grid 0, the jump: handing `get_macro(3)` back to `set_macro(3, ...)`, even rounded to a 7-bit MIDI value, keeps the glide (at grid 1's slew, 0.967). One slower -than the knob's 8 s plays 8 s, and the knob reads 8 s. A constructor -Time of 0 is 12.5 ms, and a Repeat Tone or Repeat Cut of 0 is that -filter out of circuit, as it is on the node. +than the knob's 8 s plays 8 s, and the knob reads 8 s; a Glide of 0, +a negative or NaN is the jump. A constructor Time of 0 is 12.5 ms, and +a Repeat Tone or Repeat Cut of 0 is that filter out of circuit, as it is +on the node. A `max_time_ms` above 800 or NaN is 800 ms. **Where the pitch claim stops.** The node walks the read head in single precision, so the rate it plays is the Glide's rate rounded to the float -step of the delay. Near a stall that rounding is worth several cents. -The claim is stated for a rising 150 -> 200 ms move: at the Glide knob's -fastest positions, strictly between grid 1 and grid 3 (814.6-844.7 ms), -the repeats can read up to 15.4 cents off the glide law at 48 kHz and -12.7 at 44.1 kHz, so that part of the knob is not claimed on a rising -move. Grid 1 itself and everything from grid 3 up are (worst 8.7 cents). -Nor is a constructor Glide under 844.7 ms claimed on a rising move, -except 800 ms (slew 63/64, which single precision holds exactly): at -the 0.99 pin a rising Time can read 41 cents off. A longer rising move -reaches delays where the float step is coarser, and at a fast Glide it -is not claimed: 350 -> 500 ms at grid 3 reads +15.8 cents at 48 kHz and -+15.3 at 44.1 kHz, and 400 -> 800 ms at grid 5 reads +10.7 at 48 kHz. -At the default Glide every move the dossier names is inside 10 cents. +step of the delay, and that step doubles each time the delay passes a +power of two: a move across one plays two rates, one on each side. Near +a stall that rounding is worth several cents. The claim is stated for a +rising 150 -> 200 ms move, read on each side of the power of two it +crosses: at the Glide knob's fastest positions, strictly between grid 1 +and grid 4 (814.6-860.2 ms), the part of that move past the power of +two can read up to 25.3 cents off the glide law at 48 and 44.1 kHz and +12.7 at 22.05 kHz (just above grid 3 it is 12.5 cents over the last +26 ms of the walk), so that part of the knob is not claimed on a rising +move. Grid 1 itself and everything from grid 4 up are, and the margin +at the edge is thin: grid 4 reads 9.7 cents and the worst position above +it, grid 4.05, 9.9 cents, against a 10-cent bar. Nor is a constructor +Glide under 860.2 ms claimed on a rising move, except 800 ms (slew +63/64, which single precision holds exactly): at the 0.99 pin a rising +Time can read 41 cents off. A longer rising move reaches delays where +the float step is coarser, and at a fast Glide it is not claimed: +350 -> 500 ms at grid 3 reads +15.8 cents at 48 and 44.1 kHz, 400 -> +800 ms at grid 3 reads -34.4 cents past 32 768 frames at 44.1 kHz, and +at grid 5 +10.7 at 48 kHz. At the default Glide every move the dossier +names is inside 10 cents on each side of every power of two it crosses +(worst 3.4 cents, the full-range move's last stretch). The pitch and no-step claims are measured on inputs from -8.7 to -0.2 dBFS. Quieter, int16 rounding decides the reading: a near-stall rising glide is a few LSB of signal, and even a falling move at @@ -129,14 +138,18 @@ 685 at 0.99 (nine minutes at Time 800 ms). Since audiodsp v0.6.2 the node steps a repeat toward zero wherever rounding would hand it back unchanged, so with both filters out the bound holds at every Feedback. -With Repeat Tone in circuit each lap is a little longer, and wherever -0.5 / (1 - Feedback) is within a hair of a whole number (0.5, 0.75, 0.83, -0.875, 0.9 and on up to the knob's top, 0.99) it is `None`: there the -node's loop low-pass can come to rest a hair above a small value and send -it round for ever. A 2 LSB DC at Feedback 0.5 with Repeat Tone at 800 Hz -leaves 1 LSB going round; at 0.9, 5 LSB. That is a floor bug in the node, -not in this class, and it keeps the rebuild parked. With Repeat Cut in -circuit it is `None` too: no bound is derived there. +With Repeat Tone in circuit each lap is a little longer, and the node +has a second floor of its own: wherever 0.5 / (1 - Feedback) is within a +hair of a whole number k (0.5, 0.75, 0.83, 0.875, 0.9 and on up to the +knob's top, 0.99), its loop low-pass can come to rest a hair above k LSB +and send it round for ever (a 2 LSB DC at Feedback 0.5, Tone 800 Hz, +used to leave 1 LSB going round; at 0.9, 5 LSB). So with Repeat Tone in +the class hands the node a Feedback just outside each of those windows, +at the nearer edge, at most 0.00003 from the one you set (0.99 plays as +about 0.98998 and 0.5 as 0.49999), far inside one step of the knob, +which still reads what you set. The tail then reaches exact zero there too, inside +a finite `tail_samples`. With Repeat Cut in circuit `tail_samples` is +`None`: no bound is derived there. `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). @@ -198,6 +211,20 @@ #: single-precision Python, can only make the bound longer, never shorter. FEEDBACK_MARGIN = 2.0 ** -16 +#: How far outside a Repeat Tone stall window `clear_of_stalls` puts the +#: Feedback it hands the node, relative to the window's edge: many times a +#: single-precision float's step (2^-24), so a board's arithmetic lands on +#: the same side, and far under the window's own width (2-4 x 10^-5) and +#: the knob's 7-bit step (0.0078). +STALL_CLEARANCE = 2.0 ** -20 + +#: `stall_window` widens each window by this much, relatively, either side, +#: so a Feedback on an edge that `laps_to_zero`'s own rounding puts inside +#: (a constructor value that comes back through the macro 10^-17 away) is +#: moved too. Four times under `STALL_CLEARANCE`, so a moved value is +#: never itself on the widened edge. +STALL_FUZZ = 2.0 ** -22 + #: 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. @@ -313,6 +340,53 @@ def laps_to_zero(feedback, excess=0.0): return laps +def stall_window(feedback, excess): + """The Repeat Tone stall window `feedback` sits in, as (low, high), or + `None` outside every window. + + `laps_to_zero(f, excess)` is `None` exactly when some whole peak x it + reaches is handed back: the rounding branch taken, + x (1 + excess)(1 - f (1 - m)) > 0.5, and the image not below x, + f (1 + m) x (1 + excess) + 0.5 >= x, m being `FEEDBACK_MARGIN`. For + each x that is one window, [(1 - 0.5 / x) / ((1 + m)(1 + excess)), + (1 - 0.5 / (x (1 + excess))) / (1 - m)), 2-4 x 10^-5 wide around + 1 - 0.5 / x; x = 1 ... 50 are the ones under the node's 0.99. Each is + returned widened by `STALL_FUZZ` either side. With Repeat Tone out + (`excess` 0) no lap hands a value back, and there is no window.""" + if excess <= 0.0 or feedback <= 0.0: + return None + grow = (1.0 + FEEDBACK_MARGIN) * (1.0 + excess) + centre = int(math.floor(0.5 / (1.0 - feedback) + 0.5)) if feedback < 1.0 \ + else 50 + for x in (centre - 1, centre, centre + 1): + if x < 1: + continue + low = (1.0 - 0.5 / x) / grow * (1.0 - STALL_FUZZ) + high = ((1.0 - 0.5 / (x * (1.0 + excess))) / (1.0 - FEEDBACK_MARGIN) + * (1.0 + STALL_FUZZ)) + if low <= feedback < high: + return low, high + return None + + +def clear_of_stalls(feedback, excess): + """`feedback` moved to the nearer edge of the Repeat Tone stall window + it sits in (`stall_window`), just outside it, or unchanged outside + every window. The top window's upper edge is above the node's 0.99, so + there it always moves down. Every move is under 3 x 10^-5 of Feedback + (2.6 x 10^-5 at the 0.99 stop), far inside one step of the 7-bit knob, + so `get_macro(1)` still names the setting that plays.""" + window = stall_window(feedback, excess) + if window is None: + return feedback + low, high = window + below = low * (1.0 - STALL_CLEARANCE) + above = high * (1.0 + STALL_CLEARANCE) + if above > FEEDBACK_MAX or feedback - below <= above - feedback: + return below + return above + + class DigitalDelay(_component.Component): """A clean digital delay with the DD-2's control law: the dry path is a wire, and turning Time pitch-bends the repeats instead of clicking. @@ -374,7 +448,9 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, sync=False, division=6, tone_hz=16000.0, cut_hz=20.0, max_time_ms=TIME_MAX_MS, patch=None): max_time_ms = float(max_time_ms) - if max_time_ms > TIME_MAX_MS: + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: max_time_ms = TIME_MAX_MS if max_time_ms < TIME_MIN_MS: max_time_ms = TIME_MIN_MS @@ -412,6 +488,10 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, glide_ms = float(glide_ms) if glide_ms > GLIDE_MAX_MS: glide_ms = GLIDE_MAX_MS + if not glide_ms > 0.0: + # 0, a negative and NaN are the jump; a NaN slew would neither + # walk nor jump, and the Time knob would do nothing. + glide_ms = 0.0 self._glide_exact = glide_ms self._seeding = True self._delay = audioecho.FeedbackDelay( @@ -571,6 +651,11 @@ def _refresh(self): feedback = FEEDBACK_MAX if feedback < 0.0: feedback = 0.0 + if self._macros[TONE_I] < 1.0: + # With Repeat Tone in, the node can hold a small value for ever + # at Feedback values a hair either side of 1 - 0.5 / k; the node + # is handed the nearer edge of that window instead. + feedback = self._loop_feedback(feedback) self._feedback = feedback self._delay.set( delay_slew=slew, @@ -580,6 +665,11 @@ def _refresh(self): damping_hz=self._tone_damping(self._macros[TONE_I]), cut_hz=self._cut_hz(self._macros[CUT_I])) + def _loop_feedback(self, feedback): + """The Feedback handed to the node with Repeat Tone in circuit: + `clear_of_stalls` at this Tone's excess.""" + return clear_of_stalls(feedback, self._tone_excess()[1]) + @property def tail_samples(self): """Frames until the output is exactly zero once the input stops, as @@ -627,6 +717,8 @@ def _tone_excess(self): if self._macros[TONE_I] >= 1.0: return 0, 0.0 damping = self._tone_damping(self._macros[TONE_I]) + if damping <= 0.0: + return 0, 0.0 per_frame = 2.0 * math.pi * damping / self._sample_rate coefficient = 1.0 - math.exp(-per_frame) frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index a35d652..a73c207 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -42,6 +42,15 @@ class says `None`, beside fix round 2's one-extra-lap rule. T2's rising class of the node's walk, and the edge test goes red at the old edge. A constructor Glide slower than 8 s reads back as the 8 s it plays, and a constructor Time, Repeat Tone or Repeat Cut of 0 builds. + +Re-audit fix round 2 (2026-09-27, after gate re-audit 1): with Repeat Tone +in circuit the class hands the node a Feedback just outside its own stall +windows, and every stall cell and window centre reaches exact zero inside +a finite `tail_samples`, beside re-audit fix round 1's class, which said +`None` there and held 1-5 LSB. T2's pitch clause is read per binade of the +node's walk (`t2_segments`), which goes red at grid 3.0079 where the +whole-walk fit passed, and the rising edge moves to Glide grid 4. A NaN +`glide_ms` is the jump and a NaN `max_time_ms` is 800 ms. """ import math @@ -483,6 +492,18 @@ def tail_samples(self): return int(laps * (self._reach + 1)) +class StallWindowToneDelay(DigitalDelay): + """Re-audit fix round 1's class with Repeat Tone in: the node handed + the Feedback asked for, inside the stall windows, where the class said + `None` and the node held 1-5 LSB for ever (1 at Feedback 0.5, 5 at + 0.9, Tone 800 Hz, a 2 LSB DC).""" + + NAME = 'DigitalDelay' + + def _loop_feedback(self, feedback): + return feedback + + class SlowSeedGlideDelay(DigitalDelay): """The seeding up to re-audit fix round 1: a constructor Glide slower than 8 s stayed on the audio path while the knob read 8 s, so handing @@ -656,8 +677,10 @@ def dossier_slew(glide_ms): #: Where T2's rising 150 -> 200 ms move is claimed from on the Glide knob -#: (dossier section 8.12): grid 3, 844.72 ms. Fix round 2 said grid 2. -RISING_EDGE_GRID = 3.0 +#: (dossier section 8.13): grid 4, 860.17 ms, read per binade. Fix round 2 +#: said grid 2 and re-audit fix round 1 grid 3, both read by one fit over +#: the whole walk. +RISING_EDGE_GRID = 4.0 def glide_of_grid(grid): @@ -734,6 +757,96 @@ def move(frame): return left(pull(effect, frames, 2, on_block=move), 2) +#: The shortest binade segment of a walk the pitch clause reads (frames, +#: after trimming); a shorter one is reported unread. +T2_MIN_SEGMENT = 128 + +#: The sine fit reads a segment only where it holds at least this many +#: periods of the shifted tone; under that (the top binade of a near-stall +#: rising walk at 22.05 kHz is 0.1-0.3 of a period, where the fit read +#: -18.6 c at grid 1 and +142 c at 800 ms against the ramp's -5.65 and +#: +0.08) it reads its own floor, not the walk, and the fine ramp alone is +#: graded there. +T2_MIN_PERIODS = 1.0 + + +def t2_fine_delay(cls, rate, start_ms, target_ms, glide_ms, glide_grid, + move_at, frames, end, coarse): + """The delay the node plays at each frame, read off a ramp as steep as + int16 allows over just the source frames the walk reads: n - D(n) runs + from move_at - a to end - b (a, b the whole-frame start and target), so + near a stall the walk reads only a few dozen source frames and the ramp + can climb hundreds of LSB per frame. Linear interpolation is exact on + a ramp, so the only error is the output's int16 rounding, half an LSB, + which is 0.5 / slope of a frame. A walk that reads more source frames + than int16 can ramp over one LSB at a time (the full-range move) keeps + `coarse`, the measurement's own slope-1/k ramp, whose k-frame steps + are nothing against segments tens of thousands of frames long.""" + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + j_lo = min(move_at - a, end - b) - 16 + j_hi = max(move_at - a, end - b) + 16 + if j_hi - j_lo > 60000: + return coarse, 0 + slope = min(1000, 60000 // (j_hi - j_lo)) + values = [max(-30000, min(30000, -30000 + slope * (j - j_lo))) + for j in range(frames)] + r = t2_render(cls, rate, values, start_ms, target_ms, glide_ms, + glide_grid, move_at, frames) + delay = np.arange(frames) - (j_lo + (r + 30000.0) / slope) + delay[(r <= -30000) | (r >= 30000)] = np.nan + return delay, slope + + +def t2_segments(y, delay, rate, start_ms, target_ms, slew, first, last, + falling, ratio): + """T2's pitch clause read per binade of the node's walk (re-audit fix + round 2). The node steps `current += slew` in float32 + (`audiodsp_feedback_delay.c:444` / `:449` at v0.6.2), so inside each + binade [2^e, 2^(e+1)) of the read head it plays the slew rounded to + 2^(e-23): a walk that crosses a power of two plays two rates, and one + fit over the whole walk reads their weighted mean, which is what hid + gate re-audit 1's grid 3.0079. The walk window [first, last) is cut + where the fine delay (`t2_fine_delay`) crosses each power of two, less + a pad either side, and each piece of at least `T2_MIN_SEGMENT` frames + is read two ways against the law: the fine ramp's delay slope, and + `ls_hz` on the 997 Hz render where the piece holds `T2_MIN_PERIODS` of + the shifted tone. Returns [(start, stop, sine cents or None, ramp cents + or None)]; both None where a piece is too short to read.""" + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + lo, hi = min(a, b), max(a, b) + cuts = [] + e = int(math.floor(math.log(lo, 2))) + 1 + span = delay[first:last] + while 2 ** e < hi: + bound = float(2 ** e) + with np.errstate(invalid="ignore"): + crossed = np.where(span < bound if falling else span >= bound)[0] + if len(crossed): + cuts.append(first + int(crossed[0])) + e += 1 + pad = int(math.ceil(2.0 / slew)) + 8 + edges = [first] + sorted(cuts) + [last] + shifted = 997.0 * ratio + out = [] + for i in range(len(edges) - 1): + s0 = edges[i] + (pad if i > 0 else 0) + s1 = edges[i + 1] - (pad if i + 1 < len(edges) - 1 else 0) + piece = delay[s0:s1] + if s1 - s0 < T2_MIN_SEGMENT or np.isnan(piece).any(): + out.append((s0, s1, None, None)) + continue + played = abs(float(np.polyfit(np.arange(s0, s1), piece, 1)[0])) + ramp = cents(((1.0 + played) if falling else (1.0 - played)) + / ratio) + sine = None + if (s1 - s0) * shifted / rate >= T2_MIN_PERIODS: + sine = cents(ls_hz(y[s0:s1], rate) / shifted) + out.append((s0, s1, sine, ramp)) + return out + + def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, target_ms=150.0, move_at=20480, glide_grid=None, level=12000): """T2's three clauses at one cell, against the dossier's own law. @@ -742,8 +855,12 @@ def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, constructor's `glide_ms`, or the knob's label at `glide_grid` - never the class's own `slew_of`. The walk's end is read off a ramp render of the same move (the node's float32 walk ends early, A8.1). Pitch: the - least-squares frequency of the walk less a margin of min(400, walk/10) - frames at each end; residual: the same fit 50-250 ms after the walk. + walk less a margin of min(400, walk/10) frames at each end, read per + binade of the node's walk (`t2_segments`), and the worst piece is + `pitch_cents`; `whole_pitch_cents` is the least-squares fit over the + whole window, the reading up to re-audit fix round 1, which averaged + two rates where the walk crosses a power of two. Residual: the same + fit 50-250 ms after the walk. No-step: the largest first difference in the walk against 1.05 |1 - dD| x the unramped maximum, a gap past the ramp's end against the looser legitimate slope, and 2000 frames after against the unramped @@ -798,8 +915,14 @@ def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, after = float(np.abs(np.diff(y[end + margin:end + margin + 2000])).max()) edge = min(400, walked // 10) window = walked - 2 * edge - pitch = cents(ls_hz(y[move_at + edge:end - edge], rate) + whole = cents(ls_hz(y[move_at + edge:end - edge], rate) / (997.0 * ratio)) + fine, _ = t2_fine_delay(cls, rate, start_ms, target_ms, glide_ms, + glide_grid, move_at, frames, end, delay) + segments = t2_segments(y, fine, rate, start_ms, target_ms, slew, + move_at + edge, end - edge, falling, ratio) + read = [c for s in segments for c in s[2:] if c is not None] + pitch = max(read, key=abs) if read else whole settled = end + margin residual = cents(ls_hz(y[settled + int(0.05 * rate): settled + int(0.25 * rate)], rate) / 997.0) @@ -809,6 +932,7 @@ def t2_measure(cls, rate=RATE, glide_ms=3937.5, start_ms=200.0, and walk <= result["bar"] and gap <= bar_gap and after <= bar_after) result.update({"passed": passed, "pitch_cents": pitch, + "whole_pitch_cents": whole, "segments": segments, "residual_cents": residual, "walk": walk, "body": body, "end": end_jump, "gap": gap, "after": after, "bar_gap": bar_gap, "bar_after": bar_after, @@ -860,6 +984,30 @@ def advance(c): return 1200.0 * np.log2(ratio) +def binade_model_cents(rate, grids, start_ms=150.0, target_ms=200.0): + """The exact per-binade model of the node's walk (re-audit fix round + 2): in each binade the move crosses, the slew as float32 rounded to + that binade's ulp, 2^(e-23), against the dossier's law. Returns, per + Glide grid position, the worst binade's cents off the law.""" + grids = np.asarray(grids, dtype=np.float64) + # dossier_slew(glide_of_grid(g)) for every g > 0, vectorised + law = np.minimum(0.99, 787.5 / (800.0 * 10.0 ** (grids / 127.0))) + s32 = law.astype(np.float32).astype(np.float64) + a = dd.whole_frames(start_ms, rate) + b = dd.whole_frames(target_ms, rate) + worst = np.zeros(len(grids)) + for e in range(int(math.floor(math.log(min(a, b), 2))), + int(math.floor(math.log(max(a, b), 2))) + 1): + ulp = 2.0 ** (e - 23) + played = np.round(s32 / ulp) * ulp + if b > a: + c = 1200.0 * np.log2((1.0 - played) / (1.0 - law)) + else: + c = 1200.0 * np.log2((1.0 + played) / (1.0 + law)) + worst = np.where(np.abs(c) > np.abs(worst), c, worst) + return worst + + def t3_burst(rate): n = int(rate * 50.0 / 1000.0) rng = np.random.RandomState(12345) @@ -1052,7 +1200,7 @@ def shares(segment): def tail_measure(cls, feedback, material, rate=RATE, channels=2, - time_ms=12.5, **options): + time_ms=12.5, budget=None, **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 @@ -1063,7 +1211,7 @@ def tail_measure(cls, feedback, material, rate=RATE, channels=2, time_ms=time_ms, feedback=feedback, mix=2.0, **options) lap = probe._frames - budget = probe.tail_samples or 4 * rate + budget = budget or probe.tail_samples or 4 * rate probe.deinit() if material == "floor": values = [0] * 256 + sine_values(997.0, 2048, rate, 12000) @@ -1332,16 +1480,23 @@ def test_tail_samples_follows_time_and_feedback(self): effect.set_macro(FEEDBACK_I, 64) self.assertEqual(effect.tail_samples, 15 * 601) # Repeat Tone in: each lap is the low-pass's forgetting time longer - # (22 frames at grid 100, 48 kHz), and at Feedback 0.99 the class - # has no bound, because a lap can hand a value back there. + # (22 frames at grid 100, 48 kHz). Feedback 0.99 sits in a stall + # window, so the node is handed the window's lower edge, a hair + # under 0.99, the knob still reads 127, and the bound is finite. effect.set_macro(TONE_I, 100) self.assertEqual(effect._tone_excess()[0], 22) self.assertEqual(effect.tail_samples, 15 * (601 + 22)) effect.set_macro(FEEDBACK_I, 65) self.assertEqual(effect.tail_samples, 17 * (601 + 22)) effect.set_macro(FEEDBACK_I, 127) - self.assertIsNone(effect.tail_samples) + self.assertEqual(effect.get_macro(FEEDBACK_I), 127.0) + self.assertLess(effect._feedback, 0.99) + self.assertGreater(effect._feedback, 0.99 - 3e-5) + laps = dd.laps_to_zero(effect._feedback, effect._tone_excess()[1]) + self.assertIsNotNone(laps) + self.assertEqual(effect.tail_samples, laps * (601 + 22)) effect.set_macro(TONE_I, 127) + self.assertEqual(effect._feedback, 0.99) self.assertEqual(effect.tail_samples, 685 * 601) self.assertEqual(dd.laps_to_zero(0.99, 1e-5), None) self.assertEqual(dd.laps_to_zero(0.5, 1e-5), None) @@ -1426,12 +1581,14 @@ def test_a_rising_move_at_the_default_glide(self): self.assertTrue(result["passed"], result) def test_a_near_stall_rising_cell(self): - # Glide grid 2, 150 -> 200 ms: the law's ratio is 0.051 (a 50 Hz - # tone, 1.7 cycles in the window). The analytic-signal median read - # -15.00 c here; the least-squares fit reads the node. - result = t2_measure(DigitalDelay, glide_grid=2, start_ms=150.0, - target_ms=200.0) - self.assertTrue(result["passed"], result) + # Glide grid 1, 150 -> 200 ms: the law's ratio is 0.033 (a 33 Hz + # tone), and at 22.05 kHz the walk's top binade is under a third of + # a period, where the sine fit reads its own floor (-18.6 c) and + # the fine ramp reads the walk (-5.65 c). Claimed at three rates. + for rate in (48000, 44100, 22050): + result = t2_measure(DigitalDelay, rate=rate, glide_grid=1, + start_ms=150.0, target_ms=200.0) + self.assertTrue(result["passed"], (rate, result)) def test_the_constructor_800_ms_cell(self): result = t2_measure(DigitalDelay, glide_ms=800.0) @@ -1447,49 +1604,69 @@ def test_the_block_staircase_is_red(self): self.assertFalse(result["passed"], result) self.assertGreater(result["body"], result["bar"]) - def test_the_unclaimed_band_is_where_the_float32_walk_says(self): - # A cross-check, not where the edge came from: the float32 model of - # the node's walk, over the whole knob in steps of 0.01, puts every - # position more than 10 c off the law inside the unclaimed band at - # every rate, and does find some there at 48 kHz (so the scan can - # fail). The edge is placed by rendering every rounding class - # (`digitaldelay_reaudit1_t2edge.py`); the model reads 1-1.5 c - # kinder than the render near the stall, which is how fix round 2 - # put the edge at grid 2 and grid 2.0342 got past it. - grids = np.arange(100, 12701) / 100.0 + def test_the_unclaimed_band_is_where_the_binade_model_says(self): + # A cross-check: the exact per-binade model of the node's walk, + # over the whole knob in steps of 0.0001, puts every position more + # than 10 c off the law inside the unclaimed band (1, 4) at every + # rate, and does find some there at 48 and 44.1 kHz, so the scan + # can fail. Falling moves stay under 1 c everywhere. + grids = np.arange(10000, 1270001) / 10000.0 for rate in (48000, 44100, 22050): - off = grids[np.abs(f32_walk_cents(rate, grids)) > 10.0] + model = binade_model_cents(rate, grids) + off = grids[np.abs(model) > 10.0] self.assertTrue(np.all((off > 1.0) & (off < RISING_EDGE_GRID)), (rate, off[(off <= 1.0) | (off >= RISING_EDGE_GRID)])) - if rate == 48000: - self.assertGreater(len(off), 0) - falling = f32_walk_cents(48000, grids, 200.0, 150.0) - self.assertLess(float(np.abs(falling).max()), 10.0) + if rate != 22050: + self.assertGreater(len(off), 0, rate) + falling = binade_model_cents(rate, grids, 200.0, 150.0) + self.assertLess(float(np.abs(falling).max()), 1.0, rate) + + def test_the_pitch_is_read_per_binade(self): + # Gate re-audit 1's cell: grid 3.0079, rising 150 -> 200 ms. The + # walk plays the law below 8192 frames and 12.5 c off it above; one + # fit over the whole walk reads their mean (-8.66 c at 48 kHz, + # -1.57 c at 44.1 kHz) and passed. Read per binade it is red, the + # sine fit and the fine ramp agreeing on the top piece. + for rate in (48000, 44100): + result = t2_measure(DigitalDelay, rate=rate, glide_grid=3.0079, + start_ms=150.0, target_ms=200.0) + self.assertLess(abs(result["whole_pitch_cents"]), 10.0, + (rate, result)) + self.assertLess(result["pitch_cents"], -12.0, (rate, result)) + self.assertFalse(result["passed"], (rate, result)) + top = result["segments"][-1] + self.assertLess(top[3], -12.0, (rate, top)) + # At 22.05 kHz the same position holds (+0.02 c on the ramp). + result = t2_measure(DigitalDelay, rate=22050, glide_grid=3.0079, + start_ms=150.0, target_ms=200.0) + self.assertTrue(result["passed"], result) def test_inside_the_band_is_red_and_outside_it_holds(self): - # Re-audit fix round 1 (dossier section 8.12): the rising 150 -> - # 200 ms move is claimed from Glide grid 3 up (and at grid 1 - # itself). Every rounding class of the node's float32 walk was - # rendered at three rates; the red ones reach grid 2.505 at - # 48 kHz and 1.512 at 44.1 kHz, none at 22.05 kHz. Inside: gate - # audit round 3's cell 2.0342, which fix round 2's edge claimed, - # and each rate's worst class. Outside: the edge, and the worst - # class above it at each rate (8.68 c at 48 kHz, just above 3). - # A cell passes exactly when the edge claims it, so an edge moved - # back to 2 claims 2.0342 and 2.49031 and goes red on them. + # Re-audit fix round 2 (dossier section 8.13): the rising 150 -> + # 200 ms move is claimed at Glide grid 1 and from grid 4 up, read + # per binade. Inside: gate audit round 3's 2.0342 and 2.49031, + # round 2's 1.245 and 1.0076, and gate re-audit 1's 3.0079 at 48 + # and 44.1 kHz, 3.24 and 3.30, which re-audit fix round 1's edge + # (grid 3) claimed. Outside: the edge at three rates and each + # rate's worst position above it in the per-binade model (9.90 c + # at 4.05795 at 48 and 44.1 kHz, 4.98 c at 4.0138 at 22.05). A + # cell passes exactly when the edge claims it, so an edge moved + # back to 3 claims 3.0079, 3.24 and 3.30 and goes red on them. for rate, grid in ((48000, 2.0342), (48000, 2.49031), - (48000, 1.245), (44100, 1.0076), (48000, 3.0), - (48000, 3.00397), (48000, 3.01517), - (48000, 4.06536), (44100, 3.03684), - (22050, 3.00794)): + (48000, 1.245), (44100, 1.0076), + (48000, 3.0079), (44100, 3.0079), (48000, 3.24), + (48000, 3.3), (48000, 4.0), (44100, 4.0), + (22050, 4.0), (48000, 4.05795), (44100, 4.05795), + (22050, 4.0138)): result = t2_measure(DigitalDelay, rate=rate, glide_grid=grid, start_ms=150.0, target_ms=200.0) self.assertEqual(result["passed"], grid >= RISING_EDGE_GRID, (rate, grid, result)) - # The constructor, the same way: 830.056 ms is red (fix round 2 - # claimed it, above its 829.5 ms), the edge's own 844.72 ms holds. - for glide_ms in (830.056, glide_of_grid(3.0)): + # The constructor, the same way: 830.056 ms and 844.84 ms (grid + # 3.0079, which re-audit fix round 1 claimed from its 844.72 ms) + # are red, the new edge's own 860.17 ms holds. + for glide_ms in (830.056, glide_of_grid(3.0079), glide_of_grid(4.0)): result = t2_measure(DigitalDelay, glide_ms=glide_ms, start_ms=150.0, target_ms=200.0) self.assertEqual(result["passed"], @@ -1682,31 +1859,72 @@ def test_the_tail_is_bounded_at_every_feedback(self): result = tail_measure(SixtyDbTailDelay, 0.7, "fullscale") self.assertFalse(result["passed"], result) - def test_repeat_tone_can_hold_a_value_where_the_class_says_none(self): - # With Repeat Tone in, the node sends its damping state round, and - # that single-precision state can rest a few ulps above a small - # value v. Where 0.5 / (1 - f) is v, `recirculated` then rounds - # f x back up to v, and the line holds v for ever: 1 LSB at 0.5, - # 2 at 0.75, 5 at 0.9 on a 2 LSB DC, Tone 800 Hz, at audiodsp - # v0.6.2 (issue draft `audiodsp-feedback-delay-damping-holds-the- - # line.md`). The class says None there. Fix round 2's rule for - # Repeat Tone, one extra lap, declares a finite tail and is red. - for feedback, held in ((0.5, 1), (0.75, 2), (0.9, 5)): + def test_repeat_tone_stall_windows_are_stepped_clear(self): + # With Repeat Tone in, the node sends its single-precision damping + # state round, and that state can rest a few ulps above a small + # value v; where 0.5 / (1 - f) is v, `recirculated` then rounds + # f v back up to v, and the line holds v for ever (issue draft + # `audiodsp-feedback-delay-damping-holds-the-line.md`). Re-audit + # fix round 2 hands the node the nearer edge of the class's own + # window instead. The stall cells on a 2 LSB DC, 20 s at least: + # re-audit fix round 1's class says None and holds v; this class + # declares a bound and reaches exact zero inside it. + for feedback, tone_hz, held in ((0.5, 800.0, 1), (0.75, 800.0, 2), + (1.0 - 0.5 / 3.0, 800.0, 3), + (0.875, 800.0, 4), (0.9, 800.0, 5), + (0.5, 3000.0, 1)): result = tail_measure(DigitalDelay, feedback, "dc2", - tone_hz=800.0) - self.assertIsNone(result["declared"], feedback) - self.assertEqual(result["held"], held, (feedback, result)) - result = tail_measure(OneLapToneDelay, 0.9, "dc2", tone_hz=800.0) - self.assertIsNotNone(result["declared"]) - self.assertFalse(result["passed"], result) - # Where the class does declare a bound with Repeat Tone in, the - # low-pass still stretches the tail past one extra lap: Feedback - # grid 113, Tone at its 800 Hz stop, full scale. + tone_hz=tone_hz) + self.assertTrue(result["passed"], (feedback, tone_hz, result)) + self.assertEqual(result["held"], 0, (feedback, tone_hz, result)) + old = tail_measure(StallWindowToneDelay, feedback, "dc2", + tone_hz=tone_hz, budget=20 * RATE) + self.assertIsNone(old["declared"], (feedback, tone_hz, old)) + self.assertFalse(old["passed"], (feedback, tone_hz, old)) + self.assertEqual(old["held"], held, (feedback, tone_hz, old)) + # A Feedback on a window's edge: the re-refuter's k = 1 edge at + # Tone grid 0 came back through the Feedback macro 10^-17 inside + # `laps_to_zero`'s window and read None. Every value within a few + # float steps of the lower edge now declares a bound. + probe = DigitalDelay(silence_src(64), sample_rate=RATE) + probe.set_macro(TONE_I, 0) + excess = probe._tone_excess()[1] + edge = 0.5 / ((1.0 + dd.FEEDBACK_MARGIN) * (1.0 + excess)) + for steps in range(-4, 5): + feedback = edge * (1.0 + steps * 2.0 ** -52) + effect = DigitalDelay(silence_src(64), sample_rate=RATE, + feedback=feedback) + effect.set_macro(TONE_I, 0) + self.assertIsNotNone(effect.tail_samples, (steps, feedback)) + # Fix round 2's rule for Repeat Tone, one extra lap, is still red + # where a bound holds: Feedback grid 113, Tone at 800 Hz. feedback = 113 * 0.99 / 127.0 for cls, passed in ((DigitalDelay, True), (OneLapToneDelay, False)): result = tail_measure(cls, feedback, "fullscale", tone_hz=800.0) self.assertEqual(result["passed"], passed, (cls, result)) + def test_every_window_centre_reaches_zero(self): + # The k = 1 ... 50 window centres, f = 1 - 0.5 / k, at Repeat Tone + # grid 0, 64 and 126, on the 2 LSB DC (the line fills to 4k LSB and + # decays through k): each reaches exact zero inside a finite + # declared tail, and the handed Feedback stays within 3e-5 of the + # one asked. Re-audit fix round 1's class says None at every one. + for grid in (0, 64, 126): + tone_hz = 800.0 * 20.0 ** (grid / 127.0) + for k in range(1, 51): + feedback = 1.0 - 0.5 / k + result = tail_measure(DigitalDelay, feedback, "dc2", + tone_hz=tone_hz) + self.assertTrue(result["passed"], (grid, k, result)) + self.assertEqual(result["held"], 0, (grid, k, result)) + probe = DigitalDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, tone_hz=tone_hz) + self.assertLess(abs(probe._feedback - feedback), 3e-5) + old = StallWindowToneDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, + tone_hz=tone_hz) + self.assertIsNone(old.tail_samples, (grid, k)) + def _walk_tail(self, cls): """800 ms of 997 Hz, then Time 800 -> 12.5 ms at the default Glide on the tone's last block, Feedback 0, Mix 2.""" @@ -1945,6 +2163,48 @@ def test_zero_filters_render_as_filters_out(self): self.assertTrue(np.array_equal(renders[0], renders[1])) self.assertGreater(int(np.abs(renders[0]).max()), 0) + def _clicks(self, **options): + """Impulses at frames 1 000 and 30 000 through the class (Feedback + 0, Mix 2), Time moved to 100 ms on the block at frame 29 952: the + frames each impulse comes back at, and the knobs read after.""" + values = [0] * 50000 + values[1000] = 30000 + values[30000] = 30000 + effect = DigitalDelay(array_src(values), sample_rate=RATE, + feedback=0.0, mix=2.0, **options) + frames = effect._frames + + def move(frame): + if frame == 29952: + effect.set_macro(TIME_I, midi_of_ms(100.0)) + + out = left(pull(effect, len(values), on_block=move), 2) + first = int(np.argmax(np.abs(out[:29952]))) - 1000 + second = int(np.argmax(np.abs(out[30001:]))) + 1 + return frames, first, second, effect.get_macro(GLIDE_I) + + def test_nan_glide_and_nan_line_build_on_their_stated_values(self): + # Gate re-audit 1: a NaN `glide_ms` passed the 8 s clamp and handed + # the node a NaN slew, which neither walks nor jumps, so a Time + # move did nothing (the click stayed at 16 800 frames) while + # get_macro(3) read the jump; a NaN `max_time_ms` passed both + # clamps, and the class reported 16 800 frames while the line + # played a 1-frame delay. NaN Glide is now the jump, as 0 and a + # negative are, and NaN `max_time_ms` is 800 ms. + nan = float("nan") + self.assertEqual(self._clicks(glide_ms=nan), (16800, 16800, 4800, + 0.0)) + self.assertEqual(self._clicks(glide_ms=0.0), (16800, 16800, 4800, + 0.0)) + frames, first, second, _ = self._clicks(max_time_ms=nan, + glide_ms=0.0) + self.assertEqual((frames, first, second), (16800, 16800, 4800)) + effect = DigitalDelay(silence_src(512), sample_rate=RATE, + max_time_ms=nan) + self.assertEqual(effect._max_time_ms, 800.0) + effect.set_macro(TIME_I, 127) + self.assertEqual(effect._frames, 38400) + class ZeroBpmHost(unittest.TestCase): def test_a_tempo_that_is_not_finite_leaves_time_on_the_knob(self): From 52a70139aa251af2edc8b6206678743c9497325a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:21:57 -0500 Subject: [PATCH 10/88] SlapbackDelay Station B: the rebuild and its tests One audioecho.FeedbackDelay against the dossier frozen at anchor 7a5a4cb: six macros (Time, Level, Saturation, Tone, Wow, Repeats), six patches, Time landed on a whole frame, a fixed 0.1875 walk, Tone pre-warped with an exact-zero out stop, Wow in cents at 0.7 Hz, Repeats stepped clear of the Tone stall window, tail_samples recomputed on every move. Parked, not adopted. Tests: every Tier 2 row with a same-kind planted fault, each fault walked off the surface, each row red on a wire, and Tier 1's fast half. --- lib/audioeffects/rebuilt/slapbackdelay.py | 432 +++++++ tests/test_cpython_effects_slapbackdelay.py | 1161 +++++++++++++++++++ 2 files changed, 1593 insertions(+) create mode 100644 lib/audioeffects/rebuilt/slapbackdelay.py create mode 100644 tests/test_cpython_effects_slapbackdelay.py diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py new file mode 100644 index 0000000..1417df9 --- /dev/null +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -0,0 +1,432 @@ +"""`SlapbackDelay` - one tape repeat at 135 ms, in mono, the Sun Studio slap. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/SlapbackDelay.md`, whose trait +table was frozen at Station A before this file existed (anchor commit +7a5a4cbd8a734ea3df6ae8b8b04e32e763a15b5a, the Station A critique's +re-freeze, 2026-09-27). The old class in `delay.py` is consulted only for +the seven defects that dossier's section 7 names; it stays the class the +library serves until the board runner adopts this one. + +**What it sounds like.** Your dry signal passes untouched, and one copy of +it comes back 135 ms later, from the same place, a little quieter: the +two-machine tape echo on the 1955 Sun sides, which Halmrast measured at +134-137 ms, one repeat, mono. Time (40-250 ms) is the head spacing over the +tape speed. Level (0-2) is the console return: dry at unity up to 1, the +repeat alone at 2, and Level 0 is a wire while the line keeps recording. +Saturation is how hard the return drove the record amplifier; it colours +the repeat and never the dry. Tone is the tape path's top end, out of +circuit by default. Wow is the transport's slow wobble, in cents at a fixed +0.7 Hz. Repeats sends the slap back round for a second and third; the Sun +rig had no feedback path, so it defaults to 0. + +**The standout:** Sam Phillips' two-Ampex-350 slapback at Sun Studio, as +measured on *Baby Let's Play House* and *Tryin' to Get to You*. You get its +time, its single repeat and its mono placement as defaults, and its tape +colours as knobs. + +**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The stock +`audiodelays.Echo` limits its only output at +-28000, so its Mix 0 is not +a wire. On a stock CircuitPython board this module imports cleanly and +construction raises `ImportError`. + +**Latency: zero samples, at every setting and every rate.** Nothing looks +ahead. The 135 ms is the repeat, not latency on the dry path, and no option +adds any. + +**Mono.** The repeat sits exactly where the dry sits: on a source identical +in both channels the output is identical in both channels, at every knob +position. A one-channel source gets the identical effect on its one channel, +sample for sample the left channel of a stereo render. The class never +passes `input_pan`, and it has no width, spread or pan knob, and never will. + +**RAM.** A fixed line of 251 ms (Time's top plus 1 ms) of two int16 lanes +whatever the channel count: 48 192 B at 48 kHz, 44 276 B at 44.1 kHz, +22 136 B at 22.05 kHz, plus about 1.2 KB of node. No option sizes it. + +**Cost.** One `audioecho.FeedbackDelay` with `delay_slew`, wow and +`loop_drive` on; no mixer. Palette row FeedbackDelay +options (the nearest +not-cheaper row; no row prices `loop_drive`), glue 0: **P4 <= 9 %, +S3 <= 15 %** of a 5.333 ms stereo block. The board measurement is pending +hardware. + +**What the default surrenders.** The default is Tone out, so the repeat is +as bright as the dry: an Ampex 350 at 15 ips rolls off at 15 kHz and at +7.5 ips lower still, and the Tone knob (patch 5, Dark Slap) is how you get +there. Wow is on at 1 cent, and wow moves the read head between samples, so +the repeat's top end breathes: at 48 kHz a 15 kHz tone in the repeat swings +between -0.02 and -5.11 dB (mean -2.61 dB) about 25 times a second; at +44.1 kHz between -0.03 and -6.38 dB (mean -3.34 dB). With Wow at 0 the +repeat loses nothing: every static Time is landed on the nearest whole frame +at the running rate, so the default 135 ms is 6 480 frames at 48 kHz and +5 954 frames (135.011 ms) at 44.1 kHz, not the 5 953.5 that would cost the +repeat 6.35 dB at 15 kHz for as long as it played. + +**Saturation** is the node's cubic soft clip on the repeat, applied again on +each pass when Repeats is up. On the repeat of a -6 dBFS tone the default +0.15 adds a third harmonic at -50.0 dB re the fundamental and takes the +fundamental down 0.08 dB; 1.0 puts the third at -33.1 dB and the +fundamental at -0.56 dB. There is no second harmonic. + +**Turning Time while it plays** walks the repeat to the new time at a fixed +0.1875 delay-seconds per second, instead of clicking: the repeat bends ++297.5 cents while Time falls and -359.5 cents while it rises, and +135 -> 85 ms takes 267 ms. There is no Glide knob; a slap's time is set, +not played. + +**Tone at a low rate.** The knob's corners clamp below Nyquist at the +running rate. At 22.05 kHz grid positions 94-126 all sit on the 10 804.5 Hz +clamp and do the same thing, and position 127 takes the filter out. At 44.1 +and 48 kHz every position moves. + +**Input ceiling.** The dry path sits at unity and the repeat adds to it, +and there is no input gain to turn down. Measured on the kit's `noise_det` +at 48 kHz over 4 s, the defaults put no sample on the rail from -2.5 dBFS +peak down (at -2.4 they rail 14 samples), and the shipped patches from +-3.4 dBFS (patch 2, Doubling, the first to rail) down. + +**Tail.** `tail_samples` is an upper bound on how long the output takes to +reach exact zero after your input stops: one lap of the line at Repeats 0 +(6 488 frames at the defaults, 48 kHz), 11 laps at 0.35, 21 at the 0.6 +stop. With Tone in circuit the node's loop low-pass can hold a small value +for ever at a Feedback a hair either side of 0.5, which Repeats' span +reaches, so there the class hands the node a Feedback just outside that +window (under 2.5 x 10^-5 away, far inside one step of the knob, which still +reads what you set) and the tail reaches zero inside the bound. + +`capabilities = ()`: a slapback's time is a fixed distance over a fixed +tape speed, with no musical relationship to a tempo, so the class never +reads `self._transport()`. + +A constructor value stays on the audio path unrounded by the knob's grid; +Time is then landed on a whole frame. A value outside a knob's span clamps +to the nearer stop (a Wow above 3.5 cents plays 3.5), a `tone_hz` of 0 or +less is Tone out, and NaN takes that option's default. +""" + +VENDOR = "PyDevices" + +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail arithmetic, reused rather than copied: the same node +# rounds the same way in both classes. Its module moves up one level when +# it comes home, so both homes are tried. +try: + from .digitaldelay import clear_of_stalls, laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import clear_of_stalls, laps_to_zero, whole_frames + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The Time span, fixed on every instance (dossier section 6): 40-250 ms, log. +TIME_MIN_MS = 40.0 +TIME_MAX_MS = 250.0 + +#: The line: Time's top plus 1 ms. The node clamps a delay at +#: `line_frames - 2` (`audiodsp_feedback_delay.c:148-150`), and the read +#: reaches 250 ms plus the wow's 22.1 frames at 3.5 cents; 251 ms clears +#: that at every rate (dossier Tier 3, A8.6). +LINE_MS = TIME_MAX_MS + 1.0 + +#: The Tone span's corners; the top stop is exactly `damping_hz = 0`. +TONE_MIN_HZ = 2000.0 +TONE_MAX_HZ = 20000.0 + +#: The wow's fixed rate and the Wow knob's ceiling, in cents peak. +WOW_HZ = 0.7 +WOW_MAX_CENTS = 3.5 + +#: The Repeats knob's top, handed to the node's `feedback`. +REPEATS_MAX = 0.6 + +#: The fixed Time walk, delay-seconds per second: 3/16, exact in float32, +#: so every step of the walk is exact at 48 kHz (dossier section 8.6). +SLEW = 0.1875 + +TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6) + +#: The constructor defaults, which NaN falls back to. +DEFAULTS = (135.0, 0.35, 0.15, TONE_MAX_HZ, 1.0, 0.0) + + +def wow_depth_ms(cents): + """The Wow knob's cents -> the node's `wow_depth_ms` at 0.7 Hz. + + A delay swinging D sin(2 pi f t) ms moves pitch by a peak ratio of + 1 + 2 pi f D / 1000, so the rising side reaches `cents` at + D = (2^(cents/1200) - 1) / (2 pi 0.7) x 1000 (dossier A4): 1.0 cent is + 0.1314 ms, 3.5 cents 0.4601 ms. Clamped to 0..3.5 cents.""" + cents = float(cents) + if not cents > 0.0: + return 0.0 + if cents > WOW_MAX_CENTS: + cents = WOW_MAX_CENTS + return ((2.0 ** (cents / 1200.0) - 1.0) / (2.0 * math.pi * WOW_HZ) + * 1000.0) + + +def tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for a loop low-pass at `damping_hz` (already + pre-warped): after `frames` frames whatever its state held weighs under + 2^-17 of it, and its single-precision state can rest up to + 2^-24 / a above the line's peak, a being the coefficient. `DigitalDelay`'s + `_tone_excess`, as a function of the handed value. (0, 0.0) with Tone + out.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class SlapbackDelay(_component.Component): + """One tape repeat, 135 ms after the dry and in the same place: the Sun + Studio slap. audiodsp tier; zero latency. + + **What the default surrenders:** Tone is out, so the repeat is brighter + than an Ampex 350's 15 kHz top; the 1-cent wow makes the repeat's top + end breathe (a 15 kHz tone swings to -5.11 dB at 48 kHz, -6.38 dB at + 44.1); and the Time knob walks rather than jumps. + """ + + NAME = 'SlapbackDelay' + DISPLAY_NAME = 'Slapback Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Level", "Saturation", "Tone", "Wow", "Repeats") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, 2.0), # 1 Level; dry at unity to 1 + (0.0, 1.0), # 2 Saturation, loop_drive + (TONE_MIN_HZ, TONE_MAX_HZ, "log"), # 3 Tone, Hz; top = out + (0.0, WOW_MAX_CENTS), # 4 Wow, cents peak at 0.7 Hz + (0.0, REPEATS_MAX), # 5 Repeats, feedback + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Single Slap", (84, 22, 19, 127, 36, 0)), + 1: ("Short Slap", (52, 22, 19, 127, 36, 0)), + 2: ("Doubling", (0, 32, 19, 127, 73, 0)), + 3: ("Hot Return", (84, 22, 89, 127, 36, 0)), + 4: ("Two Repeats", (84, 22, 19, 127, 36, 74)), + 5: ("Dark Slap", (84, 22, 19, 51, 36, 0)), + } + + def _build(self, time_ms=135.0, level=0.35, saturation=0.15, + tone_hz=TONE_MAX_HZ, wow_cents=1.0, repeats=0.0, patch=None): + self._frames = 1 + #: The longest delay, in whole frames, the read head may still sit + #: at. The walk starts from wherever the head is and the class cannot + #: see how far it has got, so after a falling move this keeps the old + #: Time until a reset lands the head (dossier section 8.7). + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True while several macros are applied at once (the constructor, + #: `program_change`); the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._wow_ms = 0.0 + self._node_ms = 0.0 + #: The constructor's Time, exactly, until macro 0 moves. Seeding a + #: log knob and reading it back is not exact: 135.0 ms comes back a + #: few ulps under, which at 44.1 kHz (5 953.5 frames) lands on 5 953 + #: instead of the 5 954 the whole-frame law gives 135.0. + self._time_exact = None + self._seeding = True + # A log knob cannot seed 0 or a negative, so those clamp to the + # nearer stop here; 0 or less is how the node spells Tone out. + time_ms = _between(_option(time_ms, DEFAULTS[TIME_I]), + TIME_MIN_MS, TIME_MAX_MS) + tone_hz = _option(tone_hz, DEFAULTS[TONE_I]) + if not tone_hz > 0.0: + tone_hz = TONE_MAX_HZ + tone_hz = _between(tone_hz, TONE_MIN_HZ, TONE_MAX_HZ) + values = (time_ms, + _option(level, DEFAULTS[LEVEL_I]), + _option(saturation, DEFAULTS[SATURATION_I]), + tone_hz, + _option(wow_cents, DEFAULTS[WOW_I]), + _option(repeats, DEFAULTS[REPEATS_I])) + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=LINE_MS, + delay_ms=time_ms, + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + delay_slew=0.0) + # `clear()` empties the line and the loop filters and re-primes the + # read head (`audiodsp_feedback_delay.c:291-299`), so a reset is + # silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._time_exact = time_ms + self._deferred = True + try: + self._init_macros(values) + finally: + self._deferred = False + self._seeding = False + self._refresh() + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _node_time_ms(self, frames): + """What the node is handed for a whole-frame Time.""" + return frames * 1000.0 / self._sample_rate + + def _tone_damping(self, position): + """Macro 3's position -> the node's `damping_hz`: the corner the + loop low-pass achieves, clamped below Nyquist and pre-warped. The + top stop is exactly 0 (out of circuit) and is never pre-warped.""" + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _wow_depth_ms(self, cents): + return wow_depth_ms(cents) + + # -- applying ------------------------------------------------------ + + def _time_ms(self): + """The Time the audio path plays, before it is landed.""" + if self._time_exact is not None: + return self._time_exact + return self._value(TIME_I) + + def _apply_macro(self, index, position): + del position + if index == TIME_I and not self._seeding: + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the node once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + self._fresh = False + + def _refresh(self): + self._frames = max(1, whole_frames(self._time_ms(), + self._sample_rate)) + self._node_ms = self._node_time_ms(self._frames) + if self._fresh or self._frames > self._reach: + self._reach = self._frames + damping = self._tone_damping(self._macros[TONE_I]) + feedback = _between(self._value(REPEATS_I), 0.0, REPEATS_MAX) + if damping > 0.0 and feedback > 0.0: + # With Tone in, the node can hold a small value for ever at a + # Feedback a hair either side of 1 - 0.5 / k (0.5 is inside the + # span); the node is handed the nearer edge of that window. + feedback = self._loop_feedback( + feedback, tone_excess(damping, self._sample_rate)[1]) + self._feedback = feedback + self._damping = damping + self._wow_ms = self._wow_depth_ms(self._value(WOW_I)) + self._delay.set( + delay_slew=SLEW, + delay_ms=self._node_ms, + feedback=feedback, + mix=_between(self._value(LEVEL_I), 0.0, 2.0), + loop_drive=_between(self._value(SATURATION_I), 0.0, 1.0), + damping_hz=damping, + cut_hz=0.0, + wow_hz=WOW_HZ, + wow_depth_ms=self._wow_ms) + + def _loop_feedback(self, feedback, excess): + """The Feedback handed to the node with Tone in circuit.""" + return clear_of_stalls(feedback, excess) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound: `laps_to_zero(f, excess)` laps of the longest delay + the read head may be at, plus the wow's depth in frames rounded up, + plus one frame for the interpolated read, plus the Tone low-pass's + memory. One lap at Repeats 0. Finite at every setting the class + reaches.""" + 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`.""" + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + if laps is None: # pragma: no cover - stepped clear + return None + wow = int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) + return int(laps * (self._reach + wow + 1 + memory)) diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py new file mode 100644 index 0000000..9170ea9 --- /dev/null +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -0,0 +1,1161 @@ +"""`SlapbackDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/SlapbackDelay.md` +(frozen at anchor 7a5a4cb, the Station A critique's re-freeze); its Tier 2 +rows are T1-T5. Each row here is the measurement at a few of the row's +cells and the same measurement shown red on a planted fault of the same +kind, at the constructor defaults (or, for a clause the defaults do not +reach, at the row's own cell, said where it is). Every fault is shown +unreachable from every macro position and shipped patch, and every row's +measurement is shown red on the class built as a wire. The full spans, the +three interpreters and the rates live in the evidence pack, not in this +file. + +Every law a measurement checks against is written out here from the +dossier, never taken from the class: the whole-frame landing, the Wow map, +the Time span. + +The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by +`rebuilt.module_class("SlapbackDelay")`. +""" + +import math +import os +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import slapbackdelay as sd # noqa: E402 +from tools.effect_measurements import instantaneous_hz # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +BLOCK = 256 +RATES = (48000, 44100, 22050) +TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6) + +SlapbackDelay = rebuilt.module_class("SlapbackDelay") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + + +def law_frames(time_ms, rate): + """Section 6: the nearest whole frame, floor(ms fs / 1000 + 0.5).""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def law_time_ms(midi): + """Section 6: Time is 40-250 ms, log, on the 0-127 grid.""" + return 40.0 * 6.25 ** (midi / 127.0) + + +def law_wow_ms(cents): + """A4: the rising side of a 0.7 Hz wow reaches `cents` at this depth.""" + cents = min(max(cents, 0.0), 3.5) + return (2.0 ** (cents / 1200.0) - 1.0) / (2.0 * math.pi * 0.7) * 1000.0 + + +def law_wow_frames(cents, rate): + return int(math.ceil(law_wow_ms(cents) * rate / 1000.0)) + + +#: Section 6's patch table, in engineering units, which `macro_of` puts on +#: the grid: (Time ms, Level, Saturation, Tone Hz, Wow cents, Repeats). +DOSSIER_PATCHES = ( + ("Single Slap", (135.0, 0.35, 0.15, 20000.0, 1.0, 0.0)), + ("Short Slap", (85.0, 0.35, 0.15, 20000.0, 1.0, 0.0)), + ("Doubling", (40.0, 0.5, 0.15, 20000.0, 2.0, 0.0)), + ("Hot Return", (135.0, 0.35, 0.7, 20000.0, 1.0, 0.0)), + ("Two Repeats", (135.0, 0.35, 0.15, 20000.0, 1.0, 0.35)), + ("Dark Slap", (135.0, 0.35, 0.15, 5000.0, 1.0, 0.0)), +) +DOSSIER_SPANS = ((40.0, 250.0, "log"), (0.0, 2.0), (0.0, 1.0), + (2000.0, 20000.0, "log"), (0.0, 3.5), (0.0, 0.6)) + + +# -------------------------------------------------------------------------- +# Planted faults, one or more per Tier 2 row, each of the row's own kind + + +class Frames441Slapback(SlapbackDelay): + """T1's delay clause and T2: Time landed with 44.1 kHz frames at every + rate. At the 48 kHz defaults the repeat lands at 5 954 frames (124.0 ms, + 8.8 % early); at 44.1 kHz it is the right count, so it is read at 48 + and 22.05 kHz only.""" + + NAME = 'SlapbackDelay' + + def _node_time_ms(self, frames): + del frames + return law_frames(self._time_ms(), 44100) * 1000.0 / self._sample_rate + + +class FloorSlapback(SlapbackDelay): + """T1's no-second-repeat clause: a floor on the loop gain, Repeats 0 + handing the node 0.05, so a second repeat comes back about 26 dB + down.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._feedback <= 0.0: + self._delay.set(feedback=0.05) + + +class PannedSlapback(SlapbackDelay): + """T3: the node handed `input_pan` -0.2, so the repeat leans left.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + self._delay.set(input_pan=-0.2) + + +class OpenTopSlapback(SlapbackDelay): + """T4's out clause: the top stop hands the node the pre-warped 20 kHz + (13 095.11 Hz at 48 kHz) instead of exactly 0. Every in-circuit + position hands less at 48 and 44.1 kHz; at 22.05 kHz grid 94-126 hand + the same 6 183.68, so it is walked at 48 and 44.1 kHz only.""" + + NAME = 'SlapbackDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return nominal_damping_hz(self._hz(20000.0), self._sample_rate) + return SlapbackDelay._tone_damping(self, position) + + +class RawToneSlapback(SlapbackDelay): + """T4's corner clauses: Tone handed raw, not pre-warped. Silent at the + defaults (Tone out hands 0 either way) and on every shipped patch + inside the bar; read at the 15 kHz cell, where the one-pole then has no + half-power point below Nyquist.""" + + NAME = 'SlapbackDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return self._hz(corner) + + +class NoTwoPiSlapback(SlapbackDelay): + """T5: the depth map without its 2 pi, a 6.3x deeper wobble.""" + + NAME = 'SlapbackDelay' + + def _wow_depth_ms(self, cents): + return sd.wow_depth_ms(cents) * 2.0 * math.pi + + +class NoWowTailSlapback(SlapbackDelay): + """Tier 1's tail: the bound without the wow's depth, which the repeat + of a burst outlives by the frames the wow moves it late.""" + + NAME = 'SlapbackDelay' + + @property + def tail_samples(self): + memory, excess = sd.tone_excess(self._damping, self._sample_rate) + laps = sd.laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) + + +class RawFeedbackSlapback(SlapbackDelay): + """Tier 1's tail with Tone in: Repeats handed to the node as set, inside + the stall window at 0.5, where the loop low-pass holds 1 LSB for + ever.""" + + NAME = 'SlapbackDelay' + + def _loop_feedback(self, feedback, excess): + return feedback + + +class TargetOnlyTailSlapback(SlapbackDelay): + """Tier 1's tail after a falling move: the bound from the target Time + alone, while the read head is still walking down from the old one.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + self._reach = self._frames + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def to_source(values, channels=2, rate=RATE): + """A mono sequence, copied to every channel.""" + x = np.clip(np.round(np.asarray(values, dtype=np.float64)), + -32768, 32767).astype(np.int16) + data = array("h", np.repeat(x[:, None], channels, axis=1) + .reshape(-1).tobytes()) + return probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), data + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def pull(effect, frames, channels=None, on_block=None): + """(frames, channels) int16; `on_block(frame)` runs before each block.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], + dtype=np.int16).reshape(-1, channels) + + +def render(cls, values, rate=RATE, channels=2, macros=None, **options): + """`values` through `cls` built with `options`, then `macros` + ({index: MIDI}) set, so a macro can be held over a patch.""" + source, _ = to_source(values, channels, rate) + effect = cls(source, sample_rate=rate, **options) + for index in sorted(macros or {}): + effect.set_macro(index, macros[index]) + return pull(effect, len(values), channels) + + +def cell_time_and_wow(options): + """The cell's Time and Wow by the dossier's laws: a constructor value + as given, a patch at its grid values.""" + if "patch" in options: + midi = SlapbackDelay.PATCHES[options["patch"]][1] + return law_time_ms(midi[TIME_I]), 3.5 * midi[WOW_I] / 127.0 + return options.get("time_ms", 135.0), options.get("wow_cents", 1.0) + + +# -------------------------------------------------------------------------- +# T1: one repeat, and no second one + + +def t1_read(cls, rate=RATE, channels=2, click_lsb=32767, **options): + """The click response at frame 0: energy in the windows + +-k (ceil(wow) + 2) frames around kT (+64 with Tone in), k = 1..4, and + the delay in ms by the centroid around the first non-zero sample after + the click, wherever it lands.""" + time_ms, wow = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + w = law_wow_frames(wow, rate) + tone_in = options.get("tone_hz", 20000.0) < 20000.0 + tail = 64 if tone_in else 0 + n = int(4.6 * T) + 200 + x = np.zeros(n) + x[0] = click_lsb + y = render(cls, x, rate, channels, **options)[:, 0] + energies = [] + for k in (1, 2, 3, 4): + lo = max(0, k * T - k * (w + 2)) + hi = k * T + k * (w + 2) + tail + 1 + energies.append(float((y[lo:hi].astype(float) ** 2).sum())) + nonzero = np.nonzero(y[1:])[0] + if not len(nonzero): + return energies, float("nan") + lo = int(nonzero[0]) + 1 + seg = np.abs(y[lo:lo + 2 * (w + 2) + tail + 1].astype(float)) + centroid = lo + float((np.arange(len(seg)) * seg).sum() / seg.sum()) + return energies, centroid * 1000.0 / rate + + +def t1_measure(cls, rate=RATE, channels=2, click_lsb=32767, delay_bar=True, + **options): + """Repeats 0: a first repeat, exact zero in the 2T-4T windows, and (at + the defaults) the delay in 130-140 ms.""" + energies, delay_ms = t1_read(cls, rate, channels, click_lsb, **options) + red = [] + if energies[0] <= 0.0: + red.append("no first repeat") + if any(e > 0.0 for e in energies[1:]): + red.append("a 2T-4T window is not exact zero") + if delay_bar and not 130.0 <= delay_ms <= 140.0: + red.append("delay %.3f ms" % delay_ms) + second = (10.0 * math.log10(energies[1] / energies[0]) + if energies[0] > 0.0 and energies[1] > 0.0 else None) + return {"passed": not red, "red": red, "delay_ms": delay_ms, + "second_db": second} + + +def t1_control(cls, rate=RATE, **options): + """At Repeats above 0 the 2T window carries more than -20 dB re the + first.""" + energies, _ = t1_read(cls, rate, **options) + if energies[0] <= 0.0 or energies[1] <= 0.0: + return {"passed": False, "second_db": None} + second = 10.0 * math.log10(energies[1] / energies[0]) + return {"passed": second > -20.0, "second_db": second} + + +# -------------------------------------------------------------------------- +# T2: the comb's teeth are 1/T + + +PAD_S = 20.0 + + +def comb_guess(mag, bin_hz, minimum_hz=1.0): + """The spacing's first guess: an autocorrelation along frequency, by + FFT, 1 Hz floor. It takes no law.""" + m = mag - mag.mean() + size = 1 << int(math.ceil(math.log(2 * len(m), 2))) + spec = np.fft.rfft(m, n=size) + corr = np.fft.irfft(spec * np.conj(spec), n=size)[:len(m)] + low = max(1, int(minimum_hz / bin_hz)) + peak = low + int(np.argmax(corr[low:])) + y0, y1, y2 = corr[peak - 1], corr[peak], corr[peak + 1] + den = y0 - 2 * y1 + y2 + off = 0.5 * (y0 - y2) / den if den != 0 else 0.0 + return (peak + off) * bin_hz + + +def notch_spacing(mag, bin_hz, lo, hi, guess): + """Notches as local minima (parabolic sub-bin), numbered by consecutive + gaps, spacing by least squares. (nan, count) under three notches.""" + a, b = int(lo / bin_hz), int(hi / bin_hz) + half = max(2, int(0.4 * guess / bin_hz)) + freqs = [] + k = a + half + while k < b - half: + seg = mag[k - half:k + half + 1] + j = int(np.argmin(seg)) + if j == half and seg.max() - seg.min() > 1e-9 * max(seg.max(), 1): + y0, y1, y2 = mag[k - 1], mag[k], mag[k + 1] + den = y0 - 2 * y1 + y2 + off = 0.5 * (y0 - y2) / den if den != 0 else 0.0 + freqs.append((k + off) * bin_hz) + k += half + else: + k += 1 + if len(freqs) < 3: + return float("nan"), len(freqs) + freqs = np.array(freqs) + index = np.concatenate([[0.0], np.cumsum(np.round(np.diff(freqs) + / guess))]) + return float(np.polyfit(index, freqs, 1)[0]), len(freqs) + + +def t2_measure(cls, rate=RATE, click_lsb=32767, click_at=100, **options): + """The click response at Level 0.35, zero-padded to 20 s: the notch + spacing over 200 Hz-2 kHz and 8-10 kHz, each within 0.5 % of 1/T and + within 0.5 % of each other.""" + time_ms, _ = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + law = rate / float(T) + n = click_at + T + 400 + x = np.zeros(n) + x[click_at] = click_lsb + y = render(cls, x, rate, 2, **options)[:, 0].astype(float) + size = int(PAD_S * rate) + mag = np.abs(np.fft.rfft(y, n=size)) + bin_hz = rate / float(size) + guess = comb_guess(mag, bin_hz) + low, low_n = notch_spacing(mag, bin_hz, 200.0, 2000.0, guess) + high, high_n = notch_spacing(mag, bin_hz, 8000.0, 10000.0, guess) + deviations = (100.0 * (low - law) / law, 100.0 * (high - law) / law, + 100.0 * (high - low) / low) + passed = all(abs(d) <= 0.5 for d in deviations) # NaN reads red + return {"passed": passed, "deviations": deviations, + "notches": (low_n, high_n)} + + +# -------------------------------------------------------------------------- +# T3: mono, the dry's position, and present + + +def t3_measure(cls, rate=RATE, **options): + """On the channel-identical full-scale ramp: max |L - R| is 0 LSB, the + one-channel render is the stereo render's left channel sample for + sample, and at Level 2 the output is exact zero before T - (ceil(wow) + + 2) frames and carries the probe's energy, shifted by T, within + 4 dB.""" + time_ms, wow = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + w = law_wow_frames(wow, rate) + 2 + frames = T + 4096 + ramp = probes.ramp_fs(frames=frames, channels=1) + stereo = render(cls, ramp, rate, 2, **options) + mono = render(cls, ramp, rate, 1, **options)[:, 0] + lr = int(np.abs(stereo[:, 0].astype(np.int32) + - stereo[:, 1].astype(np.int32)).max()) + mono_diff = int(np.count_nonzero(mono != stereo[:, 0])) + wet = render(cls, ramp, rate, 2, macros={LEVEL_I: 127}, **options) + head = int(np.count_nonzero(wet[:T - w])) + y = wet[T - w:, 0].astype(float) + s = np.array(ramp, dtype=np.float64)[:frames - T + w] + energy_db = 10.0 * math.log10(max((y ** 2).sum(), 1e-12) + / (s ** 2).sum()) + wet_lr = int(np.abs(wet[:, 0].astype(np.int32) + - wet[:, 1].astype(np.int32)).max()) + passed = (lr == 0 and wet_lr == 0 and mono_diff == 0 and head == 0 + and abs(energy_db) <= 4.0) + return {"passed": passed, "lr": max(lr, wet_lr), "mono_diff": mono_diff, + "head": head, "energy_db": energy_db} + + +# -------------------------------------------------------------------------- +# T4: Tone's number is the corner it achieves, and out is out + + +def t4_out_differing(cls, rate=RATE, channels=2): + """The class at its defaults (Tone out) against a node given no + `damping_hz`, at the dossier's settings for the defaults, on the + full-scale ramp: the samples that differ.""" + frames = law_frames(135.0, rate) + 8192 + ramp = probes.ramp_fs(frames=frames, channels=channels) + effect = cls(probes.ArraySource(ramp, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate) + out = pull(effect, frames, channels).reshape(-1) + reference = sd.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=251.0, + delay_ms=law_frames(135.0, rate) * 1000.0 / rate, feedback=0.0, + mix=0.35, loop_drive=0.15, wow_hz=0.7, wow_depth_ms=law_wow_ms(1.0), + delay_slew=0.1875) + reference.play(probes.ArraySource(ramp, rate=rate, channels=channels, + block=BLOCK)) + ref = array("h") + while len(ref) < frames * channels: + data = bytes(audiocore.get_buffer(reference)[1]) + if not data: + break + ref.extend(memoryview(data).cast("h")) + ref = np.array(ref[:frames * channels], dtype=np.int16) + return int(np.count_nonzero(out != ref)) + + +def t4_ratio(cls, tone_hz, rate=RATE): + """(freqs, dB): the click response at 8 192 LSB with Tone at `tone_hz` + over the same class's Tone-out response, one pass, wet only, + Saturation 0, Wow 0, Time 135 ms, a 65 536-point FFT.""" + T = law_frames(135.0, rate) + size = 65536 + n = T + size + x = np.zeros(n) + x[0] = 8192 + common = {"time_ms": 135.0, "level": 2.0, "saturation": 0.0, + "wow_cents": 0.0, "repeats": 0.0} + y_in = render(cls, x, rate, 2, tone_hz=tone_hz, **common)[:, 0] + y_out = render(cls, x, rate, 2, tone_hz=20000.0, **common)[:, 0] + a = np.abs(np.fft.rfft(y_in[T - 32:T - 32 + size].astype(float))) + b = np.abs(np.fft.rfft(y_out[T - 32:T - 32 + size].astype(float))) + freqs = np.fft.rfftfreq(size, 1.0 / rate) + return freqs, 20.0 * np.log10(np.maximum(a, 1e-12) / np.maximum(b, 1e-12)) + + +def half_power_hz(freqs, db): + """The first -3.01 dB crossing, interpolated; `None` below Nyquist + never.""" + below = np.nonzero(db[1:] < -3.0103)[0] + if not len(below): + return None + i = int(below[0]) + 1 + f0, f1, d0, d1 = freqs[i - 1], freqs[i], db[i - 1], db[i] + return float(f0 + (f1 - f0) * (-3.0103 - d0) / (d1 - d0)) + + +def t4_corner(cls, tone_hz, label, rate=RATE, passband=False): + """The half-power point within +-10 % of the cell's `label`; with + `passband`, 30 Hz within 0.05 dB, 1 kHz within 0.1 dB and nothing more + than 3.1 dB down in 30 Hz-15 kHz.""" + freqs, db = t4_ratio(cls, tone_hz, rate) + corner = half_power_hz(freqs, db) + passed = corner is not None and abs(corner / label - 1.0) <= 0.10 + values = {"corner": corner} + if passband: + at = lambda hz: float(db[int(np.argmin(np.abs(freqs - hz)))]) # noqa: E731 + band = (freqs >= 30.0) & (freqs <= 15000.0) + values.update(at30=at(30.0), at1k=at(1000.0), + worst=float(db[band].min())) + passed = (passed and abs(values["at30"]) <= 0.05 + and abs(values["at1k"]) <= 0.1 and values["worst"] >= -3.1) + values["passed"] = passed + return values + + +def t4_measure(cls, rate=RATE): + """The out stop byte-identical to no `damping_hz`, and the 15 kHz and + 2 kHz corners.""" + differing = t4_out_differing(cls, rate) + top = t4_corner(cls, 15000.0, 15000.0, rate, passband=True) + bottom = t4_corner(cls, 2000.0, 2000.0, rate) + return {"passed": differing == 0 and top["passed"] and bottom["passed"], + "differing": differing, "top": top, "bottom": bottom} + + +# -------------------------------------------------------------------------- +# T5: Wow's cents are real cents + + +def t5_cents(cls, rate=RATE, amp=12000, saturation=0.15, **options): + """The repeat's pitch deviation at the 0.7 Hz wow rate, rising side, in + cents: 440 Hz, Level 2, Repeats 0, Tone out, Time 135 ms, 2 s from the + first repeat; the kit's `instantaneous_hz`, 5 % trimmed at each end, + least squares on a constant plus a cosine and a sine at 0.7 Hz.""" + T = law_frames(135.0, rate) + n = T + 2 * rate + x = amp * np.sin(2.0 * math.pi * 440.0 * np.arange(n) / rate) + options.setdefault("time_ms", 135.0) + y = render(cls, x, rate, 2, macros={LEVEL_I: 127}, repeats=0.0, + saturation=saturation, **options)[:, 0].astype(float) + f = instantaneous_hz(y[T + 64:], rate) + edge = int(0.05 * len(f)) + f = f[edge:len(f) - edge] + t = (np.arange(len(f)) + edge) / float(rate) + angle = 2.0 * math.pi * 0.7 * t + design = np.vstack([np.ones(len(f)), np.cos(angle), np.sin(angle)]).T + coef = np.linalg.lstsq(design, f, rcond=None)[0] + return 1200.0 * math.log(1.0 + math.hypot(coef[1], coef[2]) / coef[0], + 2.0) + + +def t5_measure(cls, rate=RATE): + """1.0 +- 0.25 cents at the default, 3.5 +- 0.5 at the top, under 0.05 + at zero.""" + default = t5_cents(cls, rate) + top = t5_cents(cls, rate, wow_cents=3.5) + zero = t5_cents(cls, rate, wow_cents=0.0) + passed = (abs(default - 1.0) <= 0.25 and abs(top - 3.5) <= 0.5 + and zero < 0.05) + return {"passed": passed, "default": default, "top": top, "zero": zero} + + +# -------------------------------------------------------------------------- +# Tier 1 helpers + + +def tail_measure(cls, rate=RATE, burst_ms=50.0, burst_lsb=32767, + **options): + """A full-scale DC burst, then silence: the frames from the burst's end + to the output's last non-zero frame, against the declared + `tail_samples`.""" + probe = cls(silence_src(64, 2, rate), sample_rate=rate, **options) + declared = probe.tail_samples + probe.deinit() + burst = int(burst_ms * rate / 1000.0) + n = burst + declared + rate // 2 + x = np.zeros(n) + x[:burst] = burst_lsb + y = render(cls, x, rate, 2, **options)[:, 0] + nonzero = np.nonzero(y[burst:])[0] + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return {"passed": 0 < last <= declared, "declared": declared, + "last": last, "held": int(np.abs(y[-rate // 4:]).max())} + + +def railed_samples(cls, dbfs, seconds=4.0, rate=RATE, **options): + """`noise_det` at `dbfs` peak, 48 kHz stereo: output samples on the + int16 rail that are not on it in the source.""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=dbfs, channels=2) + effect = cls(probes.ArraySource(data, rate=rate, channels=2, + block=BLOCK), sample_rate=rate, **options) + out = pull(effect, frames, 2).reshape(-1).astype(np.int64) + src = np.array(data, dtype=np.int64) + rail = (out >= 32767) | (out <= -32768) + return int(np.count_nonzero(rail & ~((src >= 32767) | (src <= -32768)))) + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed at the position walked + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node as `_handed` (the latest value of each).""" + + def __enter__(self): + node_class = sd.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._handed.update(options) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def read_landing_error(effect): + """The handed delay's whole frames against the law's for the Time the + knob (or the constructor) names.""" + rate = effect._sample_rate + handed = effect._delay._handed["delay_ms"] + return (int(math.floor(handed * rate / 1000.0 + 0.5)) + - law_frames(effect._time_ms(), rate)) + + +def read_floor(effect): + """(Repeats at its 0 stop, the handed feedback).""" + return (effect._macros[REPEATS_I] <= 0.0, + round(float(effect._delay._handed["feedback"]), 4)) + + +def read_pan(effect): + return float(effect._delay._handed.get("input_pan", 0.0)) + + +def read_damping(effect): + return round(float(effect._delay._handed["damping_hz"]), 2) + + +def read_wow_ratio(effect): + """The handed `wow_depth_ms` against the law at the knob's cents.""" + handed = float(effect._delay._handed["wow_depth_ms"]) + law = law_wow_ms(3.5 * effect._macros[WOW_I]) + if law == 0.0: + return 1.0 if handed == 0.0 else 0.0 + return round(handed / law, 4) + + +#: (name, fault, reading, rates, constructor options for both builds). +REACH_WALKS = ( + ("Frames441Slapback", Frames441Slapback, read_landing_error, + (48000, 22050), {}), + ("FloorSlapback", FloorSlapback, read_floor, RATES, {}), + ("PannedSlapback", PannedSlapback, read_pan, RATES, {}), + ("OpenTopSlapback", OpenTopSlapback, read_damping, (48000, 44100), {}), + ("RawToneSlapback", RawToneSlapback, read_damping, RATES, + {"tone_hz": 15000.0}), + ("NoTwoPiSlapback", NoTwoPiSlapback, read_wow_ratio, RATES, {}), +) + + +def reach(faulted, reading, rate, ctor): + def build(cls): + return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(SlapbackDelay, faulted, reading, + build) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = SlapbackDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Level", "Saturation", "Tone", "Wow", + "Repeats")) + self.assertEqual(len(cls.PATCHES), 6) + self.assertEqual(cls.CAPABILITIES, ()) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ()) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_parked_not_served(self): + import audioeffects + self.assertNotIn("SlapbackDelay", rebuilt.ADOPTED) + self.assertIn("SlapbackDelay", rebuilt.parked()) + self.assertIsNot(audioeffects.SlapbackDelay, SlapbackDelay) + + def test_patches_are_the_dossier_settings_on_the_grid(self): + for index, (name, values) in enumerate(DOSSIER_PATCHES): + label, midi = SlapbackDelay.PATCHES[index] + self.assertEqual(label, name) + self.assertEqual(midi, tuple(_component.macro_of(span, value) + for span, value in + zip(DOSSIER_SPANS, values))) + + def test_patch_0_is_the_constructor_grid(self): + effect = SlapbackDelay(silence_src(512), sample_rate=RATE) + grid = SlapbackDelay.PATCHES[0][1] + for index, expected in enumerate(grid): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_time_lands_on_a_whole_frame(self): + # 135 ms is 6 480 / 5 954 / 2 977 frames (5 953.5 at 44.1 kHz lands + # up); the stops 1 920 / 1 764 / 882 and 12 000 / 11 025 / 5 513. + for rate, frames in ((48000, (6480, 1920, 12000)), + (44100, (5954, 1764, 11025)), + (22050, (2977, 882, 5513))): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + got = [effect._frames] + for midi in (0, 127): + effect.set_macro(TIME_I, midi) + got.append(effect._frames) + self.assertEqual(tuple(got), frames, rate) + for midi in range(128): + effect.set_macro(TIME_I, midi) + self.assertEqual(effect._frames, + law_frames(law_time_ms(midi), rate)) + self.assertEqual(effect._node_ms, + effect._frames * 1000.0 / rate) + + def test_the_wow_map_and_its_ceiling(self): + self.assertAlmostEqual(sd.wow_depth_ms(1.0), 0.13137, places=5) + self.assertAlmostEqual(sd.wow_depth_ms(3.5), 0.46012, places=5) + self.assertEqual(sd.wow_depth_ms(0.0), 0.0) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + wow_cents=10.0) + self.assertAlmostEqual(effect._wow_ms, law_wow_ms(3.5), places=12) + for midi in range(128): + effect.set_macro(WOW_I, midi) + self.assertAlmostEqual(effect._wow_ms, + law_wow_ms(3.5 * midi / 127.0), places=12) + + def test_tone_stops_and_the_22k_clamp(self): + for rate in RATES: + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + dampings = [effect._tone_damping(m / 127.0) for m in range(128)] + self.assertEqual(dampings[127], 0.0) + if rate == 22050: + self.assertLess(dampings[93], dampings[94]) + self.assertEqual(len(set(dampings[94:127])), 1) + self.assertAlmostEqual(dampings[94], 6183.68, places=2) + else: + self.assertTrue(all(b > a for a, b in + zip(dampings[:126], dampings[1:127]))) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + tone_hz=15000.0) + self.assertAlmostEqual(effect._damping, 11566.93, places=2) + + def test_tail_samples_follows_time_tone_wow_and_repeats(self): + # laps x (reach + ceil(wow) + 1 + memory); one lap at Repeats 0. + for rate, declared in ((48000, 6488), (44100, 5961), (22050, 2981)): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + self.assertEqual(effect.tail_samples, declared, rate) + self.assertEqual(declared, law_frames(135.0, rate) + + law_wow_frames(1.0, rate) + 1) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE) + self.assertEqual(sd.laps_to_zero(0.35), 11) + self.assertEqual(sd.laps_to_zero(0.6), 21) + effect.set_macro(REPEATS_I, 127) + self.assertEqual(effect.tail_samples, 21 * 6488) + # A falling move walks from the old Time, so the old Time stays in + # the bound; a reset lands the head on patch 0. + effect.set_macro(TIME_I, 0) + self.assertEqual(effect.tail_samples, 21 * 6488) + effect.set_macro(TIME_I, 127) + self.assertEqual(effect.tail_samples, 21 * (12000 + 7 + 1)) + effect.reset() + self.assertEqual(effect.tail_samples, + law_frames(law_time_ms(84), RATE) + + law_wow_frames(3.5 * 36 / 127.0, RATE) + 1) + # Tone in at Repeats 0.5: the node is handed the stall window's + # nearer edge, and the bound is finite. + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + tone_hz=2000.0, repeats=0.5) + self.assertLess(effect._feedback, 0.5) + self.assertGreater(effect._feedback, 0.5 - 2.5e-5) + self.assertAlmostEqual(effect.get_macro(REPEATS_I), 127 * 0.5 / 0.6) + self.assertEqual(effect.tail_samples, 105184) + + def test_constructor_clamps_and_nan(self): + nan = float("nan") + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + time_ms=nan, level=nan, saturation=nan, + tone_hz=nan, wow_cents=nan, repeats=nan) + self.assertEqual([effect.macro(i) for i in (1, 2, 5)], + [0.35, 0.15, 0.0]) + self.assertEqual(effect._frames, 6480) + self.assertEqual(effect._damping, 0.0) + self.assertAlmostEqual(effect._wow_ms, law_wow_ms(1.0)) + for options, frames in (({"time_ms": 0.0, "tone_hz": 0.0}, 1920), + ({"time_ms": -5.0, "tone_hz": -1.0}, 1920), + ({"time_ms": 900.0, "tone_hz": 1e6}, 12000)): + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + **options) + self.assertEqual(effect._frames, frames, options) + self.assertEqual(effect._damping, 0.0, options) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + tone_hz=100.0, repeats=2.0, level=5.0) + self.assertEqual(effect.get_macro(TONE_I), 0.0) + self.assertEqual(effect.get_macro(REPEATS_I), 127.0) + self.assertEqual(effect.get_macro(LEVEL_I), 127.0) + + +# -------------------------------------------------------------------------- +# Tier 2 rows + + +class T1OneRepeat(unittest.TestCase): + def test_defaults_three_rates_stereo_and_mono(self): + for rate in RATES: + for channels in (2, 1): + result = t1_measure(SlapbackDelay, rate, channels) + self.assertTrue(result["passed"], (rate, channels, result)) + result = t1_measure(SlapbackDelay, RATE, 2, click_lsb=328) + self.assertTrue(result["passed"], result) + result = t1_measure(SlapbackDelay, RATE, patch=0) + self.assertTrue(result["passed"], result) + + def test_no_second_repeat_at_the_stops_and_patches(self): + for options in ({"time_ms": 40.0}, {"time_ms": 250.0}, + {"saturation": 1.0}, {"tone_hz": 2000.0}, + {"wow_cents": 3.5}, {"wow_cents": 0.0}, + {"level": 2.0}, {"patch": 1}, {"patch": 2}, + {"patch": 3}, {"patch": 5}): + result = t1_measure(SlapbackDelay, RATE, delay_bar=False, + **options) + self.assertTrue(result["passed"], (options, result)) + + def test_the_control_moves(self): + for options in ({"repeats": 0.35}, {"patch": 4}, {"repeats": 0.6}): + result = t1_control(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + + def test_frames_at_the_wrong_rate_are_red(self): + result = t1_measure(Frames441Slapback, RATE) + self.assertFalse(result["passed"]) + self.assertAlmostEqual(result["delay_ms"], 124.11, delta=0.05) + + def test_a_loop_gain_floor_is_red(self): + result = t1_measure(FloorSlapback, RATE) + self.assertFalse(result["passed"]) + self.assertLess(result["second_db"], -20.0) + self.assertGreater(result["second_db"], -30.0) + + +class T2Comb(unittest.TestCase): + def test_the_defaults_and_the_stops(self): + for rate in RATES: + result = t2_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for options in ({"time_ms": 40.0, "wow_cents": 0.0}, + {"time_ms": 250.0, "wow_cents": 0.0}, + {"time_ms": 137.0, "wow_cents": 0.0}, {"patch": 0}): + result = t2_measure(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + result = t2_measure(SlapbackDelay, RATE, click_lsb=3277) + self.assertTrue(result["passed"], result) + + def test_frames_at_the_wrong_rate_are_red(self): + result = t2_measure(Frames441Slapback, RATE) + self.assertFalse(result["passed"]) + self.assertAlmostEqual(result["deviations"][0], 8.77, delta=0.05) + + +class T3Mono(unittest.TestCase): + def test_the_defaults_the_corner_and_the_patches(self): + for rate in RATES: + result = t3_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for options in ({"repeats": 0.6, "saturation": 1.0, + "tone_hz": 2000.0, "wow_cents": 3.5}, + {"patch": 2}, {"patch": 4}, {"patch": 5}, + {"time_ms": 40.0}, {"time_ms": 250.0}): + result = t3_measure(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + + def test_a_panned_repeat_is_red(self): + result = t3_measure(PannedSlapback, RATE) + self.assertFalse(result["passed"]) + self.assertGreater(result["lr"], 0) + + +class T4Tone(unittest.TestCase): + def test_out_and_the_corners_at_48k_and_44k(self): + for rate in (48000, 44100): + result = t4_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + + def test_the_grid_cells(self): + for rate in (48000, 44100): + for midi in (51, 111, 126): + label = 2000.0 * 10.0 ** (midi / 127.0) + result = t4_corner(SlapbackDelay, label, label, rate) + self.assertTrue(result["passed"], (rate, midi, result)) + + def test_the_22k_cells(self): + self.assertEqual(t4_out_differing(SlapbackDelay, 22050), 0) + for label in (2000.0, 2000.0 * 10.0 ** (51 / 127.0)): + result = t4_corner(SlapbackDelay, label, label, 22050) + self.assertTrue(result["passed"], (label, result)) + + def test_an_open_top_stop_is_red_at_the_defaults(self): + for rate in (48000, 44100): + self.assertGreater(t4_out_differing(OpenTopSlapback, rate), 0) + self.assertFalse(t4_measure(OpenTopSlapback, rate)["passed"]) + + def test_a_raw_tone_is_red_at_the_15k_cell(self): + for rate in (48000, 44100): + result = t4_corner(RawToneSlapback, 15000.0, 15000.0, rate, + passband=True) + self.assertFalse(result["passed"]) + self.assertIsNone(result["corner"]) + + +class T5Wow(unittest.TestCase): + def test_zero_default_and_top(self): + for rate in RATES: + result = t5_measure(SlapbackDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for saturation in (0.0, 0.15): + self.assertLess(abs(t5_cents(SlapbackDelay, RATE, amp=328, + saturation=saturation) - 1.0), 0.25) + cents = t5_cents(SlapbackDelay, RATE, patch=0) + self.assertLess(abs(cents - 1.0), 0.25) + + def test_the_ceiling(self): + cents = t5_cents(SlapbackDelay, RATE, wow_cents=10.0) + self.assertLess(cents, 4.0) + self.assertLess(abs(cents - 3.5), 0.5) + + def test_a_map_without_its_two_pi_is_red(self): + result = t5_measure(NoTwoPiSlapback, RATE) + self.assertFalse(result["passed"]) + self.assertGreater(result["default"], 5.0) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half + + +class Tier1Fast(unittest.TestCase): + def test_level_zero_is_a_wire_on_the_full_scale_ramp(self): + corner = {"repeats": 0.6, "saturation": 1.0, "tone_hz": 2000.0, + "wow_cents": 3.5, "level": 0.0} + for rate in RATES: + for channels in (2, 1): + for time_ms in (40.0, 250.0): + frames = law_frames(time_ms, rate) * 3 + ramp = probes.ramp_fs(frames=frames, channels=channels) + effect = SlapbackDelay( + probes.ArraySource(ramp, rate=rate, + channels=channels, block=BLOCK), + sample_rate=rate, time_ms=time_ms, **corner) + out = pull(effect, frames, channels).reshape(-1) + self.assertTrue(np.array_equal( + out, np.array(ramp, dtype=np.int16)), + (rate, channels, time_ms)) + + def test_the_dry_is_unity_until_the_repeat(self): + # Level 1.0 and grid 63 (0.992): the first T - 24 frames are the + # source, byte for byte, with the wow at its top. + for level in (1.0, 2.0 * 63 / 127.0): + T = law_frames(40.0, RATE) + ramp = probes.ramp_fs(frames=T * 2, channels=2) + effect = SlapbackDelay( + probes.ArraySource(ramp, rate=RATE, channels=2, block=BLOCK), + sample_rate=RATE, time_ms=40.0, level=level, wow_cents=3.5, + repeats=0.6, saturation=1.0) + out = pull(effect, T * 2, 2).reshape(-1) + window = (T - 24) * 2 + self.assertTrue(np.array_equal( + out[:window], np.array(ramp, dtype=np.int16)[:window])) + + def test_silence_stays_silence(self): + effect = SlapbackDelay(silence_src(RATE), sample_rate=RATE, + repeats=0.6, level=2.0, tone_hz=2000.0, + saturation=1.0, wow_cents=3.5) + self.assertEqual(int(np.abs(pull(effect, RATE)).max()), 0) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for rate in RATES: + for level in (0.35, 2.0): + result = tail_measure(SlapbackDelay, rate, level=level) + self.assertTrue(result["passed"], (rate, level, result)) + for options in ({"repeats": 0.35, "level": 2.0}, + {"repeats": 0.6, "level": 2.0}, + {"repeats": 0.6, "level": 2.0, "saturation": 1.0, + "tone_hz": 2000.0, "wow_cents": 3.5}, + {"time_ms": 250.0, "wow_cents": 3.5, + "repeats": 0.6, "level": 2.0}): + result = tail_measure(SlapbackDelay, RATE, **options) + self.assertTrue(result["passed"], (options, result)) + + def test_a_tail_without_the_wow_is_red(self): + result = tail_measure(NoWowTailSlapback, RATE, level=2.0) + self.assertFalse(result["passed"], result) + self.assertGreater(result["last"], result["declared"]) + + def _stall(self, cls): + """Repeats 0.5, Tone 2 kHz, Level 2, Saturation 0: a 2 LSB DC for + 1 s, then 3 s of silence.""" + values = np.zeros(4 * RATE) + values[:RATE] = 2.0 + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, repeats=0.5, tone_hz=2000.0, + level=2.0, saturation=0.0) + declared = effect.tail_samples + y = pull(effect, len(values))[:, 0] + nonzero = np.nonzero(y[RATE:])[0] + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return declared, last, int(np.abs(y[-RATE // 4:]).max()) + + def test_the_stall_window_is_stepped_clear(self): + declared, last, held = self._stall(SlapbackDelay) + self.assertEqual(held, 0) + self.assertLessEqual(last, declared) + _declared, _last, held = self._stall(RawFeedbackSlapback) + self.assertEqual(held, 1) + + def _walk_tail(self, cls): + """250 ms of 997 Hz, then Time 250 -> 40 ms on the tone's last + block, Repeats 0, Level 2.""" + tone = int(0.25 * RATE) // BLOCK * BLOCK + values = np.zeros(tone + RATE) + values[:tone] = 12000 * np.sin(2 * math.pi * 997.0 + * np.arange(tone) / RATE) + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, time_ms=250.0, level=2.0) + seen = {} + + def move(frame): + if frame == tone: + effect.set_macro(TIME_I, 0) + seen["declared"] = effect.tail_samples + + y = pull(effect, len(values), on_block=move)[:, 0] + last = int(np.nonzero(y[tone:])[0][-1]) + 1 + return seen["declared"], last + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + declared, last = self._walk_tail(SlapbackDelay) + self.assertGreater(last, 4000) + self.assertLessEqual(last, declared) + declared, last = self._walk_tail(TargetOnlyTailSlapback) + self.assertGreater(last, declared) + + def test_reset_empties_the_line(self): + values = np.zeros(RATE) + values[256:2304] = 12000 * np.sin(2 * math.pi * 997.0 + * np.arange(2048) / RATE) + source, _ = to_source(values) + effect = SlapbackDelay(source, sample_rate=RATE, repeats=0.6, + level=2.0) + pull(effect, 2304) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.abs(pull(effect, RATE // 2)).max()), 0) + + def test_deinit_leaves_the_source(self): + source, _ = to_source(8000 * np.sin(2 * math.pi * 440.0 + * np.arange(1024) / RATE)) + effect = SlapbackDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + values = np.zeros(2048) + values[10] = 30000 + y = render(SlapbackDelay, values, rate)[:, 0] + self.assertEqual(int(np.argmax(np.abs(y))), 10) + self.assertEqual(int(y[10]), 30000) + + def test_the_transport_is_never_read(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = SlapbackDelay.create(silence_src(512), RATE, + transport=transport) + for index in range(6): + effect.set_macro(index, 127) + effect.program_change(4) + effect.reset() + pull(effect, 256) + self.assertEqual(reads, []) + + +class InputCeiling(unittest.TestCase): + """The docstring's ceiling on `noise_det`, 48 kHz stereo, 4 s: the + defaults clean at -2.5 dBFS peak and not at -2.4, patch 2 (Doubling, + the first shipped patch to rail) clean at -3.4 and not at -3.3.""" + + def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): + for options, ceiling, over in (({}, -2.5, -2.4), + ({"patch": 2}, -3.4, -3.3)): + self.assertEqual(railed_samples(SlapbackDelay, ceiling, + **options), 0, options) + self.assertGreater(railed_samples(SlapbackDelay, over, + **options), 0, options) + + +# -------------------------------------------------------------------------- +# The two checks every planted fault and every row is held to + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, reading what the node is handed at + each position, at the rates its docstring names.""" + + CHECKED = 6 * 17 + 6 + + def test_every_fault_is_off_the_surface(self): + for name, faulted, reading, rates, ctor in REACH_WALKS: + for rate in rates: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + + def test_the_rates_left_out_are_left_out_for_their_stated_reason(self): + # At 44.1 kHz the 44.1 kHz landing is the right one: inert. + with self.assertRaises(kit_faults.FaultInert): + reach(Frames441Slapback, read_landing_error, 44100, {}) + # At 22.05 kHz grid 94-126 hand the pre-warped clamp too. + with self.assertRaises(kit_faults.FaultReachable): + reach(OpenTopSlapback, read_damping, 22050, {}) + # The raw Tone is silent at the defaults, where Tone is out. + with self.assertRaises(kit_faults.FaultInert): + reach(RawToneSlapback, read_damping, RATE, {}) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in (("T1", t1_measure), ("T2", t2_measure), + ("T3", t3_measure), ("T4", t4_measure), + ("T5", t5_measure)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + SlapbackDelay, measure, label="SlapbackDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() From 62ca92d7f19125243866a035c1aa877b61dc312a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:47:55 -0500 Subject: [PATCH 11/88] SlapbackDelay fix round: Tone out keeps the loop low-pass on the tap after Tone has been in; the Wow step disclosed Handing damping_hz 0 after Tone had been in froze the node's filter state, and a later Tone move played it out of silence (22 271 LSB at Level 2, 96 000 frames after the input stopped). Once Tone has been in since the last reset the out stop now hands 32 x the rate, a coefficient of exactly 1. The defaults still hand no filter. A Wow move is a step at the node; the docstring now says so with the numbers, and a test pins them. --- lib/audioeffects/rebuilt/slapbackdelay.py | 46 +++++++++++ tests/test_cpython_effects_slapbackdelay.py | 86 +++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 1417df9..1e16b03 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -74,6 +74,29 @@ 135 -> 85 ms takes 267 ms. There is no Glide knob; a slap's time is set, not played. +**Turning Wow while it plays steps.** The node takes a new wow depth at +once (`audiodsp_feedback_delay.c:457-458` adds depth x wow to the read +head, with no ramp), so the repeat jumps by the change in depth times +wherever the 0.7 Hz cycle is. On a 997 Hz tone at 12 000 LSB, Level 2, +48 kHz, a Wow move from grid 36 to 73 steps the output 7 684 LSB where the +tone's own largest step is 1 565, and 0 to 127 steps 23 037; near a zero of +the cycle the same moves barely show. A patch change that moves Wow does +the same: patch 0 to patch 2 (Doubling, the one patch with a different +Wow), tried on every block boundary of that tone, steps up to 5 503 LSB +against patch 0's own 2 107 at 48 kHz (5 680 against 2 293 at 44.1 kHz). +Time walks; Wow does not, so set it before you play. + +**Tone out, after Tone has been in.** The node leaves its loop low-pass +frozen while the filter is out, and a frozen filter would play what it +held when Tone came back in, out of silence. So once Tone has been in +circuit since the last `reset()`, the out stop keeps the filter running at +a coefficient of exactly 1, which follows the repeat sample for sample. +That is the out stop up to float rounding: against the filter truly out, +over 2 916 cells (Time, Wow, Saturation, Repeats and Level at three +settings each, three rates, stereo and mono, a full-scale ramp and noise), +5 640 of 166 430 700 samples differ, each by 1 LSB, none at Wow 0. The defaults, and anything since a reset +that has not put Tone in, hand the node exactly no filter. + **Tone at a low rate.** The knob's corners clamp below Nyquist at the running rate. At 22.05 kHz grid positions 94-126 all sit on the 10 804.5 Hz clamp and do the same thing, and position 127 takes the filter out. At 44.1 @@ -139,6 +162,15 @@ TONE_MIN_HZ = 2000.0 TONE_MAX_HZ = 20000.0 +#: Tone out after Tone has been in: `damping_hz` at 32 x the rate, where +#: 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, +#: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows +#: the tap sample for sample instead of freezing. That is the identity up to +#: float rounding, which moved 5 640 of 166 430 700 samples by 1 LSB where +#: the tap is fractional (Wow on) and none by more +#: (`slapbackdelay_fix_tone_track.py`). +TONE_TRACK_PER_RATE = 32.0 + #: The wow's fixed rate and the Wow knob's ceiling, in cents peak. WOW_HZ = 0.7 WOW_MAX_CENTS = 3.5 @@ -270,6 +302,10 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, self._deferred = False self._feedback = 0.0 self._damping = 0.0 + #: True once the loop low-pass has been handed an in-circuit corner + #: since the node was built or cleared. From then on its state is + #: live, and Tone out hands `TONE_TRACK_PER_RATE` x the rate, not 0. + self._tone_used = False self._wow_ms = 0.0 self._node_ms = 0.0 #: The constructor's Time, exactly, until macro 0 moves. Seeding a @@ -323,6 +359,7 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, def _clear(self): self._delay.clear() self._fresh = True + self._tone_used = False # -- the maps ------------------------------------------------------ @@ -384,6 +421,15 @@ def _refresh(self): if self._fresh or self._frames > self._reach: self._reach = self._frames damping = self._tone_damping(self._macros[TONE_I]) + if damping > 0.0: + self._tone_used = True + elif self._tone_used: + # The node updates its loop low-pass only while the coefficient + # is above 0 (`audiodsp_feedback_delay.c:493-497`), so handing 0 + # after Tone has been in would freeze whatever the filter held, + # and a later Tone move would play it out of silence. A + # coefficient of exactly 1 keeps the state on the tap instead. + damping = TONE_TRACK_PER_RATE * self._sample_rate feedback = _between(self._value(REPEATS_I), 0.0, REPEATS_MAX) if damping > 0.0 and feedback > 0.0: # With Tone in, the node can hold a small value for ever at a diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 9170ea9..d609799 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -189,6 +189,20 @@ def _loop_feedback(self, feedback, excess): return feedback +class FrozenToneSlapback(SlapbackDelay): + """Tier 1's silence clause after a Tone move: the out stop hands the + node exactly 0 even after Tone has been in, so the loop low-pass + freezes on whatever it held and a later Tone move plays it out of + silence (the review's stale-state defect).""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._macros[TONE_I] >= 1.0: + self._delay.set(damping_hz=0.0) + + class TargetOnlyTailSlapback(SlapbackDelay): """Tier 1's tail after a falling move: the bound from the target Time alone, while the read head is still walking down from the old one.""" @@ -1027,6 +1041,78 @@ def test_the_stall_window_is_stepped_clear(self): _declared, _last, held = self._stall(RawFeedbackSlapback) self.assertEqual(held, 1) + def _tone_back_in(self, cls, rate=RATE, channels=2, level=2.0): + """300 Hz at 30 000 LSB for 0.5 s with Tone 2 kHz, Tone out, 2 s of + silence, then Tone to MIDI 0: the output's peak after that move.""" + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = np.zeros(back + rate // 4) + values[:loud] = 30000 * np.sin(2 * math.pi * 300.0 + * np.arange(loud) / rate) + source, _ = to_source(values, channels, rate) + effect = cls(source, sample_rate=rate, tone_hz=2000.0, level=level) + + def move(frame): + if frame == loud: + effect.set_macro(TONE_I, 127) + elif frame == back: + effect.set_macro(TONE_I, 0) + + y = pull(effect, len(values), channels, on_block=move) + return int(np.abs(y[back:]).max()) + + def test_tone_back_in_after_silence_stays_silent(self): + for rate in RATES: + for channels in (2, 1): + self.assertEqual(self._tone_back_in(SlapbackDelay, rate, + channels), 0, + (rate, channels)) + self.assertEqual(self._tone_back_in(SlapbackDelay, level=0.35), 0) + # Planted: the out stop frozen at 0 plays the held state back. + self.assertGreater(self._tone_back_in(FrozenToneSlapback), 20000) + self.assertGreater(self._tone_back_in(FrozenToneSlapback, + level=0.35), 5000) + + def test_tone_out_hands_no_filter_until_tone_has_been_in(self): + with NodeSpy(): + for rate in RATES: + effect = SlapbackDelay(silence_src(64, 2, rate), + sample_rate=rate) + self.assertEqual(effect._delay._handed["damping_hz"], 0.0) + effect.set_macro(TONE_I, 0) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._delay._handed["damping_hz"], + 32.0 * rate) + self.assertIsNotNone(effect.tail_samples) + effect.reset() + self.assertEqual(effect._delay._handed["damping_hz"], 0.0) + effect = SlapbackDelay(silence_src(64, 2, rate), + sample_rate=rate, patch=5) + effect.program_change(0) + self.assertEqual(effect._delay._handed["damping_hz"], + 32.0 * rate) + + def test_a_wow_move_steps_as_the_docstring_says(self): + # The node takes a new wow depth at once; the docstring states the + # step. 997 Hz at 12 000 LSB, Level 2, Wow 36 -> 73 at frame 15 616. + at = 15616 + values = 12000 * np.sin(2 * math.pi * 997.0 * np.arange(RATE) / RATE) + steps = [] + for target in (73, 127): + source, _ = to_source(values) + effect = SlapbackDelay(source, sample_rate=RATE, level=2.0) + effect.set_macro(WOW_I, 36 if target == 73 else 0) + + def move(frame, target=target, effect=effect): + if frame == at: + effect.set_macro(WOW_I, target) + + y = pull(effect, RATE, on_block=move)[:, 0].astype(int) + steady = int(np.abs(np.diff(y[at - 3000:at - 1])).max()) + steps.append((steady, int(np.abs(y[at] - y[at - 1])))) + self.assertEqual(steps[0], (1565, 7684)) + self.assertEqual(steps[1][1], 23037) + def _walk_tail(self, cls): """250 ms of 997 Hz, then Time 250 -> 40 ms on the tone's last block, Repeats 0, Level 2.""" From 8db15c68d3796ffa4f5419fda1801e31fd2f5c64 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:07:54 -0500 Subject: [PATCH 12/88] SlapbackDelay fix round 1: the audit's list, class side Time's readback: a host echoing get_macro(0) keeps the constructor's exact Time (44.1 kHz stays on 5 954 frames). The new test failed on the round-0 class (5953 != 5954) and EchoForgetsTimeSlapback plants the old behaviour, red. T2: comb_guess takes the autocorrelation's first tooth, not its tallest; a fit that raises reads red. old_comb_guess is kept as a planted fault and reads 2/T at grid 124. T1: ShortHalfFrameSlapback (a fractional landing, 124.2 ms) and SecondRepeatSlapback (a lone 2T repeat, -24.5 dB, nothing at 3T) replace FloorSlapback and Frames441Slapback, which render clean surface states; TheRetiredFaultsAreDialable keeps that as a test. Readings are handed values only, and every fault walks at three rates on the kit's grid and a quarter-step grid. T4: the out clause is measured on 0 dBFS noise_det and restated (exact until Tone has been in since a reset, within 1 LSB after); RawTopSlapback is the off-surface out fault; the raw Tone is red at patch 5 at 22.05 kHz. T2's split build runs on the class. T3 names its presence band (a third of the rate). The docstring's 15 kHz swing matches A8.12. CHANGELOG line. --- CHANGELOG.md | 13 + lib/audioeffects/rebuilt/slapbackdelay.py | 34 +- tests/test_cpython_effects_slapbackdelay.py | 674 +++++++++++++++++--- 3 files changed, 627 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e15661..5627b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ there, and are recorded in its changelog. The docstring states the input ceiling (-3 dBFS peak at the defaults, -4 over the shipped patches, on `noise_det`) and Repeat Tone's flat top at 22.05 kHz. Board cost is unmeasured. +- **`SlapbackDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** the Sun + Studio tape slap on one `audioecho.FeedbackDelay`: one mono repeat at + 135 ms, landed on a whole frame at every rate, with Saturation, Tone and + Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire + while the line keeps recording, Time walks rather than clicks, and a host + echoing Time back keeps the constructor's exact frame. Tone out is + byte-identical to no filter until Tone has been in since a reset, and + within 1 LSB after. It lives in `lib/audioeffects/rebuilt/slapbackdelay.py`, + and `audioeffects.SlapbackDelay` is still the old class. The docstring + states the input ceiling (-2.5 dBFS peak at the defaults, -3.4 over the + shipped patches, on `noise_det`), the 15 kHz swing the wow costs the + repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is + unmeasured. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 1e16b03..35d9cc0 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -55,8 +55,8 @@ 7.5 ips lower still, and the Tone knob (patch 5, Dark Slap) is how you get there. Wow is on at 1 cent, and wow moves the read head between samples, so the repeat's top end breathes: at 48 kHz a 15 kHz tone in the repeat swings -between -0.02 and -5.11 dB (mean -2.61 dB) about 25 times a second; at -44.1 kHz between -0.03 and -6.38 dB (mean -3.34 dB). With Wow at 0 the +between -0.03 and -5.11 dB (mean -2.61 dB) about 25 times a second; at +44.1 kHz between -0.02 and -6.38 dB (mean -3.35 dB). With Wow at 0 the repeat loses nothing: every static Time is landed on the nearest whole frame at the running rate, so the default 135 ms is 6 480 frames at 48 kHz and 5 954 frames (135.011 ms) at 44.1 kHz, not the 5 953.5 that would cost the @@ -94,8 +94,16 @@ That is the out stop up to float rounding: against the filter truly out, over 2 916 cells (Time, Wow, Saturation, Repeats and Level at three settings each, three rates, stereo and mono, a full-scale ramp and noise), -5 640 of 166 430 700 samples differ, each by 1 LSB, none at Wow 0. The defaults, and anything since a reset -that has not put Tone in, hand the node exactly no filter. +5 640 of 166 430 700 samples differ, each by 1 LSB, none at Wow 0. The +defaults, and anything since a reset that has not put Tone in, hand the +node exactly no filter. A Tone in the constructor counts: `tone_hz=5000` and +then patch 0, or `patch=5` and then patch 0, is the 1 LSB case (10 of +384 000 samples of 4 s of 0 dBFS noise at 48 kHz stereo), and a `reset()` +makes it exact again. + +**A host that echoes Time back** (`set_macro(0, get_macro(0))`) keeps the +constructor's exact Time: the 44.1 kHz default stays on 5 954 frames. +Any other Time position lands the knob's own value. **Tone at a low rate.** The knob's corners clamp below Nyquist at the running rate. At 22.05 kHz grid positions 94-126 all sit on the 10 804.5 Hz @@ -182,6 +190,12 @@ #: so every step of the walk is exact at 48 kHz (dossier section 8.6). SLEW = 0.1875 +#: How close, as a 0..1 knob position, a Time write must come to the +#: constructor's own seeded position to count as a host echoing it back: +#: 1e-6 is 1/7 874 of one MIDI step, and holds a single-precision board's +#: round trip through the 0-127 scale. +ECHO_TOLERANCE = 1e-6 + TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6) #: The constructor defaults, which NaN falls back to. @@ -313,6 +327,9 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, #: few ulps under, which at 44.1 kHz (5 953.5 frames) lands on 5 953 #: instead of the 5 954 the whole-frame law gives 135.0. self._time_exact = None + #: The knob position the constructor's Time seeded, which a host's + #: echo of `get_macro(0)` comes back to within `ECHO_TOLERANCE`. + self._time_seed = -1.0 self._seeding = True # A log knob cannot seed 0 or a negative, so those clamp to the # nearer stop here; 0 or less is how the node spells Tone out. @@ -351,6 +368,7 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, finally: self._deferred = False self._seeding = False + self._time_seed = self._macros[TIME_I] self._refresh() if patch is not None: self.program_change(patch) @@ -392,8 +410,12 @@ def _time_ms(self): return self._value(TIME_I) def _apply_macro(self, index, position): - del position - if index == TIME_I and not self._seeding: + if index == TIME_I and not self._seeding and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position + # (`set_macro(0, get_macro(0))`) keeps the constructor's exact + # Time; any other position drops it. self._time_exact = None if not self._deferred: self._refresh() diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index d609799..730c769 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -31,7 +31,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import audiocore # noqa: E402 -import kit_faults # noqa: E402 +import audiomixer # noqa: E402 +import audioroute # noqa: E402 +import kit_faults # noqa: E402 import kit_probes as probes # noqa: E402 from audioeffects import _component # noqa: E402 from audioeffects import rebuilt # noqa: E402 @@ -92,10 +94,13 @@ def law_wow_frames(cents, rate): class Frames441Slapback(SlapbackDelay): - """T1's delay clause and T2: Time landed with 44.1 kHz frames at every - rate. At the 48 kHz defaults the repeat lands at 5 954 frames (124.0 ms, - 8.8 % early); at 44.1 kHz it is the right count, so it is read at 48 - and 22.05 kHz only.""" + """Time landed with 44.1 kHz frames at every rate. **Off the surface at + 22.05 kHz only** (fix round 1): there it lands 5 954 frames, past Time's + 250 ms top, and the node clamps it at the line (250.9 ms). At 48 kHz + the same 5 954 frames are what the clean class plays at `time_ms` + 124.041667 (the gate audit's dial: 0 samples differ), and at 44.1 kHz + it is the right count. So it is T2's 22.05 kHz fault and nothing + else; `ShortHalfFrameSlapback` is the delay fault at every rate.""" NAME = 'SlapbackDelay' @@ -104,10 +109,24 @@ def _node_time_ms(self, frames): return law_frames(self._time_ms(), 44100) * 1000.0 / self._sample_rate +class ShortHalfFrameSlapback(SlapbackDelay): + """T1's delay clause and T2 (fix round 1): Time landed 8 % short and + half a frame over, `floor(0.92 T) + 0.5` frames. The clean class lands + every Time on a whole frame, so no Time position or constructor value + hands the node a fraction; at the defaults the repeat comes 124.2 ms + after the dry at every rate.""" + + NAME = 'SlapbackDelay' + + def _node_time_ms(self, frames): + return ((int(0.92 * frames) + 0.5) * 1000.0 / self._sample_rate) + + class FloorSlapback(SlapbackDelay): - """T1's no-second-repeat clause: a floor on the loop gain, Repeats 0 - handing the node 0.05, so a second repeat comes back about 26 dB - down.""" + """**Retired at fix round 1.** A floor on the loop gain, Repeats 0 + handing the node 0.05. The clean class renders the same bytes at + Repeats 0.05 (`set_macro(5, 10.5833)`), so it is a surface state, not + a fault; `TheRetiredFaultsAreDialable` keeps that as a test.""" NAME = 'SlapbackDelay' @@ -117,6 +136,105 @@ def _refresh(self): self._delay.set(feedback=0.05) +#: The second node's mix: at Level 0.35 the 2T copy is 0.35 x 0.0614 of the +#: click against a first repeat of 0.35 + 0.0614, about -26 dB. +SECOND_MIX = 0.0614 + + +class SecondRepeatSlapback(SlapbackDelay): + """T1's no-second-repeat clause (fix round 1): a second node after the + first, at the same whole-frame Time, dry at unity and 0.0614 wet. It + puts a copy of the first repeat at 2T, about 26 dB down, and nothing at + 3T or 4T. Every Repeats setting above 0 puts energy at 3T as well, so + no surface state renders a lone 2T repeat.""" + + NAME = 'SlapbackDelay' + + def _build(self, **options): + self._second = None + SlapbackDelay._build(self, **options) + second = audioecho_node(self._sample_rate, self._channel_count) + second.play(self._delay) + self._own(second, reset=second.clear) + self._second = second + self._output = second + self._refresh() + + def _refresh(self): + SlapbackDelay._refresh(self) + if getattr(self, "_second", None) is not None: + self._second.set(delay_slew=sd.SLEW, delay_ms=self._node_ms, + feedback=0.0, mix=SECOND_MIX, loop_drive=0.0, + damping_hz=0.0, cut_hz=0.0, wow_hz=sd.WOW_HZ, + wow_depth_ms=0.0) + + +#: The split build's crossover and its offset, as a fraction of T. +SPLIT_HZ = 4000.0 +SPLIT_SHIFT = 0.004 + + +class SplitSlapback(SlapbackDelay): + """T2's agreement clause (Station A's split build, run on the class at + fix round 1): the source split in two, one node reading 0.4 % early + through the loop low-pass at 4 kHz with the dry, and a second, wet only, + reading 0.4 % late through the loop high-pass at the same coefficient. + The low-pass and high-pass sum to the identity, so each band's comb is + a clean 1/T', and the two bands' spacings differ by 0.8 %. The late + node reads on a whole frame (Wow 0): with the class's wow on it too, + the split reads -0.03 % between the fits at 22.05 kHz at click 100, + green, and -0.75 to -0.84 % at other wow phases. The class builds one + node, so no surface state hands two delays.""" + + NAME = 'SlapbackDelay' + + def _build(self, **options): + self._high = None + SlapbackDelay._build(self, **options) + rate, channels = self._sample_rate, self._channel_count + split = audioroute.Splitter(self._source, taps=2) + low_in, high_in = split.tap(0), split.tap(1) + self._delay.play(low_in) + high = audioecho_node(rate, channels) + high.play(high_in) + mixer = audiomixer.Mixer(voice_count=2, **self._pcm(1024)) + mixer.voice[0].play(self._delay, loop=True) + mixer.voice[1].play(high, loop=True) + mixer.voice[0].level = 1.0 + self._own(mixer, reset=False) + self._own(high, reset=high.clear) + self._own(low_in, reset=False) + self._own(high_in, reset=False) + self._own(split, reset=False) + self._high, self._mixer = high, mixer + self._output = mixer + self._refresh() + + def _refresh(self): + SlapbackDelay._refresh(self) + if getattr(self, "_high", None) is None: + return + rate = self._sample_rate + shift = int(round(SPLIT_SHIFT * self._frames)) + corner = nominal_damping_hz(self._hz(SPLIT_HZ), rate) + self._delay.set(delay_ms=(self._frames - shift) * 1000.0 / rate, + damping_hz=corner) + self._high.set(delay_slew=sd.SLEW, + delay_ms=(self._frames + shift) * 1000.0 / rate, + feedback=0.0, mix=2.0, + loop_drive=self._value(SATURATION_I), damping_hz=0.0, + cut_hz=corner, wow_hz=sd.WOW_HZ, wow_depth_ms=0.0) + self._mixer.voice[1].level = min(self._value(LEVEL_I), 1.0) + + +def audioecho_node(rate, channels): + """A bare node on the class's line, for the faults that add one.""" + return sd.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, max_delay_ms=sd.LINE_MS, + delay_ms=135.0, feedback=0.0, mix=0.0, damping_hz=0.0, cut_hz=0.0, + delay_slew=0.0) + + class PannedSlapback(SlapbackDelay): """T3: the node handed `input_pan` -0.2, so the repeat leans left.""" @@ -128,10 +246,12 @@ def _refresh(self): class OpenTopSlapback(SlapbackDelay): - """T4's out clause: the top stop hands the node the pre-warped 20 kHz - (13 095.11 Hz at 48 kHz) instead of exactly 0. Every in-circuit - position hands less at 48 and 44.1 kHz; at 22.05 kHz grid 94-126 hand - the same 6 183.68, so it is walked at 48 and 44.1 kHz only.""" + """**Retired as T4's walked fault at fix round 1.** The top stop hands + the node the pre-warped 20 kHz (13 095.11 Hz at 48 kHz) instead of + exactly 0. No macro position reaches it at 48 and 44.1 kHz, but the + constructor's `tone_hz=19999.999` renders it byte for byte at three + rates, and at 22.05 kHz grid 94-126 hand the same 6 183.68. It stays + as a red on the out clause; `RawTopSlapback` is the walked fault.""" NAME = 'SlapbackDelay' @@ -141,11 +261,29 @@ def _tone_damping(self, position): return SlapbackDelay._tone_damping(self, position) +class RawTopSlapback(SlapbackDelay): + """T4's out clause (fix round 1): the top stop hands the node a raw + `damping_hz` of 20 000, not 0. Every in-circuit position and + constructor value hands a pre-warped corner, at most 13 095.11 / + 12 266.31 / 6 183.68 Hz at 48 / 44.1 / 22.05 kHz, so nothing on the + surface hands 20 000 or renders its coefficient.""" + + NAME = 'SlapbackDelay' + + def _tone_damping(self, position): + if position >= 1.0: + return 20000.0 + return SlapbackDelay._tone_damping(self, position) + + class RawToneSlapback(SlapbackDelay): """T4's corner clauses: Tone handed raw, not pre-warped. Silent at the - defaults (Tone out hands 0 either way) and on every shipped patch - inside the bar; read at the 15 kHz cell, where the one-pole then has no - half-power point below Nyquist.""" + defaults (Tone out hands 0 either way). Read at the 15 kHz cell, where + the one-pole then has no half-power point below Nyquist. On the shipped + patches it stays inside the bar at 48 and 44.1 kHz (patch 5's raw + 5 042 Hz lands +3.87 / +4.63 % off), and at 22.05 kHz patch 5 reads + +24.92 %, red: the corners have a fault red at a shipped patch there + (`test_a_raw_tone_is_red_at_patch_5_at_22k`).""" NAME = 'SlapbackDelay' @@ -214,6 +352,20 @@ def _refresh(self): self._reach = self._frames +class EchoForgetsTimeSlapback(SlapbackDelay): + """Time's readback (fix round 1): any Time move drops the constructor's + exact Time, as the class did before, so a host echoing `get_macro(0)` + back moves the 44.1 kHz default from 5 954 frames to 5 953.""" + + NAME = 'SlapbackDelay' + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding: + self._time_exact = None + if not self._deferred: + self._refresh() + + # -------------------------------------------------------------------------- # Sources and pulls @@ -335,9 +487,41 @@ def t1_control(cls, rate=RATE, **options): PAD_S = 20.0 +#: The first tooth: the first local maximum of the autocorrelation at or +#: above this fraction of its tallest (fix round 1). +FIRST_TOOTH = 0.5 + + def comb_guess(mag, bin_hz, minimum_hz=1.0): """The spacing's first guess: an autocorrelation along frequency, by - FFT, 1 Hz floor. It takes no law.""" + FFT, 1 Hz floor, taking its **first** tooth. It takes no law. + + Fix round 1: the tallest tooth is not always the first. Where 1/T + falls near half a bin of the 20 s pad, the first tooth is sampled off + its top and the second outranks it (Time grid 117, 120 and 124 at 48 + and 44.1 kHz), and the notches were then numbered two to a gap. Every + tooth of a comb's autocorrelation stands at nearly the same height, so + the first local maximum at or above half the tallest is the first + tooth.""" + m = mag - mag.mean() + size = 1 << int(math.ceil(math.log(2 * len(m), 2))) + spec = np.fft.rfft(m, n=size) + corr = np.fft.irfft(spec * np.conj(spec), n=size)[:len(m)] + low = max(1, int(minimum_hz / bin_hz)) + c = corr[low:] + tall = FIRST_TOOTH * float(c.max()) + tops = np.nonzero((c[1:-1] > c[:-2]) & (c[1:-1] >= c[2:]) + & (c[1:-1] >= tall))[0] + peak = low + (int(tops[0]) + 1 if len(tops) else int(np.argmax(c))) + y0, y1, y2 = corr[peak - 1], corr[peak], corr[peak + 1] + den = y0 - 2 * y1 + y2 + off = 0.5 * (y0 - y2) / den if den != 0 else 0.0 + return (peak + off) * bin_hz + + +def old_comb_guess(mag, bin_hz, minimum_hz=1.0): + """The guess before fix round 1, kept as a planted fault: the + autocorrelation's tallest tooth, which is sometimes the second.""" m = mag - mag.mean() size = 1 << int(math.ceil(math.log(2 * len(m), 2))) spec = np.fft.rfft(m, n=size) @@ -390,14 +574,26 @@ def t2_measure(cls, rate=RATE, click_lsb=32767, click_at=100, **options): size = int(PAD_S * rate) mag = np.abs(np.fft.rfft(y, n=size)) bin_hz = rate / float(size) + return t2_read(mag, bin_hz, law) + + +def t2_read(mag, bin_hz, law): + """The fits on a magnitude spectrum. A fit that raises (a `LinAlgError` + from `polyfit`) reads red and is reported, never skipped.""" guess = comb_guess(mag, bin_hz) - low, low_n = notch_spacing(mag, bin_hz, 200.0, 2000.0, guess) - high, high_n = notch_spacing(mag, bin_hz, 8000.0, 10000.0, guess) + try: + low, low_n = notch_spacing(mag, bin_hz, 200.0, 2000.0, guess) + high, high_n = notch_spacing(mag, bin_hz, 8000.0, 10000.0, guess) + except Exception as exc: # noqa: BLE001 - a crash is a red cell + nan = float("nan") + return {"passed": False, "deviations": (nan, nan, nan), + "notches": (0, 0), "guess": guess, + "crash": type(exc).__name__} deviations = (100.0 * (low - law) / law, 100.0 * (high - law) / law, 100.0 * (high - low) / low) passed = all(abs(d) <= 0.5 for d in deviations) # NaN reads red return {"passed": passed, "deviations": deviations, - "notches": (low_n, high_n)} + "notches": (low_n, high_n), "guess": guess, "crash": None} # -------------------------------------------------------------------------- @@ -434,36 +630,101 @@ def t3_measure(cls, rate=RATE, **options): "head": head, "energy_db": energy_db} +#: T3's presence band (fix round 1, dossier revision): the 4 dB bar holds +#: on material whose energy lies below a third of the running rate. Above +#: it the wow's fractional read (section 8.3) takes the repeat's energy +#: further down; at Nyquist `alt_fs` reads -4.86 / -5.48 / -5.20 dB. +PRESENCE_BAND = 1.0 / 3.0 + + +def presence_db(cls, values, rate=RATE, **options): + """Level 2: (non-zero frames before T - (ceil(wow) + 2), the energy + after that against the source's over the same number of frames).""" + time_ms, wow = cell_time_and_wow(options) + T = law_frames(time_ms, rate) + w = law_wow_frames(wow, rate) + 2 + y = render(cls, values, rate, 2, macros={LEVEL_I: 127}, + **options)[:, 0].astype(float) + wet = y[T - w:] + s = np.asarray(values, dtype=np.float64)[:len(wet)] + return (int(np.count_nonzero(y[:T - w])), + 10.0 * math.log10(max((wet ** 2).sum(), 1e-12) + / (s ** 2).sum())) + + +def tone(hz, rate, seconds=2.0, amp=3277): + n = int(seconds * rate) + return np.round(amp * np.sin(2.0 * math.pi * hz * np.arange(n) / rate)) + + # -------------------------------------------------------------------------- # T4: Tone's number is the corner it achieves, and out is out -def t4_out_differing(cls, rate=RATE, channels=2): +#: T4's out clause material since fix round 1: 0 dBFS `noise_det`, 4 s. The +#: full-scale ramp reads 0 differing in every Tone-out state, so it could +#: not see the 1 LSB the coefficient-1 stop leaves on a fractional tap. +OUT_SECONDS = 4.0 + + +def node_pull(rate, channels, data, **options): + """`data` through a bare node on the class's 251 ms line.""" + node = sd.audioecho.FeedbackDelay(sample_rate=rate, + channel_count=channels, + max_delay_ms=251.0, **options) + node.play(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK)) + out = array("h") + while len(out) < len(data): + chunk = bytes(audiocore.get_buffer(node)[1]) + if not chunk: + break + out.extend(memoryview(chunk).cast("h")) + return np.array(out[:len(data)], dtype=np.int16) + + +def t4_out_differing(cls, rate=RATE, channels=2, seconds=OUT_SECONDS): """The class at its defaults (Tone out) against a node given no - `damping_hz`, at the dossier's settings for the defaults, on the - full-scale ramp: the samples that differ.""" - frames = law_frames(135.0, rate) + 8192 - ramp = probes.ramp_fs(frames=frames, channels=channels) - effect = cls(probes.ArraySource(ramp, rate=rate, channels=channels, + `damping_hz`, at the dossier's settings for the defaults, on 0 dBFS + `noise_det`: the samples that differ.""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=0.0, channels=channels) + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, block=BLOCK), sample_rate=rate) out = pull(effect, frames, channels).reshape(-1) - reference = sd.audioecho.FeedbackDelay( - sample_rate=rate, channel_count=channels, max_delay_ms=251.0, - delay_ms=law_frames(135.0, rate) * 1000.0 / rate, feedback=0.0, - mix=0.35, loop_drive=0.15, wow_hz=0.7, wow_depth_ms=law_wow_ms(1.0), - delay_slew=0.1875) - reference.play(probes.ArraySource(ramp, rate=rate, channels=channels, - block=BLOCK)) - ref = array("h") - while len(ref) < frames * channels: - data = bytes(audiocore.get_buffer(reference)[1]) - if not data: - break - ref.extend(memoryview(data).cast("h")) - ref = np.array(ref[:frames * channels], dtype=np.int16) + ref = node_pull(rate, channels, data, + delay_ms=law_frames(135.0, rate) * 1000.0 / rate, + feedback=0.0, mix=0.35, loop_drive=0.15, wow_hz=0.7, + wow_depth_ms=law_wow_ms(1.0), delay_slew=0.1875) return int(np.count_nonzero(out != ref)) +def t4_out_history(cls, rate=RATE, channels=2, seconds=OUT_SECONDS, + steps=(), **ctor): + """The restated out clause: build with `ctor`, apply `steps` (patch + indices, or (macro, MIDI) pairs) before the first pull, and compare + with a node given every option the class handed except `damping_hz`, + on 0 dBFS `noise_det`. (differing, max |difference|, handed + `damping_hz`).""" + frames = int(seconds * rate) + data = probes.noise_det(frames=frames, dbfs=0.0, channels=channels) + with NodeSpy(): + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate, + **ctor) + for step in steps: + if isinstance(step, tuple): + effect.set_macro(*step) + else: + effect.program_change(step) + handed = dict(effect._delay._handed) + out = pull(effect, frames, channels).reshape(-1) + damping = handed.pop("damping_hz") + ref = node_pull(rate, channels, data, **handed) + d = np.abs(out.astype(np.int32) - ref.astype(np.int32)) + return int(np.count_nonzero(d)), int(d.max()), damping + + def t4_ratio(cls, tone_hz, rate=RATE): """(freqs, dB): the click response at 8 192 LSB with Tone at `tone_hz` over the same class's Tone-out response, one pass, wet only, @@ -624,19 +885,41 @@ def __exit__(self, *exc): return False -def read_landing_error(effect): - """The handed delay's whole frames against the law's for the Time the - knob (or the constructor) names.""" +# Every reading below is a value the node was handed, and nothing else: no +# knob position, no label, no law (fix round 1; the gate audit found +# `read_landing_error` 0 by construction and `read_floor` carrying the +# label "Repeats at 0"). + + +def delay_nodes(effect): + return [node for node in effect._nodes + if isinstance(node, sd.audioecho.FeedbackDelay)] + + +def read_fraction(effect): + """How far the handed delay sits from a whole frame at the running + rate, to 1e-6 of a frame.""" rate = effect._sample_rate - handed = effect._delay._handed["delay_ms"] - return (int(math.floor(handed * rate / 1000.0 + 0.5)) - - law_frames(effect._time_ms(), rate)) + frames = float(effect._delay._handed["delay_ms"]) * rate / 1000.0 + return round(abs(frames - math.floor(frames + 0.5)), 6) + + +def read_delays(effect): + """The handed delay of every node the class owns, in frames to 1e-3: + one entry on the clean class at every position.""" + rate = effect._sample_rate + return tuple(sorted(round(float(node._handed["delay_ms"]) * rate + / 1000.0, 3) for node in delay_nodes(effect))) + +def read_frames(effect): + """The handed delay in frames, to 1e-3.""" + return round(float(effect._delay._handed["delay_ms"]) + * effect._sample_rate / 1000.0, 3) -def read_floor(effect): - """(Repeats at its 0 stop, the handed feedback).""" - return (effect._macros[REPEATS_I] <= 0.0, - round(float(effect._delay._handed["feedback"]), 4)) + +def read_feedback(effect): + return round(float(effect._delay._handed["feedback"]), 4) def read_pan(effect): @@ -647,35 +930,35 @@ def read_damping(effect): return round(float(effect._delay._handed["damping_hz"]), 2) -def read_wow_ratio(effect): - """The handed `wow_depth_ms` against the law at the knob's cents.""" - handed = float(effect._delay._handed["wow_depth_ms"]) - law = law_wow_ms(3.5 * effect._macros[WOW_I]) - if law == 0.0: - return 1.0 if handed == 0.0 else 0.0 - return round(handed / law, 4) +def read_wow_depth(effect): + return round(float(effect._delay._handed["wow_depth_ms"]), 5) + +#: The fine grid every walk also runs on (fix round 1): quarter steps, +#: 509 positions per macro. +FINE = tuple(i / 4.0 for i in range(509)) -#: (name, fault, reading, rates, constructor options for both builds). +#: (name, fault, reading, constructor options for both builds). Every walk +#: runs at 48, 44.1 and 22.05 kHz, on the kit's grid and on `FINE`. REACH_WALKS = ( - ("Frames441Slapback", Frames441Slapback, read_landing_error, - (48000, 22050), {}), - ("FloorSlapback", FloorSlapback, read_floor, RATES, {}), - ("PannedSlapback", PannedSlapback, read_pan, RATES, {}), - ("OpenTopSlapback", OpenTopSlapback, read_damping, (48000, 44100), {}), - ("RawToneSlapback", RawToneSlapback, read_damping, RATES, + ("ShortHalfFrameSlapback", ShortHalfFrameSlapback, read_fraction, {}), + ("SecondRepeatSlapback", SecondRepeatSlapback, read_delays, {}), + ("SplitSlapback", SplitSlapback, read_delays, {}), + ("PannedSlapback", PannedSlapback, read_pan, {}), + ("RawTopSlapback", RawTopSlapback, read_damping, {}), + ("RawToneSlapback", RawToneSlapback, read_damping, {"tone_hz": 15000.0}), - ("NoTwoPiSlapback", NoTwoPiSlapback, read_wow_ratio, RATES, {}), + ("NoTwoPiSlapback", NoTwoPiSlapback, read_wow_depth, {}), ) -def reach(faulted, reading, rate, ctor): +def reach(faulted, reading, rate, ctor, grid=None): def build(cls): return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) with NodeSpy(): return kit_faults.fault_reachability(SlapbackDelay, faulted, reading, - build) + build, grid=grid) # -------------------------------------------------------------------------- @@ -742,6 +1025,27 @@ def test_time_lands_on_a_whole_frame(self): self.assertEqual(effect._node_ms, effect._frames * 1000.0 / rate) + def test_a_host_echoing_time_keeps_the_frame(self): + # Fix round 1: set_macro(0, get_macro(0)) on the constructor's + # 135.0 ms keeps 5 954 frames at 44.1 kHz; the old behaviour, + # planted, drops to 5 953. Any other position still moves it. + for rate, frames in ((48000, 6480), (44100, 5954), (22050, 2977)): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(effect._frames, frames, rate) + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(effect._frames, frames, rate) + old = EchoForgetsTimeSlapback(silence_src(64, 2, 44100), + sample_rate=44100) + old.set_macro(TIME_I, old.get_macro(TIME_I)) + self.assertEqual(old._frames, 5953) + effect = SlapbackDelay(silence_src(64, 2, 44100), sample_rate=44100) + effect.set_macro(TIME_I, effect.get_macro(TIME_I) + 0.01) + self.assertIsNone(effect._time_exact) + effect = SlapbackDelay(silence_src(64, 2, 44100), sample_rate=44100) + effect.program_change(0) + self.assertEqual(effect._frames, law_frames(law_time_ms(84), 44100)) + def test_the_wow_map_and_its_ceiling(self): self.assertAlmostEqual(sd.wow_depth_ms(1.0), 0.13137, places=5) self.assertAlmostEqual(sd.wow_depth_ms(3.5), 0.46012, places=5) @@ -855,16 +1159,34 @@ def test_the_control_moves(self): result = t1_control(SlapbackDelay, RATE, **options) self.assertTrue(result["passed"], (options, result)) - def test_frames_at_the_wrong_rate_are_red(self): - result = t1_measure(Frames441Slapback, RATE) - self.assertFalse(result["passed"]) - self.assertAlmostEqual(result["delay_ms"], 124.11, delta=0.05) - - def test_a_loop_gain_floor_is_red(self): - result = t1_measure(FloorSlapback, RATE) - self.assertFalse(result["passed"]) - self.assertLess(result["second_db"], -20.0) - self.assertGreater(result["second_db"], -30.0) + def test_a_short_fractional_landing_is_red(self): + # Fix round 1: floor(0.92 T) + 0.5 frames, off the whole-frame + # surface, red on the delay bar at every rate. + for rate in RATES: + result = t1_measure(ShortHalfFrameSlapback, rate) + self.assertFalse(result["passed"], rate) + self.assertIn("delay", " ".join(result["red"])) + self.assertLess(result["delay_ms"], 130.0) + + def test_a_lone_second_repeat_is_red(self): + # Fix round 1: a second node puts the first repeat again at 2T, + # about 26 dB down, under the control's -20 dB, and nothing at 3T. + for rate in RATES: + energies, _ = t1_read(SecondRepeatSlapback, rate) + self.assertEqual(energies[2], 0.0, rate) + self.assertEqual(energies[3], 0.0, rate) + result = t1_measure(SecondRepeatSlapback, rate) + self.assertFalse(result["passed"], rate) + self.assertEqual(result["red"], + ["a 2T-4T window is not exact zero"]) + self.assertLess(result["second_db"], -20.0) + self.assertGreater(result["second_db"], -30.0) + + def test_the_level_span(self): + # (m): the clauses hold down to a 3 LSB click (-80.8 dBFS). + for rate in RATES: + result = t1_measure(SlapbackDelay, rate, click_lsb=3) + self.assertTrue(result["passed"], (rate, result)) class T2Comb(unittest.TestCase): @@ -880,10 +1202,55 @@ def test_the_defaults_and_the_stops(self): result = t2_measure(SlapbackDelay, RATE, click_lsb=3277) self.assertTrue(result["passed"], result) - def test_frames_at_the_wrong_rate_are_red(self): - result = t2_measure(Frames441Slapback, RATE) - self.assertFalse(result["passed"]) - self.assertAlmostEqual(result["deviations"][0], 8.77, delta=0.05) + def test_the_second_tooth_cells(self): + # Fix round 1: the gate audit's red cells, Wow 0, where 1/T falls + # near half a bin of the pad and the tallest tooth is the second. + for rate in (48000, 44100): + for midi in (117, 120, 124): + result = t2_measure(SlapbackDelay, rate, + time_ms=law_time_ms(midi), + wow_cents=0.0) + self.assertTrue(result["passed"], (rate, midi, result)) + + def test_the_old_first_guess_is_red_there(self): + # The old guess, the autocorrelation's argmax, planted: at grid 124 + # (48 kHz, Wow 0) it takes 2/T and the low fit reads +47 %. + rate = 48000 + T = law_frames(law_time_ms(124), rate) + x = np.zeros(100 + T + 400) + x[100] = 32767 + y = render(SlapbackDelay, x, rate, 2, time_ms=law_time_ms(124), + wow_cents=0.0)[:, 0].astype(float) + size = int(PAD_S * rate) + mag = np.abs(np.fft.rfft(y, n=size)) + bin_hz = rate / float(size) + law = rate / float(T) + old = old_comb_guess(mag, bin_hz) + self.assertAlmostEqual(old / law, 2.0, delta=0.01) + low, _ = notch_spacing(mag, bin_hz, 200.0, 2000.0, old) + self.assertGreater(abs(low / law - 1.0), 0.005) + new = comb_guess(mag, bin_hz) + self.assertAlmostEqual(new / law, 1.0, delta=0.01) + + def test_a_short_fractional_landing_is_red(self): + for rate in RATES: + result = t2_measure(ShortHalfFrameSlapback, rate) + self.assertFalse(result["passed"], rate) + self.assertGreater(result["deviations"][0], 8.0) + + def test_frames_at_44k_are_red_at_22k(self): + result = t2_measure(Frames441Slapback, 22050) + self.assertFalse(result["passed"], result) + + def test_the_split_build_is_red_on_agreement_alone(self): + # Station A's split, on the class: each fit inside 0.5 %, the two + # fits more than 0.5 % apart. + for rate in RATES: + low, high, between = t2_measure(SplitSlapback, + rate)["deviations"] + self.assertLessEqual(abs(low), 0.5, rate) + self.assertLessEqual(abs(high), 0.5, rate) + self.assertGreater(abs(between), 0.5, rate) class T3Mono(unittest.TestCase): @@ -903,6 +1270,26 @@ def test_a_panned_repeat_is_red(self): self.assertFalse(result["passed"]) self.assertGreater(result["lr"], 0) + def test_presence_holds_inside_the_band(self): + # Fix round 1: a -20 dBFS tone at a third of the rate, the band's + # edge, at the defaults, reads within 4 dB at every rate. + for rate in RATES: + head, energy = presence_db(SlapbackDelay, + tone(PRESENCE_BAND * rate, rate), rate) + self.assertEqual(head, 0, rate) + self.assertLessEqual(abs(energy), 4.0, (rate, energy)) + + def test_presence_outside_the_band_is_the_disclosed_wow_loss(self): + # At Nyquist the defaults read outside 4 dB, and at Wow 0 inside + # 0.5 dB: the loss is the wow's fractional read, not the class + # dropping the repeat. If this moves, the band's words move too. + for rate in RATES: + alt = probes.alt_fs(frames=rate, channels=1) + _, energy = presence_db(SlapbackDelay, alt, rate) + self.assertLess(energy, -4.0, rate) + _, still = presence_db(SlapbackDelay, alt, rate, wow_cents=0.0) + self.assertLess(abs(still), 0.5, rate) + class T4Tone(unittest.TestCase): def test_out_and_the_corners_at_48k_and_44k(self): @@ -924,10 +1311,58 @@ def test_the_22k_cells(self): self.assertTrue(result["passed"], (label, result)) def test_an_open_top_stop_is_red_at_the_defaults(self): - for rate in (48000, 44100): + for rate in RATES: + self.assertGreater(t4_out_differing(RawTopSlapback, rate), 0) self.assertGreater(t4_out_differing(OpenTopSlapback, rate), 0) + for rate in (48000, 44100): + self.assertFalse(t4_measure(RawTopSlapback, rate)["passed"]) self.assertFalse(t4_measure(OpenTopSlapback, rate)["passed"]) + def test_out_after_tone_has_been_in_is_within_1_lsb(self): + # The restated out clause (fix round 1, dossier revision): once + # Tone has handed an in-circuit value since the last reset (the + # constructor counts), the out stop is within 1 LSB of the node + # given no `damping_hz`, and byte-identical at Wow 0. + for rate in RATES: + for channels in (2, 1): + for ctor, steps in (({"tone_hz": 5000.0}, (0,)), + ({"patch": 5}, (0,))): + differing, peak, damping = t4_out_history( + SlapbackDelay, rate, channels, steps=steps, **ctor) + self.assertLessEqual(peak, 1, (rate, channels, ctor)) + self.assertGreater(differing, 0, (rate, channels, ctor)) + # The pack's old reading, "patches 0-4 hand 0.0", is + # true of a fresh history only. + self.assertEqual(damping, 32.0 * rate) + differing, _, _ = t4_out_history( + SlapbackDelay, rate, channels, + steps=steps + ((WOW_I, 0),), **ctor) + self.assertEqual(differing, 0, (rate, channels, ctor)) + + def test_out_on_a_fresh_history_is_byte_identical(self): + for rate in RATES: + for channels in (2, 1): + self.assertEqual(t4_out_differing(SlapbackDelay, rate, + channels), 0) + differing, _, damping = t4_out_history( + SlapbackDelay, rate, channels, steps=(0,)) + self.assertEqual((differing, damping), (0, 0.0)) + # A Tone-in constructor with a patch 0 on top never put + # Tone in on the node after the constructor finished, but + # the constructor counts: this one is the 1 LSB case. + differing, peak, damping = t4_out_history( + SlapbackDelay, rate, channels, tone_hz=5000.0, patch=0) + self.assertLessEqual(peak, 1) + + def test_the_bound_goes_red(self): + # The restated clause's own planted faults: an open top after + # Tone has been in is far outside 1 LSB. + for rate in RATES: + for cls in (RawTopSlapback, OpenTopSlapback): + _, peak, _ = t4_out_history(cls, rate, 2, steps=(0,), + patch=5) + self.assertGreater(peak, 1, (rate, cls.__name__)) + def test_a_raw_tone_is_red_at_the_15k_cell(self): for rate in (48000, 44100): result = t4_corner(RawToneSlapback, 15000.0, 15000.0, rate, @@ -935,6 +1370,12 @@ def test_a_raw_tone_is_red_at_the_15k_cell(self): self.assertFalse(result["passed"]) self.assertIsNone(result["corner"]) + def test_a_raw_tone_is_red_at_patch_5_at_22k(self): + label = 2000.0 * 10.0 ** (51 / 127.0) + result = t4_corner(RawToneSlapback, label, label, 22050) + self.assertFalse(result["passed"], result) + self.assertGreater(result["corner"] / label - 1.0, 0.2) + class T5Wow(unittest.TestCase): def test_zero_default_and_top(self): @@ -947,6 +1388,16 @@ def test_zero_default_and_top(self): cents = t5_cents(SlapbackDelay, RATE, patch=0) self.assertLess(abs(cents - 1.0), 0.25) + def test_the_level_span(self): + # (m): the clauses hold on a 10 LSB sine (-70.3 dBFS). + for rate in RATES: + zero = t5_cents(SlapbackDelay, rate, amp=10, wow_cents=0.0) + default = t5_cents(SlapbackDelay, rate, amp=10) + top = t5_cents(SlapbackDelay, rate, amp=10, wow_cents=3.5) + self.assertLess(zero, 0.05, rate) + self.assertLess(abs(default - 1.0), 0.25, rate) + self.assertLess(abs(top - 3.5), 0.5, rate) + def test_the_ceiling(self): cents = t5_cents(SlapbackDelay, RATE, wow_cents=10.0) self.assertLess(cents, 4.0) @@ -1204,23 +1655,36 @@ def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): class FaultsAreUnreachable(unittest.TestCase): - """Every fault's reachability walk, reading what the node is handed at - each position, at the rates its docstring names.""" + """Every fault's reachability walk, reading only what the node is + handed, at 48, 44.1 and 22.05 kHz, on the kit's grid (17 positions per + macro) and on the fine grid (509), plus the six patches.""" CHECKED = 6 * 17 + 6 + CHECKED_FINE = 6 * len(FINE) + 6 def test_every_fault_is_off_the_surface(self): - for name, faulted, reading, rates, ctor in REACH_WALKS: - for rate in rates: + for name, faulted, reading, ctor in REACH_WALKS: + for rate in RATES: with self.subTest(fault=name, rate=rate): result = reach(faulted, reading, rate, ctor) self.assertEqual(result["checked"], self.CHECKED) - def test_the_rates_left_out_are_left_out_for_their_stated_reason(self): + def test_every_fault_is_off_the_fine_grid(self): + for name, faulted, reading, ctor in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor, grid=FINE) + self.assertEqual(result["checked"], self.CHECKED_FINE) + + def test_frames_at_44k_are_off_the_surface_at_22k_only(self): + reach(Frames441Slapback, read_frames, 22050, {}) + reach(Frames441Slapback, read_frames, 22050, {}, grid=FINE) # At 44.1 kHz the 44.1 kHz landing is the right one: inert. with self.assertRaises(kit_faults.FaultInert): - reach(Frames441Slapback, read_landing_error, 44100, {}) - # At 22.05 kHz grid 94-126 hand the pre-warped clamp too. + reach(Frames441Slapback, read_frames, 44100, {}) + + def test_the_rates_left_out_are_left_out_for_their_stated_reason(self): + # At 22.05 kHz grid 94-126 hand the old open top's clamp too. with self.assertRaises(kit_faults.FaultReachable): reach(OpenTopSlapback, read_damping, 22050, {}) # The raw Tone is silent at the defaults, where Tone is out. @@ -1228,6 +1692,40 @@ def test_the_rates_left_out_are_left_out_for_their_stated_reason(self): reach(RawToneSlapback, read_damping, RATE, {}) +class TheRetiredFaultsAreDialable(unittest.TestCase): + """The gate audit's dial, kept: the faults fix round 1 retired render + what a clean surface state renders, byte for byte.""" + + def _noise(self, rate, seconds=1.0): + frames = int(seconds * rate) + return np.frombuffer(probes.noise_det(frames=frames, dbfs=-6.0, + channels=1), + dtype=np.int16)[:frames].astype(float) + + def test_the_floor_is_repeats_0_05(self): + for rate in RATES: + x = self._noise(rate) + a = render(FloorSlapback, x, rate) + b = render(SlapbackDelay, x, rate, repeats=0.05) + c = render(SlapbackDelay, x, rate, + macros={REPEATS_I: 127 * 0.05 / 0.6}) + self.assertEqual(int(np.count_nonzero(a != b)), 0, rate) + self.assertEqual(int(np.count_nonzero(a != c)), 0, rate) + + def test_frames_at_44k_are_a_time_at_48k(self): + x = self._noise(48000) + a = render(Frames441Slapback, x, 48000) + b = render(SlapbackDelay, x, 48000, time_ms=5954 * 1000.0 / 48000) + self.assertEqual(int(np.count_nonzero(a != b)), 0) + + def test_the_open_top_is_a_constructor_tone(self): + for rate in RATES: + x = self._noise(rate) + a = render(OpenTopSlapback, x, rate) + b = render(SlapbackDelay, x, rate, tone_hz=19999.999) + self.assertEqual(int(np.count_nonzero(a != b)), 0, rate) + + class NullBuildRed(unittest.TestCase): """Every demonstrated row goes red on the class built as a wire, beside a control on the real class that must pass.""" From 39e60950c53d21ec42cf9c56ae6a2d402c1ba0d4 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:51:23 -0500 Subject: [PATCH 13/88] SlapbackDelay fix round 2: the audit's two sentences restated, class side Words and tests only; the class's behaviour is unchanged (digests agree 24 of 24). The docstring's Tone-out paragraph no longer reads "none at Wow 0" as general: at the Time positions where the node's float32 landing misses the whole frame (21 at 44.1 kHz, 20 at 22.05 kHz, none at 48 kHz, none at a shipped patch's Time) Wow 0 differs by 1 LSB too. The constant's comment says the same. The echo paragraph now says a restore (save get_macro(0), move Time, write it back) lands 5 953 frames at 44.1 kHz. Tests: test_out_after_tone_has_been_in_is_within_1_lsb gains 44.1 kHz grid 8 at Wow 0 (non-zero, <= 1 LSB) and grid 84 (0), stereo and mono; test_the_band_is_scoped_to_tone_out_and_repeats_0 reads Tone 2 kHz at 3 kHz below -4 dB and Repeats 0.6 at 1 kHz above +4 dB at three rates. Shown able to fail: the old words as assertions on those cells go red (48 != 0 at grid 8; 5.21 and 5.16 dB not within 4 dB). --- lib/audioeffects/rebuilt/slapbackdelay.py | 24 ++++++--- tests/test_cpython_effects_slapbackdelay.py | 54 ++++++++++++++++++--- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 35d9cc0..d0347f1 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -94,16 +94,22 @@ That is the out stop up to float rounding: against the filter truly out, over 2 916 cells (Time, Wow, Saturation, Repeats and Level at three settings each, three rates, stereo and mono, a full-scale ramp and noise), -5 640 of 166 430 700 samples differ, each by 1 LSB, none at Wow 0. The -defaults, and anything since a reset that has not put Tone in, hand the -node exactly no filter. A Tone in the constructor counts: `tone_hz=5000` and -then patch 0, or `patch=5` and then patch 0, is the 1 LSB case (10 of +5 640 of 166 430 700 samples differ, each by 1 LSB. None of those was at +Wow 0, but that run tried three Times only (40, 135 and 250 ms). Where +the node's single-precision `delay_ms * rate / 1000` misses the whole +frame the class asked for (21 Time positions at 44.1 kHz, 20 at +22.05 kHz, none at 48 kHz and none at a shipped patch's Time), Wow 0 +differs by 1 LSB as well. The defaults, and anything since a reset that +has not put Tone in, hand the node exactly no filter. A Tone in the +constructor counts: `tone_hz=5000` and then patch 0, or `patch=5` and then patch 0, is the 1 LSB case (10 of 384 000 samples of 4 s of 0 dBFS noise at 48 kHz stereo), and a `reset()` makes it exact again. **A host that echoes Time back** (`set_macro(0, get_macro(0))`) keeps the constructor's exact Time: the 44.1 kHz default stays on 5 954 frames. -Any other Time position lands the knob's own value. +Any other Time position lands the knob's own value, and that includes a +restore: save `get_macro(0)`, move Time, write the saved value back, and +the 44.1 kHz default comes back on 5 953 frames, one short. **Tone at a low rate.** The knob's corners clamp below Nyquist at the running rate. At 22.05 kHz grid positions 94-126 all sit on the 10 804.5 Hz @@ -174,9 +180,11 @@ #: 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, #: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows #: the tap sample for sample instead of freezing. That is the identity up to -#: float rounding, which moved 5 640 of 166 430 700 samples by 1 LSB where -#: the tap is fractional (Wow on) and none by more -#: (`slapbackdelay_fix_tone_track.py`). +#: float rounding, which moved 5 640 of 166 430 700 samples by 1 LSB and +#: none by more (`slapbackdelay_fix_tone_track.py`). It can move one only +#: where the tap is fractional: with Wow on, and at Wow 0 wherever the node's +#: float32 `delay_ms * rate / 1000` misses the whole frame (21 Time +#: positions at 44.1 kHz, 20 at 22.05 kHz, none at 48 kHz). TONE_TRACK_PER_RATE = 32.0 #: The wow's fixed rate and the Wow knob's ceiling, in cents peak. diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 730c769..113cd99 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -630,10 +630,13 @@ def t3_measure(cls, rate=RATE, **options): "head": head, "energy_db": energy_db} -#: T3's presence band (fix round 1, dossier revision): the 4 dB bar holds -#: on material whose energy lies below a third of the running rate. Above -#: it the wow's fractional read (section 8.3) takes the repeat's energy -#: further down; at Nyquist `alt_fs` reads -4.86 / -5.48 / -5.20 dB. +#: T3's presence band (fix round 1, dossier revision; scoped in fix round +#: 2): with Tone out and Repeats 0, the 4 dB bar holds on material whose +#: energy lies below a third of the running rate. Above it the wow's +#: fractional read (section 8.3) takes the repeat's energy further down; at +#: Nyquist `alt_fs` reads -4.86 / -5.48 / -5.20 dB. With Tone in, its +#: low-pass takes an in-band tone above the corner down, and with Repeats +#: up the repeats stack on a steady tone, so no band is claimed there. PRESENCE_BAND = 1.0 / 3.0 @@ -1279,6 +1282,22 @@ def test_presence_holds_inside_the_band(self): self.assertEqual(head, 0, rate) self.assertLessEqual(abs(energy), 4.0, (rate, energy)) + def test_the_band_is_scoped_to_tone_out_and_repeats_0(self): + # Fix round 2: the band sentence holds with Tone out and Repeats 0 + # only. Inside fs/3, Tone at its 2 kHz stop takes a 3 kHz tone + # more than 4 dB down, and Repeats at its 0.6 stop stacks a 1 kHz + # tone more than 4 dB up, at every rate. If either comes back + # inside 4 dB, the scope can widen, and the words should say so. + for rate in RATES: + head, energy = presence_db(SlapbackDelay, tone(3000.0, rate), + rate, tone_hz=2000.0) + self.assertEqual(head, 0, rate) + self.assertLess(energy, -4.0, (rate, energy)) + head, energy = presence_db(SlapbackDelay, tone(1000.0, rate), + rate, repeats=0.6) + self.assertEqual(head, 0, rate) + self.assertGreater(energy, 4.0, (rate, energy)) + def test_presence_outside_the_band_is_the_disclosed_wow_loss(self): # At Nyquist the defaults read outside 4 dB, and at Wow 0 inside # 0.5 dB: the loss is the wow's fractional read, not the class @@ -1319,10 +1338,12 @@ def test_an_open_top_stop_is_red_at_the_defaults(self): self.assertFalse(t4_measure(OpenTopSlapback, rate)["passed"]) def test_out_after_tone_has_been_in_is_within_1_lsb(self): - # The restated out clause (fix round 1, dossier revision): once - # Tone has handed an in-circuit value since the last reset (the - # constructor counts), the out stop is within 1 LSB of the node - # given no `damping_hz`, and byte-identical at Wow 0. + # The restated out clause (fix round 1, restated again in fix + # round 2): once Tone has handed an in-circuit value since the + # last reset (the constructor counts), the out stop is within + # 1 LSB of the node given no `damping_hz`, and byte-identical at + # Wow 0 where the node's float32 landing of Time is whole: every + # Time at 48 kHz, and every shipped patch's Time at every rate. for rate in RATES: for channels in (2, 1): for ctor, steps in (({"tone_hz": 5000.0}, (0,)), @@ -1338,6 +1359,23 @@ def test_out_after_tone_has_been_in_is_within_1_lsb(self): SlapbackDelay, rate, channels, steps=steps + ((WOW_I, 0),), **ctor) self.assertEqual(differing, 0, (rate, channels, ctor)) + # Fix round 2: at Wow 0 on a Time whose float32 landing + # (`audiodsp_feedback_delay.c:148`) leaves a fraction, the tap is + # fractional and the coefficient-1 stop moves samples by 1 LSB. + # 44.1 kHz grid 8 (1 980 frames asked, no float32 `delay_ms` lands + # it whole) is such a cell; grid 84 lands whole and reads 0. The + # non-zero assertion is the one the old words ("byte-identical at + # Wow 0") fail. + for channels in (2, 1): + for midi, fractional in ((8, True), (84, False)): + differing, peak, _ = t4_out_history( + SlapbackDelay, 44100, channels, + steps=(0, (WOW_I, 0), (TIME_I, midi)), tone_hz=5000.0) + self.assertLessEqual(peak, 1, (channels, midi)) + if fractional: + self.assertGreater(differing, 0, (channels, midi)) + else: + self.assertEqual(differing, 0, (channels, midi)) def test_out_on_a_fresh_history_is_byte_identical(self): for rate in RATES: From 9e4ea23fbe62c30ff6b036af1a2023f4f6f55b31 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:31:54 -0500 Subject: [PATCH 14/88] SlapbackDelay re-audit fix round 1: T3's band struck for a measured table, the landing count restated Round 3 of the gate audit disconfirmed T3's scoped band sentence at 0 dBFS (Saturation 1 at fs/3 reads -4.29 / -4.57 / -4.35 dB). No band is claimed now. PRESENCE_TABLE pins eight measured cells (defaults, Saturation 1 and patch 3 at -20 and 0 dBFS at fs/3, Saturation 1 at 0 dBFS 0.30 fs and at -3 dBFS fs/3) to 0.02 dB at three rates, and a new test holds the 0 dBFS Saturation-1 fs/3 cell outside 4 dB. Shown red: the old sentence as an assertion on that cell (4.29 not <= 4.0), a table entry moved 0.05 dB, and a class handing 0.8 x Saturation (both tests). The docstring and the tone-track constant's comment now say the fractional float32 landings are 21 of the 128 grid positions at 44.1 kHz and 20 at 22.05 kHz, about one whole-frame Time in eight (1 159 of 9 262, 579 of 4 632). No class code changed. --- lib/audioeffects/rebuilt/slapbackdelay.py | 14 ++-- tests/test_cpython_effects_slapbackdelay.py | 84 +++++++++++++++------ 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index d0347f1..fd27a1c 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -97,9 +97,11 @@ 5 640 of 166 430 700 samples differ, each by 1 LSB. None of those was at Wow 0, but that run tried three Times only (40, 135 and 250 ms). Where the node's single-precision `delay_ms * rate / 1000` misses the whole -frame the class asked for (21 Time positions at 44.1 kHz, 20 at -22.05 kHz, none at 48 kHz and none at a shipped patch's Time), Wow 0 -differs by 1 LSB as well. The defaults, and anything since a reset that +frame the class asked for, Wow 0 differs by 1 LSB as well. That is 21 +of the 128 grid positions at 44.1 kHz and 20 at 22.05 kHz, and about one +whole-frame Time in eight at either rate (1 159 of 9 262, 579 of 4 632; +`time_ms=136.054` at 44.1 kHz is one); none at 48 kHz, and none at a +shipped patch's Time. The defaults, and anything since a reset that has not put Tone in, hand the node exactly no filter. A Tone in the constructor counts: `tone_hz=5000` and then patch 0, or `patch=5` and then patch 0, is the 1 LSB case (10 of 384 000 samples of 4 s of 0 dBFS noise at 48 kHz stereo), and a `reset()` @@ -183,8 +185,10 @@ #: float rounding, which moved 5 640 of 166 430 700 samples by 1 LSB and #: none by more (`slapbackdelay_fix_tone_track.py`). It can move one only #: where the tap is fractional: with Wow on, and at Wow 0 wherever the node's -#: float32 `delay_ms * rate / 1000` misses the whole frame (21 Time -#: positions at 44.1 kHz, 20 at 22.05 kHz, none at 48 kHz). +#: float32 `delay_ms * rate / 1000` misses the whole frame: 21 of the 128 +#: grid positions at 44.1 kHz and 20 at 22.05 kHz, about one whole-frame +#: Time in eight at either rate (1 159 of 9 262, 579 of 4 632), none at +#: 48 kHz. TONE_TRACK_PER_RATE = 32.0 #: The wow's fixed rate and the Wow knob's ceiling, in cents peak. diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 113cd99..3fd1e96 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -630,14 +630,35 @@ def t3_measure(cls, rate=RATE, **options): "head": head, "energy_db": energy_db} -#: T3's presence band (fix round 1, dossier revision; scoped in fix round -#: 2): with Tone out and Repeats 0, the 4 dB bar holds on material whose -#: energy lies below a third of the running rate. Above it the wow's -#: fractional read (section 8.3) takes the repeat's energy further down; at -#: Nyquist `alt_fs` reads -4.86 / -5.48 / -5.20 dB. With Tone in, its -#: low-pass takes an in-band tone above the corner down, and with Repeats -#: up the repeats stack on a steady tone, so no band is claimed there. -PRESENCE_BAND = 1.0 / 3.0 +#: T3's presence on steady tones, as measured (re-audit fix round 1, +#: dossier revision). No band is claimed: two sentences that claimed one +#: were each disconfirmed (fix rounds 1 and 2). The 4 dB bar is the frozen +#: row's, on `ramp_fs` and `sweep_log`. On a steady tone the repeat's level +#: depends on the frequency (the wow's fractional read, section 8.3, takes +#: the top of the band down) and on the level (Saturation's cubic takes a +#: full-scale tone down), and this table is what the class reads: Level 2, +#: `presence_db`, a 2 s tone, head 0 in every cell. Keys are (cell, +#: options, dBFS, fraction of the rate); values are dB at 48 / 44.1 / +#: 22.05 kHz. Any reading more than PRESENCE_PIN_DB from its entry is red, +#: so the words cannot drift from the class either way. +PRESENCE_TABLE = ( + ("defaults", {}, -20.0, 1.0 / 3.0, (-2.90, -3.29, -3.01)), + ("Saturation 1", {"saturation": 1.0}, -20.0, 1.0 / 3.0, + (-2.91, -3.30, -3.02)), + ("patch 3", {"patch": 3}, -20.0, 1.0 / 3.0, (-2.81, -3.37, -3.11)), + ("defaults", {}, 0.0, 1.0 / 3.0, (-3.09, -3.47, -3.20)), + ("Saturation 1", {"saturation": 1.0}, 0.0, 1.0 / 3.0, + (-4.29, -4.57, -4.35)), + ("patch 3", {"patch": 3}, 0.0, 1.0 / 3.0, (-3.78, -4.24, -4.01)), + ("Saturation 1", {"saturation": 1.0}, 0.0, 0.30, (-3.87, -4.08, -3.92)), + ("Saturation 1", {"saturation": 1.0}, -3.0, 1.0 / 3.0, + (-3.57, -3.92, -3.66)), +) +PRESENCE_PIN_DB = 0.02 + + +def dbfs_amp(dbfs): + return 32767.0 * 10.0 ** (dbfs / 20.0) def presence_db(cls, values, rate=RATE, **options): @@ -1273,21 +1294,38 @@ def test_a_panned_repeat_is_red(self): self.assertFalse(result["passed"]) self.assertGreater(result["lr"], 0) - def test_presence_holds_inside_the_band(self): - # Fix round 1: a -20 dBFS tone at a third of the rate, the band's - # edge, at the defaults, reads within 4 dB at every rate. + def test_presence_on_tones_reads_the_measured_table(self): + # Re-audit fix round 1: the dossier reports this table instead of + # a band. Each cell must read within PRESENCE_PIN_DB of its entry. + for label, options, dbfs, fraction, table in PRESENCE_TABLE: + for rate, want in zip(RATES, table): + head, energy = presence_db( + SlapbackDelay, tone(fraction * rate, rate, + amp=dbfs_amp(dbfs)), + rate, **options) + cell = (label, dbfs, fraction, rate, round(energy, 3)) + self.assertEqual(head, 0, cell) + self.assertLessEqual(abs(energy - want), PRESENCE_PIN_DB, + cell) + + def test_a_full_scale_tone_at_saturation_1_reads_past_4_db(self): + # Round 3 disconfirmed "with Tone out and Repeats 0, the 4 dB bar + # holds on material below a third of the running rate" here: Tone + # out, Repeats 0, Saturation 1, a 0 dBFS tone at fs/3, at every + # rate. If this comes back inside 4 dB, a band sentence could be + # written again, and it would have to be measured first. for rate in RATES: - head, energy = presence_db(SlapbackDelay, - tone(PRESENCE_BAND * rate, rate), rate) + head, energy = presence_db( + SlapbackDelay, tone(rate / 3.0, rate, amp=dbfs_amp(0.0)), + rate, saturation=1.0) self.assertEqual(head, 0, rate) - self.assertLessEqual(abs(energy), 4.0, (rate, energy)) - - def test_the_band_is_scoped_to_tone_out_and_repeats_0(self): - # Fix round 2: the band sentence holds with Tone out and Repeats 0 - # only. Inside fs/3, Tone at its 2 kHz stop takes a 3 kHz tone - # more than 4 dB down, and Repeats at its 0.6 stop stacks a 1 kHz - # tone more than 4 dB up, at every rate. If either comes back - # inside 4 dB, the scope can widen, and the words should say so. + self.assertLess(energy, -4.0, (rate, energy)) + + def test_tone_in_and_repeats_take_a_tone_past_4_db(self): + # Fix round 2: Tone at its 2 kHz stop takes a 3 kHz tone more than + # 4 dB down, and Repeats at its 0.6 stop stacks a 1 kHz tone more + # than 4 dB up, at every rate (-20 dBFS). The low-pass and the + # repeats doing their jobs, not a defect. for rate in RATES: head, energy = presence_db(SlapbackDelay, tone(3000.0, rate), rate, tone_hz=2000.0) @@ -1298,10 +1336,10 @@ def test_the_band_is_scoped_to_tone_out_and_repeats_0(self): self.assertEqual(head, 0, rate) self.assertGreater(energy, 4.0, (rate, energy)) - def test_presence_outside_the_band_is_the_disclosed_wow_loss(self): + def test_presence_at_nyquist_is_the_disclosed_wow_loss(self): # At Nyquist the defaults read outside 4 dB, and at Wow 0 inside # 0.5 dB: the loss is the wow's fractional read, not the class - # dropping the repeat. If this moves, the band's words move too. + # dropping the repeat. If this moves, the dossier's words move too. for rate in RATES: alt = probes.alt_fs(frames=rate, channels=1) _, energy = presence_db(SlapbackDelay, alt, rate) From d0849c048cfb96b4abc9fe0f2d06f0c0aefaa51d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:40:56 -0500 Subject: [PATCH 15/88] DigitalDelay: Repeat Tone's out stop no longer freezes the loop low-pass SlapbackDelay's build found that audioecho.FeedbackDelay leaves a loop filter's state frozen while its corner is 0 (audiodsp_feedback_delay.c:493-497 at v0.6.2), so a class whose out stop hands 0 plays the frozen state back, out of silence, when the filter comes back in. DigitalDelay had the same defect: Repeat Tone 2 kHz under a 300 Hz tone at 30 000 LSB, Tone out as the input stops, 2 s of silence, Tone back to 0, played 26 443 LSB at Mix 2 and 48 kHz (26 434 at 44.1, 23 453 at 22.05; 7 933 at Mix 0.3). It is fixed the way SlapbackDelay fixed it: once Tone has been in since the last reset, the out stop hands damping_hz = 32 x the rate, where the node's coefficient is exactly 1.0f and the state follows the tap. The same move now reads 0 at three rates, stereo and mono. The Feedback is stepped clear of the stall windows there as with Tone in, and the tail bound counts one frame of filter memory; 24 rendered tails at four Feedbacks, three rates, stereo and mono end inside it. Against the filter truly out, the tracking out stop is exact at whole-frame reads, 1 LSB where the read is fractional, and inside a stall window it carries Tone-in's Feedback nudge (at most 2.4e-5), up to 51 LSB at the 0.99 stop on -18 dBFS noise. The docstring says so; a reset makes it exact again. Repeat Cut has the same node defect and no class-side cure (its high-pass subtracts its state): Cut back in after silence plays 20 858 LSB at Mix 2, 48 kHz. The docstring discloses it and a test pins the numbers, to go red when the node keeps a filter's state live while out. Tests: the Tone move after silence (FrozenToneDelay, the old out stop, red at 26 443), the handed damping before and after Tone has been in and after a reset, the tracking out stop against a fresh one (LeakyTrackDelay, a 0.957 coefficient, red), and the Cut disclosure. All three new Tone tests are red on the class before this commit. The tail-surface test's Tone-out step now states the tracking case and keeps the fresh case exact. SlapbackDelay's docstring gains the one case its count missed: at Repeats 0.5, the one stall window its span reaches, the tracking out stop differs by 1 LSB at every Wow and rate (28 624 of 384 000 samples at Wow 0, 48 kHz). Words only. --- lib/audioeffects/rebuilt/digitaldelay.py | 70 ++++++++- lib/audioeffects/rebuilt/slapbackdelay.py | 8 +- tests/test_cpython_effects_digitaldelay.py | 159 ++++++++++++++++++++- 3 files changed, 226 insertions(+), 11 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index fa69dee..08015ca 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -75,6 +75,34 @@ and position 127 takes the filter out. At 44.1 and 48 kHz every position moves. +**Repeat Tone out, after it has been in.** The node leaves a loop filter +frozen while it is out, and a frozen low-pass would play what it held, +out of silence, when Tone came back in (26 443 LSB at Mix 2, 48 kHz, +before this was fixed on 2026-09-28). So once Tone has been in circuit +since the last `reset()`, the out stop keeps the low-pass running at a +coefficient of exactly 1, which follows the repeat sample for sample, and +Tone back in after silence is silent. Against the filter truly out that is +exact at a whole-frame read and 1 LSB where the read is fractional (while +Time glides, and where the node's single-precision delay misses the whole +frame, as `time_ms=136.054` at 44.1 kHz does). At a Feedback inside one of +Repeat Tone's stall windows (0.5, 0.75, 0.9 and on up to the 0.99 stop) +the out stop also hands the Feedback that Tone in hands, moved clear by +at most 0.00003, and the repeats differ from the filter truly out by a few +LSB: 51 at most in the runs at the 0.99 stop (8 s of -18 dBFS noise, Time +12.5 ms). The defaults, and anything since a reset that has not put Tone +in, hand the node exactly no filter; a `reset()` makes the out stop exact +again. + +**Repeat Cut back in, after it has been out.** Repeat Cut has the same +node defect and no such cure: its high-pass subtracts its state, so no +corner keeps that state live without cutting. Take Cut out while the +repeats play and bring it back in after they have died away, and the +frozen state plays out of silence: 20 858 LSB at its peak over 428 +samples at Mix 2, 48 kHz stereo, after a 300 Hz tone at 30 000 LSB with +Cut at 400 Hz (18 699 at 44.1 kHz, 16 352 at 22.05). A `reset()` clears +it. The fix is the node's (audiodsp's loop filters keeping their state +live while out), due after Phase 5. + A constructor `glide_ms` faster than the knob's fastest walk (under 814.6 ms, down to the 0.99 pin) stays on the audio path, and the knob reads back at grid 1, the fastest walk it has, never at grid 0, the jump: @@ -225,6 +253,16 @@ #: never itself on the widened edge. STALL_FUZZ = 2.0 ** -22 +#: Repeat Tone out after it has been in: `damping_hz` at 32 x the rate, +#: where 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, +#: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows +#: the tap sample for sample instead of freezing on what it held +#: (`:493-497`). `SlapbackDelay`'s constant, for the same node defect. It +#: can differ from the filter truly out by 1 LSB only where the read is +#: fractional: while Time glides, and wherever the node's float32 +#: `delay_ms * rate / 1000` misses the whole frame the class asked for. +TONE_TRACK_PER_RATE = 32.0 + #: 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. @@ -468,6 +506,12 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, #: time; the node is refreshed once, after the last. self._deferred = False self._feedback = 0.0 + self._damping = 0.0 + #: True once Repeat Tone has been handed an in-circuit corner since + #: the node was built or cleared. From then on the loop low-pass's + #: state is live, and the out stop hands `TONE_TRACK_PER_RATE` x the + #: rate, not 0. + self._tone_used = False self._node_ms = 0.0 # 0 (or less) is how the node spells a filter out of circuit, so # Repeat Tone and Repeat Cut at 0 are their out stops; Time at 0 @@ -528,6 +572,7 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, def _clear(self): self._delay.clear() self._fresh = True + self._tone_used = False # -- the maps ------------------------------------------------------ @@ -651,7 +696,18 @@ def _refresh(self): feedback = FEEDBACK_MAX if feedback < 0.0: feedback = 0.0 - if self._macros[TONE_I] < 1.0: + damping = self._tone_damping(self._macros[TONE_I]) + if damping > 0.0: + self._tone_used = True + elif self._tone_used: + # The node updates its loop low-pass only while the coefficient + # is above 0 (`audiodsp_feedback_delay.c:493-497`), so handing 0 + # after Repeat Tone has been in would freeze whatever the filter + # held, and a later Tone move would play it out of silence. A + # coefficient of exactly 1 keeps the state on the tap instead. + damping = TONE_TRACK_PER_RATE * self._sample_rate + self._damping = damping + if damping > 0.0: # With Repeat Tone in, the node can hold a small value for ever # at Feedback values a hair either side of 1 - 0.5 / k; the node # is handed the nearer edge of that window instead. @@ -662,7 +718,7 @@ def _refresh(self): delay_ms=self._node_ms, feedback=feedback, mix=self._value(MIX_I), - damping_hz=self._tone_damping(self._macros[TONE_I]), + damping_hz=damping, cut_hz=self._cut_hz(self._macros[CUT_I])) def _loop_feedback(self, feedback): @@ -712,11 +768,11 @@ def _tone_excess(self): most, weighs under 2^-17 LSB, which is under 2^-17 of any non-zero peak; and the single-precision state can rest up to 2^-24 / a of the peak above it, a being the coefficient, because a step - a (v - y) under half an ulp rounds away. (0, 0.0) with Repeat Tone - out.""" - if self._macros[TONE_I] >= 1.0: - return 0, 0.0 - damping = self._tone_damping(self._macros[TONE_I]) + a (v - y) under half an ulp rounds away. Read from the `damping_hz` + the node was handed, so the out stop after Tone has been in (the + low-pass tracking the tap at a coefficient of 1) counts one frame + of memory. (0, 0.0) with the filter truly out.""" + damping = self._damping if damping <= 0.0: return 0, 0.0 per_frame = 2.0 * math.pi * damping / self._sample_rate diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index fd27a1c..8abb875 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -101,8 +101,12 @@ of the 128 grid positions at 44.1 kHz and 20 at 22.05 kHz, and about one whole-frame Time in eight at either rate (1 159 of 9 262, 579 of 4 632; `time_ms=136.054` at 44.1 kHz is one); none at 48 kHz, and none at a -shipped patch's Time. The defaults, and anything since a reset that -has not put Tone in, hand the node exactly no filter. A Tone in the +shipped patch's Time. At Repeats 0.5, the centre of the one stall window +Repeats reaches (see Tail), the out stop also hands the Feedback that +Tone in hands, moved clear by under 2.5 x 10^-5, and there it differs by +1 LSB at every Wow and rate: 28 624 of 384 000 samples at Wow 0, 48 kHz +(4 s of 0 dBFS noise, Level 0.35; 2026-09-28). The defaults, and anything +since a reset that has not put Tone in, hand the node exactly no filter. A Tone in the constructor counts: `tone_hz=5000` and then patch 0, or `patch=5` and then patch 0, is the 1 LSB case (10 of 384 000 samples of 4 s of 0 dBFS noise at 48 kHz stereo), and a `reset()` makes it exact again. diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index a73c207..7144d0a 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -504,6 +504,34 @@ def _loop_feedback(self, feedback): return feedback +class FrozenToneDelay(DigitalDelay): + """Tier 1's silence clause after a Repeat Tone move: the out stop hands + the node exactly 0 even after Tone has been in, so the loop low-pass + freezes on whatever it held and a later Tone move plays it out of + silence. The class up to the housekeeping round (2026-09-28), and + `FrozenToneSlapback`'s defect.""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + if self._macros[TONE_I] >= 1.0: + self._delay.set(damping_hz=0.0) + + +class LeakyTrackDelay(DigitalDelay): + """The out stop after Repeat Tone has been in, tracking the tap at a + coefficient under 1: `damping_hz` at half the rate (a = 1 - e^-pi, + 0.957), a low-pass left in circuit where the knob says out.""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + if self._tone_used and self._macros[TONE_I] >= 1.0: + self._delay.set(damping_hz=0.5 * self._sample_rate) + + class SlowSeedGlideDelay(DigitalDelay): """The seeding up to re-audit fix round 1: a constructor Glide slower than 8 s stayed on the audio path while the knob read 8 s, so handing @@ -1495,9 +1523,21 @@ def test_tail_samples_follows_time_and_feedback(self): laps = dd.laps_to_zero(effect._feedback, effect._tone_excess()[1]) self.assertIsNotNone(laps) self.assertEqual(effect.tail_samples, laps * (601 + 22)) + # Tone back out after it has been in: the low-pass tracks the tap + # at a coefficient of 1 (one frame of memory), so the Feedback stays + # clear of the stall window as with Tone in (housekeeping, + # 2026-09-28). An instance that never had Tone in hands 0.99 and + # one lap per frame of the line. effect.set_macro(TONE_I, 127) - self.assertEqual(effect._feedback, 0.99) - self.assertEqual(effect.tail_samples, 685 * 601) + self.assertEqual(effect._damping, dd.TONE_TRACK_PER_RATE * RATE) + self.assertLess(effect._feedback, 0.99) + self.assertEqual(effect._tone_excess()[0], 1) + laps = dd.laps_to_zero(effect._feedback, effect._tone_excess()[1]) + self.assertEqual(effect.tail_samples, laps * (601 + 1)) + fresh = copy_of(effect, silence_src(512)) + self.assertEqual(fresh._feedback, 0.99) + self.assertEqual(fresh.tail_samples, 685 * 601) + fresh.deinit() self.assertEqual(dd.laps_to_zero(0.99, 1e-5), None) self.assertEqual(dd.laps_to_zero(0.5, 1e-5), None) self.assertEqual(dd.laps_to_zero(0.7, 1e-5), 29) @@ -1799,6 +1839,121 @@ def test_silence_stays_silence(self): cut_hz=400.0) self.assertEqual(int(np.max(np.abs(pull(effect, RATE)))), 0) + def _tone_back_in(self, cls, rate=RATE, channels=2, mix=2.0): + """300 Hz at 30 000 LSB for 0.5 s with Repeat Tone 2 kHz and + Feedback 0, Tone to its out stop as the input stops, 2 s of silence, + then Tone to MIDI 0: the output's peak after that move.""" + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = [0] * (back + rate // 4) + values[:loud] = sine_values(300.0, loud, rate, 30000) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + tone_hz=2000.0, feedback=0.0, mix=mix) + + def move(frame): + if frame == loud: + effect.set_macro(TONE_I, 127) + elif frame == back: + effect.set_macro(TONE_I, 0) + + y = pull(effect, len(values), channels, on_block=move) + effect.deinit() + return int(np.abs(y[back * channels:]).max()) + + def test_tone_back_in_after_silence_stays_silent(self): + # The node freezes a loop filter's state while its corner is 0 + # (`audiodsp_feedback_delay.c:493-497`), so the out stop keeps the + # low-pass tracking the tap once Tone has been in. + for rate in (48000, 44100, 22050): + for channels in (2, 1): + self.assertEqual(self._tone_back_in(DigitalDelay, rate, + channels), 0, + (rate, channels)) + self.assertEqual(self._tone_back_in(DigitalDelay, mix=0.3), 0) + # Planted: the out stop frozen at 0 plays the held state back + # (26 443 LSB at Mix 2, 48 kHz, when this test was written). + self.assertGreater(self._tone_back_in(FrozenToneDelay), 20000) + self.assertGreater(self._tone_back_in(FrozenToneDelay, mix=0.3), + 3000) + + def test_cut_back_in_after_silence_is_the_disclosed_node_defect(self): + # Repeat Cut's frozen state has no class-side cure (its high-pass + # subtracts the state), so the docstring discloses what it plays. + # When the node keeps a filter's state live while out, this goes + # red and the docstring's paragraph comes out with it. + peaks = [] + for rate in (48000, 44100, 22050): + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = [0] * (back + rate // 4) + values[:loud] = sine_values(300.0, loud, rate, 30000) + effect = DigitalDelay(array_src(values, 2, rate), + sample_rate=rate, cut_hz=400.0, + feedback=0.0, mix=2.0) + + def move(frame, effect=effect, loud=loud, back=back): + if frame == loud: + effect.set_macro(CUT_I, 0) + elif frame == back: + effect.set_macro(CUT_I, 127) + + y = pull(effect, len(values), 2, on_block=move) + effect.deinit() + peaks.append(int(np.abs(y[back * 2:]).max())) + self.assertEqual(peaks, [20858, 18699, 16352]) + + def test_tone_out_hands_no_filter_until_tone_has_been_in(self): + with NodeSpy(): + for rate in (48000, 44100, 22050): + effect = DigitalDelay(silence_src(64, 2, rate), + sample_rate=rate) + self.assertEqual(effect._delay._handed["damping_hz"], 0.0) + effect.set_macro(TONE_I, 0) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._delay._handed["damping_hz"], + dd.TONE_TRACK_PER_RATE * rate) + self.assertIsNotNone(effect.tail_samples) + effect.reset() + self.assertEqual(effect._delay._handed["damping_hz"], 0.0) + effect.deinit() + effect = DigitalDelay(silence_src(64, 2, rate), + sample_rate=rate, patch=5) + effect.program_change(0) + self.assertEqual(effect._delay._handed["damping_hz"], + dd.TONE_TRACK_PER_RATE * rate) + effect.deinit() + + def _out_after_tone(self, cls, rate=RATE, channels=2, **options): + """Samples and worst LSB by which the out stop after Tone has been + in differs from a fresh instance's out stop, on 2 s of noise.""" + frames = 2 * rate + values = [int(v) for v in + np.random.default_rng(5).integers(-32768, 32768, frames)] + tracked = cls(array_src(values, channels, rate), sample_rate=rate, + **options) + tracked.set_macro(TONE_I, 0) + tracked.set_macro(TONE_I, 127) + fresh = DigitalDelay(array_src(values, channels, rate), + sample_rate=rate, **options) + diff = np.abs(pull(tracked, frames, channels).astype(int) + - pull(fresh, frames, channels).astype(int)) + tracked.deinit() + fresh.deinit() + return int((diff > 0).sum()), int(diff.max()) + + def test_tone_out_after_tone_tracks_the_tap(self): + # Outside a stall window, a coefficient of exactly 1 is the filter + # out up to float rounding: 0 at whole-frame reads, 1 LSB where the + # node's float32 delay misses the frame (Time 136.054 at 44.1 kHz). + self.assertEqual(self._out_after_tone(DigitalDelay), (0, 0)) + self.assertEqual(self._out_after_tone(DigitalDelay, patch=4), (0, 0)) + _count, worst = self._out_after_tone(DigitalDelay, rate=44100, + time_ms=136.054, feedback=0.6) + self.assertLessEqual(worst, 1) + # Planted: a coefficient of 0.957 is a low-pass left in circuit. + _count, worst = self._out_after_tone(LeakyTrackDelay) + self.assertGreater(worst, 1000) + def _kit_tail(self, cls, seconds, **options): data, on = probes.burst_silence(total_s=seconds, rate=RATE) effect = cls(probes.ArraySource(data, rate=RATE, channels=2, From e247c04bb77b762f793c0e0ff37458ea5510ba70 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:41:08 -0500 Subject: [PATCH 16/88] Flanger: F8 restated to the ring the fixed node plays Brad's ruling of 2026-09-28: F8's claim becomes what audiodsp v0.6.2 does, a dated restatement and not a rebuild. At Color max (0.99, 3 ms, Filter Matrix on) the ring to -60 dB reads 1.91 s after a 440 Hz burst and 2.13 s after a 200 Hz one (the tests' own reading: 20 ms RMS windows in 10 ms hops after a 50 ms burst at -6 dBFS, 48 kHz). At v0.6.1 it read 2.13 / 2.35 s; audiodsp#154 empties the line where rounding used to hold a few LSB going round, and part of the old ring was that floor. The docstring (module and class), the loop-cut comment and the catalogue row say the measured values. The two F8 tests that were red on purpose since the pin move now pin them within 0.03 s, three hops of the reading: the Color-max loop cut at 0.8 Hz (ShortRingFlanger, 1.80 / 1.87 s) and at 0.2 Hz (LongRingFlanger, 1.96 / 2.26 s) are each outside it at both frequencies, off the macro surface, and the null build is red. With the class's own cut moved to 0.8 Hz the two tests and the null build go red. No DSP line moved: the class file's diff is its docstring and a comment. --- CHANGELOG.md | 10 +++ lib/audioeffects/README.md | 2 +- lib/audioeffects/rebuilt/flanger.py | 29 +++++--- tests/test_cpython_effects_flanger.py | 102 ++++++++++++++++++-------- 4 files changed, 104 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5627b6e..df296b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,16 @@ there, and are recorded in its changelog. repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is unmeasured. +### Changed + +- **`Flanger` (parked under `rebuilt/`): F8 restated, the sound unchanged.** + At Color max (0.99, 3 ms, Filter Matrix on) the ring to -60 dB is about + 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s + at audiodsp v0.6.2), not "2 s on a 200-440 Hz burst": since v0.6.2 the + node no longer holds a few LSB going round for ever, and part of the old + ring was that. The docstring and catalogue row say the measured values, + and the two F8 tests pin them within 0.03 s. + ## v0.3.2 (2026-09-25) No change to either package's code: both packages are 0.3.1 under a new diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index c29077b..b529fc3 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -181,7 +181,7 @@ where the board stage has run, else the dossier palette budget. A | Class | Tier | Macros | Latency | Cost | Standout | |---|---|---|---|---|---| | `Chorus` | audiodsp (`audioecho`) | 5 | 0 samples; wet Delay 3–20 ms is the effect, not lookahead | **8.1 % P4 / 9.8 % S3** at patch 0 (ROW marg 0.431 / 0.524 ms), inside palette **P4 ≤ 9 % / S3 ≤ 15 %**; no lean patch | Electro-Harmonix Small Clone - one BBD voice, clock-law triangle, Mix 0 a wire. **What the default surrenders:** the within-half pitch-offset ratio is 1.98, not (d_max/d_min)² = 3.60 (T2). Tone 12 kHz is 2.4 dB down at 10 kHz, not ≥10 dB (T3). The constructor Tone 3 kHz still meets T3 | -| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the 2 s Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) is the 2 s ring on a 200–440 Hz burst; the default is not, and a click is not that bar. | +| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s measured at audiodsp v0.6.2; restated 2026-09-28 from "the 2 s ring", which the node's old rounding floor partly held up); the default does not, and a click is not that measure. | | `Phaser` | audiodsp (`audiobiquad`, `audioshaper`) | 10 | 0 samples at every setting | Palette **15 % P4 / 24 % S3** (AllPass-6 0.248 / 0.423 + Waveshaper ×1 0.223 / 0.410 + extra synthio 0.317 / 0.408 + glue 0.0). The old **9 % / 16 %** bar omitted the LFO: the palette could not price a `synthio` source. Quoted ROW **8.5 % / 15.6 %**. Patch 8 `Phaser - lean` (Drive 0) is AllPass alone → **5/8 %**. | MXR Phase 90 — four first-order all-pass stages, JFET as the variable resistor. **What the default surrenders:** Drive 0.3 is on at construction. Feedback's inter-notch peak then misses the 5 dB bar (600–1800 Hz rise −0.633 dB at 48 kHz) and the notch floor is not monotone (0.5→0.7 −1.204 dB). Both hold at Drive 0. The floor's 0.5→0.7 step also deepens at 22.05 kHz with Drive 0 (−1.286 dB). | | `Tremolo` | audiodsp (`audiomath`) | 9 | 0 samples; Lag is table shape, not a delay | Palette **7 % P4 / 9 % S3** (Multiply 0.028 / 0.047 + extra synthio 0.317 / 0.408 + glue 0.0). 1024-point board ROW **2.4 % / 4.9 %** (P4 marg 0.128 / ctrl 0.346, rt 11.23; S3 marg 0.265 / ctrl 0.532, rt 6.69). Prior 256-point ROW **2.3 / 4.9** is a shorter waveform. No lean patch | Fender Princeton 6G2 bias-vary and AB763 optical. **What the default surrenders:** Default is bias, Depth 0.5, Rate 5 Hz — not the optical standout and not L1's Depth 1. Wet peak at the default is −1.341 dB vs a 16000-LSB tone, not the ~6 dB `synthio` `>>16` ceiling; Depth 0 is still a wire. At 22.05 kHz the default's L1 bar is measured at the constructor, not assumed. Optical L1/L2 are disconfirmed; O1–O4 unmeasured at the constructor. L1 holds on sine at every rate, and misses by 0.16 dB on SQUARE material at 44.1 kHz only (−79.844 against −80; 48 kHz holds at −82.01). | | `Vibrato` | audiodsp (`audioecho`) | 8 | **mean Delay**; default **192 samples / 4.0 ms at 48 kHz** (wet alone); constructor and `program_change(0)` stay on that whole-sample bin | Palette **9 % P4 / 15 % S3** (`FeedbackDelay` +options, Mixer optioned off at Level 0 dB). Prior Mixer-in-path ROW **9.5 / 15.3 %** is stale. Board ROW of the Delay-only graph **unmeasured**. No lean patch | BBD clock-law vibrato (VB-2). Level 0 dB leaves the Mixer out of the pull. **What the default surrenders:** T1's any-2 kHz-window no-dry clause (Tone −3 dB at 17 kHz). At 22.05 kHz a 2 kHz window can tilt past 1 dB. The 17 kHz −3 dB band is at 48 kHz with a whole-sample Delay. T4 and T7 magnitude are Published defaults only. T7 quadrature is disconfirmed | diff --git a/lib/audioeffects/rebuilt/flanger.py b/lib/audioeffects/rebuilt/flanger.py index e320b1a..6b9bede 100644 --- a/lib/audioeffects/rebuilt/flanger.py +++ b/lib/audioeffects/rebuilt/flanger.py @@ -18,10 +18,17 @@ class in `modulation.py` is not consulted except for the eight defects **What the default gives up.** At the constructor default (Color 0.55, Filter Matrix off) the comb floor is about −22 dB, not the Color-0 -−37 dB null, and the −60 dB tail is about 0.1 s, not 2 s. Color 0→0.9 -peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, -Matrix on). Color max (0.99, 3 ms, Matrix on) is the 2 s ring on a -200–440 Hz burst; the default is not, and a click is not that bar. +−37 dB null, and the −60 dB tail is about 0.1 s, not Color max's ring. +Color 0→0.9 peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise +(held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to +−60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one +(1.91 s and 2.13 s, 20 ms RMS windows after a 50 ms burst at −6 dBFS, +48 kHz, audiodsp v0.6.2); the default does not, and a click is not that +measure. *Restated 2026-09-28:* this read "the 2 s ring on a 200–440 Hz +burst" until audiodsp v0.6.2, where the node stopped holding a few LSB +going round for ever (audiodsp#154) and the ring at 440 Hz fell from +2.13 s to 1.91 s. The sound is unchanged; the claim now says what the +node does. **Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). `audioroute` is only built when Through Zero is on. On a stock @@ -74,7 +81,9 @@ class in `modulation.py` is not consulted except for the eight defects # 20 Hz yields Color-max audio t60 1.63 s; 1 Hz yields 2.02 s at 440 Hz # but a 200 Hz burst was 1.96 s (indep3). Color-max cut is 0.4 Hz so # the 2 s bar has margin across those bursts. Color 0 stays 20 Hz. -# Same one-pole, not a second node. +# Same one-pole, not a second node. (Those figures are from before +# audiodsp v0.6.2. There, at 0.4 Hz, the ring reads 1.91 s at 440 Hz and +# 2.13 s at 200 Hz, and F8 is restated to those values, 2026-09-28.) CUT_HZ = 20.0 CUT_HZ_COLOR_MAX = 0.4 # Color 0.9 as raw feedback misses ≥15 dB at 44.1 / 22.05 kHz on some @@ -147,10 +156,12 @@ class Flanger(_component.Component): What the default gives up. At the constructor default (Color 0.55, Filter Matrix off) the comb floor is about −22 dB, not the Color-0 - −37 dB null, and the −60 dB tail is about 0.1 s, not 2 s. Color 0→0.9 - peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, - Matrix on). Color max (0.99, 3 ms, Matrix on) is the 2 s ring on a - 200–440 Hz burst; the default is not, and a click is not that bar. + −37 dB null, and the −60 dB tail is about 0.1 s, not Color max's + ring. Color 0→0.9 peak rise holds +15 dB at 48 / 44.1 / 22.05 kHz on + noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings + to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a + 200 Hz one (restated 2026-09-28 at audiodsp v0.6.2); the default does + not, and a click is not that measure. """ NAME = 'Flanger' diff --git a/tests/test_cpython_effects_flanger.py b/tests/test_cpython_effects_flanger.py index b5ec7d2..dc0155f 100644 --- a/tests/test_cpython_effects_flanger.py +++ b/tests/test_cpython_effects_flanger.py @@ -103,6 +103,34 @@ class SoftColorFlanger(Flanger): COLOR_CEILING = 0.5 +class _CutAtColorMaxFlanger(Flanger): + """F8's ring, planted: the loop high-pass at Color max moved from + 0.4 Hz to `COLOR_MAX_CUT_HZ`, the same travel from 20 Hz at Color 0. + The ring's length is what moves; Color and the comb do not.""" + NAME = 'Flanger' + COLOR_MAX_CUT_HZ = rebuilt.CUT_HZ_COLOR_MAX + + def _refresh(self): + Flanger._refresh(self) + color = rebuilt._color_to_feedback( + self._value(2), type(self).COLOR_CEILING, type(self).COLOR_LAW) + travel = min(1.0, max(0.0, float(color) / 0.99)) + self._wet.set(cut_hz=rebuilt.CUT_HZ + travel * ( + type(self).COLOR_MAX_CUT_HZ - rebuilt.CUT_HZ)) + + +class ShortRingFlanger(_CutAtColorMaxFlanger): + """F8: the Color-max cut at 0.8 Hz, a ring about 0.1-0.3 s shorter.""" + NAME = 'Flanger' + COLOR_MAX_CUT_HZ = 0.8 + + +class LongRingFlanger(_CutAtColorMaxFlanger): + """F8: the Color-max cut at 0.2 Hz, a ring about 0.05-0.1 s longer.""" + NAME = 'Flanger' + COLOR_MAX_CUT_HZ = 0.2 + + class ShortLatencyFlanger(Flanger): """CLICK: report 256 samples short.""" NAME = 'Flanger' @@ -522,6 +550,17 @@ def test_ceiling_is_not_a_macro(self): lambda subject: build(subject, probe=probes.silence(2048)), label="Flanger F8") + def test_ring_faults_are_not_a_macro(self): + # The node's cut is write-only, so the reading is the Color-max + # corner the class keeps, as for the ceiling above. + for fault in (ShortRingFlanger, LongRingFlanger): + kit_faults.fault_reachability( + Flanger, fault, + lambda e: getattr(type(e), "COLOR_MAX_CUT_HZ", + rebuilt.CUT_HZ_COLOR_MAX), + lambda subject: build(subject, probe=probes.silence(2048)), + label="Flanger F8 ring") + def test_soft_ceiling_fires_at_defaults(self): probe = sine(1000, frames=8192) clean = build(probe=probe) @@ -559,27 +598,37 @@ def _audio_t60(self, cls=Flanger, color=0.99, listen_s=3.0, hz=440.0): t60 = (start - burst) / float(rate) return t60 - #: Why the two tests below are red at audiodsp v0.6.2, said once. The - #: bar is the gate audit's to move or keep, not the pin move's. - F8_AT_V062 = ( - "F8's 2.0 s bar at Color max on the 440 Hz burst measures %.2f s at " - "audiodsp v0.6.2, against 2.13 s at v0.6.1. audiodsp#154 empties " - "the line where rounding used to hold a few LSB going round for " - "ever, and part of the old T60 was that floor holding the ring up " - "(the 200 Hz row reads 2.13 s now, 2.35 s at v0.6.1, and still " - "passes). The bar is left where it is and listed for the Phase 5 " - "gate audit.") - - def test_audio_t60_at_color_max_is_at_least_two_seconds(self): - """F8 audio: Color-max cut is 0.4 Hz so the ring clears 2 s.""" - t60 = self._audio_t60() - self.assertIsNotNone(t60, "never reached −60 dB") - self.assertGreaterEqual(t60, 2.0, self.F8_AT_V062 % t60) - - def test_audio_t60_at_200hz_is_at_least_two_seconds(self): - t60 = self._audio_t60(hz=200.0) - self.assertIsNotNone(t60, "never reached −60 dB") - self.assertGreaterEqual(t60, 2.0, t60) + #: F8 restated on 2026-09-28 (Brad's ruling of that date): what the + #: node does at audiodsp v0.6.2, measured, not the 2.0 s bar. The ring + #: at Color max read 2.13 s at 440 Hz and 2.35 s at 200 Hz at v0.6.1; + #: audiodsp#154 empties the line where rounding used to hold a few LSB + #: going round for ever, and part of the old ring was that floor. The + #: reading steps in 10 ms hops, and the tolerance is three of them: + #: the Color-max loop cut at 0.8 Hz or at 0.2 Hz instead of 0.4 moves + #: the ring out of it at both frequencies. + F8_RING_S = {440.0: 1.91, 200.0: 2.13} + F8_TOLERANCE_S = 0.03 + + def _f8_ring_holds(self, cls, hz, listen_s=3.0): + t60 = self._audio_t60(cls, hz=hz, listen_s=listen_s) + return (t60 is not None + and abs(t60 - self.F8_RING_S[hz]) <= self.F8_TOLERANCE_S), t60 + + def _f8_row(self, hz): + held, t60 = self._f8_ring_holds(Flanger, hz) + self.assertTrue(held, (hz, t60)) + # Planted: a ring 0.1-0.3 s shorter, and one 0.05-0.1 s longer. + for cls in (ShortRingFlanger, LongRingFlanger): + held, t60 = self._f8_ring_holds(cls, hz) + self.assertFalse(held, (cls.__name__, hz, t60)) + + def test_audio_t60_at_color_max_is_the_measured_ring(self): + """F8 audio at 440 Hz: 1.91 s to -60 dB at Color max.""" + self._f8_row(440.0) + + def test_audio_t60_at_200hz_is_the_measured_ring(self): + """F8 audio at 200 Hz: 2.13 s to -60 dB at Color max.""" + self._f8_row(200.0) def test_soft_ceiling_audio_t60_is_under_two_seconds(self): t60 = self._audio_t60(SoftColorFlanger) @@ -588,17 +637,12 @@ def test_soft_ceiling_audio_t60_is_under_two_seconds(self): def test_f8_audio_null_build_is_red(self): def measure(cls): - t60 = self._audio_t60(cls, listen_s=2.5) - passed = t60 is not None and t60 >= 2.0 - return {"passed": passed, "red": [{"t60": t60}]} - clean = self._audio_t60(listen_s=2.5) - # null_build_red needs the clean class to pass first; say why not. - self.assertTrue(clean is not None and clean >= 2.0, - self.F8_AT_V062 % (clean or 0.0)) + held, t60 = self._f8_ring_holds(cls, 440.0, listen_s=2.5) + return {"passed": held, "red": [{"t60": t60}]} kit_faults.null_build_red(Flanger, measure, label="Flanger F8 audio") def test_default_audio_tail_is_not_two_seconds(self): - """Headline: constructor Color 0.55 is ~0.1 s, not F8's Color-max 2 s.""" + """Headline: constructor Color 0.55 is ~0.1 s, not F8's Color-max ring.""" effect = build(probe=probes.silence(2048)) self.assertLess(effect.tail_samples / float(RATE), 0.2) effect.deinit() From 8f4badb3f8108e1e6563073c45feb78f6101ad58 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:52:37 -0500 Subject: [PATCH 17/88] CombFilter declares a finite tail_samples Brad's ruling of 2026-09-28. The class declared `None`; since audiodsp v0.6.2 its tail reaches exact zero, and the bound is now declared for the settings as they stand: DigitalDelay's `laps_to_zero` laps, each one frame longer than the longest line the read head may still be at (after a rising Frequency move with Glide on, the line it is walking down from, until a Glide-0 move or a reset), plus the Tone low-pass's memory, plus 1.5 s while the Trim is in circuit. The Trim's term is measured, not derived. The shelf is fixed point with 12 fractional state bits and a pole pair near z = 1, and its rounding carries the last LSB past the linear decay its poles give (about 25 000 frames at 48 kHz by the poles, 40 041 measured). Over every active Trim step and 32 materials at three rates, 15 621 cells, none held and the longest ran 0.834 s at 48 kHz (0.825 / 0.818 s at 44.1 / 22.05). 1.5 s is 1.8 times that. Finding the bound found a floor: with Tone in, the node's loop low-pass holds a small value for ever near Feedback 1 - 0.5/k (audiodsp#157, the one DigitalDelay steps around). CombFilter held 1 LSB at Feedback 0.5 and 2 LSB at 0.75 with Tone at 2 kHz, on a 2 LSB DC, and the knob's top, 0.95, is a window centre. So with Tone in the class hands DigitalDelay's `clear_of_stalls` Feedback, at most 2.5e-5 away; the knob reads what was set, and with Tone off nothing moves. No shipped patch sits in a window (patch 3, the one with Tone in, is at 0.8528). Measured over every macro's stops and three interior points, every patch, the long corners and the stall centres, at 48 / 44.1 / 22.05 kHz, stereo and mono, on full-scale DC, noise and a 2 LSB DC: 900 renders, none past the bound or holding; the tightest is 20 Hz / Feedback 0.7 on full-scale DC, 69 600 frames against 69 629. The bound reads the same on CPython, desktop MicroPython and CircuitPython at ten settings. Tests (TheTailIsDeclared): finite at every patch and stop; the tight cell beside a bound one lap short (LapShortCombFilter, red, 69 600 against 67 228); the Trim term beside a bound without it (NoTrimTermCombFilter, red); and the stall centre beside the Feedback unstepped (UnsteppedCombFilter, holds 1 LSB). All four are red on the class before this commit. Found, not fixed: Tone off after Tone has been in freezes the loop low-pass, and Tone back in from silence plays it (15 070 / 26 118 / 26 123 LSB at 48 / 44.1 / 22.05 kHz). The docstring and catalogue row disclose it. --- CHANGELOG.md | 9 ++ lib/audioeffects/README.md | 2 +- lib/audioeffects/combfilter.py | 152 ++++++++++++++++++++--- tests/test_cpython_effects_combfilter.py | 131 +++++++++++++++++++ 4 files changed, 279 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df296b6..180f422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ there, and are recorded in its changelog. node no longer holds a few LSB going round for ever, and part of the old ring was that. The docstring and catalogue row say the measured values, and the two F8 tests pin them within 0.03 s. +- **`CombFilter`: `tail_samples` is finite at every setting.** It was + declared `None`. The bound is the lap law on the longest line the read + head may be at, plus the Tone low-pass's memory, plus 1.5 s while the + Trim is in circuit (its fixed-point shelf was measured at up to 0.834 s). + With Tone in, Feedback is handed under 0.00003 clear of the node's stall + windows (audiodsp#157), where it held 1 or 2 LSB for ever at Feedback 0.5 + and 0.75; with Tone off nothing moves, and no shipped patch sits in a + window. The docstring also discloses that Tone off after Tone has been in + freezes the loop low-pass, which Tone back in from silence plays. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index b529fc3..7b673fc 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -128,7 +128,7 @@ marginal share of one block on each board at construction defaults. | `BandPass` | audiodsp (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audiodsp#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | -| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95 against a 10.7 s lap bound; `tail_samples` is still declared `None`. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | +| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. With Tone in, Feedback is stepped under 0.00003 clear of the node's stall windows (audiodsp#157; the 0.95 stop is one). Tone off after Tone has been in freezes the loop low-pass, and Tone back in from silence plays it (15 070 LSB at 48 kHz); `reset()` clears it. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | ### Time and space - `reverb.py`, `delay.py` | Class | Notes | |---|---| diff --git a/lib/audioeffects/combfilter.py b/lib/audioeffects/combfilter.py index eda82d1..7a29d61 100644 --- a/lib/audioeffects/combfilter.py +++ b/lib/audioeffects/combfilter.py @@ -80,11 +80,43 @@ 10.7 s, at 20 Hz / 0.95). While it rings, it rings at the fractional delay it was asked for: 1760 Hz / 0.8 at 27.262 frames against the asked 27.273, 0.7 cents, through its tenth repeat, and the first-repeat tap lands within -0.01 cents. `TAIL_SAMPLES` stays `None` for now: a finite bound that depends -on the setting is derivable, but declaring one changes an adopted class's -surface, and that is the Phase 5 gate audit's call, not the pin move's. -This class is a tuned resonator: Frequency is a pitch, and the first-repeat -tap is the note it plays. +0.01 cents. This class is a tuned resonator: Frequency is a pitch, and the +first-repeat tap is the note it plays. + +**`tail_samples` is finite at every setting** (declared 2026-09-28): an +upper bound, for the settings as they stand, on how long the output takes +to reach exact zero after your input stops. It is the lap law above, each +lap one frame longer than the longest line the read head may still be at +(after a rising Frequency move with Glide on, the line it is walking down +from, until a Glide-0 move or a reset lands it), plus the Tone low-pass's +memory with Tone in, plus 1.5 s while the Trim is in circuit, because the +fixed-point shelf in front lets its last LSB out for up to 0.834 s after a +full-scale input (measured, not derived). At the defaults it is 3 219 +frames at 48 kHz; at 20 Hz / Feedback 0.95 with Tone off and the Trim flat, +398 566 (8.3 s; it counts laps with `DigitalDelay`'s `laps_to_zero`, which +is tighter than the 511 413 above and still covers the 374 400 measured). +Over every macro's stops and three interior points, every patch, +the long corners and the stall centres, at three rates, stereo and mono, +on full-scale DC, noise and a 2 LSB DC, 900 renders end inside it; the +tightest, 20 Hz at Feedback 0.7 on full-scale DC, ends 29 frames short of +69 629. + +**With Tone in, the node has a floor of its own** (audiodsp#157): wherever +0.5 / (1 - Feedback) is within a hair of a whole number k, its loop +low-pass can rest a hair above k LSB and send it round for ever (1 LSB at +Feedback 0.5 and 2 at 0.75 with Tone at 2 kHz, on a 2 LSB DC). The +Feedback knob's top, 0.95, is one of those centres. So with Tone in, the +class hands the node a Feedback just outside each window, at the nearer +edge, at most 0.00003 from the one you set, and the knob still reads what +you set; the tail then reaches zero inside the bound. With Tone off +nothing is moved. + +**Tone off, after Tone has been in, is not yet safe to come back from.** +The node freezes its loop low-pass while Tone is off, and bringing Tone +back after the ring has died plays what it held: 15 070 LSB at 48 kHz after +a 300 Hz tone at 30 000 LSB, Feedback 0, Mix 2. `reset()` clears it. Found +2026-09-28 and not fixed in this class; `DigitalDelay` and `SlapbackDelay` +keep the filter tracking the tap instead. **Two traits this class does not have.** The *negative* comb, whose peaks sit on the odd half-multiples and which sounds hollow rather than pitched, is @@ -104,6 +136,8 @@ VENDOR = "PyDevices" +import math + try: import audioecho except ImportError: # pragma: no cover - a stock board @@ -115,6 +149,14 @@ from . import _component +# `DigitalDelay`'s loop-tail arithmetic, reused rather than copied: the same +# node rounds the same way here. Its module moves up one level when it comes +# home, so both homes are tried. +try: + from .rebuilt.digitaldelay import clear_of_stalls, laps_to_zero +except ImportError: # pragma: no cover - after it lands + from .digitaldelay import clear_of_stalls, laps_to_zero + #: The line, in milliseconds. 20 Hz wants 50 ms and the node keeps one frame #: of headroom below the line's length (`audiodsp_feedback_delay.c:142-150`), @@ -151,6 +193,32 @@ TRIM_CORNER_HZ = 5.0 TRIM_Q = 0.7071067811865476 +#: How long the trim can keep a non-zero output after its input stops, +#: in seconds, while it is in circuit. The shelf is fixed point with 12 +#: fractional state bits (`audiodsp_biquad.c:163-185` at audiodsp v0.6.2) +#: and a pole pair near z = 1, and its rounding carries the last LSB past +#: the linear decay its poles give, so this term is measured, not derived: +#: over every active Trim step, DC at twelve levels and both signs, 5 and +#: 40 Hz sines stopped at eight phases, and noise, 15 621 cells, none held +#: and the longest ran 40 041 frames at 48 kHz (0.834 s; 0.825 s at 44.1, +#: 0.818 s at 22.05; `housekeeping_cf_trim_tail.py`, 2026-09-28). 1.5 s is +#: 1.8 times that. The comb's lap bound counts from where the trim stops. +TRIM_TAIL_S = 1.5 + + +def _tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for the in-loop low-pass at `damping_hz`, + `DigitalDelay`'s reckoning: after `frames` frames whatever its state + held weighs under 2^-17 of it, and its single-precision state can rest + up to 2^-24 / a above the line's peak, a being the coefficient. + (0, 0.0) with Tone off.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + class CombFilter(_component.Component): """A tuned feedback comb: one delay line of 1/f seconds fed back on @@ -168,12 +236,9 @@ class CombFilter(_component.Component): CAPABILITIES = () LATENCY_SAMPLES = 0 - #: Declared unbounded, conservatively. Since audiodsp v0.6.2 (#154) the - #: tail reaches exact zero at every setting, inside - #: `laps * (ceil(F_s/Frequency) + 1)` frames (the module docstring has - #: the lap law and the numbers); up to v0.6.1 it could park for ever - #: above Feedback 0.5. Declaring the finite bound is the gate audit's - #: change to make. + #: The class-level declaration stays `None`; the instance's + #: `tail_samples` is finite at every setting (2026-09-28), because the + #: bound depends on the setting. TAIL_SAMPLES = None MACRO_LABELS = ("Frequency", "Feedback", "Mix", "Tone", "Trim", "Glide") @@ -240,8 +305,20 @@ def _build(self, frequency=440.0, feedback=0.7, mix=1.0, upstream = node self._output = upstream + #: The longest line, in frames rounded up, the read head may still + #: sit at. A Glide walk starts from wherever the head is and the + #: class cannot see how far it has got, so after a rising move + #: (a shorter line) this keeps the old length until a jump + #: (Glide 0) or a reset lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet + #: pulled: it snaps onto the configured delay on its first block. + self._fresh = True + self._feedback = 0.0 + self._damping = 0.0 self._init_macros( (frequency, feedback, mix, tone_hz, trim_db, glide), patch) + self._fresh = False # -- the control laws ---------------------------------------------- @@ -262,13 +339,28 @@ def _refresh(self): blend = 1.0 tone = self._value(3) trim_db = self._value(4) + damping = 0.0 if tone >= TONE_OFF_HZ else self._hz(tone) + slew = self._value(5) + frames = int(math.ceil(self._sample_rate / frequency)) + if self._fresh or slew <= 0.0 or frames > self._reach: + self._reach = frames + feedback = self._value(1) + if damping > 0.0 and feedback > 0.0: + # With Tone in, the node's loop low-pass can hold a small value + # for ever at a Feedback a hair either side of 1 - 0.5 / k + # (audiodsp#157; 0.5, 0.75 and the 0.95 stop are centres), so + # the node is handed the nearer edge of that window instead. + feedback = self._loop_feedback( + feedback, _tone_excess(damping, self._sample_rate)[1]) + self._feedback = feedback + self._damping = damping self._comb.set( delay_ms=1000.0 / frequency, - feedback=self._value(1), + feedback=feedback, mix=blend, - damping_hz=0.0 if tone >= TONE_OFF_HZ else self._hz(tone), - delay_slew=self._value(5)) + damping_hz=damping, + delay_slew=slew) self._trim.gain_db = trim_db self._trim.mix = (1.0 if blend > 0.0 and abs(trim_db) >= FLAT_DB @@ -277,3 +369,35 @@ def _refresh(self): def _apply_macro(self, index, position): del index, position self._refresh() + + def _loop_feedback(self, feedback, excess): + """The Feedback handed to the node with Tone in circuit.""" + return clear_of_stalls(feedback, excess) + + def reset(self): + self._fresh = True + _component.Component.reset(self) + self._fresh = False + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound for the settings as they stand: `laps_to_zero` laps + of the longest line the read head may be at, each one frame longer + for the interpolated read and the Tone low-pass's memory longer, + plus `TRIM_TAIL_S` while the trim is in circuit. Finite at every + setting.""" + self._check_live() + return self._tail_bound() + + def _tail_bound(self): + """`tail_samples` without the liveness check, for subclasses (a + MicroPython `property` has no `fget`).""" + memory, excess = _tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + if laps is None: # pragma: no cover - stepped clear + return None + trim = 0 + if self._trim.mix > 0.0: + trim = int(math.ceil(TRIM_TAIL_S * self._sample_rate)) + return int(laps * (self._reach + 1 + memory)) + trim diff --git a/tests/test_cpython_effects_combfilter.py b/tests/test_cpython_effects_combfilter.py index 79c21d1..5d61b17 100644 --- a/tests/test_cpython_effects_combfilter.py +++ b/tests/test_cpython_effects_combfilter.py @@ -730,6 +730,137 @@ def test_a_half_sample_tuning_still_reaches_exact_zero(self): tuned=self.DRAINS_HZ), 0) +class LapShortCombFilter(combfilter.CombFilter): + """`tail_samples` one lap short. On full-scale DC at 20 Hz / Feedback + 0.7, the bound's tightest cell, the tail outlives it.""" + + NAME = 'CombFilter' + + @property + def tail_samples(self): + memory = combfilter._tone_excess(self._damping, + self._sample_rate)[0] + return (combfilter.CombFilter._tail_bound(self) + - (self._reach + 1 + memory)) + + +class NoTrimTermCombFilter(combfilter.CombFilter): + """`tail_samples` without the trim's term: the comb's laps only, while + the fixed-point shelf in front is still letting its last LSB out.""" + + NAME = 'CombFilter' + + @property + def tail_samples(self): + bound = combfilter.CombFilter._tail_bound(self) + if self._trim.mix > 0.0: + bound -= int(math.ceil(combfilter.TRIM_TAIL_S + * self._sample_rate)) + return bound + + +class UnsteppedCombFilter(combfilter.CombFilter): + """With Tone in, the Feedback handed as set, inside a stall window of + audiodsp#157, where the loop low-pass holds a small value for ever.""" + + NAME = 'CombFilter' + + def _loop_feedback(self, feedback, excess): + return feedback + + +class TheTailIsDeclared(unittest.TestCase): + """`tail_samples` is finite at every setting (housekeeping, + 2026-09-28, Brad's ruling of that date): the comb's lap bound, each lap + one frame past the longest line the read head may be at plus the Tone + low-pass's memory, plus `TRIM_TAIL_S` while the trim is in circuit. + Every macro's stops and interior points, every patch, the long corners + and the stall centres, at three rates, stereo and mono, end inside it + (`housekeeping_cf_tail.py`, 900 cells); these are the cells where each + part of the bound is tight, each beside a bound that is short there.""" + + def render(self, cls, material, **options): + """(declared, last non-zero frame after a 50 ms burst, peak of the + last 0.25 s), silence running to the declared bound plus 0.5 s.""" + rate = SAMPLE_RATE + probe = cls(audiocore.RawSample(array.array("h", [0] * 512), + sample_rate=rate, + channel_count=CHANNELS), + **options) + declared = probe.tail_samples + probe.deinit() + lead = rate // 20 + frames = lead + (rate if declared is None else declared) + rate // 2 + values = array.array("h", [0] * (frames * CHANNELS)) + for index in range(lead * CHANNELS): + values[index] = material + effect = cls(audiocore.RawSample(values, sample_rate=rate, + channel_count=CHANNELS), + **options) + y = pull(effect.output, frames) + effect.deinit() + last = 0 + for index in range(len(y) - 1, lead * CHANNELS - 1, -1): + if y[index]: + last = index // CHANNELS - lead + 1 + break + held = max(abs(v) for v in y[-(rate // 4) * CHANNELS:]) + return declared, last, held + + def test_finite_at_every_patch_and_every_stop(self): + effect = combfilter.CombFilter( + audiocore.RawSample(array.array("h", [0] * 512), + sample_rate=SAMPLE_RATE, + channel_count=CHANNELS)) + for patch in range(6): + effect.program_change(patch) + self.assertIsInstance(effect.tail_samples, int, patch) + for index in range(6): + for midi in (0, 64, 127): + effect.program_change(0) + effect.set_macro(index, midi) + self.assertIsInstance(effect.tail_samples, int, + (index, midi)) + effect.deinit() + + def test_the_lap_bound_holds_where_it_is_tight(self): + # Full-scale DC at 20 Hz / Feedback 0.7: 69 600 frames against + # 69 629 when this test was written. + options = {"frequency": 20.0, "glide": 0.0} + declared, last, held = self.render(combfilter.CombFilter, 32767, + **options) + self.assertLessEqual(last, declared) + self.assertGreater(last, declared - 2402) + self.assertEqual(held, 0) + declared, last, _held = self.render(LapShortCombFilter, 32767, + **options) + self.assertGreater(last, declared) + + def test_the_trim_term_covers_the_shelf(self): + options = {"trim_db": -18.0, "feedback": 0.0, "glide": 0.0} + declared, last, held = self.render(combfilter.CombFilter, 32767, + **options) + self.assertLessEqual(last, declared) + self.assertGreater(last, 20000) + self.assertEqual(held, 0) + declared, last, held = self.render(NoTrimTermCombFilter, 32767, + **options) + self.assertTrue(last > declared or held, (declared, last, held)) + + def test_the_stall_window_is_stepped_clear(self): + # Feedback 0.5 with Tone at 2 kHz is a stall centre: the node held + # 1 LSB for ever there when handed 0.5 (2 LSB at 0.75). + options = {"frequency": 1000.0, "feedback": 0.5, "tone_hz": 2000.0, + "glide": 0.0} + declared, last, held = self.render(combfilter.CombFilter, 2, + **options) + self.assertLessEqual(last, declared) + self.assertEqual(held, 0) + _declared, _last, held = self.render(UnsteppedCombFilter, 2, + **options) + self.assertEqual(held, 1) + + class TheRingIsTheAskedPitchAndEnds(unittest.TestCase): """The TAIL-pitch trait. The ring plays the fractional delay the comb was asked for, through its tenth repeat within 1 cent, and then reaches From b44d043269fd20abcb7fea01d7840df8857e2f4d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:58:52 -0500 Subject: [PATCH 18/88] Cost tool: two FeedbackDelay rows, and a class measured beside its row Two palette targets. `node:audioecho.FeedbackDelay@fb0.9` is the base row at feedback 0.9, where audiodsp#154's rounding rule fires (at 0.45 it never does). It is the probe the boards ran beside the tool on 2026-09-27, under the same key: it renders 3b76320cb19450a8 on CPython, desktop MicroPython and CircuitPython at v0.6.2, the boards' digest, and d9fb49a51cd7379a on the toolbox's audiodsp 0.6.1. `node:audioecho.FeedbackDelay@options+damping` is the +options row with the loop low-pass in (damping_hz 5000), which no row priced and patch 5 of DigitalDelay and SlapbackDelay carries; it renders 9e06dd6c5d8a2f38 on all three. The control's inflation once audioeffects is imported is fixed for the classes the tool knows the row of, and documented for the rest. An effect target imports the package before its control, and the probe alone then costs more (P4 0.20 -> 0.60 ms, S3 0.41 -> 1.05-1.14 ms; on desktop MicroPython 0.0019 -> 0.0118 ms with the live heap at 60 KB and then 480 KB, the collector's work). On the S3 the subtraction did not cancel it, so a class and the palette row its budget was priced from were not measured alike. Now `main()` measures an effect target beside its row by default: the row in the clean VM first, before anything imports audioeffects (its clean marginal, the cost table's figure), then in the same session before and after the target, and it prints the same-conditions marginal, the row's clean marginal plus the target's ms/block minus the row's. That is the correction the board runs made by hand, and a BESIDE line carries it; the ROW line is unchanged. `BESIDE` names the row for DigitalDelay, SlapbackDelay and Flanger (FeedbackDelay@options); `beside=` names another and `beside=False` skips it; a class with no row named says so. Run on desktop MicroPython for rebuilt:DigitalDelay@5: clean row marginal 0.023, same-session row 0.030 / 0.035, target 0.052, same-conditions marginal 0.043 (a desktop figure, not a board number). tests/test_measure_effect_cost.py pins the two rows' digests and the beside lookup; with the 0.9 row planted back at 0.45 and the damping row without its damping, both row tests go red. --- tests/test_measure_effect_cost.py | 70 +++++++++++++++++ tools/measure_effect_cost.py | 124 +++++++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/test_measure_effect_cost.py diff --git a/tests/test_measure_effect_cost.py b/tests/test_measure_effect_cost.py new file mode 100644 index 0000000..450401c --- /dev/null +++ b/tests/test_measure_effect_cost.py @@ -0,0 +1,70 @@ +"""The cost tool's Phase 5 additions (housekeeping, 2026-09-28). + +`tools/measure_effect_cost.py` is a board tool and its timings are not +desktop facts, so this checks only what the desktop can: the two new palette +rows render what the boards rendered, and an effect target finds the palette +row it is measured beside without importing anything to do it. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from tools import measure_effect_cost as m # noqa: E402 + + +def digest(key): + probe = m.Probe() + output, extras, keep = m.resolve("node:" + key)(probe) + value = m._warm(output, extras, True)[0] + del keep + return value + + +class TheNewRows(unittest.TestCase): + def test_feedback_09_is_the_row_the_boards_took(self): + # Both boards and both desktop legs rendered this at audiodsp + # v0.6.2 (board-test-plan.md, "Section 1 at v0.6.2"); at v0.6.1 it + # was d9fb49a51cd7379a, which is why the row exists. + self.assertEqual(digest("audioecho.FeedbackDelay@fb0.9"), + "3b76320cb19450a8") + # The 0.45 row renders other bytes; a 0.9 row that fell back to it + # would be red here. + self.assertEqual(digest("audioecho.FeedbackDelay"), + "a6abc903a9e8e073") + + def test_the_damping_row_turns_the_loop_low_pass_on(self): + options = digest("audioecho.FeedbackDelay@options") + self.assertEqual(options, "e7a07359012d7a8e") + self.assertEqual(digest("audioecho.FeedbackDelay@options+damping"), + "9e06dd6c5d8a2f38") + + +class TheBesideRow(unittest.TestCase): + def test_a_class_target_names_its_class(self): + for target, name in ( + ("rebuilt:DigitalDelay@5", "DigitalDelay"), + ("effect:SlapbackDelay#3", "SlapbackDelay"), + ("rebuilt:Fuzz@ch-cascade@os2@4", "Fuzz"), + ("DigitalDelay", "DigitalDelay"), + ("source", None), + ("node:audioecho.FeedbackDelay", None), + ("audioecho.FeedbackDelay@options", None)): + self.assertEqual(m._class_name(target), name, target) + + def test_every_named_row_is_a_node_row(self): + for name, key in m.BESIDE.items(): + self.assertIn(key, m.NODES, name) + self.assertIs(m.resolve("node:" + key), m.NODES[key]) + self.assertEqual(m.BESIDE["DigitalDelay"], + "audioecho.FeedbackDelay@options") + self.assertEqual(m.BESIDE["SlapbackDelay"], + "audioecho.FeedbackDelay@options") + with self.assertRaises(ValueError): + m.resolve("node:audioecho.FeedbackDelay@nosuchrow") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/measure_effect_cost.py b/tools/measure_effect_cost.py index c510079..6704136 100644 --- a/tools/measure_effect_cost.py +++ b/tools/measure_effect_cost.py @@ -49,6 +49,31 @@ Read the marginal column when comparing two nodes, and the rt column when asking whether a chain fits in real time. +THE CONTROL, ONCE `audioeffects` IS IMPORTED +-------------------------------------------- +The node rows import nothing, so their control is taken on a small heap. +An effect target imports `audioeffects` (in `prime()`, before the control), +and the control then costs more: on the boards the probe alone went from +0.20 to 0.60 ms per block on the P4 and from 0.41 to 1.05-1.14 ms on the S3 +(board-test-plan.md, sections 2 for DigitalDelay and SlapbackDelay), and on +desktop MicroPython from 0.0019 to 0.0118 ms with the live heap at 60 KB +and then 480 KB. That is the collector's work growing with the heap, and it +lands on the control and the target alike. On the desktop and the P4 the +subtraction cancels it. On the S3 it does not: with the class module +imported, the `FeedbackDelay@options` row's marginal read 0.638 ms against +0.781 without, so a class's marginal and the palette row it was priced +from were not measured in the same conditions. + +So an effect target is measured BESIDE its palette row by default: the row +first, in the clean VM before anything imports `audioeffects` (its clean +marginal, the figure the cost table holds), then again in the same session +as the target, once before it and once after. The report adds the +same-conditions figure, the row's clean marginal plus the target's ms per +block minus the row's in the same session, which is the correction the board +runs made by hand. `BESIDE` names each class's row; `main(target, beside= +"")` names another, and `beside=False` skips it. A class with no +row named gets the old report and a line saying so. + THE DIGEST ---------- One sha256 per run, over the first 128 blocks (683 ms) the target renders from @@ -582,6 +607,31 @@ def _feedback_delay_options(probe): return node, (), (node, shape) +def _feedback_delay_09(probe): + """The base row's node and settings at feedback 0.9, where audiodsp#154's + rounding rule fires: at 0.45 it never does, so the two rows above render + the same bytes at v0.6.1 and v0.6.2 by design. Ran on both boards on + 2026-09-27 as a probe beside the tool (`fb09.py`, digest + `3b76320cb19450a8` at v0.6.2, `d9fb49a51cd7379a` at v0.6.1).""" + import audioecho + node = audioecho.FeedbackDelay(sample_rate=SAMPLE_RATE, + channel_count=CHANNELS, max_delay_ms=250) + node.set(delay_ms=180.0, feedback=0.9, mix=0.5) + node.play(probe.output) + return node, (), (node,) + + +def _feedback_delay_options_damping(probe): + """The +options row with the loop low-pass in circuit (`damping_hz` + 5000). Neither +options nor the base row turns a loop filter on, so no + row priced the one that `DigitalDelay`'s and `SlapbackDelay`'s patch 5 + carry (Repeat Tone, Tone). The corner does not change the work per + sample; 5000 is a value every leg holds exactly.""" + output, extras, keep = _feedback_delay_options(probe) + output.set(damping_hz=5000.0) + return output, extras, keep + + # --- the rate path the drive family prices from ---------------------------- # # Phase 4 added these. `Bitcrusher` holds two `audiospeed.SpeedChanger` nodes @@ -884,6 +934,10 @@ def _dynamics_transient(probe): "audioshaper.Waveshaper@x8": _waveshaper(8), "audioecho.FeedbackDelay@options": _feedback_delay_options, "audiodynamics.Dynamics@options": _dynamics_options, + # Phase 5: the row that can show audiodsp#154, and the loop low-pass. + "audioecho.FeedbackDelay@fb0.9": _feedback_delay_09, + "audioecho.FeedbackDelay@options+damping": + _feedback_delay_options_damping, # The rate path, Phase 4. Both legs of Bitcrusher's pair, at its rates. "audiospeed.SpeedChanger": _speed_changer(48000.0 / STAND_RATE_HZ), "audiospeed.SpeedChanger@up": _speed_changer(STAND_RATE_HZ / 48000.0), @@ -1034,6 +1088,29 @@ def build(probe): return build +#: The palette row each class's cost budget was priced from, which `main()` +#: measures beside it (see "THE CONTROL" above). From the classes' own +#: docstrings and dossiers; a class not named here gets no beside row. +BESIDE = { + "DigitalDelay": "audioecho.FeedbackDelay@options", + "SlapbackDelay": "audioecho.FeedbackDelay@options", + "Flanger": "audioecho.FeedbackDelay@options", +} + + +def _class_name(target): + """The class a target names, or `None` for `source` and node rows, + read from the string alone so nothing is imported to answer it.""" + if target == "source" or target.startswith("node:") or target in NODES: + return None + for prefix in ("effect:", "rebuilt:"): + if target.startswith(prefix): + target = target[len(prefix):] + for mark in ("@", "#"): + target = target.partition(mark)[0] + return target + + def resolve(target): """`target` -> a builder, or raise with something readable. @@ -1359,7 +1436,7 @@ def digests(*targets): print("DIGESTS DONE") -def main(target=None): +def main(target=None, beside=None): if target is None: catalogue() return None @@ -1372,12 +1449,33 @@ def main(target=None): BLOCK_SECONDS * 1000.0)) build = resolve(target) + if beside is None: + beside = BESIDE.get(_class_name(target)) + row_build = resolve("node:" + beside) if beside else None + + row_clean = None + if row_build is not None: + # Before anything imports `audioeffects`: the row as the cost table + # priced it. A node row imports nothing, so this VM is still clean. + print("clean control, before the import ", end="") + clean_control = run(_source, False) + print("clean %s " % beside, end="") + row_clean = run(row_build, False) + row_clean["marginal"] = (row_clean["ms_per_block"] + - clean_control["ms_per_block"]) prime(build) print("control (probe source alone) ", end="") control = run(_source, False) + row_before = row_after = None + if row_build is not None: + print("beside (%s) " % beside, end="") + row_before = run(row_build, False) print("target (%s) " % target, end="") measured = run(build, True) + if row_build is not None: + print("beside (%s) " % beside, end="") + row_after = run(row_build, False) print("") print("%-36s %10s %9s %10s %10s" @@ -1391,6 +1489,24 @@ def main(target=None): marginal = measured["ms_per_block"] - control["ms_per_block"] print("%-36s %10s %9s %10.3f" % ("marginal (target - control)", "", "", marginal)) + corrected = None + if row_clean is not None: + row_ms = (row_before["ms_per_block"] + row_after["ms_per_block"]) / 2 + corrected = row_clean["marginal"] + measured["ms_per_block"] - row_ms + print("") + print("beside %s:" % beside) + print(" clean marginal, before the import %10.3f" + % row_clean["marginal"]) + print(" ms/block in this session, before/after %10.3f / %.3f" + % (row_before["ms_per_block"], row_after["ms_per_block"])) + print(" same-conditions marginal (clean row + target - row) %.3f" + % corrected) + elif _class_name(target) is not None and beside is not False: + print("") + print("no palette row named for %s: the marginal above subtracts a " + "control taken after the import (see THE CONTROL); pass " + "beside= to measure it beside its row" + % _class_name(target)) print("") print("budget: %.3f ms/block is real time; this target uses %.0f%% of it" % (BLOCK_SECONDS * 1000.0, @@ -1442,6 +1558,12 @@ def main(target=None): % (target, measured["blocks_per_s"], measured["rt"], measured["ms_per_block"], control["ms_per_block"], marginal, measured["bytes"], measured["digest"])) + if corrected is not None: + print("BESIDE\t%s\t%s\t%.3f\t%.3f\t%.3f\t%.3f\t%.3f" + % (target, beside, row_clean["marginal"], + row_before["ms_per_block"], row_after["ms_per_block"], + measured["ms_per_block"], corrected)) + measured["same_conditions_marginal"] = corrected return measured From 8e8f8cc859a26de2d90d95092c1a2b85042da1f7 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:07:37 -0500 Subject: [PATCH 19/88] TapeDelay Station B: the rebuild and its tests Built from scratch against the frozen TapeDelay dossier (anchor fd711ca): one audioecho.FeedbackDelay with the varispeed and sliding-head walk laws, the eq. (13) loss corner from Spacing, Time and the character's speed, the one-table wow (two lines and a periodic slow component), and DigitalDelay's tail and transport arithmetic. Parked; rebuilt.ADOPTED untouched. --- lib/audioeffects/rebuilt/tapedelay.py | 804 +++++++++++++ tests/test_cpython_effects_tapedelay.py | 1436 +++++++++++++++++++++++ 2 files changed, 2240 insertions(+) create mode 100644 lib/audioeffects/rebuilt/tapedelay.py create mode 100644 tests/test_cpython_effects_tapedelay.py diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py new file mode 100644 index 0000000..1819c3c --- /dev/null +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -0,0 +1,804 @@ +"""`TapeDelay` - a tape loop with two transports: the RE-201's motor and the +EP-3's sliding head. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/TapeDelay.md`, whose trait table +was frozen at Station A before this file existed (anchor commit +fd711caf7cb421dff9c0f4d24c717f7d00548c4b, the Station A critique's +re-freeze, 2026-09-27). The old class in `delay.py` is consulted only for +the seven defects that dossier's section 7 names; it stays the class the +library serves until the board runner adopts this one. + +**What it sounds like.** Your dry signal passes untouched, and repeats +follow it off a loop of tape, each one a little darker than the last, +because the playback head loses the top of the band once per pass. Time +(20-1 200 ms) is the delay. Feedback (0-0.99) is how much of each repeat +goes round again. Mix (0-2) is the echo return: dry at unity up to 1, the +repeats alone at 2, and Mix 0 is a wire while the loop keeps recording. +Spacing (2-20 um) is how far the worn head sits off the tape: more spacing, +darker repeats. Wow and Flutter are the transport's slow and fast wobble, +in cents. Record Level drives the tape harder, a soft odd-order squash on +every pass. Spread feeds each channel's repeats into the other. Sync locks +Time to Division of the host's beat. + +**Two characters, and they differ in what turning Time does.** +`character="varispeed"` (the default) is the Roland RE-201: Time moves the +motor, so a Time move bends the pitch of everything on the tape by the +ratio of the two times for exactly the new time, then settles, and the +repeats that went round during the move come back at their own pitch. +200 -> 100.4 ms reads +1 193 cents for 100.4 ms. The speed also moves the +loss: between 180 and 600 ms (40 to 12 cm/s) the repeats' corner falls by +the same 3.33x as the speed. Glide does nothing on this character; the +motor's own law sets how long a move takes. `character="sliding-head"` is +the Maestro EP-3: Time slides a head, so the pitch bends only while the +head moves, by 1 180 ms / Glide delay-seconds per second, and whatever +went round during the move keeps the bend for as long as it keeps going +round. Glide 0 is an instant slide, and its price is a click. The tape +runs at a fixed 20.32 cm/s there, so the loss does not follow Time. + +**The standout:** the Roland RE-201 Space Echo and the Maestro Echoplex +EP-3, as the tape literature models them (Zavalishin & Parker's two delay +types; Chowdhury's playback-loss law; the Echoplex's two transport +components and drift). + +**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The stock +`audiodelays.Echo` has no filter, drive or cross-feed in its loop, so the +darkening per pass has nowhere to live. On a stock CircuitPython board this +module imports cleanly and construction raises `ImportError`. + +**Latency: zero samples, at every setting, character and rate.** Nothing +looks ahead. The delay is the wet path, not latency on the dry path, and +no option adds any. + +**Mono.** A one-channel source gets the same effect on its one channel. +Spread is held at 0 there: at one channel the node's cross-feed sends a +repeat to a channel that does not exist, and Spread 1 would leave one +repeat and nothing after it. The class never passes `input_pan`. + +**RAM.** The line is `max_time_ms + 5` ms of two int16 lanes whatever the +channel count: 231 360 B at 48 kHz for the default 1 200 ms (212 560 B at +44.1 kHz, 106 280 B at 22.05 kHz), plus 16 384 B for two 4 096-point wow +tables (the node reads one while a Wow or Flutter move writes the other), +two 16 KB shape tables shared by every instance, and about 1.2 KB of +node. Pass a lower `max_time_ms` to spend less; Time then stops at that +ceiling and `get_macro(0)` shows where it stopped. + +**Cost.** One `audioecho.FeedbackDelay` with `delay_slew`, a wow table, +the loop low-pass and `loop_drive` on; no mixer. Palette row +FeedbackDelay +options (the nearest not-cheaper row), glue 0: +**P4 <= 9 %, S3 <= 15 %** of a 5.333 ms stereo block. The board +measurement is pending hardware. No `" - lean"` patch: every patch runs +the same node with the same options, so none would be cheaper. + +**What the default surrenders.** The darkening follows the tape's loss law +only up to a band top: one pole in the loop holds it to 2 dB from 100 Hz +to 2.9 kHz at 12 cm/s, 4.9 kHz at 20.32 cm/s and 6 kHz at 40 cm/s (at +5 um), and above that the repeats are lighter than tape, by 21 dB a pass +at 10 kHz and 12 cm/s. The fluctuation is periodic, not random: the wow +line, the flutter line and the slow drift are harmonics 72, 512 and 1-9 of +one table the node runs at 0.009991 Hz, so the whole wobble repeats every +100.09 s (100.04 s at 22.05 kHz). Record Level has no memory: tape +hysteresis is not modelled, and the squash is a static cubic, the same +rising or falling. The RE-201's Bass and Treble are not here; the loss law +and Spacing own the repeats' tone. + +**Where the pitch claim stops.** A varispeed move takes its rate from the +last Time handed to the node and runs once, so a Time move issued while the +last one is still gliding does not telescope as a real motor would: its +bend is written into the loop and stays there. On sliding-head the node +walks the read head in single precision, and a rising move at the Glide +knob's fastest positions, strictly between grid 1 and grid 4 (1 222.0 to +1 290.3 ms), can read up to 23.5 cents off the glide law past the power +of two it crosses. That part of the knob is not claimed on a rising move, +nor is a constructor Glide in the same range. Grid 1 itself and everything +from grid 4 up are. + +**Turning Wow or Flutter while it plays steps.** The node takes a new +table and depth at once (`audiodsp_feedback_delay.c:457-458` adds depth x +table to the read head, with no ramp), so the repeats jump by the change +in excursion wherever the wobble is. Time walks; Wow and Flutter do not, +so set them before you play. + +**Input ceiling.** The dry path sits at unity and the repeats add to it, +and there is no input gain to turn down. Measured on the kit's `noise_det` +at 48 kHz over 20 s, the defaults put no sample on the rail from +-1.1 dBFS peak down on either character, in stereo and in mono (at +-1.0 dBFS 14 samples rail in stereo, 7 in mono), and every shipped patch +on either character from -2.0 dBFS down (patch 2, Short Slap, is the first +to rail on varispeed, at -1.9; patch 4, High Intensity, on sliding-head, at +-1.8). + +**Tail.** `tail_samples` is an upper bound on how long the output takes to +reach exact zero after your input stops: `laps x (reach + wow + 1 + +memory)` frames, 14 laps at the default Feedback (240 282 frames, 5.01 s, +at 48 kHz) and 85 at patch 4's 0.8965. The loop low-pass is always in, and +it can hold a small value for ever at a Feedback a hair either side of +1 - 0.5 / k, so the class hands the node a Feedback just outside each of +those windows (at most 3 x 10^-5 away, far inside one step of the knob, +which still reads what you set). + +`capabilities = ("tempo_sync",)`: with Sync on, the class reads +`self._transport()` on every macro move and program change (not per block). +With no host transport, or a host whose tempo is not a finite positive +number (0, negative, NaN, infinite or missing), Time stays where the knob +is. A synced Time change moves the way the character moves Time. + +A constructor value stays on the audio path unrounded by the knob's grid +where the grid would move it: Time (landed on a whole frame at the running +rate, so 350 ms is 7 718 frames at 22.05 kHz) and Glide. A constructor +Glide faster than grid 1 keeps its own walk (pinned at 0.99 under +1 191.9 ms) and the knob reads back at grid 1, never at grid 0, the jump; +one slower than 12 s plays 12 s; 0, a negative or NaN is the jump. A Time +of 0 or less is 20 ms, a Spacing of 0 or less is 2 um, a `max_time_ms` +above 1 200 or NaN is 1 200 ms, and any other NaN takes that option's +default. `character` must be `"varispeed"` or `"sliding-head"`. +""" + +VENDOR = "PyDevices" + +from array import array +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail and transport arithmetic, reused rather than copied: +# the same node rounds the same way in both classes. Its module moves up +# one level when it comes home, so both homes are tried. +try: + from .digitaldelay import (DIVISION_BEATS, clear_of_stalls, laps_to_zero, + whole_frames) +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import (DIVISION_BEATS, clear_of_stalls, + laps_to_zero, whole_frames) + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +CHARACTERS = ("varispeed", "sliding-head") +VARISPEED, SLIDING_HEAD = CHARACTERS + +#: The Time map, fixed on every instance (dossier section 6): 20-1 200 ms. +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 1200.0 + +#: Glide is the time a full-range Time move takes on sliding-head; the +#: node's `delay_slew` is FULL_RANGE_MS / glide_ms (dossier section 6). +FULL_RANGE_MS = TIME_MAX_MS - TIME_MIN_MS + +#: The Glide knob's span, log, with grid 0 the jump. +GLIDE_MIN_MS = 1200.0 +GLIDE_MAX_MS = 12000.0 + +#: At slew 1 a rising Time stands the read head still. A constructor Glide +#: under 1 191.9 ms is pinned here; no grid position gets there (grid 1 is +#: 1 222.0 ms, slew 0.966). +SLEW_PIN = 0.99 + +#: The Glide knob's position for a constructor Glide faster than grid 1: +#: grid 1 itself, never grid 0, the jump (DigitalDelay's `GLIDE_FLOOR`). +GLIDE_FLOOR = 1.0 / 127.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: The line's headroom over `max_time_ms`: one frame for the node's +#: `line_frames - 2` clamp (`audiodsp_feedback_delay.c:148-150`) plus the +#: wow table's largest excursion, 3.096 ms at the Wow and Flutter stops, +#: rounded up (dossier Tier 3). +LINE_HEADROOM_MS = 5.0 + +#: S3 eq. (13)'s play gap and tape thickness, metres (dossier section 6). +GAP_M = 5e-6 +THICK_M = 35e-6 + +#: The Spacing knob, micrometres, log. +SPACING_MIN_UM = 2.0 +SPACING_MAX_UM = 20.0 + +#: The speed law (dossier section 6). Varispeed: 40 cm/s at Time 180 ms and +#: below, 12 cm/s at 600 ms and above, 40 x 180 / T between (fixed heads: +#: T is inversely v). Sliding-head: the Echoplex's roughly 8 ips. +V_FAST = 0.40 +V_SLOW = 0.12 +T_FAST_MS = 180.0 +T_SLOW_MS = 600.0 +V_SLIDING = 0.2032 + +#: The wow table: one period of TABLE_POINTS Q15 points at WOW_HZ, holding +#: the wow line at harmonic WOW_HARMONIC, the flutter line at +#: FLUTTER_HARMONIC and the drift at harmonics 1-9, amplitude 1/k, at +#: DRIFT_PHASES (dossier section 6; `tapedelay_stationA_common.py:59`). +WOW_HZ = 0.01 +TABLE_POINTS = 4096 +WOW_HARMONIC = 72 +FLUTTER_HARMONIC = 512 +WOW_LINE_HZ = 0.72 +FLUTTER_LINE_HZ = 5.12 +DRIFT_PHASES = (0.37, 2.91, 5.02, 1.18, 4.40, 3.33, 0.84, 5.71, 2.26) +#: The drift's peak excursion, ms per cent of Wow. +DRIFT_PER_WOW_CENT_MS = 0.25 +WOW_MAX_CENTS = 8.0 +FLUTTER_MAX_CENTS = 4.0 + +(TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, WOW_I, FLUTTER_I, RECORD_I, SPACING_I, + SPREAD_I, SYNC_I, DIVISION_I) = range(11) + +#: How close, as a 0..1 knob position, a Time write must come to the +#: constructor's own seeded position to count as a host echoing it back. +ECHO_TOLERANCE = 1e-6 + +#: The constructor defaults, which NaN falls back to. +DEFAULTS = (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 0.0, 6.0) + + +# -- the loss law ------------------------------------------------------------- + +def eq13_gain(k, spacing_m): + """S3 eq. (13), one pass, as a linear gain at wavenumber `k` (rad/m): + spacing e^(-k d), thickness (1 - e^(-k delta)) / (k delta) and gap + |sinc(k g / 2)|, at `GAP_M` and `THICK_M`.""" + spacing = math.exp(-k * spacing_m) + kd = k * THICK_M + thickness = (1.0 - math.exp(-kd)) / kd + half = k * GAP_M / 2.0 + gap = abs(math.sin(half) / half) + return spacing * thickness * gap + + +def k3_of(spacing_m): + """The wavenumber at which eq. (13) is at half power (-3.01 dB), by + bisection in log k: 15 967 rad/m (393.5 um) at 5 um.""" + lo, hi = 1.0, 1e7 + for _ in range(200): + mid = math.sqrt(lo * hi) + gain = eq13_gain(mid, spacing_m) + if gain * gain > 0.5: + lo = mid + else: + hi = mid + return math.sqrt(lo * hi) + + +def speed(character, time_ms): + """Tape speed, m/s, for a character at a Time (dossier section 6).""" + if character == SLIDING_HEAD: + return V_SLIDING + t = float(time_ms) + if t < T_FAST_MS: + t = T_FAST_MS + if t > T_SLOW_MS: + t = T_SLOW_MS + return V_FAST * T_FAST_MS / t + + +def tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for the loop low-pass at `damping_hz` + (already pre-warped): after `frames` frames whatever its state held + weighs under 2^-17 of it, and its single-precision state can rest up to + 2^-24 / a above the line's peak, a being the coefficient. + `DigitalDelay`'s `_tone_excess`, as a function of the handed value.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +# -- the glide laws ----------------------------------------------------------- + +def slew_of(glide_ms): + """Sliding-head's `delay_slew` for a Glide: 0 (the jump) at Glide 0, + otherwise 1 180 ms over `glide_ms`, pinned at 0.99.""" + glide_ms = float(glide_ms) + if not glide_ms > 0.0: + return 0.0 + slew = FULL_RANGE_MS / glide_ms + if slew > SLEW_PIN: + slew = SLEW_PIN + return slew + + +def varispeed_slew(from_ms, to_ms): + """The tape equation for a speed step from a settled transport: + dT/dt = 1 - T_old / T_new, so the walk runs |dT| / T_new + delay-seconds per second and lasts exactly T_new (S4 eq. 3).""" + return abs(float(to_ms) - float(from_ms)) / float(to_ms) + + +# -- the wow table ------------------------------------------------------------ + +_SINE = None +_DRIFT = None + + +def _shapes(): + """(one period of sine, the unit drift) at `TABLE_POINTS`, float32, + computed once and shared by every instance. The drift is harmonics 1-9 + at amplitude 1/k and `DRIFT_PHASES`, scaled to a peak of 1.""" + global _SINE, _DRIFT + if _SINE is None: + points = TABLE_POINTS + mask = points - 1 + quarter = points // 4 + sine = array("f", [0.0] * points) + for n in range(points): + sine[n] = math.sin(2.0 * math.pi * n / points) + weights = [(k, math.cos(p) / k, math.sin(p) / k) + for k, p in zip(range(1, 10), DRIFT_PHASES)] + drift = array("f", [0.0] * points) + peak = 0.0 + for n in range(points): + total = 0.0 + for k, c, s in weights: + i = k * n + total += (sine[i & mask] * c + sine[(i + quarter) & mask] * s) + drift[n] = total + if abs(total) > peak: + peak = abs(total) + for n in range(points): + drift[n] = drift[n] / peak + _DRIFT = drift + _SINE = sine + return _SINE, _DRIFT + + +def cents_to_depth_ms(cents, line_hz): + """A delay D sin(2 pi f t) ms moves pitch by a peak ratio of + 1 + 2 pi f D / 1000, so `cents` peak is + D = (2^(cents/1200) - 1) / (2 pi f) x 1000: 8 cents of the 0.72 Hz line + is 1.024 ms, 4 cents of the 5.12 Hz line 0.072 ms.""" + cents = float(cents) + if not cents > 0.0: + return 0.0 + return ((2.0 ** (cents / 1200.0) - 1.0) / (2.0 * math.pi * line_hz) + * 1000.0) + + +def wow_table(wow_cents, flutter_cents, out, flutter_harmonic=FLUTTER_HARMONIC, + drift=True): + """Write the wow table for (Wow, Flutter) into `out`, an int16 array of + `TABLE_POINTS`, and return its peak excursion in ms, which is the + node's `wow_depth_ms`; 0.0 (and `out` untouched) with nothing to write. + + The wow line's peak is `cents_to_depth_ms(wow, 0.72 Hz)`, the flutter + line's `cents_to_depth_ms(flutter, 5.12 Hz)` and the drift's 0.25 ms per + cent of Wow; the table is their sum normalised to its own peak.""" + w = cents_to_depth_ms(wow_cents, WOW_LINE_HZ) + f = cents_to_depth_ms(flutter_cents, FLUTTER_LINE_HZ) + d = DRIFT_PER_WOW_CENT_MS * float(wow_cents) if drift else 0.0 + if not d > 0.0: + d = 0.0 + if w <= 0.0 and f <= 0.0 and d <= 0.0: + return 0.0 + sine, walk = _shapes() + mask = TABLE_POINTS - 1 + peak = 0.0 + for n in range(TABLE_POINTS): + x = (w * sine[(WOW_HARMONIC * n) & mask] + + f * sine[(flutter_harmonic * n) & mask] + d * walk[n]) + if abs(x) > peak: + peak = abs(x) + scale = 32767.0 / peak + for n in range(TABLE_POINTS): + x = (w * sine[(WOW_HARMONIC * n) & mask] + + f * sine[(flutter_harmonic * n) & mask] + d * walk[n]) + out[n] = int(math.floor(x * scale + 0.5)) + return peak + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class TapeDelay(_component.Component): + """A tape echo with the RE-201's motor (`character="varispeed"`) or the + EP-3's sliding head (`"sliding-head"`): each repeat darker than the + last, a wobbling transport, and Time moves that bend the pitch instead + of clicking. audiodsp tier; zero latency. + + **What the default surrenders:** the darkening follows the tape's loss + law only to a band top (2.9-6 kHz at 5 um, by speed) and is lighter + than tape above it; the wobble repeats every 100.09 s; Record Level has + no memory; no Bass or Treble. Glide is inert on varispeed. + """ + + NAME = 'TapeDelay' + DISPLAY_NAME = 'Tape Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Glide", "Wow", "Flutter", + "Record Level", "Spacing", "Spread", "Sync", "Division") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + 9: "TOGGLE", + 10: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (GLIDE_MIN_MS, GLIDE_MAX_MS, "log"), # 3 Glide, ms; grid 0 = jump + (0.0, WOW_MAX_CENTS), # 4 Wow, cents at 0.72 Hz + (0.0, FLUTTER_MAX_CENTS), # 5 Flutter, cents at 5.12 Hz + (0.0, 1.0), # 6 Record Level, loop_drive + (SPACING_MIN_UM, SPACING_MAX_UM, "log"), # 7 Spacing, um + (0.0, 1.0), # 8 Spread, cross_feed + (0.0, 1.0), # 9 Sync + (0.0, 15.0), # 10 Division index + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Warm Repeats", (89, 58, 22, 89, 32, 32, 25, 51, 0, 0, 51)), + 1: ("Long Repeats, Slow Glide", + (114, 71, 22, 127, 32, 32, 25, 51, 0, 0, 51)), + 2: ("Short Slap", (47, 19, 32, 89, 16, 16, 25, 51, 0, 0, 51)), + 3: ("Dark Repeats, Heavy Wow", + (97, 71, 22, 89, 95, 64, 25, 111, 0, 0, 51)), + 4: ("High Intensity", (93, 115, 25, 89, 32, 32, 76, 51, 0, 0, 51)), + 5: ("Worn Heads", (89, 58, 22, 89, 64, 95, 64, 127, 0, 0, 51)), + 6: ("Clean Transport", (89, 58, 22, 89, 0, 0, 0, 0, 0, 0, 51)), + 7: ("Dotted Eighth, Synced", + (89, 58, 22, 89, 32, 32, 25, 51, 0, 127, 68)), + } + + def _build(self, time_ms=350.0, feedback=0.45, mix=0.35, glide_ms=6000.0, + wow_cents=2.0, flutter_cents=1.0, record_level=0.2, + spacing_um=5.0, spread=0.0, sync=False, division=6, + character=VARISPEED, max_time_ms=TIME_MAX_MS, patch=None): + if character not in CHARACTERS: + raise ValueError( + "character must be 'varispeed' or 'sliding-head'") + self._character = character + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + #: The whole-frame Time last handed to the node, and in ms. + self._frames = 0 + self._node_ms = 0.0 + #: The Time the loss corner is read at: the clamped Time before it + #: is landed on a frame. + self._time_played = 0.0 + #: The longest delay, in whole frames, the read head may still sit + #: at. A walk starts from wherever the head is and the class cannot + #: see how far it has got, so after a falling move this keeps the old + #: Time until a jump or a reset lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True while several macros are applied at once (the constructor, + #: `program_change`); the node is refreshed once, after the last. + self._deferred = False + self._slew = 0.0 + self._feedback = 0.0 + self._damping = 0.0 + self._corner = 0.0 + self._spread = 0.0 + self._wow_ms = 0.0 + #: (Wow, Flutter) the current table was written for. + self._wow_key = None + #: Two tables: the node borrows one while a move writes the other. + self._tables = (array("h", [0] * TABLE_POINTS), + array("h", [0] * TABLE_POINTS)) + self._table_index = 1 + self._table = None + #: k3 for the Spacing last asked, keyed by the Spacing in metres. + self._k3_key = None + self._k3 = 0.0 + + # A log knob cannot seed 0 or a negative; those clamp to the bottom. + time_ms = _option(time_ms, DEFAULTS[TIME_I]) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + time_ms = _between(time_ms, TIME_MIN_MS, TIME_MAX_MS) + spacing_um = _option(spacing_um, DEFAULTS[SPACING_I]) + if not spacing_um > 0.0: + spacing_um = SPACING_MIN_UM + #: A constructor Glide stays on the audio path until macro 3 moves: + #: the span starts at 1 200 ms, so a faster Glide (down to the 0.99 + #: pin) or an exact 0 has no knob position. A slower one is clamped + #: to the span's top, 12 s. 0, a negative and NaN are the jump. + glide_ms = float(glide_ms) + if glide_ms > GLIDE_MAX_MS: + glide_ms = GLIDE_MAX_MS + if not glide_ms > 0.0: + glide_ms = 0.0 + self._glide_exact = glide_ms + #: The constructor's Time, exactly, until macro 0 moves. Seeding a + #: log knob and reading it back is not exact: 350.0 ms comes back a + #: few ulps under, which at 22.05 kHz (7 717.5 frames) would land on + #: 7 717 instead of the 7 718 the whole-frame law gives 350.0. + self._time_exact = time_ms + self._time_seed = -1.0 + self._seeding = True + values = (time_ms, + _option(feedback, DEFAULTS[FEEDBACK_I]), + _option(mix, DEFAULTS[MIX_I]), + max(GLIDE_MIN_MS, glide_ms), + _option(wow_cents, DEFAULTS[WOW_I]), + _option(flutter_cents, DEFAULTS[FLUTTER_I]), + _option(record_level, DEFAULTS[RECORD_I]), + spacing_um, + _option(spread, DEFAULTS[SPREAD_I]), + 1.0 if sync else 0.0, + _option(division, DEFAULTS[DIVISION_I])) + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=self._clamp_ms(time_ms), + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + delay_slew=0.0) + # `clear()` empties the line and the loop filters and re-primes the + # read head, so a reset is silent and snaps onto the current Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._deferred = True + try: + self._init_macros(values) + finally: + self._deferred = False + self._seeding = False + # The knob is seeded where the constructor's Glide is, or at grid 1 + # for a faster one; `_glide_exact` carries the real value, so a + # get_macro / set_macro round trip keeps it gliding. + if self._glide_exact > 0.0 and self._macros[GLIDE_I] < GLIDE_FLOOR: + self._macros[GLIDE_I] = GLIDE_FLOOR + self._refresh() + # Read after the refresh, which re-seats a Time above `max_time_ms`. + self._time_seed = self._macros[TIME_I] + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _clamp_ms(self, time_ms): + time_ms = float(time_ms) + if time_ms > self._max_time_ms: + return self._max_time_ms + if time_ms < TIME_MIN_MS: + return TIME_MIN_MS + return time_ms + + def _time_ms(self): + """The Time the audio path plays, before it is clamped and landed.""" + if self._time_exact is not None: + return self._time_exact + return self._value(TIME_I) + + def _glide_ms(self): + if self._glide_exact is not None: + return self._glide_exact + if self._macros[GLIDE_I] <= 0.0: + return 0.0 + return self._value(GLIDE_I) + + def _walk_rate(self, from_ms, to_ms): + """The node's `delay_slew` for a Time move: the tape equation on + varispeed, the Glide law on sliding-head.""" + if self._character == VARISPEED: + return varispeed_slew(from_ms, to_ms) + return slew_of(self._glide_ms()) + + def _corner_hz(self, time_ms, spacing_um): + """The loop low-pass's -3 dB corner, before the clamp and the + pre-warp: eq. (13)'s half-power point, v k3(d) / 2 pi.""" + spacing_m = spacing_um * 1e-6 + if spacing_m != self._k3_key: + self._k3 = k3_of(spacing_m) + self._k3_key = spacing_m + return speed(self._character, time_ms) * self._k3 / (2.0 * math.pi) + + def _write_table(self, wow_cents, flutter_cents, out): + """Hook for the table law; returns the depth in ms.""" + return wow_table(wow_cents, flutter_cents, out) + + def _transport_state(self): + transport = self._transport + state = transport() if callable(transport) else transport + return transport, state + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob. `not bpm > 0` + # catches NaN, and `bpm * 0` is NaN for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if not self._seeding: + if index == GLIDE_I: + self._glide_exact = None + elif index == TIME_I and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position + # keeps the constructor's exact Time; any other drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the node once, so Time + is read against the new patch's Sync and moves once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + # The cleared node snaps onto the current Time on its next pull. + self._reach = max(1, self._frames) + self._fresh = False + + def _refresh(self): + fs = self._sample_rate + span = self._MACRO_RANGES[TIME_I] + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + # Division quantises Time into the same map, and the clamp + # shows through get_macro(0) the same way. + self._time_exact = None + self._macros[TIME_I] = _component.macro_position( + span, self._clamp_ms(synced)) + time_ms = self._time_ms() + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + if self._time_exact is not None: + self._time_exact = clamped + self._macros[TIME_I] = _component.macro_position(span, clamped) + frames = max(1, whole_frames(clamped, fs)) + node_ms = frames * 1000.0 / fs + if self._character == VARISPEED: + # The motor's law sets each move's rate from the Time it leaves; + # with no move the walk in progress keeps its rate. + if frames != self._frames and not self._fresh: + self._slew = self._walk_rate(self._node_ms, node_ms) + else: + self._slew = self._walk_rate(self._node_ms, node_ms) + if self._fresh or self._slew <= 0.0: + # A fresh node snaps onto the target, and with the slew off the + # read head jumps there on the next frame. + self._reach = frames + elif frames > self._reach: + self._reach = frames + self._frames = frames + self._node_ms = node_ms + self._time_played = clamped + + spacing = _between(self._value(SPACING_I), SPACING_MIN_UM, + SPACING_MAX_UM) + self._corner = self._corner_hz(clamped, spacing) + self._damping = nominal_damping_hz(self._hz(self._corner), fs) + feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) + # The loop low-pass is always in, and the node can hold a small value + # for ever at a Feedback a hair either side of 1 - 0.5 / k; the node + # is handed the nearer edge of that window instead. + self._feedback = clear_of_stalls( + feedback, tone_excess(self._damping, fs)[1]) + + wow = _between(self._value(WOW_I), 0.0, WOW_MAX_CENTS) + flutter = _between(self._value(FLUTTER_I), 0.0, FLUTTER_MAX_CENTS) + key = (wow, flutter) + if key != self._wow_key: + # Write the table the node is not reading, then hand it over. + spare = 1 - self._table_index + depth = self._write_table(wow, flutter, self._tables[spare]) + if depth > 0.0: + self._table_index = spare + self._table = self._tables[spare] + else: + self._table = None + self._wow_ms = depth + self._wow_key = key + + # At one channel the node's cross-feed sends the repeat nowhere. + if self._channel_count == 1: + self._spread = 0.0 + else: + self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) + self._delay.set( + delay_slew=self._slew, + delay_ms=node_ms, + feedback=self._feedback, + mix=_between(self._value(MIX_I), 0.0, 2.0), + loop_drive=_between(self._value(RECORD_I), 0.0, 1.0), + damping_hz=self._damping, + cut_hz=0.0, + cross_feed=self._spread, + wow_hz=WOW_HZ, + wow_depth_ms=self._wow_ms, + wow_shape=self._table) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound: `laps_to_zero(f, excess)` laps of the longest delay + the read head may be at, plus the wow table's peak excursion in + frames rounded up, plus one frame for the interpolated read, plus + the loop low-pass's memory. Finite at every setting the class + reaches.""" + 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`.""" + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + if laps is None: # pragma: no cover - stepped clear + return None + wow = int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) + return int(laps * (self._reach + wow + 1 + memory)) diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py new file mode 100644 index 0000000..8c81050 --- /dev/null +++ b/tests/test_cpython_effects_tapedelay.py @@ -0,0 +1,1436 @@ +"""`TapeDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/TapeDelay.md`, +frozen at anchor commit fd711caf7cb421dff9c0f4d24c717f7d00548c4b; Tier 2 +rows are T1a, T1b, T2, T3, T4 (demonstrated) and T5 (disconfirmed by +design). Each demonstrated row here is the measurement at a few of the +cells its *Quantified over* column names, the same measurement red on a +planted fault of the same kind, the fault shown unreachable from every macro +position and shipped patch on both characters, and the measurement red on +the class built as a wire. The exhaustive grids (every Glide grid position, +every Spacing and Time position, three rates for every cell) live in the +evidence pack, not in this file. +""" + +import math +import os +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.rebuilt import tapedelay as tape # noqa: E402 + +VENDOR = "PyDevices" + +TapeDelay = tape.TapeDelay + +RATE = 48000 +BLOCK = 256 +TONE = 997.0 +(TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, WOW_I, FLUTTER_I, RECORD_I, SPACING_I, + SPREAD_I, SYNC_I, DIVISION_I) = range(11) + +#: Every Tier 2 row's Held fixed: wet only, no feedback, no wobble, no +#: squash, no spread (the rows name these; Time and Spacing vary per cell). +HELD = dict(mix=2.0, feedback=0.0, wow_cents=0.0, flutter_cents=0.0, + record_level=0.0, spread=0.0) + + +# -- planted faults ------------------------------------------------------- + +class DoubleWalkTape(TapeDelay): + """T1a: the varispeed walk at twice the tape equation's rate. The Glide + law pins at 0.99 and the doubled rates here are 1.40-1.98, and no + character hands varispeed anything but the tape equation.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_double_walk = True + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.VARISPEED: + return 2.0 * rate + return rate + + +class GlideScaledVarispeed(TapeDelay): + """Section 8.9: the varispeed walk multiplied by 6 000 / Glide, which + would make Glide live on the character that must ignore it.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_glide_scaled = True + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.VARISPEED: + glide = self._glide_ms() + if glide > 0.0: + return rate * 6000.0 / glide + return rate + + +class WalkAtZeroTape(TapeDelay): + """T1b's step: Glide 0 handed slew 0.98 instead of the jump (grid 1 is + 0.966, grid 0 the jump).""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_walk_at_zero = True + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.SLIDING_HEAD and rate <= 0.0: + return 0.98 + return rate + + +class LoopShiftTape(TapeDelay): + """T1b's gesture, varispeed: a loop pitch shift of 0.12 semitone, which + the class never sets, so every pass round the loop is transposed.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_loop_shift = True + self._delay.set(loop_semitones=0.12) + + +class DoubleCornerTape(TapeDelay): + """T2: the loss corner at twice eq. (13)'s -3 dB point.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_double_corner = True + + def _corner_hz(self, time_ms, spacing_um): + return 2.0 * TapeDelay._corner_hz(self, time_ms, spacing_um) + + +class PostLossTape(TapeDelay): + """T2's n-pass clause: the loss taken out of the loop and put once on + the wet output, through a second node one frame long, so every repeat + carries one pass of loss instead of n.""" + + NAME = 'TapeDelay' + + def _build(self, *arguments, **keywords): + self._post = None + TapeDelay._build(self, *arguments, **keywords) + import audioecho + self._post = self._own(audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, max_delay_ms=1.0, + delay_ms=1000.0 / self._sample_rate, feedback=0.0, mix=2.0, + damping_hz=self._damping)) + self._post.play(self._delay) + self._output = self._post + self._refresh() + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_post_loss = True + self._delay.set(damping_hz=0.0) + if getattr(self, "_post", None) is not None: + self._post.set(damping_hz=self._damping) + + +class SquareLawTape(TapeDelay): + """T3, varispeed: the corner following the speed squared.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_square_law = True + + def _corner_hz(self, time_ms, spacing_um): + corner = TapeDelay._corner_hz(self, time_ms, spacing_um) + if self._character == tape.VARISPEED: + corner *= (tape.speed(tape.VARISPEED, time_ms) + / tape.speed(tape.VARISPEED, 350.0)) + return corner + + +class HalfFollowTape(TapeDelay): + """T3, sliding-head: the corner following the square root of the + varispeed speed, so the fixed transport's loss moves with Time.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_half_follow = True + + def _corner_hz(self, time_ms, spacing_um): + corner = TapeDelay._corner_hz(self, time_ms, spacing_um) + if self._character == tape.SLIDING_HEAD: + corner *= math.sqrt(tape.speed(tape.VARISPEED, time_ms) + / tape.speed(tape.VARISPEED, 350.0)) + return corner + + +class NoFlutterLineTape(TapeDelay): + """T4's two-line clause: the flutter line deleted from the table while + Flutter is up.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_no_flutter = True + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, 0.0, out) + + +class Harmonic504Tape(TapeDelay): + """T4's ratio clause: the flutter line at harmonic 504, exactly 7 x 72.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_harmonic_504 = True + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, + flutter_harmonic=504) + + +class NoDriftTape(TapeDelay): + """T4's slow-band clause: the table with the slow component zeroed, + which the detector must read red-free.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._plant_no_drift = True + + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, drift=False) + + +FAULTS = ( + (DoubleWalkTape, "_plant_double_walk"), + (GlideScaledVarispeed, "_plant_glide_scaled"), + (WalkAtZeroTape, "_plant_walk_at_zero"), + (LoopShiftTape, "_plant_loop_shift"), + (DoubleCornerTape, "_plant_double_corner"), + (PostLossTape, "_plant_post_loss"), + (SquareLawTape, "_plant_square_law"), + (HalfFollowTape, "_plant_half_follow"), + (NoFlutterLineTape, "_plant_no_flutter"), + (Harmonic504Tape, "_plant_harmonic_504"), + (NoDriftTape, "_plant_no_drift"), +) + + +# -- sources and renders -------------------------------------------------- + +def src_of(x, channels=2, rate=RATE): + """A float signal (frames,) as an int16 ArraySource, the same on every + channel.""" + x = np.clip(np.round(np.asarray(x, dtype=float)), -32768, 32767) + x = np.repeat(x.astype(np.int16)[:, None], channels, axis=1) + return probes.ArraySource(array("h", x.reshape(-1).tobytes()), + rate=rate, channels=channels, block=BLOCK) + + +def sine(hz, amp, frames, rate=RATE): + return amp * np.sin(2.0 * math.pi * hz * np.arange(frames) / rate) + + +def render(effect, frames, events=None): + """Pull `frames` frames from the effect; `events` maps a frame to a + callable applied to the effect before the block that starts there + (the render pulls whole blocks). Returns (frames, channels) float.""" + channels = effect.channel_count + events = sorted((events or {}).items()) + out = [] + done = 0 + while done < frames: + while events and events[0][0] <= done: + events.pop(0)[1](effect) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + break + block = np.frombuffer(data, dtype=np.int16).reshape(-1, channels) + out.append(block) + done += block.shape[0] + y = np.concatenate(out)[:frames].astype(float) + if y.shape[0] < frames: + y = np.vstack([y, np.zeros((frames - y.shape[0], channels))]) + return y + + +def time_midi(ms): + """Time as a float MIDI value, so `set_macro` lands on `ms`.""" + return _component.macro_position(TapeDelay._MACRO_RANGES[TIME_I], + ms) * 127.0 + + +def set_time(ms): + return lambda effect: effect.set_macro(TIME_I, time_midi(ms)) + + +def frames_of(ms, rate=RATE): + return int(math.floor(ms * rate / 1000.0 + 0.5)) + + +def cents(ratio): + return 1200.0 * math.log2(ratio) + + +# -- estimators ----------------------------------------------------------- + +def _fit(seg, rate, hz): + n = np.arange(len(seg)) + a = np.stack([np.cos(2 * np.pi * hz * n / rate), + np.sin(2 * np.pi * hz * n / rate), np.ones(len(seg))], 1) + coef, *_ = np.linalg.lstsq(a, seg, rcond=None) + r = seg - a @ coef + return float(r @ r), coef + + +def peak_hz(x, rate, pad=16): + x = np.asarray(x, dtype=float) + x = (x - x.mean()) * np.hanning(len(x)) + n = len(x) * pad + s = np.abs(np.fft.rfft(x, n)) + i = int(np.argmax(s[1:])) + 1 + a, b, c = (math.log(s[i - 1] + 1e-30), math.log(s[i] + 1e-30), + math.log(s[i + 1] + 1e-30)) + denom = a - 2 * b + c + p = 0.5 * (a - c) / denom if denom != 0.0 else 0.0 + return (i + p) * rate / n + + +def lsq_hz(seg, rate): + """Least-squares sine with a DC term: the search starts at the + segment's own spectral peak and takes no law (the Station A critique's + estimator).""" + seg = np.asarray(seg, dtype=float) + if len(seg) < 16 or not np.any(seg): + return float("nan") + f0 = peak_hz(seg, rate) + span = 1.5 * rate / len(seg) + a, b = max(1.0, f0 - span), f0 + span + g = (math.sqrt(5) - 1) / 2 + x1, x2 = b - g * (b - a), a + g * (b - a) + e1, e2 = _fit(seg, rate, x1)[0], _fit(seg, rate, x2)[0] + for _ in range(50): + if e1 < e2: + b, x2, e2 = x2, x1, e1 + x1 = b - g * (b - a) + e1 = _fit(seg, rate, x1)[0] + else: + a, x1, e1 = x1, x2, e2 + x2 = a + g * (b - a) + e2 = _fit(seg, rate, x2)[0] + return 0.5 * (a + b) + + +def analytic(x): + x = np.asarray(x, dtype=float) + n = len(x) + spec = np.fft.fft(x) + h = np.zeros(n) + h[0] = 1.0 + if n % 2 == 0: + h[n // 2] = 1.0 + h[1:n // 2] = 2.0 + else: + h[1:(n + 1) // 2] = 2.0 + return np.fft.ifft(spec * h) + + +def ifreq(x, rate): + return np.diff(np.unwrap(np.angle(analytic(x)))) * rate / (2 * math.pi) + + +# -- T1: one Time move ---------------------------------------------------- + +def start_of(from_ms, rate): + return max(20480, (frames_of(from_ms, rate) + 9600) // BLOCK * BLOCK) + + +def steady_slope(hz, time_ms, character, rate=RATE, amp=12000.0, + spacing_um=5.0): + """The largest first difference a steady tone at `hz` makes through the + class at `time_ms`, unramped: a tone's own slope through the corner in + force.""" + frames = frames_of(time_ms, rate) + int(0.3 * rate) + effect = TapeDelay(src_of(sine(hz, amp, frames, rate), 1, rate), + time_ms=time_ms, character=character, + spacing_um=spacing_um, **HELD) + y = render(effect, frames)[:, 0] + return float(np.max(np.abs(np.diff(y[frames_of(time_ms, rate) + 2000:])))) + + +def move(cls, a, b, rate=RATE, character=tape.VARISPEED, amp=12000.0, + channels=1, after_ms=400.0, **options): + """A 997 Hz tone through one Time move a -> b issued on a block + boundary once the wet tone is established. Returns the left channel, + the move's frame and the effect.""" + opts = dict(HELD) + opts.update(options) + start = start_of(a, rate) + frames = start + int(abs(frames_of(b, rate) - frames_of(a, rate)) * 3 + + rate * after_ms / 1000.0) + 2 * rate + effect = cls(src_of(sine(TONE, amp, frames, rate), channels, rate), + time_ms=a, character=character, **opts) + y = render(effect, frames, {start: set_time(b)})[:, 0] + return y, start, effect + + +def t1a_cell(cls, a, b, rate=RATE, amp=12000.0): + """T1a's clauses on one move: the pitch over the walk against + 1200 log2(T_old / T_new), the hold against T_new, the residual + 50-250 ms after, and the walk's first differences against the shifted + tone's own slope.""" + y, start, effect = move(cls, a, b, rate, amp=amp) + ta, tb = frames_of(a, rate), frames_of(b, rate) + law_c = cents(ta / float(tb)) + law_hz = TONE * ta / float(tb) + walk = tb # the tape equation: the move lasts T_new + trim = min(400, walk // 10) + got = lsq_hz(y[start + trim:start + walk - trim], rate) + err = cents(got / law_hz) if got == got else float("inf") + # The hold: from where the pitch passes half way (in cents) to the law + # on the way in to where it passes back on the way out. The smoothing + # and the low-pass's settle spread both edges alike, so they cancel. + fi = ifreq(y[start - 2000:start + 3 * walk], rate) + k = max(1, int(0.001 * rate)) + fi = np.convolve(fi, np.ones(k) / k, mode="same")[k:-k] + shift = 1200.0 * np.log2(np.maximum(fi, 1e-9) / TONE) + past = np.nonzero(shift * math.copysign(1.0, law_c) > abs(law_c) / 2)[0] + hold = int(past[-1] - past[0] + 1) if len(past) else 0 + after = y[start + walk + int(0.05 * rate):start + walk + int(0.25 * rate)] + res = lsq_hz(after, rate) + residual = cents(res / TONE) if res == res else float("inf") + tau = rate / (2 * math.pi * effect._damping) + settle = int(math.ceil(5 * tau)) + body = y[start + settle:start + walk - 64] + bar = 1.05 * steady_slope(law_hz, b, tape.VARISPEED, rate, amp) + step = float(np.max(np.abs(np.diff(body)))) / bar if len(body) > 2 \ + else 0.0 + red = (abs(err) > 10.0 or abs(hold - walk) > 0.05 * walk + or abs(residual) > 1.0 or step > 1.0) + return dict(passed=not red, err=err, law=law_c, hold=hold, walk=walk, + residual=residual, step=step) + + +class TheSurface(unittest.TestCase): + def test_macros_characters_tier_latency(self): + self.assertEqual(TapeDelay.MACRO_LABELS, ( + "Time", "Feedback", "Mix", "Glide", "Wow", "Flutter", + "Record Level", "Spacing", "Spread", "Sync", "Division")) + self.assertEqual(TapeDelay.MACRO_MODES[SYNC_I], "TOGGLE") + self.assertEqual(len(TapeDelay.PATCHES), 8) + self.assertEqual(TapeDelay.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(TapeDelay.LATENCY_SAMPLES, 0) + self.assertEqual(TapeDelay.TIER, _component.AUDIODSP) + self.assertEqual(TapeDelay.REQUIRES, ("audioecho",)) + self.assertEqual(tape.CHARACTERS, ("varispeed", "sliding-head")) + for character in tape.CHARACTERS: + effect = TapeDelay(src_of(np.zeros(512)), character=character) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.patch_index, 0) + with self.assertRaises(ValueError): + TapeDelay(src_of(np.zeros(512)), character="reel") + + def test_parked_not_served(self): + self.assertIs(rebuilt.module_class("TapeDelay"), TapeDelay) + self.assertNotIn("TapeDelay", rebuilt.ADOPTED) + self.assertIn("TapeDelay", rebuilt.parked()) + self.assertIsNone(rebuilt.load("TapeDelay")) + + def test_patches_are_the_dossier_settings_on_the_grid(self): + # Section 6's table: Time, Fdbk, Mix, Glide, Wow, Flutter, Rec, + # Spacing, Spread, Sync, Div (as an index). + settings = ( + (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 0.0, 6.0), + (800.0, 0.55, 0.35, 12000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 0.0, 6.0), + (90.0, 0.15, 0.50, 6000.0, 1.0, 0.5, 0.2, 5.0, 0.0, 0.0, 6.0), + (450.0, 0.55, 0.35, 6000.0, 6.0, 2.0, 0.2, 15.0, 0.0, 0.0, 6.0), + (400.0, 0.90, 0.40, 6000.0, 2.0, 1.0, 0.6, 5.0, 0.0, 0.0, 6.0), + (350.0, 0.45, 0.35, 6000.0, 4.0, 3.0, 0.5, 20.0, 0.0, 0.0, 6.0), + (350.0, 0.45, 0.35, 6000.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 6.0), + (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 1.0, 8.0), + ) + for index, values in enumerate(settings): + want = tuple( + _component.macro_of(span, value, TapeDelay.MACRO_MODES[i]) + for i, (span, value) in enumerate( + zip(TapeDelay._MACRO_RANGES, values))) + self.assertEqual(TapeDelay.PATCHES[index][1], want, index) + + def test_patch_0_is_the_constructor_grid(self): + effect = TapeDelay(src_of(np.zeros(512))) + for index, expected in enumerate(TapeDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_time_lands_on_a_whole_frame(self): + for rate, frames in ((48000, 16800), (44100, 15435), (22050, 7718)): + effect = TapeDelay(src_of(np.zeros(512), rate=rate)) + self.assertEqual(effect._frames, frames) + self.assertEqual(effect._node_ms, frames * 1000.0 / rate) + # A host echoing Time back keeps the constructor's exact Time. + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(effect._frames, frames) + + def test_the_loss_corner(self): + # Eq. (13)'s half-power wavelength: 393.5 um at 5, 336.7 at 2 and + # 671.4 at 20 (dossier section 6). + for spacing, wavelength in ((5.0, 393.5), (2.0, 336.7), + (20.0, 671.4)): + k3 = tape.k3_of(spacing * 1e-6) + self.assertAlmostEqual(2 * math.pi / k3 * 1e6, wavelength, + delta=0.05) + # 22.05 kHz: 521.85 and 515.49 Hz (the dossier rounds the first to + # 521.9). + for rate, varispeed, sliding in ((48000, 522.6, 516.2), + (22050, 521.85, 515.49)): + got = [TapeDelay(src_of(np.zeros(512), rate=rate), + character=c)._damping + for c in tape.CHARACTERS] + self.assertAlmostEqual(got[0], varispeed, delta=0.05) + self.assertAlmostEqual(got[1], sliding, delta=0.05) + # The speed law: 40 cm/s to 180 ms, 12 cm/s from 600 ms. + self.assertEqual(tape.speed(tape.VARISPEED, 60.0), 0.40) + self.assertEqual(tape.speed(tape.VARISPEED, 1200.0), 0.12) + self.assertAlmostEqual(tape.speed(tape.VARISPEED, 350.0), 0.20571, + places=5) + self.assertEqual(tape.speed(tape.SLIDING_HEAD, 60.0), 0.2032) + + def test_the_wow_table(self): + effect = TapeDelay(src_of(np.zeros(512))) + self.assertAlmostEqual(effect._wow_ms, 0.758, delta=0.001) + table = effect._table + self.assertEqual(len(table), 4096) + self.assertEqual(max(abs(v) for v in table), 32767) + first = table + effect.set_macro(WOW_I, 127) + # A move writes the table the node is not reading. + self.assertIsNot(effect._table, first) + effect.set_macro(FLUTTER_I, 127) + self.assertIs(effect._table, first) + # The stops: 1.024 + 0.072 + 2.000 ms of components, 3.034 ms peak. + self.assertAlmostEqual(effect._wow_ms, 3.034, delta=0.001) + self.assertAlmostEqual(tape.cents_to_depth_ms(8.0, 0.72), 1.024, + delta=0.0005) + self.assertAlmostEqual(tape.cents_to_depth_ms(4.0, 5.12), 0.072, + delta=0.0005) + effect.set_macro(WOW_I, 0) + effect.set_macro(FLUTTER_I, 0) + self.assertIsNone(effect._table) + self.assertEqual(effect._wow_ms, 0.0) + + def test_the_table_holds_its_three_components(self): + out = array("h", [0] * 4096) + depth = tape.wow_table(8.0, 4.0, out) + spec = np.abs(np.fft.rfft(np.array(out, dtype=float))) \ + * depth / 32767.0 * 2.0 / 4096.0 + self.assertAlmostEqual(spec[72], 1.024, delta=0.002) + self.assertAlmostEqual(spec[512], 0.072, delta=0.002) + drift = spec[1:10] * np.arange(1, 10) + self.assertLess(float(np.ptp(drift)), 0.002) + self.assertGreater(float(np.sum(spec[1:10])), 0.5) + others = np.delete(spec[1:], [k - 1 for k in + list(range(1, 10)) + [72, 512]]) + self.assertLess(float(np.max(others)), 0.0005) + + def test_tail_samples_at_each_patch_played_from_rest(self): + # Dossier Tier 3 and App. F7', varispeed, 48 kHz. Patch 7 at the + # static transport keeps its knob Time (patch 0's). At a 120 bpm host + # it plays 1/8. = 0.75 beat = 375 ms: App. F7' sized it at 187.5 ms, + # which is 1/16. at 120 bpm, so its 128 982 frames is not the bound + # this patch needs; the class's is 257 418. + want = (241990, 692550, 27336, 407322, 1671015, 245728, 240800, + 241990) + for index, frames in enumerate(want): + effect = TapeDelay(src_of(np.zeros(512)), patch=index) + self.assertEqual(effect.tail_samples, frames, index) + effect = TapeDelay.create(src_of(np.zeros(512)), RATE, + transport=lambda: (True, 0.0, 120.0, 4, 4), + patch=7) + self.assertAlmostEqual(effect._time_played, 375.0, places=6) + memory, excess = tape.tone_excess(effect._damping, RATE) + laps = tape.laps_to_zero(effect._feedback, excess) + self.assertEqual(effect.tail_samples, + laps * (18000 + 37 + 1 + memory)) + self.assertEqual(effect.tail_samples, 257418) + effect = TapeDelay(src_of(np.zeros(512))) + self.assertEqual(effect.tail_samples, 240282) + + def test_the_glide_law_and_its_floor(self): + effect = TapeDelay(src_of(np.zeros(512)), character="sliding-head") + self.assertAlmostEqual(effect._slew, 1180.0 / 6000.0, places=12) + effect.set_macro(GLIDE_I, 1) + self.assertAlmostEqual(effect._slew, 0.965666, places=5) + effect.set_macro(GLIDE_I, 127) + self.assertAlmostEqual(effect._slew, 1180.0 / 12000.0, places=12) + effect.set_macro(GLIDE_I, 0) + self.assertEqual(effect._slew, 0.0) + # A constructor Glide faster than grid 1 keeps its walk (the 0.99 + # pin) and seeds the knob at grid 1, never grid 0, the jump. + fast = TapeDelay(src_of(np.zeros(512)), character="sliding-head", + glide_ms=1000.0) + self.assertEqual(fast._slew, 0.99) + self.assertAlmostEqual(fast.get_macro(GLIDE_I), 1.0, places=9) + fast.set_macro(GLIDE_I, round(fast.get_macro(GLIDE_I))) + self.assertGreater(fast._slew, 0.0) + for jump in (0.0, -5.0, float("nan")): + self.assertEqual(TapeDelay(src_of(np.zeros(512)), + character="sliding-head", + glide_ms=jump)._slew, 0.0) + + def test_constructor_clamps_and_nan(self): + effect = TapeDelay(src_of(np.zeros(512)), time_ms=0.0, spacing_um=0.0, + max_time_ms=float("nan"), feedback=float("nan")) + self.assertEqual(effect._frames, frames_of(20.0)) + self.assertAlmostEqual(effect.macro(SPACING_I), 2.0, places=9) + self.assertEqual(effect._max_time_ms, 1200.0) + self.assertAlmostEqual(effect.macro(FEEDBACK_I), 0.45, places=9) + low = TapeDelay(src_of(np.zeros(512)), time_ms=900.0, + max_time_ms=300.0) + self.assertEqual(low._frames, frames_of(300.0)) + self.assertAlmostEqual(low.macro(TIME_I), 300.0, places=6) + low.set_macro(TIME_I, 127) + self.assertAlmostEqual(low.macro(TIME_I), 300.0, places=6) + + def test_spread_is_held_at_zero_in_mono(self): + mono = TapeDelay(src_of(np.zeros(512), channels=1), spread=1.0) + self.assertEqual(mono._spread, 0.0) + stereo = TapeDelay(src_of(np.zeros(512)), spread=1.0) + self.assertEqual(stereo._spread, 1.0) + + +class T1aVarispeed(unittest.TestCase): + def test_the_named_moves(self): + for rate, a, b in ((48000, 200.0, 100.4), (48000, 180.0, 600.0), + (48000, 40.0, 20.0), (22050, 200.0, 100.4), + (44100, 600.0, 180.0)): + got = t1a_cell(TapeDelay, a, b, rate) + self.assertTrue(got["passed"], (rate, a, b, got)) + got = t1a_cell(TapeDelay, 200.0, 100.4) + self.assertAlmostEqual(got["law"], 1193.2, delta=0.05) + self.assertLess(abs(got["err"]), 1.0) + + def test_the_level_span(self): + for amp in (380.0, 32000.0): + got = t1a_cell(TapeDelay, 200.0, 100.4, 48000, amp) + self.assertTrue(got["passed"], (amp, got)) + + def test_a_doubled_walk_is_red(self): + for rate, a, b in ((48000, 200.0, 100.4), (22050, 180.0, 600.0)): + got = t1a_cell(DoubleWalkTape, a, b, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(abs(got["err"]), 400.0) + + +class GlideIsInertOnVarispeed(unittest.TestCase): + """Section 8.9: one varispeed Time move at Glide grid 1, grid 127 and + the constructor's 6 000 ms renders byte-identical, and the fault that + scales the walk by 6 000 / Glide does not.""" + + def _renders(self, cls): + out = [] + for glide in (None, 1, 127): + opts = dict(HELD) + frames = 20480 + 3 * RATE // 4 + effect = cls(src_of(sine(TONE, 12000.0, frames), 2), + time_ms=200.0, **opts) + if glide is not None: + effect.set_macro(GLIDE_I, glide) + out.append(render(effect, frames, + {20480: set_time(100.4)}).tobytes()) + return out + + def test_three_glides_one_render(self): + a, b, c = self._renders(TapeDelay) + self.assertEqual(a, b) + self.assertEqual(a, c) + + def test_a_glide_scaled_walk_is_red(self): + a, b, c = self._renders(GlideScaledVarispeed) + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + + +def ramp_cell(cls, rate=RATE, amp=12000.0): + """T1b's ramp: sliding-head, Glide 2 950 ms (slew 0.4), 200 -> 400 ms. + The walk is 24 000 frames; the pitch -884.4 cents while it walks.""" + y, start, effect = move(cls, 200.0, 400.0, rate, tape.SLIDING_HEAD, amp, + glide_ms=2950.0) + walk = int(round((frames_of(400.0, rate) - frames_of(200.0, rate)) + / effect._slew)) + trim = min(400, walk // 10) + law = TONE * (1.0 - 1180.0 / 2950.0) + got = lsq_hz(y[start + trim:start + walk - trim], rate) + err = cents(got / law) if got == got else float("inf") + after = lsq_hz(y[start + walk + int(0.05 * rate): + start + walk + int(0.25 * rate)], rate) + residual = cents(after / TONE) if after == after else float("inf") + return dict(passed=abs(err) <= 10.0 and abs(residual) <= 1.0, err=err, + residual=residual, law=cents(law / TONE)) + + +def step_cell(cls, rate=RATE, amp=12000.0): + """T1b's step: sliding-head at Glide 0, 200 -> 100.4 ms. The jump read + on the read recovered through the inverted loss low-pass against the + unfiltered tone's own slope, and the pitch 2-50 ms after.""" + y, start, effect = move(cls, 200.0, 100.4, rate, tape.SLIDING_HEAD, amp, + glide_ms=0.0) + coef = 1.0 - math.exp(-2.0 * math.pi * effect._damping / rate) + x = y[:-1] + np.diff(y) / coef + jump = float(np.max(np.abs(np.diff(x))[start - 3:start + 3])) + raw = 2.0 * math.pi * TONE / rate * amp + got = lsq_hz(y[start + int(0.002 * rate):start + int(0.05 * rate)], rate) + offset = cents(got / TONE) if got == got else float("inf") + return dict(passed=jump >= 5.0 * raw and abs(offset) <= 10.0, + ratio=jump / raw, offset=offset) + + +G_START = 9472 +T0, T1 = 200.0, 300.0 + + +def burst(frames, peak, rate=RATE, at=0.100): + x = np.zeros(frames) + n = int(0.040 * rate) + t = np.arange(n) + s0 = int(at * rate) + x[s0:s0 + n] = peak * np.sin(np.pi * t / n) ** 2 * np.sin( + 2 * np.pi * 1000.0 * t / rate) + return x + + +def find_repeats(y, count=4, rate=RATE): + env = np.abs(analytic(y)) + k = int(0.005 * rate) + env = np.convolve(env, np.ones(k) / k, mode="same") + i = int(0.19 * rate) + thr = 0.01 * env[i:].max() + peaks = [] + while i < len(env) and len(peaks) < count: + if env[i] > thr: + m = i + int(np.argmax(env[i:i + int(0.03 * rate)])) + lo = m + while lo > 0 and env[lo] > env[m] * 0.5: + lo -= 1 + hi = m + while hi < len(env) - 1 and env[hi] > env[m] * 0.5: + hi += 1 + peaks.append(((lo + hi) // 2) - int(0.010 * rate)) + i = hi + int(0.1 * rate) + else: + i += 1 + return peaks + + +def gesture(cls, character, peak=12000.0, moves=True): + """The 200 -> 300 -> 200 ms gesture at Feedback 0.7 with a 40 ms burst + at 100-140 ms, stereo, 48 kHz. Varispeed: two moves from rest, the + return 400 ms after the first. Sliding-head: two walks at slew 0.5 + (Glide 2 360 ms), 200 ms apart. Returns each repeat's pitch in cents + against 1 kHz.""" + frames = int(2.2 * RATE) + opts = dict(HELD) + opts.update(feedback=0.7) + effect = cls(src_of(burst(frames, peak), 2), time_ms=T0, + character=character, glide_ms=2360.0, **opts) + events = {} + if moves: + if character == tape.VARISPEED: + second = (G_START + int(0.4 * RATE)) // BLOCK * BLOCK + else: + second = G_START + int(0.2 * RATE) // BLOCK * BLOCK + events = {G_START: set_time(T1), second: set_time(T0)} + y = render(effect, frames, events)[:, 0] + out = [] + for a in find_repeats(y): + seg = y[a:a + int(0.02 * RATE)] + out.append(cents(peak_hz(seg, RATE, pad=8) / 1000.0)) + return out + + +def gesture_verdict(cls): + """T1b's feedback clauses: every sliding-head repeat 1-4 more than 100 + cents off the source; every varispeed repeat 2-4 within 5 cents of the + same repeat with no gesture; and the two characters not the same.""" + control = gesture(TapeDelay, tape.VARISPEED, moves=False) + vari = gesture(cls, tape.VARISPEED) + slide = gesture(cls, tape.SLIDING_HEAD) + ok_slide = len(slide) == 4 and all(abs(c) > 100.0 for c in slide) + ok_vari = (len(vari) == 4 and len(control) == 4 + and all(abs(v - c) <= 5.0 + for v, c in zip(vari[1:], control[1:]))) + return dict(passed=ok_slide and ok_vari, vari=vari, slide=slide, + control=control) + + +class T1bSlidingHead(unittest.TestCase): + def test_the_ramp(self): + got = ramp_cell(TapeDelay) + self.assertTrue(got["passed"], got) + self.assertAlmostEqual(got["law"], -884.4, delta=0.05) + + def test_the_ramp_at_other_levels_and_rates(self): + for rate, amp in ((48000, 120.0), (48000, 32000.0), (22050, 12000.0)): + got = ramp_cell(TapeDelay, rate, amp) + self.assertTrue(got["passed"], (rate, amp, got)) + + def test_glide_grid_cells_both_ways(self): + for grid in (4, 89, 127): + law = 1180.0 / _component.macro_value( + TapeDelay._MACRO_RANGES[GLIDE_I], grid / 127.0) + for a, b in ((200.0, 150.0), (150.0, 200.0)): + opts = dict(HELD) + start = start_of(a, RATE) + frames = start + 3 * RATE + effect = TapeDelay(src_of(sine(TONE, 12000.0, frames), 1), + time_ms=a, character=tape.SLIDING_HEAD, + **opts) + effect.set_macro(GLIDE_I, grid) + y = render(effect, frames, {start: set_time(b)})[:, 0] + walk = int(abs(frames_of(b) - frames_of(a)) / law) + want = TONE * (1.0 + law if b < a else 1.0 - law) + # Read each side of the power of two the walk crosses + # (8 192 frames), as the row's estimator does. + cross = start + int(abs(8192 - frames_of(a)) / law) + for lo, hi in ((start, cross), (cross, start + walk)): + trim = min(400, (hi - lo) // 10) + got = lsq_hz(y[lo + trim:hi - trim], RATE) + self.assertLess(abs(cents(got / want)), 10.0, + (grid, a, b, lo - start)) + after = lsq_hz(y[start + walk + int(0.05 * RATE): + start + walk + int(0.25 * RATE)], RATE) + self.assertLess(abs(cents(after / TONE)), 1.0, (grid, a, b)) + + def test_the_step_at_glide_0(self): + got = step_cell(TapeDelay) + self.assertTrue(got["passed"], got) + self.assertGreater(got["ratio"], 8.0) + + def test_a_walk_at_glide_0_is_red(self): + got = step_cell(WalkAtZeroTape) + self.assertFalse(got["passed"], got) + + def test_the_gesture(self): + got = gesture_verdict(TapeDelay) + self.assertTrue(got["passed"], got) + self.assertLess(abs(got["vari"][0] + 701.4), 2.0, got) + + def test_a_loop_shift_is_red_on_the_gesture(self): + got = gesture_verdict(LoopShiftTape) + self.assertFalse(got["passed"], got) + + +# -- T2, T3: the loss law ------------------------------------------------- + +def eq13_db(f, v, spacing_m): + k = 2.0 * np.pi * np.asarray(f, dtype=float) / v + spacing = np.exp(-k * spacing_m) + thick = (1.0 - np.exp(-k * tape.THICK_M)) / (k * tape.THICK_M) + half = k * tape.GAP_M / 2.0 + gap = np.abs(np.sin(half) / half) + return 20.0 * np.log10(spacing * thick * gap) + + +def lambda_top_um(spacing_um, tol=1.75): + """The shortest wavelength down to which a one-pole with eq. (13)'s + -3 dB point stays within `tol` dB of eq. (13) (dossier App. F3).""" + d = spacing_um * 1e-6 + kc = tape.k3_of(d) + ks = np.geomspace(kc / 30.0, kc * 100.0, 20000) + onepole = -10.0 * np.log10(1.0 + (ks / kc) ** 2) + err = np.abs(onepole - eq13_db(ks / (2 * math.pi), 1.0, d)) + bad = np.where(err > tol)[0] + kmax = ks[bad[0] - 1] if len(bad) else ks[-1] + return 2 * math.pi / kmax * 1e6 + + +def one_pass(cls, time_ms, spacing_um, character=tape.VARISPEED, rate=RATE, + amp=32767.0): + """The one-pass response of an impulse through the class: 4 096 frames + from the whole-frame Time, scaled to the impulse.""" + t = frames_of(time_ms, rate) + frames = t + 4096 + 512 + x = np.zeros(frames) + x[0] = amp + opts = dict(HELD) + effect = cls(src_of(x, 2, rate), time_ms=time_ms, spacing_um=spacing_um, + character=character, **opts) + y = render(effect, frames)[:, 0] + return y[t:t + 4096] / amp + + +def response_db(seg, rate, freqs): + n = np.arange(len(seg)) + freqs = np.asarray(freqs, dtype=float) + basis = np.exp(-2j * np.pi * np.outer(freqs, n) / rate) + mag = np.abs(basis @ seg) + return 20.0 * np.log10(np.maximum(mag, 1e-30)) + + +def t2_one_pass(cls, time_ms, spacing_um, character, rate=RATE, + amp=32767.0): + v = tape.speed(character, time_ms) + top = min(v / (lambda_top_um(spacing_um) * 1e-6), rate / 8.0) + f = np.geomspace(100.0, top, 200) + got = response_db(one_pass(cls, time_ms, spacing_um, character, rate, + amp), rate, f) + err = got - eq13_db(f, v, spacing_um * 1e-6) + worst = float(np.max(np.abs(err))) + return dict(passed=worst <= 2.0, worst=worst, top=top) + + +def t2_npass(cls, time_ms=350.0, spacing_um=5.0, character=tape.VARISPEED, + rate=RATE): + """Bursts at the band top through the loop at Feedback 0.7: the loss + of each repeat above 4 LSB against n x the first's.""" + v = tape.speed(character, time_ms) + top = min(v / (lambda_top_um(spacing_um) * 1e-6), rate / 8.0) + t = frames_of(time_ms, rate) + n_b = int(0.060 * rate) + frames = t * 6 + int(0.2 * rate) + x = np.zeros(frames) + tt = np.arange(n_b) + x[:n_b] = 30000 * np.sin(np.pi * tt / n_b) ** 2 * np.sin( + 2 * np.pi * top * tt / rate) + opts = dict(HELD) + opts.update(feedback=0.7) + effect = cls(src_of(x, 2, rate), time_ms=time_ms, spacing_um=spacing_um, + character=character, **opts) + y = render(effect, frames)[:, 0] + basis = np.stack([np.cos(2 * np.pi * top * tt / rate), + np.sin(2 * np.pi * top * tt / rate)], 1) + + def amp_of(seg): + coef, *_ = np.linalg.lstsq(basis, seg, rcond=None) + return math.hypot(*coef) + ref = amp_of(x[:n_b]) + g = 20 * math.log10(effect._feedback) + losses = [] + for k in range(1, 5): + seg = y[k * t:k * t + n_b] + a = amp_of(seg) + if a < 4.0 or float(np.max(np.abs(seg))) < 4.0: + break + losses.append(20 * math.log10(a / ref) - (k - 1) * g) + worst = max(abs(loss - (k + 1) * losses[0]) + for k, loss in enumerate(losses)) if losses else float("inf") + return dict(passed=len(losses) >= 2 and worst <= 2.0, worst=worst, + per_pass=[loss / (k + 1) for k, loss in enumerate(losses)], + top=top) + + +class T2LossLaw(unittest.TestCase): + def test_the_band_tops(self): + self.assertAlmostEqual(lambda_top_um(5.0), 41.4, delta=0.1) + self.assertAlmostEqual(lambda_top_um(2.0), 122.2, delta=0.1) + self.assertAlmostEqual(lambda_top_um(20.0), 105.3, delta=0.1) + + def test_one_pass_at_the_named_cells(self): + for rate, character, time_ms, spacing in ( + (48000, tape.VARISPEED, 350.0, 5.0), + (48000, tape.VARISPEED, 600.0, 20.0), + (48000, tape.VARISPEED, 60.0, 2.0), + (48000, tape.SLIDING_HEAD, 350.0, 5.0), + (48000, tape.VARISPEED, 350.0, 3.772), + (44100, tape.VARISPEED, 320.0, 5.0), + (22050, tape.VARISPEED, 600.0, 5.0), + (22050, tape.SLIDING_HEAD, 350.0, 20.0)): + got = t2_one_pass(TapeDelay, time_ms, spacing, character, rate) + self.assertTrue(got["passed"], + (rate, character, time_ms, spacing, got)) + + def test_one_pass_at_minus_20_dbfs(self): + got = t2_one_pass(TapeDelay, 350.0, 5.0, tape.VARISPEED, amp=3277.0) + self.assertTrue(got["passed"], got) + + def test_a_doubled_corner_is_red(self): + for rate in (48000, 22050): + got = t2_one_pass(DoubleCornerTape, 350.0, 5.0, tape.VARISPEED, + rate) + self.assertFalse(got["passed"], (rate, got)) + self.assertGreater(got["worst"], 4.0) + + def test_n_passes(self): + for character, time_ms in ((tape.VARISPEED, 350.0), + (tape.VARISPEED, 600.0), + (tape.SLIDING_HEAD, 350.0)): + got = t2_npass(TapeDelay, time_ms, character=character) + self.assertTrue(got["passed"], (character, time_ms, got)) + + def test_the_loss_outside_the_loop_is_red(self): + got = t2_npass(PostLossTape) + self.assertFalse(got["passed"], got) + self.assertGreater(got["worst"], 10.0) + + +def corner_and_10k(seg, rate): + """The first -3.01 dB crossing of a one-pass response, interpolated in + log frequency on a 20 000-point grid, and its level at 10 kHz.""" + f = np.geomspace(20.0, min(20000.0, 0.49 * rate), 20000) + db = response_db(seg, rate, f) + below = np.nonzero(db <= -3.0103)[0] + if not len(below) or below[0] == 0: + return float("nan"), float(response_db(seg, rate, [10000.0])[0]) + i = below[0] + a, b = db[i - 1], db[i] + frac = (a + 3.0103) / (a - b) + corner = math.exp(math.log(f[i - 1]) + frac + * (math.log(f[i]) - math.log(f[i - 1]))) + return corner, float(response_db(seg, rate, [10000.0])[0]) + + +def t3_cell(cls, spacing_um=5.0, rate=RATE, amp=32767.0): + out = {} + for character in tape.CHARACTERS: + c1, l1 = corner_and_10k(one_pass(cls, 180.0, spacing_um, character, + rate, amp), rate) + c2, l2 = corner_and_10k(one_pass(cls, 600.0, spacing_um, character, + rate, amp), rate) + out[character] = (c1 / c2, l1 - l2) + ratio_v, span_v = out[tape.VARISPEED] + ratio_s, span_s = out[tape.SLIDING_HEAD] + ok_v = abs(ratio_v / (10.0 / 3.0) - 1.0) <= 0.01 and span_v >= 9.0 + ok_s = abs(ratio_s - 1.0) <= 0.01 and abs(span_s) <= 1.0 + return dict(passed=ok_v and ok_s, varispeed=out[tape.VARISPEED], + sliding=out[tape.SLIDING_HEAD]) + + +class T3LossFollowsSpeed(unittest.TestCase): + def test_the_span_at_the_named_cells(self): + for rate, midi in ((48000, 51), (48000, 0), (48000, 127), + (44100, 64), (22050, 51)): + spacing = _component.macro_value( + TapeDelay._MACRO_RANGES[SPACING_I], midi / 127.0) + got = t3_cell(TapeDelay, spacing, rate) + self.assertTrue(got["passed"], (rate, midi, got)) + + def test_at_minus_20_dbfs(self): + got = t3_cell(TapeDelay, 5.0, RATE, 3277.0) + self.assertTrue(got["passed"], got) + + def test_a_square_law_and_a_half_follow_are_red(self): + for rate in (48000, 22050): + got = t3_cell(SquareLawTape, 5.0, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(got["varispeed"][0], 10.0) + got = t3_cell(HalfFollowTape, 5.0, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(got["sliding"][0], 1.5) + + +# -- T4: the fluctuation -------------------------------------------------- + +def bh4(n): + a = (0.35875, 0.48829, 0.14128, 0.01168) + k = np.arange(n) / (n - 1) + return (a[0] - a[1] * np.cos(2 * np.pi * k) + a[2] * np.cos(4 * np.pi * k) + - a[3] * np.cos(6 * np.pi * k)) + + +def delay_trace(cls, rate=RATE, wow=2.0, flutter=1.0, amp=12000.0, + seconds=60.0, time_ms=350.0): + """The delay recovered from a steady 997 Hz tone's analytic phase over + `seconds`, averaged to 200 Hz, mean removed; one channel.""" + frames = int(seconds * rate) + int(time_ms / 1000 * rate) + rate + opts = dict(HELD) + opts.update(wow_cents=wow, flutter_cents=flutter) + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), + time_ms=time_ms, **opts) + y = render(effect, frames)[:, 0] + start = int(time_ms / 1000 * rate) + rate // 2 + y = y[start:start + int(seconds * rate)] + ph = np.unwrap(np.angle(analytic(y))) + t = (np.arange(len(y)) + start) / float(rate) + trace = t - ph / (2 * np.pi * TONE) + step = int(round(rate / 200.0)) + m = len(trace) // step + trace = trace[:m * step].reshape(m, step).mean(axis=1) * 1000.0 + return trace - trace.mean(), rate / float(step) + + +def _fit_line(trace, t, f0): + best = None + for f in np.linspace(f0 - 0.005, f0 + 0.005, 101): + a = np.stack([np.sin(2 * np.pi * f * t), np.cos(2 * np.pi * f * t)], + axis=1) + coef, *_ = np.linalg.lstsq(a, trace, rcond=None) + fit = a @ coef + err = float(np.sum((trace - fit) ** 2)) + if best is None or err < best[0]: + best = (err, f, fit) + return best[1], best[2] + + +def t4_verdict(trace, rate): + """T4's clauses: two lines in 0.2-12 Hz each >= 40 dB over the median + floor between them, their ratio not within 1 % of p/q (p, q <= 8), and + the 0.017-0.1 Hz band of the residual after both lines are fitted out + >= 40 dB over that residual's floor.""" + n = len(trace) + w = bh4(n) + f = np.fft.rfftfreq(n, 1.0 / rate) + df = f[1] + sdb = 10 * np.log10(np.abs(np.fft.rfft(trace * w)) ** 2 + 1e-300) + lo = np.searchsorted(f, 0.2) + hi = np.searchsorted(f, 12.0) + lines = [] + for i in np.argsort(sdb[lo:hi])[::-1] + lo: + if all(abs(f[i] - f[j]) > 0.3 for j in lines): + lines.append(i) + if len(lines) == 2: + break + lines.sort() + a, b = lines + between = sdb[a + 8:b - 8] + if len(between) < 8: + return dict(passed=False, why="lines adjacent") + floor = float(np.median(between)) + + def parabolic(i): + p, q, r = sdb[i - 1], sdb[i], sdb[i + 1] + return (i + 0.5 * (p - r) / (p - 2 * q + r)) * df + t = np.arange(n) / rate + f1, fit1 = _fit_line(trace, t, parabolic(a)) + f2, fit2 = _fit_line(trace - fit1, t, parabolic(b)) + r = trace - fit1 - fit2 + r = r - np.sum(r * w) / np.sum(w) + rdb = 10 * np.log10(np.abs(np.fft.rfft(r * w)) ** 2 + 1e-300) + rfloor = float(np.median(rdb[a + 8:b - 8])) + ratio = f2 / f1 + near = min(abs(ratio - p / float(q)) / (p / float(q)) + for p in range(1, 9) for q in range(1, 9)) + top = np.searchsorted(f, 0.1) + drift = float(rdb[1:top].max()) - rfloor + d1, d2 = sdb[a] - floor, sdb[b] - floor + two = d1 >= 40.0 and d2 >= 40.0 + return dict(passed=two and near > 0.01 and drift >= 40.0, + two_lines=two, ratio_ok=near > 0.01, drift_ok=drift >= 40.0, + f1=f1, f2=f2, d1=d1, d2=d2, ratio=ratio, drift=drift) + + +class T4Fluctuation(unittest.TestCase): + def test_the_default_at_48k(self): + got = t4_verdict(*delay_trace(TapeDelay)) + self.assertTrue(got["passed"], got) + self.assertAlmostEqual(got["f1"], 0.7194, delta=0.001) + self.assertAlmostEqual(got["f2"], 5.1155, delta=0.001) + + def test_the_stops_and_the_default_at_22k(self): + got = t4_verdict(*delay_trace(TapeDelay, wow=8.0, flutter=4.0, + seconds=60.0)) + self.assertTrue(got["passed"], got) + got = t4_verdict(*delay_trace(TapeDelay, rate=22050)) + self.assertTrue(got["passed"], got) + + def test_the_planted_tables_are_each_red_on_their_clause(self): + got = t4_verdict(*delay_trace(NoFlutterLineTape)) + self.assertFalse(got["passed"], got) + self.assertFalse(got.get("two_lines", False), got) + got = t4_verdict(*delay_trace(Harmonic504Tape)) + self.assertFalse(got["passed"], got) + self.assertFalse(got["ratio_ok"], got) + self.assertTrue(got["drift_ok"], got) + got = t4_verdict(*delay_trace(NoDriftTape)) + self.assertFalse(got["passed"], got) + self.assertFalse(got["drift_ok"], got) + self.assertTrue(got["two_lines"], got) + + +# -- T5: disconfirmed by design ------------------------------------------- + +def loop_area(source_of, record_level, rate=RATE): + """Output against input over one period of a 5 Hz triangle at + 29 205 LSB peak, one pass, Time 350 ms, the loss low-pass in: the + enclosed area by the shoelace formula (Saturation TP3's shape).""" + per = rate // 5 + periods = 8 + t = frames_of(350.0, rate) + frames = t + per * periods + ph = (np.arange(frames) % per) / float(per) + tri = 29205.0 * (4 * np.abs(ph - 0.5) - 1) + opts = dict(HELD) + opts.update(record_level=record_level) + effect = TapeDelay(source_of(tri), time_ms=350.0, **opts) + y = render(effect, frames)[:, 0] + a = t + per * (periods - 2) + x, z = tri[a - t:a - t + per], y[a:a + per] + return 0.5 * abs(float(np.dot(x, np.roll(z, -1)) + - np.dot(z, np.roll(x, -1)))) + + +class T5NoMemory(unittest.TestCase): + """T5 is recorded disconfirmed by design: the loop's only record + nonlinearity is a static cubic, so Record Level 1's loop is no larger + than the Record Level 0 control's. These tests pin that, and show the + measurement can pass on a build with memory.""" + + def test_record_level_draws_no_hysteresis_loop(self): + control = loop_area(lambda x: src_of(x, 1), 0.0) + half = loop_area(lambda x: src_of(x, 1), 0.5) + full = loop_area(lambda x: src_of(x, 1), 1.0) + self.assertLess(10 * math.log10(full / control), 6.0) + self.assertLess(10 * math.log10(full / control), 0.0) + self.assertLess(full, half) + + def test_the_measurement_passes_a_build_with_memory(self): + import audioshaper + xs = np.linspace(-1, 1, 1025) + curve = array("h", np.round(xs * 32767).astype(np.int16).tobytes()) + + def shaped(h): + def source_of(x): + ws = audioshaper.Waveshaper(sample_rate=RATE, channel_count=1, + curve=curve, hysteresis=h, + hysteresis_width=0.02) + ws.play(src_of(x, 1)) + return ws + return source_of + control = loop_area(shaped(0.0), 0.0) + memory = loop_area(shaped(1.0), 0.0) + self.assertGreater(10 * math.log10(memory / control), 6.0) + + +# -- Tier 1 --------------------------------------------------------------- + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for channels in (2, 1): + data = probes.ramp_fs(frames=48000, channels=channels) + src = probes.ArraySource(data, rate=RATE, channels=channels) + effect = TapeDelay(src, mix=0.0, time_ms=20.0) + out = render(effect, 48000) + want = np.frombuffer(data.tobytes(), dtype=np.int16).reshape( + -1, channels) + self.assertEqual(int(np.sum(out != want)), 0, channels) + + def test_the_dry_is_unity_until_the_repeat(self): + x = sine(440.0, 12000.0, 4096) + effect = TapeDelay(src_of(x), mix=1.0) + out = render(effect, 4096)[:, 0] + self.assertTrue(np.array_equal(out, np.round(x))) + + def test_silence_stays_silence(self): + for character in tape.CHARACTERS: + for patch in range(8): + effect = TapeDelay(src_of(np.zeros(RATE)), + character=character, patch=patch) + self.assertEqual(float(np.max(np.abs(render(effect, RATE)))), + 0.0, (character, patch)) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for patch, channels in ((0, 2), (2, 1), (5, 2)): + effect = TapeDelay(src_of(np.zeros(512), channels=channels), + patch=patch) + bound = effect.tail_samples + n = 4800 + x = 0.9 * 32767.0 * (np.random.RandomState(7).rand(n) * 2 - 1) + frames = n + bound + 4096 + effect = TapeDelay(src_of(np.concatenate( + [x, np.zeros(frames - n)]), channels), patch=patch) + out = render(effect, frames) + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else 0 + self.assertLess(last - n, bound, (patch, last - n, bound)) + + def test_reset_empties_the_line(self): + x = np.concatenate([sine(1000.0, 20000.0, 4800), + np.zeros(RATE)]) + effect = TapeDelay(src_of(x), mix=2.0, time_ms=100.0) + render(effect, 5120) + effect.reset() + out = render(effect, 4800 * 3) + self.assertEqual(float(np.max(np.abs(out))), 0.0) + + def test_deinit_leaves_the_source(self): + src = src_of(sine(440.0, 8000.0, 2048)) + effect = TapeDelay(src) + render(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(src)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + x = np.zeros(2048) + x[10] = 30000 + effect = TapeDelay(src_of(x, rate=rate), mix=1.0) + out = render(effect, 2048)[:, 0] + self.assertEqual(int(np.argmax(np.abs(out))), 10) + self.assertEqual(effect.latency_samples, 0) + + def test_spread_does_not_silence_mono(self): + x = np.zeros(RATE) + x[32] = 12000 + outs = [] + for spread in (0.0, 1.0): + effect = TapeDelay(src_of(x, 1), time_ms=100.0, feedback=0.7, + mix=1.0, spread=spread) + outs.append(render(effect, RATE).tobytes()) + self.assertEqual(outs[0], outs[1]) + + def test_the_transport_is_read_only_with_sync_on(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 100.0, 4, 4) + effect = TapeDelay.create(src_of(np.zeros(512)), RATE, + transport=transport) + effect.set_macro(FEEDBACK_I, 64) + self.assertEqual(reads, []) + effect.set_macro(SYNC_I, 127) + self.assertGreater(len(reads), 0) + # 1/8 of 100 bpm is 300 ms. + self.assertAlmostEqual(effect._time_played, 300.0, places=6) + effect.set_macro(DIVISION_I, 127) + self.assertAlmostEqual(effect._time_played, 1200.0, places=6) + + def test_a_host_without_a_tempo_leaves_time_on_the_knob(self): + for bpm in (0.0, -10.0, float("nan"), float("inf"), None): + effect = TapeDelay.create( + src_of(np.zeros(512)), RATE, + transport=lambda _b=bpm: (True, 0.0, _b, 4, 4), sync=True) + self.assertAlmostEqual(effect._time_played, 350.0, places=9) + static = TapeDelay(src_of(np.zeros(512)), sync=True) + self.assertAlmostEqual(static._time_played, 350.0, places=9) + + def test_a_synced_move_is_a_varispeed_move(self): + tempo = [120.0] + effect = TapeDelay.create( + src_of(np.zeros(512)), RATE, + transport=lambda: (True, 0.0, tempo[0], 4, 4), sync=True) + self.assertAlmostEqual(effect._time_played, 250.0, places=6) + tempo[0] = 60.0 + effect.set_macro(FEEDBACK_I, 50) + self.assertAlmostEqual(effect._time_played, 500.0, places=6) + self.assertAlmostEqual(effect._slew, 250.0 / 500.0, places=9) + + def test_a_move_inside_a_walk_takes_its_rate_from_the_last_time(self): + effect = TapeDelay(src_of(np.zeros(RATE)), time_ms=200.0) + render(effect, 512) + effect.set_macro(TIME_I, time_midi(300.0)) + self.assertAlmostEqual(effect._slew, 100.0 / 300.0, places=9) + effect.set_macro(FEEDBACK_I, 10) + self.assertAlmostEqual(effect._slew, 100.0 / 300.0, places=9) + effect.set_macro(TIME_I, time_midi(200.0)) + self.assertAlmostEqual(effect._slew, 100.0 / 200.0, places=9) + # The falling move keeps the old Time in the tail's reach. + self.assertEqual(effect._reach, frames_of(300.0)) + + def test_the_stall_windows_are_stepped_clear(self): + effect = TapeDelay(src_of(np.zeros(512))) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + self.assertIsNotNone(effect.tail_samples, midi) + self.assertLess(abs(effect._feedback - effect.macro(FEEDBACK_I)), + 3e-5) + + +class InputCeiling(unittest.TestCase): + """The docstring's ceiling on the kit's `noise_det`, 20 s at 48 kHz: + clean at -1.1 dBFS at the defaults (stereo and mono) and at -2.0 dBFS + at every patch; the defaults rail 0.1 dB above.""" + + def _rails(self, dbfs, channels=2, patch=None, character="varispeed"): + frames = 20 * RATE + data = probes.noise_det(frames=frames, dbfs=dbfs, channels=channels) + src = probes.ArraySource(data, rate=RATE, channels=channels) + effect = TapeDelay(src, patch=patch, character=character) + out = render(effect, frames) + return int(np.sum((out >= 32767) | (out <= -32768))) + + def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): + for channels, over in ((2, 14), (1, 7)): + self.assertEqual(self._rails(-1.1, channels), 0, channels) + self.assertEqual(self._rails(-1.0, channels), over, channels) + for character in tape.CHARACTERS: + for patch in range(8): + self.assertEqual(self._rails(-2.0, 2, patch, character), 0, + (character, patch)) + self.assertGreater(self._rails(-1.8, 2, 2), 0) + self.assertGreater(self._rails(-1.7, 2, 4, "sliding-head"), 0) + + +class FaultsAreUnreachable(unittest.TestCase): + """Every planted fault reads a state no macro position or shipped patch + reaches, on either character (11 macros x 17 positions + 8 patches).""" + + def test_every_fault_is_off_the_surface(self): + for character in tape.CHARACTERS: + def build(cls, _c=character): + return cls(src_of(np.zeros(512)), character=_c) + for faulted, flag in FAULTS: + result = kit_faults.fault_reachability( + TapeDelay, faulted, + lambda e, _f=flag: bool(getattr(e, _f, False)), build) + self.assertEqual(result["checked"], 11 * 17 + 8) + + def test_the_varispeed_walk_is_the_tape_equation_everywhere(self): + effect = TapeDelay(src_of(np.zeros(RATE))) + render(effect, 512) + last = effect._node_ms + for midi in list(range(0, 128, 8)) + [127]: + effect.set_macro(TIME_I, midi) + if effect._node_ms != last: + self.assertAlmostEqual( + effect._slew, abs(effect._node_ms - last) + / effect._node_ms, places=12) + last = effect._node_ms + for index in range(11): + if index == TIME_I: + continue + for midi in (0, 64, 127): + effect.set_macro(index, midi) + self.assertLess(effect._slew, 60.0) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire.""" + + def test_every_demonstrated_row_is_red_on_a_wire(self): + rows = ( + ("T1a", lambda cls: t1a_cell(cls, 200.0, 100.4)), + ("T1b ramp", lambda cls: ramp_cell(cls)), + ("T2", lambda cls: t2_one_pass(cls, 350.0, 5.0, tape.VARISPEED)), + ("T3", lambda cls: t3_cell(cls)), + ("T4", lambda cls: t4_verdict(*delay_trace(cls, seconds=60.0))), + ) + for name, measure in rows: + result = kit_faults.null_build_red(TapeDelay, measure, + label="TapeDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() From b85c2954b2be9b3379971fa74f0c33b1a5732672 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:15:35 -0500 Subject: [PATCH 20/88] Flanger: keep the docstring phrase its surrender test pins The F8 restatement reworded "a click is not that bar" to "measure", which test_default_surrender_is_named_in_docstrings pins. Restored in the module and class docstrings and the catalogue row; the measured ring is unchanged. --- lib/audioeffects/README.md | 2 +- lib/audioeffects/rebuilt/flanger.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 7b673fc..d15a233 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -181,7 +181,7 @@ where the board stage has run, else the dossier palette budget. A | Class | Tier | Macros | Latency | Cost | Standout | |---|---|---|---|---|---| | `Chorus` | audiodsp (`audioecho`) | 5 | 0 samples; wet Delay 3–20 ms is the effect, not lookahead | **8.1 % P4 / 9.8 % S3** at patch 0 (ROW marg 0.431 / 0.524 ms), inside palette **P4 ≤ 9 % / S3 ≤ 15 %**; no lean patch | Electro-Harmonix Small Clone - one BBD voice, clock-law triangle, Mix 0 a wire. **What the default surrenders:** the within-half pitch-offset ratio is 1.98, not (d_max/d_min)² = 3.60 (T2). Tone 12 kHz is 2.4 dB down at 10 kHz, not ≥10 dB (T3). The constructor Tone 3 kHz still meets T3 | -| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s measured at audiodsp v0.6.2; restated 2026-09-28 from "the 2 s ring", which the node's old rounding floor partly held up); the default does not, and a click is not that measure. | +| `Flanger` | audiodsp (`audioecho`; `audioroute` only when Through Zero is on) | 12 | 0 samples at defaults; Through Zero off by default, **10 ms / 480 samples at Range max / 48 kHz** when on | **0.672 ms/block P4 (rt 7.94) / 1.081 ms S3 (rt 4.93)** at patch 0, inside palette **9 % / 15 %** (FeedbackDelay +options; no extra looping RawSample); no lean patch | Electro-Harmonix Electric Mistress - BBD swept comb, Filter Matrix, Mix 0–2. **What the default gives up:** Color 0.55 / Matrix off is about −22 dB and 0.1 s, not the Color-0 −37 dB null or the Color-max ring; Color 0→0.9 holds +15 dB at 48 / 44.1 / 22.05 kHz on noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s measured at audiodsp v0.6.2; restated 2026-09-28 from "the 2 s ring", which the node's old rounding floor partly held up); the default does not, and a click is not that bar. | | `Phaser` | audiodsp (`audiobiquad`, `audioshaper`) | 10 | 0 samples at every setting | Palette **15 % P4 / 24 % S3** (AllPass-6 0.248 / 0.423 + Waveshaper ×1 0.223 / 0.410 + extra synthio 0.317 / 0.408 + glue 0.0). The old **9 % / 16 %** bar omitted the LFO: the palette could not price a `synthio` source. Quoted ROW **8.5 % / 15.6 %**. Patch 8 `Phaser - lean` (Drive 0) is AllPass alone → **5/8 %**. | MXR Phase 90 — four first-order all-pass stages, JFET as the variable resistor. **What the default surrenders:** Drive 0.3 is on at construction. Feedback's inter-notch peak then misses the 5 dB bar (600–1800 Hz rise −0.633 dB at 48 kHz) and the notch floor is not monotone (0.5→0.7 −1.204 dB). Both hold at Drive 0. The floor's 0.5→0.7 step also deepens at 22.05 kHz with Drive 0 (−1.286 dB). | | `Tremolo` | audiodsp (`audiomath`) | 9 | 0 samples; Lag is table shape, not a delay | Palette **7 % P4 / 9 % S3** (Multiply 0.028 / 0.047 + extra synthio 0.317 / 0.408 + glue 0.0). 1024-point board ROW **2.4 % / 4.9 %** (P4 marg 0.128 / ctrl 0.346, rt 11.23; S3 marg 0.265 / ctrl 0.532, rt 6.69). Prior 256-point ROW **2.3 / 4.9** is a shorter waveform. No lean patch | Fender Princeton 6G2 bias-vary and AB763 optical. **What the default surrenders:** Default is bias, Depth 0.5, Rate 5 Hz — not the optical standout and not L1's Depth 1. Wet peak at the default is −1.341 dB vs a 16000-LSB tone, not the ~6 dB `synthio` `>>16` ceiling; Depth 0 is still a wire. At 22.05 kHz the default's L1 bar is measured at the constructor, not assumed. Optical L1/L2 are disconfirmed; O1–O4 unmeasured at the constructor. L1 holds on sine at every rate, and misses by 0.16 dB on SQUARE material at 44.1 kHz only (−79.844 against −80; 48 kHz holds at −82.01). | | `Vibrato` | audiodsp (`audioecho`) | 8 | **mean Delay**; default **192 samples / 4.0 ms at 48 kHz** (wet alone); constructor and `program_change(0)` stay on that whole-sample bin | Palette **9 % P4 / 15 % S3** (`FeedbackDelay` +options, Mixer optioned off at Level 0 dB). Prior Mixer-in-path ROW **9.5 / 15.3 %** is stale. Board ROW of the Delay-only graph **unmeasured**. No lean patch | BBD clock-law vibrato (VB-2). Level 0 dB leaves the Mixer out of the pull. **What the default surrenders:** T1's any-2 kHz-window no-dry clause (Tone −3 dB at 17 kHz). At 22.05 kHz a 2 kHz window can tilt past 1 dB. The 17 kHz −3 dB band is at 48 kHz with a whole-sample Delay. T4 and T7 magnitude are Published defaults only. T7 quadrature is disconfirmed | diff --git a/lib/audioeffects/rebuilt/flanger.py b/lib/audioeffects/rebuilt/flanger.py index 6b9bede..49b2c1a 100644 --- a/lib/audioeffects/rebuilt/flanger.py +++ b/lib/audioeffects/rebuilt/flanger.py @@ -24,7 +24,7 @@ class in `modulation.py` is not consulted except for the eight defects −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 s and 2.13 s, 20 ms RMS windows after a 50 ms burst at −6 dBFS, 48 kHz, audiodsp v0.6.2); the default does not, and a click is not that -measure. *Restated 2026-09-28:* this read "the 2 s ring on a 200–440 Hz +bar. *Restated 2026-09-28:* this read "the 2 s ring on a 200–440 Hz burst" until audiodsp v0.6.2, where the node stopped holding a few LSB going round for ever (audiodsp#154) and the ring at 440 Hz fell from 2.13 s to 1.91 s. The sound is unchanged; the claim now says what the @@ -161,7 +161,7 @@ class Flanger(_component.Component): noise (held 3 ms, Matrix on). Color max (0.99, 3 ms, Matrix on) rings to −60 dB in about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (restated 2026-09-28 at audiodsp v0.6.2); the default does - not, and a click is not that measure. + not, and a click is not that bar. """ NAME = 'Flanger' From be6d3595cc3cb1988a37d12f2bd95713188a662f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:16:17 -0500 Subject: [PATCH 21/88] DigitalDelay and SlapbackDelay are adopted: Phase 5's first two Brad's rulings of 2026-09-28. Both are THROUGH on the desktop half (audit-DigitalDelay-reaudit2.md, audit-SlapbackDelay-reaudit1.md) and their board rows met the audit's frozen bars (board-test-plan.md, section 2), with two readings Brad ruled on: the S3's patch 5 at the corrected, same-conditions cost (DigitalDelay about 0.78 ms, 14.6 % of a block; SlapbackDelay 0.813 ms, 15.2 % against the 15 % bar, "It passes"), and the patch digests that are identical board to board and differ from the desktop through single-precision Python floats in a derived setting (audiocomponents#75; DigitalDelay's patch 5 cut_hz 0.035 % high and damping_hz one float32 step, SlapbackDelay's wow_depth_ms 2.1e-5 to 6.8e-5 ms high at every patch). ADOPTED names both, so audioeffects.DigitalDelay, audioeffects.SlapbackDelay and create() serve the rebuilds. They stay under rebuilt/ for now: the loop- tail arithmetic in digitaldelay.py is imported from there by SlapbackDelay, CombFilter and, by TapeDelay's Station A probes, the FeedbackDelay classes still running in parallel, and coming home moves it. Promotion is a separate step once those classes land; delay.py keeps the old classes until then. The README gains a catalogue table for the two with the corrected S3 costs and the digest cause; the time section's "clean delays run on audiodelays" is corrected (both run on audioecho.FeedbackDelay), and the set_* example names TapeDelay. Both class docstrings state the board cost and the digest note in place of "pending hardware". The CHANGELOG entries describe the adopted classes. Tests: each class's "parked, not served" test flips to "adopted is what the package serves" (create() returns the rebuild), and the registry's ADOPTED assertion names the two; with ADOPTED emptied again all three go red. The kit's STATE faults and their control subclass the old _core.Effect delay explicitly (delay.DigitalDelay), which is the contract they were written for. Gates, this tree: validate_api 55 instruments / 45 effects; metadata valid; flake8 clean; tier and registry tests OK; the smoke 45 classes, 259 patches, 0 failures on CPython, desktop MicroPython and CircuitPython (the adopted classes' patches now count). The census moves only the two served names, from the old classes to the rebuilds' own digests, on all three interpreters. --- CHANGELOG.md | 61 ++++++++++++--------- lib/audioeffects/README.md | 30 +++++++--- lib/audioeffects/rebuilt/__init__.py | 19 ++++++- lib/audioeffects/rebuilt/digitaldelay.py | 16 +++++- lib/audioeffects/rebuilt/slapbackdelay.py | 18 ++++-- tests/support/kit_faults.py | 10 +++- tests/test_cpython_effects_digitaldelay.py | 18 ++++-- tests/test_cpython_effects_slapbackdelay.py | 18 ++++-- tests/test_effect_kit.py | 2 +- tests/test_rebuilt_registry.py | 12 +++- 10 files changed, 144 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 180f422..3cc755a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,32 +10,43 @@ there, and are recorded in its changelog. ### Added -- **`DigitalDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** a clean +- **`DigitalDelay` (rebuilt, Phase 5; adopted 2026-09-28):** a clean interpolated line with the Boss DD-2's control law on one - `audioecho.FeedbackDelay`. Turning Time pitch-bends the repeats at a rate - Glide sets instead of clicking, every static Time lands on a whole frame, - Mix 0 is a wire while the line keeps recording, and Repeat Tone and Repeat - Cut put the pedal's 7 kHz and 40 Hz corners into the loop as knobs that - default out. It lives in `lib/audioeffects/rebuilt/digitaldelay.py`, and - `audioeffects.DigitalDelay` is still the old class. It is parked on a - floor bug in the node: from Feedback 0.5 up the feedback write can hold - 1 LSB (50 at 0.99) going round for ever, which needs an audiodsp release. - The docstring states the input ceiling (-3 dBFS peak at the defaults, -4 - over the shipped patches, on `noise_det`) and Repeat Tone's flat top at - 22.05 kHz. Board cost is unmeasured. -- **`SlapbackDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** the Sun - Studio tape slap on one `audioecho.FeedbackDelay`: one mono repeat at - 135 ms, landed on a whole frame at every rate, with Saturation, Tone and - Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire - while the line keeps recording, Time walks rather than clicks, and a host - echoing Time back keeps the constructor's exact frame. Tone out is - byte-identical to no filter until Tone has been in since a reset, and - within 1 LSB after. It lives in `lib/audioeffects/rebuilt/slapbackdelay.py`, - and `audioeffects.SlapbackDelay` is still the old class. The docstring - states the input ceiling (-2.5 dBFS peak at the defaults, -3.4 over the - shipped patches, on `noise_det`), the 15 kHz swing the wow costs the - repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is - unmeasured. + `audioecho.FeedbackDelay`, and what `audioeffects.DigitalDelay` and + `create()` now serve. Turning Time pitch-bends the repeats at a rate Glide + sets instead of clicking, every static Time lands on a whole frame, Mix 0 + is a wire while the line keeps recording, and Repeat Tone and Repeat Cut + put the pedal's 7 kHz and 40 Hz corners into the loop as knobs that + default out. `tail_samples` is finite with Repeat Cut out, and with Repeat + Tone in the Feedback is stepped clear of the node's stall windows + (audiodsp#157). Repeat Tone out after it has been in keeps the loop + low-pass tracking the tap (fixed at adoption: Tone back in after silence + played 26 443 LSB); Repeat Cut back in after silence still plays its + frozen state, a node defect the docstring states. On the boards: at most + 7.0 % of a block on the P4 and about 14.6 % on the S3 (patch 5, + same-conditions reading), passed by Brad; patch 5's digest is identical + board to board and differs from the desktop through single-precision + Python floats in two derived settings (audiocomponents#75). The old + `delay.DigitalDelay` and its `set_time` / `set_mix` stay in `delay.py` + until the class comes home. It lives in + `lib/audioeffects/rebuilt/digitaldelay.py` until then. +- **`SlapbackDelay` (rebuilt, Phase 5; adopted 2026-09-28):** the Sun + Studio tape slap on one `audioecho.FeedbackDelay`, and what + `audioeffects.SlapbackDelay` now serves: one mono repeat at 135 ms, landed + on a whole frame at every rate, with Saturation, Tone and Wow as the + tape's colours and Repeats defaulting to 0. Level 0 is a wire while the + line keeps recording, Time walks rather than clicks, and a host echoing + Time back keeps the constructor's exact frame. Tone out is byte-identical + to no filter until Tone has been in since a reset, and within 1 LSB after. + On the boards: at most 7.4 % of a block on the P4; on the S3 14.6-14.7 %, + and 15.2 % at patch 5 (Tone in), which Brad passed against the 15 % bar; + every patch digest is identical board to board and differs from the + desktop through the Wow depth worked out in single precision + (audiocomponents#75). The docstring states the input ceiling (-2.5 dBFS + peak at the defaults, -3.4 over the shipped patches, on `noise_det`), the + 15 kHz swing the wow costs the repeat, the Wow step, and Tone's flat top + at 22.05 kHz. It lives in `lib/audioeffects/rebuilt/slapbackdelay.py` + until it comes home. ### Changed diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index d15a233..ea7c38c 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -24,7 +24,7 @@ Every class takes its audio source as the first argument - a synthesizer, an `.output` - and exposes its chain tail as `.output`. The underlying nodes are kept as attributes (`.node`, `.mixer`, `.cutoff`, ...) so applications can bind parameters straight to them; the classes with a natural swept control -also expose `set_*` helpers (`DigitalDelay.set_time`, ...). A class that +also expose `set_*` helpers (`TapeDelay.set_time`, ...). A class that has been rebuilt on the component contract drops those helpers for its macro surface: `LadderFilter` is the first, and its cutoff is macro 0. @@ -129,25 +129,39 @@ marginal share of one block on each board at construction defaults. | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | | `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. With Tone in, Feedback is stepped under 0.00003 clear of the node's stall windows (audiodsp#157; the 0.95 stop is one). Tone off after Tone has been in freezes the loop low-pass, and Tone back in from silence plays it (15 070 LSB at 48 kHz); `reset()` clears it. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | -### Time and space - `reverb.py`, `delay.py` +### Time and space - two adopted, the rest in `reverb.py`, `delay.py` + +Phase 5's first two classes, `DigitalDelay` and `SlapbackDelay`, were +**adopted on 2026-09-28**: `audioeffects.create()` serves the rebuilds, from +`lib/audioeffects/rebuilt/`, until they come home. Both are **audiodsp** tier +(one `audioecho.FeedbackDelay` each), zero latency at every setting, and +neither runs on a stock CircuitPython board. Cost is the class's own share of +one 256-frame stereo block at 48 kHz, measured on both boards at every +shipped patch; the S3 figures are the same-conditions reading (the class +measured beside the palette row it was priced from). + +| Class | Tier | Macros | Latency | Cost | Standout | +|---|---|---|---|---|---| +| `DigitalDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 at most 7.0 %** (0.372 ms, patch 5, rt 5.49) **/ S3 about 14.6 %** at patch 5 (about 0.78 ms corrected; 11.8 % as the tool reads it; rt 3.11), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed the S3 figure on 2026-09-28. No lean patch. RAM 155 584 B at the default 800 ms line (48 kHz); `max_time_ms` spends less | Boss DD-2 (1983), light touch: its control law and dry/wet discipline, Mix 0 a wire, Time bends the repeats in pitch at a rate Glide sets instead of clicking, every static Time on a whole frame. Repeat Tone and Repeat Cut put the pedal's 7 kHz and 40 Hz corners in the loop as knobs that default out; patch 5 is the pedal's own corners. `tail_samples` is finite with Repeat Cut out; with Repeat Tone in, Feedback is stepped at most 0.00003 clear of the node's stall windows (audiodsp#157). Repeat Tone out after it has been in keeps the loop low-pass tracking the tap (fixed 2026-09-28; within 1 LSB of the filter out, up to 51 LSB inside a stall window at the 0.99 stop); Repeat Cut back in after silence still plays its frozen state (20 858 LSB at Mix 2), a node defect, and `reset()` clears it. **Digests:** six of seven patches identical on the P4, the S3 and the desktop; patch 5 identical board to board, and different from the desktop through single-precision Python floats in two derived settings (audiocomponents#75): `cut_hz` 0.035 % high (39.44766 Hz against 39.43366) and `damping_hz` one float32 step. **What the default surrenders:** no band limit and no compander, no HOLD, and Glide 0's instant knob clicks. | +| `SlapbackDelay` | audiodsp (`audioecho`) | 6 | 0 samples | **P4 at most 7.4 %** (0.396 ms corrected; 6.4 % as the tool reads it; rt 5.82) **/ S3 14.6-14.7 %** at the default and patches 0-4 and **15.2 % at patch 5** (0.813 ms corrected, the one patch with Tone in; 11.6 % as the tool reads it; rt 3.08), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed patch 5 on 2026-09-28 ("It passes"). No lean patch. RAM about 50 KB (a fixed 251 ms line) | Sam Phillips' two-Ampex-350 slap at Sun Studio: one mono repeat at 135 ms, landed on a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire; Time walks rather than clicks. Tone out stays byte-identical to no filter until Tone has been in since a reset, and within 1 LSB after. **Digests:** all seven patches identical on the P4 and the S3, and different from the desktop through single-precision Python floats in a derived setting (audiocomponents#75): `wow_depth_ms` 2.1 x 10^-5 to 6.8 x 10^-5 ms high, under 0.0001 cent (patch 4's Feedback also one float32 step). **What the default surrenders:** Tone out, so the repeat is as bright as the dry, and Wow at 1 cent makes a 15 kHz repeat breathe down to -5.11 dB at 48 kHz. | + | Class | Notes | |---|---| | `Reverb` | presets `room` `chamber` `hall` `plate` `spring` (spring adds pre-flutter) | | `ConvolutionReverb` | a real impulse response, measured or synthesized; **patches** | -| `DigitalDelay` `SlapbackDelay` | clean repeats | | `TapeDelay` | in-loop low-pass, soft-clip and per-sample wow; **patches** | | `AnalogDelay` | BBD: band-limited both ends, `age` over the lot; **patches** | | `PingPongDelay` | true cross-feed - repeats alternate sides; **patches** | | `MultiTapDelay` | `(position, level)` tap patterns | -The delays split two ways. `DigitalDelay`, `SlapbackDelay` and -`MultiTapDelay` are clean and run on `audiodelays`, whose feedback path is +`MultiTapDelay` is clean and runs on `audiodelays`, whose feedback path is the echo times a decay. The other three are named after something that happens *inside* that path - a filter taking a little more off each pass, a soft-clip rounding it, a cross-feed sending it to the other speaker - so they -run on `audioecho.FeedbackDelay`, which is where audiodsp puts those. A -coloured delay's `max_time_ms` sizes its line and cannot change afterwards; -at 48 kHz a second of stereo line is 192 KB, so ask for what will be used. +run on `audioecho.FeedbackDelay`, which is where audiodsp puts those, as do +the two adopted delays above. A delay's `max_time_ms` sizes its line and +cannot change afterwards; at 48 kHz a second of stereo line is 192 KB, so +ask for what will be used. The two reverbs are not the same kind of thing. `Reverb` is `audiofreeverb`: a fixed network of delay lines that costs the same on a Cortex-M0 as on a diff --git a/lib/audioeffects/rebuilt/__init__.py b/lib/audioeffects/rebuilt/__init__.py index 51d6006..5fffad7 100644 --- a/lib/audioeffects/rebuilt/__init__.py +++ b/lib/audioeffects/rebuilt/__init__.py @@ -90,7 +90,24 @@ def _present(): #: there is nothing left here to arbitrate for any of them. #: #: What stays parked under this directory: Phase 3's `Flanger`. -ADOPTED = () +#: +#: Phase 5's first two, adopted 2026-09-28 on Brad's rulings of that date: +#: `DigitalDelay` and `SlapbackDelay`. Both are THROUGH on the desktop half +#: (`audit-DigitalDelay-reaudit2.md`, `audit-SlapbackDelay-reaudit1.md`), +#: and their board rows met the bars (board-test-plan.md, section 2), each +#: S3 patch 5 at the corrected cost Brad passed (DigitalDelay about +#: 0.78 ms, 14.6 % of a block; SlapbackDelay 0.813 ms, 15.2 % against the +#: 15 % bar) and each patch digest identical board to board, differing +#: from the desktop only through single-precision Python floats in a +#: derived setting (audiocomponents#75). They stay under this directory for +#: now: `digitaldelay.py`'s loop-tail arithmetic is imported from here by +#: `SlapbackDelay`, `CombFilter` and the FeedbackDelay classes Phase 5 is +#: still building, and coming home moves it. That is a separate step, after +#: those classes land. +ADOPTED = ( + "DigitalDelay", + "SlapbackDelay", +) def load(name): diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 08015ca..0c3c5f2 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -4,8 +4,8 @@ `workspace docs/effects-internal/dossiers/DigitalDelay.md`, whose trait table was frozen at Station A before this file existed (anchor commit 51207b8, 2026-09-27). The old class in `delay.py` is consulted only for the -seven defects that dossier's section 7 names; it stays the class the -library serves until the auditor adopts this one. +seven defects that dossier's section 7 names. This class was adopted on +2026-09-28, and `audioeffects.DigitalDelay` serves it. **What it sounds like.** Your dry signal passes untouched, and one clean repeat follows it, fed back for more. Time (12.5-800 ms) is the DD-2's @@ -46,7 +46,17 @@ **Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on; no mixer. Palette row FeedbackDelay +options (the nearest not-cheaper row; there is no row for the slew alone), glue 0: **P4 <= 9 %, S3 <= 15 %** of a -5.333 ms stereo block. The board measurement is pending hardware. +5.333 ms stereo block. Measured on both boards on 2026-09-27 at every +shipped patch: the P4 at most 0.372 ms, 7.0 % (patch 5, rt 5.49); the S3 +at most 0.631 ms as the tool reads it, and about 0.78 ms, 14.6 %, at +patch 5 once the tool's control is measured in the same conditions as the +palette row (the dearest patch, the one with both loop filters in; rt +3.11). Alone on an S3 it leaves about 85 % of the block for everything +else. Six of the seven patch digests are identical on both boards and the +desktop; patch 5's is identical on both boards and differs from the +desktop's because `nominal_cut_hz` and `nominal_damping_hz` are worked out +in Python, in a board's single precision: `cut_hz` lands 0.035 % high +(39.44766 Hz against 39.43366) and `damping_hz` one float32 step off. **What the default surrenders.** It is a clean line, so it does not darken on its own: the DD-2's 7 kHz band limit and its compander are not in the diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 8abb875..0e4a97b 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -5,8 +5,8 @@ table was frozen at Station A before this file existed (anchor commit 7a5a4cbd8a734ea3df6ae8b8b04e32e763a15b5a, the Station A critique's re-freeze, 2026-09-27). The old class in `delay.py` is consulted only for -the seven defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. +the seven defects that dossier's section 7 names. This class was adopted +on 2026-09-28, and `audioeffects.SlapbackDelay` serves it. **What it sounds like.** Your dry signal passes untouched, and one copy of it comes back 135 ms later, from the same place, a little quieter: the @@ -47,8 +47,18 @@ **Cost.** One `audioecho.FeedbackDelay` with `delay_slew`, wow and `loop_drive` on; no mixer. Palette row FeedbackDelay +options (the nearest not-cheaper row; no row prices `loop_drive`), glue 0: **P4 <= 9 %, -S3 <= 15 %** of a 5.333 ms stereo block. The board measurement is pending -hardware. +S3 <= 15 %** of a 5.333 ms stereo block. Measured on both boards on +2026-09-28 at every shipped patch, with the tool's control in the same +conditions as the palette row: the P4 at most 0.396 ms, 7.4 % (rt 5.82 or +better); the S3 0.781-0.783 ms, 14.6-14.7 %, at the default and patches +0-4, and 0.813 ms, 15.2 %, at patch 5, the one patch with Tone in +circuit (rt 3.08 or better). Brad passed patch 5 against the 15 % bar on +2026-09-28; alone on an S3 the class leaves about 85 % of the block for +everything else. All seven patch digests are identical on both boards and +differ from the desktop's, because the Wow depth is worked out in Python +in a board's single precision (`wow_depth_ms` 2.1 x 10^-5 to +6.8 x 10^-5 ms high, under 0.0001 cent; patch 4's Feedback also one +float32 step off). **What the default surrenders.** The default is Tone out, so the repeat is as bright as the dry: an Ampex 350 at 15 ips rolls off at 15 kHz and at diff --git a/tests/support/kit_faults.py b/tests/support/kit_faults.py index de89f8d..0f5a2e5 100644 --- a/tests/support/kit_faults.py +++ b/tests/support/kit_faults.py @@ -42,6 +42,10 @@ import audiofilters import synthio from audioeffects import _core +# The STATE faults below are written against the old `_core.Effect` delay, +# which `delay.py` keeps until the rebuilt `DigitalDelay` (adopted +# 2026-09-28) comes home; `audioeffects.DigitalDelay` is the rebuild now. +from audioeffects.delay import DigitalDelay as _OldDigitalDelay #: `_component` reads VENDOR off the module a class is defined in, and the #: CLICK fault below is a subclass of a rebuilt class. Without this the fault @@ -239,7 +243,7 @@ def reported(self): "latency_samples": property(reported)}) -class NoResetDelay(audioeffects.DigitalDelay): +class NoResetDelay(_OldDigitalDelay): """STATE's first fault: a delay line left full after `reset()`. The base class resets the node's buffer; this one keeps everything else @@ -251,7 +255,7 @@ def reset(self): self.program_change(0) -class LiveIntermediateDelay(audioeffects.DigitalDelay): +class LiveIntermediateDelay(_OldDigitalDelay): """STATE's second fault: an intermediate node left live after `deinit()`. @@ -265,7 +269,7 @@ def __init__(self, source, **options): filter=synthio.Biquad(synthio.FilterMode.LOW_PASS, 8000.0, Q=0.707), **_core.pcm()) self.pre.play(source) - audioeffects.DigitalDelay.__init__(self, self.pre, **options) + _OldDigitalDelay.__init__(self, self.pre, **options) class UnreleasableSectionLowPass(LowPass): diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index 7144d0a..477b87d 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -9,8 +9,8 @@ spans, the three interpreters and the rates live in the evidence pack, not in this file. -The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by -`rebuilt.module_class("DigitalDelay")`. +The class is reached by `rebuilt.module_class("DigitalDelay")`, which is also what +`audioeffects.DigitalDelay` serves since its adoption on 2026-09-28. Fix round 1 (2026-09-27, after gate audit round 1): T2's pitch is read by a local least-squares fit against the dossier's own law (787.5 / glide_ms, @@ -1471,11 +1471,17 @@ def test_macros_patches_tier_latency(self): effect.program_change(3) self.assertEqual(effect.patch_index, 3) - def test_parked_not_served(self): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-28, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" import audioeffects - self.assertNotIn("DigitalDelay", rebuilt.ADOPTED) - self.assertIn("DigitalDelay", rebuilt.parked()) - self.assertIsNot(audioeffects.DigitalDelay, DigitalDelay) + self.assertIn("DigitalDelay", rebuilt.ADOPTED) + self.assertNotIn("DigitalDelay", rebuilt.parked()) + self.assertIs(audioeffects.DigitalDelay, DigitalDelay) + served = audioeffects.create("DigitalDelay", silence_src(64), RATE) + self.assertIsInstance(served, DigitalDelay) + served.deinit() def test_patch_0_is_the_constructor_grid(self): effect = DigitalDelay(silence_src(512), sample_rate=RATE) diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 3fd1e96..3c8d4c4 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -15,8 +15,8 @@ dossier, never taken from the class: the whole-frame landing, the Wow map, the Time span. -The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by -`rebuilt.module_class("SlapbackDelay")`. +The class is reached by `rebuilt.module_class("SlapbackDelay")`, which is also what +`audioeffects.SlapbackDelay` serves since its adoption on 2026-09-28. """ import math @@ -1009,11 +1009,17 @@ def test_macros_patches_tier_latency(self): effect.program_change(3) self.assertEqual(effect.patch_index, 3) - def test_parked_not_served(self): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-28, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" import audioeffects - self.assertNotIn("SlapbackDelay", rebuilt.ADOPTED) - self.assertIn("SlapbackDelay", rebuilt.parked()) - self.assertIsNot(audioeffects.SlapbackDelay, SlapbackDelay) + self.assertIn("SlapbackDelay", rebuilt.ADOPTED) + self.assertNotIn("SlapbackDelay", rebuilt.parked()) + self.assertIs(audioeffects.SlapbackDelay, SlapbackDelay) + served = audioeffects.create("SlapbackDelay", silence_src(64), RATE) + self.assertIsInstance(served, SlapbackDelay) + served.deinit() def test_patches_are_the_dossier_settings_on_the_grid(self): for index, (name, values) in enumerate(DOSSIER_PATCHES): diff --git a/tests/test_effect_kit.py b/tests/test_effect_kit.py index bf6d236..d832101 100644 --- a/tests/test_effect_kit.py +++ b/tests/test_effect_kit.py @@ -246,7 +246,7 @@ def pull(blocks): def test_a_delay_line_left_full_after_reset_is_red(self): options = {"time_ms": 150.0, "feedback": 0.5, "mix": 0.5} - control = self._run(audioeffects.DigitalDelay, **options) + control = self._run(faults._OldDigitalDelay, **options) self.assertTrue(control["passed"], control["red"]) self.assertEqual(control["values"]["reset_residual_lsb"], 0) self.assertTrue(control["values"]["resumed"]) diff --git a/tests/test_rebuilt_registry.py b/tests/test_rebuilt_registry.py index f977479..2e627e9 100644 --- a/tests/test_rebuilt_registry.py +++ b/tests/test_rebuilt_registry.py @@ -861,10 +861,16 @@ def test_phase2_names_have_left_adopted(self): # `Exciter` last, under Brad's G6 ruling that the cost gate is a # real-time ceiling, 80 % of a stereo block, which all three are # inside on both boards at every shipped patch. `drive.py` is - # deleted, so ADOPTED is empty again and the substitution machinery - # stays for Flanger and the phases to come. - self.assertEqual(rebuilt.ADOPTED, ()) + # deleted, so ADOPTED emptied and the substitution machinery stayed + # for Flanger and the phases to come. Phase 5's first two, + # `DigitalDelay` and `SlapbackDelay`, were adopted on 2026-09-28 on + # Brad's rulings, and are served from here until they come home. + self.assertEqual(rebuilt.ADOPTED, ("DigitalDelay", "SlapbackDelay")) self.assertEqual(rebuilt.adopted(), rebuilt.ADOPTED) + for name in rebuilt.ADOPTED: + self.assertIs(rebuilt.load(name), rebuilt.module_class(name)) + self.assertIs(getattr(audioeffects, name), + rebuilt.module_class(name)) parked = set(rebuilt.parked()) known = set(rebuilt.known()) self.assertTrue(set(FIXTURES) <= parked) From 620a731cc720f732676b68a103c37c093a798fa3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:27:35 -0500 Subject: [PATCH 22/88] PingPongDelay Station B: the rebuild and its tests Built from the frozen dossier (anchor eb46672): nine macros, seven patches, one FeedbackDelay with the fixed 0.1875 walk, Spread as (cross_feed s, input_pan -+s), mono at (0, 0), the filters' out stops after use as section 8.11 settles. Tests: T1-T5 with their planted faults, reachability walks and the wire. --- lib/audioeffects/rebuilt/pingpongdelay.py | 581 ++++++++ tests/test_cpython_effects_pingpongdelay.py | 1385 +++++++++++++++++++ 2 files changed, 1966 insertions(+) create mode 100644 lib/audioeffects/rebuilt/pingpongdelay.py create mode 100644 tests/test_cpython_effects_pingpongdelay.py diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py new file mode 100644 index 0000000..d48d4bf --- /dev/null +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -0,0 +1,581 @@ +"""`PingPongDelay` - repeats that alternate between the speakers. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/PingPongDelay.md`, whose trait +table was frozen at Station A before this file existed (anchor commit +eb466725d4422619d8e941c043f77512c1e5237b, the Station A critique's +re-freeze, 2026-09-28). The old class in `delay.py` is consulted only for +the seven defects that dossier's section 7 names; it stays the class the +library serves until the board runner adopts this one. + +**What it sounds like.** Your dry signal passes untouched on both sides. +The first repeat comes back Time later on one side only, the side First +Side names; the next comes back Time after that on the other side, a +Feedback's worth quieter, and they keep bouncing, one side and then the +other, all the way down. Each side on its own repeats every two Times. +Time (20-1000 ms) is the spacing between one repeat and the next. Feedback +(0-0.99) is how many bounces you hear. Mix (0-2) is the wet/dry balance: +dry at unity up to 1, the repeats alone at 2, and Mix 0 is a wire while the +line keeps recording. Spread moves the whole thing between two plain +delays, one per side with the same repeats on both (0), and the full +bounce (1). Sync locks Time to Division of the host's beat. Repeat Tone +and Repeat Cut put a low-pass and a high-pass inside the loop, so each +bounce is a little darker or thinner than the last; both default out. + +**No standout.** A ping-pong is a routing of two delay lines, not a +circuit, and no product defines it (dossier section 2). The traits are the +textbook property of the topology, stated so a measurement can fail them. + +**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The +cross-feed exists only on `audioecho.FeedbackDelay`; nothing a stock +CircuitPython board carries crosses one channel's repeats into the other's +line. On a stock board this module imports cleanly and construction raises +`ImportError`. + +**Latency: zero samples, at every setting and every rate.** Nothing looks +ahead. The dry path is a wire on both channels until the first repeat +arrives, and the repeats are the effect, not latency. No option adds any. + +**Mono.** A one-channel source gets the mono sum of the stereo behaviour: +an ordinary feedback delay at the same Time, Feedback and Mix, repeats at +T, 2T, 3T ... at gains 1, f, f^2 .... Spread and First Side do nothing on +a one-channel instance, because there is no second line to cross into: +the class hands the node `cross_feed` 0 and `input_pan` 0 there whatever +they say. (Handing it the stereo settings would silence the loop after one +half-level repeat, which is what the old class did.) On a stereo source +identical in both channels, with Mix at 2, the two channels summed are +that mono delay's output exactly, sample for sample, as long as no two +repeats overlap one another: on sustained material that overlaps its own +repeats each side rounds its own write where the mono delay rounds the sum +once, and the two part by a few LSB. + +**Spread's law.** Spread s hands the node `cross_feed` s and `input_pan` +-s (First Side left) or +s (right). On a click identical in both channels, +repeat n reads f^(n-1) [(1 - s/2) + (s/2)(1 - 2s)^(n-1)] of the click on +the First Side channel and f^(n-1) [(1 - s/2) - (s/2)(1 - 2s)^(n-1)] on +the other. At Spread 0 the two channels are identical; at Spread 1 each +repeat is on one side only and the other side is exact zero. + +**RAM.** The line is `max_time_ms + 1` ms of two int16 lanes whatever the +channel count: 192 192 B at 48 kHz for the default 1000 ms (176 576 B at +44.1 kHz, 88 288 B at 22.05 kHz), plus about 1.2 KB of node. The extra +millisecond is what lets Time reach 1000 ms exactly. Pass a lower +`max_time_ms` to spend less (300 ms costs 57 792 B, 500 ms 96 192 B); +Time then stops at that ceiling and `get_macro(0)` shows where it stopped. + +**Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on; no mixer. +Palette row FeedbackDelay +options (the nearest not-cheaper row; there is +no row for the slew alone), glue 0: **P4 <= 9 %, S3 <= 15 %** of a +5.333 ms stereo block. The board measurement is pending hardware. + +**Turning Time while it plays** walks the repeats to the new time at a +fixed 0.1875 delay-seconds per second instead of clicking, so every repeat +already in the loop bends in pitch while it moves: +297.5 cents while Time +falls and -359.5 cents while it rises (1200 log2(1 +- 0.1875)). 280 -> +200 ms takes 427 ms, the full range 5.23 s. There is no Glide knob; a +ping-pong's time is set to a subdivision, not played. While Time walks, +the line is read between samples, and the two-tap read costs the top of +the band sqrt(1 - 2 frac (1 - frac)(1 - cos 2 pi f / fs)) per pass. Every +static Time is landed on the nearest whole frame at the running rate, +floor(ms fs / 1000 + 0.5), where the read is lossless, so the repeats of a +Time you have stopped turning do not darken; the knob's milliseconds and +`get_macro(0)` stay as you set them. + +**Repeat Tone** is the corner the loop low-pass achieves (800-16 000 Hz, +the top stop out). It is inside the loop, so repeat n has passed it n +times: on one side, each repeat is two passes darker than the last one +there. With Repeat Tone in, a repeat's peak also lands late by the +filter's group delay, more each pass. At a low rate the knob's corners +clamp below Nyquist: at 22.05 kHz positions 111-126 all sit on the +10 804.5 Hz clamp and do the same thing. + +**The filters' out stops, after a filter has been in.** The node leaves a +loop filter's state frozen while the filter is out, and a frozen filter +plays what it held when it comes back in, out of silence. So once Repeat +Tone has been in since the last `reset()` (a constructor `tone_hz` or a +patch counts), its out stop keeps the low-pass running at a coefficient of +exactly 1, which follows the repeats; against the filter truly out that +is within 1 LSB. Repeat Cut cannot do that (a high-pass at coefficient 1 +mutes the loop), so once Repeat Cut has been in since the last `reset()` +its bottom stop stays in circuit at the 20 Hz corner, the knob's own +bottom, until the next `reset()`. That costs the low end of the repeats +something a true out would not, and `tail_samples` is `None` while it +lasts. A `reset()` brings both exact outs back. + +**Tail.** `tail_samples` is an upper bound on how long the output takes to +reach exact zero after your input stops, and it is long: the loop rounds +its way down from full scale, 14 laps at the default Feedback (188 174 +frames, 3.9 s, at 48 kHz) and 685 at 0.99 (11.4 minutes at Time 1000 ms). +The cross-feed moves repeats between the sides without changing the loop +gain, so the figure is the same at every Spread. With Repeat Tone in the +node's loop low-pass can hold a small value for ever at a Feedback a hair +either side of 1 - 0.5 / k, so there the class hands the node a Feedback +just outside that window (under 3 x 10^-5 away, far inside one step of the +knob, which still reads what you set). With Repeat Cut in circuit +`tail_samples` is `None`: no bound is derived there. + +`capabilities = ("tempo_sync",)`: with Sync on, the class reads +`self._transport()` on every macro move and program change (not per +block). With no host transport, or a host whose tempo is not a finite +positive number, Time stays where the knob is. A Division past the +1000 ms ceiling (or `max_time_ms`) clamps there, and `get_macro(0)` shows +it. + +A constructor value stays on the audio path unrounded by the knob's grid; +Time is then landed on a whole frame. A value outside a knob's span clamps +to the nearer stop, a `tone_hz` or `cut_hz` of 0 or less is that filter +out, and NaN takes that option's default. A `max_time_ms` above 1000 or +NaN is 1000. +""" + +VENDOR = "PyDevices" + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's arithmetic, reused rather than copied: the same node rounds +# the same way in both classes. Its module moves up one level when it comes +# home, so both homes are tried; the same for SlapbackDelay's `tone_excess`. +try: + from .digitaldelay import (DIVISION_BEATS, clear_of_stalls, laps_to_zero, + nominal_cut_hz, whole_frames) +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import (DIVISION_BEATS, clear_of_stalls, + laps_to_zero, nominal_cut_hz, whole_frames) +try: + from .slapbackdelay import tone_excess +except ImportError: # pragma: no cover - after it lands + from ..slapbackdelay import tone_excess + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The Time span, fixed on every instance (dossier section 6): 20-1000 ms, +#: log. +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 1000.0 + +#: The line's headroom over `max_time_ms`: the node clamps a delay at +#: `line_frames - 2` (`audiodsp_feedback_delay.c:148-150`), so a line of +#: exactly `max_time_ms` could not reach it. +LINE_HEADROOM_MS = 1.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: The Repeat Tone and Repeat Cut spans' corners, Hz. Tone's top stop and +#: Cut's bottom stop are the filters out of circuit. +TONE_MIN_HZ = 800.0 +TONE_MAX_HZ = 16000.0 +CUT_MIN_HZ = 20.0 +CUT_MAX_HZ = 400.0 + +#: Repeat Tone out after it has been in: `damping_hz` at 32 x the rate, +#: where 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, +#: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows +#: the tap instead of freezing (dossier section 8.11, `SlapbackDelay`'s +#: answer). +TONE_TRACK_PER_RATE = 32.0 + +#: The fixed Time walk, delay-seconds per second: 3/16, exact in float32 +#: (dossier section 8.5, `SlapbackDelay`'s section 8 answer 6). +SLEW = 0.1875 + +#: How close, as a 0..1 knob position, a Time write must come to the +#: constructor's own seeded position to count as a host echoing it back: +#: 1e-6 is 1/7 874 of one MIDI step. +ECHO_TOLERANCE = 1e-6 + +(TIME_I, FEEDBACK_I, MIX_I, SPREAD_I, SIDE_I, SYNC_I, DIVISION_I, TONE_I, + CUT_I) = range(9) + +#: The constructor defaults NaN falls back to: Time, Feedback, Mix, Spread, +#: Division. +DEFAULT_TIME_MS = 280.0 +DEFAULT_FEEDBACK = 0.45 +DEFAULT_MIX = 0.3 +DEFAULT_SPREAD = 1.0 +DEFAULT_DIVISION = 6.0 + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +def _side(value): + """`first_side` -> 0.0 (left) or 1.0 (right).""" + if isinstance(value, str): + word = value.lower() + if word == "left": + return 0.0 + if word == "right": + return 1.0 + raise ValueError("first_side must be 'left' or 'right', not %r" + % (value,)) + return 1.0 if float(value) >= 0.5 else 0.0 + + +class PingPongDelay(_component.Component): + """Two delay lines crossed into each other: the repeats alternate + between the speakers, one side and then the other. audiodsp tier; zero + latency. + + **What the default surrenders:** both loop filters are out, so the + bounce does not darken on its own; Time walks rather than jumps, and a + walk bends the repeats' pitch while it moves; and on a mono source + Spread and First Side do nothing. + """ + + NAME = 'PingPongDelay' + DISPLAY_NAME = 'Ping-Pong Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Spread", "First Side", + "Sync", "Division", "Repeat Tone", "Repeat Cut") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "TOGGLE", + 5: "TOGGLE", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (0.0, 1.0), # 3 Spread + (0.0, 1.0), # 4 First Side: left, right + (0.0, 1.0), # 5 Sync + (0.0, 15.0), # 6 Division index + (TONE_MIN_HZ, TONE_MAX_HZ, "log"), # 7 Repeat Tone, Hz; top = out + (CUT_MIN_HZ, CUT_MAX_HZ, "log"), # 8 Repeat Cut, Hz; bottom = out + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Wide Bounce", (86, 58, 19, 127, 0, 0, 51, 127, 0)), + 1: ("Eighth Note Bounce", (86, 64, 19, 127, 0, 127, 51, 127, 0)), + 2: ("Quarter Note Bounce", (86, 51, 19, 127, 0, 127, 76, 127, 0)), + 3: ("Narrow Bounce", (86, 58, 19, 64, 0, 0, 51, 127, 0)), + 4: ("Two Delays", (86, 58, 19, 0, 0, 0, 51, 127, 0)), + 5: ("Dark Bounce", (99, 83, 19, 127, 0, 0, 51, 48, 0)), + 6: ("Right First", (86, 58, 19, 127, 127, 0, 51, 127, 0)), + } + + def _build(self, time_ms=DEFAULT_TIME_MS, feedback=DEFAULT_FEEDBACK, + mix=DEFAULT_MIX, spread=DEFAULT_SPREAD, first_side="left", + sync=False, division=DEFAULT_DIVISION, tone_hz=TONE_MAX_HZ, + cut_hz=CUT_MIN_HZ, max_time_ms=TIME_MAX_MS, patch=None): + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + self._frames = 1 + #: The longest delay, in whole frames, the read head may still sit + #: at. The walk starts from wherever the head is and the class cannot + #: see how far it has got, so after a falling move this keeps the old + #: Time until a reset lands the head. + self._reach = 1 + #: True while the node has been built or cleared and not yet told a + #: second Time: it snaps onto the configured delay on its first pull. + self._fresh = True + #: True while several macros are applied at once (the constructor, + #: `program_change`); the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._cut = 0.0 + self._node_ms = 0.0 + #: True once a loop filter has been handed an in-circuit corner + #: since the node was built or cleared. From then on its state is + #: live, and its out stop is not 0 (dossier section 8.11). + self._tone_used = False + self._cut_used = False + #: The constructor's Time, exactly, until macro 0 moves. Seeding a + #: log knob and reading it back is not exact, and a few ulps under a + #: half frame lands one frame short. + self._time_exact = None + self._time_seed = -1.0 + self._seeding = True + # A log knob cannot seed 0 or a negative, so those clamp to the + # nearer stop here; 0 or less is how the node spells a filter out. + time_ms = _between(_option(time_ms, DEFAULT_TIME_MS), + TIME_MIN_MS, max_time_ms) + tone_hz = _option(tone_hz, TONE_MAX_HZ) + if not tone_hz > 0.0: + tone_hz = TONE_MAX_HZ + tone_hz = _between(tone_hz, TONE_MIN_HZ, TONE_MAX_HZ) + cut_hz = _option(cut_hz, CUT_MIN_HZ) + if not cut_hz > 0.0: + cut_hz = CUT_MIN_HZ + cut_hz = _between(cut_hz, CUT_MIN_HZ, CUT_MAX_HZ) + values = (time_ms, + _between(_option(feedback, DEFAULT_FEEDBACK), + 0.0, FEEDBACK_MAX), + _between(_option(mix, DEFAULT_MIX), 0.0, 2.0), + _between(_option(spread, DEFAULT_SPREAD), 0.0, 1.0), + _side(first_side), + 1.0 if sync else 0.0, + _between(_option(division, DEFAULT_DIVISION), 0.0, 15.0), + tone_hz, + cut_hz) + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=time_ms, + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + cross_feed=0.0, + input_pan=0.0, + delay_slew=0.0) + # `clear()` empties both lanes and the loop filters and re-primes the + # read head (`audiodsp_feedback_delay.c:291-299`, `:286-288`), so a + # reset is silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._time_exact = time_ms + self._deferred = True + try: + self._init_macros(values) + finally: + self._deferred = False + self._seeding = False + self._time_seed = self._macros[TIME_I] + self._refresh() + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + self._tone_used = False + self._cut_used = False + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _clamp_ms(self, time_ms): + time_ms = float(time_ms) + if time_ms > self._max_time_ms: + return self._max_time_ms + if time_ms < TIME_MIN_MS: + return TIME_MIN_MS + return time_ms + + def _node_time_ms(self, frames): + """What the node is handed for a whole-frame Time.""" + return frames * 1000.0 / self._sample_rate + + def _tone_damping(self, position): + """Macro 7's position -> the node's `damping_hz`: the corner the + loop low-pass achieves, clamped below Nyquist and pre-warped. The + top stop is exactly 0 (out of circuit) and is never pre-warped.""" + if position >= 1.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[TONE_I], position) + return nominal_damping_hz(self._hz(corner), self._sample_rate) + + def _cut_hz(self, position): + """Macro 8's position -> the node's `cut_hz`: the corner the loop + high-pass achieves, clamped and pre-warped. The bottom stop is + exactly 0 (out of circuit).""" + if position <= 0.0: + return 0.0 + corner = _component.macro_value(self._MACRO_RANGES[CUT_I], position) + return nominal_cut_hz(self._hz(corner), self._sample_rate) + + def _cross_and_pan(self): + """(cross_feed, input_pan) for Spread and First Side. A one-channel + node has no second lane: anything but (0, 0) there silences its loop + (dossier section 7.1), so Spread and First Side are inert.""" + if self._channel_count == 1: + return 0.0, 0.0 + spread = _between(self._value(SPREAD_I), 0.0, 1.0) + if self._macros[SIDE_I] >= 0.5: + return spread, spread + return spread, -spread + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport = self._transport + if transport is _component.static_transport: + return None + state = transport() if callable(transport) else transport + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob, as the static + # transport does. `not bpm > 0` catches NaN, and `bpm * 0` is NaN + # for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position + # (`set_macro(0, get_macro(0))`) keeps the constructor's exact + # Time; any other position drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the node once. A refresh + per macro would read Time against the outgoing patch's Sync.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + self._fresh = False + + def _time_ms(self): + """The Time the audio path plays, before it is landed: Division x + the beat with Sync on and a host tempo, else the constructor's exact + Time until macro 0 moves, else the knob. Clamped at `max_time_ms`, + and the knob moved to show the clamp.""" + span = self._MACRO_RANGES[TIME_I] + synced = None + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + self._time_exact = None + time_ms = self._clamp_ms(synced) + self._macros[TIME_I] = _component.macro_position(span, time_ms) + return time_ms + if self._time_exact is not None: + return self._time_exact + time_ms = _component.macro_value(span, self._macros[TIME_I]) + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + self._macros[TIME_I] = _component.macro_position(span, clamped) + return clamped + + def _refresh(self): + rate = self._sample_rate + self._frames = max(1, whole_frames(self._time_ms(), rate)) + self._node_ms = self._node_time_ms(self._frames) + if self._fresh or self._frames > self._reach: + self._reach = self._frames + damping = self._tone_damping(self._macros[TONE_I]) + if damping > 0.0: + self._tone_used = True + elif self._tone_used: + # The node updates its loop low-pass only while the coefficient + # is above 0 (`audiodsp_feedback_delay.c:493`), so handing 0 + # after Tone has been in would freeze whatever the filter held. + # A coefficient of exactly 1 keeps the state on the tap instead. + damping = TONE_TRACK_PER_RATE * rate + cut = self._cut_hz(self._macros[CUT_I]) + if cut > 0.0: + self._cut_used = True + elif self._cut_used: + # The same freeze for the high-pass (`:498`), which outputs + # value - state and so would mute the loop at coefficient 1: + # once Cut has been in, its bottom stop is the 20 Hz corner. + cut = nominal_cut_hz(self._hz(CUT_MIN_HZ), rate) + feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) + if damping > 0.0 and feedback > 0.0: + # With Tone in, the node can hold a small value for ever at a + # Feedback a hair either side of 1 - 0.5 / k; the node is handed + # the nearer edge of that window. + feedback = self._loop_feedback(feedback, + tone_excess(damping, rate)[1]) + self._feedback = feedback + self._damping = damping + self._cut = cut + cross, pan = self._cross_and_pan() + self._delay.set( + delay_slew=SLEW, + delay_ms=self._node_ms, + feedback=feedback, + mix=_between(self._value(MIX_I), 0.0, 2.0), + damping_hz=damping, + cut_hz=cut, + cross_feed=cross, + input_pan=pan) + + def _loop_feedback(self, feedback, excess): + """The Feedback handed to the node with Repeat Tone in circuit.""" + return clear_of_stalls(feedback, excess) + + @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. + + `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:520-529`), + so the per-lap argument holds at every Spread.""" + 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 laps is None: # pragma: no cover - stepped clear + return None + return int(laps * (self._reach + 1 + memory)) diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py new file mode 100644 index 0000000..b58c1e1 --- /dev/null +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -0,0 +1,1385 @@ +"""`PingPongDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/PingPongDelay.md` +(frozen at anchor eb46672, the Station A critique's re-freeze); its Tier 2 +rows are T1-T5. Each row here is the measurement at a few of the row's +cells and the same measurement shown red on a planted fault of the same +kind, at the constructor defaults (or, for a clause the defaults do not +reach, at the row's own cell, said where it is). Every fault is shown +unreachable from every macro position and shipped patch, and every row's +measurement is shown red on the class built as a wire. The full spans, the +three interpreters and the rates live in the evidence pack, not in this +file. + +Every law a measurement checks against is written out here from the +dossier, never taken from the class: the whole-frame landing, the Time +span, the Spread law, the reference mono delay. + +The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by +`rebuilt.module_class("PingPongDelay")`. +""" + +import math +import os +import sys +import unittest +import wave +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import pingpongdelay as pp # noqa: E402 +from tools.effect_measurements import instantaneous_hz # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +BLOCK = 256 +RATES = (48000, 44100, 22050) +(TIME_I, FEEDBACK_I, MIX_I, SPREAD_I, SIDE_I, SYNC_I, DIVISION_I, TONE_I, + CUT_I) = range(9) + +PingPongDelay = rebuilt.module_class("PingPongDelay") + +HERE = os.path.dirname(os.path.abspath(__file__)) +PROBES = os.path.join(HERE, "..", "tools", "effect_probes") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + + +def law_frames(time_ms, rate): + """Section 6: the nearest whole frame, floor(ms fs / 1000 + 0.5).""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def law_time_ms(midi): + """Section 6: Time is 20-1000 ms, log, on the 0-127 grid.""" + return 20.0 * 50.0 ** (midi / 127.0) + + +def law_feedback(midi): + return 0.99 * midi / 127.0 + + +def law_spread(n, s, f, first): + """T4: repeat n's level, as a fraction of the click, on the First Side + channel (`first` True) or the other.""" + sign = 1.0 if first else -1.0 + return f ** (n - 1) * ((1.0 - s / 2.0) + + sign * (s / 2.0) * (1.0 - 2.0 * s) ** (n - 1)) + + +#: Division's sixteen values in quarter-note beats (DigitalDelay's list). +LAW_DIVISION_BEATS = (0.125, 1.0 / 6.0, 0.1875, 0.25, 1.0 / 3.0, 0.375, 0.5, + 2.0 / 3.0, 0.75, 1.0, 4.0 / 3.0, 1.5, 2.0, 8.0 / 3.0, + 3.0, 4.0) + +#: Section 6's patch table, in engineering units: (Time ms, Feedback, Mix, +#: Spread, First Side, Sync, Division index, Tone Hz, Cut Hz). +DOSSIER_PATCHES = ( + ("Wide Bounce", (280.0, 0.45, 0.3, 1.0, 0, 0, 6, 16000.0, 20.0)), + ("Eighth Note Bounce", (280.0, 0.5, 0.3, 1.0, 0, 1, 6, 16000.0, 20.0)), + ("Quarter Note Bounce", (280.0, 0.4, 0.3, 1.0, 0, 1, 9, 16000.0, 20.0)), + ("Narrow Bounce", (280.0, 0.45, 0.3, 0.5, 0, 0, 6, 16000.0, 20.0)), + ("Two Delays", (280.0, 0.45, 0.3, 0.0, 0, 0, 6, 16000.0, 20.0)), + ("Dark Bounce", (420.0, 0.65, 0.3, 1.0, 0, 0, 6, 2500.0, 20.0)), + ("Right First", (280.0, 0.45, 0.3, 1.0, 1, 0, 6, 16000.0, 20.0)), +) +DOSSIER_SPANS = ((20.0, 1000.0, "log"), (0.0, 0.99), (0.0, 2.0), (0.0, 1.0), + (0.0, 1.0), (0.0, 1.0), (0.0, 15.0), + (800.0, 16000.0, "log"), (20.0, 400.0, "log")) +DOSSIER_MODES = ("UNIPOLAR", "UNIPOLAR", "UNIPOLAR", "UNIPOLAR", "TOGGLE", + "TOGGLE", "UNIPOLAR", "UNIPOLAR", "UNIPOLAR") + +#: The presence floor T1 and T2 share (dossier section 3). +FLOOR = 200 + + +# -------------------------------------------------------------------------- +# Planted faults, one or more per Tier 2 row, each of the row's own kind + + +class LossyCrossPingPong(PingPongDelay): + """T1's exclusion bar: `cross_feed` 0.99 with `input_pan` -1 at Spread + 1. The clean class hands (s, -s), so no position reaches (0.99, -1), + and even `spread=0.99` hands (0.99, -0.99). The wrong channel reads + about -23 dB.""" + + NAME = 'PingPongDelay' + + def _cross_and_pan(self): + cross, pan = PingPongDelay._cross_and_pan(self) + return cross * 0.99, pan + + +class LateReadPingPong(PingPongDelay): + """T1's peak clause: the delay handed one frame late, T + 1 frames. + Repeat n peaks n frames late.""" + + NAME = 'PingPongDelay' + + def _node_time_ms(self, frames): + return (frames + 1) * 1000.0 / self._sample_rate + + +class ScaledFeedbackPingPong(PingPongDelay): + """T2: Feedback handed as 0.98 f, about 2 % against the 1 % bar.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + self._delay.set(feedback=self._feedback * 0.98) + + +class MonoStereoSettingsPingPong(PingPongDelay): + """T3, the seeds' own (section 7.1): the one-channel build left at the + stereo settings, `cross_feed` s and `input_pan` -s. At Spread 1 the + loop is silenced after one half-level repeat.""" + + NAME = 'PingPongDelay' + + def _cross_and_pan(self): + spread = self._value(SPREAD_I) + return spread, -spread + + +class PanlessPingPong(PingPongDelay): + """T4: `input_pan` left at 0, the cross-feed alone (section 7.3).""" + + NAME = 'PingPongDelay' + + def _cross_and_pan(self): + cross, pan = PingPongDelay._cross_and_pan(self) + return cross, 0.0 + + +class DryGainPingPong(PingPongDelay): + """T5: the output +0.1 dB (`kit_faults.HiddenGain`), `DigitalDelay`'s + `DryGainDelay`.""" + + NAME = 'PingPongDelay' + + def _build(self, *arguments, **options): + PingPongDelay._build(self, *arguments, **options) + self._output = kit_faults.HiddenGain(self._delay, 0.1) + + +class NoSlewPingPong(PingPongDelay): + """Section 8.5's walk: the slew off, so a Time move jumps. Red on the + pitch clause, and on the step bar on a rising move only.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + self._delay.set(delay_slew=0.0) + + +class FrozenFilterPingPong(PingPongDelay): + """Section 8.11, as first frozen: each loop filter's out stop hands 0 + whatever came before, which freezes the node's filter state.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + self._tone_used = False + self._cut_used = False + PingPongDelay._refresh(self) + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def to_source(values, channels=2, rate=RATE): + """A mono sequence, copied to every channel.""" + x = np.clip(np.round(np.asarray(values, dtype=np.float64)), + -32768, 32767).astype(np.int16) + data = array("h", np.repeat(x[:, None], channels, axis=1) + .reshape(-1).tobytes()) + return probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), data + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def pull(effect, frames, channels=None, on_block=None): + """(frames, channels) int16; `on_block(frame)` runs before each block.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], + dtype=np.int16).reshape(-1, channels) + + +def render(cls, values, rate=RATE, channels=2, macros=None, **options): + """`values` through `cls` built with `options`, then `macros` + ({index: MIDI}) set, so a macro can be held over a patch.""" + source, _ = to_source(values, channels, rate) + effect = cls(source, sample_rate=rate, **options) + for index in sorted(macros or {}): + effect.set_macro(index, macros[index]) + return pull(effect, len(values), channels) + + +def click(frames, level=20000): + x = np.zeros(frames) + x[0] = level + return x + + +def burst(rate, ms=50.0, peak=8192): + """T3's 50 ms deterministic noise burst: numpy `RandomState(12345)`, + uniform, peak 8 192 LSB (-12 dBFS).""" + n = int(round(ms * rate / 1000.0)) + return np.random.RandomState(12345).uniform(-peak, peak, n) + + +def material(name, rate, channels): + """A kit probe as it ships, (frames, channels) int16.""" + path = os.path.join(PROBES, str(rate), "%dch" % channels, name + ".wav") + with wave.open(path, "rb") as handle: + data = handle.readframes(handle.getnframes()) + return np.frombuffer(data, dtype=np.int16).reshape(-1, channels) + + +def render_pcm(cls, pcm, rate, macros=None, **options): + channels = pcm.shape[1] + source = probes.ArraySource(array("h", pcm.reshape(-1).tobytes()), + rate=rate, channels=channels, block=BLOCK) + effect = cls(source, sample_rate=rate, **options) + for index in sorted(macros or {}): + effect.set_macro(index, macros[index]) + return pull(effect, pcm.shape[0], channels) + + +def cell_frames(options, macros=None, rate=RATE): + """The cell's whole-frame T by the dossier's law: a constructor Time + as given, a patch or a Time macro at its grid value.""" + macros = macros or {} + if TIME_I in macros: + return law_frames(law_time_ms(macros[TIME_I]), rate) + if "patch" in options: + midi = PingPongDelay.PATCHES[options["patch"]][1] + return law_frames(law_time_ms(midi[TIME_I]), rate) + return law_frames(options.get("time_ms", 280.0), rate) + + +def cell_feedback(options, macros=None): + macros = macros or {} + if FEEDBACK_I in macros: + return law_feedback(macros[FEEDBACK_I]) + if "patch" in options: + return law_feedback(PingPongDelay.PATCHES[options["patch"]][1][1]) + return options.get("feedback", 0.45) + + +# -------------------------------------------------------------------------- +# T1: repeats alternate + + +def windows(y, T, rate, repeats=8): + """(own channel, opposite channel, own peak, peak offset) per repeat, + in +-1 ms windows at n T, First Side left: odd n on channel 0.""" + half = int(math.floor(rate / 1000.0)) + rows = [] + for n in range(1, repeats + 1): + at = n * T + lo, hi = max(0, at - half), min(len(y), at + half + 1) + own = 0 if n % 2 else 1 + seg = y[lo:hi].astype(np.int32) + if len(seg) == 0: + rows.append((own, 0, 0, None)) + continue + peak_at = int(np.argmax(np.abs(seg[:, own]))) + rows.append((own, int(np.count_nonzero(seg[:, 1 - own])), + int(np.abs(seg[:, own]).max()), lo + peak_at - at)) + return rows + + +def t1_measure(cls, rate=RATE, macros=None, peak_clause=True, **options): + """T1 at one cell: the click (20 000 LSB, both channels) at Mix 2 and + Spread 1; per repeat 1-8 the opposite channel exact zero in its +-1 ms + window, its own peak at least 200 LSB, and (with Repeat Tone out) the + peak on n T; and the First Side right render the left one swapped.""" + options = dict(options) + options.setdefault("mix", 2.0) + options.setdefault("spread", 1.0) + T = cell_frames(options, macros, rate) + frames = 9 * T + int(0.02 * rate) + x = click(frames) + left = render(cls, x, rate, 2, macros=macros, first_side="left", + **options) + right = render(cls, x, rate, 2, macros=macros, first_side="right", + **options) + rows = windows(left, T, rate) + leaks = sum(row[1] for row in rows) + weak = [n + 1 for n, row in enumerate(rows) if row[2] < FLOOR] + late = [n + 1 for n, row in enumerate(rows) + if row[3] is None or row[3] != 0] + swapped = int(np.count_nonzero(right != left[:, ::-1])) + passed = (leaks == 0 and not weak and swapped == 0 + and (not peak_clause or not late)) + return {"passed": passed, "leaks": leaks, "weak": weak, "late": late, + "swapped": swapped, "peaks": [row[2] for row in rows], + "offsets": [row[3] for row in rows], "T": T} + + +def t1_default(cls): + return t1_measure(cls, feedback=0.6) + + +# -------------------------------------------------------------------------- +# T2: one decay ratio across the alternation + + +def t2_measure(cls, rate=RATE, fit=True, **options): + """Peaks of repeats 1-8 in time order across both channels; each + successive ratio within 1 % of f, and (from Feedback MIDI 28) the two + channels' fitted decays per 2T within 1 % of each other. Only peaks of + at least 200 LSB enter; fewer than three, or fewer than two on a + channel for the fit, reads red.""" + options = dict(options) + options.setdefault("mix", 2.0) + options.setdefault("spread", 1.0) + f = cell_feedback(options) + T = cell_frames(options, None, rate) + frames = 9 * T + int(0.02 * rate) + y = render(cls, click(frames), rate, 2, **options) + rows = windows(y, T, rate) + peaks = [row[2] for row in rows] + kept = [p for p in peaks if p >= FLOOR] + # Peaks in time order stop at the first one under the floor. + run = [] + for p in peaks: + if p < FLOOR: + break + run.append(p) + ratios = [run[k + 1] / float(run[k]) for k in range(len(run) - 1)] + worst = max([abs(r / f - 1.0) for r in ratios] or [float("inf")]) + ok_ratio = len(run) >= 3 and worst <= 0.01 + agree = None + ok_fit = True + if fit: + decays = [] + for own in (0, 1): + n = [k + 1 for k, row in enumerate(rows) + if row[0] == own and row[2] >= FLOOR] + if len(n) < 2: + decays.append(None) + continue + logs = [math.log(rows[k - 1][2]) for k in n] + slope = np.polyfit(n, logs, 1)[0] + decays.append(math.exp(2.0 * slope)) + if None in decays: + ok_fit = False + else: + agree = abs(decays[0] / decays[1] - 1.0) + ok_fit = agree <= 0.01 + one_channel = [rows[k][2] for k in range(0, 8, 2) if rows[k][2] >= FLOOR] + down = (one_channel[1] / float(one_channel[0]) + if len(one_channel) >= 2 else None) + return {"passed": ok_ratio and ok_fit, "worst": worst, "agree": agree, + "kept": len(kept), "peaks": peaks, "down_one_channel": down} + + +def t2_default(cls): + return t2_measure(cls) + + +# -------------------------------------------------------------------------- +# T3: the mono sum is an ordinary delay, exactly + + +def reference(x, rate, T, f, damping=0.0, cut=0.0): + """A plain one-channel `FeedbackDelay(cross_feed=0, input_pan=0)` at + whole-frame T and f, Mix 2.""" + node = pp.audioecho.FeedbackDelay( + sample_rate=rate, channel_count=1, max_delay_ms=1001.0, + delay_ms=T * 1000.0 / rate, feedback=f, mix=2.0, + damping_hz=damping, cut_hz=cut, cross_feed=0.0, input_pan=0.0) + source, _ = to_source(x, 1, rate) + node.play(source) + out = array("h") + while len(out) < len(x): + out.extend(memoryview(bytes(audiocore.get_buffer(node)[1])) + .cast("h")) + node.deinit() + return np.array(out[:len(x)], dtype=np.int32) + + +def t3_measure(cls, rate=RATE, what="click", damping=0.0, cut=0.0, + **options): + """The class's stereo render summed in int32, the class's mono render + and the plain reference, compared sample for sample at Mix 2 (at + Spread 0 the sum is twice the reference).""" + options = dict(options) + macros = None + if "patch" in options: + # The patch's own Mix is 0.2992: Mix is held at 2 over it. + macros = {MIX_I: 127} + else: + options.setdefault("mix", 2.0) + options.setdefault("spread", 1.0) + T = cell_frames(options, None, rate) + f = cell_feedback(options) + frames = 9 * T + int(0.1 * rate) + x = np.zeros(frames) + if what == "click": + x[0] = 20000 + elif what == "quiet": + x[0] = 328 + else: + b = np.round(burst(rate)) + x[:len(b)] = b + stereo = render(cls, x, rate, 2, macros=macros, + **options).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + mono = render(cls, x, rate, 1, macros=macros, + **options)[:, 0].astype(np.int32) + ref = reference(x, rate, T, f, damping, cut) + scale = 2 if options["spread"] == 0.0 else 1 + sum_diff = int(np.count_nonzero(total != scale * ref)) + mono_diff = int(np.count_nonzero(mono != ref)) + repeats_ref = int(np.count_nonzero(ref[1:])) + repeats_mono = int(np.count_nonzero(mono[1:])) + passed = sum_diff == 0 and mono_diff == 0 and repeats_mono >= repeats_ref + return {"passed": passed, "sum_diff": sum_diff, "mono_diff": mono_diff, + "repeats": (repeats_mono, repeats_ref)} + + +def t3_default(cls): + return t3_measure(cls, feedback=0.6) + + +# -------------------------------------------------------------------------- +# T4: Spread's law + + +def t4_measure(cls, rate=RATE, spreads=None, time_ms=280.0, feedback=0.6, + level=20000, swap=False): + """On the channel-identical click at Mix 2: every repeat 1-8 on both + channels within 4 LSB of the law (values under 4 LSB recorded, not + claimed), L - R exact zero at Spread 0, the wrong-multiple windows exact + zero at Spread 1, and (`swap`) the First Side right render the left one + swapped.""" + spreads = [k / 10.0 for k in range(11)] if spreads is None else spreads + T = law_frames(time_ms, rate) + frames = 9 * T + int(0.02 * rate) + x = click(frames, level) + worst = 0.0 + red = [] + for s in spreads: + left = render(cls, x, rate, 2, time_ms=time_ms, feedback=feedback, + mix=2.0, spread=s, first_side="left") + for n in range(1, 9): + for channel, first in ((0, True), (1, False)): + law = level * law_spread(n, s, feedback, first) + got = float(left[n * T, channel]) + if abs(law) < 4.0 and law != 0.0: + continue + if law == 0.0 and got != 0.0: + red.append(("zero", s, n, channel, got)) + error = abs(got - law) + worst = max(worst, error) + if error > 4.0: + red.append(("law", s, n, channel, got, law)) + if s == 0.0: + lr = int(np.count_nonzero(left[:, 0] != left[:, 1])) + if lr: + red.append(("L-R", s, lr)) + if s == 1.0: + leaks = sum(row[1] for row in windows(left, T, rate)) + if leaks: + red.append(("wrong multiple", s, leaks)) + if swap: + right = render(cls, x, rate, 2, time_ms=time_ms, + feedback=feedback, mix=2.0, spread=s, + first_side="right") + differ = int(np.count_nonzero(right != left[:, ::-1])) + if differ: + red.append(("swap", s, differ)) + return {"passed": not red, "worst": worst, "red": red} + + +def t4_default(cls): + return t4_measure(cls, spreads=(0.0, 0.5, 1.0)) + + +# -------------------------------------------------------------------------- +# T5: the dry path is a wire until the first repeat + + +T5_MATERIALS = ("ramp_fs", "tones_step", "sweep_log") + + +def t5_measure(cls, rate=RATE, channels=2, materials=T5_MATERIALS, + macros=None, **options): + """WIRE over the first T - 1 frames on each material, and, where Mix is + above 0, the output not the source somewhere in the T frames from + frame T.""" + T = cell_frames(options, macros, rate) + probe = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + for index in sorted(macros or {}): + probe.set_macro(index, macros[index]) + mix = probe.macro(MIX_I) + probe.deinit() + differing = 0 + absent = [] + for name in materials: + pcm = material(name, rate, channels) + end = min(len(pcm), 2 * T) + y = render_pcm(cls, pcm[:end], rate, macros=macros, **options) + differing += int(np.count_nonzero(y[:T - 1] != pcm[:T - 1])) + if mix > 0.0 and len(pcm) > T: + if not np.count_nonzero(y[T:end] != pcm[T:end]): + absent.append(name) + return {"passed": differing == 0 and not absent, + "differing": differing, "absent": absent, "T": T} + + +def t5_default(cls): + return t5_measure(cls, materials=("tones_step",)) + + +# -------------------------------------------------------------------------- +# The walk (section 8.5), measured as the dossier's A6.6 did + + +def walk_cents(cls, rate=RATE, start_ms=280.0, target_ms=200.0): + """997 Hz at 12 000 LSB, Mix 2, Feedback 0; Time moved on a block + boundary at least 8 192 frames after the start Time's wet begins; the + pitch over the walk by the kit's `instantaneous_hz`, median over the + walk less 400 frames each end. (cents, walk frames).""" + T0 = law_frames(start_ms, rate) + move = ((T0 + 8192) // BLOCK + 1) * BLOCK + walk = int(round(abs(target_ms - start_ms) / 1000.0 / pp.SLEW * rate)) + frames = move + walk + rate // 2 + x = 12000 * np.sin(2.0 * math.pi * 997.0 * np.arange(frames) / rate) + source, _ = to_source(x, 2, rate) + effect = cls(source, sample_rate=rate, time_ms=start_ms, feedback=0.0, + mix=2.0) + target_midi = 127.0 * math.log(target_ms / 20.0) / math.log(50.0) + + def on_block(frame): + if frame == move: + effect.set_macro(TIME_I, target_midi) + + y = pull(effect, frames, 2, on_block)[:, 0].astype(float) + hz = instantaneous_hz(y, rate) + segment = hz[move + 400:move + walk - 400] + return 1200.0 * math.log(float(np.median(segment)) / 997.0, 2.0), walk + + +def law_cents(start_ms, target_ms): + ratio = 1.0 + pp.SLEW if target_ms < start_ms else 1.0 - pp.SLEW + return 1200.0 * math.log(ratio, 2.0) + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed at the position walked + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node as `_handed` (the latest value of each).""" + + def __enter__(self): + node_class = pp.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._handed.update(options) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def handed(effect, name): + return effect._delay._handed[name] + + +def read_cross_pan(effect): + return (round(float(handed(effect, "cross_feed")), 6), + round(float(handed(effect, "input_pan")), 6)) + + +def read_frames(effect): + return round(float(handed(effect, "delay_ms")) * effect._sample_rate + / 1000.0, 3) + + +def read_feedback(effect): + return round(float(handed(effect, "feedback")), 4) + + +def read_slew(effect): + return float(handed(effect, "delay_slew")) + + +def read_dry_gain(effect): + """The dry path's gain before the first repeat: a copy at these + positions on a 256-frame full-scale ramp, least-squares out / in.""" + ramp = probes.ramp_fs(frames=256, channels=effect.channel_count) + other = type(effect)(probes.ArraySource( + ramp, rate=effect._sample_rate, channels=effect.channel_count, + block=BLOCK), sample_rate=effect._sample_rate) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + out = pull(other, 256).reshape(-1).astype(np.float64) + src = np.array(ramp, dtype=np.float64) + other.deinit() + return round(float(np.dot(out, src) / np.dot(src, src)), 5) + + +#: The fine grid every handed-value walk also runs on: quarter steps, 509 +#: positions per macro. +FINE = tuple(i / 4.0 for i in range(509)) + +#: (name, fault, reading, channels). Every walk runs at 48, 44.1 and +#: 22.05 kHz on the kit's grid, and the handed-value walks on `FINE` too. +REACH_WALKS = ( + ("LossyCrossPingPong", LossyCrossPingPong, read_cross_pan, 2), + ("LateReadPingPong", LateReadPingPong, read_frames, 2), + ("ScaledFeedbackPingPong", ScaledFeedbackPingPong, read_feedback, 2), + ("MonoStereoSettingsPingPong", MonoStereoSettingsPingPong, + read_cross_pan, 1), + ("PanlessPingPong", PanlessPingPong, read_cross_pan, 2), + ("NoSlewPingPong", NoSlewPingPong, read_slew, 2), +) + + +def reach(faulted, reading, rate, channels=2, grid=None): + def build(cls): + return cls(silence_src(512, channels, rate), sample_rate=rate) + + with NodeSpy(): + return kit_faults.fault_reachability(PingPongDelay, faulted, reading, + build, grid=grid) + + +def spied(**options): + rate = options.pop("rate", RATE) + channels = options.pop("channels", 2) + with NodeSpy(): + effect = PingPongDelay(silence_src(512, channels, rate), + sample_rate=rate, **options) + return effect + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + """Every node `set` in these tests is recorded, so a macro moved after + construction reads back what the node was handed.""" + + def setUp(self): + self._spy = NodeSpy().__enter__() + + def tearDown(self): + self._spy.__exit__(None, None, None) + + def test_macros_patches_tier_latency(self): + cls = PingPongDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Feedback", "Mix", "Spread", "First Side", + "Sync", "Division", "Repeat Tone", "Repeat Cut")) + self.assertEqual(tuple(cls.MACRO_MODES[i] for i in range(9)), + DOSSIER_MODES) + self.assertEqual(len(cls.PATCHES), 7) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ("tempo_sync",)) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_parked_not_served(self): + import audioeffects + self.assertNotIn("PingPongDelay", rebuilt.ADOPTED) + self.assertIn("PingPongDelay", rebuilt.parked()) + self.assertIsNot(audioeffects.PingPongDelay, PingPongDelay) + + def test_patches_are_the_dossier_settings_on_the_grid(self): + for index, (name, values) in enumerate(DOSSIER_PATCHES): + label, midi = PingPongDelay.PATCHES[index] + self.assertEqual(label, name) + self.assertEqual(midi, tuple( + _component.macro_of(span, value, mode) + for span, value, mode in zip(DOSSIER_SPANS, values, + DOSSIER_MODES))) + + def test_patch_0_is_the_constructor_grid(self): + effect = PingPongDelay(silence_src(512), sample_rate=RATE) + for index, expected in enumerate(PingPongDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_what_patch_0_hands_the_node(self): + # Tier 3's reference patch on the grid: 13 575 frames, 0.4521, + # 0.2992, the full cross, both filters out. + effect = spied(patch=0) + self.assertEqual(read_frames(effect), 13575.0) + self.assertEqual(read_feedback(effect), 0.4521) + self.assertAlmostEqual(handed(effect, "mix"), 0.2992, places=4) + self.assertEqual(read_cross_pan(effect), (1.0, -1.0)) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertEqual(handed(effect, "delay_slew"), 0.1875) + + def test_time_lands_on_a_whole_frame(self): + for rate in RATES: + effect = spied(rate=rate) + self.assertEqual(read_frames(effect), law_frames(280.0, rate)) + for midi in [127.0 * k / 16.0 for k in range(17)]: + effect.set_macro(TIME_I, midi) + self.assertEqual(read_frames(effect), + law_frames(law_time_ms(midi), rate), + (rate, midi)) + # The stops at 48 kHz: 960 and 48 000 frames. + effect = spied() + effect.set_macro(TIME_I, 0) + self.assertEqual(read_frames(effect), 960.0) + effect.set_macro(TIME_I, 127) + self.assertEqual(read_frames(effect), 48000.0) + + def test_a_host_echoing_time_keeps_the_frame(self): + # 135 ms at 44.1 kHz is 5 953.5 frames, which the law lands up. + effect = spied(rate=44100, time_ms=135.0) + self.assertEqual(read_frames(effect), 5954.0) + effect.set_macro(TIME_I, effect.get_macro(TIME_I)) + self.assertEqual(read_frames(effect), 5954.0) + + def test_a_lowered_ceiling_clamps_visibly(self): + effect = spied(max_time_ms=300.0) + for midi in (100, 110, 127): + effect.set_macro(TIME_I, midi) + self.assertEqual(read_frames(effect), 14400.0) + self.assertAlmostEqual(effect.get_macro(TIME_I), 87.91, + delta=0.005) + self.assertEqual(spied(max_time_ms=float("nan"))._max_time_ms, + 1000.0) + self.assertEqual(spied(max_time_ms=5000.0)._max_time_ms, 1000.0) + + def test_sync_quantises_time_and_clamps(self): + def at_120(): + return (True, 0.0, 120.0, 4, 4) + + expected = {0: 3000, 6: 12000, 9: 24000, 12: 48000, 15: 48000} + for index, frames in expected.items(): + with NodeSpy(): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=at_120, sync=True, + division=index) + self.assertEqual(read_frames(effect), float(frames), index) + self.assertAlmostEqual(effect.get_macro(TIME_I), 127.0, places=6) + # The static transport, and hosts whose tempo is not finite and + # positive, leave Time on the knob. + for transport in (None, lambda: (True, 0.0, 0.0, 4, 4), + lambda: (True, 0.0, float("nan"), 4, 4), + lambda: (True, 0.0, float("inf"), 4, 4)): + with NodeSpy(): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=transport, sync=True) + self.assertEqual(read_frames(effect), 13440.0) + + def test_sync_patches_follow_the_beat(self): + def at_120(): + return (True, 0.0, 120.0, 4, 4) + + with NodeSpy(): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=at_120) + effect.program_change(1) + self.assertEqual(read_frames(effect), 12000.0) + effect.program_change(2) + self.assertEqual(read_frames(effect), 24000.0) + effect.program_change(0) + self.assertEqual(read_frames(effect), 13575.0) + + def test_spread_and_first_side_hand_the_pair(self): + effect = spied() + for k in range(11): + effect.set_macro(SPREAD_I, 12.7 * k) + s = handed(effect, "cross_feed") + self.assertAlmostEqual(s, k / 10.0, places=12) + self.assertEqual(handed(effect, "input_pan"), -s) + effect.set_macro(SIDE_I, 127) + self.assertEqual(handed(effect, "input_pan"), s) + effect.set_macro(SIDE_I, 0) + # Mono: inert, (0, 0) at every position and patch. + mono = spied(channels=1) + for index in (SPREAD_I, SIDE_I): + for midi in (0, 64, 127): + mono.set_macro(index, midi) + self.assertEqual(read_cross_pan(mono), (0.0, 0.0)) + for patch in PingPongDelay.PATCHES: + mono.program_change(patch) + self.assertEqual(read_cross_pan(mono), (0.0, 0.0)) + + def test_the_filter_stops(self): + for rate in RATES: + effect = spied(rate=rate) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + effect.set_macro(TONE_I, 126) + corner = 800.0 * 20.0 ** (126 / 127.0) + corner = min(corner, rate * 0.5 * 0.98) + self.assertAlmostEqual(handed(effect, "damping_hz"), + nominal_damping_hz(corner, rate), + places=6) + # Out after in: 32 x the rate, the state on the tap. + effect.set_macro(TONE_I, 127) + self.assertEqual(handed(effect, "damping_hz"), 32.0 * rate) + effect.set_macro(CUT_I, 1) + in_circuit = handed(effect, "cut_hz") + self.assertGreater(in_circuit, 0.0) + # Out after in: the 20 Hz corner, pre-warped, still in circuit. + effect.set_macro(CUT_I, 0) + bottom = handed(effect, "cut_hz") + self.assertAlmostEqual(bottom, + pp.nominal_cut_hz(20.0, rate), places=9) + self.assertLess(bottom, in_circuit) + self.assertIsNone(effect.tail_samples) + # A reset brings both exact outs back. + effect.reset() + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertIsNotNone(effect.tail_samples) + + def test_a_constructor_filter_counts_as_having_been_in(self): + effect = spied(tone_hz=5000.0, cut_hz=100.0) + effect.program_change(0) + self.assertEqual(handed(effect, "damping_hz"), 32.0 * RATE) + self.assertAlmostEqual(handed(effect, "cut_hz"), + pp.nominal_cut_hz(20.0, RATE), places=9) + effect = spied(patch=5) + effect.program_change(0) + self.assertEqual(handed(effect, "damping_hz"), 32.0 * RATE) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + + def test_repeat_tone_clamps_at_22k(self): + effect = spied(rate=22050) + values = set() + for midi in range(111, 127): + effect.set_macro(TONE_I, midi) + values.add(handed(effect, "damping_hz")) + self.assertEqual(len(values), 1) + effect.set_macro(TONE_I, 110) + self.assertNotIn(handed(effect, "damping_hz"), values) + + def test_the_walk_bends_by_the_law_and_the_jump_does_not(self): + for start, target in ((280.0, 200.0), (200.0, 280.0)): + law = law_cents(start, target) + got, walk = walk_cents(PingPongDelay, RATE, start, target) + self.assertAlmostEqual(got, law, delta=1.5, msg=(start, target)) + self.assertEqual(walk, 20480) # 427 ms at 48 kHz + jumped, _ = walk_cents(NoSlewPingPong, RATE, start, target) + self.assertLess(abs(jumped), 1.0, (start, target)) + + def test_tail_samples_follows_time_feedback_and_tone(self): + self.assertEqual(spied().tail_samples, 14 * 13441) + self.assertEqual(spied(patch=0).tail_samples, 190064) + self.assertEqual(spied(patch=5).tail_samples, 487944) + self.assertEqual(spied(time_ms=1000.0, feedback=0.99).tail_samples, + 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) + + def test_constructor_clamps_and_nan(self): + nan = float("nan") + effect = spied(time_ms=nan, feedback=nan, mix=nan, spread=nan, + division=nan, tone_hz=nan, cut_hz=nan) + self.assertEqual(read_frames(effect), 13440.0) + self.assertEqual(read_feedback(effect), 0.45) + self.assertAlmostEqual(handed(effect, "mix"), 0.3) + self.assertEqual(read_cross_pan(effect), (1.0, -1.0)) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + effect = spied(time_ms=0.0, feedback=2.0, mix=-1.0, spread=3.0, + tone_hz=0.0, cut_hz=-5.0) + self.assertEqual(read_frames(effect), 960.0) + self.assertEqual(read_feedback(effect), 0.99) + self.assertEqual(handed(effect, "mix"), 0.0) + self.assertEqual(read_cross_pan(effect), (1.0, -1.0)) + self.assertEqual(handed(effect, "damping_hz"), 0.0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertEqual(read_frames(spied(time_ms=5000.0)), 48000.0) + self.assertEqual(read_cross_pan(spied(first_side="Right")), + (1.0, 1.0)) + with self.assertRaises(ValueError): + spied(first_side="middle") + + +# -------------------------------------------------------------------------- +# The Tier 2 rows + + +class T1RepeatsAlternate(unittest.TestCase): + def test_the_three_feedbacks_at_three_rates(self): + for rate in RATES: + for f in (0.6, 0.85, 0.99): + result = t1_measure(PingPongDelay, rate, feedback=f) + self.assertTrue(result["passed"], (rate, f, result)) + + def test_the_time_stops_and_the_patch_cells(self): + for options, macros in (({"time_ms": 20.0}, None), + ({"time_ms": 1000.0}, None), + ({}, {TIME_I: 63.5}), + ({}, {TIME_I: 86}), + ({}, {TIME_I: 99})): + result = t1_measure(PingPongDelay, feedback=0.85, + macros=macros, **options) + self.assertTrue(result["passed"], (options, macros, result)) + + def test_the_lowest_claimed_feedback(self): + for rate in RATES: + result = t1_measure(PingPongDelay, rate, time_ms=20.0, + macros={FEEDBACK_I: 67}) + self.assertTrue(result["passed"], (rate, result)) + self.assertGreaterEqual(min(result["peaks"]), FLOOR) + + def test_repeat_cut_from_60_ms(self): + for rate in RATES: + for midi in (1, 32, 127): + result = t1_measure(PingPongDelay, rate, time_ms=60.0, + feedback=0.99, macros={CUT_I: midi}) + self.assertTrue(result["passed"], (rate, midi, result)) + + def test_repeat_tone_in_keeps_the_exclusion_not_the_peak(self): + result = t1_measure(PingPongDelay, feedback=0.6, + macros={TONE_I: 0}, peak_clause=False) + self.assertEqual(result["leaks"], 0) + self.assertEqual(result["swapped"], 0) + # Named in advance: presence past repeat 3 and the peak position. + self.assertEqual(result["weak"][0], 4) + self.assertTrue(result["late"]) + + def test_a_lossy_cross_is_red(self): + for rate in RATES: + result = t1_measure(LossyCrossPingPong, rate, feedback=0.6) + self.assertFalse(result["passed"], rate) + self.assertGreater(result["leaks"], 0) + + def test_a_late_read_is_red_on_the_peak(self): + for rate in RATES: + result = t1_measure(LateReadPingPong, rate, feedback=0.6) + self.assertFalse(result["passed"], rate) + self.assertEqual(result["leaks"], 0) + self.assertEqual(result["offsets"][:4], [1, 2, 3, 4]) + + +class T2OneDecayRatio(unittest.TestCase): + def test_the_constructor_feedbacks_at_the_named_times(self): + for rate in RATES: + for f in (0.6, 0.85): + for time_ms in (20.0, 280.0): + result = t2_measure(PingPongDelay, rate, feedback=f, + time_ms=time_ms) + self.assertTrue(result["passed"], + (rate, f, time_ms, result)) + + def test_the_feedback_span_ends(self): + for midi, fit in ((13, False), (28, True), (60, True), (127, True)): + result = t2_measure(PingPongDelay, fit=fit, + feedback=law_feedback(midi)) + self.assertTrue(result["passed"], (midi, result)) + # Below MIDI 28 the other channel has one peak: the fit reads red. + self.assertFalse(t2_measure(PingPongDelay, + feedback=law_feedback(27))["passed"]) + + def test_reading_down_one_channel_returns_f_squared(self): + # The seeds' fault is the measurement's own: why the ratio is taken + # across both channels. + for f in (0.6, 0.85): + result = t2_measure(PingPongDelay, feedback=f) + self.assertAlmostEqual(result["down_one_channel"], f * f, + delta=0.001) + + def test_a_scaled_feedback_is_red(self): + for rate in RATES: + for f in (0.6, 0.85): + result = t2_measure(ScaledFeedbackPingPong, rate, feedback=f) + self.assertFalse(result["passed"], (rate, f)) + self.assertGreater(result["worst"], 0.019) + + +class T3MonoSum(unittest.TestCase): + def test_the_named_pairs_at_three_rates(self): + for rate in RATES: + for options in ({"feedback": 0.45}, {"feedback": 0.6}, + {"feedback": 0.99}, + {"time_ms": 20.0, "feedback": 0.85}, + {"patch": 0}): + result = t3_measure(PingPongDelay, rate, **options) + self.assertTrue(result["passed"], (rate, options, result)) + + def test_the_quiet_click_the_burst_and_first_side_right(self): + for what in ("quiet", "burst"): + result = t3_measure(PingPongDelay, what=what, feedback=0.85) + self.assertTrue(result["passed"], (what, result)) + result = t3_measure(PingPongDelay, feedback=0.6, first_side="right") + self.assertTrue(result["passed"], result) + + def test_spread_0_is_twice_the_reference(self): + result = t3_measure(PingPongDelay, feedback=0.6, spread=0.0) + self.assertTrue(result["passed"], result) + + def test_the_loop_filters_read_exact(self): + damping = nominal_damping_hz(800.0, RATE) + result = t3_measure(PingPongDelay, feedback=0.6, tone_hz=800.0, + damping=damping) + self.assertTrue(result["passed"], result) + cut = pp.nominal_cut_hz(400.0, RATE) + result = t3_measure(PingPongDelay, feedback=0.6, cut_hz=400.0, + cut=cut) + self.assertTrue(result["passed"], result) + + def test_the_mono_stereo_settings_are_red(self): + for rate in RATES: + result = t3_measure(MonoStereoSettingsPingPong, rate, + feedback=0.6) + self.assertFalse(result["passed"], rate) + self.assertEqual(result["sum_diff"], 0) + self.assertLess(result["repeats"][0], result["repeats"][1]) + + +class T4SpreadLaw(unittest.TestCase): + def test_the_eleven_positions(self): + for rate in RATES: + for f in (0.3, 0.99): + result = t4_measure(PingPongDelay, rate, feedback=f) + self.assertTrue(result["passed"], (rate, f, result["red"])) + self.assertLessEqual(result["worst"], 1.5) + + def test_the_short_time_and_the_quiet_click(self): + result = t4_measure(PingPongDelay, time_ms=20.0, feedback=0.85) + self.assertTrue(result["passed"], result["red"]) + result = t4_measure(PingPongDelay, level=328) + self.assertTrue(result["passed"], result["red"]) + + def test_first_side_right_is_the_swap_at_every_position(self): + result = t4_measure(PingPongDelay, swap=True) + self.assertTrue(result["passed"], result["red"]) + + def test_the_panless_spread_is_red(self): + for rate in RATES: + result = t4_measure(PanlessPingPong, rate, spreads=(0.5, 1.0)) + self.assertFalse(result["passed"], rate) + self.assertGreaterEqual(result["worst"], 19999.0) + + +class T5DryPath(unittest.TestCase): + def test_the_defaults_stereo_and_mono(self): + for rate in RATES: + for channels in (2, 1): + result = t5_measure(PingPongDelay, rate, channels) + self.assertTrue(result["passed"], (rate, channels, result)) + + def test_the_named_cells(self): + cells = ( + ({"mix": 0.2}, None), + ({"mix": 0.2, "spread": 0.5}, None), + ({"mix": 0.2, "first_side": "right"}, None), + ({"feedback": 0.99, "tone_hz": 800.0, "cut_hz": 400.0, + "mix": 1.0}, None), + ({"time_ms": 20.0}, None), + ({}, {MIX_I: 63}), + ) + for options, macros in cells: + result = t5_measure(PingPongDelay, macros=macros, **options) + self.assertTrue(result["passed"], (options, macros, result)) + for patch in PingPongDelay.PATCHES: + result = t5_measure(PingPongDelay, patch=patch, + materials=("tones_step",)) + self.assertTrue(result["passed"], (patch, result)) + + def test_mix_0_is_a_wire_without_presence(self): + result = t5_measure(PingPongDelay, mix=0.0) + self.assertTrue(result["passed"], result) + + def test_a_dry_gain_is_red(self): + for rate in RATES: + for channels in (2, 1): + for options in ({}, {"mix": 0.2}): + result = t5_measure(DryGainPingPong, rate, channels, + **options) + self.assertFalse(result["passed"], + (rate, channels, options)) + self.assertGreater(result["differing"], 0) + + +# -------------------------------------------------------------------------- +# Tier 1 + + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for rate in RATES: + for channels in (2, 1): + ramp = probes.ramp_fs(frames=4 * 13440, channels=channels) + src = np.array(ramp, dtype=np.int16).reshape(-1, channels) + for options in ({"mix": 0.0}, + {"mix": 0.0, "feedback": 0.99, + "time_ms": 20.0, "tone_hz": 800.0}): + y = render_pcm(PingPongDelay, src, rate, **options) + self.assertEqual(int(np.count_nonzero(y != src)), 0, + (rate, channels, options)) + + def test_silence_stays_silence(self): + for rate in RATES: + y = render(PingPongDelay, np.zeros(rate), rate, + feedback=0.99, tone_hz=800.0, cut_hz=400.0) + self.assertEqual(int(np.count_nonzero(y)), 0) + + def _round_trip(self, cls, index, first, back, rate, channels=2): + """300 Hz at 30 000 LSB for 0.5 s with the filter in, the filter + out as the input stops, 2 s of silence, the filter back in; the + largest sample after the return (Wide Bounce's Time, Feedback 0, + Mix 2).""" + on = rate // 2 + frames = on + 2 * rate + rate // 2 + x = np.zeros(frames) + x[:on] = 30000 * np.sin(2.0 * math.pi * 300.0 * np.arange(on) + / rate) + source, _ = to_source(x, channels, rate) + effect = cls(source, sample_rate=rate, feedback=0.0, mix=2.0) + effect.set_macro(index, first) + rest = (on // BLOCK + 1) * BLOCK + back_at = rest + 2 * rate // BLOCK * BLOCK + + def on_block(frame): + if frame == rest: + effect.set_macro(index, 127 if index == TONE_I else 0) + elif frame == back_at: + effect.set_macro(index, back) + + y = pull(effect, frames, channels, on_block) + return int(np.abs(y[back_at:].astype(np.int32)).max()) + + def test_a_filter_back_in_after_silence_stays_silent(self): + tone_2k = 127.0 * math.log(2000.0 / 800.0) / math.log(20.0) + for rate in RATES: + for channels in (2, 1): + self.assertEqual(self._round_trip( + PingPongDelay, TONE_I, tone_2k, 0, rate, channels), 0) + self.assertEqual(self._round_trip( + PingPongDelay, CUT_I, 127, 1, rate, channels), 0) + # As first frozen, handing 0 at the out stop plays the frozen state. + self.assertGreater(self._round_trip( + FrozenFilterPingPong, TONE_I, tone_2k, 0, RATE), 10000) + self.assertGreater(self._round_trip( + FrozenFilterPingPong, CUT_I, 127, 1, RATE), 15000) + + def test_tone_out_after_tone_in_is_within_1_lsb_of_out(self): + for rate in RATES: + frames = 2 * rate + x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, + channels=1), + dtype=np.int16)[:frames].astype(float) + source, _ = to_source(x, 2, rate) + touched = PingPongDelay(source, sample_rate=rate, + feedback=0.85, mix=2.0, tone_hz=2000.0) + touched.set_macro(TONE_I, 127) + clean, _ = to_source(x, 2, rate) + plain = PingPongDelay(clean, sample_rate=rate, feedback=0.85, + mix=2.0) + a = pull(touched, frames).astype(np.int32) + b = pull(plain, frames).astype(np.int32) + self.assertLessEqual(int(np.abs(a - b).max()), 1, rate) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + on = 200 * RATE // 1000 + for options in ({}, {"patch": 0}, {"patch": 4}, {"patch": 5}, + {"patch": 6}): + probe = PingPongDelay(silence_src(64), sample_rate=RATE, + **options) + bound = probe.tail_samples + probe.deinit() + frames = on + bound + 1024 + x = np.zeros(frames) + x[:on] = 16384 * np.sin(2.0 * math.pi * 1000.0 + * np.arange(on) / RATE) + y = render(PingPongDelay, x, **options) + nonzero = np.flatnonzero(y.any(axis=1)) + last = int(nonzero[-1]) - on + 1 if len(nonzero) else 0 + self.assertLessEqual(last, bound, options) + self.assertGreater(last, bound // 2, options) + + def test_a_full_scale_fill_at_every_spread(self): + T = law_frames(20.0, RATE) + fill = 4 * T + for f in (0.45, 0.85): + for spread in (1.0, 0.5, 0.0): + for mix in (0.3, 2.0): + probe = PingPongDelay(silence_src(64), sample_rate=RATE, + time_ms=20.0, feedback=f, + spread=spread, mix=mix) + bound = probe.tail_samples + probe.deinit() + x = np.zeros(fill + bound + 1024) + x[:fill] = 32767 + y = render(PingPongDelay, x, time_ms=20.0, feedback=f, + spread=spread, mix=mix) + last = int(np.flatnonzero(y.any(axis=1))[-1]) - fill + 1 + self.assertLessEqual(last, bound, (f, spread, mix)) + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + time_ms=1000.0) + long_tail = effect.tail_samples + effect.set_macro(TIME_I, 0) + self.assertEqual(effect.tail_samples, long_tail) + effect.reset() + self.assertEqual(effect.tail_samples, 190064) + + def test_reset_silences_a_full_line(self): + # A line full of repeats, reset, then silence in: silence out. + frames = RATE + x = np.concatenate([16384 * np.sin(2.0 * math.pi * 440.0 + * np.arange(frames) / RATE), + np.zeros(3 * frames)]) + source, _ = to_source(x, 2) + effect = PingPongDelay(source, sample_rate=RATE, feedback=0.85) + stop = (frames // BLOCK + 1) * BLOCK + seen = [] + + def on_block(frame): + if frame == stop: + effect.reset() + seen.append(frame) + + y = pull(effect, 4 * frames, 2, on_block) + self.assertEqual(seen, [stop]) + self.assertEqual(int(np.count_nonzero(y[stop:])), 0) + + def test_deinit_leaves_the_source(self): + source, _ = to_source(8000 * np.sin(2 * math.pi * 440.0 + * np.arange(1024) / RATE)) + effect = PingPongDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + with self.assertRaises(RuntimeError): + effect.tail_samples + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + y = render(PingPongDelay, click(2048), rate) + self.assertEqual(int(np.argmax(np.abs(y[:, 0]))), 0) + self.assertEqual(int(y[0, 0]), 20000) + + def test_the_transport_is_read_only_with_sync_on(self): + calls = [] + + def host(): + calls.append(1) + return (True, 0.0, 120.0, 4, 4) + + effect = PingPongDelay(silence_src(512), sample_rate=RATE, + transport=host) + self.assertEqual(calls, []) + pull(effect, 4 * BLOCK) + self.assertEqual(calls, []) + effect.set_macro(SYNC_I, 127) + self.assertEqual(len(calls), 1) + pull(effect, 4 * BLOCK) + self.assertEqual(len(calls), 1) + + +# -------------------------------------------------------------------------- +# The gate's two checks on every fault + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, reading only what the node is + handed (or, for the dry gain, a copy's dry path), at 48, 44.1 and + 22.05 kHz on the kit's grid (17 positions per macro) plus the seven + patches, and the handed-value walks on the fine grid (509) too.""" + + CHECKED = 9 * 17 + 7 + CHECKED_FINE = 9 * len(FINE) + 7 + + def test_every_fault_is_off_the_surface(self): + for name, faulted, reading, channels in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, channels) + self.assertEqual(result["checked"], self.CHECKED) + + def test_every_fault_is_off_the_fine_grid(self): + for name, faulted, reading, channels in REACH_WALKS: + for rate in RATES: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, channels, + grid=FINE) + self.assertEqual(result["checked"], self.CHECKED_FINE) + + def test_the_dry_gain_is_off_the_surface(self): + for rate in RATES: + for channels in (2, 1): + result = reach(DryGainPingPong, read_dry_gain, rate, + channels) + self.assertEqual(result["checked"], self.CHECKED) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in (("T1", t1_default), ("T2", t2_default), + ("T3", t3_default), ("T4", t4_default), + ("T5", t5_default)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + PingPongDelay, measure, label="PingPongDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() From 263d598c4c2c8389bc584a2c133d3058c8ac03bb Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:30:13 -0500 Subject: [PATCH 23/88] AnalogDelay Station B: the rebuild and its tests Built from scratch against the frozen dossier (anchor cc61011): one audioecho.FeedbackDelay, two characters (single-line N = 4096, double-line N = 8192), the corner law 0.2211 N / T pre-warped onto the loop low-pass, every Time move walked at |dT| / T_new, Time landed on its whole frame with the single-precision hand-off, a fixed-millisecond triangle for Modulation, Sync and Division, and a finite tail_samples at every setting. The tests hold T2a, T3, T6 and T7 beside their planted faults (CornerN6144, DoubleLineN6144, the jump and ConstantGlideWalk, DryGainDelay), walk every fault's reachability at three rates, and show each row red on the class built as a wire. --- lib/audioeffects/rebuilt/analogdelay.py | 600 +++++++++ tests/test_cpython_effects_analogdelay.py | 1448 +++++++++++++++++++++ 2 files changed, 2048 insertions(+) create mode 100644 lib/audioeffects/rebuilt/analogdelay.py create mode 100644 tests/test_cpython_effects_analogdelay.py diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py new file mode 100644 index 0000000..91648e0 --- /dev/null +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -0,0 +1,600 @@ +"""`AnalogDelay` - a bucket-brigade delay whose Time knob is its clock. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/AnalogDelay.md`, whose trait +table was frozen at Station A before this file existed (anchor commit +cc61011, 2026-09-28, the Station A critique revision). The old class in +`delay.py` is consulted only for the seven defects that dossier's section 7 +names; it stays the class the library serves until the auditor adopts this +one. + +**What it sounds like.** Your dry signal passes untouched, and repeats +follow it that get darker the longer you set Time, because in a bucket +brigade the Time knob is a clock: the line has a fixed number of stages, +so a longer delay is a slower clock and a lower band limit. Two characters +pick the stage count: `"single-line"` (the default) is one 4096-stage line, +the Boss DM-2; `"double-line"` is two in series, 8192 stages, the +Electro-Harmonix Deluxe Memory Man, which at the same Time runs its clock +an octave higher and so keeps its repeats an octave brighter. Time is +20-600 ms on both, Feedback 0-0.99, and Mix 0-2 (dry at unity up to 1, wet +alone at 2; Mix 0 is a wire while the line keeps recording). Turn Time +while it plays and the repeats bend in pitch the way a clock step bends +them, and settle. Modulation (0-5 ms) and Mod Rate (0.05-8 Hz) wobble the +delay on a triangle, the Memory Man's chorus and vibrato: a blend with +Modulation up is a chorus, the wet alone (Mix 2, patch 4) a vibrato. +Spread feeds each side's repeats into the other. Sync locks Time to +Division of the host's beat. + +**The standouts:** the Boss DM-2 and the Deluxe Memory Man, as the two +characters. The band limit is the one thing that separates them, so it is +the one thing the character changes. + +**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`): one +`audioecho.FeedbackDelay`, audiodsp's own node. On a stock CircuitPython +board this module imports cleanly and construction raises `ImportError`. + +**Latency: zero samples, at every setting, patch, character and rate.** +Nothing looks ahead. The delay is the wet path, not latency on the dry +path, and no option adds any. That is a decision: oversampling is the +clean way to keep a bucket-brigade model free of aliasing, and its filters +cost latency a stompbox has none to spend. + +**Mono.** A one-channel source gets the identical effect on its one +channel with Spread held at 0. At one channel the node's cross-feed sends +the repeat nowhere (Feedback 0.7 with cross-feed 1.0 leaves the dry, the +first repeat and nothing after it), so the class hands the node 0 there +whatever the knob says. The class never passes `input_pan`, which in mono +would overwrite the node's mono feed. + +**RAM.** The line is `max_time_ms + 6` ms of two int16 lanes whatever the +channel count: 116 352 B at 48 kHz for the default 600 ms (106 896 B at +44.1 kHz, 53 448 B at 22.05 kHz), plus 512 B for the triangle table and +about 1.2 KB of node. The six milliseconds are the modulation's 5 ms peak +swing and one for the read's clamp. Pass a lower `max_time_ms` to spend +less; Time then stops at that ceiling and `get_macro(0)` shows where. + +**Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on, the loop +low-pass in and a borrowed 256-point table; no mixer, the same graph for +both characters and every patch. Palette row FeedbackDelay +options (the +nearest not-cheaper row), glue 0: **P4 <= 9 %, S3 <= 15 %** of a 5.333 ms +stereo block. The board measurement is pending hardware. + +**The band limit.** The repeats' high-frequency corner is the sinc's -3 dB +point at the line's clock, 0.2211 N / T: 3019 Hz at 300 ms single-line, +6038 Hz double-line, halving each time Time doubles. It is the node's one +loop low-pass, pre-warped so its -3 dB point is that corner, so each pass +through the loop darkens the repeat once more, as the circuit's filters +do. Where the law passes 0.98 of Nyquist (below 38.5 ms single-line and +77.0 ms double-line at 48 kHz; 41.9 / 83.8 ms at 44.1 kHz; 83.8 / +167.6 ms at 22.05 kHz) the corner holds at that clamp, so there a shorter +Time no longer brightens the repeats. + +**What the class surrenders, said plainly.** Three things both pedals do +are not here. There is no sample-and-hold, so the repeats roll off on one +pole where a bucket brigade rolls off on a sinc with a null at its clock +(12.1 dB off the sinc's shape between 300 and 600 ms, and -7.7 dB, not a +null, at the clock). There is no compander, so the repeats do not pump +and a burst's rise time does not change from repeat to repeat. And there +is no fixed anti-alias and reconstruction pair: both pedals bound their +wet path near 3 kHz at every Time, so at short Times this class's repeats +are brighter than either pedal's. The line's image spectra and its clock +noise are not modelled either. None of those has a node in audiodsp v0.6.2 +that can sit inside the loop. + +**Time.** Every Time is landed on the nearest whole frame at the running +rate, and the node is handed a delay whose read's whole part is that frame +on every interpreter: the node turns milliseconds into frames in single +precision, and at 44.1 and 22.05 kHz one frame count in about seven has +no single-precision value that lands on it exactly, so the class hands the +next value up. The read then trails the frame by at most 0.00195 frames at +44.1 kHz (0.00098 at 22.05; none at 48), never early. + +**A Time move glides in pitch on the clock's own law.** A move from +`T_old` to `T_new` walks the read head at |T_new - T_old| / T_new, so it +holds the pitch ratio T_old / T_new for exactly T_new and then returns to +unity, without a click: 200 -> 100.4 ms bends the repeats +1193 cents for +100.4 ms, 100 -> 300 ms -1902 cents for 300 ms. The walk's rate is taken +from the Time last handed to the node, so a move issued while the last one +still walks runs at a rate that no longer matches the head, and the pitch +claim does not cover it. It is claimed for moves of up to 3 : 1 and inputs +from -28.7 to -0.2 dBFS; quieter, int16 rounding decides the reading +(13 cents off at -48.7 dBFS). The claim is about the walk itself: under +feedback, each later repeat re-reads a line that was written while the +head was moving, so the repeats do not telescope the way a clock step in +a real bucket brigade makes them, and nothing here claims they do. + +**Modulation.** The swing is a fixed number of milliseconds whatever the +Time, so a Time move never steps the read offset and equal Modulation +bends equally at every Time (+-20.7 cents at patch 3's 3 ms and 1 Hz, ++257 / -302 at the stops). The shape is a plain triangle, the clock law's +first order; it differs from the exact reciprocal by up to S / T of the +swing (1.7 % at 300 ms and full depth, 25 % at 20 ms). **A Modulation move +does step the read offset**, by the change in depth times where the +triangle stands: 5 -> 2 ms at the triangle's peak jumps the read 142 +frames at 48 kHz. Mod Rate moves keep the triangle's phase and do not step. + +**Input ceiling.** The dry path sits at unity and the repeats add to it, +so a hot input can put the output on the int16 rail; there is no input +gain to turn down. The loop's low-pass, cross-feed and interpolated read +are each a convex mix, so no repeat exceeds full scale and the wet adds at +most Mix x full scale: below Mix 1, an input peaking at or below +floor(32767 (1 - Mix)) - 1 cannot reach the rail at any Time, Feedback, +Modulation or Spread (-4.4 dBFS at the default Mix 0.4). + +**Tail.** `tail_samples` is an upper bound on how long the repeats take to +reach exact zero after your input stops: DigitalDelay's lap count at the +Feedback the node is handed, each lap the longest delay the head may be at +plus the swing, one frame for the interpolated read and the low-pass's +memory. 187 954 frames (3.9 s) at the defaults; 26.5 s at patch 5, the +longest. The loop low-pass is always in, and the node can hold a small +value for ever at a Feedback a hair either side of 1 - 0.5 / k, so the +class hands the node the nearer edge of that window instead, at most +3 x 10^-5 from the Feedback you set, which the knob still reads. After a +falling Time move the bound keeps the Time the head walked from until a +reset, because the class cannot see how far the walk has got. + +`capabilities = ("tempo_sync",)`: with Sync on, the class reads +`self._transport()` on every macro move and program change (not per block). +With no host transport, or a host whose tempo is not a finite positive +number, Time stays where the knob is. A synced Time change walks at the +clock's law like any other. + +Constructor values stay on the audio path unquantised by the grid, and a +constructor Time plays exactly as given (not through the knob's map, whose +round trip can move a Time that sits on a half frame to the frame below) +until Time is moved; a host that reads the knob back and writes the same +position keeps it. A constructor Time of 0 or less is 20 ms, a Mod Rate of 0 or less 0.05 Hz, +a `max_time_ms` above 600 or NaN 600 ms. `character` is `"single-line"` +or `"double-line"`; anything else raises `ValueError`. +""" + +VENDOR = "PyDevices" + +from array import array +import math + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail and transport arithmetic, reused rather than copied: +# the same node rounds the same way in both classes. Its module moves up +# one level when it comes home, so both homes are tried. +try: + from .digitaldelay import (DIVISION_BEATS, clear_of_stalls, laps_to_zero, + whole_frames) +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import (DIVISION_BEATS, clear_of_stalls, + laps_to_zero, whole_frames) + +try: + import audioecho +except ImportError: # pragma: no cover - a stock board + audioecho = None + + +#: The characters and their stage counts (dossier section 6): one MN3005 +#: on the DM-2, two in series on the Deluxe Memory Man. +SINGLE_LINE = "single-line" +DOUBLE_LINE = "double-line" +STAGES = {SINGLE_LINE: 4096, DOUBLE_LINE: 8192} + +#: sinc(x) is -3 dB at x = 0.4422, so the corner is 0.4422 f_clk = +#: 0.2211 N / T (dossier Appendix A). +CORNER_PER_STAGE_SECOND = 0.2211 + +#: The Time map, fixed on every instance: 20-600 ms, log, on both +#: characters (the span is ours; dossier section 8.11). +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 600.0 + +#: Modulation's peak delay swing, ms, and Mod Rate's span, Hz. +SWING_MAX_MS = 5.0 +RATE_MIN_HZ = 0.05 +RATE_MAX_HZ = 8.0 + +#: The line over `max_time_ms`: the modulation's peak swing, plus one for +#: the node's `length - 2` clamp on the read (`audiodsp_feedback_delay.c` +#: `tap_for`). +LINE_HEADROOM_MS = SWING_MAX_MS + 1.0 + +#: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). +FEEDBACK_MAX = 0.99 + +#: One period of the modulation's triangle, borrowed by the node. +TABLE_POINTS = 256 + +#: How close a Time write must be to the constructor's seed position to +#: count as a host reading the knob back and writing it again, which keeps +#: the constructor's exact Time on the audio path. +ECHO_TOLERANCE = 1e-9 + +TIME_I, FEEDBACK_I, MIX_I, MODULATION_I, RATE_I, SPREAD_I, SYNC_I, \ + DIVISION_I = range(8) + + +def triangle_table(points=TABLE_POINTS): + """Q15 unit triangle, one period: 0 -> +1 -> 0 -> -1 -> 0.""" + values = array("h", [0] * points) + for index in range(points): + phase = index / float(points) + if phase < 0.25: + unit = 4.0 * phase + elif phase < 0.75: + unit = 2.0 - 4.0 * phase + else: + unit = 4.0 * phase - 4.0 + values[index] = int(round(unit * 32767.0)) + return values + + +def corner_hz(stages, time_ms): + """The sinc's -3 dB point for `stages` at `time_ms`, before the clamp.""" + return CORNER_PER_STAGE_SECOND * stages * 1000.0 / float(time_ms) + + +def clock_slew(from_frames, to_frames): + """The node's `delay_slew` for a clock step from a settled line: the + walk runs |dT| / T_new delay-seconds per second, so it lasts exactly + T_new and holds the ratio T_old / T_new (dossier section 6).""" + return abs(float(to_frames) - float(from_frames)) / float(to_frames) + + +def _f32(value): + """`value` rounded to single precision, the same on every interpreter.""" + return array("f", (value,))[0] + + +def _f32_up(value): + """The next single-precision value above a positive single `value`.""" + mantissa, exponent = math.frexp(value) + del mantissa + return _f32(value + 2.0 ** (exponent - 24)) + + +def node_frames(value_ms, sample_rate): + """What the node makes of `delay_ms = value_ms`: the single-precision + `value * rate / 1000.0f` (`audiodsp_feedback_delay.c:148`).""" + return _f32(_f32(_f32(value_ms) * sample_rate) / 1000.0) + + +def hand_off_ms(frames, sample_rate): + """The `delay_ms` the class hands the node for whole frame `frames`: + `frames * 1000 / fs` in single precision, stepped up one single step + where the node's own arithmetic would land below `frames`, so the + read's whole part is `frames` (dossier section 6, the hand-off).""" + value = _f32(frames * 1000.0 / sample_rate) + if node_frames(value, sample_rate) < frames: + value = _f32_up(value) + return value + + +def tone_excess(damping_hz, sample_rate): + """(frames, relative excess) for the loop low-pass, by DigitalDelay's + `_tone_excess` arithmetic: after `frames` frames whatever the state + held weighs under 2^-17 LSB, and the single-precision state can rest + up to 2^-24 / a of the peak above it, a being the coefficient.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +def _between(value, low, high): + value = float(value) + if not value >= low: + return low + if value > high: + return high + return value + + +class AnalogDelay(_component.Component): + """A bucket-brigade delay: the Time knob is the line's clock, so the + repeats darken as Time grows and a Time move bends their pitch. + audiodsp tier; zero latency. + + **What the default surrenders:** no sample-and-hold (a one-pole + roll-off, not the sinc, and no null at the clock), no compander, no + fixed ~3 kHz pair, so short Times are brighter than either pedal. + """ + + NAME = 'AnalogDelay' + DISPLAY_NAME = 'Analog Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho",) + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Feedback", "Mix", "Modulation", "Mod Rate", + "Spread", "Sync", "Division") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "TOGGLE", + 7: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, ms + (0.0, FEEDBACK_MAX), # 1 Feedback + (0.0, 2.0), # 2 Mix; dry at unity to 1 + (0.0, SWING_MAX_MS), # 3 Modulation, peak swing ms + (RATE_MIN_HZ, RATE_MAX_HZ, "log"), # 4 Mod Rate, Hz + (0.0, 1.0), # 5 Spread (cross-feed) + (0.0, 1.0), # 6 Sync + (0.0, 15.0), # 7 Division index + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Single Line Repeats", (101, 51, 25, 0, 69, 0, 0, 51)), + 1: ("Short Bright Repeats", (41, 38, 32, 0, 69, 0, 0, 51)), + 2: ("Long Dark Repeats", (124, 71, 25, 0, 69, 0, 0, 51)), + 3: ("Modulated Repeats", (107, 58, 29, 76, 75, 0, 0, 51)), + 4: ("Wet Vibrato", (26, 0, 127, 51, 115, 0, 0, 51)), + 5: ("High Feedback Wash", (116, 109, 22, 25, 52, 64, 0, 51)), + 6: ("Dotted Eighth, Synced", (101, 58, 25, 0, 69, 0, 127, 68)), + } + + def _build(self, time_ms=300.0, feedback=0.4, mix=0.4, modulation_ms=0.0, + mod_rate_hz=0.8, spread=0.0, sync=False, division=6, + character=SINGLE_LINE, max_time_ms=TIME_MAX_MS, patch=None): + if character not in STAGES: + raise ValueError("character must be %r or %r, not %r" + % (SINGLE_LINE, DOUBLE_LINE, character)) + self._character = character + self._stages = STAGES[character] + max_time_ms = float(max_time_ms) + # `not <=` catches NaN, which would otherwise pass both clamps and + # size the line from nothing. + if not max_time_ms <= TIME_MAX_MS: + max_time_ms = TIME_MAX_MS + if max_time_ms < TIME_MIN_MS: + max_time_ms = TIME_MIN_MS + self._max_time_ms = max_time_ms + #: The whole-frame Time last handed to the node, and what was handed. + self._frames = 1 + self._node_ms = 0.0 + #: The longest whole-frame delay the read head may still sit at. A + #: walk starts from wherever the head is and the class cannot see how + #: far it has got, so after a falling move this keeps the old Time + #: until a reset lands the head. + self._reach = 1 + #: The walk rate of the last Time move; a move of another macro + #: leaves the walk in progress at its rate. + self._slew = 0.0 + #: True while the node has been built or cleared and has not yet + #: been told a second Time: it snaps onto the delay on its first pull. + self._fresh = True + #: True inside `program_change`, which applies the macros one at a + #: time; the node is refreshed once, after the last. + self._deferred = False + self._feedback = 0.0 + self._damping = 0.0 + self._corner = 0.0 + self._swing_ms = 0.0 + self._spread = 0.0 + time_ms = float(time_ms) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + #: The constructor's Time, exactly, until Time is moved: the log + #: map's round trip can move a value by a last bit, which is enough + #: to land a Time that sits on a half frame (150 ms at 22.05 kHz, + #: 3307.5 frames) on the frame below. + self._time_exact = time_ms + self._time_seed = 0.0 + self._seeding = True + mod_rate_hz = float(mod_rate_hz) + if not mod_rate_hz > 0.0: + mod_rate_hz = RATE_MIN_HZ + #: Written once; a Modulation move changes the depth, not the table. + self._table = triangle_table() + self._delay = audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_delay_ms=max_time_ms + LINE_HEADROOM_MS, + delay_ms=self._clamp_ms(time_ms), + feedback=0.0, + mix=0.0, + damping_hz=0.0, + cut_hz=0.0, + wow_hz=0.0, + wow_depth_ms=0.0, + wow_shape=self._table, + delay_slew=0.0) + # `clear()` empties the line and the loop filter and re-primes the + # read head, so a reset is silent and snaps onto patch 0's Time. + self._own(self._delay, reset=self._clear) + self._delay.play(self._source) + self._output = self._delay + self._deferred = True + try: + self._init_macros((time_ms, feedback, mix, modulation_ms, + mod_rate_hz, spread, 1.0 if sync else 0.0, + float(division))) + finally: + self._deferred = False + self._seeding = False + self._refresh() + # Read after the refresh, which re-seats a Time above `max_time_ms`. + self._time_seed = self._macros[TIME_I] + if patch is not None: + self.program_change(patch) + self._fresh = False + + def _clear(self): + self._delay.clear() + self._fresh = True + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _clamp_ms(self, time_ms): + return _between(time_ms, TIME_MIN_MS, self._max_time_ms) + + def _corner_for(self, time_ms): + """The corner law for this character at `time_ms`, before the clamp. + A hook, so a planted fault can run it on another stage count.""" + return corner_hz(self._stages, time_ms) + + def _node_time_ms(self, frames): + """What the node is handed for whole frame `frames`.""" + return hand_off_ms(frames, self._sample_rate) + + def _walk_rate(self, from_frames, to_frames): + """The node's `delay_slew` for a Time move: the clock's law.""" + return clock_slew(from_frames, to_frames) + + def _transport_state(self): + transport = self._transport + state = transport() if callable(transport) else transport + return transport, state + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one), where Time stays where the knob is.""" + transport, state = self._transport_state() + if transport is _component.static_transport: + return None + # A host whose tempo is not a finite positive number (0, None, a + # negative, NaN or infinity) leaves Time on the knob, as the static + # transport does. `not bpm > 0` catches NaN, and `bpm * 0` is NaN + # for infinity. + bpm = float(state[2] or 0.0) + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + # -- applying ------------------------------------------------------ + + def _time_ms(self): + """The Time the audio path plays, before it is clamped and landed.""" + if self._time_exact is not None: + return self._time_exact + return self._value(TIME_I) + + def _apply_macro(self, index, position): + if index == TIME_I and not self._seeding and not ( + self._time_exact is not None + and abs(position - self._time_seed) <= ECHO_TOLERANCE): + # A host that reads Time back and writes the same position keeps + # the constructor's exact Time; any other move drops it. + self._time_exact = None + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh once, so Time is read + against the new patch's Sync and moves (and walks) once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def reset(self): + _component.Component.reset(self) + # The cleared node snapped onto patch 0's Time. + self._fresh = False + + def _refresh(self): + fs = self._sample_rate + span = self._MACRO_RANGES[TIME_I] + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + # Division quantises Time into the same map, and the clamp + # shows through get_macro(0) the same way. + self._time_exact = None + self._macros[TIME_I] = _component.macro_position( + span, self._clamp_ms(synced)) + time_ms = self._time_ms() + clamped = self._clamp_ms(time_ms) + if clamped != time_ms: + if self._time_exact is not None: + self._time_exact = clamped + self._macros[TIME_I] = _component.macro_position(span, clamped) + frames = max(1, whole_frames(clamped, fs)) + if self._fresh: + # A fresh node snaps onto the delay on its first pull; there is + # no walk to set a rate for. + self._slew = 0.0 + self._reach = frames + elif frames != self._frames: + # A clock step: the walk's rate from the Time last handed. + self._slew = self._walk_rate(self._frames, frames) + if frames > self._reach: + self._reach = frames + self._frames = frames + self._node_ms = self._node_time_ms(frames) + + # The corner law on the knob's milliseconds, clamped below Nyquist + # and pre-warped so the one-pole's -3 dB point is the corner. + self._corner = self._hz(self._corner_for(clamped)) + self._damping = nominal_damping_hz(self._corner, fs) + + feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) + # The loop low-pass is always in, and the node can hold a small value + # for ever at a Feedback a hair either side of 1 - 0.5 / k; the node + # is handed the nearer edge of that window instead. + self._feedback = clear_of_stalls( + feedback, tone_excess(self._damping, fs)[1]) + + self._swing_ms = _between(self._value(MODULATION_I), 0.0, + SWING_MAX_MS) + # At one channel the node's cross-feed sends the repeat nowhere. + if self._channel_count == 1: + self._spread = 0.0 + else: + self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) + self._delay.set( + delay_slew=self._slew, + delay_ms=self._node_ms, + feedback=self._feedback, + mix=_between(self._value(MIX_I), 0.0, 2.0), + damping_hz=self._damping, + cut_hz=0.0, + cross_feed=self._spread, + wow_hz=_between(self._value(RATE_I), RATE_MIN_HZ, RATE_MAX_HZ), + wow_depth_ms=self._swing_ms) + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound: `laps_to_zero(f, excess)` laps of the longest delay + the read head may be at, plus the modulation's peak swing in frames + rounded up, plus one frame for the interpolated read, plus the loop + low-pass's memory. Finite at every setting the class reaches.""" + 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`.""" + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + if laps is None: # pragma: no cover - stepped clear + return None + swing = int(math.ceil(self._swing_ms * self._sample_rate / 1000.0)) + return int(laps * (self._reach + swing + 1 + memory)) diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py new file mode 100644 index 0000000..fe2eab2 --- /dev/null +++ b/tests/test_cpython_effects_analogdelay.py @@ -0,0 +1,1448 @@ +"""`AnalogDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/AnalogDelay.md` +(frozen at anchor cc61011, the Station A critique revision). Four of its +Tier 2 rows can be demonstrated, T2a, T3, T6 and T7, and each is here as +the measurement at a few of the row's cells beside the same measurement +shown red on the row's planted fault at the constructor defaults. Every +fault is shown unreachable from every macro position and shipped patch at +48, 44.1 and 22.05 kHz, and every row's measurement is shown red on the +class built as a wire. T1 is unmeasured and T2b, T4 and T5 are disconfirmed +by decision; `RecordedRows` holds the numbers their verdicts rest on, so a +build that gained a hold or a fixed pair would show up here first. The +full spans, the stereo renders the rows name, the three interpreters and +the M5 table live in the evidence pack, not in this file. + +The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by +`rebuilt.module_class("AnalogDelay")`. + +The dossier's laws are written out here independently of the class: the +corner is 0.2211 N / T, the Time map 20 * 30^position ms, the whole frame +floor(ms fs / 1000 + 0.5), and a Time move's walk rate |dT| / T_new. +""" + +import math +import os +import sys +import unittest +import wave +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import analogdelay as ad # noqa: E402 +from tools import effect_measurements as kit # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +RATES = (48000, 44100, 22050) +BLOCK = 256 +TIME_I, FEEDBACK_I, MIX_I, MODULATION_I, RATE_I, SPREAD_I, SYNC_I, \ + DIVISION_I = range(8) +SINGLE, DOUBLE = "single-line", "double-line" +STAGES = {SINGLE: 4096, DOUBLE: 8192} + +AnalogDelay = rebuilt.module_class("AnalogDelay") + +PROBE_DIR = os.path.join(os.path.dirname(__file__), "..", "tools", + "effect_probes") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + + +def law_corner(character, time_ms): + """0.2211 N / T, T the knob's milliseconds (dossier T2a).""" + return 0.2211 * STAGES[character] / (time_ms / 1000.0) + + +def whole(time_ms, rate): + """The nearest whole frame at the running rate (dossier section 6).""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def grid_ms(midi): + """Time's map: 20-600 ms, log, at MIDI position `midi`.""" + return 20.0 * 30.0 ** (midi / 127.0) + + +def midi_of_ms(time_ms): + """Time's MIDI position for `time_ms`, unquantised.""" + return 127.0 * math.log(time_ms / 20.0) / math.log(30.0) + + +def clamp_hz(hz, rate): + return min(max(hz, 1.0), rate * 0.5 * 0.98) + + +def first_claimed(character, rate): + """The first Time grid position where the corner law is under the + clamp (dossier T2a, Quantified over).""" + for midi in range(128): + if law_corner(character, grid_ms(midi)) < rate * 0.5 * 0.98: + return midi + return None + + +def cents(ratio): + return 1200.0 * math.log(ratio) / math.log(2.0) + + +# -------------------------------------------------------------------------- +# Planted faults, one or more per demonstrated row, each of the row's kind + + +class CornerN6144(AnalogDelay): + """T2a: the corner law run on N = 6144 on both characters. N is the + character, not a macro, so no position or patch reaches it.""" + + NAME = 'AnalogDelay' + + def _corner_for(self, time_ms): + return ad.corner_hz(6144, time_ms) + + +class DoubleLineN6144(AnalogDelay): + """T6: double-line's corner law run on N = 6144, single-line's + untouched.""" + + NAME = 'AnalogDelay' + + def _corner_for(self, time_ms): + if self._character == DOUBLE: + return ad.corner_hz(6144, time_ms) + return AnalogDelay._corner_for(self, time_ms) + + +class JumpAnalogDelay(AnalogDelay): + """T3: the move as one jump (slew 0), which has no walk at all.""" + + NAME = 'AnalogDelay' + + def _walk_rate(self, from_frames, to_frames): + return 0.0 + + +class ConstantGlideWalk(AnalogDelay): + """T3: DigitalDelay's constant walk at its Glide 800 ms (slew 787.5 / + 800) in place of the clock's law.""" + + NAME = 'AnalogDelay' + + def _walk_rate(self, from_frames, to_frames): + return 787.5 / 800.0 + + +class DryGainDelay(AnalogDelay): + """T7: the output +0.1 dB (`kit_faults.HiddenGain`), a dry above + unity, which nothing reaches: the node's dry is exactly 1 below Mix 1.""" + + NAME = 'AnalogDelay' + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._output = kit_faults.HiddenGain(self._delay, 0.1) + + +class PlainHandOff(AnalogDelay): + """Section 6's hand-off: `k * 1000 / fs` as DigitalDelay hands it, + which the node's single-precision arithmetic lands a frame short at + some counts (patch 0's Time at 44.1 kHz).""" + + NAME = 'AnalogDelay' + + def _node_time_ms(self, frames): + return frames * 1000.0 / self._sample_rate + + +class SpreadInMono(AnalogDelay): + """Section 4: Spread passed to the node at one channel.""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + spread = self._value(SPREAD_I) + self._spread = spread + self._delay.set(cross_feed=spread) + + +class TargetOnlyTail(AnalogDelay): + """Tail: sized on the target Time only, forgetting that a falling walk + starts from the old one.""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + self._reach = self._frames + + +class NoSwingTail(AnalogDelay): + """Tail: the modulation's swing left out of the reach.""" + + NAME = 'AnalogDelay' + + def _tail_bound(self): + memory, excess = ad.tone_excess(self._damping, self._sample_rate) + laps = ad.laps_to_zero(self._feedback, excess) + return int(laps * (self._reach + 1 + memory)) + + +class HalfTail(AnalogDelay): + """Tail: half the bound.""" + + NAME = 'AnalogDelay' + + def _tail_bound(self): + return AnalogDelay._tail_bound(self) // 2 + + +class RawFeedback(AnalogDelay): + """Tail: the Feedback handed as set, inside a stall window.""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + feedback = min(0.99, self._value(FEEDBACK_I)) + self._feedback = feedback + self._delay.set(feedback=feedback) + + +class PerMacroPatch(AnalogDelay): + """A patch applied one macro at a time, refreshing after each.""" + + NAME = 'AnalogDelay' + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + _component.Component.program_change(self, index, channel, note_id, + sample_position) + + +# -------------------------------------------------------------------------- +# Sources and pulls + + +def array_src(values, channels=2, rate=RATE): + data = array("h") + for value in values: + for _ in range(channels): + data.append(int(value)) + return probes.ArraySource(data, rate=rate, channels=channels, block=BLOCK) + + +def silence_src(frames, channels=2, rate=RATE): + return probes.ArraySource(array("h", [0] * (frames * channels)), + rate=rate, channels=channels, block=BLOCK) + + +def sine_values(hz, frames, rate, level): + n = np.arange(frames) + return np.round(level * np.sin(2.0 * math.pi * hz * n / rate)).astype( + np.int64).tolist() + + +def pull(effect, frames, channels=None, on_block=None): + """Interleaved int16 of `frames` frames; `on_block(frame)` runs before + each block is pulled.""" + channels = channels or effect.channel_count + out = array("h") + while len(out) < frames * channels: + if on_block is not None: + on_block(len(out) // channels) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + out.extend([0] * (frames * channels - len(out))) + break + out.extend(memoryview(data).cast("h")) + return np.array(out[:frames * channels], dtype=np.int16) + + +def left(interleaved, channels): + return interleaved[0::channels].astype(np.float64) + + +def material(name, rate, channels): + path = os.path.join(PROBE_DIR, str(rate), "%dch" % channels, + name + ".wav") + handle = wave.open(path) + try: + data = handle.readframes(handle.getnframes()) + finally: + handle.close() + return array("h", data) + + +def analytic(x): + x = np.asarray(x, dtype=np.float64) + n = len(x) + spec = np.fft.fft(x) + h = np.zeros(n) + h[0] = 1.0 + if n % 2 == 0: + h[n // 2] = 1.0 + h[1:n // 2] = 2.0 + else: + h[1:(n + 1) // 2] = 2.0 + return np.fft.ifft(spec * h) + + +def inst_hz(x, rate): + phase = np.unwrap(np.angle(analytic(x))) + return np.diff(phase) * rate / (2.0 * math.pi) + + +# -------------------------------------------------------------------------- +# T2a / T6: the first repeat's -3 dB corner + + +def first_repeat(cls, rate, character, time_ms=None, midi=None, + amp=32767, frames=8192, **options): + """Mix 2, Feedback 0, one impulse of `amp` at frame 0; the first + repeat's samples from its whole-frame arrival, scaled to a unit + impulse. Time from the constructor (`time_ms`) or the grid (`midi`).""" + if midi is not None: + time_ms = grid_ms(midi) + arrival = whole(time_ms, rate) + total = arrival + frames + values = [0] * total + values[0] = amp + effect = cls(array_src(values, 1, rate), sample_rate=rate, + character=character, feedback=0.0, mix=2.0, + time_ms=time_ms, **options) + if midi is not None: + effect.set_macro(TIME_I, midi) + out = left(pull(effect, total, 1), 1) + effect.deinit() + return out[arrival:] / float(amp) + + +def magnitude_db(h, rate, hz): + k = np.arange(len(h)) + value = abs(np.dot(h, np.exp(-2j * math.pi * hz / rate * k))) + return 20.0 * math.log10(max(value, 1e-15)) + + +def crossing_hz(h, rate, level_db=-3.0103, low=10.0): + """The first frequency where |H| falls through `level_db`, by + bisection on the exact DTFT, or `None` where it never does below + 0.999 x Nyquist (a wire's empty first repeat, or no corner).""" + high = rate * 0.5 * 0.999 + if not np.any(h): + return None + if magnitude_db(h, rate, high) > level_db: + return None + if magnitude_db(h, rate, low) < level_db: + return None + for _ in range(50): + middle = math.sqrt(low * high) + if magnitude_db(h, rate, middle) > level_db: + low = middle + else: + high = middle + return math.sqrt(low * high) + + +def corner_ratio(cls, rate, character, time_ms=None, midi=None, amp=32767): + """The measured corner over `0.2211 N / T`, or `None`.""" + h = first_repeat(cls, rate, character, time_ms=time_ms, midi=midi, + amp=amp) + hz = crossing_hz(h, rate) + if hz is None: + return None + knob = grid_ms(midi) if midi is not None else time_ms + return hz / law_corner(character, knob) + + +def t2a_measure(cls, rate=RATE): + """T2a at the named cells, both characters: every ratio within 10 %, + and the octave ratios 1.8-2.2 between claimed cells.""" + ratios = {} + for character in (SINGLE, DOUBLE): + for time_ms in (75.0, 150.0, 300.0, 600.0): + if law_corner(character, time_ms) >= rate * 0.5 * 0.98: + continue + ratios[(character, time_ms)] = corner_ratio( + cls, rate, character, time_ms=time_ms) + passed = all(r is not None and abs(r - 1.0) <= 0.10 + for r in ratios.values()) + octaves = [] + if passed: + for (character, time_ms), ratio in ratios.items(): + longer = (character, 2.0 * time_ms) + if longer in ratios: + hz_short = ratio * law_corner(character, time_ms) + hz_long = ratios[longer] * law_corner(character, 2 * time_ms) + octaves.append(hz_short / hz_long) + passed = all(1.8 <= o <= 2.2 for o in octaves) + return {"passed": passed, "ratios": ratios, "octaves": octaves} + + +def t6_measure(cls, rate=RATE, times=(150.0, 300.0, 600.0)): + """T6: double-line's corner over single-line's at the same Time, + 1.8-2.2 at every cell where double-line's target is under the clamp.""" + ratios = {} + for time_ms in times: + if law_corner(DOUBLE, time_ms) >= rate * 0.5 * 0.98: + continue + single = first_repeat(cls, rate, SINGLE, time_ms=time_ms) + double = first_repeat(cls, rate, DOUBLE, time_ms=time_ms) + hz_single = crossing_hz(single, rate) + hz_double = crossing_hz(double, rate) + ratios[time_ms] = (None if hz_single is None or hz_double is None + else hz_double / hz_single) + passed = bool(ratios) and all( + r is not None and 1.8 <= r <= 2.2 for r in ratios.values()) + return {"passed": passed, "ratios": ratios} + + +# -------------------------------------------------------------------------- +# T3: a Time move glides on the clock's law + + +MOVE_AT = 40960 # a block boundary 853 ms in: the line is full + + +def t3_render(cls, rate, character, t_old, t_new, values, channels=1): + """Render `values` at Mix 2, Feedback 0, Time `t_old`, moving Time to + `t_new` through `set_macro(0, ...)` on the block at MOVE_AT.""" + effect = cls(array_src(values, channels, rate), sample_rate=rate, + character=character, time_ms=t_old, feedback=0.0, mix=2.0) + target = midi_of_ms(t_new) + move_at = MOVE_AT * rate // 48000 // BLOCK * BLOCK + + def move(frame): + if frame == move_at: + effect.set_macro(TIME_I, target) + + out = left(pull(effect, len(values), channels, on_block=move), channels) + effect.deinit() + return out, move_at + + +def t3_static(cls, rate, character, time_ms, values): + effect = cls(array_src(values, 1, rate), sample_rate=rate, + character=character, time_ms=time_ms, feedback=0.0, mix=2.0) + out = left(pull(effect, len(values), 1), 1) + effect.deinit() + return out + + +def open_loop(cls): + """Measurement helper, not a fault: `cls` with the loop low-pass taken + out after every refresh, so a ramp render reads the read head's delay + with no filter lag on it. The walk does not depend on the filter.""" + def _refresh(self): + cls._refresh(self) + self._delay.set(damping_hz=0.0) + return type("OpenLoop" + cls.__name__, (cls,), {"_refresh": _refresh}) + + +def read_position(cls, rate, character, t_old, t_new, frames): + """The read head's delay, frame by frame, off a slope-1 ramp render + through `open_loop(cls)`: `n - ramp(n) - out(n)`.""" + k_old = whole(t_old, rate) + move_at = MOVE_AT * rate // 48000 // BLOCK * BLOCK + origin = move_at - k_old - 2000 + n = np.arange(frames) + ramp = ((n - origin) % 65536) - 32768 + out, move_at = t3_render(open_loop(cls), rate, character, t_old, t_new, + ramp.tolist()) + return (n - origin - 32768) - out, move_at + + +def walk_end(delay, move_at, k_new): + """The first frame after the move at which the read position, and the + frame after it, sit within 2 frames of `k_new` (the Station A probe's + rule), or the end of the render if it never does.""" + near = np.abs(delay[move_at:] - k_new) <= 2.0 + both = np.nonzero(near[:-1] & near[1:])[0] + if both.size == 0: + return len(delay) + return move_at + int(both[0]) + + +def binade_pieces(delay, move_at, end, law): + """Each piece of the walk between powers of two of the read position, + at least 1000 frames long, as (pitch ratio off a least-squares slope, + frames); the node walks the head in single precision, so each binade + plays at its own rate.""" + span = delay[move_at:end] + powers = np.floor(np.log2(np.maximum(span, 1.0))) + pieces = [] + start = 0 + for index in range(1, len(span) + 1): + if index == len(span) or powers[index] != powers[start]: + a, b = start + 16, index - 16 + if b - a >= 1000: + x = np.arange(a, b, dtype=np.float64) + slope = np.polyfit(x, span[a:b], 1)[0] + pieces.append((1.0 - slope, b - a)) + start = index + return pieces + + +def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, + level=12000): + """T3's four clauses on one move: the pitch over the whole walk and on + each binade piece within 10 cents of 1200 log2(T_old / T_new), the walk + within 5 % of T_new, the residual 50-250 ms after it within 1 cent, and + the two no-step statistics within 5 % of their bars.""" + k_old, k_new = whole(t_old, rate), whole(t_new, rate) + law = cents(float(k_old) / k_new) + move_at = MOVE_AT * rate // 48000 // BLOCK * BLOCK + frames = move_at + int(1.25 * k_new) + 400 + int(0.3 * rate) + 4096 + delay, _ = read_position(cls, rate, character, t_old, t_new, frames) + end = walk_end(delay, move_at, k_new) + walk = end - move_at + walk_ok = abs(walk - k_new) <= 0.05 * k_new + + tone = sine_values(997.0, frames, rate, level) + out, _ = t3_render(cls, rate, character, t_old, t_new, tone) + hz = inst_hz(out, rate) + result = {"walk": walk, "k_new": k_new, "law": law} + if walk < 64 or end + int(0.25 * rate) + 1 > frames: + # No walk at all, or one that never lands on T_new inside the + # render: the walk clause is red and the rest has nothing to read. + result.update(passed=False, whole=None) + return result + margin = min(400, walk // 10) + whole_cents = cents(float(np.median(hz[move_at + margin:end - margin])) + / 997.0) + pieces = [cents(ratio) for ratio, _ in + binade_pieces(delay, move_at, end, law)] + after = hz[end + int(0.05 * rate):end + int(0.25 * rate)] + residual = cents(float(np.median(after)) / 997.0) + + diff = np.abs(np.diff(out)) + inside = float(np.max(diff[move_at:end])) + shifted = 997.0 * k_old / k_new + reference_values = sine_values(shifted, k_new + 8192, rate, level) + ref = t3_static(cls, rate, character, t_new, reference_values) + inside_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) + later = float(np.max(diff[end + 64:end + 4064])) + ref = t3_static(cls, rate, character, t_new, + sine_values(997.0, k_new + 8192, rate, level)) + later_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) + + result.update( + whole=whole_cents, pieces=pieces, residual=residual, + inside=inside, inside_bar=inside_bar, later=later, + later_bar=later_bar) + result["passed"] = ( + walk_ok + and abs(whole_cents - law) <= 10.0 + and all(abs(p - law) <= 10.0 for p in pieces) + and abs(residual) <= 1.0 + and inside <= 1.05 * inside_bar + and later <= 1.05 * later_bar) + return result + + +# -------------------------------------------------------------------------- +# T7: the dry path is a wire until the first repeat, and the repeat is there + + +def t7_measure(cls, rate=RATE, channels=2, name="ramp_fs", **options): + """WIRE over the first T - S - 1 frames of the kit's material, and, + where Mix is above 0 and the material outlasts it, the output not the + source somewhere in [T - S - 1, T + S + 64).""" + probe_effect = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + frames_t = probe_effect._frames + swing = int(math.ceil(probe_effect._swing_ms * rate / 1000.0)) + mix = probe_effect.macro(MIX_I) + probe_effect.deinit() + data = material(name, rate, channels) + total = len(data) // channels + effect = cls(probes.ArraySource(data, rate=rate, channels=channels, + block=BLOCK), sample_rate=rate, **options) + out = pull(effect, total, channels) + effect.deinit() + src = np.array(data, dtype=np.int16) + window = max(0, frames_t - swing - 1) * channels + differing = int(np.count_nonzero(out[:window] != src[:window])) + arrival_end = (frames_t + swing + 64) * channels + applies = mix > 0.0 and arrival_end <= len(src) + arrived = None + if applies: + arrived = int(np.count_nonzero(out[window:arrival_end] + != src[window:arrival_end])) + passed = differing == 0 and (not applies or arrived > 0) + return {"passed": passed, "differing": differing, "arrived": arrived, + "frames": frames_t, "swing": swing} + + +# -------------------------------------------------------------------------- +# Tail + + +def kit_tail(cls, seconds, rate=RATE, **options): + data, on = probes.burst_silence(total_s=seconds, rate=rate) + effect = cls(probes.ArraySource(data, rate=rate, channels=2, + block=BLOCK), sample_rate=rate, **options) + declared = effect.tail_samples + render = probes.render(effect, int(seconds * rate), rate=rate, + channels=2) + result = kit.tail(render, burst_end_frame=on, + declared_tail_samples=declared, + settle_frames=effect._frames) + effect.deinit() + return declared, result["values"], result["red"] + + +def fullscale_tail(cls, rate=RATE, channels=1, **options): + """A full-scale DC fill for two laps, then silence: the last non-zero + frame after the input stops against `tail_samples`.""" + probe_effect = cls(silence_src(64, channels, rate), sample_rate=rate, + **options) + declared = probe_effect.tail_samples + frames_t = probe_effect._frames + probe_effect.deinit() + fill = (2 * frames_t + BLOCK) // BLOCK * BLOCK + total = fill + declared + 2 * BLOCK + values = [32767] * fill + [0] * (total - fill) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + **options) + out = pull(effect, total, channels) + after = out[fill * channels:] + nonzero = np.nonzero(after)[0] + last = 0 if nonzero.size == 0 else int(nonzero[-1]) // channels + 1 + return {"passed": last <= declared, "declared": declared, "last": last} + + +# -------------------------------------------------------------------------- +# Reachability: what the node is handed, or what the output does, at the +# position walked. The twin's `FeedbackDelay.set` is watched while a walk +# runs, so a reading sees the options the class actually handed over. + + +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node: `_handed` (the latest value of each option) and + `_writes` (each call's options, in order).""" + + def __enter__(self): + node_class = ad.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._writes = [] + node._handed.update(options) + node._writes.append(dict(options)) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def copy_of(effect, source): + """A fresh instance of the same class and character on `source`, at + `effect`'s macro positions.""" + other = type(effect)(source, sample_rate=effect._sample_rate, + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + return other + + +def read_corner_law(effect): + """The `damping_hz` handed to the node against the pre-warp of the + dossier's corner law at the knob's milliseconds, clamped, for this + instance's character.""" + rate = effect._sample_rate + knob = effect.macro(TIME_I) + law = nominal_damping_hz(clamp_hz(law_corner(effect._character, knob), + rate), rate) + return round(effect._delay._handed["damping_hz"] / law, 4) + + +def read_walk_law(effect): + """On a copy at these positions, one Time move of 32 grid steps after + a block has been pulled: the `delay_slew` handed against the dossier's + |dT| / T_new of the two whole frames.""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(4 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + before = other._frames + now = other.get_macro(TIME_I) + other.set_macro(TIME_I, now + 32.0 if now < 64 else now - 32.0) + after = other._frames + slew = other._delay._handed["delay_slew"] + other.deinit() + law = abs(after - before) / float(after) + return round(slew / law, 4) + + +def read_dry_gain(effect): + """The dry path's gain before the first repeat: a copy at these + positions on a 256-frame full-scale ramp, least-squares out / in.""" + ramp = probes.ramp_fs(frames=256, channels=2) + other = copy_of(effect, probes.ArraySource( + ramp, rate=effect._sample_rate, channels=2, block=BLOCK)) + out = pull(other, 256, 2).astype(np.float64) + src = np.array(ramp, dtype=np.float64) + other.deinit() + return round(float(np.dot(out, src) / np.dot(src, src)), 5) + + +def read_landing(effect): + """Whether the node's own single-precision arithmetic lands the handed + delay at or above its whole frame.""" + ms = np.float32(effect._delay._handed["delay_ms"]) + frames = float(ms * np.float32(effect._sample_rate) / np.float32(1000.0)) + return frames >= effect._frames + + +#: (name, fault, reading, constructor options for both builds). +REACH_WALKS = ( + ("CornerN6144", CornerN6144, read_corner_law, {}), + ("CornerN6144 double", CornerN6144, read_corner_law, + {"character": DOUBLE}), + ("DoubleLineN6144", DoubleLineN6144, read_corner_law, + {"character": DOUBLE}), + ("JumpAnalogDelay", JumpAnalogDelay, read_walk_law, {}), + ("ConstantGlideWalk", ConstantGlideWalk, read_walk_law, {}), + ("DryGainDelay", DryGainDelay, read_dry_gain, {}), +) + + +def reach(faulted, reading, rate, ctor): + """`kit_faults.fault_reachability` at `rate`, the node watched.""" + def build(cls): + return cls(silence_src(512, 2, rate), sample_rate=rate, **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(AnalogDelay, faulted, reading, + build) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = AnalogDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Feedback", "Mix", "Modulation", "Mod Rate", + "Spread", "Sync", "Division")) + self.assertEqual(cls.MACRO_MODES[SYNC_I], "TOGGLE") + self.assertEqual(len(cls.PATCHES), 7) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho",)) + effect = cls(silence_src(512), sample_rate=RATE) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ("tempo_sync",)) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_parked_not_served(self): + import audioeffects + self.assertNotIn("AnalogDelay", rebuilt.ADOPTED) + self.assertIn("AnalogDelay", rebuilt.parked()) + self.assertIsNot(audioeffects.AnalogDelay, AnalogDelay) + + def test_patches_are_the_dossier_settings_on_the_grid(self): + # Section 6's table, settings in the macros' own units, through + # `_component.macro_of`; patch 0 is the constructor's defaults. + spans = AnalogDelay._MACRO_RANGES + settings = { + 0: (300.0, 0.40, 0.40, 0.0, 0.8, 0.0, 0.0, 6.0), + 1: (60.0, 0.30, 0.50, 0.0, 0.8, 0.0, 0.0, 6.0), + 2: (550.0, 0.55, 0.40, 0.0, 0.8, 0.0, 0.0, 6.0), + 3: (350.0, 0.45, 0.45, 3.0, 1.0, 0.0, 0.0, 6.0), + 4: (40.0, 0.0, 2.0, 2.0, 5.0, 0.0, 0.0, 6.0), + 5: (450.0, 0.85, 0.35, 1.0, 0.4, 0.5, 0.0, 6.0), + 6: (300.0, 0.45, 0.40, 0.0, 0.8, 0.0, 1.0, 8.0), + } + names = ("Single Line Repeats", "Short Bright Repeats", + "Long Dark Repeats", "Modulated Repeats", "Wet Vibrato", + "High Feedback Wash", "Dotted Eighth, Synced") + for index, values in settings.items(): + grid = tuple(_component.macro_of(span, value) + for span, value in zip(spans, values)) + self.assertEqual(AnalogDelay.PATCHES[index], + (names[index], grid), index) + effect = AnalogDelay(silence_src(512), sample_rate=RATE) + for index, expected in enumerate(AnalogDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_characters(self): + for character, stages in ((SINGLE, 4096), (DOUBLE, 8192)): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + character=character) + self.assertEqual(effect._stages, stages) + with self.assertRaises(ValueError): + AnalogDelay(silence_src(64), sample_rate=RATE, + character="triple-line") + + def test_the_corner_law_as_handed(self): + # Section 6: damping_hz 2 980.2 / 2 973.3 / 2 847.8 at 300 ms + # single-line, the pre-warped 3 018.8 Hz corner. + for rate, damping in ((48000, 2980.2), (44100, 2973.3), + (22050, 2847.8)): + effect = AnalogDelay(silence_src(64, 2, rate), sample_rate=rate) + self.assertAlmostEqual(effect._damping, damping, places=1) + self.assertAlmostEqual(effect._corner, 3018.8, delta=0.1) + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + character=DOUBLE) + self.assertAlmostEqual(effect._corner, 6037.5, places=1) + # Under the clamp point the corner holds at 0.98 x Nyquist. + effect.set_macro(TIME_I, 0) + self.assertEqual(effect._corner, 23520.0) + + def test_tail_samples_as_each_patch_plays(self): + # Tier 3 and App. F4: laps x (reach + swing + 1 + memory), each + # patch sized as its MIDI tuple plays, at 48 kHz, single-line. + expected = {None: 187954, 0: 172956, 1: 28940, 2: 480276, + 3: 238966, 4: 2037, 5: 1273515, 6: 201782} + for patch, frames in expected.items(): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + patch=patch) + self.assertEqual(effect.tail_samples, frames, patch) + # Patch 6 with a host at 120 bpm: 375 ms, 18 000 frames. + effect = AnalogDelay.create(silence_src(64), RATE, + transport=lambda: (True, 0.0, 120.0, + 4, 4), patch=6) + self.assertEqual(effect._frames, 18000) + self.assertEqual(effect.tail_samples, 253008) + + def test_the_tail_is_finite_everywhere_the_knobs_go(self): + for character in (SINGLE, DOUBLE): + for rate in RATES: + effect = AnalogDelay(silence_src(64, 2, rate), + sample_rate=rate, character=character) + for time_midi in (0, 25, 64, 101, 127): + effect.set_macro(TIME_I, time_midi) + for fb_midi in range(128): + effect.set_macro(FEEDBACK_I, fb_midi) + self.assertIsNotNone(effect.tail_samples, + (character, rate, time_midi, + fb_midi)) + self.assertLess(abs(effect._feedback + - 0.99 * fb_midi / 127.0), 3e-5) + + def test_constructor_edges(self): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, time_ms=0.0, + mod_rate_hz=0.0, max_time_ms=float("nan")) + self.assertEqual(effect._frames, 960) + self.assertAlmostEqual(effect.macro(RATE_I), 0.05) + self.assertEqual(effect._max_time_ms, 600.0) + effect = AnalogDelay(silence_src(64), sample_rate=RATE, + max_time_ms=100.0, time_ms=300.0) + self.assertEqual(effect._frames, 4800) + self.assertAlmostEqual(effect.macro(TIME_I), 100.0) + effect.set_macro(TIME_I, 127) + self.assertEqual(effect._frames, 4800) + self.assertAlmostEqual(effect.get_macro(TIME_I), midi_of_ms(100.0)) + + +# -------------------------------------------------------------------------- +# Section 6: Time lands on its whole frame on every interpreter + + +class TimeHandOff(unittest.TestCase): + def test_every_frame_count_lands_whole(self): + # Every count from 20 to 606 ms at the three rates: the node's + # single-precision arithmetic (modelled with numpy's float32, + # independently of the class's `array('f')`) lands the handed + # value at or above the count, at most 0.00195 frames above it. + for rate in RATES: + worst = 0.0 + stepped = 0 + fs = np.float32(rate) + for k in range(whole(20.0, rate), whole(606.0, rate) + 1): + value = ad.hand_off_ms(k, rate) + got = float(np.float32(np.float32(value) * fs) + / np.float32(1000.0)) + self.assertGreaterEqual(got, k, (rate, k)) + worst = max(worst, got - k) + plain = np.float32(k * 1000.0 / rate) + stepped += float(np.float32(plain * fs) + / np.float32(1000.0)) < k + self.assertLessEqual(worst, 0.00196, rate) + # App. F5: none at 48 kHz, 1 899 at 44.1, 953 at 22.05. + self.assertEqual(stepped, {48000: 0, 44100: 1899, + 22050: 953}[rate]) + + def _impulse(self, cls, rate, time_ms): + k = whole(time_ms, rate) + values = [0] * (k + 512) + values[0] = 32767 + effect = cls(array_src(values, 1, rate), sample_rate=rate, + time_ms=time_ms, feedback=0.0, mix=2.0) + effect.set_macro(TIME_I, midi_of_ms(time_ms)) + out = left(pull(effect, k + 512, 1), 1) + return k, out + + def test_patch_0_time_arrives_on_its_frame(self): + # Patch 0's grid Time at 44.1 kHz is k = 13 188, a count the plain + # hand-off lands short: nothing may arrive before k. + rate = 44100 + time_ms = grid_ms(101) + k, out = self._impulse(AnalogDelay, rate, time_ms) + self.assertEqual(k, 13188) + self.assertEqual(int(np.count_nonzero(out[1:k])), 0) + self.assertGreater(abs(out[k]), 10000) + k, out = self._impulse(PlainHandOff, rate, time_ms) + self.assertNotEqual(int(np.count_nonzero(out[1:k])), 0) + + def test_the_plain_hand_off_is_off_the_surface(self): + for rate in (44100, 22050): + with NodeSpy(): + result = kit_faults.fault_reachability( + AnalogDelay, False, read_landing, + lambda cls: cls(silence_src(64, 2, rate), + sample_rate=rate)) + self.assertEqual(result["clean"], True) + + +# -------------------------------------------------------------------------- +# Tier 2 rows + + +class T2aCornerTracksTime(unittest.TestCase): + def test_the_named_cells_and_octaves(self): + for rate in RATES: + result = t2a_measure(AnalogDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + for ratio in result["ratios"].values(): + self.assertLess(abs(ratio - 1.0), 0.01, (rate, result)) + + def test_the_grid_at_both_levels(self): + # Every claimed grid position at 48 kHz on both characters through + # the kit's sweep, stops first, at 0 dBFS; at -40 dBFS from one + # position higher, and every eighth. + for character in (SINGLE, DOUBLE): + first = first_claimed(character, RATE) + self.assertEqual(first, {SINGLE: 25, DOUBLE: 51}[character]) + for amp, low, midpoints in ( + (32767, first, 127 - first - 1), + (328, first + 1, (127 - first - 1) // 8)): + def measure_at(settings, amp=amp, character=character): + ratio = corner_ratio(AnalogDelay, RATE, character, + midi=settings[TIME_I], amp=amp) + return {"values": {"error": 1.0 if ratio is None + else ratio - 1.0}} + result = kit.macro_sweep( + lambda: AnalogDelay(silence_src(64), sample_rate=RATE, + character=character), + [kit.MacroSpan(TIME_I, low, 127, midpoints=midpoints)], + measure_at, figure="error", bar=0.10) + self.assertEqual(result["red"], [], + (character, amp, result["values"]["worst"], + result["values"]["at_units"])) + + def test_the_other_rates_on_the_grid(self): + for rate in (44100, 22050): + for character in (SINGLE, DOUBLE): + first = first_claimed(character, rate) + for midi in list(range(first, 128, 6)) + [127]: + ratio = corner_ratio(AnalogDelay, rate, character, + midi=midi) + self.assertIsNotNone(ratio, (rate, character, midi)) + self.assertLess(abs(ratio - 1.0), 0.10, + (rate, character, midi, ratio)) + + def test_n6144_is_red(self): + for rate in RATES: + self.assertFalse(t2a_measure(CornerN6144, rate)["passed"], rate) + ratio = corner_ratio(CornerN6144, RATE, SINGLE, time_ms=300.0) + self.assertAlmostEqual(ratio, 1.5, delta=0.01) + ratio = corner_ratio(CornerN6144, RATE, DOUBLE, time_ms=300.0) + self.assertAlmostEqual(ratio, 0.75, delta=0.01) + + +class T6CharactersAnOctaveApart(unittest.TestCase): + def test_the_named_cells(self): + for rate in RATES: + result = t6_measure(AnalogDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + # 150 ms at 22.05 kHz: double-line's target is above the clamp, so + # the cell is not claimed, and the ratio shows why. + single = crossing_hz(first_repeat(AnalogDelay, 22050, SINGLE, + time_ms=150.0), 22050) + double = crossing_hz(first_repeat(AnalogDelay, 22050, DOUBLE, + time_ms=150.0), 22050) + self.assertLess(double / single, 1.8) + self.assertAlmostEqual(double / single, 1.7927, delta=0.001) + + def test_double_lines_grid_and_the_patches(self): + for midi in list(range(51, 128, 4)) + [127, 101, 124, 107, 116]: + single = crossing_hz(first_repeat(AnalogDelay, RATE, SINGLE, + midi=midi), RATE) + double = crossing_hz(first_repeat(AnalogDelay, RATE, DOUBLE, + midi=midi), RATE) + self.assertTrue(1.8 <= double / single <= 2.2, (midi,)) + + def test_n6144_on_double_line_is_red(self): + for rate in RATES: + result = t6_measure(DoubleLineN6144, rate, times=(300.0,)) + self.assertFalse(result["passed"], rate) + self.assertAlmostEqual(result["ratios"][300.0], 1.5, delta=0.01) + + +class T3TimeGlidesOnTheClock(unittest.TestCase): + def test_the_row_cell(self): + # 200 -> 100.4 ms: +1193.2 cents for 4 819 frames at 48 kHz. + for character in (SINGLE, DOUBLE): + result = t3_measure(AnalogDelay, RATE, character) + self.assertTrue(result["passed"], (character, result)) + self.assertAlmostEqual(result["law"], 1193.2, delta=0.05) + + def test_moves_both_ways_at_three_rates(self): + for rate, t_old, t_new, level in ( + (48000, 100.4, 200.0, 12000), + (48000, 300.0, 100.0, 1200), + (44100, 100.0, 300.0, 32000), + (44100, 200.0, 600.0, 12000), + (22050, 600.0, 200.0, 3800), + (22050, 20.0, 60.0, 12000), + (48000, 40.0, 20.0, 12000)): + result = t3_measure(AnalogDelay, rate, SINGLE, t_old, t_new, + level) + self.assertTrue(result["passed"], + (rate, t_old, t_new, level, result)) + + def test_the_long_moves_are_read_per_binade(self): + # 200 -> 600 ms crosses 16 384 frames at 48 kHz; its upper binade + # plays its own rate, within 10 cents of the law (App. F5: +3.38). + result = t3_measure(AnalogDelay, RATE, SINGLE, 200.0, 600.0) + self.assertEqual(len(result["pieces"]), 2, result) + self.assertTrue(result["passed"], result) + + def test_the_jump_is_red(self): + result = t3_measure(JumpAnalogDelay, RATE, SINGLE) + self.assertFalse(result["passed"], result) + self.assertLess(result["walk"], 64) + + def test_the_constant_glide_is_red(self): + for rate in RATES: + result = t3_measure(ConstantGlideWalk, rate, SINGLE, 300.0, 100.0) + self.assertFalse(result["passed"], (rate, result)) + self.assertGreater(result["walk"], 1.5 * result["k_new"]) + self.assertLess(result["whole"] - result["law"], -600.0) + + +class T7DryIsAWire(unittest.TestCase): + def test_defaults_on_three_materials(self): + for name in ("ramp_fs", "tones_step", "sweep_log"): + for channels in (2, 1): + result = t7_measure(AnalogDelay, RATE, channels, name) + self.assertTrue(result["passed"], (name, channels, result)) + self.assertEqual(result["frames"], 14400) + + def test_the_stops_and_the_corner(self): + cells = ( + {"time_ms": 20.0}, {"time_ms": 600.0}, {"feedback": 0.99}, + {"mix": 0.0}, {"mix": 63 * 2.0 / 127.0}, {"mix": 1.0}, + {"modulation_ms": 5.0}, {"mod_rate_hz": 0.05}, + {"mod_rate_hz": 8.0}, {"spread": 1.0}, + {"time_ms": 20.0, "feedback": 0.99, "mix": 1.0, + "modulation_ms": 5.0, "mod_rate_hz": 8.0, "spread": 1.0}) + for options in cells: + for character in (SINGLE, DOUBLE): + result = t7_measure(AnalogDelay, RATE, 2, "ramp_fs", + character=character, **options) + self.assertTrue(result["passed"], (options, character, + result)) + corner = cells[-1] + for rate in RATES: + for channels in (2, 1): + result = t7_measure(AnalogDelay, rate, channels, "sweep_log", + **corner) + self.assertTrue(result["passed"], (rate, channels, result)) + self.assertEqual(result["frames"], whole(20.0, rate)) + self.assertIsNotNone(result["arrived"]) + + def test_the_patches_with_mix_up_to_one(self): + for patch in (0, 1, 2, 3, 5, 6): + result = t7_measure(AnalogDelay, RATE, 2, "tones_step", + patch=patch) + self.assertTrue(result["passed"], (patch, result)) + + def test_a_dry_above_unity_is_red_at_every_level(self): + for name in ("ramp_fs", "tones_step", "sweep_log"): + result = t7_measure(DryGainDelay, RATE, 2, name) + self.assertFalse(result["passed"], name) + self.assertGreater(result["differing"], 1000, name) + + +# -------------------------------------------------------------------------- +# The rows recorded unmeasured or disconfirmed by decision + + +class RecordedRows(unittest.TestCase): + def test_no_null_at_the_clock(self): + # T1 and T2b: with no sample-and-hold the first repeat has no null + # at f_clk = N / 2T (App. F1: -7.67 / -7.07 dB at 300 ms), so there + # is nothing to read f_clk from. A null 20 dB deep would mean the + # class gained a hold it was not built with. + for character, f_clk in ((SINGLE, 4096 / 0.6), (DOUBLE, 8192 / 0.6)): + h = first_repeat(AnalogDelay, RATE, character, time_ms=300.0) + self.assertGreater(magnitude_db(h, RATE, f_clk), -20.0) + + def test_the_only_corner_moves_with_time(self): + # T5: the class's one corner moves 75 % across 150 -> 600 ms and its + # slope over the octave above it is a one-pole's (App. F1: 3.3-3.95 + # dB/octave), against a fixed pair's 30 dB/octave. + low = crossing_hz(first_repeat(AnalogDelay, RATE, SINGLE, + time_ms=600.0), RATE) + high = crossing_hz(first_repeat(AnalogDelay, RATE, SINGLE, + time_ms=150.0), RATE) + self.assertGreater(1.0 - low / high, 0.7) + h = first_repeat(AnalogDelay, RATE, SINGLE, time_ms=300.0) + corner = crossing_hz(h, RATE) + slope = magnitude_db(h, RATE, corner) - magnitude_db( + h, RATE, 2.0 * corner) + self.assertLess(slope, 6.0) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half + + +class Tier1Fast(unittest.TestCase): + def test_mix_zero_is_a_wire_on_the_full_scale_ramp(self): + for rate in RATES: + for channels in (2, 1): + for character in (SINGLE, DOUBLE): + ramp = probes.ramp_fs(frames=rate, channels=channels) + source = probes.ArraySource(ramp, rate=rate, + channels=channels, + block=BLOCK) + effect = AnalogDelay(source, sample_rate=rate, mix=0.0, + time_ms=20.0, feedback=0.99, + modulation_ms=5.0, mod_rate_hz=8.0, + spread=1.0, character=character) + out = pull(effect, rate, channels) + self.assertTrue(np.array_equal( + out, np.array(ramp, dtype=np.int16)), + (rate, channels, character)) + + def test_silence_stays_silence(self): + for patch in range(7): + effect = AnalogDelay(silence_src(RATE // 2), sample_rate=RATE, + patch=patch) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE // 2)))), 0) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for options, seconds in (({}, 4.5), ({"patch": 1}, 1.5), + ({"patch": 3}, 5.5), ({"patch": 4}, 0.5), + ({"character": DOUBLE}, 4.5)): + declared, values, red = kit_tail(AnalogDelay, seconds, **options) + self.assertEqual(red, [], (options, values)) + self.assertLessEqual(values["tail_samples"], declared) + declared, values, red = kit_tail(HalfTail, 4.5) + self.assertNotEqual(red, [], values) + + def test_full_scale_meets_the_bound(self): + # Full scale round the loop at the patches whose Feedback sits on a + # stall window's side and at 0.99, mono, 48 kHz, short Times. + for options in ({"time_ms": 20.0, "feedback": 0.5}, + {"time_ms": 20.0, "feedback": 0.9}, + {"time_ms": 20.0, "feedback": 0.99}, + {"time_ms": 40.0, "feedback": 0.7, + "modulation_ms": 5.0, "mod_rate_hz": 8.0}): + result = fullscale_tail(AnalogDelay, **options) + self.assertTrue(result["passed"], (options, result)) + self.assertGreater(result["last"], 0) + + def test_a_raw_feedback_in_a_stall_window_never_ends(self): + # Feedback 0.5 at 600 ms, where the low-pass is slow enough to rest + # a hair above 1 LSB: handed raw, 1 LSB goes round for ever on a + # 2 LSB DC; stepped clear of the window, it ends inside the bound. + probe = AnalogDelay(silence_src(64), sample_rate=RATE, time_ms=600.0, + feedback=0.5, mix=2.0) + declared = probe.tail_samples + fill = 4 * 28800 // BLOCK * BLOCK + values = [2] * fill + [0] * (declared + RATE) + for cls, ends in ((AnalogDelay, True), (RawFeedback, False)): + effect = cls(array_src(values, 1), sample_rate=RATE, + time_ms=600.0, feedback=0.5, mix=2.0) + out = pull(effect, len(values), 1) + self.assertEqual(int(np.max(np.abs(out[-RATE:]))) == 0, ends, + cls.__name__) + + def _walk_tail(self, cls): + """600 ms of 997 Hz, Feedback 0, Mix 2; as the tone stops, Time + 600 -> 20 ms (slew 29), and one block later, with the head still + near 600 ms, 20 -> 21 ms, whose rate (|dT| / T_new, 0.048) is taken + from the Time last handed and not from where the head is. The walk + then crawls down from near 600 ms for most of a second.""" + tone = int(0.6 * RATE) // BLOCK * BLOCK + BLOCK + values = sine_values(997.0, tone, RATE, 12000) + [0] * (2 * RATE) + effect = cls(array_src(values), sample_rate=RATE, time_ms=600.0, + feedback=0.0, mix=2.0) + seen = {} + + def move(frame): + if frame == tone: + effect.set_macro(TIME_I, 0) + elif frame == tone + BLOCK: + effect.set_macro(TIME_I, midi_of_ms(21.0)) + seen["declared"] = effect.tail_samples + + out = pull(effect, len(values), on_block=move) + after = out[(tone + BLOCK) * 2:] + last = int(np.nonzero(after)[0][-1]) // 2 + 1 + return seen["declared"], last + + def test_a_falling_walk_keeps_the_old_time_in_the_tail(self): + declared, last = self._walk_tail(AnalogDelay) + self.assertGreater(last, 20000) + self.assertLessEqual(last, declared) + declared, last = self._walk_tail(TargetOnlyTail) + self.assertGreater(last, declared) + + def test_the_swing_is_in_the_tail(self): + # Feedback 0 at 20 ms with a 5 ms swing: the repeat can sit up to + # 240 frames late, and the bound says so. + options = {"time_ms": 20.0, "feedback": 0.0, "modulation_ms": 5.0, + "mod_rate_hz": 8.0, "mix": 2.0} + effect = AnalogDelay(silence_src(64), sample_rate=RATE, **options) + self.assertEqual(effect.tail_samples, 960 + 240 + 1 + + ad.tone_excess(effect._damping, RATE)[0]) + faulted = NoSwingTail(silence_src(64), sample_rate=RATE, **options) + self.assertLess(faulted.tail_samples, effect.tail_samples) + # A burst ending while the triangle holds the read late is heard + # after the faulted bound. + worst = 0 + for start in range(0, 6000, 750): + values = [0] * start + sine_values(997.0, 2048, RATE, 12000) + values += [0] * 4096 + effect = AnalogDelay(array_src(values, 1), sample_rate=RATE, + **options) + out = pull(effect, len(values), 1) + nonzero = np.nonzero(out[start + 2048:])[0] + if nonzero.size: + worst = max(worst, int(nonzero[-1]) + 1) + self.assertLessEqual(worst, AnalogDelay( + silence_src(64), sample_rate=RATE, **options).tail_samples) + self.assertGreater(worst, faulted.tail_samples) + + def test_mono_holds_spread_at_zero(self): + # Section 4: at one channel the node's cross-feed sends the repeat + # nowhere; the class holds it at 0, and the repeats go on. + def repeats(cls): + values = [0] * (3 * 4800 + 512) + values[0] = 12000 + effect = cls(array_src(values, 1), sample_rate=RATE, + time_ms=100.0, feedback=0.7, mix=2.0, spread=1.0) + out = left(pull(effect, len(values), 1), 1) + return [float(np.max(np.abs(out[k * 4800:k * 4800 + 256]))) + for k in (1, 2, 3)] + clean = repeats(AnalogDelay) + self.assertTrue(all(r > 1000 for r in clean), clean) + faulted = repeats(SpreadInMono) + self.assertEqual(faulted[1:], [0.0, 0.0]) + + def test_modulation_is_the_table_and_a_time_move_leaves_it(self): + with NodeSpy(): + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE, + modulation_ms=5.0, mod_rate_hz=1.0) + self.assertEqual(effect._delay._handed["wow_depth_ms"], 5.0) + self.assertAlmostEqual(effect._delay._handed["wow_hz"], 1.0) + pull(effect, BLOCK) + effect.set_macro(TIME_I, 64) + self.assertEqual(effect._delay._handed["wow_depth_ms"], 5.0) + self.assertNotIn("wow_shape", effect._delay._writes[-1]) + table = ad.triangle_table() + self.assertEqual((table[0], table[64], table[128], table[192]), + (0, 32767, 0, -32767)) + + def test_the_swing_reads_as_a_triangle(self): + # App. F4: 300 ms, 5 ms at 1 Hz reads +-240 frames off a ramp. + rate = RATE + start = 20000 + origin = start - 15200 + frames = start + rate + 100 + n = np.arange(frames) + ramp = ((n - origin) % 65536) - 32768 + effect = AnalogDelay(array_src(ramp.tolist(), 1, rate), + sample_rate=rate, mix=2.0, feedback=0.0, + modulation_ms=5.0, mod_rate_hz=1.0) + out = left(pull(effect, frames, 1), 1) + delay = (ramp - out)[start:start + rate] + swing = np.max(delay) - np.min(delay) + self.assertAlmostEqual(swing / 2.0, 240.0, delta=3.0) + + def test_reset_empties_the_line(self): + values = [0] * 256 + sine_values(997.0, 2048, RATE, 12000) + values += [0] * RATE + effect = AnalogDelay(array_src(values), sample_rate=RATE, + time_ms=100.0, feedback=0.9, mix=2.0) + pull(effect, 2304) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.max(np.abs(pull(effect, RATE // 2)))), 0) + + def test_deinit_leaves_the_source(self): + source = array_src(sine_values(440.0, 1024, RATE, 8000)) + effect = AnalogDelay(source, sample_rate=RATE) + pull(effect, 256) + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + + def test_click_delay_is_zero(self): + for rate in (48000, 44100): + values = [0] * 2048 + values[10] = 30000 + effect = AnalogDelay(array_src(values, 2, rate), sample_rate=rate) + out = left(pull(effect, 2048), 2) + self.assertEqual(int(np.argmax(np.abs(out))), 10) + self.assertEqual(out[10], 30000) + self.assertEqual(effect.latency_samples, 0) + + def test_the_input_ceiling(self): + # Below Mix 1 an input peaking at floor(32767 (1 - Mix)) - 1 cannot + # reach the rail: a square at that peak, Feedback 0.99, 20 ms. + mix = 0.4 + peak = int(math.floor(32767 * (1.0 - mix))) - 1 + values = ([peak] * 24 + [-peak - 1] * 24) * 2000 + effect = AnalogDelay(array_src(values), sample_rate=RATE, mix=mix, + time_ms=20.0, feedback=0.99) + out = pull(effect, len(values)) + self.assertLess(int(np.max(out)), 32767) + self.assertGreater(int(np.min(out)), -32768) + self.assertGreater(int(np.max(out)), peak + 5000) + + +class SyncAndTransport(unittest.TestCase): + def _synced(self, bpm, patch=6): + def transport(): + return (True, 0.0, bpm, 4, 4) + return AnalogDelay.create(silence_src(64), RATE, transport=transport, + patch=patch) + + def test_a_host_sets_time_from_division(self): + effect = self._synced(120.0) + self.assertEqual(effect._frames, 18000) + effect.set_macro(DIVISION_I, 127) # 1/1 = 2000 ms, clamps + self.assertEqual(effect._frames, 28800) + self.assertAlmostEqual(effect.get_macro(TIME_I), 127.0) + + def test_no_host_or_a_bad_tempo_leaves_time_on_the_knob(self): + effect = AnalogDelay(silence_src(64), sample_rate=RATE, patch=6) + self.assertEqual(effect._frames, whole(grid_ms(101), RATE)) + for bpm in (0.0, -1.0, float("nan"), float("inf"), None): + effect = self._synced(bpm) + self.assertEqual(effect._frames, whole(grid_ms(101), RATE), bpm) + + def test_the_transport_is_read_only_with_sync_on(self): + reads = [] + + def transport(): + reads.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = AnalogDelay.create(silence_src(512), RATE, + transport=transport) + self.assertEqual(reads, []) + effect.set_macro(SYNC_I, 127) + self.assertGreater(len(reads), 0) + + def test_a_patch_moves_time_once(self): + # program_change refreshes once, so a patch change from a synced + # patch to an unsynced one lands the new patch's own Time and walks + # to it at the clock's law from the synced Time. + for cls, frames in ((AnalogDelay, whole(grid_ms(41), RATE)), + (PerMacroPatch, 18000)): + effect = cls.create(silence_src(64), RATE, + transport=lambda: (True, 0.0, 120.0, 4, 4), + patch=6) + effect.program_change(1) + self.assertEqual(effect._frames, frames, cls) + with NodeSpy(): + effect = self._synced(120.0) + mark = len(effect._delay._writes) + effect.program_change(1) + writes = effect._delay._writes[mark:] + self.assertEqual(len(writes), 1) + k = whole(grid_ms(41), RATE) + self.assertAlmostEqual(writes[0]["delay_slew"], + abs(k - 18000) / float(k)) + effect = PerMacroPatch.create( + silence_src(64), RATE, + transport=lambda: (True, 0.0, 120.0, 4, 4), patch=6) + mark = len(effect._delay._writes) + effect.program_change(1) + self.assertEqual(len(effect._delay._writes[mark:]), 8) + + def test_another_macro_leaves_a_walk_at_its_rate(self): + with NodeSpy(): + effect = AnalogDelay(silence_src(8 * BLOCK), sample_rate=RATE) + pull(effect, BLOCK) + effect.set_macro(TIME_I, midi_of_ms(100.0)) + slew = effect._delay._handed["delay_slew"] + self.assertAlmostEqual(slew, 2.0) + effect.set_macro(FEEDBACK_I, 90) + self.assertEqual(effect._delay._handed["delay_slew"], slew) + + +# -------------------------------------------------------------------------- +# The two checks every planted fault and every row is held to + + +class FaultsAreUnreachable(unittest.TestCase): + """Every fault's reachability walk, at 48, 44.1 and 22.05 kHz, reading + what the node is handed (or what the output does) at each position.""" + + CHECKED = 8 * 17 + 7 + + def test_every_fault_is_off_the_surface_at_three_rates(self): + for rate in RATES: + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + self.assertNotEqual(result["target"], result["clean"]) + + +class NullBuildRed(unittest.TestCase): + """Every demonstrated row goes red on the class built as a wire, beside + a control on the real class that must pass.""" + + def test_every_row_is_red_on_a_wire(self): + for name, measure in ( + ("T2a", t2a_measure), + ("T3", t3_measure), + ("T6", t6_measure), + ("T7", t7_measure)): + with self.subTest(row=name): + result = kit_faults.null_build_red( + AnalogDelay, measure, label="AnalogDelay %s" % name) + self.assertFalse(result["null"]["passed"], name) + self.assertTrue(result["control"]["passed"], name) + + +if __name__ == "__main__": + unittest.main() From bc7b72d5c1c7c2c553d3cf988e343cfbea11bfea Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:45:10 -0500 Subject: [PATCH 24/88] TapeDelay Station B fix round 1: reachability reads the handed state; T1b's no-step clause and its two faults The fault walk read a marker attribute only the faulted subclass set, so it could not fail. Every walk now reads what the node is handed (a NodeSpy on FeedbackDelay.set), at 48, 44.1 and 22.05 kHz, and the walk is shown able to fail: the reviewer's DialableTape (Spacing 20 um) is FaultReachable at Spacing grid 127, the old NoFlutterLineTape at Flutter grid 0, and the two T3 faults read by the corner alone are FaultInert at 350 ms, which is why they are read by the handed corner's law over Time (180 over 600 ms). T4's two-line fault was the Flutter 0 position in disguise. It is replaced by FlutterOnWowLineTape, the flutter component written at the wow line's harmonic, which no Wow and Flutter pair writes; red on the two-line clause at 48 and 22.05 kHz. T1b's ramp and Glide cells now measure the no-step clause over T1a's windows, with the walk end read off a ramp render, and the two faults the dossier names are built: StaircaseTape (red at 1.038 of the bar at grid 89 and 1.199 on the ramp) and PerBlockSlidingTape (sliding-head repeats 2-4 back to -3.7, -5.2, -7.4 cents, red on the repeats clause). --- tests/test_cpython_effects_tapedelay.py | 604 ++++++++++++++++++++---- 1 file changed, 506 insertions(+), 98 deletions(-) diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 8c81050..1e5284d 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -6,8 +6,9 @@ design). Each demonstrated row here is the measurement at a few of the cells its *Quantified over* column names, the same measurement red on a planted fault of the same kind, the fault shown unreachable from every macro -position and shipped patch on both characters, and the measurement red on -the class built as a wire. The exhaustive grids (every Glide grid position, +position and shipped patch at three rates by what the node is handed (fix +round 1: the first walk read a marker only the fault set, and could not +fail), and the measurement red on the class built as a wire. The exhaustive grids (every Glide grid position, every Spacing and Time position, three rates for every cell) live in the evidence pack, not in this file. """ @@ -55,10 +56,6 @@ class DoubleWalkTape(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_double_walk = True - def _walk_rate(self, from_ms, to_ms): rate = TapeDelay._walk_rate(self, from_ms, to_ms) if self._character == tape.VARISPEED: @@ -72,10 +69,6 @@ class GlideScaledVarispeed(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_glide_scaled = True - def _walk_rate(self, from_ms, to_ms): rate = TapeDelay._walk_rate(self, from_ms, to_ms) if self._character == tape.VARISPEED: @@ -91,10 +84,6 @@ class WalkAtZeroTape(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_walk_at_zero = True - def _walk_rate(self, from_ms, to_ms): rate = TapeDelay._walk_rate(self, from_ms, to_ms) if self._character == tape.SLIDING_HEAD and rate <= 0.0: @@ -110,7 +99,6 @@ class LoopShiftTape(TapeDelay): def _refresh(self): TapeDelay._refresh(self) - self._plant_loop_shift = True self._delay.set(loop_semitones=0.12) @@ -119,10 +107,6 @@ class DoubleCornerTape(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_double_corner = True - def _corner_hz(self, time_ms, spacing_um): return 2.0 * TapeDelay._corner_hz(self, time_ms, spacing_um) @@ -149,7 +133,6 @@ def _build(self, *arguments, **keywords): def _refresh(self): TapeDelay._refresh(self) - self._plant_post_loss = True self._delay.set(damping_hz=0.0) if getattr(self, "_post", None) is not None: self._post.set(damping_hz=self._damping) @@ -160,10 +143,6 @@ class SquareLawTape(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_square_law = True - def _corner_hz(self, time_ms, spacing_um): corner = TapeDelay._corner_hz(self, time_ms, spacing_um) if self._character == tape.VARISPEED: @@ -178,10 +157,6 @@ class HalfFollowTape(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_half_follow = True - def _corner_hz(self, time_ms, spacing_um): corner = TapeDelay._corner_hz(self, time_ms, spacing_um) if self._character == tape.SLIDING_HEAD: @@ -190,15 +165,28 @@ def _corner_hz(self, time_ms, spacing_um): return corner -class NoFlutterLineTape(TapeDelay): - """T4's two-line clause: the flutter line deleted from the table while - Flutter is up.""" +class FlutterOnWowLineTape(TapeDelay): + """T4's two-line clause: the flutter component written at the wow + line's harmonic (72) instead of its own (512), so the table carries one + line in 0.2-12 Hz. No position reaches it: the clean table always puts + Flutter at harmonic 512, and at Flutter 0 the wow line carries only the + wow, with the drift at its fixed 0.25 ms per cent of Wow beside it.""" NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_no_flutter = True + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, + flutter_harmonic=tape.WOW_HARMONIC) + + +class NoFlutterLineTape(TapeDelay): + """The first round's two-line fault, kept only to show the table walk + can fail: the flutter line deleted while Flutter is up is the table the + clean class writes at Flutter grid 0 (the reviewer's 0 differing + samples), a macro position in disguise (pattern revision section 1.3). + It is not a planted fault of any row.""" + + NAME = 'TapeDelay' def _write_table(self, wow_cents, flutter_cents, out): return tape.wow_table(wow_cents, 0.0, out) @@ -209,10 +197,6 @@ class Harmonic504Tape(TapeDelay): NAME = 'TapeDelay' - def _refresh(self): - TapeDelay._refresh(self) - self._plant_harmonic_504 = True - def _write_table(self, wow_cents, flutter_cents, out): return tape.wow_table(wow_cents, flutter_cents, out, flutter_harmonic=504) @@ -224,27 +208,124 @@ class NoDriftTape(TapeDelay): NAME = 'TapeDelay' + def _write_table(self, wow_cents, flutter_cents, out): + return tape.wow_table(wow_cents, flutter_cents, out, drift=False) + + +class DialableTape(TapeDelay): + """The reviewer's control for the walk itself: Spacing forced to 20 um, + which Spacing MIDI 127 and patch 5 both play. Not a fault of any row; + the damping reading must call it reachable.""" + + NAME = 'TapeDelay' + + def _corner_hz(self, time_ms, spacing_um): + return TapeDelay._corner_hz(self, time_ms, tape.SPACING_MAX_UM) + + +class _Stepper: + """Pulls the owner's node, letting the owner move its delay from Python + first: the finest a Python-driven Time can move is once per block + (DigitalDelay's `_Stepper`).""" + + def __init__(self, owner): + self._owner = owner + node = owner._delay + self.sample_rate = node.sample_rate + self.channel_count = node.channel_count + self.bits_per_sample = 16 + self.samples_signed = True + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + audiocore.reset_buffer(self._owner._delay) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self._owner._step() + return audiocore.get_buffer(self._owner._delay, + single_channel_output, audio_channel) + + +class StaircaseTape(TapeDelay): + """T1b's no-step clause: `delay_ms` stepped from Python once per + 256-frame block with the node's slew off, at the rate the character + asks for. No position reaches it: Glide grid 0 is one jump, and every + other position is the node's own per-frame walk, handed once per + move.""" + + NAME = 'TapeDelay' + + def _build(self, *arguments, **keywords): + self._current_ms = None + self._step_per_block = 0.0 + TapeDelay._build(self, *arguments, **keywords) + self._output = _Stepper(self) + def _refresh(self): TapeDelay._refresh(self) - self._plant_no_drift = True + if self._current_ms is None or self._fresh: + self._current_ms = self._node_ms + self._step_per_block = self._slew * BLOCK * 1000.0 / self._sample_rate + self._delay.set(delay_slew=0.0, delay_ms=self._current_ms) + + def _step(self): + target = self._node_ms + step = self._step_per_block + current = self._current_ms + if current == target: + return + if step <= 0.0 or abs(target - current) <= step: + current = target + elif target > current: + current += step + else: + current -= step + self._current_ms = current + self._delay.set(delay_ms=current) - def _write_table(self, wow_cents, flutter_cents, out): - return tape.wow_table(wow_cents, flutter_cents, out, drift=False) +class PerBlockSlidingTape(TapeDelay): + """T1b's sliding-head repeats clause: the moves handed the tape + equation's T(t) once per block (a motor, not a head), a per-block hook + the class does not have. The speed in each block is L / T, T the Time + last asked and L the Time the instance was built at; T at the block's + end is the span back over which the tape ran L. With it the repeats + after a gesture telescope back to the source, as a varispeed's do.""" -FAULTS = ( - (DoubleWalkTape, "_plant_double_walk"), - (GlideScaledVarispeed, "_plant_glide_scaled"), - (WalkAtZeroTape, "_plant_walk_at_zero"), - (LoopShiftTape, "_plant_loop_shift"), - (DoubleCornerTape, "_plant_double_corner"), - (PostLossTape, "_plant_post_loss"), - (SquareLawTape, "_plant_square_law"), - (HalfFollowTape, "_plant_half_follow"), - (NoFlutterLineTape, "_plant_no_flutter"), - (Harmonic504Tape, "_plant_harmonic_504"), - (NoDriftTape, "_plant_no_drift"), -) + NAME = 'TapeDelay' + + def _build(self, *arguments, **keywords): + self._speeds = None + TapeDelay._build(self, *arguments, **keywords) + self._length = float(self._frames) + self._speeds = [] + self._current = float(self._frames) + self._output = _Stepper(self) + + def _refresh(self): + TapeDelay._refresh(self) + if self._speeds is not None: + self._delay.set(delay_slew=0.0, + delay_ms=self._current * 1000.0 + / self._sample_rate) + + def _step(self): + self._speeds.append(self._length / self._frames) + remaining = self._length + span = 0.0 + for speed in reversed(self._speeds): + if speed * BLOCK >= remaining: + span += remaining / speed + remaining = 0.0 + break + span += BLOCK + remaining -= speed * BLOCK + if remaining > 0.0: + span += remaining / self._speeds[0] + if span != self._current: + slew = max(abs(span - self._current) / BLOCK, 1e-9) + self._current = span + self._delay.set(delay_slew=slew, + delay_ms=span * 1000.0 / self._sample_rate) # -- sources and renders -------------------------------------------------- @@ -681,22 +762,126 @@ def test_a_glide_scaled_walk_is_red(self): self.assertNotEqual(a, c) +#: How far either side of the nominal walk end the edge window reaches: the +#: float32 walk lands -65 to +16 frames off it (dossier App. F1'). +WALK_SLACK = 128 + + +def ramp_walk_end(cls, a, b, rate, character, start, **options): + """Where the read head stops, read off a render of the same move with + a slope-1/k ramp in place of the tone (DigitalDelay's `walk_end_ramp`): + the wet output is the ramp delayed, so D[n] = n - k (y[n] + 30 000) is + the delay in frames to within k, plus the loss low-pass's constant lag. + The walk ends at the last frame whose D is more than 2k + 2 frames off + the settled D of the render's last 0.2 s.""" + opts = dict(HELD) + opts.update(options) + slew = abs(frames_of(b, rate) - frames_of(a, rate)) + frames = start + int(slew * 12) + rate // 2 + k = max(1, -(-frames // 60000)) + ramp = np.arange(frames) // k - 30000.0 + effect = cls(src_of(ramp, 1, rate), time_ms=a, character=character, + **opts) + y = render(effect, frames, {start: set_time(b)})[:, 0] + d = np.arange(frames) - k * (y + 30000.0) + settled = float(np.median(d[-rate // 5:])) + off = np.nonzero(np.abs(d[start:] - settled) > 2 * k + 2)[0] + return start + (int(off[-1]) + 1 if len(off) else 0) + + +def no_step(y, start, end, a, b, law_hz, rate, amp, character, damping): + """T1b's no-step clause, T1a's three windows: inside the walk, the + shifted tone's own slope at T_new through the corner in force; in the + first five time constants after either end, DigitalDelay's signed law + max(1, ratio) x the unfiltered unramped maximum; before and after, the + 997 Hz tone's own slope at each Time. Each bar plus 5 %. Returns each + window's largest first difference over its bar, in that order. The + before window's bar is the same tone's own render, so it reads + 1 / 1.05 exactly on a clean class; the four after it are the clause.""" + settle = int(math.ceil(5 * rate / (2 * math.pi * damping))) + raw = 2.0 * math.pi * TONE / rate * amp + signed = max(1.0, law_hz / TONE) * raw + windows = ( + (start - 4000, start, steady_slope(TONE, a, character, rate, amp)), + (start, start + settle, signed), + (start + settle, end - WALK_SLACK, + steady_slope(law_hz, b, character, rate, amp)), + (end - WALK_SLACK, end + settle + WALK_SLACK, signed), + (end + settle + WALK_SLACK, end + settle + WALK_SLACK + rate // 5, + steady_slope(TONE, b, character, rate, amp))) + out = [] + for lo, hi, bar in windows: + got = float(np.max(np.abs(np.diff(y[lo:hi])))) if hi - lo > 2 \ + else 0.0 + out.append(got / (1.05 * bar)) + return out + + +def glide_cell(cls, grid, a, b, rate=RATE, amp=12000.0): + """One of T1b's Glide-grid cells, sliding-head: each binade piece's + pitch against 1 - dT/dt written from the Glide asked (never the class's + slew function), the residual 50-250 ms after, and the no-step clause.""" + law = 1180.0 / _component.macro_value(TapeDelay._MACRO_RANGES[GLIDE_I], + grid / 127.0) + start = start_of(a, rate) + frames = start + 3 * rate + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), time_ms=a, + character=tape.SLIDING_HEAD, **HELD) + effect.set_macro(GLIDE_I, grid) + y = render(effect, frames, {start: set_time(b)})[:, 0] + fa, fb = frames_of(a, rate), frames_of(b, rate) + walk = int(abs(fb - fa) / law) + want = TONE * (1.0 + law if b < a else 1.0 - law) + pieces = [] + edges = [start] + power = 1 << int(math.log2(max(fa, fb))) + if min(fa, fb) < power < max(fa, fb): + edges.append(start + int(abs(power - fa) / law)) + edges.append(start + walk) + for lo, hi in zip(edges[:-1], edges[1:]): + trim = min(400, (hi - lo) // 10) + got = lsq_hz(y[lo + trim:hi - trim], rate) + pieces.append(cents(got / want) if got == got else float("inf")) + after = lsq_hz(y[start + walk + int(0.05 * rate): + start + walk + int(0.25 * rate)], rate) + residual = cents(after / TONE) if after == after else float("inf") + end = ramp_walk_end(TapeDelay, a, b, rate, tape.SLIDING_HEAD, start, + glide_ms=_component.macro_value( + TapeDelay._MACRO_RANGES[GLIDE_I], grid / 127.0)) + windows = no_step(y, start, end, a, b, want, rate, amp, + tape.SLIDING_HEAD, effect._damping) + step = max(windows) + red = (max(abs(p) for p in pieces) > 10.0 or abs(residual) > 1.0 + or step > 1.0) + return dict(passed=not red, pieces=pieces, residual=residual, step=step, + windows=windows, walk=walk, end=end - start) + + def ramp_cell(cls, rate=RATE, amp=12000.0): """T1b's ramp: sliding-head, Glide 2 950 ms (slew 0.4), 200 -> 400 ms. - The walk is 24 000 frames; the pitch -884.4 cents while it walks.""" + The walk is 24 000 frames; the pitch -884.4 cents while it walks. The + pitch, the residual and the no-step clause.""" y, start, effect = move(cls, 200.0, 400.0, rate, tape.SLIDING_HEAD, amp, glide_ms=2950.0) + law_slew = 1180.0 / 2950.0 walk = int(round((frames_of(400.0, rate) - frames_of(200.0, rate)) - / effect._slew)) + / law_slew)) trim = min(400, walk // 10) - law = TONE * (1.0 - 1180.0 / 2950.0) + law = TONE * (1.0 - law_slew) got = lsq_hz(y[start + trim:start + walk - trim], rate) err = cents(got / law) if got == got else float("inf") after = lsq_hz(y[start + walk + int(0.05 * rate): start + walk + int(0.25 * rate)], rate) residual = cents(after / TONE) if after == after else float("inf") - return dict(passed=abs(err) <= 10.0 and abs(residual) <= 1.0, err=err, - residual=residual, law=cents(law / TONE)) + end = ramp_walk_end(TapeDelay, 200.0, 400.0, rate, tape.SLIDING_HEAD, + start, glide_ms=2950.0) + windows = no_step(y, start, end, 200.0, 400.0, law, rate, amp, + tape.SLIDING_HEAD, effect._damping) + step = max(windows) + return dict(passed=abs(err) <= 10.0 and abs(residual) <= 1.0 + and step <= 1.0, err=err, residual=residual, step=step, + windows=windows, + law=cents(law / TONE), end=end - start, walk=walk) def step_cell(cls, rate=RATE, amp=12000.0): @@ -805,31 +990,27 @@ def test_the_ramp_at_other_levels_and_rates(self): self.assertTrue(got["passed"], (rate, amp, got)) def test_glide_grid_cells_both_ways(self): - for grid in (4, 89, 127): - law = 1180.0 / _component.macro_value( - TapeDelay._MACRO_RANGES[GLIDE_I], grid / 127.0) + # Each side of the power of two the walk crosses (8 192 frames at + # 48 kHz, 4 096 at 22.05) is read on its own, and the no-step + # clause holds over T1a's three windows. + for rate, grid in ((48000, 4), (48000, 89), (48000, 127), + (22050, 89)): for a, b in ((200.0, 150.0), (150.0, 200.0)): - opts = dict(HELD) - start = start_of(a, RATE) - frames = start + 3 * RATE - effect = TapeDelay(src_of(sine(TONE, 12000.0, frames), 1), - time_ms=a, character=tape.SLIDING_HEAD, - **opts) - effect.set_macro(GLIDE_I, grid) - y = render(effect, frames, {start: set_time(b)})[:, 0] - walk = int(abs(frames_of(b) - frames_of(a)) / law) - want = TONE * (1.0 + law if b < a else 1.0 - law) - # Read each side of the power of two the walk crosses - # (8 192 frames), as the row's estimator does. - cross = start + int(abs(8192 - frames_of(a)) / law) - for lo, hi in ((start, cross), (cross, start + walk)): - trim = min(400, (hi - lo) // 10) - got = lsq_hz(y[lo + trim:hi - trim], RATE) - self.assertLess(abs(cents(got / want)), 10.0, - (grid, a, b, lo - start)) - after = lsq_hz(y[start + walk + int(0.05 * RATE): - start + walk + int(0.25 * RATE)], RATE) - self.assertLess(abs(cents(after / TONE)), 1.0, (grid, a, b)) + got = glide_cell(TapeDelay, grid, a, b, rate) + self.assertTrue(got["passed"], (rate, grid, a, b, got)) + + def test_a_staircase_is_red_on_the_no_step_clause(self): + # Station A's cell (App. F8): grid 89, 200 -> 150 ms, 48 kHz. + got = glide_cell(StaircaseTape, 89, 200.0, 150.0) + self.assertFalse(got["passed"], got) + self.assertGreater(got["step"], 1.0, got) + clean = glide_cell(TapeDelay, 89, 200.0, 150.0) + self.assertLess(clean["step"], 1.0, clean) + + def test_a_staircase_is_red_on_the_ramp(self): + got = ramp_cell(StaircaseTape) + self.assertFalse(got["passed"], got) + self.assertGreater(got["step"], 1.0, got) def test_the_step_at_glide_0(self): got = step_cell(TapeDelay) @@ -849,6 +1030,17 @@ def test_a_loop_shift_is_red_on_the_gesture(self): got = gesture_verdict(LoopShiftTape) self.assertFalse(got["passed"], got) + def test_a_per_block_tape_equation_is_red_on_the_sliding_head(self): + # The sliding-head's moves driven along the tape equation once per + # block telescope: its repeats come back to the source, so the + # sliding-head clause (every repeat 1-4 over 100 cents off) fails. + slide = gesture(PerBlockSlidingTape, tape.SLIDING_HEAD) + self.assertEqual(len(slide), 4, slide) + self.assertTrue(any(abs(c) <= 100.0 for c in slide), slide) + # Measured at 48 kHz: -701.5, -3.7, -5.2, -7.4 cents. + self.assertLess(max(abs(c) for c in slide[1:]), 10.0, slide) + self.assertFalse(gesture_verdict(PerBlockSlidingTape)["passed"]) + # -- T2, T3: the loss law ------------------------------------------------- @@ -1153,9 +1345,10 @@ def test_the_stops_and_the_default_at_22k(self): self.assertTrue(got["passed"], got) def test_the_planted_tables_are_each_red_on_their_clause(self): - got = t4_verdict(*delay_trace(NoFlutterLineTape)) - self.assertFalse(got["passed"], got) - self.assertFalse(got.get("two_lines", False), got) + for rate in (48000, 22050): + got = t4_verdict(*delay_trace(FlutterOnWowLineTape, rate=rate)) + self.assertFalse(got["passed"], (rate, got)) + self.assertFalse(got.get("two_lines", False), (rate, got)) got = t4_verdict(*delay_trace(Harmonic504Tape)) self.assertFalse(got["passed"], got) self.assertFalse(got["ratio_ok"], got) @@ -1381,19 +1574,234 @@ def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): self.assertGreater(self._rails(-1.7, 2, 4, "sliding-head"), 0) -class FaultsAreUnreachable(unittest.TestCase): - """Every planted fault reads a state no macro position or shipped patch - reaches, on either character (11 macros x 17 positions + 8 patches).""" +# -- reachability: what the node is handed -------------------------------- +# +# Fix round 1: the first walk read a marker attribute only the faulted +# subclass set, so it could not fail (the reviewer's `DialableTape` passed +# it). Every reading below is a value the class handed its node, or a law +# over handed values at the position walked, and nothing else. - def test_every_fault_is_off_the_surface(self): - for character in tape.CHARACTERS: - def build(cls, _c=character): - return cls(src_of(np.zeros(512)), character=_c) - for faulted, flag in FAULTS: - result = kit_faults.fault_reachability( - TapeDelay, faulted, - lambda e, _f=flag: bool(getattr(e, _f, False)), build) - self.assertEqual(result["checked"], 11 * 17 + 8) +class NodeSpy: + """While active, every `audioecho.FeedbackDelay.set` call records its + options on the node: `_handed` (the latest value of each option) and + `_writes` (each call's options, in order).""" + + def __enter__(self): + node_class = tape.audioecho.FeedbackDelay + original = node_class.set + self._restore = (node_class, original) + + def watched(node, **options): + if not hasattr(node, "_handed"): + node._handed = {} + node._writes = [] + node._handed.update(options) + node._writes.append(dict(options)) + return original(node, **options) + + node_class.set = watched + return self + + def __exit__(self, *exc): + node_class, original = self._restore + node_class.set = original + return False + + +def copy_of(effect): + """A fresh instance of the same class and character at `effect`'s macro + positions, on silence at its rate.""" + other = type(effect)(src_of(np.zeros(4096), 2, effect._sample_rate), + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + return other + + +def nudge_time(effect): + """Move Time 32 grid steps, toward the middle of the knob.""" + now = effect.get_macro(TIME_I) + effect.set_macro(TIME_I, now + 32.0 if now < 64.0 else now - 32.0) + + +def pull(effect, blocks): + for _ in range(blocks): + audiocore.get_buffer(effect.output) + + +def read_damping(effect): + """The `damping_hz` handed to the loop now.""" + return round(float(effect._delay._handed["damping_hz"]), 2) + + +def k3_law(spacing_m): + """Eq. (13)'s half-power wavenumber, by bisection on this file's own + `eq13_db` (not the class's `k3_of`).""" + lo, hi = 1.0, 1e7 + for _ in range(100): + mid = math.sqrt(lo * hi) + if float(eq13_db(mid / (2 * math.pi), 1.0, spacing_m)) > -3.0103: + lo = mid + else: + hi = mid + return math.sqrt(lo * hi) + + +def speed_law(character, time_ms): + """Dossier section 6's speed law, written here from the numbers.""" + if character == tape.SLIDING_HEAD: + return 0.2032 + return 0.40 * 180.0 / min(600.0, max(180.0, time_ms)) + + +def read_corner_against_law(effect): + """The handed `damping_hz` over the pre-warped eq. (13) corner at the + Time and Spacing the knobs' labels say and the character's speed.""" + time_ms = min(effect._max_time_ms, effect.macro(TIME_I)) + spacing = effect.macro(SPACING_I) + fc = speed_law(effect._character, time_ms) * k3_law(spacing * 1e-6) \ + / (2 * math.pi) + law = tape.nominal_damping_hz(fc, effect._sample_rate) + return round(effect._delay._handed["damping_hz"] / law, 3) + + +def read_speed_law(effect): + """How the handed corner follows Time at these positions: on a copy, + the `damping_hz` handed at Time 180 ms over the one at 600 ms (3.333 on + varispeed, 1 on sliding-head).""" + other = copy_of(effect) + other.set_macro(TIME_I, time_midi(180.0)) + fast = other._delay._handed["damping_hz"] + other.set_macro(TIME_I, time_midi(600.0)) + slow = other._delay._handed["damping_hz"] + other.deinit() + return round(fast / slow, 3) + + +def read_loss_placement(effect): + """(the `damping_hz` handed to the loop, the one handed to a node after + it).""" + post = getattr(effect, "_post", None) + after = post._handed["damping_hz"] if post is not None else 0.0 + return (round(float(effect._delay._handed["damping_hz"]), 2), + round(float(after), 2)) + + +def read_loop_shift(effect): + """The `loop_semitones` handed to the node (never, on the clean + class).""" + return round(float(effect._delay._handed.get("loop_semitones", 0.0)), 4) + + +def read_table(effect): + """(the handed `wow_depth_ms`, the handed table's points).""" + handed = effect._delay._handed + shape = handed["wow_shape"] + return (round(float(handed["wow_depth_ms"]), 9), + tuple(shape) if shape is not None else None) + + +def read_walk(effect): + """(the `delay_slew` handed for one Time move, the law's) on a copy at + these positions: varispeed's law is |dT| / T_new from the two handed + `delay_ms`; sliding-head's is 1 180 ms over the Glide the knob's label + says, pinned at 0.99, and 0 at grid 0.""" + other = copy_of(effect) + before = other._delay._handed["delay_ms"] + nudge_time(other) + after = other._delay._handed["delay_ms"] + handed = other._delay._handed["delay_slew"] + if other._character == tape.VARISPEED: + law = abs(after - before) / after + elif other._macros[GLIDE_I] <= 0.0: + law = 0.0 + else: + law = min(0.99, 1180.0 / other.macro(GLIDE_I)) + other.deinit() + return (round(float(handed), 6), round(float(law), 6)) + + +def read_python_steps(effect): + """How many times `delay_ms` is handed while eight blocks are pulled + after one Time move, on a copy at these positions: the class hands it + once, on the move; a Python-driven Time hands it every block.""" + other = copy_of(effect) + pull(other, 1) + mark = len(other._delay._writes) + nudge_time(other) + pull(other, 8) + writes = sum(1 for w in other._delay._writes[mark:] if "delay_ms" in w) + other.deinit() + return writes + + +#: (name, fault, reading, constructor options for both builds). A fault +#: whose law is live only away from the defaults is built where it is live +#: (DigitalDelay's corner-cell precedent): `GlideScaledVarispeed` is the +#: clean class at Glide 6 000 ms, so it is built at grid 1's 1 222 ms; +#: `WalkAtZeroTape` differs only at Glide 0. +SLIDE = {"character": tape.SLIDING_HEAD} +REACH_WALKS = ( + ("DoubleWalkTape", DoubleWalkTape, read_walk, {}), + ("GlideScaledVarispeed", GlideScaledVarispeed, read_walk, + {"glide_ms": 1222.0}), + ("WalkAtZeroTape", WalkAtZeroTape, read_walk, + dict(SLIDE, glide_ms=0.0)), + ("StaircaseTape", StaircaseTape, read_python_steps, SLIDE), + ("LoopShiftTape", LoopShiftTape, read_loop_shift, {}), + ("PerBlockSlidingTape", PerBlockSlidingTape, read_python_steps, SLIDE), + ("DoubleCornerTape varispeed", DoubleCornerTape, + read_corner_against_law, {}), + ("DoubleCornerTape sliding-head", DoubleCornerTape, + read_corner_against_law, SLIDE), + ("PostLossTape", PostLossTape, read_loss_placement, {}), + ("SquareLawTape", SquareLawTape, read_speed_law, {}), + ("HalfFollowTape", HalfFollowTape, read_speed_law, SLIDE), + ("FlutterOnWowLineTape", FlutterOnWowLineTape, read_table, {}), + ("Harmonic504Tape", Harmonic504Tape, read_table, {}), + ("NoDriftTape", NoDriftTape, read_table, {}), +) + + +def reach(faulted, reading, rate, ctor): + """`kit_faults.fault_reachability` at `rate`, the node watched.""" + def build(cls): + return cls(src_of(np.zeros(512), 2, rate), **ctor) + + with NodeSpy(): + return kit_faults.fault_reachability(TapeDelay, faulted, reading, + build) + + +class FaultsAreUnreachable(unittest.TestCase): + """Every planted fault's reachability walk, at 48, 44.1 and 22.05 kHz, + reading what the node is handed at each position (11 macros x 17 + positions + 8 patches); and two builds the walk must call reachable, so + the readings are shown able to fail.""" + + CHECKED = 11 * 17 + 8 + + def test_every_fault_is_off_the_surface_at_three_rates(self): + for rate in (48000, 44100, 22050): + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + result = reach(faulted, reading, rate, ctor) + self.assertEqual(result["checked"], self.CHECKED) + + def test_a_dialable_fault_is_called_reachable(self): + # Spacing forced to 20 um: Spacing MIDI 127 plays it. + with self.assertRaises(kit_faults.FaultReachable): + reach(DialableTape, read_damping, RATE, {}) + # The first round's two-line fault is the table Flutter 0 writes. + with self.assertRaises(kit_faults.FaultReachable): + reach(NoFlutterLineTape, read_table, RATE, {}) + # Read by the handed corner alone, the T3 faults are the clean + # class at 350 ms; that is why they are read by the corner's law + # over Time. + for faulted, character in ((SquareLawTape, tape.VARISPEED), + (HalfFollowTape, tape.SLIDING_HEAD)): + with self.assertRaises(kit_faults.FaultInert): + reach(faulted, read_damping, RATE, {"character": character}) def test_the_varispeed_walk_is_the_tape_equation_everywhere(self): effect = TapeDelay(src_of(np.zeros(RATE))) From d9054d892e1b58ba0669fdcc5b2f0cec4def50ff Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:50:17 -0500 Subject: [PATCH 25/88] AnalogDelay Station B fix round: small Time moves land; knob-turn settle disclosed The walk rate is now floored at two single-precision steps of the furthest the head may sit (walk_floor), so a move whose clock-law slew rounds away in the node's float32 walk still lands on its frame. SmallTimeMovesLand holds moves of 1 to 24 frames at 48 and 44.1 kHz and shows the unfloored law short. The docstring now says how long a Time knob turned through several positions takes to settle (3.4 s for MIDI 101 -> 111 at 48 kHz, 6.9 s for 64 -> 101, 20 s for a 14-bit sweep 300 -> 400 ms), since each move takes its rate from the Time last handed. --- lib/audioeffects/rebuilt/analogdelay.py | 45 +++++++++++-- tests/test_cpython_effects_analogdelay.py | 78 +++++++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index 91648e0..42354dd 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -19,7 +19,8 @@ 20-600 ms on both, Feedback 0-0.99, and Mix 0-2 (dry at unity up to 1, wet alone at 2; Mix 0 is a wire while the line keeps recording). Turn Time while it plays and the repeats bend in pitch the way a clock step bends -them, and settle. Modulation (0-5 ms) and Mod Rate (0.05-8 Hz) wobble the +them, and settle (a turn through many positions takes seconds; see +"Turning Time" below). Modulation (0-5 ms) and Mod Rate (0.05-8 Hz) wobble the delay on a triangle, the Memory Man's chorus and vibrato: a blend with Modulation up is a chorus, the wet alone (Mix 2, patch 4) a vibrato. Spread feeds each side's repeats into the other. Sync locks Time to @@ -93,10 +94,26 @@ `T_old` to `T_new` walks the read head at |T_new - T_old| / T_new, so it holds the pitch ratio T_old / T_new for exactly T_new and then returns to unity, without a click: 200 -> 100.4 ms bends the repeats +1193 cents for -100.4 ms, 100 -> 300 ms -1902 cents for 300 ms. The walk's rate is taken -from the Time last handed to the node, so a move issued while the last one -still walks runs at a rate that no longer matches the head, and the pitch -claim does not cover it. It is claimed for moves of up to 3 : 1 and inputs +100.4 ms, 100 -> 300 ms -1902 cents for 300 ms. The node walks the head in +single precision, and a rate under half a step of the head's position +would round back to where it was and leave the head short for good, so +the rate never goes below two single-precision steps of the furthest the +head may sit (1/512 of a frame per frame from 8 192 to 16 384 frames, +1/256 above). That only touches moves of under 0.4 % of T: they land in +less than T_new, bent by at most 7 cents (300 -> 300.1 ms, 5 frames at +48 kHz, lands in 0.053 s). + +**Turning Time takes seconds to settle.** The walk's rate is taken from +the Time last handed to the node, not from where the head is, because the +class cannot see the head. A knob turned through several positions sends +several moves, and once the head falls behind, the last move's small rate +carries it the rest of the way. At 48 kHz, 7-bit positions one block +apart: MIDI 101 -> 111 (299 -> 391 ms, 10 moves) still differs from the +same move made as one jump 3.4 s after the last move, where the jump has +landed in 0.39 s; MIDI 64 -> 101 (111 -> 299 ms, 37 moves) 6.9 s. A +14-bit controller's fine steps are slower still: 300 -> 400 ms in 1386 +moves takes 20 s, walking at the floor. The pitch claim covers none of +this, only a move from rest. It is claimed for moves of up to 3 : 1 and inputs from -28.7 to -0.2 dBFS; quieter, int16 rounding decides the reading (13 cents off at -48.7 dBFS). The claim is about the walk itself: under feedback, each later repeat re-reads a line that was written while the @@ -239,6 +256,17 @@ def clock_slew(from_frames, to_frames): return abs(float(to_frames) - float(from_frames)) / float(to_frames) +def walk_floor(frames): + """The smallest `delay_slew` that still moves a read head sitting at up + to `frames`: two single-precision steps of `frames`. The node walks + `delay_current += slew` in single precision, and a slew under half a + step of the head's position rounds back to where it was, so the head + would stay put for good (a move under about T / 2048 frames).""" + mantissa, exponent = math.frexp(float(max(1, frames))) + del mantissa + return 2.0 ** (exponent - 23) + + def _f32(value): """`value` rounded to single precision, the same on every interpreter.""" return array("f", (value,))[0] @@ -456,8 +484,11 @@ def _node_time_ms(self, frames): return hand_off_ms(frames, self._sample_rate) def _walk_rate(self, from_frames, to_frames): - """The node's `delay_slew` for a Time move: the clock's law.""" - return clock_slew(from_frames, to_frames) + """The node's `delay_slew` for a Time move: the clock's law, floored + at two single-precision steps of the furthest the head may sit, so + a move too small for the law's rate still lands.""" + return max(clock_slew(from_frames, to_frames), + walk_floor(max(from_frames, to_frames, self._reach))) def _transport_state(self): transport = self._transport diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index fe2eab2..dcfcfdf 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -144,6 +144,17 @@ def _walk_rate(self, from_frames, to_frames): return 787.5 / 800.0 +class UnflooredWalk(AnalogDelay): + """Section 6: the clock's law with no floor. A slew under half a + single-precision step of the head's position rounds away in the node's + walk, so a small move never lands.""" + + NAME = 'AnalogDelay' + + def _walk_rate(self, from_frames, to_frames): + return ad.clock_slew(from_frames, to_frames) + + class DryGainDelay(AnalogDelay): """T7: the output +0.1 dB (`kit_faults.HiddenGain`), a dry above unity, which nothing reaches: the node's dry is exactly 1 below Mix 1.""" @@ -1052,6 +1063,73 @@ def test_the_constant_glide_is_red(self): self.assertLess(result["whole"] - result["law"], -600.0) +def noise_src(frames, rate, seed=5): + rng = np.random.RandomState(seed) + values = np.round(rng.uniform(-1.0, 1.0, frames) * 8000.0) + return probes.ArraySource(array("h", values.astype(np.int16).tobytes()), + rate=rate, channels=1, block=BLOCK) + + +def small_move_lands(cls, rate, t_old, t_new): + """(differing samples, walk floor handed) over the last 0.5 s of 3 s + after one move from rest `t_old -> t_new` through `set_macro`, against + the class built at `t_new`: 0 means the head reached the new frame.""" + fill = int(rate / BLOCK) + 1 + settled = int(3.0 * rate) + outs = [] + for time_ms in (t_old, t_new): + effect = cls(noise_src(fill * BLOCK + settled + BLOCK, rate), + sample_rate=rate, time_ms=time_ms, mix=2.0, + feedback=0.0) + pull(effect, fill * BLOCK) + if time_ms == t_old: + effect.set_macro(TIME_I, midi_of_ms(t_new)) + outs.append(pull(effect, settled)) + last = int(0.5 * rate) + return int(np.count_nonzero(outs[0][-last:] != outs[1][-last:])) + + +class SmallTimeMovesLand(unittest.TestCase): + """Section 6: every Time lands on its whole frame, a move of a few + frames included. The node walks in single precision, so the class + floors the walk rate at two single-precision steps of the furthest the + head may sit (written out here: 2^(e - 23) for a head under 2^e + frames).""" + + MOVES = ((300.0, 300.1), (300.0, 299.9), (600.0, 599.5), + (300.0, 300.02)) + + def test_moves_under_eight_frames_land(self): + for rate in (48000, 44100): + for t_old, t_new in self.MOVES: + moved = whole(t_new, rate) - whole(t_old, rate) + self.assertLess(abs(moved), 25) + with self.subTest(rate=rate, move=(t_old, t_new)): + self.assertEqual( + small_move_lands(AnalogDelay, rate, t_old, t_new), 0) + + def test_the_floor_as_handed(self): + with NodeSpy(): + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE) + effect.set_macro(TIME_I, midi_of_ms(300.1)) # +5 frames + self.assertEqual(effect._delay._handed["delay_slew"], 2.0 ** -9) + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE, + time_ms=600.0) + effect.set_macro(TIME_I, midi_of_ms(599.5)) # -24 frames + self.assertEqual(effect._delay._handed["delay_slew"], 2.0 ** -8) + effect = AnalogDelay(silence_src(4 * BLOCK), sample_rate=RATE) + effect.set_macro(TIME_I, midi_of_ms(100.0)) # the law, 2.0 + self.assertAlmostEqual(effect._delay._handed["delay_slew"], 2.0) + + def test_the_unfloored_law_leaves_the_head_short(self): + for rate in (48000, 44100): + for t_old, t_new in self.MOVES[:3]: + with self.subTest(rate=rate, move=(t_old, t_new)): + self.assertGreater( + small_move_lands(UnflooredWalk, rate, t_old, t_new), + 1000) + + class T7DryIsAWire(unittest.TestCase): def test_defaults_on_three_materials(self): for name in ("ramp_fs", "tones_step", "sweep_log"): From 48588830141d06c2814dadda330222a205c74c5b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:51:39 -0500 Subject: [PATCH 26/88] PingPongDelay fix round 1: the whole-frame landing restated with the positions the node misses at 44.1 and 22.05 kHz The node turns the handed ms back into frames in float32, and for 25 of the 128 Time positions at 44.1 kHz and 20 at 22.05 kHz no float32 value lands on the whole frame. The docstring now says so, with the positions and what it costs; two tests pin the set and the reviewer's MIDI 95 cell. --- lib/audioeffects/rebuilt/pingpongdelay.py | 28 +++++++++++--- tests/test_cpython_effects_pingpongdelay.py | 42 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index d48d4bf..511ba02 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -76,10 +76,28 @@ ping-pong's time is set to a subdivision, not played. While Time walks, the line is read between samples, and the two-tap read costs the top of the band sqrt(1 - 2 frac (1 - frac)(1 - cos 2 pi f / fs)) per pass. Every -static Time is landed on the nearest whole frame at the running rate, -floor(ms fs / 1000 + 0.5), where the read is lossless, so the repeats of a -Time you have stopped turning do not darken; the knob's milliseconds and -`get_macro(0)` stay as you set them. +static Time is handed to the node as the nearest whole frame at the +running rate, floor(ms fs / 1000 + 0.5); the knob's milliseconds and +`get_macro(0)` stay as you set them. At 48 kHz the node lands every one of +the 128 knob positions exactly on that frame, where the read is lossless, +so the repeats of a Time you have stopped turning do not darken. + +At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds +back into frames in float32, and for some Times no float32 value lands on +the whole frame, so the read sits one float32 step off it: at most 1/512 +of a frame at 44.1 kHz and 1/1024 at 22.05 kHz. That is 25 of the 128 +knob positions at 44.1 kHz (MIDI 2, 3, 4, 19, 20, 24, 25, 28, 38, 43, 47, +48, 50, 63, 65, 67, 69, 70, 83, 92, 93, 95, 107, 108, 114) and 20 at +22.05 kHz (MIDI 2, 4, 19, 28, 38, 47, 63, 65, 67, 69, 70, 83, 88, 92, 93, +95, 108, 110, 112, 114). No patch's own Time is among them, nor the +default 280 ms; a Time Sync takes from a host's tempo can be. At those +Times each pass puts up to 0.2 % of the repeat on the frame beside it +(a 20 000 click's first repeat reads 19 961 and 39 at MIDI 95, 44.1 kHz), +and the repeats darken slowly: at Feedback 0.99 the 60th repeat of a +10 kHz tone is 0.86 dB quieter than the Feedback alone makes it at +44.1 kHz, 0.98 dB at 22.05 kHz, and a 1 kHz tone 0.01-0.02 dB. The class +cannot hand the node a number that lands there; a node change is asked +for. **Repeat Tone** is the corner the loop low-pass achieves (800-16 000 Hz, the top stop out). It is inside the loop, so repeat n has passed it n @@ -122,7 +140,7 @@ it. A constructor value stays on the audio path unrounded by the knob's grid; -Time is then landed on a whole frame. A value outside a knob's span clamps +Time is then handed as a whole frame. A value outside a knob's span clamps to the nearer stop, a `tone_hz` or `cut_hz` of 0 or less is that filter out, and NaN takes that option's default. A `max_time_ms` above 1000 or NaN is 1000. diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index b58c1e1..fc90831 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -783,6 +783,48 @@ def test_time_lands_on_a_whole_frame(self): effect.set_macro(TIME_I, 127) self.assertEqual(read_frames(effect), 48000.0) + def test_where_the_node_lands_the_handed_frame(self): + # Section 6, restated at Station B fix round 1: the node turns the + # handed ms back into frames in float32 + # (`audiodsp_feedback_delay.c:148`). At 48 kHz every grid position + # lands exactly; at 44.1 and 22.05 kHz these land one float32 step + # off, which the class cannot avoid (the node ask is drafted). + off_frame = { + 48000: [], + 44100: [2, 3, 4, 19, 20, 24, 25, 28, 38, 43, 47, 48, 50, 63, 65, + 67, 69, 70, 83, 92, 93, 95, 107, 108, 114], + 22050: [2, 4, 19, 28, 38, 47, 63, 65, 67, 69, 70, 83, 88, 92, 93, + 95, 108, 110, 112, 114], + } + f32 = np.float32 + for rate in RATES: + effect = spied(rate=rate) + missed = [] + for midi in range(128): + effect.set_macro(TIME_I, midi) + ms = f32(handed(effect, "delay_ms")) + frames = (ms * f32(rate)) / f32(1000.0) + law = law_frames(law_time_ms(midi), rate) + self.assertEqual(read_frames(effect), law, (rate, midi)) + if float(frames) != law: + self.assertLessEqual(abs(float(frames) - law), + 2.0 ** -9, (rate, midi)) + missed.append(midi) + self.assertEqual(missed, off_frame[rate], rate) + + def test_an_off_frame_time_leaks_into_the_next_frame(self): + # The reviewer's cell: MIDI 95 at 44.1 kHz is 16 457 frames, and a + # 20 000 click's repeat reads 39 one frame early; at 48 kHz the same + # position is exact. + for rate, window in ((44100, [0, 39, 19961, 0]), + (48000, [0, 0, 20000, 0])): + frames = law_frames(law_time_ms(95), rate) + y = render(PingPongDelay, click(frames + 64), rate=rate, + macros={TIME_I: 95}, mix=2.0, feedback=0.0) + total = y.astype(np.int32).sum(axis=1) + self.assertEqual([int(v) for v in total[frames - 2:frames + 2]], + window, rate) + def test_a_host_echoing_time_keeps_the_frame(self): # 135 ms at 44.1 kHz is 5 953.5 frames, which the law lands up. effect = spied(rate=44100, time_ms=135.0) From e9e71b95bc51bf2692846dab35f5dfe4bcde0fb0 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:39:31 -0500 Subject: [PATCH 27/88] CombFilter: Tone's off stop no longer freezes the loop low-pass Brad's ruling of 2026-09-28: "Fix it now, same way." audioecho.FeedbackDelay leaves its loop low-pass frozen while damping_hz is 0 (audiodsp_feedback_delay.c:493-497 at v0.6.2), and CombFilter's Tone off stop handed 0 even after Tone had been in. Bringing Tone back after the ring had died played the held state out of silence: 15 070 LSB at 48 kHz, 10 110 at 44.1, 8 828 at 22.05, stereo and mono alike (300 Hz at 30 000 LSB, Tone 2 kHz, Feedback 0, Mix 2). The 26 118 / 26 123 disclosed for the two lower rates were a probe reading past its source's end, where RawSample replays the tone. Fixed as DigitalDelay and SlapbackDelay are: once Tone has been in since the node was built or reset, the off stop hands 32 x the rate (TONE_TRACK_PER_RATE), a coefficient of exactly 1.0f, so the state follows the tap. A fresh or reset instance still hands exactly 0. On a node whose loop filters track the signal while out, a coefficient of 1 is the same state, so the class is right on both. Against the filter truly out: exact on a whole-frame tuning, within 1 LSB on a fractional one, and up to 7 LSB inside a stall window, where the off stop keeps Tone in's stepped Feedback (clear_of_stalls). The finite tail_samples still holds: the 900-cell rendered check, run again with every cell also on the tracking off stop, is 1 800 cells with none past the bound or holding. Tests (TheToneOffStopTracksTheLine): Tone back in after silence is 0 at three rates, stereo and mono, and Mix 1, beside FrozenToneCombFilter (the old off stop, 15 070); the handed damping before and after Tone, after reset, and with a constructor Tone or patch; the tracking off stop against a fresh one beside LeakyTrackCombFilter (a 0.957 coefficient, red); the tail bound on the tracking off stop. Three are red on the class before this commit. The frozen assertion goes red on a fixed node: that is the signal the workaround can come out. --- CHANGELOG.md | 13 +- lib/audioeffects/README.md | 2 +- lib/audioeffects/combfilter.py | 63 ++++++-- tests/test_cpython_effects_combfilter.py | 180 +++++++++++++++++++++++ 4 files changed, 243 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc755a..ff7c32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,8 +64,17 @@ there, and are recorded in its changelog. With Tone in, Feedback is handed under 0.00003 clear of the node's stall windows (audiodsp#157), where it held 1 or 2 LSB for ever at Feedback 0.5 and 0.75; with Tone off nothing moves, and no shipped patch sits in a - window. The docstring also discloses that Tone off after Tone has been in - freezes the loop low-pass, which Tone back in from silence plays. + window. +- **`CombFilter`: Tone back in after silence no longer plays a stale + ring.** Tone off after Tone had been in froze the node's loop low-pass, + and bringing Tone back after the ring had died played what it held: + 15 070 LSB at 48 kHz (10 110 at 44.1, 8 828 at 22.05). Once Tone has been + in since a reset, the off stop now keeps the low-pass tracking the line at + a coefficient of exactly 1, as `DigitalDelay` and `SlapbackDelay` do; a + fresh or reset instance still hands no filter. Against the filter truly + out that is exact on a whole-frame tuning and within 1 LSB otherwise, and + a few LSB (7 at most measured) at a Feedback inside a stall window, where + the off stop keeps Tone in's stepped Feedback. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index ea7c38c..c6edc81 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -128,7 +128,7 @@ marginal share of one block on each board at construction defaults. | `BandPass` | audiodsp (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audiodsp#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | -| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. With Tone in, Feedback is stepped under 0.00003 clear of the node's stall windows (audiodsp#157; the 0.95 stop is one). Tone off after Tone has been in freezes the loop low-pass, and Tone back in from silence plays it (15 070 LSB at 48 kHz); `reset()` clears it. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | +| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. With Tone in, Feedback is stepped under 0.00003 clear of the node's stall windows (audiodsp#157; the 0.95 stop is one). Since 2026-09-28 Tone off after Tone has been in keeps the loop low-pass tracking the line (it had frozen, and Tone back in from silence played 15 070 LSB at 48 kHz); a fresh or reset instance hands no filter. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | ### Time and space - two adopted, the rest in `reverb.py`, `delay.py` Phase 5's first two classes, `DigitalDelay` and `SlapbackDelay`, were diff --git a/lib/audioeffects/combfilter.py b/lib/audioeffects/combfilter.py index 7a29d61..d17801c 100644 --- a/lib/audioeffects/combfilter.py +++ b/lib/audioeffects/combfilter.py @@ -108,15 +108,29 @@ Feedback knob's top, 0.95, is one of those centres. So with Tone in, the class hands the node a Feedback just outside each window, at the nearer edge, at most 0.00003 from the one you set, and the knob still reads what -you set; the tail then reaches zero inside the bound. With Tone off -nothing is moved. - -**Tone off, after Tone has been in, is not yet safe to come back from.** -The node freezes its loop low-pass while Tone is off, and bringing Tone -back after the ring has died plays what it held: 15 070 LSB at 48 kHz after -a 300 Hz tone at 30 000 LSB, Feedback 0, Mix 2. `reset()` clears it. Found -2026-09-28 and not fixed in this class; `DigitalDelay` and `SlapbackDelay` -keep the filter tracking the tap instead. +you set; the tail then reaches zero inside the bound. With Tone off, on +an instance that has not had Tone in since it was built or reset, nothing +is moved. + +**Tone off, after Tone has been in.** The node leaves its loop low-pass +frozen while it is out (`audiodsp_feedback_delay.c:493-497` at v0.6.2), and +up to 2026-09-28 this class's off stop handed it exactly that: bring Tone +back after the ring had died and it played what it held, out of silence, +15 070 LSB at 48 kHz (10 110 at 44.1, 8 828 at 22.05) after a 300 Hz tone +at 30 000 LSB, Feedback 0, Mix 2. So once Tone has been in circuit since +the last `reset()`, the off stop keeps the low-pass running at a +coefficient of exactly 1, which follows the line's tap sample for sample, +and Tone back in after silence is silent at every rate, stereo and mono +(the same cure as `DigitalDelay` and `SlapbackDelay`). Against the filter +truly out that is exact where the read lands on a whole frame (1000 Hz at +48 kHz) and within 1 LSB where it is fractional (440 Hz at every rate). +At a Feedback inside one of the node's stall windows (0.5, 0.75, 0.9 and +on up to the 0.95 stop) the off stop also hands the Feedback that Tone in +hands, moved clear by at most 0.00003, and there the ring differs from the +filter truly out by a few LSB: 7 at most in 2 s runs of noise at -30 to +0 dBFS, 20 / 440 / 4000 Hz, 48 and 44.1 kHz (2026-09-28). A fresh +instance, and one since a `reset()` that has not put Tone in, hands the +node exactly no filter, and a `reset()` makes the off stop exact again. **Two traits this class does not have.** The *negative* comb, whose peaks sit on the odd half-multiples and which sounds hollow rather than pitched, is @@ -153,9 +167,11 @@ class hands the node a Feedback just outside each window, at the nearer # node rounds the same way here. Its module moves up one level when it comes # home, so both homes are tried. try: - from .rebuilt.digitaldelay import clear_of_stalls, laps_to_zero + from .rebuilt.digitaldelay import (TONE_TRACK_PER_RATE, + clear_of_stalls, laps_to_zero) except ImportError: # pragma: no cover - after it lands - from .digitaldelay import clear_of_stalls, laps_to_zero + from .digitaldelay import (TONE_TRACK_PER_RATE, clear_of_stalls, + laps_to_zero) #: The line, in milliseconds. 20 Hz wants 50 ms and the node keeps one frame @@ -316,6 +332,11 @@ def _build(self, frequency=440.0, feedback=0.7, mix=1.0, self._fresh = True self._feedback = 0.0 self._damping = 0.0 + #: True once Tone has been handed an in-circuit corner since the + #: node was built or cleared. From then on the loop low-pass's + #: state is live, and the off stop hands `TONE_TRACK_PER_RATE` x + #: the rate, not 0. + self._tone_used = False self._init_macros( (frequency, feedback, mix, tone_hz, trim_db, glide), patch) self._fresh = False @@ -339,7 +360,7 @@ def _refresh(self): blend = 1.0 tone = self._value(3) trim_db = self._value(4) - damping = 0.0 if tone >= TONE_OFF_HZ else self._hz(tone) + damping = self._tone_damping(tone) slew = self._value(5) frames = int(math.ceil(self._sample_rate / frequency)) if self._fresh or slew <= 0.0 or frames > self._reach: @@ -366,6 +387,23 @@ def _refresh(self): self._trim.mix = (1.0 if blend > 0.0 and abs(trim_db) >= FLAT_DB else 0.0) + def _tone_damping(self, tone): + """The `damping_hz` handed to the node for a Tone of `tone` Hz. + + The node updates its loop low-pass only while the coefficient is + above 0 (`audiodsp_feedback_delay.c:493-497` at v0.6.2), so handing + 0 after Tone has been in would freeze whatever the filter held, and + a later Tone move would play it out of silence. At 32 x the rate the + coefficient is exactly 1.0f and the state follows the tap. On a node + whose loop filters track the signal while out this is the same + state, so the cure is right on both.""" + if tone < TONE_OFF_HZ: + self._tone_used = True + return self._hz(tone) + if self._tone_used: + return TONE_TRACK_PER_RATE * self._sample_rate + return 0.0 + def _apply_macro(self, index, position): del index, position self._refresh() @@ -376,6 +414,7 @@ def _loop_feedback(self, feedback, excess): def reset(self): self._fresh = True + self._tone_used = False _component.Component.reset(self) self._fresh = False diff --git a/tests/test_cpython_effects_combfilter.py b/tests/test_cpython_effects_combfilter.py index 5d61b17..7aa4d78 100644 --- a/tests/test_cpython_effects_combfilter.py +++ b/tests/test_cpython_effects_combfilter.py @@ -185,6 +185,34 @@ class ShortLatencyCombFilter(combfilter.CombFilter): LATENCY_SAMPLES = 256 +class FrozenToneCombFilter(combfilter.CombFilter): + """Tone's off stop handing the node exactly 0 even after Tone has been + in, so the loop low-pass freezes on what it held and a later Tone move + plays it out of silence. The class up to 2026-09-28 (15 070 LSB at + 48 kHz on `TheToneOffStopTracksTheLine`'s move).""" + + NAME = 'CombFilter' + + def _tone_damping(self, tone): + if tone >= combfilter.TONE_OFF_HZ: + return 0.0 + return self._hz(tone) + + +class LeakyTrackCombFilter(combfilter.CombFilter): + """The off stop after Tone has been in, tracking the tap at a + coefficient under 1: `damping_hz` at half the rate (a = 1 - e^-pi, + 0.957), a low-pass left in the loop where the knob says off.""" + + NAME = 'CombFilter' + + def _tone_damping(self, tone): + damping = combfilter.CombFilter._tone_damping(self, tone) + if self._tone_used and tone >= combfilter.TONE_OFF_HZ: + return 0.5 * self._sample_rate + return damping + + # -- helpers ---------------------------------------------------------------- def silence(frames, rate=SAMPLE_RATE, channels=CHANNELS): @@ -220,6 +248,11 @@ def pull(node, frames, channels=CHANNELS): return out[:frames * channels] +def silence_source(rate=SAMPLE_RATE, channels=CHANNELS, frames=512): + return audiocore.RawSample(silence(frames, rate, channels), + sample_rate=rate, channel_count=channels) + + def left(values, channels=CHANNELS): return values[0::channels] @@ -861,6 +894,153 @@ def test_the_stall_window_is_stepped_clear(self): self.assertEqual(held, 1) +class TheToneOffStopTracksTheLine(unittest.TestCase): + """Tone off after Tone has been in (2026-09-28, Brad's ruling of that + date). `audioecho.FeedbackDelay` freezes its loop low-pass while + `damping_hz` is 0 (`audiodsp_feedback_delay.c:493-497` at v0.6.2), so + once Tone has been in the off stop hands 32 x the rate, a coefficient + of exactly 1, and the state follows the tap. + + **When the node is fixed** (its loop filters tracking the signal while + out), the planted `FrozenToneCombFilter` in + `test_tone_back_in_after_silence_stays_silent` goes silent and that + assertion goes red: that is the signal that the class-side workaround + can come out. Nothing else here changes on such a node, because a + coefficient of 1 is the state such a node keeps by itself.""" + + def tone_back_in(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, + mix=2.0): + """300 Hz at 30 000 LSB for 0.5 s with Tone 2 kHz, Feedback 0; Tone + to its off stop as the input stops, 2 s of silence, Tone to MIDI 0: + the output's peak after that move. The source is exactly as long as + what is read, so it never wraps round and replays the tone.""" + loud = (rate // 2) // 256 * 256 + back = (loud + 2 * rate) // 256 * 256 + frames = back + rate // 4 + values = silence(frames, rate, channels) + for frame in range(loud): + value = int(round(30000 * math.sin(2.0 * math.pi * 300.0 * frame + / rate))) + for channel in range(channels): + values[frame * channels + channel] = value + effect = cls(audiocore.RawSample(values, sample_rate=rate, + channel_count=channels), + sample_rate=rate, frequency=440.0, feedback=0.0, + mix=mix, tone_hz=2000.0) + out = array.array("h") + while len(out) < frames * channels: + done = len(out) // channels + if done == loud: + effect.set_macro(3, 127) + elif done == back: + effect.set_macro(3, 0) + out.extend(memoryview(bytes(audiocore.get_buffer( + effect.output)[1])).cast("h")) + effect.deinit() + return max(abs(v) for v in out[back * channels:frames * channels]) + + def test_tone_back_in_after_silence_stays_silent(self): + for rate in (48000, 44100, 22050): + for channels in (2, 1): + self.assertEqual(self.tone_back_in( + combfilter.CombFilter, rate, channels), 0, + (rate, channels)) + self.assertEqual(self.tone_back_in(combfilter.CombFilter, mix=1.0), + 0) + # Planted: the off stop frozen at 0 plays the held state back + # (15 070 LSB at 48 kHz, 10 110 at 44.1, 8 828 at 22.05 when this + # was written). Red here on a node that keeps the state live while + # out: the workaround can then come out. + self.assertGreater(self.tone_back_in(FrozenToneCombFilter), 10000) + self.assertGreater(self.tone_back_in(FrozenToneCombFilter, 22050), + 5000) + + def test_the_off_stop_hands_no_filter_until_tone_has_been_in(self): + for rate in (48000, 44100, 22050): + effect = combfilter.CombFilter(silence_source(rate), + sample_rate=rate) + self.assertEqual(effect._damping, 0.0) + effect.set_macro(1, 127) # the 0.95 stall centre + self.assertEqual(effect._feedback, 0.95) + effect.set_macro(3, 0) + effect.set_macro(3, 127) + self.assertEqual(effect._damping, + combfilter.TONE_TRACK_PER_RATE * rate) + self.assertNotEqual(effect._feedback, 0.95) + self.assertLess(abs(effect._feedback - 0.95), 3e-5) + self.assertIsInstance(effect.tail_samples, int) + effect.reset() + self.assertEqual(effect._damping, 0.0) + effect.set_macro(1, 127) + self.assertEqual(effect._feedback, 0.95) + effect.deinit() + # A Tone in the constructor counts, and so does a patch. + for options in ({"tone_hz": 5000.0}, {"patch": 3}): + effect = combfilter.CombFilter(silence_source(rate), + sample_rate=rate, **options) + effect.program_change(0) + self.assertEqual(effect._damping, + combfilter.TONE_TRACK_PER_RATE * rate, + options) + effect.deinit() + + def off_after_tone(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, + **options): + """(samples, worst LSB) by which the off stop after Tone has been + in differs from a fresh instance's off stop, on 1 s of full-scale + noise.""" + frames = rate + seed = 12345 + values = array.array("h") + for _ in range(frames * channels): + seed = (1103515245 * seed + 12345) & 0x7FFFFFFF + values.append((seed >> 15) - 32768) + tracked = cls(audiocore.RawSample(values, sample_rate=rate, + channel_count=channels), + sample_rate=rate, **options) + tracked.set_macro(3, 0) + tracked.set_macro(3, 127) + fresh = combfilter.CombFilter( + audiocore.RawSample(array.array("h", values), sample_rate=rate, + channel_count=channels), + sample_rate=rate, **options) + a = pull(tracked.output, frames, channels) + b = pull(fresh.output, frames, channels) + tracked.deinit() + fresh.deinit() + diff = [abs(x - y) for x, y in zip(a, b)] + return sum(1 for d in diff if d), max(diff) + + def test_the_off_stop_after_tone_is_the_filter_out(self): + # A whole-frame read (1000 Hz at 48 kHz) is exact; a fractional one + # (440 Hz, 109.09 frames) is within 1 LSB of float rounding. + options = {"frequency": 1000.0, "feedback": 0.8, "mix": 2.0} + self.assertEqual(self.off_after_tone(combfilter.CombFilter, + **options), (0, 0)) + for rate in (48000, 22050): + _count, worst = self.off_after_tone(combfilter.CombFilter, rate, + 1) + self.assertLessEqual(worst, 1, rate) + # Planted: a coefficient of 0.957 is a low-pass left in the loop. + _count, worst = self.off_after_tone(LeakyTrackCombFilter, **options) + self.assertGreater(worst, 1000) + + def test_the_tail_bound_holds_on_the_tracking_off_stop(self): + # The bound counts the tracking filter's one frame of memory and + # the stepped Feedback; the 0.95 stop at 20 Hz is the long corner. + for rate in (48000, 22050): + effect = combfilter.CombFilter(silence_source(rate), + sample_rate=rate, frequency=20.0, + feedback=0.95, glide=0.0) + fresh_bound = effect.tail_samples + effect.set_macro(3, 0) + effect.set_macro(3, 127) + tracked_bound = effect.tail_samples + effect.deinit() + self.assertGreater(tracked_bound, fresh_bound) + self.assertLess(tracked_bound, 2 * fresh_bound) + + class TheRingIsTheAskedPitchAndEnds(unittest.TestCase): """The TAIL-pitch trait. The ring plays the fractional delay the comb was asked for, through its tenth repeat within 1 cent, and then reaches From dd4072c952861e7fb5127e2c2f8b0104196f0721 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:33:10 -0500 Subject: [PATCH 28/88] PingPongDelay fix round 1: the audit's list, class side Words and tests only; the class's arithmetic is unchanged. The docstring now states the input ceiling (-3 dBFS peak at the defaults, -3.1 over the patches, on noise_det over 20 s; the (1 - Mix) bound with Repeat Cut out; Cut's square-wave overshoot), what Repeat Tone's out stop costs after Tone has been in (0 LSB at Feedback 0.85, 6 at 0.99 on noise, 32 on a full-scale click through the tail), the filter cells' 512-frame gap for the mono sum, T4's miss at the off-frame Times, and that at full Spread the loop hears (L + R) / 2. Item 2 is restated, not fixed: the coefficient-1 state rounds on a fractional read, so dropping the stall clearance there has no no-stall proof. Each new test shows the old claim red: Tone out-after-in reads 6 LSB at 0.99 against the old "within 1 LSB"; the ceiling rails 1 dB over; T4 misses by 289 LSB at MIDI 95, 44.1 kHz; T1 loses repeat 8 at Feedback MIDI 67 with Cut in; T3 differs in 295 samples on a burst ending 1 frame before T and in 5 at its own cell with Tone in then out; MonoInPingPong and SwapInPingPong are green on the kit materials and red on independent L/R noise, and walk unreachable over 160 positions. The CHANGELOG line is added. --- CHANGELOG.md | 17 + lib/audioeffects/rebuilt/pingpongdelay.py | 60 +++- tests/test_cpython_effects_pingpongdelay.py | 353 +++++++++++++++++++- 3 files changed, 408 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5627b6e..11bc4fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,23 @@ there, and are recorded in its changelog. shipped patches, on `noise_det`), the 15 kHz swing the wow costs the repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is unmeasured. +- **`PingPongDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** repeats + that alternate between the speakers, on one `audioecho.FeedbackDelay` + whose cross-feed and input pan Spread moves between two plain delays and + the full bounce, by a stated law. The dry path is each channel's own + signal, the mono sum is an ordinary feedback delay exactly, and the class + hands a one-channel node the settings that keep its loop alive (the old + class silenced it). Every static Time lands on a whole frame at 48 kHz; + at 44.1 and 22.05 kHz 25 and 20 knob positions land one float32 step off, + which needs a node option. Time walks rather than clicks, Sync follows the + host's beat, and Repeat Tone and Repeat Cut put a low-pass and a + high-pass in the loop, defaulting out. It lives in + `lib/audioeffects/rebuilt/pingpongdelay.py`, and + `audioeffects.PingPongDelay` is still the old class. The docstring states + the input ceiling (-3 dBFS peak at the defaults, -3.1 over the shipped + patches, on `noise_det`), that at full Spread the loop hears (L + R) / 2, + and what Repeat Tone's out stop costs after Tone has been in. Board cost + is unmeasured. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index 511ba02..7fbe01c 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -47,14 +47,49 @@ that mono delay's output exactly, sample for sample, as long as no two repeats overlap one another: on sustained material that overlaps its own repeats each side rounds its own write where the mono delay rounds the sum -once, and the two part by a few LSB. +once, and the two part by a few LSB. That holds with Repeat Tone never in +since the last `reset()`; once it has been in, its out stop moves the +Feedback a hair (below), and at Feedback 0.99 the first eight repeats part +from a delay at the knob's Feedback in 5 samples by up to 5 LSB. With a +loop filter in, the material +must also end at least 512 frames before Time at 48 kHz, because each +side's filter meets that side's next repeat two Times later where the mono +delay's meets the very next one: a noise burst ending one frame before +Time parts them in 295 samples by 1 LSB (Repeat Tone 800 Hz) and in 680 by +up to 2 LSB (Repeat Cut 400 Hz). **Spread's law.** Spread s hands the node `cross_feed` s and `input_pan` -s (First Side left) or +s (right). On a click identical in both channels, repeat n reads f^(n-1) [(1 - s/2) + (s/2)(1 - 2s)^(n-1)] of the click on the First Side channel and f^(n-1) [(1 - s/2) - (s/2)(1 - 2s)^(n-1)] on the other. At Spread 0 the two channels are identical; at Spread 1 each -repeat is on one side only and the other side is exact zero. +repeat is on one side only and the other side is exact zero. At the Times +the node lands off the frame (below) the law misses by up to 289 LSB on a +20 000 click at Feedback 0.99 (44.1 kHz, MIDI 95; 145 LSB at 22.05 kHz). + +**What the loop hears.** At Spread 1 the loop is fed the average of the +two input channels, (L + R) / 2, into one line, so what the two channels +share bounces and what differs between them never repeats. A source whose +right channel is the left one upside down puts nothing in the loop: the +defaults pass it through untouched and Mix 2 is silence. The dry path is +always each channel's own signal, never swapped or summed. + +**Input ceiling.** The dry path sits at unity and the repeats add to it, +so a hot input can put the output on the int16 rail, and there is no input +gain to turn down. Measured on the kit's `noise_det` at 48 kHz over 20 s, +the defaults are clean up to -3 dBFS peak (at -2 they rail 2331 samples) +and every shipped patch up to -3.1 dBFS. Patch 4 (Spread 0, where each +side repeats every Time rather than every two) rails first: 76 samples at +-3 dBFS, and 167 902 on a 997 Hz sine there; patch 3 rails 4. On any +material, with Repeat Cut out and Mix below 1, an input peaking at or +below one LSB under (1 - Mix) of full scale, floor(32767 (1 - Mix)) - 1, +cannot reach the rail at any Time, Feedback or Spread, because the lines +hold int16 and so the repeats never exceed Mix x full scale: -3.1 dBFS at +the default Mix 0.3 and at every patch. At exactly (1 - Mix) of full scale +the sum can round onto 32767, the rail value, though nothing is clipped. +Repeat Cut's high-pass overshoots a square wave's edges, so with it in +leave more room: a 40 Hz square wave at -3.1 dBFS rails with Cut at 40 or +400 Hz, and is clean from -4 dBFS. **RAM.** The line is `max_time_ms + 1` ms of two int16 lanes whatever the channel count: 192 192 B at 48 kHz for the default 1000 ms (176 576 B at @@ -112,14 +147,25 @@ plays what it held when it comes back in, out of silence. So once Repeat Tone has been in since the last `reset()` (a constructor `tone_hz` or a patch counts), its out stop keeps the low-pass running at a coefficient of -exactly 1, which follows the repeats; against the filter truly out that -is within 1 LSB. Repeat Cut cannot do that (a high-pass at coefficient 1 +exactly 1, which follows the repeats. The Feedback is still handed clear +of the stall window described under Tail, which at some Feedbacks moves it +by up to 2.6 x 10^-5 (0.99 plays as 0.989976102; 0.85 does not move), so +the repeats die a hair sooner than with the filter truly out. On 2 s of +0 dBFS noise that is 0 LSB at Feedback 0.85, 1-5 LSB at 0.5, 0.75, 0.9 and +0.95, and 6 LSB at 0.99; a full-scale click at 0.99 followed through its +whole tail differs by up to 32 LSB, around its 70th repeat. Repeat Cut +cannot do that (a high-pass at coefficient 1 mutes the loop), so once Repeat Cut has been in since the last `reset()` its bottom stop stays in circuit at the 20 Hz corner, the knob's own bottom, until the next `reset()`. That costs the low end of the repeats something a true out would not, and `tail_samples` is `None` while it lasts. A `reset()` brings both exact outs back. +Each pass through a loop filter also takes something off a repeat's peak, +so with either filter in the late repeats of a quiet bounce fade faster +than Feedback alone says: at Feedback 0.52 (MIDI 67) with Repeat Cut at +400 Hz the 8th repeat of a 20 000 click is 143 LSB at 48 kHz. + **Tail.** `tail_samples` is an upper bound on how long the output takes to reach exact zero after your input stops, and it is long: the loop rounds its way down from full scale, 14 laps at the default Feedback (188 174 @@ -255,8 +301,10 @@ class PingPongDelay(_component.Component): **What the default surrenders:** both loop filters are out, so the bounce does not darken on its own; Time walks rather than jumps, and a - walk bends the repeats' pitch while it moves; and on a mono source - Spread and First Side do nothing. + walk bends the repeats' pitch while it moves; at full Spread what + differs between the two input channels never repeats; on a mono source + Spread and First Side do nothing; and there is no input gain, so an + input above -3 dBFS peak can reach the rail. """ NAME = 'PingPongDelay' diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index fc90831..bb71fd5 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -176,6 +176,50 @@ def _build(self, *arguments, **options): self._output = kit_faults.HiddenGain(self._delay, 0.1) +class _Steer(kit_faults._Node): + """Between the source and the node: `mode` "avg" hands both channels + (L + R) / 2, "swap" hands them exchanged. On a channel-identical source + both are the identity, byte for byte, which is why T5's kit materials + cannot see them (fix round 1; the material refuter's faults).""" + + def __init__(self, source, mode): + kit_faults._Node.__init__(self, source) + self.mode = mode + + def _process(self, frames): + if self.channel_count != 2: + return frames + x = frames.reshape(-1, 2) + if self.mode == "avg": + m = (x[:, 0] + x[:, 1]) / 2.0 + return np.repeat(m[:, None], 2, axis=1).reshape(-1) + return x[:, ::-1].reshape(-1) + + +class MonoInPingPong(PingPongDelay): + """T5's "never spread": the node fed (L + R) / 2 on both channels. At + Spread 1 the loop hears the average anyway, so only the dry changes.""" + + NAME = 'PingPongDelay' + + def _build(self, *arguments, **options): + PingPongDelay._build(self, *arguments, **options) + self._steer = _Steer(self._source, "avg") + self._delay.play(self._steer) + + +class SwapInPingPong(PingPongDelay): + """T5's "never spread": the node fed the source with its channels + exchanged, so the dry comes out on the wrong side.""" + + NAME = 'PingPongDelay' + + def _build(self, *arguments, **options): + PingPongDelay._build(self, *arguments, **options) + self._steer = _Steer(self._source, "swap") + self._delay.play(self._steer) + + class NoSlewPingPong(PingPongDelay): """Section 8.5's walk: the slew off, so a Time move jumps. Red on the pitch clause, and on the step bar on a rising move only.""" @@ -535,11 +579,53 @@ def t4_default(cls): T5_MATERIALS = ("ramp_fs", "tones_step", "sweep_log") +def lr_material(rate): + """Fix round 1: channel-different stereo, independent L and R noise + (`RandomState(7)`, uniform, peak 16 000 LSB = -6.2 dBFS), 2 T at + 280 ms long. Every kit probe is channel-identical, and on those a dry + that is mono-summed or swapped is byte-identical to the source.""" + n = 2 * law_frames(280.0, rate) + rs = np.random.RandomState(7) + left = rs.uniform(-16000, 16000, n) + right = rs.uniform(-16000, 16000, n) + return np.round(np.stack([left, right], axis=1)).astype(np.int16) + + +def antiphase_material(rate): + """R = -L: `lr_material`'s left channel against its own negative.""" + left = lr_material(rate)[:, 0] + return np.stack([left, -left], axis=1) + + +def read_dry_crosstalk(effect): + """How much of the right input reaches the left output before the first + repeat, at this instance's macro positions: two copies on 256 frames, + (L = ramp, R = 0) and (L = ramp, R = -ramp), and the count of left + output samples that differ. 0 for a dry that is each channel's own + signal, at every Mix: the first repeat is never inside 256 frames + (the material refuter's reading).""" + rate = effect._sample_rate + n = 256 + ramp = np.linspace(-30000, 30000, n) + outs = [] + for right in (np.zeros(n), -ramp): + pcm = np.stack([ramp, right], axis=1).round().astype(np.int16) + other = type(effect)(probes.ArraySource( + array("h", pcm.reshape(-1).tobytes()), rate=rate, channels=2, + block=BLOCK), sample_rate=rate) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + outs.append(pull(other, n, 2)) + other.deinit() + return int(np.count_nonzero(outs[0][:, 0] != outs[1][:, 0])) + + def t5_measure(cls, rate=RATE, channels=2, materials=T5_MATERIALS, - macros=None, **options): + macros=None, pcm=None, **options): """WIRE over the first T - 1 frames on each material, and, where Mix is above 0, the output not the source somewhere in the T frames from - frame T.""" + frame T. `pcm`, when given, is the one material, as (frames, + channels) int16.""" T = cell_frames(options, macros, rate) probe = cls(silence_src(64, channels, rate), sample_rate=rate, **options) @@ -549,8 +635,9 @@ def t5_measure(cls, rate=RATE, channels=2, materials=T5_MATERIALS, probe.deinit() differing = 0 absent = [] - for name in materials: - pcm = material(name, rate, channels) + given = pcm + for name in (("given",) if given is not None else materials): + pcm = given if given is not None else material(name, rate, channels) end = min(len(pcm), 2 * T) y = render_pcm(cls, pcm[:end], rate, macros=macros, **options) differing += int(np.count_nonzero(y[:T - 1] != pcm[:T - 1])) @@ -565,6 +652,10 @@ def t5_default(cls): return t5_measure(cls, materials=("tones_step",)) +def t5_lr_default(cls): + return t5_measure(cls, pcm=lr_material(RATE)) + + # -------------------------------------------------------------------------- # The walk (section 8.5), measured as the dossier's A6.6 did @@ -1038,6 +1129,26 @@ def test_repeat_tone_in_keeps_the_exclusion_not_the_peak(self): self.assertEqual(result["weak"][0], 4) self.assertTrue(result["late"]) + def test_presence_is_not_claimed_with_a_loop_filter_in(self): + # Fix round 1 (audit item 4): presence is claimed with both loop + # filters out. At the Feedback floor with Cut at its 400 Hz stop a + # repeat falls under 200 LSB, and the exclusion, the peak and the + # swap still hold on the same render. + weak = {48000: [8], 44100: [8], 22050: [7, 8]} + for rate in RATES: + result = t1_measure(PingPongDelay, rate, time_ms=280.0, + macros={FEEDBACK_I: 67, CUT_I: 127}) + self.assertEqual(result["weak"], weak[rate], rate) + self.assertEqual(result["leaks"], 0, rate) + self.assertEqual(result["late"], [], rate) + self.assertEqual(result["swapped"], 0, rate) + # Tone near its top loses repeat 8 at f 0.6. + result = t1_measure(PingPongDelay, rate, feedback=0.6, + macros={TONE_I: 126}, peak_clause=False) + self.assertEqual(result["weak"], [8], rate) + self.assertEqual(result["leaks"], 0, rate) + self.assertEqual(result["swapped"], 0, rate) + def test_a_lossy_cross_is_red(self): for rate in RATES: result = t1_measure(LossyCrossPingPong, rate, feedback=0.6) @@ -1118,6 +1229,66 @@ def test_the_loop_filters_read_exact(self): cut=cut) self.assertTrue(result["passed"], result) + def _gap(self, k, peak, opts, damping=0.0, cut=0.0): + """The row's burst lengthened to end `k` frames before T (280 ms, + f 0.6, 48 kHz): (sum differing, max LSB, first differing frame, + mono differing).""" + T = law_frames(280.0, RATE) + frames = 9 * T + int(0.1 * RATE) + n = T - k + x = np.zeros(frames) + x[:n] = np.round(np.random.RandomState(12345) + .uniform(-peak, peak, n)) + stereo = render(PingPongDelay, x, RATE, 2, time_ms=280.0, + feedback=0.6, mix=2.0, **opts).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + mono = render(PingPongDelay, x, RATE, 1, time_ms=280.0, + feedback=0.6, mix=2.0, **opts)[:, 0].astype(np.int32) + ref = reference(x, RATE, T, 0.6, damping, cut) + where = np.flatnonzero(total != ref) + return (len(where), int(np.abs(total - ref).max()), + int(where[0]) if len(where) else None, + int(np.count_nonzero(mono != ref))) + + def test_the_filter_cells_need_material_ending_512_frames_before_t(self): + # Fix round 1 (audit item 5): each lane's loop filter meets its + # own lane's next repeat 2 T later, the reference's the very next, + # so a burst ending close to T parts them. The filter cells are + # claimed on material ending at least 512 frames before T. + damping = nominal_damping_hz(800.0, RATE) + self.assertEqual(self._gap(1, 8192, {"tone_hz": 800.0}, damping), + (295, 1, 2 * law_frames(280.0, RATE), 0)) + self.assertEqual(self._gap(1, 8192, {}), (0, 0, None, 0)) + cut = pp.nominal_cut_hz(400.0, RATE) + for peak in (8192, 32767): + self.assertEqual(self._gap(512, peak, {"cut_hz": 400.0}, + cut=cut)[0], 0, peak) + # At 0 dBFS 256 frames is not enough for Cut 400 Hz. + self.assertEqual(self._gap(256, 32767, {"cut_hz": 400.0}, + cut=cut)[0], 16) + + def test_tone_in_then_out_is_outside_the_row(self): + # Fix round 1 (audit item 2, restated): once Tone has been in, its + # out stop hands a Feedback moved clear of the stall window + # (0.99 -> 0.989976102), so the row holds Tone never in since the + # last reset. Its own cell with Tone in then out differs in 5 + # samples, up to 5 LSB, at three rates. + for rate in RATES: + T = law_frames(280.0, rate) + frames = 9 * T + int(0.1 * rate) + x = click(frames) + src, _ = to_source(x, 2, rate) + effect = PingPongDelay(src, sample_rate=rate, time_ms=280.0, + feedback=0.99, mix=2.0, tone_hz=5000.0) + effect.set_macro(TONE_I, 127) + stereo = pull(effect, frames).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + ref = reference(x, rate, T, 0.99) + self.assertEqual(int(np.count_nonzero(total != ref)), 5, rate) + self.assertEqual(int(np.abs(total - ref).max()), 5, rate) + self.assertTrue(t3_measure(PingPongDelay, rate, + feedback=0.99)["passed"], rate) + def test_the_mono_stereo_settings_are_red(self): for rate in RATES: result = t3_measure(MonoStereoSettingsPingPong, rate, @@ -1145,6 +1316,23 @@ def test_first_side_right_is_the_swap_at_every_position(self): result = t4_measure(PingPongDelay, swap=True) self.assertTrue(result["passed"], result["red"]) + def test_the_law_misses_off_the_frame(self): + # Fix round 1 (audit item 3): at the Times the node lands one + # float32 step off the frame the two-tap read leaks each pass into + # the frame beside it, and the law is not claimed there. MIDI 95 + # misses at 44.1 and 22.05 kHz; MIDI 86 and 127 hold. + miss = {44100: 289.0, 22050: 145.0} + for rate in (44100, 22050): + result = t4_measure(PingPongDelay, rate, spreads=(1.0,), + time_ms=law_time_ms(95), feedback=0.99) + self.assertFalse(result["passed"], rate) + self.assertGreater(result["worst"], miss[rate], rate) + for midi in (86, 127): + result = t4_measure(PingPongDelay, rate, spreads=(1.0,), + time_ms=law_time_ms(midi), + feedback=0.99) + self.assertTrue(result["passed"], (rate, midi)) + def test_the_panless_spread_is_red(self): for rate in RATES: result = t4_measure(PanlessPingPong, rate, spreads=(0.5, 1.0)) @@ -1191,6 +1379,41 @@ def test_a_dry_gain_is_red(self): (rate, channels, options)) self.assertGreater(result["differing"], 0) + def test_never_spread_on_independent_channels(self): + # Fix round 1 (audit item 6): the kit's materials are + # channel-identical, so a dry that is mono-summed or swapped reads + # green on them. On independent L and R the window still reads 0, + # and both faults read red. + for rate in RATES: + pcm = lr_material(rate) + for options in ({}, {"mix": 0.2}, {"spread": 0.5}, + {"first_side": "right"}): + result = t5_measure(PingPongDelay, rate, pcm=pcm, **options) + self.assertTrue(result["passed"], (rate, options, result)) + for cls in (MonoInPingPong, SwapInPingPong): + for options in ({}, {"mix": 0.2}): + self.assertTrue(t5_measure(cls, rate, **options) + ["passed"], (rate, cls, options)) + result = t5_measure(cls, rate, pcm=pcm, **options) + self.assertFalse(result["passed"], (rate, cls, options)) + self.assertGreater(result["differing"], 0) + + def test_an_antiphase_source_does_not_repeat_at_spread_1(self): + # Disclosed, not a row: at Spread 1 the loop hears (L + R) / 2 + # (`input_pan` hard over), so R = -L puts nothing in the loop. The + # defaults are a byte wire on it and Mix 2 is silence; at Spread + # 0.5 it repeats. + for rate in RATES: + T = law_frames(280.0, rate) + anti = antiphase_material(rate) + y = render_pcm(PingPongDelay, anti, rate) + self.assertEqual(int(np.count_nonzero(y != anti)), 0, rate) + y = render_pcm(PingPongDelay, anti, rate, mix=2.0) + self.assertEqual(int(np.count_nonzero(y[T:])), 0, rate) + y = render_pcm(PingPongDelay, anti, rate, spread=0.5) + self.assertGreater(int(np.count_nonzero(y[T:] != anti[T:])), 0, + rate) + # -------------------------------------------------------------------------- # Tier 1 @@ -1254,22 +1477,43 @@ def test_a_filter_back_in_after_silence_stays_silent(self): self.assertGreater(self._round_trip( FrozenFilterPingPong, CUT_I, 127, 1, RATE), 15000) - def test_tone_out_after_tone_in_is_within_1_lsb_of_out(self): + def _tone_out_after_in(self, x, rate, **options): + """Largest |difference| between Tone out after Tone 2 kHz was in + and Tone never in, on `x`, Mix 2.""" + frames = len(x) + source, _ = to_source(x, 2, rate) + touched = PingPongDelay(source, sample_rate=rate, mix=2.0, + tone_hz=2000.0, **options) + touched.set_macro(TONE_I, 127) + clean, _ = to_source(x, 2, rate) + plain = PingPongDelay(clean, sample_rate=rate, mix=2.0, **options) + a = pull(touched, frames).astype(np.int32) + b = pull(plain, frames).astype(np.int32) + return int(np.abs(a - b).max()) + + def test_tone_out_after_tone_in_is_within_the_stated_bound(self): + # Fix round 1 (audit item 2, restated): the out stop's + # coefficient-1 low-pass follows the tap, but the Feedback still + # goes through `clear_of_stalls`, which moves it by up to + # 2.6 x 10^-5 where a stall window sits (0.99 -> 0.989976102; + # 0.85 is in no window). The docstring's bound: 0 at 0.85, at most + # 6 LSB on 2 s of 0 dBFS noise at 0.99, and at most 32 LSB on a + # full-scale click through the whole tail. The old "within 1 LSB" + # is red at 0.99. for rate in RATES: frames = 2 * rate x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, channels=1), dtype=np.int16)[:frames].astype(float) - source, _ = to_source(x, 2, rate) - touched = PingPongDelay(source, sample_rate=rate, - feedback=0.85, mix=2.0, tone_hz=2000.0) - touched.set_macro(TONE_I, 127) - clean, _ = to_source(x, 2, rate) - plain = PingPongDelay(clean, sample_rate=rate, feedback=0.85, - mix=2.0) - a = pull(touched, frames).astype(np.int32) - b = pull(plain, frames).astype(np.int32) - self.assertLessEqual(int(np.abs(a - b).max()), 1, rate) + self.assertEqual(self._tone_out_after_in(x, rate, + feedback=0.85), 0, rate) + worst = self._tone_out_after_in(x, rate, feedback=0.99) + self.assertLessEqual(worst, 6, rate) + self.assertGreater(worst, 1, rate) + x = click(700 * law_frames(20.0, RATE), 32767) + worst = self._tone_out_after_in(x, RATE, time_ms=20.0, feedback=0.99) + self.assertLessEqual(worst, 32) + self.assertGreater(worst, 1) def test_the_tail_reaches_exact_zero_inside_tail_samples(self): on = 200 * RATE // 1000 @@ -1372,6 +1616,72 @@ def host(): self.assertEqual(len(calls), 1) +def railed_samples(cls, dbfs, seconds, values=None, **options): + """Output samples on the int16 rail that the source did not put there: + the kit's `noise_det` at `dbfs` peak (or `values`), 48 kHz stereo.""" + frames = int(seconds * RATE) + if values is None: + values = np.frombuffer(probes.noise_det(frames=frames, dbfs=dbfs, + channels=1), + dtype=np.int16)[:frames].astype(float) + x = np.round(values).astype(np.int32) + y = render(cls, values, RATE, 2, **options).astype(np.int32) + source = ((x >= 32767) | (x <= -32768))[:, None] + out = (y >= 32767) | (y <= -32768) + return int(np.count_nonzero(out & ~source)) + + +class InputCeiling(unittest.TestCase): + """Fix round 1 (audit-3 ruling (o)): the docstring's ceiling on + `noise_det`, 48 kHz stereo, over 20 s. The defaults are clean at + -3 dBFS peak and patch 4 (Spread 0, the first shipped patch to rail) + at -3.1; each is red 1 dB over.""" + + SECONDS = 20.0 + + def test_the_stated_ceiling_is_clean_and_1_db_over_is_not(self): + for options, ceiling in (({}, -3.0), ({"patch": 4}, -3.1)): + self.assertEqual(railed_samples(PingPongDelay, ceiling, + self.SECONDS, **options), 0, + options) + self.assertGreater(railed_samples(PingPongDelay, ceiling + 1.0, + self.SECONDS, **options), 0, + options) + + def test_the_any_material_bound(self): + # With Repeat Cut out, a DC one LSB under floor(32767 (1 - Mix)) + # never reaches the rail at Feedback 0.99, at any Spread; at + # floor(32767 (1 - Mix)) itself the sum can round onto 32767. + for mix in (0.5, 0.3): + edge = int(math.floor(32767 * (1.0 - mix))) + for spread in (1.0, 0.0): + for level, reaches in ((edge - 1, False), + (edge, mix == 0.5)): + y = render(PingPongDelay, [level] * (RATE // 2), RATE, 2, + time_ms=12.5, feedback=0.99, mix=mix, + spread=spread) + self.assertEqual(bool(np.any(y >= 32767)), reaches, + (mix, spread, level)) + + def test_repeat_cut_needs_more_room(self): + # The loop high-pass overshoots a square's edges: at the defaults' + # Mix a 40 Hz square wave rails at -3.1 dBFS with Cut in, and is + # clean from -4 dBFS. + t = np.arange(int(self.SECONDS * RATE)) / RATE + wave_ = np.sign(np.sin(2.0 * math.pi * 40.0 * t + 1e-9)) + for cut_hz in (40.0, 400.0): + loud = 32767.0 * 10.0 ** (-3.1 / 20.0) * wave_ + quiet = 32767.0 * 10.0 ** (-4.0 / 20.0) * wave_ + self.assertGreater(railed_samples(PingPongDelay, None, + self.SECONDS, values=loud, + cut_hz=cut_hz), 0, cut_hz) + self.assertEqual(railed_samples(PingPongDelay, None, + self.SECONDS, values=quiet, + cut_hz=cut_hz), 0, cut_hz) + self.assertEqual(railed_samples(PingPongDelay, None, + self.SECONDS, values=loud), 0) + + # -------------------------------------------------------------------------- # The gate's two checks on every fault @@ -1407,6 +1717,16 @@ def test_the_dry_gain_is_off_the_surface(self): channels) self.assertEqual(result["checked"], self.CHECKED) + def test_the_spread_dry_is_off_the_surface(self): + # Fix round 1: T5's "never spread" faults, read as the right input + # reaching the left output before the first repeat. + for cls in (MonoInPingPong, SwapInPingPong): + for rate in RATES: + with self.subTest(fault=cls.__name__, rate=rate): + result = reach(cls, read_dry_crosstalk, rate) + self.assertEqual(result["checked"], self.CHECKED) + self.assertEqual(result["clean"], 0) + class NullBuildRed(unittest.TestCase): """Every demonstrated row goes red on the class built as a wire, beside @@ -1415,7 +1735,8 @@ class NullBuildRed(unittest.TestCase): def test_every_row_is_red_on_a_wire(self): for name, measure in (("T1", t1_default), ("T2", t2_default), ("T3", t3_default), ("T4", t4_default), - ("T5", t5_default)): + ("T5", t5_default), + ("T5 independent L/R", t5_lr_default)): with self.subTest(row=name): result = kit_faults.null_build_red( PingPongDelay, measure, label="PingPongDelay %s" % name) From a71d4ab5c0800644b1cc371a2c81dc0630adca39 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:14:39 -0500 Subject: [PATCH 29/88] AnalogDelay fix round 1: T3's no-step bar carries the one-pole's memory; the walk is read where the head lands; T7 across Mix; CHANGELOG line The gate audit parked the class on T3's inside no-step clause, which read the loop low-pass's memory of the pre-move slope as a step (551 against a 522 bar at the 20 -> 60 ms move ten blocks on). The class is untouched; the tests carry the dossier's post-build revision of 2026-09-28. - memory_bars / inside_clause: the bar at frame k is the shifted tone's own slope plus P (1 - a)^(k+1), a from the damping_hz the move handed. Shown able to fail: the jump reds on the walk clause at all 16 boundaries and on the inside clause at 15; the new planted fault MidWalkReadStep (the read 8 frames late from halfway) reds the inside clause at the step and is unreachable from 143 positions at 3 rates. - head_trace / landing replace walk_end: one-grid-step moves from the 20 ms stop now land within 0.45 %; the frozen rule, kept for the refuters' probes, still reads them 9.6-21 % short (tested). - walk_pieces replaces binade_pieces: a piece must be long enough for the ramp to resolve 1 cent; none left means the whole walk, and too short for that means red. Every named move now reads a piece; the 16-frame sliver below 8 192 frames is not read. - T7DryIsAWire.test_the_mix_interior: arrival at low Mix on ramp_fs and tones_step, on sweep_log from grid 17, and the null build red at grid 1. - CHANGELOG: the AnalogDelay line under Unreleased. --- CHANGELOG.md | 16 + tests/test_cpython_effects_analogdelay.py | 484 +++++++++++++++++++--- 2 files changed, 451 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5627b6e..b5529f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,22 @@ there, and are recorded in its changelog. shipped patches, on `noise_det`), the 15 kHz swing the wow costs the repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is unmeasured. +- **`AnalogDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** a + bucket-brigade delay on one `audioecho.FeedbackDelay`, with the Boss + DM-2's one 4096-stage line (`"single-line"`, the default) and the Deluxe + Memory Man's two in series (`"double-line"`) as characters. The Time knob + is the line's clock: the repeats' high-frequency corner tracks it as + 0.2211 N / T, so they darken as Time grows, and a Time move bends their + pitch by T_old / T_new for exactly T_new instead of clicking. Every Time + lands on a whole frame at every rate, and Mix 0 is a wire while the line + keeps recording. Modulation is a triangle of fixed millisecond swing, and + Sync follows a host's beat by Division. There is no sample-and-hold, no + compander and no fixed ~3 kHz pair, so short Times are brighter than + either pedal. It lives in `lib/audioeffects/rebuilt/analogdelay.py`, and + `audioeffects.AnalogDelay` is still the old class. The docstring states + the input ceiling (-4.4 dBFS peak at the default Mix 0.4) and that + turning Time through several positions takes seconds to settle. Board + cost is unmeasured. ## v0.3.2 (2026-09-25) diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index dcfcfdf..44633c4 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -1,7 +1,11 @@ """`AnalogDelay`'s own invariant and planted-fault tests. The dossier is `workspace docs/effects-internal/dossiers/AnalogDelay.md` -(frozen at anchor cc61011, the Station A critique revision). Four of its +(frozen at anchor cc61011, the Station A critique revision, with the +post-build revision of 2026-09-28 in its section 8: T3's inside no-step bar +carries the loop low-pass's memory, the walk is read where the head lands, +the binade pieces have a resolvable minimum, and T7's arrival clause is +claimed across Mix's interior material by material). Four of its Tier 2 rows can be demonstrated, T2a, T3, T6 and T7, and each is here as the measurement at a few of the row's cells beside the same measurement shown red on the row's planted fault at the constructor defaults. Every @@ -155,6 +159,57 @@ def _walk_rate(self, from_frames, to_frames): return ad.clock_slew(from_frames, to_frames) +class _ReadStep(kit_faults._Node): + """A node in the path that, from frame `step_at` on, plays its source + `frames` frames late: the read stepping back that far, once, and + staying there.""" + + def __init__(self, source, frames): + kit_faults._Node.__init__(self, source) + self.frames = int(frames) + self.pulled = 0 + self.step_at = None + self._history = np.zeros(self.frames * self.channel_count) + + def _process(self, block): + channels = self.channel_count + count = len(block) // channels + joined = np.concatenate([self._history, block]) + late = joined[:len(block)] + self._history = joined[len(block):] + out = block.copy() + if self.step_at is not None: + start = max(0, self.step_at - self.pulled) + if start < count: + out[start * channels:] = late[start * channels:] + self.pulled += count + return out + + +class MidWalkReadStep(AnalogDelay): + """T3's no-step clause: halfway through a Time move's walk the read + steps 8 frames late, once, and walks on from there. Nothing the surface + reaches puts a step in the walk: every Time move is one constant-rate + walk (section 6).""" + + NAME = 'AnalogDelay' + STEP_FRAMES = 8 + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._step = _ReadStep(self._delay, self.STEP_FRAMES) + self._output = self._step + + def _refresh(self): + before, fresh = self._frames, self._fresh + AnalogDelay._refresh(self) + step = getattr(self, "_step", None) + if (step is not None and not fresh and self._frames != before + and self._slew > 0.0): + walk = abs(self._frames - before) / self._slew + step.step_at = step.pulled + int(walk // 2) + + class DryGainDelay(AnalogDelay): """T7: the output +0.1 dB (`kit_faults.HiddenGain`), a dry above unity, which nothing reaches: the node's dry is exactly 1 below Mix 1.""" @@ -260,10 +315,10 @@ def silence_src(frames, channels=2, rate=RATE): rate=rate, channels=channels, block=BLOCK) -def sine_values(hz, frames, rate, level): +def sine_values(hz, frames, rate, level, phase=0.0): n = np.arange(frames) - return np.round(level * np.sin(2.0 * math.pi * hz * n / rate)).astype( - np.int64).tolist() + return np.round(level * np.sin(2.0 * math.pi * hz * n / rate + + phase)).astype(np.int64).tolist() def pull(effect, frames, channels=None, on_block=None): @@ -427,27 +482,42 @@ def t6_measure(cls, rate=RATE, times=(150.0, 300.0, 600.0)): MOVE_AT = 40960 # a block boundary 853 ms in: the line is full -def t3_render(cls, rate, character, t_old, t_new, values, channels=1): +def default_move_at(rate): + """The block boundary T3 moves on at `rate`: MOVE_AT's 853 ms, floored + to a block.""" + return MOVE_AT * rate // 48000 // BLOCK * BLOCK + + +def t3_render(cls, rate, character, t_old, t_new, values, channels=1, + move_at=None, handed=None): """Render `values` at Mix 2, Feedback 0, Time `t_old`, moving Time to - `t_new` through `set_macro(0, ...)` on the block at MOVE_AT.""" + `t_new` through `set_macro(0, ...)` on the block at `move_at` (a block + boundary; MOVE_AT's by default). With a dict `handed`, the node's + options as handed by that move are copied into it.""" effect = cls(array_src(values, channels, rate), sample_rate=rate, character=character, time_ms=t_old, feedback=0.0, mix=2.0) target = midi_of_ms(t_new) - move_at = MOVE_AT * rate // 48000 // BLOCK * BLOCK + if move_at is None: + move_at = default_move_at(rate) def move(frame): if frame == move_at: - effect.set_macro(TIME_I, target) + if handed is None: + effect.set_macro(TIME_I, target) + else: + with NodeSpy(): + effect.set_macro(TIME_I, target) + handed.update(getattr(effect._delay, "_handed", {})) out = left(pull(effect, len(values), channels, on_block=move), channels) effect.deinit() return out, move_at -def t3_static(cls, rate, character, time_ms, values): - effect = cls(array_src(values, 1, rate), sample_rate=rate, +def t3_static(cls, rate, character, time_ms, values, channels=1): + effect = cls(array_src(values, channels, rate), sample_rate=rate, character=character, time_ms=time_ms, feedback=0.0, mix=2.0) - out = left(pull(effect, len(values), 1), 1) + out = left(pull(effect, len(values), channels), channels) effect.deinit() return out @@ -464,9 +534,11 @@ def _refresh(self): def read_position(cls, rate, character, t_old, t_new, frames): """The read head's delay, frame by frame, off a slope-1 ramp render - through `open_loop(cls)`: `n - ramp(n) - out(n)`.""" + through `open_loop(cls)`: `n - ramp(n) - out(n)`. Superseded for T3 by + `head_trace` (dossier section 8, revision 2026-09-28); kept so the + Station C and refutation probes still run as they were written.""" k_old = whole(t_old, rate) - move_at = MOVE_AT * rate // 48000 // BLOCK * BLOCK + move_at = default_move_at(rate) origin = move_at - k_old - 2000 n = np.arange(frames) ramp = ((n - origin) % 65536) - 32768 @@ -478,7 +550,9 @@ def read_position(cls, rate, character, t_old, t_new, frames): def walk_end(delay, move_at, k_new): """The first frame after the move at which the read position, and the frame after it, sit within 2 frames of `k_new` (the Station A probe's - rule), or the end of the render if it never does.""" + rule), or the end of the render if it never does. Superseded for T3 by + `landing` (revision 2026-09-28): it reads a walk 2 / slew frames early, + which is 10-21 % of a one-grid-step move from the 20 ms stop.""" near = np.abs(delay[move_at:] - k_new) <= 2.0 both = np.nonzero(near[:-1] & near[1:])[0] if both.size == 0: @@ -489,8 +563,10 @@ def walk_end(delay, move_at, k_new): def binade_pieces(delay, move_at, end, law): """Each piece of the walk between powers of two of the read position, at least 1000 frames long, as (pitch ratio off a least-squares slope, - frames); the node walks the head in single precision, so each binade - plays at its own rate.""" + frames). Superseded for T3 by `walk_pieces` (revision 2026-09-28): it + found no piece on 60 -> 20 and 40 -> 20 ms at 48 and 22.05 kHz, and T3 + then passed with nothing read; kept so the refutation probes still + run as they were written.""" span = delay[move_at:end] powers = np.floor(np.log2(np.maximum(span, 1.0))) pieces = [] @@ -506,59 +582,211 @@ def binade_pieces(delay, move_at, end, law): return pieces +def ramp_slope(k_old, k_new): + """The steepest power-of-two ramp slope, up to 64 LSB per frame, whose + period still holds the longer delay twice over with room for the walk: + the head then reads to 1 / slope frame.""" + kmax = max(k_old, k_new) + slope = 1 + while slope < 64 and 65536 // (2 * slope) > 1.25 * kmax + 64: + slope *= 2 + return slope + + +def head_trace(cls, rate, character, t_old, t_new, frames, move_at=None, + channels=1): + """The read head's delay off one steep ramp render through + `open_loop(cls)`: (delay per frame to 1 / slope frame, the frames whose + read straddles the ramp's wrap marked False, slope, move frame).""" + k_old, k_new = whole(t_old, rate), whole(t_new, rate) + slope = ramp_slope(k_old, k_new) + n = np.arange(frames) + ramp = ((n * slope) % 65536) - 32768 + out, move_at = t3_render(open_loop(cls), rate, character, t_old, t_new, + ramp.tolist(), channels, move_at=move_at) + period = 65536.0 / slope + delay = np.mod(n - (out + 32768.0) / slope, period) + bad = np.abs(np.diff(out, prepend=out[0])) > 4 * slope + for k in (1, 2): + bad[:-k] |= bad[k:].copy() + bad[k:] |= bad[:-k].copy() + return delay, ~bad, slope, move_at + + +def landing(delay, valid, slope, move_at, k_new): + """The frame after the last one, from the move on, at which the head + is more than one ramp step (1 / slope frame) off `k_new`: where the + walk lands, at the ramp's resolution. The end of the render if the + head is still off there.""" + off = (np.abs(delay[move_at:] - k_new) > 1.0 / slope) & valid[move_at:] + idx = np.nonzero(off)[0] + if idx.size == 0: + return move_at + last = move_at + int(idx[-1]) + 1 + return len(delay) if last >= len(delay) - 3 else last + + +def min_piece(slope, k_old, k_new): + """The shortest piece whose rate the ramp resolves to 1 cent: one ramp + step of position (1 / slope frame) over the piece moves its rate by at + most (2^(1/1200) - 1) of the law's pitch ratio k_old / k_new.""" + ratio = float(k_old) / k_new + return int(math.ceil(1.0 / (slope * ratio * (2.0 ** (1.0 / 1200.0) + - 1.0)))) + + +def walk_pieces(delay, valid, slope, move_at, end, k_old, k_new): + """The walk cut where the head crosses each power of two between the + two Times (the node walks the head in single precision, so each binade + plays at its own rate), each piece's pitch ratio 1 - dD/dn by least + squares over its valid frames, as (ratio, frames). A piece shorter than + `min_piece` is left out; if none is left, the whole walk is one piece, + and if that is short too, the list is empty and the clause is red.""" + shortest = min_piece(slope, k_old, k_new) + lo_k, hi_k = sorted((k_old, k_new)) + edges = [2 ** e for e in range(1, 20) if lo_k < 2 ** e < hi_k] + cuts = [move_at] + for edge in sorted(edges, reverse=k_new < k_old): + seg = delay[move_at:end] + hit = (seg >= edge) if k_new > k_old else (seg < edge) + idx = np.nonzero(hit & valid[move_at:end])[0] + if idx.size: + cuts.append(move_at + int(idx[0])) + cuts.append(end) + + def fit(a, b): + m = np.arange(a + 2, b - 2) + m = m[valid[m]] + if len(m) < shortest: + return None + return (1.0 - np.polyfit(m.astype(float), delay[m], 1)[0], len(m)) + + pieces = [p for p in (fit(a, b) for a, b in zip(cuts, cuts[1:])) + if p is not None] + if not pieces: + whole_walk = fit(move_at, end) + if whole_walk is not None: + pieces = [whole_walk] + return pieces + + +def memory_bars(bar, pre_bar, damping_hz, rate, frames): + """T3's inside no-step bar, frame by frame from the move (dossier + section 8, revision 2026-09-28): the shifted tone's own largest first + difference, plus what the loop low-pass can still carry of the pre-move + tone's slope `k` frames on, `pre_bar * (1 - a)^(k + 1)`, with `a` the + one-pole's coefficient `1 - exp(-2 pi damping_hz / fs)` for the + `damping_hz` the move handed the node. The one-pole's first difference + obeys d(k) = (1 - a) d(k - 1) + a x'(k), so this is its memory, not a + fitted allowance.""" + if damping_hz > 0.0: + keep = math.exp(-2.0 * math.pi * damping_hz / rate) + else: + keep = 0.0 + k = np.arange(frames, dtype=np.float64) + return bar + pre_bar * keep ** (k + 1.0) + + +def inside_clause(diff, move_at, walk, bar, pre_bar, damping_hz, rate): + """T3's inside no-step statistic, revised 2026-09-28: the largest + first difference over the walk as read, each frame against its + `memory_bars` bar, as (largest ratio, its frame from the move, the + settle, the largest first difference in the same window with no + memory term, which is the frozen statistic's numerator). The walk's + frames are the move's to the landing's; the last + difference, into the landed frame, is the head's last partial step, + where the pitch goes back to unity as the law says, and is the after + clause's. The window runs at least `settle` frames, the frames until + the low-pass's memory of the pre-move slope is under 5 % of the bar, + so a move with no walk (the jump) is still read.""" + keep = (math.exp(-2.0 * math.pi * damping_hz / rate) + if damping_hz > 0.0 else 0.0) + settle = 1 + if keep > 0.0 and pre_bar > 0.05 * bar: + settle = max(1, int(math.ceil( + math.log(0.05 * bar / pre_bar) / math.log(keep)))) + span = min(max(walk - 1, settle, 1), len(diff) - move_at) + window = diff[move_at:move_at + span] + ratios = window / memory_bars(bar, pre_bar, damping_hz, rate, span) + worst = int(np.argmax(ratios)) + return float(ratios[worst]), worst, settle, float(np.max(window)) + + def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, - level=12000): - """T3's four clauses on one move: the pitch over the whole walk and on - each binade piece within 10 cents of 1200 log2(T_old / T_new), the walk - within 5 % of T_new, the residual 50-250 ms after it within 1 cent, and - the two no-step statistics within 5 % of their bars.""" + level=12000, channels=1, phase=0.0, move_at=None): + """T3's clauses on one move: the pitch over the whole walk and on each + binade piece within 10 cents of 1200 log2(T_old / T_new), the walk + within 5 % of T_new (read where the head lands, off a steep ramp), the + residual 50-250 ms after it within 1 cent, and the two no-step + statistics within 5 % of their bars (the inside one against + `memory_bars`). `inside_from_move` over `inside_bar` is the frozen + statistic (the largest first difference from the move frame against + the shifted tone's bar, no memory term) over the same window, kept + beside the revision.""" k_old, k_new = whole(t_old, rate), whole(t_new, rate) law = cents(float(k_old) / k_new) - move_at = MOVE_AT * rate // 48000 // BLOCK * BLOCK + if move_at is None: + move_at = default_move_at(rate) frames = move_at + int(1.25 * k_new) + 400 + int(0.3 * rate) + 4096 - delay, _ = read_position(cls, rate, character, t_old, t_new, frames) - end = walk_end(delay, move_at, k_new) + delay, valid, slope, _ = head_trace(cls, rate, character, t_old, t_new, + frames, move_at, channels) + end = landing(delay, valid, slope, move_at, k_new) walk = end - move_at walk_ok = abs(walk - k_new) <= 0.05 * k_new - tone = sine_values(997.0, frames, rate, level) - out, _ = t3_render(cls, rate, character, t_old, t_new, tone) - hz = inst_hz(out, rate) - result = {"walk": walk, "k_new": k_new, "law": law} + handed = {} + tone = sine_values(997.0, frames, rate, level, phase) + out, _ = t3_render(cls, rate, character, t_old, t_new, tone, channels, + move_at=move_at, handed=handed) + diff = np.abs(np.diff(out)) + + shifted = 997.0 * k_old / k_new + ref = t3_static(cls, rate, character, t_new, + sine_values(shifted, k_new + 8192, rate, level), channels) + inside_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) + ref = t3_static(cls, rate, character, t_old, + sine_values(997.0, k_old + 8192, rate, level), channels) + pre_bar = float(np.max(np.abs(np.diff(ref[k_old + 2048:])))) + ref = t3_static(cls, rate, character, t_new, + sine_values(997.0, k_new + 8192, rate, level), channels) + later_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) + + damping = float(handed.get("damping_hz", 0.0)) + inside_ratio, worst, settle, inside_from_move = inside_clause( + diff, move_at, walk, inside_bar, pre_bar, damping, rate) + inside_ok = inside_ratio <= 1.05 + + result = {"walk": walk, "k_new": k_new, "law": law, "slope": slope, + "inside_ratio": inside_ratio, "inside_at": worst, + "inside_ok": inside_ok, "inside_from_move": inside_from_move, + "inside_bar": inside_bar, "pre_bar": pre_bar, + "damping_hz": damping, "settle": settle, + "later_bar": later_bar, "walk_ok": walk_ok} if walk < 64 or end + int(0.25 * rate) + 1 > frames: # No walk at all, or one that never lands on T_new inside the - # render: the walk clause is red and the rest has nothing to read. - result.update(passed=False, whole=None) + # render: the walk clause is red and the pitch has nothing to read. + result.update(passed=False, whole=None, pieces=[], residual=None, + later=None) return result + hz = inst_hz(out, rate) margin = min(400, walk // 10) whole_cents = cents(float(np.median(hz[move_at + margin:end - margin])) / 997.0) pieces = [cents(ratio) for ratio, _ in - binade_pieces(delay, move_at, end, law)] + walk_pieces(delay, valid, slope, move_at, end, k_old, k_new)] after = hz[end + int(0.05 * rate):end + int(0.25 * rate)] residual = cents(float(np.median(after)) / 997.0) - - diff = np.abs(np.diff(out)) - inside = float(np.max(diff[move_at:end])) - shifted = 997.0 * k_old / k_new - reference_values = sine_values(shifted, k_new + 8192, rate, level) - ref = t3_static(cls, rate, character, t_new, reference_values) - inside_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) later = float(np.max(diff[end + 64:end + 4064])) - ref = t3_static(cls, rate, character, t_new, - sine_values(997.0, k_new + 8192, rate, level)) - later_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) - result.update( - whole=whole_cents, pieces=pieces, residual=residual, - inside=inside, inside_bar=inside_bar, later=later, - later_bar=later_bar) + result.update(whole=whole_cents, pieces=pieces, residual=residual, + later=later) result["passed"] = ( walk_ok and abs(whole_cents - law) <= 10.0 + and len(pieces) > 0 and all(abs(p - law) <= 10.0 for p in pieces) and abs(residual) <= 1.0 - and inside <= 1.05 * inside_bar + and inside_ok and later <= 1.05 * later_bar) return result @@ -726,6 +954,48 @@ def read_landing(effect): return frames >= effect._frames +def read_step(effect): + """On an open-loop copy at these positions, read with Mix 2, Feedback 0 + and Modulation 0, one Time move of 32 grid steps once the line has + filled, off a slope-1 ramp: whether the read head jumps, in any one + frame of the walk, by more than the walk's own rate rounded up plus + one frame.""" + rate = effect._sample_rate + now = effect.get_macro(TIME_I) + new = now + 32.0 if now < 64 else now - 32.0 + k_old = effect._frames + k_new = whole(grid_ms(new), rate) + fill = (k_old + 2 * BLOCK) // BLOCK * BLOCK + frames = fill + int(1.3 * k_new) + 2 * BLOCK + n = np.arange(frames) + ramp = (n % 65536) - 32768 + other = open_loop(type(effect))(array_src(ramp.tolist(), 1, rate), + sample_rate=rate, + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + other.set_macro(MIX_I, 127) + other.set_macro(FEEDBACK_I, 0) + other.set_macro(MODULATION_I, 0) + + def move(frame): + if frame == fill: + other.set_macro(TIME_I, new) + + out = left(pull(other, frames, 1, on_block=move), 1) + slew = other._slew + other.deinit() + delay = np.mod(n - (out + 32768.0), 65536.0) + # A read that straddles the ramp's wrap is not a jump of the head. + bad = np.abs(np.diff(out, prepend=out[0])) > 64.0 + for k in (1, 2): + bad[:-k] |= bad[k:].copy() + bad[k:] |= bad[:-k].copy() + good = ~bad + jumps = np.abs(np.diff(delay[fill:]))[good[fill + 1:]] + return bool(np.max(jumps) > math.ceil(slew) + 1.0) + + #: (name, fault, reading, constructor options for both builds). REACH_WALKS = ( ("CornerN6144", CornerN6144, read_corner_law, {}), @@ -736,6 +1006,7 @@ def read_landing(effect): ("JumpAnalogDelay", JumpAnalogDelay, read_walk_law, {}), ("ConstantGlideWalk", ConstantGlideWalk, read_walk_law, {}), ("DryGainDelay", DryGainDelay, read_dry_gain, {}), + ("MidWalkReadStep", MidWalkReadStep, read_step, {}), ) @@ -1051,9 +1322,19 @@ def test_the_long_moves_are_read_per_binade(self): self.assertTrue(result["passed"], result) def test_the_jump_is_red(self): - result = t3_measure(JumpAnalogDelay, RATE, SINGLE) - self.assertFalse(result["passed"], result) - self.assertLess(result["walk"], 64) + # The walk clause reds a jump at every boundary: there is no walk. + # The inside clause sees the step itself at 15 of these 16; at the + # other the jump lands on nearly the same sample value (0.964 of + # its bar), which no first-difference statistic can see. + base = default_move_at(RATE) + seen = 0 + for j in range(16): + result = t3_measure(JumpAnalogDelay, RATE, SINGLE, 20.0, 60.0, + move_at=base + BLOCK * j) + self.assertFalse(result["passed"], (j, result)) + self.assertLess(result["walk"], 64, j) + seen += 0 if result["inside_ok"] else 1 + self.assertGreaterEqual(seen, 15) def test_the_constant_glide_is_red(self): for rate in RATES: @@ -1061,6 +1342,91 @@ def test_the_constant_glide_is_red(self): self.assertFalse(result["passed"], (rate, result)) self.assertGreater(result["walk"], 1.5 * result["k_new"]) self.assertLess(result["whole"] - result["law"], -600.0) + self.assertTrue(all(p - result["law"] < -600.0 + for p in result["pieces"]), result) + + +class T3NoStepRevision(unittest.TestCase): + """Dossier section 8, revision 2026-09-28: the inside no-step bar + carries the loop low-pass's memory of the pre-move slope, derived from + the handed `damping_hz`; the walk is read where the head lands; the + binade pieces have a minimum the ramp resolves, and a walk with none + left is read whole.""" + + def test_the_one_pole_memory_is_not_a_step(self): + # 20 -> 60 ms moved ten blocks later than the pack's boundary: the + # frozen statistic reads 551 against a 522 bar (1.0556) at the + # walk's first frame; the revised bar there is 522 + 1562 (1 - a). + move_at = default_move_at(RATE) + 10 * BLOCK + for character in (SINGLE, DOUBLE): + result = t3_measure(AnalogDelay, RATE, character, 20.0, 60.0, + move_at=move_at) + self.assertGreater(result["inside_from_move"], + 1.05 * result["inside_bar"], character) + self.assertTrue(result["inside_ok"], (character, result)) + self.assertTrue(result["passed"], (character, result)) + result = t3_measure(open_loop(AnalogDelay), RATE, character, + 20.0, 60.0, move_at=move_at) + self.assertLessEqual(result["inside_from_move"], + 1.05 * result["inside_bar"], character) + + def test_the_memory_is_the_handed_coefficient(self): + bars = memory_bars(522.0, 1562.0, 11606.7651, RATE, 3) + keep = math.exp(-2.0 * math.pi * 11606.7651 / RATE) + self.assertAlmostEqual(bars[0], 522.0 + 1562.0 * keep) + self.assertAlmostEqual(bars[2], 522.0 + 1562.0 * keep ** 3) + self.assertEqual(list(memory_bars(522.0, 1562.0, 0.0, RATE, 2)), + [522.0, 522.0]) + + def test_a_mid_walk_read_step_is_red(self): + result = t3_measure(MidWalkReadStep, RATE, SINGLE) + self.assertFalse(result["inside_ok"], result) + self.assertLess(abs(result["inside_at"] - result["k_new"] // 2), 4, + result) + self.assertFalse(result["passed"]) + + def test_one_grid_step_from_the_stop_lands(self): + # The frozen walk_end read this 9.6 / 10.5 / 21.0 % short. + for rate in RATES: + k_new = whole(grid_ms(1), rate) + move_at = default_move_at(rate) + frames = move_at + 2 * k_new + 4096 + delay, valid, slope, _ = head_trace(AnalogDelay, rate, SINGLE, + 20.0, grid_ms(1), frames) + walk = landing(delay, valid, slope, move_at, k_new) - move_at + self.assertLess(abs(walk - k_new), 0.02 * k_new, (rate, walk)) + old, _ = read_position(AnalogDelay, rate, SINGLE, 20.0, + grid_ms(1), frames) + walk = walk_end(old, move_at, k_new) - move_at + self.assertGreater(abs(walk - k_new), 0.05 * k_new, (rate, walk)) + + def test_every_named_move_reads_a_piece(self): + # The frozen binade_pieces found none on 60 -> 20 and 40 -> 20 ms + # at 48 and 22.05 kHz, or on 20 -> 60 ms at 22.05 kHz. + for rate in (48000, 22050): + for t_old, t_new in ((60.0, 20.0), (40.0, 20.0), (20.0, 60.0), + (20.0, 40.0)): + result = t3_measure(AnalogDelay, rate, SINGLE, t_old, t_new) + self.assertGreater(len(result["pieces"]), 0, + (rate, t_old, t_new)) + self.assertTrue(result["passed"], (rate, t_old, t_new)) + + def test_a_sliver_under_the_minimum_is_not_read(self): + # 3 : 1 from 8 182 frames crosses 8 192 in a 16-frame sliver the + # frozen reading put at -31.77 cents; the ramp cannot resolve it. + k_old = 8182 + result = t3_measure(AnalogDelay, RATE, SINGLE, k_old * 1000.0 / RATE, + 3 * k_old * 1000.0 / RATE) + self.assertEqual(len(result["pieces"]), 2, result) + self.assertTrue(result["passed"], result) + self.assertGreater(min_piece(1, k_old, 3 * k_old), 16) + + def test_no_piece_is_red(self): + # A walk too short for any piece to resolve reads no piece, which + # is red rather than a pass with nothing read. + delay = np.full(100, 960.0) + pieces = walk_pieces(delay, np.ones(100, bool), 1, 0, 50, 2880, 960) + self.assertEqual(pieces, []) def noise_src(frames, rate, seed=5): @@ -1167,6 +1533,26 @@ def test_the_patches_with_mix_up_to_one(self): patch=patch) self.assertTrue(result["passed"], (patch, result)) + def test_the_mix_interior(self): + # Dossier section 8, revision 2026-09-28: arrival is claimed on + # ramp_fs and tones_step at every Mix above 0, and on sweep_log from + # grid 17 at 48 kHz, where the wet's ~2 LSB times Mix first clears + # the node's round to nearest. The null build reds at grid 1. + for name, grids in (("ramp_fs", (1, 2, 8, 16, 17, 40, 63)), + ("tones_step", (1, 2, 8, 16, 17, 40, 63)), + ("sweep_log", (17, 24, 40, 63))): + for grid in grids: + result = t7_measure(AnalogDelay, RATE, 2, name, + mix=2.0 * grid / 127.0) + self.assertTrue(result["passed"], (name, grid, result)) + result = t7_measure(AnalogDelay, RATE, 2, "sweep_log", + mix=2.0 * 16 / 127.0) + self.assertEqual(result["arrived"], 0) # the unclaimed edge + wire = kit_faults.wire_build(AnalogDelay) + result = t7_measure(wire, RATE, 2, "ramp_fs", mix=2.0 / 127.0) + self.assertFalse(result["passed"], result) + self.assertEqual(result["differing"], 0) + def test_a_dry_above_unity_is_red_at_every_level(self): for name in ("ramp_fs", "tones_step", "sweep_log"): result = t7_measure(DryGainDelay, RATE, 2, name) From 2974a8e935e35d5d768af81adb23f35b32a641b0 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:25:03 -0500 Subject: [PATCH 30/88] TapeDelay fix round 1: the audit's list, class side The class's audio path is unchanged (62 of 62 render digests identical on three interpreters). The docstring's pitch claim is restated in the node's float32 binades: varispeed rising moves past 32 768 frames are claimed to 2.95 : 1; sliding-head rising moves from grid 4, 10 and 22 in the three binades, grid 1 no longer claimed. Tests, each shown able to fail: - a per-binade-piece reader (walk_cell) that reads a piece only when the ramp slope and the sine fit agree within 1 c; red on 333 -> 1 100 ms (-11.16 c) and on grid 1 350 -> 450 ms (+40.98 c), and it leaves the span refuter's 45-frame piece unread (the old reader put -215.6 c there) - T1b's Glide-0 step read on the read position (no frame between T_old and T_new, 16 block boundaries); FastWalkAtZeroTape (slew 8) red with 597 frames between. WalkAtZeroTape was the constructor's glide_ms 1 204.08 and is now the control for a new constructor walk (CTOR_VALUES), which calls it reachable; copy_of carries the constructor's exact Glide and Time - T4's detector gains an absolute clause (1e-5 ms per line and slow band): the wire is red on every clause at 996, 997 and 1 000 Hz, where it read green on every clause at 996 before; Flutter under grid 1 red - T2's -20 dBFS impulse test becomes the control that shows the impulse, not the class, reads over 2 dB there (a steady tone reads inside) - T3's corner is read against the response's DC, not unity CHANGELOG line for the class. --- CHANGELOG.md | 16 + lib/audioeffects/rebuilt/tapedelay.py | 25 +- tests/test_cpython_effects_tapedelay.py | 572 +++++++++++++++++++++--- 3 files changed, 556 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5627b6e..40cbccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,22 @@ there, and are recorded in its changelog. shipped patches, on `noise_det`), the 15 kHz swing the wow costs the repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is unmeasured. +- **`TapeDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** a tape loop + on one `audioecho.FeedbackDelay` with two transports as characters: the + RE-201's motor (`"varispeed"`, the default), where a Time move bends the + pitch by the ratio of the two times for exactly the new time, and the + EP-3's sliding head (`"sliding-head"`), where the pitch bends only while + the head moves, at a rate Glide sets. Each repeat darkens by a playback + loss law whose corner follows Spacing and the tape speed; Wow and Flutter + drive one periodic wow table with a slow drift; Record Level is the + loop's cubic squash, without memory. Mix 0 is a wire while the loop keeps + recording. It lives in `lib/audioeffects/rebuilt/tapedelay.py`, and + `audioeffects.TapeDelay` is still the old class. The docstring states the + input ceiling (-1.1 dBFS peak at the defaults, -2.0 over the shipped + patches, on `noise_det`), the band the loss law holds in, the wobble's + 100 s period, the Wow and Flutter step, and where the pitch claim stops: + the node's single-precision walk limits rising moves past 16 384 and + 32 768 frames. Board cost is unmeasured. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index 1819c3c..9e44ced 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -85,13 +85,24 @@ **Where the pitch claim stops.** A varispeed move takes its rate from the last Time handed to the node and runs once, so a Time move issued while the last one is still gliding does not telescope as a real motor would: its -bend is written into the loop and stays there. On sliding-head the node -walks the read head in single precision, and a rising move at the Glide -knob's fastest positions, strictly between grid 1 and grid 4 (1 222.0 to -1 290.3 ms), can read up to 23.5 cents off the glide law past the power -of two it crosses. That part of the knob is not claimed on a rising move, -nor is a constructor Glide in the same range. Grid 1 itself and everything -from grid 4 up are. +bend is written into the loop and stays there. + +The node walks the read head in single precision +(`audiodsp_feedback_delay.c:444`), so each step lands on the head's +rounding grid, and that grid doubles every time the head passes a power of +two in frames: 16 384 (341.3 ms at 48 kHz, 371.5 ms at 44.1, 743.0 ms at +22.05) and 32 768 (682.7 ms at 48 kHz, 743.0 ms at 44.1; never at +22.05 kHz, where 1 200 ms is 26 460 frames). On a rising move the pitch +error this makes grows as the pitch falls, so the claim stops where it +could pass 10 cents. On varispeed, a rising move whose walk passes 32 768 +frames is claimed up to a ratio of 2.95 : 1; past that the last part of +the walk can read 11 cents off (333 -> 1 100 ms reads -11.2 cents there). +On sliding-head, a rising move is claimed from Glide grid 4 (1 290.3 ms) +while the head stays under 16 384 frames, from grid 10 (1 438.5 ms) once +it passes 16 384, and from grid 22 (1 788.2 ms) once it passes 32 768. +Grid 1 is not claimed on a rising move (350 -> 450 ms reads +41 cents +there), nor is any constructor Glide faster than those edges. Falling +moves are claimed at every Glide, and at every ratio up to 3.33 : 1. **Turning Wow or Flutter while it plays steps.** The node takes a new table and depth at once (`audiodsp_feedback_delay.c:457-458` adds depth x diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 1e5284d..0458c57 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -79,8 +79,12 @@ def _walk_rate(self, from_ms, to_ms): class WalkAtZeroTape(TapeDelay): - """T1b's step: Glide 0 handed slew 0.98 instead of the jump (grid 1 is - 0.966, grid 0 the jump).""" + """Station B's step fault, Glide 0 handed slew 0.98 instead of the + jump. Fix round 1: no longer a planted fault. The clean class built with + `glide_ms=1180/0.98` (1 204.08 ms) hands the node the same 0.98 and + renders the same bytes, so the state is a constructor value; it is kept + as the control that shows the constructor walk can call a fault + reachable.""" NAME = 'TapeDelay' @@ -91,6 +95,21 @@ def _walk_rate(self, from_ms, to_ms): return rate +class FastWalkAtZeroTape(TapeDelay): + """T1b's step (fix round 1): Glide 0 handed slew 8 instead of the jump, + so the read head walks between the two Times (598 frames for + 200 -> 100.4 ms at 48 kHz) where the class jumps. Sliding-head never + hands more than the 0.99 pin, from the knob or the constructor.""" + + NAME = 'TapeDelay' + + def _walk_rate(self, from_ms, to_ms): + rate = TapeDelay._walk_rate(self, from_ms, to_ms) + if self._character == tape.SLIDING_HEAD and rate <= 0.0: + return 8.0 + return rate + + class LoopShiftTape(TapeDelay): """T1b's gesture, varispeed: a loop pitch shift of 0.12 semitone, which the class never sets, so every pass round the loop is transposed.""" @@ -732,6 +751,23 @@ def test_a_doubled_walk_is_red(self): self.assertFalse(got["passed"], got) self.assertGreater(abs(got["err"]), 400.0) + def test_the_top_binade_per_piece(self): + # Fix round 1 (dossier T1a, revised under vision 7.2): a rising + # move from rest whose walk passes 32 768 frames is claimed to a + # ratio of 2.95 : 1. The node rounds each step of its float32 walk + # to the read head's ulp (2^-8 frames up there), and past that + # ratio the top piece can read over 10 c. The claimed edge cell is + # the float32 model's worst claimed move (408.90 -> 1 196.31 ms, + # ratio 2.926, model -9.92 c); 333 -> 1 100 ms (ratio 3.30) is the + # span refuter's excluded cell, red on its top piece. + for a, b, red in ((19627 / 48.0, 57423 / 48.0, False), + (333.0, 1100.0, True)): + law = TONE * frames_of(a) / float(frames_of(b)) + got = walk_cell(TapeDelay, a, b, RATE, tape.VARISPEED, law) + self.assertEqual(got["unread"], 0, got) + self.assertEqual(got["passed"], not red, (a, b, got)) + self.assertAlmostEqual(got["worst"], -11.16, delta=0.05) + class GlideIsInertOnVarispeed(unittest.TestCase): """Section 8.9: one varispeed Time move at Glide grid 1, grid 127 and @@ -884,20 +920,201 @@ def ramp_cell(cls, rate=RATE, amp=12000.0): law=cents(law / TONE), end=end - start, walk=walk) -def step_cell(cls, rate=RATE, amp=12000.0): - """T1b's step: sliding-head at Glide 0, 200 -> 100.4 ms. The jump read - on the read recovered through the inverted loss low-pass against the - unfiltered tone's own slope, and the pitch 2-50 ms after.""" - y, start, effect = move(cls, 200.0, 100.4, rate, tape.SLIDING_HEAD, amp, - glide_ms=0.0) - coef = 1.0 - math.exp(-2.0 * math.pi * effect._damping / rate) - x = y[:-1] + np.diff(y) / coef - jump = float(np.max(np.abs(np.diff(x))[start - 3:start + 3])) - raw = 2.0 * math.pi * TONE / rate * amp +#: Fix round 1: a binade piece counts only when its two readings - the +#: read head's slope off the ramp render and the least-squares sine on the +#: tone - both exist and agree within PIECE_AGREE cents. The span refuter +#: read -215.6 c off the ramp slope alone on a 45-frame piece (grid 1, +#: 41.7 -> 55.7 ms), where no sine fits; such a piece is reported unread. +PIECE_AGREE = 1.0 + +#: The sine is fitted on at most this many frames from the middle of a +#: piece: the pitch is constant inside a binade, and a long piece costs +#: time and says nothing more. +PIECE_FIT_MAX = 24000 + + +def binade_pieces(y, d, ok, start, end, fa, fb, law_hz, rate): + """The walk start..end cut where the read head crosses a power of two + (inside a binade the node rounds each step to the head's float32 ulp, + so each binade plays its own rate), each piece read two ways in cents + off `law_hz`. Returns [(frames, ramp cents, sine cents, read)], `read` + True when both readings exist and agree within PIECE_AGREE.""" + edges = [start] + lo_f, hi_f = min(fa, fb), max(fa, fb) + p = 1 + while p <= hi_f: + if lo_f < p < hi_f: + seg = d[start:end] + idx = np.nonzero(((seg - p) * np.sign(fb - fa) >= 0) + & ok[start:end])[0] + if len(idx): + edges.append(start + int(idx[0])) + p <<= 1 + edges.append(end) + edges = sorted(set(edges)) + out = [] + for lo, hi in zip(edges[:-1], edges[1:]): + n = hi - lo + ramp_c = float("nan") + if n > 8: + idx = np.arange(lo + 2, hi - 2) + idx = idx[ok[idx]] + if len(idx) > 4: + slope = np.polyfit(idx.astype(float), + d[idx].astype(float), 1)[0] + if 1.0 - slope > 0.0: + ramp_c = cents((1.0 - slope) * TONE / law_hz) + trim = min(400, n // 10) + a, b = lo + trim, hi - trim + if b - a > PIECE_FIT_MAX: + mid = (a + b) // 2 + a, b = mid - PIECE_FIT_MAX // 2, mid + PIECE_FIT_MAX // 2 + sine_c = float("nan") + if b - a >= max(64, rate / law_hz): + got = lsq_hz(y[a:b], rate) + if got == got: + sine_c = cents(got / law_hz) + read = (ramp_c == ramp_c and sine_c == sine_c + and abs(ramp_c - sine_c) <= PIECE_AGREE) + out.append((n, ramp_c, sine_c, read)) + return out + + +def walk_cell(cls, a, b, rate, character, law_hz, amp=12000.0, setup=None, + after_ms=300.0, **options): + """One Time move a -> b read per binade piece with the guard above: + the tone render and the ramp render of the same move, the walk's end + off the read position. `law_hz` is the pitch the law says the walk + plays. Returns dict(pieces, worst (over read pieces), unread (pieces + not read), read_frames, walk, passed (no read piece over 10 c)).""" + fa, fb = frames_of(a, rate), frames_of(b, rate) + start = start_of(a, rate) + rate_of_walk = abs(1.0 - law_hz / TONE) + walk_est = int(abs(fb - fa) / rate_of_walk) if rate_of_walk > 0 else 0 + frames = start + int(1.1 * walk_est) + int(after_ms * rate / 1000.0) + opts = dict(HELD) + opts.update(options) + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), time_ms=a, + character=character, **opts) + if setup is not None: + setup(effect) + y = render(effect, frames, {start: set_time(b)})[:, 0] + effect.deinit() + d, ok = read_position(cls, a, b, rate, character, start, frames, setup, + **options) + walking = np.nonzero((d[start:] != fb) & ok[start:])[0] + end = start + (int(walking[-1]) + 1 if len(walking) else 0) + pieces = binade_pieces(y, d, ok, start, end, fa, fb, law_hz, rate) + read = [p for p in pieces if p[3]] + worst = max((max(p[1], p[2], key=abs) for p in read), key=abs, + default=float("nan")) + return dict(pieces=pieces, worst=worst, + unread=sum(1 for p in pieces if not p[3]), + read_frames=sum(p[0] for p in read), walk=end - start, + passed=bool(read) and abs(worst) <= 10.0) + + +def glide_law_hz(glide_ms, a, b): + """Sliding-head's pitch while the head walks a -> b at Glide + `glide_ms`, from the Glide asked (1 180 ms / Glide), never from the + class's slew function.""" + law = FULL_RANGE_LAW / glide_ms + return TONE * (1.0 + law if b < a else 1.0 - law) + + +#: 1 180 ms, sliding-head's full-range Time move, written from the numbers. +FULL_RANGE_LAW = 1200.0 - 20.0 + + +def glide_ms_of(grid): + return _component.macro_value(TapeDelay._MACRO_RANGES[GLIDE_I], + grid / 127.0) + + +_READ_HEADS = {} + + +def read_head(cls): + """`cls` with its loop low-pass handed 0 after every refresh: an + instrument for reading where the read head is, never a subject. The + walk is the node's `delay_slew` (`audiodsp_feedback_delay.c:444`, + `:449`) and never sees the filter, which filters what was read, so the + positions are the class's (Station C's `read_head_class`).""" + if cls not in _READ_HEADS: + def _refresh(self): + cls._refresh(self) + self._delay.set(damping_hz=0.0) + _READ_HEADS[cls] = type("ReadHead" + cls.__name__, (cls,), + {"_refresh": _refresh}) + return _READ_HEADS[cls] + + +def read_position(cls, a, b, rate, character, start, frames, setup=None, + **options): + """The read position D[n], in frames, off a render of the move a -> b + issued at `start`, with a wrapping 1-LSB-per-frame ramp as the source + (the wet sample is n - D[n], so D is exact on a whole frame and the + interpolated position rounded between them), on `read_head(cls)`. + `ok` is False next to the ramp's wrap and before the line has filled. + `setup(effect)` runs after construction (a Glide grid position). + Returns (D, ok).""" + opts = dict(HELD) + opts.update(options) + n = np.arange(frames) + x = (n % 65536) - 32768.0 + effect = read_head(cls)(src_of(x, 1, rate), time_ms=a, + character=character, **opts) + if setup is not None: + setup(effect) + y = render(effect, frames, {start: set_time(b)})[:, 0] + d = (n - (y.astype(np.int64) + 32768)) % 65536 + wrap = np.abs(np.diff(y)) > 30000 + ok = np.ones(frames, bool) + ok[1:] &= ~wrap + ok[:-1] &= ~wrap + ok[:start // 2] = False + return d, ok + + +def step_cell(cls, rate=RATE, amp=12000.0, start=None): + """T1b's step (fix round 1): sliding-head at Glide 0, 200 -> 100.4 ms. + The read position off a ramp render sits on T_old's whole frame before + the move and on T_new's after it, and no frame reads a position between + the two: the node jumps, where any walk, however fast, reads the + positions between. The pitch 2-50 ms after the move is within 10 cents + of the tone. `start` is the frame the move is issued on (a block + boundary; the default is the other T1 cells' frame). + + The round-0 clause (a jump on the read recovered through the inverted + loss low-pass of at least 5x the unfiltered tone's slope) is struck: it + reads the tone's two phases across the jump, so it moves with the frame + the move lands on (x1.00-x13.03 over 16 block boundaries at 48 kHz).""" + fa, fb = frames_of(200.0, rate), frames_of(100.4, rate) + if start is None: + start = start_of(200.0, rate) + frames = start + rate // 2 + opts = dict(HELD) + effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), + time_ms=200.0, character=tape.SLIDING_HEAD, glide_ms=0.0, + **opts) + y = render(effect, frames, {start: set_time(100.4)})[:, 0] got = lsq_hz(y[start + int(0.002 * rate):start + int(0.05 * rate)], rate) offset = cents(got / TONE) if got == got else float("inf") - return dict(passed=jump >= 5.0 * raw and abs(offset) <= 10.0, - ratio=jump / raw, offset=offset) + d, ok = read_position(cls, 200.0, 100.4, rate, tape.SLIDING_HEAD, start, + frames, glide_ms=0.0) + lo = start - 2000 + window = np.arange(lo, frames) + window = window[ok[lo:frames]] + pos = d[window] + between = int(np.sum((pos != fa) & (pos != fb))) + before = bool(np.all(d[lo:start][ok[lo:start]] == fa)) + tail = start + rate // 10 + after = bool(np.all(d[tail:frames][ok[tail:frames]] == fb)) + landed = window[pos == fb] + first = int(landed[0]) - start if len(landed) else None + return dict(passed=before and after and between == 0 + and abs(offset) <= 10.0, between=between, before=before, + after=after, offset=offset, landed=first, fa=fa, fb=fb) G_START = 9472 @@ -1012,14 +1229,58 @@ def test_a_staircase_is_red_on_the_ramp(self): self.assertFalse(got["passed"], got) self.assertGreater(got["step"], 1.0, got) + def test_the_glide_binades(self): + # Fix round 1 (dossier T1b, revised under vision 7.2): a rising move + # is claimed from grid 4 while the head stays under 16 384 frames, + # from grid 10 past 16 384 and from grid 22 past 32 768 (48 kHz: + # 341.3 and 682.7 ms). The claimed edge grids read green inside + # their binades; grid 1 at the default Time's 350 -> 450 ms, no + # longer claimed, reads +40.98 c; falling moves are claimed at + # every grid, grid 1 included. + cells = ((4, 200.0, 330.0, False), (10, 400.0, 650.0, False), + (22, 750.0, 1150.0, False), (1, 350.0, 450.0, True), + (1, 650.0, 400.0, False)) + for grid, a, b, red in cells: + got = walk_cell(TapeDelay, a, b, RATE, tape.SLIDING_HEAD, + glide_law_hz(glide_ms_of(grid), a, b), + setup=lambda e, g=grid: e.set_macro(GLIDE_I, g)) + self.assertEqual(got["unread"], 0, (grid, a, b, got)) + self.assertEqual(got["passed"], not red, (grid, a, b, got)) + if red: + self.assertAlmostEqual(got["worst"], 40.98, delta=0.05) + + def test_a_short_piece_is_not_read(self): + # Fix round 1: a piece whose two readings do not both exist and + # agree within 1 c is reported unread, not green or red. The span + # refuter's cell, grid 1, 41.75 -> 55.66 ms (Time MIDI 31.75 and + # 0.75 of it), cuts a 45-frame piece and a 646-frame one; the + # shifted tone (34 Hz) has no period in either, so neither is read + # (Station C's ramp-slope-only reader put -215.6 c on the first). + grid = 1 + b = _component.macro_value(TapeDelay._MACRO_RANGES[TIME_I], 0.25) + a = 0.75 * b + got = walk_cell(TapeDelay, a, b, RATE, tape.SLIDING_HEAD, + glide_law_hz(glide_ms_of(grid), a, b), + setup=lambda e: e.set_macro(GLIDE_I, grid)) + self.assertEqual(got["unread"], len(got["pieces"]), got) + self.assertFalse(got["passed"], got) + self.assertLess(min(p[0] for p in got["pieces"]), 64, got) + def test_the_step_at_glide_0(self): - got = step_cell(TapeDelay) - self.assertTrue(got["passed"], got) - self.assertGreater(got["ratio"], 8.0) + # Fix round 1: the read position jumps between two frames, at three + # rates and on four block boundaries each; no pitch offset after. + for rate in (48000, 44100, 22050): + base = start_of(200.0, rate) + for k in (0, 2, 5, 13): + got = step_cell(TapeDelay, rate, start=base + k * BLOCK) + self.assertTrue(got["passed"], (rate, k, got)) + self.assertEqual(got["landed"], 0, (rate, k, got)) - def test_a_walk_at_glide_0_is_red(self): - got = step_cell(WalkAtZeroTape) - self.assertFalse(got["passed"], got) + def test_a_fast_walk_at_glide_0_is_red(self): + for rate in (48000, 22050): + got = step_cell(FastWalkAtZeroTape, rate) + self.assertFalse(got["passed"], got) + self.assertGreater(got["between"], 200, got) def test_the_gesture(self): got = gesture_verdict(TapeDelay) @@ -1098,7 +1359,29 @@ def t2_one_pass(cls, time_ms, spacing_um, character, rate=RATE, amp), rate, f) err = got - eq13_db(f, v, spacing_um * 1e-6) worst = float(np.max(np.abs(err))) - return dict(passed=worst <= 2.0, worst=worst, top=top) + return dict(passed=worst <= 2.0, worst=worst, top=top, + at=float(f[int(np.argmax(np.abs(err)))])) + + +def tone_db_off_eq13(time_ms, spacing_um, character, hz, rate=RATE, + amp=30000.0): + """One pass on a steady tone at `hz`: the least-squares amplitude of + the wet tone once the loop low-pass has settled, over the input's, in + dB, less eq. (13) there (the material refuter's `tone_level_db`).""" + t = frames_of(time_ms, rate) + frames = t + int(0.3 * rate) + effect = TapeDelay(src_of(sine(hz, amp, frames, rate), 2, rate), + time_ms=time_ms, spacing_um=spacing_um, + character=character, **HELD) + y = render(effect, frames)[:, 0] + seg = y[t + 2000:t + 2000 + int(0.2 * rate)] + n = np.arange(len(seg)) + a = np.stack([np.cos(2 * np.pi * hz * n / rate), + np.sin(2 * np.pi * hz * n / rate), np.ones(len(seg))], 1) + coef, *_ = np.linalg.lstsq(a, seg, rcond=None) + level = 20 * math.log10(math.hypot(coef[0], coef[1]) / amp) + v = tape.speed(character, time_ms) + return level - float(eq13_db([hz], v, spacing_um * 1e-6)[0]) def t2_npass(cls, time_ms=350.0, spacing_um=5.0, character=tape.VARISPEED, @@ -1161,9 +1444,24 @@ def test_one_pass_at_the_named_cells(self): self.assertTrue(got["passed"], (rate, character, time_ms, spacing, got)) - def test_one_pass_at_minus_20_dbfs(self): - got = t2_one_pass(TapeDelay, 350.0, 5.0, tape.VARISPEED, amp=3277.0) - self.assertTrue(got["passed"], got) + def test_below_0_dbfs_the_impulse_reads_high_not_the_class(self): + # Fix round 1 (dossier T2, revised under vision 7.2): the one-pass + # clause is stated at 0 dBFS. At -20 dBFS the impulse's int16- + # rounded tail carries this cell's reading over the bar (2.052 dB, + # sliding-head 350 ms, Spacing MIDI 39, 48 kHz), while a steady tone + # at the frequency it reads worst sits inside at both levels, so the + # -20 dBFS impulse is no longer a claim of the row. + spacing = _component.macro_value( + TapeDelay._MACRO_RANGES[SPACING_I], 39 / 127.0) + low = t2_one_pass(TapeDelay, 350.0, spacing, tape.SLIDING_HEAD, + amp=3277.0) + full = t2_one_pass(TapeDelay, 350.0, spacing, tape.SLIDING_HEAD) + self.assertGreater(low["worst"], 2.0, low) + self.assertTrue(full["passed"], full) + for amp in (3277.0, 30000.0): + off = tone_db_off_eq13(350.0, spacing, tape.SLIDING_HEAD, + low["at"], amp=amp) + self.assertLess(abs(off), 2.0, (amp, off)) def test_a_doubled_corner_is_red(self): for rate in (48000, 22050): @@ -1186,19 +1484,30 @@ def test_the_loss_outside_the_loop_is_red(self): def corner_and_10k(seg, rate): - """The first -3.01 dB crossing of a one-pass response, interpolated in - log frequency on a 20 000-point grid, and its level at 10 kHz.""" + """The first crossing 3.01 dB under the one-pass response's own DC + level, interpolated in log frequency on a 20 000-point grid, and its + level at 10 kHz (against unity). + + Fix round 1: the crossing was read against unity. As the impulse falls + its int16-rounded tail loses DC, which moved the corner ratio with level + (+0.734 % at -20 dBFS against unity, +0.125 % against the response's + DC; Spacing MIDI 51, 48 kHz). A response with no DC has no corner.""" + dc = float(np.sum(seg)) + level_10k = float(response_db(seg, rate, [10000.0])[0]) + if not dc > 0.0: + return float("nan"), level_10k + ref = 20.0 * math.log10(dc) - 3.0103 f = np.geomspace(20.0, min(20000.0, 0.49 * rate), 20000) db = response_db(seg, rate, f) - below = np.nonzero(db <= -3.0103)[0] + below = np.nonzero(db <= ref)[0] if not len(below) or below[0] == 0: - return float("nan"), float(response_db(seg, rate, [10000.0])[0]) + return float("nan"), level_10k i = below[0] a, b = db[i - 1], db[i] - frac = (a + 3.0103) / (a - b) + frac = (a - ref) / (a - b) corner = math.exp(math.log(f[i - 1]) + frac * (math.log(f[i]) - math.log(f[i - 1]))) - return corner, float(response_db(seg, rate, [10000.0])[0]) + return corner, level_10k def t3_cell(cls, spacing_um=5.0, rate=RATE, amp=32767.0): @@ -1250,20 +1559,21 @@ def bh4(n): def delay_trace(cls, rate=RATE, wow=2.0, flutter=1.0, amp=12000.0, - seconds=60.0, time_ms=350.0): - """The delay recovered from a steady 997 Hz tone's analytic phase over - `seconds`, averaged to 200 Hz, mean removed; one channel.""" + seconds=60.0, time_ms=350.0, tone=TONE): + """The delay recovered from a steady tone's analytic phase (997 Hz, the + row's) over `seconds`, averaged to 200 Hz, mean removed; one + channel.""" frames = int(seconds * rate) + int(time_ms / 1000 * rate) + rate opts = dict(HELD) opts.update(wow_cents=wow, flutter_cents=flutter) - effect = cls(src_of(sine(TONE, amp, frames, rate), 1, rate), + effect = cls(src_of(sine(tone, amp, frames, rate), 1, rate), time_ms=time_ms, **opts) y = render(effect, frames)[:, 0] start = int(time_ms / 1000 * rate) + rate // 2 y = y[start:start + int(seconds * rate)] ph = np.unwrap(np.angle(analytic(y))) t = (np.arange(len(y)) + start) / float(rate) - trace = t - ph / (2 * np.pi * TONE) + trace = t - ph / (2 * np.pi * tone) step = int(round(rate / 200.0)) m = len(trace) // step trace = trace[:m * step].reshape(m, step).mean(axis=1) * 1000.0 @@ -1271,6 +1581,8 @@ def delay_trace(cls, rate=RATE, wow=2.0, flutter=1.0, amp=12000.0, def _fit_line(trace, t, f0): + """The least-squares sinusoid near `f0`: (frequency, the fit, its + amplitude in the trace's units).""" best = None for f in np.linspace(f0 - 0.005, f0 + 0.005, 101): a = np.stack([np.sin(2 * np.pi * f * t), np.cos(2 * np.pi * f * t)], @@ -1279,15 +1591,26 @@ def _fit_line(trace, t, f0): fit = a @ coef err = float(np.sum((trace - fit) ** 2)) if best is None or err < best[0]: - best = (err, f, fit) - return best[1], best[2] + best = (err, f, fit, math.hypot(*coef)) + return best[1], best[2], best[3] + + +#: Fix round 1: every T4 clause also needs what it reads to be there in +#: absolute terms, each line's fitted amplitude and the slow band's at least +#: this many ms of delay. The weakest claimed line, Flutter grid 1 +#: (0.0315 c), is 5.66e-4 ms peak by the wow law; the class built as a wire +#: has a whole delay trace of 3e-7 ms rms. Read in dB over its own floor +#: alone, the detector could not tell the two apart. +T4_FLOOR_MS = 1e-5 def t4_verdict(trace, rate): """T4's clauses: two lines in 0.2-12 Hz each >= 40 dB over the median floor between them, their ratio not within 1 % of p/q (p, q <= 8), and the 0.017-0.1 Hz band of the residual after both lines are fitted out - >= 40 dB over that residual's floor.""" + >= 40 dB over that residual's floor. Fix round 1: each clause also + needs its lines, or its band, at least T4_FLOOR_MS in amplitude; the + ratio clause reads two lines, so it needs both there.""" n = len(trace) w = bh4(n) f = np.fft.rfftfreq(n, 1.0 / rate) @@ -1305,29 +1628,38 @@ def t4_verdict(trace, rate): a, b = lines between = sdb[a + 8:b - 8] if len(between) < 8: - return dict(passed=False, why="lines adjacent") + return dict(passed=False, why="lines adjacent", two_lines=False, + ratio_ok=False, drift_ok=False) floor = float(np.median(between)) def parabolic(i): p, q, r = sdb[i - 1], sdb[i], sdb[i + 1] return (i + 0.5 * (p - r) / (p - 2 * q + r)) * df t = np.arange(n) / rate - f1, fit1 = _fit_line(trace, t, parabolic(a)) - f2, fit2 = _fit_line(trace - fit1, t, parabolic(b)) + f1, fit1, a1 = _fit_line(trace, t, parabolic(a)) + f2, fit2, a2 = _fit_line(trace - fit1, t, parabolic(b)) r = trace - fit1 - fit2 r = r - np.sum(r * w) / np.sum(w) - rdb = 10 * np.log10(np.abs(np.fft.rfft(r * w)) ** 2 + 1e-300) + spec = np.abs(np.fft.rfft(r * w)) + rdb = 10 * np.log10(spec ** 2 + 1e-300) rfloor = float(np.median(rdb[a + 8:b - 8])) ratio = f2 / f1 near = min(abs(ratio - p / float(q)) / (p / float(q)) for p in range(1, 9) for q in range(1, 9)) top = np.searchsorted(f, 0.1) drift = float(rdb[1:top].max()) - rfloor + # The band's largest bin as a sinusoid's amplitude (the window's + # coherent gain is its sum). + slow_ms = 2.0 * float(spec[1:top].max()) / float(np.sum(w)) d1, d2 = sdb[a] - floor, sdb[b] - floor - two = d1 >= 40.0 and d2 >= 40.0 - return dict(passed=two and near > 0.01 and drift >= 40.0, - two_lines=two, ratio_ok=near > 0.01, drift_ok=drift >= 40.0, - f1=f1, f2=f2, d1=d1, d2=d2, ratio=ratio, drift=drift) + there = a1 >= T4_FLOOR_MS and a2 >= T4_FLOOR_MS + two = d1 >= 40.0 and d2 >= 40.0 and there + ratio_ok = near > 0.01 and there + drift_ok = drift >= 40.0 and slow_ms >= T4_FLOOR_MS + return dict(passed=two and ratio_ok and drift_ok, + two_lines=two, ratio_ok=ratio_ok, drift_ok=drift_ok, + f1=f1, f2=f2, d1=d1, d2=d2, a1=a1, a2=a2, slow_ms=slow_ms, + ratio=ratio, drift=drift) class T4Fluctuation(unittest.TestCase): @@ -1358,6 +1690,34 @@ def test_the_planted_tables_are_each_red_on_their_clause(self): self.assertFalse(got["drift_ok"], got) self.assertTrue(got["two_lines"], got) + def test_the_weakest_claimed_lines_clear_the_absolute_floor(self): + # Fix round 1: Wow grid 1 x Flutter grid 1 at the row's lower level, + # where the flutter line is 5.66e-4 ms by the wow law. + got = t4_verdict(*delay_trace(TapeDelay, wow=8.0 / 127, + flutter=4.0 / 127, amp=1200.0)) + self.assertTrue(got["passed"], got) + self.assertGreater(min(got["a1"], got["a2"], got["slow_ms"]), + 10 * T4_FLOOR_MS, got) + + def test_the_wire_is_red_on_every_clause(self): + # Fix round 1: the class built as a wire, at the probe tones where + # the round-0 detector read it green on every clause (996 Hz) or on + # all but the ratio (997 Hz, 1 000 Hz). The absolute clause is what + # turns each red; the pack runs 990-1 004 Hz at three rates. + wire = kit_faults.wire_build(TapeDelay) + for tone in (996.0, 997.0, 1000.0): + got = t4_verdict(*delay_trace(wire, tone=tone)) + self.assertFalse(got["two_lines"], (tone, got)) + self.assertFalse(got["ratio_ok"], (tone, got)) + self.assertFalse(got["drift_ok"], (tone, got)) + + def test_flutter_under_grid_1_is_red_and_not_claimed(self): + # Fix round 1: Flutter above 0 and under grid 1 (0.0315 c) is added + # to Not claimed; at 0.005 c (Wow 8 c) the flutter line reads under + # 40 dB over its floor. + got = t4_verdict(*delay_trace(TapeDelay, wow=8.0, flutter=0.005)) + self.assertFalse(got["two_lines"], got) + # -- T5: disconfirmed by design ------------------------------------------- @@ -1610,10 +1970,24 @@ def __exit__(self, *exc): def copy_of(effect): """A fresh instance of the same class and character at `effect`'s macro - positions, on silence at its rate.""" + positions, on silence at its rate. Fix round 1: it also carries the + constructor values the knobs cannot hold - an exact Glide off the grid + (under grid 1 down to the 0.99 pin, or the jump), an exact Time and the + line's `max_time_ms` - so a reading taken on the copy sees what the + node is handed; the round-0 copy dropped them.""" + ctor = {"character": effect._character, + "max_time_ms": effect._max_time_ms} + if effect._glide_exact is not None: + ctor["glide_ms"] = effect._glide_exact + if effect._time_exact is not None: + ctor["time_ms"] = effect._time_exact other = type(effect)(src_of(np.zeros(4096), 2, effect._sample_rate), - character=effect._character) + **ctor) for index in range(len(type(effect).MACRO_LABELS)): + if index == GLIDE_I and effect._glide_exact is not None: + continue + if index == TIME_I and effect._time_exact is not None: + continue other.set_macro(index, effect.get_macro(index)) return other @@ -1721,6 +2095,18 @@ def read_walk(effect): return (round(float(handed), 6), round(float(law), 6)) +def read_handed_slew(effect): + """The `delay_slew` handed for one Time move, on a copy at these + positions and constructor values: the node's walk alone, whatever the + knob's label says. It is what T1b's step fault changes, and a + constructor Glide reaches every slew up to the 0.99 pin.""" + other = copy_of(effect) + nudge_time(other) + handed = other._delay._handed["delay_slew"] + other.deinit() + return round(float(handed), 9) + + def read_python_steps(effect): """How many times `delay_ms` is handed while eight blocks are pulled after one Time move, on a copy at these positions: the class hands it @@ -1739,13 +2125,15 @@ def read_python_steps(effect): #: whose law is live only away from the defaults is built where it is live #: (DigitalDelay's corner-cell precedent): `GlideScaledVarispeed` is the #: clean class at Glide 6 000 ms, so it is built at grid 1's 1 222 ms; -#: `WalkAtZeroTape` differs only at Glide 0. +#: `FastWalkAtZeroTape` differs only at Glide 0. Fix round 1: T1b's step +#: fault is `FastWalkAtZeroTape`, read by the handed slew alone; +#: `WalkAtZeroTape` was a constructor value (1 204.08 ms). SLIDE = {"character": tape.SLIDING_HEAD} REACH_WALKS = ( ("DoubleWalkTape", DoubleWalkTape, read_walk, {}), ("GlideScaledVarispeed", GlideScaledVarispeed, read_walk, {"glide_ms": 1222.0}), - ("WalkAtZeroTape", WalkAtZeroTape, read_walk, + ("FastWalkAtZeroTape", FastWalkAtZeroTape, read_handed_slew, dict(SLIDE, glide_ms=0.0)), ("StaircaseTape", StaircaseTape, read_python_steps, SLIDE), ("LoopShiftTape", LoopShiftTape, read_loop_shift, {}), @@ -1773,6 +2161,56 @@ def build(cls): build) +#: Fix round 1: the constructor values the reachability walk visits beside +#: the macro grid and the patches - the ones a knob cannot hold. Glide: the +#: jump, under the 0.99 pin, the pin's edge (1 191.9 ms), between the pin +#: and grid 1 (1 204.08 ms is slew 0.98 exactly), grid 1 itself, the +#: defaults and past the 12 s top. Time: 0 (20 ms), off-grid values, past +#: the top. The line: a lowered `max_time_ms`. +CTOR_VALUES = ( + ("glide_ms", (0.0, 500.0, 1000.0, 1191.9, 1195.0, 1200.0, + 1180.0 / 0.98, 1210.0, 1221.0, 1221.96, 1250.0, 2950.0, + 6000.0, 12000.0, 30000.0)), + ("time_ms", (0.0, 20.0, 100.4, 351.0, 1199.9, 5000.0)), + ("max_time_ms", (300.0, 1200.0)), +) + + +def _same(a, b, tolerance): + if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)): + return len(a) == len(b) and all(_same(x, y, tolerance) + for x, y in zip(a, b)) + if a is None or b is None: + return a is b + return abs(float(a) - float(b)) <= tolerance + + +def reach_ctor(faulted, reading, rate, ctor, tolerance=1e-9): + """The fault's reading against the clean class built at every value in + CTOR_VALUES, one option at a time on top of `ctor`, the node watched. + Raises `kit_faults.FaultReachable` on a match; returns the number of + constructions checked.""" + checked = 0 + with NodeSpy(): + subject = faulted(src_of(np.zeros(512), 2, rate), **ctor) + target = reading(subject) + subject.deinit() + for name, values in CTOR_VALUES: + for value in values: + opts = dict(ctor) + opts[name] = value + clean = TapeDelay(src_of(np.zeros(512), 2, rate), **opts) + got = reading(clean) + clean.deinit() + checked += 1 + if _same(got, target, tolerance): + raise kit_faults.FaultReachable( + "%s: the clean class built with %s=%r reads %r, the " + "state the fault forces" % ( + faulted.__name__, name, value, got)) + return checked + + class FaultsAreUnreachable(unittest.TestCase): """Every planted fault's reachability walk, at 48, 44.1 and 22.05 kHz, reading what the node is handed at each position (11 macros x 17 @@ -1788,6 +2226,26 @@ def test_every_fault_is_off_the_surface_at_three_rates(self): result = reach(faulted, reading, rate, ctor) self.assertEqual(result["checked"], self.CHECKED) + def test_every_fault_is_off_the_constructor_values(self): + # Fix round 1: the walk also visits the constructor values a knob + # cannot hold (CTOR_VALUES, 23 constructions per fault). + for rate in (48000, 44100, 22050): + for name, faulted, reading, ctor in REACH_WALKS: + with self.subTest(fault=name, rate=rate): + self.assertEqual( + reach_ctor(faulted, reading, rate, ctor), + sum(len(v) for _, v in CTOR_VALUES)) + + def test_a_constructor_value_fault_is_called_reachable(self): + # Station B's step fault, slew 0.98 at Glide 0, is the clean class + # built at glide_ms 1 204.08: the constructor walk must say so, + # and the macro walk alone cannot. + ctor = dict(SLIDE, glide_ms=0.0) + with self.assertRaises(kit_faults.FaultReachable): + reach_ctor(WalkAtZeroTape, read_handed_slew, RATE, ctor) + self.assertEqual(reach(WalkAtZeroTape, read_handed_slew, RATE, + ctor)["checked"], self.CHECKED) + def test_a_dialable_fault_is_called_reachable(self): # Spacing forced to 20 um: Spacing MIDI 127 plays it. with self.assertRaises(kit_faults.FaultReachable): @@ -1832,12 +2290,26 @@ def test_every_demonstrated_row_is_red_on_a_wire(self): ("T2", lambda cls: t2_one_pass(cls, 350.0, 5.0, tape.VARISPEED)), ("T3", lambda cls: t3_cell(cls)), ("T4", lambda cls: t4_verdict(*delay_trace(cls, seconds=60.0))), + # Fix round 1: the step's read-position clause and the + # per-piece reader. + ("T1b step", lambda cls: step_cell(cls)), + ("T1a per piece", lambda cls: walk_cell( + cls, 600.0, 1200.0, RATE, tape.VARISPEED, TONE * 0.5)), + ("T1b per piece", lambda cls: walk_cell( + cls, 400.0, 650.0, RATE, tape.SLIDING_HEAD, + glide_law_hz(glide_ms_of(10), 400.0, 650.0), + setup=lambda e: e.set_macro(GLIDE_I, 10))), ) for name, measure in rows: result = kit_faults.null_build_red(TapeDelay, measure, label="TapeDelay %s" % name) self.assertFalse(result["null"]["passed"], name) self.assertTrue(result["control"]["passed"], name) + if name == "T4": + # Fix round 1: red on every clause, not on the ratio alone. + null = result["null"] + self.assertFalse(null["two_lines"] or null["ratio_ok"] + or null["drift_ok"], null) if __name__ == "__main__": From d7f7c910c96e2b33af427c971dd66c778fbaf3a5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:26:45 -0500 Subject: [PATCH 31/88] PingPongDelay fix round 2: the audit's five sentences restated and tested, class side Docstring only in the class; no arithmetic moved. - T4's off-frame exclusion is the condition, any Time the node lands off the frame: the knob's 25 / 20 positions (at most 1/512 / 1/1024 of a frame) beside the constructor or Sync worst, 5554 of 43 219 and 2785 of 21 610 whole frames, up to 1/256 / 1/512 (743.04-760.73 ms), 575.31 / 289.31 LSB on the law, 0.39 % a pass, 1.71 / 1.96 dB at repeat 60. New test pins 750.0227 ms at 44.1 kHz (miss 1/256, readback 19922 / 78, T4 575.31 red) and 750.0454 ms (on the frame, 0.69, passes). - The Cut-in ceiling: the arithmetic bound floor(32767 - 65535 Mix) - 1 = 13 105 LSB (-7.96 dBFS at Mix 0.3), rendered clean, and the worst cell measured (5 Hz square, Cut 40 Hz, f 0.99, clean from -5.5 dBFS). - Repeat Tone's out stop after Tone in: the figures are the default Time's; the worst over 18 Times is stated (19 LSB during noise, 37 LSB in the tail on the frame, 46-50 at the off-frame positions tried). - T3's filter gap per filter cell, over Repeat Cut's travel (8192 frames at Cut MIDI 1); new test pins 511 at 4096 and exact at 8192, mono 0. - Antiphase at Mix 2: within 1 LSB where the source saturates; the test adds the saturated ramp_fs. - CHANGELOG: a constructor or Sync Time can land off the frame; the Cut-in bound. Each new assertion was shown red with the old words planted in a scratch copy of the test file: 1/512 bound (0.0039 > 0.0020), clean from -4 dBFS (434 railed), 6 LSB during (15), 512 frames at Cut MIDI 1 (9352 differ), exact zero on the saturated ramp (10 samples at 48 kHz). --- CHANGELOG.md | 18 ++- lib/audioeffects/rebuilt/pingpongdelay.py | 89 ++++++---- tests/test_cpython_effects_pingpongdelay.py | 170 ++++++++++++++++---- 3 files changed, 212 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bc4fd..9e4b6d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,17 +42,19 @@ there, and are recorded in its changelog. the full bounce, by a stated law. The dry path is each channel's own signal, the mono sum is an ordinary feedback delay exactly, and the class hands a one-channel node the settings that keep its loop alive (the old - class silenced it). Every static Time lands on a whole frame at 48 kHz; - at 44.1 and 22.05 kHz 25 and 20 knob positions land one float32 step off, - which needs a node option. Time walks rather than clicks, Sync follows the - host's beat, and Repeat Tone and Repeat Cut put a low-pass and a - high-pass in the loop, defaulting out. It lives in + class silenced it). Every static Time lands on a whole frame at 48 kHz; at + 44.1 and 22.05 kHz 25 and 20 knob positions land one float32 step off, and + so can a constructor or Sync Time (5554 and 2785 of the whole frames from + 20 to 1000 ms), which needs a node option. Time walks rather than clicks, + Sync follows the host's beat, and Repeat Tone and Repeat Cut put a + low-pass and a high-pass in the loop, defaulting out. It lives in `lib/audioeffects/rebuilt/pingpongdelay.py`, and `audioeffects.PingPongDelay` is still the old class. The docstring states the input ceiling (-3 dBFS peak at the defaults, -3.1 over the shipped - patches, on `noise_det`), that at full Spread the loop hears (L + R) / 2, - and what Repeat Tone's out stop costs after Tone has been in. Board cost - is unmeasured. + patches, on `noise_det`; -7.96 dBFS on any material with Repeat Cut in at + the default Mix), that at full Spread the loop hears (L + R) / 2, and what + Repeat Tone's out stop costs after Tone has been in. Board cost is + unmeasured. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index 7fbe01c..0b6661f 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -51,27 +51,37 @@ since the last `reset()`; once it has been in, its out stop moves the Feedback a hair (below), and at Feedback 0.99 the first eight repeats part from a delay at the knob's Feedback in 5 samples by up to 5 LSB. With a -loop filter in, the material -must also end at least 512 frames before Time at 48 kHz, because each -side's filter meets that side's next repeat two Times later where the mono -delay's meets the very next one: a noise burst ending one frame before +loop filter in, the material must also end some way before Time, because +each side's filter meets that side's next repeat two Times later where the +mono delay's meets the very next one: a noise burst ending one frame before Time parts them in 295 samples by 1 LSB (Repeat Tone 800 Hz) and in 680 by -up to 2 LSB (Repeat Cut 400 Hz). +up to 2 LSB (Repeat Cut 400 Hz). How far before depends on the filter. +Measured at 48 kHz, Time 280 ms, Feedback 0.6, on noise up to 0 dBFS, the +gap after which every longer gap tried is exact: 512 frames for Repeat +Tone at 800 and 2500 Hz and Repeat Cut at 400 Hz; for Repeat Cut lower +down, 1024 frames at MIDI 96 (188 Hz), 2048 at MIDI 64 (89 Hz), 4096 at +MIDI 32 (42 Hz), and 8192 (171 ms) at MIDI 16 (29 Hz) and at MIDI 1 +(20.4 Hz), where 4096 still leaves 511 samples. MIDI 1 needs 8192 at +Feedback 0.99 too, 6144 at 44.1 kHz and 3072 at 22.05 kHz. **Spread's law.** Spread s hands the node `cross_feed` s and `input_pan` -s (First Side left) or +s (right). On a click identical in both channels, repeat n reads f^(n-1) [(1 - s/2) + (s/2)(1 - 2s)^(n-1)] of the click on the First Side channel and f^(n-1) [(1 - s/2) - (s/2)(1 - 2s)^(n-1)] on the other. At Spread 0 the two channels are identical; at Spread 1 each -repeat is on one side only and the other side is exact zero. At the Times -the node lands off the frame (below) the law misses by up to 289 LSB on a -20 000 click at Feedback 0.99 (44.1 kHz, MIDI 95; 145 LSB at 22.05 kHz). +repeat is on one side only and the other side is exact zero. The law is +not claimed at any Time the node lands off the frame (below). There it +misses a 20 000 click at Feedback 0.99 by up to 289 LSB at a knob position +(MIDI 95; 145 LSB at 22.05 kHz), and by up to 575 LSB at a constructor or +Sync Time (750.0227 ms at 44.1 kHz; 289 LSB at 22.05 kHz). **What the loop hears.** At Spread 1 the loop is fed the average of the two input channels, (L + R) / 2, into one line, so what the two channels share bounces and what differs between them never repeats. A source whose right channel is the left one upside down puts nothing in the loop: the -defaults pass it through untouched and Mix 2 is silence. The dry path is +defaults pass it through untouched, and at Mix 2 the output from Time on +is silence, or within 1 LSB of it where the source saturates (R cannot be +-L where L is -32768: a full-scale ramp leaves 1 LSB). The dry path is always each channel's own signal, never swapped or summed. **Input ceiling.** The dry path sits at unity and the repeats add to it, @@ -88,8 +98,16 @@ the default Mix 0.3 and at every patch. At exactly (1 - Mix) of full scale the sum can round onto 32767, the rail value, though nothing is clipped. Repeat Cut's high-pass overshoots a square wave's edges, so with it in -leave more room: a 40 Hz square wave at -3.1 dBFS rails with Cut at 40 or -400 Hz, and is clean from -4 dBFS. +leave more room. Its output is the line's value less its own state, both +inside the int16 line, so a repeat can reach twice full scale: an input at +or below floor(32767 - 65535 Mix) - 1 cannot reach the rail at any Time, +Feedback, Spread or Cut, which is 13 105 LSB, -7.96 dBFS, at the default +Mix 0.3 (rendered on 108 square-wave cells and on noise at that level, +none railed). Measured below that bound, the worst cell tried is a 5 Hz +square at Feedback 0.99 with Cut at 40 Hz: it rails 148 samples at +-4.5 dBFS and 40 at -5, and is clean from -5.5 dBFS. At the defaults a +5 Hz square rails at -4 dBFS (396 samples with Cut at MIDI 1, 158 at +40 Hz) and is clean from -4.5. **RAM.** The line is `max_time_ms + 1` ms of two int16 lanes whatever the channel count: 192 192 B at 48 kHz for the default 1000 ms (176 576 B at @@ -119,18 +137,24 @@ At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds back into frames in float32, and for some Times no float32 value lands on -the whole frame, so the read sits one float32 step off it: at most 1/512 -of a frame at 44.1 kHz and 1/1024 at 22.05 kHz. That is 25 of the 128 -knob positions at 44.1 kHz (MIDI 2, 3, 4, 19, 20, 24, 25, 28, 38, 43, 47, -48, 50, 63, 65, 67, 69, 70, 83, 92, 93, 95, 107, 108, 114) and 20 at -22.05 kHz (MIDI 2, 4, 19, 28, 38, 47, 63, 65, 67, 69, 70, 83, 88, 92, 93, -95, 108, 110, 112, 114). No patch's own Time is among them, nor the -default 280 ms; a Time Sync takes from a host's tempo can be. At those -Times each pass puts up to 0.2 % of the repeat on the frame beside it -(a 20 000 click's first repeat reads 19 961 and 39 at MIDI 95, 44.1 kHz), -and the repeats darken slowly: at Feedback 0.99 the 60th repeat of a -10 kHz tone is 0.86 dB quieter than the Feedback alone makes it at -44.1 kHz, 0.98 dB at 22.05 kHz, and a 1 kHz tone 0.01-0.02 dB. The class +the whole frame, so the read sits one float32 step off it. Among the 128 +knob positions that is 25 at 44.1 kHz (MIDI 2, 3, 4, 19, 20, 24, 25, 28, +38, 43, 47, 48, 50, 63, 65, 67, 69, 70, 83, 92, 93, 95, 107, 108, 114) +and 20 at 22.05 kHz (MIDI 2, 4, 19, 28, 38, 47, 63, 65, 67, 69, 70, 83, +88, 92, 93, 95, 108, 110, 112, 114), at most 1/512 of a frame off at +44.1 kHz and 1/1024 at 22.05 kHz. A constructor `time_ms` or a Time Sync +takes from a host's tempo reaches every whole frame from 20 to 1000 ms, +and 5554 of those 43 219 frames land off at 44.1 kHz and 2785 of 21 610 +at 22.05 kHz, up to 1/256 and 1/512 of a frame off (743.04-760.73 ms). +None do at 48 kHz. No patch's own Time is off, nor the default 280 ms. At +an off-frame Time each pass puts part of the repeat on the frame beside +it, and the repeats darken slowly. At a knob position that is up to 0.2 % +a pass (a 20 000 click's first repeat reads 19 961 and 39 at MIDI 95, +44.1 kHz), and at Feedback 0.99 the 60th repeat of a 10 kHz tone is +0.86 dB quieter than the Feedback alone makes it at 44.1 kHz and 0.98 dB +at 22.05 kHz (a 1 kHz tone 0.01-0.02 dB). At a constructor or Sync Time it +is up to 0.39 % a pass (19 922 and 78 at 750.0227 ms, 44.1 kHz) and +1.71 dB (44.1 kHz) and 1.96 dB (22.05 kHz) at the 60th repeat. The class cannot hand the node a number that lands there; a node change is asked for. @@ -150,12 +174,19 @@ exactly 1, which follows the repeats. The Feedback is still handed clear of the stall window described under Tail, which at some Feedbacks moves it by up to 2.6 x 10^-5 (0.99 plays as 0.989976102; 0.85 does not move), so -the repeats die a hair sooner than with the filter truly out. On 2 s of -0 dBFS noise that is 0 LSB at Feedback 0.85, 1-5 LSB at 0.5, 0.75, 0.9 and -0.95, and 6 LSB at 0.99; a full-scale click at 0.99 followed through its -whole tail differs by up to 32 LSB, around its 70th repeat. Repeat Cut -cannot do that (a high-pass at coefficient 1 -mutes the loop), so once Repeat Cut has been in since the last `reset()` +the repeats die a hair sooner than with the filter truly out. How much +depends on Time as well as Feedback. On 2 s of 0 dBFS noise at the default +280 ms it is 0 LSB at Feedback 0.85, 1-5 LSB at 0.5, 0.75, 0.9 and 0.95, +and 6 LSB at 0.99. Shorter Times fit more laps into the noise: over 18 +Times from 20 to 1000 ms, at 0.99 it is up to 19 LSB during the noise +(25.6 ms, 44.1 kHz; 16 LSB at 48 and 22.05 kHz), and at 0.95 up to 7 LSB. +Followed through the whole tail after the noise, at 0.99, it is up to +37 LSB at every one of those Times the node lands on the frame, the default +280 ms included, and more at the Times it lands off the frame (above): +50 and 49 LSB at MIDI 24 and 48 (44.1 kHz), 48 and 46 at MIDI 88 and 112 +(22.05 kHz), the four off-frame positions among those tried. No worst is +claimed off the frame. Repeat Cut cannot do that (a high-pass at +coefficient 1 mutes the loop), so once Repeat Cut has been in since the last `reset()` its bottom stop stays in circuit at the 20 Hz corner, the knob's own bottom, until the next `reset()`. That costs the low end of the repeats something a true out would not, and `tail_samples` is `None` while it diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index bb71fd5..c4c25fd 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -1229,21 +1229,23 @@ def test_the_loop_filters_read_exact(self): cut=cut) self.assertTrue(result["passed"], result) - def _gap(self, k, peak, opts, damping=0.0, cut=0.0): + def _gap(self, k, peak, opts, damping=0.0, cut=0.0, macros=None): """The row's burst lengthened to end `k` frames before T (280 ms, - f 0.6, 48 kHz): (sum differing, max LSB, first differing frame, - mono differing).""" + f 0.6, 48 kHz), `macros` set after construction: (sum differing, + max LSB, first differing frame, mono differing).""" T = law_frames(280.0, RATE) frames = 9 * T + int(0.1 * RATE) n = T - k x = np.zeros(frames) x[:n] = np.round(np.random.RandomState(12345) .uniform(-peak, peak, n)) - stereo = render(PingPongDelay, x, RATE, 2, time_ms=280.0, - feedback=0.6, mix=2.0, **opts).astype(np.int32) + stereo = render(PingPongDelay, x, RATE, 2, macros=macros, + time_ms=280.0, feedback=0.6, mix=2.0, + **opts).astype(np.int32) total = stereo[:, 0] + stereo[:, 1] - mono = render(PingPongDelay, x, RATE, 1, time_ms=280.0, - feedback=0.6, mix=2.0, **opts)[:, 0].astype(np.int32) + mono = render(PingPongDelay, x, RATE, 1, macros=macros, + time_ms=280.0, feedback=0.6, mix=2.0, + **opts)[:, 0].astype(np.int32) ref = reference(x, RATE, T, 0.6, damping, cut) where = np.flatnonzero(total != ref) return (len(where), int(np.abs(total - ref).max()), @@ -1267,6 +1269,22 @@ def test_the_filter_cells_need_material_ending_512_frames_before_t(self): self.assertEqual(self._gap(256, 32767, {"cut_hz": 400.0}, cut=cut)[0], 16) + def test_the_gap_grows_as_repeat_cut_goes_down(self): + # Fix round 2 (audit round 2, item 4): 512 frames is the three + # named cells' gap, not any loop filter's. At Cut MIDI 1 (the knob + # at 20 x 20^(1/127) Hz) and 0 dBFS a burst ending 4 096 frames + # before T still differs in 511 samples, and 8 192 is exact; the + # mono build is exact at both. The old "512 frames" is red there. + cut = pp.nominal_cut_hz(20.0 * 20.0 ** (1.0 / 127.0), RATE) + macros = {CUT_I: 1} + at_512 = self._gap(512, 32767, {}, cut=cut, macros=macros) + self.assertGreater(at_512[0], 0) + self.assertEqual(at_512[3], 0) + at_4096 = self._gap(4096, 32767, {}, cut=cut, macros=macros) + self.assertEqual((at_4096[0], at_4096[3]), (511, 0)) + at_8192 = self._gap(8192, 32767, {}, cut=cut, macros=macros) + self.assertEqual((at_8192[0], at_8192[3]), (0, 0)) + def test_tone_in_then_out_is_outside_the_row(self): # Fix round 1 (audit item 2, restated): once Tone has been in, its # out stop hands a Feedback moved clear of the stall window @@ -1333,6 +1351,36 @@ def test_the_law_misses_off_the_frame(self): feedback=0.99) self.assertTrue(result["passed"], (rate, midi)) + def test_a_constructor_time_off_the_frame_is_outside_the_law(self): + # Fix round 2 (audit round 2, item 1): T4's exclusion is a + # condition, any Time the node lands off the frame, not a list of + # knob positions. A constructor or Sync Time reaches every whole + # frame; at 44.1 kHz 750.0227 ms (n 33 076) is the worst band, + # 1/256 of a frame off, where the old docstring said at most 1/512. + rate = 44100 + f32 = np.float32 + for n, miss, readback, worst in ((33076, 2.0 ** -8, [19922, 78], + 575.31), + (33077, 0.0, [20000, 0], 0.69)): + time_ms = n * 1000.0 / rate + effect = spied(rate=rate, time_ms=time_ms) + self.assertEqual(read_frames(effect), float(n)) + ms = f32(handed(effect, "delay_ms")) + landed = float((ms * f32(rate)) / f32(1000.0)) + self.assertEqual(abs(landed - n), miss, n) + y = render(PingPongDelay, click(n + 8), rate=rate, + time_ms=time_ms, mix=2.0, feedback=0.0, spread=0.0) + self.assertEqual([int(v) for v in y[n:n + 2, 0]], readback, n) + result = t4_measure(PingPongDelay, rate, spreads=(0.0, 0.5, 1.0), + time_ms=time_ms, feedback=0.99) + self.assertAlmostEqual(result["worst"], worst, 2) + self.assertEqual(result["passed"], miss == 0.0, n) + # The old bound, 1/512 of a frame, is red on n 33 076. + effect = spied(rate=rate, time_ms=33076 * 1000.0 / rate) + ms = f32(handed(effect, "delay_ms")) + self.assertGreater(abs(float((ms * f32(rate)) / f32(1000.0)) + - 33076), 2.0 ** -9) + def test_the_panless_spread_is_red(self): for rate in RATES: result = t4_measure(PanlessPingPong, rate, spreads=(0.5, 1.0)) @@ -1413,6 +1461,20 @@ def test_an_antiphase_source_does_not_repeat_at_spread_1(self): y = render_pcm(PingPongDelay, anti, rate, spread=0.5) self.assertGreater(int(np.count_nonzero(y[T:] != anti[T:])), 0, rate) + # Fix round 2 (audit round 2, item 5): where the source + # saturates R cannot be -L (L is -32 768), so the full-scale + # ramp leaves 1 LSB at Mix 2, not the silence the old sentence + # said: the exact-zero assertion above is red on it. + left = material("ramp_fs", rate, 1)[:, 0].astype(np.int32) + sat = np.stack([left, np.clip(-left, -32768, 32767)], + axis=1).astype(np.int16) + self.assertGreater(int(np.count_nonzero(left == -32768)), 0) + y = render_pcm(PingPongDelay, sat, rate) + self.assertEqual(int(np.count_nonzero(y != sat)), 0, rate) + y = render_pcm(PingPongDelay, sat, rate, mix=2.0) + self.assertEqual(int(np.abs(y[T:].astype(np.int32)).max()), 1, + rate) + self.assertGreater(int(np.count_nonzero(y[T:])), 0, rate) # -------------------------------------------------------------------------- @@ -1496,10 +1558,9 @@ def test_tone_out_after_tone_in_is_within_the_stated_bound(self): # coefficient-1 low-pass follows the tap, but the Feedback still # goes through `clear_of_stalls`, which moves it by up to # 2.6 x 10^-5 where a stall window sits (0.99 -> 0.989976102; - # 0.85 is in no window). The docstring's bound: 0 at 0.85, at most - # 6 LSB on 2 s of 0 dBFS noise at 0.99, and at most 32 LSB on a - # full-scale click through the whole tail. The old "within 1 LSB" - # is red at 0.99. + # 0.85 is in no window). At the default 280 ms: 0 at 0.85, at most + # 6 LSB on 2 s of 0 dBFS noise at 0.99. The old "within 1 LSB" is + # red at 0.99. for rate in RATES: frames = 2 * rate x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, @@ -1510,10 +1571,34 @@ def test_tone_out_after_tone_in_is_within_the_stated_bound(self): worst = self._tone_out_after_in(x, rate, feedback=0.99) self.assertLessEqual(worst, 6, rate) self.assertGreater(worst, 1, rate) - x = click(700 * law_frames(20.0, RATE), 32767) - worst = self._tone_out_after_in(x, RATE, time_ms=20.0, feedback=0.99) - self.assertLessEqual(worst, 32) - self.assertGreater(worst, 1) + # Fix round 2 (audit round 2, item 3): those figures are the + # default Time's. At Time 20 ms more laps fit into the noise, and + # the docstring's worst over Time is 19 LSB during the noise and, + # at a Time on the frame (20 ms at 48 kHz is), 37 LSB through the + # tail after it; the old 6 and 32 are exceeded. + n = 2 * RATE + probe = PingPongDelay(silence_src(64), sample_rate=RATE, + time_ms=20.0, feedback=0.99) + frames = n + probe.tail_samples + law_frames(20.0, RATE) + probe.deinit() + x = np.zeros(frames) + x[:n] = np.frombuffer(probes.noise_det(frames=n, dbfs=0.0, + channels=1), + dtype=np.int16)[:n] + source, _ = to_source(x, 2, RATE) + touched = PingPongDelay(source, sample_rate=RATE, mix=2.0, + time_ms=20.0, feedback=0.99, tone_hz=2000.0) + touched.set_macro(TONE_I, 127) + clean, _ = to_source(x, 2, RATE) + plain = PingPongDelay(clean, sample_rate=RATE, mix=2.0, time_ms=20.0, + feedback=0.99) + d = np.abs(pull(touched, frames).astype(np.int32) + - pull(plain, frames).astype(np.int32)).max(axis=1) + during, after = int(d[:n].max()), int(d[n:].max()) + self.assertLessEqual(during, 19) + self.assertGreater(during, 6) + self.assertLessEqual(after, 37) + self.assertGreater(after, 32) def test_the_tail_reaches_exact_zero_inside_tail_samples(self): on = 200 * RATE // 1000 @@ -1665,21 +1750,50 @@ def test_the_any_material_bound(self): def test_repeat_cut_needs_more_room(self): # The loop high-pass overshoots a square's edges: at the defaults' - # Mix a 40 Hz square wave rails at -3.1 dBFS with Cut in, and is - # clean from -4 dBFS. - t = np.arange(int(self.SECONDS * RATE)) / RATE + # Mix a 40 Hz square wave rails at -3.1 dBFS with Cut in, and not + # with Cut out. + t = np.arange(int(4 * RATE)) / RATE wave_ = np.sign(np.sin(2.0 * math.pi * 40.0 * t + 1e-9)) + loud = 32767.0 * 10.0 ** (-3.1 / 20.0) * wave_ for cut_hz in (40.0, 400.0): - loud = 32767.0 * 10.0 ** (-3.1 / 20.0) * wave_ - quiet = 32767.0 * 10.0 ** (-4.0 / 20.0) * wave_ - self.assertGreater(railed_samples(PingPongDelay, None, - self.SECONDS, values=loud, - cut_hz=cut_hz), 0, cut_hz) - self.assertEqual(railed_samples(PingPongDelay, None, - self.SECONDS, values=quiet, - cut_hz=cut_hz), 0, cut_hz) - self.assertEqual(railed_samples(PingPongDelay, None, - self.SECONDS, values=loud), 0) + self.assertGreater(railed_samples(PingPongDelay, None, 4.0, + values=loud, cut_hz=cut_hz), 0, + cut_hz) + self.assertEqual(railed_samples(PingPongDelay, None, 4.0, + values=loud), 0) + + def test_the_cut_in_ceiling(self): + # Fix round 2 (audit round 2, item 2): "clean from -4 dBFS" was the + # 40 Hz square at the defaults only. The worst cell tried, a 5 Hz + # square with Cut at 40 Hz at Feedback 0.99, is clean at the stated + # -5.5 dBFS and rails 1 dB over; at -4 dBFS it rails, so the old + # sentence is red on it. Under the arithmetic bound, + # floor(32767 - 65535 Mix) - 1 = 13 105 LSB at Mix 0.3, it and the + # defaults' 5 Hz square at Cut MIDI 1 are clean. + t = np.arange(int(4 * RATE)) / RATE + wave_ = np.sign(np.sin(2.0 * math.pi * 5.0 * t + 1e-9)) + + def railed(dbfs=None, peak=None, macros=None, **options): + level = peak if peak is not None else 32767.0 * 10.0 ** ( + dbfs / 20.0) + values = level * wave_ + x = np.round(values).astype(np.int32) + y = render(PingPongDelay, values, RATE, 2, macros=macros, + **options).astype(np.int32) + source = ((x >= 32767) | (x <= -32768))[:, None] + return int(np.count_nonzero(((y >= 32767) | (y <= -32768)) + & ~source)) + + corner = {"cut_hz": 40.0, "feedback": 0.99} + self.assertEqual(railed(-5.5, **corner), 0) + self.assertGreater(railed(-4.5, **corner), 0) + self.assertGreater(railed(-4.0, **corner), 0) + edge = int(math.floor(32767 - 65535 * 0.3)) - 1 + self.assertEqual(edge, 13105) + self.assertEqual(railed(peak=edge, **corner), 0) + self.assertEqual(railed(peak=edge, macros={CUT_I: 1}), 0) + self.assertGreater(railed(-4.0, macros={CUT_I: 1}), 0) + self.assertEqual(railed(-4.5, macros={CUT_I: 1}), 0) # -------------------------------------------------------------------------- From c2906a368e6f0a320233677c64adc14794798f76 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:27:52 -0500 Subject: [PATCH 32/88] TapeDelay fix round 1: step_cell reports the struck round-0 ratio, ungraded Station C's t1b and faults parts and the surface refuter's faults part print step_cell's recovered-read ratio; the new step_cell reports it (x9.02 / x1.00 / x0.99 at 48 / 44.1 / 22.05 kHz, as the pack read it) and grades only the read position and the pitch after, so those probes still run. --- tests/test_cpython_effects_tapedelay.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 0458c57..f90ab33 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -1100,6 +1100,12 @@ def step_cell(cls, rate=RATE, amp=12000.0, start=None): y = render(effect, frames, {start: set_time(100.4)})[:, 0] got = lsq_hz(y[start + int(0.002 * rate):start + int(0.05 * rate)], rate) offset = cents(got / TONE) if got == got else float("inf") + # The struck round-0 figure, reported and never graded, so the earlier + # probes that print it still run. + coef = 1.0 - math.exp(-2.0 * math.pi * effect._damping / rate) + recovered = y[:-1] + np.diff(y) / coef + ratio = float(np.max(np.abs(np.diff(recovered))[start - 3:start + 3])) \ + / (2.0 * math.pi * TONE / rate * amp) d, ok = read_position(cls, 200.0, 100.4, rate, tape.SLIDING_HEAD, start, frames, glide_ms=0.0) lo = start - 2000 @@ -1114,7 +1120,8 @@ def step_cell(cls, rate=RATE, amp=12000.0, start=None): first = int(landed[0]) - start if len(landed) else None return dict(passed=before and after and between == 0 and abs(offset) <= 10.0, between=between, before=before, - after=after, offset=offset, landed=first, fa=fa, fb=fb) + after=after, offset=offset, landed=first, fa=fa, fb=fb, + ratio=ratio) G_START = 9472 From 7d0311dad0ca059c4fc6a04d2e50c19130ee982c Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:13:05 -0500 Subject: [PATCH 33/88] TapeDelay fix round 2: a Wow under grid 1 is red on T4's slow-band floor (the test that shows the new Not claimed line is needed) --- tests/test_cpython_effects_tapedelay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index f90ab33..290f45b 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -1725,6 +1725,22 @@ def test_flutter_under_grid_1_is_red_and_not_claimed(self): got = t4_verdict(*delay_trace(TapeDelay, wow=8.0, flutter=0.005)) self.assertFalse(got["two_lines"], got) + def test_wow_under_grid_1_is_red_and_not_claimed(self): + # Fix round 2: Wow above 0 and under grid 1 (0.063 c) is added to + # Not claimed, as Flutter's was. At 1.2e-4 c (Flutter 1 c, 48 kHz) + # the slow band still reads well over 40 dB above its own floor but + # carries under T4_FLOOR_MS of delay (8.21e-6 ms), so the absolute + # clause is what fails it: the exclusion is needed, and grid 1 + # (test_the_weakest_claimed_lines_clear_the_absolute_floor) is not + # in it. + got = t4_verdict(*delay_trace(TapeDelay, wow=1.2e-4, flutter=1.0)) + self.assertFalse(got["passed"], got) + self.assertFalse(got["drift_ok"], got) + self.assertGreaterEqual(got["drift"], 40.0, got) + self.assertLess(got["slow_ms"], T4_FLOOR_MS, got) + self.assertTrue(got["two_lines"], got) + self.assertTrue(got["ratio_ok"], got) + # -- T5: disconfirmed by design ------------------------------------------- From 515b9a68ec052db6d235790ede473c8f81f34680 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:15:40 -0500 Subject: [PATCH 34/88] AnalogDelay fix round 2: T3's inside bar on the node's carry across the coefficient change, from the move's own first difference; the landing gap read; EarlyStepWalk and LandingBlip planted; T7 off-grid Mix The inside bar's memory term is now c0 P (1 - a_new)^(k+1), c0 = (a_new/a_old)(1 - a_old), a_old read off what the constructor handed the node. EarlyStepWalk (3 frames late, one frame after the move) is red at 4 of 8 phases on 200 -> 600 ms at 48 kHz single-line, where the fix-round-1 bar passed all 8; LandingBlip (one frame 8 late, 10 past the landing) is red on the new gap clause, where no clause read it before. The jump is now inside-red at 16 of 16 boundaries on 20 -> 60 ms. The class file is untouched. --- tests/test_cpython_effects_analogdelay.py | 419 ++++++++++++++++++---- 1 file changed, 351 insertions(+), 68 deletions(-) diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index 44633c4..fadb767 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -2,10 +2,13 @@ The dossier is `workspace docs/effects-internal/dossiers/AnalogDelay.md` (frozen at anchor cc61011, the Station A critique revision, with the -post-build revision of 2026-09-28 in its section 8: T3's inside no-step bar -carries the loop low-pass's memory, the walk is read where the head lands, -the binade pieces have a resolvable minimum, and T7's arrival clause is -claimed across Mix's interior material by material). Four of its +post-build revisions of 2026-09-28 in its section 8: T3's inside no-step bar +carries the loop low-pass's carry across the move's coefficient change and +reads from the move's own first difference, the landing gap is read, the +walk is read where the head lands, the binade pieces have a resolvable +minimum, and T7's arrival clause is claimed across Mix's interior material +by material, at every grid position and at any Mix at or above +1.01 x 0.5 / W on the two loud materials). Four of its Tier 2 rows can be demonstrated, T2a, T3, T6 and T7, and each is here as the measurement at a few of the row's cells beside the same measurement shown red on the row's planted fault at the constructor defaults. Every @@ -210,6 +213,91 @@ def _refresh(self): step.step_at = step.pulled + int(walk // 2) +class EarlyStepWalk(AnalogDelay): + """T3's inside clause at the start of a walk (the round-2 re-refuter's + plant): on a Time move the node is handed `T_new - STEP` frames and + walks there at the clock's law, and `AT` frames after the move a node + in the path starts playing its source STEP frames late, so the read + still lands on `T_new`, with one step of STEP frames one frame into its + walk. Nothing the surface reaches does this: every Time move is one + constant-rate walk to the frame it names (section 6).""" + + NAME = 'AnalogDelay' + STEP = 3 + AT = 1 + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._step = _ReadStep(self._delay, self.STEP) + self._output = self._step + + def _refresh(self): + before, fresh = self._frames, self._fresh + AnalogDelay._refresh(self) + step = getattr(self, "_step", None) + if (step is not None and not fresh and self._frames != before + and self._slew > 0.0): + target = self._frames - self.STEP + self._delay.set(delay_ms=ad.hand_off_ms(target, + self._sample_rate)) + step.step_at = step.pulled + self.AT + + +class _Blip(kit_faults._Node): + """From frame `start`, for `length` frames, play the source `frames` + frames late; otherwise pass it through.""" + + def __init__(self, source, frames, length): + kit_faults._Node.__init__(self, source) + self.frames = int(frames) + self.length = int(length) + self.pulled = 0 + self.start = None + self._history = np.zeros(self.frames * self.channel_count) + + def _process(self, block): + channels = self.channel_count + count = len(block) // channels + joined = np.concatenate([self._history, block]) + late = joined[:len(block)] + self._history = joined[len(block):] + out = block.copy() + if self.start is not None: + a = max(0, self.start - self.pulled) + b = min(count, self.start + self.length - self.pulled) + if a < b: + out[a * channels:b * channels] = late[a * channels: + b * channels] + self.pulled += count + return out + + +class LandingBlip(AnalogDelay): + """T3's landing gap clause (the round-2 re-refuter's plant): for one + frame, 10 frames past where the walk lands, the output plays its source + 8 frames late, then goes back. Nothing the surface reaches does this: + the head lands once and stays (section 6).""" + + NAME = 'AnalogDelay' + STEP = 8 + LENGTH = 1 + AFTER = 10 + + def _build(self, *arguments, **options): + AnalogDelay._build(self, *arguments, **options) + self._blip = _Blip(self._delay, self.STEP, self.LENGTH) + self._output = self._blip + + def _refresh(self): + before, fresh = self._frames, self._fresh + AnalogDelay._refresh(self) + blip = getattr(self, "_blip", None) + if (blip is not None and not fresh and self._frames != before + and self._slew > 0.0): + walk = int(math.ceil(abs(self._frames - before) / self._slew)) + blip.start = blip.pulled + walk + self.AFTER + + class DryGainDelay(AnalogDelay): """T7: the output +0.1 dB (`kit_faults.HiddenGain`), a dry above unity, which nothing reaches: the node's dry is exactly 1 below Mix 1.""" @@ -493,9 +581,19 @@ def t3_render(cls, rate, character, t_old, t_new, values, channels=1, """Render `values` at Mix 2, Feedback 0, Time `t_old`, moving Time to `t_new` through `set_macro(0, ...)` on the block at `move_at` (a block boundary; MOVE_AT's by default). With a dict `handed`, the node's - options as handed by that move are copied into it.""" - effect = cls(array_src(values, channels, rate), sample_rate=rate, - character=character, time_ms=t_old, feedback=0.0, mix=2.0) + options as handed by that move are copied into it, and the + `damping_hz` the constructor handed goes in as `damping_before`.""" + if handed is None: + effect = cls(array_src(values, channels, rate), sample_rate=rate, + character=character, time_ms=t_old, feedback=0.0, + mix=2.0) + else: + with NodeSpy(): + effect = cls(array_src(values, channels, rate), sample_rate=rate, + character=character, time_ms=t_old, feedback=0.0, + mix=2.0) + before = getattr(getattr(effect, "_delay", None), "_handed", {}) + handed["damping_before"] = before.get("damping_hz", 0.0) target = midi_of_ms(t_new) if move_at is None: move_at = default_move_at(rate) @@ -670,46 +768,87 @@ def fit(a, b): return pieces -def memory_bars(bar, pre_bar, damping_hz, rate, frames): - """T3's inside no-step bar, frame by frame from the move (dossier - section 8, revision 2026-09-28): the shifted tone's own largest first - difference, plus what the loop low-pass can still carry of the pre-move - tone's slope `k` frames on, `pre_bar * (1 - a)^(k + 1)`, with `a` the - one-pole's coefficient `1 - exp(-2 pi damping_hz / fs)` for the - `damping_hz` the move handed the node. The one-pole's first difference - obeys d(k) = (1 - a) d(k - 1) + a x'(k), so this is its memory, not a - fitted allowance.""" +def one_pole_keep(damping_hz, rate): + """1 - a for the node's loop low-pass at `damping_hz`, a = 1 - + exp(-2 pi damping_hz / fs) (`one_pole_coefficient`); 0 with the + low-pass out, where the node passes the read straight through (a = 1).""" if damping_hz > 0.0: - keep = math.exp(-2.0 * math.pi * damping_hz / rate) - else: - keep = 0.0 + return math.exp(-2.0 * math.pi * damping_hz / rate) + return 0.0 + + +def carry_c0(damping_before, damping_hz, rate): + """c0 = (a_new / a_old)(1 - a_old): what the low-pass's first + difference keeps of the last pre-move difference across the one sample + at which its coefficient changes from `damping_before`'s to + `damping_hz`'s. The node's update is y += a (x - y) + (`audiodsp_feedback_delay.c:493-496`, the coefficient set at `:170`), + and x - y before the move is d(-1) (1 - a_old) / a_old, so + d(0) = a_new x'(0) + c0 d(-1). With `damping_before` None this is 1, + the fix-round-1 term, kept so its probes still run as written.""" + if damping_before is None: + return 1.0 + a_new = 1.0 - one_pole_keep(damping_hz, rate) + a_old = 1.0 - one_pole_keep(damping_before, rate) + return (a_new / a_old) * (1.0 - a_old) + + +def memory_bars(bar, pre_bar, damping_hz, rate, frames, damping_before=None, + first=1): + """T3's inside no-step bar, frame by frame (dossier section 8, + revisions R1 and R8): the shifted tone's own largest first difference, + plus what the loop low-pass can still carry of the pre-move tone's + slope, `c0 * pre_bar * (1 - a_new)^(k + first)`, `c0` from `carry_c0` + with `a_old` from the `damping_hz` the node held before the move and + `a_new` from the one the move handed it. Exponent 0 is the move's own + first difference, `diff[move - 1]` = d(0), and exponent k + 1 is + `diff[move + k]` = d(k + 1). After the move the first difference obeys + d(k) = (1 - a_new) d(k - 1) + a_new x'(k), so this is the node's own + carry of |d(-1)| <= pre_bar. With `damping_before` None, `c0` is 1: + the fix-round-1 term, which was 4.33 to 8.12 times the carry on rising + moves and 0.50 to 5.82 times on falling ones, kept so its probes still + run as written.""" + keep = one_pole_keep(damping_hz, rate) + c0 = carry_c0(damping_before, damping_hz, rate) if keep > 0.0 else 0.0 k = np.arange(frames, dtype=np.float64) - return bar + pre_bar * keep ** (k + 1.0) - - -def inside_clause(diff, move_at, walk, bar, pre_bar, damping_hz, rate): - """T3's inside no-step statistic, revised 2026-09-28: the largest - first difference over the walk as read, each frame against its - `memory_bars` bar, as (largest ratio, its frame from the move, the - settle, the largest first difference in the same window with no - memory term, which is the frozen statistic's numerator). The walk's - frames are the move's to the landing's; the last - difference, into the landed frame, is the head's last partial step, - where the pitch goes back to unity as the law says, and is the after - clause's. The window runs at least `settle` frames, the frames until - the low-pass's memory of the pre-move slope is under 5 % of the bar, - so a move with no walk (the jump) is still read.""" - keep = (math.exp(-2.0 * math.pi * damping_hz / rate) - if damping_hz > 0.0 else 0.0) + return bar + c0 * pre_bar * keep ** (k + first) + + +def inside_clause(diff, move_at, walk, bar, pre_bar, damping_hz, rate, + damping_before=None): + """T3's inside no-step statistic, as (largest ratio, its frame from the + move, the settle, the largest first difference in `diff[move : move + + span]` with no memory term, which is the frozen statistic's numerator). + + With `damping_before` (revision R8, fix round 2) the window is + `diff[move - 1 : move + span]`, from the move's own first difference to + the one before the landing's, each against `memory_bars` on the node's + carry (exponent 0 at `diff[move - 1]`, whose frame is reported as -1). + The difference into the landed frame is the landing gap clause's + (`t3_measure`). `span` is at least `settle`, the frames until the carry + term falls under 5 % of the bar, so a move with no walk (the jump) is + still read. With `damping_before` None it is fix round 1's clause as + it ran: the window from `diff[move]`, the term with no `c0`, and the + settle off that term.""" + keep = one_pole_keep(damping_hz, rate) + carried = pre_bar * (carry_c0(damping_before, damping_hz, rate) + if keep > 0.0 else 0.0) settle = 1 - if keep > 0.0 and pre_bar > 0.05 * bar: + if keep > 0.0 and carried > 0.05 * bar: settle = max(1, int(math.ceil( - math.log(0.05 * bar / pre_bar) / math.log(keep)))) + math.log(0.05 * bar / carried) / math.log(keep)))) span = min(max(walk - 1, settle, 1), len(diff) - move_at) - window = diff[move_at:move_at + span] - ratios = window / memory_bars(bar, pre_bar, damping_hz, rate, span) + frozen = float(np.max(diff[move_at:move_at + span])) + if damping_before is None: + window = diff[move_at:move_at + span] + ratios = window / memory_bars(bar, pre_bar, damping_hz, rate, span) + worst = int(np.argmax(ratios)) + return float(ratios[worst]), worst, settle, frozen + window = diff[move_at - 1:move_at + span] + ratios = window / memory_bars(bar, pre_bar, damping_hz, rate, span + 1, + damping_before, first=0) worst = int(np.argmax(ratios)) - return float(ratios[worst]), worst, settle, float(np.max(window)) + return float(ratios[worst]), worst - 1, settle, frozen def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, @@ -717,12 +856,17 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, """T3's clauses on one move: the pitch over the whole walk and on each binade piece within 10 cents of 1200 log2(T_old / T_new), the walk within 5 % of T_new (read where the head lands, off a steep ramp), the - residual 50-250 ms after it within 1 cent, and the two no-step - statistics within 5 % of their bars (the inside one against - `memory_bars`). `inside_from_move` over `inside_bar` is the frozen + residual 50-250 ms after it within 1 cent, and the three no-step + statistics within 5 % of their bars: inside the walk against + `memory_bars` on the node's carry; over the landing gap, + `diff[landing - 1 : landing + 64]`, against the larger of the inside + and after bars (revision R8: no clause read those frames before); and + after it, `diff[landing + 64 : landing + 4064]`, against the unshifted + tone's bar. `inside_from_move` over `inside_bar` is the frozen statistic (the largest first difference from the move frame against - the shifted tone's bar, no memory term) over the same window, kept - beside the revision.""" + the shifted tone's bar, no memory term) over the same window, and + `inside_r1` the fix-round-1 bar's reading (no `c0`), kept beside the + revision.""" k_old, k_new = whole(t_old, rate), whole(t_new, rate) law = cents(float(k_old) / k_new) if move_at is None: @@ -738,7 +882,7 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, tone = sine_values(997.0, frames, rate, level, phase) out, _ = t3_render(cls, rate, character, t_old, t_new, tone, channels, move_at=move_at, handed=handed) - diff = np.abs(np.diff(out)) + diff = np.abs(np.diff(out.astype(np.int64))) shifted = 997.0 * k_old / k_new ref = t3_static(cls, rate, character, t_new, @@ -752,21 +896,26 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, later_bar = float(np.max(np.abs(np.diff(ref[k_new + 2048:])))) damping = float(handed.get("damping_hz", 0.0)) + damping_before = float(handed.get("damping_before", 0.0)) inside_ratio, worst, settle, inside_from_move = inside_clause( - diff, move_at, walk, inside_bar, pre_bar, damping, rate) + diff, move_at, walk, inside_bar, pre_bar, damping, rate, + damping_before) inside_ok = inside_ratio <= 1.05 + inside_r1 = inside_clause(diff, move_at, walk, inside_bar, pre_bar, + damping, rate)[0] result = {"walk": walk, "k_new": k_new, "law": law, "slope": slope, "inside_ratio": inside_ratio, "inside_at": worst, "inside_ok": inside_ok, "inside_from_move": inside_from_move, "inside_bar": inside_bar, "pre_bar": pre_bar, - "damping_hz": damping, "settle": settle, + "damping_hz": damping, "damping_before": damping_before, + "settle": settle, "inside_r1": inside_r1, "later_bar": later_bar, "walk_ok": walk_ok} if walk < 64 or end + int(0.25 * rate) + 1 > frames: # No walk at all, or one that never lands on T_new inside the # render: the walk clause is red and the pitch has nothing to read. result.update(passed=False, whole=None, pieces=[], residual=None, - later=None) + later=None, gap=None, gap_ok=False) return result hz = inst_hz(out, rate) margin = min(400, walk // 10) @@ -777,9 +926,11 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, after = hz[end + int(0.05 * rate):end + int(0.25 * rate)] residual = cents(float(np.median(after)) / 997.0) later = float(np.max(diff[end + 64:end + 4064])) + gap = float(np.max(diff[end - 1:end + 64])) + gap_ok = gap <= 1.05 * max(inside_bar, later_bar) result.update(whole=whole_cents, pieces=pieces, residual=residual, - later=later) + later=later, gap=gap, gap_ok=gap_ok) result["passed"] = ( walk_ok and abs(whole_cents - law) <= 10.0 @@ -787,6 +938,7 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, and all(abs(p - law) <= 10.0 for p in pieces) and abs(residual) <= 1.0 and inside_ok + and gap_ok and later <= 1.05 * later_bar) return result @@ -825,6 +977,23 @@ def t7_measure(cls, rate=RATE, channels=2, name="ramp_fs", **options): "frames": frames_t, "swing": swing} +def wet_peak_in_arrival(rate, channels, name, character=SINGLE): + """W: the wet alone (Mix 2) at the defaults, its largest magnitude in + [T - 1, T + 64), as the node rounds it.""" + probe_effect = AnalogDelay(silence_src(64, channels, rate), + sample_rate=rate, character=character) + k = probe_effect._frames + probe_effect.deinit() + data = material(name, rate, channels) + effect = AnalogDelay(probes.ArraySource(data, rate=rate, + channels=channels, block=BLOCK), + sample_rate=rate, character=character, mix=2.0) + out = pull(effect, len(data) // channels, channels) + effect.deinit() + lo, hi = (k - 1) * channels, (k + 64) * channels + return float(np.max(np.abs(out[lo:hi].astype(np.float64)))) + + # -------------------------------------------------------------------------- # Tail @@ -996,6 +1165,21 @@ def move(frame): return bool(np.max(jumps) > math.ceil(slew) + 1.0) +def read_early(effect): + """On a copy at these positions, one Time move of 32 grid steps after + a block has been pulled: whether the `delay_ms` handed is not the hand + off of the whole frame the move names (EarlyStepWalk's signature).""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(4 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + now = other.get_macro(TIME_I) + other.set_macro(TIME_I, now + 32.0 if now < 64 else now - 32.0) + handed = float(other._delay._handed["delay_ms"]) + frames = other._frames + other.deinit() + return handed != ad.hand_off_ms(frames, rate) + + #: (name, fault, reading, constructor options for both builds). REACH_WALKS = ( ("CornerN6144", CornerN6144, read_corner_law, {}), @@ -1007,6 +1191,8 @@ def move(frame): ("ConstantGlideWalk", ConstantGlideWalk, read_walk_law, {}), ("DryGainDelay", DryGainDelay, read_dry_gain, {}), ("MidWalkReadStep", MidWalkReadStep, read_step, {}), + ("EarlyStepWalk", EarlyStepWalk, read_early, {}), + ("LandingBlip", LandingBlip, read_step, {}), ) @@ -1323,9 +1509,10 @@ def test_the_long_moves_are_read_per_binade(self): def test_the_jump_is_red(self): # The walk clause reds a jump at every boundary: there is no walk. - # The inside clause sees the step itself at 15 of these 16; at the - # other the jump lands on nearly the same sample value (0.964 of - # its bar), which no first-difference statistic can see. + # The inside clause sees the step itself at all 16 since revision + # R8 reads the move's own first difference, diff[move - 1] (fix + # round 1's window, from diff[move], saw 15: at the other the jump + # landed on nearly the same sample value, 0.964 of its bar). base = default_move_at(RATE) seen = 0 for j in range(16): @@ -1334,7 +1521,7 @@ def test_the_jump_is_red(self): self.assertFalse(result["passed"], (j, result)) self.assertLess(result["walk"], 64, j) seen += 0 if result["inside_ok"] else 1 - self.assertGreaterEqual(seen, 15) + self.assertEqual(seen, 16) def test_the_constant_glide_is_red(self): for rate in RATES: @@ -1347,16 +1534,22 @@ def test_the_constant_glide_is_red(self): class T3NoStepRevision(unittest.TestCase): - """Dossier section 8, revision 2026-09-28: the inside no-step bar - carries the loop low-pass's memory of the pre-move slope, derived from - the handed `damping_hz`; the walk is read where the head lands; the - binade pieces have a minimum the ramp resolves, and a walk with none - left is read whole.""" + """Dossier section 8, revisions R1-R2 and R8 (2026-09-28): the inside + no-step bar carries the loop low-pass's carry of the pre-move slope + across the coefficient change the move makes, from the `damping_hz` + the node held and the one it was handed; the landing gap, which no + clause read before, is read against the larger of the inside and after + bars; the walk is read where the head lands; the binade pieces have a + minimum the ramp resolves, and a walk with none left is read whole. + A first-difference statistic cannot see a read step of 1 or 2 frames + (a step of s frames moves one first difference by at most s - 1 times + the slope); the inside clause sees steps from about 3 frames.""" def test_the_one_pole_memory_is_not_a_step(self): # 20 -> 60 ms moved ten blocks later than the pack's boundary: the # frozen statistic reads 551 against a 522 bar (1.0556) at the - # walk's first frame; the revised bar there is 522 + 1562 (1 - a). + # walk's first frame; the revised bar there is 522 + c0 1562 (1 - a) + # with c0 = (a_new / a_old)(1 - a_old) = 0.162, 577. move_at = default_move_at(RATE) + 10 * BLOCK for character in (SINGLE, DOUBLE): result = t3_measure(AnalogDelay, RATE, character, 20.0, 60.0, @@ -1371,13 +1564,43 @@ def test_the_one_pole_memory_is_not_a_step(self): 1.05 * result["inside_bar"], character) def test_the_memory_is_the_handed_coefficient(self): - bars = memory_bars(522.0, 1562.0, 11606.7651, RATE, 3) keep = math.exp(-2.0 * math.pi * 11606.7651 / RATE) + keep_old = math.exp(-2.0 * math.pi * 13461.1 / RATE) + c0 = (1.0 - keep) / (1.0 - keep_old) * keep_old + self.assertAlmostEqual(c0, 0.162, delta=0.001) + bars = memory_bars(522.0, 1562.0, 11606.7651, RATE, 3, + damping_before=13461.1) + self.assertAlmostEqual(bars[0], 522.0 + c0 * 1562.0 * keep) + self.assertAlmostEqual(bars[2], 522.0 + c0 * 1562.0 * keep ** 3) + # The fix-round-1 term, kept for its probes: no c0. + bars = memory_bars(522.0, 1562.0, 11606.7651, RATE, 1) self.assertAlmostEqual(bars[0], 522.0 + 1562.0 * keep) - self.assertAlmostEqual(bars[2], 522.0 + 1562.0 * keep ** 3) - self.assertEqual(list(memory_bars(522.0, 1562.0, 0.0, RATE, 2)), + self.assertEqual(list(memory_bars(522.0, 1562.0, 0.0, RATE, 2, + damping_before=13461.1)), [522.0, 522.0]) + def test_the_carry_is_the_nodes_recursion(self): + # Across the move the node's first difference is a_new x'(0) + + # c0 d(-1): predicted off an open-loop render of the same move to + # a few LSB, where the constant-a form misses by hundreds. + rate, move_at = RATE, default_move_at(RATE) + frames = move_at + 4096 + tone = sine_values(997.0, frames, rate, 12000) + handed = {} + y, _ = t3_render(AnalogDelay, rate, SINGLE, 200.0, 600.0, tone, + handed=handed) + x, _ = t3_render(open_loop(AnalogDelay), rate, SINGLE, 200.0, 600.0, + tone) + y, x = y.astype(float), x.astype(float) + a_new = 1.0 - one_pole_keep(handed["damping_hz"], rate) + c0 = carry_c0(handed["damping_before"], handed["damping_hz"], rate) + m = move_at + d_prev = y[m - 1] - y[m - 2] + node = a_new * (x[m] - x[m - 1]) + c0 * d_prev + constant = a_new * (x[m] - x[m - 1]) + (1.0 - a_new) * d_prev + self.assertLess(abs(node - (y[m] - y[m - 1])), 3.0) + self.assertGreater(abs(constant - (y[m] - y[m - 1])), 100.0) + def test_a_mid_walk_read_step_is_red(self): result = t3_measure(MidWalkReadStep, RATE, SINGLE) self.assertFalse(result["inside_ok"], result) @@ -1385,6 +1608,40 @@ def test_a_mid_walk_read_step_is_red(self): result) self.assertFalse(result["passed"]) + def test_an_early_step_is_red(self): + # EarlyStepWalk, 3 frames late one frame after the move and landing + # on T_new, on 200 -> 600 ms at 48 kHz single-line over 8 tone + # phases: the fix-round-1 bar passes it at every phase, the node's + # carry reds it at half of them. + red = r1_red = 0 + for p in range(8): + result = t3_measure(EarlyStepWalk, RATE, SINGLE, 200.0, 600.0, + channels=2, phase=2.0 * math.pi * p / 8.0) + self.assertTrue(result["walk_ok"], (p, result)) + red += 0 if result["inside_ok"] else 1 + r1_red += 1 if result["inside_r1"] > 1.05 else 0 + self.assertEqual(r1_red, 0) + self.assertGreaterEqual(red, 4) + + def test_a_landing_blip_is_red(self): + # LandingBlip, one frame 8 late 10 frames past the landing: before + # R8 no clause read it and the row passed; the gap clause reds it + # and nothing else does. + for t_old, t_new in ((200.0, 100.4), (100.4, 200.0)): + result = t3_measure(LandingBlip, RATE, SINGLE, t_old, t_new) + self.assertFalse(result["gap_ok"], (t_old, t_new, result)) + self.assertFalse(result["passed"]) + self.assertTrue(result["inside_ok"] and result["walk_ok"] + and result["later"] <= 1.05 * result["later_bar"], + result) + + def test_the_landing_gap_holds_on_the_class(self): + for t_old, t_new in ((300.0, 100.0), (100.0, 300.0), (20.0, 60.0), + (60.0, 20.0)): + result = t3_measure(AnalogDelay, RATE, DOUBLE, t_old, t_new) + self.assertTrue(result["gap_ok"], (t_old, t_new, result)) + self.assertTrue(result["passed"], (t_old, t_new, result)) + def test_one_grid_step_from_the_stop_lands(self): # The frozen walk_end read this 9.6 / 10.5 / 21.0 % short. for rate in RATES: @@ -1534,10 +1791,12 @@ def test_the_patches_with_mix_up_to_one(self): self.assertTrue(result["passed"], (patch, result)) def test_the_mix_interior(self): - # Dossier section 8, revision 2026-09-28: arrival is claimed on - # ramp_fs and tones_step at every Mix above 0, and on sweep_log from - # grid 17 at 48 kHz, where the wet's ~2 LSB times Mix first clears - # the node's round to nearest. The null build reds at grid 1. + # Dossier section 8, revisions R4 and R9: arrival is claimed on + # ramp_fs and tones_step at every Mix grid position above 0 and at + # any Mix at or above 1.01 x 0.5 / W, W the wet alone's peak in the + # arrival window, and on sweep_log from grid 17 at 48 kHz, where + # the wet's ~2 LSB times Mix first clears the node's round to + # nearest. The null build reds at grid 1. for name, grids in (("ramp_fs", (1, 2, 8, 16, 17, 40, 63)), ("tones_step", (1, 2, 8, 16, 17, 40, 63)), ("sweep_log", (17, 24, 40, 63))): @@ -1548,6 +1807,30 @@ def test_the_mix_interior(self): result = t7_measure(AnalogDelay, RATE, 2, "sweep_log", mix=2.0 * 16 / 127.0) self.assertEqual(result["arrived"], 0) # the unclaimed edge + # Off the grid (a constructor Mix stays unquantised): at 1.01 x + # 0.5 / W on tones_step (W = 776 LSB, 6.44e-4) the repeat arrives; + # at Mix 1e-4, and at set_macro(2, 0.001) (Mix 1.57e-5), every + # sample stays on the source, which the row does not claim. The + # edge 0.5 / W itself is not claimed: W is the rounded peak. + wet = wet_peak_in_arrival(RATE, 2, "tones_step") + self.assertEqual(wet, 776.0) + result = t7_measure(AnalogDelay, RATE, 2, "tones_step", + mix=1.01 * 0.5 / wet) + self.assertTrue(result["passed"], result) + result = t7_measure(AnalogDelay, RATE, 2, "tones_step", mix=1e-4) + self.assertEqual((result["differing"], result["arrived"]), (0, 0)) + result = t7_measure(AnalogDelay, RATE, 2, "ramp_fs", mix=1e-5) + self.assertEqual((result["differing"], result["arrived"]), (0, 0)) + data = material("tones_step", RATE, 2) + effect = AnalogDelay(probes.ArraySource(data, rate=RATE, channels=2, + block=BLOCK), + sample_rate=RATE) + effect.set_macro(MIX_I, 0.001) + self.assertLess(effect.macro(MIX_I), 0.5 / wet) + out = pull(effect, 14400 + 64, 2) + effect.deinit() + src = np.array(data[:len(out)], dtype=np.int16) + self.assertEqual(int(np.count_nonzero(out != src)), 0) wire = kit_faults.wire_build(AnalogDelay) result = t7_measure(wire, RATE, 2, "ramp_fs", mix=2.0 / 127.0) self.assertFalse(result["passed"], result) From 6c6c02f26a19b8928c8309e6dac4e677246bb7e4 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:09:21 -0500 Subject: [PATCH 35/88] MultiTapDelay Station B: the rebuild and its tests Built from scratch against the frozen dossier (anchor 02e7e0c): the composition FeedbackDelay (the laps, Repeat Tone in the loop) into audiodelays.MultiTapDelay (the heads) behind a Splitter/Mixer dry path, nine macros, seven patches, Repeat Tone's out stop the lean graph, the lap node handed the guarded float32 delay. Mix 0 hands the source back through the port, because CircuitPython's stock Mixer at level 1.0 is not a wire above |32736|. Tests: T1-T5 at a few cells each with their planted faults red at the defaults, unreachable from the surface, and red on the wire build; Tier 1's wire, silence, latency, tail, reset and deinit. --- lib/audioeffects/rebuilt/multitapdelay.py | 746 +++++++++++++ tests/test_cpython_effects_multitapdelay.py | 1077 +++++++++++++++++++ 2 files changed, 1823 insertions(+) create mode 100644 lib/audioeffects/rebuilt/multitapdelay.py create mode 100644 tests/test_cpython_effects_multitapdelay.py diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py new file mode 100644 index 0000000..ecf2981 --- /dev/null +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -0,0 +1,746 @@ +"""`MultiTapDelay` - one recording read by several heads on a fixed grid. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/MultiTapDelay.md`, whose trait +table was frozen at Station A before this file existed (anchor commit +02e7e0c1467ff43b13ff239325eed54dd84b2dcb, the Station A revision's freeze, +2026-09-28). The old class in `delay.py` is consulted only for the six +defects that dossier's section 7 names; it stays the class the library +serves until the board runner adopts this one. + +**What it sounds like.** Your dry signal passes untouched, and a pattern of +echoes follows it: the heads of a multi-head tape echo, each one the same +recording read further along. The heads sit on a grid of whole multiples of +one base time, so head 2 is exactly twice head 1 and head 3 exactly three +times. Time (20-400 ms) is that base, the gap to head 1. Heads (3-8) is how +many heads the grid has, and the pattern repeats once per trip past the +farthest of them. Pattern picks which heads sound, from the twelve +positions of the Roland RE-202's mode selector. Feedback (0-0.95) sends +the pattern round again, each lap quieter; Repeat Tone darkens each lap a +little more than the one before, once per lap, never once per head. Tilt +leans the pattern's levels towards the near heads or the far ones. Mix +(0-2) is the echo level: dry at unity up to 1, the echoes alone at 2, and +Mix 0 is a wire. Sync locks Time to Division of the host's beat. + +**The standout:** the Roland RE-201 Space Echo's multi-head modes, with the +Binson Echorec as a second reference. You get the RE-201's three heads and +its one-knob grid as defaults; patch 3 is the Echorec's 74 ms head spacing +on four heads. + +**The grid, exactly.** Time is landed on the nearest whole frame, +n1 = floor(t1 fs / 1000 + 0.5), and head k sounds at exactly k n1 frames. +The lap is P = K n1 frames for Heads K, clamped so it fits `max_lap_ms`: +at the default 1600 ms the base stops at 400 / 400 / 320 / 266.67 / +228.56 / 200 ms for 3 ... 8 heads. That clamp is on the audio path only and +`get_macro(0)` keeps the knob's Time, because it moves with Heads and a +Heads move back must give you your Time back. The tap node is handed +(P + 0.5) frames' worth of `delay_ms` and head k at (k n1 + 0.5) / P (head +K at 1.0), and it truncates both, so a single-precision board lands on the +same frames as a desktop. The lap node interpolates instead, so it is +handed the least single-precision `delay_ms` whose frames, computed the +node's way (`value * rate / 1000.0f`), are at or over P: stepping one +float32 unit at a time from float32(P 1000 / fs), every intermediate +rounded to float32, which is exact on the desktop and the identity on a +board. Where no float32 value lands on P exactly (some laps at 44.1 and +22.05 kHz) the read sits a few thousandths of a frame over P and puts that +fraction of each lap one frame late, inside the lap's own response, never +early. + +**Pattern.** Modes 1-11 are S1's head sets, keeping only the heads that +exist on the grid: 1 = {1}, 2 = {2}, 3 = {3}, 4 = {1,2}, 5 = {2,3}, +6 = {1,3}, 7 = {1,2,3}, 8 = {1,4}, 9 = {3,4}, 10 = {1,3,4}, 11 = {1,2,4} +(at three heads, modes 8-11 read {1}, {3}, {1,3}, {1,2}). **Mode 12 departs +from S1**: the RE-202's mode 12 puts its heads at "optimized" positions +that Roland does not publish, and this class puts mode 12 on the plain +grid, every head 1 ... K, instead (patch 4 is eight of them). Heads above 4 +sound only in mode 12; in modes 1-11 they lengthen the lap without +sounding. + +**Where the feedback is tapped is a design decision, not a source.** The +lap is the farthest head on the grid, sounded or not, as if the record +head were fed from head K. So in a mode that does not sound head K the +repeats keep the lap's rhythm, not the heads': mode 1 at three heads +sounds t1, 4 t1, 7 t1 ..., where a machine fed back from the sounding head +would give t1, 2 t1, 3 t1. + +**Repeat Tone's out stop is a lighter graph.** With Repeat Tone in circuit +the laps go round an `audioecho.FeedbackDelay` whose in-loop low-pass is the +darkening (its corner pre-warped so it is the -3 dB point of one pass); at +the out stop the class unplugs that node and lets the tap node's own +`decay` make the laps, and nothing darkens. Crossing the out stop while +audio plays changes the graph between blocks: the wet does not move +against the dry, but the laps in flight are dropped or doubled once (up to +half the click's level at Feedback 0.5). + +**Mix 0 hands your source straight through**, the class's output port +pointed at the source itself, so it is byte for byte a wire on every +interpreter. It departs from the dossier's section 6 there, which kept +both lines recording at Mix 0 through the Mixer: CircuitPython's stock +`audiomixer` scales a voice at level 1.0 by 32768 / 32767, so on +CircuitPython that route put every sample at |value| >= 32736 one LSB out +(audiodsp's own Mixer, the one MicroPython and the boards run, passes +unity through). The price is that the lines are not fed while Mix is 0, +and turning Mix up from 0 starts the echoes from empty lines. The same +stock Mixer is on the dry path above Mix 0, so on CircuitPython alone the +dry's top 32 LSB of range read one LSB hot. + +**Moving Time or Heads clicks.** Both nodes jump to the new grid, and in +stereo the tap node's planar line also crosses channels for up to one lap +(the right channel briefly replays what the left one wrote). Nothing about +the grid is claimed while Time or Heads moves. + +**Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). +The laps are `audioecho.FeedbackDelay` and the dry fan-out is +`audioroute.Splitter`; `audiodelays.MultiTapDelay` and `audiomixer.Mixer` +are stock. On a stock CircuitPython board this module imports cleanly and +construction raises `ImportError`. + +**Latency: zero samples, at every setting, patch and rate.** The dry is a +Splitter tap into a Mixer voice, a wire, and nothing looks ahead. The heads +are the effect, not latency, and no option adds any. + +**Mono.** A one-channel source gets the same effect on its one channel. +There is no Spread: the tap node applies one set of heads to every +channel, so heads cannot be placed across the field. + +**RAM at 48 kHz: about 655 KB** at the default `max_lap_ms` 1600: two lines +of 307 392 B (the lap node's is `max_lap_ms` + 1 ms of two int16 lanes +whatever the channel count; the tap node's is the same length times the +channel count, 153 696 B mono), plus the Splitter's ring (about 34 KB), the +Mixer and the small buffers. At 44.1 kHz each line is 282 416 B, at +22.05 kHz 141 208 B. Pass a lower `max_lap_ms` to spend less (800 ms makes +each line 153 792 B, and the base then stops at 800 / K ms). It has a +floor of 540 ms: below that some Heads leave Time dead or its span under +3.33 : 1. A `max_lap_ms` above 1600 or NaN is 1600. + +**Cost, a planning estimate; the board measurement is pending hardware.** +Palette rows (MultiTapDelay, which ran four taps; FeedbackDelay +options; +Splitter, two taps; Mixer; MidSide; one Python pull of glue) put the full +graph at **P4 <= 66 %, S3 <= 87 %** of a 5.333 ms stereo block, which is +over the S3's 80 % line, and the lean graph (Repeat Tone out) at +**P4 <= 58 %, S3 <= 73 %**. **On an S3, stack patch 1, `Three Heads, +Even - lean`, not patch 0**, and only with light classes: the tap node +alone is most of an S3 block. Every Repeat Tone-in setting, the defaults +included, is over the S3's line by this estimate. Eight sounding heads +(patch 4) cost more than any palette row has priced, and so may the long +lines on a PSRAM board. + +**Input ceiling.** The heads sit at unity, and the tap node soft-limits at ++-28000 (on the heads' sum, on the line write and on its output), so the +echoes of a hot input are kneed while the dry is not: one head on a +30 000 LSB click reads 28 023. On the kit's `noise_det` at 48 kHz the +output reaches the int16 rail at no patch up to -6 dBFS peak; the wet +passes the knee at -12 dBFS only at patch 4 (eight heads) and patch 1, and +at patch 0 from -9 dBFS. + +**Tail.** `tail_samples` is an upper bound, in frames, on how long the +echoes take to reach exact zero once your input stops, recomputed on every +Time, Heads, Feedback or Repeat Tone move. With Repeat Tone in it is +`DigitalDelay`'s bound for the lap node plus one lap for the tap node's +line, and the lap node is handed a Feedback just outside the windows where +its loop low-pass could hold a small value for ever (at most 0.00003 from +the one you set; 0.95 plays as 0.950016). With Repeat Tone out the tap node +truncates every lap toward zero, so the bound is (laps + 1) x P. At patch 0 +it is 321 435 frames at 48 kHz (6.7 s). + +`capabilities = ("tempo_sync",)`: with Sync on, the class reads +`self._transport()` on every macro move and program change (not per +block), and Time's knob is rewritten to Division x the beat, clamped to its +span. With no host transport, or a tempo that is not a finite positive +number, Time stays where the knob is. +""" + +VENDOR = "PyDevices" + +from array import array +import math +import struct + +from .. import _component +from ..chorus import nominal_damping_hz + +# DigitalDelay's tail arithmetic, reused rather than copied: the lap node +# is the same node and rounds the same way. Its module moves up one level +# when it comes home, so both homes are tried. +try: + from .digitaldelay import clear_of_stalls, laps_to_zero, whole_frames +except ImportError: # pragma: no cover - after it lands + from ..digitaldelay import clear_of_stalls, laps_to_zero, whole_frames + +import audiocore + +try: + import audiodelays + import audiomixer +except ImportError: # pragma: no cover - a bare build + audiodelays = None + audiomixer = None + +try: + import audioecho + import audioroute +except ImportError: # pragma: no cover - a stock board + audioecho = None + audioroute = None + + +#: Section 6's spans, fixed on every instance. +TIME_MIN_MS = 20.0 +TIME_MAX_MS = 400.0 +HEADS_MIN = 3 +HEADS_MAX = 8 +FEEDBACK_MAX = 0.95 +TONE_MIN_HZ = 800.0 +TONE_MAX_HZ = 16000.0 + +#: Tilt's span in dB from head 1 to head K at |Tilt| = 1 (ours). +TILT_DB = 12.0 + +#: `max_lap_ms`: 400 ms x four heads by default, and never under 540 ms, +#: where every Heads keeps Time alive and its span at 3.33 : 1 or more. +MAX_LAP_MS = 1600.0 +MIN_LAP_MS = 540.0 + +#: The block every node renders, in frames. +BLOCK = 256 + +#: S1's `Head Combinations for Each Mode`, modes 1-11. Mode 12 is every +#: head on the plain grid (section 8.3), so it is not in the table. +HEAD_SETS = ((1,), (2,), (3,), (1, 2), (2, 3), (1, 3), (1, 2, 3), (1, 4), + (3, 4), (1, 3, 4), (1, 2, 4)) + +#: 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 (`DigitalDelay`'s). +DIVISION_BEATS = (0.125, 1.0 / 6.0, 0.1875, 0.25, 1.0 / 3.0, 0.375, 0.5, + 2.0 / 3.0, 0.75, 1.0, 4.0 / 3.0, 1.5, 2.0, 8.0 / 3.0, 3.0, + 4.0) + +#: The tap node truncates every sample it sends round toward zero; the +#: tail bound reckons with a decay this much larger, relatively, so a +#: board's single-precision arithmetic can only lengthen it. +DECAY_MARGIN = 2.0 ** -16 + +(TIME_I, PATTERN_I, HEADS_I, FEEDBACK_I, MIX_I, TILT_I, TONE_I, SYNC_I, + DIVISION_I) = range(9) + + +def f32(value): + """`value` rounded to single precision: exact emulation on a desktop, + the identity on a board, whose float already is one.""" + return struct.unpack(" lap: + lower = _f32_step(value, False) + if lap_node_frames(lower, sample_rate) < lap: + break + value = lower + frames = lap_node_frames(value, sample_rate) + return value + + +def head_set(mode, heads, table=HEAD_SETS): + """Pattern mode 1-12 on a grid of `heads` heads -> the heads that + sound, rising. Mode 12 is every head 1 ... K.""" + if mode >= 12: + return tuple(range(1, heads + 1)) + return tuple(k for k in table[mode - 1] if k <= heads) + + +def head_levels(selected, heads, tilt): + """Tilt's law: a straight line in dB across the grid, `TILT_DB` x Tilt + from head 1 to head K, far heads louder for Tilt > 0, with the loudest + sounding head at exactly 1.0.""" + db = [TILT_DB * tilt * (k - 1) / (heads - 1) for k in selected] + top = max(db) + return tuple(1.0 if value == top else 10.0 ** ((value - top) / 20.0) + for value in db) + + +def landed(time_ms, heads, sample_rate, max_lap_ms): + """(n1, P) in frames: the base on the nearest whole frame, clamped so + the lap P = K n1 fits `max_lap_ms`.""" + n1 = max(1, whole_frames(time_ms, sample_rate)) + cap = int(math.floor(max_lap_ms * sample_rate / 1000.0)) // heads + if n1 > cap: + n1 = cap + return n1, heads * n1 + + +def trunc_laps(decay): + """Laps of the tap node's own loop to exact zero from full scale. Once + the input stops it writes `(int32_t)(delayed * decay)` + (`audiodsp_multitap.c:32`), which truncates toward zero, so a peak x + goes to floor(x d) < x and the count is finite at every decay.""" + decay = min(max(float(decay), 0.0), 1.0) * (1.0 + DECAY_MARGIN) + laps = 0 + peak = 32768 + while peak > 0: + laps += 1 + image = int(math.floor(decay * peak)) + if image >= peak: + image = peak - 1 + peak = image + return laps + + +def tone_excess(damping_hz, sample_rate): + """`DigitalDelay._tone_excess` for a pre-warped `damping_hz`: (memory + frames, relative excess) of the lap node's loop low-pass, (0, 0.0) + with it out.""" + if damping_hz <= 0.0: + return 0, 0.0 + per_frame = 2.0 * math.pi * damping_hz / sample_rate + coefficient = 1.0 - math.exp(-per_frame) + frames = int(math.ceil(32.0 * math.log(2.0) / per_frame)) + return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient + + +def _number(value, default): + """`value` as a float, or `default` for NaN or something that is not a + number.""" + try: + value = float(value) + except (TypeError, ValueError): + return float(default) + if value != value: + return float(default) + return value + + +class MultiTapDelay(_component.Component): + """A multi-head echo: several heads on a grid of whole multiples of one + base time, S1's twelve head sets, laps that darken once per lap. + audiodsp tier; zero latency. + + **What the default surrenders:** mode 12 is the plain grid, not the + RE-202's unpublished "optimized" heads; the feedback is taken from the + farthest head on the grid by decision; Time and Heads click when they + move; and on an ESP32-S3 every Repeat Tone-in setting, the defaults + included, is over the 80 % cost line by the palette estimate, so the + S3 build is patch 1 (Repeat Tone out). + """ + + NAME = 'MultiTapDelay' + DISPLAY_NAME = 'Multi-Tap Delay' + CATEGORIES = ('Delay',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioecho", "audioroute") + + CAPABILITIES = ("tempo_sync",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Time", "Pattern", "Heads", "Feedback", "Mix", "Tilt", + "Repeat Tone", "Sync", "Division") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "BIPOLAR", + 6: "UNIPOLAR", + 7: "TOGGLE", + 8: "UNIPOLAR", + } + _MACRO_RANGES = ( + (TIME_MIN_MS, TIME_MAX_MS, "log"), # 0 Time, the base head t1, ms + (0.0, 11.0), # 1 Pattern index -> mode 1-12 + (float(HEADS_MIN), float(HEADS_MAX)), # 2 Heads K + (0.0, FEEDBACK_MAX), # 3 Feedback, the lap level + (0.0, 2.0), # 4 Mix; dry at unity to 1 + (-1.0, 1.0), # 5 Tilt + (TONE_MIN_HZ, TONE_MAX_HZ, "log"), # 6 Repeat Tone, Hz; top = out + (0.0, 1.0), # 7 Sync + (0.0, 15.0), # 8 Division index + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 + #: is the constructor's defaults on the grid. + PATCHES = { + 0: ("Three Heads, Even", (85, 69, 0, 60, 22, 64, 68, 0, 25)), + 1: ("Three Heads, Even - lean", (85, 69, 0, 60, 22, 64, 127, 0, 25)), + 2: ("Two Heads, Near Loudest", (93, 35, 25, 67, 22, 32, 68, 0, 25)), + 3: ("Four Heads, Far Loudest", (55, 127, 25, 67, 22, 96, 78, 0, 25)), + 4: ("Eight Heads, Dense", (29, 127, 127, 74, 19, 64, 56, 0, 25)), + 5: ("One Head, Long Repeats", (76, 23, 0, 94, 22, 64, 48, 0, 25)), + 6: ("Triplet Grid, Synced", (85, 69, 0, 60, 22, 64, 68, 127, 34)), + } + + #: The head-set table a Pattern position reads (S1's, modes 1-11). + _SETS = HEAD_SETS + + def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, + mix=0.35, tilt=0.0, tone_hz=4000.0, sync=False, division=3, + max_lap_ms=MAX_LAP_MS, patch=None): + rate = self._sample_rate + channels = self._channel_count + max_lap_ms = _number(max_lap_ms, MAX_LAP_MS) + if max_lap_ms > MAX_LAP_MS: + max_lap_ms = MAX_LAP_MS + if max_lap_ms < MIN_LAP_MS: + max_lap_ms = MIN_LAP_MS + self._max_lap_ms = max_lap_ms + + # Constructor values in the macros' own units. A value outside a + # span clamps to the nearer stop (`macro_position` does that), NaN + # takes the default, and a Time of 0 or less is the bottom of its + # span, which a log knob cannot seed. Repeat Tone at 0 or less is + # its out stop, as it is on the node. + time_ms = _number(time_ms, 150.0) + if not time_ms > 0.0: + time_ms = TIME_MIN_MS + tone_hz = _number(tone_hz, 4000.0) + if not tone_hz > 0.0: + tone_hz = TONE_MAX_HZ + pattern = int(math.floor(_number(pattern, 7) + 0.5)) + pattern = min(12, max(1, pattern)) + heads = int(math.floor(_number(heads, HEADS_MIN) + 0.5)) + heads = min(HEADS_MAX, max(HEADS_MIN, heads)) + + #: What the nodes were last handed, so a move writes only what it + #: changes (a `delay_ms` write re-bases the tap node's line). + self._tap_ms = None + self._taps = None + self._n1 = 1 + self._lap = HEADS_MIN + self._heads = heads + self._selected = () + self._levels = () + self._lap_ms = 0.0 + self._feedback = 0.0 + self._decay = 0.0 + self._damping = 0.0 + self._lean = False + #: Which node the tap node is playing: None until the graph is + #: wired, then True (the Splitter's tap, the lean graph) or False + #: (the lap node). + self._plugged = None + #: True once the Mixer's voices and the tap node have been played, + #: which happens the first time Mix is above 0. + self._wired = False + #: True while Mix 0 has the output port on the borrowed source. + self._at_source = False + self._deferred = False + + # The input adapter re-blocks whatever the app hands the class into + # the palette's own blocks, which `audioroute.Splitter` needs: a + # bare `audiocore.RawSample` hands back its whole array at once. + adapter = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + adapter.play(self._source) + split = audioroute.Splitter(adapter, taps=2) + dry = split.tap(0) + tap1 = split.tap(1) + self._silence = audiocore.RawSample( + array("h", bytes(2 * 2 * channels)), + sample_rate=rate, channel_count=channels) + + # The laps: the lap node recirculates at P with the darkening in + # its loop. Its `mix` is the Feedback, so its output is the dry plus + # Feedback x the loop: lap 1 at unity, lap n at Feedback^(n - 1). + self._fd = audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, + max_delay_ms=max_lap_ms + 1.0, + delay_ms=max_lap_ms * 0.5, feedback=0.0, mix=0.0, + damping_hz=0.0, cut_hz=0.0, delay_slew=0.0) + # The heads: wet only (`mix` 1.0 in its 0..1 convention, doubled to + # 2 in the node), 256 frames a pull. + self._tapnode = audiodelays.MultiTapDelay( + max_delay_ms=int(math.ceil(max_lap_ms)) + 1, + delay_ms=max_lap_ms * 0.5, decay=0.0, mix=1.0, + taps=((1.0, 1.0),), buffer_size=BLOCK * channels * 2, + sample_rate=rate, bits_per_sample=16, samples_signed=True, + channel_count=channels) + self._mixer = audiomixer.Mixer( + voice_count=2, buffer_size=BLOCK * channels * 4, + channel_count=channels, bits_per_sample=16, samples_signed=True, + sample_rate=rate) + + self._adapter = adapter + self._split = split + self._dry = dry + self._tap1 = tap1 + + # Tail first in `reset()` and `deinit()`. The Mixer and the + # Splitter's side are not reset: a Mixer voice resets its source + # recursively, which would reach the borrowed source. The tap + # node's reset empties its line through `audiocore.reset_buffer`; + # the lap node's `clear` empties its line and loop filters. + self._own(self._mixer, reset=False) + self._own(self._tapnode) + self._own(self._fd, reset=self._fd.clear) + self._own(dry, reset=False) + self._own(tap1, reset=False) + self._own(split, reset=False) + self._own(adapter) + self._own(self._silence, reset=False) + self._output = self._mixer + + self._ready = False + self._init_macros((time_ms, float(pattern - 1), float(heads), + feedback, mix, tilt, tone_hz, + 1.0 if sync else 0.0, _number(division, 3)), + patch) + + self._ready = True + self._route() + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _pattern_mode(self): + """Pattern's position -> mode 1-12: index floor(11 p + 0.5).""" + index = int(math.floor(11.0 * self._macros[PATTERN_I] + 0.5)) + return min(12, max(1, index + 1)) + + def _heads_count(self): + """Heads' position -> K = floor(3 + 5 p + 0.5), 3-8.""" + count = int(math.floor(HEADS_MIN + (HEADS_MAX - HEADS_MIN) + * self._macros[HEADS_I] + 0.5)) + return min(HEADS_MAX, max(HEADS_MIN, count)) + + def _head_set(self, mode, heads=None): + return head_set(mode, self._heads_count() if heads is None + else heads, type(self)._SETS) + + def _tone_damping(self): + """Repeat Tone -> the lap node's `damping_hz`, pre-warped so one + pass is -3 dB at the knob's corner after the Nyquist clamp. The + top stop is exactly 0: out of circuit, the lean graph.""" + if self._macros[TONE_I] >= 1.0: + return 0.0 + return nominal_damping_hz(self._hz(self._value(TONE_I)), + self._sample_rate) + + def _synced_ms(self): + """Division x the host's beat, or `None` with no host transport + (the static one) or a tempo that is not a finite positive number, + where Time stays on the knob.""" + transport = self._transport + if transport is _component.static_transport: + return None + state = transport() if callable(transport) else transport + try: + bpm = float(state[2] or 0.0) + except (TypeError, ValueError, IndexError): + return None + if not bpm > 0.0 or bpm * 0.0 != 0.0: + return None + index = int(round(self._value(DIVISION_I))) + index = min(len(DIVISION_BEATS) - 1, max(0, index)) + return DIVISION_BEATS[index] * 60000.0 / bpm + + def _tap_node_ms(self, lap): + """The tap node's `delay_ms` for a lap of `lap` frames: half a + frame over, so its truncation lands on `lap` in either float + width.""" + return (lap + 0.5) * 1000.0 / self._sample_rate + + def _tap_positions(self, selected, n1, lap): + """Head k at (k n1 + 0.5) / P, so the node's truncation of the + offset lands on k n1; the farthest head on the grid at 1.0.""" + heads = lap // n1 + return tuple(1.0 if k == heads else (k * n1 + 0.5) / lap + for k in selected) + + def _lap_node_ms(self, lap): + return lap_node_ms(lap, self._sample_rate) + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del index, position + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh once, so Time is never + read against the outgoing patch's Sync and the graph re-plugs at + most once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def _refresh(self): + rate = self._sample_rate + if self._macros[SYNC_I] >= 0.5: + synced = self._synced_ms() + if synced is not None: + synced = min(TIME_MAX_MS, max(TIME_MIN_MS, synced)) + self._macros[TIME_I] = _component.macro_position( + self._MACRO_RANGES[TIME_I], synced) + heads = self._heads_count() + n1, lap = landed(self._value(TIME_I), heads, rate, self._max_lap_ms) + selected = self._head_set(self._pattern_mode(), heads) + levels = head_levels(selected, heads, self._value(TILT_I)) + taps = tuple(zip(self._tap_positions(selected, n1, lap), levels)) + + feedback = self._value(FEEDBACK_I) + feedback = min(FEEDBACK_MAX, max(0.0, feedback)) + lean = self._macros[TONE_I] >= 1.0 + damping = self._tone_damping() + if lean: + loop = feedback + decay = feedback + else: + # With Repeat Tone in, the lap node can hold a small value for + # ever at Feedback values a hair either side of 1 - 0.5 / k; it + # is handed the nearer edge of that window instead. + loop = clear_of_stalls(feedback, + tone_excess(damping, rate)[1]) + decay = 0.0 + + tap_ms = self._tap_node_ms(lap) + lap_ms = self._lap_node_ms(lap) + if tap_ms != self._tap_ms: + self._tapnode.delay_ms = tap_ms + self._tap_ms = tap_ms + if taps != self._taps: + self._tapnode.taps = taps + self._taps = taps + self._tapnode.decay = decay + self._fd.set(delay_ms=lap_ms, feedback=loop, mix=loop, + damping_hz=damping, cut_hz=0.0, delay_slew=0.0) + mix = self._value(MIX_I) + self._mixer.voice[0].level = min(1.0, 2.0 - mix) + self._mixer.voice[1].level = min(1.0, mix) + + self._n1 = n1 + self._lap = lap + self._heads = heads + self._selected = selected + self._levels = levels + self._lap_ms = lap_ms + self._feedback = loop + self._decay = decay + self._damping = damping + self._lean = lean + self._route() + + def _route(self): + """Where the output port points, and which node the tap node plays. + + Mix 0 hands the borrowed source straight back through the port, so + it is a wire on every interpreter: CircuitPython's stock + `audiomixer` scales a voice at level 1.0 by 32768 / 32767, which + puts every sample at |value| >= 32736 one LSB out (audiodsp's own + Mixer passes unity through). Nothing is pulled through the graph + while the port is on the source, so nothing here may prime a node + then: a `play()` would take a block of the source away from the + port. + + Above Mix 0 the graph is wired the first time (a `play()` on the + tap node or a Mixer voice primes one block from its source, so each + is called exactly once and the dry and the wet start on the same + block; `FeedbackDelay.play` pulls nothing), and afterwards the tap + node is re-plugged if Repeat Tone crossed its out stop. Coming back + from Mix 0 empties both lines, which were not fed while it was 0. + """ + if not self._ready: + return + if self._macros[MIX_I] <= 0.0: + self._output = self._source + self._at_source = True + return + if not self._wired: + self._wire() + self._wired = True + else: + if self._at_source: + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + if self._lean != self._plugged: + self._plug(self._lean) + self._at_source = False + self._output = self._mixer + + def _wire(self): + """Play every node once, the first time Mix is above 0.""" + self._fd.play(self._tap1) + self._plug(self._lean) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry, loop=False) + self._mixer.voice[1].play(self._tapnode, loop=False) + + def _plug(self, lean): + """Point the tap node at the Splitter's tap (the lean graph, the + tap node's own `decay` making the laps) or at the lap node. The + tap node's `play` resets the new source and primes one block from + it: the lap node's reset empties its line, the Splitter tap's is a + deliberate no-op, so the dry is never disturbed.""" + if lean: + self._tapnode.play(self._tap1, loop=False) + else: + self._tapnode.play(self._fd, loop=False) + self._plugged = lean + + # -- the contract's reads ------------------------------------------ + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, + as an upper bound. + + Repeat Tone in: `laps_to_zero(f', excess) x (P + 1 + memory) + P`, + `DigitalDelay`'s bound for the lap node at the Feedback it is + handed, plus one lap for the tap node's line to be read out. + Repeat Tone out: `(trunc_laps(f) + 1) x P`, the tap node's own + truncating loop. Finite at every setting. + """ + 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`.""" + lap = self._lap + if self._macros[MIX_I] <= 0.0: + return 0 + if self._lean: + return int((trunc_laps(self._decay) + 1) * lap) + memory, excess = tone_excess(self._damping, self._sample_rate) + laps = laps_to_zero(self._feedback, excess) + if laps is None: # pragma: no cover - cleared above + return None + return int(laps * (lap + 1 + memory) + lap) diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py new file mode 100644 index 0000000..90b6235 --- /dev/null +++ b/tests/test_cpython_effects_multitapdelay.py @@ -0,0 +1,1077 @@ +"""`MultiTapDelay`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/MultiTapDelay.md` +(frozen at anchor 02e7e0c, the Station A revision); its Tier 2 rows are +T1-T5. Each row here is the measurement at a few of the row's cells and the +same measurement shown red on a planted fault of the same kind, at the +constructor defaults. Every fault is shown unreachable from every macro +position and shipped patch, and every row's measurement is shown red on the +class built as a wire. The full spans, the three interpreters and the rates +live in the evidence pack, not in this file. + +Every law a measurement checks against is written out here from the +dossier, never taken from the class: the whole-frame landing, the lap +clamp, S1's head sets, Tilt's line and the lap node's float32 hand-off. + +The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by +`rebuilt.module_class("MultiTapDelay")`. + +Two plants are built for real here that Station A emulated: T4's per-head +6 kHz low-pass (a second tap node for head 2 behind an +`audiofilters.Filter`) and T5 clause 2's compose-first build (a front +Filter into the tap node's own decay). T5 clause 1's plant, head 2 +darkening on its own each lap, is still the dossier's emulation on the +rendered windows, said where it is. +""" + +import math +import os +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import audiodelays # noqa: E402 +import audiofilters # noqa: E402 +import audiomixer # noqa: E402 +import audioroute # noqa: E402 +import synthio # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +import audioeffects # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 + +VENDOR = "PyDevices" + +RATE = 48000 +RATES = (48000, 44100, 22050) +BLOCK = 256 +(TIME_I, PATTERN_I, HEADS_I, FEEDBACK_I, MIX_I, TILT_I, TONE_I, SYNC_I, + DIVISION_I) = range(9) + +MultiTapDelay = rebuilt.module_class("MultiTapDelay") + + +# -------------------------------------------------------------------------- +# The dossier's laws, written out independently of the class + +#: S1's table, modes 1-11 (section 6 row 1); mode 12 is every head 1 ... K. +S1 = ((1,), (2,), (3,), (1, 2), (2, 3), (1, 3), (1, 2, 3), (1, 4), (3, 4), + (1, 3, 4), (1, 2, 4)) + + +def law_heads(mode, heads): + if mode == 12: + return tuple(range(1, heads + 1)) + return tuple(k for k in S1[mode - 1] if k <= heads) + + +def law_frames(time_ms, rate): + """Section 6 row 0: the nearest whole frame.""" + return int(math.floor(time_ms * rate / 1000.0 + 0.5)) + + +def law_landed(time_ms, heads, rate, max_lap_ms=1600.0): + """(n1, P): the base landed, clamped so K n1 fits max_lap_ms.""" + n1 = max(1, law_frames(time_ms, rate)) + n1 = min(n1, int(math.floor(max_lap_ms * rate / 1000.0)) // heads) + return n1, heads * n1 + + +def law_time_ms(midi): + """Section 6 row 0: 20-400 ms, log, on the 0-127 grid.""" + return 20.0 * 20.0 ** (midi / 127.0) + + +def law_pattern_midi(mode): + """The MIDI position whose index floor(11 p + 0.5) is mode - 1.""" + return int(round((mode - 1) * 127.0 / 11.0)) + + +def law_heads_midi(heads): + return int(round((heads - 3) * 127.0 / 5.0)) + + +def law_tilt_levels(selected, heads, tilt): + """Section 6 row 5: 12 dB x Tilt from head 1 to head K, loudest + sounding head at 1.0.""" + db = [12.0 * tilt * (k - 1) / (heads - 1) for k in selected] + top = max(db) + return [10.0 ** ((d - top) / 20.0) for d in db] + + +def f32(x): + return array("f", (float(x),))[0] + + +def law_node_frames(value, rate): + """The lap node's frames, `value * rate / 1000.0f`, in float32.""" + return f32(f32(f32(value) * f32(rate)) / f32(1000.0)) + + +def law_lap_node_ms(lap, rate): + """Section 4, the lap node's hand-off: the least float32 value, by + one-unit steps from float32(P 1000 / fs), whose frames are >= P.""" + def step(v, up): + raw = array("I", array("f", (v,)).tobytes()) + raw[0] += 1 if up else -1 + return array("f", raw.tobytes())[0] + v = f32(lap * 1000.0 / rate) + while law_node_frames(v, rate) < lap: + v = step(v, True) + while law_node_frames(v, rate) > lap: + lower = step(v, False) + if law_node_frames(lower, rate) < lap: + break + v = lower + return v + + +#: Section 6's patch table in engineering units, which `macro_of` puts on +#: the grid: (Time ms, mode, Heads, Feedback, Mix, Tilt, Tone Hz, Sync, +#: Division index). Patch 1's Tone is its out stop, the span's top. +DOSSIER_PATCHES = ( + ("Three Heads, Even", (150.0, 7, 3, 0.45, 0.35, 0.0, 4000.0, 0, 3)), + ("Three Heads, Even - lean", + (150.0, 7, 3, 0.45, 0.35, 0.0, 16000.0, 0, 3)), + ("Two Heads, Near Loudest", (180.0, 4, 4, 0.5, 0.35, -0.5, 4000.0, 0, 3)), + ("Four Heads, Far Loudest", (74.0, 12, 4, 0.5, 0.35, 0.5, 5000.0, 0, 3)), + ("Eight Heads, Dense", (40.0, 12, 8, 0.55, 0.3, 0.0, 3000.0, 0, 3)), + ("One Head, Long Repeats", (120.0, 3, 3, 0.7, 0.35, 0.0, 2500.0, 0, 3)), + ("Triplet Grid, Synced", (150.0, 7, 3, 0.45, 0.35, 0.0, 4000.0, 1, 4)), +) +SPANS = ((20.0, 400.0, "log"), (0.0, 11.0), (3.0, 8.0), (0.0, 0.95), + (0.0, 2.0), (-1.0, 1.0), (800.0, 16000.0, "log"), (0.0, 1.0), + (0.0, 15.0)) +MODES = ("UNIPOLAR", "UNIPOLAR", "UNIPOLAR", "UNIPOLAR", "UNIPOLAR", + "BIPOLAR", "UNIPOLAR", "TOGGLE", "UNIPOLAR") + + +# -------------------------------------------------------------------------- +# Material and rendering + + +class Endless: + """int16 frames, BLOCK per pull, then silence for ever: a finite probe + would stop the lap node's line, which only advances on frames that + arrive.""" + + def __init__(self, data, rate=RATE, channels=2): + self.sample_rate = int(rate) + self.channel_count = int(channels) + self.bits_per_sample = 16 + self.samples_signed = True + self._pcm = bytes(data) + self._stride = BLOCK * channels * 2 + self._position = 0 + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + self._position = 0 + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + chunk = self._pcm[self._position:self._position + self._stride] + self._position += self._stride + if len(chunk) < self._stride: + chunk += bytes(self._stride - len(chunk)) + return 1, memoryview(chunk) + + +def click(level=20000, channels=2, at=0): + data = array("h", [0] * (BLOCK * 4 * channels)) + for ch in range(channels): + data[at * channels + ch] = level + return data + + +def silence(frames=BLOCK, channels=2): + return array("h", [0] * (frames * channels)) + + +def pull(effect, frames): + channels = effect.channel_count + want = frames * channels * 2 + pcm = bytearray() + while len(pcm) < want: + chunk = bytes(audiocore.get_buffer(effect.output)[1]) + if not chunk: + pcm += bytes(want - len(pcm)) + break + pcm += chunk + return np.frombuffer(bytes(pcm[:want]), dtype=" 0, "late": late, + "arrivals": len(arrivals)} + + +def spectra(out, n1, lap, heads, laps=4, size=4096): + win = np.hanning(size) + res = {} + for n in range(1, laps + 1): + for k in heads: + a = (n - 1) * lap + k * n1 + seg = out[a - size // 2:a + size // 2, 0].astype(float) + peak = float(np.abs(seg).max()) + mag = np.abs(np.fft.rfft(seg * win)) + res[(n, k)] = (20.0 * np.log10(np.maximum(mag, 1e-9)), peak, seg) + return res + + +def t45_reading(cls, rate=RATE, feedback=0.45, tone=4000.0, heads=3, + mode=7, midi=None, emulate=None): + """T4 and T5 at one cell: click 24 000, Mix 2, Tilt 0, t1 200 ms, + 4096-point Hann windows at every arrival of laps 1-4. Returns the + worst pairwise T4 spread, the worst clause-1 increment spread, the + corner and 8/5 kHz D2/D4 of head 1, and whether every head sounded + (every window non-zero), with the lap-1 peaks.""" + midi = dict(midi or {}) + midi.setdefault(MIX_I, 127) + size = 4096 + effect = build(cls, data=click(24000), rate=rate, midi=midi, + time_ms=200.0, heads=heads, pattern=mode, + feedback=feedback, tone_hz=tone) + selected = law_heads(mode, heads) + n1, lap = law_landed(200.0, heads, rate) + out = pull(effect, 4 * lap + size) + effect.deinit() + res = spectra(out, n1, lap, selected, size=size) + if emulate is not None: + res = emulate(res, rate) + freqs = np.fft.rfftfreq(size, 1.0 / rate) + band = (freqs >= 100.0) & (freqs <= 10000.0) + i100 = int(np.argmin(np.abs(freqs - 100.0))) + present = all(res[(n, k)][1] > 0 for n in range(1, 5) + for k in selected) + t4 = 0.0 + t5 = 0.0 + for n in range(1, 5): + norm = np.array([res[(n, k)][0] - 20.0 * math.log10( + max(res[(n, k)][1], 1.0)) for k in selected]) + if len(selected) > 1: + t4 = max(t4, float(np.max(np.ptp(norm[:, band], axis=0)))) + if n > 1 and len(selected) > 1: + inc = np.array([res[(n, k)][0] - res[(1, k)][0] + for k in selected]) + t5 = max(t5, float(np.max(np.ptp(inc[:, band], axis=0)))) + h = selected[0] + + def dark(n, f): + iq = int(np.argmin(np.abs(freqs - f))) + inc = res[(n, h)][0] - res[(1, h)][0] + return -float(inc[iq] - inc[i100]) + corner = min(tone, rate * 0.5 * _component.NYQUIST_MARGIN) + top = 8000.0 if rate > 30000 else 5000.0 + return {"t4": t4, "t5": t5, "present": present, + "lap1": [res[(1, k)][1] for k in selected], + "c2": dark(2, corner), "c4": dark(4, corner), + "q2": dark(2, top), "q4": dark(4, top)} + + +def one_pole(seg, hz, rate, passes=1): + """A one-pole low-pass over a rendered window: the dossier's emulation + of a per-head filter (T4's B4, T5 clause 1's B5).""" + a = 1.0 - math.exp(-2.0 * math.pi * hz / rate) + y = np.asarray(seg, dtype=float) + for _ in range(passes): + z = np.empty_like(y) + s = 0.0 + for i, v in enumerate(y): + s += a * (v - s) + z[i] = s + y = z + return y + + +def darken_head2_per_lap(res, rate, size=4096): + """T5 clause 1's plant, emulated as the dossier states it: head 2's + window through n - 1 extra passes of a 6 kHz one-pole at lap n.""" + win = np.hanning(size) + out = dict(res) + for (n, k), (_db, _peak, seg) in res.items(): + if k != 2 or n == 1: + continue + y = one_pole(seg, 6000.0, rate, passes=n - 1) + mag = np.abs(np.fft.rfft(y * win)) + out[(n, k)] = (20.0 * np.log10(np.maximum(mag, 1e-9)), + float(np.abs(y).max()), y) + return out + + +# -------------------------------------------------------------------------- +# Planted faults + + +class LateHeads(MultiTapDelay): + """T1 (1): every head below K handed one frame late, + (k n1 + 1.5) / P.""" + + NAME = 'MultiTapDelay' + + def _tap_positions(self, selected, n1, lap): + heads = lap // n1 + return tuple(1.0 if k == heads else (k * n1 + 1.5) / lap + for k in selected) + + +class LongLap(MultiTapDelay): + """T1 (2), the lap clause's own: the lap node handed P + 1 frames.""" + + NAME = 'MultiTapDelay' + + def _lap_node_ms(self, lap): + return law_lap_node_ms(lap + 1, self._sample_rate) + + +class ShortTapLap(MultiTapDelay): + """T2 (a): the tap node's lap one frame short, heads still at + (k n1 + 0.5) / P.""" + + NAME = 'MultiTapDelay' + + def _tap_node_ms(self, lap): + return (lap - 1 + 0.5) * 1000.0 / self._sample_rate + + +class NarrowTime(MultiTapDelay): + """T2 (b): Time's span handed as 120-200 ms, log. The constructor's + 150 ms still lands on the same frame.""" + + NAME = 'MultiTapDelay' + _MACRO_RANGES = ((120.0, 200.0, "log"),) + \ + MultiTapDelay._MACRO_RANGES[1:] + + +class RotatedTable(MultiTapDelay): + """T3: S1's table rotated by one position: position m sounds mode + m + 1's set, position 12 mode 1's.""" + + NAME = 'MultiTapDelay' + + def _head_set(self, mode, heads=None): + return MultiTapDelay._head_set(self, mode % 12 + 1, heads) + + +class FilteredHead(MultiTapDelay): + """T4: head 2 read by a second tap node behind a 6 kHz low-pass, built + for real: the lap node's output split into two tap nodes, head 2's + behind the filter, summed as a third voice of the output Mixer (a + nested Mixer would reset the tap nodes' lines when it is played). + Full graph only; the plant is read at Tone in.""" + + NAME = 'MultiTapDelay' + CORNER_HZ = 6000.0 + + def _wire(self): + rate, channels = self._sample_rate, self._channel_count + self._plant_filtered_head = True + self._fd.play(self._tap1) + split = audioroute.Splitter(self._fd, taps=2) + self._filter = audiofilters.Filter( + filter=synthio.Biquad(synthio.FilterMode.LOW_PASS, + type(self).CORNER_HZ), + mix=1.0, buffer_size=BLOCK * channels * 2, sample_rate=rate, + channel_count=channels) + self._filter.play(split.tap(1)) + self._head2 = audiodelays.MultiTapDelay( + max_delay_ms=1601, delay_ms=self._tap_ms, decay=0.0, mix=1.0, + taps=self._head2_taps(), buffer_size=BLOCK * channels * 2, + sample_rate=rate, channel_count=channels) + self._head2.play(self._filter) + self._tapnode.taps = self._other_taps() + self._tapnode.play(split.tap(0)) + self._plugged = False + mix = self._value(MIX_I) + self._mixer = audiomixer.Mixer( + voice_count=3, buffer_size=BLOCK * channels * 4, + channel_count=channels, sample_rate=rate) + self._mixer.voice[0].level = min(1.0, 2.0 - mix) + self._mixer.voice[1].level = min(1.0, mix) + self._mixer.voice[2].level = min(1.0, mix) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry) + self._mixer.voice[1].play(self._tapnode) + self._mixer.voice[2].play(self._head2) + + def _other_taps(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k != 2) + + def _head2_taps(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k == 2) + + def _refresh(self): + MultiTapDelay._refresh(self) + if getattr(self, "_head2", None) is not None: + self._tapnode.taps = self._other_taps() + self._mixer.voice[2].level = self._mixer.voice[1].level + + +class FrontFilter(MultiTapDelay): + """T5 clause 2: the seed's compose-first build, built for real - a + front `audiofilters.Filter` (low-pass at the Tone value) into the tap + node, whose own decay makes the laps. It decays and never darkens.""" + + NAME = 'MultiTapDelay' + + def _wire(self): + rate, channels = self._sample_rate, self._channel_count + self._plant_front_filter = True + self._filter = audiofilters.Filter( + filter=synthio.Biquad(synthio.FilterMode.LOW_PASS, + self._hz(self._value(TONE_I))), + mix=1.0, buffer_size=BLOCK * channels * 2, sample_rate=rate, + channel_count=channels) + self._filter.play(self._tap1) + self._tapnode.decay = self._value(FEEDBACK_I) + self._tapnode.play(self._filter) + self._plugged = False + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry) + self._mixer.voice[1].play(self._tapnode) + + def _refresh(self): + MultiTapDelay._refresh(self) + self._tapnode.decay = self._value(FEEDBACK_I) + + +class NoClear(MultiTapDelay): + """Tier 1's reset plant: `reset()` restores patch 0 and leaves both + lines full.""" + + NAME = 'MultiTapDelay' + + def reset(self): + self._check_live() + self.program_change(0) + + +def reach_build(cls): + return cls(Endless(silence()), sample_rate=RATE) + + +def reach(faulted, reading, **kw): + return kit_faults.fault_reachability(MultiTapDelay, faulted, reading, + reach_build, **kw) + + +def head_offset_error(effect): + """What the tap node does with what the class hands it: each sounding + head's offset, the node's way (the lap truncated from `delay_ms`, the + offset truncated from the position), less k n1.""" + rate = effect._sample_rate + lap = int(rate / 1000.0 * effect._tap_ms) + return tuple(int(lap * position) - k * effect._n1 + for k, (position, _level) in zip(effect._selected, + effect._taps)) + + +def lap_node_error(effect): + """The lap node's frames, the node's way, less P, floored: 0 on the + clean class at every setting.""" + return int(math.floor(law_node_frames(effect._lap_ms, + effect._sample_rate))) - effect._lap + + +def tap_lap_error(effect): + return int(effect._sample_rate / 1000.0 * effect._tap_ms) - effect._lap + + +def time_span_landed(effect): + """The landed t1 in ms at Time's two stops, at the current Heads.""" + span = type(effect)._MACRO_RANGES[TIME_I] + rate = effect._sample_rate + out = [] + for position in (0.0, 1.0): + n1, _lap = law_landed(_component.macro_value(span, position), + effect._heads, rate, effect._max_lap_ms) + out.append(n1 * 1000.0 / rate) + return tuple(out) + + +def head_table(effect): + return tuple(effect._head_set(mode) for mode in range(1, 13)) + + +# -------------------------------------------------------------------------- +# The surface + + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + cls = MultiTapDelay + self.assertEqual(cls.MACRO_LABELS, + ("Time", "Pattern", "Heads", "Feedback", "Mix", + "Tilt", "Repeat Tone", "Sync", "Division")) + self.assertEqual(tuple(cls.MACRO_MODES[i] for i in range(9)), MODES) + self.assertEqual(len(cls.PATCHES), 7) + self.assertEqual(cls.CAPABILITIES, ("tempo_sync",)) + self.assertEqual(cls.LATENCY_SAMPLES, 0) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioecho", "audioroute")) + effect = build() + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.patch_index, None) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + + def test_parked_not_served(self): + self.assertNotIn("MultiTapDelay", rebuilt.ADOPTED) + self.assertIsNot(audioeffects.MultiTapDelay, MultiTapDelay) + + def test_patch_table_is_the_dossier_on_the_grid(self): + for index, (name, values) in enumerate(DOSSIER_PATCHES): + engineering = list(values) + engineering[1] = engineering[1] - 1 + grid = tuple(_component.macro_of(span, value, mode) + for span, value, mode + in zip(SPANS, engineering, MODES)) + self.assertEqual(MultiTapDelay.PATCHES[index], (name, grid)) + + def test_patch_0_is_the_constructor_grid(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) + self.assertEqual(effect.patch_index, 0) + for index, expected in enumerate(MultiTapDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_constructor_options_clamp(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE, + max_lap_ms=100.0, heads=8, time_ms=400.0) + self.assertEqual(effect._max_lap_ms, 540.0) + self.assertEqual(effect._n1, 25920 // 8) + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE, + max_lap_ms=float("nan"), time_ms=float("nan"), + tone_hz=0.0, pattern=40, heads=1) + self.assertEqual(effect._max_lap_ms, 1600.0) + self.assertEqual(effect._n1, 7200) + self.assertEqual(effect.get_macro(TONE_I), 127.0) + self.assertTrue(effect._lean) + self.assertEqual(effect._pattern_mode(), 12) + self.assertEqual(effect._heads, 3) + + def test_tilt_law(self): + # A7.11: K 4 mode 12, Tilt -1 reads 20 000 / 12 619 / 7 962 / 5 023. + frames, values, expected = first_lap( + MultiTapDelay, tone=0.0, heads=4, pattern=12, time_ms=20.0, + midi={TILT_I: 0}) + self.assertEqual(frames, expected) + levels = law_tilt_levels((1, 2, 3, 4), 4, -1.0) + self.assertEqual(values, [int(20000 * v) for v in levels]) + self.assertEqual(values, [20000, 12619, 7962, 5023]) + + def test_sync_reads_the_transport_only_when_on(self): + calls = [] + + def transport(): + calls.append(1) + return (True, 0.0, 120.0, 4, 4) + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE, + transport=transport) + self.assertEqual(calls, []) + effect.program_change(6) + self.assertTrue(calls) + # 1/8T at 120 bpm is 166.67 ms: 8000 frames. + self.assertEqual(effect._n1, 8000) + static = MultiTapDelay(Endless(silence()), sample_rate=RATE) + static.program_change(6) + self.assertEqual(static._n1, law_frames(law_time_ms(85), RATE)) + + def test_the_lap_node_hand_off_is_the_law(self): + # Section 4: never under P, at the 50 cells at 44.1 kHz where the + # plain value would land under. + effect = MultiTapDelay(Endless(silence(), rate=44100), + sample_rate=44100, mix=0.0) + under_plain = 0 + for heads in range(3, 9): + effect.set_macro(HEADS_I, law_heads_midi(heads)) + for time_midi in range(128): + effect.set_macro(TIME_I, time_midi) + lap = effect._lap + self.assertEqual(effect._lap_ms, law_lap_node_ms(lap, 44100)) + self.assertGreaterEqual( + law_node_frames(effect._lap_ms, 44100), lap) + if law_node_frames(lap * 1000.0 / 44100, 44100) < lap: + under_plain += 1 + self.assertEqual(under_plain, 50) + + +# -------------------------------------------------------------------------- +# Tier 1 + + +class Tier1(unittest.TestCase): + def test_mix_zero_is_a_wire(self): + for rate in RATES: + for channels in (2, 1): + for tone in (4000.0, 0.0): + data = probes.ramp_fs(8192, channels) + effect = MultiTapDelay( + Endless(data, rate, channels), sample_rate=rate, + mix=0.0, tone_hz=tone, feedback=0.95, pattern=12, + heads=8, time_ms=20.0) + out = pull(effect, 8192).reshape(-1) + self.assertTrue(np.array_equal( + out, np.array(data, dtype=np.int64)), + (rate, channels, tone)) + self.assertEqual(effect.tail_samples, 0) + + def test_mix_back_from_zero_starts_from_empty_lines(self): + effect = build(data=click(), mix=1.0, feedback=0.9, time_ms=20.0) + pull(effect, 2000) + effect.set_macro(MIX_I, 0) + pull(effect, 256) + effect.set_macro(MIX_I, 64) + out = pull(effect, 12000) + self.assertEqual(int(np.abs(out).max()), 0) + + def test_silence_stays_silence(self): + for tone in (4000.0, 0.0): + effect = build(data=silence(), tone_hz=tone, feedback=0.95) + self.assertEqual(int(np.abs(pull(effect, 48000)).max()), 0) + + def test_click_latency_is_zero(self): + effect = build(data=click(at=10), mix=1.0) + out = pull(effect, 7000) + self.assertEqual(nonzero(out), [10]) + self.assertEqual(int(out[10, 0]), 20000) + self.assertEqual(effect.latency_samples, 0) + + def test_tail_ends_inside_tail_samples(self): + for tone in (4000.0, 0.0): + for feedback in (0.45, 0.85): + probe = build(data=silence(), tone_hz=tone, + feedback=feedback, time_ms=20.0) + lap = probe._lap + burst = 4 * lap + tail = probe.tail_samples + probe.deinit() + noise = probes.noise_det(burst, dbfs=0.0) + effect = build(data=noise, mix=2.0, tone_hz=tone, + feedback=feedback, time_ms=20.0) + self.assertEqual(effect.tail_samples, tail) + out = pull(effect, burst + tail + 4 * lap) + last = nonzero(out)[-1] + self.assertLess(last - burst, tail, (tone, feedback)) + self.assertGreater(last - burst, 0) + + def test_tail_samples_is_finite_at_every_patch(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) + for index in sorted(MultiTapDelay.PATCHES): + effect.program_change(index) + self.assertIsInstance(effect.tail_samples, int) + self.assertGreater(effect.tail_samples, 0) + effect.program_change(0) + self.assertEqual(effect.tail_samples, 321435) + + def _after_reset(self, cls): + effect = build(cls, data=click(), mix=1.0, feedback=0.9, + time_ms=20.0) + pull(effect, 3000) + effect.reset() + return int(np.abs(pull(effect, 20000)).max()) + + def test_reset_empties_both_lines(self): + self.assertEqual(self._after_reset(MultiTapDelay), 0) + + def test_reset_plant_is_red(self): + self.assertGreater(self._after_reset(NoClear), 0) + + def test_deinit_leaves_the_source(self): + source = probes.ArraySource(probes.sine(440.0, 0.1, -6.0), + rate=RATE, channels=2) + effect = MultiTapDelay(source, sample_rate=RATE) + pull(effect, 512) + effect.deinit() + effect.deinit() + data = memoryview(bytes(audiocore.get_buffer(source)[1])).cast("h") + self.assertGreater(max(abs(int(v)) for v in data), 0) + with self.assertRaises(RuntimeError): + effect.output + + +# -------------------------------------------------------------------------- +# T1 - taps on integer multiples of one base, exactly + + +class T1Grid(unittest.TestCase): + def test_first_lap_at_the_defaults_three_rates_both_graphs(self): + for rate in RATES: + for tone in (4000.0, 0.0): + result = first_lap_green(MultiTapDelay, rate=rate, tone=tone) + self.assertTrue(result["passed"], (rate, tone, result)) + result = first_lap_green(MultiTapDelay, channels=1) + self.assertTrue(result["passed"], result) + + def test_first_lap_over_modes_heads_and_time_stops(self): + for mode in (1, 7, 9, 12): + for heads in (3, 4, 8): + for time_midi in (0, 127): + result = first_lap_green( + MultiTapDelay, tone=0.0, + midi={PATTERN_I: law_pattern_midi(mode), + HEADS_I: law_heads_midi(heads), + TIME_I: time_midi}) + self.assertTrue(result["passed"], + (mode, heads, time_midi, result)) + + def test_laps_land_on_the_grid(self): + for rate in RATES: + lean = laps_reading(MultiTapDelay, rate=rate, tone=0.0) + self.assertTrue(lean["passed"], (rate, lean)) + full = laps_reading(MultiTapDelay, rate=rate) + self.assertTrue(full["passed"], (rate, full)) + self.assertEqual(full["arrivals"], 12) + # A cell where the plain P 1000 / fs lands under P in float32 at + # 44.1 kHz (K 3, Time MIDI 67, P 12 852): every onset on the grid. + full = laps_reading(MultiTapDelay, rate=44100, feedback=0.6, + midi={TIME_I: 67}) + self.assertTrue(full["passed"], full) + + def test_late_heads_are_red_at_the_defaults(self): + for rate in RATES: + result = first_lap_green(LateHeads, rate=rate) + self.assertFalse(result["passed"], rate) + frames, _values, expected = first_lap(LateHeads) + self.assertEqual(frames, [7201, 14401, 21600]) + self.assertEqual(expected, [7200, 14400, 21600]) + + def test_long_lap_is_red_on_the_lap_clause(self): + for rate in RATES: + result = laps_reading(LongLap, rate=rate) + self.assertFalse(result["passed"], rate) + self.assertEqual(len(result["late"]), 9, rate) + + def test_the_t1_faults_are_not_on_the_surface(self): + result = reach(LateHeads, head_offset_error) + self.assertEqual(result["target"], (1, 1, 0)) + self.assertEqual(result["checked"], 9 * 17 + 7) + result = reach(LongLap, lap_node_error) + self.assertEqual(result["target"], 1) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: first_lap_green(cls), label="T1") + kit_faults.null_build_red( + MultiTapDelay, lambda cls: laps_reading(cls), label="T1 laps") + + +# -------------------------------------------------------------------------- +# T2 - one control moves the whole grid + + +def walk_reading(cls, heads, time_midis, rate=RATE): + bad = [] + for midi in time_midis: + frames, values, expected = first_lap( + cls, rate=rate, tone=0.0, pattern=12, + midi={HEADS_I: law_heads_midi(heads), TIME_I: midi}) + if frames != expected or values != [20000] * len(values): + bad.append(midi) + return {"passed": not bad, "bad": bad} + + +def span_reading(cls, heads, rate=RATE): + landed = [] + for midi in (0, 127): + frames, _values, _expected = first_lap( + cls, rate=rate, tone=0.0, pattern=12, + midi={HEADS_I: law_heads_midi(heads), TIME_I: midi}) + if not frames or frames[0] <= 0: + return {"passed": False, "span": 0.0} + landed.append(frames[0]) + span = landed[1] / float(landed[0]) + return {"passed": span >= 3.33, "span": span} + + +class T2OneControl(unittest.TestCase): + TIMES = tuple(range(0, 128, 8)) + (127,) + + def test_time_walk_keeps_every_head_on_the_grid(self): + for heads in (3, 8): + result = walk_reading(MultiTapDelay, heads, self.TIMES) + self.assertTrue(result["passed"], (heads, result)) + result = walk_reading(MultiTapDelay, 4, (0, 55, 127), rate=44100) + self.assertTrue(result["passed"], result) + + def test_span_is_at_least_the_re201s(self): + for heads, expected in ((3, 20.0), (8, 10.0)): + result = span_reading(MultiTapDelay, heads) + self.assertTrue(result["passed"], result) + self.assertAlmostEqual(result["span"], expected, places=6) + + def test_short_tap_lap_is_red_at_the_defaults(self): + for rate in RATES: + self.assertFalse(first_lap_green(ShortTapLap, rate=rate) + ["passed"], rate) + frames, _values, _expected = first_lap(ShortTapLap) + self.assertEqual(frames, [7200, 14399, 21599]) + + def test_narrow_time_span_is_red(self): + for heads in (3, 8): + result = span_reading(NarrowTime, heads) + self.assertFalse(result["passed"], result) + self.assertAlmostEqual(result["span"], 200.0 / 120.0, places=6) + # The constructor's 150 ms still lands on the same frame. + self.assertEqual(first_lap(NarrowTime)[0], [7200, 14400, 21600]) + + def test_the_t2_faults_are_not_on_the_surface(self): + result = reach(ShortTapLap, tap_lap_error) + self.assertEqual(result["target"], -1) + result = reach(NarrowTime, time_span_landed) + self.assertEqual(result["target"], (120.0, 200.0)) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: walk_reading(cls, 3, (0, 64, 127)), + label="T2 walk") + kit_faults.null_build_red( + MultiTapDelay, lambda cls: span_reading(cls, 3), label="T2 span") + + +# -------------------------------------------------------------------------- +# T3 - a subset selector over the grid + + +def table_reading(cls, heads, modes=range(1, 13)): + bad = [] + for mode in modes: + frames, _values, _expected = first_lap( + cls, tone=0.0, time_ms=20.0, + midi={PATTERN_I: law_pattern_midi(mode), + HEADS_I: law_heads_midi(heads)}) + n1 = law_frames(20.0, RATE) + sounded = tuple(f // n1 for f in frames if f > 0 and f % n1 == 0) + if sounded != law_heads(mode, heads) or len(sounded) != len(frames): + bad.append((mode, sounded)) + return {"passed": not bad, "bad": bad} + + +class T3Selector(unittest.TestCase): + def test_s1_table_at_four_heads(self): + result = table_reading(MultiTapDelay, 4) + self.assertTrue(result["passed"], result) + self.assertEqual(law_heads(10, 4), (1, 3, 4)) + + def test_other_heads(self): + result = table_reading(MultiTapDelay, 3, (8, 9, 10, 11, 12)) + self.assertTrue(result["passed"], result) + self.assertEqual([law_heads(m, 3) for m in (8, 9, 10, 11)], + [(1,), (3,), (1, 3), (1, 2)]) + result = table_reading(MultiTapDelay, 8, (4, 12)) + self.assertTrue(result["passed"], result) + + def test_rotated_table_is_red_at_the_defaults(self): + frames, _values, expected = first_lap(RotatedTable) + self.assertEqual(frames, [7200]) + self.assertEqual(expected, [7200, 14400, 21600]) + for rate in RATES: + self.assertFalse(first_lap_green(RotatedTable, rate=rate) + ["passed"], rate) + + def test_the_t3_fault_is_not_on_the_surface(self): + result = reach(RotatedTable, head_table) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: table_reading(cls, 4, (1, 7, 12)), + label="T3") + + +# -------------------------------------------------------------------------- +# T4 and T5 - one timbre per lap; darkening once per lap, and really there + + +def t4_green(result): + return result["present"] and result["t4"] <= 0.5 + + +def t5_green(result, tone, rate): + clause1 = result["present"] and result["t5"] <= 0.5 + clause2a = result["c2"] >= 1.0 and abs(result["c4"] - 3 * result["c2"]) \ + <= 2.0 + top = 3620.0 if rate > 30000 else 2482.0 + clause2b = tone > top + 1.0 or result["q4"] >= 15.0 + return clause1 and clause2a and clause2b + + +def tone_of(midi): + return 800.0 * 20.0 ** (midi / 127.0) + + +def feedback_of(midi): + return 0.95 * midi / 127.0 + + +class T4T5Laps(unittest.TestCase): + def test_at_the_row_cells(self): + cells = [(RATE, fb, tone) for fb in (feedback_of(20), 0.45, 0.95) + for tone in (tone_of(0), tone_of(64), tone_of(126), 4000.0)] + cells += [(44100, 0.45, 4000.0), (22050, 0.45, 4000.0), + (22050, 0.95, tone_of(0))] + for rate, feedback, tone in cells: + result = t45_reading(MultiTapDelay, rate=rate, feedback=feedback, + tone=tone) + self.assertTrue(t4_green(result), (rate, feedback, tone, result)) + self.assertTrue(t5_green(result, tone, rate), + (rate, feedback, tone, result)) + self.assertLess(result["t4"], 0.001) + self.assertLess(result["t5"], 0.001) + self.assertEqual(result["lap1"], [24000.0] * 3) + + def test_other_mode_and_heads_pairs(self): + for heads, mode in ((4, 12), (4, 10), (8, 12)): + result = t45_reading(MultiTapDelay, heads=heads, mode=mode) + self.assertTrue(t4_green(result), (heads, mode, result)) + self.assertTrue(t5_green(result, 4000.0, RATE), + (heads, mode, result)) + + def test_constructor_defaults_numbers(self): + # A7.13 f: D2 3.00, D4 8.99 at the corner, D4 20.19 at 8 kHz. + result = t45_reading(MultiTapDelay) + self.assertAlmostEqual(result["c2"], 3.00, delta=0.05) + self.assertAlmostEqual(result["c4"], 8.99, delta=0.05) + self.assertAlmostEqual(result["q4"], 20.19, delta=0.05) + + def test_filtered_head_is_red_on_t4(self): + clean = t45_reading(MultiTapDelay) + result = t45_reading(FilteredHead) + self.assertTrue(result["present"], result) + self.assertGreater(result["t4"], 0.5, result) + self.assertTrue(t4_green(clean)) + + def test_front_filter_is_red_on_clause_2(self): + result = t45_reading(FrontFilter) + self.assertTrue(result["present"], result) + self.assertLess(abs(result["c2"]), 0.5, result) + self.assertLess(abs(result["q4"]), 0.5, result) + self.assertFalse(t5_green(result, 4000.0, RATE)) + + def test_emulated_per_head_darkening_is_red_on_clause_1(self): + # Emulated on the rendered windows, as the dossier states it. + result = t45_reading(MultiTapDelay, emulate=darken_head2_per_lap) + self.assertGreater(result["t5"], 0.5, result) + self.assertFalse(t5_green(result, 4000.0, RATE)) + + def test_the_t4_t5_faults_are_not_on_the_surface(self): + for faulted, flag in ((FilteredHead, "_plant_filtered_head"), + (FrontFilter, "_plant_front_filter")): + result = reach(faulted, + lambda e, flag=flag: getattr(e, flag, False)) + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: t4_green(t45_reading(cls)), + label="T4") + kit_faults.null_build_red( + MultiTapDelay, + lambda cls: t5_green(t45_reading(cls), 4000.0, RATE), + label="T5") + + +if __name__ == "__main__": + unittest.main() From c80ca59545260edc23f58aebe522de71029c4da5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:41:30 -0500 Subject: [PATCH 36/88] MultiTapDelay fix round: reset() from the lean graph (or never wired at Mix 0) no longer primes a block of the borrowed source; the primes read a silent block through the input adapter instead, and the tests call the kit's STATE and count the source's pulls, with the base reset planted --- lib/audioeffects/rebuilt/multitapdelay.py | 47 +++++++++-- tests/test_cpython_effects_multitapdelay.py | 86 +++++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index ecf2981..b754044 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -451,6 +451,9 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, #: True while Mix 0 has the output port on the borrowed source. self._at_source = False self._deferred = False + #: True inside `reset()`, where a `play()` must not reach the + #: borrowed source (`_route`). + self._resetting = False # The input adapter re-blocks whatever the app hands the class into # the palette's own blocks, which `audioroute.Splitter` needs: a @@ -674,6 +677,15 @@ def _route(self): block; `FeedbackDelay.play` pulls nothing), and afterwards the tap node is re-plugged if Repeat Tone crossed its out stop. Coming back from Mix 0 empties both lines, which were not fed while it was 0. + + Inside `reset()` those primes would take a block of the borrowed + source and leave it in the Splitter for the dry, which is the last + take coming out after the reset: patch 0 has Repeat Tone in, so a + reset from the lean graph re-plugs, and one from a class never + wired at Mix 0 wires. There the input adapter plays a silent + sample for the length of the primes, so they read one block of + zeros on both branches and the source is not touched; the output + after such a reset opens with that one silent block. """ if not self._ready: return @@ -681,18 +693,37 @@ def _route(self): self._output = self._source self._at_source = True return - if not self._wired: - self._wire() - self._wired = True - else: - if self._at_source: + quiet = self._resetting and (not self._wired + or self._lean != self._plugged) + if quiet: + self._adapter.play(self._silence) + try: + if not self._wired: + self._wire() + self._wired = True + else: + if self._at_source: + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + if self._lean != self._plugged: + self._plug(self._lean) + finally: + if quiet: + self._adapter.play(self._source) self._fd.clear() - audiocore.reset_buffer(self._tapnode) - if self._lean != self._plugged: - self._plug(self._lean) self._at_source = False self._output = self._mixer + def reset(self): + """Empty both lines and restore patch 0, without pulling the + borrowed source (`_route`).""" + self._check_live() + self._resetting = True + try: + _component.Component.reset(self) + finally: + self._resetting = False + def _wire(self): """Play every node once, the first time Mix is above 0.""" self._fd.play(self._tap1) diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 90b6235..109c115 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -46,6 +46,7 @@ class built as a wire. The full spans, the three interpreters and the rates import audioeffects # noqa: E402 from audioeffects import _component # noqa: E402 from audioeffects import rebuilt # noqa: E402 +from tools import effect_measurements as kit # noqa: E402 VENDOR = "PyDevices" @@ -534,6 +535,69 @@ def _refresh(self): self._tapnode.decay = self._value(FEEDBACK_I) +class PrimingReset(MultiTapDelay): + """The review's finding, planted: the base's `reset()`, whose patch-0 + restore re-plugs (or wires) the graph by priming a block of the + borrowed source into the Splitter.""" + + NAME = 'MultiTapDelay' + + def reset(self): + _component.Component.reset(self) + + +class Counting(Endless): + """`Endless` that counts the blocks it has handed out.""" + + pulls = 0 + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self.pulls += 1 + return Endless._get_buffer(self, single_channel_output, + audio_channel) + + +def reset_pulls(cls, start, channels=2): + """Blocks of the borrowed source `reset()` takes from each starting + graph: patch 1 (lean), Repeat Tone at its out stop, and a class built + at Mix 0 that has never wired.""" + source = Counting(probes.noise_det(4 * BLOCK, channels=channels), + RATE, channels) + effect = cls(source, sample_rate=RATE, + mix=0.0 if start == "mix0" else 0.35) + if start == "patch1": + effect.program_change(1) + elif start == "tone127": + effect.set_macro(TONE_I, 127) + pull(effect, 8 * BLOCK) + before = source.pulls + effect.reset() + taken = source.pulls - before + effect.deinit() + return taken + + +def state_reading(cls, patch, channels=2): + """The kit's STATE at `patch`, 48 kHz: reset with the probe still + sounding, then a silent source must render exact zero.""" + data = probes.noise_det(RATE // 2, dbfs=-6.0, channels=channels) + holder = probes.SwitchableSource( + probes.ArraySource(data, rate=RATE, channels=channels, block=BLOCK)) + effect = cls.create(holder, RATE) + effect.program_change(patch) + silent = probes.ArraySource(probes.silence(2048, channels), rate=RATE, + channels=channels, block=BLOCK) + + def render(blocks): + return probes.render(effect.output, blocks * BLOCK, rate=RATE, + channels=channels, block=BLOCK, + class_name="MultiTapDelay") + + return kit.state(effect, pull=render, swap=holder.swap, + probe_source=holder.inner, silent_source=silent, + blocks=64, alloc_pulls=50) + + class NoClear(MultiTapDelay): """Tier 1's reset plant: `reset()` restores patch 0 and leaves both lines full.""" @@ -776,6 +840,28 @@ def test_reset_empties_both_lines(self): def test_reset_plant_is_red(self): self.assertGreater(self._after_reset(NoClear), 0) + def test_reset_takes_nothing_from_the_source(self): + for start in ("patch1", "tone127", "mix0"): + for channels in (2, 1): + self.assertEqual(reset_pulls(MultiTapDelay, start, + channels), 0, + (start, channels)) + + def test_reset_priming_plant_is_red(self): + for start in ("patch1", "tone127", "mix0"): + self.assertGreater(reset_pulls(PrimingReset, start), 0, start) + + def test_state_from_the_lean_graph(self): + for channels in (2, 1): + result = state_reading(MultiTapDelay, 1, channels) + self.assertEqual(result["values"]["reset_residual_lsb"], 0, + result["red"]) + self.assertTrue(result["values"]["resumed"]) + + def test_state_priming_plant_is_red(self): + result = state_reading(PrimingReset, 1) + self.assertGreater(result["values"]["reset_residual_lsb"], 0) + def test_deinit_leaves_the_source(self): source = probes.ArraySource(probes.sine(440.0, 0.1, -6.0), rate=RATE, channels=2) From b09e7816c24cae95de507c78905e64c1697e5327 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:48:21 -0500 Subject: [PATCH 37/88] The audiodsp floor moves to v0.6.3rc1; the node workarounds come out AUDIODSP_PIN names v0.6.3rc1 (bd7d2e2), Brad's "fix the node now, in parallel". audiodsp#161 fixes four FeedbackDelay defects: an out loop low-pass keeps its state on the tap (#158), an out high-pass holds zero (#159), a stalled damping state lands on its input (#157), and a new wow depth ramps over 20 ms (#160). Eight tests went red at the new pin, each because it pinned the old node or proved a workaround was needed, and nothing else did. The workarounds come out: DigitalDelay, SlapbackDelay and CombFilter hand exactly 0 at a filter's out stop (no 32 x rate tracking stop) and hand the Feedback set (no stepping clear of the stall windows); laps_to_zero counts one landing lap at a window centre, measured at blocks of 64 to 4096 frames, so every bound stays finite. DigitalDelay's Repeat Cut disclosure and SlapbackDelay's Wow step come out of the docstrings. clear_of_stalls and stall_window stay in digitaldelay.py for the classes still built at v0.6.2. The restated tests assert the good behaviour directly, each beside a planted fault: a filter left in at 0.001 Hz (frozen), the retired tracking stop, the retired stepping, the v0.6.2 reckoning that says None, and a Wow move that jumps the read head. Run against the old class code, 13 of the 16 restated tests are red. Both docstrings now say which static Times the node lands one float32 step off the whole frame (DigitalDelay 18 / 18 knob positions at 44.1 / 22.05 kHz, patch 4 at 44.1 among them; SlapbackDelay 21 / 20), with two surface tests each, as PingPongDelay's do. No census cell moved (319 cells, three interpreters). Gates: unittest 1693 tests OK (skipped=3); validate_api 55 / 45; metadata valid; flake8 clean; smoke 45 classes, 259 patches, 0 failures on CPython, MicroPython and CircuitPython. --- AUDIODSP_PIN | 35 +- CHANGELOG.md | 40 +- lib/audioeffects/README.md | 6 +- lib/audioeffects/combfilter.py | 90 ++--- lib/audioeffects/rebuilt/digitaldelay.py | 178 ++++----- lib/audioeffects/rebuilt/slapbackdelay.py | 147 +++---- tests/test_cpython_effects_combfilter.py | 204 ++++++---- tests/test_cpython_effects_digitaldelay.py | 411 ++++++++++++++------ tests/test_cpython_effects_slapbackdelay.py | 296 +++++++++----- 9 files changed, 859 insertions(+), 548 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 16b551c..0b4799b 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -199,5 +199,38 @@ # AnalogDelay (2), PingPongDelay (1) and the parked DigitalDelay (1). # Pending both boards, as v0.6.1 was. # +# 2026-09-28: v0.6.2 -> v0.6.3rc1 (bd7d2e2), a pre-release of the next +# patch, on Brad's ruling of the same day, "fix the node now, in parallel". +# It is on TestPyPI only (18 wheels; pre-releases skip production PyPI and +# MIP), so CI's `git+...@v0.6.3rc1` install is the one that reaches it. +# What moved beneath the components, by `git log v0.6.2..v0.6.3rc1 -- +# src`: audiodsp#161, four fixes to audioecho.FeedbackDelay. A loop +# low-pass taken out (damping_hz 0) keeps its state on the tap, and a +# high-pass taken out (cut_hz 0) holds its state at zero, where both used +# to freeze and play back out of silence when the filter came back (#158, +# #159); a damping state that has stopped moving at or below 64 LSB is set +# onto its input once a block, so a tail reaches exact zero with the +# low-pass in (#157); a new wow_depth_ms ramps in over 20 ms instead of +# landing at once (#160). Eight tests here pinned the old node or proved a +# class-side workaround was needed (DigitalDelay 3, SlapbackDelay 3, +# CombFilter 2, one of them the stall canary) and went red at the new pin, +# nothing else did; the workarounds came out in the same change (the +# 32 x rate tracking off stops, the stall-window stepping, the Repeat Cut +# disclosure, SlapbackDelay's Wow step) and those tests assert the good +# behaviour directly, each beside a planted fault. The census (every class +# and parked rebuild at the default and every patch, 319 cells) is +# identical to v0.6.2's on CPython, MicroPython and CircuitPython, both +# before and after the workarounds came out; the library smoke moves 15 +# peaks on all three, all where a patch change on a playing instance moves +# a wow depth (Chorus 1-5, Vibrato 3-6, the parked Flanger 1-6), which is +# #160's ramp. Re-run here on Brad's workstation, with the desktop +# interpreters rebuilt at the tag (audioecho.__revision__ +# v0.0.3-290-gbd7d2e2 on MicroPython; CircuitPython carries none, and its +# provenance stamp names the same): the unit suite `Ran 1693 tests ... OK +# (skipped=3)`; validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 259 +# patches, 0 failures on CPython, MicroPython and CircuitPython. Pending +# both boards. +# # -- ref is what CI installs (git+https://...@) -v0.6.2 1c89b032abb9e625390d634165a561683df81d14 +v0.6.3rc1 bd7d2e219208731cc100a1e8210ec72f8cec9af1 diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7c32b..a9e15d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,14 @@ there, and are recorded in its changelog. interpolated line with the Boss DD-2's control law on one `audioecho.FeedbackDelay`, and what `audioeffects.DigitalDelay` and `create()` now serve. Turning Time pitch-bends the repeats at a rate Glide - sets instead of clicking, every static Time lands on a whole frame, Mix 0 - is a wire while the line keeps recording, and Repeat Tone and Repeat Cut - put the pedal's 7 kHz and 40 Hz corners into the loop as knobs that - default out. `tail_samples` is finite with Repeat Cut out, and with Repeat - Tone in the Feedback is stepped clear of the node's stall windows - (audiodsp#157). Repeat Tone out after it has been in keeps the loop - low-pass tracking the tap (fixed at adoption: Tone back in after silence - played 26 443 LSB); Repeat Cut back in after silence still plays its - frozen state, a node defect the docstring states. On the boards: at most + sets instead of clicking, every static Time is handed as a whole frame + (the node lands 18 of the 128 knob positions one float32 step off it at + 44.1 and at 22.05 kHz, none at 48 kHz), Mix 0 is a wire while the line + keeps recording, and Repeat Tone and Repeat Cut put the pedal's 7 kHz and + 40 Hz corners into the loop as knobs that default out. `tail_samples` is + finite with Repeat Cut out, and either filter back in after silence plays + nothing (at audiodsp v0.6.3rc1, whose node keeps an out filter's state + live). On the boards: at most 7.0 % of a block on the P4 and about 14.6 % on the S3 (patch 5, same-conditions reading), passed by Brad; patch 5's digest is identical board to board and differs from the desktop through single-precision @@ -33,23 +32,40 @@ there, and are recorded in its changelog. - **`SlapbackDelay` (rebuilt, Phase 5; adopted 2026-09-28):** the Sun Studio tape slap on one `audioecho.FeedbackDelay`, and what `audioeffects.SlapbackDelay` now serves: one mono repeat at 135 ms, landed - on a whole frame at every rate, with Saturation, Tone and Wow as the + as a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire while the line keeps recording, Time walks rather than clicks, and a host echoing Time back keeps the constructor's exact frame. Tone out is byte-identical - to no filter until Tone has been in since a reset, and within 1 LSB after. + to no filter whatever came before, and a Wow move glides over 20 ms (both + at audiodsp v0.6.3rc1). On the boards: at most 7.4 % of a block on the P4; on the S3 14.6-14.7 %, and 15.2 % at patch 5 (Tone in), which Brad passed against the 15 % bar; every patch digest is identical board to board and differs from the desktop through the Wow depth worked out in single precision (audiocomponents#75). The docstring states the input ceiling (-2.5 dBFS peak at the defaults, -3.4 over the shipped patches, on `noise_det`), the - 15 kHz swing the wow costs the repeat, the Wow step, and Tone's flat top + 15 kHz swing the wow costs the repeat, the Times the node lands off the + frame at 44.1 and 22.05 kHz, and Tone's flat top at 22.05 kHz. It lives in `lib/audioeffects/rebuilt/slapbackdelay.py` until it comes home. ### Changed +- **The audiodsp pin moves to v0.6.3rc1, and three workarounds come out.** + The release carries audiodsp#161: `audioecho.FeedbackDelay` keeps an out + loop filter's state live (#158, #159), lands a stalled damping state so a + tail reaches zero with the low-pass in (#157), and ramps a new wow depth + over 20 ms (#160). `DigitalDelay`, `SlapbackDelay` and `CombFilter` now + hand exactly 0 at a filter's out stop (no more 32 x rate tracking stop, + which was 1 LSB off the filter out in places) and hand the Feedback you + set (no more stepping clear of the stall windows, up to 3 x 10^-5 away); + `tail_samples` counts one landing lap at a window centre and stays finite. + `DigitalDelay`'s Repeat Cut back in after silence is silent (it played + 20 858 LSB, disclosed), and `SlapbackDelay`'s Wow no longer steps (7 684 + LSB at a 36 -> 73 move). Both docstrings now say which static Times the + node lands one float32 step off the whole frame at 44.1 and 22.05 kHz, as + `PingPongDelay`'s does. No shipped patch's render moved (the census, + three interpreters). - **`Flanger` (parked under `rebuilt/`): F8 restated, the sound unchanged.** At Color max (0.99, 3 ms, Filter Matrix on) the ring to -60 dB is about 1.9 s after a 440 Hz burst and 2.1 s after a 200 Hz one (1.91 / 2.13 s diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index c6edc81..8478cfa 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -128,7 +128,7 @@ marginal share of one block on each board at construction defaults. | `BandPass` | audiodsp (`audiobiquad`) | 4 | 0 samples | 3.8 % P4 / 6.2 % S3 of a block, inside its 4 % / 13 % budget, and the same at every patch | none - the two-pole resonant band-pass in RBJ's constant 0 dB peak-gain form, so `Width` moves the skirts without moving the peak. The peak holds within 0.05 dB in 55 of 56 measured cells, both knob stops included; the one miss is -0.09 dB at f0 31.5 Hz with Q 32 (audiodsp#64 fixed the rest). The +-6 dB/oct and -3 dB figures are Q 0.707 statements below about 2 kHz - above that the bilinear warp moves them, and this class tracks the warped prototype to 0.009 dB | | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | -| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. With Tone in, Feedback is stepped under 0.00003 clear of the node's stall windows (audiodsp#157; the 0.95 stop is one). Since 2026-09-28 Tone off after Tone has been in keeps the loop low-pass tracking the line (it had frozen, and Tone back in from silence played 15 070 LSB at 48 kHz); a fresh or reset instance hands no filter. The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | +| `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. Feedback is handed as set; at a stall-window centre (the 0.95 stop is one) the bound counts one landing lap, since audiodsp v0.6.3rc1 lands a stalled loop low-pass (#157). Tone off is exactly 0 whatever came before, and Tone back in from silence plays nothing (the v0.6.2 node froze the low-pass and played 15 070 LSB at 48 kHz). The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | ### Time and space - two adopted, the rest in `reverb.py`, `delay.py` Phase 5's first two classes, `DigitalDelay` and `SlapbackDelay`, were @@ -142,8 +142,8 @@ measured beside the palette row it was priced from). | Class | Tier | Macros | Latency | Cost | Standout | |---|---|---|---|---|---| -| `DigitalDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 at most 7.0 %** (0.372 ms, patch 5, rt 5.49) **/ S3 about 14.6 %** at patch 5 (about 0.78 ms corrected; 11.8 % as the tool reads it; rt 3.11), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed the S3 figure on 2026-09-28. No lean patch. RAM 155 584 B at the default 800 ms line (48 kHz); `max_time_ms` spends less | Boss DD-2 (1983), light touch: its control law and dry/wet discipline, Mix 0 a wire, Time bends the repeats in pitch at a rate Glide sets instead of clicking, every static Time on a whole frame. Repeat Tone and Repeat Cut put the pedal's 7 kHz and 40 Hz corners in the loop as knobs that default out; patch 5 is the pedal's own corners. `tail_samples` is finite with Repeat Cut out; with Repeat Tone in, Feedback is stepped at most 0.00003 clear of the node's stall windows (audiodsp#157). Repeat Tone out after it has been in keeps the loop low-pass tracking the tap (fixed 2026-09-28; within 1 LSB of the filter out, up to 51 LSB inside a stall window at the 0.99 stop); Repeat Cut back in after silence still plays its frozen state (20 858 LSB at Mix 2), a node defect, and `reset()` clears it. **Digests:** six of seven patches identical on the P4, the S3 and the desktop; patch 5 identical board to board, and different from the desktop through single-precision Python floats in two derived settings (audiocomponents#75): `cut_hz` 0.035 % high (39.44766 Hz against 39.43366) and `damping_hz` one float32 step. **What the default surrenders:** no band limit and no compander, no HOLD, and Glide 0's instant knob clicks. | -| `SlapbackDelay` | audiodsp (`audioecho`) | 6 | 0 samples | **P4 at most 7.4 %** (0.396 ms corrected; 6.4 % as the tool reads it; rt 5.82) **/ S3 14.6-14.7 %** at the default and patches 0-4 and **15.2 % at patch 5** (0.813 ms corrected, the one patch with Tone in; 11.6 % as the tool reads it; rt 3.08), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed patch 5 on 2026-09-28 ("It passes"). No lean patch. RAM about 50 KB (a fixed 251 ms line) | Sam Phillips' two-Ampex-350 slap at Sun Studio: one mono repeat at 135 ms, landed on a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire; Time walks rather than clicks. Tone out stays byte-identical to no filter until Tone has been in since a reset, and within 1 LSB after. **Digests:** all seven patches identical on the P4 and the S3, and different from the desktop through single-precision Python floats in a derived setting (audiocomponents#75): `wow_depth_ms` 2.1 x 10^-5 to 6.8 x 10^-5 ms high, under 0.0001 cent (patch 4's Feedback also one float32 step). **What the default surrenders:** Tone out, so the repeat is as bright as the dry, and Wow at 1 cent makes a 15 kHz repeat breathe down to -5.11 dB at 48 kHz. | +| `DigitalDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 at most 7.0 %** (0.372 ms, patch 5, rt 5.49) **/ S3 about 14.6 %** at patch 5 (about 0.78 ms corrected; 11.8 % as the tool reads it; rt 3.11), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed the S3 figure on 2026-09-28. No lean patch. RAM 155 584 B at the default 800 ms line (48 kHz); `max_time_ms` spends less | Boss DD-2 (1983), light touch: its control law and dry/wet discipline, Mix 0 a wire, Time bends the repeats in pitch at a rate Glide sets instead of clicking, every static Time on a whole frame. Repeat Tone and Repeat Cut put the pedal's 7 kHz and 40 Hz corners in the loop as knobs that default out; patch 5 is the pedal's own corners. `tail_samples` is finite with Repeat Cut out; Feedback is handed as set, and with Repeat Tone in the bound counts one landing lap at a stall-window centre (audiodsp v0.6.3rc1, #157). Both out stops are exactly 0, and either filter back in after silence plays nothing (at v0.6.2 Tone's played 26 443 LSB and Cut's 20 858). The node lands 18 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 18 at 22.05 kHz (patch 4 at 44.1 kHz: a click's first repeat reads 19 922 and 78), none at 48 kHz. **Digests:** six of seven patches identical on the P4, the S3 and the desktop; patch 5 identical board to board, and different from the desktop through single-precision Python floats in two derived settings (audiocomponents#75): `cut_hz` 0.035 % high (39.44766 Hz against 39.43366) and `damping_hz` one float32 step. **What the default surrenders:** no band limit and no compander, no HOLD, and Glide 0's instant knob clicks. | +| `SlapbackDelay` | audiodsp (`audioecho`) | 6 | 0 samples | **P4 at most 7.4 %** (0.396 ms corrected; 6.4 % as the tool reads it; rt 5.82) **/ S3 14.6-14.7 %** at the default and patches 0-4 and **15.2 % at patch 5** (0.813 ms corrected, the one patch with Tone in; 11.6 % as the tool reads it; rt 3.08), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed patch 5 on 2026-09-28 ("It passes"). No lean patch. RAM about 50 KB (a fixed 251 ms line) | Sam Phillips' two-Ampex-350 slap at Sun Studio: one mono repeat at 135 ms, handed as a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire; Time walks rather than clicks. Tone out is byte-identical to no filter whatever came before, and a Wow move glides over 20 ms (both since audiodsp v0.6.3rc1). The node lands 21 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 20 at 22.05 kHz, none at 48 kHz and no shipped patch. **Digests:** all seven patches identical on the P4 and the S3, and different from the desktop through single-precision Python floats in a derived setting (audiocomponents#75): `wow_depth_ms` 2.1 x 10^-5 to 6.8 x 10^-5 ms high, under 0.0001 cent (patch 4's Feedback also one float32 step). **What the default surrenders:** Tone out, so the repeat is as bright as the dry, and Wow at 1 cent makes a 15 kHz repeat breathe down to -5.11 dB at 48 kHz. | | Class | Notes | |---|---| diff --git a/lib/audioeffects/combfilter.py b/lib/audioeffects/combfilter.py index d17801c..568f664 100644 --- a/lib/audioeffects/combfilter.py +++ b/lib/audioeffects/combfilter.py @@ -101,36 +101,23 @@ tightest, 20 Hz at Feedback 0.7 on full-scale DC, ends 29 frames short of 69 629. -**With Tone in, the node has a floor of its own** (audiodsp#157): wherever -0.5 / (1 - Feedback) is within a hair of a whole number k, its loop -low-pass can rest a hair above k LSB and send it round for ever (1 LSB at -Feedback 0.5 and 2 at 0.75 with Tone at 2 kHz, on a 2 LSB DC). The -Feedback knob's top, 0.95, is one of those centres. So with Tone in, the -class hands the node a Feedback just outside each window, at the nearer -edge, at most 0.00003 from the one you set, and the knob still reads what -you set; the tail then reaches zero inside the bound. With Tone off, on -an instance that has not had Tone in since it was built or reset, nothing -is moved. - -**Tone off, after Tone has been in.** The node leaves its loop low-pass -frozen while it is out (`audiodsp_feedback_delay.c:493-497` at v0.6.2), and -up to 2026-09-28 this class's off stop handed it exactly that: bring Tone -back after the ring had died and it played what it held, out of silence, -15 070 LSB at 48 kHz (10 110 at 44.1, 8 828 at 22.05) after a 300 Hz tone -at 30 000 LSB, Feedback 0, Mix 2. So once Tone has been in circuit since -the last `reset()`, the off stop keeps the low-pass running at a -coefficient of exactly 1, which follows the line's tap sample for sample, -and Tone back in after silence is silent at every rate, stereo and mono -(the same cure as `DigitalDelay` and `SlapbackDelay`). Against the filter -truly out that is exact where the read lands on a whole frame (1000 Hz at -48 kHz) and within 1 LSB where it is fractional (440 Hz at every rate). -At a Feedback inside one of the node's stall windows (0.5, 0.75, 0.9 and -on up to the 0.95 stop) the off stop also hands the Feedback that Tone in -hands, moved clear by at most 0.00003, and there the ring differs from the -filter truly out by a few LSB: 7 at most in 2 s runs of noise at -30 to -0 dBFS, 20 / 440 / 4000 Hz, 48 and 44.1 kHz (2026-09-28). A fresh -instance, and one since a `reset()` that has not put Tone in, hands the -node exactly no filter, and a `reset()` makes the off stop exact again. +**With Tone in, the Feedback you set is the one the node plays.** Wherever +0.5 / (1 - Feedback) is within a hair of a whole number k (0.5, 0.75, and +the knob's top, 0.95, among them), the loop low-pass can rest a hair above +k LSB and hand it back. Up to audiodsp v0.6.2 it did so for ever (1 LSB at +Feedback 0.5 and 2 at 0.75 with Tone at 2 kHz, on a 2 LSB DC), and this +class handed the node a Feedback just clear of each window. Since +v0.6.3rc1 the node sets a stalled low-pass onto its input (audiodsp#157), +nothing is moved, and the bound counts one more lap there. + +**Tone off is off, after Tone has been in too.** Bring Tone back after the +ring has died and nothing plays: 0 LSB at 48, 44.1 and 22.05 kHz, stereo +and mono, after a 300 Hz tone at 30 000 LSB, Feedback 0, Mix 2. The off +stop hands the node exactly 0, and since audiodsp v0.6.3rc1 the node keeps +an off low-pass's state on the signal (audiodsp#158). Up to v0.6.2 it froze +that state and played it back here (15 070 LSB at 48 kHz); this class +cured it with a tracking off stop on 2026-09-28, and the cure came out +when the node was fixed. **Two traits this class does not have.** The *negative* comb, whose peaks sit on the odd half-multiples and which sounds hollow rather than pitched, is @@ -167,11 +154,9 @@ class hands the node a Feedback just outside each window, at the nearer # node rounds the same way here. Its module moves up one level when it comes # home, so both homes are tried. try: - from .rebuilt.digitaldelay import (TONE_TRACK_PER_RATE, - clear_of_stalls, laps_to_zero) + from .rebuilt.digitaldelay import laps_to_zero except ImportError: # pragma: no cover - after it lands - from .digitaldelay import (TONE_TRACK_PER_RATE, clear_of_stalls, - laps_to_zero) + from .digitaldelay import laps_to_zero #: The line, in milliseconds. 20 Hz wants 50 ms and the node keeps one frame @@ -332,11 +317,6 @@ def _build(self, frequency=440.0, feedback=0.7, mix=1.0, self._fresh = True self._feedback = 0.0 self._damping = 0.0 - #: True once Tone has been handed an in-circuit corner since the - #: node was built or cleared. From then on the loop low-pass's - #: state is live, and the off stop hands `TONE_TRACK_PER_RATE` x - #: the rate, not 0. - self._tone_used = False self._init_macros( (frequency, feedback, mix, tone_hz, trim_db, glide), patch) self._fresh = False @@ -365,14 +345,9 @@ def _refresh(self): frames = int(math.ceil(self._sample_rate / frequency)) if self._fresh or slew <= 0.0 or frames > self._reach: self._reach = frames + # Handed as set: since audiodsp v0.6.3rc1 the node lands a stalled + # loop low-pass (#157), so no Feedback is moved clear of a window. feedback = self._value(1) - if damping > 0.0 and feedback > 0.0: - # With Tone in, the node's loop low-pass can hold a small value - # for ever at a Feedback a hair either side of 1 - 0.5 / k - # (audiodsp#157; 0.5, 0.75 and the 0.95 stop are centres), so - # the node is handed the nearer edge of that window instead. - feedback = self._loop_feedback( - feedback, _tone_excess(damping, self._sample_rate)[1]) self._feedback = feedback self._damping = damping @@ -388,33 +363,20 @@ def _refresh(self): else 0.0) def _tone_damping(self, tone): - """The `damping_hz` handed to the node for a Tone of `tone` Hz. - - The node updates its loop low-pass only while the coefficient is - above 0 (`audiodsp_feedback_delay.c:493-497` at v0.6.2), so handing - 0 after Tone has been in would freeze whatever the filter held, and - a later Tone move would play it out of silence. At 32 x the rate the - coefficient is exactly 1.0f and the state follows the tap. On a node - whose loop filters track the signal while out this is the same - state, so the cure is right on both.""" + """The `damping_hz` handed to the node for a Tone of `tone` Hz: the + top of the travel is exactly 0, the filter off. Since audiodsp + v0.6.3rc1 the node keeps an off low-pass's state on the signal + (audiodsp#158), so off is off whatever came before.""" if tone < TONE_OFF_HZ: - self._tone_used = True return self._hz(tone) - if self._tone_used: - return TONE_TRACK_PER_RATE * self._sample_rate return 0.0 def _apply_macro(self, index, position): del index, position self._refresh() - def _loop_feedback(self, feedback, excess): - """The Feedback handed to the node with Tone in circuit.""" - return clear_of_stalls(feedback, excess) - def reset(self): self._fresh = True - self._tone_used = False _component.Component.reset(self) self._fresh = False @@ -434,8 +396,6 @@ def _tail_bound(self): MicroPython `property` has no `fget`).""" memory, excess = _tone_excess(self._damping, self._sample_rate) laps = laps_to_zero(self._feedback, excess) - if laps is None: # pragma: no cover - stepped clear - return None trim = 0 if self._trim.mix > 0.0: trim = int(math.ceil(TRIM_TAIL_S * self._sample_rate)) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 0c3c5f2..94c7230 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -74,8 +74,30 @@ While Time moves, the line is read between samples, and the two-tap read costs the top of the band sqrt(1 - 2 frac (1 - frac)(1 - cos 2 pi f / fs)) per pass: 5.1 dB at 15 kHz at a half frame, 48 kHz. Every static Time is -landed on the nearest whole frame at the running rate, where the read is -lossless, so the repeats of a Time you have stopped turning do not darken. +handed to the node as the nearest whole frame at the running rate, +floor(ms fs / 1000 + 0.5); the knob's milliseconds and `get_macro(0)` stay +as you set them. At 48 kHz the node lands every one of the 128 knob +positions exactly on that frame, where the read is lossless, so the +repeats of a Time you have stopped turning do not darken. + +At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds +back into frames in float32, and for some Times no float32 value lands on +the whole frame, so the read sits one float32 step off it. Among the 128 +knob positions that is 18 at 44.1 kHz (MIDI 8, 9, 13, 19, 29, 34, 52, 57, +61, 74, 75, 78, 80, 94, 101, 104, 118, 125) and 18 at 22.05 kHz (MIDI 8, +9, 13, 16, 34, 39, 51, 52, 57, 74, 75, 78, 80, 94, 101, 104, 118, 123), at +most 1/256 of a frame off at 44.1 kHz and 1/1024 at 22.05 kHz. A +constructor `time_ms` or a Time Sync takes from a host's tempo reaches +every whole frame from 12.5 to 800 ms, and 5 600 of those 34 730 frames +land off at 44.1 kHz and 2 809 of 17 365 at 22.05 kHz, up to 1/256 and +1/512 of a frame off. None do at 48 kHz, nor the default 350 ms at any +rate. One shipped patch does: patch 4 (Long Ambient, MIDI 125) at +44.1 kHz. At an off-frame Time each pass puts part of the repeat on the +frame beside it, and the repeats darken slowly: a 20 000 click's first +repeat reads 19 922 and 78 at patch 4's Time, 44.1 kHz (19 980 and 20 at +MIDI 104, 22.05 kHz, the worst there). The class cannot hand the node a +number that lands there; a node change is asked for. + Above Mix 1 the dry falls as 2 - Mix, by `audiodelays.Echo`'s convention. **Repeat Tone at a low rate.** The knob's corners clamp below Nyquist at @@ -85,33 +107,17 @@ and position 127 takes the filter out. At 44.1 and 48 kHz every position moves. -**Repeat Tone out, after it has been in.** The node leaves a loop filter -frozen while it is out, and a frozen low-pass would play what it held, -out of silence, when Tone came back in (26 443 LSB at Mix 2, 48 kHz, -before this was fixed on 2026-09-28). So once Tone has been in circuit -since the last `reset()`, the out stop keeps the low-pass running at a -coefficient of exactly 1, which follows the repeat sample for sample, and -Tone back in after silence is silent. Against the filter truly out that is -exact at a whole-frame read and 1 LSB where the read is fractional (while -Time glides, and where the node's single-precision delay misses the whole -frame, as `time_ms=136.054` at 44.1 kHz does). At a Feedback inside one of -Repeat Tone's stall windows (0.5, 0.75, 0.9 and on up to the 0.99 stop) -the out stop also hands the Feedback that Tone in hands, moved clear by -at most 0.00003, and the repeats differ from the filter truly out by a few -LSB: 51 at most in the runs at the 0.99 stop (8 s of -18 dBFS noise, Time -12.5 ms). The defaults, and anything since a reset that has not put Tone -in, hand the node exactly no filter; a `reset()` makes the out stop exact -again. - -**Repeat Cut back in, after it has been out.** Repeat Cut has the same -node defect and no such cure: its high-pass subtracts its state, so no -corner keeps that state live without cutting. Take Cut out while the -repeats play and bring it back in after they have died away, and the -frozen state plays out of silence: 20 858 LSB at its peak over 428 -samples at Mix 2, 48 kHz stereo, after a 300 Hz tone at 30 000 LSB with -Cut at 400 Hz (18 699 at 44.1 kHz, 16 352 at 22.05). A `reset()` clears -it. The fix is the node's (audiodsp's loop filters keeping their state -live while out), due after Phase 5. +**Repeat Tone and Repeat Cut back in, after they have been out.** Take +either filter out while the repeats play and bring it back in after they +have died away, and nothing plays: 0 LSB at 48, 44.1 and 22.05 kHz, +stereo and mono, after a 300 Hz tone at 30 000 LSB, Mix 2 (Tone at +2 kHz, Cut at 400 Hz). Both out stops hand the node exactly 0, which is +the filter out whatever came before. Up to audiodsp v0.6.2 the node froze +an out filter's state and played it back here (26 443 LSB for Tone, which +this class cured with a tracking out stop, and 20 858 for Cut, which it +could only disclose); since v0.6.3rc1 the node keeps the low-pass's state +on the signal and the high-pass's at zero while out (audiodsp#158, #159), +and the cure came out. A constructor `glide_ms` faster than the knob's fastest walk (under 814.6 ms, down to the 0.99 pin) stays on the audio path, and the knob @@ -176,18 +182,17 @@ 685 at 0.99 (nine minutes at Time 800 ms). Since audiodsp v0.6.2 the node steps a repeat toward zero wherever rounding would hand it back unchanged, so with both filters out the bound holds at every Feedback. -With Repeat Tone in circuit each lap is a little longer, and the node -has a second floor of its own: wherever 0.5 / (1 - Feedback) is within a -hair of a whole number k (0.5, 0.75, 0.83, 0.875, 0.9 and on up to the -knob's top, 0.99), its loop low-pass can come to rest a hair above k LSB -and send it round for ever (a 2 LSB DC at Feedback 0.5, Tone 800 Hz, -used to leave 1 LSB going round; at 0.9, 5 LSB). So with Repeat Tone in -the class hands the node a Feedback just outside each of those windows, -at the nearer edge, at most 0.00003 from the one you set (0.99 plays as -about 0.98998 and 0.5 as 0.49999), far inside one step of the knob, -which still reads what you set. The tail then reaches exact zero there too, inside -a finite `tail_samples`. With Repeat Cut in circuit `tail_samples` is -`None`: no bound is derived there. +With Repeat Tone in circuit each lap is a little longer. Wherever +0.5 / (1 - Feedback) is within a hair of a whole number k (0.5, 0.75, +0.83, 0.875, 0.9 and on up to the knob's top, 0.99), the loop low-pass +can come to rest a hair above k LSB and hand it back; up to audiodsp +v0.6.2 it did so for ever, and this class moved the Feedback it handed +the node clear of those windows. Since v0.6.3rc1 the node sets a stalled +low-pass onto its input (audiodsp#157), the Feedback you set is the one +the node plays, and `tail_samples` counts one more lap there: every window +centre, on a 2 LSB DC and on full scale, reaches exact zero inside it. +With Repeat Cut in circuit `tail_samples` is `None`: no bound is derived +there. `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). @@ -253,7 +258,9 @@ #: Feedback it hands the node, relative to the window's edge: many times a #: single-precision float's step (2^-24), so a board's arithmetic lands on #: the same side, and far under the window's own width (2-4 x 10^-5) and -#: the knob's 7-bit step (0.0078). +#: the knob's 7-bit step (0.0078). This class stopped stepping at audiodsp +#: v0.6.3rc1, whose node lands a stalled damping state (audiodsp#157); the +#: helper stays for the classes still built at v0.6.2. STALL_CLEARANCE = 2.0 ** -20 #: `stall_window` widens each window by this much, relatively, either side, @@ -263,16 +270,6 @@ #: never itself on the widened edge. STALL_FUZZ = 2.0 ** -22 -#: Repeat Tone out after it has been in: `damping_hz` at 32 x the rate, -#: where 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, -#: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows -#: the tap sample for sample instead of freezing on what it held -#: (`:493-497`). `SlapbackDelay`'s constant, for the same node defect. It -#: can differ from the filter truly out by 1 LSB only where the read is -#: fractional: while Time glides, and wherever the node's float32 -#: `delay_ms * rate / 1000` misses the whole frame the class asked for. -TONE_TRACK_PER_RATE = 32.0 - #: 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. @@ -338,7 +335,7 @@ def whole_frames(time_ms, sample_rate): def laps_to_zero(feedback, excess=0.0): """How many laps of the line can still hold a non-zero sample once the - input stops, or `None` where a lap could hand a sample back unchanged. + input stops. Finite at every Feedback since audiodsp v0.6.3rc1. Once the input stops, the node writes `to_s16(recirculated(s, f))` for each value s it sends round (`audiodsp_feedback_delay.c:529`). Since @@ -357,9 +354,19 @@ def laps_to_zero(feedback, excess=0.0): above the line's peak by up to `excess` times it (`DigitalDelay`'s `_tone_excess`). Then a lap maps x to the node's write at s = x (1 + excess), whichever branch it takes, and the guarantee is - gone: where that write is still x, the peak can stay there for ever. - The node does that (1 LSB at Feedback 0.5, 5 at 0.9, Tone 800 Hz, on - a DC input), so the count is `None` wherever the reckoning allows it. + gone: where that write is still x, the peak is handed back. Up to + audiodsp v0.6.2 it stayed there for ever (1 LSB at Feedback 0.5, 5 at + 0.9, Tone 800 Hz, on a DC input), and this count was `None` there. + From v0.6.3rc1 (audiodsp#157) the node sets a damping state that has + stopped moving onto its input, once a block, at or below 64 LSB, which + covers every such x (at most 50, at the node's 0.99). So the peak is + counted one more lap there and then leaves as with the filter out, + min(x - 1, floor(f x + 0.5)). The extra lap is measured, not derived + (the landing waits for a block's end, and a host picks the block): + at every window centre k = 1 ... 50, Repeat Tone grid 0, 64 and 126, + on a 2 LSB DC and on full scale, in blocks of 64 to 4 096 frames at + Time 12.5 ms, each tail ends inside the count with the extra lap left + out, 239 frames or more short of it (`pin063_stall_landing.py`). """ feedback = float(feedback) if feedback < 0.0: @@ -383,7 +390,12 @@ def laps_to_zero(feedback, excess=0.0): else: image = int(math.floor(up * sent)) if image >= peak: - return None + # The node's landing (audiodsp#157): one more lap, then the + # peak leaves as it does with the filter out. + laps += 1 + image = int(math.floor(up * peak + 0.5)) + if image >= peak: + image = peak - 1 peak = image return laps @@ -392,8 +404,8 @@ def stall_window(feedback, excess): """The Repeat Tone stall window `feedback` sits in, as (low, high), or `None` outside every window. - `laps_to_zero(f, excess)` is `None` exactly when some whole peak x it - reaches is handed back: the rounding branch taken, + `laps_to_zero(f, excess)` takes its extra landing lap exactly when + some whole peak x it reaches is handed back: the rounding branch taken, x (1 + excess)(1 - f (1 - m)) > 0.5, and the image not below x, f (1 + m) x (1 + excess) + 0.5 >= x, m being `FEEDBACK_MARGIN`. For each x that is one window, [(1 - 0.5 / x) / ((1 + m)(1 + excess)), @@ -423,7 +435,11 @@ def clear_of_stalls(feedback, excess): every window. The top window's upper edge is above the node's 0.99, so there it always moves down. Every move is under 3 x 10^-5 of Feedback (2.6 x 10^-5 at the 0.99 stop), far inside one step of the 7-bit knob, - so `get_macro(1)` still names the setting that plays.""" + so `get_macro(1)` still names the setting that plays. + + Not called by this class since audiodsp v0.6.3rc1 (the node lands a + stalled damping state, audiodsp#157); kept for the classes that import + it and are still built at v0.6.2.""" window = stall_window(feedback, excess) if window is None: return feedback @@ -517,11 +533,6 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, self._deferred = False self._feedback = 0.0 self._damping = 0.0 - #: True once Repeat Tone has been handed an in-circuit corner since - #: the node was built or cleared. From then on the loop low-pass's - #: state is live, and the out stop hands `TONE_TRACK_PER_RATE` x the - #: rate, not 0. - self._tone_used = False self._node_ms = 0.0 # 0 (or less) is how the node spells a filter out of circuit, so # Repeat Tone and Repeat Cut at 0 are their out stops; Time at 0 @@ -582,7 +593,6 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, def _clear(self): self._delay.clear() self._fresh = True - self._tone_used = False # -- the maps ------------------------------------------------------ @@ -706,22 +716,11 @@ def _refresh(self): feedback = FEEDBACK_MAX if feedback < 0.0: feedback = 0.0 + # Both out stops are exactly 0. Since audiodsp v0.6.3rc1 the node + # keeps an out filter's state live (#158, #159), and lands a damping + # state that has stopped moving (#157), so nothing is moved here. damping = self._tone_damping(self._macros[TONE_I]) - if damping > 0.0: - self._tone_used = True - elif self._tone_used: - # The node updates its loop low-pass only while the coefficient - # is above 0 (`audiodsp_feedback_delay.c:493-497`), so handing 0 - # after Repeat Tone has been in would freeze whatever the filter - # held, and a later Tone move would play it out of silence. A - # coefficient of exactly 1 keeps the state on the tap instead. - damping = TONE_TRACK_PER_RATE * self._sample_rate self._damping = damping - if damping > 0.0: - # With Repeat Tone in, the node can hold a small value for ever - # at Feedback values a hair either side of 1 - 0.5 / k; the node - # is handed the nearer edge of that window instead. - feedback = self._loop_feedback(feedback) self._feedback = feedback self._delay.set( delay_slew=slew, @@ -731,11 +730,6 @@ def _refresh(self): damping_hz=damping, cut_hz=self._cut_hz(self._macros[CUT_I])) - def _loop_feedback(self, feedback): - """The Feedback handed to the node with Repeat Tone in circuit: - `clear_of_stalls` at this Tone's excess.""" - return clear_of_stalls(feedback, self._tone_excess()[1]) - @property def tail_samples(self): """Frames until the output is exactly zero once the input stops, as @@ -748,10 +742,10 @@ 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 both filters out. With Repeat Tone - in, each lap is `memory` frames longer, the time the low-pass takes - to forget the lap before, and the count is `None` near the Feedback - values where the node can hold a small value for ever + 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, whose high-pass can more than double a peak in one pass, so the per-lap argument does not hold; the node measures that case rather than @@ -768,8 +762,6 @@ def _tail_bound(self): return None memory, excess = self._tone_excess() laps = laps_to_zero(self._feedback, excess) - if laps is None: - return None return int(laps * (self._reach + 1 + memory)) def _tone_excess(self): @@ -779,9 +771,7 @@ def _tone_excess(self): peak; and the single-precision state can rest up to 2^-24 / a of the peak above it, a being the coefficient, because a step a (v - y) under half an ulp rounds away. Read from the `damping_hz` - the node was handed, so the out stop after Tone has been in (the - low-pass tracking the tap at a coefficient of 1) counts one frame - of memory. (0, 0.0) with the filter truly out.""" + the node was handed. (0, 0.0) with the filter out.""" damping = self._damping if damping <= 0.0: return 0, 0.0 diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 0e4a97b..cec03dc 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -66,11 +66,29 @@ there. Wow is on at 1 cent, and wow moves the read head between samples, so the repeat's top end breathes: at 48 kHz a 15 kHz tone in the repeat swings between -0.03 and -5.11 dB (mean -2.61 dB) about 25 times a second; at -44.1 kHz between -0.02 and -6.38 dB (mean -3.35 dB). With Wow at 0 the -repeat loses nothing: every static Time is landed on the nearest whole frame -at the running rate, so the default 135 ms is 6 480 frames at 48 kHz and -5 954 frames (135.011 ms) at 44.1 kHz, not the 5 953.5 that would cost the -repeat 6.35 dB at 15 kHz for as long as it played. +44.1 kHz between -0.02 and -6.38 dB (mean -3.35 dB). With Wow at 0 every +static Time is handed to the node as the nearest whole frame at the +running rate, so the default 135 ms is 6 480 frames at 48 kHz and 5 954 +frames (135.011 ms) at 44.1 kHz, not the 5 953.5 that would cost the +repeat 6.35 dB at 15 kHz for as long as it played. At 48 kHz the node +lands every one of the 128 Time positions exactly on that frame, and the +repeat loses nothing. + +At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds +back into frames in float32, and for some Times no float32 value lands on +the whole frame, so the read sits one float32 step off it. Among the 128 +Time positions that is 21 at 44.1 kHz (MIDI 4, 8, 9, 10, 11, 38, 39, 40, +41, 49, 50, 53, 55, 60, 83, 86, 91, 93, 96, 99, 102) and 20 at 22.05 kHz +(MIDI 8, 10, 34, 38, 39, 40, 41, 45, 53, 60, 81, 83, 86, 91, 93, 96, 97, +98, 99, 102), at most 1/2048 of a frame off at 44.1 kHz and 1/4096 at +22.05 kHz. Of the 9 262 whole frames a constructor `time_ms` reaches +from 40 to 250 ms, 1 159 land off at 44.1 kHz, and 579 of 4 632 at +22.05 kHz, up to 1/1024 and 1/2048 of a frame. None do at 48 kHz, nor the +default 135 ms or any shipped patch's Time at any rate. There the repeat +puts a sliver on the frame beside it: a 20 000 click's repeat reads +19 618 and 10 at MIDI 60, 44.1 kHz (19 623 and 5 at 22.05 kHz), against +19 627 on the frame (the default Saturation's own loss). The class cannot +hand the node a number that lands there; a node change is asked for. **Saturation** is the node's cubic soft clip on the repeat, applied again on each pass when Repeats is up. On the repeat of a -6 dBFS tone the default @@ -84,42 +102,30 @@ 135 -> 85 ms takes 267 ms. There is no Glide knob; a slap's time is set, not played. -**Turning Wow while it plays steps.** The node takes a new wow depth at -once (`audiodsp_feedback_delay.c:457-458` adds depth x wow to the read -head, with no ramp), so the repeat jumps by the change in depth times -wherever the 0.7 Hz cycle is. On a 997 Hz tone at 12 000 LSB, Level 2, -48 kHz, a Wow move from grid 36 to 73 steps the output 7 684 LSB where the -tone's own largest step is 1 565, and 0 to 127 steps 23 037; near a zero of -the cycle the same moves barely show. A patch change that moves Wow does -the same: patch 0 to patch 2 (Doubling, the one patch with a different -Wow), tried on every block boundary of that tone, steps up to 5 503 LSB -against patch 0's own 2 107 at 48 kHz (5 680 against 2 293 at 44.1 kHz). -Time walks; Wow does not, so set it before you play. - -**Tone out, after Tone has been in.** The node leaves its loop low-pass -frozen while the filter is out, and a frozen filter would play what it -held when Tone came back in, out of silence. So once Tone has been in -circuit since the last `reset()`, the out stop keeps the filter running at -a coefficient of exactly 1, which follows the repeat sample for sample. -That is the out stop up to float rounding: against the filter truly out, -over 2 916 cells (Time, Wow, Saturation, Repeats and Level at three -settings each, three rates, stereo and mono, a full-scale ramp and noise), -5 640 of 166 430 700 samples differ, each by 1 LSB. None of those was at -Wow 0, but that run tried three Times only (40, 135 and 250 ms). Where -the node's single-precision `delay_ms * rate / 1000` misses the whole -frame the class asked for, Wow 0 differs by 1 LSB as well. That is 21 -of the 128 grid positions at 44.1 kHz and 20 at 22.05 kHz, and about one -whole-frame Time in eight at either rate (1 159 of 9 262, 579 of 4 632; -`time_ms=136.054` at 44.1 kHz is one); none at 48 kHz, and none at a -shipped patch's Time. At Repeats 0.5, the centre of the one stall window -Repeats reaches (see Tail), the out stop also hands the Feedback that -Tone in hands, moved clear by under 2.5 x 10^-5, and there it differs by -1 LSB at every Wow and rate: 28 624 of 384 000 samples at Wow 0, 48 kHz -(4 s of 0 dBFS noise, Level 0.35; 2026-09-28). The defaults, and anything -since a reset that has not put Tone in, hand the node exactly no filter. A Tone in the -constructor counts: `tone_hz=5000` and then patch 0, or `patch=5` and then patch 0, is the 1 LSB case (10 of -384 000 samples of 4 s of 0 dBFS noise at 48 kHz stereo), and a `reset()` -makes it exact again. +**Turning Wow while it plays glides.** Since audiodsp v0.6.3rc1 the node +ramps a new wow depth in over 20 ms (audiodsp#160), so the repeat bends +for those 20 ms instead of jumping. On a 997 Hz tone at 12 000 LSB, +Level 2, 48 kHz, a Wow move from grid 36 to 73 or from 0 to 127 steps the +output by no more than the tone's own largest step, 1 564 LSB, anywhere +in the 2 000 frames after it (up to v0.6.2 the same moves stepped 7 684 +and 23 037). While the depth travels, the extra pitch is the change over +20 ms times where the 0.7 Hz cycle is: 0 to 127 is 0.46 ms, up to 2.3 % +at the cycle's crest for those 20 ms. A patch change that moves Wow is a +click only as far as its Level jump makes it: patch 0 to patch 2 +(Doubling), tried on every block boundary of that tone, steps up to 3 416 +LSB against patch 0's own 2 106 at 48 kHz (3 569 against 2 292 at +44.1 kHz; 5 503 and 5 680 at v0.6.2), and 3 414 with Wow held at patch 0's, +so what is left is the Level moving at once, not the Wow. + +**Tone out is out, after Tone has been in too.** Bring Tone back in after +the repeat has died away and nothing plays: 0 LSB at 48, 44.1 and +22.05 kHz, stereo and mono, after a 300 Hz tone at 30 000 LSB with Tone at +2 kHz. The out stop hands the node exactly 0, and since audiodsp v0.6.3rc1 +the node keeps an out low-pass's state on the signal (audiodsp#158), so +Tone out after Tone in renders the same bytes as a fresh instance's Tone +out. Up to v0.6.2 the node froze that state and played it back; this class +cured it with a tracking out stop, 1 LSB off the filter truly out in +places, and the cure came out when the node was fixed. **A host that echoes Time back** (`set_macro(0, get_macro(0))`) keeps the constructor's exact Time: the 44.1 kHz default stays on 5 954 frames. @@ -141,11 +147,12 @@ **Tail.** `tail_samples` is an upper bound on how long the output takes to reach exact zero after your input stops: one lap of the line at Repeats 0 (6 488 frames at the defaults, 48 kHz), 11 laps at 0.35, 21 at the 0.6 -stop. With Tone in circuit the node's loop low-pass can hold a small value -for ever at a Feedback a hair either side of 0.5, which Repeats' span -reaches, so there the class hands the node a Feedback just outside that -window (under 2.5 x 10^-5 away, far inside one step of the knob, which still -reads what you set) and the tail reaches zero inside the bound. +stop. With Tone in circuit, at a Repeats a hair either side of 0.5 (which +the span reaches), the loop low-pass can rest a hair above 1 LSB and hand +it back; up to audiodsp v0.6.2 it did so for ever and the class moved +Repeats clear of it. Since v0.6.3rc1 the node sets a stalled low-pass onto +its input (audiodsp#157), Repeats is handed as set, the bound counts one +more lap there, and the tail reaches exact zero inside it. `capabilities = ()`: a slapback's time is a fixed distance over a fixed tape speed, with no musical relationship to a tempo, so the class never @@ -168,9 +175,9 @@ # rounds the same way in both classes. Its module moves up one level when # it comes home, so both homes are tried. try: - from .digitaldelay import clear_of_stalls, laps_to_zero, whole_frames + from .digitaldelay import laps_to_zero, whole_frames except ImportError: # pragma: no cover - after it lands - from ..digitaldelay import clear_of_stalls, laps_to_zero, whole_frames + from ..digitaldelay import laps_to_zero, whole_frames try: import audioecho @@ -192,19 +199,6 @@ TONE_MIN_HZ = 2000.0 TONE_MAX_HZ = 20000.0 -#: Tone out after Tone has been in: `damping_hz` at 32 x the rate, where -#: 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, -#: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows -#: the tap sample for sample instead of freezing. That is the identity up to -#: float rounding, which moved 5 640 of 166 430 700 samples by 1 LSB and -#: none by more (`slapbackdelay_fix_tone_track.py`). It can move one only -#: where the tap is fractional: with Wow on, and at Wow 0 wherever the node's -#: float32 `delay_ms * rate / 1000` misses the whole frame: 21 of the 128 -#: grid positions at 44.1 kHz and 20 at 22.05 kHz, about one whole-frame -#: Time in eight at either rate (1 159 of 9 262, 579 of 4 632), none at -#: 48 kHz. -TONE_TRACK_PER_RATE = 32.0 - #: The wow's fixed rate and the Wow knob's ceiling, in cents peak. WOW_HZ = 0.7 WOW_MAX_CENTS = 3.5 @@ -342,10 +336,6 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, self._deferred = False self._feedback = 0.0 self._damping = 0.0 - #: True once the loop low-pass has been handed an in-circuit corner - #: since the node was built or cleared. From then on its state is - #: live, and Tone out hands `TONE_TRACK_PER_RATE` x the rate, not 0. - self._tone_used = False self._wow_ms = 0.0 self._node_ms = 0.0 #: The constructor's Time, exactly, until macro 0 moves. Seeding a @@ -403,7 +393,6 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, def _clear(self): self._delay.clear() self._fresh = True - self._tone_used = False # -- the maps ------------------------------------------------------ @@ -468,23 +457,11 @@ def _refresh(self): self._node_ms = self._node_time_ms(self._frames) if self._fresh or self._frames > self._reach: self._reach = self._frames + # Tone out is exactly 0, and Repeats is handed as set: since + # audiodsp v0.6.3rc1 the node keeps an out low-pass on the signal + # (#158) and lands a stalled one (#157). damping = self._tone_damping(self._macros[TONE_I]) - if damping > 0.0: - self._tone_used = True - elif self._tone_used: - # The node updates its loop low-pass only while the coefficient - # is above 0 (`audiodsp_feedback_delay.c:493-497`), so handing 0 - # after Tone has been in would freeze whatever the filter held, - # and a later Tone move would play it out of silence. A - # coefficient of exactly 1 keeps the state on the tap instead. - damping = TONE_TRACK_PER_RATE * self._sample_rate feedback = _between(self._value(REPEATS_I), 0.0, REPEATS_MAX) - if damping > 0.0 and feedback > 0.0: - # With Tone in, the node can hold a small value for ever at a - # Feedback a hair either side of 1 - 0.5 / k (0.5 is inside the - # span); the node is handed the nearer edge of that window. - feedback = self._loop_feedback( - feedback, tone_excess(damping, self._sample_rate)[1]) self._feedback = feedback self._damping = damping self._wow_ms = self._wow_depth_ms(self._value(WOW_I)) @@ -499,10 +476,6 @@ def _refresh(self): wow_hz=WOW_HZ, wow_depth_ms=self._wow_ms) - def _loop_feedback(self, feedback, excess): - """The Feedback handed to the node with Tone in circuit.""" - return clear_of_stalls(feedback, excess) - @property def tail_samples(self): """Frames until the output is exactly zero once the input stops, as @@ -520,7 +493,5 @@ def _tail_bound(self): `fget`.""" memory, excess = tone_excess(self._damping, self._sample_rate) laps = laps_to_zero(self._feedback, excess) - if laps is None: # pragma: no cover - stepped clear - return None - wow = int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) + wow =int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) return int(laps * (self._reach + wow + 1 + memory)) diff --git a/tests/test_cpython_effects_combfilter.py b/tests/test_cpython_effects_combfilter.py index 7aa4d78..5153c0a 100644 --- a/tests/test_cpython_effects_combfilter.py +++ b/tests/test_cpython_effects_combfilter.py @@ -31,6 +31,7 @@ from audioeffects import _component # noqa: E402 from audioeffects import combfilter # noqa: E402 +from audioeffects.rebuilt.digitaldelay import clear_of_stalls # noqa: E402 #: The subject is named directly. `CombFilter` has come home to #: `audioeffects/combfilter.py`. These tests still import the home module so #: a planted-fault subclass is measured against this file, not only @@ -186,20 +187,52 @@ class ShortLatencyCombFilter(combfilter.CombFilter): class FrozenToneCombFilter(combfilter.CombFilter): - """Tone's off stop handing the node exactly 0 even after Tone has been - in, so the loop low-pass freezes on what it held and a later Tone move - plays it out of silence. The class up to 2026-09-28 (15 070 LSB at - 48 kHz on `TheToneOffStopTracksTheLine`'s move).""" + """Tone's off stop leaving the low-pass in at 0.001 Hz, where its + float32 coefficient is one step above 0 and its state cannot move: it + holds what it held, as the node's off stop did up to audiodsp v0.6.2 + (15 070 LSB at 48 kHz on `TheToneOffStopIsTheFilterOut`'s move then), + and plays it out of silence.""" NAME = 'CombFilter' def _tone_damping(self, tone): if tone >= combfilter.TONE_OFF_HZ: - return 0.0 + return 0.001 return self._hz(tone) -class LeakyTrackCombFilter(combfilter.CombFilter): +class _ToneMemory: + """Remembers whether Tone has been in since the last reset, for the + off-stop faults below (the class itself no longer needs to).""" + + def _tone_damping(self, tone): + if tone < combfilter.TONE_OFF_HZ: + self._was_in = True + return combfilter.CombFilter._tone_damping(self, tone) + + def reset(self): + self._was_in = False + combfilter.CombFilter.reset(self) + + def _off_after_in(self, tone): + return getattr(self, "_was_in", False) and \ + tone >= combfilter.TONE_OFF_HZ + + +class TrackingCombFilter(_ToneMemory, combfilter.CombFilter): + """The workaround retired at audiodsp v0.6.3rc1: once Tone has been in, + the off stop hands `damping_hz` at 32 x the rate instead of 0.""" + + NAME = 'CombFilter' + + def _tone_damping(self, tone): + damping = _ToneMemory._tone_damping(self, tone) + if self._off_after_in(tone): + return 32.0 * self._sample_rate + return damping + + +class LeakyTrackCombFilter(_ToneMemory, combfilter.CombFilter): """The off stop after Tone has been in, tracking the tap at a coefficient under 1: `damping_hz` at half the rate (a = 1 - e^-pi, 0.957), a low-pass left in the loop where the knob says off.""" @@ -207,8 +240,8 @@ class LeakyTrackCombFilter(combfilter.CombFilter): NAME = 'CombFilter' def _tone_damping(self, tone): - damping = combfilter.CombFilter._tone_damping(self, tone) - if self._tone_used and tone >= combfilter.TONE_OFF_HZ: + damping = _ToneMemory._tone_damping(self, tone) + if self._off_after_in(tone): return 0.5 * self._sample_rate return damping @@ -792,14 +825,20 @@ def tail_samples(self): return bound -class UnsteppedCombFilter(combfilter.CombFilter): - """With Tone in, the Feedback handed as set, inside a stall window of - audiodsp#157, where the loop low-pass holds a small value for ever.""" +class SteppedCombFilter(combfilter.CombFilter): + """The workaround retired at audiodsp v0.6.3rc1: with Tone in, the + Feedback handed at the nearer edge of a stall window + (`clear_of_stalls`), a Feedback nobody set.""" NAME = 'CombFilter' - def _loop_feedback(self, feedback, excess): - return feedback + def _refresh(self): + combfilter.CombFilter._refresh(self) + if self._damping > 0.0 and self._feedback > 0.0: + excess = combfilter._tone_excess(self._damping, + self._sample_rate)[1] + self._feedback = clear_of_stalls(self._feedback, excess) + self._comb.set(feedback=self._feedback) class TheTailIsDeclared(unittest.TestCase): @@ -880,33 +919,36 @@ def test_the_trim_term_covers_the_shelf(self): **options) self.assertTrue(last > declared or held, (declared, last, held)) - def test_the_stall_window_is_stepped_clear(self): - # Feedback 0.5 with Tone at 2 kHz is a stall centre: the node held - # 1 LSB for ever there when handed 0.5 (2 LSB at 0.75). + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 with Tone at 2 kHz is a stall centre: up to audiodsp + # v0.6.2 the node held 1 LSB there for ever and the class stepped + # the Feedback clear. Since v0.6.3rc1 (#157) the node lands the + # stalled state: 0.5 is handed as set, and the tail ends inside the + # bound, which counts its landing lap. options = {"frequency": 1000.0, "feedback": 0.5, "tone_hz": 2000.0, "glide": 0.0} declared, last, held = self.render(combfilter.CombFilter, 2, **options) + self.assertGreater(last, 0) self.assertLessEqual(last, declared) self.assertEqual(held, 0) - _declared, _last, held = self.render(UnsteppedCombFilter, 2, - **options) - self.assertEqual(held, 1) - + for cls, handed_as_set in ((combfilter.CombFilter, True), + (SteppedCombFilter, False)): + effect = cls(silence_source(SAMPLE_RATE), **options) + self.assertEqual(effect._feedback == effect._value(1), + handed_as_set, cls) + self.assertLess(abs(effect._feedback - 0.5), 3e-5) + effect.deinit() -class TheToneOffStopTracksTheLine(unittest.TestCase): - """Tone off after Tone has been in (2026-09-28, Brad's ruling of that - date). `audioecho.FeedbackDelay` freezes its loop low-pass while - `damping_hz` is 0 (`audiodsp_feedback_delay.c:493-497` at v0.6.2), so - once Tone has been in the off stop hands 32 x the rate, a coefficient - of exactly 1, and the state follows the tap. - **When the node is fixed** (its loop filters tracking the signal while - out), the planted `FrozenToneCombFilter` in - `test_tone_back_in_after_silence_stays_silent` goes silent and that - assertion goes red: that is the signal that the class-side workaround - can come out. Nothing else here changes on such a node, because a - coefficient of 1 is the state such a node keeps by itself.""" +class TheToneOffStopIsTheFilterOut(unittest.TestCase): + """Tone off after Tone has been in. Up to audiodsp v0.6.2 the node froze + its loop low-pass while `damping_hz` was 0, and from 2026-09-28 this + class handed 32 x the rate there instead (a coefficient of exactly 1). + Since v0.6.3rc1 the node keeps an off low-pass's state on the tap + (audiodsp#158), the off stop is exactly 0 again, and these assert what + that buys: silence when Tone comes back, and the same bytes and bound + as a fresh instance. The retired cure is planted beside each.""" def tone_back_in(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, mix=2.0): @@ -947,42 +989,36 @@ def test_tone_back_in_after_silence_stays_silent(self): (rate, channels)) self.assertEqual(self.tone_back_in(combfilter.CombFilter, mix=1.0), 0) - # Planted: the off stop frozen at 0 plays the held state back - # (15 070 LSB at 48 kHz, 10 110 at 44.1, 8 828 at 22.05 when this - # was written). Red here on a node that keeps the state live while - # out: the workaround can then come out. + # Planted: a low-pass left in with a frozen state (0.001 Hz) plays + # what it held (at v0.6.2 the node's own off stop played 15 070 LSB + # at 48 kHz, 10 110 at 44.1, 8 828 at 22.05 here). self.assertGreater(self.tone_back_in(FrozenToneCombFilter), 10000) self.assertGreater(self.tone_back_in(FrozenToneCombFilter, 22050), 5000) - def test_the_off_stop_hands_no_filter_until_tone_has_been_in(self): + def test_the_off_stop_hands_exactly_zero(self): + # Fresh, after Tone has been in, after a Tone in the constructor or + # a patch, and at the 0.95 stall centre, which is handed as set. + # The retired tracking cure, planted, hands 32 x the rate. for rate in (48000, 44100, 22050): - effect = combfilter.CombFilter(silence_source(rate), - sample_rate=rate) - self.assertEqual(effect._damping, 0.0) - effect.set_macro(1, 127) # the 0.95 stall centre - self.assertEqual(effect._feedback, 0.95) - effect.set_macro(3, 0) - effect.set_macro(3, 127) - self.assertEqual(effect._damping, - combfilter.TONE_TRACK_PER_RATE * rate) - self.assertNotEqual(effect._feedback, 0.95) - self.assertLess(abs(effect._feedback - 0.95), 3e-5) - self.assertIsInstance(effect.tail_samples, int) - effect.reset() - self.assertEqual(effect._damping, 0.0) - effect.set_macro(1, 127) - self.assertEqual(effect._feedback, 0.95) - effect.deinit() - # A Tone in the constructor counts, and so does a patch. - for options in ({"tone_hz": 5000.0}, {"patch": 3}): - effect = combfilter.CombFilter(silence_source(rate), - sample_rate=rate, **options) - effect.program_change(0) - self.assertEqual(effect._damping, - combfilter.TONE_TRACK_PER_RATE * rate, - options) + for cls, expected in ((combfilter.CombFilter, 0.0), + (TrackingCombFilter, 32.0 * rate)): + effect = cls(silence_source(rate), sample_rate=rate) + self.assertEqual(effect._damping, 0.0) + effect.set_macro(1, 127) + effect.set_macro(3, 0) + effect.set_macro(3, 127) + self.assertEqual(effect._damping, expected, (cls, rate)) + self.assertEqual(effect._feedback, 0.95) + self.assertIsInstance(effect.tail_samples, int) effect.deinit() + for options in ({"tone_hz": 5000.0}, {"patch": 3}): + effect = cls(silence_source(rate), sample_rate=rate, + **options) + effect.program_change(0) + self.assertEqual(effect._damping, expected, + (cls, rate, options)) + effect.deinit() def off_after_tone(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, **options): @@ -1012,33 +1048,39 @@ def off_after_tone(self, cls, rate=SAMPLE_RATE, channels=CHANNELS, return sum(1 for d in diff if d), max(diff) def test_the_off_stop_after_tone_is_the_filter_out(self): - # A whole-frame read (1000 Hz at 48 kHz) is exact; a fractional one - # (440 Hz, 109.09 frames) is within 1 LSB of float rounding. + # Byte for byte a fresh instance's off stop, at a whole-frame read + # (1000 Hz at 48 kHz) and a fractional one (440 Hz, 109.09 frames, + # at 48 and 22.05 kHz mono), where the retired tracking cure was + # within 1 LSB. options = {"frequency": 1000.0, "feedback": 0.8, "mix": 2.0} self.assertEqual(self.off_after_tone(combfilter.CombFilter, **options), (0, 0)) for rate in (48000, 22050): - _count, worst = self.off_after_tone(combfilter.CombFilter, rate, - 1) - self.assertLessEqual(worst, 1, rate) - # Planted: a coefficient of 0.957 is a low-pass left in the loop. + self.assertEqual(self.off_after_tone(combfilter.CombFilter, + rate, 1), (0, 0), rate) + # Planted: the retired cure moves samples at the fractional read, + # and a coefficient of 0.957 is a low-pass left in the loop. + count, _worst = self.off_after_tone(TrackingCombFilter, 48000, 1) + self.assertGreater(count, 0) _count, worst = self.off_after_tone(LeakyTrackCombFilter, **options) self.assertGreater(worst, 1000) - def test_the_tail_bound_holds_on_the_tracking_off_stop(self): - # The bound counts the tracking filter's one frame of memory and - # the stepped Feedback; the 0.95 stop at 20 Hz is the long corner. + def test_the_tail_bound_after_tone_is_the_fresh_one(self): + # Tone off after Tone is the filter off, so the bound is the fresh + # instance's, with no memory term; the retired cure counted one + # frame of memory a lap. The 0.95 stop at 20 Hz is the long corner. for rate in (48000, 22050): - effect = combfilter.CombFilter(silence_source(rate), - sample_rate=rate, frequency=20.0, - feedback=0.95, glide=0.0) - fresh_bound = effect.tail_samples - effect.set_macro(3, 0) - effect.set_macro(3, 127) - tracked_bound = effect.tail_samples - effect.deinit() - self.assertGreater(tracked_bound, fresh_bound) - self.assertLess(tracked_bound, 2 * fresh_bound) + bounds = [] + for cls in (combfilter.CombFilter, TrackingCombFilter): + effect = cls(silence_source(rate), sample_rate=rate, + frequency=20.0, feedback=0.95, glide=0.0) + fresh_bound = effect.tail_samples + effect.set_macro(3, 0) + effect.set_macro(3, 127) + bounds.append((fresh_bound, effect.tail_samples)) + effect.deinit() + self.assertEqual(bounds[0][0], bounds[0][1], rate) + self.assertGreater(bounds[1][1], bounds[1][0], rate) class TheRingIsTheAskedPitchAndEnds(unittest.TestCase): diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index 477b87d..dd1aec7 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -51,6 +51,18 @@ class of the node's walk, and the edge test goes red at the old edge. A node's walk (`t2_segments`), which goes red at grid 3.0079 where the whole-walk fit passed, and the rising edge moves to Glide grid 4. A NaN `glide_ms` is the jump and a NaN `max_time_ms` is 800 ms. + +The pin's move to audiodsp v0.6.3rc1 (2026-09-28): the node keeps an out +loop filter's state live and lands a stalled damping state (audiodsp#158, +#159, #157), so the class's tracking out stop and its stall-window stepping +came out. The tests that pinned the old node or proved a workaround was +needed now assert the good behaviour directly: either filter back in after +silence is silent (planted: a filter left in at 0.001 Hz, frozen), both out +stops hand exactly 0 and render a fresh instance's bytes (planted: the +retired tracking stop), every stall cell reaches zero at the Feedback set +inside a finite bound (planted: the retired stepping, and the v0.6.2 +reckoning that says `None`). Two surface tests pin the Times the node lands +off the whole frame at 44.1 and 22.05 kHz, as `PingPongDelay`'s do. """ import math @@ -492,43 +504,108 @@ def tail_samples(self): return int(laps * (self._reach + 1)) -class StallWindowToneDelay(DigitalDelay): - """Re-audit fix round 1's class with Repeat Tone in: the node handed - the Feedback asked for, inside the stall windows, where the class said - `None` and the node held 1-5 LSB for ever (1 at Feedback 0.5, 5 at - 0.9, Tone 800 Hz, a 2 LSB DC).""" +class SteppedToneDelay(DigitalDelay): + """The workaround retired at audiodsp v0.6.3rc1: with Repeat Tone in, + the node handed the nearer edge of a stall window (`clear_of_stalls`) + instead of the Feedback asked for, up to 2.6 x 10^-5 away. On the + fixed node it plays a Feedback nobody set.""" NAME = 'DigitalDelay' - def _loop_feedback(self, feedback): - return feedback + def _refresh(self): + DigitalDelay._refresh(self) + if self._damping > 0.0: + self._feedback = dd.clear_of_stalls(self._feedback, + self._tone_excess()[1]) + self._delay.set(feedback=self._feedback) + + +class WindowNoneToneDelay(DigitalDelay): + """The v0.6.2 reckoning with the stepping gone: `None` wherever a lap + hands a peak back, the node's landing not counted. Declares no bound at + every stall cell.""" + + NAME = 'DigitalDelay' + + @property + def tail_samples(self): + if self._macros[CUT_I] > 0.0: + return None + memory, excess = self._tone_excess() + if dd.stall_window(self._feedback, excess) is not None: + return None + return int(dd.laps_to_zero(self._feedback, excess) + * (self._reach + 1 + memory)) + + +class _ToneMemory: + """Remembers whether Repeat Tone has been in since the last clear, for + the out-stop faults below (the class itself no longer needs to).""" + + def _refresh(self): + DigitalDelay._refresh(self) + if self._damping > 0.0: + self._was_in = True + self._after_refresh() + + def _clear(self): + DigitalDelay._clear(self) + self._was_in = False + + def _out_after_in(self): + return getattr(self, "_was_in", False) and \ + self._macros[TONE_I] >= 1.0 class FrozenToneDelay(DigitalDelay): - """Tier 1's silence clause after a Repeat Tone move: the out stop hands - the node exactly 0 even after Tone has been in, so the loop low-pass - freezes on whatever it held and a later Tone move plays it out of - silence. The class up to the housekeeping round (2026-09-28), and - `FrozenToneSlapback`'s defect.""" + """Tier 1's silence clause after a Repeat Tone move: the out stop + leaves the low-pass in at 0.001 Hz, where its float32 coefficient is + one step above 0 and the state cannot move, so it holds what it held, + as the node's out stop did up to v0.6.2, and plays it out of silence + (26 112 LSB after the move at Mix 2, 48 kHz, and already before it).""" NAME = 'DigitalDelay' def _refresh(self): DigitalDelay._refresh(self) if self._macros[TONE_I] >= 1.0: - self._delay.set(damping_hz=0.0) + self._delay.set(damping_hz=0.001) + + +class FrozenCutDelay(DigitalDelay): + """The same for Repeat Cut: the out stop leaves the high-pass in at + 0.001 Hz, its state frozen (20 666 LSB after the move at 48 kHz).""" + + NAME = 'DigitalDelay' + + def _refresh(self): + DigitalDelay._refresh(self) + if self._macros[CUT_I] <= 0.0: + self._delay.set(cut_hz=0.001) -class LeakyTrackDelay(DigitalDelay): +class TrackingToneDelay(_ToneMemory, DigitalDelay): + """The workaround retired at audiodsp v0.6.3rc1: once Repeat Tone has + been in, the out stop hands `damping_hz` at 32 x the rate (a + coefficient of exactly 1) instead of 0. On the fixed node it is the + filter out only up to float rounding.""" + + NAME = 'DigitalDelay' + + def _after_refresh(self): + if self._out_after_in(): + self._delay.set(damping_hz=32.0 * self._sample_rate) + + +class LeakyTrackDelay(_ToneMemory, DigitalDelay): """The out stop after Repeat Tone has been in, tracking the tap at a coefficient under 1: `damping_hz` at half the rate (a = 1 - e^-pi, 0.957), a low-pass left in circuit where the knob says out.""" NAME = 'DigitalDelay' - def _refresh(self): - DigitalDelay._refresh(self) - if self._tone_used and self._macros[TONE_I] >= 1.0: + def _after_refresh(self): + if self._out_after_in(): self._delay.set(damping_hz=0.5 * self._sample_rate) @@ -1515,8 +1592,9 @@ def test_tail_samples_follows_time_and_feedback(self): self.assertEqual(effect.tail_samples, 15 * 601) # Repeat Tone in: each lap is the low-pass's forgetting time longer # (22 frames at grid 100, 48 kHz). Feedback 0.99 sits in a stall - # window, so the node is handed the window's lower edge, a hair - # under 0.99, the knob still reads 127, and the bound is finite. + # window; since audiodsp v0.6.3rc1 the node lands a stalled state + # (#157), so the node is handed 0.99 itself and the count takes its + # one landing lap there. effect.set_macro(TONE_I, 100) self.assertEqual(effect._tone_excess()[0], 22) self.assertEqual(effect.tail_samples, 15 * (601 + 22)) @@ -1524,28 +1602,24 @@ def test_tail_samples_follows_time_and_feedback(self): self.assertEqual(effect.tail_samples, 17 * (601 + 22)) effect.set_macro(FEEDBACK_I, 127) self.assertEqual(effect.get_macro(FEEDBACK_I), 127.0) - self.assertLess(effect._feedback, 0.99) - self.assertGreater(effect._feedback, 0.99 - 3e-5) + self.assertEqual(effect._feedback, 0.99) laps = dd.laps_to_zero(effect._feedback, effect._tone_excess()[1]) - self.assertIsNotNone(laps) self.assertEqual(effect.tail_samples, laps * (601 + 22)) - # Tone back out after it has been in: the low-pass tracks the tap - # at a coefficient of 1 (one frame of memory), so the Feedback stays - # clear of the stall window as with Tone in (housekeeping, - # 2026-09-28). An instance that never had Tone in hands 0.99 and - # one lap per frame of the line. + # Tone back out after it has been in is the filter out: exactly 0 + # handed, no memory, and the same bound as an instance that never + # had Tone in, 685 laps of the line at 0.99. effect.set_macro(TONE_I, 127) - self.assertEqual(effect._damping, dd.TONE_TRACK_PER_RATE * RATE) - self.assertLess(effect._feedback, 0.99) - self.assertEqual(effect._tone_excess()[0], 1) - laps = dd.laps_to_zero(effect._feedback, effect._tone_excess()[1]) - self.assertEqual(effect.tail_samples, laps * (601 + 1)) + self.assertEqual(effect._damping, 0.0) + self.assertEqual(effect._feedback, 0.99) + self.assertEqual(effect.tail_samples, 685 * 601) fresh = copy_of(effect, silence_src(512)) - self.assertEqual(fresh._feedback, 0.99) self.assertEqual(fresh.tail_samples, 685 * 601) fresh.deinit() - self.assertEqual(dd.laps_to_zero(0.99, 1e-5), None) - self.assertEqual(dd.laps_to_zero(0.5, 1e-5), None) + # A window centre counts its landing lap: one more than the filter + # out at 0.5 and 0.99 (16 and 685); outside a window, 0.7, the + # excess alone decides. + self.assertEqual(dd.laps_to_zero(0.5, 1e-5), 17) + self.assertEqual(dd.laps_to_zero(0.99, 1e-5), 686) self.assertEqual(dd.laps_to_zero(0.7, 1e-5), 29) effect.set_macro(FEEDBACK_I, 0) effect.set_macro(CUT_I, 1) @@ -1578,6 +1652,66 @@ def test_filter_stops_are_exactly_zero(self): self.assertAlmostEqual(dd.nominal_cut_hz(400.0, 44100), 378.3309, places=3) + def test_where_the_node_lands_the_handed_frame(self): + # The node turns the handed ms back into frames in float32 + # (`audiodsp_feedback_delay.c:148`). At 48 kHz every knob position + # lands exactly; at 44.1 and 22.05 kHz these land one float32 step + # off, which the class cannot avoid (the node ask is drafted: + # `audiodsp-feedback-delay-whole-frame-landing.md`). Goes red when + # the node lands every whole frame. + off_frame = { + 48000: [], + 44100: [8, 9, 13, 19, 29, 34, 52, 57, 61, 74, 75, 78, 80, 94, + 101, 104, 118, 125], + 22050: [8, 9, 13, 16, 34, 39, 51, 52, 57, 74, 75, 78, 80, 94, + 101, 104, 118, 123], + } + f32 = np.float32 + with NodeSpy(): + for rate, worst in ((48000, 0.0), (44100, 2.0 ** -8), + (22050, 2.0 ** -10)): + effect = DigitalDelay(silence_src(64, 2, rate), + sample_rate=rate) + missed = [] + for midi in range(128): + effect.set_macro(TIME_I, midi) + ms = f32(effect._delay._handed["delay_ms"]) + frames = float((ms * f32(rate)) / f32(1000.0)) + law = mapped_frames(midi / 127.0, rate) + self.assertEqual(effect._frames, law, (rate, midi)) + if frames != law: + self.assertLessEqual(abs(frames - law), worst, + (rate, midi)) + missed.append(midi) + effect.deinit() + self.assertEqual(missed, off_frame[rate], rate) + + def test_an_off_frame_time_leaks_into_the_next_frame(self): + # Patch 4 (MIDI 125) at 44.1 kHz is 33 043 frames, which the node + # lands 1/256 of a frame short: a 20 000 click's repeat reads 78 + # one frame early. The default 350 ms and 48 kHz are exact; a Time + # handed half a frame off, planted, leaks at 48 kHz. + def window(cls, rate, patch=None): + probe = cls(silence_src(64, 2, rate), sample_rate=rate, + patch=patch) + frames = probe._frames + probe.deinit() + values = [0] * (frames + 64) + values[0] = 20000 + effect = cls(array_src(values, 2, rate), sample_rate=rate, + patch=patch, feedback=0.0, mix=2.0) + if patch is not None: + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 127) + y = left(pull(effect, frames + 64, 2), 2) + effect.deinit() + return [int(v) for v in y[frames - 1:frames + 2]] + + self.assertEqual(window(DigitalDelay, 44100, 4), [78, 19922, 0]) + self.assertEqual(window(DigitalDelay, 48000, 4), [0, 20000, 0]) + self.assertEqual(window(DigitalDelay, 44100), [0, 20000, 0]) + self.assertNotEqual(window(HalfFrameDelay, 48000, 4), [0, 20000, 0]) + # -------------------------------------------------------------------------- # Tier 2 rows @@ -1867,67 +2001,79 @@ def move(frame): return int(np.abs(y[back * channels:]).max()) def test_tone_back_in_after_silence_stays_silent(self): - # The node freezes a loop filter's state while its corner is 0 - # (`audiodsp_feedback_delay.c:493-497`), so the out stop keeps the - # low-pass tracking the tap once Tone has been in. + # Since audiodsp v0.6.3rc1 the node keeps an out low-pass's state + # on the tap (#158), so the out stop is exactly 0 and Tone back in + # after silence plays nothing. for rate in (48000, 44100, 22050): for channels in (2, 1): self.assertEqual(self._tone_back_in(DigitalDelay, rate, channels), 0, (rate, channels)) self.assertEqual(self._tone_back_in(DigitalDelay, mix=0.3), 0) - # Planted: the out stop frozen at 0 plays the held state back - # (26 443 LSB at Mix 2, 48 kHz, when this test was written). + # Planted: a low-pass left in with a frozen state (0.001 Hz) plays + # what it held (26 112 LSB at Mix 2, 7 834 at Mix 0.3, 48 kHz). self.assertGreater(self._tone_back_in(FrozenToneDelay), 20000) self.assertGreater(self._tone_back_in(FrozenToneDelay, mix=0.3), 3000) - def test_cut_back_in_after_silence_is_the_disclosed_node_defect(self): - # Repeat Cut's frozen state has no class-side cure (its high-pass - # subtracts the state), so the docstring discloses what it plays. - # When the node keeps a filter's state live while out, this goes - # red and the docstring's paragraph comes out with it. - peaks = [] - for rate in (48000, 44100, 22050): - loud = (rate // 2) // BLOCK * BLOCK - back = (loud + 2 * rate) // BLOCK * BLOCK - values = [0] * (back + rate // 4) - values[:loud] = sine_values(300.0, loud, rate, 30000) - effect = DigitalDelay(array_src(values, 2, rate), - sample_rate=rate, cut_hz=400.0, - feedback=0.0, mix=2.0) - - def move(frame, effect=effect, loud=loud, back=back): - if frame == loud: - effect.set_macro(CUT_I, 0) - elif frame == back: - effect.set_macro(CUT_I, 127) - - y = pull(effect, len(values), 2, on_block=move) - effect.deinit() - peaks.append(int(np.abs(y[back * 2:]).max())) - self.assertEqual(peaks, [20858, 18699, 16352]) + def _cut_back_in(self, cls, rate=RATE, channels=2): + """The same move on Repeat Cut at 400 Hz: out as the tone stops, + back in after 2 s of silence; the peak after the move.""" + loud = (rate // 2) // BLOCK * BLOCK + back = (loud + 2 * rate) // BLOCK * BLOCK + values = [0] * (back + rate // 4) + values[:loud] = sine_values(300.0, loud, rate, 30000) + effect = cls(array_src(values, channels, rate), sample_rate=rate, + cut_hz=400.0, feedback=0.0, mix=2.0) + + def move(frame): + if frame == loud: + effect.set_macro(CUT_I, 0) + elif frame == back: + effect.set_macro(CUT_I, 127) - def test_tone_out_hands_no_filter_until_tone_has_been_in(self): + y = pull(effect, len(values), channels, on_block=move) + effect.deinit() + return int(np.abs(y[back * channels:]).max()) + + def test_cut_back_in_after_silence_stays_silent(self): + # Up to audiodsp v0.6.2 the node froze the high-pass state while + # Cut was out and this played 20 858 / 18 699 / 16 352 LSB, which + # the class could only disclose; since v0.6.3rc1 the node holds it + # at zero (#159). + for rate in (48000, 44100, 22050): + for channels in (2, 1): + self.assertEqual(self._cut_back_in(DigitalDelay, rate, + channels), 0, + (rate, channels)) + # Planted: a high-pass left in with a frozen state (0.001 Hz). + for rate, floor in ((48000, 20000), (44100, 18000), (22050, 15000)): + self.assertGreater(self._cut_back_in(FrozenCutDelay, rate), + floor, rate) + + def test_the_out_stops_hand_exactly_zero(self): + # After Tone has been in, after a patch with Tone in, and fresh: + # the out stop is 0, the filter out. The retired tracking cure, + # planted, hands 32 x the rate there. with NodeSpy(): for rate in (48000, 44100, 22050): - effect = DigitalDelay(silence_src(64, 2, rate), - sample_rate=rate) - self.assertEqual(effect._delay._handed["damping_hz"], 0.0) - effect.set_macro(TONE_I, 0) - effect.set_macro(TONE_I, 127) - self.assertEqual(effect._delay._handed["damping_hz"], - dd.TONE_TRACK_PER_RATE * rate) - self.assertIsNotNone(effect.tail_samples) - effect.reset() - self.assertEqual(effect._delay._handed["damping_hz"], 0.0) - effect.deinit() - effect = DigitalDelay(silence_src(64, 2, rate), - sample_rate=rate, patch=5) - effect.program_change(0) - self.assertEqual(effect._delay._handed["damping_hz"], - dd.TONE_TRACK_PER_RATE * rate) - effect.deinit() + for cls, expected in ((DigitalDelay, 0.0), + (TrackingToneDelay, 32.0 * rate)): + effect = cls(silence_src(64, 2, rate), sample_rate=rate) + self.assertEqual(effect._delay._handed["damping_hz"], + 0.0) + effect.set_macro(TONE_I, 0) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + effect.deinit() + effect = cls(silence_src(64, 2, rate), sample_rate=rate, + patch=5) + effect.program_change(0) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + self.assertEqual(effect._delay._handed["cut_hz"], 0.0) + effect.deinit() def _out_after_tone(self, cls, rate=RATE, channels=2, **options): """Samples and worst LSB by which the out stop after Tone has been @@ -1947,16 +2093,21 @@ def _out_after_tone(self, cls, rate=RATE, channels=2, **options): fresh.deinit() return int((diff > 0).sum()), int(diff.max()) - def test_tone_out_after_tone_tracks_the_tap(self): - # Outside a stall window, a coefficient of exactly 1 is the filter - # out up to float rounding: 0 at whole-frame reads, 1 LSB where the - # node's float32 delay misses the frame (Time 136.054 at 44.1 kHz). - self.assertEqual(self._out_after_tone(DigitalDelay), (0, 0)) - self.assertEqual(self._out_after_tone(DigitalDelay, patch=4), (0, 0)) - _count, worst = self._out_after_tone(DigitalDelay, rate=44100, - time_ms=136.054, feedback=0.6) - self.assertLessEqual(worst, 1) - # Planted: a coefficient of 0.957 is a low-pass left in circuit. + def test_tone_out_after_tone_is_the_filter_out(self): + # Byte for byte a fresh instance's out stop, where the retired + # tracking cure was 1 LSB off: at a fractional read (Time 136.054 + # at 44.1 kHz, which the node lands off the frame) and at a stall + # centre (Feedback 0.99). + cells = ({}, {"patch": 4}, + {"rate": 44100, "time_ms": 136.054, "feedback": 0.6}, + {"time_ms": 12.5, "feedback": 0.99}) + for options in cells: + self.assertEqual(self._out_after_tone(DigitalDelay, **options), + (0, 0), options) + # Planted: the retired cure is red at the fractional read, and a + # coefficient of 0.957 is a low-pass left in circuit. + count, _worst = self._out_after_tone(TrackingToneDelay, **cells[2]) + self.assertGreater(count, 0) _count, worst = self._out_after_tone(LeakyTrackDelay) self.assertGreater(worst, 1000) @@ -2020,29 +2171,37 @@ def test_the_tail_is_bounded_at_every_feedback(self): result = tail_measure(SixtyDbTailDelay, 0.7, "fullscale") self.assertFalse(result["passed"], result) - def test_repeat_tone_stall_windows_are_stepped_clear(self): - # With Repeat Tone in, the node sends its single-precision damping - # state round, and that state can rest a few ulps above a small - # value v; where 0.5 / (1 - f) is v, `recirculated` then rounds - # f v back up to v, and the line holds v for ever (issue draft - # `audiodsp-feedback-delay-damping-holds-the-line.md`). Re-audit - # fix round 2 hands the node the nearer edge of the class's own - # window instead. The stall cells on a 2 LSB DC, 20 s at least: - # re-audit fix round 1's class says None and holds v; this class - # declares a bound and reaches exact zero inside it. - for feedback, tone_hz, held in ((0.5, 800.0, 1), (0.75, 800.0, 2), - (1.0 - 0.5 / 3.0, 800.0, 3), - (0.875, 800.0, 4), (0.9, 800.0, 5), - (0.5, 3000.0, 1)): + def test_repeat_tone_stall_cells_reach_zero_at_the_feedback_set(self): + # With Repeat Tone in, the node's single-precision damping state + # can rest a few ulps above a small value v, and where + # 0.5 / (1 - f) is v, `recirculated` rounds f v back up to v. Up to + # audiodsp v0.6.2 the line held v for ever (1-5 LSB on these + # cells) and the class handed the node a Feedback clear of the + # window. Since v0.6.3rc1 (#157) the node lands a stalled state: + # the Feedback set is the one handed, the class declares a finite + # bound, and the tail reaches exact zero inside it. The stall + # cells on a 2 LSB DC. + for feedback, tone_hz in ((0.5, 800.0), (0.75, 800.0), + (1.0 - 0.5 / 3.0, 800.0), (0.875, 800.0), + (0.9, 800.0), (0.5, 3000.0)): result = tail_measure(DigitalDelay, feedback, "dc2", tone_hz=tone_hz) self.assertTrue(result["passed"], (feedback, tone_hz, result)) self.assertEqual(result["held"], 0, (feedback, tone_hz, result)) - old = tail_measure(StallWindowToneDelay, feedback, "dc2", - tone_hz=tone_hz, budget=20 * RATE) + effect = DigitalDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, tone_hz=tone_hz) + self.assertEqual(effect._feedback, feedback) + effect.deinit() + # Planted: the retired stepping plays a Feedback nobody set, + # and the v0.6.2 reckoning declares no bound at all. + stepped = SteppedToneDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, tone_hz=tone_hz) + self.assertNotEqual(stepped._feedback, feedback) + stepped.deinit() + old = tail_measure(WindowNoneToneDelay, feedback, "dc2", + tone_hz=tone_hz) self.assertIsNone(old["declared"], (feedback, tone_hz, old)) self.assertFalse(old["passed"], (feedback, tone_hz, old)) - self.assertEqual(old["held"], held, (feedback, tone_hz, old)) # A Feedback on a window's edge: the re-refuter's k = 1 edge at # Tone grid 0 came back through the Feedback macro 10^-17 inside # `laps_to_zero`'s window and read None. Every value within a few @@ -2068,8 +2227,26 @@ def test_every_window_centre_reaches_zero(self): # The k = 1 ... 50 window centres, f = 1 - 0.5 / k, at Repeat Tone # grid 0, 64 and 126, on the 2 LSB DC (the line fills to 4k LSB and # decays through k): each reaches exact zero inside a finite - # declared tail, and the handed Feedback stays within 3e-5 of the - # one asked. Re-audit fix round 1's class says None at every one. + # declared tail, and the handed Feedback is the one asked. The + # v0.6.2 reckoning without the stepping says None at every one. + # A host's block decides when the node's landing runs, so the k = 1, + # 10 and 50 centres at grid 0 are also rendered in 4096-frame + # blocks, seven laps of the 12.5 ms line. + for k in (1, 10, 50): + feedback = 1.0 - 0.5 / k + data = array("h", [2] * (RATE // 2 * 2) + + [0] * ((RATE * 10) * 2)) + effect = DigitalDelay(probes.ArraySource(data, rate=RATE, + channels=2, block=4096), + sample_rate=RATE, time_ms=12.5, + feedback=feedback, mix=2.0, tone_hz=800.0) + declared = effect.tail_samples + out = pull(effect, len(data) // 2, 2) + effect.deinit() + nonzero = np.nonzero(out[RATE // 2 * 2:])[0] + last = int(nonzero[-1]) // 2 + 1 + self.assertLessEqual(last, declared, k) + self.assertEqual(int(np.abs(out[-RATE // 4 * 2:]).max()), 0, k) for grid in (0, 64, 126): tone_hz = 800.0 * 20.0 ** (grid / 127.0) for k in range(1, 51): @@ -2080,11 +2257,13 @@ def test_every_window_centre_reaches_zero(self): self.assertEqual(result["held"], 0, (grid, k, result)) probe = DigitalDelay(silence_src(64), sample_rate=RATE, feedback=feedback, tone_hz=tone_hz) - self.assertLess(abs(probe._feedback - feedback), 3e-5) - old = StallWindowToneDelay(silence_src(64), sample_rate=RATE, - feedback=feedback, - tone_hz=tone_hz) + self.assertEqual(probe._feedback, feedback) + probe.deinit() + old = WindowNoneToneDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, + tone_hz=tone_hz) self.assertIsNone(old.tail_samples, (grid, k)) + old.deinit() def _walk_tail(self, cls): """800 ms of 997 Hz, then Time 800 -> 12.5 ms at the default Glide diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 3c8d4c4..ff9ed3d 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -17,6 +17,14 @@ The class is reached by `rebuilt.module_class("SlapbackDelay")`, which is also what `audioeffects.SlapbackDelay` serves since its adoption on 2026-09-28. + +The pin's move to audiodsp v0.6.3rc1 (2026-09-28) took out the tracking +Tone stop and the stall-window stepping: Tone out hands exactly 0 and is +byte-identical to no filter (planted: the retired tracking stop), Repeats +0.5 with Tone in reaches zero as set (planted: the retired stepping), and a +Wow move no longer steps (planted: the read head moved by the whole change +at once). Two surface tests pin the Times the node lands off the whole +frame at 44.1 and 22.05 kHz. """ import math @@ -39,6 +47,8 @@ from audioeffects import rebuilt # noqa: E402 from audioeffects.chorus import nominal_damping_hz # noqa: E402 from audioeffects.rebuilt import slapbackdelay as sd # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls as dd_clear_of_stalls) from tools.effect_measurements import instantaneous_hz # noqa: E402 VENDOR = "PyDevices" @@ -141,6 +151,16 @@ def _refresh(self): SECOND_MIX = 0.0614 +class HalfFrameSlapback(SlapbackDelay): + """The whole-frame landing's surface test: Time handed half a frame + over the whole frame, so the repeat splits across two frames.""" + + NAME = 'SlapbackDelay' + + def _node_time_ms(self, frames): + return (frames + 0.5) * 1000.0 / self._sample_rate + + class SecondRepeatSlapback(SlapbackDelay): """T1's no-second-repeat clause (fix round 1): a second node after the first, at the same whole-frame Time, dry at unity and 0.0614 wet. It @@ -316,29 +336,67 @@ def tail_samples(self): return int(laps * (self._reach + 1 + memory)) -class RawFeedbackSlapback(SlapbackDelay): - """Tier 1's tail with Tone in: Repeats handed to the node as set, inside - the stall window at 0.5, where the loop low-pass holds 1 LSB for - ever.""" +class SteppedSlapback(SlapbackDelay): + """The workaround retired at audiodsp v0.6.3rc1: with Tone in, Repeats + handed to the node at the nearer edge of the stall window at 0.5 + (`clear_of_stalls`), a Feedback nobody set.""" NAME = 'SlapbackDelay' - def _loop_feedback(self, feedback, excess): - return feedback + def _refresh(self): + SlapbackDelay._refresh(self) + if self._damping > 0.0 and self._feedback > 0.0: + excess = sd.tone_excess(self._damping, self._sample_rate)[1] + self._feedback = dd_clear_of_stalls(self._feedback, excess) + self._delay.set(feedback=self._feedback) class FrozenToneSlapback(SlapbackDelay): - """Tier 1's silence clause after a Tone move: the out stop hands the - node exactly 0 even after Tone has been in, so the loop low-pass - freezes on whatever it held and a later Tone move plays it out of - silence (the review's stale-state defect).""" + """Tier 1's silence clause after a Tone move: the out stop leaves the + low-pass in at 0.001 Hz, where its float32 coefficient is one step + above 0 and its state cannot move, so it holds what it held, as the + node's out stop did up to v0.6.2 (the review's stale-state defect), + and plays it out of silence.""" NAME = 'SlapbackDelay' def _refresh(self): SlapbackDelay._refresh(self) if self._macros[TONE_I] >= 1.0: - self._delay.set(damping_hz=0.0) + self._delay.set(damping_hz=0.001) + + +class TrackingSlapback(SlapbackDelay): + """The workaround retired at audiodsp v0.6.3rc1: once Tone has been in, + the out stop hands `damping_hz` at 32 x the rate instead of 0.""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + if self._damping > 0.0: + self._was_in = True + elif getattr(self, "_was_in", False): + self._delay.set(damping_hz=32.0 * self._sample_rate) + + def _clear(self): + SlapbackDelay._clear(self) + self._was_in = False + + +class JumpWowSlapback(SlapbackDelay): + """Tier 1's click-free Wow: a Wow move that moves the read head by the + whole change in depth at once, as the node did at the wow's crest up to + v0.6.2 (it added depth x wow with no ramp).""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + old = self._wow_ms + SlapbackDelay._refresh(self) + if not self._seeding and not self._deferred and self._wow_ms != old: + self._delay.set(delay_slew=0.0, + delay_ms=self._node_ms + self._wow_ms - old) class TargetOnlyTailSlapback(SlapbackDelay): @@ -1055,6 +1113,54 @@ def test_time_lands_on_a_whole_frame(self): self.assertEqual(effect._node_ms, effect._frames * 1000.0 / rate) + def test_where_the_node_lands_the_handed_frame(self): + # The node turns the handed ms back into frames in float32 + # (`audiodsp_feedback_delay.c:148`). At 48 kHz every Time position + # lands exactly; at 44.1 and 22.05 kHz these land one float32 step + # off, which the class cannot avoid (the node ask is drafted). + # Goes red when the node lands every whole frame. + off_frame = { + 48000: [], + 44100: [4, 8, 9, 10, 11, 38, 39, 40, 41, 49, 50, 53, 55, 60, 83, + 86, 91, 93, 96, 99, 102], + 22050: [8, 10, 34, 38, 39, 40, 41, 45, 53, 60, 81, 83, 86, 91, + 93, 96, 97, 98, 99, 102], + } + f32 = np.float32 + for rate, worst in ((48000, 0.0), (44100, 2.0 ** -11), + (22050, 2.0 ** -12)): + effect = SlapbackDelay(silence_src(64, 2, rate), sample_rate=rate) + missed = [] + for midi in range(128): + effect.set_macro(TIME_I, midi) + ms = f32(effect._node_ms) + frames = float((ms * f32(rate)) / f32(1000.0)) + if frames != effect._frames: + self.assertLessEqual(abs(frames - effect._frames), worst, + (rate, midi)) + missed.append(midi) + self.assertEqual(missed, off_frame[rate], rate) + + def test_an_off_frame_time_leaks_into_the_next_frame(self): + # MIDI 60 at 44.1 kHz is 4 193 frames, landed 1/2048 of a frame + # late: a 20 000 click's repeat (Wow 0, Level 2) reads 19 618 and + # 10 in the frame after; at 48 kHz the same position reads 19 627 alone + # (the default Saturation's loss). A half frame, planted, leaks. + def window(cls, rate): + probe = cls(silence_src(64, 2, rate), sample_rate=rate) + probe.set_macro(TIME_I, 60) + frames = probe._frames + values = np.zeros(frames + 64) + values[0] = 20000 + y = render(cls, values, rate, macros={TIME_I: 60}, level=2.0, + wow_cents=0.0)[:, 0] + return [int(v) for v in y[frames - 1:frames + 2]] + + self.assertEqual(window(SlapbackDelay, 44100), [0, 19618, 10]) + self.assertEqual(window(SlapbackDelay, 48000), [0, 19627, 0]) + self.assertNotEqual(window(HalfFrameSlapback, 48000), + [0, 19627, 0]) + def test_a_host_echoing_time_keeps_the_frame(self): # Fix round 1: set_macro(0, get_macro(0)) on the constructor's # 135.0 ms keeps 5 954 frames at 44.1 kHz; the old behaviour, @@ -1126,14 +1232,14 @@ def test_tail_samples_follows_time_tone_wow_and_repeats(self): self.assertEqual(effect.tail_samples, law_frames(law_time_ms(84), RATE) + law_wow_frames(3.5 * 36 / 127.0, RATE) + 1) - # Tone in at Repeats 0.5: the node is handed the stall window's - # nearer edge, and the bound is finite. + # Tone in at Repeats 0.5, a stall centre: since audiodsp v0.6.3rc1 + # the node is handed 0.5 itself, and the bound takes its landing + # lap there (111 758 frames; 105 184 with the retired stepping). effect = SlapbackDelay(silence_src(64), sample_rate=RATE, tone_hz=2000.0, repeats=0.5) - self.assertLess(effect._feedback, 0.5) - self.assertGreater(effect._feedback, 0.5 - 2.5e-5) + self.assertEqual(effect._feedback, 0.5) self.assertAlmostEqual(effect.get_macro(REPEATS_I), 127 * 0.5 / 0.6) - self.assertEqual(effect.tail_samples, 105184) + self.assertEqual(effect.tail_samples, 111758) def test_constructor_clamps_and_nan(self): nan = float("nan") @@ -1381,45 +1487,38 @@ def test_an_open_top_stop_is_red_at_the_defaults(self): self.assertFalse(t4_measure(RawTopSlapback, rate)["passed"]) self.assertFalse(t4_measure(OpenTopSlapback, rate)["passed"]) - def test_out_after_tone_has_been_in_is_within_1_lsb(self): - # The restated out clause (fix round 1, restated again in fix - # round 2): once Tone has handed an in-circuit value since the - # last reset (the constructor counts), the out stop is within - # 1 LSB of the node given no `damping_hz`, and byte-identical at - # Wow 0 where the node's float32 landing of Time is whole: every - # Time at 48 kHz, and every shipped patch's Time at every rate. + def test_out_after_tone_has_been_in_is_the_filter_out(self): + # Since audiodsp v0.6.3rc1 the node keeps an out low-pass on the + # tap (#158), so the out stop hands exactly 0 whatever came before + # (the constructor and a patch count as Tone in) and the output is + # byte-identical to the node given no `damping_hz`: with Wow on, + # and at Wow 0 on a Time whose float32 landing leaves a fraction + # (44.1 kHz grid 8, 1 980 frames asked) as on one it lands whole + # (grid 84). The tracking cure this replaced moved samples by 1 LSB + # on those cells; planted, it is red on every one. for rate in RATES: for channels in (2, 1): - for ctor, steps in (({"tone_hz": 5000.0}, (0,)), - ({"patch": 5}, (0,))): + for ctor in ({"tone_hz": 5000.0}, {"patch": 5}): differing, peak, damping = t4_out_history( - SlapbackDelay, rate, channels, steps=steps, **ctor) - self.assertLessEqual(peak, 1, (rate, channels, ctor)) - self.assertGreater(differing, 0, (rate, channels, ctor)) - # The pack's old reading, "patches 0-4 hand 0.0", is - # true of a fresh history only. + SlapbackDelay, rate, channels, steps=(0,), **ctor) + self.assertEqual((differing, peak, damping), + (0, 0, 0.0), (rate, channels, ctor)) + differing, _peak, damping = t4_out_history( + TrackingSlapback, rate, channels, steps=(0,), + **ctor) self.assertEqual(damping, 32.0 * rate) - differing, _, _ = t4_out_history( - SlapbackDelay, rate, channels, - steps=steps + ((WOW_I, 0),), **ctor) - self.assertEqual(differing, 0, (rate, channels, ctor)) - # Fix round 2: at Wow 0 on a Time whose float32 landing - # (`audiodsp_feedback_delay.c:148`) leaves a fraction, the tap is - # fractional and the coefficient-1 stop moves samples by 1 LSB. - # 44.1 kHz grid 8 (1 980 frames asked, no float32 `delay_ms` lands - # it whole) is such a cell; grid 84 lands whole and reads 0. The - # non-zero assertion is the one the old words ("byte-identical at - # Wow 0") fail. + self.assertGreater(differing, 0, (rate, channels, ctor)) for channels in (2, 1): - for midi, fractional in ((8, True), (84, False)): + for midi in (8, 84): + steps = (0, (WOW_I, 0), (TIME_I, midi)) differing, peak, _ = t4_out_history( - SlapbackDelay, 44100, channels, - steps=(0, (WOW_I, 0), (TIME_I, midi)), tone_hz=5000.0) - self.assertLessEqual(peak, 1, (channels, midi)) - if fractional: - self.assertGreater(differing, 0, (channels, midi)) - else: - self.assertEqual(differing, 0, (channels, midi)) + SlapbackDelay, 44100, channels, steps=steps, + tone_hz=5000.0) + self.assertEqual((differing, peak), (0, 0), (channels, midi)) + differing, _peak, _ = t4_out_history( + TrackingSlapback, 44100, channels, + steps=(0, (WOW_I, 0), (TIME_I, 8)), tone_hz=5000.0) + self.assertGreater(differing, 0, channels) def test_out_on_a_fresh_history_is_byte_identical(self): for rate in RATES: @@ -1567,12 +1666,23 @@ def _stall(self, cls): last = int(nonzero[-1]) + 1 if len(nonzero) else 0 return declared, last, int(np.abs(y[-RATE // 4:]).max()) - def test_the_stall_window_is_stepped_clear(self): + def test_the_stall_cell_reaches_zero_at_the_repeats_set(self): + # Repeats 0.5 with Tone in is a stall centre: up to audiodsp v0.6.2 + # the node held 1 LSB there for ever and the class moved Repeats + # clear of it. Since v0.6.3rc1 (#157) the node lands the stalled + # state, so 0.5 is handed as set and the tail ends inside the bound. declared, last, held = self._stall(SlapbackDelay) self.assertEqual(held, 0) + self.assertGreater(last, 0) self.assertLessEqual(last, declared) - _declared, _last, held = self._stall(RawFeedbackSlapback) - self.assertEqual(held, 1) + effect = SlapbackDelay(silence_src(64), sample_rate=RATE, + repeats=0.5, tone_hz=2000.0) + self.assertEqual(effect._feedback, 0.5) + # Planted: the retired stepping hands a Repeats nobody set. + stepped = SteppedSlapback(silence_src(64), sample_rate=RATE, + repeats=0.5, tone_hz=2000.0) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 2.5e-5) def _tone_back_in(self, cls, rate=RATE, channels=2, level=2.0): """300 Hz at 30 000 LSB for 0.5 s with Tone 2 kHz, Tone out, 2 s of @@ -1601,50 +1711,60 @@ def test_tone_back_in_after_silence_stays_silent(self): channels), 0, (rate, channels)) self.assertEqual(self._tone_back_in(SlapbackDelay, level=0.35), 0) - # Planted: the out stop frozen at 0 plays the held state back. + # Planted: a low-pass left in with a frozen state (0.001 Hz) plays + # what it held back out of silence. self.assertGreater(self._tone_back_in(FrozenToneSlapback), 20000) self.assertGreater(self._tone_back_in(FrozenToneSlapback, level=0.35), 5000) - def test_tone_out_hands_no_filter_until_tone_has_been_in(self): + def test_tone_out_hands_exactly_zero(self): + # After Tone has been in, after a patch with Tone in, and fresh. + # The retired tracking cure, planted, hands 32 x the rate. with NodeSpy(): for rate in RATES: - effect = SlapbackDelay(silence_src(64, 2, rate), - sample_rate=rate) - self.assertEqual(effect._delay._handed["damping_hz"], 0.0) - effect.set_macro(TONE_I, 0) - effect.set_macro(TONE_I, 127) - self.assertEqual(effect._delay._handed["damping_hz"], - 32.0 * rate) - self.assertIsNotNone(effect.tail_samples) - effect.reset() - self.assertEqual(effect._delay._handed["damping_hz"], 0.0) - effect = SlapbackDelay(silence_src(64, 2, rate), - sample_rate=rate, patch=5) - effect.program_change(0) - self.assertEqual(effect._delay._handed["damping_hz"], - 32.0 * rate) - - def test_a_wow_move_steps_as_the_docstring_says(self): - # The node takes a new wow depth at once; the docstring states the - # step. 997 Hz at 12 000 LSB, Level 2, Wow 36 -> 73 at frame 15 616. + for cls, expected in ((SlapbackDelay, 0.0), + (TrackingSlapback, 32.0 * rate)): + effect = cls(silence_src(64, 2, rate), sample_rate=rate) + self.assertEqual(effect._delay._handed["damping_hz"], + 0.0) + effect.set_macro(TONE_I, 0) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + effect = cls(silence_src(64, 2, rate), sample_rate=rate, + patch=5) + effect.program_change(0) + self.assertEqual(effect._delay._handed["damping_hz"], + expected, (cls, rate)) + + def _wow_move(self, cls, start, target): + """(the tone's own largest step before the move, the largest step + over the 2 000 frames from the move) for a Wow move at frame + 15 616 on 997 Hz at 12 000 LSB, Level 2, 48 kHz.""" at = 15616 values = 12000 * np.sin(2 * math.pi * 997.0 * np.arange(RATE) / RATE) - steps = [] - for target in (73, 127): - source, _ = to_source(values) - effect = SlapbackDelay(source, sample_rate=RATE, level=2.0) - effect.set_macro(WOW_I, 36 if target == 73 else 0) - - def move(frame, target=target, effect=effect): - if frame == at: - effect.set_macro(WOW_I, target) - - y = pull(effect, RATE, on_block=move)[:, 0].astype(int) - steady = int(np.abs(np.diff(y[at - 3000:at - 1])).max()) - steps.append((steady, int(np.abs(y[at] - y[at - 1])))) - self.assertEqual(steps[0], (1565, 7684)) - self.assertEqual(steps[1][1], 23037) + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, level=2.0) + effect.set_macro(WOW_I, start) + + def move(frame): + if frame == at: + effect.set_macro(WOW_I, target) + + y = pull(effect, RATE, on_block=move)[:, 0].astype(int) + steady = int(np.abs(np.diff(y[at - 3000:at - 1])).max()) + return steady, int(np.abs(np.diff(y[at - 1:at + 2000])).max()) + + def test_a_wow_move_does_not_step(self): + # Since audiodsp v0.6.3rc1 the node ramps a new depth in over 20 ms + # (#160): no step larger than the tone's own (1 565 LSB) after a + # move 36 -> 73 or 0 -> 127, where v0.6.2 stepped 7 684 and 23 037. + for start, target in ((36, 73), (0, 127)): + steady, worst = self._wow_move(SlapbackDelay, start, target) + self.assertLessEqual(worst, steady, (start, target)) + # Planted: the read head moved by the whole change at once. + steady, worst = self._wow_move(JumpWowSlapback, start, target) + self.assertGreater(worst, 4 * steady, (start, target)) def _walk_tail(self, cls): """250 ms of 997 Hz, then Time 250 -> 40 ms on the tone's last From 56574020ddb1e93a8fb08820fbd62cf4e4746ebd Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:12:56 -0500 Subject: [PATCH 38/88] ConvolutionReverb Station B: the rebuild and its tests Built against the frozen dossier (anchor 85cc2bf): one Convolver, six macros (Decay, Damping, Predelay, Diffusion, Room, Mix) with Decay, Predelay and Diffusion as laws over a 0.08 s default allocation; D2's ceiling and floor raised by the class with the class name, the taps, the rate and the floored ceiling; latency_samples and tail_samples read from the node (0 on an empty impulse); Mix 0 the source delayed by the partition through the node; measured mode (bytes, int16 array or a 16-bit WAV at the graph's rate) trimmed by start_ms through a slice, loaded at unit mean energy plus ir_gain_db, with IndexError on the five synthesis macros and a live_macros read; one synthesis per construction, patch and reset, none for a move onto the room already held. Tests: D1-D6 each measured, red on its planted fault at the defaults, walked out of reach (D1 by hand over Mix and the patches, since fault_reachability walks every label), and red on its null. --- lib/audioeffects/rebuilt/convolutionreverb.py | 582 +++++++++ .../test_cpython_effects_convolutionreverb.py | 1152 +++++++++++++++++ 2 files changed, 1734 insertions(+) create mode 100644 lib/audioeffects/rebuilt/convolutionreverb.py create mode 100644 tests/test_cpython_effects_convolutionreverb.py diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py new file mode 100644 index 0000000..f911f8e --- /dev/null +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -0,0 +1,582 @@ +"""`ConvolutionReverb` - a room made by convolution, synthesized or loaded. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/ConvolutionReverb.md`, whose trait +table was frozen at Station A before this file existed (anchor commit +85cc2bfc9a5a3c34c6906fbf0c27b285ba1050d2, the Station A critique's +re-freeze, 2026-09-28). The old class in `reverb.py` is consulted only for +the six defects that dossier's section 7 names; it stays the class the +library serves until the board runner adopts this one. + +**What it sounds like.** A short room behind your dry signal. With nothing +loaded the class synthesizes the room: noise under an exponential that +reaches -60 dB at the Decay time, a one-pole Damping roll-off on the tail, +a Predelay of silence before it, a Diffusion fade-in so it does not start +as a burst, and Room picks one of 64 noise seeds (two seeds are two rooms +of one size). Mix runs 0 to 2: dry at unity up to 1, the room alone at 2. +Hand it an impulse (`impulse=`, a bytes-like of int16 frames or a path to +a 16-bit PCM WAV at the graph's rate) and the room is that recording, and +only Mix is live. + +**The allocation.** `seconds` (default **0.08 s**) is how long a room this +instance can ever hold, carved once at construction: 256-frame partitions, +`ceil(round(seconds * fs) / 256)` of them, from a floor of **0.06 s** to a +ceiling of **512 partitions = 131 072 taps**, which is 2.730 s at 48 kHz, +2.972 s at 44.1 kHz and 5.944 s at 22.05 kHz. Outside those, construction +raises `ValueError` naming this class, the taps, the rate and the limit. +Decay, Predelay and Diffusion are laws over what the allocation leaves +(section 6): Decay is the T60, log from the node's 50 ms floor to +`seconds - predelay`, so at its top the room reaches -60 dB exactly at the +allocation's end; Predelay runs to `min(200 ms, (seconds - 50 ms) / 2)`; +Diffusion to `min(500 ms, T60 / 4)`. + +**Which allocations are desktop-only.** By the line through the cost +table's two Convolver rows (an interpolation, pending hardware), any +`seconds` above **0.091 s on an ESP32-S3** and above **0.219 s on an +ESP32-P4** is over Brad's 80 % real-time ceiling. The default is under +both. The old class's one-second room is a desktop or offline render, and +so is any measured room or hall impulse; a cabinet-length impulse fits. + +**Portability tier: audiodsp** (`REQUIRES = ("audioconvolve",)`). The +convolver is audiodsp's own node. On a stock CircuitPython board this +module imports cleanly and construction raises `ImportError`. + +**Cost: one node.** One `audioconvolve.Convolver`, no mixer, no glue. At +patch 0 on the default allocation (15 partitions at 48 kHz, stereo) the +palette line gives **P4 <= 34 %, S3 <= 72 %** of a 5.333 ms stereo block +(dossier Tier 3). The board measurement is pending hardware. + +**Latency: 256 frames whenever an impulse is loaded** - 5.333 ms at +48 kHz, 5.805 ms at 44.1 kHz, 11.610 ms at 22.05 kHz - **and 0 when none +is.** It is the partition: a block cannot be transformed until it is +complete. `latency_samples` reads the node's own report, so it follows the +loaded state; the synthesized room is always loaded, and only an empty +impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is +the source delayed by exactly `latency_samples`, byte for byte, because the +node stays in the path at Mix 0 and a Mix move never jumps the timeline. + +**Tail.** `tail_samples` is `latency_samples` plus the loaded impulse +rounded up to a partition: 4 096 frames (85.3 ms) at the default at +48 kHz, 3 840 at 44.1 kHz, 2 048 at 22.05 kHz. After it the output is +exactly zero. + +**RAM.** 141 800 B at the default at 48 kHz with a stereo room (8 224 B a +partition plus 18 440 B fixed), 110 960 B with a mono one; 133 576 B at +44.1 kHz and 76 008 B at 22.05 kHz. A measured impulse is read once at +construction, handed to the node and dropped; the class keeps no copy. + +**Moving a room knob starts the room empty.** Decay, Damping, Predelay, +Diffusion and Room re-synthesize the impulse, and the node empties its +history when it does, so a tail ringing at that moment stops dead. A move +that lands on the room already loaded does nothing, and a patch change, +the constructor and `reset()` synthesize once, not once per knob. Mix moves +never touch the room. + +**What the default surrenders.** The room is normalised to unit energy +across the whole band, so with Damping in, low material comes back louder +than it went in: a 220 / 277 / 330 Hz chord +3.73 dB at the default +6 kHz Damping and +13.28 dB at the 500 Hz stop; white-spectrum material +comes back at its own level at every setting. Damping clamps at 0.159 fs, +under the point where the node's one-pole coefficient stops moving, so at +48 kHz every one of its 128 positions is a room of its own, while at +22.05 kHz the positions from 92 up (the 6 kHz default among them) are one +3 506 Hz room. A single Room's decay with Damping in can read up to 16 % +off the Decay law; the 64 Rooms' mean holds within 2 %. + +**Measured mode.** The impulse is trimmed by `start_ms` +(int(start_ms * fs / 1000) frames, truncated) through a slice that copies +nothing on a board, then loaded at unit mean energy across the channels +the room holds, trimmed by `ir_gain_db` (-24 to +12 dB). A stereo impulse +over a mono source keeps its left channel. An impulse with frames but no +energy raises. Decay, Damping, Predelay, Diffusion and Room raise +`IndexError` from `set_macro` and `get_macro` in this mode: the loaded +impulse is the room. `live_macros` says which macros an instance has. No +impulse ships with this class; it loads yours and keeps no copy (Brad's +ruling, 2026-09-08). + +`capabilities = ()`: nothing here reads a beat. +""" + +VENDOR = "PyDevices" + +from . import _component + +try: + import audioconvolve +except ImportError: # pragma: no cover - a stock board + audioconvolve = None + + +#: One partition, in frames; the node's `FRAMES`. +PARTITION = 256 + +#: The node's ceiling in partitions (`audiodsp_convolve.h:58-60`), and in +#: taps. It is the same count of taps at every rate. +MAX_PARTITIONS = 512 +CEILING_TAPS = MAX_PARTITIONS * PARTITION + +#: The shortest allocation the class builds: under it Decay, which runs +#: from the node's 50 ms floor, would have no travel. +FLOOR_SECONDS = 0.06 + +#: The node's shortest decay (`audiodsp_convolve.c:177`) and its clamps on +#: predelay and diffusion (`:178`, `:179`), in seconds and milliseconds. +NODE_DECAY_FLOOR = 0.05 +PREDELAY_MAX_MS = 200.0 +DIFFUSION_MAX_MS = 500.0 + +#: Damping's span, log, and the clamp as a fraction of the rate: just under +#: fs / (2 pi), where the node's `exp_small` stops moving the coefficient. +DAMPING_LOW_HZ = 500.0 +DAMPING_HIGH_HZ = 7500.0 +DAMPING_CLAMP = 0.159 + +#: `ir_gain_db` and `start_ms` spans, measured mode. +IR_GAIN_MIN_DB = -24.0 +IR_GAIN_MAX_DB = 12.0 +START_MAX_MS = 200.0 + +#: Room seeds: 1 + round(position * 63), 64 rooms. +ROOMS = 64 + +DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I = range(6) +SYNTHESIS_MACROS = (DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I) + + +def _floor3(value): + """`value` floored to three decimal places, so the number a message + names is one that builds (2.730 s is 131 040 taps; 2.731 s raises).""" + return int(value * 1000.0) / 1000.0 + + +def _u16(data, at): + return data[at] | (data[at + 1] << 8) + + +def _u32(data, at): + return _u16(data, at) | (_u16(data, at + 2) << 16) + + +def read_wav(path, sample_rate): + """`(data, channels)` from a 16-bit PCM WAV at `sample_rate`. + + The file is read once; `data` is the `data` chunk's bytes. A WAV at + another rate raises naming both rates: an effect does not resample. + """ + with open(path, "rb") as handle: + head = handle.read(12) + if len(head) < 12 or head[0:4] != b"RIFF" or head[8:12] != b"WAVE": + raise ValueError("ConvolutionReverb: %r is not a RIFF WAVE file" + % (path,)) + channels = None + while True: + chunk = handle.read(8) + if len(chunk) < 8: + break + size = _u32(chunk, 4) + name = chunk[0:4] + if name == b"fmt ": + body = handle.read(size) + if size & 1: + handle.read(1) + if len(body) < 16: + raise ValueError("ConvolutionReverb: %r has a short fmt " + "chunk" % (path,)) + tag = _u16(body, 0) + channels = _u16(body, 2) + rate = _u32(body, 4) + bits = _u16(body, 14) + if tag not in (1, 0xFFFE) or bits != 16: + raise ValueError( + "ConvolutionReverb: %r is not 16-bit PCM (format %d, " + "%d bits)" % (path, tag, bits)) + if channels not in (1, 2): + raise ValueError( + "ConvolutionReverb: %r has %d channels; an impulse " + "is mono or stereo" % (path, channels)) + if rate != sample_rate: + raise ValueError( + "ConvolutionReverb: %r is a %d Hz impulse and the " + "graph runs at %d Hz; an effect does not resample" + % (path, rate, sample_rate)) + elif name == b"data": + if channels is None: + raise ValueError("ConvolutionReverb: %r has its data " + "before its fmt chunk" % (path,)) + return handle.read(size), channels + else: + handle.seek(size + (size & 1), 1) + raise ValueError("ConvolutionReverb: %r has no data chunk" % (path,)) + + +def _byte_width(impulse, view): + """Bytes per item of `view`: 1 for bytes-likes, 2 for an int16 array.""" + width = getattr(view, "itemsize", None) + if width is None: + typecode = getattr(impulse, "typecode", None) + width = 2 if typecode in ("h", "H") else 1 + if width not in (1, 2): + raise TypeError("ConvolutionReverb: impulse must be bytes-like int16 " + "frames, or an int16 array") + return width + + +def _sum_squares(view, width, frames, channels, lane): + """sum_k (h[k, lane] / 32768)^2 over `frames` frames of `view`. + + Every term is an exact binary fraction and, on a desktop, so is the + sum, so every interpreter computes the same number from the same file. + """ + total = 0.0 + if width == 2: + for index in range(lane, frames * channels, channels): + value = view[index] + if value >= 32768: + value -= 65536 + value = value / 32768.0 + total += value * value + return total + step = 2 * channels + for index in range(2 * lane, frames * step, step): + value = view[index] | (view[index + 1] << 8) + if value >= 32768: + value -= 65536 + value = value / 32768.0 + total += value * value + return total + + +class ConvolutionReverb(_component.Component): + """A short synthesized room, or the room a loaded impulse was measured + in. audiodsp tier; 256 frames of latency whenever an impulse is loaded. + + **What the default surrenders:** a dark room lifts low material (a low + chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), moving a + room knob starts the room empty, and anything longer than 0.091 s on + an S3 or 0.219 s on a P4 is a desktop room (pending hardware). + """ + + NAME = 'ConvolutionReverb' + DISPLAY_NAME = 'Convolution Reverb' + CATEGORIES = ('Reverb',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioconvolve",) + + CAPABILITIES = () + #: One partition, the loaded state. An instance reports what its node + #: holds (`latency_samples` below): 0 on an empty impulse. + LATENCY_SAMPLES = PARTITION + TAIL_SAMPLES = None + + MACRO_LABELS = ("Decay", "Damping", "Predelay", "Diffusion", "Room", + "Mix") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + } + _MACRO_RANGES = ( + (0.0, 1.0), # 0 Decay, law position + (DAMPING_LOW_HZ, DAMPING_HIGH_HZ, "log"), # 1 Damping; top = out + (0.0, 1.0), # 2 Predelay, position + (0.0, 1.0), # 3 Diffusion, position + (1.0, float(ROOMS)), # 4 Room, seed 1..64 + (0.0, 2.0), # 5 Mix; dry unity to 1 + ) + + #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is + #: the constructor's defaults on the grid. + PATCHES = { + 0: ("Full Room", (127, 117, 0, 64, 0, 38)), + 1: ("Short Room", (38, 98, 13, 64, 4, 44)), + 2: ("Late Room", (127, 124, 76, 64, 12, 51)), + 3: ("Dark Room", (102, 52, 25, 64, 20, 44)), + 4: ("Bright Room", (102, 127, 0, 32, 8, 51)), + 5: ("Soft Onset", (127, 108, 38, 127, 24, 63)), + 6: ("Tight Room", (0, 124, 25, 0, 2, 76)), + 7: ("Wet Only", (127, 117, 0, 64, 0, 127)), + } + + def _build(self, decay=1.0, damping_hz=6000.0, predelay=0.0, + diffusion=0.5, room=1, mix=0.6, seconds=0.08, impulse=None, + impulse_channels=1, ir_gain_db=0.0, start_ms=0.0, + patch=None): + #: True while several macros are applied at once (the constructor, + #: `program_change`, `reset`); the room is synthesized once, after. + self._deferred = True + #: The synthesis tuple the node holds, or None. + self._loaded = None + self._measured = impulse is not None + rate = self._sample_rate + if self._measured: + self._node = self._load_impulse(impulse, impulse_channels, + ir_gain_db, start_ms) + else: + seconds = float(seconds) + taps = int(round(seconds * rate)) + self._check_allocation(taps, seconds) + self._seconds = seconds + self._node = audioconvolve.Convolver( + max_taps=self._partitions(taps) * PARTITION, + ir_channels=self._channel_count, + sample_rate=rate, + channel_count=self._channel_count) + # `clear()` drops the history and the block in flight and keeps the + # impulse (`Convolver.c:229`): a reset empties the room, it does not + # rebuild it. + self._own(self._node, reset=self._node.clear) + self._node.play(self._source) + self._output = self._node + damping_hz = float(damping_hz) + if not 0.0 < damping_hz < DAMPING_HIGH_HZ: + damping_hz = DAMPING_HIGH_HZ # 0 (or the top) is out + try: + self._init_macros((decay, damping_hz, predelay, diffusion, + room, mix), patch) + finally: + self._deferred = False + self._refresh() + + # -- the allocation (D2) ------------------------------------------- + + def _partitions(self, taps): + """Partitions for `taps`: the law, and never fewer than one.""" + return max(1, (taps + PARTITION - 1) // PARTITION) + + def _check_allocation(self, taps, seconds=None): + """Raise, naming the class and the limit, over the ceiling or (in + synthesized mode) under the floor. The node's own `impulse is too + long` never reaches the caller.""" + rate = self._sample_rate + if taps > CEILING_TAPS: + raise ValueError( + "%s: %d taps at %d Hz is over the ceiling of %d taps (%d " + "partitions), %.3f s at this rate" + % (self.NAME, taps, rate, CEILING_TAPS, MAX_PARTITIONS, + _floor3(CEILING_TAPS / float(rate)))) + if seconds is not None and not seconds >= FLOOR_SECONDS: + raise ValueError( + "%s: seconds=%r is under the floor of %.3f s, where Decay " + "would have no travel" % (self.NAME, seconds, FLOOR_SECONDS)) + + # -- measured mode (D1) -------------------------------------------- + + def _trim_frames(self, start_ms): + """Frames `start_ms` trims: int(start_ms * fs / 1000), truncated, + with the product formed first so a whole-ms trim is exact.""" + return int(start_ms * self._sample_rate / 1000.0) + + def _load_impulse(self, impulse, impulse_channels, ir_gain_db, + start_ms): + rate = self._sample_rate + if isinstance(impulse, str): + impulse, impulse_channels = read_wav(impulse, rate) + if impulse_channels not in (1, 2): + raise ValueError("%s: impulse_channels must be 1 or 2" + % self.NAME) + ir_gain_db = float(ir_gain_db) + if not IR_GAIN_MIN_DB <= ir_gain_db <= IR_GAIN_MAX_DB: + raise ValueError("%s: ir_gain_db=%r is outside %.0f..%+.0f dB" + % (self.NAME, ir_gain_db, IR_GAIN_MIN_DB, + IR_GAIN_MAX_DB)) + start_ms = float(start_ms) + if not 0.0 <= start_ms <= START_MAX_MS: + raise ValueError("%s: start_ms=%r is outside 0..%.0f ms" + % (self.NAME, start_ms, START_MAX_MS)) + view = memoryview(impulse) + width = _byte_width(impulse, view) + size = len(view) * width + if size % (2 * impulse_channels): + raise ValueError("%s: impulse length must be whole int16 frames" + % self.NAME) + frames = size // (2 * impulse_channels) + trim = min(frames, self._trim_frames(start_ms)) + frames -= trim + per_frame = impulse_channels * (2 // width) + view = view[trim * per_frame:] + self._check_allocation(frames) + self._seconds = frames / float(rate) + room_channels = min(impulse_channels, self._channel_count) + node = audioconvolve.Convolver( + max_taps=self._partitions(frames) * PARTITION, + ir_channels=room_channels, + sample_rate=rate, + channel_count=self._channel_count) + if frames: + energy = 0.0 + for lane in range(room_channels): + energy += _sum_squares(view, width, frames, + impulse_channels, lane) + if not energy > 0.0: + node.deinit() + raise ValueError( + "%s: the impulse has %d frames and no energy; a silent " + "room is a dead Mix" % (self.NAME, frames)) + gain = (10.0 ** (ir_gain_db / 20.0) + / (energy / room_channels) ** 0.5) + node.load(view, impulse_channels, gain) + return node + + # -- the laws (section 6) ------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _predelay_ms(self): + span = (self._seconds - NODE_DECAY_FLOOR) * 1000.0 / 2.0 + if span > PREDELAY_MAX_MS: + span = PREDELAY_MAX_MS + return self._macros[PREDELAY_I] * span + + def _decay_seconds(self, predelay_ms): + room = self._seconds - predelay_ms / 1000.0 + return NODE_DECAY_FLOOR * (room / NODE_DECAY_FLOOR) ** \ + self._macros[DECAY_I] + + def _damping_hz(self): + position = self._macros[DAMPING_I] + if position >= 1.0: + return 0.0 + hz = _component.macro_value(self._MACRO_RANGES[DAMPING_I], position) + ceiling = DAMPING_CLAMP * self._sample_rate + if hz > ceiling: + hz = ceiling + return self._hz(hz) + + def _seed(self): + return 1 + int(round(self._macros[ROOM_I] * (ROOMS - 1))) + + def _synthesis(self): + """(decay s, damping Hz, predelay ms, diffusion ms, seed): what the + node is handed for the current positions.""" + predelay = self._predelay_ms() + t60 = self._decay_seconds(predelay) + span = t60 * 1000.0 / 4.0 + if span > DIFFUSION_MAX_MS: + span = DIFFUSION_MAX_MS + return (t60, self._damping_hz(), predelay, + self._macros[DIFFUSION_I] * span, self._seed()) + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del position + if self._deferred: + return + if index == MIX_I: + self._node.set(mix=self._value(MIX_I) * 0.5) + return + self._refresh() + + def _refresh(self): + """Hand the node the room and the Mix. The room is re-synthesized + only when its tuple differs from the one it holds.""" + if not self._measured: + room = self._synthesis() + if room != self._loaded: + self._node.synthesize(decay=room[0], damping_hz=room[1], + predelay_ms=room[2], + diffusion_ms=room[3], seed=room[4]) + self._loaded = room + # The node takes 0..1 and doubles it (`audiodsp_convolve.c:88`); + # halving is a power of two, so it adds no rounding of its own. + self._node.set(mix=self._value(MIX_I) * 0.5) + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then synthesize once. In measured + mode only its Mix is audible; the synthesis positions are stored + and inert. Inside the constructor the one synthesis is the + constructor's own.""" + outer = self._deferred + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = outer + if not outer and type(self).PATCHES.get(index) is not None: + self._refresh() + + def _macro_index(self, index): + index = _component.Component._macro_index(self, index) + if self._measured and index != MIX_I: + raise IndexError( + "%s holds a measured impulse, so macro %d %r is not live: the " + "loaded impulse is the room, and only Mix (macro %d) is" + % (self.NAME, index, self.MACRO_LABELS[index], MIX_I)) + return index + + # -- the reads ----------------------------------------------------- + + @property + def node(self): + """The one `audioconvolve.Convolver` this class built.""" + self._check_live() + return self._node + + @property + def measured(self): + """True when an impulse was given: the room is that impulse.""" + self._check_live() + return self._measured + + @property + def live_macros(self): + """The macro indexes this instance has: all six synthesized, only + Mix (5) in measured mode.""" + self._check_live() + if self._measured: + return (MIX_I,) + return (DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I) + + @property + def seconds(self): + """The allocation asked for; in measured mode, the trimmed + impulse's own length.""" + self._check_live() + return self._seconds + + @property + def allocated_seconds(self): + """The allocation actually held: partitions x 256 / fs.""" + self._check_live() + taps = self._partitions(int(round(self._seconds + * self._sample_rate))) + return taps * PARTITION / float(self._sample_rate) + + @property + def decay_seconds(self): + """The room's T60 by the Decay law, or None in measured mode.""" + self._check_live() + if self._measured: + return None + return self._synthesis()[0] + + @property + def latency_samples(self): + """256 whenever the node holds an impulse, 0 when it holds none, + read from the node.""" + self._check_live() + return self._latency() + + def _latency(self): + """`latency_samples` as a plain method, so a subclass can reach it + on MicroPython, whose `property` has no `fget`.""" + return int(self._node.latency) + + @property + def tail_samples(self): + """`latency_samples` plus the loaded impulse rounded up to a + partition; 0 when nothing is loaded.""" + self._check_live() + taps = int(self._node.taps) + if not taps: + return 0 + return self._latency() + taps diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py new file mode 100644 index 0000000..d49f666 --- /dev/null +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -0,0 +1,1152 @@ +"""`ConvolutionReverb`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/ConvolutionReverb.md` +(frozen at anchor 85cc2bf); its Tier 2 rows are D1-D6. Each row here is the +measurement over a slice of the span the row quantifies over, the same +measurement red on the row's planted fault at the constructor defaults +(D1's at the measured-mode defaults), the fault shown out of reach of every +macro position and shipped patch, and the measurement red on the class +built as a wire (D2: on the class with its checks removed, since the wire +keeps the class's raise). Exhaustive spans and rates live in the evidence +pack, not in this file. + +Every measurement here computes its reference itself - D1's direct sum and +its load gain, D5's Decay law - and never reads it back from the class. +The M1 reference uses numpy and runs on CPython only. +""" + +import os +import sys +import tempfile +import unittest +import wave +from unittest import mock + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audioconvolve # noqa: E402 +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects.rebuilt import convolutionreverb as rebuilt # noqa: E402 + +VENDOR = "PyDevices" + +ConvolutionReverb = rebuilt.ConvolutionReverb +NAME = "ConvolutionReverb" +RATE = 48000 +RATES = (48000, 44100, 22050) +LATENCY = 256 +DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I, MIX_I = range(6) +GRID = tuple(range(0, 128, 8)) + (127,) + +#: The constructor's defaults as positions, computed here from section 6 +#: (Damping 6 kHz on the 500 Hz-7.5 kHz log law). +DEFAULT_POS = (1.0, float(np.log(12.0) / np.log(15.0)), 0.0, 0.5, 0.0, 0.3) + + +# -------------------------------------------------------------------------- +# Material, rendering, and the laws computed independently of the class +# -------------------------------------------------------------------------- + +def silence(frames, channels=2): + return np.zeros((frames, channels), dtype=np.int16) + + +def click(frames, channels=2, value=32767, at=0): + pcm = silence(frames, channels) + pcm[at, :] = value + return pcm + + +def white(frames, channels=2, peak_dbfs=-12.0, seed=12345): + rng = np.random.RandomState(seed) + peak = 32767.0 * 10 ** (peak_dbfs / 20.0) + return np.round(rng.uniform(-1, 1, (frames, channels)) * peak).astype( + np.int16) + + +def alt_fs(frames, channels=2): + pcm = np.empty((frames, channels), dtype=np.int16) + pcm[0::2] = 32767 + pcm[1::2] = -32768 + return pcm + + +def ramp_fs(channels=2): + """Every int16 value in turn, in every channel.""" + values = (np.arange(65536) - 32768).astype(np.int16) + return np.repeat(values[:, None], channels, axis=1) + + +def switchable(channels=2, rate=RATE): + return probes.SwitchableSource( + probes.ArraySource(silence(256, channels), rate=rate, + channels=channels)) + + +def build(cls=None, rate=RATE, channels=2, **options): + """`cls` around a SwitchableSource, so `run` can hand it any material.""" + cls = cls or ConvolutionReverb + return cls(switchable(channels, rate), sample_rate=rate, **options) + + +def run(effect, pcm, frames=None): + """Swap `pcm` in behind `effect`, empty the node, pull `frames` frames + (default: len(pcm)) and return them as (frames, channels) int16.""" + channels = effect.channel_count + pcm = np.asarray(pcm, dtype=np.int16).reshape(-1, channels) + total = pcm.shape[0] if frames is None else frames + if pcm.shape[0] < total: + pcm = np.vstack([pcm, silence(total - pcm.shape[0], channels)]) + effect._source.swap(probes.ArraySource(pcm, rate=effect.sample_rate, + channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + want = total * channels * 2 + while len(out) < want: + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + break + out += data + return np.frombuffer(bytes(out[:want]), dtype=np.int16).reshape( + -1, channels) + + +def at_mix(effect, midi, pcm, frames=None): + """Render at Mix `midi`, then put Mix back where it was.""" + before = effect.get_macro(MIX_I) + effect.set_macro(MIX_I, midi) + try: + return run(effect, pcm, frames) + finally: + effect.set_macro(MIX_I, before) + + +def digest(pcm): + return np.asarray(pcm, dtype=np.int16).tobytes() + + +def first_arrival(out): + hits = np.nonzero(np.any(out != 0, axis=1))[0] + return int(hits[0]) if len(hits) else None + + +def law_t60(decay_pos, predelay_pos, seconds): + """Section 6: T60 = 50 ms * ((S - P) / 50 ms) ** position.""" + predelay = predelay_pos * min(200.0, (seconds - 0.05) * 1000.0 / 2.0) + room = seconds - predelay / 1000.0 + return 0.05 * (room / 0.05) ** decay_pos + + +def schroeder_t60(energy, rate): + """T60 from the Schroeder curve of `energy`, fit over -5..-35 dB and + extrapolated to -60 dB; None when the fit region is empty.""" + e = np.asarray(energy, dtype=np.float64) + if not np.any(e): + return None + edc = np.cumsum(e[::-1])[::-1] + edc_db = 10 * np.log10(edc / edc[0] + 1e-300) + idx = np.where((edc_db <= -5.0) & (edc_db >= -35.0))[0] + if len(idx) < 2: + return None + slope, _ = np.polyfit(idx / float(rate), edc_db[idx], 1) + return -60.0 / slope + + +def m5_cell(effect): + """(fitted T60 or None, floor clean): a 0 dBFS click at Mix 2, L^2+R^2.""" + taps = effect.node.taps + out = at_mix(effect, 127, click(LATENCY + taps + 1024, 2)) + ir = out[LATENCY:LATENCY + taps].astype(np.float64) + t60 = schroeder_t60(np.sum(ir ** 2, axis=1), effect.sample_rate) + return t60, not np.any(out[LATENCY + taps:]) + + +def m4_verdict(out, pcm, latency): + head = not np.any(out[:latency]) + same = digest(out[latency:]) == digest(pcm[:len(pcm) - latency]) + return head and same + + +def m4(effect, pcm): + return m4_verdict(at_mix(effect, 0, pcm), pcm, effect.latency_samples) + + +def fftconv(x, h): + n = len(x) + len(h) - 1 + size = 1 << (n - 1).bit_length() + return np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(h, size), + size)[:len(x)] + + +def rho(out, pcm, lags): + worst = 0.0 + n = len(out) + size = 1 << (2 * n - 1).bit_length() + for c in range(out.shape[1]): + y = out[:, c].astype(np.float64) + x = pcm[:n, c].astype(np.float64) + r = np.fft.irfft(np.fft.rfft(y, size) * np.conj(np.fft.rfft(x, size)), + size)[:lags + 1] + den = np.sqrt(np.sum(y * y) * np.sum(x * x)) + worst = max(worst, float(np.max(np.abs(r)) / den) if den else 0.0) + return worst + + +def m6_cell(effect, pcm): + """(wet/dry dB after the room has built, rho) at Mix 2.""" + taps = effect.node.taps + out = at_mix(effect, 127, pcm) + start = LATENCY + taps + wet = np.sqrt(np.mean(out[start:].astype(np.float64) ** 2)) + dry = np.sqrt(np.mean(pcm[start - LATENCY:len(pcm) - LATENCY].astype( + np.float64) ** 2)) + level = 20 * np.log10(wet / dry) if wet > 0 else -np.inf + return level, rho(out, pcm, LATENCY + taps) + + +# -- D1's impulse, trim and gain ------------------------------------------ + +def make_impulse(taps=3840, channels=1, seed=7): + rng = np.random.RandomState(seed) + env = 3000.0 * np.exp(-np.arange(taps) / (0.25 * taps)) + return np.round(env[:, None] * rng.uniform(-1, 1, (taps, channels)) + ).astype(np.int16) + + +def trim_frames(start_ms, rate): + return int(start_ms * rate / 1000.0) + + +def unit_gain(h, room_channels, gain_db): + kept = h[:, :room_channels].astype(np.float64) / 32768.0 + return 10 ** (gain_db / 20.0) / np.sqrt(np.sum(kept ** 2) + / room_channels) + + +def m1_error(cls, rate, ir_channels, channels, kind, target, gain_db=0.0, + start_ms=0.0, mix_midi=127, h_full=None): + """M1's peak error at Mix `mix_midi` through the Mix law, or None when + the input would have to leave int16 to put the reference at `target`. + At Mix 2 (127) this is M1 exactly.""" + if h_full is None: + h_full = make_impulse(3840, ir_channels) + h = h_full[trim_frames(start_ms, rate):] + room = min(ir_channels, channels) + g = unit_gain(h, room, gain_db) + hf = h.astype(np.float64) * g / 32768.0 + frames = len(h) + rate // 4 + if kind == "sine": + t = np.arange(frames) / float(rate) + unit = np.repeat(np.sin(2 * np.pi * 997.0 * t)[:, None], channels, 1) + else: + unit = np.random.RandomState(12345).uniform(-1, 1, (frames, channels)) + mix = 2.0 * mix_midi / 127.0 + dry, wet = min(2.0 - mix, 1.0), min(mix, 1.0) + + def reference(x): + return np.stack([dry * x[:, c] + wet * fftconv( + x[:, c], hf[:, c if room == 2 else 0]) for c in range(channels)], + axis=1) + + ref_unit = reference(unit) + x = np.round(unit * target / np.max(np.abs(ref_unit))) + if np.max(np.abs(x)) > 32767: + return None + x = x.astype(np.int16) + ref = reference(x.astype(np.float64)) + assert np.max(np.abs(ref)) <= 32767 + effect = build(cls, rate, channels, impulse=h_full.reshape(-1).tobytes(), + impulse_channels=ir_channels, ir_gain_db=gain_db, + start_ms=start_ms, mix=mix) + try: + out = run(effect, x, frames + LATENCY) + finally: + effect.deinit() + return float(np.max(np.abs(out[LATENCY:].astype(np.float64) - ref))) + + +# -------------------------------------------------------------------------- +# The planted faults. Each is something the class or the kit can build; +# none reaches inside the C node. +# -------------------------------------------------------------------------- + +class TrimLate(ConvolutionReverb): + """D1: the trim one frame late - at start_ms 0 it drops the first tap.""" + + NAME = NAME + + def _trim_frames(self, start_ms): + return ConvolutionReverb._trim_frames(self, start_ms) + 1 + + +class ExtraPartition(ConvolutionReverb): + """D2: one partition too many.""" + + NAME = NAME + + def _partitions(self, taps): + return ConvolutionReverb._partitions(self, taps) + 1 + + +class NoChecks(ConvolutionReverb): + """D2's null: the class with its allocation checks removed.""" + + NAME = NAME + + def _check_allocation(self, taps, seconds=None): + return None + + +class ZeroLatency(ConvolutionReverb): + """D3: `latency_samples` hard-wired 0, the old class's own defect.""" + + NAME = NAME + + @property + def latency_samples(self): + self._check_live() + return 0 + + +class _After(ConvolutionReverb): + """A kit fault node after the convolver.""" + + NAME = NAME + + def _fault(self, node): + raise NotImplementedError + + def _build(self, *arguments, **options): + ConvolutionReverb._build(self, *arguments, **options) + self._planted = self._fault(self._node) + self._output = self._planted + + +class OneLsbAfter(_After): + """D4: `kit_faults.OneLsbScale` after the node.""" + + NAME = NAME + + def _fault(self, node): + return kit_faults.OneLsbScale(node) + + +class StuckDcAfter(_After): + """D5 (2): `kit_faults.StuckDc` after the node.""" + + NAME = NAME + + def _fault(self, node): + return kit_faults.StuckDc(node) + + +class UnnormalisedAfter(_After): + """D6: `kit_faults.HiddenGain` at 10 log10(T60 / 50 ms) dB, the level an + unnormalised room would have, which tracks the decay.""" + + NAME = NAME + + def _fault(self, node): + return kit_faults.HiddenGain( + node, 10.0 * np.log10(self._synthesis()[0] / 0.05)) + + +class LongDecay(ConvolutionReverb): + """D5 (1): the decay handed to the node 5 % long against the law.""" + + NAME = NAME + + def _synthesis(self): + room = ConvolutionReverb._synthesis(self) + return (room[0] * 1.05,) + tuple(room[1:]) + + +class NoReset(ConvolutionReverb): + """Tier 1 reset: `reset()` that leaves the history in the node.""" + + NAME = NAME + + def reset(self): + self._check_live() + self.program_change(0) + + +def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2): + return kit_faults.fault_reachability( + ConvolutionReverb, faulted, reading, + lambda cls: build(cls, rate, channels), tolerance=tolerance) + + +WALKED = 6 * len(GRID) + 8 + + +# -------------------------------------------------------------------------- +# The surface +# -------------------------------------------------------------------------- + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_capabilities(self): + cls = ConvolutionReverb + self.assertEqual(cls.MACRO_LABELS, ("Decay", "Damping", "Predelay", + "Diffusion", "Room", "Mix")) + self.assertEqual(len(cls.PATCHES), 8) + self.assertEqual(cls.CAPABILITIES, ()) + self.assertEqual(cls.TIER, _component.AUDIODSP) + self.assertEqual(cls.REQUIRES, ("audioconvolve",)) + effect = build() + self.assertEqual(effect.capabilities, ()) + self.assertEqual(effect.patch_index, 0) + self.assertEqual(effect.live_macros, (0, 1, 2, 3, 4, 5)) + self.assertFalse(effect.measured) + effect.set_macro(0, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(3) + self.assertEqual(effect.patch_index, 3) + effect.deinit() + + def test_the_module_is_what_the_registry_builds(self): + from audioeffects import rebuilt as registry + self.assertIs(registry.module_class(NAME), ConvolutionReverb) + self.assertIn(NAME, registry.parked()) + + def test_patch_0_is_the_constructor_grid(self): + effect = build() + grid = ConvolutionReverb.PATCHES[0][1] + for index, expected in enumerate(grid): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + effect.deinit() + + def test_patch_table_is_macro_of_the_dossier_settings(self): + spans = ConvolutionReverb._MACRO_RANGES + settings = { + 0: (1.0, 6000.0, 0.0, 0.5, 1, 0.6), + 1: (0.3, 4000.0, 0.1, 0.5, 3, 0.7), + 2: (1.0, 7000.0, 0.6, 0.5, 7, 0.8), + 3: (0.8, 1500.0, 0.2, 0.5, 11, 0.7), + 4: (0.8, 7500.0, 0.0, 0.25, 5, 0.8), + 5: (1.0, 5000.0, 0.3, 1.0, 13, 0.99), + 6: (0.0, 7000.0, 0.2, 0.0, 2, 1.2), + 7: (1.0, 6000.0, 0.0, 0.5, 1, 2.0), + } + for index, values in settings.items(): + grid = tuple(_component.macro_of(span, value) + for span, value in zip(spans, values)) + self.assertEqual(ConvolutionReverb.PATCHES[index][1], grid, + index) + + def test_the_laws_at_the_defaults(self): + effect = build() + room = effect._synthesis() + self.assertAlmostEqual(room[0], 0.08, places=12) # T60 + self.assertAlmostEqual(room[1], 6000.0, places=6) # Damping + self.assertEqual(room[2], 0.0) # Predelay + self.assertAlmostEqual(room[3], 10.0, places=9) # Diffusion + self.assertEqual(room[4], 1) # Room seed + self.assertAlmostEqual(effect.decay_seconds, 0.08, places=12) + self.assertEqual(effect.seconds, 0.08) + self.assertAlmostEqual(effect.allocated_seconds, 0.08, places=12) + effect.set_macro(DAMPING_I, 127) + self.assertEqual(effect._synthesis()[1], 0.0) # out + effect.set_macro(DAMPING_I, 126) + self.assertAlmostEqual(effect._synthesis()[1], + 500.0 * 15.0 ** (126 / 127.0), places=6) + effect.set_macro(PREDELAY_I, 127) + self.assertAlmostEqual(effect._synthesis()[2], 15.0, places=9) + effect.set_macro(ROOM_I, 127) + self.assertEqual(effect._synthesis()[4], 64) + effect.deinit() + + def test_damping_clamps_at_the_rate(self): + # 0.159 fs: at 22.05 kHz the default is the 3 506 Hz clamp. + effect = build(rate=22050) + self.assertAlmostEqual(effect._synthesis()[1], 0.159 * 22050, + places=6) + effect.deinit() + + def test_measured_mode_refuses_the_synthesis_macros(self): + effect = build(impulse=make_impulse().tobytes()) + self.assertTrue(effect.measured) + self.assertEqual(effect.live_macros, (5,)) + self.assertIsNone(effect.decay_seconds) + for index in range(5): + with self.assertRaises(IndexError) as caught: + effect.set_macro(index, 10) + self.assertIn(NAME, str(caught.exception)) + self.assertIn(ConvolutionReverb.MACRO_LABELS[index], + str(caught.exception)) + with self.assertRaises(IndexError): + effect.get_macro(index) + effect.set_macro(MIX_I, 127) + self.assertEqual(effect.get_macro(MIX_I), 127) + with self.assertRaises(IndexError): + effect.set_macro(6, 0) + # A patch sets Mix and leaves the synthesis positions inert. + with mock.patch.object(audioconvolve.Convolver, "synthesize") as s: + effect.program_change(6) + self.assertEqual(s.call_count, 0) + self.assertAlmostEqual(effect.get_macro(MIX_I), 76, places=9) + self.assertEqual(effect.patch_index, 6) + effect.deinit() + + def test_impulse_level_and_shape_are_checked(self): + with self.assertRaises(ValueError): + build(impulse=np.zeros(512, dtype=np.int16).tobytes()) + with self.assertRaises(ValueError): + build(impulse=b"\x00\x00\x00") + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), ir_gain_db=12.5) + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), start_ms=200.5) + + def test_an_int16_array_is_trimmed_by_frames(self): + from array import array + h = make_impulse(1000, 2) + as_array = array("h") + as_array.frombytes(h.tobytes()) + one = build(impulse=h.tobytes(), impulse_channels=2, start_ms=5.0) + two = build(impulse=as_array, impulse_channels=2, start_ms=5.0) + pcm = white(4096) + self.assertEqual(digest(run(one, pcm)), digest(run(two, pcm))) + self.assertEqual(one.node.taps, 768) # 1000 - 240 -> 3 x 256 + one.deinit() + two.deinit() + + +class ResynthesisIsDeduplicated(unittest.TestCase): + """Section 4 and 8.6: one synthesis per construction, patch and reset; + none for a move that lands on the room already loaded, or for Mix.""" + + def count(self, action): + with mock.patch.object(audioconvolve.Convolver, "synthesize", + autospec=True, + side_effect=audioconvolve.Convolver.synthesize + ) as spy: + result = action() + return spy.call_count, result + + def test_construction_patch_and_moves(self): + calls, effect = self.count(lambda: build(patch=3)) + self.assertEqual(calls, 1) + self.assertEqual(effect.patch_index, 3) + calls, _ = self.count(lambda: effect.set_macro(MIX_I, 90)) + self.assertEqual(calls, 0) + # Patch 3's own Decay, written back: the room it already holds. + calls, _ = self.count(lambda: effect.set_macro( + DECAY_I, ConvolutionReverb.PATCHES[3][1][DECAY_I])) + self.assertEqual(calls, 0) + calls, _ = self.count(lambda: effect.set_macro(DECAY_I, 10)) + self.assertEqual(calls, 1) + calls, _ = self.count(lambda: effect.program_change(1)) + self.assertEqual(calls, 1) + calls, _ = self.count(lambda: effect.program_change(1)) + self.assertEqual(calls, 0) + calls, _ = self.count(effect.reset) + self.assertEqual(calls, 1) + self.assertEqual(effect.patch_index, 0) + effect.deinit() + + def test_a_room_move_starts_the_room_empty_and_a_mix_move_does_not(self): + # The docstring's claim, measured: the node empties its history on + # a re-synthesis (audiodsp_convolve.c:254). + burst = np.vstack([white(2400), silence(20000)]) + clean = build(mix=2.0) + ref = run(clean, burst) + for move, expect_cut in (((DECAY_I, 100), True), + ((MIX_I, 126), False)): + effect = build(mix=2.0) + channels = effect.channel_count + effect._source.swap(probes.ArraySource(burst, rate=RATE, + channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for block in range(40): + if block == 10: + effect.set_macro(*move) + out += bytes(audiocore.get_buffer(effect.output)[1]) + out = np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, 2) + after = out[10 * 256:] + if expect_cut: + self.assertEqual(int(np.max(np.abs(after))), 0) + self.assertGreater(int(np.max(np.abs(ref[2560:10240]))), 1000) + else: + self.assertGreater(int(np.max(np.abs(after))), 1000) + effect.deinit() + clean.deinit() + + +# -------------------------------------------------------------------------- +# D1 - measured mode is exactly convolution, within one output LSB +# -------------------------------------------------------------------------- + +class D1ExactConvolution(unittest.TestCase): + def test_within_one_lsb_across_layouts_levels_gain_and_trim(self): + worst = 0.0 + cells = 0 + for ir_channels, channels in ((1, 2), (2, 2), (1, 1), (2, 1)): + for kind in ("sine", "noise"): + for target in (2000.0, 8000.0, 30000.0): + for gain_db, start_ms in ((0.0, 0.0), (0.0, 10.0), + (12.0, 10.0), (-24.0, 0.0)): + error = m1_error(ConvolutionReverb, RATE, ir_channels, + channels, kind, target, gain_db, + start_ms) + if error is None: + continue + cells += 1 + worst = max(worst, error) + self.assertGreater(cells, 50) + self.assertLessEqual(worst, 1.0) + + def test_within_one_lsb_at_the_lower_rates(self): + for rate in (44100, 22050): + for start_ms in (0.0, 10.0): + error = m1_error(ConvolutionReverb, rate, 2, 2, "noise", + 8000.0, 0.0, start_ms) + self.assertLessEqual(error, 1.0, (rate, start_ms)) + + def test_trim_one_frame_late_is_red_at_the_measured_defaults(self): + # The measured-mode defaults: Mix 0.6 (grid 38), ir_gain_db 0, + # start_ms 0. M1 through the Mix law, and at Mix 2. + for mix_midi in (38, 127): + clean = m1_error(ConvolutionReverb, RATE, 1, 2, "noise", 3000.0, + mix_midi=mix_midi) + planted = m1_error(TrimLate, RATE, 1, 2, "noise", 3000.0, + mix_midi=mix_midi) + self.assertLessEqual(clean, 1.0) + self.assertGreater(planted, 1000.0, mix_midi) + + def test_the_mix_walk_never_reaches_the_plant(self): + # fault_reachability walks every label, and in measured mode five of + # them raise IndexError by design; the walk here is its positions + # over the one live macro, Mix, and the 8 patches (which set only + # Mix in this mode). + h = make_impulse() + planted = m1_error(TrimLate, RATE, 1, 2, "noise", 3000.0, h_full=h) + readings = [] + mixes = list(GRID) + [p[1][MIX_I] for p in + ConvolutionReverb.PATCHES.values()] + for mix_midi in mixes: + readings.append(m1_error(ConvolutionReverb, RATE, 1, 2, "noise", + 3000.0, mix_midi=mix_midi, h_full=h)) + self.assertEqual(len(readings), 25) + self.assertLessEqual(max(readings), 1.0) + self.assertGreater(planted, 1000.0) + + def test_null_build_is_red(self): + def measure(cls): + error = m1_error(cls, RATE, 1, 2, "noise", 8000.0) + return {"passed": error is not None and error <= 1.0} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D1") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D2 - the allocation has a ceiling and a floor, and the class names both +# -------------------------------------------------------------------------- + +def m2(cls, cells): + """Red entries over (label, seconds, rate, impulse frames, expect).""" + reds = [] + for label, seconds, rate, frames, expect in cells: + taps = int(round(seconds * rate)) if frames is None else frames + law = max(1, (taps + 255) // 256) * 256 + options = {"seconds": seconds} + if frames is not None: + options = {"impulse": make_impulse(frames).tobytes()} + try: + effect = build(cls, rate, **options) + except ValueError as exc: + text = str(exc) + if expect != "raise": + reds.append("%s: raised %r" % (label, text)) + continue + if taps > 131072: + want = (NAME, str(taps), str(rate), + "%.3f s" % (int(131072.0 / rate * 1000) / 1000.0)) + else: + want = (NAME, "0.060 s") + missing = [w for w in want if w not in text] + if missing: + reds.append("%s: %r lacks %s" % (label, text, missing)) + continue + got = effect.node.taps + effect.deinit() + if expect != "build": + reds.append("%s: built" % label) + elif got != law: + reds.append("%s: %d taps, the law says %d" % (label, got, law)) + return reds + + +def d2_cells(): + cells = [] + for rate in RATES: + ceiling = 131072.0 / rate + printed = int(ceiling * 1000) / 1000.0 + cells += [("ceiling %d" % rate, ceiling, rate, None, "build"), + ("ceiling+256 %d" % rate, 131328.0 / rate, rate, None, + "raise"), + ("printed %.3f %d" % (printed, rate), printed, rate, None, + "build"), + ("floor 0.06 %d" % rate, 0.06, rate, None, "build"), + ("floor 0.0599 %d" % rate, 0.0599, rate, None, "raise"), + ("default 0.08 %d" % rate, 0.08, rate, None, "build")] + cells += [("measured 131072", 0.0, RATE, 131072, "build"), + ("measured 131073", 0.0, RATE, 131073, "raise")] + return cells + + +class D2Allocation(unittest.TestCase): + def test_every_cell_builds_or_raises_by_the_law(self): + self.assertEqual(m2(ConvolutionReverb, d2_cells()), []) + + def test_the_message_at_three_seconds(self): + with self.assertRaises(ValueError) as caught: + build(seconds=3.0) + self.assertEqual( + str(caught.exception), + "ConvolutionReverb: 144000 taps at 48000 Hz is over the ceiling " + "of 131072 taps (512 partitions), 2.730 s at this rate") + + def test_the_default_allocation_at_three_rates(self): + for rate, taps in ((48000, 3840), (44100, 3584), (22050, 1792)): + effect = build(rate=rate) + self.assertEqual(effect.node.taps, taps) + effect.deinit() + + def test_null_with_the_checks_removed_is_red(self): + reds = m2(NoChecks, d2_cells()) + self.assertGreaterEqual(len(reds), 7, reds) + self.assertTrue(any("impulse is too long" in r for r in reds)) + self.assertTrue(any("0.0599" in r and "built" in r for r in reds)) + + def test_one_partition_too_many_is_red_at_the_defaults(self): + for rate, law in ((48000, 3840), (44100, 3584), (22050, 1792)): + effect = build(ExtraPartition, rate) + self.assertEqual(effect.node.taps, law + 256) + effect.deinit() + self.assertTrue(m2(ExtraPartition, d2_cells())) + + def test_the_plant_is_not_on_the_surface(self): + result = reach(ExtraPartition, lambda e: e.node.taps) + self.assertEqual(result["target"], 4096) + self.assertEqual(result["clean"], 3840) + self.assertEqual(result["checked"], WALKED) + + +# -------------------------------------------------------------------------- +# D3 - latency is one partition loaded, zero unloaded, and reported so +# -------------------------------------------------------------------------- + +class D3Latency(unittest.TestCase): + def test_loaded_every_patch_and_the_predelay_stop(self): + for rate in RATES: + for channels in ((2, 1) if rate == RATE else (2,)): + effect = build(rate=rate, channels=channels) + pulse = click(4096, channels) + settings = [("patch", p) for p in sorted( + ConvolutionReverb.PATCHES)] + [("predelay", 127)] + for kind, value in settings: + if kind == "patch": + effect.program_change(value) + else: + effect.program_change(0) + effect.set_macro(PREDELAY_I, value) + self.assertEqual(effect.latency_samples, 256) + for mix in (0, 63): + self.assertEqual( + first_arrival(at_mix(effect, mix, pulse)), 256, + (rate, channels, kind, value, mix)) + effect.deinit() + + def test_wet_arrives_at_the_partition_over_64_rooms(self): + effect = build(diffusion=0.0, mix=2.0) + pulse = click(4096) + for room in range(0, 128, 2): + effect.set_macro(ROOM_I, room) + self.assertEqual(first_arrival(run(effect, pulse)), 256, room) + effect.deinit() + + def test_unloaded_is_an_undelayed_wire(self): + for rate in RATES: + effect = build(rate=rate, impulse=b"") + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.tail_samples, 0) + pcm = white(2048) + for mix in (0, 63, 127): + out = at_mix(effect, mix, pcm) + self.assertEqual(digest(out), digest(pcm), (rate, mix)) + self.assertEqual(first_arrival(at_mix(effect, mix, + click(1024))), 0) + effect.deinit() + + def test_tail_is_latency_plus_the_loaded_impulse(self): + for rate, tail in ((48000, 4096), (44100, 3840), (22050, 2048)): + effect = build(rate=rate) + self.assertEqual(effect.tail_samples, tail) + effect.deinit() + effect = build(impulse=make_impulse(1000).tobytes()) + self.assertEqual(effect.tail_samples, 256 + 1024) + effect.deinit() + + def test_zero_latency_is_red_at_the_defaults(self): + effect = build(ZeroLatency) + measured = first_arrival(at_mix(effect, 63, click(4096))) + self.assertEqual(measured, 256) + self.assertNotEqual(effect.latency_samples, measured) + effect.deinit() + + def test_the_plant_is_not_on_the_surface(self): + result = reach(ZeroLatency, lambda e: e.latency_samples) + self.assertEqual(result["target"], 0) + self.assertEqual(result["clean"], 256) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + def measure(cls): + effect = build(cls) + arrived = first_arrival(at_mix(effect, 63, click(4096))) + reported = effect.latency_samples + effect.deinit() + return {"passed": arrived == reported == 256} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D3") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D4 - Mix 0 is the source delayed by latency_samples, byte for byte +# -------------------------------------------------------------------------- + +class D4DelayedWire(unittest.TestCase): + def test_full_scale_at_three_rates_stereo_and_mono(self): + for rate in RATES: + for channels in (2, 1): + effect = build(rate=rate, channels=channels, mix=0.0) + for pcm in (alt_fs(4096, channels), ramp_fs(channels)): + self.assertTrue(m4(effect, pcm), (rate, channels)) + effect.deinit() + + def test_every_macro_stop_and_patch_with_mix_at_0(self): + effect = build() + ramp = ramp_fs(2) + for index in range(5): + for stop in (0, 127): + effect.program_change(0) + effect.set_macro(index, stop) + self.assertTrue(m4(effect, ramp), (index, stop)) + for patch in sorted(ConvolutionReverb.PATCHES): + effect.program_change(patch) + self.assertTrue(m4(effect, ramp), patch) + effect.deinit() + + def test_measured_and_unloaded(self): + measured = build(impulse=make_impulse().tobytes()) + self.assertTrue(m4(measured, ramp_fs(2))) + measured.deinit() + empty = build(impulse=b"") + self.assertEqual(empty.latency_samples, 0) + self.assertTrue(m4(empty, ramp_fs(2))) + empty.deinit() + + def test_first_256_zero_is_not_redundant(self): + # On alt_fs, whose period divides 256, a wire's out[256:] equals + # in[:-256]: the digest alone reads green. + pcm = alt_fs(4096) + self.assertEqual(digest(pcm[256:]), digest(pcm[:-256])) + self.assertFalse(m4_verdict(pcm, pcm, 256)) + + def test_one_lsb_scale_is_red_at_the_defaults(self): + for rate in RATES: + for channels in (2, 1): + effect = build(OneLsbAfter, rate, channels) + for pcm in (alt_fs(4096, channels), ramp_fs(channels)): + self.assertFalse(m4(effect, pcm), (rate, channels)) + effect.deinit() + + def test_the_plant_is_not_on_the_surface(self): + alt, ramp = alt_fs(2048), ramp_fs(2) + result = reach(OneLsbAfter, lambda e: m4(e, alt) and m4(e, ramp)) + self.assertIs(result["target"], False) + self.assertIs(result["clean"], True) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + def measure(cls): + effect = build(cls) + verdict = m4(effect, ramp_fs(2)) + effect.deinit() + return {"passed": verdict} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D4") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D5 - the synthesized room decays at the Decay law's T60, to exact zero +# -------------------------------------------------------------------------- + +class D5DecayLaw(unittest.TestCase): + def errors_over_rooms(self, cls=None, rate=RATE, **options): + effect = build(cls, rate, **options) + errors = [] + for room in range(0, 128, 2): + effect.set_macro(ROOM_I, room) + t60, floor = m5_cell(effect) + self.assertTrue(floor, room) + self.assertIsNotNone(t60, room) + law = law_t60(effect._macros[DECAY_I], effect._macros[PREDELAY_I], + effect.seconds) + errors.append(t60 / law - 1.0) + effect.deinit() + return np.array(errors) + + def test_damping_out_every_room_within_3_percent(self): + for rate, decays in ((48000, (0.0, 64 / 127.0, 1.0)), + (22050, (1.0,))): + for decay in decays: + errors = self.errors_over_rooms(rate=rate, decay=decay, + damping_hz=0.0, + diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(errors))), 0.03, + (rate, decay)) + + def test_damping_in_the_64_room_mean_within_2_percent(self): + for rate in RATES: + errors = self.errors_over_rooms(rate=rate) + self.assertLessEqual(abs(float(np.mean(errors))), 0.02, rate) + errors = self.errors_over_rooms(decay=0.0, damping_hz=500.0, + diffusion=0.0) + self.assertLessEqual(abs(float(np.mean(errors))), 0.02) + + def test_every_patch_mean_within_2_percent_at_one_second(self): + for patch in (1, 3, 5): + effect = build(seconds=1.0, patch=patch) + errors = [] + for room in range(0, 128, 8): + effect.set_macro(ROOM_I, room) + t60, floor = m5_cell(effect) + self.assertTrue(floor) + law = law_t60(effect._macros[DECAY_I], + effect._macros[PREDELAY_I], 1.0) + errors.append(t60 / law - 1.0) + effect.deinit() + self.assertLessEqual(abs(float(np.mean(errors))), 0.02, patch) + + def test_a_decay_5_percent_long_is_red_at_the_defaults(self): + errors = self.errors_over_rooms(LongDecay) + self.assertGreater(float(np.mean(errors)), 0.02) + + def test_stuck_dc_turns_the_floor_red(self): + effect = build(StuckDcAfter) + t60, floor = m5_cell(effect) + self.assertFalse(floor) + effect.deinit() + + def test_the_plants_are_not_on_the_surface(self): + def handed(effect): + law = law_t60(effect._macros[DECAY_I], + effect._macros[PREDELAY_I], effect.seconds) + return effect._loaded[0] / law + + result = reach(LongDecay, handed, tolerance=0.01) + self.assertAlmostEqual(result["target"], 1.05, places=9) + self.assertAlmostEqual(result["clean"], 1.0, places=9) + self.assertEqual(result["checked"], WALKED) + result = reach(StuckDcAfter, lambda e: m5_cell(e)[1]) + self.assertIs(result["target"], False) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + def measure(cls): + effect = build(cls) + t60, floor = m5_cell(effect) + law = law_t60(1.0, 0.0, 0.08) + effect.deinit() + return {"passed": floor and t60 is not None + and abs(t60 / law - 1.0) <= 0.03} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D5") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# D6 - unit energy, not the source: no synthesis macro is a level control +# -------------------------------------------------------------------------- + +class D6UnitEnergy(unittest.TestCase): + def spread(self, cls, index, rate=RATE, peak_dbfs=-12.0, grid=GRID): + pcm = white(int(1.5 * rate), peak_dbfs=peak_dbfs) + levels, rhos = [], [] + for position in grid: + effect = build(cls, rate) + effect.set_macro(index, position) + if cls is UnnormalisedAfter: + # The plant's gain follows the room it was built for. + effect.deinit() + options = {"decay": position / 127.0} + effect = build(cls, rate, **options) + level, r = m6_cell(effect, pcm) + effect.deinit() + levels.append(level) + rhos.append(r) + return max(levels) - min(levels), max(rhos) + + def test_decay_and_damping_at_two_levels(self): + for peak in (-12.0, -30.0): + for index in (DECAY_I, DAMPING_I): + spread, worst = self.spread(ConvolutionReverb, index, + peak_dbfs=peak) + self.assertLessEqual(spread, 0.5, (index, peak)) + self.assertLess(worst, 0.5, (index, peak)) + + def test_predelay_diffusion_room_and_the_lower_rates(self): + for index in (PREDELAY_I, DIFFUSION_I, ROOM_I): + spread, worst = self.spread(ConvolutionReverb, index) + self.assertLessEqual(spread, 0.5, index) + self.assertLess(worst, 0.5, index) + for rate in (44100, 22050): + spread, worst = self.spread(ConvolutionReverb, DECAY_I, rate) + self.assertLessEqual(spread, 0.5, rate) + self.assertLess(worst, 0.5, rate) + + def test_an_unnormalised_room_is_red_at_the_defaults(self): + spread, _ = self.spread(UnnormalisedAfter, DECAY_I) + self.assertGreater(spread, 0.5) + + def test_the_plant_is_not_on_the_surface(self): + pcm = white(RATE) + result = reach(UnnormalisedAfter, lambda e: m6_cell(e, pcm)[0], + tolerance=1.0) + self.assertGreater(result["target"], 1.5) + self.assertLess(abs(result["clean"]), 0.5) + self.assertEqual(result["checked"], WALKED) + + def test_null_build_is_red(self): + pcm = white(int(1.5 * RATE)) + + def measure(cls): + effect = build(cls) + level, r = m6_cell(effect, pcm) + effect.deinit() + return {"passed": abs(level) <= 0.5 and r < 0.5} + + result = kit_faults.null_build_red(ConvolutionReverb, measure, + label="ConvolutionReverb D6") + self.assertFalse(result["null"]["passed"]) + + +# -------------------------------------------------------------------------- +# Tier 1, the fast half +# -------------------------------------------------------------------------- + +class Tier1Fast(unittest.TestCase): + def test_silence_stays_silence(self): + for channels in (2, 1): + effect = build(channels=channels) + for patch in sorted(ConvolutionReverb.PATCHES): + effect.program_change(patch) + out = run(effect, silence(8192, channels)) + self.assertEqual(int(np.max(np.abs(out))), 0, patch) + effect.deinit() + + def test_reset_empties_the_room(self): + # Built at patch 0, so reset's program_change(0) finds the room it + # holds and does not re-synthesize: only the reset clears it. + for cls, silent in ((ConvolutionReverb, True), (NoReset, False)): + effect = build(cls, patch=0) + burst = np.vstack([white(1024), silence(8192)]) + effect._source.swap(probes.ArraySource(burst, rate=RATE, + channels=2)) + audiocore.reset_buffer(effect.node) + for _ in range(6): + audiocore.get_buffer(effect.output) + effect.reset() + out = bytearray() + for _ in range(8): + out += bytes(audiocore.get_buffer(effect.output)[1]) + peak = int(np.max(np.abs(np.frombuffer(bytes(out), + dtype=np.int16)))) + if silent: + self.assertEqual(peak, 0) + else: + self.assertGreater(peak, 0) + effect.deinit() + + def test_deinit_leaves_the_source(self): + pcm = white(2048) + source = probes.ArraySource(pcm, rate=RATE, channels=2) + effect = ConvolutionReverb(source) + audiocore.get_buffer(effect.output) + node = effect.node + effect.deinit() + effect.deinit() + self.assertTrue(node._deinited) + data = bytes(audiocore.get_buffer(source)[1]) + self.assertGreater(int(np.max(np.abs(np.frombuffer( + data, dtype=np.int16)))), 0) + with self.assertRaises(RuntimeError): + effect.latency_samples + + def test_the_tail_reaches_exact_zero(self): + effect = build(mix=1.2) + burst = np.vstack([white(4096), silence(8192)]) + out = run(effect, burst) + self.assertEqual(int(np.max(np.abs(out[4096 + effect.tail_samples:]))), + 0) + self.assertGreater(int(np.max(np.abs( + out[4096 + effect.tail_samples - 512: + 4096 + effect.tail_samples]))), 0) + effect.deinit() + + +class ImpulseFiles(unittest.TestCase): + """The loader: a 16-bit WAV at the graph's rate, read once.""" + + def write(self, folder, rate, channels, frames): + path = os.path.join(folder, "ir_%d_%d.wav" % (rate, channels)) + with wave.open(path, "wb") as handle: + handle.setnchannels(channels) + handle.setsampwidth(2) + handle.setframerate(rate) + handle.writeframes(make_impulse(frames, channels).tobytes()) + return path + + def test_a_wav_loads_the_same_room_as_its_frames(self): + with tempfile.TemporaryDirectory() as folder: + for channels in (1, 2): + path = self.write(folder, RATE, channels, 900) + from_file = build(impulse=path) + from_bytes = build( + impulse=make_impulse(900, channels).tobytes(), + impulse_channels=channels) + pcm = white(4096) + self.assertEqual(digest(run(from_file, pcm)), + digest(run(from_bytes, pcm))) + self.assertEqual(from_file.node.taps, 1024) + from_file.deinit() + from_bytes.deinit() + + def test_a_wav_at_another_rate_raises_naming_both(self): + with tempfile.TemporaryDirectory() as folder: + path = self.write(folder, 44100, 1, 900) + with self.assertRaises(ValueError) as caught: + build(impulse=path) + self.assertIn("44100", str(caught.exception)) + self.assertIn("48000", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() From f15acc4f184da2fb13c7ff156b1e30f687437697 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:36:47 -0500 Subject: [PATCH 39/88] ConvolutionReverb Station B fix round: a start_ms that trims away every frame raises instead of building a dead-Mix wire; the docstring says a room-knob move drops the 256 frames in flight, dry included, at every Mix, and a test pins it at three rates, stereo and mono --- lib/audioeffects/rebuilt/convolutionreverb.py | 38 ++++++--- .../test_cpython_effects_convolutionreverb.py | 77 +++++++++++++++++++ 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index f911f8e..0457a86 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -54,6 +54,7 @@ impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is the source delayed by exactly `latency_samples`, byte for byte, because the node stays in the path at Mix 0 and a Mix move never jumps the timeline. +The one exception is the partition a room knob moves in (below). **Tail.** `tail_samples` is `latency_samples` plus the loaded impulse rounded up to a partition: 4 096 frames (85.3 ms) at the default at @@ -65,12 +66,19 @@ 44.1 kHz and 76 008 B at 22.05 kHz. A measured impulse is read once at construction, handed to the node and dropped; the class keeps no copy. -**Moving a room knob starts the room empty.** Decay, Damping, Predelay, -Diffusion and Room re-synthesize the impulse, and the node empties its -history when it does, so a tail ringing at that moment stops dead. A move -that lands on the room already loaded does nothing, and a patch change, -the constructor and `reset()` synthesize once, not once per knob. Mix moves -never touch the room. +**Moving a room knob starts the room empty and drops one partition of +everything.** Decay, Damping, Predelay, Diffusion and Room re-synthesize +the impulse, and the node empties itself when it does: a tail ringing at +that moment stops dead, and the 256 frames in flight (5.333 ms at 48 kHz), +dry and wet alike, come out as exact zero. That is a gap in your dry +signal at every Mix, Mix 0 included; after it, Mix 0 is the source delayed +by `latency_samples` again, byte for byte. The class cannot avoid it from +Python (the node's `synthesize()` ends in a reset of its pending and +output blocks; the node ask is drafted), so do not sweep a room knob under +a signal you need unbroken. A move that lands on the room already loaded +does nothing and drops nothing, and a patch change, the constructor and +`reset()` synthesize once, not once per knob. Mix moves never touch the +room. **What the default surrenders.** The room is normalised to unit energy across the whole band, so with Damping in, low material comes back louder @@ -88,7 +96,8 @@ nothing on a board, then loaded at unit mean energy across the channels the room holds, trimmed by `ir_gain_db` (-24 to +12 dB). A stereo impulse over a mono source keeps its left channel. An impulse with frames but no -energy raises. Decay, Damping, Predelay, Diffusion and Room raise +energy raises, and so does a `start_ms` that trims away every frame it +has: either would be a room whose Mix does nothing. Decay, Damping, Predelay, Diffusion and Room raise `IndexError` from `set_macro` and `get_macro` in this mode: the loaded impulse is the room. `live_macros` says which macros an instance has. No impulse ships with this class; it loads yours and keeps no copy (Brad's @@ -252,7 +261,8 @@ class ConvolutionReverb(_component.Component): **What the default surrenders:** a dark room lifts low material (a low chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), moving a - room knob starts the room empty, and anything longer than 0.091 s on + room knob starts the room empty and drops the 256 frames in flight, + dry included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). """ @@ -395,7 +405,17 @@ def _load_impulse(self, impulse, impulse_channels, ir_gain_db, raise ValueError("%s: impulse length must be whole int16 frames" % self.NAME) frames = size // (2 * impulse_channels) - trim = min(frames, self._trim_frames(start_ms)) + trim = self._trim_frames(start_ms) + if frames and trim >= frames: + # Clamping would build the unloaded wire, a Mix that does + # nothing, with no error (ruling (o)). Only `impulse=b""` is + # the deliberate empty room. + raise ValueError( + "%s: start_ms=%r trims %d frames at %d Hz and the impulse " + "has %d; the trim leaves no room, and a Mix that does " + "nothing" % (self.NAME, start_ms, trim, rate, frames)) + if trim > frames: + trim = frames frames -= trim per_frame = impulse_channels * (2 // width) view = view[trim * per_frame:] diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index d49f666..02e9d62 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -505,6 +505,28 @@ def test_impulse_level_and_shape_are_checked(self): with self.assertRaises(ValueError): build(impulse=make_impulse().tobytes(), start_ms=200.5) + def test_a_trim_past_the_impulse_raises(self): + # A clamped trim would build the unloaded wire, whose Mix does + # nothing, with no error (review probe + # convolutionreverb_review_trimall.py; ruling (o)). 100 frames is + # 2.083 ms at 48 kHz: 2.0 ms trims 96 and builds a 256-tap room. + h = make_impulse(100).tobytes() + effect = build(impulse=h, start_ms=2.0) + self.assertEqual(effect.node.taps, 256) + self.assertEqual(effect.latency_samples, LATENCY) + effect.deinit() + for start_ms, trim in ((2.1, 100), (10.0, 480)): + with self.assertRaises(ValueError) as caught: + build(impulse=h, start_ms=start_ms) + text = str(caught.exception) + for field in (NAME, "start_ms=%r" % start_ms, "%d frames" % trim, + "48000 Hz", "has 100"): + self.assertIn(field, text) + # The deliberate empty room stays the undelayed wire (D3). + effect = build(impulse=b"", start_ms=10.0) + self.assertEqual((effect.node.taps, effect.latency_samples), (0, 0)) + effect.deinit() + def test_an_int16_array_is_trimmed_by_frames(self): from array import array h = make_impulse(1000, 2) @@ -580,6 +602,61 @@ def test_a_room_move_starts_the_room_empty_and_a_mix_move_does_not(self): effect.deinit() clean.deinit() + def test_a_room_move_drops_the_partition_in_flight_dry_included(self): + # The docstring's other half, measured: the reset at the end of + # synthesize() (audiodsp_convolve.c:254) zeroes the pending and + # output blocks too, so at Mix 0 the source frames in flight at the + # move (2304..2559 for a move before block 10) come out as exact + # zero, and every other frame is the source 256 late. A move onto + # the room already loaded, and a Mix move that stays at 0, drop + # nothing (review probe convolutionreverb_review_movedrop.py). + frames = 40 * 256 + moves = ((DECAY_I, 64), (DAMPING_I, 30), (PREDELAY_I, 40), + (DIFFUSION_I, 100), (ROOM_I, 50), (DECAY_I, None), + (MIX_I, 0)) + for rate in RATES: + for channels in (2, 1): + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( + np.int16) + pcm = np.repeat(pcm[:, None], channels, axis=1) + for index, value in moves: + effect = build(rate=rate, channels=channels, mix=0.0) + effect._source.swap(probes.ArraySource( + pcm, rate=rate, channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for block in range(40): + if block == 10: + effect.set_macro(index, effect.get_macro(index) + if value is None else value) + out += bytes(audiocore.get_buffer(effect.output)[1]) + out = np.frombuffer(bytes(out), dtype=np.int16).reshape( + -1, channels) + want = np.vstack([silence(LATENCY, channels), + pcm[:frames - LATENCY]]) + cut = index != MIX_I and value is not None + if cut: + want[2560:2816] = 0 + self.assertEqual(digest(out), digest(want), + (rate, channels, index, value)) + self.assertEqual(bool(np.any(pcm[2304:2560] != 0)), True) + effect.deinit() + # At the constructor's Mix 0.6 the same partition reads exact zero. + effect = build(channels=2) + pcm = white(frames) + effect._source.swap(probes.ArraySource(pcm, rate=RATE, channels=2)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for block in range(40): + if block == 10: + effect.set_macro(DECAY_I, 64) + out += bytes(audiocore.get_buffer(effect.output)[1]) + out = np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, 2) + self.assertGreater(int(np.max(np.abs(out[2304:2560]))), 1000) + self.assertEqual(int(np.max(np.abs(out[2560:2816]))), 0) + self.assertGreater(int(np.max(np.abs(out[2816:3072]))), 1000) + effect.deinit() + # -------------------------------------------------------------------------- # D1 - measured mode is exactly convolution, within one output LSB From ec9d72810cf68e3788ad679238cb6f1d7dbf17cc Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:58:51 -0500 Subject: [PATCH 40/88] Reverb Station B: the rebuild and its tests One audioverb.Tank cut four ways from Dattorro's table (plate, room, chamber, hall; the spring parks), thirteen macros, ten patches, the Decay law with its low-frequency ceiling and the plate's damper law, Character and Size rebuilding the Tank once per move, tail_samples on the handed decay's bass T60. Built against the dossier frozen at anchor 34753de. Tests: T1-T3 and T7-T11 at a claimed cell, each red on its planted fault, the fault unreachable from every macro position and patch, and red on the wire; Tier 1 at every patch, stereo and mono. --- lib/audioeffects/rebuilt/reverb.py | 609 +++++++++++++ tests/test_cpython_effects_reverb.py | 1223 ++++++++++++++++++++++++++ 2 files changed, 1832 insertions(+) create mode 100644 lib/audioeffects/rebuilt/reverb.py create mode 100644 tests/test_cpython_effects_reverb.py diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py new file mode 100644 index 0000000..c4379ac --- /dev/null +++ b/lib/audioeffects/rebuilt/reverb.py @@ -0,0 +1,609 @@ +"""`Reverb` - Dattorro's plate network, cut four ways: an EMT 140 plate, a +room, a chamber and a hall. + +Rebuilt from scratch for Phase 5 against +`workspace docs/effects-internal/dossiers/Reverb.md`, whose trait table was +frozen at Station A before this file existed (anchor commit +34753deba8a9d542050d42eaa9f756bcd160951f, the Station A critique's +re-freeze, 2026-09-28). The old class in `reverb.py` is consulted only for +the seven defects that dossier's section 7 names; it stays the class the +library serves until the board runner adopts this one. + +**What it sounds like.** Your dry signal passes untouched and a reverb +tail rises behind it. Character picks the machine: `plate` is dense from +the first milliseconds, the way a steel sheet is, while `room`, `chamber` +and `hall` start sparse and build, and differ in how long and how late +they are. Decay is the time the tail takes to fall 60 dB at 500 Hz (0.3 to +10 s). Size stretches every line of the network (0.5 to 1.5). Predelay +holds the tail back from the dry (0 to 200 ms). Diffusion smears the early +echoes (0 to 0.9). Damping darkens the tail as it rings (500 Hz to 16 kHz; +on the plate it is where the damper ends up at the shortest Decays, see +below). Bandwidth darkens what goes in, Low Cut keeps the bass out of the +tank while the dry keeps it (20 to 500 Hz). Mod Depth and Mod Rate wobble +two lines inside the tank so it does not ring on fixed pitches. Width +narrows the image to mono at 0. Tone tilts the tail about 1 kHz. Mix is +`audiodelays.Echo`'s: the dry at unity until 1, the tail alone at 2, and +Mix 0 is a byte-exact wire while the tank keeps ringing behind it. + +**The plate's damper.** On the plate, Decay also moves the tail's loss +corner, the way the EMT 140's damping panel does: fully open at Decay 8 s +and above, at the Damping setting at 1 s and below, geometric in log Decay +between. So a short plate is a darker plate, and the upper bands shorten +more than the lower ones as you turn Decay down. + +**The standout:** the EMT 140 plate (Russo's thesis on its physics) and +Dattorro's "Effect Design, Part 1" network, which he wrote as the plate +class of reverberator; the room, chamber and hall are that network +re-proportioned. The Fender 6G15 spring the dossier also studied is not +here: the tank has no dispersive chain and the palette cannot build one +that chirps upward (dossier section 8.6), so the spring character is +parked until the node carries one. Asking for `character="spring"` says so. + +**Portability tier: audiodsp** (`REQUIRES = ("audioverb",)`). The whole wet +path is one `audioverb.Tank`. On a stock CircuitPython board this module +imports cleanly and construction raises `ImportError`. + +**Character and Size are set-and-leave.** The Tank's line lengths are fixed +when it is built, so a Character move to another zone, or a Size move that +changes the line set (almost any Size move does), builds a new Tank: the +tail drops to nothing at the move, and you will hear the reverb cut off. +The order is fixed: the output plays the dry alone, the old Tank is +released, the new one is built with every other setting applied and +starts from empty lines. Set them before you play, not while a tail rings. +A patch that changes both rebuilds once. + +**Decay is T60 at 500 Hz, between two knees.** The label holds to within +12 % over most of the span (dossier T11). Below each character's *floor +knee* its own diffusers and taps ring longer than the label, so the +shortest Decays land at the floor (0.24 to 1.38 s over characters and +Size) and the knob does least there; under the knee the delivered time can +even step backward a little as the label rises. Above the *ceiling knee*, +which a low Damping corner brings down on the room, chamber and hall, the +class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the +label. Both knees are tabled per character and Size in the dossier's +section 8.9; the plate has no ceiling knee on the span. + +**A sparse, quiet input decays sooner than the knob.** The tank's lines are +16-bit and truncate toward zero, which is what lets the tail reach exact +silence, but it also bites into a quiet tail: an impulse of 1 000 LSB on +an 8 s plate reads 2.97 s, where interrupted noise at 8 000 LSB RMS reads +8.29 s (dossier section 8.7). Every Decay figure here is from interrupted +noise at 8 000 LSB RMS. + +**The patches.** Steel Plate (the defaults), Short Plate, Damped Plate, +Bass-Free Plate (Low Cut at the 6G15's 360 Hz), Small Room, Live Room, +Concert Hall, Dark Chamber, Bright Chamber, Slow Bloom. Small Room's Decay +reads 0.45 s and it rings 0.55 s at 500 Hz: it sits under the room's floor +knee, and ships so because the rooms must stay that much shorter than the +hall. + +**Latency: zero samples, at every setting, character and rate.** Nothing +looks ahead. Predelay is the wet path, not latency on the dry. + +**Mono.** The tank sums its input to one signal either way; a one-channel +source gets the mono fold-down of the stereo tank, both tap sets in the one +lane, halved (`audiodsp_tank.c:571-580`). + +**Tone** spans +/-12 dB of end-to-end tilt, which the node puts half at +each end: +12 is about -6 dB at 40 Hz and +6 dB at 16 kHz. + +**Input ceiling.** The tank adds dry and wet before it rounds and clamps +at the int16 rail. On 2 s of uniform noise at Mix 1, nothing reaches the +rail at 4 000 LSB RMS (-18.3 dBFS) at any patch; at 8 000 LSB RMS +(-12.3 dBFS) eight of the ten patches do, up to 446 samples in 2 s on +Bright Chamber. There is no input gain to turn down. + +**RAM.** One int16 allocation: the twelve lines plus 200 ms of predelay. +89 714 B for Steel Plate at 48 kHz; 146 914 B for the hall at Size 1.5, the +most the class allocates. A Character or Size move frees the old Tank +before it builds the new one. A board that cannot afford the larger lines +fails the move with the allocation's `MemoryError`, and the output is then +the dry alone until a move that fits. + +**Cost.** One `audioverb.Tank`, no mixer (the Tank sums the dry itself), +so no glue: **P4 <= 19 %, S3 <= 33 %** of a 5.333 ms stereo block, from the +cost table's Tank row at Dattorro's network. The board measurement is +pending hardware, and the row did not run Low Cut, Tone or Width below 1, +which most patches do. No `" - lean"` patch: every patch runs the same +graph. + +**Tail.** `tail_samples` bounds the frames until the output is exactly zero +once your input stops: fs x (Predelay + 1.6 x max(1.2 x T_lf, 1.5 x Size)), +T_lf being the low-frequency T60 the handed `decay` gives, the longest any +band rings. 222 868 frames (4.64 s) at the defaults at 48 kHz. + +`capabilities = ()`: nothing here reads the host's transport. + +A value outside a macro's span clamps to the nearer stop; NaN takes the +option's default; `character` must be `"plate"`, `"room"`, `"chamber"` or +`"hall"`. +""" + +VENDOR = "PyDevices" + +import math + +from .. import _component + +try: + import audioverb +except ImportError: # pragma: no cover - a stock board + audioverb = None + + +#: Dattorro's reference rate: his Fig. 1 lengths are frames at 29 761 Hz. +REF_RATE = 29761.0 + +#: Dattorro Fig. 1's twelve lines, in the Tank's order: four input +#: diffusers, then half A (modulated all-pass, delay, all-pass, delay) and +#: half B the same. +DATTORRO_LINES = (142, 107, 379, 277, 672, 4453, 1800, 3720, + 908, 4217, 2656, 3163) + +#: Dattorro Table 2's fourteen output taps, (channel, line, offset, gain). +DATTORRO_TAPS = ( + (0, 9, 266, 0.6), (0, 9, 2974, 0.6), (0, 10, 1913, -0.6), + (0, 11, 1996, 0.6), (0, 5, 1990, -0.6), (0, 6, 187, -0.6), + (0, 7, 1066, -0.6), + (1, 5, 353, 0.6), (1, 5, 3627, 0.6), (1, 6, 1228, -0.6), + (1, 7, 2673, 0.6), (1, 9, 2111, -0.6), (1, 10, 335, -0.6), + (1, 11, 121, -0.6), +) + +CHARACTERS = ("plate", "room", "chamber", "hall") +PLATE, ROOM, CHAMBER, HALL = range(4) + +#: Each character's ratio over Dattorro's twelve lines (dossier App. A8.8). +#: The plate is his tank with its input diffusers at 0.3 of his, so the +#: density is there by 20 ms (T1); the others stretch the diffusers so the +#: density builds (T8) and re-proportion the tank. No two rows are +#: proportional, so no Size makes two characters' line sets equal (T9). +RATIOS = ( + (0.30, 0.30, 0.30, 0.30, 1.00, 1.00, 1.00, 1.00, + 1.00, 1.00, 1.00, 1.00), + (4.50, 4.20, 3.40, 3.70, 0.50, 0.36, 0.44, 0.40, + 0.52, 0.38, 0.42, 0.37), + (4.00, 4.30, 3.00, 3.40, 0.80, 0.62, 0.70, 0.66, + 0.78, 0.64, 0.72, 0.60), + (2.00, 1.86, 1.60, 1.78, 1.30, 1.12, 1.24, 1.08, + 1.26, 1.16, 1.20, 1.10), +) + +#: The Decay law's per-character constant (dossier section 4), each a +#: multiple of 1/64 so every float format holds it exactly. +KAPPA = (1.078125, 1.046875, 1.078125, 1.046875) + +#: The Decay law's low-frequency ceiling: the loop's bass never rings longer +#: than LF_CAP x Decay (dossier section 4; the 1.5 is the design's). +LF_CAP = 1.5 + +#: The node refuses a line under 4 frames (`audiodsp_tank.c:145-146`); the +#: shortest the class cuts anywhere on its span is 12. +MIN_LINE = 4 + +#: The Tank's predelay allocation, fixed at construction; the Predelay +#: macro's top. +MAX_PREDELAY_MS = 200.0 + +#: Where each character's zone sits on the 0-127 grid when the constructor +#: or a patch names it: plate 0-31, room 32-63, chamber 64-95, hall 96-127. +CHARACTER_MIDI = (0, 42, 85, 127) + +#: The plate's damper law: open at DAMPER_LONG_S, the Damping setting at +#: DAMPER_SHORT_S, geometric in log Decay between (dossier section 4, T3). +DAMPER_LONG_S = 8.0 +DAMPER_SHORT_S = 1.0 + +#: The frequency Decay is stated at. +DECAY_HZ = 500.0 + +(CHARACTER_I, DECAY_I, SIZE_I, PREDELAY_I, DIFFUSION_I, DAMPING_I, + BANDWIDTH_I, LOW_CUT_I, MOD_DEPTH_I, MOD_RATE_I, WIDTH_I, TONE_I, + MIX_I) = range(13) + +#: The constructor defaults, in macro order, which NaN falls back to. +DEFAULTS = (0.0, 2.4, 1.0, 0.0, 0.75, 1000.0, 12000.0, 40.0, 0.27, 1.0, + 1.0, 0.0, 0.35) + + +# -- the cut ----------------------------------------------------------------- + +def line_set(index, size, sample_rate, ratios=None): + """The twelve line lengths, in frames, of character `index` at `size` + and `sample_rate`: round(n x fs / 29 761 x size x r) per line.""" + ratios = RATIOS[index] if ratios is None else ratios + fs = float(sample_rate) + size = float(size) + out = [] + for n, r in zip(DATTORRO_LINES, ratios): + frames = int(round(n * fs / REF_RATE * size * r)) + out.append(frames if frames > MIN_LINE else MIN_LINE) + return out + + +def tap_table(index, size, sample_rate, ratios=None, lines=None): + """The fourteen taps, flattened as the Tank takes them: each offset + scaled by its own line's factor and held under that line's length.""" + ratios = RATIOS[index] if ratios is None else ratios + if lines is None: + lines = line_set(index, size, sample_rate, ratios) + fs = float(sample_rate) + size = float(size) + out = [] + for channel, line, offset, gain in DATTORRO_TAPS: + at = int(round(offset * fs / REF_RATE * size * ratios[line])) + if at > lines[line] - 1: + at = lines[line] - 1 + out.extend((channel, line, at, gain)) + return out + + +def half_periods(lines): + """Frames around each half of the figure-eight: lines 4-7 and 8-11.""" + return (lines[4] + lines[5] + lines[6] + lines[7], + lines[8] + lines[9] + lines[10] + lines[11]) + + +# -- the laws ---------------------------------------------------------------- + +def one_pole_mag(corner_hz, frequency, sample_rate): + """|H| at `frequency` of the node's loop one-pole, whose coefficient is + 1 - exp(-2 pi corner / fs).""" + if corner_hz <= 0.0: + return 1.0 + a = 1.0 - math.exp(-2.0 * math.pi * corner_hz / sample_rate) + w = 2.0 * math.pi * frequency / sample_rate + b = 1.0 - a + re = 1.0 - b * math.cos(w) + im = b * math.sin(w) + return a / math.sqrt(re * re + im * im) + + +def damper_hz(decay_s, damping_hz, sample_rate): + """The plate's loop corner at a Decay: 0.98 x Nyquist at 8 s and above, + `damping_hz` at 1 s and below, geometric in log Decay between.""" + top = 0.98 * sample_rate * 0.5 + t = decay_s + if t < DAMPER_SHORT_S: + t = DAMPER_SHORT_S + if t > DAMPER_LONG_S: + t = DAMPER_LONG_S + x = math.log(DAMPER_LONG_S / t) / math.log(DAMPER_LONG_S / DAMPER_SHORT_S) + return top * (damping_hz / top) ** x + + +def decay_law(kappa, lines, sample_rate, decay_s, loop_hz): + """The Tank's `decay` for a T60 of `decay_s` at 500 Hz. + + One pass through a half multiplies by `decay` twice, the cross-feed + read and the in-loop multiply (`audiodsp_tank.c:537-538`, `:552`), and + the loop one-pole once, so + + decay = min(10^(-3 k P / (2 T fs)) / sqrt|H(500 Hz)|, + 10^(-3 k P / (2 x 1.5 T fs))) + + with P the mean half period in frames. The first term lifts `decay` by + the one-pole's loss at 500 Hz; the second caps the bass (where the + one-pole is 1) at 1.5 x T. Returns (decay, capped, t_lf): `t_lf` is the + low-frequency T60 the handed decay gives, which bounds the tail.""" + a, b = half_periods(lines) + p = 0.5 * (a + b) + fs = float(sample_rate) + mag = one_pole_mag(loop_hz, DECAY_HZ, fs) + d = 10.0 ** (-3.0 * kappa * p / (2.0 * decay_s * fs)) / math.sqrt(mag) + ceiling = 10.0 ** (-3.0 * kappa * p / (2.0 * LF_CAP * decay_s * fs)) + capped = d > ceiling + if capped: + d = ceiling + if d > 0.999: + d = 0.999 + if d < 0.0: + d = 0.0 + if 0.0 < d < 1.0: + t_lf = -3.0 * kappa * p / (2.0 * fs * math.log10(d)) + else: # pragma: no cover - never on the span + t_lf = float("inf") + return d, capped, t_lf + + +def tail_frames(sample_rate, predelay_ms, t_lf, size): + """Tier 3's bound: ceil(fs x (Predelay + 1.6 x max(1.2 x T_lf, + 1.5 x Size))).""" + longest = 1.2 * t_lf + floor = 1.5 * size + if floor > longest: + longest = floor + return int(math.ceil(sample_rate * (predelay_ms / 1000.0 + + 1.6 * longest))) + + +def _option(value, default): + """A constructor option as a float; NaN is the option's default.""" + value = float(value) + if value != value: + return default + return value + + +def _between(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +class Reverb(_component.Component): + """Dattorro's network as four machines: an EMT 140 plate that is dense + at once and darkens as Decay shortens, and a room, chamber and hall + that build. Decay is T60 at 500 Hz. audiodsp tier; zero latency. + + **What the default surrenders:** Decay holds its label only between a + floor knee and a ceiling knee (dossier section 8.9); a sparse, quiet + input decays sooner than the knob; Character and Size rebuild the tank + and cut the tail; the spring character is parked. + """ + + NAME = 'Reverb' + DISPLAY_NAME = 'Reverb' + CATEGORIES = ('Reverb',) + VERSION = '0.1.0' + + TIER = _component.AUDIODSP + REQUIRES = ("audioverb",) + + CAPABILITIES = () + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = None + + MACRO_LABELS = ("Character", "Decay", "Size", "Predelay", "Diffusion", + "Damping", "Bandwidth", "Low Cut", "Mod Depth", + "Mod Rate", "Width", "Tone", "Mix") + MACRO_MODES = { + 0: "UNIPOLAR", + 1: "UNIPOLAR", + 2: "UNIPOLAR", + 3: "UNIPOLAR", + 4: "UNIPOLAR", + 5: "UNIPOLAR", + 6: "UNIPOLAR", + 7: "UNIPOLAR", + 8: "UNIPOLAR", + 9: "UNIPOLAR", + 10: "UNIPOLAR", + 11: "BIPOLAR", + 12: "UNIPOLAR", + } + _MACRO_RANGES = ( + (0.0, 4.0), # 0 Character, zone min(3, int(v)) + (0.3, 10.0, "log"), # 1 Decay, T60 at 500 Hz, s + (0.5, 1.5), # 2 Size, x the character's lines + (0.0, MAX_PREDELAY_MS), # 3 Predelay, ms + (0.0, 0.9), # 4 Diffusion + (500.0, 16000.0, "log"), # 5 Damping, Hz + (500.0, 20000.0, "log"), # 6 Bandwidth, Hz + (20.0, 500.0, "log"), # 7 Low Cut, Hz + (0.0, 2.0), # 8 Mod Depth, ms half swing + (0.1, 5.0, "log"), # 9 Mod Rate, Hz + (0.0, 1.0), # 10 Width + (-12.0, 12.0), # 11 Tone, dB end to end + (0.0, 2.0), # 12 Mix; dry at unity to 1 + ) + + #: `_component.macro_of` of the dossier's section 6 settings, Character + #: at 0 / 42 / 85 / 127; patch 0 is the constructor's defaults on the + #: grid. + PATCHES = { + 0: ("Steel Plate", + (0, 75, 64, 0, 106, 25, 109, 27, 17, 75, 127, 64, 22)), + 1: ("Short Plate", + (0, 50, 64, 0, 106, 40, 115, 43, 13, 81, 114, 74, 19)), + 2: ("Damped Plate", + (0, 44, 95, 0, 106, 12, 95, 55, 13, 75, 102, 48, 19)), + 3: ("Bass-Free Plate", + (0, 65, 64, 0, 106, 25, 103, 114, 17, 75, 102, 74, 25)), + 4: ("Small Room", + (42, 15, 25, 1, 85, 91, 103, 43, 13, 68, 102, 64, 16)), + 5: ("Live Room", + (42, 44, 95, 3, 85, 102, 109, 36, 19, 63, 114, 74, 19)), + 6: ("Concert Hall", + (127, 86, 95, 16, 99, 84, 100, 32, 32, 58, 127, 64, 22)), + 7: ("Dark Chamber", + (85, 65, 64, 5, 99, 59, 86, 49, 19, 71, 102, 43, 20)), + 8: ("Bright Chamber", + (85, 61, 64, 5, 99, 116, 119, 59, 19, 71, 102, 85, 20)), + 9: ("Slow Bloom", + (127, 98, 127, 25, 113, 76, 95, 32, 64, 45, 127, 64, 29)), + } + + def _build(self, character="plate", decay=2.4, size=1.0, + predelay_ms=0.0, diffusion=0.75, damping_hz=1000.0, + bandwidth_hz=12000.0, low_cut_hz=40.0, mod_depth_ms=0.27, + mod_rate_hz=1.0, width=1.0, tone_db=0.0, mix=0.35, + patch=None): + if character == "spring": + raise ValueError( + "the spring character is parked: audioverb.Tank has no " + "dispersive chain yet, so Reverb ships plate, room, chamber " + "and hall") + if character not in CHARACTERS: + raise ValueError("character must be 'plate', 'room', 'chamber' " + "or 'hall'") + index = CHARACTERS.index(character) + self._tank = None + self._lines = None + self._taps = None + self._index = index + self._handed = {} + self._capped = False + self._t_lf = 0.0 + self._tail = 0 + #: True while several macros are applied at once (the constructor, + #: `program_change`); the Tank is refreshed once, after the last, so + #: a patch that moves Character and Size rebuilds it once. + self._deferred = False + spans = self._MACRO_RANGES + values = [4.0 * CHARACTER_MIDI[index] / 127.0] + options = (decay, size, predelay_ms, diffusion, damping_hz, + bandwidth_hz, low_cut_hz, mod_depth_ms, mod_rate_hz, + width, tone_db, mix) + for macro, value in zip(range(1, 13), options): + span = spans[macro] + values.append(_between(_option(value, DEFAULTS[macro]), + span[0], span[1])) + self._deferred = True + try: + self._init_macros(tuple(values)) + finally: + self._deferred = False + self._refresh() + if patch is not None: + self.program_change(patch) + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + return _component.macro_value(self._MACRO_RANGES[index], + self._macros[index]) + + def _character(self): + """The zone Character's position sits in: 0 plate .. 3 hall.""" + zone = int(self._value(CHARACTER_I)) + if zone > HALL: + return HALL + if zone < PLATE: + return PLATE + return zone + + # -- hooks a planted fault overrides -------------------------------- + + def _cut(self, index, size): + """(lines, taps) the Tank is built on.""" + lines = line_set(index, size, self._sample_rate) + return lines, tap_table(index, size, self._sample_rate, lines=lines) + + def _loop_hz(self, index, decay_s, damping): + """The loop one-pole's corner: the damper law on the plate, the + Damping setting elsewhere, clamped below Nyquist.""" + if index == PLATE: + return self._hz(damper_hz(decay_s, damping, self._sample_rate)) + return self._hz(damping) + + def _decay(self, index, lines, decay_s, loop_hz): + """(decay, capped, t_lf) by the Decay law.""" + return decay_law(KAPPA[index], lines, self._sample_rate, decay_s, + loop_hz) + + def _low_cut_hz(self, value): + return self._hz(value) + + def _mod_rate_hz(self, value): + return value + + def _mod_depth_ms(self, value, lines): + del lines + return value + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + del index, position + if not self._deferred: + self._refresh() + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + """Apply patch `index` whole, then refresh the Tank once, so a + patch that moves Character and Size rebuilds it once.""" + self._deferred = True + try: + _component.Component.program_change( + self, index, channel, note_id, sample_position) + finally: + self._deferred = False + if type(self).PATCHES.get(index) is not None: + self._refresh() + + def _disown(self, node): + for position in range(len(self._nodes) - 1, -1, -1): + if self._nodes[position] is node: + del self._nodes[position] + del self._resets[position] + del self._deinits[position] + + def _refresh(self): + fs = self._sample_rate + index = self._character() + size = _between(self._value(SIZE_I), 0.5, 1.5) + lines, taps = self._cut(index, size) + decay_s = _between(self._value(DECAY_I), 0.3, 10.0) + loop = self._loop_hz(index, decay_s, + _between(self._value(DAMPING_I), 500.0, + 16000.0)) + decay, capped, t_lf = self._decay(index, lines, decay_s, loop) + predelay = _between(self._value(PREDELAY_I), 0.0, MAX_PREDELAY_MS) + handed = { + "decay": decay, + "diffusion": _between(self._value(DIFFUSION_I), 0.0, 0.9), + "damping_hz": loop, + "bandwidth_hz": self._hz(_between(self._value(BANDWIDTH_I), + 500.0, 20000.0)), + "low_cut_hz": self._low_cut_hz( + _between(self._value(LOW_CUT_I), 20.0, 500.0)), + "predelay_ms": predelay, + "mod_depth_ms": self._mod_depth_ms( + _between(self._value(MOD_DEPTH_I), 0.0, 2.0), lines), + "mod_rate_hz": self._mod_rate_hz( + _between(self._value(MOD_RATE_I), 0.1, 5.0)), + "drive": 0.0, + "width": _between(self._value(WIDTH_I), 0.0, 1.0), + "tone_db": _between(self._value(TONE_I), -12.0, 12.0), + "mix": _between(self._value(MIX_I), 0.0, 2.0), + } + if (self._tank is None or index != self._index + or lines != self._lines or taps != self._taps): + self._rebuild(index, lines, taps, handed) + else: + self._tank.set(**handed) + self._handed = handed + self._capped = capped + self._t_lf = t_lf + self._tail = tail_frames(fs, predelay, t_lf, size) + + def _rebuild(self, index, lines, taps, handed): + """Replace the Tank (dossier section 8.8): the output plays the dry + alone, the old Tank is released, the new one is built with every + setting applied, plays the source, and the output points at it.""" + old = self._tank + if old is not None: + self._output = self._source + self._disown(old) + self._tank = None + self._lines = None + self._taps = None + old.deinit() + tank = audioverb.Tank( + sample_rate=self._sample_rate, + channel_count=self._channel_count, + max_predelay_ms=MAX_PREDELAY_MS, + delays=lines, + taps=taps, + **handed) + # The Tank's reset empties every line and filter + # (`audiodsp_tank_reset`), so the base's reset walk clears the tail. + self._tank = self._own(tank) + self._index = index + self._lines = lines + self._taps = taps + tank.play(self._source) + self._output = tank + + @property + def tail_samples(self): + """Frames until the output is exactly zero once the input stops, as + an upper bound (the dossier's Tier 3): the low-frequency T60 the + handed `decay` gives, x 1.2 for the law's error, or 1.5 x Size for + the character's own ringing, whichever is longer, x 1.6, plus the + predelay.""" + self._check_live() + return int(self._tail) diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py new file mode 100644 index 0000000..9086c03 --- /dev/null +++ b/tests/test_cpython_effects_reverb.py @@ -0,0 +1,1223 @@ +"""`Reverb`'s own invariant and planted-fault tests. + +The dossier is `workspace docs/effects-internal/dossiers/Reverb.md`; its +demonstrated Tier 2 rows are T1-T3 and T7-T11 (T4-T6 are the spring's and +park with it). Each row here is the measurement at one of its claimed +cells, the same measurement shown red on a fault of the same kind at the +constructor defaults, that fault shown unreachable from every macro +position and shipped patch, and the measurement shown red on the class +built as a wire. The measurements are the dossier's (App. E as Station A +restated them); the grids over Size, rate and channel count live in the +evidence pack, not in this file. +""" + +import math +import os +import sys +import unittest +from array import array + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import audiocore # noqa: E402 +import kit_faults # noqa: E402 +import kit_probes as probes # noqa: E402 +from audioeffects import _component # noqa: E402 +from audioeffects import rebuilt # noqa: E402 +from audioeffects.rebuilt import reverb as rv # noqa: E402 + +VENDOR = "PyDevices" + +Reverb = rv.Reverb +RATE = 48000 +SEEDS = tuple(range(7, 15)) +BURST_S = 2.0 +NOISE_RMS = 8000.0 + +#: Every shipped patch in engineering units, the dossier's section 6 table. +PATCH_SETTINGS = ( + ("Steel Plate", dict(character="plate", decay=2.4, size=1.0, + predelay_ms=0.0, diffusion=0.75, damping_hz=1000.0, + bandwidth_hz=12000.0, low_cut_hz=40.0, + mod_depth_ms=0.27, mod_rate_hz=1.0, width=1.0, + tone_db=0.0, mix=0.35)), + ("Short Plate", dict(character="plate", decay=1.2, size=1.0, + predelay_ms=0.0, diffusion=0.75, damping_hz=1500.0, + bandwidth_hz=14000.0, low_cut_hz=60.0, + mod_depth_ms=0.2, mod_rate_hz=1.2, width=0.9, + tone_db=2.0, mix=0.30)), + ("Damped Plate", dict(character="plate", decay=1.0, size=1.25, + predelay_ms=0.0, diffusion=0.75, damping_hz=700.0, + bandwidth_hz=8000.0, low_cut_hz=80.0, + mod_depth_ms=0.2, mod_rate_hz=1.0, width=0.8, + tone_db=-3.0, mix=0.30)), + ("Bass-Free Plate", dict(character="plate", decay=1.8, size=1.0, + predelay_ms=0.0, diffusion=0.75, + damping_hz=1000.0, bandwidth_hz=10000.0, + low_cut_hz=360.0, mod_depth_ms=0.27, + mod_rate_hz=1.0, width=0.8, tone_db=2.0, + mix=0.40)), + ("Small Room", dict(character="room", decay=0.45, size=0.7, + predelay_ms=2.0, diffusion=0.6, damping_hz=6000.0, + bandwidth_hz=10000.0, low_cut_hz=60.0, + mod_depth_ms=0.2, mod_rate_hz=0.8, width=0.8, + tone_db=0.0, mix=0.25)), + ("Live Room", dict(character="room", decay=1.0, size=1.25, + predelay_ms=4.0, diffusion=0.6, damping_hz=8000.0, + bandwidth_hz=12000.0, low_cut_hz=50.0, + mod_depth_ms=0.3, mod_rate_hz=0.7, width=0.9, + tone_db=2.0, mix=0.30)), + ("Concert Hall", dict(character="hall", decay=3.2, size=1.25, + predelay_ms=25.0, diffusion=0.7, damping_hz=5000.0, + bandwidth_hz=9000.0, low_cut_hz=45.0, + mod_depth_ms=0.5, mod_rate_hz=0.6, width=1.0, + tone_db=0.0, mix=0.35)), + ("Dark Chamber", dict(character="chamber", decay=1.8, size=1.0, + predelay_ms=8.0, diffusion=0.7, damping_hz=2500.0, + bandwidth_hz=6000.0, low_cut_hz=70.0, + mod_depth_ms=0.3, mod_rate_hz=0.9, width=0.8, + tone_db=-4.0, mix=0.32)), + ("Bright Chamber", dict(character="chamber", decay=1.6, size=1.0, + predelay_ms=8.0, diffusion=0.7, + damping_hz=12000.0, bandwidth_hz=16000.0, + low_cut_hz=90.0, mod_depth_ms=0.3, + mod_rate_hz=0.9, width=0.8, tone_db=4.0, + mix=0.32)), + ("Slow Bloom", dict(character="hall", decay=4.5, size=1.5, + predelay_ms=40.0, diffusion=0.8, damping_hz=4000.0, + bandwidth_hz=8000.0, low_cut_hz=45.0, + mod_depth_ms=1.0, mod_rate_hz=0.4, width=1.0, + tone_db=0.0, mix=0.45)), +) +SETTINGS = dict(PATCH_SETTINGS) + +#: Each character's reference patch (T11). +REFERENCE = {"plate": "Steel Plate", "room": "Live Room", + "chamber": "Dark Chamber", "hall": "Concert Hall"} + +#: Clean measurements shared between a row's test and its null build's +#: control, so the eight-seed rows render once. +_MEMO = {} + + +# -- planted faults ---------------------------------------------------------- + +def _cut_with(sample_rate, index, size, ratios): + lines = rv.line_set(index, size, sample_rate, ratios) + return lines, rv.tap_table(index, size, sample_rate, ratios, lines) + + +class DattorroDiffuserPlate(Reverb): + """T1: the plate on Dattorro's published input diffusers (ratio 1.0, + not the class's 0.3).""" + + NAME = 'Reverb' + + def _cut(self, index, size): + if index != rv.PLATE: + return Reverb._cut(self, index, size) + ratios = (1.0, 1.0, 1.0, 1.0) + rv.RATIOS[rv.PLATE][4:] + return _cut_with(self._sample_rate, index, size, ratios) + + +class InvertedCut(Reverb): + """T2: every line and tap cut with the rate ratio upside down, + round(n x 29 761 / fs x Size x r).""" + + NAME = 'Reverb' + + def _cut(self, index, size): + ratios = rv.RATIOS[index] + k = rv.REF_RATE / self._sample_rate + lines = [max(rv.MIN_LINE, int(round(n * k * size * r))) + for n, r in zip(rv.DATTORRO_LINES, ratios)] + taps = [] + for ch, line, off, gain in rv.DATTORRO_TAPS: + o = min(int(round(off * k * size * ratios[line])), + lines[line] - 1) + taps.extend((ch, line, o, gain)) + return lines, taps + + +class FlatDamper(Reverb): + """T3: the plate's corner held at the Damping value at every Decay (the + damper law off; the Decay law still compensates 500 Hz).""" + + NAME = 'Reverb' + + def _loop_hz(self, index, decay_s, damping): + return self._hz(damping) + + +class LowCutRadians(Reverb): + """T7 wet: Low Cut handed divided by 2 pi (a rad/s slip).""" + + NAME = 'Reverb' + + def _low_cut_hz(self, value): + return self._hz(value / (2.0 * math.pi)) + + +class PlateDiffuserRooms(Reverb): + """T8: room, chamber and hall on the plate's input diffusers.""" + + NAME = 'Reverb' + + def _cut(self, index, size): + if index == rv.PLATE: + return Reverb._cut(self, index, size) + ratios = rv.RATIOS[rv.PLATE][:4] + rv.RATIOS[index][4:] + return _cut_with(self._sample_rate, index, size, ratios) + + +class HallOnRoomLines(Reverb): + """T9 (M8's own): the hall built on the room's lines and taps.""" + + NAME = 'Reverb' + + def _cut(self, index, size): + if index == rv.HALL: + return Reverb._cut(self, rv.ROOM, size) + return Reverb._cut(self, index, size) + + +class RateReciprocal(Reverb): + """T10 on-clause: Mod Rate handed as its reciprocal in ms.""" + + NAME = 'Reverb' + + def _mod_rate_hz(self, value): + return 1000.0 / value + + +def depth_ceiling_ms(lines, sample_rate): + """The node's own Mod Depth ceiling, half the shorter modulated line + less a frame (`audiodsp_tank.c:328-336`).""" + return min(lines[4] - 2, lines[8] - 2) * 0.5 * 1000.0 / sample_rate + + +class DepthMirrored(Reverb): + """T10 off-clause: Mod Depth handed mirrored about the node's ceiling.""" + + NAME = 'Reverb' + + def _mod_depth_ms(self, value, lines): + return depth_ceiling_ms(lines, self._sample_rate) - value + + +class OneMultiply(Reverb): + """T11: the law counting one decay multiply per half pass, not the + node's two (`audiodsp_tank.c:537-538`, `:552`).""" + + NAME = 'Reverb' + + def _decay(self, index, lines, decay_s, loop_hz): + d, capped, t_lf = Reverb._decay(self, index, lines, decay_s, loop_hz) + return d * d, capped, 0.5 * t_lf + + +# -- probes and rendering ---------------------------------------------------- + +def interleave(mono, channels, frames): + """`mono` (int16) on every channel, padded with silence to `frames`: + the Tank advances only for frames that arrive.""" + x = np.zeros(frames, dtype=np.int16) + n = min(len(mono), frames) + x[:n] = np.asarray(mono[:n], dtype=np.int16) + return array("h", np.repeat(x, channels).tobytes()) + + +def make(cls, name=None, rate=RATE, channels=2, mono=(), frames=512, + **override): + settings = dict(SETTINGS[name]) if name else {} + settings.update(override) + src = probes.ArraySource(interleave(np.asarray(mono), channels, frames), + rate=rate, channels=channels, block=256) + return cls(src, sample_rate=rate, **settings) + + +def render(effect, frames): + channels = effect.channel_count + out = array("h") + while len(out) < frames * channels: + out.extend(memoryview(bytes( + audiocore.get_buffer(effect.output)[1])).cast("h")) + return np.array(out[:frames * channels], dtype=np.int32).reshape( + -1, channels) + + +def noise_burst(rate, seed=7, rms=NOISE_RMS, seconds=BURST_S): + rng = np.random.RandomState(seed) + n = int(seconds * rate) + return np.round(rng.uniform(-1, 1, n) * rms * math.sqrt(3)).astype( + np.int16) + + +def impulse(level=30000): + return np.array([level], dtype=np.int16) + + +def band(y, rate, centre, fraction=3): + """Zero-phase fractional-octave band (FFT domain, raised-cosine skirts + of a sixth of the band either side), the dossier's M3 filter.""" + n = len(y) + size = 1 << int(math.ceil(math.log2(n + 1))) + spec = np.fft.rfft(y.astype(np.float64), size) + f = np.fft.rfftfreq(size, 1.0 / rate) + lo = centre * 2 ** (-0.5 / fraction) + hi = centre * 2 ** (0.5 / fraction) + skirt = 2 ** (1.0 / (6 * fraction)) + g = np.zeros_like(f) + g[(f >= lo) & (f <= hi)] = 1.0 + lower = (f >= lo / skirt) & (f < lo) + g[lower] = 0.5 - 0.5 * np.cos(np.pi * np.log(f[lower] / (lo / skirt)) + / np.log(skirt)) + upper = (f > hi) & (f <= hi * skirt) + g[upper] = 0.5 + 0.5 * np.cos(np.pi * np.log(f[upper] / hi) + / np.log(skirt)) + return np.fft.irfft(spec * g, size)[:n] + + +def t60_slope(x, rate, hi_db=-5.0, lo_db=-35.0, win_ms=10.0): + """M3: least-squares slope of the 10 ms log envelope over -5...-35 dB + re its maximum, extrapolated to -60 dB; None where it cannot be fitted. + Never a -60 dB crossing.""" + w = int(round(win_ms * rate / 1000.0)) + m = len(x) // w + env = np.sqrt(np.mean(x[:m * w].reshape(m, w) ** 2, axis=1)) + env = 20.0 * np.log10(np.maximum(env, 1e-12)) + k = int(np.argmax(env)) + rel = env - env[k] + after = rel[k:] + below_hi = np.nonzero(after <= hi_db)[0] + above_lo = np.nonzero(after >= lo_db)[0] + if not len(below_hi) or not len(above_lo): + return None + s = k + int(below_hi[0]) + e = k + int(above_lo[-1]) + if e - s < 3: + return None + t = np.arange(s, e + 1) * w / rate + slope, _ = np.polyfit(t, rel[s:e + 1], 1) + return -60.0 / slope if slope < 0 else None + + +def band_t60s(cls, name, seed, centres, rate=RATE, channels=2, **override): + """T60 per band by M3 on interrupted noise: 2 s of uniform noise at + 8 000 LSB RMS then silence, the mono sum, Mix 2.""" + settings = dict(SETTINGS[name]) + settings.update(override) + t = settings["decay"] + frames = int((BURST_S + 1.3 * max(t, 1.0) + 0.8) * rate) + effect = make(cls, name, rate, channels, noise_burst(rate, seed), frames, + **dict(override, mix=2.0)) + try: + y = render(effect, frames) + finally: + effect.deinit() + m = y.astype(np.float64).sum(axis=1) + cut = int(BURST_S * rate) + return [t60_slope(band(m, rate, fc)[cut:], rate) for fc in centres] + + +def m1_profile(y, rate, onset, win_ms=10.0, floor_db=-40.0, upto_ms=260.0): + """M1: local maxima of |y| per 10 ms window above -40 dB of the + window's own RMS, windows from the channel's first non-zero sample.""" + a = np.abs(y.astype(np.float64)) + w = int(round(win_ms * rate / 1000.0)) + counts = [] + k = 0 + while True: + s = onset + k * w + e = s + w + if (k * win_ms) > upto_ms or e + 1 >= len(a): + break + seg = a[max(0, s - 1):e + 1] + body = seg[1:-1] + rms = math.sqrt(float(np.mean(body ** 2))) if len(body) else 0.0 + if rms == 0.0: + counts.append(0) + else: + thr = rms * 10 ** (floor_db / 20.0) + peaks = (body > seg[:-2]) & (body >= seg[2:]) & (body > thr) + counts.append(int(np.count_nonzero(peaks))) + k += 1 + return counts + + +def density(cls, name, rate=RATE, channels=2, **override): + """Per channel (count at 20 ms, count at 200 ms, lowest from 20 to + 200 ms) of M1 on an impulse of 30 000, Mix 2, 500 ms.""" + frames = int(0.5 * rate) + effect = make(cls, name, rate, channels, impulse(), frames, + **dict(override, mix=2.0)) + try: + y = render(effect, frames) + finally: + effect.deinit() + out = [] + for c in range(channels): + nz = np.nonzero(y[:, c])[0] + if not len(nz): + out.append((0, 0, 0)) + continue + counts = m1_profile(y[:, c], rate, int(nz[0])) + out.append((counts[2], counts[20], min(counts[2:21]))) + return out + + +def prominences(db): + n = len(db) + peaks = np.nonzero((db[1:-1] > db[:-2]) & (db[1:-1] >= db[2:]))[0] + 1 + out = {} + for p in peaks: + h = db[p] + i = p - 1 + low_l = h + while i >= 0 and db[i] <= h: + low_l = min(low_l, db[i]) + i -= 1 + j = p + 1 + low_r = h + while j < n and db[j] <= h: + low_r = min(low_r, db[j]) + j += 1 + out[p] = h - max(low_l, low_r) + return out + + +def modal_counts(cls, name="Steel Plate", rate=RATE, channels=2, + **override): + """M2 restated: Decay 10 s, Mod Depth 0, Damping 16 kHz, the mono sum's + 8 s segment from 0.2 s after a 2 s noise burst, one Hann FFT; >= 6 dB + prominence maxima per Hz in 200-400 Hz and 2-4 kHz.""" + frames = int((BURST_S + 8.3) * rate) + effect = make(cls, name, rate, channels, noise_burst(rate), frames, + **dict(dict(decay=10.0, mod_depth_ms=0.0, + damping_hz=16000.0), mix=2.0, **override)) + try: + y = render(effect, frames) + finally: + effect.deinit() + seg = y.astype(np.float64).sum(axis=1)[int((BURST_S + 0.2) * rate):] + n = 8 * rate + spec = np.abs(np.fft.rfft(seg[:n] * np.hanning(n), n)) + db = 20 * np.log10(np.maximum(spec, 1e-9)) + f = np.fft.rfftfreq(n, 1.0 / rate) + i0 = int(np.searchsorted(f, 150.0)) + i1 = int(np.searchsorted(f, 4200.0)) + sub, fsub = db[i0:i1], f[i0:i1] + prom = prominences(sub) + return [sum(1 for p, v in prom.items() if lo <= fsub[p] <= hi + and v >= 6.0) / (hi - lo) + for lo, hi in ((200.0, 400.0), (2000.0, 4000.0))] + + +def declared_density(character, size, rate=RATE): + """The class's declared modal density: lines 4-11 of the character's + own table at `size`, over fs (not the lines the Tank was handed).""" + lines = rv.line_set(rv.CHARACTERS.index(character), size, rate) + return sum(lines[4:12]) / float(rate) + + +def blackman_harris(n): + k = np.arange(n) / (n - 1.0) + return (0.35875 - 0.48829 * np.cos(2 * np.pi * k) + + 0.14128 * np.cos(4 * np.pi * k) + - 0.01168 * np.cos(6 * np.pi * k)) + + +def sidebands_db(cls, name, hz, window_rate, rate=RATE, channels=2, + **override): + """M6 restated for T10: a sine at 8 000 LSB, Mix 2, the mono sum; 4 s + to settle, then L = max(6, 8 / rate) s under Blackman-Harris; the + energy 4/L-20 Hz either side over the energy within 4/L Hz, in dB.""" + length = max(6.0, 8.0 / window_rate) + n_total = int((4.0 + length) * rate) + x = np.round(8000 * np.sin(2 * np.pi * hz * np.arange(n_total) / rate)) + effect = make(cls, name, rate, channels, x.astype(np.int16), n_total, + **dict(override, mix=2.0)) + try: + y = render(effect, n_total) + finally: + effect.deinit() + m = y.astype(np.float64).sum(axis=1)[n_total - int(length * rate):] + p = np.abs(np.fft.rfft(m * blackman_harris(len(m)))) ** 2 + f = np.fft.rfftfreq(len(m), 1.0 / rate) + d = np.abs(f - hz) + edge = 4.0 / length + carrier = p[d <= edge].sum() + side = p[(d > edge) & (d <= 20.0)].sum() + return 10 * math.log10(side / carrier) if side > 0 else -300.0 + + +def twelfth_bands(lo=15.0, hi=4000.0): + edges = [] + f = lo + while f < hi: + edges.append(f) + f *= 2 ** (1.0 / 12) + return [(a, b, math.sqrt(a * b)) for a, b in zip(edges, edges[1:])] + + +def band_power(x, rate, bands): + n = 1 << int(math.ceil(math.log2(len(x)))) + p = np.abs(np.fft.rfft(x, n)) ** 2 + f = np.fft.rfftfreq(n, 1.0 / rate) + return np.array([p[(f >= a) & (f < b)].sum() for a, b, _ in bands]) + + +def at_hz(centres, db, f): + return float(np.interp(math.log(f), np.log(centres), db)) + + +def corner_hz(centres, db): + target = at_hz(centres, db, 2000.0) - 3.0 + for i in range(len(db) - 1, 0, -1): + if db[i - 1] < target <= db[i]: + a, b = math.log(centres[i - 1]), math.log(centres[i]) + t = (target - db[i - 1]) / (db[i] - db[i - 1]) + return math.exp(a + t * (b - a)) + return None + + +def wet_sum(cls, name, rate, channels, reference=False, **override): + frames = int(3.0 * rate) + effect = make(cls, name, rate, channels, noise_burst(rate), frames, + **dict(override, mix=2.0)) + if reference and hasattr(effect, "_tank") and effect._tank is not None: + # Not a class state (the macro stops at 20 Hz): the test sets the + # class's own Tank's low_cut_hz to 0, the only difference. + effect._tank.set(low_cut_hz=0.0) + try: + return render(effect, frames).astype(np.float64).sum(axis=1) + finally: + effect.deinit() + + +def low_cut_readings(cls, name="Bass-Free Plate", rate=RATE, channels=2, + **override): + """M7 restated: the wet at Low Cut over the wet with the Tank's Low Cut + at 0, 1/12-octave bands. (level at 362 Hz re 2 kHz, slope 90-180 Hz, + 1 kHz over 90 Hz, the -3 dB corner).""" + bands = twelfth_bands() + centres = np.array([c for _, _, c in bands]) + cut = band_power(wet_sum(cls, name, rate, channels, **override), rate, + bands) + ref = band_power(wet_sum(cls, name, rate, channels, reference=True, + **override), rate, bands) + db = 10 * np.log10(np.maximum(cut, 1e-30) / np.maximum(ref, 1e-30)) + return (at_hz(centres, db, 362.0) - at_hz(centres, db, 2000.0), + at_hz(centres, db, 180.0) - at_hz(centres, db, 90.0), + at_hz(centres, db, 1000.0) - at_hz(centres, db, 90.0), + corner_hz(centres, db)) + + +def low_cut_green(readings, low_cut): + level, slope, depth, corner = readings + return (-4.0 <= level <= -2.0 and 5.0 <= slope <= 7.0 and depth >= 11.0 + and corner is not None and abs(corner / low_cut - 1.0) <= 0.10) + + +def dry_deviation(name, mix, rate=RATE, channels=2, fault=False): + """T7's dry clause: the output less min(Mix, 1) x the wet-only render, + against the source x min(1, 2 - Mix), 1/12-octave bands over + 20 Hz-1 kHz, at 4 000 LSB RMS. `fault` moves the Low Cut into the dry + path, emulated as the same one-pole on the source (the seed's plant).""" + rng = np.random.RandomState(11) + n = 2 * rate + x = np.round(rng.uniform(-1, 1, n) * 4000.0 * math.sqrt(3)).astype( + np.int16) + effect = make(Reverb, name, rate, channels, x, n, mix=mix) + out = render(effect, n).astype(np.float64) + effect.deinit() + effect = make(Reverb, name, rate, channels, x, n, mix=2.0) + wet = render(effect, n).astype(np.float64) + effect.deinit() + dry = out - min(mix, 1.0) * wet + if fault: + a = 1.0 - math.exp(-2 * math.pi * SETTINGS[name]["low_cut_hz"] / rate) + lp = 0.0 + hp = np.empty(n) + for i in range(n): + lp += a * (float(x[i]) - lp) + hp[i] = float(x[i]) - lp + dry = np.repeat(hp, channels).reshape(-1, channels) + bands = twelfth_bands(18.0, 1100.0) + level = min(1.0, 2.0 - mix) + src = band_power(x.astype(np.float64) * level, rate, bands) + d = band_power(dry[:, 0], rate, bands) + dev = 10 * np.log10(d / src) + sel = [i for i, (_, _, c) in enumerate(bands) if 20.0 <= c <= 1000.0] + return float(np.max(np.abs(dev[sel]))) + + +def first_arrival_ms(cls, name, rate=RATE, channels=2): + """T9: the earlier channel's first non-zero wet sample of an impulse of + 30 000, less int(Predelay x fs / 1000) frames, in ms.""" + frames = int(0.2 * rate) + effect = make(cls, name, rate, channels, impulse(), frames, mix=2.0) + try: + y = render(effect, frames) + finally: + effect.deinit() + firsts = [np.nonzero(y[:, c])[0] for c in range(channels)] + firsts = [int(f[0]) for f in firsts if len(f)] + if not firsts: + return None + pre = int(SETTINGS[name]["predelay_ms"] * rate / 1000.0) + return (min(firsts) - pre) * 1000.0 / rate + + +def silent_build(cls, **options): + src = probes.ArraySource(array("h", [0] * 1024), rate=RATE, channels=2) + return cls(src, sample_rate=RATE, **options) + + +def reach(faulted, reading, **options): + return kit_faults.fault_reachability( + Reverb, faulted, reading, lambda cls: silent_build(cls, **options)) + + +#: 13 macros x 17 grid positions, plus the 10 patches. +WALK = 13 * 17 + 10 + + +def handed_cut(effect): + return (effect._index, tuple(effect._lines)) + + +# -- the surface ------------------------------------------------------------- + +class TheSurface(unittest.TestCase): + def test_macros_patches_tier_latency(self): + self.assertIs(rebuilt.module_class("Reverb"), Reverb) + self.assertEqual(Reverb.MACRO_LABELS, ( + "Character", "Decay", "Size", "Predelay", "Diffusion", + "Damping", "Bandwidth", "Low Cut", "Mod Depth", "Mod Rate", + "Width", "Tone", "Mix")) + self.assertEqual(Reverb.MACRO_MODES[rv.TONE_I], "BIPOLAR") + self.assertEqual(len(Reverb.PATCHES), 10) + self.assertEqual(Reverb.CAPABILITIES, ()) + self.assertEqual(Reverb.LATENCY_SAMPLES, 0) + self.assertEqual(Reverb.TIER, _component.AUDIODSP) + self.assertEqual(Reverb.REQUIRES, ("audioverb",)) + effect = silent_build(Reverb) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.capabilities, ()) + self.assertEqual(effect.patch_index, 0) + effect.set_macro(rv.MIX_I, 64) + self.assertIsNone(effect.patch_index) + effect.program_change(6) + self.assertEqual(effect.patch_index, 6) + + def test_patches_are_the_dossier_settings_on_the_grid(self): + spans = Reverb._MACRO_RANGES + keys = ("decay", "size", "predelay_ms", "diffusion", "damping_hz", + "bandwidth_hz", "low_cut_hz", "mod_depth_ms", "mod_rate_hz", + "width", "tone_db", "mix") + for index, (name, settings) in enumerate(PATCH_SETTINGS): + label, grid = Reverb.PATCHES[index] + self.assertEqual(label, name) + expected = [rv.CHARACTER_MIDI[ + rv.CHARACTERS.index(settings["character"])]] + for macro, key in enumerate(keys, start=1): + expected.append(_component.macro_of( + spans[macro], settings[key], Reverb.MACRO_MODES[macro])) + self.assertEqual(tuple(expected), grid, name) + + def test_patch_read_backs(self): + # dossier App. F: what the grid reads back. + effect = silent_build(Reverb) + effect.program_change(6) + self.assertEqual(effect._index, rv.HALL) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 3.224, places=3) + self.assertAlmostEqual(effect.macro(rv.SIZE_I), 1.248, places=3) + self.assertAlmostEqual(effect.macro(rv.PREDELAY_I), 25.2, places=1) + effect.program_change(0) + self.assertEqual(effect._index, rv.PLATE) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 2.379, places=3) + self.assertAlmostEqual(effect.macro(rv.DAMPING_I), 989.1, places=1) + self.assertEqual(effect.macro(rv.TONE_I), 0.0) + for index, zone in ((4, rv.ROOM), (7, rv.CHAMBER), (9, rv.HALL)): + effect.program_change(index) + self.assertEqual(effect._index, zone) + + def test_patch_0_is_the_constructor_grid(self): + effect = silent_build(Reverb) + for index, expected in enumerate(Reverb.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), expected, + delta=0.6) + + def test_character_zones(self): + effect = silent_build(Reverb) + for midi, zone in ((0, 0), (31, 0), (32, 1), (63, 1), (64, 2), + (95, 2), (96, 3), (127, 3)): + effect.set_macro(rv.CHARACTER_I, midi) + self.assertEqual(effect._index, zone, midi) + for name, zone in zip(rv.CHARACTERS, range(4)): + self.assertEqual(silent_build(Reverb, character=name)._index, + zone) + + def test_options_clamp_default_and_refuse(self): + with self.assertRaises(ValueError) as caught: + silent_build(Reverb, character="spring") + self.assertIn("parked", str(caught.exception)) + with self.assertRaises(ValueError): + silent_build(Reverb, character="cathedral") + nan = float("nan") + effect = silent_build(Reverb, decay=nan, size=nan, low_cut_hz=nan) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 2.4, places=9) + self.assertAlmostEqual(effect.macro(rv.SIZE_I), 1.0, places=9) + self.assertAlmostEqual(effect.macro(rv.LOW_CUT_I), 40.0, places=9) + effect = silent_build(Reverb, decay=50.0, size=0.1, low_cut_hz=-5.0, + mix=3.0, tone_db=-40.0) + self.assertAlmostEqual(effect.macro(rv.DECAY_I), 10.0, places=9) + self.assertAlmostEqual(effect.macro(rv.SIZE_I), 0.5, places=9) + self.assertAlmostEqual(effect.macro(rv.LOW_CUT_I), 20.0, places=9) + self.assertAlmostEqual(effect.macro(rv.MIX_I), 2.0, places=9) + self.assertAlmostEqual(effect.macro(rv.TONE_I), -12.0, places=9) + + def test_hz_clamps_below_nyquist_at_22050(self): + src = probes.ArraySource(array("h", [0] * 1024), rate=22050, + channels=2) + effect = Reverb(src, sample_rate=22050, bandwidth_hz=20000.0, + character="room", damping_hz=16000.0) + self.assertAlmostEqual(effect._handed["bandwidth_hz"], 10804.5) + self.assertAlmostEqual(effect._handed["damping_hz"], 10804.5) + + +class TheCut(unittest.TestCase): + def test_the_plate_tank_is_dattorros_at_48k(self): + # dossier App. B: Dattorro's tank lines scaled to 48 kHz. + lines = rv.line_set(rv.PLATE, 1.0, 48000) + self.assertEqual(lines[4:], [1084, 7182, 2903, 6000, + 1464, 6801, 4284, 5101]) + self.assertEqual(lines[:4], [69, 52, 183, 134]) + + def test_the_shortest_line_clears_the_node_floor(self): + shortest = min(min(rv.line_set(i, 0.5, 22050)) for i in range(4)) + self.assertEqual(shortest, 12) + + def test_ram(self): + # dossier Tier 3: the lines plus 200 ms of predelay, int16. + def ram(index, size): + return 2 * (sum(rv.line_set(index, size, 48000)) + 9600) + self.assertEqual(ram(rv.PLATE, 1.0), 89714) + self.assertEqual(ram(rv.HALL, 1.5), 146914) + self.assertEqual(ram(rv.ROOM, 0.5), 38526) + + def test_the_law_never_reaches_the_node_clamp(self): + largest = 0.0 + spans = Reverb._MACRO_RANGES + for index in range(4): + for size in (0.5, 1.0, 1.5): + lines = rv.line_set(index, size, RATE) + for dm in range(0, 128, 8): + damping = _component.macro_value(spans[rv.DAMPING_I], + dm / 127.0) + for de in list(range(0, 128, 8)) + [127]: + t = _component.macro_value(spans[rv.DECAY_I], + de / 127.0) + loop = (rv.damper_hz(t, damping, RATE) + if index == rv.PLATE else damping) + d = rv.decay_law(rv.KAPPA[index], lines, RATE, t, + min(loop, 0.49 * RATE))[0] + largest = max(largest, d) + self.assertLess(largest, 0.9829) + self.assertGreater(largest, 0.98) + + def test_tail_samples_at_the_patches(self): + # dossier section 6: the bound at each patch's settings, 48 kHz. + for name, frames in (("Steel Plate", 222868), ("Small Room", 80736), + ("Slow Bloom", 419791), + ("Concert Hall", 297320)): + self.assertEqual(make(Reverb, name).tail_samples, frames, name) + + +class Rebuilds(unittest.TestCase): + def test_a_character_move_rebuilds_and_releases(self): + effect = silent_build(Reverb) + port = effect.output + first = effect._tank + effect.set_macro(rv.CHARACTER_I, 20) # same zone: no rebuild + self.assertIs(effect._tank, first) + effect.set_macro(rv.CHARACTER_I, 42) # room + self.assertIsNot(effect._tank, first) + self.assertIs(effect.output, port) + self.assertEqual(effect._nodes, [effect._tank]) + with self.assertRaises(Exception): + first.set(decay=0.5) # released + second = effect._tank + effect.set_macro(rv.DECAY_I, 100) + self.assertIs(effect._tank, second) + effect.set_macro(rv.SIZE_I, 100) + self.assertIsNot(effect._tank, second) + + def test_a_patch_rebuilds_once(self): + class Counting(Reverb): + NAME = 'Reverb' + count = 0 + + def _rebuild(self, *arguments): + type(self).count += 1 + Reverb._rebuild(self, *arguments) + + effect = silent_build(Counting) + Counting.count = 0 + effect.program_change(9) # Character and Size move + self.assertEqual(Counting.count, 1) + + def test_a_rebuild_cuts_the_tail(self): + frames = RATE + effect = make(Reverb, "Steel Plate", mono=noise_burst(RATE, + seconds=0.25), + frames=frames, mix=2.0) + render(effect, int(0.5 * RATE)) + effect.set_macro(rv.CHARACTER_I, 127) + after = render(effect, int(0.25 * RATE)) + self.assertEqual(int(np.max(np.abs(after))), 0) + + +# -- Tier 1 ------------------------------------------------------------------ + +class Tier1(unittest.TestCase): + def test_mix_zero_is_a_wire_at_every_patch(self): + frames = 4096 + ramp = [((i * 37) % 65536) - 32768 for i in range(frames)] + for channels in (2, 1): + for index in range(10): + data = array("h", [v for v in ramp for _ in range(channels)]) + src = probes.ArraySource(data, rate=RATE, channels=channels) + effect = Reverb(src, sample_rate=RATE) + effect.program_change(index) + effect.set_macro(rv.MIX_I, 0) + out = render(effect, frames) + self.assertTrue(np.array_equal( + out.reshape(-1), np.array(data, dtype=np.int32)), + (channels, index)) + + def test_silence_stays_silence(self): + for channels in (2, 1): + effect = make(Reverb, frames=RATE, channels=channels, mix=2.0) + for index in range(10): + effect.program_change(index) + self.assertEqual(int(np.max(np.abs(render(effect, 4096)))), + 0, index) + + def test_click_delay_is_zero(self): + for name in ("Steel Plate", "Concert Hall"): + frames = 2048 + mono = np.zeros(frames, dtype=np.int16) + mono[1000] = 20000 + effect = make(Reverb, name, mono=mono, frames=frames, mix=1.0) + out = render(effect, frames) + self.assertEqual(int(out[1000, 0]), 20000, name) + self.assertEqual(int(np.max(np.abs(out[:1000]))), 0, name) + + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): + for name, channels in (("Steel Plate", 2), ("Small Room", 1)): + rng = np.random.RandomState(3) + burst = np.round(rng.uniform(-1, 1, RATE) * 32767).astype( + np.int16) + probe = make(Reverb, name, channels=channels) + bound = probe.tail_samples + frames = RATE + bound + RATE // 2 + effect = make(Reverb, name, channels=channels, mono=burst, + frames=frames, mix=2.0) + y = render(effect, frames) + nz = np.nonzero(np.any(y != 0, axis=1))[0] + last = int(nz[-1]) - RATE + self.assertLess(last, bound, name) + self.assertGreater(last, bound // 4, name) + + def test_reset_clears_the_tail_and_restores_patch_0(self): + effect = make(Reverb, "Concert Hall", + mono=np.concatenate([ + np.zeros(4096, dtype=np.int16), + noise_burst(RATE, seconds=0.25)]), + frames=2 * RATE, mix=2.0) + effect.program_change(6) + render(effect, RATE // 2) + effect.reset() + self.assertEqual(effect.patch_index, 0) + # the borrowed source is not reset: it is past its burst, so what + # comes out is the emptied tank and a silent dry + self.assertEqual(int(np.max(np.abs(render(effect, 4096)))), 0) + + def test_deinit_releases_every_tank_and_leaves_the_source(self): + src = probes.ArraySource(array("h", [9000] * 4096), rate=RATE, + channels=2) + effect = Reverb(src, sample_rate=RATE) + first = effect._tank + effect.set_macro(rv.CHARACTER_I, 127) + second = effect._tank + render(effect, 256) + effect.deinit() + for tank in (first, second): + with self.assertRaises(Exception): + tank.set(decay=0.5) + data = memoryview(bytes(audiocore.get_buffer(src)[1])).cast("h") + self.assertEqual(max(abs(int(v)) for v in data), 9000) + + def test_lower_rates_build_and_ring(self): + for rate in (44100, 22050): + for index in (0, 6): + src = probes.ArraySource( + interleave(impulse(), 2, rate // 2), rate=rate, + channels=2) + effect = Reverb(src, sample_rate=rate) + effect.program_change(index) + self.assertGreater(int(np.max(np.abs( + render(effect, rate // 2)[rate // 10:]))), 0) + + +# -- Tier 2 ------------------------------------------------------------------ + +def t1_verdict(profile): + """T1: on every channel the 200 ms window is not empty and no window + from 20 to 200 ms reads more than 20 % below it.""" + return all(c200 > 0 and low >= 0.80 * c200 + for _, c200, low in profile) + + +class T1PlateDensity(unittest.TestCase): + def test_steel_plate_is_dense_from_20_ms(self): + profile = density(Reverb, "Steel Plate") + self.assertTrue(t1_verdict(profile), profile) + for _, c200, low in profile: + self.assertGreaterEqual(low / float(c200), 0.81) + + def test_dattorros_published_diffusers_are_red(self): + profile = density(DattorroDiffuserPlate, "Steel Plate") + self.assertFalse(t1_verdict(profile), profile) + for _, c200, low in profile: + self.assertLess(low / float(c200), 0.6) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(DattorroDiffuserPlate, handed_cut) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t1_verdict( + density(cls, "Steel Plate"))}, label="Reverb T1") + + +def t2_verdict(counts, declared): + lo, hi = counts + if lo <= 0.0 or hi <= 0.0: + return False + return (abs(lo / declared - 1.0) <= 0.10 + and abs(hi / declared - 1.0) <= 0.10 and hi / lo <= 2.0) + + +class T2ModalDensity(unittest.TestCase): + def test_resolved_modes_count_the_declared_table(self): + declared = declared_density("plate", 1.0) + self.assertAlmostEqual(declared, 0.725, places=3) + counts = modal_counts(Reverb) + self.assertTrue(t2_verdict(counts, declared), counts) + + def test_the_inverted_cut_is_red(self): + declared = declared_density("plate", 1.0) + counts = modal_counts(InvertedCut) + self.assertFalse(t2_verdict(counts, declared), counts) + self.assertLess(max(counts) / declared, 0.75) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(InvertedCut, handed_cut) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + declared = declared_density("plate", 1.0) + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t2_verdict(modal_counts(cls), + declared)}, + label="Reverb T2") + + +T3_STOPS = (8.0, 4.0, 2.0, 1.0) + + +def t3_ratios(cls): + """R = T60(500 Hz) / T60(4 kHz) at Decay 8 / 4 / 2 / 1 s on Steel Plate + as patched, each the mean over seeds 7-14; None where a fit fails.""" + key = ("T3", cls) + if key not in _MEMO: + rs = [] + for t in T3_STOPS: + vals = [band_t60s(cls, "Steel Plate", seed, (500, 4000), + decay=t) for seed in SEEDS] + if any(a is None or b is None for a, b in vals): + rs = None + break + rs.append(float(np.mean([a / b for a, b in vals]))) + _MEMO[key] = rs + return _MEMO[key] + + +def t3_verdict(rs): + if rs is None: + return False + return (all(b >= a for a, b in zip(rs, rs[1:])) + and rs[-1] / rs[0] - 1.0 >= 0.20) + + +class T3Damper(unittest.TestCase): + def test_the_upper_band_shortens_more_as_decay_shortens(self): + rs = t3_ratios(Reverb) + self.assertTrue(t3_verdict(rs), rs) + # dossier T3: 1.129 -> 1.214 -> 1.376 -> 1.627, +44.2 % + self.assertAlmostEqual(rs[-1] / rs[0] - 1.0, 0.442, delta=0.05) + + def test_a_frequency_flat_damper_is_red(self): + rs = t3_ratios(FlatDamper) + self.assertFalse(t3_verdict(rs), rs) + self.assertLess(rs[-1] / rs[0] - 1.0, -0.5) + + def test_the_fault_is_not_on_the_surface(self): + # The character is part of the reading: the room, chamber and hall + # hand the Damping value flat by design, and the fault is the + # plate doing it. + result = reach(FlatDamper, lambda e: ( + e._index, round(e.macro(rv.DECAY_I), 9), + e._handed["damping_hz"])) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t3_verdict(t3_ratios(cls))}, + label="Reverb T3") + + +class T7LowCut(unittest.TestCase): + def test_the_tank_sees_no_bass_on_bass_free_plate(self): + readings = low_cut_readings(Reverb) + self.assertTrue(low_cut_green(readings, 360.0), readings) + + def test_the_corner_follows_low_cut(self): + for low_cut in (100.0, 500.0): + readings = low_cut_readings(Reverb, low_cut_hz=low_cut) + self.assertLessEqual(abs(readings[3] / low_cut - 1.0), 0.10, + (low_cut, readings)) + + def test_the_dry_is_flat(self): + for name in ("Steel Plate", "Concert Hall"): + for mix in (0.35, 1.0, 1.5): + self.assertLessEqual(dry_deviation(name, mix), 0.1, + (name, mix)) + + def test_low_cut_in_the_dry_is_red(self): + self.assertGreater(dry_deviation("Bass-Free Plate", 0.4, fault=True), + 10.0) + + def test_low_cut_in_radians_is_red(self): + readings = low_cut_readings(LowCutRadians) + self.assertFalse(low_cut_green(readings, 360.0), readings) + self.assertLess(readings[3], 70.0) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(LowCutRadians, lambda e: e._handed["low_cut_hz"]) + self.assertAlmostEqual(result["target"], 40.0 / (2.0 * math.pi)) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": low_cut_green( + low_cut_readings(cls), 360.0)}, label="Reverb T7") + + +def t8_verdict(cls, name): + return all(c200 >= 1.5 * max(c20, 1) + for c20, c200, _ in density(cls, name)) + + +T8_PATCHES = ("Small Room", "Concert Hall", "Dark Chamber") + + +class T8TheyBuild(unittest.TestCase): + def test_room_hall_and_chamber_build(self): + for name in T8_PATCHES: + profile = density(Reverb, name) + self.assertTrue(t8_verdict(Reverb, name), (name, profile)) + + def test_on_the_plates_diffusers_they_do_not(self): + for name in T8_PATCHES: + self.assertFalse(t8_verdict(PlateDiffuserRooms, name), name) + + def test_the_contrast_the_plate_does_not_build(self): + self.assertFalse(t8_verdict(Reverb, "Steel Plate")) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(PlateDiffuserRooms, handed_cut, character="room") + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t8_verdict(cls, "Small Room")}, + label="Reverb T8") + + +def t60_1k(cls, name): + key = ("T9", cls, name) + if key not in _MEMO: + vals = [band_t60s(cls, name, seed, (1000,))[0] + for seed in (7, 8, 9, 10)] + _MEMO[key] = None if None in vals else float(np.mean(vals)) + return _MEMO[key] + + +def t9_verdict(cls): + hall, live, small = (t60_1k(cls, n) for n in + ("Concert Hall", "Live Room", "Small Room")) + arrivals = [first_arrival_ms(cls, n) for n in + ("Concert Hall", "Live Room", "Small Room")] + if None in (hall, live, small) or None in arrivals: + return False + return (hall >= 2.0 * live and hall >= 4.0 * small + and arrivals[0] >= 10.0 and arrivals[1] <= 6.0 + and arrivals[2] <= 6.0) + + +def construction_pairs(cls, rate=RATE): + """(Character index, line set) at every Size grid position, read off + built instances' handed state.""" + pairs = set() + effect = silent_build(cls) + for midi in rv.CHARACTER_MIDI: + effect.set_macro(rv.CHARACTER_I, midi) + for size in range(128): + effect.set_macro(rv.SIZE_I, size) + pairs.add(handed_cut(effect)) + effect.deinit() + return pairs + + +class T9ThreeTunings(unittest.TestCase): + def test_the_patches_are_three_tunings(self): + self.assertTrue(t9_verdict(Reverb)) + self.assertGreaterEqual(first_arrival_ms(Reverb, "Concert Hall"), + 12.9) + + def test_no_two_characters_share_a_line_set(self): + for rate in (48000, 44100, 22050): + owners = {} + for index in range(4): + for midi in range(128): + lines = tuple(rv.line_set(index, 0.5 + midi / 127.0, + rate)) + owners.setdefault(lines, set()).add(index) + self.assertEqual([s for s in owners.values() if len(s) > 1], [], + rate) + + def test_the_hall_on_the_rooms_lines_is_red(self): + self.assertFalse(t9_verdict(HallOnRoomLines)) + self.assertLess(first_arrival_ms(HallOnRoomLines, "Concert Hall"), + 10.0) + planted = silent_build(HallOnRoomLines, character="hall", + size=0.5 + 95 / 127.0) + self.assertNotIn(handed_cut(planted), construction_pairs(Reverb)) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(HallOnRoomLines, handed_cut, character="hall") + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t9_verdict(cls)}, + label="Reverb T9") + + +def t10_on(cls): + return all(sidebands_db(cls, name, 1000.0, SETTINGS[name]["mod_rate_hz"]) + >= -20.0 for name in ("Steel Plate", "Concert Hall")) + + +def t10_off(cls): + return all(sidebands_db(cls, name, 1000.0, 1.0e9, mod_depth_ms=0.0) + <= -60.0 for name in ("Steel Plate", "Concert Hall")) + + +class T10Modulation(unittest.TestCase): + def test_a_still_tank_is_one_line(self): + self.assertTrue(t10_off(Reverb)) + + def test_the_patches_modulation_spreads_it(self): + self.assertTrue(t10_on(Reverb)) + + def test_the_low_corner_of_the_claim(self): + v = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, + mod_depth_ms=0.1, mod_rate_hz=1.0) + self.assertGreaterEqual(v, -20.0) + + def test_mod_rate_as_its_reciprocal_is_red(self): + self.assertFalse(t10_on(RateReciprocal)) + + def test_mod_depth_mirrored_is_red(self): + self.assertFalse(t10_off(DepthMirrored)) + + def test_the_faults_are_not_on_the_surface(self): + result = reach(RateReciprocal, lambda e: e._handed["mod_rate_hz"]) + self.assertAlmostEqual(result["target"], 1000.0, places=6) + self.assertEqual(result["checked"], WALK) + result = reach(DepthMirrored, lambda e: e._handed["mod_depth_ms"]) + self.assertGreater(result["target"], 2.0) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t10_on(cls)}, label="Reverb T10") + + +T11_STOPS = (2.0, 4.0, 8.0, 10.0) + + +def t11_errors(cls, character, stops=T11_STOPS): + """(T60 at 500 Hz / Decay - 1) at each stop, the mean over seeds 7-14, + at the character's reference patch with Size 1.0.""" + key = ("T11", cls, character, stops) + if key not in _MEMO: + errors = [] + for t in stops: + vals = [band_t60s(cls, REFERENCE[character], seed, (500,), + decay=t, size=1.0)[0] for seed in SEEDS] + if None in vals: + errors = None + break + errors.append(float(np.mean(vals)) / t - 1.0) + _MEMO[key] = errors + return _MEMO[key] + + +def t11_verdict(errors): + return errors is not None and all(abs(e) <= 0.12 for e in errors) + + +class T11DecayIsT60(unittest.TestCase): + def test_every_character_lands_its_label(self): + for character in rv.CHARACTERS: + errors = t11_errors(Reverb, character) + self.assertTrue(t11_verdict(errors), (character, errors)) + + def test_one_multiply_per_half_pass_is_red(self): + errors = t11_errors(OneMultiply, "plate") + self.assertFalse(t11_verdict(errors), errors) + self.assertTrue(all(e < -0.35 for e in errors), errors) + + def test_the_fault_is_not_on_the_surface(self): + result = reach(OneMultiply, lambda e: ( + round(e.macro(rv.DECAY_I), 9), e._handed["decay"])) + self.assertEqual(result["checked"], WALK) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + Reverb, lambda cls: {"passed": t11_verdict( + t11_errors(cls, "plate"))}, label="Reverb T11") + + +if __name__ == "__main__": + unittest.main() From 769918a6733125d732049a928fe8b6b0f242a96f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:59:27 -0500 Subject: [PATCH 41/88] Ten effects stop replaying old audio when a bypassed branch comes back Brad's ruling of 2026-09-28: "fix the stale blocks" (#113). When a control routes part of a class's graph around - Mix 0 handing the source straight back, Lookahead to 0, Band Limit or Dither off - nothing pulls that part, and it kept whatever it held: filter and shaper memory, a lookahead line, the block each mixer voice had queued. Bringing the control back after a pause played it out of silence. One helper in _component.Component, no public name: _route_around() marks the graph stranded, _rejoin() clears every node the class registered for reset (the walk reset() does, now _clear_nodes(), without patch 0), and the class then arms the graph the way its constructor does - level gates at the current levels, voices re-played, a biased coupling pole charged - before the port is pointed back. That cures Mix back up from 0 in Bitcrusher, Compressor (its construction-primed block), Distortion, DynamicEQ, Exciter, Fuzz, MultibandCompressor, Overdrive and Saturation. DynamicEQ and MultibandCompressor keep their guard (the bypass reads it) out of the walk; classes whose mixer reset re-plays its voices keep the mixer out and re-play once themselves. Three other mechanisms, cured on their own terms: - Limiter: the node writes its lookahead line only as far as its delay, so a delay that grows exposed old samples (31 373 LSB, the whole last note). A stage whose delay grows is reset first. - Bitcrusher: Band Limit's sections and Dither's gate are cleared when they come back. - Saturation: the plate pole drains when Bias returns to the centre (29 058), re-charges on Drive, Output, Headroom and Hysteresis moves off centre, Hysteresis back in starts its play operator at the centre (314), and a Bias re-charge no longer pulls a block off the dry tap. Waveshaper.play() takes no block (measured on all three interpreters), so that pull put the dry leg 256 frames ahead of the wet after every program_change and left a stale block in the Splitter (13 071 at patch 7). DynamicEQ's mixer buffer is 1024 * channels: it rendered two blocks mono, and the tail kept half a render queued across a bypass. After: 0 LSB out of silence at 48, 44.1 and 22.05 kHz, stereo and mono, at every patch, identical on CPython, MicroPython and CircuitPython; at most 1 LSB at Saturation's off-centre patches 4 and 6, the residue its constructor's charge leaves. With no control moved, only Saturation patch 7 (Mix 44) moved its digest (a675b37d to ba76c1cc), the dry leg back in line; the catalogue census and a stereo-and-mono census of the ten classes are otherwise unchanged three ways. Tests in each class's file, red on the class before the fix, with planted faults: the old rejoin (StaleRejoin), a clear without re-arming (ClearOnlyRejoin), and per mechanism the old class and one wrong cure. Closes #113. --- CHANGELOG.md | 28 +++ lib/audioeffects/_component.py | 86 ++++++++- lib/audioeffects/bitcrusher.py | 37 +++- lib/audioeffects/compressor.py | 17 +- lib/audioeffects/distortion.py | 25 ++- lib/audioeffects/dynamiceq.py | 32 +++- lib/audioeffects/exciter.py | 19 +- lib/audioeffects/fuzz.py | 33 +++- lib/audioeffects/limiter.py | 33 ++++ lib/audioeffects/multibandcompressor.py | 16 +- lib/audioeffects/overdrive.py | 42 ++++- lib/audioeffects/saturation.py | 116 +++++++++++- tests/support/stale_blocks.py | 223 +++++++++++++++++++++++ tests/test_cpython_effects_bitcrusher.py | 119 ++++++++++++ tests/test_cpython_effects_compressor.py | 42 +++++ tests/test_cpython_effects_distortion.py | 67 +++++++ tests/test_cpython_effects_dynamiceq.py | 88 +++++++++ tests/test_cpython_effects_exciter.py | 64 +++++++ tests/test_cpython_effects_fuzz.py | 77 ++++++++ tests/test_cpython_effects_limiter.py | 70 +++++++ tests/test_cpython_effects_multiband.py | 64 +++++++ tests/test_cpython_effects_overdrive.py | 68 +++++++ tests/test_cpython_effects_saturation.py | 158 ++++++++++++++++ 23 files changed, 1491 insertions(+), 33 deletions(-) create mode 100644 tests/support/stale_blocks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7c32b..fa56151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,34 @@ there, and are recorded in its changelog. a few LSB (7 at most measured) at a Feedback inside a stall window, where the off stop keeps Tone in's stepped Feedback. +### Fixed + +- **Ten effects no longer replay old audio when a control brings a bypassed + part of their graph back** (#113). Mix 0 hands the source straight back, + so nothing behind it is pulled, and the graph kept what it held - its + filters' memory and the block each mixer voice had queued; Mix back up + after a pause played it out of silence. Measured peaks, LSB: Distortion + 24 589, Fuzz 23 153, MultibandCompressor 22 264, Bitcrusher 16 896, + Overdrive 11 640, Exciter 10 768, Saturation 7 927, DynamicEQ 7 769, and + Compressor's construction block (20 000). One helper in + `_component.Component` (`_route_around`, `_rejoin`, `_clear_nodes`) marks + a graph routed around and clears it when it comes back, and each class + then arms it the way its constructor does: level gates, voices, a biased + coupling pole charged. Limiter's Lookahead and True Peak (31 373: the + whole last note) were the node's lookahead line, written only as far as + its delay reaches; a stage whose delay grows is now reset first. Bitcrusher's + Band Limit and Dither clear their sections and gate when they come back. + Saturation drains its plate pole when Bias returns to the centre (29 058), + re-charges it on Drive, Output, Headroom and Hysteresis moves off centre, + starts Hysteresis's play operator at the centre when it comes back in, and + no longer pulls a block off its dry tap on a Bias re-charge - which put the + dry leg 256 frames ahead of the wet after every `program_change` and moves + the one census digest that changed, patch 7 (Mix 44). DynamicEQ's mixer + renders one block at a time in mono as well as stereo. After: 0 LSB out of + silence at every rate, channel count and patch on CPython, MicroPython and + CircuitPython (at most 1 LSB at Saturation's off-centre patches, what its + constructor's charge leaves); nothing else moved with no control moved. + ## v0.3.2 (2026-09-25) No change to either package's code: both packages are 0.3.1 under a new diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index 5ecfbc1..b227718 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -661,6 +661,9 @@ def __init__(self, source, *options, **keywords): self._macros = [] self._patch_index = 0 self._deinited = False + #: Whether a control has routed the output around the graph since the + #: graph last played. See `_route_around` and `_rejoin`. + self._stranded = False #: True only while `_build` is running. #: #: `_init_macros` applies a constructor `patch=` from inside @@ -740,6 +743,67 @@ def _own(self, node, reset=True, deinit=True): self._deinits.append(deinit) return node + def _clear_nodes(self, keep=(), only=None): + """Clear the nodes this class registered, tail first, the way + `reset()` does, and leave the macros and the patch alone. + + Each node is cleared by what it was registered with: `reset=True` is + `audiocore.reset_buffer`, a callable is called, `False` is skipped. + `keep` names nodes to leave untouched (a node the bypass itself + reads, which is still live). `only`, when given, limits the walk to + those nodes - a sub-branch that a control takes out and puts back. + """ + for position in range(len(self._nodes) - 1, -1, -1): + node = self._nodes[position] + if only is not None and not _named(node, only): + continue + if _named(node, keep): + continue + clear = self._resets[position] + if clear is False: + continue + if clear is True: + audiocore.reset_buffer(node) + else: + clear() + + def _route_around(self, bypass): + """Point the output at `bypass` - the borrowed source, or the node + a bypass hands back - and remember that the graph behind it has + stopped being pulled. + + The stale-block defect (audiocomponents#113): a graph nobody pulls + keeps whatever it held when the control moved - its filters' and + shapers' memory, a lookahead line, the block a mixer voice had + queued. Bring the control back after a pause and that plays out of + silence: a 5 ms fragment of whatever was sounding when the knob went + down. `_rejoin` is the other half. During `_build` nothing has + played yet, so nothing is stranded. + """ + if not self._constructing: + self._stranded = True + self._output = bypass + + def _rejoin(self, keep=()): + """The graph `_route_around` left is about to be pulled again. + + If it was routed around since it last played, every node it + registered is cleared (`_clear_nodes`, `keep` passed through) and + this returns True: the caller then arms it the way its constructor + does - the level gates, the voices, a coupling capacitor charged on + the bias - so the graph comes back as it was built rather than as it + was left. Clearing is the state silence would have decayed to for + every node whose silence answer is zero; a node that answers silence + with a constant (a biased shaper's coupling pole) is exactly what + the constructor's charge settles, which is why re-arming is the + caller's half. Returns False, and touches nothing, otherwise. + """ + if not self._stranded: + return False + self._stranded = False + self._clear_nodes(keep) + return True + def _pcm(self, buffer_size=2048): """The keyword bundle an audiodsp node wants, at this instance's format. There is no module state behind it.""" @@ -940,14 +1004,10 @@ def reset(self): """Clear every node this class built, tail first, and restore patch 0. The borrowed source is never named here.""" self._check_live() - for position in range(len(self._nodes) - 1, -1, -1): - clear = self._resets[position] - if clear is False: - continue - if clear is True: - audiocore.reset_buffer(self._nodes[position]) - else: - clear() + self._clear_nodes() + # Everything is clear now, so nothing is stranded: patch 0 coming + # back off a bypass is the reset's own re-arm, not a second clear. + self._stranded = False self.program_change(0) def deinit(self): @@ -979,6 +1039,16 @@ def deinit(self): self._deinited = True +def _named(node, nodes): + """Whether `node` is one of `nodes`, by identity. A node may define + `__eq__`, and a walk over the graph means this object, not an equal + one.""" + for other in nodes: + if other is node: + return True + return False + + # -------------------------------------------------------------------------- # MIDI-native argument checks, shared by the handlers above. diff --git a/lib/audioeffects/bitcrusher.py b/lib/audioeffects/bitcrusher.py index 863c3f3..5d39658 100644 --- a/lib/audioeffects/bitcrusher.py +++ b/lib/audioeffects/bitcrusher.py @@ -15,6 +15,14 @@ are the measurements' controls, not the machine's. Mix 0 is the Tier 1 wire. +**What a switch leaves behind is cleared when it comes back** +(audiocomponents#113). Mix 0 leaves the whole crusher un-pulled, Band +Limit off leaves its low-pass, Dither off its gate; each used to keep what +it held and play it when the switch came back - the hold's last sample and +queued blocks (up to 16 896 LSB out of silence), the low-pass's memory +(16 384), the gate open on the dither (32). Each is now cleared first, and +Mix re-arms the voices the way the constructor does. + **Bits stops at 12, and that is the converter, not a shortcut.** A `Waveshaper` reads its curve by linear interpolation, so a table whose spacing in input codes reaches the step it is meant to describe hands the @@ -1561,22 +1569,49 @@ def _refresh(self): def _prime_if_wet(self): mix = getattr(self, "_mix", 1.0) if mix <= 0.0: - self._output = self._source + self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._blend return + if self._rejoin(): + # Back off the bypass: every node is cleared - the hold's last + # value, the band limit's memory, the dither gate - and the + # voices, which still hold the blocks they had queued, are + # re-armed below the way `_build` arms them: levels, gates, + # then sources (audiocomponents#113). + self._set_levels() + _component.open_level_gates( + self._dither_mix, [self._dither_mix.voice[0], + self._dither_mix.voice[1], + self._dither_mix.voice[2]], + self._silence) + _component.open_level_gates( + self._blend, [self._blend.voice[0], self._blend.voice[1]], + self._silence) + self._dither_primed = False + self._blend_primed = False want = self._sections[-1] if getattr(self, "_band", False) \ else self._wet_in if self._hold_source is not want: # `play()` re-arms the accumulator, so it is called when the # branch actually moves and not on every macro write. + if want is not self._wet_in: + # Band Limit back on: the sections were not pulled while it + # was off and still hold what they held when it went off. + # A low-pass answers silence with silence, so cleared is + # where they would have decayed to (audiocomponents#113). + self._clear_nodes(only=self._sections) self._hold.play(want) self._hold_source = want hold = self._hold dither_lsb = getattr(self, "_dither_lsb", 0.0) if dither_lsb > 0.0: if not self._dither_primed: + # Dither back on: the gate was not pulled while it was off, + # and an open gate plays the dither out of silence until it + # closes. Closed is what silence would have left it. + self._clear_nodes(only=(self._dither_gate,)) self._dither_mix.voice[0].play(hold) self._dither_mix.voice[1].play(self._dither_a, loop=True) self._dither_mix.voice[2].play(self._dither_b, loop=True) diff --git a/lib/audioeffects/compressor.py b/lib/audioeffects/compressor.py index 901f6ee..63559b2 100644 --- a/lib/audioeffects/compressor.py +++ b/lib/audioeffects/compressor.py @@ -6,6 +6,11 @@ ratio law and side-chain weighting** - not in three time constants, which is all the class this replaces had (dossier §7.2). +Mix back up from 0 comes back as the class was built: the two stages +cleared and the voices primed afresh. An instance built wet and moved to 0 +before it played used to keep the block its construction primed and play it +256 frames after the input stopped (audiocomponents#113). + * **`fet`** - the 1176. Peak detector, both time knobs live and *faster clockwise* across 800 to 20 microseconds and 1.1 seconds to 50 milliseconds, threshold rising with ratio. Turn everything up and it @@ -368,8 +373,18 @@ def _refresh_output(self): if not self._ready: return if self.macro(13) <= 0.0: - self._output = self._source + self._route_around(self._source) return + # Back off a bypass each voice still holds the block it had queued - + # on an instance built wet and moved to 0 before it played, the + # block `_prime` took at construction, which came out 256 frames + # after the input stopped. So the graph is cleared and the voices + # primed afresh (audiocomponents#113). The two Dynamics hold a gain + # and no audio, but a voice's `play()` resets what it plays, so the + # stage it reads restarts its gain either way: the class comes back + # as it was built, envelopes and all. + if self._rejoin(): + self._primed = False if not self._primed: self._prime() self._output = self._mixer diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 7a8fc63..2017e57 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -14,6 +14,12 @@ The DS-1's booster is in the circuit, not on the panel, so `scoop` starts with Boost at 35 dB; the Rat has no booster and starts at 0. +**Mix back up from 0 plays nothing from before.** At Mix 0 nothing behind +the source is pulled, so the circuit used to keep what it held and play it +when Mix came back - 24 589 LSB out of silence at shipped patch 1. It now +comes back as it was built: cleared, the output capacitor charged on the +bias at the scoop patches, the voices re-armed (audiocomponents#113). + **Portability tier: audiodsp** (`REQUIRES = ("audioshaper", "audiobiquad", "audioroute")`). The clipper is `audioshaper.Waveshaper` with a table derived from the 1N4148 pair (tools/curves/distortion_curve.py). Tone and @@ -863,15 +869,30 @@ def _push(self): volume = self.macro(VOL) if mix <= 0.0: self._latency = 0 - self._output = self._source + self._route_around(self._source) return + # Back off a bypass, the graph behind the mixer has not been pulled + # since Mix went to 0 and still holds what it held then: its + # filters' memory and the block each voice had queued. `_rejoin` + # clears it, and it is armed below exactly as `_build` arms it - + # the output capacitor charged on the bias, the level gates opened + # at the levels just pushed, every voice re-played - before the + # port is pointed at it (audiocomponents#113). + rejoin = self._rejoin() # The dry leg is not delayed and the wet leg is, so the class's own # onset is the DRY one at every Mix that lets any dry through: a # click at Mix 64 arrives at sample 0, not at sample 2. Reporting # the wet leg's 2 there was true of one point on the axis and false # of the other 126. self._latency = self._wet_latency if mix >= 1.0 else 0 - self._output = self._mixer + if not rejoin: + self._output = self._mixer + self._push_wet(mix, volume) + if rejoin: + self._connect() + self._output = self._mixer + + def _push_wet(self, mix, volume): self._dry.level = 1.0 - mix ceiling = self.macro(CEIL) if self._scoop: diff --git a/lib/audioeffects/dynamiceq.py b/lib/audioeffects/dynamiceq.py index c605597..4701513 100644 --- a/lib/audioeffects/dynamiceq.py +++ b/lib/audioeffects/dynamiceq.py @@ -35,8 +35,10 @@ upstream's Q15 level is `1.0 * 32768` and the kernel divides by 32767, so the dry tap at unity came out one LSB high at every sample from 32736 up (audiodsp#95, three of 16384 on a full-scale ramp). The mixer's voices take -their sources the first time Mix leaves 0, with their level gates opened on -one block of silence first. +their sources whenever Mix leaves 0, with their level gates opened on one +block of silence first, and the filters, cell and tail are cleared before +they do: they used to keep what they held when Mix went to 0 and play it +when it came back, up to 7 769 LSB out of silence (audiocomponents#113). **`expand=True` is a build, not a knob.** `audiodynamics.Dynamics` fixes its mode at construction, and a class that carried both a compressor and an @@ -64,7 +66,9 @@ **Cost, and it went up.** Two biquads, one detector-and-gain-cell, a three-tap splitter ring, a three-voice mixer, a block-sized guard and an -identity `MidSide`, on a graph that runs in 256-frame blocks throughout. +identity `MidSide`, on a graph that runs in 256-frame blocks throughout - +mono included since 2026-09-28, when the mixer's fixed 2048-byte buffer +(two blocks mono) became `1024 * channels`; stereo is the same bytes. Measured on the desktop against the class this replaces, five interleaved repeats of each: **1.46-1.56 ms per 256-frame stereo block against 1.10-1.15**, about **45 % more**. Three of those nodes are new - the guard, @@ -285,9 +289,18 @@ def _build(self, frequency=3000.0, q=2.0, threshold_db=-30.0, ratio=4.0, # why the audible bell is narrower than f0/Q (dossier T5). cell.play(band) + # One render is one 256-frame block at either channel count + # (`Mixer._render_size` is `buffer_size // 2 // 4 * 4` bytes). It + # was a fixed 2048, which is one block stereo but TWO mono, and then + # the tail below hands out half a render and keeps the other half + # queued: a Mix move to 0 between the halves skipped those 256 + # frames, and the move back played them - old audio out of silence + # (audiocomponents#113). Stereo is byte for byte what it was; mono + # renders the same samples in blocks half the size. mixer = audiomixer.Mixer( voice_count=3, sample_rate=rate, channel_count=channels, - bits_per_sample=16, samples_signed=True, buffer_size=2048) + bits_per_sample=16, samples_signed=True, + buffer_size=1024 * channels) # The class does **not** end in the Mixer, and that is not decoration. # On upstream CircuitPython `audiomixer.Mixer.reset_buffer` *stops* @@ -430,8 +443,17 @@ def _refresh_output(self): return if self.macro(self._MIX) <= 0.0 \ and self._macros[self._LISTEN] < 0.5: - self._output = self._head + self._route_around(self._head) return + # Back off the bypass, the filters, the cell and the identity tail + # still hold what they held when Mix went to 0, and each voice the + # block it had queued. Cleared, then the voices re-played once + # through the cleared nodes, as `_build` does (audiocomponents#113). + # The guard is the bypass's own node and still live, so it is kept; + # the mixer is kept out of the walk because its registered reset + # re-plays the voices twice over. + if self._rejoin(keep=(self._head, self._mixer)): + self._primed = False if not self._primed: self._play_voices() self._output = self._tail diff --git a/lib/audioeffects/exciter.py b/lib/audioeffects/exciter.py index c53b242..d497294 100644 --- a/lib/audioeffects/exciter.py +++ b/lib/audioeffects/exciter.py @@ -15,6 +15,12 @@ Envelope-following `Waveshaper.bias` was not granted; bias is static. Transient Time is not on the surface: T5's τ clause is disconfirmed. +**Mix back up from 0 plays nothing from before.** At Mix 0 (with Output +at 0 dB) the class hands back its source and the harmonic branch is not +pulled; it used to keep what it held and play it when Mix came back, up to +10 768 LSB out of silence. It is now cleared and its voices re-armed first +(audiocomponents#113). + **Harmonics is 0…+14 dB of drive across the diode, not 0…+24.** Measured on this class's own curve, the top ten of those twenty-four decibels buy **1.76 dB** of h2 at −6 dBFS and cost **6.7 dB** of alias: past the knee @@ -679,11 +685,22 @@ def _prime_if_wet(self): # top of the macro put the wet over the dry (audiocomponents#72). out = 10.0 ** (self._value(4) / 20.0) if mix <= 0.0 and out >= 1.0: - self._output = self._source + self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._blend return + if self._rejoin(): + # Back off the bypass: the nodes are cleared, and the voices + # still hold the blocks they had queued when the bypass began. + # Re-armed as `_build` arms them - levels, gates, voices - and + # only then is the port pointed back (audiocomponents#113). + self._blend.voice[0].level = out + self._blend.voice[1].level = mix * out + _component.open_level_gates( + self._blend, [self._blend.voice[0], self._blend.voice[1]], + self._silence) + self._primed = False if not self._primed: self._dc.play(self._shaper) self._blend.voice[0].play(self._dry) diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py index 716786d..05b3b83 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -12,6 +12,12 @@ when you hit it. There is no tone stack. `cascade` is a Ram's Head 1973 Big Muff: two silicon pairs and a mid scoop between 482 Hz and 1206 Hz. +**Mix back up from 0 plays nothing from before.** At Mix 0 the class hands +back its source and nothing behind it is pulled; it used to keep what it +held and play it when Mix came back, up to 23 153 LSB out of silence. It +now comes back as it was built: cleared, the output pole charged on the +bias, the tone mixer's voices re-armed (audiocomponents#113). + **What the default surrenders.** Mix 1, Fuzz 36 dB, Bias 0, Load 0, Tilt 0. Tone is inert on germanium. G2's 0.5 dB-per-step THD staircase is not met here (the last steps are +0.17 / +0.04 / +0.008 dB); h2/h1 still moves. @@ -980,12 +986,37 @@ def _refresh_output(self): if not self._ready: return if self._value(5) <= 0.0: - self._output = self._source + self._route_around(self._source) self._latency = 0 else: + if self._rejoin(keep=(self._tone_mix,)): + self._rearm() self._output = self._tilt self._latency = self._wet_delay + def _rearm(self): + """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. + + Back off a bypass every node still holds what it held when Mix went + to 0, and on `cascade` each voice still holds the block it had + queued; `_rejoin` has cleared the nodes. The mixer is left out of + that walk on purpose: its registered reset re-plays the voices + there and then, which would take a block through a pole not yet + charged. So the order is `_build`'s: the output pole charged on the + offset, the shapers pointed back at the chain, then the gates and + the voices (audiocomponents#113). + """ + self._charge_output(rewire=True) + if self._tone_mix is not None: + _component.open_level_gates( + self._tone_mix, + [self._tone_mix.voice[0], self._tone_mix.voice[1], + self._tone_mix.voice[2]], + self._silence) + self._tone_mix.voice[0].play(self._tone_lp) + self._tone_mix.voice[1].play(self._tone_hp) + self._tone_mix.voice[2].play(self._dry) + def program_change(self, index, channel=0, note_id=-1, sample_position=0): _component.Component.program_change(self, index, channel, note_id, diff --git a/lib/audioeffects/limiter.py b/lib/audioeffects/limiter.py index abd0264..bb2070b 100644 --- a/lib/audioeffects/limiter.py +++ b/lib/audioeffects/limiter.py @@ -32,6 +32,18 @@ class that delays anything, it defaults to **0 ms**, and `latency_samples` to 480 samples (10 ms) at 48 kHz. True Peak adds **no latency at all**: the 4x reconstruction runs on the detector signal and never reaches the audio path. +**A lookahead that grows starts from silence.** The node writes its line +only as far as its delay reaches, so lengthening it used to expose what +the line held the last time it was that long: Lookahead off and back on +after a pause replayed the last note at full level (31 373 LSB), and True +Peak moving its twelve-sample reserve between the stages did the same. A +stage whose delay grows is now reset first (audiocomponents#113). The node +cannot clear its line alone, so that stage's gain restarts too: with the +shape stage's zero attack it catches the next peak on its first sample, and +the catch stage holds the ceiling throughout. Lengthening the lookahead +while audio plays therefore inserts that much silence, where it used to +insert old audio. + **True Peak costs real time, and it costs lookahead.** The palette prices each `Dynamics` with `true_peak=2` at the +options row: **1.611 ms / 3.461 ms** marginal on the P4 / S3. This class lights it on *both* stages, so the @@ -230,6 +242,10 @@ def _build(self, ceiling_db=-1.0, gain_db=0.0, lookahead_ms=0.0, low material - see the class docstring.""" self._latency = 0 self._true_peak = bool(true_peak) + #: The delay each stage was last handed, in samples. See + #: `_apply_lookahead` for why a stage's growth clears it. Made there + #: if a subclass's `_build` did not, so a planted build still runs. + self._held = {} self._shape = self._own(audiodynamics.Dynamics( audiodynamics.DYN_COMPRESS, sample_rate=self._sample_rate, @@ -337,6 +353,23 @@ def _apply_lookahead(self, position): reserve = (min(samples, type(self).TRUE_PEAK_RESERVE_SAMPLES) if self._true_peak else 0) shape = samples - reserve + # A stage whose delay GROWS is cleared first. The node writes its + # lookahead line only as far as the delay it is set to, so the rest + # of the line keeps whatever it held the last time the delay reached + # that far; lengthen it again and that plays - Lookahead off and + # back on after a pause replayed the last note at full level, 31 373 + # LSB, and True Peak's twelve-sample reserve moving between the + # stages did the same (audiocomponents#113). The node has no way to + # clear the line alone, so the stage is reset: the line goes to + # silence and the stage's gain starts afresh. A delay that shrinks + # or stays keeps its line. + held = getattr(self, "_held", None) + if held is None: + held = self._held = {} + for stage, frames in ((self._shape, shape), (self._catch, reserve)): + if frames > held.get(stage, 0) and not self._constructing: + self._clear_nodes(only=(stage,)) + held[stage] = frames self._shape.set(lookahead_ms=(0.0 if shape == 0 else (shape + 0.5) * 1000.0 / rate)) release_ms = _CATCH_RELEASE_MS diff --git a/lib/audioeffects/multibandcompressor.py b/lib/audioeffects/multibandcompressor.py index 61621e9..6edfb7d 100644 --- a/lib/audioeffects/multibandcompressor.py +++ b/lib/audioeffects/multibandcompressor.py @@ -43,7 +43,10 @@ so. Nor is the dry tap at unity enough: a mixer voice at level 1.0 scales by 32768/32767 on every target (audiodsp#95), so `_refresh_output` hands back the borrowed source itself and puts no node of this class's in the - path at all. + path at all. Mix back up from 0 comes back as the class was built: the + crossover and detectors cleared, the voices re-armed. It used to play what + the bands held when Mix went to 0, up to 22 264 LSB out of silence + (audiocomponents#113). * **How close the two crossovers may get.** The three-way parallel split has a floor that is a function of the crossover *ratio*, not of the @@ -663,8 +666,17 @@ def _refresh_output(self): if not self._ready: return if self._mix <= 0.0: - self._output = self._head + self._route_around(self._head) return + # Back off the bypass, the crossover and the detectors still hold + # what they held when Mix went to 0, and each voice the block it had + # queued. Cleared, then the voices re-played once through the + # cleared nodes, as `_build` does (audiocomponents#113). The guard + # is the bypass's own node and still live, so it is kept; the mixer + # is kept out of the walk because its registered reset re-plays the + # voices twice over. + if self._rejoin(keep=(self._head, self._mixer)): + self._primed = False if not self._primed: self._play_voices() self._output = self._mixer diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py index 27481bb..3c8a656 100644 --- a/lib/audioeffects/overdrive.py +++ b/lib/audioeffects/overdrive.py @@ -17,7 +17,10 @@ of circuit, the way a true-bypass footswitch takes it out. One step above zero the tone stack is in circuit on the dry note as well, so the step off Mix 0 is a tone change, and that is the pedal switching on rather than a -fade. +fade. It switches on as it was built, not as it was left: the circuit is +cleared and armed again - the output capacitor charged on the offset - +before it is heard, where it used to play what it held when Mix went to 0, +up to 11 640 LSB out of silence (audiocomponents#113). **The clip table is a normalised shape, not volts.** `CURVE` fills Q15 to both rails and `CURVE_VOLTS` is what its full scale stands for; the class @@ -785,11 +788,14 @@ def _prime_if_wet(self): mix = self._value(3) level = self._value(2) if mix <= 0.0: - self._output = self._source + self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._out return + if self._rejoin(): + self._rearm(level) + return if not self._primed: self._circuit.voice[0].play(self._dry) self._circuit.voice[1].play(self._dc) @@ -799,3 +805,35 @@ def _prime_if_wet(self): self._circuit.voice[0].level = 1.0 self._circuit.voice[1].level = 1.0 self._out.voice[0].level = level + + def _rearm(self, level): + """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. + + Back off a bypass the filters, the shaper and the output capacitor + still hold what they held when Mix went to 0, and each mixer voice + still holds the block it had queued; `_rejoin` has just cleared the + nodes. What is left is `_build`'s own sequence: the levels, the + gates opened at them, the voices re-played with the clip branch + muted at the shaper so the block each keeps is a zero one, and the + capacitor charged on the offset before the port is pointed back at + the graph (audiocomponents#113). + """ + self._dc_charged = False + self._circuit.voice[0].level = 1.0 + self._circuit.voice[1].level = 1.0 + self._out.voice[0].level = level + if self._clip_bias: + self._shaper.set(post_gain=0.0) + _component.open_level_gates( + self._circuit, [self._circuit.voice[0], self._circuit.voice[1]], + self._silence) + _component.open_level_gates(self._out, [self._out.voice[0]], + self._silence) + self._circuit.voice[0].play(self._dry) + self._circuit.voice[1].play(self._dc) + self._out.voice[0].play(self._lp) + self._primed = True + if self._clip_bias: + self._shaper.set(post_gain=self._post_gain) + self._settle_dc() + self._output = self._out diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py index da64fd9..2a60fae 100644 --- a/lib/audioeffects/saturation.py +++ b/lib/audioeffects/saturation.py @@ -17,6 +17,20 @@ capacitor, the playback head that cannot see DC, the flux high-pass. That is where Bias's operating-point shift goes instead of onto the output. +**Controls moved and moved back play nothing out of silence** +(audiocomponents#113). Mix back up from 0 comes back as the class was +built - cleared, the pole charged on the bias, the voices re-armed - where +it used to play what it held, up to 7 927 LSB. Off centre the pole holds +`curve(bias) * post_gain`, so Bias back to the centre now drains it (it bled +out as a 29 058 LSB thump), and Drive, Output, Headroom and Hysteresis +re-charge it as Bias does. Hysteresis back in starts its play operator at +the centre instead of where it froze (314 LSB). Off centre each charge +leaves at most 1 LSB, as the constructor's does. And a Bias re-charge no +longer pulls a block off the dry tap: that put the dry leg 256 frames ahead +of the wet one after every `program_change`, a comb at patch 7 (Mix 44), +and left a block of old audio in the Splitter for the next Mix move off 0 +to play (13 071 LSB). + **What the default surrenders.** Mix 1, Drive 0 dB, Headroom 0, Bias 0, Tilt 0, Hysteresis 0, Speed 15 ips (inert on tube). @@ -711,6 +725,15 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0, self._ready = False self._attached = False self._bias = 0.0 + #: Whether the plate pole was last charged on an offset, so a Bias + #: move back to the centre drains it rather than leaving it to + #: bleed out as a thump (audiocomponents#113). + self._charged = False + #: The Hysteresis the shapers were last handed; see `_apply_macro`. + self._hysteresis = 0.0 + #: The shapers' `post_gain`, and what it was at the last charge. + self._post = 1.0 + self._charged_post = 1.0 self._wet_head = 1.0 self._group_delay = _group_delay_samples(oversample, self._extra_delay) @@ -866,10 +889,28 @@ def _charge_coupling(self, rewire=False): stops as soon as a whole block comes back zero. A **live** Bias move - a `program_change` onto silence, most of - all - re-charges with `rewire=True`. That costs one block: the - re-point takes a block from the wet tap, so the dry tap is pulled - once to match it, and the split stays level. Without it a change - onto patch 4 banged 8 489 LSB out of nothing. + all - re-charges with `rewire=True`. Without it a change onto patch + 4 banged 8 489 LSB out of nothing. + + **The re-point costs no block, so nothing is pulled to level the + split.** This used to pull one block off the dry tap after the + re-point, on the belief that `Waveshaper.play()` takes one from the + wet tap. It does not - measured on CPython, desktop MicroPython and + CircuitPython, a `play()` onto a source takes nothing from it (only + `audiofilters.Filter.play` and `MixerVoice.play` do) - so the pull + put the dry leg a block **ahead** of the wet one at every + `program_change` and every Bias move, and left the wet tap a block + behind in the Splitter's ring. The dry leg's impulse came out 256 + frames early at patch 4, 512 after one more Bias move; at shipped + patch 7 (Mix 44), where the dry leg is heard, that is a comb; and + the block left behind in the ring is old audio that the next Mix + move off 0 played out of silence, 13 071 LSB (audiocomponents#113). + + A Bias move **back to the centre** drains the pole the same way: + the shaper then answers silence with zero, and the charge the pole + holds from the old offset would otherwise bleed out of silence as a + thump - 29 058 LSB after Bias went to its bottom stop and back + (audiocomponents#113). On a build with no `audiocore.get_buffer` this does nothing and the bang is back. @@ -877,13 +918,14 @@ def _charge_coupling(self, rewire=False): pull = getattr(audiocore, "get_buffer", None) if pull is None or self._after is None: return False - if not self._bias: + if not self._bias and not self._charged: if rewire: for shaper in self._shapers(): shaper.play(self._quiet) shaper.play(self._shaper_head) - pull(self._dry) return False + self._charged = bool(self._bias) + self._charged_post = self._post for index in range(CHARGE_BLOCKS): for shaper in self._shapers(): shaper.play(self._quiet) @@ -897,9 +939,6 @@ def _charge_coupling(self, rewire=False): if rewire: for shaper in self._shapers(): shaper.play(self._shaper_head) - # One block from the dry tap, so the split stays level: the - # re-point above took one from the wet one. - pull(self._dry) return True def _attach(self): @@ -955,6 +994,15 @@ def _apply_macro(self, index, position): value = _component.macro_value(self._MACRO_RANGES[index], position) if index in (0, 1, 3): self._push_gains() + # Drive, Output and Headroom move `post_gain`, and off centre + # the offset the plate pole holds is `curve(bias) * post_gain`, + # so they move it as much as Bias does: Headroom at patch 4 and + # back banged 4 885 LSB out of silence. Re-charged the way a + # Bias move is (audiocomponents#113). At the centre there is no + # offset and nothing runs. + if self._ready and (self._bias or self._charged) \ + and self._post != self._charged_post: + self._charge_coupling(rewire=True) elif index == 2: self._push_mix() self._refresh_output() @@ -978,8 +1026,33 @@ def _apply_macro(self, index, position): elif index == 6: self._tilt.gain_db = round(value * 8.0) / 8.0 elif index == 7: + back_in = (value > 0.0 and self._hysteresis <= 0.0 + and not self._constructing) + moved = value != self._hysteresis + self._hysteresis = value for shaper in self._shapers(): shaper.set(hysteresis=value) + if back_in: + # Hysteresis back in: the node skips its play operator + # outright while Hysteresis is 0, so the operator's position + # is frozen where it was when the knob went down and the + # first sample back steps from there - an offset out of + # silence, 314 LSB through the plate pole. Cleared, it + # starts at the centre, which is where silence would have + # left it (audiocomponents#113). Clearing a shaper clears + # its oversampler too, and off centre the offset then has to + # climb back through the half-band - a dip the charged pole + # passes as a thump, 7 560 LSB at patch 4 - so off centre + # the pole is re-charged behind it, as a Bias move does, + # with the operator already engaged so the charge settles + # where it will sit. + self._clear_nodes(only=self._shapers()) + if moved and self._ready and (self._bias or self._charged): + # Off centre the operator rests a half-width below the bias, + # so any Hysteresis move - off included - moves the offset + # the pole holds: 14 LSB out of silence at patch 4 after + # Hysteresis went off and came back. + self._charge_coupling(rewire=True) def _curve_at(self, x): """The table at `x`, interpolated the way the node interpolates.""" @@ -1053,6 +1126,7 @@ def _push_gains(self): self._wet_head = 1.0 for shaper in self._shapers(): shaper.set(pre_gain=pre, post_gain=post) + self._post = post self._push_mix() def _push_mix(self): @@ -1087,13 +1161,35 @@ def _refresh_output(self): if not self._ready: return if self._value(2) <= 0.0: - self._output = self._source + self._route_around(self._source) self._latency = 0 else: + if self._rejoin(keep=(self._mix,)): + self._rearm() self._attach() self._output = self._mix self._latency = self._wet_delay + def _rearm(self): + """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. + + Back off a bypass every node still holds what it held when Mix went + to 0 - the plate pole, the tilt and makeup shelves, the shapers' + oversamplers - and each voice the block it had queued; `_rejoin` has + cleared the nodes. The mixer is left out of that walk on purpose: + its registered reset re-plays the voices there and then, through a + pole not yet charged. So the order is `_build`'s: the pole charged + on the bias with the shapers on the zero sample, the shapers pointed + back at the chain, the gates opened at the levels, and the voices + re-played (audiocomponents#113). + """ + self._charged = False + self._charge_coupling(rewire=True) + _component.open_level_gates( + self._mix, [self._mix.voice[0], self._mix.voice[1]], + self._silence) + self._attached = False + def program_change(self, index, channel=0, note_id=-1, sample_position=0): _component.Component.program_change(self, index, channel, note_id, diff --git a/tests/support/stale_blocks.py b/tests/support/stale_blocks.py new file mode 100644 index 0000000..5616b06 --- /dev/null +++ b/tests/support/stale_blocks.py @@ -0,0 +1,223 @@ +"""The stale-block measurement, shared by the ten classes' tests +(audiocomponents#113). + +A control that routes a class's graph around - Mix to 0 handing the source +straight back, Lookahead to 0, Band Limit off - leaves the graph behind it +un-pulled, holding whatever it held. `blip()` plays the move the way a +musician would: a 300 Hz tone at 20 000 LSB for 0.5 s with the macro at `a`, +the macro to `b` as the tone stops, 1 s of silence, the macro back to `a`, +0.5 s more silence. It returns the peak over the last 0.25 s before the move +back (the class's own ring: 0 means the move back is into silence) and the +peak after it, short of the last two blocks. Sound there is old audio +coming back out of silence. + +`first_blip()` is the construction variant: built at its default (which is +wet, so a mixer voice primes itself with the first block of the source), Mix +to 0 before anything is pulled, the tone and the silence through the bypass, +then Mix up. What it returns is the peak after the move up. + +`StaleRejoin` and `ClearOnlyRejoin` are the two planted faults every class +test shows red, applied to a class with `planted(cls, fault)`: + +* `StaleRejoin` - the old behaviour: coming back off a bypass touches + nothing. +* `ClearOnlyRejoin` - a wrong cure: the nodes are cleared but the class is + told nothing happened, so it does not re-arm - the voices keep the blocks + they had queued and a biased shaper's coupling pole is left uncharged. +""" + +import math +import os +import sys +from array import array + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", + "lib")) + +import audiocore # noqa: E402 +import audiofilters # noqa: E402 + +#: `_component` reads VENDOR off the module a class is defined in, and the +#: planted classes `planted()` builds are defined here. +VENDOR = "PyDevices" + +BLOCK = 256 +RATES = (48000, 44100, 22050) + + +def _block_up(frames): + return (frames + BLOCK - 1) // BLOCK * BLOCK + + +def spans(rate): + """Where the tone stops, where the move back lands, and where reading + ends, in frames.""" + tone = _block_up(rate // 2) + back = tone + _block_up(rate) + end = back + _block_up(rate // 2) + return tone, back, end + + +def source(rate, channels): + """The tone, then silence, delivered a block at a time and never + again: `render_effect.py`'s bit-transparent adapter. A bare RawSample + hands its whole buffer to a class whose Mix 0 is the source itself, and + starts again when it runs out.""" + tone, _back, end = spans(rate) + values = array("h", bytes(2 * channels * end)) + step = 2.0 * math.pi * 300.0 / rate + for frame in range(tone): + value = int(20000 * math.sin(step * frame)) + for channel in range(channels): + values[frame * channels + channel] = value + sample = audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + wrap = audiofilters.Filter(filter=None, mix=1, + buffer_size=BLOCK * channels * 2, + sample_rate=rate, bits_per_sample=16, + samples_signed=True, channel_count=channels) + wrap.play(sample, loop=False) + return wrap + + +def _render(effect, frames, channels, moves): + out = array("h") + pending = list(moves) + while len(out) < frames * channels: + done = len(out) // channels + while pending and done >= pending[0][0]: + _frame, index, value = pending.pop(0) + effect.set_macro(index, value) + data = bytes(audiocore.get_buffer(effect.output)[1]) + if not data: + data = bytes(2 * BLOCK * channels) + out.extend(array("h", data)) + return out + + +def _peak(values, lo, hi): + top = 0 + for value in values[lo:hi]: + if value < 0: + value = -value + if value > top: + top = value + return top + + +def _build(cls, rate, channels, patch, options): + effect = cls(source(rate, channels), sample_rate=rate, **options) + if patch is not None: + effect.program_change(patch) + return effect + + +def blip(cls, index, a, b, rate=48000, channels=2, patch=None, **options): + """(before, after): the peak before the move back and after it.""" + tone, back, end = spans(rate) + effect = _build(cls, rate, channels, patch, options) + effect.set_macro(index, a) + out = _render(effect, end, channels, + [(tone, index, b), (back, index, a)]) + effect.deinit() + ch = channels + return (_peak(out, (back - rate // 4) * ch, back * ch), + _peak(out, back * ch, (end - 2 * BLOCK) * ch)) + + +def twin(cls, index, a, rate=48000, channels=2, patch=None, **options): + """The peak an instance that never moves plays over the window `blip` + reads after the move back: the class's own floor there. A few settings + never reach silence whatever the move (an off-centre Bias's wander), + and a blip is only old audio where this is lower.""" + _tone, back, end = spans(rate) + effect = _build(cls, rate, channels, patch, options) + effect.set_macro(index, a) + out = _render(effect, end, channels, []) + effect.deinit() + return _peak(out, back * channels, (end - 2 * BLOCK) * channels) + + +def in_step(cls, index, a, b, rate=48000, channels=2, patch=None, + **options): + """The largest difference between a moved instance and one that never + moved over the second half of a second tone played after the move back: + 0 (or a few LSB of settling) when the graph came back in step, and + thousands when one leg came back a block out of line.""" + tone, back, end = spans(rate) + again = end + _block_up(rate // 4) + + def material(): + values = array("h", bytes(2 * channels * again)) + step = 2.0 * math.pi * 300.0 / rate + for lo, hi in ((0, tone), (end, again)): + for frame in range(lo, hi): + value = int(20000 * math.sin(step * (frame - lo))) + for channel in range(channels): + values[frame * channels + channel] = value + sample = audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + wrap = audiofilters.Filter( + filter=None, mix=1, buffer_size=BLOCK * channels * 2, + sample_rate=rate, bits_per_sample=16, samples_signed=True, + channel_count=channels) + wrap.play(sample, loop=False) + return wrap + + outs = [] + for moves in ([(tone, index, b), (back, index, a)], []): + effect = cls(material(), sample_rate=rate, **options) + if patch is not None: + effect.program_change(patch) + effect.set_macro(index, a) + outs.append(_render(effect, again, channels, moves)) + effect.deinit() + moved, still = outs + half = end + (again - end) // 2 + gap = 0 + for position in range(half * channels, again * channels): + step = moved[position] - still[position] + if step < 0: + step = -step + if step > gap: + gap = step + return gap + + +def first_blip(cls, index, rate=48000, channels=2, patch=None, **options): + """The peak after Mix comes up on an instance that went to 0 before its + first pull.""" + _tone, back, end = spans(rate) + effect = _build(cls, rate, channels, patch, options) + effect.set_macro(index, 0) + out = _render(effect, end, channels, [(back, index, 127)]) + effect.deinit() + ch = channels + return _peak(out, back * ch, (end - 2 * BLOCK) * ch) + + +class StaleRejoin: + """Planted: the old behaviour. A bypass left behind is taken back + untouched, so what the graph held plays.""" + + def _rejoin(self, keep=()): + self._stranded = False + return False + + +class ClearOnlyRejoin: + """Planted, a wrong cure: the nodes are cleared, but the class is not + told, so it does not re-arm them the way its constructor does.""" + + def _rejoin(self, keep=()): + if self._stranded: + self._stranded = False + self._clear_nodes(keep) + return False + + +def planted(cls, fault): + """`cls` with `fault` mixed in, under the same provider name.""" + return type(cls.__name__ + fault.__name__, (fault, cls), + {"NAME": cls.NAME}) diff --git a/tests/test_cpython_effects_bitcrusher.py b/tests/test_cpython_effects_bitcrusher.py index 0c47f9e..b105364 100644 --- a/tests/test_cpython_effects_bitcrusher.py +++ b/tests/test_cpython_effects_bitcrusher.py @@ -1083,3 +1083,122 @@ def test_program_change_onto_digital_silence_stays_silent(self): self.assertEqual(peak, 0, "program_change(%d) on silence emitted %d " "LSB" % (patch, peak)) + + +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + No in-step check here: the hold's accumulator restarts with the graph, + so the returning tone is held on a different grid from an instance + that never moved - hundreds of LSB apart before the fix as well. + """ + + CLS = rebuilt.Bitcrusher + MIX = 4 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + +class NoBranchClear(rebuilt.Bitcrusher): + """Planted: the class before the fix. Band Limit or Dither back on and + nothing is cleared.""" + + def _clear_nodes(self, keep=(), only=None): + if only is None: + rebuilt.Bitcrusher._clear_nodes(self, keep, only) + + +class FirstSectionClear(rebuilt.Bitcrusher): + """Planted, a wrong cure: Band Limit back on clears the first section of + the low-pass and leaves the rest holding what they held.""" + + def _clear_nodes(self, keep=(), only=None): + if only is not None and only is self._sections: + only = self._sections[:1] + rebuilt.Bitcrusher._clear_nodes(self, keep, only) + + +class TheBandLimitAndTheDitherComeBackClean(unittest.TestCase): + """Band Limit and Dither back on after a pause play nothing from before + it (audiocomponents#113). The band-limit sections and the dither gate + are not pulled while their switch is off; the sections kept their + memory (13 646 LSB out of silence) and the gate stayed open on the + dither (32). Each is cleared when it comes back.""" + + CLS = rebuilt.Bitcrusher + + def test_band_limit_back_on_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual(stale.blip(self.CLS, 3, 127, 0, rate, + channels), (0, 0), + (rate, channels)) + for patch in sorted(self.CLS.PATCHES): + self.assertEqual(stale.blip(self.CLS, 3, 127, 0, patch=patch), + (0, 0), patch) + + def test_dither_back_on_plays_nothing_it_did_not_already(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual(stale.blip(self.CLS, 2, 127, 0, rate, + channels), (0, 0), + (rate, channels)) + # At the low-bit patches the dither is never silent at 127, + # moved or not: the move adds nothing to what the class plays. + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, 2, 127, 0, patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, stale.twin(self.CLS, 2, 127, patch=patch), patch) + + def test_the_old_class_and_a_partial_clear_are_red(self): + self.assertGreater(stale.blip(NoBranchClear, 3, 127, 0)[1], 5000) + self.assertGreater(stale.blip(NoBranchClear, 2, 127, 0)[1], 0) + self.assertGreater(stale.blip(FirstSectionClear, 3, 127, 0)[1], 100) diff --git a/tests/test_cpython_effects_compressor.py b/tests/test_cpython_effects_compressor.py index 0e58216..35363fa 100644 --- a/tests/test_cpython_effects_compressor.py +++ b/tests/test_cpython_effects_compressor.py @@ -239,5 +239,47 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +from audioeffects import compressor # noqa: E402 + + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix up from 0 plays nothing from before (audiocomponents#113; Brad, + 2026-09-28: "fix the stale blocks"). An instance built wet primes its + mixer voices with the first block of the source; moved to 0 before it + played, and back up after the input stopped, that block came out 256 + frames later. `_component.Component._rejoin` clears the graph and the + voices are primed afresh.""" + + CLS = compressor.Compressor + MIX = 13 + + def test_the_block_primed_at_construction_is_not_replayed(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual(stale.first_blip( + self.CLS, self.MIX, rate, channels), 0, (rate, channels)) + for patch in sorted(self.CLS.PATCHES): + self.assertEqual(stale.first_blip( + self.CLS, self.MIX, patch=patch), 0, patch) + + def test_mix_back_after_silence_plays_nothing(self): + for channels in (2, 1): + self.assertEqual(stale.blip(self.CLS, self.MIX, 127, 0, + channels=channels), (0, 0)) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + for fault in (stale.StaleRejoin, stale.ClearOnlyRejoin): + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, fault), self.MIX), 10000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index 4dfbcc9..f229be2 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -1541,5 +1541,72 @@ def test_program_change_onto_digital_silence_stays_silent(self): "LSB" % (patch, peak)) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + At the scoop patches the output capacitor is charged on the bias again + before the port is pointed back, as `_build` charges it. + """ + + CLS = rebuilt.Distortion + MIX = 4 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_dynamiceq.py b/tests/test_cpython_effects_dynamiceq.py index 1701ca4..2a93202 100644 --- a/tests/test_cpython_effects_dynamiceq.py +++ b/tests/test_cpython_effects_dynamiceq.py @@ -960,5 +960,93 @@ def test_state_goes_red_when_a_node_is_left_out_of_the_walk(self): self.assertGreater(faulted, 1000) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + """ + + CLS = module.DynamicEQ + MIX = 6 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + patch=1) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + def test_mono_renders_one_block_at_a_time(self): + # The mixer used to render 512 mono frames at a time, and the tail + # kept the second half queued: a Mix move to 0 between the halves + # skipped it, and the move back played it. Rendered a block at a + # time, nothing is queued, and the class comes back in step at + # 44.1 kHz mono, where the move lands between the halves. + self.assertLessEqual(stale.in_step(module.DynamicEQ, 6, 127, 0, + 44100, 1), 3) + # Planted: the fixed 2048-byte mixer, which is two blocks mono. + # With the tail cleared on the way back the class comes back 256 + # frames ahead of an instance that never moved. + real = module.audiomixer.Mixer + + def wide(*args, **kwargs): + kwargs["buffer_size"] = 2048 + return real(*args, **kwargs) + + module.audiomixer.Mixer = wide + try: + self.assertGreater(stale.in_step(module.DynamicEQ, 6, 127, 0, + 44100, 1), 1000) + finally: + module.audiomixer.Mixer = real + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_cpython_effects_exciter.py b/tests/test_cpython_effects_exciter.py index e75f2ae..8515259 100644 --- a/tests/test_cpython_effects_exciter.py +++ b/tests/test_cpython_effects_exciter.py @@ -1418,5 +1418,69 @@ def test_the_block_length_moves_no_sample(self): rebuilt.MIXER_BUFFER_BYTES = kept +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + """ + + CLS = rebuilt.Exciter + MIX = 2 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_fuzz.py b/tests/test_cpython_effects_fuzz.py index c7521d1..3c181fb 100644 --- a/tests/test_cpython_effects_fuzz.py +++ b/tests/test_cpython_effects_fuzz.py @@ -2241,5 +2241,82 @@ def test_program_change_onto_digital_silence_stays_silent(self): "output pole was charged" % (patch, peak, self.RESIDUAL_LSB)) + +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + `germanium` primes nothing at construction, so the construction variant + reads 0 before and after; off-centre Bias the output pole is charged + again, and a clear without that charge is red at shipped patch 2. + """ + + CLS = rebuilt.Fuzz + MIX = 5 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + # `germanium` primes no voice at construction, so the construction + # variant cannot tell a clear-only cure apart: the next test does. + + def test_a_clear_without_the_charge_is_red_off_centre(self): + # Shipped patch 2 is Bias -0.844: cleared and not re-charged, the + # output pole starts cold and the offset bangs out of silence. + _before, after = stale.blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX, 127, + 0, patch=2) + self.assertGreater(after, 10000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_limiter.py b/tests/test_cpython_effects_limiter.py index 0fbbee8..9e54388 100644 --- a/tests/test_cpython_effects_limiter.py +++ b/tests/test_cpython_effects_limiter.py @@ -1030,5 +1030,75 @@ def test_true_peak_off_is_byte_for_byte_what_it_always_was(self): self.assertEqual(digests[0], digests[1]) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class NoStageClear(rebuilt.Limiter): + """Planted: the class before the fix. A stage's delay grows and nothing + is cleared, so the line plays what it held when it was last that + long.""" + + def _clear_nodes(self, keep=(), only=None): + if only is None: + rebuilt.Limiter._clear_nodes(self, keep, only) + + +class ShapeStageOnlyClear(rebuilt.Limiter): + """Planted, a wrong cure: only the shape stage is cleared when its delay + grows. True Peak's twelve-sample reserve lives on the catch stage, and + that line keeps its old contents.""" + + def _clear_nodes(self, keep=(), only=None): + if only is not None and self._catch in only: + return + rebuilt.Limiter._clear_nodes(self, keep, only) + + +class ALookaheadThatGrowsPlaysNothingOld(unittest.TestCase): + """Lookahead back up after a pause plays nothing from before the pause + (audiocomponents#113; Brad, 2026-09-28: "fix the stale blocks"). The + node writes its lookahead line only as far as its delay reaches, so the + rest keeps what it held the last time the delay was that long, and + Lookahead off and back on replayed the last note at full level (31 373 + LSB). A stage whose delay grows is now cleared first.""" + + CLS = rebuilt.Limiter + + def test_lookahead_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + for low in (0, 40): + self.assertEqual( + stale.blip(self.CLS, 2, 127, low, rate, channels), + (0, 0), (rate, channels, low)) + for patch in sorted(self.CLS.PATCHES): + self.assertEqual(stale.blip(self.CLS, 2, 127, 0, patch=patch), + (0, 0), patch) + + def test_true_peak_moving_its_reserve_plays_nothing(self): + # Patches 1, 4 and 5 turn True Peak on with lookahead. + for patch in (1, 4, 5): + for a, b in ((0, 127), (127, 0)): + self.assertEqual( + stale.blip(self.CLS, 4, a, b, patch=patch), (0, 0), + (patch, a, b)) + + def test_the_old_class_and_a_shape_only_clear_are_red(self): + before, after = stale.blip(NoStageClear, 2, 127, 0) + self.assertEqual(before, 0) + self.assertGreater(after, 15000) + # True Peak on during the tone fills the catch stage's reserve; off + # hands the twelve samples back to the shape stage and the catch + # line stops being written; on again after the silence reads them. + before, after = stale.blip(ShapeStageOnlyClear, 4, 127, 0, patch=1) + self.assertEqual(before, 0) + self.assertGreater(after, 2000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_multiband.py b/tests/test_cpython_effects_multiband.py index 50ca414..da91535 100644 --- a/tests/test_cpython_effects_multiband.py +++ b/tests/test_cpython_effects_multiband.py @@ -870,5 +870,69 @@ def nonzero(cls, block, old_ring=False): self.assertEqual(nonzero(CLASS, 16384, old_ring=True), 0) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + """ + + CLS = multibandcompressor.MultibandCompressor + MIX = 13 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_overdrive.py b/tests/test_cpython_effects_overdrive.py index b03017c..71390b5 100644 --- a/tests/test_cpython_effects_overdrive.py +++ b/tests/test_cpython_effects_overdrive.py @@ -1197,5 +1197,73 @@ def test_program_change_onto_digital_silence_stays_silent(self): "LSB" % (patch, peak)) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + Off-centre Symmetry the output capacitor is charged on the offset again + with the clip branch muted for the voices' first block, as `_build` + does. + """ + + CLS = rebuilt.Overdrive + MIX = 3 + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_effects_saturation.py b/tests/test_cpython_effects_saturation.py index 5283d04..73626ac 100644 --- a/tests/test_cpython_effects_saturation.py +++ b/tests/test_cpython_effects_saturation.py @@ -1603,5 +1603,163 @@ def test_program_change_onto_digital_silence_stays_silent(self): % (patch, peak, self.RESIDUAL_LSB)) +# -- the stale blocks (audiocomponents#113) ------------------------------ + +import os # noqa: E402 +import sys # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) +import stale_blocks as stale # noqa: E402 + +class TheBypassComesBackAsBuilt(unittest.TestCase): + """Mix back up from 0 after a pause plays nothing that was there before + the pause (audiocomponents#113; Brad, 2026-09-28: "fix the stale + blocks"). At Mix 0 the class hands back its source and nothing behind + it is pulled, so the graph kept its filters' memory and the block each + mixer voice had queued; bringing Mix back played that out of silence. + `_component.Component._rejoin` clears the graph and the class re-arms + it the way its constructor does. + + Off-centre Bias (patches 4 and 6) the plate pole is charged again, and + the charge leaves at most 1 LSB behind it, as it does at construction. + + """ + + CLS = rebuilt.Saturation + MIX = 2 + + #: Patches where the class answers silence with a fixed residue of its + #: own after any move (see the class docstring), and the bound it holds. + BOUNDED = {4: 1, 6: 1} + + def test_mix_back_after_silence_plays_nothing(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertEqual( + stale.blip(self.CLS, self.MIX, 127, 0, rate, channels), + (0, 0), (rate, channels)) + self.assertEqual(stale.blip(self.CLS, self.MIX, 64, 0), (0, 0)) + + def test_at_every_patch(self): + for patch in sorted(self.CLS.PATCHES): + before, after = stale.blip(self.CLS, self.MIX, 127, 0, + patch=patch) + self.assertEqual(before, 0, patch) + self.assertLessEqual( + after, max(stale.twin(self.CLS, self.MIX, 127, patch=patch), + getattr(self, "BOUNDED", {}).get(patch, 0)), + patch) + + def test_it_comes_back_in_step(self): + for rate in stale.RATES: + for channels in (2, 1): + self.assertLessEqual( + stale.in_step(self.CLS, self.MIX, 127, 0, rate, + channels), 3, (rate, channels)) + + def test_a_block_primed_at_construction_is_not_replayed(self): + for channels in (2, 1): + self.assertEqual( + stale.first_blip(self.CLS, self.MIX, channels=channels), 0) + + def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): + # The class before the fix: the graph taken back untouched. + before, after = stale.blip( + stale.planted(self.CLS, stale.StaleRejoin), self.MIX, 127, 0, + ) + self.assertEqual(before, 0) + self.assertGreater(after, 1000) + # A wrong cure: cleared but not re-armed, so the voices keep the + # block they queued at construction. + self.assertGreater(stale.first_blip( + stale.planted(self.CLS, stale.ClearOnlyRejoin), self.MIX), 1000) + + +class DrainlessSaturation(rebuilt.Saturation): + """Planted: the class before the fix. Bias back to the centre charges + nothing, so the pole bleeds the old offset out as a thump.""" + + def _charge_coupling(self, rewire=False): + if not self._bias: + self._charged = False + return rebuilt.Saturation._charge_coupling(self, rewire) + + +class UnchargedHysteresis(rebuilt.Saturation): + """Planted, a wrong cure: Hysteresis back in clears the shapers and + charges nothing, so off centre the half-band's climb back to the offset + passes the charged pole as a thump.""" + + def _apply_macro(self, index, position): + if index != 7: + return rebuilt.Saturation._apply_macro(self, index, position) + value = rebuilt._component.macro_value(self._MACRO_RANGES[7], + position) + if value > 0.0 and self._hysteresis <= 0.0 \ + and not self._constructing: + self._clear_nodes(only=self._shapers()) + self._hysteresis = value + for shaper in self._shapers(): + shaper.set(hysteresis=value) + + +class LevellingSaturation(rebuilt.Saturation): + """Planted: the class before the fix. A Bias re-charge pulled a block + off the dry tap "to level the split", which a `Waveshaper.play()` never + unlevelled: the dry leg ran a block ahead of the wet one.""" + + def _charge_coupling(self, rewire=False): + done = rebuilt.Saturation._charge_coupling(self, rewire) + if rewire: + rebuilt.audiocore.get_buffer(self._dry) + return done + + +class TheOperatingPointComesBackClean(unittest.TestCase): + """Bias, Hysteresis and the gain macros moved and moved back play + nothing out of silence (audiocomponents#113). Off centre the plate pole + holds `curve(bias) * post_gain`; Bias back to the centre left it to + bleed out (29 058 LSB), Headroom and Hysteresis moved it without a + re-charge (4 885, 14), and Hysteresis back in stepped from where its + play operator had frozen (314). And the Bias re-charge no longer pulls a + block off the dry tap, which put the dry leg a block ahead of the wet + and left the wet a block behind in the ring (13 071 at patch 7).""" + + CLS = rebuilt.Saturation + + def quiet(self, index, a, b, patch=None, cls=None): + before, after = stale.blip(cls or self.CLS, index, a, b, + patch=patch) + return after if before == 0 else None + + def test_bias_moves_play_nothing(self): + for patch in [None] + sorted(self.CLS.PATCHES): + for b in (0, 127): + _before, after = stale.blip(self.CLS, 4, 64, b, patch=patch) + self.assertLessEqual(after, max(1, stale.twin( + self.CLS, 4, 64, patch=patch)), (patch, b)) + + def test_hysteresis_and_headroom_moves_play_nothing(self): + for patch in [None, 0, 4, 6]: + bound = 1 if patch in (4, 6) else 0 + for index, a, b in ((7, 127, 0), (7, 0, 127), (3, 64, 0), + (0, 64, 0), (1, 64, 0)): + _before, after = stale.blip(self.CLS, index, a, b, + patch=patch) + self.assertLessEqual(after, max(bound, stale.twin( + self.CLS, index, a, patch=patch)), (patch, index, a, b)) + + def test_the_dry_leg_stays_in_line_through_bias_moves(self): + self.assertLessEqual(stale.in_step(self.CLS, 4, 64, 0, patch=7), 3) + + def test_the_old_class_and_a_wrong_cure_are_red(self): + self.assertGreater(stale.blip(DrainlessSaturation, 4, 64, 0)[1], + 10000) + self.assertGreater(stale.blip(UnchargedHysteresis, 7, 127, 0, + patch=4)[1], 1000) + self.assertGreater(stale.in_step(LevellingSaturation, 4, 64, 0, + patch=7), 1000) + + if __name__ == "__main__": unittest.main() From 11fadb12c0ca648d40f0cb70166f207339dba51a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:30:32 -0500 Subject: [PATCH 42/88] Reverb Station B fix round: Tone's detent hands 2^-24 dB so the tilt keeps tracking (a Tone move out of silence stays silent); the dry a Character or Size rebuild skips is disclosed and pinned The Tank runs its tilt one-pole only while tone_db is non-zero (audiodsp_tank.c:596-602), so at the exact-0 detent its state froze and came out of exact silence when Tone moved again (7798 LSB on Steel Plate in the review). 2^-24 dB rounds both gains to 1 in single precision; the default render is byte-identical. A rebuild drops the frames the old Tank pulled and had not played (none on a source whose buffers divide 256 frames, 512 on a 1024- or 2048-frame source moved 1536 frames in); the node offers no way to carry them, so the docstring says so and two tests pin both sides. --- lib/audioeffects/rebuilt/reverb.py | 46 ++++++++++++-- tests/test_cpython_effects_reverb.py | 89 ++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py index c4379ac..0181fb7 100644 --- a/lib/audioeffects/rebuilt/reverb.py +++ b/lib/audioeffects/rebuilt/reverb.py @@ -52,6 +52,21 @@ class of reverberator; the room, chamber and hall are that network starts from empty lines. Set them before you play, not while a tail rings. A patch that changes both rebuilds once. +A rebuild can also skip some of your dry. The Tank pulls its source a +buffer at a time and plays 256 frames per block, so between blocks it may +hold the rest of a source buffer it has not played yet; the old Tank takes +those frames with it, and the new one starts at the source's next buffer. +A source whose buffers divide 256 frames (a 256-frame host block does) +loses nothing. One that hands 1024 or 2048 frames can lose up to a buffer +less a block (512 frames when the move lands 1536 frames in), and the +output then runs that far ahead of the source, at Mix 0 too: the byte-exact +wire holds across a move only on such a source. A `RawSample` played +straight in, which hands its whole buffer at once, loses the rest of it. +`reset()` drops the same pending frames, as every class's reset does +(`audiocore.reset_buffer`), and at the constructor's Size its patch 0 +rebuilds the Tank as well, since the grid's Size 64 cuts different lines +from Size 1.0. + **Decay is T60 at 500 Hz, between two knees.** The label holds to within 12 % over most of the span (dossier T11). Below each character's *floor knee* its own diffusers and taps ring longer than the label, so the @@ -85,7 +100,9 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the lane, halved (`audiodsp_tank.c:571-580`). **Tone** spans +/-12 dB of end-to-end tilt, which the node puts half at -each end: +12 is about -6 dB at 40 Hz and +6 dB at 16 kHz. +each end: +12 is about -6 dB at 40 Hz and +6 dB at 16 kHz. At the centre +the class hands 2^-24 dB rather than 0, so the tilt's filter keeps +following the tail and a later Tone move out of silence stays silent. **Input ceiling.** The tank adds dry and wet before it rounds and clamps at the int16 rail. On 2 s of uniform noise at Mix 1, nothing reaches the @@ -197,6 +214,15 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the #: The frequency Decay is stated at. DECAY_HZ = 500.0 +#: What the class hands for Tone at its centre detent instead of 0 dB. The +#: Tank runs its tilt one-pole only while `tone_db` is non-zero +#: (`audiodsp_tank.c:596-602`), so at exact 0 the filter's state freezes +#: on whatever it last held and comes out as sound the moment Tone leaves +#: the detent, even out of exact silence. 2^-24 dB keeps the one-pole +#: tracking; both of its gains round to exactly 1 in single precision, so +#: it is the flat tilt, and every float format holds it exactly. +TONE_TRACK_DB = 1.0 / 16777216.0 + (CHARACTER_I, DECAY_I, SIZE_I, PREDELAY_I, DIFFUSION_I, DAMPING_I, BANDWIDTH_I, LOW_CUT_I, MOD_DEPTH_I, MOD_RATE_I, WIDTH_I, TONE_I, MIX_I) = range(13) @@ -340,8 +366,9 @@ class Reverb(_component.Component): **What the default surrenders:** Decay holds its label only between a floor knee and a ceiling knee (dossier section 8.9); a sparse, quiet - input decays sooner than the knob; Character and Size rebuild the tank - and cut the tail; the spring character is parked. + input decays sooner than the knob; Character and Size rebuild the tank, + cut the tail and can skip the dry the old tank held pending; the + spring character is parked. """ NAME = 'Reverb' @@ -497,6 +524,12 @@ def _decay(self, index, lines, decay_s, loop_hz): def _low_cut_hz(self, value): return self._hz(value) + def _tone_db(self, value): + """The tilt handed to the Tank: never exactly 0 (`TONE_TRACK_DB`).""" + if value == 0.0: + return TONE_TRACK_DB + return value + def _mod_rate_hz(self, value): return value @@ -557,7 +590,8 @@ def _refresh(self): _between(self._value(MOD_RATE_I), 0.1, 5.0)), "drive": 0.0, "width": _between(self._value(WIDTH_I), 0.0, 1.0), - "tone_db": _between(self._value(TONE_I), -12.0, 12.0), + "tone_db": self._tone_db( + _between(self._value(TONE_I), -12.0, 12.0)), "mix": _between(self._value(MIX_I), 0.0, 2.0), } if (self._tank is None or index != self._index @@ -573,7 +607,9 @@ def _refresh(self): def _rebuild(self, index, lines, taps, handed): """Replace the Tank (dossier section 8.8): the output plays the dry alone, the old Tank is released, the new one is built with every - setting applied, plays the source, and the output points at it.""" + setting applied, plays the source, and the output points at it. + Source frames the old Tank pulled and had not played go with it + (the module docstring says when).""" old = self._tank if old is not None: self._output = self._source diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index 9086c03..3de9d36 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -193,6 +193,16 @@ def _mod_rate_hz(self, value): return 1000.0 / value +class ToneDetentZero(Reverb): + """Tier 1 silence: Tone's centre detent handed as exact 0 dB, which + freezes the Tank's tilt one-pole (`audiodsp_tank.c:596-602`).""" + + NAME = 'Reverb' + + def _tone_db(self, value): + return value + + def depth_ceiling_ms(lines, sample_rate): """The node's own Mod Depth ceiling, half the shorter modulated line less a frame (`audiodsp_tank.c:328-336`).""" @@ -771,6 +781,49 @@ def _rebuild(self, *arguments): effect.program_change(9) # Character and Size move self.assertEqual(Counting.count, 1) + def _wire_across_a_move(self, block, move): + """Mix 0 over a ramp served `block` frames at a time, one move + 1536 frames in: (frames that differ from the source, how far the + output runs ahead of it after the move).""" + frames = 12288 + ramp = np.array([((i * 7) % 20001) - 10000 for i in range(frames)], + dtype=np.int16) + src = probes.ArraySource(interleave(ramp, 2, frames), rate=RATE, + channels=2, block=block) + effect = Reverb(src, sample_rate=RATE, mix=0.0) + head = render(effect, 1536) + effect.set_macro(*move) + out = np.concatenate([head, render(effect, frames - 2048)])[:, 0] + differ = int(np.sum(out != ramp[:len(out)])) + ahead = None + if differ: + for k in range(1, 4096): + if np.array_equal(out[1536:1600], ramp[1536 + k:1600 + k]): + ahead = k + break + return differ, ahead + + def test_a_rebuild_keeps_the_wire_on_a_256_frame_source(self): + # the module docstring: a source whose buffers divide the Tank's + # 256-frame block loses nothing across a rebuild + for block in (256, 128): + for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70)): + self.assertEqual(self._wire_across_a_move(block, move), + (0, None), (block, move)) + + def test_a_rebuild_skips_what_the_old_tank_held(self): + # the disclosed loss: on a 1024- or 2048-frame source the old + # Tank's unplayed 512 frames go with it; a move that does not + # rebuild keeps the wire + for block in (1024, 2048): + for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70)): + differ, ahead = self._wire_across_a_move(block, move) + self.assertEqual(ahead, 512, (block, move)) + self.assertGreater(differ, 0, (block, move)) + self.assertEqual(self._wire_across_a_move(block, + (rv.DECAY_I, 90)), + (0, None), block) + def test_a_rebuild_cuts_the_tail(self): frames = RATE effect = make(Reverb, "Steel Plate", mono=noise_burst(RATE, @@ -808,6 +861,42 @@ def test_silence_stays_silence(self): self.assertEqual(int(np.max(np.abs(render(effect, 4096)))), 0, index) + def _tone_route(self, cls, channels): + """Short Plate (Tone 74) over full-scale noise, Steel Plate (Tone + 64, the same lines, so no rebuild) for its last block, 6 s of + silence, then Short Plate again: (peak over the last second before + the move, peak over the 0.1 s after it), Mix 2.""" + noise = RATE // 2 + rng = np.random.RandomState(5) + burst = np.round(rng.uniform(-1, 1, noise) * 32767).astype(np.int16) + effect = make(cls, channels=channels, mono=burst, + frames=noise + 7 * RATE) + effect.program_change(1) + effect.set_macro(rv.MIX_I, 127) + render(effect, noise - 256) + first = effect._tank + effect.program_change(0) + effect.set_macro(rv.MIX_I, 127) + self.assertIs(effect._tank, first) + quiet = render(effect, 256 + 6 * RATE) + effect.program_change(1) + effect.set_macro(rv.MIX_I, 127) + self.assertIs(effect._tank, first) + after = render(effect, RATE // 10) + return (int(np.max(np.abs(quiet[-RATE:]))), + int(np.max(np.abs(after)))) + + def test_a_tone_move_out_of_silence_stays_silent(self): + for channels in (2, 1): + self.assertEqual(self._tone_route(Reverb, channels), (0, 0), + channels) + + def test_tone_detent_as_exact_zero_is_red(self): + for channels in (2, 1): + before, after = self._tone_route(ToneDetentZero, channels) + self.assertEqual(before, 0, channels) + self.assertGreater(after, 100, channels) + def test_click_delay_is_zero(self): for name in ("Steel Plate", "Concert Hall"): frames = 2048 From e745e35091eda9538735836c5392ee7085d9e219 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:39:57 -0500 Subject: [PATCH 43/88] TapeDelay at audiodsp v0.6.3rc1: the stepping out, the wow table kept at depth 0 The pin moved to v0.6.3rc1 with the housekeeping merge. Its node lands a loss low-pass that has stopped moving (#157), so the Feedback is handed as set: nothing is stepped clear of the stall windows any more (only knob position 127 ever moved, 0.99 played as about 0.98998), and the bound counts one landing lap there (686 laps at the stop, 683 stepped). The node now ramps a new wow depth over 20 ms (#160) but swaps a wow table at once. The class used to hand no table when Wow and Flutter reached 0, so the old depth ramped out on the node's own sine, a jump in the read offset. A playing node now keeps the last table while its depth goes to 0 (a fresh node still gets none; at depth 0 the table moves nothing). A move that only changes the wobble's depth glides: Wow 32 -> 127 at Flutter 0 reads 731 LSB against the tone's own 728 (1057 at v0.6.2), Wow 127 -> 0 reads 740 (945 at v0.6.2, 1082 without the kept table). A move that changes the balance of Wow and Flutter still steps (Flutter 0 -> 127 at Wow grid 32, 803); the docstring says so and a test pins it. No test went red at the new pin before this change. Five tests are new or restated, each red on the class before it: the Feedback handed as set and the stall cell reaching zero at 0.5 as set (planted: the retired stepping), the wow table kept at 0 and a depth move that does not step (planted: no table at 0, and a read head moved by the whole change at once), and the balance-move step pinned. No digest moved: Station C's 62 cells, the cost tool's seven and the census's nine are unchanged on CPython, MicroPython and CircuitPython. The Tier 1 stall cell and the Feedback-0.9 corner's bound grow by one lap. --- CHANGELOG.md | 11 +- lib/audioeffects/rebuilt/tapedelay.py | 59 +++++--- tests/test_cpython_effects_tapedelay.py | 191 +++++++++++++++++++++++- 3 files changed, 228 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e349bcd..500fa6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,9 +61,14 @@ there, and are recorded in its changelog. `audioeffects.TapeDelay` is still the old class. The docstring states the input ceiling (-1.1 dBFS peak at the defaults, -2.0 over the shipped patches, on `noise_det`), the band the loss law holds in, the wobble's - 100 s period, the Wow and Flutter step, and where the pitch claim stops: - the node's single-precision walk limits rising moves past 16 384 and - 32 768 frames. Board cost is unmeasured. + 100 s period, where the pitch claim stops (the node's single-precision + walk limits rising moves past 16 384 and 32 768 frames), and which Wow + and Flutter moves still step. Board cost is unmeasured. At audiodsp + v0.6.3rc1 its Feedback is handed as set (no stepping clear of the stall + windows; the bound counts one landing lap there), a Wow or Flutter move + that changes only the wobble's depth glides over 20 ms, and a knob turned + down to 0 keeps the last wow table while the depth ramps out; a move + that changes the balance of Wow and Flutter still steps, disclosed. ### Changed diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index 9e44ced..7ddafe0 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -104,11 +104,26 @@ there), nor is any constructor Glide faster than those edges. Falling moves are claimed at every Glide, and at every ratio up to 3.33 : 1. -**Turning Wow or Flutter while it plays steps.** The node takes a new -table and depth at once (`audiodsp_feedback_delay.c:457-458` adds depth x -table to the read head, with no ramp), so the repeats jump by the change -in excursion wherever the wobble is. Time walks; Wow and Flutter do not, -so set them before you play. +**Turning Wow or Flutter while it plays.** Since audiodsp v0.6.3rc1 the +node ramps a new wobble depth in over 20 ms (audiodsp#160), so a move that +changes only how deep the wobble is glides: Wow with Flutter at 0, either +knob down to 0 on its own, or both up from 0. The class keeps the last +table handed while the depth ramps out to 0, so the old wobble leaves on +its own shape. On a 997 Hz tone at 12 000 LSB, wet only at 48 kHz, whose +own largest step through the loss low-pass is 728 LSB, Wow 32 -> 127 at +Flutter 0 steps at most 731 LSB in the 2 000 frames after it (1 057 at +v0.6.2) and Wow 127 -> 0 at most 740 (945 at v0.6.2, and 1 082 on the +fixed node without the kept table). While the depth travels the +extra pitch is the change over 20 ms times where the wobble is: up to +15 % (about 240 cents) for those 20 ms on the full 3 ms move at its crest. + +A move that changes the balance of Wow and Flutter still steps. It changes +the table's shape, and the node swaps a table at once, so the repeats jump +by the depth times the change in shape: Flutter 0 -> 127 at Wow grid 32 +steps 803 LSB against the tone's 728 (786 at v0.6.2). Turning both to 0 +one after the other passes through a table of one of them alone: Wow to 0 +first, with Flutter at grid 32, steps 1 117. Set the balance before you +play. **Input ceiling.** The dry path sits at unity and the repeats add to it, and there is no input gain to turn down. Measured on the kit's `noise_det` @@ -123,10 +138,12 @@ reach exact zero after your input stops: `laps x (reach + wow + 1 + memory)` frames, 14 laps at the default Feedback (240 282 frames, 5.01 s, at 48 kHz) and 85 at patch 4's 0.8965. The loop low-pass is always in, and -it can hold a small value for ever at a Feedback a hair either side of -1 - 0.5 / k, so the class hands the node a Feedback just outside each of -those windows (at most 3 x 10^-5 away, far inside one step of the knob, -which still reads what you set). +at a Feedback a hair either side of 1 - 0.5 / k it can come to rest a hair +above k LSB and hand it back. Up to audiodsp v0.6.2 it did so for ever, +and the class handed the node a Feedback just outside each such window. +Since v0.6.3rc1 the node sets a stalled low-pass onto its input +(audiodsp#157), the Feedback you set is the one the node plays, and the +bound counts one more lap there: 686 laps at the 0.99 stop. `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). @@ -157,11 +174,9 @@ # the same node rounds the same way in both classes. Its module moves up # one level when it comes home, so both homes are tried. try: - from .digitaldelay import (DIVISION_BEATS, clear_of_stalls, laps_to_zero, - whole_frames) + from .digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames except ImportError: # pragma: no cover - after it lands - from ..digitaldelay import (DIVISION_BEATS, clear_of_stalls, - laps_to_zero, whole_frames) + from ..digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames try: import audioecho @@ -752,12 +767,10 @@ def _refresh(self): SPACING_MAX_UM) self._corner = self._corner_hz(clamped, spacing) self._damping = nominal_damping_hz(self._hz(self._corner), fs) - feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) - # The loop low-pass is always in, and the node can hold a small value - # for ever at a Feedback a hair either side of 1 - 0.5 / k; the node - # is handed the nearer edge of that window instead. - self._feedback = clear_of_stalls( - feedback, tone_excess(self._damping, fs)[1]) + # Handed as set: since audiodsp v0.6.3rc1 the node lands a loop + # low-pass that has stopped moving (#157), so no Feedback holds a + # small value for ever and nothing is stepped clear here. + self._feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) wow = _between(self._value(WOW_I), 0.0, WOW_MAX_CENTS) flutter = _between(self._value(FLUTTER_I), 0.0, FLUTTER_MAX_CENTS) @@ -769,8 +782,12 @@ def _refresh(self): if depth > 0.0: self._table_index = spare self._table = self._tables[spare] - else: + elif self._fresh: self._table = None + # Otherwise a playing node keeps the table it has: the node ramps + # the old depth out over 20 ms (audiodsp#160), and without a + # table it would ramp it out on its own sine instead, a jump in + # the read offset. At depth 0 the table moves nothing. self._wow_ms = depth self._wow_key = key @@ -809,7 +826,5 @@ def _tail_bound(self): `fget`.""" memory, excess = tone_excess(self._damping, self._sample_rate) laps = laps_to_zero(self._feedback, excess) - if laps is None: # pragma: no cover - stepped clear - return None wow = int(math.ceil(self._wow_ms * self._sample_rate / 1000.0)) return int(laps * (self._reach + wow + 1 + memory)) diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 290f45b..b14c6ee 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -30,6 +30,8 @@ from audioeffects import _component # noqa: E402 from audioeffects import rebuilt # noqa: E402 from audioeffects.rebuilt import tapedelay as tape # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls) VENDOR = "PyDevices" @@ -347,6 +349,51 @@ def _step(self): delay_ms=span * 1000.0 / self._sample_rate) +class SteppedTape(TapeDelay): + """The workaround retired at audiodsp v0.6.3rc1: the Feedback handed to + the node at the nearer edge of the loss low-pass's stall window + (`clear_of_stalls`), a Feedback nobody set.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + excess = tape.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._delay.set(feedback=stepped) + + +class NoneAtZeroTape(TapeDelay): + """The class before audiodsp v0.6.3rc1: a Wow and Flutter of 0 hands the + node no table, so the node ramps the old depth out (#160) on its own + sine instead of the table's shape, a jump in the read offset.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + if self._wow_ms == 0.0 and self._table is not None: + self._table = None + self._delay.set(wow_shape=None) + + +class JumpWowTape(TapeDelay): + """A Wow or Flutter move that moves the read head by the whole change in + depth at once, as the node did at the wobble's crest up to v0.6.2 (it + added depth x table with no ramp).""" + + NAME = 'TapeDelay' + + def _refresh(self): + old = self._wow_ms + TapeDelay._refresh(self) + if not self._seeding and not self._deferred and self._wow_ms != old: + self._delay.set(delay_slew=0.0, + delay_ms=self._node_ms + self._wow_ms - old) + + # -- sources and renders -------------------------------------------------- def src_of(x, channels=2, rate=RATE): @@ -644,10 +691,25 @@ def test_the_wow_table(self): delta=0.0005) self.assertAlmostEqual(tape.cents_to_depth_ms(4.0, 5.12), 0.072, delta=0.0005) + # Down to 0 on a playing node the last table stays handed, so the + # depth the node ramps out over 20 ms (audiodsp#160) leaves on its + # own shape; at depth 0 the table moves nothing. effect.set_macro(WOW_I, 0) effect.set_macro(FLUTTER_I, 0) - self.assertIsNone(effect._table) self.assertEqual(effect._wow_ms, 0.0) + self.assertIsNotNone(effect._table) + # Planted: the class before v0.6.3rc1 dropped it. + dropped = NoneAtZeroTape(src_of(np.zeros(512))) + dropped.set_macro(WOW_I, 0) + dropped.set_macro(FLUTTER_I, 0) + self.assertIsNone(dropped._table) + # A fresh node snaps onto its depth, so a constructor or patch at 0 + # hands no table at all. + for effect in (TapeDelay(src_of(np.zeros(512)), wow_cents=0.0, + flutter_cents=0.0), + TapeDelay(src_of(np.zeros(512)), patch=6)): + self.assertIsNone(effect._table) + self.assertEqual(effect._wow_ms, 0.0) def test_the_table_holds_its_three_components(self): out = array("h", [0] * 4096) @@ -1923,13 +1985,126 @@ def test_a_move_inside_a_walk_takes_its_rate_from_the_last_time(self): # The falling move keeps the old Time in the tail's reach. self.assertEqual(effect._reach, frames_of(300.0)) - def test_the_stall_windows_are_stepped_clear(self): - effect = TapeDelay(src_of(np.zeros(512))) - for midi in range(128): - effect.set_macro(FEEDBACK_I, midi) - self.assertIsNotNone(effect.tail_samples, midi) - self.assertLess(abs(effect._feedback - effect.macro(FEEDBACK_I)), - 3e-5) + def test_the_feedback_is_handed_as_set(self): + # Up to audiodsp v0.6.2 the always-in loss low-pass could hold a + # small value for ever a hair either side of 1 - 0.5 / k, and the + # class stepped the Feedback clear (the 0.99 stop played as about + # 0.98998). Since v0.6.3rc1 the node lands a stalled low-pass + # (#157): every position is handed as set and the bound is finite, + # 686 laps at the stop (683 stepped). + for rate in (48000, 44100, 22050): + effect = TapeDelay(src_of(np.zeros(512), rate=rate)) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + self.assertEqual(effect._feedback, + min(0.99, effect._value(FEEDBACK_I)), + (rate, midi)) + self.assertIsNotNone(effect.tail_samples, (rate, midi)) + excess = tape.tone_excess(effect._damping, rate)[1] + self.assertEqual(effect._feedback, 0.99) + self.assertEqual(tape.laps_to_zero(effect._feedback, excess), + 686) + # Planted: the retired stepping hands a Feedback nobody set. + stepped = SteppedTape(src_of(np.zeros(512), rate=rate)) + stepped.set_macro(FEEDBACK_I, 127) + self.assertNotEqual(stepped._feedback, 0.99, rate) + self.assertLess(abs(stepped._feedback - 0.99), 3e-5) + self.assertEqual(tape.laps_to_zero(stepped._feedback, excess), + 683) + + def _stall(self, cls, rate=RATE, channels=2): + """The stall cell: Feedback 0.5 (k = 1), Mix 2, Time 100 ms, no + wobble or squash, a 2 LSB DC for 1 s, then 3 s of silence. Returns + (the Feedback handed, tail_samples, frames from the input's end to + the last non-zero sample, whether the last frame is non-zero).""" + frames = 4 * rate + x = np.zeros(frames) + x[:rate] = 2.0 + effect = cls(src_of(x, channels, rate), feedback=0.5, mix=2.0, + record_level=0.0, time_ms=100.0, wow_cents=0.0, + flutter_cents=0.0) + declared = effect.tail_samples + out = render(effect, frames) + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else -1 + return effect._feedback, declared, last - rate + 1, last == frames - 1 + + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Up to audiodsp v0.6.2 the node held this cell for ever with the + # Feedback handed raw, and the class stepped it clear. Since + # v0.6.3rc1 (#157) 0.5 is handed as set and the tail ends inside + # the bound (the Station C Tier 1 cell). + for rate in (48000, 44100, 22050): + for channels in (2, 1): + feedback, declared, tail, held = self._stall( + TapeDelay, rate, channels) + self.assertEqual(feedback, 0.5, (rate, channels)) + self.assertFalse(held, (rate, channels)) + self.assertGreater(tail, 0, (rate, channels)) + self.assertLessEqual(tail, declared, (rate, channels)) + # Planted: the retired stepping hands a Feedback nobody set. + stepped = SteppedTape(src_of(np.zeros(512)), feedback=0.5) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + def _depth_move(self, cls, start, target, points=8): + """(the tone's own largest step before the move, the largest step in + the 2 000 frames after it) over `points` moves a quarter of the + 0.72 Hz wow line apart, on 997 Hz at 12 000 LSB, mono, wet only, + Time 350 ms, 48 kHz (`pin063cls_tape_wowmove.py`).""" + first = (16800 + 9600) // BLOCK * BLOCK + steadies, worsts = [], [] + for k in range(points): + at = first + k * 65 * BLOCK + events = {at: lambda e: [e.set_macro(i, v) + for i, v in target.items()]} + effect = cls(src_of(sine(TONE, 12000.0, at + 2400), 1), + mix=2.0, feedback=0.0, record_level=0.0, + spread=0.0) + for index, value in start.items(): + effect.set_macro(index, value) + y = render(effect, at + 2000 + BLOCK, events)[:, 0] + steadies.append(float(np.abs(np.diff(y[at - 3000:at - 1])).max())) + worsts.append(float(np.abs(np.diff(y[at - 1:at + 2000])).max())) + return max(steadies), max(worsts) + + def test_a_depth_move_does_not_step(self): + # Since audiodsp v0.6.3rc1 the node ramps a new depth in over 20 ms + # (#160). While it travels the read offset may move |change| / 20 ms + # of a frame per frame on top of the wobble, so the tone may slope up + # to its own largest step times 1 + |change| / 20 ms, and no more. + # Wow at Flutter 0 keeps the table's shape; a knob down to 0 keeps + # the last table while the depth ramps out. + # The planted faults are read where they show: through the loss + # low-pass a read-head jump is plain only at some phases of the + # tone (on Wow 32 -> 127 the eight moves read 791 against 809). + wow_up = ({WOW_I: 32, FLUTTER_I: 0}, {WOW_I: 127}, 2.257, ()) + wow_out = ({WOW_I: 127, FLUTTER_I: 0}, {WOW_I: 0}, 3.017, + (NoneAtZeroTape,)) + flutter_out = ({WOW_I: 0, FLUTTER_I: 127}, {FLUTTER_I: 0}, 0.072, + (NoneAtZeroTape, JumpWowTape)) + for start, target, change, faults in (wow_up, wow_out, flutter_out): + steady, worst = self._depth_move(TapeDelay, start, target) + self.assertLessEqual(worst, steady * (1.0 + change / 20.0), + (start, target)) + # Planted: no table at 0, so the depth ramps out on the node's + # sine; and the read head moved by the whole change at once. + for cls in faults: + steady, worst = self._depth_move(cls, start, target) + self.assertGreater(worst, steady * (1.0 + change / 20.0), + (cls.__name__, start, target)) + + def test_a_balance_move_steps_as_the_docstring_says(self): + # A move that changes the balance of Wow and Flutter changes the + # table's shape, which the node swaps at once: the docstring's 803 + # against the tone's 728, and 1 117 through a flutter-only table. + self.assertEqual(self._depth_move(TapeDelay, + {WOW_I: 32, FLUTTER_I: 0}, + {FLUTTER_I: 127}), (728.0, 803.0)) + self.assertEqual(self._depth_move(TapeDelay, + {WOW_I: 32, FLUTTER_I: 32}, + {WOW_I: 0, FLUTTER_I: 0}), + (728.0, 1117.0)) class InputCeiling(unittest.TestCase): From b0ec27d05c79ce9b7b08733e5992c3bae389b007 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:06:30 -0500 Subject: [PATCH 44/88] ConvolutionReverb fix round 1: the audit's list, class side Tests that can now fail where the frozen readings could not, each shown red on a control that the old reading passed: - D2 reads the node's capacity through load() (the native binding refuses an impulse past it), not node.taps; ExtraPartition goes red in measured mode at 1000 / 3840 / 3841 / 48000 frames (capacity 1280 / 4096 / 4352 / 48384 against 1024 / 3840 / 4096 / 48128), where node.taps reads the law on the planted class too. - D5's law takes the seconds the test handed the constructor; the AllocatedSeconds control (seconds stretched to the partition edge) is red at 0.06 s on the handed law and green on a law read from effect.seconds. - D1 adds an interior trim where truncation and rounding differ (41.7 ms at 48 kHz, 3.3 ms at 44.1 and 22.05 kHz); TrimRounded is red there. - D6 adds the absolute clause |wet/dry| <= 0.5 dB; HotRoom (+3 dB at every setting) passes spread and rho and is red on it. UnnormalisedAfter's gain now follows every re-synthesis, so a set_macro walk reads it red. - The Mix move's one-partition lag and reset()'s mid-stream drop are pinned, and damping_hz under 500 Hz reads the span bottom. Class: a 2-D impulse raises TypeError naming the class instead of the memoryview's bare NotImplementedError. The docstring states the per-side balance swing (the node normalises the mean of the sides), the allocation law's double-precision half-to-even tie, the Mix lag, the reset() drop, the silent damping_hz clamp and the patch_index-0 readback. CHANGELOG line added. --- CHANGELOG.md | 20 ++ lib/audioeffects/rebuilt/convolutionreverb.py | 55 +++- .../test_cpython_effects_convolutionreverb.py | 311 ++++++++++++++++-- 3 files changed, 347 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40cbccd..5e6f5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,26 @@ there, and are recorded in its changelog. 100 s period, the Wow and Flutter step, and where the pitch claim stops: the node's single-precision walk limits rising moves past 16 384 and 32 768 frames. Board cost is unmeasured. +- **`ConvolutionReverb` (rebuilt, Phase 5, parked under `rebuilt/`):** a + short room on one `audioconvolve.Convolver`, synthesized from Decay, + Damping, Predelay, Diffusion and one of 64 Room seeds, or loaded from + your own impulse (int16 frames or a 16-bit WAV at the graph's rate) with + only Mix live. The allocation is `seconds`, 0.08 s by default, from a + 0.06 s floor to the node's 512-partition ceiling, and anything outside + raises naming the class, the taps, the rate and the limit in seconds. + `latency_samples` reads the node: 256 frames whenever an impulse is + loaded, 0 when none is, and Mix 0 is the source delayed by exactly that, + byte for byte. Measured mode loads at unit mean energy and refuses the + synthesis macros with `IndexError`; no impulse ships. It lives in + `lib/audioeffects/rebuilt/convolutionreverb.py`, and + `audioeffects.ConvolutionReverb` is still the old class. The room holds + its level over both channels together, but Damping and Decay move a + stereo room's left-right balance by up to about 1.3 dB (2.6 dB at + 22.05 kHz); fixing that is a node change, drafted as an audiodsp ask. The + docstring states that, the low-material lift a dark room gives, and the + partition a room-knob move or a mid-stream `reset()` drops. Board cost + is unmeasured; by the cost table's line the default fits an S3, and + anything over 0.091 s there is desktop-only. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 0457a86..e2713b3 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -6,7 +6,10 @@ 85cc2bfc9a5a3c34c6906fbf0c27b285ba1050d2, the Station A critique's re-freeze, 2026-09-28). The old class in `reverb.py` is consulted only for the six defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. +library serves until the board runner adopts this one. After the gate +audit's round 1 the dossier carries a dated post-build revision (fix round +1, 2026-09-28): D6 claims the level of both channels pooled, and this +docstring says what that leaves out. **What it sounds like.** A short room behind your dry signal. With nothing loaded the class synthesizes the room: noise under an exponential that @@ -24,6 +27,11 @@ ceiling of **512 partitions = 131 072 taps**, which is 2.730 s at 48 kHz, 2.972 s at 44.1 kHz and 5.944 s at 22.05 kHz. Outside those, construction raises `ValueError` naming this class, the taps, the rate and the limit. +The product is taken in double precision and `round` sends a half frame +to even, so a `seconds` a hair over half a frame past a partition edge +builds one partition fewer than exact arithmetic would: 0.08001041666666667 +s at 48 kHz is 3 840.5 + 7/2^48 frames exactly, and builds 3 840 taps, not +4 096. No floor or ceiling cell moves. Decay, Predelay and Diffusion are laws over what the allocation leaves (section 6): Decay is the T60, log from the node's 50 ms floor to `seconds - predelay`, so at its top the room reaches -60 dB exactly at the @@ -54,7 +62,8 @@ impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is the source delayed by exactly `latency_samples`, byte for byte, because the node stays in the path at Mix 0 and a Mix move never jumps the timeline. -The one exception is the partition a room knob moves in (below). +The exceptions are the partition a room knob moves in and the partition +`reset()` is called in (below). **Tail.** `tail_samples` is `latency_samples` plus the loaded impulse rounded up to a partition: 4 096 frames (85.3 ms) at the default at @@ -77,14 +86,22 @@ output blocks; the node ask is drafted), so do not sweep a room knob under a signal you need unbroken. A move that lands on the room already loaded does nothing and drops nothing, and a patch change, the constructor and -`reset()` synthesize once, not once per knob. Mix moves never touch the -room. +`reset()` synthesize once, not once per knob. `reset()` in the middle of a +stream drops the 256 frames in flight the same way, dry included. Mix +moves never touch the room, but a Mix move acts on the audio entering the +node after it, so you hear it one partition later: the 256 frames already +in flight come out at the old Mix. **What the default surrenders.** The room is normalised to unit energy across the whole band, so with Damping in, low material comes back louder than it went in: a 220 / 277 / 330 Hz chord +3.73 dB at the default -6 kHz Damping and +13.28 dB at the 500 Hz stop; white-spectrum material -comes back at its own level at every setting. Damping clamps at 0.159 fs, +6 kHz Damping and +13.28 dB at the 500 Hz stop. White-spectrum material +comes back at its own level, within 0.5 dB, at every setting, counting +both channels together. Each side on its own does not: the node scales +the room by the mean of its two sides' energies, so on a stereo room +Damping and Decay move the left-right balance by up to about 1.3 dB at +48 kHz (2.6 dB at 22.05 kHz) while the total holds. A mono room is one +side and holds. Damping clamps at 0.159 fs, under the point where the node's one-pole coefficient stops moving, so at 48 kHz every one of its 128 positions is a room of its own, while at 22.05 kHz the positions from 92 up (the 6 kHz default among them) are one @@ -101,7 +118,17 @@ `IndexError` from `set_macro` and `get_macro` in this mode: the loaded impulse is the room. `live_macros` says which macros an instance has. No impulse ships with this class; it loads yours and keeps no copy (Brad's -ruling, 2026-09-08). +ruling, 2026-09-08). An impulse is one-dimensional: a 2-D array (numpy's +`(frames, channels)`) raises `TypeError`, so flatten it first. + +**Two readbacks that are not what they look like.** `damping_hz` under +500 Hz is taken as 500 Hz, the span's bottom, with no error (0, or +7 500 Hz and up, is out of circuit). And a fresh instance reports +`patch_index` 0, the family's convention, although the constructor's exact +defaults (Damping 6 000 Hz, Mix 0.6) sit between grid steps and patch 0 is +those settings on the grid (6 059.8 Hz, Mix 0.598). Pass `patch=0` for +patch 0's room exactly. `reset()` restores patch 0, so an instance built +from the plain defaults moves onto the grid at its first reset. `capabilities = ()`: nothing here reads a beat. """ @@ -260,9 +287,11 @@ class ConvolutionReverb(_component.Component): in. audiodsp tier; 256 frames of latency whenever an impulse is loaded. **What the default surrenders:** a dark room lifts low material (a low - chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), moving a - room knob starts the room empty and drops the 256 frames in flight, - dry included, at every Mix, and anything longer than 0.091 s on + chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), Damping + and Decay move a stereo room's left-right balance by up to about + 1.3 dB (2.6 dB at 22.05 kHz) while the total holds, moving a room knob + or calling `reset()` mid-stream drops the 256 frames in flight, dry + included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). """ @@ -399,6 +428,12 @@ def _load_impulse(self, impulse, impulse_channels, ir_gain_db, raise ValueError("%s: start_ms=%r is outside 0..%.0f ms" % (self.NAME, start_ms, START_MAX_MS)) view = memoryview(impulse) + if getattr(view, "ndim", 1) != 1: + # A (frames, channels) array would otherwise fail at the trim's + # slice with Python's bare NotImplementedError. + raise TypeError("%s: impulse must be one-dimensional int16 " + "frames; flatten a (frames, channels) array " + "first" % self.NAME) width = _byte_width(impulse, view) size = len(view) * width if size % (2 * impulse_channels): diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 02e9d62..4a361ea 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -11,8 +11,17 @@ pack, not in this file. Every measurement here computes its reference itself - D1's direct sum and -its load gain, D5's Decay law - and never reads it back from the class. -The M1 reference uses numpy and runs on CPython only. +its load gain, D5's Decay law from the seconds the test handed the +constructor - and never reads it back from the class. The M1 reference +uses numpy and runs on CPython only. + +Fix round 1 (gate audit round 1, 2026-09-28) added four readings that can +fail where the frozen ones could not, each with a control that goes red on +it and passes the old reading: D1 at an interior trim where truncation and +rounding differ (`TrimRounded`), D2's allocation read as the node's +capacity through `load()` rather than `node.taps` (`ExtraPartition` in +measured mode), D5's law from the handed seconds (`AllocatedSeconds`), and +D6's absolute clause |wet/dry| <= 0.5 dB (`HotRoom`, a constant +3 dB). """ import os @@ -285,6 +294,17 @@ def _trim_frames(self, start_ms): return ConvolutionReverb._trim_frames(self, start_ms) + 1 +class TrimRounded(ConvolutionReverb): + """D1's control for the truncation clause: the trim rounded to the + nearest frame. At 0 and 10 ms the two agree, so only an interior trim + (41.7 ms at 48 kHz, 3.3 ms at 44.1 and 22.05 kHz) can see it.""" + + NAME = NAME + + def _trim_frames(self, start_ms): + return int(round(start_ms * self._sample_rate / 1000.0)) + + class ExtraPartition(ConvolutionReverb): """D2: one partition too many.""" @@ -348,13 +368,62 @@ def _fault(self, node): class UnnormalisedAfter(_After): """D6: `kit_faults.HiddenGain` at 10 log10(T60 / 50 ms) dB, the level an - unnormalised room would have, which tracks the decay.""" + unnormalised room would have, which tracks the decay. The gain follows + every re-synthesis, so a host walking Decay by `set_macro` sees it move + (fix round 1: built once, it held the constructor's gain and a walk read + a 0.010 dB spread).""" + + NAME = NAME + + def _gain_db(self): + return 10.0 * np.log10(self._synthesis()[0] / 0.05) + + def _fault(self, node): + return kit_faults.HiddenGain(node, self._gain_db()) + + def _refresh(self): + ConvolutionReverb._refresh(self) + planted = getattr(self, "_planted", None) + if planted is not None: + planted.gain_db = self._gain_db() + planted._gain = 10.0 ** (planted.gain_db / 20.0) + + +class HotRoom(_After): + """D6's absolute clause: a room a constant +3 dB hot at every setting. + The spread and rho clauses both pass it; only |wet/dry| <= 0.5 dB sees + it.""" NAME = NAME + GAIN_DB = 3.0 def _fault(self, node): - return kit_faults.HiddenGain( - node, 10.0 * np.log10(self._synthesis()[0] / 0.05)) + return kit_faults.HiddenGain(node, self.GAIN_DB) + + +class AllocatedSeconds(ConvolutionReverb): + """D5's control for a law that shares the class's inputs: the class + keeps the allocation it holds (partitions x 256 / fs) as `seconds`, so + every law over the allocation stretches to the partition edge. A law + read from `effect.seconds` moves with it and stays green; a law from + the seconds the test handed the constructor goes red (at 0.06 s: 64-Room + mean +6.62 / +6.39 / +16.18 % at 48 / 44.1 / 22.05 kHz, audit round 1). + It is inert at 0.08 s at 48 kHz, where 3 840 taps is a whole number of + partitions.""" + + NAME = NAME + + def _check_allocation(self, taps, seconds=None): + ConvolutionReverb._check_allocation(self, taps, seconds) + if seconds is not None: + self._stretched = (self._partitions(taps) * 256 + / float(self._sample_rate)) + + def _refresh(self): + stretched = getattr(self, "_stretched", None) + if stretched is not None and not self._measured: + self._seconds = stretched + ConvolutionReverb._refresh(self) class LongDecay(ConvolutionReverb): @@ -527,6 +596,27 @@ def test_a_trim_past_the_impulse_raises(self): self.assertEqual((effect.node.taps, effect.latency_samples), (0, 0)) effect.deinit() + def test_a_two_dimensional_impulse_raises_naming_the_class(self): + # It used to reach the trim's slice and raise Python's bare + # NotImplementedError (surface refuter, item 9). + for start_ms in (0.0, 5.0): + with self.assertRaises(TypeError) as caught: + build(impulse=make_impulse(1000, 2), impulse_channels=2, + start_ms=start_ms) + self.assertIn(NAME, str(caught.exception)) + self.assertIn("one-dimensional", str(caught.exception)) + effect = build(impulse=make_impulse(1000, 2).reshape(-1), + impulse_channels=2) + self.assertEqual(effect.node.taps, 1024) + effect.deinit() + + def test_damping_under_the_span_is_the_span_bottom(self): + # Disclosed in the docstring: no error, the 500 Hz stop. + for hz in (100.0, 499.0, 500.0): + effect = build(damping_hz=hz) + self.assertAlmostEqual(effect._synthesis()[1], 500.0, places=6) + effect.deinit() + def test_an_int16_array_is_trimmed_by_frames(self): from array import array h = make_impulse(1000, 2) @@ -602,6 +692,43 @@ def test_a_room_move_starts_the_room_empty_and_a_mix_move_does_not(self): effect.deinit() clean.deinit() + def test_a_mix_move_lands_a_partition_late_and_reset_drops_one(self): + # The docstring's mid-stream lines (fix round 1, audit item 7): a + # Mix move before block 10 leaves the 256 frames in flight + # (2 560..2 815) at the old Mix and the wire exact after; reset() + # mid-stream zeroes that partition, dry included. + frames = 32 * 256 + pcm = white(frames, peak_dbfs=-1.0) + wire = np.vstack([silence(LATENCY), pcm[:frames - LATENCY]]) + + def pull(effect, action): + effect._source.swap(probes.ArraySource(pcm, rate=RATE, + channels=2)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for block in range(32): + if block == 10: + action(effect) + out += bytes(audiocore.get_buffer(effect.output)[1]) + return np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, 2) + + effect = build() + out = pull(effect, lambda e: e.set_macro(MIX_I, 0)) + effect.deinit() + late = np.any(out != wire, axis=1) + self.assertEqual(int(np.sum(late[2560:])), 256) + self.assertTrue(np.all(late[2560:2816])) + effect = build(mix=0.0) + + def reset_then_wire(e): + e.reset() + e.set_macro(MIX_I, 0) + + out = pull(effect, reset_then_wire) + effect.deinit() + self.assertEqual(int(np.max(np.abs(out[2560:2816]))), 0) + self.assertEqual(digest(out[2816:]), digest(wire[2816:])) + def test_a_room_move_drops_the_partition_in_flight_dry_included(self): # The docstring's other half, measured: the reset at the end of # synthesize() (audiodsp_convolve.c:254) zeroes the pending and @@ -688,6 +815,28 @@ def test_within_one_lsb_at_the_lower_rates(self): 8000.0, 0.0, start_ms) self.assertLessEqual(error, 1.0, (rate, start_ms)) + def test_an_interior_trim_where_truncation_and_rounding_differ(self): + # The row's trims 0 and 10 ms are whole-frame at every rate, where a + # rounded trim equals a truncated one, so they cannot carry the + # truncation clause (audit round 1). 41.7 ms at 48 kHz is 2 001.6 + # frames and 3.3 ms at 44.1 / 22.05 kHz is 145.53 / 72.765: the + # class keeps 2 001 / 145 / 72, a rounded trim 2 002 / 146 / 73. + h = make_impulse(3840) + for rate, start_ms in ((48000, 41.7), (44100, 3.3), (22050, 3.3)): + self.assertNotEqual(trim_frames(start_ms, rate), + int(round(start_ms * rate / 1000.0))) + clean = m1_error(ConvolutionReverb, rate, 1, 2, "noise", 8000.0, + 0.0, start_ms, h_full=h) + planted = m1_error(TrimRounded, rate, 1, 2, "noise", 8000.0, + 0.0, start_ms, h_full=h) + self.assertLessEqual(clean, 1.0, (rate, start_ms)) + self.assertGreater(planted, 1000.0, (rate, start_ms)) + # And the control is invisible at the row's own trims. + for start_ms in (0.0, 10.0): + self.assertLessEqual(m1_error(TrimRounded, RATE, 1, 2, "noise", + 8000.0, 0.0, start_ms, h_full=h), + 1.0) + def test_trim_one_frame_late_is_red_at_the_measured_defaults(self): # The measured-mode defaults: Mix 0.6 (grid 38), ir_gain_db 0, # start_ms 0. M1 through the Mix law, and at Mix 2. @@ -730,8 +879,30 @@ def measure(cls): # D2 - the allocation has a ceiling and a floor, and the class names both # -------------------------------------------------------------------------- -def m2(cls, cells): - """Red entries over (label, seconds, rate, impulse frames, expect).""" +def capacity(node): + """The node's allocation in taps, read as a board would: the longest + impulse its `load()` accepts (the native binding refuses more, + `Convolver.c:183-186` at 1c89b03; the twin at `audioconvolve.py:127`). + `node.taps` cannot stand in for it: in measured mode it reports the + loaded length rounded to a partition, which is the law whatever the + allocation (audit round 1). Destructive - it replaces the room - so it + is the last thing read off an instance.""" + taps = max(256, (int(node.taps) + 255) // 256 * 256) + while taps <= 131072 + 256: + try: + node.load(bytes(2 * (taps + 1)), 1, 1.0) + except ValueError: + return taps + taps += 256 + return taps + + +def m2(cls, cells, reading=None): + """Red entries over (label, seconds, rate, impulse frames, expect). + + A build reads both `node.taps` and the capacity against the law; + `reading="taps"` reads `node.taps` alone, the frozen reading, kept so + the test can show what it missed.""" reds = [] for label, seconds, rate, frames, expect in cells: taps = int(round(seconds * rate)) if frames is None else frames @@ -756,11 +927,15 @@ def m2(cls, cells): reds.append("%s: %r lacks %s" % (label, text, missing)) continue got = effect.node.taps + held = got if reading == "taps" else capacity(effect.node) effect.deinit() if expect != "build": reds.append("%s: built" % label) elif got != law: reds.append("%s: %d taps, the law says %d" % (label, got, law)) + elif held != law: + reds.append("%s: capacity %d taps, the law says %d" + % (label, held, law)) return reds @@ -779,9 +954,16 @@ def d2_cells(): ("default 0.08 %d" % rate, 0.08, rate, None, "build")] cells += [("measured 131072", 0.0, RATE, 131072, "build"), ("measured 131073", 0.0, RATE, 131073, "raise")] + cells += MEASURED_BELOW return cells +#: Measured mode below the ceiling (fix round 1): lengths off, on and one +#: past a partition edge, and a one-second room. +MEASURED_BELOW = [("measured %d" % frames, 0.0, RATE, frames, "build") + for frames in (1000, 3840, 3841, 48000)] + + class D2Allocation(unittest.TestCase): def test_every_cell_builds_or_raises_by_the_law(self): self.assertEqual(m2(ConvolutionReverb, d2_cells()), []) @@ -813,6 +995,19 @@ def test_one_partition_too_many_is_red_at_the_defaults(self): effect.deinit() self.assertTrue(m2(ExtraPartition, d2_cells())) + def test_one_partition_too_many_is_red_in_measured_mode(self): + # node.taps reads the law on the planted class too (1 024 / 3 840 / + # 4 096 / 48 128), so the frozen reading is green there; the + # capacity reads 1 280 / 4 096 / 4 352 / 48 384 and is red. + self.assertEqual(m2(ExtraPartition, MEASURED_BELOW, reading="taps"), + []) + reds = m2(ExtraPartition, MEASURED_BELOW) + self.assertEqual(len(reds), 4, reds) + for (label, _, _, frames, _), red in zip(MEASURED_BELOW, reds): + law = (frames + 255) // 256 * 256 + self.assertIn("capacity %d taps" % (law + 256), red) + self.assertEqual(m2(ConvolutionReverb, MEASURED_BELOW), []) + def test_the_plant_is_not_on_the_surface(self): result = reach(ExtraPartition, lambda e: e.node.taps) self.assertEqual(result["target"], 4096) @@ -976,6 +1171,10 @@ def measure(cls): class D5DecayLaw(unittest.TestCase): def errors_over_rooms(self, cls=None, rate=RATE, **options): + # The law takes the seconds this test hands the constructor, never + # `effect.seconds`: a class that stretched its own allocation would + # move a law read back from it (AllocatedSeconds, below). + seconds = options.get("seconds", 0.08) effect = build(cls, rate, **options) errors = [] for room in range(0, 128, 2): @@ -984,7 +1183,7 @@ def errors_over_rooms(self, cls=None, rate=RATE, **options): self.assertTrue(floor, room) self.assertIsNotNone(t60, room) law = law_t60(effect._macros[DECAY_I], effect._macros[PREDELAY_I], - effect.seconds) + seconds) errors.append(t60 / law - 1.0) effect.deinit() return np.array(errors) @@ -1025,6 +1224,33 @@ def test_a_decay_5_percent_long_is_red_at_the_defaults(self): errors = self.errors_over_rooms(LongDecay) self.assertGreater(float(np.mean(errors)), 0.02) + def test_a_stretched_allocation_is_red_on_the_handed_law(self): + # At 0.06 s the allocation rounds up to a whole partition (2 880 -> + # 3 072 taps at 48 kHz, 0.064 s; 0.0639 s at 44.1 kHz, 0.0697 s at + # 22.05 kHz); a class keeping that as `seconds` puts every Room long + # against the law of the seconds it was handed. + for rate in RATES: + errors = self.errors_over_rooms(AllocatedSeconds, rate, + seconds=0.06, damping_hz=0.0, + diffusion=0.0) + self.assertGreater(float(np.max(np.abs(errors))), 0.03, rate) + self.assertGreater(float(np.mean(errors)), 0.05, rate) + clean = self.errors_over_rooms(rate=rate, seconds=0.06, + damping_hz=0.0, diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + # The frozen reading, the law from `effect.seconds`, stays green on + # the plant: that is the hole the handed law closes. + effect = build(AllocatedSeconds, RATE, seconds=0.06, damping_hz=0.0, + diffusion=0.0) + self.assertAlmostEqual(effect.seconds, 0.064, places=12) + read_back = [] + for room in range(0, 128, 2): + effect.set_macro(ROOM_I, room) + t60, _ = m5_cell(effect) + read_back.append(t60 / law_t60(1.0, 0.0, effect.seconds) - 1.0) + effect.deinit() + self.assertLessEqual(float(np.max(np.abs(read_back))), 0.03) + def test_stuck_dc_turns_the_floor_red(self): effect = build(StuckDcAfter) t60, floor = m5_cell(effect) @@ -1034,7 +1260,7 @@ def test_stuck_dc_turns_the_floor_red(self): def test_the_plants_are_not_on_the_surface(self): def handed(effect): law = law_t60(effect._macros[DECAY_I], - effect._macros[PREDELAY_I], effect.seconds) + effect._macros[PREDELAY_I], 0.08) return effect._loaded[0] / law result = reach(LongDecay, handed, tolerance=0.01) @@ -1064,45 +1290,66 @@ def measure(cls): # -------------------------------------------------------------------------- class D6UnitEnergy(unittest.TestCase): + """Three clauses since fix round 1: the pooled wet/dry spread <= 0.5 dB, + |wet/dry| <= 0.5 dB at every cell (the absolute level, which the null + test already read), and rho < 0.5. Level is both channels pooled, which + is what the node normalises; each side on its own is not claimed.""" + def spread(self, cls, index, rate=RATE, peak_dbfs=-12.0, grid=GRID): + """(spread dB, worst |level| dB, worst rho), walking `index` by + `set_macro` on one instance as a host would.""" pcm = white(int(1.5 * rate), peak_dbfs=peak_dbfs) levels, rhos = [], [] + effect = build(cls, rate) for position in grid: - effect = build(cls, rate) effect.set_macro(index, position) - if cls is UnnormalisedAfter: - # The plant's gain follows the room it was built for. - effect.deinit() - options = {"decay": position / 127.0} - effect = build(cls, rate, **options) level, r = m6_cell(effect, pcm) - effect.deinit() levels.append(level) rhos.append(r) - return max(levels) - min(levels), max(rhos) + effect.deinit() + return (max(levels) - min(levels), max(abs(v) for v in levels), + max(rhos)) + + def assertGreen(self, reading, label): + spread, level, worst = reading + self.assertLessEqual(spread, 0.5, label) + self.assertLessEqual(level, 0.5, label) + self.assertLess(worst, 0.5, label) def test_decay_and_damping_at_two_levels(self): for peak in (-12.0, -30.0): for index in (DECAY_I, DAMPING_I): - spread, worst = self.spread(ConvolutionReverb, index, - peak_dbfs=peak) - self.assertLessEqual(spread, 0.5, (index, peak)) - self.assertLess(worst, 0.5, (index, peak)) + self.assertGreen(self.spread(ConvolutionReverb, index, + peak_dbfs=peak), (index, peak)) def test_predelay_diffusion_room_and_the_lower_rates(self): for index in (PREDELAY_I, DIFFUSION_I, ROOM_I): - spread, worst = self.spread(ConvolutionReverb, index) - self.assertLessEqual(spread, 0.5, index) - self.assertLess(worst, 0.5, index) + self.assertGreen(self.spread(ConvolutionReverb, index), index) for rate in (44100, 22050): - spread, worst = self.spread(ConvolutionReverb, DECAY_I, rate) - self.assertLessEqual(spread, 0.5, rate) - self.assertLess(worst, 0.5, rate) + self.assertGreen(self.spread(ConvolutionReverb, DECAY_I, rate), + rate) def test_an_unnormalised_room_is_red_at_the_defaults(self): - spread, _ = self.spread(UnnormalisedAfter, DECAY_I) + # Walked by set_macro: the plant's gain follows each re-synthesis. + spread, _, _ = self.spread(UnnormalisedAfter, DECAY_I) self.assertGreater(spread, 0.5) + def test_a_constantly_hot_room_is_red_on_the_absolute_clause(self): + # +3 dB at every setting: the spread and rho clauses pass it (the + # frozen criterion's hole, audit round 1: spread 0.010 dB, rho + # 0.118); the absolute clause does not. + spread, level, worst = self.spread(HotRoom, DECAY_I) + self.assertLessEqual(spread, 0.5) + self.assertLess(worst, 0.5) + self.assertGreater(level, 2.5) + + def test_the_hot_room_is_not_on_the_surface(self): + pcm = white(RATE) + result = reach(HotRoom, lambda e: m6_cell(e, pcm)[0], tolerance=1.0) + self.assertGreater(result["target"], 2.5) + self.assertLess(abs(result["clean"]), 0.5) + self.assertEqual(result["checked"], WALKED) + def test_the_plant_is_not_on_the_surface(self): pcm = white(RATE) result = reach(UnnormalisedAfter, lambda e: m6_cell(e, pcm)[0], @@ -1141,7 +1388,13 @@ def test_silence_stays_silence(self): def test_reset_empties_the_room(self): # Built at patch 0, so reset's program_change(0) finds the room it - # holds and does not re-synthesize: only the reset clears it. + # holds and does not re-synthesize: only the reset clears it. From + # the plain defaults NoReset would be inert: their exact Damping + # 6 000 Hz and Mix 0.6 are not patch 0's grid values (6 059.8 Hz, + # 0.598), so program_change(0) re-synthesizes, and the node empties + # itself on a re-synthesis (audit round 1: peak 0 after reset() for + # clean and planted from the defaults, 16 666 LSB planted from + # patch=0). for cls, silent in ((ConvolutionReverb, True), (NoReset, False)): effect = build(cls, patch=0) burst = np.vstack([white(1024), silence(8192)]) From d419ac40fa3dc770bf1afd34cdb708b356faa452 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:46:25 -0500 Subject: [PATCH 45/88] MultiTapDelay fix round 1: the tap node runs one block behind the dry, re-plugs through a port, and the output ends in a width-1 MidSide Gate audit round 1 (PARKED) items 1-6 and 9, class side. 1. A Time, Heads or program_change setting after the plain constructor and before the first pull no longer misplaces the first 256 frames in stereo. The tap node primes a block of zeros (it plays a Port pointed at a sourceless MidSide) for the wet voice's prime, and reads the source one block after the dry, with every head handed k n1 - 256 frames. Tests: the constructor route (Time 96 on c80ca59 read left [3118, 9241, 12359, 18482], right none), program_change before the first pull, the frame-256 control; plant PrimedWire (Station B's wiring) red on the route only. 2. reset() from the lean graph, from Repeat Tone's out stop and from a class never wired at Mix 0 leaves the output on time (was +256 against a reported 0). Plant LateReset red at +256; StaleReset (the pending tap-1 block left in) red on STATE. 3. A host's reset of the output keeps the source's first block (the 3 000 click at 3 000, was 2 744). Plant MixerTail red at 2 744. 4. Repeat Tone crossings re-point the feed port, which pulls nothing: any number between two pulls leaves the wet on the law (was +4 088 for +4 344 at two). Plant PrimingPlug red at two crossings. 5. The lap clause, T4, T5 and first_lap read every lane; PannedLaps (lane-1 plant), a left-only click and CrossedLaps added, with reachability walks. 6. Lap-level reading with LapMixOne, Repeat Tone corner reading with ToneHalf, both red, with reachability walks. 7 (partial). At one channel the dry's unity is handed as 1 - 2^-15, exact on every Mixer here; the stereo right lane on stock CircuitPython stays one LSB hot and waits for a ruling. 9. The CHANGELOG line. Each new test was shown able to fail on its plant, and the route cells on the class at c80ca59 itself (the anchor's multitapdelay_fix1.py, part old). The steady-state output is byte-identical to c80ca59 on the pack's route. --- CHANGELOG.md | 19 + lib/audioeffects/rebuilt/multitapdelay.py | 247 +++++-- tests/test_cpython_effects_multitapdelay.py | 724 ++++++++++++++++++-- 3 files changed, 842 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5627b6e..2f94454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,25 @@ there, and are recorded in its changelog. shipped patches, on `noise_det`), the 15 kHz swing the wow costs the repeat, the Wow step, and Tone's flat top at 22.05 kHz. Board cost is unmeasured. +- **`MultiTapDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** the + RE-201's multi-head modes, with the Echorec as a second reference. Heads + sit on a grid of whole multiples of one base time, exactly, at every + rate; Pattern is the RE-202's twelve head sets over that grid (mode 12 + the plain grid, not Roland's unpublished one); the laps go round an + `audioecho.FeedbackDelay` whose loop low-pass darkens once per lap, and + Repeat Tone's out stop swaps to a lighter graph (patch 1, the one to + stack on an S3). Mix 0 is a wire. It lives in + `lib/audioeffects/rebuilt/multitapdelay.py`, and + `audioeffects.MultiTapDelay` is still the old class. The tap node reads + its input one block behind the dry, so settings made before the first + pull or after `reset()` land on the grid in both lanes, a reset leaves + the output on time, and any number of Repeat Tone crossings between + pulls leave the wet where it was; the output ends in a width-1 MidSide, + so a host resetting the output keeps the source's first block. The + docstring states the input ceiling, the click and channel crossing of a + Time or Heads move while audio plays, and that on CircuitPython alone + the stereo dry's right lane is one LSB hot within 32 LSB of the rails. + Board cost is unmeasured. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index b754044..e1df434 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -33,10 +33,13 @@ at the default 1600 ms the base stops at 400 / 400 / 320 / 266.67 / 228.56 / 200 ms for 3 ... 8 heads. That clamp is on the audio path only and `get_macro(0)` keeps the knob's Time, because it moves with Heads and a -Heads move back must give you your Time back. The tap node is handed -(P + 0.5) frames' worth of `delay_ms` and head k at (k n1 + 0.5) / P (head -K at 1.0), and it truncates both, so a single-precision board lands on the -same frames as a desktop. The lap node interpolates instead, so it is +Heads move back must give you your Time back. The tap node reads its input +one 256-frame block after the dry has played it (so that nothing you set +before the first pull, or right after `reset()`, lands on audio it has +already written; see `_route`), so it is handed (P + 0.5) frames' worth of +`delay_ms` and head k at (k n1 - 256 + 0.5) / P, and it truncates both: +every head sounds at exactly k n1 against the dry, and a single-precision +board lands on the same frames as a desktop. The lap node interpolates instead, so it is handed the least single-precision `delay_ms` whose frames, computed the node's way (`value * rate / 1000.0f`), are at or over P: stepping one float32 unit at a time from float32(P 1000 / fs), every intermediate @@ -68,9 +71,11 @@ darkening (its corner pre-warped so it is the -3 dB point of one pass); at the out stop the class unplugs that node and lets the tap node's own `decay` make the laps, and nothing darkens. Crossing the out stop while -audio plays changes the graph between blocks: the wet does not move -against the dry, but the laps in flight are dropped or doubled once (up to -half the click's level at Feedback 0.5). +audio plays changes the graph between blocks by re-pointing a port, which +pulls nothing: the wet does not move against the dry, however many times +you cross between two pulls (a host flipping patches 1 and 0 included), +but the laps in flight are dropped or doubled once (up to half the click's +level at Feedback 0.5). **Mix 0 hands your source straight through**, the class's output port pointed at the source itself, so it is byte for byte a wire on every @@ -81,13 +86,19 @@ (audiodsp's own Mixer, the one MicroPython and the boards run, passes unity through). The price is that the lines are not fed while Mix is 0, and turning Mix up from 0 starts the echoes from empty lines. The same -stock Mixer is on the dry path above Mix 0, so on CircuitPython alone the -dry's top 32 LSB of range read one LSB hot. +stock Mixer is on the dry path above Mix 0. At one channel the class hands +the dry's unity as 1 - 2^-15, which every Mixer here passes exactly; at two +the stock Mixer's pan law leaves no level that is exact in both lanes, so +on CircuitPython alone the right lane's dry reads one LSB hot on the +source values within 32 LSB of the rails. **Moving Time or Heads clicks.** Both nodes jump to the new grid, and in stereo the tap node's planar line also crosses channels for up to one lap (the right channel briefly replays what the left one wrote). Nothing about -the grid is claimed while Time or Heads moves. +the grid is claimed while Time or Heads moves with audio playing. A move +made before the first pull, or after `reset()` and before the next pull, +is not a move of that kind: the tap node's line holds only zeros then, so +the first lap lands on the grid in both lanes. **Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). The laps are `audioecho.FeedbackDelay` and the dry fan-out is @@ -95,9 +106,14 @@ are stock. On a stock CircuitPython board this module imports cleanly and construction raises `ImportError`. -**Latency: zero samples, at every setting, patch and rate.** The dry is a -Splitter tap into a Mixer voice, a wire, and nothing looks ahead. The heads -are the effect, not latency, and no option adds any. +**Latency: zero samples, at every setting, patch and rate**, and after +`reset()` from any graph. The dry is a Splitter tap into a Mixer voice, a +wire, and nothing looks ahead. The heads are the effect, not latency, and +no option adds any. The output ends in an `audioroute.MidSide` at width 1, +the identity, whose reset forwards nothing: a host that resets the output +(a mixer voice's `play()` does) no longer reaches the Mixer, whose voices +would re-prime from the Splitter and drop the source's first block. That +reset leaves the lines as they are; call `reset()` to empty them. **Mono.** A one-channel source gets the same effect on its one channel. There is no Spread: the tap node applies one set of heads to every @@ -115,10 +131,10 @@ **Cost, a planning estimate; the board measurement is pending hardware.** Palette rows (MultiTapDelay, which ran four taps; FeedbackDelay +options; -Splitter, two taps; Mixer; MidSide; one Python pull of glue) put the full -graph at **P4 <= 66 %, S3 <= 87 %** of a 5.333 ms stereo block, which is -over the S3's 80 % line, and the lean graph (Repeat Tone out) at -**P4 <= 58 %, S3 <= 73 %**. **On an S3, stack patch 1, `Three Heads, +Splitter, two taps; Mixer; MidSide, twice, the input adapter and the tail; +one Python pull of glue) put the full graph at **P4 <= 67 %, S3 <= 88 %** +of a 5.333 ms stereo block, which is over the S3's 80 % line, and the +lean graph (Repeat Tone out) at **P4 <= 59 %, S3 <= 74 %**. **On an S3, stack patch 1, `Three Heads, Even - lean`, not patch 0**, and only with light classes: the tap node alone is most of an S3 block. Every Repeat Tone-in setting, the defaults included, is over the S3's line by this estimate. Eight sounding heads @@ -204,6 +220,12 @@ #: The block every node renders, in frames. BLOCK = 256 +#: How far the tap node's own timeline runs behind the dry, in frames, and +#: so how much shorter than k n1 each head's offset is handed (`_route`). +#: The shortest head is 20 ms, 441 frames at 22.05 kHz, so every offset +#: stays positive. +LAG = BLOCK + #: S1's `Head Combinations for Each Mode`, modes 1-11. Mode 12 is every #: head on the plain grid (section 8.3), so it is not in the table. HEAD_SETS = ((1,), (2,), (3,), (1, 2), (2, 3), (1, 3), (1, 2, 3), (1, 4), @@ -438,6 +460,7 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, self._levels = () self._lap_ms = 0.0 self._feedback = 0.0 + self._lap_mix = 0.0 self._decay = 0.0 self._damping = 0.0 self._lean = False @@ -467,6 +490,15 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, self._silence = audiocore.RawSample( array("h", bytes(2 * 2 * channels)), sample_rate=rate, channel_count=channels) + # A MidSide with no source hands out one block of zeros per pull and + # never finishes: what the tap node primes from, and what the input + # adapter reads while a reset wires the graph (`_route`). + self._hush = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + # The tap node always plays this port; the graph's two shapes are + # the port pointed at the lap node or at the Splitter's tap, and a + # re-point is one store that pulls nothing. + self._feed = audioroute.Port(self._hush) # The laps: the lap node recirculates at P with the darkening in # its loop. Its `mix` is the Feedback, so its output is the dry plus @@ -488,26 +520,38 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, voice_count=2, buffer_size=BLOCK * channels * 4, channel_count=channels, bits_per_sample=16, samples_signed=True, sample_rate=rate) + # The tail: a MidSide at width 1 is the identity, byte for byte, and + # its reset forwards nothing upstream. A host that resets the + # output (a mixer voice's `play()` does) would otherwise reach the + # Mixer, whose voices re-prime from the Splitter's taps and throw + # away the source's block they already hold (`_route`). + self._tail = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + self._tail.play(self._mixer) self._adapter = adapter self._split = split self._dry = dry self._tap1 = tap1 - # Tail first in `reset()` and `deinit()`. The Mixer and the - # Splitter's side are not reset: a Mixer voice resets its source - # recursively, which would reach the borrowed source. The tap - # node's reset empties its line through `audiocore.reset_buffer`; - # the lap node's `clear` empties its line and loop filters. + # The Mixer and the Splitter's side are not reset: a Mixer voice + # resets its source recursively, which would reach the borrowed + # source. The tap node's reset empties its line through + # `audiocore.reset_buffer`; the lap node's `clear` empties its line + # and loop filters. The feed port is not reset either: it would + # forward the reset to whatever it points at. + self._own(self._tail) self._own(self._mixer, reset=False) self._own(self._tapnode) + self._own(self._feed, reset=False) self._own(self._fd, reset=self._fd.clear) self._own(dry, reset=False) self._own(tap1, reset=False) self._own(split, reset=False) self._own(adapter) + self._own(self._hush, reset=False) self._own(self._silence, reset=False) - self._output = self._mixer + self._output = self._tail self._ready = False self._init_macros((time_ms, float(pattern - 1), float(heads), @@ -573,11 +617,11 @@ def _tap_node_ms(self, lap): return (lap + 0.5) * 1000.0 / self._sample_rate def _tap_positions(self, selected, n1, lap): - """Head k at (k n1 + 0.5) / P, so the node's truncation of the - offset lands on k n1; the farthest head on the grid at 1.0.""" - heads = lap // n1 - return tuple(1.0 if k == heads else (k * n1 + 0.5) / lap - for k in selected) + """Head k at (k n1 - LAG + 0.5) / P, so the node's truncation of + the offset lands on k n1 - LAG: the tap node reads its input one + block after the dry has played it (`_route`), so each head sounds + at exactly k n1 against the dry.""" + return tuple((k * n1 - LAG + 0.5) / lap for k in selected) def _lap_node_ms(self, lap): return lap_node_ms(lap, self._sample_rate) @@ -643,8 +687,10 @@ def _refresh(self): self._tapnode.decay = decay self._fd.set(delay_ms=lap_ms, feedback=loop, mix=loop, damping_hz=damping, cut_hz=0.0, delay_slew=0.0) + #: The lap node's `mix`, which is its Feedback (dossier section 4). + self._lap_mix = loop mix = self._value(MIX_I) - self._mixer.voice[0].level = min(1.0, 2.0 - mix) + self._mixer.voice[0].level = self._dry_level(mix) self._mixer.voice[1].level = min(1.0, mix) self._n1 = n1 @@ -659,8 +705,22 @@ def _refresh(self): self._lean = lean self._route() + def _dry_level(self, mix): + """The dry voice's level, min(1, 2 - Mix). At one channel unity is + handed as 1 - 2^-15, which every Mixer this class meets passes + through exactly: CircuitPython's stock `audiomixer` turns a level + of 1.0 into 32768 / 32767 and puts the samples nearest the rails + one LSB hot, while 32767 / 32767 is 1. Two channels cannot do that + (the stock Mixer's pan law hands the left lane 32767 x level >> 15, + so 1 - 2^-15 is one LSB cold there), so they keep 1.0, and on + CircuitPython alone their right lane is the hot one.""" + level = min(1.0, 2.0 - mix) + if level >= 1.0 and self._channel_count == 1: + return 1.0 - 2.0 ** -15 + return level + def _route(self): - """Where the output port points, and which node the tap node plays. + """Where the output port points, and what the tap node reads. Mix 0 hands the borrowed source straight back through the port, so it is a wire on every interpreter: CircuitPython's stock @@ -671,21 +731,40 @@ def _route(self): then: a `play()` would take a block of the source away from the port. - Above Mix 0 the graph is wired the first time (a `play()` on the - tap node or a Mixer voice primes one block from its source, so each - is called exactly once and the dry and the wet start on the same - block; `FeedbackDelay.play` pulls nothing), and afterwards the tap - node is re-plugged if Repeat Tone crossed its out stop. Coming back - from Mix 0 empties both lines, which were not fed while it was 0. - - Inside `reset()` those primes would take a block of the borrowed - source and leave it in the Splitter for the dry, which is the last - take coming out after the reset: patch 0 has Repeat Tone in, so a - reset from the lean graph re-plugs, and one from a class never - wired at Mix 0 wires. There the input adapter plays a silent - sample for the length of the primes, so they read one block of - zeros on both branches and the source is not touched; the output - after such a reset opens with that one silent block. + **The tap node runs one block behind the dry.** A Mixer voice's + `play()` primes one block from its source, and the voices hand + that block out on the first pull, so whatever the tap node renders + at wiring is rendered with the settings of that moment. If that + were the source's first block, a Time or Heads move before the + first pull would re-base the tap node's planar line under it, and + in stereo the right lane would lose its first block's heads. So + the tap node primes a block of zeros instead (it plays `_feed`, + pointed at `_hush`), renders those zeros for the wet voice's + prime, and only then is the port pointed at the lap node or the + Splitter's tap, which pulls nothing. The source's first block + reaches the tap node's line on the second pull, with whatever the + settings are by then; every head is handed LAG frames short + (`_tap_positions`), so it still sounds at exactly k n1 against the + dry, and the Splitter's tap 1 always holds the one block the tap + node has not reached yet. + + Crossing Repeat Tone's out stop re-points the port (and empties + the lap node going in), which pulls nothing, so any number of + crossings between two pulls leaves the wet where it was against the + dry. Coming back from Mix 0, and in `reset()`, `_resync` empties + both lines, drops that one pending block from tap 1 and primes the + tap node with zeros again, so no audio from before either reaches + the lines and nothing is taken from the source. + + A reset that has to wire the graph (a class never wired at Mix 0) + must not pull the source either: the input adapter plays `_hush` + while the voices prime, so the dry's primed block and tap 1's + pending block are zeros, and one pull of the Mixer then hands the + dry's zeros out, so the next pull reads the source's next block on + time. That pull needs `audiocore.get_buffer`; where a build leaves + it out, the output after such a reset opens with that one silent + block, and a reset or a return from Mix 0 lets tap 1's pending block + into the lines. """ if not self._ready: return @@ -693,26 +772,15 @@ def _route(self): self._output = self._source self._at_source = True return - quiet = self._resetting and (not self._wired - or self._lean != self._plugged) - if quiet: - self._adapter.play(self._silence) - try: - if not self._wired: - self._wire() - self._wired = True - else: - if self._at_source: - self._fd.clear() - audiocore.reset_buffer(self._tapnode) - if self._lean != self._plugged: - self._plug(self._lean) - finally: - if quiet: - self._adapter.play(self._source) - self._fd.clear() + if not self._wired: + self._wire(self._resetting) + self._wired = True + elif self._at_source or self._resetting: + self._resync() + elif self._lean != self._plugged: + self._plug(self._lean) self._at_source = False - self._output = self._mixer + self._output = self._tail def reset(self): """Empty both lines and restore patch 0, without pulling the @@ -724,25 +792,52 @@ def reset(self): finally: self._resetting = False - def _wire(self): - """Play every node once, the first time Mix is above 0.""" + def _target(self, lean): + """What the feed port points at: the Splitter's tap (the lean + graph, the tap node's own `decay` making the laps) or the lap + node.""" + return self._tap1 if lean else self._fd + + def _wire(self, quiet=False): + """Play every node once, the first time Mix is above 0 (`_route`). + `quiet` inside `reset()`: nothing is taken from the source.""" + pull = getattr(audiocore, "get_buffer", None) self._fd.play(self._tap1) - self._plug(self._lean) + self._feed.play(self._hush) + self._tapnode.play(self._feed, loop=False) + if quiet: + self._adapter.play(self._hush) _component.open_level_gates(self._mixer, self._mixer.voice, self._silence) self._mixer.voice[0].play(self._dry, loop=False) self._mixer.voice[1].play(self._tapnode, loop=False) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + if quiet: + self._adapter.play(self._source) + if pull is not None: + pull(self._mixer) + + def _resync(self): + """Empty both lines, drop the block tap 1 holds for the tap node, + and prime the tap node with zeros again (`_route`).""" + pull = getattr(audiocore, "get_buffer", None) + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + if pull is not None: + pull(self._tap1) + self._feed.play(self._hush) + self._tapnode.play(self._feed, loop=False) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean def _plug(self, lean): - """Point the tap node at the Splitter's tap (the lean graph, the - tap node's own `decay` making the laps) or at the lap node. The - tap node's `play` resets the new source and primes one block from - it: the lap node's reset empties its line, the Splitter tap's is a - deliberate no-op, so the dry is never disturbed.""" - if lean: - self._tapnode.play(self._tap1, loop=False) - else: - self._tapnode.play(self._fd, loop=False) + """Point the feed port at the Splitter's tap or at the lap node, + emptying the lap node going in. A store: nothing is pulled, so the + dry is never disturbed and the wet does not move against it.""" + if not lean: + self._fd.clear() + self._feed.play(self._target(lean)) self._plugged = lean # -- the contract's reads ------------------------------------------ diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 109c115..4593b61 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -46,6 +46,7 @@ class built as a wire. The full spans, the three interpreters and the rates import audioeffects # noqa: E402 from audioeffects import _component # noqa: E402 from audioeffects import rebuilt # noqa: E402 +from audioeffects.chorus import nominal_damping_hz # noqa: E402 from tools import effect_measurements as kit # noqa: E402 VENDOR = "PyDevices" @@ -85,6 +86,12 @@ def law_landed(time_ms, heads, rate, max_lap_ms=1600.0): return n1, heads * n1 +#: Section 4 (revised in fix round 1): the tap node reads its input one +#: block after the dry has played it, so each head is handed k n1 - LAG +#: frames and sounds at k n1 against the dry. +LAW_LAG = 256 + + def law_time_ms(midi): """Section 6 row 0: 20-400 ms, log, on the 0-127 grid.""" return 20.0 * 20.0 ** (midi / 127.0) @@ -244,7 +251,9 @@ def nonzero(out, channel=0): def first_lap(cls, rate=RATE, channels=2, tone=4000.0, midi=None, **opts): """T1's first-lap reading: (non-zero frames, their values, law set). - Click 20 000, Feedback 0, Mix 2, Tilt 0, P + 512 frames.""" + Click 20 000, Feedback 0, Mix 2, Tilt 0, P + 512 frames. Lane 0's + frames and values; a lane whose frames or values differ from lane 0's + appends minus its number, so every lane is read.""" midi = dict(midi or {}) midi.setdefault(MIX_I, 127) opts.setdefault("feedback", 0.0) @@ -257,8 +266,10 @@ def first_lap(cls, rate=RATE, channels=2, tone=4000.0, midi=None, **opts): out = pull(effect, lap + 512) frames = nonzero(out, 0) values = [int(out[i, 0]) for i in frames] - if channels == 2 and nonzero(out, 1) != frames: - frames = frames + [-1] + for lane in range(1, channels): + if nonzero(out, lane) != frames or \ + [int(out[i, lane]) for i in frames] != values: + frames = frames + [-lane] expected = [k * n1 for k in law_heads(mode, heads)] effect.deinit() return frames, values, expected @@ -282,8 +293,9 @@ def lap_arrivals(n1, lap, heads, laps, window): def laps_reading(cls, rate=RATE, tone=4000.0, feedback=0.45, midi=None, channels=2, **opts): - """T1's lap clause: lean, the non-zero set against every arrival - inside 4P + 512, exact; full, the onset pair at every arrival.""" + """T1's lap clause, every lane: lean, the non-zero set against every + arrival inside 4P + 512, exact; full, the onset pair at every + arrival. `late` lists every miss, lane by lane.""" midi = dict(midi or {}) midi.setdefault(MIX_I, 127) effect = build(cls, rate=rate, channels=channels, midi=midi, @@ -295,22 +307,26 @@ def laps_reading(cls, rate=RATE, tone=4000.0, feedback=0.45, midi=None, out = pull(effect, window) effect.deinit() arrivals = lap_arrivals(n1, lap, heads, 5, window) + lanes = range(out.shape[1]) if tone <= 0.0: - return {"passed": nonzero(out, 0) == arrivals, - "arrivals": len(arrivals), "late": []} - late = [a for a in arrivals - if out[a, 0] == 0 or out[a - 1, 0] != 0] + off = [(lane, sorted(set(nonzero(out, lane)) ^ set(arrivals))) + for lane in lanes] + off = [item for item in off if item[1]] + return {"passed": not off and len(arrivals) > 0, + "arrivals": len(arrivals), "late": off} + late = [a for lane in lanes for a in arrivals + if out[a, lane] == 0 or out[a - 1, lane] != 0] return {"passed": not late and len(arrivals) > 0, "late": late, "arrivals": len(arrivals)} -def spectra(out, n1, lap, heads, laps=4, size=4096): +def spectra(out, n1, lap, heads, laps=4, size=4096, lane=0): win = np.hanning(size) res = {} for n in range(1, laps + 1): for k in heads: a = (n - 1) * lap + k * n1 - seg = out[a - size // 2:a + size // 2, 0].astype(float) + seg = out[a - size // 2:a + size // 2, lane].astype(float) peak = float(np.abs(seg).max()) mag = np.abs(np.fft.rfft(seg * win)) res[(n, k)] = (20.0 * np.log10(np.maximum(mag, 1e-9)), peak, seg) @@ -318,53 +334,59 @@ def spectra(out, n1, lap, heads, laps=4, size=4096): def t45_reading(cls, rate=RATE, feedback=0.45, tone=4000.0, heads=3, - mode=7, midi=None, emulate=None): + mode=7, midi=None, emulate=None, data=None, time_ms=200.0): """T4 and T5 at one cell: click 24 000, Mix 2, Tilt 0, t1 200 ms, - 4096-point Hann windows at every arrival of laps 1-4. Returns the - worst pairwise T4 spread, the worst clause-1 increment spread, the - corner and 8/5 kHz D2/D4 of head 1, and whether every head sounded - (every window non-zero), with the lap-1 peaks.""" + 4096-point Hann windows at every arrival of laps 1-4, on every lane. + Returns lane 0's worst pairwise T4 spread, worst clause-1 increment + spread, corner and 8/5 kHz D2/D4 of head 1, whether every head sounded + (every window non-zero) and the lap-1 peaks, with the same for every + lane under "lanes"; `t4_green` and `t5_green` judge every lane.""" midi = dict(midi or {}) midi.setdefault(MIX_I, 127) size = 4096 - effect = build(cls, data=click(24000), rate=rate, midi=midi, - time_ms=200.0, heads=heads, pattern=mode, - feedback=feedback, tone_hz=tone) + effect = build(cls, data=click(24000) if data is None else data, + rate=rate, midi=midi, time_ms=time_ms, heads=heads, + pattern=mode, feedback=feedback, tone_hz=tone) selected = law_heads(mode, heads) - n1, lap = law_landed(200.0, heads, rate) + n1, lap = law_landed(time_ms, heads, rate) out = pull(effect, 4 * lap + size) effect.deinit() - res = spectra(out, n1, lap, selected, size=size) - if emulate is not None: - res = emulate(res, rate) freqs = np.fft.rfftfreq(size, 1.0 / rate) band = (freqs >= 100.0) & (freqs <= 10000.0) i100 = int(np.argmin(np.abs(freqs - 100.0))) - present = all(res[(n, k)][1] > 0 for n in range(1, 5) - for k in selected) - t4 = 0.0 - t5 = 0.0 - for n in range(1, 5): - norm = np.array([res[(n, k)][0] - 20.0 * math.log10( - max(res[(n, k)][1], 1.0)) for k in selected]) - if len(selected) > 1: - t4 = max(t4, float(np.max(np.ptp(norm[:, band], axis=0)))) - if n > 1 and len(selected) > 1: - inc = np.array([res[(n, k)][0] - res[(1, k)][0] - for k in selected]) - t5 = max(t5, float(np.max(np.ptp(inc[:, band], axis=0)))) - h = selected[0] - - def dark(n, f): - iq = int(np.argmin(np.abs(freqs - f))) - inc = res[(n, h)][0] - res[(1, h)][0] - return -float(inc[iq] - inc[i100]) corner = min(tone, rate * 0.5 * _component.NYQUIST_MARGIN) top = 8000.0 if rate > 30000 else 5000.0 - return {"t4": t4, "t5": t5, "present": present, - "lap1": [res[(1, k)][1] for k in selected], - "c2": dark(2, corner), "c4": dark(4, corner), - "q2": dark(2, top), "q4": dark(4, top)} + lanes = [] + for lane in range(out.shape[1]): + res = spectra(out, n1, lap, selected, size=size, lane=lane) + if emulate is not None: + res = emulate(res, rate) + present = all(res[(n, k)][1] > 0 for n in range(1, 5) + for k in selected) + t4 = 0.0 + t5 = 0.0 + for n in range(1, 5): + norm = np.array([res[(n, k)][0] - 20.0 * math.log10( + max(res[(n, k)][1], 1.0)) for k in selected]) + if len(selected) > 1: + t4 = max(t4, float(np.max(np.ptp(norm[:, band], axis=0)))) + if n > 1 and len(selected) > 1: + inc = np.array([res[(n, k)][0] - res[(1, k)][0] + for k in selected]) + t5 = max(t5, float(np.max(np.ptp(inc[:, band], axis=0)))) + h = selected[0] + + def dark(n, f, res=res, h=h): + iq = int(np.argmin(np.abs(freqs - f))) + inc = res[(n, h)][0] - res[(1, h)][0] + return -float(inc[iq] - inc[i100]) + lanes.append({"t4": t4, "t5": t5, "present": present, + "lap1": [res[(1, k)][1] for k in selected], + "c2": dark(2, corner), "c4": dark(4, corner), + "q2": dark(2, top), "q4": dark(4, top)}) + result = dict(lanes[0]) + result["lanes"] = lanes + return result def one_pole(seg, hz, rate, passes=1): @@ -403,13 +425,13 @@ def darken_head2_per_lap(res, rate, size=4096): class LateHeads(MultiTapDelay): """T1 (1): every head below K handed one frame late, - (k n1 + 1.5) / P.""" + (k n1 - LAG + 1.5) / P.""" NAME = 'MultiTapDelay' def _tap_positions(self, selected, n1, lap): heads = lap // n1 - return tuple(1.0 if k == heads else (k * n1 + 1.5) / lap + return tuple((k * n1 - LAW_LAG + (0.5 if k == heads else 1.5)) / lap for k in selected) @@ -455,13 +477,14 @@ class FilteredHead(MultiTapDelay): """T4: head 2 read by a second tap node behind a 6 kHz low-pass, built for real: the lap node's output split into two tap nodes, head 2's behind the filter, summed as a third voice of the output Mixer (a - nested Mixer would reset the tap nodes' lines when it is played). - Full graph only; the plant is read at Tone in.""" + nested Mixer would reset the tap nodes' lines when it is played). Both + tap nodes are wired the class's way, a block of zeros first and one + block behind the dry. Full graph only; the plant is read at Tone in.""" NAME = 'MultiTapDelay' CORNER_HZ = 6000.0 - def _wire(self): + def _wire(self, quiet=False): rate, channels = self._sample_rate, self._channel_count self._plant_filtered_head = True self._fd.play(self._tap1) @@ -476,10 +499,11 @@ def _wire(self): max_delay_ms=1601, delay_ms=self._tap_ms, decay=0.0, mix=1.0, taps=self._head2_taps(), buffer_size=BLOCK * channels * 2, sample_rate=rate, channel_count=channels) - self._head2.play(self._filter) + self._feed2 = audioroute.Port(self._hush) + self._head2.play(self._feed2) self._tapnode.taps = self._other_taps() - self._tapnode.play(split.tap(0)) - self._plugged = False + self._feed.play(self._hush) + self._tapnode.play(self._feed) mix = self._value(MIX_I) self._mixer = audiomixer.Mixer( voice_count=3, buffer_size=BLOCK * channels * 4, @@ -492,6 +516,10 @@ def _wire(self): self._mixer.voice[0].play(self._dry) self._mixer.voice[1].play(self._tapnode) self._mixer.voice[2].play(self._head2) + self._feed.play(split.tap(0)) + self._feed2.play(self._filter) + self._tail.play(self._mixer) + self._plugged = False def _other_taps(self): return tuple(t for k, t in zip(self._selected, self._taps) if k != 2) @@ -509,11 +537,13 @@ def _refresh(self): class FrontFilter(MultiTapDelay): """T5 clause 2: the seed's compose-first build, built for real - a front `audiofilters.Filter` (low-pass at the Tone value) into the tap - node, whose own decay makes the laps. It decays and never darkens.""" + node, whose own decay makes the laps. It decays and never darkens. The + filter holds the source's first block from its own prime, which is the + one block the tap node runs behind the dry.""" NAME = 'MultiTapDelay' - def _wire(self): + def _wire(self, quiet=False): rate, channels = self._sample_rate, self._channel_count self._plant_front_filter = True self._filter = audiofilters.Filter( @@ -523,12 +553,14 @@ def _wire(self): channel_count=channels) self._filter.play(self._tap1) self._tapnode.decay = self._value(FEEDBACK_I) - self._tapnode.play(self._filter) - self._plugged = False + self._feed.play(self._hush) + self._tapnode.play(self._feed) _component.open_level_gates(self._mixer, self._mixer.voice, self._silence) self._mixer.voice[0].play(self._dry) self._mixer.voice[1].play(self._tapnode) + self._feed.play(self._filter) + self._plugged = False def _refresh(self): MultiTapDelay._refresh(self) @@ -536,9 +568,9 @@ def _refresh(self): class PrimingReset(MultiTapDelay): - """The review's finding, planted: the base's `reset()`, whose patch-0 - restore re-plugs (or wires) the graph by priming a block of the - borrowed source into the Splitter.""" + """Station B review's finding, planted: the base's `reset()`, which + wires a never-wired graph by priming a block of the borrowed source + and restores patch 0 without dropping the block tap 1 holds.""" NAME = 'MultiTapDelay' @@ -546,6 +578,142 @@ def reset(self): _component.Component.reset(self) +class StaleReset(MultiTapDelay): + """Tier 1 STATE, planted: `reset()` empties both lines but leaves the + one block tap 1 holds for the tap node, audio from before the reset.""" + + NAME = 'MultiTapDelay' + + def _resync(self): + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + + +class PrimedWire(MultiTapDelay): + """Gate audit round 1, item 1, planted: Station B's wiring. The tap + node primes the source's first block and renders it into its planar + line for the wet voice's prime, level with the dry, heads handed at + (k n1 + 0.5) / P with head K at 1.0, so a Time or Heads move before + the first pull re-bases that block.""" + + NAME = 'MultiTapDelay' + + def _tap_positions(self, selected, n1, lap): + heads = lap // n1 + return tuple(1.0 if k == heads else (k * n1 + 0.5) / lap + for k in selected) + + def _wire(self, quiet=False): + self._plant_primed_wire = True + self._fd.play(self._tap1) + self._feed.play(self._target(self._lean)) + self._tapnode.play(self._feed, loop=False) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry, loop=False) + self._mixer.voice[1].play(self._tapnode, loop=False) + self._plugged = self._lean + + +class LateReset(MultiTapDelay): + """Item 2, planted: a reset that puts a silent block in front of the + source (fix round c80ca59's quiet prime did), so every later frame is + a block late against a reported 0.""" + + NAME = 'MultiTapDelay' + _plant_late_reset = True + + def _resync(self): + MultiTapDelay._resync(self) + if self._resetting: + self._adapter.play(self._hush) + self._mixer.voice[0].play(self._dry, loop=False) + self._adapter.play(self._source) + + +class MixerTail(MultiTapDelay): + """Item 3, planted: the output port on the Mixer, as before the tail. + A host's reset reaches the Mixer, whose voices re-prime from the + Splitter's taps and drop the block they hold.""" + + NAME = 'MultiTapDelay' + + def _route(self): + MultiTapDelay._route(self) + if self._ready and not self._at_source: + self._plant_mixer_tail = True + self._output = self._mixer + + +class PrimingPlug(MultiTapDelay): + """Item 4, planted: a Repeat Tone crossing that re-plays the tap node + (Station B's `_plug`), which primes a block from its new source; two + crossings between pulls take two.""" + + NAME = 'MultiTapDelay' + _plant_priming_plug = True + + def _plug(self, lean): + if not lean: + self._fd.clear() + self._feed.play(self._target(lean)) + self._tapnode.play(self._feed, loop=False) + self._plugged = lean + + +class PannedLaps(MultiTapDelay): + """Item 5, the lane-1 plant (the material refuter's): the lap node's + input steered to the left line (`input_pan` -1), so the right lane + keeps lap 1 (the lap node's dry pass) and loses every later lap.""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + self._plant_panned_laps = True + self._fd.set(input_pan=-1.0) + + +class CrossedLaps(MultiTapDelay): + """Item 5 (the material refuter's): the lap node's cross-feed at 1, + so every lap swaps lanes.""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + self._plant_crossed_laps = True + self._fd.set(cross_feed=1.0) + + +class LapMixOne(MultiTapDelay): + """Item 6 (the material refuter's): the lap node's `mix` at 1.0, not + the Feedback (dossier section 4's correction undone), so lap 2 is at + unity against lap 1.""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + self._lap_mix = 1.0 + self._fd.set(mix=1.0) + + +class ToneHalf(MultiTapDelay): + """Item 6 (the material refuter's): Repeat Tone's corner at half the + knob, one pass about -7 dB at the knob's frequency, not -3.""" + + NAME = 'MultiTapDelay' + + def _tone_damping(self): + if self._macros[TONE_I] >= 1.0: + return 0.0 + return nominal_damping_hz(0.5 * self._hz(self._value(TONE_I)), + self._sample_rate) + + class Counting(Endless): """`Endless` that counts the blocks it has handed out.""" @@ -621,10 +789,10 @@ def reach(faulted, reading, **kw): def head_offset_error(effect): """What the tap node does with what the class hands it: each sounding head's offset, the node's way (the lap truncated from `delay_ms`, the - offset truncated from the position), less k n1.""" + offset truncated from the position), less k n1 - LAG.""" rate = effect._sample_rate lap = int(rate / 1000.0 * effect._tap_ms) - return tuple(int(lap * position) - k * effect._n1 + return tuple(int(lap * position) - (k * effect._n1 - LAW_LAG) for k, (position, _level) in zip(effect._selected, effect._taps)) @@ -656,6 +824,229 @@ def head_table(effect): return tuple(effect._head_set(mode) for mode in range(1, 13)) +def click_at(frame, channels=2, level=20000, tail=BLOCK * 4, lanes=None): + """A click at `frame` (every lane, or the lanes named), then silence.""" + data = array("h", [0] * ((frame + tail) * channels)) + for ch in (range(channels) if lanes is None else lanes): + data[frame * channels + ch] = level + return data + + +def lane_hits(out): + return [nonzero(out, lane) for lane in range(out.shape[1])] + + +def ctor_route(cls, move, rate=RATE, channels=2, at=0, pattern=None): + """Gate audit round 1, item 1: the plain constructor (`mix=2.0, + feedback=0.0`), then one setting before the first pull, a click at + frame `at` of every lane; every lane's non-zero frames against the + law for the knob positions.""" + opts = dict(sample_rate=rate, mix=2.0, feedback=0.0) + if pattern is not None: + opts["pattern"] = pattern + effect = cls(Endless(click_at(at, channels), rate, channels), **opts) + if move is not None: + effect.set_macro(*move) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate, + effect._max_lap_ms) + law = [at + k * n1 for k in law_heads(effect._pattern_mode(), + effect._heads)] + hits = lane_hits(pull(effect, at + lap + 512)) + effect.deinit() + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +def patch_route(cls, patch, rate=RATE, channels=2): + """Item 1's program_change route: the constructor's defaults, + `program_change(patch)`, Feedback 0 and Mix 127 before the first pull. + Read from frame 1: a Mix move after wiring leaves frame 0 at the old + dry level in stereo (the surface record's item 4).""" + effect = cls(Endless(click(channels=channels), rate, channels), + sample_rate=rate) + effect.program_change(patch) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 127) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate, + effect._max_lap_ms) + law = [k * n1 for k in law_heads(effect._pattern_mode(), + effect._heads)] + hits = [[f for f in h if f > 0] + for h in lane_hits(pull(effect, lap + 512))] + effect.deinit() + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +RESET_ORIGIN = 60 * BLOCK + 101 # source frame 15 461 + + +def reset_route(cls, start, rate=RATE, channels=2): + """Item 2: 30 blocks from `start`, then `reset()`, Feedback 0 and Mix + 1.0 (MIDI 63.5), a click at source frame 15 461: where the dry click + lands against the source, and the heads against the dry.""" + source = Endless(click_at(RESET_ORIGIN, channels), rate, channels) + if start == "patch1": + effect = cls(source, sample_rate=rate, patch=1) + elif start == "mix0": + effect = cls(source, sample_rate=rate, mix=0.0) + else: + effect = cls(source, sample_rate=rate) + if start == "tone127": + effect.set_macro(TONE_I, 127) + head = pull(effect, 30 * BLOCK) + effect.reset() + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 63.5) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + law = [k * n1 for k in law_heads(effect._pattern_mode(), + effect._heads)] + rest = pull(effect, RESET_ORIGIN - 30 * BLOCK + lap + 1024) + reported = effect.latency_samples + effect.deinit() + out = np.concatenate([head, rest]) + hits = lane_hits(out) + dry = hits[0][0] if hits[0] else None + heads = [f - dry for f in hits[0][1:]] if hits[0] else [] + same = all(h == hits[0] for h in hits) + return {"passed": dry == RESET_ORIGIN and heads == law and same + and reported == 0, "dry": None if dry is None + else dry - RESET_ORIGIN, "heads": heads, "law": law} + + +HOST_CLICKS = (0, 3000) + + +def host_reset_route(cls, patch=None, rate=RATE, channels=2): + """Item 3: clicks at source frames 0 and 3 000, the host resets the + output before its first pull (`tools/render_effect.py` does, and so + does a mixer voice's `play()`), Feedback 0: each click's dry at its own + frame and its heads at +k n1.""" + data = array("h", [0] * (12000 * channels)) + for at in HOST_CLICKS: + for ch in range(channels): + data[at * channels + ch] = 20000 + effect = cls(Endless(data, rate, channels), sample_rate=rate, + feedback=0.0, mix=1.0) + if patch is not None: + effect.program_change(patch) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 63.5) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + audiocore.reset_buffer(effect.output) + out = pull(effect, 3000 + lap + 512) + effect.deinit() + law = sorted(set(at + k * n1 for at in HOST_CLICKS + for k in (0,) + law_heads(effect._pattern_mode(), + effect._heads))) + hits = lane_hits(out) + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + +def crossing_route(cls, crossings, rate=RATE, channels=2): + """Item 4: 40 blocks at Mix 1, Feedback 0, Time MIDI 64, then Repeat + Tone across its out stop and back `crossings` times in one gap, then + a click: the dry at its frame and the heads at +k n1 on every lane.""" + origin = 100 * BLOCK + 29 + effect = build(data=click_at(origin, channels), cls=cls, rate=rate, + channels=channels, midi={MIX_I: 63.5, TIME_I: 64}, + feedback=0.0) + head = pull(effect, 40 * BLOCK) + lean = False + for _ in range(crossings): + lean = not lean + effect.set_macro(TONE_I, 127 if lean else 68) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + law = [origin + k * n1 for k in (0,) + law_heads( + effect._pattern_mode(), effect._heads)] + rest = pull(effect, origin - 40 * BLOCK + lap + 1024) + effect.deinit() + hits = lane_hits(np.concatenate([head, rest])) + return {"passed": all(h == law for h in hits), + "hits": [[f - origin for f in h] for h in hits], + "law": [f - origin for f in law]} + + +def level_reading(cls, rate=RATE, tone=4000.0): + """Item 6, section 6 row 3's Feedback law (the material refuter's + reading): click 20 000, the defaults' cell (150 ms, mode 7, K 3), + Feedback MIDI 60; on every lane head 1's lap-n response summed over + one head spacing, against lap 1's, is the Feedback to the n - 1 within + 2 % (the loop low-pass has unity gain at DC); and at Feedback 0 nothing + sounds after the first lap.""" + f = 0.95 * 60 / 127.0 + midi = {FEEDBACK_I: 60, MIX_I: 127} + effect = build(cls, rate=rate, midi=midi, tone_hz=tone) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + out = pull(effect, 4 * lap + 512) + effect.deinit() + ratios = [] + err = 0.0 + for lane in range(out.shape[1]): + sums = [float(out[(n - 1) * lap + n1:(n - 1) * lap + 2 * n1, + lane].sum()) for n in range(1, 5)] + if sums[0] == 0.0: + return {"passed": False, "ratios": [], "err": 1.0, "after": 0} + lane_ratios = [value / sums[0] for value in sums] + ratios.append([round(value, 4) for value in lane_ratios]) + err = max([err] + [abs(lane_ratios[n] / f ** n - 1.0) + for n in range(1, 4)]) + zero = build(cls, rate=rate, midi={FEEDBACK_I: 0, MIX_I: 127}, + tone_hz=tone) + out = pull(zero, 2 * lap + n1 + 512) + zero.deinit() + after = int(np.count_nonzero(out[lap + 1:, :])) + return {"passed": err <= 0.02 and after == 0, "ratios": ratios, + "err": err, "after": after} + + +def corner_reading(cls, rate=RATE, tone=4000.0): + """Item 6, section 6 row 6's corner (the material refuter's reading): + one pass at Repeat Tone's corner, head 1's lap 2 against lap 1 there + less the same at 100 Hz, is 3 dB within 0.5 dB on every lane.""" + result = t45_reading(cls, rate=rate, tone=tone) + worst = max(abs(lane["c2"] - 3.0) for lane in result["lanes"]) + return {"passed": worst <= 0.5, "c2": [round(lane["c2"], 2) + for lane in result["lanes"]]} + + +def left_only_reading(cls, rate=RATE): + """Item 5, channel-different material: a click in the left lane only, + the full graph at the defaults, Feedback 0.45: the right lane stays + exactly zero, and the left lane has every lap's onset.""" + effect = build(cls, data=click_at(0, 2, lanes=(0,)), rate=rate, + midi={MIX_I: 127}, feedback=0.45) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + window = 4 * lap + 512 + out = pull(effect, window) + effect.deinit() + arrivals = lap_arrivals(n1, lap, (1, 2, 3), 5, window) + late = [a for a in arrivals if out[a, 0] == 0 or out[a - 1, 0] != 0] + right = int(np.count_nonzero(out[:, 1])) + return {"passed": right == 0 and not late, "right": right, + "right_peak": int(np.abs(out[:, 1]).max()), "late": late} + + +def damping_error(effect): + """The lap node's corner against the knob's own pre-warped corner, + as a ratio (1 on the clean class, 0 at the out stop).""" + if effect._damping <= 0.0: + return 0.0 + law = nominal_damping_hz(effect._hz(effect.macro(TONE_I)), + effect._sample_rate) + return round(effect._damping / law, 6) + + +def lap_mix_error(effect): + """The lap node's `mix` less its Feedback: 0 on the clean class.""" + return round(effect._lap_mix - effect._feedback, 6) + + +def flag(name): + return lambda effect: getattr(effect, name, False) + + # -------------------------------------------------------------------------- # The surface @@ -848,8 +1239,10 @@ def test_reset_takes_nothing_from_the_source(self): (start, channels)) def test_reset_priming_plant_is_red(self): - for start in ("patch1", "tone127", "mix0"): - self.assertGreater(reset_pulls(PrimingReset, start), 0, start) + # The base's reset wires a never-wired class by priming the source. + # From a wired class a re-plug is a store and takes nothing, so the + # plant's other half is read by STATE below. + self.assertGreater(reset_pulls(PrimingReset, "mix0"), 0) def test_state_from_the_lean_graph(self): for channels in (2, 1): @@ -862,6 +1255,14 @@ def test_state_priming_plant_is_red(self): result = state_reading(PrimingReset, 1) self.assertGreater(result["values"]["reset_residual_lsb"], 0) + def test_state_stale_block_plant_is_red(self): + for patch in (0, 1): + result = state_reading(StaleReset, patch) + self.assertGreater(result["values"]["reset_residual_lsb"], 0, + patch) + result = state_reading(MultiTapDelay, 0) + self.assertEqual(result["values"]["reset_residual_lsb"], 0) + def test_deinit_leaves_the_source(self): source = probes.ArraySource(probes.sine(440.0, 0.1, -6.0), rate=RATE, channels=2) @@ -925,7 +1326,8 @@ def test_long_lap_is_red_on_the_lap_clause(self): for rate in RATES: result = laps_reading(LongLap, rate=rate) self.assertFalse(result["passed"], rate) - self.assertEqual(len(result["late"]), 9, rate) + # Nine of twelve onsets late, in each lane. + self.assertEqual(len(result["late"]), 18, rate) def test_the_t1_faults_are_not_on_the_surface(self): result = reach(LateHeads, head_offset_error) @@ -1071,16 +1473,20 @@ def test_null_build_is_red(self): def t4_green(result): - return result["present"] and result["t4"] <= 0.5 + return all(lane["present"] and lane["t4"] <= 0.5 + for lane in result.get("lanes", [result])) def t5_green(result, tone, rate): - clause1 = result["present"] and result["t5"] <= 0.5 - clause2a = result["c2"] >= 1.0 and abs(result["c4"] - 3 * result["c2"]) \ - <= 2.0 top = 3620.0 if rate > 30000 else 2482.0 - clause2b = tone > top + 1.0 or result["q4"] >= 15.0 - return clause1 and clause2a and clause2b + for lane in result.get("lanes", [result]): + clause1 = lane["present"] and lane["t5"] <= 0.5 + clause2a = lane["c2"] >= 1.0 and \ + abs(lane["c4"] - 3 * lane["c2"]) <= 2.0 + clause2b = tone > top + 1.0 or lane["q4"] >= 15.0 + if not (clause1 and clause2a and clause2b): + return False + return True def tone_of(midi): @@ -1159,5 +1565,179 @@ def test_null_build_is_red(self): label="T5") +# -------------------------------------------------------------------------- +# Gate audit round 1: the routes the pack did not take, and both lanes + + +def reach_flag(faulted, name): + return reach(faulted, flag(name)) + + +class RoundOneRoutes(unittest.TestCase): + """Items 1-4: a setting made on the wired instance before the first + pull, a reset from the lean graph, a host's reset of the output, and + several Repeat Tone crossings in one gap. Each is red on its plant, + which reproduces the code of c80ca59.""" + + def test_setting_before_the_first_pull_keeps_both_lanes_on_the_law(self): + # Red on c80ca59: left [3 118, 9 241, 12 359, 18 482], right none. + result = ctor_route(MultiTapDelay, (TIME_I, 96)) + self.assertEqual(result["law"], [9241, 18482, 27723]) + self.assertTrue(result["passed"], result) + for rate in RATES: + for move, pattern in (((TIME_I, 0), None), ((TIME_I, 127), None), + ((HEADS_I, 127), 12)): + result = ctor_route(MultiTapDelay, move, rate=rate, + pattern=pattern) + self.assertTrue(result["passed"], (rate, move, result)) + self.assertTrue(ctor_route(MultiTapDelay, (TIME_I, 96), + channels=1)["passed"]) + + def test_program_change_before_the_first_pull(self): + for rate in RATES: + for patch in (0, 2, 4): + result = patch_route(MultiTapDelay, patch, rate=rate) + self.assertTrue(result["passed"], (rate, patch, result)) + + def test_primed_wire_plant_is_red_on_the_route_only(self): + result = ctor_route(PrimedWire, (TIME_I, 96)) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"][0][:4], [3118, 9241, 12359, 18482]) + self.assertEqual(result["hits"][1], []) + self.assertFalse(patch_route(PrimedWire, 0)["passed"]) + # The control: from frame 256 the plant is on the law, as c80ca59 + # was, and so is the pack's build. + self.assertTrue(ctor_route(PrimedWire, (TIME_I, 96), + at=256)["passed"]) + self.assertTrue(ctor_route(MultiTapDelay, (TIME_I, 96), + at=256)["passed"]) + self.assertTrue(first_lap_green(PrimedWire)["passed"]) + result = reach_flag(PrimedWire, "_plant_primed_wire") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_reset_leaves_the_output_on_time(self): + # Red on c80ca59 from patch 1, Tone at its out stop and a class + # never wired: the dry at +256. + for start in ("patch1", "tone127", "mix0", "full"): + for rate, channels in ((RATE, 2), (RATE, 1), (44100, 2), + (22050, 2)): + result = reset_route(MultiTapDelay, start, rate, channels) + self.assertTrue(result["passed"], + (start, rate, channels, result)) + + def test_late_reset_plant_is_red(self): + result = reset_route(LateReset, "patch1") + self.assertFalse(result["passed"], result) + self.assertEqual(result["dry"], 256) + result = reach_flag(LateReset, "_plant_late_reset") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_a_host_reset_keeps_the_first_block(self): + # Red on c80ca59: the 3 000 click at 2 744 on all three + # interpreters (multitapdelay_stationC_hostreset.py). + for rate in RATES: + for channels in (2, 1): + for patch in (None, 1): + result = host_reset_route(MultiTapDelay, patch, rate, + channels) + self.assertTrue(result["passed"], + (rate, channels, patch, result)) + + def test_mixer_tail_plant_is_red(self): + result = host_reset_route(MixerTail) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"][0][0], 2744) + result = reach_flag(MixerTail, "_plant_mixer_tail") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_tone_crossings_in_one_gap(self): + # Red on c80ca59 at two crossings: heads at +4 088 for +4 344. + for rate in RATES: + for crossings in (1, 2, 3, 4): + result = crossing_route(MultiTapDelay, crossings, rate) + self.assertTrue(result["passed"], (rate, crossings, result)) + self.assertTrue(crossing_route(MultiTapDelay, 2, + channels=1)["passed"]) + + def test_priming_plug_plant_is_red(self): + self.assertTrue(crossing_route(PrimingPlug, 1)["passed"]) + result = crossing_route(PrimingPlug, 2) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"][0], [0, 4088, 8432, 12776]) + result = reach_flag(PrimingPlug, "_plant_priming_plug") + self.assertEqual(result["checked"], 9 * 17 + 7) + + +class RoundOneLanes(unittest.TestCase): + """Item 5: the lap and spectral readings read every lane, with a + lane-1 plant and channel-different material.""" + + def test_panned_laps_is_red_on_every_lane_reading(self): + for rate in RATES: + result = laps_reading(PannedLaps, rate=rate) + self.assertFalse(result["passed"], (rate, result)) + self.assertEqual(len(result["late"]), 9, rate) + self.assertFalse(t4_green(t45_reading(PannedLaps, rate=rate))) + self.assertTrue(laps_reading(PannedLaps, rate=rate, + channels=1)["passed"]) + result = reach_flag(PannedLaps, "_plant_panned_laps") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_left_only_click(self): + for rate in RATES: + result = left_only_reading(MultiTapDelay, rate) + self.assertTrue(result["passed"], (rate, result)) + result = left_only_reading(CrossedLaps) + self.assertFalse(result["passed"], result) + self.assertGreater(result["right"], 0) + result = reach_flag(CrossedLaps, "_plant_crossed_laps") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: left_only_reading(cls), + label="left-only") + + +class RoundOneSurfaceLaws(unittest.TestCase): + """Item 6: section 6's Feedback law and Repeat Tone's corner, each + with a plant.""" + + def test_lap_levels_follow_feedback(self): + for rate in RATES: + for tone in (4000.0, 0.0): + result = level_reading(MultiTapDelay, rate, tone) + self.assertTrue(result["passed"], (rate, tone, result)) + + def test_lap_mix_one_is_red(self): + for rate in RATES: + result = level_reading(LapMixOne, rate) + self.assertFalse(result["passed"], (rate, result)) + self.assertGreater(result["ratios"][0][1], 0.99) + result = reach(LapMixOne, lap_mix_error) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_repeat_tone_corner_is_3_db(self): + for rate in RATES: + for midi in (0, 64, 126): + result = corner_reading(MultiTapDelay, rate, tone_of(midi)) + self.assertTrue(result["passed"], (rate, midi, result)) + self.assertTrue(corner_reading(MultiTapDelay, rate)["passed"]) + + def test_tone_half_is_red(self): + for rate in RATES: + result = corner_reading(ToneHalf, rate) + self.assertFalse(result["passed"], (rate, result)) + self.assertGreater(min(result["c2"]), 6.0) + result = reach(ToneHalf, damping_error) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_null_build_is_red(self): + kit_faults.null_build_red( + MultiTapDelay, lambda cls: level_reading(cls), label="levels") + kit_faults.null_build_red( + MultiTapDelay, lambda cls: corner_reading(cls), label="corner") + + if __name__ == "__main__": unittest.main() From e9408a503f6754108208e8d1107ba74171228a26 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:09:23 -0500 Subject: [PATCH 46/88] ConvolutionReverb fix round 2: the round-2 audit's list, class side Words and tests only; the audio is unchanged (60 of 60 digest cells agree, Tier 1 failures 0 on all three interpreters). The docstring, the class docstring and the CHANGELOG line now give the left-right balance as about +/-4.5 dB at every rate, moved by Room as much as by Damping and Decay (was "up to about 1.3 dB, 2.6 dB at 22.05 kHz"). "Counting both channels together" now holds for material about equally loud on both sides, with the one-sided figures. The single-Room Damping-in figure gains its mono reading (22 %). D2's double precision is said to be the desktop's, with the single-precision emulation's 118-against-119 cells. A negative damping_hz is out of circuit (was said to be 500 Hz), and impulse=b"" holds one partition; tests pin both. D5's law now takes the Decay and Predelay positions the test handed the instance (handed_build), not effect._macros. DecayKeptSquared and DecayMidiSquared are the controls: at Decay 64/127 they read -12.781 % worst at 48 kHz and -13.147 % at 22.05 kHz on the handed law, and -1.907 / +2.420 % on the read-back one. Planted the old reading into the new test (errors_over_rooms forced to read back): it fails, 1 failure. The LongDecay reach walk reads the handed law too. --- CHANGELOG.md | 7 +- lib/audioeffects/rebuilt/convolutionreverb.py | 56 ++++-- .../test_cpython_effects_convolutionreverb.py | 174 ++++++++++++++++-- 3 files changed, 199 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6f5fb..a99365a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,9 +65,10 @@ there, and are recorded in its changelog. synthesis macros with `IndexError`; no impulse ships. It lives in `lib/audioeffects/rebuilt/convolutionreverb.py`, and `audioeffects.ConvolutionReverb` is still the old class. The room holds - its level over both channels together, but Damping and Decay move a - stereo room's left-right balance by up to about 1.3 dB (2.6 dB at - 22.05 kHz); fixing that is a node change, drafted as an audiodsp ask. The + its level over both channels together (for material about equally loud + on both sides), but Room, Damping and Decay move a stereo room's + left-right balance by up to about +/-4.5 dB at every rate; fixing that + is a node change, drafted as an audiodsp ask. The docstring states that, the low-material lift a dark room gives, and the partition a room-knob move or a mid-stream `reset()` drops. Board cost is unmeasured; by the cost table's line the default fits an S3, and diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index e2713b3..0f93538 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -9,7 +9,9 @@ library serves until the board runner adopts this one. After the gate audit's round 1 the dossier carries a dated post-build revision (fix round 1, 2026-09-28): D6 claims the level of both channels pooled, and this -docstring says what that leaves out. +docstring says what that leaves out. Fix round 2 (the same day, after the +round-2 audit) corrected the left-right balance figure it gives, from +about 1.3 dB to about 4.5 dB, and three edges; the audio did not change. **What it sounds like.** A short room behind your dry signal. With nothing loaded the class synthesizes the room: noise under an exponential that @@ -27,11 +29,15 @@ ceiling of **512 partitions = 131 072 taps**, which is 2.730 s at 48 kHz, 2.972 s at 44.1 kHz and 5.944 s at 22.05 kHz. Outside those, construction raises `ValueError` naming this class, the taps, the rate and the limit. -The product is taken in double precision and `round` sends a half frame -to even, so a `seconds` a hair over half a frame past a partition edge -builds one partition fewer than exact arithmetic would: 0.08001041666666667 -s at 48 kHz is 3 840.5 + 7/2^48 frames exactly, and builds 3 840 taps, not -4 096. No floor or ceiling cell moves. +On a desktop the product is taken in double precision and `round` sends a +half frame to even, so a `seconds` a hair over half a frame past a +partition edge builds one partition fewer than exact arithmetic would: +0.08001041666666667 s at 48 kHz is 3 840.5 + 7/2^48 frames exactly, and +builds 3 840 taps, not 4 096. No floor or ceiling cell moves. A board's +float is single precision, and there a few `seconds` land on the other +side of a partition edge: in a single-precision emulation (not a board +run) 0.685 s at 44.1 kHz and 1.370 s at 22.05 kHz build 118 partitions +where a desktop builds 119. No floor, ceiling or default cell moves. Decay, Predelay and Diffusion are laws over what the allocation leaves (section 6): Decay is the T60, log from the node's 50 ms floor to `seconds - predelay`, so at its top the room reaches -60 dB exactly at the @@ -96,17 +102,24 @@ across the whole band, so with Damping in, low material comes back louder than it went in: a 220 / 277 / 330 Hz chord +3.73 dB at the default 6 kHz Damping and +13.28 dB at the 500 Hz stop. White-spectrum material -comes back at its own level, within 0.5 dB, at every setting, counting -both channels together. Each side on its own does not: the node scales -the room by the mean of its two sides' energies, so on a stereo room -Damping and Decay move the left-right balance by up to about 1.3 dB at -48 kHz (2.6 dB at 22.05 kHz) while the total holds. A mono room is one -side and holds. Damping clamps at 0.159 fs, +about equally loud on both sides comes back at its own level, within +0.5 dB, at every setting, counting both channels together. Material on +one side only does not: white noise hard left comes back 2.7 dB down and +hard right 1.8 dB up at 48 kHz with Decay 0, Damping 500 Hz and +Diffusion 0 (about 0.8 / 0.5 dB at Decay 1.0, Diffusion 0.5). Each side +on its own does not hold either: the node scales the room by the mean of +its two sides' energies, so on a stereo room the left-right balance moves +by up to about +/-4.5 dB at every rate while the total holds, and Room +moves it as much as Damping and Decay do (the widest measured, with +Damping at 500 Hz and Decay 0: -4.4 / +4.5 dB at 48 and 44.1 kHz, -4.2 / ++4.0 dB at 22.05 kHz). A mono room is one side and holds. Damping clamps +at 0.159 fs, under the point where the node's one-pole coefficient stops moving, so at 48 kHz every one of its 128 positions is a room of its own, while at 22.05 kHz the positions from 92 up (the 6 kHz default among them) are one 3 506 Hz room. A single Room's decay with Damping in can read up to 16 % -off the Decay law; the 64 Rooms' mean holds within 2 %. +off the Decay law on a stereo room and up to 22 % on a mono one (48 kHz, +Decay 0, Damping 500 Hz); the 64 Rooms' mean holds within 2 %. **Measured mode.** The impulse is trimmed by `start_ms` (int(start_ms * fs / 1000) frames, truncated) through a slice that copies @@ -121,9 +134,14 @@ ruling, 2026-09-08). An impulse is one-dimensional: a 2-D array (numpy's `(frames, channels)`) raises `TypeError`, so flatten it first. -**Two readbacks that are not what they look like.** `damping_hz` under -500 Hz is taken as 500 Hz, the span's bottom, with no error (0, or -7 500 Hz and up, is out of circuit). And a fresh instance reports +An empty impulse (`impulse=b""`) reports no taps and no latency, but its +node is built with one partition: measured mode's allocation starts at +one frame, and zero frames is that one partition. + +**Two readbacks that are not what they look like.** A `damping_hz` +between 0 and 500 Hz is taken as 500 Hz, the span's bottom, with no +error; 0 or below, or 7 500 Hz and up, is out of circuit (-100 hands the +node 0 Hz and `get_macro` reads 127). And a fresh instance reports `patch_index` 0, the family's convention, although the constructor's exact defaults (Damping 6 000 Hz, Mix 0.6) sit between grid steps and patch 0 is those settings on the grid (6 059.8 Hz, Mix 0.598). Pass `patch=0` for @@ -287,9 +305,9 @@ class ConvolutionReverb(_component.Component): in. audiodsp tier; 256 frames of latency whenever an impulse is loaded. **What the default surrenders:** a dark room lifts low material (a low - chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), Damping - and Decay move a stereo room's left-right balance by up to about - 1.3 dB (2.6 dB at 22.05 kHz) while the total holds, moving a room knob + chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), Room, + Damping and Decay move a stereo room's left-right balance by up to + about +/-4.5 dB at every rate while the total holds, moving a room knob or calling `reset()` mid-stream drops the 256 frames in flight, dry included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 4a361ea..ae0c2f0 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -22,6 +22,14 @@ capacity through `load()` rather than `node.taps` (`ExtraPartition` in measured mode), D5's law from the handed seconds (`AllocatedSeconds`), and D6's absolute clause |wet/dry| <= 0.5 dB (`HotRoom`, a constant +3 dB). + +Fix round 2 (gate audit round 2) took D5's law off the class's positions +too: the Decay and Predelay positions come from what the test handed the +instance (`handed_build`), with `DecayKeptSquared` and `DecayMidiSquared` +as the controls, red on the handed law at Decay 64/127 and green on the +law read back off `effect._macros`. It also pins two edges the docstring +now states: a negative `damping_hz` is out of circuit, and `impulse=b""` +holds one partition. """ import os @@ -152,6 +160,49 @@ def law_t60(decay_pos, predelay_pos, seconds): return 0.05 * (room / 0.05) ** decay_pos +#: Each shipped patch's Decay and Predelay MIDI, copied from dossier +#: section 6's patch table, so a law over a patch never reads the class. +PATCH_DECAY_PREDELAY_MIDI = { + 0: (127, 0), 1: (38, 13), 2: (127, 76), 3: (102, 25), + 4: (102, 0), 5: (127, 38), 6: (0, 25), 7: (127, 0), +} + + +def handed_build(cls=None, rate=RATE, channels=2, **options): + """`build`, keeping `effect.handed`: the [Decay, Predelay] positions + this test handed the instance, from the constructor's arguments, every + `set_macro` (MIDI / 127, both macros UNIPOLAR) and every + `program_change` (the dossier's patch MIDI). D5's law takes these, + never `effect._macros` or `get_macro` (fix round 2): a class that held + a position other than the one it was handed would move a law read back + from it (DecayKeptSquared, DecayMidiSquared, below).""" + effect = build(cls, rate, channels, **options) + handed = [float(options.get("decay", 1.0)), + float(options.get("predelay", 0.0))] + if options.get("patch") is not None: + handed[:] = [m / 127.0 for m in + PATCH_DECAY_PREDELAY_MIDI[options["patch"]]] + set_macro = effect.set_macro + program_change = effect.program_change + + def spy_set(index, value, *args, **kwargs): + set_macro(index, value, *args, **kwargs) + if index == DECAY_I: + handed[0] = value / 127.0 + elif index == PREDELAY_I: + handed[1] = value / 127.0 + + def spy_program(index, *args, **kwargs): + program_change(index, *args, **kwargs) + if index in PATCH_DECAY_PREDELAY_MIDI: + handed[:] = [m / 127.0 for m in PATCH_DECAY_PREDELAY_MIDI[index]] + + effect.set_macro = spy_set + effect.program_change = spy_program + effect.handed = handed + return effect + + def schroeder_t60(energy, rate): """T60 from the Schroeder curve of `energy`, fit over -5..-35 dB and extrapolated to -60 dB; None when the fit region is empty.""" @@ -426,6 +477,36 @@ def _refresh(self): ConvolutionReverb._refresh(self) +class DecayKeptSquared(ConvolutionReverb): + """D5's control for a law read back off the class's positions (fix + round 2): the constructor's Decay kept squared. 0 and 1 are fixed + points, so it is inert at Decay 1.0 and visible at 64/127: the + re-refuter read 1.907 % on a law from `effect._macros` (green) and + -12.781 % on the handed law at 48 kHz (-13.147 % at 22.05 kHz).""" + + NAME = NAME + + def _init_macros(self, values, patch=None): + values = list(values) + values[DECAY_I] = values[DECAY_I] ** 2 + ConvolutionReverb._init_macros(self, tuple(values), patch) + + +class DecayMidiSquared(ConvolutionReverb): + """The same control through `set_macro`: a Decay move is kept + squared.""" + + NAME = NAME + + def set_macro(self, index, value, channel=0, note_id=-1, + sample_position=0): + ConvolutionReverb.set_macro(self, index, value, channel, note_id, + sample_position) + if index == DECAY_I: + self._macros[DECAY_I] = self._macros[DECAY_I] ** 2 + self._apply_macro(DECAY_I, self._macros[DECAY_I]) + + class LongDecay(ConvolutionReverb): """D5 (1): the decay handed to the node 5 % long against the law.""" @@ -446,10 +527,11 @@ def reset(self): self.program_change(0) -def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2): +def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2, + builder=build): return kit_faults.fault_reachability( ConvolutionReverb, faulted, reading, - lambda cls: build(cls, rate, channels), tolerance=tolerance) + lambda cls: builder(cls, rate, channels), tolerance=tolerance) WALKED = 6 * len(GRID) + 8 @@ -616,6 +698,28 @@ def test_damping_under_the_span_is_the_span_bottom(self): effect = build(damping_hz=hz) self.assertAlmostEqual(effect._synthesis()[1], 500.0, places=6) effect.deinit() + # Fix round 2 (gate audit round 2, item 7b): a negative damping_hz + # is out of circuit, like 0, not the 500 Hz stop. + for hz in (-100.0, 0.0): + effect = build(damping_hz=hz) + self.assertEqual(effect._synthesis()[1], 0.0) + self.assertEqual(effect.get_macro(DAMPING_I), 127) + effect.deinit() + + def test_an_empty_impulse_holds_one_partition(self): + # Fix round 2 (item 7c): impulse=b"" reports taps 0 and latency 0 + # (D3's unloaded wire), and its node is built with one partition, + # so it accepts a 256-frame load. Measured mode's allocation starts + # at one frame; zero frames is this one partition. + effect = build(impulse=b"") + self.assertEqual(effect.node.taps, 0) + self.assertEqual(effect.latency_samples, 0) + self.assertEqual(effect.tail_samples, 0) + # Read directly, since `capacity()` starts at one partition. + effect.node.load(bytes(2 * 256), 1, 1.0) + with self.assertRaises(ValueError): + effect.node.load(bytes(2 * 257), 1, 1.0) + effect.deinit() def test_an_int16_array_is_trimmed_by_frames(self): from array import array @@ -1170,21 +1274,32 @@ def measure(cls): # -------------------------------------------------------------------------- class D5DecayLaw(unittest.TestCase): - def errors_over_rooms(self, cls=None, rate=RATE, **options): - # The law takes the seconds this test hands the constructor, never - # `effect.seconds`: a class that stretched its own allocation would - # move a law read back from it (AllocatedSeconds, below). + def errors_over_rooms(self, cls=None, rate=RATE, moves=(), + read_back=False, **options): + # The law takes the seconds and the Decay and Predelay positions + # this test handed the instance, never `effect.seconds` or + # `effect._macros`: a class that stretched its own allocation, or + # held a position other than the one it was handed, would move a + # law read back from it (AllocatedSeconds; DecayKeptSquared and + # DecayMidiSquared, below). `moves` are (macro, MIDI) handed after + # construction; `read_back=True` is the frozen reading, kept only + # to show the hole the handed law closes. seconds = options.get("seconds", 0.08) - effect = build(cls, rate, **options) + effect = handed_build(cls, rate, **options) + for index, midi in moves: + effect.set_macro(index, midi) errors = [] for room in range(0, 128, 2): effect.set_macro(ROOM_I, room) t60, floor = m5_cell(effect) self.assertTrue(floor, room) self.assertIsNotNone(t60, room) - law = law_t60(effect._macros[DECAY_I], effect._macros[PREDELAY_I], - seconds) - errors.append(t60 / law - 1.0) + if read_back: + decay, predelay = (effect._macros[DECAY_I], + effect._macros[PREDELAY_I]) + else: + decay, predelay = effect.handed + errors.append(t60 / law_t60(decay, predelay, seconds) - 1.0) effect.deinit() return np.array(errors) @@ -1208,14 +1323,13 @@ def test_damping_in_the_64_room_mean_within_2_percent(self): def test_every_patch_mean_within_2_percent_at_one_second(self): for patch in (1, 3, 5): - effect = build(seconds=1.0, patch=patch) + effect = handed_build(seconds=1.0, patch=patch) errors = [] for room in range(0, 128, 8): effect.set_macro(ROOM_I, room) t60, floor = m5_cell(effect) self.assertTrue(floor) - law = law_t60(effect._macros[DECAY_I], - effect._macros[PREDELAY_I], 1.0) + law = law_t60(effect.handed[0], effect.handed[1], 1.0) errors.append(t60 / law - 1.0) effect.deinit() self.assertLessEqual(abs(float(np.mean(errors))), 0.02, patch) @@ -1251,6 +1365,34 @@ def test_a_stretched_allocation_is_red_on_the_handed_law(self): effect.deinit() self.assertLessEqual(float(np.max(np.abs(read_back))), 0.03) + def test_a_decay_held_off_the_handed_position_is_red(self): + # Fix round 2 (gate audit round 2, item 4). At Decay 64/127, where + # squaring moves the position (0.504 -> 0.254), a class that keeps + # the constructor's Decay squared, or a set_macro Decay squared, + # is red on the law from the handed position and green on the law + # read back off the class: that is the hole the handed law closes. + for rate in (48000, 22050): + planted = self.errors_over_rooms(DecayKeptSquared, rate, + decay=64 / 127.0, + damping_hz=0.0, diffusion=0.0) + self.assertGreater(float(np.max(np.abs(planted))), 0.03, rate) + held = self.errors_over_rooms(DecayKeptSquared, rate, + read_back=True, decay=64 / 127.0, + damping_hz=0.0, diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + moved = self.errors_over_rooms(DecayMidiSquared, rate, + moves=((DECAY_I, 64),), + damping_hz=0.0, diffusion=0.0) + self.assertGreater(float(np.max(np.abs(moved))), 0.03, rate) + held = self.errors_over_rooms(DecayMidiSquared, rate, + moves=((DECAY_I, 64),), + read_back=True, damping_hz=0.0, + diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + clean = self.errors_over_rooms(rate=rate, moves=((DECAY_I, 64),), + damping_hz=0.0, diffusion=0.0) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + def test_stuck_dc_turns_the_floor_red(self): effect = build(StuckDcAfter) t60, floor = m5_cell(effect) @@ -1259,11 +1401,11 @@ def test_stuck_dc_turns_the_floor_red(self): def test_the_plants_are_not_on_the_surface(self): def handed(effect): - law = law_t60(effect._macros[DECAY_I], - effect._macros[PREDELAY_I], 0.08) + law = law_t60(effect.handed[0], effect.handed[1], 0.08) return effect._loaded[0] / law - result = reach(LongDecay, handed, tolerance=0.01) + result = reach(LongDecay, handed, tolerance=0.01, + builder=handed_build) self.assertAlmostEqual(result["target"], 1.05, places=9) self.assertAlmostEqual(result["clean"], 1.0, places=9) self.assertEqual(result["checked"], WALKED) From aec7a89d44292353623800762d4164c59c4bf665 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:22:02 -0500 Subject: [PATCH 47/88] PingPongDelay at audiodsp v0.6.3rc1: the out-stop cures and the stepping out The pin moved to v0.6.3rc1 with the merge of effects/phase5. Its node keeps an out low-pass's state on the signal and an out high-pass's at zero (#158, #159) and lands a low-pass that has stopped moving (#157), so three workarounds come out. Both loop filters' out stops hand exactly 0 whatever came before: no tracking Tone stop at 32 x the rate, and no Cut held in at the 20 Hz corner once it has been in, so tail_samples is finite again once Cut is out. With Repeat Tone in the Feedback is handed as set (0.99 played as 0.989976102), and the bound counts one landing lap at a window centre. Tone out after Tone 2 kHz renders the same bytes as Tone never in on 2 s of 0 dBFS noise at Feedback 0.85 and 0.99 (up to 6 LSB before), and at Time 20 ms through the whole tail (35-38 LSB before); Cut out after Cut the same bytes as Cut never in; the T3 cell with Tone in then out reads exact (5 samples, 5 LSB before). Three rates each. At the new pin one test was red before this change, the frozen-filter fault, which the node no longer freezes; it is restated as a filter left in at 0.001 Hz once it has been in. Nine tests are new or restated, each red on the class before this change, beside planted copies of the retired cures (tracking Tone, held Cut, stepping). The two whole-frame landing tests stay green: the node's float32 landing did not change. No digest moved: Station C's 24 cells, the cost tool's seven (the ones the boards matched) and the census's 13 are unchanged on CPython, MicroPython and CircuitPython. The stall cell and the Feedback-0.99 Tone corner's bounds grow by one lap. --- CHANGELOG.md | 10 +- lib/audioeffects/rebuilt/pingpongdelay.py | 109 ++---- tests/test_cpython_effects_pingpongdelay.py | 346 +++++++++++++++----- 3 files changed, 296 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8831c..9572ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,9 +85,13 @@ there, and are recorded in its changelog. `audioeffects.PingPongDelay` is still the old class. The docstring states the input ceiling (-3 dBFS peak at the defaults, -3.1 over the shipped patches, on `noise_det`; -7.96 dBFS on any material with Repeat Cut in at - the default Mix), that at full Spread the loop hears (L + R) / 2, and what - Repeat Tone's out stop costs after Tone has been in. Board cost is - unmeasured. + the default Mix), and that at full Spread the loop hears (L + R) / 2. + Board cost is unmeasured. At audiodsp v0.6.3rc1 both loop filters' out + stops hand exactly 0 and a filter taken out is out whatever came before + (no tracking Tone stop, no Cut held in at 20 Hz, so `tail_samples` is + finite once Cut is out again), and the Feedback is handed as set (no + stepping clear of the stall windows; the bound counts one landing lap + there). ### Changed diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index 0b6661f..0413ba1 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -47,10 +47,8 @@ that mono delay's output exactly, sample for sample, as long as no two repeats overlap one another: on sustained material that overlaps its own repeats each side rounds its own write where the mono delay rounds the sum -once, and the two part by a few LSB. That holds with Repeat Tone never in -since the last `reset()`; once it has been in, its out stop moves the -Feedback a hair (below), and at Feedback 0.99 the first eight repeats part -from a delay at the knob's Feedback in 5 samples by up to 5 LSB. With a +once, and the two part by a few LSB. That holds whatever the loop filters +did before: a filter taken out is out exactly (below). With a loop filter in, the material must also end some way before Time, because each side's filter meets that side's next repeat two Times later where the mono delay's meets the very next one: a noise burst ending one frame before @@ -166,31 +164,19 @@ clamp below Nyquist: at 22.05 kHz positions 111-126 all sit on the 10 804.5 Hz clamp and do the same thing. -**The filters' out stops, after a filter has been in.** The node leaves a -loop filter's state frozen while the filter is out, and a frozen filter -plays what it held when it comes back in, out of silence. So once Repeat -Tone has been in since the last `reset()` (a constructor `tone_hz` or a -patch counts), its out stop keeps the low-pass running at a coefficient of -exactly 1, which follows the repeats. The Feedback is still handed clear -of the stall window described under Tail, which at some Feedbacks moves it -by up to 2.6 x 10^-5 (0.99 plays as 0.989976102; 0.85 does not move), so -the repeats die a hair sooner than with the filter truly out. How much -depends on Time as well as Feedback. On 2 s of 0 dBFS noise at the default -280 ms it is 0 LSB at Feedback 0.85, 1-5 LSB at 0.5, 0.75, 0.9 and 0.95, -and 6 LSB at 0.99. Shorter Times fit more laps into the noise: over 18 -Times from 20 to 1000 ms, at 0.99 it is up to 19 LSB during the noise -(25.6 ms, 44.1 kHz; 16 LSB at 48 and 22.05 kHz), and at 0.95 up to 7 LSB. -Followed through the whole tail after the noise, at 0.99, it is up to -37 LSB at every one of those Times the node lands on the frame, the default -280 ms included, and more at the Times it lands off the frame (above): -50 and 49 LSB at MIDI 24 and 48 (44.1 kHz), 48 and 46 at MIDI 88 and 112 -(22.05 kHz), the four off-frame positions among those tried. No worst is -claimed off the frame. Repeat Cut cannot do that (a high-pass at -coefficient 1 mutes the loop), so once Repeat Cut has been in since the last `reset()` -its bottom stop stays in circuit at the 20 Hz corner, the knob's own -bottom, until the next `reset()`. That costs the low end of the repeats -something a true out would not, and `tail_samples` is `None` while it -lasts. A `reset()` brings both exact outs back. +**The filters' out stops, after a filter has been in.** Both out stops +hand the node exactly 0, and a filter taken out is out, whatever came +before: on 2 s of 0 dBFS noise at Mix 2, Tone out after Tone 2 kHz was in +renders the same bytes as Tone never in (Feedback 0.85 and 0.99), and Cut +out after Cut 400 Hz was in the same bytes as Cut never in (Feedback 0.99), +at 48, 44.1 and 22.05 kHz; a filter brought back in after the repeats have +died plays nothing. Up to audiodsp v0.6.2 the node froze an out filter's +state and played it back, and this class kept Tone's low-pass tracking the +repeats and Cut's high-pass in at 20 Hz once they had been in (the first up +to 50 LSB off the filter out at Feedback 0.99, the second with +`tail_samples` `None`). Since +v0.6.3rc1 the node keeps an out low-pass's state on the signal and an out +high-pass's at zero (audiodsp#158, #159), and both cures came out. Each pass through a loop filter also takes something off a repeat's peak, so with either filter in the late repeats of a quiet bounce fade faster @@ -202,11 +188,13 @@ its way down from full scale, 14 laps at the default Feedback (188 174 frames, 3.9 s, at 48 kHz) and 685 at 0.99 (11.4 minutes at Time 1000 ms). The cross-feed moves repeats between the sides without changing the loop -gain, so the figure is the same at every Spread. With Repeat Tone in the -node's loop low-pass can hold a small value for ever at a Feedback a hair -either side of 1 - 0.5 / k, so there the class hands the node a Feedback -just outside that window (under 3 x 10^-5 away, far inside one step of the -knob, which still reads what you set). With Repeat Cut in circuit +gain, so the figure is the same at every Spread. With Repeat Tone in, at a +Feedback a hair either side of 1 - 0.5 / k, the loop low-pass can come to +rest a hair above k LSB and hand it back; up to audiodsp v0.6.2 it did so +for ever and the class handed the node a Feedback just outside that +window. Since v0.6.3rc1 the node sets a stalled low-pass onto its input +(audiodsp#157), the Feedback you set is the one the node plays, and the +bound counts one more lap there. With Repeat Cut in circuit `tail_samples` is `None`: no bound is derived there. `capabilities = ("tempo_sync",)`: with Sync on, the class reads @@ -232,11 +220,11 @@ # the same way in both classes. Its module moves up one level when it comes # home, so both homes are tried; the same for SlapbackDelay's `tone_excess`. try: - from .digitaldelay import (DIVISION_BEATS, clear_of_stalls, laps_to_zero, - nominal_cut_hz, whole_frames) + from .digitaldelay import (DIVISION_BEATS, laps_to_zero, nominal_cut_hz, + whole_frames) except ImportError: # pragma: no cover - after it lands - from ..digitaldelay import (DIVISION_BEATS, clear_of_stalls, - laps_to_zero, nominal_cut_hz, whole_frames) + from ..digitaldelay import (DIVISION_BEATS, laps_to_zero, + nominal_cut_hz, whole_frames) try: from .slapbackdelay import tone_excess except ImportError: # pragma: no cover - after it lands @@ -268,13 +256,6 @@ CUT_MIN_HZ = 20.0 CUT_MAX_HZ = 400.0 -#: Repeat Tone out after it has been in: `damping_hz` at 32 x the rate, -#: where 1 - expf(-2 pi 32) is exactly 1.0f (`one_pole_coefficient`, -#: `audiodsp_feedback_delay.c:33-40`), so the loop low-pass's state follows -#: the tap instead of freezing (dossier section 8.11, `SlapbackDelay`'s -#: answer). -TONE_TRACK_PER_RATE = 32.0 - #: The fixed Time walk, delay-seconds per second: 3/16, exact in float32 #: (dossier section 8.5, `SlapbackDelay`'s section 8 answer 6). SLEW = 0.1875 @@ -415,11 +396,6 @@ def _build(self, time_ms=DEFAULT_TIME_MS, feedback=DEFAULT_FEEDBACK, self._damping = 0.0 self._cut = 0.0 self._node_ms = 0.0 - #: True once a loop filter has been handed an in-circuit corner - #: since the node was built or cleared. From then on its state is - #: live, and its out stop is not 0 (dossier section 8.11). - self._tone_used = False - self._cut_used = False #: The constructor's Time, exactly, until macro 0 moves. Seeding a #: log knob and reading it back is not exact, and a few ulps under a #: half frame lands one frame short. @@ -482,8 +458,6 @@ def _build(self, time_ms=DEFAULT_TIME_MS, feedback=DEFAULT_FEEDBACK, def _clear(self): self._delay.clear() self._fresh = True - self._tone_used = False - self._cut_used = False # -- the maps ------------------------------------------------------ @@ -608,30 +582,13 @@ def _refresh(self): self._node_ms = self._node_time_ms(self._frames) if self._fresh or self._frames > self._reach: self._reach = self._frames + # Both out stops are exactly 0, and the Feedback is handed as set. + # Since audiodsp v0.6.3rc1 the node keeps an out low-pass's state on + # the tap and an out high-pass's at zero (#158, #159), and lands a + # low-pass that has stopped moving (#157). damping = self._tone_damping(self._macros[TONE_I]) - if damping > 0.0: - self._tone_used = True - elif self._tone_used: - # The node updates its loop low-pass only while the coefficient - # is above 0 (`audiodsp_feedback_delay.c:493`), so handing 0 - # after Tone has been in would freeze whatever the filter held. - # A coefficient of exactly 1 keeps the state on the tap instead. - damping = TONE_TRACK_PER_RATE * rate cut = self._cut_hz(self._macros[CUT_I]) - if cut > 0.0: - self._cut_used = True - elif self._cut_used: - # The same freeze for the high-pass (`:498`), which outputs - # value - state and so would mute the loop at coefficient 1: - # once Cut has been in, its bottom stop is the 20 Hz corner. - cut = nominal_cut_hz(self._hz(CUT_MIN_HZ), rate) feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) - if damping > 0.0 and feedback > 0.0: - # With Tone in, the node can hold a small value for ever at a - # Feedback a hair either side of 1 - 0.5 / k; the node is handed - # the nearer edge of that window. - feedback = self._loop_feedback(feedback, - tone_excess(damping, rate)[1]) self._feedback = feedback self._damping = damping self._cut = cut @@ -646,10 +603,6 @@ def _refresh(self): cross_feed=cross, input_pan=pan) - def _loop_feedback(self, feedback, excess): - """The Feedback handed to the node with Repeat Tone in circuit.""" - return clear_of_stalls(feedback, excess) - @property def tail_samples(self): """Frames until the output is exactly zero once the input stops, as @@ -673,6 +626,4 @@ def _tail_bound(self): return None memory, excess = tone_excess(self._damping, self._sample_rate) laps = laps_to_zero(self._feedback, excess) - if laps is None: # pragma: no cover - stepped clear - return None return int(laps * (self._reach + 1 + memory)) diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index c4c25fd..71e3604 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -38,6 +38,8 @@ from audioeffects import rebuilt # noqa: E402 from audioeffects.chorus import nominal_damping_hz # noqa: E402 from audioeffects.rebuilt import pingpongdelay as pp # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls) from tools.effect_measurements import instantaneous_hz # noqa: E402 VENDOR = "PyDevices" @@ -232,15 +234,90 @@ def _refresh(self): class FrozenFilterPingPong(PingPongDelay): - """Section 8.11, as first frozen: each loop filter's out stop hands 0 - whatever came before, which freezes the node's filter state.""" + """Section 8.11's defect, restated at audiodsp v0.6.3rc1: once a loop + filter has been in, its out stop leaves it in at 0.001 Hz, where its + float32 coefficient is one step above 0 and its state cannot move, so + it holds what it held, as the node's out stop did up to v0.6.2, and + plays it back when the filter comes back in.""" NAME = 'PingPongDelay' def _refresh(self): - self._tone_used = False - self._cut_used = False PingPongDelay._refresh(self) + if self._damping > 0.0: + self._tone_was_in = True + elif getattr(self, "_tone_was_in", False): + self._delay.set(damping_hz=0.001) + if self._cut > 0.0: + self._cut_was_in = True + elif getattr(self, "_cut_was_in", False): + self._delay.set(cut_hz=0.001) + + def _clear(self): + PingPongDelay._clear(self) + self._tone_was_in = self._cut_was_in = False + + +class SteppedPingPong(PingPongDelay): + """The workaround retired at audiodsp v0.6.3rc1: with Repeat Tone in, + the Feedback handed at the nearer edge of the stall window + (`clear_of_stalls`), a Feedback nobody set.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + self._step() + + def _step(self): + if self._damping > 0.0 and self._feedback > 0.0: + excess = pp.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._delay.set(feedback=stepped) + + +class TrackingPingPong(SteppedPingPong): + """The class before audiodsp v0.6.3rc1: once Repeat Tone has been in, + its out stop hands `damping_hz` at 32 x the rate (the low-pass tracking + the tap), and the Feedback is stepped clear as with Tone in.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + if self._damping > 0.0: + self._tone_was_in = True + elif getattr(self, "_tone_was_in", False): + self._damping = 32.0 * self._sample_rate + self._delay.set(damping_hz=self._damping) + self._step() + + def _clear(self): + PingPongDelay._clear(self) + self._tone_was_in = False + + +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.""" + + NAME = 'PingPongDelay' + + def _refresh(self): + PingPongDelay._refresh(self) + if self._cut > 0.0: + self._cut_was_in = True + elif getattr(self, "_cut_was_in", False): + self._cut = pp.nominal_cut_hz(self._hz(pp.CUT_MIN_HZ), + self._sample_rate) + self._delay.set(cut_hz=self._cut) + + def _clear(self): + PingPongDelay._clear(self) + self._cut_was_in = False # -------------------------------------------------------------------------- @@ -781,12 +858,12 @@ def build(cls): build, grid=grid) -def spied(**options): +def spied(cls=None, **options): rate = options.pop("rate", RATE) channels = options.pop("channels", 2) with NodeSpy(): - effect = PingPongDelay(silence_src(512, channels, rate), - sample_rate=rate, **options) + effect = (cls or PingPongDelay)(silence_src(512, channels, rate), + sample_rate=rate, **options) return effect @@ -1001,35 +1078,47 @@ def test_the_filter_stops(self): self.assertAlmostEqual(handed(effect, "damping_hz"), nominal_damping_hz(corner, rate), places=6) - # Out after in: 32 x the rate, the state on the tap. + # Out after in is out: since audiodsp v0.6.3rc1 the node keeps + # an out filter's state live (#158, #159), so both out stops + # hand exactly 0 whatever came before, and the bound is finite. effect.set_macro(TONE_I, 127) - self.assertEqual(handed(effect, "damping_hz"), 32.0 * rate) + self.assertEqual(handed(effect, "damping_hz"), 0.0) effect.set_macro(CUT_I, 1) - in_circuit = handed(effect, "cut_hz") - self.assertGreater(in_circuit, 0.0) - # Out after in: the 20 Hz corner, pre-warped, still in circuit. - effect.set_macro(CUT_I, 0) - bottom = handed(effect, "cut_hz") - self.assertAlmostEqual(bottom, - pp.nominal_cut_hz(20.0, rate), places=9) - self.assertLess(bottom, in_circuit) + self.assertGreater(handed(effect, "cut_hz"), 0.0) self.assertIsNone(effect.tail_samples) - # A reset brings both exact outs back. - effect.reset() - self.assertEqual(handed(effect, "damping_hz"), 0.0) + effect.set_macro(CUT_I, 0) self.assertEqual(handed(effect, "cut_hz"), 0.0) self.assertIsNotNone(effect.tail_samples) - - def test_a_constructor_filter_counts_as_having_been_in(self): - effect = spied(tone_hz=5000.0, cut_hz=100.0) - effect.program_change(0) - self.assertEqual(handed(effect, "damping_hz"), 32.0 * RATE) - self.assertAlmostEqual(handed(effect, "cut_hz"), + # Planted: the retired cures (Tone tracking at 32 x the rate, + # Cut held in at the 20 Hz corner with no bound). + for cls, name, want in ( + (TrackingPingPong, "damping_hz", 32.0 * rate), + (HeldCutPingPong, "cut_hz", + pp.nominal_cut_hz(20.0, rate))): + old = spied(cls, rate=rate) + old.set_macro(TONE_I, 126) + old.set_macro(TONE_I, 127) + old.set_macro(CUT_I, 1) + old.set_macro(CUT_I, 0) + self.assertAlmostEqual(handed(old, name), want, places=9) + + def test_a_constructor_filter_out_again_is_out(self): + # A constructor or patch filter, then a patch without it: both out + # stops hand exactly 0 since audiodsp v0.6.3rc1 (up to v0.6.2 they + # counted as having been in and handed the tracking stop and the + # 20 Hz corner). Planted: the retired cures. + for ctor in ({"tone_hz": 5000.0, "cut_hz": 100.0}, {"patch": 5}): + effect = spied(**ctor) + effect.program_change(0) + self.assertEqual(handed(effect, "damping_hz"), 0.0, ctor) + self.assertEqual(handed(effect, "cut_hz"), 0.0, ctor) + old = spied(TrackingPingPong, **ctor) + old.program_change(0) + self.assertEqual(handed(old, "damping_hz"), 32.0 * RATE, ctor) + old = spied(HeldCutPingPong, cut_hz=100.0) + old.program_change(0) + self.assertAlmostEqual(handed(old, "cut_hz"), pp.nominal_cut_hz(20.0, RATE), places=9) - effect = spied(patch=5) - effect.program_change(0) - self.assertEqual(handed(effect, "damping_hz"), 32.0 * RATE) - self.assertEqual(handed(effect, "cut_hz"), 0.0) def test_repeat_tone_clamps_at_22k(self): effect = spied(rate=22050) @@ -1059,6 +1148,39 @@ def test_tail_samples_follows_time_feedback_and_tone(self): for patch in (3, 4, 6): self.assertEqual(spied(patch=patch).tail_samples, 190064) self.assertIsNone(spied(cut_hz=40.0).tail_samples) + # 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. + effect = spied(cut_hz=40.0) + effect.set_macro(CUT_I, 0) + self.assertEqual(handed(effect, "cut_hz"), 0.0) + self.assertEqual(effect.tail_samples, 14 * 13441) + held = HeldCutPingPong(silence_src(64), sample_rate=RATE, + cut_hz=40.0) + held.set_macro(CUT_I, 0) + self.assertIsNone(held.tail_samples) + + def test_the_feedback_is_handed_as_set(self): + # Up to audiodsp v0.6.2 the class stepped the Feedback clear of the + # loop low-pass's stall windows with Repeat Tone in (0.99 played as + # 0.989976102). Since v0.6.3rc1 the node lands a stalled low-pass + # (#157): with Tone in or out every Feedback position is handed as + # set, and the bound is finite. Planted: the retired stepping. + for rate in RATES: + for tone in (0.0, 800.0, 16000.0): + effect = spied(rate=rate, tone_hz=tone) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + want = min(0.99, effect._value(FEEDBACK_I)) + self.assertEqual(effect._feedback, want, + (rate, tone, midi)) + self.assertEqual(handed(effect, "feedback"), want) + self.assertIsNotNone(effect.tail_samples) + stepped = SteppedPingPong(silence_src(64, rate=rate), + sample_rate=rate, tone_hz=800.0, + feedback=0.99) + self.assertNotEqual(stepped._feedback, 0.99, rate) + self.assertLess(abs(stepped._feedback - 0.99), 3e-5) def test_constructor_clamps_and_nan(self): nan = float("nan") @@ -1285,27 +1407,35 @@ def test_the_gap_grows_as_repeat_cut_goes_down(self): at_8192 = self._gap(8192, 32767, {}, cut=cut, macros=macros) self.assertEqual((at_8192[0], at_8192[3]), (0, 0)) - def test_tone_in_then_out_is_outside_the_row(self): - # Fix round 1 (audit item 2, restated): once Tone has been in, its - # out stop hands a Feedback moved clear of the stall window - # (0.99 -> 0.989976102), so the row holds Tone never in since the - # last reset. Its own cell with Tone in then out differs in 5 - # samples, up to 5 LSB, at three rates. + def _tone_in_then_out(self, cls, rate): + """(samples differing, largest difference) between the channels' + sum with Tone 5 kHz in then out and the mono reference at + Feedback 0.99, on a click, Time 280 ms, Mix 2.""" + T = law_frames(280.0, rate) + frames = 9 * T + int(0.1 * rate) + x = click(frames) + src, _ = to_source(x, 2, rate) + effect = cls(src, sample_rate=rate, time_ms=280.0, feedback=0.99, + mix=2.0, tone_hz=5000.0) + effect.set_macro(TONE_I, 127) + stereo = pull(effect, frames).astype(np.int32) + total = stereo[:, 0] + stereo[:, 1] + ref = reference(x, rate, T, 0.99) + return (int(np.count_nonzero(total != ref)), + int(np.abs(total - ref).max())) + + def test_tone_in_then_out_is_inside_the_row(self): + # Fix round 1 put this cell outside the row: the out stop after + # Tone had been in handed a Feedback stepped clear of the stall + # window (0.99 -> 0.989976102), 5 samples up to 5 LSB off. Since + # audiodsp v0.6.3rc1 Tone out is out whatever came before, and the + # cell reads exact at three rates. Planted: the retired tracking + # stop with its stepping. for rate in RATES: - T = law_frames(280.0, rate) - frames = 9 * T + int(0.1 * rate) - x = click(frames) - src, _ = to_source(x, 2, rate) - effect = PingPongDelay(src, sample_rate=rate, time_ms=280.0, - feedback=0.99, mix=2.0, tone_hz=5000.0) - effect.set_macro(TONE_I, 127) - stereo = pull(effect, frames).astype(np.int32) - total = stereo[:, 0] + stereo[:, 1] - ref = reference(x, rate, T, 0.99) - self.assertEqual(int(np.count_nonzero(total != ref)), 5, rate) - self.assertEqual(int(np.abs(total - ref).max()), 5, rate) - self.assertTrue(t3_measure(PingPongDelay, rate, - feedback=0.99)["passed"], rate) + self.assertEqual(self._tone_in_then_out(PingPongDelay, rate), + (0, 0), rate) + self.assertEqual(self._tone_in_then_out(TrackingPingPong, rate), + (5, 5), rate) def test_the_mono_stereo_settings_are_red(self): for rate in RATES: @@ -1533,19 +1663,20 @@ def test_a_filter_back_in_after_silence_stays_silent(self): PingPongDelay, TONE_I, tone_2k, 0, rate, channels), 0) self.assertEqual(self._round_trip( PingPongDelay, CUT_I, 127, 1, rate, channels), 0) - # As first frozen, handing 0 at the out stop plays the frozen state. + # Planted: a filter whose state cannot move (left in at 0.001 Hz, + # as the node's out stop behaved up to v0.6.2) plays it back. self.assertGreater(self._round_trip( FrozenFilterPingPong, TONE_I, tone_2k, 0, RATE), 10000) self.assertGreater(self._round_trip( FrozenFilterPingPong, CUT_I, 127, 1, RATE), 15000) - def _tone_out_after_in(self, x, rate, **options): + def _tone_out_after_in(self, x, rate, cls=PingPongDelay, **options): """Largest |difference| between Tone out after Tone 2 kHz was in - and Tone never in, on `x`, Mix 2.""" + (on `cls`) and Tone never in, on `x`, Mix 2.""" frames = len(x) source, _ = to_source(x, 2, rate) - touched = PingPongDelay(source, sample_rate=rate, mix=2.0, - tone_hz=2000.0, **options) + touched = cls(source, sample_rate=rate, mix=2.0, tone_hz=2000.0, + **options) touched.set_macro(TONE_I, 127) clean, _ = to_source(x, 2, rate) plain = PingPongDelay(clean, sample_rate=rate, mix=2.0, **options) @@ -1553,29 +1684,25 @@ def _tone_out_after_in(self, x, rate, **options): b = pull(plain, frames).astype(np.int32) return int(np.abs(a - b).max()) - def test_tone_out_after_tone_in_is_within_the_stated_bound(self): - # Fix round 1 (audit item 2, restated): the out stop's - # coefficient-1 low-pass follows the tap, but the Feedback still - # goes through `clear_of_stalls`, which moves it by up to - # 2.6 x 10^-5 where a stall window sits (0.99 -> 0.989976102; - # 0.85 is in no window). At the default 280 ms: 0 at 0.85, at most - # 6 LSB on 2 s of 0 dBFS noise at 0.99. The old "within 1 LSB" is - # red at 0.99. + def test_tone_out_after_tone_in_is_the_filter_out(self): + # Up to audiodsp v0.6.2 the out stop after Tone had been in kept + # the low-pass tracking the tap and the Feedback stepped clear of + # the stall windows: 0 at 0.85, up to 6 LSB at 0.99 on 2 s of + # 0 dBFS noise, and at Time 20 ms up to 19 LSB during the noise and + # 37 through the tail. Since v0.6.3rc1 (#158, #157) it hands 0, and + # every one of those cells renders the same bytes as Tone never in. + # Planted: the retired tracking stop with its stepping (35 LSB + # through the 20 ms tail on the fixed node; 37 on v0.6.2's). for rate in RATES: frames = 2 * rate x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, channels=1), dtype=np.int16)[:frames].astype(float) - self.assertEqual(self._tone_out_after_in(x, rate, - feedback=0.85), 0, rate) - worst = self._tone_out_after_in(x, rate, feedback=0.99) - self.assertLessEqual(worst, 6, rate) - self.assertGreater(worst, 1, rate) - # Fix round 2 (audit round 2, item 3): those figures are the - # default Time's. At Time 20 ms more laps fit into the noise, and - # the docstring's worst over Time is 19 LSB during the noise and, - # at a Time on the frame (20 ms at 48 kHz is), 37 LSB through the - # tail after it; the old 6 and 32 are exceeded. + for feedback in (0.85, 0.99): + self.assertEqual(self._tone_out_after_in( + x, rate, feedback=feedback), 0, (rate, feedback)) + self.assertGreater(self._tone_out_after_in( + x, rate, cls=TrackingPingPong, feedback=0.99), 1, rate) n = 2 * RATE probe = PingPongDelay(silence_src(64), sample_rate=RATE, time_ms=20.0, feedback=0.99) @@ -1585,20 +1712,65 @@ def test_tone_out_after_tone_in_is_within_the_stated_bound(self): x[:n] = np.frombuffer(probes.noise_det(frames=n, dbfs=0.0, channels=1), dtype=np.int16)[:n] - source, _ = to_source(x, 2, RATE) - touched = PingPongDelay(source, sample_rate=RATE, mix=2.0, - time_ms=20.0, feedback=0.99, tone_hz=2000.0) - touched.set_macro(TONE_I, 127) - clean, _ = to_source(x, 2, RATE) - plain = PingPongDelay(clean, sample_rate=RATE, mix=2.0, time_ms=20.0, - feedback=0.99) - d = np.abs(pull(touched, frames).astype(np.int32) - - pull(plain, frames).astype(np.int32)).max(axis=1) - during, after = int(d[:n].max()), int(d[n:].max()) - self.assertLessEqual(during, 19) - self.assertGreater(during, 6) - self.assertLessEqual(after, 37) - self.assertGreater(after, 32) + for cls, worst in ((PingPongDelay, 0), (TrackingPingPong, 35)): + source, _ = to_source(x, 2, RATE) + touched = cls(source, sample_rate=RATE, mix=2.0, time_ms=20.0, + feedback=0.99, tone_hz=2000.0) + touched.set_macro(TONE_I, 127) + clean, _ = to_source(x, 2, RATE) + plain = PingPongDelay(clean, sample_rate=RATE, mix=2.0, + time_ms=20.0, feedback=0.99) + d = np.abs(pull(touched, frames).astype(np.int32) + - pull(plain, frames).astype(np.int32)).max() + self.assertEqual(int(d), worst, cls.__name__) + + def test_cut_out_after_cut_in_is_the_filter_out(self): + # Up to audiodsp v0.6.2 Cut's bottom stop stayed in at 20 Hz once + # Cut had been in. Since v0.6.3rc1 (#159) it hands 0, and 2 s of + # 0 dBFS noise at Feedback 0.99 renders the same bytes as Cut never + # in, at three rates. Planted: the retired held corner. + for rate in RATES: + frames = 2 * rate + x = np.frombuffer(probes.noise_det(frames=frames, dbfs=0.0, + channels=1), + dtype=np.int16)[:frames].astype(float) + for cls, differs in ((PingPongDelay, False), + (HeldCutPingPong, True)): + source, _ = to_source(x, 2, rate) + touched = cls(source, sample_rate=rate, mix=2.0, + feedback=0.99, cut_hz=400.0) + touched.set_macro(CUT_I, 0) + clean, _ = to_source(x, 2, rate) + plain = PingPongDelay(clean, sample_rate=rate, mix=2.0, + feedback=0.99) + d = int(np.abs(pull(touched, frames).astype(np.int32) + - pull(plain, frames).astype(np.int32)).max()) + self.assertEqual(d > 0, differs, (rate, cls.__name__, d)) + + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 with Repeat Tone 800 Hz in is a stall centre (k = 1): + # up to audiodsp v0.6.2 the node could hold 1 LSB there for ever and + # the class stepped the Feedback clear. Since v0.6.3rc1 (#157) 0.5 + # is handed as set, and a 2 LSB DC for 1 s ends inside the bound. + for rate in RATES: + frames = 4 * rate + x = np.zeros(frames) + x[:rate] = 2.0 + source, _ = to_source(x, 2, rate) + effect = PingPongDelay(source, sample_rate=rate, feedback=0.5, + mix=2.0, time_ms=100.0, tone_hz=800.0) + self.assertEqual(effect._feedback, 0.5) + declared = effect.tail_samples + y = pull(effect, frames) + nonzero = np.flatnonzero(y.any(axis=1)) + self.assertGreater(len(nonzero), 0, rate) + last = int(nonzero[-1]) + self.assertLess(last, frames - 1, rate) + self.assertLessEqual(last - rate + 1, declared, rate) + stepped = SteppedPingPong(silence_src(64), sample_rate=RATE, + feedback=0.5, tone_hz=800.0) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) def test_the_tail_reaches_exact_zero_inside_tail_samples(self): on = 200 * RATE // 1000 From ba7948b3b3dadd367308f9f5e74bd9d13657c4e4 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:15:49 -0500 Subject: [PATCH 48/88] AnalogDelay at audiodsp v0.6.3rc1: the stepping out, the Modulation step gone The pin moved to v0.6.3rc1 with the merge of effects/phase5. Its node lands a loop low-pass that has stopped moving (#157), so the Feedback is handed as set: nothing is stepped clear of the stall windows (only knob position 127 ever moved, and no patch), and the bound counts one landing lap there. The node also ramps a new wow depth over 20 ms (#160), and the class hands its triangle once, so a Modulation move now glides: 1 -> 1.5 ms reads at most 1522 LSB against the tone's own 1491 and the ramp's allowance of 1528, where v0.6.2 read 7337. The docstring says so; the Time glide, its walk floor and its slow-settle disclosure are untouched, as Brad ruled. At the new pin one test was red before this change, the raw-Feedback stall test, whose planted raw Feedback now ends. It is restated to the stall cell reaching zero at 0.5 as set, and the tail-everywhere test to the Feedback handed as set; both are red on the class before this change beside the retired stepping, planted. New: a Modulation move does not step, beside a planted read head moved by the whole change. No digest moved: Station C's 48 cells, the cost tool's seven and the census's 13 are unchanged on CPython, MicroPython and CircuitPython. The stall cell, the Feedback-0.9 cell and the 0.99 corner's bounds grow by one lap. --- CHANGELOG.md | 5 +- lib/audioeffects/rebuilt/analogdelay.py | 43 +++++---- tests/test_cpython_effects_analogdelay.py | 107 +++++++++++++++++++--- 3 files changed, 119 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0c634..65e3b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,7 +84,10 @@ there, and are recorded in its changelog. `audioeffects.AnalogDelay` is still the old class. The docstring states the input ceiling (-4.4 dBFS peak at the default Mix 0.4) and that turning Time through several positions takes seconds to settle. Board - cost is unmeasured. + cost is unmeasured. At audiodsp v0.6.3rc1 the Feedback is handed as set + (no stepping clear of the stall windows; the bound counts one landing lap + there), and a Modulation move glides over 20 ms instead of stepping the + read offset. ### Changed diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index 42354dd..57c0925 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -125,10 +125,17 @@ class cannot see the head. A knob turned through several positions sends bends equally at every Time (+-20.7 cents at patch 3's 3 ms and 1 Hz, +257 / -302 at the stops). The shape is a plain triangle, the clock law's first order; it differs from the exact reciprocal by up to S / T of the -swing (1.7 % at 300 ms and full depth, 25 % at 20 ms). **A Modulation move -does step the read offset**, by the change in depth times where the -triangle stands: 5 -> 2 ms at the triangle's peak jumps the read 142 -frames at 48 kHz. Mod Rate moves keep the triangle's phase and do not step. +swing (1.7 % at 300 ms and full depth, 25 % at 20 ms). A Modulation move +glides: since audiodsp v0.6.3rc1 the node ramps a new swing in over 20 ms +(audiodsp#160), where up to v0.6.2 it jumped the read by the change in +depth times where the triangle stood (142 frames for 5 -> 2 ms at the +triangle's peak, 48 kHz). While the swing travels the extra pitch is the +change over 20 ms times where the triangle stands: 5 -> 2 ms at the peak +bends the repeats 15 % (about 240 cents) for those 20 ms. On a 997 Hz tone +at 12 000 LSB, wet only, Time 300 ms, Mod Rate 1 Hz, 48 kHz, a move from +1 to 1.5 ms steps at most 1 522 LSB over the 2 000 frames after it, where +the tone's own largest step is 1 491 and the ramp allows 1 528 (7 337 at +v0.6.2). Mod Rate moves keep the triangle's phase and do not step. **Input ceiling.** The dry path sits at unity and the repeats add to it, so a hot input can put the output on the int16 rail; there is no input @@ -143,10 +150,12 @@ class cannot see the head. A knob turned through several positions sends Feedback the node is handed, each lap the longest delay the head may be at plus the swing, one frame for the interpolated read and the low-pass's memory. 187 954 frames (3.9 s) at the defaults; 26.5 s at patch 5, the -longest. The loop low-pass is always in, and the node can hold a small -value for ever at a Feedback a hair either side of 1 - 0.5 / k, so the -class hands the node the nearer edge of that window instead, at most -3 x 10^-5 from the Feedback you set, which the knob still reads. After a +longest. The loop low-pass is always in, and at a Feedback a hair either +side of 1 - 0.5 / k it can come to rest a hair above k LSB and hand it +back. Up to audiodsp v0.6.2 it did so for ever, and the class handed the +node the nearer edge of that window instead. Since v0.6.3rc1 the node sets +a stalled low-pass onto its input (audiodsp#157), the Feedback you set is +the one the node plays, and the bound counts one more lap there. After a falling Time move the bound keeps the Time the head walked from until a reset, because the class cannot see how far the walk has got. @@ -177,11 +186,9 @@ class hands the node the nearer edge of that window instead, at most # the same node rounds the same way in both classes. Its module moves up # one level when it comes home, so both homes are tried. try: - from .digitaldelay import (DIVISION_BEATS, clear_of_stalls, laps_to_zero, - whole_frames) + from .digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames except ImportError: # pragma: no cover - after it lands - from ..digitaldelay import (DIVISION_BEATS, clear_of_stalls, - laps_to_zero, whole_frames) + from ..digitaldelay import DIVISION_BEATS, laps_to_zero, whole_frames try: import audioecho @@ -584,12 +591,10 @@ def _refresh(self): self._corner = self._hz(self._corner_for(clamped)) self._damping = nominal_damping_hz(self._corner, fs) - feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) - # The loop low-pass is always in, and the node can hold a small value - # for ever at a Feedback a hair either side of 1 - 0.5 / k; the node - # is handed the nearer edge of that window instead. - self._feedback = clear_of_stalls( - feedback, tone_excess(self._damping, fs)[1]) + # Handed as set: since audiodsp v0.6.3rc1 the node lands a loop + # low-pass that has stopped moving (#157), so no Feedback holds a + # small value for ever and nothing is stepped clear here. + self._feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) self._swing_ms = _between(self._value(MODULATION_I), 0.0, SWING_MAX_MS) @@ -625,7 +630,5 @@ def _tail_bound(self): `fget`.""" memory, excess = tone_excess(self._damping, self._sample_rate) laps = laps_to_zero(self._feedback, excess) - if laps is None: # pragma: no cover - stepped clear - return None swing = int(math.ceil(self._swing_ms * self._sample_rate / 1000.0)) return int(laps * (self._reach + swing + 1 + memory)) diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index fadb767..368964a 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -47,6 +47,8 @@ class built as a wire. T1 is unmeasured and T2b, T4 and T5 are disconfirmed from audioeffects import rebuilt # noqa: E402 from audioeffects.chorus import nominal_damping_hz # noqa: E402 from audioeffects.rebuilt import analogdelay as ad # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls) from tools import effect_measurements as kit # noqa: E402 VENDOR = "PyDevices" @@ -363,16 +365,38 @@ def _tail_bound(self): return AnalogDelay._tail_bound(self) // 2 -class RawFeedback(AnalogDelay): - """Tail: the Feedback handed as set, inside a stall window.""" +class SteppedAnalog(AnalogDelay): + """The workaround retired at audiodsp v0.6.3rc1: the Feedback handed + to the node at the nearer edge of the loop low-pass's stall window + (`clear_of_stalls`), a Feedback nobody set. (Up to v0.6.2 the fault + here was the opposite, `RawFeedback`: the Feedback handed as set, which + held 1 LSB for ever inside a window.)""" NAME = 'AnalogDelay' def _refresh(self): AnalogDelay._refresh(self) - feedback = min(0.99, self._value(FEEDBACK_I)) - self._feedback = feedback - self._delay.set(feedback=feedback) + excess = ad.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._delay.set(feedback=stepped) + + +class JumpModAnalog(AnalogDelay): + """A Modulation move that moves the read head by the whole change in + swing at once, as the node did at the triangle's peak up to v0.6.2 (it + added depth x triangle with no ramp).""" + + NAME = 'AnalogDelay' + + def _refresh(self): + old = self._swing_ms + AnalogDelay._refresh(self) + if (not getattr(self, "_seeding", False) + and not self._deferred and self._swing_ms != old): + self._delay.set(delay_slew=0.0, + delay_ms=self._node_ms + self._swing_ms - old) class PerMacroPatch(AnalogDelay): @@ -1315,8 +1339,16 @@ def test_the_tail_is_finite_everywhere_the_knobs_go(self): self.assertIsNotNone(effect.tail_samples, (character, rate, time_midi, fb_midi)) - self.assertLess(abs(effect._feedback - - 0.99 * fb_midi / 127.0), 3e-5) + # Handed as set since audiodsp v0.6.3rc1 (#157); + # it was stepped clear, 3 x 10^-5 at most. + self.assertEqual(effect._feedback, + min(0.99, effect._value(FEEDBACK_I))) + # Planted: the retired stepping moves the 0.99 stop. + stepped = SteppedAnalog(silence_src(64, 2, rate), + sample_rate=rate, + character=character, feedback=0.99) + self.assertNotEqual(stepped._feedback, 0.99) + self.assertLess(abs(stepped._feedback - 0.99), 3e-5) def test_constructor_edges(self): effect = AnalogDelay(silence_src(64), sample_rate=RATE, time_ms=0.0, @@ -1923,21 +1955,66 @@ def test_full_scale_meets_the_bound(self): self.assertTrue(result["passed"], (options, result)) self.assertGreater(result["last"], 0) - def test_a_raw_feedback_in_a_stall_window_never_ends(self): + def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): # Feedback 0.5 at 600 ms, where the low-pass is slow enough to rest - # a hair above 1 LSB: handed raw, 1 LSB goes round for ever on a - # 2 LSB DC; stepped clear of the window, it ends inside the bound. + # a hair above 1 LSB. Up to audiodsp v0.6.2, handed raw, 1 LSB went + # round for ever on a 2 LSB DC, and the class stepped the Feedback + # clear. Since v0.6.3rc1 (#157) 0.5 is handed as set and the tail + # ends inside the bound. Planted: the retired stepping. probe = AnalogDelay(silence_src(64), sample_rate=RATE, time_ms=600.0, feedback=0.5, mix=2.0) + self.assertEqual(probe._feedback, 0.5) declared = probe.tail_samples fill = 4 * 28800 // BLOCK * BLOCK values = [2] * fill + [0] * (declared + RATE) - for cls, ends in ((AnalogDelay, True), (RawFeedback, False)): + effect = AnalogDelay(array_src(values, 1), sample_rate=RATE, + time_ms=600.0, feedback=0.5, mix=2.0) + out = pull(effect, len(values), 1) + nonzero = np.flatnonzero(out) + self.assertGreater(len(nonzero), 0) + self.assertLessEqual(int(nonzero[-1]) - fill + 1, declared) + stepped = SteppedAnalog(silence_src(64), sample_rate=RATE, + time_ms=600.0, feedback=0.5, mix=2.0) + self.assertNotEqual(stepped._feedback, 0.5) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + def _modulation_move(self, cls, start_ms, target_ms, points=8): + """(the tone's own largest step before the move, the largest step in + the 2 000 frames after it) over `points` moves a quarter of the 1 Hz + triangle apart: 997 Hz at 12 000 LSB, mono, wet only, Time 300 ms, + 48 kHz (`pin063cls_ad_modmove.py`).""" + first = (14400 + 9600) // BLOCK * BLOCK + steadies, worsts = [], [] + for k in range(points): + at = first + k * 47 * BLOCK + values = sine_values(997.0, at + 2400, RATE, 12000) effect = cls(array_src(values, 1), sample_rate=RATE, - time_ms=600.0, feedback=0.5, mix=2.0) - out = pull(effect, len(values), 1) - self.assertEqual(int(np.max(np.abs(out[-RATE:]))) == 0, ends, - cls.__name__) + time_ms=300.0, feedback=0.0, mix=2.0, spread=0.0, + modulation_ms=start_ms, mod_rate_hz=1.0) + + def move(frame, at=at, effect=effect): + if frame == at: + effect.set_macro(MODULATION_I, target_ms * 127.0 / 5.0) + + y = pull(effect, at + 2000 + BLOCK, 1, + on_block=move).astype(float) + steadies.append(float(np.abs(np.diff(y[at - 3000:at - 1])).max())) + worsts.append(float(np.abs(np.diff(y[at - 1:at + 2000])).max())) + return max(steadies), max(worsts) + + def test_a_modulation_move_does_not_step(self): + # Since audiodsp v0.6.3rc1 the node ramps a new swing in over 20 ms + # (#160). While it travels the read offset may move |change| / 20 ms + # of a frame per frame on top of the triangle, so the tone may slope + # up to its own largest step times 1 + |change| / 20 ms, and no more. + # Up to v0.6.2 the same moves read 7 337 (1 -> 1.5 ms) and 2 107 + # (5 -> 0 ms). Planted: the read head moved by the whole change. + for start, target in ((1.0, 1.5), (5.0, 0.0), (5.0, 2.0)): + bar = 1.0 + abs(target - start) / 20.0 + steady, worst = self._modulation_move(AnalogDelay, start, target) + self.assertLessEqual(worst, steady * bar, (start, target)) + steady, worst = self._modulation_move(JumpModAnalog, 1.0, 1.5) + self.assertGreater(worst, steady * 1.025) def _walk_tail(self, cls): """600 ms of 997 Hz, Feedback 0, Mix 2; as the tone stops, Time From fff7b285b6a6680adf3c7bedcdff39a43a462e83 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:26:05 -0500 Subject: [PATCH 49/88] Reverb fix round 1: the audit's list, class side Words and tests; the audio path is unchanged. The docstring names Damped Plate (1.011 s, rings 1.228 s) beside Small Room (0.454 s, rings 0.576 s) under their floor knees, says the knee table is on the grid and the chamber at Size 1.5 has none at or under 2 s, says Mod Depth under 0.27 ms follows the line set, and rewrites the input ceiling: the tank's lines compress from 8 000 LSB RMS at any Mix, and 4 000 LSB RMS is the patches' figure, not every setting's. Tests: DecayKnees holds section 8.9's floor knees on the grid (19 cells), the chamber's Size 1.5 cell at 2 s as not claimed, and the 19 capped cells within 12 % of the capped law. Shown red on planted knee shifts: LongDiffusers (diffusers 1.5 x) reads the plate, room and chamber knee positions at +13.7 / +21.9 / +24.3 %; LooseCeiling (cap 2 x) reads the chamber at Size 1.0 8 s +14.6 % and the room at Size 1.5 8 s +15.0 %; both unreachable over 231 positions. InputCeiling: 0 railed samples at every patch at 4 000 LSB RMS, red (8 of 10 patches railed) on an input 6 dB hotter; the 362 Hz wet compresses by more than 1 dB from 8 000 LSB RMS. T10's 0.1 ms test now pins the disclosure (green at Size 1.0, red at 1.0039); T7's dry cells gain Mix 1.9. CHANGELOG line added. --- CHANGELOG.md | 14 ++ lib/audioeffects/rebuilt/reverb.py | 40 +++- tests/test_cpython_effects_reverb.py | 304 ++++++++++++++++++++++++++- 3 files changed, 345 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40cbccd..3a7f241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,20 @@ there, and are recorded in its changelog. 100 s period, the Wow and Flutter step, and where the pitch claim stops: the node's single-precision walk limits rising moves past 16 384 and 32 768 frames. Board cost is unmeasured. +- **`Reverb` (rebuilt, Phase 5, parked under `rebuilt/`):** Dattorro's plate + network on one `audioverb.Tank`, cut four ways as a Character macro: an + EMT 140 plate that is dense at once and darkens as Decay shortens, and a + room, a chamber and a hall that build. Decay is T60 at 500 Hz between a + floor knee and a ceiling knee, both tabled in the dossier and held by the + class tests; Size and Character rebuild the tank and cut the tail; Mix 0 + is a byte-exact wire while the tank keeps ringing; latency is zero. It + lives in `lib/audioeffects/rebuilt/reverb.py`, and `audioeffects.Reverb` + is still the old class. The Fender 6G15 spring character is parked until + the node carries a dispersive chain. The docstring names the two patches + that ring longer than their Decay reads, where Mod Depth under 0.27 ms + stops spreading a tone, and the input ceiling: the tank's lines compress + from about -12 dBFS RMS at any Mix, and no shipped patch reaches the rail + at -18 dBFS RMS at Mix 1. Board cost is unmeasured. ## v0.3.2 (2026-09-25) diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py index 0181fb7..f6af7fa 100644 --- a/lib/audioeffects/rebuilt/reverb.py +++ b/lib/audioeffects/rebuilt/reverb.py @@ -76,7 +76,10 @@ class of reverberator; the room, chamber and hall are that network which a low Damping corner brings down on the room, chamber and hall, the class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the label. Both knees are tabled per character and Size in the dossier's -section 8.9; the plate has no ceiling knee on the span. +section 8.9, read on the 0-127 grid at each character's reference patch; +the plate has no ceiling knee on the span, and the chamber at Size 1.5 has +no floor knee at or under 2 s (its 2 s position reads 10 to 13 % long, +depending on the noise it is measured with). **A sparse, quiet input decays sooner than the knob.** The tank's lines are 16-bit and truncate toward zero, which is what lets the tail reach exact @@ -87,10 +90,12 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the **The patches.** Steel Plate (the defaults), Short Plate, Damped Plate, Bass-Free Plate (Low Cut at the 6G15's 360 Hz), Small Room, Live Room, -Concert Hall, Dark Chamber, Bright Chamber, Slow Bloom. Small Room's Decay -reads 0.45 s and it rings 0.55 s at 500 Hz: it sits under the room's floor -knee, and ships so because the rooms must stay that much shorter than the -hall. +Concert Hall, Dark Chamber, Bright Chamber, Slow Bloom. Two of them sit +under their character's floor knee and ring longer than their Decay reads. +Small Room's Decay reads 0.454 s and it rings 0.576 s at 500 Hz; it ships +so because the rooms must stay that much shorter than the hall. Damped +Plate's reads 1.011 s and it rings 1.228 s, because at Size 1.25 the +plate's own ringing sets the time until Decay 1.5 s. **Latency: zero samples, at every setting, character and rate.** Nothing looks ahead. Predelay is the wet path, not latency on the dry. @@ -99,16 +104,31 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the source gets the mono fold-down of the stereo tank, both tap sets in the one lane, halved (`audiodsp_tank.c:571-580`). +**Mod Depth under 0.27 ms.** Whether a small depth spreads a steady tone +into sidebands depends on the exact lines Size cuts. At 0.1 ms and 1 Hz on +Steel Plate the sidebands of a 1 kHz tone sit 18.4 dB under it at Size 1.0 +and 27.2 dB under it at patch 0's Size (1.0039), and over the nine Size +steps around it they range from 27.2 dB under to 12.5 dB over. From +0.27 ms up they measure within 20 dB of the tone on Steel Plate and +Concert Hall, for tones from 300 Hz to 3 kHz at every Mod Rate. + **Tone** spans +/-12 dB of end-to-end tilt, which the node puts half at each end: +12 is about -6 dB at 40 Hz and +6 dB at 16 kHz. At the centre the class hands 2^-24 dB rather than 0, so the tilt's filter keeps following the tail and a later Tone move out of silence stays silent. -**Input ceiling.** The tank adds dry and wet before it rounds and clamps -at the int16 rail. On 2 s of uniform noise at Mix 1, nothing reaches the -rail at 4 000 LSB RMS (-18.3 dBFS) at any patch; at 8 000 LSB RMS -(-12.3 dBFS) eight of the ten patches do, up to 446 samples in 2 s on -Bright Chamber. There is no input gain to turn down. +**Input ceiling.** Two things limit how hard you can drive it, and there +is no input gain to turn down. First, the tank's 16-bit lines clamp at the +rail on every write, whatever Mix is, so the tail compresses before the +output clips: at the defaults a steady 362 Hz sine comes back 1.3 dB +quieter in the tail at 8 000 LSB RMS (-12.3 dBFS) than at 1 000 to 4 000, +and 7.5 dB quieter at 16 000, with the output still under the rail. Second, +the tank adds dry and wet before it rounds and clamps the output. On 2 s of +uniform noise at Mix 1 no shipped patch reaches the rail at 4 000 LSB RMS +(-18.3 dBFS), at 48, 44.1 or 22.05 kHz; at 8 000 LSB RMS most of them do, +up to 482 samples in 2 s on Bright Chamber. The 4 000 LSB RMS figure is the +patches', not every setting's: with Decay 10 s, Size 1.5 and Diffusion 0.9 +at Mix 1, Live Room puts 12 samples on the rail at 22.05 kHz. **RAM.** One int16 allocation: the twelve lines plus 200 ms of predelay. 89 714 B for Steel Plate at 48 kHz; 146 914 B for the hall at Size 1.5, the diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index 3de9d36..a2b4ee5 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -229,6 +229,45 @@ def _decay(self, index, lines, decay_s, loop_hz): return d * d, capped, 0.5 * t_lf +class LongDiffusers(Reverb): + """The floor knee (dossier section 8.9): every character's four input + diffusers at 1.5 x its own, the direction Station A tried and dropped + because longer diffusers ring on their own and lift the floor under + Decay (App. A8.8).""" + + NAME = 'Reverb' + + def _cut(self, index, size): + ratios = tuple(1.5 * r for r in rv.RATIOS[index][:4]) + \ + rv.RATIOS[index][4:] + return _cut_with(self._sample_rate, index, size, ratios) + + +def capped_law(kappa, lines, sample_rate, decay_s, loop_hz, cap): + """The dossier's section 4 Decay law with its low-frequency cap at + `cap` x Decay, written out here rather than read off the class.""" + a, b = rv.half_periods(lines) + p = 0.5 * (a + b) + fs = float(sample_rate) + mag = rv.one_pole_mag(loop_hz, 500.0, fs) + d = 10.0 ** (-3.0 * kappa * p / (2.0 * decay_s * fs)) / math.sqrt(mag) + ceiling = 10.0 ** (-3.0 * kappa * p / (2.0 * cap * decay_s * fs)) + capped = d > ceiling + d = min(d, ceiling, 0.999) + return d, capped, -3.0 * kappa * p / (2.0 * fs * math.log10(d)) + + +class LooseCeiling(Reverb): + """The ceiling knee (dossier section 8.9): the law's low-frequency cap + at 2 x Decay instead of 1.5 x, so every ceiling knee moves up.""" + + NAME = 'Reverb' + + def _decay(self, index, lines, decay_s, loop_hz): + return capped_law(rv.KAPPA[index], lines, self._sample_rate, decay_s, + loop_hz, 2.0) + + # -- probes and rendering ---------------------------------------------------- def interleave(mono, channels, frames): @@ -1096,7 +1135,7 @@ def test_the_corner_follows_low_cut(self): def test_the_dry_is_flat(self): for name in ("Steel Plate", "Concert Hall"): - for mix in (0.35, 1.0, 1.5): + for mix in (0.35, 1.0, 1.5, 1.9): self.assertLessEqual(dry_deviation(name, mix), 0.1, (name, mix)) @@ -1238,11 +1277,23 @@ def test_a_still_tank_is_one_line(self): def test_the_patches_modulation_spreads_it(self): self.assertTrue(t10_on(Reverb)) - def test_the_low_corner_of_the_claim(self): + def test_the_lowest_claimed_depth(self): v = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, - mod_depth_ms=0.1, mod_rate_hz=1.0) + mod_depth_ms=0.27, mod_rate_hz=1.0) self.assertGreaterEqual(v, -20.0) + def test_0_1_ms_is_not_claimed_it_follows_the_line_set(self): + # dossier section 8, R2: at Mod Depth 0.1 ms on Steel Plate the + # reading depends on the exact lines; green at Size 1.0 (-18.4 dB), + # red on patch 0's grid Size 64 = 1.0039 (-27.2 dB) + at_1 = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, + mod_depth_ms=0.1, mod_rate_hz=1.0) + at_grid = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, + mod_depth_ms=0.1, mod_rate_hz=1.0, + size=0.5 + 64 / 127.0) + self.assertGreaterEqual(at_1, -20.0) + self.assertLess(at_grid, -20.0) + def test_mod_rate_as_its_reciprocal_is_red(self): self.assertFalse(t10_on(RateReciprocal)) @@ -1308,5 +1359,252 @@ def test_null_build_is_red(self): t11_errors(cls, "plate"))}, label="Reverb T11") +# -- the Decay knees (dossier section 8.9, audit-3 ruling (o)) ---------------- + +def midi_for(index, value): + """The fractional MIDI position that lands macro `index` on `value`.""" + return _component.midi_of_position( + Reverb.MACRO_MODES.get(index, "UNIPOLAR"), + _component.macro_position(Reverb._MACRO_RANGES[index], value)) + + +PATCH_INDEX = {name: index for index, (name, _) in enumerate(PATCH_SETTINGS)} + + +def t500_mean(build, decay, seeds=SEEDS, rate=RATE, hint=None): + """M3 at 500 Hz on interrupted noise (2 s at 8 000 LSB RMS, then + silence), the mono sum, Mix 2, the mean over `seeds`; `build(src)` + makes the instance. The render runs 1.3 x max(`hint` or Decay, 1 s) + past the burst, which reaches past -35 dB at every cell here.""" + t = decay if hint is None else hint + frames = int((BURST_S + 1.3 * max(t, 1.0) + 0.8) * rate) + vals = [] + for seed in seeds: + src = probes.ArraySource(interleave(noise_burst(rate, seed), 2, + frames), + rate=rate, channels=2, block=256) + effect = build(src) + try: + y = render(effect, frames) + finally: + effect.deinit() + m = y.astype(np.float64).sum(axis=1) + vals.append(t60_slope(band(m, rate, 500)[int(BURST_S * rate):], + rate)) + return None if None in vals else float(np.mean(vals)) + + +def knee_t500(cls, character, size, decay, seeds=SEEDS): + """The floor-knee cell: the character's reference patch on the grid + (`program_change`), Size and Decay set on the macros, Mix 2.""" + key = ("floor", cls, character, size, decay, seeds) + if key not in _MEMO: + def build(src): + effect = cls(src, sample_rate=RATE) + effect.program_change(PATCH_INDEX[REFERENCE[character]]) + effect.set_macro(rv.SIZE_I, midi_for(rv.SIZE_I, size)) + effect.set_macro(rv.DECAY_I, midi_for(rv.DECAY_I, decay)) + effect.set_macro(rv.MIX_I, 127) + return effect + _MEMO[key] = t500_mean(build, decay, seeds) + return _MEMO[key] + + +FLOOR_STOPS = (0.3, 0.45, 0.6, 0.8, 1.0, 1.25, 1.5, 2.0) +KNEE_SIZES = (0.5, 0.75, 1.0, 1.25, 1.5) + +#: Section 8.9's floor-knee table on the grid, as revised 2026-09-28 (R5): +#: the lowest Decay from which every position up to 2 s lands within +#: +/-12 % at 500 Hz. The chamber at Size 1.5 has none at or under 2 s: +#: its 2 s cell sits on the bar (T11, R7). +FLOOR_KNEES = { + "plate": (0.3, 0.45, 1.0, 1.5, 1.5), + "room": (0.45, 0.8, 0.8, 1.0, 1.0), + "chamber": (0.6, 0.8, 1.0, 1.5, None), + "hall": (0.6, 1.0, 1.5, 1.25, 2.0), +} + + +def predicted_t500(character, size, decay, damping=1000.0, rate=RATE, + cap=1.5): + """T60 at 500 Hz the dossier's capped law gives on the room, chamber + or hall (their loop corner is Damping): (seconds, capped).""" + index = rv.CHARACTERS.index(character) + lines = rv.line_set(index, size, rate) + a, b = rv.half_periods(lines) + p = 0.5 * (a + b) + d, capped, _ = capped_law(rv.KAPPA[index], lines, rate, decay, damping, + cap) + g = d * d * rv.one_pole_mag(damping, 500.0, rate) + return 3.0 * rv.KAPPA[index] * p / (-rate * math.log10(g)), capped + + +def capped_cells(): + """The constructor defaults (Damping 1 kHz) on the room, chamber and + hall at Size 0.5 / 1.0 / 1.5 and Decay 2 / 4 / 8 / 10 s where the + dossier's law caps the bass.""" + out = [] + for character in ("room", "chamber", "hall"): + for size in (0.5, 1.0, 1.5): + for t in (2.0, 4.0, 8.0, 10.0): + pred, capped = predicted_t500(character, size, t) + if capped: + out.append((character, size, t, pred)) + return out + + +def ceiling_t500(cls, character, size, decay, hint): + key = ("ceiling", cls, character, size, decay) + if key not in _MEMO: + _MEMO[key] = t500_mean( + lambda src: cls(src, sample_rate=RATE, character=character, + size=size, decay=decay, mix=2.0), + decay, hint=hint) + return _MEMO[key] + + +class DecayKnees(unittest.TestCase): + """Section 8.9's promise: a test that fails if a knee moves.""" + + def _floor(self, character): + for size, knee in zip(KNEE_SIZES, FLOOR_KNEES[character]): + if knee is None: + continue + i = FLOOR_STOPS.index(knee) + for t in FLOOR_STOPS[i:]: + e = knee_t500(Reverb, character, size, t) / t - 1.0 + self.assertLessEqual(abs(e), 0.12, (character, size, t, e)) + if i: + t = FLOOR_STOPS[i - 1] + e = knee_t500(Reverb, character, size, t) / t - 1.0 + self.assertGreater(abs(e), 0.12, (character, size, t, e)) + + def test_the_plates_floor_knees(self): + self._floor("plate") + + def test_the_rooms_floor_knees(self): + self._floor("room") + + def test_the_chambers_floor_knees(self): + self._floor("chamber") + + def test_the_halls_floor_knees(self): + self._floor("hall") + + def test_the_chamber_at_size_1_5_has_no_knee_under_2_s(self): + # T11's cell, Not claimed (R7): 1.5 s is outside, and 2 s reads + # inside on seeds 7-14 but outside on seeds 23-30 + e15 = knee_t500(Reverb, "chamber", 1.5, 1.5) / 1.5 - 1.0 + self.assertGreater(abs(e15), 0.12, e15) + e2 = knee_t500(Reverb, "chamber", 1.5, 2.0) / 2.0 - 1.0 + self.assertTrue(0.08 <= e2 <= 0.12, e2) + e2b = knee_t500(Reverb, "chamber", 1.5, 2.0, + tuple(range(23, 31))) / 2.0 - 1.0 + self.assertGreater(e2b, 0.12, e2b) + + def test_longer_diffusers_move_the_floor_knee(self): + # the plate's, room's and chamber's knee position at Size 1.0 lands + # outside +/-12 % once their diffusers are 1.5 x longer (the hall's + # does not move: it reads +7.3 % at its 1.5 s knee, and the plant + # is recorded as blind there) + for character in ("plate", "room", "chamber"): + knee = FLOOR_KNEES[character][KNEE_SIZES.index(1.0)] + e = knee_t500(LongDiffusers, character, 1.0, knee) / knee - 1.0 + self.assertGreater(abs(e), 0.12, (character, knee, e)) + + def test_the_longer_diffusers_are_not_on_the_surface(self): + result = reach(LongDiffusers, handed_cut) + self.assertEqual(result["checked"], WALK) + + def test_the_ceiling_knees(self): + cells = capped_cells() + self.assertEqual(len(cells), 19) + misses = [] + for character, size, t, pred in cells: + got = ceiling_t500(Reverb, character, size, t, pred) + self.assertLessEqual(abs(got / pred - 1.0), 0.12, + (character, size, t, got, pred)) + misses.append(1.0 - got / t) + # while the label there misses by up to about 60 % + self.assertGreater(max(misses), 0.5, misses) + + def test_a_loose_ceiling_is_red(self): + # the two cells where a cap at 2.0 moves 500 Hz most (+14.6 and + # +15.0 % measured); at most cells it moves under 12 % + for character, size, t in (("chamber", 1.0, 8.0), + ("room", 1.5, 8.0)): + pred, capped = predicted_t500(character, size, t) + self.assertTrue(capped) + got = ceiling_t500(LooseCeiling, character, size, t, 1.4 * pred) + self.assertGreater(abs(got / pred - 1.0), 0.12, + (character, size, t, got, pred)) + + def test_the_loose_ceiling_is_not_on_the_surface(self): + result = reach(LooseCeiling, lambda e: ( + e._index, tuple(e._lines), round(e.macro(rv.DECAY_I), 9), + e._handed["decay"]), character="room", size=0.5, decay=10.0) + self.assertEqual(result["checked"], WALK) + + +# -- the input ceiling (audit-3 rulings (m) and (o)) ------------------------- + +def railed(name, rms, rate=RATE, channels=2, mix=1.0, **override): + """Samples on the int16 rail over 2 s of uniform noise at `rms` LSB RMS + (seed 11) and 0.5 s after, at Mix 1 by default.""" + rng = np.random.RandomState(11) + n = 2 * rate + x = np.round(np.clip(rng.uniform(-1, 1, n) * rms * math.sqrt(3), + -32768, 32767)).astype(np.int16) + frames = n + rate // 2 + effect = make(Reverb, name, rate, channels, x, frames, mix=mix, + **override) + try: + y = render(effect, frames) + finally: + effect.deinit() + return int(np.count_nonzero(np.abs(y) >= 32767)) + + +def sine_wet_gain(hz, rms, rate=RATE): + """The wet's carrier gain on a steady sine at `rms` LSB RMS at the + constructor defaults, Mix 2: the last 2 s of 6 s, one Hann FFT.""" + n = 6 * rate + x = np.round(rms * math.sqrt(2) * np.sin( + 2 * np.pi * hz * np.arange(n) / rate)).astype(np.int16) + effect = make(Reverb, None, rate, 2, x, n, mix=2.0) + try: + y = render(effect, n) + finally: + effect.deinit() + seg = y.astype(np.float64).sum(axis=1)[-2 * rate:] / 2.0 + xin = x.astype(np.float64)[-2 * rate:] + win = np.hanning(len(seg)) + f = np.fft.rfftfreq(len(seg), 1.0 / rate) + k = int(np.argmin(np.abs(f - hz))) + a = np.abs(np.fft.rfft(seg * win))[k - 2:k + 3].max() + b = np.abs(np.fft.rfft(xin * win))[k - 2:k + 3].max() + return 20 * math.log10(a / b) + + +class InputCeiling(unittest.TestCase): + def test_no_patch_reaches_the_rail_at_4000_lsb_rms(self): + for name, _ in PATCH_SETTINGS: + self.assertEqual(railed(name, 4000.0), 0, name) + + def test_6_db_more_reaches_it(self): + # the same reading, the input planted 6 dB hotter + hot = [name for name, _ in PATCH_SETTINGS + if railed(name, 8000.0) > 0] + self.assertGreaterEqual(len(hot), 6, hot) + + def test_the_tanks_lines_compress_from_8000_lsb_rms(self): + # the docstring: flat to 4 000 LSB RMS, about 1.3 dB down at 8 000 + # on a 362 Hz sine at the defaults, with the output under the rail + g1, g4, g8 = (sine_wet_gain(362.0, r) for r in (1000.0, 4000.0, + 8000.0)) + self.assertLessEqual(abs(g4 - g1), 0.1, (g1, g4)) + self.assertLess(g8 - g4, -1.0, (g4, g8)) + + if __name__ == "__main__": unittest.main() From 1c9308b0103c5181e308b1e1da6276a95a0b98b8 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:59:32 -0500 Subject: [PATCH 50/88] ConvolutionReverb re-audit fix round 1: the round-3 audit's list, class side The audio does not change. The docstring, CHANGELOG line and tests do. D6's per-side swing is now a floor, not a bound. The docstring and the CHANGELOG say the balance moves by at least about 5.3 dB at 48 and 44.1 kHz and 4.7 dB at 22.05 kHz, that Diffusion, Room, Damping and Decay all move it, and that the widest setting is not known. The module docstring names each rate's widest found by a walk of Diffusion's every position x 64 Rooms x Decay 0-12 x Damping 0-8: L - R -5.30 / -5.26 / -4.67 dB at Decay 0, Damping 500 Hz, seed 36, Diffusion 12 / 13 / 22 (white noise about -5.5 / -5.4 / -4.6 dB), and +4.49 / +4.55 / +4.05 dB the other way. D6Balance pins those figures to the room at the named cells and walks a slice through them; the fix-round-2 docstring (+/-4.5 dB), a figure 0.1 dB narrow, and SideTilt (left +0.5 dB after the node) are each red on it. D5 gets its Predelay control: PredelayKeptSquared is red on the handed law at Predelay 64/127 (+6.518 % worst at 48 kHz, +7.244 % at 22.05 kHz) and green read back; with it standing in for the clean class the new test fails. A NaN damping_hz is named out of circuit and pinned; NanIsSpanBottom is red on the pin. The single-Room figure says "about 16 %" and gives the quiet-click 16.5 %. --- CHANGELOG.md | 7 +- lib/audioeffects/rebuilt/convolutionreverb.py | 37 ++-- .../test_cpython_effects_convolutionreverb.py | 205 +++++++++++++++++- 3 files changed, 230 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a99365a..d7d505a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,9 +66,10 @@ there, and are recorded in its changelog. `lib/audioeffects/rebuilt/convolutionreverb.py`, and `audioeffects.ConvolutionReverb` is still the old class. The room holds its level over both channels together (for material about equally loud - on both sides), but Room, Damping and Decay move a stereo room's - left-right balance by up to about +/-4.5 dB at every rate; fixing that - is a node change, drafted as an audiodsp ask. The + on both sides), but Diffusion, Room, Damping and Decay move a stereo + room's left-right balance, by at least about 5.3 dB at 48 and 44.1 kHz + and 4.7 dB at 22.05 kHz (the widest found; how far it can go is not + known); fixing that is a node change, drafted as an audiodsp ask. The docstring states that, the low-material lift a dark room gives, and the partition a room-knob move or a mid-stream `reset()` drops. Board cost is unmeasured; by the cost table's line the default fits an S3, and diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 0f93538..199143d 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -12,6 +12,9 @@ docstring says what that leaves out. Fix round 2 (the same day, after the round-2 audit) corrected the left-right balance figure it gives, from about 1.3 dB to about 4.5 dB, and three edges; the audio did not change. +The re-audit's fix round 1 (after the round-3 audit) replaced that bound +with the widest balance a walk over Diffusion's every position found, +stated as a floor on the swing, not a bound; the audio did not change. **What it sounds like.** A short room behind your dry signal. With nothing loaded the class synthesizes the room: noise under an exponential that @@ -109,17 +112,23 @@ Diffusion 0 (about 0.8 / 0.5 dB at Decay 1.0, Diffusion 0.5). Each side on its own does not hold either: the node scales the room by the mean of its two sides' energies, so on a stereo room the left-right balance moves -by up to about +/-4.5 dB at every rate while the total holds, and Room -moves it as much as Damping and Decay do (the widest measured, with -Damping at 500 Hz and Decay 0: -4.4 / +4.5 dB at 48 and 44.1 kHz, -4.2 / -+4.0 dB at 22.05 kHz). A mono room is one side and holds. Damping clamps +while the total holds, and Diffusion, Room, Damping and Decay all move it. +It moves by at least about 5.3 dB at 48 and 44.1 kHz and 4.7 dB at +22.05 kHz, and how far it can go is not known: no walk covers every +setting. The widest found on the room's own impulse is L - R -5.30 dB at +48 kHz, -5.26 dB at 44.1 kHz and -4.67 dB at 22.05 kHz, all at Decay 0, +Damping 500 Hz and Room seed 36, with Diffusion at 12, 13 and 22 of 127; +white noise there reads about -5.5, -5.4 and -4.6 dB. The other way, the +widest found is +4.49, +4.55 and +4.05 dB (Decay 0, Damping 500 Hz, +Diffusion 0, seed 4). A mono room is one side and holds. Damping clamps at 0.159 fs, under the point where the node's one-pole coefficient stops moving, so at 48 kHz every one of its 128 positions is a room of its own, while at 22.05 kHz the positions from 92 up (the 6 kHz default among them) are one -3 506 Hz room. A single Room's decay with Damping in can read up to 16 % -off the Decay law on a stereo room and up to 22 % on a mono one (48 kHz, -Decay 0, Damping 500 Hz); the 64 Rooms' mean holds within 2 %. +3 506 Hz room. A single Room's decay with Damping in can read up to about +16 % off the Decay law on a stereo room (16.5 % with a quiet click) and +up to about 22 % on a mono one (48 kHz, Decay 0, Damping 500 Hz); the 64 +Rooms' mean holds within 2 %. **Measured mode.** The impulse is trimmed by `start_ms` (int(start_ms * fs / 1000) frames, truncated) through a slice that copies @@ -140,9 +149,10 @@ **Two readbacks that are not what they look like.** A `damping_hz` between 0 and 500 Hz is taken as 500 Hz, the span's bottom, with no -error; 0 or below, or 7 500 Hz and up, is out of circuit (-100 hands the -node 0 Hz and `get_macro` reads 127). And a fresh instance reports -`patch_index` 0, the family's convention, although the constructor's exact +error; 0 or below, 7 500 Hz and up, or NaN, is out of circuit (-100 and +NaN hand the node 0 Hz and `get_macro` reads 127). And a fresh instance +reports `patch_index` 0, the family's convention, although the +constructor's exact defaults (Damping 6 000 Hz, Mix 0.6) sit between grid steps and patch 0 is those settings on the grid (6 059.8 Hz, Mix 0.598). Pass `patch=0` for patch 0's room exactly. `reset()` restores patch 0, so an instance built @@ -305,9 +315,10 @@ class ConvolutionReverb(_component.Component): in. audiodsp tier; 256 frames of latency whenever an impulse is loaded. **What the default surrenders:** a dark room lifts low material (a low - chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), Room, - Damping and Decay move a stereo room's left-right balance by up to - about +/-4.5 dB at every rate while the total holds, moving a room knob + chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), + Diffusion, Room, Damping and Decay move a stereo room's left-right + balance by at least about 5.3 dB (the widest setting is not known) + while the total holds, moving a room knob or calling `reset()` mid-stream drops the 256 frames in flight, dry included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index ae0c2f0..608ed0f 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -30,9 +30,18 @@ law read back off `effect._macros`. It also pins two edges the docstring now states: a negative `damping_hz` is out of circuit, and `impulse=b""` holds one partition. + +The re-audit's fix round 1 (gate audit round 3) added three checks, each +shown red on its plant: D5's Predelay control (`PredelayKeptSquared`, red +on the handed law at Predelay 64/127 and green read back), a NaN +`damping_hz` pinned out of circuit (`NanIsSpanBottom`), and `D6Balance`, +which pins the widest per-side balance the docstring prints to the room at +the cell the walk named (the fix-round-2 docstring, a figure 0.1 dB narrow +and `SideTilt` are red on it). """ import os +import re import sys import tempfile import unittest @@ -270,6 +279,19 @@ def m6_cell(effect, pcm): return level, rho(out, pcm, LATENCY + taps) +def balance(effect): + """(L - R dB, pooled dB) of the room's own impulse: a 0 dBFS click at + Mix 2, each side's energy summed over the loaded taps. It is what white + noise reads per side in expectation (D6's Not claimed line; re-audit + fix round 1). Mix is put back after.""" + taps = effect.node.taps + out = at_mix(effect, 127, click(LATENCY + taps + 256, 2)) + ir = out[LATENCY:LATENCY + taps].astype(np.float64) / 32767.0 + energy = np.sum(ir * ir, axis=0) + return (float(10 * np.log10(energy[0] / energy[1])), + float(10 * np.log10(np.mean(energy)))) + + # -- D1's impulse, trim and gain ------------------------------------------ def make_impulse(taps=3840, channels=1, seed=7): @@ -452,6 +474,26 @@ def _fault(self, node): return kit_faults.HiddenGain(node, self.GAIN_DB) +class _LeftGain(kit_faults.HiddenGain): + """`kit_faults.HiddenGain` on the left channel only.""" + + def _process(self, frames): + out = frames.copy() + out[0::self.channel_count] *= self._gain + return out + + +class SideTilt(_After): + """D6's balance disclosure: the left side 0.5 dB hot after the node, a + balance fault the pooled clauses barely see (+0.25 dB).""" + + NAME = NAME + GAIN_DB = 0.5 + + def _fault(self, node): + return _LeftGain(node, self.GAIN_DB) + + class AllocatedSeconds(ConvolutionReverb): """D5's control for a law that shares the class's inputs: the class keeps the allocation it holds (partitions x 256 / fs) as `seconds`, so @@ -507,6 +549,36 @@ def set_macro(self, index, value, channel=0, note_id=-1, self._apply_macro(DECAY_I, self._macros[DECAY_I]) +class PredelayKeptSquared(ConvolutionReverb): + """D5's Predelay control (re-audit fix round 1, from the round-3 + audit's item 3): the constructor's Predelay kept squared. It is + Predelay's shape of DecayKeptSquared: at Predelay 64/127, Decay 127, + Damping out, Diffusion 0 it reads worst +6.518 % on the handed law at + 48 kHz (+7.244 % at 22.05 kHz) and +1.276 % on a law read back off the + class, and it passed every D5 test before this one.""" + + NAME = NAME + + def _init_macros(self, values, patch=None): + values = list(values) + values[PREDELAY_I] = values[PREDELAY_I] ** 2 + ConvolutionReverb._init_macros(self, tuple(values), patch) + + +class NanIsSpanBottom(ConvolutionReverb): + """The damping pin's control: a NaN `damping_hz` taken as the 500 Hz + stop instead of out of circuit, which is what the docstring said of + NaN before it named it.""" + + NAME = NAME + + def _build(self, *arguments, **options): + hz = options.get("damping_hz", 6000.0) + if hz != hz: + options["damping_hz"] = 100.0 + ConvolutionReverb._build(self, *arguments, **options) + + class LongDecay(ConvolutionReverb): """D5 (1): the decay handed to the node 5 % long against the law.""" @@ -700,10 +772,13 @@ def test_damping_under_the_span_is_the_span_bottom(self): effect.deinit() # Fix round 2 (gate audit round 2, item 7b): a negative damping_hz # is out of circuit, like 0, not the 500 Hz stop. - for hz in (-100.0, 0.0): + # Re-audit fix round 1 (gate audit round 3, item 6): NaN is out of + # circuit too, and the docstring now says so (NanIsSpanBottom is + # the control, red here). + for hz in (-100.0, 0.0, float("nan")): effect = build(damping_hz=hz) - self.assertEqual(effect._synthesis()[1], 0.0) - self.assertEqual(effect.get_macro(DAMPING_I), 127) + self.assertEqual(effect._synthesis()[1], 0.0, hz) + self.assertEqual(effect.get_macro(DAMPING_I), 127, hz) effect.deinit() def test_an_empty_impulse_holds_one_partition(self): @@ -1393,6 +1468,27 @@ def test_a_decay_held_off_the_handed_position_is_red(self): damping_hz=0.0, diffusion=0.0) self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + def test_a_predelay_held_off_the_handed_position_is_red(self): + # Re-audit fix round 1 (gate audit round 3, item 3). The law takes + # the Predelay position the test handed too, and until this test no + # D5 cell sat at an interior Predelay at 0.08 s, where the law + # depends on it most: PredelayKeptSquared passed every D5 test. At + # Predelay 64/127, Decay 127, Damping out, Diffusion 0 it is red on + # the handed law and green on the law read back off the class; + # the clean class is green on the handed law. + for rate in (48000, 22050): + options = dict(predelay=64 / 127.0, decay=1.0, damping_hz=0.0, + diffusion=0.0) + planted = self.errors_over_rooms(PredelayKeptSquared, rate, + **options) + self.assertGreater(float(np.max(np.abs(planted))), 0.03, rate) + self.assertGreater(float(np.mean(planted)), 0.03, rate) + held = self.errors_over_rooms(PredelayKeptSquared, rate, + read_back=True, **options) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + clean = self.errors_over_rooms(rate=rate, **options) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + def test_stuck_dc_turns_the_floor_red(self): effect = build(StuckDcAfter) t60, floor = m5_cell(effect) @@ -1514,6 +1610,109 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) +#: The sentence in the module docstring that gives the widest per-side +#: balance found, one figure per rate, two decimals (re-audit fix round 1). +BALANCE_RE = re.compile( + r"widest\s+found\s+on\s+the\s+room's\s+own\s+impulse\s+is\s+L\s+-\s+R\s+" + r"(-\d+\.\d\d)\s+dB\s+at\s+48\s+kHz,\s+(-\d+\.\d\d)\s+dB\s+at\s+44\.1\s+" + r"kHz\s+and\s+(-\d+\.\d\d)\s+dB\s+at\s+22\.05\s+kHz") + +#: The sentence after it: the widest found the other way. +BALANCE_OTHER_RE = re.compile( + r"The\s+other\s+way,\s+the\s+widest\s+found\s+is\s+\+(\d+\.\d\d),\s+" + r"\+(\d+\.\d\d)\s+and\s+\+(\d+\.\d\d)\s+dB") + +#: Where the walk found the widest the other way, at every rate. +WIDEST_OTHER_CELL = (0, 0, 0, 0, 6) + +#: Where the walk behind that sentence found each rate's widest (evidence +#: pack, "Re-audit fix round 1"): (Decay, Damping, Predelay, Diffusion, +#: Room) as MIDI positions, 0.08 s. +WIDEST_BALANCE_CELL = { + 48000: (0, 0, 0, 12, 70), + 44100: (0, 0, 0, 13, 70), + 22050: (0, 0, 0, 22, 70), +} + + +def documented_balance(doc): + """{rate: L - R dB} as the module docstring states it, or None.""" + found = BALANCE_RE.search(doc or "") + if found is None: + return None + return dict(zip(RATES, (float(v) for v in found.groups()))) + + +def documented_other_way(doc): + """{rate: L - R dB} the other way, as the module docstring states it, + or None.""" + found = BALANCE_OTHER_RE.search(doc or "") + if found is None: + return None + return dict(zip(RATES, (float(v) for v in found.groups()))) + + +def build_at_cell(rate, cell): + effect = build(rate=rate) + for index, midi in zip((DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, + ROOM_I), cell): + effect.set_macro(index, midi) + return effect + + +class D6Balance(unittest.TestCase): + """D6's Not claimed line, each side on its own: the node scales a + stereo room by the mean of its sides' energies, so the left-right + balance moves. Nothing is claimed about it, but the docstring tells a + player how far it was found to move, and twice that figure was too + small (gate audits rounds 2 and 3). These tests pin the figure the + docstring prints to the room at the cell the walk named, and check no + cell of a slice through it (Diffusion's every position at that + setting, and the 64 Rooms at that Diffusion) reads wider. They do not + make the figure a bound: the walk is a floor on the swing + (re-audit fix round 1).""" + + def test_the_documented_balance_is_what_the_room_reads(self): + documented = documented_balance(rebuilt.__doc__) + self.assertIsNotNone(documented, "no widest-balance sentence") + for rate in RATES: + effect = build_at_cell(rate, WIDEST_BALANCE_CELL[rate]) + side, pooled = balance(effect) + effect.deinit() + self.assertLessEqual(abs(side - documented[rate]), 0.005, + (rate, side)) + self.assertLessEqual(abs(pooled), 0.01, rate) + other = documented_other_way(rebuilt.__doc__) + self.assertIsNotNone(other, "no widest-the-other-way sentence") + for rate in RATES: + effect = build_at_cell(rate, WIDEST_OTHER_CELL) + side, _ = balance(effect) + effect.deinit() + self.assertLessEqual(abs(side - other[rate]), 0.005, + (rate, side)) + # The class's own summary gives the floor too. + self.assertIn("at least about %.1f dB" % abs(documented[48000]), + " ".join(ConvolutionReverb.__doc__.split())) + + def test_no_cell_of_the_slice_is_wider_than_documented(self): + documented = documented_balance(rebuilt.__doc__) + self.assertIsNotNone(documented, "no widest-balance sentence") + for rate in RATES: + diffusion = WIDEST_BALANCE_CELL[rate][3] + effect = build_at_cell(rate, WIDEST_BALANCE_CELL[rate]) + readings = [] + for position in range(128): + effect.set_macro(DIFFUSION_I, position) + readings.append(balance(effect)[0]) + effect.set_macro(DIFFUSION_I, diffusion) + for position in range(0, 128, 2): + effect.set_macro(ROOM_I, position) + readings.append(balance(effect)[0]) + effect.deinit() + self.assertGreaterEqual(min(readings), documented[rate] - 0.005, + rate) + + # -------------------------------------------------------------------------- # Tier 1, the fast half # -------------------------------------------------------------------------- From a65bb9785856edeb12b02e836261c3c4f1e81824 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:19:13 -0500 Subject: [PATCH 51/88] PingPongDelay re-audit fix round 1: the out stops tested with the filter playing, the tail after Cut measured The class at audiodsp v0.6.3rc1 (bd7d2e2) checked against the pin move's own list. Its claims hold; four gaps are closed, words and tests only (the class's arithmetic does not move). The out-stop tests put the filter out before any audio, so the node's filter never ran. The docstring now says what that shows, that taking a filter out does not undo what it already did to the repeats going round, and states the cell that does exercise the node: 1 s of 0 dBFS noise with Tone 2 kHz or Cut 400 Hz in, the filter out as it stops, then fresh noise once the loop is empty renders the same bytes from the fresh noise on as an instance that never had the filter (three rates, stereo and mono). New test test_a_filter_really_in_then_out_is_out, red on the class before the move (78 206 samples, 1 LSB) and on both planted cures. The bound once Cut is out was declared but never rendered after Cut had played. New test test_the_tail_after_cut_in_then_out_is_inside_the_bound: full-scale noise with Cut at 400 Hz or MIDI 1, Cut out as it stops, the tail inside the bound read after the move (0.9990 at worst in the probe); red on the class before the move (bound None) and on a bound one lap short. Gate audit round 3's restatements 2 and 3: "At louder inputs than that bound", and the T3 gap paragraph names the knob's corners (192.5, 90.5, 42.5, 29.2, 20.5 Hz) and MIDI 127 for the 400 Hz cell. The T3 paragraph's "whatever the loop filters did before" is scoped to filters out while the material goes round. Node cites at bd7d2e2: clear() is audiodsp_feedback_delay.c:295-303, the cross-fed write :581-589. --- lib/audioeffects/rebuilt/pingpongdelay.py | 57 +++++++----- tests/test_cpython_effects_pingpongdelay.py | 98 +++++++++++++++++++++ 2 files changed, 133 insertions(+), 22 deletions(-) diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index 0413ba1..1878899 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -47,8 +47,9 @@ that mono delay's output exactly, sample for sample, as long as no two repeats overlap one another: on sustained material that overlaps its own repeats each side rounds its own write where the mono delay rounds the sum -once, and the two part by a few LSB. That holds whatever the loop filters -did before: a filter taken out is out exactly (below). With a +once, and the two part by a few LSB. That holds with both loop filters out +while the material and its repeats go round, whether or not a filter was +in before: a filter taken out is out exactly (below). With a loop filter in, the material must also end some way before Time, because each side's filter meets that side's next repeat two Times later where the mono delay's meets the very next one: a noise burst ending one frame before @@ -56,11 +57,12 @@ up to 2 LSB (Repeat Cut 400 Hz). How far before depends on the filter. Measured at 48 kHz, Time 280 ms, Feedback 0.6, on noise up to 0 dBFS, the gap after which every longer gap tried is exact: 512 frames for Repeat -Tone at 800 and 2500 Hz and Repeat Cut at 400 Hz; for Repeat Cut lower -down, 1024 frames at MIDI 96 (188 Hz), 2048 at MIDI 64 (89 Hz), 4096 at -MIDI 32 (42 Hz), and 8192 (171 ms) at MIDI 16 (29 Hz) and at MIDI 1 -(20.4 Hz), where 4096 still leaves 511 samples. MIDI 1 needs 8192 at -Feedback 0.99 too, 6144 at 44.1 kHz and 3072 at 22.05 kHz. +Tone at 800 and 2500 Hz and Repeat Cut at 400 Hz (MIDI 127); for Repeat +Cut lower down, 1024 frames at MIDI 96 (the knob's 192.5 Hz), 2048 at +MIDI 64 (90.5 Hz), 4096 at MIDI 32 (42.5 Hz), and 8192 (171 ms) at MIDI 16 +(29.2 Hz) and at MIDI 1 (20.5 Hz), where 4096 still leaves 511 samples. +MIDI 1 needs 8192 at Feedback 0.99 too, 6144 at 44.1 kHz and 3072 at +22.05 kHz. **Spread's law.** Spread s hands the node `cross_feed` s and `input_pan` -s (First Side left) or +s (right). On a click identical in both channels, @@ -101,7 +103,7 @@ or below floor(32767 - 65535 Mix) - 1 cannot reach the rail at any Time, Feedback, Spread or Cut, which is 13 105 LSB, -7.96 dBFS, at the default Mix 0.3 (rendered on 108 square-wave cells and on noise at that level, -none railed). Measured below that bound, the worst cell tried is a 5 Hz +none railed). At louder inputs than that bound, the worst cell tried is a 5 Hz square at Feedback 0.99 with Cut at 40 Hz: it rails 148 samples at -4.5 dBFS and 40 at -5, and is clean from -5.5 dBFS. At the defaults a 5 Hz square rails at -4 dBFS (396 samples with Cut at MIDI 1, 158 at @@ -165,18 +167,29 @@ 10 804.5 Hz clamp and do the same thing. **The filters' out stops, after a filter has been in.** Both out stops -hand the node exactly 0, and a filter taken out is out, whatever came -before: on 2 s of 0 dBFS noise at Mix 2, Tone out after Tone 2 kHz was in -renders the same bytes as Tone never in (Feedback 0.85 and 0.99), and Cut -out after Cut 400 Hz was in the same bytes as Cut never in (Feedback 0.99), -at 48, 44.1 and 22.05 kHz; a filter brought back in after the repeats have -died plays nothing. Up to audiodsp v0.6.2 the node froze an out filter's -state and played it back, and this class kept Tone's low-pass tracking the -repeats and Cut's high-pass in at 20 Hz once they had been in (the first up -to 50 LSB off the filter out at Feedback 0.99, the second with -`tail_samples` `None`). Since -v0.6.3rc1 the node keeps an out low-pass's state on the signal and an out -high-pass's at zero (audiodsp#158, #159), and both cures came out. +hand the node exactly 0 and the Feedback as set, whatever came before, and +a filter taken out is out. Taking it out does not undo what it already did +to the repeats going round; those stay filtered. Once they have died, the +instance plays exactly as one whose filter was never in: 1 s of 0 dBFS +noise with Tone 2 kHz or Cut 400 Hz in, the filter out as the noise stops, +then fresh noise once the loop is empty, renders the same bytes from the +fresh noise on as an instance that never had the filter (Time 20 ms, +Feedback 0.5 and 0.85, Mix 2, at 48, 44.1 and 22.05 kHz, stereo and mono). +Put straight to its out stop before any audio, a filter that has been in +renders the same bytes as one never in (Tone: 2 s of 0 dBFS noise at +Feedback 0.85 and 0.99 at the three rates, and at Time 20 ms and Feedback +0.99 through the whole tail at 48 kHz; Cut: 2 s at 0.99, three rates). +A filter brought back in after the repeats have died plays nothing. Up to +audiodsp v0.6.2 the node froze an out filter's state and played it back, +and this class kept Tone's low-pass tracking the repeats and Cut's +high-pass in at 20 Hz once they had been in (the first up to 50 LSB off +the filter out at Feedback 0.99, the second with `tail_samples` `None`). +Since v0.6.3rc1 the node keeps an out low-pass's state on the signal and +an out high-pass's at zero (audiodsp#158, #159), and both cures came out. +With Cut out again, `tail_samples` is the bound Cut never in has, and a +tail after Cut was in ends inside it (full-scale noise with Cut at 400 Hz +or MIDI 1, Cut out as it stops: at most 0.9990 of the bound, Feedback +0.45, 0.85 and 0.99, Spread 1, 0.5 and 0, three rates). Each pass through a loop filter also takes something off a repeat's peak, so with either filter in the late repeats of a quiet bounce fade faster @@ -437,7 +450,7 @@ def _build(self, time_ms=DEFAULT_TIME_MS, feedback=DEFAULT_FEEDBACK, input_pan=0.0, delay_slew=0.0) # `clear()` empties both lanes and the loop filters and re-primes the - # read head (`audiodsp_feedback_delay.c:291-299`, `:286-288`), so a + # read head (`audiodsp_feedback_delay.c:295-303`, `:286-288`), so a # reset is silent and snaps onto patch 0's Time. self._own(self._delay, reset=self._clear) self._delay.play(self._source) @@ -613,7 +626,7 @@ def tail_samples(self): 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:520-529`), + of the two lanes' loop values (`audiodsp_feedback_delay.c:581-589`), so the per-lap argument holds at every Spread.""" self._check_live() return self._tail_bound() diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index 71e3604..a5ba5d9 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -1772,6 +1772,104 @@ def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): self.assertNotEqual(stepped._feedback, 0.5) self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + def _in_then_out(self, cls, which, rate): + """(samples differing, largest difference) from the fresh noise on: + 1 s of 0 dBFS noise with Tone 2 kHz or Cut 400 Hz really in, the + filter out as the noise stops, silence past `tail_samples`, then + 1 s of fresh noise, against an instance whose filter was never in. + Time 20 ms, Feedback 0.5 (a stall window centre), Mix 2.""" + loud = rate // BLOCK * BLOCK + bound = PingPongDelay(silence_src(64, rate=rate), sample_rate=rate, + time_ms=20.0, feedback=0.5).tail_samples + fresh = (loud + bound + rate // 4) // BLOCK * BLOCK + frames = fresh + rate + bound + rate // 4 + x = np.zeros(frames) + x[:loud] = np.random.RandomState(12345).uniform(-32767, 32767, loud) + x[fresh:fresh + rate] = np.random.RandomState(777).uniform( + -32767, 32767, rate) + ctor = {"time_ms": 20.0, "feedback": 0.5, "mix": 2.0} + filt = {"tone_hz": 2000.0} if which == TONE_I else {"cut_hz": 400.0} + source, _ = to_source(x, 2, rate) + touched = cls(source, sample_rate=rate, **dict(ctor, **filt)) + + def move(frame): + if frame == loud: + touched.set_macro(which, 127 if which == TONE_I else 0) + + a = pull(touched, frames, 2, move).astype(np.int32) + clean, _ = to_source(x, 2, rate) + b = pull(PingPongDelay(clean, sample_rate=rate, **ctor), + frames).astype(np.int32) + d = np.abs(a[fresh:] - b[fresh:]) + return int(np.count_nonzero(d)), int(d.max()) + + def test_a_filter_really_in_then_out_is_out(self): + # The out-stop tests above hand the out stop before any audio. + # Here the filter plays 1 s of noise first: once those repeats have + # died, the instance renders the same bytes as one whose filter was + # never in, at three rates. Planted: the retired cures, the Tone + # stop tracking with the Feedback stepped, and Cut held at 20 Hz. + for rate in RATES: + for which in (TONE_I, CUT_I): + self.assertEqual(self._in_then_out(PingPongDelay, which, + rate), (0, 0), + (rate, which)) + self.assertGreater(self._in_then_out(TrackingPingPong, TONE_I, + rate)[0], 0, rate) + self.assertGreater(self._in_then_out(HeldCutPingPong, CUT_I, + rate)[0], 0, rate) + + 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. + + class OneLapShort(PingPongDelay): + NAME = 'PingPongDelay' + + def _tail_bound(self): + laps = pp.laps_to_zero(self._feedback, 0.0) + return int((laps - 1) * (self._reach + 1)) + + def cell(cls, cut_midi, feedback, spread): + loud = RATE // 2 // BLOCK * BLOCK + bound = PingPongDelay(silence_src(64), sample_rate=RATE, + time_ms=20.0, feedback=feedback, + spread=spread).tail_samples + frames = loud + bound + RATE // 4 + x = np.zeros(frames) + x[:loud] = np.random.RandomState(4242).uniform(-32767, 32767, + loud) + source, _ = to_source(x, 2) + effect = cls(source, sample_rate=RATE, time_ms=20.0, + feedback=feedback, spread=spread, mix=2.0) + effect.set_macro(CUT_I, cut_midi) + seen = {} + + def move(frame): + if frame == loud: + effect.set_macro(CUT_I, 0) + seen["declared"] = effect.tail_samples + + y = pull(effect, frames, 2, move) + last = int(np.flatnonzero(y.any(axis=1))[-1]) - loud + 1 + return seen["declared"], last + + for cut_midi in (127, 1): + for feedback in (0.45, 0.85): + for spread in (1.0, 0.0): + declared, tail = cell(PingPongDelay, cut_midi, feedback, + spread) + self.assertIsNotNone(declared) + self.assertLessEqual(tail, declared, + (cut_midi, feedback, spread)) + self.assertGreater(tail, declared // 2) + self.assertIsNone(cell(HeldCutPingPong, 127, 0.45, 1.0)[0]) + declared, tail = cell(OneLapShort, 127, 0.45, 1.0) + self.assertGreater(tail, declared) + def test_the_tail_reaches_exact_zero_inside_tail_samples(self): on = 200 * RATE // 1000 for options in ({}, {"patch": 0}, {"patch": 4}, {"patch": 5}, From ac2181fc992e6f88763b373d05b3e1784100b7b1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:21:37 -0500 Subject: [PATCH 52/88] MultiTapDelay fix round 2: a second reset or Mix-0 return between two pulls keeps the heads on time, and rates below 12 825 Hz are refused _resync now swaps tap 1's pending block for a block of zeros instead of pulling it: the Splitter reads the input adapter through a new port, which _resync points at the sourceless MidSide for one pull of tap 1 and one of the dry tap. That leaves tap 0 caught up and tap 1 one block of zeros behind from either starting state, so two or three calls in one gap leave what one does and nothing is taken from the source. The constructor refuses a rate at which Time's 20 ms lands on 256 frames or fewer (below 12 825 Hz), where the one-block lag could not place the head. The LAG comment, the docstring's reset and latency paragraphs and the CHANGELOG line say what the code does now. Tests, each shown able to fail: a second _resync in one gap (reset twice, three times, reset then Mix 0 and back, Mix 0 and back twice; clicks in the first and second block after the event, every lane against the law, three rates and mono, and from the lean graph), red on PullingResync, which is d419ac4's _resync (A dry only and B's heads 256 early at two resets; one reset and one Mix-0 return green on it as the control); the refusal at 8 000 / 11 025 / 12 000 / 12 800 / 12 824 Hz and Time 20 ms on the grid at 12 825 and 16 000 Hz, red on NoRateFloor (d419ac4 without the floor: heads at [512, 768, 1024] for [256, 512, 768] at 12 800 Hz, ValueError from the node at 12 000 Hz); and T5 clause 1's plant built on this graph (Head2OwnLoop, clause 1 16.492 / 16.079 / 14.652 dB in both lanes at the defaults, flag walked over 160 positions). --- CHANGELOG.md | 7 +- lib/audioeffects/rebuilt/multitapdelay.py | 89 ++++-- tests/test_cpython_effects_multitapdelay.py | 285 +++++++++++++++++++- 3 files changed, 353 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f94454..62cd519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,10 +47,13 @@ there, and are recorded in its changelog. `lib/audioeffects/rebuilt/multitapdelay.py`, and `audioeffects.MultiTapDelay` is still the old class. The tap node reads its input one block behind the dry, so settings made before the first - pull or after `reset()` land on the grid in both lanes, a reset leaves - the output on time, and any number of Repeat Tone crossings between + pull or after `reset()` land on the grid in both lanes, the dry stays at + +0 and every head at +k n1 after any number of resets and returns from + Mix 0 between two pulls, and any number of Repeat Tone crossings between pulls leave the wet where it was; the output ends in a width-1 MidSide, so a host resetting the output keeps the source's first block. The + constructor refuses a sample rate below 12 825 Hz with a `ValueError`, + where the one-block lag could not place the 20 ms head. The docstring states the input ceiling, the click and channel crossing of a Time or Heads move while audio plays, and that on CircuitPython alone the stereo dry's right lane is one LSB hot within 32 LSB of the rails. diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index e1df434..f896720 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -98,7 +98,8 @@ the grid is claimed while Time or Heads moves with audio playing. A move made before the first pull, or after `reset()` and before the next pull, is not a move of that kind: the tap node's line holds only zeros then, so -the first lap lands on the grid in both lanes. +the first lap lands on the grid in both lanes. That holds however many +times you call `reset()`, or take Mix to 0 and back, between two pulls. **Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). The laps are `audioecho.FeedbackDelay` and the dry fan-out is @@ -106,9 +107,17 @@ are stock. On a stock CircuitPython board this module imports cleanly and construction raises `ImportError`. -**Latency: zero samples, at every setting, patch and rate**, and after -`reset()` from any graph. The dry is a Splitter tap into a Mixer voice, a -wire, and nothing looks ahead. The heads are the effect, not latency, and +**Sample rate: 12 825 Hz and up.** The tap node reads one block behind +the dry, so each head's offset is handed 256 frames short, and the 20 ms +head must land on more than 256 frames for that to place it. Below +12 825 Hz it would not, and the constructor raises `ValueError` rather +than build a class whose Time knob cannot reach its low end. + +**Latency: zero samples, at every setting and patch, at every rate the +class accepts**, and after `reset()` from any graph, however many resets +and returns from Mix 0 land between two pulls: the dry stays at +0 and +every head at +k n1 against it. The dry is a Splitter tap into a Mixer +voice, a wire, and nothing looks ahead. The heads are the effect, not latency, and no option adds any. The output ends in an `audioroute.MidSide` at width 1, the identity, whose reset forwards nothing: a host that resets the output (a mixer voice's `play()` does) no longer reaches the Mixer, whose voices @@ -222,10 +231,18 @@ #: How far the tap node's own timeline runs behind the dry, in frames, and #: so how much shorter than k n1 each head's offset is handed (`_route`). -#: The shortest head is 20 ms, 441 frames at 22.05 kHz, so every offset -#: stays positive. +#: Every offset k n1 - LAG must be at least one frame: an offset of 0 reads +#: the line a whole lap back, and a negative one is a position the node +#: refuses. The shortest head is Time's 20 ms, and the lap clamp never +#: lands n1 below it, so that holds exactly where 20 ms lands on more than +#: LAG frames: at MIN_SAMPLE_RATE and above (257 frames at 12 825 Hz, 441 +#: at 22.05 kHz). The class refuses a lower rate at construction. LAG = BLOCK +#: The lowest sample rate the class accepts: the least whole rate at which +#: Time's 20 ms lands on more than LAG frames, floor(20 fs / 1000 + 0.5). +MIN_SAMPLE_RATE = 12825 + #: S1's `Head Combinations for Each Mode`, modes 1-11. Mode 12 is every #: head on the plain grid (section 8.3), so it is not in the table. HEAD_SETS = ((1,), (2,), (3,), (1, 2), (2, 3), (1, 3), (1, 2, 3), (1, 4), @@ -426,6 +443,7 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, max_lap_ms=MAX_LAP_MS, patch=None): rate = self._sample_rate channels = self._channel_count + self._check_rate(rate) max_lap_ms = _number(max_lap_ms, MAX_LAP_MS) if max_lap_ms > MAX_LAP_MS: max_lap_ms = MAX_LAP_MS @@ -484,17 +502,23 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, adapter = audioroute.MidSide(width=1.0, sample_rate=rate, channel_count=channels) adapter.play(self._source) - split = audioroute.Splitter(adapter, taps=2) + # A MidSide with no source hands out one block of zeros per pull and + # never finishes: what the tap node primes from, what the input + # adapter reads while a reset wires the graph, and what the Splitter + # reads while `_resync` swaps tap 1's pending block for zeros + # (`_route`). + self._hush = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + # The Splitter reads the adapter through a port, so `_resync` can + # point it at `_hush` for two pulls without touching the adapter + # (whose unread rest of a long source buffer stays where it is). + self._in = audioroute.Port(adapter) + split = audioroute.Splitter(self._in, taps=2) dry = split.tap(0) tap1 = split.tap(1) self._silence = audiocore.RawSample( array("h", bytes(2 * 2 * channels)), sample_rate=rate, channel_count=channels) - # A MidSide with no source hands out one block of zeros per pull and - # never finishes: what the tap node primes from, and what the input - # adapter reads while a reset wires the graph (`_route`). - self._hush = audioroute.MidSide(width=1.0, sample_rate=rate, - channel_count=channels) # The tap node always plays this port; the graph's two shapes are # the port pointed at the lap node or at the Splitter's tap, and a # re-point is one store that pulls nothing. @@ -548,6 +572,7 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, self._own(dry, reset=False) self._own(tap1, reset=False) self._own(split, reset=False) + self._own(self._in, reset=False) self._own(adapter) self._own(self._hush, reset=False) self._own(self._silence, reset=False) @@ -562,6 +587,16 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, self._ready = True self._route() + def _check_rate(self, rate): + """Refuse, before anything is built, a rate at which the shortest + head would be handed an offset of 0 or less (`LAG`).""" + if whole_frames(TIME_MIN_MS, rate) <= LAG: + raise ValueError( + "MultiTapDelay needs a sample rate of at least %d Hz (got " + "%d): below it the 20 ms head is %d frames or fewer, which " + "the tap node's one-block lag cannot place" + % (MIN_SAMPLE_RATE, rate, LAG)) + # -- the maps ------------------------------------------------------ def _value(self, index): @@ -752,9 +787,11 @@ def _route(self): the lap node going in), which pulls nothing, so any number of crossings between two pulls leaves the wet where it was against the dry. Coming back from Mix 0, and in `reset()`, `_resync` empties - both lines, drops that one pending block from tap 1 and primes the - tap node with zeros again, so no audio from before either reaches - the lines and nothing is taken from the source. + both lines and swaps that one pending block on tap 1 for a block of + zeros (the Splitter reads `_hush` for one pull of each tap), so no + audio from before either reaches the lines, nothing is taken from + the source, and a second or third `_resync` before the next pull + leaves exactly what the first did. A reset that has to wire the graph (a class never wired at Mix 0) must not pull the source either: the input adapter plays `_hush` @@ -819,15 +856,27 @@ def _wire(self, quiet=False): pull(self._mixer) def _resync(self): - """Empty both lines, drop the block tap 1 holds for the tap node, - and prime the tap node with zeros again (`_route`).""" + """Empty both lines and swap the block tap 1 holds for the tap node + for a block of zeros, the same however many times it runs between + two pulls (`_route`). + + With the Splitter reading `_hush`, one pull of tap 1 then one of the + dry tap always leaves tap 0 caught up and tap 1 one block of zeros + behind it. From the usual state (tap 1 holding the source's last + block, tap 0 caught up) the first pull drops that block and the + second writes the zeros. From the state this leaves, a second call + reads those zeros back and writes them again, so nothing of the + source is taken and the tap node stays one block behind the dry.""" pull = getattr(audiocore, "get_buffer", None) self._fd.clear() audiocore.reset_buffer(self._tapnode) if pull is not None: - pull(self._tap1) - self._feed.play(self._hush) - self._tapnode.play(self._feed, loop=False) + self._in.play(self._hush) + try: + pull(self._tap1) + pull(self._dry) + finally: + self._in.play(self._adapter) self._feed.play(self._target(self._lean)) self._plugged = self._lean diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 4593b61..b6608b6 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -16,12 +16,13 @@ class built as a wire. The full spans, the three interpreters and the rates The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by `rebuilt.module_class("MultiTapDelay")`. -Two plants are built for real here that Station A emulated: T4's per-head -6 kHz low-pass (a second tap node for head 2 behind an -`audiofilters.Filter`) and T5 clause 2's compose-first build (a front -Filter into the tap node's own decay). T5 clause 1's plant, head 2 -darkening on its own each lap, is still the dossier's emulation on the -rendered windows, said where it is. +Three plants are built for real here that Station A emulated: T4's +per-head 6 kHz low-pass (a second tap node for head 2 behind an +`audiofilters.Filter`), T5 clause 2's compose-first build (a front Filter +into the tap node's own decay) and, since fix round 2, T5 clause 1's +(`Head2OwnLoop`: head 2 read through its own, darker lap node, Station C's +plant ported to the fix round 1 graph). The dossier's emulation of clause +1's plant on the rendered windows stays beside it. """ import math @@ -37,6 +38,7 @@ class built as a wire. The full spans, the three interpreters and the rates import audiocore # noqa: E402 import audiodelays # noqa: E402 +import audioecho # noqa: E402 import audiofilters # noqa: E402 import audiomixer # noqa: E402 import audioroute # noqa: E402 @@ -714,6 +716,114 @@ def _tone_damping(self): self._sample_rate) +class PullingResync(MultiTapDelay): + """Gate audit round 2, item 1, planted: fix round 1's `_resync` + (d419ac4), which drops tap 1's pending block with a pull and primes the + tap node with zeros. A second call before the next pull takes a block + of the source's future, and every later head sounds a block early.""" + + NAME = 'MultiTapDelay' + _plant_pulling_resync = True + + def _resync(self): + pull = getattr(audiocore, "get_buffer", None) + self._fd.clear() + audiocore.reset_buffer(self._tapnode) + if pull is not None: + pull(self._tap1) + self._feed.play(self._hush) + self._tapnode.play(self._feed, loop=False) + self._feed.play(self._target(self._lean)) + self._plugged = self._lean + + +class NoRateFloor(MultiTapDelay): + """Gate audit round 2, item 2, planted: the class without its rate + floor (d419ac4). At 12 800 Hz Time 20 ms lands on 256 frames, head 1 is + handed an offset of 0, which reads a whole lap back, and every head + sounds 256 frames late; below it the constructor's own Time raises + from the node.""" + + NAME = 'MultiTapDelay' + + def _check_rate(self, rate): + del rate + + +class Head2OwnLoop(MultiTapDelay): + """T5 clause 1, built for real (Station C's plant, ported to the fix + round 1 graph): head 2 is read by a second tap node behind a second + lap node whose loop low-pass sits at half Repeat Tone's corner + (pre-warped the class's way), so on every lap after the first head 2 + darkens more than heads 1 and 3; lap 1, the lap nodes' dry pass, is the + same for every head. Both lap nodes are fed from a second Splitter on + tap 1, and head 2 is a third voice of the output Mixer. Both tap nodes + are wired the class's way, a block of zeros first and one block behind + the dry. Full graph only; read at Repeat Tone in.""" + + NAME = 'MultiTapDelay' + + def _damping2(self): + if self._macros[TONE_I] >= 1.0: + return 0.0 + return nominal_damping_hz(0.5 * self._hz(self._value(TONE_I)), + self._sample_rate) + + def _others(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k != 2) + + def _mine(self): + return tuple(t for k, t in zip(self._selected, self._taps) if k == 2) + + def _wire(self, quiet=False): + rate, channels = self._sample_rate, self._channel_count + self._plant_head2_loop = True + split = audioroute.Splitter(self._tap1, taps=2) + self._fd.play(split.tap(0)) + self._fd2 = audioecho.FeedbackDelay( + sample_rate=rate, channel_count=channels, + max_delay_ms=self._max_lap_ms + 1.0, delay_ms=self._lap_ms, + feedback=self._feedback, mix=self._feedback, + damping_hz=self._damping2(), cut_hz=0.0, delay_slew=0.0) + self._fd2.play(split.tap(1)) + self._head2 = audiodelays.MultiTapDelay( + max_delay_ms=int(math.ceil(self._max_lap_ms)) + 1, + delay_ms=self._tap_ms, decay=0.0, mix=1.0, taps=self._mine(), + buffer_size=BLOCK * channels * 2, sample_rate=rate, + channel_count=channels) + self._feed2 = audioroute.Port(self._hush) + self._head2.play(self._feed2) + self._tapnode.taps = self._others() + self._feed.play(self._hush) + self._tapnode.play(self._feed) + mix = self._value(MIX_I) + self._mixer = audiomixer.Mixer( + voice_count=3, buffer_size=BLOCK * channels * 4, + channel_count=channels, sample_rate=rate) + self._mixer.voice[0].level = min(1.0, 2.0 - mix) + self._mixer.voice[1].level = min(1.0, mix) + self._mixer.voice[2].level = min(1.0, mix) + _component.open_level_gates(self._mixer, self._mixer.voice, + self._silence) + self._mixer.voice[0].play(self._dry) + self._mixer.voice[1].play(self._tapnode) + self._mixer.voice[2].play(self._head2) + self._feed.play(self._fd) + self._feed2.play(self._fd2) + self._tail.play(self._mixer) + self._plugged = False + + def _refresh(self): + MultiTapDelay._refresh(self) + if getattr(self, "_head2", None) is not None: + self._tapnode.taps = self._others() + self._head2.taps = self._mine() + self._fd2.set(delay_ms=self._lap_ms, feedback=self._feedback, + mix=self._feedback, damping_hz=self._damping2(), + cut_hz=0.0, delay_slew=0.0) + self._mixer.voice[2].level = self._mixer.voice[1].level + + class Counting(Endless): """`Endless` that counts the blocks it has handed out.""" @@ -968,6 +1078,70 @@ def crossing_route(cls, crossings, rate=RATE, channels=2): "law": [f - origin for f in law]} +#: Round 2, item 1: two clicks after an event made between two pulls at +#: 30 blocks, A in the first block after it and B in the second, at +#: different levels so their heads cannot be mistaken for each other. +RESYNC_CLICKS = ((30 * BLOCK + 10, 20000), (31 * BLOCK + 100, 10000)) + + +def resync_route(cls, steps, start="full", rate=RATE, channels=2): + """Gate audit round 2, item 1: 30 blocks from the defaults (the full + graph) or patch 1 (the lean graph), then `steps` between two pulls + ("reset" is `reset()`, "mix0" is Mix to 0 and back), then Feedback 0 + and Mix 1.0: every lane's non-zero frames against each click and its + heads at +k n1, n1 and the heads from the dossier's laws at the knob + positions.""" + total = 32 * BLOCK + 40000 + data = array("h", [0] * (total * channels)) + for at, level in RESYNC_CLICKS: + for ch in range(channels): + data[at * channels + ch] = level + source = Endless(data, rate, channels) + if start == "lean": + effect = cls(source, sample_rate=rate, patch=1) + else: + effect = cls(source, sample_rate=rate) + head = pull(effect, 30 * BLOCK) + for step in steps: + if step == "reset": + effect.reset() + else: + effect.set_macro(MIX_I, 0) + effect.set_macro(MIX_I, 63.5) + effect.set_macro(FEEDBACK_I, 0) + effect.set_macro(MIX_I, 63.5) + heads = law_heads(effect._pattern_mode(), effect._heads) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + rest = pull(effect, 2 * BLOCK + 100 + lap + 512) + reported = effect.latency_samples + effect.deinit() + out = np.concatenate([head, rest]) + law = sorted(at + k * n1 for at, _level in RESYNC_CLICKS + for k in (0,) + heads) + hits = lane_hits(out) + return {"passed": all(h == law for h in hits) and reported == 0, + "hits": hits, "law": law} + + +def low_rate_first_lap(cls, rate, how, channels=2): + """Gate audit round 2, item 2: Time 20 ms by the constructor + (`time_ms=20`) or by `set_macro(0, 0)`, Feedback 0, Mix 2, a 20 000 + click in frame 0: every lane's non-zero frames against {k n1}.""" + if how == "ctor": + effect = cls(Endless(click(channels=channels), rate, channels), + sample_rate=rate, feedback=0.0, mix=2.0, time_ms=20.0) + else: + effect = cls(Endless(click(channels=channels), rate, channels), + sample_rate=rate, feedback=0.0, mix=2.0) + effect.set_macro(TIME_I, 0) + n1, lap = law_landed(20.0, 3, rate) + law = [k * n1 for k in law_heads(7, 3)] + hits = lane_hits(pull(effect, lap + 512)) + effect.deinit() + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + def level_reading(cls, rate=RATE, tone=4000.0): """Item 6, section 6 row 3's Feedback law (the material refuter's reading): click 20 000, the defaults' cell (150 ms, mode 7, K 3), @@ -1739,5 +1913,104 @@ def test_null_build_is_red(self): MultiTapDelay, lambda cls: corner_reading(cls), label="corner") +class RoundTwo(unittest.TestCase): + """Gate audit round 2: a second `_resync` between two pulls, the rate + floor, and T5 clause 1's plant built on the fix round 1 graph.""" + + ROUTES = (("reset", "reset"), ("reset", "mix0"), ("mix0", "mix0"), + ("reset", "reset", "reset")) + + def test_a_second_resync_in_one_gap_keeps_the_heads_on_time(self): + # Red on d419ac4 (PullingResync below): click A's heads missing and + # B's 256 frames early at two, 512 at three. + for steps in self.ROUTES: + for rate, channels in ((RATE, 2), (44100, 2), (22050, 2), + (RATE, 1)): + result = resync_route(MultiTapDelay, steps, rate=rate, + channels=channels) + self.assertTrue(result["passed"], + (steps, rate, channels, result)) + result = resync_route(MultiTapDelay, steps, start="lean") + self.assertTrue(result["passed"], (steps, "lean", result)) + + def test_pulling_resync_plant_is_red(self): + # The control: one reset, and one return from Mix 0, are green on + # the plant too; that is all fix round 1 tested. + for steps in (("reset",), ("mix0",)): + self.assertTrue(resync_route(PullingResync, steps)["passed"], + steps) + self.assertTrue(resync_route(MultiTapDelay, steps)["passed"], + steps) + for steps in self.ROUTES: + for rate, channels in ((RATE, 2), (44100, 2), (22050, 2), + (RATE, 1)): + result = resync_route(PullingResync, steps, rate=rate, + channels=channels) + self.assertFalse(result["passed"], + (steps, rate, channels, result)) + result = resync_route(PullingResync, ("reset", "reset")) + # A (7 690) sounds dry only; B (8 036) and its heads 256 early. + # reset() restores patch 0: Time MIDI 85, n1 7 129 at 48 kHz. + n1 = law_landed(law_time_ms(85), 3, RATE)[0] + self.assertEqual(n1, 7129) + self.assertEqual(result["hits"][0], + [7690, 8036] + [8036 + k * n1 - 256 + for k in (1, 2, 3)]) + result = reach_flag(PullingResync, "_plant_pulling_resync") + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_rates_below_the_floor_are_refused(self): + for rate in (8000, 11025, 12000, 12800, 12824): + for channels in (2, 1): + for options in ({}, {"time_ms": 20.0}): + with self.assertRaises(ValueError) as caught: + MultiTapDelay(Endless(silence(), rate, channels), + sample_rate=rate, **options) + self.assertIn("12825 Hz", str(caught.exception)) + with self.assertRaises(ValueError): + MultiTapDelay.create(Endless(silence(), rate, 2), rate) + + def test_time_20_ms_lands_on_the_grid_at_the_floor(self): + for rate in (12825, 16000): + for channels in (2, 1): + for how in ("ctor", "macro"): + result = low_rate_first_lap(MultiTapDelay, rate, how, + channels) + self.assertTrue(result["passed"], + (rate, channels, how, result)) + self.assertEqual(low_rate_first_lap(MultiTapDelay, 12825, "ctor") + ["law"], [257, 514, 771]) + + def test_no_rate_floor_plant_is_red(self): + # d419ac4 at 12 800 Hz: heads at [512, 768, 1 024] for + # [256, 512, 768]; at 12 000 Hz Time's low end raises from the node. + for how in ("ctor", "macro"): + result = low_rate_first_lap(NoRateFloor, 12800, how) + self.assertFalse(result["passed"], (how, result)) + self.assertEqual(result["hits"], [[512, 768, 1024]] * 2) + self.assertEqual(result["law"], [256, 512, 768]) + with self.assertRaises(ValueError) as caught: + low_rate_first_lap(NoRateFloor, 12000, how) + self.assertNotIn("12825 Hz", str(caught.exception)) + self.assertTrue(low_rate_first_lap(NoRateFloor, 16000, + "ctor")["passed"]) + + def test_head2_own_loop_is_red_on_clause_1(self): + # Built, not emulated: lap 1 is the same for every head (the lap + # nodes' dry pass), and head 2 darkens on its own after it. + for rate in RATES: + result = t45_reading(Head2OwnLoop, rate=rate) + for lane in result["lanes"]: + self.assertTrue(lane["present"], (rate, lane)) + self.assertEqual(lane["lap1"], [24000.0] * 3, rate) + self.assertGreater(lane["t5"], 0.5, (rate, lane)) + self.assertFalse(t5_green(result, 4000.0, RATE), rate) + self.assertTrue(t5_green(t45_reading(MultiTapDelay, rate=rate), + 4000.0, rate), rate) + result = reach_flag(Head2OwnLoop, "_plant_head2_loop") + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + if __name__ == "__main__": unittest.main() From 71e680b3234e84180ff0170eccc7d11623ff4ba5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:32:07 -0500 Subject: [PATCH 53/88] AnalogDelay re-audit fix round 1: a Modulation move read phase by phase, T3's right channel held to its left The Modulation-move test compared the largest step over eight triangle phases with the largest of a bar (the output's step before the move times 1 + |change| / 20 ms) that leaves out the triangle's slope after the move; read phase by phase that bar is over on the clean class at 16 of 240 cells. The bar is now the source's own largest step times 1 + |change| / 20 ms + 4 x Mod Rate x swing, plus 1, per phase: 0 over at 240 cells on v0.6.3rc1 (worst 0.943), 133 over on v0.6.2's node; JumpModAnalog over it at 8 of 8. The docstring says the same. T3 on a stereo render now fails unless the right channel equals the left byte for byte (the round-3 audit's restatement 1): RightReadStep, a step on the right alone, passes every left clause and is red on the identity. RightReadStep, SteppedAnalog and JumpModAnalog join the reachability walk (143 positions, three rates). The audio path is unchanged: 48 of 48 Station C digests on three interpreters. --- lib/audioeffects/rebuilt/analogdelay.py | 17 +- tests/test_cpython_effects_analogdelay.py | 181 ++++++++++++++++++---- 2 files changed, 164 insertions(+), 34 deletions(-) diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index 57c0925..9b25fe3 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -130,12 +130,17 @@ class cannot see the head. A knob turned through several positions sends (audiodsp#160), where up to v0.6.2 it jumped the read by the change in depth times where the triangle stood (142 frames for 5 -> 2 ms at the triangle's peak, 48 kHz). While the swing travels the extra pitch is the -change over 20 ms times where the triangle stands: 5 -> 2 ms at the peak -bends the repeats 15 % (about 240 cents) for those 20 ms. On a 997 Hz tone -at 12 000 LSB, wet only, Time 300 ms, Mod Rate 1 Hz, 48 kHz, a move from -1 to 1.5 ms steps at most 1 522 LSB over the 2 000 frames after it, where -the tone's own largest step is 1 491 and the ramp allows 1 528 (7 337 at -v0.6.2). Mod Rate moves keep the triangle's phase and do not step. +change over 20 ms times where the triangle stands: 5 -> 2 ms at a peak +bends the repeats by 15 % for those 20 ms, +242 cents at one peak and +-281 at the other. So the read moves at most |change| / 20 ms plus the +triangle's own 4 x Mod Rate x swing of a frame per frame faster or slower +than the tone, and no step in the output is larger than the input's own +largest step times 1 plus that: on a 997 Hz tone at 12 000 LSB, wet only, +Time 300 ms, five moves between 0 and 5 ms at Mod Rate 1 and 8 Hz and +eight points of the triangle, at 48, 44.1 and 22.05 kHz, the largest step +in the 40 ms after a move is at most 0.943 of that (1 -> 1.5 ms at 1 Hz, +48 kHz: 1 522 LSB against 1 615, where v0.6.2's node read 7 133). Mod +Rate moves keep the triangle's phase and do not step. **Input ceiling.** The dry path sits at unity and the repeats add to it, so a hot input can put the output on the int16 rail; there is no input diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index 368964a..35a63ec 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -169,6 +169,8 @@ class _ReadStep(kit_faults._Node): `frames` frames late: the read stepping back that far, once, and staying there.""" + right_only = False + def __init__(self, source, frames): kit_faults._Node.__init__(self, source) self.frames = int(frames) @@ -186,7 +188,11 @@ def _process(self, block): if self.step_at is not None: start = max(0, self.step_at - self.pulled) if start < count: - out[start * channels:] = late[start * channels:] + if self.right_only and channels > 1: + first = start * channels + 1 + out[first::channels] = late[first::channels] + else: + out[start * channels:] = late[start * channels:] self.pulled += count return out @@ -215,6 +221,19 @@ def _refresh(self): step.step_at = step.pulled + int(walk // 2) +class RightReadStep(MidWalkReadStep): + """T3's stereo identity (dossier section 8, R10): `MidWalkReadStep` on + the right channel alone, so the left, which every T3 clause reads, walks + clean. Nothing the surface reaches treats the two lanes differently at + Spread 0.""" + + NAME = 'AnalogDelay' + + def _build(self, *arguments, **options): + MidWalkReadStep._build(self, *arguments, **options) + self._step.right_only = True + + class EarlyStepWalk(AnalogDelay): """T3's inside clause at the start of a walk (the round-2 re-refuter's plant): on a Time move the node is handed `T_new - STEP` frames and @@ -631,7 +650,13 @@ def move(frame): effect.set_macro(TIME_I, target) handed.update(getattr(effect._delay, "_handed", {})) - out = left(pull(effect, len(values), channels, on_block=move), channels) + interleaved = pull(effect, len(values), channels, on_block=move) + if handed is not None and channels > 1: + # Every clause reads the left channel; the right is held to it byte + # for byte (dossier section 8, R10), so the left clauses read both. + lanes = interleaved.reshape(-1, channels).astype(np.int64) + handed["lr"] = int(np.max(np.abs(lanes - lanes[:, :1]))) + out = left(interleaved, channels) effect.deinit() return out, move_at @@ -934,7 +959,8 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, "inside_bar": inside_bar, "pre_bar": pre_bar, "damping_hz": damping, "damping_before": damping_before, "settle": settle, "inside_r1": inside_r1, - "later_bar": later_bar, "walk_ok": walk_ok} + "later_bar": later_bar, "walk_ok": walk_ok, + "lr": int(handed.get("lr", 0))} if walk < 64 or end + int(0.25 * rate) + 1 > frames: # No walk at all, or one that never lands on T_new inside the # render: the walk clause is red and the pitch has nothing to read. @@ -963,7 +989,8 @@ def t3_measure(cls, rate=RATE, character=SINGLE, t_old=200.0, t_new=100.4, and abs(residual) <= 1.0 and inside_ok and gap_ok - and later <= 1.05 * later_bar) + and later <= 1.05 * later_bar + and result["lr"] == 0) return result @@ -1127,6 +1154,28 @@ def read_walk_law(effect): return round(slew / law, 4) +def read_feedback_as_set(effect): + """The Feedback handed to the node less the knob's (since audiodsp + v0.6.3rc1 the class hands the Feedback as set).""" + return round(effect._delay._handed["feedback"] + - effect.macro(FEEDBACK_I), 9) + + +def read_modulation_move(effect): + """On a copy at these positions, one Modulation move of 16 grid steps + after a block has been pulled: the `delay_ms` handed less the + whole-frame Time's hand-off (a Modulation move changes the depth, not + the delay).""" + rate = effect._sample_rate + other = copy_of(effect, silence_src(4 * BLOCK, 2, rate)) + pull(other, BLOCK, 2) + now = other.get_macro(MODULATION_I) + other.set_macro(MODULATION_I, now + 16.0 if now < 64 else now - 16.0) + offset = other._delay._handed["delay_ms"] - other._node_ms + other.deinit() + return round(offset, 6) + + def read_dry_gain(effect): """The dry path's gain before the first repeat: a copy at these positions on a 256-frame full-scale ramp, least-squares out / in.""" @@ -1147,6 +1196,36 @@ def read_landing(effect): return frames >= effect._frames +def read_lanes(effect): + """At these positions, stereo, with Mix 2, Feedback 0 and Modulation 0, + one Time move of 32 grid steps once the line has filled, on a slope-1 + ramp in both lanes: the largest |L - R| over the render.""" + rate = effect._sample_rate + now = effect.get_macro(TIME_I) + new = now + 32.0 if now < 64 else now - 32.0 + k_old = effect._frames + k_new = whole(grid_ms(new), rate) + fill = (k_old + 2 * BLOCK) // BLOCK * BLOCK + frames = fill + int(1.3 * k_new) + 2 * BLOCK + ramp = (np.arange(frames) % 65536) - 32768 + other = type(effect)(array_src(ramp.tolist(), 2, rate), sample_rate=rate, + character=effect._character) + for index in range(len(type(effect).MACRO_LABELS)): + other.set_macro(index, effect.get_macro(index)) + other.set_macro(MIX_I, 127) + other.set_macro(FEEDBACK_I, 0) + other.set_macro(MODULATION_I, 0) + + def move(frame): + if frame == fill: + other.set_macro(TIME_I, new) + + lanes = pull(other, frames, 2, on_block=move).reshape(-1, 2) + other.deinit() + lanes = lanes.astype(np.int64) + return int(np.max(np.abs(lanes[:, 1] - lanes[:, 0]))) + + def read_step(effect): """On an open-loop copy at these positions, read with Mix 2, Feedback 0 and Modulation 0, one Time move of 32 grid steps once the line has @@ -1217,6 +1296,10 @@ def read_early(effect): ("MidWalkReadStep", MidWalkReadStep, read_step, {}), ("EarlyStepWalk", EarlyStepWalk, read_early, {}), ("LandingBlip", LandingBlip, read_step, {}), + ("RightReadStep", RightReadStep, read_lanes, {}), + ("SteppedAnalog", SteppedAnalog, read_feedback_as_set, + {"feedback": 0.99}), + ("JumpModAnalog", JumpModAnalog, read_modulation_move, {}), ) @@ -1667,6 +1750,29 @@ def test_a_landing_blip_is_red(self): and result["later"] <= 1.05 * result["later_bar"], result) + def test_stereo_is_the_left_channel_and_the_right_held_to_it(self): + # Revision R10: every clause reads the left channel, and on a + # stereo render the right must equal it byte for byte. A step on + # the right channel alone passes every left clause and is red on + # the identity only (the gate audit's round 3: 60 of 60 passed the + # row before this). + for rate, t_old, t_new in ((48000, 200.0, 100.4), + (48000, 100.0, 300.0), + (44100, 20.0, 60.0), + (22050, 60.0, 20.0)): + for character in (SINGLE, DOUBLE): + result = t3_measure(AnalogDelay, rate, character, t_old, + t_new, channels=2) + self.assertEqual(result["lr"], 0, (rate, t_old, character)) + self.assertTrue(result["passed"], (rate, t_old, result)) + for t_old, t_new in ((200.0, 100.4), (100.4, 200.0)): + result = t3_measure(RightReadStep, RATE, SINGLE, t_old, t_new, + channels=2) + self.assertGreater(result["lr"], 0, (t_old, result)) + self.assertFalse(result["passed"], (t_old, result)) + self.assertTrue(result["walk_ok"] and result["inside_ok"] + and result["gap_ok"], (t_old, result)) + def test_the_landing_gap_holds_on_the_class(self): for t_old, t_new in ((300.0, 100.0), (100.0, 300.0), (20.0, 60.0), (60.0, 20.0)): @@ -1978,43 +2084,62 @@ def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): self.assertNotEqual(stepped._feedback, 0.5) self.assertLess(abs(stepped._feedback - 0.5), 3e-5) - def _modulation_move(self, cls, start_ms, target_ms, points=8): - """(the tone's own largest step before the move, the largest step in - the 2 000 frames after it) over `points` moves a quarter of the 1 Hz - triangle apart: 997 Hz at 12 000 LSB, mono, wet only, Time 300 ms, - 48 kHz (`pin063cls_ad_modmove.py`).""" - first = (14400 + 9600) // BLOCK * BLOCK - steadies, worsts = [], [] + def _modulation_move(self, cls, start_ms, target_ms, mod_hz=1.0, + points=8): + """Per move, (the largest first difference in the 40 ms after it, + its bar) at `points` moves an eighth of the triangle's period + apart: 997 Hz at 12 000 LSB, mono, wet only, Time 300 ms, 48 kHz + (`analogdelay_refix1.py modmove`). The read offset is S(t) u(t) + fs / 1000 frames; while the node ramps the swing (20 ms, audiodsp + #160) |dD/dn| <= |change| / 20 + 0.004 r max(S_old, S_new), and + neither the linear-interpolated read nor the one-pole can raise a + first difference, so the bar is the source's own largest step times + 1 plus that, plus 1 for the output's rounding.""" + first = (14400 + RATE // 5) // BLOCK * BLOCK + spacing = int(RATE / mod_hz / 8.0) // BLOCK * BLOCK + change = abs(target_ms - start_ms) + rows = [] for k in range(points): - at = first + k * 47 * BLOCK + at = first + k * spacing values = sine_values(997.0, at + 2400, RATE, 12000) effect = cls(array_src(values, 1), sample_rate=RATE, time_ms=300.0, feedback=0.0, mix=2.0, spread=0.0, - modulation_ms=start_ms, mod_rate_hz=1.0) + modulation_ms=start_ms, mod_rate_hz=mod_hz) def move(frame, at=at, effect=effect): if frame == at: effect.set_macro(MODULATION_I, target_ms * 127.0 / 5.0) - y = pull(effect, at + 2000 + BLOCK, 1, + y = pull(effect, at + 1920 + BLOCK, 1, on_block=move).astype(float) - steadies.append(float(np.abs(np.diff(y[at - 3000:at - 1])).max())) - worsts.append(float(np.abs(np.diff(y[at - 1:at + 2000])).max())) - return max(steadies), max(worsts) + effect.deinit() + source = float(np.abs(np.diff(np.array(values, float))).max()) + bar = source * (1.0 + change / 20.0 + + 0.004 * mod_hz * max(start_ms, target_ms)) + 1 + rows.append((float(np.abs(np.diff(y[at - 1:at + 1920])).max()), + bar)) + return rows def test_a_modulation_move_does_not_step(self): # Since audiodsp v0.6.3rc1 the node ramps a new swing in over 20 ms - # (#160). While it travels the read offset may move |change| / 20 ms - # of a frame per frame on top of the triangle, so the tone may slope - # up to its own largest step times 1 + |change| / 20 ms, and no more. - # Up to v0.6.2 the same moves read 7 337 (1 -> 1.5 ms) and 2 107 - # (5 -> 0 ms). Planted: the read head moved by the whole change. - for start, target in ((1.0, 1.5), (5.0, 0.0), (5.0, 2.0)): - bar = 1.0 + abs(target - start) / 20.0 - steady, worst = self._modulation_move(AnalogDelay, start, target) - self.assertLessEqual(worst, steady * bar, (start, target)) - steady, worst = self._modulation_move(JumpModAnalog, 1.0, 1.5) - self.assertGreater(worst, steady * 1.025) + # (#160), so a Modulation move glides; at every phase each first + # difference stays under the ramp-and-triangle bar (worst 0.943 of + # it over 240 cells, three rates). On v0.6.2's node, which jumped + # the read, the same bar is red at 133 of those 240 cells, 4.42 x + # at worst (7 133 on 1 -> 1.5 ms). The bar at ba7948b, the output's own step before the move + # times 1 + |change| / 20, left out the triangle's slope after the + # move and was red on the clean class at 16 of those 240 cells. + # Planted: the read head moved by the whole change, half a period of + # the tone for 1 -> 1.5 ms (a 3 ms change is 2.99 periods, which no + # first difference sees). + for start, target in ((1.0, 1.5), (5.0, 0.0), (5.0, 2.0), + (0.0, 5.0)): + for mod_hz in (1.0, 8.0): + for worst, bar in self._modulation_move( + AnalogDelay, start, target, mod_hz): + self.assertLessEqual(worst, bar, (start, target, mod_hz)) + rows = self._modulation_move(JumpModAnalog, 1.0, 1.5) + self.assertEqual(sum(1 for worst, bar in rows if worst > bar), 8) def _walk_tail(self, cls): """600 ms of 997 Hz, Feedback 0, Mix 2; as the tone stops, Time From 80d2b70babf614258fbf619b501ef9921a6b0145 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:14:05 -0500 Subject: [PATCH 54/88] Reverb fix round 2: the floor knee as the band four seed sets give (FLOOR_BANDS; the floor tests assert the band's top to 2 s inside and the stop under its bottom outside, green on seeds 7-14, 15-22, 23-30 and 31-38; the plate's run on seeds 23-30 as a test; LongDiffusers red through the new assertion at the plate, room and chamber); the capped room at Size 0.5 held to 15 % (5.1 to 12.9 % over four sets); docstring: the knee paragraph and Damped Plate's knee as bands, T10 named at its three carriers with the tones between them and Concert Hall at 0.1 ms quoted --- lib/audioeffects/rebuilt/reverb.py | 28 +++++++---- tests/test_cpython_effects_reverb.py | 69 ++++++++++++++++++---------- 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py index f6af7fa..67bf925 100644 --- a/lib/audioeffects/rebuilt/reverb.py +++ b/lib/audioeffects/rebuilt/reverb.py @@ -76,10 +76,16 @@ class of reverberator; the room, chamber and hall are that network which a low Damping corner brings down on the room, chamber and hall, the class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the label. Both knees are tabled per character and Size in the dossier's -section 8.9, read on the 0-127 grid at each character's reference patch; -the plate has no ceiling knee on the span, and the chamber at Size 1.5 has -no floor knee at or under 2 s (its 2 s position reads 10 to 13 % long, -depending on the noise it is measured with). +section 8.9, read on the 0-127 grid at each character's reference patch. +Where the floor knee sits depends on the noise it is measured with, so it +is tabled as a band: the lowest and the highest knee read on four sets of +eight noise seeds. The bands' tops run from 0.45 s (the room at Size 0.5) +to 2 s (the plate at Size 1.25, the hall at Size 1.5), and the widest band +spans four Decay stops (the plate at Size 0.5, 0.3 to 1 s). From a band's +top up the label held on every set measured; inside the band it holds on +some sets and not others. The plate has no ceiling knee on the span, and +the chamber at Size 1.5 has no floor knee at or under 2 s on one set of +the four (its 2 s position reads 10 to 13 % long). **A sparse, quiet input decays sooner than the knob.** The tank's lines are 16-bit and truncate toward zero, which is what lets the tail reach exact @@ -95,7 +101,8 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the Small Room's Decay reads 0.454 s and it rings 0.576 s at 500 Hz; it ships so because the rooms must stay that much shorter than the hall. Damped Plate's reads 1.011 s and it rings 1.228 s, because at Size 1.25 the -plate's own ringing sets the time until Decay 1.5 s. +plate's own ringing sets the time until somewhere between Decay 1.5 and +2 s, depending on the noise it is measured with. **Latency: zero samples, at every setting, character and rate.** Nothing looks ahead. Predelay is the wet path, not latency on the dry. @@ -108,9 +115,14 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the into sidebands depends on the exact lines Size cuts. At 0.1 ms and 1 Hz on Steel Plate the sidebands of a 1 kHz tone sit 18.4 dB under it at Size 1.0 and 27.2 dB under it at patch 0's Size (1.0039), and over the nine Size -steps around it they range from 27.2 dB under to 12.5 dB over. From -0.27 ms up they measure within 20 dB of the tone on Steel Plate and -Concert Hall, for tones from 300 Hz to 3 kHz at every Mod Rate. +steps around it they range from 27.2 dB under to 12.5 dB over. Concert +Hall is no steadier there: at 0.1 ms and 1 Hz a 1 kHz tone's sidebands sit +2.3 dB under it and a 533.5 Hz tone's 32.5 dB under. From 0.27 ms up they +measure within 20 dB of the tone on Steel Plate and Concert Hall at every +Mod Rate for tones at 300 Hz, 1 kHz and 3 kHz, the three measured on the +grid. That is three tones, not the band between them: at 0.27 ms and 5 Hz +a 317.8 Hz tone on Concert Hall reads 23.9 dB under, and a 1004.9 Hz tone +on Steel Plate 21.2 dB under. **Tone** spans +/-12 dB of end-to-end tilt, which the node puts half at each end: +12 is about -6 dB at 40 Hz and +6 dB at 16 kHz. At the centre diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index a2b4ee5..3f241d5 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -1413,15 +1413,18 @@ def build(src): FLOOR_STOPS = (0.3, 0.45, 0.6, 0.8, 1.0, 1.25, 1.5, 2.0) KNEE_SIZES = (0.5, 0.75, 1.0, 1.25, 1.5) -#: Section 8.9's floor-knee table on the grid, as revised 2026-09-28 (R5): -#: the lowest Decay from which every position up to 2 s lands within -#: +/-12 % at 500 Hz. The chamber at Size 1.5 has none at or under 2 s: -#: its 2 s cell sits on the bar (T11, R7). -FLOOR_KNEES = { - "plate": (0.3, 0.45, 1.0, 1.5, 1.5), - "room": (0.45, 0.8, 0.8, 1.0, 1.0), - "chamber": (0.6, 0.8, 1.0, 1.5, None), - "hall": (0.6, 1.0, 1.5, 1.25, 2.0), +#: Section 8.9's floor knee as a band, as revised 2026-09-28 (R12): the +#: knee is the lowest Decay from which every position up to 2 s lands +#: within +/-12 % at 500 Hz, and the noise moves it, so each cell is the +#: (lowest, highest) knee read on four sets of eight seeds (7-14, 15-22, +#: 23-30, 31-38). The chamber at Size 1.5 has none at or under 2 s on one +#: set (its 2 s cell sits on the bar, T11, R7) and has its own test. +FLOOR_BANDS = { + "plate": ((0.3, 1.0), (0.45, 1.0), (1.0, 1.0), (1.5, 2.0), (1.5, 1.5)), + "room": ((0.45, 0.45), (0.8, 1.0), (0.8, 0.8), (1.0, 1.5), (1.0, 1.25)), + "chamber": ((0.6, 0.6), (0.8, 0.8), (1.0, 1.25), (1.5, 1.5), None), + "hall": ((0.45, 0.8), (1.0, 1.25), (1.25, 1.5), (1.25, 1.5), + (2.0, 2.0)), } @@ -1466,17 +1469,22 @@ def ceiling_t500(cls, character, size, decay, hint): class DecayKnees(unittest.TestCase): """Section 8.9's promise: a test that fails if a knee moves.""" - def _floor(self, character): - for size, knee in zip(KNEE_SIZES, FLOOR_KNEES[character]): - if knee is None: + def _floor(self, character, cls=None, seeds=SEEDS, sizes=KNEE_SIZES): + """What holds on every seed set: every position from the band's + top to 2 s inside +/-12 %, and the position under the band's + bottom outside (none where the bottom is the first stop).""" + cls = Reverb if cls is None else cls + for size, band in zip(KNEE_SIZES, FLOOR_BANDS[character]): + if band is None or size not in sizes: continue - i = FLOOR_STOPS.index(knee) - for t in FLOOR_STOPS[i:]: - e = knee_t500(Reverb, character, size, t) / t - 1.0 + bottom, top = band + for t in FLOOR_STOPS[FLOOR_STOPS.index(top):]: + e = knee_t500(cls, character, size, t, seeds) / t - 1.0 self.assertLessEqual(abs(e), 0.12, (character, size, t, e)) + i = FLOOR_STOPS.index(bottom) if i: t = FLOOR_STOPS[i - 1] - e = knee_t500(Reverb, character, size, t) / t - 1.0 + e = knee_t500(cls, character, size, t, seeds) / t - 1.0 self.assertGreater(abs(e), 0.12, (character, size, t, e)) def test_the_plates_floor_knees(self): @@ -1491,6 +1499,11 @@ def test_the_chambers_floor_knees(self): def test_the_halls_floor_knees(self): self._floor("hall") + def test_the_plates_floor_bands_hold_on_another_seed_set(self): + # the property the one-stop table lacked: the same assertions on + # seeds 23-30, where that table failed at two plate cells + self._floor("plate", seeds=tuple(range(23, 31))) + def test_the_chamber_at_size_1_5_has_no_knee_under_2_s(self): # T11's cell, Not claimed (R7): 1.5 s is outside, and 2 s reads # inside on seeds 7-14 but outside on seeds 23-30 @@ -1503,14 +1516,17 @@ def test_the_chamber_at_size_1_5_has_no_knee_under_2_s(self): self.assertGreater(e2b, 0.12, e2b) def test_longer_diffusers_move_the_floor_knee(self): - # the plate's, room's and chamber's knee position at Size 1.0 lands - # outside +/-12 % once their diffusers are 1.5 x longer (the hall's - # does not move: it reads +7.3 % at its 1.5 s knee, and the plant - # is recorded as blind there) + # the floor assertions at Size 1.0 fail on the plate, room and + # chamber once their diffusers are 1.5 x longer: the band's top + # reads +13.7 % (plate, 1 s), +21.9 % (room, 0.8 s) and +14.5 % + # (chamber, 1.25 s). The hall's does not move (+7.3 % at its 1.5 s + # top), and the plant is recorded as blind there. for character in ("plate", "room", "chamber"): - knee = FLOOR_KNEES[character][KNEE_SIZES.index(1.0)] - e = knee_t500(LongDiffusers, character, 1.0, knee) / knee - 1.0 - self.assertGreater(abs(e), 0.12, (character, knee, e)) + with self.assertRaises(AssertionError, msg=character): + self._floor(character, LongDiffusers, sizes=(1.0,)) + top = FLOOR_BANDS[character][KNEE_SIZES.index(1.0)][1] + e = knee_t500(LongDiffusers, character, 1.0, top) / top - 1.0 + self.assertGreater(abs(e), 0.12, (character, top, e)) def test_the_longer_diffusers_are_not_on_the_surface(self): result = reach(LongDiffusers, handed_cut) @@ -1522,7 +1538,12 @@ def test_the_ceiling_knees(self): misses = [] for character, size, t, pred in cells: got = ceiling_t500(Reverb, character, size, t, pred) - self.assertLessEqual(abs(got / pred - 1.0), 0.12, + # R13: on four seed sets the room at Size 0.5 lands 5.1 to + # 12.9 % long of the prediction (2 s reads +12.9 % on seeds + # 31-38), the other 15 cells within 6.7 %; the room's four + # cells are held to 15 %, a margin that is ours + bar = 0.15 if (character, size) == ("room", 0.5) else 0.12 + self.assertLessEqual(abs(got / pred - 1.0), bar, (character, size, t, got, pred)) misses.append(1.0 - got / t) # while the label there misses by up to about 60 % From 2ad414c6dd35c459a633dd92ae88f5c27d87002e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:31:03 -0500 Subject: [PATCH 55/88] ConvolutionReverb re-audit fix round 2: the re-audit round-1 audit's list, class side Words and tests only; the audio did not change. The docstring gives a single Room's distance from the Decay law as floors, each at its named cell: at least about 24 % on a mono room (+23.70 % at 44.1 kHz, +24.02 % there with a -20 dBFS click, +22.83 % at 48 kHz, +21.00 % at 22.05 kHz) and at least about 16.5 % on a stereo one (+16.20 % at 48 kHz, +16.47 % at 22.05 kHz, -20 dBFS). The one-sided example names its Rooms (seed 36 reads -2.7 / +1.8 dB, the default seed 1 +1.3 / -2.1 dB) and its noise draw. Predelay joins what moves the balance, in the docstring, the class summary and the CHANGELOG line; the class summary's floor names its rates; the balance cells' white-noise figures are dropped for the impulse's. Tests: D5SingleRoom pins the printed single-Room figures to their cells (red on the 1c9308b words "up to about 22 %" and on +23.70 or +16.20 moved half a point); D6OneSided reads the example at its Rooms (red on the 1c9308b words, with seed 36 changed to 1, and on SideTilt); D6Balance wants the summary's rates (red on the 1c9308b summary); the Predelay test has a set_macro leg with PredelayMidiSquared as its control (red with the plant as the clean class, which passes 1c9308b's test). --- CHANGELOG.md | 13 +- lib/audioeffects/rebuilt/convolutionreverb.py | 69 ++++-- .../test_cpython_effects_convolutionreverb.py | 217 +++++++++++++++++- 3 files changed, 266 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d505a..4bfa24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,12 +66,13 @@ there, and are recorded in its changelog. `lib/audioeffects/rebuilt/convolutionreverb.py`, and `audioeffects.ConvolutionReverb` is still the old class. The room holds its level over both channels together (for material about equally loud - on both sides), but Diffusion, Room, Damping and Decay move a stereo - room's left-right balance, by at least about 5.3 dB at 48 and 44.1 kHz - and 4.7 dB at 22.05 kHz (the widest found; how far it can go is not - known); fixing that is a node change, drafted as an audiodsp ask. The - docstring states that, the low-material lift a dark room gives, and the - partition a room-knob move or a mid-stream `reset()` drops. Board cost + on both sides), but Diffusion, Room, Damping, Decay and Predelay move + a stereo room's left-right balance, by at least about 5.3 dB at 48 and + 44.1 kHz and 4.7 dB at 22.05 kHz (the widest found; how far it can go + is not known); fixing that is a node change, drafted as an audiodsp + ask. The docstring states that, the low-material lift a dark room + gives, and the partition a room-knob move or a mid-stream `reset()` + drops. Board cost is unmeasured; by the cost table's line the default fits an S3, and anything over 0.091 s there is desktop-only. diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 199143d..f527c71 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -15,6 +15,9 @@ The re-audit's fix round 1 (after the round-3 audit) replaced that bound with the widest balance a walk over Diffusion's every position found, stated as a floor on the swing, not a bound; the audio did not change. +The re-audit's fix round 2 did the same for a single Room's distance from +the Decay law, named the Room behind the one-sided example below, and +added Predelay to what moves the balance; the audio did not change. **What it sounds like.** A short room behind your dry signal. With nothing loaded the class synthesizes the room: noise under an exponential that @@ -107,28 +110,43 @@ 6 kHz Damping and +13.28 dB at the 500 Hz stop. White-spectrum material about equally loud on both sides comes back at its own level, within 0.5 dB, at every setting, counting both channels together. Material on -one side only does not: white noise hard left comes back 2.7 dB down and -hard right 1.8 dB up at 48 kHz with Decay 0, Damping 500 Hz and -Diffusion 0 (about 0.8 / 0.5 dB at Decay 1.0, Diffusion 0.5). Each side -on its own does not hold either: the node scales the room by the mean of -its two sides' energies, so on a stereo room the left-right balance moves -while the total holds, and Diffusion, Room, Damping and Decay all move it. -It moves by at least about 5.3 dB at 48 and 44.1 kHz and 4.7 dB at -22.05 kHz, and how far it can go is not known: no walk covers every -setting. The widest found on the room's own impulse is L - R -5.30 dB at -48 kHz, -5.26 dB at 44.1 kHz and -4.67 dB at 22.05 kHz, all at Decay 0, -Damping 500 Hz and Room seed 36, with Diffusion at 12, 13 and 22 of 127; -white noise there reads about -5.5, -5.4 and -4.6 dB. The other way, the -widest found is +4.49, +4.55 and +4.05 dB (Decay 0, Damping 500 Hz, -Diffusion 0, seed 4). A mono room is one side and holds. Damping clamps -at 0.159 fs, -under the point where the node's one-pole coefficient stops moving, so at -48 kHz every one of its 128 positions is a room of its own, while at -22.05 kHz the positions from 92 up (the 6 kHz default among them) are one -3 506 Hz room. A single Room's decay with Damping in can read up to about -16 % off the Decay law on a stereo room (16.5 % with a quiet click) and -up to about 22 % on a mono one (48 kHz, Decay 0, Damping 500 Hz); the 64 -Rooms' mean holds within 2 %. +one side only does not, and which side comes back louder depends on the +Room. At 48 kHz with Decay 0, Damping 500 Hz and Diffusion 0, white noise +hard left comes back 2.7 dB down and hard right 1.8 dB up at Room seed 36, +and at the default Room, seed 1, hard left comes back 1.3 dB up and hard +right 2.1 dB down (at seed 1, Decay 1.0, Diffusion 0.5 and Damping 500 Hz, +about 0.8 dB down and 0.5 dB up). That noise is one draw, the kit's +uniform seed 12345 at -12 dBFS peak; another draw reads a few tenths of a +dB off it. Each side on its own does not hold either: the node scales the +room by the mean of its two sides' energies, so on a stereo room the +left-right balance moves while the total holds, and Diffusion, Room, +Damping, Decay and Predelay all move it. It moves by at least about +5.3 dB at 48 and 44.1 kHz and 4.7 dB at 22.05 kHz, and how far it can go +is not known: no walk covers every setting. The widest found on the +room's own impulse is L - R -5.30 dB at 48 kHz, -5.26 dB at 44.1 kHz and +-4.67 dB at 22.05 kHz, all at Decay 0, Damping 500 Hz and Room seed 36, +with Diffusion at 12, 13 and 22 of 127. The other way, the widest found +is +4.49, +4.55 and +4.05 dB (Decay 0, Damping 500 Hz, Diffusion 0, +seed 4). Those are the impulse's own figures, which is what white noise +reads per side on average; any one noise draw reads a little off them. +A mono room is one side and holds. Damping clamps at 0.159 fs, under the +point where the node's one-pole coefficient stops moving, so at 48 kHz +every one of its 128 positions is a room of its own, while at 22.05 kHz +the positions from 92 up (the 6 kHz default among them) are one 3 506 Hz +room. + +A single Room's decay with Damping in is not held to the Decay law, and +how far one can read off it is not known: no walk covers every setting. +It reads at least about 24 % off on a mono room: +23.70 % at 44.1 kHz +(Decay 0, Damping 500 Hz, Predelay 0, Diffusion 32 of 127, Room seed 43), ++24.02 % there with a -20 dBFS click (Decay 0, Damping 500 Hz, +Predelay 127, Diffusion 28, seed 43), +22.83 % at 48 kHz (Decay 0, +Damping 500 Hz, Predelay 0, Diffusion 10, seed 43) and +21.00 % at +22.05 kHz (Decay 0, Damping 500 Hz, Predelay 0, Diffusion 46, seed 61). +On a stereo room it is at least about 16.5 %: +16.20 % at 48 kHz +(Decay 16, Damping 500 Hz, Predelay 127, Diffusion 32, seed 43) and ++16.47 % at 22.05 kHz with a -20 dBFS click (Decay 127, Damping 500 Hz, +Predelay 0, Diffusion 0, seed 27). The 64 Rooms' mean holds within 2 %. **Measured mode.** The impulse is trimmed by `start_ms` (int(start_ms * fs / 1000) frames, truncated) through a slice that copies @@ -316,9 +334,10 @@ class ConvolutionReverb(_component.Component): **What the default surrenders:** a dark room lifts low material (a low chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), - Diffusion, Room, Damping and Decay move a stereo room's left-right - balance by at least about 5.3 dB (the widest setting is not known) - while the total holds, moving a room knob + Diffusion, Room, Damping, Decay and Predelay move a stereo room's + left-right balance by at least about 5.3 dB at 48 and 44.1 kHz + (4.7 dB at 22.05 kHz; the widest setting is not known) while the + total holds, moving a room knob or calling `reset()` mid-stream drops the 256 frames in flight, dry included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 608ed0f..8b2f1d9 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -38,6 +38,15 @@ which pins the widest per-side balance the docstring prints to the room at the cell the walk named (the fix-round-2 docstring, a figure 0.1 dB narrow and `SideTilt` are red on it). + +The re-audit's fix round 2 (gate audit, re-audit round 1) added three +more, each shown red: `D5SingleRoom`, which pins the single-Room figures +the docstring prints as floors to their named cells (the `1c9308b` words +"up to about 22 %" and a figure half a point off are red on it); +`D6OneSided`, which reads the one-sided example at the Rooms it names (red +with its seed 36 changed to 1); and a `set_macro` leg for D5's Predelay +control (`PredelayMidiSquared`, which passed the constructor-only test). +`D6Balance` now wants the class summary's floor with its rates. """ import os @@ -565,6 +574,26 @@ def _init_macros(self, values, patch=None): ConvolutionReverb._init_macros(self, tuple(values), patch) +class PredelayMidiSquared(ConvolutionReverb): + """The same control through `set_macro` (re-audit fix round 2, from + the re-audit round-1 audit's item 5): a Predelay move is kept squared, + the constructor's Predelay held exactly. At `set_macro` Predelay 64, + Decay 127, Damping out, Diffusion 0 the auditor read worst +6.518 % on + the handed law at 48 kHz (+7.244 % at 22.05 kHz), and it passed the + Predelay test while that test's clean leg went through the + constructor.""" + + NAME = NAME + + def set_macro(self, index, value, channel=0, note_id=-1, + sample_position=0): + ConvolutionReverb.set_macro(self, index, value, channel, note_id, + sample_position) + if index == PREDELAY_I: + self._macros[PREDELAY_I] = self._macros[PREDELAY_I] ** 2 + self._apply_macro(PREDELAY_I, self._macros[PREDELAY_I]) + + class NanIsSpanBottom(ConvolutionReverb): """The damping pin's control: a NaN `damping_hz` taken as the 500 Hz stop instead of out of circuit, which is what the docstring said of @@ -1488,6 +1517,24 @@ def test_a_predelay_held_off_the_handed_position_is_red(self): self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) clean = self.errors_over_rooms(rate=rate, **options) self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) + # Re-audit fix round 2: the same by `set_macro`, Predelay 64 + # handed after construction, with PredelayMidiSquared as the + # control. The clean leg goes through `set_macro` too, so a + # class that held a moved Predelay wrong fails it. + moved = dict(decay=1.0, damping_hz=0.0, diffusion=0.0) + planted = self.errors_over_rooms(PredelayMidiSquared, rate, + moves=((PREDELAY_I, 64),), + **moved) + self.assertGreater(float(np.max(np.abs(planted))), 0.03, rate) + self.assertGreater(float(np.mean(planted)), 0.03, rate) + held = self.errors_over_rooms(PredelayMidiSquared, rate, + moves=((PREDELAY_I, 64),), + read_back=True, **moved) + self.assertLessEqual(float(np.max(np.abs(held))), 0.03, rate) + clean = self.errors_over_rooms(rate=rate, + moves=((PREDELAY_I, 64),), + **moved) + self.assertLessEqual(float(np.max(np.abs(clean))), 0.03, rate) def test_stuck_dc_turns_the_floor_red(self): effect = build(StuckDcAfter) @@ -1523,6 +1570,109 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) +#: The docstring's single-Room sentences (re-audit fix round 2): a floor +#: ("at least about"), then each figure with its cell in brackets. +SINGLE_MONO_RE = re.compile( + r"at\s+least\s+about\s+(\d+(?:\.\d+)?)\s+%\s+off\s+on\s+a\s+mono\s+room:" + r"\s+\+(\d+\.\d\d)\s+%\s+at\s+44\.1\s+kHz\s+\(([^)]*)\)," + r"\s+\+(\d+\.\d\d)\s+%\s+there\s+with\s+a\s+-20\s+dBFS\s+click" + r"\s+\(([^)]*)\)," + r"\s+\+(\d+\.\d\d)\s+%\s+at\s+48\s+kHz\s+\(([^)]*)\)" + r"\s+and\s+\+(\d+\.\d\d)\s+%\s+at\s+22\.05\s+kHz\s+\(([^)]*)\)") +SINGLE_STEREO_RE = re.compile( + r"On\s+a\s+stereo\s+room\s+it\s+is\s+at\s+least\s+about" + r"\s+(\d+(?:\.\d+)?)\s+%:" + r"\s+\+(\d+\.\d\d)\s+%\s+at\s+48\s+kHz\s+\(([^)]*)\)" + r"\s+and\s+\+(\d+\.\d\d)\s+%\s+at\s+22\.05\s+kHz\s+with\s+a\s+-20\s+dBFS" + r"\s+click\s+\(([^)]*)\)") + +#: The cell behind each figure, in the sentences' order: (rate, channels, +#: click LSB, Decay MIDI, Predelay MIDI, Diffusion MIDI, Room seed), all at +#: Damping 500 Hz and 0.08 s. The walk behind them is the re-audit round-1 +#: audit's (`convolutionreverb_reaudit1_audit.py mono stereo monowalk`). +SINGLE_MONO_CELLS = ( + (44100, 1, 32767, 0, 0, 32, 43), + (44100, 1, 3277, 0, 127, 28, 43), + (48000, 1, 32767, 0, 0, 10, 43), + (22050, 1, 32767, 0, 0, 46, 61), +) +SINGLE_STEREO_CELLS = ( + (48000, 2, 32767, 16, 127, 32, 43), + (22050, 2, 3277, 127, 0, 0, 27), +) + + +def documented_single_rooms(doc): + """((mono floor, [(figure, words)...]), (stereo floor, [...])) as the + module docstring states them, or None where a sentence is missing.""" + text = " ".join((doc or "").split()) + found = [] + for pattern in (SINGLE_MONO_RE, SINGLE_STEREO_RE): + match = pattern.search(text) + if match is None: + found.append(None) + continue + groups = match.groups() + pairs = [(float(groups[i]), groups[i + 1]) + for i in range(1, len(groups), 2)] + found.append((float(groups[0]), pairs)) + return tuple(found) + + +def cell_words(cell): + """The words a cell's brackets must carry.""" + _, _, _, decay, predelay, diffusion, seed = cell + return ("Decay %d" % decay, "Damping 500 Hz", "Predelay %d" % predelay, + "Diffusion %d" % diffusion, "seed %d" % seed) + + +def single_room_error(cell, cls=None): + """(% off the Decay law, floor clean) of one Room at `cell`: a click at + Mix 2, the Schroeder fit on the sum of the channels' energy, the law + from the positions this test hands the constructor.""" + rate, channels, value, decay, predelay, diffusion, seed = cell + effect = build(cls, rate, channels, decay=decay / 127.0, + damping_hz=500.0, predelay=predelay / 127.0, + diffusion=diffusion / 127.0, room=seed) + taps = effect.node.taps + out = at_mix(effect, 127, click(LATENCY + taps + 1024, channels, + value=value)) + effect.deinit() + ir = out[LATENCY:LATENCY + taps].astype(np.float64) + t60 = schroeder_t60(np.sum(ir ** 2, axis=1), rate) + law = law_t60(decay / 127.0, predelay / 127.0, 0.08) + return 100.0 * (t60 / law - 1.0), not np.any(out[LATENCY + taps:]) + + +class D5SingleRoom(unittest.TestCase): + """D5's Not claimed line, a single Room with Damping in: nothing is + claimed, but the docstring tells a player how far one was found off the + law, and twice a figure written as a bound was exceeded (the re-audit + round-1 audit). This pins each printed figure to the Room it names, + to the printed hundredth, and wants each "at least about" within half a + point of its sentence's widest figure. It does not make a figure a + bound (re-audit fix round 2).""" + + def test_the_documented_single_rooms_are_what_the_room_reads(self): + mono, stereo = documented_single_rooms(rebuilt.__doc__) + for label, found, cells in (("mono", mono, SINGLE_MONO_CELLS), + ("stereo", stereo, SINGLE_STEREO_CELLS)): + self.assertIsNotNone(found, "no %s single-Room floor" % label) + floor, pairs = found + self.assertEqual(len(pairs), len(cells), label) + self.assertLessEqual(abs(floor - max(f for f, _ in pairs)), 0.5, + (label, floor)) + for (figure, words), cell in zip(pairs, cells): + for word in cell_words(cell): + self.assertIsNotNone( + re.search(r"\b%s\b" % re.escape(word), words), + (label, cell, word, words)) + error, floor_clean = single_room_error(cell) + self.assertTrue(floor_clean, cell) + self.assertLessEqual(abs(error - figure), 0.006, + (label, cell, error, figure)) + + # -------------------------------------------------------------------------- # D6 - unit energy, not the source: no synthesis macro is a level control # -------------------------------------------------------------------------- @@ -1690,8 +1840,11 @@ def test_the_documented_balance_is_what_the_room_reads(self): effect.deinit() self.assertLessEqual(abs(side - other[rate]), 0.005, (rate, side)) - # The class's own summary gives the floor too. - self.assertIn("at least about %.1f dB" % abs(documented[48000]), + # The class's own summary gives the floor too, with its rates (the + # re-audit fix round 2: the round-1 sentence named none). + self.assertIn("at least about %.1f dB at 48 and 44.1 kHz (%.1f dB at " + "22.05 kHz" % (abs(documented[48000]), + abs(documented[22050])), " ".join(ConvolutionReverb.__doc__.split())) def test_no_cell_of_the_slice_is_wider_than_documented(self): @@ -1713,6 +1866,66 @@ def test_no_cell_of_the_slice_is_wider_than_documented(self): rate) +#: The docstring's one-sided example (re-audit fix round 2): the Room of +#: each reading is named, since the sign turns with it. +ONE_SIDED_RE = re.compile( + r"At\s+48\s+kHz\s+with\s+Decay\s+0,\s+Damping\s+500\s+Hz\s+and\s+" + r"Diffusion\s+0,\s+white\s+noise\s+hard\s+left\s+comes\s+back\s+" + r"(\d+\.\d)\s+dB\s+down\s+and\s+hard\s+right\s+(\d+\.\d)\s+dB\s+up\s+at\s+" + r"Room\s+seed\s+(\d+),\s+and\s+at\s+the\s+default\s+Room,\s+seed\s+(\d+)," + r"\s+hard\s+left\s+comes\s+back\s+(\d+\.\d)\s+dB\s+up\s+and\s+hard\s+" + r"right\s+(\d+\.\d)\s+dB\s+down\s+\(at\s+seed\s+(\d+),\s+Decay\s+1\.0,\s+" + r"Diffusion\s+0\.5\s+and\s+Damping\s+500\s+Hz,\s+about\s+(\d+\.\d)\s+dB\s+" + r"down\s+and\s+(\d+\.\d)\s+dB\s+up\)") + + +def documented_one_sided(doc): + """[(options, left dB, right dB)] as the docstring states them, signs + applied, or None.""" + found = ONE_SIDED_RE.search(" ".join((doc or "").split())) + if found is None: + return None + g = found.groups() + corner = dict(decay=0.0, damping_hz=500.0, diffusion=0.0) + return [(dict(corner, room=int(g[2])), -float(g[0]), float(g[1])), + (dict(corner, room=int(g[3])), float(g[4]), -float(g[5])), + (dict(decay=1.0, damping_hz=500.0, diffusion=0.5, + room=int(g[6])), -float(g[7]), float(g[8]))] + + +def one_sided_level(side, cls=None, rate=RATE, **options): + """Pooled wet/dry dB at Mix 2 of the kit's white noise (seed 12345, + -12 dBFS peak, 3 s) on `side` alone, the other side silent, read after + the room has built.""" + effect = build(cls, rate, predelay=0.0, **options) + pcm = white(3 * rate) + pcm[:, 1 - side] = 0 + out = at_mix(effect, 127, pcm) + start = LATENCY + effect.node.taps + effect.deinit() + wet = out[start:].astype(np.float64) + dry = pcm[start - LATENCY:len(pcm) - LATENCY].astype(np.float64) + return float(10 * np.log10(np.mean(wet ** 2) / np.mean(dry ** 2))) + + +class D6OneSided(unittest.TestCase): + """The docstring's one-sided example, read at the Rooms it names: the + sign of each side turns with the Room (seed 36 and the default seed 1 + read the other way round), so a sentence that named no Room was false + at the default one (re-audit round-1 audit). Each printed figure is + held to its printed tenth.""" + + def test_the_documented_one_sided_example_is_what_the_room_reads(self): + cells = documented_one_sided(rebuilt.__doc__) + self.assertIsNotNone(cells, "no one-sided example naming its Rooms") + self.assertEqual(cells[1][0]["room"], 1) # "the default Room" + for options, left, right in cells: + for side, printed in ((0, left), (1, right)): + level = one_sided_level(side, **options) + self.assertLessEqual(abs(level - printed), 0.051, + (options, side, level, printed)) + + # -------------------------------------------------------------------------- # Tier 1, the fast half # -------------------------------------------------------------------------- From d9ced37ef26d054b1d5ca5a61c5263458ed88b5d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:48:27 -0500 Subject: [PATCH 56/88] AnalogDelay re-audit fix round 2: Spread handed on a 1/4096 grid, so the stereo tail ends Off a short binary grid the node's cross-feed sum can land an ulp above both lanes, and at a Feedback a float32 step or two under 1 - 0.5/k it handed k LSB (9-50) back for ever. On the grid both products are exact for lanes under 4096 LSB, so the sum lies between them on every interpreter. Spread moves by at most 1/8192; 0, 1 and mono unchanged. New tests: the three portable cross-feed cells and the typed feedback=0.9899999, spread=39/127 end inside tail_samples (RawSpread, the class as at 71e680b, holds k at all four; the test on 71e680b's class fails 149264 > 148048); Spread handed on the grid at every 7-bit position. RawSpread unreachable in REACH_WALKS. Docstring: the Tail paragraph, the Modulation bar under a knob turn and the step sentence's scope restated. --- CHANGELOG.md | 5 +- lib/audioeffects/rebuilt/analogdelay.py | 74 +++++++++++++----- tests/test_cpython_effects_analogdelay.py | 93 ++++++++++++++++++++++- 3 files changed, 153 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e3b0a..c996a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,10 @@ there, and are recorded in its changelog. cost is unmeasured. At audiodsp v0.6.3rc1 the Feedback is handed as set (no stepping clear of the stall windows; the bound counts one landing lap there), and a Modulation move glides over 20 ms instead of stepping the - read offset. + read offset. In stereo, Spread reaches the node on a grid of 4096ths + (within 1/8192 of the knob; 0 and 1 unchanged), because off that grid + the node's cross-feed sum could land a step above both sides and hold + 9-50 LSB for ever at a Feedback a float32 step under 1 - 0.5 / k. ### Changed diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index 9b25fe3..28f7b47 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -79,7 +79,7 @@ is no fixed anti-alias and reconstruction pair: both pedals bound their wet path near 3 kHz at every Time, so at short Times this class's repeats are brighter than either pedal's. The line's image spectra and its clock -noise are not modelled either. None of those has a node in audiodsp v0.6.2 +noise are not modelled either. None of those has a node in audiodsp v0.6.3rc1 that can sit inside the loop. **Time.** Every Time is landed on the nearest whole frame at the running @@ -132,15 +132,23 @@ class cannot see the head. A knob turned through several positions sends triangle's peak, 48 kHz). While the swing travels the extra pitch is the change over 20 ms times where the triangle stands: 5 -> 2 ms at a peak bends the repeats by 15 % for those 20 ms, +242 cents at one peak and --281 at the other. So the read moves at most |change| / 20 ms plus the -triangle's own 4 x Mod Rate x swing of a frame per frame faster or slower -than the tone, and no step in the output is larger than the input's own -largest step times 1 plus that: on a 997 Hz tone at 12 000 LSB, wet only, -Time 300 ms, five moves between 0 and 5 ms at Mod Rate 1 and 8 Hz and -eight points of the triangle, at 48, 44.1 and 22.05 kHz, the largest step -in the 40 ms after a move is at most 0.943 of that (1 -> 1.5 ms at 1 Hz, -48 kHz: 1 522 LSB against 1 615, where v0.6.2's node read 7 133). Mod -Rate moves keep the triangle's phase and do not step. +-281 at the other. A move made while the last one's 20 ms is still +running starts a new 20 ms from wherever the swing has got to, so the +swing travels at (target - where it stands) / 20 ms, which a knob turned +through several positions a block apart can make a little faster than +any one move's own |change| / 20 ms. So the read moves at most that +distance over 20 ms plus the triangle's own 4 x Mod Rate x swing of a +frame per frame faster or slower than the tone. With the wet alone and +Feedback 0, no step in the output is then larger than the input's own +largest step times 1 plus that: on a 997 Hz tone at 12 000 LSB, Time +300 ms, five moves between 0 and 5 ms at Mod Rate 1 and 8 Hz and eight +points of the triangle, at 48, 44.1 and 22.05 kHz, the largest step in +the 40 ms after a move is at most 0.943 of that (1 -> 1.5 ms at 1 Hz, +48 kHz: 1 522 LSB against 1 615, where v0.6.2's node read 7 133). With +the dry in or the repeats recirculating, the output's own step already +passes that bar before any move (1.9 x at Mix 1, 1.3 x at Feedback 0.5), +so the sentence says nothing there. Mod Rate moves keep the triangle's +phase and do not step. **Input ceiling.** The dry path sits at unity and the repeats add to it, so a hot input can put the output on the int16 rail; there is no input @@ -160,9 +168,18 @@ class cannot see the head. A knob turned through several positions sends back. Up to audiodsp v0.6.2 it did so for ever, and the class handed the node the nearer edge of that window instead. Since v0.6.3rc1 the node sets a stalled low-pass onto its input (audiodsp#157), the Feedback you set is -the one the node plays, and the bound counts one more lap there. After a -falling Time move the bound keeps the Time the head walked from until a -reset, because the class cannot see how far the walk has got. +the one the node plays, and the bound counts one more lap there. In +stereo the cross-feed could do the same thing: the node's sum of the two +sides, in single precision, can come out a step above both, and with +Spread at 39 / 127 or any other value off a short binary grid and a +Feedback a float32 step or two under 1 - 0.5 / k that handed k LSB back for +ever. So Spread reaches the node on a grid of 4096ths, within 1/8192 of +the knob, where that sum is exact on every interpreter for any side +under 4096 LSB (above that a step is far too small to hold a repeat); +0 and 1 are untouched. The bound then holds at every Feedback and Spread +the constructor or a macro can hand, stereo and mono. After a falling +Time move the bound keeps the Time the head walked from until a reset, +because the class cannot see how far the walk has got. `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). @@ -229,6 +246,10 @@ class cannot see the head. A knob turned through several positions sends #: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). FEEDBACK_MAX = 0.99 +#: Spread's grid as the node is handed it: whole 4096ths, so the loop's +#: cross-feed sum is exact for the small lanes a tail ends on. +SPREAD_GRID = 4096 + #: One period of the modulation's triangle, borrowed by the node. TABLE_POINTS = 256 @@ -321,6 +342,19 @@ def tone_excess(damping_hz, sample_rate): return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient +def spread_on_grid(spread): + """`spread` on the 1/4096 grid the node is handed (dossier section 8, + R13). The node sends `own * (1 - s) + other * s` round the loop in + single precision; with s = 39 / 127 or any other value off a short + binary grid those two products can add up to one step above both + lanes, and at a Feedback a hair under 1 - 0.5 / k that hands a landed + k LSB back for ever. On the grid both products of a lane under 4096 + LSB are exact, so the sum lies between the lanes on every interpreter, + whether or not a board fuses the multiply-add. The grid moves Spread + by at most 1/8192.""" + return math.floor(spread * SPREAD_GRID + 0.5) / SPREAD_GRID + + def _between(value, low, high): value = float(value) if not value >= low: @@ -597,17 +631,22 @@ def _refresh(self): self._damping = nominal_damping_hz(self._corner, fs) # Handed as set: since audiodsp v0.6.3rc1 the node lands a loop - # low-pass that has stopped moving (#157), so no Feedback holds a - # small value for ever and nothing is stepped clear here. + # low-pass that has stopped moving (#157), so the low-pass holds no + # Feedback's small value for ever and nothing is stepped clear + # here. The cross-feed's own stall is closed below, by Spread's + # grid. self._feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) self._swing_ms = _between(self._value(MODULATION_I), 0.0, SWING_MAX_MS) # At one channel the node's cross-feed sends the repeat nowhere. + # At two, Spread goes on the 1/4096 grid, where the loop's sum of + # the two sides cannot land above both and hand a value back. if self._channel_count == 1: self._spread = 0.0 else: - self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) + self._spread = spread_on_grid( + _between(self._value(SPREAD_I), 0.0, 1.0)) self._delay.set( delay_slew=self._slew, delay_ms=self._node_ms, @@ -625,7 +664,8 @@ def tail_samples(self): an upper bound: `laps_to_zero(f, excess)` laps of the longest delay the read head may be at, plus the modulation's peak swing in frames rounded up, plus one frame for the interpolated read, plus the loop - low-pass's memory. Finite at every setting the class reaches.""" + low-pass's memory. Finite at every setting the class reaches, and + with Spread on its grid it holds at every Spread in stereo.""" self._check_live() return self._tail_bound() diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index 35a63ec..e5373c1 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -8,7 +8,8 @@ walk is read where the head lands, the binade pieces have a resolvable minimum, and T7's arrival clause is claimed across Mix's interior material by material, at every grid position and at any Mix at or above -1.01 x 0.5 / W on the two loud materials). Four of its +1.01 x 0.5 / W on the two loud materials; and, from re-audit fix round 2, +R13: Spread handed on a 1/4096 grid so the stereo tail ends). Four of its Tier 2 rows can be demonstrated, T2a, T3, T6 and T7, and each is here as the measurement at a few of the row's cells beside the same measurement shown red on the row's planted fault at the constructor defaults. Every @@ -402,6 +403,23 @@ def _refresh(self): self._delay.set(feedback=stepped) +class RawSpread(AnalogDelay): + """The class as at 71e680b: Spread handed to the node as set, off the + 1/4096 grid. At two channels with Spread inside (0, 1) and a Feedback + a float32 step or two under 1 - 0.5 / k, the node's cross-feed sum + lands an ulp above a landed k and hands it back for ever.""" + + NAME = 'AnalogDelay' + + def _refresh(self): + AnalogDelay._refresh(self) + if self._channel_count == 2: + spread = min(1.0, max(0.0, self._value(SPREAD_I))) + if spread != self._spread: + self._spread = spread + self._delay.set(cross_feed=spread) + + class JumpModAnalog(AnalogDelay): """A Modulation move that moves the read head by the whole change in swing at once, as the node did at the triangle's peak up to v0.6.2 (it @@ -1161,6 +1179,13 @@ def read_feedback_as_set(effect): - effect.macro(FEEDBACK_I), 9) +def read_spread_on_grid(effect): + """Whether the `cross_feed` handed to the node is a whole number of + 4096ths (RawSpread hands 39/127, which is not).""" + handed = float(effect._delay._handed["cross_feed"]) * 4096.0 + return handed == math.floor(handed) + + def read_modulation_move(effect): """On a copy at these positions, one Modulation move of 16 grid steps after a block has been pulled: the `delay_ms` handed less the @@ -1300,6 +1325,8 @@ def read_early(effect): ("SteppedAnalog", SteppedAnalog, read_feedback_as_set, {"feedback": 0.99}), ("JumpModAnalog", JumpModAnalog, read_modulation_move, {}), + ("RawSpread", RawSpread, read_spread_on_grid, + {"feedback": 0.9899999, "spread": 39.0 / 127.0}), ) @@ -2084,6 +2111,70 @@ def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): self.assertNotEqual(stepped._feedback, 0.5) self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + #: (feedback, spread) where the node's cross-feed sum, off the grid, + #: hands a landed k back: the re-refutation's three portable cells + #: (k = 9, 11, 50; the Feedback as its float32 value) and the typed + #: Feedback 0.9899999 at Spread 39/127 (analogdelay_reaudit1_refute.py). + CROSS_FEED_CELLS = ((0.9444443583488464, 1.0 / 127.0, 9), + (0.9545453786849976, 3.0 / 127.0, 11), + (0.9899999499320984, 2.0 / 127.0, 50), + (0.9899999, 39.0 / 127.0, 50)) + + def _cross_feed_tail(self, cls, feedback, spread, k): + """Stereo, 48 kHz, Time 20 ms, Mix 2: a DC of 2k + 2 LSB on both + lanes for four laps, then silence to `tail_samples` plus a lap. + Returns (declared, the last non-zero frame after the fill, the + largest |sample| past `tail_samples`).""" + options = {"time_ms": 20.0, "feedback": feedback, "mix": 2.0, + "spread": spread} + probe = cls(silence_src(64), sample_rate=RATE, **options) + declared = probe.tail_samples + probe.deinit() + fill = 4 * 960 // BLOCK * BLOCK + BLOCK + values = [2 * k + 2] * fill + [0] * (declared + 960 + BLOCK) + effect = cls(array_src(values), sample_rate=RATE, **options) + out = pull(effect, len(values), 2) + effect.deinit() + after = out[fill * 2:] + nonzero = np.flatnonzero(after) + last = 0 if nonzero.size == 0 else int(nonzero[-1]) // 2 + 1 + past = after[declared * 2:] + return declared, last, int(np.max(np.abs(past.astype(np.int64)))) + + def test_the_cross_feed_stall_cells_reach_zero(self): + # Re-audit round 1's Tier 1 failure (dossier section 8, R13): with + # Spread handed as set these cells hold k LSB on both lanes for + # ever. On the 1/4096 grid the cross-feed sum is exact and each + # ends inside the bound. Planted: RawSpread, the class as at + # 71e680b, red at all four. + for feedback, spread, k in self.CROSS_FEED_CELLS: + declared, last, past = self._cross_feed_tail( + AnalogDelay, feedback, spread, k) + self.assertGreater(last, 0, (feedback, spread)) + self.assertLessEqual(last, declared, (feedback, spread)) + self.assertEqual(past, 0, (feedback, spread)) + declared, last, past = self._cross_feed_tail( + RawSpread, feedback, spread, k) + self.assertEqual(past, k, (feedback, spread)) + + def test_spread_is_handed_on_the_grid(self): + for rate in RATES: + effect = AnalogDelay(silence_src(64, 2, rate), sample_rate=rate) + for midi in range(128): + effect.set_macro(SPREAD_I, midi) + knob = effect._value(SPREAD_I) + self.assertEqual(effect._spread * 4096.0, + math.floor(effect._spread * 4096.0), midi) + self.assertLessEqual(abs(effect._spread - knob), + 1.0 / 8192.0, midi) + effect.set_macro(SPREAD_I, 0) + self.assertEqual(effect._spread, 0.0) + effect.set_macro(SPREAD_I, 127) + self.assertEqual(effect._spread, 1.0) + mono = AnalogDelay(silence_src(64, 1, rate), sample_rate=rate, + spread=39.0 / 127.0) + self.assertEqual(mono._spread, 0.0) + def _modulation_move(self, cls, start_ms, target_ms, mod_hz=1.0, points=8): """Per move, (the largest first difference in the 40 ms after it, From e4718723a3f469e1686691f6a3c9bb8ae22c633f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:19:56 -0500 Subject: [PATCH 57/88] MultiTapDelay re-audit fix round 1: the class at audiodsp v0.6.3rc1, the first block, the source's own buffers and Mix 0 The merge before this brought the pin to v0.6.3rc1, whose lap node lands a stalled loop low-pass (audiodsp#157). The Feedback is now handed as set: clear_of_stalls is no longer imported. It only ever moved Feedback 127 (0.95 played as 0.950016), at every Repeat Tone position and rate, and the bound there now counts the landing's extra lap (167 laps, 166 stepped). No test went red at the new pin; two new ones hold the good behaviour beside the retired cure (SteppedLaps): the Feedback handed as set over 128 positions at three rates, and a stall-window cell (Feedback 0.5 at 800 Hz) whose tail ends inside the bound. Gate audit round 3's items 1-4: 1 and 2. Every wiring is quiet where audiocore.get_buffer exists: the Splitter reads the sourceless MidSide through the _in port while the voices prime, and one pull of the Mixer hands the primed zeros out. No voice holds a block after construction and tap 1 holds zeros, which is the state _resync expects. Before, a reset or Mix-0 return before the first pull dropped the first block's heads (every lane, every interpreter), and in mono on a native build it refilled the tap buffer the dry voice pointed into, so the first 256 samples played as zeros. 3. The input adapter is registered without a reset, as DeEsser's is: a reset no longer drops the unread part of a source buffer (256 frames early with 512-frame buffers; a bare RawSample replayed from frame 0). 4. Mix 0 hands out the input adapter through one more width-1 MidSide (_through), not the borrowed source, so a switch to Mix 0 neither skips nor replays frames, and a host reset at Mix 0 leaves the adapter alone. The route marks the graph stranded with the base's _route_around and the return clears it with _rejoin before _resync. A registered adapter reset would fight that, and its plant is red at every Mix-0 return as well as at reset(). 5. The docstring's reset, latency, Mix-0 and tail words and the CHANGELOG line say what the class now does, including what a build without get_buffer gets (a class built at Mix 0 and reset before it was turned up plays 256 late). Tests: one numpy-free route module (ROUTES_MODULE) run under CPython and under the workspace's current bin/micropython* and bin/circuitpython* (chosen by provenance stamp; skipped with no workspace bin/, failed with a stale one), with plants for ac2181f's wiring, adapter reset and Mix-0 route; plus Mix 0 through the adapter, a host reset at Mix 0 (plant BareAdapter), and reachability for every new plant. On ac2181f's class at the new pin the new tests read 5 failures and 1 error of 8. MixerTail is no longer red at construction (nothing primed to drop), so its test reads the mid-stream route, where a host reset reaching the Mixer empties the lines (a click's three heads gone); PrimingReset keeps ac2181f's wiring. Numbers at the new pin: the audit's own probe 0/0/0 red on three interpreters (ac2181f 12/24/42 natively, 0/24/42 on CPython); digests 48 of 48, identical to v0.6.2's; Tier 1 failures 0/0/18 (the 18 stereo CircuitPython LEVEL rows, accepted on Brad's ruling). Gates: unittest 1896 tests OK (skipped=3); scoped class run green; flake8 clean; validate_api 55/45; metadata valid; smoke 45 classes, 259 patches, 0 failures on CPython, MicroPython and CircuitPython. --- CHANGELOG.md | 21 +- lib/audioeffects/rebuilt/multitapdelay.py | 233 ++++--- tests/test_cpython_effects_multitapdelay.py | 637 +++++++++++++++++++- 3 files changed, 810 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1474752..551554f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,13 +80,22 @@ there, and are recorded in its changelog. `lib/audioeffects/rebuilt/multitapdelay.py`, and `audioeffects.MultiTapDelay` is still the old class. The tap node reads its input one block behind the dry, so settings made before the first - pull or after `reset()` land on the grid in both lanes, the dry stays at - +0 and every head at +k n1 after any number of resets and returns from - Mix 0 between two pulls, and any number of Repeat Tone crossings between - pulls leave the wet where it was; the output ends in a width-1 MidSide, - so a host resetting the output keeps the source's first block. The + pull or after `reset()` land on the grid in both lanes. Any number of + resets and returns from Mix 0, before the first pull or between two + pulls, leave the dry at +0 against the source and every head at +k n1, + and take no frame from the source or play one twice, whatever size of + buffer it hands out (a bare `RawSample` included): the source is read + through the input adapter at every Mix, Mix 0 included, and a reset + leaves the adapter's unread frames where they are. Any number of Repeat + Tone crossings between pulls leave the wet where it was; the output ends + in a width-1 MidSide, so a host resetting the output leaves the lines as + they are. On a build without `audiocore.get_buffer` (a patched + CircuitPython board may leave it out), a class built at Mix 0 and reset + before it was ever turned up plays its source 256 frames late. The constructor refuses a sample rate below 12 825 Hz with a `ValueError`, - where the one-block lag could not place the 20 ms head. The + where the one-block lag could not place the 20 ms head. At audiodsp + v0.6.3rc1 the Feedback is handed to the lap node as set (no stepping + clear of the stall windows; the bound counts one landing lap there). The docstring states the input ceiling, the click and channel crossing of a Time or Heads move while audio plays, and that on CircuitPython alone the stereo dry's right lane is one LSB hot within 32 LSB of the rails. diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index f896720..14256a7 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -78,14 +78,19 @@ level at Feedback 0.5). **Mix 0 hands your source straight through**, the class's output port -pointed at the source itself, so it is byte for byte a wire on every -interpreter. It departs from the dossier's section 6 there, which kept -both lines recording at Mix 0 through the Mixer: CircuitPython's stock -`audiomixer` scales a voice at level 1.0 by 32768 / 32767, so on -CircuitPython that route put every sample at |value| >= 32736 one LSB out -(audiodsp's own Mixer, the one MicroPython and the boards run, passes -unity through). The price is that the lines are not fed while Mix is 0, -and turning Mix up from 0 starts the echoes from empty lines. The same +pointed at its input adapter (through one more width-1 MidSide), an +`audioroute.MidSide` at width 1 that passes the source byte for byte in +256-frame blocks, so it is a wire on every interpreter. Going through the adapter, not round it, keeps the +source's timeline whole: a switch to Mix 0 and back mid-stream neither +skips nor repeats a frame, whatever size of buffer your source hands out, +a bare `audiocore.RawSample` included. It departs from the dossier's +section 6 there, which kept both lines recording at Mix 0 through the +Mixer: CircuitPython's stock `audiomixer` scales a voice at level 1.0 by +32768 / 32767, so on CircuitPython that route put every sample at +|value| >= 32736 one LSB out (audiodsp's own Mixer, the one MicroPython +and the boards run, passes unity through). The price is that the lines +are not fed while Mix is 0, and turning Mix up from 0 starts the echoes +from empty lines. The same stock Mixer is on the dry path above Mix 0. At one channel the class hands the dry's unity as 1 - 2^-15, which every Mixer here passes exactly; at two the stock Mixer's pan law leaves no level that is exact in both lanes, so @@ -99,7 +104,8 @@ made before the first pull, or after `reset()` and before the next pull, is not a move of that kind: the tap node's line holds only zeros then, so the first lap lands on the grid in both lanes. That holds however many -times you call `reset()`, or take Mix to 0 and back, between two pulls. +times you call `reset()`, or take Mix to 0 and back, before the first +pull or between two pulls. **Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). The laps are `audioecho.FeedbackDelay` and the dry fan-out is @@ -114,15 +120,34 @@ than build a class whose Time knob cannot reach its low end. **Latency: zero samples, at every setting and patch, at every rate the -class accepts**, and after `reset()` from any graph, however many resets -and returns from Mix 0 land between two pulls: the dry stays at +0 and -every head at +k n1 against it. The dry is a Splitter tap into a Mixer -voice, a wire, and nothing looks ahead. The heads are the effect, not latency, and -no option adds any. The output ends in an `audioroute.MidSide` at width 1, -the identity, whose reset forwards nothing: a host that resets the output -(a mixer voice's `play()` does) no longer reaches the Mixer, whose voices -would re-prime from the Splitter and drop the source's first block. That -reset leaves the lines as they are; call `reset()` to empty them. +class accepts.** The dry is a Splitter tap into a Mixer voice, a wire, +and nothing looks ahead; the heads are the effect, not latency, and no +option adds any. The first pull after construction plays your source's +first frame. Any number of `reset()` calls and returns from Mix 0, before +the first pull or between two pulls, leave the dry at +0 against your +source and every head at +k n1 against the dry, for audio that arrives +after them: `reset()` empties the lines, so audio from before it has no +heads, and a return from Mix 0 starts the heads from empty lines. None of +it takes a frame from your source or plays one twice, whatever size of +buffer your source hands out: the input adapter keeps the unread part of +a buffer across a reset and a Mix 0 switch, and the next pull plays it. + +That rests on `audiocore.get_buffer`, which every desktop build and the +MicroPython boards carry and a patched CircuitPython board build may not. +Without it a class that wires its graph inside `reset()` (one built at +Mix 0 and never turned up) opens with one silent 256-frame block and +plays your source 256 frames late from then on, and a reset or a return +from Mix 0 lets the one block the tap node had not read into the lines. + +The output ends in an `audioroute.MidSide` at width 1, the identity, whose +reset forwards nothing, above Mix 0 and at it: a host that resets the +output (a mixer voice's `play()` does) reaches neither the Mixer, whose +voices would empty the tap node's line (the heads of what was playing +would go) and, on a build without `get_buffer`, re-prime from the +Splitter and drop the source's first block, nor the input adapter, which +would drop the part of a source buffer it holds. That reset leaves the +lines and your source's timeline as they are; call `reset()` to empty the +lines. **Mono.** A one-channel source gets the same effect on its one channel. There is no Spread: the tap node applies one set of heads to every @@ -162,11 +187,15 @@ class accepts**, and after `reset()` from any graph, however many resets echoes take to reach exact zero once your input stops, recomputed on every Time, Heads, Feedback or Repeat Tone move. With Repeat Tone in it is `DigitalDelay`'s bound for the lap node plus one lap for the tap node's -line, and the lap node is handed a Feedback just outside the windows where -its loop low-pass could hold a small value for ever (at most 0.00003 from -the one you set; 0.95 plays as 0.950016). With Repeat Tone out the tap node -truncates every lap toward zero, so the bound is (laps + 1) x P. At patch 0 -it is 321 435 frames at 48 kHz (6.7 s). +line. At a Feedback a hair either side of 1 - 0.5 / k the lap node's loop +low-pass can come to rest a hair above k LSB and hand it back; up to +audiodsp v0.6.2 it did so for ever, and this class handed the node a +Feedback just outside each such window (0.95 played as 0.950016). Since +v0.6.3rc1 the node sets a stalled low-pass onto its input (audiodsp#157), +the Feedback you set is the one the node plays, and the bound counts one +more lap there: 167 laps at 0.95, Repeat Tone at the default. With Repeat +Tone out the tap node truncates every lap toward zero, so the bound is +(laps + 1) x P. At patch 0 it is 321 435 frames at 48 kHz (6.7 s). `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per @@ -188,9 +217,9 @@ class accepts**, and after `reset()` from any graph, however many resets # is the same node and rounds the same way. Its module moves up one level # when it comes home, so both homes are tried. try: - from .digitaldelay import clear_of_stalls, laps_to_zero, whole_frames + from .digitaldelay import laps_to_zero, whole_frames except ImportError: # pragma: no cover - after it lands - from ..digitaldelay import clear_of_stalls, laps_to_zero, whole_frames + from ..digitaldelay import laps_to_zero, whole_frames import audiocore @@ -280,7 +309,7 @@ def _f32_step(value, up): def lap_node_frames(value_ms, sample_rate): """The lap node's own arithmetic for a `delay_ms` it is handed, `value * rate / 1000.0f` in single precision - (`audiodsp_feedback_delay.c:148` at v0.6.2).""" + (`audiodsp_feedback_delay.c:148` at v0.6.3rc1).""" return f32(f32(f32(value_ms) * f32(sample_rate)) / f32(1000.0)) @@ -335,7 +364,7 @@ def landed(time_ms, heads, sample_rate, max_lap_ms): def trunc_laps(decay): """Laps of the tap node's own loop to exact zero from full scale. Once the input stops it writes `(int32_t)(delayed * decay)` - (`audiodsp_multitap.c:32`), which truncates toward zero, so a peak x + (`audiodsp_multitap.c:32` at v0.6.3rc1), which truncates toward zero, so a peak x goes to floor(x d) < x and the count is finite at every decay.""" decay = min(max(float(decay), 0.0), 1.0) * (1.0 + DECAY_MARGIN) laps = 0 @@ -498,20 +527,23 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, # The input adapter re-blocks whatever the app hands the class into # the palette's own blocks, which `audioroute.Splitter` needs: a - # bare `audiocore.RawSample` hands back its whole array at once. + # bare `audiocore.RawSample` hands back its whole array at once. It + # is also what Mix 0 hands out (`_route`), so the source is read + # through it, and only through it, at every Mix: whatever part of a + # source buffer it holds plays next, whichever way the output goes. adapter = audioroute.MidSide(width=1.0, sample_rate=rate, channel_count=channels) adapter.play(self._source) # A MidSide with no source hands out one block of zeros per pull and - # never finishes: what the tap node primes from, what the input - # adapter reads while a reset wires the graph, and what the Splitter - # reads while `_resync` swaps tap 1's pending block for zeros - # (`_route`). + # never finishes: what the tap node primes from, and what the + # Splitter reads while the graph is wired and while `_resync` swaps + # tap 1's pending block for zeros (`_route`). self._hush = audioroute.MidSide(width=1.0, sample_rate=rate, channel_count=channels) - # The Splitter reads the adapter through a port, so `_resync` can - # point it at `_hush` for two pulls without touching the adapter - # (whose unread rest of a long source buffer stays where it is). + # The Splitter reads the adapter through a port, so `_wire` and + # `_resync` can point it at `_hush` without touching the adapter: + # a MidSide's `play()` drops the frames it holds, and the unread + # rest of a long source buffer has to stay where it is. self._in = audioroute.Port(adapter) split = audioroute.Splitter(self._in, taps=2) dry = split.tap(0) @@ -552,6 +584,14 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, self._tail = audioroute.MidSide(width=1.0, sample_rate=rate, channel_count=channels) self._tail.play(self._mixer) + # What Mix 0 hands out (`_bypass`): the input adapter behind one + # more width-1 MidSide, byte for byte, whose reset forwards nothing, + # so a host resetting the output at Mix 0 leaves the adapter's + # unread frames where they are, as the tail does above Mix 0. The + # adapter hands it whole 256-frame blocks, so it never holds any. + self._through = audioroute.MidSide(width=1.0, sample_rate=rate, + channel_count=channels) + self._through.play(adapter) self._adapter = adapter self._split = split @@ -563,7 +603,13 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, # source. The tap node's reset empties its line through # `audiocore.reset_buffer`; the lap node's `clear` empties its line # and loop filters. The feed port is not reset either: it would - # forward the reset to whatever it points at. + # forward the reset to whatever it points at. Nor is the input + # adapter: what it holds is the source's own audio, not yet heard, + # and a reset that dropped it would put every later frame early (a + # source in 512-frame buffers, 256 frames) or play a bare + # `RawSample` again from its first frame (`DeEsser` owns its + # adapter the same way). + self._own(self._through) self._own(self._tail) self._own(self._mixer, reset=False) self._own(self._tapnode) @@ -573,7 +619,7 @@ def _build(self, time_ms=150.0, pattern=7, heads=3, feedback=0.45, self._own(tap1, reset=False) self._own(split, reset=False) self._own(self._in, reset=False) - self._own(adapter) + self._own(adapter, reset=False) self._own(self._hush, reset=False) self._own(self._silence, reset=False) self._output = self._tail @@ -700,16 +746,14 @@ def _refresh(self): feedback = min(FEEDBACK_MAX, max(0.0, feedback)) lean = self._macros[TONE_I] >= 1.0 damping = self._tone_damping() - if lean: - loop = feedback - decay = feedback - else: - # With Repeat Tone in, the lap node can hold a small value for - # ever at Feedback values a hair either side of 1 - 0.5 / k; it - # is handed the nearer edge of that window instead. - loop = clear_of_stalls(feedback, - tone_excess(damping, rate)[1]) - decay = 0.0 + # The Feedback is handed as set. Up to audiodsp v0.6.2 the lap + # node's loop low-pass could hold a small value for ever a hair + # either side of 1 - 0.5 / k, and this class stepped the Feedback + # clear of those windows; since v0.6.3rc1 the node lands a damping + # state that has stopped moving (audiodsp#157), and `laps_to_zero` + # counts that landing's one extra lap. + loop = feedback + decay = feedback if lean else 0.0 tap_ms = self._tap_node_ms(lap) lap_ms = self._lap_node_ms(lap) @@ -757,14 +801,20 @@ def _dry_level(self, mix): def _route(self): """Where the output port points, and what the tap node reads. - Mix 0 hands the borrowed source straight back through the port, so - it is a wire on every interpreter: CircuitPython's stock - `audiomixer` scales a voice at level 1.0 by 32768 / 32767, which - puts every sample at |value| >= 32736 one LSB out (audiodsp's own - Mixer passes unity through). Nothing is pulled through the graph - while the port is on the source, so nothing here may prime a node - then: a `play()` would take a block of the source away from the - port. + Mix 0 hands the input adapter out through the port (`_bypass`, by + way of `_through`), a width-1 MidSide that passes the source byte + for byte, so it is a + wire on every interpreter: CircuitPython's stock `audiomixer` + scales a voice at level 1.0 by 32768 / 32767, which puts every + sample at |value| >= 32736 one LSB out (audiodsp's own Mixer passes + unity through). The source is read through the adapter at every + Mix, so whatever part of a buffer it holds plays next either way. + Nothing is pulled through the graph while the port is on the + adapter, so nothing here may prime from it then: a `play()` would + take a block away from the port. `_route_around` marks the graph + stranded, and the return clears it through `_rejoin` (the base's + stale-block helper) before `_resync`; the adapter is not reset by + either, since it is registered without one. **The tap node runs one block behind the dry.** A Mixer voice's `play()` primes one block from its source, and the voices hand @@ -793,26 +843,37 @@ def _route(self): the source, and a second or third `_resync` before the next pull leaves exactly what the first did. - A reset that has to wire the graph (a class never wired at Mix 0) - must not pull the source either: the input adapter plays `_hush` - while the voices prime, so the dry's primed block and tap 1's - pending block are zeros, and one pull of the Mixer then hands the - dry's zeros out, so the next pull reads the source's next block on - time. That pull needs `audiocore.get_buffer`; where a build leaves - it out, the output after such a reset opens with that one silent - block, and a reset or a return from Mix 0 lets tap 1's pending block - into the lines. + **Every wiring is quiet** where `audiocore.get_buffer` exists + (`_wire`): the Splitter reads `_hush` while the voices prime, and + one pull of the Mixer hands the primed zeros out, so the graph is + left as it is between two pulls mid-stream and nothing is taken + from the source. Up to re-audit fix round 1 a wiring outside + `reset()` primed the dry voice with the source's first block, and + a `_resync` before the first pull then dropped that block's heads + (tap 1 held it, not yet heard) and, in mono on a native build, + refilled the tap's own buffer the voice was pointing into, so the + dry's first 256 samples played as zeros. Inside `reset()` the + wiring is quiet on every build, because a reset must take nothing + from the source. Where `get_buffer` is left out, a wiring outside + `reset()` primes the source (and plays it on time, since `_resync` + cannot pull there either), a wiring inside `reset()` opens the + output with that one silent block, and a reset or a return from + Mix 0 lets tap 1's pending block into the lines. """ if not self._ready: return if self._macros[MIX_I] <= 0.0: - self._output = self._source + self._route_around(self._bypass()) self._at_source = True return if not self._wired: - self._wire(self._resetting) + self._wire(self._quiet_wiring()) self._wired = True elif self._at_source or self._resetting: + # The base's stale-block helper clears what `_route_around` + # left (both lines and the tail); `_resync` clears the lines + # again, which changes nothing, and swaps tap 1's block. + self._rejoin() self._resync() elif self._lean != self._plugged: self._plug(self._lean) @@ -835,15 +896,41 @@ def _target(self, lean): node.""" return self._tap1 if lean else self._fd + def _bypass(self): + """What Mix 0 hands out: the input adapter, a width-1 MidSide that + passes the source byte for byte in the palette's blocks, behind + `_through`, which keeps a host's reset off it. Pointing the output + at the source itself would skip whatever part of a source buffer + the adapter holds (a source in 512-frame buffers jumps 256 frames + ahead) and hand a bare `RawSample` out again from its first + frame.""" + return self._through + + def _quiet_wiring(self): + """Whether `_wire` primes the voices from `_hush`: wherever + `audiocore.get_buffer` can hand the primed zeros out, and always + inside `reset()`, which must take nothing from the source.""" + return self._resetting or \ + getattr(audiocore, "get_buffer", None) is not None + def _wire(self, quiet=False): """Play every node once, the first time Mix is above 0 (`_route`). - `quiet` inside `reset()`: nothing is taken from the source.""" + + `quiet`: the Splitter reads `_hush` while the voices prime, so the + dry voice primes zeros and tap 1's pending block is zeros, and one + pull of the Mixer hands those zeros out. Nothing is taken from the + source, and the graph is left as it is between two pulls + mid-stream: no voice holds a block, the dry tap is caught up, and + tap 1 is one block behind it. A voice that still held its primed + block would point into a mono `SplitterTap`'s own buffer, which + `_resync` refills, and tap 1 would hold audio whose dry had not + played yet, which `_resync` drops.""" pull = getattr(audiocore, "get_buffer", None) self._fd.play(self._tap1) self._feed.play(self._hush) self._tapnode.play(self._feed, loop=False) if quiet: - self._adapter.play(self._hush) + self._in.play(self._hush) _component.open_level_gates(self._mixer, self._mixer.voice, self._silence) self._mixer.voice[0].play(self._dry, loop=False) @@ -851,7 +938,7 @@ def _wire(self, quiet=False): self._feed.play(self._target(self._lean)) self._plugged = self._lean if quiet: - self._adapter.play(self._source) + self._in.play(self._adapter) if pull is not None: pull(self._mixer) @@ -866,7 +953,11 @@ def _resync(self): block, tap 0 caught up) the first pull drops that block and the second writes the zeros. From the state this leaves, a second call reads those zeros back and writes them again, so nothing of the - source is taken and the tap node stays one block behind the dry.""" + source is taken and the tap node stays one block behind the dry. + A quiet wiring (`_wire`) leaves tap 1 holding zeros and no voice + holding a block, so right after construction this is that state + too: the dry tap's pull refills no buffer a voice still points + into, and what tap 1 drops was never audio.""" pull = getattr(audiocore, "get_buffer", None) self._fd.clear() audiocore.reset_buffer(self._tapnode) @@ -916,6 +1007,4 @@ def _tail_bound(self): return int((trunc_laps(self._decay) + 1) * lap) memory, excess = tone_excess(self._damping, self._sample_rate) laps = laps_to_zero(self._feedback, excess) - if laps is None: # pragma: no cover - cleared above - return None return int(laps * (lap + 1 + memory) + lap) diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index b6608b6..216dc62 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -23,11 +23,23 @@ class built as a wire. The full spans, the three interpreters and the rates (`Head2OwnLoop`: head 2 read through its own, darker lap node, Station C's plant ported to the fix round 1 graph). The dossier's emulation of clause 1's plant on the rendered windows stays beside it. + +Re-audit fix round 1 (2026-09-28) moved the class to audiodsp v0.6.3rc1, +whose lap node lands a stalled loop low-pass (audiodsp#157): the Feedback is +handed as set, and `ReauditRoundOne` holds that beside the retired stepping. +Its route tests run one numpy-free script (`ROUTES_MODULE`) under CPython +and under the workspace's desktop MicroPython and CircuitPython where they +are present and current for `AUDIODSP_PIN`: a mono dry block lost after a +reset shows only on a native interpreter, whose mixer voice points into the +tap's own buffer where the CPython shim copies. """ import math import os +import subprocess import sys +import tempfile +import types import unittest from array import array @@ -49,6 +61,9 @@ class built as a wire. The full spans, the three interpreters and the rates from audioeffects import _component # noqa: E402 from audioeffects import rebuilt # noqa: E402 from audioeffects.chorus import nominal_damping_hz # noqa: E402 +from audioeffects.rebuilt import multitapdelay as mtd # noqa: E402 +from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 + clear_of_stalls, laps_to_zero) from tools import effect_measurements as kit # noqa: E402 VENDOR = "PyDevices" @@ -579,6 +594,11 @@ class PrimingReset(MultiTapDelay): def reset(self): _component.Component.reset(self) + def _quiet_wiring(self): + # The base's reset never sets `_resetting`, and before re-audit fix + # round 1 a wiring outside `reset()` primed the source. + return self._resetting + class StaleReset(MultiTapDelay): """Tier 1 STATE, planted: `reset()` empties both lines but leaves the @@ -1054,6 +1074,29 @@ def host_reset_route(cls, patch=None, rate=RATE, channels=2): "law": law} +def host_reset_lines_route(cls, rate=RATE, channels=2): + """Item 3 mid-stream (re-audit fix round 1): a click at source frame + 7 000, 30 blocks, the host resets the output, Feedback 0, Mix 1.0: the + host's reset leaves the lines as they are, so the click's heads sound + at +k n1 after it.""" + at = 7000 + data = array("h", [0] * (40000 * channels)) + for ch in range(channels): + data[at * channels + ch] = 20000 + effect = cls(Endless(data, rate, channels), sample_rate=rate, + feedback=0.0, mix=1.0) + n1, lap = law_landed(effect.macro(TIME_I), effect._heads, rate) + head = pull(effect, 30 * BLOCK) + audiocore.reset_buffer(effect.output) + rest = pull(effect, at + lap + 512 - 30 * BLOCK) + effect.deinit() + law = [at + k * n1 for k in (0,) + law_heads(effect._pattern_mode(), + effect._heads)] + hits = lane_hits(np.concatenate([head, rest])) + return {"passed": all(h == law for h in hits), "hits": hits, + "law": law} + + def crossing_route(cls, crossings, rate=RATE, channels=2): """Item 4: 40 blocks at Mix 1, Feedback 0, Time MIDI 64, then Repeat Tone across its out stop and back `crossings` times in one gap, then @@ -1816,11 +1859,21 @@ def test_a_host_reset_keeps_the_first_block(self): channels) self.assertTrue(result["passed"], (rate, channels, patch, result)) + result = host_reset_lines_route(MultiTapDelay, rate, + channels) + self.assertTrue(result["passed"], (rate, channels, result)) def test_mixer_tail_plant_is_red(self): - result = host_reset_route(MixerTail) - self.assertFalse(result["passed"], result) - self.assertEqual(result["hits"][0][0], 2744) + # Since re-audit fix round 1 every wiring is quiet, so no voice + # holds the source's first block at construction and a host reset + # that reaches the Mixer drops nothing there (c80ca59 put the 3 000 + # click at 2 744). Mid-stream it still empties both lines: the + # click's heads are gone where the tail leaves them sounding. + self.assertTrue(host_reset_route(MixerTail)["passed"]) + for channels in (2, 1): + result = host_reset_lines_route(MixerTail, channels=channels) + self.assertFalse(result["passed"], result) + self.assertEqual(result["hits"], [[7000]] * channels) result = reach_flag(MixerTail, "_plant_mixer_tail") self.assertEqual(result["checked"], 9 * 17 + 7) @@ -2012,5 +2065,583 @@ def test_head2_own_loop_is_red_on_clause_1(self): self.assertEqual(result["checked"], 9 * 17 + 7) +# -------------------------------------------------------------------------- +# Re-audit fix round 1: the pin at v0.6.3rc1, and the first block, the +# source's own buffers and Mix 0 (audit round 3's items 1-4) + + +class SteppedLaps(MultiTapDelay): + """The cure retired at audiodsp v0.6.3rc1: with Repeat Tone in, the lap + node handed the nearer edge of the stall window its Feedback sits in + (`clear_of_stalls`), a Feedback nobody set (0.95 played as 0.950016).""" + + NAME = 'MultiTapDelay' + + def _refresh(self): + MultiTapDelay._refresh(self) + if self._lean: + return + excess = mtd.tone_excess(self._damping, self._sample_rate)[1] + stepped = clear_of_stalls(self._feedback, excess) + if stepped != self._feedback: + self._feedback = stepped + self._lap_mix = stepped + self._fd.set(feedback=stepped, mix=stepped) + + +class BareAdapter(MultiTapDelay): + """Mix 0 on the input adapter itself, without `_through`: a host's + reset of the output reaches the adapter and drops what it holds.""" + + NAME = 'MultiTapDelay' + + def _bypass(self): + return self._adapter + + +#: One numpy-free script, run under CPython, MicroPython and CircuitPython: +#: the class after a route of calls made before the first pull, and after +#: an event between two pulls with sources whose buffers are not 256 frames, +#: every lane against the source and the dossier's head law. Three plants +#: put back ac2181f's code for the three causes. Each output line is +#: `PART|plant|route|rate|channels|samples wrong`. +ROUTES_MODULE = """from array import array + +import audiocore # noqa: E402 +from audioeffects import rebuilt # noqa: E402 + +VENDOR = "PyDevices" + +M = rebuilt.module_class("MultiTapDelay") +BLOCK = 256 +FB_I = 3 +MIX_I = 4 +RATES = (48000, 44100, 22050) + + +class OldWiring(M): + '''ac2181f's wiring: quiet only inside reset(), so a class wired at + construction or by a Mix move holds the source's first block in the dry + voice and on tap 1 until the first pull.''' + + NAME = 'MultiTapDelay' + + def _quiet_wiring(self): + return self._resetting + + +class AdapterReset(M): + '''ac2181f's reset: the input adapter owned with a reset, which drops + what it holds of a source buffer.''' + + NAME = 'MultiTapDelay' + + def _build(self, *args, **kwargs): + M._build(self, *args, **kwargs) + self._resets[self._nodes.index(self._adapter)] = True + + +class SourceBypass(M): + '''ac2181f's Mix 0: the output port on the borrowed source itself, past + whatever the input adapter holds.''' + + NAME = 'MultiTapDelay' + + def _bypass(self): + return self._source + + +PLANTS = {"clean": M, "oldwiring": OldWiring, "adapterreset": AdapterReset, + "sourcebypass": SourceBypass} + + +def noise(channels, total): + values = array("h", bytes(2 * total * channels)) + state = 987654 + for i in range(total): + for c in range(channels): + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + tri = (i * (37 + 29 * c)) % 16001 - 8000 + values[i * channels + c] = tri + (state >> 8) % 8001 - 4000 + return values + + +def clicks(channels, total, where): + values = array("h", bytes(2 * total * channels)) + for s, v in where: + for c in range(channels): + values[s * channels + c] = v + return values + + +def source(values, rate, channels, frames): + raw = audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + if frames is None: + return raw + import audiofilters + block = audiofilters.Filter(filter=None, mix=1.0, + buffer_size=frames * channels * 2, + sample_rate=rate, channel_count=channels) + block.play(raw) + return block + + +def samples(data): + out = array("h") + try: + out.extend(memoryview(data).cast("h")) + except (AttributeError, TypeError): + import struct + out.extend(struct.unpack("<%dh" % (len(data) // 2), data)) + return out + + +def pull_frames(e, frames): + ch = e.channel_count + out = array("h") + while len(out) < frames * ch: + out.extend(samples(bytes(audiocore.get_buffer(e.output)[1]))) + return out + + +#: Routes between construction and the first pull. "mix0 up" is a class +#: built at Mix 0 and turned up, which wires it. +ROUTES = ("none", "program_change(1)", "reset", "reset x2", + "Mix 0 and back", "reset, Mix 0 and back", "mix0 up, reset") + + +def build(cls, src, rate, name): + if name.startswith("mix0 up"): + e = cls(src, sample_rate=rate, mix=0.0) + e.set_macro(MIX_I, 22) + else: + e = cls(src, sample_rate=rate) + for step in name.split(", "): + if step in ("none", "mix0 up"): + continue + if step == "reset": + e.reset() + elif step == "reset x2": + e.reset() + e.reset() + elif step == "Mix 0 and back": + e.set_macro(MIX_I, 0) + e.set_macro(MIX_I, 22) + elif step == "program_change(1)": + e.program_change(1) + else: + raise ValueError(step) + return e + + +def law(where, n1, heads, unfed_before=0): + want = {} + for s, v in where: + want[s] = v + if s < unfed_before: + continue + for k in heads: + want[s + k * n1] = want.get(s + k * n1, 0) + v + return want + + +def judge(out, channels, frames, want): + '''Samples wrong, over every lane: missing or wrong law frames plus + non-zero frames the law does not name.''' + wrong = 0 + for c in range(channels): + got = {} + for f in range(frames): + v = out[f * channels + c] + if v: + got[f] = v + wrong += len([s for s in want if s < frames and got.get(s) != want[s]]) + wrong += len([s for s in got if s not in want]) + return wrong + + +def part_firstdry(cls, label): + for rate in RATES: + for channels in (2, 1): + values = noise(channels, 8 * BLOCK) + for name in ROUTES: + e = build(cls, source(values, rate, channels, BLOCK), rate, + name) + window = min(e._n1, 2 * BLOCK) + out = pull_frames(e, window) + e.deinit() + diff = len([i for i in range(window * channels) + if out[i] != values[i]]) + print("FIRSTDRY|%s|%s|%d|%d|%d" % (label, name, rate, + channels, diff)) + + +def part_firstheads(cls, label): + where = ((10, 20000), (300, 10000), (700, 5000)) + for rate in RATES: + for channels in (2, 1): + for name in ROUTES: + total = 700 + 3 * (rate * 150 // 1000 + 1) + 4 * BLOCK + values = clicks(channels, total, where) + e = build(cls, source(values, rate, channels, BLOCK), rate, + name) + e.set_macro(FB_I, 0) + e.set_macro(MIX_I, 63.5) + n1 = e._n1 + frames = 700 + 3 * n1 + 2 * BLOCK + out = pull_frames(e, frames) + e.deinit() + wrong = judge(out, channels, frames, + law(where, n1, (1, 2, 3))) + print("FIRSTHEADS|%s|%s|%d|%d|%d" % (label, name, rate, + channels, wrong)) + + +def part_blocks(cls, label): + where = ((8400, 20000), (8900, 10000), (10100, 5000)) + for rate, channels in ((48000, 2), (48000, 1), (22050, 1)): + for frames_in in (256, 512, 100, None): + for pulls in (31, 32): + for event in ("none", "reset", "Mix 0 and back", + "Mix-0 run"): + total = 10100 + 3 * (rate * 150 // 1000 + 1) + 6 * BLOCK + values = clicks(channels, total, where) + e = cls(source(values, rate, channels, frames_in), + sample_rate=rate) + out = pull_frames(e, pulls * BLOCK) + if event == "reset": + e.reset() + elif event == "Mix 0 and back": + e.set_macro(MIX_I, 0) + e.set_macro(MIX_I, 22) + elif event == "Mix-0 run": + e.set_macro(MIX_I, 0) + out.extend(pull_frames(e, 4 * BLOCK)) + e.set_macro(FB_I, 0) + e.set_macro(MIX_I, 63.5) + n1 = e._n1 + span = 10100 + 3 * n1 + BLOCK - len(out) // channels + out.extend(pull_frames(e, span)) + e.deinit() + frames = len(out) // channels + unfed = (pulls + 4) * BLOCK if event == "Mix-0 run" else 0 + wrong = judge(out, channels, frames, + law(where, n1, (1, 2, 3), unfed)) + print("BLOCKS|%s|%s %d %s|%d|%d|%d" + % (label, frames_in or "raw", pulls, event, rate, + channels, wrong)) + + +def main(args): + '''Each argument is `plant,plant=part,part`.''' + parts = {"firstdry": part_firstdry, "firstheads": part_firstheads, + "blocks": part_blocks} + for group in args: + plants, names = group.split("=") + for plant in plants.split(","): + for part in names.split(","): + parts[part](PLANTS[plant], plant) + print("DONE") +""" + +ROUTES_RUNNER = """import sys +sys.path.insert(0, "lib") +sys.path.insert(0, sys.argv[1]) +import mtd_routes +mtd_routes.main(sys.argv[2:]) +""" + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +#: The routes before the first pull that `_resync` or a wiring takes, and +#: the two that take neither (the controls). +RESYNC_ROUTES = ("reset", "reset x2", "Mix 0 and back", + "reset, Mix 0 and back", "mix0 up, reset") +ROUTE_CONTROLS = ("none", "program_change(1)") +ROUTE_RATES = (48000, 44100, 22050) +BLOCK_CASES = ((48000, 2), (48000, 1), (22050, 1)) +#: Where a source's buffers leave the adapter holding frames at the event: +#: 512 after 31 blocks (256 held), 100 and one RawSample after 31 or 32. +HELD = (("512", 31), ("100", 31), ("100", 32), ("raw", 31), ("raw", 32)) + + +def run_routes(binary, groups): + """{(part, plant, route, rate, channels): samples wrong} from one run + of `ROUTES_MODULE` under `binary`.""" + with tempfile.TemporaryDirectory() as directory: + with open(os.path.join(directory, "mtd_routes.py"), "w") as handle: + handle.write(ROUTES_MODULE) + runner = os.path.join(directory, "run.py") + with open(runner, "w") as handle: + handle.write(ROUTES_RUNNER) + env = dict(os.environ, MICROPYPATH="lib", GCOV_PREFIX=directory, + PYTHONDONTWRITEBYTECODE="1") + argv = [binary] + ([] if binary == sys.executable + else ["-X", "heapsize=256M"]) + done = subprocess.run(argv + [runner, directory] + list(groups), + capture_output=True, text=True, cwd=ROOT, + env=env) + lines = done.stdout.splitlines() + if done.returncode != 0 or not lines or lines[-1] != "DONE": + raise AssertionError("%s: %s" % (binary, done.stderr[-2000:])) + out = {} + for line in lines[:-1]: + part, plant, name, rate, channels, wrong = line.split("|") + out[(part, plant, name, int(rate), int(channels))] = int(wrong) + return out + + +def red_cells(results, part, plant): + return sorted(key[2:] for key, wrong in results.items() + if key[0] == part and key[1] == plant and wrong) + + +def current_native_interpreters(): + """{"micropython": path, "circuitpython": path} from the workspace's + `bin/`, each the first whose provenance stamp contains `AUDIODSP_PIN`'s + commit (None where no binary does), or None where there is no + workspace `bin/` with its provenance tool above this checkout.""" + from tools import provenance_gate + here = ROOT + workspace = None + for _up in range(5): + here = os.path.dirname(here) + if os.path.isdir(os.path.join(here, "bin")) and os.path.isfile( + os.path.join(here, "tools", "provenance.py")): + workspace = here + break + if workspace is None: + return None + bindir = os.path.join(workspace, "bin") + tool = os.path.join(workspace, "tools", "provenance.py") + pin = provenance_gate.audiodsp_pin() + found = {} + for family in ("micropython", "circuitpython"): + found[family] = None + names = sorted(name for name in os.listdir(bindir) + if name.startswith(family) and "." not in name) + for name in names: + path = os.path.join(bindir, name) + done = subprocess.run( + [sys.executable, tool, "check", path, "--source", "audiodsp", + "--contains", "audiodsp=%s" % pin], capture_output=True) + if done.returncode == 0: + found[family] = path + break + return found + + +def routes_module(): + """`ROUTES_MODULE` imported in this process, for its plants.""" + module = sys.modules.get("mtd_routes") + if module is None: + module = types.ModuleType("mtd_routes") + sys.modules["mtd_routes"] = module + exec(ROUTES_MODULE, module.__dict__) + return module + + +def stall_cell(cls, rate=RATE, channels=2): + """Feedback 0.5 by the constructor (Repeat Tone 800 Hz's k = 1 window), + Time 20 ms, Mix 2, a 2 LSB DC for 1 s, then silence: (the Feedback the + lap node is handed, the knob's own Feedback, tail_samples, frames from + the input's end to the last non-zero sample, whether the last frame + rendered is non-zero).""" + frames = rate + data = array("h", [2] * (frames * channels)) + effect = cls(Endless(data, rate, channels), sample_rate=rate, + feedback=0.5, tone_hz=800.0, time_ms=20.0, mix=2.0) + declared = effect.tail_samples + total = frames + declared + 8 * effect._lap + out = pull(effect, total) + handed = effect._feedback + knob = effect.macro(FEEDBACK_I) + effect.deinit() + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else -1 + return handed, knob, declared, last - frames + 1, last == total - 1 + + +class ReauditRoundOne(unittest.TestCase): + """Re-audit fix round 1 (2026-09-28): the class at audiodsp v0.6.3rc1, + and the gate audit's round 3 items 1-4.""" + + def test_the_feedback_is_handed_as_set(self): + # Up to audiodsp v0.6.2 the lap node's loop low-pass could hold a + # small value for ever a hair either side of 1 - 0.5 / k, and the + # class handed it the nearer edge of the window instead: only + # Feedback 127 (0.95, k = 10) ever moved, at every Repeat Tone + # position and rate, to 0.950016. Since v0.6.3rc1 (#157) every + # position is handed as set and the bound counts the landing's lap: + # 167 laps at 0.95, Repeat Tone at the default (166 stepped). + for rate in RATES: + effect = MultiTapDelay(Endless(silence(), rate), sample_rate=rate) + for midi in range(128): + effect.set_macro(FEEDBACK_I, midi) + want = min(0.95, effect.macro(FEEDBACK_I)) + self.assertEqual(effect._feedback, want, (rate, midi)) + self.assertEqual(effect._lap_mix, want, (rate, midi)) + self.assertIsInstance(effect.tail_samples, int) + excess = mtd.tone_excess(effect._damping, rate)[1] + self.assertEqual(effect._feedback, 0.95) + self.assertEqual(laps_to_zero(0.95, excess), 167) + # Planted: the retired stepping hands a Feedback nobody set. + stepped = SteppedLaps(Endless(silence(), rate), sample_rate=rate) + stepped.set_macro(FEEDBACK_I, 127) + self.assertNotEqual(stepped._feedback, 0.95, rate) + self.assertLess(abs(stepped._feedback - 0.95), 3e-5) + self.assertEqual(laps_to_zero(stepped._feedback, excess), 166) + memory = mtd.tone_excess(effect._damping, rate)[0] + self.assertEqual(effect.tail_samples - stepped.tail_samples, + effect._lap + 1 + memory) + + def test_a_stall_window_cell_reaches_zero_at_the_feedback_set(self): + # Feedback 0.5 with Repeat Tone at 800 Hz is inside a stall window + # (the retired stepping moves it), and up to v0.6.2 the node held + # this cell for ever there. Since v0.6.3rc1 it is handed as set and + # the tail ends inside the bound. At v0.6.2 the same cell holds + # past it (the pack's re-audit fix round 1 section, on + # bin/micropython and bin/circuitpython at v0.6.2). + for rate, channels in ((RATE, 2), (RATE, 1), (44100, 2), + (22050, 2)): + handed, knob, declared, tail, held = stall_cell( + MultiTapDelay, rate, channels) + # The knob's 0.5 (0.49999999999999994 through the position) is + # inside the window: the retired stepping moves it. + self.assertLess(abs(knob - 0.5), 1e-15) + self.assertNotEqual(clear_of_stalls(knob, mtd.tone_excess( + nominal_damping_hz(800.0, rate), rate)[1]), knob) + self.assertEqual(handed, knob, (rate, channels)) + self.assertFalse(held, (rate, channels)) + self.assertGreater(tail, 0, (rate, channels)) + self.assertLessEqual(tail, declared, (rate, channels)) + stepped = SteppedLaps(Endless(silence()), sample_rate=RATE, + feedback=0.5, tone_hz=800.0) + self.assertNotEqual(stepped._feedback, stepped.macro(FEEDBACK_I)) + self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + + def _judge_routes(self, results, native): + """Items 1 and 2 on one interpreter's run.""" + for part in ("FIRSTDRY", "FIRSTHEADS"): + self.assertEqual(red_cells(results, part, "clean"), [], part) + cells = [key for key in results + if key[0] == part and key[1] == "clean"] + self.assertEqual(len(cells), 7 * 3 * 2, part) + # The plant: ac2181f's wiring. Every route that resyncs or wires + # before the first pull loses the first block's heads, in every + # lane; the controls stay green. + self.assertEqual( + red_cells(results, "FIRSTHEADS", "oldwiring"), + sorted((name, rate, channels) for name in RESYNC_ROUTES + for rate in ROUTE_RATES for channels in (2, 1))) + # And on a native interpreter, in mono, the dry's first block: all + # 256 samples zero. The CPython shim copies the primed block, so + # there the dry is kept (which is why this runs natively). + dry = red_cells(results, "FIRSTDRY", "oldwiring") + if native: + self.assertEqual(dry, sorted( + (name, rate, 1) for name in RESYNC_ROUTES + for rate in ROUTE_RATES)) + for name, rate, channels in dry: + self.assertEqual(results[("FIRSTDRY", "oldwiring", name, + rate, channels)], 256) + else: + self.assertEqual(dry, []) + + def _judge_blocks(self, results): + """Items 3 and 4 on one interpreter's run.""" + self.assertEqual(red_cells(results, "BLOCKS", "clean"), []) + cells = [key for key in results + if key[0] == "BLOCKS" and key[1] == "clean"] + self.assertEqual(len(cells), 3 * 4 * 2 * 4) + # The adapter registered for a reset drops its frames at reset() and, + # through the base's `_rejoin`, at every return from Mix 0 as well; + # ac2181f, which had no `_rejoin`, lost them at reset() only. Mix 0 + # on the source itself skips them while Mix is 0. The 256-frame + # control, which leaves the adapter holding nothing, stays green. + for plant, events in (("adapterreset", + ("reset", "Mix 0 and back", "Mix-0 run")), + ("sourcebypass", ("Mix-0 run",))): + want = sorted(("%s %d %s" % (buffers, pulls, event), rate, + channels) + for buffers, pulls in HELD for event in events + for rate, channels in BLOCK_CASES) + self.assertEqual(red_cells(results, "BLOCKS", plant), want, + plant) + + def test_routes_before_the_first_pull_and_source_buffers_cpython(self): + results = run_routes(sys.executable, ( + "clean,oldwiring=firstdry,firstheads", + "clean,adapterreset,sourcebypass=blocks")) + self._judge_routes(results, native=False) + self._judge_blocks(results) + + def test_routes_before_the_first_pull_and_source_buffers_native(self): + found = current_native_interpreters() + if found is None: + self.skipTest("no workspace bin/ above this checkout") + for family, binary in sorted(found.items()): + # A binary present but built before the pin renders a node the + # pin does not name; that is a failure, not a skip. + self.assertIsNotNone(binary, "no %s in the workspace bin/ " + "contains AUDIODSP_PIN's audiodsp" % family) + results = run_routes(binary, ( + "clean,oldwiring=firstdry,firstheads", + "clean,adapterreset,sourcebypass=blocks")) + self._judge_routes(results, native=True) + self._judge_blocks(results) + + def test_the_route_plants_are_not_on_the_surface(self): + module = routes_module() + for faulted, reading, target in ( + (module.OldWiring, lambda e: e._quiet_wiring(), False), + (module.AdapterReset, + lambda e: e._resets[e._nodes.index(e._adapter)], True), + (module.SourceBypass, lambda e: e._bypass() is e._source, + True)): + result = reach(faulted, reading) + self.assertIs(result["target"], target, faulted.__name__) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_mix_zero_hands_out_the_input_adapter(self): + # A wire at Mix 0 through the adapter (behind `_through`): a bare + # RawSample, byte for byte from the first frame, on the three rates + # and both channel counts. + for rate in RATES: + for channels in (2, 1): + data = probes.ramp_fs(4096, channels) + raw = audiocore.RawSample(data, sample_rate=rate, + channel_count=channels) + effect = MultiTapDelay(raw, sample_rate=rate, mix=0.0) + self.assertIs(_component.port_target(effect._output), + effect._through) + out = pull(effect, 4096).reshape(-1) + self.assertTrue(np.array_equal( + out, np.array(data, dtype=np.int64)), (rate, channels)) + effect.deinit() + + def test_a_host_reset_at_mix_zero_keeps_the_timeline(self): + # 31 blocks at Mix 0 from a source in 512-frame buffers, so the + # adapter holds 256 frames, then the host resets the output: the + # click at 8 400 stays at 8 400 (the adapter itself as the port's + # target puts it at 8 144). + module = routes_module() + for channels in (2, 1): + values = module.clicks(channels, 20000, ((8400, 20000),)) + for cls, want in ((MultiTapDelay, 8400), (BareAdapter, 8144)): + effect = cls(module.source(values, RATE, channels, 512), + sample_rate=RATE, mix=0.0) + head = pull(effect, 31 * BLOCK) + audiocore.reset_buffer(effect.output) + out = np.concatenate([head, pull(effect, 2000)]) + effect.deinit() + self.assertEqual(lane_hits(out), [[want]] * channels, + (cls.__name__, channels)) + result = reach(BareAdapter, lambda e: e._bypass() is e._adapter) + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + if __name__ == "__main__": unittest.main() From a5675d7dba8454d992e0c5ae5369c1ac23e55433 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:53:22 -0500 Subject: [PATCH 58/88] TapeDelay re-audit fix round 1: patch 8 'Tape Delay - lean' on Brad's cost ruling Patch 8 is patch 0 with Record Level 0. Built with max_time_ms=800 it hands the node what the cost study's variant K hands it at patch 0, which met the P4 and S3 bars in every run (P4 0.445-0.455 ms, S3 0.781-0.797 ms, at audiodsp v0.6.2); the docstring says so, gives the full class's measured over-budget figures, and says what the lean build gives up: the tape saturation and Time above 800 ms. TABLE_POINTS carries the warning that a shorter wow table kills Flutter while T4 reads green. The float32-walk citation is audiodsp_feedback_delay.c:492 at v0.6.3rc1. Tests: LeanPatch (the patch, what the node is handed, byte identity with variant K; LeanDriveOnTape and LeanMovesMoreTape red), and the patch-count walks at nine patches (reachability 204 positions). No trait, default, TIME_MAX_MS or other patch moved. --- CHANGELOG.md | 6 +- lib/audioeffects/rebuilt/tapedelay.py | 42 ++++++-- tests/test_cpython_effects_tapedelay.py | 138 ++++++++++++++++++++++-- 3 files changed, 169 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a183408..ef451cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,11 @@ there, and are recorded in its changelog. patches, on `noise_det`), the band the loss law holds in, the wobble's 100 s period, where the pitch claim stops (the node's single-precision walk limits rising moves past 16 384 and 32 768 frames), and which Wow - and Flutter moves still step. Board cost is unmeasured. At audiodsp + and Flutter moves still step. On the boards the full class is over its + budget (P4 10.3-11.4 % against 9 %, S3 19.8-20.5 % against 15 %, at + audiodsp v0.6.2); patch 8 `Tape Delay - lean` (patch 0 with Record + Level at 0) built with `max_time_ms=800` met both bars in every run, and + gives up the tape saturation and Time above 800 ms. At audiodsp v0.6.3rc1 its Feedback is handed as set (no stepping clear of the stall windows; the bound counts one landing lap there), a Wow or Flutter move that changes only the wobble's depth glides over 20 ms, and a knob turned diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index 7ddafe0..8cd7fa2 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -63,12 +63,29 @@ node. Pass a lower `max_time_ms` to spend less; Time then stops at that ceiling and `get_macro(0)` shows where it stopped. -**Cost.** One `audioecho.FeedbackDelay` with `delay_slew`, a wow table, -the loop low-pass and `loop_drive` on; no mixer. Palette row -FeedbackDelay +options (the nearest not-cheaper row), glue 0: -**P4 <= 9 %, S3 <= 15 %** of a 5.333 ms stereo block. The board -measurement is pending hardware. No `" - lean"` patch: every patch runs -the same node with the same options, so none would be cheaper. +**Cost, and what to run on a board.** One `audioecho.FeedbackDelay` with +`delay_slew`, a wow table, the loop low-pass and `loop_drive` on; no mixer. +The budget, from palette row FeedbackDelay +options with no glue, is +**P4 <= 9 % (0.480 ms), S3 <= 15 % (0.800 ms)** of a 5.333 ms stereo +block. **The full class is over it on both boards.** Measured at audiodsp +v0.6.2 at the default and every patch, it costs 0.551-0.608 ms a block on +the P4 (10.3-11.4 %) and 1.056-1.093 ms on the S3 (19.8-20.5 %); patch 6 +reads 0.470 ms (8.8 %) and 0.855 ms (16.0 %). It still runs in real time on +both. + +**To meet both bars, play patch 8, `Tape Delay - lean`, and build the class +with `max_time_ms=800`.** That configuration read 0.445-0.455 ms on the P4 +(8.3-8.5 %) and 0.781-0.797 ms on the S3 (14.6-14.9 %, a thin margin) in +each of three runs, measured at v0.6.2; the default and patch 5 built the +same way with the drive off read 0.434-0.447 and 0.764-0.794 ms. Patch 8 +is patch 0 with Record Level at 0, and the 800 ms line is the other half +of the saving. What you give up is the tape saturation: the repeats stay +clean however hard you play, and chords no longer grit up as the repeats +stack. And Time stops at 800 ms instead of 1 200 (patch 1's 789 ms still +fits, and `get_macro(0)` shows where Time stopped). The darkening, the +wobble and the pitch bends are patch 0's. Turning Record Level up again +brings the drive, and its cost, back. The boards are re-measured at the +release. **What the default surrenders.** The darkening follows the tape's loss law only up to a band top: one pole in the loop holds it to 2 dB from 100 Hz @@ -88,7 +105,7 @@ bend is written into the loop and stays there. The node walks the read head in single precision -(`audiodsp_feedback_delay.c:444`), so each step lands on the head's +(`audiodsp_feedback_delay.c:492`), so each step lands on the head's rounding grid, and that grid doubles every time the head passes a power of two in frames: 16 384 (341.3 ms at 48 kHz, 371.5 ms at 44.1, 743.0 ms at 22.05) and 32 768 (682.7 ms at 48 kHz, 743.0 ms at 44.1; never at @@ -238,6 +255,12 @@ #: the wow line at harmonic WOW_HARMONIC, the flutter line at #: FLUTTER_HARMONIC and the drift at harmonics 1-9, amplitude 1/k, at #: DRIFT_PHASES (dossier section 6; `tapedelay_stationA_common.py:59`). +#: TABLE_POINTS is not a cost lever. The node reads any length the same way, +#: and a shorter table silently kills the flutter: at 1 024 points harmonic +#: 512 sits on the table's Nyquist, at 256 it folds onto DC, and the Flutter +#: knob then writes the same table as Flutter 0 while T4 still reads two +#: lines, an interpolation image of the wow line (the 2026-09-28 cost study, +#: `audits/phase5/tapedelay-cost-options.md`). WOW_HZ = 0.01 TABLE_POINTS = 4096 WOW_HARMONIC = 72 @@ -487,7 +510,9 @@ class TapeDelay(_component.Component): ) #: `_component.macro_of` of the dossier's section 6 settings; patch 0 is - #: the constructor's defaults on the grid. + #: the constructor's defaults on the grid. Patch 8 is patch 0 with Record + #: Level at 0, the board's lean patch (with `max_time_ms=800`; the module + #: docstring's Cost). PATCHES = { 0: ("Warm Repeats", (89, 58, 22, 89, 32, 32, 25, 51, 0, 0, 51)), 1: ("Long Repeats, Slow Glide", @@ -500,6 +525,7 @@ class TapeDelay(_component.Component): 6: ("Clean Transport", (89, 58, 22, 89, 0, 0, 0, 0, 0, 0, 51)), 7: ("Dotted Eighth, Synced", (89, 58, 22, 89, 32, 32, 25, 51, 0, 127, 68)), + 8: ("Tape Delay - lean", (89, 58, 22, 89, 32, 32, 0, 51, 0, 0, 51)), } def _build(self, time_ms=350.0, feedback=0.45, mix=0.35, glide_ms=6000.0, diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index b14c6ee..d78501d 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -379,6 +379,39 @@ def _refresh(self): self._delay.set(wow_shape=None) +class LeanDriveOnTape(TapeDelay): + """The lean patch with the drive left on: patch 8 plays patch 0's + Record Level, so it names the saving and makes none (Brad's cost + ruling, 2026-09-28).""" + + NAME = 'TapeDelay' + PATCHES = dict(TapeDelay.PATCHES) + PATCHES[8] = ("Tape Delay - lean", TapeDelay.PATCHES[0][1]) + + +class LeanMovesMoreTape(TapeDelay): + """A lean patch that also moves Spacing: the drive is off, but it is no + longer patch 0 with the drive off.""" + + NAME = 'TapeDelay' + PATCHES = dict(TapeDelay.PATCHES) + PATCHES[8] = ("Tape Delay - lean", + (89, 58, 22, 89, 32, 32, 0, 60, 0, 0, 51)) + + +class DriveOffTape(TapeDelay): + """Not a fault: the cost study's variant D, `loop_drive` handed 0 after + every refresh (`tapedelay_cost_variants.py`, `DriveOff`). Built at + `max_time_ms=800` it is variant K, the configuration the boards + measured.""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + self._delay.set(loop_drive=0.0) + + class JumpWowTape(TapeDelay): """A Wow or Flutter move that moves the read head by the whole change in depth at once, as the node did at the wobble's crest up to v0.6.2 (it @@ -595,7 +628,7 @@ def test_macros_characters_tier_latency(self): "Time", "Feedback", "Mix", "Glide", "Wow", "Flutter", "Record Level", "Spacing", "Spread", "Sync", "Division")) self.assertEqual(TapeDelay.MACRO_MODES[SYNC_I], "TOGGLE") - self.assertEqual(len(TapeDelay.PATCHES), 8) + self.assertEqual(len(TapeDelay.PATCHES), 9) self.assertEqual(TapeDelay.CAPABILITIES, ("tempo_sync",)) self.assertEqual(TapeDelay.LATENCY_SAMPLES, 0) self.assertEqual(TapeDelay.TIER, _component.AUDIODSP) @@ -626,6 +659,7 @@ def test_patches_are_the_dossier_settings_on_the_grid(self): (350.0, 0.45, 0.35, 6000.0, 4.0, 3.0, 0.5, 20.0, 0.0, 0.0, 6.0), (350.0, 0.45, 0.35, 6000.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 6.0), (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.2, 5.0, 0.0, 1.0, 8.0), + (350.0, 0.45, 0.35, 6000.0, 2.0, 1.0, 0.0, 5.0, 0.0, 0.0, 6.0), ) for index, values in enumerate(settings): want = tuple( @@ -732,7 +766,7 @@ def test_tail_samples_at_each_patch_played_from_rest(self): # which is 1/16. at 120 bpm, so its 128 982 frames is not the bound # this patch needs; the class's is 257 418. want = (241990, 692550, 27336, 407322, 1671015, 245728, 240800, - 241990) + 241990, 241990) for index, frames in enumerate(want): effect = TapeDelay(src_of(np.zeros(512)), patch=index) self.assertEqual(effect.tail_samples, frames, index) @@ -791,6 +825,94 @@ def test_spread_is_held_at_zero_in_mono(self): self.assertEqual(stereo._spread, 1.0) +LEAN = 8 + + +def lean_surface(cls): + """(the lean patch's name, the positions where it differs from patch 0 + as {index: (patch 0, lean)}).""" + name, lean = cls.PATCHES[LEAN] + full = cls.PATCHES[0][1] + return name, {i: (a, b) for i, (a, b) in enumerate(zip(full, lean)) + if a != b} + + +def lean_handed(cls, **ctor): + """What the node is handed at the lean patch and at patch 0, as + {option: (patch 0, lean)} for every option that differs; a table is + compared point by point.""" + def state(patch): + effect = cls(src_of(np.zeros(512)), patch=patch, **ctor) + handed = dict(effect._delay._handed) + effect.deinit() + if handed.get("wow_shape") is not None: + handed["wow_shape"] = tuple(handed["wow_shape"]) + return handed + with NodeSpy(): + full, lean = state(0), state(LEAN) + return {k: (full.get(k), lean.get(k)) for k in set(full) | set(lean) + if full.get(k) != lean.get(k)} + + +def lean_render(cls, patch=LEAN, **ctor): + """A 997 Hz tone at -1 dBFS for 300 ms, then silence, through `patch`, + one second at 48 kHz stereo: loud enough that the drive shows on every + repeat.""" + x = np.zeros(RATE) + x[:int(0.3 * RATE)] = sine(TONE, 29205.0, int(0.3 * RATE)) + return render(cls(src_of(x), patch=patch, **ctor), RATE) + + +class LeanPatch(unittest.TestCase): + """Brad's cost ruling of 2026-09-28: keep the class and add a lean + patch. Patch 8 `Tape Delay - lean` is patch 0 with Record Level 0, and + with `max_time_ms=800` it is the cost study's variant K, which met the + P4 and S3 bars in every run.""" + + def test_the_lean_patch_is_patch_0_with_the_drive_off(self): + name, moved = lean_surface(TapeDelay) + self.assertEqual(name, "Tape Delay - lean") + self.assertTrue(name.endswith(" - lean")) + self.assertEqual(moved, {RECORD_I: (25, 0)}) + # What the node is handed: the drive off, and nothing else moved. + handed = lean_handed(TapeDelay) + self.assertEqual(set(handed), {"loop_drive"}) + self.assertGreater(handed["loop_drive"][0], 0.19) + self.assertEqual(handed["loop_drive"][1], 0.0) + self.assertEqual(lean_handed(TapeDelay, max_time_ms=800.0), + lean_handed(TapeDelay)) + # Planted: the drive left on, and a lean patch that moves more. + self.assertEqual(lean_handed(LeanDriveOnTape), {}) + self.assertNotEqual(lean_surface(LeanDriveOnTape)[1], + {RECORD_I: (25, 0)}) + self.assertIn("damping_hz", lean_handed(LeanMovesMoreTape)) + self.assertNotEqual(lean_surface(LeanMovesMoreTape)[1], + {RECORD_I: (25, 0)}) + + def test_the_lean_build_renders_what_the_boards_measured(self): + lean = lean_render(TapeDelay, max_time_ms=800.0) + self.assertGreater(float(np.max(np.abs(lean[int(0.4 * RATE):]))), + 1000.0) + # Variant K at patch 0 is the cell the boards timed; patch 8 at the + # 800 ms line renders it byte for byte, and so does patch 8 on the + # full line (the shorter line moves no byte at this Time). + k = lean_render(DriveOffTape, 0, max_time_ms=800.0) + self.assertEqual(lean.tobytes(), k.tobytes()) + self.assertEqual(lean.tobytes(), lean_render(TapeDelay).tobytes()) + # The drive is what the lean patch drops: patch 0 differs. + self.assertNotEqual(lean.tobytes(), + lean_render(TapeDelay, 0).tobytes()) + # Planted: the drive left on renders patch 0, not variant K. + on = lean_render(LeanDriveOnTape, max_time_ms=800.0) + self.assertNotEqual(on.tobytes(), k.tobytes()) + # The 800 ms build stops Time there, where get_macro(0) shows it. + effect = TapeDelay(src_of(np.zeros(512)), patch=LEAN, + max_time_ms=800.0) + effect.set_macro(TIME_I, 127) + self.assertAlmostEqual(effect.macro(TIME_I), 800.0, places=6) + self.assertEqual(effect._frames, frames_of(800.0)) + + class T1aVarispeed(unittest.TestCase): def test_the_named_moves(self): for rate, a, b in ((48000, 200.0, 100.4), (48000, 180.0, 600.0), @@ -1099,8 +1221,8 @@ def glide_ms_of(grid): def read_head(cls): """`cls` with its loop low-pass handed 0 after every refresh: an instrument for reading where the read head is, never a subject. The - walk is the node's `delay_slew` (`audiodsp_feedback_delay.c:444`, - `:449`) and never sees the filter, which filters what was read, so the + walk is the node's `delay_slew` (`audiodsp_feedback_delay.c:492`, + `:497` at v0.6.3rc1) and never sees the filter, which filters what was read, so the positions are the class's (Station C's `read_head_class`).""" if cls not in _READ_HEADS: def _refresh(self): @@ -1879,7 +2001,7 @@ def test_the_dry_is_unity_until_the_repeat(self): def test_silence_stays_silence(self): for character in tape.CHARACTERS: - for patch in range(8): + for patch in range(len(TapeDelay.PATCHES)): effect = TapeDelay(src_of(np.zeros(RATE)), character=character, patch=patch) self.assertEqual(float(np.max(np.abs(render(effect, RATE)))), @@ -2125,7 +2247,7 @@ def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): self.assertEqual(self._rails(-1.1, channels), 0, channels) self.assertEqual(self._rails(-1.0, channels), over, channels) for character in tape.CHARACTERS: - for patch in range(8): + for patch in range(len(TapeDelay.PATCHES)): self.assertEqual(self._rails(-2.0, 2, patch, character), 0, (character, patch)) self.assertGreater(self._rails(-1.8, 2, 2), 0) @@ -2412,10 +2534,10 @@ def reach_ctor(faulted, reading, rate, ctor, tolerance=1e-9): class FaultsAreUnreachable(unittest.TestCase): """Every planted fault's reachability walk, at 48, 44.1 and 22.05 kHz, reading what the node is handed at each position (11 macros x 17 - positions + 8 patches); and two builds the walk must call reachable, so + positions + 9 patches); and two builds the walk must call reachable, so the readings are shown able to fail.""" - CHECKED = 11 * 17 + 8 + CHECKED = 11 * 17 + 9 def test_every_fault_is_off_the_surface_at_three_rates(self): for rate in (48000, 44100, 22050): From 1b3bb947400a06609667036ab82b1c35c5e6a1f1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:53:56 -0500 Subject: [PATCH 59/88] ConvolutionReverb at audiodsp v0.6.3rc2: the class on the fixed convolution node AUDIODSP_PIN names v0.6.3rc2 (0d35a90), on this class branch first, so the class is audited once on the fixed node. audiodsp#165: a re-synthesis on a playing node keeps its state and crossfades the block in flight (#163), and each side of a stereo room is normalised on its own (#164). Five tests went red at the new pin, each because it pinned a defect or a figure of the old node, and nothing else did. Each is restated to assert the good behaviour and shown red on a planted copy of the old: - The two room-move tests now assert that a room-knob move drops no dry frame at Mix 0 (every knob, three rates, stereo and mono, sources in 256- and 100-frame blocks) and that the room rings on, lands byte for byte on a room built new after the block in flight, and crossfades within 1 LSB inside it. ResetOnMove (the node cleared after every re-synthesis) is red; on a mono room it renders the v0.6.3rc1 node's bytes. - D6Balance and D6OneSided now pin each side's level (the dossier's new D6 clause 4): within 0.001 dB on the room's own impulse over the settings walked, widest +0.0005 dB, and one-sided noise within a few tenths. SideTilt, SideNudge (0.05 dB) and the 2ad414c words are red. - D5SingleRoom's stereo cells are read again on the fixed node (+16.56 % at 48 kHz, +16.05 % at 22.05 kHz, -20 dBFS); the 2ad414c figures are red. The reset test now covers the plain defaults, where NoReset was inert while the node emptied itself on a re-synthesis. The docstring drops the dropped partition and the lean, says what a player now hears on a room move, re-reads the low-chord lift (+3.83 / +13.40 dB at 48 kHz), and names the board race (audiodsp#166, open). The CHANGELOG line follows, with a Changed entry for the pin. Gates: unittest 1966 tests OK (skipped=3); class suite 197 OK; flake8 clean; validate_api 55 / 45; metadata valid; the smoke 45 classes, 259 patches, 0 failures on CPython and MicroPython and CircuitPython built at the tag; Station C's 60 digests agree on the three (30 stereo moved, 30 mono did not). --- AUDIODSP_PIN | 31 +- CHANGELOG.md | 28 +- lib/audioeffects/rebuilt/convolutionreverb.py | 134 +++-- .../test_cpython_effects_convolutionreverb.py | 486 ++++++++++-------- 4 files changed, 391 insertions(+), 288 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 0b4799b..75bab47 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -232,5 +232,34 @@ # patches, 0 failures on CPython, MicroPython and CircuitPython. Pending # both boards. # +# 2026-09-28: v0.6.3rc1 -> v0.6.3rc2 (0d35a90), on this class branch +# (effects/phase5-convolutionreverb) first, so ConvolutionReverb is audited +# once, on the fixed node (Brad, the same day). A pre-release of the next +# patch, on TestPyPI only, like rc1. What moved beneath the components, by +# `git log v0.6.3rc1..v0.6.3rc2 -- src`: audiodsp#165, two fixes to +# audioconvolve.Convolver. A re-synthesis on a node that holds an impulse +# keeps the node's state: the block in flight is recomputed against the +# old room and the new and crossfaded over its unplayed frames, no frame +# of the dry drops at any mix, and from the next block the node is a node +# built with the new room (#163; load() still resets, and a first +# synthesis on an empty node still does). A stereo synthesized room gets +# one energy and one scale per side, so it no longer leans (#164). Mono +# rooms and measured impulses render the bytes they did; every stereo +# synthesized room moved. Five ConvolutionReverb tests pinned a defect or +# a figure of the old node and went red at the new pin, nothing else did; +# each is restated to the good behaviour beside a planted copy of the old +# (ResetOnMove renders the v0.6.3rc1 node's bytes on a mono room). Station +# C's 60 digests agree on CPython, MicroPython and CircuitPython built at +# the tag (bin/micropython-063rc2, audioecho.__revision__ +# v0.0.3-295-g0d35a90, and bin/circuitpython-063rc2, whose provenance +# stamp names the same): the 30 stereo cells moved, the 30 mono cells did +# not. Gates here: the unit suite `Ran 1966 tests ... OK (skipped=3)` +# (no Flanger F8 failure: effects/phase5 restated F8); validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 259 +# patches, 0 failures on CPython and the two new binaries. The old +# reverb.ConvolutionReverb synthesizes stereo rooms too, so its renders +# move; no test holds them. Pending both boards, and the pump race on a +# board (audiodsp#166, open). +# # -- ref is what CI installs (git+https://...@) -v0.6.3rc1 bd7d2e219208731cc100a1e8210ec72f8cec9af1 +v0.6.3rc2 0d35a90c82c106c5bc3f18c7af976aac412aa904 diff --git a/CHANGELOG.md b/CHANGELOG.md index fce8cd9..8c45f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,20 +104,28 @@ there, and are recorded in its changelog. byte for byte. Measured mode loads at unit mean energy and refuses the synthesis macros with `IndexError`; no impulse ships. It lives in `lib/audioeffects/rebuilt/convolutionreverb.py`, and - `audioeffects.ConvolutionReverb` is still the old class. The room holds - its level over both channels together (for material about equally loud - on both sides), but Diffusion, Room, Damping, Decay and Predelay move - a stereo room's left-right balance, by at least about 5.3 dB at 48 and - 44.1 kHz and 4.7 dB at 22.05 kHz (the widest found; how far it can go - is not known); fixing that is a node change, drafted as an audiodsp - ask. The docstring states that, the low-material lift a dark room - gives, and the partition a room-knob move or a mid-stream `reset()` - drops. Board cost - is unmeasured; by the cost table's line the default fits an S3, and + `audioeffects.ConvolutionReverb` is still the old class. It stands on + audiodsp v0.6.3rc2: moving a room knob changes the room over the block + in flight, with no frame of the dry dropped or repeated at any Mix and + the tail ringing on into the new room, and each side of a stereo room + is unit energy on its own, so the room sits in the middle (the two + sides within 0.001 dB on the room's own impulse over the settings + walked). The docstring states the low-material lift a dark room gives + and the partition a mid-stream `reset()` drops. Board cost is unmeasured; + by the cost table's line the default fits an S3, and anything over 0.091 s there is desktop-only. ### Changed +- **The audiodsp pin moves to v0.6.3rc2.** The release adds audiodsp#165 to + rc1: `audioconvolve.Convolver` keeps the audio in flight when a room is + re-synthesized (#163: the block on its way out crossfades from the old + room to the new, nothing drops, the tail rings on) and normalises each + side of a stereo room on its own (#164: the room no longer leans, and + the pair's level holds). Mono rooms and measured impulses render the + bytes they did. `ConvolutionReverb`, rebuilt and old, synthesizes + stereo rooms, so its stereo renders move; nothing else here uses the + node. - **The audiodsp pin moves to v0.6.3rc1, and three workarounds come out.** The release carries audiodsp#161: `audioecho.FeedbackDelay` keeps an out loop filter's state live (#158, #159), lands a stalled damping state so a diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index f527c71..7a844bb 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -6,18 +6,15 @@ 85cc2bfc9a5a3c34c6906fbf0c27b285ba1050d2, the Station A critique's re-freeze, 2026-09-28). The old class in `reverb.py` is consulted only for the six defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. After the gate -audit's round 1 the dossier carries a dated post-build revision (fix round -1, 2026-09-28): D6 claims the level of both channels pooled, and this -docstring says what that leaves out. Fix round 2 (the same day, after the -round-2 audit) corrected the left-right balance figure it gives, from -about 1.3 dB to about 4.5 dB, and three edges; the audio did not change. -The re-audit's fix round 1 (after the round-3 audit) replaced that bound -with the widest balance a walk over Diffusion's every position found, -stated as a floor on the swing, not a bound; the audio did not change. -The re-audit's fix round 2 did the same for a single Room's distance from -the Decay law, named the Room behind the one-sided example below, and -added Predelay to what moves the balance; the audio did not change. +library serves until the board runner adopts this one. The dossier's dated +post-build revisions (2026-09-28) record what each audit round changed in +the words and tests; the audio did not change in any of them. Since the +re-audit at audiodsp v0.6.3rc2 the class stands on the fixed convolution +node (audiodsp#165): a room-knob move no longer drops the block in flight +or stops the tail (#163), and each side of a stereo room is normalised on +its own, so the room no longer leans (#164). The two sentences that +disclosed those defects are gone, and every stereo figure below was read +again on the fixed node. **What it sounds like.** A short room behind your dry signal. With nothing loaded the class synthesizes the room: noise under an exponential that @@ -74,7 +71,7 @@ impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is the source delayed by exactly `latency_samples`, byte for byte, because the node stays in the path at Mix 0 and a Mix move never jumps the timeline. -The exceptions are the partition a room knob moves in and the partition +A room-knob move keeps it too; the one exception is the partition `reset()` is called in (below). **Tail.** `tail_samples` is `latency_samples` plus the loaded impulse @@ -87,53 +84,48 @@ 44.1 kHz and 76 008 B at 22.05 kHz. A measured impulse is read once at construction, handed to the node and dropped; the class keeps no copy. -**Moving a room knob starts the room empty and drops one partition of -everything.** Decay, Damping, Predelay, Diffusion and Room re-synthesize -the impulse, and the node empties itself when it does: a tail ringing at -that moment stops dead, and the 256 frames in flight (5.333 ms at 48 kHz), -dry and wet alike, come out as exact zero. That is a gap in your dry -signal at every Mix, Mix 0 included; after it, Mix 0 is the source delayed -by `latency_samples` again, byte for byte. The class cannot avoid it from -Python (the node's `synthesize()` ends in a reset of its pending and -output blocks; the node ask is drafted), so do not sweep a room knob under -a signal you need unbroken. A move that lands on the room already loaded -does nothing and drops nothing, and a patch change, the constructor and -`reset()` synthesize once, not once per knob. `reset()` in the middle of a -stream drops the 256 frames in flight the same way, dry included. Mix -moves never touch the room, but a Mix move acts on the audio entering the -node after it, so you hear it one partition later: the 256 frames already -in flight come out at the old Mix. +**Moving a room knob changes the room over the block in flight.** Decay, +Damping, Predelay, Diffusion and Room re-synthesize the impulse, and the +node keeps what it holds: no frame of your dry signal drops or repeats, at +any Mix (at Mix 0 the output stays the source delayed by +`latency_samples`, byte for byte, across the move), and a tail ringing at +that moment rings on into the new room. Over the 256 frames already on +their way out (5.333 ms at 48 kHz) the old room fades into the new one in +a straight line, within 1 LSB (a sample at full scale in either room is +clipped after the fade), and in every move measured the fade stepped no +further from one frame to the next than the larger of the two rooms does +on its own over the same frames; from the next block the output is exactly +that of an instance that always had the new room. A move that lands on the room already loaded does +nothing, and a patch change, the constructor and `reset()` synthesize +once, not once per knob. The synthesis itself runs on the thread that +moves the knob; on a board it can race the audio pump (audiodsp#166, +open), which a desktop cannot show. `reset()` in the middle of a stream is +a reset: it empties the room, and the 256 frames in flight come out as +exact zero, dry included. Mix moves never touch the room, but a Mix move +acts on the audio entering the node after it, so you hear it one partition +later: the 256 frames already in flight come out at the old Mix. **What the default surrenders.** The room is normalised to unit energy across the whole band, so with Damping in, low material comes back louder -than it went in: a 220 / 277 / 330 Hz chord +3.73 dB at the default -6 kHz Damping and +13.28 dB at the 500 Hz stop. White-spectrum material -about equally loud on both sides comes back at its own level, within -0.5 dB, at every setting, counting both channels together. Material on -one side only does not, and which side comes back louder depends on the -Room. At 48 kHz with Decay 0, Damping 500 Hz and Diffusion 0, white noise -hard left comes back 2.7 dB down and hard right 1.8 dB up at Room seed 36, -and at the default Room, seed 1, hard left comes back 1.3 dB up and hard -right 2.1 dB down (at seed 1, Decay 1.0, Diffusion 0.5 and Damping 500 Hz, -about 0.8 dB down and 0.5 dB up). That noise is one draw, the kit's -uniform seed 12345 at -12 dBFS peak; another draw reads a few tenths of a -dB off it. Each side on its own does not hold either: the node scales the -room by the mean of its two sides' energies, so on a stereo room the -left-right balance moves while the total holds, and Diffusion, Room, -Damping, Decay and Predelay all move it. It moves by at least about -5.3 dB at 48 and 44.1 kHz and 4.7 dB at 22.05 kHz, and how far it can go -is not known: no walk covers every setting. The widest found on the -room's own impulse is L - R -5.30 dB at 48 kHz, -5.26 dB at 44.1 kHz and --4.67 dB at 22.05 kHz, all at Decay 0, Damping 500 Hz and Room seed 36, -with Diffusion at 12, 13 and 22 of 127. The other way, the widest found -is +4.49, +4.55 and +4.05 dB (Decay 0, Damping 500 Hz, Diffusion 0, -seed 4). Those are the impulse's own figures, which is what white noise -reads per side on average; any one noise draw reads a little off them. -A mono room is one side and holds. Damping clamps at 0.159 fs, under the -point where the node's one-pole coefficient stops moving, so at 48 kHz -every one of its 128 positions is a room of its own, while at 22.05 kHz -the positions from 92 up (the 6 kHz default among them) are one 3 506 Hz -room. +than it went in: a 220 / 277 / 330 Hz chord (the three sines summed, at +an 8 000 LSB peak) +3.83 dB at the default 6 kHz Damping and +13.40 dB at +the 500 Hz stop, at 48 kHz. White-spectrum material comes back at its own +level, within 0.5 dB, at every setting measured, and that holds on each +side of a stereo room on its own, not only for the two together: each side is +normalised on its own, so the room sits in the middle. On the room's own +impulse the two sides read within 0.001 dB of each other at every setting +walked (the widest found, L - R +0.0005 dB at 44.1 kHz, Decay 66, +Damping 18, Predelay 34 and Diffusion 62 of 127, Room seed 48); what is +left is the rounding of the impulse to int16. So material on one side only comes back +at its own level too: at 48 kHz with Decay 0, Damping 500 Hz and +Diffusion 0, white noise hard left comes back at -0.06 dB and hard right +at +0.15 dB at Room seed 36, and at +0.02 and -0.21 dB at the default +Room, seed 1 (the kit's uniform noise, seed 12345, at -12 dBFS peak; the +tenths of a dB are that draw against that room). A mono room is one side +and holds. Damping clamps at 0.159 fs, under the point where the node's +one-pole coefficient stops moving, so at 48 kHz every one of its 128 +positions is a room of its own, while at 22.05 kHz the positions from 92 +up (the 6 kHz default among them) are one 3 506 Hz room. A single Room's decay with Damping in is not held to the Decay law, and how far one can read off it is not known: no walk covers every setting. @@ -143,9 +135,9 @@ Predelay 127, Diffusion 28, seed 43), +22.83 % at 48 kHz (Decay 0, Damping 500 Hz, Predelay 0, Diffusion 10, seed 43) and +21.00 % at 22.05 kHz (Decay 0, Damping 500 Hz, Predelay 0, Diffusion 46, seed 61). -On a stereo room it is at least about 16.5 %: +16.20 % at 48 kHz -(Decay 16, Damping 500 Hz, Predelay 127, Diffusion 32, seed 43) and -+16.47 % at 22.05 kHz with a -20 dBFS click (Decay 127, Damping 500 Hz, +On a stereo room it is at least about 16.5 %: +16.56 % at 48 kHz +(Decay 8, Damping 500 Hz, Predelay 0, Diffusion 32, seed 43) and ++16.05 % at 22.05 kHz with a -20 dBFS click (Decay 127, Damping 500 Hz, Predelay 0, Diffusion 0, seed 27). The 64 Rooms' mean holds within 2 %. **Measured mode.** The impulse is trimmed by `start_ms` @@ -161,9 +153,12 @@ ruling, 2026-09-08). An impulse is one-dimensional: a 2-D array (numpy's `(frames, channels)`) raises `TypeError`, so flatten it first. -An empty impulse (`impulse=b""`) reports no taps and no latency, but its -node is built with one partition: measured mode's allocation starts at -one frame, and zero frames is that one partition. +An empty impulse (`impulse=b""`) reports no taps and no latency: it is an +undelayed wire, and its Mix does nothing. Its node is built with one +partition: measured mode's allocation starts at one frame, and zero frames +is that one partition. The class loads a measured impulse once, at +construction. Loading another into its node mid-stream (`node.load()`) +empties the room, because the node's `load()` resets. **Two readbacks that are not what they look like.** A `damping_hz` between 0 and 500 Hz is taken as 500 Hz, the span's bottom, with no @@ -333,14 +328,11 @@ class ConvolutionReverb(_component.Component): in. audiodsp tier; 256 frames of latency whenever an impulse is loaded. **What the default surrenders:** a dark room lifts low material (a low - chord +3.73 dB at the default Damping, +13.28 dB at 500 Hz), - Diffusion, Room, Damping, Decay and Predelay move a stereo room's - left-right balance by at least about 5.3 dB at 48 and 44.1 kHz - (4.7 dB at 22.05 kHz; the widest setting is not known) while the - total holds, moving a room knob - or calling `reset()` mid-stream drops the 256 frames in flight, dry - included, at every Mix, and anything longer than 0.091 s on - an S3 or 0.219 s on a P4 is a desktop room (pending hardware). + chord +3.83 dB at the default Damping, +13.40 dB at 500 Hz, 48 kHz), + calling `reset()` mid-stream drops the 256 frames in flight, dry + included, at every Mix, and anything longer than 0.091 s on an S3 or + 0.219 s on a P4 is a desktop room (pending hardware). A room-knob move + drops nothing: the room changes over the block in flight. """ NAME = 'ConvolutionReverb' diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 8b2f1d9..813cf61 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -47,6 +47,17 @@ with its seed 36 changed to 1); and a `set_macro` leg for D5's Predelay control (`PredelayMidiSquared`, which passed the constructor-only test). `D6Balance` now wants the class summary's floor with its rates. + +The re-audit at audiodsp v0.6.3rc2 moved the class to the fixed +convolution node (audiodsp#165), and five tests that pinned a defect or a +figure of the old node went red there. Each is restated to assert the good +behaviour and shown red on a planted copy of the old one: the two room-move +tests on `ResetOnMove` (the node cleared after every re-synthesis, which +renders the v0.6.3rc1 node's bytes); `D6Balance` and `D6OneSided`, which +now pin each side's level (D6's clause 4, dossier section 3.6), on +`SideTilt`, `SideNudge` and the v0.6.2 words; and `D5SingleRoom` on the +v0.6.2 stereo figures. The reset test now covers the plain defaults too, +where `NoReset` was inert while the node emptied itself on a re-synthesis. """ import os @@ -503,6 +514,33 @@ def _fault(self, node): return _LeftGain(node, self.GAIN_DB) +class SideNudge(SideTilt): + """D6 (4)'s fine control: the left side 0.05 dB hot after the node, ten + times narrower than SideTilt and five times the 0.01 dB bar (re-audit + fix round 1, audiodsp v0.6.3rc2).""" + + NAME = NAME + GAIN_DB = 0.05 + + +class ResetOnMove(ConvolutionReverb): + """The v0.6.3rc1 node's room move, planted from the class side + (re-audit fix round 1, audiodsp#163): the node cleared after every + re-synthesis on a playing node, so the tail stops dead and the 256 + frames in flight come out as exact zero, dry included, at every Mix. + `clear()` drops the history and the block in flight, which is what the + old node's `synthesize()` ended in; the pack shows this plant renders + the old node's bytes.""" + + NAME = NAME + + def _refresh(self): + before = self._loaded + ConvolutionReverb._refresh(self) + if before is not None and self._loaded != before: + self._node.clear() + + class AllocatedSeconds(ConvolutionReverb): """D5's control for a law that shares the class's inputs: the class keeps the allocation it holds (partitions x 256 / fs) as `seconds`, so @@ -872,33 +910,109 @@ def test_construction_patch_and_moves(self): self.assertEqual(effect.patch_index, 0) effect.deinit() - def test_a_room_move_starts_the_room_empty_and_a_mix_move_does_not(self): - # The docstring's claim, measured: the node empties its history on - # a re-synthesis (audiodsp_convolve.c:254). - burst = np.vstack([white(2400), silence(20000)]) - clean = build(mix=2.0) - ref = run(clean, burst) - for move, expect_cut in (((DECAY_I, 100), True), - ((MIX_I, 126), False)): - effect = build(mix=2.0) - channels = effect.channel_count - effect._source.swap(probes.ArraySource(burst, rate=RATE, - channels=channels)) - audiocore.reset_buffer(effect.node) - out = bytearray() - for block in range(40): - if block == 10: - effect.set_macro(*move) - out += bytes(audiocore.get_buffer(effect.output)[1]) - out = np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, 2) - after = out[10 * 256:] - if expect_cut: - self.assertEqual(int(np.max(np.abs(after))), 0) - self.assertGreater(int(np.max(np.abs(ref[2560:10240]))), 1000) - else: - self.assertGreater(int(np.max(np.abs(after))), 1000) - effect.deinit() - clean.deinit() + def pull(self, effect, pcm, action=None, block=256, blocks=40): + """`blocks` output pulls over `pcm` from a source handing `block` + frames a call; `action(effect)` runs before pull 10.""" + channels = effect.channel_count + effect._source.swap(probes.ArraySource( + pcm, rate=effect.sample_rate, channels=channels, block=block)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for number in range(blocks): + if number == 10 and action is not None: + action(effect) + out += bytes(audiocore.get_buffer(effect.output)[1]) + return np.frombuffer(bytes(out), dtype=np.int16).reshape( + -1, channels) + + def test_a_room_move_keeps_the_room_ringing_and_lands_on_the_new_room( + self, cls=None): + # Restated at audiodsp v0.6.3rc2 (audiodsp#163; re-audit fix round + # 1). Up to v0.6.3rc1 the node emptied itself on a re-synthesis, so + # a tail ringing at a room-knob move stopped dead; this test pinned + # that (13 561 LSB where it asserted 0 at the fix). Now: before the + # block in flight the output is the old room's; the block in flight + # (frames 2 560..2 815 for a move before pull 10) runs in a straight + # line from the old room's frames to the new room's, within 1 LSB; + # from the next block on it is, byte for byte, an instance that had + # the new room from the start; and the tail rings on across the + # move. ResetOnMove (the old node's behaviour, planted) is red. + moves = ((DECAY_I, 64), (DAMPING_I, 30), (PREDELAY_I, 40), + (DIFFUSION_I, 100), (ROOM_I, 50)) + k = (np.arange(1, 257, dtype=np.float64) / 256.0)[:, None] + for rate in RATES: + for channels in (2, 1): + burst = np.vstack([white(8 * 256 + 37, channels, + peak_dbfs=-12.0), + silence(32 * 256, channels)]) + for mix in (2.0, 0.6): + old = self.pull(build(cls, rate, channels, mix=mix), + burst) + for index, value in moves: + fresh = build(cls, rate, channels, mix=mix) + fresh.set_macro(index, value) + new = self.pull(fresh, burst) + fresh.deinit() + effect = build(cls, rate, channels, mix=mix) + out = self.pull(effect, burst, + lambda e: e.set_macro(index, value)) + effect.deinit() + label = (rate, channels, mix, index) + self.assertEqual(digest(out[:2560]), + digest(old[:2560]), label) + self.assertEqual(digest(out[2816:]), + digest(new[2816:]), label) + line = old[2560:2816] + k * ( + new[2560:2816].astype(np.float64) + - old[2560:2816]) + # The node fades in float and clips after, so a + # sample at full scale in either room is off the + # line by design; at -12 dBFS none is. + self.assertLess(int(max(np.max(np.abs(old)), + np.max(np.abs(new)))), + 32767, label) + self.assertLessEqual( + float(np.max(np.abs(out[2560:2816] - line))), + 1.0, label) + self.assertGreater( + int(np.max(np.abs(out[2560:4096]))), 1000, label) + + def test_a_room_move_drops_no_dry_frame(self, cls=None): + # Restated at audiodsp v0.6.3rc2 (audiodsp#163; re-audit fix round + # 1). Up to v0.6.3rc1 the reset at the end of the node's + # synthesize() zeroed the frames in flight too, so at Mix 0 the + # source frames 2 304..2 559 came out as exact zero at a move + # before pull 10, and this test pinned that gap. Now Mix 0 is the + # source delayed by `latency_samples`, byte for byte, across every + # room knob's move, at three rates, stereo and mono, from a source + # in 256-frame blocks and from one in 100-frame blocks; so is a + # move onto the room already loaded, and a Mix move that stays at + # 0. ResetOnMove is red. + frames = 40 * 256 + moves = ((DECAY_I, 64), (DAMPING_I, 30), (PREDELAY_I, 40), + (DIFFUSION_I, 100), (ROOM_I, 50), (DECAY_I, None), + (MIX_I, 0)) + for rate in RATES: + for channels in (2, 1): + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( + np.int16) + pcm = np.repeat(pcm[:, None], channels, axis=1) + want = np.vstack([silence(LATENCY, channels), + pcm[:frames - LATENCY]]) + self.assertTrue(np.any(pcm[2304:2560] != 0)) + for block in (256, 100): + for index, value in moves: + effect = build(cls, rate, channels, mix=0.0) + out = self.pull( + effect, pcm, + lambda e: e.set_macro(index, e.get_macro(index) + if value is None + else value), + block=block) + effect.deinit() + self.assertEqual( + digest(out), digest(want), + (rate, channels, block, index, value)) def test_a_mix_move_lands_a_partition_late_and_reset_drops_one(self): # The docstring's mid-stream lines (fix round 1, audit item 7): a @@ -937,61 +1051,6 @@ def reset_then_wire(e): self.assertEqual(int(np.max(np.abs(out[2560:2816]))), 0) self.assertEqual(digest(out[2816:]), digest(wire[2816:])) - def test_a_room_move_drops_the_partition_in_flight_dry_included(self): - # The docstring's other half, measured: the reset at the end of - # synthesize() (audiodsp_convolve.c:254) zeroes the pending and - # output blocks too, so at Mix 0 the source frames in flight at the - # move (2304..2559 for a move before block 10) come out as exact - # zero, and every other frame is the source 256 late. A move onto - # the room already loaded, and a Mix move that stays at 0, drop - # nothing (review probe convolutionreverb_review_movedrop.py). - frames = 40 * 256 - moves = ((DECAY_I, 64), (DAMPING_I, 30), (PREDELAY_I, 40), - (DIFFUSION_I, 100), (ROOM_I, 50), (DECAY_I, None), - (MIX_I, 0)) - for rate in RATES: - for channels in (2, 1): - pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( - np.int16) - pcm = np.repeat(pcm[:, None], channels, axis=1) - for index, value in moves: - effect = build(rate=rate, channels=channels, mix=0.0) - effect._source.swap(probes.ArraySource( - pcm, rate=rate, channels=channels)) - audiocore.reset_buffer(effect.node) - out = bytearray() - for block in range(40): - if block == 10: - effect.set_macro(index, effect.get_macro(index) - if value is None else value) - out += bytes(audiocore.get_buffer(effect.output)[1]) - out = np.frombuffer(bytes(out), dtype=np.int16).reshape( - -1, channels) - want = np.vstack([silence(LATENCY, channels), - pcm[:frames - LATENCY]]) - cut = index != MIX_I and value is not None - if cut: - want[2560:2816] = 0 - self.assertEqual(digest(out), digest(want), - (rate, channels, index, value)) - self.assertEqual(bool(np.any(pcm[2304:2560] != 0)), True) - effect.deinit() - # At the constructor's Mix 0.6 the same partition reads exact zero. - effect = build(channels=2) - pcm = white(frames) - effect._source.swap(probes.ArraySource(pcm, rate=RATE, channels=2)) - audiocore.reset_buffer(effect.node) - out = bytearray() - for block in range(40): - if block == 10: - effect.set_macro(DECAY_I, 64) - out += bytes(audiocore.get_buffer(effect.output)[1]) - out = np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, 2) - self.assertGreater(int(np.max(np.abs(out[2304:2560]))), 1000) - self.assertEqual(int(np.max(np.abs(out[2560:2816]))), 0) - self.assertGreater(int(np.max(np.abs(out[2816:3072]))), 1000) - effect.deinit() - # -------------------------------------------------------------------------- # D1 - measured mode is exactly convolution, within one output LSB @@ -1588,8 +1647,12 @@ def measure(cls): #: The cell behind each figure, in the sentences' order: (rate, channels, #: click LSB, Decay MIDI, Predelay MIDI, Diffusion MIDI, Room seed), all at -#: Damping 500 Hz and 0.08 s. The walk behind them is the re-audit round-1 -#: audit's (`convolutionreverb_reaudit1_audit.py mono stereo monowalk`). +#: Damping 500 Hz and 0.08 s. The walk behind the mono cells is the +#: re-audit round-1 audit's (`convolutionreverb_reaudit1_audit.py mono +#: monowalk`); the stereo cells are its re-refuter's walk +#: (`convolutionreverb_reaudit1_refute.py single`) re-run on the fixed node +#: at audiodsp v0.6.3rc2, where every stereo room moved (re-audit fix round +#: 1: v0.6.2's +16.20 % cell reads otherwise there). SINGLE_MONO_CELLS = ( (44100, 1, 32767, 0, 0, 32, 43), (44100, 1, 3277, 0, 127, 28, 43), @@ -1597,7 +1660,7 @@ def measure(cls): (22050, 1, 32767, 0, 0, 46, 61), ) SINGLE_STEREO_CELLS = ( - (48000, 2, 32767, 16, 127, 32, 43), + (48000, 2, 32767, 8, 0, 32, 43), (22050, 2, 3277, 127, 0, 0, 27), ) @@ -1680,8 +1743,9 @@ def test_the_documented_single_rooms_are_what_the_room_reads(self): class D6UnitEnergy(unittest.TestCase): """Three clauses since fix round 1: the pooled wet/dry spread <= 0.5 dB, |wet/dry| <= 0.5 dB at every cell (the absolute level, which the null - test already read), and rho < 0.5. Level is both channels pooled, which - is what the node normalises; each side on its own is not claimed.""" + test already read), and rho < 0.5. Level is both channels pooled. Since + audiodsp v0.6.3rc2 each side is normalised on its own, and D6's clause + 4 claims it (re-audit fix round 1): `D6Balance` and `D6OneSided`.""" def spread(self, cls, index, rate=RATE, peak_dbfs=-12.0, grid=GRID): """(spread dB, worst |level| dB, worst rho), walking `index` by @@ -1760,99 +1824,97 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) -#: The sentence in the module docstring that gives the widest per-side -#: balance found, one figure per rate, two decimals (re-audit fix round 1). +#: The module docstring's balance sentence (re-audit fix round 1, audiodsp +#: v0.6.3rc2): the bound over the walk, then the widest setting found. BALANCE_RE = re.compile( - r"widest\s+found\s+on\s+the\s+room's\s+own\s+impulse\s+is\s+L\s+-\s+R\s+" - r"(-\d+\.\d\d)\s+dB\s+at\s+48\s+kHz,\s+(-\d+\.\d\d)\s+dB\s+at\s+44\.1\s+" - r"kHz\s+and\s+(-\d+\.\d\d)\s+dB\s+at\s+22\.05\s+kHz") - -#: The sentence after it: the widest found the other way. -BALANCE_OTHER_RE = re.compile( - r"The\s+other\s+way,\s+the\s+widest\s+found\s+is\s+\+(\d+\.\d\d),\s+" - r"\+(\d+\.\d\d)\s+and\s+\+(\d+\.\d\d)\s+dB") - -#: Where the walk found the widest the other way, at every rate. -WIDEST_OTHER_CELL = (0, 0, 0, 0, 6) - -#: Where the walk behind that sentence found each rate's widest (evidence -#: pack, "Re-audit fix round 1"): (Decay, Damping, Predelay, Diffusion, -#: Room) as MIDI positions, 0.08 s. -WIDEST_BALANCE_CELL = { + r"On\s+the\s+room's\s+own\s+impulse\s+the\s+two\s+sides\s+read\s+within" + r"\s+(\d+\.\d+)\s+dB\s+of\s+each\s+other\s+at\s+every\s+setting\s+walked" + r"\s+\(the\s+widest\s+found,\s+L\s+-\s+R\s+([+-]\d+\.\d{4})\s+dB\s+at" + r"\s+(48|44\.1|22\.05)\s+kHz,\s+Decay\s+(\d+),\s+Damping\s+(\d+)," + r"\s+Predelay\s+(\d+)\s+and\s+Diffusion\s+(\d+)\s+of\s+127,\s+Room" + r"\s+seed\s+(\d+)\)") + +#: D6 (4)'s bar: each side of a stereo room within 0.01 dB of the other on +#: the room's own impulse (dossier section 3.6). +BALANCE_BAR_DB = 0.01 + +#: Where v0.6.2's node leaned widest (re-audit fix round 1 at v0.6.2): +#: (Decay, Damping, Predelay, Diffusion, Room) as MIDI positions, 0.08 s; +#: the fixed node is held to the bar there too, and on a slice through it. +OLD_WIDEST_CELL = { 48000: (0, 0, 0, 12, 70), 44100: (0, 0, 0, 13, 70), 22050: (0, 0, 0, 22, 70), } +OLD_WIDEST_OTHER_CELL = (0, 0, 0, 0, 6) def documented_balance(doc): - """{rate: L - R dB} as the module docstring states it, or None.""" - found = BALANCE_RE.search(doc or "") + """(bound dB, widest L - R dB, rate, (Decay, Damping, Predelay, + Diffusion MIDI), Room seed) as the module docstring states it, or + None.""" + found = BALANCE_RE.search(" ".join((doc or "").split())) if found is None: return None - return dict(zip(RATES, (float(v) for v in found.groups()))) - - -def documented_other_way(doc): - """{rate: L - R dB} the other way, as the module docstring states it, - or None.""" - found = BALANCE_OTHER_RE.search(doc or "") - if found is None: - return None - return dict(zip(RATES, (float(v) for v in found.groups()))) + g = found.groups() + rate = {"48": 48000, "44.1": 44100, "22.05": 22050}[g[2]] + return (float(g[0]), float(g[1]), rate, + tuple(int(v) for v in g[3:7]), int(g[7])) -def build_at_cell(rate, cell): - effect = build(rate=rate) +def build_at_cell(rate, cell, cls=None): + effect = build(cls, rate=rate) for index, midi in zip((DECAY_I, DAMPING_I, PREDELAY_I, DIFFUSION_I, ROOM_I), cell): effect.set_macro(index, midi) return effect +def room_midi(seed): + """The Room MIDI position whose seed is `seed` (1 + round(m/127*63)).""" + for midi in range(128): + if 1 + int(round(midi / 127.0 * 63)) == seed: + return midi + raise ValueError(seed) + + class D6Balance(unittest.TestCase): - """D6's Not claimed line, each side on its own: the node scales a - stereo room by the mean of its sides' energies, so the left-right - balance moves. Nothing is claimed about it, but the docstring tells a - player how far it was found to move, and twice that figure was too - small (gate audits rounds 2 and 3). These tests pin the figure the - docstring prints to the room at the cell the walk named, and check no - cell of a slice through it (Diffusion's every position at that - setting, and the 64 Rooms at that Diffusion) reads wider. They do not - make the figure a bound: the walk is a floor on the swing - (re-audit fix round 1).""" + """D6 (4), each side on its own (re-audit fix round 1, audiodsp + v0.6.3rc2). Up to v0.6.3rc1 the node scaled a stereo room by the mean + of its two sides' energies, so the left-right balance moved by at + least 5.3 dB, and these tests pinned that disclosure. Since audiodsp#164 + each side is unit energy on its own. They now pin the docstring's + bound and its widest setting to the room, hold v0.6.2's widest cells + and a slice through them to the bar, and show a side tilted after the + node red and out of reach of the surface (SideTilt, 0.5 dB; SideNudge, + 0.05 dB).""" def test_the_documented_balance_is_what_the_room_reads(self): documented = documented_balance(rebuilt.__doc__) - self.assertIsNotNone(documented, "no widest-balance sentence") - for rate in RATES: - effect = build_at_cell(rate, WIDEST_BALANCE_CELL[rate]) - side, pooled = balance(effect) - effect.deinit() - self.assertLessEqual(abs(side - documented[rate]), 0.005, - (rate, side)) - self.assertLessEqual(abs(pooled), 0.01, rate) - other = documented_other_way(rebuilt.__doc__) - self.assertIsNotNone(other, "no widest-the-other-way sentence") + self.assertIsNotNone(documented, "no balance sentence") + bound, widest, rate, cell, seed = documented + self.assertLessEqual(bound, BALANCE_BAR_DB) + self.assertLessEqual(abs(widest), bound) + effect = build_at_cell(rate, cell + (room_midi(seed),)) + side, pooled = balance(effect) + effect.deinit() + self.assertLessEqual(abs(side - widest), 0.00006, (rate, side)) + self.assertLessEqual(abs(pooled), 0.01, rate) for rate in RATES: - effect = build_at_cell(rate, WIDEST_OTHER_CELL) - side, _ = balance(effect) - effect.deinit() - self.assertLessEqual(abs(side - other[rate]), 0.005, - (rate, side)) - # The class's own summary gives the floor too, with its rates (the - # re-audit fix round 2: the round-1 sentence named none). - self.assertIn("at least about %.1f dB at 48 and 44.1 kHz (%.1f dB at " - "22.05 kHz" % (abs(documented[48000]), - abs(documented[22050])), - " ".join(ConvolutionReverb.__doc__.split())) - - def test_no_cell_of_the_slice_is_wider_than_documented(self): + for cell in (OLD_WIDEST_CELL[rate], OLD_WIDEST_OTHER_CELL): + effect = build_at_cell(rate, cell) + side, pooled = balance(effect) + effect.deinit() + self.assertLessEqual(abs(side), bound, (rate, cell, side)) + self.assertLessEqual(abs(pooled), 0.01, (rate, cell)) + + def test_no_cell_of_the_slice_is_past_the_bound(self): documented = documented_balance(rebuilt.__doc__) - self.assertIsNotNone(documented, "no widest-balance sentence") + self.assertIsNotNone(documented, "no balance sentence") + bound = documented[0] for rate in RATES: - diffusion = WIDEST_BALANCE_CELL[rate][3] - effect = build_at_cell(rate, WIDEST_BALANCE_CELL[rate]) + diffusion = OLD_WIDEST_CELL[rate][3] + effect = build_at_cell(rate, OLD_WIDEST_CELL[rate]) readings = [] for position in range(128): effect.set_macro(DIFFUSION_I, position) @@ -1862,35 +1924,43 @@ def test_no_cell_of_the_slice_is_wider_than_documented(self): effect.set_macro(ROOM_I, position) readings.append(balance(effect)[0]) effect.deinit() - self.assertGreaterEqual(min(readings), documented[rate] - 0.005, - rate) + self.assertLessEqual(max(abs(v) for v in readings), bound, rate) + + def test_a_tilted_side_is_red_and_not_on_the_surface(self): + for plant, gain in ((SideTilt, 0.5), (SideNudge, 0.05)): + for rate in RATES: + effect = build(plant, rate) + side, _ = balance(effect) + effect.deinit() + self.assertGreater(abs(side), BALANCE_BAR_DB, (plant, rate)) + self.assertAlmostEqual(side, gain, delta=0.002) + result = reach(SideNudge, lambda e: balance(e)[0], tolerance=0.02) + self.assertAlmostEqual(result["target"], 0.05, delta=0.002) + self.assertLessEqual(abs(result["clean"]), BALANCE_BAR_DB) + self.assertEqual(result["checked"], WALKED) -#: The docstring's one-sided example (re-audit fix round 2): the Room of -#: each reading is named, since the sign turns with it. +#: The docstring's one-sided example (re-audit fix round 1, audiodsp +#: v0.6.3rc2): white noise on one side only, at two Rooms, each figure to +#: the hundredth of a dB. ONE_SIDED_RE = re.compile( - r"At\s+48\s+kHz\s+with\s+Decay\s+0,\s+Damping\s+500\s+Hz\s+and\s+" - r"Diffusion\s+0,\s+white\s+noise\s+hard\s+left\s+comes\s+back\s+" - r"(\d+\.\d)\s+dB\s+down\s+and\s+hard\s+right\s+(\d+\.\d)\s+dB\s+up\s+at\s+" - r"Room\s+seed\s+(\d+),\s+and\s+at\s+the\s+default\s+Room,\s+seed\s+(\d+)," - r"\s+hard\s+left\s+comes\s+back\s+(\d+\.\d)\s+dB\s+up\s+and\s+hard\s+" - r"right\s+(\d+\.\d)\s+dB\s+down\s+\(at\s+seed\s+(\d+),\s+Decay\s+1\.0,\s+" - r"Diffusion\s+0\.5\s+and\s+Damping\s+500\s+Hz,\s+about\s+(\d+\.\d)\s+dB\s+" - r"down\s+and\s+(\d+\.\d)\s+dB\s+up\)") + r"at\s+48\s+kHz\s+with\s+Decay\s+0,\s+Damping\s+500\s+Hz\s+and\s+" + r"Diffusion\s+0,\s+white\s+noise\s+hard\s+left\s+comes\s+back\s+at\s+" + r"([+-]\d+\.\d\d)\s+dB\s+and\s+hard\s+right\s+at\s+([+-]\d+\.\d\d)\s+dB" + r"\s+at\s+Room\s+seed\s+(\d+),\s+and\s+at\s+([+-]\d+\.\d\d)\s+and\s+" + r"([+-]\d+\.\d\d)\s+dB\s+at\s+the\s+default\s+Room,\s+seed\s+(\d+)") def documented_one_sided(doc): - """[(options, left dB, right dB)] as the docstring states them, signs - applied, or None.""" + """[(options, left dB, right dB)] as the docstring states them, or + None.""" found = ONE_SIDED_RE.search(" ".join((doc or "").split())) if found is None: return None g = found.groups() corner = dict(decay=0.0, damping_hz=500.0, diffusion=0.0) - return [(dict(corner, room=int(g[2])), -float(g[0]), float(g[1])), - (dict(corner, room=int(g[3])), float(g[4]), -float(g[5])), - (dict(decay=1.0, damping_hz=500.0, diffusion=0.5, - room=int(g[6])), -float(g[7]), float(g[8]))] + return [(dict(corner, room=int(g[2])), float(g[0]), float(g[1])), + (dict(corner, room=int(g[5])), float(g[3]), float(g[4]))] def one_sided_level(side, cls=None, rate=RATE, **options): @@ -1909,11 +1979,12 @@ def one_sided_level(side, cls=None, rate=RATE, **options): class D6OneSided(unittest.TestCase): - """The docstring's one-sided example, read at the Rooms it names: the - sign of each side turns with the Room (seed 36 and the default seed 1 - read the other way round), so a sentence that named no Room was false - at the default one (re-audit round-1 audit). Each printed figure is - held to its printed tenth.""" + """The docstring's one-sided example, read at the Rooms it names (re- + audit fix round 1, audiodsp v0.6.3rc2). Up to v0.6.3rc1 white noise on + one side came back up to 2.7 dB off its level, with a sign that turned + with the Room; since each side is normalised on its own it comes back + within a few tenths. Each printed figure is held to its printed + hundredth, and each within D6's 0.5 dB.""" def test_the_documented_one_sided_example_is_what_the_room_reads(self): cells = documented_one_sided(rebuilt.__doc__) @@ -1922,8 +1993,9 @@ def test_the_documented_one_sided_example_is_what_the_room_reads(self): for options, left, right in cells: for side, printed in ((0, left), (1, right)): level = one_sided_level(side, **options) - self.assertLessEqual(abs(level - printed), 0.051, + self.assertLessEqual(abs(level - printed), 0.006, (options, side, level, printed)) + self.assertLessEqual(abs(level), 0.5, (options, side)) # -------------------------------------------------------------------------- @@ -1941,33 +2013,35 @@ def test_silence_stays_silence(self): effect.deinit() def test_reset_empties_the_room(self): - # Built at patch 0, so reset's program_change(0) finds the room it - # holds and does not re-synthesize: only the reset clears it. From - # the plain defaults NoReset would be inert: their exact Damping - # 6 000 Hz and Mix 0.6 are not patch 0's grid values (6 059.8 Hz, - # 0.598), so program_change(0) re-synthesizes, and the node empties - # itself on a re-synthesis (audit round 1: peak 0 after reset() for - # clean and planted from the defaults, 16 666 LSB planted from - # patch=0). - for cls, silent in ((ConvolutionReverb, True), (NoReset, False)): - effect = build(cls, patch=0) - burst = np.vstack([white(1024), silence(8192)]) - effect._source.swap(probes.ArraySource(burst, rate=RATE, - channels=2)) - audiocore.reset_buffer(effect.node) - for _ in range(6): - audiocore.get_buffer(effect.output) - effect.reset() - out = bytearray() - for _ in range(8): - out += bytes(audiocore.get_buffer(effect.output)[1]) - peak = int(np.max(np.abs(np.frombuffer(bytes(out), - dtype=np.int16)))) - if silent: - self.assertEqual(peak, 0) - else: - self.assertGreater(peak, 0) - effect.deinit() + # Built at patch 0, reset's program_change(0) finds the room it + # holds and does not re-synthesize, so only the reset clears it. + # Built from the plain defaults, whose exact Damping 6 000 Hz and + # Mix 0.6 are not patch 0's grid values (6 059.8 Hz, 0.598), it + # re-synthesizes. Up to audiodsp v0.6.3rc1 the node emptied itself + # on that re-synthesis and NoReset was inert there (audit round 1: + # peak 0 clean and planted); since v0.6.3rc2 (#163) a re-synthesis + # keeps the history, so NoReset is red from both (re-audit fix + # round 1). + for options in (dict(patch=0), {}): + for cls, silent in ((ConvolutionReverb, True), (NoReset, False)): + effect = build(cls, **options) + burst = np.vstack([white(1024), silence(8192)]) + effect._source.swap(probes.ArraySource(burst, rate=RATE, + channels=2)) + audiocore.reset_buffer(effect.node) + for _ in range(6): + audiocore.get_buffer(effect.output) + effect.reset() + out = bytearray() + for _ in range(8): + out += bytes(audiocore.get_buffer(effect.output)[1]) + peak = int(np.max(np.abs(np.frombuffer(bytes(out), + dtype=np.int16)))) + if silent: + self.assertEqual(peak, 0, options) + else: + self.assertGreater(peak, 0, options) + effect.deinit() def test_deinit_leaves_the_source(self): pcm = white(2048) From 26017d2b20073fa1beffe39e518061fd15cc3c3a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:06:06 -0500 Subject: [PATCH 60/88] MultiTapDelay re-audit fix round 2: the words for a build without get_buffer, and a plant for the tail reading Restated, not fixed: on a build without audiocore.get_buffer, a class built above Mix 0 holds the source's first block in the dry until the first pull. Taken to Mix 0 before it, the Mix-0 run plays 256 frames early without that block, which plays with its heads at the return or reset(), as late as the run was long; a reset or a return from Mix 0 lets tap 1's block into the lines with its heads late by any Mix-0 run between. The docstring, _route's docstring and the CHANGELOG line say so. No audio code changed. ReauditRoundTwo runs ROUTES_MODULE's new gb / nogb parts (a stand-in audiocore without get_buffer, so it runs natively too): seven routes, every lane against the words, 0 of 21 cells off with and without get_buffer on CPython, micropython-063rc1 and circuitpython-063rc1. Plants: OldWiring red in 9 cells with get_buffer, QuietAlways (zeros primed on every build) red in 18 without; the class file planted the same way fails the CPython test. NoTapLap, a tail bound one lap short, is over at full-scale DC, Feedback MIDI 96, Tone 126 (92 169 > 89 776 at 48 kHz stereo, 42 343 > 41 447 at 22.05 kHz mono) where the clean class is inside (92 656, 42 770). Whole suite: 1900 tests OK (skipped=3). flake8, validate_api, validate_metadata clean; digests agree 48 of 48. --- CHANGELOG.md | 7 +- lib/audioeffects/rebuilt/multitapdelay.py | 37 ++- tests/test_cpython_effects_multitapdelay.py | 287 +++++++++++++++++++- 3 files changed, 319 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 551554f..cc7baf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,7 +91,12 @@ there, and are recorded in its changelog. in a width-1 MidSide, so a host resetting the output leaves the lines as they are. On a build without `audiocore.get_buffer` (a patched CircuitPython board may leave it out), a class built at Mix 0 and reset - before it was ever turned up plays its source 256 frames late. The + before it was ever turned up plays its source 256 frames late; a class + built above Mix 0 and taken to Mix 0 before the first pull plays the + Mix-0 run 256 frames early without the source's first block, then plays + that block, with its heads, when Mix comes back up or `reset()` is + called; and a reset or a return from Mix 0 lets the block the tap node + had not read into the lines, its heads late by any Mix-0 run between. The constructor refuses a sample rate below 12 825 Hz with a `ValueError`, where the one-block lag could not place the 20 ms head. At audiodsp v0.6.3rc1 the Feedback is handed to the lap node as set (no stepping diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index 14256a7..bbacd18 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -134,10 +134,20 @@ class accepts.** The dry is a Splitter tap into a Mixer voice, a wire, That rests on `audiocore.get_buffer`, which every desktop build and the MicroPython boards carry and a patched CircuitPython board build may not. -Without it a class that wires its graph inside `reset()` (one built at -Mix 0 and never turned up) opens with one silent 256-frame block and -plays your source 256 frames late from then on, and a reset or a return -from Mix 0 lets the one block the tap node had not read into the lines. +Without it the class cannot pull its own graph, so a class built above +Mix 0 holds your source's first block in the dry until the first pull, and +three things differ. If you take it to Mix 0 before that pull, the Mix-0 +run plays your source from its second block, 256 frames early, without the +first; when Mix comes back up, or you call `reset()`, that first block +plays then, with its heads after it, as late as the run was long, and +everything after it is on time (after a 24-block run at 48 kHz, a click in +the first block sounds 6 144 frames late). A class built at Mix 0 and reset +before it was ever turned up opens with one silent 256-frame block and +plays your source 256 frames late from then on. And a reset or a return +from Mix 0 lets the one block the tap node had not read into the lines: +its heads sound, as late as any Mix-0 run between was long. Taking Mix to +0 and back before the first pull, with nothing played between, is on +time. The output ends in an `audioroute.MidSide` at width 1, the identity, whose reset forwards nothing, above Mix 0 and at it: a host that resets the @@ -854,11 +864,20 @@ def _route(self): refilled the tap's own buffer the voice was pointing into, so the dry's first 256 samples played as zeros. Inside `reset()` the wiring is quiet on every build, because a reset must take nothing - from the source. Where `get_buffer` is left out, a wiring outside - `reset()` primes the source (and plays it on time, since `_resync` - cannot pull there either), a wiring inside `reset()` opens the - output with that one silent block, and a reset or a return from - Mix 0 lets tap 1's pending block into the lines. + from the source. + + Where `get_buffer` is left out, a wiring outside `reset()` primes + the source's first block into the dry voice, and nothing here can + pull it out. The first pull plays it on time. A Mix-0 route before + that pull hands out the adapter, which has already given that block + away, so the run plays the source one block early without it; the + voice keeps the block, and plays it at the return (or the reset) + with tap 1's copy going into the lines, since `_resync` cannot swap + it, so its heads follow it. A wiring inside `reset()` opens the + output with the one silent block it primed, and a reset or a return + from Mix 0 lets tap 1's pending block into the lines, its heads + late by any Mix-0 run between. The module docstring says all of + this in a host's terms. """ if not self._ready: return diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 216dc62..59f53bd 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -32,6 +32,13 @@ class built as a wire. The full spans, the three interpreters and the rates are present and current for `AUDIODSP_PIN`: a mono dry block lost after a reset shows only on a native interpreter, whose mixer voice points into the tap's own buffer where the CPython shim copies. + +Re-audit fix round 2 (2026-09-28) adds `ReauditRoundTwo`: the same script +with `audiocore.get_buffer` withheld from the class (a stand-in module, so +it runs natively too), every route held to what the docstring says such a +build plays, beside a plant that moves the first block; and `NoTapLap`, a +tail bound one lap short, red on the tail reading at a cell where the tail +comes within a lap. """ import math @@ -2333,10 +2340,163 @@ def part_blocks(cls, label): channels, wrong)) +class QuietAlways(M): + '''A wiring that primes zeros on every build, get_buffer or not: where + get_buffer is left out nothing hands the primed zeros out, so the + output opens with a silent block. It moves the first block, which is + what the no-get_buffer words describe.''' + + NAME = 'MultiTapDelay' + + def _quiet_wiring(self): + return True + + +PLANTS["quietalways"] = QuietAlways + + +class NoGetBuffer: + '''audiocore without get_buffer, every other name passed through: what + a patched CircuitPython board build that leaves it out gives the class. + Installed in the two modules that look it up; the pulls below keep the + real one.''' + + def __getattr__(self, name): + if name == "get_buffer": + raise AttributeError(name) + return getattr(audiocore, name) + + +def use_audiocore(module): + from audioeffects import _component + top = __import__("audioeffects.rebuilt.multitapdelay") + _component.audiocore = module + top.rebuilt.multitapdelay.audiocore = module + + +#: Clicks for the no-get_buffer routes: two in the first block, one in +#: block 23 (the block before an event at block 24), two after block 24. +NG_CLICKS = ((10, 20000), (700, 9000), (6000, 8000), (6500, 7000), + (9000, 5000)) +NG_AT = 24 * BLOCK +#: route -> (constructor Mix, {block: [actions]}). +NG_ROUTES = ( + ("constructed", 1.0, {}), + ("Mix 0 first, back at 24", 1.0, {0: ["mix0"], 24: ["back"]}), + ("Mix 0 first, reset at 24", 1.0, {0: ["mix0"], 24: ["reset"]}), + ("Mix 0 and back first", 1.0, {0: ["mix0", "back"]}), + ("built at Mix 0, reset first", 0.0, {0: ["reset"]}), + ("reset at 24", 1.0, {24: ["reset"]}), + ("Mix 0 at 24, back at 48", 1.0, {24: ["mix0"], 48: ["back"]}), +) + + +def ng_act(e, action): + if action == "mix0": + e.set_macro(MIX_I, 0) + elif action == "back": + e.set_macro(MIX_I, 63.5) + else: + e.reset() + e.set_macro(FB_I, 0) + e.set_macro(MIX_I, 63.5) + + +def ng_law(route, pulls, n1a, n1b, frames): + '''What the docstring says each route plays, every lane: `pulls` False + is a build without get_buffer. n1a is the base as built, n1b after the + route's events (a reset restores patch 0's Time).''' + want = {} + + def put(f, v): + if f < frames: + want[f] = want.get(f, 0) + v + + def heads(o, v, n1, stop=frames): + for k in (1, 2, 3): + if o + k * n1 < stop: + put(o + k * n1, v) + + late = not pulls + for s, v in NG_CLICKS: + block = s // BLOCK + if route in ("constructed", "Mix 0 and back first"): + put(s, v) + heads(s, v, n1a) + elif route.startswith("Mix 0 first"): + if not late: + put(s, v) + if s >= NG_AT: + heads(s, v, n1b) + elif block == 0: + # the first block, held in the dry, plays at the return + put(s + NG_AT, v) + heads(s + NG_AT, v, n1b) + elif block <= 24: + # the Mix-0 run: one block early, no heads + put(s - BLOCK, v) + else: + put(s, v) + heads(s, v, n1b) + elif route == "built at Mix 0, reset first": + o = s + BLOCK if late else s + put(o, v) + heads(o, v, n1b) + elif route == "reset at 24": + put(s, v) + if s >= NG_AT or (late and block == 23): + heads(s, v, n1b) + else: + heads(s, v, n1a, NG_AT) + else: # Mix 0 at 24, back at 48 + put(s, v) + if s < NG_AT: + heads(s, v, n1a, NG_AT) + if late and block == 23: + heads(s + NG_AT, v, n1a) + elif s >= 2 * NG_AT: + heads(s, v, n1a) + return want + + +def ng_run(cls, route, mix, plan, rate, channels, pulls): + n1 = (rate * 150 + 500) // 1000 + frames = 2 * NG_AT + 3 * n1 + 2 * BLOCK + values = clicks(channels, frames + 16 * BLOCK, NG_CLICKS) + use_audiocore(audiocore if pulls else NoGetBuffer()) + try: + e = cls(source(values, rate, channels, BLOCK), sample_rate=rate, + feedback=0.0, mix=mix) + n1a = e._n1 + out = array("h") + block = 0 + while len(out) < frames * channels: + for action in plan.get(block, ()): + ng_act(e, action) + out.extend(samples(bytes(audiocore.get_buffer(e.output)[1]))) + block += 1 + n1b = e._n1 + e.deinit() + finally: + use_audiocore(audiocore) + return judge(out, channels, frames, + ng_law(route, pulls, n1a, n1b, frames)) + + +def part_ng(cls, label, pulls): + for route, mix, plan in NG_ROUTES: + for rate, channels in ((48000, 2), (48000, 1), (22050, 1)): + wrong = ng_run(cls, route, mix, plan, rate, channels, pulls) + print("%s|%s|%s|%d|%d|%d" % ("GB" if pulls else "NOGB", label, + route, rate, channels, wrong)) + + def main(args): '''Each argument is `plant,plant=part,part`.''' parts = {"firstdry": part_firstdry, "firstheads": part_firstheads, - "blocks": part_blocks} + "blocks": part_blocks, + "gb": lambda cls, label: part_ng(cls, label, True), + "nogb": lambda cls, label: part_ng(cls, label, False)} for group in args: plants, names = group.split("=") for plant in plants.split(","): @@ -2502,7 +2662,10 @@ def test_a_stall_window_cell_reaches_zero_at_the_feedback_set(self): # this cell for ever there. Since v0.6.3rc1 it is handed as set and # the tail ends inside the bound. At v0.6.2 the same cell holds # past it (the pack's re-audit fix round 1 section, on - # bin/micropython and bin/circuitpython at v0.6.2). + # bin/micropython-062 and bin/circuitpython-062). SteppedLaps below + # is red on the handed Feedback only: this cell's tail is far + # inside the bound, and the tail reading's plant (NoTapLap) is in + # ReauditRoundTwo, at a cell where the tail comes within a lap. for rate, channels in ((RATE, 2), (RATE, 1), (44100, 2), (22050, 2)): handed, knob, declared, tail, held = stall_cell( @@ -2643,5 +2806,125 @@ def test_a_host_reset_at_mix_zero_keeps_the_timeline(self): self.assertEqual(result["checked"], 9 * 17 + 7) +# -------------------------------------------------------------------------- +# Re-audit fix round 2: the words for a build without get_buffer, and a +# plant for the tail reading + + +class NoTapLap(MultiTapDelay): + """A tail bound one lap short: `tail_samples` leaves out the lap the + tap node needs to read its line out (the re-refuter's plant).""" + + NAME = 'MultiTapDelay' + + def _tail_bound(self): + bound = MultiTapDelay._tail_bound(self) + return bound - self._lap if bound else bound + + +def tail_reading(cls, rate, channels, settings, level=32767): + """DC at `level` for max(rate / 2, two laps), then silence: (tail_samples, + frames from the input's end to the last non-zero sample, whether the + last frame rendered is non-zero).""" + probe = cls(Endless(silence(channels=channels), rate, channels), + sample_rate=rate) + for index, midi in settings: + probe.set_macro(index, midi) + hold = max(rate // 2, 2 * probe._lap) + probe.deinit() + effect = cls(Endless(array("h", [level] * (hold * channels)), rate, + channels), sample_rate=rate) + for index, midi in settings: + effect.set_macro(index, midi) + declared = effect.tail_samples + total = hold + declared + 3 * effect._lap + 2 * BLOCK + out = pull(effect, total) + effect.deinit() + nz = np.nonzero(np.any(out != 0, axis=1))[0] + last = int(nz[-1]) if len(nz) else -1 + return declared, max(0, last - hold + 1), last == total - 1 + + +#: The route cells of `ROUTES_MODULE`'s gb / nogb parts. +NG_ROUTE_NAMES = ("constructed", "Mix 0 first, back at 24", + "Mix 0 first, reset at 24", "Mix 0 and back first", + "built at Mix 0, reset first", "reset at 24", + "Mix 0 at 24, back at 48") +NG_CASES = ((48000, 2), (48000, 1), (22050, 1)) +NG_GROUPS = ("clean=gb,nogb", "oldwiring=gb", "quietalways=nogb") + + +class ReauditRoundTwo(unittest.TestCase): + """Re-audit fix round 2 (2026-09-28): the gate audit's re-audit round 1 + items 1 and 2.""" + + def _judge_ng(self, results): + def cells(names): + return sorted((name, rate, channels) for name in names + for rate, channels in NG_CASES) + + # The clean class plays what the docstring says, with get_buffer + # and without it, on every route and in every lane. + for part in ("GB", "NOGB"): + self.assertEqual(len([key for key in results if key[0] == part + and key[1] == "clean"]), 7 * 3, part) + self.assertEqual(red_cells(results, part, "clean"), [], part) + # With get_buffer the law is the source on time: ac2181f's wiring + # breaks it wherever Mix goes to 0 before the first pull. + self.assertEqual(red_cells(results, "GB", "oldwiring"), cells( + ("Mix 0 first, back at 24", "Mix 0 first, reset at 24", + "Mix 0 and back first"))) + # Without it the law is the one the words state (the Mix-0 run one + # block early, the first block at the return with its heads, the + # pending block's heads after a reset or a Mix-0 run): a wiring + # that primes zeros on every build moves the first block, and is + # red wherever the class was built above Mix 0. + self.assertEqual(red_cells(results, "NOGB", "quietalways"), cells( + name for name in NG_ROUTE_NAMES + if name != "built at Mix 0, reset first")) + + def test_the_no_get_buffer_words_cpython(self): + self._judge_ng(run_routes(sys.executable, NG_GROUPS)) + + def test_the_no_get_buffer_words_native(self): + found = current_native_interpreters() + if found is None: + self.skipTest("no workspace bin/ above this checkout") + for family, binary in sorted(found.items()): + self.assertIsNotNone(binary, "no %s in the workspace bin/ " + "contains AUDIODSP_PIN's audiodsp" % family) + self._judge_ng(run_routes(binary, NG_GROUPS)) + + def test_quiet_always_is_not_on_the_surface(self): + # Where get_buffer is left out, no macro position or patch makes + # the clean class's wiring outside reset() quiet. + module = routes_module() + module.use_audiocore(module.NoGetBuffer()) + try: + result = reach(module.QuietAlways, lambda e: e._quiet_wiring()) + finally: + module.use_audiocore(audiocore) + self.assertIs(result["target"], True) + self.assertEqual(result["checked"], 9 * 17 + 7) + + def test_the_tail_reading_is_red_on_a_bound_one_lap_short(self): + # The stall cell's tail sits far inside its bound, so the stall + # test's tail half cannot see a bound one lap short. Full-scale DC + # at the shortest lap, Feedback MIDI 96, Repeat Tone MIDI 126, comes + # within one lap of it: inside the bound, and over it on NoTapLap. + settings = ((TIME_I, 0), (FEEDBACK_I, 96), (MIX_I, 127), + (TONE_I, 126)) + for rate, channels in ((RATE, 2), (22050, 1)): + declared, tail, held = tail_reading(MultiTapDelay, rate, + channels, settings) + self.assertFalse(held, (rate, channels)) + self.assertLessEqual(tail, declared, (rate, channels)) + short, planted, held = tail_reading(NoTapLap, rate, channels, + settings) + self.assertFalse(held, (rate, channels)) + self.assertEqual(planted, tail, (rate, channels)) + self.assertGreater(planted, short, (rate, channels)) + + if __name__ == "__main__": unittest.main() From bf12da1a38b28d2ef1fc9eb377d0deef76ab2107 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:06:08 -0500 Subject: [PATCH 61/88] TapeDelay re-audit fix round 2: Spread handed on a 1/4096 grid, so the stereo tail ends Off a short binary grid the node's cross-feed sum can land a float32 step above two equal lanes, and at a Feedback a step or two under 1 - 0.5/k it handed a landed k LSB (9-50) back for ever in stereo. On the grid both products of a whole lane under 4096 LSB are exact, so equal whole lanes sum to themselves however the sum is rounded or fused; with #157's landing that closes it. Spread moves by at most 1/8192; 0, 1 and mono unchanged. New tests: the five stall cells (three by knob, one typed, one that hands back under every summation order) end inside tail_samples in stereo and mono while RawSpreadTape, the class as at a5675d7, holds k at all five; the kit's TAIL over Spread's 128 positions at two Feedbacks, green, with the plant red at {1, 5} and {2, 39}; Spread handed on the grid at every quarter-step position; reset() brings the lean patch's drive back (LeanResetTape read as different). RawSpreadTape unreachable in REACH_WALKS. Docstring: the Tail paragraph, tail_samples and the reset sentence restated. --- lib/audioeffects/rebuilt/tapedelay.py | 51 ++++- tests/test_cpython_effects_tapedelay.py | 245 ++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 4 deletions(-) diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index 8cd7fa2..cd20559 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -84,8 +84,10 @@ stack. And Time stops at 800 ms instead of 1 200 (patch 1's 789 ms still fits, and `get_macro(0)` shows where Time stopped). The darkening, the wobble and the pitch bends are patch 0's. Turning Record Level up again -brings the drive, and its cost, back. The boards are re-measured at the -release. +brings the drive, and its cost, back, and so does `reset()`: like every +component's, it restores patch 0 (Record Level 25 on the grid, a drive of +0.197), so a board that resets a lean instance should call +`program_change(8)` after it. The boards are re-measured at the release. **What the default surrenders.** The darkening follows the tape's loss law only up to a band top: one pole in the loop holds it to 2 dB from 100 Hz @@ -162,6 +164,23 @@ (audiodsp#157), the Feedback you set is the one the node plays, and the bound counts one more lap there: 686 laps at the 0.99 stop. +In stereo the cross-feed could do the same thing. Spread mixes each +channel's loop with the other's, which in exact arithmetic never lands +above the larger of the two, but the node's single-precision sum can come +out a step above two equal lanes. With Spread strictly between its stops +and off a short binary grid (Spread 1/127 on the knob, or 0.1726 typed) +and a Feedback a float32 step or two under 1 - 0.5 / k, that handed k LSB +(9 to 50 in the cells found) back for ever. So Spread reaches the node on +a grid of 4096ths, within 1/8192 of the knob, and 0 and 1 are untouched. +There, two equal whole lanes under 4 096 LSB sum to themselves exactly on +every interpreter, whether or not a board's compiler fuses the sum. The +lanes are whole at rest because the node lands a stalled low-pass on its +tap, so the closure is the grid and that landing together. The bound then +holds at every Feedback and Spread the constructor or a macro can hand, +stereo and mono. After a falling Time move it keeps the Time the head +walked from until a reset, because the class cannot see how far the walk +has got. + `capabilities = ("tempo_sync",)`: with Sync on, the class reads `self._transport()` on every macro move and program change (not per block). With no host transport, or a host whose tempo is not a finite positive @@ -228,6 +247,10 @@ #: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). FEEDBACK_MAX = 0.99 +#: Spread's grid as the node is handed it at two channels: whole 4096ths, +#: so the loop's cross-feed sum of two equal whole lanes is exact. +_SPREAD_GRID = 4096 + #: The line's headroom over `max_time_ms`: one frame for the node's #: `line_frames - 2` clamp (`audiodsp_feedback_delay.c:148-150`) plus the #: wow table's largest excursion, 3.096 ms at the Wow and Flutter stops, @@ -440,6 +463,22 @@ def wow_table(wow_cents, flutter_cents, out, flutter_harmonic=FLUTTER_HARMONIC, return peak +def _spread_on_grid(spread): + """`spread` on the 1/4096 grid the node is handed at two channels + (dossier section 8, re-audit fix round 2; AnalogDelay's law). The node + sends `own * (1 - s) + other * s` round the loop in single precision. + With s off a short binary grid (1/127, or a typed 0.1726) those two + products can add up to one float32 step above two equal lanes, and at a + Feedback a step or two under 1 - 0.5 / k that hands a landed k LSB back + for ever. On the grid both products of a whole lane under 4096 LSB are + exact, so two equal whole lanes sum to themselves however the sum is + rounded or fused. The lanes are whole at rest because the node lands a + stalled loop low-pass on its tap (audiodsp#157): the closure is the grid + and that landing together. The grid moves Spread by at most 1/8192 and + leaves 0 and 1 where they are.""" + return math.floor(spread * _SPREAD_GRID + 0.5) / _SPREAD_GRID + + def _option(value, default): """A constructor option as a float; NaN is the option's default.""" value = float(value) @@ -818,10 +857,13 @@ def _refresh(self): self._wow_key = key # At one channel the node's cross-feed sends the repeat nowhere. + # At two, Spread goes on the 1/4096 grid, where the loop's sum of + # two landed lanes cannot come out above both and hand a value back. if self._channel_count == 1: self._spread = 0.0 else: - self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) + self._spread = _spread_on_grid( + _between(self._value(SPREAD_I), 0.0, 1.0)) self._delay.set( delay_slew=self._slew, delay_ms=node_ms, @@ -842,7 +884,8 @@ def tail_samples(self): the read head may be at, plus the wow table's peak excursion in frames rounded up, plus one frame for the interpolated read, plus the loop low-pass's memory. Finite at every setting the class - reaches.""" + reaches, and in stereo it holds at every Spread because Spread is + handed on its 1/4096 grid (the module docstring's Tail).""" self._check_live() return self._tail_bound() diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index d78501d..0085917 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -11,6 +11,11 @@ fail), and the measurement red on the class built as a wire. The exhaustive grids (every Glide grid position, every Spacing and Time position, three rates for every cell) live in the evidence pack, not in this file. + +Re-audit fix round 2 (2026-09-28) adds Tier 1's cross-feed stall: the five +stall cells and the kit's TAIL over Spread's whole travel, each beside +`RawSpreadTape` (the class before Spread was handed on the 1/4096 grid), +and the reset sentence of the lean patch's paragraph. """ import math @@ -32,6 +37,7 @@ from audioeffects.rebuilt import tapedelay as tape # noqa: E402 from audioeffects.rebuilt.digitaldelay import ( # noqa: E402 clear_of_stalls) +from tools import effect_measurements as kit # noqa: E402 VENDOR = "PyDevices" @@ -399,6 +405,18 @@ class LeanMovesMoreTape(TapeDelay): (89, 58, 22, 89, 32, 32, 0, 60, 0, 0, 51)) +class LeanResetTape(TapeDelay): + """Not the contract: a reset that restores the lean patch instead of + patch 0, so the drive stays off. The docstring says reset() brings it + back; this is the build that sentence would be false on.""" + + NAME = 'TapeDelay' + + def reset(self): + TapeDelay.reset(self) + self.program_change(8) + + class DriveOffTape(TapeDelay): """Not a fault: the cost study's variant D, `loop_drive` handed 0 after every refresh (`tapedelay_cost_variants.py`, `DriveOff`). Built at @@ -427,6 +445,24 @@ def _refresh(self): delay_ms=self._node_ms + self._wow_ms - old) +class RawSpreadTape(TapeDelay): + """Tier 1 TAIL: the class as at a5675d7, Spread handed to the node as + set at two channels, off the 1/4096 grid. With Spread strictly inside + (0, 1) and a Feedback a float32 step or two under 1 - 0.5 / k, the + node's cross-feed sum lands a float32 step above two landed k lanes and + hands k back for ever (re-audit round 1).""" + + NAME = 'TapeDelay' + + def _refresh(self): + TapeDelay._refresh(self) + if self._channel_count == 2: + spread = min(1.0, max(0.0, self._value(SPREAD_I))) + if spread != self._spread: + self._spread = spread + self._delay.set(cross_feed=spread) + + # -- sources and renders -------------------------------------------------- def src_of(x, channels=2, rate=RATE): @@ -912,6 +948,34 @@ def test_the_lean_build_renders_what_the_boards_measured(self): self.assertAlmostEqual(effect.macro(TIME_I), 800.0, places=6) self.assertEqual(effect._frames, frames_of(800.0)) + def _drive_across_reset(self, cls): + """(Record Level MIDI and the drive handed) on the lean patch at the + 800 ms build, after `reset()`, and after `program_change(8)`.""" + readings = [] + with NodeSpy(): + effect = cls(src_of(np.zeros(512)), patch=LEAN, + max_time_ms=800.0) + for step in (None, effect.reset, + lambda: effect.program_change(LEAN)): + if step is not None: + step() + readings.append((effect.get_macro(RECORD_I), + round(effect._delay._handed["loop_drive"], + 5))) + effect.deinit() + return readings + + def test_reset_brings_the_drive_back(self): + # Re-audit fix round 2, the docstring's restated sentence: reset() + # restores patch 0 (the component contract), so the drive and its + # cost come back and a board calls program_change(8) after it. + self.assertEqual(self._drive_across_reset(TapeDelay), + [(0.0, 0.0), (25.0, 0.19685), (0.0, 0.0)]) + # Planted: a reset that restored the lean patch would make the + # sentence false; the reading sees it. + self.assertEqual(self._drive_across_reset(LeanResetTape)[1], + (0.0, 0.0)) + class T1aVarispeed(unittest.TestCase): def test_the_named_moves(self): @@ -2169,6 +2233,116 @@ def test_the_stall_cell_reaches_zero_at_the_feedback_set(self): self.assertNotEqual(stepped._feedback, 0.5) self.assertLess(abs(stepped._feedback - 0.5), 3e-5) + #: Re-audit round 1's cross-feed stall cells, (label, k, the Feedback + #: the node is handed as float32, Spread): "knob" cells set Spread's + #: MIDI position and a fractional Feedback position by `set_macro`, + #: "ctor" cells pass both to the constructor. The first three are the + #: re-refuter's portable cells (k = 9, 11, 50), the fourth a constructor + #: Spread off every grid, and the fifth one of the 13 cells whose + #: hand-back survives every order a compiler may sum the cross-feed in + #: (separate roundings and both fused multiply-adds), so the plant holds + #: there on a board too (tapedelay_reaudit1_audit.mirror.out.txt). + CROSS_FEED_CELLS = ( + ("knob", 9, 0.9444443583488464, 1), + ("knob", 11, 0.9545453786849976, 3), + ("knob", 50, 0.9899999499320984, 2), + ("ctor", 50, 0.9899998903274536, 0.1726040393114090), + ("knob", 15, 0.9666665792465210, 37), + ) + + @staticmethod + def feedback_position(target): + """A fractional Feedback knob position whose value reaches the node + as exactly `target` in float32 (0..0.99, linear).""" + want = np.float32(target) + m = float(want) * 127.0 / 0.99 + for step in range(-400, 401): + cand = m + step * 1e-9 + if np.float32(0.99 * (cand / 127.0)) == want: + return cand + raise ValueError("no Feedback position reaches %r" % target) + + def _cross_feed_stall(self, cls, cell, rate=RATE, channels=2): + """Time 20 ms, Mix 2, Record Level 0, patch 8's wobble (Wow 2 c, + Flutter 1 c), the cell's Feedback and Spread: a DC of 2k + 2 LSB for + four laps, then silence for `tail_samples` (read after the knobs + move) plus a lap plus 4 096 frames. Returns (tail_samples, frames + from the input's end to the last non-zero frame, |the last frame|, + the Feedback handed).""" + route, k, feedback, spread = cell + ctor = dict(time_ms=20.0, mix=2.0, record_level=0.0, wow_cents=2.0, + flutter_cents=1.0) + if route == "ctor": + ctor.update(feedback=feedback, spread=spread) + + def build(src): + effect = cls(src, **ctor) + if route == "knob": + effect.set_macro(FEEDBACK_I, self.feedback_position(feedback)) + effect.set_macro(SPREAD_I, spread) + return effect + probe = build(src_of(np.zeros(512), channels, rate)) + declared = probe.tail_samples + lap = probe._frames + handed = probe._feedback + probe.deinit() + lead = 4 * lap + frames = lead + declared + lap + 4096 + x = np.zeros(frames) + x[:lead] = 2 * k + 2 + out = render(build(src_of(x, channels, rate)), frames) + nz = np.nonzero(np.any(out[lead:] != 0, axis=1))[0] + tail = int(nz[-1]) + 1 if len(nz) else 0 + return declared, tail, int(np.max(np.abs(out[-1]))), handed + + def test_the_cross_feed_stall_cells_reach_zero(self): + # Re-audit round 1's Tier 1 failure: with Spread handed as set these + # cells hold k LSB on both lanes for ever. With Spread on the 1/4096 + # grid each ends inside the bound, in stereo and (Spread held at 0) + # in mono. Planted: RawSpreadTape, the class as at a5675d7, holds + # exactly k at every one. + for cell in self.CROSS_FEED_CELLS: + route, k, feedback, spread = cell + for channels in (2, 1): + declared, tail, final, handed = self._cross_feed_stall( + TapeDelay, cell, channels=channels) + self.assertEqual(np.float32(handed), np.float32(feedback), + cell) + self.assertEqual(final, 0, (cell, channels)) + self.assertGreater(tail, 0, (cell, channels)) + self.assertLessEqual(tail, declared, (cell, channels)) + declared, tail, final, handed = self._cross_feed_stall( + RawSpreadTape, cell) + self.assertEqual(final, k, cell) + + def test_spread_is_handed_on_the_grid(self): + # At two channels Spread reaches the node on the 1/4096 grid, within + # 1/8192 of the knob, 0 and 1 untouched; the constructor's too. + for rate in (48000, 44100, 22050): + effect = TapeDelay(src_of(np.zeros(512), 2, rate)) + with NodeSpy(): + for midi in [m / 4.0 for m in range(4 * 127 + 1)]: + effect.set_macro(SPREAD_I, midi) + knob = effect._value(SPREAD_I) + handed = effect._delay._handed["cross_feed"] * 4096.0 + self.assertEqual(handed, math.floor(handed), midi) + self.assertEqual(effect._spread * 4096.0, handed, midi) + self.assertLessEqual(abs(effect._spread - knob), + 1.0 / 8192.0, midi) + effect.set_macro(SPREAD_I, 0) + self.assertEqual(effect._spread, 0.0) + effect.set_macro(SPREAD_I, 127) + self.assertEqual(effect._spread, 1.0) + typed = TapeDelay(src_of(np.zeros(512), 2, rate), + spread=0.1726040393114090) + self.assertEqual(typed._spread, 707.0 / 4096.0) + mono = TapeDelay(src_of(np.zeros(512), 1, rate), + spread=37.0 / 127.0) + self.assertEqual(mono._spread, 0.0) + # Planted: the class as at a5675d7 hands 37/127 as set. + raw = RawSpreadTape(src_of(np.zeros(512)), spread=37.0 / 127.0) + self.assertEqual(raw._spread, 37.0 / 127.0) + def _depth_move(self, cls, start, target, points=8): """(the tone's own largest step before the move, the largest step in the 2 000 frames after it) over `points` moves a quarter of the @@ -2229,6 +2403,66 @@ def test_a_balance_move_steps_as_the_docstring_says(self): (728.0, 1117.0)) +def spread_tail_sweep(cls, feedback, rate=RATE): + """The kit's TAIL through `macro_sweep` over Spread's 128 grid + positions, stereo, Time 20 ms, Mix 2, Record Level 0, no wobble, at + `feedback` (the constructor's, reaching the node as its float32): each + cell a 0.5 FS DC held 0.5 s (`kit_probes.dc_step`), then silence for + `tail_samples` plus a lap plus 4 096 frames. A cell's figure is its tail + over `tail_samples`, infinite when the line never empties. Returns + (the sweep's result, the MIDI positions whose figure is over 1).""" + ctor = dict(feedback=feedback, mix=2.0, time_ms=20.0, record_level=0.0, + wow_cents=0.0, flutter_cents=0.0) + subject = cls(src_of(np.zeros(512), 2, rate), **ctor) + step, held = probes.dc_step(level=0.5, hold_s=0.5, total_s=0.5, + rate=rate, channels=2) + lead = np.frombuffer(step.tobytes(), dtype=np.int16).reshape(-1, 2)[:, 0] + + def measure(settings): + declared = subject.tail_samples + frames = held + declared + subject._frames + 4096 + x = np.zeros(frames) + x[:len(lead)] = lead + effect = cls(src_of(x, 2, rate), **ctor) + effect.set_macro(SPREAD_I, settings[SPREAD_I]) + out = render(effect, frames).astype(np.int16) + effect.deinit() + got = kit.tail(kit.Render(out.tobytes(), rate, 2), + burst_end_frame=held, declared_tail_samples=declared, + settle_frames=4096)["values"] + if not got["returns_to_zero"]: + return float("inf") + return got["tail_samples"] / float(declared) + + result = kit.macro_sweep( + subject, [kit.MacroSpan(SPREAD_I, 0, 127, midpoints=126)], measure, + worst="max", bar=1.0, name="TAIL/SPREAD") + red = sorted(c["settings"][SPREAD_I] for c in result["values"]["cells"] + if not c["figure"] <= 1.0) + subject.deinit() + return result, red + + +class CrossFeedTailSweep(unittest.TestCase): + """Tier 1 TAIL over Spread's whole travel at the re-refuter's two + Feedbacks (re-audit round 1): the class is green at all 128 positions, + and RawSpreadTape, the class as at a5675d7, is red exactly where the + re-refutation found it (MIDI 1 and 5 at 0.9444443583, 2 and 39 at + 0.9899999499).""" + + def test_the_tail_ends_at_every_spread(self): + for feedback, raw_red in ((0.9444443583488464, [1.0, 5.0]), + (0.9899999499320984, [2.0, 39.0])): + result, red = spread_tail_sweep(TapeDelay, feedback) + self.assertEqual(result["values"]["points"], 128) + self.assertEqual(red, [], feedback) + self.assertFalse(result["red"], feedback) + self.assertLess(result["values"]["worst"], 1.0, feedback) + result, red = spread_tail_sweep(RawSpreadTape, feedback) + self.assertEqual(red, raw_red, feedback) + self.assertTrue(result["red"], feedback) + + class InputCeiling(unittest.TestCase): """The docstring's ceiling on the kit's `noise_det`, 20 s at 48 kHz: clean at -1.1 dBFS at the defaults (stereo and mono) and at -2.0 dBFS @@ -2441,6 +2675,13 @@ def read_python_steps(effect): return writes +def read_spread_on_grid(effect): + """Whether the `cross_feed` handed to the node is a whole number of + 4096ths (RawSpreadTape hands 37/127, which is not).""" + handed = float(effect._delay._handed["cross_feed"]) * 4096.0 + return handed == math.floor(handed) + + #: (name, fault, reading, constructor options for both builds). A fault #: whose law is live only away from the defaults is built where it is live #: (DigitalDelay's corner-cell precedent): `GlideScaledVarispeed` is the @@ -2468,6 +2709,10 @@ def read_python_steps(effect): ("FlutterOnWowLineTape", FlutterOnWowLineTape, read_table, {}), ("Harmonic504Tape", Harmonic504Tape, read_table, {}), ("NoDriftTape", NoDriftTape, read_table, {}), + # Re-audit fix round 2: built at an interior Spread off the grid, where + # the plant hands the node something the class never does. + ("RawSpreadTape", RawSpreadTape, read_spread_on_grid, + {"feedback": 0.9666665792, "spread": 37.0 / 127.0}), ) From cf17a88826bba3042105fd63804302fa0685a704 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:09:05 -0500 Subject: [PATCH 62/88] ConvolutionReverb re-audit fix round 2 at audiodsp v0.6.3rc2: the room-move paragraph pinned to the room Words and tests only; the audio is unchanged. The re-audit round-1 audit at v0.6.3rc2 parked the class on the docstring's room-move paragraph. - The straight-line fade is one move's. A new paragraph, "One room move per block", says that a second room move before the next pull starts the block in flight on the first move's room, never played, and names one cell: 44.1 kHz stereo, Damping 0, Mix 2, a 40 Hz sine at 2 000 LSB, Room 50 then Predelay 40 jumps 10 046 LSB into the block against the rooms' own 32. It says what stays exact (dry, frames before and after) and how to avoid it (one room move a block, or a patch change). The class summary and both CHANGELOG lines carry the qualifier. - The step sentence names its material: on low material the fade can step further than either room, 1.97x at 48 kHz on the 40 Hz sine. - The fade covers the unplayed frames: 256 from a host, fewer after a source ran dry mid-block. - The Predelay plants' docstrings carry the figures at this pin (+6.540 / +7.214 %, clean +1.376 / +2.322 %). - Node citations re-pointed at 0d35a90. New tests, RoomMoveWords, each shown red: the two-move cell (red on the 1b3bb94 docstring, on the jump or the rooms' own moved 1 LSB, and on OneSynthesisPerBlock, which gathers the moves into one synthesis, as a node fix would); the step cell (red on the 1b3bb94 docstring and on 1.97 moved to 2.02 or 1.92); a patch change on the straight line (red on PatchPerKnob). Class suite 200 tests OK; full unittest discover 1969 tests OK (skipped 3); flake8, both validators, tier and registry tests, the smoke on CPython and the two rc2 natives; Station C digests 60 of 60 unchanged. --- CHANGELOG.md | 11 +- lib/audioeffects/rebuilt/convolutionreverb.py | 66 ++-- .../test_cpython_effects_convolutionreverb.py | 287 +++++++++++++++++- 3 files changed, 331 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c45f58..23f92ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,7 +107,10 @@ there, and are recorded in its changelog. `audioeffects.ConvolutionReverb` is still the old class. It stands on audiodsp v0.6.3rc2: moving a room knob changes the room over the block in flight, with no frame of the dry dropped or repeated at any Mix and - the tail ringing on into the new room, and each side of a stereo room + the tail ringing on into the new room (one move a block: a second room + move before the next pull starts the block on the first move's room, + never played, and the output can jump there; a patch change is one + synthesis), and each side of a stereo room is unit energy on its own, so the room sits in the middle (the two sides within 0.001 dB on the room's own impulse over the settings walked). The docstring states the low-material lift a dark room gives @@ -119,8 +122,10 @@ there, and are recorded in its changelog. - **The audiodsp pin moves to v0.6.3rc2.** The release adds audiodsp#165 to rc1: `audioconvolve.Convolver` keeps the audio in flight when a room is - re-synthesized (#163: the block on its way out crossfades from the old - room to the new, nothing drops, the tail rings on) and normalises each + re-synthesized (#163: nothing drops, the tail rings on, and after one + room move the block on its way out crossfades from the old room to the + new; after two before the next pull it starts on the first move's room) + and normalises each side of a stereo room on its own (#164: the room no longer leans, and the pair's level holds). Mono rooms and measured impulses render the bytes they did. `ConvolutionReverb`, rebuilt and old, synthesizes diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 7a844bb..77fe8d1 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -89,21 +89,45 @@ node keeps what it holds: no frame of your dry signal drops or repeats, at any Mix (at Mix 0 the output stays the source delayed by `latency_samples`, byte for byte, across the move), and a tail ringing at -that moment rings on into the new room. Over the 256 frames already on -their way out (5.333 ms at 48 kHz) the old room fades into the new one in -a straight line, within 1 LSB (a sample at full scale in either room is -clipped after the fade), and in every move measured the fade stepped no -further from one frame to the next than the larger of the two rooms does -on its own over the same frames; from the next block the output is exactly -that of an instance that always had the new room. A move that lands on the room already loaded does -nothing, and a patch change, the constructor and `reset()` synthesize -once, not once per knob. The synthesis itself runs on the thread that -moves the knob; on a board it can race the audio pump (audiodsp#166, -open), which a desktop cannot show. `reset()` in the middle of a stream is -a reset: it empties the room, and the 256 frames in flight come out as -exact zero, dry included. Mix moves never touch the room, but a Mix move -acts on the audio entering the node after it, so you hear it one partition -later: the 256 frames already in flight come out at the old Mix. +that moment rings on into the new room. The old room fades into the new +one over the frames of the block in flight that have not played yet, in a +straight line, within 1 LSB (a sample at full scale in either room is +clipped after the fade); from the next block the output is exactly that +of an instance that always had the new room. From a host, whose moves land +between pulls, the fade is all 256 frames of the block (5.333 ms at +48 kHz). If the source ran dry part-way through a block and play went on +from another source without a `reset()`, it is only the frames left, down +to one, which is close to a hard switch. A straight line is not smooth, +so on low material the fade can step further from one frame to the next +than either room does on its own, and how much further is not known: at +48 kHz stereo with Damping 0 of 127 (500 Hz) and Mix 2, a 40 Hz sine at +2 000 LSB steps 1.97 times the larger room's own largest step when +Predelay moves from 0 to 127. A move that lands on the room already +loaded does nothing, and a patch change, the constructor and `reset()` +synthesize once, not once per knob. The synthesis itself runs on the +thread that moves the knob; on a board it can race the audio pump +(audiodsp#166, open), which a desktop cannot show. `reset()` in the middle +of a stream is a reset: it empties the room, and the 256 frames in flight +come out as exact zero, dry included. Mix moves never touch the room, but +a Mix move acts on the audio entering the node after it, so you hear it +one partition later: the 256 frames already in flight come out at the old +Mix. + +**One room move per block.** The straight line is one move's. When a +second room move lands before the next pull (a host setting two knobs at +once, or one knob's controller stepping twice inside a block), the node +starts the block in flight on the room of the first move, which never +played, and fades from there to the second, so the output can jump at the +block's first frame: at 44.1 kHz stereo with Damping 0 of 127 (500 Hz) +and Mix 2, a 40 Hz sine at 2 000 LSB with Room moved to 50 and then +Predelay to 40 before one pull jumps 10 046 LSB into the block, where the +two rooms move at most 32 LSB a frame. Your dry is untouched (at Mix 0 the +output stays exact), and the frames before the block and after it are +exact. To avoid it, move one room knob per block, or change patch: a patch +change moves every knob in one synthesis and keeps the straight line. The +class cannot gather the moves for you, because nothing tells it where a +block ends; a node change that fades from the room that played is asked +for. **What the default surrenders.** The room is normalised to unit energy across the whole band, so with Damping in, low material comes back louder @@ -196,8 +220,8 @@ #: from the node's 50 ms floor, would have no travel. FLOOR_SECONDS = 0.06 -#: The node's shortest decay (`audiodsp_convolve.c:177`) and its clamps on -#: predelay and diffusion (`:178`, `:179`), in seconds and milliseconds. +#: The node's shortest decay (`audiodsp_convolve.c:183` at 0d35a90) and its +#: clamps on predelay and diffusion (`:184`, `:185`), in seconds and milliseconds. NODE_DECAY_FLOOR = 0.05 PREDELAY_MAX_MS = 200.0 DIFFUSION_MAX_MS = 500.0 @@ -332,7 +356,9 @@ class ConvolutionReverb(_component.Component): calling `reset()` mid-stream drops the 256 frames in flight, dry included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). A room-knob move - drops nothing: the room changes over the block in flight. + drops nothing: the room changes over the block in flight. Two room + moves before one pull can jump at the block's first frame; move one + room knob a block, or change patch. """ NAME = 'ConvolutionReverb' @@ -406,8 +432,8 @@ def _build(self, decay=1.0, damping_hz=6000.0, predelay=0.0, sample_rate=rate, channel_count=self._channel_count) # `clear()` drops the history and the block in flight and keeps the - # impulse (`Convolver.c:229`): a reset empties the room, it does not - # rebuild it. + # impulse (`Convolver.c:230` at 0d35a90): a reset empties the room, + # it does not rebuild it. self._own(self._node, reset=self._node.clear) self._node.play(self._source) self._output = self._node diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 813cf61..6d289d6 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -58,6 +58,14 @@ `SideTilt`, `SideNudge` and the v0.6.2 words; and `D5SingleRoom` on the v0.6.2 stereo figures. The reset test now covers the plain defaults too, where `NoReset` was inert while the node emptied itself on a re-synthesis. + +The re-audit's fix round 2 at v0.6.3rc2 pins the room-move paragraph to +the room (`RoomMoveWords`): the jump two moves before one pull make at the +cell the docstring names (red on `OneSynthesisPerBlock`, which gathers +them into one synthesis), the step one move takes on a low sine (red on the +figure moved), and a patch change held to the straight line (red on +`PatchPerKnob`, one synthesis a knob). The `1b3bb94` words, which printed +neither figure, are red on the first two. """ import os @@ -599,10 +607,14 @@ def set_macro(self, index, value, channel=0, note_id=-1, class PredelayKeptSquared(ConvolutionReverb): """D5's Predelay control (re-audit fix round 1, from the round-3 audit's item 3): the constructor's Predelay kept squared. It is - Predelay's shape of DecayKeptSquared: at Predelay 64/127, Decay 127, - Damping out, Diffusion 0 it reads worst +6.518 % on the handed law at - 48 kHz (+7.244 % at 22.05 kHz) and +1.276 % on a law read back off the - class, and it passed every D5 test before this one.""" + Predelay's shape of DecayKeptSquared, and it passed every D5 test + before this one. At Predelay 64/127, Decay 127, Damping out, Diffusion + 0, at audiodsp v0.6.3rc2 (re-audit fix round 2 there), it reads worst + +6.540 % (mean +5.194 %) on the handed law at 48 kHz and +7.214 % + (+5.197 %) at 22.05 kHz, and +1.296 / -2.360 % on a law read back off + the class; the clean class reads +1.376 / +2.322 %. (At v0.6.2, before + each side of a stereo room was scaled on its own: +6.518 / +7.244 %, + clean +1.375 / +2.347 %.)""" NAME = NAME @@ -615,11 +627,12 @@ def _init_macros(self, values, patch=None): class PredelayMidiSquared(ConvolutionReverb): """The same control through `set_macro` (re-audit fix round 2, from the re-audit round-1 audit's item 5): a Predelay move is kept squared, - the constructor's Predelay held exactly. At `set_macro` Predelay 64, - Decay 127, Damping out, Diffusion 0 the auditor read worst +6.518 % on - the handed law at 48 kHz (+7.244 % at 22.05 kHz), and it passed the - Predelay test while that test's clean leg went through the - constructor.""" + the constructor's Predelay held exactly. It passed the Predelay test + while that test's clean leg went through the constructor. At + `set_macro` Predelay 64, Decay 127, Damping out, Diffusion 0 it reads + what PredelayKeptSquared does at audiodsp v0.6.3rc2: worst +6.540 % on + the handed law at 48 kHz and +7.214 % at 22.05 kHz, clean +1.376 / + +2.322 % by this route too (v0.6.2: +6.518 / +7.244 %).""" NAME = NAME @@ -666,6 +679,70 @@ def reset(self): self.program_change(0) +class _PullHook(kit_faults.HiddenGain): + """A 0 dB `HiddenGain` (a gain of exactly 1.0, so every frame passes + unchanged) that tells its owner a pull is about to happen.""" + + def __init__(self, source, owner): + kit_faults.HiddenGain.__init__(self, source, 0.0) + self._owner = owner + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self._owner._flush() + return kit_faults.HiddenGain._get_buffer( + self, single_channel_output, audio_channel) + + +class OneSynthesisPerBlock(ConvolutionReverb): + """The two-move test's control (re-audit fix round 2 at audiodsp + v0.6.3rc2): room moves that land between two pulls are gathered into + one synthesis at the next pull. It is what the class would do with a + hook at the block edge, and what a node that kept fading from the room + that played would render (the node ask): the block starts on that + room, not on the first move's. Two moves before one pull then read + like one move, on the straight line from the old room to the new, and + the docstring's jump is gone.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._hooked = False + ConvolutionReverb._build(self, *arguments, **options) + self._pending = False + self._output = _PullHook(self._node, self) + self._hooked = True + + def _refresh(self): + if not self._hooked: + ConvolutionReverb._refresh(self) + return + self._pending = True + self._node.set(mix=self._value(MIX_I) * 0.5) + + def _flush(self): + if self._pending: + self._pending = False + ConvolutionReverb._refresh(self) + + +class PatchPerKnob(ConvolutionReverb): + """The patch-change test's control: a patch change after construction + applied one knob at a time, one synthesis per room knob, as five moves + before one pull.""" + + NAME = NAME + + def program_change(self, index, channel=0, note_id=-1, + sample_position=0): + patch = type(self).PATCHES.get(index) + if patch is None or self._deferred: + ConvolutionReverb.program_change(self, index, channel, note_id, + sample_position) + return + for macro, value in enumerate(patch[1]): + self.set_macro(macro, value) + + def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2, builder=build): return kit_faults.fault_reachability( @@ -1052,6 +1129,196 @@ def reset_then_wire(e): self.assertEqual(digest(out[2816:]), digest(wire[2816:])) +#: The docstring's room-move figures (re-audit fix round 2 at audiodsp +#: v0.6.3rc2, from the re-audit round-1 audit at v0.6.3rc2, items 1 and 2): +#: the step one move's straight line takes on low material, and the jump +#: two moves before one pull make. Each names its cell. +_KNOB = r"(Decay|Damping|Predelay|Diffusion|Room)" +_LSB = r"(\d{1,3}(?: \d{3})*)" +_CELL = (r"at\s+(48|44\.1|22\.05)\s+kHz\s+(stereo|mono)\s+with\s+Damping" + r"\s+(\d+)\s+of\s+127\s+\(500\s+Hz\)\s+and\s+Mix\s+2,\s+a\s+(\d+)" + r"\s+Hz\s+sine\s+at\s+" + _LSB + r"\s+LSB\s+") +STEP_RE = re.compile( + _CELL + r"steps\s+(\d+\.\d\d)\s+times\s+the\s+larger\s+room's\s+own" + r"\s+largest\s+step\s+when\s+" + _KNOB + r"\s+moves\s+from\s+(\d+)" + r"\s+to\s+(\d+)") +TWO_MOVES_RE = re.compile( + _CELL + r"with\s+" + _KNOB + r"\s+moved\s+to\s+(\d+)\s+and\s+then\s+" + + _KNOB + r"\s+to\s+(\d+)\s+before\s+one\s+pull\s+jumps\s+" + _LSB + + r"\s+LSB\s+into\s+the\s+block,\s+where\s+the\s+two\s+rooms\s+move\s+at" + r"\s+most\s+" + _LSB + r"\s+LSB\s+a\s+frame") +KNOB_INDEX = dict(zip(ConvolutionReverb.MACRO_LABELS, range(6))) +RATE_OF = {"48": 48000, "44.1": 44100, "22.05": 22050} + +#: The pull the moves land before, and the block in flight it plays. +MOVE_AT = 10 +BLOCK_IN_FLIGHT = slice(MOVE_AT * 256, MOVE_AT * 256 + 256) + + +def documented_move(doc, pattern): + """(rate, channels, start moves, sine Hz, sine peak, the rest of the + groups) of the docstring's sentence, or None.""" + found = pattern.search(" ".join((doc or "").split())) + if found is None: + return None + g = found.groups() + number = lambda text: int(text.replace(" ", "")) # noqa: E731 + return (RATE_OF[g[0]], 2 if g[1] == "stereo" else 1, + ((DAMPING_I, int(g[2])),), int(g[3]), number(g[4]), g[5:], + number) + + +def sine(frames, channels, hz, rate, peak): + t = np.arange(frames) / float(rate) + x = np.round(peak * np.sin(2 * np.pi * hz * t)).astype(np.int16) + return np.repeat(x[:, None], channels, axis=1) + + +def pulled(effect, pcm, actions=None, blocks=40): + """`blocks` pulls over `pcm` from a source in 256-frame blocks; + `actions[n](effect)` runs just before pull n.""" + channels = effect.channel_count + effect._source.swap(probes.ArraySource(pcm, rate=effect.sample_rate, + channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for number in range(blocks): + if actions and number in actions: + actions[number](effect) + out += bytes(audiocore.get_buffer(effect.output)[1]) + return np.frombuffer(bytes(out), dtype=np.int16).reshape(-1, channels) + + +def apply_moves(effect, moves): + for index, value in moves: + effect.set_macro(index, value) + + +def room_render(cls, rate, channels, moves, pcm, mix=2.0, actions=None): + effect = build(cls, rate, channels, mix=mix) + apply_moves(effect, moves) + out = pulled(effect, pcm, actions) + effect.deinit() + return out + + +def fade_reading(old, new, moved, first=None): + """The block in flight of `moved` against the straight line from `old` + to `new` (full-scale samples left out: the node clips after its fade), + the jump into it, its largest step and the rooms' own largest step from + the frame before it to the frame after it, how many frames before it + are off `old` and after it off `new`, and how far its first frame sits + from `first` (the room of a first move) and from `old`.""" + o, n, m = (x.astype(np.float64) for x in (old, new, moved)) + a, b = BLOCK_IN_FLIGHT.start, BLOCK_IN_FLIGHT.stop + k = (np.arange(1, 257, dtype=np.float64) / 256.0)[:, None] + line = o[a:b] + k * (n[a:b] - o[a:b]) + full = (np.abs(o[a:b]) >= 32767) | (np.abs(n[a:b]) >= 32767) + span = slice(a - 1, b + 1) + reading = dict( + pre=int(np.sum(np.any(moved[:a] != old[:a], axis=1))), + post=int(np.sum(np.any(moved[b:] != new[b:], axis=1))), + off_line=float(np.max(np.where(full, 0.0, np.abs(m[a:b] - line)))), + jump=int(np.max(np.abs(m[a] - m[a - 1]))), + step=float(np.max(np.abs(np.diff(m[span], axis=0)))), + own=float(max(np.max(np.abs(np.diff(o[span], axis=0))), + np.max(np.abs(np.diff(n[span], axis=0))))), + near_old=float(np.max(np.abs(m[a] - o[a])))) + if first is not None: + reading["near_first"] = float(np.max(np.abs( + m[a] - first[a].astype(np.float64)))) + return reading + + +class RoomMoveWords(unittest.TestCase): + """The docstring's room-move paragraph, pinned to the room (re-audit + fix round 2 at audiodsp v0.6.3rc2). The round-1 audit at v0.6.3rc2 + parked the class on it: its straight line and its step held for one + move at a block edge on white noise, and two moves before one pull + start the block in flight on a room never played. Each figure the + docstring prints is read at the cell it names; the `1b3bb94` words, + which printed neither, are red, and so is each figure moved.""" + + def test_two_moves_before_one_pull_jump_as_documented(self, cls=None): + # The two-move sentence: frames before the block in flight are the + # old room's and after it a room built with both moves, exactly; + # the block's first frame sits nearer the first move's room than + # the old one; the jump into it and the rooms' own largest step + # are the printed LSB; and Mix 0 stays the source delayed by + # `latency_samples` across the same two moves. OneSynthesisPerBlock + # (the moves gathered into one synthesis) is red: its block starts + # on the old room. + found = documented_move(rebuilt.__doc__, TWO_MOVES_RE) + self.assertIsNotNone(found, "no two-move sentence naming its cell") + rate, channels, start, hz, peak, rest, number = found + first = (KNOB_INDEX[rest[0]], int(rest[1])) + second = (KNOB_INDEX[rest[2]], int(rest[3])) + jump, own = number(rest[4]), number(rest[5]) + pcm = sine(40 * 256, channels, hz, rate, peak) + old = room_render(cls, rate, channels, start, pcm) + mid = room_render(cls, rate, channels, start + (first,), pcm) + new = room_render(cls, rate, channels, start + (first, second), pcm) + moved = room_render(cls, rate, channels, start, pcm, actions={ + MOVE_AT: lambda e: apply_moves(e, (first, second))}) + r = fade_reading(old, new, moved, mid) + self.assertEqual((r["pre"], r["post"]), (0, 0), r) + self.assertLess(r["near_first"], r["near_old"], r) + self.assertEqual((r["jump"], int(r["own"])), (jump, own), r) + self.assertGreater(r["jump"], 10 * r["own"], r) + wire = np.vstack([silence(LATENCY, channels), pcm[:-LATENCY]]) + out = room_render(cls, rate, channels, start, pcm, mix=0.0, + actions={MOVE_AT: lambda e: apply_moves( + e, (first, second))}) + self.assertEqual(digest(out), digest(wire)) + + def test_one_move_steps_as_documented(self): + # The step sentence: one move at a block edge holds the straight + # line within 1 LSB and steps the printed multiple, to the + # hundredth, of the larger room's own largest step at its cell. + found = documented_move(rebuilt.__doc__, STEP_RE) + self.assertIsNotNone(found, "no step sentence naming its cell") + rate, channels, start, hz, peak, rest, _ = found + ratio = float(rest[0]) + knob, before, after = KNOB_INDEX[rest[1]], int(rest[2]), int(rest[3]) + start = start + ((knob, before),) + pcm = sine(40 * 256, channels, hz, rate, peak) + old = room_render(None, rate, channels, start, pcm) + new = room_render(None, rate, channels, start + ((knob, after),), + pcm) + moved = room_render(None, rate, channels, start, pcm, actions={ + MOVE_AT: lambda e: e.set_macro(knob, after)}) + r = fade_reading(old, new, moved) + self.assertEqual((r["pre"], r["post"]), (0, 0), r) + self.assertLessEqual(r["off_line"], 1.0, r) + self.assertGreater(ratio, 1.0) + self.assertLessEqual(abs(r["step"] / r["own"] - ratio), 0.006, r) + + def test_a_patch_change_is_one_synthesis_on_the_line(self, cls=None): + # "A patch change moves every knob in one synthesis and keeps the + # straight line": patch 1 -> 3 (every room knob moves, Mix does + # not) before one pull, at three rates, stereo and mono, on white + # noise and on the low sine. PatchPerKnob (one synthesis a knob) is + # red. + for rate in RATES: + for channels in (2, 1): + for pcm in (white(40 * 256, channels, -6.0, seed=4242), + sine(40 * 256, channels, 40.0, rate, 2000.0)): + renders = [] + for action in (None, "built", "moved"): + effect = build(cls, rate, channels, patch=1) + acts = None + if action == "built": + effect.program_change(3) + elif action == "moved": + acts = {MOVE_AT: lambda e: e.program_change(3)} + renders.append(pulled(effect, pcm, acts)) + effect.deinit() + r = fade_reading(*renders) + label = (rate, channels, r) + self.assertEqual((r["pre"], r["post"]), (0, 0), label) + self.assertLessEqual(r["off_line"], 1.0, label) + + # -------------------------------------------------------------------------- # D1 - measured mode is exactly convolution, within one output LSB # -------------------------------------------------------------------------- @@ -1149,7 +1416,7 @@ def measure(cls): def capacity(node): """The node's allocation in taps, read as a board would: the longest impulse its `load()` accepts (the native binding refuses more, - `Convolver.c:183-186` at 1c89b03; the twin at `audioconvolve.py:127`). + `Convolver.c:183-186` at 0d35a90; the twin at `audioconvolve.py:127`). `node.taps` cannot stand in for it: in measured mode it reports the loaded length rounded to a partition, which is the law whatever the allocation (audit round 1). Destructive - it replaces the room - so it From 8a572820d12f51d2b01ff936b382d20518d831ff Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:09:34 -0500 Subject: [PATCH 63/88] ConvolutionReverb re-audit fix round 1 after the re-audit round 2 at audiodsp v0.6.3rc2: the room-move paragraph stated as a rule, with no mechanism in it Words and tests only; the module's code is identical to cf17a88 outside its docstrings. The re-audit round 2 parked the class on three sentences (a source that runs dry mid-block shortens every later fade and Mix move, a patch change keeps the line only as its block's one room change, three moves start the block on the room before the last). The paragraph now says only what holds in every case: no dry frame drops or repeats after any number of moves, the tail rings on, the output is the new settings' from the end of the block in flight; the change is a straight line when it is the only room change between two pulls and the source has not handed back an empty buffer part-way through a block since the last reset(); otherwise it can jump, how far not known, with the example cell now naming its pull. The Mix sentence says "at most 256 frames"; reset silences the next 256. Class summary and CHANGELOG lines carry the two conditions. RoomMoveWords reads every sentence: the rule's legs at three rates, stereo and mono (OneSynthesisPerBlock red on the pairs, the new RetryOnEmpty red on the under-run leg), the dry wire after every pair and after under-runs (ResetOnMove), the patch test with a Mix-moving patch (PatchPerKnob), the Mix sentence (new MixOnePullLate, 512 frames) and the reset sentence (NoReset). The cf17a88 words are red on all seven, and each printed figure moved is red on its test. Class suite 204 OK, full suite 1973 OK (skipped 3), flake8 and both validators clean, smoke, Tier 1 and the 60 digests unchanged on CPython and the rc2 natives. --- CHANGELOG.md | 20 +- lib/audioeffects/rebuilt/convolutionreverb.py | 90 ++- .../test_cpython_effects_convolutionreverb.py | 525 ++++++++++++++++-- 3 files changed, 516 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f92ec..8ca9576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,12 +105,12 @@ there, and are recorded in its changelog. synthesis macros with `IndexError`; no impulse ships. It lives in `lib/audioeffects/rebuilt/convolutionreverb.py`, and `audioeffects.ConvolutionReverb` is still the old class. It stands on - audiodsp v0.6.3rc2: moving a room knob changes the room over the block - in flight, with no frame of the dry dropped or repeated at any Mix and - the tail ringing on into the new room (one move a block: a second room - move before the next pull starts the block on the first move's room, - never played, and the output can jump there; a patch change is one - synthesis), and each side of a stereo room + audiodsp v0.6.3rc2: moving a room knob changes the room within the + block in flight, with no frame of the dry dropped or repeated at any Mix + and the tail ringing on into the new room (a straight-line crossfade + when it is the only room change between two pulls and the source has + not run dry part-way through a block since the last `reset()`; + otherwise the output can jump), and each side of a stereo room is unit energy on its own, so the room sits in the middle (the two sides within 0.001 dB on the room's own impulse over the settings walked). The docstring states the low-material lift a dark room gives @@ -122,10 +122,10 @@ there, and are recorded in its changelog. - **The audiodsp pin moves to v0.6.3rc2.** The release adds audiodsp#165 to rc1: `audioconvolve.Convolver` keeps the audio in flight when a room is - re-synthesized (#163: nothing drops, the tail rings on, and after one - room move the block on its way out crossfades from the old room to the - new; after two before the next pull it starts on the first move's room) - and normalises each + re-synthesized (#163: nothing drops, the tail rings on, and the block + on its way out crossfades to the new room, in a straight line when that + is its only re-synthesis and the node's source has not run dry + part-way through a block since the last reset) and normalises each side of a stereo room on its own (#164: the room no longer leans, and the pair's level holds). Mono rooms and measured impulses render the bytes they did. `ConvolutionReverb`, rebuilt and old, synthesizes diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 77fe8d1..036ba4c 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -71,8 +71,8 @@ impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is the source delayed by exactly `latency_samples`, byte for byte, because the node stays in the path at Mix 0 and a Mix move never jumps the timeline. -A room-knob move keeps it too; the one exception is the partition -`reset()` is called in (below). +A room-knob move keeps it too; the one exception is the 256 frames after +a `reset()` (below). **Tail.** `tail_samples` is `latency_samples` plus the loaded impulse rounded up to a partition: 4 096 frames (85.3 ms) at the default at @@ -84,50 +84,40 @@ 44.1 kHz and 76 008 B at 22.05 kHz. A measured impulse is read once at construction, handed to the node and dropped; the class keeps no copy. -**Moving a room knob changes the room over the block in flight.** Decay, -Damping, Predelay, Diffusion and Room re-synthesize the impulse, and the -node keeps what it holds: no frame of your dry signal drops or repeats, at -any Mix (at Mix 0 the output stays the source delayed by -`latency_samples`, byte for byte, across the move), and a tail ringing at -that moment rings on into the new room. The old room fades into the new -one over the frames of the block in flight that have not played yet, in a -straight line, within 1 LSB (a sample at full scale in either room is -clipped after the fade); from the next block the output is exactly that -of an instance that always had the new room. From a host, whose moves land -between pulls, the fade is all 256 frames of the block (5.333 ms at -48 kHz). If the source ran dry part-way through a block and play went on -from another source without a `reset()`, it is only the frames left, down -to one, which is close to a hard switch. A straight line is not smooth, -so on low material the fade can step further from one frame to the next -than either room does on its own, and how much further is not known: at -48 kHz stereo with Damping 0 of 127 (500 Hz) and Mix 2, a 40 Hz sine at -2 000 LSB steps 1.97 times the larger room's own largest step when -Predelay moves from 0 to 127. A move that lands on the room already -loaded does nothing, and a patch change, the constructor and `reset()` -synthesize once, not once per knob. The synthesis itself runs on the -thread that moves the knob; on a board it can race the audio pump -(audiodsp#166, open), which a desktop cannot show. `reset()` in the middle -of a stream is a reset: it empties the room, and the 256 frames in flight -come out as exact zero, dry included. Mix moves never touch the room, but -a Mix move acts on the audio entering the node after it, so you hear it -one partition later: the 256 frames already in flight come out at the old -Mix. - -**One room move per block.** The straight line is one move's. When a -second room move lands before the next pull (a host setting two knobs at -once, or one knob's controller stepping twice inside a block), the node -starts the block in flight on the room of the first move, which never -played, and fades from there to the second, so the output can jump at the -block's first frame: at 44.1 kHz stereo with Damping 0 of 127 (500 Hz) -and Mix 2, a 40 Hz sine at 2 000 LSB with Room moved to 50 and then -Predelay to 40 before one pull jumps 10 046 LSB into the block, where the -two rooms move at most 32 LSB a frame. Your dry is untouched (at Mix 0 the -output stays exact), and the frames before the block and after it are -exact. To avoid it, move one room knob per block, or change patch: a patch -change moves every knob in one synthesis and keeps the straight line. The -class cannot gather the moves for you, because nothing tells it where a -block ends; a node change that fades from the room that played is asked -for. +**Moving a room knob changes the room within the block in flight.** +Decay, Damping, Predelay, Diffusion and Room re-synthesize the impulse, and +the node keeps what it holds. No frame of your dry signal drops or +repeats, at any Mix and after any number of moves: at Mix 0 the output is +byte for byte what it would have been with no move. A tail +ringing at that moment rings on into the new room, and from the end of +the block in flight the output is exactly that of an instance that always +had the new settings. + +The change runs in a straight line from the old room to the new, at the +Mix already in flight and within 1 LSB where neither room is at full +scale, when two things hold: it is the only room change between two +pulls, and the source has not handed back an empty buffer part-way +through a block since the instance was built or last `reset()`. A patch +change counts as one room change however many knobs it moves, and a move +that lands on the room already loaded leaves the audio untouched. +Otherwise the output can jump by many times what either room does on its +own, and how far is not known. For example, at 44.1 kHz stereo with +Damping 0 of 127 (500 Hz) and Mix 2, a 40 Hz sine at 2 000 LSB with Room +moved to 50 and then Predelay to 40 after 10 pulls jumps 10 046 LSB into +the block, where the two rooms move at most 32 LSB a frame. Even on +the straight line, low material can step further from one frame to the +next than either room does, and how much further is not known: at 48 kHz +stereo with Damping 0 of 127 (500 Hz) and Mix 2, a 40 Hz sine at 2 000 LSB +steps 1.97 times the larger room's own largest step when Predelay moves +from 0 to 127 after 10 pulls. + +A patch change, the constructor and `reset()` synthesize at most once, +not once per knob. The synthesis runs on the thread that moves the knob; +on a board it can race the audio pump (audiodsp#166, open), which a +desktop cannot show. `reset()` in the middle of a stream empties the room: the +next 256 frames come out as exact zero, dry included. A Mix move never +touches the room. It acts on the audio entering the node after it, so the +block already in flight, at most 256 frames, comes out at the old Mix. **What the default surrenders.** The room is normalised to unit energy across the whole band, so with Damping in, low material comes back louder @@ -353,12 +343,12 @@ class ConvolutionReverb(_component.Component): **What the default surrenders:** a dark room lifts low material (a low chord +3.83 dB at the default Damping, +13.40 dB at 500 Hz, 48 kHz), - calling `reset()` mid-stream drops the 256 frames in flight, dry + calling `reset()` mid-stream silences the next 256 frames, dry included, at every Mix, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). A room-knob move - drops nothing: the room changes over the block in flight. Two room - moves before one pull can jump at the block's first frame; move one - room knob a block, or change patch. + drops no dry frame. Two room changes between two pulls, or a move + after the source has handed back an empty buffer part-way through a + block (until a `reset()`), can make the output jump. """ NAME = 'ConvolutionReverb' diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 6d289d6..bd224d7 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -66,6 +66,18 @@ figure moved), and a patch change held to the straight line (red on `PatchPerKnob`, one synthesis a knob). The `1b3bb94` words, which printed neither figure, are red on the first two. + +The re-audit round 2 at v0.6.3rc2 parked the class on three of those +sentences, and re-audit fix round 1 after it restates the paragraph as a +rule with no mechanism in it: what holds after any number of moves, the two +conditions for the straight line (one room change between two pulls, and a +source that has not run dry part-way through a block since the last +`reset()`), and that anything else can jump. `RoomMoveWords` now reads each +of its sentences: the rule's legs (`OneSynthesisPerBlock` red on the pairs, +`RetryOnEmpty`, which never lets the node see the empty buffer, red on the +under-run), the dry wire after every pair and an under-run (`ResetOnMove`), +the Mix sentence (`MixOnePullLate`) and the reset sentence (`NoReset`). The +`cf17a88` words are red on all seven. """ import os @@ -699,9 +711,10 @@ class OneSynthesisPerBlock(ConvolutionReverb): one synthesis at the next pull. It is what the class would do with a hook at the block edge, and what a node that kept fading from the room that played would render (the node ask): the block starts on that - room, not on the first move's. Two moves before one pull then read - like one move, on the straight line from the old room to the new, and - the docstring's jump is gone.""" + room, not on the room before the last move, which is where the node at + v0.6.3rc2 starts it. Two or more changes before one pull then read like + one, on the straight line from the old room to the new, and the + docstring's jump is gone (the rule test's pairs too).""" NAME = NAME @@ -743,6 +756,71 @@ def program_change(self, index, channel=0, note_id=-1, self.set_macro(macro, value) +class _RetrySource: + """The class's source behind a proxy that never hands the node an + empty buffer: an empty read is followed at once by another.""" + + def __init__(self, inner): + self.inner = inner + for name in ("sample_rate", "channel_count", "bits_per_sample", + "samples_signed"): + setattr(self, name, getattr(inner, name)) + + def swap(self, source): + self.inner.swap(source) + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + self.inner._reset_buffer(single_channel_output, audio_channel) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + for _ in range(8): + result, data = self.inner._get_buffer(single_channel_output, + audio_channel) + if len(data): + break + return result, data + + +class RetryOnEmpty(ConvolutionReverb): + """The under-run leg's control (re-audit fix round 1 after the re-audit + round 2 at audiodsp v0.6.3rc2): the class pulls again when its source + hands back an empty buffer, so the node never returns a short block and + stays at its block edge. A move after an under-run then fades over the + whole block, as if the source had never run dry.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._source = _RetrySource(self._source) + ConvolutionReverb._build(self, *arguments, **options) + + +class MixOnePullLate(ConvolutionReverb): + """The Mix sentence's control: a Mix move handed to the node one pull + late, so 512 frames come out at the old Mix, not 256.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._hooked = False + ConvolutionReverb._build(self, *arguments, **options) + self._mix_wait = 0 + self._output = _PullHook(self._node, self) + self._hooked = True + + def _apply_macro(self, index, position): + if self._hooked and not self._deferred and index == MIX_I: + self._mix_wait = 2 + return + ConvolutionReverb._apply_macro(self, index, position) + + def _flush(self): + if self._mix_wait: + self._mix_wait -= 1 + if not self._mix_wait: + self._node.set(mix=self._value(MIX_I) * 0.5) + + def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2, builder=build): return kit_faults.fault_reachability( @@ -1132,7 +1210,10 @@ def reset_then_wire(e): #: The docstring's room-move figures (re-audit fix round 2 at audiodsp #: v0.6.3rc2, from the re-audit round-1 audit at v0.6.3rc2, items 1 and 2): #: the step one move's straight line takes on low material, and the jump -#: two moves before one pull make. Each names its cell. +#: two moves before one pull make. Each names its cell, and since re-audit +#: fix round 1 after the re-audit round 2 at v0.6.3rc2, the pull its moves +#: land after (the jump moves with it: 4 434 to 10 260 LSB over pulls 4 to +#: 31 at the two-move cell, the round-2 audit's `cell`). _KNOB = r"(Decay|Damping|Predelay|Diffusion|Room)" _LSB = r"(\d{1,3}(?: \d{3})*)" _CELL = (r"at\s+(48|44\.1|22\.05)\s+kHz\s+(stereo|mono)\s+with\s+Damping" @@ -1141,10 +1222,10 @@ def reset_then_wire(e): STEP_RE = re.compile( _CELL + r"steps\s+(\d+\.\d\d)\s+times\s+the\s+larger\s+room's\s+own" r"\s+largest\s+step\s+when\s+" + _KNOB + r"\s+moves\s+from\s+(\d+)" - r"\s+to\s+(\d+)") + r"\s+to\s+(\d+)\s+after\s+(\d+)\s+pulls") TWO_MOVES_RE = re.compile( _CELL + r"with\s+" + _KNOB + r"\s+moved\s+to\s+(\d+)\s+and\s+then\s+" - + _KNOB + r"\s+to\s+(\d+)\s+before\s+one\s+pull\s+jumps\s+" + _LSB + + _KNOB + r"\s+to\s+(\d+)\s+after\s+(\d+)\s+pulls\s+jumps\s+" + _LSB + r"\s+LSB\s+into\s+the\s+block,\s+where\s+the\s+two\s+rooms\s+move\s+at" r"\s+most\s+" + _LSB + r"\s+LSB\s+a\s+frame") KNOB_INDEX = dict(zip(ConvolutionReverb.MACRO_LABELS, range(6))) @@ -1202,15 +1283,17 @@ def room_render(cls, rate, channels, moves, pcm, mix=2.0, actions=None): return out -def fade_reading(old, new, moved, first=None): - """The block in flight of `moved` against the straight line from `old` - to `new` (full-scale samples left out: the node clips after its fade), - the jump into it, its largest step and the rooms' own largest step from - the frame before it to the frame after it, how many frames before it - are off `old` and after it off `new`, and how far its first frame sits - from `first` (the room of a first move) and from `old`.""" +def fade_reading(old, new, moved, first=None, at=None): + """The block in flight of `moved` (the 256 frames from frame `at`, + default the block after MOVE_AT pulls) against the straight line from + `old` to `new` (full-scale samples left out: the node clips after its + fade), the jump into it, its largest step and the rooms' own largest + step from the frame before it to the frame after it, how many frames + before it are off `old` and after it off `new`, and how far its first + frame sits from `first` (the room of a first move) and from `old`.""" o, n, m = (x.astype(np.float64) for x in (old, new, moved)) - a, b = BLOCK_IN_FLIGHT.start, BLOCK_IN_FLIGHT.stop + a = BLOCK_IN_FLIGHT.start if at is None else at + b = a + 256 k = (np.arange(1, 257, dtype=np.float64) / 256.0)[:, None] line = o[a:b] + k * (n[a:b] - o[a:b]) full = (np.abs(o[a:b]) >= 32767) | (np.abs(n[a:b]) >= 32767) @@ -1230,93 +1313,417 @@ def fade_reading(old, new, moved, first=None): return reading +class DryOnce(probes.ArraySource): + """int16 frames in 256-frame calls, except where `plan` maps a call + number (from 1) to how many frames that call hands: 0 is an empty + buffer, the source running dry for one call and then going on.""" + + def __init__(self, data, rate, channels, plan=None): + probes.ArraySource.__init__(self, data, rate=rate, channels=channels) + self.plan = dict(plan or {}) + self.calls = 0 + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + probes.ArraySource._reset_buffer(self) + self.calls = 0 + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + self.calls += 1 + take = self.plan.get(self.calls) + if take is None: + return probes.ArraySource._get_buffer(self) + stride = take * self.channel_count * 2 + chunk = bytes(self._pcm[self._position:self._position + stride]) + self._position += len(chunk) + return 1, memoryview(chunk) + + +#: The source hands 255 frames on its fourth call and an empty buffer on +#: its fifth, then goes on: the node's block phase is 255 from then on. +UNDERRUN = {4: 255, 5: 0} + + +def act(effect, moves): + """Apply `moves`: (macro, MIDI), ("patch", index) or ("reset", None).""" + for index, value in moves: + if index == "patch": + effect.program_change(value) + elif index == "reset": + effect.reset() + else: + effect.set_macro(index, value) + + +def dry_render(cls, rate, channels, start, pcm, plan=None, actions=None, + mix=2.0, blocks=40): + """`blocks` host pulls over `pcm` from a DryOnce source, `start` applied + at construction and `actions[n]` just before pull n. Returns the output + as (frames, channels) and the frame count of each pull.""" + effect = build(cls, rate, channels, mix=mix) + act(effect, start) + effect._source.swap(DryOnce(pcm, rate, channels, plan)) + audiocore.reset_buffer(effect.node) + out, sizes = [], [] + for number in range(blocks): + if actions and number in actions: + act(effect, actions[number]) + data = np.frombuffer(bytes(audiocore.get_buffer(effect.output)[1]), + dtype=np.int16).reshape(-1, channels) + out.append(data) + sizes.append(len(data)) + effect.deinit() + return np.vstack(out), sizes + + +def said(doc, words): + """True when `words` are in `doc`, whitespace aside.""" + return " ".join(words.split()) in " ".join((doc or "").split()) + + +#: The room-move rule as the docstring states it (re-audit fix round 1 +#: after the re-audit round 2 at audiodsp v0.6.3rc2): what holds after any +#: move, when the change is the straight line, and what else can jump. +MOVE_WORDS = ( + "No frame of your dry signal drops or repeats, at any Mix and after any " + "number of moves: at Mix 0 the output is byte for byte what it would " + "have been with no move.", + "from the end of the block in flight the output is exactly that of an " + "instance that always had the new settings.", + "when two things hold: it is the only room change between two pulls, " + "and the source has not handed back an empty buffer part-way through a " + "block since the instance was built or last `reset()`.", + "A patch change counts as one room change however many knobs it moves, " + "and a move that lands on the room already loaded leaves the audio " + "untouched.", + "Otherwise the output can jump by many times what either room does on " + "its own, and how far is not known.", +) +SUMMARY_WORDS = ( + "A room-knob move drops no dry frame. Two room changes between two " + "pulls, or a move after the source has handed back an empty buffer " + "part-way through a block (until a `reset()`), can make the output " + "jump.") +RESET_WORDS = ( + "`reset()` in the middle of a stream empties the room: the next 256 " + "frames come out as exact zero, dry included.") +MIX_LATE_RE = re.compile( + r"A\s+Mix\s+move\s+never\s+touches\s+the\s+room\.\s+It\s+acts\s+on\s+the" + r"\s+audio\s+entering\s+the\s+node\s+after\s+it,\s+so\s+the\s+block" + r"\s+already\s+in\s+flight,\s+at\s+most\s+(\d+)\s+frames,\s+comes\s+out" + r"\s+at\s+the\s+old\s+Mix\.") + +#: What the rule says breaks the line, each before one pull, on the dark +#: room (Damping 0) with Mix held at 2: (label, start, the changes). +DARK = ((DAMPING_I, 0),) +HOLD_MIX = ((MIX_I, 127),) +TWO_CHANGES = ( + ("two knobs", DARK, ((ROOM_I, 50), (PREDELAY_I, 40))), + ("three knobs", DARK, ((ROOM_I, 50), (PREDELAY_I, 40), (DECAY_I, 30))), + ("a knob, then a patch", (("patch", 1),) + DARK, + ((PREDELAY_I, 40), ("patch", 3))), + ("a patch, then a knob", (("patch", 1),), (("patch", 3), (ROOM_I, 50))), + ("two patches", (("patch", 0),), (("patch", 1), ("patch", 3))), +) + + class RoomMoveWords(unittest.TestCase): - """The docstring's room-move paragraph, pinned to the room (re-audit - fix round 2 at audiodsp v0.6.3rc2). The round-1 audit at v0.6.3rc2 - parked the class on it: its straight line and its step held for one - move at a block edge on white noise, and two moves before one pull - start the block in flight on a room never played. Each figure the - docstring prints is read at the cell it names; the `1b3bb94` words, - which printed neither, are red, and so is each figure moved.""" + """The docstring's room-move paragraph, pinned to the room. Re-audit fix + round 2 at audiodsp v0.6.3rc2 pinned its figures; the re-audit round 2 + there parked the class again on three of its sentences (a source that + ran dry shortens every later fade, a patch change keeps the line only as + its block's one room change, three moves start the block elsewhere than + two), so re-audit fix round 1 after it states only the rule: what holds + after any move, the two conditions for the straight line, and that + anything else can jump. Every sentence of it is read here, the + `cf17a88` words are red, and so is each plant.""" def test_two_moves_before_one_pull_jump_as_documented(self, cls=None): - # The two-move sentence: frames before the block in flight are the - # old room's and after it a room built with both moves, exactly; - # the block's first frame sits nearer the first move's room than - # the old one; the jump into it and the rooms' own largest step - # are the printed LSB; and Mix 0 stays the source delayed by - # `latency_samples` across the same two moves. OneSynthesisPerBlock - # (the moves gathered into one synthesis) is red: its block starts - # on the old room. + # The example: frames before the block in flight are the old room's + # and after it a room built with both moves, exactly; the jump into + # it and the rooms' own largest step are the printed LSB, with the + # moves after the printed number of pulls; and Mix 0 stays the + # source delayed by `latency_samples` across the same two moves. + # OneSynthesisPerBlock (the moves gathered into one synthesis) is + # red: its jump is the rooms' own. found = documented_move(rebuilt.__doc__, TWO_MOVES_RE) self.assertIsNotNone(found, "no two-move sentence naming its cell") rate, channels, start, hz, peak, rest, number = found first = (KNOB_INDEX[rest[0]], int(rest[1])) second = (KNOB_INDEX[rest[2]], int(rest[3])) - jump, own = number(rest[4]), number(rest[5]) + at = int(rest[4]) + jump, own = number(rest[5]), number(rest[6]) pcm = sine(40 * 256, channels, hz, rate, peak) old = room_render(cls, rate, channels, start, pcm) - mid = room_render(cls, rate, channels, start + (first,), pcm) new = room_render(cls, rate, channels, start + (first, second), pcm) moved = room_render(cls, rate, channels, start, pcm, actions={ - MOVE_AT: lambda e: apply_moves(e, (first, second))}) - r = fade_reading(old, new, moved, mid) + at: lambda e: apply_moves(e, (first, second))}) + r = fade_reading(old, new, moved, at=at * 256) self.assertEqual((r["pre"], r["post"]), (0, 0), r) - self.assertLess(r["near_first"], r["near_old"], r) self.assertEqual((r["jump"], int(r["own"])), (jump, own), r) self.assertGreater(r["jump"], 10 * r["own"], r) wire = np.vstack([silence(LATENCY, channels), pcm[:-LATENCY]]) out = room_render(cls, rate, channels, start, pcm, mix=0.0, - actions={MOVE_AT: lambda e: apply_moves( + actions={at: lambda e: apply_moves( e, (first, second))}) self.assertEqual(digest(out), digest(wire)) def test_one_move_steps_as_documented(self): - # The step sentence: one move at a block edge holds the straight - # line within 1 LSB and steps the printed multiple, to the - # hundredth, of the larger room's own largest step at its cell. + # The step sentence: one move holds the straight line within 1 LSB + # and steps the printed multiple, to the hundredth, of the larger + # room's own largest step at its cell, after the printed pulls. found = documented_move(rebuilt.__doc__, STEP_RE) self.assertIsNotNone(found, "no step sentence naming its cell") rate, channels, start, hz, peak, rest, _ = found ratio = float(rest[0]) knob, before, after = KNOB_INDEX[rest[1]], int(rest[2]), int(rest[3]) + at = int(rest[4]) start = start + ((knob, before),) pcm = sine(40 * 256, channels, hz, rate, peak) old = room_render(None, rate, channels, start, pcm) new = room_render(None, rate, channels, start + ((knob, after),), pcm) moved = room_render(None, rate, channels, start, pcm, actions={ - MOVE_AT: lambda e: e.set_macro(knob, after)}) - r = fade_reading(old, new, moved) + at: lambda e: e.set_macro(knob, after)}) + r = fade_reading(old, new, moved, at=at * 256) self.assertEqual((r["pre"], r["post"]), (0, 0), r) self.assertLessEqual(r["off_line"], 1.0, r) self.assertGreater(ratio, 1.0) self.assertLessEqual(abs(r["step"] / r["own"] - ratio), 0.006, r) def test_a_patch_change_is_one_synthesis_on_the_line(self, cls=None): - # "A patch change moves every knob in one synthesis and keeps the - # straight line": patch 1 -> 3 (every room knob moves, Mix does - # not) before one pull, at three rates, stereo and mono, on white - # noise and on the low sine. PatchPerKnob (one synthesis a knob) is - # red. + # "A patch change counts as one room change however many knobs it + # moves": patch 1 -> 3 (every room knob moves, Mix does not) and + # patch 1 -> 5 (Mix moves too, 44 -> 63) before one pull, at three + # rates, stereo and mono, on white noise and on the low sine. The + # block in flight is on the straight line from the old room to the + # new at the Mix already in flight (patch 5's room at patch 1's + # Mix), and after it the output is the new patch's exactly. + # PatchPerKnob (one synthesis a knob) is red. + self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[3])) for rate in RATES: for channels in (2, 1): for pcm in (white(40 * 256, channels, -6.0, seed=4242), sine(40 * 256, channels, 40.0, rate, 2000.0)): - renders = [] - for action in (None, "built", "moved"): - effect = build(cls, rate, channels, patch=1) - acts = None - if action == "built": - effect.program_change(3) - elif action == "moved": - acts = {MOVE_AT: lambda e: e.program_change(3)} - renders.append(pulled(effect, pcm, acts)) - effect.deinit() - r = fade_reading(*renders) - label = (rate, channels, r) - self.assertEqual((r["pre"], r["post"]), (0, 0), label) - self.assertLessEqual(r["off_line"], 1.0, label) + for target in (3, 5): + old_mix = ConvolutionReverb.PATCHES[1][1][MIX_I] + renders = [] + for action in ("old", "line", "built", "moved"): + effect = build(cls, rate, channels, patch=1) + acts = None + if action in ("line", "built"): + effect.program_change(target) + if action == "line": + effect.set_macro(MIX_I, old_mix) + if action == "moved": + acts = {MOVE_AT: lambda e, t=target: + e.program_change(t)} + renders.append(pulled(effect, pcm, acts)) + effect.deinit() + old, line, built, moved = renders + r = fade_reading(old, line, moved) + post = fade_reading(old, built, moved)["post"] + label = (rate, channels, target, r) + self.assertEqual((r["pre"], post), (0, 0), label) + self.assertLessEqual(r["off_line"], 1.0, label) + + def test_the_line_holds_only_as_the_rule_says(self, cls=None): + # The rule, read at three rates, stereo and mono, on a 40 Hz sine at + # 2 000 LSB through the dark room, Mix held at 2: + # - on the line within 1 LSB, frames before and after exact: one + # knob move; one knob a pull (Room, then Predelay the next pull); + # a move after an under-run and a reset(); + # - the audio untouched: a move onto the room already loaded (Room + # 0 -> 1, both seed 1); + # - frames before and after exact, but a step past ten times the + # rooms' own: every pair of changes before one pull the rule + # names (two knobs, three, a knob and a patch either way, two + # patches), and one knob move after the source ran dry 255 + # frames into a block. + # OneSynthesisPerBlock (changes gathered into one synthesis) is red + # on the pairs, RetryOnEmpty (the class never lets the node see the + # empty buffer) on the under-run, and the cf17a88 words on the + # parse. + for words in MOVE_WORDS: + self.assertTrue(said(rebuilt.__doc__, words), words) + self.assertTrue(said(ConvolutionReverb.__doc__, SUMMARY_WORDS)) + for rate in RATES: + for channels in (2, 1): + pcm = sine(40 * 256, channels, 40.0, rate, 2000.0) + + def render(start, actions=None, plan=None): + return dry_render(cls, rate, channels, start + HOLD_MIX, + pcm, plan, actions) + + def on_line(old, new, moved, at, label, post=True): + r = fade_reading(old, new, moved, at=at) + self.assertEqual(r["pre"], 0, (label, r)) + if post: + self.assertEqual(r["post"], 0, (label, r)) + self.assertLessEqual(r["off_line"], 1.0, (label, r)) + + def jumps(old, new, moved, at, label): + r = fade_reading(old, new, moved, at=at) + self.assertEqual((r["pre"], r["post"]), (0, 0), + (label, r)) + self.assertGreater(r["step"], 10 * r["own"], (label, r)) + + label = (rate, channels) + a = MOVE_AT * 256 + room = ((ROOM_I, 50),) + pre = ((PREDELAY_I, 40),) + old = render(DARK)[0] + one = render(DARK + room)[0] + on_line(old, one, render(DARK, {MOVE_AT: room})[0], a, + label + ("one knob",)) + both = render(DARK + room + pre)[0] + paced = render(DARK, {MOVE_AT: room, MOVE_AT + 1: pre})[0] + on_line(old, one, paced, a, label + ("paced, first",), + post=False) + first = render(DARK, {MOVE_AT: room})[0] + on_line(first, both, paced, a + 256, + label + ("paced, second",)) + same = render(DARK, {MOVE_AT: ((ROOM_I, 1),)})[0] + self.assertEqual(digest(same), digest(old), + label + ("the room already loaded",)) + for name, start, changes in TWO_CHANGES: + changes = changes + HOLD_MIX + jumps(render(start)[0], render(start + changes)[0], + render(start, {MOVE_AT: changes})[0], a, + label + (name,)) + # The under-run: the move twenty pulls on, and the same move + # after a reset() that follows the under-run. + p0, p127 = ((PREDELAY_I, 0),), ((PREDELAY_I, 127),) + old, sizes = render(DARK + p0, plan=UNDERRUN) + new = render(DARK + p127, plan=UNDERRUN)[0] + moved = render(DARK + p0, {20: p127}, UNDERRUN)[0] + u = sum(sizes[:20]) + jumps(old, new, moved, u, label + ("under-run",)) + self.assertEqual(sizes[3], 255, label) + # The block in flight is the one frame left of the node's + # block; from its end the output is the new room's. + self.assertEqual(digest(moved[u + 1:]), digest(new[u + 1:]), + label) + # An empty buffer at a block edge is not part-way through + # one: the node plays 256 frames of silence and no phase + # moves, and a move made before that pull fades, on the + # line, over the block the node plays next. + edge = {11: 0} + old, sizes = render(DARK, plan=edge) + self.assertEqual(sizes, [256] * 40, label) + self.assertEqual(int(np.max(np.abs(old[a:a + 256]))), 0, + label) + on_line(old, render(DARK + room, plan=edge)[0], + render(DARK, {MOVE_AT: room}, edge)[0], a + 256, + label + ("an empty buffer at a block edge",)) + again = (("reset", None),) + DARK + HOLD_MIX + p0 + old, sizes = render(DARK + p0, {12: again}, UNDERRUN) + new = render(DARK + p0, {12: again + p127}, UNDERRUN)[0] + moved = render(DARK + p0, {12: again, 24: p127}, + UNDERRUN)[0] + on_line(old, new, moved, sum(sizes[:24]), + label + ("under-run, reset()",)) + + def test_no_dry_frame_drops_after_any_number_of_changes(self, cls=None): + # "No frame of your dry signal drops or repeats, at any Mix and + # after any number of moves: at Mix 0 the output is byte for byte + # what it would have been with no move", across every pair of + # changes the rule names (Mix put back to 0 after a patch), across + # moves after the source ran dry 100 and 255 frames into a block, + # and across a move made before a pull the source leaves empty at + # a block edge. Where the source never leaves a block short that is + # the source delayed by `latency_samples`, and the test wants that + # too. ResetOnMove is red. + self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[0])) + wire_back = ((MIX_I, 0),) + for rate in RATES: + for channels in (2, 1): + frames = 40 * 256 + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( + np.int16) + pcm = np.repeat(pcm[:, None], channels, axis=1) + want = np.vstack([silence(LATENCY, channels), pcm]) + # (name, source plan, moves, whether the output is the + # source delayed by `latency_samples`: not where the node + # plays a starved pull as silence) + cases = [(name, None, {MOVE_AT: changes + wire_back}, True) + for name, _, changes in TWO_CHANGES] + for p in (100, 255): + cases.append(("under-run %d" % p, {4: p, 5: 0}, { + 20: ((ROOM_I, 50),), + 21: ((PREDELAY_I, 40), (DECAY_I, 30))}, True)) + cases.append(("empty at a block edge", {11: 0}, + {MOVE_AT: ((ROOM_I, 50),)}, False)) + for name, plan, actions, delayed in cases: + label = (rate, channels, name) + out = dry_render(cls, rate, channels, (), pcm, plan, + actions, mix=0.0)[0] + still = dry_render(cls, rate, channels, (), pcm, plan, + mix=0.0)[0] + self.assertEqual(digest(out), digest(still), label) + if delayed: + self.assertEqual(digest(out), + digest(want[:len(out)]), label) + + def test_a_mix_move_leaves_the_block_in_flight_at_the_old_mix( + self, cls=None): + # "A Mix move ... acts on the audio entering the node after it, so + # the block already in flight, at most 256 frames, comes out at the + # old Mix": Mix 0 -> 2 twenty pulls in. + # The frames after the move at the old Mix are never more than the + # printed count, and from the first one at the new Mix the output + # is an instance that always had it; from a steady source the count + # is the printed one exactly, and after the source ran dry 100 + # frames into a block it is 156. MixOnePullLate (512) is red, and + # so are the cf17a88 words, which said the 256 frames in flight + # come out at the old Mix. + found = MIX_LATE_RE.search(" ".join(rebuilt.__doc__.split())) + self.assertIsNotNone(found, "no Mix sentence with its frames") + most = int(found.group(1)) + for rate in RATES: + for channels in (2, 1): + pcm = white(40 * 256, channels, -6.0, seed=4243) + for plan, frames in ((None, most), ({4: 100, 5: 0}, 156)): + old, sizes = dry_render(cls, rate, channels, (), pcm, + plan, mix=0.0) + new = dry_render(cls, rate, channels, (), pcm, plan, + mix=2.0)[0] + moved = dry_render(cls, rate, channels, (), pcm, plan, + {20: ((MIX_I, 127),)}, mix=0.0)[0] + a = sum(sizes[:20]) + self.assertEqual(digest(moved[:a]), digest(old[:a])) + off = np.nonzero(np.any(moved[a:] != old[a:], axis=1))[0] + late = int(off[0]) + label = (rate, channels, plan, late) + self.assertLessEqual(late, most, label) + self.assertEqual(late, frames, label) + self.assertEqual(digest(moved[a + late:]), + digest(new[a + late:]), label) + + def test_reset_silences_the_next_256_frames(self, cls=None): + # "`reset()` in the middle of a stream empties the room: the next + # 256 frames come out as exact zero, dry included": at Mix 0, 1.2 + # and 2, from a steady source and after one that ran dry 100 frames + # into a block, the 256 frames after a reset() twenty pulls in are + # exact zero and the frames either side of them are not. NoReset + # (a reset that keeps the history) is red. + self.assertTrue(said(rebuilt.__doc__, RESET_WORDS)) + for rate in RATES: + for channels in (2, 1): + pcm = white(40 * 256, channels, -6.0, seed=4244) + for plan in (None, {4: 100, 5: 0}): + for midi in (0, 76, 127): + out, sizes = dry_render( + cls, rate, channels, ((MIX_I, midi),), pcm, plan, + {20: (("reset", None), (MIX_I, midi))}, mix=0.0) + a = sum(sizes[:20]) + label = (rate, channels, plan, midi) + self.assertEqual( + int(np.max(np.abs(out[a:a + 256]))), 0, label) + self.assertGreater( + int(np.max(np.abs(out[a - 256:a]))), 0, label) + self.assertGreater( + int(np.max(np.abs(out[a + 256:a + 512]))), 0, + label) # -------------------------------------------------------------------------- From 82419c018ba5ac2e97c94c78f5510e2dcb0317f0 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:10:10 -0500 Subject: [PATCH 64/88] The audiodsp floor moves to v0.6.3rc3 AUDIODSP_PIN names v0.6.3rc3 (b386248), over rc1: audiodsp#165 (the convolution node keeps the block in flight across a re-synthesis, #163, and normalises each side of a stereo room on its own, #164), audiodsp#172 (the tank's tilt pole tracks at tone_db 0, #168; set(delays=, taps=) re-cuts a playing node, #169) and audiodsp#173 (a cross-fed stereo FeedbackDelay tail reaches exact zero, #170). Nothing on this branch pinned the old behaviour. The unit suite: Ran 1894 tests, FAILED (failures=1, skipped=2); the one failure is the provenance test refusing bin/micropython, an rc1 build, and it passes pointed at bin/micropython-063rc3. The census (336 cells) moves the 8 library ConvolutionReverb cells on CPython, MicroPython and CircuitPython, from #164, and nothing else; the smoke moves ConvolutionReverb's 7 patch peaks. validate_api 55 / 45; metadata valid; flake8 clean; smoke 45 classes, 259 patches, 0 failures on CPython and both rc3 binaries. --- AUDIODSP_PIN | 24 +++++++++++++++++++++++- CHANGELOG.md | 8 ++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 0b4799b..b416335 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -232,5 +232,27 @@ # patches, 0 failures on CPython, MicroPython and CircuitPython. Pending # both boards. # +# 2026-09-29: v0.6.3rc1 -> v0.6.3rc3 (b386248), the next pre-release of the +# same patch, under the same ruling; TestPyPI only, as rc1. What moved +# beneath the components, by `git log v0.6.3rc1..v0.6.3rc3 -- src`: +# audiodsp#165, audioconvolve.Convolver keeps the block in flight across a +# re-synthesis (#163) and normalises each side of a synthesized stereo room +# on its own (#164); audiodsp#172, audioverb.Tank's tilt pole keeps tracking +# at tone_db 0 (#168) and set(delays=, taps=) re-cuts a playing node in place +# (#169); audiodsp#173, a cross-fed stereo audioecho.FeedbackDelay tail +# reaches exact zero (#170). Nothing on this branch pinned the old +# behaviour: the unit suite `Ran 1894 tests ... FAILED (failures=1, +# skipped=2)`, the one failure the provenance test refusing bin/micropython +# (an rc1 build; it passes pointed at bin/micropython-063rc3). The census +# (336 cells, three interpreters) moves exactly the 8 library +# ConvolutionReverb cells, from #164, to the digests audiodsp#165's own +# census printed; no delay class and no Reverb cell moves. The smoke moves +# ConvolutionReverb's 7 patch peaks and nothing else. Desktop interpreters +# built clean at the tag beside the live pair (bin/*-063rc3; +# audioecho.__revision__ v0.0.3-305-gb386248 on MicroPython, the provenance +# stamp on CircuitPython). validate_api 55 instruments and 45 effects; +# validate_metadata valid; flake8 clean; the smoke 45 classes, 259 patches, +# 0 failures on CPython and both rc3 binaries. Pending both boards. +# # -- ref is what CI installs (git+https://...@) -v0.6.3rc1 bd7d2e219208731cc100a1e8210ec72f8cec9af1 +v0.6.3rc3 b386248518b381d62bd3360224b02f9a9ef9bf37 diff --git a/CHANGELOG.md b/CHANGELOG.md index a183408..c3f2e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,14 @@ there, and are recorded in its changelog. ### Changed +- **The audiodsp pin moves to v0.6.3rc3.** The release carries the + convolution node's two fixes (audiodsp#165: the block in flight kept + across a re-synthesis, each side of a stereo room normalised on its own), + the reverb tank's two (audiodsp#172: the tilt keeps tracking at Tone 0, + `set(delays=, taps=)` re-cuts a playing node) and `FeedbackDelay`'s + stereo cross-feed stall (audiodsp#173). `ConvolutionReverb`'s default + and its seven patches render differently (the stereo room no longer + leans); no other class's render moved (the census, three interpreters). - **The audiodsp pin moves to v0.6.3rc1, and three workarounds come out.** The release carries audiodsp#161: `audioecho.FeedbackDelay` keeps an out loop filter's state live (#158, #159), lands a stalled damping state so a From e083810aafd404ad377af7e23f9b5b233428c474 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:19:24 -0500 Subject: [PATCH 65/88] The lifecycle matrix: one mechanical test of reset, reset_buffer, deinit, macro stops, Mix 0 and back, patch changes, a source that runs dry or ends, odd source buffers, moves before the first pull and several moves before one pull, judged by P1-P6 on three interpreters, with a planted fault for every property --- tests/support/LIFECYCLE.md | 118 ++++ tests/support/lifecycle.py | 1210 ++++++++++++++++++++++++++++++++ tests/test_lifecycle_matrix.py | 290 ++++++++ 3 files changed, 1618 insertions(+) create mode 100644 tests/support/LIFECYCLE.md create mode 100644 tests/support/lifecycle.py create mode 100644 tests/test_lifecycle_matrix.py diff --git a/tests/support/LIFECYCLE.md b/tests/support/LIFECYCLE.md new file mode 100644 index 0000000..8142939 --- /dev/null +++ b/tests/support/LIFECYCLE.md @@ -0,0 +1,118 @@ +# The lifecycle matrix + +`lifecycle.py` runs one fixed set of lifecycle events on an effect class and +judges every cell by the same properties, with no model in the loop. It +replaces hunting for the same defects class by class: audio lost across a +reset, a stale block when a control comes back, a tail that outlasts +`tail_samples`, a source that runs dry, source buffers that are not 256 +frames, two moves before one pull. + +To run it on one class (CPython, or a native binary with +`MICROPYPATH=lib:tests/support`), from the repo root: + + python tests/support/lifecycle.py DigitalDelay + python tests/support/lifecycle.py DigitalDelay --events=E1,E5 + python tests/support/lifecycle.py MultiTapDelay --quick + +It prints one line per cell, `class|event|rate|channels|patch|P1:..|...|h:..`, +and a `SUMMARY` line per class: cells run, cells with any red property, +declared exceptions. `tests/test_lifecycle_matrix.py` runs it on every Phase 5 +class on the branch, on CPython and both native interpreters, and holds the +red cells to its `KNOWN_RED` table. + +## The grid + +Every event runs at 48 000 and 22 050 Hz, stereo and mono, at the +constructor defaults (`d`) and at every shipped patch. `--quick` keeps the +full event list at the defaults and runs the patch configurations on the +events that do not multiply by the macro count (no E4, E10-macros or E11, +and E6 only to patch 0 and the last patch). The material is deterministic +and identical on every interpreter: a click train whose clicks each have +their own amplitude (the second channel another), an LCG noise burst, and an +integer triangle for P5. + +## Events + +The move lands before pull 12 (mid-stream), and "and back" comes 2 pulls +later (6 for Mix). + +- **E1** `reset()`, with 256-frame source buffers (on a block boundary) and + 100-frame buffers (part-way through one). +- **E2** the host's `audiocore.reset_buffer(effect.output)`. +- **E3** `deinit()`: the source still renders, every public method and + property raises, a second `deinit()` is harmless. +- **E4** each macro to each stop (0 and 127; and 64 for a bipolar one) and + back. +- **E5** Mix (the class's wet/dry control) to 0 and back. +- **E6** `program_change` to each other patch and back. +- **E8** the source hands back an empty buffer once, part-way through a + block, then carries on (`E8-dry`); the source ends (`E8-end`). A native + source can only run dry by handing back an empty buffer with + `GET_BUFFER_DONE`, so that is what the matrix does, through an + `audioroute.Port` it re-points. +- **E9** source buffers of 256, 100, 512 and 1000 frames and one whole + RawSample: P1 is Mix 0 all along; the other properties use the E5 move. +- **E10** reset, every macro to 127 and back, Mix to 0 and back, a patch + change and back, all before the first pull. +- **E11** two macro moves; a Mix move and a patch change; two patch changes; + three moves; all before one pull, back later. + +"Back" is `program_change(patch)` at a patch, and `set_macro` with the +values `get_macro` read at the defaults (landed exactly where that +round trip is not bit-exact). + +## Properties + +Each is judged against a control instance that never met the event, fed the +same material. A reset returns a class to patch 0, so E1's control is built +at patch 0. + +- **P1** At Mix 0 the output is the source, byte for byte, on time (shifted + by `latency_samples`). Events that do not move Mix take Mix back to 0 in + the same gap; E5 is judged over the stretch it holds Mix at 0. `E8-dry` may + insert silence but may not lose, repeat or reorder a frame; after + `E8-end` the output is the frames the source handed, then silence. +- **P2** Silence in gives silence out: after the event and `tail_samples` + (read after it) + `latency_samples` + one block + the last source buffer, + every sample is exactly 0 for 2048 frames. +- **P3** Nothing plays out of silence: the move away as the input stops, a + wait until the output is exactly silent, the move back; the next four + blocks are exactly 0. A single action is taken in the silence instead. +- **P4** From `tail_samples + latency_samples + one block` after the last + move on, the output is byte-identical to the control's, over 2048 frames + of material. `E8-dry` may converge to the control delayed by up to one + block. +- **P5** No step within one block of the move larger than 1.5 x the larger + of the triangle's own largest step and the control's. Control moves only + (E4, E5, E6, E9, E11): a reset, a deinit and a source fault are cuts by + definition, and E10 has nothing before it. +- **P6** The three interpreters print identical lines for every cell. Each + line carries `h:`, a CRC-32 of the event instance's whole render, so P6 + compares bytes, not only verdicts. +- **PD** E3's surface check. + +A verdict is `ok`, `na(...)` (the property does not apply to that cell, +said why: `tail None`, a whole RawSample, a class that never falls silent), +`RED(...)` with what was measured, or `decl`. + +## Declaring an exception + +A class that cannot satisfy a property by design (a glide, a reverb tail, a +free-running LFO) adds a row to `DECLARED` in `lifecycle.py`: + + DECLARED = { + ("TapeDelay", "E1-", "P4"): "wow and flutter run free across a reset", + } + +The key is (class `NAME`, event name prefix, property); the cell then prints +`decl` and counts as a declared exception. Nothing else skips a cell. The +test file's `KNOWN_RED` is different: it records what is red today, as +findings, so the suite goes red when a cell changes either way. + +## The plants + +The helper ships a control and a planted fault for every property, and +`TestPlants` holds each plant red on its cell with its control green there: +`DropOnReset` (P1), `HoldDC` (P2), `StaleMix` against `Routed` (P3), +`LateMove` (P4), `HardSwitch` (P5), `BusyDeinit` (PD) and `ImplPlant` +(P6, a level a hair different off CPython). diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py new file mode 100644 index 0000000..427bacb --- /dev/null +++ b/tests/support/lifecycle.py @@ -0,0 +1,1210 @@ +"""The shared lifecycle matrix for audioeffects classes. + +One fixed set of lifecycle EVENTS (reset, a host reset of the output, +deinit, macro stops, Mix to 0 and back, patch changes, a source that runs +dry or ends, odd source buffer sizes, moves before the first pull, several +moves before one pull), run the same way on every class, each cell judged by +the same PROPERTIES against a CONTROL instance that never met the event. No +model is in the loop: a cell is green or red by arithmetic on the bytes. + +It is numpy-free and runs unchanged on CPython, MicroPython and +CircuitPython, so the three interpreters can be held to printing the same +line for every cell (P6, judged by `tests/test_lifecycle_matrix.py`). + + python tests/support/lifecycle.py DigitalDelay [--events E1,E5] [--quick] + +`LIFECYCLE.md` beside this file says what each event and property is and +how a class declares an exception. `run_class()` is the entry point; the +planted faults at the bottom are the matrix's own proof that each property +can fail. +""" + +import sys + +if __name__ == "__main__": + sys.path.insert(0, "lib") + +import binascii + +try: + from array import array +except ImportError: # pragma: no cover + array = None + +import audiocore +import audiofilters +import audiomixer + +try: + from audioroute import Port +except ImportError: # pragma: no cover + Port = None + +try: + from audioeffects import _component +except ImportError: # pragma: no cover + _component = None + +#: `_component` reads VENDOR off the module a class is defined in, and the +#: planted classes are defined here. +VENDOR = "PyDevices" + +BLOCK = 256 +RATES = (48000, 22050) +CHANNELS = (2, 1) +#: The pull the mid-stream event lands before, and how many pulls later the +#: move comes back. +K0 = 12 +GAP = 2 +#: Frames compared (P4) or held to zero (P2) past each bound. +MARGIN = 2048 +#: P5's factor, as a fraction: 3/2 = 1.5. +STEP_NUM = 3 +STEP_DEN = 2 +#: Frames of silence P3 waits for when a class reports no tail. +NO_TAIL_WAIT = 10 * 48000 + +#: The class's wet/dry control, by macro label. A class not named here uses +#: the macro labelled "Mix". +MIX_INDEX = { + "SlapbackDelay": 1, # "Level": the slap's level; 0 is the dry +} + +#: Where a class cannot satisfy a property by design, it says so HERE, one +#: row per (class NAME, event name prefix, property): reason. A matching +#: cell prints `decl` instead of RED and is counted as a declared exception. +#: Nothing else skips a cell. Empty on purpose: no class has declared one. +DECLARED = { +} + +SOURCE_KINDS = ("256", "100", "512", "1000", "raw") + + +# -------------------------------------------------------------------------- +# Material + + +def _lcg(state): + return (1103515245 * state + 12345) & 0x7FFFFFFF + + +def click_noise(frames, channels, spacing=64, seed=12345): + """Clicks every `spacing` frames, each at its own amplitude (and the + second channel at a different one), with an LCG noise burst in the + middle eighth: a frame lost, repeated or swapped is visible.""" + values = array("h", bytes(2 * frames * channels)) + state = seed + for frame in range(3, frames, spacing): + state = _lcg(state) + amp = 2000 + (state >> 8) % 24000 + if (state >> 4) & 1: + amp = -amp + values[frame * channels] = amp + if channels == 2: + values[frame * channels + 1] = -(amp // 2) - 7 + lo = frames // 2 + for frame in range(lo, lo + frames // 8): + for channel in range(channels): + state = _lcg(state) + values[frame * channels + channel] = ((state >> 8) % 16001) - 8000 + return values + + +def triangle(frames, channels, rate, amp=12000, hz=100): + """An integer triangle: the same bytes on every interpreter, and its + largest step is small (4 * amp * hz / rate).""" + values = array("h", bytes(2 * frames * channels)) + period = rate // hz + for frame in range(frames): + phase = (frame % period) * 4 * amp // period + if phase < amp: + value = phase + elif phase < 3 * amp: + value = 2 * amp - phase + else: + value = phase - 4 * amp + for channel in range(channels): + values[frame * channels + channel] = value + return values + + +def _raw(values, rate, channels): + return audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + + +def _adapter(values, rate, channels, frames, loop): + """A RawSample handed out `frames` at a time: the house re-blocker. + With loop False it pads silence after the material, for ever.""" + block = audiofilters.Filter(filter=None, mix=1.0, + buffer_size=frames * channels * 2, + sample_rate=rate, channel_count=channels) + block.play(_raw(values, rate, channels), loop=loop) + return block + + +class Feed: + """The source a class is built around: a Port the host re-points. + + `mat` is the material, `sil` silence, `empty` a sample that hands back + an empty buffer (the only way a native source can run dry). A Port + moves no bytes, so a class sees exactly what the source behind it + hands.""" + + def __init__(self, values, rate, channels, kind, loop): + self.rate = rate + self.channels = channels + if kind == "raw": + self.mat = _raw(values, rate, channels) + else: + self.mat = _adapter(values, rate, channels, int(kind), loop) + self.sil = _adapter(array("h", bytes(4 * channels * BLOCK)), rate, + channels, BLOCK, True) + # MicroPython's RawSample refuses an empty array but takes an + # empty 16-bit memoryview. + self.empty = _raw(memoryview(array("h", [0, 0]))[0:0], rate, + channels) + self.port = Port(self.mat) + self.current = self.mat + self.sample_rate = rate + self.channel_count = channels + + def point(self, node): + self.current = node + self.port.play(node) + + +# -------------------------------------------------------------------------- +# Bytes + + +ZERO = bytes(BLOCK * 2 * 2 * 8) + + +def _is_zero(data): + step = len(ZERO) + for lo in range(0, len(data), step): + chunk = data[lo:lo + step] + if chunk != ZERO[:len(chunk)]: + return False + return True + + +def _samples(data): + out = array("h") + try: + out.frombytes(bytes(data)) + return out + except AttributeError: + pass + import struct + out.extend(struct.unpack("<%dh" % (len(data) // 2), bytes(data))) + return out + + +def _first_diff(a, b, lo, hi): + """First byte index in [lo, hi) where a and b differ, or -1.""" + step = 4096 + pos = lo + while pos < hi: + end = min(hi, pos + step) + if a[pos:end] != b[pos:end]: + for i in range(pos, end): + if a[i] != b[i]: + return i + pos = end + return -1 + + +def _last_diff(a, b, lo, hi): + """Last byte index in [lo, hi) where a and b differ, or -1.""" + step = 4096 + end = hi + while end > lo: + pos = max(lo, end - step) + if a[pos:end] != b[pos:end]: + for i in range(end - 1, pos - 1, -1): + if a[i] != b[i]: + return i + end = pos + return -1 + + +def _max_step(values, channels, lo, hi): + top = 0 + for ch in range(channels): + prev = None + for i in range(lo * channels + ch, hi * channels, channels): + v = values[i] + if prev is not None: + d = v - prev + if d < 0: + d = -d + if d > top: + top = d + prev = v + return top + + +# -------------------------------------------------------------------------- +# The class under test + + +def _starts(text, prefixes): + for prefix in prefixes: + if text.startswith(prefix): + return True + return False + + +def mix_index(cls): + name = cls.NAME + if name in MIX_INDEX: + return MIX_INDEX[name] + labels = cls.MACRO_LABELS + for i in range(len(labels)): + if labels[i] == "Mix": + return i + return None + + +def stops(cls, index): + mode = cls.MACRO_MODES.get(index, "UNIPOLAR") + if mode == "BIPOLAR": + return (0, 64, 127) + return (0, 127) + + +class Ctx: + """What one instance needs to undo a move: the patch it was built at, + or its macros' exact positions at the default.""" + + def __init__(self, e, patch): + self.patch = patch + self.positions = list(e._macros) + self.midi = [e.get_macro(i) for i in range(len(self.positions))] + self.inexact = 0 + + +def restore(e, ctx): + if ctx.patch is not None: + e.program_change(ctx.patch) + return + for i in range(len(ctx.midi)): + e.set_macro(i, ctx.midi[i]) + # set_macro(get_macro()) is not always bit-exact at the constructor's + # unquantised defaults; land exactly, so P4 measures the event and not + # the width of a float, and count it. + for i in range(len(ctx.positions)): + if e._macros[i] != ctx.positions[i]: + ctx.inexact += 1 + e._macros[i] = ctx.positions[i] + e._apply_macro(i, ctx.positions[i]) + + +def act(e, feed, ctx, actions): + for a in actions: + op = a[0] + if op == "reset": + e.reset() + elif op == "rb": + audiocore.reset_buffer(e.output) + elif op == "macro": + e.set_macro(a[1], a[2]) + elif op == "patch": + e.program_change(a[1]) + elif op == "restore": + restore(e, ctx) + elif op == "dry": + feed.port.play(feed.empty) + elif op == "wet": + feed.port.play(feed.current) + elif op == "end": + feed.point(feed.empty) + else: + raise ValueError(op) + + +class Event: + """`away` before pull K0 (or before the first pull when `first`), + `back` GAP pulls later (in the same gap when `first`).""" + + def __init__(self, name, away, back=None, kind="256", moves_mix=False, + control_move=True, post0=False, first=False, gap=GAP): + self.name = name + self.away = away + self.back = back or [] + self.kind = kind + self.moves_mix = moves_mix + self.control_move = control_move + self.post0 = post0 + self.first = first + self.gap = gap + + +def events(cls, patch, quick=False): + """Every event for `cls` at `patch` (None: the constructor defaults).""" + mix = mix_index(cls) + labels = cls.MACRO_LABELS + npatch = len(cls.PATCHES) + full = (not quick) or patch is None + out = [ + Event("E1-reset@block", [("reset",)], kind="256", + control_move=False, post0=True), + Event("E1-reset@part", [("reset",)], kind="100", + control_move=False, post0=True), + Event("E2-reset_buffer", [("rb",)], control_move=False), + ] + if full: + for i in range(len(labels)): + for v in stops(cls, i): + out.append(Event("E4-m%d=%d" % (i, v), [("macro", i, v)], + [("restore",)], moves_mix=(i == mix))) + if mix is not None: + out.append(Event("E5-mix0", [("macro", mix, 0)], [("restore",)], + moves_mix=True, gap=6)) + targets = range(npatch) if full else (0, npatch - 1) + for q in targets: + if q == patch: + continue + out.append(Event("E6-p%d" % q, [("patch", q)], [("restore",)], + moves_mix=True)) + out.append(Event("E8-dry", [("dry",)], [("wet",)], kind="100", + control_move=False, gap=1)) + out.append(Event("E8-end", [("end",)], kind="100", control_move=False)) + for kind in SOURCE_KINDS: + if mix is not None: + out.append(Event("E9-src%s" % kind, [("macro", mix, 0)], + [("restore",)], kind=kind, moves_mix=True, + gap=6)) + out.append(Event("E10-reset", [("reset",)], control_move=False, + post0=True, first=True)) + if full: + out.append(Event("E10-macros", + [("macro", i, 127) for i in range(len(labels))], + [("restore",)], moves_mix=True, first=True)) + if mix is not None: + out.append(Event("E10-mix0", [("macro", mix, 0)], [("restore",)], + moves_mix=True, first=True)) + out.append(Event("E10-patch", [("patch", npatch - 1 if patch != npatch - 1 + else 0)], [("restore",)], + moves_mix=True, first=True)) + if full: + out.append(Event("E11-2macro", [("macro", 0, 0), ("macro", 0, 127)], + [("restore",)], moves_mix=(mix == 0))) + if mix is not None: + out.append(Event("E11-mix+patch", + [("macro", mix, 0), ("patch", npatch - 1)], + [("restore",)], moves_mix=True)) + out.append(Event("E11-2patch", [("patch", npatch - 1), ("patch", 0)], + [("restore",)], moves_mix=True)) + out.append(Event("E11-3moves", + [("macro", 0, 0), ("macro", 0, 127), + ("patch", npatch - 1)], + [("restore",)], moves_mix=True)) + return out + + +# -------------------------------------------------------------------------- +# Rendering + + +def _build(cls, feed, rate, patch, options, post0=False): + e = cls(feed.port, sample_rate=rate, **options) + if patch is not None: + e.program_change(patch) + if post0: + e.program_change(0) + return e + + +def _pull(e, out): + """One host pull, appended as the class handed it. An empty pull adds + nothing; the caller counts them.""" + data = bytes(audiocore.get_buffer(e.output)[1]) + out.extend(data) + return len(data) + + +def _pull_to(e, out, nbytes, fsize): + """Pull until `out` holds `nbytes`. 64 empty pulls in a row is a source + that has ended at Mix 0: silence from there, which is what a host + playing it hears.""" + empties = 0 + while len(out) < nbytes: + if _pull(e, out) == 0: + empties += 1 + if empties >= 64: + out.extend(bytes(nbytes - len(out))) + return True + else: + empties = 0 + return False + + +def _tail(e): + t = e.tail_samples + return t + + +class Control: + """A control instance per (kind, material, rate, channels, patch, + post0), rendered lazily and shared by every cell that needs it.""" + + def __init__(self, cls, rate, channels, patch, options, kind, material, + post0): + values = material_for(material, rate, channels) + self.feed = Feed(values, rate, channels, kind, + loop=(material == "loop")) + self.e = _build(cls, self.feed, rate, patch, options, post0) + self.out = bytearray() + self.fsize = 2 * channels + self.ended = False + + def upto(self, frames): + nbytes = frames * self.fsize + if len(self.out) < nbytes and not self.ended: + self.ended = _pull_to(self.e, self.out, nbytes, self.fsize) + return self.out + + def close(self): + self.e.deinit() + + +_MATERIAL = {} + + +def material_for(which, rate, channels): + key = (which, rate, channels) + if key not in _MATERIAL: + if which == "loop": + _MATERIAL[key] = click_noise(2400, channels, spacing=150, + seed=777) + elif which == "once": + _MATERIAL[key] = click_noise(8192, channels) + elif which == "tri": + _MATERIAL[key] = triangle(8192, channels, rate) + else: + raise ValueError(which) + return _MATERIAL[key] + + +def _schedule_run(e, feed, ctx, ev, out, fsize, upto_frames, mixfix=None): + """Pull to `upto_frames`, taking `ev.away` before pull K0 (or before + the first pull) and `ev.back` `ev.gap` pulls later. Returns the frame + positions the actions landed at.""" + at = [] + ctx.lats = [] + k0 = 0 if ev.first else K0 + kb = k0 if ev.first else k0 + ev.gap + k = 0 + nbytes = upto_frames * fsize + empties = 0 + while len(out) < nbytes: + if k == k0: + at.append(len(out) // fsize) + act(e, feed, ctx, ev.away) + if mixfix: + mixfix(e) + ctx.lats.append(e.latency_samples) + if k == kb and ev.back: + if kb != k0: + at.append(len(out) // fsize) + act(e, feed, ctx, ev.back) + if mixfix: + mixfix(e) + if kb != k0: + ctx.lats.append(e.latency_samples) + if _pull(e, out) == 0: + empties += 1 + if empties >= 64: + out.extend(bytes(nbytes - len(out))) + break + else: + empties = 0 + k += 1 + ctx.pulls = k + return at + + +# -------------------------------------------------------------------------- +# The properties + + +def p1(cls, ev, rate, channels, patch, options): + """At Mix 0 the output is the source, byte for byte, on time.""" + mix = mix_index(cls) + if mix is None: + return "na" + if ev.name.startswith("E4-m%d=" % mix) and not ev.name.endswith("=0"): + return "na" + if ev.name.startswith("E10-mix"): + return "na" + values = material_for("once", rate, channels) + feed = Feed(values, rate, channels, ev.kind, loop=False) + e = _build(cls, feed, rate, patch, options, False) + ctx = Ctx(e, patch) + fsize = 2 * channels + if _starts(ev.name, ("E5", "E4-m%d=0" % mix)): + # The event itself takes Mix to 0: judge the stretch it holds it. + mixfix = None + window = None + else: + e.set_macro(mix, 0) + + def mixfix(effect): + effect.set_macro(mix, 0) + window = "all" + if ev.name.startswith("E9"): + # E9's P1 is the baseline: Mix 0 from the start, no move, this + # source shape. + ev = Event(ev.name, [], kind=ev.kind) + out = bytearray() + total = (K0 + 8 + 8) * BLOCK + try: + at = _schedule_run(e, feed, ctx, ev, out, fsize, total, mixfix) + lat = e.latency_samples + finally: + e.deinit() + expect = bytes(values) + if ev.kind == "raw": + total = min(total, len(values) // channels) + if window == "all": + lo, hi = 0, total + else: + if not at: + return "na" + lo = at[0] + hi = at[1] if len(at) > 1 else total + if ev.first: + return "na" + # Mix 0's own latency, read while the event holds it there. + lat = ctx.lats[0] + shifted = bytes(lat * fsize) + expect + shifted = shifted + bytes(max(0, total * fsize - len(shifted))) + d = _first_diff(out, shifted, lo * fsize, hi * fsize) + if d < 0: + return "ok" + frame = d // fsize + if ev.name.startswith("E8-end") and at and frame >= at[0]: + # The source's frames up to where it ended (it may have handed + # some past the pull it ended at), then silence. + if _is_zero(out[d:hi * fsize]): + return "ok" + if ev.name.startswith("E8-dry"): + # A starve may play as silence: zeros inserted, then the source + # again from where it stopped, with nothing lost or repeated. + z = frame + while z < hi and out[z * fsize:(z + 1) * fsize] == bytes(fsize): + z += 1 + gap = z - frame + if gap and _first_diff(out[z * fsize:], shifted[frame * fsize:], 0, + (hi - z) * fsize) < 0: + return "ok" + return "RED(first wrong frame %d%s)" % (frame - lo, _diagnose( + out, shifted, frame, fsize, hi)) + + +def _diagnose(out, expect, frame, fsize, hi): + """Where the wrong frame's run came from in the source.""" + want = out[frame * fsize:(frame + 16) * fsize] + if want == bytes(len(want)): + return ", silence" + for off in range(1, 4097): + for sign in (1, -1): + src = frame + sign * off + if src < 0: + continue + if expect[src * fsize:(src + 16) * fsize] == want: + if sign > 0: + return ", %d frames of source lost" % off + return ", %d frames of source repeated" % off + return ", not the source near there" + + +def _decl(cls, ev, prop): + for (name, prefix, p), reason in DECLARED.items(): + if name == cls.NAME and p == prop and ev.name.startswith(prefix): + return True + return False + + +def main_render(cls, ev, rate, channels, patch, options, controls): + """P2, P3, P4 from one instance, against a shared control.""" + fsize = 2 * channels + res = {} + values = material_for("loop", rate, channels) + feed = Feed(values, rate, channels, ev.kind, loop=True) + e = _build(cls, feed, rate, patch, options, False) + ctx = Ctx(e, patch) + ctx.cls = cls + ctx.options = options + # The control is rendered in step with the instance, pull for pull, + # and compared as it goes: nothing tail-long is kept. + cfeed = Feed(values, rate, channels, ev.kind, loop=True) + c = _build(cls, cfeed, rate, patch, options, ev.post0) + try: + out = bytearray() + at = _schedule_run(e, feed, ctx, ev, out, fsize, + ((0 if ev.first else K0) + ev.gap + 1) * BLOCK) + cout = bytearray() + for k in range(ctx.pulls): + if k == K0 and ev.name.startswith("E8-end"): + # The control's source goes silent where the instance's + # ended, and stays silent. + cfeed.point(cfeed.sil) + _pull(c, cout) + last = at[-1] if at else 0 + tail = _tail(e) + lat = e.latency_samples + if tail is None: + res["P4"] = "na(tail None)" + bound = last + BLOCK + lat + else: + bound = last + tail + lat + BLOCK + stop = bound + MARGIN + verdict, crc, s0 = _p4_stream(e, c, out, cout, last, bound, stop, + fsize, ev.name.startswith("E8-dry")) + if "P4" not in res: + res["P4"] = verdict + out = None + # P2: silence in, silence out after the tail. + feed.point(feed.sil) + tail = _tail(e) + if ev.kind == "raw": + # A whole RawSample is one buffer the class may still be + # playing through: the input is not silent until it has. + res["P2"] = "na(whole RawSample)" + wait = (tail or NO_TAIL_WAIT) + lat + BLOCK + MARGIN + elif tail is None: + res["P2"] = "na(tail None)" + wait = NO_TAIL_WAIT + else: + # Silence starts once the class has used the source buffer it + # was handed last. + b2 = s0 + tail + lat + BLOCK + int(ev.kind) + # Streamed: only the frames P2 reads are kept. + crc, left = _stream(e, (b2 - s0) * fsize, crc) + out = bytearray(left) + _pull_to(e, out, MARGIN * fsize, fsize) + out = out[:MARGIN * fsize] + crc = binascii.crc32(bytes(out), crc) + res["P2"] = _zero_after(out, 0, MARGIN, fsize, s0 - b2) + wait = tail + lat + BLOCK + MARGIN + res["h"] = "%08x" % (crc & 0xFFFFFFFF) + # P3: an event during silence plays nothing. Not for a whole + # RawSample, whose one buffer the class may hold part of. + if ev.kind == "raw": + res["P3"] = "na(whole RawSample)" + else: + res["P3"] = _p3(e, feed, ctx, ev, fsize, wait) + finally: + e.deinit() + c.deinit() + return res + + +def _pull_some(x, fsize): + """One pull that hands back bytes: 64 empty pulls in a row are a block + of silence.""" + for _ in range(64): + data = bytes(audiocore.get_buffer(x.output)[1]) + if data: + return data + return bytes(BLOCK * fsize) + + +def _p4_stream(e, c, out, cout, last, bound, stop, fsize, shifted): + """P4, streamed: the instance's and the control's bytes compared as + they are pulled, from the last move to `stop`. Returns the verdict, + the CRC of every byte the instance handed, and its frame count.""" + crc = binascii.crc32(bytes(out)) + base = 0 + lo = last * fsize + hi = stop * fsize + lastdiff = -1 + win_e = bytearray() + win_c = bytearray() + lo_e = bound * fsize + lo_c = (bound - BLOCK) * fsize + while base < hi: + if not out: + data = _pull_some(e, fsize) + crc = binascii.crc32(data, crc) + out = bytearray(data) + if not cout: + cout = bytearray(_pull_some(c, fsize)) + n = min(len(out), len(cout), hi - base) + a = out[:n] + b = cout[:n] + if base + n > lo and a != b: + d = _last_diff(a, b, max(0, lo - base), n) + if d >= 0: + lastdiff = base + d + if shifted: + if base + n > lo_e: + win_e.extend(a[max(0, lo_e - base):]) + if base + n > lo_c: + win_c.extend(b[max(0, lo_c - base):]) + out = out[n:] + cout = cout[n:] + base += n + s0 = (base + len(out)) // fsize + if lastdiff < 0 or lastdiff // fsize < bound: + return "ok", crc, s0 + if shifted: + for s in range(1, BLOCK + 1): + off = (BLOCK - s) * fsize + if win_c[off:off + len(win_e)] == win_e: + return "ok(shift %d)" % s, crc, s0 + return ("RED(differs at +%d, bound +%d)" % (lastdiff // fsize - last, + bound - last), crc, s0) + + +def _stream(e, nbytes, crc): + """Pull `nbytes` without keeping them, folded into `crc`. Returns the + crc and the bytes the last pull handed past `nbytes`.""" + got = 0 + empties = 0 + while got < nbytes: + data = bytes(audiocore.get_buffer(e.output)[1]) + if not data: + empties += 1 + if empties >= 64: + data = bytes(nbytes - got) + else: + continue + empties = 0 + take = min(len(data), nbytes - got) + crc = binascii.crc32(data[:take], crc) + got += take + if take < len(data): + return crc, data[take:] + return crc, b"" + + +def _zero_after(out, lo, hi, fsize, s0): + seg = out[lo * fsize:hi * fsize] + if _is_zero(seg): + return "ok" + vals = _samples(seg) + top = 0 + first = -1 + for i in range(len(vals)): + v = vals[i] + if v: + if first < 0: + first = i + if v < 0: + v = -v + if v > top: + top = v + return "RED(peak %d at +%d after input end, bound +%d)" % ( + top, lo - s0 + first // (fsize // 2), lo - s0) + + +def _p3(e, feed, ctx, ev, fsize, wait): + """Ring on the material, take the move away as the input stops, wait + for the output to reach exact silence, take the move back: the four + blocks after it are exactly zero. A single action is taken in the + silence instead.""" + out = bytearray() + if ev.first: + if not ev.back: + return "na" + # The first-pull variant: the move away before anything is pulled, + # a fresh instance, the material through it, then silence. + e = _build(ctx.cls, feed, feed.rate, ctx.patch, ctx.options, False) + ctx = Ctx(e, ctx.patch) + act(e, feed, ctx, ev.away) + feed.point(feed.mat) + try: + return _p3_body(e, feed, ctx, ev, fsize, wait, out) + finally: + if ev.first: + e.deinit() + + +def _p3_body(e, feed, ctx, ev, fsize, wait, out): + _pull_to(e, out, 8 * BLOCK * fsize, fsize) + feed.point(feed.sil) + if ev.back and not ev.first: + act(e, feed, ctx, ev.away) + blk = BLOCK * fsize + # Wait the whole tail, not only for a quiet stretch: a delay is quiet + # between the input and its first repeat. + tail = _tail(e) + hold = BLOCK if ev.kind == "raw" else int(ev.kind) + if tail is None: + need = max(wait, NO_TAIL_WAIT) + else: + need = max(wait, tail + e.latency_samples + BLOCK + hold + MARGIN) + _crc, left = _stream(e, (need - 8 * BLOCK) * fsize, 0) + seg = bytearray(left) + _pull_to(e, seg, 8 * blk, fsize) + if not _is_zero(seg): + return "na(never silent)" + if ev.back: + act(e, feed, ctx, ev.back) + else: + act(e, feed, ctx, ev.away) + seg = bytearray() + _pull_to(e, seg, 4 * blk, fsize) + if _is_zero(seg): + return "ok" + vals = _samples(seg) + top = 0 + first = -1 + for i in range(len(vals)): + v = vals[i] + if v: + if first < 0: + first = i + if v < 0: + v = -v + if v > top: + top = v + return "RED(peak %d from silence, first at +%d)" % ( + top, first // (fsize // 2)) + + +def p5(cls, ev, rate, channels, patch, options, controls): + """No step at the move larger than 1.5 x the larger of the material's + and the control's own largest step, on a triangle.""" + if not ev.control_move or ev.first: + return "na" + fsize = 2 * channels + values = material_for("tri", rate, channels) + key = ("tri", ev.kind, rate, channels, patch) + ctl = controls.get(key) + if ctl is None: + ctl = Control(cls, rate, channels, patch, options, ev.kind, "tri", + False) + controls[key] = ctl + total = (K0 + ev.gap + 6) * BLOCK + feed = Feed(values, rate, channels, ev.kind, loop=False) + e = _build(cls, feed, rate, patch, options, False) + ctx = Ctx(e, patch) + out = bytearray() + try: + at = _schedule_run(e, feed, ctx, ev, out, fsize, total) + finally: + e.deinit() + ref = _samples(bytes(ctl.upto(total)[:total * fsize])) + got = _samples(bytes(out[:total * fsize])) + own = _max_step(values, channels, 0, total) + base = max(own, _max_step(ref, channels, 0, total)) + limit = base * STEP_NUM // STEP_DEN + worst = 0 + where = 0 + for a in at: + lo = max(0, a - 1) + hi = min(total, a + BLOCK + 64) + s = _max_step(got, channels, lo, hi) + if s > worst: + worst = s + where = a + if worst <= limit: + return "ok" + return "RED(step %d at %d, limit %d)" % (worst, where, limit) + + +def e3(cls, rate, channels, patch, options): + """deinit(): the source still renders, the surface raises, a second + deinit is harmless.""" + fsize = 2 * channels + values = material_for("loop", rate, channels) + feed = Feed(values, rate, channels, "256", loop=True) + e = _build(cls, feed, rate, patch, options, False) + out = bytearray() + _pull_to(e, out, K0 * BLOCK * fsize, fsize) + e.deinit() + res = {} + try: + got = bytes(audiocore.get_buffer(feed.port)[1]) + res["P1"] = "ok" if len(got) == BLOCK * fsize else ( + "RED(source handed %d bytes)" % len(got)) + except Exception as err: # noqa: BLE001 + res["P1"] = "RED(source: %s)" % type(err).__name__ + quiet = [] + for name in sorted(dir(type(e))): + if name.startswith("_") or name.upper() == name or name in ( + "deinit", "create"): + continue + try: + value = getattr(e, name) + if callable(value): + args = {"set_macro": (0, 64), "get_macro": (0,), + "program_change": (0,), "macro": (0,), + "pitch_bend": (8192,), "control_change": (1, 64), + "channel_pressure": (64,), + "poly_pressure": (60, 64)}.get(name, ()) + value(*args) + quiet.append(name) + except Exception: # noqa: BLE001 + pass + try: + e.deinit() + second = "" + except Exception as err: # noqa: BLE001 + second = "; second deinit raised %s" % type(err).__name__ + if quiet or second: + res["PD"] = "RED(no raise after deinit: %s%s)" % ( + ",".join(quiet), second) + else: + res["PD"] = "ok" + return res + + +PROPS = ("P1", "P2", "P3", "P4", "P5") + + +def run_cell(cls, ev, rate, channels, patch, options, controls): + res = {} + res["P1"] = p1(cls, ev, rate, channels, patch, options) + res.update(main_render(cls, ev, rate, channels, patch, options, + controls)) + res["P5"] = p5(cls, ev, rate, channels, patch, options, controls) + for prop in PROPS: + if res[prop].startswith("RED") and _decl(cls, ev, prop): + res[prop] = "decl" + return res + + +def fmt(name, event, rate, channels, patch, res): + parts = [name, event, str(rate), str(channels), + "d" if patch is None else str(patch)] + for prop in sorted(res): + parts.append("%s:%s" % (prop, res[prop])) + return "|".join(parts) + + +def run_class(cls, options=None, rates=RATES, channels=CHANNELS, + patches=None, only=None, quick=False, emit=print, name=None): + """Run the matrix on `cls`, emitting one line per cell and a summary + line last. Returns (cells, red, declared).""" + options = options or {} + name = name or cls.NAME + if patches is None: + patches = [None] + sorted(cls.PATCHES) + cells = red = decl = 0 + for rate in rates: + for ch in channels: + for patch in patches: + controls = {} + try: + if only is None or "E3" in only: + res = e3(cls, rate, ch, patch, options) + line = fmt(name, "E3-deinit", rate, ch, patch, res) + emit(line) + cells += 1 + red += 1 if "RED" in line else 0 + for ev in events(cls, patch, quick): + if only is not None and ev.name.split("-")[0] \ + not in only: + continue + res = run_cell(cls, ev, rate, ch, patch, options, + controls) + line = fmt(name, ev.name, rate, ch, patch, res) + emit(line) + cells += 1 + red += 1 if "RED" in line else 0 + decl += line.count(":decl") + finally: + for ctl in controls.values(): + ctl.close() + emit("SUMMARY|%s|cells %d|red %d|declared %d" % (name, cells, red, decl)) + return cells, red, decl + + +# -------------------------------------------------------------------------- +# Planted faults: the matrix's proof that each property can fail. +# +# `Plain` is the control for P1, P2, P4, P5 and PD: one mixer voice whose +# level is the dry/wet law (Mix 0 is level 1.0, the source bit for bit; +# Mix 1 is the Gain macro's level), so nothing is ever routed around. +# `Routed` is the control for P3: Mix 0 routes around to the source and the +# way back rejoins and re-arms, the house pattern. Each plant breaks exactly +# one thing. + +_COMPONENT = _component.Component if _component is not None else object + + +class Plain(_COMPONENT): + NAME = "LifecyclePlain" + TIER = "audiodsp" if _component is None else _component.AUDIODSP + REQUIRES = ("audioroute",) + LATENCY_SAMPLES = 0 + TAIL_SAMPLES = 0 + MACRO_LABELS = ("Mix", "Gain") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (0.0, 1.0)) + PATCHES = {0: ("Unity", (127, 127)), 1: ("Soft", (127, 96))} + + def _build(self, mix=1.0, gain=1.0, patch=None): + pcm = self._pcm(BLOCK * self._channel_count * 2) + self._mixer = self._own(audiomixer.Mixer(voice_count=2, **pcm)) + self._dc = _raw(array("h", bytes(4 * self._channel_count)), + self._sample_rate, self._channel_count) + self._arm() + self._output = self._mixer + self._macros = [mix, gain] + self._init_macros((mix, gain), patch) + + def _arm(self): + self._mixer.voice[0].play(self._source, loop=True) + self._mixer.voice[1].play(self._dc, loop=True) + + def _level(self): + mix = self._macros[0] + return 1.0 - mix + mix * self._macros[1] + + def _apply_macro(self, index, position): + self._mixer.voice[0].level = self._level() + + +class Routed(Plain): + """Mix 0 routes around a 5 ms echo line to the source; the way back + rejoins, clearing the line, and re-arms.""" + NAME = "LifecycleRouted" + REQUIRES = ("audioroute",) + TAIL_SAMPLES = 512 + + def _build(self, mix=1.0, gain=1.0, patch=None): + import audiodelays + pcm = self._pcm(BLOCK * self._channel_count * 2) + self._echo = self._own(audiodelays.Echo( + max_delay_ms=10, delay_ms=5, decay=0.0, mix=1.0, **pcm)) + Plain._build(self, mix, gain, patch) + + def _arm(self): + self._echo.play(self._source, loop=True) + self._mixer.voice[0].play(self._echo, loop=True) + self._mixer.voice[1].play(self._dc, loop=True) + + def _apply_macro(self, index, position): + if index == 0 and position == 0.0: + self._route_around(self._source) + return + if index == 0: + if self._rejoin(): + self._arm() + self._output = self._mixer + self._mixer.voice[0].level = self._macros[1] + + +class DropOnReset(Plain): + """P1: reset() swallows one block of the borrowed source.""" + + def reset(self): + Plain.reset(self) + audiocore.get_buffer(self._source) + + +class HoldDC(Plain): + """P2: after a reset the output sits on a DC offset for ever.""" + + def reset(self): + Plain.reset(self) + ch = self._channel_count + self._dc_on = _raw(array("h", [9] * (2 * ch)), self._sample_rate, ch) + self._mixer.voice[1].play(self._dc_on, loop=True) + + +class StaleMix(Routed): + """P3: Mix back from 0 takes the graph back untouched, so the echo line + plays what it held out of silence (stale_blocks.StaleRejoin).""" + + def _rejoin(self, keep=()): + self._stranded = False + return False + + +class LateMove(Plain): + """P4: a move is answered one move late, so away-and-back leaves the + class where it went.""" + + def _apply_macro(self, index, position): + late = getattr(self, "_late", None) + self._late = self._level() + self._mixer.voice[0].level = self._late if late is None else late + + +class HardSwitch(Plain): + """P5: Mix below half re-points the output to a primed silent mixer, + mid-waveform, a hard switch.""" + + def _build(self, mix=1.0, gain=1.0, patch=None): + pcm = self._pcm(BLOCK * self._channel_count * 2) + self._quiet = self._own(audiomixer.Mixer(voice_count=1, **pcm)) + self._quiet.voice[0].level = 0.0 + Plain._build(self, mix, gain, patch) + + def _arm(self): + Plain._arm(self) + self._quiet.voice[0].play(self._dc, loop=True) + + def _apply_macro(self, index, position): + Plain._apply_macro(self, index, position) + if self._constructing: + return + self._output = self._quiet if self._macros[0] < 0.5 else self._mixer + + +class ImplPlant(Plain): + """P6: the level is a hair different off CPython, so the bytes differ + between interpreters while every verdict stays the same.""" + + def _level(self): + level = Plain._level(self) + if sys.implementation.name != "cpython": + level = level * 0.99 + return level + + +class BusyDeinit(Plain): + """PD: tail_samples still answers after deinit.""" + + @property + def tail_samples(self): + return 0 + + +PLANTS = {"plain": Plain, "routed": Routed, "droponreset": DropOnReset, + "holddc": HoldDC, + "stalemix": StaleMix, "latemove": LateMove, + "hardswitch": HardSwitch, "busydeinit": BusyDeinit, + "implplant": ImplPlant} + + +def _cli(argv): + only = None + quick = False + names = [] + for arg in argv: + if arg.startswith("--events="): + only = arg.split("=", 1)[1].split(",") + elif arg == "--quick": + quick = True + else: + names.append(arg) + for name in names: + if name in PLANTS: + cls = PLANTS[name] + else: + from audioeffects import rebuilt + cls = rebuilt.module_class(name) + if cls is None: + print("MISSING|%s" % name) + continue + run_class(cls, only=only, quick=quick, name=name if name in PLANTS + else None) + print("DONE") + + +if __name__ == "__main__": + # Run as the module `lifecycle`, not `__main__`: `_component` reads + # VENDOR off the module a planted class is defined in. + sys.path.insert(0, "tests/support") + import lifecycle + lifecycle._cli(sys.argv[1:]) diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py new file mode 100644 index 0000000..7e34a88 --- /dev/null +++ b/tests/test_lifecycle_matrix.py @@ -0,0 +1,290 @@ +"""The lifecycle matrix (`tests/support/lifecycle.py`) on every Phase 5 +class this branch carries, on CPython and on the two native interpreters. + +What is red today is written down in KNOWN_RED below, one row per defect, +each with a one-line reason. The file passes while the red cells are exactly +those rows' cells, and goes red when a cell changes either way: a new red +cell nobody listed, or a listed cell that turned green (then delete the +row). A row is a finding, not an exception: a class opts out of a property +only through `lifecycle.DECLARED`. + +The planted faults at the bottom are the matrix's proof that each property +can fail: every plant is red on its cell and its control is green there. + + OMP_NUM_THREADS=1 python -m unittest tests.test_lifecycle_matrix + LIFECYCLE_CLASSES=DigitalDelay python -m unittest tests.test_lifecycle_matrix + +`LIFECYCLE.md` beside the helper says what each event and property is. +""" + +import os +import subprocess +import sys +import tempfile +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "lib")) +sys.path.insert(0, os.path.join(ROOT, "tests", "support")) + +import lifecycle # noqa: E402 +from audioeffects import rebuilt # noqa: E402 + +PHASE5 = ("DigitalDelay", "SlapbackDelay", "TapeDelay", "PingPongDelay", + "MultiTapDelay", "AnalogDelay", "Reverb", "ConvolutionReverb") + +#: (class, event, property, rate, channels, patch) -> reason. `event` may +#: end in `*` (a prefix); rate, channels and patch may be `*`; patch is +#: "d" for the constructor defaults. Every red cell must match a row, and +#: every cell a row matches must be red. +KNOWN_RED = { +} + + +def _match(row, cell): + for want, got in zip(row, cell): + if want == "*": + continue + if isinstance(want, str) and want.endswith("*"): + if not str(got).startswith(want[:-1]): + return False + elif str(want) != str(got): + return False + return True + + +def parse(lines): + """{(class, event, rate, ch, patch): {prop: verdict}} and summaries.""" + cells = {} + for line in lines: + parts = line.split("|") + if len(parts) < 6 or parts[0] in ("SUMMARY", "DONE"): + continue + key = (parts[0], parts[1], int(parts[2]), int(parts[3]), parts[4]) + cells[key] = dict(p.split(":", 1) for p in parts[5:]) + return cells + + +def red_cells(cells): + out = set() + for key, props in cells.items(): + for prop, verdict in props.items(): + if verdict.startswith("RED"): + out.add((key[0], key[1], prop, key[2], key[3], key[4])) + return out + + +def check_known(name, red, cells): + """Problems with `red` against KNOWN_RED, for class `name`.""" + rows = [row for row in KNOWN_RED if row[0] == name] + problems = [] + for cell in sorted(red): + if not any(_match(row, cell) for row in rows): + problems.append("new red: %s" % "|".join(map(str, cell))) + for row in rows: + matched = [c for c in cells_as_props(cells) if _match(row, c)] + if not matched: + problems.append("row matches no cell: %r" % (row,)) + for cell in matched: + if cell not in red: + problems.append("turned green: %s (row %r)" % ( + "|".join(map(str, cell)), row)) + return problems + + +def cells_as_props(cells): + for key, props in cells.items(): + for prop in props: + yield (key[0], key[1], prop, key[2], key[3], key[4]) + + +def classes(): + wanted = os.environ.get("LIFECYCLE_CLASSES") + names = wanted.split(",") if wanted else PHASE5 + return [n for n in names if rebuilt.module_class(n) is not None] + + +def native_binaries(): + """{"micropython": path or None, "circuitpython": path or None}: the + workspace's `bin/`, or LIFECYCLE_MICROPYTHON / LIFECYCLE_CIRCUITPYTHON.""" + found = {} + here = ROOT + bindir = None + for _up in range(5): + here = os.path.dirname(here) + if os.path.isfile(os.path.join(here, "bin", "micropython")): + bindir = os.path.join(here, "bin") + break + for family in ("micropython", "circuitpython"): + path = os.environ.get("LIFECYCLE_" + family.upper()) + if path is None and bindir is not None: + path = os.path.join(bindir, family) + found[family] = path if path and os.path.isfile(path) else None + return found + + +def run_native(binary, names, extra=()): + """The helper's printed lines for `names` under a native binary.""" + with tempfile.TemporaryDirectory() as scratch: + env = dict(os.environ, MICROPYPATH="lib:tests/support", + GCOV_PREFIX=scratch, PYTHONDONTWRITEBYTECODE="1") + done = subprocess.run( + [binary, "-X", "heapsize=256M", "tests/support/lifecycle.py"] + + list(names) + list(extra), + capture_output=True, text=True, cwd=ROOT, env=env) + lines = done.stdout.splitlines() + if done.returncode != 0 or not lines or lines[-1] != "DONE": + raise AssertionError("%s: %s%s" % (binary, done.stdout[-1500:], + done.stderr[-1500:])) + return lines + + +def start_native(binary, names, scratch): + env = dict(os.environ, MICROPYPATH="lib:tests/support", + GCOV_PREFIX=scratch, PYTHONDONTWRITEBYTECODE="1") + return subprocess.Popen( + [binary, "-X", "heapsize=256M", "tests/support/lifecycle.py"] + + list(names), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, cwd=ROOT, env=env) + + +def run_cpython(name): + lines = [] + lifecycle.run_class(rebuilt.module_class(name), emit=lines.append) + return lines + + +class TestMatrix(unittest.TestCase): + """Each class's red cells are exactly KNOWN_RED's, and the three + interpreters print the same line for every cell (P6).""" + + @classmethod + def setUpClass(cls): + cls.names = classes() + cls.natives = native_binaries() + cls.scratch = tempfile.TemporaryDirectory() + cls.procs = {} + for family, binary in cls.natives.items(): + if binary is not None and cls.names: + cls.procs[family] = start_native(binary, cls.names, + cls.scratch.name) + cls.cpython = {} + for name in cls.names: + cls.cpython[name] = run_cpython(name) + cls.native_lines = {} + for family, proc in cls.procs.items(): + out, err = proc.communicate() + lines = out.splitlines() + if proc.returncode != 0 or not lines or lines[-1] != "DONE": + cls.native_lines[family] = AssertionError( + "%s: %s%s" % (family, out[-1500:], err[-1500:])) + else: + cls.native_lines[family] = lines + + @classmethod + def tearDownClass(cls): + cls.scratch.cleanup() + + def test_known_red_on_cpython(self): + problems = [] + for name in self.names: + cells = parse(self.cpython[name]) + problems += check_known(name, red_cells(cells), cells) + self.assertEqual(problems, [], "\n".join(problems[:60])) + + def _p6(self, family): + if self.natives.get(family) is None: + self.skipTest("no %s binary: set LIFECYCLE_%s or build the " + "workspace's bin/%s" % (family, family.upper(), + family)) + got = self.native_lines[family] + if isinstance(got, Exception): + raise got + problems = [] + for name in self.names: + ours = parse(self.cpython[name]) + theirs = parse([l for l in got if l.startswith(name + "|")]) + for key in sorted(set(ours) | set(theirs)): + if ours.get(key) != theirs.get(key): + problems.append("%s: %s cpython %s, %s %s" % ( + family, "|".join(map(str, key)), ours.get(key), + family, theirs.get(key))) + self.assertEqual(problems, [], "\n".join(problems[:60])) + + def test_p6_micropython(self): + self._p6("micropython") + + def test_p6_circuitpython(self): + self._p6("circuitpython") + + +def plant_cell(plant, event, prop, patch=None, rate=48000, channels=2): + lines = [] + lifecycle.run_class(lifecycle.PLANTS[plant], rates=(rate,), + channels=(channels,), patches=[patch], + only=[event.split("-")[0]], emit=lines.append, + name=plant) + cells = parse(lines) + return cells[(plant, event, rate, channels, + "d" if patch is None else str(patch))][prop] + + +class TestPlants(unittest.TestCase): + """Every property can fail: each plant is red on its cell, and its + control is green on the same cell.""" + + CASES = ( + ("droponreset", "plain", "E1-reset@block", "P1"), + ("holddc", "plain", "E1-reset@block", "P2"), + ("stalemix", "routed", "E5-mix0", "P3"), + ("latemove", "plain", "E4-m1=0", "P4"), + ("hardswitch", "plain", "E4-m0=0", "P5"), + ("busydeinit", "plain", "E3-deinit", "PD"), + ) + + def test_each_plant_is_red_and_its_control_green(self): + for plant, control, event, prop in self.CASES: + with self.subTest(plant=plant): + self.assertTrue(plant_cell(plant, event, prop) + .startswith("RED"), plant) + self.assertEqual(plant_cell(control, event, prop), "ok", + control) + + def test_a_declared_exception_prints_decl(self): + lifecycle.DECLARED[("LifecyclePlain", "E1-reset@block", "P1")] = \ + "planted declaration" + try: + got = plant_cell("droponreset", "E1-reset@block", "P1") + finally: + del lifecycle.DECLARED[("LifecyclePlain", "E1-reset@block", + "P1")] + self.assertEqual(got, "decl") + + def test_known_red_catches_both_directions(self): + cells = {("X", "E1", 48000, 2, "d"): {"P1": "RED(x)", "P2": "ok"}} + red = red_cells(cells) + KNOWN_RED[("X", "E1", "P2", "*", "*", "*")] = "planted" + try: + problems = check_known("X", red, cells) + finally: + del KNOWN_RED[("X", "E1", "P2", "*", "*", "*")] + self.assertTrue(any(p.startswith("new red") for p in problems)) + self.assertTrue(any(p.startswith("turned green") for p in problems)) + + def test_p6_can_fail(self): + """A plant whose level depends on the interpreter prints a + different line natively than on CPython.""" + binary = native_binaries()["micropython"] + if binary is None: + self.skipTest("no micropython binary") + extra = ["--events=E4"] + ours = parse(run_native(sys.executable, ["implplant"], extra)) + theirs = parse(run_native(binary, ["implplant"], extra)) + self.assertNotEqual(ours, theirs) + same = parse(run_native(binary, ["plain"], extra)) + self.assertEqual(parse(run_native(sys.executable, ["plain"], extra)), + same) + + +if __name__ == "__main__": + unittest.main() From 13ef3f0f3c29c160e728a5dbb0febd895a1f7bd8 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:22:45 -0500 Subject: [PATCH 66/88] ConvolutionReverb re-audit fix round 2 after the re-audit round 2 at audiodsp v0.6.3rc2: coming up short, the reset, the starved pull and the tail, restated and tested The audit of 8a57282 parked the class on four docstring sentences. Words and tests only; the module's code is identical to 8a57282 and cf17a88 outside its docstrings. - Coming up short is named once, in the latency paragraph: an empty buffer, one shorter than a frame, or an error result. The room-move rule's second condition and the class summary refer to it. New test test_every_short_read_part_way_counts_as_coming_up_short: the byte, error and empty legs at p 100 give 155 frames off the new room and 156 at the old Mix, the 3-frame and 5-byte controls 255 and 256. Red on RetryOnShort (255 where it wants 155) and on the 8a57282 words; RetryOnEmpty is the clean class on the byte and error legs. - The latency paragraph no longer counts one exception: Mix 0 does not hold over the latency_samples frames after a reset(), nor across a pull that comes up short before it has a frame (256 frames of silence, the rest 256 later), and the node takes whole frames only. The dry test now asserts the documented wire in every source case (256-, 100- and 1000-frame calls, starved pulls, an error's frames dropped, a part frame dropped). Red on ResetOnMove, KeepShortReads and the 8a57282 words. - reset() silences latency_samples frames: 256 with a room loaded, none on the empty impulse. The reset test gains the empty impulse, a measured impulse and the Mix 0 wire around the reset. Red on NoReset, ResetSilentOnEmpty and the 8a57282 words. - The tail is counted in frames the source hands; a starved pull holds it and a stopped source stops it. New test test_the_tail_counts_the_frames_the_source_hands (margin +1 in frames handed, 0 on the empty impulse, with 0, 1 or 3 starved pulls). Red on TailTwoShort and the 8a57282 words. - The CHANGELOG entry carries the same limits. Class suite 206 OK, full suite 1975 OK (skipped 3), flake8 and both validators clean; Tier 1 and 60 of 60 digests unchanged on CPython, bin/micropython-063rc2 and bin/circuitpython-063rc2. --- CHANGELOG.md | 10 +- lib/audioeffects/rebuilt/convolutionreverb.py | 39 +- .../test_cpython_effects_convolutionreverb.py | 443 ++++++++++++++++-- 3 files changed, 429 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca9576..14a70c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,10 @@ there, and are recorded in its changelog. raises naming the class, the taps, the rate and the limit in seconds. `latency_samples` reads the node: 256 frames whenever an impulse is loaded, 0 when none is, and Mix 0 is the source delayed by exactly that, - byte for byte. Measured mode loads at unit mean energy and refuses the + byte for byte, except for the frames a mid-stream `reset()` silences + and a pull the source leaves without a single frame, which comes out + as 256 frames of silence and puts what follows 256 frames later. + Measured mode loads at unit mean energy and refuses the synthesis macros with `IndexError`; no impulse ships. It lives in `lib/audioeffects/rebuilt/convolutionreverb.py`, and `audioeffects.ConvolutionReverb` is still the old class. It stands on @@ -109,12 +112,13 @@ there, and are recorded in its changelog. block in flight, with no frame of the dry dropped or repeated at any Mix and the tail ringing on into the new room (a straight-line crossfade when it is the only room change between two pulls and the source has - not run dry part-way through a block since the last `reset()`; + not come up short (an empty buffer, one shorter than a frame, or an + error result) part-way through a block since the last `reset()`; otherwise the output can jump), and each side of a stereo room is unit energy on its own, so the room sits in the middle (the two sides within 0.001 dB on the room's own impulse over the settings walked). The docstring states the low-material lift a dark room gives - and the partition a mid-stream `reset()` drops. Board cost is unmeasured; + and the frames a mid-stream `reset()` silences. Board cost is unmeasured; by the cost table's line the default fits an S3, and anything over 0.091 s there is desktop-only. diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 036ba4c..9ee7318 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -71,13 +71,22 @@ impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is the source delayed by exactly `latency_samples`, byte for byte, because the node stays in the path at Mix 0 and a Mix move never jumps the timeline. -A room-knob move keeps it too; the one exception is the 256 frames after -a `reset()` (below). +A room-knob move keeps it too. It does not hold over the +`latency_samples` frames after a `reset()` (below), nor across a pull in +which the source comes up short (an empty buffer, one shorter than a +frame, or an error result) before the pull has a single frame: that pull +comes out as 256 frames of silence, and everything after it comes out 256 +frames later. The node takes whole frames only: a part frame at the end +of a buffer, and anything an error result carries, never reach it. **Tail.** `tail_samples` is `latency_samples` plus the loaded impulse rounded up to a partition: 4 096 frames (85.3 ms) at the default at -48 kHz, 3 840 at 44.1 kHz, 2 048 at 22.05 kHz. After it the output is -exactly zero. +48 kHz, 3 840 at 44.1 kHz, 2 048 at 22.05 kHz. Counted in the frames the +source hands, the output is exactly zero from more than `tail_samples` +frames after the last non-zero one. Only frames the source hands move the +room on: a pull of silence like the one above holds the tail where it is, +and a source that stops handing frames stops the tail with it, until it +hands frames again. **RAM.** 141 800 B at the default at 48 kHz with a stereo room (8 224 B a partition plus 18 440 B fixed), 110 960 B with a mono one; 133 576 B at @@ -96,8 +105,8 @@ The change runs in a straight line from the old room to the new, at the Mix already in flight and within 1 LSB where neither room is at full scale, when two things hold: it is the only room change between two -pulls, and the source has not handed back an empty buffer part-way -through a block since the instance was built or last `reset()`. A patch +pulls, and the source has not come up short (above) part-way through a +block since the instance was built or last `reset()`. A patch change counts as one room change however many knobs it moves, and a move that lands on the room already loaded leaves the audio untouched. Otherwise the output can jump by many times what either room does on its @@ -114,10 +123,12 @@ A patch change, the constructor and `reset()` synthesize at most once, not once per knob. The synthesis runs on the thread that moves the knob; on a board it can race the audio pump (audiodsp#166, open), which a -desktop cannot show. `reset()` in the middle of a stream empties the room: the -next 256 frames come out as exact zero, dry included. A Mix move never -touches the room. It acts on the audio entering the node after it, so the -block already in flight, at most 256 frames, comes out at the old Mix. +desktop cannot show. `reset()` in the middle of a stream empties the room: +the next `latency_samples` frames come out as exact zero, dry included. +That is 256 with a room loaded and none on the empty impulse, whose output +stays the source. A Mix move never touches the room. It acts on the audio +entering the node after it, so the block already in flight, at most 256 +frames, comes out at the old Mix. **What the default surrenders.** The room is normalised to unit energy across the whole band, so with Damping in, low material comes back louder @@ -343,12 +354,12 @@ class ConvolutionReverb(_component.Component): **What the default surrenders:** a dark room lifts low material (a low chord +3.83 dB at the default Damping, +13.40 dB at 500 Hz, 48 kHz), - calling `reset()` mid-stream silences the next 256 frames, dry - included, at every Mix, and anything longer than 0.091 s on an S3 or + calling `reset()` mid-stream with a room loaded silences the next 256 + frames, dry included, and anything longer than 0.091 s on an S3 or 0.219 s on a P4 is a desktop room (pending hardware). A room-knob move drops no dry frame. Two room changes between two pulls, or a move - after the source has handed back an empty buffer part-way through a - block (until a `reset()`), can make the output jump. + after the source has come up short part-way through a block (until a + `reset()`), can make the output jump. """ NAME = 'ConvolutionReverb' diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index bd224d7..97fe368 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -78,6 +78,21 @@ under-run), the dry wire after every pair and an under-run (`ResetOnMove`), the Mix sentence (`MixOnePullLate`) and the reset sentence (`NoReset`). The `cf17a88` words are red on all seven. + +The audit after that round parked the class on four sentences that said +more than the node does with a source that comes up short, and re-audit +fix round 2 after the re-audit round 2 restates them, each read by a test +that is red on the `8a57282` words and on a plant: coming up short is an +empty buffer, one shorter than a frame or an error result +(`test_every_short_read_part_way_counts_as_coming_up_short`, `RetryOnShort`; +`RetryOnEmpty` is the clean class on the byte and error legs); a pull that +comes up short before it has a frame is 256 frames of silence and moves +the rest 256 frames later, and an error result's frames and a part frame +never reach the node (the dry test's source cases, `ResetOnMove` and +`KeepShortReads`); `reset()` silences `latency_samples` frames, none on the +empty impulse (`ResetSilentOnEmpty`, `NoReset`); and the tail is counted in +the frames the source hands (`test_the_tail_counts_the_frames_the_source_hands`, +`TailTwoShort`). """ import os @@ -795,6 +810,112 @@ def _build(self, *arguments, **options): ConvolutionReverb._build(self, *arguments, **options) +class _RetryShortSource(_RetrySource): + """`_RetrySource` for every short read the binding stops on: an error + result, or a buffer shorter than one frame.""" + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + width = 2 * self.channel_count + for _ in range(8): + result, data = self.inner._get_buffer(single_channel_output, + audio_channel) + if result != audiocore.GET_BUFFER_ERROR and len(data) >= width: + break + return result, data + + +class RetryOnShort(ConvolutionReverb): + """The short-read legs' control (re-audit fix round 2 after the re-audit + round 2 at audiodsp v0.6.3rc2): the class pulls again on every short + read the binding stops on (`Convolver.c:278` at 0d35a90), an empty + buffer, one shorter than a frame or an error result, so the node never + returns a short block. A move after any of them then fades over the + whole block. `RetryOnEmpty`, which tests only for an empty buffer, is + the clean class on the byte and error legs.""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._source = _RetryShortSource(self._source) + ConvolutionReverb._build(self, *arguments, **options) + + +class _KeepShortSource(_RetrySource): + """The class's source behind a proxy that hands the node what the + binding would drop: an error result's frames as plain data, and a + part frame at the end of a buffer padded with zero bytes to a whole + frame.""" + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + result, data = self.inner._get_buffer(single_channel_output, + audio_channel) + data = bytes(data) + width = 2 * self.channel_count + if len(data) > width and len(data) % width: + data += bytes(width - len(data) % width) + if result == audiocore.GET_BUFFER_ERROR: + result = audiocore.GET_BUFFER_MORE_DATA + return result, memoryview(data) + + +class KeepShortReads(ConvolutionReverb): + """The whole-frames sentence's control: the part frame and the error + result's frames reach the node (`_KeepShortSource`).""" + + NAME = NAME + + def _build(self, *arguments, **options): + self._source = _KeepShortSource(self._source) + ConvolutionReverb._build(self, *arguments, **options) + + +class _SilentBlock(kit_faults.HiddenGain): + """A 0 dB pass-through that, once armed, answers one pull with a + 256-frame block of silence without pulling what is behind it.""" + + def __init__(self, source): + kit_faults.HiddenGain.__init__(self, source, 0.0) + self.armed = False + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + if self.armed: + self.armed = False + return (audiocore.GET_BUFFER_MORE_DATA, + memoryview(bytes(256 * 2 * self.channel_count))) + return kit_faults.HiddenGain._get_buffer( + self, single_channel_output, audio_channel) + + +class ResetSilentOnEmpty(ConvolutionReverb): + """The empty-impulse reset leg's control: on the empty impulse, + `reset()` plays a 256-frame block of silence, which is what "the next + 256 frames come out as exact zero" (the `8a57282` words) promised + there too. With a room loaded it is the clean class.""" + + NAME = NAME + + def _build(self, *arguments, **options): + ConvolutionReverb._build(self, *arguments, **options) + self._silent = _SilentBlock(self._node) + self._output = self._silent + + def reset(self): + ConvolutionReverb.reset(self) + self._silent.armed = not int(self._node.taps) + + +class TailTwoShort(ConvolutionReverb): + """The tail leg's control: `tail_samples` two frames short.""" + + NAME = NAME + + @property + def tail_samples(self): + self._check_live() + taps = int(self._node.taps) + return (self._latency() + taps if taps else 0) - 2 + + class MixOnePullLate(ConvolutionReverb): """The Mix sentence's control: a Mix move handed to the node one pull late, so 512 frames come out at the old Mix, not 256.""" @@ -1314,13 +1435,17 @@ def fade_reading(old, new, moved, first=None, at=None): class DryOnce(probes.ArraySource): - """int16 frames in 256-frame calls, except where `plan` maps a call - number (from 1) to how many frames that call hands: 0 is an empty - buffer, the source running dry for one call and then going on.""" - - def __init__(self, data, rate, channels, plan=None): + """int16 frames in 256-frame calls (or `size`-frame calls), except + where `plan` maps a call number (from 1) to what that call hands: a + number is that many frames of the material, 0 an empty buffer, the + source running dry for one call and then going on; ("bytes", n) is n + stray bytes of 0x11 that take nothing from the material; ("error", n) + is `GET_BUFFER_ERROR` carrying the next n frames of the material.""" + + def __init__(self, data, rate, channels, plan=None, size=None): probes.ArraySource.__init__(self, data, rate=rate, channels=channels) self.plan = dict(plan or {}) + self.size = size self.calls = 0 def _reset_buffer(self, single_channel_output=False, audio_channel=0): @@ -1329,13 +1454,19 @@ def _reset_buffer(self, single_channel_output=False, audio_channel=0): def _get_buffer(self, single_channel_output=False, audio_channel=0): self.calls += 1 - take = self.plan.get(self.calls) + take = self.plan.get(self.calls, self.size) if take is None: return probes.ArraySource._get_buffer(self) + result = audiocore.GET_BUFFER_MORE_DATA + if isinstance(take, tuple): + kind, take = take + if kind == "bytes": + return result, memoryview(b"\x11" * take) + result = audiocore.GET_BUFFER_ERROR stride = take * self.channel_count * 2 chunk = bytes(self._pcm[self._position:self._position + stride]) self._position += len(chunk) - return 1, memoryview(chunk) + return result, memoryview(chunk) #: The source hands 255 frames on its fourth call and an empty buffer on @@ -1355,13 +1486,14 @@ def act(effect, moves): def dry_render(cls, rate, channels, start, pcm, plan=None, actions=None, - mix=2.0, blocks=40): - """`blocks` host pulls over `pcm` from a DryOnce source, `start` applied - at construction and `actions[n]` just before pull n. Returns the output + mix=2.0, blocks=40, size=None, **options): + """`blocks` host pulls over `pcm` from a DryOnce source (calls of `size` + frames, default 256), `start` applied at construction and `actions[n]` + just before pull n; `options` go to the constructor. Returns the output as (frames, channels) and the frame count of each pull.""" - effect = build(cls, rate, channels, mix=mix) + effect = build(cls, rate, channels, mix=mix, **options) act(effect, start) - effect._source.swap(DryOnce(pcm, rate, channels, plan)) + effect._source.swap(DryOnce(pcm, rate, channels, plan, size)) audiocore.reset_buffer(effect.node) out, sizes = [], [] for number in range(blocks): @@ -1390,8 +1522,8 @@ def said(doc, words): "from the end of the block in flight the output is exactly that of an " "instance that always had the new settings.", "when two things hold: it is the only room change between two pulls, " - "and the source has not handed back an empty buffer part-way through a " - "block since the instance was built or last `reset()`.", + "and the source has not come up short (above) part-way through a block " + "since the instance was built or last `reset()`.", "A patch change counts as one room change however many knobs it moves, " "and a move that lands on the room already loaded leaves the audio " "untouched.", @@ -1400,12 +1532,37 @@ def said(doc, words): ) SUMMARY_WORDS = ( "A room-knob move drops no dry frame. Two room changes between two " - "pulls, or a move after the source has handed back an empty buffer " - "part-way through a block (until a `reset()`), can make the output " - "jump.") + "pulls, or a move after the source has come up short part-way through " + "a block (until a `reset()`), can make the output jump.") +#: What coming up short is, and what the node does with a short read +#: (re-audit fix round 2 after the re-audit round 2 at audiodsp +#: v0.6.3rc2): the latency paragraph. +LATENCY_WORDS = ( + "Mix 0 is the source delayed by exactly `latency_samples`, byte for " + "byte, because the node stays in the path at Mix 0 and a Mix move never " + "jumps the timeline. A room-knob move keeps it too. It does not hold " + "over the `latency_samples` frames after a `reset()` (below), nor " + "across a pull in which the source comes up short (an empty buffer, one " + "shorter than a frame, or an error result) before the pull has a single " + "frame: that pull comes out as 256 frames of silence, and everything " + "after it comes out 256 frames later.", + "The node takes whole frames only: a part frame at the end of a buffer, " + "and anything an error result carries, never reach it.", +) +TAIL_WORDS = ( + "Counted in the frames the source hands, the output is exactly zero " + "from more than `tail_samples` frames after the last non-zero one. Only " + "frames the source hands move the room on: a pull of silence like the " + "one above holds the tail where it is, and a source that stops handing " + "frames stops the tail with it, until it hands frames again.") RESET_WORDS = ( - "`reset()` in the middle of a stream empties the room: the next 256 " - "frames come out as exact zero, dry included.") + "`reset()` in the middle of a stream empties the room: the next " + "`latency_samples` frames come out as exact zero, dry included. That is " + "256 with a room loaded and none on the empty impulse, whose output " + "stays the source.") +RESET_SUMMARY_WORDS = ( + "calling `reset()` mid-stream with a room loaded silences the next 256 " + "frames, dry included,") MIX_LATE_RE = re.compile( r"A\s+Mix\s+move\s+never\s+touches\s+the\s+room\.\s+It\s+acts\s+on\s+the" r"\s+audio\s+entering\s+the\s+node\s+after\s+it,\s+so\s+the\s+block" @@ -1623,6 +1780,69 @@ def jumps(old, new, moved, at, label): on_line(old, new, moved, sum(sizes[:24]), label + ("under-run, reset()",)) + def test_every_short_read_part_way_counts_as_coming_up_short( + self, cls=None): + # "comes up short (an empty buffer, one shorter than a frame, or an + # error result)", and the rule's "has not come up short (above) + # part-way through a block": the source hands 100 frames on its + # fourth call and, on its fifth, an empty buffer, 1 stray byte, 3 + # stray bytes (stereo) or an error result carrying 7 frames. Each + # returns a short block of 100 frames, and a Predelay 0 -> 127 move + # twenty pulls on is off the new room over at most 155 frames, not + # 255, and off the 256-frame line by far more than 1 LSB; a Mix + # 0 -> 2 move there leaves 156 frames at the old Mix, not 256. The + # controls, 3 whole frames and (stereo) 5 bytes, a frame and a + # part, are not short: the node pulls again, the block is whole, + # and the move is on the line (255 frames off the new room, 256 at + # the old Mix). RetryOnShort (the class pulls again on every short + # read) is red on the short legs; RetryOnEmpty is red only on the + # empty one. + self.assertTrue(said(rebuilt.__doc__, LATENCY_WORDS[0])) + self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[2])) + self.assertTrue(said(ConvolutionReverb.__doc__, SUMMARY_WORDS)) + start = DARK + HOLD_MIX + move = ((PREDELAY_I, 127),) + for rate in RATES: + for channels in (2, 1): + pcm = sine(44 * 256, channels, 40.0, rate, 2000.0) + legs = [("empty", 0, True), ("1 byte", ("bytes", 1), True), + ("error, 7 frames", ("error", 7), True), + ("3 frames", 3, False)] + if channels == 2: + legs += [("3 bytes", ("bytes", 3), True), + ("5 bytes", ("bytes", 5), False)] + for name, what, short in legs: + plan = {4: 100, 5: what} + label = (rate, channels, name) + old, sizes = dry_render(cls, rate, channels, start, pcm, + plan, blocks=44) + new = dry_render(cls, rate, channels, start + move, pcm, + plan, blocks=44)[0] + moved = dry_render(cls, rate, channels, start, pcm, plan, + {24: move}, blocks=44)[0] + a = sum(sizes[:24]) + r = fade_reading(old, new, moved, at=a) + off = np.nonzero(np.any(moved[a:] != new[a:], axis=1))[0] + fade = int(off[-1]) + 1 + m0 = dry_render(cls, rate, channels, (), pcm, plan, + mix=0.0, blocks=44)[0] + m2 = dry_render(cls, rate, channels, (), pcm, plan, + {24: ((MIX_I, 127),)}, mix=0.0, + blocks=44)[0] + late = int(np.nonzero(np.any(m0[a:] != m2[a:], + axis=1))[0][0]) + self.assertEqual(r["pre"], 0, (label, r)) + if short: + self.assertEqual(sizes[3], 100, label) + self.assertLessEqual(fade, 155, (label, fade)) + self.assertGreater(r["off_line"], 100.0, (label, r)) + self.assertEqual(late, 156, label) + else: + self.assertEqual(sizes[3], 256, label) + self.assertEqual(fade, 255, label) + self.assertLessEqual(r["off_line"], 1.0, (label, r)) + self.assertEqual(late, 256, label) + def test_no_dry_frame_drops_after_any_number_of_changes(self, cls=None): # "No frame of your dry signal drops or repeats, at any Mix and # after any number of moves: at Mix 0 the output is byte for byte @@ -1630,39 +1850,82 @@ def test_no_dry_frame_drops_after_any_number_of_changes(self, cls=None): # changes the rule names (Mix put back to 0 after a patch), across # moves after the source ran dry 100 and 255 frames into a block, # and across a move made before a pull the source leaves empty at - # a block edge. Where the source never leaves a block short that is - # the source delayed by `latency_samples`, and the test wants that - # too. ResetOnMove is red. + # a block edge. And it reads the latency paragraph against the same + # renders: Mix 0 is the source delayed by `latency_samples`, byte + # for byte, from a source in 256-frame calls, in 100-frame calls and + # in 1 000-frame calls, and after it comes up short part-way through + # a block; a pull in which it comes up short before the pull has a + # frame (an empty buffer, 1 stray byte or an error result at a block + # edge, and two such pulls) comes out as 256 frames of silence with + # everything after it 256 frames later; an error result's frames + # never reach the node, and neither does the part frame at the end + # of a buffer of a frame and a byte (its whole frame does). The + # moves come before and after the starved pull. ResetOnMove is red, + # and so are the 8a57282 words, whose one exception was the reset. self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[0])) + for words in LATENCY_WORDS: + self.assertTrue(said(rebuilt.__doc__, words), words) wire_back = ((MIX_I, 0),) + stray = np.frombuffer(b"\x11\x11", dtype=np.int16)[0] + room = ((ROOM_I, 50),) for rate in RATES: for channels in (2, 1): frames = 40 * 256 pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype( np.int16) pcm = np.repeat(pcm[:, None], channels, axis=1) - want = np.vstack([silence(LATENCY, channels), pcm]) - # (name, source plan, moves, whether the output is the - # source delayed by `latency_samples`: not where the node - # plays a starved pull as silence) - cases = [(name, None, {MOVE_AT: changes + wire_back}, True) - for name, _, changes in TWO_CHANGES] + + def wire(source, silent=()): + """`source` delayed by `latency_samples`, with 256 + frames of silence at each output frame in `silent` + (output frames, the earlier silences counted).""" + out = np.vstack([silence(LATENCY, channels), source]) + for at in sorted(silent): + out = np.vstack([out[:at], silence(256, channels), + out[at:]]) + return out + + edge = MOVE_AT * 256 # the starved pull, call 11 + dropped = np.vstack([pcm[:edge], pcm[edge + 7:]]) + part = np.vstack([pcm[:edge], + np.full((1, channels), stray, np.int16), + pcm[edge:]]) + # (name, source plan, moves, the call size, what Mix 0 is) + cases = [(name, None, {MOVE_AT: changes + wire_back}, None, + wire(pcm)) for name, _, changes in TWO_CHANGES] for p in (100, 255): cases.append(("under-run %d" % p, {4: p, 5: 0}, { - 20: ((ROOM_I, 50),), - 21: ((PREDELAY_I, 40), (DECAY_I, 30))}, True)) - cases.append(("empty at a block edge", {11: 0}, - {MOVE_AT: ((ROOM_I, 50),)}, False)) - for name, plan, actions, delayed in cases: + 20: room, 21: ((PREDELAY_I, 40), (DECAY_I, 30))}, + None, wire(pcm))) + cases += [ + ("100-frame calls, one empty", {30: 0}, {20: room}, 100, + wire(pcm)), + ("1 000-frame calls, one empty", {5: 0}, {6: room}, 1000, + wire(pcm)), + ("empty at a block edge, a move before it", {11: 0}, + {MOVE_AT: room}, None, wire(pcm, (edge,))), + ("empty at a block edge, a move after it", {11: 0}, + {20: room}, None, wire(pcm, (edge,))), + ("1 byte at a block edge", {11: ("bytes", 1)}, + {20: room}, None, wire(pcm, (edge,))), + ("an error with 7 frames at a block edge", + {11: ("error", 7)}, {20: room}, None, + wire(dropped, (edge,))), + ("two empty pulls", {11: 0, 15: 0}, {20: room}, None, + wire(pcm, (edge, 14 * 256))), + ("a frame and a byte at a block edge", + {11: ("bytes", 2 * channels + 1)}, {20: room}, None, + wire(part)), + ] + for name, plan, actions, size, want in cases: label = (rate, channels, name) out = dry_render(cls, rate, channels, (), pcm, plan, - actions, mix=0.0)[0] + actions, mix=0.0, size=size)[0] still = dry_render(cls, rate, channels, (), pcm, plan, - mix=0.0)[0] + mix=0.0, size=size)[0] self.assertEqual(digest(out), digest(still), label) - if delayed: - self.assertEqual(digest(out), - digest(want[:len(out)]), label) + self.assertEqual(digest(out), digest(want[:len(out)]), + label) def test_a_mix_move_leaves_the_block_in_flight_at_the_old_mix( self, cls=None): @@ -1701,22 +1964,39 @@ def test_a_mix_move_leaves_the_block_in_flight_at_the_old_mix( def test_reset_silences_the_next_256_frames(self, cls=None): # "`reset()` in the middle of a stream empties the room: the next - # 256 frames come out as exact zero, dry included": at Mix 0, 1.2 - # and 2, from a steady source and after one that ran dry 100 frames - # into a block, the 256 frames after a reset() twenty pulls in are - # exact zero and the frames either side of them are not. NoReset - # (a reset that keeps the history) is red. + # `latency_samples` frames come out as exact zero, dry included. + # That is 256 with a room loaded": at Mix 0, 1.2 and 2, on the + # synthesized room from a steady source and after one that ran dry + # 100 frames into a block, and on a measured impulse of 1 000 taps, + # the 256 frames after a reset() twenty pulls in are + # exact zero and the frames either side of them are not; at Mix 0 + # the output is the source delayed by `latency_samples` but for + # those 256 frames (the latency paragraph's first exception). On + # the empty impulse (`latency_samples` 0) the reset silences + # nothing: at Mix 0, 0.6, 1.2 and 2 no output frame is zero and the + # output is the source, frame for frame. NoReset (a reset that + # keeps the history) is red on the room, ResetSilentOnEmpty (a + # reset that plays 256 frames of silence whatever the node holds) + # on the empty impulse, and so are the 8a57282 words, which said + # the next 256 frames at every Mix. self.assertTrue(said(rebuilt.__doc__, RESET_WORDS)) + self.assertTrue(said(ConvolutionReverb.__doc__, RESET_SUMMARY_WORDS)) for rate in RATES: for channels in (2, 1): pcm = white(40 * 256, channels, -6.0, seed=4244) - for plan in (None, {4: 100, 5: 0}): + pcm[pcm == 0] = 1 + want = np.vstack([silence(LATENCY, channels), pcm]) + rooms = ({}, dict(impulse=make_impulse(1000).tobytes())) + for plan, room in ((None, rooms[0]), ({4: 100, 5: 0}, + rooms[0]), + (None, rooms[1])): for midi in (0, 76, 127): out, sizes = dry_render( cls, rate, channels, ((MIX_I, midi),), pcm, plan, - {20: (("reset", None), (MIX_I, midi))}, mix=0.0) + {20: (("reset", None), (MIX_I, midi))}, mix=0.0, + **room) a = sum(sizes[:20]) - label = (rate, channels, plan, midi) + label = (rate, channels, plan, bool(room), midi) self.assertEqual( int(np.max(np.abs(out[a:a + 256]))), 0, label) self.assertGreater( @@ -1724,6 +2004,77 @@ def test_reset_silences_the_next_256_frames(self, cls=None): self.assertGreater( int(np.max(np.abs(out[a + 256:a + 512]))), 0, label) + if midi == 0: + wire = want[:len(out)].copy() + wire[a:a + 256] = 0 + self.assertEqual(digest(out), digest(wire), + label) + for midi in (0, 38, 76, 127): + label = (rate, channels, "empty impulse", midi) + out = dry_render( + cls, rate, channels, ((MIX_I, midi),), pcm, None, + {20: (("reset", None), (MIX_I, midi))}, mix=0.0, + impulse=b"")[0] + self.assertTrue(np.all(np.any(out != 0, axis=1)), label) + self.assertEqual(digest(out), digest(pcm[:len(out)]), + label) + + def test_the_tail_counts_the_frames_the_source_hands(self, cls=None): + # The Tail paragraph: white noise at -6 dBFS over frames 0..4999, + # then zero frames, at Mix 1.2 and 2, on the synthesized room at + # patch 0 and on the empty impulse, three rates, stereo and mono; + # the source leaves no pull empty, one pull (call 23) empty inside + # the tail, or three in a row (calls 21 to 23), which is a source + # that stops handing frames mid-tail and then goes on. With the + # starved pulls' output taken out (each exact zero, 256 frames), + # the output is the no-starve render frame for frame, so the tail + # waits and then goes on where it was; and every frame more than + # `tail_samples` past the last non-zero input frame is exact zero, + # counted in the frames the source hands, while the room's last + # non-zero frame lands within one frame of that edge. TailTwoShort + # (`tail_samples` two frames short) is red, and so are the 8a57282 + # words, which said only that the output is zero after the tail. + self.assertTrue(said(rebuilt.__doc__, TAIL_WORDS)) + burst = 5000 + for rate in RATES: + for channels in (2, 1): + pcm = silence(40 * 256, channels) + pcm[:burst] = white(burst, channels, -6.0, seed=808) + for options in ({}, dict(impulse=b"")): + probe = build(cls, rate, channels, **options) + tail = probe.tail_samples + probe.deinit() + for midi in (76, 127): + steady = None + for plan in (None, {23: 0}, {21: 0, 22: 0, 23: 0}): + label = (rate, channels, options, midi, plan) + out, sizes = dry_render( + cls, rate, channels, ((MIX_I, midi),), pcm, + plan, mix=0.0, **options) + self.assertEqual(sizes, [256] * 40, label) + handed = out + if plan: + starved = [call - 1 for call in plan] + for pull in starved: + self.assertEqual(int(np.max(np.abs( + out[pull * 256:pull * 256 + 256]))), + 0, label) + keep = np.ones(len(out), bool) + for pull in starved: + keep[pull * 256:pull * 256 + 256] = False + handed = out[keep] + self.assertEqual( + digest(handed), + digest(steady[:len(handed)]), label) + else: + steady = out + edge = burst - 1 + tail + self.assertEqual( + int(np.max(np.abs(handed[edge + 1:]))), 0, + label) + last = int(np.nonzero(np.any(handed != 0, + axis=1))[0][-1]) + self.assertGreaterEqual(last, edge - 1, label) # -------------------------------------------------------------------------- From 6cf445cb3d9e363cbb4a2bc524cba54b4f12071a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:57:52 -0500 Subject: [PATCH 67/88] The lifecycle matrix's KNOWN_RED: 31 findings over the eight Phase 5 classes, each a count and digest of its red cells in the quick and the full matrix, so the file passes today and goes red when a cell changes either way --- tests/test_lifecycle_matrix.py | 211 ++++++++++++++++++++++++++------- 1 file changed, 165 insertions(+), 46 deletions(-) diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 7e34a88..9e6f47e 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -22,6 +22,7 @@ class this branch carries, on CPython and on the two native interpreters. import sys import tempfile import unittest +import zlib ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(ROOT, "lib")) @@ -33,28 +34,122 @@ class this branch carries, on CPython and on the two native interpreters. PHASE5 = ("DigitalDelay", "SlapbackDelay", "TapeDelay", "PingPongDelay", "MultiTapDelay", "AnalogDelay", "Reverb", "ConvolutionReverb") -#: (class, event, property, rate, channels, patch) -> reason. `event` may -#: end in `*` (a prefix); rate, channels and patch may be `*`; patch is -#: "d" for the constructor defaults. Every red cell must match a row, and -#: every cell a row matches must be red. +#: What is red today. (class, event name prefix, property) -> +#: (red cells in the quick matrix, their digest, red cells in the full +#: matrix, their digest, reason). A red cell belongs to the row with the +#: longest matching prefix. The file goes red when a red cell belongs to no +#: row, or when a row's cells change in number or in which cells they are +#: (the message lists them). The full-matrix pair is None where the full +#: matrix has not been run (MultiTapDelay: it takes about 40 minutes on +#: CPython). KNOWN_RED = { + ('DigitalDelay', 'E', 'P5'): ( + 36, 'cfa18482', 94, '4627b739', + 'Mix, Repeat Tone, Repeat Cut and patch moves step the output within one block'), + ('DigitalDelay', 'E8-dry', 'P3'): ( + 6, 'e155e27d', 6, 'e155e27d', + 'a source that stays dry through a pause and comes back: old audio plays out of silence'), + ('SlapbackDelay', 'E', 'P5'): ( + 124, '01ddb3de', 198, '4cda9a29', + 'Level, Tone and patch moves step the output within one block (no ramp)'), + ('SlapbackDelay', 'E1-', 'P4'): ( + 56, 'c608fc28', 56, 'c608fc28', + 'reset() lands patch 0 at another moment than a fresh instance, so the Wow phase never re-converges'), + ('SlapbackDelay', 'E2-', 'P4'): ( + 28, 'cbf2eabb', 28, 'cbf2eabb', + 'a host reset_buffer shifts the Wow phase, so the output never re-converges'), + ('SlapbackDelay', 'E8-dry', 'P3'): ( + 16, '95d6ec10', 16, '95d6ec10', + 'a source that stays dry through a pause and comes back: old audio plays out of silence'), + ('TapeDelay', 'E', 'P5'): ( + 34, '60c8dd58', 106, '4ce31e31', + 'Time, Mix, Wow and patch moves step the output within one block'), + ('TapeDelay', 'E1-', 'P4'): ( + 72, '407e821c', 72, '407e821c', + 'reset() lands patch 0 at another moment than a fresh instance, so wow and flutter never re-converge'), + ('TapeDelay', 'E2-', 'P4'): ( + 32, 'd3f42f25', 32, 'd3f42f25', + 'a host reset_buffer shifts the wow and flutter phase, so the output never re-converges'), + ('TapeDelay', 'E4-m0=0', 'P4'): ( + 0, '00000000', 10, '816d86bd', + 'Time to 0 and back at 22.05 kHz: the glide back outlasts tail_samples'), + ('TapeDelay', 'E8-dry', 'P3'): ( + 2, '1da480f7', 2, '1da480f7', + 'a source that stays dry through a pause and comes back: old audio plays out of silence'), + ('PingPongDelay', 'E4-m2=127', 'P5'): ( + 4, '31c4e1c7', 32, 'c9e4e06a', + 'Mix to 127 steps the output within one block'), + ('MultiTapDelay', 'E', 'P4'): ( + 4, '11f966a4', None, None, + 'at 22.05 kHz stereo a Mix, Repeat Tone or patch move re-converges after tail_samples allows'), + ('MultiTapDelay', 'E', 'P5'): ( + 84, '6aacc9b1', None, None, + 'Time, Heads, Tilt and patch moves step the output within one block (Time and Heads disclosed)'), + ('MultiTapDelay', 'E1-', 'P4'): ( + 16, '9a49aaf5', None, None, + 'at 22.05 kHz mono a reset re-converges about 1000 frames after tail_samples allows'), + ('MultiTapDelay', 'E5-', 'P4'): ( + 14, 'b3271a6f', None, None, + 'Mix 0 and back leaves the class out of step with a fresh instance for good'), + ('MultiTapDelay', 'E8-dry', 'P4'): ( + 15, '6743df4e', None, None, + 'a source that runs dry once leaves the class out of step with a fresh instance for good'), + ('MultiTapDelay', 'E9-', 'P4'): ( + 70, '123b91ee', None, None, + 'Mix 0 and back leaves the class out of step with a fresh instance for good'), + ('AnalogDelay', 'E', 'P4'): ( + 37, '1266f888', 150, 'bb0595c6', + "the Modulation LFO's phase moves with the event, so a modulated patch never re-converges"), + ('AnalogDelay', 'E', 'P5'): ( + 74, 'b699cca1', 214, '8d248b43', + 'Time, Mix and patch moves step the output within one block'), + ('AnalogDelay', 'E8-dry', 'P3'): ( + 8, 'e1967d96', 8, 'e1967d96', + 'a source that stays dry through a pause and comes back: old audio plays out of silence'), + ('Reverb', 'E', 'P4'): ( + 322, '606b9d85', 1409, '722197fe', + 'a network move, reset or reset_buffer never re-converges to a fresh instance (modulation phase?)'), + ('Reverb', 'E', 'P5'): ( + 416, '8e447fac', 1166, '886ecc00', + 'almost every macro and patch move steps the output within one block'), + ('Reverb', 'E1-reset@part', 'P1'): ( + 44, 'df421423', 44, 'df421423', + 'reset() part-way through a source buffer drops the frames the input held: silence at Mix 0'), + ('Reverb', 'E8-dry', 'P3'): ( + 44, 'e23e44d7', 44, 'e23e44d7', + 'a source that stays dry through a pause and comes back: old audio plays out of silence'), + ('ConvolutionReverb', 'E', 'P5'): ( + 324, '9421d061', 678, 'e8b57cdc', + 'Mix, Damping, Predelay, Room and patch moves step the output within one block'), + ('ConvolutionReverb', 'E1-', 'P1'): ( + 72, '387f53ad', 72, '387f53ad', + 'reset() silences the next 256 frames, dry included (disclosed)'), + ('ConvolutionReverb', 'E2-', 'P1'): ( + 36, '78219cf4', 36, '78219cf4', + 'a host reset_buffer silences the next 256 frames at Mix 0, dry included'), + ('ConvolutionReverb', 'E4-m5=0', 'P1'): ( + 4, '2857f42f', 36, 'd4cc4837', + 'Mix to 0 lands one block late: the first block after the move is not the source'), + ('ConvolutionReverb', 'E5-', 'P1'): ( + 36, 'caf98973', 36, 'caf98973', + 'Mix to 0 lands one block late: the first block after the move is not the source'), + ('ConvolutionReverb', 'E8-dry', 'P3'): ( + 36, 'b7504caa', 36, 'b7504caa', + 'a source that stays dry through a pause and comes back: old audio plays out of silence'), } +#: LIFECYCLE_FULL=1 runs the full matrix (every event at every patch); +#: otherwise the quick one (lifecycle.run_class(quick=True)). +FULL = bool(os.environ.get("LIFECYCLE_FULL")) -def _match(row, cell): - for want, got in zip(row, cell): - if want == "*": - continue - if isinstance(want, str) and want.endswith("*"): - if not str(got).startswith(want[:-1]): - return False - elif str(want) != str(got): - return False - return True + +def digest(cells): + text = "\n".join(sorted("|".join(map(str, c)) for c in cells)) + return "%08x" % (zlib.crc32(text.encode()) & 0xFFFFFFFF) def parse(lines): - """{(class, event, rate, ch, patch): {prop: verdict}} and summaries.""" + """{(class, event, rate, ch, patch): {prop: verdict}}.""" cells = {} for line in lines: parts = line.split("|") @@ -74,28 +169,47 @@ def red_cells(cells): return out -def check_known(name, red, cells): - """Problems with `red` against KNOWN_RED, for class `name`.""" - rows = [row for row in KNOWN_RED if row[0] == name] - problems = [] - for cell in sorted(red): - if not any(_match(row, cell) for row in rows): - problems.append("new red: %s" % "|".join(map(str, cell))) +def row_of(cell, rows): + best = None for row in rows: - matched = [c for c in cells_as_props(cells) if _match(row, c)] - if not matched: - problems.append("row matches no cell: %r" % (row,)) - for cell in matched: - if cell not in red: - problems.append("turned green: %s (row %r)" % ( - "|".join(map(str, cell)), row)) - return problems + if row[0] == cell[0] and row[2] == cell[2] and \ + cell[1].startswith(row[1]): + if best is None or len(row[1]) > len(best[1]): + best = row + return best -def cells_as_props(cells): - for key, props in cells.items(): - for prop in props: - yield (key[0], key[1], prop, key[2], key[3], key[4]) +def group(name, red, table): + rows = [row for row in table if row[0] == name] + groups = dict((row, set()) for row in rows) + loose = [] + for cell in sorted(red): + row = row_of(cell, rows) + if row is None: + loose.append(cell) + else: + groups[row].add(cell) + return groups, loose + + +def check_known(name, red, table=None, full=None): + """Problems with class `name`'s red cells against KNOWN_RED.""" + table = KNOWN_RED if table is None else table + full = FULL if full is None else full + groups, loose = group(name, red, table) + problems = ["new red: %s" % "|".join(map(str, c)) for c in loose] + for row, got in sorted(groups.items()): + entry = table[row] + count, dig = (entry[2], entry[3]) if full else (entry[0], entry[1]) + if count is None: + continue + if (len(got), digest(got)) != (count, dig): + problems.append("row %r changed: %d red cells (digest %s), " + "expected %d (%s); now red: %s" % ( + row, len(got), digest(got), count, dig, + ", ".join("|".join(map(str, c[1:])) + for c in sorted(got))[:1500])) + return problems def classes(): @@ -144,13 +258,14 @@ def start_native(binary, names, scratch): GCOV_PREFIX=scratch, PYTHONDONTWRITEBYTECODE="1") return subprocess.Popen( [binary, "-X", "heapsize=256M", "tests/support/lifecycle.py"] - + list(names), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + + list(names) + ([] if FULL else ["--quick"]), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=ROOT, env=env) def run_cpython(name): lines = [] - lifecycle.run_class(rebuilt.module_class(name), emit=lines.append) + lifecycle.run_class(rebuilt.module_class(name), emit=lines.append, + quick=not FULL) return lines @@ -189,7 +304,7 @@ def test_known_red_on_cpython(self): problems = [] for name in self.names: cells = parse(self.cpython[name]) - problems += check_known(name, red_cells(cells), cells) + problems += check_known(name, red_cells(cells)) self.assertEqual(problems, [], "\n".join(problems[:60])) def _p6(self, family): @@ -261,15 +376,19 @@ def test_a_declared_exception_prints_decl(self): self.assertEqual(got, "decl") def test_known_red_catches_both_directions(self): - cells = {("X", "E1", 48000, 2, "d"): {"P1": "RED(x)", "P2": "ok"}} - red = red_cells(cells) - KNOWN_RED[("X", "E1", "P2", "*", "*", "*")] = "planted" - try: - problems = check_known("X", red, cells) - finally: - del KNOWN_RED[("X", "E1", "P2", "*", "*", "*")] - self.assertTrue(any(p.startswith("new red") for p in problems)) - self.assertTrue(any(p.startswith("turned green") for p in problems)) + one = ("X", "E1-a", "P1", 48000, 2, "d") + two = ("X", "E1-b", "P1", 48000, 2, "d") + table = {("X", "E1-", "P1"): (2, digest([one, two]), None, None, + "planted")} + self.assertEqual(check_known("X", {one, two}, table, False), []) + # A listed cell turned green, a cell swapped for another, a red + # cell no row names: each is a problem. + self.assertTrue(check_known("X", {one}, table, False)) + three = ("X", "E1-c", "P1", 48000, 2, "d") + self.assertTrue(check_known("X", {one, three}, table, False)) + four = ("X", "E2-a", "P1", 48000, 2, "d") + got = check_known("X", {one, two, four}, table, False) + self.assertTrue(any(p.startswith("new red") for p in got)) def test_p6_can_fail(self): """A plant whose level depends on the interpreter prints a From c2cc24b90961c7f6bb107e631a858621c81c24ab Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:58:00 -0500 Subject: [PATCH 68/88] LIFECYCLE.md: how KNOWN_RED holds a finding, and the quick, full and per-class runs --- tests/support/LIFECYCLE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/support/LIFECYCLE.md b/tests/support/LIFECYCLE.md index 8142939..bcebe04 100644 --- a/tests/support/LIFECYCLE.md +++ b/tests/support/LIFECYCLE.md @@ -107,7 +107,10 @@ free-running LFO) adds a row to `DECLARED` in `lifecycle.py`: The key is (class `NAME`, event name prefix, property); the cell then prints `decl` and counts as a declared exception. Nothing else skips a cell. The test file's `KNOWN_RED` is different: it records what is red today, as -findings, so the suite goes red when a cell changes either way. +findings, one row per (class, event prefix, property) with the number and a +digest of its red cells, so the suite goes red when a cell changes either +way. The test runs the quick matrix; `LIFECYCLE_FULL=1` runs the full one, +and `LIFECYCLE_CLASSES=DigitalDelay,TapeDelay` limits it to some classes. ## The plants From 40edece665612dbd91c0c1c13e5b4e01469331f0 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:15:18 -0500 Subject: [PATCH 69/88] KNOWN_RED from the CPython printouts, and KNOWN_P6: MultiTapDelay renders other bytes on CPython than on both native interpreters after a Time move or a patch change --- tests/test_lifecycle_matrix.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 9e6f47e..310d1cf 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -79,11 +79,14 @@ class this branch carries, on CPython and on the two native interpreters. ('PingPongDelay', 'E4-m2=127', 'P5'): ( 4, '31c4e1c7', 32, 'c9e4e06a', 'Mix to 127 steps the output within one block'), + ('MultiTapDelay', 'E', 'P3'): ( + 20, '273472a7', None, None, + 'CPython only: a Time move or a patch change and back plays old audio out of silence (see KNOWN_P6)'), ('MultiTapDelay', 'E', 'P4'): ( 4, '11f966a4', None, None, 'at 22.05 kHz stereo a Mix, Repeat Tone or patch move re-converges after tail_samples allows'), ('MultiTapDelay', 'E', 'P5'): ( - 84, '6aacc9b1', None, None, + 85, '511cd123', None, None, 'Time, Heads, Tilt and patch moves step the output within one block (Time and Heads disclosed)'), ('MultiTapDelay', 'E1-', 'P4'): ( 16, '9a49aaf5', None, None, @@ -138,6 +141,18 @@ class this branch carries, on CPython and on the two native interpreters. 'a source that stays dry through a pause and comes back: old audio plays out of silence'), } +#: P6 differences today: class -> (cells whose line differs between CPython +#: and a native interpreter in the quick matrix, their digest, the same for +#: the full matrix, reason). Both native interpreters print the same lines +#: as each other for every class. +KNOWN_P6 = { + "MultiTapDelay": ( + 29, "cedec0fa", None, None, + "after a Time move or a patch change CPython renders other bytes " + "than both native interpreters, and on 20 cells plays old audio " + "out of silence where they do not"), +} + #: LIFECYCLE_FULL=1 runs the full matrix (every event at every patch); #: otherwise the quick one (lifecycle.run_class(quick=True)). FULL = bool(os.environ.get("LIFECYCLE_FULL")) @@ -319,11 +334,18 @@ def _p6(self, family): for name in self.names: ours = parse(self.cpython[name]) theirs = parse([l for l in got if l.startswith(name + "|")]) - for key in sorted(set(ours) | set(theirs)): - if ours.get(key) != theirs.get(key): - problems.append("%s: %s cpython %s, %s %s" % ( - family, "|".join(map(str, key)), ours.get(key), - family, theirs.get(key))) + keys = [key for key in sorted(set(ours) | set(theirs)) + if ours.get(key) != theirs.get(key)] + entry = KNOWN_P6.get(name) + if entry is not None: + count, dig = (entry[2], entry[3]) if FULL else entry[:2] + if count is None or (len(keys), digest(keys)) == (count, + dig): + continue + for key in keys: + problems.append("%s: %s cpython %s, %s %s" % ( + family, "|".join(map(str, key)), ours.get(key), + family, theirs.get(key))) self.assertEqual(problems, [], "\n".join(problems[:60])) def test_p6_micropython(self): From 098162907897e504710d52afadabac671fbdace6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:31:28 -0500 Subject: [PATCH 70/88] AnalogDelay at audiodsp v0.6.3rc3, trial of the second process: Spread handed as set, the docstring rewritten with a CLAIMS table, the matrix's red cells declared The node ends the stereo cross-feed tail itself at rc3 (audiodsp#170, #173), so the 1/4096 Spread grid comes out. The cross-feed cells keep their clean-class half; the retired RawSpread plant no longer goes red, so OneLapTail stands in for it. The docstring is the player's text, every claim tied to a test by CLAIMS, with the two family limits. The class's P5, E8-dry P3 and modulated-patch P4 cells are declared in the matrix and leave KNOWN_RED. --- CHANGELOG.md | 9 +- lib/audioeffects/rebuilt/analogdelay.py | 293 +++++----------------- tests/support/lifecycle.py | 20 +- tests/test_cpython_effects_analogdelay.py | 231 ++++++++++++++--- tests/test_lifecycle_matrix.py | 9 - 5 files changed, 280 insertions(+), 282 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92eb164..96dd786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,10 +87,11 @@ there, and are recorded in its changelog. cost is unmeasured. At audiodsp v0.6.3rc1 the Feedback is handed as set (no stepping clear of the stall windows; the bound counts one landing lap there), and a Modulation move glides over 20 ms instead of stepping the - read offset. In stereo, Spread reaches the node on a grid of 4096ths - (within 1/8192 of the knob; 0 and 1 unchanged), because off that grid - the node's cross-feed sum could land a step above both sides and hold - 9-50 LSB for ever at a Feedback a float32 step under 1 - 0.5 / k. + read offset. At v0.6.3rc3 Spread is handed as set: the node's stereo + cross-feed tail now ends on its own (audiodsp#170), so the 1/4096 grid + the class used to dodge it is gone. A jumping control steps the output + and a tail cut short by a stopped source carries on when it comes back, + disclosed as family limits (audiocomponents#117, audiodsp#180). - **`PingPongDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** repeats that alternate between the speakers, on one `audioecho.FeedbackDelay` whose cross-feed and input pan Spread moves between two plain delays and diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index 28f7b47..7dfeb7f 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -1,199 +1,9 @@ """`AnalogDelay` - a bucket-brigade delay whose Time knob is its clock. -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/AnalogDelay.md`, whose trait -table was frozen at Station A before this file existed (anchor commit -cc61011, 2026-09-28, the Station A critique revision). The old class in -`delay.py` is consulted only for the seven defects that dossier's section 7 -names; it stays the class the library serves until the auditor adopts this -one. - -**What it sounds like.** Your dry signal passes untouched, and repeats -follow it that get darker the longer you set Time, because in a bucket -brigade the Time knob is a clock: the line has a fixed number of stages, -so a longer delay is a slower clock and a lower band limit. Two characters -pick the stage count: `"single-line"` (the default) is one 4096-stage line, -the Boss DM-2; `"double-line"` is two in series, 8192 stages, the -Electro-Harmonix Deluxe Memory Man, which at the same Time runs its clock -an octave higher and so keeps its repeats an octave brighter. Time is -20-600 ms on both, Feedback 0-0.99, and Mix 0-2 (dry at unity up to 1, wet -alone at 2; Mix 0 is a wire while the line keeps recording). Turn Time -while it plays and the repeats bend in pitch the way a clock step bends -them, and settle (a turn through many positions takes seconds; see -"Turning Time" below). Modulation (0-5 ms) and Mod Rate (0.05-8 Hz) wobble the -delay on a triangle, the Memory Man's chorus and vibrato: a blend with -Modulation up is a chorus, the wet alone (Mix 2, patch 4) a vibrato. -Spread feeds each side's repeats into the other. Sync locks Time to -Division of the host's beat. - -**The standouts:** the Boss DM-2 and the Deluxe Memory Man, as the two -characters. The band limit is the one thing that separates them, so it is -the one thing the character changes. - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`): one -`audioecho.FeedbackDelay`, audiodsp's own node. On a stock CircuitPython -board this module imports cleanly and construction raises `ImportError`. - -**Latency: zero samples, at every setting, patch, character and rate.** -Nothing looks ahead. The delay is the wet path, not latency on the dry -path, and no option adds any. That is a decision: oversampling is the -clean way to keep a bucket-brigade model free of aliasing, and its filters -cost latency a stompbox has none to spend. - -**Mono.** A one-channel source gets the identical effect on its one -channel with Spread held at 0. At one channel the node's cross-feed sends -the repeat nowhere (Feedback 0.7 with cross-feed 1.0 leaves the dry, the -first repeat and nothing after it), so the class hands the node 0 there -whatever the knob says. The class never passes `input_pan`, which in mono -would overwrite the node's mono feed. - -**RAM.** The line is `max_time_ms + 6` ms of two int16 lanes whatever the -channel count: 116 352 B at 48 kHz for the default 600 ms (106 896 B at -44.1 kHz, 53 448 B at 22.05 kHz), plus 512 B for the triangle table and -about 1.2 KB of node. The six milliseconds are the modulation's 5 ms peak -swing and one for the read's clamp. Pass a lower `max_time_ms` to spend -less; Time then stops at that ceiling and `get_macro(0)` shows where. - -**Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on, the loop -low-pass in and a borrowed 256-point table; no mixer, the same graph for -both characters and every patch. Palette row FeedbackDelay +options (the -nearest not-cheaper row), glue 0: **P4 <= 9 %, S3 <= 15 %** of a 5.333 ms -stereo block. The board measurement is pending hardware. - -**The band limit.** The repeats' high-frequency corner is the sinc's -3 dB -point at the line's clock, 0.2211 N / T: 3019 Hz at 300 ms single-line, -6038 Hz double-line, halving each time Time doubles. It is the node's one -loop low-pass, pre-warped so its -3 dB point is that corner, so each pass -through the loop darkens the repeat once more, as the circuit's filters -do. Where the law passes 0.98 of Nyquist (below 38.5 ms single-line and -77.0 ms double-line at 48 kHz; 41.9 / 83.8 ms at 44.1 kHz; 83.8 / -167.6 ms at 22.05 kHz) the corner holds at that clamp, so there a shorter -Time no longer brightens the repeats. - -**What the class surrenders, said plainly.** Three things both pedals do -are not here. There is no sample-and-hold, so the repeats roll off on one -pole where a bucket brigade rolls off on a sinc with a null at its clock -(12.1 dB off the sinc's shape between 300 and 600 ms, and -7.7 dB, not a -null, at the clock). There is no compander, so the repeats do not pump -and a burst's rise time does not change from repeat to repeat. And there -is no fixed anti-alias and reconstruction pair: both pedals bound their -wet path near 3 kHz at every Time, so at short Times this class's repeats -are brighter than either pedal's. The line's image spectra and its clock -noise are not modelled either. None of those has a node in audiodsp v0.6.3rc1 -that can sit inside the loop. - -**Time.** Every Time is landed on the nearest whole frame at the running -rate, and the node is handed a delay whose read's whole part is that frame -on every interpreter: the node turns milliseconds into frames in single -precision, and at 44.1 and 22.05 kHz one frame count in about seven has -no single-precision value that lands on it exactly, so the class hands the -next value up. The read then trails the frame by at most 0.00195 frames at -44.1 kHz (0.00098 at 22.05; none at 48), never early. - -**A Time move glides in pitch on the clock's own law.** A move from -`T_old` to `T_new` walks the read head at |T_new - T_old| / T_new, so it -holds the pitch ratio T_old / T_new for exactly T_new and then returns to -unity, without a click: 200 -> 100.4 ms bends the repeats +1193 cents for -100.4 ms, 100 -> 300 ms -1902 cents for 300 ms. The node walks the head in -single precision, and a rate under half a step of the head's position -would round back to where it was and leave the head short for good, so -the rate never goes below two single-precision steps of the furthest the -head may sit (1/512 of a frame per frame from 8 192 to 16 384 frames, -1/256 above). That only touches moves of under 0.4 % of T: they land in -less than T_new, bent by at most 7 cents (300 -> 300.1 ms, 5 frames at -48 kHz, lands in 0.053 s). - -**Turning Time takes seconds to settle.** The walk's rate is taken from -the Time last handed to the node, not from where the head is, because the -class cannot see the head. A knob turned through several positions sends -several moves, and once the head falls behind, the last move's small rate -carries it the rest of the way. At 48 kHz, 7-bit positions one block -apart: MIDI 101 -> 111 (299 -> 391 ms, 10 moves) still differs from the -same move made as one jump 3.4 s after the last move, where the jump has -landed in 0.39 s; MIDI 64 -> 101 (111 -> 299 ms, 37 moves) 6.9 s. A -14-bit controller's fine steps are slower still: 300 -> 400 ms in 1386 -moves takes 20 s, walking at the floor. The pitch claim covers none of -this, only a move from rest. It is claimed for moves of up to 3 : 1 and inputs -from -28.7 to -0.2 dBFS; quieter, int16 rounding decides the reading -(13 cents off at -48.7 dBFS). The claim is about the walk itself: under -feedback, each later repeat re-reads a line that was written while the -head was moving, so the repeats do not telescope the way a clock step in -a real bucket brigade makes them, and nothing here claims they do. - -**Modulation.** The swing is a fixed number of milliseconds whatever the -Time, so a Time move never steps the read offset and equal Modulation -bends equally at every Time (+-20.7 cents at patch 3's 3 ms and 1 Hz, -+257 / -302 at the stops). The shape is a plain triangle, the clock law's -first order; it differs from the exact reciprocal by up to S / T of the -swing (1.7 % at 300 ms and full depth, 25 % at 20 ms). A Modulation move -glides: since audiodsp v0.6.3rc1 the node ramps a new swing in over 20 ms -(audiodsp#160), where up to v0.6.2 it jumped the read by the change in -depth times where the triangle stood (142 frames for 5 -> 2 ms at the -triangle's peak, 48 kHz). While the swing travels the extra pitch is the -change over 20 ms times where the triangle stands: 5 -> 2 ms at a peak -bends the repeats by 15 % for those 20 ms, +242 cents at one peak and --281 at the other. A move made while the last one's 20 ms is still -running starts a new 20 ms from wherever the swing has got to, so the -swing travels at (target - where it stands) / 20 ms, which a knob turned -through several positions a block apart can make a little faster than -any one move's own |change| / 20 ms. So the read moves at most that -distance over 20 ms plus the triangle's own 4 x Mod Rate x swing of a -frame per frame faster or slower than the tone. With the wet alone and -Feedback 0, no step in the output is then larger than the input's own -largest step times 1 plus that: on a 997 Hz tone at 12 000 LSB, Time -300 ms, five moves between 0 and 5 ms at Mod Rate 1 and 8 Hz and eight -points of the triangle, at 48, 44.1 and 22.05 kHz, the largest step in -the 40 ms after a move is at most 0.943 of that (1 -> 1.5 ms at 1 Hz, -48 kHz: 1 522 LSB against 1 615, where v0.6.2's node read 7 133). With -the dry in or the repeats recirculating, the output's own step already -passes that bar before any move (1.9 x at Mix 1, 1.3 x at Feedback 0.5), -so the sentence says nothing there. Mod Rate moves keep the triangle's -phase and do not step. - -**Input ceiling.** The dry path sits at unity and the repeats add to it, -so a hot input can put the output on the int16 rail; there is no input -gain to turn down. The loop's low-pass, cross-feed and interpolated read -are each a convex mix, so no repeat exceeds full scale and the wet adds at -most Mix x full scale: below Mix 1, an input peaking at or below -floor(32767 (1 - Mix)) - 1 cannot reach the rail at any Time, Feedback, -Modulation or Spread (-4.4 dBFS at the default Mix 0.4). - -**Tail.** `tail_samples` is an upper bound on how long the repeats take to -reach exact zero after your input stops: DigitalDelay's lap count at the -Feedback the node is handed, each lap the longest delay the head may be at -plus the swing, one frame for the interpolated read and the low-pass's -memory. 187 954 frames (3.9 s) at the defaults; 26.5 s at patch 5, the -longest. The loop low-pass is always in, and at a Feedback a hair either -side of 1 - 0.5 / k it can come to rest a hair above k LSB and hand it -back. Up to audiodsp v0.6.2 it did so for ever, and the class handed the -node the nearer edge of that window instead. Since v0.6.3rc1 the node sets -a stalled low-pass onto its input (audiodsp#157), the Feedback you set is -the one the node plays, and the bound counts one more lap there. In -stereo the cross-feed could do the same thing: the node's sum of the two -sides, in single precision, can come out a step above both, and with -Spread at 39 / 127 or any other value off a short binary grid and a -Feedback a float32 step or two under 1 - 0.5 / k that handed k LSB back for -ever. So Spread reaches the node on a grid of 4096ths, within 1/8192 of -the knob, where that sum is exact on every interpreter for any side -under 4096 LSB (above that a step is far too small to hold a repeat); -0 and 1 are untouched. The bound then holds at every Feedback and Spread -the constructor or a macro can hand, stereo and mono. After a falling -Time move the bound keeps the Time the head walked from until a reset, -because the class cannot see how far the walk has got. - -`capabilities = ("tempo_sync",)`: with Sync on, the class reads -`self._transport()` on every macro move and program change (not per block). -With no host transport, or a host whose tempo is not a finite positive -number, Time stays where the knob is. A synced Time change walks at the -clock's law like any other. - -Constructor values stay on the audio path unquantised by the grid, and a -constructor Time plays exactly as given (not through the knob's map, whose -round trip can move a Time that sits on a half frame to the frame below) -until Time is moved; a host that reads the knob back and writes the same -position keeps it. A constructor Time of 0 or less is 20 ms, a Mod Rate of 0 or less 0.05 Hz, -a `max_time_ms` above 600 or NaN 600 ms. `character` is `"single-line"` -or `"double-line"`; anything else raises `ValueError`. +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/AnalogDelay.md`). """ VENDOR = "PyDevices" @@ -246,10 +56,6 @@ class cannot see the head. A knob turned through several positions sends #: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). FEEDBACK_MAX = 0.99 -#: Spread's grid as the node is handed it: whole 4096ths, so the loop's -#: cross-feed sum is exact for the small lanes a tail ends on. -SPREAD_GRID = 4096 - #: One period of the modulation's triangle, borrowed by the node. TABLE_POINTS = 256 @@ -342,19 +148,6 @@ def tone_excess(damping_hz, sample_rate): return frames, 2.0 ** -17 + 2.0 ** -24 / coefficient -def spread_on_grid(spread): - """`spread` on the 1/4096 grid the node is handed (dossier section 8, - R13). The node sends `own * (1 - s) + other * s` round the loop in - single precision; with s = 39 / 127 or any other value off a short - binary grid those two products can add up to one step above both - lanes, and at a Feedback a hair under 1 - 0.5 / k that hands a landed - k LSB back for ever. On the grid both products of a lane under 4096 - LSB are exact, so the sum lies between the lanes on every interpreter, - whether or not a board fuses the multiply-add. The grid moves Spread - by at most 1/8192.""" - return math.floor(spread * SPREAD_GRID + 0.5) / SPREAD_GRID - - def _between(value, low, high): value = float(value) if not value >= low: @@ -365,13 +158,63 @@ def _between(value, low, high): class AnalogDelay(_component.Component): - """A bucket-brigade delay: the Time knob is the line's clock, so the - repeats darken as Time grows and a Time move bends their pitch. - audiodsp tier; zero latency. - - **What the default surrenders:** no sample-and-hold (a one-pole - roll-off, not the sinc, and no null at the clock), no compander, no - fixed ~3 kHz pair, so short Times are brighter than either pedal. + """A bucket-brigade delay whose Time knob is the line's clock. + + A bucket brigade has a fixed number of stages, so a longer delay is a + slower clock and a lower band limit: the repeats get darker as you turn + Time up, and your dry signal passes untouched. + + **The controls.** Time sets the delay and with it the clock. Feedback + sends each repeat round again. Mix blends the repeats in. Modulation + and Mod Rate wobble the delay, a chorus in a blend and a vibrato with + the repeats alone. Spread feeds each side's repeats into the other. + Sync locks Time to Division of the host's beat. + Time runs from 20 to 600 ms, Feedback from 0 to 0.99 and Mix from 0 to 2. + Up to Mix 1 the dry passes untouched until the first repeat arrives. + Mix 0 is a wire. + A click comes out on the frame it went in: there is no latency. + A one-channel source gets the same effect with Spread held at 0. + + **Two characters.** `"single-line"` (the default) is one 4096-stage + line, the Boss DM-2; `"double-line"` is two in series, the Deluxe Memory + Man. Any other `character` raises `ValueError`. + The repeats' high-frequency corner is 0.2211 N / T for N stages and a + Time of T seconds, so it halves each time Time doubles. + At the same Time the double line's repeats are an octave brighter. + Where the law passes 0.98 of Nyquist the corner holds there, so a + shorter Time no longer brightens the repeats. + + **Time.** Every Time lands on a whole frame at every rate. + A Time move bends the repeats' pitch by T_old / T_new for exactly T_new + and then returns to unity, without a click. + A small Time move still lands. + Turning Time through several positions a block apart takes seconds to + settle, where one jump to the same place lands within the new Time. + + **Modulation.** The delay swings on a triangle by a fixed number of + milliseconds, whatever the Time. + A Time move leaves the swing as it is. + A Modulation move glides over 20 ms. + + **Level and tail.** Below Mix 1, an input peaking at or below + floor(32767 (1 - Mix)) - 1 does not reach the rail. + `tail_samples` is an upper bound on how long the repeats take to reach + exact zero after your input stops. + `reset()` empties the line and returns to patch 0. + With Sync on and a host tempo, Time is Division of the host's beat. + With no host tempo, Time stays on the knob. + + **What it leaves out.** There is no sample-and-hold, so the repeats + have no null at the clock. + There is no fixed anti-alias pair: the repeats' one corner is the one + that moves with Time. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + The tail rings only while the source keeps feeding: feed silence to let + it ring out. A tail cut short by a source that stopped carries on when + the source comes back. """ NAME = 'AnalogDelay' @@ -631,22 +474,17 @@ def _refresh(self): self._damping = nominal_damping_hz(self._corner, fs) # Handed as set: since audiodsp v0.6.3rc1 the node lands a loop - # low-pass that has stopped moving (#157), so the low-pass holds no - # Feedback's small value for ever and nothing is stepped clear - # here. The cross-feed's own stall is closed below, by Spread's - # grid. + # low-pass that has stopped moving (#157), and since v0.6.3rc3 a + # stereo cross-feed sum too (#170), so nothing is stepped clear. self._feedback = _between(self._value(FEEDBACK_I), 0.0, FEEDBACK_MAX) self._swing_ms = _between(self._value(MODULATION_I), 0.0, SWING_MAX_MS) # At one channel the node's cross-feed sends the repeat nowhere. - # At two, Spread goes on the 1/4096 grid, where the loop's sum of - # the two sides cannot land above both and hand a value back. if self._channel_count == 1: self._spread = 0.0 else: - self._spread = spread_on_grid( - _between(self._value(SPREAD_I), 0.0, 1.0)) + self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) self._delay.set( delay_slew=self._slew, delay_ms=self._node_ms, @@ -664,8 +502,7 @@ def tail_samples(self): an upper bound: `laps_to_zero(f, excess)` laps of the longest delay the read head may be at, plus the modulation's peak swing in frames rounded up, plus one frame for the interpolated read, plus the loop - low-pass's memory. Finite at every setting the class reaches, and - with Spread on its grid it holds at every Spread in stereo.""" + low-pass's memory. Finite at every setting the class reaches.""" self._check_live() return self._tail_bound() diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..6e8a1ae 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -73,8 +73,26 @@ #: Where a class cannot satisfy a property by design, it says so HERE, one #: row per (class NAME, event name prefix, property): reason. A matching #: cell prints `decl` instead of RED and is counted as a declared exception. -#: Nothing else skips a cell. Empty on purpose: no class has declared one. +#: Nothing else skips a cell. DECLARED = { + ("AnalogDelay", "E", "P5"): + "a control that jumps steps the output (family, audiocomponents#117);" + " a Time move bends the pitch on the clock's law by design", + ("AnalogDelay", "E8-dry", "P3"): + "a tail cut short by a stopped source carries on when it comes back" + " (family, audiodsp#180)", + ("AnalogDelay", "E2-", "P4"): + "the modulation triangle runs free, so a modulated patch never" + " matches a fresh instance after reset_buffer", + ("AnalogDelay", "E4-", "P4"): + "a Mod Rate move shifts the free-running triangle; a Time move's" + " walk still runs after the move back (glide kept, 2026-09-28)", + ("AnalogDelay", "E6-", "P4"): + "a patch change shifts the free-running triangle or leaves a Time" + " walk running after the move back (glide kept, 2026-09-28)", + ("AnalogDelay", "E11-", "P4"): + "a patch change shifts the free-running triangle or leaves a Time" + " walk running after the move back (glide kept, 2026-09-28)", } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index e5373c1..f955e8e 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -8,8 +8,8 @@ walk is read where the head lands, the binade pieces have a resolvable minimum, and T7's arrival clause is claimed across Mix's interior material by material, at every grid position and at any Mix at or above -1.01 x 0.5 / W on the two loud materials; and, from re-audit fix round 2, -R13: Spread handed on a 1/4096 grid so the stereo tail ends). Four of its +1.01 x 0.5 / W on the two loud materials; R13's Spread grid came out at +audiodsp v0.6.3rc3, whose node ends the stereo tail itself). Four of its Tier 2 rows can be demonstrated, T2a, T3, T6 and T7, and each is here as the measurement at a few of the row's cells beside the same measurement shown red on the row's planted fault at the constructor defaults. Every @@ -403,21 +403,14 @@ def _refresh(self): self._delay.set(feedback=stepped) -class RawSpread(AnalogDelay): - """The class as at 71e680b: Spread handed to the node as set, off the - 1/4096 grid. At two channels with Spread inside (0, 1) and a Feedback - a float32 step or two under 1 - 0.5 / k, the node's cross-feed sum - lands an ulp above a landed k and hands it back for ever.""" +class OneLapTail(AnalogDelay): + """Tail: one lap of the longest delay, where the bound counts the laps + to exact zero. Red on any tail that takes more than one lap.""" NAME = 'AnalogDelay' - def _refresh(self): - AnalogDelay._refresh(self) - if self._channel_count == 2: - spread = min(1.0, max(0.0, self._value(SPREAD_I))) - if spread != self._spread: - self._spread = spread - self._delay.set(cross_feed=spread) + def _tail_bound(self): + return self._reach + 1 class JumpModAnalog(AnalogDelay): @@ -1179,13 +1172,6 @@ def read_feedback_as_set(effect): - effect.macro(FEEDBACK_I), 9) -def read_spread_on_grid(effect): - """Whether the `cross_feed` handed to the node is a whole number of - 4096ths (RawSpread hands 39/127, which is not).""" - handed = float(effect._delay._handed["cross_feed"]) * 4096.0 - return handed == math.floor(handed) - - def read_modulation_move(effect): """On a copy at these positions, one Modulation move of 16 grid steps after a block has been pulled: the `delay_ms` handed less the @@ -1325,8 +1311,6 @@ def read_early(effect): ("SteppedAnalog", SteppedAnalog, read_feedback_as_set, {"feedback": 0.99}), ("JumpModAnalog", JumpModAnalog, read_modulation_move, {}), - ("RawSpread", RawSpread, read_spread_on_grid, - {"feedback": 0.9899999, "spread": 39.0 / 127.0}), ) @@ -2142,11 +2126,14 @@ def _cross_feed_tail(self, cls, feedback, spread, k): return declared, last, int(np.max(np.abs(past.astype(np.int64)))) def test_the_cross_feed_stall_cells_reach_zero(self): - # Re-audit round 1's Tier 1 failure (dossier section 8, R13): with - # Spread handed as set these cells hold k LSB on both lanes for - # ever. On the 1/4096 grid the cross-feed sum is exact and each - # ends inside the bound. Planted: RawSpread, the class as at - # 71e680b, red at all four. + # Re-audit round 1's Tier 1 failure (dossier section 8, R13): up to + # audiodsp v0.6.3rc2 these cells held k LSB on both lanes for ever + # with Spread handed as set, and the class put Spread on a 1/4096 + # grid. At v0.6.3rc3 the node ends them (audiodsp#170, #173), the + # grid is gone, and each ends inside the bound with Spread as set. + # The old plant (Spread off the grid) no longer goes red on this + # node; planted instead: OneLapTail, a bound of one lap, red at + # all four, so the check reads the tail. for feedback, spread, k in self.CROSS_FEED_CELLS: declared, last, past = self._cross_feed_tail( AnalogDelay, feedback, spread, k) @@ -2154,23 +2141,18 @@ def test_the_cross_feed_stall_cells_reach_zero(self): self.assertLessEqual(last, declared, (feedback, spread)) self.assertEqual(past, 0, (feedback, spread)) declared, last, past = self._cross_feed_tail( - RawSpread, feedback, spread, k) - self.assertEqual(past, k, (feedback, spread)) + OneLapTail, feedback, spread, k) + self.assertGreater(last, declared, (feedback, spread)) + self.assertGreater(past, 0, (feedback, spread)) - def test_spread_is_handed_on_the_grid(self): + def test_spread_is_handed_as_set(self): + # Since audiodsp v0.6.3rc3 the node hands Spread nothing to dodge. for rate in RATES: effect = AnalogDelay(silence_src(64, 2, rate), sample_rate=rate) for midi in range(128): effect.set_macro(SPREAD_I, midi) - knob = effect._value(SPREAD_I) - self.assertEqual(effect._spread * 4096.0, - math.floor(effect._spread * 4096.0), midi) - self.assertLessEqual(abs(effect._spread - knob), - 1.0 / 8192.0, midi) - effect.set_macro(SPREAD_I, 0) - self.assertEqual(effect._spread, 0.0) - effect.set_macro(SPREAD_I, 127) - self.assertEqual(effect._spread, 1.0) + self.assertEqual(effect._spread, effect._value(SPREAD_I), + midi) mono = AnalogDelay(silence_src(64, 1, rate), sample_rate=rate, spread=39.0 / 127.0) self.assertEqual(mono._spread, 0.0) @@ -2448,6 +2430,175 @@ def test_another_macro_leaves_a_walk_at_its_rate(self): self.assertEqual(effect._delay._handed["delay_slew"], slew) +# -------------------------------------------------------------------------- +# The docstring's claims, each tied to the test that asserts it + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the docstring that makes a claim is +#: here; one that could not be tied to a test was struck. +CLAIMS = ( + ("Time runs from 20 to 600 ms, Feedback from 0 to 0.99 and Mix from 0 " + "to 2.", "test_the_knob_spans"), + ("Up to Mix 1 the dry passes untouched until the first repeat arrives.", + "test_the_stops_and_the_corner"), + ("Mix 0 is a wire.", "test_mix_zero_is_a_wire_on_the_full_scale_ramp"), + ("A click comes out on the frame it went in: there is no latency.", + "test_click_delay_is_zero"), + ("A one-channel source gets the same effect with Spread held at 0.", + "test_mono_holds_spread_at_zero"), + ('`"single-line"` (the default) is one 4096-stage line, the Boss DM-2; ' + '`"double-line"` is two in series, the Deluxe Memory Man.', + "test_characters"), + ("Any other `character` raises `ValueError`.", "test_characters"), + ("The repeats' high-frequency corner is 0.2211 N / T for N stages and a " + "Time of T seconds, so it halves each time Time doubles.", + "test_the_named_cells_and_octaves"), + ("At the same Time the double line's repeats are an octave brighter.", + "test_the_named_cells"), + ("Where the law passes 0.98 of Nyquist the corner holds there, so a " + "shorter Time no longer brightens the repeats.", "test_characters"), + ("Every Time lands on a whole frame at every rate.", + "test_every_frame_count_lands_whole"), + ("A Time move bends the repeats' pitch by T_old / T_new for exactly " + "T_new and then returns to unity, without a click.", + "test_the_row_cell"), + ("A small Time move still lands.", "test_moves_under_eight_frames_land"), + ("Turning Time through several positions a block apart takes seconds to " + "settle, where one jump to the same place lands within the new Time.", + "test_turning_time_settles_in_seconds"), + ("The delay swings on a triangle by a fixed number of milliseconds, " + "whatever the Time.", "test_the_swing_reads_as_a_triangle"), + ("A Time move leaves the swing as it is.", + "test_modulation_is_the_table_and_a_time_move_leaves_it"), + ("A Modulation move glides over 20 ms.", + "test_a_modulation_move_does_not_step"), + ("Below Mix 1, an input peaking at or below floor(32767 (1 - Mix)) - 1 " + "does not reach the rail.", "test_the_input_ceiling"), + ("`tail_samples` is an upper bound on how long the repeats take to " + "reach exact zero after your input stops.", + "test_the_tail_reaches_exact_zero_inside_tail_samples"), + ("`reset()` empties the line and returns to patch 0.", + "test_reset_empties_the_line"), + ("With Sync on and a host tempo, Time is Division of the host's beat.", + "test_a_host_sets_time_from_division"), + ("With no host tempo, Time stays on the knob.", + "test_no_host_or_a_bad_tempo_leaves_time_on_the_knob"), + ("There is no sample-and-hold, so the repeats have no null at the " + "clock.", "test_no_null_at_the_clock"), + ("There is no fixed anti-alias pair: the repeats' one corner is the one " + "that moves with Time.", "test_the_only_corner_moves_with_time"), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "test_a_jumping_control_steps_the_output"), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", "test_a_tail_cut_short_carries_on"), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", "test_a_tail_cut_short_carries_on"), +) + + +def _flat(text): + return " ".join(text.split()) + + +def _cell(event, patch, channels=1, rate=RATE): + """One lifecycle matrix cell on the class, as measured, with the class's + DECLARED rows lifted so the raw verdict shows.""" + import lifecycle + ev = [e for e in lifecycle.events(AnalogDelay, patch) + if e.name == event][0] + saved = dict(lifecycle.DECLARED) + for key in list(lifecycle.DECLARED): + if key[0] == "AnalogDelay": + del lifecycle.DECLARED[key] + controls = {} + try: + return lifecycle.run_cell(AnalogDelay, ev, rate, channels, patch, {}, + controls) + finally: + lifecycle.DECLARED.clear() + lifecycle.DECLARED.update(saved) + for ctl in controls.values(): + ctl.close() + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(AnalogDelay.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_the_knob_spans(self): + effect = AnalogDelay(silence_src(64), sample_rate=RATE) + for index, low, high in ((TIME_I, 20.0, 600.0), + (FEEDBACK_I, 0.0, 0.99), + (MIX_I, 0.0, 2.0)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + + def test_turning_time_settles_in_seconds(self): + # Brad's ruling of 2026-09-28 keeps the walk as it is. At 48 kHz, + # wet alone, Feedback 0: MIDI 101 -> 111 as ten moves a block + # apart against one jump made with the last of them. The jump + # lands on a static 111 within its Time (391 ms); the ten moves + # still differ from the jump a second later, and match it by eight. + move_at = MOVE_AT + last = move_at + 9 * BLOCK + frames = last + 8 * RATE + values = sine_values(997.0, frames, RATE, 12000) + + def render(moves, midi=101): + effect = AnalogDelay(array_src(values, 1), sample_rate=RATE, + time_ms=grid_ms(midi), feedback=0.0, + mix=2.0) + + def on_block(frame): + for at, to in moves: + if frame == at: + effect.set_macro(TIME_I, to) + out = pull(effect, frames, 1, on_block=on_block).astype(int) + effect.deinit() + return out + + steps = render([(move_at + k * BLOCK, 102 + k) for k in range(10)]) + jump = render([(last, 111)]) + static = render([], midi=111) + landed = np.flatnonzero(jump != static) + self.assertLessEqual(int(landed[-1]) - last, + whole(grid_ms(111), RATE) + BLOCK) + settled = np.flatnonzero(steps != jump) + self.assertGreater(int(settled[-1]) - last, RATE) + self.assertLess(int(settled[-1]), frames - RATE // 10) + + def test_a_jumping_control_steps_the_output(self): + # The matrix's E5 cell at patch 4 (Mix 2 -> 0 and back) steps past + # its bar; the class declares it (audiocomponents#117). + res = _cell("E5-mix0", 4) + self.assertTrue(res["P5"].startswith("RED"), res) + self.assertEqual(res["P1"], "ok", res) + + def test_a_tail_cut_short_carries_on(self): + # The matrix's E8-dry cell at patch 4: the source hands back an + # empty buffer once, and when it comes back the tail it cut short + # plays out of the silence (audiodsp#180). + res = _cell("E8-dry", 4) + self.assertTrue(res["P3"].startswith("RED(peak"), res) + self.assertEqual(res["P2"], "ok", res) + + # -------------------------------------------------------------------------- # The two checks every planted fault and every row is held to diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..6a9d05b 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -100,15 +100,6 @@ class this branch carries, on CPython and on the two native interpreters. ('MultiTapDelay', 'E9-', 'P4'): ( 70, '123b91ee', None, None, 'Mix 0 and back leaves the class out of step with a fresh instance for good'), - ('AnalogDelay', 'E', 'P4'): ( - 37, '1266f888', 150, 'bb0595c6', - "the Modulation LFO's phase moves with the event, so a modulated patch never re-converges"), - ('AnalogDelay', 'E', 'P5'): ( - 74, 'b699cca1', 214, '8d248b43', - 'Time, Mix and patch moves step the output within one block'), - ('AnalogDelay', 'E8-dry', 'P3'): ( - 8, 'e1967d96', 8, 'e1967d96', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), ('Reverb', 'E', 'P4'): ( 322, '606b9d85', 1409, '722197fe', 'a network move, reset or reset_buffer never re-converges to a fresh instance (modulation phase?)'), From 44e4ec12ed35877440d2f8753c54d98df56e6af9 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:42:20 -0500 Subject: [PATCH 71/88] TapeDelay under the trial of the second process at audiodsp v0.6.3rc3: Spread handed as set, the docstring cut to its claims with a CLAIMS table, the lifecycle matrix's red cells declared The node ends a cross-fed tail itself since rc3 (audiodsp#173), so the 1/4096 Spread grid comes out and the RawSpreadTape plant is dropped: it was the class as it now is. Every docstring sentence that makes a claim is tied to a test in CLAIMS, the Cost figures read from BOARD_COST (measured at v0.6.2), and the docstring carries the family's two limits. The quick matrix's red cells for TapeDelay are declared in lifecycle.DECLARED (wobble phase across a reset, the varispeed pitch bend on Time moves, loop rounding after a Time move and back, and the two family limits) and its KNOWN_RED rows come out. --- CHANGELOG.md | 15 +- lib/audioeffects/rebuilt/tapedelay.py | 301 +++++--------------- tests/support/lifecycle.py | 30 +- tests/test_cpython_effects_tapedelay.py | 351 +++++++++++++++++++----- tests/test_lifecycle_matrix.py | 15 - 5 files changed, 387 insertions(+), 325 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64605b2..4e19bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,12 +58,11 @@ there, and are recorded in its changelog. drive one periodic wow table with a slow drift; Record Level is the loop's cubic squash, without memory. Mix 0 is a wire while the loop keeps recording. It lives in `lib/audioeffects/rebuilt/tapedelay.py`, and - `audioeffects.TapeDelay` is still the old class. The docstring states the - input ceiling (-1.1 dBFS peak at the defaults, -2.0 over the shipped - patches, on `noise_det`), the band the loss law holds in, the wobble's - 100 s period, where the pitch claim stops (the node's single-precision - walk limits rising moves past 16 384 and 32 768 frames), and which Wow - and Flutter moves still step. On the boards the full class is over its + `audioeffects.TapeDelay` is still the old class. Every sentence of the + docstring that makes a claim is tied to a test (`CLAIMS` in its test + file); it says where the pitch bend stops, which Wow and Flutter moves + still step, and the family's two limits (a control that jumps steps the + output; the tail rings only while the source feeds). On the boards the full class is over its budget (P4 10.3-11.4 % against 9 %, S3 19.8-20.5 % against 15 %, at audiodsp v0.6.2); patch 8 `Tape Delay - lean` (patch 0 with Record Level at 0) built with `max_time_ms=800` met both bars in every run, and @@ -72,7 +71,9 @@ there, and are recorded in its changelog. windows; the bound counts one landing lap there), a Wow or Flutter move that changes only the wobble's depth glides over 20 ms, and a knob turned down to 0 keeps the last wow table while the depth ramps out; a move - that changes the balance of Wow and Flutter still steps, disclosed. + that changes the balance of Wow and Flutter still steps, disclosed. At + v0.6.3rc3 Spread is handed as set: the node ends a cross-fed tail itself + (audiodsp#173), so the 1/4096 grid the class used is gone. - **`PingPongDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** repeats that alternate between the speakers, on one `audioecho.FeedbackDelay` whose cross-feed and input pan Spread moves between two plain delays and diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index cd20559..158510a 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -1,201 +1,63 @@ -"""`TapeDelay` - a tape loop with two transports: the RE-201's motor and the -EP-3's sliding head. - -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/TapeDelay.md`, whose trait table -was frozen at Station A before this file existed (anchor commit -fd711caf7cb421dff9c0f4d24c717f7d00548c4b, the Station A critique's -re-freeze, 2026-09-27). The old class in `delay.py` is consulted only for -the seven defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. - -**What it sounds like.** Your dry signal passes untouched, and repeats -follow it off a loop of tape, each one a little darker than the last, -because the playback head loses the top of the band once per pass. Time -(20-1 200 ms) is the delay. Feedback (0-0.99) is how much of each repeat -goes round again. Mix (0-2) is the echo return: dry at unity up to 1, the -repeats alone at 2, and Mix 0 is a wire while the loop keeps recording. -Spacing (2-20 um) is how far the worn head sits off the tape: more spacing, -darker repeats. Wow and Flutter are the transport's slow and fast wobble, -in cents. Record Level drives the tape harder, a soft odd-order squash on -every pass. Spread feeds each channel's repeats into the other. Sync locks -Time to Division of the host's beat. - -**Two characters, and they differ in what turning Time does.** -`character="varispeed"` (the default) is the Roland RE-201: Time moves the -motor, so a Time move bends the pitch of everything on the tape by the -ratio of the two times for exactly the new time, then settles, and the -repeats that went round during the move come back at their own pitch. -200 -> 100.4 ms reads +1 193 cents for 100.4 ms. The speed also moves the -loss: between 180 and 600 ms (40 to 12 cm/s) the repeats' corner falls by -the same 3.33x as the speed. Glide does nothing on this character; the -motor's own law sets how long a move takes. `character="sliding-head"` is -the Maestro EP-3: Time slides a head, so the pitch bends only while the -head moves, by 1 180 ms / Glide delay-seconds per second, and whatever -went round during the move keeps the bend for as long as it keeps going -round. Glide 0 is an instant slide, and its price is a click. The tape -runs at a fixed 20.32 cm/s there, so the loss does not follow Time. - -**The standout:** the Roland RE-201 Space Echo and the Maestro Echoplex -EP-3, as the tape literature models them (Zavalishin & Parker's two delay -types; Chowdhury's playback-loss law; the Echoplex's two transport -components and drift). - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The stock -`audiodelays.Echo` has no filter, drive or cross-feed in its loop, so the -darkening per pass has nowhere to live. On a stock CircuitPython board this -module imports cleanly and construction raises `ImportError`. - -**Latency: zero samples, at every setting, character and rate.** Nothing -looks ahead. The delay is the wet path, not latency on the dry path, and -no option adds any. - -**Mono.** A one-channel source gets the same effect on its one channel. -Spread is held at 0 there: at one channel the node's cross-feed sends a -repeat to a channel that does not exist, and Spread 1 would leave one -repeat and nothing after it. The class never passes `input_pan`. - -**RAM.** The line is `max_time_ms + 5` ms of two int16 lanes whatever the -channel count: 231 360 B at 48 kHz for the default 1 200 ms (212 560 B at -44.1 kHz, 106 280 B at 22.05 kHz), plus 16 384 B for two 4 096-point wow -tables (the node reads one while a Wow or Flutter move writes the other), -two 16 KB shape tables shared by every instance, and about 1.2 KB of -node. Pass a lower `max_time_ms` to spend less; Time then stops at that -ceiling and `get_macro(0)` shows where it stopped. - -**Cost, and what to run on a board.** One `audioecho.FeedbackDelay` with -`delay_slew`, a wow table, the loop low-pass and `loop_drive` on; no mixer. -The budget, from palette row FeedbackDelay +options with no glue, is -**P4 <= 9 % (0.480 ms), S3 <= 15 % (0.800 ms)** of a 5.333 ms stereo -block. **The full class is over it on both boards.** Measured at audiodsp -v0.6.2 at the default and every patch, it costs 0.551-0.608 ms a block on -the P4 (10.3-11.4 %) and 1.056-1.093 ms on the S3 (19.8-20.5 %); patch 6 -reads 0.470 ms (8.8 %) and 0.855 ms (16.0 %). It still runs in real time on -both. - -**To meet both bars, play patch 8, `Tape Delay - lean`, and build the class -with `max_time_ms=800`.** That configuration read 0.445-0.455 ms on the P4 -(8.3-8.5 %) and 0.781-0.797 ms on the S3 (14.6-14.9 %, a thin margin) in -each of three runs, measured at v0.6.2; the default and patch 5 built the -same way with the drive off read 0.434-0.447 and 0.764-0.794 ms. Patch 8 -is patch 0 with Record Level at 0, and the 800 ms line is the other half -of the saving. What you give up is the tape saturation: the repeats stay -clean however hard you play, and chords no longer grit up as the repeats -stack. And Time stops at 800 ms instead of 1 200 (patch 1's 789 ms still -fits, and `get_macro(0)` shows where Time stopped). The darkening, the -wobble and the pitch bends are patch 0's. Turning Record Level up again -brings the drive, and its cost, back, and so does `reset()`: like every -component's, it restores patch 0 (Record Level 25 on the grid, a drive of -0.197), so a board that resets a lean instance should call -`program_change(8)` after it. The boards are re-measured at the release. - -**What the default surrenders.** The darkening follows the tape's loss law -only up to a band top: one pole in the loop holds it to 2 dB from 100 Hz -to 2.9 kHz at 12 cm/s, 4.9 kHz at 20.32 cm/s and 6 kHz at 40 cm/s (at -5 um), and above that the repeats are lighter than tape, by 21 dB a pass -at 10 kHz and 12 cm/s. The fluctuation is periodic, not random: the wow -line, the flutter line and the slow drift are harmonics 72, 512 and 1-9 of -one table the node runs at 0.009991 Hz, so the whole wobble repeats every -100.09 s (100.04 s at 22.05 kHz). Record Level has no memory: tape -hysteresis is not modelled, and the squash is a static cubic, the same -rising or falling. The RE-201's Bass and Treble are not here; the loss law -and Spacing own the repeats' tone. - -**Where the pitch claim stops.** A varispeed move takes its rate from the -last Time handed to the node and runs once, so a Time move issued while the -last one is still gliding does not telescope as a real motor would: its -bend is written into the loop and stays there. - -The node walks the read head in single precision -(`audiodsp_feedback_delay.c:492`), so each step lands on the head's -rounding grid, and that grid doubles every time the head passes a power of -two in frames: 16 384 (341.3 ms at 48 kHz, 371.5 ms at 44.1, 743.0 ms at -22.05) and 32 768 (682.7 ms at 48 kHz, 743.0 ms at 44.1; never at -22.05 kHz, where 1 200 ms is 26 460 frames). On a rising move the pitch -error this makes grows as the pitch falls, so the claim stops where it -could pass 10 cents. On varispeed, a rising move whose walk passes 32 768 -frames is claimed up to a ratio of 2.95 : 1; past that the last part of -the walk can read 11 cents off (333 -> 1 100 ms reads -11.2 cents there). -On sliding-head, a rising move is claimed from Glide grid 4 (1 290.3 ms) -while the head stays under 16 384 frames, from grid 10 (1 438.5 ms) once -it passes 16 384, and from grid 22 (1 788.2 ms) once it passes 32 768. -Grid 1 is not claimed on a rising move (350 -> 450 ms reads +41 cents -there), nor is any constructor Glide faster than those edges. Falling -moves are claimed at every Glide, and at every ratio up to 3.33 : 1. - -**Turning Wow or Flutter while it plays.** Since audiodsp v0.6.3rc1 the -node ramps a new wobble depth in over 20 ms (audiodsp#160), so a move that -changes only how deep the wobble is glides: Wow with Flutter at 0, either -knob down to 0 on its own, or both up from 0. The class keeps the last -table handed while the depth ramps out to 0, so the old wobble leaves on -its own shape. On a 997 Hz tone at 12 000 LSB, wet only at 48 kHz, whose -own largest step through the loss low-pass is 728 LSB, Wow 32 -> 127 at -Flutter 0 steps at most 731 LSB in the 2 000 frames after it (1 057 at -v0.6.2) and Wow 127 -> 0 at most 740 (945 at v0.6.2, and 1 082 on the -fixed node without the kept table). While the depth travels the -extra pitch is the change over 20 ms times where the wobble is: up to -15 % (about 240 cents) for those 20 ms on the full 3 ms move at its crest. - -A move that changes the balance of Wow and Flutter still steps. It changes -the table's shape, and the node swaps a table at once, so the repeats jump -by the depth times the change in shape: Flutter 0 -> 127 at Wow grid 32 -steps 803 LSB against the tone's 728 (786 at v0.6.2). Turning both to 0 -one after the other passes through a table of one of them alone: Wow to 0 -first, with Flutter at grid 32, steps 1 117. Set the balance before you -play. - -**Input ceiling.** The dry path sits at unity and the repeats add to it, -and there is no input gain to turn down. Measured on the kit's `noise_det` -at 48 kHz over 20 s, the defaults put no sample on the rail from --1.1 dBFS peak down on either character, in stereo and in mono (at --1.0 dBFS 14 samples rail in stereo, 7 in mono), and every shipped patch -on either character from -2.0 dBFS down (patch 2, Short Slap, is the first -to rail on varispeed, at -1.9; patch 4, High Intensity, on sliding-head, at --1.8). - -**Tail.** `tail_samples` is an upper bound on how long the output takes to -reach exact zero after your input stops: `laps x (reach + wow + 1 + -memory)` frames, 14 laps at the default Feedback (240 282 frames, 5.01 s, -at 48 kHz) and 85 at patch 4's 0.8965. The loop low-pass is always in, and -at a Feedback a hair either side of 1 - 0.5 / k it can come to rest a hair -above k LSB and hand it back. Up to audiodsp v0.6.2 it did so for ever, -and the class handed the node a Feedback just outside each such window. -Since v0.6.3rc1 the node sets a stalled low-pass onto its input -(audiodsp#157), the Feedback you set is the one the node plays, and the -bound counts one more lap there: 686 laps at the 0.99 stop. - -In stereo the cross-feed could do the same thing. Spread mixes each -channel's loop with the other's, which in exact arithmetic never lands -above the larger of the two, but the node's single-precision sum can come -out a step above two equal lanes. With Spread strictly between its stops -and off a short binary grid (Spread 1/127 on the knob, or 0.1726 typed) -and a Feedback a float32 step or two under 1 - 0.5 / k, that handed k LSB -(9 to 50 in the cells found) back for ever. So Spread reaches the node on -a grid of 4096ths, within 1/8192 of the knob, and 0 and 1 are untouched. -There, two equal whole lanes under 4 096 LSB sum to themselves exactly on -every interpreter, whether or not a board's compiler fuses the sum. The -lanes are whole at rest because the node lands a stalled low-pass on its -tap, so the closure is the grid and that landing together. The bound then -holds at every Feedback and Spread the constructor or a macro can hand, -stereo and mono. After a falling Time move it keeps the Time the head -walked from until a reset, because the class cannot see how far the walk -has got. - -`capabilities = ("tempo_sync",)`: with Sync on, the class reads -`self._transport()` on every macro move and program change (not per block). -With no host transport, or a host whose tempo is not a finite positive -number (0, negative, NaN, infinite or missing), Time stays where the knob -is. A synced Time change moves the way the character moves Time. - -A constructor value stays on the audio path unrounded by the knob's grid -where the grid would move it: Time (landed on a whole frame at the running -rate, so 350 ms is 7 718 frames at 22.05 kHz) and Glide. A constructor -Glide faster than grid 1 keeps its own walk (pinned at 0.99 under -1 191.9 ms) and the knob reads back at grid 1, never at grid 0, the jump; -one slower than 12 s plays 12 s; 0, a negative or NaN is the jump. A Time -of 0 or less is 20 ms, a Spacing of 0 or less is 2 um, a `max_time_ms` -above 1 200 or NaN is 1 200 ms, and any other NaN takes that option's -default. `character` must be `"varispeed"` or `"sliding-head"`. +"""`TapeDelay` - a tape echo with two transports: the Roland RE-201's motor +and the Maestro EP-3's sliding head. + +Your dry signal passes untouched, and the repeats follow it off a loop of +tape, each one a little darker than the last. + +**Controls.** Time is the delay, from 20 to 1200 ms, and Feedback is how +much of each repeat goes round again, up to 0.99. Mix is the echo return: +the dry stays at unity up to Mix 1, Mix 2 is the repeats alone, and at Mix 0 +the output is the input. Spacing is how far the worn head sits off the tape: +more spacing, darker repeats. Wow and Flutter are the transport's slow and +fast wobble. Record Level drives the tape harder, a soft squash on every +pass. Spread feeds each channel's repeats into the other, and does nothing +on a mono source. With Sync on, Time is Division of the host's beat; with no +host tempo, Time stays where the knob is. + +**Two characters.** `character="varispeed"`, the default, is the RE-201: +Time moves the motor, so a Time move bends the pitch of everything on the +tape instead of clicking, then settles. A longer Time runs the tape slower, +and the repeats come back darker. Glide does nothing on this character. +`character="sliding-head"` is the EP-3: Time slides a head, so the pitch +bends only while the head moves, at the rate Glide sets, and the tape runs +at one speed, so the darkening does not follow Time. Glide 0 is an instant +slide, and its price is a click. + +**Where the pitch bend stops.** A Time move made while the last one is still +bending takes its rate from the last Time you set, not from where the tape +has got to, so it does not telescope as a real motor would. A long rising +move, or a rising slide at the fastest Glides, can read more than 10 cents +off the ideal bend, because the node walks its read head in single +precision. + +**The tape.** The wobble is periodic, not random. A Wow or Flutter move that +changes only how deep the wobble is glides in, and one that changes their +balance steps, so set the balance before you play. The darkening follows +the tape's loss law only up to a band top that rises with the tape's speed. +Record Level has no memory: tape hysteresis is not modelled. The RE-201's +Bass and Treble are not here. + +**Limits shared by the family.** A control that jumps makes the output step: +move it in small steps from the host if you need it smooth. The tail rings +only while the source keeps feeding: feed silence to let it ring out. A tail +cut short by a source that stopped carries on when the source comes back. + +**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 and Spread, stereo and mono. +Pass a lower `max_time_ms` for a shorter line: Time then stops at that +ceiling, and `get_macro(0)` shows where it stopped. The class needs +audiodsp's `audioecho`, and on a board without it construction raises +`ImportError`. `character` must be `"varispeed"` or `"sliding-head"`. + +**Cost.** Measured at v0.6.2 at the default and every patch, the full class +costs 0.551-0.608 ms a block on the P4 and 1.056-1.093 ms on the S3, over the +budgets of 0.480 ms and 0.800 ms. Patch 8, `Tape Delay - lean`, is patch 0 +with Record Level at 0: the repeats stay clean however hard you play. +Measured at v0.6.2, patch 8 on a class built with `max_time_ms=800` costs +0.445-0.455 ms on the P4 and 0.781-0.797 ms on the S3, inside both budgets. +`reset()` returns to patch 0, so a host that wants the lean patch sets it +again after a reset. """ VENDOR = "PyDevices" @@ -247,10 +109,6 @@ #: The node's own loop ceiling (`audiodsp_feedback_delay.c:157`). FEEDBACK_MAX = 0.99 -#: Spread's grid as the node is handed it at two channels: whole 4096ths, -#: so the loop's cross-feed sum of two equal whole lanes is exact. -_SPREAD_GRID = 4096 - #: The line's headroom over `max_time_ms`: one frame for the node's #: `line_frames - 2` clamp (`audiodsp_feedback_delay.c:148-150`) plus the #: wow table's largest excursion, 3.096 ms at the Wow and Flutter stops, @@ -463,22 +321,6 @@ def wow_table(wow_cents, flutter_cents, out, flutter_harmonic=FLUTTER_HARMONIC, return peak -def _spread_on_grid(spread): - """`spread` on the 1/4096 grid the node is handed at two channels - (dossier section 8, re-audit fix round 2; AnalogDelay's law). The node - sends `own * (1 - s) + other * s` round the loop in single precision. - With s off a short binary grid (1/127, or a typed 0.1726) those two - products can add up to one float32 step above two equal lanes, and at a - Feedback a step or two under 1 - 0.5 / k that hands a landed k LSB back - for ever. On the grid both products of a whole lane under 4096 LSB are - exact, so two equal whole lanes sum to themselves however the sum is - rounded or fused. The lanes are whole at rest because the node lands a - stalled loop low-pass on its tap (audiodsp#157): the closure is the grid - and that landing together. The grid moves Spread by at most 1/8192 and - leaves 0 and 1 where they are.""" - return math.floor(spread * _SPREAD_GRID + 0.5) / _SPREAD_GRID - - def _option(value, default): """A constructor option as a float; NaN is the option's default.""" value = float(value) @@ -496,16 +338,8 @@ def _between(value, low, high): class TapeDelay(_component.Component): - """A tape echo with the RE-201's motor (`character="varispeed"`) or the - EP-3's sliding head (`"sliding-head"`): each repeat darker than the - last, a wobbling transport, and Time moves that bend the pitch instead - of clicking. audiodsp tier; zero latency. - - **What the default surrenders:** the darkening follows the tape's loss - law only to a band top (2.9-6 kHz at 5 um, by speed) and is lighter - than tape above it; the wobble repeats every 100.09 s; Record Level has - no memory; no Bass or Treble. Glide is inert on varispeed. - """ + """A tape echo with the RE-201's motor or the EP-3's sliding head. + The module docstring is the player's manual.""" NAME = 'TapeDelay' DISPLAY_NAME = 'Tape Delay' @@ -857,13 +691,13 @@ def _refresh(self): self._wow_key = key # At one channel the node's cross-feed sends the repeat nowhere. - # At two, Spread goes on the 1/4096 grid, where the loop's sum of - # two landed lanes cannot come out above both and hand a value back. + # At two, Spread is handed as set: since audiodsp v0.6.3rc3 a + # cross-fed tail reaches exact zero however the node rounds the + # cross-feed sum (audiodsp#173), so the 1/4096 grid is gone. if self._channel_count == 1: self._spread = 0.0 else: - self._spread = _spread_on_grid( - _between(self._value(SPREAD_I), 0.0, 1.0)) + self._spread = _between(self._value(SPREAD_I), 0.0, 1.0) self._delay.set( delay_slew=self._slew, delay_ms=node_ms, @@ -884,8 +718,7 @@ def tail_samples(self): the read head may be at, plus the wow table's peak excursion in frames rounded up, plus one frame for the interpolated read, plus the loop low-pass's memory. Finite at every setting the class - reaches, and in stereo it holds at every Spread because Spread is - handed on its 1/4096 grid (the module docstring's Tail).""" + reaches, stereo and mono, at every Spread.""" self._check_live() return self._tail_bound() diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..55f0ecb 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -73,8 +73,36 @@ #: Where a class cannot satisfy a property by design, it says so HERE, one #: row per (class NAME, event name prefix, property): reason. A matching #: cell prints `decl` instead of RED and is counted as a declared exception. -#: Nothing else skips a cell. Empty on purpose: no class has declared one. +#: Nothing else skips a cell. DECLARED = { + ("TapeDelay", 'E1-', 'P4'): + 'reset() restarts the wow and flutter table, so the wobble is never in phase with a fresh instance again', + ("TapeDelay", 'E2-', 'P4'): + 'a host reset_buffer restarts the wow and flutter table, so the wobble is never in phase with a fresh instance again', + ("TapeDelay", 'E4-m0=0', 'P4'): + "a Time move and back leaves other rounding in the loop than a fresh instance's; while material plays a 1 LSB difference can outlast tail_samples, which bounds silence", + ("TapeDelay", 'E4-m0=', 'P5'): + "varispeed bends the pitch on a Time move: a bend up past 1.5x steepens the triangle past P5's limit, and T1a's step clause holds (no click)", + ("TapeDelay", 'E11-2macro', 'P5'): + "varispeed bends the pitch on a Time move: a bend up past 1.5x steepens the triangle past P5's limit, and T1a's step clause holds (no click)", + ("TapeDelay", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("TapeDelay", 'E4-m4=', 'P5'): + "family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Wow: a balance move swaps the table's shape)", + ("TapeDelay", 'E5-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("TapeDelay", 'E6-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("TapeDelay", 'E9-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("TapeDelay", 'E11-mix+patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix and a patch change)', + ("TapeDelay", 'E11-2patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("TapeDelay", 'E11-3moves', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Time and a patch change)', + ("TapeDelay", 'E8-dry', 'P3'): + 'family limit, disclosed (audiodsp#180): a tail cut short by a source that stopped carries on when the source comes back', } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 0085917..98630a1 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -12,14 +12,19 @@ every Spacing and Time position, three rates for every cell) live in the evidence pack, not in this file. -Re-audit fix round 2 (2026-09-28) adds Tier 1's cross-feed stall: the five -stall cells and the kit's TAIL over Spread's whole travel, each beside -`RawSpreadTape` (the class before Spread was handed on the 1/4096 grid), -and the reset sentence of the lean patch's paragraph. +Re-audit fix round 2 (2026-09-28) added Tier 1's cross-feed stall: the five +stall cells and the kit's TAIL over Spread's whole travel. The trial of the +second process (2026-09-28, audiodsp v0.6.3rc3) hands Spread as set, since +the node cures the stall itself (audiodsp#173), and drops the +`RawSpreadTape` plant: it was the class as it now is, and at rc3 it ends. +`CLAIMS` ties every sentence of the module docstring to the test that +asserts it, and `BOARD_COST` holds the board figures the Cost paragraph +quotes. """ import math import os +import re import sys import unittest from array import array @@ -30,6 +35,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import audiocore # noqa: E402 +import lifecycle # noqa: E402 import kit_faults # noqa: E402 import kit_probes as probes # noqa: E402 from audioeffects import _component # noqa: E402 @@ -445,24 +451,6 @@ def _refresh(self): delay_ms=self._node_ms + self._wow_ms - old) -class RawSpreadTape(TapeDelay): - """Tier 1 TAIL: the class as at a5675d7, Spread handed to the node as - set at two channels, off the 1/4096 grid. With Spread strictly inside - (0, 1) and a Feedback a float32 step or two under 1 - 0.5 / k, the - node's cross-feed sum lands a float32 step above two landed k lanes and - hands k back for ever (re-audit round 1).""" - - NAME = 'TapeDelay' - - def _refresh(self): - TapeDelay._refresh(self) - if self._channel_count == 2: - spread = min(1.0, max(0.0, self._value(SPREAD_I))) - if spread != self._spread: - self._spread = spread - self._delay.set(cross_feed=spread) - - # -- sources and renders -------------------------------------------------- def src_of(x, channels=2, rate=RATE): @@ -2063,6 +2051,74 @@ def test_the_dry_is_unity_until_the_repeat(self): out = render(effect, 4096)[:, 0] self.assertTrue(np.array_equal(out, np.round(x))) + def test_the_control_spans(self): + # Time runs 20 to 1 200 ms and Feedback stops at 0.99, handed so. + effect = TapeDelay(src_of(np.zeros(512))) + effect.set_macro(TIME_I, 0) + self.assertAlmostEqual(effect.macro(TIME_I), 20.0, places=9) + self.assertEqual(effect._frames, frames_of(20.0)) + effect.set_macro(TIME_I, 127) + self.assertAlmostEqual(effect.macro(TIME_I), 1200.0, places=9) + self.assertEqual(effect._frames, frames_of(1200.0)) + effect.set_macro(FEEDBACK_I, 0) + self.assertEqual(effect._feedback, 0.0) + effect.set_macro(FEEDBACK_I, 127) + self.assertEqual(effect._feedback, 0.99) + + def test_mix_2_is_the_repeats_alone(self): + # A click at Mix 2: nothing until the repeat, 100 ms later. + x = np.zeros(8192) + x[10] = 30000 + out = render(TapeDelay(src_of(x), mix=2.0, time_ms=100.0, + feedback=0.0), 8192)[:, 0] + self.assertEqual(float(np.max(np.abs(out[:4800]))), 0.0) + self.assertGreater(float(np.max(np.abs(out[4800:5200]))), 1000.0) + + def test_construction_needs_audioecho(self): + saved = sys.modules.get("audioecho", False) + sys.modules["audioecho"] = None + try: + with self.assertRaises(ImportError): + TapeDelay(src_of(np.zeros(512))) + finally: + if saved is False: + del sys.modules["audioecho"] + else: + sys.modules["audioecho"] = saved + TapeDelay(src_of(np.zeros(512))).deinit() + + def _tail_across_a_stop(self, stop): + """A 50 ms burst into Time 100 ms, Feedback 0.5, Mix 2, then + silence; after 24 pulls the source hands empty buffers for `stop` + pulls, then silence again. Returns (the bytes handed while it was + stopped, the 40 blocks pulled after it came back).""" + feed = lifecycle.Feed(array("h", np.repeat( + np.round(sine(TONE, 12000.0, 2400)).astype(np.int16), 2) + .tobytes()), RATE, 2, "256", False) + effect = TapeDelay(feed.port, sample_rate=RATE, time_ms=100.0, + feedback=0.5, mix=2.0) + for _ in range(24): + audiocore.get_buffer(effect.output) + feed.point(feed.empty) + stopped = bytearray() + for _ in range(stop): + stopped.extend(bytes(audiocore.get_buffer(effect.output)[1])) + feed.point(feed.sil) + after = bytearray() + while len(after) < 40 * BLOCK * 4: + after.extend(bytes(audiocore.get_buffer(effect.output)[1])) + effect.deinit() + return bytes(stopped), bytes(after[:40 * BLOCK * 4]) + + def test_the_tail_waits_for_the_source(self): + # The family limit (audiocomponents#117, audiodsp#180): a source + # that hands empty buffers stops the tail; when it feeds again the + # tail carries on where it was, as if the stop had not happened. + stopped, after = self._tail_across_a_stop(30) + self.assertEqual(stopped.strip(b"\x00"), b"") + self.assertGreater(max(abs(v) for v in array("h", after)), 1000) + self.assertEqual(after, self._tail_across_a_stop(0)[1]) + def test_silence_stays_silence(self): for character in tape.CHARACTERS: for patch in range(len(TapeDelay.PATCHES)): @@ -2116,11 +2172,12 @@ def test_spread_does_not_silence_mono(self): x = np.zeros(RATE) x[32] = 12000 outs = [] - for spread in (0.0, 1.0): + for spread in (0.0, 37.0 / 127.0, 1.0): effect = TapeDelay(src_of(x, 1), time_ms=100.0, feedback=0.7, mix=1.0, spread=spread) outs.append(render(effect, RATE).tobytes()) self.assertEqual(outs[0], outs[1]) + self.assertEqual(outs[0], outs[2]) def test_the_transport_is_read_only_with_sync_on(self): reads = [] @@ -2296,11 +2353,13 @@ def build(src): return declared, tail, int(np.max(np.abs(out[-1]))), handed def test_the_cross_feed_stall_cells_reach_zero(self): - # Re-audit round 1's Tier 1 failure: with Spread handed as set these - # cells hold k LSB on both lanes for ever. With Spread on the 1/4096 - # grid each ends inside the bound, in stereo and (Spread held at 0) - # in mono. Planted: RawSpreadTape, the class as at a5675d7, holds - # exactly k at every one. + # Re-audit round 1's Tier 1 failure: up to audiodsp v0.6.3rc2 these + # cells held k LSB on both lanes for ever with Spread handed as set, + # and the class put Spread on a 1/4096 grid. Since v0.6.3rc3 the node + # ends a cross-fed tail itself (#173): Spread is handed as set and + # each cell ends inside the bound, in stereo and (Spread held at 0) + # in mono. The trial dropped the RawSpreadTape plant: it was the + # class as it now is, and at rc3 it ends too. for cell in self.CROSS_FEED_CELLS: route, k, feedback, spread = cell for channels in (2, 1): @@ -2311,37 +2370,26 @@ def test_the_cross_feed_stall_cells_reach_zero(self): self.assertEqual(final, 0, (cell, channels)) self.assertGreater(tail, 0, (cell, channels)) self.assertLessEqual(tail, declared, (cell, channels)) - declared, tail, final, handed = self._cross_feed_stall( - RawSpreadTape, cell) - self.assertEqual(final, k, cell) - def test_spread_is_handed_on_the_grid(self): - # At two channels Spread reaches the node on the 1/4096 grid, within - # 1/8192 of the knob, 0 and 1 untouched; the constructor's too. + def test_spread_is_handed_as_set(self): + # At two channels Spread reaches the node as the knob sets it, the + # constructor's too; at one it is held at 0. The 1/4096 grid the + # class used up to v0.6.3rc2 is gone. for rate in (48000, 44100, 22050): effect = TapeDelay(src_of(np.zeros(512), 2, rate)) with NodeSpy(): for midi in [m / 4.0 for m in range(4 * 127 + 1)]: effect.set_macro(SPREAD_I, midi) - knob = effect._value(SPREAD_I) - handed = effect._delay._handed["cross_feed"] * 4096.0 - self.assertEqual(handed, math.floor(handed), midi) - self.assertEqual(effect._spread * 4096.0, handed, midi) - self.assertLessEqual(abs(effect._spread - knob), - 1.0 / 8192.0, midi) - effect.set_macro(SPREAD_I, 0) - self.assertEqual(effect._spread, 0.0) - effect.set_macro(SPREAD_I, 127) - self.assertEqual(effect._spread, 1.0) + knob = min(1.0, max(0.0, effect._value(SPREAD_I))) + self.assertEqual(effect._delay._handed["cross_feed"], + knob, midi) + self.assertEqual(effect._spread, knob, midi) typed = TapeDelay(src_of(np.zeros(512), 2, rate), spread=0.1726040393114090) - self.assertEqual(typed._spread, 707.0 / 4096.0) + self.assertEqual(typed._spread, 0.1726040393114090) mono = TapeDelay(src_of(np.zeros(512), 1, rate), spread=37.0 / 127.0) self.assertEqual(mono._spread, 0.0) - # Planted: the class as at a5675d7 hands 37/127 as set. - raw = RawSpreadTape(src_of(np.zeros(512)), spread=37.0 / 127.0) - self.assertEqual(raw._spread, 37.0 / 127.0) def _depth_move(self, cls, start, target, points=8): """(the tone's own largest step before the move, the largest step in @@ -2445,22 +2493,18 @@ def measure(settings): class CrossFeedTailSweep(unittest.TestCase): """Tier 1 TAIL over Spread's whole travel at the re-refuter's two - Feedbacks (re-audit round 1): the class is green at all 128 positions, - and RawSpreadTape, the class as at a5675d7, is red exactly where the - re-refutation found it (MIDI 1 and 5 at 0.9444443583, 2 and 39 at - 0.9899999499).""" + Feedbacks (re-audit round 1): the class is green at all 128 positions. + Up to audiodsp v0.6.3rc2 Spread handed as set was red at MIDI 1 and 5 + (0.9444443583) and 2 and 39 (0.9899999499); at rc3 the node ends those + tails itself (#173).""" def test_the_tail_ends_at_every_spread(self): - for feedback, raw_red in ((0.9444443583488464, [1.0, 5.0]), - (0.9899999499320984, [2.0, 39.0])): + for feedback in (0.9444443583488464, 0.9899999499320984): result, red = spread_tail_sweep(TapeDelay, feedback) self.assertEqual(result["values"]["points"], 128) self.assertEqual(red, [], feedback) self.assertFalse(result["red"], feedback) self.assertLess(result["values"]["worst"], 1.0, feedback) - result, red = spread_tail_sweep(RawSpreadTape, feedback) - self.assertEqual(red, raw_red, feedback) - self.assertTrue(result["red"], feedback) class InputCeiling(unittest.TestCase): @@ -2675,13 +2719,6 @@ def read_python_steps(effect): return writes -def read_spread_on_grid(effect): - """Whether the `cross_feed` handed to the node is a whole number of - 4096ths (RawSpreadTape hands 37/127, which is not).""" - handed = float(effect._delay._handed["cross_feed"]) * 4096.0 - return handed == math.floor(handed) - - #: (name, fault, reading, constructor options for both builds). A fault #: whose law is live only away from the defaults is built where it is live #: (DigitalDelay's corner-cell precedent): `GlideScaledVarispeed` is the @@ -2709,10 +2746,6 @@ def read_spread_on_grid(effect): ("FlutterOnWowLineTape", FlutterOnWowLineTape, read_table, {}), ("Harmonic504Tape", Harmonic504Tape, read_table, {}), ("NoDriftTape", NoDriftTape, read_table, {}), - # Re-audit fix round 2: built at an interior Spread off the grid, where - # the plant hands the node something the class never does. - ("RawSpreadTape", RawSpreadTape, read_spread_on_grid, - {"feedback": 0.9666665792, "spread": 37.0 / 127.0}), ) @@ -2877,5 +2910,187 @@ def test_every_demonstrated_row_is_red_on_a_wire(self): or null["drift_ok"], null) +# -- the docstring's claims ----------------------------------------------- + +#: The board figures the docstring's Cost quotes, in ms per 256-frame stereo +#: block (budget: palette row FeedbackDelay +options with no glue). Measured +#: at audiodsp v0.6.2 (the full class at the default and every patch; patch +#: 8 at `max_time_ms=800`, three runs); re-measured once at the final release. +BOARD_COST = { + "measured_at": "v0.6.2", + "budget": {"P4": 0.480, "S3": 0.800}, + "full": {"P4": (0.551, 0.608), "S3": (1.056, 1.093)}, + "lean": {"P4": (0.445, 0.455), "S3": (0.781, 0.797)}, +} + +#: Every claim the module docstring makes, word for word, and the tests that +#: assert it ("Class.test_name", in this file). +CLAIMS = ( + ("Time is the delay, from 20 to 1200 ms, and Feedback is how much of " + "each repeat goes round again, up to 0.99.", + ("Tier1Fast.test_the_control_spans",)), + ("Mix is the echo return: the dry stays at unity up to Mix 1, Mix 2 is " + "the repeats alone, and at Mix 0 the output is the input.", + ("Tier1Fast.test_the_dry_is_unity_until_the_repeat", + "Tier1Fast.test_mix_2_is_the_repeats_alone", + "Tier1Fast.test_mix_zero_is_a_wire_on_the_full_scale_ramp")), + ("Spread feeds each channel's repeats into the other, and does nothing " + "on a mono source.", + ("Tier1Fast.test_spread_does_not_silence_mono", + "Tier1Fast.test_spread_is_handed_as_set")), + ("With Sync on, Time is Division of the host's beat; with no host " + "tempo, Time stays where the knob is.", + ("Tier1Fast.test_the_transport_is_read_only_with_sync_on", + "Tier1Fast.test_a_host_without_a_tempo_leaves_time_on_the_knob")), + ("`character=\"varispeed\"`, the default, is the RE-201: Time moves the " + "motor, so a Time move bends the pitch of everything on the tape " + "instead of clicking, then settles.", + ("T1aVarispeed.test_the_named_moves",)), + ("A longer Time runs the tape slower, and the repeats come back darker.", + ("T3LossFollowsSpeed.test_the_span_at_the_named_cells",)), + ("Glide does nothing on this character.", + ("GlideIsInertOnVarispeed.test_three_glides_one_render",)), + ("`character=\"sliding-head\"` is the EP-3: Time slides a head, so the " + "pitch bends only while the head moves, at the rate Glide sets, and " + "the tape runs at one speed, so the darkening does not follow Time.", + ("T1bSlidingHead.test_the_ramp", "TheSurface.test_the_loss_corner")), + ("Glide 0 is an instant slide, and its price is a click.", + ("T1bSlidingHead.test_the_step_at_glide_0",)), + ("A Time move made while the last one is still bending takes its rate " + "from the last Time you set, not from where the tape has got to, so it " + "does not telescope as a real motor would.", + ("Tier1Fast.test_a_move_inside_a_walk_takes_its_rate_from_the_last_time",)), + ("A long rising move, or a rising slide at the fastest Glides, can read " + "more than 10 cents off the ideal bend, because the node walks its " + "read head in single precision.", + ("T1aVarispeed.test_the_top_binade_per_piece", + "T1bSlidingHead.test_the_glide_binades")), + ("The wobble is periodic, not random.", + ("TheSurface.test_the_table_holds_its_three_components",)), + ("A Wow or Flutter move that changes only how deep the wobble is glides " + "in, and one that changes their balance steps, so set the balance " + "before you play.", + ("Tier1Fast.test_a_depth_move_does_not_step", + "Tier1Fast.test_a_balance_move_steps_as_the_docstring_says")), + ("The darkening follows the tape's loss law only up to a band top that " + "rises with the tape's speed.", + ("T2LossLaw.test_one_pass_at_the_named_cells",)), + ("Record Level has no memory: tape hysteresis is not modelled.", + ("T5NoMemory.test_record_level_draws_no_hysteresis_loop",)), + ("The RE-201's Bass and Treble are not here.", + ("TheSurface.test_macros_characters_tier_latency",)), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("Tier1Fast.test_a_balance_move_steps_as_the_docstring_says",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", + ("Tier1Fast.test_the_tail_waits_for_the_source",)), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", + ("Tier1Fast.test_the_tail_waits_for_the_source",)), + ("Latency is zero samples: nothing looks ahead.", + ("Tier1Fast.test_click_delay_is_zero",)), + ("`tail_samples` is an upper bound on how long the output takes to " + "reach exact zero once your input stops, at every Feedback and Spread, " + "stereo and mono.", + ("Tier1Fast.test_the_tail_reaches_exact_zero_inside_tail_samples", + "Tier1Fast.test_the_cross_feed_stall_cells_reach_zero", + "CrossFeedTailSweep.test_the_tail_ends_at_every_spread")), + ("Pass a lower `max_time_ms` for a shorter line: Time then stops at " + "that ceiling, and `get_macro(0)` shows where it stopped.", + ("LeanPatch.test_the_lean_build_renders_what_the_boards_measured",)), + ("The class needs audiodsp's `audioecho`, and on a board without it " + "construction raises `ImportError`.", + ("Tier1Fast.test_construction_needs_audioecho",)), + ("`character` must be `\"varispeed\"` or `\"sliding-head\"`.", + ("TheSurface.test_macros_characters_tier_latency",)), + ("Measured at v0.6.2 at the default and every patch, the full class " + "costs 0.551-0.608 ms a block on the P4 and 1.056-1.093 ms on the S3, " + "over the budgets of 0.480 ms and 0.800 ms.", + ("Claims.test_the_board_figures_are_the_table",)), + ("Patch 8, `Tape Delay - lean`, is patch 0 with Record Level at 0: the " + "repeats stay clean however hard you play.", + ("LeanPatch.test_the_lean_patch_is_patch_0_with_the_drive_off",)), + ("Measured at v0.6.2, patch 8 on a class built with `max_time_ms=800` " + "costs 0.445-0.455 ms on the P4 and 0.781-0.797 ms on the S3, inside " + "both budgets.", + ("Claims.test_the_board_figures_are_the_table", + "LeanPatch.test_the_lean_build_renders_what_the_boards_measured")), + ("`reset()` returns to patch 0, so a host that wants the lean patch " + "sets it again after a reset.", + ("LeanPatch.test_reset_brings_the_drive_back",)), +) + +#: Model names that carry digits and are not figures. +NAMES = ("RE-201", "EP-3") + +FAMILY_HEADING = "**Limits shared by the family.**" + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims=CLAIMS): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a digit outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(globals().get(owner), test): + problems.append("no test %s" % name) + for name in NAMES: + rest = rest.replace(name, " ") + for match in re.finditer(r"\S*\d\S*", rest): + problems.append("figure outside a claim: %s" % match.group()) + return problems + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claim_problems(tape.__doc__), []) + self.assertEqual(claim_problems(TapeDelay.__doc__, ()), []) + self.assertIn(FAMILY_HEADING, _flat(tape.__doc__)) + # The checker can fail: a figure outside a claim, a claim the + # docstring does not carry, a test that does not exist. + self.assertTrue(claim_problems(tape.__doc__ + " It reads 12 ms.")) + self.assertTrue(claim_problems(tape.__doc__.replace( + "nothing looks ahead", "nothing looks back"))) + self.assertTrue(claim_problems(tape.__doc__, CLAIMS + ( + ("The wobble is periodic, not random.", + ("Tier1Fast.test_nothing_here",)),))) + + def test_the_board_figures_are_the_table(self): + cost = BOARD_COST + doc = _flat(tape.__doc__) + + def span(pair): + return "%.3f-%.3f ms" % pair + full = ("Measured at %s at the default and every patch, the full " + "class costs %s a block on the P4 and %s on the S3, over " + "the budgets of %.3f ms and %.3f ms." % ( + cost["measured_at"], span(cost["full"]["P4"]), + span(cost["full"]["S3"]), cost["budget"]["P4"], + cost["budget"]["S3"])) + lean = ("Measured at %s, patch 8 on a class built with " + "`max_time_ms=800` costs %s on the P4 and %s on the S3, " + "inside both budgets." % ( + cost["measured_at"], span(cost["lean"]["P4"]), + span(cost["lean"]["S3"]))) + self.assertIn(full, doc) + self.assertIn(lean, doc) + # "over" and "inside" are what the table says. + for board in ("P4", "S3"): + self.assertGreater(cost["full"][board][0], cost["budget"][board]) + self.assertLess(cost["lean"][board][1], cost["budget"][board]) + self.assertEqual(TapeDelay.PATCHES[8][0], "Tape Delay - lean") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..4c8087e 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -61,21 +61,6 @@ class this branch carries, on CPython and on the two native interpreters. ('SlapbackDelay', 'E8-dry', 'P3'): ( 16, '95d6ec10', 16, '95d6ec10', 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('TapeDelay', 'E', 'P5'): ( - 34, '60c8dd58', 106, '4ce31e31', - 'Time, Mix, Wow and patch moves step the output within one block'), - ('TapeDelay', 'E1-', 'P4'): ( - 72, '407e821c', 72, '407e821c', - 'reset() lands patch 0 at another moment than a fresh instance, so wow and flutter never re-converge'), - ('TapeDelay', 'E2-', 'P4'): ( - 32, 'd3f42f25', 32, 'd3f42f25', - 'a host reset_buffer shifts the wow and flutter phase, so the output never re-converges'), - ('TapeDelay', 'E4-m0=0', 'P4'): ( - 0, '00000000', 10, '816d86bd', - 'Time to 0 and back at 22.05 kHz: the glide back outlasts tail_samples'), - ('TapeDelay', 'E8-dry', 'P3'): ( - 2, '1da480f7', 2, '1da480f7', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), ('PingPongDelay', 'E4-m2=127', 'P5'): ( 4, '31c4e1c7', 32, 'c9e4e06a', 'Mix to 127 steps the output within one block'), From 7b13a5b1a4e14c7f01ec30f74db06f59349d1e1f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:06:14 -0500 Subject: [PATCH 72/88] AnalogDelay, second fixer in the trial: three false claims struck, Sync's sentence clamped to Time's span and tested, the P4 and P5 declared reasons made true --- lib/audioeffects/rebuilt/analogdelay.py | 6 +---- tests/support/lifecycle.py | 22 +++++++++------- tests/test_cpython_effects_analogdelay.py | 31 ++++++++++++++++++----- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/lib/audioeffects/rebuilt/analogdelay.py b/lib/audioeffects/rebuilt/analogdelay.py index 7dfeb7f..6040913 100644 --- a/lib/audioeffects/rebuilt/analogdelay.py +++ b/lib/audioeffects/rebuilt/analogdelay.py @@ -168,7 +168,7 @@ class AnalogDelay(_component.Component): sends each repeat round again. Mix blends the repeats in. Modulation and Mod Rate wobble the delay, a chorus in a blend and a vibrato with the repeats alone. Spread feeds each side's repeats into the other. - Sync locks Time to Division of the host's beat. + Sync locks Time to Division of the host's beat, clamped to Time's span. Time runs from 20 to 600 ms, Feedback from 0 to 0.99 and Mix from 0 to 2. Up to Mix 1 the dry passes untouched until the first repeat arrives. Mix 0 is a wire. @@ -180,13 +180,10 @@ class AnalogDelay(_component.Component): Man. Any other `character` raises `ValueError`. The repeats' high-frequency corner is 0.2211 N / T for N stages and a Time of T seconds, so it halves each time Time doubles. - At the same Time the double line's repeats are an octave brighter. Where the law passes 0.98 of Nyquist the corner holds there, so a shorter Time no longer brightens the repeats. **Time.** Every Time lands on a whole frame at every rate. - A Time move bends the repeats' pitch by T_old / T_new for exactly T_new - and then returns to unity, without a click. A small Time move still lands. Turning Time through several positions a block apart takes seconds to settle, where one jump to the same place lands within the new Time. @@ -201,7 +198,6 @@ class AnalogDelay(_component.Component): `tail_samples` is an upper bound on how long the repeats take to reach exact zero after your input stops. `reset()` empties the line and returns to patch 0. - With Sync on and a host tempo, Time is Division of the host's beat. With no host tempo, Time stays on the knob. **What it leaves out.** There is no sample-and-hold, so the repeats diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 6e8a1ae..547c8f4 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -77,22 +77,26 @@ DECLARED = { ("AnalogDelay", "E", "P5"): "a control that jumps steps the output (family, audiocomponents#117);" - " a Time move bends the pitch on the clock's law by design", + " a Time move bends the pitch while the line walks, by design", ("AnalogDelay", "E8-dry", "P3"): "a tail cut short by a stopped source carries on when it comes back" " (family, audiodsp#180)", ("AnalogDelay", "E2-", "P4"): - "the modulation triangle runs free, so a modulated patch never" - " matches a fresh instance after reset_buffer", + "reset_buffer restarts the modulation triangle (the node's" + " state_init), so a modulated patch then matches a fresh instance," + " not the uninterrupted control", ("AnalogDelay", "E4-", "P4"): - "a Mod Rate move shifts the free-running triangle; a Time move's" - " walk still runs after the move back (glide kept, 2026-09-28)", + "a Mod Rate move shifts the free-running triangle; after a Time move" + " and back, a 1-LSB rounding difference circulates in the feedback" + " loop past tail_samples", ("AnalogDelay", "E6-", "P4"): - "a patch change shifts the free-running triangle or leaves a Time" - " walk running after the move back (glide kept, 2026-09-28)", + "a patch change shifts the free-running triangle, or leaves a 1-LSB" + " rounding difference circulating in the feedback loop past" + " tail_samples", ("AnalogDelay", "E11-", "P4"): - "a patch change shifts the free-running triangle or leaves a Time" - " walk running after the move back (glide kept, 2026-09-28)", + "a patch change shifts the free-running triangle, or leaves a 1-LSB" + " rounding difference circulating in the feedback loop past" + " tail_samples", } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index f955e8e..042b31f 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -2373,6 +2373,28 @@ def test_a_host_sets_time_from_division(self): self.assertEqual(effect._frames, 28800) self.assertAlmostEqual(effect.get_macro(TIME_I), 127.0) + def test_sync_is_division_clamped_to_the_span(self): + # Every Division at tempos where the clamp bites at both ends. Each + # tempo has a Division the clamp moves, so an unclamped claim fails. + for rate in RATES: + for bpm in (30.0, 60.0, 90.0, 120.0, 400.0): + effect = AnalogDelay.create( + silence_src(64, rate=rate), rate, patch=6, + transport=lambda bpm=bpm: (True, 0.0, bpm, 4, 4)) + clamped = 0 + for i, beats in enumerate(ad.DIVISION_BEATS): + effect.set_macro(DIVISION_I, i * 127.0 / 15.0) + want = beats * 60000.0 / bpm + held = min(600.0, max(20.0, want)) + clamped += held != want + # To the nearest frame: a tie (1837.5 frames at 90 BPM, + # 22.05 kHz) may land either side. + self.assertLessEqual( + abs(effect._frames - held * rate / 1000.0), 0.5 + 1e-9, + (rate, bpm, i)) + self.assertGreater(clamped, 0, bpm) + effect.deinit() + def test_no_host_or_a_bad_tempo_leaves_time_on_the_knob(self): effect = AnalogDelay(silence_src(64), sample_rate=RATE, patch=6) self.assertEqual(effect._frames, whole(grid_ms(101), RATE)) @@ -2446,6 +2468,8 @@ def test_another_macro_leaves_a_walk_at_its_rate(self): "test_click_delay_is_zero"), ("A one-channel source gets the same effect with Spread held at 0.", "test_mono_holds_spread_at_zero"), + ("Sync locks Time to Division of the host's beat, clamped to Time's " + "span.", "test_sync_is_division_clamped_to_the_span"), ('`"single-line"` (the default) is one 4096-stage line, the Boss DM-2; ' '`"double-line"` is two in series, the Deluxe Memory Man.', "test_characters"), @@ -2453,15 +2477,10 @@ def test_another_macro_leaves_a_walk_at_its_rate(self): ("The repeats' high-frequency corner is 0.2211 N / T for N stages and a " "Time of T seconds, so it halves each time Time doubles.", "test_the_named_cells_and_octaves"), - ("At the same Time the double line's repeats are an octave brighter.", - "test_the_named_cells"), ("Where the law passes 0.98 of Nyquist the corner holds there, so a " "shorter Time no longer brightens the repeats.", "test_characters"), ("Every Time lands on a whole frame at every rate.", "test_every_frame_count_lands_whole"), - ("A Time move bends the repeats' pitch by T_old / T_new for exactly " - "T_new and then returns to unity, without a click.", - "test_the_row_cell"), ("A small Time move still lands.", "test_moves_under_eight_frames_land"), ("Turning Time through several positions a block apart takes seconds to " "settle, where one jump to the same place lands within the new Time.", @@ -2479,8 +2498,6 @@ def test_another_macro_leaves_a_walk_at_its_rate(self): "test_the_tail_reaches_exact_zero_inside_tail_samples"), ("`reset()` empties the line and returns to patch 0.", "test_reset_empties_the_line"), - ("With Sync on and a host tempo, Time is Division of the host's beat.", - "test_a_host_sets_time_from_division"), ("With no host tempo, Time stays on the knob.", "test_no_host_or_a_bad_tempo_leaves_time_on_the_knob"), ("There is no sample-and-hold, so the repeats have no null at the " From 3e74bb8ed79e29ed5e40b7e744c1e938b70917c2 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:02 -0500 Subject: [PATCH 73/88] TapeDelay: strike two false docstring sentences and their CLAIMS rows (a longer Time darkens only between 180 and 600 ms; the lean patch's loop still saturates on sustained loud material) --- lib/audioeffects/rebuilt/tapedelay.py | 12 +++++------- tests/test_cpython_effects_tapedelay.py | 5 ----- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/lib/audioeffects/rebuilt/tapedelay.py b/lib/audioeffects/rebuilt/tapedelay.py index 158510a..209e2f2 100644 --- a/lib/audioeffects/rebuilt/tapedelay.py +++ b/lib/audioeffects/rebuilt/tapedelay.py @@ -16,9 +16,8 @@ **Two characters.** `character="varispeed"`, the default, is the RE-201: Time moves the motor, so a Time move bends the pitch of everything on the -tape instead of clicking, then settles. A longer Time runs the tape slower, -and the repeats come back darker. Glide does nothing on this character. -`character="sliding-head"` is the EP-3: Time slides a head, so the pitch +tape instead of clicking, then settles. Glide does nothing on this +character. `character="sliding-head"` is the EP-3: Time slides a head, so the pitch bends only while the head moves, at the rate Glide sets, and the tape runs at one speed, so the darkening does not follow Time. Glide 0 is an instant slide, and its price is a click. @@ -52,10 +51,9 @@ **Cost.** Measured at v0.6.2 at the default and every patch, the full class costs 0.551-0.608 ms a block on the P4 and 1.056-1.093 ms on the S3, over the -budgets of 0.480 ms and 0.800 ms. Patch 8, `Tape Delay - lean`, is patch 0 -with Record Level at 0: the repeats stay clean however hard you play. -Measured at v0.6.2, patch 8 on a class built with `max_time_ms=800` costs -0.445-0.455 ms on the P4 and 0.781-0.797 ms on the S3, inside both budgets. +budgets of 0.480 ms and 0.800 ms. Measured at v0.6.2, patch 8 on a class +built with `max_time_ms=800` costs 0.445-0.455 ms on the P4 and 0.781-0.797 +ms on the S3, inside both budgets. `reset()` returns to patch 0, so a host that wants the lean patch sets it again after a reset. """ diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 98630a1..04d2683 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -2946,8 +2946,6 @@ def test_every_demonstrated_row_is_red_on_a_wire(self): "motor, so a Time move bends the pitch of everything on the tape " "instead of clicking, then settles.", ("T1aVarispeed.test_the_named_moves",)), - ("A longer Time runs the tape slower, and the repeats come back darker.", - ("T3LossFollowsSpeed.test_the_span_at_the_named_cells",)), ("Glide does nothing on this character.", ("GlideIsInertOnVarispeed.test_three_glides_one_render",)), ("`character=\"sliding-head\"` is the EP-3: Time slides a head, so the " @@ -3008,9 +3006,6 @@ def test_every_demonstrated_row_is_red_on_a_wire(self): "costs 0.551-0.608 ms a block on the P4 and 1.056-1.093 ms on the S3, " "over the budgets of 0.480 ms and 0.800 ms.", ("Claims.test_the_board_figures_are_the_table",)), - ("Patch 8, `Tape Delay - lean`, is patch 0 with Record Level at 0: the " - "repeats stay clean however hard you play.", - ("LeanPatch.test_the_lean_patch_is_patch_0_with_the_drive_off",)), ("Measured at v0.6.2, patch 8 on a class built with `max_time_ms=800` " "costs 0.445-0.455 ms on the P4 and 0.781-0.797 ms on the S3, inside " "both budgets.", From 28b10edf67ce62f77e4a1783259e2bc54b3d603d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:55:43 -0500 Subject: [PATCH 74/88] Reverb trial fix at audiodsp v0.6.3rc3: re-cut in place, exact Tone 0, claims on eight seed sets Tone's centre is handed as 0 dB (the Tank tracks there, audiodsp#168); TONE_TRACK_DB and the ToneDetentZero plant go. A Character or Size move re-cuts the one Tank with set(delays=, taps=) (audiodsp#169) and keeps the source frames it holds; reset() clears through Tank.clear. The Rebuilds tests are Recuts: no frame lost at 128, 256, 1024 and 2048-frame buffers, red on RebuildOnMove. The reset test starts from patch 0 too, with a plant that clears nothing. Every claim on noise is held to eight seed sets with a margin over the spread: the floor knees and their bands are struck, T11 is claimed from 2 s (the hall from 4 s), T3 per halving, three long patches, the Size 0.5 ceiling cells and the rail. T10 is claimed on the patches as shipped over Mod Depth 17-64 by Mod Rate 45-81, walked stepped by 1. The docstring is rewritten with the family limits and a CLAIMS table and its test. The lifecycle matrix declares Reverb's P4 (int16 lines and the modulation phase), P5 and E8-dry P3 (the family rulings); KNOWN_RED loses its rows. --- CHANGELOG.md | 21 +- lib/audioeffects/rebuilt/reverb.py | 320 +++----- tests/support/lifecycle.py | 17 + tests/test_cpython_effects_reverb.py | 1045 +++++++++++++++++--------- tests/test_lifecycle_matrix.py | 12 - 5 files changed, 837 insertions(+), 578 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa3e82..01e9e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,17 +131,16 @@ there, and are recorded in its changelog. - **`Reverb` (rebuilt, Phase 5, parked under `rebuilt/`):** Dattorro's plate network on one `audioverb.Tank`, cut four ways as a Character macro: an EMT 140 plate that is dense at once and darkens as Decay shortens, and a - room, a chamber and a hall that build. Decay is T60 at 500 Hz between a - floor knee and a ceiling knee, both tabled in the dossier and held by the - class tests; Size and Character rebuild the tank and cut the tail; Mix 0 - is a byte-exact wire while the tank keeps ringing; latency is zero. It - lives in `lib/audioeffects/rebuilt/reverb.py`, and `audioeffects.Reverb` - is still the old class. The Fender 6G15 spring character is parked until - the node carries a dispersive chain. The docstring names the two patches - that ring longer than their Decay reads, where Mod Depth under 0.27 ms - stops spreading a tone, and the input ceiling: the tank's lines compress - from about -12 dBFS RMS at any Mix, and no shipped patch reaches the rail - at -18 dBFS RMS at Mix 1. Board cost is unmeasured. + room, a chamber and a hall that build. Decay is T60 at 500 Hz within 12 % + at the reference patches from 2 s to 10 s (the hall from 4 s), held on + eight seed sets; shorter Decays are not claimed. At audiodsp v0.6.3rc3 a + Character or Size move re-cuts the one Tank in place and cuts the tail + without losing a frame of the dry, `reset()` keeps the dry the Tank holds, + and Tone's centre is handed as 0 dB. Mix 0 is a byte-exact wire while the + tank keeps ringing; latency is zero. It lives in + `lib/audioeffects/rebuilt/reverb.py`, and `audioeffects.Reverb` is still + the old class. The Fender 6G15 spring character is parked until the node + carries a dispersive chain. Board cost is unmeasured. ### Changed diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py index 67bf925..9927450 100644 --- a/lib/audioeffects/rebuilt/reverb.py +++ b/lib/audioeffects/rebuilt/reverb.py @@ -1,171 +1,77 @@ -"""`Reverb` - Dattorro's plate network, cut four ways: an EMT 140 plate, a -room, a chamber and a hall. - -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/Reverb.md`, whose trait table was -frozen at Station A before this file existed (anchor commit -34753deba8a9d542050d42eaa9f756bcd160951f, the Station A critique's -re-freeze, 2026-09-28). The old class in `reverb.py` is consulted only for -the seven defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. - -**What it sounds like.** Your dry signal passes untouched and a reverb -tail rises behind it. Character picks the machine: `plate` is dense from -the first milliseconds, the way a steel sheet is, while `room`, `chamber` -and `hall` start sparse and build, and differ in how long and how late -they are. Decay is the time the tail takes to fall 60 dB at 500 Hz (0.3 to -10 s). Size stretches every line of the network (0.5 to 1.5). Predelay -holds the tail back from the dry (0 to 200 ms). Diffusion smears the early -echoes (0 to 0.9). Damping darkens the tail as it rings (500 Hz to 16 kHz; -on the plate it is where the damper ends up at the shortest Decays, see -below). Bandwidth darkens what goes in, Low Cut keeps the bass out of the -tank while the dry keeps it (20 to 500 Hz). Mod Depth and Mod Rate wobble -two lines inside the tank so it does not ring on fixed pitches. Width -narrows the image to mono at 0. Tone tilts the tail about 1 kHz. Mix is -`audiodelays.Echo`'s: the dry at unity until 1, the tail alone at 2, and -Mix 0 is a byte-exact wire while the tank keeps ringing behind it. - -**The plate's damper.** On the plate, Decay also moves the tail's loss -corner, the way the EMT 140's damping panel does: fully open at Decay 8 s -and above, at the Damping setting at 1 s and below, geometric in log Decay -between. So a short plate is a darker plate, and the upper bands shorten -more than the lower ones as you turn Decay down. - -**The standout:** the EMT 140 plate (Russo's thesis on its physics) and -Dattorro's "Effect Design, Part 1" network, which he wrote as the plate -class of reverberator; the room, chamber and hall are that network -re-proportioned. The Fender 6G15 spring the dossier also studied is not -here: the tank has no dispersive chain and the palette cannot build one -that chirps upward (dossier section 8.6), so the spring character is -parked until the node carries one. Asking for `character="spring"` says so. - -**Portability tier: audiodsp** (`REQUIRES = ("audioverb",)`). The whole wet -path is one `audioverb.Tank`. On a stock CircuitPython board this module -imports cleanly and construction raises `ImportError`. - -**Character and Size are set-and-leave.** The Tank's line lengths are fixed -when it is built, so a Character move to another zone, or a Size move that -changes the line set (almost any Size move does), builds a new Tank: the -tail drops to nothing at the move, and you will hear the reverb cut off. -The order is fixed: the output plays the dry alone, the old Tank is -released, the new one is built with every other setting applied and -starts from empty lines. Set them before you play, not while a tail rings. -A patch that changes both rebuilds once. - -A rebuild can also skip some of your dry. The Tank pulls its source a -buffer at a time and plays 256 frames per block, so between blocks it may -hold the rest of a source buffer it has not played yet; the old Tank takes -those frames with it, and the new one starts at the source's next buffer. -A source whose buffers divide 256 frames (a 256-frame host block does) -loses nothing. One that hands 1024 or 2048 frames can lose up to a buffer -less a block (512 frames when the move lands 1536 frames in), and the -output then runs that far ahead of the source, at Mix 0 too: the byte-exact -wire holds across a move only on such a source. A `RawSample` played -straight in, which hands its whole buffer at once, loses the rest of it. -`reset()` drops the same pending frames, as every class's reset does -(`audiocore.reset_buffer`), and at the constructor's Size its patch 0 -rebuilds the Tank as well, since the grid's Size 64 cuts different lines -from Size 1.0. - -**Decay is T60 at 500 Hz, between two knees.** The label holds to within -12 % over most of the span (dossier T11). Below each character's *floor -knee* its own diffusers and taps ring longer than the label, so the -shortest Decays land at the floor (0.24 to 1.38 s over characters and -Size) and the knob does least there; under the knee the delivered time can -even step backward a little as the label rises. Above the *ceiling knee*, -which a low Damping corner brings down on the room, chamber and hall, the -class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the -label. Both knees are tabled per character and Size in the dossier's -section 8.9, read on the 0-127 grid at each character's reference patch. -Where the floor knee sits depends on the noise it is measured with, so it -is tabled as a band: the lowest and the highest knee read on four sets of -eight noise seeds. The bands' tops run from 0.45 s (the room at Size 0.5) -to 2 s (the plate at Size 1.25, the hall at Size 1.5), and the widest band -spans four Decay stops (the plate at Size 0.5, 0.3 to 1 s). From a band's -top up the label held on every set measured; inside the band it holds on -some sets and not others. The plate has no ceiling knee on the span, and -the chamber at Size 1.5 has no floor knee at or under 2 s on one set of -the four (its 2 s position reads 10 to 13 % long). - -**A sparse, quiet input decays sooner than the knob.** The tank's lines are -16-bit and truncate toward zero, which is what lets the tail reach exact -silence, but it also bites into a quiet tail: an impulse of 1 000 LSB on -an 8 s plate reads 2.97 s, where interrupted noise at 8 000 LSB RMS reads -8.29 s (dossier section 8.7). Every Decay figure here is from interrupted -noise at 8 000 LSB RMS. - -**The patches.** Steel Plate (the defaults), Short Plate, Damped Plate, -Bass-Free Plate (Low Cut at the 6G15's 360 Hz), Small Room, Live Room, -Concert Hall, Dark Chamber, Bright Chamber, Slow Bloom. Two of them sit -under their character's floor knee and ring longer than their Decay reads. -Small Room's Decay reads 0.454 s and it rings 0.576 s at 500 Hz; it ships -so because the rooms must stay that much shorter than the hall. Damped -Plate's reads 1.011 s and it rings 1.228 s, because at Size 1.25 the -plate's own ringing sets the time until somewhere between Decay 1.5 and -2 s, depending on the noise it is measured with. - -**Latency: zero samples, at every setting, character and rate.** Nothing -looks ahead. Predelay is the wet path, not latency on the dry. - -**Mono.** The tank sums its input to one signal either way; a one-channel -source gets the mono fold-down of the stereo tank, both tap sets in the one -lane, halved (`audiodsp_tank.c:571-580`). - -**Mod Depth under 0.27 ms.** Whether a small depth spreads a steady tone -into sidebands depends on the exact lines Size cuts. At 0.1 ms and 1 Hz on -Steel Plate the sidebands of a 1 kHz tone sit 18.4 dB under it at Size 1.0 -and 27.2 dB under it at patch 0's Size (1.0039), and over the nine Size -steps around it they range from 27.2 dB under to 12.5 dB over. Concert -Hall is no steadier there: at 0.1 ms and 1 Hz a 1 kHz tone's sidebands sit -2.3 dB under it and a 533.5 Hz tone's 32.5 dB under. From 0.27 ms up they -measure within 20 dB of the tone on Steel Plate and Concert Hall at every -Mod Rate for tones at 300 Hz, 1 kHz and 3 kHz, the three measured on the -grid. That is three tones, not the band between them: at 0.27 ms and 5 Hz -a 317.8 Hz tone on Concert Hall reads 23.9 dB under, and a 1004.9 Hz tone -on Steel Plate 21.2 dB under. - -**Tone** spans +/-12 dB of end-to-end tilt, which the node puts half at -each end: +12 is about -6 dB at 40 Hz and +6 dB at 16 kHz. At the centre -the class hands 2^-24 dB rather than 0, so the tilt's filter keeps -following the tail and a later Tone move out of silence stays silent. - -**Input ceiling.** Two things limit how hard you can drive it, and there -is no input gain to turn down. First, the tank's 16-bit lines clamp at the -rail on every write, whatever Mix is, so the tail compresses before the -output clips: at the defaults a steady 362 Hz sine comes back 1.3 dB -quieter in the tail at 8 000 LSB RMS (-12.3 dBFS) than at 1 000 to 4 000, -and 7.5 dB quieter at 16 000, with the output still under the rail. Second, -the tank adds dry and wet before it rounds and clamps the output. On 2 s of -uniform noise at Mix 1 no shipped patch reaches the rail at 4 000 LSB RMS -(-18.3 dBFS), at 48, 44.1 or 22.05 kHz; at 8 000 LSB RMS most of them do, -up to 482 samples in 2 s on Bright Chamber. The 4 000 LSB RMS figure is the -patches', not every setting's: with Decay 10 s, Size 1.5 and Diffusion 0.9 -at Mix 1, Live Room puts 12 samples on the rail at 22.05 kHz. - -**RAM.** One int16 allocation: the twelve lines plus 200 ms of predelay. -89 714 B for Steel Plate at 48 kHz; 146 914 B for the hall at Size 1.5, the -most the class allocates. A Character or Size move frees the old Tank -before it builds the new one. A board that cannot afford the larger lines -fails the move with the allocation's `MemoryError`, and the output is then -the dry alone until a move that fits. - -**Cost.** One `audioverb.Tank`, no mixer (the Tank sums the dry itself), -so no glue: **P4 <= 19 %, S3 <= 33 %** of a 5.333 ms stereo block, from the -cost table's Tank row at Dattorro's network. The board measurement is -pending hardware, and the row did not run Low Cut, Tone or Width below 1, -which most patches do. No `" - lean"` patch: every patch runs the same -graph. - -**Tail.** `tail_samples` bounds the frames until the output is exactly zero -once your input stops: fs x (Predelay + 1.6 x max(1.2 x T_lf, 1.5 x Size)), -T_lf being the low-frequency T60 the handed `decay` gives, the longest any -band rings. 222 868 frames (4.64 s) at the defaults at 48 kHz. - -`capabilities = ()`: nothing here reads the host's transport. - -A value outside a macro's span clamps to the nearer stop; NaN takes the -option's default; `character` must be `"plate"`, `"room"`, `"chamber"` or -`"hall"`. +"""`Reverb` - Dattorro's plate network, cut four ways: a plate, a room, a +chamber and a hall. + +Your dry signal passes untouched and a reverb tail rises behind it. +Character picks the machine: `plate` is dense from the first milliseconds, +the way the EMT 140's steel sheet is, while `room`, `chamber` and `hall` +start sparse and build. Decay (0.3 to 10 s) sets how long the tail rings, +Size (0.5 to 1.5) stretches every line of the network, and Predelay (0 to +200 ms) holds the tail back from the dry. Diffusion (0 to 0.9) smears the +early echoes, Damping (500 Hz to 16 kHz) darkens the tail as it rings, and +Bandwidth (500 Hz to 20 kHz) darkens what goes in. Low Cut (20 to 500 Hz) +keeps the bass out of the tank while the dry keeps it. Mod Depth (0 to 2 ms) +and Mod Rate (0.1 to 5 Hz) wobble two lines inside the tank, Width (0 to 1) +sets the stereo spread, and Tone (-12 to +12 dB) tilts the tail. Mix (0 to +2) is `audiodelays.Echo`'s: the dry at unity until 1, the tail alone at 2. +Mix 0 is a byte-exact wire while the tank keeps ringing behind it. Latency +is zero: nothing looks ahead, and Predelay delays only the tail. + +On the plate, Decay also moves the tail's loss corner, the way the EMT +140's damping panel does: open at Decay 8 s and above, at the Damping +setting at 1 s and below. So a short plate is a darker plate: each halving +of Decay from 8 s to 1 s shortens the upper band more than the lower one. + +A Character or Size move re-cuts the tank: the tail drops to nothing at the +move, and the dry carries on without losing a frame. `reset()` empties the +tank the same way, keeps the dry, and restores patch 0. + +**Decay.** At each character's reference patch (Steel Plate, Live Room, +Dark Chamber, Concert Hall) at Size 1.0, the tail falls 60 dB at 500 Hz +within 12 % of Decay at 2, 3, 4, 6, 8 and 10 s, and on the hall from 4 s. +Shorter Decays, other Sizes and the other patches are not claimed: Damped +Plate, Small Room and Live Room ring longer than their Decay reads. With +Damping at 1 kHz and Size 0.5, the room, chamber and hall at Decay 8 and +10 s ring more than 12 % short of it: the class holds the bass to 1.5 x +Decay. A sparse, quiet input rings out sooner than the knob: one click at +1 000 LSB on an 8 s plate is exactly silent within 2 s. + +**Modulation.** With Mod Depth at 0, a 1 kHz tone on Steel Plate or Concert +Hall comes out as one line, its sidebands more than 60 dB under it. On those +two patches as shipped, at every grid position from 17 to 64 of Mod Depth +(about 0.27 to 1 ms) and from 45 to 81 of Mod Rate (about 0.4 to 1.2 Hz), +tones at 300 Hz, 1 kHz and 3 kHz spread into sidebands within 20 dB of the +tone. Outside that it is not claimed: on Steel Plate a 3 kHz tone at Mod +Depth position 81 (about 1.28 ms) and Mod Rate position 121 (about 4.16 Hz) +reads more than 20 dB under. + +**Input ceiling.** There is no input gain, and the tank's lines clamp at the +rail on every write whatever Mix is: a steady 362 Hz tone at 8 000 LSB RMS +comes back more than 1 dB quieter in the tail than at 4 000. On 2 s of +uniform noise at 4 000 LSB RMS at Mix 1, no shipped patch reaches the rail +at 48, 44.1 or 22.05 kHz; Bright Chamber at 44.1 kHz is not claimed. + +**The patches:** Steel Plate (the defaults), Short Plate, Damped Plate, +Bass-Free Plate, Small Room, Live Room, Concert Hall, Dark Chamber, Bright +Chamber, Slow Bloom. + +`tail_samples` bounds the frames until the output is exactly zero once +your input stops: 222 868 frames at the defaults at 48 kHz. One int16 +allocation holds the lines and 200 ms of predelay: 89 714 B for Steel Plate +at 48 kHz, and 146 914 B for the hall at Size 1.5, the most it takes. + +**Limits shared by the family** + +A control that jumps makes the output step: move it in small steps from +the host if you need it smooth. + +The tail rings only while the source keeps feeding: feed silence to let +it ring out. A tail cut short by a source that stopped carries on when +the source comes back. + +Asking for `character="spring"` says it is parked: the tank has no +dispersive chain yet. A value outside a macro's span clamps to the nearer +stop, and NaN takes the option's default. On a board without `audioverb`, +construction raises `ImportError`. """ VENDOR = "PyDevices" @@ -246,15 +152,6 @@ class holds the bass to 1.5 x Decay and T60 at 500 Hz lands short of the #: The frequency Decay is stated at. DECAY_HZ = 500.0 -#: What the class hands for Tone at its centre detent instead of 0 dB. The -#: Tank runs its tilt one-pole only while `tone_db` is non-zero -#: (`audiodsp_tank.c:596-602`), so at exact 0 the filter's state freezes -#: on whatever it last held and comes out as sound the moment Tone leaves -#: the detent, even out of exact silence. 2^-24 dB keeps the one-pole -#: tracking; both of its gains round to exactly 1 in single precision, so -#: it is the flat tilt, and every float format holds it exactly. -TONE_TRACK_DB = 1.0 / 16777216.0 - (CHARACTER_I, DECAY_I, SIZE_I, PREDELAY_I, DIFFUSION_I, DAMPING_I, BANDWIDTH_I, LOW_CUT_I, MOD_DEPTH_I, MOD_RATE_I, WIDTH_I, TONE_I, MIX_I) = range(13) @@ -392,16 +289,9 @@ def _between(value, low, high): class Reverb(_component.Component): - """Dattorro's network as four machines: an EMT 140 plate that is dense - at once and darkens as Decay shortens, and a room, chamber and hall - that build. Decay is T60 at 500 Hz. audiodsp tier; zero latency. - - **What the default surrenders:** Decay holds its label only between a - floor knee and a ceiling knee (dossier section 8.9); a sparse, quiet - input decays sooner than the knob; Character and Size rebuild the tank, - cut the tail and can skip the dry the old tank held pending; the - spring character is parked. - """ + """Dattorro's network as four machines: an EMT plate that is dense at + once and darkens as Decay shortens, and a room, chamber and hall that + build. The module docstring is the player's page.""" NAME = 'Reverb' DISPLAY_NAME = 'Reverb' @@ -557,9 +447,8 @@ def _low_cut_hz(self, value): return self._hz(value) def _tone_db(self, value): - """The tilt handed to the Tank: never exactly 0 (`TONE_TRACK_DB`).""" - if value == 0.0: - return TONE_TRACK_DB + """The tilt handed to the Tank, as set: the node keeps its tilt + tracking at 0 dB (audiodsp#168).""" return value def _mod_rate_hz(self, value): @@ -589,13 +478,6 @@ def program_change(self, index, channel=0, note_id=-1, if type(self).PATCHES.get(index) is not None: self._refresh() - def _disown(self, node): - for position in range(len(self._nodes) - 1, -1, -1): - if self._nodes[position] is node: - del self._nodes[position] - del self._resets[position] - del self._deinits[position] - def _refresh(self): fs = self._sample_rate index = self._character() @@ -626,9 +508,11 @@ def _refresh(self): _between(self._value(TONE_I), -12.0, 12.0)), "mix": _between(self._value(MIX_I), 0.0, 2.0), } - if (self._tank is None or index != self._index - or lines != self._lines or taps != self._taps): - self._rebuild(index, lines, taps, handed) + if self._tank is None: + self._build_tank(index, lines, taps, handed) + elif index != self._index or lines != self._lines \ + or taps != self._taps: + self._recut(index, lines, taps, handed) else: self._tank.set(**handed) self._handed = handed @@ -636,20 +520,10 @@ def _refresh(self): self._t_lf = t_lf self._tail = tail_frames(fs, predelay, t_lf, size) - def _rebuild(self, index, lines, taps, handed): - """Replace the Tank (dossier section 8.8): the output plays the dry - alone, the old Tank is released, the new one is built with every - setting applied, plays the source, and the output points at it. - Source frames the old Tank pulled and had not played go with it - (the module docstring says when).""" - old = self._tank - if old is not None: - self._output = self._source - self._disown(old) - self._tank = None - self._lines = None - self._taps = None - old.deinit() + def _build_tank(self, index, lines, taps, handed): + """Build the one Tank, at construction. Its sample rate, channel + count and predelay allocation never change after, so every later + Character or Size move is a re-cut of this node (`_recut`).""" tank = audioverb.Tank( sample_rate=self._sample_rate, channel_count=self._channel_count, @@ -657,15 +531,27 @@ def _rebuild(self, index, lines, taps, handed): delays=lines, taps=taps, **handed) - # The Tank's reset empties every line and filter - # (`audiodsp_tank_reset`), so the base's reset walk clears the tail. - self._tank = self._own(tank) + # `clear` empties every line and filter and keeps the source frames + # the Tank has pulled and not yet played, so `reset()` does not skip + # the dry; `audiocore.reset_buffer` would drop them. + self._tank = self._own(tank, reset=tank.clear) self._index = index self._lines = lines self._taps = taps tank.play(self._source) self._output = tank + def _recut(self, index, lines, taps, handed): + """Re-cut the playing Tank in place (audiodsp#169): every line and + filter starts empty, as a new Tank's would, and the source frames it + holds stay, so the dry does not skip. The node allocates the new + lines before it frees the old, and a refused allocation leaves it + as it was.""" + self._tank.set(delays=lines, taps=taps, **handed) + self._index = index + self._lines = lines + self._taps = taps + @property def tail_samples(self): """Frames until the output is exactly zero once the input stops, as diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..429c9aa 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -75,6 +75,23 @@ #: cell prints `decl` instead of RED and is counted as a declared exception. #: Nothing else skips a cell. Empty on purpose: no class has declared one. DECLARED = { + # Reverb: the tank's int16 lines truncate on every write and its + # filters run in float, so once two tanks have heard different material + # they stay a few LSB apart under the same input for good (seen at Mod + # Depth 0 too); a reset or a re-cut also restarts the modulation phase. + ("Reverb", "E1-", "P4"): "a reset tank never re-converges byte for byte (int16 lines, free modulation phase)", + ("Reverb", "E2-", "P4"): "a reset_buffer tank never re-converges byte for byte (int16 lines, free modulation phase)", + ("Reverb", "E4-", "P4"): "a move that reaches the loop leaves the tank a few LSB off a fresh one for good", + ("Reverb", "E6-", "P4"): "a patch move reaches the loop and leaves the tank a few LSB off a fresh one for good", + ("Reverb", "E11-", "P4"): "moves that reach the loop leave the tank a few LSB off a fresh one for good", + # Brad's ruling of 2026-09-28, disclosed in the docstring's "Limits + # shared by the family" (audiocomponents#117, audiodsp#180). + ("Reverb", "E4-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E5-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E6-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E9-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E11-", "P5"): "family: a control that jumps makes the output step (#117)", + ("Reverb", "E8-dry", "P3"): "family: the tail rings only while the source feeds (audiodsp#180)", } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index 3f241d5..dc28a4d 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -13,6 +13,7 @@ import math import os +import re import sys import unittest from array import array @@ -34,6 +35,9 @@ Reverb = rv.Reverb RATE = 48000 SEEDS = tuple(range(7, 15)) +#: The eight seed sets every claim on noise is held to (the trial brief: +#: eight sets, a margin larger than the spread between them). +SEED_SETS = tuple(tuple(range(7 + 8 * k, 15 + 8 * k)) for k in range(8)) BURST_S = 2.0 NOISE_RMS = 8000.0 @@ -103,6 +107,21 @@ _MEMO = {} +def holds(values, low=None, high=None): + """The trial brief's rule for a number read on eight seed sets: every + value inside [low, high], and its distance to the nearer bar larger + than the spread between the sets.""" + if values is None or None in values or len(values) < len(SEED_SETS): + return False + spread = max(values) - min(values) + margins = [] + if low is not None: + margins.append(min(values) - low) + if high is not None: + margins.append(high - max(values)) + return min(margins) > spread + + # -- planted faults ---------------------------------------------------------- def _cut_with(sample_rate, index, size, ratios): @@ -193,14 +212,44 @@ def _mod_rate_hz(self, value): return 1000.0 / value -class ToneDetentZero(Reverb): - """Tier 1 silence: Tone's centre detent handed as exact 0 dB, which - freezes the Tank's tilt one-pole (`audiodsp_tank.c:596-602`).""" +class RebuildOnMove(Reverb): + """The class before audiodsp#169: a Character or Size move replaces the + Tank, and the source frames the old one held go with it.""" + + NAME = 'Reverb' + + def _recut(self, index, lines, taps, handed): + old = self._tank + self._output = self._source + for position in range(len(self._nodes) - 1, -1, -1): + if self._nodes[position] is old: + del self._nodes[position] + del self._resets[position] + del self._deinits[position] + self._tank = None + old.deinit() + self._build_tank(index, lines, taps, handed) + + +class ResetDropsHeldFrames(Reverb): + """`reset()` through `audiocore.reset_buffer` on the Tank, which drops + the source frames it holds, instead of `Tank.clear`.""" + + NAME = 'Reverb' + + def _build_tank(self, index, lines, taps, handed): + Reverb._build_tank(self, index, lines, taps, handed) + self._resets[self._nodes.index(self._tank)] = True + + +class ResetClearsNothing(Reverb): + """`reset()` that restores patch 0 and leaves the Tank's lines alone.""" NAME = 'Reverb' - def _tone_db(self, value): - return value + def _build_tank(self, index, lines, taps, handed): + Reverb._build_tank(self, index, lines, taps, handed) + self._resets[self._nodes.index(self._tank)] = False def depth_ceiling_ms(lines, sample_rate): @@ -229,20 +278,6 @@ def _decay(self, index, lines, decay_s, loop_hz): return d * d, capped, 0.5 * t_lf -class LongDiffusers(Reverb): - """The floor knee (dossier section 8.9): every character's four input - diffusers at 1.5 x its own, the direction Station A tried and dropped - because longer diffusers ring on their own and lift the floor under - Decay (App. A8.8).""" - - NAME = 'Reverb' - - def _cut(self, index, size): - ratios = tuple(1.5 * r for r in rv.RATIOS[index][:4]) + \ - rv.RATIOS[index][4:] - return _cut_with(self._sample_rate, index, size, ratios) - - def capped_law(kappa, lines, sample_rate, decay_s, loop_hz, cap): """The dossier's section 4 Decay law with its low-frequency cap at `cap` x Decay, written out here rather than read off the class.""" @@ -257,17 +292,6 @@ def capped_law(kappa, lines, sample_rate, decay_s, loop_hz, cap): return d, capped, -3.0 * kappa * p / (2.0 * fs * math.log10(d)) -class LooseCeiling(Reverb): - """The ceiling knee (dossier section 8.9): the law's low-frequency cap - at 2 x Decay instead of 1.5 x, so every ceiling knee moves up.""" - - NAME = 'Reverb' - - def _decay(self, index, lines, decay_s, loop_hz): - return capped_law(rv.KAPPA[index], lines, self._sample_rate, decay_s, - loop_hz, 2.0) - - # -- probes and rendering ---------------------------------------------------- def interleave(mono, channels, frames): @@ -479,21 +503,33 @@ def blackman_harris(n): - 0.01168 * np.cos(6 * np.pi * k)) -def sidebands_db(cls, name, hz, window_rate, rate=RATE, channels=2, - **override): - """M6 restated for T10: a sine at 8 000 LSB, Mix 2, the mono sum; 4 s - to settle, then L = max(6, 8 / rate) s under Blackman-Harris; the - energy 4/L-20 Hz either side over the energy within 4/L Hz, in dB.""" +def sidebands_at(cls, patch, hz, depth_position, rate_position, rate=RATE, + channels=2): + """`sidebands_db` on shipped patch `patch` as the player has it + (`program_change`), Mod Depth and Mod Rate set on the grid, Mix 2.""" + spans = Reverb._MACRO_RANGES + window_rate = _component.macro_value(spans[rv.MOD_RATE_I], + rate_position / 127.0) length = max(6.0, 8.0 / window_rate) n_total = int((4.0 + length) * rate) x = np.round(8000 * np.sin(2 * np.pi * hz * np.arange(n_total) / rate)) - effect = make(cls, name, rate, channels, x.astype(np.int16), n_total, - **dict(override, mix=2.0)) + src = probes.ArraySource(interleave(x.astype(np.int16), channels, + n_total), + rate=rate, channels=channels, block=256) + effect = cls(src, sample_rate=rate) + effect.program_change(patch) + effect.set_macro(rv.MOD_DEPTH_I, depth_position) + effect.set_macro(rv.MOD_RATE_I, rate_position) + effect.set_macro(rv.MIX_I, 127) try: y = render(effect, n_total) finally: effect.deinit() - m = y.astype(np.float64).sum(axis=1)[n_total - int(length * rate):] + return _sideband_ratio(y, hz, length, rate) + + +def _sideband_ratio(y, hz, length, rate): + m = y.astype(np.float64).sum(axis=1)[len(y) - int(length * rate):] p = np.abs(np.fft.rfft(m * blackman_harris(len(m)))) ** 2 f = np.fft.rfftfreq(len(m), 1.0 / rate) d = np.abs(f - hz) @@ -503,6 +539,23 @@ def sidebands_db(cls, name, hz, window_rate, rate=RATE, channels=2, return 10 * math.log10(side / carrier) if side > 0 else -300.0 +def sidebands_db(cls, name, hz, window_rate, rate=RATE, channels=2, + **override): + """M6 restated for T10: a sine at 8 000 LSB, Mix 2, the mono sum; 4 s + to settle, then L = max(6, 8 / rate) s under Blackman-Harris; the + energy 4/L-20 Hz either side over the energy within 4/L Hz, in dB.""" + length = max(6.0, 8.0 / window_rate) + n_total = int((4.0 + length) * rate) + x = np.round(8000 * np.sin(2 * np.pi * hz * np.arange(n_total) / rate)) + effect = make(cls, name, rate, channels, x.astype(np.int16), n_total, + **dict(override, mix=2.0)) + try: + y = render(effect, n_total) + finally: + effect.deinit() + return _sideband_ratio(y, hz, length, rate) + + def twelfth_bands(lo=15.0, hi=4000.0): edges = [] f = lo @@ -739,6 +792,37 @@ def test_hz_clamps_below_nyquist_at_22050(self): self.assertAlmostEqual(effect._handed["damping_hz"], 10804.5) + def test_the_spans(self): + # the docstring's control spans, and where each end lands + spans = dict(zip(Reverb.MACRO_LABELS, Reverb._MACRO_RANGES)) + for label, low, high in ( + ("Decay", 0.3, 10.0), ("Size", 0.5, 1.5), + ("Predelay", 0.0, 200.0), ("Diffusion", 0.0, 0.9), + ("Damping", 500.0, 16000.0), ("Bandwidth", 500.0, 20000.0), + ("Low Cut", 20.0, 500.0), ("Mod Depth", 0.0, 2.0), + ("Mod Rate", 0.1, 5.0), ("Width", 0.0, 1.0), + ("Tone", -12.0, 12.0), ("Mix", 0.0, 2.0)): + self.assertEqual(tuple(spans[label][:2]), (low, high), label) + index = Reverb.MACRO_LABELS.index(label) + effect = silent_build(Reverb) + effect.set_macro(index, 0) + self.assertAlmostEqual(effect.macro(index), low, places=6) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect.macro(index), high, places=6) + + def test_without_audioverb_construction_raises_import_error(self): + saved = sys.modules.get("audioverb") + sys.modules["audioverb"] = None + try: + with self.assertRaises(ImportError): + silent_build(Reverb) + finally: + if saved is None: + del sys.modules["audioverb"] + else: + sys.modules["audioverb"] = saved + + class TheCut(unittest.TestCase): def test_the_plate_tank_is_dattorros_at_48k(self): # dossier App. B: Dattorro's tank lines scaled to 48 kHz. @@ -758,6 +842,9 @@ def ram(index, size): self.assertEqual(ram(rv.PLATE, 1.0), 89714) self.assertEqual(ram(rv.HALL, 1.5), 146914) self.assertEqual(ram(rv.ROOM, 0.5), 38526) + # the hall at Size 1.5 is the most the class allocates + self.assertEqual(max(ram(i, 0.5 + k / 127.0) for i in range(4) + for k in range(128)), 146914) def test_the_law_never_reaches_the_node_clamp(self): largest = 0.0 @@ -779,6 +866,21 @@ def test_the_law_never_reaches_the_node_clamp(self): self.assertLess(largest, 0.9829) self.assertGreater(largest, 0.98) + def test_the_damper_law(self): + # the plate's loop corner: open (0.98 x Nyquist) at Decay 8 s and + # above, the Damping setting at 1 s and below; the others hand + # Damping at every Decay + for decay, want in ((8.0, 0.49 * RATE), (10.0, 0.49 * RATE), + (1.0, 1000.0), (0.3, 1000.0)): + effect = silent_build(Reverb, decay=decay, damping_hz=1000.0) + self.assertAlmostEqual(effect._handed["damping_hz"], want, + delta=1e-6 * want) + room = silent_build(Reverb, character="room", decay=decay, + damping_hz=1000.0) + self.assertAlmostEqual(room._handed["damping_hz"], 1000.0) + mid = silent_build(Reverb, decay=2.8, damping_hz=1000.0) + self.assertTrue(1000.0 < mid._handed["damping_hz"] < 0.49 * RATE) + def test_tail_samples_at_the_patches(self): # dossier section 6: the bound at each patch's settings, 48 kHz. for name, frames in (("Steel Plate", 222868), ("Small Room", 80736), @@ -787,40 +889,37 @@ def test_tail_samples_at_the_patches(self): self.assertEqual(make(Reverb, name).tail_samples, frames, name) -class Rebuilds(unittest.TestCase): - def test_a_character_move_rebuilds_and_releases(self): +class Recuts(unittest.TestCase): + def test_a_character_or_size_move_recuts_the_one_tank(self): effect = silent_build(Reverb) port = effect.output - first = effect._tank - effect.set_macro(rv.CHARACTER_I, 20) # same zone: no rebuild - self.assertIs(effect._tank, first) + tank = effect._tank + lines = handed_cut(effect) + effect.set_macro(rv.CHARACTER_I, 20) # same zone: no re-cut + self.assertEqual(handed_cut(effect), lines) effect.set_macro(rv.CHARACTER_I, 42) # room - self.assertIsNot(effect._tank, first) - self.assertIs(effect.output, port) - self.assertEqual(effect._nodes, [effect._tank]) - with self.assertRaises(Exception): - first.set(decay=0.5) # released - second = effect._tank - effect.set_macro(rv.DECAY_I, 100) - self.assertIs(effect._tank, second) + self.assertEqual(effect._index, rv.ROOM) effect.set_macro(rv.SIZE_I, 100) - self.assertIsNot(effect._tank, second) + self.assertNotEqual(handed_cut(effect)[1], lines[1]) + self.assertIs(effect._tank, tank) + self.assertIs(effect.output, port) + self.assertEqual(effect._nodes, [tank]) - def test_a_patch_rebuilds_once(self): + def test_a_patch_recuts_once(self): class Counting(Reverb): NAME = 'Reverb' count = 0 - def _rebuild(self, *arguments): + def _recut(self, *arguments): type(self).count += 1 - Reverb._rebuild(self, *arguments) + Reverb._recut(self, *arguments) effect = silent_build(Counting) Counting.count = 0 effect.program_change(9) # Character and Size move self.assertEqual(Counting.count, 1) - def _wire_across_a_move(self, block, move): + def _wire_across_a_move(self, block, move, cls=Reverb): """Mix 0 over a ramp served `block` frames at a time, one move 1536 frames in: (frames that differ from the source, how far the output runs ahead of it after the move).""" @@ -829,7 +928,7 @@ def _wire_across_a_move(self, block, move): dtype=np.int16) src = probes.ArraySource(interleave(ramp, 2, frames), rate=RATE, channels=2, block=block) - effect = Reverb(src, sample_rate=RATE, mix=0.0) + effect = cls(src, sample_rate=RATE, mix=0.0) head = render(effect, 1536) effect.set_macro(*move) out = np.concatenate([head, render(effect, frames - 2048)])[:, 0] @@ -842,33 +941,35 @@ def _wire_across_a_move(self, block, move): break return differ, ahead - def test_a_rebuild_keeps_the_wire_on_a_256_frame_source(self): - # the module docstring: a source whose buffers divide the Tank's - # 256-frame block loses nothing across a rebuild - for block in (256, 128): - for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70)): + def test_a_recut_keeps_every_frame_of_the_dry(self): + # audiodsp#169: the frames the Tank holds stay across the re-cut, + # on source buffers shorter and longer than its 256-frame block + for block in (128, 256, 1024, 2048): + for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70), + (rv.DECAY_I, 90)): self.assertEqual(self._wire_across_a_move(block, move), (0, None), (block, move)) - def test_a_rebuild_skips_what_the_old_tank_held(self): - # the disclosed loss: on a 1024- or 2048-frame source the old - # Tank's unplayed 512 frames go with it; a move that does not - # rebuild keeps the wire + def test_the_old_rebuild_is_red(self): + # a new Tank per move loses the old one's unplayed 512 frames on a + # 1024- or 2048-frame source, and nothing on 128 or 256 for block in (1024, 2048): for move in ((rv.CHARACTER_I, 42), (rv.SIZE_I, 70)): - differ, ahead = self._wire_across_a_move(block, move) + differ, ahead = self._wire_across_a_move(block, move, + RebuildOnMove) self.assertEqual(ahead, 512, (block, move)) self.assertGreater(differ, 0, (block, move)) - self.assertEqual(self._wire_across_a_move(block, - (rv.DECAY_I, 90)), - (0, None), block) + for block in (128, 256): + self.assertEqual(self._wire_across_a_move( + block, (rv.CHARACTER_I, 42), RebuildOnMove), (0, None)) - def test_a_rebuild_cuts_the_tail(self): + def test_a_recut_cuts_the_tail(self): frames = RATE effect = make(Reverb, "Steel Plate", mono=noise_burst(RATE, seconds=0.25), frames=frames, mix=2.0) render(effect, int(0.5 * RATE)) + self.assertGreater(int(np.max(np.abs(render(effect, 256)))), 0) effect.set_macro(rv.CHARACTER_I, 127) after = render(effect, int(0.25 * RATE)) self.assertEqual(int(np.max(np.abs(after))), 0) @@ -930,12 +1031,6 @@ def test_a_tone_move_out_of_silence_stays_silent(self): self.assertEqual(self._tone_route(Reverb, channels), (0, 0), channels) - def test_tone_detent_as_exact_zero_is_red(self): - for channels in (2, 1): - before, after = self._tone_route(ToneDetentZero, channels) - self.assertEqual(before, 0, channels) - self.assertGreater(after, 100, channels) - def test_click_delay_is_zero(self): for name in ("Steel Plate", "Concert Hall"): frames = 2048 @@ -947,8 +1042,9 @@ def test_click_delay_is_zero(self): self.assertEqual(int(np.max(np.abs(out[:1000]))), 0, name) def test_the_tail_reaches_exact_zero_inside_tail_samples(self): - for name, channels in (("Steel Plate", 2), ("Small Room", 1)): - rng = np.random.RandomState(3) + for name, channels, seed in [(n, c, k) for n, c in ( + ("Steel Plate", 2), ("Small Room", 1)) for k in range(3, 11)]: + rng = np.random.RandomState(seed) burst = np.round(rng.uniform(-1, 1, RATE) * 32767).astype( np.int16) probe = make(Reverb, name, channels=channels) @@ -959,38 +1055,136 @@ def test_the_tail_reaches_exact_zero_inside_tail_samples(self): y = render(effect, frames) nz = np.nonzero(np.any(y != 0, axis=1))[0] last = int(nz[-1]) - RATE - self.assertLess(last, bound, name) - self.assertGreater(last, bound // 4, name) - - def test_reset_clears_the_tail_and_restores_patch_0(self): - effect = make(Reverb, "Concert Hall", - mono=np.concatenate([ - np.zeros(4096, dtype=np.int16), - noise_burst(RATE, seconds=0.25)]), - frames=2 * RATE, mix=2.0) - effect.program_change(6) - render(effect, RATE // 2) + self.assertLess(last, bound, (name, seed)) + self.assertGreater(last, bound // 4, (name, seed)) + + def _reset_route(self, cls, patch): + """Patch `patch` rings on a 0.25 s burst; `reset()` 0.5 s in: (peak + of the block before the reset, peak of the 4096 frames after).""" + effect = make(cls, mono=np.concatenate([ + np.zeros(4096, dtype=np.int16), + noise_burst(RATE, seconds=0.25)]), frames=2 * RATE) + effect.program_change(patch) + effect.set_macro(rv.MIX_I, 127) + render(effect, RATE // 2 - 256) + before = int(np.max(np.abs(render(effect, 256)))) effect.reset() self.assertEqual(effect.patch_index, 0) # the borrowed source is not reset: it is past its burst, so what # comes out is the emptied tank and a silent dry - self.assertEqual(int(np.max(np.abs(render(effect, 4096)))), 0) + return before, int(np.max(np.abs(render(effect, 4096)))) + + def test_reset_clears_the_tail_and_restores_patch_0(self): + # from patch 0 the reset re-cuts nothing, so the clear is its own + for patch in (0, 6): + before, after = self._reset_route(Reverb, patch) + self.assertGreater(before, 0, patch) + self.assertEqual(after, 0, patch) + + def test_a_reset_that_clears_nothing_is_red_from_patch_0(self): + before, after = self._reset_route(ResetClearsNothing, 0) + self.assertGreater(after, 100) + # from Concert Hall patch 0's re-cut empties the lines anyway, + # which is what hid it + self.assertEqual(self._reset_route(ResetClearsNothing, 6)[1], 0) + + def _dry_across_a_reset(self, cls, block): + """Mix 0 over a ramp served `block` frames at a time; `reset()` 1536 + frames in and Mix back to 0: frames of the output that are not the + source.""" + frames = 8192 + ramp = np.array([((i * 7) % 20001) - 10000 for i in range(frames)], + dtype=np.int16) + src = probes.ArraySource(interleave(ramp, 2, frames), rate=RATE, + channels=2, block=block) + effect = cls(src, sample_rate=RATE, mix=0.0) + head = render(effect, 1536) + effect.reset() + effect.set_macro(rv.MIX_I, 0) + out = np.concatenate([head, render(effect, frames - 2048)])[:, 0] + return int(np.sum(out != ramp[:len(out)])) + + def test_reset_keeps_the_dry_the_tank_holds(self): + for block in (100, 256, 1024): + self.assertEqual(self._dry_across_a_reset(Reverb, block), 0, + block) - def test_deinit_releases_every_tank_and_leaves_the_source(self): + def test_a_reset_through_reset_buffer_is_red(self): + for block in (100, 1024): + self.assertGreater( + self._dry_across_a_reset(ResetDropsHeldFrames, block), 0, + block) + + def test_deinit_releases_the_tank_and_leaves_the_source(self): src = probes.ArraySource(array("h", [9000] * 4096), rate=RATE, channels=2) effect = Reverb(src, sample_rate=RATE) - first = effect._tank + tank = effect._tank effect.set_macro(rv.CHARACTER_I, 127) - second = effect._tank render(effect, 256) effect.deinit() - for tank in (first, second): - with self.assertRaises(Exception): - tank.set(decay=0.5) + with self.assertRaises(Exception): + tank.set(decay=0.5) data = memoryview(bytes(audiocore.get_buffer(src)[1])).cast("h") self.assertEqual(max(abs(int(v)) for v in data), 9000) + def test_a_sparse_quiet_click_rings_out_early(self): + # one click at 1 000 LSB on an 8 s plate is exactly silent within + # 2 s (the lines truncate toward zero); at 30 000 it is not + frames = 4 * RATE + for level, silent in ((1000, True), (30000, False)): + effect = make(Reverb, "Steel Plate", mono=impulse(level), + frames=frames, mix=2.0, decay=8.0) + y = render(effect, frames) + effect.deinit() + self.assertEqual(not np.any(y[2 * RATE:]), silent, level) + self.assertTrue(np.any(y[RATE // 10:]), level) + + def test_a_control_that_jumps_steps_the_output(self): + # the family limit: Mix 0 to 2 in one move over a steady tone steps + # the output at the block the move lands on + frames = 16384 + x = np.round(8000 * np.sin(2 * np.pi * 200.0 * np.arange(frames) + / RATE)).astype(np.int16) + effect = make(Reverb, mono=x, frames=frames, mix=0.0) + head = render(effect, 8192) + effect.set_macro(rv.MIX_I, 127) + y = np.concatenate([head, render(effect, 256)])[:, 0] + step = int(np.max(np.abs(np.diff(y[8190:8200])))) + own = int(np.max(np.abs(np.diff(x.astype(np.int32))))) + self.assertGreater(step, 3 * own, (step, own)) + + def test_the_tail_rings_only_while_the_source_feeds(self): + # the family limit: a source that stops handing frames stops the + # tail where it is, and it carries on when the source comes back + class Gated: + def __init__(self, burst): + self.sample_rate = RATE + self.channel_count = 2 + self.bits_per_sample = 16 + self.samples_signed = True + self.data = interleave(burst, 2, len(burst)).tobytes() + self.open = True + + def _reset_buffer(self, *args): + pass + + def _get_buffer(self, *args): + if not self.open: + return 0, memoryview(b"") + chunk, self.data = self.data[:1024], self.data[1024:] + return 1, memoryview(chunk or bytes(1024)) + + src = Gated(noise_burst(RATE, seconds=0.25)) + effect = Reverb(src, sample_rate=RATE, mix=2.0) + render(effect, RATE // 2) + self.assertGreater(int(np.max(np.abs(render(effect, 256)))), 0) + src.open = False + render(effect, 1024) # what the Tank holds + self.assertEqual(int(np.max(np.abs(render(effect, 2 * RATE)))), 0) + src.open = True + self.assertGreater(int(np.max(np.abs(render(effect, 256)))), 0) + def test_lower_rates_build_and_ring(self): for rate in (44100, 22050): for index in (0, 6): @@ -1071,41 +1265,48 @@ def test_null_build_is_red(self): T3_STOPS = (8.0, 4.0, 2.0, 1.0) -def t3_ratios(cls): +def t3_ratios(cls, seed_sets=SEED_SETS): """R = T60(500 Hz) / T60(4 kHz) at Decay 8 / 4 / 2 / 1 s on Steel Plate - as patched, each the mean over seeds 7-14; None where a fit fails.""" - key = ("T3", cls) + as patched: per seed set, the mean over its eight seeds at each stop; + None where a fit fails.""" + key = ("T3", cls, seed_sets) if key not in _MEMO: - rs = [] - for t in T3_STOPS: - vals = [band_t60s(cls, "Steel Plate", seed, (500, 4000), - decay=t) for seed in SEEDS] - if any(a is None or b is None for a, b in vals): - rs = None + out = [] + for seeds in seed_sets: + rs = [] + for t in T3_STOPS: + vals = [band_t60s(cls, "Steel Plate", seed, (500, 4000), + decay=t) for seed in seeds] + if any(a is None or b is None for a, b in vals): + rs = None + break + rs.append(float(np.mean([a / b for a, b in vals]))) + if rs is None: + out = None break - rs.append(float(np.mean([a / b for a, b in vals]))) - _MEMO[key] = rs + out.append(rs) + _MEMO[key] = out return _MEMO[key] -def t3_verdict(rs): - if rs is None: +def t3_verdict(sets): + """Each step down in Decay raises R on every seed set, by a margin + larger than the spread of that step between the sets.""" + if sets is None: return False - return (all(b >= a for a, b in zip(rs, rs[1:])) - and rs[-1] / rs[0] - 1.0 >= 0.20) + return all(holds([rs[i + 1] - rs[i] for rs in sets], low=0.0) + for i in range(len(T3_STOPS) - 1)) class T3Damper(unittest.TestCase): def test_the_upper_band_shortens_more_as_decay_shortens(self): - rs = t3_ratios(Reverb) - self.assertTrue(t3_verdict(rs), rs) - # dossier T3: 1.129 -> 1.214 -> 1.376 -> 1.627, +44.2 % - self.assertAlmostEqual(rs[-1] / rs[0] - 1.0, 0.442, delta=0.05) + sets = t3_ratios(Reverb) + self.assertTrue(t3_verdict(sets), sets) def test_a_frequency_flat_damper_is_red(self): - rs = t3_ratios(FlatDamper) - self.assertFalse(t3_verdict(rs), rs) - self.assertLess(rs[-1] / rs[0] - 1.0, -0.5) + sets = t3_ratios(FlatDamper, SEED_SETS[:1]) + self.assertFalse(t3_verdict(sets * 8), sets) + self.assertLess(sets[0][-1] / sets[0][0] - 1.0, -0.5) def test_the_fault_is_not_on_the_surface(self): # The character is part of the reading: the room, chamber and hall @@ -1118,7 +1319,8 @@ def test_the_fault_is_not_on_the_surface(self): def test_null_build_is_red(self): kit_faults.null_build_red( - Reverb, lambda cls: {"passed": t3_verdict(t3_ratios(cls))}, + Reverb, lambda cls: {"passed": t3_verdict( + t3_ratios(cls, SEED_SETS[:1]) and t3_ratios(cls))}, label="Reverb T3") @@ -1190,25 +1392,36 @@ def test_null_build_is_red(self): label="Reverb T8") -def t60_1k(cls, name): - key = ("T9", cls, name) +def t60_1k(cls, name, seed_sets=SEED_SETS): + """Per seed set, the mean T60 at 1 kHz over its eight seeds.""" + key = ("T9", cls, name, seed_sets) if key not in _MEMO: - vals = [band_t60s(cls, name, seed, (1000,))[0] - for seed in (7, 8, 9, 10)] - _MEMO[key] = None if None in vals else float(np.mean(vals)) + out = [] + for seeds in seed_sets: + vals = [band_t60s(cls, name, seed, (1000,))[0] for seed in seeds] + if None in vals: + out = None + break + out.append(float(np.mean(vals))) + _MEMO[key] = out return _MEMO[key] -def t9_verdict(cls): - hall, live, small = (t60_1k(cls, n) for n in - ("Concert Hall", "Live Room", "Small Room")) +def t9_verdict(cls, seed_sets=SEED_SETS): arrivals = [first_arrival_ms(cls, n) for n in ("Concert Hall", "Live Room", "Small Room")] - if None in (hall, live, small) or None in arrivals: + if None in arrivals or not (arrivals[0] >= 10.0 and arrivals[1] <= 6.0 + and arrivals[2] <= 6.0): + return False + hall, live, small = (t60_1k(cls, n, seed_sets) for n in + ("Concert Hall", "Live Room", "Small Room")) + if None in (hall, live, small): return False - return (hall >= 2.0 * live and hall >= 4.0 * small - and arrivals[0] >= 10.0 and arrivals[1] <= 6.0 - and arrivals[2] <= 6.0) + if len(seed_sets) < len(SEED_SETS): + return all(h >= 2.0 * lv and h >= 4.0 * sm + for h, lv, sm in zip(hall, live, small)) + return (holds([h / lv for h, lv in zip(hall, live)], low=2.0) + and holds([h / sm for h, sm in zip(hall, small)], low=4.0)) def construction_pairs(cls, rate=RATE): @@ -1260,14 +1473,41 @@ def test_null_build_is_red(self): label="Reverb T9") +T10_PATCHES = (0, 6) # Steel Plate, Concert Hall + + def t10_on(cls): - return all(sidebands_db(cls, name, 1000.0, SETTINGS[name]["mod_rate_hz"]) - >= -20.0 for name in ("Steel Plate", "Concert Hall")) + """At each patch's own Mod Depth and Mod Rate, a 1 kHz tone's sidebands + within 20 dB of it.""" + return all(sidebands_at(cls, index, 1000.0, + Reverb.PATCHES[index][1][rv.MOD_DEPTH_I], + Reverb.PATCHES[index][1][rv.MOD_RATE_I]) + >= -20.0 for index in T10_PATCHES) def t10_off(cls): - return all(sidebands_db(cls, name, 1000.0, 1.0e9, mod_depth_ms=0.0) - <= -60.0 for name in ("Steel Plate", "Concert Hall")) + """Mod Depth at 0 on each patch: a 1 kHz tone's sidebands more than + 60 dB under it.""" + return all(sidebands_at(cls, index, 1000.0, 0, + Reverb.PATCHES[index][1][rv.MOD_RATE_I]) + <= -60.0 for index in T10_PATCHES) + + +#: T10's claimed rectangle on the 0-127 grid, both ends in: Mod Depth +#: positions 17-64 (0.268-1.008 ms), Mod Rate positions 45-81 (0.400-1.212 +#: Hz); and one cell outside it where Steel Plate's 3 kHz tone fails (the +#: round-3 audit's). +T10_DEPTHS = (17, 64) +T10_RATES = (45, 81) +T10_HOLE = (81, 121) + + +def t10_cell(depth_position, rate_position): + spans = Reverb._MACRO_RANGES + return (_component.macro_value(spans[rv.MOD_DEPTH_I], + depth_position / 127.0), + _component.macro_value(spans[rv.MOD_RATE_I], + rate_position / 127.0)) class T10Modulation(unittest.TestCase): @@ -1277,11 +1517,6 @@ def test_a_still_tank_is_one_line(self): def test_the_patches_modulation_spreads_it(self): self.assertTrue(t10_on(Reverb)) - def test_the_lowest_claimed_depth(self): - v = sidebands_db(Reverb, "Steel Plate", 1000.0, 1.0, - mod_depth_ms=0.27, mod_rate_hz=1.0) - self.assertGreaterEqual(v, -20.0) - def test_0_1_ms_is_not_claimed_it_follows_the_line_set(self): # dossier section 8, R2: at Mod Depth 0.1 ms on Steel Plate the # reading depends on the exact lines; green at Size 1.0 (-18.4 dB), @@ -1294,6 +1529,30 @@ def test_0_1_ms_is_not_claimed_it_follows_the_line_set(self): self.assertGreaterEqual(at_1, -20.0) self.assertLess(at_grid, -20.0) + def test_the_claimed_rectangle(self): + # every grid point, stepped by 1, is REVERB_T10_WALK=1 (about half + # an hour a patch; its printout is in the anchor's trial folder); + # the default walks the corners and every eighth position + full = bool(os.environ.get("REVERB_T10_WALK")) + step = 1 if full else 8 + depths = sorted(set(list(range(T10_DEPTHS[0], T10_DEPTHS[1] + 1, + step)) + [T10_DEPTHS[1]])) + rates = sorted(set(list(range(T10_RATES[0], T10_RATES[1] + 1, + step)) + [T10_RATES[1]])) + for index in T10_PATCHES: + for hz in (300.0, 1000.0, 3000.0): + for dp in depths: + for rp in rates: + v = sidebands_at(Reverb, index, hz, dp, rp) + self.assertGreaterEqual(v, -20.0, + (index, hz, dp, rp, v)) + + def test_where_it_does_not_hold(self): + # outside the rectangle: Steel Plate, a 3 kHz tone, Mod Depth at + # position 81, Mod Rate at position 121 + v = sidebands_at(Reverb, 0, 3000.0, T10_HOLE[0], T10_HOLE[1]) + self.assertLess(v, -20.0, v) + def test_mod_rate_as_its_reciprocal_is_red(self): self.assertFalse(t10_on(RateReciprocal)) @@ -1313,40 +1572,57 @@ def test_null_build_is_red(self): Reverb, lambda cls: {"passed": t10_on(cls)}, label="Reverb T10") -T11_STOPS = (2.0, 4.0, 8.0, 10.0) +T11_STOPS = (2.0, 3.0, 4.0, 6.0, 8.0, 10.0) -def t11_errors(cls, character, stops=T11_STOPS): - """(T60 at 500 Hz / Decay - 1) at each stop, the mean over seeds 7-14, - at the character's reference patch with Size 1.0.""" - key = ("T11", cls, character, stops) +def t11_errors(cls, character, stops=T11_STOPS, seed_sets=SEED_SETS): + """Per stop, per seed set, (T60 at 500 Hz / Decay - 1), the set's mean + over its eight seeds, at the character's reference patch with Size + 1.0; None where a fit fails.""" + key = ("T11", cls, character, stops, seed_sets) if key not in _MEMO: errors = [] for t in stops: - vals = [band_t60s(cls, REFERENCE[character], seed, (500,), - decay=t, size=1.0)[0] for seed in SEEDS] - if None in vals: + row = [] + for seeds in seed_sets: + vals = [band_t60s(cls, REFERENCE[character], seed, (500,), + decay=t, size=1.0)[0] for seed in seeds] + if None in vals: + row = None + break + row.append(float(np.mean(vals)) / t - 1.0) + if row is None: errors = None break - errors.append(float(np.mean(vals)) / t - 1.0) + errors.append(row) _MEMO[key] = errors return _MEMO[key] def t11_verdict(errors): - return errors is not None and all(abs(e) <= 0.12 for e in errors) + """Every stop inside +/-12 % on every seed set, by a margin larger than + the spread between the sets.""" + return errors is not None and all(holds(row, -0.12, 0.12) + for row in errors) + + +#: The stops the docstring claims per character. The hall at 2 and 3 s +#: reads inside +/-12 % on every set but by a margin under the spread, so +#: it is not claimed there. +T11_CLAIMED = {"plate": T11_STOPS, "room": T11_STOPS, "chamber": T11_STOPS, + "hall": (4.0, 6.0, 8.0, 10.0)} class T11DecayIsT60(unittest.TestCase): def test_every_character_lands_its_label(self): for character in rv.CHARACTERS: - errors = t11_errors(Reverb, character) + errors = t11_errors(Reverb, character, T11_CLAIMED[character]) self.assertTrue(t11_verdict(errors), (character, errors)) def test_one_multiply_per_half_pass_is_red(self): - errors = t11_errors(OneMultiply, "plate") + errors = t11_errors(OneMultiply, "plate", seed_sets=SEED_SETS[:1]) self.assertFalse(t11_verdict(errors), errors) - self.assertTrue(all(e < -0.35 for e in errors), errors) + self.assertTrue(all(row[0] < -0.35 for row in errors), errors) def test_the_fault_is_not_on_the_surface(self): result = reach(OneMultiply, lambda e: ( @@ -1356,20 +1632,35 @@ def test_the_fault_is_not_on_the_surface(self): def test_null_build_is_red(self): kit_faults.null_build_red( Reverb, lambda cls: {"passed": t11_verdict( + t11_errors(cls, "plate", seed_sets=SEED_SETS[:1]) and t11_errors(cls, "plate"))}, label="Reverb T11") -# -- the Decay knees (dossier section 8.9, audit-3 ruling (o)) ---------------- +def patch_t500(name, seed_sets=SEED_SETS): + """Per seed set, the patch's mean T60 at 500 Hz over its eight seeds, + over its Decay.""" + key = ("patch", name, seed_sets) + if key not in _MEMO: + decay = SETTINGS[name]["decay"] + _MEMO[key] = [float(np.mean([band_t60s(Reverb, name, seed, + (500,))[0] + for seed in seeds])) / decay + for seeds in seed_sets] + return _MEMO[key] + + +#: The patches whose tail outlasts their Decay at 500 Hz on every seed set. +LONG_PATCHES = ("Damped Plate", "Small Room", "Live Room") -def midi_for(index, value): - """The fractional MIDI position that lands macro `index` on `value`.""" - return _component.midi_of_position( - Reverb.MACRO_MODES.get(index, "UNIPOLAR"), - _component.macro_position(Reverb._MACRO_RANGES[index], value)) +class PatchDecays(unittest.TestCase): + def test_three_patches_ring_longer_than_their_decay(self): + for name in LONG_PATCHES: + ratios = patch_t500(name) + self.assertTrue(holds(ratios, low=1.0), (name, ratios)) -PATCH_INDEX = {name: index for index, (name, _) in enumerate(PATCH_SETTINGS)} +# -- the ceiling (dossier section 8.9) ----------------------------------------- def t500_mean(build, decay, seeds=SEEDS, rate=RATE, hint=None): """M3 at 500 Hz on interrupted noise (2 s at 8 000 LSB RMS, then @@ -1394,174 +1685,51 @@ def t500_mean(build, decay, seeds=SEEDS, rate=RATE, hint=None): return None if None in vals else float(np.mean(vals)) -def knee_t500(cls, character, size, decay, seeds=SEEDS): - """The floor-knee cell: the character's reference patch on the grid - (`program_change`), Size and Decay set on the macros, Mix 2.""" - key = ("floor", cls, character, size, decay, seeds) +def ceiling_t500(cls, character, size, decay, seed_sets=SEED_SETS): + """Per seed set, T60 at 500 Hz at the constructor defaults (Damping + 1 kHz) on `character` at `size` and `decay`, Mix 2.""" + key = ("ceiling", cls, character, size, decay, seed_sets) if key not in _MEMO: - def build(src): - effect = cls(src, sample_rate=RATE) - effect.program_change(PATCH_INDEX[REFERENCE[character]]) - effect.set_macro(rv.SIZE_I, midi_for(rv.SIZE_I, size)) - effect.set_macro(rv.DECAY_I, midi_for(rv.DECAY_I, decay)) - effect.set_macro(rv.MIX_I, 127) - return effect - _MEMO[key] = t500_mean(build, decay, seeds) + hint = 2.0 * decay + _MEMO[key] = [t500_mean( + lambda src: cls(src, sample_rate=RATE, character=character, + size=size, decay=decay, mix=2.0), + decay, seeds, hint=hint) for seeds in seed_sets] return _MEMO[key] -FLOOR_STOPS = (0.3, 0.45, 0.6, 0.8, 1.0, 1.25, 1.5, 2.0) -KNEE_SIZES = (0.5, 0.75, 1.0, 1.25, 1.5) - -#: Section 8.9's floor knee as a band, as revised 2026-09-28 (R12): the -#: knee is the lowest Decay from which every position up to 2 s lands -#: within +/-12 % at 500 Hz, and the noise moves it, so each cell is the -#: (lowest, highest) knee read on four sets of eight seeds (7-14, 15-22, -#: 23-30, 31-38). The chamber at Size 1.5 has none at or under 2 s on one -#: set (its 2 s cell sits on the bar, T11, R7) and has its own test. -FLOOR_BANDS = { - "plate": ((0.3, 1.0), (0.45, 1.0), (1.0, 1.0), (1.5, 2.0), (1.5, 1.5)), - "room": ((0.45, 0.45), (0.8, 1.0), (0.8, 0.8), (1.0, 1.5), (1.0, 1.25)), - "chamber": ((0.6, 0.6), (0.8, 0.8), (1.0, 1.25), (1.5, 1.5), None), - "hall": ((0.45, 0.8), (1.0, 1.25), (1.25, 1.5), (1.25, 1.5), - (2.0, 2.0)), -} - - -def predicted_t500(character, size, decay, damping=1000.0, rate=RATE, - cap=1.5): - """T60 at 500 Hz the dossier's capped law gives on the room, chamber - or hall (their loop corner is Damping): (seconds, capped).""" - index = rv.CHARACTERS.index(character) - lines = rv.line_set(index, size, rate) - a, b = rv.half_periods(lines) - p = 0.5 * (a + b) - d, capped, _ = capped_law(rv.KAPPA[index], lines, rate, decay, damping, - cap) - g = d * d * rv.one_pole_mag(damping, 500.0, rate) - return 3.0 * rv.KAPPA[index] * p / (-rate * math.log10(g)), capped +#: Where the docstring says the tail lands more than 12 % short: Damping +#: 1 kHz, Size 0.5, Decay 8 and 10 s. +SHORT_CELLS = tuple((c, 0.5, t) for c in ("room", "chamber", "hall") + for t in (8.0, 10.0)) -def capped_cells(): - """The constructor defaults (Damping 1 kHz) on the room, chamber and - hall at Size 0.5 / 1.0 / 1.5 and Decay 2 / 4 / 8 / 10 s where the - dossier's law caps the bass.""" - out = [] - for character in ("room", "chamber", "hall"): - for size in (0.5, 1.0, 1.5): - for t in (2.0, 4.0, 8.0, 10.0): - pred, capped = predicted_t500(character, size, t) - if capped: - out.append((character, size, t, pred)) - return out +class NoCeiling(Reverb): + """The Decay law without its low-frequency cap (x 1000 Decay).""" + NAME = 'Reverb' -def ceiling_t500(cls, character, size, decay, hint): - key = ("ceiling", cls, character, size, decay) - if key not in _MEMO: - _MEMO[key] = t500_mean( - lambda src: cls(src, sample_rate=RATE, character=character, - size=size, decay=decay, mix=2.0), - decay, hint=hint) - return _MEMO[key] + def _decay(self, index, lines, decay_s, loop_hz): + return capped_law(rv.KAPPA[index], lines, self._sample_rate, decay_s, + loop_hz, 1000.0) -class DecayKnees(unittest.TestCase): - """Section 8.9's promise: a test that fails if a knee moves.""" - - def _floor(self, character, cls=None, seeds=SEEDS, sizes=KNEE_SIZES): - """What holds on every seed set: every position from the band's - top to 2 s inside +/-12 %, and the position under the band's - bottom outside (none where the bottom is the first stop).""" - cls = Reverb if cls is None else cls - for size, band in zip(KNEE_SIZES, FLOOR_BANDS[character]): - if band is None or size not in sizes: - continue - bottom, top = band - for t in FLOOR_STOPS[FLOOR_STOPS.index(top):]: - e = knee_t500(cls, character, size, t, seeds) / t - 1.0 - self.assertLessEqual(abs(e), 0.12, (character, size, t, e)) - i = FLOOR_STOPS.index(bottom) - if i: - t = FLOOR_STOPS[i - 1] - e = knee_t500(cls, character, size, t, seeds) / t - 1.0 - self.assertGreater(abs(e), 0.12, (character, size, t, e)) - - def test_the_plates_floor_knees(self): - self._floor("plate") - - def test_the_rooms_floor_knees(self): - self._floor("room") - - def test_the_chambers_floor_knees(self): - self._floor("chamber") - - def test_the_halls_floor_knees(self): - self._floor("hall") - - def test_the_plates_floor_bands_hold_on_another_seed_set(self): - # the property the one-stop table lacked: the same assertions on - # seeds 23-30, where that table failed at two plate cells - self._floor("plate", seeds=tuple(range(23, 31))) - - def test_the_chamber_at_size_1_5_has_no_knee_under_2_s(self): - # T11's cell, Not claimed (R7): 1.5 s is outside, and 2 s reads - # inside on seeds 7-14 but outside on seeds 23-30 - e15 = knee_t500(Reverb, "chamber", 1.5, 1.5) / 1.5 - 1.0 - self.assertGreater(abs(e15), 0.12, e15) - e2 = knee_t500(Reverb, "chamber", 1.5, 2.0) / 2.0 - 1.0 - self.assertTrue(0.08 <= e2 <= 0.12, e2) - e2b = knee_t500(Reverb, "chamber", 1.5, 2.0, - tuple(range(23, 31))) / 2.0 - 1.0 - self.assertGreater(e2b, 0.12, e2b) - - def test_longer_diffusers_move_the_floor_knee(self): - # the floor assertions at Size 1.0 fail on the plate, room and - # chamber once their diffusers are 1.5 x longer: the band's top - # reads +13.7 % (plate, 1 s), +21.9 % (room, 0.8 s) and +14.5 % - # (chamber, 1.25 s). The hall's does not move (+7.3 % at its 1.5 s - # top), and the plant is recorded as blind there. - for character in ("plate", "room", "chamber"): - with self.assertRaises(AssertionError, msg=character): - self._floor(character, LongDiffusers, sizes=(1.0,)) - top = FLOOR_BANDS[character][KNEE_SIZES.index(1.0)][1] - e = knee_t500(LongDiffusers, character, 1.0, top) / top - 1.0 - self.assertGreater(abs(e), 0.12, (character, top, e)) - - def test_the_longer_diffusers_are_not_on_the_surface(self): - result = reach(LongDiffusers, handed_cut) - self.assertEqual(result["checked"], WALK) +class TheCeiling(unittest.TestCase): + def test_small_long_rooms_land_short_of_the_label(self): + for character, size, t in SHORT_CELLS: + got = ceiling_t500(Reverb, character, size, t) + short = [1.0 - g / t for g in got] + self.assertTrue(holds(short, low=0.12), (character, t, short)) + + def test_without_the_cap_they_do_not(self): + # one seed set: the chamber lands long and the hall within 2 % + for character, t in (("chamber", 8.0), ("hall", 10.0)): + got = ceiling_t500(NoCeiling, character, 0.5, t, + SEED_SETS[:1])[0] + self.assertLess(1.0 - got / t, 0.05, (character, t, got)) - def test_the_ceiling_knees(self): - cells = capped_cells() - self.assertEqual(len(cells), 19) - misses = [] - for character, size, t, pred in cells: - got = ceiling_t500(Reverb, character, size, t, pred) - # R13: on four seed sets the room at Size 0.5 lands 5.1 to - # 12.9 % long of the prediction (2 s reads +12.9 % on seeds - # 31-38), the other 15 cells within 6.7 %; the room's four - # cells are held to 15 %, a margin that is ours - bar = 0.15 if (character, size) == ("room", 0.5) else 0.12 - self.assertLessEqual(abs(got / pred - 1.0), bar, - (character, size, t, got, pred)) - misses.append(1.0 - got / t) - # while the label there misses by up to about 60 % - self.assertGreater(max(misses), 0.5, misses) - - def test_a_loose_ceiling_is_red(self): - # the two cells where a cap at 2.0 moves 500 Hz most (+14.6 and - # +15.0 % measured); at most cells it moves under 12 % - for character, size, t in (("chamber", 1.0, 8.0), - ("room", 1.5, 8.0)): - pred, capped = predicted_t500(character, size, t) - self.assertTrue(capped) - got = ceiling_t500(LooseCeiling, character, size, t, 1.4 * pred) - self.assertGreater(abs(got / pred - 1.0), 0.12, - (character, size, t, got, pred)) - - def test_the_loose_ceiling_is_not_on_the_surface(self): - result = reach(LooseCeiling, lambda e: ( + def test_the_cap_is_not_on_the_surface(self): + result = reach(NoCeiling, lambda e: ( e._index, tuple(e._lines), round(e.macro(rv.DECAY_I), 9), e._handed["decay"]), character="room", size=0.5, decay=10.0) self.assertEqual(result["checked"], WALK) @@ -1569,21 +1737,25 @@ def test_the_loose_ceiling_is_not_on_the_surface(self): # -- the input ceiling (audit-3 rulings (m) and (o)) ------------------------- -def railed(name, rms, rate=RATE, channels=2, mix=1.0, **override): - """Samples on the int16 rail over 2 s of uniform noise at `rms` LSB RMS - (seed 11) and 0.5 s after, at Mix 1 by default.""" - rng = np.random.RandomState(11) - n = 2 * rate - x = np.round(np.clip(rng.uniform(-1, 1, n) * rms * math.sqrt(3), - -32768, 32767)).astype(np.int16) - frames = n + rate // 2 - effect = make(Reverb, name, rate, channels, x, frames, mix=mix, - **override) - try: - y = render(effect, frames) - finally: - effect.deinit() - return int(np.count_nonzero(np.abs(y) >= 32767)) +def rail_peaks(name, rms, rate=RATE, channels=2, mix=1.0, seeds=None, + **override): + """The output's peak over 2 s of uniform noise at `rms` LSB RMS and + 0.5 s after, at Mix 1 by default, one per seed (11-18).""" + peaks = [] + for seed in (range(11, 19) if seeds is None else seeds): + rng = np.random.RandomState(seed) + n = 2 * rate + x = np.round(np.clip(rng.uniform(-1, 1, n) * rms * math.sqrt(3), + -32768, 32767)).astype(np.int16) + frames = n + rate // 2 + effect = make(Reverb, name, rate, channels, x, frames, mix=mix, + **override) + try: + y = render(effect, frames) + finally: + effect.deinit() + peaks.append(int(np.max(np.abs(y)))) + return peaks def sine_wet_gain(hz, rms, rate=RATE): @@ -1607,16 +1779,28 @@ def sine_wet_gain(hz, rms, rate=RATE): return 20 * math.log10(a / b) +#: Bright Chamber at 44.1 kHz peaks 5 232 LSB under the rail at the most, +#: with 5 302 LSB between its eight seeds: not claimed. +RAIL_UNCLAIMED = (("Bright Chamber", 44100),) + + class InputCeiling(unittest.TestCase): def test_no_patch_reaches_the_rail_at_4000_lsb_rms(self): + # on eight noise seeds, at three rates, each patch's peak stays + # under the rail by more than the spread of its peaks for name, _ in PATCH_SETTINGS: - self.assertEqual(railed(name, 4000.0), 0, name) + for rate in (48000, 44100, 22050): + if (name, rate) in RAIL_UNCLAIMED: + continue + peaks = rail_peaks(name, 4000.0, rate) + self.assertTrue(holds(peaks, high=32767), (name, rate, + peaks)) def test_6_db_more_reaches_it(self): - # the same reading, the input planted 6 dB hotter - hot = [name for name, _ in PATCH_SETTINGS - if railed(name, 8000.0) > 0] - self.assertGreaterEqual(len(hot), 6, hot) + # the same reading with the input planted 6 dB hotter: Bright + # Chamber reaches the rail on every seed + self.assertTrue(all(p >= 32767 for p in rail_peaks( + "Bright Chamber", 8000.0))) def test_the_tanks_lines_compress_from_8000_lsb_rms(self): # the docstring: flat to 4 000 LSB RMS, about 1.3 dB down at 8 000 @@ -1627,5 +1811,190 @@ def test_the_tanks_lines_compress_from_8000_lsb_rms(self): self.assertLess(g8 - g4, -1.0, (g4, g8)) +# -- the claims --------------------------------------------------------------- + +#: Every sentence of the docstrings that makes a claim, word for word, and +#: the tests that assert it (the trial brief). A sentence that is not here +#: may hold no number. +CLAIMS = ( + ("Your dry signal passes untouched and a reverb tail rises behind it.", + ("T7LowCut.test_the_dry_is_flat",)), + ("Character picks the machine: `plate` is dense from the first " + "milliseconds, the way the EMT 140's steel sheet is, while `room`, " + "`chamber` and `hall` start sparse and build.", + ("T1PlateDensity.test_steel_plate_is_dense_from_20_ms", + "T8TheyBuild.test_room_hall_and_chamber_build")), + ("Decay (0.3 to 10 s) sets how long the tail rings, Size (0.5 to 1.5) " + "stretches every line of the network, and Predelay (0 to 200 ms) holds " + "the tail back from the dry.", + ("TheSurface.test_the_spans",)), + ("Diffusion (0 to 0.9) smears the early echoes, Damping (500 Hz to " + "16 kHz) darkens the tail as it rings, and Bandwidth (500 Hz to 20 kHz) " + "darkens what goes in.", + ("TheSurface.test_the_spans",)), + ("Low Cut (20 to 500 Hz) keeps the bass out of the tank while the dry " + "keeps it.", + ("TheSurface.test_the_spans", + "T7LowCut.test_the_tank_sees_no_bass_on_bass_free_plate", + "T7LowCut.test_the_dry_is_flat")), + ("Mod Depth (0 to 2 ms) and Mod Rate (0.1 to 5 Hz) wobble two lines " + "inside the tank, Width (0 to 1) sets the stereo spread, and Tone (-12 " + "to +12 dB) tilts the tail.", + ("TheSurface.test_the_spans", + "T10Modulation.test_the_patches_modulation_spreads_it")), + ("Mix (0 to 2) is `audiodelays.Echo`'s: the dry at unity until 1, the " + "tail alone at 2.", + ("TheSurface.test_the_spans", "T7LowCut.test_the_dry_is_flat")), + ("Mix 0 is a byte-exact wire while the tank keeps ringing behind it.", + ("Tier1.test_mix_zero_is_a_wire_at_every_patch",)), + ("On the plate, Decay also moves the tail's loss corner, the way the EMT " + "140's damping panel does: open at Decay 8 s and above, at the Damping " + "setting at 1 s and below.", + ("TheCut.test_the_damper_law",)), + ("So a short plate is a darker plate: each halving of Decay from 8 s to " + "1 s shortens the upper band more than the lower one.", + ("T3Damper.test_the_upper_band_shortens_more_as_decay_shortens",)), + ("A Character or Size move re-cuts the tank: the tail drops to nothing " + "at the move, and the dry carries on without losing a frame.", + ("Recuts.test_a_recut_cuts_the_tail", + "Recuts.test_a_recut_keeps_every_frame_of_the_dry")), + ("`reset()` empties the tank the same way, keeps the dry, and restores " + "patch 0.", + ("Tier1.test_reset_clears_the_tail_and_restores_patch_0", + "Tier1.test_reset_keeps_the_dry_the_tank_holds")), + ("At each character's reference patch (Steel Plate, Live Room, Dark " + "Chamber, Concert Hall) at Size 1.0, the tail falls 60 dB at 500 Hz " + "within 12 % of Decay at 2, 3, 4, 6, 8 and 10 s, and on the hall from " + "4 s.", + ("T11DecayIsT60.test_every_character_lands_its_label",)), + ("Shorter Decays, other Sizes and the other patches are not claimed: " + "Damped Plate, Small Room and Live Room ring longer than their Decay " + "reads.", + ("PatchDecays.test_three_patches_ring_longer_than_their_decay",)), + ("With Damping at 1 kHz and Size 0.5, the room, chamber and hall at " + "Decay 8 and 10 s ring more than 12 % short of it: the class holds the " + "bass to 1.5 x Decay.", + ("TheCeiling.test_small_long_rooms_land_short_of_the_label", + "TheCeiling.test_without_the_cap_they_do_not")), + ("A sparse, quiet input rings out sooner than the knob: one click at " + "1 000 LSB on an 8 s plate is exactly silent within 2 s.", + ("Tier1.test_a_sparse_quiet_click_rings_out_early",)), + ("With Mod Depth at 0, a 1 kHz tone on Steel Plate or Concert Hall comes " + "out as one line, its sidebands more than 60 dB under it.", + ("T10Modulation.test_a_still_tank_is_one_line",)), + ("On those two patches as shipped, at every grid position from 17 to " + "64 of Mod " + "Depth (about 0.27 to 1 ms) and from 45 to 81 of Mod Rate (about 0.4 " + "to 1.2 Hz), tones at 300 Hz, 1 kHz and 3 kHz spread into sidebands " + "within 20 dB of the tone.", + ("T10Modulation.test_the_claimed_rectangle",)), + ("Outside that it is not claimed: on Steel Plate a 3 kHz tone at Mod " + "Depth position 81 (about 1.28 ms) and Mod Rate position 121 (about " + "4.16 Hz) reads more than 20 dB under.", + ("T10Modulation.test_where_it_does_not_hold",)), + ("There is no input gain, and the tank's lines clamp at the rail on " + "every write whatever Mix is: a steady 362 Hz tone at 8 000 LSB RMS " + "comes back more than 1 dB quieter in the tail than at 4 000.", + ("InputCeiling.test_the_tanks_lines_compress_from_8000_lsb_rms",)), + ("On 2 s of uniform noise at 4 000 LSB RMS at Mix 1, no shipped patch " + "reaches the rail at 48, 44.1 or 22.05 kHz; Bright Chamber at 44.1 kHz " + "is not claimed.", + ("InputCeiling.test_no_patch_reaches_the_rail_at_4000_lsb_rms",)), + ("**The patches:** Steel Plate (the defaults), Short Plate, Damped " + "Plate, Bass-Free Plate, Small Room, Live Room, Concert Hall, Dark " + "Chamber, Bright Chamber, Slow Bloom.", + ("TheSurface.test_patches_are_the_dossier_settings_on_the_grid", + "TheSurface.test_patch_0_is_the_constructor_grid")), + ("`tail_samples` bounds the frames until the output is exactly zero " + "once your input stops: 222 868 frames at the defaults at 48 kHz.", + ("Tier1.test_the_tail_reaches_exact_zero_inside_tail_samples", + "TheCut.test_tail_samples_at_the_patches")), + ("One int16 allocation holds the lines and 200 ms of predelay: 89 714 B " + "for Steel Plate at 48 kHz, and 146 914 B for the hall at Size 1.5, the " + "most it takes.", + ("TheCut.test_ram",)), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("Tier1.test_a_control_that_jumps_steps_the_output",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out. A tail cut short by a source that stopped carries on " + "when the source comes back.", + ("Tier1.test_the_tail_rings_only_while_the_source_feeds",)), + ("Asking for `character=\"spring\"` says it is parked: the tank has no " + "dispersive chain yet.", + ("TheSurface.test_options_clamp_default_and_refuse",)), + ("A value outside a macro's span clamps to the nearer stop, and NaN " + "takes the option's default.", + ("TheSurface.test_options_clamp_default_and_refuse",)), + ("On a board without `audioverb`, construction raises `ImportError`.", + ("TheSurface.test_without_audioverb_construction_raises_import_error",)), + ("Latency is zero: nothing looks ahead, and Predelay delays only the " + "tail.", + ("Tier1.test_click_delay_is_zero",)), +) + + +def _words(text): + return " ".join(text.split()) + + +def claims_problems(doc, claims, module): + """What is wrong between `doc` and `claims`: a sentence missing from the + docstring, a named test that does not exist, a number outside every + claimed sentence.""" + problems = [] + text = _words(doc) + for sentence, tests in claims: + words = _words(sentence) + if words not in text: + problems.append("not in the docstring: %r" % sentence) + text = text.replace(words, " ") + for name in tests: + owner, _, method = name.partition(".") + if not hasattr(getattr(module, owner, None), method): + problems.append("no test %s" % name) + stray = re.findall(r"[^\s]*\d[^\s]*", text) + if stray: + problems.append("numbers outside CLAIMS: %r" % stray) + return problems + + +def docstrings(): + return rv.__doc__ + "\n" + Reverb.__doc__ + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claims_problems(docstrings(), CLAIMS, + sys.modules[__name__]), []) + + def test_the_check_can_fail(self): + module = sys.modules[__name__] + planted = docstrings() + "\nIt rings for 3 s." + self.assertEqual(len(claims_problems(planted, CLAIMS, module)), 1) + missing = CLAIMS + (("A sentence nobody wrote.", ()),) + self.assertEqual(len(claims_problems(docstrings(), missing, + module)), 1) + untested = CLAIMS[:-1] + ((CLAIMS[-1][0], ("Claims.test_nothing",)),) + self.assertEqual(len(claims_problems(docstrings(), untested, + module)), 1) + + def test_the_family_limits_are_word_for_word(self): + text = _words(rv.__doc__) + self.assertIn("**Limits shared by the family**", text) + for sentence in FAMILY: + self.assertIn(_words(sentence), text) + + +#: The two family sentences, as Brad ruled them on 2026-09-28. +FAMILY = ( + "A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "The tail rings only while the source keeps feeding: feed silence to " + "let it ring out. A tail cut short by a source that stopped carries on " + "when the source comes back.", +) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..e72ae1a 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -109,18 +109,6 @@ class this branch carries, on CPython and on the two native interpreters. ('AnalogDelay', 'E8-dry', 'P3'): ( 8, 'e1967d96', 8, 'e1967d96', 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('Reverb', 'E', 'P4'): ( - 322, '606b9d85', 1409, '722197fe', - 'a network move, reset or reset_buffer never re-converges to a fresh instance (modulation phase?)'), - ('Reverb', 'E', 'P5'): ( - 416, '8e447fac', 1166, '886ecc00', - 'almost every macro and patch move steps the output within one block'), - ('Reverb', 'E1-reset@part', 'P1'): ( - 44, 'df421423', 44, 'df421423', - 'reset() part-way through a source buffer drops the frames the input held: silence at Mix 0'), - ('Reverb', 'E8-dry', 'P3'): ( - 44, 'e23e44d7', 44, 'e23e44d7', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), ('ConvolutionReverb', 'E', 'P5'): ( 324, '9421d061', 678, 'e8b57cdc', 'Mix, Damping, Predelay, Room and patch moves step the output within one block'), From 2a1b6e46de940fa2d9da8082d268c8d7f6264f6e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:30:19 -0500 Subject: [PATCH 75/88] Reverb trial, second fixer: two false sentences struck (the darker short plate, the click silent within 2 s) with their CLAIMS rows --- lib/audioeffects/rebuilt/reverb.py | 6 ++---- tests/test_cpython_effects_reverb.py | 6 ------ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/audioeffects/rebuilt/reverb.py b/lib/audioeffects/rebuilt/reverb.py index 9927450..17b528a 100644 --- a/lib/audioeffects/rebuilt/reverb.py +++ b/lib/audioeffects/rebuilt/reverb.py @@ -18,8 +18,7 @@ On the plate, Decay also moves the tail's loss corner, the way the EMT 140's damping panel does: open at Decay 8 s and above, at the Damping -setting at 1 s and below. So a short plate is a darker plate: each halving -of Decay from 8 s to 1 s shortens the upper band more than the lower one. +setting at 1 s and below. A Character or Size move re-cuts the tank: the tail drops to nothing at the move, and the dry carries on without losing a frame. `reset()` empties the @@ -32,8 +31,7 @@ Plate, Small Room and Live Room ring longer than their Decay reads. With Damping at 1 kHz and Size 0.5, the room, chamber and hall at Decay 8 and 10 s ring more than 12 % short of it: the class holds the bass to 1.5 x -Decay. A sparse, quiet input rings out sooner than the knob: one click at -1 000 LSB on an 8 s plate is exactly silent within 2 s. +Decay. **Modulation.** With Mod Depth at 0, a 1 kHz tone on Steel Plate or Concert Hall comes out as one line, its sidebands more than 60 dB under it. On those diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index dc28a4d..a35da72 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -1851,9 +1851,6 @@ def test_the_tanks_lines_compress_from_8000_lsb_rms(self): "140's damping panel does: open at Decay 8 s and above, at the Damping " "setting at 1 s and below.", ("TheCut.test_the_damper_law",)), - ("So a short plate is a darker plate: each halving of Decay from 8 s to " - "1 s shortens the upper band more than the lower one.", - ("T3Damper.test_the_upper_band_shortens_more_as_decay_shortens",)), ("A Character or Size move re-cuts the tank: the tail drops to nothing " "at the move, and the dry carries on without losing a frame.", ("Recuts.test_a_recut_cuts_the_tail", @@ -1876,9 +1873,6 @@ def test_the_tanks_lines_compress_from_8000_lsb_rms(self): "bass to 1.5 x Decay.", ("TheCeiling.test_small_long_rooms_land_short_of_the_label", "TheCeiling.test_without_the_cap_they_do_not")), - ("A sparse, quiet input rings out sooner than the knob: one click at " - "1 000 LSB on an 8 s plate is exactly silent within 2 s.", - ("Tier1.test_a_sparse_quiet_click_rings_out_early",)), ("With Mod Depth at 0, a 1 kHz tone on Steel Plate or Concert Hall comes " "out as one line, its sidebands more than 60 dB under it.", ("T10Modulation.test_a_still_tank_is_one_line",)), From dd5b48f3fd52b7619bc0299e665378469697163b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:46:32 -0500 Subject: [PATCH 76/88] DigitalDelay under the trial of the second process at audiodsp v0.6.3rc3: the docstring cut to its claims with a CLAIMS table, tail_samples None with Repeat Cut in stated, the lifecycle matrix's red cells declared as the family limits No audio-path change in this commit: the class's code is untouched apart from comments. stall_window and clear_of_stalls stay, used only by planted faults. New tests for the claims no earlier test asserted (TrialClaims). --- CHANGELOG.md | 7 +- lib/audioeffects/rebuilt/digitaldelay.py | 265 ++++------------- tests/support/lifecycle.py | 22 +- tests/test_cpython_effects_digitaldelay.py | 322 +++++++++++++++++++++ tests/test_lifecycle_matrix.py | 6 - 5 files changed, 403 insertions(+), 219 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d621684..440144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,12 @@ there, and are recorded in its changelog. Python floats in two derived settings (audiocomponents#75). The old `delay.DigitalDelay` and its `set_time` / `set_mix` stay in `delay.py` until the class comes home. It lives in - `lib/audioeffects/rebuilt/digitaldelay.py` until then. + `lib/audioeffects/rebuilt/digitaldelay.py` until then. Its docstring + carries only sentences a test asserts (`CLAIMS`), with the family's two + disclosed limits: a control that jumps steps the output + (audiocomponents#117), and a tail waits for a source that stopped + (audiodsp#180); `tail_samples` is `None` with Repeat Cut in, as at + patch 5. - **`SlapbackDelay` (rebuilt, Phase 5; adopted 2026-09-28):** the Sun Studio tape slap on one `audioecho.FeedbackDelay`, and what `audioeffects.SlapbackDelay` now serves: one mono repeat at 135 ms, landed diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 94c7230..3ff47a7 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -1,204 +1,50 @@ -"""`DigitalDelay` - a clean interpolated line with the Boss DD-2's control law. - -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/DigitalDelay.md`, whose trait -table was frozen at Station A before this file existed (anchor commit -51207b8, 2026-09-27). The old class in `delay.py` is consulted only for the -seven defects that dossier's section 7 names. This class was adopted on -2026-09-28, and `audioeffects.DigitalDelay` serves it. - -**What it sounds like.** Your dry signal passes untouched, and one clean -repeat follows it, fed back for more. Time (12.5-800 ms) is the DD-2's -D.TIME and its three MODE ranges folded into one knob. Feedback (0-0.99) -is F.BACK, Mix (0-2) is E.LEVEL: dry at unity up to 1, wet alone at 2, and -Mix 0 is a wire while the line keeps recording. Turn Time while it plays -and the repeats bend in pitch and settle, the way the pedal's single master -clock resamples its memory, instead of clicking. Glide sets how fast that -happens: the time a full-range Time move takes, 800 ms to 8 s. Repeat Tone -and Repeat Cut put the pedal's 7 kHz and 40 Hz corners into the loop as -knobs, so each repeat gets a little darker or thinner than the last. Sync -locks Time to Division of the host's beat. - -**The standout:** the Boss DD-2 Digital Delay (1983), light touch. You get -its control law and its dry/wet discipline as defaults, and its converter -colour as two knobs that default off. Patch 5 is the pedal's own corners. - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The stock -`audiodelays.Echo` limits its only output at +-28000, so its Mix 0 is not -a wire, and its one continuous-time mode lands 350 ms 33 samples late. On -a stock CircuitPython board this module imports cleanly and construction -raises `ImportError`. - -**Latency: zero samples, at every setting and every rate.** Nothing looks -ahead. The delay is the wet path, not latency on the dry path, and no -option adds any. - -**Mono.** A one-channel source gets the identical effect on its one -channel. The class never passes `input_pan`, which in mono would overwrite -the node's mono feed and halve the repeats. - -**RAM.** The line is `max_time_ms + 1` ms of two int16 lanes whatever the -channel count: 153 792 B at 48 kHz for the default 800 ms (141 296 B at -44.1 kHz, 70 648 B at 22.05 kHz), plus about 1.2 KB of node. Pass a lower -`max_time_ms` to spend less (300 ms costs 57 792 B); Time then stops at -that ceiling and `get_macro(0)` shows where it stopped. - -**Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on; no mixer. -Palette row FeedbackDelay +options (the nearest not-cheaper row; there is -no row for the slew alone), glue 0: **P4 <= 9 %, S3 <= 15 %** of a -5.333 ms stereo block. Measured on both boards on 2026-09-27 at every -shipped patch: the P4 at most 0.372 ms, 7.0 % (patch 5, rt 5.49); the S3 -at most 0.631 ms as the tool reads it, and about 0.78 ms, 14.6 %, at -patch 5 once the tool's control is measured in the same conditions as the -palette row (the dearest patch, the one with both loop filters in; rt -3.11). Alone on an S3 it leaves about 85 % of the block for everything -else. Six of the seven patch digests are identical on both boards and the -desktop; patch 5's is identical on both boards and differs from the -desktop's because `nominal_cut_hz` and `nominal_damping_hz` are worked out -in Python, in a board's single precision: `cut_hz` lands 0.035 % high -(39.44766 Hz against 39.43366) and `damping_hz` one float32 step off. - -**What the default surrenders.** It is a clean line, so it does not darken -on its own: the DD-2's 7 kHz band limit and its compander are not in the -default sound (patch 5 and the Tone and Cut knobs put the corners back; the -compander is not modelled at all). Freeze (the pedal's HOLD) is not here: -the node's loop tops out at 0.99, so a held phrase would fade 0.087 dB a -lap, and a HOLD that fades is worse than none. **Glide 0 is an instant -knob, and its price is a click**: a 200 -> 150 ms jump steps 7712 LSB into -a tone whose own steepest step is 1565. At the default Glide (4 s for the -full range) a falling Time bends the repeats 311 cents up and a rising one -380 cents down while it moves, and a 200 -> 150 ms move takes 254 ms. At the -knob's fastest glide (grid 1, 814.6 ms) a falling move reads +1171 cents and -a rising one nearly stalls the read head. - -While Time moves, the line is read between samples, and the two-tap read -costs the top of the band sqrt(1 - 2 frac (1 - frac)(1 - cos 2 pi f / fs)) -per pass: 5.1 dB at 15 kHz at a half frame, 48 kHz. Every static Time is -handed to the node as the nearest whole frame at the running rate, -floor(ms fs / 1000 + 0.5); the knob's milliseconds and `get_macro(0)` stay -as you set them. At 48 kHz the node lands every one of the 128 knob -positions exactly on that frame, where the read is lossless, so the -repeats of a Time you have stopped turning do not darken. - -At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds -back into frames in float32, and for some Times no float32 value lands on -the whole frame, so the read sits one float32 step off it. Among the 128 -knob positions that is 18 at 44.1 kHz (MIDI 8, 9, 13, 19, 29, 34, 52, 57, -61, 74, 75, 78, 80, 94, 101, 104, 118, 125) and 18 at 22.05 kHz (MIDI 8, -9, 13, 16, 34, 39, 51, 52, 57, 74, 75, 78, 80, 94, 101, 104, 118, 123), at -most 1/256 of a frame off at 44.1 kHz and 1/1024 at 22.05 kHz. A -constructor `time_ms` or a Time Sync takes from a host's tempo reaches -every whole frame from 12.5 to 800 ms, and 5 600 of those 34 730 frames -land off at 44.1 kHz and 2 809 of 17 365 at 22.05 kHz, up to 1/256 and -1/512 of a frame off. None do at 48 kHz, nor the default 350 ms at any -rate. One shipped patch does: patch 4 (Long Ambient, MIDI 125) at -44.1 kHz. At an off-frame Time each pass puts part of the repeat on the -frame beside it, and the repeats darken slowly: a 20 000 click's first -repeat reads 19 922 and 78 at patch 4's Time, 44.1 kHz (19 980 and 20 at -MIDI 104, 22.05 kHz, the worst there). The class cannot hand the node a -number that lands there; a node change is asked for. - -Above Mix 1 the dry falls as 2 - Mix, by `audiodelays.Echo`'s convention. - -**Repeat Tone at a low rate.** The knob's corners clamp below Nyquist at -the running rate, so where the rate is too low for the top of the span the -top of the knob goes flat. At 22.05 kHz positions 111-126 (labelled -10 970-15 627 Hz) all sit on the 10 804.5 Hz clamp and do the same thing, -and position 127 takes the filter out. At 44.1 and 48 kHz every position -moves. - -**Repeat Tone and Repeat Cut back in, after they have been out.** Take -either filter out while the repeats play and bring it back in after they -have died away, and nothing plays: 0 LSB at 48, 44.1 and 22.05 kHz, -stereo and mono, after a 300 Hz tone at 30 000 LSB, Mix 2 (Tone at -2 kHz, Cut at 400 Hz). Both out stops hand the node exactly 0, which is -the filter out whatever came before. Up to audiodsp v0.6.2 the node froze -an out filter's state and played it back here (26 443 LSB for Tone, which -this class cured with a tracking out stop, and 20 858 for Cut, which it -could only disclose); since v0.6.3rc1 the node keeps the low-pass's state -on the signal and the high-pass's at zero while out (audiodsp#158, #159), -and the cure came out. - -A constructor `glide_ms` faster than the knob's fastest walk (under -814.6 ms, down to the 0.99 pin) stays on the audio path, and the knob -reads back at grid 1, the fastest walk it has, never at grid 0, the jump: -handing `get_macro(3)` back to `set_macro(3, ...)`, even rounded to a -7-bit MIDI value, keeps the glide (at grid 1's slew, 0.967). One slower -than the knob's 8 s plays 8 s, and the knob reads 8 s; a Glide of 0, -a negative or NaN is the jump. A constructor Time of 0 is 12.5 ms, and -a Repeat Tone or Repeat Cut of 0 is that filter out of circuit, as it is -on the node. A `max_time_ms` above 800 or NaN is 800 ms. - -**Where the pitch claim stops.** The node walks the read head in single -precision, so the rate it plays is the Glide's rate rounded to the float -step of the delay, and that step doubles each time the delay passes a -power of two: a move across one plays two rates, one on each side. Near -a stall that rounding is worth several cents. The claim is stated for a -rising 150 -> 200 ms move, read on each side of the power of two it -crosses: at the Glide knob's fastest positions, strictly between grid 1 -and grid 4 (814.6-860.2 ms), the part of that move past the power of -two can read up to 25.3 cents off the glide law at 48 and 44.1 kHz and -12.7 at 22.05 kHz (just above grid 3 it is 12.5 cents over the last -26 ms of the walk), so that part of the knob is not claimed on a rising -move. Grid 1 itself and everything from grid 4 up are, and the margin -at the edge is thin: grid 4 reads 9.7 cents and the worst position above -it, grid 4.05, 9.9 cents, against a 10-cent bar. Nor is a constructor -Glide under 860.2 ms claimed on a rising move, except 800 ms (slew -63/64, which single precision holds exactly): at the 0.99 pin a rising -Time can read 41 cents off. A longer rising move reaches delays where -the float step is coarser, and at a fast Glide it is not claimed: -350 -> 500 ms at grid 3 reads +15.8 cents at 48 and 44.1 kHz, 400 -> -800 ms at grid 3 reads -34.4 cents past 32 768 frames at 44.1 kHz, and -at grid 5 +10.7 at 48 kHz. At the default Glide every move the dossier -names is inside 10 cents on each side of every power of two it crosses -(worst 3.4 cents, the full-range move's last stretch). -The pitch and no-step claims are measured on inputs from -8.7 to --0.2 dBFS. Quieter, int16 rounding decides the reading: a near-stall -rising glide is a few LSB of signal, and even a falling move at --48.7 dBFS can step 0.4 % past its bar. - -**Input ceiling.** The dry path sits at unity and the repeats add to it, so -a hot input can put the output on the int16 rail; there is no input gain -to turn down. Measured on the kit's `noise_det` at 48 kHz over 20 s, the -defaults are clean up to -3.1 dBFS peak and the shipped patches up to --4 dBFS (patch 3, Mix 0.5, rails first). At -3.0 dBFS the defaults put a -few samples on the rail over 4 s and more. On any material, with Repeat -Cut out and Mix below 1, an input peaking at or below one LSB under -(1 - Mix) of full scale, floor(32767 (1 - Mix)) - 1, cannot reach the -rail at any Time or Feedback, because the line holds int16 and so the -repeats never exceed Mix x full scale: -3.1 dBFS at the default Mix 0.3, --6.1 dBFS at patch 3. At exactly (1 - Mix) of full scale the sum can round -onto 32767, the rail value, though nothing is clipped. High Feedback does -not keep building past that: at Feedback 0.99 the line saturates, and the -defaults' noise_det ceiling is still -3.1 dBFS over 20 s (at -3.0 it -rails 2284 samples). Repeat Cut's high-pass can overshoot a peak, so with -it in circuit leave more room: patch 5 (Cut at 40 Hz) is clean on -noise_det at -4 dBFS, but a 40 Hz square wave there puts 54752 samples on -the rail over 20 s, and it is clean from -6 dBFS down. - -**Tail.** `tail_samples` is an upper bound on how long the repeats take to -reach exact zero after your input stops, and it is long: the loop has to -round its way down from full scale, 11 laps at the default Feedback and -685 at 0.99 (nine minutes at Time 800 ms). Since audiodsp v0.6.2 the node -steps a repeat toward zero wherever rounding would hand it back -unchanged, so with both filters out the bound holds at every Feedback. -With Repeat Tone in circuit each lap is a little longer. Wherever -0.5 / (1 - Feedback) is within a hair of a whole number k (0.5, 0.75, -0.83, 0.875, 0.9 and on up to the knob's top, 0.99), the loop low-pass -can come to rest a hair above k LSB and hand it back; up to audiodsp -v0.6.2 it did so for ever, and this class moved the Feedback it handed -the node clear of those windows. Since v0.6.3rc1 the node sets a stalled -low-pass onto its input (audiodsp#157), the Feedback you set is the one -the node plays, and `tail_samples` counts one more lap there: every window -centre, on a 2 LSB DC and on full scale, reaches exact zero inside it. -With Repeat Cut in circuit `tail_samples` is `None`: no bound is derived -there. - -`capabilities = ("tempo_sync",)`: with Sync on, the class reads -`self._transport()` on every macro move and program change (not per block). -With no host transport, or a host whose tempo is not a finite positive -number (0, negative, NaN, infinite or missing), Time stays where the knob +"""`DigitalDelay` - a clean digital delay with the Boss DD-2's control law. + +Your dry signal passes untouched, and one clean repeat follows it, fed back +for more. + +**Controls.** Time is the delay, from 12.5 to 800 ms, and Feedback is how +much of each repeat goes round again, up to 0.99. Mix is the echo level: the +dry stays at unity up to Mix 1, Mix 2 is the repeats alone, and at Mix 0 the +output is the input. Turn Time while it plays and the repeats bend in pitch +and settle, instead of clicking. Glide is how long a full-range Time move +takes, from 800 ms to 8 s. Glide 0 is an instant knob, and its price is a +click. Repeat Tone is a low-pass and Repeat Cut a high-pass inside the loop, +so each repeat is a little darker or thinner than the last. Repeat Tone's +top stop and Repeat Cut's bottom stop take them out. With Sync on, Time is +Division of the host's beat; with no host tempo, Time stays where the knob is. + +**The pedal.** Patch 5 puts the DD-2's 7 kHz and 40 Hz corners in the +loop. The DD-2's compander and its HOLD are not here. + +**Where it stops.** At 48 kHz the repeats of a Time you have stopped +turning do not darken. At 44.1 and 22.05 kHz a few Times land a hair off +the whole frame, and at those each repeat spills a little onto the frame +beside it. A rising Time move at the fastest Glides can read more than 10 +cents off the ideal bend, because the node walks its read head in single +precision. At 22.05 kHz the top positions of Repeat Tone sit on one clamp +below Nyquist and sound the same. The dry sits at unity and the repeats add +to it, so a hot input can reach the int16 rail. With Repeat Cut out and Mix +below 1, an input that peaks at or below floor(32767 (1 - Mix)) - 1 cannot +reach the rail, at any Time or Feedback. + +**Limits shared by the family.** A control that jumps makes the output step: +move it in small steps from the host if you need it smooth. The tail rings +only while the source keeps feeding: feed silence to let it ring out. A tail +cut short by a source that stopped carries on when the source comes back. + +**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 +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 +line and returns to patch 0. The class reads the host's transport only +while Sync is on. The class needs audiodsp's `audioecho`, and on a board +without it construction raises `ImportError`. """ VENDOR = "PyDevices" @@ -259,8 +105,7 @@ #: single-precision float's step (2^-24), so a board's arithmetic lands on #: the same side, and far under the window's own width (2-4 x 10^-5) and #: the knob's 7-bit step (0.0078). This class stopped stepping at audiodsp -#: v0.6.3rc1, whose node lands a stalled damping state (audiodsp#157); the -#: helper stays for the classes still built at v0.6.2. +#: v0.6.3rc1, whose node lands a stalled damping state (audiodsp#157). STALL_CLEARANCE = 2.0 ** -20 #: `stall_window` widens each window by this much, relatively, either side, @@ -400,6 +245,11 @@ def laps_to_zero(feedback, excess=0.0): return laps +# `stall_window` and `clear_of_stalls` are no longer called by this class +# (the node lands a stalled damping state since audiodsp v0.6.3rc1, #157). +# Only planted faults use them: the faults in this class's test file and in +# other classes' test files (SlapbackDelay, PingPongDelay, CombFilter, +# TapeDelay, MultiTapDelay) that hand the node the old stepped Feedback. def stall_window(feedback, excess): """The Repeat Tone stall window `feedback` sits in, as (low, high), or `None` outside every window. @@ -438,8 +288,7 @@ def clear_of_stalls(feedback, excess): so `get_macro(1)` still names the setting that plays. Not called by this class since audiodsp v0.6.3rc1 (the node lands a - stalled damping state, audiodsp#157); kept for the classes that import - it and are still built at v0.6.2.""" + stalled damping state, audiodsp#157); only planted faults use it.""" window = stall_window(feedback, excess) if window is None: return feedback @@ -454,13 +303,7 @@ def clear_of_stalls(feedback, excess): class DigitalDelay(_component.Component): """A clean digital delay with the DD-2's control law: the dry path is a wire, and turning Time pitch-bends the repeats instead of clicking. - audiodsp tier; zero latency. - - **What the default surrenders:** no band limit and no compander in the - default sound (patch 5 and the Tone and Cut knobs are the corners), no - HOLD, and Glide 0's instant knob clicks. At the default Glide a falling - Time bends the repeats +311 cents while it moves, a rising one -380. - """ + audiodsp tier; zero latency. The module docstring has the rest.""" NAME = 'DigitalDelay' DISPLAY_NAME = 'Digital Delay' diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..f707cda 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -73,8 +73,28 @@ #: Where a class cannot satisfy a property by design, it says so HERE, one #: row per (class NAME, event name prefix, property): reason. A matching #: cell prints `decl` instead of RED and is counted as a declared exception. -#: Nothing else skips a cell. Empty on purpose: no class has declared one. +#: Nothing else skips a cell. DECLARED = { + ("DigitalDelay", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("DigitalDelay", 'E4-m6=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Repeat Tone)', + ("DigitalDelay", 'E4-m7=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Repeat Cut)', + ("DigitalDelay", 'E5-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("DigitalDelay", 'E6-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("DigitalDelay", 'E9-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("DigitalDelay", 'E11-mix+patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix and a patch change)', + ("DigitalDelay", 'E11-2patch', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("DigitalDelay", 'E11-3moves', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (the patch change; the Time moves glide)', + ("DigitalDelay", 'E8-dry', 'P3'): + 'family limit, disclosed (audiodsp#180): a tail cut short by a source that stopped carries on when the source comes back', } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index dd1aec7..50133bc 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -63,10 +63,22 @@ class of the node's walk, and the edge test goes red at the old edge. A inside a finite bound (planted: the retired stepping, and the v0.6.2 reckoning that says `None`). Two surface tests pin the Times the node lands off the whole frame at 44.1 and 22.05 kHz, as `PingPongDelay`'s do. + +The trial of the second process (2026-09-29, at audiodsp v0.6.3rc3): the +module docstring is cut to the sentences `CLAIMS` ties to a test, and +`Claims` holds it there (every sentence present, every named test real, no +figure outside a claim, and the check shown able to fail). `TrialClaims` +asserts the claims no earlier test did: Glide 0's click beside the default +Glide's bend, a Mix jump that steps beside the same move in small steps, +the tail that waits for its source, Mix 2 as the repeats alone, `None` +with Repeat Cut in, patch 5's corners and the `ImportError` without +`audioecho`. The lifecycle matrix's red cells for the class are declared +as the two family limits (audiocomponents#117, audiodsp#180). """ import math import os +import re import sys import unittest from array import array @@ -79,6 +91,7 @@ class of the node's walk, and the edge test goes red at the old edge. A import audiocore # noqa: E402 import kit_faults # noqa: E402 import kit_probes as probes # noqa: E402 +import lifecycle # noqa: E402 from audioeffects import _component # noqa: E402 from audioeffects import rebuilt # noqa: E402 from audioeffects.chorus import nominal_damping_hz # noqa: E402 @@ -2634,5 +2647,314 @@ def test_every_row_is_red_on_a_wire(self): self.assertTrue(result["control"]["passed"], name) +# -------------------------------------------------------------------------- +# The trial of the second process (2026-09-29): the docstring's claims that +# no earlier test asserted, each beside the reading that would refute it. + + +def max_step_after(y, frame, frames=BLOCK): + """The largest first difference of `y` within `frames` of `frame`.""" + seg = y[frame - 1:frame + frames] + return float(np.max(np.abs(np.diff(seg)))) + + +class TrialClaims(unittest.TestCase): + LEVEL = 12000 + HZ = 440.0 + + #: The Time move's tone: 50 ms is 23.5 of its cycles, so the jump lands + #: the read head half a cycle away (at 440 Hz, 22 whole cycles, a jump + #: would be seamless and prove nothing). + MOVE_HZ = 470.0 + + def _bar(self, hz=None): + """1.5 x the tone's own steepest step, the lifecycle matrix's P5.""" + return 1.5 * 2.0 * math.pi * (hz or self.HZ) / RATE * self.LEVEL + + def _time_move(self, glide_ms): + """A 470 Hz tone at Mix 2, Feedback 0, Time 200 -> 150 ms on the + block at frame 20 480; the left channel.""" + frames = 20480 + RATE // 2 + values = sine_values(self.MOVE_HZ, frames, RATE, self.LEVEL) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=200.0, feedback=0.0, mix=2.0, + glide_ms=glide_ms) + + def move(frame): + if frame == 20480: + effect.set_macro(TIME_I, midi_of_ms(150.0)) + y = left(pull(effect, frames, on_block=move), 2) + effect.deinit() + return y + + def test_glide_0_jumps_with_a_click(self): + # Glide 0: the read head jumps, and the output steps far past the + # tone's own slope. At the default Glide the same move does not. + bar = self._bar(self.MOVE_HZ) + self.assertGreater(max_step_after(self._time_move(0.0), 20480), + 2.0 * bar) + self.assertLess(max_step_after(self._time_move(4000.0), 20480, + RATE // 4), bar) + + def _mix_moves(self, steps): + """The tone at Time 12.5 ms, Feedback 0, Mix from MIDI 0 to 127 in + `steps` equal moves, one per block from frame 20 480.""" + frames = 20480 + (steps + 8) * BLOCK + values = sine_values(self.HZ, frames, RATE, self.LEVEL) + effect = DigitalDelay(array_src(values), sample_rate=RATE, + time_ms=12.5, feedback=0.0, mix=0.0) + + def move(frame): + k = (frame - 20480) // BLOCK + 1 + if frame >= 20480 and k <= steps: + effect.set_macro(MIX_I, 127.0 * k / steps) + y = left(pull(effect, frames, on_block=move), 2) + effect.deinit() + return float(np.max(np.abs(np.diff(y[20479:])))) + + def test_a_jump_steps_and_small_steps_do_not(self): + # The family limit (audiocomponents#117): Mix 0 -> 2 in one move + # steps the output; the same move in 127 steps from the host, one + # a block, stays under the bar. + self.assertGreater(self._mix_moves(1), 4.0 * self._bar()) + self.assertLess(self._mix_moves(127), self._bar()) + + def _tail_across_a_stop(self, stop): + """A 50 ms burst into Time 100 ms, Feedback 0.5, Mix 2, then + silence; after 24 pulls the source hands empty buffers for `stop` + pulls, then silence again. Returns (the bytes handed while it was + stopped, the 40 blocks pulled after it came back).""" + burst = array("h") + for v in sine_values(self.HZ, 2400, RATE, self.LEVEL): + burst.extend((v, v)) + feed = lifecycle.Feed(burst, RATE, 2, "256", False) + effect = DigitalDelay(feed.port, sample_rate=RATE, time_ms=100.0, + feedback=0.5, mix=2.0) + for _ in range(24): + audiocore.get_buffer(effect.output) + feed.point(feed.empty) + stopped = bytearray() + for _ in range(stop): + stopped.extend(bytes(audiocore.get_buffer(effect.output)[1])) + feed.point(feed.sil) + after = bytearray() + while len(after) < 40 * BLOCK * 4: + after.extend(bytes(audiocore.get_buffer(effect.output)[1])) + effect.deinit() + return bytes(stopped), bytes(after[:40 * BLOCK * 4]) + + def test_the_tail_waits_for_the_source(self): + # The family limit (audiodsp#180): a source that hands empty + # buffers stops the tail; when it feeds again the tail carries on + # where it was, as if the stop had not happened. + stopped, after = self._tail_across_a_stop(30) + self.assertEqual(stopped.strip(b"\x00"), b"") + self.assertGreater(max(abs(v) for v in array("h", after)), 1000) + self.assertEqual(after, self._tail_across_a_stop(0)[1]) + + def test_mix_2_is_the_repeats_alone(self): + # A click at Mix 2: nothing until the repeat, 100 ms later. + values = [0] * 8192 + values[10] = 30000 + effect = DigitalDelay(array_src(values), sample_rate=RATE, mix=2.0, + time_ms=100.0, feedback=0.0) + out = left(pull(effect, 8192), 2) + 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): + # 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. + effect = DigitalDelay(silence_src(512), sample_rate=RATE, patch=5) + self.assertGreater(effect.get_macro(CUT_I), 0.0) + self.assertIsNone(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) + + def test_patch_5_is_the_pedal_corners(self): + # Patch 5's Repeat Tone and Repeat Cut positions are the 7 kHz and + # 40 Hz corners T5 measures, within one step of the 7-bit knob. + grid = DigitalDelay.PATCHES[5][1] + ranges = DigitalDelay._MACRO_RANGES + tone = _component.macro_value(ranges[TONE_I], grid[TONE_I] / 127.0) + cut = _component.macro_value(ranges[CUT_I], grid[CUT_I] / 127.0) + step = 20.0 ** (1.0 / 127.0) - 1.0 + self.assertLessEqual(abs(tone / 7000.0 - 1.0), step, tone) + self.assertLessEqual(abs(cut / 40.0 - 1.0), step, cut) + + def test_construction_needs_audioecho(self): + saved = sys.modules.get("audioecho", False) + sys.modules["audioecho"] = None + try: + with self.assertRaises(ImportError): + DigitalDelay(silence_src(512), sample_rate=RATE) + finally: + if saved is False: + del sys.modules["audioecho"] + else: + sys.modules["audioecho"] = saved + DigitalDelay(silence_src(512), sample_rate=RATE).deinit() + + +# -------------------------------------------------------------------------- +# The docstring's claims + +#: Every claim the module docstring makes, word for word, and the tests that +#: assert it ("Class.test_name", in this file). +CLAIMS = ( + ("Time is the delay, from 12.5 to 800 ms, and Feedback is how much of " + "each repeat goes round again, up to 0.99.", + ("T4TimeLaw.test_the_map_at_the_stops_and_between", + "Tier1Fast.test_the_tail_is_bounded_at_every_feedback")), + ("Mix is the echo level: the dry stays at unity up to Mix 1, Mix 2 is " + "the repeats alone, and at Mix 0 the output is the input.", + ("T1DryIsAWire.test_the_hardest_cells", + "T1DryIsAWire.test_defaults_stereo_and_mono", + "TrialClaims.test_mix_2_is_the_repeats_alone", + "Tier1Fast.test_mix_zero_is_a_wire_on_the_full_scale_ramp")), + ("Turn Time while it plays and the repeats bend in pitch and settle, " + "instead of clicking.", + ("T2TimeResamples.test_the_row_cell_falls_on_the_law", + "T2TimeResamples.test_a_rising_move_at_the_default_glide", + "T2TimeResamples.test_the_block_staircase_is_red")), + ("Glide is how long a full-range Time move takes, from 800 ms to 8 s.", + ("TheSurface.test_glide_law", + "T2TimeResamples.test_a_wrong_glide_law_is_red")), + ("Glide 0 is an instant knob, and its price is a click.", + ("TrialClaims.test_glide_0_jumps_with_a_click",)), + ("Repeat Tone is a low-pass and Repeat Cut a high-pass inside the loop, " + "so each repeat is a little darker or thinner than the last.", + ("T5BandLimit.test_the_tone_compounds_in_the_loop", + "T5BandLimit.test_the_cut_compounds_in_the_loop")), + ("Repeat Tone's top stop and Repeat Cut's bottom stop take them out.", + ("TheSurface.test_filter_stops_are_exactly_zero", + "T5BandLimit.test_an_open_top_stop_is_red")), + ("With Sync on, Time is Division of the host's beat; with no host " + "tempo, Time stays where the knob is.", + ("T4TimeLaw.test_sync_quantises_time_into_the_map", + "T4TimeLaw.test_no_host_leaves_time_on_the_knob", + "ZeroBpmHost.test_no_tempo_leaves_time_on_the_knob", + "ZeroBpmHost.test_a_tempo_that_is_not_finite_leaves_time_on_the_knob")), + ("Patch 5 puts the DD-2's 7 kHz and 40 Hz corners in the loop.", + ("TrialClaims.test_patch_5_is_the_pedal_corners", + "T5BandLimit.test_the_corners_at_48k")), + ("The DD-2's compander and its HOLD are not here.", + ("TheSurface.test_macros_patches_tier_latency",)), + ("At 48 kHz the repeats of a Time you have stopped turning do not " + "darken.", + ("T3NoDarkening.test_the_constructor_time_at_48k_and_22k", + "TheSurface.test_where_the_node_lands_the_handed_frame")), + ("At 44.1 and 22.05 kHz a few Times land a hair off the whole frame, " + "and at those each repeat spills a little onto the frame beside it.", + ("TheSurface.test_where_the_node_lands_the_handed_frame", + "TheSurface.test_an_off_frame_time_leaks_into_the_next_frame")), + ("A rising Time move at the fastest Glides can read more than 10 cents " + "off the ideal bend, because the node walks its read head in single " + "precision.", + ("T2TimeResamples.test_the_pitch_is_read_per_binade", + "T2TimeResamples.test_inside_the_band_is_red_and_outside_it_holds", + "T2TimeResamples.test_the_unclaimed_band_is_where_the_binade_model_says")), + ("At 22.05 kHz the top positions of Repeat Tone sit on one clamp below " + "Nyquist and sound the same.", + ("RepeatToneKnee.test_the_knee",)), + ("The dry sits at unity and the repeats add to it, so a hot input can " + "reach the int16 rail.", + ("InputCeiling.test_the_stated_ceiling_is_clean_and_just_over_is_not",)), + ("With Repeat Cut out and Mix below 1, an input that peaks at or below " + "floor(32767 (1 - Mix)) - 1 cannot reach the rail, at any Time or " + "Feedback.", + ("InputCeiling.test_the_any_material_bound",)), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("TrialClaims.test_a_jump_steps_and_small_steps_do_not",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("Latency is zero samples: nothing looks ahead.", + ("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.", + ("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",)), + ("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",)), + ("A constructor Time of 0 is the bottom of its span, and a Repeat Tone " + "or Repeat Cut of 0 is that filter out.", + ("ConstructorZeros.test_zero_builds_and_means_the_stop", + "ConstructorZeros.test_zero_filters_render_as_filters_out")), + ("`reset()` empties the line and returns to patch 0.", + ("Tier1Fast.test_reset_empties_the_line",)), + ("The class reads the host's transport only while Sync is on.", + ("Tier1Fast.test_the_transport_is_read_only_with_sync_on",)), + ("The class needs audiodsp's `audioecho`, and on a board without it " + "construction raises `ImportError`.", + ("TrialClaims.test_construction_needs_audioecho",)), +) + +#: Model names that carry digits and are not figures. +NAMES = ("DD-2",) + +FAMILY_HEADING = "**Limits shared by the family.**" + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims=CLAIMS): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a digit outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(globals().get(owner), test): + problems.append("no test %s" % name) + for name in NAMES: + rest = rest.replace(name, " ") + for match in re.finditer(r"\S*\d\S*", rest): + problems.append("figure outside a claim: %s" % match.group()) + return problems + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claim_problems(dd.__doc__), []) + self.assertEqual(claim_problems(DigitalDelay.__doc__, ()), []) + self.assertIn(FAMILY_HEADING, _flat(dd.__doc__)) + # The checker can fail: a figure outside a claim, a claim the + # docstring does not carry, a test that does not exist. + self.assertTrue(claim_problems(dd.__doc__ + " It reads 12 ms.")) + self.assertTrue(claim_problems(_flat(dd.__doc__).replace( + "nothing looks ahead", "nothing looks back"))) + self.assertTrue(claim_problems(dd.__doc__, CLAIMS + ( + ("Latency is zero samples: nothing looks ahead.", + ("Tier1Fast.test_nothing_here",)),))) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..3135d22 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -43,12 +43,6 @@ class this branch carries, on CPython and on the two native interpreters. #: matrix has not been run (MultiTapDelay: it takes about 40 minutes on #: CPython). KNOWN_RED = { - ('DigitalDelay', 'E', 'P5'): ( - 36, 'cfa18482', 94, '4627b739', - 'Mix, Repeat Tone, Repeat Cut and patch moves step the output within one block'), - ('DigitalDelay', 'E8-dry', 'P3'): ( - 6, 'e155e27d', 6, 'e155e27d', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), ('SlapbackDelay', 'E', 'P5'): ( 124, '01ddb3de', 198, '4cda9a29', 'Level, Tone and patch moves step the output within one block (no ramp)'), From d0ec713d04039cdc88df09b2abdfed9d071c122d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:48:14 -0500 Subject: [PATCH 77/88] MultiTapDelay, trial of the second process at audiodsp v0.6.3rc3: the docstring as the player's text with a CLAIMS table, the class's lifecycle rows declared No audio-path change. A return from Mix 0 and a reset leave the lines empty where the control's hold older laps; those die to 1 LSB inside tail_samples and a 1-LSB rounding residue then outlasts it while material plays (gone by 1.4x at worst), so E1/E5/E9/E4-m4/E4-m6/E6/E11/E8-dry P4 are declared. tail_samples holds under silence (worst 0.857x). The CPython-only P3 cells are the node's twin (audiodsp#177). P5 is the family limit (audiocomponents#117). The second family sentence is not carried: this class's tail rings on while the source hands back nothing, which the docstring says and a test asserts. --- CHANGELOG.md | 6 +- lib/audioeffects/rebuilt/multitapdelay.py | 274 ++++---------------- tests/support/lifecycle.py | 55 ++++ tests/test_cpython_effects_multitapdelay.py | 204 +++++++++++++++ tests/test_lifecycle_matrix.py | 25 +- 5 files changed, 319 insertions(+), 245 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d621684..e3bfe19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,7 +127,11 @@ there, and are recorded in its changelog. docstring states the input ceiling, the click and channel crossing of a Time or Heads move while audio plays, and that on CircuitPython alone the stereo dry's right lane is one LSB hot within 32 LSB of the rails. - Board cost is unmeasured. + Board cost is unmeasured. At audiodsp v0.6.3rc3 the class plays what it + played at rc1; its docstring is now the player's text, each claim tied + to a test, with a jumping control disclosed as a family limit + (audiocomponents#117), and a return from Mix 0 said plainly to start + both lines empty. ### Changed diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index bbacd18..4cb4419 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -1,217 +1,10 @@ """`MultiTapDelay` - one recording read by several heads on a fixed grid. -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/MultiTapDelay.md`, whose trait -table was frozen at Station A before this file existed (anchor commit -02e7e0c1467ff43b13ff239325eed54dd84b2dcb, the Station A revision's freeze, -2026-09-28). The old class in `delay.py` is consulted only for the six -defects that dossier's section 7 names; it stays the class the library -serves until the board runner adopts this one. - -**What it sounds like.** Your dry signal passes untouched, and a pattern of -echoes follows it: the heads of a multi-head tape echo, each one the same -recording read further along. The heads sit on a grid of whole multiples of -one base time, so head 2 is exactly twice head 1 and head 3 exactly three -times. Time (20-400 ms) is that base, the gap to head 1. Heads (3-8) is how -many heads the grid has, and the pattern repeats once per trip past the -farthest of them. Pattern picks which heads sound, from the twelve -positions of the Roland RE-202's mode selector. Feedback (0-0.95) sends -the pattern round again, each lap quieter; Repeat Tone darkens each lap a -little more than the one before, once per lap, never once per head. Tilt -leans the pattern's levels towards the near heads or the far ones. Mix -(0-2) is the echo level: dry at unity up to 1, the echoes alone at 2, and -Mix 0 is a wire. Sync locks Time to Division of the host's beat. - -**The standout:** the Roland RE-201 Space Echo's multi-head modes, with the -Binson Echorec as a second reference. You get the RE-201's three heads and -its one-knob grid as defaults; patch 3 is the Echorec's 74 ms head spacing -on four heads. - -**The grid, exactly.** Time is landed on the nearest whole frame, -n1 = floor(t1 fs / 1000 + 0.5), and head k sounds at exactly k n1 frames. -The lap is P = K n1 frames for Heads K, clamped so it fits `max_lap_ms`: -at the default 1600 ms the base stops at 400 / 400 / 320 / 266.67 / -228.56 / 200 ms for 3 ... 8 heads. That clamp is on the audio path only and -`get_macro(0)` keeps the knob's Time, because it moves with Heads and a -Heads move back must give you your Time back. The tap node reads its input -one 256-frame block after the dry has played it (so that nothing you set -before the first pull, or right after `reset()`, lands on audio it has -already written; see `_route`), so it is handed (P + 0.5) frames' worth of -`delay_ms` and head k at (k n1 - 256 + 0.5) / P, and it truncates both: -every head sounds at exactly k n1 against the dry, and a single-precision -board lands on the same frames as a desktop. The lap node interpolates instead, so it is -handed the least single-precision `delay_ms` whose frames, computed the -node's way (`value * rate / 1000.0f`), are at or over P: stepping one -float32 unit at a time from float32(P 1000 / fs), every intermediate -rounded to float32, which is exact on the desktop and the identity on a -board. Where no float32 value lands on P exactly (some laps at 44.1 and -22.05 kHz) the read sits a few thousandths of a frame over P and puts that -fraction of each lap one frame late, inside the lap's own response, never -early. - -**Pattern.** Modes 1-11 are S1's head sets, keeping only the heads that -exist on the grid: 1 = {1}, 2 = {2}, 3 = {3}, 4 = {1,2}, 5 = {2,3}, -6 = {1,3}, 7 = {1,2,3}, 8 = {1,4}, 9 = {3,4}, 10 = {1,3,4}, 11 = {1,2,4} -(at three heads, modes 8-11 read {1}, {3}, {1,3}, {1,2}). **Mode 12 departs -from S1**: the RE-202's mode 12 puts its heads at "optimized" positions -that Roland does not publish, and this class puts mode 12 on the plain -grid, every head 1 ... K, instead (patch 4 is eight of them). Heads above 4 -sound only in mode 12; in modes 1-11 they lengthen the lap without -sounding. - -**Where the feedback is tapped is a design decision, not a source.** The -lap is the farthest head on the grid, sounded or not, as if the record -head were fed from head K. So in a mode that does not sound head K the -repeats keep the lap's rhythm, not the heads': mode 1 at three heads -sounds t1, 4 t1, 7 t1 ..., where a machine fed back from the sounding head -would give t1, 2 t1, 3 t1. - -**Repeat Tone's out stop is a lighter graph.** With Repeat Tone in circuit -the laps go round an `audioecho.FeedbackDelay` whose in-loop low-pass is the -darkening (its corner pre-warped so it is the -3 dB point of one pass); at -the out stop the class unplugs that node and lets the tap node's own -`decay` make the laps, and nothing darkens. Crossing the out stop while -audio plays changes the graph between blocks by re-pointing a port, which -pulls nothing: the wet does not move against the dry, however many times -you cross between two pulls (a host flipping patches 1 and 0 included), -but the laps in flight are dropped or doubled once (up to half the click's -level at Feedback 0.5). - -**Mix 0 hands your source straight through**, the class's output port -pointed at its input adapter (through one more width-1 MidSide), an -`audioroute.MidSide` at width 1 that passes the source byte for byte in -256-frame blocks, so it is a wire on every interpreter. Going through the adapter, not round it, keeps the -source's timeline whole: a switch to Mix 0 and back mid-stream neither -skips nor repeats a frame, whatever size of buffer your source hands out, -a bare `audiocore.RawSample` included. It departs from the dossier's -section 6 there, which kept both lines recording at Mix 0 through the -Mixer: CircuitPython's stock `audiomixer` scales a voice at level 1.0 by -32768 / 32767, so on CircuitPython that route put every sample at -|value| >= 32736 one LSB out (audiodsp's own Mixer, the one MicroPython -and the boards run, passes unity through). The price is that the lines -are not fed while Mix is 0, and turning Mix up from 0 starts the echoes -from empty lines. The same -stock Mixer is on the dry path above Mix 0. At one channel the class hands -the dry's unity as 1 - 2^-15, which every Mixer here passes exactly; at two -the stock Mixer's pan law leaves no level that is exact in both lanes, so -on CircuitPython alone the right lane's dry reads one LSB hot on the -source values within 32 LSB of the rails. - -**Moving Time or Heads clicks.** Both nodes jump to the new grid, and in -stereo the tap node's planar line also crosses channels for up to one lap -(the right channel briefly replays what the left one wrote). Nothing about -the grid is claimed while Time or Heads moves with audio playing. A move -made before the first pull, or after `reset()` and before the next pull, -is not a move of that kind: the tap node's line holds only zeros then, so -the first lap lands on the grid in both lanes. That holds however many -times you call `reset()`, or take Mix to 0 and back, before the first -pull or between two pulls. - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). -The laps are `audioecho.FeedbackDelay` and the dry fan-out is -`audioroute.Splitter`; `audiodelays.MultiTapDelay` and `audiomixer.Mixer` -are stock. On a stock CircuitPython board this module imports cleanly and -construction raises `ImportError`. - -**Sample rate: 12 825 Hz and up.** The tap node reads one block behind -the dry, so each head's offset is handed 256 frames short, and the 20 ms -head must land on more than 256 frames for that to place it. Below -12 825 Hz it would not, and the constructor raises `ValueError` rather -than build a class whose Time knob cannot reach its low end. - -**Latency: zero samples, at every setting and patch, at every rate the -class accepts.** The dry is a Splitter tap into a Mixer voice, a wire, -and nothing looks ahead; the heads are the effect, not latency, and no -option adds any. The first pull after construction plays your source's -first frame. Any number of `reset()` calls and returns from Mix 0, before -the first pull or between two pulls, leave the dry at +0 against your -source and every head at +k n1 against the dry, for audio that arrives -after them: `reset()` empties the lines, so audio from before it has no -heads, and a return from Mix 0 starts the heads from empty lines. None of -it takes a frame from your source or plays one twice, whatever size of -buffer your source hands out: the input adapter keeps the unread part of -a buffer across a reset and a Mix 0 switch, and the next pull plays it. - -That rests on `audiocore.get_buffer`, which every desktop build and the -MicroPython boards carry and a patched CircuitPython board build may not. -Without it the class cannot pull its own graph, so a class built above -Mix 0 holds your source's first block in the dry until the first pull, and -three things differ. If you take it to Mix 0 before that pull, the Mix-0 -run plays your source from its second block, 256 frames early, without the -first; when Mix comes back up, or you call `reset()`, that first block -plays then, with its heads after it, as late as the run was long, and -everything after it is on time (after a 24-block run at 48 kHz, a click in -the first block sounds 6 144 frames late). A class built at Mix 0 and reset -before it was ever turned up opens with one silent 256-frame block and -plays your source 256 frames late from then on. And a reset or a return -from Mix 0 lets the one block the tap node had not read into the lines: -its heads sound, as late as any Mix-0 run between was long. Taking Mix to -0 and back before the first pull, with nothing played between, is on -time. - -The output ends in an `audioroute.MidSide` at width 1, the identity, whose -reset forwards nothing, above Mix 0 and at it: a host that resets the -output (a mixer voice's `play()` does) reaches neither the Mixer, whose -voices would empty the tap node's line (the heads of what was playing -would go) and, on a build without `get_buffer`, re-prime from the -Splitter and drop the source's first block, nor the input adapter, which -would drop the part of a source buffer it holds. That reset leaves the -lines and your source's timeline as they are; call `reset()` to empty the -lines. - -**Mono.** A one-channel source gets the same effect on its one channel. -There is no Spread: the tap node applies one set of heads to every -channel, so heads cannot be placed across the field. - -**RAM at 48 kHz: about 655 KB** at the default `max_lap_ms` 1600: two lines -of 307 392 B (the lap node's is `max_lap_ms` + 1 ms of two int16 lanes -whatever the channel count; the tap node's is the same length times the -channel count, 153 696 B mono), plus the Splitter's ring (about 34 KB), the -Mixer and the small buffers. At 44.1 kHz each line is 282 416 B, at -22.05 kHz 141 208 B. Pass a lower `max_lap_ms` to spend less (800 ms makes -each line 153 792 B, and the base then stops at 800 / K ms). It has a -floor of 540 ms: below that some Heads leave Time dead or its span under -3.33 : 1. A `max_lap_ms` above 1600 or NaN is 1600. - -**Cost, a planning estimate; the board measurement is pending hardware.** -Palette rows (MultiTapDelay, which ran four taps; FeedbackDelay +options; -Splitter, two taps; Mixer; MidSide, twice, the input adapter and the tail; -one Python pull of glue) put the full graph at **P4 <= 67 %, S3 <= 88 %** -of a 5.333 ms stereo block, which is over the S3's 80 % line, and the -lean graph (Repeat Tone out) at **P4 <= 59 %, S3 <= 74 %**. **On an S3, stack patch 1, `Three Heads, -Even - lean`, not patch 0**, and only with light classes: the tap node -alone is most of an S3 block. Every Repeat Tone-in setting, the defaults -included, is over the S3's line by this estimate. Eight sounding heads -(patch 4) cost more than any palette row has priced, and so may the long -lines on a PSRAM board. - -**Input ceiling.** The heads sit at unity, and the tap node soft-limits at -+-28000 (on the heads' sum, on the line write and on its output), so the -echoes of a hot input are kneed while the dry is not: one head on a -30 000 LSB click reads 28 023. On the kit's `noise_det` at 48 kHz the -output reaches the int16 rail at no patch up to -6 dBFS peak; the wet -passes the knee at -12 dBFS only at patch 4 (eight heads) and patch 1, and -at patch 0 from -9 dBFS. - -**Tail.** `tail_samples` is an upper bound, in frames, on how long the -echoes take to reach exact zero once your input stops, recomputed on every -Time, Heads, Feedback or Repeat Tone move. With Repeat Tone in it is -`DigitalDelay`'s bound for the lap node plus one lap for the tap node's -line. At a Feedback a hair either side of 1 - 0.5 / k the lap node's loop -low-pass can come to rest a hair above k LSB and hand it back; up to -audiodsp v0.6.2 it did so for ever, and this class handed the node a -Feedback just outside each such window (0.95 played as 0.950016). Since -v0.6.3rc1 the node sets a stalled low-pass onto its input (audiodsp#157), -the Feedback you set is the one the node plays, and the bound counts one -more lap there: 167 laps at 0.95, Repeat Tone at the default. With Repeat -Tone out the tap node truncates every lap toward zero, so the bound is -(laps + 1) x P. At patch 0 it is 321 435 frames at 48 kHz (6.7 s). - -`capabilities = ("tempo_sync",)`: with Sync on, the class reads -`self._transport()` on every macro move and program change (not per -block), and Time's knob is rewritten to Division x the beat, clamped to its -span. With no host transport, or a tempo that is not a finite positive -number, Time stays where the knob is. +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/MultiTapDelay.md`), and in the +docstrings of `_route`, `_wire` and `_resync` below. """ VENDOR = "PyDevices" @@ -413,16 +206,53 @@ def _number(value, default): class MultiTapDelay(_component.Component): - """A multi-head echo: several heads on a grid of whole multiples of one - base time, S1's twelve head sets, laps that darken once per lap. - audiodsp tier; zero latency. - - **What the default surrenders:** mode 12 is the plain grid, not the - RE-202's unpublished "optimized" heads; the feedback is taken from the - farthest head on the grid by decision; Time and Heads click when they - move; and on an ESP32-S3 every Repeat Tone-in setting, the defaults - included, is over the 80 % cost line by the palette estimate, so the - S3 build is patch 1 (Repeat Tone out). + """A multi-head echo: one recording read by several heads on a grid. + + The heads of a multi-head tape echo, after the Roland Space Echo's + multi-head modes: your dry signal passes untouched, and a pattern of + echoes follows it. + + **The controls.** + Time is the gap to head 1, landed on a whole frame, and head k sounds at + exactly k times that gap. + Heads is how many heads the grid has, and the pattern repeats once per + trip past the farthest of them. + Pattern picks which heads sound, from the twelve positions of the Roland + RE-202's mode selector. + Its last position sounds every head on the grid, not the RE-202's + unpublished positions. + The lap is the farthest head on the grid, sounded or not, so heads past + the ones a position sounds lengthen the lap without sounding. + Feedback sends the pattern round again, each lap quieter. + Repeat Tone darkens each lap a little more than the one before, once per + lap, never once per head. + Tilt leans the pattern's levels towards the near heads or the far ones. + Up to Mix 1 the dry passes at unity, and at Mix 2 the echoes play alone. + Mix 0 is a wire. + Sync locks Time to Division of the host's beat, clamped to Time's span. + With no host tempo, Time stays on the knob. + Time runs from 20 to 400 ms, Heads from 3 to 8, Feedback from 0 to 0.95 + and Mix from 0 to 2. + `max_lap_ms` (1600 by default, 540 at least) caps the lap: where a grid + would pass it, Time comes down to fit. + A click comes out on the frame it went in: there is no latency. + A one-channel source gets the same effect on its one channel. + The sample rate must be at least 12 825 Hz: below it the constructor + raises `ValueError`. + + **Reset, Mix at zero and the tail.** + The lines are not fed while Mix is 0, so a return from Mix 0 starts both + lines empty. + `reset()` empties both lines and returns to patch 0. + Neither a reset nor a trip to Mix 0 drops a frame of your source or + plays one twice, whatever size of buffer it hands out. + `tail_samples` is an upper bound on how long the echoes take to reach + exact zero once your input stops. + The tail rings on while your source hands back nothing. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. """ NAME = 'MultiTapDelay' diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..df5913d 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -75,6 +75,61 @@ #: cell prints `decl` instead of RED and is counted as a declared exception. #: Nothing else skips a cell. Empty on purpose: no class has declared one. DECLARED = { + ("MultiTapDelay", "E", "P5"): + "family limit, disclosed (audiocomponents#117): a control that jumps" + " makes the output step (Time and Heads move every head to a new" + " grid, Mix, Tilt and a patch change jump a level)", + ("MultiTapDelay", "E1-", "P4"): + "reset() empties both lines where the control's hold older laps; they" + " die to 1 LSB inside tail_samples, and a 1-LSB rounding difference" + " then circulates in the loop past it (silence reaches zero inside" + " it)", + ("MultiTapDelay", "E5-", "P4"): + "a return from Mix 0 starts both lines empty where the control's hold" + " older laps; they die to 1 LSB inside tail_samples, and a 1-LSB" + " rounding difference then circulates in the loop past it", + ("MultiTapDelay", "E9-", "P4"): + "a return from Mix 0 starts both lines empty where the control's hold" + " older laps; they die to 1 LSB inside tail_samples, and a 1-LSB" + " rounding difference then circulates in the loop past it", + ("MultiTapDelay", "E4-m4=", "P4"): + "a return from Mix 0 starts both lines empty where the control's hold" + " older laps; they die to 1 LSB inside tail_samples, and a 1-LSB" + " rounding difference then circulates in the loop past it", + ("MultiTapDelay", "E4-m6=", "P4"): + "crossing Repeat Tone's out stop swaps the loop that makes the laps" + " and empties the lap node going in; the old laps die to 1 LSB inside" + " tail_samples, and a 1-LSB rounding difference then circulates past" + " it", + ("MultiTapDelay", "E6-", "P4"): + "a patch change that crosses Repeat Tone's out stop empties the lap" + " node going in; the old laps die to 1 LSB inside tail_samples, and a" + " 1-LSB rounding difference then circulates past it", + ("MultiTapDelay", "E11-", "P4"): + "Mix 0 or a crossing of Repeat Tone's out stop empties a line; the old" + " laps die to 1 LSB inside tail_samples, and a 1-LSB rounding" + " difference then circulates past it", + ("MultiTapDelay", "E8-dry", "P4"): + "the lines hold the gap the source left, where the control's hold" + " audio; that dies to 1 LSB inside tail_samples, and a 1-LSB rounding" + " difference then circulates in the loop past it, for good at some" + " settings", + ("MultiTapDelay", "E4-m0=", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a Time move and back replays it", + ("MultiTapDelay", "E6-", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a patch change and back replays it", + ("MultiTapDelay", "E11-2patch", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a patch change and back replays it", + ("MultiTapDelay", "E11-3moves", "P3"): + "CPython only, the node's twin (audiodsp#177): the CPython" + " audiodelays.MultiTapDelay keeps the line past a shorter delay_ms" + " that the C node zeroes, so a patch change and back replays it", } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 59f53bd..b188606 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -2926,5 +2926,209 @@ def test_the_tail_reading_is_red_on_a_bound_one_lap_short(self): self.assertGreater(planted, short, (rate, channels)) +# -------------------------------------------------------------------------- +# The docstring's claims, each tied to the test that asserts it + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the class docstring that makes a +#: claim is here; one that could not be tied to a test was struck (the +#: trial fixer's dated note in the dossier lists them). +CLAIMS = ( + ("Time is the gap to head 1, landed on a whole frame, and head k sounds " + "at exactly k times that gap.", + "test_first_lap_over_modes_heads_and_time_stops"), + ("Heads is how many heads the grid has, and the pattern repeats once per " + "trip past the farthest of them.", "test_laps_land_on_the_grid"), + ("Pattern picks which heads sound, from the twelve positions of the " + "Roland RE-202's mode selector.", "test_s1_table_at_four_heads"), + ("Its last position sounds every head on the grid, not the RE-202's " + "unpublished positions.", "test_other_heads"), + ("The lap is the farthest head on the grid, sounded or not, so heads " + "past the ones a position sounds lengthen the lap without sounding.", + "test_the_lap_is_the_farthest_head"), + ("Feedback sends the pattern round again, each lap quieter.", + "test_lap_levels_follow_feedback"), + ("Repeat Tone darkens each lap a little more than the one before, once " + "per lap, never once per head.", "test_at_the_row_cells"), + ("Tilt leans the pattern's levels towards the near heads or the far " + "ones.", "test_tilt_law"), + ("Up to Mix 1 the dry passes at unity, and at Mix 2 the echoes play " + "alone.", "test_the_mix_stops"), + ("Mix 0 is a wire.", "test_mix_zero_is_a_wire"), + ("Sync locks Time to Division of the host's beat, clamped to Time's " + "span.", "test_sync_is_division_clamped_to_the_span"), + ("With no host tempo, Time stays on the knob.", + "test_sync_reads_the_transport_only_when_on"), + ("Time runs from 20 to 400 ms, Heads from 3 to 8, Feedback from 0 to " + "0.95 and Mix from 0 to 2.", "test_the_knob_spans"), + ("`max_lap_ms` (1600 by default, 540 at least) caps the lap: where a " + "grid would pass it, Time comes down to fit.", + "test_constructor_options_clamp"), + ("A click comes out on the frame it went in: there is no latency.", + "test_click_latency_is_zero"), + ("A one-channel source gets the same effect on its one channel.", + "test_first_lap_at_the_defaults_three_rates_both_graphs"), + ("The sample rate must be at least 12 825 Hz: below it the constructor " + "raises `ValueError`.", "test_rates_below_the_floor_are_refused"), + ("The lines are not fed while Mix is 0, so a return from Mix 0 starts " + "both lines empty.", "test_mix_back_from_zero_starts_from_empty_lines"), + ("`reset()` empties both lines and returns to patch 0.", + "test_reset_empties_both_lines_and_returns_to_patch_0"), + ("Neither a reset nor a trip to Mix 0 drops a frame of your source or " + "plays one twice, whatever size of buffer it hands out.", + "test_reset_and_mix_zero_keep_the_timeline"), + ("`tail_samples` is an upper bound on how long the echoes take to reach " + "exact zero once your input stops.", "test_tail_ends_inside_tail_samples"), + ("The tail rings on while your source hands back nothing.", + "test_the_tail_rings_on_while_the_source_is_dry"), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "test_a_jumping_control_steps_the_output"), +) + + +def _flat(text): + return " ".join(text.split()) + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(MultiTapDelay.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_the_knob_spans(self): + effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) + self.assertEqual(effect._max_lap_ms, 1600.0) + for index, low, high in ((TIME_I, 20.0, 400.0), + (FEEDBACK_I, 0.0, 0.95), + (MIX_I, 0.0, 2.0)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + effect.set_macro(HEADS_I, 0) + self.assertEqual(effect._heads_count(), 3) + effect.set_macro(HEADS_I, 127) + self.assertEqual(effect._heads_count(), 8) + + def test_the_lap_is_the_farthest_head(self): + # Mode 1 sounds head 1 alone. With Repeat Tone out (single-frame + # laps) and the echoes alone, a click at frame 10 comes back at + # 10 + n1 and then once per lap of K n1 (n1 = 960 at 20 ms), so the + # heads it does not sound lengthen the lap. + for heads, laps in ((3, (1, 4, 7)), (8, (1, 9))): + effect = build(data=click(at=10), mix=2.0, feedback=0.5, + time_ms=20.0, tone_hz=0.0, pattern=1, + heads=heads) + out = pull(effect, 10 + 960 * laps[-1] + 100) + self.assertEqual(nonzero(out), [10 + 960 * k for k in laps], + heads) + effect.deinit() + + def test_the_mix_stops(self): + for channels in (2, 1): + for mix in (0.35, 1.0): + effect = build(data=click(at=10, channels=channels), + channels=channels, mix=mix) + out = pull(effect, 7300) + self.assertEqual(int(out[10, 0]), 20000, (channels, mix)) + effect.deinit() + effect = build(data=click(at=10, channels=channels), + channels=channels, mix=2.0) + out = pull(effect, 7300) + self.assertEqual(nonzero(out)[0], 10 + 7200, channels) + effect.deinit() + + def test_sync_is_division_clamped_to_the_span(self): + # 1/1 at 30 bpm is 8 s, clamped to 400 ms; 1/32 at 600 bpm is + # 12.5 ms, clamped to 20 ms; 1/4 at 120 bpm is 500 ms, over the top. + for bpm, division, n1 in ((30.0, 15, 19200), (600.0, 0, 960), + (120.0, 9, 19200), (100.0, 6, 14400)): + effect = MultiTapDelay( + Endless(silence()), sample_rate=RATE, division=division, + transport=lambda bpm=bpm: (True, 0.0, bpm, 4, 4)) + effect.set_macro(SYNC_I, 127) + self.assertEqual(effect._n1, n1, (bpm, division)) + effect.deinit() + + def test_reset_empties_both_lines_and_returns_to_patch_0(self): + effect = build(data=click(), mix=1.0, feedback=0.9, time_ms=20.0) + effect.program_change(3) + pull(effect, 3000) + effect.reset() + self.assertEqual(effect.patch_index, 0) + self.assertEqual(int(np.abs(pull(effect, 20000)).max()), 0) + + def test_reset_and_mix_zero_keep_the_timeline(self): + import lifecycle + names = ("E1-reset@block", "E1-reset@part", "E5-mix0", "E9-src100", + "E9-src512", "E9-src1000", "E9-srcraw", "E10-reset", + "E10-mix0") + for patch in (None, 1): + events = dict((e.name, e) for e in + lifecycle.events(MultiTapDelay, patch)) + for channels in (2, 1): + for name in names: + got = lifecycle.p1(MultiTapDelay, events[name], RATE, + channels, patch, {}) + self.assertIn(got, ("ok", "na"), + (name, patch, channels)) + + def test_the_tail_rings_on_while_the_source_is_dry(self): + # The source hands back an empty buffer, done, from the third block + # on; the heads and laps of a click it played before keep coming, + # every pull a full block, on every lap of the lean graph. + import lifecycle + values = array("h", [0] * (BLOCK * 4)) + values[10] = 20000 + feed = lifecycle.Feed(values, RATE, 1, "256", loop=False) + effect = MultiTapDelay(feed.port, sample_rate=RATE, time_ms=20.0, + mix=2.0, feedback=0.5, tone_hz=0.0) + out = bytearray() + for _ in range(2): + out += bytes(audiocore.get_buffer(effect.output)[1]) + feed.port.play(feed.empty) + for _ in range(40): + chunk = bytes(audiocore.get_buffer(effect.output)[1]) + self.assertEqual(len(chunk), BLOCK * 2) + out += chunk + got = np.frombuffer(bytes(out), dtype=" Date: Tue, 29 Sep 2026 00:49:05 -0500 Subject: [PATCH 78/88] SlapbackDelay at audiodsp v0.6.3rc3, trial of the second process: the docstring rewritten with a CLAIMS table (tail_samples names its clock, tested with settings moved mid-tail), the matrix's red cells declared (the free-running Wow across a reset, the family's two limits) --- lib/audioeffects/rebuilt/slapbackdelay.py | 243 +++++------------- tests/support/lifecycle.py | 16 +- tests/test_cpython_effects_slapbackdelay.py | 271 +++++++++++++++++++- tests/test_lifecycle_matrix.py | 12 - 4 files changed, 350 insertions(+), 192 deletions(-) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index cec03dc..59b1603 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -1,167 +1,9 @@ -"""`SlapbackDelay` - one tape repeat at 135 ms, in mono, the Sun Studio slap. - -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/SlapbackDelay.md`, whose trait -table was frozen at Station A before this file existed (anchor commit -7a5a4cbd8a734ea3df6ae8b8b04e32e763a15b5a, the Station A critique's -re-freeze, 2026-09-27). The old class in `delay.py` is consulted only for -the seven defects that dossier's section 7 names. This class was adopted -on 2026-09-28, and `audioeffects.SlapbackDelay` serves it. - -**What it sounds like.** Your dry signal passes untouched, and one copy of -it comes back 135 ms later, from the same place, a little quieter: the -two-machine tape echo on the 1955 Sun sides, which Halmrast measured at -134-137 ms, one repeat, mono. Time (40-250 ms) is the head spacing over the -tape speed. Level (0-2) is the console return: dry at unity up to 1, the -repeat alone at 2, and Level 0 is a wire while the line keeps recording. -Saturation is how hard the return drove the record amplifier; it colours -the repeat and never the dry. Tone is the tape path's top end, out of -circuit by default. Wow is the transport's slow wobble, in cents at a fixed -0.7 Hz. Repeats sends the slap back round for a second and third; the Sun -rig had no feedback path, so it defaults to 0. - -**The standout:** Sam Phillips' two-Ampex-350 slapback at Sun Studio, as -measured on *Baby Let's Play House* and *Tryin' to Get to You*. You get its -time, its single repeat and its mono placement as defaults, and its tape -colours as knobs. - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The stock -`audiodelays.Echo` limits its only output at +-28000, so its Mix 0 is not -a wire. On a stock CircuitPython board this module imports cleanly and -construction raises `ImportError`. - -**Latency: zero samples, at every setting and every rate.** Nothing looks -ahead. The 135 ms is the repeat, not latency on the dry path, and no option -adds any. - -**Mono.** The repeat sits exactly where the dry sits: on a source identical -in both channels the output is identical in both channels, at every knob -position. A one-channel source gets the identical effect on its one channel, -sample for sample the left channel of a stereo render. The class never -passes `input_pan`, and it has no width, spread or pan knob, and never will. - -**RAM.** A fixed line of 251 ms (Time's top plus 1 ms) of two int16 lanes -whatever the channel count: 48 192 B at 48 kHz, 44 276 B at 44.1 kHz, -22 136 B at 22.05 kHz, plus about 1.2 KB of node. No option sizes it. - -**Cost.** One `audioecho.FeedbackDelay` with `delay_slew`, wow and -`loop_drive` on; no mixer. Palette row FeedbackDelay +options (the nearest -not-cheaper row; no row prices `loop_drive`), glue 0: **P4 <= 9 %, -S3 <= 15 %** of a 5.333 ms stereo block. Measured on both boards on -2026-09-28 at every shipped patch, with the tool's control in the same -conditions as the palette row: the P4 at most 0.396 ms, 7.4 % (rt 5.82 or -better); the S3 0.781-0.783 ms, 14.6-14.7 %, at the default and patches -0-4, and 0.813 ms, 15.2 %, at patch 5, the one patch with Tone in -circuit (rt 3.08 or better). Brad passed patch 5 against the 15 % bar on -2026-09-28; alone on an S3 the class leaves about 85 % of the block for -everything else. All seven patch digests are identical on both boards and -differ from the desktop's, because the Wow depth is worked out in Python -in a board's single precision (`wow_depth_ms` 2.1 x 10^-5 to -6.8 x 10^-5 ms high, under 0.0001 cent; patch 4's Feedback also one -float32 step off). - -**What the default surrenders.** The default is Tone out, so the repeat is -as bright as the dry: an Ampex 350 at 15 ips rolls off at 15 kHz and at -7.5 ips lower still, and the Tone knob (patch 5, Dark Slap) is how you get -there. Wow is on at 1 cent, and wow moves the read head between samples, so -the repeat's top end breathes: at 48 kHz a 15 kHz tone in the repeat swings -between -0.03 and -5.11 dB (mean -2.61 dB) about 25 times a second; at -44.1 kHz between -0.02 and -6.38 dB (mean -3.35 dB). With Wow at 0 every -static Time is handed to the node as the nearest whole frame at the -running rate, so the default 135 ms is 6 480 frames at 48 kHz and 5 954 -frames (135.011 ms) at 44.1 kHz, not the 5 953.5 that would cost the -repeat 6.35 dB at 15 kHz for as long as it played. At 48 kHz the node -lands every one of the 128 Time positions exactly on that frame, and the -repeat loses nothing. - -At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds -back into frames in float32, and for some Times no float32 value lands on -the whole frame, so the read sits one float32 step off it. Among the 128 -Time positions that is 21 at 44.1 kHz (MIDI 4, 8, 9, 10, 11, 38, 39, 40, -41, 49, 50, 53, 55, 60, 83, 86, 91, 93, 96, 99, 102) and 20 at 22.05 kHz -(MIDI 8, 10, 34, 38, 39, 40, 41, 45, 53, 60, 81, 83, 86, 91, 93, 96, 97, -98, 99, 102), at most 1/2048 of a frame off at 44.1 kHz and 1/4096 at -22.05 kHz. Of the 9 262 whole frames a constructor `time_ms` reaches -from 40 to 250 ms, 1 159 land off at 44.1 kHz, and 579 of 4 632 at -22.05 kHz, up to 1/1024 and 1/2048 of a frame. None do at 48 kHz, nor the -default 135 ms or any shipped patch's Time at any rate. There the repeat -puts a sliver on the frame beside it: a 20 000 click's repeat reads -19 618 and 10 at MIDI 60, 44.1 kHz (19 623 and 5 at 22.05 kHz), against -19 627 on the frame (the default Saturation's own loss). The class cannot -hand the node a number that lands there; a node change is asked for. - -**Saturation** is the node's cubic soft clip on the repeat, applied again on -each pass when Repeats is up. On the repeat of a -6 dBFS tone the default -0.15 adds a third harmonic at -50.0 dB re the fundamental and takes the -fundamental down 0.08 dB; 1.0 puts the third at -33.1 dB and the -fundamental at -0.56 dB. There is no second harmonic. - -**Turning Time while it plays** walks the repeat to the new time at a fixed -0.1875 delay-seconds per second, instead of clicking: the repeat bends -+297.5 cents while Time falls and -359.5 cents while it rises, and -135 -> 85 ms takes 267 ms. There is no Glide knob; a slap's time is set, -not played. - -**Turning Wow while it plays glides.** Since audiodsp v0.6.3rc1 the node -ramps a new wow depth in over 20 ms (audiodsp#160), so the repeat bends -for those 20 ms instead of jumping. On a 997 Hz tone at 12 000 LSB, -Level 2, 48 kHz, a Wow move from grid 36 to 73 or from 0 to 127 steps the -output by no more than the tone's own largest step, 1 564 LSB, anywhere -in the 2 000 frames after it (up to v0.6.2 the same moves stepped 7 684 -and 23 037). While the depth travels, the extra pitch is the change over -20 ms times where the 0.7 Hz cycle is: 0 to 127 is 0.46 ms, up to 2.3 % -at the cycle's crest for those 20 ms. A patch change that moves Wow is a -click only as far as its Level jump makes it: patch 0 to patch 2 -(Doubling), tried on every block boundary of that tone, steps up to 3 416 -LSB against patch 0's own 2 106 at 48 kHz (3 569 against 2 292 at -44.1 kHz; 5 503 and 5 680 at v0.6.2), and 3 414 with Wow held at patch 0's, -so what is left is the Level moving at once, not the Wow. - -**Tone out is out, after Tone has been in too.** Bring Tone back in after -the repeat has died away and nothing plays: 0 LSB at 48, 44.1 and -22.05 kHz, stereo and mono, after a 300 Hz tone at 30 000 LSB with Tone at -2 kHz. The out stop hands the node exactly 0, and since audiodsp v0.6.3rc1 -the node keeps an out low-pass's state on the signal (audiodsp#158), so -Tone out after Tone in renders the same bytes as a fresh instance's Tone -out. Up to v0.6.2 the node froze that state and played it back; this class -cured it with a tracking out stop, 1 LSB off the filter truly out in -places, and the cure came out when the node was fixed. - -**A host that echoes Time back** (`set_macro(0, get_macro(0))`) keeps the -constructor's exact Time: the 44.1 kHz default stays on 5 954 frames. -Any other Time position lands the knob's own value, and that includes a -restore: save `get_macro(0)`, move Time, write the saved value back, and -the 44.1 kHz default comes back on 5 953 frames, one short. - -**Tone at a low rate.** The knob's corners clamp below Nyquist at the -running rate. At 22.05 kHz grid positions 94-126 all sit on the 10 804.5 Hz -clamp and do the same thing, and position 127 takes the filter out. At 44.1 -and 48 kHz every position moves. - -**Input ceiling.** The dry path sits at unity and the repeat adds to it, -and there is no input gain to turn down. Measured on the kit's `noise_det` -at 48 kHz over 4 s, the defaults put no sample on the rail from -2.5 dBFS -peak down (at -2.4 they rail 14 samples), and the shipped patches from --3.4 dBFS (patch 2, Doubling, the first to rail) down. - -**Tail.** `tail_samples` is an upper bound on how long the output takes to -reach exact zero after your input stops: one lap of the line at Repeats 0 -(6 488 frames at the defaults, 48 kHz), 11 laps at 0.35, 21 at the 0.6 -stop. With Tone in circuit, at a Repeats a hair either side of 0.5 (which -the span reaches), the loop low-pass can rest a hair above 1 LSB and hand -it back; up to audiodsp v0.6.2 it did so for ever and the class moved -Repeats clear of it. Since v0.6.3rc1 the node sets a stalled low-pass onto -its input (audiodsp#157), Repeats is handed as set, the bound counts one -more lap there, and the tail reaches exact zero inside it. - -`capabilities = ()`: a slapback's time is a fixed distance over a fixed -tape speed, with no musical relationship to a tempo, so the class never -reads `self._transport()`. - -A constructor value stays on the audio path unrounded by the knob's grid; -Time is then landed on a whole frame. A value outside a knob's span clamps -to the nearer stop (a Wow above 3.5 cents plays 3.5), a `tone_hz` of 0 or -less is Tone out, and NaN takes that option's default. +"""`SlapbackDelay` - one tape repeat after the dry, in mono: the Sun Studio slap. + +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/SlapbackDelay.md`). """ VENDOR = "PyDevices" @@ -270,13 +112,64 @@ def _between(value, low, high): class SlapbackDelay(_component.Component): - """One tape repeat, 135 ms after the dry and in the same place: the Sun - Studio slap. audiodsp tier; zero latency. - - **What the default surrenders:** Tone is out, so the repeat is brighter - than an Ampex 350's 15 kHz top; the 1-cent wow makes the repeat's top - end breathe (a 15 kHz tone swings to -5.11 dB at 48 kHz, -6.38 dB at - 44.1); and the Time knob walks rather than jumps. + """One tape repeat after the dry, in the same place: the Sun Studio slap. + + Your dry signal passes untouched, and one copy of it comes back a moment + later, a little quieter: the two-machine tape echo on Sam Phillips' Sun + sides. + By default the repeat comes 135 ms after the dry, once. + + **The controls.** Time is the gap between the dry and the repeat. Level + is the console's return. Saturation is how hard the return drove the + record amplifier. Tone is the tape path's top end. Wow is the + transport's slow wobble. Repeats sends the slap round again. + Time runs from 40 to 250 ms, Level from 0 to 2, Saturation from 0 to 1, + Tone from 2 kHz to out at its top stop, Wow from 0 to 3.5 cents and + Repeats from 0 to 0.6. + Level 0 is a wire. + Up to Level 1 the dry passes untouched until the repeat arrives, however + hard Saturation drives the repeat. + A hot input can reach the rail, since the repeat adds to a dry at unity. + At Repeats 0 there is one repeat and no second. + Repeats above 0 sends the repeat round for more. + Wow swings the repeat's pitch by the cents the knob reads, at a slow + fixed rate. + The default Wow takes the repeat's very top more than 4 dB down at + Nyquist, where Wow 0 leaves it within half a dB. + At 22.05 kHz the last Tone positions below the top stop clamp below + Nyquist and all do the same thing. + + **Time.** Every Time position lands on the nearest whole frame at + 48 kHz. + At 44.1 and 22.05 kHz the node lands some positions a fraction of a + frame off, and a sliver of the repeat falls on the frame beside it. + A host that writes back `get_macro(0)` keeps the constructor's exact + Time. + Turning Time walks the repeat to the new Time, bending its pitch, + instead of clicking. + A Wow move glides instead of stepping. + + **Mono, latency, tail.** A source the same in both channels comes out + the same in both channels, and a one-channel source gets the stereo + render's left channel. + A click comes out on the frame it went 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. + `reset()` empties the line and returns to patch 0. + With Wow above 0 the wobble runs free, so after a reset the output never + lines up with a fresh instance's again. + The class never reads the host's tempo. + A constructor value outside a knob's span clamps to the nearer stop, a + `tone_hz` of 0 or less is Tone out, and NaN takes the option's default. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + The tail rings only while the source keeps feeding: feed silence to let + it ring out. A tail cut short by a source that stopped carries on when + the source comes back. """ NAME = 'SlapbackDelay' @@ -478,12 +371,8 @@ def _refresh(self): @property def tail_samples(self): - """Frames until the output is exactly zero once the input stops, as - an upper bound: `laps_to_zero(f, excess)` laps of the longest delay - the read head may be at, plus the wow's depth in frames rounded up, - plus one frame for the interpolated read, plus the Tone low-pass's - memory. One lap at Repeats 0. Finite at every setting the class - reaches.""" + """What this bound promises is in the class docstring; how it is + built is in the dossier.""" self._check_live() return self._tail_bound() diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..5850609 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -73,8 +73,22 @@ #: Where a class cannot satisfy a property by design, it says so HERE, one #: row per (class NAME, event name prefix, property): reason. A matching #: cell prints `decl` instead of RED and is counted as a declared exception. -#: Nothing else skips a cell. Empty on purpose: no class has declared one. +#: Nothing else skips a cell. DECLARED = { + ("SlapbackDelay", "E", "P5"): + "a control that jumps steps the output (family, audiocomponents#117):" + " Level, Tone and patch moves have no ramp", + ("SlapbackDelay", "E8-dry", "P3"): + "a tail cut short by a stopped source carries on when it comes back" + " (family, audiodsp#180)", + ("SlapbackDelay", "E1-", "P4"): + "the Wow wobble runs free: a reset does not put its phase where a" + " fresh instance's is, so with Wow above 0 the output never matches" + " it again (ok at Wow 0)", + ("SlapbackDelay", "E2-", "P4"): + "the Wow wobble runs free: reset_buffer does not put its phase where" + " a fresh instance's is, so with Wow above 0 the output never" + " matches it again (ok at Wow 0)", } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index ff9ed3d..8ce48b5 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -424,6 +424,17 @@ def _apply_macro(self, index, position): self._refresh() +class JumpTimeSlapback(SlapbackDelay): + """A Time move that jumps: the node's walk turned off, so the read head + lands on the new Time at once (trial, 2026-09-29).""" + + NAME = 'SlapbackDelay' + + def _refresh(self): + SlapbackDelay._refresh(self) + self._delay.set(delay_slew=0.0) + + # -------------------------------------------------------------------------- # Sources and pulls @@ -1145,7 +1156,8 @@ def test_an_off_frame_time_leaks_into_the_next_frame(self): # MIDI 60 at 44.1 kHz is 4 193 frames, landed 1/2048 of a frame # late: a 20 000 click's repeat (Wow 0, Level 2) reads 19 618 and # 10 in the frame after; at 48 kHz the same position reads 19 627 alone - # (the default Saturation's loss). A half frame, planted, leaks. + # (the default Saturation's loss). At 22.05 kHz it reads 5 in the + # frame before and 19 623. A half frame, planted, leaks. def window(cls, rate): probe = cls(silence_src(64, 2, rate), sample_rate=rate) probe.set_macro(TIME_I, 60) @@ -1157,6 +1169,7 @@ def window(cls, rate): return [int(v) for v in y[frames - 1:frames + 2]] self.assertEqual(window(SlapbackDelay, 44100), [0, 19618, 10]) + self.assertEqual(window(SlapbackDelay, 22050), [5, 19623, 0]) self.assertEqual(window(SlapbackDelay, 48000), [0, 19627, 0]) self.assertNotEqual(window(HalfFrameSlapback, 48000), [0, 19627, 0]) @@ -1615,7 +1628,7 @@ def test_level_zero_is_a_wire_on_the_full_scale_ramp(self): def test_the_dry_is_unity_until_the_repeat(self): # Level 1.0 and grid 63 (0.992): the first T - 24 frames are the # source, byte for byte, with the wow at its top. - for level in (1.0, 2.0 * 63 / 127.0): + for level in (0.0, 0.35, 0.7, 1.0, 2.0 * 63 / 127.0): T = law_frames(40.0, RATE) ramp = probes.ramp_fs(frames=T * 2, channels=2) effect = SlapbackDelay( @@ -1852,6 +1865,260 @@ def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): **options), 0, options) +# -------------------------------------------------------------------------- +# The docstring's claims, each tied to the test that asserts it (the trial +# of the second process, 2026-09-29) + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the docstring that makes a claim is +#: here; one that could not be tied to a test was struck. +CLAIMS = ( + ("By default the repeat comes 135 ms after the dry, once.", + "test_defaults_three_rates_stereo_and_mono"), + ("Time runs from 40 to 250 ms, Level from 0 to 2, Saturation from 0 to " + "1, Tone from 2 kHz to out at its top stop, Wow from 0 to 3.5 cents " + "and Repeats from 0 to 0.6.", "test_the_knob_spans"), + ("Level 0 is a wire.", "test_level_zero_is_a_wire_on_the_full_scale_ramp"), + ("Up to Level 1 the dry passes untouched until the repeat arrives, " + "however hard Saturation drives the repeat.", + "test_the_dry_is_unity_until_the_repeat"), + ("A hot input can reach the rail, since the repeat adds to a dry at " + "unity.", "test_the_stated_ceiling_is_clean_and_just_over_is_not"), + ("At Repeats 0 there is one repeat and no second.", + "test_no_second_repeat_at_the_stops_and_patches"), + ("Repeats above 0 sends the repeat round for more.", + "test_the_control_moves"), + ("Wow swings the repeat's pitch by the cents the knob reads, at a slow " + "fixed rate.", "test_zero_default_and_top"), + ("The default Wow takes the repeat's very top more than 4 dB down at " + "Nyquist, where Wow 0 leaves it within half a dB.", + "test_presence_at_nyquist_is_the_disclosed_wow_loss"), + ("At 22.05 kHz the last Tone positions below the top stop clamp below " + "Nyquist and all do the same thing.", + "test_tone_stops_and_the_22k_clamp"), + ("Every Time position lands on the nearest whole frame at 48 kHz.", + "test_where_the_node_lands_the_handed_frame"), + ("At 44.1 and 22.05 kHz the node lands some positions a fraction of a " + "frame off, and a sliver of the repeat falls on the frame beside it.", + "test_an_off_frame_time_leaks_into_the_next_frame"), + ("A host that writes back `get_macro(0)` keeps the constructor's exact " + "Time.", "test_a_host_echoing_time_keeps_the_frame"), + ("Turning Time walks the repeat to the new Time, bending its pitch, " + "instead of clicking.", "test_a_time_move_walks"), + ("A Wow move glides instead of stepping.", "test_a_wow_move_does_not_step"), + ("A source the same in both channels comes out the same in both " + "channels, and a one-channel source gets the stereo render's left " + "channel.", "test_the_defaults_the_corner_and_the_patches"), + ("A click comes out on the frame it went in: there is no latency.", + "test_click_delay_is_zero"), + ("`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.", "test_tail_samples_holds_for_the_settings_as_they_stand"), + ("`reset()` empties the line and returns to patch 0.", + "test_reset_empties_the_line"), + ("With Wow above 0 the wobble runs free, so after a reset the output " + "never lines up with a fresh instance's again.", + "test_the_wobble_runs_free_across_a_reset"), + ("The class never reads the host's tempo.", + "test_the_transport_is_never_read"), + ("A constructor value outside a knob's span clamps to the nearer stop, a " + "`tone_hz` of 0 or less is Tone out, and NaN takes the option's " + "default.", "test_constructor_clamps_and_nan"), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "test_a_jumping_control_steps_the_output"), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", "test_a_tail_cut_short_carries_on"), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", "test_a_tail_cut_short_carries_on"), +) + + +def _flat(text): + return " ".join(text.split()) + + +def _cell(event, patch, channels=1, rate=RATE, cls=None): + """One lifecycle matrix cell on the class, as measured, with the class's + DECLARED rows lifted so the raw verdict shows.""" + import lifecycle + cls = cls or SlapbackDelay + ev = [e for e in lifecycle.events(cls, patch) if e.name == event][0] + saved = dict(lifecycle.DECLARED) + for key in list(lifecycle.DECLARED): + if key[0] == "SlapbackDelay": + del lifecycle.DECLARED[key] + controls = {} + try: + return lifecycle.run_cell(cls, ev, rate, channels, patch, {}, + controls) + finally: + lifecycle.DECLARED.clear() + lifecycle.DECLARED.update(saved) + for ctl in controls.values(): + ctl.close() + + +class NoWowSlapback(SlapbackDelay): + """The class with Wow at 0 in the constructor and every patch: the + control for the free-running wobble.""" + + NAME = 'SlapbackDelay' + PATCHES = dict((index, (name, values[:WOW_I] + (0,) + + values[WOW_I + 1:])) + for index, (name, values) in SlapbackDelay.PATCHES.items()) + + def _build(self, *args, **options): + options.setdefault("wow_cents", 0.0) + SlapbackDelay._build(self, *args, **options) + + +def _time_move(cls, target=52): + """(the tone's own largest step before the move, the largest step over + the 2 000 frames from it, zero crossings in 1 024 frames before and + after) for Time 135 ms -> `target` at frame 15 616 on 997 Hz at 12 000 + LSB, Level 2 (the repeat alone), Wow 0, 48 kHz.""" + at = 15616 + values = 12000 * np.sin(2 * math.pi * 997.0 * np.arange(RATE) / RATE) + source, _ = to_source(values) + effect = cls(source, sample_rate=RATE, level=2.0, wow_cents=0.0) + + def move(frame): + if frame == at: + effect.set_macro(TIME_I, target) + + y = pull(effect, RATE, on_block=move)[:, 0].astype(int) + steady = int(np.abs(np.diff(y[at - 3000:at - 1])).max()) + worst = int(np.abs(np.diff(y[at - 1:at + 2000])).max()) + + def crossings(seg): + return int(np.count_nonzero(np.diff(np.sign(seg)) != 0)) + return (steady, worst, crossings(y[at - 1024:at]), + crossings(y[at + 256:at + 1280])) + + +def _midtail(ctor, moves, at=2048, rate=RATE): + """A full-scale DC burst of 50 ms at Level 2, then silence; `moves` made + `at` frames into the silence. (`tail_samples` read as the input stops, + `tail_samples` read just after the moves, frames from the moves to the + output's last non-zero frame).""" + burst = int(0.05 * rate) // BLOCK * BLOCK + move = burst + at + frames = move + 30 * rate + values = np.zeros(frames) + values[:burst] = 32767 + source, _ = to_source(values, 2, rate) + effect = SlapbackDelay(source, sample_rate=rate, level=2.0, **ctor) + seen = {} + + def on_block(frame): + if frame == burst: + seen["before"] = effect.tail_samples + if frame == move: + for index, value in moves: + effect.set_macro(index, value) + seen["after"] = effect.tail_samples + + y = pull(effect, frames, on_block=on_block)[:, 0] + nonzero = np.nonzero(y[move:])[0] + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return seen["before"], seen["after"], last + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(SlapbackDelay.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_the_knob_spans(self): + effect = SlapbackDelay(silence_src(64), sample_rate=RATE) + for index, low, high in ((TIME_I, 40.0, 250.0), (LEVEL_I, 0.0, 2.0), + (SATURATION_I, 0.0, 1.0), + (WOW_I, 0.0, 3.5), (REPEATS_I, 0.0, 0.6)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + effect.set_macro(TONE_I, 0) + self.assertAlmostEqual(effect._value(TONE_I), 2000.0, places=6) + self.assertEqual(effect._damping, + nominal_damping_hz(2000.0, RATE)) + effect.set_macro(TONE_I, 127) + self.assertEqual(effect._damping, 0.0) + + def test_a_time_move_walks(self): + # 135 -> 85 ms (grid 52) on the repeat alone: the pitch bends up + # (more crossings after the move), and no step is larger than the + # bent tone's own (+297.5 cents, x 1.19). Planted: the walk turned + # off, so the head jumps 50 ms and the output clicks. + steady, worst, before, after = _time_move(SlapbackDelay) + self.assertLessEqual(worst, 1.25 * steady) + self.assertGreater(after, before) + steady, worst, _, _ = _time_move(JumpTimeSlapback) + self.assertGreater(worst, 2 * steady) + + def test_tail_samples_holds_for_the_settings_as_they_stand(self): + # Re-audit 1 found the tail sentence named no clock. Each row moves + # a setting 2 048 frames into the silence (1 024 on a 40 ms Time, + # whose repeat is over by then) and reads `tail_samples` after it: + # the output is exact zero within that many frames of the move. + rows = (({}, [(REPEATS_I, 127)], 2048), + ({"repeats": 0.6}, [(REPEATS_I, 0)], 2048), + ({"time_ms": 250.0}, [(TIME_I, 0)], 2048), + ({"time_ms": 40.0}, [(TIME_I, 127)], 1024), + ({"wow_cents": 0.0}, [(WOW_I, 127)], 2048), + ({"wow_cents": 3.5, "repeats": 0.6}, [(WOW_I, 0)], 2048), + ({"time_ms": 40.0, "wow_cents": 3.5}, [(WOW_I, 0)], 1024), + ({"repeats": 0.5}, [(TONE_I, 0)], 2048), + ({"tone_hz": 2000.0, "repeats": 0.6}, [(TONE_I, 127)], 2048)) + for ctor, moves, at in rows: + before, after, last = _midtail(ctor, moves, at) + self.assertGreater(last, 0, (ctor, moves)) + self.assertLessEqual(last, after, (ctor, moves, before, after)) + # The value read before a Repeats move up does not hold after it. + before, after, last = _midtail({}, [(REPEATS_I, 127)]) + self.assertGreater(last, before) + + def test_the_wobble_runs_free_across_a_reset(self): + # The matrix's E1 and E2 cells go red on P4 at every patch, each + # with Wow above 0; with Wow 0 in the constructor and every patch + # the same cells are ok. The class declares it. + for event in ("E1-reset@block", "E1-reset@part", "E2-reset_buffer"): + for patch in (None, 2): + res = _cell(event, patch) + self.assertTrue(res["P4"].startswith("RED"), (event, res)) + res = _cell(event, patch, cls=NoWowSlapback) + self.assertEqual(res["P4"], "ok", (event, res)) + + def test_a_jumping_control_steps_the_output(self): + # The matrix's E5 cell at patch 2 (Level to 0 and back) steps past + # its bar; the class declares it (audiocomponents#117). + res = _cell("E5-mix0", 2) + self.assertTrue(res["P5"].startswith("RED"), res) + self.assertEqual(res["P1"], "ok", res) + + def test_a_tail_cut_short_carries_on(self): + # The matrix's E8-dry cell at patch 2: the source hands back an + # empty buffer once, and when it comes back the tail it cut short + # plays out of the silence (audiodsp#180). + res = _cell("E8-dry", 2) + self.assertTrue(res["P3"].startswith("RED(peak"), res) + self.assertEqual(res["P2"], "ok", res) + + # -------------------------------------------------------------------------- # The two checks every planted fault and every row is held to diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..c9012fa 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -49,18 +49,6 @@ class this branch carries, on CPython and on the two native interpreters. ('DigitalDelay', 'E8-dry', 'P3'): ( 6, 'e155e27d', 6, 'e155e27d', 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('SlapbackDelay', 'E', 'P5'): ( - 124, '01ddb3de', 198, '4cda9a29', - 'Level, Tone and patch moves step the output within one block (no ramp)'), - ('SlapbackDelay', 'E1-', 'P4'): ( - 56, 'c608fc28', 56, 'c608fc28', - 'reset() lands patch 0 at another moment than a fresh instance, so the Wow phase never re-converges'), - ('SlapbackDelay', 'E2-', 'P4'): ( - 28, 'cbf2eabb', 28, 'cbf2eabb', - 'a host reset_buffer shifts the Wow phase, so the output never re-converges'), - ('SlapbackDelay', 'E8-dry', 'P3'): ( - 16, '95d6ec10', 16, '95d6ec10', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), ('TapeDelay', 'E', 'P5'): ( 34, '60c8dd58', 106, '4ce31e31', 'Time, Mix, Wow and patch moves step the output within one block'), From b32aa3a312b29686b5fce5a4ce211400934dd187 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:51:48 -0500 Subject: [PATCH 79/88] MultiTapDelay: the dry passes at unity except on CircuitPython's stereo right lane near the rails; the docstring says so and a test holds it on the three interpreters --- lib/audioeffects/rebuilt/multitapdelay.py | 2 + tests/test_cpython_effects_multitapdelay.py | 86 +++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index 4cb4419..427f490 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -228,6 +228,8 @@ class MultiTapDelay(_component.Component): lap, never once per head. Tilt leans the pattern's levels towards the near heads or the far ones. Up to Mix 1 the dry passes at unity, and at Mix 2 the echoes play alone. + On CircuitPython alone, a stereo dry's right lane reads one LSB hot on + source values within 32 LSB of the rails. Mix 0 is a wire. Sync locks Time to Division of the host's beat, clamped to Time's span. With no host tempo, Time stays on the knob. diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index b188606..493022d 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -2954,6 +2954,9 @@ def test_the_tail_reading_is_red_on_a_bound_one_lap_short(self): "ones.", "test_tilt_law"), ("Up to Mix 1 the dry passes at unity, and at Mix 2 the echoes play " "alone.", "test_the_mix_stops"), + ("On CircuitPython alone, a stereo dry's right lane reads one LSB hot " + "on source values within 32 LSB of the rails.", + "test_the_right_lane_is_hot_only_on_circuitpython"), ("Mix 0 is a wire.", "test_mix_zero_is_a_wire"), ("Sync locks Time to Division of the host's beat, clamped to Time's " "span.", "test_sync_is_division_clamped_to_the_span"), @@ -2987,6 +2990,68 @@ def test_the_tail_reading_is_red_on_a_bound_one_lap_short(self): ) +#: Every source value within 64 LSB of either rail, and a few inside, +#: through the class at Mix 1 in one run of 256-frame blocks shorter than +#: the first head: prints `channels|left wrong|right wrong|worst|least |v|`. +HOT_SCRIPT = """ +import audiocore +from array import array +from audioeffects.rebuilt.multitapdelay import MultiTapDelay +values = list(range(-32768, -32704)) + list(range(32704, 32768)) + [ + -20000, -1, 0, 1, 20000] +for channels in (2, 1): + data = array("h", [0] * (len(values) * channels)) + for i in range(len(values)): + for c in range(channels): + data[i * channels + c] = values[i] + source = audiocore.RawSample(data, sample_rate=48000, + channel_count=channels) + effect = MultiTapDelay(source, sample_rate=48000, mix=1.0) + got = array("h") + while len(got) < len(data): + got.extend(memoryview(audiocore.get_buffer(effect.output)[1]).cast( + "h") if hasattr(memoryview, "cast") else + audiocore.get_buffer(effect.output)[1]) + lanes = [0, 0] + worst = 0 + least = 99999 + for i in range(len(values)): + for c in range(channels): + d = got[i * channels + c] - values[i] + if d: + lanes[c] += 1 + worst = max(worst, abs(d)) + least = min(least, abs(values[i])) + print("%d|%d|%d|%d|%d" % (channels, lanes[0], lanes[1], worst, least)) + effect.deinit() +print("DONE") +""" + + +def run_hot(binary): + """{channels: (left wrong, right wrong, worst, least |v| wrong)}.""" + with tempfile.TemporaryDirectory() as directory: + script = os.path.join(directory, "hot.py") + with open(script, "w") as handle: + handle.write(HOT_SCRIPT) + env = dict(os.environ, MICROPYPATH="lib", GCOV_PREFIX=directory, + PYTHONDONTWRITEBYTECODE="1", + PYTHONPATH=os.path.join(ROOT, "lib")) + argv = [binary] + ([] if binary == sys.executable + else ["-X", "heapsize=256M"]) + done = subprocess.run(argv + [script], capture_output=True, + text=True, cwd=ROOT, env=env) + lines = done.stdout.splitlines() + if done.returncode != 0 or not lines or lines[-1] != "DONE": + raise AssertionError("%s: %s%s" % (binary, done.stdout[-1000:], + done.stderr[-2000:])) + out = {} + for line in lines[:-1]: + parts = [int(v) for v in line.split("|")] + out[parts[0]] = tuple(parts[1:]) + return out + + def _flat(text): return " ".join(text.split()) @@ -3008,6 +3073,27 @@ def test_every_claim_is_in_the_docstring_and_tested(self): numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] self.assertEqual(numbers, []) + def test_the_right_lane_is_hot_only_on_circuitpython(self): + # The stock audiomixer's pan law: 32768 / 32767 on the right lane, + # which moves the 63 values from 32 736 up (and down from -32 736, + # the rail itself clipping) one LSB out. Mono and the left lane are + # exact, and every other interpreter is exact. + exact = {2: (0, 0, 0, 99999), 1: (0, 0, 0, 99999)} + self.assertEqual(run_hot(sys.executable), exact) + found = current_native_interpreters() + if found is None: + self.skipTest("no workspace bin/ above this checkout") + for family, binary in sorted(found.items()): + self.assertIsNotNone(binary, family) + got = run_hot(binary) + if family == "circuitpython": + self.assertEqual(got[1], exact[1]) + left, right, worst, least = got[2] + self.assertEqual((left, worst, least), (0, 1, 32736)) + self.assertEqual(right, 63) + else: + self.assertEqual(got, exact, family) + def test_the_knob_spans(self): effect = MultiTapDelay(Endless(silence()), sample_rate=RATE) self.assertEqual(effect._max_lap_ms, 1600.0) From 9b639795bfd13ba1ce9589383660ae59ca0065b3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:03:31 -0500 Subject: [PATCH 80/88] SlapbackDelay second fixer: strike the false free-running-wobble sentence and its CLAIMS row; the E1/E2 P4 DECLARED reasons now say a reset restarts the wobble where a fresh instance's starts and the unreset control is further along; test_a_reset_restarts_the_wobble backs it --- lib/audioeffects/rebuilt/slapbackdelay.py | 2 - tests/support/lifecycle.py | 12 ++--- tests/test_cpython_effects_slapbackdelay.py | 50 ++++++++++++++++++--- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index 59b1603..51b0fec 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -158,8 +158,6 @@ class SlapbackDelay(_component.Component): read it if that is later, for the settings as they stand when you read it. `reset()` empties the line and returns to patch 0. - With Wow above 0 the wobble runs free, so after a reset the output never - lines up with a fresh instance's again. The class never reads the host's tempo. A constructor value outside a knob's span clamps to the nearer stop, a `tone_hz` of 0 or less is Tone out, and NaN takes the option's default. diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 5850609..ee44f93 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -82,13 +82,13 @@ "a tail cut short by a stopped source carries on when it comes back" " (family, audiodsp#180)", ("SlapbackDelay", "E1-", "P4"): - "the Wow wobble runs free: a reset does not put its phase where a" - " fresh instance's is, so with Wow above 0 the output never matches" - " it again (ok at Wow 0)", + "a reset restarts the Wow wobble where a fresh instance's starts;" + " the control never stopped, so its wobble is further along and" + " with Wow above 0 the two never line up again (ok at Wow 0)", ("SlapbackDelay", "E2-", "P4"): - "the Wow wobble runs free: reset_buffer does not put its phase where" - " a fresh instance's is, so with Wow above 0 the output never" - " matches it again (ok at Wow 0)", + "reset_buffer restarts the Wow wobble where a fresh instance's" + " starts; the control never stopped, so its wobble is further along" + " and with Wow above 0 the two never line up again (ok at Wow 0)", } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 8ce48b5..80dea6a 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -1917,9 +1917,6 @@ def test_the_stated_ceiling_is_clean_and_just_over_is_not(self): "read it.", "test_tail_samples_holds_for_the_settings_as_they_stand"), ("`reset()` empties the line and returns to patch 0.", "test_reset_empties_the_line"), - ("With Wow above 0 the wobble runs free, so after a reset the output " - "never lines up with a fresh instance's again.", - "test_the_wobble_runs_free_across_a_reset"), ("The class never reads the host's tempo.", "test_the_transport_is_never_read"), ("A constructor value outside a knob's span clamps to the nearer stop, a " @@ -1962,7 +1959,7 @@ def _cell(event, patch, channels=1, rate=RATE, cls=None): class NoWowSlapback(SlapbackDelay): """The class with Wow at 0 in the constructor and every patch: the - control for the free-running wobble.""" + control for the reset cells' declared P4 rows.""" NAME = 'SlapbackDelay' PATCHES = dict((index, (name, values[:WOW_I] + (0,) @@ -2092,10 +2089,13 @@ def test_tail_samples_holds_for_the_settings_as_they_stand(self): before, after, last = _midtail({}, [(REPEATS_I, 127)]) self.assertGreater(last, before) - def test_the_wobble_runs_free_across_a_reset(self): + def test_the_reset_cells_differ_from_an_unreset_control_with_wow(self): # The matrix's E1 and E2 cells go red on P4 at every patch, each # with Wow above 0; with Wow 0 in the constructor and every patch - # the same cells are ok. The class declares it. + # the same cells are ok. A reset restarts the wobble where a fresh + # instance's starts (test_a_reset_restarts_the_wobble), and the + # matrix's control never stopped, so its wobble is further along. + # The class declares the cells. for event in ("E1-reset@block", "E1-reset@part", "E2-reset_buffer"): for patch in (None, 2): res = _cell(event, patch) @@ -2103,6 +2103,44 @@ def test_the_wobble_runs_free_across_a_reset(self): res = _cell(event, patch, cls=NoWowSlapback) self.assertEqual(res["P4"], "ok", (event, res)) + def test_a_reset_restarts_the_wobble(self): + # Backs the DECLARED reason for E1/E2 P4: after reset() or the + # host's reset_buffer, patch 0 with Wow at its top, the output is + # sample for sample a fresh instance's fed the same material from + # that frame, while an instance that ran on without the reset + # differs in nearly every sample. + at = 40 * BLOCK + for rate in (48000, 22050): + for channels in (2, 1): + noise = np.random.default_rng(7).uniform( + -12000, 12000, at + rate // 2) + for how in ("reset", "reset_buffer"): + source, _ = to_source(noise, channels, rate) + a = SlapbackDelay(source, sample_rate=rate, patch=0) + a.set_macro(WOW_I, 127) + pull(a, at) + if how == "reset": + a.reset() + else: + audiocore.reset_buffer(a.output) + a.set_macro(WOW_I, 127) + got = pull(a, rate // 2) + source, _ = to_source(noise[at:], channels, rate) + b = SlapbackDelay(source, sample_rate=rate, patch=0) + b.set_macro(WOW_I, 127) + want = pull(b, rate // 2) + key = (rate, channels, how) + self.assertEqual(int(np.count_nonzero(got != want)), 0, + key) + source, _ = to_source(noise, channels, rate) + c = SlapbackDelay(source, sample_rate=rate, patch=0) + c.set_macro(WOW_I, 127) + ran_on = pull(c, at + rate // 2)[at:] + differ = int(np.count_nonzero(ran_on[-1000:] + != want[-1000:])) + self.assertGreater(differ, 0.99 * 1000 * channels, + (rate, channels)) + def test_a_jumping_control_steps_the_output(self): # The matrix's E5 cell at patch 2 (Level to 0 and back) steps past # its bar; the class declares it (audiocomponents#117). From e76a031f2273cd2af5f1d10408fda98d3b682649 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:07:55 -0500 Subject: [PATCH 81/88] DigitalDelay, trial second fixer: Sync's two limits stated and tested (Time stops at 800 ms; the tempo is read only when a control moves or a patch loads), tail_samples' false high-pass mechanism struck --- lib/audioeffects/rebuilt/digitaldelay.py | 12 +-- tests/test_cpython_effects_digitaldelay.py | 91 +++++++++++++++++++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index 3ff47a7..b9287e7 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -12,8 +12,10 @@ click. Repeat Tone is a low-pass and Repeat Cut a high-pass inside the loop, so each repeat is a little darker or thinner than the last. Repeat Tone's top stop and Repeat Cut's bottom stop take them out. With Sync on, Time is -Division of the host's beat; with no host tempo, Time stays where the knob -is. +Division of the host's beat, up to 800 ms; with no host tempo, Time stays +where the knob is. The class reads the tempo only when a control moves or a +patch loads, so after a tempo change Time keeps the old beat until you move +a control. **The pedal.** Patch 5 puts the DD-2's 7 kHz and 40 Hz corners in the loop. The DD-2's compander and its HOLD are not here. @@ -589,10 +591,8 @@ def tail_samples(self): 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, whose - high-pass can more than double a peak in one pass, so the per-lap - argument does not hold; the node measures that case rather than - bounding it, and this class has no bound for it either. + (`laps_to_zero`). `None` with Repeat Cut in circuit: this class + derives no bound there. """ self._check_live() return self._tail_bound() diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index 50133bc..b2fda1b 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -2803,6 +2803,88 @@ def test_construction_needs_audioecho(self): DigitalDelay(silence_src(512), sample_rate=RATE).deinit() +class SyncLimits(unittest.TestCase): + """Second fixer (2026-09-29): Sync's two limits, at the attacker's + settings. A Division longer than 800 ms stops at 800 ms, and the tempo + is read only when a control moves or a patch loads.""" + + #: The sixteen Divisions in beats, written out here: 1/32, 1/16T, + #: 1/32D, 1/16, 1/8T, 1/16D, 1/8, 1/4T, 1/8D, 1/4, 1/2T, 1/4D, 1/2, + #: 1/1T, 1/2D, 1/1. + BEATS = (1 / 8, 1 / 6, 3 / 16, 1 / 4, 1 / 3, 3 / 8, 1 / 2, 2 / 3, + 3 / 4, 1, 4 / 3, 3 / 2, 2, 8 / 3, 3, 4) + + def test_divisions_stop_at_800_ms(self): + for bpm in (120.0, 60.0): + def transport(_bpm=bpm): + return (True, 0.0, _bpm, 4, 4) + clamped = 0 + for index, beats in enumerate(self.BEATS): + ideal = beats * 60.0 / bpm * RATE + expected = min(ideal, 0.8 * RATE) + measured, _ = t4_delay(DigitalDelay, transport=transport, + sync=127, + division=index * 127.0 / 15.0) + self.assertLessEqual(abs(measured - expected), 1.0, + (bpm, index)) + if expected < ideal: + clamped += 1 + self.assertGreater(abs(measured - ideal), 1.0) + self.assertEqual(clamped, 4 if bpm == 120.0 else 7, bpm) + + def _tempo_change(self, action=None, channels=2): + """Sync on, a quarter note, Glide 0, Feedback 0, Mix 2. The host + goes from 120 to 60 bpm at frame 24 064, `action(effect)` runs, and + an impulse follows at frame 48 000: the echo's distance.""" + at = 48000 + frames = at + 50000 + values = [0] * frames + values[at] = 32767 + tempo = [120.0] + + def transport(): + return (True, 0.0, tempo[0], 4, 4) + effect = DigitalDelay.create(array_src(values, channels), RATE, + transport=transport, feedback=0.0, + mix=2.0, glide_ms=0.0) + effect.set_macro(SYNC_I, 127) + effect.set_macro(DIVISION_I, 9 * 127.0 / 15.0) + + def change(frame): + if frame == 24064: + tempo[0] = 60.0 + if action is not None: + action(effect) + y = np.abs(left(pull(effect, frames, channels, on_block=change), + channels)) + effect.deinit() + y[at] = 0.0 + return int(np.argmax(y)) - at + + def test_a_tempo_change_waits_for_a_control(self): + self.assertEqual(self._tempo_change(), 24000) + self.assertEqual(self._tempo_change(channels=1), 24000) + # Any control moved, even to where it already is, reads the new + # tempo: a 60 bpm quarter, clamped to 800 ms. + for index in range(8): + def touch(effect, _index=index): + effect.set_macro(_index, effect.get_macro(_index)) + self.assertEqual(self._tempo_change(touch), 38400, index) + self.assertEqual(self._tempo_change(touch, channels=1), 38400) + # A patch load reads it too. Patch 2 syncs on a dotted eighth and + # glides, so the Time it hands the node is read, not rendered: + # 750 ms at 60 bpm, 375 ms at 120. + for bpm, frames in ((120.0, 18000), (60.0, 36000)): + tempo = [120.0] + effect = DigitalDelay.create( + silence_src(512), RATE, + transport=lambda: (True, 0.0, tempo[0], 4, 4)) + tempo[0] = bpm + effect.program_change(2) + self.assertEqual(effect._frames, frames, bpm) + effect.deinit() + + # -------------------------------------------------------------------------- # The docstring's claims @@ -2836,12 +2918,17 @@ def test_construction_needs_audioecho(self): ("Repeat Tone's top stop and Repeat Cut's bottom stop take them out.", ("TheSurface.test_filter_stops_are_exactly_zero", "T5BandLimit.test_an_open_top_stop_is_red")), - ("With Sync on, Time is Division of the host's beat; with no host " - "tempo, Time stays where the knob is.", + ("With Sync on, Time is Division of the host's beat, up to 800 ms; " + "with no host tempo, Time stays where the knob is.", ("T4TimeLaw.test_sync_quantises_time_into_the_map", + "SyncLimits.test_divisions_stop_at_800_ms", "T4TimeLaw.test_no_host_leaves_time_on_the_knob", "ZeroBpmHost.test_no_tempo_leaves_time_on_the_knob", "ZeroBpmHost.test_a_tempo_that_is_not_finite_leaves_time_on_the_knob")), + ("The class reads the tempo only when a control moves or a patch " + "loads, so after a tempo change Time keeps the old beat until you " + "move a control.", + ("SyncLimits.test_a_tempo_change_waits_for_a_control",)), ("Patch 5 puts the DD-2's 7 kHz and 40 Hz corners in the loop.", ("TrialClaims.test_patch_5_is_the_pedal_corners", "T5BandLimit.test_the_corners_at_48k")), From 21c50572d7e84ab67c9c751674ee6ce1efa82688 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:19:20 -0500 Subject: [PATCH 82/88] PingPongDelay under the trial of the second process at audiodsp v0.6.3rc3: the docstring cut to its claims with a CLAIMS table, tail_samples' clock stated and tested with settings moved mid-tail, the cross-feed stall cells (audiodsp#170) measured on this class, Mix's jump declared as the family limit --- lib/audioeffects/rebuilt/pingpongdelay.py | 295 +++---------- tests/support/lifecycle.py | 2 + tests/test_cpython_effects_pingpongdelay.py | 452 ++++++++++++++++++++ tests/test_lifecycle_matrix.py | 3 - 4 files changed, 517 insertions(+), 235 deletions(-) diff --git a/lib/audioeffects/rebuilt/pingpongdelay.py b/lib/audioeffects/rebuilt/pingpongdelay.py index 1878899..143ecde 100644 --- a/lib/audioeffects/rebuilt/pingpongdelay.py +++ b/lib/audioeffects/rebuilt/pingpongdelay.py @@ -1,227 +1,9 @@ """`PingPongDelay` - repeats that alternate between the speakers. -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/PingPongDelay.md`, whose trait -table was frozen at Station A before this file existed (anchor commit -eb466725d4422619d8e941c043f77512c1e5237b, the Station A critique's -re-freeze, 2026-09-28). The old class in `delay.py` is consulted only for -the seven defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. - -**What it sounds like.** Your dry signal passes untouched on both sides. -The first repeat comes back Time later on one side only, the side First -Side names; the next comes back Time after that on the other side, a -Feedback's worth quieter, and they keep bouncing, one side and then the -other, all the way down. Each side on its own repeats every two Times. -Time (20-1000 ms) is the spacing between one repeat and the next. Feedback -(0-0.99) is how many bounces you hear. Mix (0-2) is the wet/dry balance: -dry at unity up to 1, the repeats alone at 2, and Mix 0 is a wire while the -line keeps recording. Spread moves the whole thing between two plain -delays, one per side with the same repeats on both (0), and the full -bounce (1). Sync locks Time to Division of the host's beat. Repeat Tone -and Repeat Cut put a low-pass and a high-pass inside the loop, so each -bounce is a little darker or thinner than the last; both default out. - -**No standout.** A ping-pong is a routing of two delay lines, not a -circuit, and no product defines it (dossier section 2). The traits are the -textbook property of the topology, stated so a measurement can fail them. - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho",)`). The -cross-feed exists only on `audioecho.FeedbackDelay`; nothing a stock -CircuitPython board carries crosses one channel's repeats into the other's -line. On a stock board this module imports cleanly and construction raises -`ImportError`. - -**Latency: zero samples, at every setting and every rate.** Nothing looks -ahead. The dry path is a wire on both channels until the first repeat -arrives, and the repeats are the effect, not latency. No option adds any. - -**Mono.** A one-channel source gets the mono sum of the stereo behaviour: -an ordinary feedback delay at the same Time, Feedback and Mix, repeats at -T, 2T, 3T ... at gains 1, f, f^2 .... Spread and First Side do nothing on -a one-channel instance, because there is no second line to cross into: -the class hands the node `cross_feed` 0 and `input_pan` 0 there whatever -they say. (Handing it the stereo settings would silence the loop after one -half-level repeat, which is what the old class did.) On a stereo source -identical in both channels, with Mix at 2, the two channels summed are -that mono delay's output exactly, sample for sample, as long as no two -repeats overlap one another: on sustained material that overlaps its own -repeats each side rounds its own write where the mono delay rounds the sum -once, and the two part by a few LSB. That holds with both loop filters out -while the material and its repeats go round, whether or not a filter was -in before: a filter taken out is out exactly (below). With a -loop filter in, the material must also end some way before Time, because -each side's filter meets that side's next repeat two Times later where the -mono delay's meets the very next one: a noise burst ending one frame before -Time parts them in 295 samples by 1 LSB (Repeat Tone 800 Hz) and in 680 by -up to 2 LSB (Repeat Cut 400 Hz). How far before depends on the filter. -Measured at 48 kHz, Time 280 ms, Feedback 0.6, on noise up to 0 dBFS, the -gap after which every longer gap tried is exact: 512 frames for Repeat -Tone at 800 and 2500 Hz and Repeat Cut at 400 Hz (MIDI 127); for Repeat -Cut lower down, 1024 frames at MIDI 96 (the knob's 192.5 Hz), 2048 at -MIDI 64 (90.5 Hz), 4096 at MIDI 32 (42.5 Hz), and 8192 (171 ms) at MIDI 16 -(29.2 Hz) and at MIDI 1 (20.5 Hz), where 4096 still leaves 511 samples. -MIDI 1 needs 8192 at Feedback 0.99 too, 6144 at 44.1 kHz and 3072 at -22.05 kHz. - -**Spread's law.** Spread s hands the node `cross_feed` s and `input_pan` --s (First Side left) or +s (right). On a click identical in both channels, -repeat n reads f^(n-1) [(1 - s/2) + (s/2)(1 - 2s)^(n-1)] of the click on -the First Side channel and f^(n-1) [(1 - s/2) - (s/2)(1 - 2s)^(n-1)] on -the other. At Spread 0 the two channels are identical; at Spread 1 each -repeat is on one side only and the other side is exact zero. The law is -not claimed at any Time the node lands off the frame (below). There it -misses a 20 000 click at Feedback 0.99 by up to 289 LSB at a knob position -(MIDI 95; 145 LSB at 22.05 kHz), and by up to 575 LSB at a constructor or -Sync Time (750.0227 ms at 44.1 kHz; 289 LSB at 22.05 kHz). - -**What the loop hears.** At Spread 1 the loop is fed the average of the -two input channels, (L + R) / 2, into one line, so what the two channels -share bounces and what differs between them never repeats. A source whose -right channel is the left one upside down puts nothing in the loop: the -defaults pass it through untouched, and at Mix 2 the output from Time on -is silence, or within 1 LSB of it where the source saturates (R cannot be --L where L is -32768: a full-scale ramp leaves 1 LSB). The dry path is -always each channel's own signal, never swapped or summed. - -**Input ceiling.** The dry path sits at unity and the repeats add to it, -so a hot input can put the output on the int16 rail, and there is no input -gain to turn down. Measured on the kit's `noise_det` at 48 kHz over 20 s, -the defaults are clean up to -3 dBFS peak (at -2 they rail 2331 samples) -and every shipped patch up to -3.1 dBFS. Patch 4 (Spread 0, where each -side repeats every Time rather than every two) rails first: 76 samples at --3 dBFS, and 167 902 on a 997 Hz sine there; patch 3 rails 4. On any -material, with Repeat Cut out and Mix below 1, an input peaking at or -below one LSB under (1 - Mix) of full scale, floor(32767 (1 - Mix)) - 1, -cannot reach the rail at any Time, Feedback or Spread, because the lines -hold int16 and so the repeats never exceed Mix x full scale: -3.1 dBFS at -the default Mix 0.3 and at every patch. At exactly (1 - Mix) of full scale -the sum can round onto 32767, the rail value, though nothing is clipped. -Repeat Cut's high-pass overshoots a square wave's edges, so with it in -leave more room. Its output is the line's value less its own state, both -inside the int16 line, so a repeat can reach twice full scale: an input at -or below floor(32767 - 65535 Mix) - 1 cannot reach the rail at any Time, -Feedback, Spread or Cut, which is 13 105 LSB, -7.96 dBFS, at the default -Mix 0.3 (rendered on 108 square-wave cells and on noise at that level, -none railed). At louder inputs than that bound, the worst cell tried is a 5 Hz -square at Feedback 0.99 with Cut at 40 Hz: it rails 148 samples at --4.5 dBFS and 40 at -5, and is clean from -5.5 dBFS. At the defaults a -5 Hz square rails at -4 dBFS (396 samples with Cut at MIDI 1, 158 at -40 Hz) and is clean from -4.5. - -**RAM.** The line is `max_time_ms + 1` ms of two int16 lanes whatever the -channel count: 192 192 B at 48 kHz for the default 1000 ms (176 576 B at -44.1 kHz, 88 288 B at 22.05 kHz), plus about 1.2 KB of node. The extra -millisecond is what lets Time reach 1000 ms exactly. Pass a lower -`max_time_ms` to spend less (300 ms costs 57 792 B, 500 ms 96 192 B); -Time then stops at that ceiling and `get_macro(0)` shows where it stopped. - -**Cost.** One `audioecho.FeedbackDelay` with `delay_slew` on; no mixer. -Palette row FeedbackDelay +options (the nearest not-cheaper row; there is -no row for the slew alone), glue 0: **P4 <= 9 %, S3 <= 15 %** of a -5.333 ms stereo block. The board measurement is pending hardware. - -**Turning Time while it plays** walks the repeats to the new time at a -fixed 0.1875 delay-seconds per second instead of clicking, so every repeat -already in the loop bends in pitch while it moves: +297.5 cents while Time -falls and -359.5 cents while it rises (1200 log2(1 +- 0.1875)). 280 -> -200 ms takes 427 ms, the full range 5.23 s. There is no Glide knob; a -ping-pong's time is set to a subdivision, not played. While Time walks, -the line is read between samples, and the two-tap read costs the top of -the band sqrt(1 - 2 frac (1 - frac)(1 - cos 2 pi f / fs)) per pass. Every -static Time is handed to the node as the nearest whole frame at the -running rate, floor(ms fs / 1000 + 0.5); the knob's milliseconds and -`get_macro(0)` stay as you set them. At 48 kHz the node lands every one of -the 128 knob positions exactly on that frame, where the read is lossless, -so the repeats of a Time you have stopped turning do not darken. - -At 44.1 and 22.05 kHz it does not always. The node turns the milliseconds -back into frames in float32, and for some Times no float32 value lands on -the whole frame, so the read sits one float32 step off it. Among the 128 -knob positions that is 25 at 44.1 kHz (MIDI 2, 3, 4, 19, 20, 24, 25, 28, -38, 43, 47, 48, 50, 63, 65, 67, 69, 70, 83, 92, 93, 95, 107, 108, 114) -and 20 at 22.05 kHz (MIDI 2, 4, 19, 28, 38, 47, 63, 65, 67, 69, 70, 83, -88, 92, 93, 95, 108, 110, 112, 114), at most 1/512 of a frame off at -44.1 kHz and 1/1024 at 22.05 kHz. A constructor `time_ms` or a Time Sync -takes from a host's tempo reaches every whole frame from 20 to 1000 ms, -and 5554 of those 43 219 frames land off at 44.1 kHz and 2785 of 21 610 -at 22.05 kHz, up to 1/256 and 1/512 of a frame off (743.04-760.73 ms). -None do at 48 kHz. No patch's own Time is off, nor the default 280 ms. At -an off-frame Time each pass puts part of the repeat on the frame beside -it, and the repeats darken slowly. At a knob position that is up to 0.2 % -a pass (a 20 000 click's first repeat reads 19 961 and 39 at MIDI 95, -44.1 kHz), and at Feedback 0.99 the 60th repeat of a 10 kHz tone is -0.86 dB quieter than the Feedback alone makes it at 44.1 kHz and 0.98 dB -at 22.05 kHz (a 1 kHz tone 0.01-0.02 dB). At a constructor or Sync Time it -is up to 0.39 % a pass (19 922 and 78 at 750.0227 ms, 44.1 kHz) and -1.71 dB (44.1 kHz) and 1.96 dB (22.05 kHz) at the 60th repeat. The class -cannot hand the node a number that lands there; a node change is asked -for. - -**Repeat Tone** is the corner the loop low-pass achieves (800-16 000 Hz, -the top stop out). It is inside the loop, so repeat n has passed it n -times: on one side, each repeat is two passes darker than the last one -there. With Repeat Tone in, a repeat's peak also lands late by the -filter's group delay, more each pass. At a low rate the knob's corners -clamp below Nyquist: at 22.05 kHz positions 111-126 all sit on the -10 804.5 Hz clamp and do the same thing. - -**The filters' out stops, after a filter has been in.** Both out stops -hand the node exactly 0 and the Feedback as set, whatever came before, and -a filter taken out is out. Taking it out does not undo what it already did -to the repeats going round; those stay filtered. Once they have died, the -instance plays exactly as one whose filter was never in: 1 s of 0 dBFS -noise with Tone 2 kHz or Cut 400 Hz in, the filter out as the noise stops, -then fresh noise once the loop is empty, renders the same bytes from the -fresh noise on as an instance that never had the filter (Time 20 ms, -Feedback 0.5 and 0.85, Mix 2, at 48, 44.1 and 22.05 kHz, stereo and mono). -Put straight to its out stop before any audio, a filter that has been in -renders the same bytes as one never in (Tone: 2 s of 0 dBFS noise at -Feedback 0.85 and 0.99 at the three rates, and at Time 20 ms and Feedback -0.99 through the whole tail at 48 kHz; Cut: 2 s at 0.99, three rates). -A filter brought back in after the repeats have died plays nothing. Up to -audiodsp v0.6.2 the node froze an out filter's state and played it back, -and this class kept Tone's low-pass tracking the repeats and Cut's -high-pass in at 20 Hz once they had been in (the first up to 50 LSB off -the filter out at Feedback 0.99, the second with `tail_samples` `None`). -Since v0.6.3rc1 the node keeps an out low-pass's state on the signal and -an out high-pass's at zero (audiodsp#158, #159), and both cures came out. -With Cut out again, `tail_samples` is the bound Cut never in has, and a -tail after Cut was in ends inside it (full-scale noise with Cut at 400 Hz -or MIDI 1, Cut out as it stops: at most 0.9990 of the bound, Feedback -0.45, 0.85 and 0.99, Spread 1, 0.5 and 0, three rates). - -Each pass through a loop filter also takes something off a repeat's peak, -so with either filter in the late repeats of a quiet bounce fade faster -than Feedback alone says: at Feedback 0.52 (MIDI 67) with Repeat Cut at -400 Hz the 8th repeat of a 20 000 click is 143 LSB at 48 kHz. - -**Tail.** `tail_samples` is an upper bound on how long the output takes to -reach exact zero after your input stops, and it is long: the loop rounds -its way down from full scale, 14 laps at the default Feedback (188 174 -frames, 3.9 s, at 48 kHz) and 685 at 0.99 (11.4 minutes at Time 1000 ms). -The cross-feed moves repeats between the sides without changing the loop -gain, so the figure is the same at every Spread. With Repeat Tone in, at a -Feedback a hair either side of 1 - 0.5 / k, the loop low-pass can come to -rest a hair above k LSB and hand it back; up to audiodsp v0.6.2 it did so -for ever and the class handed the node a Feedback just outside that -window. Since v0.6.3rc1 the node sets a stalled low-pass onto its input -(audiodsp#157), the Feedback you set is the one the node plays, and the -bound counts one more lap there. With Repeat Cut in circuit -`tail_samples` is `None`: no bound is derived there. - -`capabilities = ("tempo_sync",)`: with Sync on, the class reads -`self._transport()` on every macro move and program change (not per -block). With no host transport, or a host whose tempo is not a finite -positive number, Time stays where the knob is. A Division past the -1000 ms ceiling (or `max_time_ms`) clamps there, and `get_macro(0)` shows -it. - -A constructor value stays on the audio path unrounded by the knob's grid; -Time is then handed as a whole frame. A value outside a knob's span clamps -to the nearer stop, a `tone_hz` or `cut_hz` of 0 or less is that filter -out, and NaN takes that option's default. A `max_time_ms` above 1000 or -NaN is 1000. +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/PingPongDelay.md`). """ VENDOR = "PyDevices" @@ -320,16 +102,65 @@ def _side(value): class PingPongDelay(_component.Component): - """Two delay lines crossed into each other: the repeats alternate - between the speakers, one side and then the other. audiodsp tier; zero - latency. - - **What the default surrenders:** both loop filters are out, so the - bounce does not darken on its own; Time walks rather than jumps, and a - walk bends the repeats' pitch while it moves; at full Spread what - differs between the two input channels never repeats; on a mono source - Spread and First Side do nothing; and there is no input gain, so an - input above -3 dBFS peak can reach the rail. + """Two delay lines crossed into each other: the repeats bounce between the + speakers. + + Your dry signal passes untouched on both sides, and the repeats come + back one side and then the other, all the way down. + + **The controls.** The first repeat comes back Time later on the side + First Side names, the next Time after that on the other side, and they + keep bouncing, each a Feedback's worth quieter than the last. Time runs + from 20 to 1000 ms and Feedback from 0 to 0.99. Mix is the echo level: + the dry stays at unity up to Mix 1, Mix 2 is the repeats alone, and at + Mix 0 the output is the input. Spread moves between two plain delays + with the same repeats on both sides, at 0, and the full bounce, at 1. At + Spread 1 each repeat is on one side only, and the other side is exact + zero. With Sync on, Time is Division of the host's beat, up to 1000 ms; + with no host tempo, Time stays where the knob is. The class reads the + host's transport only while Sync is on, and then only when a control + moves or a patch loads, never while it plays. Repeat Tone is a low-pass + and Repeat Cut a high-pass inside the loop, so each bounce is a little + darker or thinner than the last. Repeat Tone's top stop and Repeat Cut's + bottom stop take them out, and a filter taken out is out. At 22.05 kHz + the top positions of Repeat Tone sit on one clamp below Nyquist and + sound the same. Turning Time walks the repeats to the new Time, bending + their pitch, instead of clicking. + + **Stereo and mono.** At Spread 1 the loop hears the average of the two + input channels, so what differs between them never repeats. The dry is + always each channel's own signal, never swapped or summed. A one-channel + source gets an ordinary feedback delay at the same Time, Feedback and + Mix, and Spread and First Side do nothing there. + + **Where it stops.** At 48 kHz every Time position lands on the nearest + whole frame. At 44.1 and 22.05 kHz the node lands some positions a + fraction of a frame off, and a sliver of each repeat falls on the frame + beside it. The dry sits at unity and the repeats add to it, so a hot + input can reach the int16 rail. With Repeat Cut out and Mix below 1, an + input that peaks at or below floor(32767 (1 - Mix)) - 1 cannot reach the + rail, at any Time, Feedback or Spread. Repeat Cut's high-pass + overshoots, so with it in leave more room. + + **Limits shared by the family.** A control that jumps makes the output + step: move it in small steps from the host if you need it smooth. The + tail rings only while the source keeps feeding: feed silence to let it + ring out. A tail cut short by a source that stopped carries on when the + source comes back. + + **Latency, tail, portability.** A click comes out on the frame it went + 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 + `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 + less is that filter out, and NaN takes the option's default. `reset()` + empties the line and returns to patch 0. The class needs audiodsp's + `audioecho`, and on a board without it construction raises + `ImportError`. """ NAME = 'PingPongDelay' diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..263a540 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -75,6 +75,8 @@ #: cell prints `decl` instead of RED and is counted as a declared exception. #: Nothing else skips a cell. Empty on purpose: no class has declared one. DECLARED = { + ("PingPongDelay", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index a5ba5d9..44b8f4b 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -2128,5 +2128,457 @@ def test_every_row_is_red_on_a_wire(self): self.assertTrue(result["control"]["passed"], name) + +# -------------------------------------------------------------------------- +# The trial of the second process: the tests the docstring's claims and the +# re-audit's restatements needed that the rows above did not already give + + +class HeldFloorPingPong(PingPongDelay): + """The pre-v0.6.3rc3 cross-feed stall (audiodsp#170), planted on the + output: once a channel has read `held` LSB it never reads less, so the + tail sits on a DC of `held` for ever.""" + + NAME = 'PingPongDelay' + held = 1 + + def _build(self, *arguments, **options): + PingPongDelay._build(self, *arguments, **options) + self._output = _HoldFloor(self._delay, type(self).held) + + +class _HoldFloor(kit_faults._Node): + def __init__(self, source, held): + kit_faults._Node.__init__(self, source) + self.held = held + self._sign = [0] * source.channel_count + + def _process(self, frames): + ch = self.channel_count + x = frames.reshape(-1, ch).copy() + for c in range(ch): + col = x[:, c] + if not self._sign[c]: + hit = np.flatnonzero(np.abs(col) == self.held) + if len(hit): + self._sign[c] = 1 if col[hit[0]] > 0 else -1 + tail = col[hit[0]:] + low = np.abs(tail) < self.held + tail[low] = self._sign[c] * self.held + continue + low = np.abs(col) < self.held + col[low] = self._sign[c] * self.held + return x.reshape(-1) + + +def _midtail(ctor, moves, at=2048, rate=RATE): + """A full-scale DC burst of 50 ms in both channels at Mix 2, then + silence; `moves` made `at` frames into the silence. Returns + (`tail_samples` read as the input stops, `tail_samples` read just after + the moves, frames from the moves to the output's last non-zero frame). + The render runs to the larger of the two bounds plus a second.""" + burst = int(0.05 * rate) // BLOCK * BLOCK + move = burst + at + options = dict(ctor, mix=2.0) + probe = PingPongDelay(silence_src(64, rate=rate), sample_rate=rate, + **options) + for index, value in moves: + probe.set_macro(index, value) + longest = max(probe.tail_samples, 13454) + rate + probe.deinit() + frames = move + longest + BLOCK + values = np.zeros(frames) + values[:burst] = 32767 + source, _ = to_source(values, 2, rate) + effect = PingPongDelay(source, sample_rate=rate, **options) + seen = {} + + def on_block(frame): + if frame == burst: + seen["before"] = effect.tail_samples + if frame == move: + for index, value in moves: + effect.set_macro(index, value) + seen["after"] = effect.tail_samples + + y = pull(effect, frames, 2, on_block) + effect.deinit() + nonzero = np.flatnonzero(y[move:].any(axis=1)) + last = int(nonzero[-1]) + 1 if len(nonzero) else 0 + return seen["before"], seen["after"], last + + +class TrialClaims(unittest.TestCase): + def test_the_knob_spans(self): + effect = PingPongDelay(silence_src(64), sample_rate=RATE) + for index, low, high in ((TIME_I, 20.0, 1000.0), + (FEEDBACK_I, 0.0, 0.99), + (MIX_I, 0.0, 2.0), (SPREAD_I, 0.0, 1.0)): + effect.set_macro(index, 0) + self.assertAlmostEqual(effect._value(index), low, places=9) + effect.set_macro(index, 127) + self.assertAlmostEqual(effect._value(index), high, places=9) + effect.set_macro(FEEDBACK_I, 127) + self.assertEqual(effect._feedback, 0.99) + effect.set_macro(FEEDBACK_I, 0) + self.assertEqual(effect._feedback, 0.0) + + def test_mix_2_is_the_repeats_alone(self): + # A click at Mix 2: nothing until the repeat, Time later. + T = law_frames(100.0, RATE) + y = render(PingPongDelay, click(2 * T), mix=2.0, time_ms=100.0, + feedback=0.0) + self.assertEqual(int(np.count_nonzero(y[:T])), 0) + self.assertEqual(int(y[T, 0]), 20000) + + HZ = 997.0 + LEVEL = 8000.0 + + def _bar(self): + """1.5 x the tone's own largest step.""" + t = np.arange(4096) / RATE + x = np.round(self.LEVEL * np.sin(2.0 * math.pi * self.HZ * t)) + return 1.5 * float(np.max(np.abs(np.diff(x)))) + + def _mix_moves(self, steps): + """The tone at Time 987 frames (the repeat half a cycle off the + dry), Feedback 0, Mix from MIDI 0 to 127 in `steps` equal moves, one + a block from frame 20 480; the largest step in the output from + there.""" + frames = 20480 + (steps + 8) * BLOCK + t = np.arange(frames) / RATE + values = self.LEVEL * np.sin(2.0 * math.pi * self.HZ * t) + source, _ = to_source(values) + effect = PingPongDelay(source, sample_rate=RATE, + time_ms=987 * 1000.0 / RATE, feedback=0.0, + mix=0.0) + + def move(frame): + k = (frame - 20480) // BLOCK + 1 + if frame >= 20480 and k <= steps: + effect.set_macro(MIX_I, 127.0 * k / steps) + + y = pull(effect, frames, 2, move)[:, 0].astype(np.int32) + effect.deinit() + return float(np.max(np.abs(np.diff(y[20479:])))) + + def test_a_jump_steps_and_small_steps_do_not(self): + # The family limit (audiocomponents#117), the matrix's E4-m2=127 + # P5 cells: Mix 0 -> 2 in one move steps the output; the same move + # in 127 steps from the host, one a block, stays under the bar. + self.assertGreater(self._mix_moves(1), 4.0 * self._bar()) + self.assertLess(self._mix_moves(127), self._bar()) + + def _tail_across_a_stop(self, stop): + """A 50 ms tone burst into Time 100 ms, Feedback 0.5, Mix 2, then + silence; after 24 pulls the source hands empty buffers for `stop` + pulls, then silence again. Returns (the bytes handed while it was + stopped, the 40 blocks pulled after it came back).""" + import lifecycle + tone = np.round(self.LEVEL * np.sin( + 2.0 * math.pi * self.HZ * np.arange(2400) / RATE)).astype(int) + burst = array("h") + for v in tone: + burst.extend((int(v), int(v))) + feed = lifecycle.Feed(burst, RATE, 2, "256", False) + effect = PingPongDelay(feed.port, sample_rate=RATE, time_ms=100.0, + feedback=0.5, mix=2.0) + for _ in range(24): + audiocore.get_buffer(effect.output) + feed.point(feed.empty) + stopped = bytearray() + for _ in range(stop): + stopped.extend(bytes(audiocore.get_buffer(effect.output)[1])) + feed.point(feed.sil) + after = bytearray() + while len(after) < 40 * BLOCK * 4: + after.extend(bytes(audiocore.get_buffer(effect.output)[1])) + effect.deinit() + return bytes(stopped), bytes(after[:40 * BLOCK * 4]) + + def test_the_tail_waits_for_the_source(self): + # The family limit (audiodsp#180): a source that hands empty + # buffers stops the tail; when it feeds again the tail carries on + # where it was, as if the stop had not happened. + stopped, after = self._tail_across_a_stop(30) + self.assertEqual(stopped.strip(b"\x00"), b"") + self.assertGreater(max(abs(v) for v in array("h", after)), 1000) + self.assertEqual(after, self._tail_across_a_stop(0)[1]) + + def test_tail_samples_holds_for_the_settings_as_they_stand(self): + # Re-audit 1's first restatement: the bound counts from when it is + # read, for the settings as they stand. Each row moves a setting + # 2 048 frames into the silence and reads `tail_samples` after it: + # the output is exact zero within that many frames of the move. + rows = (({"time_ms": 20.0}, [(FEEDBACK_I, 127)]), + ({"time_ms": 20.0, "feedback": 0.99}, [(FEEDBACK_I, 0)]), + ({"time_ms": 20.0, "feedback": 0.99}, [(FEEDBACK_I, 64)]), + ({"time_ms": 20.0, "feedback": 0.85}, [(TIME_I, 30)]), + ({"time_ms": 60.0, "feedback": 0.85}, [(TIME_I, 0)]), + ({"time_ms": 20.0, "feedback": 0.85}, [(TONE_I, 0)]), + ({"time_ms": 20.0, "feedback": 0.85, "tone_hz": 800.0}, + [(TONE_I, 127)]), + ({"time_ms": 20.0, "feedback": 0.85}, [(SPREAD_I, 39)]), + ({"time_ms": 20.0, "feedback": 0.85, "cut_hz": 400.0}, + [(CUT_I, 0)])) + for ctor, moves in rows: + before, after, last = _midtail(ctor, moves) + self.assertGreater(last, 0, (ctor, moves)) + self.assertIsNotNone(after, (ctor, moves)) + self.assertLessEqual(last, after, (ctor, moves, before, after)) + # The value read before a Feedback move up does not hold after it. + before, after, last = _midtail({"time_ms": 20.0}, + [(FEEDBACK_I, 127)]) + self.assertGreater(last, before) + self.assertGreater(after, before) + + #: (typed feedback, Spread, k): where the pre-rc3 node's float32 + #: cross-feed sum handed k back on both lanes for ever (audiodsp#170). + #: The first is the re-audit's typed cell; the rest are the cells the + #: fixer's probe (`trial/PingPongDelay/stall_probe.py` in the workspace) + #: finds held under every order of the sum, one per Feedback. No + #: Feedback knob position is among them. + CROSS_FEED_CELLS = ((0.9899999, 39.0 / 127.0, 50), + (0.9899999, 2.0 / 127.0, 50), + (0.9666666, 37.0 / 127.0, 15), + (0.9827586, 38.0 / 127.0, 29), + (0.9833333, 43.0 / 127.0, 30), + (0.9838709, 42.0 / 127.0, 31)) + + def _cross_feed_tail(self, cls, feedback, spread, k): + """Stereo, 48 kHz, Time 20 ms, Mix 2: a DC of 2k + 2 LSB in both + channels for four laps, then silence to `tail_samples` plus a lap. + Returns (declared, the last non-zero frame after the fill, the + largest |sample| past `tail_samples`).""" + options = {"time_ms": 20.0, "feedback": feedback, "mix": 2.0, + "spread": spread} + probe = PingPongDelay(silence_src(64), sample_rate=RATE, **options) + declared = probe.tail_samples + probe.deinit() + fill = 4 * 960 // BLOCK * BLOCK + BLOCK + values = [2 * k + 2] * fill + [0] * (declared + 960 + BLOCK) + source, _ = to_source(values) + cls.held = k + effect = cls(source, sample_rate=RATE, **options) + out = pull(effect, len(values), 2).astype(np.int64) + effect.deinit() + after = out[fill:] + nonzero = np.flatnonzero(after.any(axis=1)) + last = 0 if nonzero.size == 0 else int(nonzero[-1]) + 1 + return declared, last, int(np.max(np.abs(after[declared:]))) + + def test_the_cross_feed_stall_cells_reach_zero(self): + # Re-audit 1's third item: this class hands Spread as `cross_feed`, + # and up to audiodsp v0.6.3rc2 these typed cells held k LSB on both + # lanes for ever. At v0.6.3rc3 the node ends them (#170): the tail + # is exact zero inside `tail_samples`. Planted: a tail that holds a + # DC of k once it gets there, red at every cell. + for feedback, spread, k in self.CROSS_FEED_CELLS: + key = (feedback, spread) + effect = PingPongDelay(silence_src(64), sample_rate=RATE, + feedback=feedback, spread=spread) + self.assertEqual(float(np.float32(effect._feedback)), + float(np.float32(feedback)), key) + effect.deinit() + declared, last, past = self._cross_feed_tail( + PingPongDelay, feedback, spread, k) + self.assertGreater(last, 0, key) + self.assertLessEqual(last, declared, key) + self.assertEqual(past, 0, key) + declared, last, past = self._cross_feed_tail( + HeldFloorPingPong, feedback, spread, k) + self.assertGreater(last, declared, key) + self.assertEqual(past, k, key) + + def test_reset_returns_to_patch_0(self): + effect = PingPongDelay(silence_src(512), sample_rate=RATE, patch=5) + effect.set_macro(SPREAD_I, 10) + effect.reset() + self.assertEqual(effect.patch_index, 0) + for index, midi in enumerate(PingPongDelay.PATCHES[0][1]): + self.assertAlmostEqual(effect.get_macro(index), midi, delta=0.6) + + def test_construction_needs_audioecho(self): + saved = sys.modules.get("audioecho", False) + saved_attr = pp.audioecho + sys.modules["audioecho"] = None + pp.audioecho = None + try: + with self.assertRaises(ImportError): + PingPongDelay(silence_src(512), sample_rate=RATE) + finally: + pp.audioecho = saved_attr + if saved is False: + del sys.modules["audioecho"] + else: + sys.modules["audioecho"] = saved + + +# -------------------------------------------------------------------------- +# The docstring's claims + +#: Every claim the class docstring makes, word for word, and the tests that +#: assert it ("Class.test_name", in this file). +CLAIMS = ( + ("Two delay lines crossed into each other: the repeats bounce between " + "the speakers.", + ("T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates",)), + ("Your dry signal passes untouched on both sides, and the repeats come " + "back one side and then the other, all the way down.", + ("T5DryPath.test_the_defaults_stereo_and_mono", + "T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates")), + ("The first repeat comes back Time later on the side First Side names, " + "the next Time after that on the other side, and they keep bouncing, " + "each a Feedback's worth quieter than the last.", + ("T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates", + "T1RepeatsAlternate.test_the_time_stops_and_the_patch_cells", + "T2OneDecayRatio.test_the_constructor_feedbacks_at_the_named_times", + "T4SpreadLaw.test_first_side_right_is_the_swap_at_every_position")), + ("Time runs from 20 to 1000 ms and Feedback from 0 to 0.99.", + ("TrialClaims.test_the_knob_spans", + "TheSurface.test_time_lands_on_a_whole_frame")), + ("Mix is the echo level: the dry stays at unity up to Mix 1, Mix 2 is " + "the repeats alone, and at Mix 0 the output is the input.", + ("T5DryPath.test_the_named_cells", + "TrialClaims.test_mix_2_is_the_repeats_alone", + "Tier1Fast.test_mix_zero_is_a_wire_on_the_full_scale_ramp")), + ("Spread moves between two plain delays with the same repeats on both " + "sides, at 0, and the full bounce, at 1.", + ("T4SpreadLaw.test_the_eleven_positions", + "T3MonoSum.test_spread_0_is_twice_the_reference")), + ("At Spread 1 each repeat is on one side only, and the other side is " + "exact zero.", + ("T1RepeatsAlternate.test_the_three_feedbacks_at_three_rates",)), + ("With Sync on, Time is Division of the host's beat, up to 1000 ms; " + "with no host tempo, Time stays where the knob is.", + ("TheSurface.test_sync_quantises_time_and_clamps",)), + ("The class reads the host's transport only while Sync is on, and then " + "only when a control moves or a patch loads, never while it plays.", + ("Tier1Fast.test_the_transport_is_read_only_with_sync_on", + "TheSurface.test_sync_patches_follow_the_beat")), + ("Repeat Tone is a low-pass and Repeat Cut a high-pass inside the " + "loop, so each bounce is a little darker or thinner than the last.", + ("T3MonoSum.test_the_loop_filters_read_exact",)), + ("Repeat Tone's top stop and Repeat Cut's bottom stop take them out, " + "and a filter taken out is out.", + ("TheSurface.test_the_filter_stops", + "Tier1Fast.test_a_filter_really_in_then_out_is_out")), + ("At 22.05 kHz the top positions of Repeat Tone sit on one clamp below " + "Nyquist and sound the same.", + ("TheSurface.test_repeat_tone_clamps_at_22k",)), + ("Turning Time walks the repeats to the new Time, bending their pitch, " + "instead of clicking.", + ("TheSurface.test_the_walk_bends_by_the_law_and_the_jump_does_not",)), + ("At Spread 1 the loop hears the average of the two input channels, so " + "what differs between them never repeats.", + ("T5DryPath.test_an_antiphase_source_does_not_repeat_at_spread_1",)), + ("The dry is always each channel's own signal, never swapped or " + "summed.", + ("T5DryPath.test_never_spread_on_independent_channels",)), + ("A one-channel source gets an ordinary feedback delay at the same " + "Time, Feedback and Mix, and Spread and First Side do nothing there.", + ("T3MonoSum.test_the_named_pairs_at_three_rates", + "TheSurface.test_spread_and_first_side_hand_the_pair")), + ("At 48 kHz every Time position lands on the nearest whole frame.", + ("TheSurface.test_where_the_node_lands_the_handed_frame",)), + ("At 44.1 and 22.05 kHz the node lands some positions a fraction of a " + "frame off, and a sliver of each repeat falls on the frame beside it.", + ("TheSurface.test_where_the_node_lands_the_handed_frame", + "TheSurface.test_an_off_frame_time_leaks_into_the_next_frame")), + ("The dry sits at unity and the repeats add to it, so a hot input can " + "reach the int16 rail.", + ("InputCeiling.test_the_stated_ceiling_is_clean_and_1_db_over_is_not",)), + ("With Repeat Cut out and Mix below 1, an input that peaks at or below " + "floor(32767 (1 - Mix)) - 1 cannot reach the rail, at any Time, " + "Feedback or Spread.", + ("InputCeiling.test_the_any_material_bound",)), + ("Repeat Cut's high-pass overshoots, so with it in leave more room.", + ("InputCeiling.test_repeat_cut_needs_more_room", + "InputCeiling.test_the_cut_in_ceiling")), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + ("TrialClaims.test_a_jump_steps_and_small_steps_do_not",)), + ("The tail rings only while the source keeps feeding: feed silence to " + "let it ring out.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("A tail cut short by a source that stopped carries on when the source " + "comes back.", + ("TrialClaims.test_the_tail_waits_for_the_source",)), + ("A click comes out on the frame it went in: there is no latency.", + ("Tier1Fast.test_click_delay_is_zero", + "TheSurface.test_macros_patches_tier_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.", + ("TrialClaims.test_tail_samples_holds_for_the_settings_as_they_stand", + "TrialClaims.test_the_cross_feed_stall_cells_reach_zero", + "Tier1Fast.test_the_tail_reaches_exact_zero_inside_tail_samples", + "Tier1Fast.test_a_full_scale_fill_at_every_spread", + "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.", + ("TheSurface.test_tail_samples_follows_time_feedback_and_tone", + "TheSurface.test_the_filter_stops")), + ("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",)), + ("A constructor value outside a knob's span clamps to the nearer stop, " + "a `tone_hz` or `cut_hz` of 0 or less is that filter out, and NaN " + "takes the option's default.", + ("TheSurface.test_constructor_clamps_and_nan",)), + ("`reset()` empties the line and returns to patch 0.", + ("Tier1Fast.test_reset_silences_a_full_line", + "TrialClaims.test_reset_returns_to_patch_0")), + ("The class needs audiodsp's `audioecho`, and on a board without it " + "construction raises `ImportError`.", + ("TrialClaims.test_construction_needs_audioecho",)), +) + +FAMILY_HEADING = "**Limits shared by the family.**" + + +def _flat(text): + return " ".join(text.split()) + + +def claim_problems(doc, claims=CLAIMS): + """What is wrong between a docstring and `claims`: a sentence missing, a + named test that does not exist, a figure outside every claim.""" + doc = _flat(doc) + problems = [] + rest = doc + for sentence, tests in claims: + if sentence not in doc: + problems.append("missing: %s" % sentence) + rest = rest.replace(sentence, " ") + for name in tests: + owner, _, test = name.partition(".") + if not hasattr(globals().get(owner), test): + problems.append("no test %s" % name) + for word in rest.split(): + if any(c.isdigit() for c in word): + problems.append("figure outside a claim: %s" % word) + return problems + + +class Claims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + self.assertEqual(claim_problems(PingPongDelay.__doc__), []) + self.assertEqual(claim_problems(pp.__doc__, ()), []) + self.assertIn(FAMILY_HEADING, _flat(PingPongDelay.__doc__)) + # The checker can fail: a figure outside a claim, a claim the + # docstring does not carry, a test that does not exist. + self.assertTrue(claim_problems(PingPongDelay.__doc__ + + " It reads 12 ms.")) + self.assertTrue(claim_problems(_flat(PingPongDelay.__doc__).replace( + "there is no latency", "there is little latency"))) + self.assertTrue(claim_problems(PingPongDelay.__doc__, CLAIMS + ( + ("A click comes out on the frame it went in: there is no " + "latency.", ("Tier1Fast.test_nothing_here",)),))) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..3ebf9ed 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -76,9 +76,6 @@ class this branch carries, on CPython and on the two native interpreters. ('TapeDelay', 'E8-dry', 'P3'): ( 2, '1da480f7', 2, '1da480f7', 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('PingPongDelay', 'E4-m2=127', 'P5'): ( - 4, '31c4e1c7', 32, 'c9e4e06a', - 'Mix to 127 steps the output within one block'), ('MultiTapDelay', 'E', 'P3'): ( 20, '273472a7', None, None, 'CPython only: a Time move or a patch change and back plays old audio out of silence (see KNOWN_P6)'), From 6f82897cb2023288f487079ea63cd3e12b4321d2 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:25:50 -0500 Subject: [PATCH 83/88] ConvolutionReverb trial of the second process at audiodsp v0.6.3rc3: the player's docstring with CLAIMS, the matrix's red cells declared The docstring keeps only what holds in every case and has a test: no dry frame drops or repeats at a room move, every move has landed from the end of the block in flight, reset() silences that block. The room-move mechanisms, the latency exceptions, the tail's counting clause and the figures are struck (dossier note). New tests: a reset while the node holds part of a source buffer keeps its whole frames (ReplugOnReset red), the empty impulse is a wire whatever Mix and reset do, a host reset_buffer silences the block and drops what the node held, and what each knob does. The class's matrix cells are declared (reset and reset_buffer silence the block in flight, Mix lands a block late, the family's two limits); the audio path is AST-identical to cf17a88. --- CHANGELOG.md | 36 +- lib/audioeffects/rebuilt/convolutionreverb.py | 282 ++----- tests/support/lifecycle.py | 26 + .../test_cpython_effects_convolutionreverb.py | 754 ++++++++++-------- tests/test_lifecycle_matrix.py | 18 - 5 files changed, 553 insertions(+), 563 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33609d6..c324dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,27 +100,19 @@ there, and are recorded in its changelog. 0.06 s floor to the node's 512-partition ceiling, and anything outside raises naming the class, the taps, the rate and the limit in seconds. `latency_samples` reads the node: 256 frames whenever an impulse is - loaded, 0 when none is, and Mix 0 is the source delayed by exactly that, - byte for byte, except for the frames a mid-stream `reset()` silences - and a pull the source leaves without a single frame, which comes out - as 256 frames of silence and puts what follows 256 frames later. - Measured mode loads at unit mean energy and refuses the - synthesis macros with `IndexError`; no impulse ships. It lives in - `lib/audioeffects/rebuilt/convolutionreverb.py`, and + loaded, 0 when none is, and held at Mix 0 the output is the source + delayed by exactly that, byte for byte, while the source keeps feeding + it and nothing resets it. Measured mode loads at unit mean energy and + refuses the synthesis macros with `IndexError`; no impulse ships. It + lives in `lib/audioeffects/rebuilt/convolutionreverb.py`, and `audioeffects.ConvolutionReverb` is still the old class. It stands on - audiodsp v0.6.3rc2: moving a room knob changes the room within the - block in flight, with no frame of the dry dropped or repeated at any Mix - and the tail ringing on into the new room (a straight-line crossfade - when it is the only room change between two pulls and the source has - not come up short (an empty buffer, one shorter than a frame, or an - error result) part-way through a block since the last `reset()`; - otherwise the output can jump), and each side of a stereo room - is unit energy on its own, so the room sits in the middle (the two - sides within 0.001 dB on the room's own impulse over the settings - walked). The docstring states the low-material lift a dark room gives - and the frames a mid-stream `reset()` silences. Board cost is unmeasured; - by the cost table's line the default fits an S3, and - anything over 0.091 s there is desktop-only. + audiodsp v0.6.3rc3: a room-knob move never drops or repeats a dry frame + at any Mix, from the end of the block in flight the output is that of an + instance that always had the new settings, and each side of a stereo + room is unit energy on its own, so the room sits in the middle. + `reset()` mid-stream silences the block in flight, dry included, and a + Mix move lands at the end of it. The docstring is the player's text, + every claim in it tied to a test. Board cost is unmeasured. - **`MultiTapDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** the RE-201's multi-head modes, with the Echorec as a second reference. Heads sit on a grid of whole multiples of one base time, exactly, at every @@ -171,9 +163,7 @@ there, and are recorded in its changelog. - **The audiodsp pin moves to v0.6.3rc2.** The release adds audiodsp#165 to rc1: `audioconvolve.Convolver` keeps the audio in flight when a room is re-synthesized (#163: nothing drops, the tail rings on, and the block - on its way out crossfades to the new room, in a straight line when that - is its only re-synthesis and the node's source has not run dry - part-way through a block since the last reset) and normalises each + on its way out crossfades to the new room) and normalises each side of a stereo room on its own (#164: the room no longer leans, and the pair's level holds). Mono rooms and measured impulses render the bytes they did. `ConvolutionReverb`, rebuilt and old, synthesizes diff --git a/lib/audioeffects/rebuilt/convolutionreverb.py b/lib/audioeffects/rebuilt/convolutionreverb.py index 9ee7318..0d48b69 100644 --- a/lib/audioeffects/rebuilt/convolutionreverb.py +++ b/lib/audioeffects/rebuilt/convolutionreverb.py @@ -1,202 +1,9 @@ """`ConvolutionReverb` - a room made by convolution, synthesized or loaded. -Rebuilt from scratch for Phase 5 against -`workspace docs/effects-internal/dossiers/ConvolutionReverb.md`, whose trait -table was frozen at Station A before this file existed (anchor commit -85cc2bfc9a5a3c34c6906fbf0c27b285ba1050d2, the Station A critique's -re-freeze, 2026-09-28). The old class in `reverb.py` is consulted only for -the six defects that dossier's section 7 names; it stays the class the -library serves until the board runner adopts this one. The dossier's dated -post-build revisions (2026-09-28) record what each audit round changed in -the words and tests; the audio did not change in any of them. Since the -re-audit at audiodsp v0.6.3rc2 the class stands on the fixed convolution -node (audiodsp#165): a room-knob move no longer drops the block in flight -or stops the tail (#163), and each side of a stereo room is normalised on -its own, so the room no longer leans (#164). The two sentences that -disclosed those defects are gone, and every stereo figure below was read -again on the fixed node. - -**What it sounds like.** A short room behind your dry signal. With nothing -loaded the class synthesizes the room: noise under an exponential that -reaches -60 dB at the Decay time, a one-pole Damping roll-off on the tail, -a Predelay of silence before it, a Diffusion fade-in so it does not start -as a burst, and Room picks one of 64 noise seeds (two seeds are two rooms -of one size). Mix runs 0 to 2: dry at unity up to 1, the room alone at 2. -Hand it an impulse (`impulse=`, a bytes-like of int16 frames or a path to -a 16-bit PCM WAV at the graph's rate) and the room is that recording, and -only Mix is live. - -**The allocation.** `seconds` (default **0.08 s**) is how long a room this -instance can ever hold, carved once at construction: 256-frame partitions, -`ceil(round(seconds * fs) / 256)` of them, from a floor of **0.06 s** to a -ceiling of **512 partitions = 131 072 taps**, which is 2.730 s at 48 kHz, -2.972 s at 44.1 kHz and 5.944 s at 22.05 kHz. Outside those, construction -raises `ValueError` naming this class, the taps, the rate and the limit. -On a desktop the product is taken in double precision and `round` sends a -half frame to even, so a `seconds` a hair over half a frame past a -partition edge builds one partition fewer than exact arithmetic would: -0.08001041666666667 s at 48 kHz is 3 840.5 + 7/2^48 frames exactly, and -builds 3 840 taps, not 4 096. No floor or ceiling cell moves. A board's -float is single precision, and there a few `seconds` land on the other -side of a partition edge: in a single-precision emulation (not a board -run) 0.685 s at 44.1 kHz and 1.370 s at 22.05 kHz build 118 partitions -where a desktop builds 119. No floor, ceiling or default cell moves. -Decay, Predelay and Diffusion are laws over what the allocation leaves -(section 6): Decay is the T60, log from the node's 50 ms floor to -`seconds - predelay`, so at its top the room reaches -60 dB exactly at the -allocation's end; Predelay runs to `min(200 ms, (seconds - 50 ms) / 2)`; -Diffusion to `min(500 ms, T60 / 4)`. - -**Which allocations are desktop-only.** By the line through the cost -table's two Convolver rows (an interpolation, pending hardware), any -`seconds` above **0.091 s on an ESP32-S3** and above **0.219 s on an -ESP32-P4** is over Brad's 80 % real-time ceiling. The default is under -both. The old class's one-second room is a desktop or offline render, and -so is any measured room or hall impulse; a cabinet-length impulse fits. - -**Portability tier: audiodsp** (`REQUIRES = ("audioconvolve",)`). The -convolver is audiodsp's own node. On a stock CircuitPython board this -module imports cleanly and construction raises `ImportError`. - -**Cost: one node.** One `audioconvolve.Convolver`, no mixer, no glue. At -patch 0 on the default allocation (15 partitions at 48 kHz, stereo) the -palette line gives **P4 <= 34 %, S3 <= 72 %** of a 5.333 ms stereo block -(dossier Tier 3). The board measurement is pending hardware. - -**Latency: 256 frames whenever an impulse is loaded** - 5.333 ms at -48 kHz, 5.805 ms at 44.1 kHz, 11.610 ms at 22.05 kHz - **and 0 when none -is.** It is the partition: a block cannot be transformed until it is -complete. `latency_samples` reads the node's own report, so it follows the -loaded state; the synthesized room is always loaded, and only an empty -impulse (`impulse=b""`) leaves the node a plain undelayed wire. Mix 0 is -the source delayed by exactly `latency_samples`, byte for byte, because the -node stays in the path at Mix 0 and a Mix move never jumps the timeline. -A room-knob move keeps it too. It does not hold over the -`latency_samples` frames after a `reset()` (below), nor across a pull in -which the source comes up short (an empty buffer, one shorter than a -frame, or an error result) before the pull has a single frame: that pull -comes out as 256 frames of silence, and everything after it comes out 256 -frames later. The node takes whole frames only: a part frame at the end -of a buffer, and anything an error result carries, never reach it. - -**Tail.** `tail_samples` is `latency_samples` plus the loaded impulse -rounded up to a partition: 4 096 frames (85.3 ms) at the default at -48 kHz, 3 840 at 44.1 kHz, 2 048 at 22.05 kHz. Counted in the frames the -source hands, the output is exactly zero from more than `tail_samples` -frames after the last non-zero one. Only frames the source hands move the -room on: a pull of silence like the one above holds the tail where it is, -and a source that stops handing frames stops the tail with it, until it -hands frames again. - -**RAM.** 141 800 B at the default at 48 kHz with a stereo room (8 224 B a -partition plus 18 440 B fixed), 110 960 B with a mono one; 133 576 B at -44.1 kHz and 76 008 B at 22.05 kHz. A measured impulse is read once at -construction, handed to the node and dropped; the class keeps no copy. - -**Moving a room knob changes the room within the block in flight.** -Decay, Damping, Predelay, Diffusion and Room re-synthesize the impulse, and -the node keeps what it holds. No frame of your dry signal drops or -repeats, at any Mix and after any number of moves: at Mix 0 the output is -byte for byte what it would have been with no move. A tail -ringing at that moment rings on into the new room, and from the end of -the block in flight the output is exactly that of an instance that always -had the new settings. - -The change runs in a straight line from the old room to the new, at the -Mix already in flight and within 1 LSB where neither room is at full -scale, when two things hold: it is the only room change between two -pulls, and the source has not come up short (above) part-way through a -block since the instance was built or last `reset()`. A patch -change counts as one room change however many knobs it moves, and a move -that lands on the room already loaded leaves the audio untouched. -Otherwise the output can jump by many times what either room does on its -own, and how far is not known. For example, at 44.1 kHz stereo with -Damping 0 of 127 (500 Hz) and Mix 2, a 40 Hz sine at 2 000 LSB with Room -moved to 50 and then Predelay to 40 after 10 pulls jumps 10 046 LSB into -the block, where the two rooms move at most 32 LSB a frame. Even on -the straight line, low material can step further from one frame to the -next than either room does, and how much further is not known: at 48 kHz -stereo with Damping 0 of 127 (500 Hz) and Mix 2, a 40 Hz sine at 2 000 LSB -steps 1.97 times the larger room's own largest step when Predelay moves -from 0 to 127 after 10 pulls. - -A patch change, the constructor and `reset()` synthesize at most once, -not once per knob. The synthesis runs on the thread that moves the knob; -on a board it can race the audio pump (audiodsp#166, open), which a -desktop cannot show. `reset()` in the middle of a stream empties the room: -the next `latency_samples` frames come out as exact zero, dry included. -That is 256 with a room loaded and none on the empty impulse, whose output -stays the source. A Mix move never touches the room. It acts on the audio -entering the node after it, so the block already in flight, at most 256 -frames, comes out at the old Mix. - -**What the default surrenders.** The room is normalised to unit energy -across the whole band, so with Damping in, low material comes back louder -than it went in: a 220 / 277 / 330 Hz chord (the three sines summed, at -an 8 000 LSB peak) +3.83 dB at the default 6 kHz Damping and +13.40 dB at -the 500 Hz stop, at 48 kHz. White-spectrum material comes back at its own -level, within 0.5 dB, at every setting measured, and that holds on each -side of a stereo room on its own, not only for the two together: each side is -normalised on its own, so the room sits in the middle. On the room's own -impulse the two sides read within 0.001 dB of each other at every setting -walked (the widest found, L - R +0.0005 dB at 44.1 kHz, Decay 66, -Damping 18, Predelay 34 and Diffusion 62 of 127, Room seed 48); what is -left is the rounding of the impulse to int16. So material on one side only comes back -at its own level too: at 48 kHz with Decay 0, Damping 500 Hz and -Diffusion 0, white noise hard left comes back at -0.06 dB and hard right -at +0.15 dB at Room seed 36, and at +0.02 and -0.21 dB at the default -Room, seed 1 (the kit's uniform noise, seed 12345, at -12 dBFS peak; the -tenths of a dB are that draw against that room). A mono room is one side -and holds. Damping clamps at 0.159 fs, under the point where the node's -one-pole coefficient stops moving, so at 48 kHz every one of its 128 -positions is a room of its own, while at 22.05 kHz the positions from 92 -up (the 6 kHz default among them) are one 3 506 Hz room. - -A single Room's decay with Damping in is not held to the Decay law, and -how far one can read off it is not known: no walk covers every setting. -It reads at least about 24 % off on a mono room: +23.70 % at 44.1 kHz -(Decay 0, Damping 500 Hz, Predelay 0, Diffusion 32 of 127, Room seed 43), -+24.02 % there with a -20 dBFS click (Decay 0, Damping 500 Hz, -Predelay 127, Diffusion 28, seed 43), +22.83 % at 48 kHz (Decay 0, -Damping 500 Hz, Predelay 0, Diffusion 10, seed 43) and +21.00 % at -22.05 kHz (Decay 0, Damping 500 Hz, Predelay 0, Diffusion 46, seed 61). -On a stereo room it is at least about 16.5 %: +16.56 % at 48 kHz -(Decay 8, Damping 500 Hz, Predelay 0, Diffusion 32, seed 43) and -+16.05 % at 22.05 kHz with a -20 dBFS click (Decay 127, Damping 500 Hz, -Predelay 0, Diffusion 0, seed 27). The 64 Rooms' mean holds within 2 %. - -**Measured mode.** The impulse is trimmed by `start_ms` -(int(start_ms * fs / 1000) frames, truncated) through a slice that copies -nothing on a board, then loaded at unit mean energy across the channels -the room holds, trimmed by `ir_gain_db` (-24 to +12 dB). A stereo impulse -over a mono source keeps its left channel. An impulse with frames but no -energy raises, and so does a `start_ms` that trims away every frame it -has: either would be a room whose Mix does nothing. Decay, Damping, Predelay, Diffusion and Room raise -`IndexError` from `set_macro` and `get_macro` in this mode: the loaded -impulse is the room. `live_macros` says which macros an instance has. No -impulse ships with this class; it loads yours and keeps no copy (Brad's -ruling, 2026-09-08). An impulse is one-dimensional: a 2-D array (numpy's -`(frames, channels)`) raises `TypeError`, so flatten it first. - -An empty impulse (`impulse=b""`) reports no taps and no latency: it is an -undelayed wire, and its Mix does nothing. Its node is built with one -partition: measured mode's allocation starts at one frame, and zero frames -is that one partition. The class loads a measured impulse once, at -construction. Loading another into its node mid-stream (`node.load()`) -empties the room, because the node's `load()` resets. - -**Two readbacks that are not what they look like.** A `damping_hz` -between 0 and 500 Hz is taken as 500 Hz, the span's bottom, with no -error; 0 or below, 7 500 Hz and up, or NaN, is out of circuit (-100 and -NaN hand the node 0 Hz and `get_macro` reads 127). And a fresh instance -reports `patch_index` 0, the family's convention, although the -constructor's exact -defaults (Damping 6 000 Hz, Mix 0.6) sit between grid steps and patch 0 is -those settings on the grid (6 059.8 Hz, Mix 0.598). Pass `patch=0` for -patch 0's room exactly. `reset()` restores patch 0, so an instance built -from the plain defaults moves onto the grid at its first reset. - -`capabilities = ()`: nothing here reads a beat. +The player's text is the class docstring, and every sentence in it that +makes a claim is tied to a test by the `CLAIMS` table in the class's test +file. How it works, and why, is in the class's dossier in the workspace +repo (`docs/effects-internal/dossiers/ConvolutionReverb.md`). """ VENDOR = "PyDevices" @@ -349,17 +156,76 @@ def _sum_squares(view, width, frames, channels, lane): class ConvolutionReverb(_component.Component): - """A short synthesized room, or the room a loaded impulse was measured - in. audiodsp tier; 256 frames of latency whenever an impulse is loaded. - - **What the default surrenders:** a dark room lifts low material (a low - chord +3.83 dB at the default Damping, +13.40 dB at 500 Hz, 48 kHz), - calling `reset()` mid-stream with a room loaded silences the next 256 - frames, dry included, and anything longer than 0.091 s on an S3 or - 0.219 s on a P4 is a desktop room (pending hardware). A room-knob move - drops no dry frame. Two room changes between two pulls, or a move - after the source has come up short part-way through a block (until a - `reset()`), can make the output jump. + """A room behind your dry signal, made by convolution: synthesized from + five knobs, or the room your own impulse was recorded in. + + With nothing loaded the class synthesizes a short room from noise. + By default the room is 0.08 s long. + + **The controls.** Decay is how long the room rings, Damping darkens its + tail, Predelay puts silence between the dry and the room, and Diffusion + fades the room in instead of starting it as a burst. + Room picks one of 64 rooms of the same size. + Damping runs from 500 Hz at its bottom stop to out at its top stop. + At 22.05 kHz its brightest positions below the top stop clamp and all + make the same room. + Mix runs from 0 to 2: the dry at unity up to 1, the room alone at 2. + With Damping out, each of the 64 Rooms falls 60 dB within 3 % of the + Decay time. + With Damping in, the 64 Rooms fall 60 dB within 2 % of the Decay time on + average. + A single Room with Damping in can take more than 15 % longer. + At Damping's 500 Hz stop, low material comes back louder than it went + in. + Each side of a stereo room is normalised on its own, so the room sits in + the middle. + + **Your own impulse.** Hand it `impulse=`, int16 frames or the path to a + 16-bit PCM WAV at the graph's rate, and the room is that recording. + The room is then your source convolved with the impulse at unit + energy, within 1 LSB. + `ir_gain_db` trims it from -24 to +12 dB, and `start_ms` cuts up to + 200 ms from its start. + Only Mix is live then: the other five knobs raise `IndexError`. + A WAV at another rate raises `ValueError`, and so does an impulse with + no energy or a `start_ms` that trims away every frame. + An empty impulse, `impulse=b""`, is an undelayed wire whose Mix does + nothing and whose `reset()` silences nothing. + + **The allocation.** `seconds` is the longest room the instance can hold, + carved once when you build it: from 0.06 s up to 131 072 frames, and + outside that the constructor raises `ValueError`. + + **Latency and tail.** `latency_samples` reads 256 while an impulse is + loaded and 0 on the empty impulse. + Held at Mix 0, the output is your source, byte for byte, + `latency_samples` late, while the source keeps feeding it and nothing + resets it. + `tail_samples` is `latency_samples` plus the loaded room rounded up to + a whole block of 256 frames. + More than `tail_samples` frames after your input's last non-zero frame, + the output is exact zero. + + **Moving the knobs.** No frame of your dry signal drops or repeats when + you move a room knob, at any Mix, however many moves you make. + From the end of the block in flight, the output is that of an instance + that always had the new settings. + A Mix move acts from the end of the block in flight, so Mix 0 reaches + the plain source up to 256 frames late. + `reset()` empties the room and returns to patch 0. + With an impulse loaded, `reset()` in the middle of a stream silences the + block in flight, 256 frames, dry included, and with Mix set back to 0 + your source carries on on time after it. + A host that calls `audiocore.reset_buffer` on the output silences the + block in flight too, but also drops the frames the node holds from a + source buffer it had not finished. + + **Limits shared by the family.** + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + The tail rings only while the source keeps feeding: feed silence to let + it ring out. A tail cut short by a source that stopped carries on when + the source comes back. """ NAME = 'ConvolutionReverb' diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 427bacb..263a990 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -75,6 +75,32 @@ #: cell prints `decl` instead of RED and is counted as a declared exception. #: Nothing else skips a cell. Empty on purpose: no class has declared one. DECLARED = { + ("ConvolutionReverb", 'E1-', 'P1'): + 'by design, disclosed: reset() empties the room, so the block in flight, 256 frames, comes out as exact zero, dry included', + ("ConvolutionReverb", 'E2-', 'P1'): + 'by design, disclosed: a host reset_buffer resets the node, so the block in flight, 256 frames, comes out as exact zero, dry included', + ("ConvolutionReverb", 'E4-m5=0', 'P1'): + 'by design, disclosed: Mix acts from the end of the block in flight, so the block after a move to Mix 0 comes out at the old Mix', + ("ConvolutionReverb", 'E5-', 'P1'): + 'by design, disclosed: Mix acts from the end of the block in flight, so the block after a move to Mix 0 comes out at the old Mix', + ("ConvolutionReverb", 'E4-m1=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Damping re-synthesizes the room)', + ("ConvolutionReverb", 'E4-m2=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Predelay re-synthesizes the room)', + ("ConvolutionReverb", 'E4-m4=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Room re-synthesizes the room)', + ("ConvolutionReverb", 'E4-m5=', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("ConvolutionReverb", 'E5-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("ConvolutionReverb", 'E6-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (a patch change)', + ("ConvolutionReverb", 'E9-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (Mix)', + ("ConvolutionReverb", 'E11-', 'P5'): + 'family limit, disclosed (audiocomponents#117): a control that jumps makes the output step (two or three moves before one pull)', + ("ConvolutionReverb", 'E8-dry', 'P3'): + 'family limit, disclosed (audiodsp#180): a tail cut short by a source that stopped carries on when the source comes back', } SOURCE_KINDS = ("256", "100", "512", "1000", "raw") diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index 97fe368..a7f59e3 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -93,10 +93,20 @@ empty impulse (`ResetSilentOnEmpty`, `NoReset`); and the tail is counted in the frames the source hands (`test_the_tail_counts_the_frames_the_source_hands`, `TailTwoShort`). + +The trial of the second process (2026-09-29) made the class docstring the +player's text and tied each of its claims to a test through `CLAIMS` +(`TheClaims`). The module docstring no longer carries figures, so the tests +that parsed it hold their cells as constants here, and the two that pinned +struck figures (the jump two moves make, the step one move takes) are +gone; the fade, jump and short-read mechanisms stay pinned as tests, not +promises. New: a reset while the node holds part of a source buffer keeps +the whole frames it holds (`ReplugOnReset` red), the empty impulse's Mix +and reset do nothing (`ResetSilentOnEmpty` red), a host `reset_buffer` +drops what the node held, and what each knob does. """ import os -import re import sys import tempfile import unittest @@ -942,6 +952,18 @@ def _flush(self): self._node.set(mix=self._value(MIX_I) * 0.5) +class ReplugOnReset(ConvolutionReverb): + """The reset-mid-buffer leg's control (the trial of the second process, + 2026-09-29): `reset()` plugs the source back into the node, which drops + the frames the node held of a source buffer it had not finished.""" + + NAME = NAME + + def reset(self): + ConvolutionReverb.reset(self) + self._node.play(self._source) + + def reach(faulted, reading, tolerance=0.0, rate=RATE, channels=2, builder=build): return kit_faults.fault_reachability( @@ -1030,11 +1052,28 @@ def test_the_laws_at_the_defaults(self): effect.deinit() def test_damping_clamps_at_the_rate(self): - # 0.159 fs: at 22.05 kHz the default is the 3 506 Hz clamp. + # 0.159 fs: at 22.05 kHz the default is the 3 506 Hz clamp, and + # every position from 92 to 126 is that one room (the room's own + # impulse, compared byte for byte); at 48 kHz no two positions are. effect = build(rate=22050) self.assertAlmostEqual(effect._synthesis()[1], 0.159 * 22050, places=6) + pulse = click(LATENCY + effect.node.taps + 256) + rooms = {} + for midi in range(88, 127): + effect.set_macro(DAMPING_I, midi) + rooms[midi] = digest(at_mix(effect, 127, pulse)) + effect.deinit() + self.assertEqual(len(set(rooms[m] for m in range(92, 127))), 1) + self.assertNotEqual(rooms[91], rooms[92]) + effect = build() + pulse = click(LATENCY + effect.node.taps + 256) + seen = set() + for midi in range(0, 127): + effect.set_macro(DAMPING_I, midi) + seen.add(digest(at_mix(effect, 127, pulse))) effect.deinit() + self.assertEqual(len(seen), 127) def test_measured_mode_refuses_the_synthesis_macros(self): effect = build(impulse=make_impulse().tobytes()) @@ -1068,6 +1107,13 @@ def test_impulse_level_and_shape_are_checked(self): build(impulse=b"\x00\x00\x00") with self.assertRaises(ValueError): build(impulse=make_impulse().tobytes(), ir_gain_db=12.5) + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), ir_gain_db=-24.5) + with self.assertRaises(ValueError): + build(impulse=make_impulse().tobytes(), start_ms=-0.5) + for gain_db, start_ms in ((-24.0, 0.0), (12.0, 200.0)): + build(impulse=make_impulse(12000).tobytes(), ir_gain_db=gain_db, + start_ms=start_ms).deinit() with self.assertRaises(ValueError): build(impulse=make_impulse().tobytes(), start_ms=200.5) @@ -1328,48 +1374,11 @@ def reset_then_wire(e): self.assertEqual(digest(out[2816:]), digest(wire[2816:])) -#: The docstring's room-move figures (re-audit fix round 2 at audiodsp -#: v0.6.3rc2, from the re-audit round-1 audit at v0.6.3rc2, items 1 and 2): -#: the step one move's straight line takes on low material, and the jump -#: two moves before one pull make. Each names its cell, and since re-audit -#: fix round 1 after the re-audit round 2 at v0.6.3rc2, the pull its moves -#: land after (the jump moves with it: 4 434 to 10 260 LSB over pulls 4 to -#: 31 at the two-move cell, the round-2 audit's `cell`). -_KNOB = r"(Decay|Damping|Predelay|Diffusion|Room)" -_LSB = r"(\d{1,3}(?: \d{3})*)" -_CELL = (r"at\s+(48|44\.1|22\.05)\s+kHz\s+(stereo|mono)\s+with\s+Damping" - r"\s+(\d+)\s+of\s+127\s+\(500\s+Hz\)\s+and\s+Mix\s+2,\s+a\s+(\d+)" - r"\s+Hz\s+sine\s+at\s+" + _LSB + r"\s+LSB\s+") -STEP_RE = re.compile( - _CELL + r"steps\s+(\d+\.\d\d)\s+times\s+the\s+larger\s+room's\s+own" - r"\s+largest\s+step\s+when\s+" + _KNOB + r"\s+moves\s+from\s+(\d+)" - r"\s+to\s+(\d+)\s+after\s+(\d+)\s+pulls") -TWO_MOVES_RE = re.compile( - _CELL + r"with\s+" + _KNOB + r"\s+moved\s+to\s+(\d+)\s+and\s+then\s+" - + _KNOB + r"\s+to\s+(\d+)\s+after\s+(\d+)\s+pulls\s+jumps\s+" + _LSB - + r"\s+LSB\s+into\s+the\s+block,\s+where\s+the\s+two\s+rooms\s+move\s+at" - r"\s+most\s+" + _LSB + r"\s+LSB\s+a\s+frame") -KNOB_INDEX = dict(zip(ConvolutionReverb.MACRO_LABELS, range(6))) -RATE_OF = {"48": 48000, "44.1": 44100, "22.05": 22050} - #: The pull the moves land before, and the block in flight it plays. MOVE_AT = 10 BLOCK_IN_FLIGHT = slice(MOVE_AT * 256, MOVE_AT * 256 + 256) -def documented_move(doc, pattern): - """(rate, channels, start moves, sine Hz, sine peak, the rest of the - groups) of the docstring's sentence, or None.""" - found = pattern.search(" ".join((doc or "").split())) - if found is None: - return None - g = found.groups() - number = lambda text: int(text.replace(" ", "")) # noqa: E731 - return (RATE_OF[g[0]], 2 if g[1] == "stereo" else 1, - ((DAMPING_I, int(g[2])),), int(g[3]), number(g[4]), g[5:], - number) - - def sine(frames, channels, hz, rate, peak): t = np.arange(frames) / float(rate) x = np.round(peak * np.sin(2 * np.pi * hz * t)).astype(np.int16) @@ -1507,68 +1516,6 @@ def dry_render(cls, rate, channels, start, pcm, plan=None, actions=None, return np.vstack(out), sizes -def said(doc, words): - """True when `words` are in `doc`, whitespace aside.""" - return " ".join(words.split()) in " ".join((doc or "").split()) - - -#: The room-move rule as the docstring states it (re-audit fix round 1 -#: after the re-audit round 2 at audiodsp v0.6.3rc2): what holds after any -#: move, when the change is the straight line, and what else can jump. -MOVE_WORDS = ( - "No frame of your dry signal drops or repeats, at any Mix and after any " - "number of moves: at Mix 0 the output is byte for byte what it would " - "have been with no move.", - "from the end of the block in flight the output is exactly that of an " - "instance that always had the new settings.", - "when two things hold: it is the only room change between two pulls, " - "and the source has not come up short (above) part-way through a block " - "since the instance was built or last `reset()`.", - "A patch change counts as one room change however many knobs it moves, " - "and a move that lands on the room already loaded leaves the audio " - "untouched.", - "Otherwise the output can jump by many times what either room does on " - "its own, and how far is not known.", -) -SUMMARY_WORDS = ( - "A room-knob move drops no dry frame. Two room changes between two " - "pulls, or a move after the source has come up short part-way through " - "a block (until a `reset()`), can make the output jump.") -#: What coming up short is, and what the node does with a short read -#: (re-audit fix round 2 after the re-audit round 2 at audiodsp -#: v0.6.3rc2): the latency paragraph. -LATENCY_WORDS = ( - "Mix 0 is the source delayed by exactly `latency_samples`, byte for " - "byte, because the node stays in the path at Mix 0 and a Mix move never " - "jumps the timeline. A room-knob move keeps it too. It does not hold " - "over the `latency_samples` frames after a `reset()` (below), nor " - "across a pull in which the source comes up short (an empty buffer, one " - "shorter than a frame, or an error result) before the pull has a single " - "frame: that pull comes out as 256 frames of silence, and everything " - "after it comes out 256 frames later.", - "The node takes whole frames only: a part frame at the end of a buffer, " - "and anything an error result carries, never reach it.", -) -TAIL_WORDS = ( - "Counted in the frames the source hands, the output is exactly zero " - "from more than `tail_samples` frames after the last non-zero one. Only " - "frames the source hands move the room on: a pull of silence like the " - "one above holds the tail where it is, and a source that stops handing " - "frames stops the tail with it, until it hands frames again.") -RESET_WORDS = ( - "`reset()` in the middle of a stream empties the room: the next " - "`latency_samples` frames come out as exact zero, dry included. That is " - "256 with a room loaded and none on the empty impulse, whose output " - "stays the source.") -RESET_SUMMARY_WORDS = ( - "calling `reset()` mid-stream with a room loaded silences the next 256 " - "frames, dry included,") -MIX_LATE_RE = re.compile( - r"A\s+Mix\s+move\s+never\s+touches\s+the\s+room\.\s+It\s+acts\s+on\s+the" - r"\s+audio\s+entering\s+the\s+node\s+after\s+it,\s+so\s+the\s+block" - r"\s+already\s+in\s+flight,\s+at\s+most\s+(\d+)\s+frames,\s+comes\s+out" - r"\s+at\s+the\s+old\s+Mix\.") - #: What the rule says breaks the line, each before one pull, on the dark #: room (Damping 0) with Mix held at 2: (label, start, the changes). DARK = ((DAMPING_I, 0),) @@ -1584,68 +1531,13 @@ def said(doc, words): class RoomMoveWords(unittest.TestCase): - """The docstring's room-move paragraph, pinned to the room. Re-audit fix - round 2 at audiodsp v0.6.3rc2 pinned its figures; the re-audit round 2 - there parked the class again on three of its sentences (a source that - ran dry shortens every later fade, a patch change keeps the line only as - its block's one room change, three moves start the block elsewhere than - two), so re-audit fix round 1 after it states only the rule: what holds - after any move, the two conditions for the straight line, and that - anything else can jump. Every sentence of it is read here, the - `cf17a88` words are red, and so is each plant.""" - - def test_two_moves_before_one_pull_jump_as_documented(self, cls=None): - # The example: frames before the block in flight are the old room's - # and after it a room built with both moves, exactly; the jump into - # it and the rooms' own largest step are the printed LSB, with the - # moves after the printed number of pulls; and Mix 0 stays the - # source delayed by `latency_samples` across the same two moves. - # OneSynthesisPerBlock (the moves gathered into one synthesis) is - # red: its jump is the rooms' own. - found = documented_move(rebuilt.__doc__, TWO_MOVES_RE) - self.assertIsNotNone(found, "no two-move sentence naming its cell") - rate, channels, start, hz, peak, rest, number = found - first = (KNOB_INDEX[rest[0]], int(rest[1])) - second = (KNOB_INDEX[rest[2]], int(rest[3])) - at = int(rest[4]) - jump, own = number(rest[5]), number(rest[6]) - pcm = sine(40 * 256, channels, hz, rate, peak) - old = room_render(cls, rate, channels, start, pcm) - new = room_render(cls, rate, channels, start + (first, second), pcm) - moved = room_render(cls, rate, channels, start, pcm, actions={ - at: lambda e: apply_moves(e, (first, second))}) - r = fade_reading(old, new, moved, at=at * 256) - self.assertEqual((r["pre"], r["post"]), (0, 0), r) - self.assertEqual((r["jump"], int(r["own"])), (jump, own), r) - self.assertGreater(r["jump"], 10 * r["own"], r) - wire = np.vstack([silence(LATENCY, channels), pcm[:-LATENCY]]) - out = room_render(cls, rate, channels, start, pcm, mix=0.0, - actions={at: lambda e: apply_moves( - e, (first, second))}) - self.assertEqual(digest(out), digest(wire)) - - def test_one_move_steps_as_documented(self): - # The step sentence: one move holds the straight line within 1 LSB - # and steps the printed multiple, to the hundredth, of the larger - # room's own largest step at its cell, after the printed pulls. - found = documented_move(rebuilt.__doc__, STEP_RE) - self.assertIsNotNone(found, "no step sentence naming its cell") - rate, channels, start, hz, peak, rest, _ = found - ratio = float(rest[0]) - knob, before, after = KNOB_INDEX[rest[1]], int(rest[2]), int(rest[3]) - at = int(rest[4]) - start = start + ((knob, before),) - pcm = sine(40 * 256, channels, hz, rate, peak) - old = room_render(None, rate, channels, start, pcm) - new = room_render(None, rate, channels, start + ((knob, after),), - pcm) - moved = room_render(None, rate, channels, start, pcm, actions={ - at: lambda e: e.set_macro(knob, after)}) - r = fade_reading(old, new, moved, at=at * 256) - self.assertEqual((r["pre"], r["post"]), (0, 0), r) - self.assertLessEqual(r["off_line"], 1.0, r) - self.assertGreater(ratio, 1.0) - self.assertLessEqual(abs(r["step"] / r["own"] - ratio), 0.006, r) + """What a move does while audio plays. Since the trial of the second + process (2026-09-29) the docstring claims only what holds in every + case: no dry frame drops or repeats, every move has landed from the end + of the block in flight, a Mix move leaves that block at the old Mix, and + a reset silences it. The straight-line fade, the jumps and the short + reads these tests also pin are the node's mechanism, stated in the + dossier, not promises in the docstring.""" def test_a_patch_change_is_one_synthesis_on_the_line(self, cls=None): # "A patch change counts as one room change however many knobs it @@ -1656,7 +1548,6 @@ def test_a_patch_change_is_one_synthesis_on_the_line(self, cls=None): # new at the Mix already in flight (patch 5's room at patch 1's # Mix), and after it the output is the new patch's exactly. # PatchPerKnob (one synthesis a knob) is red. - self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[3])) for rate in RATES: for channels in (2, 1): for pcm in (white(40 * 256, channels, -6.0, seed=4242), @@ -1683,7 +1574,8 @@ def test_a_patch_change_is_one_synthesis_on_the_line(self, cls=None): self.assertEqual((r["pre"], post), (0, 0), label) self.assertLessEqual(r["off_line"], 1.0, label) - def test_the_line_holds_only_as_the_rule_says(self, cls=None): + def test_every_move_has_landed_from_the_end_of_the_block_in_flight( + self, cls=None): # The rule, read at three rates, stereo and mono, on a 40 Hz sine at # 2 000 LSB through the dark room, Mix held at 2: # - on the line within 1 LSB, frames before and after exact: one @@ -1700,9 +1592,6 @@ def test_the_line_holds_only_as_the_rule_says(self, cls=None): # on the pairs, RetryOnEmpty (the class never lets the node see the # empty buffer) on the under-run, and the cf17a88 words on the # parse. - for words in MOVE_WORDS: - self.assertTrue(said(rebuilt.__doc__, words), words) - self.assertTrue(said(ConvolutionReverb.__doc__, SUMMARY_WORDS)) for rate in RATES: for channels in (2, 1): pcm = sine(40 * 256, channels, 40.0, rate, 2000.0) @@ -1797,9 +1686,6 @@ def test_every_short_read_part_way_counts_as_coming_up_short( # the old Mix). RetryOnShort (the class pulls again on every short # read) is red on the short legs; RetryOnEmpty is red only on the # empty one. - self.assertTrue(said(rebuilt.__doc__, LATENCY_WORDS[0])) - self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[2])) - self.assertTrue(said(ConvolutionReverb.__doc__, SUMMARY_WORDS)) start = DARK + HOLD_MIX move = ((PREDELAY_I, 127),) for rate in RATES: @@ -1862,9 +1748,6 @@ def test_no_dry_frame_drops_after_any_number_of_changes(self, cls=None): # of a buffer of a frame and a byte (its whole frame does). The # moves come before and after the starved pull. ResetOnMove is red, # and so are the 8a57282 words, whose one exception was the reset. - self.assertTrue(said(rebuilt.__doc__, MOVE_WORDS[0])) - for words in LATENCY_WORDS: - self.assertTrue(said(rebuilt.__doc__, words), words) wire_back = ((MIX_I, 0),) stray = np.frombuffer(b"\x11\x11", dtype=np.int16)[0] room = ((ROOM_I, 50),) @@ -1939,9 +1822,7 @@ def test_a_mix_move_leaves_the_block_in_flight_at_the_old_mix( # frames into a block it is 156. MixOnePullLate (512) is red, and # so are the cf17a88 words, which said the 256 frames in flight # come out at the old Mix. - found = MIX_LATE_RE.search(" ".join(rebuilt.__doc__.split())) - self.assertIsNotNone(found, "no Mix sentence with its frames") - most = int(found.group(1)) + most = 256 for rate in RATES: for channels in (2, 1): pcm = white(40 * 256, channels, -6.0, seed=4243) @@ -1979,8 +1860,6 @@ def test_reset_silences_the_next_256_frames(self, cls=None): # reset that plays 256 frames of silence whatever the node holds) # on the empty impulse, and so are the 8a57282 words, which said # the next 256 frames at every Mix. - self.assertTrue(said(rebuilt.__doc__, RESET_WORDS)) - self.assertTrue(said(ConvolutionReverb.__doc__, RESET_SUMMARY_WORDS)) for rate in RATES: for channels in (2, 1): pcm = white(40 * 256, channels, -6.0, seed=4244) @@ -2034,7 +1913,6 @@ def test_the_tail_counts_the_frames_the_source_hands(self, cls=None): # non-zero frame lands within one frame of that edge. TailTwoShort # (`tail_samples` two frames short) is red, and so are the 8a57282 # words, which said only that the output is zero after the tail. - self.assertTrue(said(rebuilt.__doc__, TAIL_WORDS)) burst = 5000 for rate in RATES: for channels in (2, 1): @@ -2654,66 +2532,26 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) -#: The docstring's single-Room sentences (re-audit fix round 2): a floor -#: ("at least about"), then each figure with its cell in brackets. -SINGLE_MONO_RE = re.compile( - r"at\s+least\s+about\s+(\d+(?:\.\d+)?)\s+%\s+off\s+on\s+a\s+mono\s+room:" - r"\s+\+(\d+\.\d\d)\s+%\s+at\s+44\.1\s+kHz\s+\(([^)]*)\)," - r"\s+\+(\d+\.\d\d)\s+%\s+there\s+with\s+a\s+-20\s+dBFS\s+click" - r"\s+\(([^)]*)\)," - r"\s+\+(\d+\.\d\d)\s+%\s+at\s+48\s+kHz\s+\(([^)]*)\)" - r"\s+and\s+\+(\d+\.\d\d)\s+%\s+at\s+22\.05\s+kHz\s+\(([^)]*)\)") -SINGLE_STEREO_RE = re.compile( - r"On\s+a\s+stereo\s+room\s+it\s+is\s+at\s+least\s+about" - r"\s+(\d+(?:\.\d+)?)\s+%:" - r"\s+\+(\d+\.\d\d)\s+%\s+at\s+48\s+kHz\s+\(([^)]*)\)" - r"\s+and\s+\+(\d+\.\d\d)\s+%\s+at\s+22\.05\s+kHz\s+with\s+a\s+-20\s+dBFS" - r"\s+click\s+\(([^)]*)\)") - -#: The cell behind each figure, in the sentences' order: (rate, channels, -#: click LSB, Decay MIDI, Predelay MIDI, Diffusion MIDI, Room seed), all at -#: Damping 500 Hz and 0.08 s. The walk behind the mono cells is the +#: The single Rooms the walks found furthest off the law, each with its +#: reading in % (the figures the docstring printed until the trial of the +#: second process, 2026-09-29): (rate, channels, click LSB, Decay MIDI, +#: Predelay MIDI, Diffusion MIDI, Room seed), all at Damping 500 Hz and +#: 0.08 s. The walk behind the mono cells is the #: re-audit round-1 audit's (`convolutionreverb_reaudit1_audit.py mono #: monowalk`); the stereo cells are its re-refuter's walk #: (`convolutionreverb_reaudit1_refute.py single`) re-run on the fixed node #: at audiodsp v0.6.3rc2, where every stereo room moved (re-audit fix round #: 1: v0.6.2's +16.20 % cell reads otherwise there). -SINGLE_MONO_CELLS = ( - (44100, 1, 32767, 0, 0, 32, 43), - (44100, 1, 3277, 0, 127, 28, 43), - (48000, 1, 32767, 0, 0, 10, 43), - (22050, 1, 32767, 0, 0, 46, 61), -) -SINGLE_STEREO_CELLS = ( - (48000, 2, 32767, 8, 0, 32, 43), - (22050, 2, 3277, 127, 0, 0, 27), +SINGLE_ROOM_CELLS = ( + ((44100, 1, 32767, 0, 0, 32, 43), 23.70), + ((44100, 1, 3277, 0, 127, 28, 43), 24.02), + ((48000, 1, 32767, 0, 0, 10, 43), 22.83), + ((22050, 1, 32767, 0, 0, 46, 61), 21.00), + ((48000, 2, 32767, 8, 0, 32, 43), 16.56), + ((22050, 2, 3277, 127, 0, 0, 27), 16.05), ) -def documented_single_rooms(doc): - """((mono floor, [(figure, words)...]), (stereo floor, [...])) as the - module docstring states them, or None where a sentence is missing.""" - text = " ".join((doc or "").split()) - found = [] - for pattern in (SINGLE_MONO_RE, SINGLE_STEREO_RE): - match = pattern.search(text) - if match is None: - found.append(None) - continue - groups = match.groups() - pairs = [(float(groups[i]), groups[i + 1]) - for i in range(1, len(groups), 2)] - found.append((float(groups[0]), pairs)) - return tuple(found) - - -def cell_words(cell): - """The words a cell's brackets must carry.""" - _, _, _, decay, predelay, diffusion, seed = cell - return ("Decay %d" % decay, "Damping 500 Hz", "Predelay %d" % predelay, - "Diffusion %d" % diffusion, "seed %d" % seed) - - def single_room_error(cell, cls=None): """(% off the Decay law, floor clean) of one Room at `cell`: a click at Mix 2, the Schroeder fit on the sum of the channels' energy, the law @@ -2733,32 +2571,18 @@ def single_room_error(cell, cls=None): class D5SingleRoom(unittest.TestCase): - """D5's Not claimed line, a single Room with Damping in: nothing is - claimed, but the docstring tells a player how far one was found off the - law, and twice a figure written as a bound was exceeded (the re-audit - round-1 audit). This pins each printed figure to the Room it names, - to the printed hundredth, and wants each "at least about" within half a - point of its sentence's widest figure. It does not make a figure a - bound (re-audit fix round 2).""" - - def test_the_documented_single_rooms_are_what_the_room_reads(self): - mono, stereo = documented_single_rooms(rebuilt.__doc__) - for label, found, cells in (("mono", mono, SINGLE_MONO_CELLS), - ("stereo", stereo, SINGLE_STEREO_CELLS)): - self.assertIsNotNone(found, "no %s single-Room floor" % label) - floor, pairs = found - self.assertEqual(len(pairs), len(cells), label) - self.assertLessEqual(abs(floor - max(f for f, _ in pairs)), 0.5, - (label, floor)) - for (figure, words), cell in zip(pairs, cells): - for word in cell_words(cell): - self.assertIsNotNone( - re.search(r"\b%s\b" % re.escape(word), words), - (label, cell, word, words)) - error, floor_clean = single_room_error(cell) - self.assertTrue(floor_clean, cell) - self.assertLessEqual(abs(error - figure), 0.006, - (label, cell, error, figure)) + """D5's Not claimed line, a single Room with Damping in: the docstring + says only that one can take more than 15 % longer than the Decay time. + Each cell the walks found is held to its reading, to the hundredth, and + every one is past the 15 %.""" + + def test_a_single_room_can_take_more_than_15_percent_longer(self): + for cell, figure in SINGLE_ROOM_CELLS: + error, floor_clean = single_room_error(cell) + self.assertTrue(floor_clean, cell) + self.assertLessEqual(abs(error - figure), 0.006, + (cell, error, figure)) + self.assertGreater(error, 15.0, cell) # -------------------------------------------------------------------------- @@ -2849,16 +2673,6 @@ def measure(cls): self.assertFalse(result["null"]["passed"]) -#: The module docstring's balance sentence (re-audit fix round 1, audiodsp -#: v0.6.3rc2): the bound over the walk, then the widest setting found. -BALANCE_RE = re.compile( - r"On\s+the\s+room's\s+own\s+impulse\s+the\s+two\s+sides\s+read\s+within" - r"\s+(\d+\.\d+)\s+dB\s+of\s+each\s+other\s+at\s+every\s+setting\s+walked" - r"\s+\(the\s+widest\s+found,\s+L\s+-\s+R\s+([+-]\d+\.\d{4})\s+dB\s+at" - r"\s+(48|44\.1|22\.05)\s+kHz,\s+Decay\s+(\d+),\s+Damping\s+(\d+)," - r"\s+Predelay\s+(\d+)\s+and\s+Diffusion\s+(\d+)\s+of\s+127,\s+Room" - r"\s+seed\s+(\d+)\)") - #: D6 (4)'s bar: each side of a stereo room within 0.01 dB of the other on #: the room's own impulse (dossier section 3.6). BALANCE_BAR_DB = 0.01 @@ -2873,18 +2687,12 @@ def measure(cls): } OLD_WIDEST_OTHER_CELL = (0, 0, 0, 0, 6) - -def documented_balance(doc): - """(bound dB, widest L - R dB, rate, (Decay, Damping, Predelay, - Diffusion MIDI), Room seed) as the module docstring states it, or - None.""" - found = BALANCE_RE.search(" ".join((doc or "").split())) - if found is None: - return None - g = found.groups() - rate = {"48": 48000, "44.1": 44100, "22.05": 22050}[g[2]] - return (float(g[0]), float(g[1]), rate, - tuple(int(v) for v in g[3:7]), int(g[7])) +#: The bound over the walk and the widest setting it found (re-audit fix +#: round 1 at v0.6.3rc2; printed in the docstring until the trial of the +#: second process): L - R +0.0005 dB at 44.1 kHz, Decay 66, Damping 18, +#: Predelay 34, Diffusion 62, Room seed 48. +BALANCE_BOUND_DB = 0.001 +BALANCE_WIDEST = (0.0005, 44100, (66, 18, 34, 62), 48) def build_at_cell(rate, cell, cls=None): @@ -2915,9 +2723,8 @@ class D6Balance(unittest.TestCase): 0.05 dB).""" def test_the_documented_balance_is_what_the_room_reads(self): - documented = documented_balance(rebuilt.__doc__) - self.assertIsNotNone(documented, "no balance sentence") - bound, widest, rate, cell, seed = documented + bound = BALANCE_BOUND_DB + widest, rate, cell, seed = BALANCE_WIDEST self.assertLessEqual(bound, BALANCE_BAR_DB) self.assertLessEqual(abs(widest), bound) effect = build_at_cell(rate, cell + (room_midi(seed),)) @@ -2934,9 +2741,7 @@ def test_the_documented_balance_is_what_the_room_reads(self): self.assertLessEqual(abs(pooled), 0.01, (rate, cell)) def test_no_cell_of_the_slice_is_past_the_bound(self): - documented = documented_balance(rebuilt.__doc__) - self.assertIsNotNone(documented, "no balance sentence") - bound = documented[0] + bound = BALANCE_BOUND_DB for rate in RATES: diffusion = OLD_WIDEST_CELL[rate][3] effect = build_at_cell(rate, OLD_WIDEST_CELL[rate]) @@ -2965,29 +2770,6 @@ def test_a_tilted_side_is_red_and_not_on_the_surface(self): self.assertEqual(result["checked"], WALKED) -#: The docstring's one-sided example (re-audit fix round 1, audiodsp -#: v0.6.3rc2): white noise on one side only, at two Rooms, each figure to -#: the hundredth of a dB. -ONE_SIDED_RE = re.compile( - r"at\s+48\s+kHz\s+with\s+Decay\s+0,\s+Damping\s+500\s+Hz\s+and\s+" - r"Diffusion\s+0,\s+white\s+noise\s+hard\s+left\s+comes\s+back\s+at\s+" - r"([+-]\d+\.\d\d)\s+dB\s+and\s+hard\s+right\s+at\s+([+-]\d+\.\d\d)\s+dB" - r"\s+at\s+Room\s+seed\s+(\d+),\s+and\s+at\s+([+-]\d+\.\d\d)\s+and\s+" - r"([+-]\d+\.\d\d)\s+dB\s+at\s+the\s+default\s+Room,\s+seed\s+(\d+)") - - -def documented_one_sided(doc): - """[(options, left dB, right dB)] as the docstring states them, or - None.""" - found = ONE_SIDED_RE.search(" ".join((doc or "").split())) - if found is None: - return None - g = found.groups() - corner = dict(decay=0.0, damping_hz=500.0, diffusion=0.0) - return [(dict(corner, room=int(g[2])), float(g[0]), float(g[1])), - (dict(corner, room=int(g[5])), float(g[3]), float(g[4]))] - - def one_sided_level(side, cls=None, rate=RATE, **options): """Pooled wet/dry dB at Mix 2 of the kit's white noise (seed 12345, -12 dBFS peak, 3 s) on `side` alone, the other side silent, read after @@ -3004,17 +2786,18 @@ def one_sided_level(side, cls=None, rate=RATE, **options): class D6OneSided(unittest.TestCase): - """The docstring's one-sided example, read at the Rooms it names (re- - audit fix round 1, audiodsp v0.6.3rc2). Up to v0.6.3rc1 white noise on + """The one-sided example the docstring printed until the trial of the + second process, read at its Rooms (re-audit fix round 1, audiodsp + v0.6.3rc2). Up to v0.6.3rc1 white noise on one side came back up to 2.7 dB off its level, with a sign that turned with the Room; since each side is normalised on its own it comes back within a few tenths. Each printed figure is held to its printed hundredth, and each within D6's 0.5 dB.""" def test_the_documented_one_sided_example_is_what_the_room_reads(self): - cells = documented_one_sided(rebuilt.__doc__) - self.assertIsNotNone(cells, "no one-sided example naming its Rooms") - self.assertEqual(cells[1][0]["room"], 1) # "the default Room" + corner = dict(decay=0.0, damping_hz=500.0, diffusion=0.0) + cells = [(dict(corner, room=36), -0.06, 0.15), + (dict(corner, room=1), 0.02, -0.21)] for options, left, right in cells: for side, printed in ((0, left), (1, right)): level = one_sided_level(side, **options) @@ -3057,6 +2840,7 @@ def test_reset_empties_the_room(self): for _ in range(6): audiocore.get_buffer(effect.output) effect.reset() + self.assertEqual(effect.patch_index, 0) out = bytearray() for _ in range(8): out += bytes(audiocore.get_buffer(effect.output)[1]) @@ -3130,6 +2914,348 @@ def test_a_wav_at_another_rate_raises_naming_both(self): self.assertIn("44100", str(caught.exception)) self.assertIn("48000", str(caught.exception)) +# -------------------------------------------------------------------------- +# The claims (the trial of the second process, 2026-09-29) +# -------------------------------------------------------------------------- + +#: (sentence, word for word as the class docstring has it, and the test +#: that asserts it). Every sentence in the docstring that makes a claim is +#: here; one that could not be tied to a test was struck. +CLAIMS = ( + ("By default the room is 0.08 s long.", "test_the_laws_at_the_defaults"), + ("Decay is how long the room rings, Damping darkens its tail, Predelay " + "puts silence between the dry and the room, and Diffusion fades the " + "room in instead of starting it as a burst.", + "test_what_each_knob_does"), + ("Room picks one of 64 rooms of the same size.", + "test_room_picks_one_of_64_rooms"), + ("Damping runs from 500 Hz at its bottom stop to out at its top stop.", + "test_what_each_knob_does"), + ("At 22.05 kHz its brightest positions below the top stop clamp and all " + "make the same room.", "test_damping_clamps_at_the_rate"), + ("Mix runs from 0 to 2: the dry at unity up to 1, the room alone at 2.", + "test_the_mix_walk_never_reaches_the_plant"), + ("With Damping out, each of the 64 Rooms falls 60 dB within 3 % of the " + "Decay time.", "test_damping_out_every_room_within_3_percent"), + ("With Damping in, the 64 Rooms fall 60 dB within 2 % of the Decay time " + "on average.", "test_damping_in_the_64_room_mean_within_2_percent"), + ("A single Room with Damping in can take more than 15 % longer.", + "test_a_single_room_can_take_more_than_15_percent_longer"), + ("At Damping's 500 Hz stop, low material comes back louder than it went " + "in.", "test_a_dark_room_lifts_low_material"), + ("Each side of a stereo room is normalised on its own, so the room sits " + "in the middle.", "test_no_cell_of_the_slice_is_past_the_bound"), + ("Hand it `impulse=`, int16 frames or the path to a 16-bit PCM WAV at " + "the graph's rate, and the room is that recording.", + "test_a_wav_loads_the_same_room_as_its_frames"), + ("The room is then your source convolved with the impulse at unit " + "energy, within 1 LSB.", + "test_within_one_lsb_across_layouts_levels_gain_and_trim"), + ("`ir_gain_db` trims it from -24 to +12 dB, and `start_ms` cuts up to " + "200 ms from its start.", "test_impulse_level_and_shape_are_checked"), + ("Only Mix is live then: the other five knobs raise `IndexError`.", + "test_measured_mode_refuses_the_synthesis_macros"), + ("A WAV at another rate raises `ValueError`, and so does an impulse with " + "no energy or a `start_ms` that trims away every frame.", + "test_what_measured_mode_refuses"), + ("An empty impulse, `impulse=b\"\"`, is an undelayed wire whose Mix does " + "nothing and whose `reset()` silences nothing.", + "test_an_empty_impulse_is_a_wire_whatever_mix_and_reset"), + ("`seconds` is the longest room the instance can hold, carved once when " + "you build it: from 0.06 s up to 131 072 frames, and outside that the " + "constructor raises `ValueError`.", + "test_every_cell_builds_or_raises_by_the_law"), + ("`latency_samples` reads 256 while an impulse is loaded and 0 on the " + "empty impulse.", "test_loaded_every_patch_and_the_predelay_stop"), + ("Held at Mix 0, the output is your source, byte for byte, " + "`latency_samples` late, while the source keeps feeding it and nothing " + "resets it.", "test_every_macro_stop_and_patch_with_mix_at_0"), + ("`tail_samples` is `latency_samples` plus the loaded room rounded up to " + "a whole block of 256 frames.", + "test_tail_is_latency_plus_the_loaded_impulse"), + ("More than `tail_samples` frames after your input's last non-zero " + "frame, the output is exact zero.", + "test_the_tail_counts_the_frames_the_source_hands"), + ("No frame of your dry signal drops or repeats when you move a room " + "knob, at any Mix, however many moves you make.", + "test_no_dry_frame_drops_after_any_number_of_changes"), + ("From the end of the block in flight, the output is that of an " + "instance that always had the new settings.", + "test_every_move_has_landed_from_the_end_of_the_block_in_flight"), + ("A Mix move acts from the end of the block in flight, so Mix 0 reaches " + "the plain source up to 256 frames late.", + "test_a_mix_move_lands_a_partition_late_and_reset_drops_one"), + ("`reset()` empties the room and returns to patch 0.", + "test_reset_empties_the_room"), + ("With an impulse loaded, `reset()` in the middle of a stream silences " + "the block in flight, 256 frames, dry included, and with Mix set back " + "to 0 your source carries on on time after it.", + "test_a_reset_keeps_the_whole_frames_the_node_holds"), + ("A host that calls `audiocore.reset_buffer` on the output silences the " + "block in flight too, but also drops the frames the node holds from a " + "source buffer it had not finished.", + "test_a_host_reset_buffer_silences_the_block_and_drops_held_frames"), +) + +#: The family's two limits, ruled by Brad on 2026-09-28, word for word. +FAMILY = ( + "A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "The tail rings only while the source keeps feeding: feed silence to let " + "it ring out. A tail cut short by a source that stopped carries on when " + "the source comes back.", +) + + +def _flat(text): + return " ".join((text or "").split()) + + +def chord(rate, frames, channels): + """220, 277 and 330 Hz summed, at an 8 000 LSB peak.""" + t = np.arange(frames) / float(rate) + x = sum(np.sin(2 * np.pi * hz * t) for hz in (220.0, 277.0, 330.0)) + x = np.round(8000.0 * x / np.max(np.abs(x))).astype(np.int16) + return np.repeat(x[:, None], channels, axis=1) + + +def wire_render(cls, rate, channels, pcm, size, at, action, blocks=40, + **options): + """`blocks` pulls at Mix 0 from a source in `size`-frame calls; + `action(effect)` just before pull `at`, then Mix put back to 0. Returns + the output and the frame where pull `at` starts.""" + effect = build(cls, rate, channels, mix=0.0, **options) + effect._source.swap(DryOnce(pcm, rate, channels, None, size)) + audiocore.reset_buffer(effect.node) + out = [] + for number in range(blocks): + if number == at: + action(effect) + effect.set_macro(MIX_I, 0) + out.append(np.frombuffer(bytes(audiocore.get_buffer(effect.output)[1]), + dtype=np.int16).reshape(-1, channels)) + effect.deinit() + return np.vstack(out), at * 256 + + +def counting(frames, channels): + """A source with no zero frame and no two frames alike nearby.""" + pcm = ((np.arange(frames) * 7) % 20001 - 10000).astype(np.int16) + pcm[pcm == 0] = 1 + return np.repeat(pcm[:, None], channels, axis=1) + + +class TheClaims(unittest.TestCase): + def test_every_claim_is_in_the_docstring_and_tested(self): + doc = _flat(ConvolutionReverb.__doc__) + tests = set() + for value in globals().values(): + if isinstance(value, type) and issubclass(value, + unittest.TestCase): + tests.update(n for n in dir(value) if n.startswith("test_")) + rest = doc + for sentence, test in CLAIMS: + self.assertIn(sentence, doc, sentence) + self.assertIn(test, tests, sentence) + rest = rest.replace(sentence, " ") + self.assertIn("**Limits shared by the family.**", doc) + for sentence in FAMILY: + self.assertIn(sentence, doc, sentence) + numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] + self.assertEqual(numbers, []) + + def test_what_each_knob_does(self): + # On the room's own impulse (a click at Mix 2, 48 kHz): Decay 127 + # rings longer than Decay 0; Damping at its 500 Hz bottom stop + # leaves under a fifth of the share of the tail's second half above + # 4 kHz that its top stop leaves, which + # hands the node no roll-off at all; Predelay 127 leaves 15 ms of + # silence after the latency before the room, Predelay 0 none; and + # Diffusion 127 starts the room at least 6 dB quieter over its first + # 5 ms than Diffusion 0, with the same energy overall. + def impulse(**options): + effect = build(mix=2.0, **options) + taps = effect.node.taps + out = run(effect, click(LATENCY + taps + 256)) + synthesis = effect._synthesis() + effect.deinit() + return out[LATENCY:LATENCY + taps].astype(np.float64), synthesis + + long_room, _ = impulse(decay=1.0, damping_hz=0.0) + short_room, _ = impulse(decay=0.0, damping_hz=0.0) + self.assertGreater(schroeder_t60(np.sum(long_room ** 2, axis=1), RATE), + 1.4 * schroeder_t60(np.sum(short_room ** 2, axis=1), + RATE)) + + def top(ir): + ir = ir[len(ir) // 2:] # the tail's second half + spectrum = np.abs(np.fft.rfft(ir[:, 0])) ** 2 + hz = np.fft.rfftfreq(len(ir), 1.0 / RATE) + return float(np.sum(spectrum[hz > 4000.0]) / np.sum(spectrum)) + + dark, synthesis = impulse(damping_hz=500.0) + self.assertAlmostEqual(synthesis[1], 500.0, places=6) + effect = build() + effect.set_macro(DAMPING_I, 0) + self.assertAlmostEqual(effect._synthesis()[1], 500.0, places=6) + effect.set_macro(DAMPING_I, 127) + self.assertEqual(effect._synthesis()[1], 0.0) + effect.deinit() + bright, synthesis = impulse(damping_hz=0.0) + self.assertEqual(synthesis[1], 0.0) + self.assertLess(top(dark), 0.2 * top(bright)) + + late, _ = impulse(predelay=1.0, diffusion=0.0) + early, _ = impulse(predelay=0.0, diffusion=0.0) + self.assertEqual(first_arrival(late), 720) + self.assertEqual(first_arrival(early), 0) + + soft, _ = impulse(diffusion=1.0) + hard, _ = impulse(diffusion=0.0) + head = int(0.005 * RATE) + self.assertLess(10 * np.log10(np.sum(soft[:head] ** 2) + / np.sum(hard[:head] ** 2)), -6.0) + + def test_room_picks_one_of_64_rooms(self): + # Walked by set_macro over every position: 64 seeds, 64 impulses no + # two alike, all on one allocation, and each on the Decay law with + # Damping out (D5 walks that within 3 %). + effect = build(damping_hz=0.0) + taps = effect.node.taps + pulse = click(LATENCY + taps + 256) + seeds, rooms = set(), set() + for midi in range(128): + effect.set_macro(ROOM_I, midi) + seeds.add(effect._synthesis()[4]) + rooms.add(digest(at_mix(effect, 127, pulse))) + self.assertEqual(effect.node.taps, taps) + effect.deinit() + self.assertEqual(seeds, set(range(1, 65))) + self.assertEqual(len(rooms), 64) + + def test_a_dark_room_lifts_low_material(self): + # A low chord at Mix 2, the level after the room has built, at + # Damping's 500 Hz stop: louder than the dry at three rates, stereo + # and mono, at 16 Rooms and three Decays (the least lift over that + # walk is about 2 dB). No number is claimed: the lift turns with the + # Room. + least = None + for rate in RATES: + for channels in (2, 1): + pcm = chord(rate, int(1.5 * rate), channels) + for decay in (0.0, 0.5, 1.0): + effect = build(rate=rate, channels=channels, decay=decay, + damping_hz=500.0) + for room in range(0, 128, 8): + effect.set_macro(ROOM_I, room) + level, _ = m6_cell(effect, pcm) + least = level if least is None else min(least, level) + self.assertGreater(level, 0.0, + (rate, channels, decay, room)) + effect.deinit() + self.assertGreater(least, 1.0) + + def test_what_measured_mode_refuses(self): + with tempfile.TemporaryDirectory() as folder: + path = ImpulseFiles().write(folder, 44100, 1, 900) + with self.assertRaises(ValueError): + build(impulse=path) + with self.assertRaises(ValueError): + build(impulse=np.zeros(512, dtype=np.int16).tobytes()) + with self.assertRaises(ValueError): + build(impulse=make_impulse(100).tobytes(), start_ms=2.1) + + def test_an_empty_impulse_is_a_wire_whatever_mix_and_reset(self): + # `impulse=b""`: at Mix 0, 0.6, 1.2 and 2, across a reset() before + # pull 20 (Mix put back after it) and a host `reset_buffer` on the + # output before pull 25, the output is the source frame for frame, + # with no latency and no frame silenced. ResetSilentOnEmpty (a + # reset that plays 256 frames of silence) is red. + for rate in (48000, 22050): + for channels in (2, 1): + pcm = counting(40 * 256, channels) + for cls in (None, ResetSilentOnEmpty): + for midi in (0, 38, 76, 127): + effect = build(cls, rate, channels, impulse=b"") + effect.set_macro(MIX_I, midi) + effect._source.swap(probes.ArraySource( + pcm, rate=rate, channels=channels)) + audiocore.reset_buffer(effect.node) + out = bytearray() + for number in range(40): + if number == 20: + effect.reset() + effect.set_macro(MIX_I, midi) + if number == 25: + audiocore.reset_buffer(effect.output) + out += bytes(audiocore.get_buffer( + effect.output)[1]) + effect.deinit() + out = np.frombuffer(bytes(out), dtype=np.int16 + ).reshape(-1, channels) + label = (rate, channels, cls, midi) + if cls is None: + self.assertEqual(digest(out), digest(pcm), label) + else: + self.assertNotEqual(digest(out), digest(pcm), + label) + + def test_a_reset_keeps_the_whole_frames_the_node_holds(self): + # The re-audit's ask: a reset() while the node holds part of a + # source buffer. From sources in 512-, 100- and 256-frame calls, at + # Mix 0, the reset before pull 20 or 21 (with 512-frame calls pull + # 21 finds the node holding half a buffer; with 100-frame calls + # both do): the 256 frames after it are exact zero and every other + # frame is the source on time. ReplugOnReset (the source plugged + # back into the node on reset, which drops what the node held) is + # red wherever the node held frames, and clean where it held none. + for rate in (48000, 22050): + for channels in (2, 1): + pcm = counting(40 * 256, channels) + wire = np.vstack([silence(LATENCY, channels), pcm]) + for size in (512, 100, 256): + for at in (20, 21): + held = (at * 256) % size != 0 + for cls in (None, ReplugOnReset): + out, a = wire_render(cls, rate, channels, pcm, + size, at, + lambda e: e.reset()) + want = wire[:len(out)].copy() + want[a:a + 256] = 0 + label = (rate, channels, size, at, cls) + if cls is None or not held: + self.assertEqual(digest(out), digest(want), + label) + else: + self.assertNotEqual(digest(out), + digest(want), label) + + def test_a_host_reset_buffer_silences_the_block_and_drops_held_frames( + self): + # A host `audiocore.reset_buffer(effect.output)` before pull 21, at + # Mix 0: the 256 frames after it are exact zero, and from then on + # the output is the source moved on by the frames the node held of + # its current buffer: none from 256-frame calls, 256 from 512-frame + # calls, 24 from 100-frame calls (54 calls handed 5 400 frames and + # the node had taken 5 376). audiodsp: the node's reset drops them. + for rate in (48000, 22050): + for channels in (2, 1): + pcm = counting(44 * 256, channels) + for size, dropped in ((256, 0), (512, 256), (100, 24)): + out, a = wire_render( + None, rate, channels, pcm, size, 21, + lambda e: audiocore.reset_buffer(e.output)) + label = (rate, channels, size) + wire = np.vstack([silence(LATENCY, channels), pcm]) + self.assertEqual(digest(out[:a]), digest(wire[:a]), label) + self.assertEqual(int(np.max(np.abs(out[a:a + 256]))), 0, + label) + rest = out[a + 256:] + self.assertEqual( + digest(rest), + digest(pcm[a + dropped:a + dropped + len(rest)]), + label) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 310d1cf..5e47979 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -121,24 +121,6 @@ class this branch carries, on CPython and on the two native interpreters. ('Reverb', 'E8-dry', 'P3'): ( 44, 'e23e44d7', 44, 'e23e44d7', 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('ConvolutionReverb', 'E', 'P5'): ( - 324, '9421d061', 678, 'e8b57cdc', - 'Mix, Damping, Predelay, Room and patch moves step the output within one block'), - ('ConvolutionReverb', 'E1-', 'P1'): ( - 72, '387f53ad', 72, '387f53ad', - 'reset() silences the next 256 frames, dry included (disclosed)'), - ('ConvolutionReverb', 'E2-', 'P1'): ( - 36, '78219cf4', 36, '78219cf4', - 'a host reset_buffer silences the next 256 frames at Mix 0, dry included'), - ('ConvolutionReverb', 'E4-m5=0', 'P1'): ( - 4, '2857f42f', 36, 'd4cc4837', - 'Mix to 0 lands one block late: the first block after the move is not the source'), - ('ConvolutionReverb', 'E5-', 'P1'): ( - 36, 'caf98973', 36, 'caf98973', - 'Mix to 0 lands one block late: the first block after the move is not the source'), - ('ConvolutionReverb', 'E8-dry', 'P3'): ( - 36, 'b7504caa', 36, 'b7504caa', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), } #: P6 differences today: class -> (cells whose line differs between CPython From 3f723cf915a6055d802e61e5a2865be5836e770d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:30:02 -0500 Subject: [PATCH 84/88] The lifecycle matrix's known-red table is empty: every Phase 5 class reads no red cell, with its exceptions declared --- tests/test_lifecycle_matrix.py | 85 +--------------------------------- 1 file changed, 1 insertion(+), 84 deletions(-) diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 1a58230..7d57b3d 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -42,90 +42,7 @@ class this branch carries, on CPython and on the two native interpreters. #: (the message lists them). The full-matrix pair is None where the full #: matrix has not been run (MultiTapDelay: it takes about 40 minutes on #: CPython). -KNOWN_RED = { - ('SlapbackDelay', 'E', 'P5'): ( - 124, '01ddb3de', 198, '4cda9a29', - 'Level, Tone and patch moves step the output within one block (no ramp)'), - ('SlapbackDelay', 'E1-', 'P4'): ( - 56, 'c608fc28', 56, 'c608fc28', - 'reset() lands patch 0 at another moment than a fresh instance, so the Wow phase never re-converges'), - ('SlapbackDelay', 'E2-', 'P4'): ( - 28, 'cbf2eabb', 28, 'cbf2eabb', - 'a host reset_buffer shifts the Wow phase, so the output never re-converges'), - ('SlapbackDelay', 'E8-dry', 'P3'): ( - 16, '95d6ec10', 16, '95d6ec10', - ('DigitalDelay', 'E', 'P5'): ( - 36, 'cfa18482', 94, '4627b739', - 'Mix, Repeat Tone, Repeat Cut and patch moves step the output within one block'), - ('DigitalDelay', 'E8-dry', 'P3'): ( - 6, 'e155e27d', 6, 'e155e27d', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('TapeDelay', 'E', 'P5'): ( - 34, '60c8dd58', 106, '4ce31e31', - 'Time, Mix, Wow and patch moves step the output within one block'), - ('TapeDelay', 'E1-', 'P4'): ( - 72, '407e821c', 72, '407e821c', - 'reset() lands patch 0 at another moment than a fresh instance, so wow and flutter never re-converge'), - ('TapeDelay', 'E2-', 'P4'): ( - 32, 'd3f42f25', 32, 'd3f42f25', - 'a host reset_buffer shifts the wow and flutter phase, so the output never re-converges'), - ('TapeDelay', 'E4-m0=0', 'P4'): ( - 0, '00000000', 10, '816d86bd', - 'Time to 0 and back at 22.05 kHz: the glide back outlasts tail_samples'), - ('TapeDelay', 'E8-dry', 'P3'): ( - 2, '1da480f7', 2, '1da480f7', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('MultiTapDelay', 'E', 'P3'): ( - 20, '273472a7', None, None, - 'CPython only: a Time move or a patch change and back plays old audio out of silence (see KNOWN_P6)'), - ('MultiTapDelay', 'E', 'P4'): ( - 4, '11f966a4', None, None, - 'at 22.05 kHz stereo a Mix, Repeat Tone or patch move re-converges after tail_samples allows'), - ('MultiTapDelay', 'E', 'P5'): ( - 85, '511cd123', None, None, - 'Time, Heads, Tilt and patch moves step the output within one block (Time and Heads disclosed)'), - ('MultiTapDelay', 'E1-', 'P4'): ( - 16, '9a49aaf5', None, None, - 'at 22.05 kHz mono a reset re-converges about 1000 frames after tail_samples allows'), - ('MultiTapDelay', 'E5-', 'P4'): ( - 14, 'b3271a6f', None, None, - 'Mix 0 and back leaves the class out of step with a fresh instance for good'), - ('MultiTapDelay', 'E8-dry', 'P4'): ( - 15, '6743df4e', None, None, - 'a source that runs dry once leaves the class out of step with a fresh instance for good'), - ('MultiTapDelay', 'E9-', 'P4'): ( - 70, '123b91ee', None, None, - 'Mix 0 and back leaves the class out of step with a fresh instance for good'), - ('AnalogDelay', 'E', 'P4'): ( - 37, '1266f888', 150, 'bb0595c6', - "the Modulation LFO's phase moves with the event, so a modulated patch never re-converges"), - ('AnalogDelay', 'E', 'P5'): ( - 74, 'b699cca1', 214, '8d248b43', - 'Time, Mix and patch moves step the output within one block'), - ('AnalogDelay', 'E8-dry', 'P3'): ( - 8, 'e1967d96', 8, 'e1967d96', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), - ('Reverb', 'E', 'P4'): ( - 322, '606b9d85', 1409, '722197fe', - 'a network move, reset or reset_buffer never re-converges to a fresh instance (modulation phase?)'), - ('Reverb', 'E', 'P5'): ( - 416, '8e447fac', 1166, '886ecc00', - 'almost every macro and patch move steps the output within one block'), - ('Reverb', 'E1-reset@part', 'P1'): ( - 44, 'df421423', 44, 'df421423', - 'reset() part-way through a source buffer drops the frames the input held: silence at Mix 0'), - ('Reverb', 'E8-dry', 'P3'): ( - 44, 'e23e44d7', 44, 'e23e44d7', - ('AnalogDelay', 'E', 'P4'): ( - 37, '1266f888', 150, 'bb0595c6', - "the Modulation LFO's phase moves with the event, so a modulated patch never re-converges"), - ('AnalogDelay', 'E', 'P5'): ( - 74, 'b699cca1', 214, '8d248b43', - 'Time, Mix and patch moves step the output within one block'), - ('AnalogDelay', 'E8-dry', 'P3'): ( - 8, 'e1967d96', 8, 'e1967d96', - 'a source that stays dry through a pause and comes back: old audio plays out of silence'), -} +KNOWN_RED = {} #: P6 differences today: class -> (cells whose line differs between CPython #: and a native interpreter in the quick matrix, their digest, the same for From 503a389aa037a2599774e74b4eea2f002d59d7c4 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:07:38 -0500 Subject: [PATCH 85/88] PingPongDelay tests: the planted stall's level is set on the planted class only, never on PingPongDelay itself --- tests/test_cpython_effects_pingpongdelay.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index 44b8f4b..759f99c 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -2358,7 +2358,10 @@ def _cross_feed_tail(self, cls, feedback, spread, k): fill = 4 * 960 // BLOCK * BLOCK + BLOCK values = [2 * k + 2] * fill + [0] * (declared + 960 + BLOCK) source, _ = to_source(values) - cls.held = k + if cls is not PingPongDelay: + # never on the class itself: the attribute would outlive this + # test and read as a public name to every later one + cls.held = k effect = cls(source, sample_rate=RATE, **options) out = pull(effect, len(values), 2).astype(np.int64) effect.deinit() From e7a4eb25e49a549cf074e8c061d6752de291a256 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:43:17 -0500 Subject: [PATCH 86/88] Pin audiodsp v0.6.3 (5afe010), the release: no source moved from v0.6.3rc3; 2280 tests OK at the pin --- AUDIODSP_PIN | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index ccd1139..f987e6e 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -283,5 +283,17 @@ # validate_metadata valid; flake8 clean; the smoke 45 classes, 259 patches, # 0 failures on CPython and both rc3 binaries. Pending both boards. # +# 2026-09-29: v0.6.3rc3 -> v0.6.3 (5afe010), the release, named by Brad +# the same day ("release audiodsp 0.6.3"); on TestPyPI and production +# PyPI. Nothing moved beneath the components: `git diff v0.6.3rc3 v0.6.3` +# is VERSION and CHANGELOG.md, no source file. Re-run here on Brad's +# workstation with both desktop interpreters rebuilt from clean build +# directories at the tag (audioecho.__revision__ v0.0.3-308-g5afe010 on +# MicroPython, the provenance stamp on CircuitPython; kept as bin/*-063) +# and the venv's audiodsp installed from PyPI: the unit suite `Ran 2280 +# tests ... OK (skipped=2)`, the lifecycle matrix for all eight Phase 5 +# classes included; validate_api, validate_metadata and flake8 clean. +# Pending both boards. +# # -- ref is what CI installs (git+https://...@) -v0.6.3rc3 b386248518b381d62bd3360224b02f9a9ef9bf37 +v0.6.3 5afe010b61b1b9e628534bc90895b005f7df6d6d From f1a23df0bac0ed5e615c62ea65b490b8d5a2406f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:44:14 -0500 Subject: [PATCH 87/88] MultiTapDelay: the docstring says patch 4 needs a P4-class board and patch 3 may, from the boards' figures at audiodsp v0.6.3 --- CHANGELOG.md | 1 + lib/audioeffects/rebuilt/multitapdelay.py | 8 ++++ tests/test_cpython_effects_multitapdelay.py | 44 +++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c3e3..cf1225f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ there, and are recorded in its changelog. ### Added +- MultiTapDelay: the docstring says which patches need a P4-class board (patch 4 does, patch 3 may), from the boards' figures at audiodsp v0.6.3 - **`DigitalDelay` (rebuilt, Phase 5; adopted 2026-09-28):** a clean interpolated line with the Boss DD-2's control law on one `audioecho.FeedbackDelay`, and what `audioeffects.DigitalDelay` and diff --git a/lib/audioeffects/rebuilt/multitapdelay.py b/lib/audioeffects/rebuilt/multitapdelay.py index 427f490..74a797e 100644 --- a/lib/audioeffects/rebuilt/multitapdelay.py +++ b/lib/audioeffects/rebuilt/multitapdelay.py @@ -252,6 +252,14 @@ class MultiTapDelay(_component.Component): exact zero once your input stops. The tail rings on while your source hands back nothing. + **On a board.** + Measured at v0.6.3, patch 4 costs 5.567 ms a block on the ESP32-S3, + where a block lasts 5.333 ms, so it needs a P4-class board: the ESP32-P4 + runs it in 3.865 ms. + Patch 3 costs 4.163 ms on the ESP32-S3, inside the 4.267 ms budget, and + may need a P4-class board too, especially when the board is running + anything else. + **Limits shared by the family.** A control that jumps makes the output step: move it in small steps from the host if you need it smooth. diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 493022d..1ccf950 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -2933,7 +2933,26 @@ def test_the_tail_reading_is_red_on_a_bound_one_lap_short(self): #: that asserts it). Every sentence in the class docstring that makes a #: claim is here; one that could not be tied to a test was struck (the #: trial fixer's dated note in the dossier lists them). +#: What the boards measured, marginal ms a block at 48 kHz stereo (the +#: anchor's phase5_probes/boards_063/RESULTS.md, audiodsp v0.6.3). The +#: budget is Brad's G6 bar, 80 % of the block. +BOARD_COST = { + "measured_at": "v0.6.3", + "block_ms": 5.333, + "budget_ms": 4.267, + "S3": {3: 4.163, 4: 5.567}, + "P4": {3: 2.748, 4: 3.865}, +} + CLAIMS = ( + ("Measured at v0.6.3, patch 4 costs 5.567 ms a block on the ESP32-S3, " + "where a block lasts 5.333 ms, so it needs a P4-class board: the " + "ESP32-P4 runs it in 3.865 ms.", + "test_the_board_figures_are_the_table"), + ("Patch 3 costs 4.163 ms on the ESP32-S3, inside the 4.267 ms budget, " + "and may need a P4-class board too, especially when the board is " + "running anything else.", + "test_the_board_figures_are_the_table"), ("Time is the gap to head 1, landed on a whole frame, and head k sounds " "at exactly k times that gap.", "test_first_lap_over_modes_heads_and_time_stops"), @@ -3073,6 +3092,31 @@ def test_every_claim_is_in_the_docstring_and_tested(self): numbers = [w for w in rest.split() if any(c.isdigit() for c in w)] self.assertEqual(numbers, []) + def test_the_board_figures_are_the_table(self): + cost = BOARD_COST + doc = _flat(MultiTapDelay.__doc__) + dense = ("Measured at %s, patch 4 costs %.3f ms a block on the " + "ESP32-S3, where a block lasts %.3f ms, so it needs a " + "P4-class board: the ESP32-P4 runs it in %.3f ms." % ( + cost["measured_at"], cost["S3"][4], cost["block_ms"], + cost["P4"][4])) + far = ("Patch 3 costs %.3f ms on the ESP32-S3, inside the %.3f ms " + "budget, and may need a P4-class board too, especially when " + "the board is running anything else." % ( + cost["S3"][3], cost["budget_ms"])) + self.assertIn(dense, doc) + self.assertIn(far, doc) + # "needs", "runs it" and "inside" are what the table says. + self.assertAlmostEqual(cost["budget_ms"], 0.8 * cost["block_ms"], + places=2) + self.assertGreater(cost["S3"][4], cost["block_ms"]) + self.assertLess(cost["P4"][4], cost["budget_ms"]) + self.assertLess(cost["S3"][3], cost["budget_ms"]) + self.assertLess(cost["P4"][3], cost["budget_ms"]) + self.assertEqual(MultiTapDelay.PATCHES[4][0], "Eight Heads, Dense") + self.assertEqual(MultiTapDelay.PATCHES[3][0], + "Four Heads, Far Loudest") + def test_the_right_lane_is_hot_only_on_circuitpython(self): # The stock audiomixer's pan law: 32768 / 32767 on the right lane, # which moves the 63 values from 32 736 up (and down from -32 736, From 4755c71703738d208d44ab57a9624ffb0be52d68 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:22:34 -0500 Subject: [PATCH 88/88] TapeDelay, AnalogDelay, PingPongDelay, MultiTapDelay, Reverb and ConvolutionReverb are adopted: Phase 5's last six The board session at audiodsp v0.6.3 (2026-09-29, both boards at the same image) met the cost bar (at most 80 % of a 5.333 ms stereo block) and real time at every cell of the six on the ESP32-P4, and on the ESP32-S3 at every cell but MultiTapDelay's patch 4 (5.567 ms), which its docstring already discloses on Brad's ruling. Each board digest equals the desktop's or a single-precision desktop MicroPython's (audiocomponents#75); Reverb's patch 7 differs from that build by nine one-LSB samples of 65536 (audiodsp#183). ADOPTED names all eight Phase 5 classes, so audioeffects. and create() serve the rebuilds. They stay under rebuilt/ with the first two; delay.py and reverb.py keep the old classes until promotion. ShimmerHall and AirSpace built their tape delay and hall by NAME with the old classes' settings (preset=, wow=, drive=), which the rebuilds refuse, so both racks failed to construct. They now name delay.TapeDelay and reverb.Reverb directly: a Rack chain entry may be a class in place of a NAME. Their renders are unchanged (the census). Moving them onto the rebuilds is a re-voicing and is left for later. The Rack and _core docstring examples say character=. The README's time-and-space table gains the six with their v0.6.3 board figures; the old-class table and the audiofreeverb sentence go. The six CHANGELOG entries describe the adopted classes and their board rows, and two stray duplicated lines in the AnalogDelay and Reverb entries are folded back where they belong. No class docstring still said "pending hardware". Tests: each class's parked test flips to "adopted is what the package serves" (Reverb had none and gains one; ConvolutionReverb's registry test flips), and the registry names all eight; with ADOPTED cut back to the first two, all seven go red. Tests written against the old classes by their served name now name them from their family module: test_cpython_effects_time (TapeDelay wow/clear, AnalogDelay age, ConvolutionReverb decay_seconds and stereo), test_cpython_effects_library (Reverb preset, Reverb's empty macro surface), test_cpython_effects_racks (the Reverb preset chains). --- CHANGELOG.md | 98 ++++++++++++------- lib/audioeffects/README.md | 65 ++++++------ lib/audioeffects/_core.py | 2 +- lib/audioeffects/rack.py | 26 +++-- lib/audioeffects/rebuilt/__init__.py | 17 ++++ tests/test_cpython_effects_analogdelay.py | 18 ++-- .../test_cpython_effects_convolutionreverb.py | 13 ++- tests/test_cpython_effects_library.py | 10 +- tests/test_cpython_effects_multitapdelay.py | 18 +++- tests/test_cpython_effects_pingpongdelay.py | 18 ++-- tests/test_cpython_effects_racks.py | 10 +- tests/test_cpython_effects_reverb.py | 15 +++ tests/test_cpython_effects_tapedelay.py | 16 ++- tests/test_cpython_effects_time.py | 41 ++++---- tests/test_rebuilt_registry.py | 6 +- 15 files changed, 253 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1225f..bd100b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ there, and are recorded in its changelog. frame at 44.1 and 22.05 kHz, and Tone's flat top at 22.05 kHz. It lives in `lib/audioeffects/rebuilt/slapbackdelay.py` until it comes home. -- **`TapeDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** a tape loop +- **`TapeDelay` (rebuilt, Phase 5; adopted 2026-09-29):** a tape loop on one `audioecho.FeedbackDelay` with two transports as characters: the RE-201's motor (`"varispeed"`, the default), where a Time move bends the pitch by the ratio of the two times for exactly the new time, and the @@ -63,22 +63,24 @@ there, and are recorded in its changelog. loss law whose corner follows Spacing and the tape speed; Wow and Flutter drive one periodic wow table with a slow drift; Record Level is the loop's cubic squash, without memory. Mix 0 is a wire while the loop keeps - recording. It lives in `lib/audioeffects/rebuilt/tapedelay.py`, and - `audioeffects.TapeDelay` is still the old class. Every sentence of the + recording. It is what `audioeffects.TapeDelay` and `create()` now serve, + from `lib/audioeffects/rebuilt/tapedelay.py` until it comes home; the old + `delay.TapeDelay` stays in `delay.py` until then. Every sentence of the docstring that makes a claim is tied to a test (`CLAIMS` in its test file); it says where the pitch bend stops, which Wow and Flutter moves still step, and the family's two limits (a control that jumps steps the - output; the tail rings only while the source feeds). On the boards the full class is over its - budget (P4 10.3-11.4 % against 9 %, S3 19.8-20.5 % against 15 %, at - audiodsp v0.6.2); patch 8 `Tape Delay - lean` (patch 0 with Record - Level at 0) built with `max_time_ms=800` met both bars in every run, and - gives up the tape saturation and Time above 800 ms. At audiodsp + output; the tail rings only while the source feeds). At audiodsp v0.6.3rc1 its Feedback is handed as set (no stepping clear of the stall windows; the bound counts one landing lap there), a Wow or Flutter move that changes only the wobble's depth glides over 20 ms, and a knob turned down to 0 keeps the last wow table while the depth ramps out; a move - that changes the balance of Wow and Flutter still steps, disclosed. -- **`AnalogDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** a + that changes the balance of Wow and Flutter still steps, disclosed. At + v0.6.3rc3 Spread is handed as set: the node ends a cross-fed tail itself + (audiodsp#173), so the 1/4096 grid the class used is gone. On the boards + at audiodsp v0.6.3 every patch runs in real time at 13.4-14.1 % of a + block on the P4 and 10.0-12.8 % on the S3, and every digest equals the + desktop's or a single-precision desktop build's (audiocomponents#75). +- **`AnalogDelay` (rebuilt, Phase 5; adopted 2026-09-29):** a bucket-brigade delay on one `audioecho.FeedbackDelay`, with the Boss DM-2's one 4096-stage line (`"single-line"`, the default) and the Deluxe Memory Man's two in series (`"double-line"`) as characters. The Time knob @@ -89,22 +91,22 @@ there, and are recorded in its changelog. keeps recording. Modulation is a triangle of fixed millisecond swing, and Sync follows a host's beat by Division. There is no sample-and-hold, no compander and no fixed ~3 kHz pair, so short Times are brighter than - either pedal. It lives in `lib/audioeffects/rebuilt/analogdelay.py`, and - `audioeffects.AnalogDelay` is still the old class. The docstring states - the input ceiling (-4.4 dBFS peak at the default Mix 0.4) and that - turning Time through several positions takes seconds to settle. Board - cost is unmeasured. At audiodsp v0.6.3rc1 the Feedback is handed as set + either pedal. It is what `audioeffects.AnalogDelay` and `create()` now + serve, from `lib/audioeffects/rebuilt/analogdelay.py` until it comes + home. The docstring states the input ceiling (-4.4 dBFS peak at the + default Mix 0.4) and that turning Time through several positions takes + seconds to settle. At audiodsp v0.6.3rc1 the Feedback is handed as set (no stepping clear of the stall windows; the bound counts one landing lap there), and a Modulation move glides over 20 ms instead of stepping the read offset. At v0.6.3rc3 Spread is handed as set: the node's stereo cross-feed tail now ends on its own (audiodsp#170), so the 1/4096 grid the class used to dodge it is gone. A jumping control steps the output and a tail cut short by a stopped source carries on when it comes back, - disclosed as family limits (audiocomponents#117, audiodsp#180). - that changes the balance of Wow and Flutter still steps, disclosed. At - v0.6.3rc3 Spread is handed as set: the node ends a cross-fed tail itself - (audiodsp#173), so the 1/4096 grid the class used is gone. -- **`PingPongDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** repeats + disclosed as family limits (audiocomponents#117, audiodsp#180). On the + boards at audiodsp v0.6.3 every patch runs in real time at 7.0-7.5 % of + a block on the P4 and 14.0-14.5 % on the S3, and every digest equals the + desktop's or a single-precision desktop build's (audiocomponents#75). +- **`PingPongDelay` (rebuilt, Phase 5; adopted 2026-09-29):** repeats that alternate between the speakers, on one `audioecho.FeedbackDelay` whose cross-feed and input pan Spread moves between two plain delays and the full bounce, by a stated law. The dry path is each channel's own @@ -116,18 +118,21 @@ there, and are recorded in its changelog. 20 to 1000 ms), which needs a node option. Time walks rather than clicks, Sync follows the host's beat, and Repeat Tone and Repeat Cut put a low-pass and a high-pass in the loop, defaulting out. It lives in - `lib/audioeffects/rebuilt/pingpongdelay.py`, and - `audioeffects.PingPongDelay` is still the old class. The docstring states + `lib/audioeffects/rebuilt/pingpongdelay.py` until it comes home, and it + is what `audioeffects.PingPongDelay` and `create()` now serve. The + docstring states the input ceiling (-3 dBFS peak at the defaults, -3.1 over the shipped patches, on `noise_det`; -7.96 dBFS on any material with Repeat Cut in at the default Mix), and that at full Spread the loop hears (L + R) / 2. - Board cost is unmeasured. At audiodsp v0.6.3rc1 both loop filters' out + On the boards at audiodsp v0.6.3 every patch runs in real time at + 11.9-12.2 % of a block on the P4 and 16.4-16.6 % on the S3, and every + digest equals the desktop's. At audiodsp v0.6.3rc1 both loop filters' out stops hand exactly 0 and a filter taken out is out whatever came before (no tracking Tone stop, no Cut held in at 20 Hz, so `tail_samples` is finite once Cut is out again), and the Feedback is handed as set (no stepping clear of the stall windows; the bound counts one landing lap there). -- **`ConvolutionReverb` (rebuilt, Phase 5, parked under `rebuilt/`):** a +- **`ConvolutionReverb` (rebuilt, Phase 5; adopted 2026-09-29):** a short room on one `audioconvolve.Convolver`, synthesized from Decay, Damping, Predelay, Diffusion and one of 64 Room seeds, or loaded from your own impulse (int16 frames or a 16-bit WAV at the graph's rate) with @@ -139,16 +144,20 @@ there, and are recorded in its changelog. delayed by exactly that, byte for byte, while the source keeps feeding it and nothing resets it. Measured mode loads at unit mean energy and refuses the synthesis macros with `IndexError`; no impulse ships. It - lives in `lib/audioeffects/rebuilt/convolutionreverb.py`, and - `audioeffects.ConvolutionReverb` is still the old class. It stands on + lives in `lib/audioeffects/rebuilt/convolutionreverb.py` until it comes + home, and it is what `audioeffects.ConvolutionReverb` and `create()` now + serve. It stands on audiodsp v0.6.3rc3: a room-knob move never drops or repeats a dry frame at any Mix, from the end of the block in flight the output is that of an instance that always had the new settings, and each side of a stereo room is unit energy on its own, so the room sits in the middle. `reset()` mid-stream silences the block in flight, dry included, and a Mix move lands at the end of it. The docstring is the player's text, - every claim in it tied to a test. Board cost is unmeasured. -- **`MultiTapDelay` (rebuilt, Phase 5, parked under `rebuilt/`):** the + every claim in it tied to a test. On the boards at audiodsp v0.6.3 every + patch runs in real time at 41.6-42.2 % of a block on the P4 and + 71.2-73.1 % on the S3, and every digest equals the desktop's or a + single-precision desktop build's (audiocomponents#75). +- **`MultiTapDelay` (rebuilt, Phase 5; adopted 2026-09-29):** the RE-201's multi-head modes, with the Echorec as a second reference. Heads sit on a grid of whole multiples of one base time, exactly, at every rate; Pattern is the RE-202's twelve head sets over that grid (mode 12 @@ -156,8 +165,9 @@ there, and are recorded in its changelog. `audioecho.FeedbackDelay` whose loop low-pass darkens once per lap, and Repeat Tone's out stop swaps to a lighter graph (patch 1, the one to stack on an S3). Mix 0 is a wire. It lives in - `lib/audioeffects/rebuilt/multitapdelay.py`, and - `audioeffects.MultiTapDelay` is still the old class. The tap node reads + `lib/audioeffects/rebuilt/multitapdelay.py` until it comes home, and it + is what `audioeffects.MultiTapDelay` and `create()` now serve. The tap + node reads its input one block behind the dry, so settings made before the first pull or after `reset()` land on the grid in both lanes. Any number of resets and returns from Mix 0, before the first pull or between two @@ -183,8 +193,12 @@ there, and are recorded in its changelog. docstring states the input ceiling, the click and channel crossing of a Time or Heads move while audio plays, and that on CircuitPython alone the stereo dry's right lane is one LSB hot within 32 LSB of the rails. - Board cost is unmeasured. -- **`Reverb` (rebuilt, Phase 5, parked under `rebuilt/`):** Dattorro's plate + On the boards at audiodsp v0.6.3 every patch runs in real time on the P4 + (36.0-72.5 % of a block); on the S3 patches 0-3, 5 and 6 do (57.8-78.1 %) + and patch 4 does not (5.567 ms, 104.4 %), so the docstring says patch 4 + needs a P4-class board and patch 3 may. Every digest equals the + desktop's or a single-precision desktop build's (audiocomponents#75). +- **`Reverb` (rebuilt, Phase 5; adopted 2026-09-29):** Dattorro's plate network on one `audioverb.Tank`, cut four ways as a Character macro: an EMT 140 plate that is dense at once and darkens as Decay shortens, and a room, a chamber and a hall that build. Decay is T60 at 500 Hz within 12 % @@ -194,10 +208,15 @@ there, and are recorded in its changelog. without losing a frame of the dry, `reset()` keeps the dry the Tank holds, and Tone's centre is handed as 0 dB. Mix 0 is a byte-exact wire while the tank keeps ringing; latency is zero. It lives in - `lib/audioeffects/rebuilt/reverb.py`, and `audioeffects.Reverb` is still - the old class. The Fender 6G15 spring character is parked until the node - carries a dispersive chain. Board cost is unmeasured. - Board cost is unmeasured. At audiodsp v0.6.3rc3 the class plays what it + `lib/audioeffects/rebuilt/reverb.py` until it comes home, and it is what + `audioeffects.Reverb` and `create()` now serve; the old `reverb.Reverb`, + with its `preset`, stays in `reverb.py` until then. The Fender 6G15 + spring character is parked until the node carries a dispersive chain. + On the boards at audiodsp v0.6.3 every patch runs in real time at + 19.0-23.4 % of a block on the P4 and 33.7-36.3 % on the S3; every digest + equals a single-precision desktop build's (audiocomponents#75) but patch + 7's, which differs from it by nine one-LSB samples of 65536 + (audiodsp#183). At audiodsp v0.6.3rc3 the class plays what it played at rc1; its docstring is now the player's text, each claim tied to a test, with a jumping control disclosed as a family limit (audiocomponents#117), and a return from Mix 0 said plainly to start @@ -205,6 +224,13 @@ there, and are recorded in its changelog. ### Changed +- **`ShimmerHall` and `AirSpace` keep the classes they were ported + against.** With the Phase 5 `TapeDelay` and `Reverb` adopted, the two + racks name the old `delay.TapeDelay` and `reverb.Reverb` directly (a + `Rack` chain entry may now be a class in place of a `NAME`), so they + build and sound as before; moving them onto the rebuilds would re-voice + them. A chain that names `"Reverb"` gets the rebuild, which takes + `character=` where the old class took `preset=`. - **The audiodsp pin moves to v0.6.3rc3.** The release carries the convolution node's two fixes (audiodsp#165: the block in flight kept across a re-synthesis, each side of a stereo room normalised on its own), diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 8478cfa..b12f16c 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -129,43 +129,43 @@ marginal share of one block on each board at construction defaults. | `Notch` | audiodsp (`audiobiquad`) | 5 | 0 samples | 5.6 % P4 / 9.6 % S3 of a block, **over** its 1.5 % / 5 % budget; a `" - lean"` patch is still owed and none was invented | none (the Twin-T was weighed and dropped on scope) - a band-stop whose `Width` is a bandwidth and not a depth, with a Harmonics toggle for mains hum. A `float` coefficient set cannot put the zeros exactly on the unit circle, so at 60 Hz it is a hum *reducer*, not an eliminator | | `LadderFilter` | audiodsp (`audioladder`) | 7 | 0 samples | 16.2 % P4 / 26.7 % S3 of a block at patch 4 against the palette-derived 17 % / 28 %; lean patch 6 is 8.6 % / 14.7 %. T5 and T1's stopband slope stay disconfirmed | the Moog transistor ladder - four one-pole stages round one global feedback loop with an odd saturator **inside** it, so the passband sinks as `Resonance` rises. That droop is the circuit | | `CombFilter` | audiodsp (`audioecho`, `audiobiquad`) | 6 | 0 samples | 7.4 % P4 / 12.2 % S3 of a block at patch 0 against the palette-derived 8 % / 13 %. The tail reaches exact zero at every Feedback and tuning since audiodsp v0.6.2 (#154; up to v0.6.1 it parked for ever above Feedback 0.5, +17.4 cents at 1760 Hz / 0.8): at most 7.8 s measured at 20 Hz / 0.95. Since 2026-09-28 `tail_samples` is finite at every setting: the lap bound on the longest line the read head may be at, plus Tone's memory, plus 1.5 s while the Trim (a fixed-point shelf, measured at up to 0.834 s) is in circuit; 900 renders over the surface end inside it. Feedback is handed as set; at a stall-window centre (the 0.95 stop is one) the bound counts one landing lap, since audiodsp v0.6.3rc1 lands a stalled loop low-pass (#157). Tone off is exactly 0 whatever came before, and Tone back in from silence plays nothing (the v0.6.2 node froze the low-pass and played 15 070 LSB at 48 kHz). The ring plays the asked fractional delay, 0.7 cents at 1760 Hz / 0.8, and the first-repeat tap lands within 0.01 cents. T2/T5 miss at fractional tunings | none - the naked textbook feedback comb `y(n) = x(n) + g·y(n−M)`: a delay short enough to be a pitch, fed back, so noise grows resonances on that note's harmonic series | -### Time and space - two adopted, the rest in `reverb.py`, `delay.py` - -Phase 5's first two classes, `DigitalDelay` and `SlapbackDelay`, were -**adopted on 2026-09-28**: `audioeffects.create()` serves the rebuilds, from -`lib/audioeffects/rebuilt/`, until they come home. Both are **audiodsp** tier -(one `audioecho.FeedbackDelay` each), zero latency at every setting, and -neither runs on a stock CircuitPython board. Cost is the class's own share of -one 256-frame stereo block at 48 kHz, measured on both boards at every -shipped patch; the S3 figures are the same-conditions reading (the class -measured beside the palette row it was priced from). +### Time and space - all eight adopted, served from `rebuilt/` + +Phase 5's eight classes are adopted: `DigitalDelay` and `SlapbackDelay` on +2026-09-28, the other six on 2026-09-29. `audioeffects.create()` serves the +rebuilds, from `lib/audioeffects/rebuilt/`, until they come home. All eight +are **audiodsp** tier, and none runs on a stock CircuitPython board. Cost is +the class's own share of one 256-frame stereo block at 48 kHz, measured on +both boards at every shipped patch. For the first two the S3 figures are the +same-conditions reading (the class measured beside the palette row it was +priced from); the other six were measured at audiodsp v0.6.3, where every +cell met the bar (at most 80 % of a block) and ran in real time on the P4, +and on the S3 every cell did but `MultiTapDelay`'s patch 4. Their digests +equal the desktop's, or a desktop MicroPython's built with single-precision +floats (a setting worked out in Python float, audiocomponents#75), except +`Reverb`'s patch 7 (below). | Class | Tier | Macros | Latency | Cost | Standout | |---|---|---|---|---|---| | `DigitalDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 at most 7.0 %** (0.372 ms, patch 5, rt 5.49) **/ S3 about 14.6 %** at patch 5 (about 0.78 ms corrected; 11.8 % as the tool reads it; rt 3.11), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed the S3 figure on 2026-09-28. No lean patch. RAM 155 584 B at the default 800 ms line (48 kHz); `max_time_ms` spends less | Boss DD-2 (1983), light touch: its control law and dry/wet discipline, Mix 0 a wire, Time bends the repeats in pitch at a rate Glide sets instead of clicking, every static Time on a whole frame. Repeat Tone and Repeat Cut put the pedal's 7 kHz and 40 Hz corners in the loop as knobs that default out; patch 5 is the pedal's own corners. `tail_samples` is finite with Repeat Cut out; Feedback is handed as set, and with Repeat Tone in the bound counts one landing lap at a stall-window centre (audiodsp v0.6.3rc1, #157). Both out stops are exactly 0, and either filter back in after silence plays nothing (at v0.6.2 Tone's played 26 443 LSB and Cut's 20 858). The node lands 18 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 18 at 22.05 kHz (patch 4 at 44.1 kHz: a click's first repeat reads 19 922 and 78), none at 48 kHz. **Digests:** six of seven patches identical on the P4, the S3 and the desktop; patch 5 identical board to board, and different from the desktop through single-precision Python floats in two derived settings (audiocomponents#75): `cut_hz` 0.035 % high (39.44766 Hz against 39.43366) and `damping_hz` one float32 step. **What the default surrenders:** no band limit and no compander, no HOLD, and Glide 0's instant knob clicks. | | `SlapbackDelay` | audiodsp (`audioecho`) | 6 | 0 samples | **P4 at most 7.4 %** (0.396 ms corrected; 6.4 % as the tool reads it; rt 5.82) **/ S3 14.6-14.7 %** at the default and patches 0-4 and **15.2 % at patch 5** (0.813 ms corrected, the one patch with Tone in; 11.6 % as the tool reads it; rt 3.08), against palette **9 % / 15 %** (FeedbackDelay +options); Brad passed patch 5 on 2026-09-28 ("It passes"). No lean patch. RAM about 50 KB (a fixed 251 ms line) | Sam Phillips' two-Ampex-350 slap at Sun Studio: one mono repeat at 135 ms, handed as a whole frame at every rate, with Saturation, Tone and Wow as the tape's colours and Repeats defaulting to 0. Level 0 is a wire; Time walks rather than clicks. Tone out is byte-identical to no filter whatever came before, and a Wow move glides over 20 ms (both since audiodsp v0.6.3rc1). The node lands 21 of the 128 Time positions one float32 step off the whole frame at 44.1 kHz and 20 at 22.05 kHz, none at 48 kHz and no shipped patch. **Digests:** all seven patches identical on the P4 and the S3, and different from the desktop through single-precision Python floats in a derived setting (audiocomponents#75): `wow_depth_ms` 2.1 x 10^-5 to 6.8 x 10^-5 ms high, under 0.0001 cent (patch 4's Feedback also one float32 step). **What the default surrenders:** Tone out, so the repeat is as bright as the dry, and Wow at 1 cent makes a 15 kHz repeat breathe down to -5.11 dB at 48 kHz. | - -| Class | Notes | -|---|---| -| `Reverb` | presets `room` `chamber` `hall` `plate` `spring` (spring adds pre-flutter) | -| `ConvolutionReverb` | a real impulse response, measured or synthesized; **patches** | -| `TapeDelay` | in-loop low-pass, soft-clip and per-sample wow; **patches** | -| `AnalogDelay` | BBD: band-limited both ends, `age` over the lot; **patches** | -| `PingPongDelay` | true cross-feed - repeats alternate sides; **patches** | -| `MultiTapDelay` | `(position, level)` tap patterns | - -`MultiTapDelay` is clean and runs on `audiodelays`, whose feedback path is -the echo times a decay. The other three are named after something that -happens *inside* that path - a filter taking a little more off each pass, a -soft-clip rounding it, a cross-feed sending it to the other speaker - so they -run on `audioecho.FeedbackDelay`, which is where audiodsp puts those, as do -the two adopted delays above. A delay's `max_time_ms` sizes its line and +| `TapeDelay` | audiodsp (`audioecho`) | 11 | 0 samples | **P4 13.4-14.1 %** (0.714-0.751 ms, rt 3.85-3.95) **/ S3 10.0-12.8 %** (0.531-0.683 ms, rt 2.51-2.70) at every shipped patch, audiodsp v0.6.3; the lean patch 8 is the cheapest on both. RAM 250 064 B | The RE-201's motor (`"varispeed"`, the default: a Time move bends the pitch for exactly the new time) and the EP-3's sliding head (`"sliding-head"`: the pitch bends only while the head moves, at a rate Glide sets) as characters. Each repeat darkens by a playback loss law that follows Spacing and the tape speed; Wow and Flutter drive one wow table; Record Level is the loop's squash. Mix 0 is a wire. | +| `AnalogDelay` | audiodsp (`audioecho`) | 8 | 0 samples | **P4 7.0-7.5 %** (0.374-0.399 ms, rt 5.19-5.31) **/ S3 14.0-14.5 %** (0.746-0.774 ms, rt 3.06-3.12), audiodsp v0.6.3. RAM 118 896 B | A bucket brigade: the Boss DM-2's one 4096-stage line (`"single-line"`) and the Deluxe Memory Man's two in series (`"double-line"`). Time is the line's clock, so the repeats darken as Time grows and a Time move bends their pitch instead of clicking; every Time lands on a whole frame. No sample-and-hold, no compander, so short Times are brighter than either pedal. | +| `PingPongDelay` | audiodsp (`audioecho`) | 9 | 0 samples | **P4 11.9-12.2 %** (0.636-0.649 ms, rt 4.20-4.22) **/ S3 16.4-16.6 %** (0.872-0.887 ms, rt 2.87-2.90), audiodsp v0.6.3; every digest equals the desktop's. RAM 194 112 B | Repeats that alternate between the speakers, on one node whose cross-feed and input pan Spread moves from two plain delays to the full bounce. The dry is each channel's own signal and the mono sum is an ordinary feedback delay exactly; Repeat Tone and Repeat Cut put a low-pass and a high-pass in the loop, defaulting out. | +| `MultiTapDelay` | audiodsp (`audioecho`, `audiodelays`) | 9 | 0 samples | **P4 36.0-72.5 %** (1.922-3.865 ms, rt 1.19-2.09) **/ S3 57.8-78.1 %** at patches 0-3, 5 and 6 (rt 1.01-1.34), audiodsp v0.6.3. **Patch 4 misses on the S3**: 5.567 ms, 104.4 % of a block, rt 0.80, so it needs a P4-class board, and patch 3 may; the docstring says so. RAM 661 376 B | The RE-201's multi-head modes, with the Echorec as a second reference: heads on a grid of whole multiples of one base time, Pattern the RE-202's twelve head sets, laps round a loop low-pass that darkens once per lap. Repeat Tone's out stop swaps to a lighter graph (patch 1, the one to stack on an S3). Mix 0 is a wire. | +| `Reverb` | audiodsp (`audioverb`) | 13 | 0 samples | **P4 19.0-23.4 %** (1.012-1.249 ms, rt 2.84-3.23) **/ S3 33.7-36.3 %** (1.799-1.935 ms, rt 1.84-1.93), audiodsp v0.6.3. RAM 49 120-149 904 B by patch | Dattorro's plate network on one `audioverb.Tank`, cut four ways by Character: an EMT 140 plate, dense at once, and a room, a chamber and a hall that build. Decay is T60 at 500 Hz within 12 % from 2 s to 10 s at the reference patches; Mix 0 is a byte-exact wire while the tank rings. The spring character is parked. **Digests:** patch 7's differs from the single-precision desktop build by nine one-LSB samples of 65536 (audiodsp#183). | +| `ConvolutionReverb` | audiodsp (`audioconvolve`) | 6 | 256 frames with an impulse loaded, 0 without | **P4 41.6-42.2 %** (2.217-2.249 ms, rt 1.85-1.88) **/ S3 71.2-73.1 %** (3.796-3.900 ms, rt 1.07-1.09) at the default 0.08 s, audiodsp v0.6.3. RAM 142 544 B | A short room synthesized from Decay, Damping, Predelay, Diffusion and one of 64 Room seeds, or your own impulse (int16 frames or a 16-bit WAV) with only Mix live. `seconds` is the allocation, from 0.06 s to the node's 512-partition ceiling. | + +The old classes (`Reverb`'s `preset`, `TapeDelay`'s `wow`, `AnalogDelay`'s +`age`, `MultiTapDelay`'s `(position, level)` taps) stay in `delay.py` and +`reverb.py` until the rebuilds come home, and `ShimmerHall` and `AirSpace` +still build on them (see [Effect racks](#effect-racks---rackpy)). A delay's `max_time_ms` sizes its line and cannot change afterwards; at 48 kHz a second of stereo line is 192 KB, so ask for what will be used. -The two reverbs are not the same kind of thing. `Reverb` is `audiofreeverb`: -a fixed network of delay lines that costs the same on a Cortex-M0 as on a -workstation, and that sounds like a plausible room. `ConvolutionReverb` is +The two reverbs are not the same kind of thing. `Reverb` is `audioverb`: a +fixed network of delay lines whose cost does not depend on how long the room +rings, and that sounds like a plausible room. `ConvolutionReverb` is `audioconvolve`: it applies an actual impulse response, so it sounds like a *particular* room - and one second of stereo impulse is about 1.5 MB and ~150 MFLOPS, which is a desktop or an offline render. Reach for `Reverb` @@ -262,7 +262,7 @@ micropython-vst3's soundtrack builds its custom racks: rack = audioeffects.create("Rack", source, 48000, chain=( ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}), ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}), - ("Reverb", {"preset": "hall", "mix": 0.3}), + ("Reverb", {"character": "hall", "mix": 0.3}), )) ``` @@ -275,7 +275,10 @@ unbounded), and its `reset()` clears every child's DSP history without reapplying the children's own patches over the options the rack built them with. `ShimmerHall` and `AirSpace` are the two racks the vst3 soundtrack shares between pieces, ported whole: fixed topologies whose -macros move their children's controls. +macros move their children's controls. They name the classes they were +ported against, `delay.TapeDelay` and `reverb.Reverb`, directly: a chain +entry may be a class in place of a `NAME`. Moving them onto the rebuilds +adopted on 2026-09-29 would re-voice them, and is not part of the adoption. ## Deliberately absent diff --git a/lib/audioeffects/_core.py b/lib/audioeffects/_core.py index 401e85d..a171a8b 100644 --- a/lib/audioeffects/_core.py +++ b/lib/audioeffects/_core.py @@ -10,7 +10,7 @@ comp = audioeffects.create("Compressor", source, 48000, threshold_db=-20, ratio=3) verb = audioeffects.create("Reverb", comp.output, 48000, - preset="hall", mix=0.3) + character="hall", mix=0.3) audio_out.play(verb.output) The underlying audiodsp nodes are kept as attributes so applications can bind diff --git a/lib/audioeffects/rack.py b/lib/audioeffects/rack.py index efcba67..3e1d5bc 100644 --- a/lib/audioeffects/rack.py +++ b/lib/audioeffects/rack.py @@ -10,7 +10,7 @@ rack = audioeffects.create("Rack", source, 48000, chain=( ("Compressor", {"threshold_db": -24.0, "ratio": 3.0}), ("TapeDelay", {"time_ms": 340.0, "mix": 0.25}), - ("Reverb", {"preset": "hall", "mix": 0.3}), + ("Reverb", {"character": "hall", "mix": 0.3}), )) audio_out.play(rack.output) @@ -19,6 +19,13 @@ fixed topologies with a macro surface over the children, so they carry patches the way any patchable effect here does. Racks may contain and be used by other racks: a `chain` entry may itself be a `("Rack", {...})`. + +Those two racks name the classes they were ported against, `delay.py`'s +`TapeDelay` and `reverb.py`'s `Reverb`, directly rather than by NAME: the +rebuilt classes the package serves under those names since their adoption +on 2026-09-29 take different settings (no `preset`, no `wow`) and sound +different, and moving a rack onto them is a re-voicing of the rack, not a +part of the adoption. """ VENDOR = "PyDevices" @@ -27,10 +34,15 @@ from ._component import macro_position as _macro_position from ._component import midi_of_position as _midi_of_position from ._component import port_target as _port_target +from .delay import TapeDelay as _OldTapeDelay +from .reverb import Reverb as _OldReverb def _child(name, tail, options): - """One chain entry built around `tail` at the configured rate.""" + """One chain entry built around `tail` at the configured rate. `name` + is an effect NAME, or, for the soundtrack racks below, a class.""" + if not isinstance(name, str): + return name.create(tail, _core.sample_rate(), **options) import audioeffects return audioeffects.create(name, tail, _core.sample_rate(), **options) @@ -175,9 +187,9 @@ def __init__(self, source, shimmer=0.55, echo=0.36, space=0.42, # skipped for starting at zero could never come back. Rack.__init__(self, source, chain=( ("Octaver", {"down": 0.0, "up": 1.0}), - ("TapeDelay", {"time_ms": 420.0, "feedback": 0.5, "wow": 0.3, - "drive": 0.2}), - ("Reverb", {"preset": "hall"}), + (_OldTapeDelay, {"time_ms": 420.0, "feedback": 0.5, + "wow": 0.3, "drive": 0.2}), + (_OldReverb, {"preset": "hall"}), )) self.octave, self.tape, self.hall = self.effects self._init_macros((shimmer, echo, space, tone_hz), patch) @@ -215,8 +227,8 @@ def __init__(self, source, space=0.3, echo=0.25, frequency=4200.0, patch=None): Rack.__init__(self, source, chain=( ("LowPass", {"frequency": 4200.0}), - ("TapeDelay", {"time_ms": 375.0, "feedback": 0.4}), - ("Reverb", {"preset": "hall"}), + (_OldTapeDelay, {"time_ms": 375.0, "feedback": 0.4}), + (_OldReverb, {"preset": "hall"}), )) self.tone, self.tape, self.hall = self.effects self._init_macros((space, echo, frequency), patch) diff --git a/lib/audioeffects/rebuilt/__init__.py b/lib/audioeffects/rebuilt/__init__.py index 5fffad7..51df1d5 100644 --- a/lib/audioeffects/rebuilt/__init__.py +++ b/lib/audioeffects/rebuilt/__init__.py @@ -104,9 +104,26 @@ def _present(): #: `SlapbackDelay`, `CombFilter` and the FeedbackDelay classes Phase 5 is #: still building, and coming home moves it. That is a separate step, after #: those classes land. +#: +#: Phase 5's last six, adopted 2026-09-29: `TapeDelay`, `AnalogDelay`, +#: `PingPongDelay`, `MultiTapDelay`, `Reverb` and `ConvolutionReverb`. The +#: board session at audiodsp v0.6.3 met the cost bar (80 % of a 5.333 ms +#: stereo block) and real time at every cell on the ESP32-P4, and on the +#: ESP32-S3 at every cell but MultiTapDelay's patch 4 (5.567 ms), which its +#: docstring discloses on Brad's ruling. Each board digest equals the +#: desktop's, or a desktop MicroPython's built with single-precision floats +#: (audiocomponents#75); Reverb's patch 7 differs from that build by nine +#: one-LSB samples of 65536 (audiodsp#183). They stay under this directory +#: with the first two, for the same reason. ADOPTED = ( "DigitalDelay", "SlapbackDelay", + "TapeDelay", + "AnalogDelay", + "PingPongDelay", + "MultiTapDelay", + "Reverb", + "ConvolutionReverb", ) diff --git a/tests/test_cpython_effects_analogdelay.py b/tests/test_cpython_effects_analogdelay.py index 042b31f..3ce1471 100644 --- a/tests/test_cpython_effects_analogdelay.py +++ b/tests/test_cpython_effects_analogdelay.py @@ -21,8 +21,8 @@ class built as a wire. T1 is unmeasured and T2b, T4 and T5 are disconfirmed full spans, the stereo renders the rows name, the three interpreters and the M5 table live in the evidence pack, not in this file. -The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by -`rebuilt.module_class("AnalogDelay")`. +The class is reached by `rebuilt.module_class("AnalogDelay")`, which is also what +`audioeffects.AnalogDelay` serves since its adoption on 2026-09-29. The dossier's laws are written out here independently of the class: the corner is 0.2211 N / T, the Time map 20 * 30^position ms, the whole frame @@ -1349,11 +1349,17 @@ def test_macros_patches_tier_latency(self): effect.program_change(3) self.assertEqual(effect.patch_index, 3) - def test_parked_not_served(self): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" import audioeffects - self.assertNotIn("AnalogDelay", rebuilt.ADOPTED) - self.assertIn("AnalogDelay", rebuilt.parked()) - self.assertIsNot(audioeffects.AnalogDelay, AnalogDelay) + self.assertIn("AnalogDelay", rebuilt.ADOPTED) + self.assertNotIn("AnalogDelay", rebuilt.parked()) + self.assertIs(audioeffects.AnalogDelay, AnalogDelay) + served = audioeffects.create("AnalogDelay", silence_src(64), RATE) + self.assertIsInstance(served, AnalogDelay) + served.deinit() def test_patches_are_the_dossier_settings_on_the_grid(self): # Section 6's table, settings in the macros' own units, through diff --git a/tests/test_cpython_effects_convolutionreverb.py b/tests/test_cpython_effects_convolutionreverb.py index a7f59e3..999668a 100644 --- a/tests/test_cpython_effects_convolutionreverb.py +++ b/tests/test_cpython_effects_convolutionreverb.py @@ -998,10 +998,19 @@ def test_macros_patches_tier_capabilities(self): self.assertEqual(effect.patch_index, 3) effect.deinit() - def test_the_module_is_what_the_registry_builds(self): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects from audioeffects import rebuilt as registry self.assertIs(registry.module_class(NAME), ConvolutionReverb) - self.assertIn(NAME, registry.parked()) + self.assertIn(NAME, registry.ADOPTED) + self.assertNotIn(NAME, registry.parked()) + self.assertIs(audioeffects.ConvolutionReverb, ConvolutionReverb) + served = audioeffects.create(NAME, switchable(), RATE) + self.assertIsInstance(served, ConvolutionReverb) + served.deinit() def test_patch_0_is_the_constructor_grid(self): effect = build() diff --git a/tests/test_cpython_effects_library.py b/tests/test_cpython_effects_library.py index 55807d7..f1aec81 100644 --- a/tests/test_cpython_effects_library.py +++ b/tests/test_cpython_effects_library.py @@ -36,6 +36,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) from effects_measure import SAMPLE_RATE, build, peak, source # noqa: E402 +# `reverb.Reverb` is the old `_core.Effect` class, the one with `preset` and +# no macro surface, which `audioeffects.Reverb` served until the Phase 5 +# rebuild's adoption on 2026-09-29. The two tests that name it are about +# those two properties of that class. +from audioeffects import reverb # noqa: E402 + #: Every name the package exports as an effect. `ALL` is the catalogue the #: contract freezes - the provider names, computed by the package itself - #: so these tests read it rather than deriving the set from `dir()` again. @@ -82,7 +88,7 @@ def test_effects_chain_into_each_other(self): drive = audioeffects.Exciter(source(), tune=2500.0, harmonics=0.4) comp = audioeffects.Compressor(drive.output, threshold_db=-30.0, ratio=6.0, character="optical") - verb = audioeffects.Reverb(comp.output, preset="hall", mix=0.35) + verb = reverb.Reverb(comp.output, preset="hall", mix=0.35) self.assertGreater(peak(verb.output, 12), 0.001) def test_an_instrument_feeds_an_effect(self): @@ -156,7 +162,7 @@ def test_a_program_change_to_a_patch_that_is_not_there_is_ignored(self): self.assertEqual(effect._macros, before) def test_a_class_without_macros_declares_an_empty_macro_surface(self): - plain = audioeffects.Reverb(source()) + plain = reverb.Reverb(source()) self.assertEqual(plain.MACRO_LABELS, ()) self.assertEqual(plain.MACRO_MODES, {}) self.assertEqual(plain.PATCHES, {0: ("Default", ())}) diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 1ccf950..49fbd6e 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -13,8 +13,8 @@ class built as a wire. The full spans, the three interpreters and the rates dossier, never taken from the class: the whole-frame landing, the lap clamp, S1's head sets, Tilt's line and the lap node's float32 hand-off. -The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by -`rebuilt.module_class("MultiTapDelay")`. +The class is reached by `rebuilt.module_class("MultiTapDelay")`, which is also what +`audioeffects.MultiTapDelay` serves since its adoption on 2026-09-29. Three plants are built for real here that Station A emulated: T4's per-head 6 kHz low-pass (a second tap node for head 2 behind an @@ -1293,9 +1293,17 @@ def test_macros_patches_tier_latency(self): effect.program_change(3) self.assertEqual(effect.patch_index, 3) - def test_parked_not_served(self): - self.assertNotIn("MultiTapDelay", rebuilt.ADOPTED) - self.assertIsNot(audioeffects.MultiTapDelay, MultiTapDelay) + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + self.assertIn("MultiTapDelay", rebuilt.ADOPTED) + self.assertNotIn("MultiTapDelay", rebuilt.parked()) + self.assertIs(audioeffects.MultiTapDelay, MultiTapDelay) + served = audioeffects.create( + "MultiTapDelay", Endless(silence(), RATE, 2), RATE) + self.assertIsInstance(served, MultiTapDelay) + served.deinit() def test_patch_table_is_the_dossier_on_the_grid(self): for index, (name, values) in enumerate(DOSSIER_PATCHES): diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index 759f99c..3a1db96 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -15,8 +15,8 @@ dossier, never taken from the class: the whole-frame landing, the Time span, the Spread law, the reference mono delay. -The rebuild is parked (not in `rebuilt.ADOPTED`), so the class is reached by -`rebuilt.module_class("PingPongDelay")`. +The class is reached by `rebuilt.module_class("PingPongDelay")`, which is also what +`audioeffects.PingPongDelay` serves since its adoption on 2026-09-29. """ import math @@ -902,11 +902,17 @@ def test_macros_patches_tier_latency(self): effect.program_change(3) self.assertEqual(effect.patch_index, 3) - def test_parked_not_served(self): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" import audioeffects - self.assertNotIn("PingPongDelay", rebuilt.ADOPTED) - self.assertIn("PingPongDelay", rebuilt.parked()) - self.assertIsNot(audioeffects.PingPongDelay, PingPongDelay) + self.assertIn("PingPongDelay", rebuilt.ADOPTED) + self.assertNotIn("PingPongDelay", rebuilt.parked()) + self.assertIs(audioeffects.PingPongDelay, PingPongDelay) + served = audioeffects.create("PingPongDelay", silence_src(64), RATE) + self.assertIsInstance(served, PingPongDelay) + served.deinit() def test_patches_are_the_dossier_settings_on_the_grid(self): for index, (name, values) in enumerate(DOSSIER_PATCHES): diff --git a/tests/test_cpython_effects_racks.py b/tests/test_cpython_effects_racks.py index b09ce91..b711b96 100644 --- a/tests/test_cpython_effects_racks.py +++ b/tests/test_cpython_effects_racks.py @@ -19,6 +19,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) from effects_measure import SAMPLE_RATE, peak, source # noqa: E402 +# `reverb.Reverb` is the old `_core.Effect` class, the one with `preset`, +# which `audioeffects.Reverb` served until the Phase 5 rebuild's adoption on +# 2026-09-29. A chain entry may name a class in place of a NAME, which is +# how `ShimmerHall` and `AirSpace` keep it; these tests do the same. +from audioeffects import reverb # noqa: E402 + class RackTest(unittest.TestCase): """The rack kind: one component whose graph is several effects. @@ -31,7 +37,7 @@ class RackTest(unittest.TestCase): """ CHAIN = (("Overdrive", {"drive": 0.3, "mix": 0.4}), - ("Reverb", {"preset": "plate", "mix": 0.25})) + (reverb.Reverb, {"preset": "plate", "mix": 0.25})) def test_a_chain_spec_builds_children_in_order_and_renders(self): rack = audioeffects.create("Rack", source(), SAMPLE_RATE, @@ -67,7 +73,7 @@ def test_racks_nest(self): # "Racks may contain and be used by other racks" - both directions. inner = ("Rack", {"chain": (("Saturation", {"drive_db": 6.0}),)}) outer = audioeffects.Rack(source(), chain=( - inner, ("Reverb", {"preset": "room", "mix": 0.2}))) + inner, (reverb.Reverb, {"preset": "room", "mix": 0.2}))) self.assertEqual(type(outer.effects[0]).__name__, "Rack") self.assertGreater(peak(outer.output, 8), 0.001) diff --git a/tests/test_cpython_effects_reverb.py b/tests/test_cpython_effects_reverb.py index a35da72..e84a87e 100644 --- a/tests/test_cpython_effects_reverb.py +++ b/tests/test_cpython_effects_reverb.py @@ -695,6 +695,21 @@ def handed_cut(effect): # -- the surface ------------------------------------------------------------- class TheSurface(unittest.TestCase): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects + self.assertIs(rebuilt.module_class("Reverb"), Reverb) + self.assertIn("Reverb", rebuilt.ADOPTED) + self.assertNotIn("Reverb", rebuilt.parked()) + self.assertIs(audioeffects.Reverb, Reverb) + served = audioeffects.create( + "Reverb", probes.ArraySource(interleave((), 2, 64), rate=RATE, + channels=2, block=64), RATE) + self.assertIsInstance(served, Reverb) + served.deinit() + def test_macros_patches_tier_latency(self): self.assertIs(rebuilt.module_class("Reverb"), Reverb) self.assertEqual(Reverb.MACRO_LABELS, ( diff --git a/tests/test_cpython_effects_tapedelay.py b/tests/test_cpython_effects_tapedelay.py index 04d2683..1497cc4 100644 --- a/tests/test_cpython_effects_tapedelay.py +++ b/tests/test_cpython_effects_tapedelay.py @@ -665,11 +665,19 @@ def test_macros_characters_tier_latency(self): with self.assertRaises(ValueError): TapeDelay(src_of(np.zeros(512)), character="reel") - def test_parked_not_served(self): + def test_adopted_is_what_the_package_serves(self): + """Adopted on 2026-09-29, so `create()` serves this one. It was the + reverse assertion while the class was parked; revert + `rebuilt.ADOPTED` and this goes red.""" + import audioeffects self.assertIs(rebuilt.module_class("TapeDelay"), TapeDelay) - self.assertNotIn("TapeDelay", rebuilt.ADOPTED) - self.assertIn("TapeDelay", rebuilt.parked()) - self.assertIsNone(rebuilt.load("TapeDelay")) + self.assertIn("TapeDelay", rebuilt.ADOPTED) + self.assertNotIn("TapeDelay", rebuilt.parked()) + self.assertIs(rebuilt.load("TapeDelay"), TapeDelay) + self.assertIs(audioeffects.TapeDelay, TapeDelay) + served = audioeffects.create("TapeDelay", src_of(np.zeros(64)), RATE) + self.assertIsInstance(served, TapeDelay) + served.deinit() def test_patches_are_the_dossier_settings_on_the_grid(self): # Section 6's table: Time, Fdbk, Mix, Glide, Wow, Flutter, Rec, diff --git a/tests/test_cpython_effects_time.py b/tests/test_cpython_effects_time.py index ad18246..45a4030 100644 --- a/tests/test_cpython_effects_time.py +++ b/tests/test_cpython_effects_time.py @@ -27,6 +27,13 @@ from effects_measure import (SAMPLE_RATE, burst, channels, loudest_in, peak, source, tone_gain_db) # noqa: E402 +# The tests below that name `delay.` or `reverb.` are written against the old +# `_core.Effect` classes those modules keep. `audioeffects.TapeDelay`, +# `AnalogDelay` and `ConvolutionReverb` serve the Phase 5 rebuilds since +# their adoption on 2026-09-29, which take different settings (no `wow`, +# `age` or `stereo`) and have no `clear()`. +from audioeffects import delay, reverb # noqa: E402 + class TimeTest(unittest.TestCase): def test_a_tape_delay_leaves_the_dry_path_alone(self): @@ -47,10 +54,10 @@ def test_a_tape_delay_darkens_each_repeat_more_than_the_last(self): # through it once per lap, so the third is darker than the first. # Measured as how much of each tone survives three laps. def survival(hz): - delay = audioeffects.TapeDelay(burst(hz), time_ms=100.0, - feedback=0.7, mix=1.0, wow=0.0, - tone_hz=3000.0, drive=0.0) - left, _ = channels(delay.output, 200) + line = delay.TapeDelay(burst(hz), time_ms=100.0, + feedback=0.7, mix=1.0, wow=0.0, + tone_hz=3000.0, drive=0.0) + left, _ = channels(line.output, 200) step = int(0.1 * SAMPLE_RATE) first = loudest_in(left, step, 2000) third = loudest_in(left, step * 3, 2000) @@ -80,26 +87,26 @@ def test_an_older_analog_delay_is_darker_and_narrower(self): # `age` is one knob over the loop's low-pass, its high-pass and its # drift. Only the first two are measurable as a level. def repeat_level(hz, age): - delay = audioeffects.AnalogDelay(burst(hz), time_ms=100.0, - feedback=0.6, mix=1.0, age=age, - drive=0.0) - left, _ = channels(delay.output, 200) + line = delay.AnalogDelay(burst(hz), time_ms=100.0, + feedback=0.6, mix=1.0, age=age, + drive=0.0) + left, _ = channels(line.output, 200) return loudest_in(left, int(0.2 * SAMPLE_RATE), 2000) self.assertLess(repeat_level(6000.0, 1.0), repeat_level(6000.0, 0.0)) self.assertLess(repeat_level(80.0, 1.0), repeat_level(80.0, 0.0)) def test_a_delay_line_can_be_emptied(self): - delay = audioeffects.TapeDelay(burst(1000.0), time_ms=100.0, - feedback=0.8, mix=1.0) - channels(delay.output, 40) - delay.clear() - self.assertEqual(peak(delay.output, 4), 0.0) + line = delay.TapeDelay(burst(1000.0), time_ms=100.0, + feedback=0.8, mix=1.0) + channels(line.output, 40) + line.clear() + self.assertEqual(peak(line.output, 4), 0.0) def test_a_synthesized_room_decays_at_the_time_it_was_asked_for(self): - verb = audioeffects.ConvolutionReverb(source(), seconds=0.5) + verb = reverb.ConvolutionReverb(source(), seconds=0.5) verb.set_macro(4, 127) # full wet, so only the tail is measured - low, high = audioeffects.ConvolutionReverb._MACRO_RANGES[0][:2] + low, high = reverb.ConvolutionReverb._MACRO_RANGES[0][:2] for knob in (127, 64, 0): verb.set_macro(0, knob) expected = 0.5 * (low + (high - low) * knob / 127.0) @@ -108,8 +115,8 @@ def test_a_synthesized_room_decays_at_the_time_it_was_asked_for(self): def test_a_synthesized_room_is_not_the_same_noise_on_both_sides(self): # A stereo impulse whose channels agreed would be a mono impulse, and # the whole reason to spend twice the memory is that they do not. - verb = audioeffects.ConvolutionReverb(source(), seconds=0.25, - stereo=True) + verb = reverb.ConvolutionReverb(source(), seconds=0.25, + stereo=True) verb.set_macro(4, 127) left, right = [], [] for _ in range(12): diff --git a/tests/test_rebuilt_registry.py b/tests/test_rebuilt_registry.py index 2e627e9..9678cf7 100644 --- a/tests/test_rebuilt_registry.py +++ b/tests/test_rebuilt_registry.py @@ -865,7 +865,11 @@ def test_phase2_names_have_left_adopted(self): # for Flanger and the phases to come. Phase 5's first two, # `DigitalDelay` and `SlapbackDelay`, were adopted on 2026-09-28 on # Brad's rulings, and are served from here until they come home. - self.assertEqual(rebuilt.ADOPTED, ("DigitalDelay", "SlapbackDelay")) + # Phase 5's last six followed on 2026-09-29, on the board session + # at audiodsp v0.6.3. + self.assertEqual(rebuilt.ADOPTED, ( + "DigitalDelay", "SlapbackDelay", "TapeDelay", "AnalogDelay", + "PingPongDelay", "MultiTapDelay", "Reverb", "ConvolutionReverb")) self.assertEqual(rebuilt.adopted(), rebuilt.ADOPTED) for name in rebuilt.ADOPTED: self.assertIs(rebuilt.load(name), rebuilt.module_class(name))