diff --git a/AUDIODSP_PIN b/AUDIODSP_PIN index 0bd5873..af06d36 100644 --- a/AUDIODSP_PIN +++ b/AUDIODSP_PIN @@ -470,5 +470,17 @@ # AirSpace's get_buffer digests are re-read. A sampler voice that sets none # of the new keywords renders the bytes it did. # +# Moved to 84b8b45 on 2026-11-10, for the level ramps (audiodsp#400): the +# Mixer's `level_ramp`, the 20 ms walk of a Mix, post_gain or makeup move in +# the Waveshaper, the Biquad, the AllPass, the Dynamics node and the +# FeedbackDelay, and `audioroute.Port.play(after=)`, which the effects use so +# a jump of Mix or a level walks instead of stepping. The walks +# change what a moved control renders and nothing else: a node's first value +# lands at once, so a fresh instance renders the bytes it did. What else +# moved beneath the components, by `git log 0c0824e..c0e7c61 -- src`: new +# audiograph nodes and Graph.carry and swap, audiograph's BIQUAD_RAMP, +# CircuitPython main's widened synthio biquad, MP3 decoding on CPython, type +# stubs for thirteen modules, and pump and build changes for boards. +# # -- ref is what CI installs (git+https://...@) -0c0824e3966998613d8c5314cefb1d752bc70693 0c0824e3966998613d8c5314cefb1d752bc70693 +84b8b45f5b838afe3529f53e1468abe7711b6bcc 84b8b45f5b838afe3529f53e1468abe7711b6bcc diff --git a/ROADMAP.md b/ROADMAP.md index 684d2b7..13b83d5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,9 +5,9 @@ way on every interpreter and board. ## Next -- Control moves that ramp instead of stepping: Mix, levels and depths over - 20 ms in audiodsp's nodes, then filter corners, then crossfaded patch - changes. [The policy](docs/how-it-works/control-smoothing.md). +- Control moves that ramp instead of stepping where they still step: Fuzz's + way back from Mix 0 and Tremolo's Depth, then filter corners, 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 704be6b..a1311b0 100644 --- a/docs/how-it-works/control-smoothing.md +++ b/docs/how-it-works/control-smoothing.md @@ -1,37 +1,42 @@ # Smoothing control moves -When a control jumps, the effects hand the new value to their nodes at once, -and the output steps. You hear a click. That happens on a patch change, on a -MIDI value that leaps, or when a host sets Mix from 0.3 to 1.0 in one call. A -knob turned by hand sends many small moves, so it sounds smooth already. - -If you need a jump smooth, send it 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 of this page is the policy planned for the effects, so that a jump -stops clicking by itself. None of it is built in the effects here yet. -audiodsp's `audiograph` `BIQUAD` node already walks to new coefficients over -`BIQUAD_RAMP` milliseconds, 20 by default, but these effects drive -`audiobiquad` directly. It is on the +When you jump a level or a Mix, these effects walk the output to the new +setting over 20 ms instead of stepping there, so the jump doesn't click. That +covers a patch change's levels, a MIDI value that leaps, and a host setting +Mix from 0.3 to 1.0 in one call: Bitcrusher, Chorus, CombFilter, Compressor, +DeEsser, Distortion, DynamicEQ, Exciter, Fuzz, MultibandCompressor, Overdrive, +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 +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 [roadmap](https://github.com/PyDevices/audioeffects/blob/main/ROADMAP.md). -## Which controls will ramp, and for how long +## Which controls ramp, and for how long -Every control that scales a signal will ramp: Mix, Level and Volume, a wet or dry -gain, and a modulation depth. A move will ramp in a straight line over 20 ms, the -time `audioecho.FeedbackDelay` already takes for its wow depth. That is short -enough to sound like the knob you turned. Whether it is long enough to take -the click out of every jump is the step property's to say (below), once the -ramp is built. +Every control that scales a signal ramps: Mix, Level, Volume, Output and +Makeup, a band's gain, a wet or dry gain, and a modulation depth. A move +walks in a straight line over 20 ms, the time `audioecho.FeedbackDelay` +already takes for its wow depth. That is short enough to sound like the knob +you turned, and the lifecycle matrix's step property (below) holds it to not +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. 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 than a level does; it comes after the levels, with the node moving its coefficients across the same 20 ms. -A switch, or a patch change that rebuilds part of a graph, crossfades from the -old graph to the new over the same 20 ms instead of cutting. +A switch, or a patch change that rebuilds part of a graph, will crossfade from +the old graph to the new over the same 20 ms instead of cutting. ## Where the ramp lives @@ -42,8 +47,12 @@ only if the host calls something every block. Built into the node, the ramp costs a multiply a sample, runs on every interpreter and every board, and reaches anyone using the bare nodes too, CircuitPython users included. -Mix comes first, because it is the control most likely to be jumped and the -loudest when it is. +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 +`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. ## When it is done diff --git a/lib/audioeffects/_component.py b/lib/audioeffects/_component.py index 5e2fc11..b27ccbc 100644 --- a/lib/audioeffects/_component.py +++ b/lib/audioeffects/_component.py @@ -517,7 +517,7 @@ def _drain(pull, mixer): the rest to play first, and the output started half a block late.""" total = 0 for _ in range(64): - state, data = pull(mixer) + state, data = unramped_pull(pull, mixer) frames = len(data) // (2 * mixer.channel_count) if data else 0 if not frames: return @@ -537,10 +537,113 @@ def release(node): call() +# -------------------------------------------------------------------------- +# Level ramps + +#: How long a mixer voice's level move takes, in milliseconds: the time a +#: control's jump is spread over so it does not click +#: (docs/how-it-works/control-smoothing.md). +LEVEL_RAMP_MS = 20 + + +def ramp_levels(mixer, sample_rate): + """Make `mixer`'s level and pan moves ramp over `LEVEL_RAMP_MS`. + + audiodsp's `audiomixer.Mixer` walks a voice from its old level to a new + one over `level_ramp` frames. Without it a level move waits for the + voice's next zero crossing and lands at once, and two voices crossfading + (a dry and a wet path) cross zero at different moments, so a jump of Mix + steps the output. A voice's first level still lands at once, so a + class's first block is what it was. CircuitPython's own Mixer has no + `level_ramp`; there the levels step as before. + + Args: + mixer: an `audiomixer.Mixer`. + sample_rate: its rate, in Hz. + + Returns: + `mixer`, so a constructor can wrap the call. + """ + global LEVELS_RAMP + try: + mixer.level_ramp = level_ramp_frames(sample_rate) + LEVELS_RAMP = True + except AttributeError: + LEVELS_RAMP = False + return mixer + + +#: Whether this interpreter's mixers walk a level move (`ramp_levels` +#: found `level_ramp`), or None before any class has asked. +LEVELS_RAMP = None + + +def level_ramp_frames(sample_rate): + """`LEVEL_RAMP_MS` in frames at `sample_rate`, rounded to the nearest.""" + return (int(sample_rate) * LEVEL_RAMP_MS + 500) // 1000 + + +#: How far past a level ramp a Mix 0 route-around waits before the port +#: switches: one block, for a walk that starts a block down the graph. +BYPASS_MARGIN_FRAMES = 256 + + +def _hold(held): + """Stand each voice in `held`, a list of (voice, level), at its level; + returns what they were set to, for `_let_go`.""" + wanted = [(voice, voice.level) for voice, _level in held] + for voice, level in held: + voice.level = level + return wanted + + +def _let_go(wanted): + """Hand the voices `_hold` held the levels they were set to: the next + pull walks to them.""" + for voice, level in wanted: + voice.level = level + + # -------------------------------------------------------------------------- # Level gates +#: While True, `unramped_pull` leaves a mixer's ramp on: a graph walking +#: back in from a bypass (`Component._rearm_from`) pulls its silence with +#: the walk primed where the bypass left it. +_KEEP_RAMPS = [False] + + +def unramped_pull(pull, node): + """`pull(node)` with `node`'s `level_ramp` off for that one pull. + + The pulls a class makes on its own - opening the level gates, handing + the lazy silence up - are not the host's audio. Rendered with the ramp + on they would prime it, and a level set after them and before the + host's first pull would walk instead of landing where a fresh instance + built there starts. With the ramp off for them, a voice's next level + lands at once, as a fresh mixer's first one does.""" + ramp = 0 if _KEEP_RAMPS[0] else getattr(node, "level_ramp", 0) + if not ramp: + return pull(node) + node.level_ramp = 0 + try: + return pull(node) + finally: + node.level_ramp = ramp + + +def with_ramps(call, *args): + """`call(*args)` with the pulls it makes leaving the level ramps on (see + `unramped_pull`).""" + keep = _KEEP_RAMPS[0] + _KEEP_RAMPS[0] = True + try: + return call(*args) + finally: + _KEEP_RAMPS[0] = keep + + def open_level_gates(node, voices, silence): """Render one block of silence through `node` so every voice starts at its level instead of at zero. Returns whether it could. @@ -573,7 +676,7 @@ def open_level_gates(node, voices, silence): return False for voice in voices: voice.play(silence, loop=True) - pull(node) + unramped_pull(pull, node) return True @@ -1020,8 +1123,98 @@ def _route_around(self, bypass): """ if not self._constructing: self._stranded = True + port = self._port + walk = self._bypass_walk() + if walk and not self._constructing and port is not None: + if port.source is bypass: + # A write at Mix 0 after the switch: the bypass stays, and + # so does whether the graph walked out to it. + self._output = bypass + return + if self._bypass_pending is bypass and self._walked_out: + # Already waiting for the walk to land. + return + # The graph walks its levels to a wire over `LEVEL_RAMP_MS`, so + # the port plays it on until the walk has landed and switches + # then, instead of cutting from the wet to the dry. + self._bypass_pending = bypass + port.play(bypass, after=walk) + # A port nothing has pulled switches at once: nothing walked. + self._walked_out = port.source is not bypass + return + self._bypass_pending = None + self._walked_out = False self._output = bypass + #: A class sets this when its graph, at Mix 0, walks to the source it + #: routes around: its mixer levels to the dry alone, on ramped mixers + #: (`ramp_levels`). `_route_around` then lets the port switch once the + #: walk has landed rather than at once. Mix 0 is still the source, byte + #: for byte, from then on. + WALKS_TO_BYPASS = False + + #: The bypass `_route_around` has asked the port to switch to once the + #: walk lands, or None. + _bypass_pending = None + #: Whether the last bypass was reached by a walk the host heard, so + #: the way back walks in from it (`_rearm_from`). + _walked_out = False + #: Set while `_reset_rearmed` re-arms: a reset is a fresh instance, + #: and its levels do not walk in. + _rearming_reset = False + #: Voices `_rearm_from` holds at a bypass's levels until the lazy + #: arming has pulled its silence through: (mixer, levels, wanted). + _walk_in = () + + 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): + 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, + # which the port, playing the source itself, then skips. + frames = level_ramp_frames(self._sample_rate) + BYPASS_MARGIN_FRAMES + return -(-frames // BYPASS_MARGIN_FRAMES) * BYPASS_MARGIN_FRAMES + + 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 + walks back in from it either.""" + self._walked_out = False + pending = self._bypass_pending + if pending is not None: + self._bypass_pending = None + self._output = pending + + def _rearm_from(self, held, rearm): + """Re-arm a graph coming back off a walked bypass so its levels + walk in from the bypass's. + + `rearm` (a class's `_rearm`) pulls a block of silence through the + mixer to open its level gates, and a lazily armed class pulls it + again to hand its primed silence up. On a ramped mixer + (`ramp_levels`) a level set before those pulls would walk away on + the silence. So each voice in `held`, a list of (voice, level) + with the levels that make the mixer the source the bypass played, + stands at its level through them, and is handed back the level it + was set to after: the first pull of real audio walks from the + bypass to it. Where the bypass did not walk this is `rearm()`. + """ + walked, self._walked_out = self._walked_out, False + if self._rearming_reset or not walked: + # A reset comes back as a fresh instance, at its levels. + rearm() + return + if self._arming_lazily: + # The lazy hand-up pulls again, after any later control has set + # the levels: it holds them there too (`_hold_levels`). + self._walk_in = list(self._walk_in) + [held] + wanted = _hold(held) + with_ramps(rearm) + _let_go(wanted) + def _rejoin(self, keep=()): """The graph `_route_around` left is about to be pulled again. @@ -1039,6 +1232,13 @@ def _rejoin(self, keep=()): if not self._stranded: return False self._stranded = False + pending = self._bypass_pending + self._bypass_pending = None + if pending is not None and self._port.source is not pending: + # The switch `_route_around` left waiting never came: the graph + # played on, and holds nothing stale. The caller's re-point + # cancels the switch. + return False self._clear_nodes(keep) return True @@ -1358,6 +1558,7 @@ def reset(self): """Clear every node this class built, tail first, and restore patch 0. The borrowed source is never named here.""" self._check_live() + self._land_bypass() self._clear_nodes() # Everything is clear now, so nothing is stranded: patch 0 coming # back off a bypass is the reset's own re-arm, not a second clear. @@ -1490,7 +1691,7 @@ def _pull_node(self, node): where neither way exists (no `audioroute`).""" pull = getattr(audiocore, "get_buffer", None) if pull is not None: - return pull(node) + return unramped_pull(pull, node) if PORT is None: return False scratch = getattr(self, "_puller", None) @@ -1510,7 +1711,8 @@ def _pull_node(self, node): scratch = self._puller = (mixer, shim) mixer, shim = scratch shim.play(node) - mixer.voice[0].play(shim) + # The same pull as `get_buffer`'s, so the same ramp rule. + unramped_pull(lambda _: mixer.voice[0].play(shim), node) mixer.voice[0].stop() return None @@ -1544,6 +1746,7 @@ def _reset_rearmed(self, rearm, primed=(), building=False): # The frames the graph's head has taken are old audio only if a # bypass left the graph un-pulled before the reset # (`_let_go_of_source`). + self._land_bypass() read_past = self._stranded self._clear_nodes() for node in self._nodes: @@ -1580,11 +1783,13 @@ def _reset_rearmed(self, rearm, primed=(), building=False): pull = getattr(audiocore, "get_buffer", None) self._arming_lazily = PORT is not None self._source_read_past = read_past + self._rearming_reset = True try: rearm() finally: self._arming_lazily = False self._source_read_past = True + self._rearming_reset = False # Settings that route around the graph leave it as `_build` leaves # a graph it routes around: cleared and not played since, so not # stranded. A later Mix move then arms it as a fresh instance's @@ -1593,17 +1798,20 @@ def _reset_rearmed(self, rearm, primed=(), building=False): pending = self._lazy_pending self._lazy_pending = [] if pending: - # The graph as it stands now: a patch that builds part of it - # again has new mixers. - for node in list(self._nodes): - if getattr(node, "voice", None) is None: - continue - if pull is not None: - _drain(pull, node) - else: - self._pull_node(node) + def drains(): + # The graph as it stands now: a patch that builds part of + # it again has new mixers. + for node in list(self._nodes): + if getattr(node, "voice", None) is None: + continue + if pull is not None: + _drain(pull, node) + else: + self._pull_node(node) + self._hold_levels(drains) for _voice, port, source in pending: port.play(source) + self._walk_in = () def _hand_up_lazy(self, pull): """The constructor's half of the lazy arming: pull each mixer @@ -1615,27 +1823,43 @@ def _hand_up_lazy(self, pull): pending = self._lazy_pending self._lazy_pending = [] if not pending: + self._walk_in = () return - # The graph as it stands now (a snapshot: `_pull_node` may own its - # scratch mixer): a patch that builds part of it again has new - # mixers, and released the old voices' stand-ins. Only a mixer with - # a voice on the silence: mid-stream, pulling one that plays its - # real sources would take a block of them. - for node in list(self._nodes): - voices = getattr(node, "voice", None) - if voices is None: - continue - if not any(voice is armed for armed, _p, _s in pending - for voice in voices): - continue - if pull is not None: - _drain(pull, node) - else: - self._pull_node(node) + + def drains(): + # The graph as it stands now (a snapshot: `_pull_node` may own + # its scratch mixer): a patch that builds part of it again has + # new mixers, and released the old voices' stand-ins. Only a + # mixer with a voice on the silence: mid-stream, pulling one + # that plays its real sources would take a block of them. + for node in list(self._nodes): + voices = getattr(node, "voice", None) + if voices is None: + continue + if not any(voice is armed for armed, _p, _s in pending + for voice in voices): + continue + if pull is not None: + _drain(pull, node) + else: + self._pull_node(node) + self._hold_levels(drains) for _voice, port, source in pending: if _named(port, self._nodes): port.play(source) + def _hold_levels(self, pulls): + """`pulls()` - the lazy hand-up's drains - with the voices + `_rearm_from` asked for held at the bypass's levels through it.""" + held, self._walk_in = self._walk_in, () + if not held: + pulls() + return + saved = [_hold(entry) for entry in held] + with_ramps(pulls) + for wanted in saved: + _let_go(wanted) + def deinit(self): """Release every node this class built, tail first. Idempotent, and it never deinitialises the borrowed source.""" diff --git a/lib/audioeffects/bitcrusher.py b/lib/audioeffects/bitcrusher.py index 25bf140..d4ee306 100644 --- a/lib/audioeffects/bitcrusher.py +++ b/lib/audioeffects/bitcrusher.py @@ -1497,12 +1497,14 @@ def _build(self, bits=12.0, rate_hz=STAND_RATE_HZ, dither=0.0, threshold_db=DITHER_GATE_DB, attack_ms=0.0, release_ms=DITHER_GATE_RELEASE_MS, hold_ms=DITHER_GATE_HOLD_MS, depth_db=-80.0, lookahead_ms=0.0) - dither_mix = audiomixer.Mixer(voice_count=3, **pcm) + dither_mix = _component.ramp_levels( + audiomixer.Mixer(voice_count=3, **pcm), rate) shaper = audioshaper.Waveshaper( sample_rate=rate, channel_count=channels, curve=CURVE, oversample=1, mix=1.0, pre_gain=1.0, post_gain=1.0, bias=0.0, hysteresis=0.0) - blend = audiomixer.Mixer(voice_count=2, **pcm) + blend = _component.ramp_levels( + audiomixer.Mixer(voice_count=2, **pcm), rate) silence = audiocore.RawSample( array("h", bytes(2 * 2 * channels)), sample_rate=rate, channel_count=channels) @@ -1644,6 +1646,8 @@ def _refresh(self): self._output_gain = _component.db_to_gain(output_db) self._prime_if_wet() + WALKS_TO_BYPASS = True + def reset(self): """Clear every node and restore patch 0, then arm the graph as `_build` does, lazily, so a reset is a fresh instance and takes @@ -1657,6 +1661,9 @@ def reset(self): def _prime_if_wet(self): mix = getattr(self, "_mix", 1.0) if mix <= 0.0: + if getattr(self, "_ready", False): + # The blend walks to the dry alone before the bypass lands. + self._set_levels() self._route_around(self._source) return if not getattr(self, "_ready", False): @@ -1678,14 +1685,9 @@ def _prime_if_wet(self): # re-armed below the way `_build` arms them: levels, gates, # then sources (audiocomponents#113). self._set_levels() - _component.open_level_gates( - self._dither_mix, [self._dither_mix.voice[0], - self._dither_mix.voice[1], - self._dither_mix.voice[2]], - self._silence) - _component.open_level_gates( - self._blend, [self._blend.voice[0], self._blend.voice[1]], - self._silence) + # Off a walked bypass the blend walks in from the dry alone. + self._rearm_from(((self._blend.voice[0], 1.0), + (self._blend.voice[1], 0.0)), self._open_gates) self._dither_primed = False self._blend_primed = False want = self._sections[-1] if getattr(self, "_band", False) \ @@ -1727,6 +1729,16 @@ def _prime_if_wet(self): self._output = self._blend self._set_levels() + def _open_gates(self): + _component.open_level_gates( + self._dither_mix, [self._dither_mix.voice[0], + self._dither_mix.voice[1], + self._dither_mix.voice[2]], + self._silence) + _component.open_level_gates( + self._blend, [self._blend.voice[0], self._blend.voice[1]], + self._silence) + def _set_levels(self): """Every mixer level this class owns, from the current macros. @@ -1738,7 +1750,9 @@ def _set_levels(self): """ mix = getattr(self, "_mix", 1.0) gain = getattr(self, "_output_gain", 1.0) - if gain > 1.0: + if gain > 1.0 or mix <= 0.0: + # Mix 0 is the source itself, so the blend walks to the dry at + # unity before the bypass takes over. gain = 1.0 self._blend.voice[0].level = (1.0 - mix) * gain self._blend.voice[1].level = mix * gain diff --git a/lib/audioeffects/chorus.py b/lib/audioeffects/chorus.py index 2c4c755..6eaf6b3 100644 --- a/lib/audioeffects/chorus.py +++ b/lib/audioeffects/chorus.py @@ -227,6 +227,8 @@ class Chorus(_component.Component): def _build(self, rate=0.8, depth=0.35, mix=0.815, delay_ms=8.8, tone_hz=3000.0, patch=None): self._shape = clock_shape(0.0) + #: The mean `delay_ms` last written, so a move can glide from it. + self._delay_written = None self._tail = 0 self._delay = self._own(audioecho.FeedbackDelay( sample_rate=self._sample_rate, @@ -244,6 +246,13 @@ def _build(self, rate=0.8, depth=0.35, mix=0.815, delay_ms=8.8, self._output = self._delay self._init_macros((rate, depth, mix, delay_ms, tone_hz), patch) + def reset(self): + """A reset is a fresh instance: patch 0's delay lands at once.""" + self._check_live() + self._delay_written = None + self._delay.set(delay_slew=0.0) + _component.Component.reset(self) + def _value(self, index): return _component.macro_value(self._MACRO_RANGES[index], self._macros[index]) @@ -261,6 +270,15 @@ def _refresh(self): delay_ms, wow_depth_ms = delay_law(mean_ms, depth) self._shape = clock_shape(depth) damping = nominal_damping_hz(tone, self._sample_rate) + # A Depth or Delay move moves the mean line, and written as it was + # the read head jumped by the whole change on the next frame. So + # the line walks there over 20 ms (the node's `delay_slew`, + # delay-seconds per second, set from the size of this move). + last = self._delay_written + if last is not None and last != delay_ms: + self._delay.set( + delay_slew=abs(delay_ms - last) / _component.LEVEL_RAMP_MS) + self._delay_written = delay_ms self._delay.set( delay_ms=delay_ms, wow_depth_ms=wow_depth_ms, diff --git a/lib/audioeffects/compressor.py b/lib/audioeffects/compressor.py index 1ee2187..d9365e7 100644 --- a/lib/audioeffects/compressor.py +++ b/lib/audioeffects/compressor.py @@ -383,8 +383,11 @@ def _build(self, character="optical", patch=None, **overrides): # of rewinding it. Tier 1 asks that `reset()` leave the borrowed # source untouched, so the walk stops here; the mixer has no state # of its own to clear, and the two Dynamics nodes below it do. - mixer = self._own(audiomixer.Mixer(voice_count=2, **self._pcm(1024)), - reset=False) + mixer = self._own( + _component.ramp_levels( + audiomixer.Mixer(voice_count=2, **self._pcm(1024)), + self._sample_rate), + reset=False) self._mixer = mixer self._wet = mixer.voice[0] self._dry = mixer.voice[1] @@ -407,6 +410,8 @@ def _build(self, character="optical", patch=None, **overrides): self._ready = True self._refresh_output() + WALKS_TO_BYPASS = True + def reset(self): """Clear every node and restore patch 0, then arm the graph as `_build` does, so a reset is a fresh instance (no block primed @@ -470,7 +475,9 @@ def _refresh_output(self): ((self._fast, self._wet_tap),)) self._primed = False if not self._primed: - self._prime() + # Off a walked bypass the levels walk in from the dry alone. + self._rearm_from(((self._wet, 0.0), (self._dry, 1.0)), + self._prime) self._output = self._mixer # -- the macro surface -------------------------------------------- diff --git a/lib/audioeffects/deesser.py b/lib/audioeffects/deesser.py index cd02e6a..97855d6 100644 --- a/lib/audioeffects/deesser.py +++ b/lib/audioeffects/deesser.py @@ -329,7 +329,8 @@ def _build(self, frequency=None, range_db=None, sensitivity_db=None, # broadband mode, the high band alone in HF-only. A mixer rather than # a re-`play()`, because `play()` on a live graph drops the pending # bytes and clicks. - pre = audiomixer.Mixer(voice_count=2, **self._pcm(1024)) + pre = _component.ramp_levels( + audiomixer.Mixer(voice_count=2, **self._pcm(1024)), rate) # The levels go on before anything is played, and that ordering is # load-bearing: `audiomixer.MixerVoice.play()` fetches a chunk from # its source there and then, so `out.voice[3].play(duck)` below @@ -365,7 +366,8 @@ def _build(self, frequency=None, range_db=None, sensitivity_db=None, # Voice 0 plays the dry, broadband; 1 the low half, HF-only; 2 the # dry high half, HF-only; 3 the ducked stream, both modes. They are # handed their sources at the end of construction - see there. - out = audiomixer.Mixer(voice_count=4, **self._pcm(1024)) + out = _component.ramp_levels( + audiomixer.Mixer(voice_count=4, **self._pcm(1024)), rate) # The class does NOT end in a mixer, and that is not decoration. # On CircuitPython `audiomixer.Mixer.reset_buffer` *stops* every diff --git a/lib/audioeffects/digitaldelay.py b/lib/audioeffects/digitaldelay.py index 875e74f..3505c1b 100644 --- a/lib/audioeffects/digitaldelay.py +++ b/lib/audioeffects/digitaldelay.py @@ -35,9 +35,10 @@ reach the rail, at any Time or Feedback. Keep the input under -6 dBFS and the defaults and patches stay off the rails. -**Limits shared by the family.** A control that jumps makes the output step: -move it in small steps from the host if you need it smooth. When your source -ends, the tail rings out as it would on silence. +**Limits shared by the family.** Mix walks a jump over 20 ms, but any other +control that jumps makes the output step: move it in small steps from the +host if you need it smooth. When your source ends, the tail rings out as it +would on silence. **Latency, tail, portability.** Latency is zero samples: nothing looks ahead. `tail_samples` is an upper bound on how long the output takes to diff --git a/lib/audioeffects/distortion.py b/lib/audioeffects/distortion.py index 52fb190..b05ce12 100644 --- a/lib/audioeffects/distortion.py +++ b/lib/audioeffects/distortion.py @@ -785,7 +785,8 @@ def _charge_output_cap(self): def _mixer_node(self, voices=2): node = self._own( - audiomixer.Mixer(**self._pcm_mixer(voices)), reset=False) + _component.ramp_levels(audiomixer.Mixer(**self._pcm_mixer(voices)), + self._sample_rate), reset=False) self._mixers.append(node) return node @@ -1001,6 +1002,8 @@ def _release(self, nodes): if release is not None: release() + WALKS_TO_BYPASS = True + def reset(self): """Clear every node and restore patch 0, then arm the graph as `_build` does, so a reset is a fresh instance (no block primed @@ -1028,6 +1031,10 @@ def _push(self): volume = self.macro(VOL) if mix <= 0.0: self._latency = 0 + # The mixer walks to the dry alone, which is the source, and the + # bypass takes over once it has landed. + for voice, level in self._at_dry(): + voice.level = level self._route_around(self._source) return # Back off a bypass, the graph behind the mixer has not been pulled @@ -1057,9 +1064,17 @@ def _push(self): self._output = self._mixer self._push_wet(mix, volume) if rejoin and not pending: - self._connect() + # Off a walked bypass the mixer walks in from the dry alone. + self._rearm_from(self._at_dry(), self._connect) self._output = self._mixer + def _at_dry(self): + """The output mixer's voices at the levels that make it the dry + alone: (voice, level) pairs.""" + wet = ((self._tone_lo, self._tone_hi) if self._tone_lo is not None + else (self._wet,)) + return [(self._dry, 1.0)] + [(voice, 0.0) for voice in wet] + def _push_wet(self, mix, volume): self._dry.level = 1.0 - mix ceiling = self.macro(CEIL) diff --git a/lib/audioeffects/dynamiceq.py b/lib/audioeffects/dynamiceq.py index 7ec51b7..ee7a2a9 100644 --- a/lib/audioeffects/dynamiceq.py +++ b/lib/audioeffects/dynamiceq.py @@ -342,10 +342,10 @@ def _build(self, frequency=3000.0, q=2.0, threshold_db=-30.0, ratio=4.0, # frames, and the move back played them - old audio out of silence # (audiocomponents#113). Stereo is byte for byte what it was; mono # renders the same samples in blocks half the size. - mixer = audiomixer.Mixer( + mixer = _component.ramp_levels(audiomixer.Mixer( voice_count=3, sample_rate=rate, channel_count=channels, bits_per_sample=16, samples_signed=True, - buffer_size=1024 * channels) + buffer_size=1024 * channels), rate) # The class does **not** end in the Mixer, and that is not decoration. # On upstream CircuitPython `audiomixer.Mixer.reset_buffer` *stops* @@ -500,9 +500,14 @@ def _refresh_output(self): if self._rejoin(keep=(self._head, self._mixer)): self._primed = False if not self._primed: - self._play_voices() + # Off a walked bypass the mixer walks in from the dry alone. + voices = self._mixer.voice + self._rearm_from(((voices[0], 1.0), (voices[1], 0.0), + (voices[2], 0.0)), self._play_voices) self._output = self._tail + WALKS_TO_BYPASS = True + def reset(self): """Clear every node and restore patch 0, then hand the voices their sources at patch 0's levels, as `_build` does. The walk used to diff --git a/lib/audioeffects/exciter.py b/lib/audioeffects/exciter.py index 1ca17d9..7b6a3b3 100644 --- a/lib/audioeffects/exciter.py +++ b/lib/audioeffects/exciter.py @@ -636,8 +636,8 @@ def _build(self, tune=TUNE_DEFAULT, harmonics=HARMONICS_DEFAULT, sample_rate=rate, channel_count=channels, curve=CURVE, oversample=factor, mix=1.0, pre_gain=1.0, post_gain=1.0, bias=0.0, hysteresis=0.0) - blend = audiomixer.Mixer( - voice_count=2, **self._pcm(MIXER_BUFFER_BYTES * channels)) + blend = _component.ramp_levels(audiomixer.Mixer( + voice_count=2, **self._pcm(MIXER_BUFFER_BYTES * channels)), rate) self._hp.play(wet_in) @@ -670,12 +670,19 @@ def _build(self, tune=TUNE_DEFAULT, harmonics=HARMONICS_DEFAULT, self._ready = True self._prime_if_wet() + WALKS_TO_BYPASS = True + def reset(self): """Clear every node and restore patch 0, then arm the graph as `_build` does, so a reset is a fresh instance (no block primed through the chain before it was cleared plays after it).""" self._reset_rearmed(self._prime_if_wet, ("_primed",)) + def _open_gates(self): + _component.open_level_gates( + self._blend, [self._blend.voice[0], self._blend.voice[1]], + self._silence) + def _value(self, index): return _component.macro_value(self._MACRO_RANGES[index], self._macros[index]) @@ -753,6 +760,10 @@ def _prime_if_wet(self): # top of the macro put the wet over the dry (audiocomponents#72). out = 10.0 ** (self._value(4) / 20.0) if mix <= 0.0 and out >= 1.0: + # The blend walks to the dry at unity, which is the source, and + # the bypass takes over once it has landed. + self._blend.voice[0].level = 1.0 + self._blend.voice[1].level = 0.0 self._route_around(self._source) return if not getattr(self, "_ready", False): @@ -770,9 +781,9 @@ def _prime_if_wet(self): # only then is the port pointed back (audiocomponents#113). self._blend.voice[0].level = out self._blend.voice[1].level = mix * out - _component.open_level_gates( - self._blend, [self._blend.voice[0], self._blend.voice[1]], - self._silence) + # Off a walked bypass the blend walks in from the dry alone. + dry, wet = self._blend.voice[0], self._blend.voice[1] + self._rearm_from(((dry, 1.0), (wet, 0.0)), self._open_gates) self._primed = False if not self._primed: self._dc.play(self._shaper) diff --git a/lib/audioeffects/fuzz.py b/lib/audioeffects/fuzz.py index 371a36e..0565c68 100644 --- a/lib/audioeffects/fuzz.py +++ b/lib/audioeffects/fuzz.py @@ -880,8 +880,11 @@ def _build(self, fuzz_db=36.0, level=1.0, tone=0.5, bias=0.0, # are gone - the tilt stage is the last node either way, so the # tail's only reason ("never end on a Mixer") went with them. self._tone_mix = self._own( - audiomixer.Mixer(voice_count=3, - **self._pcm(MIXER_BUFFER_BYTES * channels)), + _component.ramp_levels( + audiomixer.Mixer( + voice_count=3, + **self._pcm(MIXER_BUFFER_BYTES * channels)), + self._sample_rate), reset=self._reset_mixers) self._silence = self._own(audiocore.RawSample( array("h", [0] * (2 * channels)), diff --git a/lib/audioeffects/multibandcompressor.py b/lib/audioeffects/multibandcompressor.py index dc36f03..0e204b6 100644 --- a/lib/audioeffects/multibandcompressor.py +++ b/lib/audioeffects/multibandcompressor.py @@ -558,6 +558,8 @@ def _play_voices(self): self._detectors[band], loop=True) self._primed = True + WALKS_TO_BYPASS = True + def reset(self): """Clear every node and restore patch 0, then hand the voices their sources at patch 0's levels, as `_build` does. The walk used to @@ -774,7 +776,12 @@ def _refresh_output(self): if self._rejoin(keep=(self._head, self._mixer)): self._primed = False if not self._primed: - self._play_voices() + # Off a walked bypass the mixer walks in from the dry alone. + voices = self._mixer.voice + self._rearm_from( + [(voices[0], 1.0)] + [(voices[band + 1], 0.0) + for band in range(self._bands)], + self._play_voices) self._output = self._mixer @@ -787,6 +794,7 @@ def audiomixer_mixer(rate, channels, voices): at the top. """ import audiomixer - return audiomixer.Mixer(voice_count=voices, sample_rate=rate, - channel_count=channels, bits_per_sample=16, - samples_signed=True) + return _component.ramp_levels( + audiomixer.Mixer(voice_count=voices, sample_rate=rate, + channel_count=channels, bits_per_sample=16, + samples_signed=True), rate) diff --git a/lib/audioeffects/overdrive.py b/lib/audioeffects/overdrive.py index a541037..4bfa782 100644 --- a/lib/audioeffects/overdrive.py +++ b/lib/audioeffects/overdrive.py @@ -519,6 +519,23 @@ def _one_pole(freq, park): return f0, min(MAX_Q, max(MIN_Q, q)) +class _MixAsLevel: + """A filter's `mix` under the name a mixer voice gives its level, so + `Component._rearm_from` can hold it at the dry and let it walk back + with the voices: `audiobiquad.Biquad` walks a `mix` move over 20 ms.""" + + def __init__(self, node): + self._node = node + + @property + def level(self): + return self._node.mix + + @level.setter + def level(self, value): + self._node.mix = value + + class Overdrive(_component.Component): """A Tube Screamer-shaped overdrive: your note at unity, with a high-passed, clipped copy added on top. @@ -680,7 +697,8 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0, self._trim.play(self._shaper) self._dc.play(self._trim) - circuit = audiomixer.Mixer(voice_count=2, **self._pcm()) + circuit = _component.ramp_levels( + audiomixer.Mixer(voice_count=2, **self._pcm()), rate) self._lp = audiobiquad.Biquad( mode=audiobiquad.LOW_PASS, frequency=TONE_LP_HZ, Q=0.15, sample_rate=rate, channel_count=channels) @@ -688,7 +706,8 @@ def _build(self, drive=DRIVE_DEFAULT, tone=64.0 / 127.0, # rather than a second summing point the budget cannot price - and # the node is built with one, which is what the comment used to # claim while asking for two. - out = audiomixer.Mixer(voice_count=1, **self._pcm()) + out = _component.ramp_levels( + audiomixer.Mixer(voice_count=1, **self._pcm()), rate) self._lp.play(circuit) @@ -892,17 +911,36 @@ def reset(self): # frames, and `_rearm` keeps them (`_source_read_past`). self._reset_rearmed(self._prime_if_wet, ("_primed",)) + WALKS_TO_BYPASS = True + + def _at_dry(self): + """What makes the graph the dry alone: the clip branch out of the + circuit sum, the Tone low-pass out of the way and Level at unity, + as (stage, level) pairs (`Component._rearm_from`).""" + return ((self._circuit.voice[0], 1.0), (self._circuit.voice[1], 0.0), + (_MixAsLevel(self._lp), 0.0), (self._out.voice[0], 1.0)) + def _prime_if_wet(self): mix = self._value(3) level = self._value(2) if mix <= 0.0: + if getattr(self, "_ready", False): + # The graph walks to the dry alone, which is the source, + # and the bypass takes over once it has landed. + for stage, at in self._at_dry(): + stage.level = at self._route_around(self._source) return if not getattr(self, "_ready", False): self._output = self._out return if self._rejoin(): - self._rearm(level) + # The levels it comes back to, which a graph off a walked + # bypass walks to from the dry alone. + self._circuit.voice[0].level = 1.0 + self._circuit.voice[1].level = 1.0 + self._out.voice[0].level = level + self._rearm_from(self._at_dry(), self._rearm) return if not self._primed: self._play_voice(self._circuit.voice[0], self._dry) @@ -914,7 +952,7 @@ def _prime_if_wet(self): self._circuit.voice[1].level = 1.0 self._out.voice[0].level = level - def _rearm(self, level): + def _rearm(self): """Arm a graph Mix 0 left un-pulled, the way `_build` arms it. Back off a bypass the filters, the shaper and the output capacitor @@ -942,12 +980,16 @@ def _rearm(self, level): self._trim.play(self._shaper) self._dc.play(self._trim) self._lp.play(self._circuit) - self._circuit.voice[0].level = 1.0 - self._circuit.voice[1].level = 1.0 - self._out.voice[0].level = level if self._clip_bias: self._shaper.set(post_gain=0.0) self._trim.set(bias=0.0) + # The Tone low-pass takes its `mix` at once on its first block after + # the clear. One block of the quiet line hands it the mix it stands + # at now (the dry, off a walked bypass), leaves its state at zero, + # and the mix it is handed back walks from there. + self._lp.play(self._quiet) + self._pull_node(self._lp) + self._lp.play(self._circuit) _component.open_level_gates( self._circuit, [self._circuit.voice[0], self._circuit.voice[1]], self._silence) diff --git a/lib/audioeffects/phaser.py b/lib/audioeffects/phaser.py index 4d6f812..455cc48 100644 --- a/lib/audioeffects/phaser.py +++ b/lib/audioeffects/phaser.py @@ -507,9 +507,15 @@ def _refresh_drive(self): # lean patch is AllPass (and the guard) alone. The cascade is # re-pointed only when that route changes: a move that keeps it # (or a reset) plays nothing again. - if self._value(4) <= 0.0 or self._value(7) <= 0.0: + if self._value(7) <= 0.0: self._shaper.set(mix=0.0, pre_gain=1.0) head = self._guard + elif self._value(4) <= 0.0: + # Mix 0 with Drive up: the shaper stays in the pull and walks + # its own mix to 0, at the drive it had, so the drive fades + # out with the sweep instead of cutting. + self._shaper.set(mix=0.0) + head = self._shaper else: drive = self._value(7) self._shaper.set(mix=drive, pre_gain=1.0 + 8.0 * drive) diff --git a/lib/audioeffects/pingpongdelay.py b/lib/audioeffects/pingpongdelay.py index cb60add..f7e250f 100644 --- a/lib/audioeffects/pingpongdelay.py +++ b/lib/audioeffects/pingpongdelay.py @@ -140,9 +140,10 @@ class PingPongDelay(_component.Component): Keep the input under -6 dBFS and the defaults and patches stay off the rails. - **Limits shared by the family.** A control that jumps makes the output - step: move it in small steps from the host if you need it smooth. When - your source ends, the tail rings out as it would on silence. + **Limits shared by the family.** Mix walks a jump over 20 ms, but any + other control that jumps makes the output step: move it in small steps + from the host if you need it smooth. When your source ends, the tail + rings out as it would on silence. **Latency, tail, portability.** A click comes out on the frame it went in: there is no latency. `tail_samples` is an upper bound on how many diff --git a/lib/audioeffects/saturation.py b/lib/audioeffects/saturation.py index f500fcc..cccced3 100644 --- a/lib/audioeffects/saturation.py +++ b/lib/audioeffects/saturation.py @@ -750,6 +750,7 @@ class Saturation(_component.Component): #: Construction arms the voices on silence (`Component._play_voice`), #: so a Mix 0 set before the first pull hands out the source whole. LAZY_BUILD = True + WALKS_TO_BYPASS = True NAME = 'Saturation' DISPLAY_NAME = 'Saturation' CATEGORIES = ('Distortion',) @@ -947,8 +948,10 @@ def _build(self, drive_db=0.0, output_db=0.0, mix=1.0, headroom_db=0.0, self._makeup_shelf.play(self._tilt) self._after.play(self._shaper) self._mix = self._own( - audiomixer.Mixer(voice_count=2, - **self._pcm(MIXER_BUFFER_BYTES * channels)), + _component.ramp_levels( + audiomixer.Mixer(voice_count=2, + **self._pcm(MIXER_BUFFER_BYTES * channels)), + self._sample_rate), reset=self._reset_mixers) self._output = self._mix # Two frames, not one: `audiocore.get_buffer` of a 1-channel Mixer @@ -1115,6 +1118,7 @@ def reset(self): gates opened, then the voices played. """ self._check_live() + self._land_bypass() self._resetting = True try: self._clear_nodes() @@ -1359,7 +1363,9 @@ def _refresh_output(self): # The Splitter drops the source frames it took before the # bypass, which the host has since read past. self._let_go_of_source(self._split, self._source) - self._rearm() + # The bypass was the dry alone: walk in from there. + dry, wet = self._mix.voice + self._rearm_from(((dry, 1.0), (wet, 0.0)), self._rearm) self._attach() self._output = self._mix self._latency = self._wet_delay diff --git a/lib/audioeffects/slapbackdelay.py b/lib/audioeffects/slapbackdelay.py index 5a45efc..6a0bb71 100644 --- a/lib/audioeffects/slapbackdelay.py +++ b/lib/audioeffects/slapbackdelay.py @@ -174,8 +174,9 @@ class SlapbackDelay(_component.Component): `tone_hz` of 0 or less is Tone out, and NaN takes the option's default. **Limits shared by the family.** - A control that jumps makes the output step: move it in small steps from - the host if you need it smooth. + Level walks a jump over 20 ms, but any other control that jumps makes + the output step: move it in small steps from the host if you need it + smooth. When your source ends, the tail rings out as it would on silence. """ diff --git a/lib/audioeffects/vibrato.py b/lib/audioeffects/vibrato.py index 8a7a1c3..af5114d 100644 --- a/lib/audioeffects/vibrato.py +++ b/lib/audioeffects/vibrato.py @@ -370,11 +370,11 @@ def _reset_delay(single_channel_output=False, audio_channel=0, self._delay._reset_buffer = _reset_delay bytes_per_block = BLOCK_FRAMES * self._channel_count * 2 self._mixer = self._own( - audiomixer.Mixer( + _component.ramp_levels(audiomixer.Mixer( voice_count=1, sample_rate=self._sample_rate, channel_count=self._channel_count, - buffer_size=bytes_per_block), + buffer_size=bytes_per_block), self._sample_rate), reset=self._reset_mixer) # Default Level is 0 dB: pull the delay. Mixer is optioned. self._output = self._delay @@ -407,11 +407,25 @@ def _level_is_unity(self): return abs(self._value(7)) < 0.01 def _route_output(self): + port = self._port if self._level_is_unity(): + if (_component.LEVELS_RAMP and _component.PORT is not None + and port is not None and port.source is self._mixer): + # The level walks back to unity in the mixer first, and the + # port goes straight to the delay once it has landed. + port.play(self._delay, after=_component.level_ramp_frames( + self._sample_rate) + _component.BYPASS_MARGIN_FRAMES) + return self._output = self._delay return if not self._mixer_armed: - self._arm_mixer_voice() + # Armed at unity, which is what the delay alone played, so the + # level walks down from there instead of landing at once. + voice = self._mixer.voice[0] + level = voice.level + voice.level = 1.0 + _component.with_ramps(self._arm_mixer_voice) + voice.level = level self._output = self._mixer def _arm_mixer_voice(self): diff --git a/tests/support/LIFECYCLE.md b/tests/support/LIFECYCLE.md index 7e7e36b..c3a57c4 100644 --- a/tests/support/LIFECYCLE.md +++ b/tests/support/LIFECYCLE.md @@ -78,7 +78,10 @@ at patch 0. - **P1** At Mix 0 the output is the source, byte for byte, on time (shifted by `latency_samples`). Events that do not move Mix take Mix back to 0 in - the same gap; E5 is judged over the stretch it holds Mix at 0. After + the same gap. E5, and E4 taking Mix to 0, hold Mix there for the run and + are judged over a stretch as long as the event's gap, from where Mix has + walked to 0: 20 ms, or for a class that routes around its graph, the walk + it waits for and a block after it, when it has switched to the source. After `E8-end` the output is the frames the source handed, then silence. - **P2** Silence in gives silence out: after the event and `tail_samples` (read after it) + `latency_samples` + one block + the last source buffer, @@ -87,10 +90,15 @@ at patch 0. wait until the output is exactly silent, the move back; the next four blocks are exactly 0. A single action is taken in the silence instead. - **P4** From `tail_samples + latency_samples + one block` after the last - move on, the output is byte-identical to the control's, over 2048 frames - of material. -- **P5** No step within one block of the move larger than 1.5 x the larger - of the triangle's own largest step and the control's. Control moves only + move on, plus the 20 ms a level walks after a control move, the output + is byte-identical to the control's, over 2048 frames of material. +- **P5** No step within one block of the move larger than 1.5 x the + largest of the triangle's own largest step, the control's, and the + landing's: an instance given the move's macros before its first pull, + read from where the move happens on. A level turned up walks to a + louder output whose every step is larger; a jump still steps by the gap + between the two outputs, which none of the three forgives. The landing + counts for macro moves other than Mix. Control moves only (E4, E5, E6, E9, E11): a reset, a deinit and a source fault are cuts by definition, and E10 has nothing before it. - **PF** (E12) The first block equals the fresh instance's, byte for @@ -101,7 +109,10 @@ at patch 0. return from Mix 0 that routed around the graph, a rejoin: the cell's own configuration; a Mix 0 that keeps the graph running prints `na`) on, 4096 frames are byte-identical to a fresh instance built there and fed - the source from the same frame. Where the source stands is read off a + the source from the same frame. A class that walks to the dry before its + bypass walks back in from it, so its comparison starts once that walk + has landed; a return that comes before the bypass switched prints `na`, + the graph never having stopped. 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 @@ -216,7 +227,9 @@ and `LIFECYCLE_CLASSES=DigitalDelay,TapeDelay` limits it to some classes. The helper ships a control and a planted fault for every property, and `TestPlants` holds each plant red on its cell with its control green there: `DropOnReset` (P1), `HoldDC` (P2), `StaleMix` against `Routed` (P3), -`LateMove` (P4), `HardSwitch` (P5), `BusyDeinit` (PD), `LatePatch` (PF, a +`LateMove` (P4), `HardSwitch` (P5), `LoudJump` against `LoudRamp` (P5's +landing: a level jumping to louder is red, the same level walking there +over 20 ms green), `BusyDeinit` (PD), `LatePatch` (PF, a pre-pull patch that lands a block late), `ResetLeak` against `Routed` (PR, a reset that keeps its echo line; P4 reads both as ok) and `ImplPlant` (P6, a level a hair different off CPython). diff --git a/tests/support/lifecycle.py b/tests/support/lifecycle.py index 7d21cd2..d31c7f5 100644 --- a/tests/support/lifecycle.py +++ b/tests/support/lifecycle.py @@ -1454,6 +1454,10 @@ def material_for(which, rate, channels): seed=777) elif which == "once": _MATERIAL[key] = click_noise(8192, channels) + elif which == "long": + # PR's: a mono class that walks to its bypass in 1024-frame + # mixer blocks reads past 8192 frames before it lands. + _MATERIAL[key] = click_noise(16384, channels) elif which == "tri": _MATERIAL[key] = triangle(8192, channels, rate) elif which == "loud": @@ -1526,9 +1530,13 @@ def p1(cls, ev, rate, channels, patch, options): ctx = Ctx(e, patch) fsize = 2 * channels if _starts(ev.name, ("E5", "E4-m%d=0" % mix)): - # The event itself takes Mix to 0: judge the stretch it holds it. + # The event itself takes Mix to 0: judge a stretch as long as it + # holds it, from where the walk to 0 has landed. Mix stays at 0 for + # the run, so a hold shorter than the walk is still judged. mixfix = None window = None + hold = ev.gap + ev = Event(ev.name, ev.away, kind=ev.kind, first=ev.first) else: e.set_macro(mix, 0) @@ -1544,6 +1552,8 @@ def mixfix(effect): try: at = _schedule_run(e, feed, ctx, ev, out, fsize, total, mixfix) lat = e.latency_samples + walk = e._bypass_walk() if getattr(e, "WALKS_TO_BYPASS", False) \ + else 0 finally: e.deinit() expect = bytes(values) @@ -1554,10 +1564,16 @@ def mixfix(effect): else: if not at: return "na" - lo = at[0] - hi = at[1] if len(at) > 1 else total if ev.first: return "na" + lo = at[0] + pull = at[0] // K0 + # Mix walks to 0 over the ramp (`_component.LEVEL_RAMP_MS`); a + # class that routes around its graph there switches to the source + # at the first pull after the walk (`WALKS_TO_BYPASS`), judged from + # a block after it. + lo += walk + BLOCK if walk else _component.level_ramp_frames(rate) + hi = min(total, lo + hold * pull) # Mix 0's own latency, read while the event holds it there. lat = ctx.lats[0] shifted = bytes(lat * fsize) + expect @@ -1662,6 +1678,10 @@ def main_render(cls, ev, rate, channels, patch, options, controls): bound = last + BLOCK + lat else: bound = last + tail + lat + BLOCK + if ev.control_move and _component is not None: + # A level or a Mix that moves walks there over the ramp + # (`_component.LEVEL_RAMP_MS`) before it lands. + bound += _component.level_ramp_frames(rate) stop = bound + MARGIN verdict, crc, s0 = _p4_stream(e, c, out, cout, last, bound, stop, fsize) @@ -1854,9 +1874,40 @@ def _p3_body(e, feed, ctx, ev, fsize, wait, out): top, first // (fsize // 2)) +def _landing_step(cls, ev, rate, channels, patch, options, total): + """The largest step, from pull K0 on, of an instance given the move's + macro settings before its first pull: the class at the setting the + move lands on. 0 for a move that is not macros alone, or that moves + the wet/dry control, whose landing is the source or the control. + + A level turned up ramps to a louder output, and a louder triangle + steps further every frame; held to the old setting's step alone, a + 20 ms walk up 24 dB would read as a click it is not. A jump still + steps by the gap between the two outputs, far past either one's own + step on a triangle, so this does not forgive a cut (the `LoudJump` + plant).""" + mix = mix_index(cls) + if not ev.away or any(a[0] != "macro" or a[1] == mix for a in ev.away): + return 0 + feed = Feed(material_for("tri", rate, channels), rate, channels, + ev.kind, loop=False) + e = _build(cls, feed, rate, patch, options, False) + out = bytearray() + try: + act(e, feed, Ctx(e, patch), ev.away) + _pull_to(e, out, total * 2 * channels, 2 * channels) + finally: + e.deinit() + # From where the move happens on: the landing's own start (a block + # primed before the move, an envelope settling) is not the setting. + return _max_step(_samples(bytes(out[:total * 2 * channels])), channels, + K0 * BLOCK, total) + + def p5(cls, ev, rate, channels, patch, options, controls): - """No step at the move larger than 1.5 x the larger of the material's - and the control's own largest step, on a triangle.""" + """No step at the move larger than 1.5 x the largest of the material's + own step, the control's, and the landing's (`_landing_step`), on a + triangle.""" if not ev.control_move or ev.first: return "na" fsize = 2 * channels @@ -1879,7 +1930,8 @@ def p5(cls, ev, rate, channels, patch, options, controls): ref = _samples(bytes(ctl.upto(total)[:total * fsize])) got = _samples(bytes(out[:total * fsize])) own = _max_step(values, channels, 0, total) - base = max(own, _max_step(ref, channels, 0, total)) + base = max(own, _max_step(ref, channels, 0, total), + _landing_step(cls, ev, rate, channels, patch, options, total)) limit = base * STEP_NUM // STEP_DEN depth = is_depth(cls) and any( a[0] == "macro" and a[1] == mix_index(cls) for a in ev.away) @@ -1986,9 +2038,9 @@ def _locate(material, block, fsize): return -1 if i < 0 else i // fsize -def _diverge(a, b, frames, fsize): - """The first frame where `a` and `b` render differently, pulled in - step for `frames`, or -1.""" +def _diverge(a, b, frames, fsize, skip=0): + """The first frame from `skip` on where `a` and `b` render + differently, pulled in step for `frames`, or -1.""" abuf = bytearray() bbuf = bytearray() base = 0 @@ -1999,8 +2051,9 @@ def _diverge(a, b, frames, fsize): if not bbuf: bbuf = bytearray(_pull_some(b, fsize)) n = min(len(abuf), len(bbuf), total - base) - if abuf[:n] != bbuf[:n]: - return (base + _first_diff(abuf, bbuf, 0, n)) // fsize + lo = max(0, skip * fsize - base) + if lo < n and abuf[lo:n] != bbuf[lo:n]: + return (base + _first_diff(abuf, bbuf, lo, n)) // fsize abuf = abuf[n:] bbuf = bbuf[n:] base += n @@ -2020,12 +2073,23 @@ def _routes_around(cls, ev, rate, channels, patch, options): e.deinit() +def _walk_in_frames(e): + """Frames by which a class's levels have walked in off a walked + bypass, 0 where its Mix 0 switches at once: the walk, and the block a + stage further down the graph may start its own walk late by, as for + the walk out (`_bypass_walk`).""" + walk = getattr(e, "_bypass_walk", None) + if walk is None: + return 0 + return walk() + + def pr(cls, ev, rate, channels, patch, options): """PR: from the move that lands the class (a reset, a return from Mix 0) on, it renders byte for byte what a fresh instance built at the landing state renders, fed the same source from the same frame.""" fsize = 2 * channels - values = material_for("once", rate, channels) + values = material_for("long", rate, channels) upto = _land_pull(ev) if ev.landing == "config" and not _routes_around(cls, ev, rate, channels, patch, options): @@ -2043,8 +2107,16 @@ def pr(cls, ev, rate, channels, patch, options): try: _drive(e, feed, Ctx(e, patch), ev, upto, False) block = bytes(audiocore.get_buffer(feed.mat)[1]) + # A walked bypass the move back reaches before the port has + # switched: the graph never stopped, so it comes back to its + # own state, not a fresh one's. + walking = (ev.landing == "config" + and getattr(e, "_bypass_pending", None) is not None + and e._port.source is not e._bypass_pending) finally: e.deinit() + if walking: + return "na(Mix came back before the walk to the dry landed)" start = _locate(bytes(values), block, fsize) if start < 0: return "na(source position not found)" @@ -2053,6 +2125,11 @@ def pr(cls, ev, rate, channels, patch, options): f = None try: _drive(e, feed, Ctx(e, patch), ev, upto, True) + # A class whose Mix 0 walks to the dry walks back in from it + # (`WALKS_TO_BYPASS`): a fresh instance starts at the levels, so + # the two meet once the walk has landed. + skip = (_walk_in_frames(e) if ev.landing == "config" + and not ev.first else 0) ffeed = Feed(values[start * channels:], rate, channels, "256", loop=False) try: @@ -2060,7 +2137,7 @@ def pr(cls, ev, rate, channels, patch, options): except TypeError as err: return "na(no fresh build: %s)" % ( str(err).replace("|", "/")[:60]) - d = _diverge(e, f, PR_FRAMES, fsize) + d = _diverge(e, f, PR_FRAMES + skip, fsize, skip) finally: e.deinit() if f is not None: @@ -2436,12 +2513,49 @@ def _shape(self): return self._factor(), 20000 if pop else 0 +class LoudJump(Plain): + """P5: a linear Waveshaper after the source whose Gain is its drive, + from 0.05 up to 2.5, a jump to an output louder than the source: the + landing clause does not forgive the cut.""" + LO = (0.05, 1.0) + HI = (2.5, 1.0) + + def _build(self, mix=1.0, gain=0.0, patch=None): + import audioshaper + self._shaper = self._own(audioshaper.Waveshaper( + sample_rate=self._sample_rate, channel_count=self._channel_count, + curve=array("h", (-32767, 0, 32767)), oversample=1, + pre_gain=self.LO[0], post_gain=self.LO[1], mix=1.0)) + Plain._build(self, mix, gain, patch) + + def _arm(self): + self._shaper.play(self._source, loop=True) + self._mixer.voice[0].play(self._shaper, loop=True) + self._mixer.voice[1].play(self._dc, loop=True) + + def _apply_macro(self, index, position): + if index == 1: + lo, hi = self.LO, self.HI + self._shaper.set(pre_gain=lo[0] + (hi[0] - lo[0]) * position, + post_gain=lo[1] + (hi[1] - lo[1]) * position) + + +class LoudRamp(LoudJump): + """LoudJump with Gain on the shaper's `post_gain`, which walks a move + over 20 ms. At 22.05 kHz the walk is past 0.7 of the way when P5's + window ends, so the output steps half again as far as the triangle, + and only the landing clause holds it green.""" + LO = (2.5, 0.02) + HI = (2.5, 1.0) + + PLANTS = {"plain": Plain, "routed": Routed, "droponreset": DropOnReset, "holddc": HoldDC, "latepatch": LatePatch, "resetleak": ResetLeak, "stalemix": StaleMix, "latemove": LateMove, "hardswitch": HardSwitch, "busydeinit": BusyDeinit, "implplant": ImplPlant, "depthjump": DepthJump, - "depthleak": DepthLeak, "depthpop": DepthPop} + "depthleak": DepthLeak, "depthpop": DepthPop, + "loudramp": LoudRamp, "loudjump": LoudJump} def _cli(argv): diff --git a/tests/support/lifecycle_known.py b/tests/support/lifecycle_known.py index 0e64620..9a470a9 100644 --- a/tests/support/lifecycle_known.py +++ b/tests/support/lifecycle_known.py @@ -5,8 +5,8 @@ """What is red in the lifecycle matrix today, class by class: the rows `tests/test_lifecycle_matrix.py` holds as KNOWN_RED. -Counted on the quick matrix on CPython at audiodsp v0.6.6rc9, every class -the package serves. A row is (class NAME, event prefix, property) -> +Counted on the quick matrix on CPython, every class the package serves; +recounted for the node level ramps (audiodsp#400). A row is (class NAME, event prefix, property) -> (red cells in the quick matrix, their digest, the same for the full matrix or None where it was not counted, reason). Each reason names the issue that tracks it, or the docstring sentence that states it. @@ -14,13 +14,21 @@ R_JUMP = ( "a control that jumps hands its node the new value at once, so the" - " output steps; disclosed in docs/how-it-works/control-smoothing.md, lifted when" - " the node ramps land (audiocomponents#153)") + " output steps; disclosed in docs/how-it-works/control-smoothing.md." + " Mix and the levels walk since audiodsp#400; a filter corner, a" + " switch, a depth, and a patch change's controls other than its levels" + " still jump, until the later stages there land (audiocomponents#153)") R_STATE_RAMP = ( "state that outlives tail_samples, as R_STATE (audiocomponents#155);" " and since audiodsp v0.6.6rc9 a filter corner move walks over 20 ms" " (#162's fix), so a move and back meets the uninterrupted control up" " to 20 ms past the bound, where it used to step") +R_FUZZ_CHARGE = ( + "Fuzz charges its output pole through its shapers when it is built, so" + " the shapers have played a block before the host's first pull: a Mix," + " Level or patch set after the build and before that pull walks over" + " 20 ms from the built value, where a fresh instance built there starts" + " at it (the node level ramps, audiodsp#400)") R_PHASE = ( "a free-running phase (an LFO, a carrier, a sample-and-hold count)" " restarted by a reset or moved by a rate or patch change never meets" @@ -55,146 +63,116 @@ ('AutoPan', 'E4-', 'P5'): (12, 'c576f01c', None, None, R_JUMP), ('AutoPan', 'E6-', 'P4'): (18, 'ab6136b8', None, None, R_PHASE), ('AutoPan', 'E6-', 'P5'): (40, '4a1ecd5f', None, None, R_JUMP), - ('BandPass', 'E11-', 'P4'): (16, '417a9fcb', None, None, R_STATE_RAMP), - ('BandPass', 'E11-', 'P5'): (8, '1682ea8f', None, None, R_JUMP), + ('BandPass', 'E11-', 'P5'): (4, '29718af4', None, None, R_JUMP), ('BandPass', 'E2-reset_buffer', 'P4'): (4, '26674fe7', None, None, R_STATE), - ('BandPass', 'E4-', 'P4'): (20, '184674d3', None, None, R_STATE_RAMP), - ('BandPass', 'E4-', 'P5'): (8, '9f7e97fe', None, None, R_JUMP), ('BandPass', 'E5-mix0', 'P4'): (4, '3db4de60', None, None, R_STATE), - ('BandPass', 'E5-mix0', 'P5'): (28, 'cde90b31', None, None, R_JUMP), - ('BandPass', 'E6-', 'P4'): (61, '6f81c567', None, None, R_STATE_RAMP), - ('BandPass', 'E6-', 'P5'): (34, 'aaaf3cbf', None, None, R_JUMP), + ('BandPass', 'E6-', 'P4'): (17, 'c97cd2b1', None, None, R_STATE_RAMP), + ('BandPass', 'E6-', 'P5'): (12, 'aba60ae5', None, None, R_JUMP), ('BandPass', 'E9-', 'P4'): (20, '7c7d321c', None, None, R_STATE), - ('BandPass', 'E9-', 'P5'): (140, '331bff40', None, None, R_JUMP), ('Bitcrusher', 'E1-reset@block', 'P4'): (16, '0c568ee1', None, None, R_PHASE), ('Bitcrusher', 'E1-reset@part', 'P4'): (16, 'efb72d5f', None, None, R_PHASE), ('Bitcrusher', 'E11-', 'P4'): (6, 'ddb49c52', None, None, R_PHASE), ('Bitcrusher', 'E11-', 'P5'): (8, 'b8cd0394', None, None, R_JUMP), - ('Bitcrusher', 'E4-', 'P4'): (8, '00998826', None, None, R_PHASE), - ('Bitcrusher', 'E4-', 'P5'): (13, '4c09238b', None, None, R_JUMP), - ('Bitcrusher', 'E5-mix0', 'P4'): (22, 'cae3b03e', None, None, R_PHASE), - ('Bitcrusher', 'E5-mix0', 'P5'): (4, '158bbe35', None, None, R_JUMP), + ('Bitcrusher', 'E4-', 'P4'): (6, 'bec49683', None, None, R_PHASE), + ('Bitcrusher', 'E4-', 'P5'): (4, '9c47c5b7', None, None, R_JUMP), + ('Bitcrusher', 'E5-mix0', 'P4'): (11, '2df8cd4f', None, None, R_PHASE), ('Bitcrusher', 'E6-', 'P4'): (48, 'c98676ff', None, None, R_PHASE), ('Bitcrusher', 'E6-', 'P5'): (45, '4976f308', None, None, R_JUMP), - ('Bitcrusher', 'E9-', 'P4'): (145, '2d77c1d5', None, None, R_PHASE), - ('Bitcrusher', 'E9-', 'P5'): (90, 'beeb46d3', None, None, R_JUMP), + ('Bitcrusher', 'E9-', 'P4'): (75, '73777806', None, None, R_PHASE), ('CabinetSim', 'E1-reset@block', 'P4'): (32, 'd4049af3', None, None, R_STATE), ('CabinetSim', 'E1-reset@part', 'P4'): (32, '18d29744', None, None, R_STATE), ('CabinetSim', 'E11-', 'P4'): (11, '5cfd274d', None, None, R_STATE), ('CabinetSim', 'E4-', 'P4'): (19, '8b68cb22', None, None, R_STATE_RAMP), - ('CabinetSim', 'E4-', 'P5'): (4, 'bb8c0c6c', None, None, R_JUMP), - ('CabinetSim', 'E5-mix0', 'P4'): (23, '237af205', None, None, R_STATE), - ('CabinetSim', 'E5-mix0', 'P5'): (32, 'af8b4b35', None, None, R_JUMP), + ('CabinetSim', 'E5-mix0', 'P4'): (28, 'da1c3f4c', None, None, R_STATE), ('CabinetSim', 'E6-', 'P4'): (48, 'b2e9280e', None, None, R_STATE_RAMP), ('CabinetSim', 'E6-', 'P5'): (2, 'ce52ab85', None, None, R_JUMP), - ('CabinetSim', 'E9-', 'P4'): (115, 'bd72fb3d', None, None, R_STATE), - ('CabinetSim', 'E9-', 'P5'): (160, 'd4d046ca', None, None, R_JUMP), + ('CabinetSim', 'E9-', 'P4'): (140, 'a5b3a451', None, None, R_STATE), ('Chorus', 'E1-reset@block', 'P4'): (32, '4a87c73b', None, None, R_PHASE), ('Chorus', 'E1-reset@part', 'P4'): (32, 'e74bb915', None, None, R_PHASE), ('Chorus', 'E11-', 'P4'): (16, 'f8ff4250', None, None, R_PHASE), - ('Chorus', 'E11-', 'P5'): (8, '214d7ede', None, None, R_JUMP), ('Chorus', 'E2-reset_buffer', 'P4'): (32, '44a3e08c', None, None, R_PHASE), - ('Chorus', 'E4-', 'P4'): (38, 'd2f35c85', None, None, R_PHASE), - ('Chorus', 'E4-', 'P5'): (26, '5c8eedb2', None, None, R_JUMP), - ('Chorus', 'E5-mix0', 'P4'): (32, '42f78641', None, None, R_PHASE), - ('Chorus', 'E5-mix0', 'P5'): (32, '5b6fcae7', None, None, R_JUMP), - ('Chorus', 'E6-', 'P4'): (76, 'fb5be030', None, None, R_PHASE), - ('Chorus', 'E6-', 'P5'): (64, '531f4b89', None, None, R_JUMP), - ('Chorus', 'E9-', 'P4'): (128, '99555806', None, None, R_PHASE), - ('Chorus', 'E9-', 'P5'): (160, '52dbd13a', None, None, R_JUMP), - ('CombFilter', 'E11-', 'P4'): (4, '311df2e2', None, None, R_STATE), - ('CombFilter', 'E11-', 'P5'): (8, 'becbb467', None, None, R_JUMP), + ('Chorus', 'E4-', 'P4'): (8, '6e4d9f04', None, None, R_PHASE), + ('Chorus', 'E4-', 'P5'): (6, '7f58228f', None, None, R_JUMP), + ('Chorus', 'E5-mix0', 'P4'): (13, '55590580', None, None, R_PHASE), + ('Chorus', 'E6-', 'P4'): (60, '035bcc9f', None, None, R_PHASE), + ('Chorus', 'E9-', 'P4'): (52, 'a47316fb', None, None, R_PHASE), + ('CombFilter', 'E11-', 'P5'): (4, 'db431f7a', None, None, R_JUMP), ('CombFilter', 'E2-reset_buffer', 'P4'): (2, 'e484c73e', None, None, R_STATE), - ('CombFilter', 'E4-', 'P4'): (6, '3c7e5d22', None, None, R_STATE), - ('CombFilter', 'E4-', 'P5'): (28, '90fd3f33', None, None, R_JUMP), - ('CombFilter', 'E5-mix0', 'P5'): (28, '183f1bf1', None, None, R_JUMP), - ('CombFilter', 'E6-', 'P4'): (22, 'ce80804b', None, None, R_STATE), - ('CombFilter', 'E6-', 'P5'): (58, '1b4f34b8', None, None, R_JUMP), - ('CombFilter', 'E9-', 'P5'): (140, '0123c6b0', None, None, R_JUMP), + ('CombFilter', 'E4-', 'P4'): (2, '797c2e38', None, None, R_STATE), + ('CombFilter', 'E4-', 'P5'): (12, '9f58fa94', None, None, R_JUMP), + ('CombFilter', 'E6-', 'P4'): (17, '3a8a8df8', None, None, R_STATE), + ('CombFilter', 'E6-', 'P5'): (44, '3b44b8cc', None, None, R_JUMP), ('Compressor', 'E1-reset@block', 'P4'): (64, '425ab558', None, None, R_STATE), ('Compressor', 'E1-reset@part', 'P4'): (64, '56933d4c', None, None, R_STATE), ('Compressor', 'E11-', 'P4'): (8, 'e6342cf4', None, None, R_STATE), ('Compressor', 'E11-', 'P5'): (8, 'e77977a7', None, None, R_JUMP), - ('Compressor', 'E4-', 'P4'): (12, '8e419543', None, None, R_STATE), - ('Compressor', 'E4-', 'P5'): (30, '8cf81483', None, None, R_JUMP), - ('Compressor', 'E5-mix0', 'P4'): (60, '1606976a', None, None, R_STATE), - ('Compressor', 'E5-mix0', 'P5'): (57, '87bcb9cf', None, None, R_JUMP), - ('Compressor', 'E6-', 'P4'): (158, '8a381bc5', None, None, R_STATE), - ('Compressor', 'E6-', 'P5'): (158, 'f381ffce', None, None, R_JUMP), - ('Compressor', 'E9-', 'P4'): (314, '9d0fc034', None, None, R_STATE), - ('Compressor', 'E9-', 'P5'): (292, 'eef8a082', None, None, R_JUMP), - ('DeEsser', 'E11-', 'P4'): (4, '5578b3ca', None, None, R_STATE), - ('DeEsser', 'E4-', 'P4'): (8, 'e51a0a4b', None, None, R_STATE), - ('DeEsser', 'E4-', 'P5'): (11, '1042cde7', None, None, R_JUMP), - ('DeEsser', 'E5-mix0', 'P4'): (19, '7d1cd715', None, None, R_STATE), - ('DeEsser', 'E6-', 'P4'): (20, '85041b2c', None, None, R_STATE_RAMP), - ('DeEsser', 'E6-', 'P5'): (2, '2c626f74', None, None, R_JUMP), - ('DeEsser', 'E9-', 'P4'): (76, 'cb126a47', None, None, R_STATE), - ('DeEsser', 'E9-src256', 'P4'): (19, '9f6acd29', None, None, R_STATE), + ('Compressor', 'E4-', 'P4'): (8, '265bf6d2', None, None, R_STATE), + ('Compressor', 'E4-', 'P5'): (19, '7fa91f6d', None, None, R_JUMP), + ('Compressor', 'E5-mix0', 'P4'): (28, 'e7fff304', None, None, R_STATE), + ('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', '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), ('Distortion', 'E1-reset@block', 'P4'): (48, 'd84a1067', None, None, R_STATE), ('Distortion', 'E1-reset@part', 'P4'): (48, 'ce661657', None, None, R_STATE), - ('Distortion', 'E11-', 'P4'): (4, 'b2d5e10e', None, None, R_STATE), ('Distortion', 'E11-', 'P5'): (12, '5be4892b', None, None, R_JUMP), - ('Distortion', 'E4-', 'P4'): (8, '8fd46470', None, None, R_STATE), - ('Distortion', 'E4-', 'P5'): (13, '82c778dc', None, None, R_JUMP), + ('Distortion', 'E4-', 'P4'): (4, '0f2adcdf', None, None, R_STATE), + ('Distortion', 'E4-', 'P5'): (4, '909c890f', None, None, R_JUMP), ('Distortion', 'E5-mix0', 'P4'): (44, '14ea31c6', None, None, R_STATE), - ('Distortion', 'E5-mix0', 'P5'): (38, '8e927361', None, None, R_JUMP), ('Distortion', 'E6-', 'P4'): (46, '365ceaaa', None, None, R_STATE), - ('Distortion', 'E6-', 'P5'): (77, '842e56c5', None, None, R_JUMP), + ('Distortion', 'E6-', 'P5'): (76, '88dad621', None, None, R_JUMP), ('Distortion', 'E9-', 'P4'): (236, 'f3a8ddc3', None, None, R_STATE), - ('Distortion', 'E9-', 'P5'): (174, '91062d1d', None, None, R_JUMP), ('DynamicEQ', 'E1-reset@block', 'P4'): (28, '0b10ffa7', None, None, R_STATE), ('DynamicEQ', 'E1-reset@part', 'P4'): (28, '4e33bc13', None, None, R_STATE), ('DynamicEQ', 'E11-', 'P4'): (16, 'a0afa5a4', None, None, R_STATE), - ('DynamicEQ', 'E11-', 'P5'): (1, '685e2e29', None, None, R_JUMP), ('DynamicEQ', 'E4-', 'P1'): (4, '65f0c3d8', None, None, R_STATED_P1), - ('DynamicEQ', 'E4-', 'P4'): (36, 'd098085e', None, None, R_STATE), - ('DynamicEQ', 'E4-', 'P5'): (5, '3b907625', None, None, R_JUMP), + ('DynamicEQ', 'E4-', 'P4'): (32, 'd84c1f5d', None, None, R_STATE), + ('DynamicEQ', 'E4-', 'P5'): (4, '50bc4b3a', None, None, R_JUMP), ('DynamicEQ', 'E5-mix0', 'P4'): (26, 'e755b82b', None, None, R_STATE), - ('DynamicEQ', 'E5-mix0', 'P5'): (4, '66b6249f', None, None, R_JUMP), - ('DynamicEQ', 'E6-', 'P4'): (61, '2e0ca973', None, None, R_STATE_RAMP), - ('DynamicEQ', 'E6-', 'P5'): (13, '9920db49', None, None, R_JUMP), + ('DynamicEQ', 'E6-', 'P4'): (60, 'e878f970', None, None, R_STATE_RAMP), + ('DynamicEQ', 'E6-', 'P5'): (12, 'fb7800c3', None, None, R_JUMP), ('DynamicEQ', 'E9-', 'P4'): (104, 'eeee87de', None, None, R_STATE), - ('DynamicEQ', 'E9-', 'P5'): (20, 'cd16412c', None, None, R_JUMP), ('Exciter', 'E1-reset@block', 'P4'): (32, '2ea00aec', None, None, R_STATE), ('Exciter', 'E1-reset@part', 'P4'): (32, '862fb9d8', None, None, R_STATE), ('Exciter', 'E11-', 'P4'): (14, '43436585', None, None, R_STATE), - ('Exciter', 'E11-', 'P5'): (4, '7a299ce8', None, None, R_JUMP), ('Exciter', 'E4-', 'P1'): (4, '99c82410', None, None, R_STATED_P1), - ('Exciter', 'E4-', 'P4'): (21, '180e99c7', None, None, R_STATE), - ('Exciter', 'E4-', 'P5'): (4, '4bec9f4f', None, None, R_JUMP), + ('Exciter', 'E4-', 'P4'): (17, '298025b3', None, None, R_STATE), ('Exciter', 'E5-mix0', 'P4'): (32, '93dc7e80', None, None, R_STATE), - ('Exciter', 'E5-mix0', 'P5'): (32, 'cc57a31b', None, None, R_JUMP), ('Exciter', 'E6-', 'P4'): (68, 'ae6fb523', None, None, R_STATE), + ('Exciter', 'E6-', 'P5'): (8, '8dcf21c6', None, None, R_JUMP), ('Exciter', 'E9-', 'P4'): (160, '4359c45d', None, None, R_STATE), - ('Exciter', 'E9-', 'P5'): (128, '63acada5', None, None, R_JUMP), ('Expander', 'E1-reset@block', 'P4'): (16, '374c116a', None, None, R_STATE), ('Expander', 'E1-reset@part', 'P4'): (16, '5e4aff57', None, None, R_STATE), ('Expander', 'E11-', 'P5'): (4, '83adb352', None, None, R_JUMP), ('Expander', 'E2-reset_buffer', 'P4'): (16, 'ed5b5919', None, None, R_STATE), ('Expander', 'E4-', 'P5'): (8, '0de52e7a', None, None, R_JUMP), - ('Expander', 'E6-', 'P4'): (4, 'c555ff9c', None, None, R_STATE), + ('Expander', 'E6-', 'P4'): (6, '2c2c6b3a', None, None, R_STATE), + ('Fuzz', 'E10-', 'P4'): (16, 'c7e49d06', None, None, R_FUZZ_CHARGE), ('Fuzz', 'E10-macros', 'P4'): (4, '92e3f2ae', None, None, R_STATE), ('Fuzz', 'E10-mix0', 'P3'): (4, '1c2a8dc6', None, None, R_FUZZ_WANDER), ('Fuzz', 'E10-patch', 'P3'): (4, '617e432c', None, None, R_FUZZ_WANDER), - ('Fuzz', 'E10-patch', 'P4'): (4, 'f73d24bd', None, None, R_FUZZ_LEAN), + ('Fuzz', 'E10-patch', 'P4'): (20, '9cadc2f0', None, None, R_FUZZ_LEAN), ('Fuzz', 'E10-reset', 'P4'): (4, '0785c28c', None, None, R_STATE), ('Fuzz', 'E11-', 'P4'): (16, '67c5faea', None, None, R_FUZZ_LEAN), - ('Fuzz', 'E11-', 'P5'): (2, 'ee31ffec', None, None, R_JUMP), + ('Fuzz', 'E12-', 'P4'): (20, '05d517b6', None, None, R_FUZZ_CHARGE), + ('Fuzz', 'E12-', 'PF'): (20, '86e47d09', None, None, R_FUZZ_CHARGE), ('Fuzz', 'E4-', 'P4'): (32, 'd667b376', None, None, R_STATE), - ('Fuzz', 'E4-', 'P5'): (8, '51237885', None, None, R_JUMP), + ('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', 'E6-', 'P3'): (8, 'f027b35d', None, None, R_FUZZ_WANDER), - ('Fuzz', 'E6-', 'P4'): (90, '8332ee90', None, None, R_FUZZ_LEAN), - ('Fuzz', 'E6-', 'P5'): (36, 'b27d3336', None, None, R_JUMP), + ('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'): (8, '5e87946e', 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'): (94, 'eee9b9a1', None, None, R_JUMP), + ('GraphicEQ', 'E4-', 'P5'): (54, 'f206751b', None, None, R_JUMP), ('GraphicEQ', 'E6-', 'P4'): (28, 'df3917fc', None, None, R_STATE), ('GraphicEQ', 'E6-', 'P5'): (60, 'fc0f3765', None, None, R_JUMP), ('HighPass', 'E1-reset@block', 'P4'): (21, 'd9891c63', None, None, R_STATE), @@ -203,16 +181,14 @@ ('HighPass', 'E11-', 'P5'): (12, '4a25493b', None, None, R_JUMP), ('HighPass', 'E2-reset_buffer', 'P4'): (8, 'd1d172ed', None, None, R_STATE), ('HighPass', 'E4-', 'P4'): (16, 'd39c8c68', None, None, R_STATE), - ('HighPass', 'E4-', 'P5'): (18, '0ed7977b', None, None, R_JUMP), - ('HighPass', 'E5-mix0', 'P4'): (12, 'c4ed62c2', None, None, R_STATE), - ('HighPass', 'E5-mix0', 'P5'): (28, '1fcb9bb8', None, None, R_JUMP), + ('HighPass', 'E4-', 'P5'): (4, '8fe22ac6', None, None, R_JUMP), + ('HighPass', 'E5-mix0', 'P4'): (11, '227acce3', None, None, R_STATE), ('HighPass', 'E6-', 'P4'): (53, 'e040235e', None, None, R_STATE_RAMP), - ('HighPass', 'E6-', 'P5'): (42, 'b26c9502', None, None, R_JUMP), - ('HighPass', 'E9-', 'P4'): (60, '35dd8111', None, None, R_STATE), - ('HighPass', 'E9-', 'P5'): (140, 'd9ed43e6', None, None, R_JUMP), + ('HighPass', 'E6-', 'P5'): (24, '65a9c1f9', None, None, R_JUMP), + ('HighPass', 'E9-', 'P4'): (55, '95a58d3e', None, None, R_STATE), ('LadderFilter', 'E10-mix0', 'P3'): (4, 'b6574e03', None, None, R_LADDER_OSC), ('LadderFilter', 'E2-reset_buffer', 'P4'): (2, 'f71d78d2', None, None, R_STATE), - ('LadderFilter', 'E4-', 'P5'): (26, 'f2142be7', None, None, R_JUMP), + ('LadderFilter', 'E4-', 'P5'): (22, '62ce6690', None, None, R_JUMP), ('LadderFilter', 'E5-mix0', 'P3'): (4, '5df6ec0e', None, None, R_LADDER_OSC), ('LadderFilter', 'E5-mix0', 'P5'): (30, '87c00d0a', None, None, R_JUMP), ('LadderFilter', 'E6-', 'P4'): (4, 'b99eecc4', None, None, R_STATE), @@ -221,102 +197,87 @@ ('LadderFilter', 'E9-', 'P5'): (150, '638865df', None, None, R_JUMP), ('Limiter', 'E11-', 'P5'): (4, '3b25af0f', None, None, R_JUMP), ('Limiter', 'E4-', 'P5'): (12, 'c136df4f', None, None, R_JUMP), - ('Limiter', 'E6-', 'P4'): (12, 'aa0deac1', None, None, R_STATE), + ('Limiter', 'E6-', 'P4'): (6, '6dd682df', None, None, R_STATE), ('Limiter', 'E6-', 'P5'): (64, '851616ce', None, None, R_JUMP), - ('LowPass', 'E11-', 'P4'): (10, 'd449e944', None, None, R_STATE_RAMP), - ('LowPass', 'E11-', 'P5'): (8, '63b62611', None, None, R_JUMP), + ('LowPass', 'E11-', 'P5'): (4, '001c564d', None, None, R_JUMP), ('LowPass', 'E2-reset_buffer', 'P4'): (8, '960b8f7c', None, None, R_STATE), - ('LowPass', 'E4-', 'P4'): (12, '35d8966d', None, None, R_STATE_RAMP), - ('LowPass', 'E4-', 'P5'): (8, 'd0de571b', None, None, R_JUMP), - ('LowPass', 'E5-mix0', 'P4'): (11, '17e9bc9b', None, None, R_STATE), - ('LowPass', 'E5-mix0', 'P5'): (18, 'fe3523d4', None, None, R_JUMP), - ('LowPass', 'E6-', 'P4'): (62, 'dc8b4cc3', None, None, R_STATE_RAMP), - ('LowPass', 'E6-', 'P5'): (44, '662b03fa', None, None, R_JUMP), - ('LowPass', 'E9-', 'P4'): (55, '1389130d', None, None, R_STATE), - ('LowPass', 'E9-', 'P5'): (90, '308dbe2d', None, None, R_JUMP), + ('LowPass', 'E5-mix0', 'P4'): (12, 'deb21c7a', None, None, R_STATE), + ('LowPass', 'E5-mix0', 'P5'): (2, '98a49ec8', None, None, R_JUMP), + ('LowPass', 'E6-', 'P4'): (20, '48ecf86c', None, None, R_STATE_RAMP), + ('LowPass', 'E6-', 'P5'): (16, 'aa06899d', None, None, R_JUMP), + ('LowPass', 'E9-', 'P4'): (60, '7c0a5b65', None, None, R_STATE), + ('LowPass', 'E9-', 'P5'): (10, '2d3f6552', None, None, R_JUMP), ('MultibandCompressor', 'E1-reset@block', 'P4'): (18, '2ff6f235', None, None, R_STATE), ('MultibandCompressor', 'E1-reset@part', 'P4'): (18, '79006c73', None, None, R_STATE), - ('MultibandCompressor', 'E11-', 'P4'): (13, 'f6399098', None, None, R_STATE_RAMP), + ('MultibandCompressor', 'E11-', 'P4'): (12, 'cdb143fb', None, None, R_STATE_RAMP), ('MultibandCompressor', 'E11-', 'P5'): (8, 'b97c4673', None, None, R_JUMP), - ('MultibandCompressor', 'E4-', 'P4'): (17, '924a9aea', None, None, R_STATE_RAMP), - ('MultibandCompressor', 'E4-', 'P5'): (44, '5046136c', None, None, R_JUMP), - ('MultibandCompressor', 'E5-mix0', 'P4'): (21, '54656bb3', None, None, R_STATE), - ('MultibandCompressor', 'E5-mix0', 'P5'): (24, '8123a02d', None, None, R_JUMP), - ('MultibandCompressor', 'E6-', 'P4'): (45, '57db48dd', None, None, R_STATE_RAMP), - ('MultibandCompressor', 'E6-', 'P5'): (46, 'ed039b34', None, None, R_JUMP), - ('MultibandCompressor', 'E9-', 'P4'): (84, '94963081', None, None, R_STATE), - ('MultibandCompressor', 'E9-', 'P5'): (120, 'ced26bbc', None, None, R_JUMP), + ('MultibandCompressor', 'E4-', 'P4'): (11, '0fa1b121', None, None, R_STATE_RAMP), + ('MultibandCompressor', 'E4-', 'P5'): (19, 'f45b7444', None, None, R_JUMP), + ('MultibandCompressor', 'E5-mix0', 'P4'): (10, '5536d0f1', None, None, R_STATE), + ('MultibandCompressor', 'E6-', 'P4'): (40, '77eac701', None, None, R_STATE_RAMP), + ('MultibandCompressor', 'E6-', 'P5'): (46, '46adef16', None, None, R_JUMP), + ('MultibandCompressor', 'E9-', 'P4'): (40, '9221d3e1', None, None, R_STATE), ('NoiseGate', 'E1-reset@block', 'P4'): (14, '8e201f94', None, None, R_STATE), ('NoiseGate', 'E1-reset@part', 'P4'): (14, 'b8240548', None, None, R_STATE), ('NoiseGate', 'E2-reset_buffer', 'P4'): (10, '5e5004a0', None, None, R_STATE), ('NoiseGate', 'E4-', 'P5'): (4, '00a5daae', None, None, R_JUMP), - ('Notch', 'E11-', 'P4'): (16, 'c8eab446', None, None, R_STATE_RAMP), ('Notch', 'E11-', 'P5'): (4, '795303da', None, None, R_JUMP), - ('Notch', 'E4-', 'P4'): (16, 'a0fc490b', None, None, R_STATE_RAMP), - ('Notch', 'E4-', 'P5'): (16, '3084052b', None, None, R_JUMP), - ('Notch', 'E5-mix0', 'P4'): (7, 'd1169fb2', None, None, R_STATE), - ('Notch', 'E6-', 'P4'): (57, '19ae7e40', None, None, R_STATE_RAMP), - ('Notch', 'E6-', 'P5'): (56, '9bcc846b', None, None, R_JUMP), - ('Notch', 'E9-', 'P4'): (35, '8596d61b', None, None, R_STATE), + ('Notch', 'E4-', 'P5'): (4, '19e73042', None, None, R_JUMP), + ('Notch', 'E5-mix0', 'P4'): (8, '0822c04f', None, None, R_STATE), + ('Notch', 'E6-', 'P4'): (24, '6d9aa0d4', None, None, R_STATE_RAMP), + ('Notch', 'E6-', 'P5'): (44, 'ec221fd4', None, None, R_JUMP), + ('Notch', 'E9-', 'P4'): (40, '1b5d55d5', None, None, R_STATE), ('Overdrive', 'E1-reset@block', 'P4'): (34, 'aaf11cd2', None, None, R_STATE), ('Overdrive', 'E1-reset@part', 'P4'): (34, '75164359', None, None, R_STATE), ('Overdrive', 'E10-macros', 'P4'): (2, '0af84d6b', None, None, R_STATE), ('Overdrive', 'E10-patch', 'P4'): (2, '9ee38023', None, None, R_STATE), ('Overdrive', 'E10-reset', 'P4'): (4, '34caff5e', None, None, R_STATE), ('Overdrive', 'E11-', 'P4'): (8, '294ebbde', None, None, R_STATE), - ('Overdrive', 'E11-', 'P5'): (2, '51122eee', None, None, R_JUMP), + ('Overdrive', 'E11-', 'P5'): (2, 'ec676210', None, None, R_JUMP), ('Overdrive', 'E4-', 'P4'): (16, 'b10db851', None, None, R_STATE), - ('Overdrive', 'E4-', 'P5'): (2, '7fd4cd6f', None, None, R_JUMP), ('Overdrive', 'E5-mix0', 'P4'): (18, '60d901f0', None, None, R_STATE), - ('Overdrive', 'E5-mix0', 'P5'): (16, '5e835e27', None, None, R_JUMP), ('Overdrive', 'E6-', 'P4'): (44, 'f84cecd5', None, None, R_STATE), - ('Overdrive', 'E6-', 'P5'): (3, 'dc935331', None, None, R_JUMP), + ('Overdrive', 'E6-', 'P5'): (9, '59322590', None, None, R_JUMP), ('Overdrive', 'E9-', 'P4'): (90, 'e2899302', None, None, R_STATE), - ('Overdrive', 'E9-', 'P5'): (63, '63f22e1f', None, None, R_JUMP), - ('ParametricEQ', 'E11-', 'P5'): (4, 'b81844e6', None, None, R_JUMP), - ('ParametricEQ', 'E4-', 'P5'): (42, '6fc23975', None, None, R_JUMP), - ('ParametricEQ', 'E6-', 'P4'): (17, '5d762940', None, None, R_STATE_RAMP), - ('ParametricEQ', 'E6-', 'P5'): (54, '3e44f93f', None, None, R_JUMP), + ('ParametricEQ', 'E11-', 'P5'): (2, '1a3d01df', None, None, R_JUMP), + ('ParametricEQ', 'E6-', 'P4'): (15, '05c0db34', None, None, R_STATE_RAMP), + ('ParametricEQ', 'E6-', 'P5'): (10, '19891537', None, None, R_JUMP), ('Phaser', 'E1-reset@block', 'P4'): (40, 'eca258f1', None, None, R_PHASE), ('Phaser', 'E1-reset@part', 'P4'): (40, '54e5b3be', None, None, R_PHASE), ('Phaser', 'E11-', 'P4'): (16, '3eec4c9a', None, None, R_PHASE), ('Phaser', 'E11-', 'P5'): (8, '2408ab3c', None, None, R_JUMP), ('Phaser', 'E4-', 'P4'): (24, '7eca4736', None, None, R_PHASE), - ('Phaser', 'E4-', 'P5'): (24, '8e313415', None, None, R_JUMP), - ('Phaser', 'E5-mix0', 'P5'): (40, '343f01b2', None, None, R_JUMP), + ('Phaser', 'E4-', 'P5'): (14, '3c192f73', None, None, R_JUMP), ('Phaser', 'E6-', 'P4'): (76, '520373a6', None, None, R_PHASE), - ('Phaser', 'E6-', 'P5'): (86, '0a39c5d0', None, None, R_JUMP), - ('Phaser', 'E9-', 'P5'): (200, 'f4918799', None, None, R_JUMP), + ('Phaser', 'E6-', 'P5'): (84, 'e9989249', None, None, R_JUMP), ('RingMod', 'E1-reset@block', 'P4'): (40, '5f037604', None, None, R_PHASE), ('RingMod', 'E1-reset@part', 'P4'): (40, '4cdc6f50', None, None, R_PHASE), ('RingMod', 'E11-', 'P4'): (16, 'fb67e9fc', None, None, R_PHASE), - ('RingMod', 'E11-', 'P5'): (12, '697ade72', None, None, R_JUMP), + ('RingMod', 'E11-', 'P5'): (8, '739846f9', None, None, R_JUMP), ('RingMod', 'E4-', 'P4'): (8, '97e3e000', None, None, R_PHASE), - ('RingMod', 'E4-', 'P5'): (34, 'ee53bb96', None, None, R_JUMP), + ('RingMod', 'E4-', 'P5'): (30, '289d459f', None, None, R_JUMP), ('RingMod', 'E5-mix0', 'P5'): (28, 'c504653d', 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'): (10, 'f86a3f39', None, None, R_JUMP), - ('Saturation', 'E4-', 'P5'): (40, '9b4efa06', None, None, R_JUMP), - ('Saturation', 'E5-mix0', 'P5'): (40, 'fc14e42e', 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), - ('Saturation', 'E9-', 'P5'): (184, '559386b8', None, None, R_JUMP), ('TransientShaper', 'E11-', 'P5'): (8, '24eee129', None, None, R_JUMP), ('TransientShaper', 'E2-reset_buffer', 'P4'): (22, 'bee31922', None, None, R_STATE), - ('TransientShaper', 'E4-', 'P5'): (22, 'f1638686', None, None, R_JUMP), + ('TransientShaper', 'E4-', 'P5'): (8, '60441386', None, None, R_JUMP), ('TransientShaper', 'E6-', 'P4'): (22, 'a20cab27', None, None, R_STATE), - ('TransientShaper', 'E6-', 'P5'): (68, '858e96a7', None, None, R_JUMP), + ('TransientShaper', 'E6-', 'P5'): (50, '554a84b9', None, None, R_JUMP), ('Tremolo', 'E11-', 'P4'): (12, 'eedd0ced', None, None, R_PHASE), ('Tremolo', 'E11-', 'P5'): (4, 'dc306763', None, None, R_JUMP), ('Tremolo', 'E4-', 'P4'): (12, '058ac2bd', None, None, R_PHASE), ('Tremolo', 'E4-', 'P5'): (4, '034d4667', None, None, R_JUMP), - ('Tremolo', 'E6-', 'P4'): (92, 'a3a22dcf', None, None, R_PHASE), + ('Tremolo', 'E6-', 'P4'): (96, 'cf3d5954', None, None, R_PHASE), ('Tremolo', 'E6-', 'P5'): (33, 'dc1bc985', None, None, R_JUMP), ('Vibrato', 'E11-', 'P4'): (12, '4204b137', None, None, R_PHASE), ('Vibrato', 'E2-reset_buffer', 'P4'): (32, 'a6de06f2', None, None, R_PHASE), ('Vibrato', 'E4-', 'P4'): (8, 'f6a551f6', None, None, R_PHASE), - ('Vibrato', 'E4-', 'P5'): (6, '3c699fea', None, None, R_JUMP), + ('Vibrato', 'E4-', 'P5'): (2, 'e006a60f', None, None, R_JUMP), ('Vibrato', 'E6-', 'P4'): (52, 'b9c53ae9', None, None, R_PHASE), } diff --git a/tests/support/stale_blocks.py b/tests/support/stale_blocks.py index ecfa95d..c344fcd 100644 --- a/tests/support/stale_blocks.py +++ b/tests/support/stale_blocks.py @@ -203,7 +203,10 @@ def first_blip(cls, index, rate=48000, channels=2, patch=None, **options): class StaleRejoin: """Planted: the old behaviour. A bypass left behind is taken back - untouched, so what the graph held plays.""" + untouched, so what the graph held plays. Switched at once, as it was + then: a walk to the dry and back fades what the graph held.""" + + WALKS_TO_BYPASS = False def _rejoin(self, keep=()): self._stranded = False @@ -212,7 +215,10 @@ def _rejoin(self, keep=()): class ClearOnlyRejoin: """Planted, a wrong cure: the nodes are cleared, but the class is not - told, so it does not re-arm them the way its constructor does.""" + told, so it does not re-arm them the way its constructor does. Switched + at once, as `StaleRejoin` is.""" + + WALKS_TO_BYPASS = False def _rejoin(self, keep=()): if self._stranded: diff --git a/tests/test_cpython_effects_digitaldelay.py b/tests/test_cpython_effects_digitaldelay.py index 379f3c5..69e6fc9 100644 --- a/tests/test_cpython_effects_digitaldelay.py +++ b/tests/test_cpython_effects_digitaldelay.py @@ -2741,11 +2741,11 @@ def move(frame): effect.deinit() return float(np.max(np.abs(np.diff(y[20479:])))) - def test_a_jump_steps_and_small_steps_do_not(self): - # The family limit (audiocomponents#117): Mix 0 -> 2 in one move - # steps the output; the same move in 127 steps from the host, one - # a block, stays under the bar. - self.assertGreater(self._mix_moves(1), 4.0 * self._bar()) + def test_a_jump_walks_as_small_steps_do(self): + # The node walks a Mix move over 20 ms, so Mix 0 -> 2 in one move + # stays under the bar, as the same move in 127 steps from the host, + # one a block, does. + self.assertLess(self._mix_moves(1), self._bar()) self.assertLess(self._mix_moves(127), self._bar()) def _tail_across_a_stop(self, stop, loud=False): @@ -3032,9 +3032,10 @@ def test_clean_at_the_stated_input_level(self): "floor(32767 (1 - Mix)) - 1 cannot reach the rail, at any Time or " "Feedback.", ("InputCeiling.test_the_any_material_bound",)), - ("A control that jumps makes the output step: move it in small steps " - "from the host if you need it smooth.", - ("TrialClaims.test_a_jump_steps_and_small_steps_do_not",)), + ("Mix walks a jump over 20 ms, but any other control that jumps makes " + "the output step: move it in small steps from the host if you need it " + "smooth.", + ("TrialClaims.test_a_jump_walks_as_small_steps_do",)), ("When your source ends, the tail rings out as it would on silence.", ("TrialClaims.test_the_tail_rings_out_when_the_source_ends",)), ("Latency is zero samples: nothing looks ahead.", diff --git a/tests/test_cpython_effects_graphiceq.py b/tests/test_cpython_effects_graphiceq.py index ba58a45..74945f1 100644 --- a/tests/test_cpython_effects_graphiceq.py +++ b/tests/test_cpython_effects_graphiceq.py @@ -476,7 +476,12 @@ def test_the_fault_bites_on_every_crossing_and_on_one_band_alone(self): # cut to boost; 31 Hz, boost to cut). The departure does not: at rc9 # it is 0.25 of the settled level at 31 Hz and 33 times it with the # whole bank boost to cut, and the crossing-free control below holds - # it at exactly zero. + # it at exactly zero. Since a Biquad's mix walks over 20 ms + # (audiodsp#400), a band comes back from the detent fading in from + # nothing, and the stale state fades in with it: at 31 Hz the + # departure is now 0.17 of the settled level boost to cut and 0.19 + # cut to boost. Anything above zero is the fault, so the bar is a + # tenth of the settled level. for label, steps in ( ("all bands, boost to cut", _across_the_detent(127, 0)), ("all bands, cut to boost", _across_the_detent(0, 127)), @@ -490,7 +495,7 @@ def test_the_fault_bites_on_every_crossing_and_on_one_band_alone(self): _sweep(NoWakeGraphicEQ, steps, kept=theirs) self.assertEqual(len(ours), len(theirs)) departure = max(abs(a - b) for a, b in zip(ours, theirs)) - self.assertGreater(departure, clean[1] * 0.2, + self.assertGreater(departure, clean[1] * 0.1, "%s: fault inert, departs %d from a fresh " "start settling at %d" % (label, departure, clean[1])) diff --git a/tests/test_cpython_effects_overdrive.py b/tests/test_cpython_effects_overdrive.py index 9be1879..7c6f21b 100644 --- a/tests/test_cpython_effects_overdrive.py +++ b/tests/test_cpython_effects_overdrive.py @@ -473,9 +473,13 @@ def rising(cls): effect.deinit() self.assertEqual(checked, 13) + # At Mix 0 the class walks the clip branch out and plays the source, + # so the circuit's levels there are not what plays: read them only + # where the graph does. out = kit_faults.fault_reachability( Overdrive, NoClipAdd, - lambda effect: float(effect._circuit.voice[1].level), + lambda effect: (float(effect._circuit.voice[1].level) + if effect._value(3) > 0.0 else float("nan")), lambda cls: build(cls, probe=probes.silence(512, 2), frames=512), label="Overdrive T5 NoClipAdd") self.assertGreater(abs(out["target"] - out["clean"]), 0.5) @@ -1279,8 +1283,8 @@ class DoubleChargeOverdrive(rebuilt.Overdrive): `_rearm` had charged it.""" NAME = "Overdrive" - def _rearm(self, level): - super()._rearm(level) + def _rearm(self): + super()._rearm() self._dc_key = None diff --git a/tests/test_cpython_effects_pingpongdelay.py b/tests/test_cpython_effects_pingpongdelay.py index d2e651a..197f498 100644 --- a/tests/test_cpython_effects_pingpongdelay.py +++ b/tests/test_cpython_effects_pingpongdelay.py @@ -2325,11 +2325,11 @@ def move(frame): effect.deinit() return float(np.max(np.abs(np.diff(y[20479:])))) - def test_a_jump_steps_and_small_steps_do_not(self): - # The family limit (audiocomponents#117), the matrix's E4-m2=127 - # P5 cells: Mix 0 -> 2 in one move steps the output; the same move - # in 127 steps from the host, one a block, stays under the bar. - self.assertGreater(self._mix_moves(1), 4.0 * self._bar()) + def test_a_jump_walks_as_small_steps_do(self): + # The matrix's E4-m2=127 P5 cells: the node walks a Mix move over + # 20 ms, so Mix 0 -> 2 in one move stays under the bar, as the same + # move in 127 steps from the host, one a block, does. + self.assertLess(self._mix_moves(1), self._bar()) self.assertLess(self._mix_moves(127), self._bar()) def _tail_across_a_stop(self, stop, loud=False): @@ -2581,9 +2581,10 @@ def test_clean_at_the_stated_input_level(self): ("Repeat Cut's high-pass overshoots, so with it in leave more room.", ("InputCeiling.test_repeat_cut_needs_more_room", "InputCeiling.test_the_cut_in_ceiling")), - ("A control that jumps makes the output step: move it in small steps " - "from the host if you need it smooth.", - ("TrialClaims.test_a_jump_steps_and_small_steps_do_not",)), + ("Mix walks a jump over 20 ms, but any other control that jumps makes " + "the output step: move it in small steps from the host if you need it " + "smooth.", + ("TrialClaims.test_a_jump_walks_as_small_steps_do",)), ("When your source ends, the tail rings out as it would on silence.", ("TrialClaims.test_the_tail_rings_out_when_the_source_ends",)), ("A click comes out on the frame it went in: there is no latency.", diff --git a/tests/test_cpython_effects_slapbackdelay.py b/tests/test_cpython_effects_slapbackdelay.py index 9b7128b..57598c8 100644 --- a/tests/test_cpython_effects_slapbackdelay.py +++ b/tests/test_cpython_effects_slapbackdelay.py @@ -1974,9 +1974,10 @@ def test_clean_at_the_stated_input_level(self): ("A constructor value outside a knob's span clamps to the nearer stop, a " "`tone_hz` of 0 or less is Tone out, and NaN takes the option's " "default.", "test_constructor_clamps_and_nan"), - ("A control that jumps makes the output step: move it in small steps " - "from the host if you need it smooth.", - "test_a_jumping_control_steps_the_output"), + ("Level walks a jump over 20 ms, but any other control that jumps makes " + "the output step: move it in small steps from the host if you need it " + "smooth.", + "test_level_to_0_and_back_walks"), ("When your source ends, the tail rings out as it would on silence.", "test_the_tail_rings_out_when_the_source_ends"), ("`SlapbackDelay` - one tape repeat after the dry, in mono: the Sun " @@ -2207,11 +2208,11 @@ def test_a_reset_restarts_the_wobble(self): self.assertGreater(differ, 0.99 * 1000 * channels, (rate, channels)) - def test_a_jumping_control_steps_the_output(self): - # The matrix's E5 cell at patch 2 (Level to 0 and back) steps past - # its bar; the class declares it (audiocomponents#117). + def test_level_to_0_and_back_walks(self): + # The matrix's E5 cell at patch 2 (Level to 0 and back): the node + # walks the move over 20 ms, so the output stays under its bar. res = _cell("E5-mix0", 2) - self.assertTrue(res["P5"].startswith("RED"), res) + self.assertEqual(res["P5"], "ok", res) self.assertEqual(res["P1"], "ok", res) def test_the_tail_rings_out_when_the_source_ends(self): diff --git a/tests/test_first_pull.py b/tests/test_first_pull.py index 16cbdcd..7b7f55a 100644 --- a/tests/test_first_pull.py +++ b/tests/test_first_pull.py @@ -100,7 +100,9 @@ class AMixZeroAfterAMidStreamArmingIsTheWire(unittest.TestCase): source, on time. Each primed a block eagerly, so the bypass after it handed out a source 256 frames short (5 ms of the song skipped).""" - FRAMES = 4096 + # Long enough for a class that walks to the dry before it switches, + # with the 512-frame blocks of a mono mixer. + FRAMES = 8192 TAIL = 1024 def check(self, name, channels, move): diff --git a/tests/test_level_ramp.py b/tests/test_level_ramp.py new file mode 100644 index 0000000..18f5b09 --- /dev/null +++ b/tests/test_level_ramp.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""A level that jumps walks over 20 ms in the mixer that carries it. + +Eleven classes carry Mix, or another level, on `audiomixer.Mixer` voices. +Each sets the mixer's `level_ramp` to 20 ms (`_component.ramp_levels`), so +a jump of that control walks there instead of landing at the voice's next +zero crossing. CircuitPython's Mixer has no `level_ramp`; there the levels +step as before, and `ramp_levels` says nothing. + +| what it checks | bar | +|---|---| +| every mixer in the eleven ramps | `level_ramp` is `sample_rate * 20 / 1000` on each `audiomixer.Mixer` a class builds, at 48 and 22.05 kHz | +| a Mix jump does not step | DynamicEQ's Mix moved 127 to 38 in one call on a 200 Hz tone: the largest step within two blocks of the move is under 1.5 times the larger of the tone's own before and after (it was 1.70 times with the level landing at once) | +| a mixer without the ramp | `ramp_levels` on an object that refuses `level_ramp` hands it back untouched | + + python -m unittest tests.test_level_ramp +""" + +import math +import os +import sys +import unittest +from array import array + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "lib")) + +import audiocore # noqa: E402 +import audiofilters # noqa: E402 +import audiomixer # noqa: E402 + +import audioeffects # noqa: E402 +from audioeffects import _component # noqa: E402 + +CLASSES = ("Bitcrusher", "Compressor", "DeEsser", "Distortion", "DynamicEQ", + "Exciter", "Fuzz", "MultibandCompressor", "Overdrive", + "Saturation", "Vibrato") +#: Fuzz's germanium circuit, its default, sums on no mixer. +OPTIONS = {"Fuzz": {"character": "cascade"}} +BLOCK = 256 +HZ = 200.0 +HAS_RAMP = hasattr(audiomixer.Mixer(voice_count=1, sample_rate=48000), + "level_ramp") + + +def tone(rate, channels=2): + """A looped 200 Hz sine, handed out a block at a time.""" + period = int(rate / HZ) * 20 + values = array("h", bytes(2 * period * channels)) + for i in range(period): + v = int(round(12000 * math.sin(2 * math.pi * HZ * i / rate))) + for c in range(channels): + values[i * channels + c] = v + raw = audiocore.RawSample(values, sample_rate=rate, channel_count=channels) + block = audiofilters.Filter(filter=None, mix=1.0, + buffer_size=BLOCK * channels * 2, + sample_rate=rate, channel_count=channels) + block.play(raw, loop=True) + return block + + +def mixers(effect): + """Every audiomixer.Mixer reachable from the instance's attributes.""" + found = [] + for value in vars(effect).values(): + items = value if isinstance(value, (list, tuple)) else (value,) + for item in items: + if isinstance(item, audiomixer.Mixer) and item not in found: + found.append(item) + return found + + +def pull(effect, frames, channels=2): + out = array("h") + while len(out) < frames * channels: + out.extend(array("h", bytes(audiocore.get_buffer(effect.output)[1]))) + return list(out[0::channels]) + + +@unittest.skipUnless(HAS_RAMP, "this audiodsp's Mixer has no level_ramp") +class LevelRamp(unittest.TestCase): + + def test_every_mixer_ramps(self): + for rate in (48000, 22050): + for name in CLASSES: + with self.subTest(name=name, rate=rate): + effect = audioeffects.create(name, tone(rate), rate, + **OPTIONS.get(name, {})) + found = mixers(effect) + self.assertTrue(found, "no mixer found") + for node in found: + self.assertEqual(node.level_ramp, + (rate * 20 + 500) // 1000) + effect.deinit() + + def test_mix_jump_does_not_step(self): + rate = 48000 + effect = audioeffects.create("DynamicEQ", tone(rate), rate) + index = list(type(effect).MACRO_LABELS).index("Mix") + effect.set_macro(index, 127) + head = pull(effect, 24000) + effect.set_macro(index, 38) + tail = pull(effect, 24000) + effect.deinit() + trace = head + tail + at = len(head) + steps = [abs(trace[i] - trace[i - 1]) for i in range(1, len(trace))] + near = max(steps[at - BLOCK:at + 2 * BLOCK]) + own = max(max(steps[at // 2:at - 2 * BLOCK]), + max(steps[at + 4800:at + 14400])) + self.assertLess(near / own, 1.5) + + +class NoRamp(unittest.TestCase): + + def test_a_mixer_without_it(self): + class Refuses: + __slots__ = () + node = Refuses() + self.assertIs(_component.ramp_levels(node, 48000), node) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle_matrix.py b/tests/test_lifecycle_matrix.py index 203a7bb..91bab0e 100644 --- a/tests/test_lifecycle_matrix.py +++ b/tests/test_lifecycle_matrix.py @@ -591,15 +591,18 @@ class TestPlants(unittest.TestCase): ("stalemix", "routed", "E5-mix0", "PR"), ("depthleak", "depthjump", "E5-mix0", "P1"), ("depthpop", "depthjump", "E5-mix0", "P5"), + ("loudjump", "loudramp", "E4-m1=127", "P5", 22050), ) def test_each_plant_is_red_and_its_control_green(self): - for plant, control, event, prop in self.CASES: + for row in self.CASES: + plant, control, event, prop = row[:4] + rate = row[4] if len(row) > 4 else 48000 with self.subTest(plant=plant): - self.assertTrue(plant_cell(plant, event, prop) + self.assertTrue(plant_cell(plant, event, prop, rate=rate) .startswith("RED"), plant) - self.assertEqual(plant_cell(control, event, prop), "ok", - control) + self.assertEqual(plant_cell(control, event, prop, rate=rate), + "ok", control) def test_a_depth_is_judged_as_a_depth_and_not_as_a_crossfade(self): """A depth jumps: its P5 allows the step the jump itself makes. The