Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion AUDIODSP_PIN
Original file line number Diff line number Diff line change
Expand Up @@ -482,5 +482,13 @@
# CircuitPython main's widened synthio biquad, MP3 decoding on CPython, type
# stubs for thirteen modules, and pump and build changes for boards.
#
# Moved to ec82463 on 2026-11-10, for audiomath.Multiply's 20 ms walk of a
# `mix` move (audiodsp#411), which takes Tremolo's Depth to 0 and back
# without a step. What else moved beneath the components, by
# `git log 84b8b45..ec82463 -- src`: type stubs for the remaining modules,
# CircuitPython's -Werror=float-equal on the level ramps, a pump that can
# render from an interrupt, and `__version__`/`__revision__` on audiocore,
# audiomp3, audiopump and synthio.
#
# <ref> <commit> -- ref is what CI installs (git+https://...@<ref>)
84b8b45f5b838afe3529f53e1468abe7711b6bcc 84b8b45f5b838afe3529f53e1468abe7711b6bcc
ec824639bb6bec929740272e500e97c1f423a990 ec824639bb6bec929740272e500e97c1f423a990
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ way on every interpreter and board.

## Next

- Control moves that ramp where they still step: Fuzz back from Mix 0,
Tremolo's Depth, DeEsser's Range at 22.05 kHz, then filter corners, then
crossfaded patch changes. [The policy](docs/how-it-works/control-smoothing.md).
- Control moves that ramp where they still step: filter corners, an effect
built at Mix 0 coming in the first time, then crossfaded patch changes.
[The policy](docs/how-it-works/control-smoothing.md).

## Later

Expand Down
12 changes: 7 additions & 5 deletions docs/how-it-works/control-smoothing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ Phaser, Saturation, Tremolo and Vibrato, with the exceptions below. The Mix
of DigitalDelay and PingPongDelay and SlapbackDelay's Level walk too, since
their delay node does.

A few moves still step. Fuzz coming back from Mix 0 cuts in, Tremolo's Depth
jumps, a filter corner moves at once, and so does a patch change that rebuilds
part of a graph. Until those walk too, send such a jump as small steps, one a
A few moves still step. A filter corner moves at once, and so do a switch, a
patch change that rebuilds part of a graph, and an effect built at Mix 0 the
first time its Mix leaves 0. Until those walk too, send such a jump as small steps, one a
block: the delays' own tests show Mix moved in 127 steps, one a block, steps
the output by less than one and a half times the steepest step of the tone
going through it. The rest is on the
Expand All @@ -28,7 +28,8 @@ clicking.

Mix 0 is still your signal, byte for byte. An effect that hands you the
source itself there walks its graph to the dry first and switches once the
walk has landed, and walks back in from the dry when Mix leaves 0.
walk has landed, and walks back in from the dry when Mix leaves 0. DeEsser's
Range 0 works the same way, and Tremolo's Depth 0 walks there and back too.

A delay's Time is not ramped here: it already glides, at the rate its Glide
control sets. A filter corner that jumps steps the output too, more quietly
Expand All @@ -49,7 +50,8 @@ reaches anyone using the bare nodes too, CircuitPython users included.

The nodes that walk are `audiomixer.Mixer`'s voice levels (its `level_ramp`),
the Waveshaper's `mix` and `post_gain`, the Biquad's and the AllPass's `mix`,
the Dynamics node's makeup and the FeedbackDelay's `mix`. An
the Dynamics node's makeup, the FeedbackDelay's `mix` and
`audiomath.Multiply`'s `mix`. An
`audioroute.Port` told `play(source, after=frames)` keeps playing what it
plays until a walk has landed, which is how Mix 0 switches to the source
without a cut.
Expand Down
9 changes: 8 additions & 1 deletion lib/audioeffects/_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -1170,14 +1170,21 @@ def _bypass_walk(self):
"""Frames a Mix 0 bypass waits for the graph's walk to the dry
before the port switches, or 0 where it switches at once: a class
that does not walk, a mixer without `level_ramp`, or no Port."""
if not (self.WALKS_TO_BYPASS and LEVELS_RAMP and PORT is not None):
if not (self.WALKS_TO_BYPASS and self._walks_levels()
and PORT is not None):
return 0
# Whole blocks: a switch a block's part past a move on a block
# boundary would leave the head holding part of a source buffer,
# 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 _walks_levels(self):
"""Whether the levels the walk to the dry moves walk: the mixers'
on this interpreter (`ramp_levels`). A class that walks on its
nodes' own `mix` instead says so here."""
return bool(LEVELS_RAMP)

