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/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. 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()