From 9b081adc053961cb3ce229278d6d1c4887c4a804 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:27:07 -0600 Subject: [PATCH 1/4] audiomixer: Mixer.level_ramp walks a level move instead of stepping Set level_ramp to a number of frames and a voice's level or pan move walks from the old level to the new one in a straight line over that many frames, landing exactly on the level asked for. Left at 0, the default, the Mixer is CircuitPython's to the byte. A voice's first level still lands at once, so a fresh Mixer's first block doesn't change with the ramp on. CircuitPython's zero-crossing wait keeps a single voice from stepping, but two voices crossfading cross zero at different moments, and a voice that never crosses zero lands at the end of a stretch at once, so a crossfade moved in one call can click. The walk is in the node because it has to move once a sample. The native mixer steps once per packed word and the CPython twin once per int16 pair, the same word. tests/test_cpython_audiomixer_level_ramp.py holds the walk's shape; mixer_level_ramp_probe.py holds MicroPython and CPython to the same bytes (verify_dsp.py, 64 comparisons, 0 failures, against a unix build of this branch). --- ROADMAP.md | 3 - docs/how-it-works/upstream-diff.md | 42 ++++ docs/how-it-works/upstream-sync.md | 3 + src/audiomixer/Mixer.c | 138 +++++++++++-- src/audiomixer/Mixer.h | 5 + src/audiomixer/MixerVoice.c | 3 + src/audiomixer/MixerVoice.h | 19 ++ src/cpython/audiomixer.py | 99 ++++++++++ tests/parity/mixer_level_ramp_probe.py | 113 +++++++++++ tests/parity/verify_dsp.py | 7 + tests/test_cpython_audiomixer_level_ramp.py | 203 ++++++++++++++++++++ 11 files changed, 612 insertions(+), 23 deletions(-) create mode 100644 tests/parity/mixer_level_ramp_probe.py create mode 100644 tests/test_cpython_audiomixer_level_ramp.py diff --git a/ROADMAP.md b/ROADMAP.md index 216e246c..b742186b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,9 +12,6 @@ audio pump holding up under live use such as a stompbox or a stage rig. sample rather than at the top of the next block. - An LC3 codec module beside `audiomp3`, frame by frame: encode PCM to one frame, decode a frame (or a lost one) to PCM. Leaves room for LE Audio later. -- A level ramp on `audiomixer` voices: a level move ramps over a set number of - frames instead of stepping, so an effect can fade its wet path in or out - without a click when you switch it in mid-note. - `audiopump.DriftResampler` converting down as well as up, with its filter's cutoff following the output rate, so a 48 kHz stream can play on a 44.1 kHz output. diff --git a/docs/how-it-works/upstream-diff.md b/docs/how-it-works/upstream-diff.md index d01324b3..fdd59621 100644 --- a/docs/how-it-works/upstream-diff.md +++ b/docs/how-it-works/upstream-diff.md @@ -5,6 +5,48 @@ CircuitPython, newest first. For the current list of deliberate differences, and how and when to resync with upstream, start at [upstream-sync.md](upstream-sync.md). +## A level ramp on Mixer voices + +`audiomixer.Mixer.level_ramp` is new here; CircuitPython's Mixer doesn't have +it. Set it to a number of frames and a voice's level or pan move walks from +the old level to the new one in a straight line over that many frames. Left +at 0, the default, the Mixer is CircuitPython's to the byte. + +```python +mixer.level_ramp = 960 # 20 ms at 48 kHz +mixer.voice[0].level = 0.0 # walks down over the next 960 frames +mixer.voice[1].level = 1.0 # walks up over the same frames +``` + +CircuitPython 10.3.0 holds a level move until the voice's next zero crossing, +so a move can't step a single waveform mid-cycle. That isn't enough for a +crossfade. A dry voice and a wet one cross zero at different moments, and a +voice whose signal never crosses zero (a lopsided shaper's output, a DC +offset) waits until the end of a stretch of its source and then lands at +once. Either way the sum steps, and a crossfade moved in one call can click. +The walk lives in the node because it has to move once a sample, and Python +only runs when a control is called. + +How it walks, in [`src/audiomixer/Mixer.c`](../../src/audiomixer/Mixer.c): + +- One step per packed 32-bit word the mix-down scales: a frame in a stereo + Mixer, two frames in a mono one. The level is held as Q15 shifted up 15 + more bits, so a step smaller than one Q15 unit still adds up, and the last + step lands exactly on the level asked for. +- A voice's first level, the one it starts at, lands as CircuitPython's does. + The walk starts from the next move, so a fresh Mixer's first block is the + same bytes with the ramp on or off. +- A move made during a walk starts a new walk from where the level stands. +- Level and pan are read once per stretch, as before. A level driven by a + `synthio.LFO` starts a new walk each time it's read, so it follows the LFO + a walk behind. + +The CPython twin walks one int16 pair at a time, which is the same word. +[`tests/test_cpython_audiomixer_level_ramp.py`](../../tests/test_cpython_audiomixer_level_ramp.py) +holds the walk's shape, and `mixer_level_ramp_probe.py` holds MicroPython and +CPython to the same bytes through `verify_dsp.py`. CircuitPython doesn't run +it, because its Mixer has no `level_ramp`. + ## `audiosampler`: a sample player, because a Mixer voice cannot change pitch Nothing in CircuitPython does this. `audiomixer.MixerVoice` pulls a *source* diff --git a/docs/how-it-works/upstream-sync.md b/docs/how-it-works/upstream-sync.md index 8fc57c19..4d51f74d 100644 --- a/docs/how-it-works/upstream-sync.md +++ b/docs/how-it-works/upstream-sync.md @@ -284,6 +284,9 @@ reason, and a resync should not take them. Level and panning are always block inputs, and the ARM CMSIS intrinsics are the portable C ([why](upstream-diff.md#audiomixer-synthio-block-input-made-unconditional-arm-cmsis-dropped-tier-3)). + `Mixer.level_ramp` is new: set it and a level or pan move walks instead of + waiting for a zero crossing + ([why](upstream-diff.md#a-level-ramp-on-mixer-voices)). - **audiospeed**: the Q16 rate rounds and the phase carries across a source buffer ([rate](upstream-diff.md#audiospeed-the-q16-rate-rounds-here-upstream-truncates-audiodsp92), diff --git a/src/audiomixer/Mixer.c b/src/audiomixer/Mixer.c index 30461117..fa431a26 100644 --- a/src/audiomixer/Mixer.c +++ b/src/audiomixer/Mixer.c @@ -75,6 +75,7 @@ void common_hal_audiomixer_mixer_construct(audiomixer_mixer_obj_t *self, uint32_t sample_rate) { self->len = buffer_size / 2 / sizeof(uint32_t) * sizeof(uint32_t); self->in_pull = false; + self->level_ramp = 0; self->first_buffer = m_malloc(self->len); if (self->first_buffer == NULL) { @@ -164,6 +165,33 @@ static MP_DEFINE_CONST_FUN_OBJ_1(audiomixer_mixer_get_voice_obj, audiomixer_mixe MP_PROPERTY_GETTER(audiomixer_mixer_voice_obj, (mp_obj_t)&audiomixer_mixer_get_voice_obj); +// Not upstream: Mixer.level_ramp, the frames a voice's level or pan move +// takes. See docs/how-it-works/upstream-diff.md, "A level ramp on Mixer voices". +#define AUDIOMIXER_LEVEL_RAMP_MAX (1 << 24) + +static mp_obj_t audiomixer_mixer_obj_get_level_ramp(mp_obj_t self_in) { + audiomixer_mixer_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_int_from_uint(self->level_ramp); +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiomixer_mixer_get_level_ramp_obj, audiomixer_mixer_obj_get_level_ramp); + +static mp_obj_t audiomixer_mixer_obj_set_level_ramp(mp_obj_t self_in, mp_obj_t frames_in) { + audiomixer_mixer_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + mp_int_t frames = mp_arg_validate_int_range(mp_obj_get_int(frames_in), 0, + AUDIOMIXER_LEVEL_RAMP_MAX, MP_QSTR_level_ramp); + audiodsp_pump_lock_acquire(); + self->level_ramp = (uint32_t)frames; + audiodsp_pump_lock_release(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(audiomixer_mixer_set_level_ramp_obj, audiomixer_mixer_obj_set_level_ramp); + +MP_PROPERTY_GETSET(audiomixer_mixer_level_ramp_obj, + (mp_obj_t)&audiomixer_mixer_get_level_ramp_obj, + (mp_obj_t)&audiomixer_mixer_set_level_ramp_obj); + static mp_obj_t audiomixer_mixer_obj_play(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { enum { ARG_sample, ARG_voice, ARG_loop }; static const mp_arg_t allowed_args[] = { @@ -215,6 +243,7 @@ static const mp_rom_map_elem_t audiomixer_mixer_locals_dict_table[] = { // Properties { MP_ROM_QSTR(MP_QSTR_playing), MP_ROM_PTR(&audiomixer_mixer_playing_obj) }, { MP_ROM_QSTR(MP_QSTR_voice), MP_ROM_PTR(&audiomixer_mixer_voice_obj) }, + { MP_ROM_QSTR(MP_QSTR_level_ramp), MP_ROM_PTR(&audiomixer_mixer_level_ramp_obj) }, AUDIOSAMPLE_FIELDS, }; static MP_DEFINE_CONST_DICT(audiomixer_mixer_locals_dict, audiomixer_mixer_locals_dict_table); @@ -323,6 +352,32 @@ static inline uint32_t copy8msb(uint32_t val) { #define ALMOST_ONE (MICROPY_FLOAT_CONST(32767.) / 32768) +// Not upstream: one step of a level move's straight-line walk (Mixer.level_ramp). +// The last step lands on the target exactly, whatever the division dropped. +static inline void audiomixer_level_ramp_step(audiomixer_level_ramp_t *ramp, + int32_t *active_lo, int32_t *active_hi) { + ramp->lo += ramp->step_lo; + ramp->hi += ramp->step_hi; + if (--ramp->left == 0) { + ramp->lo = ramp->target_lo << 15; + ramp->hi = ramp->target_hi << 15; + } + *active_lo = ramp->lo >> 15; + *active_hi = ramp->hi >> 15; +} + +// Every packed word the mix-down scales takes its level from here: a step of +// the walk while a level move is ramping, else CircuitPython's wait for a zero +// crossing. With level_ramp 0, `ramp.left` is always 0. +#define MIX_LEVEL_STEP(word, last, alo, ahi, plo, phi) \ + do { \ + if (ramp.left) { \ + audiomixer_level_ramp_step(&ramp, (alo), (ahi)); \ + } else { \ + audiodsp_assign_packed_level((word), (last), (alo), (ahi), (plo), (phi)); \ + } \ + } while (0) + static void mix_down_one_voice(audiomixer_mixer_obj_t *self, audiomixer_mixervoice_obj_t *voice, bool voices_active, uint32_t *word_buffer, uint32_t length) { @@ -429,6 +484,37 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, int32_t active_hi_level = voice->active_hi_level; uint32_t last_word = 0; + // Not upstream: with level_ramp set, a move walks to its new level in + // a straight line instead (docs/how-it-works/upstream-diff.md, "A + // level ramp on Mixer voices"). One step per packed word: a word is a + // frame in a stereo mixer and two frames in a mono one. A voice's first + // level lands as CircuitPython's does; the walk starts from the next. + audiomixer_level_ramp_t ramp = voice->ramp; + if (self->level_ramp == 0) { + ramp.left = 0; + voice->ramp_primed = false; + } else if (!voice->ramp_primed) { + ramp.left = 0; + ramp.target_lo = pending_lo_level; + ramp.target_hi = pending_hi_level; + voice->ramp_primed = true; + } else if (pending_lo_level != ramp.target_lo || pending_hi_level != ramp.target_hi) { + if (ramp.left == 0) { + ramp.lo = active_lo_level << 15; + ramp.hi = active_hi_level << 15; + } + uint32_t steps = self->level_ramp; + if (self->base.channel_count == 1) { + steps = (steps + 1) / 2; + } + ramp.target_lo = pending_lo_level; + ramp.target_hi = pending_hi_level; + ramp.left = steps; + ramp.step_lo = ((pending_lo_level << 15) - ramp.lo) / (int32_t)steps; + ramp.step_hi = ((pending_hi_level << 15) - ramp.hi) / (int32_t)steps; + } + const bool ramping = ramp.left != 0; + // First active voice gets copied over verbatim. if (!voices_active) { if (MP_LIKELY(self->base.bits_per_sample == 16)) { @@ -436,17 +522,17 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, if (MP_LIKELY(self->base.channel_count == sample->channel_count)) { for (uint32_t i = 0; i < n; i++) { uint32_t word = src[i]; - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = mult16signed(word, active_lo_level, active_hi_level); } } else { for (uint32_t i = 0; i + 1 < n; i += 2) { uint32_t word = src[i >> 1]; uint32_t word_lsb = copy16lsb(word); - audiodsp_assign_packed_level(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = mult16signed(word_lsb, active_lo_level, active_hi_level); word = copy16msb(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i + 1] = mult16signed(word, active_lo_level, active_hi_level); } } @@ -455,7 +541,7 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, for (uint32_t i = 0; i < n; i++) { uint32_t word = src[i]; word = tosigned16(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = mult16signed(word, active_lo_level, active_hi_level); } } else { @@ -463,10 +549,10 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, uint32_t word = src[i >> 1]; word = tosigned16(word); uint32_t word_lsb = copy16lsb(word); - audiodsp_assign_packed_level(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = mult16signed(word_lsb, active_lo_level, active_hi_level); word = copy16msb(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i + 1] = mult16signed(word, active_lo_level, active_hi_level); } } @@ -480,7 +566,7 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, if (MP_LIKELY(!self->base.samples_signed)) { word = tosigned16(word); } - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word = mult16signed(word, active_lo_level, active_hi_level); hword_buffer[i] = pack8(word); } @@ -491,10 +577,10 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, word = tosigned16(word); } uint32_t word_lsb = copy16lsb(word); - audiodsp_assign_packed_level(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); hword_buffer[i] = pack8(mult16signed(word_lsb, active_lo_level, active_hi_level)); word = copy16msb(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); hword_buffer[i + 1] = pack8(mult16signed(word, active_lo_level, active_hi_level)); } } @@ -505,17 +591,17 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, if (MP_LIKELY(self->base.channel_count == sample->channel_count)) { for (uint32_t i = 0; i < n; i++) { uint32_t word = src[i]; - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = add16signed(mult16signed(word, active_lo_level, active_hi_level), word_buffer[i]); } } else { for (uint32_t i = 0; i + 1 < n; i += 2) { uint32_t word = src[i >> 1]; uint32_t word_lsb = copy16lsb(word); - audiodsp_assign_packed_level(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = add16signed(mult16signed(word_lsb, active_lo_level, active_hi_level), word_buffer[i]); word = copy16msb(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i + 1] = add16signed(mult16signed(word, active_lo_level, active_hi_level), word_buffer[i + 1]); } } @@ -524,7 +610,7 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, for (uint32_t i = 0; i < n; i++) { uint32_t word = src[i]; word = tosigned16(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = add16signed(mult16signed(word, active_lo_level, active_hi_level), word_buffer[i]); } } else { @@ -532,10 +618,10 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, uint32_t word = src[i >> 1]; word = tosigned16(word); uint32_t word_lsb = copy16lsb(word); - audiodsp_assign_packed_level(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i] = add16signed(mult16signed(word_lsb, active_lo_level, active_hi_level), word_buffer[i]); word = copy16msb(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word_buffer[i + 1] = add16signed(mult16signed(word, active_lo_level, active_hi_level), word_buffer[i + 1]); } } @@ -549,7 +635,7 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, if (MP_LIKELY(!self->base.samples_signed)) { word = tosigned16(word); } - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); word = mult16signed(word, active_lo_level, active_hi_level); word = add16signed(word, unpack8(hword_buffer[i])); hword_buffer[i] = pack8(word); @@ -561,10 +647,10 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, word = tosigned16(word); } uint32_t word_lsb = copy16lsb(word); - audiodsp_assign_packed_level(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word_lsb, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); hword_buffer[i] = pack8(add16signed(mult16signed(word_lsb, active_lo_level, active_hi_level), unpack8(hword_buffer[i]))); word = copy16msb(word); - audiodsp_assign_packed_level(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); + MIX_LEVEL_STEP(word, &last_word, &active_lo_level, &active_hi_level, pending_lo_level, pending_hi_level); hword_buffer[i + 1] = pack8(add16signed(mult16signed(word, active_lo_level, active_hi_level), unpack8(hword_buffer[i + 1]))); } } @@ -583,8 +669,20 @@ static void mix_down_one_voice(audiomixer_mixer_obj_t *self, // Force the active level to match the pending level in the case that the conditions of a // zero crossing weren't met within the last `SYNTHIO_MAX_DUR` frames. Will ensure minimal // delay in level or panning changes at the potential expensive of an audible pop. - voice->active_lo_level = pending_lo_level; - voice->active_hi_level = pending_hi_level; + // + // Not upstream: a walk in progress keeps its place instead, and goes on + // in the next stretch. + if (ramping) { + voice->ramp = ramp; + voice->active_lo_level = active_lo_level; + voice->active_hi_level = active_hi_level; + } else { + voice->ramp.left = 0; + voice->ramp.target_lo = ramp.target_lo; + voice->ramp.target_hi = ramp.target_hi; + voice->active_lo_level = pending_lo_level; + voice->active_hi_level = pending_hi_level; + } } if (length && !voices_active) { diff --git a/src/audiomixer/Mixer.h b/src/audiomixer/Mixer.h index b0c62565..09d88b5a 100644 --- a/src/audiomixer/Mixer.h +++ b/src/audiomixer/Mixer.h @@ -32,6 +32,11 @@ typedef struct { // entry rather than mixing a voice the outer pull is still using // (audiodsp#178). bool in_pull; + // Not upstream: how many frames a voice's level or pan move takes, in a + // straight line, instead of waiting for a zero crossing. 0, the default, + // is CircuitPython's behaviour. See docs/how-it-works/upstream-diff.md, + // "A level ramp on Mixer voices". + uint32_t level_ramp; mp_obj_tuple_t *voice_tuple; mp_obj_t voice[]; } audiomixer_mixer_obj_t; diff --git a/src/audiomixer/MixerVoice.c b/src/audiomixer/MixerVoice.c index 0c8ac106..672a3949 100644 --- a/src/audiomixer/MixerVoice.c +++ b/src/audiomixer/MixerVoice.c @@ -15,6 +15,7 @@ // SPDX-License-Identifier: MIT #include +#include #include "audiomixer/Mixer.h" #include "audiomixer/MixerVoice.h" @@ -45,6 +46,8 @@ void common_hal_audiomixer_mixervoice_construct(audiomixer_mixervoice_obj_t *sel self->buffer_length = 0; self->active_lo_level = 0; self->active_hi_level = 0; + memset(&self->ramp, 0, sizeof(self->ramp)); + self->ramp_primed = false; common_hal_audiomixer_mixervoice_set_level(self, mp_obj_new_float(1.0)); common_hal_audiomixer_mixervoice_set_panning(self, mp_obj_new_float(0.0)); } diff --git a/src/audiomixer/MixerVoice.h b/src/audiomixer/MixerVoice.h index d842352c..fe825757 100644 --- a/src/audiomixer/MixerVoice.h +++ b/src/audiomixer/MixerVoice.h @@ -19,6 +19,20 @@ #include "audiomixer/Mixer.h" #include "synthio/block.h" +// Not upstream: a level move walking in a straight line (Mixer.level_ramp). +// `lo` and `hi` are the level now, Q15 shifted up 15 more bits so a step +// smaller than one Q15 unit still adds up; `left` counts the steps to go, one +// per packed word the mix-down scales. +typedef struct { + int32_t lo; + int32_t hi; + int32_t step_lo; + int32_t step_hi; + int32_t target_lo; + int32_t target_hi; + uint32_t left; +} audiomixer_level_ramp_t; + typedef struct { mp_obj_base_t base; audiomixer_mixer_obj_t *parent; @@ -34,6 +48,11 @@ typedef struct { // cannot step the waveform mid-cycle and click. int32_t active_lo_level; int32_t active_hi_level; + // Not upstream: the walk a level move takes when the Mixer's level_ramp + // is set. `ramp_primed` is false until the voice has rendered once with + // the ramp on, so its first level lands as CircuitPython's does. + audiomixer_level_ramp_t ramp; + bool ramp_primed; } audiomixer_mixervoice_obj_t; extern const mp_obj_type_t audiomixer_mixervoice_type; diff --git a/src/cpython/audiomixer.py b/src/cpython/audiomixer.py index 603c619c..7b6afd26 100644 --- a/src/cpython/audiomixer.py +++ b/src/cpython/audiomixer.py @@ -62,6 +62,45 @@ def _scale_s16_levels_py(data, active_lo, active_hi, pending_lo, pending_hi, for #: Python loop above. The two are byte-identical. _scale_s16_levels = getattr(_audiodsp, "scale_s16_levels", _scale_s16_levels_py) +#: The most frames `Mixer.level_ramp` takes, as `src/audiomixer/Mixer.c` has it. +_LEVEL_RAMP_MAX = 1 << 24 + + +def _trunc_div(numerator, denominator): + """C's `/` on int32: the quotient truncated toward zero.""" + quotient = abs(numerator) // denominator + return quotient if numerator >= 0 else -quotient + + +def _ramp_s16_levels(data, ramp): + """Scale int16 pairs while a level move walks (`Mixer.level_ramp`). + + One step of the walk per pair, which is one packed word in + `src/audiomixer/Mixer.c`, the authority this mirrors + (`audiomixer_level_ramp_step`). `ramp` is the voice's + [lo, hi, step_lo, step_hi, target_lo, target_hi, left], updated in place. + Returns the scaled bytes for the pairs the walk covers, and how many bytes + of `data` that was. + """ + import array + samples = array.array("h") + pairs = min(ramp[6], len(data) // 4) + samples.frombytes(data[:pairs * 4]) + lo, hi, step_lo, step_hi, target_lo, target_hi, left = ramp + for index in range(0, pairs * 2, 2): + lo += step_lo + hi += step_hi + left -= 1 + if left == 0: + lo = target_lo << 15 + hi = target_hi << 15 + mul_lo = lo >> 15 + mul_hi = hi >> 15 + samples[index] = max(-32768, min(32767, (samples[index] * mul_lo) >> 15)) + samples[index + 1] = max(-32768, min(32767, (samples[index + 1] * mul_hi) >> 15)) + ramp[0], ramp[1], ramp[6] = lo, hi, left + return samples.tobytes(), pairs * 4 + def _source_chunk(sample): """The block a voice will mix from -- BORROWED from the source, not copied. @@ -96,6 +135,13 @@ def __init__(self, mixer): # a fresh voice, as CircuitPython's zero-initialised # active_lo_level/active_hi_level are, and never cleared thereafter. self._active_level = (0, 0) + # Not upstream: the walk a level move takes when the Mixer's + # level_ramp is set, [lo, hi, step_lo, step_hi, target_lo, target_hi, + # left] as `audiomixer_level_ramp_t` has it, and whether the voice has + # rendered once with the ramp on (its first level lands as + # CircuitPython's does). + self._ramp = [0, 0, 0, 0, 0, 0, 0] + self._ramp_primed = False @property def playing(self): return self._sample is not None @@ -162,8 +208,26 @@ def __init__(self, *, voice_count=2, buffer_size=1024, channel_count=2, self.buffer_size = int(buffer_size) self._render_size = self.buffer_size // 2 // 4 * 4 self.voice = tuple(MixerVoice(self) for _ in range(voice_count)) + self._level_ramp = 0 self._deinited = False + @property + def level_ramp(self): + """How many frames a voice's level or pan move takes, in a straight + line. 0, the default, waits for a zero crossing, as CircuitPython's + Mixer does. Not in CircuitPython; see + docs/how-it-works/upstream-diff.md, "A level ramp on Mixer voices".""" + self._check() + return self._level_ramp + + @level_ramp.setter + def level_ramp(self, frames): + self._check() + frames = int(frames) + if not 0 <= frames <= _LEVEL_RAMP_MAX: + raise ValueError("level_ramp must be %d-%d" % (0, _LEVEL_RAMP_MAX)) + self._level_ramp = frames + @property def playing(self): return any(voice.playing for voice in self.voice) @@ -285,6 +349,41 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): left = (left_scale * level) >> 15 right = (right_scale * level) >> 15 pending = (left, right) + # Not upstream: with level_ramp set, a move walks to its new + # level in a straight line, one step per int16 pair, which is + # one packed word in Mixer.c. A voice's first level lands as + # CircuitPython's does; the walk starts from the next. + ramp = voice._ramp + if not self._level_ramp: + ramp[6] = 0 + voice._ramp_primed = False + elif not voice._ramp_primed: + ramp[6] = 0 + ramp[4], ramp[5] = pending + voice._ramp_primed = True + elif pending != (ramp[4], ramp[5]): + if not ramp[6]: + ramp[0] = voice._active_level[0] << 15 + ramp[1] = voice._active_level[1] << 15 + steps = self._level_ramp + if self.channel_count == 1: + steps = (steps + 1) // 2 + ramp[4], ramp[5] = pending + ramp[6] = steps + ramp[2] = _trunc_div((left << 15) - ramp[0], steps) + ramp[3] = _trunc_div((right << 15) - ramp[1], steps) + if ramp[6]: + walked, taken = _ramp_s16_levels(data, ramp) + rest = data[taken:] + if ramp[6]: + voice._active_level = (ramp[0] >> 15, ramp[1] >> 15) + chunks.append(walked + rest) + continue + voice._active_level = pending + if rest and pending != (_UNITY_Q15, _UNITY_Q15): + rest = _scale_s16_levels(rest, *pending, *pending, ()) + chunks.append(walked + rest) + continue if (voice._active_level == (_UNITY_Q15, _UNITY_Q15) and left == _UNITY_Q15 and right == _UNITY_Q15): # Unity on both channels, now and pending: `(x * 32768) diff --git a/tests/parity/mixer_level_ramp_probe.py b/tests/parity/mixer_level_ramp_probe.py new file mode 100644 index 00000000..4d7a9f4c --- /dev/null +++ b/tests/parity/mixer_level_ramp_probe.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""A Mixer voice's level move walks in a straight line with `level_ramp` set. + + mixer_level_ramp_probe.py audiomixer + +`Mixer.level_ramp` is this port's, not CircuitPython's (docs/how-it-works/upstream-diff.md, +"A level ramp on Mixer voices"). The native mixer walks a move one packed +word at a time and the CPython twin one int16 pair at a time; this prints +what each renders so `verify_dsp.py` can hold them to the same bytes. The +cases: a level move and a pan move, mono and stereo, a move made during a +walk, a walk through blocks shorter than it, a two-voice crossfade, and +`level_ramp` 0 beside a Mixer that never set it. CircuitPython's own Mixer has +no `level_ramp`, so `verify_dsp.py` doesn't run this there. +""" + +import sys +from array import array + +import audiocore + +mixers = __import__(sys.argv[1] if len(sys.argv) > 1 else "audiomixer") + +RATE = 48000 + + +def checksum(data): + value = 2166136261 + for byte in data: + value = ((value ^ byte) * 16777619) & 0xffffffff + return value + + +FAILED = [] + + +def check(tag, ok): + print("check", tag, "ok" if ok else "FAILED") + if not ok: + FAILED.append(tag) + + +def material(frames, channels, offset=0): + """A triangle a little off zero, so some moves wait for a stretch end and + some land on a crossing, and every frame differs from its neighbours.""" + values = array("h") + for frame in range(frames): + phase = (frame * 37 + offset) % 800 + value = (phase if phase < 400 else 800 - phase) * 60 - 9000 + for channel in range(channels): + values.append(value + 700 * channel) + return values + + +def mixer(channels, voices, block): + return mixers.Mixer(voice_count=voices, sample_rate=RATE, + channel_count=channels, bits_per_sample=16, + samples_signed=True, + buffer_size=block * channels * 2 * 2) + + +def render(channels, ramp, block, moves, voices=1, touch=True): + """Pull 12 blocks; `moves` maps a block index to (voice, attr, value).""" + node = mixer(channels, voices, block) + if touch: + node.level_ramp = ramp + for v in range(voices): + node.voice[v].level = 1.0 if v == 0 else 0.0 + node.voice[v].play(audiocore.RawSample( + material(2400, channels, 211 * v), sample_rate=RATE, + channel_count=channels), loop=True) + out = [] + for index in range(12): + for voice, attr, value in moves.get(index, ()): + setattr(node.voice[voice], attr, value) + out.append(bytes(audiocore.get_buffer(node)[1])) + node.deinit() + return out + + +CASES = ( + ("level", {3: ((0, "level", 0.25),)}), + ("level-up", {3: ((0, "level", 0.0),), 7: ((0, "level", 0.8),)}), + ("during", {3: ((0, "level", 0.0),), 4: ((0, "level", 0.6),)}), + ("pan", {3: ((0, "panning", -1.0),), 6: ((0, "panning", 0.5),)}), +) + +for channels in (1, 2): + for block in (256, 100): + for ramp in (0, 960, 37): + for name, moves in CASES: + if name == "pan" and channels == 1: + continue + tag = "%s-%dch-b%d-r%d" % (name, channels, block, ramp) + print("mixer-ramp", tag, + [checksum(b) for b in render(channels, ramp, block, + moves)]) + tag = "crossfade-%dch-b%d" % (channels, block) + fade = {3: ((0, "level", 0.0), (1, "level", 1.0))} + print("mixer-ramp", tag, + [checksum(b) for b in render(channels, 960, block, fade, 2)]) + # level_ramp 0 is CircuitPython's mixer: the same bytes as a Mixer + # that never touched the property. + moves = CASES[0][1] + check("off-%dch-b%d" % (channels, block), + render(channels, 0, block, moves) + == render(channels, 0, block, moves, touch=False)) + +print("done mixer-ramp") +if FAILED: + raise SystemExit("failed: " + " ".join(FAILED)) diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index 553e2922..356b9554 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -201,6 +201,13 @@ "docs/how-it-works/upstream-diff.md, \"Resetting a Mixer silenced " "it\""}, None), + # A level move walks with Mixer.level_ramp set: the native mixer and the + # twin walk the same steps. CircuitPython's Mixer has no level_ramp. + ("mixer_level_ramp_probe.py", "audiomixer", + {"circuitpython": "Mixer.level_ramp is this port's, not " + "CircuitPython's - docs/how-it-works/upstream-diff.md, " + "\"A level ramp on Mixer voices\""}, + None), ("tank_probe.py", "audioverb", {}, None), # The node's own state: Tone out and back through 0 (#168) and the # network re-cut in place on a playing node (#169). Its own file, so the diff --git a/tests/test_cpython_audiomixer_level_ramp.py b/tests/test_cpython_audiomixer_level_ramp.py new file mode 100644 index 00000000..7930d13f --- /dev/null +++ b/tests/test_cpython_audiomixer_level_ramp.py @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""`Mixer.level_ramp`: a voice's level move walks instead of stepping. + +CircuitPython's Mixer holds a level move until the voice's next zero crossing, +so it cannot step a single waveform mid-cycle. Two voices crossfading (a dry +path and a wet one) cross zero at different moments, so their sum still +steps. With `level_ramp` set to a number of frames, a move walks to its new +level in a straight line over that many frames, one step a frame in a stereo +mixer and one every two frames in a mono one (a packed word), and lands +exactly on the level asked for. `src/audiomixer/Mixer.c` is the authority; the +CPython twin mirrors it. + +| what it checks | bar | +|---|---| +| the default is CircuitPython's | `level_ramp` is 0, and a Mixer set to 0 renders the bytes of one never touched, through a level move | +| a voice's first level is not walked | the first block at `level_ramp` 960 is byte-identical to the same block at 0 | +| the walk is a straight line and lands | after a move from 1.0 to 0.25 on DC, the level falls by the same step, to within one LSB, frame after frame, and is exactly 0.25's product from frame 960 on | +| a mono mixer steps once a word | at `level_ramp` 960 a mono voice walks in 480 steps, each held for two frames | +| a walk spans blocks | 256-frame blocks give the same frames as 2048-frame blocks | +| a move during a walk starts from where it stands | no frame steps by more than one step's worth, and it lands on the second target 960 frames after the second move | +| a pan move walks too | a stereo voice panned hard left in one call walks its right channel down | +| the crossfade that clicks without it | a sine voice going 1.0 to 0.0 while a voice that never crosses zero goes 0.0 to 1.0: the largest step within the move is under 1.5 times the tone's own at `level_ramp` 960, and over it at 0 | +| out of range is refused | -1 and 2**24 + 1 raise ValueError | + +`tests/parity/mixer_level_ramp_probe.py` holds MicroPython's native mixer to +the same bytes. `verify_dsp.py` runs it there and here, and not on +CircuitPython, whose Mixer has no `level_ramp`. +""" + +import array +import math +import unittest + +import audiocore +import audiomixer + +RATE = 48000 +RAMP = 960 +DC = 16384 + + +def _mixer(channels=2, voices=1, buffer_frames=256): + return audiomixer.Mixer( + voice_count=voices, sample_rate=RATE, channel_count=channels, + bits_per_sample=16, samples_signed=True, + buffer_size=buffer_frames * channels * 2 * 2) + + +def _source(values, channels): + return audiocore.RawSample(array.array("h", values), sample_rate=RATE, + channel_count=channels) + + +def _pull(mixer, frames, channels): + """`frames` frames, pulled whole blocks at a time: a multiple of the + block, or the rest of the last block is lost.""" + out = array.array("h") + while len(out) < frames * channels: + out.frombytes(bytes(audiocore.get_buffer(mixer)[1])) + return list(out[:frames * channels]) + + +def _sine(frames, phase=0.0): + return [int(12000 * math.sin(2 * math.pi * 200.0 * i / RATE + phase)) + for i in range(frames)] + + +def _stereo(values): + return [v for v in values for _ in (0, 1)] + + +def _dc_move(ramp, channels=2, buffer_frames=256, to=0.25, pan=None): + """A DC voice at 1.0 for 512 frames, then a move of level or pan.""" + mixer = _mixer(channels, buffer_frames=buffer_frames) + mixer.level_ramp = ramp + mixer.voice[0].level = 1.0 + mixer.play(_source([DC] * (8192 * channels), channels), loop=True) + head = _pull(mixer, 512, channels) + if pan is None: + mixer.voice[0].level = to + else: + mixer.voice[0].panning = pan + return head, _pull(mixer, 2048, channels) + + +class LevelRamp(unittest.TestCase): + + def test_default_is_circuitpythons(self): + self.assertEqual(_mixer().level_ramp, 0) + renders = [] + for touch in (False, True): + mixer = _mixer() + if touch: + mixer.level_ramp = 0 + mixer.play(_source(_stereo(_sine(4800)), 2), loop=True) + first = _pull(mixer, 512, 2) + mixer.voice[0].level = 0.3 + renders.append(first + _pull(mixer, 1024, 2)) + self.assertEqual(renders[0], renders[1]) + + def test_first_level_is_not_walked(self): + blocks = [] + for ramp in (0, RAMP): + mixer = _mixer() + mixer.level_ramp = ramp + mixer.voice[0].level = 0.5 + mixer.play(_source(_stereo(_sine(4800)), 2), loop=True) + blocks.append(_pull(mixer, 256, 2)) + self.assertEqual(blocks[0], blocks[1]) + + def test_walk_is_a_straight_line_and_lands(self): + head, tail = _dc_move(RAMP) + # The first stretch waits for a zero crossing DC never makes, as + # CircuitPython's does; the level has landed by the second. + self.assertEqual(set(head[256 * 2:]), {DC}) + left = tail[0::2] + target = (DC * 8192) >> 15 + steps = [left[i - 1] - left[i] for i in range(1, RAMP)] + self.assertLessEqual(max(steps) - min(steps), 1, steps[:8]) + self.assertTrue(all(v > target for v in left[:RAMP - 1])) + self.assertEqual(left[RAMP - 1:], [target] * (len(left) - RAMP + 1)) + self.assertEqual(tail[0::2], tail[1::2]) + + def test_mono_steps_once_a_word(self): + _, tail = _dc_move(RAMP, channels=1) + target = (DC * 8192) >> 15 + pairs = [(tail[i], tail[i + 1]) for i in range(0, RAMP, 2)] + self.assertTrue(all(a == b for a, b in pairs)) + self.assertTrue(all(a > target for a, _ in pairs[:-1])) + self.assertEqual(tail[RAMP - 2:], [target] * (len(tail) - RAMP + 2)) + + def test_walk_spans_blocks(self): + _, small = _dc_move(RAMP, buffer_frames=256) + _, large = _dc_move(RAMP, buffer_frames=2048) + self.assertEqual(small, large) + + def test_move_during_walk(self): + mixer = _mixer() + mixer.level_ramp = RAMP + mixer.voice[0].level = 1.0 + mixer.play(_source([DC] * 16384, 2), loop=True) + _pull(mixer, 512, 2) + mixer.voice[0].level = 0.0 + first = _pull(mixer, 512, 2)[0::2] + mixer.voice[0].level = 1.0 + second = _pull(mixer, 2048, 2)[0::2] + trace = first + second + biggest = max(abs(trace[i] - trace[i - 1]) + for i in range(1, len(trace))) + self.assertLessEqual(biggest, DC // RAMP + 1) + self.assertEqual(second[RAMP - 1:], [DC] * (len(second) - RAMP + 1)) + self.assertLess(second[RAMP - 2], DC) + + def test_pan_walks(self): + _, tail = _dc_move(RAMP, to=None, pan=-1.0) + left, right = tail[0::2], tail[1::2] + self.assertEqual(set(left), {DC}) + self.assertLess(right[RAMP // 2], DC) + self.assertGreater(right[RAMP // 2], 1) + self.assertEqual(right[RAMP:], [right[RAMP]] * (len(right) - RAMP)) + self.assertLessEqual(right[RAMP], 1) + + def _crossfade_step(self, ramp): + """The largest step within a crossfade, over the tone's own.""" + mixer = _mixer(voices=2) + mixer.level_ramp = ramp + mixer.voice[0].level = 1.0 + mixer.voice[1].level = 0.0 + mixer.voice[0].play(_source(_stereo(_sine(4800)), 2), loop=True) + # The wet voice never crosses zero, as a shaper's lopsided output + # may not, so without the walk its move waits for the end of a + # stretch and lands there at once. + wet = [7000 + v // 4 for v in _sine(4800, math.pi / 2)] + mixer.voice[1].play(_source(_stereo(wet), 2), loop=True) + head = _pull(mixer, 2048, 2)[0::2] + mixer.voice[0].level = 0.0 + mixer.voice[1].level = 1.0 + tail = _pull(mixer, 4096, 2)[0::2] + trace = head + tail + at = len(head) + own = max(abs(trace[i] - trace[i - 1]) for i in range(at - 1024, at)) + near = max(abs(trace[i] - trace[i - 1]) for i in range(at, at + 2048)) + return near / own + + def test_crossfade_does_not_click(self): + self.assertLess(self._crossfade_step(RAMP), 1.5) + self.assertGreater(self._crossfade_step(0), 1.5) + + def test_out_of_range_is_refused(self): + mixer = _mixer() + for bad in (-1, (1 << 24) + 1): + with self.subTest(bad=bad): + with self.assertRaises(ValueError): + mixer.level_ramp = bad + mixer.level_ramp = 1 << 24 + self.assertEqual(mixer.level_ramp, 1 << 24) + + +if __name__ == "__main__": + unittest.main() From 45ffcbc1a8b45cf84fd4c54b36734fd7a0ba7d34 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:23:21 -0600 Subject: [PATCH 2/4] Nodes: Mix, post_gain and makeup walk a move over 20 ms The Waveshaper's mix and post_gain, the Biquad's and the AllPass's mix, the Dynamics node's makeup and the FeedbackDelay's mix now walk a move in a straight line over 20 ms instead of landing at once, so a jump of an effect's Mix or level doesn't click. A node's first value lands at once, and a reset clears a walk in progress. audioroute.Port.play() takes `after=frames`: the port keeps playing what it plays until that many frames have gone, then switches at the start of the next pull. That lets an effect walk its graph to the dry and only then hand the source over. Pointing a port at the source it already plays is no longer a new start. --- docs/guides/swap-what-plays.md | 13 ++ docs/reference/filters-and-effects.md | 8 +- src/audioroute/Port.c | 92 +++++++- src/audioroute/Port.h | 10 + .../shared-bindings/audioroute/Port.c | 53 ++++- .../shared-module/audioroute/Port.c | 39 +++- .../shared-module/audioroute/Port.h | 12 ++ src/cpython/audiobiquad.py | 3 +- src/cpython/audioroute.py | 50 ++++- src/shared/audiodsp_dynamics.c | 12 +- src/shared/audiodsp_dynamics.h | 6 + src/shared/audiodsp_feedback_delay.c | 13 +- src/shared/audiodsp_feedback_delay.h | 6 + src/shared/audiodsp_filter_f32.c | 77 +++++-- src/shared/audiodsp_filter_f32.h | 9 + src/shared/audiodsp_level_ramp.h | 93 ++++++++ src/shared/audiodsp_shaper.c | 17 +- src/shared/audiodsp_shaper.h | 8 + tests/parity/delay_lifecycle_probe.py | 21 +- tests/parity/node_level_ramp_probe.py | 198 ++++++++++++++++++ tests/parity/verify_dsp.py | 3 + tests/test_cpython_audioroute.py | 40 ++++ tests/test_cpython_node_level_ramps.py | 195 +++++++++++++++++ 23 files changed, 917 insertions(+), 61 deletions(-) create mode 100644 src/shared/audiodsp_level_ramp.h create mode 100644 tests/parity/node_level_ramp_probe.py create mode 100644 tests/test_cpython_node_level_ramps.py diff --git a/docs/guides/swap-what-plays.md b/docs/guides/swap-what-plays.md index ceb5d1de..4244580b 100644 --- a/docs/guides/swap-what-plays.md +++ b/docs/guides/swap-what-plays.md @@ -17,6 +17,19 @@ port.play(bitcrusher) # heard on the next block; nothing rewires port.source # what it is playing right now ``` +To switch once the old source has had time to finish something, such as a +level walking down over 20 ms, give `after` a number of frames. The port +keeps playing what it plays for that many more frames and switches at the +start of the next pull. Until then `source` is still the old one, and a +`play()` replaces the waiting switch. A port nothing has pulled since it was +pointed at what it plays now switches at once, since that hasn't started. +Pointing it at the same source again doesn't count as a new start. + +```python +mixer.voice[0].level = 0.0 # walks down over 960 frames +port.play(dry, after=960 + 256) # switches once the walk has landed +``` + `Port` has no `stop()`, deliberately — a port always has a source. A port pulled while it is already inside itself, which is what a component wrapping diff --git a/docs/reference/filters-and-effects.md b/docs/reference/filters-and-effects.md index cf9a9f7a..dad27318 100644 --- a/docs/reference/filters-and-effects.md +++ b/docs/reference/filters-and-effects.md @@ -5,6 +5,12 @@ introduction to the biquad, see [Filter a sound](../guides/filter-a-sound.md). The reasons these exist beside CircuitPython's own modules are in [Additions beyond CircuitPython](../how-it-works/additions.md). +A level moved on a playing node walks to its new value in a straight line +over 20 ms instead of stepping, so turning it in one call doesn't click: the +`mix` of `Biquad`, `AllPass`, `Waveshaper` and `FeedbackDelay`, the +Waveshaper's `post_gain` and the Dynamics node's `makeup_db`. A node that +hasn't played yet, or has just been reset, takes its levels at once. + ## `audiobiquad` @@ -21,7 +27,7 @@ When a `Biquad`'s `frequency`, `Q`, `gain_db` or `mode` changes while it plays, it glides to the new filter over about 20 ms instead of jumping, so a preset change that drops the corner from 20 kHz to 20 Hz doesn't thump. A block input glides the same way, so an LFO on `frequency` arrives about a block late. -`mix` still moves at once, and so does a filter that hasn't played yet. +A filter that hasn't played yet takes its new settings at once. `mode` is one of `LOW_PASS`, `HIGH_PASS`, `BAND_PASS`, `NOTCH`, `PEAKING_EQ`, `LOW_SHELF`, `HIGH_SHELF` — numbered exactly as diff --git a/src/audioroute/Port.c b/src/audioroute/Port.c index fad01fd4..755f4255 100644 --- a/src/audioroute/Port.c +++ b/src/audioroute/Port.c @@ -51,6 +51,9 @@ static mp_obj_t audioroute_port_make_new(const mp_obj_type_t *type, // mp_obj_malloc does not zero, and a garbage byte here would be a port // that refuses its first pull for no reason anybody could reproduce. self->in_pull = false; + self->switch_to = MP_OBJ_NULL; + self->switch_left = 0; + self->pulled = false; return MP_OBJ_FROM_PTR(self); } @@ -66,30 +69,69 @@ static mp_obj_t audioroute_port_make_new(const mp_obj_type_t *type, // `play` rather than `retarget` or `source =` because that is the verb the // whole palette already uses for "this is what you play now" -- every node // audiodsp ports and every node it wrote has one. -static mp_obj_t audioroute_port_play(mp_obj_t self_in, mp_obj_t sample_in) { - audioroute_port_obj_t *self = MP_OBJ_TO_PTR(self_in); +// +// `after` (not upstream of anything; this node is audiodsp's own): the frames +// the port goes on playing what it plays now before it switches, at the start +// of the next pull -- time for a level the old source is walking to land +// before the new one takes over. Until that pull `source` is still the old +// one, and a `play()` replaces the switch that was waiting. A port nothing +// has pulled since it was pointed at what it plays now switches at once: that +// has not started, so there is nothing to finish. Pointed at the same source +// again, the port has not started anything new. +static void audioroute_port_switch(audioroute_port_obj_t *self, + mp_obj_t sample_in) { + const audiosample_base_t *sample = MP_OBJ_TO_PTR(sample_in); + // The same source again goes on playing the same stream, so whether + // it has started stands. + if (self->source != sample_in) { + self->pulled = false; + } + self->source = sample_in; + self->base.max_buffer_length = sample->max_buffer_length; + self->base.single_buffer = sample->single_buffer; +} + +static mp_obj_t audioroute_port_play(size_t n_args, const mp_obj_t *pos_args, + mp_map_t *kw_args) { + enum { ARG_sample, ARG_loop, ARG_after }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_sample, MP_ARG_OBJ | MP_ARG_REQUIRED, {.u_obj = MP_ROM_NONE} }, + { MP_QSTR_loop, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_after, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + audioroute_port_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, + MP_ARRAY_SIZE(allowed_args), allowed_args, args); audiosample_check_for_deinit(&self->base); + mp_obj_t sample_in = args[ARG_sample].u_obj; if (sample_in == mp_const_none) { mp_raise_ValueError(MP_ERROR_TEXT("a Port needs a source")); } + const mp_int_t after = args[ARG_after].u_int; + if (after < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("after must be 0 or more")); + } // Refuses a rate, channel count, depth or signedness that differs. A // Component's output already has to match its source for the constructor // to accept it, so the Mix-0 bypass -- the port pointed straight at the // borrowed source -- passes; anything that would have been a different // format downstream is caught here instead of being heard. audiosample_must_match(&self->base, sample_in, false); - const audiosample_base_t *sample = MP_OBJ_TO_PTR(sample_in); - const uint32_t max_buffer_length = sample->max_buffer_length; - const bool single_buffer = sample->single_buffer; audiodsp_pump_lock_acquire(); - self->source = sample_in; - self->base.max_buffer_length = max_buffer_length; - self->base.single_buffer = single_buffer; + if (after > 0 && self->pulled) { + self->switch_to = sample_in; + self->switch_left = (uint32_t)after; + } else { + self->switch_to = MP_OBJ_NULL; + self->switch_left = 0; + audioroute_port_switch(self, sample_in); + } audiodsp_pump_lock_release(); return mp_const_none; } -static MP_DEFINE_CONST_FUN_OBJ_2(audioroute_port_play_obj, +static MP_DEFINE_CONST_FUN_OBJ_KW(audioroute_port_play_obj, 1, audioroute_port_play); // Zero copy, and that is the point: the source's own pointer, its own length @@ -101,6 +143,15 @@ static audioio_get_buffer_result_t audioroute_port_get_buffer( mp_obj_t self_in, bool single_channel_output, uint8_t channel, uint8_t **buffer, uint32_t *buffer_length) { audioroute_port_obj_t *self = MP_OBJ_TO_PTR(self_in); + // A switch left waiting whose frames have all been handed on happens + // here, before this pull reads anything. Until then `source` is still + // the old one, so a `play()` that comes first finds nothing switched. + const bool counts = !single_channel_output || channel == 0; + if (counts && self->switch_to != MP_OBJ_NULL && self->switch_left == 0u) { + mp_obj_t waiting = self->switch_to; + self->switch_to = MP_OBJ_NULL; + audioroute_port_switch(self, waiting); + } // One read of one word. Whatever `play()` does to it, this sees one of // the two values and never half of either. mp_obj_t source = self->source; @@ -138,6 +189,19 @@ static audioio_get_buffer_result_t audioroute_port_get_buffer( const audioio_get_buffer_result_t got = audiosample_get_buffer(source, single_channel_output, channel, buffer, buffer_length); self->in_pull = false; + self->pulled = true; + // A switch left waiting counts the frames this pull handed on, once a + // block (channel 0 of a single-channel pull); the next pull after its + // count runs out makes it. + if (counts && self->switch_to != MP_OBJ_NULL) { + uint32_t width = self->base.bits_per_sample / 8u; + if (!single_channel_output) { + width *= self->base.channel_count; + } + const uint32_t frames = width ? *buffer_length / width : 0u; + self->switch_left -= frames < self->switch_left ? frames + : self->switch_left; + } return got; } @@ -200,6 +264,8 @@ static mp_obj_t audioroute_port_deinit(mp_obj_t self_in) { audiodsp_pump_lock_acquire(); audiosample_mark_deinit(&self->base); self->source = mp_const_none; + self->switch_to = MP_OBJ_NULL; + self->switch_left = 0; audiodsp_pump_lock_release(); return mp_const_none; } @@ -221,8 +287,12 @@ static MP_DEFINE_CONST_DICT(audioroute_port_locals, // audiodsp#112: the pump refuses a file-backed source at handover, // and the protocol had no way to look past the tail. static mp_obj_t audioroute_port_sources(mp_obj_t self_in, uint8_t index) { - return index == 0 ? ((audioroute_port_obj_t *)MP_OBJ_TO_PTR(self_in))->source - : MP_OBJ_NULL; + const audioroute_port_obj_t *self = MP_OBJ_TO_PTR(self_in); + if (index == 0) { + return self->source; + } + // A switch left waiting is a source too: a graph walk has to reach it. + return index == 1 ? self->switch_to : MP_OBJ_NULL; } static const audiosample_p_t audioroute_port_proto = { diff --git a/src/audioroute/Port.h b/src/audioroute/Port.h index 516704e4..d805bdf2 100644 --- a/src/audioroute/Port.h +++ b/src/audioroute/Port.h @@ -47,6 +47,16 @@ typedef struct { //: native. One byte, read and written on whichever thread is pulling, //: under the lock the funnel already holds. bool in_pull; + //: A switch `play(sample, after=frames)` left waiting: `switch_to` takes + //: over at the start of the first pull after `switch_left` more frames + //: have been pulled through the port. MP_OBJ_NULL when + //: none is waiting. Read and written under the pump lock. + mp_obj_t switch_to; + uint32_t switch_left; + //: Whether the port has been pulled since it was pointed at its + //: source (a `play()` of the same source again keeps it): a + //: `play(sample, after=frames)` on one that has not switches at once. + bool pulled; } audioroute_port_obj_t; extern const mp_obj_type_t audioroute_port_type; diff --git a/src/circuitpython_spike/shared-bindings/audioroute/Port.c b/src/circuitpython_spike/shared-bindings/audioroute/Port.c index bb78e0da..b6b0a495 100644 --- a/src/circuitpython_spike/shared-bindings/audioroute/Port.c +++ b/src/circuitpython_spike/shared-bindings/audioroute/Port.c @@ -81,13 +81,27 @@ static mp_obj_t audioroute_port_make_new(const mp_obj_type_t *type, audioroute_port_obj_t *self = mp_obj_malloc(audioroute_port_obj_t, type); audioroute_port_adopt_format(self, sample); self->source = args[ARG_source].u_obj; + self->switch_to = MP_OBJ_NULL; + self->switch_left = 0; + self->pulled = false; return MP_OBJ_FROM_PTR(self); } -//| def play(self, sample: circuitpython_typing.AudioSample) -> None: +//| def play(self, sample: circuitpython_typing.AudioSample, *, +//| loop: bool = False, after: int = 0) -> None: //| """Re-point the wire. Whatever holds this ``Port`` now hears //| ``sample``, without being told and without changing what it -//| holds.""" +//| holds. +//| +//| With ``after`` a number of frames, the port plays what it plays +//| now for that many more frames and switches at the start of the +//| next pull: time for a level the old source is walking to land +//| before the new one takes over. Until that pull ``source`` is still +//| the old one, and a ``play()`` replaces the switch that was +//| waiting. A port nothing has pulled since it was pointed at what +//| it plays now switches at once: that has not started, so there is +//| nothing to finish. Pointed at the same source again, the port has +//| not started anything new.""" //| ... // // Re-point the port. This is the whole node. @@ -102,25 +116,44 @@ static mp_obj_t audioroute_port_make_new(const mp_obj_type_t *type, // // `play` rather than `retarget` or `source =` because that is the verb the // whole palette already uses for "this is what you play now". -static mp_obj_t audioroute_port_play(mp_obj_t self_in, mp_obj_t sample_in) { - audioroute_port_obj_t *self = MP_OBJ_TO_PTR(self_in); +static mp_obj_t audioroute_port_play(size_t n_args, const mp_obj_t *pos_args, + mp_map_t *kw_args) { + enum { ARG_sample, ARG_loop, ARG_after }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_sample, MP_ARG_OBJ | MP_ARG_REQUIRED, {.u_obj = MP_ROM_NONE} }, + { MP_QSTR_loop, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_after, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + }; + audioroute_port_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, + MP_ARRAY_SIZE(allowed_args), allowed_args, args); audiosample_check_for_deinit(&self->base); + mp_obj_t sample_in = args[ARG_sample].u_obj; if (sample_in == mp_const_none) { mp_raise_ValueError(MP_ERROR_TEXT("a Port needs a source")); } + const mp_int_t after = args[ARG_after].u_int; + if (after < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("after must be 0 or more")); + } // Refuses a rate, channel count, depth or signedness that differs. A // Component's output already has to match its source for the constructor // to accept it, so the Mix-0 bypass -- the port pointed straight at the // borrowed source -- passes; anything that would have been a different // format downstream is caught here instead of being heard. audiosample_must_match(&self->base, sample_in, false); - const audiosample_base_t *sample = MP_OBJ_TO_PTR(sample_in); - self->source = sample_in; - self->base.max_buffer_length = sample->max_buffer_length; - self->base.single_buffer = sample->single_buffer; + if (after > 0 && self->pulled) { + self->switch_to = sample_in; + self->switch_left = (uint32_t)after; + return mp_const_none; + } + self->switch_to = MP_OBJ_NULL; + self->switch_left = 0; + audioroute_port_switch(self, sample_in); return mp_const_none; } -MP_DEFINE_CONST_FUN_OBJ_2(audioroute_port_play_obj, audioroute_port_play); +MP_DEFINE_CONST_FUN_OBJ_KW(audioroute_port_play_obj, 1, audioroute_port_play); //| source: circuitpython_typing.AudioSample //| """What the port is playing right now. Read-only: `play` is how it @@ -152,6 +185,8 @@ static mp_obj_t audioroute_port_deinit(mp_obj_t self_in) { audioroute_port_obj_t *self = MP_OBJ_TO_PTR(self_in); audiosample_mark_deinit(&self->base); self->source = mp_const_none; + self->switch_to = MP_OBJ_NULL; + self->switch_left = 0; return mp_const_none; } MP_DEFINE_CONST_FUN_OBJ_1(audioroute_port_deinit_obj, audioroute_port_deinit); diff --git a/src/circuitpython_spike/shared-module/audioroute/Port.c b/src/circuitpython_spike/shared-module/audioroute/Port.c index 1176eb50..5c00a9d2 100644 --- a/src/circuitpython_spike/shared-module/audioroute/Port.c +++ b/src/circuitpython_spike/shared-module/audioroute/Port.c @@ -12,6 +12,15 @@ audioio_get_buffer_result_t audioroute_port_get_buffer( audioroute_port_obj_t *self, bool single_channel_output, uint8_t channel, uint8_t **buffer, uint32_t *buffer_length) { + // A switch left waiting whose frames have all been handed on happens + // here, before this pull reads anything. Until then `source` is still + // the old one, so a `play()` that comes first finds nothing switched. + const bool counts = !single_channel_output || channel == 0; + if (counts && self->switch_to != MP_OBJ_NULL && self->switch_left == 0u) { + mp_obj_t waiting = self->switch_to; + self->switch_to = MP_OBJ_NULL; + audioroute_port_switch(self, waiting); + } // One read of one word. Whatever `play()` does to it, this sees one of // the two values and never half of either. mp_obj_t source = self->source; @@ -27,8 +36,34 @@ audioio_get_buffer_result_t audioroute_port_get_buffer( // build the funnel is stock `shared-module/audiocore/__init__.c` and it // still raises exactly where CircuitPython raises -- there is no fault // register here, because there is no thread that cannot raise. - return audiosample_get_buffer(source, single_channel_output, channel, - buffer, buffer_length); + const audioio_get_buffer_result_t got = audiosample_get_buffer(source, + single_channel_output, channel, buffer, buffer_length); + self->pulled = true; + // A switch left waiting counts the frames this pull handed on, once a + // block (channel 0 of a single-channel pull); the next pull after its + // count runs out makes it. + if (counts && self->switch_to != MP_OBJ_NULL) { + uint32_t width = self->base.bits_per_sample / 8u; + if (!single_channel_output) { + width *= self->base.channel_count; + } + const uint32_t frames = width ? *buffer_length / width : 0u; + self->switch_left -= frames < self->switch_left ? frames + : self->switch_left; + } + return got; +} + +void audioroute_port_switch(audioroute_port_obj_t *self, mp_obj_t sample_in) { + const audiosample_base_t *sample = MP_OBJ_TO_PTR(sample_in); + // The same source again goes on playing the same stream, so whether + // it has started stands. + if (self->source != sample_in) { + self->pulled = false; + } + self->source = sample_in; + self->base.max_buffer_length = sample->max_buffer_length; + self->base.single_buffer = sample->single_buffer; } // Forwarded, and it has to be. diff --git a/src/circuitpython_spike/shared-module/audioroute/Port.h b/src/circuitpython_spike/shared-module/audioroute/Port.h index f4f1ca73..574c0100 100644 --- a/src/circuitpython_spike/shared-module/audioroute/Port.h +++ b/src/circuitpython_spike/shared-module/audioroute/Port.h @@ -29,8 +29,20 @@ typedef struct { //: What the port currently plays. One aligned word, so a pull either //: reads the whole old source or the whole new one. mp_obj_t source; + //: A switch `play(sample, after=frames)` left waiting: `switch_to` takes + //: over at the start of the first pull after `switch_left` more frames + //: have been pulled through the port. MP_OBJ_NULL when + //: none is waiting. + mp_obj_t switch_to; + uint32_t switch_left; + //: Whether the port has been pulled since it was pointed at its + //: source (a `play()` of the same source again keeps it): a + //: `play(sample, after=frames)` on one that has not switches at once. + bool pulled; } audioroute_port_obj_t; +void audioroute_port_switch(audioroute_port_obj_t *self, mp_obj_t sample); + void audioroute_port_reset_buffer(audioroute_port_obj_t *self, bool single_channel_output, uint8_t channel); audioio_get_buffer_result_t audioroute_port_get_buffer( diff --git a/src/cpython/audiobiquad.py b/src/cpython/audiobiquad.py index 79ac49e8..49959bab 100644 --- a/src/cpython/audiobiquad.py +++ b/src/cpython/audiobiquad.py @@ -47,7 +47,8 @@ A `Biquad` whose `frequency`, `Q`, `gain_db` or `mode` changes while it is playing walks to the new filter over about 20 ms rather than stepping, so a jump in one call does not ring its old memory through the new section at -full scale (audiocomponents#162). `mix` is not walked. +full scale (audiocomponents#162). A `mix` move on either node walks to +the new blend in a straight line over 20 ms. Two things this does that the ported nodes cannot: diff --git a/src/cpython/audioroute.py b/src/cpython/audioroute.py index a0013998..3ce8db1e 100644 --- a/src/cpython/audioroute.py +++ b/src/cpython/audioroute.py @@ -310,6 +310,9 @@ def __init__(self, source): raise ValueError("a Port needs a source") self._deinited = False self._source = source + self._switch_to = None + self._switch_left = 0 + self._pulled = False self._adopt(source) def _adopt(self, sample): @@ -335,16 +338,41 @@ def source(self): self._check() return self._source - def play(self, sample, *, loop=False): + def play(self, sample, *, loop=False, after=0): """Re-point the port. On the native build this is the swap the pump - lock protects; here there is no pump, so it is one store.""" + lock protects; here there is no pump, so it is one store. + + With `after` a number of frames, the port plays what it plays now + for that many more frames and switches at the start of the next + pull: time for a level the old source is walking to land before the + new one takes over. Until that pull, `source` is still the old one, + and a `play()` replaces the switch that was waiting. A port nothing + has pulled since it was pointed at what it plays now switches at + once: that has not started, so there is nothing to finish. Pointed + at the same source again, the port has not started anything new.""" self._check() if sample is None: raise ValueError("a Port needs a source") + after = int(after) + if after < 0: + raise ValueError("after must be 0 or more") for field in ("sample_rate", "bits_per_sample", "channel_count", "samples_signed"): if getattr(sample, field) != getattr(self, field): raise ValueError("The sample's %s does not match" % field) + if after and self._pulled: + self._switch_to = sample + self._switch_left = after + return + self._switch_to = None + self._switch_left = 0 + self._switch(sample) + + def _switch(self, sample): + if sample is not self._source: + # The same source again goes on playing the same stream, so + # whether it has started stands. + self._pulled = False self._source = sample self.single_buffer = bool(getattr(sample, "single_buffer", False)) self.max_buffer_length = int(getattr(sample, "max_buffer_length", 0)) @@ -353,6 +381,8 @@ def _release(self): # Releasing a port releases the reference, not the graph: the nodes # behind it belong to the class that built them. self._source = None + self._switch_to = None + self._switch_left = 0 def _reset_buffer(self, single_channel_output=False, audio_channel=0): self._check() @@ -367,10 +397,24 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): self._check() if self._source is None: return GET_BUFFER_ERROR, memoryview(b"") + first = not single_channel_output or audio_channel == 0 + if first and self._switch_to is not None and not self._switch_left: + waiting = self._switch_to + self._switch_to = None + self._switch(waiting) # `_borrow`, not `get_buffer`: the native hands back the source's own # pointer, so a copy here would make this target's borrowers see a # snapshot the native ones do not. See `audiocore._AudioSample._publish`. - return _borrow(self._source, single_channel_output, audio_channel) + result, data = _borrow(self._source, single_channel_output, + audio_channel) + self._pulled = True + if first and self._switch_to is not None: + width = self.bits_per_sample // 8 + if not single_channel_output: + width *= self.channel_count + frames = getattr(data, "nbytes", len(data)) // width + self._switch_left -= min(frames, self._switch_left) + return result, data __all__ = ("MidSide", "Port", "Splitter", "SplitterTap") diff --git a/src/shared/audiodsp_dynamics.c b/src/shared/audiodsp_dynamics.c index 69ce6d3e..51d810d8 100644 --- a/src/shared/audiodsp_dynamics.c +++ b/src/shared/audiodsp_dynamics.c @@ -278,6 +278,8 @@ void audiodsp_dynamics_clear_detector(audiodsp_dynamics_state_t *state) { state->smoothed_gain = 0.0f; state->gain_smooth_primed = false; memset(state->peak_history, 0, sizeof(state->peak_history)); + // The makeup gain's walk: a fresh node takes its makeup whole. + audiodsp_level_ramp_clear(&state->makeup_ramp); audiodsp_dynamics_clear_extras(state); } @@ -531,7 +533,11 @@ void audiodsp_dynamics_process_s16_key(const audiodsp_dynamics_config_t *config, ? audiodsp_dynamics_db_to_gain(config->threshold_db) : 0.0f; const uint32_t channels = config->channel_count == 1u ? 1u : 2u; + audiodsp_level_ramp_aim(&state->makeup_ramp, config->makeup_gain, + audiodsp_level_ramp_frames(config->sample_rate, + AUDIODSP_LEVEL_RAMP_MS)); while (frames-- != 0) { + const float makeup = audiodsp_level_ramp_next(&state->makeup_ramp); float source[2] = {0.0f, 0.0f}; for (uint32_t channel = 0; channel < channels; ++channel) { source[channel] = (float)input[channel] / 32768.0f; @@ -759,7 +765,7 @@ void audiodsp_dynamics_process_s16_key(const audiodsp_dynamics_config_t *config, : config->sustain_gain_db * -norm; } state->gain_reduction_db = gain_db; - gain = audiodsp_dynamics_db_to_gain(gain_db) * config->makeup_gain; + gain = audiodsp_dynamics_db_to_gain(gain_db) * makeup; } else if (gate_machine) { // Closed -> attack -> hold -> decay, on the same peak level. Once // ATTACK has been entered it runs to unity whatever the key does @@ -807,7 +813,7 @@ void audiodsp_dynamics_process_s16_key(const audiodsp_dynamics_config_t *config, } state->gain_reduction_db = audiodsp_dynamics_gain_to_db(state->gate_gain); - gain = state->gate_gain * config->makeup_gain; + gain = state->gate_gain * makeup; } else { const bool rising = level > state->envelope; float coef = rising ? config->attack_coef : config->release_coef; @@ -841,7 +847,7 @@ void audiodsp_dynamics_process_s16_key(const audiodsp_dynamics_config_t *config, } const float gain_db = dynamics_gain_db(config, env_db); state->gain_reduction_db = gain_db; - gain = audiodsp_dynamics_db_to_gain(gain_db) * config->makeup_gain; + gain = audiodsp_dynamics_db_to_gain(gain_db) * makeup; } // The gain smoother sits here, after every one of the gain // computers above and before the multiply, so one place covers all diff --git a/src/shared/audiodsp_dynamics.h b/src/shared/audiodsp_dynamics.h index cf65a83f..8a0685c6 100644 --- a/src/shared/audiodsp_dynamics.h +++ b/src/shared/audiodsp_dynamics.h @@ -18,6 +18,8 @@ #include #include +#include "shared/audiodsp_level_ramp.h" + typedef enum { AUDIODSP_DYNAMICS_COMPRESS = 0, AUDIODSP_DYNAMICS_LIMIT = 1, @@ -246,6 +248,10 @@ typedef struct { // reset. float smoothed_gain; bool gain_smooth_primed; + // The makeup gain as the output stands, walking to a new `makeup_db` + // over AUDIODSP_LEVEL_RAMP_MS instead of stepping there. A reset clears + // it, with the detector (audiodsp_dynamics_clear_detector). + audiodsp_level_ramp_t makeup_ramp; } audiodsp_dynamics_state_t; //: Where the gate's four-stage envelope is. CLOSED sits at the floor, ATTACK diff --git a/src/shared/audiodsp_feedback_delay.c b/src/shared/audiodsp_feedback_delay.c index 87e68594..4fdf6ea5 100644 --- a/src/shared/audiodsp_feedback_delay.c +++ b/src/shared/audiodsp_feedback_delay.c @@ -362,6 +362,7 @@ void audiodsp_feedback_delay_state_init(audiodsp_feedback_delay_state_t *state, memset(state->band_now, 0, sizeof(state->band_now)); state->resting = false; state->quiet_frames = 0; + audiodsp_level_ramp_clear(&state->mix_ramp); } void audiodsp_feedback_delay_reset(audiodsp_feedback_delay_state_t *state, @@ -800,8 +801,11 @@ static void feedback_delay_run( audiodsp_feedback_delay_state_t *state, int16_t *out, const int16_t *in, uint32_t frames) { const uint32_t length = config->line_frames; - const float dry = 2.0f - config->mix < 1.0f ? 2.0f - config->mix : 1.0f; - const float wet = config->mix < 1.0f ? config->mix : 1.0f; + audiodsp_level_ramp_aim(&state->mix_ramp, config->mix, + audiodsp_level_ramp_frames(config->sample_rate, + AUDIODSP_LEVEL_RAMP_MS)); + float dry = 2.0f - config->mix < 1.0f ? 2.0f - config->mix : 1.0f; + float wet = config->mix < 1.0f ? config->mix : 1.0f; const float direct = 1.0f - config->cross_feed; const float crossed = config->cross_feed; const float slew = config->delay_slew_frames; @@ -961,6 +965,11 @@ static void feedback_delay_run( // The last frame's tap per channel, for the damping landing below. float tapped[2] = { 0.0f, 0.0f }; for (uint32_t frame = 0; frame < frames; ++frame) { + if (audiodsp_level_ramp_moving(&state->mix_ramp)) { + const float mix = audiodsp_level_ramp_next(&state->mix_ramp); + dry = 2.0f - mix < 1.0f ? 2.0f - mix : 1.0f; + wet = mix < 1.0f ? mix : 1.0f; + } if (band_moving) { if (frame + 1u == frames) { memcpy(state->band_now, config->band_coef, diff --git a/src/shared/audiodsp_feedback_delay.h b/src/shared/audiodsp_feedback_delay.h index 2457ef8b..7d5cdcbf 100644 --- a/src/shared/audiodsp_feedback_delay.h +++ b/src/shared/audiodsp_feedback_delay.h @@ -59,6 +59,8 @@ #include #include +#include "shared/audiodsp_level_ramp.h" + //: Frames one output block carries. The same 256 audiodynamics and audiomath //: use, so a chain built out of several of them moves in one block size. #define AUDIODSP_FEEDBACK_DELAY_FRAMES 256u @@ -245,6 +247,10 @@ typedef struct { // the state (audiodsp#180). bool resting; uint32_t quiet_frames; + // `mix` as the output stands, walking to a new value over + // AUDIODSP_LEVEL_RAMP_MS instead of stepping there. A reset clears it, + // so the first block after one lands at `mix` as a fresh node's does. + audiodsp_level_ramp_t mix_ramp; } audiodsp_feedback_delay_state_t; //: How long a change of `wow_depth_ms` takes to land by default: 20 ms, a diff --git a/src/shared/audiodsp_filter_f32.c b/src/shared/audiodsp_filter_f32.c index ce4f9be2..01ab4fef 100644 --- a/src/shared/audiodsp_filter_f32.c +++ b/src/shared/audiodsp_filter_f32.c @@ -469,25 +469,10 @@ static uint32_t biquad_process_move(audiodsp_biquad_f32_state_t *state, return run; } -void audiodsp_biquad_f32_process_s16(const audiodsp_biquad_f32_config_t *config, - audiodsp_biquad_f32_state_t *state, int16_t *out, const int16_t *in, - uint32_t frames) { - const uint32_t channels = config->channel_count == 1u ? 1u : 2u; - const float mix = config->mix; - const float dry = 1.0f - mix; - if (!state->primed) { - biquad_take(state, config); - state->primed = true; - } else if (biquad_moved(state, config)) { - biquad_begin_move(config, state, channels); - } - if (state->ramp_left != 0u) { - const uint32_t done = biquad_process_move(state, channels, dry, mix, - out, in, frames); - out += (size_t)done * channels; - in += (size_t)done * channels; - frames -= done; - } +// Up to `frames` frames of the section in force, direct form. +static void biquad_process_steady(const audiodsp_biquad_f32_config_t *config, + audiodsp_biquad_f32_state_t *state, uint32_t channels, float dry, + float mix, int16_t *out, const int16_t *in, uint32_t frames) { const float b0 = config->b0, b1 = config->b1, b2 = config->b2; const float a1 = config->a1, a2 = config->a2; for (uint32_t frame = 0; frame < frames; ++frame) { @@ -509,6 +494,48 @@ void audiodsp_biquad_f32_process_s16(const audiodsp_biquad_f32_config_t *config, } } +// Up to `frames` frames at one mix: through a coefficient walk while one +// runs, then the section in force. +static void biquad_process_at(const audiodsp_biquad_f32_config_t *config, + audiodsp_biquad_f32_state_t *state, uint32_t channels, float dry, + float mix, int16_t *out, const int16_t *in, uint32_t frames) { + if (state->ramp_left != 0u) { + const uint32_t done = biquad_process_move(state, channels, dry, mix, + out, in, frames); + out += (size_t)done * channels; + in += (size_t)done * channels; + frames -= done; + } + biquad_process_steady(config, state, channels, dry, mix, out, in, frames); +} + +void audiodsp_biquad_f32_process_s16(const audiodsp_biquad_f32_config_t *config, + audiodsp_biquad_f32_state_t *state, int16_t *out, const int16_t *in, + uint32_t frames) { + const uint32_t channels = config->channel_count == 1u ? 1u : 2u; + if (!state->primed) { + biquad_take(state, config); + state->primed = true; + } else if (biquad_moved(state, config)) { + biquad_begin_move(config, state, channels); + } + audiodsp_level_ramp_aim(&state->mix_ramp, config->mix, + audiodsp_level_ramp_frames(config->sample_rate, config->ramp_ms)); + // While the mix walks, a frame at a time at the walk's mix; then the + // rest of the block at the mix in force, which is the config's. + while (frames != 0u && audiodsp_level_ramp_moving(&state->mix_ramp)) { + const float mix = audiodsp_level_ramp_next(&state->mix_ramp); + biquad_process_at(config, state, channels, 1.0f - mix, mix, out, in, + 1u); + out += channels; + in += channels; + --frames; + } + const float mix = config->mix; + biquad_process_at(config, state, channels, 1.0f - mix, mix, out, in, + frames); +} + // -------------------------------------------------------------- all-pass -- void audiodsp_allpass_f32_config_init(audiodsp_allpass_f32_config_t *config, @@ -590,6 +617,7 @@ void audiodsp_allpass_f32_state_init(audiodsp_allpass_f32_state_t *state, state->loop[0] = state->loop[1] = 0.0f; state->coefficient = 0.0f; state->primed = false; + audiodsp_level_ramp_clear(&state->mix_ramp); if (state->stage_state != NULL) { memset(state->stage_state, 0, (size_t)state->stage_state_count * sizeof(float)); @@ -614,8 +642,11 @@ void audiodsp_allpass_f32_process_s16( } return; } - const float mix = config->mix; - const float dry = 1.0f - mix; + audiodsp_level_ramp_aim(&state->mix_ramp, config->mix, + audiodsp_level_ramp_frames(config->sample_rate, + AUDIODSP_LEVEL_RAMP_MS)); + float mix = config->mix; + float dry = 1.0f - mix; const float feedback = config->feedback; const float target = config->coefficient; // The block's value arrives at the block boundary; the coefficient walks @@ -632,6 +663,10 @@ void audiodsp_allpass_f32_process_s16( for (uint32_t frame = 0; frame < frames; ++frame) { const float c = coefficient; coefficient += step; + if (audiodsp_level_ramp_moving(&state->mix_ramp)) { + mix = audiodsp_level_ramp_next(&state->mix_ramp); + dry = 1.0f - mix; + } for (uint32_t channel = 0; channel < channels; ++channel) { const size_t index = (size_t)frame * channels + channel; const float source = (float)in[index]; diff --git a/src/shared/audiodsp_filter_f32.h b/src/shared/audiodsp_filter_f32.h index 184c06f9..6434897c 100644 --- a/src/shared/audiodsp_filter_f32.h +++ b/src/shared/audiodsp_filter_f32.h @@ -46,6 +46,8 @@ #include #include +#include "shared/audiodsp_level_ramp.h" + //: Frames one output block carries. The same 256 audiodynamics, audiomath and //: audioecho use, so a chain built out of several of them moves in one block //: size. @@ -185,6 +187,10 @@ typedef struct { // False until the first block, which takes the config as it stands -- // what the node was built with is not a move. bool primed; + // `mix` as the output stands, walking to a new value over `ramp_ms` + // (rounded to the nearest frame) instead of stepping there. A reset + // clears it with the rest, so the first block after one lands at once. + audiodsp_level_ramp_t mix_ramp; } audiodsp_biquad_f32_state_t; //: How long a coefficient move takes unless AUDIODSP_BIQUAD_F32_OPT_RAMP says @@ -264,6 +270,9 @@ typedef struct { // block-rate step is what makes the ported phaser's sweep buzz. float coefficient; bool primed; + // `mix` as the output stands, walking to a new value over + // AUDIODSP_LEVEL_RAMP_MS instead of stepping there. A reset clears it. + audiodsp_level_ramp_t mix_ramp; } audiodsp_allpass_f32_state_t; void audiodsp_allpass_f32_config_init(audiodsp_allpass_f32_config_t *config, diff --git a/src/shared/audiodsp_level_ramp.h b/src/shared/audiodsp_level_ramp.h new file mode 100644 index 00000000..55aba84c --- /dev/null +++ b/src/shared/audiodsp_level_ramp.h @@ -0,0 +1,93 @@ +// A gain that walks to a new value instead of stepping to it. +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices +// +// A node that scales its signal by a control -- a mix, a post gain, a makeup +// gain -- and takes a new value at the next block steps its output there, +// and a step is a click. With one of these per gain the node walks to the new +// value in a straight line over `frames` frames instead, one step a frame, +// and the last step lands exactly on the value asked for. +// +// A node's first value is not walked: it is what the node was built at, and +// a fresh node renders its first block at it. A reset clears the ramp, so the +// first block after one lands the same way. A move during a walk starts a +// new walk from where the gain stands. `frames` 0 steps at once, which is +// the node before the ramp. +// +// The arithmetic is float and the same on every target: one subtraction and +// one division when a move arrives, one addition a frame while it runs. + +#pragma once + +#include +#include + +//: How long a level move takes when the node is not told otherwise, in +//: milliseconds: long enough not to click, short enough to sound like the +//: knob that moved. +#define AUDIODSP_LEVEL_RAMP_MS 20.0f + +typedef struct { + float current; + float target; + float step; + uint32_t left; + bool primed; +} audiodsp_level_ramp_t; + +//: Frames in a `ramp_ms` walk at `sample_rate`, rounded to the nearest. +static inline uint32_t audiodsp_level_ramp_frames(uint32_t sample_rate, + float ramp_ms) { + if (!(ramp_ms > 0.0f)) { + return 0u; + } + const float frames = (float)sample_rate * ramp_ms / 1000.0f + 0.5f; + return frames < 1.0f ? 1u : (uint32_t)frames; +} + +//: Forget the walk: the next `aim` lands at once, as a fresh node's does. +static inline void audiodsp_level_ramp_clear(audiodsp_level_ramp_t *ramp) { + ramp->current = 0.0f; + ramp->target = 0.0f; + ramp->step = 0.0f; + ramp->left = 0u; + ramp->primed = false; +} + +//: Hand the ramp the value the node is asked for now, once a block before +//: the block's frames are rendered. +static inline void audiodsp_level_ramp_aim(audiodsp_level_ramp_t *ramp, + float target, uint32_t frames) { + if (!ramp->primed || frames == 0u) { + ramp->current = target; + ramp->target = target; + ramp->left = 0u; + ramp->primed = true; + return; + } + if (target == ramp->target) { + return; + } + ramp->target = target; + ramp->step = (target - ramp->current) / (float)frames; + ramp->left = frames; +} + +//: Whether a walk is under way: when it is not, `current` is the target and +//: a node may take its unramped path. +static inline bool audiodsp_level_ramp_moving( + const audiodsp_level_ramp_t *ramp) { + return ramp->left != 0u; +} + +//: The gain for the next frame. +static inline float audiodsp_level_ramp_next(audiodsp_level_ramp_t *ramp) { + if (ramp->left != 0u) { + if (--ramp->left == 0u) { + ramp->current = ramp->target; + } else { + ramp->current += ramp->step; + } + } + return ramp->current; +} diff --git a/src/shared/audiodsp_shaper.c b/src/shared/audiodsp_shaper.c index 26efba51..326ac921 100644 --- a/src/shared/audiodsp_shaper.c +++ b/src/shared/audiodsp_shaper.c @@ -289,9 +289,20 @@ void audiodsp_shaper_process_s16(const audiodsp_shaper_config_t *config, uint32_t frames) { const uint32_t channels = config->channel_count == 1u ? 1u : 2u; const uint32_t stages = config->stages; - const float wet_gain = config->mix; - const float dry_gain = 1.0f - config->mix; + const uint32_t ramp = audiodsp_level_ramp_frames(config->sample_rate, + AUDIODSP_LEVEL_RAMP_MS); + audiodsp_level_ramp_aim(&state->post_ramp, config->post_gain, ramp); + audiodsp_level_ramp_aim(&state->mix_ramp, config->mix, ramp); + float post_gain = config->post_gain; + float wet_gain = config->mix; + float dry_gain = 1.0f - config->mix; for (uint32_t frame = 0; frame < frames; ++frame) { + if (audiodsp_level_ramp_moving(&state->post_ramp) || + audiodsp_level_ramp_moving(&state->mix_ramp)) { + post_gain = audiodsp_level_ramp_next(&state->post_ramp); + wet_gain = audiodsp_level_ramp_next(&state->mix_ramp); + dry_gain = 1.0f - wet_gain; + } for (uint32_t channel = 0; channel < channels; ++channel) { const int16_t raw = in[frame * channels + channel]; if (raw != 0) { @@ -330,7 +341,7 @@ void audiodsp_shaper_process_s16(const audiodsp_shaper_config_t *config, oversampled[i * 2u + 1u]); } } - const float wet = oversampled[0] * config->post_gain * 32768.0f; + const float wet = oversampled[0] * post_gain * 32768.0f; out[frame * channels + channel] = to_s16(dry_gain * source + wet_gain * wet); } diff --git a/src/shared/audiodsp_shaper.h b/src/shared/audiodsp_shaper.h index 14df951b..2ae3992f 100644 --- a/src/shared/audiodsp_shaper.h +++ b/src/shared/audiodsp_shaper.h @@ -62,6 +62,8 @@ #include #include +#include "shared/audiodsp_level_ramp.h" + //: Frames one output block carries. The same 256 audiodynamics, audiomath and //: audioecho use, so a chain built out of several of them moves in one block //: size. @@ -170,6 +172,12 @@ typedef struct { //: AUDIODSP_SHAPER_FRAMES. Read only by audiodsp_shaper_recentre, so it //: moves no rendered byte. uint32_t quiet[2]; + //: `post_gain` and `mix` as the output stands, walking to a new value + //: over AUDIODSP_LEVEL_RAMP_MS instead of stepping there. A reset clears + //: them, as it clears everything a fresh node starts without: the first + //: block after it lands at the controls as they stand. + audiodsp_level_ramp_t post_ramp; + audiodsp_level_ramp_t mix_ramp; } audiodsp_shaper_state_t; void audiodsp_shaper_config_init(audiodsp_shaper_config_t *config, diff --git a/tests/parity/delay_lifecycle_probe.py b/tests/parity/delay_lifecycle_probe.py index 6a8138a0..f20a3373 100644 --- a/tests/parity/delay_lifecycle_probe.py +++ b/tests/parity/delay_lifecycle_probe.py @@ -20,9 +20,10 @@ the node plays exactly what a node built at that delay plays. - **mix**: mix taken to 0 and brought back. While it is 0 the input passes through untouched. A node whose loop runs on at a mix of 0 then plays - exactly what a node left at that mix plays. `Echo` is upstream's and holds - its line still instead: what it plays after is what a node fed the same - source with the muted stretch cut out plays. + exactly what a node left at that mix plays. `FeedbackDelay` walks a mix + move over 20 ms, so both hold from where its walk lands. `Echo` is + upstream's and holds its line still instead: what it plays after is what a + node fed the same source with the muted stretch cut out plays. - **settings**: several settings before one pull. Settings split over several calls land as one call does, two settings land the same in either order, and a value set and replaced before the pull leaves no trace. @@ -110,6 +111,7 @@ class FeedbackDelay: delay = 160 # 20 ms at 8 kHz runs_on_at_mix_0 = True has_slew = True + mix_walk = 160 # a mix move walks over 20 ms def make(self, feedback=0.5, mix=0.5, delay_ms=20.0, **extra): return module.FeedbackDelay( @@ -293,8 +295,14 @@ def mix_event(kind): kind.set(node, mix=0.375) back = pulls(node, 8) print(tag, [checksum(b) for b in before + muted + back]) + # A node whose mix walks reaches the input once the walk has landed, + # and walks back from it. + walk = getattr(kind, "mix_walk", 0) check(tag + " passes the input at 0", - b"".join(muted) == bytes(values[BLOCK:BLOCK * 4])) + b"".join(muted)[walk * 2:] == bytes(values[BLOCK + walk:BLOCK * 4])) + if walk: + check(tag + " walks to 0", + b"".join(muted)[:walk * 2] != bytes(values[BLOCK:BLOCK + walk])) if kind.runs_on_at_mix_0: reference_values = values offset = 4 @@ -308,7 +316,10 @@ def mix_event(kind): reference.play(raw(reference_values)) expected = pulls(reference, offset + 8) check(tag + " comes back where the loop is", - back == expected[offset:]) + b"".join(back)[walk * 2:] == b"".join(expected[offset:])[walk * 2:]) + if walk: + check(tag + " walks back", + b"".join(back)[:walk * 2] != b"".join(expected[offset:])[:walk * 2]) node.deinit() reference.deinit() diff --git a/tests/parity/node_level_ramp_probe.py b/tests/parity/node_level_ramp_probe.py new file mode 100644 index 00000000..b45f70af --- /dev/null +++ b/tests/parity/node_level_ramp_probe.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""Node level ramps and `Port.play(after=)`, the same bytes on every target. + + node_level_ramp_probe.py audioshaper + + RAMP + PORT + +The Waveshaper's `mix` and `post_gain`, the Biquad's and the AllPass's `mix`, +the Dynamics node's makeup and the FeedbackDelay's `mix` walk a move over +20 ms (`src/shared/audiodsp_level_ramp.h`). Each is moved on a playing node, +moved before the first pull, and moved and then reset halfway through the +walk, mono and stereo, at 48 and 22.05 kHz. A Port told to switch `after` +some frames switches at the start of the next pull once they have played, +and at once when nothing has pulled it since it was pointed at what it plays; +pointing it at that same source again is not a new start. The nodes and the +Port are this package's own, and every target runs the same C for them. +""" + +import math +from array import array + +import audiobiquad +import audiocore +import audiodynamics +import audioecho +import audioroute +import audioshaper + +PULLS = 12 +MOVE_AT = 3 + + +def checksum(data): + value = 2166136261 + for byte in bytes(data): + value = ((value ^ byte) * 16777619) & 0xffffffff + return value + + +def clip_curve(points=1025): + last = points - 1 + out = array("h") + for i in range(points): + x = (-1.0 + 2.0 * i / last) * 4.0 + out.append(int(round(max(-1.0, min(1.0, x)) * 32767))) + return out + + +CLIP = clip_curve() + + +def tone(rate, channels): + # Whole cycles, and longer than PULLS pulls of 256 frames: a node's + # play() takes no loop on MicroPython. + period = (rate // 200) * 30 + values = array("h") + for i in range(period): + v = 12000 * math.sin(2 * math.pi * 200.0 * i / rate) + values.append(int(v)) + if channels == 2: + values.append(int(0.7 * v)) + return audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + + +def shaper(rate, channels, name, value): + options = {name: value} + return audioshaper.Waveshaper(sample_rate=rate, curve=CLIP, oversample=1, + channel_count=channels, **options) + + +def biquad(rate, channels, name, value): + return audiobiquad.Biquad(mode=audiobiquad.HIGH_PASS, frequency=400.0, + mix=value, sample_rate=rate, + channel_count=channels) + + +def allpass(rate, channels, name, value): + return audiobiquad.AllPass(stages=4, frequency=300.0, mix=value, + sample_rate=rate, channel_count=channels) + + +def dynamics(rate, channels, name, value): + return audiodynamics.Dynamics(audiodynamics.DYN_COMPRESS, + sample_rate=rate, channel_count=channels, + threshold_db=-24, ratio=4, makeup_db=value) + + +def delay(rate, channels, name, value): + return audioecho.FeedbackDelay(sample_rate=rate, max_delay_ms=20, + channel_count=channels, delay_ms=3.0, + feedback=0.3, mix=value) + + +def by_set(node, name, value): + options = {name: value} + node.set(**options) + + +def by_attr(node, name, value): + setattr(node, name, value) + + +CASES = ( + ("shaper-mix", shaper, by_set, "mix", 1.0, 0.2), + ("shaper-post", shaper, by_set, "post_gain", 0.9, 0.3), + ("biquad-mix", biquad, by_attr, "mix", 1.0, 0.0), + ("allpass-mix", allpass, by_attr, "mix", 0.5, 0.0), + ("dynamics-makeup", dynamics, by_set, "makeup_db", 0.0, 9.0), + ("delay-mix", delay, by_set, "mix", 0.0, 1.0), +) + + +def render(node, rate, channels, move_at, move, reset_at=None): + node.play(tone(rate, channels)) + sums = [] + for index in range(PULLS): + if index == move_at: + move(node) + if index == reset_at: + audiocore.reset_buffer(node) + sums.append(checksum(audiocore.get_buffer(node)[1])) + node.deinit() + return sums + + +for name, build, mover, control, old, new in CASES: + for rate in (48000, 22050): + for channels in (1, 2): + def move(node): + mover(node, control, new) + for tag, move_at, reset_at in (("move", MOVE_AT, None), + ("first", 0, None), + ("reset", MOVE_AT, MOVE_AT + 1)): + print("RAMP", "%s-%s-%d-%dch" % (name, tag, rate, channels), + render(build(rate, channels, control, old), rate, + channels, move_at, move, reset_at)) + + +def constant(value, frames=256): + return audiocore.RawSample(array("h", [value] * frames), + sample_rate=48000, channel_count=1) + + +def firsts(port, pulls): + out = [] + for _ in range(pulls): + out.append(array("h", bytes(audiocore.get_buffer(port)[1]))[0]) + return out + + +A, B, C = constant(1000), constant(2000), constant(3000) + +port = audioroute.Port(A) +seen = firsts(port, 1) +port.play(B, after=300) +print("PORT", "after-pulled", seen + firsts(port, 4)) + +port = audioroute.Port(A) +port.play(B, after=300) +print("PORT", "after-unpulled", firsts(port, 3)) + +port = audioroute.Port(A) +seen = firsts(port, 1) +port.play(B, after=300) +seen += firsts(port, 3) +port.play(C, after=300) +print("PORT", "after-switched", seen + firsts(port, 4)) + +port = audioroute.Port(A) +seen = firsts(port, 1) +port.play(B, after=600) +seen += firsts(port, 1) +port.play(C, after=300) +print("PORT", "after-replaced", seen + firsts(port, 4)) + +port = audioroute.Port(A) +seen = firsts(port, 1) +port.play(B, after=256) +print("PORT", "after-exact", seen + firsts(port, 3)) + +port = audioroute.Port(A) +seen = firsts(port, 1) +port.play(A) +port.play(B, after=300) +print("PORT", "after-same-again", seen + firsts(port, 4)) + +try: + audioroute.Port(A).play(B, after=-1) + print("PORT", "after-negative", "accepted") +except ValueError: + print("PORT", "after-negative", "ValueError") + +print("done node-level-ramp") diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index 356b9554..10e36548 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -208,6 +208,9 @@ "CircuitPython's - docs/how-it-works/upstream-diff.md, " "\"A level ramp on Mixer voices\""}, None), + # A node's mix, post gain or makeup walks a move over 20 ms, and a Port + # switches after the frames it is told to. + ("node_level_ramp_probe.py", "audioshaper", {}, None), ("tank_probe.py", "audioverb", {}, None), # The node's own state: Tone out and back through 0 (#168) and the # network re-cut in place on a playing node (#169). Its own file, so the diff --git a/tests/test_cpython_audioroute.py b/tests/test_cpython_audioroute.py index 9a20c947..9343293f 100644 --- a/tests/test_cpython_audioroute.py +++ b/tests/test_cpython_audioroute.py @@ -34,6 +34,7 @@ | R14 | A Port re-points only to a matching source, and forwards a reset | raises / exact | | R15 | A mono tap's buffer is the tap's own: its next pull rewrites it | exact | | R16 | `Splitter.clear()` drops what nobody read, and reads nothing | exact, frame for frame | +| R17 | `Port.play(after=)` plays the old source on for that many frames and switches at the start of the next pull; one nothing has pulled since it was pointed at its source switches at once, and pointing it at the same source again is not a new start | exact, pull for pull | R7 is this module's form of the identity trait (`docs/how-it-works/correctness-standard.md`): an exact answer through the DSP rather than a @@ -400,6 +401,45 @@ def test_play_re_points_and_refuses_a_mismatch(self): audioroute.Port(None) self.assertIs(port.source, second) + def test_play_after_waits_for_the_frames(self): + """R17: the switch waits for `after` frames, then for the next + pull; `source` is the old one until then, a `play()` before then + replaces it, and a port nothing has pulled switches at once. A + `play()` of the source it already plays is not a new start.""" + def constant(value): + return audiocore.RawSample(array("h", [value] * 512), + sample_rate=SAMPLE_RATE, + channel_count=2) + + def firsts(port, pulls): + return [array("h", bytes(audiocore.get_buffer(port)[1]))[0] + for _ in range(pulls)] + + a, b, c = constant(1000), constant(2000), constant(3000) + port = audioroute.Port(a) + port.play(b, after=300) + self.assertIs(port.source, b) + port = audioroute.Port(a) + firsts(port, 1) + port.play(b, after=256) + self.assertEqual(firsts(port, 1), [1000]) + self.assertIs(port.source, a) + self.assertEqual(firsts(port, 2), [2000, 2000]) + port = audioroute.Port(a) + firsts(port, 1) + port.play(b, after=300) + firsts(port, 1) + port.play(c, after=300) + self.assertEqual(firsts(port, 3), [1000, 1000, 3000]) + port = audioroute.Port(a) + firsts(port, 1) + port.play(a) + port.play(b, after=256) + self.assertIs(port.source, a) + self.assertEqual(firsts(port, 2), [1000, 2000]) + with self.assertRaises(ValueError): + port.play(a, after=-1) + def test_deinit_lets_the_source_go(self): port = audioroute.Port(source()) port.deinit() diff --git a/tests/test_cpython_node_level_ramps.py b/tests/test_cpython_node_level_ramps.py new file mode 100644 index 00000000..c4edd4bf --- /dev/null +++ b/tests/test_cpython_node_level_ramps.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""A node's level control walks to a new value over 20 ms instead of stepping. + +The Waveshaper's `mix` and `post_gain`, the Biquad's and the AllPass's `mix`, +the Dynamics node's makeup and the FeedbackDelay's `mix` each scale a signal +the node has already worked out. Moved on a playing node, each walks there in +a straight line over 20 ms (`audiodsp_level_ramp.h`), so the move cannot click. +None of these controls feeds the node's own state, so the walked output is +exactly the blend of two nodes that never moved: one held at the old value, +one at the new. That is what this holds them to. + +| what it checks | bar | +|---|---| +| a move walks in a straight line and lands | for each control, mono and stereo, at 48 and 22.05 kHz: up to the move, the bytes of a node that stayed at the old value; for 20 ms after it, that node's frame plus `(i + 1) / n` of the way to a node built at the new value, to within 2 LSB; from then on, that node's bytes exactly | +| a control set before the first pull is not walked | a node built at the old value and moved before anything pulled it renders the bytes of one built at the new value | +| a reset clears the walk | a node reset halfway through a walk renders, from the reset on, the bytes of a node built at the new value and reset at the same pull | + +The C in `src/shared` is the authority; this target runs the same C, so the +native builds are held to the same bytes by +`tests/parity/node_level_ramp_probe.py`. + + python -m unittest tests.test_cpython_node_level_ramps +""" + +import math +import unittest +from array import array + +import audiobiquad +import audiocore +import audiodynamics +import audioecho +import audioshaper + +MOVE_AT = 4 +PULLS = 16 + + +def clip_curve(threshold=0.25, points=1025): + """A hard clip at a quarter of full scale, so the wet path is far from + the dry one.""" + last = points - 1 + return array("h", [ + int(round(max(-1.0, min(1.0, (-1.0 + 2.0 * i / last) / threshold)) + * 32767)) + for i in range(points)]) + + +CLIP = clip_curve() + + +def tone(rate, channels): + """A looped 200 Hz sine, the right channel quieter than the left.""" + period = int(rate / 200.0) * 10 + values = array("h") + for i in range(period): + v = 12000 * math.sin(2 * math.pi * 200.0 * i / rate) + values.append(int(v)) + if channels == 2: + values.append(int(0.7 * v)) + return audiocore.RawSample(values, sample_rate=rate, + channel_count=channels) + + +def shaper(rate, channels, **options): + return audioshaper.Waveshaper(sample_rate=rate, curve=CLIP, oversample=1, + channel_count=channels, **options) + + +def biquad(rate, channels, mix): + return audiobiquad.Biquad(mode=audiobiquad.HIGH_PASS, frequency=400.0, + mix=mix, sample_rate=rate, + channel_count=channels) + + +def allpass(rate, channels, mix): + return audiobiquad.AllPass(stages=4, frequency=300.0, mix=mix, + sample_rate=rate, channel_count=channels) + + +def dynamics(rate, channels, makeup_db): + return audiodynamics.Dynamics(audiodynamics.DYN_COMPRESS, + sample_rate=rate, channel_count=channels, + threshold_db=-24, ratio=4, + makeup_db=makeup_db) + + +def delay(rate, channels, mix): + return audioecho.FeedbackDelay(sample_rate=rate, max_delay_ms=20, + channel_count=channels, delay_ms=3.0, + feedback=0.3, mix=mix) + + +def _set(name): + def move(node, value): + node.set(**{name: value}) + return move + + +def _attr(name): + def move(node, value): + setattr(node, name, value) + return move + + +#: name, build(rate, channels, value), move(node, value), old, new +CASES = ( + ("shaper mix", lambda r, c, v: shaper(r, c, mix=v), _set("mix"), + 1.0, 0.2), + ("shaper post_gain", lambda r, c, v: shaper(r, c, post_gain=v), + _set("post_gain"), 0.9, 0.3), + ("biquad mix", biquad, _attr("mix"), 1.0, 0.0), + ("allpass mix", allpass, _attr("mix"), 0.5, 0.0), + ("dynamics makeup", dynamics, _set("makeup_db"), 0.0, 9.0), + ("delay mix", delay, _set("mix"), 0.0, 1.0), +) + + +def render(node, rate, channels, moves=None, reset_at=None): + """PULLS blocks; `moves` is (pull index, callable). Returns the frames, + interleaved, and how many frames came before each pull.""" + node.play(tone(rate, channels), loop=True) + out = array("h") + starts = [] + for index in range(PULLS): + if moves is not None and index == moves[0]: + moves[1](node) + if index == reset_at: + audiocore.reset_buffer(node) + starts.append(len(out) // channels) + out.extend(array("h", bytes(audiocore.get_buffer(node)[1]))) + node.deinit() + return out, starts + + +class NodeLevelRamps(unittest.TestCase): + + def _cases(self): + for name, build, move, old, new in CASES: + for rate in (48000, 22050): + for channels in (1, 2): + yield name, build, move, old, new, rate, channels + + def test_a_move_walks_and_lands(self): + for name, build, move, old, new, rate, channels in self._cases(): + with self.subTest(name=name, rate=rate, channels=channels): + held, _ = render(build(rate, channels, old), rate, channels) + there, _ = render(build(rate, channels, new), rate, channels) + self.assertNotEqual(held, there) + walked, starts = render( + build(rate, channels, old), rate, channels, + (MOVE_AT, lambda node: move(node, new))) + n = (rate * 20 + 500) // 1000 + at = starts[MOVE_AT] + self.assertGreater(len(walked), (at + n) * channels) + self.assertEqual(walked[:at * channels], + held[:at * channels]) + worst = 0 + for i in range(n): + t = (i + 1) / n + for c in range(channels): + k = (at + i) * channels + c + want = held[k] + (there[k] - held[k]) * t + worst = max(worst, abs(walked[k] - want)) + self.assertLessEqual(worst, 2) + self.assertEqual(walked[(at + n) * channels:], + there[(at + n) * channels:]) + + def test_a_move_before_the_first_pull_lands(self): + for name, build, move, old, new, rate, channels in self._cases(): + with self.subTest(name=name, rate=rate, channels=channels): + there, _ = render(build(rate, channels, new), rate, channels) + moved, _ = render(build(rate, channels, old), rate, channels, + (0, lambda node: move(node, new))) + self.assertEqual(moved, there) + + def test_a_reset_clears_the_walk(self): + for name, build, move, old, new, rate, channels in self._cases(): + with self.subTest(name=name, rate=rate, channels=channels): + there, starts = render(build(rate, channels, new), rate, + channels, reset_at=MOVE_AT + 1) + walked, _ = render(build(rate, channels, old), rate, + channels, + (MOVE_AT, lambda node: move(node, new)), + reset_at=MOVE_AT + 1) + at = starts[MOVE_AT + 1] * channels + self.assertNotEqual(walked[:at], there[:at]) + self.assertEqual(walked[at:], there[at:]) + + +if __name__ == "__main__": + unittest.main() From 220290cb056431a58eec3c2fc37281b5c88b6ee1 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:34:33 -0600 Subject: [PATCH 3/4] Node level ramp probe: an integer triangle, the same input at single precision --- tests/parity/node_level_ramp_probe.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/parity/node_level_ramp_probe.py b/tests/parity/node_level_ramp_probe.py index b45f70af..35bdd12b 100644 --- a/tests/parity/node_level_ramp_probe.py +++ b/tests/parity/node_level_ramp_probe.py @@ -20,7 +20,6 @@ Port are this package's own, and every target runs the same C for them. """ -import math from array import array import audiobiquad @@ -54,15 +53,17 @@ def clip_curve(points=1025): def tone(rate, channels): - # Whole cycles, and longer than PULLS pulls of 256 frames: a node's - # play() takes no loop on MicroPython. - period = (rate // 200) * 30 + """A 200 Hz triangle in whole cycles, worked in integers so a + single-precision build plays the same input, and longer than PULLS + pulls of 256 frames: a node's play() takes no loop on MicroPython.""" + cycle = rate // 200 values = array("h") - for i in range(period): - v = 12000 * math.sin(2 * math.pi * 200.0 * i / rate) - values.append(int(v)) + for i in range(cycle * 30): + t = 48000 * (i % cycle) // cycle + v = t if t < 12000 else (24000 - t if t < 36000 else t - 48000) + values.append(v) if channels == 2: - values.append(int(0.7 * v)) + values.append(v * 7 // 10) return audiocore.RawSample(values, sample_rate=rate, channel_count=channels) From c0e7c6187df460b0a95c2bbd7b339c3de12675b7 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:59:12 -0600 Subject: [PATCH 4/4] audioroute stub: Port.play takes after= The stub now declares the after= keyword the node takes since the level ramps, so a type checker accepts a deferred switch. Checked with mypy over the stub and tests/test_stubs_match_modules.py. --- src/audioroute/audioroute.pyi | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/audioroute/audioroute.pyi b/src/audioroute/audioroute.pyi index a416af38..37e5ea3f 100644 --- a/src/audioroute/audioroute.pyi +++ b/src/audioroute/audioroute.pyi @@ -168,14 +168,25 @@ class Port: @property def source(self) -> _AudioSample: """The stream the port is passing through now.""" - def play(self, source: _AudioSample, /) -> None: + def play(self, source: _AudioSample, /, *, after: int = 0) -> None: """Point the port at a different source. It takes effect between blocks, and the player does not need to stop. + Args: + source: The stream to pass through from now on. + after: Frames to keep playing the current source first. The port + switches at the start of the first pull after that many + frames have played, which gives a level the old source is + walking time to land. Until then `source` is still the old + one, and another `play` replaces the switch that was waiting. + A port nothing has pulled since it was pointed at what it + plays switches at once, and so does ``after=0``. + Raises: TypeError: ``source`` is not an audio sample. - ValueError: ``source`` is None, or its sample rate, channel count, - bit depth or signedness differs from the port's. + ValueError: ``source`` is None, ``after`` is negative, or the + source's sample rate, channel count, bit depth or signedness + differs from the port's. """ def deinit(self) -> None: """Let go of the source and release the port; the source itself is