Skip to content

Level and Mix moves walk over 20 ms: Mixer.level_ramp, the nodes, Port.play(after=) - #400

Merged
bdbarnett merged 4 commits into
mainfrom
mixer-level-ramp
Nov 10, 2026
Merged

bdbarnett merged 4 commits into
mainfrom
mixer-level-ramp

Conversation

@bdbarnett

@bdbarnett bdbarnett commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

A jump of a level or a Mix now walks over 20 ms instead of stepping, in the nodes that carry them. This is the first stage of the control-smoothing plan audioeffects follows: Mix, levels and depths first, then filter corners, then crossfaded patch changes.

What walks:

  • audiomixer.Mixer gets a level_ramp property, a number of frames a voice's level or pan move takes, in a straight line, instead of waiting for the voice's next zero crossing. At 0, the default, the Mixer renders CircuitPython's bytes. CircuitPython's zero-crossing wait stops a single voice stepping mid-cycle, but a dry and a wet voice cross zero at different moments, and a voice that never crosses zero waits for the end of a stretch and then lands at once. The new test's crossfade into a voice that stays positive steps 14.6 times the tone's own largest step without the ramp and 0.96 times with it. How it walks is in docs/how-it-works/upstream-diff.md, "A level ramp on Mixer voices".
  • 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 walk a move over 20 ms (src/shared/audiodsp_level_ramp.h). These nodes are this package's own, so nothing upstream changes. A node's first value lands at once, so a fresh node renders as it did, and a reset clears a walk in progress.
  • audioroute.Port.play(source, after=frames) keeps playing what the port plays until that many frames have gone, then switches at the start of the next pull. An effect uses it to walk its graph to the dry before handing over the source, so Mix 0 is still the source byte for byte without a cut on the way there. Pointing a port at the source it already plays is no longer a new start, so it no longer cuts a switch short.

The audioroute stub declares Port.play(after=) too, so a type checker accepts a deferred switch; checked with mypy over the stub and tests/test_stubs_match_modules.py.

What I ran: the CPython tests for every module this touches (audiobiquad, audioecho, audiodynamics, audioshaper, audioroute, audiograph nodes, the mixer level ramp, the new node ramp tests, binding parity, free threading and the API test: 441 tests, all passing); the five CI gates; and verify_dsp.py with CPython and a unix MicroPython built from this branch, 65 comparisons and 0 failures, including the new node_level_ramp_probe.py and mixer_level_ramp_probe.py. delay_lifecycle_probe.py held the FeedbackDelay's output at Mix 0 to the input from the first frame; it now holds it from where the 20 ms walk lands and checks the walk is there. In audioeffects, renders of the 15 classes that build on these nodes step at most 1.09 times the tone's own steepest step when Mix or a level jumps, where they stepped up to 62 times before.

Not verified: the three-way run with the CircuitPython oracle (its pinned binary predates a keyword filter_f32_frames_probe.py uses and stops the run there, unrelated to this change), and no board build.

@bdbarnett bdbarnett changed the title audiomixer: Mixer.level_ramp walks a level move instead of stepping Level and Mix moves walk over 20 ms: Mixer.level_ramp, the nodes, Port.play(after=) Nov 10, 2026
Set level_ramp to a number of frames and a voice's level or pan move walks
from the old level to the new one in a straight line over that many frames,
landing exactly on the level asked for. Left at 0, the default, the Mixer is
CircuitPython's to the byte. A voice's first level still lands at once, so a
fresh Mixer's first block doesn't change with the ramp on.

CircuitPython's zero-crossing wait keeps a single voice from stepping, but two
voices crossfading cross zero at different moments, and a voice that never
crosses zero lands at the end of a stretch at once, so a crossfade moved in
one call can click. The walk is in the node because it has to move once a
sample.

The native mixer steps once per packed word and the CPython twin once per
int16 pair, the same word. tests/test_cpython_audiomixer_level_ramp.py holds
the walk's shape; mixer_level_ramp_probe.py holds MicroPython and CPython to
the same bytes (verify_dsp.py, 64 comparisons, 0 failures, against a unix
build of this branch).
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 now walk a move in
a straight line over 20 ms instead of landing at once, so a jump of an
effect's Mix or level doesn't click. A node's first value lands at once,
and a reset clears a walk in progress.

audioroute.Port.play() takes `after=frames`: the port keeps playing what
it plays until that many frames have gone, then switches at the start of
the next pull. That lets an effect walk its graph to the dry and only then
hand the source over. Pointing a port at the source it already plays is
no longer a new start.
The stub now declares the after= keyword the node takes since the level ramps, so a type checker accepts a deferred switch. Checked with mypy over the stub and tests/test_stubs_match_modules.py.
@bdbarnett
bdbarnett merged commit 84b8b45 into main Nov 10, 2026
36 checks passed
@bdbarnett
bdbarnett deleted the mixer-level-ramp branch November 10, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant