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/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/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/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/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/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/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 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/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/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/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/node_level_ramp_probe.py b/tests/parity/node_level_ramp_probe.py new file mode 100644 index 00000000..35bdd12b --- /dev/null +++ b/tests/parity/node_level_ramp_probe.py @@ -0,0 +1,199 @@ +# 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. +""" + +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): + """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(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(v * 7 // 10) + 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 553e2922..10e36548 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -201,6 +201,16 @@ "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), + # 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_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() 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()