def _land_bypass(self):
"""Switch the port now to a bypass `_route_around` left waiting: a
reset cuts anyway, and starts from the state it leaves, so nothing
Expand Down
30 changes: 28 additions & 2 deletions lib/audioeffects/deesser.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ class DeEsser(_component.Component):
#: coupling state" in docs/reference/component-api.md.
TAIL_SAMPLES = 256

#: Range 0 walks the output mixer to the dry before the port switches.
WALKS_TO_BYPASS = True

MACRO_LABELS = ("Frequency", "Range", "Sensitivity", "Mode", "Release",
"Attack", "Listen")
MACRO_MODES = {0: "UNIPOLAR", 1: "UNIPOLAR", 2: "UNIPOLAR", 3: "TOGGLE",
Expand Down Expand Up @@ -522,10 +525,31 @@ def _refresh_output(self):
if not self._ready:
return
if self._is_wire():
self._output = self._adapter
# The output mixer walks to the dry alone, and the port switches
# to the adapter once the walk has landed.
self._route_around(self._adapter)
return
# Back off a bypass the walk reached, the graph still holds what it
# had when Range went to 0: the Splitters' rings, the filters' and
# the gain cell's memory, the block each voice had queued. Played
# on, that was a 256-frame lump on the way back. So the rings let
# go of those frames, which the host has read past since, and every
# voice is primed afresh, its levels walking in from the dry.
if self._rejoin():
top, raw, band = self._splitters
if self._let_go_of_source(top, self._adapter):
raw.clear()
band.clear()
for node, tap in ((self._lows[0], top.tap(1)),
(self._highs[0], top.tap(2))):
node.play(tap)
self._duck.key(top.tap(0))
self._primed = False
if not self._primed:
self._prime()
voices = self._out.voice
self._rearm_from(((voices[0], 1.0), (voices[1], 0.0),
(voices[2], 0.0), (voices[3], 0.0)),
self._prime)
self._output = self._tail

# -- reset ---------------------------------------------------------
Expand Down Expand Up @@ -579,6 +603,8 @@ def reset(self):
self._kept_adapter = None
self._hand_up_lazy(pull)
self._stranded = False
self._bypass_pending = None
self._walked_out = False
for node, release in reversed(old):
if release is False:
continue
Expand Down
144 changes: 143 additions & 1 deletion lib/audioeffects/fuzz.py
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,38 @@ def _group_delay_samples(oversample):
return int(round(delay))


class _MixAsLevel:
"""A node's `mix` under the name a mixer voice gives its level, so the
walk to the dry and back can hold it the way it holds a voice. A Biquad
and a Waveshaper walk a `mix` move over 20 ms. The Waveshaper takes its
`mix` through `set()` and doesn't hand it back, so the last value set is
kept here. Which one a node is gets asked once: a plain attribute
assignment would land on a Python-level node without reaching it."""

def __init__(self, node, level=1.0):
self._node = node
self._level = level
try:
node.mix
self._by_set = False
except AttributeError:
self._by_set = True

@property
def level(self):
if self._by_set:
return self._level
return self._node.mix

@level.setter
def level(self, value):
self._level = value
if self._by_set:
self._node.set(mix=value)
else:
self._node.mix = value


class Fuzz(_component.Component):
"""Fuzz Face germanium, or a Ram's Head 1973 Big Muff as `cascade`.

