Skip to content

Effects: Flanger on audiodelays.Flanger, and a round of fixes across the library - #165

Merged
bdbarnett merged 132 commits into
mainfrom
effects-p7/land
Oct 15, 2026
Merged

bdbarnett merged 132 commits into
mainfrom
effects-p7/land

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

The effects library's next release.

New: Flanger is now a thin class on CircuitPython's audiodelays.Flanger, and the two older Flanger implementations are gone.

Fixed, each with a test that failed before:

  • Resets now leave every class exactly as a fresh build, and never take audio from the source.
  • Mix 0 is the wire, including before the first pull and after a reset or a patch change.
  • No more clicks, thumps or replayed audio on resets, bypass returns, or patch and lean-patch switches across about 30 classes.
  • Tremolo, AutoPan, RingMod and Phaser restart their LFO on a reset. Phaser and Flanger follow the host's time signature.
  • ConvolutionReverb trims its darker rooms to the room's own measured low-frequency gain, so every patch takes at least −12 dBFS.
  • Limiter gains a lean patch for the ESP32-S3; MultibandCompressor documents bands=2 there.
  • One clamp rule for out-of-range, NaN and infinite macro values.

Needs audiodsp 0.6.6 (pinned at its release candidate here); the filters' coefficient moves, the delay's wow ramp and shape swap, and several node fixes come from it.

Tests: the lifecycle matrix now gates all 45 classes. A pull request runs the classes it touches, a change to a shared module runs a nine-class sample, and a push to main runs all of them.

Known and filed: #142, #143 to #158, #160, #161.

…lays.Flanger in the node matrix

AUDIODSP_PIN names the 7b candidate by commit until v0.6.6rc2 is cut.
NodeFlanger joins the node matrix: green on CPython and MicroPython with
its sweep's free-running LFO declared (reset, Rate moves, patches), and
its E8 skipped on CircuitPython, where upstream's Flanger never returns
from a looped source that ends empty, as Echo and the pitch shifters
already are. NodePitchShift declares E1 with E2 and a freeze set
mid-splice, and its CircuitPython difference is restated to the splice
alignment (285 cells).
A class that raised without get_buffer at one patch read as a single
'run' cell for the whole class, and the check ran on CPython only, so a
path a class takes off CPython was never checked. Each patch and layout
is now its own run/<rate>/<ch>/<patch> cell, and the stand-in works on
MicroPython and CircuitPython (no __mro__, vars or instance __dict__
there). Plants: PatchPulls (raises at the middle patch only) and
NativeFlush (leans on get_buffer only off CPython), both red on the old
check's tests and green on this one.
The class is the node's controls (Rate, Range, Manual, Color, Mix,
Polarity, Sync, Division), a live tail bound and a tempo, at the stock
tier. create("Flanger") serves it from audioeffects/flanger.py;
modulation.py and the parked rebuild under rebuilt/ are gone.

Its six traits are tested through the class with a planted fault each;
the lifecycle matrix declares the free-running sweep's P4 and the N1 step,
and skips E8 on CircuitPython, whose own Flanger spins on a source that
comes back empty. The cost tool gains an audiodelays.Flanger node row.
… through get_buffer

_RampTap and _SplitOut exist only on the CPython twin and pulled their
upstream with the script-facing audiocore.get_buffer on every pull, so
the class raised wherever that is hidden. They now pull with the twin's
in-graph _borrow and copy what they keep: the same bytes. Red then green
in TheGraphPullsWithoutGetBuffer and the kit's nogb (CPython; the native
graph never had the path).
reset() pushed each of the eight taps empty with audiocore.get_buffer,
which raises where CircuitPython's board builds compile it out. A
Splitter's ring cannot be cleared, so the reset now builds the three
Splitters again on the same nodes and releases the old ones, on every
interpreter: renders with and without get_buffer are byte-identical.
The reset's own bytes move (it was not a fresh instance before or after;
see the 7d report). Red then green in TheResetNeedsNoGetBuffer and the
kit's nogb on three interpreters.
_charge_output_cap pulled the output capacitor with audiocore.get_buffer
unconditionally at patches 6 to 9. Where it is absent the charge is now
skipped, as Fuzz and Overdrive skip theirs; the scoop tables answer zero
with zero, so nothing differs: every patch renders the same bytes with
and without it. Red then green in TheScoopBuildsWithoutGetBuffer and the
kit's nogb on three interpreters.
… tests

