Skip to content

audio: apply mix and decay in fixed point, not floating point - #11415

Open
peterbay wants to merge 1 commit into
adafruit:mainfrom
peterbay:audio-softfloat
Open

peterbay wants to merge 1 commit into
adafruit:mainfrom
peterbay:audio-softfloat

Conversation

@peterbay

Copy link
Copy Markdown

Code written by Claude Code, guided and corrected by @peterbay.

The problem

An audio effect reads its mix, decay or level once per block from a BlockInput, and those values do not change again until the next block. Four of the callbacks were nonetheless applying them to every sample in floating point.

On a part with a floating point unit that is a couple of instructions. On one without — RP2040, SAMD21, the RISC-V ESP32s — every one of them is an out-of-line call into a soft-float routine, and it happens inside a callback that has to keep up with the sample rate. nm -u on the built Mixer.o shows U __divsf3, which in an RP2040 firmware resolves to 0x40002274: the ROM routine, a real unpack, divide and round.

audiodelays/Flanger, audiofilters/Phaser and audiofreeverb/Freeverb already avoid this — they convert their parameters to fixed point once per block and keep the sample loop in integers. The five files this PR touches did not.

What this does

  • Each block-constant parameter is converted to Q15 where it is already being read, and the per-sample arithmetic becomes an integer multiply and a shift. No behaviour is added or removed; the same values are applied to the same samples.

  • The scale is 1 << 15, which is exactly unity. audiomixer had been dividing by 32767, and its level reaches mult16signed as a float in 0.01.0 scaled by 1 << 15, so unity became a gain of 32768/32767 — slightly above one. A sample of 32766 came back as 32767, and full-scale samples clipped. The portable branch also disagreed with the ARM branch it exists to mirror. Both are now exact.

  • The effects that already use fixed point are left alone. They scale by 32767, which errs the other way — a nominal unity mix is 32767/32768 — and by less. Changing them is a separate question from removing the soft-float, so this PR does not.

What it costs and what it saves

Measured on a Waveshare RP2040-GEEK, two channels at 22050 Hz, 512-byte buffers, PWM output. A fixed Python loop is timed with the effect feeding the output and again with it stopped; the shortfall is the callback's share of the core. Three rounds each, spread within a round under half a point:

effect before after
audiodelays/Echo 57.5 % 18.1 %
audiodelays/MultiTapDelay 36.9 % 10.6 %
audiofilters/Filter 29.4 % 10.5 %
audiodelays/Chorus 15.3 % 9.4 %

audiomixer was measured separately, one 16-bit stereo voice at 44.1 kHz with I2S running throughout: 4.07 % of the core before and 1.81 % after, five rounds each, ranges 3.93–4.64 % and 1.25–2.31 %. Its compiled function went from 5394 to 4170 bytes, which matters on its own — it was the largest in shared-bindings and shared-module against a 16 kB instruction cache.

Soft-float calls remaining in each callback, counted in the built objects. What is left is the once-per-block setup, not per-sample work, which is why MultiTapDelay loses only three calls and still runs three and a half times lighter:

before after
Echo 36 14
Filter 16 6
MultiTapDelay 16 13
Chorus 12 11

Firmware size is 32 bytes smaller on that build. No new translatable strings.

Accuracy

Checked exhaustively on the host against the floating point it replaces.

For audiomixer, over all 2 147 549 184 (sample, multiplier) pairs the mixer can produce — multiplier 032768, sample -3276832767: every product stays inside int32, the new result never differs from the old by more than one LSB, and the old code lands on a rail three times as often as the new one.

For the effects, over 65 601 536 (sample, coefficient) combinations: at most one LSB per term, two for a dry/wet sum, which is the difference between truncating toward zero and shifting — about −84 dB.

One thing noticed and not verified

audiomixer's ARMv7EM branch does lomul <<= 16 on an int32_t that reaches 32768 at level 1.0, which shifts into the sign bit. Modelling smulwb and ssat on the host makes that branch return -sample there. I have no M4 or M7 board with an audio setup to check it on, so I am only mentioning it — it is not addressed here and it is not what this PR changes.

Testing

Waveshare RP2040-GEEK, built with CIRCUITPY_AUDIODELAYS, CIRCUITPY_AUDIOFILTERS and CIRCUITPY_AUDIOFREEVERB enabled. Each effect is constructed, played through audiopwmio.PWMAudioOut into two unused pins, and torn down before the next, with the timing loop described above run three times on each side of the switch. audiomixer was tested on a Seeed XIAO nRF52840 Sense with an Adafruit Audio BFF over I2S.

The mix, decay and level an effect works with are read once per block from their
BlockInput and do not change for the rest of it, but four of the audio callbacks
were applying them to every sample in floating point. On a part with no floating
point unit each of those is an out-of-line call into a soft-float routine, inside
a callback that has to keep up with the sample rate.

Each parameter is now converted to Q15 where it is already being read, and the
per-sample arithmetic is an integer multiply and a shift. audiodelays/Flanger,
audiofilters/Phaser and audiofreeverb/Freeverb already work this way; this brings
audiomixer, audiodelays/Echo, audiodelays/MultiTapDelay, audiodelays/Chorus and
audiofilters/Filter in line with them.

The scale is 1<<15, which is exactly unity. audiomixer had been dividing by 32767
instead, and its level reaches that code as a float in 0 to 1 scaled by 1<<15, so
unity became a gain slightly above one: a full-scale sample came back clipped, and
the portable branch disagreed with the ARM one it exists to mirror. The effects
that already use fixed point scale by 32767, which errs the other way and by less,
and they are left alone.
@tannewt
tannewt requested a review from FoamyGuy September 18, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant