CircuitPython 10.3.0's shared-module/audiodelays/Flanger.c:365 computes the
wet tap's interpolation in int32_t:
int32_t wet = s0 + (((s1 - s0) * (int32_t)delay_frac) >> 16);With the delay line holding adjacent samples at opposite rails, s1 - s0
reaches 65535 and delay_frac reaches 65535, so the product is 4.29e9 - past
INT32_MAX. Signed overflow, reachable on ordinary full-scale material.
Upstream used the widening cast eighteen lines earlier, at :347, for the
span times the triangle; it is absent here.
This port widens it, src/shared/audioif_flanger.c. So above about
±26000 alternating, our MicroPython build and CircuitPython 10.3.0 render
different bytes, and ours is the arithmetically correct one. Found by the
three-way verify_dsp run of 2026-09-09: 18 of 54 probe lines differed and
they were exactly the rails cases, every other case agreeing to the byte -
the signature of an overflow that only bites at the extremes.
verify_dsp carries a stated skip for flanger_probe.py on circuitpython
naming that issue, so the three-way is not red on a defect that is not ours.
An upstream report is drafted and held, per the rule that none goes out until
our own house is clean.
Our CPython twin was never wrong, and that is worth knowing rather than comforting: it is written in Python, which has no int32 to overflow. A twin in a language without fixed-width arithmetic cannot surface this class of defect, so it is not evidence about it either way.
Running log of every place this port's behavior deliberately differs from CircuitPython's, or needed a workspace-side fix that isn't a plain port. Goal: keep this list short. Python-level API and behavior match CP unless noted here.
Six of the entries below are upstream bugs rather than divergences, written up in upstream-reports/. Five have been filed with CircuitPython and four are merged (see that directory's README for the issue and PR numbers): peaking-eq-sign, dds-oscillator-off-by-one, distortion-soft-clip-union and biquad-reset are fixed upstream; biquad-band-edges remains open, awaiting maintainer appetite on a three-option ask.
Merged upstream is not the same as released. peaking-eq-sign
(8fabdbbfb1) and biquad-reset (8a3deace5c) are on CircuitPython main and in
no tag -- 10.3.0 is the newest, and predates both. So this port is ahead of
the pinned oracle in exactly those two places, and each is a named departure
that verify_dsp records and that expires when upstream cuts a release
containing them. They are the reason biquad_component_probe.py compares only
the CPython twin against MicroPython and skips CircuitPython. The numbers here were measured on this port and are not
upstream's -- the reports carry figures measured on a build of upstream
main, which differ.
CircuitPython's MP_PROPERTY_GETTER/MP_PROPERTY_GETSET (declared via
py/objproperty.h) only work in CircuitPython because CP patches its own
py/runtime.c (mp_convert_member_lookup, tagged CIRCUITPY-CHANGE) to
recognize a mp_type_property value sitting in a native type's
locals_dict and invoke its getter/setter. Mainline MicroPython's
mp_convert_member_lookup has no such case — confirmed by reading it
directly, not assumed. Discovered the hard way: a first pass at
audiocore.RawSample built cleanly but sample_rate returned the raw
property object (<property>) instead of its value, and assigning to it
raised AttributeError.
Can't carry CP's core patch (no edits to micropython/). Fix lives entirely
in this usermod: cp_compat_attr (src/cp_compat/objproperty.c)
reimplements the same lookup-and-invoke logic from usermod code, using
mainline's own supported extension point for this — a type's attr slot
(the same mechanism mainline's own examples/usercmodule/cexample
demonstrates for its AdvancedTimer.seconds property).
Consequence for every future tier: every MP_DEFINE_CONST_OBJ_TYPE(...)
call for a type that uses MP_PROPERTY_GETTER/GETSET in its
locals_dict must add attr, cp_compat_attr to that type's slot list, or
the property is silently unreadable/unwritable at runtime (it builds fine —
this doesn't show up as a compile error). RawSample and WaveFile do
this; use them as the reference when porting Note, Synthesizer, Mixer,
MixerVoice, and the effects types.
CircuitPython's audiocore.WaveFile reads its file via raw FatFS calls
(f_read/f_lseek/f_tell against a pyb_file_obj_t's embedded .fp),
and its constructor hard-requires mp_obj_is_type(arg, &mp_type_vfs_fat_fileio).
This only works when the mounted filesystem is FatFS — confirmed by testing
directly: even CircuitPython's own unix coverage build (bin/circuitpython
in this workspace, used as the parity oracle everywhere else) raises
TypeError: file must be a file opened in byte mode for WaveFile(path)
and WaveFile(open(path, "rb")), because the unix port's open() returns
a POSIX-backed file object, not a vfs_fat_fileio one. WaveFile is
effectively FAT/MCU-only upstream, undocumented as such.
This port's WaveFile (src/audiocore/WaveFile.c) reads through
MicroPython's generic stream protocol instead (mp_stream_read_exactly +
an ioctl(MP_STREAM_SEEK)), which works over any VFS a port has mounted
(POSIX files on unix/windows, littlefs or FAT on mcu boards). The WAV chunk
parsing and the double-buffered refill/get_buffer state machine are an
unchanged, mechanical port — verified byte-for-byte against Python's own
wave module output for a generated test tone (nbytes/checksum/first-
and-last-32-bytes all matched exactly). Because CircuitPython's own unix
build can't open a WaveFile at all, a true CP-oracle diff for WaveFile PCM
output isn't possible on this port; if a FAT-mounted test fixture becomes
available later (real MCU or a FAT disk image), redo this comparison
directly against CP for full confidence — it's expected to match, since the
buffer/refill logic itself is unmodified, but it hasn't been checked yet
that way.
synthio.from_file() (loads a MidiTrack from an SMF file) has the exact
same FatFS coupling as audiocore.WaveFile — same fix, same rationale: see
"WaveFile: portable stream I/O instead of direct FatFS calls" above. Only
this one function needed it in tier 2; synthio.MidiTrack(buffer, tempo)
itself (constructing from an in-memory buffer, no file I/O) is an unchanged
mechanical port. Verified functionally with a hand-built SMF fixture
(from_file accepts both a path string and an already-open file object);
no CP-oracle comparison possible here either, for the same reason as
WaveFile.
synthio/Math.c's OP_MUL_DIV/OP_DIV_ADD/OP_ADD_DIV cases used
upstream's fpclassify(x) == FP_ZERO to test for exact zero. This port's
unix build enables -Wfloat-conversion -Werror (ports/unix/Makefile),
and glibc's type-generic fpclassify() macro expands to a ternary across
__fpclassifyf/__fpclassify/__fpclassifyl (float/double/long double)
that GCC apparently still type-checks on the untaken branches, so it
flags a double truncating to float even though mp_float_t is double
here. Replaced with a direct x == 0 comparison — exactly equivalent for
testing "is this float exactly zero" (+0.0 == -0.0 is true in IEEE 754,
same outcome fpclassify would give), and avoids the compiler-specific
warning. If a future mcu toolchain doesn't hit this warning, the direct
comparison is still correct there, so no port-specific #ifdef needed.
Two deviations, both mechanical:
- CircuitPython guards
MixerVoice.level/.panningassynthio_block_slot_tbehind#if CIRCUITPY_SYNTHIO(some CP boards omit synthio entirely) and falls back to plain scaled-uint16 fields otherwise. This port always builds synthio (tier 2), so the block-input path (src/audiomixer/MixerVoice.h/.c) is unconditional; the plain-float fallback fields and branches inmix_down_one_voice(src/audiomixer/Mixer.c) are dropped rather than carried as dead code. Mixer.c's bit-twiddling helpers (add16signed,mult16signed,tounsigned8/16,tosigned16) have ARM Cortex-M4/M7 CMSIS DSP intrinsic fast paths (__QADD16,__UADD8,__UADD16, gated on__ARM_ARCH_7EM__) plus a top-of-file#include "cmsis_compiler.h"gated on__arm__. Dropped in favor of the portable C fallback unconditionally: numerically identical (verified via oracle-diff, not just assumed), and this port doesn't vendor CMSIS, so the include would be a dangling dependency on any ARM mcu build (phase 9) that doesn't happen to have it on the include path -- including non-DSP cores like Cortex-M0+ (rp2), where__QADD16isn't even a valid instruction.
Verified via oracle-diff against bin/circuitpython across every branch
mix_down_one_voice/audiomixer_mixer_get_buffer can take: mono source
into mono mixer, mono source upmixed into a stereo mixer with panning,
16-bit signed, 8-bit unsigned, single- and multi-voice mixdown (verbatim-
copy vs. add-mixed paths), looping, and level/panning driven by a
synthio.LFO/synthio.Math block input instead of a plain float -- all
byte-for-byte identical, including the Mixer.reset_buffer semantics
(stops every voice; call it before attaching voices, exactly like an
output device's play() would, not after -- documented here because a
first draft of the parity test called it in the wrong order and produced
an all-zero-but-still-oracle-matching result, which was CP's real
behavior for that call order, not a bug).
Nine types across four modules, all mechanical ports with the same fix
pattern as every prior tier (m_malloc_without_collect -> m_malloc,
attr, cp_compat_attr added to every type registration). Module-specific
notes:
audiospeed.SpeedChangerhas no oracle in the canonicalbin/circuitpython:CIRCUITPY_AUDIOSPEEDis gated per-port (onlyports/raspberrypi/mpconfigport.mksets it), and this workspace's unix coverage variant (circuitpython/ports/unix/variants/coverage/mpconfigvariant.mk) hand-lists itsSRC_Cfiles rather than using theCIRCUITPY_AUDIOSPEEDMake-variable cascade, so simply passing the flag on the command line doesn't pull the module in. Verified anyway: built a temporary oracle by addingshared-bindings/audiospeed/{__init__,SpeedChanger}.candshared-module/audiospeed/{__init__,SpeedChanger}.cto that variant file and rebuilding into a scratchports/unix/build-coverage-audiospeed/directory (never touching the canonicalbuild-coverage/), diffed byte-for-byte, then reverted the variant-file edit and deleted the scratch build -- the parent workspace's circuitpython checkout is back to its pre-existing local patch state, unmodified beyond that one-off test. Also needed the same0.001/1000.0->0/1000literal-truncation fix as this port'srate_to_fpformp_arg_validate_obj_float_range's int-typed min/max (this is a stock upstream compile hazard under-Wfloat-conversion -Werror, not something introduced by the port -- confirmed by building CircuitPython's own unmodifiedSpeedChanger.cand hitting the same error; it has apparently never been caught because unix is the only build that enables that warning and unix never compiles this module by default).audiofreeverb.Freeverbported unchanged, including upstream's owncombfitlersidentifier typo and the type's lowercaseMP_QSTR_freeverbname (sotype(x).__name__prints"freeverb"even though the class isaudiofreeverb.Freeverb) -- both kept verbatim for parity, confirmed genuine upstream quirks (not transcription errors) by reading the original source directly.audiofilters.Distortionoriginally had two verbatim-kept upstream oddities; one was later reversed (see "Distortion soft_clip" below, phase 8d) once it turned out to be architecture-dependent rather than a stable quirk. The one still kept verbatim: the unsigned-16-bit silence path'smemset(word_buffer, 32768, ...), which -- becausememset's fill value truncates to anunsigned char-- actually writes zero bytes, not the intended0x8000"quiet" level.audiodelays.PitchShifthas the samememset(..., 32768, ...)quirk in its own silence path, plus a separate one: its per-samplebuf_offsetcalculation ignoressingle_channel_outputentirely (channel == 1 || i % channel_count == 1, unlike every sibling effect's(single_channel_output && channel == 1) || (!single_channel_output && ...)pattern) -- upstream's own inconsistency between effects, not a port bug.audiodelays.Chorusandaudiodelays.PitchShifteach define a custom__exit__that callscommon_hal_..._deinit(args[0])directly instead of using the shareddefault___exit___objmethod-dispatch helper every other type in this port uses -- kept verbatim as upstream's own micro-optimization for those two types specifically, not a port artifact.
All nine types (SpeedChanger, Freeverb, Filter, Distortion,
Phaser, Chorus, Echo, MultiTapDelay, PitchShift) verified
byte-for-byte against the oracle across mono/stereo, 8/16-bit
signed/unsigned, looping, block-input-driven (synthio.LFO) parameters,
mid-stream parameter changes, stop()/tail-drain behavior, and (for
MultiTapDelay) invalid-input error paths -- including the exact error
message text (a CRLF-vs-LF difference in how each interpreter's traceback
printer formats output was the only non-match found anywhere in this tier,
confirmed as a REPL-layer difference between CircuitPython and mainline
MicroPython, unrelated to any ported module).
tests/vendor/synthtools is todbot's
CircuitPython_SynthTools
(MIT, commit recorded in tests/vendor/SYNTHTOOLS_COMMIT.txt), vendored
unmodified -- the synthtools/ package only, not examples//tests//
docs/, since the acceptance script (tests/parity/synthtools_acceptance.py)
is this port's own, not upstream's. It drives SubtractiveSynth (dual
detuned oscillators, filter envelope, vibrato, pitch envelope, an
LFO-driven cutoff sweep, live mid-note parameter writes, a JSON patch
round-trip) and BasslineSynth (mono, decay-only filter envelope, glide)
through a real synthio.Synthesizer + audiomixer.Mixer, with an
EffectsChain wiring in audiofilters.Filter (via tracking_filter()),
audiofilters.Distortion, and audiodelays.Echo -- i.e. tiers 2/3/4 all
exercised together through an unmodified third-party engine, which is what
"full parity" is actually for. Runs unchanged on both interpreters; the
runner diffs stdout.
Found and fixed a real port bug, not a verbatim-kept quirk:
src/synthio/__init__.h defines CIRCUITPY_SYNTHIO_MAX_CHANNELS (max
concurrent Notes across one Synthesizer, not per-key) defaulting to 2 --
correctly mirroring CP's own conservative default
(py/circuitpy_mpconfig.mk). But this port's unix/windows build never
overrode it, while this workspace's CircuitPython oracle
(the parent workspace's circuitpython checkout, unix coverage variant,
bin/circuitpython) is itself built with -DCIRCUITPY_SYNTHIO_MAX_CHANNELS=14
(ports/unix/variants/coverage/mpconfigvariant.mk). At 2, anything past
the first two concurrently-alive Notes (e.g. two held notes on a
detuned/dual-oscillator patch -- already 4 Notes) silently truncates
voices, and every synthio.LFO/synthio.Math block nested under an
evicted voice desyncs from the oracle from that point on. Isolated via
binary search on tests/parity/synthtools_acceptance.py's divergence: a
single note-lifecycle (attack through release, no overlap) matched
byte-for-byte on this port's unpatched build; 2 overlapping plain notes
(no modulation) also matched; 3+ overlapping notes, or 2 overlapping
notes on a detuned (2-oscillator) patch, diverged -- both cross the
2-concurrent-Note ceiling. Fixed by setting
CFLAGS_USERMOD += -DCIRCUITPY_SYNTHIO_MAX_CHANNELS=14 in
micropython.mk (unix/windows only, matching the oracle's own build
choice so voice-stealing arithmetic lines up exactly, not just "enough
channels"); micropython.cmake (mcu/CMake ports) deliberately leaves the
header's conservative default of 2 alone, since the right value there is
a phase 10 (port matrix) RAM/polyphony tradeoff, not this one. Rebuilt
bin/micropython and reran the full tier 0-4 parity suite plus the LVGL
smoke test after the change: clean, no regressions.
Test-harness-only, not a port deviation: synthtools/waves.py's
random_phase_wave() (used by SubtractiveSynth._make_notes() on every
note-on, by design -- see its docstring) calls random.randint() to pick
each oscillator's starting phase. MicroPython's and CircuitPython's
random modules implement different PRNGs, so the same script would
legitimately render different-but-valid PCM on each interpreter and defeat
a byte-exact diff. Neither interpreter's built-in random module allows
monkeypatching an attribute onto it directly (both raise AttributeError
on random.randint = ...), so synthtools_acceptance.py pre-seeds a tiny
deterministic-LCG substitute into sys.modules["random"] before importing
synthtools -- import picks up the substitute via the module cache, no
vendored file touched. This is a property of the test script, not of
synthtools or of the port.
Verified byte-for-byte against bin/circuitpython: the full acceptance
script's stdout (four SubtractiveSynth notes with a mid-run live
filt_f/wave change, a pitch_bend() sweep, a Patch JSON round-trip
reconstructing an equivalent synth, four BasslineSynth steps with
glide, all_notes_off()/voice.stop() tail-drain, and final Mixer
state) is identical after the CIRCUITPY_SYNTHIO_MAX_CHANNELS fix.
There is no longer a ceiling deviation. The comparison build takes the ceiling
this port ships, which is what docs/correctness-standard.md requires of any
node CircuitPython also has: compare with CircuitPython at the same
compile-time configuration.
CIRCUITPY_SYNTHIO_MAX_CHANNELS is 64 on every build path (14 fits the drum
kits; 64 fits a ten-finger chord on the melodic library, whose instruments press
up to 9 Notes per key), and the pinned CircuitPython is now built at 64 too.
SYNTHIO_MIX_DOWN_SCALE is therefore 129 on both sides rather than 129 against
623, and tests/parity/verify_mixdown_knee.py is byte-identical to
CircuitPython above the knee as well as below. Nothing about that gate is
advisory any more.
What it took: the unix coverage variant hardcodes
-DCIRCUITPY_SYNTHIO_MAX_CHANNELS=14 into CFLAGS rather than taking the ?=
make variable the way ports/raspberrypi/mpconfigport.mk does, so overriding it
is a change to the build path and not a flag. cmods/build_cp.sh passes
CP_CFLAGS_EXTRA through for this, and the override must be -U then -D:
-Werror makes a conflicting redefinition an error, so "the last -D wins" is
not true here.
What the rebuild found, which is the argument for doing it: two behaviour
changes CircuitPython 10.3.0 made to synthio and audiomixer that this port
had not taken — the panning polarity flip and the zero-crossing loudness gate.
Both were quiet arithmetic edits rather than new nodes, every gate compared us
against our own past, and nothing else in the repository could have seen them.
That is the whole case for an oracle at our own configuration and version.
The old rule that the oracle must never be rebuilt is retired with this section;
tests/test_voice_ceiling_consistency.py carries the replacement and the
provenance of each re-pin.
CircuitPython's lib/mp3 (upstream adafruit/Adafruit_MP3, cloned as a
parent workspace sibling pinned to the same commit,
aac02afd9f24d2ee930f650156654ab9211a306a)
is the Helix fixed-point MP3 decoder, originally developed by RealNetworks in
2003. Every core decoder source file (bitstream.c through statname.h,
i.e. everything actually compiled -- not Adafruit_MP3.cpp/.h, Adafruit's
own Arduino wrapper, which neither CircuitPython nor this port uses) carries
a RCSL 1.0/RPSL 1.0 SPDX-style header block, not MIT:
Portions Copyright (c) 1995-2002 RealNetworks, Inc. All Rights Reserved.
... subject to the current version of the RealNetworks Public Source
License Version 1.0 (the "RPSL") ... or ... the RealNetworks Community
Source License Version 1.0 (the "RCSL") ...
RPSL 1.0 is an OSI-approved open-source license; the obligation it places is
on modifications to the licensed files themselves (publish them under
RPSL), not on other code that merely links against or calls into them --
this is exactly the same relationship a project has with any vendored
GPL-incompatible-but-OSI-approved C library, and it is precisely how
CircuitPython itself (MIT overall) carries this dependency: unmodified,
under its own original headers, no relicensing attempted, no separate
top-level LICENSE entry for it either (confirmed -- CircuitPython's own repo
has no lib/mp3-specific license documentation beyond the per-file
headers). This port does the same: the parent workspace's mp3 sibling is
vendored verbatim (one local patch, see below, kept as narrow as possible
and documented inline), its RPSL/RCSL headers untouched, and this section
is the disclosure.
Its mp3/examples/test.mp3 (Adafruit's own bundled test fixture, used for
oracle-diff verification below) ships alongside it, license unclear but
not carried into this repo's own source tree -- it's a test fixture read at
test time from the cloned sibling, not vendored content.
All three were found only when building/running this workspace's Windows
MicroPython target (mp-windows/mingw-w64) -- CircuitPython has no Windows
port, so none was ever reachable upstream:
- the parent workspace's
mp3/src/assembly.hpicks an MSVC-only inline-__asm{}code path (plus an MSVC-only#pragma warning) whenever_WIN32is defined and_WIN32_WCEisn't. mingw-w64 GCC also defines_WIN32(confirmed:echo | x86_64-w64-mingw32-gcc -dM -E - | grep _WIN32->#define _WIN32 1), so it hit that branch and failed to compile (__asm { mov eax, x ... }is not valid GCC syntax under any target). Local patch: added&& !defined(__GNUC__)to the branch's condition, falling through to the file's portable C implementation (FASTABS/MULSHIFT32/CLZas plain C, no inline asm) -- the same path this port's unix build already takes, since_WIN32is never defined there. Patch is inline-commented in the file itself with a pointer back to this section. - This port's own
src/audiomp3/mp3_alloc.c(mp3_alloc/mp3_free, standing in for CP'sshared-module/audiomp3/__init__.c) dropped upstream'sMP_WEAK(__attribute__((weak))). Upstream needs it so a board can override the allocator viaCIRCUITPY_AUDIOMP3_USE_PORT_ALLOCATOR; this port has no such opt-in, so there is never a second, strong definition anywhere in the link.x86_64-w64-mingw32-ldfailed with "undefined reference tomp3_alloc/mp3_free" duringmicropython.exelinking even thoughmp3_alloc.ocompiled cleanly andnmshowed both symbols correctly emitted as PE weak externals (.weak.mp3_alloc./w mp3_alloc) -- a lone weak definition with nothing else in the link strongly referencing it is a known mingw-w64/GNU-ld PE-COFF gap, distinct from ELF (this port's own unix build resolves the identical weak-only definition without issue). DroppingMP_WEAKentirely -- correct here since nothing in this port ever needs to override these two one-line wrappers -- fixed the windows link with no effect on unix. src/audiomp3/MP3Decoder.c'sstream_readable()(ported from CP's ownMP3Decoder.c) callsstream_p->ioctl(stream, MP_STREAM_POLL, ...)to check whether the input stream has data ready before attempting a read. Found by running the phase 7/9 oracle-diff parity scripts underbin/micropython.exefor the first time (phase 8c) -- previously only verified on unix and againstbin/circuitpython, neither of which exercises this path the same way. Mainline's ownextmod/vfs_posix_file.craisesNotImplementedError("poll on file not available on win32")fromMP_STREAM_POLLfor a real POSIX-style file object (not a socket) on_WIN32-- not a bug in this port, a documented gap in mainline's own windows-port VFS ioctl implementation, confirmed by reading that file directly. Every successful frame decode from a real file calls this (via the synchronousbackground_callback_addstub -- seecp_compat/background_callback.h), soMP3Decoderreading from anopen()ed file was completely broken onmicropython.exebefore this fix: the very firstget_buffer()past the first frame raised. Local fix:stream_readable()now has an#ifdef _WIN32branch that skips the poll call and returnstrueunconditionally, exactly the same fallback already used a few lines below for a stream with noioctlslot at all --mp3file_update_inbuf_always()'s own non-blocking-read handling (mp_is_nonblocking_error) is what actually prevents a stall either way, so this is a false-availability-check removal, not a correctness change. Verified:parity_mp3decoder.py/parity_mp3decoder2.py, previously untested on windows, now pass byte-for-byte againstbin/circuitpythonundermicropython.exe; unix behavior (and the_WIN32branch never taken there) is unaffected, confirmed by re-running the full tier 0-5 parity suite plus the LVGL smoke test on unix after the change.
Upstream's Make glue picks between two different MPDEC_ALLOCATOR(x)/
MPDEC_FREE(x) wirings for lib/mp3/src/buffers.c depending on build
variant: the unix coverage variant (this workspace's oracle,
bin/circuitpython) wires straight to malloc(x)/free(x); every other
CP port wires through mp3_alloc(x)/mp3_free(x)
(shared-module/audiomp3/__init__.c, MP_WEAK, defaulting to
m_malloc_maybe/m_free). This port always uses the second (production)
path -- see docs/porting-plan.md, "Tier 5" -- since it changes only where
the Helix decoder's internal buffers come from (GC heap vs. the C heap),
never the rendered PCM, confirmed by the byte-exact oracle diffs below
against a bin/circuitpython that itself takes the other path: this is a
build-configuration choice invisible to output, not a parity-relevant
deviation. mp3_free itself also isn't a literal port of CP's version --
see the previous section and the mp3_alloc.c header comment for why it
uses gc_free rather than a direct m_free(ptr) call (mainline's m_free
needs an explicit size on this workspace's unix port, which mp3_free's
callers never have in scope).
MP3Decoder oracle-diffed byte-for-byte against bin/circuitpython using
the parent workspace's mp3/examples/test.mp3 (Adafruit's own bundled fixture, ID3v2.3,
MPEG1 Layer III, 40 kbps CBR, 44.1 kHz stereo, ~12s): full-track decode via
reset_buffer/get_buffer (checksum + byte count identical), rms_level
and samples_decoded at multiple points, construction from a filename
string vs. an already-open binary stream vs. a caller-supplied pre-allocated
buffer (all three produce identical PCM), the file property's getter and
setter, explicit open(), the deinit guard, and error paths (TypeError
for a text-mode file, RuntimeError("Failed to parse MP3 file") for
non-MP3 data). One behavior was deliberately NOT "fixed" because it isn't
broken: calling reset_buffer again after a full decode to EOF and
decoding a second time produces a different total byte count than the
first pass (1048320 bytes first pass vs. 1078272 second, on the test
fixture) -- confirmed byte-for-byte identical on both interpreters,
including down to the exact discrepancy, so this is a genuine,
faithfully-reproduced characteristic of the real decoder/ID3-skip
interaction, not a port bug.
Rebuilding micropython.mjs/.wasm (the depth-1 USER_C_MODULES glob picks
up audioif automatically once built; it just hadn't been
rebuilt since the usermod landed) surfaced a batch of portability bugs,
none reachable on unix or windows before now:
- the parent workspace's
mp3/src/mp3dec.hpicks its fixed-point/asm path from a closed list of(__GNUC__, arch)combinations,#erroring on anything else -- with an explicitMP3DEC_GENERICescape hatch for exactly this case. wasm32 matches none of the listed architectures. Fixed inmicropython.mk:-DMP3DEC_GENERICadded, but only when building for thewebassemblyport specifically (detected the same way the parent workspace'swasmbridge/micropython.mkdetects it:$(findstring /ports/webassembly,$(abspath $(CURDIR)))) -- unix and windows both already match a named__GNUC__/arch branch and must keep using it, not silently fall back to the generic path. src/synthio/__init__.h'ssynthio_synth_t, andsrc/synthio/Note.h'ssynthio_bend_mode_twere each declared with twotypedefs of the same name -- a forward declaration plus the real definition, exactly mirroring how CircuitPython spells the same thing across its separateshared-bindings/shared-moduleheader pair. Twotypedefs of an identical type are legal under GNU extensions/C11 (and never even co-occur in one CP translation unit, since CP never merges those two headers), but a hard error under emscripten's strict-std=c99 -Werror, which this port's merged-into-one-file convention exposes for the first time. Fixed by dropping the second (redundant)typedefin each case -- the type is already complete by then either way.src/synthio/Biquad.cusedM_PI, a glibc/mingw<math.h>extension, not ISO C99 -- present transitively on unix/windows, absent under emscripten's stricter libc. Fixed with a local#ifndef M_PI #define M_PI ... #endifguard.src/audiomixer/Mixer.c'scopy8lsb/copy8msbare genuinely dead code -- confirmed by reading CircuitPython's ownMixer.cdirectly, which defines the identical pair, also unused (onlycopy16lsb/copy16msbare called; these look like a leftover from a since-removed 8-bit-native mixdown path). Not a port bug, but unix/windows's compilers apparently don't enable-Wunused-functionas an error here while emscripten's-Wall -Werrordoes. Fixed with__attribute__((unused))rather than deleting faithfully-ported (if currently unreachable) code.src/audiomp3/MP3Decoder.cneeded an explicit#include <errno.h>for the bareEINVALit uses (matching upstream, which also uses it bare rather thanMP_EINVAL) -- glibc and mingw-w64 expose it transitively through<sys/types.h>/<unistd.h>already; emscripten's libc does not. Also needed an explicit(mp_float_t)cast oncommon_hal_audiomp3_mp3file_get_rms_level(self)'sfloatreturn value beforemp_obj_new_float()(which takesmp_float_t,doubleon every build in this workspace) -- the implicit widening is fine under GCC's default warning set but trips-Wdouble-promotion -Werrorunder clang.
Full DSP parity suite (all of tiers 0-5, plus synthtools_acceptance.py)
re-run against the rebuilt wasm interpreter via a headless Node driver
(loadMicroPython() from micropython.mjs, since there is no
micropython-style CLI binary for this port) and diffed against
bin/circuitpython: byte-for-byte identical on every script except two
environment-only gaps, neither a DSP bug --
parity_mp3decoder.py/parity_mp3decoder2.py need a real file
(mp3/examples/test.mp3) on disk, which this ad hoc Node harness never
staged into wasm's virtual filesystem (already separately verified on
unix/windows against the same oracle); and parity_multitapdelay.py's
error-path case shows a differently-formatted traceback (PythonError: Traceback ... from the JS loader wrapping the exception, vs. the
interpreter's own native traceback text) -- the same category of
REPL/host-layer artifact as the CRLF-vs-LF difference documented for tier 4,
not a module bug. synthtools_acceptance.py (phase 7) also does not run
byte-for-byte under this harness: it computes its vendor path from
__file__, and mp.runPythonAsync(source_string) (this Node driver, since
there is no wasm-equivalent of a micropython script.py CLI invocation)
executes the script as a REPL-style source string with no __file__
bound, unlike the other three interpreters invoked with a real path
argument. Not a DSP issue -- every DSP-only script above ran and diffed
cleanly through the same harness -- just a limitation of this one ad hoc
test driver, left as a documented gap rather than built out further this
phase.
Distortion soft_clip: verbatim-kept union type-pun turned out to be architecture-dependent (phase 8d)
Tier 4's original decision (see above) kept upstream's args[ARG_soft_clip] .u_obj type-pun (reading an mp_arg_val_t union through the wrong member)
verbatim, on the grounds that it reproduced the oracle's output
byte-for-byte -- true, but only ever checked on x86-64 (this port's
unix/windows targets and CP's own unix coverage oracle, all the same
architecture and calling convention). Running the same parity script
(parity_distortion.py) against the newly-rebuilt wasm32 interpreter
(phase 8d) exposed the actual bug: constructing Distortion() with no
soft_clip argument at all (default False) reported soft_clip == True
on the wasm build, because the union-punned read happened to interpret
args[ARG_soft_clip].u_obj's default value (a boxed mp_obj_t, not a
real bool) as truthy there, where x86-64's specific pointer-truncation
behavior for that same default value happened to read as falsy.
Fixed to args[ARG_soft_clip].u_bool (see src/audiofilters/Distortion.c)
rather than kept verbatim a second time, for two reasons: it is
unambiguously what upstream's own C function signature (bool soft_clip)
intends, and every real CircuitPython board is 32-bit ARM, not x86-64 --
so the byte-exact-on-unix result was already the architecture-unrepresentative
case, not evidence the quirk was safe to standardize on. Verified after the
fix: soft_clip now reads correctly (matching caller intent) and produces
identical checksums across all three of this port's targets (unix,
windows, wasm) for the same script -- a stronger three-architecture
consistency check than the tier 4 verification had access to at the time.
Sharper than the phase-8d writeup, found 2026-08-27 while drafting the
upstream report: this is not wasm-only, and "no unix impact" was too kind.
The default reads correctly everywhere because mp_arg_parse_all() copies the
whole union for a defaulted argument, and the static {.u_bool = false} is
zero-filled. A supplied argument only has its one bool byte written into
an uninitialised stack union, so .u_obj is a pointer made of one meaningful
byte and seven stale ones. On this port's own x86-64 oracle build,
Distortion(soft_clip=False) reads back True -- the one case a user
would reach for to get the hard curve is the one case that cannot be right
except by luck. Drafted:
docs/upstream-reports/distortion-soft-clip-union.md.
This is now a deliberate, documented divergence from the x86-64 CP oracle
for this one field (the oracle still exhibits the original bug); the
memset(word_buffer, 32768, ...) truncation quirk noted alongside it in
tier 4 was re-checked and is not similarly architecture-dependent (memset
truncating its fill value to unsigned char is guaranteed by the C standard
everywhere), so it remains kept verbatim.
Tier 5 audiomp3 on CMake/mcu ports: mp3dec.h's platform list, and a QSTR-extraction blind spot (phase 10)
Wiring tier 5 into micropython.cmake (deferred at phase 8e, closed at
phase 10 -- see docs/porting-plan.md) surfaced two separate issues, one
upstream (Adafruit_MP3/Helix, vendored as the parent workspace's mp3) and one in mainline
MicroPython's own CMake glue (py/mkrules.cmake, py/py.cmake).
mp3dec.h's closed (__GNUC__, arch) platform list has no Xtensa
branch, not just no RISC-V branch. The phase 8e writeup (building the
ESP32-P4) only identified RISC-V as the gap, since that was the one board
being built at the time. Actually wiring tier 5 for CMake ports generally
showed the real shape of the problem: the list covers ARM, __ARMEL__,
__i386__, __amd64__, __AVR32_UC__, and a couple of named
microcontrollers -- Xtensa (esp32/esp32s2/esp32s3, not just the RISC-V
esp32 variants) was never covered either, matching wasm32 for the same
reason: it's just not -Defined into the pinned Helix decoder's platform
list. So -DMP3DEC_GENERIC is needed for every esp32 target this
workspace builds, Xtensa and RISC-V alike; only rp2 (RP2040/RP2350, ARM
Cortex-M, __GNUC__ && __ARMEL__) matches natively. Gated in
audioif/micropython.cmake on ESP-IDF's own
CONFIG_IDF_TARGET_ARCH_RISCV/CONFIG_IDF_TARGET_ARCH_XTENSA sdkconfig
variables (the same ones esp32_common.cmake branches on for its own
MICROPY_CROSS_FLAGS selection), not a bespoke detection mechanism.
QSTR extraction on ESP-IDF CMake builds does not see INTERFACE-library
compile definitions the way the real compile does. Setting
target_compile_definitions(usermod_mpaudio INTERFACE MP3DEC_GENERIC)
alone was not sufficient, even though the real per-object compile picked
it up correctly (confirmed present in build.ninja's DEFINES for every
mp3 .o). MicroPython's own py/mkrules.cmake runs a separate
QSTR-extraction preprocessing pass (makeqstrdefs.py pp ... -E ...,
producing qstr.i.last) that builds its own flag list from a raw
get_target_property(${MICROPY_TARGET} COMPILE_DEFINITIONS) call --
which, per ordinary CMake semantics, returns only that target's own
directly-set definitions, not ones contributed transitively by a linked
INTERFACE library several links down the chain
(usermod_mpaudio → usermod → MICROPY_TARGET). That pass still fully
preprocesses every source (it needs real macro expansion to find MP_QSTR_
tokens), so it hit mp3dec.h's #error No platform defined directly,
independent of whether the actual object compile would have succeeded.
Confirmed by diffing the qstr pass's actual gcc -E ... invocation
against the real compile rule for the same file: -DMP3DEC_GENERIC present
in the latter's DEFINES, absent from the former's flags entirely.
MicroPython's own py/py.cmake already has a helper for exactly this
class of problem -- micropy_gather_target_properties(targ), which reads
INTERFACE_COMPILE_DEFINITIONS for INTERFACE_LIBRARY-typed targets and
folds them into MICROPY_CPP_DEF_EXTRA (the same accumulator
mkrules.cmake appends into MICROPY_CPP_DEF before building
MICROPY_CPP_FLAGS). But esp32_common.cmake only calls it in a loop over
__COMPONENT_NAMES_RESOLVED -- registered ESP-IDF components -- and
usermod/usermod_mpaudio are plain CMake targets created inside the
main component's own CMakeLists.txt, not components in their own right,
so that loop never reaches them. This is a real, if narrow, gap in
mainline MicroPython's ESP-IDF CMake glue, not anything specific to this
usermod -- any usermod defining INTERFACE-scoped compile definitions
needed for QSTR-sensitive preprocessing on ESP-IDF would hit the same
blind spot.
Fixed on our side, without touching mainline files: append
MP3DEC_GENERIC directly to MICROPY_CPP_DEF_EXTRA from
audioif/micropython.cmake when the arch check fires. This
works because usermod.cmake (and our aggregator beneath it) is
include()d into the port's CMakeLists, not add_subdirectory()d -- plain
(non-cache) CMake variables set here are visible later when
mkrules.cmake reads MICROPY_CPP_DEF_EXTRA, in the same directory scope,
regardless of the gather-loop's component-only reach. Verified end to end
by rebuilding both mcu data points from a clean build directory: ESP32-P4
(MP3DEC_GENERIC branch) and RPI_PICO (native-ARM branch, exercising the
"don't need the define, don't break anything" path) both built clean with
tier 5 now included -- see docs/porting-plan.md phase 10 for the exact
before/after firmware sizes.
Unlike WaveFile, audiocore.RawSample needed no adaptation and diffs
byte-for-byte against bin/circuitpython (10.2.1) for construction,
sample_rate/bits_per_sample/channel_count properties (get and set),
the deinit guard, the context-manager protocol, and rendered PCM via
get_buffer/reset_buffer. This is the calibration case: when a tier's
oracle diff matches this cleanly, the port is source-faithful, not just
"looks right."
The oscillator wraps its accumulator one sample late, and reads off the end of the waveform (instruments tier)
Upstream's DDS loop (shared-module/synthio/__init__.c, and the ring
modulator beside it) advances a fixed-point accumulator and wraps it with
if (accum > lim) { accum = accum - lim + offset; }
int16_t idx = accum >> SYNTHIO_FREQUENCY_SHIFT;
out_buffer32[i] = waveform[idx];lim is waveform_end << SHIFT, and waveform_end is an exclusive bound --
the samples a note may read are [waveform_start, waveform_end). Wrapping on
> rather than >= lets the accumulator sit exactly on lim, so that
iteration indexes waveform[waveform_end]: one past the loop, and for the
common case of a note looping an entire table, one past the end of the buffer
itself. The read is out of bounds, and what it returns is whatever the
allocator happened to leave after the array.
This is not a rare edge. Any note whose dds_rate is an exact multiple of the
sample step lands on the boundary on a schedule -- the noise tables the drum
machines play at sample_rate / 8192 advance exactly one sample per frame and
hit it every 8192 frames, and each voice hits it at its own offset. The
practical consequence is that a render is not reproducible: the same script,
same events, same interpreter produced different PCM depending on how the heap
happened to be laid out. Confirmed directly on CPython -- rendering one TR-909
sequence gave different output under PYTHONMALLOC=default, malloc, and
debug, and changed again if unrelated objects were allocated beforehand.
Drafted for upstream: docs/upstream-reports/dds-oscillator-off-by-one.md,
with a repro that keeps the errant read inside the buffer (a loop end short
of the buffer end) so it is deterministic. Still present on main 2026-08-27,
at four sites.
Fixed here (audioif_oscillator_fill() in src/shared/audioif_synth_dsp.c,
shared by the MicroPython usermod and the CPython extension, plus the
MicroPython ring-modulator loop in src/synthio/__init__.c) by wrapping on
>= and subtracting the loop span, and by reducing an out-of-range incoming
accumulator into [offset, limit) rather than into [0, limit) + offset.
Kept upstream's structure otherwise; this is a correctness fix, not a
behavioral redesign. It is a deliberate divergence from the oracle only where
the oracle's behavior is undefined: every committed parity fixture
(verify_effects, verify_streaming, verify_acceptance, and the CPython API
tests) still matches its recorded hash after the change, because those
fixtures' waveforms and rates never land on the boundary. Where the boundary
is hit, there is no oracle value to be faithful to -- upstream is reading
memory it does not own.
Note for anyone diffing instruments against bin/circuitpython: the oracle
build still has this bug, so CircuitPython's own renders of boundary-hitting
material remain sensitive to its heap layout. Instrument parity runs treat
CircuitPython as advisory for that reason; CPython and MicroPython (both of
which take the fix) are the enforced targets.
Every other module here is CircuitPython's, moved. These two are not: they come
from micropython-vst3's vstaudio usermod (usermods/vstaudio/vstaudio_dsp.c),
where its effects library's compressors, limiters, gates, de-essers and
parallel branches were built. CircuitPython has no equivalent and never had
one, so there is no oracle in the parent workspace's circuitpython checkout to diff against and nothing
in this section is an upstream deviation. What it records instead is where the
port differs from its original.
The DSP itself is unchanged, float working precision included -- doubles
would be a better filter and a different one. It lives in
src/shared/audioif_dynamics.c and src/shared/audioif_splitter.c, so the
MicroPython usermod, the CPython extension and the CircuitPython spike all run
the same arithmetic; the per-runtime code is only the loop that pulls the
source. tests/parity/verify_dsp.py holds all three to what the original
rendered, byte for byte, by compiling vstaudio_dsp.c itself -- unmodified,
straight out of the sibling checkout -- into a throwaway interpreter
(tests/parity/build_vstaudio_oracle.sh). The usermod that publishes those
types cannot be imported directly: it is the plugin sidecar, and it wants a
shared memory mapping that a VST host created.
Two deliberate changes:
Splitter(source, taps=n)accepts the tap count as a keyword. The original was positional-only, which reads badly at the effects library's call sites.- A tap holds a real object reference to its Splitter, not the raw C
pointer the original stored. Handing a tap to a
Mixerand dropping every other name for the Splitter is the ordinary case, not an unusual one, and the collector has to be able to see that the 32 KB ring is still in use.
Quirks kept on purpose, because the effects library is written around them:
- Neither node ever reports
GET_BUFFER_DONE. A starved chain gets silence. Both sit in the middle of a live graph, which is still running. Dynamicshands out at most 256 frames per call, and carries leftover source frames across output blocks.Dynamics.reset_bufferdrops the detector envelopes but keeps the sidechain filter's memory and the last reported gain reduction.- An
attack_msso long that its coefficient rounds to zero silently gets the 10 ms default instead. This is whyaudioif_dynamics_config_finish()is a separate call rather than part of the initial state. - Writing past a laggard tap's cursor drags that cursor forward and drops what it never collected; the branch skips ahead rather than stalling the graph.
SplitterTap.reset_bufferdoes nothing. The cursors belong to the Splitter, and the other branches are still reading against them.
Neither node has deinit/__enter__/__exit__, unlike the ported
CircuitPython effects around them. The originals had no lifecycle, and giving
one to three implementations to keep in step buys nothing the collector does
not already do.
audiodynamics and audioroute above are at least someone's code moved.
audiomath is not: nothing in CircuitPython and nothing in
micropython-vst3's engine multiplies one audio stream by another, so there is
no oracle to diff against and this section records a new module rather than a
deviation.
Multiply(source, modulator, mix=1.0) writes source * modulator, blended
back against the untouched source by mix. That is ring modulation, and with
a modulator that does not cross zero it is amplitude modulation. The palette
could not do either:
synthiorings a note against an oscillator. It reaches synthesized notes and nothing else -- not a microphone, not a sample, not the output of another effect.- An LFO-driven parameter updates once per block, about 187 Hz at 48 kHz.
audioeffects.Tremolois exactly this effect inside that ceiling; a ring modulator wants hundreds of hertz and a carrier of a few kilohertz.
The arithmetic is shared/audioif_multiply.c, Q15 and stateless: the product
is (a * b) >> 15, blended (dry * a + wet * product) >> 15, clamped. The
two negative rails are the one product that lands outside int16, which is
what the clamp is for; tests/parity/multiply_probe.py drives it there
deliberately rather than assuming.
The two inputs fail in opposite directions, on purpose. A source that runs
dry gives silence, the way every other node in the palette does. A modulator
that is absent, or has stopped, lets the signal through untouched --
audioif_multiply_passthrough_s16(). This is the one place where "no input"
and "an input of zero" must not mean the same thing: a missing modulator that
muted the signal would make every dropout a hard gate.
A modulator is normally a short looping table, and looping is free here rather
than a feature: audiocore.RawSample returns GET_BUFFER_DONE with its whole
buffer every time it is asked, so pulling one repeatedly is the loop. The
carrier audioeffects.RingMod builds therefore holds a whole number of cycles
(modulation.py, _carrier), because a partial one would step the phase once
per table and buzz at the table rate.
verify_dsp.py covers it, but differently from its two neighbours: with no
oracle, the golden is captured from the port under CPython and what it proves
is cross-interpreter agreement and no accidental drift, not fidelity to
something older. All three interpreters render it identically.
The module gained a second class, SubOctave, in the effects program's
Phase 1 -- an analog octave divider, which is arithmetic on one stream
against its own zero crossings. See "audiomath gains SubOctave" below.
Found while landing this one, and worth recording because it failed quietly.
insert_block_after skipped any file whose audioif-cp begin marker was
already there, so extending a block -- which is exactly what adding a module
does -- reached a fresh CircuitPython tree and no other. CIRCUITPY_AUDIOMATH
never landed, and the build then failed a long way from the cause. It now
rewrites the contents between the markers when they differ, and reports
current / updated / patched so the three cases are distinguishable.
audiodelays.Echo exists upstream, and its feedback path is echo * decay
and nothing else. Everything a delay is actually named after falls out of
what happens in that path, so without it there is one delay with a level
knob:
- Tape. Every pass through a tape machine loses top and bottom and softens. Filtering the delay's output once, after the fact, is not the same thing: it darkens the first repeat exactly as much as the tenth.
- Analog / BBD. The same, further. A bucket-brigade line is band-limited by construction and its clock drifts.
- Ping-pong. Repeats alternating between the speakers needs each channel's output fed into the other channel's line. Two delays panned hard apart, which is all the palette could do, gives repeats on both sides at once.
audioecho.FeedbackDelay puts a one-pole low-pass (damping_hz), a one-pole
high-pass (cut_hz), a cubic soft-clip (loop_drive), a per-sample delay
modulation (wow_hz/wow_depth_ms) and a cross-feed (cross_feed,
input_pan) in the loop. shared/audioif_feedback_delay.c, float working
precision to match audioif_dynamics.c.
A new module rather than arguments on Echo, deliberately. An argument
added to audioif's copy of a CircuitPython module would not exist on a stock
board, so a TapeDelay written against it would silently be a different
effect there -- the exact failure apply_cp_patches.sh exists to avoid. A
new module either installs whole or is absent and says so on import.
Two details worth recording:
- The wow oscillator is a magic-circle resonator, two states rotated by a
constant each frame, not
sinf(). Per-sample modulation is the point (an LFO-drivendelay_msupdates once per block, about 187 Hz at 48 kHz, so it steps rather than glides and there is no doppler), and a library call per sample would not be affordable on the parts this has to run on. reset_bufferreally does drop everything, unlikeaudiodynamics, which keeps its sidechain filter and last gain. A delay's whole state is audible: a chain restarted with the old repeats still in the line plays the previous take over the new one.
Verified by tests/parity/feedback_delay_probe.py through verify_dsp.py,
with no oracle -- the golden is captured from the port. That is a weaker
claim than the audiodynamics fixtures make, but a stricter cross-interpreter
one than anything else in the suite: the loop is recursive and runs in
float, so a one-ulp disagreement between two builds would be fed back and
amplified rather than staying one ulp. All three render it identically.
Found while rebuilding it, and it had been there since the class was written. The tone filter sat after the delay node, and the delay node had already blended dry with wet -- so the filter darkened the untouched signal along with the repeats. At the class's own defaults, and worse at the low mixes the soundtrack uses:
tone through TapeDelay(mix=0.14, tone_hz=3800) |
before | after |
|---|---|---|
| 1 kHz | -0.02 dB | -1.30 dB |
| 4.3 kHz | -4.28 dB | -1.30 dB |
| 10 kHz | -19.26 dB | -1.31 dB |
| 16 kHz | -33.33 dB | -1.31 dB |
("after" is the mix attenuation, flat across the band, which is what a delay at 14% wet should cost.) Three racks in the soundtrack used it, so their renders move -- that is the fix arriving, not a regression.
audioecho.FeedbackDelay gains a shape table, a slew, wet AM and a loop pitch shift (effects Phase 1)
Additive, and all four default off, so a FeedbackDelay built the way it was
is what it was -- tests/parity/feedback_delay_probe.py's hash is unchanged
across this phase, which is the check that says so. The new paths get their
own fixture (feedback_delay_options_probe.py) rather than joining that one,
because a probe's golden is one hash over its whole output: a case appended to
it would move the very number the additive claim rests on. That fixture's
first two cases render feedback_delay_probe.py's plain line for line.
Asked for by the effects program (audioif#37, its Phase 1 palette work): the Phase 0 survey fixed traits across the modulation, delay and pitch families that no arrangement of the existing nodes reaches, and the program's vision §6 permits additive options on audioif's own modules for exactly that.
-
wow_shapereplaces the built-in sine with one period of the caller's own,int16Q15, a power of two from 2 to 4096 samples;Noneis the sine again. The node's only modulation waswow_depth_frames * wow_sineadded to the delay, and a bucket brigade's delay is its line length over twice its clock -- so the Small Clone's triangle on the clock arrives as a reciprocal on the delay, which no sine is. The magic-circle pair carries no phase index to look a table up with, so the table gets a Q32 accumulator beside it, stepped from the samewow_hz. The pair keeps turning either way:wow_am_depthreads it, and stopping it would move the default path's arithmetic. The table is borrowed, not copied -- the bindings hold the object (self->wow_shapeunder MicroPython and CircuitPython, an openPy_bufferunder CPython) because the config only keeps a pointer. -
delay_slewwalks the read head to a newdelay_msinstead of jumping to it, in delay-seconds per second -- a dimensionless rate, so it is the same number at every sample rate.delay_msis a plain float read unsmoothed at the top of the loop, so before this a Time move landed in one jump on a block boundary: no pitch bend, and a discontinuity. A constant slew rate is a constant pitch offset for exactly as long as the move lasts, which is what an Echoplex's capstan and a DM-2's clock both do. -
wow_am_depth(0..1) puts the wow oscillator on the wet gain as well as on the delay. Tape and bucket-brigade level wobble is a loss, so this dips to1 - depthand returns to unity and never boosts; it is on the output only, so it cannot change what the feedback path does. -
loop_semitones(-24..+24) pitch-shifts the line read inside the loop, two taps half aloop_window_mswindow apart under a triangular crossfade whose two halves sum to exactly one. Every pass rises again, which is what a shimmer is;audiodelays.PitchShiftchained after a delay shifts the sum of the repeats once, not each repeat one more time.loop_window_msdefaults to 25 ms and is capped at a quarter of the line: it is the grain rate traded against smear, and it also bounds how far the read head may wander from the delay it was asked for.A shifted loop repeats half a window later than an unshifted one, and a class that reports its own timing has to account for it. The two taps sit at
delay + turn * windowanddelay + (turn + 0.5) * windowunder gains that sum to one, so their weighted mean isdelay + window / 2at every point of the cycle -- constant, not drifting. Measured with a single-sample click at 48 kHz,delay_ms=200, the shift on: the repeat lands at 212.49 ms with a 25 ms window and 229.98 ms with a 60 ms one, against 200.00 ms with the shift off. The consequence to state plainly: turningloop_semitoneson or off mid-stream steps the read position by that half window, which is the same class of discontinuitydelay_slewexists to remove fromdelay_msand is not smoothed here. Set it when the node is built, or between takes.
The option enum is appended, never renumbered. src/cpython/audioecho.py
maps option names to those integers and _audioif.c range-checks against the
last one, so inserting an option would silently change what an already
installed wheel configures.
Off means the same floats in the same order, not the same floats times
one. With no table the offset is the expression it always was; with no slew
the read head is config->delay_frames itself, assigned rather than
computed; and both new gains are branched around rather than multiplied by
1.0. The one structural change to the default path is that the single line
read moved into a tap_for()/read_tap() pair the shifter's two taps share
-- the same operations on the same operands, which the unchanged hash
confirms.
What the four do, measured on the CPython target at 48 kHz rather than asserted:
| Option | Measurement | Result |
|---|---|---|
delay_slew |
200 -> 100.4 ms at 0.99175, pitch while travelling | +1192.9 cents (the law says +1192.8), +0.00 cents after arrival |
delay_slew |
first difference across the block boundary carrying the step | 1559, against 1566 in steady state -- and 15960 with the slew off |
wow_shape |
a flat table at depth 4 ms vs. moving delay_ms by 4 ms |
peak difference 0.0 |
wow_shape |
256-point ramp, 2 Hz, depth 10 ms: d(delay)/dt over the middle half | mean +0.04000, spread 0.00219 (the sine's spread is 0.12966) |
wow_am_depth |
envelope min/max at depth 0 / 0.25 / 0.5 / 1.0 | 1.0000 / 0.7499 / 0.5000 / 0.0000, against a law of 1-depth |
loop_semitones |
a 500 Hz burst, first three passes at +12 / -12 / +7 st | 1000, 2000, 4000 / 250, 124, 60 (ideal 250, 125, 62.5) / 749, 1118, 1700 (ideal 749, 1122, 1682) |
One cost worth stating: powf is called once, at configure time, to turn
semitones into a ratio -- the same shape as the expf and sinf the node
already calls there. The desktop targets and the parity oracle share a libm
so the goldens are safe, but a board's newlib could round that ratio
differently in the last bit, which would show as a different phase step and
so a different digest. Nothing per sample is a library call.
Additive, and both default off, so a Dynamics built the way the original was
is the original -- tests/parity/dynamics_probe.py's hash is unchanged
across this phase, which is the check that says so. The new paths get their
own fixture (dynamics_extras_probe.py) rather than joining that one, because
that one is held against vstaudio_dsp.c compiled unmodified and may only use
forms the original accepts.
lookahead_msholds the audio back while the detector reads ahead of it, so the gain is already down when the transient arrives rather than a fraction of a millisecond after it. Without it, brickwall limiting always overshoots the first cycle of every attack. Capped at 50 ms: it is latency the whole chain pays, and past a few milliseconds a limiter stops sounding like a limiter and starts sounding like it is ducking before the note.true_peakadds the level between samples to what the detector sees, by four-point half-band interpolation of the midpoint. A signal can pass through the ceiling between one sample and the next with no sample over it, and a converter downstream reproduces that peak; sample-peak limiting cannot see it at all. This is an estimate, not ITU-R BS.1770 true-peak metering, which oversamples by four -- it is the cheapest version worth having.
The lookahead buffer is allocated by the bindings, not the DSP, and only
when someone asks for one. 50 ms of stereo is 9.6 KB and audioeffects builds
nine Dynamics instances, so an unconditional buffer would cost 86 KB for a
feature almost nothing uses. audioif_dynamics_lookahead_frames() tells a
binding how much to hand over; the DSP uses whatever it has, so a binding that
allocates nothing gets no lookahead rather than reading off the end of one.
One deliberate difference from the node's documented reset behaviour: the
sidechain filter's memory and the last reported gain reduction still survive
reset_buffer, but what is in the lookahead buffer does not. That is
audio in flight, and a chain restarted with the previous take still queued
would play it.
Additive, every one of them default-off, and the check that says so is inside
the probe rather than beside it: tests/parity/dynamics_options_probe.py's
first case sets none of them and reproduces dynamics_probe.py's compress
case line for line, on the same source at the same settings. That probe is
held against vstaudio_dsp.c compiled unmodified and its hash is unchanged
across this work; the new paths get their own fixture, with no oracle, because
the original has no ancestor for any of them.
The effects program's dossiers put eleven asks on this one node (audioif#38). Each is a fixed trait of a named circuit that the shipped knobs cannot reach by tuning, and the numbers below were measured through the built CPython extension, one frame at a time, so the gain traces are at sample rate.
The detector, and what it listens to.
detector="rms", withrms_ms(10 ms unset), runs a one-pole mean square and roots it, instead of the largest rectified sample across channels. A sine and a square of equal RMS 15 dB below threshold get gains 0.68 dB apart from the peak detector and 0.00 dB apart from this one.feedback_detector=Truepoints the detector at the frame the node last put out rather than at this frame's input: the side chain tapped after the gain cell, which is what a 1176, an LA-2A and a Fairchild all do. The gain still lands on the audio. Without it the feedback arm of a paired feed-forward/feedback measurement cannot be built at all, so three of the four compressor characters were feed-forward by construction.key(sample)feeds the detector from a different stream entirely. In C that is a new entry point,audioif_dynamics_process_s16_key; the oldaudioif_dynamics_process_s16forwards to it with a NULL key, so nothing that calls it changes. A key that runs dry starves the node the way an absent source does - silence, never a short block, and never a detector that quietly reverts to the audio. Measured: a gate over a quiet 220 Hz tone sits at -60.00 dB for twelve blocks unkeyed, and opens to 0.00 dB on block six when a keyed burst starts.true_peakwas a flag. It is a level now: 1 is the four-point half-band midpoint estimate it always selected, 2 a 4x polyphase reconstruction - four phases of twelve taps, an order-48 FIR, the shape ITU-R BS.1770 Annex 2 specifies for true-peak metering. The taps are not the Recommendation's table. They are a Blackman-windowed sinc, generated once on CPython and written intoaudioif_dynamics.cas literals, so no target recomputes them through its own libm and a board's single-precision build reads the same numbers the desktop does. Measured against a worst-phase f_s/4 full-scale tone at sixteen phases, error against the tone's true peak: off -3.01..+0.00 dB, 1 -1.24..+0.00 dB, 2 -0.17..+0.42 dB. The half-band estimate's own comment already conceded it does not pretend to be true-peak metering; this is the version that does, and it errs high, which for a limiter's ceiling is the safe direction.sidechain_lp_hzcloses the top of the key bandsidechain_hzopened the bottom of, andsidechain_poles=2cascades a second pole through both ends. Measured below a 2500 Hz corner: 5.29 dB/octave at one pole, 10.31 dB/octave at two.key_listen=Trueputs the detector's own signal on the output instead of the audio - a gate's Key Listen switch. With a 4 kHz key high-pass over a 220 Hz tone the output peaks at 125 against the ordinary output's 3000.
The gain computer.
depth_dbreplaces the expander's fixed -60 dB (:185) and the gate's fixed -80 dB (:192). A gate held closed settles at -0.00, -20.00, -40.00, -60.00 and -80.00 dB for those five settings. Positive means unset, and unset is those literals: a depth is an attenuation, so no usable setting is above 0 dB, and 0.0 had to stay available as a real value.hold_ms, withhysteresis_db, swaps the gate's memoryless gain computer for a closed/attack/hold/decay machine on the same peak level. Once ATTACK is entered it runs to full open whatever the key does next, and a crossing during DECAY re-enters ATTACK from wherever the gain got to - the trigger state a pure function of the instantaneous envelope cannot have. Measured on a 1 ms burst under a 200 ms attack: without hold the gate never leaves -80.00 dB; withhold_ms=20it runs to -0.44 dB and stays within 0.5 dB of open for 23.5 ms. The thresholds are precomputed linear, so this path skips thelogfat:307;gain_reduction_dbstill costs onegain_to_db.relative_threshold=Truedrives the computer with the side-chained level minus the full-band level instead of an absolute overshoot, so a fixed spectral balance gets the same reduction wherever it sits in the level range. A fixed 200 Hz + 6 kHz balance at -6, -20 and -40 dBFS spreads 10.57 dB with it off and 0.00 dB with it on.program_attack=Truescales the attack coefficient by the square root of how far the level is over the threshold. Two things that did not work and why: measured against the envelope, the ratio is enormous out of silence and every attack collapses to one sample; scaled by the overshoot itself, 20 dB over lands 6.2x faster than 10 dB over, past every dossier's band. The root gives time-to-63% of 1.167 ms at 10 dB over and 0.333 ms at 20 dB over, a ratio of 3.50 where the DeEsser dossier asks 3.3 within 25%.
The transient shaper.
transient_fast_attack_ms,transient_fast_release_ms,transient_slow_attack_msandtransient_slow_release_msare the four detector time constants that were compiled-in literals evaluated at process entry (:215,:217,:219,:221), under a comment saying they were fixed by the shaper's design. They are config fields now, defaulted to those same literals, so a node that leaves them alone computes exactly what it computed before.slow_hold_msmakes the slow envelope a peak-hold rather than a follower, so the sustain difference grows monotonically through a note's decay instead of peaking early and falling away with it. On a 220 Hz note decaying at 10 dB/s, gain read once per block: the stock shaper reaches -0.87 dB at 200 ms and the peak-hold reaches -9.09 dB with no block coming back up by as much as 0.25 dB.transient_dual=Trueruns a second, slower envelope pair (sustain_fast_attack_msand its three siblings, defaulting to 1, 200, 25 and 1200 ms) for the sustain section, and applies both differences at once rather than selecting one by the sign of the first. The peak-hold goes on whichever pair drives the sustain section - the only one there is, or the second whentransient_dualgives sustain its own. Held on the first pair while a second exists it costs the attack gain for nothing, because a held slow envelope is never below the fast one: measured +0.00 dB of attack gain that way against +4.91 dB the right way, with the sustain still reaching -6.99 dB at 200 ms.
What a reset drops. audioif_dynamics_clear_extras() is called by both
state_init and reset, and clears the RMS envelope, the stored output the
feedback detector reads, the full-band envelope, the second transient pair,
the slow peak-hold, the gate machine's stage/gain/hold and the 4x
reconstruction's history. The side-chain filters are deliberately not in
it - not the second high-pass pole and not either low-pass pole - because
the original keeps its side-chain filter memory and its last reported gain
reduction across a reset, and these are the same kind of thing.
Cost. None of it is paid by a node that does not ask. The per-sample additions sit behind branches on config fields that are zero or false by default, and the two expensive ones are opt-in by construction: the 4x reconstruction is 48 multiplies per channel per sample, and the second side-chain pole is one multiply-add per channel per sample per end of the band. The four transient coefficients and the second pair's four are computed once per block, as the originals were.
The one place apply_cp_patches.sh changes code CircuitPython already had,
rather than adding to it. Upstream returns a memoryview typed by the sample's
width, so len() counts samples while the C protocol's buffer_length counts
bytes -- every byte calculation downstream is then wrong by the sample width, a
silent 2x for ordinary 16-bit audio. This port's own audiocore.get_buffer
returns a byte view (audioif 413d87a), and the parity probes compare len()
and slices across all three interpreters, so the oracle has to agree.
The rewrite lives in src/circuitpython_spike/apply_replacements.py with the
upstream text it replaces. It is idempotent, and it fails loudly rather than
quietly if neither its marker nor the original text is present -- that means
the file moved upstream and a person should re-read it.
audiomixer.Mixer's reset_buffer stops every voice upstream:
for (uint8_t i = 0; i < self->voice_count; i++) {
common_hal_audiomixer_mixervoice_stop(self->voice[i]); // sample = NULL
}Every other source in the stack treats reset_buffer as "rewind to the
beginning". This one drops what was playing and never picks it up again, so a
Mixer that has been reset renders zeros for the rest of its life.
That is not a corner case, because pulling from a source resets it first.
Filter.play(sample), Echo.play(sample), every effect's play() and every
output's, all call reset_buffer on what they were handed. So
mixer.voice[0].play(source)
effect.play(mixer) # <- silences the mixer hererenders silence, and always has. It went unnoticed because a Mixer is normally
the last node before the output, and because MixerVoice.play() re-primes the
voice, so a voice started after the reset works fine — which is how every
example is written.
Found while moving micropython-vst3's effects library into audioeffects:
ParametricEQ sums its boost branches in a Mixer and then chains the cut
sections after it, so any curve with both a boost and a cut was silent.
Confirmed against bin/circuitpython directly — the oracle does the same
thing.
Fixed here by rewinding instead of stopping: a new
common_hal_audiomixer_mixervoice_reset() (src/audiomixer/MixerVoice.c,
and MixerVoice.reset() in audiomixer.py) does exactly what
MixerVoice.play() already does — reset the sample, re-prime the voice's
buffer — for each voice that is still playing. A stopped voice stays stopped.
Every committed parity fixture still matches its recorded hash, because none
of them reset a Mixer with voices playing.
Not applied to the CircuitPython target. apply_cp_patches.sh only adds
modules; the one CircuitPython source it rewrites is audiocore.get_buffer's
return type, which the parity harness needs to compare like with like. Fixing
DSP inside the oracle would erase the divergence this file exists to record.
The consequence is real and worth stating: on CircuitPython, an effect chained
directly after a Mixer is still silent. Anything in audioeffects that ends in
a Mixer — MultibandCompressor, Harmonizer, Octaver, StereoWidener,
DynamicEQ, PingPongDelay, Exciter — can be the last node in a chain
there, but not the middle of one. (ParametricEQ was on that list until the
peaking-EQ fix below let it drop the Mixer entirely.)
audioif_biquad_configure_w0() builds a peaking bell (mode 4) from the RBJ
cookbook. Upstream computes
b0 = 1 + alpha * A; b1 = -2 * cos; b2 = 1 + alpha * A;
a0 = 1 + alpha / A; a1 = -2 * cos; a2 = 1 - alpha / A;b2 is 1 - alpha * A. The sign is not cosmetic: it is what makes numerator
and denominator sum to the same value at DC and again at Nyquist, which is the
entire premise of a peaking filter — unity everywhere except the band around
f0. With the plus, the numerator picks up 2 * alpha * A at DC that the
denominator does not, and since 1 - cos(W0) is very small at low
frequencies, that term dominates. The DC gain becomes
1 + alpha * A / (1 - cos(W0)), so a +6 dB bell at 1 kHz with Q of 1 at
48 kHz arrives as roughly +21 dB at DC, and it worsens as f0 drops. It is
not a bell with a blemish; it is a bass shelf with a bell buried in it.
Measured through the built extension after the fix, a +6 dB bell at 1200 Hz (Q 1, 8 kHz) reads +6.00 dB at center and 0.00 dB at DC and Nyquist, and a −6 dB cut reads −6.000 dB. Before it, DC read +21.42 dB.
This is upstream's bug, not a porting error: CircuitPython 10.2.1 carries the
identical lines at shared-module/synthio/Biquad.c:157-159. It survived
because PEAKING_EQ is the one mode a synthesis library rarely reaches for —
nothing in this repository, in audioeffects, or in micropython-vst3's
instruments or soundtrack used it. audioeffects.ParametricEQ worked around
it by synthesizing bells out of notch and band-pass sections instead — cuts
as notches blended to depth through the Filter's mix, boosts as band-passed
Splitter branches summed back over the dry signal through a Mixer, which is
why it was capped at three boosts (a Splitter has four taps). All of that is
now one Biquad per band in a single Filter cascade.
Deviation: fixed here, so PEAKING_EQ diverges from bin/circuitpython.
Nothing else moves — no existing fixture reached mode 4, which is also why
tests/parity/biquad_component_probe.py now walks all seven modes.
Still present in upstream main as of 2026-08-27, not just in the pinned
10.2.1, so this one is worth reporting rather than waiting out. Drafted:
docs/upstream-reports/peaking-eq-sign.md.
A biquad is a recursion: each output sample is computed from the two input and
two output samples before it. Upstream's audiofilters.Filter allocates one
biquad_filter_state per cascade stage and runs it across the whole
interleaved buffer, so when it processes a left sample, the "two samples
before it" are the previous right sample and the previous left one.
Two consequences, both measured:
- Every frequency lands an octave high. The recursion advances twice per
stereo frame, so the filter effectively runs at double the rate its
coefficients were computed for. A bell asked for 1200 Hz peaked near
2400 Hz.
audioeffectscompensated for this with aSPECTRAL_SCALE = 0.5factor applied to every frequency before handing it to a Biquad. - The channels are not independent. With left fed 2400 Hz, the left output measured +3.63 dB when the right carried 300 Hz and +4.89 dB when the right carried 2400 Hz — the left channel's level moved 1.26 dB because of a change the right channel made. Identical input in both channels came out 3.3 dB apart at 3 kHz. No scale factor can correct this one.
This one is a catch-up, and it is CLOSED (2026-09-09). Upstream fixed it
after 10.2.1, the CP pin has since moved to 10.3.0, and this port now carries
upstream's exact shape: audiofilters_assign_filter_chain(..., channel_count)
in src/audiofilters/__init__.c, states[j * channel_count + channel], and
10.3.0's own synthio_biquad_filter_sample() single-sample entry point. So this
entry describes no difference at all any more — it is kept because the failure
it records (a stereo Filter sharing one biquad state between the channels,
which sounds like a filter that works and measures as one channel moving the
other by 1.26 dB) is worth being able to recognise again. The fix here was
arrived at independently and landed on the same design, which was reassuring
about both.
Change: one state per stage per channel, indexed
[stage * channels + channel], with the buffer deinterleaved per channel and
chunked in whole frames so the channels stay in lockstep across chunk
boundaries. filter_states_len still counts stages, so callers are unchanged.
Fixed in both implementations — the usermod (src/audiofilters/Filter.c) and
the CPython extension (BiquadState.process_s16 in src/cpython/_audioif.c,
which gained a channels argument). The two agree byte-for-byte.
After the fix the same bell peaks at 1200 Hz where it was asked to, both
channels read identically for identical input, and the left channel's level is
unchanged by the right channel's content. Mono and stereo now agree exactly;
SPECTRAL_SCALE and _core.filter_hz() are gone from audioeffects.
Not affected: synthio's per-note filters. synthio_synth_synthesize()
filters a mono tmp_buffer32 with a per-note state and only then expands to
stereo (src/synthio/__init__.c), so every instrument renders identically
before and after. The blast radius was the audiofilters.Filter sites in
audioeffects.
Stereo Filter had no fixture anywhere in this repository before this change —
its only two uses in the suite pass channel_count=1 — which is how both of
these survived. tests/parity/biquad_component_probe.py covers mono and stereo
across all seven modes, and its golden is captured from this port rather than
from CircuitPython, because of the two deviations above.
SPECTRAL_SCALE and _core.filter_hz() are deleted along with their thirteen
call sites, so a frequency handed to any class in the library is now the
frequency it filters at. _core.check_hz() replaces them: halving everything
kept the library clear of Nyquist by accident, and a biquad configured above
Nyquist folds its coefficients and produces noise silently, so the library now
refuses instead. GraphicEQ drops ISO bands that a low configured rate puts
out of range — the 16 kHz band needs better than 32 kHz to exist.
ParametricEQ is one Biquad per band in one Filter cascade, bells and shelves
alike; it no longer builds a Splitter or a Mixer, no longer caps boosts at
three, and exposes the sections as .biquads for parameter binding. An EQ with
every band flat returns its source untouched rather than a chain of unity
sections. GraphicEQ inherits all of it. DynamicEQ keeps its topology but is
no longer an approximation: RBJ's notch and 0 dB-peak band-pass share a
denominator and their numerators sum to it, so with the compressor idle the
split reconstructs the input — measured flat to 0.03 dB from 100 Hz to 8 kHz,
where before the shared biquad state leaked each channel's band into the
other's notch.
Measured after the sweep at 48 kHz: a +6 dB / Q 2 bell at 1 kHz reads +5.98 dB
at 1 kHz and ±0.1 dB two octaves out either way; a −9 dB / Q 1.4 cut at 500 Hz
reads −9.05 dB; LowPass at 1 kHz reads −3.00 dB at cutoff and −12.33 dB an
octave above. tests/test_cpython_effects_library.py pins the bell placement,
the flat-EQ passthrough, and the Nyquist refusal.
SCOPE CHANGED 2026-09-09 (Brad's call on audioif#77). This deviation now applies to
audiobiquad, which is ours, and to nothing CircuitPython has.synthio.Biquad— and soaudiofilters.Filterand aNote.filterchain — runsaudioif_biquad_cp_*, CircuitPython's own Q15 arithmetic, on all three targets, becausedocs/correctness-standard.mdholds a node CircuitPython also has to CircuitPython's bytes.Why the scope had to be decided at all: it had been applied on one target only. The CPython twin ran the widened kernel and desktop MicroPython ran CircuitPython's Q15, so
audiofilters.Filterrendered different bytes on two of our own targets — 11 LSB apart by the eighth sample of an 800 Hz low-pass, and still opening. Nothing caught it because each target was graded against its own stored digest;biquad_component_probe.pyis now inverify_dsp's cross-interpreter set, and a planted 1-LSB coefficient change fails it.What that costs, measured rather than estimated. Holding
synthio.Biquadto CircuitPython's arithmetic brings back its low-frequency fixed points. Below roughly 100 Hz the filter stops being the filter it was asked for, and the mechanism is one column wide — these are the Q15 coefficients the code actually computes, through its ownfast_sincosrather than an exact cosine:
corner b0trueb0in Q151000 Hz 3.93e-03 129 200 Hz 1.73e-04 6 100 Hz 4.51e-05 1 63 Hz 1.87e-05 1 40 Hz 7.96e-06 0 At 40 Hz the input coefficient rounds to zero, so the section has no input gain at all. What remains is a feedback recursion with poles at radius 0.9963, which settles on an integer fixed point and holds it. At 63 and 100 Hz
b0is a single LSB against true values of 0.61 and 1.48, so the gain is already wrong by 64% and 32% before the recursion is reached.It does not go quiet, it latches, and ordinary material triggers it. Where the report draft says a low-frequency low-pass goes "silent", that is too kind. Fed 512 frames and then silence, a 40 Hz
LOW_PASSat Q 0.707 settles on a constant and stays there:
material parks at DC burst 16143 kick, 60 Hz decaying sine 16143 bass note, 55 Hz 16143 square, 80 Hz 16143 noise burst 16143 asymmetric saw, 45 Hz 0 Five of six, and at 100 Hz all six park at 2631. Feed it fresh audio afterwards and the output stays constant —
min == max == 16143— so the section is not merely inaccurate, it has stopped passing signal.audiofilters.Filterexposes noclear(), so nothing short of building a new node recovers it.Identical on all three targets, so it is a fact about CircuitPython's kernel and not about a binding.
Scope, stated plainly, because the numbers invite overstatement. This is a corner we live in and most callers do not: at 200 Hz and above the coefficients are fine, and synthio's own note filters sit far above it. We are trying to be an exact replica and to build instruments a musician would use; the projects synthio was designed for ask neither of those things (Brad, 2026-09-09). It is severe for us, it has a fix on our side, and it is not a fire under anyone else's chair.
docs/upstream-reports/biquad-band-edges.mdcarries the report. It is held along with the other two — nothing goes upstream until we have done a great deal more work across the org (Brad, 2026-09-09).
Above a few hundred hertz, audiofilters.Filter is fine — the difference
from audiobiquad there is one to a few LSB, and it is the node CircuitPython
has, so it is the one a ported sketch will already be using.
Below about 100 Hz, use audiobiquad. It reaches exact zero on every one of
the cases above, where synthio.Biquad settles on a constant it cannot be
talked out of. The same applies to audiobiquad.AllPass against
audiofilters.Phaser, whose feedback is clamped to 0.1..0.9 so a true null is
not reachable.
The README's audiobiquad section links here rather than repeating any of it.
Two smaller entries are not in this deviation's scope and stay applied everywhere, because upstream has already merged them: PEAKING_EQ's
b2sign (CircuitPythonmain8fabdbbfb1) andsynthio_biquad_filter_reset()clearing all four state words rather than two (8a3deace5c). Neither is in a release yet, so each is a named departure from the pinned 10.3.0 oracle that expires when CircuitPython cuts a release containing it.The rest of this section is the original write-up. Read it as the case for
audiobiquad's arithmetic and as the record of a failure worth recognising again — not as a description of whatsynthio.Biquaddoes today.
Found while looking for somewhere to put a tape head bump, recorded as a limitation, and then fixed one phase later once the user approved a third deviation from the oracle. The section keeps its original measurements as the "before" column, because the failure was silent and worth being able to recognise again.
audioif_biquad_configure_w0() stored its five coefficients as Q15 integers
(AUDIOIF_BIQUAD_SHIFT = 15, scale() rounding value * 32768 to an
int32_t), and audioif_biquad_process() accumulated the five products in
int32_t. Both came straight from CircuitPython's
shared-module/synthio/Biquad.c, and both are defensible on a
microcontroller. The cost is that low-frequency sections are unrepresentable:
as W0 goes to zero a low-pass's b0 = (1 - cos W0) / 2 goes with it, and at
100 Hz / 48 kHz it is 4.3e-5, which is 1.4 in Q15 and rounds to 1. Meanwhile
a1 approaches -2 and its product with a full-scale sample approaches
INT32_MAX on its own, so the accumulator has nothing left for the other four
terms.
There was a second, independent cause, found while fixing the first and
approved with it. fast_sincos() fits one 5th-order polynomial to both sine
and cosine over [0, pi/2], and it is wrong at both ends of the audio band
for two different reasons:
- Its cosine carries up to
2.3e-5of absolute error over the quarter it is fitted to, and5.4e-6at theW0a 100 Hz corner uses. Every low-pass, notch and shelf coefficient is built from1 - cos W0, which at 100 Hz / 48 kHz is8.6e-5-- so that error is 6 percent of the answer, 13 percent at 50 Hz and 34 percent at 20 Hz. Widening the fixed-point format alone would have left a 1 dB error at 50 Hz. pi/2inW0is only 12 kHz at 48 kHz, and above that the fit is extrapolating. At 20 kHz its sine is off by 6.6 percent and1 + cos W0by 15 percent; at 22 kHz, by 31 and 128. This half had never been noticed at all: aHIGH_PASSat 22 kHz passed its entire stopband.
At 48 kHz, one biquad, Q 0.707 unless stated, against the double-precision closed form:
| asked for | before | after | ideal |
|---|---|---|---|
LOW_PASS 50 Hz, at cutoff |
-- | −2.99 | −3.01 |
LOW_PASS 100 Hz, at cutoff |
silence | −3.02 | −3.01 |
LOW_PASS 200 Hz, two octaves below |
+1.25 | −0.03 | −0.02 |
HIGH_PASS 30 Hz, at cutoff |
+21.6 | −3.04 | −3.01 |
HIGH_PASS 100 Hz, at cutoff |
+6.24 | −3.02 | −3.01 |
LOW_SHELF 80 Hz / +1.5 dB, at 20 Hz |
+13.4 | +1.50 | +1.49 |
LOW_PASS 20 kHz, at cutoff |
−3.71 | −3.01 | −3.01 |
LOW_PASS 22 kHz, at cutoff |
−7.29 | −3.01 | −3.01 |
HIGH_PASS 22 kHz, at cutoff |
+0.06 | −3.01 | −3.01 |
Everything from 50 Hz to 22 kHz now lands within 0.03 dB of the closed form. The old usable floor was about 300 Hz, and 400 Hz for anything under half a decibel of error.
That "before" column is this port's, not upstream's, and the two do not
match: measured on a build of upstream main, a LOW_PASS at 100 Hz reads
-3.94 dB rather than silence and a HIGH_PASS at 30 Hz reads +9.03 rather
than +21.6. Same two causes, same conclusion, different arithmetic on the way
(this port had already moved to per-channel state and computes its
coefficients in double, where mp_float_t on a board is float). The
upstream numbers, and a repro that produces them, are in
docs/upstream-reports/biquad-band-edges.md. Do not quote this table
upstream.
src/shared/audioif_biquad.c, three changes:
- Per-filter coefficient format instead of a fixed Q15.
choose_shift()takes the largest of the five normalised coefficients and gives them all as many fractional bits as that one has room for in anint32_t, capped at 30. A plain low-pass tops out near 2 (that isa1) and gets 29 bits; a 20 dB shelf reaches ~200 and gets 23. Fixing one format for every filter would mean giving them all the shelf's. The chosen shift travels with the coefficients, sosynthio_biquad_tcaches it alongsidea1..b2-- they are meaningless apart. int64_taccumulator, and a feedback state with 12 fractional bits below the sample grid (AUDIOIF_BIQUAD_STATE_SHIFT). The second half matters more than it looks: a biquad low down has both poles close to the unit circle, and1/A(z)-- the gain the loop applies to whatever error is fed back into it -- is 4000 at DC for a 100 Hz low-pass and 43000 for a 30 Hz high-pass. Rounding the feedback to whole samples, as upstream does, hands that gain half an LSB of error to amplify.fast_sincos()replaced bysine_and_cosine(): reflect into[0, pi/2](sin(pi - t) = sin t,cos(pi - t) = -cos t) and evaluate the two Taylor series properly, seven terms each. Worst error across 20 Hz -- 24 kHz falls from5.4e-6to6.3e-9, and it stays accurate past Nyquist, so a frequency asked for above Nyquist is now merely wrong rather than absurd. Deliberately not libm: glibc, newlib and MicroPython's ownsin/cosdiffer in the last place, andverify_dsp's one-hash-covers- every-interpreter rule depends on this being the same function everywhere.
Measured by cross-compiling audioif_biquad.c at -Os and counting
audioif_biquad_process():
| core | before | after | note |
|---|---|---|---|
| Cortex-M4 / M7 | 37 instructions | 76 | no library calls -- SMULL/SMLAL |
| Cortex-M0+ | 55 instructions | 154 | 5 __aeabi_lmul + 1 __aeabi_lasr |
So roughly 2x on anything with a long multiply, and closer to 4x on
Cortex-M0+, which has none -- one stereo biquad at 22050 Hz goes from about
5 percent of a 48 MHz M0+ to about 17. That is the trade upstream made when it
chose Q15, and it is a real one on an RP2040. It is accepted here rather than
made conditional: a filter that sounds different on a Pico than on an ESP32-S3
would be worse than either. If it ever needs clawing back, the products are
32x32 into 64 and could be hand-written for M0 rather than going through
__aeabi_lmul.
GraphicEQ's bottom three ISO bands were wrong. Asked for +6 dB and measured at its own center, the 31.5 Hz band gave +12.14 dB, 63 Hz +6.96, 125 Hz +3.07. All ten bands now read +6.01 dB or better.MultibandCompressor's defaultlow_hz=200.0crossover mis-split, leaving a +5.2 dB bump below 100 Hz. That is gone -- and with the filters working it exposed a separate defect underneath, a 3.4 dB dip at the crossover, because the low band cascaded two Butterworth low-passes against the mid band's single high-pass. Both sides are Linkwitz-Riley pairs now and the three bands recombine flat to 0.23 dB from 30 Hz to 8 kHz.
Worth noting on the timing: before the effects-extension tier,
_core.filter_hz halved every frequency on the way in, so both of those ran
an octave lower still -- GraphicEQ's 63 Hz band was configured at 31.5 Hz,
and the multiband low band was a 100 Hz low-pass, i.e. silent. Removing that
workaround moved them up out of the worst of it by accident.
golden/biquad_component.jsonre-captured. The probe gainedbiquad_edge, seven modes across four centers from 60 Hz to 3600 Hz at an 8 kHz rate, so both ends are pinned by a fixture for the first time. Every one of the 28 mode/center pairs moved.golden/synthtools_acceptance.jsonre-captured, and it is no longer a byte-for-byte CircuitPython match -- the first fixture in the suite to lose that. Three checksums moved: lead +0.020 percent, bend +0.24, bass +1.50. The bass is the tell: it is a Q 6.0 low-pass sweeping downward from 300 Hz at 22050 Hz, which is precisely the case that could not be represented before.verify_acceptance.pynow keeps the oracle's own answer beside the port's undercircuitpython_stdoutrather than discarding it.- All four
golden/instruments_*.jsonre-captured, all 93 instruments. Every one of them builds asynthio.Biquad, so every one moved -- but both sides of that comparison run through the same engine, so the property the fixture exists to prove is untouched: 186 comparisons, 0 failures after re-capture. - Not
verify_dsp,verify_effectsorverify_streaming: no filter in any of their probes. - Stock CircuitPython does not get this.
apply_cp_patches.shonly adds modules to a CP tree;synthioandaudiofiltersthere are upstream's, so a CP board still cannot filter below a few hundred hertz. See the effects README, "A note on filters off a stock CircuitPython board".
shared-module/audiofilters/Distortion.c never reads drive in the
DISTORTION_MODE_OVERDRIVE branch -- the curve is a fixed shape. CLIP and
WAVESHAPE both use it, and the drive docstring says it is "the amount of
distortion" without noting the exception, so the argument looks connected and
is not. Measured: drive 0.0 / 0.2 / 0.5 / 0.9 in OVERDRIVE render byte-
identical output, while the same four in WAVESHAPE differ.
Still present on main 2026-08-27. Drafted for upstream as "wire it up or
document it": docs/upstream-reports/distortion-overdrive-drive.md. It may
well be intentional, which is why the draft asks rather than patches.
Not fixed in the C here either. audioeffects works around it instead --
drive.py's _push() maps a 0..1 drive knob onto pre_gain, which is the
only way into that curve, with the level put back so the historical default
stays bit-identical. See phase 8 in the plan, and the drive.py docstring.
Found while preparing the upstream drafts. synthio_biquad_filter_reset()
does
memset(&st->x, 0, 4 * sizeof(int16_t));and biquad_filter_state is int32_t x[2], y[2] -- sixteen bytes, of which
that clears eight. x[0] and x[1] go; y[0] and y[1] keep the previous
output history, which is the filter's feedback memory. The int16_t looks
like a leftover from a time when the state was 16-bit.
Measured through audiofilters.Filter: fill the filter with a 200 Hz tone at
30000, call reset_buffer, then feed pure silence, and the first block comes
back with a peak of 28072 -- a clean exponential decay of the audio that
was supposed to have been cleared, at -1.3 dBFS. Both callers mean a full
reset (audiofilters_filter_reset_buffer, and synthio.Note when a note's
filter is initialised).
The fix is one line (memset(st, 0, sizeof(*st))), and it was verified here
by applying it to this port's audioif_biquad_reset() and re-running that
measurement: 28072 -> 0.
Fixed here -- the fifth approved deviation, taken 2026-08-27 --
and reported upstream as docs/upstream-reports/biquad-reset.md.
Nothing moved. Every golden held without re-capture: verify_biquad,
verify_effects, verify_streaming, verify_acceptance, verify_dsp on all
three interpreters, and all 93 instrument comparisons. That is worth
recording, because synthio_note_start() resets a note's filter on every
press and the obvious worry was that a re-pressed voice inheriting the
previous note's tail was baked into the fixtures. It was not: nowhere in the
suite does a biquad get reset with a non-zero y. So this deviation changes
what happens in the one case upstream leaves undefined and nothing else.
Stock CircuitPython does not get it, same as the other four:
apply_cp_patches.sh only adds modules, and synthio/audiofilters on a
CP board are upstream's. bin/circuitpython therefore still exhibits the bug,
which is correct -- it is the oracle.
Note that common_hal_audiofilters_filter_play() does not call
audiofilters_filter_reset_buffer(); it resets the source only. So a plain
filter.play(other) carrying filter memory over is by design, and is not this
bug.
Nothing in CircuitPython transforms anything, and neither did
micropython-vst3's engine. audioconvolve.Convolver applies an impulse
response by uniform-partitioned overlap-save FFT convolution:
shared/audioif_convolve.c over shared/audioif_fft.c, float throughout.
Why it is not a preset over the existing reverb. audiofreeverb is a
fixed network of delay lines. It sounds like a room, and with the right
settings it sounds like a plausible room, but it cannot sound like a
particular one. Convolving with a plate's recorded impulse is that plate,
and the same node is then a hall, a guitar cabinet, a spring tank or a
telephone depending only on which impulse it was handed. That is a different
kind of thing from a preset, and it is the last entry on the catalogue that
the rest of the library genuinely could not approximate.
shared/audioif_fft.c is a radix-2 Cooley-Tukey with a bit-reversal pass,
wrapped in the usual real-input packing: an N-point real transform runs on an
N/2-point complex one, so it costs half of what a naive complex transform of
the same block would. There is no split-radix and no hand-unrolled first
stage, because the honest bottleneck in a convolver is the pointwise multiply
across the partitions, not the two transforms either side of it.
Two decisions worth recording:
float, notdouble. A double transform doubles the memory of every stored partition, and memory is what decides whether an impulse fits on a board at all. float32 gives ~7 digits and the transform's error grows as sqrt(log2 N), so a 512-point transform of int16 audio lands ~1e-3 out of a full-scale 32768 -- five orders of magnitude below the samples it is made of. Measured against numpy'srfft: 1.1e-7 relative at N=512.- The twiddles come from a series, not libm. Same rule as the biquad's
(see "The biquads are Q15" above): a golden hash of one probe has to match
on CPython, MicroPython and CircuitPython, and three libms agree to within
an ulp and differ in the last place. That is what
shared/audioif_trig.cis for.
The deterministic sine and cosine used to be static inside
audioif_biquad.c. The FFT needs the same guarantee for the same reason, so
they moved to a file of their own. The biquad's arithmetic is unchanged:
audioif_sincos_reflect() is the old function operation for operation,
reflecting about pi/2 only, and it is deliberately not "fixed" to
full-circle reduction -- a frequency above Nyquist would then get a different
wrong answer, and several goldens are pinned to this one. audioif_sincos()
is the new full-circle entry point, used only by the twiddle tables.
verify_biquad, verify_effects, verify_acceptance and all four
instruments_*.json were unchanged by the extraction, which is the check
that says so.
- One partition of latency, accepted. A block cannot be transformed until it is complete, so the output trails the input by 256 frames (5.3 ms at 48 kHz). Removing that means a non-uniform partitioning scheme -- a few direct taps, then small partitions, then large -- which is roughly triple the code for a saving that matters only when monitoring a live player. A convolver with no impulse loaded is a bypass with no latency at all: an impulse that has not arrived is a missing setting, not a null room, and a chain built before its impulse arrives must not drift against its neighbours.
mixfollowsaudiofreeverb, notaudiodelays. 0..1 with the dry at unity until halfway, rather thanEcho's 0..2. This is a reverb; matching the other reverb matters more than matching the delays.- The synthesized room is normalized to unit energy, in two passes. A tail of unit-amplitude noise convolved with anything is enormous -- 48000 taps near full scale sum to tens of thousands of times the input -- so an unnormalized synthetic room is not a quiet room, it is a clipped one. The partitions are transformed as they are generated and there is nowhere to keep the taps, so the deterministic generator simply runs twice: once to measure the energy, once to write it scaled. The second pass costs only the noise, not the transforms.
- The noise is xorshift32 and the exponentials are a series, for the determinism reason again. A room that is not bit-identical between builds is not a room, it is three rooms.
reset_bufferdrops the history and keeps the impulse. One is audio in flight; the other is a setting, and reloading a room because playback restarted would be both wrong and expensive.
Each partition holds 257 complex floats, about 2 KB, and there is one frequency-delay line per audio channel plus one stored impulse per impulse channel. So:
| impulse | partitions | memory | arithmetic |
|---|---|---|---|
| 1024 taps (21 ms) — a cabinet | 4 | ~25 KB | ~3 MFLOPS |
| 4096 taps (85 ms) | 16 | ~100 KB | ~12 MFLOPS |
| 1 second, stereo | 188 | ~1.5 MB | ~150 MFLOPS |
A cabinet is comfortable on a microcontroller. A second of stereo reverb is a
desktop or a render, or a board with PSRAM and nothing else to do. Both are
in audioeffects: drive.CabinetSim and reverb.ConvolutionReverb, and
each says so in its docstring.
Worth recording because the first cut got it wrong. A cabinet's response was modelled as a sum of damped sinusoids -- box resonance, presence peak, top roll-off -- with decay times chosen by ear from the description. That gives resonances of Q 17 and a peak gain of 818 at the box frequency: a bell, not a box. Rebuilt as the impulse response of the filter cascade the description actually names (a high-pass under the resonance, two peaking bells, and two cascaded low-passes for the 24 dB/octave a cone rolls off at), run over a unit impulse in float.
And the normalizer has to be the response, not the tallest tap: the cascade
sits several dB above unity at its bump, and a cabinet that multiplies
everything by four is a cabinet that clips. _peak_response sweeps the
cascade's magnitude at 48 log-spaced frequencies and normalizes by the
largest. Measured, 4x12 Stack: +0 dB at 100 Hz, -3.9 at 1 kHz, -10.9 at
5 kHz, -27.0 at 8 kHz, -45.7 at 12 kHz.
Verified by tests/parity/convolve_probe.py through verify_dsp.py, with no
oracle -- the golden is captured from the port. It is the most
float-dependent fixture in the suite: every output sample is a sum of
hundreds of float products routed through two transforms. All three
interpreters render it identically, synthesized rooms included.
Issues #8 and #9, found by the accuracy program's fixed-circuit drum
rebuilds. The CPython target's Synthesizer.press deviated from
synthio_span_change_note three ways: it evicted the oldest note when
full (upstream refuses the new press), it evicted a bystander and leaked
a slot when a member was re-pressed at the cap, and it re-initialized
the envelope from zero on re-press where upstream re-enters ATTACK from
the current level with the oscillator phase intact. All three now mirror
the oracle and the re-press render is byte-identical with a built
CircuitPython (tests/test_cpython_press_semantics.py holds the
behaviors). MicroPython was already correct.
Issue #11, a deliberate extension beyond the oracle (like audioecho):
Note.filter also accepts a tuple/list of up to four Biquads applied
in series, each stage with its own state — steeper slopes than one
biquad's 12 dB/oct can give a noise voice. A single filter behaves
exactly as stock CircuitPython, which never sees the extension: code
that must run on stock CP passes one Biquad (the usual portability
posture). MicroPython and CPython render cascades byte-identically;
per-note cost is one biquad pass per stage, and the resident state is
four biquad states per note on MicroPython (SYNTHIO_NOTE_MAX_FILTER_STAGES).
Deviation from the oracle (the sixth), and reported upstream.
Pressing a note that still holds its channel slot takes the fast path in
synthio_span_change_note, which sets ATTACK but deliberately leaves
level alone — correct while the note is still sounding, so it swells
back up from where it is and keeps its oscillator phase. But a note whose
envelope has already run down to 0 is finished, merely not yet
collected, and upstream treats it the same way: ATTACK with the level
left at 0. The reaper at the top of the render then tests level == 0
before anything steps the envelope, frees the slot and continues, and
the envelope-advance loop skips freed slots — so the note is deleted
without ever being stepped. The press renders byte-identical to never
having pressed at all.
It is a one-block race: a block earlier the level is still above 0 and the note re-attacks normally; a block later the slot has already been reclaimed and the press takes the init path and works. Measured window, sweeping the re-press block and reading the level at press time (oracle and, before this fix, our MicroPython port):
| re-press at | level at press | note audible after |
|---|---|---|
| block 2 | 0.289 | yes |
| block 3 | 0.000 | no — silently dropped |
| block 4+ | 0.000 (slot already reclaimed) | yes |
Musically this is a missed drum hit: every fixed-circuit instrument presses several notes per circuit (a bass drum is body + noise click), and a note-off followed by a re-strike onto the same circuit lands in that window often enough to be heard.
The fix, in both targets: a re-press of a note whose level is 0 is a new hit, not a swell — re-initialise its envelope and reset its phase, exactly as a press on an already-reclaimed slot does. Applying the same rule to both targets also makes the timing of slot collection unobservable, which is why it does not matter that the C implementation frees a slot as soon as the level reaches 0 while the CPython reimplementation keeps a note until it is released: a silent note contributes nothing either way, and the only thing that could depend on whether its slot had been reclaimed was which press branch ran later.
level == 0 is being used as a proxy for "finished", and during ATTACK it is
not one — the reaper's own comment says "note is truly finished", and a
note re-pressed one call earlier is not.
Not closed by this: the two targets are still not bit-identical on full instrument sequences. That is a series of smaller lifecycle differences between the C implementation and the Python reimplementation, tracked separately; this entry covers only the dropped-note defect.
Not a deviation — a gap in our port, now closed. Note accepted and
stored ring_frequency, ring_bend, ring_waveform and its loop bounds,
and the render loop never read them: a ringed note rendered byte-identically
to an unringed one, while the MicroPython usermod and the CircuitPython
oracle both applied the ring and agreed with each other exactly.
The stage is now ported from the usermod's own synth_note_into_buffer(),
including the parts that are easy to lose: the accumulator advances before
the sample is read; the product is narrowed to int16 before it returns to
the int32 voice buffer; and there are two separate guards, the first
bounding the rate against the ring table and the second — which reads as a
copy-paste at first glance but is not — bounding it against the main
waveform's limit and skipping the ring entirely rather than clamping. The
per-note ring accumulator is deliberately never reset on a press, matching
the usermod, which resets accum there but not ring_accum.
Verified bit-identical on all three runtimes. Eight instruments use ring
modulation (cs80, cp70, b3, dx7, ms20, odyssey, rhodes,
wurlitzer), so desktop renders of those were previously missing a feature
the boards had.
Not an upstream issue. MicroPython, CircuitPython and the built oracle are all correct; this was a divergence in this repository's CPython reimplementation, now fixed to match them.
The C builds fetch a note's envelope on every render block —
synthio_synth_get_note_envelope(synth, note_obj) is called from the render
loop itself (src/synthio/__init__.c:295), and synthio_envelope_state_t
carries only the running state (level, substep, phase), never the parameters.
So assigning note.envelope is live: the next block steps the new envelope.
The CPython target built the definition once, at press time, and stored it
inside note._envelope_state (_start_note). A re-press took the reattack
path, which sets the phase back to ATTACK but never rebuilt the definition — so
the note kept stepping the envelope it was pressed with, no matter what had
been assigned since.
Invisible to a one-shot render. It only appears when one circuit is shared by
two voices with different decays, which is the idiom several audioinstruments
kits use for closed/open hats and for clap/maracas. Measured on a shared
claves+cowbell circuit:
| alone | overlapped, before the fix | after | |
|---|---|---|---|
| claves | 53 ms | 207 ms — inherited the cowbell's tail | 53 ms |
| cowbell | 203 ms | 55 ms — cut to the claves' tail | 203 ms |
The same code under the parent workspace's built micropython was already correct (57.3 / 204.3),
which is what identified the target rather than the instrument as the fault.
Fix. _audioif.EnvelopeState gains set_definition(...), which replaces the
envelope parameters while leaving level, substep and phase untouched.
Synthesizer._refresh_envelope calls it from the render loop when
note.envelope is no longer the object the definition was built from —
Envelope is an immutable namedtuple, so identity is a sound and cheap guard,
and an unchanged envelope costs one comparison per note per block.
Verified on all three runtimes with the same script: a note pressed SHORT then
re-pressed LONG now decays over 405.3 ms on CPython, MicroPython and the
CircuitPython oracle alike; LONG then SHORT gives 58.6 ms on all three. Before
the fix CPython returned exactly the opposite pair. Regression coverage:
tests/test_cpython_envelope_reassignment.py.
Not an upstream issue. The oracle is correct; this was a divergence in the CPython target, now fixed to match it.
At capacity the oracle does not simply refuse. synthio_span_change_note
calls find_channel_with_note with SYNTHIO_SILENCE
(src/synthio/__init__.c:361), which scans the channels where the note is
not playing — released ones, still sounding out their tails — and takes the
quietest. Only when every channel is genuinely held does it return -1 and
refuse the press.
The CPython target dropped a note from _notes only once its envelope reached
zero (_render), while press refused at len(self._notes) >= max_polyphony. So a released note occupied a slot for its entire release
tail, and a released note was indistinguishable from a held one as far as
admission was concerned.
Measured before the fix:
| native engine | CPython target | |
|---|---|---|
| blocks before a released note's channel returns | 1 | 758 |
| fresh press with all 14 notes released | accepted | refused |
And in use rather than in a probe: driving rhodes through the parity
sequence at capacity, 117 of its 122 refusals happened with no key held at
all. The overwhelming majority of dropped notes were not a polyphony limit
being reached — they were the engine clogged by its own decaying voices. That
sequence never holds more than a four-note chord.
Fix. When no channel is free, press now takes the quietest released
note's slot, exactly as the oracle does, and still refuses when every channel
is held. Verified on all three runtimes with one script: all released → press
taken, all held → press refused and every held note intact, identical on the
CPython target, and the parent workspace's built micropython and circuitpython.
Regression coverage: tests/test_cpython_released_reclaim.py.
This was most of the difficulty behind audiocomponents#18, where the pianos appeared to run out of voices far below the engine's ceiling.
audioif's CI builds the unix port twice: once at the port's default
double mp_float_t, and once with -DMICROPY_FLOAT_IMPL=MICROPY_FLOAT_IMPL_FLOAT
so that a 32-bit mp_float_t — what every ESP32/RP2 board actually runs —
gets compiled at all. The unix port enables -Werror with
-Wdouble-promotion and -Wfloat-conversion, which MCU ports do not, and
under single precision four ported sites tripped them. The float cell used
to downgrade both classes to warnings; those downgrades are now gone and
the cell runs at full -Werror.
The four sites, all inherited from upstream CircuitPython's source text (only the third is audioif's own code):
| site | was | now |
|---|---|---|
src/synthio/__init__.c:65 |
arg / 12. - 3 |
arg / MICROPY_FLOAT_CONST(12.) - 3 |
src/synthio/Synthesizer.c:169 |
level / 32767. |
level / MICROPY_FLOAT_CONST(32767.) |
src/audiodelays/Chorus.c:210 |
MAX(slot_get(...), 1.0) |
MAX(slot_get(...), MICROPY_FLOAT_CONST(1.0)) |
src/audiodelays/MultiTapDelay.c:156,248,251 |
implicit double → mp_float_t |
explicit (mp_float_t) cast |
MICROPY_FLOAT_CONST(x) expands to bare x under
MICROPY_FLOAT_IMPL_DOUBLE (py/mpconfig.h:967) and (mp_float_t) is
(double) there, so on desktop these six edits preprocess to the source
text that was already being compiled. Shown, not assumed: every one of
the 115 objects the usermod builds — the four changed files included — is
byte-identical in disassembly, relocations and section contents before and
after (objdump -w -d -r -s, comparator proved able to fail by planting
12. → 12.5, which reported exactly one moved object). No parity golden
can move, and none did.
On a single-precision board, midi_to_hz does move.
common_hal_synthio_midi_to_hz_float used to divide in double and narrow
the result; it now divides in float. Measured on two unix binaries built
either side of the edit: of 12801 fractional MIDI values, 5082 (39.7%)
differ, by at most 7 ulps of float — 0.0009 cents, about a millionth
of a semitone. 54 of the 128 integer MIDI notes are among them (A440 is
not). Nothing in this workspace has ever captured a single-precision
render as a golden, so nothing goes red; the change is recorded here
because it is a real, if inaudible, difference in board output.
The other three sites are bit-identical in single precision too, checked
the same way: 1.0 is exact, and an explicit cast is only the implicit
conversion made visible. Chorus, MultiTapDelay and
Synthesizer.note_info all render identical bytes across the two float
binaries.
MultiTapDelay's tap_levels[] stays double deliberately
(src/shared/audioif_multitap.h:13, and the rationale at
MultiTapDelay.h:28-33): the levels are converted once at set time so the
audio callback never marshals. Retyping it would change board DSP inside a
render loop, which is a different decision from silencing a warning.
CircuitPython sizes a Synthesizer at CIRCUITPY_SYNTHIO_MAX_CHANNELS,
and the oracle this port is measured against is built at 14 (the
CIRCUITPY_SYNTHIO_MAX_CHANNELS entry above). This port ships 64 at
all five sites that carry the number -- src/synthio/__init__.h,
micropython.mk, micropython.cmake, src/cpython/synthio.py and
src/cpython/_audioif.c -- moved together in 8f8b10d by Brad, on #31's
evidence. 14 was the drum kits' number (cr78 holds exactly 14 permanent
Notes) and never the melodic library's, whose instruments press several
Notes per key; over the parity sequence 4743 of 7335 presses got a channel
only by evicting a still-decaying note, and a seven-note b3 chord measured
4.8 dB quieter at 14 than at 36. 64 covers a ten-finger chord on every
instrument. It is a choice, not a convergence point: steals keep falling
until roughly N=196.
What it changes in the sound. Two things, and the audible one is not
the limiter. First, a Synthesizer at 14 refuses or steals the Notes it
has no channel for. The melodic instruments press several Notes per key, so
a four-key chord on farfisa (5 Notes per key, 20 wanted) admits
[5, 5, 4, 0] channels at 14 -- the fourth key is silent -- and
[5, 5, 5, 5] at 64; vox_continental (4 per key) loses the top key's upper
partials at 14; minimoog (3 per key) fits its chord under 14 and only
differs where a new patch's chord lands on the previous patch's ringing
tail and the 14-channel engine steals decaying voices. That is what
8f8b10d was raised for, and it is what a listener hears: chord tones
present at 64 that are missing at 14. Second, and only for material that
also crosses the mix-down limiter's ±28000 knee, SYNTHIO_MIX_DOWN_SCALE
is a function of the ceiling -- 623 at 14, 129 at 64 -- so peaks above the
knee are squashed harder at 64. Measured by tests/parity/verify_mixdown_knee.py
(de4f9db), full-scale square voices on one Synthesizer, peak sample per
block:
| voices | peak at 14 (oracle) | peak at 64 (this port) |
|---|---|---|
| 1 | 16383 | 16383 |
| 2 | 28046 | 28010 |
| 3 | 28202 | 28042 |
| 6 | 28669 | 28139 |
| 10 | 29292 | 28268 |
A third of a decibel at ten full-scale voices. On real instrument material
the knee alone is inaudible: a minimoog chord held two seconds, both
renders peaking just over 28000, differs in 176 of 290304 samples by at
most 59 counts (measured 2026-09-06, renders under
audiocomponents/.reference-captures/ceiling-ab/). Below 14 requested
Notes and below the knee, every sample is byte-identical to CircuitPython's.
On audiocomponents' instruments gate, three of 53 instruments moved at the
gate's material -- farfisa, minimoog, vox_continental -- and at 64 the
CPython target and cmods/bin/micropython land on the same bytes for all
three (audiocomponents#24, audioif#27). Nine other melodic instruments press
more than four Notes per key and lose chord tones at 14 the same way; nobody
has yet counted how many change on the gate's material.
How it is gated. The four original parity gates are byte-identical at
14, 36, 56 and 64 because none of their material crosses the knee; they
cannot see a ceiling change and are not claimed to. verify_mixdown_knee
enforces this port's above-knee answer against this port and records the
oracle's beside it in the same golden; tests/test_voice_ceiling_consistency.py
guards a half-applied change across the five sites. The oracle stays at 14
and is never rebuilt. Material that crosses the knee is non-parity by
construction; everything under it still byte-matches.
Still open on #31. The prebuilt Windows interpreter, the wasm pair and the micropython-vst3 sidecar carry 14 until rebuilt; the melodic instruments that stack notes are a listen Brad reserved for himself when he made the raise; audiocomponents' CPython leg follows the next audioif release, at which point its three moved digests are re-captured at 64 with this entry as the reason, and not before.
Additive, in a new module of audioif's own, and neither ported kernel is
touched. Both dynamics_probe.py and route_probe.py still hash to
vstaudio_dsp.c compiled unmodified, and the diff of
tests/parity/golden/dsp_nodes.json for this change adds one digest and
leaves the other seven byte-identical, which is the check that says the
addition is additive (audioif commit 48c3575 is the precedent).
What was unreachable. Every EQ, filter and phaser class in audiocomponents is held to one invariant the effects program calls Tier 1 — silence in, silence out; a decaying tail reaches exact zero — and on the ported nodes it is not a matter of tuning:
shared/audioif_biquad.ckeeps its output memory in Q12 sample units (AUDIOIF_BIQUAD_STATE_SHIFT,audioif_biquad.h:14) and rounds to nearest at:176-178with no dither and no leak term, so the recursion has fixed points: states that reproduce themselves exactly. Driven with a 256-frame DC burst and then 3000 blocks of silence at 48 kHz, aLOW_PASSat 100 Hz settles on ±1 LSB and one at 40 Hz on ±4 LSB, which are the numbers audioif#23 reports. The reproduction is intests/test_cpython_audiobiquad.pyrather than cited, and it is careful about what it claims: not every trajectory lands on a fixed point. The same settings driven through theaudiofilters.Filterwrapper with a different stimulus settled cleanly, which is why the defect went unnoticed for as long as it did.shared/audioif_phaser.chas its own version of the same defect in its own arithmetic. Its all-pass memory isint16_tin plain sample units (:28-38), so it settles on a non-zero word and holds it; fixing the biquad would not move it. Filed separately as audioif#36, and it is why this is one module carrying two kernels rather than a patch to one.audiofilters/Phaser.c:211clampsfeedbackto0.1..0.9before the value reaches the kernel, so a phaser built on the ported node can never be asked for the feedback-free topology every script phaser uses, and 0.0 and 0.1 render byte-identically.
What the module does instead. audiobiquad.Biquad and
audiobiquad.AllPass over shared/audioif_filter_f32.c, float state and
coefficients throughout — the working precision audioif_dynamics.c and
audioif_feedback_delay.c already use. A float recursion decays
geometrically and so never actually arrives, so any state word below
AUDIOIF_FILTER_F32_FLUSH (1e-20) is written as exact zero. That threshold
sits far above the float32 denormal floor and far below one LSB of a 16-bit
sample, so it can only catch a tail already past −400 dB.
A new module rather than arguments on audiofilters, for exactly the
reason audioecho is a new module rather than arguments on
audiodelays.Echo: an argument added to this port's copy of a CircuitPython
module would not exist on a stock board, so a class written against it would
silently be a different effect there. This installs whole or is absent and
says so on import.
At 8 kHz, a 500 Hz tone (an integer-exact 64-entry sine table, stepped by
four) through a four-stage cascade at mix=0.5, dry peak 28000:
| best null | re dry | |
|---|---|---|
audiobiquad.AllPass, feedback=0.0 |
18 | −63.8 dB |
audiofilters.Phaser, feedback=0.1 (its floor), frequency swept over 100 settings and its own best taken |
2341 | −21.5 dB |
42 dB, and the ported node was given every chance: its frequency is not
the notch frequency, so it was swept rather than handed one number. On this
node feedback=0.0 and feedback=0.1 are different settings (peaks 18 and
1274); on the ported one they are the same bytes.
Tail to exact zero, burst then silence, first all-zero block: LOW_PASS
100 Hz at block 1, 40 Hz q=8 at block 16, 31.25 Hz at 1, a +12 dB peaking
section at 62.5 Hz at 9; the four-stage cascade at feedback 0.95 at block 6
and at −0.95 at block 7; a twelve-stage cascade at 31.25 Hz at block 30.
Every biquad mode and every feedback value tested reaches zero and stays
there.
- The all-pass coefficient is the true bilinear, `c = (tan(pi f / fs) −
- / (tan(pi f / fs) + 1)
, sofrequencyreally is where one stage's phase passes −90° — verified within 0.2° at 500, 1000 and 2000 Hz.audioif_phaser.c:14uses(1 − f/nyquist)/(1 + f/nyquist)and multiplies by the negative of it (:32), which is the small-angle form of the same expression; that lands the break at(fs/pi)·atan(2f/fs), and it is why the ported node's class has to pre-warp in Python (audiofilters/Phaser.c:210`).
- / (tan(pi f / fs) + 1)
- The all-pass coefficient walks to each block's value across the block rather than stepping to it at the boundary. A block-rate step is 187 Hz at 48 kHz with 256-frame blocks, in the middle of the range a phaser sweeps through. The biquad's coefficients are not interpolated: interpolating between two high-Q sections can pass through an unstable pair, and the biquad's case for existing is the tail, not the sweep.
- Two bounds are stability, not taste, and both are there to protect the
tail.
Qis clamped to 0.05..60, because the pole radius of an RBJ section issqrt((1 − alpha)/(1 + alpha))withalpha = sin(W0)/(2Q), so an unbounded Q is a filter that rings for ever and the invariant this module exists for would be vacuous. The all-pass coefficient is clamped to ±0.9999 for the same reason one order down.feedbackis bounded to ±0.99 — the point of the ask is that zero is reachable and negative is allowed, not that the loop may be unstable.
sqrt() rather than audioif_biquad.c:26's Newton-step reciprocal
approximation for the shelf root: that trick buys speed on a part with no
divider and costs about 0.2% of A, and it is there because the result is
about to be quantized into 23 bits. Nothing here is quantized, and IEEE
sqrt is correctly rounded, so this is both more accurate and identical on
every target. The trig is shared — audioif_sincos_reflect()
(audioif_trig.c:72) — so at one W0 both kernels start from the same sine
and cosine and a comparison between them measures the arithmetic rather than
two libm builds.
Verified by tests/parity/filter_f32_probe.py through verify_dsp.py, with
no oracle: the golden is captured from the port, the same weaker claim
audiomath, audioecho and audioconvolve make. That probe prints two
invariants as integers beside its PCM — the block at which a tail reaches
zero, and the null depth at each feedback value — because a hash over PCM
alone would not say whether either still held. Its planted fault is the
saturating int16 write-back the ported kernels use: restore it and ten of
the eleven tail cases go to "never reached zero", the null collapses from 18
to 1140, and the gate fails.
Additive, and the default is the identity, so an audioroute built the way it
was is what it was -- tests/parity/route_probe.py's and
route_dry_probe.py's hashes are unchanged across this addition, which is the
check that says so. Splitter still hashes to vstaudio_dsp.c compiled
unmodified. The new class gets its own fixture (midside_probe.py) rather
than joining either of those, because both are held against that oracle and
may only use forms the original accepts, and the original has no MidSide
at all: audioroute came from micropython-vst3's engine, this class did not.
It is audioif's own, like audiomath, audioecho and audioconvolve, so
there is nothing older to diff against and this section records a new class
rather than a deviation.
What it is. A stereo pair taken apart into its mono sum and the difference
between its channels, the difference scaled by width, and the pair put back
together. width=0 collapses to mono, 1 passes through, 2 doubles the
sides; set(width=) moves it mid-stream and out-of-range values clamp to
those rails rather than being honoured, the same treatment
audioif_multiply_set_mix gives a mix outside 0..1.
Why the palette could not already do it. audiomixer.Mixer has a
per-voice pan, which places a source between the speakers. It cannot reach
what is already between them: no combination of pans collapses a pair to mono
or pushes its sides out, and Splitter's branches all carry the same stereo
pair, never a mid on one and a side on another.
Why a drive node lives in a routing module. The traits that asked for this
are drive traits, not stereo-width ones. A nonlinearity applied to a stereo
pair intermodulates the channels -- what comes back on the sides is
sum-and-difference products of both -- so a stereo overdrive, fuzz or
saturator that wants to keep its image drives the mid and leaves the side
alone. That is a routing decision (which signal reaches which channel), which
is the job Splitter already does one level up (which branch reaches which
chain), so it goes here rather than into a module of its own. A new module
would have been a third import for every class that already needs Splitter,
and one more thing apply_cp_patches.sh has to install whole.
The width is Q14, not Q15. Every other integer coefficient in this tree is
Q15, because every other one runs 0..1. This one runs 0..2. Q15 would put
width = 2 at 65536, and 65536 * 65535 -- 65535 being the widest difference
two int16 samples can have -- is 4294901760, past INT32_MAX, on the hot
path, on parts where a 64-bit multiply is not free. Q14 caps the same product
at 32768 * 65535 = 2147450880, which fits int32 with 32767 to spare. That
bound is checked rather than argued: a standalone driver over
src/shared/audioif_midside.c forms the widest product the kernel can reach
and prints it beside INT32_MAX.
The halving comes last, and that is what makes the identity exact. Per
frame the kernel computes sum = L+R, diff = L-R, side = (w*diff) >> 14,
then outL = clamp16((sum+side+1) >> 1) and outR = clamp16((sum-side+1) >> 1).
At width = 1 (w = 16384) a Q14 multiply is a shift left by 14 and the
shift right by 14 returns diff bit for bit, negatives included -- so
sum + side is 2L and (2L+1) >> 1 is L for every int16 there is. The
output bytes are the input bytes, with no special case in the code and none in
the API. Halving the sum first would lose a bit before the side was ever
added, and a MidSide left at its default would quietly cost the chain an LSB
wherever a class leaves one in a signal path it is not currently using.
The +1 rounds half up rather than toward minus infinity. Without it every
odd result loses half an LSB downward, which is a small DC offset on anything
asymmetric -- exactly what a drive downstream turns into an audible bias. It
goes on both halves, so the mono sum lands on L+R or one more, never split
between the channels; midside_probe.py holds that to 1 LSB per frame at
every width, below the clamp.
Latency and state: none of either. No line, no filter, no accumulator, so
a block boundary is not observable, reset_buffer has only the source cursor
to drop, and no option can add latency later without changing what the class
is. That is the trait a FeedbackDelay composition cannot hold: its delay
clamps at one frame (audioif_feedback_delay.c:81-83).
What the probe asserts that a checksum cannot. Two things, both printed so
the golden covers them: at width=1 the output bytes are compared against the
source bytes rather than against a hash of themselves -- a checksum would go on
matching a golden captured from a node that had started costing an LSB -- and
the mono sum is held to 1 LSB per frame at every width. Three planted faults
were run, each caught by a different mechanism: halving before adding breaks
the identity comparison; scaling the mid instead of the side leaves the
identity intact and moves the mono sum by 30000; dropping the +1 leaves both
assertions passing and moves the checksum.
Additive: a new class in an audioif-own module, nothing existing touched.
tests/parity/multiply_probe.py's hash is unchanged across it, and so are
dynamics_probe.py's, route_probe.py's and feedback_delay_probe.py's --
the diff of tests/parity/golden/dsp_nodes.json in the commit that landed it
adds one digest and changes none, which is the check that says so.
Why the palette could not do it. Nothing here divides a frequency:
audiodelays.PitchShiftis granular. It resamples grains and crossfades them, so an octave down arrives with the smear and the comb that windowing costs, and it costs a grain buffer per instance. It is a pitch shifter, which is a different device from a divider and does not sound like one.audiospeed.SpeedChangermoves the whole stream in time. An octave down there is the take played at half speed, which is not something a player can perform through.audiomath.Multiplyrings the signal against a free-running table. Multiplying by a square that is not locked to the input is a ring modulator, and its sum and difference tones are exactly what a divider must not produce.
What the circuit does, and what this does. A Boss OC-2 and every pedal
like it squares the input up with a comparator, halves that square with a
flip-flop, and multiplies the original signal by the halved square. The
square is +/-1, so that multiply is a negate: the output is the input with
every other cycle inverted, which has twice the period, the input's own
timbre, and no latency at all. A second flip-flop in series gives order=2,
two octaves down. shared/audioif_suboctave.c, int16 in and out with Q15
coefficients and int32 intermediates -- audioif_multiply.c's convention, no
float on the pull path. Two multiplies and a clamp per sample, one fewer than
Multiply's three, plus three compares and a counter.
SubOctave(source, order=1, mix=1.0, threshold=0.01, hold_ms=1.0, sample_rate=48000, channel_count=2), with set() taking the same four
options mid-stream and clear() restarting the count. mix follows
Multiply's 0..1 convention, not Echo's 0..2: at 0.5 a signal and its own
inverse cancel exactly, which the parity fixture renders as a block of
digital silence.
Four things worth recording:
-
The defaults live in the C, once.
audioif_suboctave_config_init()is the only placeorder1,mix1.0,threshold0.01 andhold_ms1.0 are written down; every binding passes "not asked for" rather than a copy of the number, so the four targets cannot drift apart. Written the obvious way first -- with the values repeated in each binding's signature -- and caught by a planted fault: moving the C default did not move the golden, because no target ever reached it. -
One divider drives every channel, clocked by the mean of the frame. Two dividers, one per channel, would count differently the moment the channels differed, and the pair would then be an octave down in opposite polarities -- which cancels in mono.
-
thresholdandhold_msare the two ways of not counting a harmonic, and both are the class's business, not the node's. A waveform with a strong second harmonic crosses zero more than twice per period, and a divider that counts the extra crossings drops an octave too far. Measured on a 110 Hz triangle carrying a second harmonic at 83% of its amplitude, rendered throughorder=1, mix=1.0and read as the magnitude at 55 Hz (right) and 110 Hz (wrong):hold_msthreshold55 Hz 110 Hz 0.0 0.01 112 3816 1.0 (default) 0.01 112 3816 6.0 0.01 6111 65 0.0 0.25 5719 57 A lockout longer than the harmonic's period fixes it; so does a threshold above the harmonic's own excursion. The default of 1.0 ms does not, and is not meant to: 1 ms is a period of 1 kHz, so the default tracks every fundamental a guitar or bass can produce and rejects only re-triggers closer together than that. A class built for one note range sets
hold_msfrom the lowest note it expects, or filters the input the way the pedal does. The Phase 0 sketch proposed 8.0 ms, which rejects the harmonic on a low note but silently divides by four above 125 Hz -- a wrong octave with no error, which is worse than a divider that counts what it was given. -
reset_bufferrestarts the count. The divider holds no audio, so unlike a delay's reset this drops nothing anybody can hear; what it stops is a restarted chain beginning on the inverted half of the count.
Verified by tests/parity/suboctave_probe.py through verify_dsp.py, with
no oracle -- the golden is captured from the port under CPython and what it
proves is cross-interpreter agreement, not fidelity to something older. The
fixtures are integer-only for a reason a triangle does not have elsewhere in
the suite: a divider is decided by where the signal crosses a threshold, so
a fixture that moved by one LSB because two interpreters rounded sin()
differently would move an edge and change every sample after it.
The probe was shown to fail before it was believed. Twelve faults planted
one at a time in audioif_suboctave.c, each rebuilt and run: the second
flip-flop clocked on the falling edge; the lockout ignored; each of the three
defaults moved by one step (threshold 328 -> 329, hold 1.0 -> 0.9 ms and
1.0 -> 1.1 ms); the hysteresis made one-sided; the divider clocked by the left
channel alone; order ignored; the positive clamp rail moved; the Q15 rounding
term dropped; reset starting inverted; and the square never inverting. All
twelve moved the digest. Two fixtures exist only because a fault got through
the first version of the probe and did not: the two pulse trains at 45 and 50
frames, which are the only cases that pin the default lockout from both
sides, and the rails square, which was written with the channels in
opposition so that the frame mean was -1 and the comparator never fired at
all. The one thing the probe does not pin is the negative clamp rail,
which is unreachable: with q = +/-s the blend cannot fall below -32768, so
that branch is defensive symmetry with audioif_multiply.c and nothing
exercises it.
A new module, not a port and not from vstaudio either. It exists because
the palette's only steerable nonlinearity is audiofilters.Distortion, and
two facts about that node put every drive circuit out of reach.
The curve is not yours. Its whole argument list is drive, pre_gain,
post_gain, mode, soft_clip and mix (audioif_distortion.c:7-9), and
mode selects one of four fixed shapes written for a game engine
(:15-34). A diode pair in an op-amp's feedback loop, diodes to ground, a
biased germanium transistor: each is a specific curve, and none of the four
is any of them. Worse, two of the four are odd-symmetric by construction --
CLIP is pow(fabs(value), drive) with the sign restored (:16-19), and
WAVESHAPE is (1+d)x / (1+d|x|) (:30-32) -- so neither can produce an even
harmonic at all, and CLIP is homogeneous, so its harmonic profile is
identical at -20 dBFS and at -6 dBFS where a circuit's is not.
It runs at the base rate. A nonlinearity makes harmonics above Nyquist
and they fold straight back onto the signal. Nothing else in audioif
resamples either: audiospeed.SpeedChanger steps phase >> SPEED_SHIFT with
no interpolation and no anti-alias filter, and is itself a CircuitPython
port.
audioshaper.Waveshaper takes the curve as int16 Q15 data -- computed
once on CPython, where a dossier can show the circuit maths that produced it,
and never rebuilt on the target, whose float is single-precision where the
desktop's is double, so a table built on a board would be a different table
-- and applies it at 2x, 4x or 8x the sample rate between a matched pair
of two-path polyphase all-pass half-bands. pre_gain is the drive knob (gain
into one normalised curve, never a curve rebuilt per knob move), bias moves
the operating point, post_gain and a 0..1 mix follow, the last being
audiofilters.Distortion's convention (audioif_distortion.c:47) rather
than audiodelays.Echo's 0..2, because this is the node the drive family is
leaving.
A new module rather than arguments on Distortion, for the reason
audioecho is not audiodelays: an argument added to audioif's copy of a
CircuitPython module would not exist on a stock board, so an Overdrive
written against it would silently be a different effect there. This installs
whole, through apply_cp_patches.sh, or is absent and says so on import.
tools/design_halfband.py is where they come from: a minimax search over
four coefficients for the stopband peak of
H(w) = (A0(e^{j2w}) + e^{-jw} A1(e^{j2w})) / 2 from 0.5833*pi. That
stopband edge is what the last decimation stage of a 48 kHz chain needs --
it runs at 96 kHz, folds everything above 24 kHz down, and has to stay clear
of 20 kHz -- so the transition band is 20..28 kHz. --verify is the check
that the numbers in the C are the numbers the search produced:
passband ripple +0.00000 / -0.00000 dB (to 0.4167 pi)
stopband peak -64.57 dB (from 0.5833 pi)
gain at pi/2 0.7071 (the half-band condition wants 0.7071)
all poles at |z| = sqrt(a) < 1
Four coefficients rather than six, and that is a measurement rather than a preference: with an identity curve loaded, the whole up/down chain's non-harmonic energy sits 88-89 dB below the fundamental at every factor, which is the int16 output's own quantisation floor. A six-coefficient design (-68.8 dB in the same search) moves nothing this node can measure.
Measured on the built CPython extension with an exact-bin DFT -- 65536
points, 1379 and 5051 cycles in the window, so every harmonic and every alias
lands on its own bin and a rectangular window leaks nothing -- through a soft
+-0.6 knee at post_gain 0.5, non-harmonic energy against the fundamental:
| 1010 Hz, pre_gain 8 | 3700 Hz, pre_gain 8 | 3700 Hz, pre_gain 2 | |
|---|---|---|---|
| x1 | -36.2 dBc | -19.3 dBc | -31.9 dBc |
| x2 | -47.6 | -33.2 | -45.1 |
| x4 | -55.8 | -41.6 | -56.1 |
| x8 | -57.0 | -43.4 | -63.9 |
Two things that table says and a bare "it oversamples" would not. The probe frequency is part of the measurement: at 1000 Hz exactly, every alias folds back onto the harmonic grid and the same node reads a floor that is not there. And the -60 dB bar the drive dossiers ask for is a function of curve, drive and factor together, never of the factor alone -- at heavy drive a near-square curve's own in-band aliasing sets the floor and a further doubling does not move it. The alias floor at each factor on the P4 and the S3 is Phase 1's, measured on flashed firmware; this is the desktop figure the board table is measured against.
Measured by output phase against input phase over 200 Hz..5 kHz at 48 kHz:
group delay 0 samples at x1, 2.2 (46 us) at x2, 3.3 (69 us) at x4, 3.9 (80
us) at x8, with the magnitude response flat to within 0.001 dB from 200 Hz to
18 kHz. It is small -- a linear-phase FIR half-band steep enough for this
transition would be several times that -- but it is not zero and it is not an
integer, so audioshaper.GROUP_DELAY_SAMPLES carries the table and a
component reporting latency_samples reports the entry for the factor it
built with.
Additive and off by default, so a Waveshaper built without it is a static
table sample for sample -- waveshaper_probe.py prints
shp hysteresis-off identical and shp hysteresis-on changed, so the golden
pins both halves of that claim and neither can be met by the option doing
nothing.
Above zero it puts a play (backlash) operator in front of the table: one position per channel that follows the signal at a lag of one half-width, so the rising and falling branches separate. This is the one thing a table cannot do. A static curve's output depends only on its present input, so a slow triangle in and out retraces its own path and encloses exactly zero area, which is the disconfirmation condition of the Saturation dossier's TP3 met a priori, without a measurement.
hysteresis_width is a fraction of full scale at the node's input, and
config_finish multiplies by |pre_gain| once, at configure time. That
factor is the whole entry. Written the other way -- a half-width in the
operator's own units, the operator sitting after the drive gain -- twice the
drive covers half as much of the input's swing and the enclosed loop
shrinks: normalised area 0.163, 0.084, 0.045 over pre_gain 1, 2, 4,
measured before the fix. That is precisely the direction the two memory
elements the palette already has fail TP3 in, and the Phase 0 seed drove both
rather than arguing them: audioecho.FeedbackDelay's normalised area is
largest with its nonlinearity switched off and falls when loop_drive is
engaged, and audiodynamics.Dynamics' shrinks with drive and swings two
orders of magnitude with probe frequency, because its loop is an envelope
artefact against the period rather than a quasi-static magnetisation loop.
Shipping a third node that fails the same way would have been no use to
anybody.
hysteresis_bias splits the half-width between the rising and falling
branches, so the loop may be asymmetric. There is no direction flag: the
comparison that fires already says which branch is which, so the flag the
Phase 0 sketch carried would have been a byte of state nothing reads.
pre_gainandbiasare plain floats, notBlockInputs. The sketch allowed them as streams becauseTremoloB4 and the Fuzz Face's gating move the operating point per sample. B4 is struck: both of its refuters showed, and Arthur's ruling of 2026-09-07 accepted, that a per-sample bias is a second stream summed in front of a static shaper, whichaudiomixer.Mixerdoes sample by sample with a saturating signed add (src/audiomixer/Mixer.c:216-230). No surviving claimant needs a stream here, and keeping the kernel free ofmp_objis what lets one golden hash cover every interpreter -- the arithmetic all lives insrc/shared/, so two interpreters disagreeing would itself be the finding.- There is no
pre_filter_hz. Vision section 6 says the input filter is the class's to compose, andaudiofilters.Filterwith asynthio.Biquadcomposes a better one than a one-pole inside this node would be. A node option that duplicates a palette node is what the "compose the existing palette first" rule refuses.
tests/parity/waveshaper_probe.py, oracle None -- there is no ancestor to
hold it to, so what the golden pins is that every interpreter renders the
same bytes. Its curves are built out of integer arithmetic on purpose: a
table computed with math.exp would be a different table under
CircuitPython, whose floats are single-precision, and the probe would then be
measuring three libms rather than this node.
tests/test_cpython_waveshaper.py carries what the golden cannot: that
oversampling actually lowers the alias floor, that the hysteresis knob is
monotone and clears its control by 6 dB, and that a static table and a
zero-width operator both enclose exactly 0.0. Each check was shown to fail
before it was believed -- discarding the play operator's result reddens both
hysteresis tests, and replacing the half-bands with a zero-order hold and a
decimating drop reddens the alias-floor test.
audiofilters.Filter cascades RBJ biquads and does it very well: measured
against the analytic four-pole ladder response it tracks the resonant peak to
-0.05 dB at every feedback through 3.95. What it cannot do is not a matter of
degree. It is linear, and three of the ladder's fixed traits are things a
linear filter does not have at any setting:
- It sustains. At a feedback of 4 a ladder's loop gain reaches 1 at its own cutoff and a tone appears out of silence. A biquad cascade at any Q decays; fed silence it stays silent. The trait fails at its own definition.
- What bounds that tone is a nonlinearity inside the loop. Not a limiter after the filter, which would bound the level and leave the tone a sine: the saturator is in the feedback path, so it is what sets the amplitude, and its odd curve is where the growl comes from. A linear filter has no harmonic ladder to measure at all.
- Its character follows the input level, for the same reason. A linear filter's is identically level-independent, which is that trait's own stated disconfirmation.
Nothing else in the palette closes a loop of the right shape. An audioif
graph is a pull DAG in which no node accepts its own output, so a loop exists
only inside one C kernel, and no kernel held four one-poles round a feedback
path with a saturator in it. audiodelays.Echo's loop is
echo * decay + sample into a delay line -- one tap, no filter, no saturator
(audioif_echo.c:24, :31). audioecho.FeedbackDelay's loop has a low-pass,
a high-pass and a cubic clip in it, but behind a delay line clamped to at
least one frame: that is a comb, whose resonances are harmonics of 1/delay,
not one movable cutoff. And a loop closed in Python runs at block rate, about
187 Hz at 48 kHz, which is not a filter.
audioladder.Ladder is four topology-preserving one-pole stages round a
global feedback loop with the odd cubic saturator inside it -- the same curve
audioif_feedback_delay.c puts in its own loop, in the +-1 domain rather than
the int16 one. shared/audioif_ladder.c, float working precision to match
audioif_dynamics.c and audioif_feedback_delay.c. The options are
cutoff_hz, resonance (0..4.2), drive (a linear gain into the loop),
poles (1..4, which stage the output is tapped from), passband_comp,
oversample (1 or 2) and mix (a plain crossfade).
A new module rather than arguments on audiofilters.Filter,
deliberately -- the same reason audioecho is not arguments on
audiodelays.Echo. An argument added to audioif's copy of a CircuitPython
module would not exist on a stock board, so a LadderFilter written against
it would silently be a different effect there. A new module either installs
whole or is absent and says so on import.
Three details worth recording.
The obvious way to write this is to feed back the previous sample's fourth stage. It does not work, and it fails at exactly the trait the node exists for. Four bilinear one-poles reach -180 degrees at the cutoff with a gain of 1/4, so the loop sustains at a feedback of exactly 4, at exactly the cutoff, and not before. One sample of delay adds its own phase, which moves the 180-degree point below the cutoff, where each stage is louder -- so the loop sustains early, and at the wrong frequency. Measured on the first draft of this file: 3.52 rather than 4 at a 1 kHz cutoff and 48 kHz, oscillating six percent flat, and worse the higher the cutoff goes, since the delay's phase is a fixed fraction of the sample rate while the filter's is not.
So the loop is solved instead of delayed, and the solve costs no division, no
library call and no branch on the signal. The loop equation is
y + a*sat(y) = c, with a = resonance * g^4 and c what the chain would
put out with the feedback disconnected. Where |y| >= 1 the saturator is
constant at 2/3 and the equation is linear, so that branch is exact and
closed form -- and the test for it is exact too. Inside, sat(y) = y - y^3/3
exactly, so it rearranges to y = (c + a*y^3/3)/(1 + a), a map whose
derivative is a*y^2/(1+a) -- below 1 for every a and every |y| <= 1, so
it contracts from any seed. Four passes, seeded by extrapolating the last two
solved samples, with two constants computed in set() and three multiplies a
pass. The count is fixed rather than tested for convergence on purpose: a
convergence test branches on the signal, and two interpreters that took
different branches would render different bytes.
g = tan(pi*fc/fs')/(1 + tan(pi*fc/fs')) and everything derived from it are
recomputed whenever any option changes, which for a class with a macro on it
is once a block -- about 187 Hz at 48 kHz. The integrator state is kept
unscaled for that reason: the per-sample loop recomputes (1-g)*z rather
than storing it, which costs one multiply a stage and is what lets
cutoff_hz move mid-stream without the stored state silently meaning
something else afterwards.
fs' is the sample rate times oversample, so oversample moves the rate
the filter is measured against; cutoff_hz is therefore stored as asked for
and clamped where it is used, not on the way in.
Deliberately different, and this is the place it is written down.
audiodelays.Echo's convention -- dry at unity until 1, wet alone at 2 --
exists because a delay's wet signal is something added to a signal that is
still there, and six delay classes sit on Echo and FeedbackDelay where
mix has to mean one thing. A filter's wet signal is that same signal,
changed. "The dry plus all of the filtered" is not a setting any filter has.
So mix is 0..1 and crossfades, defaulting to 1, and mix=0 is a wire
sample for sample -- the dry path takes the raw input, never the driven one.
Through the Python surface, RawSample -> Ladder -> get_buffer at 48 kHz, an
impulse then three seconds of silence:
| cutoff | k=3.0 | k=3.5 | k=3.9 | k=3.95 | k=4.0 | k=4.05 | k=4.2 |
|---|---|---|---|---|---|---|---|
| 200 Hz | silent | silent | decays | decays | sustains | sustains | sustains |
| 800 Hz | silent | silent | silent | silent | sustains | sustains | sustains |
| 3 kHz | silent | silent | silent | silent | sustains | sustains | sustains |
and the sustained tone measures 199.5, 800.1 and 3000.0 Hz against cutoffs of
200, 800 and 3000. At k=4.2 and an 800 Hz cutoff the peak moves 0.01 dB
across the last two seconds of the three and the tone is 0.07 % THD; its
second harmonic is 132 dB below the first and 69 dB below the third, which is
what an odd nonlinearity looks like. Driving a 200 Hz tone into the filter at
k=3.9, h3/h1 rises monotonically 45 dB across drive 0/+8/+16/+24 dB. THD
rises monotonically with input level, 0.010 % at -40 dBFS in to 0.302 % at 0.
Feeding -x returns exactly -y wherever the signal is off the int16 rails,
where the asymmetry is two's complement's (+32767 against -32768) and is
the same in every other node here.
Verified by tests/parity/ladder_probe.py through verify_dsp.py, with no
oracle -- the golden is captured from the port, as audiomath, audioecho
and audioconvolve are. It is the most sensitive fixture in that file: the
loop is recursive, runs in float, and is solved, so a one-ulp
disagreement between two builds has the solver's seed to grow through as well
as the feedback path, and several of its cases sit where the loop sustains a
tone of its own and nothing damps a difference at all.
audiofreeverb.Freeverb exists upstream, and it is the only reverberator the
palette had. It is a fixed Schroeder/Moorer bank: eight combs and four
all-passes, with roomsize, damp and mix on top. Three things follow that
no argument on it could reach:
- The network cannot be re-cut. Its line lengths are
static consttables in a CircuitPython-ported kernel --default_comb_sizes(audioif_freeverb.c:6) anddefault_allpass_sizes(:8) -- and the exported entry point hands those two to the loop unconditionally (:81-:82), so nothing a binding can pass reaches them. A plate, a hall, a room and a chamber are not one topology at four settings; they are different line-length sets and different tap positions, and Freeverb's are compiled in. - There are no modulated taps. Each comb and each all-pass walks its index
by exactly one per sample and wraps (
:39,:49); there is no oscillator in the file at all. A static plate rings with a picket fence of fixed modes, and breaking it means wobbling a line inside the loop by a fraction of a millisecond, which nothing outside the loop can do. - Nothing diffuses the input. Freeverb's four all-passes sit on the sum
of the comb bank (
:43-:51), after the recirculation rather than before it, so what enters the combs is the raw signal and the early density is whatever the comb spacing happens to give.
And the palette cannot compose one, which was measured rather than assumed.
A tank's defining element is recirculation, and the pull graph has no cycles:
d1.play(s); d2.play(d1); d1.play(d2) constructs without complaint and the
first audioecho.get_buffer(d1) raises RecursionError: maximum recursion depth exceeded. The only Python-side loop that exists at all quantises every
line to one 256-frame block -- 5.33 ms at 48 kHz, longer than most of a
plate's lines.
audioverb.Tank is Dattorro's plate network in shared/audioif_tank.c,
float working precision over int16 lines to match
audioif_feedback_delay.c: predelay, a one-pole bandwidth_hz, an optional
low_cut_hz and cubic drive, four Schroeder all-passes, then two tank
halves that feed each other -- each a modulated all-pass, a delay, an in-loop
damping_hz one-pole, the decay multiply, a second all-pass and another
delay. The stereo comes back out of the tap table, then width, a tilt at
tone_db and mix. delays is twelve line lengths and taps is four values
per tap (channel, line, offset, gain); both default to Dattorro's published
table scaled from 29761 Hz, so a bare Tank(sample_rate=...) is his network
and not a transposed one.
A new module rather than arguments on Freeverb, deliberately -- the same
reason audioecho is not audiodelays. An argument added to audioif's copy of
a CircuitPython module would not exist on a stock board, so a Plate written
against it would silently be a different effect there. audiofreeverb is not
touched by any of this.
Four details worth recording:
- Every line write is a magnitude truncation, not a rounding, and that is
what makes the tail reach exact zero. A recirculating
int16network that rounds to nearest has limit cycles by construction: a line holding 1 withdecay0.5 computes 0.5, which rounds back to 1, and the tank hums at one LSB for as long as anything pulls it. Truncating toward zero makes|Q(v)| <= |v|unconditionally, so a loop under unity gain strictly loses magnitude every pass and lands on exact zero. It is the standard cure for fixed-point limit cycles and it costs nothing. Proved by planting the fault: withtank_quantizerounding instead, the probe's tail fixture sits at a block peak of 4 for 1500 blocks (48 s at 8 kHz) and never reaches zero; with the truncation restored it is exactly zero at block 81 (2.59 s,decay0.5) and at block 348 (11.14 s,decay0.9, against an RT60 of about 8.5 s). The output quantiser still rounds -- it is in no loop. - The cross between the halves reads a delay line, not the other half's output. Each half's contribution is taken from its last delay line before anything in that frame writes to it, so it is the value that entered a whole line-length ago and neither half waits on the other. There is no algebraic loop and no one-sample fudge.
- One
diffusionknob drives four all-pass coefficients, as ratios of Dattorro's own: input diffusion 1 at the knob, input diffusion 2 at 0.8333 of it, decay diffusion 1 at 0.9333 and decay diffusion 2 at 0.6667.diffusion=0.75therefore reproduces his 0.75 / 0.625 / 0.70 / 0.50 exactly, and the knob moves all four together the way a diffusion control is expected to. reset_bufferreally does drop everything, unlikeaudiodynamics. A reverberation tail is entirely state: a chain restarted with the old tail still in the lines plays the previous take underneath the new one. And, likeaudioecho.FeedbackDelay, the lines only advance for frames that arrive -- a starved chain gets silence and the tail stops with the source rather than ringing on. A class that wants the tail rung out feeds the tank silence for as long as itstail_samplessays.
What it costs. Counted from the source, with everything switched on and
the default 14-tap table: about 62 multiplies per stereo frame -- input
fold-down 1, bandwidth 1, low cut 1, drive 6, four input diffusers 8, the two
tank halves 16, the modulation oscillator 2, the taps 14, width 3, the tilt 6,
the output mix 4. audioif_freeverb.c spends 30 int16 multiplies per sample
per channel (:28 the input scale, then :36 twice and :38 once in each
of eight combs, :42 the sum scale, :52 the output scale, :53-:54 dry
and wet, plus the mix-down's pair_scale at :56), which is 60 per stereo
frame: the same order, in float rather than Q15. Its four all-passes cost no
multiply at all -- they are a shift and a subtract (:47-:48).
Measured on this desktop (x86-64, CPython, the two kernels called directly on four seconds of stereo at 48 kHz, best of five), the tank is the cheaper of the two -- 13.1 ms against Freeverb's 48.7 ms, 306x real time against 82x -- because Freeverb's saturating Q15 helper costs more on a part with a hardware FPU than the tank's floats do. That ratio is a desktop fact and does not transfer: the ESP32-S3 and ESP32-P4 figures are the effects roadmap's Phase 1 cost-table item, produced by the board cost runner on flashed firmware, and nothing here estimates them from this.
Memory is the other half of the cost, and it is the part a board feels first.
The default table needs 36,280 int16 (70.9 KB) of line at 48 kHz, plus the
predelay: 89.6 KB with the 200 ms default, 41.2 KB at 22.05 kHz. A shorter
delays table is how a lean character pays less.
Verified by tests/parity/tank_probe.py through verify_dsp.py, with no
oracle -- the golden is captured from the port, the same weaker-but-stricter
claim audioecho and audioconvolve make. It is the most recursive fixture
in the suite: ten lines feeding each other in float, the two halves crossed,
so a one-ulp disagreement between two builds is amplified for thousands of
frames rather than staying one ulp. The probe's own last two lines are not
checksums -- wire-exact says mix=0 came out bit-identical to what went in
while the whole network ran, and tail is the block at which the wet output
reaches exact zero, which is the fixture a build with a rounding quantiser
would fail while still hashing everything else plausibly.