transport_changed() now sets the sweep again when the meter moves at one
tempo; the base followed the bpm alone. New cells hold Polarity from its
knob and patch 3, Sync from its knob and patch 5, a 4/4 to 3/4 change, and
a rack's restore of the exact constructor values, each beside a plant the
old tests let through. The ring sentence no longer reads as a ceiling.
transport_changed() treats a NaN tempo or meter as unchanged, as the base
does, and applies the Sync switch through _apply_macro, honouring
_SYNC_MACRO, so the shared tempo tests hold it; Flanger joins their
SYNCED list. The restore test gains its plant.
…m every patch

kit_fresh.py holds a fresh instance to patch 0 exactly (boot), and a reset
from the defaults and every patch, before the first pull and mid-stream,
to a fresh instance handed patch 0 and fed the source from where the reset
found it: the bytes for 2 s, the readback, and each macro moved after the
reset. Shown red on three plants (OffGrid, PatchLeak, MoveLeak) against
FreshKit, with the same counts on CPython, MicroPython and CircuitPython.
…patch is the onset again

reset() re-played the mixer's voices inside the walk, before the chain
behind them was cleared, so the first block after a reset was the old
audio (1504 LSB at the defaults, every patch). The voices now stop in the
walk and the graph is armed after it the way _build arms it. On
CircuitPython the host reset of the stock Mixer also cost the reset a
block of the source; stopping the voices instead keeps it.

A visit to the lean patch set latency_samples from the group delay, so
it read 3 at tube x4 afterwards where a fresh instance reads the onset,
1; the lean patch itself now reads the x2 onset. Two tests that pinned
the group delay are restated to the onset CLICK_ONSET_SAMPLES declares.

The fresh check builds its fresh instance with the constructor's patch=,
as the lifecycle's PR does: a live program_change may wait for a zero
crossing, and a reset is held to a build.
…re on the node

A reset alone comes back as a fresh instance (green). A Time 0 move after
it does not: audiodelays.MultiTapDelay's reset_buffer empties the line but
leaves the write head where it was, so a shorter Time keeps a different
stretch of the line than on a fresh node. Red on CPython, MicroPython and
CircuitPython alike; a reset that puts the twin's write head at the top
turns it green. The cure is the node's, so the test is an expected
failure until it lands.
…nded, as built

The rack's put-back can land Saturation on Mix 0; the reset armed the graph
and then routed around it, which marked it stranded where a build does not
(test_cpython_effects_rack TestReset, three interpreters).
…et lands where it starts

Time was built off the 0-127 grid: a fresh DigitalDelay played 350 ms
beside patch 0's 352.8 ms (grid 102), and a SlapbackDelay 135 ms beside
134.4 ms (grid 84), so a reset sounded different from a fresh instance
and reported a looser tail (186 296 against 184 811 frames). An option
left out now plays patch 0's step (_init_macros' new defaults flags); a
value you pass plays as passed. NaN on SlapbackDelay takes the step, as
it took the default.

Tests that read figures at the dossier's settings pass them explicitly
(DOSSIER, time_ms=350.0); tests about the defaults read patch 0's steps.
The kit's ctor and opt cells read an option whose default is None from
the class's _OPTION_DEFAULTS. CHANGELOG lines for these and Saturation.
… and nothing is charged

Bias was the node's own offset into an asymmetric curve, so the shaper
answered silence with a constant and the plate pole passed the step:
8489 / 7044 LSB out of silence at patches 4 and 6. The class charged the
pole by pulling it with audiocore.get_buffer, which left 1 LSB, had to
run again on every Bias, Drive, Output, Headroom and Hysteresis move, and
where get_buffer is compiled out did nothing, so the bang came back.

The shapers now read a table the class builds in integers: the curve at
(1 + |Bias|) * u + Bias, less its value at u = 0, with pre_gain divided by
the span and post_gain carrying any scale the table needed to fit. Silence
reads exactly zero at every Bias, on every interpreter, with or without
get_buffer, and the charge is gone.

