diff --git a/ROADMAP.md b/ROADMAP.md index 95d11526..06414679 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,6 @@ audio pump holding up under live use such as a stompbox or a stage rig. - The CircuitPython glue's folder takes a lasting name (`src/circuitpython_spike/` becomes `src/circuitpython/`). -- `audiocore`, `audiomp3`, `audiopump` and `synthio` report a `__revision__` - like the other modules, so a board can say which source each was built from - (today those four have none on MicroPython). Bugs, and things you need that don't work yet, go to [issues](https://github.com/PyDevices/audiodsp/issues). diff --git a/docs/how-it-works/upstream-diff.md b/docs/how-it-works/upstream-diff.md index 3f4edeae..c6fffda0 100644 --- a/docs/how-it-works/upstream-diff.md +++ b/docs/how-it-works/upstream-diff.md @@ -1570,8 +1570,12 @@ could not do either: `audioeffects.Tremolo` is exactly this effect inside that ceiling; a ring modulator wants hundreds of hertz and a carrier of a few kilohertz. -The arithmetic is `shared/audiodsp_multiply.c`, Q15 and stateless: the product -is `(a * b) >> 15`, blended `(dry * a + wet * product) >> 15`, clamped. The +The arithmetic is `shared/audiodsp_multiply.c`, Q15: the product is +`(a * b) >> 15`, blended `(dry * a + wet * product) >> 15`, clamped. The one +thing it carries between runs is a move of `mix`, which walks over 20 ms +(`shared/audiodsp_level_ramp.h`) a frame at a time, rounded to the Q15 grid, +and lands on `mix` exactly; before the first pull and after a reset `mix` +lands at once. The two negative rails are the one product that lands outside `int16`, which is what the clamp is for; [`tests/parity/multiply_probe.py`](../../tests/parity/multiply_probe.py) drives it there deliberately rather than assuming. diff --git a/docs/reference/filters-and-effects.md b/docs/reference/filters-and-effects.md index 06d97217..8fe38bd2 100644 --- a/docs/reference/filters-and-effects.md +++ b/docs/reference/filters-and-effects.md @@ -7,8 +7,9 @@ The reasons these exist beside CircuitPython's own modules are in A level moved on a playing node walks to its new value in a straight line over 20 ms instead of stepping, so turning it in one call doesn't click: the -`mix` of `Biquad`, `AllPass`, `Waveshaper` and `FeedbackDelay`, the -Waveshaper's `post_gain` and the Dynamics node's `makeup_db`. A node that +`mix` of `Biquad`, `AllPass`, `Waveshaper`, `FeedbackDelay` and +`audiomath.Multiply`, the Waveshaper's `post_gain` and the Dynamics node's +`makeup_db`. A node that hasn't played yet, or has just been reset, takes its levels at once. ## `audiobiquad` diff --git a/src/audiocore/audiocore.pyi b/src/audiocore/audiocore.pyi index 3c3b1d00..c426c0b4 100644 --- a/src/audiocore/audiocore.pyi +++ b/src/audiocore/audiocore.pyi @@ -17,6 +17,12 @@ of nodes produces. from typing import Self +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + class RawSample: """Plays a buffer of raw PCM samples that you provide. diff --git a/src/audiocore/module.c b/src/audiocore/module.c index 8770775a..b1e1b0bb 100644 --- a/src/audiocore/module.c +++ b/src/audiocore/module.c @@ -20,6 +20,7 @@ #include "audiocore/RawSample.h" #include "audiocore/WaveFile.h" #include "audiocore/__init__.h" +#include "cp_compat/audiodsp_build.h" #include "py/obj.h" #include "py/runtime.h" @@ -84,6 +85,7 @@ static MP_DEFINE_CONST_FUN_OBJ_1(audiocore_reset_buffer_obj, audiocore_reset_buf static const mp_rom_map_elem_t audiocore_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiocore) }, + AUDIODSP_BUILD_GLOBALS, { MP_ROM_QSTR(MP_QSTR_RawSample), MP_ROM_PTR(&audioio_rawsample_type) }, { MP_ROM_QSTR(MP_QSTR_WaveFile), MP_ROM_PTR(&audioio_wavefile_type) }, { MP_ROM_QSTR(MP_QSTR_get_buffer), MP_ROM_PTR(&audiocore_get_buffer_obj) }, diff --git a/src/audiomath/Multiply.c b/src/audiomath/Multiply.c index c579f29d..4363cf9b 100644 --- a/src/audiomath/Multiply.c +++ b/src/audiomath/Multiply.c @@ -45,6 +45,8 @@ static mp_obj_t audiomath_multiply_make_new(const mp_obj_type_t *type, audiodsp_multiply_config_init(&self->config); audiodsp_multiply_set_channel_count(&self->config, (uint32_t)self->base.channel_count); + audiodsp_multiply_set_sample_rate(&self->config, self->base.sample_rate); + audiodsp_multiply_state_clear(&self->state); if (args[ARG_source].u_obj != mp_const_none) { audiosample_base_t *sample = audiosample_check(args[ARG_source].u_obj); @@ -178,7 +180,7 @@ static audioio_get_buffer_result_t audiomath_multiply_get_buffer( if (run > self->pending_modulator_frames) { run = self->pending_modulator_frames; } - audiodsp_multiply_process_s16(&self->config, + audiodsp_multiply_process_s16(&self->config, &self->state, &self->buffer[produced * self->base.channel_count], self->pending_source, self->pending_modulator, run); @@ -208,12 +210,14 @@ static void audiomath_multiply_reset_buffer(mp_obj_t self_in, bool single_channel_output, uint8_t channel) { (void)single_channel_output; (void)channel; - (void)self_in; - // Nothing to clear: the product holds nothing between frames. The frames - // already taken from the source and from the modulator are kept, the rule - // CircuitPython's effects follow for a host reset: neither input was - // reset, so dropping them would skip that much of it and shift the - // modulator against the source. + audiomath_multiply_obj_t *self = MP_OBJ_TO_PTR(self_in); + // The product holds nothing between frames; only a walk of `mix` in + // progress is cleared, so the next block lands at `mix` as it stands. + // The frames already taken from the source and from the modulator are + // kept, the rule CircuitPython's effects follow for a host reset: neither + // input was reset, so dropping them would skip that much of it and shift + // the modulator against the source. + audiodsp_multiply_state_clear(&self->state); } // `deinit()` releases what this binding holds and marks the node diff --git a/src/audiomath/Multiply.h b/src/audiomath/Multiply.h index 578d52b0..b7d30e9c 100644 --- a/src/audiomath/Multiply.h +++ b/src/audiomath/Multiply.h @@ -24,6 +24,8 @@ typedef struct { // failure: no modulator means the signal passes through untouched. mp_obj_t modulator; audiodsp_multiply_config_t config; + // `mix` as the output stands, walking to a move (audiodsp_multiply.h). + audiodsp_multiply_state_t state; int16_t buffer[AUDIODSP_MULTIPLY_FRAMES * 2]; // Frames fetched from each input but not yet consumed, carried across // output blocks. The two inputs hand out different block lengths, so diff --git a/src/audiomath/audiomath.pyi b/src/audiomath/audiomath.pyi index 19866098..68de203f 100644 --- a/src/audiomath/audiomath.pyi +++ b/src/audiomath/audiomath.pyi @@ -87,6 +87,10 @@ class Multiply: def set(self, *, mix: float = ...) -> None: """Change an option while the node plays. + A move of ``mix`` on a playing node walks to the new value in a + straight line over 20 ms instead of stepping, so it doesn't click. + Before the first pull, or after a reset, it lands at once. + Args: mix: Wet amount from 0.0 to 1.0, clamped. diff --git a/src/audiomp3/audiomp3.pyi b/src/audiomp3/audiomp3.pyi index 305bb181..53e273c1 100644 --- a/src/audiomp3/audiomp3.pyi +++ b/src/audiomp3/audiomp3.pyi @@ -14,6 +14,12 @@ sample plays. from typing import BinaryIO, Self +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + class MP3Decoder: """Decodes an MP3 file as it plays. diff --git a/src/audiomp3/module.c b/src/audiomp3/module.c index 9a403151..995831f8 100644 --- a/src/audiomp3/module.c +++ b/src/audiomp3/module.c @@ -5,11 +5,13 @@ // SPDX-License-Identifier: MIT #include "audiomp3/MP3Decoder.h" +#include "cp_compat/audiodsp_build.h" #include "py/obj.h" static const mp_rom_map_elem_t audiomp3_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiomp3) }, + AUDIODSP_BUILD_GLOBALS, { MP_ROM_QSTR(MP_QSTR_MP3Decoder), MP_ROM_PTR(&audiomp3_mp3file_type) }, }; static MP_DEFINE_CONST_DICT(audiomp3_module_globals, audiomp3_module_globals_table); diff --git a/src/audiopump/audiopump.c b/src/audiopump/audiopump.c index 7c39b217..ce9c90c1 100644 --- a/src/audiopump/audiopump.c +++ b/src/audiopump/audiopump.c @@ -46,6 +46,7 @@ #include "audiopump/audiopump_ring.h" #include "audiopump/audiopump_tap.h" #include "audiopump/audiopump_drift.h" +#include "cp_compat/audiodsp_build.h" // The same acquire/release pair the push ring uses, and here for the same // reason: `volatile` orders the compiler, not the machine, and a 64-bit store @@ -2580,6 +2581,7 @@ static const mp_rom_map_elem_t audiopump_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_lock_reset), MP_ROM_PTR(&audiopump_lock_reset_obj) }, { MP_ROM_QSTR(MP_QSTR_fault), MP_ROM_PTR(&audiopump_fault_obj) }, { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiopump) }, + AUDIODSP_BUILD_GLOBALS, { MP_ROM_QSTR(MP_QSTR_pull), MP_ROM_PTR(&audiopump_pull_obj) }, { MP_ROM_QSTR(MP_QSTR_spawn), MP_ROM_PTR(&audiopump_spawn_obj) }, { MP_ROM_QSTR(MP_QSTR_join), MP_ROM_PTR(&audiopump_join_obj) }, diff --git a/src/audiopump/audiopump.pyi b/src/audiopump/audiopump.pyi index 95b93439..7baf58ec 100644 --- a/src/audiopump/audiopump.pyi +++ b/src/audiopump/audiopump.pyi @@ -21,6 +21,12 @@ layer already pulls from its own audio thread. from typing import Protocol, Self +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + class _AudioSample(Protocol): @property def sample_rate(self) -> int: ... diff --git a/src/circuitpython_spike/shared-bindings/audiomath/Multiply.c b/src/circuitpython_spike/shared-bindings/audiomath/Multiply.c index 7b695ef6..891d6df2 100644 --- a/src/circuitpython_spike/shared-bindings/audiomath/Multiply.c +++ b/src/circuitpython_spike/shared-bindings/audiomath/Multiply.c @@ -73,6 +73,8 @@ static mp_obj_t audiomath_multiply_make_new(const mp_obj_type_t *type, audiodsp_multiply_config_init(&self->config); audiodsp_multiply_set_channel_count(&self->config, (uint32_t)self->base.channel_count); + audiodsp_multiply_set_sample_rate(&self->config, self->base.sample_rate); + audiodsp_multiply_state_clear(&self->state); if (args[ARG_source].u_obj != mp_const_none) { audiosample_base_t *sample = audiosample_check(args[ARG_source].u_obj); diff --git a/src/circuitpython_spike/shared-module/audiomath/Multiply.c b/src/circuitpython_spike/shared-module/audiomath/Multiply.c index 1a71b77b..b1ee4273 100644 --- a/src/circuitpython_spike/shared-module/audiomath/Multiply.c +++ b/src/circuitpython_spike/shared-module/audiomath/Multiply.c @@ -9,14 +9,15 @@ void audiomath_multiply_reset_buffer(audiomath_multiply_obj_t *self, bool single_channel_output, uint8_t channel) { - (void)self; (void)single_channel_output; (void)channel; - // Nothing to clear: the product holds nothing between frames. The frames - // already taken from the source and from the modulator are kept, the rule - // CircuitPython's effects follow for a host reset: neither input was - // reset, so dropping them would skip that much of it and shift the - // modulator against the source. + // The product holds nothing between frames; only a walk of `mix` in + // progress is cleared, so the next block lands at `mix` as it stands. + // The frames already taken from the source and from the modulator are + // kept, the rule CircuitPython's effects follow for a host reset: neither + // input was reset, so dropping them would skip that much of it and shift + // the modulator against the source. + audiodsp_multiply_state_clear(&self->state); } audioio_get_buffer_result_t audiomath_multiply_get_buffer( @@ -64,7 +65,7 @@ audioio_get_buffer_result_t audiomath_multiply_get_buffer( if (run > self->pending_modulator_frames) { run = self->pending_modulator_frames; } - audiodsp_multiply_process_s16(&self->config, + audiodsp_multiply_process_s16(&self->config, &self->state, &self->buffer[produced * self->base.channel_count], self->pending_source, self->pending_modulator, run); diff --git a/src/circuitpython_spike/shared-module/audiomath/Multiply.h b/src/circuitpython_spike/shared-module/audiomath/Multiply.h index 428b7251..67ebe648 100644 --- a/src/circuitpython_spike/shared-module/audiomath/Multiply.h +++ b/src/circuitpython_spike/shared-module/audiomath/Multiply.h @@ -23,6 +23,8 @@ typedef struct { // failure: no modulator means the signal passes through untouched. mp_obj_t modulator; audiodsp_multiply_config_t config; + // `mix` as the output stands, walking to a move (audiodsp_multiply.h). + audiodsp_multiply_state_t state; int16_t buffer[AUDIODSP_MULTIPLY_FRAMES * 2]; // Frames fetched from each input but not yet consumed, carried across // output blocks. The two inputs hand out different block lengths, so diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index b9801b92..1879d79b 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -5619,15 +5619,30 @@ static PyObject *audiodsp_meter_db_byte_py(PyObject *module, PyObject *args) { static PyObject *audiodsp_multiply_s16(PyObject *module, PyObject *args) { Py_buffer signal = {0}; Py_buffer modulator = {0}; + Py_buffer state = {0}; double mix = 1.0; unsigned int channel_count = 2; - if (!PyArg_ParseTuple(args, "y*y*d|I:multiply_s16", &signal, &modulator, - &mix, &channel_count)) { + unsigned int ramp_frames = 0; + // `state`, when given, is a writable buffer of MULTIPLY_STATE_BYTES the + // caller keeps between calls (zeroed is a cleared walk): a move of `mix` + // then walks over `ramp_frames`, as the MicroPython node's does. + if (!PyArg_ParseTuple(args, "y*y*d|Iw*I:multiply_s16", &signal, + &modulator, &mix, &channel_count, &state, &ramp_frames)) { + return NULL; + } + if (state.buf != NULL && + state.len != (Py_ssize_t)sizeof(audiodsp_multiply_state_t)) { + PyBuffer_Release(&signal); + PyBuffer_Release(&modulator); + PyBuffer_Release(&state); + PyErr_SetString(PyExc_ValueError, + "state must be MULTIPLY_STATE_BYTES long"); return NULL; } if (channel_count < 1 || channel_count > 2) { PyBuffer_Release(&signal); PyBuffer_Release(&modulator); + PyBuffer_Release(&state); PyErr_SetString(PyExc_ValueError, "channel_count must be 1 or 2"); return NULL; } @@ -5635,6 +5650,7 @@ static PyObject *audiodsp_multiply_s16(PyObject *module, PyObject *args) { if (signal.len != modulator.len || signal.len % width) { PyBuffer_Release(&signal); PyBuffer_Release(&modulator); + PyBuffer_Release(&state); PyErr_SetString(PyExc_ValueError, "buffers must be the same whole number of configured frames"); return NULL; @@ -5645,12 +5661,15 @@ static PyObject *audiodsp_multiply_s16(PyObject *module, PyObject *args) { audiodsp_multiply_config_init(&config); audiodsp_multiply_set_channel_count(&config, channel_count); audiodsp_multiply_set_mix(&config, (float)mix); + config.ramp_frames = ramp_frames; audiodsp_multiply_process_s16(&config, + (audiodsp_multiply_state_t *)state.buf, (int16_t *)PyBytes_AS_STRING(result), (const int16_t *)signal.buf, (const int16_t *)modulator.buf, (uint32_t)(signal.len / width)); } PyBuffer_Release(&signal); PyBuffer_Release(&modulator); + PyBuffer_Release(&state); return result; } @@ -6591,6 +6610,8 @@ static int audiodsp_exec(PyObject *module) { AUDIODSP_DYNAMICS_FRAMES) < 0) return -1; if (PyModule_AddIntConstant(module, "MULTIPLY_FRAMES", AUDIODSP_MULTIPLY_FRAMES) < 0) return -1; + if (PyModule_AddIntConstant(module, "MULTIPLY_STATE_BYTES", + (long)sizeof(audiodsp_multiply_state_t)) < 0) return -1; if (PyModule_AddIntConstant(module, "MIDSIDE_FRAMES", AUDIODSP_MIDSIDE_FRAMES) < 0) return -1; state->feedback_delay_state_type = PyType_FromModuleAndSpec(module, diff --git a/src/cpython/audiocore.py b/src/cpython/audiocore.py index 21a367c8..5bb3f57f 100644 --- a/src/cpython/audiocore.py +++ b/src/cpython/audiocore.py @@ -6,7 +6,10 @@ import wave -import _audiodsp # noqa: F401 - verifies that the native runtime is present +import _audiodsp + +__version__ = _audiodsp.__version__ +__revision__ = _audiodsp.__revision__ GET_BUFFER_DONE = 0 GET_BUFFER_MORE_DATA = 1 diff --git a/src/cpython/audiomath.py b/src/cpython/audiomath.py index 488d8fca..f71aa9a2 100644 --- a/src/cpython/audiomath.py +++ b/src/cpython/audiomath.py @@ -111,6 +111,10 @@ def __init__(self, source=None, modulator=None, mix=1.0, self._source = source self._modulator = modulator self._mix = 1.0 + # A move of `mix` walks over 20 ms in the shared kernel, which keeps + # where the walk stands here between runs; zeroed is no walk. + self._ramp = bytearray(_audiodsp.MULTIPLY_STATE_BYTES) + self._ramp_frames = (self.sample_rate * 20 + 500) // 1000 self._pending_source = b"" self._pending_modulator = b"" self._apply({"mix": mix}) @@ -151,11 +155,13 @@ def _release(self): def _reset_buffer(self, single_channel_output=False, audio_channel=0): self._check() - # Nothing to clear: the product holds nothing between frames. The - # frames already taken from the source and from the modulator are - # kept, the rule CircuitPython's effects follow for a host reset: - # neither input was reset, so dropping them would skip that much of - # it and shift the modulator against the source. + # The product holds nothing between frames; only a walk of `mix` in + # progress is cleared, so the next block lands at `mix` as it + # stands. The frames already taken from the source and from the + # modulator are kept, the rule CircuitPython's effects follow for a + # host reset: neither input was reset, so dropping them would skip + # that much of it and shift the modulator against the source. + self._ramp[:] = bytes(len(self._ramp)) def _pull(self, sample): result, data = get_buffer(sample, False, 0) @@ -189,7 +195,7 @@ def _get_buffer(self, single_channel_output=False, audio_channel=0): output += _audiodsp.multiply_s16( self._pending_source[:run * width], self._pending_modulator[:run * width], self._mix, - self.channel_count) + self.channel_count, self._ramp, self._ramp_frames) self._pending_modulator = self._pending_modulator[run * width:] else: output += self._pending_source[:run * width] diff --git a/src/cpython/audiopump.py b/src/cpython/audiopump.py index 4c03437c..ee80cbac 100644 --- a/src/cpython/audiopump.py +++ b/src/cpython/audiopump.py @@ -44,6 +44,9 @@ GET_BUFFER_ERROR, GET_BUFFER_MORE_DATA, _AudioSample, _borrow, ) +__version__ = _audiodsp.__version__ +__revision__ = _audiodsp.__revision__ + _fnv = _audiodsp.fnv1a64 STATUS_WORDS = 34 diff --git a/src/cpython/synthio.py b/src/cpython/synthio.py index 00a75a27..75e08f91 100644 --- a/src/cpython/synthio.py +++ b/src/cpython/synthio.py @@ -14,6 +14,9 @@ import _audiodsp from audiocore import GET_BUFFER_MORE_DATA, _AudioSample +__version__ = _audiodsp.__version__ +__revision__ = _audiodsp.__revision__ + waveform_max_length = 16384 _DEFAULT_WAVEFORM = array("h", (-32768, 32767)) _MIDI_BASE_FREQUENCIES = (8372, 8870, 9397, 9956, 10548, 11175, 11840, diff --git a/src/shared/audiodsp_multiply.c b/src/shared/audiodsp_multiply.c index 13f800ac..4a254bef 100644 --- a/src/shared/audiodsp_multiply.c +++ b/src/shared/audiodsp_multiply.c @@ -3,11 +3,24 @@ #include "shared/audiodsp_fp_contract.h" +#include + #include "shared/audiodsp_multiply.h" void audiodsp_multiply_config_init(audiodsp_multiply_config_t *config) { config->channel_count = 2; config->mix = 32768; + config->ramp_frames = 0; +} + +void audiodsp_multiply_set_sample_rate(audiodsp_multiply_config_t *config, + uint32_t sample_rate) { + config->ramp_frames = audiodsp_level_ramp_frames(sample_rate, + AUDIODSP_LEVEL_RAMP_MS); +} + +void audiodsp_multiply_state_clear(audiodsp_multiply_state_t *state) { + audiodsp_level_ramp_clear(&state->mix_ramp); } void audiodsp_multiply_set_channel_count(audiodsp_multiply_config_t *config, @@ -26,25 +39,40 @@ void audiodsp_multiply_set_mix(audiodsp_multiply_config_t *config, float mix) { } void audiodsp_multiply_process_s16(const audiodsp_multiply_config_t *config, - int16_t *out, const int16_t *a, const int16_t *b, uint32_t frames) { - const int32_t wet = config->mix; - const int32_t dry = 32768 - wet; + audiodsp_multiply_state_t *state, int16_t *out, const int16_t *a, + const int16_t *b, uint32_t frames) { + int32_t wet = config->mix; + int32_t dry = 32768 - wet; const uint32_t channels = config->channel_count == 1u ? 1u : 2u; - const uint32_t samples = frames * channels; - for (uint32_t i = 0; i < samples; ++i) { - const int32_t signal = a[i]; - // Scaling by 32768 rather than 32767 keeps a full-scale modulator a - // gain of one to within an LSB and keeps the shift a shift. The two - // negative rails are the one product that lands outside int16, which - // is what the clamp below is for. - const int32_t product = (signal * (int32_t)b[i]) >> 15; - int32_t value = (dry * signal + wet * product) >> 15; - if (value > 32767) { - value = 32767; - } else if (value < -32768) { - value = -32768; + audiodsp_level_ramp_t *ramp = NULL; + if (state != NULL) { + ramp = &state->mix_ramp; + audiodsp_level_ramp_aim(ramp, (float)config->mix, config->ramp_frames); + } + for (uint32_t frame = 0; frame < frames; ++frame) { + // While a walk runs the frame takes the next step, rounded to the + // Q15 grid; once it lands `current` is `mix` exactly, so a node at + // rest renders the bytes it did before the walk existed. + if (ramp != NULL && audiodsp_level_ramp_moving(ramp)) { + wet = (int32_t)(audiodsp_level_ramp_next(ramp) + 0.5f); + dry = 32768 - wet; + } + for (uint32_t channel = 0; channel < channels; ++channel) { + const uint32_t i = frame * channels + channel; + const int32_t signal = a[i]; + // Scaling by 32768 rather than 32767 keeps a full-scale + // modulator a gain of one to within an LSB and keeps the shift a + // shift. The two negative rails are the one product that lands + // outside int16, which is what the clamp below is for. + const int32_t product = (signal * (int32_t)b[i]) >> 15; + int32_t value = (dry * signal + wet * product) >> 15; + if (value > 32767) { + value = 32767; + } else if (value < -32768) { + value = -32768; + } + out[i] = (int16_t)value; } - out[i] = (int16_t)value; } } diff --git a/src/shared/audiodsp_multiply.h b/src/shared/audiodsp_multiply.h index f9151fd9..d7158636 100644 --- a/src/shared/audiodsp_multiply.h +++ b/src/shared/audiodsp_multiply.h @@ -19,6 +19,8 @@ #include +#include "shared/audiodsp_level_ramp.h" + //: Frames one output block carries. The same 256 audiodynamics uses, so a //: chain built out of both moves in one block size. #define AUDIODSP_MULTIPLY_FRAMES 256u @@ -29,8 +31,19 @@ typedef struct { // Q15 integer rather than a float because the process loop is the hot // path and every other term in it is already an integer. int32_t mix; + // Frames a move of `mix` walks over (audiodsp_level_ramp.h); 0 steps at + // once. audiodsp_multiply_set_sample_rate() sets AUDIODSP_LEVEL_RAMP_MS. + uint32_t ramp_frames; } audiodsp_multiply_config_t; +// What the multiply carries from one run to the next: `mix` as the output +// stands, walking to a new value instead of stepping there, on the Q15 scale +// of `config->mix`. A reset clears it, so the first run after one lands at +// `mix` as it stands, as a fresh node's first block does. +typedef struct { + audiodsp_level_ramp_t mix_ramp; +} audiodsp_multiply_state_t; + void audiodsp_multiply_config_init(audiodsp_multiply_config_t *config); void audiodsp_multiply_set_channel_count(audiodsp_multiply_config_t *config, @@ -40,10 +53,19 @@ void audiodsp_multiply_set_channel_count(audiodsp_multiply_config_t *config, // than a request worth honouring. void audiodsp_multiply_set_mix(audiodsp_multiply_config_t *config, float mix); +// A move of `mix` walks over AUDIODSP_LEVEL_RAMP_MS at this rate. +void audiodsp_multiply_set_sample_rate(audiodsp_multiply_config_t *config, + uint32_t sample_rate); + +void audiodsp_multiply_state_clear(audiodsp_multiply_state_t *state); + // out[] = a[] blended toward a[]*b[] by `mix`, over interleaved stereo -// frames. `out` may alias `a`; the arithmetic is sample by sample. +// frames. `out` may alias `a`; the arithmetic is sample by sample. With a +// `state`, a move of `mix` walks over `config->ramp_frames` frames, one step a +// frame, the run after it was set; NULL takes `mix` at once. void audiodsp_multiply_process_s16(const audiodsp_multiply_config_t *config, - int16_t *out, const int16_t *a, const int16_t *b, uint32_t frames); + audiodsp_multiply_state_t *state, int16_t *out, const int16_t *a, + const int16_t *b, uint32_t frames); // What to write when there is no modulator to multiply by: the signal, // untouched, whatever `mix` says. A modulator that is absent or has run dry diff --git a/src/synthio/__init__.c b/src/synthio/__init__.c index 8e4c1066..837c1337 100644 --- a/src/synthio/__init__.c +++ b/src/synthio/__init__.c @@ -20,6 +20,7 @@ #include #include "cp_compat/argcheck.h" +#include "cp_compat/audiodsp_build.h" #include "cp_compat/enum.h" #include "cp_compat/namedtuple.h" #include "cp_compat/objproperty.h" @@ -702,6 +703,7 @@ MP_DEFINE_CONST_FUN_OBJ_VAR(synthio_lfo_tick_obj, 1, synthio_lfo_tick); static const mp_rom_map_elem_t synthio_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_synthio) }, + AUDIODSP_BUILD_GLOBALS, { MP_ROM_QSTR(MP_QSTR_Biquad), MP_ROM_PTR(&synthio_biquad_type_obj) }, { MP_ROM_QSTR(MP_QSTR_FilterMode), MP_ROM_PTR(&synthio_filter_mode_type) }, { MP_ROM_QSTR(MP_QSTR_Math), MP_ROM_PTR(&synthio_math_type) }, diff --git a/src/synthio/synthio.pyi b/src/synthio/synthio.pyi index bb505d58..bcf1ddf5 100644 --- a/src/synthio/synthio.pyi +++ b/src/synthio/synthio.pyi @@ -18,6 +18,12 @@ Beyond CircuitPython's own API this build adds `lfo_tick`, from array import array from typing import BinaryIO, Protocol, Self, Sequence, Union +__version__: str +"""The audiodsp release this module came from.""" +__revision__: str +"""Which source this build was made from, from `git describe`: a release tag, +or the nearest one and the commit. "unknown" when the build had no git.""" + # Types the API takes. They are for the type checker and are not importable # from the module. _ReadableBuffer = Union[bytes, bytearray, memoryview, array[int]] diff --git a/tests/parity/node_level_ramp_probe.py b/tests/parity/node_level_ramp_probe.py index 35bdd12b..79bfd3f5 100644 --- a/tests/parity/node_level_ramp_probe.py +++ b/tests/parity/node_level_ramp_probe.py @@ -10,7 +10,8 @@ PORT The Waveshaper's `mix` and `post_gain`, the Biquad's and the AllPass's `mix`, -the Dynamics node's makeup and the FeedbackDelay's `mix` walk a move over +the Dynamics node's makeup, the FeedbackDelay's `mix` and the Multiply's +`mix` walk a move over 20 ms (`src/shared/audiodsp_level_ramp.h`). Each is moved on a playing node, moved before the first pull, and moved and then reset halfway through the walk, mono and stereo, at 48 and 22.05 kHz. A Port told to switch `after` @@ -26,6 +27,7 @@ import audiocore import audiodynamics import audioecho +import audiomath import audioroute import audioshaper @@ -97,6 +99,23 @@ def delay(rate, channels, name, value): feedback=0.3, mix=value) +def multiply(rate, channels, name, value): + """A Multiply under a 50 Hz integer triangle that runs from 0 to full + scale, the way a tremolo's modulator swings.""" + period = rate // 50 + values = array("h") + for i in range(period): + t = 65534 * i // period + v = t if t < 32767 else 65534 - t + for _ in range(channels): + values.append(v) + node = audiomath.Multiply(mix=value, sample_rate=rate, + channel_count=channels) + node.modulate(audiocore.RawSample(values, sample_rate=rate, + channel_count=channels)) + return node + + def by_set(node, name, value): options = {name: value} node.set(**options) @@ -113,6 +132,7 @@ def by_attr(node, name, value): ("allpass-mix", allpass, by_attr, "mix", 0.5, 0.0), ("dynamics-makeup", dynamics, by_set, "makeup_db", 0.0, 9.0), ("delay-mix", delay, by_set, "mix", 0.0, 1.0), + ("multiply-mix", multiply, by_set, "mix", 1.0, 0.0), ) diff --git a/tests/test_binding_parity.py b/tests/test_binding_parity.py index e62ddcad..d083ff09 100644 --- a/tests/test_binding_parity.py +++ b/tests/test_binding_parity.py @@ -227,9 +227,10 @@ class EveryModuleSaysWhichAudiodspItIs(unittest.TestCase): firmware built from a throwaway source tree during a pin move could not be attributed to any commit, because nothing in it recorded one. - Upstream's modules are deliberately NOT checked: adding a dunder to - `audiocore` or `synthio` would deviate from CircuitPython's surface, and any - of our nine answers the question (decided 2026-09-09). + Upstream's modules are not checked here. `audiocore`, `audiomp3`, + `audiopump` and `synthio` carry the marker too, so a firmware with only + those can still be attributed; the other upstream modules stay as + CircuitPython has them. **The CircuitPython spike bindings are also excluded, on purpose.** They have the same globals-table shape and could carry the marker, but the CP @@ -263,7 +264,7 @@ def test_the_python_twins_answer_the_same_way(self): def test_upstream_modules_are_left_alone(self): """The control: this suite must not pass by marking everything.""" - for module in ("audiocore", "audiodelays", "audiofilters", "audiomixer"): + for module in ("audiodelays", "audiofilters", "audiomixer"): with self.subTest(module=module): source = (ROOT / "src" / module / "module.c").read_text() self.assertNotIn(self.MARKER, source) diff --git a/tests/test_cpython_node_level_ramps.py b/tests/test_cpython_node_level_ramps.py index c4edd4bf..19c2acf9 100644 --- a/tests/test_cpython_node_level_ramps.py +++ b/tests/test_cpython_node_level_ramps.py @@ -5,7 +5,8 @@ """A node's level control walks to a new value over 20 ms instead of stepping. The Waveshaper's `mix` and `post_gain`, the Biquad's and the AllPass's `mix`, -the Dynamics node's makeup and the FeedbackDelay's `mix` each scale a signal +the Dynamics node's makeup, the FeedbackDelay's `mix` and the Multiply's +`mix` each scale a signal the node has already worked out. Moved on a playing node, each walks there in a straight line over 20 ms (`audiodsp_level_ramp.h`), so the move cannot click. None of these controls feeds the node's own state, so the walked output is @@ -33,6 +34,7 @@ import audiocore import audiodynamics import audioecho +import audiomath import audioshaper MOVE_AT = 4 @@ -94,6 +96,21 @@ def delay(rate, channels, mix): feedback=0.3, mix=mix) +def multiply(rate, channels, mix): + """A Multiply whose modulator is a looped 50 Hz sine at full scale, so + the product swings the tone right down and back, as a tremolo does.""" + period = rate // 50 + values = array("h") + for i in range(period): + v = int(32767 * (0.5 + 0.5 * math.sin(2 * math.pi * i / period))) + values.extend([v] * channels) + node = audiomath.Multiply(mix=mix, sample_rate=rate, + channel_count=channels) + node.modulate(audiocore.RawSample(values, sample_rate=rate, + channel_count=channels)) + return node + + def _set(name): def move(node, value): node.set(**{name: value}) @@ -116,6 +133,7 @@ def move(node, value): ("allpass mix", allpass, _attr("mix"), 0.5, 0.0), ("dynamics makeup", dynamics, _set("makeup_db"), 0.0, 9.0), ("delay mix", delay, _set("mix"), 0.0, 1.0), + ("multiply mix", multiply, _set("mix"), 1.0, 0.0), )