From 5f9231359625ea2eb9c2edd94e274bbc9bcebdcd Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:28:49 -0500 Subject: [PATCH 01/76] The lifecycle matrix on single audiodsp nodes --- tests/support/lifecycle.py | 6 + tests/support/node_matrix.py | 374 +++++++++++++++++++++++++++++++++++ tests/test_node_matrix.py | 141 +++++++++++++ 3 files changed, 521 insertions(+) create mode 100644 tests/support/node_matrix.py create mode 100644 tests/test_node_matrix.py diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index c527a93..047a230 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -1327,6 +1327,12 @@ def _cli(argv): for name in names: if name in PLANTS: cls = PLANTS[name] + elif name.startswith("Node"): + import node_matrix + cls = node_matrix.NODES.get(name) + if cls is None: + print("MISSING|%s" % name) + continue else: from audioeffects import rebuilt cls = rebuilt.module_class(name) diff --git a/tests/support/node_matrix.py b/tests/support/node_matrix.py new file mode 100644 index 0000000..879b0b4 --- /dev/null +++ b/tests/support/node_matrix.py @@ -0,0 +1,374 @@ +"""The lifecycle matrix on audiodsp nodes, before any class is built on them. + +`lifecycle.py` judges effect classes. This module wraps each node a phase +stands on in the thinnest class that can carry the matrix: the node alone, +its own controls as the macros, its own `mix` (or the control that is a wire +at one end) as Mix, and two or three settings as patches. Nothing is routed +around and nothing is composed, so a red cell is the node's. + + python tests/support/lifecycle.py NodePitchShift [--quick] + LIFECYCLE_CLASSES=NodePitchShift python -m unittest tests.test_node_matrix + +`clear()` is what `reset()` calls where the node has one (E1), and the +host's `audiocore.reset_buffer` is E2, so the two are both exercised. + +Which nodes, and why: Phase 6's classes (PitchShifter, Harmonizer, Octaver, +StereoWidener, Rack, ShimmerHall, AirSpace) and their seed dossiers. The +racks' children are the Phase 2 to 5 classes, so `audioverb.Tank`, +`audioconvolve.Convolver` and `audioecho.FeedbackDelay`'s delay modes are +qualified by `tests/test_lifecycle_matrix.py` running those classes at the +same pin; the routing every class uses (`audioroute.Port`) is under every +class's output already. +""" + +import audiodelays +import audiomixer + +try: + import audiobiquad + import audioecho + import audiomath + import audioroute +except ImportError: # pragma: no cover + audiobiquad = audioecho = audiomath = audioroute = None + +from audioeffects import _component + +#: `_component` reads VENDOR off the module a class is defined in. +VENDOR = "PyDevices" + +BLOCK = 256 + + +def _seconds(e, seconds): + return int(seconds * e._sample_rate) + + +class _Node(_component.Component): + """One node, its controls as macros. A subclass sets `_node_cls`, + `_make(options)` and `_set(name, value)`.""" + + TIER = _component.AUDIODSP + MACRO_MODES = {} + #: Seconds; the tail at the running rate is this times the rate. + _TAIL_S = 0.0 + LATENCY_SAMPLES = 0 + + @property + def tail_samples(self): + self._check_live() + return _seconds(self, self._TAIL_S) + + def _values(self): + return [_component.macro_value(span, self._macros[i]) + for i, span in enumerate(type(self)._MACRO_RANGES)] + + def _register(self, node): + clear = getattr(node, "clear", None) + return self._own(node, reset=clear if clear is not None else True) + + +# -- pitch ---------------------------------------------------------------- + + +class NodePitchShift(_Node): + """`audiodelays.PitchShift`: the H910's splice shifter (PitchShifter, + Harmonizer, ShimmerHall's octave).""" + NAME = "NodePitchShift" + TIER = _component.STOCK + REQUIRES = () + MACRO_LABELS = ("Mix", "Semitones", "Freeze") + MACRO_MODES = {0: "UNIPOLAR", 1: "BIPOLAR", 2: "TOGGLE"} + _MACRO_RANGES = ((0.0, 1.0), (-12.0, 12.0), (0.0, 1.0)) + PATCHES = {0: ("Octave up", (127, 127, 0)), + 1: ("Octave down", (127, 0, 0)), + 2: ("Fifth, half", (64, 96, 0))} + #: The window plus the overlap, twice, at the node's defaults. + _TAIL_S = 2 * (1024 + 128) / 22050.0 + + def _build(self, mix=1.0, semitones=12.0, freeze=0.0, patch=None): + self._node = self._register(audiodelays.PitchShift( + semitones=semitones, mix=mix, window=1024, overlap=128, + **self._pcm(512))) + self._node.play(self._source, loop=True) + self._output = self._node + self._init_macros((mix, semitones, freeze), patch) + + def _apply_macro(self, index, position): + mix, semitones, freeze = self._values() + if index == 0: + self._node.mix = mix + elif index == 1: + self._node.semitones = semitones + else: + self._node.freeze = freeze >= 0.5 + + +class NodeGranularPitchShift(_Node): + """`audiodelays.GranularPitchShift`: the alternative shifter a + PitchShifter card may choose.""" + NAME = "NodeGranularPitchShift" + TIER = _component.STOCK + REQUIRES = () + MACRO_LABELS = ("Mix", "Semitones", "Spread") + MACRO_MODES = {0: "UNIPOLAR", 1: "BIPOLAR", 2: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (-12.0, 12.0), (0.0, 1.0)) + PATCHES = {0: ("Octave up", (127, 127, 0)), + 1: ("Octave down, spread", (127, 0, 96)), + 2: ("Fifth, half", (64, 96, 0))} + _TAIL_S = 4 * 1024 / 22050.0 + + def _build(self, mix=1.0, semitones=12.0, spread=0.0, patch=None): + self._node = self._register(audiodelays.GranularPitchShift( + semitones=semitones, mix=mix, grain_size=1024, density=2, + spread=spread, **self._pcm(512))) + self._node.play(self._source, loop=True) + self._output = self._node + self._init_macros((mix, semitones, spread), patch) + + def _apply_macro(self, index, position): + mix, semitones, spread = self._values() + if index == 0: + self._node.mix = mix + elif index == 1: + self._node.semitones = semitones + else: + self._node.spread = spread + + +class NodeShimmerLoop(_Node): + """`audioecho.FeedbackDelay` with a pitch shift in its loop + (`loop_semitones`, ShimmerHall's Rise). Mix runs to 2.0, wet alone.""" + NAME = "NodeShimmerLoop" + REQUIRES = ("audioecho",) + MACRO_LABELS = ("Mix", "Feedback", "Semitones", "Time") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "BIPOLAR", + 3: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 2.0), (0.0, 0.7), (-12.0, 12.0), + (10.0, 100.0)) + PATCHES = {0: ("Rise", (64, 64, 127, 64)), + 1: ("Fall, wet", (127, 96, 0, 32)), + 2: ("Fifth", (64, 32, 96, 127))} + #: 0.7 ** n * 32768 < 1 at n = 30 repeats of the longest time, plus + #: the loop shifter's window. + _TAIL_S = 30 * 0.1 + 0.1 + + def _build(self, mix=1.0, feedback=0.35, semitones=12.0, time_ms=55.0, + patch=None): + self._node = self._register(audioecho.FeedbackDelay( + sample_rate=self._sample_rate, + channel_count=self._channel_count, max_delay_ms=120.0, + delay_ms=time_ms, feedback=feedback, mix=mix, + loop_semitones=semitones)) + self._node.play(self._source) + self._output = self._node + self._init_macros((mix, feedback, semitones, time_ms), patch) + + def _apply_macro(self, index, position): + mix, feedback, semitones, time_ms = self._values() + name = ("mix", "feedback", "loop_semitones", "delay_ms")[index] + self._node.set(**{name: (mix, feedback, semitones, time_ms)[index]}) + + +class NodeSubOctave(_Node): + """`audiomath.SubOctave`: the OC-2's flip-flop divider (Octaver).""" + NAME = "NodeSubOctave" + REQUIRES = ("audiomath",) + MACRO_LABELS = ("Mix", "Order", "Threshold") + MACRO_MODES = {0: "UNIPOLAR", 1: "TOGGLE", 2: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (1.0, 2.0), (0.001, 0.1, "log")) + PATCHES = {0: ("One octave", (127, 0, 64)), + 1: ("Two octaves", (127, 127, 64)), + 2: ("Half, high threshold", (64, 0, 120))} + _TAIL_S = 0.05 + + def _build(self, mix=1.0, order=1.0, threshold=0.01, patch=None): + self._node = self._register(audiomath.SubOctave( + order=int(order), mix=mix, threshold=threshold, + sample_rate=self._sample_rate, + channel_count=self._channel_count)) + self._node.play(self._source) + self._output = self._node + self._init_macros((mix, order, threshold), patch) + + def _apply_macro(self, index, position): + mix, order, threshold = self._values() + if index == 0: + self._node.set(mix=mix) + elif index == 1: + self._node.set(order=2 if order >= 1.5 else 1) + else: + self._node.set(threshold=threshold) + + +class NodeSquare(_Node): + """`audiomath.Multiply` fed two taps of one `audioroute.Splitter`: the + frequency doubler the Octaver dossier names, and the Splitter under it. + Mix is the Multiply's own.""" + NAME = "NodeSquare" + REQUIRES = ("audiomath", "audioroute") + MACRO_LABELS = ("Mix",) + MACRO_MODES = {0: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0),) + PATCHES = {0: ("Square", (127,)), 1: ("Half", (64,))} + _TAIL_S = 0.0 + + def _build(self, mix=1.0, patch=None): + split = audioroute.Splitter(self._source, taps=2) + self._split = self._own(split, reset=False) + self._node = self._register(audiomath.Multiply( + split.tap(0), split.tap(1), mix=mix, + sample_rate=self._sample_rate, + channel_count=self._channel_count)) + self._output = self._node + self._init_macros((mix,), patch) + + def _apply_macro(self, index, position): + self._node.set(mix=self._values()[0]) + + +# -- stereo and routing --------------------------------------------------- + + +class NodeMidSide(_Node): + """`audioroute.MidSide`: StereoWidener's matrix. Width 1 is a wire, so + Mix carries the width from 1 (Mix 0) to the Target macro (Mix 1).""" + NAME = "NodeMidSide" + REQUIRES = ("audioroute",) + MACRO_LABELS = ("Mix", "Target") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (0.0, 2.0)) + PATCHES = {0: ("Wide", (127, 127)), 1: ("Mono", (127, 0)), + 2: ("Narrow, half", (64, 32))} + _TAIL_S = 0.0 + + def _build(self, mix=1.0, target=2.0, patch=None): + self._node = self._register(audioroute.MidSide( + width=1.0, sample_rate=self._sample_rate, + channel_count=self._channel_count)) + self._node.play(self._source) + self._output = self._node + self._init_macros((mix, target), patch) + + def _apply_macro(self, index, position): + mix, target = self._values() + self._node.set(width=1.0 + mix * (target - 1.0)) + + +class NodeSplitMix(_Node): + """`audioroute.Splitter` into `audiomixer.Mixer`, the house fan-out + (every parallel class, Harmonizer's stack, the racks' dry). Mix moves + the second tap's voice up and the first's down; at Mix 0 the first tap + plays at unity, which the Rack dossier found byte-identical (A6.3).""" + NAME = "NodeSplitMix" + REQUIRES = ("audioroute",) + MACRO_LABELS = ("Mix", "Level") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (0.0, 1.0)) + PATCHES = {0: ("Even", (64, 127)), 1: ("Second only", (127, 127)), + 2: ("Quiet", (127, 32))} + _TAIL_S = 0.0 + + def _build(self, mix=0.5, level=1.0, patch=None): + split = audioroute.Splitter(self._source, taps=2) + self._split = self._own(split, reset=False) + self._mixer = self._register(audiomixer.Mixer( + voice_count=2, **self._pcm(BLOCK * self._channel_count * 2))) + self._mixer.voice[0].play(split.tap(0), loop=True) + self._mixer.voice[1].play(split.tap(1), loop=True) + self._output = self._mixer + self._init_macros((mix, level), patch) + + def _apply_macro(self, index, position): + mix, level = self._values() + self._mixer.voice[0].level = 1.0 - mix + self._mixer.voice[1].level = mix * level + + +class NodeBiquad(_Node): + """`audiobiquad.Biquad`, low-pass: the Octaver's tracking and tone + filters, AirSpace's top cut.""" + NAME = "NodeBiquad" + REQUIRES = ("audiobiquad",) + MACRO_LABELS = ("Mix", "Frequency", "Q") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (60.0, 8000.0, "log"), (0.5, 4.0)) + PATCHES = {0: ("Dark", (127, 32, 10)), 1: ("Resonant", (127, 64, 127)), + 2: ("Half, bright", (64, 120, 10))} + _TAIL_S = 0.5 + + def _build(self, mix=1.0, frequency=500.0, Q=0.7071, patch=None): + self._node = self._register(audiobiquad.Biquad( + mode=audiobiquad.LOW_PASS, frequency=frequency, Q=Q, mix=mix, + sample_rate=self._sample_rate, + channel_count=self._channel_count)) + self._node.play(self._source) + self._output = self._node + self._init_macros((mix, frequency, Q), patch) + + def _apply_macro(self, index, position): + mix, frequency, q = self._values() + if index == 0: + self._node.mix = mix + elif index == 1: + self._node.frequency = self._hz(frequency) + else: + self._node.Q = q + + +class NodeEcho(_Node): + """`audiodelays.Echo`: Reverb's predelay and the Haas line a + StereoWidener card may choose.""" + NAME = "NodeEcho" + TIER = _component.STOCK + REQUIRES = () + MACRO_LABELS = ("Mix", "Time", "Decay") + MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR"} + _MACRO_RANGES = ((0.0, 1.0), (5.0, 100.0), (0.0, 0.5)) + PATCHES = {0: ("Haas", (127, 5, 0)), 1: ("Slap", (64, 127, 64)), + 2: ("Short repeats", (96, 40, 127))} + #: 0.5 ** n * 32768 < 1 at n = 16 repeats of the longest time. + _TAIL_S = 16 * 0.1 + 0.1 + + def _build(self, mix=1.0, time_ms=10.0, decay=0.0, patch=None): + self._node = self._register(audiodelays.Echo( + max_delay_ms=120, delay_ms=time_ms, decay=decay, mix=mix, + freq_shift=False, **self._pcm(512))) + self._node.play(self._source, loop=True) + self._output = self._node + self._init_macros((mix, time_ms, decay), patch) + + def _apply_macro(self, index, position): + mix, time_ms, decay = self._values() + if index == 0: + self._node.mix = mix + elif index == 1: + self._node.delay_ms = time_ms + else: + self._node.decay = decay + + +NODES = {} +for _cls in (NodePitchShift, NodeGranularPitchShift, NodeShimmerLoop, + NodeSubOctave, NodeSquare, NodeMidSide, NodeSplitMix, + NodeBiquad, NodeEcho): + NODES[_cls.NAME] = _cls + + +#: Red cells a node has by design, one row per (adapter NAME, event name +#: prefix, property): reason. Merged into lifecycle.DECLARED on import. +DECLARED = { + ("NodeSubOctave", "E1-", "P4"): + "a reset restarts the divider at the pass-through polarity; a node" + " that counted an odd number of edges plays the same octave inverted", + ("NodeSubOctave", "E2-", "P4"): + "as E1: the host reset restarts the divider's count", + ("NodeBiquad", "E2-", "P4"): + "a float32 recursion on looping input can settle one ulp off a" + " never-reset one and flip one LSB now and then for good", + ("NodeBiquad", "E6-", "P4"): + "as E2: one ulp of filter state after a move and back", +} + +import lifecycle # noqa: E402 +lifecycle.DECLARED.update(DECLARED) diff --git a/tests/test_node_matrix.py b/tests/test_node_matrix.py new file mode 100644 index 0000000..3e8e75d --- /dev/null +++ b/tests/test_node_matrix.py @@ -0,0 +1,141 @@ +"""The lifecycle matrix on the audiodsp nodes the effects stand on +(`tests/support/node_matrix.py`), on CPython and on the two native +interpreters. + +The same rules as `tests/test_lifecycle_matrix.py`: a red cell is either a +row of KNOWN_RED below (a finding, with its reason) or a declared exception +in `node_matrix.DECLARED` (a behaviour the node has by design); the file goes +red when a cell changes either way, and the three interpreters must print +the same line for every cell. + + OMP_NUM_THREADS=1 python -m unittest tests.test_node_matrix + LIFECYCLE_CLASSES=NodePitchShift python -m unittest tests.test_node_matrix +""" + +import os +import subprocess +import sys +import tempfile +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "lib")) +sys.path.insert(0, os.path.join(ROOT, "tests", "support")) + +import lifecycle # noqa: E402 +import node_matrix # noqa: E402 +from tests.test_lifecycle_matrix import (FULL, check_known, digest, # noqa: E402 + native_binaries, parse, red_cells) + +VENDOR = "PyDevices" + +#: What is red today, in the same shape as test_lifecycle_matrix.KNOWN_RED. +KNOWN_RED = {} + + +def names(): + wanted = os.environ.get("LIFECYCLE_CLASSES") + picked = wanted.split(",") if wanted else sorted(node_matrix.NODES) + return [n for n in picked if n in node_matrix.NODES] + + +def start_native(binary, picked, scratch): + env = dict(os.environ, MICROPYPATH="lib:tests/support", + GCOV_PREFIX=scratch, PYTHONDONTWRITEBYTECODE="1") + return subprocess.Popen( + [binary, "-X", "heapsize=256M", "tests/support/lifecycle.py"] + + list(picked) + ([] if FULL else ["--quick"]), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + cwd=ROOT, env=env) + + +class TestNodeMatrix(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.names = names() + cls.natives = native_binaries() + cls.scratch = tempfile.TemporaryDirectory() + cls.procs = {} + for family, binary in cls.natives.items(): + if binary is not None and cls.names: + cls.procs[family] = start_native(binary, cls.names, + cls.scratch.name) + cls.cpython = {} + for name in cls.names: + lines = [] + lifecycle.run_class(node_matrix.NODES[name], emit=lines.append, + quick=not FULL) + cls.cpython[name] = lines + cls.native_lines = {} + for family, proc in cls.procs.items(): + out, err = proc.communicate() + lines = out.splitlines() + if proc.returncode != 0 or not lines or lines[-1] != "DONE": + cls.native_lines[family] = AssertionError( + "%s: %s%s" % (family, out[-1500:], err[-1500:])) + else: + cls.native_lines[family] = lines + + @classmethod + def tearDownClass(cls): + cls.scratch.cleanup() + + def test_known_red_on_cpython(self): + problems = [] + for name in self.names: + problems += check_known(name, red_cells(parse(self.cpython[name])), + KNOWN_RED) + self.assertEqual(problems, [], "\n".join(problems[:60])) + + def _p6(self, family): + if self.natives.get(family) is None: + self.skipTest("no %s binary" % family) + got = self.native_lines[family] + if isinstance(got, Exception): + raise got + problems = [] + for name in self.names: + ours = parse(self.cpython[name]) + theirs = parse([l for l in got if l.startswith(name + "|")]) + for key in sorted(set(ours) | set(theirs)): + if ours.get(key) != theirs.get(key): + problems.append("%s: %s cpython %s, %s %s" % ( + family, "|".join(map(str, key)), ours.get(key), + family, theirs.get(key))) + self.assertEqual(problems, [], "\n".join(problems[:60])) + + def test_p6_micropython(self): + self._p6("micropython") + + def test_p6_circuitpython(self): + self._p6("circuitpython") + + +class TestAdapters(unittest.TestCase): + """An adapter can show a reset defect: one whose reset leaves the node + where it was instead of where a fresh one starts is red on P4 where the + clean adapter is green.""" + + def test_a_reset_that_keeps_state_is_red(self): + def cell(cls): + lines = [] + lifecycle.run_class(cls, rates=(48000,), channels=(2,), + patches=[None], only=["E1"], + emit=lines.append) + return parse(lines)[(cls.NAME, "E1-reset@block", 48000, 2, + "d")]["P4"] + + class KeepsState(node_matrix.NodeBiquad): + NAME = "NodeBiquadKeepsState" + + def reset(self): + self._check_live() + self._clear_nodes() + + self.assertEqual(cell(node_matrix.NodeBiquad), "ok") + self.assertTrue(cell(KeepsState).startswith("RED")) + + +if __name__ == "__main__": + unittest.main() From e793fada4fddce0db27b5a62b68557f4dfd288e7 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:29:01 -0500 Subject: [PATCH 02/76] test_node_matrix: drop an unused import --- tests/test_node_matrix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_node_matrix.py b/tests/test_node_matrix.py index 3e8e75d..2c7165b 100644 --- a/tests/test_node_matrix.py +++ b/tests/test_node_matrix.py @@ -24,7 +24,7 @@ import lifecycle # noqa: E402 import node_matrix # noqa: E402 -from tests.test_lifecycle_matrix import (FULL, check_known, digest, # noqa: E402 +from tests.test_lifecycle_matrix import (FULL, check_known, # noqa: E402 native_binaries, parse, red_cells) VENDOR = "PyDevices" From 644b68af40748341df92766dfd10f2d6f676181b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:44:34 -0500 Subject: [PATCH 03/76] The effects kit: tail, plant validity, claims, readback and arguments, input ceiling and paperwork checks Six mechanical checks on one class, each printing one line per cell and a SUMMARY per check. The four render checks are numpy-free and print the same lines on CPython, MicroPython and CircuitPython; the claims and paperwork checks read source and git, on CPython. kit_planted.py holds the tiny planted components the checks are proven on. --- tests/support/kit.py | 99 +++++++++ tests/support/kit_args.py | 368 +++++++++++++++++++++++++++++++++ tests/support/kit_ceiling.py | 183 ++++++++++++++++ tests/support/kit_claims.py | 238 +++++++++++++++++++++ tests/support/kit_common.py | 344 ++++++++++++++++++++++++++++++ tests/support/kit_options.py | 16 ++ tests/support/kit_paperwork.py | 212 +++++++++++++++++++ tests/support/kit_planted.py | 314 ++++++++++++++++++++++++++++ tests/support/kit_plants.py | 167 +++++++++++++++ tests/support/kit_tail.py | 160 ++++++++++++++ 10 files changed, 2101 insertions(+) create mode 100644 tests/support/kit.py create mode 100644 tests/support/kit_args.py create mode 100644 tests/support/kit_ceiling.py create mode 100644 tests/support/kit_claims.py create mode 100644 tests/support/kit_common.py create mode 100644 tests/support/kit_options.py create mode 100644 tests/support/kit_paperwork.py create mode 100644 tests/support/kit_planted.py create mode 100644 tests/support/kit_plants.py create mode 100644 tests/support/kit_tail.py diff --git a/tests/support/kit.py b/tests/support/kit.py new file mode 100644 index 0000000..529308a --- /dev/null +++ b/tests/support/kit.py @@ -0,0 +1,99 @@ +"""The effects kit: six 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 +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. +""" + +import sys + +if __name__ == "__main__": + sys.path.insert(0, "lib") + sys.path.insert(0, "tests/support") + sys.path.insert(0, "tests") + +import kit_common as kc + +CHECKS = ("paperwork", "claims", "args", "plants", "ceiling", "tail") +STATIC = ("paperwork", "claims") + + +def run_check(check, cls, emit=print, quick=False, plants_module=None, + claims_module=None, execute=True, name=None): + import kit_options + options = kit_options.options_for(cls.NAME) + if check == "tail": + import kit_tail + return kit_tail.run(cls, emit, quick=quick, name=name, + options=options) + if check == "plants": + import kit_plants + return kit_plants.run(cls, emit, module=plants_module, name=name, + options=options) + if check == "args": + import kit_args + return kit_args.run(cls, emit, quick=quick, name=name, + options=options) + if check == "ceiling": + import kit_ceiling + return kit_ceiling.run(cls, emit, quick=quick, name=name, + options=options) + if check == "claims": + import kit_claims + return kit_claims.run(cls, emit, module=claims_module, + execute=execute, name=name) + if check == "paperwork": + import kit_paperwork + return kit_paperwork.run(cls, emit, name=name) + raise ValueError(check) + + +def main(argv, only=None, emit=print): + names = [] + quick = False + execute = True + plants_module = claims_module = None + joined = [] + for arg in argv: + if joined and joined[-1] in ("--only", "--plants-module", + "--claims-module"): + joined[-1] += "=" + arg + else: + joined.append(arg) + for arg in joined: + if arg.startswith("--only="): + only = tuple(arg.split("=", 1)[1].split(",")) + elif arg == "--quick": + quick = True + elif arg == "--no-run": + execute = False + elif arg.startswith("--plants-module="): + plants_module = arg.split("=", 1)[1] + elif arg.startswith("--claims-module="): + claims_module = arg.split("=", 1)[1] + else: + names.append(arg) + only = only or CHECKS + for name in names: + cls = kc.resolve(name) + if cls is None: + emit("MISSING|%s" % name) + continue + for check in CHECKS: + if check not in only: + continue + if check in STATIC and not kc.CPYTHON: + emit("SKIP|%s|%s|CPython only" % (name, check)) + continue + run_check(check, cls, emit, quick, plants_module, claims_module, + execute) + emit("DONE") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tests/support/kit_args.py b/tests/support/kit_args.py new file mode 100644 index 0000000..41ae01e --- /dev/null +++ b/tests/support/kit_args.py @@ -0,0 +1,368 @@ +"""Readback and arguments: what the control surface does with the values a +host can hand it. + +Cells, all on an 8192-frame click and noise train then silence: + +- `rt-m//`: `set_macro(i, get_macro(i))` mid-stream + changes no byte of the render, at the defaults and at every patch. +- `stops-m`: 0, 64 and 127 are accepted and `get_macro` reads each + back within 1e-4. +- `bad-m`: NaN, +inf, -inf, -1 and 128. The contract does not say + whether an out-of-range macro value raises or clamps, so either passes: + a raise must be ValueError or TypeError and leave every macro where it + was (`r`); a clamp must read back a finite value in 0..127 (0 below the + range, 127 above it) and render exactly what `set_macro(i, readback)` + renders (`c`). +- `patch-p`: from a fresh instance, `program_change(k)` sets + `patch_index` and reads back the patch's values within 1e-4, and + `program_change(0)` reads back what the fresh instance read. +- `ctor-