Red first: SilenceAnswersSilenceOffCentre (patches 4 and 6 on silence,
with and without get_buffer; and nogb bytes) was 20 failures before.
Plants that replaced the curve now fold Bias into theirs
(_fold_planted); NodeBiasSaturation plants the old node offset in place
of the two charge plants. Bias +0.25's A4 floor moved onto the bar
(-60.047).
reset() cleared the graph in place and never came back fresh: lifecycle
E1's PR read 'differs from a fresh instance at +3' from the defaults and
every patch. It now builds the graph again at patch 0, re-points the port
at the new tail, then releases the old graph. An option left out of the
constructor plays patch 0's step (Component._init_macros(defaults=), the
same hunk as on effects-p7/7d-reset), so a fresh instance is patch 0
exactly. Red then green in AResetIsAFreshInstance and the fresh check
(86 red, now 0). The STATE harness tests now put the silent source under
the class before the reset, since a fresh instance takes its first block
from its source as it is built.
Patch values are integers and a declared default may sit off their grid
(docs/audio-components.md), so a fresh instance reading exactly patch 0 is
not a contract claim. boot now prints na(info: ...) where they differ;
the reset cells, judged against a fresh instance built at patch 0, are the
check. OffGrid becomes a control that is never red; PatchLeak and MoveLeak
stay the plants.
…odsp's node fix

Green against audiodsp effects-p7/mtd-reset, whose reset_buffer puts the
write heads at the top; red at v0.6.6rc3 until the pin moves. The kit's
DECLARED rows can name one interpreter, and CircuitPython's Time 0 cells
after a reset are declared there: its MultiTapDelay is upstream's, which
keeps the heads. NodeMultiTapDelay joins the node matrix.
…ollows it

With Sync on, the sweep lasts Division bars of numerator x 4 / denominator
quarter notes, the transport's fourth and fifth fields; it read the
denominator alone as the beats in a bar, so 3/4 swept as 4/4 and 6/8 as
eight beats. transport_changed() now sets the rate again when the time
signature moves at one tempo, with NaN read twice as no change, as the
base has it for a tempo. In 4/4 no byte moves. MeterDeaf, the base's
follow, is the control the new tests tell apart.
…d room

The room is normalised to unit energy, so a dark one gathers it low: an
82 Hz square at Mix 2 and Damping's 500 Hz stop comes back 11.06 times its
peak (+20.87 dB), the worst of the kit's ceiling grid, and a 0 dBFS 50 Hz
sine railed for 156 frames at the defaults. INPUT_CEILING_DBFS is -21.0,
the docstring says so with its claim row, and the test holds the worst
cells clean there and red 1.5 dB hotter. No byte of the audio path moves.
The API now says it: below 0, minus infinity and NaN set the bottom stop,
above 127 and plus infinity the top, and the component plays what that
stop plays. It is what the base class, every effect but CabinetSim and all
55 instruments already did. The kit's bad-m cells hold it: a raise of any
kind is red and NaN must read back 0 (NanRaises and NanTop, green before,
are red now). CabinetSim's snap clamps before int(), so +inf is the top
stop instead of OverflowError; no finite value moves.
…t docs

A reset is a fresh instance exactly on both (PR). P4 holds it to the
control that never was reset, which neither can meet again: Vibrato's
reset restarts its LFO, and Fuzz's 10.5 and 12.4 Hz coupling sections keep
a few LSB of the earlier material in float state for seconds, raised to a
few hundred by the 36 dB stage. Each class's test holds the declaration,
and Fuzz's holds the residue under 1024 LSB. KIT.md and LIFECYCLE.md name
the clamp contract's cells and plants; CHANGELOG lines for the three
fixes.
The reset emptied the tape with the outgoing patch's Time as the head's
reach, and patch 0's shorter Time kept it, so from patch 2 tail_samples
read 361 384 frames where a fresh instance reads 251 836. A tape just
emptied lands its head on the next Time it is handed, so that Time is the
reach. Kit fresh green on three interpreters; the class tests pass.
With Sync on it read the transport's fifth field, the denominator, as the
beats in a bar, so a host in 3/4 swept as 4/4 and one in 6/8 as eight
beats; the tests' Transport put the meter there too and hid it. The test
transport now hands (bpm, numerator, denominator) in the API's order, a new
cell holds 3/4, 6/8 and 7/8, and transport_changed() follows all three
fields (NaN read twice is no change). In 4/4 no byte moves; Phaser's rule.
… took

Building the input stage again dropped the rest of a source buffer the
old adapter had already taken, so a reset that landed part-way through
one left the render shifted against an uninterrupted instance until the
source ended (lifecycle E1-reset@part P4, red at 0ec69f5 too). The reset
now keeps the adapter. Red then green in
AResetPartWayThroughABufferDropsNothing; the fresh check stays 0 red.
…n unconditionally

rc7 fixes CPython's buffer-GC crash, Dynamics' mono stride and adds
Splitter.clear(); rc8 swaps FeedbackDelay's wow_shape at zero depth.
test_bypass_return's Splitter case no longer skips below the pin: it
fails there instead (red at rc6, green at rc8).
491eff7 added 10**400 and -10**400 to the kit's off-grid values and
updated test_kit, not this copy of the expected cell.
Overdrive is LAZY_BUILD now, so ClearOnlyRejoin keeps a block of silence
and plays nothing: it takes EagerClearOnlyRejoin, as the other lazily
built classes' tests do. Rack's T4 Mix 0 plant, PutBackRack, was red only
through the pedals' eager re-arm on the Mix move after a reset (Bitcrusher,
Overdrive, Saturation); with the re-arm lazy it renders as built, so the
Mix 0 check gets PrimingRack, a reset that takes a block from each pedal.
PutBackRack stays the exact-values plant. Both red at the merge, green
here on CPython, MicroPython and CircuitPython.
Leaving Mix 0 under signal starts the wet chain cold for 16 samples,
until the mixer level ramp lands. The lazy re-arm that causes it stays:
priming from the source loses a block under a following Mix 0 (F1).
…n the way back from Mix 0