Expand Down Expand Up @@ -738,6 +770,7 @@ def _build(self, fuzz_db=36.0, level=1.0, tone=0.5, bias=0.0,
self._wet_delay = _group_delay_samples(oversample)
self._latency = self._wet_delay if mix > 0.0 else 0
self._routed_lean = False
self._wet_mix = 1.0
self._ready = False

# The class keeps a reference to the table it loaded. `Waveshaper`
Expand Down Expand Up @@ -1040,6 +1073,7 @@ def _refresh(self):
for shaper in self._shapers:
shaper.set(pre_gain=fuzz_gain, bias=bias, mix=wet_mix,
post_gain=post)
self._wet_mix = wet_mix
if self._ready and bias != self._charged_bias:
self._charge_output(rewire=True)
else:
Expand All @@ -1062,10 +1096,18 @@ def _refresh_output(self):
construction rather than by luck - which is what the `MidSide` tail
stopped being when the pin moved to audiodsp `3388df4` (seven samples
of 16384 off by one LSB).

The way there and back walks. At Mix 0 the graph walks to the
source over 20 ms (`_at_dry`), and the port switches to the source
itself once the walk has landed; back off Mix 0 the graph comes back
as it was built and walks in from the source.
"""
if not self._ready:
return
if self._value(5) <= 0.0:
if not self._constructing:
for stage, at in self._at_dry():
stage.level = at
self._route_around(self._source)
self._latency = 0
else:
Expand All @@ -1075,10 +1117,110 @@ def _refresh_output(self):
# the host has since read past.
self._let_go_of_source(self._head, self._source,
self._head_consumers)
self._rearm()
self._walk_back_in()
else:
self._wet_again()
self._output = self._tilt
self._latency = self._wet_delay

WALKS_TO_BYPASS = True

def _at_dry(self):
"""What makes the graph the source itself, as (stage, level) pairs.

On `germanium` the chain is in series, so every node in it passes
its input on: both coupling sections, the shapers and the tilt shelf
at `mix` 0. On `cascade` the dry voice alone, at unity, and the tilt
shelf at `mix` 0."""
if self._tone_mix is None:
stages = [_MixAsLevel(self._hps[0])]
stages += [_MixAsLevel(shaper, self._wet_mix)
for shaper in self._shapers]
stages += [_MixAsLevel(self._hps[1]), _MixAsLevel(self._tilt)]
return [(stage, 0.0) for stage in stages]
voices = self._tone_mix.voice
return [(voices[0], 0.0), (voices[1], 0.0), (voices[2], 1.0),
(_MixAsLevel(self._tilt), 0.0)]

def _walks_levels(self):
# `germanium` walks on its nodes' own `mix`, which audiodsp's
# Biquad and Waveshaper walk on every interpreter.
if self._tone_mix is None:
return True
return _component.Component._walks_levels(self)

def _wet_again(self):
"""Put back what `_at_dry` took away, where the graph played on: a
Mix 0 that comes back before its walk landed walks back from where
it stands. `_refresh` has set the shapers and the voices."""
self._tilt.mix = 1.0
if self._tone_mix is None:
self._hps[0].mix = 1.0
self._hps[1].mix = 1.0

def _walk_back_in(self):
"""Arm the graph as `_build` does and walk it in from the source.

Off a bypass that walked out, every stage `_at_dry` names starts the
first block at its dry level and walks to where the controls stand,
so the output leaves the source without a step. A bypass reached
without a walk (a reset, or a Mix 0 the host never heard), and a
germanium graph at an off-centre Bias, come back as a fresh instance
does, at once."""
walked, self._walked_out = self._walked_out, False
# Off-centre Bias the shapers answer silence with a constant that
# only the charged output pole holds back. Walking the pole's mix
# in would let a quarter of it through on the way, 6847 LSB out of
# silence at patch 2, so there the graph comes back at once.
offset = self._tone_mix is None and self._value(3) != 0.0
if self._rearming_reset or not walked or offset:
self._wet_again()
self._rearm()
return
if self._tone_mix is not None:
self._wet_again()
held = self._at_dry()
wanted = [(stage, stage.level) for stage, _at in held]
for stage, at in held:
stage.level = at
self._prime_on_quiet(self._tilt, self._tone_mix)
_component.with_ramps(self._rearm)
for stage, level in wanted:
stage.level = level
return
# germanium. The output pole is charged on the offset the shapers
# give at the controls' own mix, with the pole itself held at the
# dry; then the shapers are cleared and primed at the dry, and the
# first coupling section and the tilt shelf, cleared since the
# bypass, prime at the dry on silence too.
hp_a, hp_b = self._hps
hp_b.mix = 0.0
self._charge_output()
for shaper in self._shapers:
audiocore.reset_buffer(shaper)
shaper.set(mix=0.0)
self._pull_node(shaper)
for node, source in self._charge_wiring:
node.play(source)
hp_a.mix = 0.0
self._prime_on_quiet(hp_a, self._source)
self._prime_on_quiet(self._tilt, hp_b)
hp_a.mix = 1.0
for shaper in self._shapers:
shaper.set(mix=self._wet_mix)
hp_b.mix = 1.0
self._tilt.mix = 1.0

def _prime_on_quiet(self, node, source):
"""Pull one block of silence through `node` alone, so its `mix`
stands where it is set now, and point it back at `source`."""
held = node.mix
node.mix = 0.0
node.play(self._quiet)
self._pull_node(node)
node.play(source)
node.mix = held

def _rearm(self):
"""Arm a graph Mix 0 left un-pulled, the way `_build` arms it.

Expand Down
14 changes: 8 additions & 6 deletions tests/support/lifecycle_known.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
"state that outlives tail_samples: a detector re-attacking after a"
" reset or a rejoin, or a filter's or coupling pole's residue a few"
" LSB past the declared tail (audiocomponents#155)")
R_FUZZ_WALK_IN = (
"Fuzz walks back from Mix 0 on its nodes' own mix, so its output pole"
" hears the walk: it meets a fresh instance as that pole settles, from"
" a few hundred LSB just after the walk to under 20 within 100 ms")
R_FUZZ_LEAN = (
"the lean shaper follows the patch, so macros moved back after a move"
" to or from Fuzz - lean stay at that patch's oversampling; with the"
Expand Down Expand Up @@ -112,7 +116,8 @@
('Compressor', 'E6-', 'P4'): (150, 'fa3d35a7', None, None, R_STATE),
('Compressor', 'E6-', 'P5'): (162, '8c2759f9', None, None, R_JUMP),
('Compressor', 'E9-', 'P4'): (156, '02b3f3c5', None, None, R_STATE),
('DeEsser', 'E4-', 'P5'): (10, 'c323d9b3', None, None, R_JUMP),
('DeEsser', 'E4-', 'P5'): (8, '7cf53fdf', None, None, R_JUMP),
('DeEsser', 'E5-mix0', 'PR'): (6, 'a56b79fd', None, None, R_STATE),
('DeEsser', 'E5-mix0', 'P4'): (2, '8051e9a5', None, None, R_STATE),
('DeEsser', 'E9-', 'P4'): (8, '2156c340', None, None, R_STATE),
('DeEsser', 'E9-src256', 'P4'): (2, 'a589814f', None, None, R_STATE),
Expand Down Expand Up @@ -163,13 +168,12 @@
('Fuzz', 'E4-', 'P5'): (4, 'eb979d08', None, None, R_JUMP),
('Fuzz', 'E5-mix0', 'P3'): (4, '785ac763', None, None, R_FUZZ_WANDER),
('Fuzz', 'E5-mix0', 'P4'): (36, '70f1f6b6', None, None, R_STATE),
('Fuzz', 'E5-mix0', 'P5'): (10, '0df66d65', None, None, R_JUMP),
('Fuzz', 'E5-mix0', 'PR'): (36, '70ed1de8', None, None, R_FUZZ_WALK_IN),
('Fuzz', 'E6-', 'P3'): (8, 'f027b35d', None, None, R_FUZZ_WANDER),
('Fuzz', 'E6-', 'P4'): (89, '6d78d82a', None, None, R_FUZZ_LEAN),
('Fuzz', 'E6-', 'P5'): (24, 'abac2451', None, None, R_JUMP),
('Fuzz', 'E9-', 'P3'): (16, 'a4652cfe', None, None, R_FUZZ_WANDER),
('Fuzz', 'E9-', 'P4'): (192, '1cf6be45', None, None, R_STATE),
('Fuzz', 'E9-', 'P5'): (26, '95969050', None, None, R_JUMP),
('GraphicEQ', 'E11-', 'P5'): (4, '1805e152', None, None, R_JUMP),
('GraphicEQ', 'E2-reset_buffer', 'P4'): (12, '17431038', None, None, R_STATE),
('GraphicEQ', 'E4-', 'P5'): (54, 'f206751b', None, None, R_JUMP),
Expand Down Expand Up @@ -255,11 +259,9 @@
('RingMod', 'E11-', 'P4'): (16, 'fb67e9fc', None, None, R_PHASE),
('RingMod', 'E11-', 'P5'): (8, '739846f9', None, None, R_JUMP),
('RingMod', 'E4-', 'P4'): (8, '97e3e000', None, None, R_PHASE),
('RingMod', 'E4-', 'P5'): (30, '289d459f', None, None, R_JUMP),
('RingMod', 'E5-mix0', 'P5'): (28, 'c504653d', None, None, R_JUMP),
('RingMod', 'E4-', 'P5'): (22, '60c2ddf8', None, None, R_JUMP),
('RingMod', 'E6-', 'P4'): (96, '862d0a32', None, None, R_PHASE),
('RingMod', 'E6-', 'P5'): (44, 'aefba1ae', None, None, R_JUMP),
('RingMod', 'E9-', 'P5'): (140, 'f09d9cee', None, None, R_JUMP),
('Saturation', 'E11-', 'P5'): (8, 'd037b49c', None, None, R_JUMP),
('Saturation', 'E4-', 'P5'): (28, 'b87df81b', None, None, R_JUMP),
('Saturation', 'E6-', 'P5'): (94, 'c9c25f1c', None, None, R_JUMP),
Expand Down
Loading