Conversation
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.
FoamyGuy
left a comment
There was a problem hiding this comment.
Thanks for working on this.
There are some cases where rounding behavior has changed, and a few spots where an existing helper synthio_sat16() can be used.
|
|
||
| // Apply decay and add sample | ||
| delay_word = (int32_t)(delay_word * decay) + sample_word; | ||
| delay_word = ((delay_word * decay_scaled) >> 15) + sample_word; |
There was a problem hiding this comment.
This should use synthio_sat16(). The old code rounds toward zero, but the new code always goes toward -infinity. The helper should resolve it.
delay_word = synthio_sat16(delay_word * decay_scaled, 15) + sample_word;
|
|
||
| for (uint32_t j = echo_buffer_pos >> 8; j < next_buffer_pos >> 8; j++) { | ||
| word = (int16_t)(echo_buffer[(j % echo_buf_len) + echo_buffer_offset] * decay); | ||
| word = (int16_t)((echo_buffer[(j % echo_buf_len) + echo_buffer_offset] * decay_scaled) >> 15); |
There was a problem hiding this comment.
| word = (int16_t)((echo_buffer[(j % echo_buf_len) + echo_buffer_offset] * decay_scaled) >> 15); | |
| word = synthio_sat16(echo_buffer[(j % echo_buf_len) + echo_buffer_offset] * decay_scaled, 15); |
| } else { | ||
| echo = echo_buffer[echo_buffer_pos + echo_buffer_offset]; | ||
| word = (int16_t)(echo * decay); | ||
| word = (int16_t)((echo * decay_scaled) >> 15); |
There was a problem hiding this comment.
| word = (int16_t)((echo * decay_scaled) >> 15); | |
| word = synthio_sat16(echo * decay_scaled, 15); |
| } | ||
|
|
||
| word = (int16_t)(echo * MIN(mix, MICROPY_FLOAT_CONST(1.0))); | ||
| word = (int16_t)((echo * echo_scaled) >> 15); |
There was a problem hiding this comment.
| word = (int16_t)((echo * echo_scaled) >> 15); | |
| word = synthio_sat16(echo * echo_scaled, 15); |
| } else { | ||
| echo = echo_buffer[echo_buffer_pos + echo_buffer_offset]; | ||
| word = (int32_t)(echo * decay + sample_word); | ||
| word = ((echo * decay_scaled) >> 15) + sample_word; |
There was a problem hiding this comment.
| word = ((echo * decay_scaled) >> 15) + sample_word; | |
| synthio_sat16(echo * decay_scaled, 15) + sample_word; |
| mp_float_t mix = synthio_block_slot_get_limited(&self->mix, MICROPY_FLOAT_CONST(0.0), MICROPY_FLOAT_CONST(1.0)); | ||
|
|
||
| int32_t wet_scaled = (int32_t)(mix * MICROPY_FLOAT_CONST(32768.0)); | ||
| int32_t dry_scaled = (int32_t)((MICROPY_FLOAT_CONST(1.0) - mix) * MICROPY_FLOAT_CONST(32768.0)); |
There was a problem hiding this comment.
| int32_t dry_scaled = (int32_t)((MICROPY_FLOAT_CONST(1.0) - mix) * MICROPY_FLOAT_CONST(32768.0)); | |
| int32_t dry_scaled = 32768 - wet_scaled; |
Both wet and dry truncate toward zero. Could cause off by 1 error when they're summed.
| @@ -238,15 +241,15 @@ audioio_get_buffer_result_t audiofilters_filter_get_buffer(audiofilters_filter_o | |||
| bool buf_offset = (j % self->base.channel_count) == 1; | |||
| uint32_t k = j / self->base.channel_count; | |||
There was a problem hiding this comment.
This section has different behavior rounding always down instead of toward zero. It should use the new helper function.
Add here:
int32_t filtered_word = self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset];
| uint32_t k = j / self->base.channel_count; | ||
| if (MP_LIKELY(self->base.bits_per_sample == 16)) { | ||
| word_buffer[i + j] = synthio_mix_down_sample((int32_t)((sample_src[i + j] * (MICROPY_FLOAT_CONST(1.0) - mix)) + (self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * mix)), SYNTHIO_MIX_DOWN_SCALE(2)); | ||
| word_buffer[i + j] = synthio_mix_down_sample(((sample_src[i + j] * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15), SYNTHIO_MIX_DOWN_SCALE(2)); |
There was a problem hiding this comment.
| word_buffer[i + j] = synthio_mix_down_sample(((sample_src[i + j] * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15), SYNTHIO_MIX_DOWN_SCALE(2)); | |
| word_buffer[i + j] = synthio_mix_down_sample(synthio_shift_round_to_zero(sample_src[i + j] * dry_scaled + filtered_word * wet_scaled, 15), SYNTHIO_MIX_DOWN_SCALE(2)); |
| } else { | ||
| if (self->base.samples_signed) { | ||
| hword_buffer[i + j] = (int8_t)((sample_hsrc[i + j] * (MICROPY_FLOAT_CONST(1.0) - mix)) + (self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * mix)); | ||
| hword_buffer[i + j] = (int8_t)(((sample_hsrc[i + j] * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15)); |
There was a problem hiding this comment.
| hword_buffer[i + j] = (int8_t)(((sample_hsrc[i + j] * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15)); | |
| hword_buffer[i + j] = (int8_t)synthio_shift_round_to_zero(sample_hsrc[i + j] * dry_scaled + filtered_word * wet_scaled, 15); |
| hword_buffer[i + j] = (int8_t)(((sample_hsrc[i + j] * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15)); | ||
| } else { | ||
| hword_buffer[i + j] = (uint8_t)(((int8_t)(((uint8_t)sample_hsrc[i + j]) ^ 0x80) * (MICROPY_FLOAT_CONST(1.0) - mix)) + (self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * mix)) ^ 0x80; | ||
| hword_buffer[i + j] = (uint8_t)((((int8_t)(((uint8_t)sample_hsrc[i + j]) ^ 0x80) * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15)) ^ 0x80; |
There was a problem hiding this comment.
| hword_buffer[i + j] = (uint8_t)((((int8_t)(((uint8_t)sample_hsrc[i + j]) ^ 0x80) * dry_scaled) >> 15) + ((self->filter_buffer[k + SYNTHIO_MAX_DUR * buf_offset] * wet_scaled) >> 15)) ^ 0x80; | |
| hword_buffer[i + j] = (uint8_t)synthio_shift_round_to_zero((int8_t)(((uint8_t)sample_hsrc[i + j]) ^ 0x80) * dry_scaled + filtered_word * wet_scaled, 15) ^ 0x80; |
Code written by Claude Code, guided and corrected by @peterbay.
The problem
An audio effect reads its
mix,decayorlevelonce per block from aBlockInput, 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 -uon the builtMixer.oshowsU __divsf3, which in an RP2040 firmware resolves to0x40002274: the ROM routine, a real unpack, divide and round.audiodelays/Flanger,audiofilters/Phaserandaudiofreeverb/Freeverbalready 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.audiomixerhad been dividing by32767, and itslevelreachesmult16signedas a float in0.0–1.0scaled by1 << 15, so unity became a gain of32768/32767— slightly above one. A sample of32766came back as32767, 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 is32767/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:
audiodelays/Echoaudiodelays/MultiTapDelayaudiofilters/Filteraudiodelays/Chorusaudiomixerwas 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 inshared-bindingsandshared-moduleagainst 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
MultiTapDelayloses only three calls and still runs three and a half times lighter:EchoFilterMultiTapDelayChorusFirmware 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 — multiplier0–32768, sample-32768–32767: every product stays insideint32, 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 doeslomul <<= 16on anint32_tthat reaches32768at level1.0, which shifts into the sign bit. Modellingsmulwbandssaton the host makes that branch return-samplethere. 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_AUDIOFILTERSandCIRCUITPY_AUDIOFREEVERBenabled. Each effect is constructed, played throughaudiopwmio.PWMAudioOutinto two unused pins, and torn down before the next, with the timing loop described above run three times on each side of the switch.audiomixerwas tested on a Seeed XIAO nRF52840 Sense with an Adafruit Audio BFF over I2S.