lc-2 cleared the Splitter on a rejoin but not the node reading its wet
tap, which carries part of a chunk across blocks on source buffers that
aren't 256 frames. At Telephone Grid (patch 3) that played up to 5 888
LSB from before the bypass. The lifecycle matrix's E9-src1000 P3 cell
found it; test_bypass_return now holds patch 3, red without the change.
tests/test_lifecycle_matrix.py gated only the eight Phase 5 classes, so
the Phase 2 to 4 classes' red cells were never held. It now runs all 45
(audioeffects.ALL). What is red today is in tests/support/lifecycle_known.py:
267 KNOWN_RED rows, one per (class, event, property), counted on the quick
matrix on CPython at audiodsp v0.6.6rc8, each with its cause and the issue
that tracks it (#153 control jumps, #154 free-running phases, #155 state
past tail_samples, #156 stated behaviours, #157, #142), and five
CircuitPython-own KNOWN_P6 rows (#158). MicroPython prints CPython's line
for every cell of every class.
rc8's FeedbackDelay takes a new wow_shape through zero depth, so a Wow or
Flutter move that changes their balance no longer steps (730 and 729
against the tone's 728, where rc7 read 803 and 1 117). The docstring
says so, the test holds the new figures (red at rc6), and NoneAtZeroTape,
which the node now renders as the class does, leaves the depth-move
plants; the read-head jump plant stays.
…building added

Since Overdrive builds lazily, the declared residue (the get_buffer path
stops the pole charge at the first zero block, the board path runs all 64)
also reads on E10-reset and E10-patch at patch 6 and E10-macros at the
defaults. Same cause, same P4-only difference; nogb is clean on CPython,
MicroPython and CircuitPython with the rows.
A fresh Overdrive now primes silence, so in mono a reset renders as a
fresh build: test_overdrive_mono passes (it was an unexpected success),
and the kit's 132 mono fresh declarations read ok on all three
interpreters without being used. Both come out. What is left of #142 is a
PR difference at patch 6, 22.05 kHz, which the lifecycle row now says.
Measured: from frame 10 a move to patch 7 before the first pull differs
from a fresh patch=7 build by at most 2 LSB, in about one frame in eight,
and keeps doing so past 3 s (22.05 kHz stereo). Inaudible; filed with a
trigger and cited in the row.
…162

A Frequency jump to the bottom in one call rings the kept filter state at
full scale for about 30 ms on these three (found in the spot checks of the
untriaged classes); their E4-m0= P5 rows now cite #162, and their patch
rows name it beside #153.
…can move

On a pull request LIFECYCLE_CHANGED_FROM names the base branch, and the
matrix test asks git which files changed: a class module's change runs that
class, a shared one (the base and package modules, tests/support, the
matrix test, AUDIODSP_PIN) all 45, and a change to neither none. A push to
main sets nothing and runs all 45. The checkout fetches history on a pull
request so the base is there; without it the test falls back to all 45.
test_a_change_picks_the_classes_it_can_move holds the selection.
…, about 13 minutes

All 45 classes take 4.4 CPU hours on CPython. A pull request that changes a
shared file now runs SAMPLE, one class per mechanism family (Bitcrusher,
Tremolo, DigitalDelay, Reverb, Octaver, LowPass, Rack, Saturation,
Distortion), beside the classes whose own module it changes: 3 114 CPU
seconds, about 13 minutes on a 4-core runner. A push to main, a manual run
or the 'lifecycle: all classes' label runs all 45. KIT.md and the workflow
say why.
…he old bound at rc9

rc9's biquad walks a corner move over 20 ms instead of carrying the old
section's state into the new one, so the move that rang 279 149 frames
(LowPass), 8 981 (BandPass) and 288 768 (Notch) now rings 15 to 132 063.
The old-bound controls are restated to that: each now holds the ring
inside the pre-#114 bound, red at rc8 and green at rc9.
Recounted on the quick matrix at v0.6.6rc9, all 45 classes, three
interpreters. The Frequency-jump P5 rows of LowPass, BandPass and Notch
are gone with the thump; seventeen classes on the biquad changed cells.
Where a corner move now walks over 20 ms, a move and back meets the
control up to 20 ms past the bound (R_STATE_RAMP), and Octaver's patch
picked before the first pull glides for 30 ms (#164). 273 rows; P6
unchanged: MicroPython prints CPython's line everywhere, CircuitPython
differs only in the known rows.
The same restatement as the three filters: rc9's walk leaves the move
ringing 56 frames where it rang 19 372, so the old-bound control holds the
ring inside the pre-#114 bound (red at rc8, green at rc9).
…c9 (#164)

Arming already cleared the all-pass, which had heard only zeros, so a
Tracking move before the first pull lands at once. rc9's biquad walks any
move after its first block, so the clock and Tone biquads (also only
zeros) are cleared there too; a patch before the first pull renders as a
fresh build again. test_a_patch_before_the_first_pull_renders_as_fresh
was red at rc9 and is green; Octaver's lifecycle matrix is clean on all
three interpreters and its E12 rows leave the gate (271 rows).
…start, and the old tail figure holds

rc9's biquad walks a coefficient move over about 21 ms with its state
remapped (#162). NoWakeGraphicEQ's band now walks in from the curve it held
before the detent, which can stay under the fresh start's own overshoot, so
the peak missed three crossings. The test now reads how far the faulted
render departs from the class's fresh start: 0.25 to 33 times the settled
level at rc9, and zero where the detent is never crossed. A Gain or Volume
move to the bottom rings 9 566 frames, inside the old 0.47 s it outlived up
to rc8, so that control is restated to hold.
Building a class used to pull one block from its source and throw it away,
so a probe the render's own length ran dry 88 frames early. The lazy build
no longer does, the render reached its last frame live, and the guard's
two tests had nothing to catch. The probe is now planted 256 frames short:
98 silent frames, a -33.1 dB floor against -115.6 with headroom.
…ks; the node matrix takes the same rule

fetch-depth was written `== 'pull_request' && 0 || 1`, and 0 is false in
an expression, so every pull request checked out at depth 1. With no base
to diff from, the lifecycle matrix fell back to all 45 classes and a pull
request's run took 1 h 43 min. The expression now reads `!= ... && 1 || 0`,
a base git cannot diff from is an error rather than a full run, and a new
step prints what each matrix will run.

The node matrix runs the nodes whose adapter class or table rows a pull
request changes, read by the line, and NODE_SAMPLE (Biquad, Echo,
SplitMix, ShimmerLoop: 86 s of 541 on CPython) when a shared file
changes. Main runs every node. KIT.md and the workflow say why.
@bdbarnett
bdbarnett merged commit 16cb7ef into main Oct 15, 2026
6 checks passed
@bdbarnett
bdbarnett deleted the effects-p7/land branch October 15, 2026 12:27
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