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/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..2ff7862 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -604,6 +604,38 @@ 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. 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): + if self._by_set: + return self._level + return self._node.mix + + @level.setter + def level(self, value): + self._level = value + if self._by_set: + self._node.set(mix=value) + else: + self._node.mix = value + + class Fuzz(_component.Component): """Fuzz Face germanium, or a Ram's Head 1973 Big Muff as `cascade`. @@ -738,6 +770,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 +1073,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 +1096,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 +1117,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..aa5e2f8 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), @@ -255,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),