From 4ec6fbf40baf88fe553accf3d614e878da482a28 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:10:28 -0500 Subject: [PATCH 1/2] audioladder: a cutoff input read every sample, for audio-rate filter FM set(cutoff_hz=...) moves the cutoff once a block, so a cutoff swept by an audio-rate signal is a staircase and buzzes at the block rate. Ladder now takes a `cutoff` audio input, connected with modulate(cutoff=...) or in the constructor, as audiooscillator.Oscillator takes its pitch: the cutoff is cutoff_hz * 2 ** (v * cutoff_octaves) for an input at v of full scale, first channel only, clamped to the band cutoff_hz is. cutoff_octaves defaults to 10, the oscillator's pitch scale. The per-sample coefficients come from a deterministic exp2 (moved out of the oscillator into shared/audiodsp_exp2.h) and sin/(sin + cos) series rather than libm's tanf, so every target renders them alike; a held input is cached. An input of zero, or none, is the configured filter bit for bit, and the unmodulated loop is unchanged in output and speed. All three bindings (MicroPython, the CircuitPython spike and CPython) get the same surface. Tests: zero input matches no input exactly, a constant input matches set() within 1 LSB, and FFT shows the 750 Hz block-rate spur of a 64-frame block 190 dB under the carrier with the input against 18 dB under when stepped with set(). ladder_probe gains swept, held and stereo-input fixtures for verify_dsp. --- README.md | 9 + src/audioladder/Ladder.c | 119 ++++++++- src/audioladder/Ladder.h | 6 + .../shared-bindings/audioladder/Ladder.c | 90 ++++++- .../shared-module/audioladder/Ladder.c | 36 ++- .../shared-module/audioladder/Ladder.h | 6 + src/cpython/_audiodsp.c | 55 +++- src/cpython/audioladder.py | 78 +++++- src/shared/audiodsp_exp2.h | 42 +++ src/shared/audiodsp_ladder.c | 184 ++++++++++--- src/shared/audiodsp_ladder.h | 62 ++++- src/shared/audiodsp_oscillator.c | 35 +-- tests/parity/ladder_probe.py | 50 ++++ tests/test_cpython_audioladder_cutoff.py | 246 ++++++++++++++++++ 14 files changed, 914 insertions(+), 104 deletions(-) create mode 100644 src/shared/audiodsp_exp2.h create mode 100644 tests/test_cpython_audioladder_cutoff.py diff --git a/README.md b/README.md index e1dc3d6e..5edea985 100644 --- a/README.md +++ b/README.md @@ -361,13 +361,22 @@ ladder = audioladder.Ladder( passband_comp=0.0, # 0..1, how much of the passband to give back oversample=2, # 1 or 2; 2 folds back fewer of its harmonics mix=1.0, # 0 a wire, 1 the filter + cutoff_octaves=10.0, # octaves for a full-scale cutoff input buffer_size=None, # bytes a block, as on the effects; 256 frames ) ladder.play(source) ladder.set(cutoff_hz=1200.0) # any option, mid-stream, once a block +ladder.modulate(cutoff=lfo) # the cutoff read from a stream, every sample ladder.clear() # empty the integrators; stops a sustained tone ``` +`set()` moves the cutoff once a block, which is plenty for an envelope or a +knob. Sweep it with an audio-rate signal that way and you hear the staircase, +a buzz at the block rate. The `cutoff` input reads the cutoff every sample +instead, as `cutoff_hz * 2 ** (v * cutoff_octaves)` for an input at `v` of +full scale (first channel only), so filter FM sounds like filter FM. An input +of zero, or none at all, is the filter exactly as `cutoff_hz` sets it. + `audiooscillator` is the oscillator synthio does not have. synthio's moves its pitch once per block and reads a wavetable with no band limiting; this one reads its pitch, linear FM, pulse width and hard sync from audio streams every diff --git a/src/audioladder/Ladder.c b/src/audioladder/Ladder.c index a9092e9d..97c9cb3e 100644 --- a/src/audioladder/Ladder.c +++ b/src/audioladder/Ladder.c @@ -26,6 +26,7 @@ static const ladder_option_name_t ladder_option_names[] = { { MP_QSTR_passband_comp, AUDIODSP_LADDER_OPT_PASSBAND_COMP }, { MP_QSTR_oversample, AUDIODSP_LADDER_OPT_OVERSAMPLE }, { MP_QSTR_mix, AUDIODSP_LADDER_OPT_MIX }, + { MP_QSTR_cutoff_octaves, AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES }, }; // Every option is read and checked into a copy of the config before @@ -43,7 +44,8 @@ static void ladder_apply_kwargs(audioladder_ladder_obj_t *self, } qstr name = mp_obj_str_get_qstr(kw->table[i].key); if (name == MP_QSTR_sample_rate || name == MP_QSTR_channel_count || - name == MP_QSTR_buffer_size) { + name == MP_QSTR_buffer_size || + (!live && name == MP_QSTR_cutoff)) { continue; } float value = (float)mp_obj_get_float(kw->table[i].value); @@ -74,6 +76,51 @@ static void ladder_apply_kwargs(audioladder_ladder_obj_t *self, } } +// Connects the cutoff input if `kw` names it. Checked first, stored after +// under the lock, so a bad one leaves the input as it was. `strict` is +// modulate()'s: from the constructor every other keyword is an option. +static void ladder_apply_inputs(audioladder_ladder_obj_t *self, + const mp_map_t *kw, bool strict) { + bool named = false; + mp_obj_t chosen = MP_OBJ_NULL; + uint8_t channels = 1; + for (size_t i = 0; i < kw->alloc; ++i) { + if (!mp_map_slot_is_filled(kw, i)) { + continue; + } + qstr name = mp_obj_str_get_qstr(kw->table[i].key); + if (name != MP_QSTR_cutoff) { + if (strict) { + mp_raise_msg_varg(&mp_type_TypeError, + MP_ERROR_TEXT("unknown Ladder input '%q'"), name); + } + continue; + } + mp_obj_t sample = kw->table[i].value; + named = true; + chosen = MP_OBJ_NULL; + channels = 1; + if (sample != mp_const_none) { + audiosample_base_t *base = audiosample_check(sample); + if (base->channel_count < 1 || base->channel_count > 2) { + mp_raise_ValueError( + MP_ERROR_TEXT("an input must be mono or stereo")); + } + chosen = sample; + channels = base->channel_count; + } + } + if (!named) { + return; + } + audiodsp_pump_lock_acquire(); + self->cutoff = chosen; + self->cutoff_channels = channels; + self->cutoff_pending = NULL; + self->cutoff_pending_frames = 0; + audiodsp_pump_lock_release(); +} + static mp_obj_t audioladder_ladder_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { mp_arg_check_num(n_args, n_kw, 0, 0, true); @@ -129,6 +176,10 @@ static mp_obj_t audioladder_ladder_make_new(const mp_obj_type_t *type, self->source = MP_OBJ_NULL; self->pending = NULL; self->pending_frames = 0; + self->cutoff = MP_OBJ_NULL; + self->cutoff_pending = NULL; + self->cutoff_pending_frames = 0; + self->cutoff_channels = 1; audiodsp_ladder_config_init(&self->config, sample_rate); audiodsp_ladder_set_channel_count(&self->config, channel_count); @@ -136,6 +187,7 @@ static mp_obj_t audioladder_ladder_make_new(const mp_obj_type_t *type, ladder_apply_kwargs(self, &kw_map, false); audiodsp_ladder_config_finish(&self->config); + ladder_apply_inputs(self, &kw_map, false); return MP_OBJ_FROM_PTR(self); } @@ -164,6 +216,17 @@ static mp_obj_t audioladder_ladder_set(size_t n_args, const mp_obj_t *args, static MP_DEFINE_CONST_FUN_OBJ_KW(audioladder_ladder_set_obj, 1, audioladder_ladder_set); +static mp_obj_t audioladder_ladder_modulate(size_t n_args, + const mp_obj_t *args, mp_map_t *kw_args) { + audioladder_ladder_obj_t *self = MP_OBJ_TO_PTR(args[0]); + audiosample_check_for_deinit(&self->base); + (void)n_args; + ladder_apply_inputs(self, kw_args, true); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(audioladder_ladder_modulate_obj, 1, + audioladder_ladder_modulate); + static mp_obj_t audioladder_ladder_clear(mp_obj_t self_in) { audioladder_ladder_obj_t *self = MP_OBJ_TO_PTR(self_in); audiosample_check_for_deinit(&self->base); @@ -202,9 +265,39 @@ static audioio_get_buffer_result_t audioladder_ladder_get_buffer( if (run > self->pending_frames) { run = self->pending_frames; } - audiodsp_ladder_process_s16(&self->config, &self->state, - &self->buffer[produced * self->base.channel_count], - self->pending, run); + const int16_t *cutoff = NULL; + if (self->cutoff != MP_OBJ_NULL) { + if (self->cutoff_pending_frames == 0) { + uint8_t *raw = NULL; + uint32_t raw_bytes = 0; + audioio_get_buffer_result_t result = audiosample_get_buffer( + self->cutoff, false, 0, &raw, &raw_bytes); + const uint32_t width = 2u * self->cutoff_channels; + // An input that has run dry counts as zero for this run. + if (result != GET_BUFFER_ERROR && raw != NULL && + raw_bytes >= width) { + self->cutoff_pending = (const int16_t *)raw; + self->cutoff_pending_frames = raw_bytes / width; + } + } + if (self->cutoff_pending_frames != 0) { + if (run > self->cutoff_pending_frames) { + run = self->cutoff_pending_frames; + } + cutoff = self->cutoff_pending; + } + } + if (cutoff == NULL) { + audiodsp_ladder_process_s16(&self->config, &self->state, + &self->buffer[produced * self->base.channel_count], + self->pending, run); + } else { + audiodsp_ladder_process_cutoff_s16(&self->config, &self->state, + &self->buffer[produced * self->base.channel_count], + self->pending, run, cutoff, self->cutoff_channels); + self->cutoff_pending += run * self->cutoff_channels; + self->cutoff_pending_frames -= run; + } self->pending += run * self->base.channel_count; self->pending_frames -= run; produced += run; @@ -257,6 +350,9 @@ static mp_obj_t audioladder_ladder_deinit(mp_obj_t self_in) { self->source = mp_const_none; self->pending = NULL; self->pending_frames = 0; + self->cutoff = MP_OBJ_NULL; + self->cutoff_pending = NULL; + self->cutoff_pending_frames = 0; audiodsp_pump_lock_release(); return mp_const_none; } @@ -268,6 +364,8 @@ static const mp_rom_map_elem_t audioladder_ladder_locals_table[] = { { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, { MP_ROM_QSTR(MP_QSTR_play), MP_ROM_PTR(&audioladder_ladder_play_obj) }, { MP_ROM_QSTR(MP_QSTR_set), MP_ROM_PTR(&audioladder_ladder_set_obj) }, + { MP_ROM_QSTR(MP_QSTR_modulate), + MP_ROM_PTR(&audioladder_ladder_modulate_obj) }, { MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&audioladder_ladder_clear_obj) }, AUDIOSAMPLE_FIELDS, }; @@ -278,8 +376,17 @@ static MP_DEFINE_CONST_DICT(audioladder_ladder_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 audioladder_ladder_sources(mp_obj_t self_in, uint8_t index) { - return index == 0 ? ((audioladder_ladder_obj_t *)MP_OBJ_TO_PTR(self_in))->source - : MP_OBJ_NULL; + audioladder_ladder_obj_t *self = MP_OBJ_TO_PTR(self_in); + // An empty slot is mp_const_none and the walk asks for the next one; + // MP_OBJ_NULL ends it, so a source not yet played must not hide the + // cutoff input behind it. + if (index == 0) { + return self->source == MP_OBJ_NULL ? mp_const_none : self->source; + } + if (index == 1) { + return self->cutoff == MP_OBJ_NULL ? mp_const_none : self->cutoff; + } + return MP_OBJ_NULL; } static const audiosample_p_t audioladder_ladder_proto = { diff --git a/src/audioladder/Ladder.h b/src/audioladder/Ladder.h index 87aae498..6fbe9168 100644 --- a/src/audioladder/Ladder.h +++ b/src/audioladder/Ladder.h @@ -30,6 +30,12 @@ typedef struct { uint32_t pending_frames; // Frames per output block: buffer_size's, at most AUDIODSP_LADDER_FRAMES. uint32_t frames; + // The cutoff input, MP_OBJ_NULL when not connected, and its frames + // fetched but not yet consumed. + mp_obj_t cutoff; + const int16_t *cutoff_pending; + uint32_t cutoff_pending_frames; + uint8_t cutoff_channels; } audioladder_ladder_obj_t; extern const mp_obj_type_t audioladder_ladder_type; diff --git a/src/circuitpython_spike/shared-bindings/audioladder/Ladder.c b/src/circuitpython_spike/shared-bindings/audioladder/Ladder.c index b35c3d6d..1bafe820 100644 --- a/src/circuitpython_spike/shared-bindings/audioladder/Ladder.c +++ b/src/circuitpython_spike/shared-bindings/audioladder/Ladder.c @@ -28,6 +28,7 @@ //| sample_rate: int = 48000, //| channel_count: int = 2, //| buffer_size: Optional[int] = None, +//| cutoff: Optional[circuitpython_typing.AudioSample] = None, //| cutoff_hz: float = 1000.0, //| resonance: float = 0.0, //| drive: float = 1.0, @@ -35,6 +36,7 @@ //| passband_comp: float = 0.0, //| oversample: int = 2, //| mix: float = 1.0, +//| cutoff_octaves: float = 10.0, //| ) -> None: //| """Create a ladder filter. //| @@ -53,7 +55,15 @@ //| back less. ``mix`` is a plain crossfade, 0 a wire and 1 the //| filter. ``buffer_size`` is the size in bytes of each block handed //| out, as on the CircuitPython effects, from one frame to the -//| default of 256.""" +//| default of 256. +//| +//| ``cutoff`` is an audio input that moves the cutoff every sample, +//| for filter FM at an audio rate. Its first channel is read as a +//| fraction ``v`` of full scale, and the cutoff is +//| ``cutoff_hz * 2 ** (v * cutoff_octaves)``, clamped to the same +//| band ``cutoff_hz`` is. An input that is not connected, or runs +//| dry, counts as zero, and zero is the filter as ``cutoff_hz`` +//| sets it.""" //| ... // The options __init__ and set() accept, paired with the shared DSP's enum. @@ -73,17 +83,19 @@ static const ladder_option_name_t ladder_option_names[] = { { MP_QSTR_passband_comp, AUDIODSP_LADDER_OPT_PASSBAND_COMP }, { MP_QSTR_oversample, AUDIODSP_LADDER_OPT_OVERSAMPLE }, { MP_QSTR_mix, AUDIODSP_LADDER_OPT_MIX }, + { MP_QSTR_cutoff_octaves, AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES }, }; static void ladder_apply_kwargs(audioladder_ladder_obj_t *self, - const mp_map_t *kw) { + const mp_map_t *kw, bool live) { for (size_t i = 0; i < kw->alloc; ++i) { if (!mp_map_slot_is_filled(kw, i)) { continue; } qstr name = mp_obj_str_get_qstr(kw->table[i].key); if (name == MP_QSTR_sample_rate || name == MP_QSTR_channel_count || - name == MP_QSTR_buffer_size) { + name == MP_QSTR_buffer_size || + (!live && name == MP_QSTR_cutoff)) { continue; } float value = (float)mp_obj_get_float(kw->table[i].value); @@ -104,6 +116,49 @@ static void ladder_apply_kwargs(audioladder_ladder_obj_t *self, } } +// Connects the cutoff input if `kw` names it. Checked first, stored after, +// so a bad one leaves the input as it was. `strict` is modulate()'s: from +// the constructor every other keyword is an option. +static void ladder_apply_inputs(audioladder_ladder_obj_t *self, + const mp_map_t *kw, bool strict) { + bool named = false; + mp_obj_t chosen = MP_OBJ_NULL; + uint8_t channels = 1; + for (size_t i = 0; i < kw->alloc; ++i) { + if (!mp_map_slot_is_filled(kw, i)) { + continue; + } + qstr name = mp_obj_str_get_qstr(kw->table[i].key); + if (name != MP_QSTR_cutoff) { + if (strict) { + mp_raise_msg_varg(&mp_type_TypeError, + MP_ERROR_TEXT("unknown Ladder input '%q'"), name); + } + continue; + } + mp_obj_t sample = kw->table[i].value; + named = true; + chosen = MP_OBJ_NULL; + channels = 1; + if (sample != mp_const_none) { + audiosample_base_t *base = audiosample_check(sample); + if (base->channel_count < 1 || base->channel_count > 2) { + mp_raise_ValueError( + MP_ERROR_TEXT("an input must be mono or stereo")); + } + chosen = sample; + channels = base->channel_count; + } + } + if (!named) { + return; + } + self->cutoff = chosen; + self->cutoff_channels = channels; + self->cutoff_pending = NULL; + self->cutoff_pending_frames = 0; +} + static mp_obj_t audioladder_ladder_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { mp_arg_check_num(n_args, n_kw, 0, 0, true); @@ -159,13 +214,18 @@ static mp_obj_t audioladder_ladder_make_new(const mp_obj_type_t *type, self->source = MP_OBJ_NULL; self->pending = NULL; self->pending_frames = 0; + self->cutoff = MP_OBJ_NULL; + self->cutoff_pending = NULL; + self->cutoff_pending_frames = 0; + self->cutoff_channels = 1; audiodsp_ladder_config_init(&self->config, sample_rate); audiodsp_ladder_set_channel_count(&self->config, channel_count); audiodsp_ladder_state_init(&self->state); - ladder_apply_kwargs(self, &kw_map); + ladder_apply_kwargs(self, &kw_map, false); audiodsp_ladder_config_finish(&self->config); + ladder_apply_inputs(self, &kw_map, false); return MP_OBJ_FROM_PTR(self); } @@ -194,12 +254,27 @@ static mp_obj_t audioladder_ladder_set(size_t n_args, const mp_obj_t *args, audioladder_ladder_obj_t *self = MP_OBJ_TO_PTR(args[0]); audiosample_check_for_deinit(&self->base); (void)n_args; - ladder_apply_kwargs(self, kw_args); + ladder_apply_kwargs(self, kw_args, true); return mp_const_none; } MP_DEFINE_CONST_FUN_OBJ_KW(audioladder_ladder_set_obj, 1, audioladder_ladder_set); +//| def modulate(self, **inputs: Optional[circuitpython_typing.AudioSample]) -> None: +//| """Connect inputs by name: ``cutoff``. None disconnects it; an +//| input not named is left as it is.""" +//| ... +static mp_obj_t audioladder_ladder_modulate(size_t n_args, + const mp_obj_t *args, mp_map_t *kw_args) { + audioladder_ladder_obj_t *self = MP_OBJ_TO_PTR(args[0]); + audiosample_check_for_deinit(&self->base); + (void)n_args; + ladder_apply_inputs(self, kw_args, true); + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_KW(audioladder_ladder_modulate_obj, 1, + audioladder_ladder_modulate); + //| def clear(self) -> None: //| """Empty the integrators and the solver's history, which also //| stops a self-oscillation.""" @@ -226,6 +301,9 @@ static mp_obj_t audioladder_ladder_deinit(mp_obj_t self_in) { self->source = mp_const_none; self->pending = NULL; self->pending_frames = 0; + self->cutoff = MP_OBJ_NULL; + self->cutoff_pending = NULL; + self->cutoff_pending_frames = 0; return mp_const_none; } MP_DEFINE_CONST_FUN_OBJ_1(audioladder_ladder_deinit_obj, audioladder_ladder_deinit); @@ -236,6 +314,8 @@ static const mp_rom_map_elem_t audioladder_ladder_locals_dict_table[] = { { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, { MP_ROM_QSTR(MP_QSTR_play), MP_ROM_PTR(&audioladder_ladder_play_obj) }, { MP_ROM_QSTR(MP_QSTR_set), MP_ROM_PTR(&audioladder_ladder_set_obj) }, + { MP_ROM_QSTR(MP_QSTR_modulate), + MP_ROM_PTR(&audioladder_ladder_modulate_obj) }, { MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&audioladder_ladder_clear_obj) }, AUDIOSAMPLE_FIELDS, }; diff --git a/src/circuitpython_spike/shared-module/audioladder/Ladder.c b/src/circuitpython_spike/shared-module/audioladder/Ladder.c index 80719d27..4f57fc1b 100644 --- a/src/circuitpython_spike/shared-module/audioladder/Ladder.c +++ b/src/circuitpython_spike/shared-module/audioladder/Ladder.c @@ -47,9 +47,39 @@ audioio_get_buffer_result_t audioladder_ladder_get_buffer( if (run > self->pending_frames) { run = self->pending_frames; } - audiodsp_ladder_process_s16(&self->config, &self->state, - &self->buffer[produced * self->base.channel_count], - self->pending, run); + const int16_t *cutoff = NULL; + if (self->cutoff != MP_OBJ_NULL) { + if (self->cutoff_pending_frames == 0) { + uint8_t *raw = NULL; + uint32_t raw_bytes = 0; + audioio_get_buffer_result_t result = audiosample_get_buffer( + self->cutoff, false, 0, &raw, &raw_bytes); + const uint32_t width = 2u * self->cutoff_channels; + // An input that has run dry counts as zero for this run. + if (result != GET_BUFFER_ERROR && raw != NULL && + raw_bytes >= width) { + self->cutoff_pending = (const int16_t *)(const void *)raw; + self->cutoff_pending_frames = raw_bytes / width; + } + } + if (self->cutoff_pending_frames != 0) { + if (run > self->cutoff_pending_frames) { + run = self->cutoff_pending_frames; + } + cutoff = self->cutoff_pending; + } + } + if (cutoff == NULL) { + audiodsp_ladder_process_s16(&self->config, &self->state, + &self->buffer[produced * self->base.channel_count], + self->pending, run); + } else { + audiodsp_ladder_process_cutoff_s16(&self->config, &self->state, + &self->buffer[produced * self->base.channel_count], + self->pending, run, cutoff, self->cutoff_channels); + self->cutoff_pending += run * self->cutoff_channels; + self->cutoff_pending_frames -= run; + } self->pending += run * self->base.channel_count; self->pending_frames -= run; produced += run; diff --git a/src/circuitpython_spike/shared-module/audioladder/Ladder.h b/src/circuitpython_spike/shared-module/audioladder/Ladder.h index 4a461f6c..367b4377 100644 --- a/src/circuitpython_spike/shared-module/audioladder/Ladder.h +++ b/src/circuitpython_spike/shared-module/audioladder/Ladder.h @@ -27,6 +27,12 @@ typedef struct { uint32_t pending_frames; // Frames per output block: buffer_size's, at most AUDIODSP_LADDER_FRAMES. uint32_t frames; + // The cutoff input, MP_OBJ_NULL when not connected, and its frames + // fetched but not yet consumed. + mp_obj_t cutoff; + const int16_t *cutoff_pending; + uint32_t cutoff_pending_frames; + uint8_t cutoff_channels; } audioladder_ladder_obj_t; void audioladder_ladder_reset_buffer(audioladder_ladder_obj_t *self, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 12c561e9..550df5ab 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -1933,7 +1933,7 @@ static PyObject *ladder_state_configure(audiodsp_ladder_object_t *self, double value; if (!PyArg_ParseTuple(args, "id:configure", &option, &value)) return NULL; if (option < AUDIODSP_LADDER_OPT_CUTOFF_HZ || - option > AUDIODSP_LADDER_OPT_MIX) { + option > AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES) { PyErr_SetString(PyExc_ValueError, "unknown ladder option"); return NULL; } @@ -1975,15 +1975,68 @@ static PyObject *ladder_state_process(audiodsp_ladder_object_t *self, return result; } +// process(), with the cutoff read every sample from `cutoff`: whole frames of +// signed 16-bit audio, `channels` samples a frame, of which the first is +// read. A separate method so the unmodulated path keeps its single argument. +static PyObject *ladder_state_process_cutoff(audiodsp_ladder_object_t *self, + PyObject *args) { + PyObject *input_object; + PyObject *cutoff_object; + unsigned int channels; + if (!PyArg_ParseTuple(args, "OOI:process_cutoff", &input_object, + &cutoff_object, &channels)) return NULL; + if (channels < 1 || channels > 2) { + PyErr_SetString(PyExc_ValueError, + "the cutoff input's channel_count must be 1 or 2"); + return NULL; + } + Py_buffer input = {0}; + Py_buffer cutoff = {0}; + PyObject *result = NULL; + const Py_ssize_t width = 2 * (Py_ssize_t)self->config.channel_count; + Py_ssize_t frames = 0; + if (PyObject_GetBuffer(input_object, &input, PyBUF_SIMPLE) < 0) { + return NULL; + } + if (PyObject_GetBuffer(cutoff_object, &cutoff, PyBUF_SIMPLE) < 0) { + PyBuffer_Release(&input); + return NULL; + } + if (input.len % width) { + PyErr_SetString(PyExc_ValueError, + "input must be whole 16-bit frames for the configured channel count"); + goto done; + } + frames = input.len / width; + if (cutoff.len < frames * 2 * (Py_ssize_t)channels) { + PyErr_SetString(PyExc_ValueError, + "the cutoff input is shorter than the frames given"); + goto done; + } + result = PyBytes_FromStringAndSize(NULL, input.len); + if (result != NULL) { + audiodsp_ladder_process_cutoff_s16(&self->config, &self->state, + (int16_t *)PyBytes_AS_STRING(result), (const int16_t *)input.buf, + (uint32_t)frames, (const int16_t *)cutoff.buf, channels); + } +done: + PyBuffer_Release(&cutoff); + PyBuffer_Release(&input); + return result; +} + AUDIODSP_LOCKED_ARGS(ladder_state_configure, audiodsp_ladder_object_t) AUDIODSP_LOCKED_ARGS(ladder_state_finish, audiodsp_ladder_object_t) AUDIODSP_LOCKED_ARGS(ladder_state_reset, audiodsp_ladder_object_t) AUDIODSP_LOCKED_ARGS(ladder_state_process, audiodsp_ladder_object_t) +AUDIODSP_LOCKED_ARGS(ladder_state_process_cutoff, audiodsp_ladder_object_t) static PyMethodDef ladder_state_methods[] = { {"configure", (PyCFunction)ladder_state_configure_locked, METH_VARARGS, NULL}, {"finish", (PyCFunction)ladder_state_finish_locked, METH_NOARGS, NULL}, {"reset", (PyCFunction)ladder_state_reset_locked, METH_NOARGS, NULL}, {"process", (PyCFunction)ladder_state_process_locked, METH_O, NULL}, + {"process_cutoff", (PyCFunction)ladder_state_process_cutoff_locked, + METH_VARARGS, NULL}, {NULL, NULL, 0, NULL}, }; diff --git a/src/cpython/audioladder.py b/src/cpython/audioladder.py index 6c5cd20a..13b27362 100644 --- a/src/cpython/audioladder.py +++ b/src/cpython/audioladder.py @@ -27,6 +27,19 @@ default, because half the cost is the wrong saving on the one node whose job is to distort. `mix` is a plain crossfade, 0 a wire and 1 the filter. +`cutoff` is an audio input that moves the cutoff every sample, which is what +filter FM at an audio rate needs: `set(cutoff_hz=...)` moves it once a block, +and a cutoff swept by an audio-rate signal that way buzzes at the block rate. +Connect it with `modulate(cutoff=source)` or in the constructor. Only its +first channel is read, as a fraction `v` of full scale (-1..1), and the +cutoff is `cutoff_hz * 2 ** (v * cutoff_octaves)`, clamped to the same band +`cutoff_hz` is. `cutoff_octaves` defaults to 10, the same scale as +`audiooscillator`'s pitch input. An input that is not connected, or runs +dry, counts as zero, and zero is the filter exactly as `cutoff_hz` sets it. + + ladder.modulate(cutoff=lfo) + ladder.set(cutoff_octaves=2.0) + `buffer_size` is the size in bytes of each block handed out, as on the CircuitPython effects: a host running 64-frame blocks passes `buffer_size=64 * 2 * channel_count` and the filter keeps pace with it, with @@ -41,7 +54,8 @@ """ from audiocore import ( - GET_BUFFER_ERROR, GET_BUFFER_MORE_DATA, _AudioSample, get_buffer, + GET_BUFFER_ERROR, GET_BUFFER_MORE_DATA, _AudioSample, _sample_method, + get_buffer, ) import _audiodsp @@ -64,8 +78,12 @@ "passband_comp": 4, "oversample": 5, "mix": 6, + "cutoff_octaves": 7, } +#: The inputs `modulate()` connects. +_INPUTS = ("cutoff",) + def _frames_for(buffer_size, channel_count): """Frames per block for a `buffer_size` in bytes, as the CircuitPython @@ -82,7 +100,7 @@ def _frames_for(buffer_size, channel_count): class Ladder(_AudioSample): - def __init__(self, sample_rate=48000, **options): + def __init__(self, sample_rate=48000, cutoff=None, **options): channel_count = int(options.pop("channel_count", 2)) if channel_count not in (1, 2): raise ValueError("channel_count must be 1 or 2") @@ -98,10 +116,14 @@ def __init__(self, sample_rate=48000, **options): self._deinited = False self._source = None self._pending = b"" + self._cutoff = None + self._cutoff_channels = 1 + self._cutoff_pending = b"" self._state = _audiodsp.LadderState( sample_rate=self.sample_rate, channel_count=channel_count) self._apply(options) self._state.finish() + self._connect({"cutoff": cutoff}) def _apply(self, options): for name, value in options.items(): @@ -110,6 +132,31 @@ def _apply(self, options): raise TypeError("unknown Ladder option %r" % (name,)) self._state.configure(slot, float(value)) + def _connect(self, inputs): + if "cutoff" not in inputs: + return + sample = inputs["cutoff"] + channels = 1 + if sample is not None: + # Not an audio source -- a number meant for `cutoff_hz`, say -- + # is a TypeError, as it is on the native builds. + _sample_method(sample, "_get_buffer") + channels = int(getattr(sample, "channel_count", 1)) + if channels not in (1, 2): + raise ValueError("an input must be mono or stereo") + self._cutoff = sample + self._cutoff_channels = channels + self._cutoff_pending = b"" + + def modulate(self, **inputs): + """Connect inputs by name: `cutoff`. None disconnects it; an input + not named is left as it is.""" + self._check() + for name in inputs: + if name not in _INPUTS: + raise TypeError("unknown Ladder input %r" % (name,)) + self._connect(inputs) + def set(self, **options): """Change settings mid-stream. The integrators keep their charge; only what the loop does to them changes. This is the call a macro @@ -138,6 +185,8 @@ def stop(self): def _release(self): self.stop() + self._cutoff = None + self._cutoff_pending = b"" def _reset_buffer(self, single_channel_output=False, audio_channel=0): self._check() @@ -150,6 +199,15 @@ def _reset_buffer(self, single_channel_output=False, audio_channel=0): # charged carries the previous take's tone into the new one. self._state.reset() + def _take_cutoff(self): + # An input that has run dry counts as zero for this run. + result, data = get_buffer(self._cutoff, False, 0) + width = 2 * self._cutoff_channels + data = bytes(data) + if result == GET_BUFFER_ERROR or len(data) < width: + return b"" + return data[:len(data) // width * width] + def _get_buffer(self, single_channel_output=False, audio_channel=0): self._check() output = bytearray() @@ -166,7 +224,21 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): break self._pending = data[:len(data) // width * width] run = min(frames - produced, len(self._pending) // width) - output += self._state.process(self._pending[:run * width]) + cutoff = None + if self._cutoff is not None: + if not self._cutoff_pending: + self._cutoff_pending = self._take_cutoff() + if self._cutoff_pending: + step = 2 * self._cutoff_channels + run = min(run, len(self._cutoff_pending) // step) + cutoff = self._cutoff_pending[:run * step] + self._cutoff_pending = self._cutoff_pending[run * step:] + if cutoff is None: + output += self._state.process(self._pending[:run * width]) + else: + output += self._state.process_cutoff( + self._pending[:run * width], cutoff, + self._cutoff_channels) self._pending = self._pending[run * width:] produced += run # A starved chain gets silence rather than a short block: this node diff --git a/src/shared/audiodsp_exp2.h b/src/shared/audiodsp_exp2.h new file mode 100644 index 00000000..09c388da --- /dev/null +++ b/src/shared/audiodsp_exp2.h @@ -0,0 +1,42 @@ +// 2^x with the same bits on every target, for the nodes whose pitch or cutoff +// is read from an audio stream. +// +// libm's exp2f differs in the last place between glibc, newlib and +// MicroPython's own, and our nodes are held to one render everywhere. The +// integer part goes into the exponent; the fraction, centred on a half so the +// series converges fast, is a seven-term Taylor series, about 1e-7 relative at +// the ends. +// +// Static inline rather than a function of its own, so each translation unit +// that includes it compiles it under its own audiodsp_fp_contract.h. +// +// SPDX-License-Identifier: MIT + +#pragma once + +static inline float audiodsp_exp2_poly(float x) { + if (x > 60.0f) { + x = 60.0f; + } else if (x < -60.0f) { + x = -60.0f; + } + int whole = (int)x; + if ((float)whole > x) { + --whole; + } + const float g = (x - (float)whole) - 0.5f; + const float p = 1.0f + g * (0.69314718f + g * (0.24022651f + + g * (0.05550411f + g * (0.00961813f + g * (0.00133336f + + g * 0.00015404f))))); + float scale = 1.0f; + float step = whole >= 0 ? 2.0f : 0.5f; + unsigned int n = (unsigned int)(whole >= 0 ? whole : -whole); + while (n) { + if (n & 1u) { + scale *= step; + } + step *= step; + n >>= 1; + } + return 1.41421356f * p * scale; +} diff --git a/src/shared/audiodsp_ladder.c b/src/shared/audiodsp_ladder.c index 8432e3ec..1e5436f8 100644 --- a/src/shared/audiodsp_ladder.c +++ b/src/shared/audiodsp_ladder.c @@ -39,8 +39,10 @@ #include "shared/audiodsp_fp_contract.h" #include "shared/audiodsp_ladder.h" +#include "shared/audiodsp_exp2.h" #include +#include #define AUDIODSP_LADDER_PI 3.14159265358979323846f @@ -70,6 +72,19 @@ static float ladder_saturate(float value) { return clipped - clipped * clipped * clipped * (1.0f / 3.0f); } +// Everything that follows from g, for the configured cutoff and for the +// cutoff input alike, so the two run the same arithmetic. +static void ladder_finish_coeffs(audiodsp_ladder_coeffs_t *coeffs, + float resonance, float g) { + coeffs->g = g; + const float squared = g * g; + coeffs->g4 = squared * squared; + coeffs->loop_gain = resonance * coeffs->g4; + coeffs->inv_one_plus_loop = 1.0f / (1.0f + coeffs->loop_gain); + coeffs->loop_over_three = + coeffs->loop_gain * coeffs->inv_one_plus_loop * (1.0f / 3.0f); +} + // Everything the per-sample loop reads, from everything the caller set. // One tanf() and one division, per set() rather than per sample. static void ladder_update_coefficients(audiodsp_ladder_config_t *config) { @@ -80,14 +95,46 @@ static void ladder_update_coefficients(audiodsp_ladder_config_t *config) { // is what "wide open" means. const float cutoff = clampf(config->cutoff_hz, 1.0f, rate * 0.49f); const float warped = tanf(AUDIODSP_LADDER_PI * cutoff / rate); - config->g = warped / (1.0f + warped); - const float squared = config->g * config->g; - config->g4 = squared * squared; - config->loop_gain = config->resonance * config->g4; - config->inv_one_plus_loop = 1.0f / (1.0f + config->loop_gain); - config->loop_over_three = - config->loop_gain * config->inv_one_plus_loop * (1.0f / 3.0f); + ladder_finish_coeffs(&config->coeffs, config->resonance, + warped / (1.0f + warped)); config->output_gain = 1.0f + config->passband_comp * config->resonance; + config->cutoff_scale = config->cutoff_octaves * (1.0f / 32768.0f); + config->pi_over_rate = AUDIODSP_LADDER_PI / rate; + config->cutoff_max = rate * 0.49f; + ++config->revision; +} + +// sin(w) and cos(w) for w in (0, 0.49 pi], as series in w: the cutoff input +// derives g every sample, and libm's tanf is neither the same bits on every +// target nor cheap on a part without one in hardware. Seven terms each, which +// leaves the error under 1e-9 at the top of the range and relative, not +// absolute, at the bottom, where g is small and a cancellation would cost it +// its digits. +static float ladder_sin(float w) { + const float w2 = w * w; + return w * (1.0f + w2 * (-1.0f / 6.0f + w2 * (1.0f / 120.0f + + w2 * (-1.0f / 5040.0f + w2 * (1.0f / 362880.0f + + w2 * (-1.0f / 39916800.0f + w2 * (1.0f / 6227020800.0f))))))); +} + +static float ladder_cos(float w) { + const float w2 = w * w; + return 1.0f + w2 * (-0.5f + w2 * (1.0f / 24.0f + w2 * (-1.0f / 720.0f + + w2 * (1.0f / 40320.0f + w2 * (-1.0f / 3628800.0f + + w2 * (1.0f / 479001600.0f + w2 * (-1.0f / 87178291200.0f))))))); +} + +// The coefficients for one sample of the cutoff input. tan/(1 + tan) is +// sin/(sin + cos), which is one division and no tan. +static void ladder_cutoff_coeffs(const audiodsp_ladder_config_t *config, + int32_t value, audiodsp_ladder_coeffs_t *coeffs) { + const float cutoff = clampf(config->cutoff_hz * + audiodsp_exp2_poly((float)value * config->cutoff_scale), + 1.0f, config->cutoff_max); + const float w = cutoff * config->pi_over_rate; + const float sine = ladder_sin(w); + ladder_finish_coeffs(coeffs, config->resonance, + sine / (sine + ladder_cos(w))); } void audiodsp_ladder_config_init(audiodsp_ladder_config_t *config, @@ -99,6 +146,10 @@ void audiodsp_ladder_config_init(audiodsp_ladder_config_t *config, config->drive = 1.0f; config->passband_comp = 0.0f; config->mix = 1.0f; + // Ten octaves for a full-scale input, the same as audiooscillator's + // pitch input, so one wire moves an oscillator and a filter alike. + config->cutoff_octaves = 10.0f; + config->revision = 0; config->poles = (uint8_t)AUDIODSP_LADDER_STAGES; // 2x by default. The filter is nonlinear, so it makes harmonics of // whatever it is given, and the ones above the band fold back. Half the @@ -169,6 +220,12 @@ void audiodsp_ladder_configure(audiodsp_ladder_config_t *config, // filter has. 0 is a wire, sample for sample. config->mix = clampf(value, 0.0f, 1.0f); break; + case AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES: + // Any sign: a negative depth closes the filter as the input + // rises. However large, the cutoff it reaches is clamped to the + // same band `cutoff_hz` is. + config->cutoff_octaves = value; + break; } ladder_update_coefficients(config); } @@ -186,6 +243,10 @@ void audiodsp_ladder_state_init(audiodsp_ladder_state_t *state) { state->previous_last[channel] = 0.0f; state->previous_input[channel] = 0.0f; } + // No int16 is INT32_MIN, so the first cutoff sample always derives its + // coefficients. + state->cutoff_last = INT32_MIN; + state->cutoff_revision = 0; } void audiodsp_ladder_reset(audiodsp_ladder_state_t *state) { @@ -194,9 +255,9 @@ void audiodsp_ladder_reset(audiodsp_ladder_state_t *state) { // Solve y + a*sat(y) = c for y. See the file header for why this is a solve // and not a delay, and why it converges. -static float ladder_solve(const audiodsp_ladder_config_t *config, float c, +static float ladder_solve(const audiodsp_ladder_coeffs_t *coeffs, float c, float seed) { - const float saturated = config->loop_gain * (2.0f / 3.0f); + const float saturated = coeffs->loop_gain * (2.0f / 3.0f); // Where the saturator is clamped the equation is linear, so these two // tests are exact, not approximations: y >= 1 exactly when // c >= 1 + a*sat(1). @@ -208,11 +269,11 @@ static float ladder_solve(const audiodsp_ladder_config_t *config, float c, if (y <= -1.0f) { return y; } - const float base = c * config->inv_one_plus_loop; + const float base = c * coeffs->inv_one_plus_loop; y = seed; for (int step = 0; step < AUDIODSP_LADDER_SOLVE_STEPS; ++step) { y = clampf(y, -1.0f, 1.0f); - y = base + config->loop_over_three * (y * y * y); + y = base + coeffs->loop_over_three * (y * y * y); } // The solution is inside the clamp by construction -- the two branches // above took every case where it is not. @@ -223,9 +284,10 @@ static float ladder_solve(const audiodsp_ladder_config_t *config, float c, // `x` is already driven and in the +-1 domain; the return is the tapped // stage, before the passband compensation and the mix. static float ladder_step(const audiodsp_ladder_config_t *config, - audiodsp_ladder_state_t *state, uint32_t channel, float x) { + const audiodsp_ladder_coeffs_t *coeffs, audiodsp_ladder_state_t *state, + uint32_t channel, float x) { float *integrator = state->integrator[channel]; - const float g = config->g; + const float g = coeffs->g; const float one_minus_g = 1.0f - g; // What each stage contributes to its own output with no input: the state @@ -242,7 +304,7 @@ static float ladder_step(const audiodsp_ladder_config_t *config, // the four states are already holding. const float carried = ((held[0] * g + held[1]) * g + held[2]) * g + held[3]; - const float c = config->g4 * x + carried; + const float c = coeffs->g4 * x + carried; // Extrapolating the last two solved samples costs one multiply and one // subtract and removes most of the solver's work: at audio rates the @@ -250,7 +312,7 @@ static float ladder_step(const audiodsp_ladder_config_t *config, // its last two points lands very close to the next. const float seed = 2.0f * state->last[channel] - state->previous_last[channel]; - const float solved = ladder_solve(config, c, seed); + const float solved = ladder_solve(coeffs, c, seed); state->previous_last[channel] = state->last[channel]; state->last[channel] = solved; @@ -283,6 +345,40 @@ static int16_t to_s16(float value) { return (int16_t)(value >= 0.0f ? value + 0.5f : value - 0.5f); } +// One frame, every channel, at the coefficients given. Inline, so the +// unmodulated loop below compiles to what it was before the cutoff input. +static inline void ladder_frame(const audiodsp_ladder_config_t *config, + const audiodsp_ladder_coeffs_t *coeffs, audiodsp_ladder_state_t *state, + int16_t *out, const int16_t *in, uint32_t channels, bool doubled, + float drive, float mix, float gain) { + for (uint32_t channel = 0; channel < channels; ++channel) { + const float dry = (float)in[channel] * (1.0f / 32768.0f); + float wet; + if (doubled) { + // Linear interpolation up, a two-tap average down: half a + // sample of group delay for the pair, which is why the node + // reports no latency. A nonlinearity makes harmonics of what + // it is given and the ones above the band fold back, so the + // point is to make them above a band twice as wide and then + // throw that half away. + const float middle = + 0.5f * (state->previous_input[channel] + dry); + const float first = + ladder_step(config, coeffs, state, channel, middle * drive); + const float second = + ladder_step(config, coeffs, state, channel, dry * drive); + wet = 0.5f * (first + second); + } else { + wet = ladder_step(config, coeffs, state, channel, dry * drive); + } + // Kept whether or not it was used, so turning oversampling on + // mid-stream does not start from a stale sample. + state->previous_input[channel] = dry; + wet *= gain; + out[channel] = to_s16((dry + (wet - dry) * mix) * 32768.0f); + } +} + void audiodsp_ladder_process_s16(const audiodsp_ladder_config_t *config, audiodsp_ladder_state_t *state, int16_t *out, const int16_t *in, uint32_t frames) { @@ -291,34 +387,40 @@ void audiodsp_ladder_process_s16(const audiodsp_ladder_config_t *config, const float drive = config->drive; const float mix = config->mix; const float gain = config->output_gain; + const audiodsp_ladder_coeffs_t *coeffs = &config->coeffs; + for (uint32_t frame = 0; frame < frames; ++frame) { + ladder_frame(config, coeffs, state, &out[frame * channels], + &in[frame * channels], channels, doubled, drive, mix, gain); + } +} + +void audiodsp_ladder_process_cutoff_s16(const audiodsp_ladder_config_t *config, + audiodsp_ladder_state_t *state, int16_t *out, const int16_t *in, + uint32_t frames, const int16_t *cutoff, uint32_t cutoff_stride) { + if (cutoff == NULL) { + audiodsp_ladder_process_s16(config, state, out, in, frames); + return; + } + const uint32_t channels = config->channel_count == 1u ? 1u : 2u; + const bool doubled = config->oversample >= 2u; + const float drive = config->drive; + const float mix = config->mix; + const float gain = config->output_gain; for (uint32_t frame = 0; frame < frames; ++frame) { - for (uint32_t channel = 0; channel < channels; ++channel) { - const float dry = - (float)in[frame * channels + channel] * (1.0f / 32768.0f); - float wet; - if (doubled) { - // Linear interpolation up, a two-tap average down: half a - // sample of group delay for the pair, which is why the node - // reports no latency. A nonlinearity makes harmonics of what - // it is given and the ones above the band fold back, so the - // point is to make them above a band twice as wide and then - // throw that half away. - const float middle = - 0.5f * (state->previous_input[channel] + dry); - const float first = - ladder_step(config, state, channel, middle * drive); - const float second = - ladder_step(config, state, channel, dry * drive); - wet = 0.5f * (first + second); - } else { - wet = ladder_step(config, state, channel, dry * drive); + const int32_t value = cutoff[frame * cutoff_stride]; + const audiodsp_ladder_coeffs_t *coeffs = &config->coeffs; + // Zero is the filter as configured, with the configured set's own + // bits, so a silent input renders exactly what no input does. + if (value != 0) { + if (value != state->cutoff_last || + config->revision != state->cutoff_revision) { + state->cutoff_last = value; + state->cutoff_revision = config->revision; + ladder_cutoff_coeffs(config, value, &state->cutoff_coeffs); } - // Kept whether or not it was used, so turning oversampling on - // mid-stream does not start from a stale sample. - state->previous_input[channel] = dry; - wet *= gain; - out[frame * channels + channel] = - to_s16((dry + (wet - dry) * mix) * 32768.0f); + coeffs = &state->cutoff_coeffs; } + ladder_frame(config, coeffs, state, &out[frame * channels], + &in[frame * channels], channels, doubled, drive, mix, gain); } } diff --git a/src/shared/audiodsp_ladder.h b/src/shared/audiodsp_ladder.h index 38a31ed5..b1fdd522 100644 --- a/src/shared/audiodsp_ladder.h +++ b/src/shared/audiodsp_ladder.h @@ -57,8 +57,27 @@ typedef enum { AUDIODSP_LADDER_OPT_PASSBAND_COMP, AUDIODSP_LADDER_OPT_OVERSAMPLE, AUDIODSP_LADDER_OPT_MIX, + AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES, } audiodsp_ladder_option_t; +//: What the per-sample loop reads that the cutoff moves. Held apart so the +//: cutoff input can derive a set of its own every sample, through the same +//: loop the configured set goes through. +typedef struct { + // TPT one-pole coefficient: tan(pi*fc/fs') / (1 + tan(pi*fc/fs')), where + // fs' is sample_rate * oversample. + float g; + // g to the fourth: the forward gain of the whole chain at DC. + float g4; + // resonance * g4 -- the loop gain the solver works against. + float loop_gain; + // 1 / (1 + loop_gain), and loop_gain/(3*(1 + loop_gain)). The solver is + // written so that these two constants are all it needs: no division and + // no library call per sample. + float inv_one_plus_loop; + float loop_over_three; +} audiodsp_ladder_coeffs_t; + //: Everything derived from the constructor/`set()` arguments. Held apart from //: the running state so `set()` can rewrite it mid-stream without disturbing //: the integrators. @@ -74,25 +93,25 @@ typedef struct { float drive; float passband_comp; float mix; + // Octaves the cutoff moves for a full-scale cutoff input. + float cutoff_octaves; uint8_t poles; uint8_t oversample; // --- derived, all computed in ladder_update_coefficients() --- - // TPT one-pole coefficient: tan(pi*fc/fs') / (1 + tan(pi*fc/fs')), where - // fs' is sample_rate * oversample. One tanf() per set(), never per + // The coefficients at `cutoff_hz`. One tanf() per set(), never per // sample. - float g; - // g to the fourth: the forward gain of the whole chain at DC. - float g4; - // resonance * g4 -- the loop gain the solver works against. - float loop_gain; - // 1 / (1 + loop_gain), and loop_gain/(3*(1 + loop_gain)). The solver is - // written so that these two constants are all it needs: no division and - // no library call per sample. - float inv_one_plus_loop; - float loop_over_three; + audiodsp_ladder_coeffs_t coeffs; // 1 + passband_comp * resonance: what the tapped output is scaled by to // give back the passband the feedback subtracts. float output_gain; + // What the cutoff input needs to derive its own coefficients: octaves per + // input count, pi / fs', and the band the cutoff is clamped to. + float cutoff_scale; + float pi_over_rate; + float cutoff_max; + // Bumped by every change, so a coefficient set the state cached from the + // cutoff input is known to be stale. + uint32_t revision; } audiodsp_ladder_config_t; typedef struct { @@ -108,6 +127,11 @@ typedef struct { // The previous input sample, per channel: the 2x upsampler interpolates // between it and the current one. float previous_input[2]; + // The last cutoff input, the config revision it was read against, and + // the coefficients it gave, so a held cutoff costs nothing per sample. + int32_t cutoff_last; + uint32_t cutoff_revision; + audiodsp_ladder_coeffs_t cutoff_coeffs; } audiodsp_ladder_state_t; void audiodsp_ladder_config_init(audiodsp_ladder_config_t *config, @@ -138,3 +162,17 @@ void audiodsp_ladder_reset(audiodsp_ladder_state_t *state); void audiodsp_ladder_process_s16(const audiodsp_ladder_config_t *config, audiodsp_ladder_state_t *state, int16_t *out, const int16_t *in, uint32_t frames); + +// The same, with the cutoff read from an audio stream every sample: +// `frames` frames of signed 16-bit audio with `cutoff_stride` samples between +// frames, of which only the first channel is read. The cutoff is +// +// cutoff_hz * 2^(cutoff/32768 * cutoff_octaves) +// +// clamped to the same band `cutoff_hz` is. An input of zero is the filter as +// configured, sample for sample; NULL is the same as +// audiodsp_ladder_process_s16(). With oversampling on, both of an output +// sample's steps run at that sample's cutoff. +void audiodsp_ladder_process_cutoff_s16(const audiodsp_ladder_config_t *config, + audiodsp_ladder_state_t *state, int16_t *out, const int16_t *in, + uint32_t frames, const int16_t *cutoff, uint32_t cutoff_stride); diff --git a/src/shared/audiodsp_oscillator.c b/src/shared/audiodsp_oscillator.c index 75b517bf..e623ac2c 100644 --- a/src/shared/audiodsp_oscillator.c +++ b/src/shared/audiodsp_oscillator.c @@ -4,6 +4,7 @@ #include "shared/audiodsp_fp_contract.h" #include "shared/audiodsp_oscillator.h" +#include "shared/audiodsp_exp2.h" #include #include @@ -125,38 +126,6 @@ void audiodsp_oscillator_reset(audiodsp_oscillator_state_t *state) { reset_pending(state); } -// 2^x, the same bits on every target: libm's exp2f differs in the last place -// between glibc, newlib and MicroPython's own, and this module is held to one -// render everywhere. The integer part goes into the exponent; the fraction, -// centred on a half so the series converges fast, is a seven-term Taylor -// series, about 1e-7 relative at the ends. -static float exp2_poly(float x) { - if (x > 60.0f) { - x = 60.0f; - } else if (x < -60.0f) { - x = -60.0f; - } - int whole = (int)x; - if ((float)whole > x) { - --whole; - } - const float g = (x - (float)whole) - 0.5f; - const float p = 1.0f + g * (0.69314718f + g * (0.24022651f - + g * (0.05550411f + g * (0.00961813f + g * (0.00133336f - + g * 0.00015404f))))); - float scale = 1.0f; - float step = whole >= 0 ? 2.0f : 0.5f; - unsigned int n = (unsigned int)(whole >= 0 ? whole : -whole); - while (n) { - if (n & 1u) { - scale *= step; - } - step *= step; - n >>= 1; - } - return 1.41421356f * p * scale; -} - // sin(2 pi phase) for phase in [0, 1), deterministic for the same reason. // Folded into a quarter either side of zero, then an odd series to the // eleventh power. @@ -325,7 +294,7 @@ void audiodsp_oscillator_process_s16( config->pitch_scale != state->pitch_scale) { state->pitch_last = value; state->pitch_scale = config->pitch_scale; - state->pitch_ratio = exp2_poly((float)value + state->pitch_ratio = audiodsp_exp2_poly((float)value * config->pitch_scale); } dt = config->frequency * config->inv_rate * state->pitch_ratio; diff --git a/tests/parity/ladder_probe.py b/tests/parity/ladder_probe.py index b1cac741..009c6254 100644 --- a/tests/parity/ladder_probe.py +++ b/tests/parity/ladder_probe.py @@ -138,3 +138,53 @@ def emit(tag, node, blocks): node = ladder.Ladder(sample_rate=SAMPLE_RATE, cutoff_hz=400.0, resonance=4.2) emit("starved", node, 2) + +# The cutoff read every sample from an audio input. The input is a triangle +# computed in integers, so it is the same bytes everywhere; what is compared +# is the coefficients the node derives from it each sample, through its own +# exp2 and sine and cosine series rather than libm's. + + +def sweep(frames=1600, level=24000, period=29, channels=1): + values = array("h", [0] * (frames * channels)) + half = period // 2 + for frame in range(frames): + step = frame % period + rise = step if step < half else period - step + shape = rise * 2 * level // half - level + for channel in range(channels): + values[frame * channels + channel] = \ + shape if channel == 0 else -shape + return audiocore.RawSample(values, sample_rate=SAMPLE_RATE, + channel_count=channels) + + +def held(value, frames=1600): + return audiocore.RawSample(array("h", [value] * frames), + sample_rate=SAMPLE_RATE, channel_count=1) + + +node = ladder.Ladder(sample_rate=SAMPLE_RATE, cutoff_hz=600.0, + resonance=3.8, drive=2.0, cutoff_octaves=3.0) +node.modulate(cutoff=sweep()) +node.play(buzz()) +emit("swept", node, 6) + +# Plain rate, a stereo input of which only the first channel is read, and a +# negative depth. +node = ladder.Ladder(sample_rate=SAMPLE_RATE, cutoff_hz=900.0, + resonance=4.1, oversample=1, cutoff_octaves=-2.0, + cutoff=sweep(channels=2, period=53)) +node.play(buzz()) +emit("swept-plain", node, 6) + +# A held input takes the cached coefficients; a depth changed mid-stream must +# not keep them; a disconnected input is the filter as set. +node = ladder.Ladder(sample_rate=SAMPLE_RATE, cutoff_hz=500.0, + resonance=3.9, cutoff=held(-9000)) +node.play(buzz()) +emit("held-a", node, 2) +node.set(cutoff_octaves=4.0) +emit("held-b", node, 2) +node.modulate(cutoff=None) +emit("held-c", node, 2) diff --git a/tests/test_cpython_audioladder_cutoff.py b/tests/test_cpython_audioladder_cutoff.py new file mode 100644 index 00000000..1ed819c8 --- /dev/null +++ b/tests/test_cpython_audioladder_cutoff.py @@ -0,0 +1,246 @@ +"""audioladder's cutoff input: the cutoff moved every sample, not every block. + +`set(cutoff_hz=...)` moves the cutoff once a block, so a cutoff swept by an +audio-rate signal that way is a staircase, and the staircase is heard: images +of the modulation at multiples of the block rate, a buzz at +sample_rate / block_size. The `cutoff` input exists to remove that, and to +change nothing else: + +| trait | bar | +| --- | --- | +| an input of zero | the same bytes as no input at all | +| a constant input | within 1 LSB of `set()` at the cutoff it names | +| audio-rate FM through the input, at a 64-frame block | the block-rate spur at 750 Hz more than 100 dB under the carrier | +| the same FM, stepped once a block with `set()` | the spur less than 60 dB under: the control, so the measurement is shown to see it | +| `cutoff_octaves`, and an input that runs dry | the octaves it names; dry counts as zero | +| the surface | `modulate()` connects and disconnects, and refuses what is not an input | + +The FFT row's frequencies are chosen so the answer is unambiguous. The carrier +is 1900 Hz and the modulator 100 Hz, so everything a per-sample filter can +make from them sits on a multiple of 100 Hz. The block rate is 750 Hz, and +1900 - 4 * 100 - 750 = 750, so the stepped filter puts an image exactly on the +block rate, where the per-sample one has nothing at all to put. +""" + +import math +import unittest +from array import array + +import numpy as np + +import audiocore +import audioladder + +RATE = 48000 +BLOCK = 64 + + +def raw(values, channels=1): + return audiocore.RawSample(array("h", [int(v) for v in values]), + sample_rate=RATE, channel_count=channels) + + +def sine(hz, level, frames): + t = np.arange(frames) + return np.round(level * np.sin(2 * np.pi * hz * t / RATE)) + + +def render(node, frames, before_block=None): + blocks = [] + got = 0 + index = 0 + while got < frames: + if before_block is not None: + before_block(index) + data = bytes(audiocore.get_buffer(node)[1]) + block = np.frombuffer(data, dtype=np.int16) + blocks.append(block) + got += len(block) // node.channel_count + index += 1 + return np.concatenate(blocks)[:frames * node.channel_count] + + +class Once: + """An input that hands out one block and then nothing: one that has run + dry. A RawSample would not do: pulled again, it starts over.""" + + channel_count = 1 + + def __init__(self, values): + self._data = bytes(array("h", values)) + + def _get_buffer(self, single_channel_output=False, audio_channel=0): + data, self._data = self._data, b"" + return audiocore.GET_BUFFER_DONE, data + + def _reset_buffer(self, single_channel_output=False, audio_channel=0): + pass + + +def mono(**options): + options.setdefault("buffer_size", BLOCK * 2) + return audioladder.Ladder(sample_rate=RATE, channel_count=1, **options) + + +def line_db(spectrum, hz, reference): + """The peak within three bins of `hz`, in dB against `reference`.""" + peak = spectrum[int(hz) - 3:int(hz) + 4].max() + return 20.0 * math.log10(max(peak, 1e-300) / reference) + + +def blackman_spectrum(samples): + x = np.asarray(samples, dtype=float) + window = np.blackman(len(x)) + return np.abs(np.fft.rfft(x * window)) * 2.0 / window.sum() + + +class ParityTest(unittest.TestCase): + """The input changes nothing it is not asked to.""" + + FRAMES = 12000 + + def source(self): + return raw(sine(1900.0, 12000, self.FRAMES + 512)) + + def test_an_input_of_zero_is_no_input(self): + plain = mono(cutoff_hz=900.0, resonance=3.9, drive=2.0) + plain.play(self.source()) + zero = mono(cutoff_hz=900.0, resonance=3.9, drive=2.0, + cutoff=raw([0] * (self.FRAMES + 512))) + zero.play(self.source()) + self.assertEqual(render(plain, self.FRAMES).tobytes(), + render(zero, self.FRAMES).tobytes()) + + def test_a_constant_input_is_set_at_the_cutoff_it_names(self): + for value in (8192, -12345, 32767): + with self.subTest(value=value): + octaves = 3.0 + hz = 1000.0 * 2.0 ** (value / 32768.0 * octaves) + stepped = mono(cutoff_hz=hz, resonance=3.0) + stepped.play(self.source()) + driven = mono(cutoff_hz=1000.0, resonance=3.0, + cutoff_octaves=octaves, + cutoff=raw([value] * (self.FRAMES + 512))) + driven.play(self.source()) + a = render(stepped, self.FRAMES).astype(int) + b = render(driven, self.FRAMES).astype(int) + self.assertLessEqual(int(np.abs(a - b).max()), 1) + + def test_the_default_depth_is_ten_octaves(self): + # Full scale on a 20 Hz cutoff, ten octaves up, is 20480 Hz: the + # filter wide open against one at 20 Hz. + closed = mono(cutoff_hz=20.0) + closed.play(self.source()) + opened = mono(cutoff_hz=20.0, cutoff=raw([32767] * 13000)) + opened.play(self.source()) + quiet = np.abs(render(closed, 6000)[2000:]).max() + loud = np.abs(render(opened, 6000)[2000:]).max() + self.assertGreater(loud, 10000) + self.assertLess(quiet, 300) + + def test_an_input_that_runs_dry_counts_as_zero(self): + short = mono(cutoff_hz=900.0, resonance=3.5, + cutoff=Once([20000] * 100)) + short.play(self.source()) + plain = mono(cutoff_hz=900.0, resonance=3.5) + plain.play(self.source()) + a = render(short, 6000) + b = render(plain, 6000) + # The two differ while the input lasts and then the dry one is the + # plain filter, carrying only the state the first 100 frames left. + self.assertNotEqual(a[:100].tobytes(), b[:100].tobytes()) + tail = np.abs(a[4000:].astype(int) - b[4000:].astype(int)).max() + self.assertLessEqual(int(tail), 1) + + +class AudioRateFmTest(unittest.TestCase): + """The block-rate spur is the staircase's, and the input has none.""" + + CARRIER = 1900.0 + MODULATOR = 100.0 + OCTAVES = 2.0 # the modulator at half scale: one octave either way + SETTLE = RATE // 4 + FRAMES = RATE + + def setUp(self): + total = self.SETTLE + self.FRAMES + 1024 + self.carrier = sine(self.CARRIER, 12000, total) + self.modulator = sine(self.MODULATOR, 16384, total) + self.options = dict(cutoff_hz=self.CARRIER, resonance=2.0) + + def per_sample(self): + node = mono(cutoff_octaves=self.OCTAVES, **self.options) + node.play(raw(self.carrier)) + node.modulate(cutoff=raw(self.modulator)) + return render(node, self.SETTLE + self.FRAMES)[self.SETTLE:] + + def stepped(self): + # What a host without the input does: one set() a block, from the + # modulator at the middle of the block. + node = mono(**self.options) + node.play(raw(self.carrier)) + + def before(index): + value = self.modulator[index * BLOCK + BLOCK // 2] / 32768.0 + node.set(cutoff_hz=self.CARRIER + * 2.0 ** (value * self.OCTAVES)) + return render(node, self.SETTLE + self.FRAMES, before)[self.SETTLE:] + + def spur_db(self, samples): + spectrum = blackman_spectrum(samples) + carrier = spectrum[int(self.CARRIER) - 3:int(self.CARRIER) + 4].max() + return line_db(spectrum, RATE / BLOCK, carrier) + + def test_the_input_leaves_no_block_rate_spur(self): + self.assertLess(self.spur_db(self.per_sample()), -100.0) + + def test_the_measurement_sees_the_staircase(self): + self.assertGreater(self.spur_db(self.stepped()), -60.0) + + def test_the_input_does_modulate(self): + # The sidebands at 1900 +- 100 Hz are the FM itself; a cutoff input + # that did nothing would leave the carrier alone in its spectrum. + spectrum = blackman_spectrum(self.per_sample()) + carrier = spectrum[int(self.CARRIER) - 3:int(self.CARRIER) + 4].max() + for hz in (self.CARRIER - self.MODULATOR, + self.CARRIER + self.MODULATOR): + self.assertGreater(line_db(spectrum, hz, carrier), -40.0, hz) + + +class SurfaceTest(unittest.TestCase): + + def test_modulate_connects_and_disconnects(self): + source = raw(sine(1900.0, 12000, 4000)) + node = mono(cutoff_hz=400.0) + node.play(source) + node.modulate(cutoff=raw([30000] * 4000)) + opened = np.abs(render(node, 1000)[500:]).max() + node.modulate(cutoff=None) + closed = np.abs(render(node, 2000)[1500:]).max() + self.assertGreater(opened, 4 * closed) + + def test_it_refuses_what_is_not_an_input(self): + node = mono() + with self.assertRaises(TypeError): + node.modulate(cutof=raw([0] * 10)) + with self.assertRaises(TypeError): + node.modulate(cutoff=600.0) + with self.assertRaises(TypeError): + audioladder.Ladder(sample_rate=RATE, cutoff=600.0) + with self.assertRaises(TypeError): + node.set(cutoff=raw([0] * 10)) + + def test_a_stereo_input_is_read_from_its_first_channel(self): + frames = 4000 + left = [20000] * frames + stereo = [v for pair in zip(left, [-20000] * frames) for v in pair] + source = sine(1900.0, 12000, frames) + a = mono(cutoff_hz=600.0, cutoff=raw(left)) + a.play(raw(source)) + b = mono(cutoff_hz=600.0, cutoff=raw(stereo, channels=2)) + b.play(raw(source)) + self.assertEqual(render(a, 3000).tobytes(), render(b, 3000).tobytes()) + + +if __name__ == "__main__": + unittest.main() From 9edd4f02aaf301add02dae329a1802f47cee576f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:18:38 -0500 Subject: [PATCH 2/2] deinit probe: call Ladder.modulate with the cutoff input it takes --- tests/parity/deinit_surface_probe.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/parity/deinit_surface_probe.py b/tests/parity/deinit_surface_probe.py index 4c0a788c..4ea59fbd 100644 --- a/tests/parity/deinit_surface_probe.py +++ b/tests/parity/deinit_surface_probe.py @@ -277,6 +277,8 @@ def check_splitter(deinit): ("audioshaper.SampleHold", "set"): lambda n: n.set(num=400, den=217), ("audiooscillator.Oscillator", "modulate"): lambda n: n.modulate(sync=source()), + ("audioladder.Ladder", "modulate"): + lambda n: n.modulate(cutoff=source()), } #: Not checked: releasing is the one call a released node must still take.