From 965f7109380f0ebdf9541e0da8d438fcc3292dbf Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:49:43 -0500 Subject: [PATCH 001/117] Pin audiodsp at the Phase 7 node batch (bd7f409), and qualify audiodelays.Flanger in the node matrix AUDIODSP_PIN names the 7b candidate by commit until v0.6.6rc2 is cut. NodeFlanger joins the node matrix: green on CPython and MicroPython with its sweep's free-running LFO declared (reset, Rate moves, patches), and its E8 skipped on CircuitPython, where upstream's Flanger never returns from a looped source that ends empty, as Echo and the pitch shifters already are. NodePitchShift declares E1 with E2 and a freeze set mid-splice, and its CircuitPython difference is restated to the splice alignment (285 cells). --- AUDIODSP_PIN | 17 +++++++++++- tests/support/lifecycle.py | 3 ++ tests/support/node_matrix.py | 53 +++++++++++++++++++++++++++++++++++- tests/test_node_matrix.py | 8 ++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index d337e91..70a685e 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -345,5 +345,20 @@ # holds its line still at mix 0 (#226); audiovorbis (#238, #239); and an # fmod-free angle reduction for the RP2040 (#225). # +# 2026-10-13: v0.6.6rc1 -> bd7f409, the effects program's Phase 7 node batch +# (step 7b): the candidate for audiodsp's next pre-release, v0.6.6rc2, and +# the final pin of the phase. Named by commit until the tag is cut; the tag +# names the same source. What moves beneath the components, by `git log +# v0.6.6rc1..bd7f409 -- src`: audiodelays.Flanger lets go of a looped source that +# comes back empty and gives a stalled one silence, instead of spinning the +# pull; audioecho.FeedbackDelay's upward loop_semitones writes its line +# through a band limit at 0.7 of the frequency the next pass would carry to +# Nyquist, so a climb fades out at the top instead of folding back; and both +# shifters (the loop's two taps and audiodelays.PitchShift's splice) move a +# splice's incoming side into phase with the outgoing one, so a held note +# plays its shifted pitch instead of cancelling. Unshifted and downward +# FeedbackDelay renders, silence and a lone click through either shifter, and +# a frozen PitchShift window render the bytes they did. +# # -- ref is what CI installs (git+https://...@) -v0.6.6rc1 30cbbf619751ab6ff7c40d99336907af0a081fa6 +bd7f4096a4aa9e4fb467c3c5e1b0d45434142a66 bd7f4096a4aa9e4fb467c3c5e1b0d45434142a66 diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index d4ca726..9283fc5 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -660,6 +660,9 @@ "upstream's GranularPitchShift never returns from a pull on a looped" " source that ends empty; audiodsp#243 fixed it on CPython and" " MicroPython", + ("NodeFlanger", "E8-", "circuitpython"): + "upstream's Flanger never returns from a pull on a looped source" + " that ends empty; audiodsp 7b fixed it on CPython and MicroPython", ("ShimmerHall", "E8-", "circuitpython"): "Bloom pulls upstream's PitchShift, which never returns from a pull" " on a looped source that ends empty (audiodsp#243 fixed it on" diff --git a/tests/support/node_matrix.py b/tests/support/node_matrix.py index 87e45c5..06a4041 100644 --- a/tests/support/node_matrix.py +++ b/tests/support/node_matrix.py @@ -348,6 +348,37 @@ def _apply_macro(self, index, position): self._node.decay = decay +class NodeFlanger(_Node): + """`audiodelays.Flanger`: CircuitPython's flanger node, which Phase 7's + `Flanger` is rebuilt on. Mix is a wire at 0.""" + NAME = "NodeFlanger" + TIER = _component.STOCK + REQUIRES = () + MACRO_LABELS = ("Mix", "Rate", "Depth", "Feedback") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", + 3: "BIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (0.0, 5.0), (0.0, 1.0), (-0.9, 0.9)) + PATCHES = {0: ("Jet", (127, 13, 127, 115)), + 1: ("Slow sweep", (64, 3, 96, 80)), + 2: ("Negative", (96, 26, 64, 13))} + #: 0.9 ** n * 32768 < 1 at n = 99 repeats of the 10 ms line, plus one + #: sweep's worth of line. + _TAIL_S = 99 * 0.010 + 0.02 + + def _build(self, mix=0.5, rate=0.5, depth=0.5, feedback=0.5, + patch=None): + self._node = self._register(audiodelays.Flanger( + max_delay_ms=10, min_delay_ms=1.0, rate=rate, depth=depth, + feedback=feedback, mix=mix, **self._pcm(512))) + self._node.play(self._source, loop=True) + self._output = self._node + self._init_macros((mix, rate, depth, feedback), patch) + + def _apply_macro(self, index, position): + name = ("mix", "rate", "depth", "feedback")[index] + setattr(self._node, name, self._values()[index]) + + class NodeComparator(_Node): """`audioshaper.Waveshaper` with a step curve: the Octaver's comparator ahead of its divider.""" @@ -410,7 +441,8 @@ def _apply_macro(self, index, position): NODES = {} for _cls in (NodePitchShift, NodeGranularPitchShift, NodeShimmerLoop, NodeSubOctave, NodeSquare, NodeMidSide, NodeSplitMix, - NodeBiquad, NodeEcho, NodeComparator, NodeAllPass): + NodeBiquad, NodeEcho, NodeFlanger, NodeComparator, + NodeAllPass): NODES[_cls.NAME] = _cls @@ -448,6 +480,13 @@ def _apply_macro(self, index, position): ("NodePitchShift", "E2-", "P4"): "reset_buffer restarts the splice phase, so the node matches a fresh" " one, not the uninterrupted control", + ("NodePitchShift", "E1-", "P4"): + "as E2: a reset starts the splice again, and the splice's move" + " depends on what played, so the node matches a fresh one, not the" + " uninterrupted control", + ("NodePitchShift", "E4-m2=127", "P4"): + "a freeze set while a splice crossfades finishes that splice's move" + " first, so the frozen loop reads from where the control's does not", ("NodePitchShift", "E4-m1", "P4"): "a Semitones move runs the read position at another rate, so the" " splice phase lands elsewhere; putting it back would click", @@ -455,6 +494,18 @@ def _apply_macro(self, index, position): "as E4: a patch with other semitones moves the splice phase", ("NodePitchShift", "E11-", "P4"): "as E4: moves before a pull move the splice phase", + ("NodeFlanger", "E1-", "P4"): + "a reset starts the sweep's LFO again from the top, so the node" + " matches a fresh one, not the uninterrupted control", + ("NodeFlanger", "E2-", "P4"): + "as E1: the host reset starts the sweep again", + ("NodeFlanger", "E4-m1=", "P4"): + "a Rate move runs the free-running sweep at another rate, so its" + " phase lands elsewhere; putting it back would jump the sweep", + ("NodeFlanger", "E6-", "P4"): + "as E4: a patch with another Rate moves the sweep's phase", + ("NodeFlanger", "E11-", "P4"): + "as E4: Rate moves before a pull move the sweep's phase", ("NodeGranularPitchShift", "E1-", "P4"): "a reset restarts the grain clock, so grains never line up with the" " uninterrupted control again", diff --git a/tests/test_node_matrix.py b/tests/test_node_matrix.py index e41b86f..de5b992 100644 --- a/tests/test_node_matrix.py +++ b/tests/test_node_matrix.py @@ -48,9 +48,11 @@ "head: upstream's Echo plays one stray sample when it grows back " "(audiodsp#241)"), "NodePitchShift": ( - 12, "1e08a674", None, None, - "upstream's reset keeps the read and write positions, so after a " - "reset at the fifth the splice falls elsewhere (audiodsp#240)"), + 285, "3c88af12", None, None, + "upstream's splice lands wherever the window does and ours moves " + "each splice's incoming side into phase (audiodsp 7b), so nearly " + "every cell that splices live audio renders other bytes; and " + "upstream's reset keeps the read and write positions (audiodsp#240)"), "NodeGranularPitchShift": ( 4, "91e67a25", None, None, "upstream's reset_buffer keeps the jitter sequence, so the grains " From 1a0aea38610833c80ba1e93ca4814977523a37eb Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:00:05 -0500 Subject: [PATCH 002/117] Pin audiodsp at 9e72eac, the 7b candidate for v0.6.6rc3: the loop shifter's splice matches a chord as a chord --- AUDIODSP_PIN | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 70a685e..6061b84 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -345,20 +345,20 @@ # holds its line still at mix 0 (#226); audiovorbis (#238, #239); and an # fmod-free angle reduction for the RP2040 (#225). # -# 2026-10-13: v0.6.6rc1 -> bd7f409, the effects program's Phase 7 node batch -# (step 7b): the candidate for audiodsp's next pre-release, v0.6.6rc2, and +# 2026-10-13: v0.6.6rc1 -> 9e72eac, the effects program's Phase 7 node batch +# (step 7b): the candidate for audiodsp's next pre-release, v0.6.6rc3, and # the final pin of the phase. Named by commit until the tag is cut; the tag # names the same source. What moves beneath the components, by `git log -# v0.6.6rc1..bd7f409 -- src`: audiodelays.Flanger lets go of a looped source that +# v0.6.6rc1..9e72eac -- src`: audiodelays.Flanger lets go of a looped source that # comes back empty and gives a stalled one silence, instead of spinning the # pull; audioecho.FeedbackDelay's upward loop_semitones writes its line # through a band limit at 0.7 of the frequency the next pass would carry to # Nyquist, so a climb fades out at the top instead of folding back; and both # shifters (the loop's two taps and audiodelays.PitchShift's splice) move a # splice's incoming side into phase with the outgoing one, so a held note -# plays its shifted pitch instead of cancelling. Unshifted and downward +# plays its shifted pitch, and a chord its notes, instead of cancelling. Unshifted and downward # FeedbackDelay renders, silence and a lone click through either shifter, and # a frozen PitchShift window render the bytes they did. # # -- ref is what CI installs (git+https://...@) -bd7f4096a4aa9e4fb467c3c5e1b0d45434142a66 bd7f4096a4aa9e4fb467c3c5e1b0d45434142a66 +9e72eacf3dbe354ac13564a0aa10652561f5fd45 9e72eacf3dbe354ac13564a0aa10652561f5fd45 From e46932626753ded9f40b2d8e73d48ed547d6e2af Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:13:15 -0500 Subject: [PATCH 003/117] Pin audiodsp at f7a86c9, the 7b candidate for v0.6.6rc3 --- AUDIODSP_PIN | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 6061b84..a7502c8 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -345,20 +345,21 @@ # holds its line still at mix 0 (#226); audiovorbis (#238, #239); and an # fmod-free angle reduction for the RP2040 (#225). # -# 2026-10-13: v0.6.6rc1 -> 9e72eac, the effects program's Phase 7 node batch +# 2026-10-13: v0.6.6rc1 -> f7a86c9, the effects program's Phase 7 node batch # (step 7b): the candidate for audiodsp's next pre-release, v0.6.6rc3, and # the final pin of the phase. Named by commit until the tag is cut; the tag # names the same source. What moves beneath the components, by `git log -# v0.6.6rc1..9e72eac -- src`: audiodelays.Flanger lets go of a looped source that +# v0.6.6rc1..f7a86c9 -- src`: audiodelays.Flanger lets go of a looped source +# that # comes back empty and gives a stalled one silence, instead of spinning the # pull; audioecho.FeedbackDelay's upward loop_semitones writes its line # through a band limit at 0.7 of the frequency the next pass would carry to # Nyquist, so a climb fades out at the top instead of folding back; and both # shifters (the loop's two taps and audiodelays.PitchShift's splice) move a # splice's incoming side into phase with the outgoing one, so a held note -# plays its shifted pitch, and a chord its notes, instead of cancelling. Unshifted and downward -# FeedbackDelay renders, silence and a lone click through either shifter, and +# plays its shifted pitch, and a chord its notes, instead of cancelling. +# Unshifted and downward FeedbackDelay renders, silence and a lone click through either shifter, and # a frozen PitchShift window render the bytes they did. # # -- ref is what CI installs (git+https://...@) -9e72eacf3dbe354ac13564a0aa10652561f5fd45 9e72eacf3dbe354ac13564a0aa10652561f5fd45 +f7a86c9c21f0a8d5a7c7e8e62155859a24a35869 f7a86c9c21f0a8d5a7c7e8e62155859a24a35869 From b65e719a89a684d6894646ac63bff0ac25717ba5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:13:21 -0500 Subject: [PATCH 004/117] AUDIODSP_PIN: rewrap the 7b note --- AUDIODSP_PIN | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index a7502c8..001226b 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -350,16 +350,16 @@ # the final pin of the phase. Named by commit until the tag is cut; the tag # names the same source. What moves beneath the components, by `git log # v0.6.6rc1..f7a86c9 -- src`: audiodelays.Flanger lets go of a looped source -# that -# comes back empty and gives a stalled one silence, instead of spinning the -# pull; audioecho.FeedbackDelay's upward loop_semitones writes its line +# that comes back empty and gives a stalled one silence, instead of spinning +# the pull; audioecho.FeedbackDelay's upward loop_semitones writes its line # through a band limit at 0.7 of the frequency the next pass would carry to # Nyquist, so a climb fades out at the top instead of folding back; and both # shifters (the loop's two taps and audiodelays.PitchShift's splice) move a # splice's incoming side into phase with the outgoing one, so a held note # plays its shifted pitch, and a chord its notes, instead of cancelling. -# Unshifted and downward FeedbackDelay renders, silence and a lone click through either shifter, and -# a frozen PitchShift window render the bytes they did. +# Unshifted and downward FeedbackDelay renders, silence and a lone click +# through either shifter, and a frozen PitchShift window render the bytes they +# did. # # -- ref is what CI installs (git+https://...@) f7a86c9c21f0a8d5a7c7e8e62155859a24a35869 f7a86c9c21f0a8d5a7c7e8e62155859a24a35869 From 4476df35ca702581538eaf1445041fc7456fa430 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:24:36 -0500 Subject: [PATCH 005/117] Kit: nogb runs every patch on its own and on all three interpreters A class that raised without get_buffer at one patch read as a single 'run' cell for the whole class, and the check ran on CPython only, so a path a class takes off CPython was never checked. Each patch and layout is now its own run/// cell, and the stand-in works on MicroPython and CircuitPython (no __mro__, vars or instance __dict__ there). Plants: PatchPulls (raises at the middle patch only) and NativeFlush (leans on get_buffer only off CPython), both red on the old check's tests and green on this one. --- tests/support/KIT.md | 30 +++++++++++--- tests/support/LIFECYCLE.md | 10 +++++ tests/support/kit.py | 8 ++-- tests/support/kit_nogb.py | 80 ++++++++++++++++++++++++++---------- tests/support/kit_planted.py | 36 +++++++++++++++- tests/test_kit.py | 44 ++++++++++++++++++++ 6 files changed, 175 insertions(+), 33 deletions(-) diff --git a/tests/support/KIT.md b/tests/support/KIT.md index b72b1ca..f647256 100644 --- a/tests/support/KIT.md +++ b/tests/support/KIT.md @@ -29,9 +29,9 @@ why), `RED(...)` with what was measured, or `decl`. `h:` is a CRC-32 of the cell's render, so two interpreters' lines agree only when their bytes do. The four render checks (tail, plants, args, ceiling) are numpy-free and -print the same lines on CPython, MicroPython and CircuitPython. The claims, -paperwork and nogb checks read source, unittest and git, or patch modules, -and run on CPython only; a native run prints `SKIP` for them. +print the same lines on CPython, MicroPython and CircuitPython. nogb runs +on all three too. The claims and paperwork checks read source, unittest and +git, and run on CPython only; a native run prints `SKIP` for them. A class that does nothing at all must not read as a pass: the tail and args checks print `na(silent)` for a render that was zero throughout, and @@ -185,6 +185,11 @@ stand-in that has everything but `get_buffer`, and puts it back; the matrix's own pulls keep the real one) and compares two things with the run that has it. +- `run/48000//`: the lifecycle events below, at the + defaults and at each patch, each its own run. Red when the class raises + there, with `get_buffer` or without, so a class that calls it + unconditionally at one patch says which, and every other patch is still + judged. - Lifecycle cells (E1, E5, E6, E10 at 48 kHz, stereo and mono, every patch): P1 to P4 must be the same kind of verdict (ok, na, RED), so a cell is red when the class is fine with `get_buffer` and not without. The @@ -195,8 +200,16 @@ run that has it. apart, at the defaults and every patch. A class whose first block is rightly gated differently without `get_buffer` declares the cell in `DECLARED`. -CPython only. A module that imports `get_buffer` inside a function is not -reached. +On all three interpreters. On MicroPython and CircuitPython the class's +own Python loses `get_buffer` while the native nodes under it are the real +ones, which is what a board build without it runs; a path a class takes +only off CPython is checked there and nowhere else. Run it on each: + + python tests/support/kit.py Saturation --only nogb + MICROPYPATH=lib:tests/support micropython -X heapsize=256M \ + tests/support/kit.py Saturation --only nogb + +A module that imports `get_buffer` inside a function is not reached. ## Known gaps @@ -245,7 +258,12 @@ a fixture card whose surface table differs (paperwork), `FlushOnly` and `DoubleR on `get_buffer` to flush a primed voice, the second drops a block on a second reset in a row without it; `FlushKit`, which primes a voice of its own and plays it again where it cannot flush, is the control; `MidPatchReset` -does it at a middle patch only, against `FlushKit3`), and `Silent`, which +does it at a middle patch only, against `FlushKit3`; `PatchPulls` calls +`get_buffer` unconditionally at a middle patch, which an earlier nogb read +as one `run` cell for the whole class, and is red on that patch's `run` +cells alone; `NativeFlush` leans on `get_buffer` only off CPython, so it +is clean on CPython and red on MicroPython and CircuitPython, where the +check once did not run), and `Silent`, which does nothing. Its native half holds the render checks to the same lines on all three interpreters, and shows that agreement can fail with `ImplKit`, whose level differs off diff --git a/tests/support/LIFECYCLE.md b/tests/support/LIFECYCLE.md index 1fc3bd7..a1d350a 100644 --- a/tests/support/LIFECYCLE.md +++ b/tests/support/LIFECYCLE.md @@ -112,6 +112,16 @@ 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`. +## Without `get_buffer` + +CircuitPython's default board builds compile `audiocore.get_buffer` out. +The kit's nogb check ([KIT.md](KIT.md#the-checks), `kit_nogb.py`) runs +E1, E5, E6 and E10 of this matrix at 48 kHz, at the defaults and at every +patch, each its own run, once as it is and once with `get_buffer` hidden +from the class's own modules, on CPython, MicroPython and CircuitPython. +A run that raises is a red `run///` cell; a cell +whose P1 to P4 verdicts change kind without `get_buffer` is red. + ## Declaring an exception A class that cannot satisfy a property by design (a glide, a reverb tail, a diff --git a/tests/support/kit.py b/tests/support/kit.py index b468d7d..e6f8afa 100644 --- a/tests/support/kit.py +++ b/tests/support/kit.py @@ -5,9 +5,9 @@ Prints one line per cell, `name|check|key|V:verdict[|h:digest]`, a SUMMARY line per check, and DONE. On MicroPython and CircuitPython -(`MICROPYPATH=lib:tests/support`) it runs the four render checks; the -claims, paperwork and nogb checks read source and git or patch modules, -and run on CPython only. +(`MICROPYPATH=lib:tests/support`) it runs the four render checks and +nogb; the claims and paperwork checks read source and git, and run on +CPython only. `KIT.md` beside this file says what each check measures and its bars. """ @@ -22,7 +22,7 @@ CHECKS = ("paperwork", "claims", "args", "plants", "ceiling", "tail", "nogb") -STATIC = ("paperwork", "claims", "nogb") +STATIC = ("paperwork", "claims") def run_check(check, cls, emit=print, quick=False, plants_module=None, diff --git a/tests/support/kit_nogb.py b/tests/support/kit_nogb.py index 4de8783..7d567fb 100644 --- a/tests/support/kit_nogb.py +++ b/tests/support/kit_nogb.py @@ -15,15 +15,21 @@ when any of P1 to P4 reads other than it did with `get_buffer` present (ok, na or RED: the frames a red verdict names are not compared). +Every patch, and the defaults, runs on its own: `run/48000//` +is red when the class raises there (with `get_buffer` or without), and the +other patches are still judged. + `bytes-` cells hold the whole render, every channel, to equal the one with `get_buffer` present: a reset, two resets in a row, a reset before the first pull, a patch change and a reset in one block, at the defaults and at every patch, stereo and mono. -CPython only: it patches the `audiocore` name those modules read, with a -stand-in that has everything but `get_buffer`, and puts it back afterwards. -The matrix's own pulls (lifecycle, kit_common) keep the real one. A module -that imports `get_buffer` inside a function is not reached. +It runs on CPython, MicroPython and CircuitPython: it rebinds the +`audiocore` name those modules read to a stand-in that has everything but +`get_buffer`, and puts it back afterwards, so on a native build it is the +class's own Python that loses `get_buffer`, as on a board. The matrix's own +pulls (lifecycle, kit_common) keep the real one. A module that imports +`get_buffer` inside a function is not reached. python tests/support/kit_nogb.py StereoWidener """ @@ -49,7 +55,7 @@ class _NoGetBuffer: """`audiocore` without `get_buffer`.""" def __init__(self, real): - self.__dict__["_real"] = real + self._real = real def __getattr__(self, name): if name == "get_buffer": @@ -57,13 +63,26 @@ def __getattr__(self, name): return getattr(self._real, name) +def _bases(cls, out): + """`cls` and every class it derives from (MicroPython has no + `__mro__`).""" + if cls in out: + return out + out.append(cls) + for base in getattr(cls, "__bases__", ()): + _bases(base, out) + return out + + def _modules(cls): names = set(["audioeffects._component"]) for name in list(sys.modules): if name == "audioeffects" or name.startswith("audioeffects."): names.add(name) - for base in cls.__mro__: - names.add(base.__module__) + for base in _bases(cls, []): + module = getattr(base, "__module__", None) + if module is not None: + names.add(module) return [sys.modules[n] for n in sorted(names) if n in sys.modules and n not in KEEP] @@ -80,7 +99,7 @@ def __enter__(self): stand_in = _NoGetBuffer(real) pull = real.get_buffer for module in _modules(self.cls): - for key, value in list(vars(module).items()): + for key, value in list(module.__dict__.items()): if value is real: self.saved.append((module, key, value)) setattr(module, key, stand_in) @@ -167,28 +186,46 @@ def _bytes_cell(cls, options, rate, ch, patch, actions): % (d // (2 * ch), (d // 2) % ch) -def _run(cls, options, quick, name): +def _run(cls, options, quick, name, ch, patch): lines = [] lifecycle.run_class(cls, options=options, rates=(48000,), - only=EVENTS, quick=quick, emit=lines.append, - name=name) + channels=(ch,), patches=[patch], only=EVENTS, + quick=quick, emit=lines.append, name=name) return _parse(lines) +def _raised(exc): + return "%s: %s" % (type(exc).__name__, exc) + + def run(cls, emit=print, quick=False, name=None, options=None): """Every nogb cell for `cls`; returns the red count.""" cells = kc.Cells(name or cls.NAME, CHECK, emit) - if not kc.CPYTHON: - return cells.summary() - present = _run(cls, options, quick, cells.name) - try: - with hidden(cls): - absent = _run(cls, options, quick, cells.name) - except Exception as exc: # noqa: BLE001 - cells.cell("run", "RED(raised %s: %s)" % (type(exc).__name__, exc)) - return cells.summary() + present = {} + absent = {} for patch in [None] + sorted(cls.PATCHES): for ch in (2, 1): + # One lifecycle run per patch and layout, each in its own + # cell, so a class that raises without get_buffer at one patch + # says which, and the other patches are still judged. + key = "run/48000/%d/%s" % (ch, "d" if patch is None + else "p%d" % patch) + try: + want = _run(cls, options, quick, cells.name, ch, patch) + except Exception as exc: # noqa: BLE001 + cells.cell(key, "RED(raised with get_buffer: %s)" + % _raised(exc)) + continue + try: + with hidden(cls): + got = _run(cls, options, quick, cells.name, ch, patch) + except Exception as exc: # noqa: BLE001 + cells.cell(key, "RED(raised without get_buffer: %s)" + % _raised(exc)) + continue + cells.cell(key, "ok") + present.update(want) + absent.update(got) for label, actions in SCENARIOS: key = "bytes-%s/48000/%d/%s" % ( label, ch, "d" if patch is None else "p%d" % patch) @@ -196,8 +233,7 @@ def run(cls, emit=print, quick=False, name=None, options=None): cells.cell(key, _bytes_cell(cls, options or {}, 48000, ch, patch, actions)) except Exception as exc: # noqa: BLE001 - cells.cell(key, "RED(raised %s: %s)" % ( - type(exc).__name__, exc)) + cells.cell(key, "RED(raised %s)" % _raised(exc)) for key in sorted(present): want = present[key] got = absent.get(key) diff --git a/tests/support/kit_planted.py b/tests/support/kit_planted.py index a3bb03a..2b71280 100644 --- a/tests/support/kit_planted.py +++ b/tests/support/kit_planted.py @@ -547,10 +547,44 @@ def reset(self): lifecycle.Routed._arm(self) +class PatchPulls(FlushKit3): + """Nogb: at the middle patch only, the build pulls its quiet mixer + with `audiocore.get_buffer` unconditionally, so where that is compiled + out it raises AttributeError there and nowhere else (Distortion's + patches 6 to 9 did this).""" + NAME = "PatchPulls" + + def _build(self, mix=1.0, gain=1.0, patch=None): + FlushKit3._build(self, mix, gain, patch) + if patch == 1: + audiocore.get_buffer(self._quiet) + + def program_change(self, index, *args, **kw): + FlushKit3.program_change(self, index, *args, **kw) + if index == 1: + audiocore.get_buffer(self._quiet) + + +class NativeFlush(FlushKit): + """Nogb: FlushOnly's fault on MicroPython and CircuitPython only (a + path a class takes off CPython, as Vibrato's native graph and + Saturation's CircuitPython reset do): a check that runs on CPython + alone reads it as clean.""" + NAME = "NativeFlush" + + def _arm(self): + FlushKit._arm(self) + if sys.implementation.name == "cpython": + return + pull = getattr(audiocore, "get_buffer", None) + if pull is not None: + pull(self._mixer) + + PLANTS = {} for _cls in (KitPlain, Silent, WrapKit, WrapKitQuiet, ClipKit, DeadStop, CondKit, MidWrap, ComboWrap, AllWrap, LowWrap, ImplKit, EchoTail, ShortTail, EventTail, NoneSilent, NoneDC, ReadbackOff, StopRaises, NanLevel, PatchStale, CtorNan, ClearsFirst, ClearsMacro, FlushKit, FlushOnly, DoubleReset, - FlushKit3, MidPatchReset): + FlushKit3, MidPatchReset, PatchPulls, NativeFlush): PLANTS[_cls.NAME] = _cls diff --git a/tests/test_kit.py b/tests/test_kit.py index baa40ce..ccd8616 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -254,6 +254,31 @@ def test_every_patch_is_run(self): control = nogb_cells(kp.FlushKit3) self.assertEqual(verdict(control, "FlushKit3", "nogb", key), "ok") + def test_a_patch_that_raises_is_its_own_cell(self): + cells = nogb_cells(kp.PatchPulls) + for ch in (2, 1): + key = "run/48000/%d/p1" % ch + self.assertIn("raised without get_buffer: AttributeError", + verdict(cells, "PatchPulls", "nogb", key)) + for other in ("p0", "p2"): + self.assertEqual(verdict(cells, "PatchPulls", "nogb", + "run/48000/%d/%s" % (ch, other)), + "ok", other) + # The patches that do not raise are still judged. + self.assertEqual(verdict(cells, "PatchPulls", "nogb", + "bytes-reset-once/48000/2/p2"), "ok") + control = nogb_cells(kp.FlushKit3) + self.assertEqual(verdict(control, "FlushKit3", "nogb", + "run/48000/2/p1"), "ok") + + def test_a_native_only_flush_is_clean_here(self): + # NativeFlush leans on get_buffer only off CPython; TestNative + # holds it red on MicroPython and CircuitPython. + cells = nogb_cells(kp.NativeFlush) + for key in (k for (_n, _c, k) in cells): + self.assertEqual(verdict(cells, "NativeFlush", "nogb", key), + "ok", key) + def test_get_buffer_is_put_back(self): import audiocore import audioeffects._component as component @@ -637,6 +662,25 @@ def test_micropython_agrees(self): def test_circuitpython_agrees(self): self.agree("circuitpython") + def nogb_native(self, family): + binary = self.binary(family) + args = ["NativeFlush", "FlushKit", "--only=nogb", "--quick"] + cells = kc.parse(run_kit(binary, args)) + bad = sorted(k for (n, _c, k), v in cells.items() + if n == "NativeFlush" and red(v["V"])) + self.assertTrue(any(k.startswith("bytes-reset-once") for k in bad), + bad) + self.assertTrue(any(k.startswith("E5-mix0") for k in bad), bad) + for (n, _c, k), v in cells.items(): + if n == "FlushKit": + self.assertEqual(v["V"], "ok", k) + + def test_nogb_runs_on_micropython(self): + self.nogb_native("micropython") + + def test_nogb_runs_on_circuitpython(self): + self.nogb_native("circuitpython") + def test_agreement_can_fail(self): binary = self.binary("micropython") args = ["ImplKit", "--only=args", "--quick"] From 0ec69f5fe5534cc4e86ae3d92de46ff227766cdf Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:33:57 -0500 Subject: [PATCH 006/117] Pin audiodsp at v0.6.6rc3, the release of the Phase 7 node batch --- AUDIODSP_PIN | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 001226b..4227c4c 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -361,5 +361,9 @@ # through either shifter, and a frozen PitchShift window render the bytes they # did. # +# Moved to v0.6.6rc3 (a9d36e6) on 2026-10-13: the release of the commit +# above (audiodsp#245, #247), the same node tree as f7a86c9 outside VERSION +# and CHANGELOG.md, so CI can install it from GitHub. +# # -- ref is what CI installs (git+https://...@) -f7a86c9c21f0a8d5a7c7e8e62155859a24a35869 f7a86c9c21f0a8d5a7c7e8e62155859a24a35869 +v0.6.6rc3 a9d36e6993885b6c65730cf4f77908a6986d2ccc From 7837870aaaa7820af3c4ad5d0fdc06f7b905ed0e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:41:43 -0500 Subject: [PATCH 007/117] Flanger: a thin class on audiodelays.Flanger; both old flangers deleted The class is the node's controls (Rate, Range, Manual, Color, Mix, Polarity, Sync, Division), a live tail bound and a tempo, at the stock tier. create("Flanger") serves it from audioeffects/flanger.py; modulation.py and the parked rebuild under rebuilt/ are gone. Its six traits are tested through the class with a planted fault each; the lifecycle matrix declares the free-running sweep's P4 and the N1 step, and skips E8 on CircuitPython, whose own Flanger spins on a source that comes back empty. The cost tool gains an audiodelays.Flanger node row. --- CHANGELOG.md | 11 + ROADMAP.md | 2 - lib/audioeffects/README.md | 13 +- lib/audioeffects/__init__.py | 4 +- lib/audioeffects/flanger.py | 342 +++++ lib/audioeffects/modulation.py | 37 - lib/audioeffects/rebuilt/__init__.py | 8 +- lib/audioeffects/rebuilt/flanger.py | 393 ------ tests/support/lifecycle.py | 24 + tests/test_cpython_effects_flanger.py | 1789 +++++++++++++++---------- tests/test_rebuilt_registry.py | 11 +- tools/measure_effect_cost.py | 11 +- 12 files changed, 1516 insertions(+), 1129 deletions(-) create mode 100644 lib/audioeffects/flanger.py delete mode 100644 lib/audioeffects/modulation.py delete mode 100644 lib/audioeffects/rebuilt/flanger.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db62a61..ff592ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ there, and are recorded in its changelog. ### Added +- **`Flanger` (rebuilt, Phase 7):** a thin class on CircuitPython's own + `audiodelays.Flanger`, so it now runs on a stock CircuitPython board. + Eight macros (Rate, Range, Manual, Color, Mix, Polarity, Sync, Division) + and six patches; `create("Flanger")` serves it from + `audioeffects/flanger.py`. Both old flangers are gone: + `modulation.Flanger` (and `modulation.py` with it) and the parked Phase 3 + rebuild under `rebuilt/`. The old `depth_ms=` and `feedback=` keywords + give way to the macros; `rate=` and `mix=` keep their names, and Mix 1 is + now the default. The long ring and the bucket-brigade sweep of the parked + rebuild do not carry: the node stops Color at 0.95 (about 0.4 s to -60 dB + at 3 ms) and sweeps a triangle in milliseconds. - **`ShimmerHall` (rebuilt, Phase 6; adopted 2026-10-13):** a pitch shifter around a hall, in two characters. Bloom sends the shifted copy into the hall once, Bloom after you play, on `audiodelays.PitchShift`; Rise puts the shift inside diff --git a/ROADMAP.md b/ROADMAP.md index f661de9..221ee1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,8 +10,6 @@ the real thing. StereoWidener, Rack, ShimmerHall and AirSpace. The README gains a latency table for every class, each rack and a five-effect chain. - A release that carries the rebuilt effects, with a changelog. -- Flanger rebuilt after the Electric Mistress. Until it measures up on every - target, the package keeps serving today's `modulation.Flanger`. - The two placeholder classes in `audioeffects/rebuilt/` (`ExampleStock`, `ExampleAudioif`) go once shipped classes cover both portability tiers. diff --git a/lib/audioeffects/README.md b/lib/audioeffects/README.md index 15565da..5463113 100644 --- a/lib/audioeffects/README.md +++ b/lib/audioeffects/README.md @@ -177,14 +177,13 @@ within that allocation, which is why it is a proportion rather than a time. Both convolution classes trail their input by `audioconvolve.FRAMES` (5.3 ms at 48 kHz) once an impulse is loaded, and by nothing at all before one is. -### Modulation - six at home, one still in `rebuilt/` +### Modulation - all seven at home Phase 3's THROUGH classes (`AutoPan`, `Chorus`, `Phaser`, `Tremolo`, -`Vibrato`) and `RingMod` have come home as one file per effect beside this -README. `Flanger` stays parked under `rebuilt/`; the package still serves -`modulation.Flanger`. `modulation.py` cannot be deleted while Flanger -remains. Every one of them is **audiodsp** tier - none runs on a stock -CircuitPython board. +`Vibrato`), `RingMod` and `Flanger` live one file per effect beside this +README. `Flanger` is a thin class on CircuitPython's own +`audiodelays.Flanger`, so it is **stock** tier and runs on a stock +CircuitPython board; every other one is **audiodsp** tier. **Cost** is the class's own share of one 256-frame stereo block at 48 kHz where the board stage has run, else the dossier palette budget. A @@ -193,7 +192,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 bar. | +| `Flanger` | stock (`audiodelays`) | 8 | 0 samples at every setting | Not yet measured on the boards; one native node, expected about 8 % P4 / 12 % S3 of a block (the shape of `audiodelays.Chorus`); no lean patch | A swept comb: Rate, Range, Manual, Color, Mix, Polarity and tempo Sync. At Color 0 and Mix 1 a notch is at least 40 dB down; Color raises the peaks up to +26.4 dB and rings about 0.4 s to -60 dB at 3 ms. If the source ends, the output stops dead; on a CircuitPython board, stop it from the host if the source can run dry. | | `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/__init__.py b/lib/audioeffects/__init__.py index ede9190..f4cbde2 100644 --- a/lib/audioeffects/__init__.py +++ b/lib/audioeffects/__init__.py @@ -46,7 +46,7 @@ class construction remains supported for local code after `configure()`. from .autopan import AutoPan from .vibrato import Vibrato from .ringmod import RingMod -from .modulation import Flanger +from .flanger import Flanger from .overdrive import Overdrive from .distortion import Distortion from .bitcrusher import Bitcrusher @@ -117,7 +117,7 @@ def _adopt(namespace, names): (Overdrive, Distortion, Fuzz, Saturation, Bitcrusher, Exciter, CabinetSim) have come home as one file per effect beside this module, so they are imported above rather than substituted. The machinery stays for - Flanger and for the phases still to come: `rebuilt.load()` answers + the phases still to come: `rebuilt.load()` answers `None` for a parked name (or a name with no file), and `rebuilt.module_class()` is how a tool or a class's own tests reach a rebuild that has not come home yet. diff --git a/lib/audioeffects/flanger.py b/lib/audioeffects/flanger.py new file mode 100644 index 0000000..a17814a --- /dev/null +++ b/lib/audioeffects/flanger.py @@ -0,0 +1,342 @@ +"""`Flanger` - a swept comb on CircuitPython's own `audiodelays.Flanger`. + +The player's text is the class docstring; the `CLAIMS` table in the class's +test file ties its claim sentences to tests. How the node behaves is told +where the class drives it, in `Flanger._build`. +""" + +VENDOR = "PyDevices" + +import math + +import audiodelays + +from . import _component + + +RATE_I, RANGE_I, MANUAL_I, COLOR_I, MIX_I, POLARITY_I, SYNC_I, DIVISION_I = \ + range(8) + +#: The line, fixed: nothing re-allocates after construction. +MAX_DELAY_MS = 10 + +#: Frames a pull hands out: the palette's block. +BLOCK = 256 + +#: Sync's divisions in bars, Phaser's own (`phaser.py:DIVISION_BARS`). +DIVISION_BARS = (4.0, 2.0, 1.0, 0.5, 1.0 / 3.0, 0.25, 0.125) + +POLARITIES = ("positive", "negative") + +#: 20 log10(32768), rounded up: a peak this far under full scale is under +#: one LSB. +SILENCE_DB = 90.31 + +#: How far a knob may sit from the step its constructor value seeded and +#: still count as not moved (a host echoing `get_macro` back). +ECHO_TOLERANCE = 1e-6 + +_RANGES = ( + (0.05, 10.0, "log"), # 0 Rate, Hz + (0.0, 1.0), # 1 Range: Manual to 10 ms + (0.25, 5.0, "log"), # 2 Manual, ms + (0.0, 0.95), # 3 Color; the node stops at 0.95 + (0.0, 1.0), # 4 Mix + (0.0, 1.0), # 5 Polarity (TOGGLE) + (0.0, 1.0), # 6 Sync (TOGGLE) + (0.0, 6.0), # 7 Division index into DIVISION_BARS +) + + +def _patch(rate, depth, manual, color, mix, negative, sync, bars): + """A patch's MIDI steps from the card's engineering values.""" + division = DIVISION_BARS.index(bars) + values = (rate, depth, manual, color, mix, 1.0 if negative else 0.0, + 1.0 if sync else 0.0, float(division)) + return tuple(_component.macro_of(span, value) + for span, value in zip(_RANGES, values)) + + +#: Patch 0, "Slow jet", on the grid. Construction is patch 0 (the +#: contract), so each constructor default is the value of patch 0's step. +PATCH_0 = _patch(0.25, 0.6, 1.0, 0.7, 1.0, False, False, 1.0) + +#: Each constructor keyword's default, which NaN falls back to: Rate about +#: 0.25 Hz, Range about 0.6, Manual about 1 ms, Color about 0.7, Mix 1. +DEFAULTS = tuple(_component.macro_value(_RANGES[i], PATCH_0[i] / 127.0) + for i in range(5)) +RATE, RANGE, MANUAL_MS, COLOR, MIX = DEFAULTS + + +def _choice(choices, position): + last = len(choices) - 1 + return choices[min(last, max(0, int(round(position * last))))] + + +def _option(value, default, span): + """A constructor value: NaN is the default, past a stop is that stop.""" + value = float(value) + if value != value: + value = default + return min(span[1], max(span[0], value)) + + +def tail_frames(color, top_ms, rate): + """Frames until the ring is exact silence, for Color `color` and a + sweep whose top is `top_ms`, at `rate` (the card's section 5). + + Interpolation never exceeds its two samples and the feedback truncates + toward zero, so the line's peak falls by at least g each D + 1 frames, + and is zero once it would fall under one LSB.""" + line = int(rate // 100) + longest = min(line, int(math.ceil(top_ms * rate / 1000.0))) + g = int(color * 32767) / 32767.0 + if g <= 0.0: + return longest + 3 + trips = int(math.ceil(SILENCE_DB / (-20.0 * math.log10(g)))) + return (trips + 2) * (longest + 1) + 2 + + +class Flanger(_component.Component): + """A flanger: your signal plus a copy of itself a few milliseconds + late, the delay swept up and down by a triangle, so the comb of notches + the sum makes moves through the spectrum. + + **The controls.** Rate runs the sweep from 0.05 to 10 Hz. + Manual, from 0.25 to 5 ms, is the sweep's floor, and Range is how far + it climbs from there towards 10 ms: at Range 0 the comb holds still at + Manual. + Color, from 0 to 0.95, feeds the copy back into the line and raises the + comb's peaks into a ring. + Mix is the copy's level against a dry that never moves: 1 is equal + weight and the deepest notches, 0 is a wire. + Polarity minus turns the comb over and puts a notch at DC. + With Sync on, one sweep lasts Division bars (4 bars down to an eighth) + at the host's tempo and meter, and Rate is ignored. + Constructor values play exactly until their knob first moves or a patch + loads. + + **What you hear.** The copy is your signal exactly Manual late, added + at Mix. + At Color 0 and Mix 1 a tone on a notch comes out at least 40 dB down. + Color raises the peaks by up to 26.4 dB over the input and fills the + notches to 6 dB under it. + The sweep is a triangle in milliseconds that starts at Manual and + reaches its top once a sweep. + The ring lasts at most about 0.4 s to -60 dB at 3 ms and full Color, + and about 0.7 s at 5 ms; it never oscillates on its own. + + **Limits.** It is linear only to about -28 dBFS on a resonant frequency + at full Color, and -7.5 dBFS at Color 0: above that the output is + squashed and gritty, not louder. + Turning Mix up makes the output louder, by 6 dB on a comb peak at Mix 1: + there is no wet-only setting. + There is no tone control, no through-zero and no stereo spread: both + channels sweep together. + Sync sets the sweep's period, not its phase: the sweep starts at Manual + when the class is built or reset, not on a bar line, and a synced sweep + faster than 20 Hz runs at 20 Hz. + A control that jumps makes the output step: move it in small steps from + the host if you need it smooth. + At 22.05 kHz the sweep's top is 9.977 ms, half a frame under 10 ms. + Mix's first step above 0 is still a wire; the copy comes in at the + second step, about 36 dB down. + Keep the input under -6 dBFS. + + **Wire, latency, tail.** At Mix 0 the output is the input, byte for + byte. + There is no latency. + `tail_samples` is the longest the ring can last at the controls in + force, while the source keeps playing, silence included. + If the source ends, the output stops dead; `reset()` clears what the + line held and returns to patch 0. + On a CircuitPython board, stop the flanger from the host if your source + can run dry: CircuitPython's own flanger waits on a source that comes + back empty. + """ + + NAME = 'Flanger' + DISPLAY_NAME = 'Flanger' + CATEGORIES = ('Modulation',) + VERSION = '0.1.0' + + #: Upstream's own node: it runs on a stock CircuitPython board. + TIER = _component.STOCK + REQUIRES = () + + CAPABILITIES = ("tempo_sync",) + _SYNC_MACRO = SYNC_I + LATENCY_SAMPLES = 0 + #: The worst case at 48 kHz: full Color and the sweep at the line's top + #: (D = 480 frames). `tail_samples` is the bound at the controls in + #: force. + TAIL_SAMPLES = 98607 + + #: On stock CircuitPython the wet's interpolation overflows int32 where + #: two neighbouring line samples are more than 32768 apart at a + #: fractional delay (audiodsp#76, widened in audiodsp's copy). At Color + #: 0 the line is the input, so -6 dBFS keeps it safe there. + INPUT_CEILING_DBFS = -6.0 + + #: The constructor's exact values, which play until their knob moves. + _KEPT = ("_exact",) + + MACRO_LABELS = ("Rate", "Range", "Manual", "Color", "Mix", "Polarity", + "Sync", "Division") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", + 3: "UNIPOLAR", 4: "UNIPOLAR", 5: "TOGGLE", 6: "TOGGLE", + 7: "UNIPOLAR"} + _MACRO_RANGES = _RANGES + + PATCHES = { + 0: ("Slow jet", PATCH_0), + 1: ("Fast metallic", + _patch(4.0, 0.15, 0.5, 0.85, 1.0, False, False, 1.0)), + 2: ("Held comb", _patch(0.25, 0.0, 2.0, 0.6, 1.0, False, False, 1.0)), + 3: ("Negative jet", + _patch(0.2, 0.6, 1.0, 0.7, 1.0, True, False, 1.0)), + 4: ("Gentle", _patch(0.5, 0.3, 2.0, 0.0, 0.5, False, False, 1.0)), + 5: ("Synced one bar", + _patch(0.25, 0.6, 1.0, 0.6, 1.0, False, True, 1.0)), + } + + def _build(self, rate=RATE, depth=RANGE, manual_ms=MANUAL_MS, + color=COLOR, mix=MIX, polarity="positive", sync=False, division=1.0, + patch=None): + if polarity not in POLARITIES: + raise ValueError("polarity must be %r or %r, not %r" + % (POLARITIES[0], POLARITIES[1], polarity)) + asked = (rate, depth, manual_ms, color, mix) + exact = tuple(_option(asked[i], DEFAULTS[i], _RANGES[i]) + for i in range(5)) + #: The constructor's values, exactly, None for a knob that has + #: moved since. A patch drops them all. + self._exact = exact + bars = float(division) + index = 2 + if bars == bars: + for k in range(len(DIVISION_BARS)): + if (abs(DIVISION_BARS[k] - bars) + < abs(DIVISION_BARS[index] - bars)): + index = k + self._ready = False + + # The whole graph is the node. Each frame its line is read at + # Manual + Range * (10 ms - Manual) * tri(n), a 0-1-0 triangle whose + # phase starts at 0 on construction and on a reset + # (src/audiodelays/Flanger.c:94, `:172-183`) and advances by + # rate / sample_rate a frame (`:280-281`); the line writes + # x + wet * feedback and the output is x +- wet * mix through a soft + # knee. Every control is read once a pull. A reset, the base's walk + # over `_own`, clears the line, the write positions and the phase. + self._node = self._own(audiodelays.Flanger( + max_delay_ms=MAX_DELAY_MS, min_delay_ms=exact[MANUAL_I], + rate=exact[RATE_I], depth=exact[RANGE_I], + feedback=exact[COLOR_I], mix=exact[MIX_I], invert=False, + sample_rate=self._sample_rate, + channel_count=self._channel_count, bits_per_sample=16, + samples_signed=True, + buffer_size=BLOCK * 2 * self._channel_count)) + self._node.play(self._source) + self._output = self._node + + # Each knob seeds the step nearest its constructor value, so a + # fresh instance reads whole MIDI steps; the exact value plays. + values = exact + (1.0 if polarity == POLARITIES[1] else 0.0, + 1.0 if sync else 0.0, float(index)) + self._seeds = [ + _component.position_of_midi( + self._mode(i), _component.macro_of(_RANGES[i], values[i])) + for i in range(8)] + self._seeding = True + try: + self._init_macros(values) + for i in range(8): + self._macros[i] = self._seeds[i] + finally: + self._seeding = False + if patch is not None: + self.program_change(patch) + self._ready = True + self._refresh() + + # -- the maps ------------------------------------------------------ + + def _value(self, index): + exact = self._exact[index] if index < 5 else None + if exact is not None: + return exact + return _component.macro_value(_RANGES[index], self._macros[index]) + + def _negative(self): + return self._macros[POLARITY_I] >= 0.5 + + def _rate_hz(self): + """The sweep's rate: Rate, or with Sync on, one sweep per Division + bars at the host's tempo and meter (Phaser's rule).""" + if self._macros[SYNC_I] < 0.5: + return self._value(RATE_I) + state = self._transport() if callable(self._transport) \ + else self._transport + bpm, beats_per_bar = state[2], state[4] + bars = _choice(DIVISION_BARS, self._macros[DIVISION_I]) + beats = max(1.0, float(beats_per_bar)) * bars + return max(1.0, float(bpm)) / (60.0 * beats) + + def _top_ms(self): + manual = self._value(MANUAL_I) + return manual + self._value(RANGE_I) * (MAX_DELAY_MS - manual) + + # -- applying ------------------------------------------------------ + + def _apply_macro(self, index, position): + if (index < 5 and self._exact[index] is not None + and not self._seeding + and abs(position - self._seeds[index]) > ECHO_TOLERANCE): + exact = list(self._exact) + exact[index] = None + self._exact = tuple(exact) + if self._ready: + self._refresh() + + def _apply_positions(self, positions, patch_index): + """A patch (or a rack's restore) applied whole, then the node set + once. A patch drops the constructor's values; a restore keeps the + ones it put back (`_KEPT`).""" + ready = self._ready + self._ready = False + try: + _component.Component._apply_positions(self, positions, + patch_index) + finally: + self._ready = ready + if not self._seeding: + self._exact = (None,) * 5 + if ready: + self._refresh() + + def _refresh(self): + node = self._node + color = self._value(COLOR_I) + negative = self._negative() + node.min_delay_ms = self._value(MANUAL_I) + node.depth = self._value(RANGE_I) + node.rate = self._rate_hz() + node.feedback = -color if negative else color + node.invert = negative + node.mix = self._value(MIX_I) + + def macro(self, index): + """What macro `index` currently stands for, in its own units; for + the first five, the value that plays.""" + self._check_live() + if isinstance(index, int) and 0 <= index < 5: + return self._value(index) + return _component.Component.macro(self, index) + + @property + def tail_samples(self): + """The bound at the controls in force; see the class docstring.""" + self._check_live() + return tail_frames(self._value(COLOR_I), self._top_ms(), + self._sample_rate) diff --git a/lib/audioeffects/modulation.py b/lib/audioeffects/modulation.py deleted file mode 100644 index 26dee65..0000000 --- a/lib/audioeffects/modulation.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Modulation effects still served from this family module: `Flanger`. - -Phase 3's THROUGH classes and `RingMod` live one file per effect beside -this package. LFOs tick at the engine's block rate (about 187 Hz at 48 kHz), -which is ample for musical sweep rates. `Flanger` stays here until CircuitPython -10.3.0's own node lands in a later follow-up. -""" - -VENDOR = "PyDevices" - -import audiodelays -import synthio - -from . import _core - - -class Flanger(_core.Effect): - """A very short modulated delay with feedback and doppler - the real - swept comb, jet engine included.""" - - NAME = 'Flanger' - DISPLAY_NAME = 'Flanger' - CATEGORIES = ('Modulation',) - VERSION = '0.0.1' - MACRO_LABELS = () - MACRO_MODES = {} - PATCHES = {0: ("Default", ())} - - def __init__(self, source, rate=0.25, depth_ms=2.5, feedback=0.6, - mix=0.5): - self.motion = synthio.LFO(rate=rate, scale=depth_ms, - offset=depth_ms + 1.0) - self.node = audiodelays.Echo( - max_delay_ms=int(depth_ms * 2 + 20), delay_ms=self.motion, - decay=feedback, mix=mix, freq_shift=True, **_core.pcm()) - self.node.play(source) - self._output = self.node diff --git a/lib/audioeffects/rebuilt/__init__.py b/lib/audioeffects/rebuilt/__init__.py index ad2e4c0..91c98ad 100644 --- a/lib/audioeffects/rebuilt/__init__.py +++ b/lib/audioeffects/rebuilt/__init__.py @@ -1,6 +1,5 @@ """One module per rebuilt effect class, named after the class's `NAME` in -lower case: `rebuilt/flanger.py` holds parked `Flanger` until that program -comes home. +lower case: `rebuilt/octaver.py` holds `Octaver` until it comes home. **Nothing in this file lists the classes.** A rebuild adds exactly one file and edits nothing else -- not this module, not the family module it @@ -21,7 +20,7 @@ `ringmod.py`) and all seven of Phase 4's (`overdrive.py`, `distortion.py`, `fuzz.py`, `saturation.py`, `bitcrusher.py`, `exciter.py`, `cabinetsim.py`). This directory keeps the substitution machinery, the two -`Example` fixtures and the parked Phase 3 rebuild (`flanger.py`). `ADOPTED` +`Example` fixtures and the later phases' classes. `ADOPTED` below no longer names the classes that have come home -- there is nothing left here for it to arbitrate for them -- and it will name later phases' classes until those @@ -89,7 +88,8 @@ def _present(): #: shipped patch, and all three are inside it). `drive.py` is deleted, and #: there is nothing left here to arbitrate for any of them. #: -#: What stays parked under this directory: Phase 3's `Flanger`. +#: Nothing is parked under this directory now: Phase 3's parked `Flanger` +#: was deleted in Phase 7, and `Flanger` is served from `flanger.py`. #: #: Phase 5's first two, adopted 2026-09-28 on the rulings of that date: #: `DigitalDelay` and `SlapbackDelay`. Both are THROUGH on the desktop half diff --git a/lib/audioeffects/rebuilt/flanger.py b/lib/audioeffects/rebuilt/flanger.py deleted file mode 100644 index 444b4ef..0000000 --- a/lib/audioeffects/rebuilt/flanger.py +++ /dev/null @@ -1,393 +0,0 @@ -"""`Flanger` - a BBD swept comb informed by the Electric Mistress. - -Rebuilt from scratch against a trait table frozen on 2026-09-08 before -this file existed. The old class in `modulation.py` is not consulted -except for eight known defects, and it stays the class the library serves -until the auditor adopts this one. - -**What it sounds like.** One interpolated delay line mixes with the dry -path at equal weight, so the comb's notches are true nulls. A triangle -on the *clock* becomes a hyperbola in milliseconds - that is the bucket -brigade law, not a triangle LFO on the delay. Color is regeneration: -peaks rise and the floor fills. Filter Matrix stops the LFO and leaves -Manual, Color and Mix live. Through Zero is off unless you turn it on, -and then the dry path is late by the sweep's maximum at Range max -(**10 ms / 480 samples at 48 kHz**). - -**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 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 -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 -node does. - -**Portability tier: audiodsp** (`REQUIRES = ("audioecho", "audioroute")`). -`audioroute` is only built when Through Zero is on. On a stock -CircuitPython board this module imports and construction raises -`ImportError`. - -**Cost.** Reference patch 0 is one `audioecho.FeedbackDelay` with a -256-point `wow_shape` (inside the palette +options row; this class does -not add a looping `RawSample` carrier). Palette +options 0.434 / 0.752 ms -plus glue 0.0 ms: **P4 ≤ 9 %, S3 ≤ 15 %** of a 5.333 ms stereo block. -Measured at patch 0: 0.681 ms/block on the P4 (rt 7.84) and 1.073 ms on -the S3 (rt 4.97). Lean patch: no. - -**Latency is zero at defaults.** Through Zero is the only latency-adding -option; it defaults off. - -`capabilities = ("tempo_sync",)`: this class always reads -`self._transport()`. -""" - -VENDOR = "PyDevices" - -from array import array -import math - -try: - import audioecho -except ImportError: # pragma: no cover - a stock board - audioecho = None -try: - import audioroute -except ImportError: - audioroute = None -try: - import audiomixer -except ImportError: - audiomixer = None - -from .. import _component - - -#: Line length. F5's 10 ms top plus Through Zero's dry copy of the same, -#: plus the node's two-frame clamp. -MAX_DELAY_MS = 20.0 -SHORT_MS = 2.5 -LONG_MS = 10.0 -BBD_N = 1024 -SHAPE_LEN = 256 -# One cut cannot do Color-0 exact-zero and Color-max ≥2 s together: -# 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. (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 -# noise seeds (14.32–14.83 dB). A 0.03 lift at the 0.9 stop (0.90→0.93) -# is the same regeneration law, hotter by a trim — not a second node. -COLOR_EXPAND_AT_09 = 0.03 -# Color 0 still has the 20 Hz pole: last non-zero 2356 frames / 48 kHz -# (0.049 s) after a burst. Declared tail at Color 0 must cover that. -COLOR0_SETTLE_S = 0.08 -DELAY_SLEW = 1.0 -MIX_UNITY_SNAP = 0.04 -DIVISIONS = (4.0, 2.0, 1.0, 0.5, 1.0 / 3.0, 0.25, 0.125) -TONE_MAX_HZ = 16000.0 - - -def _hyperbola_table(duty, length=SHAPE_LEN, ratio=4.0): - """One period of delay wow in Q15: a linear clock ramp of `duty` is a - hyperbola in milliseconds, mapped onto [-1, 1].""" - duty = min(0.75, max(0.25, float(duty))) - samples = [] - centre = (1.0 + ratio) / 2.0 - depth = (ratio - 1.0) / 2.0 - for index in range(length): - phase = index / float(length) - if phase < duty: - clock = phase / duty - else: - clock = 1.0 - (phase - duty) / (1.0 - duty) - delay = 1.0 / (1.0 / ratio + clock * (1.0 - 1.0 / ratio)) - wow = (delay - centre) / depth - samples.append(int(max(-32767, min(32767, round(wow * 32767.0))))) - return array("h", samples) - - -def _loop_cut_hz(color): - """20 Hz at Color 0 (Tier 1 exact zero); 0.4 Hz at Color 0.99 (F8).""" - travel = min(1.0, max(0.0, float(color) / 0.99)) - return CUT_HZ + travel * (CUT_HZ_COLOR_MAX - CUT_HZ) - - -def _color_to_feedback(color, ceiling=0.99, law="linear"): - """Panel Color → node feedback. 0 stays 0; 0.9 is lifted so F3's - +15 dB four-stop rise holds at 48 / 44.1 / 22.05 kHz.""" - value = min(ceiling, max(0.0, float(color))) - if law == "squared": - value = value * value - if value > 0.0: - value = min(ceiling, value + COLOR_EXPAND_AT_09 * (value / 0.9)) - return value - - -def _linear_ms_table(duty, length=SHAPE_LEN): - """F1's null: a triangle in milliseconds, not in clock.""" - duty = min(0.75, max(0.25, float(duty))) - samples = [] - for index in range(length): - phase = index / float(length) - if phase < duty: - wow = 2.0 * (phase / duty) - 1.0 - else: - wow = 1.0 - 2.0 * (phase - duty) / (1.0 - duty) - samples.append(int(max(-32767, min(32767, round(wow * 32767.0))))) - return array("h", samples) - - -class Flanger(_component.Component): - """Electric Mistress-informed flanger: one `FeedbackDelay`, audiodsp - tier, zero latency until Through Zero (10 ms at Range max / 48 kHz) is - turned on. - - 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 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 bar. - """ - - NAME = 'Flanger' - DISPLAY_NAME = 'Flanger' - CATEGORIES = ('Modulation',) - VERSION = '0.0.2' - - TIER = _component.AUDIODSP - REQUIRES = ("audioecho", "audioroute") - - CAPABILITIES = ("tempo_sync",) - LATENCY_SAMPLES = 0 - TAIL_SAMPLES = None - - MACRO_LABELS = ( - "Rate", "Range", "Color", "Manual", "Filter Matrix", "Mix", - "Shape", "Tone", "Spread", "Sync", "Division", "Through Zero", - ) - MACRO_MODES = { - 0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", 3: "UNIPOLAR", - 4: "TOGGLE", 5: "UNIPOLAR", 6: "UNIPOLAR", 7: "UNIPOLAR", - 8: "UNIPOLAR", 9: "TOGGLE", 10: "UNIPOLAR", 11: "TOGGLE", - } - _MACRO_RANGES = ( - (0.02, 8.0, "log"), # 0 Rate, Hz - (0.0, 1.0), # 1 Range - (0.0, 0.99), # 2 Color - (0.4, 12.0, "log"), # 3 Manual, ms - (0.0, 1.0), # 4 Filter Matrix - (0.0, 2.0), # 5 Mix; 1.0 equal weight, 2.0 wet only - (0.25, 0.75), # 6 Shape, ramp duty - (2000.0, 16000.0, "log"), # 7 Tone, Hz - (0.0, 1.0), # 8 Spread - (0.0, 1.0), # 9 Sync - (0.0, 6.0), # 10 Division index - (0.0, 1.0), # 11 Through Zero - ) - - #: Values are `macro_of` of the engineering settings in the comment. - PATCHES = { - 0: ("Slow jet", (54, 108, 71, 75, 0, 64, 64, 127, 0, 0, 42, 0)), - 1: ("Fast metallic", (98, 89, 90, 60, 0, 64, 64, 85, 0, 0, 42, 0)), - 2: ("Held comb", (54, 64, 71, 75, 127, 64, 64, 127, 0, 0, 42, 0)), - 3: ("Wide sweep with resonance", - (68, 127, 115, 75, 0, 64, 64, 67, 0, 0, 42, 0)), - 4: ("Shallow and short", (83, 19, 19, 34, 0, 64, 64, 127, 0, 0, 42, 0)), - 5: ("Deep and slow, no resonance", - (29, 127, 0, 94, 0, 64, 64, 127, 0, 0, 42, 0)), - 6: ("Synced one bar", (54, 108, 71, 75, 0, 64, 64, 127, 0, 127, 42, 0)), - 7: ("Through zero, dry delayed", - (54, 108, 38, 75, 0, 64, 64, 127, 0, 0, 42, 127)), - } - - #: F1's law. Planted faults flip this without a macro that can. - DELAY_LAW = "reciprocal" - COLOR_LAW = "linear" - DAMPING_HONOURED = True - MATRIX_HONOURED = True - RANGE_SCALE = 1.0 - COLOR_CEILING = 0.99 - DELAY_SLEW = DELAY_SLEW - - def _build(self, rate_hz=0.25, range_=0.85, color=0.55, manual_ms=3.0, - filter_matrix=0.0, mix=1.0, shape=0.5, tone_hz=TONE_MAX_HZ, - spread=0.0, sync=0.0, division=2.0, through_zero=0.0, - patch=None): - """`through_zero` defaults off (0 ms). On, Range max is 10 ms - (480 samples at 48 kHz).""" - self._latency = 0 - self._shape_table = _hyperbola_table(shape) - self._wet = self._own(audioecho.FeedbackDelay( - sample_rate=self._sample_rate, - channel_count=self._channel_count, - max_delay_ms=MAX_DELAY_MS, - delay_ms=manual_ms, feedback=0.0, mix=0.0, - cut_hz=CUT_HZ, delay_slew=type(self).DELAY_SLEW, - wow_shape=self._shape_table), - reset=lambda: self._wet.clear()) - self._wet.play(self._source) - - # Dry delay + mixer exist only for Through Zero. Attaching the mixer - # to `_wet` at construction makes a pull of `_wet` come back at half - # scale (the mixer and the live output sharing one node). So they - # stay dark until the toggle is actually on. - self._dry = None - self._mixer = None - self._output = self._wet - self._tz = False - - self._init_macros( - (rate_hz, range_, color, manual_ms, filter_matrix, mix, shape, - tone_hz, spread, sync, division, through_zero), patch) - - @property - def latency_samples(self): - self._check_live() - return self._latency - - @property - def tail_samples(self): - self._check_live() - mix = self._value(5) - if mix <= MIX_UNITY_SNAP: - return 0 - delay_ms = self._held_ms() - color = _color_to_feedback( - self._value(2), type(self).COLOR_CEILING, type(self).COLOR_LAW) - frames = int(delay_ms * self._sample_rate / 1000.0) + 2 - if color <= 0.0: - return max(frames, int(COLOR0_SETTLE_S * self._sample_rate)) - tau = delay_ms / 1000.0 - t60 = tau * 60.0 / (-20.0 * math.log10(max(color, 1e-6))) - return int(t60 * self._sample_rate) + frames - - def _value(self, index): - return _component.macro_value(self._MACRO_RANGES[index], - self._macros[index]) - - def _transport_tuple(self): - raw = self._transport - if callable(raw): - raw = raw() - return raw - - def _lfo_hz(self): - """Always reads the transport so `tempo_sync` is honest.""" - playing, beat, bpm, num, den = self._transport_tuple()[:5] - del playing, beat, den - if self._value(9) >= 0.5: - index = int(round(self._value(10))) - index = min(len(DIVISIONS) - 1, max(0, index)) - bar_s = (60.0 / max(1.0, float(bpm))) * max(1.0, float(num)) - period = DIVISIONS[index] * bar_s - if period <= 0.0: - return self._value(0) - return 1.0 / period - return self._value(0) - - def _span_ms(self): - depth = min(1.0, max(0.0, self._value(1))) * type(self).RANGE_SCALE - t_long = SHORT_MS + (LONG_MS - SHORT_MS) * max(depth, 1e-6) - t_short = t_long / (1.0 + 3.0 * depth) if depth > 0.0 else t_long - return t_short, t_long - - def _held_ms(self): - if (self._value(4) >= 0.5) and type(self).MATRIX_HONOURED: - return self._value(3) - t_short, t_long = self._span_ms() - return 0.5 * (t_short + t_long) - - def _bake_shape(self): - duty = self._value(6) - if type(self).DELAY_LAW == "linear": - self._shape_table = _linear_ms_table(duty) - else: - self._shape_table = _hyperbola_table(duty) - - def _ensure_tz_graph(self): - if self._dry is not None: - return - split = self._own(audioroute.Splitter(self._source, taps=2), - reset=False, deinit=False) - self._dry = self._own(audioecho.FeedbackDelay( - sample_rate=self._sample_rate, - channel_count=self._channel_count, - max_delay_ms=MAX_DELAY_MS, - delay_ms=LONG_MS, feedback=0.0, mix=2.0, delay_slew=0.0), - reset=lambda: self._dry.clear()) - self._dry.play(split.tap(0)) - self._wet.play(split.tap(1)) - self._mixer = self._own( - audiomixer.Mixer(voice_count=2, **self._pcm(1024)), reset=False) - self._mixer.voice[0].play(self._dry) - self._mixer.voice[1].play(self._wet) - - def _route_tz(self, on): - on = bool(on) - t_short, t_long = self._span_ms() - del t_short - if on: - self._ensure_tz_graph() - self._latency = int(round(t_long * self._sample_rate / 1000.0)) - self._dry.set(delay_ms=t_long, mix=2.0, feedback=0.0) - self._output = self._mixer - else: - self._latency = 0 - self._output = self._wet - self._tz = on - - def _refresh(self): - self._bake_shape() - t_short, t_long = self._span_ms() - matrix = (self._value(4) >= 0.5) and type(self).MATRIX_HONOURED - mix = self._value(5) - if abs(mix - 1.0) <= MIX_UNITY_SNAP: - mix = 1.0 - color = _color_to_feedback( - self._value(2), type(self).COLOR_CEILING, type(self).COLOR_LAW) - tone = self._hz(self._value(7)) - held = self._value(3) if matrix else 0.5 * (t_short + t_long) - depth = 0.0 if matrix else 0.5 * (t_long - t_short) - clock_hz = BBD_N / (2.0 * max(held, 0.1) * 0.001) - damping = max(1.0, clock_hz / 3.0) - if tone < TONE_MAX_HZ * 0.98: - damping = min(damping, tone) - wow_hz = 0.0 if matrix else self._lfo_hz() - tz = self._value(11) >= 0.5 - wet_mix = 2.0 if tz else mix - self._wet.set( - delay_ms=held, - wow_depth_ms=depth, - wow_hz=wow_hz, - wow_shape=self._shape_table, - feedback=color, - mix=wet_mix, - damping_hz=damping, - cut_hz=_loop_cut_hz(color), - cross_feed=self._value(8), - delay_slew=type(self).DELAY_SLEW) - self._route_tz(tz) - if tz and self._mixer is not None: - dry_gain = 0.0 if mix >= 2.0 - MIX_UNITY_SNAP else ( - 1.0 if mix <= 1.0 else 2.0 - mix) - wet_gain = 0.0 if mix <= MIX_UNITY_SNAP else ( - mix if mix <= 1.0 else 1.0) - self._mixer.voice[0].level = dry_gain - self._mixer.voice[1].level = wet_gain - - def _apply_macro(self, index, position): - del index, position - self._refresh() diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 9283fc5..8b579d3 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -572,6 +572,22 @@ # shifter, whose output depends on where its read position sits in the # window (the ruling's N2). A reset renders exactly as a fresh instance # does (the class's reset test); it is the control that never was reset. + # Flanger: the sweep is the node's own free-running triangle, so + # anything that restarts it or runs it at another rate leaves it off + # the uninterrupted control's phase for good; PR holds (a reset is a + # fresh instance). P5 is the family limit the docstring discloses (N1). + ("Flanger", "E1-", "P4"): "a reset starts the sweep again from Manual, so the class matches a fresh instance (PR), not the uninterrupted control", + ("Flanger", "E2-", "P4"): "as E1: the host's reset of the node starts the sweep again", + ("Flanger", "E4-m0=", "P4"): "a Rate move runs the free-running sweep at another rate, so its phase lands elsewhere; putting it back would jump the sweep", + ("Flanger", "E4-m6=", "P4"): "Sync on runs the sweep at the tempo's rate, so its phase lands elsewhere (as m0)", + ("Flanger", "E4-m7=", "P4"): "a Division move with Sync on runs the sweep at another rate (as m0)", + ("Flanger", "E6-", "P4"): "a patch with another Rate or Sync moves the sweep's phase (as E4-m0)", + ("Flanger", "E11-", "P4"): "moves before a pull that reach Rate or the patch move the sweep's phase (as E4-m0)", + ("Flanger", "E4-", "P5"): "family limit, disclosed (N1): a control that jumps makes the output step", + ("Flanger", "E5-", "P5"): "family limit, disclosed (N1): a control that jumps makes the output step (Mix)", + ("Flanger", "E6-", "P5"): "family limit, disclosed (N1): a control that jumps makes the output step (a patch change)", + ("Flanger", "E9-", "P5"): "family limit, disclosed (N1): a control that jumps makes the output step (Mix)", + ("Flanger", "E11-", "P5"): "family limit, disclosed (N1): a control that jumps makes the output step (two or three moves before one pull)", ("ShimmerHall", "E1-", "P4"): "reset() empties the hall and restarts its modulation, and restarts the shifter's splice, so it never re-converges with the uninterrupted control (Reverb's E1 P4; the splice phase, N2)", ("ShimmerHall", "E4-m0=", "P4"): "Character re-cuts the wet chain, so it empties the hall and restarts the shifter's splice, and leaves the hall's tank off the control's for good (Reverb's E4 P4; the splice phase, N2)", ("ShimmerHall", "E4-m1=", "P4"): "Shimmer changes what enters the hall, so it leaves the hall's tank off the control's for good (Reverb's E4 P4)", @@ -663,6 +679,10 @@ ("NodeFlanger", "E8-", "circuitpython"): "upstream's Flanger never returns from a pull on a looped source" " that ends empty; audiodsp 7b fixed it on CPython and MicroPython", + ("Flanger", "E8-", "circuitpython"): + "the class is upstream's Flanger, which never returns from a pull on" + " a looped source that ends empty; audiodsp 7b fixed it on CPython" + " and MicroPython", ("ShimmerHall", "E8-", "circuitpython"): "Bloom pulls upstream's PitchShift, which never returns from a pull" " on a looped source that ends empty (audiodsp#243 fixed it on" @@ -2168,6 +2188,10 @@ def _cli(argv): else: from audioeffects import rebuilt cls = rebuilt.module_class(name) + if cls is None: + # A class that has come home: the one the package serves. + import audioeffects + cls = getattr(audioeffects, name, None) if cls is None: print("MISSING|%s" % name) continue diff --git a/tests/test_cpython_effects_flanger.py b/tests/test_cpython_effects_flanger.py index 766ff8f..1f60782 100644 --- a/tests/test_cpython_effects_flanger.py +++ b/tests/test_cpython_effects_flanger.py @@ -1,738 +1,1171 @@ -"""`Flanger`'s own invariant and planted-fault tests. - -The dossier was frozen -2026-09-08. Exhaustive rate coverage lives in the evidence pack; this -file asserts at 48 kHz unless the test is about rate or latency. +"""`Flanger`'s trait and planted-fault tests. + +Six traits, T1 to T6 (the card's), each with its measurement run over the +span listed with it and the same measurement shown red on a planted fault +of the same kind. `KIT_PLANTS` hands the plants to the kit, which shows +each one differs from the clean class and that no knob stop or patch +reproduces it. `CLAIMS` ties every claim sentence of the docstring to the +tests that hold it. + +Every law a measurement checks against is written out here, never taken +from the class: the comb's sum in integers, the two Color laws, the ring's +trips, the triangle, the tempo's period. + +The module is numpy-free and imports on MicroPython and CircuitPython, +where `unittest` is absent: the kit reads `KIT_PLANTS` there, and +`native_report()` prints each trait's figures for the three interpreters to +be compared (`python tests/test_cpython_effects_flanger.py --report`). """ import math -import os import sys -import unittest from array import array -import numpy as np +try: + import unittest + TestCase = unittest.TestCase +except ImportError: # a native interpreter + unittest = None + TestCase = object -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "support")) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +try: + import os + _HERE = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, os.path.join(_HERE, "support")) + sys.path.insert(0, os.path.join(_HERE, "..", "lib")) +except (ImportError, AttributeError, NameError): # no os.path natively + pass -import kit_faults # noqa: E402 -import kit_probes as probes # noqa: E402 -from audioeffects import rebuilt as rebuilt_pkg # noqa: E402 -from audioeffects.rebuilt import flanger as rebuilt # noqa: E402 -from tools import effect_measurements as kit # noqa: E402 +import kit_common as kc # noqa: E402 +from audioeffects import flanger as flanger_module # noqa: E402 VENDOR = "PyDevices" -RATE = 48000 -Flanger = rebuilt.Flanger +Flanger = flanger_module.Flanger +RATES = (48000, 22050) +BLOCK = 256 +RATE, RANGE, MANUAL, COLOR, MIX, POLARITY, SYNC, DIVISION = range(8) +SEEDS = range(8) -def macro_of(index, value): - from audioeffects import _component - return _component.macro_of(Flanger._MACRO_RANGES[index], value) +# -------------------------------------------------------------------------- +# Material -class LinearMsFlanger(Flanger): - """F1: delay linear in milliseconds instead of 1/clock.""" - NAME = 'Flanger' - DELAY_LAW = "linear" +class Rng: + """A 31-bit LCG, the same on every interpreter.""" -class UnequalMixFlanger(Flanger): - """F2: equal-weight mix forced off the node's 1.0 after every refresh.""" - NAME = 'Flanger' - FORCED_MIX = 0.55 + def __init__(self, seed): + self.state = (seed * 2654435761 + 12345) & 0x7FFFFFFF - def _refresh(self): - Flanger._refresh(self) - if getattr(type(self), "FORCED_MIX", None) is not None: - self._wet.set(mix=type(self).FORCED_MIX) + def next(self): + self.state = (self.state * 1103515245 + 12345) & 0x7FFFFFFF + return self.state + def randint(self, low, high): + return low + self.next() % (high - low + 1) -class SquaredColorFlanger(Flanger): - """F3: Color applied as g^2.""" - NAME = 'Flanger' - COLOR_LAW = "squared" + def unit(self): + return self.next() / 2147483648.0 -class DeafMatrixFlanger(Flanger): - """F4: Filter Matrix does not stop the LFO.""" - NAME = 'Flanger' - MATRIX_HONOURED = False +def noise(seed, frames, peak, channels=1): + """Interleaved seeded noise, each channel its own draw.""" + rng = Rng(seed) + return array("h", [rng.randint(-peak, peak) + for _ in range(frames * channels)]) -class InvertedMatrixFlanger(Flanger): - """F4 at defaults: Matrix off stops the LFO (the switch is inverted).""" - NAME = 'Flanger' - MATRIX_INVERTED = True +def sine(f, rate, frames, amp, phase=0.0): + return array("h", [int(round(amp * math.sin( + 2.0 * math.pi * f * n / rate + phase))) for n in range(frames)]) - def _refresh(self): - Flanger._refresh(self) - if type(self).MATRIX_INVERTED: - matrix = self._value(4) >= 0.5 - if not matrix: - self._wet.set(wow_hz=0.0, wow_depth_ms=0.0) +def clicks(rate, frames, offset, spacing, amp=16384): + x = array("h", bytes(2 * frames)) + for c in range(offset, frames - spacing, spacing): + x[c] = amp + return x, list(range(offset, frames - spacing, spacing)) -class HalfRangeFlanger(Flanger): - """F5: Range travel halved, so 4:1 is unreachable.""" - NAME = 'Flanger' - RANGE_SCALE = 0.35 +def render(cls, values, rate, channels=1, frames=None, options=None, + macros=None, transport=None, actions=None): + """`cls` around `values`, then silence: `frames` frames, interleaved.""" + if frames is None: + frames = len(values) // channels + feed = kc.feed(values, rate, channels) + keywords = dict(options or {}) + if transport is not None: + keywords["transport"] = transport + e = cls(feed.port, sample_rate=rate, **keywords) + try: + for index, value in (macros or ()): + e.set_macro(index, value) + return kc.samples(kc.render(e, frames, actions)) + finally: + e.deinit() -class NoSlewFlanger(Flanger): - """F6: delay jumps; slew is not a macro.""" - NAME = 'Flanger' - DELAY_SLEW = 0.0 +def channel(values, channels, which=0): + return [values[i] for i in range(which, len(values), channels)] -class FlatDampingFlanger(Flanger): - """F7: no clock-dependent darkening.""" - NAME = 'Flanger' - DAMPING_HONOURED = False - def _refresh(self): - Flanger._refresh(self) - self._wet.set(damping_hz=0.0) +def held(tau, rate, color=0.0, mix=1.0, polarity="positive"): + """Options for a comb held still at `tau` frames: Range 0.""" + return {"depth": 0.0, "manual_ms": tau * 1000.0 / rate, "color": color, + "mix": mix, "polarity": polarity} -class SoftColorFlanger(Flanger): - """F8: Color ceiling too low for a 2 s tail at 3 ms.""" - NAME = 'Flanger' - COLOR_CEILING = 0.5 +# -------------------------------------------------------------------------- +# The laws, written out + + +def trunc_mul(d, m): + """trunc(d * m / 32768), toward zero, in integers.""" + return (d * m) >> 15 if d >= 0 else -((-d * m) >> 15) + + +def comb_law(x, tau, mix, sign=1): + """x[n] + sign * trunc(x[n - tau] * int(mix * 32767) / 32768); the dry + alone at Mix 0.01 and below.""" + m = int(mix * 32767) + out = [] + for n in range(len(x)): + if mix <= 0.01: + out.append(x[n]) + continue + d = x[n - tau] if n >= tau else 0 + out.append(x[n] + sign * trunc_mul(d, m)) + return out + + +def q15(color): + return int(color * 32767) / 32767.0 + + +def color_laws(g, negative=False): + """(gain at k/tau, gain at (2k + 1)/(2 tau)) of the comb at Mix 1.""" + plus = (2.0 - g) / (1.0 - g) + minus = g / (1.0 + g) + return (minus, plus) if negative else (plus, minus) + + +def db(v): + return 20.0 * math.log10(max(v, 1e-12)) + + +def amp_at(y, f, rate, start): + """Least-squares amplitude of `f` over y[start:].""" + s = c = 0.0 + for i in range(start, len(y)): + w = 2.0 * math.pi * f * i / rate + s += y[i] * math.sin(w) + c += y[i] * math.cos(w) + return 2.0 * math.sqrt(s * s + c * c) / (len(y) - start) + + +def trips_60(g): + return 60.0 / (-20.0 * math.log10(g)) + + +def trips_silence(g): + return 90.3 / (-20.0 * math.log10(g)) + 1.0 + +def card_tail(color, top_ms, rate): + """The card's section 5 bound, written out.""" + g = int(color * 32767) / 32767.0 + d = min(int(rate // 100), int(math.ceil(top_ms * rate / 1000.0))) + if g <= 0.0: + return d + 3 + return (int(math.ceil(90.31 / (-20.0 * math.log10(g)))) + 2) * (d + 1) + 2 -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.""" + +def triangle_ms(n, lfo, rate, manual, depth, clamp=True): + """The delay the node reads at output frame n, in frames: from the + floor, the phase advanced once a frame from 0, the top clamped to the + line (`clamp`).""" + inc = int(min(lfo / rate, 0.5) * 4294967296.0) + ph = ((n + 1) * inc) & 0xFFFFFFFF + tri = ph >> 15 + if tri > 65535: + tri = 131071 - tri + line = int(rate // 100) + top = manual + depth * (10.0 - manual) + top_f = top * rate / 1000.0 + if clamp: + top_f = min(top_f, line) + lo_f = manual * rate / 1000.0 + return lo_f + (top_f - lo_f) * tri / 65536.0 + + +def tempo_rate(bpm, beats_per_bar, bars): + return bpm / (60.0 * beats_per_bar * bars) + + +# -------------------------------------------------------------------------- +# The measurements + + +def t1_worst(cls, rate, tau, mix, channels, seed, sign=1, polarity=None): + """The largest |out - law| over every channel, and how many samples + differ at all.""" + frames = 3000 + x = noise(seed, frames, 14000, channels) + options = held(tau, rate, mix=mix, + polarity=polarity or ("negative" if sign < 0 + else "positive")) + y = render(cls, x, rate, channels, frames, options) + worst = differ = 0 + for ch in range(channels): + law = comb_law(channel(x, channels, ch), tau, mix, sign) + got = channel(y, channels, ch) + for n in range(frames): + e = abs(got[n] - law[n]) + if e: + differ += 1 + worst = max(worst, e) + return worst, differ + + +def t1_notch_db(cls, rate, tau, k, phase, channels=1): + """A sine of 8000 on the k-th notch at Mix 1, Color 0: output level + against input, dB.""" + f = (2 * k + 1) * rate / (2.0 * tau) + frames = int(rate * 0.2) + x = sine(f, rate, frames, 8000, phase) + if channels == 2: + stereo = array("h", bytes(4 * frames)) + for n in range(frames): + stereo[2 * n] = stereo[2 * n + 1] = x[n] + x = stereo + y = render(cls, x, rate, channels, frames, held(tau, rate)) + return max(db(amp_at(channel(y, channels, ch), f, rate, frames // 2) + / 8000.0) for ch in range(channels)) + + +def comb_pair_db(cls, rate, color, negative, phase, tau=None): + """(level at k/tau, at (2k + 1)/(2 tau)), k = 1, against the input, dB: + a sine of 1000, the second half of 0.6 s.""" + tau = tau or (144 if rate == 48000 else 66) + frames = int(rate * 0.6) + out = [] + for f in (rate / float(tau), rate / (2.0 * tau)): + x = sine(f, rate, frames, 1000, phase) + y = render(cls, x, rate, 1, frames, + held(tau, rate, color=color, + polarity="negative" if negative else "positive")) + out.append(db(amp_at(y, f, rate, frames // 2) / 1000.0)) + return out + + +def ring_reading(cls, rate, manual_ms, color, seed, depth=0.0, lfo=None): + """(the -60 dB time as a fraction of its law, the last non-zero + sample as a fraction of the silence law, the last non-zero sample in + frames after the burst, `tail_samples` read after it).""" + burst = int(0.05 * rate) + x = noise(seed, burst, 16000) + options = {"depth": depth, "manual_ms": manual_ms, "color": color, + "mix": 1.0} + if lfo is not None: + options["rate"] = lfo + feed = kc.feed(x, rate, 1) + e = cls(feed.port, sample_rate=rate, **options) + try: + top = manual_ms + depth * (10.0 - manual_ms) + long = max(e.tail_samples, card_tail(color, top, rate)) + # One render: a pull hands whole blocks, so two renders would drop + # the frames of the burst's last block past the burst. + y = kc.samples(kc.render(e, burst + long + 4 * BLOCK)) + tail = e.tail_samples + finally: + e.deinit() + win, hop = int(0.01 * rate), int(0.001 * rate) + sq = [0] + for v in y: + sq.append(sq[-1] + v * v) + + def rms(a, b): + return math.sqrt((sq[b] - sq[a]) / float(b - a)) + ref = rms(burst - win, burst) + t60 = None + pos = burst + while pos + win <= len(y): + if rms(pos, pos + win) <= ref * 1e-3: + t60 = (pos - burst) / float(rate) + break + pos += hop + last = 0 + for i in range(len(y) - 1, -1, -1): + if y[i]: + last = i + break + g = q15(color) + top = manual_ms + depth * (10.0 - manual_ms) + top_frames = min(rate // 100, top * rate / 1000.0) + law60 = trips_60(g) * manual_ms / 1000.0 + law_sil = trips_silence(g) * top_frames + return (None if t60 is None else t60 / law60, + (last - burst) / law_sil, last - burst, tail) + + +def end_then_replay(cls, reset): + """The source ends after a burst at 3 ms and full Color; then, with or + without a reset, the node is played again on silence. Non-zero + samples in what comes out.""" + rate = 48000 + x = noise(0, 2400, 16000) + feed = kc.feed(x, rate, 1) + e = cls(feed.port, sample_rate=rate, depth=0.0, manual_ms=3.0, + color=0.95) + try: + kc.render(e, 2560) + feed.point(feed.empty) + ended = kc.samples(kc.render(e, 4096)) + if reset: + e.reset() + feed.point(feed.sil) + # A host plays the node again: the only way back in after an end. + e._node.play(feed.port) + again = kc.samples(kc.render(e, 4864)) + finally: + e.deinit() + return sum(1 for v in ended if v), sum(1 for v in again if v) + + +def sweep_readings(cls, rate, lfo, depth, manual, seed, frames=None, + options=None, transport=None, actions=None): + """[(output frame, delay in frames)] from a click every 12 ms, Color 0, + Mix 1: each click's echo as a centroid of its frames.""" + spacing = int(0.012 * rate) + offset = Rng(seed).randint(0, spacing - 1) + if frames is None: + frames = int(rate / lfo) + spacing + x, at = clicks(rate, frames, offset, spacing) + opts = {"rate": lfo, "depth": depth, "manual_ms": manual, "color": 0.0, + "mix": 1.0} + opts.update(options or {}) + y = render(cls, x, rate, 1, frames, opts, transport=transport, + actions=actions) + out = [] + for c in at: + tot = mom = 0.0 + for i in range(c + 1, min(len(y), c + spacing)): + if y[i]: + tot += y[i] + mom += (i - c) * y[i] + if tot: + d = mom / tot + out.append((c + int(d), d)) + return out + + +def sweep_worst(readings, lfo, rate, manual, depth): + return max(abs(d - triangle_ms(n, lfo, rate, manual, depth)) + for n, d in readings) + + +def fit_rate(readings, rate, manual, depth, guess, start_phase=0, + start_frame=-1): + """The sweep rate whose triangle fits the readings best, by a golden + search within 2 % of `guess`; the phase runs from `start_phase` at + `start_frame`.""" + lo_f = manual * rate / 1000.0 + top = manual + depth * (10.0 - manual) + top_f = min(top * rate / 1000.0, int(rate // 100)) + + def err(lfo): + inc = lfo / rate * 4294967296.0 + total = 0.0 + for n, d in readings: + ph = (start_phase + (n - start_frame) * inc) % 4294967296.0 + tri = ph / 32768.0 + if tri > 65536.0: + tri = 131072.0 - tri + exp = lo_f + (top_f - lo_f) * tri / 65536.0 + total += (d - exp) ** 2 + return total + a, b = guess * 0.98, guess * 1.02 + r = (math.sqrt(5.0) - 1.0) / 2.0 + c, d = b - r * (b - a), a + r * (b - a) + fc, fd = err(c), err(d) + for _ in range(60): + if fc < fd: + b, d, fd = d, c, fc + c = b - r * (b - a) + fc = err(c) + else: + a, c, fc = c, d, fd + d = a + r * (b - a) + fd = err(d) + return (a + b) / 2.0 + + +class Transport: + """A host transport whose tempo and meter a test moves.""" + + def __init__(self, bpm, beats_per_bar): + self.bpm = bpm + self.beats_per_bar = beats_per_bar + + def __call__(self): + return (True, 0.0, self.bpm, 0, self.beats_per_bar) + + +def synced_period_error(cls, rate, bpm, beats_per_bar, bars, seed): + """The fitted sweep's period against Division bars at the tempo, as a + fraction.""" + transport = Transport(bpm, beats_per_bar) + want = tempo_rate(bpm, beats_per_bar, bars) + manual, depth = 1.0, 0.6 + readings = sweep_readings( + cls, rate, want, depth, manual, seed, + frames=int(rate / want * 1.05) + int(0.012 * rate), + options={"rate": 0.25, "sync": True, "division": bars}, + transport=transport) + got = fit_rate(readings, rate, manual, depth, want) + return abs(got / want - 1.0) + + +def tempo_change_error(cls, rate, seed): + """120 -> 150 bpm in 4/4 at one bar, `transport_changed()` between two + pulls: the next full period against 150 bpm, the phase carried.""" + transport = Transport(120.0, 4) + first = tempo_rate(120.0, 4, 1.0) + second = tempo_rate(150.0, 4, 1.0) + manual, depth = 1.0, 0.6 + change = 120 # the pull before which it lands + boundary = change * BLOCK + + def move(e): + transport.bpm = 150.0 + e.transport_changed() + frames = boundary + int(rate / second * 1.05) + int(0.012 * rate) + readings = sweep_readings( + cls, rate, first, depth, manual, seed, frames=frames, + options={"rate": 0.25, "sync": True, "division": 1.0}, + transport=transport, actions={change: move}) + inc1 = int(min(first / rate, 0.5) * 4294967296.0) + phase = (boundary * inc1) & 0xFFFFFFFF + after = [(n, d) for n, d in readings if n >= boundary + 300] + got = fit_rate(after, rate, manual, depth, second, start_phase=phase, + start_frame=boundary - 1) + return abs(got / second - 1.0) + + +# -------------------------------------------------------------------------- +# The planted faults + + +class HalfMix(Flanger): + """T1: Mix sent to the node halved (the old class's law).""" 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)) + self._node.mix = self._value(MIX) * 0.5 -class ShortRingFlanger(_CutAtColorMaxFlanger): - """F8: the Color-max cut at 0.8 Hz, a ring about 0.1-0.3 s shorter.""" +class SquaredColor(Flanger): + """T2: Color squared on the way to the node.""" NAME = 'Flanger' - COLOR_MAX_CUT_HZ = 0.8 + + def _refresh(self): + Flanger._refresh(self) + color = self._value(COLOR) ** 2 + self._node.feedback = -color if self._negative() else color -class LongRingFlanger(_CutAtColorMaxFlanger): - """F8: the Color-max cut at 0.2 Hz, a ring about 0.05-0.1 s longer.""" +class SixtyTail(Flanger): + """T3: `tail_samples` from the 60 dB law instead of silence.""" NAME = 'Flanger' - COLOR_MAX_CUT_HZ = 0.2 + + @property + def tail_samples(self): + self._check_live() + g = q15(self._value(COLOR)) + d = min(self._sample_rate // 100, int(math.ceil( + self._top_ms() * self._sample_rate / 1000.0))) + if g <= 0.0: + return d + 3 + return int(math.ceil(trips_60(g))) * (d + 1) -class ShortLatencyFlanger(Flanger): - """CLICK: report 256 samples short.""" +class KeepLine(Flanger): + """T3: a `reset()` that leaves the node's line alone.""" NAME = 'Flanger' - @property - def latency_samples(self): + def reset(self): self._check_live() - return max(0, self._latency - 256) + self.program_change(0) + + +class SquaredRange(Flanger): + """T4: Range squared on the way to the node.""" + NAME = 'Flanger' + + def _refresh(self): + Flanger._refresh(self) + self._node.depth = self._value(RANGE) ** 2 -def build(cls=None, rate=RATE, channels=2, frames=48000, probe=None, - **options): - cls = cls or Flanger - if probe is None: - probe = probes.silence(frames, channels) - source = probes.ArraySource(probe, rate=rate, block=256, - channels=channels) - return cls.create(source, rate, **options) +class FourBeatBar(Flanger): + """T5: the bar taken as four beats whatever the meter.""" + NAME = 'Flanger' + def _rate_hz(self): + if self._macros[SYNC] < 0.5: + return Flanger._rate_hz(self) + state = self._transport() + bars = flanger_module._choice(flanger_module.DIVISION_BARS, + self._macros[DIVISION]) + return max(1.0, float(state[2])) / (60.0 * 4.0 * bars) -def render(effect, frames, rate=RATE, channels=2, label=None): - return probes.render(effect.output, frames, rate=rate, channels=channels, - block=256, label=label, class_name="Flanger", - latency_samples=effect.latency_samples) +class InvertOnly(Flanger): + """T6: `invert` set with the feedback left positive.""" + NAME = 'Flanger' -def noise(frames=65536, channels=2, seed=1): - rng = np.random.RandomState(seed) - mono = rng.randint(-8000, 8000, size=frames, dtype=np.int32) - if channels == 2: - stacked = np.repeat(mono[:, None], 2, axis=1) - else: - stacked = mono[:, None] - return array("h", stacked.reshape(-1).tolist()) + def _refresh(self): + Flanger._refresh(self) + self._node.feedback = self._value(COLOR) -def sine(hz, frames=48000, dbfs=-20.0, rate=RATE, channels=2): - index = np.arange(frames) - values = 10.0 ** (dbfs / 20.0) * np.sin(2.0 * math.pi * hz * index / rate) - quantised = np.clip(np.round(values * 32768.0), -32768, 32767).astype(np.int64) - if channels == 2: - quantised = np.repeat(quantised[:, None], 2, axis=1) - return array("h", quantised.reshape(-1).tolist()) - - -def transfer_db(wet, dry, rate=RATE, nfft=16384): - w = wet.float[:, 0] - d = dry.float[:, 0] - hop = nfft // 2 - acc_w = None - acc_d = None - count = 0 - window = np.hanning(nfft) - for start in range(0, min(len(w), len(d)) - nfft, hop): - fw = np.fft.rfft(w[start:start + nfft] * window) - fd = np.fft.rfft(d[start:start + nfft] * window) - pw = np.abs(fw) ** 2 - pd = np.abs(fd) ** 2 - acc_w = pw if acc_w is None else acc_w + pw - acc_d = pd if acc_d is None else acc_d + pd - count += 1 - mag = 10.0 * np.log10((acc_w / count) / np.maximum(acc_d / count, 1e-24)) - hz = np.fft.rfftfreq(nfft, 1.0 / rate) - return hz, mag - - -def deepest_notch(hz, mag, lo=100.0, hi=8000.0): - band = (hz >= lo) & (hz <= hi) - mean = float(np.mean(mag[band])) - idx = int(np.argmin(mag[band])) - return float(hz[band][idx]), float(mag[band][idx] - mean), mean - - -def peak_in_band(hz, mag, lo=100.0, hi=6000.0): - band = (hz >= lo) & (hz <= hi) - return float(np.max(mag[band])) - - -class LinearMsFlangerReach(unittest.TestCase): - def test_f1_law_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, LinearMsFlanger, - lambda e: type(e).DELAY_LAW, - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F1") - - def test_linear_ms_fires_at_defaults(self): - probe = sine(1000, frames=8192) - clean = build(probe=probe) - dirty = build(LinearMsFlanger, probe=probe) - a = render(clean, 8192, label="tone") - b = render(dirty, 8192, label="tone") - clean.deinit() - dirty.deinit() - self.assertGreater(float(np.max(np.abs(a.float - b.float))), 1e-4) - - -class F2EqualWeightComb(unittest.TestCase): - def _notch(self, cls=Flanger, delay_ms=3.0): - probe = noise(65536) - dry_src = probes.ArraySource(probe, rate=RATE, block=256) - dry = probes.render(dry_src, 65536, rate=RATE, block=256, - probe="noise", class_name="source") - effect = build(cls, probe=probe, mix=1.0, color=0.0, - filter_matrix=1.0, manual_ms=delay_ms, range_=0.0) - wet = render(effect, 65536, label="noise") - effect.deinit() - hz, mag = transfer_db(wet, dry) - freq, depth, mean = deepest_notch(hz, mag) - return {"freq": freq, "depth": depth, "mean": mean, - "hz": hz, "mag": mag} - - def test_held_3ms_notches_are_odd_harmonics_and_deep(self): - reading = self._notch() - expected = 1000.0 / (2.0 * 3.0) - self.assertGreater(abs(reading["depth"]), 30.0, reading) - # nearest predicted (2k+1)/(2 tau) - k = round((reading["freq"] / expected - 1.0) / 2.0) - pred = (2 * k + 1) * expected - self.assertLess(abs(reading["freq"] - pred) / pred, 0.005, reading) - - def test_forced_mix_shallower_than_30db(self): - reading = self._notch(UnequalMixFlanger) - self.assertLess(abs(reading["depth"]), 30.0, reading) - - def test_mix_fault_is_not_a_surface_flag(self): - kit_faults.fault_reachability( - Flanger, UnequalMixFlanger, - lambda e: getattr(type(e), "FORCED_MIX", None), - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F2") - - def test_unequal_mix_fires_at_defaults(self): - probe = noise(32768) - dry_src = probes.ArraySource(probe, rate=RATE, block=256) - dry = probes.render(dry_src, 32768, rate=RATE, block=256, - probe="noise", class_name="source") - clean = build(probe=probe) - dirty = build(UnequalMixFlanger, probe=probe) - a = render(clean, 32768, label="noise") - b = render(dirty, 32768, label="noise") - clean.deinit() - dirty.deinit() - _, da, _ = deepest_notch(*transfer_db(a, dry)) - _, db, _ = deepest_notch(*transfer_db(b, dry)) - self.assertGreater(abs(abs(da) - abs(db)), 1.0, (da, db)) - - def test_null_build_has_no_notch(self): - def measure(cls): - reading = self._notch(cls) - return {"passed": abs(reading["depth"]) >= 30.0, "red": [reading]} - kit_faults.null_build_red(Flanger, measure, label="Flanger F2") - - -class F3ColorIsPeaks(unittest.TestCase): - def _peak(self, color, cls=Flanger): - probe = noise(32768) - dry_src = probes.ArraySource(probe, rate=RATE, block=256) - dry = probes.render(dry_src, 32768, rate=RATE, block=256, - probe="noise", class_name="source") - effect = build(cls, probe=probe, mix=1.0, color=color, - filter_matrix=1.0, manual_ms=3.0) - wet = render(effect, 32768, label="noise") - effect.deinit() - hz, mag = transfer_db(wet, dry) - return peak_in_band(hz, mag) - - def test_peaks_rise_from_color_0_to_09(self): - lows = [self._peak(c) for c in (0.0, 0.3, 0.6, 0.9)] - self.assertEqual(lows, sorted(lows), lows) - self.assertGreaterEqual(lows[-1] - lows[0], 15.0, lows) - - def _peak_at(self, color, rate, seed=1): - probe = noise(32768, seed=seed) - dry_src = probes.ArraySource(probe, rate=rate, block=256) - dry = probes.render(dry_src, 32768, rate=rate, block=256, - probe="noise", class_name="source") - effect = build(rate=rate, probe=probe, mix=1.0, color=color, - filter_matrix=1.0, manual_ms=3.0) - wet = render(effect, 32768, rate=rate, label="noise") - effect.deinit() - return peak_in_band(*transfer_db(wet, dry, rate=rate)) - - def test_peaks_rise_at_44100_and_22050(self): - """F3's +15 dB four-stop bar at the two lower rates the audit named.""" - for rate, seed in ((44100, 99), (22050, 1), (22050, 99)): - lows = [self._peak_at(c, rate, seed=seed) - for c in (0.0, 0.3, 0.6, 0.9)] - self.assertEqual(lows, sorted(lows), (rate, seed, lows)) - self.assertGreaterEqual(lows[-1] - lows[0], 15.0, - (rate, seed, lows)) - - def test_squared_color_is_not_plus_15db(self): - lows = [self._peak(c, SquaredColorFlanger) for c in (0.0, 0.9)] - self.assertLess(lows[-1] - lows[0], 15.0, lows) - - def test_color_law_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, SquaredColorFlanger, - lambda e: type(e).COLOR_LAW, - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F3") - - def test_floor_fills_at_least_10db(self): - probe = noise(32768) - dry_src = probes.ArraySource(probe, rate=RATE, block=256) - dry = probes.render(dry_src, 32768, rate=RATE, block=256, - probe="noise", class_name="source") - floors = [] - for color in (0.0, 0.9): - effect = build(probe=probe, mix=1.0, color=color, - filter_matrix=1.0, manual_ms=3.0) - wet = render(effect, 32768, label="noise") - effect.deinit() - hz, mag = transfer_db(wet, dry) - _, depth, _ = deepest_notch(hz, mag) - floors.append(depth) - self.assertGreaterEqual(floors[1] - floors[0], 10.0, floors) - - def test_squared_color_fires_at_defaults(self): - probe = noise(32768) - dry_src = probes.ArraySource(probe, rate=RATE, block=256) - dry = probes.render(dry_src, 32768, rate=RATE, block=256, - probe="noise", class_name="source") - clean = build(probe=probe) - dirty = build(SquaredColorFlanger, probe=probe) - a = render(clean, 32768, label="noise") - b = render(dirty, 32768, label="noise") - clean.deinit() - dirty.deinit() - ha, ma = transfer_db(a, dry) - hb, mb = transfer_db(b, dry) - self.assertGreater(abs(peak_in_band(ha, ma) - peak_in_band(hb, mb)), - 0.5) - - def test_f3_null_build_is_red(self): - def measure(cls): - lows = [self._peak(c, cls) for c in (0.0, 0.9)] - rise = lows[-1] - lows[0] - return {"passed": rise >= 15.0, "red": [{"rise": rise}]} - kit_faults.null_build_red(Flanger, measure, label="Flanger F3") - - -class F4FilterMatrix(unittest.TestCase): - def test_matrix_stops_wow(self): - effect = build(probe=probes.silence(2048), filter_matrix=1.0, - rate_hz=8.0) - # wow_hz is not a macro; Matrix on must leave it at 0. - self.assertEqual(effect._value(4) >= 0.5, True) - effect.deinit() - - def test_deaf_matrix_keeps_sweeping_flag(self): - kit_faults.fault_reachability( - Flanger, DeafMatrixFlanger, - lambda e: type(e).MATRIX_HONOURED, - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F4") - - def test_inverted_matrix_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, InvertedMatrixFlanger, - lambda e: bool(getattr(type(e), "MATRIX_INVERTED", False)), - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F4 invert") - - def test_inverted_matrix_kills_sweep_at_defaults(self): - probe = sine(1000, frames=8192, dbfs=-16.0) - clean = build(probe=probe, rate_hz=8.0) - dirty = build(InvertedMatrixFlanger, probe=probe, rate_hz=8.0) - a = render(clean, 8192, label="tone") - b = render(dirty, 8192, label="tone") - clean.deinit() - dirty.deinit() - self.assertGreater(float(np.max(np.abs(a.float - b.float))), 1e-4) - - -class F5SweepSpan(unittest.TestCase): - def test_range_max_is_four_to_one(self): - effect = build(probe=probes.silence(2048), range_=1.0, - filter_matrix=0.0) - short, long_ms = effect._span_ms() - self.assertGreaterEqual(long_ms / short, 4.0 - 1e-6) - self.assertGreaterEqual(long_ms, 8.0) - self.assertLessEqual(long_ms, 12.0) - effect.deinit() - - def test_half_range_misses_the_ratio(self): - effect = build(HalfRangeFlanger, probe=probes.silence(2048), - range_=1.0, filter_matrix=0.0) - short, long_ms = effect._span_ms() - self.assertLess(long_ms / short, 4.0) - effect.deinit() - - def test_range_scale_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, HalfRangeFlanger, - lambda e: type(e).RANGE_SCALE, - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F5") - - def test_half_range_fires_at_defaults(self): - probe = sine(1000, frames=8192) - clean = build(probe=probe) - dirty = build(HalfRangeFlanger, probe=probe) - a = render(clean, 8192, label="tone") - b = render(dirty, 8192, label="tone") - clean.deinit() - dirty.deinit() - self.assertGreater(float(np.max(np.abs(a.float - b.float))), 1e-4) - - -class F6Slew(unittest.TestCase): - def test_slew_is_one_on_the_shipped_class(self): - self.assertEqual(Flanger.DELAY_SLEW, 1.0) - self.assertEqual(NoSlewFlanger.DELAY_SLEW, 0.0) - - def test_slew_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, NoSlewFlanger, - lambda e: type(e).DELAY_SLEW, - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F6") - - def test_no_slew_fires_on_the_live_manual_step(self): - from audioeffects import _component - probe = sine(1000, frames=8192) - pos = _component.macro_of(Flanger._MACRO_RANGES[3], 6.0) - clean = build(probe=probe, filter_matrix=1.0, manual_ms=1.0, - color=0.0, mix=2.0) - dirty = build(NoSlewFlanger, probe=probe, filter_matrix=1.0, - manual_ms=1.0, color=0.0, mix=2.0) - render(clean, 256, label="tone") - render(dirty, 256, label="tone") - clean.set_macro(3, pos) - dirty.set_macro(3, pos) - a = render(clean, 2048, label="tone") - b = render(dirty, 2048, label="tone") - clean.deinit() - dirty.deinit() - self.assertGreater( - float(np.max(np.abs(a.float[:1024] - b.float[:1024]))), 1e-4) - - def test_no_slew_is_silent_at_constructor_defaults(self): - """F6's plant does not move audio until a live delay_ms step.""" - probe = sine(1000, frames=8192) - clean = build(probe=probe) - dirty = build(NoSlewFlanger, probe=probe) - a = render(clean, 8192, label="tone") - b = render(dirty, 8192, label="tone") - clean.deinit() - dirty.deinit() - self.assertEqual(float(np.max(np.abs(a.float - b.float))), 0.0) - - -class F7Darkens(unittest.TestCase): - def _level_10k(self, delay_ms, cls=Flanger): - probe = sine(10000, frames=8192, dbfs=-16.0) - effect = build(cls, probe=probe, mix=2.0, color=0.0, - filter_matrix=1.0, manual_ms=delay_ms, range_=0.0) - wet = render(effect, 8192, label="tone10k") - effect.deinit() - skip = int(delay_ms * RATE / 1000.0) + 64 - return kit.rms_db(wet.float[skip:, 0]) - - def test_10khz_falls_from_1ms_to_10ms(self): - short = self._level_10k(1.0) - long_ = self._level_10k(10.0) - self.assertGreaterEqual(short - long_, 0.4, (short, long_)) - - def test_flat_damping_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, FlatDampingFlanger, - lambda e: getattr(type(e), "DAMPING_HONOURED", True), - lambda subject: build(subject, probe=probes.silence(2048)), - label="Flanger F7") - - def test_flat_damping_does_not_fall_0p4(self): - short = self._level_10k(1.0, FlatDampingFlanger) - long_ = self._level_10k(10.0, FlatDampingFlanger) - self.assertLess(short - long_, 0.4, (short, long_)) - - def test_flat_damping_fires_at_defaults(self): - probe = sine(10000, frames=8192, dbfs=-16.0) - clean = build(probe=probe) - dirty = build(FlatDampingFlanger, probe=probe) - a = render(clean, 8192, label="tone10k") - b = render(dirty, 8192, label="tone10k") - clean.deinit() - dirty.deinit() - self.assertGreater(abs(kit.rms_db(a.float[256:, 0]) - - kit.rms_db(b.float[256:, 0])), 0.05) - - def test_f7_fall_null_build_is_red(self): - def measure(cls): - fall = self._level_10k(1.0, cls) - self._level_10k(10.0, cls) - return {"passed": fall >= 0.4, "red": [{"fall": fall}]} - kit_faults.null_build_red(Flanger, measure, label="Flanger F7") - - -class F8ColorCeiling(unittest.TestCase): - def test_max_color_tail_is_at_least_two_seconds(self): - effect = build(probe=probes.silence(2048), color=0.99, - filter_matrix=1.0, manual_ms=3.0, mix=1.0) - self.assertGreaterEqual(effect.tail_samples / float(RATE), 2.0) - effect.deinit() - - def test_soft_ceiling_is_under_two_seconds(self): - effect = build(SoftColorFlanger, probe=probes.silence(2048), - color=0.99, filter_matrix=1.0, manual_ms=3.0, mix=1.0) - self.assertLess(effect.tail_samples / float(RATE), 2.0) - effect.deinit() - - def test_ceiling_is_not_a_macro(self): - kit_faults.fault_reachability( - Flanger, SoftColorFlanger, - lambda e: type(e).COLOR_CEILING, - 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) - dirty = build(SoftColorFlanger, probe=probe) - a = render(clean, 8192, label="tone") - b = render(dirty, 8192, label="tone") - declared_gap = clean.tail_samples - dirty.tail_samples - clean.deinit() - dirty.deinit() - self.assertGreater(declared_gap, 0) - self.assertGreater(float(np.max(np.abs(a.float - b.float))), 1e-4) - - def _audio_t60(self, cls=Flanger, color=0.99, listen_s=3.0, hz=440.0): - rate = RATE - burst = int(rate * 0.05) - total = burst + int(rate * listen_s) - probe = sine(hz, frames=total, dbfs=-6.0, rate=rate) - arr = np.frombuffer(probe, dtype=np.int16).copy() - arr[burst * 2:] = 0 - probe = array("h", arr.tolist()) - effect = build(cls, probe=probe, color=color, filter_matrix=1.0, - manual_ms=3.0, mix=1.0) - wet = render(effect, total, label="burst") - effect.deinit() - x = wet.float[:, 0] - win = int(rate * 0.02) - peak = 0.0 - t60 = None - for start in range(burst, len(x) - win, win // 2): - rms = float(np.sqrt(np.mean(x[start:start + win] ** 2))) - if start == burst: - peak = max(rms, 1e-12) - db = 20.0 * math.log10(max(rms, 1e-12) / peak) - if t60 is None and db <= -60.0: - t60 = (start - burst) / float(rate) - return t60 - - #: F8 restated on 2026-09-28 (a 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) - self.assertIsNotNone(t60, "never reached −60 dB") - self.assertLess(t60, 2.0, t60) - - def test_f8_audio_null_build_is_red(self): - def measure(cls): - 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 ring.""" - effect = build(probe=probes.silence(2048)) - self.assertLess(effect.tail_samples / float(RATE), 0.2) - effect.deinit() - - -class TierOne(unittest.TestCase): - def test_mix_zero_is_a_wire(self): - probe = probes.ramp_fs(8192) - frames = len(probe) // 2 - dry = probes.render( - probes.ArraySource(probe, rate=RATE, block=256), frames, - rate=RATE, block=256, probe="ramp_fs", class_name="source") - effect = build(probe=probe, mix=0.0) - wet = render(effect, frames) - effect.deinit() - result = kit.wire(wet, dry, latency_samples=0) - self.assertTrue(result["passed"], result["red"]) - - def test_click_matches_reported_latency(self): - probe = probes.click_stereo(4096) - frames = len(probe) // 2 - dry = probes.render( - probes.ArraySource(probe, rate=RATE, block=256), frames, - rate=RATE, block=256, probe="click", class_name="source") - effect = build(probe=probe, mix=0.0) - wet = render(effect, frames) - result = kit.click(wet, dry, effect.latency_samples) - effect.deinit() - self.assertTrue(result["passed"], result["red"]) - - def test_click_red_when_reported_256_short(self): - probe = probes.click_stereo(8192) - frames = len(probe) // 2 - dry = probes.render( - probes.ArraySource(probe, rate=RATE, block=256), frames, - rate=RATE, block=256, probe="click", class_name="source") - effect = build(ShortLatencyFlanger, probe=probe, mix=0.0, - through_zero=1.0, range_=1.0) - wet = render(effect, frames) - result = kit.click(wet, dry, effect.latency_samples) - effect.deinit() - self.assertFalse(result["passed"], result) - - def test_capabilities_is_tempo_sync_and_transport_is_read(self): - calls = [] - - def transport(): - calls.append(1) - return (True, 0.0, 120.0, 4, 4) - - probe = probes.silence(2048) - source = probes.ArraySource(probe, rate=RATE, block=256) - effect = Flanger.create(source, RATE, transport=transport) - self.assertEqual(effect.capabilities, ("tempo_sync",)) - effect.set_macro(0, 64) - self.assertGreaterEqual(len(calls), 1) - effect.deinit() - - def test_through_zero_defaults_off_and_is_named_in_ms(self): - text = (Flanger.__doc__ or "") + (Flanger._build.__doc__ or "") - self.assertIn("0 ms", text) - self.assertIn("10 ms", text) - effect = build(probe=probes.silence(2048)) - self.assertEqual(effect.latency_samples, 0) - effect.deinit() - - def test_library_still_serves_the_old_class(self): +class FeedbackOnly(Flanger): + """T6: feedback -Color with `invert` left off.""" + NAME = 'Flanger' + + def _refresh(self): + Flanger._refresh(self) + self._node.invert = False + + +#: A 3/4 host, for the plant whose fault only a meter other than 4 shows. +WALTZ = Transport(90.0, 3) + +KIT_PLANTS = { + "T1": HalfMix, + "T2": SquaredColor, + "T3": SixtyTail, + "T4": SquaredRange, + "T5": (FourBeatBar, 5, {"options": {"transport": WALTZ}}), + "T6-invert": (InvertOnly, 3), + "T6-feedback": (FeedbackOnly, 3), +} + + +# -------------------------------------------------------------------------- +# The trait tests (CPython: unittest) + + +#: Manual at 0.25, 1 and 5 ms, the knob's floor, a middle and its top: +#: whole frames at 48 kHz, and at 22.05 kHz 11, 44 and 110 frames (0.499, +#: 1.995 and 4.989 ms), none of them whole milliseconds. The card's 220 +#: frames (9.977 ms) is past Manual's top, so it is read where the sweep +#: reaches it (T4). +T1_TAUS = {48000: (12, 48, 240), 22050: (11, 44, 110)} + + +class T1Comb(TestCase): + """The copy is your signal exactly Manual late, added at Mix.""" + + def test_the_comb_is_the_sum_in_integers(self): + for rate in RATES: + for tau in T1_TAUS[rate]: + for mix in (0.0, 0.5, 1.0): + for channels in (1, 2): + for seed in SEEDS: + worst, _ = t1_worst(Flanger, rate, tau, mix, + channels, seed) + self.assertLessEqual( + worst, 1, (rate, tau, mix, channels, seed)) + + def test_mix_0_is_the_input_byte_for_byte(self): + for rate in RATES: + for channels in (1, 2): + x = noise(3, 4096, 30000, channels) + y = render(Flanger, x, rate, channels, options={"mix": 0.0}) + self.assertEqual(list(y), list(x)) + # Mix brought to 0 from its knob: the wire again. + y = render(Flanger, x, rate, channels, macros=((MIX, 0),)) + self.assertEqual(list(y), list(x)) + + def test_a_notch_is_40_db_down(self): + for rate in RATES: + for tau in T1_TAUS[rate]: + for k in range(3): + if (2 * k + 1) * rate / (2.0 * tau) >= 8000.0: + continue + for channels in (1, 2): + for seed in SEEDS: + level = t1_notch_db(Flanger, rate, tau, k, + 2.0 * math.pi * seed / 8.0, + channels) + self.assertLessEqual(level, -40.0, + (rate, tau, k, seed)) + + def test_the_plant_is_red(self): + worst, _ = t1_worst(HalfMix, 48000, 48, 1.0, 1, 0) + self.assertGreater(worst, 2) + worst, _ = t1_worst(HalfMix, 48000, 48, 0.0, 1, 0) + self.assertEqual(worst, 0) + + +class T2Color(TestCase): + """Color raises the peaks and fills the notches, on the two laws.""" + + def test_the_two_laws(self): + for rate in RATES: + for color in (0.0, 0.5, 0.8, 0.95): + plus, minus = color_laws(q15(color)) + for seed in SEEDS: + peak, notch = comb_pair_db(Flanger, rate, color, False, + 2.0 * math.pi * seed / 8.0) + self.assertLessEqual(abs(peak - db(plus)), 0.25, + (rate, color, seed, peak)) + if color > 0.0: + self.assertLessEqual(abs(notch - db(minus)), 0.25, + (rate, color, seed, notch)) + else: + self.assertLessEqual(notch, -40.0) + + def test_full_color_and_color_0(self): + """+26.4 dB and -6.2 dB at full Color; +6 dB on a peak at Color 0.""" + plus, minus = color_laws(q15(0.95)) + self.assertAlmostEqual(db(plus), 26.4, delta=0.06) + self.assertAlmostEqual(db(minus), -6.2, delta=0.06) + self.assertAlmostEqual(db(color_laws(0.0)[0]), 6.02, places=2) + peak, notch = comb_pair_db(Flanger, 48000, 0.95, False, 0.0) + self.assertLessEqual(abs(peak - 26.4), 0.3) + self.assertLessEqual(abs(notch + 6.2), 0.3) + + def test_the_plant_is_red(self): + for color in (0.5, 0.95): + plus, _ = color_laws(q15(color)) + peak, _ = comb_pair_db(SquaredColor, 48000, color, False, 0.0) + self.assertGreater(abs(peak - db(plus)), 1.0) + + +#: The card's 8 ms is past Manual's top; 5 ms is the longest held comb. +T3_MANUALS = (1.0, 3.0, 5.0) + + +class T3Ring(TestCase): + """Color sets how long the comb rings while the source plays silence.""" + + def test_the_ring_follows_its_laws(self): + for rate in RATES: + for manual in T3_MANUALS: + for color in (0.5, 0.8, 0.95): + for seed in SEEDS: + t60, silence, last, tail = ring_reading( + Flanger, rate, manual, color, seed) + key = (rate, manual, color, seed) + self.assertIsNotNone(t60, key) + self.assertTrue(0.5 <= t60 <= 1.1, (key, t60)) + self.assertLessEqual(silence, 1.0, (key, silence)) + self.assertLessEqual(last, tail, key) + + def test_the_swept_ring_is_inside_the_tail(self): + for rate in RATES: + for manual in (1.0, 5.0): + for color in (0.5, 0.95): + for seed in SEEDS: + _, _, last, tail = ring_reading( + Flanger, rate, manual, color, seed, depth=1.0, + lfo=4.0) + self.assertLessEqual(last, tail, + (rate, manual, color, seed)) + + def test_the_longest_rings(self): + """At most about 0.4 s at 3 ms and 0.7 s at 5 ms, full Color.""" + g = q15(0.95) + self.assertAlmostEqual(trips_60(g) * 0.003, 0.404, places=3) + self.assertAlmostEqual(trips_60(g) * 0.005, 0.673, places=3) + for manual, law in ((3.0, 0.404), (5.0, 0.673)): + for rate in RATES: + worst = max(ring_reading(Flanger, rate, manual, 0.95, + seed)[0] for seed in SEEDS) + self.assertLessEqual(worst * law, 1.1 * law) + + def test_the_source_ends_and_reset_clears_the_line(self): + ended, again = end_then_replay(Flanger, reset=True) + self.assertEqual(ended, 0) + self.assertEqual(again, 0) + # Without the reset the frozen ring comes back: the case is live. + ended, again = end_then_replay(Flanger, reset=False) + self.assertEqual(ended, 0) + self.assertGreater(again, 4000) + + def test_the_plants_are_red(self): + reds = 0 + for seed in SEEDS: + _, _, last, tail = ring_reading(SixtyTail, 48000, 3.0, 0.95, + seed) + reds += last > tail + self.assertEqual(reds, len(SEEDS)) + # The plant's reset loads patch 0 over the frozen ring, which then + # plays at patch 0's Color; the clean class plays nothing. + self.assertGreater(end_then_replay(KeepLine, reset=True)[1], 500) + + +T4_CELLS = ((1.0, 1.0, 0.5), (8.0, 0.5, 2.0), (0.25, 0.25, 1.0)) + + +class T4Sweep(TestCase): + """The sweep is a triangle in milliseconds from Manual to its top.""" + + def test_the_triangle(self): + for rate in RATES: + for lfo, depth, manual in T4_CELLS: + for seed in SEEDS: + readings = sweep_readings(Flanger, rate, lfo, depth, + manual, seed) + worst = sweep_worst(readings, lfo, rate, manual, depth) + self.assertLessEqual(worst, 0.15, + (rate, lfo, depth, manual, seed)) + + def test_the_top_at_22050_is_half_a_frame_under_10_ms(self): + self.assertAlmostEqual(220 * 1000.0 / 22050, 9.977, places=3) + readings = sweep_readings(Flanger, 22050, 1.0, 1.0, 0.5, 0) + self.assertLessEqual(sweep_worst(readings, 1.0, 22050, 0.5, 1.0), + 0.15) + # A top at 10 ms itself, 220.5 frames, misses the readings near it. + self.assertGreater(max( + abs(d - triangle_ms(n, 1.0, 22050, 0.5, 1.0, clamp=False)) + for n, d in readings), 0.3) + + def test_the_sweep_starts_at_manual_and_again_after_a_reset(self): + rate = 48000 + first = sweep_readings(Flanger, rate, 1.0, 1.0, 2.0, 0) + self.assertLessEqual(abs(first[0][1] - triangle_ms( + first[0][0], 1.0, rate, 2.0, 1.0)), 0.15) + self.assertLess(first[0][1], 2.0 * 48 + 8) + # A reset half-way through: the sweep reads from Manual again. + spacing = int(0.012 * rate) + x, at = clicks(rate, rate, 7, spacing) + feed = kc.feed(x, rate, 1) + e = Flanger(feed.port, sample_rate=rate, rate=1.0, depth=1.0, + manual_ms=2.0, color=0.0, mix=1.0) + try: + kc.render(e, 24576) + e.reset() + # Patch 0 now; hold its sweep to the card's triangle from 0. + y = kc.samples(kc.render(e, rate - 24576)) + manual = e.macro(MANUAL) + depth = e.macro(RANGE) + lfo = e.macro(RATE) + color = e.macro(COLOR) + finally: + e.deinit() + self.assertGreater(color, 0.0) + # Color is patch 0's now, so read the first click after the reset + # only: its echo arrives before any feedback does. + c = next(c for c in at if c >= 24576) - 24576 + seg = [(i, y[i]) for i in range(c + 1, c + spacing) if y[i]] + lead = [(i, v) for i, v in seg if i - c < manual * 48 + 4] + d = sum((i - c) * v for i, v in lead) / sum(v for _, v in lead) + self.assertLessEqual(abs(d - triangle_ms(c + int(d), lfo, rate, + manual, depth)), 0.15) + + def test_both_channels_sweep_together(self): + rate = 48000 + mono = noise(5, 9600, 12000) + x = array("h", bytes(4 * len(mono))) + for n in range(len(mono)): + x[2 * n] = x[2 * n + 1] = mono[n] + y = render(Flanger, x, rate, 2, options={"rate": 4.0, "depth": 1.0}) + self.assertEqual(channel(y, 2, 0), channel(y, 2, 1)) + self.assertNotEqual(channel(y, 2, 0), list(mono)) + + def test_the_plant_is_red(self): + readings = sweep_readings(SquaredRange, 48000, 8.0, 0.5, 2.0, 0) + self.assertGreater(sweep_worst(readings, 8.0, 48000, 2.0, 0.5), 50) + + +class T5Sync(TestCase): + """With Sync on, one sweep lasts Division bars at the host's tempo.""" + + def test_the_period(self): + for rate in RATES: + for bpm, beats in ((120.0, 4), (90.0, 3)): + for bars in (1.0, 0.25): + for seed in SEEDS: + err = synced_period_error(Flanger, rate, bpm, beats, + bars, seed) + self.assertLessEqual(err, 0.005, + (rate, bpm, beats, bars, seed)) + + def test_a_tempo_change(self): + for rate in RATES: + for seed in SEEDS: + self.assertLessEqual(tempo_change_error(Flanger, rate, seed), + 0.005, (rate, seed)) + + def test_rate_is_ignored_and_20_hz_is_the_stop(self): + transport = Transport(700.0, 4) + self.assertGreater(tempo_rate(700.0, 4, 0.125), 20.0) + readings = sweep_readings( + Flanger, 48000, 20.0, 0.1, 1.0, 0, frames=24000, + options={"rate": 3.0, "sync": True, "division": 0.125}, + transport=transport) + self.assertLessEqual(abs(fit_rate(readings, 48000, 1.0, 0.1, 20.0) + / 20.0 - 1.0), 0.005) + + def test_the_plant_is_red(self): + err = synced_period_error(FourBeatBar, 48000, 90.0, 3, 1.0, 0) + self.assertGreater(err, 0.005) + self.assertLessEqual( + synced_period_error(FourBeatBar, 48000, 120.0, 4, 1.0, 0), 0.005) + + +class T6Polarity(TestCase): + """Polarity minus turns the comb over.""" + + def test_the_comb_turns_over(self): + for rate in RATES: + for color in (0.0, 0.5, 0.95): + low, high = color_laws(q15(color), negative=True) + for seed in SEEDS: + at_k, at_half = comb_pair_db(Flanger, rate, color, True, + 2.0 * math.pi * seed / 8.0) + self.assertLessEqual(abs(at_half - db(high)), 0.25) + if color > 0.0: + self.assertLessEqual(abs(at_k - db(low)), 0.25, + (rate, color, seed, at_k)) + else: + self.assertLessEqual(at_k, -40.0) + + def test_color_0_subtracts_the_copy(self): + for rate in RATES: + for tau in T1_TAUS[rate]: + for seed in SEEDS[:2]: + worst, _ = t1_worst(Flanger, rate, tau, 1.0, 1, seed, + sign=-1) + self.assertLessEqual(worst, 1) + + def test_the_plants_are_red(self): + for plant in (InvertOnly, FeedbackOnly): + for color in (0.5, 0.95): + low, _ = color_laws(q15(color), negative=True) + at_k, _ = comb_pair_db(plant, 48000, color, True, 0.0) + self.assertGreater(abs(at_k - db(low)), 1.0, plant) + worst, _ = t1_worst(InvertOnly, 48000, 48, 1.0, 1, 0, sign=-1) + self.assertEqual(worst, 0) # Color 0: the byte check + worst, _ = t1_worst(FeedbackOnly, 48000, 48, 1.0, 1, 0, sign=-1) + self.assertGreater(worst, 2) + + +class TheAdoption(TestCase): + def test_create_serves_this_class_from_its_own_module(self): + import audiocore import audioeffects - self.assertEqual(audioeffects.Flanger.__module__, - "audioeffects.modulation") - self.assertIs(rebuilt_pkg.load("Flanger"), None) - self.assertIs(rebuilt_pkg.module_class("Flanger"), Flanger) - - def test_reset_then_silent_source_is_zero(self): - probe = probes.ramp_fs(2048) - effect = build(probe=probe) - render(effect, 256) - effect.reset() - silent = probes.ArraySource(probes.silence(2048), rate=RATE, block=256) - effect._source = silent - effect._wet.play(silent) - wet = render(effect, 512) - effect.deinit() - self.assertEqual(int(np.max(np.abs(wet.data))), 0) - - def test_default_surrender_is_named_in_docstrings(self): - text = ((Flanger.__doc__ or "") + (rebuilt.__doc__ or "")) - self.assertIn("What the default gives up", text) - self.assertIn("−22 dB", text) - self.assertIn("0.1 s", text) - self.assertIn("48 / 44.1 / 22.05 kHz", text) - self.assertIn("2 s ring", text) - self.assertIn("click is not that bar", text) + self.assertIs(audioeffects.Flanger, Flanger) + self.assertEqual(Flanger.__module__, "audioeffects.flanger") + served = audioeffects.create( + "Flanger", + audiocore.RawSample(array("h", [0, 0] * 64), sample_rate=48000, + channel_count=2), + 48000) + self.assertIsInstance(served, Flanger) + served.deinit() + + def test_both_old_flangers_are_gone(self): + from audioeffects import rebuilt + self.assertIsNone(rebuilt.module_class("Flanger")) + for name in ("audioeffects.modulation", + "audioeffects.rebuilt.flanger"): + with self.assertRaises(ImportError): + __import__(name) + + def test_the_tier_is_stock(self): + from audioeffects import _component + self.assertEqual(Flanger.TIER, _component.STOCK) + self.assertEqual(Flanger.REQUIRES, ()) + + +class TheSurface(TestCase): + """The controls, the wire, latency, tail, reset and tempo.""" + + def _fresh(self, sample_rate=48000, channels=1, **options): + feed = kc.feed(array("h", bytes(4 * channels)), sample_rate, + channels) + return Flanger(feed.port, sample_rate=sample_rate, **options) + + def test_the_spans(self): + e = self._fresh() + self.assertEqual(e.MACRO_LABELS, ("Rate", "Range", "Manual", "Color", + "Mix", "Polarity", "Sync", + "Division")) + ends = ((0.05, 10.0), (0.0, 1.0), (0.25, 5.0), (0.0, 0.95), + (0.0, 1.0)) + for i, (low, high) in enumerate(ends): + e.set_macro(i, 0) + self.assertAlmostEqual(e.macro(i), low, places=9) + e.set_macro(i, 127) + self.assertAlmostEqual(e.macro(i), high, places=9) + bars = [] + e.set_macro(SYNC, 127) + for step in range(128): + e.set_macro(DIVISION, step) + bars.append(flanger_module._choice(flanger_module.DIVISION_BARS, + e._macros[DIVISION])) + self.assertEqual(sorted(set(bars), reverse=True), + [4.0, 2.0, 1.0, 0.5, 1.0 / 3.0, 0.25, 0.125]) + e.deinit() + + def test_range_0_holds_the_comb_at_manual(self): + readings = sweep_readings(Flanger, 48000, 4.0, 0.0, 2.0, 0, + frames=24000) + for _, d in readings: + self.assertAlmostEqual(d, 96.0, delta=0.01) + + def test_polarity_and_sync(self): + e = self._fresh(polarity="negative", sync=True) + self.assertEqual(e.get_macro(POLARITY), 127.0) + self.assertEqual(e.get_macro(SYNC), 127.0) + self.assertTrue(e._node.invert) + self.assertLess(e._node.feedback, 0.0) + e.deinit() + with self.assertRaises(ValueError): + self._fresh(polarity="minus") + + def test_rate_is_ignored_while_sync_is_on(self): + e = self._fresh(sync=True) + before = e._node.rate + e.set_macro(RATE, 127) + self.assertEqual(e._node.rate, before) + self.assertAlmostEqual(before, tempo_rate(120.0, 4, 1.0)) + e.deinit() + + def test_a_fresh_instance_is_patch_0(self): + e = self._fresh() + self.assertEqual(e.patch_index, 0) + self.assertEqual(tuple(e.get_macro(i) for i in range(8)), + tuple(float(v) for v in Flanger.PATCHES[0][1])) + e.deinit() + + def test_constructor_values_play_exactly_until_their_knob_moves(self): + e = self._fresh(rate=0.3, depth=0.33, manual_ms=1.7, color=0.41, + mix=0.77) + node = e._node + self.assertEqual((node.rate, node.depth, node.min_delay_ms, + node.feedback, node.mix), + (0.3, 0.33, 1.7, 0.41, 0.77)) + # A host echoing the knob back moves nothing. + for i in range(5): + e.set_macro(i, e.get_macro(i)) + self.assertEqual((node.rate, node.depth, node.min_delay_ms, + node.feedback, node.mix), + (0.3, 0.33, 1.7, 0.41, 0.77)) + e.set_macro(COLOR, e.get_macro(COLOR) + 1) + self.assertNotEqual(node.feedback, 0.41) + self.assertEqual(node.mix, 0.77) + e.program_change(4) + self.assertNotEqual(node.mix, 0.77) + e.deinit() + + def test_mix_s_first_step_is_a_wire_and_its_second_36_db_down(self): + rate = 48000 + x = noise(9, 9600, 12000) + options = held(48, rate) + y = render(Flanger, x, rate, 1, options=options, macros=((MIX, 1),)) + self.assertEqual(list(y), list(x)) + y = render(Flanger, x, rate, 1, options=options, macros=((MIX, 2),)) + wet = [y[n] - x[n] for n in range(48, len(x))] + level = db(math.sqrt(sum(v * v for v in wet) / len(wet)) + / math.sqrt(sum(v * v for v in x[:-48]) / (len(x) - 48))) + self.assertAlmostEqual(level, db(2.0 / 127.0), delta=0.2) + self.assertAlmostEqual(level, -36.0, delta=0.5) + + def test_the_linear_range(self): + """About -28 dBFS at full Color, -7.5 dBFS at Color 0, on a peak; + the knee above.""" + rate, tau = 48000, 144 + f = rate / float(tau) + for color, linear, hot in ((0.95, -28.0, -24.0), (0.0, -7.5, -3.0)): + law = db(color_laws(q15(color))[0]) + got = [] + for level in (linear, hot): + amp = int(32767 * 10.0 ** (level / 20.0)) + x = sine(f, rate, 28800, amp) + y = render(Flanger, x, rate, 1, + options=held(tau, rate, color=color)) + got.append(db(amp_at(y, f, rate, 14400) / amp)) + self.assertLessEqual(abs(got[0] - law), 0.1, (color, got)) + self.assertLess(got[1], law - 0.5, (color, got)) + + def test_no_wrap_at_the_input_ceiling(self): + """The kit's ceiling check at -6 dBFS, mono, both rates: no wrap, + no rail, no silence at any patch or knob stop.""" + import kit_ceiling + self.assertEqual(Flanger.INPUT_CEILING_DBFS, -6.0) + lines = [] + red = kit_ceiling.run(Flanger, emit=lines.append, channels=(1,)) + self.assertEqual(red, 0, [l for l in lines if "RED" in l][:3]) + + def test_no_latency(self): + self.assertEqual(Flanger.LATENCY_SAMPLES, 0) + e = self._fresh() + self.assertEqual(e.latency_samples, 0) + e.deinit() + + def test_the_tail_is_the_card_s_bound(self): + self.assertEqual(card_tail(0.95, 10.0, 48000), 98607) + self.assertEqual(Flanger.TAIL_SAMPLES, 98607) + self.assertEqual(card_tail(0.95, 5.5, 48000), 54327) + for rate in RATES: + for options in ({}, {"color": 0.0}, {"color": 0.95, "depth": 1.0}, + {"color": 0.5, "depth": 0.0, "manual_ms": 3.0}): + e = self._fresh(sample_rate=rate, **options) + top = e.macro(MANUAL) + e.macro(RANGE) * (10.0 - + e.macro(MANUAL)) + self.assertEqual(e.tail_samples, + card_tail(e.macro(COLOR), top, rate)) + self.assertLessEqual(e.tail_samples, Flanger.TAIL_SAMPLES) + e.deinit() + + def test_reset_returns_to_patch_0(self): + e = self._fresh() + e.set_macro(COLOR, 0) + e.program_change(3) + e.reset() + self.assertEqual(e.patch_index, 0) + self.assertEqual(tuple(e.get_macro(i) for i in range(8)), + tuple(float(v) for v in Flanger.PATCHES[0][1])) + e.deinit() + + def test_the_circuitpython_hang_is_a_stated_skip(self): + import lifecycle + self.assertIn(("Flanger", "E8-", "circuitpython"), lifecycle.SKIP) + + +# -------------------------------------------------------------------------- +# The figures, on any interpreter + + +def native_report(emit=print): + """One line per trait reading, the same on every interpreter whose node + renders the same bytes (a reduced grid: the unit tests run the whole + one on CPython).""" + impl = sys.implementation.name + for rate in RATES: + for tau in T1_TAUS[rate]: + for mix in (0.5, 1.0): + worst = 0 + for seed in (0, 1): + worst = max(worst, t1_worst(Flanger, rate, tau, mix, 1, + seed)[0]) + emit("%s|T1|%d|%d|%.1f|%d" % (impl, rate, tau, mix, worst)) + for color in (0.0, 0.95): + for negative in (False, True): + a, b = comb_pair_db(Flanger, rate, color, negative, 0.0) + emit("%s|T2T6|%d|%.2f|%d|%.3f|%.3f" + % (impl, rate, color, negative, a, b)) + t60, sil, last, tail = ring_reading(Flanger, rate, 3.0, 0.95, 0) + emit("%s|T3|%d|%.3f|%.3f|%d|%d" % (impl, rate, t60, sil, last, tail)) + readings = sweep_readings(Flanger, rate, 8.0, 0.5, 2.0, 0) + emit("%s|T4|%d|%.4f" % (impl, rate, sweep_worst(readings, 8.0, rate, + 2.0, 0.5))) + emit("%s|T5|%d|%.5f" % (impl, rate, synced_period_error( + Flanger, rate, 90.0, 3, 1.0, 0))) + emit("%s|T3-end|%d|%d" % ((impl,) + end_then_replay(Flanger, True))) + emit("DONE") + + +#: Each claim sentence of the docstring, word for word, and its tests. +CLAIMS = ( + ("Rate runs the sweep from 0.05 to 10 Hz.", + ("TheSurface.test_the_spans", "T4Sweep.test_the_triangle")), + ("Manual, from 0.25 to 5 ms, is the sweep's floor, and Range is how far " + "it climbs from there towards 10 ms: at Range 0 the comb holds still at " + "Manual.", + ("TheSurface.test_the_spans", "T4Sweep.test_the_triangle", + "TheSurface.test_range_0_holds_the_comb_at_manual")), + ("Color, from 0 to 0.95, feeds the copy back into the line and raises " + "the comb's peaks into a ring.", + ("TheSurface.test_the_spans", "T2Color.test_the_two_laws", + "T3Ring.test_the_ring_follows_its_laws")), + ("Mix is the copy's level against a dry that never moves: 1 is equal " + "weight and the deepest notches, 0 is a wire.", + ("TheSurface.test_the_spans", "T1Comb.test_the_comb_is_the_sum_in_" + "integers", "T1Comb.test_a_notch_is_40_db_down", + "T1Comb.test_mix_0_is_the_input_byte_for_byte")), + ("With Sync on, one sweep lasts Division bars (4 bars down to an " + "eighth) at the host's tempo and meter, and Rate is ignored.", + ("T5Sync.test_the_period", "T5Sync.test_a_tempo_change", + "TheSurface.test_the_spans", + "TheSurface.test_rate_is_ignored_while_sync_is_on")), + ("Polarity minus turns the comb over and puts a notch at DC.", + ("T6Polarity.test_the_comb_turns_over", + "T6Polarity.test_color_0_subtracts_the_copy")), + ("Constructor values play exactly until their knob first moves or a " + "patch loads.", + "TheSurface.test_constructor_values_play_exactly_until_their_knob_" + "moves"), + ("The copy is your signal exactly Manual late, added at Mix.", + "T1Comb.test_the_comb_is_the_sum_in_integers"), + ("At Color 0 and Mix 1 a tone on a notch comes out at least 40 dB " + "down.", + "T1Comb.test_a_notch_is_40_db_down"), + ("Color raises the peaks by up to 26.4 dB over the input and fills the " + "notches to 6 dB under it.", + ("T2Color.test_the_two_laws", "T2Color.test_full_color_and_color_0")), + ("The sweep is a triangle in milliseconds that starts at Manual and " + "reaches its top once a sweep.", + ("T4Sweep.test_the_triangle", + "T4Sweep.test_the_sweep_starts_at_manual_and_again_after_a_reset")), + ("The ring lasts at most about 0.4 s to -60 dB at 3 ms and full Color, " + "and about 0.7 s at 5 ms; it never oscillates on its own.", + ("T3Ring.test_the_longest_rings", + "T3Ring.test_the_ring_follows_its_laws")), + ("It is linear only to about -28 dBFS on a resonant frequency at full " + "Color, and -7.5 dBFS at Color 0: above that the output is squashed " + "and gritty, not louder.", + "TheSurface.test_the_linear_range"), + ("Turning Mix up makes the output louder, by 6 dB on a comb peak at Mix " + "1: there is no wet-only setting.", + ("T2Color.test_full_color_and_color_0", "TheSurface.test_the_spans")), + ("There is no tone control, no through-zero and no stereo spread: both " + "channels sweep together.", + "T4Sweep.test_both_channels_sweep_together"), + ("Sync sets the sweep's period, not its phase: the sweep starts at " + "Manual when the class is built or reset, not on a bar line, and a " + "synced sweep faster than 20 Hz runs at 20 Hz.", + ("T4Sweep.test_the_sweep_starts_at_manual_and_again_after_a_reset", + "T5Sync.test_rate_is_ignored_and_20_hz_is_the_stop")), + ("A control that jumps makes the output step: move it in small steps " + "from the host if you need it smooth.", + "DECLARED:Flanger,E4-,P5"), + ("At 22.05 kHz the sweep's top is 9.977 ms, half a frame under 10 ms.", + "T4Sweep.test_the_top_at_22050_is_half_a_frame_under_10_ms"), + ("Mix's first step above 0 is still a wire; the copy comes in at the " + "second step, about 36 dB down.", + "TheSurface.test_mix_s_first_step_is_a_wire_and_its_second_36_db_down"), + ("Keep the input under -6 dBFS.", + "TheSurface.test_no_wrap_at_the_input_ceiling"), + ("At Mix 0 the output is the input, byte for byte.", + "T1Comb.test_mix_0_is_the_input_byte_for_byte"), + ("There is no latency.", "TheSurface.test_no_latency"), + ("`tail_samples` is the longest the ring can last at the controls in " + "force, while the source keeps playing, silence included.", + ("TheSurface.test_the_tail_is_the_card_s_bound", + "T3Ring.test_the_ring_follows_its_laws", + "T3Ring.test_the_swept_ring_is_inside_the_tail")), + ("If the source ends, the output stops dead; `reset()` clears what the " + "line held and returns to patch 0.", + ("T3Ring.test_the_source_ends_and_reset_clears_the_line", + "TheSurface.test_reset_returns_to_patch_0")), + ("On a CircuitPython board, stop the flanger from the host if your " + "source can run dry: CircuitPython's own flanger waits on a source " + "that comes back empty.", + "TheSurface.test_the_circuitpython_hang_is_a_stated_skip"), +) + + +if __name__ == "__main__": + if "--report" in sys.argv: + native_report() + elif unittest is not None: + unittest.main() diff --git a/tests/test_rebuilt_registry.py b/tests/test_rebuilt_registry.py index 2d50394..1eed2b7 100644 --- a/tests/test_rebuilt_registry.py +++ b/tests/test_rebuilt_registry.py @@ -45,7 +45,8 @@ FIXTURES = ("ExampleAudiodsp", "ExampleStock") #: Every class of the 46 that has come home: Phase 2's sixteen, Phase 3's -#: five THROUGH classes plus RingMod, and Phase 4's four adopted. Coming +#: five THROUGH classes plus RingMod, Phase 4's adopted, and Phase 7's +#: Flanger. Coming #: home is what puts a name here, so `rebuilt.module_class` misses each one #: and the package imports it from its own file. `TheLookup.REBUILT` is this #: same tuple. @@ -55,7 +56,7 @@ "LadderFilter", "CombFilter", "DynamicEQ", "AutoPan", "Chorus", "Phaser", "Tremolo", "Vibrato", "RingMod", "Overdrive", "Distortion", "Fuzz", "Saturation", "Bitcrusher", - "Exciter", "CabinetSim") + "Exciter", "CabinetSim", "Flanger") def source(channels=2, rate=48000): @@ -562,7 +563,7 @@ def test_every_rebuilt_name_resolves_to_a_component(self): def test_known_lists_what_is_there__lf(self): listed = set(rebuilt.known()) self.assertTrue(set(FIXTURES) <= listed) - self.assertIn("Flanger", listed) + self.assertNotIn("Flanger", listed) for name in listed: if name not in FIXTURES: self.assertIn(name, audioeffects.ALL) @@ -813,7 +814,7 @@ def test_the_fixtures_are_not_among_the_46__lf(self): self.assertIn(name, audioeffects.__all__) listed = set(rebuilt.known()) self.assertTrue(set(FIXTURES) <= listed) - self.assertIn("Flanger", listed) + self.assertNotIn("Flanger", listed) for name in listed: if name not in FIXTURES: self.assertIn(name, audioeffects.ALL) @@ -884,7 +885,7 @@ def test_phase2_names_have_left_adopted(self): self.assertEqual(parked, known - set(rebuilt.ADOPTED)) self.assertTrue(parked - set(FIXTURES) <= set(audioeffects.ALL)) self.assertTrue(known - set(FIXTURES) <= set(audioeffects.ALL)) - self.assertIn("Flanger", parked) + self.assertNotIn("Flanger", parked) self.assertNotIn("RingMod", parked) self.assertNotIn("AutoPan", parked) self.assertNotIn("Chorus", parked) diff --git a/tools/measure_effect_cost.py b/tools/measure_effect_cost.py index 5fe2e68..5e87dfe 100644 --- a/tools/measure_effect_cost.py +++ b/tools/measure_effect_cost.py @@ -385,6 +385,14 @@ def _chorus(probe): return node, (), (node,) +def _flanger(probe): + import audiodelays + node = audiodelays.Flanger(max_delay_ms=10, min_delay_ms=1.0, rate=0.25, + depth=0.6, feedback=0.7, mix=1.0, **pcm()) + node.play(probe.output) + return node, (), (node,) + + def _echo(probe): import audiodelays node = audiodelays.Echo(max_delay_ms=300, delay_ms=180.0, decay=0.45, @@ -908,6 +916,7 @@ def _dynamics_transient(probe): "audiofilters.Distortion": _distortion, "audiodelays.Chorus": _chorus, "audiodelays.Echo": _echo, + "audiodelays.Flanger": _flanger, "audiodelays.MultiTapDelay": _multitap, "audiodelays.PitchShift": _pitch_shift, "audiofreeverb.Freeverb": _freeverb, @@ -1094,7 +1103,7 @@ def build(probe): BESIDE = { "DigitalDelay": "audioecho.FeedbackDelay@options", "SlapbackDelay": "audioecho.FeedbackDelay@options", - "Flanger": "audioecho.FeedbackDelay@options", + "Flanger": "audiodelays.Flanger", } From 684cbd30fb4418ea41abf36cbc1b9964ec3b4dd5 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:41:56 -0500 Subject: [PATCH 008/117] Kit: nogb takes patches=, so a slow class runs a patch at a time --- tests/support/kit_nogb.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/support/kit_nogb.py b/tests/support/kit_nogb.py index 7d567fb..6b6dd0c 100644 --- a/tests/support/kit_nogb.py +++ b/tests/support/kit_nogb.py @@ -198,12 +198,17 @@ def _raised(exc): return "%s: %s" % (type(exc).__name__, exc) -def run(cls, emit=print, quick=False, name=None, options=None): - """Every nogb cell for `cls`; returns the red count.""" +def run(cls, emit=print, quick=False, name=None, options=None, + patches=None): + """Every nogb cell for `cls`; returns the red count. `patches` limits + the run to some of `[None] + sorted(cls.PATCHES)` (None the defaults), + so a slow class can be run a patch at a time.""" cells = kc.Cells(name or cls.NAME, CHECK, emit) present = {} absent = {} - for patch in [None] + sorted(cls.PATCHES): + if patches is None: + patches = [None] + sorted(cls.PATCHES) + for patch in patches: for ch in (2, 1): # One lifecycle run per patch and layout, each in its own # cell, so a class that raises without get_buffer at one patch From 66da35ac2fd9c783936e4ecc35520b4c252db367 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:41:56 -0500 Subject: [PATCH 009/117] Vibrato: its CPython re-blocking sources pull the graph in-graph, not through get_buffer _RampTap and _SplitOut exist only on the CPython twin and pulled their upstream with the script-facing audiocore.get_buffer on every pull, so the class raised wherever that is hidden. They now pull with the twin's in-graph _borrow and copy what they keep: the same bytes. Red then green in TheGraphPullsWithoutGetBuffer and the kit's nogb (CPython; the native graph never had the path). --- lib/audioeffects/vibrato.py | 22 ++++++++++++++++------ tests/test_cpython_effects_vibrato.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/audioeffects/vibrato.py b/lib/audioeffects/vibrato.py index 42edd4d..73ecd27 100644 --- a/lib/audioeffects/vibrato.py +++ b/lib/audioeffects/vibrato.py @@ -237,6 +237,18 @@ def _patch(rate, depth_cents, rise, engage, delay_ms, tone_hz, body_hz, ) +def _pull(sample, single_channel_output, audio_channel): + """One block of `sample`, the way one node pulls another. + + The two sources below exist only on the CPython twin (a native + `FeedbackDelay` will not play a Python source), and the twin's nodes + pull each other with `audiocore._borrow`. `audiocore.get_buffer` is the + script-facing entry, and CircuitPython's board builds compile it out. + The caller copies what it keeps. + """ + return audiocore._borrow(sample, single_channel_output, audio_channel) + + class _RampTap: """Re-blocking source in front of the delay. Advances the Engage envelope once per 256-frame native block, then forwards at most that @@ -267,9 +279,8 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): while len(self._pending) < need: if self._source is None: break - result, data = audiocore.get_buffer(self._source, - single_channel_output, - audio_channel) + result, data = _pull(self._source, single_channel_output, + audio_channel) chunk = bytes(data) if not chunk: break @@ -304,9 +315,8 @@ def _reset_buffer(self, single_channel_output=False, audio_channel=0): def _get_buffer(self, single_channel_output=False, audio_channel=0): need = SPLIT_FRAMES * self.channel_count * 2 while len(self._pending) < need: - result, data = audiocore.get_buffer(self._delay, - single_channel_output, - audio_channel) + result, data = _pull(self._delay, single_channel_output, + audio_channel) chunk = bytes(data) if not chunk: break diff --git a/tests/test_cpython_effects_vibrato.py b/tests/test_cpython_effects_vibrato.py index 81f6fcd..2ac0ed9 100644 --- a/tests/test_cpython_effects_vibrato.py +++ b/tests/test_cpython_effects_vibrato.py @@ -771,3 +771,20 @@ def test_a_bound_without_the_filters_is_red(self): got, bound = tail_ring.ring("Vibrato", rate, 2, start=((6, 0),), cls=OldTail) self.assertGreater(got, bound, rate) + + +class TheGraphPullsWithoutGetBuffer(unittest.TestCase): + """Vibrato's own re-blocking sources pull the graph the way the + twin's nodes do, not through the script-facing `audiocore.get_buffer` + that CircuitPython's board builds compile out: with it hidden, every + patch renders the bytes it renders with it, through a reset.""" + + def test_every_patch_renders_the_same(self): + import kit_nogb + scenarios = dict(kit_nogb.SCENARIOS) + for patch in [None] + sorted(rebuilt.Vibrato.PATCHES): + for label in ("reset-once", "reset-first"): + with self.subTest(patch=patch, scenario=label): + self.assertEqual(kit_nogb._bytes_cell( + rebuilt.Vibrato, {}, RATE, 2, patch, + scenarios[label]), "ok") From e7cb533bfc63b66debcc925fa21116da13b19baf Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:41:56 -0500 Subject: [PATCH 010/117] DeEsser: reset builds its Splitters again instead of pulling them empty reset() pushed each of the eight taps empty with audiocore.get_buffer, which raises where CircuitPython's board builds compile it out. A Splitter's ring cannot be cleared, so the reset now builds the three Splitters again on the same nodes and releases the old ones, on every interpreter: renders with and without get_buffer are byte-identical. The reset's own bytes move (it was not a fresh instance before or after; see the 7d report). Red then green in TheResetNeedsNoGetBuffer and the kit's nogb on three interpreters. --- lib/audioeffects/deesser.py | 56 ++++++++++++++++++++------- tests/test_cpython_effects_deesser.py | 16 ++++++++ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py index 7f623af..79e0472 100644 --- a/lib/audioeffects/deesser.py +++ b/lib/audioeffects/deesser.py @@ -142,12 +142,6 @@ #: thing in both paths. _DETECTOR_CORNER = 1.0 / 1.5537739740300374 -#: What a reset has to push through a Splitter's ring. The ring is 8192 -#: frames (`audiodsp/src/shared/audiodsp_splitter.h:20`) and one take is at -#: most 256 (`audiodsp_splitter.c:64-70`), so this many pulls a tap empties -#: any backlog it can be holding. -_FLUSH_PULLS = 8192 // 256 + 1 - #: Detector averaging. The manual gives none ("dbx patented RMS level #: detectors" and no time constant), so this is a design choice, and it was #: measured rather than picked: the RMS filter sits *before* the attack @@ -345,8 +339,9 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0, # the reason is worth stating rather than discovering: a # `Splitter`'s ring cannot be cleared - the type exposes `tap()` and # nothing else on every target, and `SplitterTap.reset_buffer` is - # "deliberately nothing" - so the rings have to be pushed empty - # against a silent source, and `audiomixer.Mixer._reset_buffer` + # "deliberately nothing" - so a reset builds the Splitters again + # (`_renew_splitters`) with a silent source on, and + # `audiomixer.Mixer._reset_buffer` # refetches a chunk into every voice, so both mixers have to be # cleared *while* that silent source is still on. One ordered # routine can express that and a per-node walk cannot, so the tail @@ -370,8 +365,8 @@ def _build(self, frequency=2500.0, range_db=12.0, sensitivity_db=30.0, # too, and they belong on the list for the reason the paragraph # above gives: `deinit()` walks it, and a node missing from it is a # node nothing releases. Both decline their own reset - a tap's - # `reset_buffer` is deliberately nothing and `_reset_chain()` empties - # the rings by pulling them, and the filler holds no state to clear. + # `reset_buffer` is deliberately nothing and `_reset_chain()` builds + # them again, and the filler holds no state to clear. # `Splitter.tap(n)` returns the same object each call, so these are # the same eight taps `self._voices` plays. for tap in self._taps: @@ -475,17 +470,15 @@ def _reset_chain(self): `audiomixer.Mixer._reset_buffer` refetches a chunk into every voice and that fetch pulled live audio through. - So: the silent sample goes on first, every tap is pulled until its - backlog is gone, every clearable node is cleared while the silence + So: the silent sample goes on first, the Splitters are built again + empty (`_renew_splitters`), every clearable node is cleared while the silence is still there - the mixers' refetch then fetches silence - and the borrowed source goes back on last. The source itself is never reset and never rewound; `audioroute.MidSide.play()` sets its source and drops its own pending bytes, and does nothing else. """ self._adapter.play(self._silence) - for tap in self._taps: - for _ in range(_FLUSH_PULLS): - audiocore.get_buffer(tap) + self._renew_splitters() for node in (self._lows[0], self._lows[1], self._highs[0], self._highs[1], self._duck): audiocore.reset_buffer(node) @@ -505,6 +498,39 @@ def _reset_chain(self): audiocore.reset_buffer(self._adapter) audiocore.reset_buffer(self._tail) + def _renew_splitters(self): + """Empty the three Splitter rings: build them again, wired to the + same nodes, and release the old ones. + + A ring cannot be cleared, and pushing it empty takes + `audiocore.get_buffer`, which CircuitPython's default board builds + compile out. A new Splitter holds nothing, on every interpreter. + """ + old = self._splitters + self._taps + top = audioroute.Splitter(self._adapter, taps=4) + raw = audioroute.Splitter(top.tap(3), taps=2) + lows, highs, duck = self._lows, self._highs, self._duck + lows[0].play(top.tap(1)) + highs[0].play(top.tap(2)) + band = audioroute.Splitter(highs[1], taps=2) + duck.key(top.tap(0)) + pre, out = self._pre, self._out + self._voices = ((pre, 0, raw.tap(0)), (pre, 1, band.tap(0)), + (out, 0, raw.tap(1)), (out, 1, lows[1]), + (out, 2, band.tap(1)), (out, 3, duck)) + self._taps = (top.tap(0), top.tap(1), top.tap(2), top.tap(3), + raw.tap(0), raw.tap(1), band.tap(0), band.tap(1)) + self._splitters = (top, raw, band) + new = self._splitters + self._taps + for position, node in enumerate(self._nodes): + for was, now in zip(old, new): + if node is was: + self._nodes[position] = now + for node in reversed(old): + release = getattr(node, "deinit", None) + if release is not None: + release() + # -- macros -------------------------------------------------------- def _apply_macro(self, index, position): diff --git a/tests/test_cpython_effects_deesser.py b/tests/test_cpython_effects_deesser.py index ff92e86..a21029d 100644 --- a/tests/test_cpython_effects_deesser.py +++ b/tests/test_cpython_effects_deesser.py @@ -1659,3 +1659,19 @@ def builder(cls): if __name__ == "__main__": # pragma: no cover unittest.main() + + +class TheResetNeedsNoGetBuffer(unittest.TestCase): + """`reset()` empties the Splitter rings without `audiocore.get_buffer`, + which CircuitPython's board builds compile out: with it hidden, a + reset at every patch renders the bytes it renders with it.""" + + def test_every_patch_resets_the_same(self): + import kit_nogb + scenarios = dict(kit_nogb.SCENARIOS) + for patch in [None] + sorted(deesser.DeEsser.PATCHES): + for label in ("reset-once", "reset-twice", "reset-first"): + with self.subTest(patch=patch, scenario=label): + self.assertEqual(kit_nogb._bytes_cell( + deesser.DeEsser, {}, 48000, 2, patch, + scenarios[label]), "ok") From ba888e0e0bac17d0b4ed0de3e8f670520fb4c9e6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:41:56 -0500 Subject: [PATCH 011/117] Distortion: the scoop patches build without get_buffer _charge_output_cap pulled the output capacitor with audiocore.get_buffer unconditionally at patches 6 to 9. Where it is absent the charge is now skipped, as Fuzz and Overdrive skip theirs; the scoop tables answer zero with zero, so nothing differs: every patch renders the same bytes with and without it. Red then green in TheScoopBuildsWithoutGetBuffer and the kit's nogb on three interpreters. --- lib/audioeffects/distortion.py | 9 +++++-- tests/test_cpython_effects_distortion.py | 34 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 75c2019..a3ec38d 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -678,8 +678,13 @@ def _charge_output_cap(self): at its own silence buffer and the chain downstream of it is pulled until the pole has settled, which happens before anything reads the Splitter, so no tap is starved and the real source is not touched. + + On a build with no `audiocore.get_buffer` - CircuitPython's default + board build compiles it out - nothing can pull the pole, so this + does nothing and the scoop patches start with the step. """ - if self._out_hp is None or not self._macros: + pull = getattr(audiocore, "get_buffer", None) + if pull is None or self._out_hp is None or not self._macros: return # Ten time constants of the pole, in whole blocks. The silence # buffer is one block long, so it is re-played each time round. @@ -687,7 +692,7 @@ def _charge_output_cap(self): / (2.0 * math.pi * OUT_HP_HZ * 256.0)) + 1 for _ in range(blocks): self._shaper.play(self._silence) - if not audiocore.get_buffer(self._out_hp)[1]: + if not pull(self._out_hp)[1]: break self._shaper.play(self._booster) diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index 5ce3dc7..6c5f9f4 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -1609,3 +1609,37 @@ def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): if __name__ == "__main__": unittest.main() + + +class TheScoopBuildsWithoutGetBuffer(unittest.TestCase): + """Where `audiocore.get_buffer` is compiled out (CircuitPython's board + builds) the scoop patches build, take a patch change and reset without + raising, and the patches with no output capacitor to charge render the + bytes they render with it.""" + + def test_every_patch_builds_and_resets(self): + import kit_nogb + scenarios = dict(kit_nogb.SCENARIOS) + for patch in [None] + sorted(rebuilt.Distortion.PATCHES): + for label in ("reset-once", "patch-reset"): + with self.subTest(patch=patch, scenario=label): + got = kit_nogb._bytes_cell( + rebuilt.Distortion, {}, RATE, 2, patch, + scenarios[label]) + self.assertNotIn("raised", got) + if patch is not None and patch < 6: + self.assertEqual(got, "ok") + + def test_a_program_change_onto_a_scoop_patch(self): + import kit_nogb + source = audiocore.RawSample(array.array("h", bytes(2 * 2 * 4096)), + sample_rate=RATE, channel_count=2) + with kit_nogb.hidden(rebuilt.Distortion): + effect = rebuilt.Distortion(source) + try: + for patch in (6, 7, 8, 9, 0): + effect.program_change(patch) + audiocore.get_buffer(effect.output) + effect.reset() + finally: + effect.deinit() From 7d8df156f886e5b3ed7f7ee674afcb48438ee858 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:42:15 -0500 Subject: [PATCH 012/117] Distortion: say why the skipped charge changes nothing, and hold every patch to it --- lib/audioeffects/distortion.py | 3 ++- tests/test_cpython_effects_distortion.py | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index a3ec38d..a2cc0c7 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -681,7 +681,8 @@ def _charge_output_cap(self): On a build with no `audiocore.get_buffer` - CircuitPython's default board build compiles it out - nothing can pull the pole, so this - does nothing and the scoop patches start with the step. + does nothing. The scoop tables answer zero with zero, so there is + no offset to settle and nothing renders differently. """ pull = getattr(audiocore, "get_buffer", None) if pull is None or self._out_hp is None or not self._macros: diff --git a/tests/test_cpython_effects_distortion.py b/tests/test_cpython_effects_distortion.py index 6c5f9f4..eddb3b5 100644 --- a/tests/test_cpython_effects_distortion.py +++ b/tests/test_cpython_effects_distortion.py @@ -1614,8 +1614,9 @@ def test_the_old_rejoin_and_a_clear_without_rearming_are_red(self): class TheScoopBuildsWithoutGetBuffer(unittest.TestCase): """Where `audiocore.get_buffer` is compiled out (CircuitPython's board builds) the scoop patches build, take a patch change and reset without - raising, and the patches with no output capacitor to charge render the - bytes they render with it.""" + raising, and every patch renders the bytes it renders with it: the + scoop tables answer zero with zero, so the output capacitor has + nothing to settle.""" def test_every_patch_builds_and_resets(self): import kit_nogb @@ -1626,9 +1627,7 @@ def test_every_patch_builds_and_resets(self): got = kit_nogb._bytes_cell( rebuilt.Distortion, {}, RATE, 2, patch, scenarios[label]) - self.assertNotIn("raised", got) - if patch is not None and patch < 6: - self.assertEqual(got, "ok") + self.assertEqual(got, "ok") def test_a_program_change_onto_a_scoop_patch(self): import kit_nogb From afdc2eee5334f5efb9c67d8330161ddc6557fd85 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:05:19 -0500 Subject: [PATCH 013/117] Flanger: follow the host's meter as well as its tempo; the attacker's tests transport_changed() now sets the sweep again when the meter moves at one tempo; the base followed the bpm alone. New cells hold Polarity from its knob and patch 3, Sync from its knob and patch 5, a 4/4 to 3/4 change, and a rack's restore of the exact constructor values, each beside a plant the old tests let through. The ring sentence no longer reads as a ceiling. --- lib/audioeffects/flanger.py | 21 +++- tests/test_cpython_effects_flanger.py | 160 +++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/lib/audioeffects/flanger.py b/lib/audioeffects/flanger.py index a17814a..af95e37 100644 --- a/lib/audioeffects/flanger.py +++ b/lib/audioeffects/flanger.py @@ -123,8 +123,9 @@ class Flanger(_component.Component): notches to 6 dB under it. The sweep is a triangle in milliseconds that starts at Manual and reaches its top once a sweep. - The ring lasts at most about 0.4 s to -60 dB at 3 ms and full Color, - and about 0.7 s at 5 ms; it never oscillates on its own. + The ring lasts about 0.4 s to -60 dB at 3 ms and full Color, and + about 0.7 s at 5 ms; it grows as Range sweeps the delay longer, and it + never oscillates on its own. **Limits.** It is linear only to about -28 dBFS on a resonant frequency at full Color, and -7.5 dBFS at Color 0: above that the output is @@ -326,6 +327,22 @@ def _refresh(self): node.invert = negative node.mix = self._value(MIX_I) + def transport_changed(self): + """With Sync on, follow the host's tempo and its meter: a move of + either sets the sweep's rate again. The base follows the tempo + alone, so a 4/4 to 3/4 change at one tempo would keep the old + rate.""" + self._check_live() + if self._macros[SYNC_I] < 0.5: + return + state = self._transport() if callable(self._transport) \ + else self._transport + followed = (state[2], state[4]) + if followed == self._followed_bpm: + return + self._followed_bpm = followed + self._refresh() + def macro(self, index): """What macro `index` currently stands for, in its own units; for the first five, the value that plays.""" diff --git a/tests/test_cpython_effects_flanger.py b/tests/test_cpython_effects_flanger.py index 1f60782..63fdd44 100644 --- a/tests/test_cpython_effects_flanger.py +++ b/tests/test_cpython_effects_flanger.py @@ -454,6 +454,66 @@ def move(e): return abs(got / second - 1.0) +def meter_change_error(cls, rate, seed): + """4/4 to 3/4 at 120 bpm, one bar, `transport_changed()` between two + pulls: the next full period against the new bar.""" + transport = Transport(120.0, 4) + first = tempo_rate(120.0, 4, 1.0) + second = tempo_rate(120.0, 3, 1.0) + manual, depth = 1.0, 0.6 + change = 120 + boundary = change * BLOCK + + def move(e): + transport.beats_per_bar = 3 + e.transport_changed() + frames = boundary + int(rate / second * 1.05) + int(0.012 * rate) + # A host calls `transport_changed()` once a block: the first call has + # read 120 bpm in 4/4 long before the meter moves. + readings = sweep_readings( + cls, rate, first, depth, manual, seed, frames=frames, + options={"rate": 0.25, "sync": True, "division": 1.0}, + transport=transport, + actions={1: lambda e: e.transport_changed(), change: move}) + inc1 = int(min(first / rate, 0.5) * 4294967296.0) + phase = (boundary * inc1) & 0xFFFFFFFF + after = [(n, d) for n, d in readings if n >= boundary + 300] + got = fit_rate(after, rate, manual, depth, second, start_phase=phase, + start_frame=boundary - 1) + return abs(got / second - 1.0) + + +def knob_pair_db(cls, rate, macros, patch=None): + """The comb's level against the input, dB, at its first peak k/tau + (a dip once Polarity is minus), Range 0, with the polarity set by + `macros` or `patch` after construction.""" + feed = kc.feed(array("h", [0, 0]), rate, 1) + e = cls(feed.port, sample_rate=rate, **held(144, rate, color=0.5)) + if patch is not None: + e.program_change(patch) + e.set_macro(RANGE, 0) + for index, value in macros: + e.set_macro(index, value) + delay = e.macro(MANUAL) * rate / 1000.0 + e.deinit() + frames = int(rate * 0.6) + f = rate / delay + x = sine(f, rate, frames, 1000) + feed = kc.feed(x, rate, 1) + e = cls(feed.port, sample_rate=rate, **held(144, rate, color=0.5)) + try: + if patch is not None: + e.program_change(patch) + e.set_macro(RANGE, 0) + for index, value in macros: + e.set_macro(index, value) + y = kc.samples(kc.render(e, frames)) + color = e.macro(COLOR) + finally: + e.deinit() + return db(amp_at(y, f, rate, frames // 2) / 1000.0), color + + # -------------------------------------------------------------------------- # The planted faults @@ -523,6 +583,41 @@ def _rate_hz(self): return max(1.0, float(state[2])) / (60.0 * 4.0 * bars) +class MeterDeaf(Flanger): + """T5: `transport_changed()` follows the tempo and not the meter.""" + NAME = 'Flanger' + + def transport_changed(self): + from audioeffects import _component + _component.Component.transport_changed(self) + + +class FrozenPolarity(Flanger): + """T6: Polarity read from the constructor, so the knob does nothing.""" + NAME = 'Flanger' + + def _build(self, polarity="positive", **options): + self._frozen = polarity == "negative" + Flanger._build(self, polarity=polarity, **options) + + def _negative(self): + return self._frozen + + +class FrozenSync(Flanger): + """T5: Sync read from the constructor, so the knob does nothing.""" + NAME = 'Flanger' + + def _build(self, sync=False, **options): + self._frozen = bool(sync) + Flanger._build(self, sync=sync, **options) + + def _rate_hz(self): + if not self._frozen: + return self._value(RATE) + return Flanger._rate_hz(self) + + class InvertOnly(Flanger): """T6: `invert` set with the feedback left positive.""" NAME = 'Flanger' @@ -803,6 +898,31 @@ def test_a_tempo_change(self): self.assertLessEqual(tempo_change_error(Flanger, rate, seed), 0.005, (rate, seed)) + def test_a_meter_change(self): + for rate in RATES: + for seed in SEEDS: + self.assertLessEqual(meter_change_error(Flanger, rate, seed), + 0.005, (rate, seed)) + self.assertGreater(meter_change_error(MeterDeaf, 48000, 0), 0.005) + + def test_the_sync_knob(self): + """Sync turned on from its knob, and from patch 5.""" + transport = Transport(90.0, 3) + want = tempo_rate(90.0, 3, 1.0) + for cls, red in ((Flanger, False), (FrozenSync, True)): + readings = sweep_readings( + cls, 48000, want, 0.6, 1.0, 0, + frames=int(48000 / want * 1.05) + 576, + options={"rate": 0.25}, transport=transport, + actions={0: lambda e: e.set_macro(SYNC, 127)}) + err = abs(fit_rate(readings, 48000, 1.0, 0.6, want) / want - 1) + self.assertEqual(err > 0.005, red, cls) + feed = kc.feed(array("h", [0, 0]), 48000, 1) + e = cls(feed.port, sample_rate=48000, transport=transport) + e.program_change(5) + self.assertEqual(abs(e._node.rate - want) > 1e-9, red, cls) + e.deinit() + def test_rate_is_ignored_and_20_hz_is_the_stop(self): transport = Transport(700.0, 4) self.assertGreater(tempo_rate(700.0, 4, 0.125), 20.0) @@ -837,6 +957,16 @@ def test_the_comb_turns_over(self): else: self.assertLessEqual(at_k, -40.0) + def test_the_polarity_knob_and_patch_3(self): + low, _ = color_laws(q15(0.5), negative=True) + for cls, red in ((Flanger, False), (FrozenPolarity, True)): + at_k, _ = knob_pair_db(cls, 48000, ((POLARITY, 127),)) + self.assertEqual(abs(at_k - db(low)) > 0.25, red, (cls, at_k)) + # Patch 3, "Negative jet", held still: its first peak is a dip. + at_k, color = knob_pair_db(cls, 48000, (), patch=3) + low3, _ = color_laws(q15(color), negative=True) + self.assertEqual(abs(at_k - db(low3)) > 1.0, red, (cls, at_k)) + def test_color_0_subtracts_the_copy(self): for rate in RATES: for tau in T1_TAUS[rate]: @@ -966,6 +1096,20 @@ def test_constructor_values_play_exactly_until_their_knob_moves(self): self.assertNotEqual(node.mix, 0.77) e.deinit() + def test_a_restore_puts_the_exact_values_back(self): + e = self._fresh(rate=0.3, depth=0.33, manual_ms=1.7, color=0.41, + mix=0.77) + record = e._record() + e.program_change(2) + e._reset_to = record + e.reset() + e._reset_to = None + node = e._node + self.assertEqual((node.rate, node.depth, node.min_delay_ms, + node.feedback, node.mix), + (0.3, 0.33, 1.7, 0.41, 0.77)) + e.deinit() + def test_mix_s_first_step_is_a_wire_and_its_second_36_db_down(self): rate = 48000 x = noise(9, 9600, 12000) @@ -1096,14 +1240,16 @@ def native_report(emit=print): "eighth) at the host's tempo and meter, and Rate is ignored.", ("T5Sync.test_the_period", "T5Sync.test_a_tempo_change", "TheSurface.test_the_spans", - "TheSurface.test_rate_is_ignored_while_sync_is_on")), + "TheSurface.test_rate_is_ignored_while_sync_is_on", + "T5Sync.test_a_meter_change", "T5Sync.test_the_sync_knob")), ("Polarity minus turns the comb over and puts a notch at DC.", ("T6Polarity.test_the_comb_turns_over", + "T6Polarity.test_the_polarity_knob_and_patch_3", "T6Polarity.test_color_0_subtracts_the_copy")), ("Constructor values play exactly until their knob first moves or a " "patch loads.", - "TheSurface.test_constructor_values_play_exactly_until_their_knob_" - "moves"), + ("TheSurface.test_constructor_values_play_exactly_until_their_knob_" + "moves", "TheSurface.test_a_restore_puts_the_exact_values_back")), ("The copy is your signal exactly Manual late, added at Mix.", "T1Comb.test_the_comb_is_the_sum_in_integers"), ("At Color 0 and Mix 1 a tone on a notch comes out at least 40 dB " @@ -1116,10 +1262,12 @@ def native_report(emit=print): "reaches its top once a sweep.", ("T4Sweep.test_the_triangle", "T4Sweep.test_the_sweep_starts_at_manual_and_again_after_a_reset")), - ("The ring lasts at most about 0.4 s to -60 dB at 3 ms and full Color, " - "and about 0.7 s at 5 ms; it never oscillates on its own.", + ("The ring lasts about 0.4 s to -60 dB at 3 ms and full Color, and " + "about 0.7 s at 5 ms; it grows as Range sweeps the delay longer, and " + "it never oscillates on its own.", ("T3Ring.test_the_longest_rings", - "T3Ring.test_the_ring_follows_its_laws")), + "T3Ring.test_the_ring_follows_its_laws", + "T3Ring.test_the_swept_ring_is_inside_the_tail")), ("It is linear only to about -28 dBFS on a resonant frequency at full " "Color, and -7.5 dBFS at Color 0: above that the output is squashed " "and gritty, not louder.", From 95ca8df5c6261ffad25776cede7a65a7bdbcbc0a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:24:54 -0500 Subject: [PATCH 014/117] Flanger: the meter follow keeps the base's NaN rule and its path transport_changed() treats a NaN tempo or meter as unchanged, as the base does, and applies the Sync switch through _apply_macro, honouring _SYNC_MACRO, so the shared tempo tests hold it; Flanger joins their SYNCED list. The restore test gains its plant. --- lib/audioeffects/flanger.py | 15 +++++++++--- tests/test_cpython_effects_flanger.py | 33 +++++++++++++++++---------- tests/test_tempo_follow.py | 2 +- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/lib/audioeffects/flanger.py b/lib/audioeffects/flanger.py index af95e37..083ac4a 100644 --- a/lib/audioeffects/flanger.py +++ b/lib/audioeffects/flanger.py @@ -81,6 +81,11 @@ def _option(value, default, span): return min(span[1], max(span[0], value)) +def _same(a, b): + """Equal, with NaN the same as NaN (the base's rule for a tempo).""" + return a == b or (a != a and b != b) + + def tail_frames(color, top_ms, rate): """Frames until the ring is exact silence, for Color `color` and a sweep whose top is `top_ms`, at `rate` (the card's section 5). @@ -333,15 +338,19 @@ def transport_changed(self): alone, so a 4/4 to 3/4 change at one tempo would keep the old rate.""" self._check_live() - if self._macros[SYNC_I] < 0.5: + index = type(self)._SYNC_MACRO + if index is None or self._macros[index] < 0.5: return state = self._transport() if callable(self._transport) \ else self._transport followed = (state[2], state[4]) - if followed == self._followed_bpm: + last = self._followed_bpm + if last is not _component._UNREAD and _same(followed[0], last[0]) \ + and _same(followed[1], last[1]): return self._followed_bpm = followed - self._refresh() + # The base's own path: the Sync switch applied where it stands. + self._apply_macro(index, self._macros[index]) def macro(self, index): """What macro `index` currently stands for, in its own units; for diff --git a/tests/test_cpython_effects_flanger.py b/tests/test_cpython_effects_flanger.py index 63fdd44..fefc9e6 100644 --- a/tests/test_cpython_effects_flanger.py +++ b/tests/test_cpython_effects_flanger.py @@ -618,6 +618,12 @@ def _rate_hz(self): return Flanger._rate_hz(self) +class KeptNothing(Flanger): + """A restore that leaves the exact constructor values out.""" + NAME = 'Flanger' + _KEPT = () + + class InvertOnly(Flanger): """T6: `invert` set with the feedback left positive.""" NAME = 'Flanger' @@ -1097,18 +1103,21 @@ def test_constructor_values_play_exactly_until_their_knob_moves(self): e.deinit() def test_a_restore_puts_the_exact_values_back(self): - e = self._fresh(rate=0.3, depth=0.33, manual_ms=1.7, color=0.41, - mix=0.77) - record = e._record() - e.program_change(2) - e._reset_to = record - e.reset() - e._reset_to = None - node = e._node - self.assertEqual((node.rate, node.depth, node.min_delay_ms, - node.feedback, node.mix), - (0.3, 0.33, 1.7, 0.41, 0.77)) - e.deinit() + exact = (0.3, 0.33, 1.7, 0.41, 0.77) + for cls, red in ((Flanger, False), (KeptNothing, True)): + e = self._fresh(rate=0.3, depth=0.33, manual_ms=1.7, color=0.41, + mix=0.77) + e.__class__ = cls + record = e._record() + e.program_change(2) + e._reset_to = record + e.reset() + e._reset_to = None + node = e._node + got = (node.rate, node.depth, node.min_delay_ms, node.feedback, + node.mix) + self.assertEqual(got != exact, red, (cls, got)) + e.deinit() def test_mix_s_first_step_is_a_wire_and_its_second_36_db_down(self): rate = 48000 diff --git a/tests/test_tempo_follow.py b/tests/test_tempo_follow.py index 9ac3042..f8e1b54 100644 --- a/tests/test_tempo_follow.py +++ b/tests/test_tempo_follow.py @@ -103,7 +103,7 @@ def nothing(effect): SYNCED = ("DigitalDelay", "TapeDelay", "AnalogDelay", "PingPongDelay", - "MultiTapDelay", "Phaser", "AutoPan", "Tremolo") + "MultiTapDelay", "Phaser", "AutoPan", "Tremolo", "Flanger") class TheSyncedClasses(unittest.TestCase): From e32593e3dbfd0421221f0d7d15a2072f376309be Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:19:18 -0500 Subject: [PATCH 015/117] Kit: the fresh check, a reset that comes back as a fresh instance from every patch kit_fresh.py holds a fresh instance to patch 0 exactly (boot), and a reset from the defaults and every patch, before the first pull and mid-stream, to a fresh instance handed patch 0 and fed the source from where the reset found it: the bytes for 2 s, the readback, and each macro moved after the reset. Shown red on three plants (OffGrid, PatchLeak, MoveLeak) against FreshKit, with the same counts on CPython, MicroPython and CircuitPython. --- tests/support/KIT.md | 44 +++++-- tests/support/LIFECYCLE.md | 5 + tests/support/kit.py | 10 +- tests/support/kit_fresh.py | 222 +++++++++++++++++++++++++++++++++++ tests/support/kit_planted.py | 59 +++++++++- tests/test_kit.py | 46 +++++++- 6 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 tests/support/kit_fresh.py diff --git a/tests/support/KIT.md b/tests/support/KIT.md index b72b1ca..ecadde0 100644 --- a/tests/support/KIT.md +++ b/tests/support/KIT.md @@ -1,16 +1,16 @@ # The effects kit -Seven mechanical checks that run on one effect class with no model in the +Eight mechanical checks that run on one effect class with no model in the loop: every verdict is arithmetic on rendered bytes, or on the source. They sit beside the [lifecycle matrix](LIFECYCLE.md) and catch the kinds of defect that kept turning up class after class: a tail that outlasts `tail_samples`, a planted fault a knob can reach, a docstring promise no test holds, a control that does not read back what it was set to, an input level that wraps, a citation that no longer resolves, a refused call that -leaves a trace, and a class that only behaves where `audiocore.get_buffer` -exists. +leaves a trace, a class that only behaves where `audiocore.get_buffer` +exists, and a reset that does not come back as a fresh instance. -To run all seven on one class, from the repo root: +To run all eight on one class, from the repo root: python tests/support/kit.py DigitalDelay python tests/support/kit.py DigitalDelay --only tail,ceiling --quick @@ -28,8 +28,8 @@ It prints one line per cell, `name|check|key|V:verdict[|h:digest]`, a why), `RED(...)` with what was measured, or `decl`. `h:` is a CRC-32 of the cell's render, so two interpreters' lines agree only when their bytes do. -The four render checks (tail, plants, args, ceiling) are numpy-free and -print the same lines on CPython, MicroPython and CircuitPython. The claims, +The five render checks (tail, plants, args, ceiling, fresh) are numpy-free +and print the same lines on CPython, MicroPython and CircuitPython. The claims, paperwork and nogb checks read source, unittest and git, or patch modules, and run on CPython only; a native run prints `SKIP` for them. @@ -198,6 +198,33 @@ run that has it. CPython only. A module that imports `get_buffer` inside a function is not reached. +**fresh** (`kit_fresh.py`). A reset comes back as a fresh instance, from +every patch. The contract says a reset restores patch 0 and that a fresh +instance and one given patch 0 are the same, so: + +- `boot//`: a fresh instance and one handed `program_change(0)` + before its first pull render the same bytes and read back the same + macros (exactly, not within a tolerance), `patch_index`, `tail_samples` + and `latency_samples`. A constructor default off patch 0's 0-127 grid + is red here. +- `reset@///`: built at the defaults or a patch, played + `k` pulls (0, before the first pull, and 8), then `reset()`. From that + pull on it renders byte for byte what a fresh instance handed patch 0 + before its first pull renders (so a default off the grid is red once, in + `boot`, and hides no reset fault), fed the source from the frame the reset left it at (found off a + dry run), and reads back what the fresh one reads. No tail is forgiven: + a block a voice still held, a line the reset kept, a filter's memory or + a delay left from the patch before is red. +- `reset@8+m=/...`: the same, then the same move on both four pulls + later, each macro to each stop, and the readback after it. A state the + reset left that only a move wakes (a line length, a lane) is red here. + +Each cell compares 2 s from the reset and stops at the first difference. +Grid: 48 and 22.05 kHz, stereo and mono, the defaults and every patch; +`--quick` runs 48 kHz and the move cells from the defaults and the last +patch only. A class whose reset cannot meet a cell by design declares it +in `DECLARED`. + ## Known gaps - **Several levels.** Every render check plays its material at one level @@ -245,7 +272,10 @@ a fixture card whose surface table differs (paperwork), `FlushOnly` and `DoubleR on `get_buffer` to flush a primed voice, the second drops a block on a second reset in a row without it; `FlushKit`, which primes a voice of its own and plays it again where it cannot flush, is the control; `MidPatchReset` -does it at a middle patch only, against `FlushKit3`), and `Silent`, which +does it at a middle patch only, against `FlushKit3`), `OffGrid`, +`PatchLeak` and `MoveLeak` (fresh: a Gain default a hair under patch 0's +127; a reset from patch 1 only that keeps the repeat line; a first move +after a reset that lands 1 % off, against `FreshKit`), and `Silent`, which does nothing. Its native half holds the render checks to the same lines on all three interpreters, and shows that agreement can fail with `ImplKit`, whose level differs off diff --git a/tests/support/LIFECYCLE.md b/tests/support/LIFECYCLE.md index 1fc3bd7..a5e5e15 100644 --- a/tests/support/LIFECYCLE.md +++ b/tests/support/LIFECYCLE.md @@ -103,6 +103,11 @@ at patch 0. the source from the same frame. Where the source stands is read off a dry run to the move. No tail is forgiven: a child left at its own patch 0, a line kept across a reset or a block primed twice is red here. + PR runs on the reset events at the cell's own configuration. The kit's + `fresh` check ([KIT.md](KIT.md)) holds the same property more widely: a + reset from every patch, before the first pull and mid-stream, the + readback after it, each macro moved after the reset, and a fresh + instance's defaults held to patch 0 exactly. - **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. diff --git a/tests/support/kit.py b/tests/support/kit.py index b468d7d..46fab12 100644 --- a/tests/support/kit.py +++ b/tests/support/kit.py @@ -1,11 +1,11 @@ -"""The effects kit: seven mechanical checks on one class, no model in the loop. +"""The effects kit: eight mechanical checks on one class, no model in the loop. python tests/support/kit.py DigitalDelay [--only tail,plants,...] [--quick] [--plants-module NAME] [--claims-module NAME] [--no-run] Prints one line per cell, `name|check|key|V:verdict[|h:digest]`, a SUMMARY line per check, and DONE. On MicroPython and CircuitPython -(`MICROPYPATH=lib:tests/support`) it runs the four render checks; the +(`MICROPYPATH=lib:tests/support`) it runs the five render checks; the claims, paperwork and nogb checks read source and git or patch modules, and run on CPython only. `KIT.md` beside this file says what each check measures and its bars. @@ -21,7 +21,7 @@ import kit_common as kc CHECKS = ("paperwork", "claims", "args", "plants", "ceiling", "tail", - "nogb") + "nogb", "fresh") STATIC = ("paperwork", "claims", "nogb") @@ -53,6 +53,10 @@ def run_check(check, cls, emit=print, quick=False, plants_module=None, return kit_nogb.run(cls, emit, quick=quick, name=name, options=kit_options.lifecycle_options_for( cls.NAME)) + if check == "fresh": + import kit_fresh + return kit_fresh.run(cls, emit, quick=quick, name=name, + options=options) if check == "claims": import kit_claims return kit_claims.run(cls, emit, module=claims_module, diff --git a/tests/support/kit_fresh.py b/tests/support/kit_fresh.py new file mode 100644 index 0000000..4b8045d --- /dev/null +++ b/tests/support/kit_fresh.py @@ -0,0 +1,222 @@ +"""Reset equals fresh: after `reset()`, from every patch, a class renders +byte for byte what a fresh instance renders. + +The contract says a reset clears every node and restores patch 0, and that +a fresh component and one given patch 0 are the same. So three things must +hold, and each is a cell: + +- `boot//`: a fresh instance and a fresh instance handed + `program_change(0)` before its first pull render the same bytes and read + back the same macros, `tail_samples` and `latency_samples`. A constructor + default off patch 0's 0-127 grid is red here. +- `reset@///`: built at the defaults or at a patch, + played for `k` pulls (0: before the first), then `reset()`. From that + pull on it renders what a fresh instance handed patch 0 renders, fed the + source from the frame the reset left it at, and reads back what the fresh + one reads. (Against patch 0, not the bare defaults, so a default off the + grid reads red once, in `boot`, and does not hide a reset fault.) No tail is forgiven: a block a voice still held, a line the + reset kept or a filter's memory is red. +- `reset@8+m=///`: the same, then the same macro + move on both, four pulls later. A state the reset left that only a move + wakes (an old line length, a lane the reset did not empty) is red here + and not in the cell before. + +Every cell compares `WINDOW_S` seconds from the reset, block by block, +stopping at the first difference. Numpy-free: the lines are the same on +CPython, MicroPython and CircuitPython. + + python tests/support/kit_fresh.py Saturation [--quick] +""" + +import sys + +if __name__ == "__main__": + sys.path.insert(0, "lib") + sys.path.insert(0, "tests/support") + +import audiocore +import kit_common as kc +import lifecycle + +CHECK = "fresh" +BLOCK = kc.BLOCK +#: The pull the mid-stream reset lands before, and how many pulls after it +#: the move comes. +RESET_AT = 8 +MOVE_AFTER = 4 +#: How long past the reset each cell compares, in seconds. +WINDOW_S = 2.0 +#: The patch the fresh instance a reset is judged against is handed before +#: its first pull. Patch 0, where a reset lands; `boot` holds the defaults +#: to it on its own. +FRESH_PATCH = 0 + + +def _material(rate, channels): + return lifecycle.material_for("once", rate, channels) + + +def _feed(values, rate, channels): + return lifecycle.Feed(values, rate, channels, "256", loop=False) + + +def _readback(e): + n = len(type(e).MACRO_LABELS) + return ([e.get_macro(i) for i in range(n)], e.patch_index, + e.tail_samples, e.latency_samples) + + +def _says(a, b): + """What differs between two readbacks, or None.""" + names = ("macros", "patch_index", "tail_samples", "latency_samples") + for name, x, y in zip(names, a, b): + if x != y: + return "%s %s, fresh %s" % (name, x, y) + return None + + +def _lockstep(e, f, frames, fsize, actions): + """Pull `e` and `f` in step, taking `actions` (pull index: callable on + an instance) on both; the first frame they differ at, or -1.""" + a = bytearray() + b = bytearray() + k = 0 + base = 0 + total = frames * fsize + while base < total: + if k in actions: + actions[k](e) + actions[k](f) + a.extend(kc.pull(e)) + b.extend(kc.pull(f)) + k += 1 + n = min(len(a), len(b)) + if a[:n] != b[:n]: + return (base + lifecycle._first_diff(a, b, 0, n)) // fsize + # MicroPython's bytearray takes no slice deletion. + a = a[n:] + b = b[n:] + base += n + return -1 + + +def _source_at(cls, rate, ch, patch, options, k): + """The frame of the material the source stands at after `k` pulls of + an instance built at `patch`, just before its reset, or -1. + + Before, not after: a fresh instance takes its first block from there, + and so must a reset that re-primes a voice. A reset that took a block + of its own and dropped it would start a block later and be red.""" + values = _material(rate, ch) + feed = _feed(values, rate, ch) + e = kc.build(cls, feed.port, rate, patch, options) + try: + for _ in range(k): + kc.pull(e) + block = bytes(audiocore.get_buffer(feed.mat)[1]) + finally: + e.deinit() + return lifecycle._locate(bytes(values), block, 2 * ch) + + +def reset_cell(cls, rate, ch, patch, options, k, move=None): + """One reset cell's verdict.""" + fsize = 2 * ch + values = _material(rate, ch) + start = _source_at(cls, rate, ch, patch, options, k) + if start < 0: + return "na(source position not found)" + feed = _feed(values, rate, ch) + e = kc.build(cls, feed.port, rate, patch, options) + f = None + try: + for _ in range(k): + kc.pull(e) + e.reset() + f = kc.build(cls, _feed(values[start * ch:], rate, ch).port, rate, + FRESH_PATCH, options) + said = _says(_readback(e), _readback(f)) + if said: + return "RED(after the reset: %s)" % said + actions = {} + if move is not None: + index, value = move + + def act(x): + x.set_macro(index, value) + actions[MOVE_AFTER] = act + d = _lockstep(e, f, int(WINDOW_S * rate), fsize, actions) + if d >= 0: + return "RED(differs from a fresh instance at +%d)" % d + if move is not None: + said = _says(_readback(e), _readback(f)) + if said: + return "RED(after the move: %s)" % said + return "ok" + finally: + e.deinit() + if f is not None: + f.deinit() + + +def boot_cell(cls, rate, ch, options): + """A fresh instance against one handed patch 0 before its first pull.""" + fsize = 2 * ch + values = _material(rate, ch) + e = kc.build(cls, _feed(values, rate, ch).port, rate, None, options) + f = kc.build(cls, _feed(values, rate, ch).port, rate, 0, options) + try: + said = _says(_readback(f), _readback(e)) + if said: + return "RED(patch 0: %s)" % said + pos = [p for p in e._macros] + if pos != list(f._macros): + for i in range(len(pos)): + if pos[i] != f._macros[i]: + return ("RED(macro %d sits at %r fresh, %r at patch 0)" + % (i, pos[i], f._macros[i])) + d = _lockstep(f, e, int(WINDOW_S * rate), fsize, {}) + if d >= 0: + return "RED(patch 0 renders differently from fresh at +%d)" % d + return "ok" + finally: + e.deinit() + f.deinit() + + +def run(cls, emit=print, quick=False, name=None, options=None): + """Every fresh cell for `cls`; returns the red count.""" + options = options or {} + cells = kc.Cells(name or cls.NAME, CHECK, emit) + rates = (48000,) if quick else kc.RATES + configs = kc.patches(cls) + moving = configs if not quick else [None, configs[-1]] + for rate in rates: + for ch in kc.CHANNELS: + where = "%d/%d" % (rate, ch) + todo = [("boot/" + where, boot_cell, (cls, rate, ch, options))] + for patch in configs: + tail = "%s/%s" % (where, kc.pname(patch)) + for k in (0, RESET_AT): + todo.append(("reset@%d/%s" % (k, tail), reset_cell, + (cls, rate, ch, patch, options, k))) + if patch not in moving: + continue + for index, value in kc.knob_positions(cls): + todo.append(("reset@%d+m%d=%d/%s" % ( + RESET_AT, index, value, tail), reset_cell, + (cls, rate, ch, patch, options, RESET_AT, + (index, value)))) + for key, fn, args in todo: + try: + verdict = fn(*args) + except Exception as exc: # noqa: BLE001 + verdict = "RED(raised %s: %s)" % (type(exc).__name__, + exc) + cells.cell(key, verdict) + return cells.summary() + + +if __name__ == "__main__": + import kit + kit.main(sys.argv[1:], only=(CHECK,)) diff --git a/tests/support/kit_planted.py b/tests/support/kit_planted.py index a3bb03a..2302993 100644 --- a/tests/support/kit_planted.py +++ b/tests/support/kit_planted.py @@ -547,10 +547,67 @@ def reset(self): lifecycle.Routed._arm(self) +# -- reset equals fresh ------------------------------------------------------- + + +class FreshKit(EchoTail): + """The control for the fresh check: EchoTail with a second patch; a + reset clears the repeat line and lands on patch 0, which is where the + constructor's defaults sit.""" + NAME = "FreshKit" + + +class OffGrid(FreshKit): + """Fresh: the constructor's Gain default is a hair under patch 0's 127, + so a fresh instance is not patch 0 and a reset does not land where a + fresh instance starts.""" + NAME = "OffGrid" + + def _build(self, mix=1.0, gain=0.999, patch=None): + FreshKit._build(self, mix, gain, patch) + + +class PatchLeak(FreshKit): + """Fresh: from patch 1 only, reset() keeps the repeat line, so the + 20 ms it held plays after the reset; from the defaults and patch 0 the + reset is clean.""" + NAME = "PatchLeak" + + def reset(self): + if self._patch_index != 1: + FreshKit.reset(self) + return + self._check_live() + self._clear_nodes(keep=(self._delay,)) + self._stranded = False + self.program_change(0) + + +class MoveLeak(FreshKit): + """Fresh: the reset itself is clean, but the first macro move after it + lands a hair off where the same move on a fresh instance lands, as a + state the reset left behind and only a move wakes would.""" + NAME = "MoveLeak" + _after_reset = False + + def reset(self): + FreshKit.reset(self) + self._after_reset = True + + def _apply_macro(self, index, position): + level = self._level() + if self._after_reset and not self._constructing \ + and self._patch_index is None: + level = level * 0.99 + self._after_reset = False + self._shaper.set(post_gain=level) + + PLANTS = {} for _cls in (KitPlain, Silent, WrapKit, WrapKitQuiet, ClipKit, DeadStop, CondKit, MidWrap, ComboWrap, AllWrap, LowWrap, ImplKit, EchoTail, ShortTail, EventTail, NoneSilent, NoneDC, ReadbackOff, StopRaises, NanLevel, PatchStale, CtorNan, ClearsFirst, ClearsMacro, FlushKit, FlushOnly, DoubleReset, - FlushKit3, MidPatchReset): + FlushKit3, MidPatchReset, FreshKit, OffGrid, PatchLeak, + MoveLeak): PLANTS[_cls.NAME] = _cls diff --git a/tests/test_kit.py b/tests/test_kit.py index baa40ce..c5958fd 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -25,6 +25,7 @@ import kit_args # noqa: E402 import kit_ceiling # noqa: E402 import kit_claims # noqa: E402 +import kit_fresh # noqa: E402 import kit_common as kc # noqa: E402 import kit_nogb # noqa: E402 import kit_paperwork # noqa: E402 @@ -273,6 +274,49 @@ def test_red_numbers_are_not_compared(self): self.assertNotEqual(kit_nogb._kind("ok"), kit_nogb._kind("RED(x)")) +def fresh_cells(cls): + return cells_of(kit_fresh.run, cls, quick=True) + + +class TestFresh(unittest.TestCase): + """Reset equals fresh: each plant is red on its own cells and nowhere + the fault does not reach, and the control is green on every cell.""" + + def reds(self, cls): + cells = fresh_cells(cls) + return sorted(k for (name, check, k), v in cells.items() + if red(v["V"])) + + def test_the_control_is_green(self): + cells = fresh_cells(kp.FreshKit) + self.assertTrue(cells) + for key, value in cells.items(): + self.assertEqual(value["V"], "ok", key) + + def test_a_default_off_patch_0_is_red(self): + cells = fresh_cells(kp.OffGrid) + self.assertTrue(red(verdict(cells, "OffGrid", "fresh", + "boot/48000/2"))) + # Once, in boot: the reset cells judge against patch 0. + reds = self.reds(kp.OffGrid) + self.assertEqual(reds, ["boot/48000/1", "boot/48000/2"]) + + def test_a_reset_that_keeps_a_line_is_red_from_its_patch_only(self): + reds = self.reds(kp.PatchLeak) + self.assertIn("reset@8/48000/2/p1", reds) + self.assertIn("reset@8/48000/1/p1", reds) + for key in reds: + self.assertTrue(key.endswith("/p1"), key) + # Before the first pull the line holds nothing to keep. + self.assertNotIn("reset@0/48000/2/p1", reds) + + def test_a_fault_only_a_move_wakes_is_red_on_the_move_cells(self): + reds = self.reds(kp.MoveLeak) + self.assertIn("reset@8+m1=0/48000/2/d", reds) + for key in reds: + self.assertIn("+m", key) + + class TestCeiling(unittest.TestCase): def v(self, cls, which="sine", patch=None, knob=None): @@ -611,7 +655,7 @@ def run_kit(binary, args): RENDER = ["--quick", "--plants-module=kit_planted", - "--only=args,plants,ceiling,tail"] + "--only=args,plants,ceiling,tail,fresh"] class TestNative(unittest.TestCase): From 6d92e30e27a5045aad3e762cae20e710d08f1c7d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:42:05 -0500 Subject: [PATCH 016/117] Saturation: a reset plays no stale block, and latency after the lean patch is the onset again reset() re-played the mixer's voices inside the walk, before the chain behind them was cleared, so the first block after a reset was the old audio (1504 LSB at the defaults, every patch). The voices now stop in the walk and the graph is armed after it the way _build arms it. On CircuitPython the host reset of the stock Mixer also cost the reset a block of the source; stopping the voices instead keeps it. A visit to the lean patch set latency_samples from the group delay, so it read 3 at tube x4 afterwards where a fresh instance reads the onset, 1; the lean patch itself now reads the x2 onset. Two tests that pinned the group delay are restated to the onset CLICK_ONSET_SAMPLES declares. The fresh check builds its fresh instance with the constructor's patch=, as the lifecycle's PR does: a live program_change may wait for a zero crossing, and a reset is held to a build. --- lib/audioeffects/saturation.py | 50 +++++++++++++++++++++++- tests/support/KIT.md | 4 +- tests/support/kit_fresh.py | 25 +++++++++--- tests/test_cpython_effects_saturation.py | 38 +++++++++++++++++- 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py index 8c0a5f8..538874d 100644 --- a/lib/audioeffects/saturation.py +++ b/lib/audioeffects/saturation.py @@ -660,7 +660,8 @@ class Saturation(_component.Component): CAPABILITIES = () #: The shipped x4 group delay, 3.3 samples. The instance property is the - #: one to read: Mix 0 is 0, the lean patch's x2 is 2, `console` adds 2. + #: one to read: Mix 0 is 0, and it is the onset + #: `CLICK_ONSET_SAMPLES` measures at the running character and factor. LATENCY_SAMPLES = 3 #: Measured, not inherited. The longest memory in the graph is the 20 Hz #: plate capacitor - 5 Hz on `console`, which is the slow one. A 200 ms @@ -722,6 +723,8 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0, if character == "console" else 0.0) self._routed_lean = False self._ready = False + #: True inside `reset()`, whose walk must not re-play the voices. + self._resetting = False self._attached = False self._bias = 0.0 #: Whether the plate pole was last charged on an offset, so a Bias @@ -959,11 +962,51 @@ def _attach(self): self._mix.voice[1].play(self._makeup_shelf) def _reset_mixers(self): + if self._resetting: + # Inside `reset()` the voices only let go: it arms them again + # once the chain behind them is clear. A host reset of the + # Mixer here reached the voices' sources on CircuitPython's + # stock `audiomixer` and cost the reset a block of the source. + for voice in self._mix.voice: + voice.stop() + return audiocore.reset_buffer(self._mix) if self._attached: self._mix.voice[0].play(self._dry) self._mix.voice[1].play(self._makeup_shelf) + def reset(self): + """Clear every node and come back as `_build` leaves a fresh + instance at patch 0. + + The walk clears the mixer first (it is registered last), and its + voices used to be re-played there: a play primes a voice with a + block of its source, so the wet voice took one from a chain the + walk had not cleared yet and the first block after the reset was + the old audio, 1504 LSB at the defaults. Here the voices are + stopped, the patch goes in with the graph unarmed, and the graph is + armed the way `_build` arms it: the pole charged on the bias, the + level gates opened, then the voices played. + """ + self._check_live() + self._resetting = True + try: + self._clear_nodes() + finally: + self._resetting = False + self._stranded = False + self._ready = False + try: + if self._reset_to is None: + self.program_change(0) + else: + self._restore(self._reset_to) + finally: + self._ready = True + self._charged = False + self._rearm() + self._refresh_output() + def _value(self, index): return _component.macro_value(self._MACRO_RANGES[index], self._macros[index]) @@ -1205,7 +1248,10 @@ def _route(self): factor = self._lean_oversample if lean else self._oversample self._after.play(shaper) self._routed_lean = lean - self._wet_delay = _group_delay_samples(factor, self._extra_delay) + # The onset, as `_build` declares it: the group delay here made a + # visit to the lean patch leave `latency_samples` reading the + # centroid (3 at tube x4) where a fresh instance reads 1. + self._wet_delay = _click_onset_samples(self._character, factor) self._refresh_output() @property diff --git a/tests/support/KIT.md b/tests/support/KIT.md index ecadde0..91d7622 100644 --- a/tests/support/KIT.md +++ b/tests/support/KIT.md @@ -209,8 +209,8 @@ instance and one given patch 0 are the same, so: is red here. - `reset@///`: built at the defaults or a patch, played `k` pulls (0, before the first pull, and 8), then `reset()`. From that - pull on it renders byte for byte what a fresh instance handed patch 0 - before its first pull renders (so a default off the grid is red once, in + pull on it renders byte for byte what a fresh instance built at patch 0 + (the constructor's `patch=`) renders (so a default off the grid is red once, in `boot`, and hides no reset fault), fed the source from the frame the reset left it at (found off a dry run), and reads back what the fresh one reads. No tail is forgiven: a block a voice still held, a line the reset kept, a filter's memory or diff --git a/tests/support/kit_fresh.py b/tests/support/kit_fresh.py index 4b8045d..34b7ebc 100644 --- a/tests/support/kit_fresh.py +++ b/tests/support/kit_fresh.py @@ -11,7 +11,7 @@ default off patch 0's 0-127 grid is red here. - `reset@///`: built at the defaults or at a patch, played for `k` pulls (0: before the first), then `reset()`. From that - pull on it renders what a fresh instance handed patch 0 renders, fed the + pull on it renders what a fresh instance built at patch 0 renders, fed the source from the frame the reset left it at, and reads back what the fresh one reads. (Against patch 0, not the bare defaults, so a default off the grid reads red once, in `boot`, and does not hide a reset fault.) No tail is forgiven: a block a voice still held, a line the @@ -46,9 +46,9 @@ MOVE_AFTER = 4 #: How long past the reset each cell compares, in seconds. WINDOW_S = 2.0 -#: The patch the fresh instance a reset is judged against is handed before -#: its first pull. Patch 0, where a reset lands; `boot` holds the defaults -#: to it on its own. +#: The patch the fresh instance a reset is judged against is built at (the +#: constructor's `patch=`). Patch 0, where a reset lands; `boot` holds the +#: defaults to it on its own. FRESH_PATCH = 0 @@ -100,6 +100,19 @@ def _lockstep(e, f, frames, fsize, actions): return -1 +def _fresh_at(cls, port, rate, options): + """A fresh instance built at `FRESH_PATCH` by the constructor's own + `patch=`, the way the lifecycle's PR builds one: a live + `program_change` on a built instance may glide or wait for a zero + crossing, and a reset is held to a build, not to a move.""" + keywords = dict(options) + keywords["patch"] = FRESH_PATCH + try: + return cls(port, sample_rate=rate, **keywords) + except TypeError: + return kc.build(cls, port, rate, FRESH_PATCH, options) + + def _source_at(cls, rate, ch, patch, options, k): """The frame of the material the source stands at after `k` pulls of an instance built at `patch`, just before its reset, or -1. @@ -133,8 +146,8 @@ def reset_cell(cls, rate, ch, patch, options, k, move=None): for _ in range(k): kc.pull(e) e.reset() - f = kc.build(cls, _feed(values[start * ch:], rate, ch).port, rate, - FRESH_PATCH, options) + f = _fresh_at(cls, _feed(values[start * ch:], rate, ch).port, + rate, options) said = _says(_readback(e), _readback(f)) if said: return "RED(after the reset: %s)" % said diff --git a/tests/test_cpython_effects_saturation.py b/tests/test_cpython_effects_saturation.py index 3714699..d5b02dd 100644 --- a/tests/test_cpython_effects_saturation.py +++ b/tests/test_cpython_effects_saturation.py @@ -1222,8 +1222,11 @@ def test_lean_patch_is_one_factor_down(self): "Saturation - lean") probe = sine(1010, 48000, -6.0) lean, latency = self._lean_render(probe) - self.assertEqual(latency, 2) plain = build(probe=probe, oversample=2, patch=0) + # The x2 build's onset, as `CLICK_ONSET_SAMPLES` declares it (this + # used to read 2, the group delay, which no build reports). + self.assertEqual(latency, plain.latency_samples) + self.assertEqual(latency, rebuilt.CLICK_ONSET_SAMPLES[("tube", 2)]) two = render(plain, 48000).float[24000:, 0] plain.deinit() eight = build(probe=probe) @@ -1238,9 +1241,12 @@ def test_lean_patch_is_one_factor_down(self): def test_leaving_the_lean_patch_restores_the_factor(self): effect = build() + fresh = effect.latency_samples effect.program_change(rebuilt.LEAN_PATCH) effect.program_change(0) - self.assertEqual(effect.latency_samples, 3) + # The onset a fresh instance reports, not the group delay (3). + self.assertEqual(effect.latency_samples, fresh) + self.assertEqual(fresh, rebuilt.CLICK_ONSET_SAMPLES[("tube", 4)]) effect.deinit() def test_one_priming_path_for_every_interpreter(self): @@ -1760,5 +1766,33 @@ def test_the_old_class_and_a_wrong_cure_are_red(self): patch=7), 1000) +class TestResetIsFresh(unittest.TestCase): + """A reset comes back as a fresh instance at patch 0 (kit `fresh`).""" + + def test_a_reset_plays_no_stale_block(self): + # The mixer's voices were re-primed inside the reset, before the + # chain behind them was cleared, so the first block after a reset + # was the old chain's (1504 LSB at the defaults). + import kit_fresh + for patch in (None, 0, 1, 2, 3): + for ch in (2, 1): + for k in (0, kit_fresh.RESET_AT): + self.assertEqual(kit_fresh.reset_cell( + Saturation, RATE, ch, patch, {}, k), "ok", + (patch, ch, k)) + + def test_latency_after_the_lean_patch_is_the_onset_again(self): + effect = build() + fresh = effect.latency_samples + effect.program_change(rebuilt.LEAN_PATCH) + self.assertEqual(effect.latency_samples, + rebuilt.CLICK_ONSET_SAMPLES[("tube", 2)]) + effect.program_change(0) + self.assertEqual(effect.latency_samples, fresh) + effect.program_change(rebuilt.LEAN_PATCH) + effect.reset() + self.assertEqual(effect.latency_samples, fresh) + + if __name__ == "__main__": unittest.main() From 1a1b7d997b0a3cde67da01aa92d15b0a6d28bc50 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:48:07 -0500 Subject: [PATCH 017/117] MultiTapDelay: the Time move after a reset, held as an expected failure on the node A reset alone comes back as a fresh instance (green). A Time 0 move after it does not: audiodelays.MultiTapDelay's reset_buffer empties the line but leaves the write head where it was, so a shorter Time keeps a different stretch of the line than on a fresh node. Red on CPython, MicroPython and CircuitPython alike; a reset that puts the twin's write head at the top turns it green. The cure is the node's, so the test is an expected failure until it lands. --- tests/test_cpython_effects_multitapdelay.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_cpython_effects_multitapdelay.py b/tests/test_cpython_effects_multitapdelay.py index 58010b6..14f910d 100644 --- a/tests/test_cpython_effects_multitapdelay.py +++ b/tests/test_cpython_effects_multitapdelay.py @@ -3290,5 +3290,34 @@ def test_a_jumping_control_steps_the_output(self): ctl.close() +class TestResetIsFresh(unittest.TestCase): + """A reset comes back as a fresh instance at patch 0 (kit `fresh`).""" + + @unittest.expectedFailure + def test_a_time_move_after_a_reset_plays_as_on_a_fresh_instance(self): + # Red: audiodelays.MultiTapDelay's reset_buffer empties the line + # but leaves the write head where it was, and a fresh node's starts + # at the top. A shorter Time then keeps a different stretch of the + # line, so a Time 0 move after a reset plays repeats a fresh + # instance does not (from +1027, 35 frames apart, 10 146 LSB at + # most). The cure is the node's: a reset puts its write head back + # at the top. Nothing the class can reach moves the head. + import kit_fresh + for patch in (None, 0, 3, 6): + for ch in (2, 1): + self.assertEqual(kit_fresh.reset_cell( + MultiTapDelay, RATE, ch, patch, {}, kit_fresh.RESET_AT, + (mtd.TIME_I, 0)), "ok", (patch, ch)) + + def test_a_reset_alone_is_a_fresh_instance(self): + import kit_fresh + for patch in (None, 0, 3, 6): + for ch in (2, 1): + for k in (0, kit_fresh.RESET_AT): + self.assertEqual(kit_fresh.reset_cell( + MultiTapDelay, RATE, ch, patch, {}, k), "ok", + (patch, ch, k)) + + if __name__ == "__main__": unittest.main() From 78848f937bf1383ef9ff3fad4ba405f340a874f6 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:00:44 -0500 Subject: [PATCH 018/117] Saturation: a reset onto Mix 0 inside a rack leaves the graph un-stranded, as built The rack's put-back can land Saturation on Mix 0; the reset armed the graph and then routed around it, which marked it stranded where a build does not (test_cpython_effects_rack TestReset, three interpreters). --- lib/audioeffects/saturation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py index 538874d..cbe66f0 100644 --- a/lib/audioeffects/saturation.py +++ b/lib/audioeffects/saturation.py @@ -1006,6 +1006,9 @@ def reset(self): self._charged = False self._rearm() self._refresh_output() + # Armed just now: a Mix 0 it lands on routes around a graph that + # has not played since it was cleared, as `_build`'s does. + self._stranded = False def _value(self, index): return _component.macro_value(self._MACRO_RANGES[index], From 3cd82ec8e9cffa88818ba45e428120420fbbb64e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:00:44 -0500 Subject: [PATCH 019/117] DigitalDelay and SlapbackDelay: a fresh instance is patch 0, so a reset lands where it starts Time was built off the 0-127 grid: a fresh DigitalDelay played 350 ms beside patch 0's 352.8 ms (grid 102), and a SlapbackDelay 135 ms beside 134.4 ms (grid 84), so a reset sounded different from a fresh instance and reported a looser tail (186 296 against 184 811 frames). An option left out now plays patch 0's step (_init_macros' new defaults flags); a value you pass plays as passed. NaN on SlapbackDelay takes the step, as it took the default. Tests that read figures at the dossier's settings pass them explicitly (DOSSIER, time_ms=350.0); tests about the defaults read patch 0's steps. The kit's ctor and opt cells read an option whose default is None from the class's _OPTION_DEFAULTS. CHANGELOG lines for these and Saturation. --- CHANGELOG.md | 2 + lib/audioeffects/_component.py | 17 +++- lib/audioeffects/rebuilt/digitaldelay.py | 34 ++++++-- lib/audioeffects/rebuilt/slapbackdelay.py | 39 +++++++-- tests/support/KIT.md | 4 +- tests/support/kit_args.py | 4 + tests/test_cpython_effects_digitaldelay.py | 44 +++++++++- tests/test_cpython_effects_slapbackdelay.py | 96 ++++++++++++++++----- 8 files changed, 202 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db62a61..b5dd802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -364,6 +364,8 @@ there, and are recorded in its changelog. ### Fixed +- `Saturation`: `reset()` no longer plays one block of the audio from before it, and after the lean patch `latency_samples` reads the onset again (1 at tube x4, 0 on the lean patch itself) instead of the group delay. +- `DigitalDelay` and `SlapbackDelay`: an option you leave out plays patch 0's step, so a fresh instance is patch 0 exactly and a reset sounds and reports its tail as a fresh instance does. At the defaults Time is 352.8 ms on `DigitalDelay` (it was 350) and 134.4 ms on `SlapbackDelay` (it was 135), and the other controls move by under a step; a value you pass plays as passed. - A Time brought back after a shorter one no longer plays a stray sample from before the move in MultiTapDelay and the delays built on `audiodelays.Echo` (on CPython and MicroPython; CircuitPython's own `audiodelays` still plays it); PitchShifter, Harmonizer and Octaver start again from scratch on a reset and no longer hang on a source with nothing to give; and a host reset that reaches a Mixer inside an effect keeps the audio its voices hold. All of it is audiodsp's, at the pre-release `AUDIODSP_PIN` now names (v0.6.6rc1). - The delays, Reverb and ConvolutionReverb now ring out when their source ends, then rest at exact zero, instead of freezing the tail until the source came back; a one-shot source plays once instead of feeding them for ever. A source that comes up short part-way through a block leaves a whole block with the rest silent, and a host `reset_buffer` on ConvolutionReverb keeps the frames it holds. All of it is audiodsp's, at the commit `AUDIODSP_PIN` now names (audiocomponents#127). diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index 4f61784..152e386 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -899,17 +899,30 @@ def _hz(self, frequency): return 1.0 return frequency - def _init_macros(self, values, patch=None): + def _init_macros(self, values, patch=None, defaults=None): """Seed the knobs from `_build`'s own arguments, in the macros' own units, and apply a patch over the top if one was asked for. This is deliberately the only route into `_apply_macro`, so the constructor - and a later `set_macro` cannot drift apart.""" + and a later `set_macro` cannot drift apart. + + `defaults`, when given, holds one flag per value: True where the + caller did not choose it (the argument was left out). That value is + seeded on patch 0's step for its macro rather than at the value + itself, so a fresh instance is patch 0 exactly, as the contract + says, and a reset lands where a fresh instance starts. A value the + caller passed is seeded where it is.""" cls = type(self) if len(values) != len(cls.MACRO_LABELS): raise TypeError("%s seeded %d macros for %d labels" % (cls.NAME, len(values), len(cls.MACRO_LABELS))) self._macros = [macro_position(span, value) for span, value in zip(cls._MACRO_RANGES, values)] + if defaults is not None: + steps = cls.PATCHES[0][1] + for index in range(len(values)): + if defaults[index]: + self._macros[index] = position_of_midi( + self._mode(index), steps[index]) for index in range(len(self._macros)): self._apply_macro(index, self._macros[index]) self._patch_index = 0 diff --git a/lib/audioeffects/rebuilt/digitaldelay.py b/lib/audioeffects/rebuilt/digitaldelay.py index e1a4fff..6bbba45 100644 --- a/lib/audioeffects/rebuilt/digitaldelay.py +++ b/lib/audioeffects/rebuilt/digitaldelay.py @@ -125,6 +125,10 @@ TIME_I, FEEDBACK_I, MIX_I, GLIDE_I, SYNC_I, DIVISION_I, TONE_I, CUT_I = \ range(8) +#: `_build`'s defaults in the macros' units: patch 0's steps, rounded. An +#: option left out plays the step itself. +DEFAULTS = (350.0, 0.35, 0.3, 4000.0, 0.0, 6.0, 16000.0, 20.0) + def nominal_cut_hz(corner_hz, sample_rate): """`cut_hz` whose one-pole high-pass -3 dB is `corner_hz` at @@ -354,9 +358,27 @@ class DigitalDelay(_component.Component): 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, + #: The number each option left out stands for (patch 0's step, + #: rounded), for a reader of the signature. + _OPTION_DEFAULTS = {"time_ms": 350.0, "feedback": 0.35, "mix": 0.3, + "glide_ms": 4000.0, "division": 6, + "tone_hz": 16000.0, "cut_hz": 20.0} + + def _build(self, time_ms=None, feedback=None, mix=None, glide_ms=None, + sync=None, division=None, tone_hz=None, cut_hz=None, max_time_ms=TIME_MAX_MS, patch=None): + # An option left out plays patch 0's step, so a fresh instance is + # patch 0 exactly and a reset lands where it starts + # (`_init_macros`): Time 350 ms is grid 102, 352.8 ms, and the two + # used to play side by side. `DEFAULTS` is each step rounded. A + # value you pass plays as passed. + raw = (time_ms, feedback, mix, glide_ms, sync, division, tone_hz, + cut_hz) + seeded = tuple(value is None for value in raw) + time_ms, feedback, mix, glide_ms, sync, division, tone_hz, cut_hz = ( + DEFAULTS[index] if seeded[index] else raw[index] + for index in range(len(raw))) + sync = bool(sync) max_time_ms = float(max_time_ms) # `not <=` catches NaN, which would otherwise pass both clamps and # size the line from nothing. @@ -403,7 +425,8 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.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 + # A Glide left out plays its grid step, so it is not kept exact. + self._glide_exact = None if seeded[GLIDE_I] else glide_ms self._seeding = True self._delay = audioecho.FeedbackDelay( sample_rate=self._sample_rate, @@ -428,8 +451,9 @@ def _build(self, time_ms=350.0, feedback=0.35, mix=0.3, glide_ms=4000.0, 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: + cut_hz), defaults=seeded) + if self._glide_exact is not None and 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: diff --git a/lib/audioeffects/rebuilt/slapbackdelay.py b/lib/audioeffects/rebuilt/slapbackdelay.py index c479ef8..ee55009 100644 --- a/lib/audioeffects/rebuilt/slapbackdelay.py +++ b/lib/audioeffects/rebuilt/slapbackdelay.py @@ -59,7 +59,8 @@ TIME_I, LEVEL_I, SATURATION_I, TONE_I, WOW_I, REPEATS_I = range(6) -#: The constructor defaults, which NaN falls back to. +#: The constructor defaults, which NaN falls back to: patch 0's steps, +#: rounded. An option left out plays the step itself. DEFAULTS = (135.0, 0.35, 0.15, TONE_MAX_HZ, 1.0, 0.0) @@ -102,6 +103,15 @@ def _option(value, default): return value +def _unset(value): + """True for an option left out (None) or NaN, which takes patch 0's + step.""" + if value is None: + return True + value = float(value) + return value != value + + def _between(value, low, high): if value < low: return low @@ -116,7 +126,7 @@ class SlapbackDelay(_component.Component): 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. + By default the repeat comes 134.4 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 @@ -210,8 +220,23 @@ class SlapbackDelay(_component.Component): 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): + #: The number each option left out stands for (patch 0's step, + #: rounded), for a reader of the signature. + _OPTION_DEFAULTS = dict(zip(("time_ms", "level", "saturation", + "tone_hz", "wow_cents", "repeats"), + DEFAULTS)) + + def _build(self, time_ms=None, level=None, saturation=None, + tone_hz=None, wow_cents=None, repeats=None, patch=None): + # An option left out (or NaN) plays patch 0's step, so a fresh + # instance is patch 0 exactly and a reset lands where it starts + # (`_init_macros`); `DEFAULTS` is that step rounded to a readable + # number. A value you pass plays as passed. + raw = (time_ms, level, saturation, tone_hz, wow_cents, repeats) + seeded = tuple(_unset(value) for value in raw) + time_ms, level, saturation, tone_hz, wow_cents, repeats = ( + DEFAULTS[index] if seeded[index] else raw[index] + for index in range(len(raw))) 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 @@ -267,10 +292,12 @@ def _build(self, time_ms=135.0, level=0.35, saturation=0.15, self._own(self._delay, reset=self._clear) self._delay.play(self._source) self._output = self._delay - self._time_exact = time_ms + # A Time left out is patch 0's step, grid 84 (134.4 ms); only a + # Time you pass is kept exact. + self._time_exact = None if seeded[TIME_I] else time_ms self._deferred = True try: - self._init_macros(values) + self._init_macros(values, defaults=seeded) finally: self._deferred = False self._seeding = False diff --git a/tests/support/KIT.md b/tests/support/KIT.md index 91d7622..f648d2d 100644 --- a/tests/support/KIT.md +++ b/tests/support/KIT.md @@ -115,7 +115,9 @@ hands it: patch's values, and `program_change(0)` reads back what a fresh instance read (the contract: a fresh component and one given patch 0 are the same). -- `ctor-