Repository navigation
Level and Mix moves walk over 20 ms: Mixer.level_ramp, the nodes, Port.play(after=) - #400
Merged
Merged
Conversation
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
force-pushed
the
mixer-level-ramp
branch
from
November 10, 2026 12:59
745e52a to
c0e7c61
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Mixergets alevel_rampproperty, 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".mixandpost_gain, the Biquad's and the AllPass'smix, the Dynamics node's makeup and the FeedbackDelay'smixwalk 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 andtests/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.pywith CPython and a unix MicroPython built from this branch, 65 comparisons and 0 failures, including the newnode_level_ramp_probe.pyandmixer_level_ramp_probe.py.delay_lifecycle_probe.pyheld 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.pyuses and stops the run there, unrelated to this change), and no board build.