From 96f0a645bf845fde784fd4c71be4100d27673210 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:00:23 -0600 Subject: [PATCH 1/3] Fuzz and DeEsser: Mix 0 and Range 0 walk there and back Fuzz cut to its source at Mix 0 and cut back in, and DeEsser's Range 0 did the same, with a 256-frame lump of the old graph on the way back. Both now walk to the dry over 20 ms before the port switches, and walk back in from it. Fuzz's germanium chain has no mixer, so it walks on its stages' own mix; at an off-centre Bias it still comes back at once, since a walk would let the shapers' offset through. DeEsser lets its Splitters drop what they held and primes its voices afresh on the way back. The lifecycle matrix: Fuzz's Mix 0 step cells (36) and DeEsser's two Range 0 steps at 22.05 kHz are green. Fuzz's walk-in differs from a fresh instance while its output pole settles, and DeEsser's by one LSB in the block after the walk; both are declared. --- lib/audioeffects/_component.py | 9 +- lib/audioeffects/deesser.py | 30 ++++++- lib/audioeffects/fuzz.py | 139 ++++++++++++++++++++++++++++++- tests/support/lifecycle_known.py | 10 ++- 4 files changed, 181 insertions(+), 7 deletions(-) diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index b27ccbc..d679210 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -1170,7 +1170,8 @@ def _bypass_walk(self): """Frames a Mix 0 bypass waits for the graph's walk to the dry before the port switches, or 0 where it switches at once: a class that does not walk, a mixer without `level_ramp`, or no Port.""" - if not (self.WALKS_TO_BYPASS and LEVELS_RAMP and PORT is not None): + if not (self.WALKS_TO_BYPASS and self._walks_levels() + and PORT is not None): return 0 # Whole blocks: a switch a block's part past a move on a block # boundary would leave the head holding part of a source buffer, @@ -1178,6 +1179,12 @@ def _bypass_walk(self): frames = level_ramp_frames(self._sample_rate) + BYPASS_MARGIN_FRAMES return -(-frames // BYPASS_MARGIN_FRAMES) * BYPASS_MARGIN_FRAMES + def _walks_levels(self): + """Whether the levels the walk to the dry moves walk: the mixers' + on this interpreter (`ramp_levels`). A class that walks on its + nodes' own `mix` instead says so here.""" + return bool(LEVELS_RAMP) + def _land_bypass(self): """Switch the port now to a bypass `_route_around` left waiting: a reset cuts anyway, and starts from the state it leaves, so nothing diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py index 97855d6..9c6c41d 100644 --- a/lib/audioeffects/deesser.py +++ b/lib/audioeffects/deesser.py @@ -234,6 +234,9 @@ class DeEsser(_component.Component): #: coupling state" in docs/reference/component-api.md. TAIL_SAMPLES = 256 + #: Range 0 walks the output mixer to the dry before the port switches. + WALKS_TO_BYPASS = True + MACRO_LABELS = ("Frequency", "Range", "Sensitivity", "Mode", "Release", "Attack", "Listen") MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", 3: "TOGGLE", @@ -522,10 +525,31 @@ def _refresh_output(self): if not self._ready: return if self._is_wire(): - self._output = self._adapter + # The output mixer walks to the dry alone, and the port switches + # to the adapter once the walk has landed. + self._route_around(self._adapter) return + # Back off a bypass the walk reached, the graph still holds what it + # had when Range went to 0: the Splitters' rings, the filters' and + # the gain cell's memory, the block each voice had queued. Played + # on, that was a 256-frame lump on the way back. So the rings let + # go of those frames, which the host has read past since, and every + # voice is primed afresh, its levels walking in from the dry. + if self._rejoin(): + top, raw, band = self._splitters + if self._let_go_of_source(top, self._adapter): + raw.clear() + band.clear() + for node, tap in ((self._lows[0], top.tap(1)), + (self._highs[0], top.tap(2))): + node.play(tap) + self._duck.key(top.tap(0)) + self._primed = False if not self._primed: - self._prime() + voices = self._out.voice + self._rearm_from(((voices[0], 1.0), (voices[1], 0.0), + (voices[2], 0.0), (voices[3], 0.0)), + self._prime) self._output = self._tail # -- reset --------------------------------------------------------- @@ -579,6 +603,8 @@ def reset(self): self._kept_adapter = None self._hand_up_lazy(pull) self._stranded = False + self._bypass_pending = None + self._walked_out = False for node, release in reversed(old): if release is False: continue diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py index 0565c68..a0280ef 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -604,6 +604,33 @@ def _group_delay_samples(oversample): return int(round(delay)) +class _MixAsLevel: + """A node's `mix` under the name a mixer voice gives its level, so the + walk to the dry and back can hold it the way it holds a voice. A Biquad + and a Waveshaper walk a `mix` move over 20 ms. The Waveshaper takes its + `mix` through `set()` and doesn't hand it back, so the last value set is + kept here.""" + + def __init__(self, node, level=1.0): + self._node = node + self._level = level + + @property + def level(self): + try: + return self._node.mix + except AttributeError: + return self._level + + @level.setter + def level(self, value): + self._level = value + try: + self._node.mix = value + except AttributeError: + self._node.set(mix=value) + + class Fuzz(_component.Component): """Fuzz Face germanium, or a Ram's Head 1973 Big Muff as `cascade`. @@ -738,6 +765,7 @@ def _build(self, fuzz_db=36.0, level=1.0, tone=0.5, bias=0.0, self._wet_delay = _group_delay_samples(oversample) self._latency = self._wet_delay if mix > 0.0 else 0 self._routed_lean = False + self._wet_mix = 1.0 self._ready = False # The class keeps a reference to the table it loaded. `Waveshaper` @@ -1040,6 +1068,7 @@ def _refresh(self): for shaper in self._shapers: shaper.set(pre_gain=fuzz_gain, bias=bias, mix=wet_mix, post_gain=post) + self._wet_mix = wet_mix if self._ready and bias != self._charged_bias: self._charge_output(rewire=True) else: @@ -1062,10 +1091,18 @@ def _refresh_output(self): construction rather than by luck - which is what the `MidSide` tail stopped being when the pin moved to audiodsp `3388df4` (seven samples of 16384 off by one LSB). + + The way there and back walks. At Mix 0 the graph walks to the + source over 20 ms (`_at_dry`), and the port switches to the source + itself once the walk has landed; back off Mix 0 the graph comes back + as it was built and walks in from the source. """ if not self._ready: return if self._value(5) <= 0.0: + if not self._constructing: + for stage, at in self._at_dry(): + stage.level = at self._route_around(self._source) self._latency = 0 else: @@ -1075,10 +1112,110 @@ def _refresh_output(self): # the host has since read past. self._let_go_of_source(self._head, self._source, self._head_consumers) - self._rearm() + self._walk_back_in() + else: + self._wet_again() self._output = self._tilt self._latency = self._wet_delay + WALKS_TO_BYPASS = True + + def _at_dry(self): + """What makes the graph the source itself, as (stage, level) pairs. + + On `germanium` the chain is in series, so every node in it passes + its input on: both coupling sections, the shapers and the tilt shelf + at `mix` 0. On `cascade` the dry voice alone, at unity, and the tilt + shelf at `mix` 0.""" + if self._tone_mix is None: + stages = [_MixAsLevel(self._hps[0])] + stages += [_MixAsLevel(shaper, self._wet_mix) + for shaper in self._shapers] + stages += [_MixAsLevel(self._hps[1]), _MixAsLevel(self._tilt)] + return [(stage, 0.0) for stage in stages] + voices = self._tone_mix.voice + return [(voices[0], 0.0), (voices[1], 0.0), (voices[2], 1.0), + (_MixAsLevel(self._tilt), 0.0)] + + def _walks_levels(self): + # `germanium` walks on its nodes' own `mix`, which audiodsp's + # Biquad and Waveshaper walk on every interpreter. + if self._tone_mix is None: + return True + return _component.Component._walks_levels(self) + + def _wet_again(self): + """Put back what `_at_dry` took away, where the graph played on: a + Mix 0 that comes back before its walk landed walks back from where + it stands. `_refresh` has set the shapers and the voices.""" + self._tilt.mix = 1.0 + if self._tone_mix is None: + self._hps[0].mix = 1.0 + self._hps[1].mix = 1.0 + + def _walk_back_in(self): + """Arm the graph as `_build` does and walk it in from the source. + + Off a bypass that walked out, every stage `_at_dry` names starts the + first block at its dry level and walks to where the controls stand, + so the output leaves the source without a step. A bypass reached + without a walk (a reset, or a Mix 0 the host never heard), and a + germanium graph at an off-centre Bias, come back as a fresh instance + does, at once.""" + walked, self._walked_out = self._walked_out, False + # Off-centre Bias the shapers answer silence with a constant that + # only the charged output pole holds back. Walking the pole's mix + # in would let a quarter of it through on the way, 6847 LSB out of + # silence at patch 2, so there the graph comes back at once. + offset = self._tone_mix is None and self._value(3) != 0.0 + if self._rearming_reset or not walked or offset: + self._wet_again() + self._rearm() + return + if self._tone_mix is not None: + self._wet_again() + held = self._at_dry() + wanted = [(stage, stage.level) for stage, _at in held] + for stage, at in held: + stage.level = at + self._prime_on_quiet(self._tilt, self._tone_mix) + _component.with_ramps(self._rearm) + for stage, level in wanted: + stage.level = level + return + # germanium. The output pole is charged on the offset the shapers + # give at the controls' own mix, with the pole itself held at the + # dry; then the shapers are cleared and primed at the dry, and the + # first coupling section and the tilt shelf, cleared since the + # bypass, prime at the dry on silence too. + hp_a, hp_b = self._hps + hp_b.mix = 0.0 + self._charge_output() + for shaper in self._shapers: + audiocore.reset_buffer(shaper) + shaper.set(mix=0.0) + self._pull_node(shaper) + for node, source in self._charge_wiring: + node.play(source) + hp_a.mix = 0.0 + self._prime_on_quiet(hp_a, self._source) + self._prime_on_quiet(self._tilt, hp_b) + hp_a.mix = 1.0 + for shaper in self._shapers: + shaper.set(mix=self._wet_mix) + hp_b.mix = 1.0 + self._tilt.mix = 1.0 + + def _prime_on_quiet(self, node, source): + """Pull one block of silence through `node` alone, so its `mix` + stands where it is set now, and point it back at `source`.""" + held = node.mix + node.mix = 0.0 + node.play(self._quiet) + self._pull_node(node) + node.play(source) + node.mix = held + def _rearm(self): """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. diff --git a/tests/support/lifecycle_known.py b/tests/support/lifecycle_known.py index 9a470a9..d0a7810 100644 --- a/tests/support/lifecycle_known.py +++ b/tests/support/lifecycle_known.py @@ -38,6 +38,10 @@ "state that outlives tail_samples: a detector re-attacking after a" " reset or a rejoin, or a filter's or coupling pole's residue a few" " LSB past the declared tail (audiocomponents#155)") +R_FUZZ_WALK_IN = ( + "Fuzz walks back from Mix 0 on its nodes' own mix, so its output pole" + " hears the walk: it meets a fresh instance as that pole settles, from" + " a few hundred LSB just after the walk to under 20 within 100 ms") R_FUZZ_LEAN = ( "the lean shaper follows the patch, so macros moved back after a move" " to or from Fuzz - lean stay at that patch's oversampling; with the" @@ -112,7 +116,8 @@ ('Compressor', 'E6-', 'P4'): (150, 'fa3d35a7', None, None, R_STATE), ('Compressor', 'E6-', 'P5'): (162, '8c2759f9', None, None, R_JUMP), ('Compressor', 'E9-', 'P4'): (156, '02b3f3c5', None, None, R_STATE), - ('DeEsser', 'E4-', 'P5'): (10, 'c323d9b3', None, None, R_JUMP), + ('DeEsser', 'E4-', 'P5'): (8, '7cf53fdf', None, None, R_JUMP), + ('DeEsser', 'E5-mix0', 'PR'): (6, 'a56b79fd', None, None, R_STATE), ('DeEsser', 'E5-mix0', 'P4'): (2, '8051e9a5', None, None, R_STATE), ('DeEsser', 'E9-', 'P4'): (8, '2156c340', None, None, R_STATE), ('DeEsser', 'E9-src256', 'P4'): (2, 'a589814f', None, None, R_STATE), @@ -163,13 +168,12 @@ ('Fuzz', 'E4-', 'P5'): (4, 'eb979d08', None, None, R_JUMP), ('Fuzz', 'E5-mix0', 'P3'): (4, '785ac763', None, None, R_FUZZ_WANDER), ('Fuzz', 'E5-mix0', 'P4'): (36, '70f1f6b6', None, None, R_STATE), - ('Fuzz', 'E5-mix0', 'P5'): (10, '0df66d65', None, None, R_JUMP), + ('Fuzz', 'E5-mix0', 'PR'): (36, '70ed1de8', None, None, R_FUZZ_WALK_IN), ('Fuzz', 'E6-', 'P3'): (8, 'f027b35d', None, None, R_FUZZ_WANDER), ('Fuzz', 'E6-', 'P4'): (89, '6d78d82a', None, None, R_FUZZ_LEAN), ('Fuzz', 'E6-', 'P5'): (24, 'abac2451', None, None, R_JUMP), ('Fuzz', 'E9-', 'P3'): (16, 'a4652cfe', None, None, R_FUZZ_WANDER), ('Fuzz', 'E9-', 'P4'): (192, '1cf6be45', None, None, R_STATE), - ('Fuzz', 'E9-', 'P5'): (26, '95969050', None, None, R_JUMP), ('GraphicEQ', 'E11-', 'P5'): (4, '1805e152', None, None, R_JUMP), ('GraphicEQ', 'E2-reset_buffer', 'P4'): (12, '17431038', None, None, R_STATE), ('GraphicEQ', 'E4-', 'P5'): (54, 'f206751b', None, None, R_JUMP), From f9dc941b9cb6959449a6b3c0c1ba6d593a879e7b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:10:04 -0600 Subject: [PATCH 2/3] AUDIODSP_PIN: ec82463, where Multiply walks a mix move; Tremolo's Depth 0 walks audiodsp#411 walks audiomath.Multiply's mix over 20 ms. Tremolo sets that mix to take Depth to 0 and back, which stepped the output 21 times the material's own steepest step at 48 kHz and 32 times at 22.05 kHz; it now steps 1.07 and 1.0 times. RingMod's Mix walks for the same reason: 176 of its step cells in the lifecycle matrix are green. The lifecycle matrix was rerun at the new pin for every class that uses Multiply; only RingMod's rows moved. The roadmap and control-smoothing.md now name what still steps. --- AUDIODSP_PIN | 10 +++++++++- ROADMAP.md | 6 +++--- docs/how-it-works/control-smoothing.md | 12 +++++++----- tests/support/lifecycle_known.py | 4 +--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index af06d36..9bfb4bc 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -482,5 +482,13 @@ # CircuitPython main's widened synthio biquad, MP3 decoding on CPython, type # stubs for thirteen modules, and pump and build changes for boards. # +# Moved to ec82463 on 2026-11-10, for audiomath.Multiply's 20 ms walk of a +# `mix` move (audiodsp#411), which takes Tremolo's Depth to 0 and back +# without a step. What else moved beneath the components, by +# `git log 84b8b45..ec82463 -- src`: type stubs for the remaining modules, +# CircuitPython's -Werror=float-equal on the level ramps, a pump that can +# render from an interrupt, and `__version__`/`__revision__` on audiocore, +# audiomp3, audiopump and synthio. +# # -- ref is what CI installs (git+https://...@) -84b8b45f5b838afe3529f53e1468abe7711b6bcc 84b8b45f5b838afe3529f53e1468abe7711b6bcc +ec824639bb6bec929740272e500e97c1f423a990 ec824639bb6bec929740272e500e97c1f423a990 diff --git a/ROADMAP.md b/ROADMAP.md index 43f9fa9..5887ef2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,9 @@ way on every interpreter and board. ## Next -- Control moves that ramp where they still step: Fuzz back from Mix 0, - Tremolo's Depth, DeEsser's Range at 22.05 kHz, then filter corners, then - crossfaded patch changes. [The policy](docs/how-it-works/control-smoothing.md). +- Control moves that ramp where they still step: filter corners, an effect + built at Mix 0 coming in the first time, then crossfaded patch changes. + [The policy](docs/how-it-works/control-smoothing.md). ## Later diff --git a/docs/how-it-works/control-smoothing.md b/docs/how-it-works/control-smoothing.md index a1311b0..f143e86 100644 --- a/docs/how-it-works/control-smoothing.md +++ b/docs/how-it-works/control-smoothing.md @@ -9,9 +9,9 @@ Phaser, Saturation, Tremolo and Vibrato, with the exceptions below. The Mix of DigitalDelay and PingPongDelay and SlapbackDelay's Level walk too, since their delay node does. -A few moves still step. Fuzz coming back from Mix 0 cuts in, Tremolo's Depth -jumps, a filter corner moves at once, and so does a patch change that rebuilds -part of a graph. Until those walk too, send such a jump as small steps, one a +A few moves still step. A filter corner moves at once, and so do a switch, a +patch change that rebuilds part of a graph, and an effect built at Mix 0 the +first time its Mix leaves 0. Until those walk too, send such a jump as small steps, one a block: the delays' own tests show Mix moved in 127 steps, one a block, steps the output by less than one and a half times the steepest step of the tone going through it. The rest is on the @@ -28,7 +28,8 @@ clicking. Mix 0 is still your signal, byte for byte. An effect that hands you the source itself there walks its graph to the dry first and switches once the -walk has landed, and walks back in from the dry when Mix leaves 0. +walk has landed, and walks back in from the dry when Mix leaves 0. DeEsser's +Range 0 works the same way, and Tremolo's Depth 0 walks there and back too. A delay's Time is not ramped here: it already glides, at the rate its Glide control sets. A filter corner that jumps steps the output too, more quietly @@ -49,7 +50,8 @@ reaches anyone using the bare nodes too, CircuitPython users included. The nodes that walk are `audiomixer.Mixer`'s voice levels (its `level_ramp`), the Waveshaper's `mix` and `post_gain`, the Biquad's and the AllPass's `mix`, -the Dynamics node's makeup and the FeedbackDelay's `mix`. An +the Dynamics node's makeup, the FeedbackDelay's `mix` and +`audiomath.Multiply`'s `mix`. An `audioroute.Port` told `play(source, after=frames)` keeps playing what it plays until a walk has landed, which is how Mix 0 switches to the source without a cut. diff --git a/tests/support/lifecycle_known.py b/tests/support/lifecycle_known.py index d0a7810..aa5e2f8 100644 --- a/tests/support/lifecycle_known.py +++ b/tests/support/lifecycle_known.py @@ -259,11 +259,9 @@ ('RingMod', 'E11-', 'P4'): (16, 'fb67e9fc', None, None, R_PHASE), ('RingMod', 'E11-', 'P5'): (8, '739846f9', None, None, R_JUMP), ('RingMod', 'E4-', 'P4'): (8, '97e3e000', None, None, R_PHASE), - ('RingMod', 'E4-', 'P5'): (30, '289d459f', None, None, R_JUMP), - ('RingMod', 'E5-mix0', 'P5'): (28, 'c504653d', None, None, R_JUMP), + ('RingMod', 'E4-', 'P5'): (22, '60c2ddf8', None, None, R_JUMP), ('RingMod', 'E6-', 'P4'): (96, '862d0a32', None, None, R_PHASE), ('RingMod', 'E6-', 'P5'): (44, 'aefba1ae', None, None, R_JUMP), - ('RingMod', 'E9-', 'P5'): (140, 'f09d9cee', None, None, R_JUMP), ('Saturation', 'E11-', 'P5'): (8, 'd037b49c', None, None, R_JUMP), ('Saturation', 'E4-', 'P5'): (28, 'b87df81b', None, None, R_JUMP), ('Saturation', 'E6-', 'P5'): (94, 'c9c25f1c', None, None, R_JUMP), From 467fe283bc705de498621c2277c4d505941d76a1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:30:39 -0600 Subject: [PATCH 3/3] Fuzz: the shapers' Mix walk reaches them on CPython too A plain mix assignment landed on the Python-level Waveshaper as an attribute, so on CPython the shapers stayed wet through the walk while MicroPython walked them, and the two printed different lifecycle lines. --- lib/audioeffects/fuzz.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py index a0280ef..2ff7862 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -609,26 +609,31 @@ class _MixAsLevel: walk to the dry and back can hold it the way it holds a voice. A Biquad and a Waveshaper walk a `mix` move over 20 ms. The Waveshaper takes its `mix` through `set()` and doesn't hand it back, so the last value set is - kept here.""" + kept here. Which one a node is gets asked once: a plain attribute + assignment would land on a Python-level node without reaching it.""" def __init__(self, node, level=1.0): self._node = node self._level = level + try: + node.mix + self._by_set = False + except AttributeError: + self._by_set = True @property def level(self): - try: - return self._node.mix - except AttributeError: + if self._by_set: return self._level + return self._node.mix @level.setter def level(self, value): self._level = value - try: - self._node.mix = value - except AttributeError: + if self._by_set: self._node.set(mix=value) + else: + self._node.mix = value class Fuzz(_component.Component):