diff --git a/docs/upstream-diff.md b/docs/upstream-diff.md index c395719..e496b54 100644 --- a/docs/upstream-diff.md +++ b/docs/upstream-diff.md @@ -3172,7 +3172,7 @@ a CircuitPython module would not exist on a stock board, so a `Plate` written against it would silently be a different effect there. `audiofreeverb` is not touched by any of this. -Four details worth recording: +Six details worth recording: - **Every line write is a magnitude truncation, not a rounding, and that is what makes the tail reach exact zero.** A recirculating `int16` network that @@ -3205,6 +3205,41 @@ Four details worth recording: a starved chain gets silence and the tail stops with the source rather than ringing on. A class that wants the tail rung out feeds the tank silence for as long as its `tail_samples` says. +- **The tilt's pole runs while `tone_db` is 0** (audiodsp#168, 2026-09-28). + At 0 the output skips the tilt, because its gains are exactly 1 and + `s + (v - s)` is not always `v` in `float`, so a node held at 0 renders what + it always did. Until then the pole stopped there too, and held the state it + had when Tone reached 0: moved off 0 after the tail had died, the node + played `state * (low_gain - high_gain)` out of exact silence, 1 382 LSB at + 48 kHz stereo for a move back to +12 dB. Now Tone comes back exactly as it + would from 2^-24 dB, where both gains round to 1 and the pole never + stopped. *Rejected:* following the signal while out (`s = v`, a store + rather than a multiply-add). Out of silence it is silent too, but the tilt + then comes back as a pole at the last sample rather than at the signal's + low band, and the first millisecond or so of wet is wrong: back to +12 dB + while noise plays, 2 402 LSB off at 48 kHz stereo, against 2 995 frozen. + Holding the state at 0 is wrong the same way (1 914). +- **`set(delays=..., taps=...)` re-cuts a playing node in place** + (audiodsp#169, 2026-09-28). The line table and the tap table size and index + the allocation, so until then `set()` refused them and the only way to + change a reverb's size or character mid-stream was a new node. That lost + audio: the node pulls its source a buffer at a time and plays 256 frames a + block, so between blocks it can hold the unplayed rest of a source buffer, + and those frames went with the old node (512 frames of a 1024- or + 2048-frame source for a move 1536 frames in; a RawSample handed whole + replayed from its start). Now the node keeps its source and the frames it + holds; the new tables are checked on a copy, the network is allocated (or + cleared, at the same size) and starts exactly as a newly built node's does, + lines, filters, predelay and modulation phase, and options in the same call + apply after it. `sample_rate`, `channel_count` and `max_predelay_ms` stay + fixed. No new name: the two keywords are the constructor's. *Rejected:* a + way to read the held frames out of the old node and hand them to a new one + (a new public method, and a pointer into the source's memory handed across + objects); and the old node leaving its frames somewhere a new node playing + the same source would find them (hidden state shared between objects, and + wrong the moment anything pulls the source in between). `reset_buffer` + still drops the held frames, as every node in the family does; + `clear()` has always kept them. **What it costs.** Counted from the source, with everything switched on and the default 14-tap table: about 62 multiplies per stereo frame -- input diff --git a/src/audioverb/Tank.c b/src/audioverb/Tank.c index 8caaab5..68dbb72 100644 --- a/src/audioverb/Tank.c +++ b/src/audioverb/Tank.c @@ -196,19 +196,102 @@ static mp_obj_t audioverb_tank_play(mp_obj_t self_in, mp_obj_t sample) { } static MP_DEFINE_CONST_FUN_OBJ_2(audioverb_tank_play_obj, audioverb_tank_play); -static mp_obj_t audioverb_tank_set(size_t n_args, const mp_obj_t *args, - mp_map_t *kw_args) { - audioverb_tank_obj_t *self = MP_OBJ_TO_PTR(args[0]); - (void)n_args; - for (size_t i = 0; i < kw_args->alloc; ++i) { - if (!mp_map_slot_is_filled(kw_args, i)) { +// `delays` and `taps` in `set()` re-cut the network in place (audiodsp#169). +// A class that changes a reverb's size or character used to have to build a +// new node for it, and the old node took with it any source frames it had +// pulled and not yet played -- up to a source buffer less a block, lost from +// the dry as well as the wet. The node now keeps its source and those frames; +// the network starts empty, exactly as a newly built node's does. Everything +// that can be refused is checked before anything running changes. +static bool tank_is_topology_keyword(qstr name) { + return name == MP_QSTR_delays || name == MP_QSTR_taps; +} + +static void tank_check_options(const mp_map_t *kw) { + 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 (tank_is_topology_keyword(name)) { continue; } - qstr name = mp_obj_str_get_qstr(kw_args->table[i].key); if (tank_is_shape_keyword(name)) { mp_raise_msg_varg(&mp_type_TypeError, MP_ERROR_TEXT("'%q' is fixed at construction"), name); } + bool known = false; + for (size_t option = 0; option < MP_ARRAY_SIZE(tank_option_names); + ++option) { + if (tank_option_names[option].name == name) { + known = true; + break; + } + } + if (!known) { + mp_raise_msg_varg(&mp_type_TypeError, + MP_ERROR_TEXT("unknown Tank option '%q'"), name); + } + } +} + +static void tank_recut(audioverb_tank_obj_t *self, mp_obj_t delays, + mp_obj_t taps) { + uint32_t frames[AUDIODSP_TANK_LINES]; + uint32_t frame_count = 0; + float tap_values[AUDIODSP_TANK_MAX_TAPS * 4u]; + uint32_t tap_count = 0; + if (delays != MP_OBJ_NULL) { + float values[AUDIODSP_TANK_LINES]; + frame_count = tank_read_floats(delays, values, AUDIODSP_TANK_LINES); + for (uint32_t line = 0; line < frame_count; ++line) { + frames[line] = values[line] < 0.0f ? 0u : (uint32_t)values[line]; + } + } + if (taps != MP_OBJ_NULL) { + tap_count = tank_read_floats(taps, tap_values, + AUDIODSP_TANK_MAX_TAPS * 4u); + } + audiodsp_tank_config_t recut; + tank_raise_status(audiodsp_tank_recut(&recut, &self->config, + delays != MP_OBJ_NULL ? frames : NULL, frame_count, + taps != MP_OBJ_NULL ? tap_values : NULL, tap_count)); + const uint32_t old_samples = audiodsp_tank_buffer_samples(&self->config); + const uint32_t samples = audiodsp_tank_buffer_samples(&recut); + int16_t *old_lines = self->state.lines[0]; + int16_t *lines = old_lines; + if (samples != old_samples) { + // Allocated and cleared before the pump is held off, so a refused + // allocation leaves the node as it was, and the pump waits only for + // the swap. + lines = m_malloc((size_t)samples * sizeof(int16_t)); + memset(lines, 0, (size_t)samples * sizeof(int16_t)); + } + audiodsp_pump_lock_acquire(); + self->config = recut; + if (lines == old_lines) { + // The same size: cleared where it is, under the lock, as clear() is. + memset(lines, 0, (size_t)samples * sizeof(int16_t)); + } + audiodsp_tank_state_init(&self->state, &self->config, lines); + audiodsp_pump_lock_release(); + if (lines != old_lines) { + m_del(int16_t, old_lines, old_samples); + } +} + +static mp_obj_t audioverb_tank_set(size_t n_args, const mp_obj_t *args, + mp_map_t *kw_args) { + audioverb_tank_obj_t *self = MP_OBJ_TO_PTR(args[0]); + (void)n_args; + tank_check_options(kw_args); + mp_map_elem_t *delays = + mp_map_lookup(kw_args, MP_OBJ_NEW_QSTR(MP_QSTR_delays), MP_MAP_LOOKUP); + mp_map_elem_t *taps = + mp_map_lookup(kw_args, MP_OBJ_NEW_QSTR(MP_QSTR_taps), MP_MAP_LOOKUP); + if (delays != NULL || taps != NULL) { + tank_recut(self, delays != NULL ? delays->value : MP_OBJ_NULL, + taps != NULL ? taps->value : MP_OBJ_NULL); } tank_apply_kwargs(self, kw_args); audiodsp_pump_lock_acquire(); diff --git a/src/circuitpython_spike/shared-bindings/audioverb/Tank.c b/src/circuitpython_spike/shared-bindings/audioverb/Tank.c index 4b40d71..318bc85 100644 --- a/src/circuitpython_spike/shared-bindings/audioverb/Tank.c +++ b/src/circuitpython_spike/shared-bindings/audioverb/Tank.c @@ -45,8 +45,9 @@ //| mix: float = 0.3, //| ) -> None: //| """Create a reverberation tank. ``max_predelay_ms``, ``delays`` and -//| ``taps`` size the allocation and cut the topology, and none of the -//| three can change afterwards. +//| ``taps`` size the allocation and cut the topology. +//| ``max_predelay_ms`` cannot change afterwards; ``delays`` and +//| ``taps`` can be re-cut with `set`, which empties the network. //| //| ``delays`` is twelve line lengths in frames - the four input //| diffusers, then each tank half's modulated all-pass, delay, @@ -254,21 +255,98 @@ MP_DEFINE_CONST_FUN_OBJ_2(audioverb_tank_play_obj, audioverb_tank_play); //| def set(self, **options: float) -> None: //| """Change settings mid-stream. The lines keep their contents; only -//| what the network does to them changes.""" +//| what the network does to them changes. +//| +//| ``delays`` and ``taps`` are the exception: either one re-cuts the +//| network in place, and every line and filter starts empty, exactly +//| as a newly built tank's would. The source, and any source frames +//| the tank has pulled and not yet played, stay where they are, so +//| the dry does not skip. The other shape keywords (``sample_rate``, +//| ``channel_count``, ``max_predelay_ms``) are fixed at construction.""" //| ... -static mp_obj_t audioverb_tank_set(size_t n_args, const mp_obj_t *args, - mp_map_t *kw_args) { - audioverb_tank_obj_t *self = MP_OBJ_TO_PTR(args[0]); - (void)n_args; - for (size_t i = 0; i < kw_args->alloc; ++i) { - if (!mp_map_slot_is_filled(kw_args, i)) { +// `delays` and `taps` re-cut the network in place (audiodsp#169): the +// MicroPython binding's tank_recut, line for line, without the pump lock this +// runtime does not have. +static bool tank_is_topology_keyword(qstr name) { + return name == MP_QSTR_delays || name == MP_QSTR_taps; +} + +static void tank_check_options(const mp_map_t *kw) { + 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 (tank_is_topology_keyword(name)) { continue; } - qstr name = mp_obj_str_get_qstr(kw_args->table[i].key); if (tank_is_shape_keyword(name)) { mp_raise_msg_varg(&mp_type_TypeError, MP_ERROR_TEXT("'%q' is fixed at construction"), name); } + bool known = false; + for (size_t option = 0; option < MP_ARRAY_SIZE(tank_option_names); + ++option) { + if (tank_option_names[option].name == name) { + known = true; + break; + } + } + if (!known) { + mp_raise_msg_varg(&mp_type_TypeError, + MP_ERROR_TEXT("unknown Tank option '%q'"), name); + } + } +} + +static void tank_recut(audioverb_tank_obj_t *self, mp_obj_t delays, + mp_obj_t taps) { + uint32_t frames[AUDIODSP_TANK_LINES]; + uint32_t frame_count = 0; + float tap_values[AUDIODSP_TANK_MAX_TAPS * 4u]; + uint32_t tap_count = 0; + if (delays != MP_OBJ_NULL) { + float values[AUDIODSP_TANK_LINES]; + frame_count = tank_read_floats(delays, values, AUDIODSP_TANK_LINES); + for (uint32_t line = 0; line < frame_count; ++line) { + frames[line] = values[line] < 0.0f ? 0u : (uint32_t)values[line]; + } + } + if (taps != MP_OBJ_NULL) { + tap_count = tank_read_floats(taps, tap_values, + AUDIODSP_TANK_MAX_TAPS * 4u); + } + audiodsp_tank_config_t recut; + tank_raise_status(audiodsp_tank_recut(&recut, &self->config, + delays != MP_OBJ_NULL ? frames : NULL, frame_count, + taps != MP_OBJ_NULL ? tap_values : NULL, tap_count)); + const uint32_t old_samples = audiodsp_tank_buffer_samples(&self->config); + const uint32_t samples = audiodsp_tank_buffer_samples(&recut); + int16_t *old_lines = self->state.lines[0]; + int16_t *lines = old_lines; + if (samples != old_samples) { + lines = m_malloc((size_t)samples * sizeof(int16_t)); + } + memset(lines, 0, (size_t)samples * sizeof(int16_t)); + self->config = recut; + audiodsp_tank_state_init(&self->state, &self->config, lines); + if (lines != old_lines) { + m_del(int16_t, old_lines, old_samples); + } +} + +static mp_obj_t audioverb_tank_set(size_t n_args, const mp_obj_t *args, + mp_map_t *kw_args) { + audioverb_tank_obj_t *self = MP_OBJ_TO_PTR(args[0]); + (void)n_args; + tank_check_options(kw_args); + mp_map_elem_t *delays = + mp_map_lookup(kw_args, MP_OBJ_NEW_QSTR(MP_QSTR_delays), MP_MAP_LOOKUP); + mp_map_elem_t *taps = + mp_map_lookup(kw_args, MP_OBJ_NEW_QSTR(MP_QSTR_taps), MP_MAP_LOOKUP); + if (delays != NULL || taps != NULL) { + tank_recut(self, delays != NULL ? delays->value : MP_OBJ_NULL, + taps != NULL ? taps->value : MP_OBJ_NULL); } tank_apply_kwargs(self, kw_args); audiodsp_tank_config_finish(&self->config); diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 89d97cf..428a661 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -1931,6 +1931,49 @@ static PyObject *tank_state_reset(audiodsp_tank_object_t *self, Py_RETURN_NONE; } +// A re-cut of a running node (audiodsp#169): the new tables checked on a +// copy, then the network rebuilt empty on them, exactly as tank_state_init +// leaves a new one. `delays` or `taps` may be None to keep that table. +static PyObject *tank_state_recut(audiodsp_tank_object_t *self, + PyObject *args) { + PyObject *delays = Py_None; + PyObject *taps = Py_None; + if (!PyArg_ParseTuple(args, "OO:recut", &delays, &taps)) return NULL; + uint32_t frames[AUDIODSP_TANK_LINES]; + uint32_t frame_count = 0; + float tap_values[AUDIODSP_TANK_MAX_TAPS * 4u]; + uint32_t tap_count = 0; + if (delays != Py_None) { + float values[AUDIODSP_TANK_LINES]; + if (tank_state_floats(delays, values, AUDIODSP_TANK_LINES, + &frame_count) < 0) return NULL; + for (uint32_t line = 0; line < frame_count; ++line) { + frames[line] = values[line] < 0.0f ? 0u : (uint32_t)values[line]; + } + } + if (taps != Py_None) { + if (tank_state_floats(taps, tap_values, AUDIODSP_TANK_MAX_TAPS * 4u, + &tap_count) < 0) return NULL; + } + audiodsp_tank_config_t recut; + if (tank_state_status(audiodsp_tank_recut(&recut, &self->config, + delays != Py_None ? frames : NULL, frame_count, + taps != Py_None ? tap_values : NULL, tap_count)) < 0) return NULL; + const uint32_t samples = audiodsp_tank_buffer_samples(&recut); + int16_t *lines = self->lines; + if (samples != audiodsp_tank_buffer_samples(&self->config)) { + lines = PyMem_Calloc((size_t)samples, sizeof(int16_t)); + if (lines == NULL) return PyErr_NoMemory(); + PyMem_Free(self->lines); + self->lines = lines; + } else { + memset(lines, 0, (size_t)samples * sizeof(int16_t)); + } + self->config = recut; + audiodsp_tank_state_init(&self->state, &self->config, lines); + Py_RETURN_NONE; +} + static PyObject *tank_state_process(audiodsp_tank_object_t *self, PyObject *argument) { Py_buffer input = {0}; @@ -1956,6 +1999,7 @@ static PyMethodDef tank_state_methods[] = { {"configure", (PyCFunction)tank_state_configure, METH_VARARGS, NULL}, {"finish", (PyCFunction)tank_state_finish, METH_NOARGS, NULL}, {"reset", (PyCFunction)tank_state_reset, METH_NOARGS, NULL}, + {"recut", (PyCFunction)tank_state_recut, METH_VARARGS, NULL}, {"process", (PyCFunction)tank_state_process, METH_O, NULL}, {NULL, NULL, 0, NULL}, }; diff --git a/src/cpython/audioverb.py b/src/cpython/audioverb.py index b2e25e9..e03a907 100644 --- a/src/cpython/audioverb.py +++ b/src/cpython/audioverb.py @@ -29,7 +29,8 @@ 8..11 tank half B: the same four `taps` is four values per tap -- channel, line index, offset in frames, gain -- -and at most 32 of them. Both default to Dattorro's own published table, scaled +and at most 32 of them. Either can be re-cut later with `set()`, which empties +the network and keeps the source where it is. Both default to Dattorro's own published table, scaled from 29761 Hz to whatever `sample_rate` says. His figures, for a class that wants to scale or re-cut them itself: @@ -87,10 +88,11 @@ "mix": 11, } -#: Keywords that shape the allocation rather than setting an option. They are -#: constructor-only, and `set()` refuses them rather than silently ignoring -#: them. +#: Keywords that shape the allocation rather than setting an option. The first +#: three are constructor-only, and `set()` refuses them rather than silently +#: ignoring them; `delays` and `taps` in `set()` re-cut the network in place. _SHAPE = ("sample_rate", "channel_count", "max_predelay_ms", "delays", "taps") +_FIXED = ("sample_rate", "channel_count", "max_predelay_ms") class Tank(_AudioSample): @@ -130,11 +132,29 @@ def _apply(self, options): def set(self, **options): """Change settings mid-stream. The lines keep their contents; only what - the network does to them changes.""" + the network does to them changes. + + `delays` and `taps` are the exception: either one re-cuts the network + in place, and every line and filter starts empty, exactly as a newly + built tank's would. The source, and any source frames the tank has + pulled and not yet played, stay where they are, so the dry does not + skip (audiodsp#169). A class that changes a reverb's size or character + used to need a new node for it, and the frames went with the old one. + `sample_rate`, `channel_count` and `max_predelay_ms` are fixed at + construction.""" self._check() - for name in _SHAPE: + for name in _FIXED: if name in options: raise TypeError("%r is fixed at construction" % (name,)) + delays = options.pop("delays", None) + taps = options.pop("taps", None) + for name in options: + if name not in _OPTIONS: + raise TypeError("unknown Tank option %r" % (name,)) + if delays is not None or taps is not None: + self._state.recut( + None if delays is None else [float(v) for v in delays], + None if taps is None else [float(v) for v in taps]) self._apply(options) self._state.finish() diff --git a/src/shared/audiodsp_tank.c b/src/shared/audiodsp_tank.c index f914517..acfb34d 100644 --- a/src/shared/audiodsp_tank.c +++ b/src/shared/audiodsp_tank.c @@ -200,6 +200,23 @@ audiodsp_tank_status_t audiodsp_tank_set_taps(audiodsp_tank_config_t *config, return AUDIODSP_TANK_OK; } +audiodsp_tank_status_t audiodsp_tank_recut(audiodsp_tank_config_t *recut, + const audiodsp_tank_config_t *running, const uint32_t *frames, + uint32_t frame_count, const float *taps, uint32_t tap_values) { + *recut = *running; + if (frames != NULL) { + const audiodsp_tank_status_t status = + audiodsp_tank_set_delays(recut, frames, frame_count); + if (status != AUDIODSP_TANK_OK) { + return status; + } + } + if (taps != NULL) { + return audiodsp_tank_set_taps(recut, taps, tap_values); + } + return AUDIODSP_TANK_OK; +} + void audiodsp_tank_config_init(audiodsp_tank_config_t *config, uint32_t sample_rate, float max_predelay_ms) { memset(config, 0, sizeof(*config)); @@ -593,12 +610,22 @@ void audiodsp_tank_process_s16(const audiodsp_tank_config_t *config, for (uint32_t channel = 0; channel < channels; ++channel) { float value = tapped[channel]; - if (config->tone_db != 0.0f && config->tone_coef > 0.0f) { + // The tilt's pole runs whether or not the tilt is applied. At + // tone_db 0 the output skips the tilt (its two gains are 1, and + // `s + (v - s)` is not always `v` in float), but a pole that + // stopped there would hold its last state and play it the moment + // Tone moved off 0: a tick out of exact silence (audiodsp#168). + // Tracking keeps it where a pole that never stopped would be, so + // Tone comes back in exactly as it would from 2^-24 dB. + if (config->tone_coef > 0.0f) { state->tone_state[channel] += config->tone_coef * (value - state->tone_state[channel]); - value = state->tone_state[channel] * config->tone_low_gain + - (value - state->tone_state[channel]) * - config->tone_high_gain; + if (config->tone_db != 0.0f) { + value = state->tone_state[channel] * + config->tone_low_gain + + (value - state->tone_state[channel]) * + config->tone_high_gain; + } } const float source = (float)in[frame * channels + channel]; out[frame * channels + channel] = diff --git a/src/shared/audiodsp_tank.h b/src/shared/audiodsp_tank.h index aec77a1..8cce1ac 100644 --- a/src/shared/audiodsp_tank.h +++ b/src/shared/audiodsp_tank.h @@ -90,7 +90,7 @@ typedef enum { //: The whole network as it was asked for. Held apart from the running state so //: `set()` can rewrite it mid-stream without emptying the lines. The line //: table and the tap table are the exception: they size the allocation, so -//: they are fixed once the state is initialised. +//: changing either is a re-cut (audiodsp_tank_recut), which empties it. typedef struct { uint32_t sample_rate; uint32_t channel_count; @@ -163,15 +163,29 @@ void audiodsp_tank_default_delays(uint32_t sample_rate, uint32_t *delays); uint32_t audiodsp_tank_default_taps(uint32_t sample_rate, float *taps); #define AUDIODSP_TANK_DEFAULT_TAPS 14u -// Re-cuts the topology. Both size the allocation, so both are applied before -// audiodsp_tank_state_init and are refused afterwards by construction: nothing -// re-reads them per frame except through the state's own pointers. +// Cut the topology. Both size or index the allocation, so on a new node both +// are applied before audiodsp_tank_state_init; on a running one they go +// through audiodsp_tank_recut, never straight onto the live config. audiodsp_tank_status_t audiodsp_tank_set_delays(audiodsp_tank_config_t *config, const uint32_t *frames, uint32_t count); // `values` is 4 floats per tap: channel, line index, offset in frames, gain. audiodsp_tank_status_t audiodsp_tank_set_taps(audiodsp_tank_config_t *config, const float *values, uint32_t count); +// Re-cuts a running node's topology, checked on a copy so nothing running +// changes if it is refused: `recut` becomes `running` with `frames` +// (AUDIODSP_TANK_LINES values; NULL keeps the lines) and then `taps` (4 +// floats a tap; NULL keeps the table) applied, in the constructor's order. +// The binding then allocates audiodsp_tank_buffer_samples(recut) (or clears +// the buffer it has, when that is the same size), installs `recut` and calls +// audiodsp_tank_state_init on it, so the network is exactly a newly built +// one's while the node's source, and any source frames it holds, stay where +// they are (audiodsp#169). Followed by audiodsp_tank_config_finish, since the +// modulation depth's ceiling is a line length. +audiodsp_tank_status_t audiodsp_tank_recut(audiodsp_tank_config_t *recut, + const audiodsp_tank_config_t *running, const uint32_t *frames, + uint32_t frame_count, const float *taps, uint32_t tap_values); + void audiodsp_tank_set_channel_count(audiodsp_tank_config_t *config, uint32_t channel_count); diff --git a/tests/parity/tank_state_probe.py b/tests/parity/tank_state_probe.py new file mode 100644 index 0000000..a15333d --- /dev/null +++ b/tests/parity/tank_state_probe.py @@ -0,0 +1,157 @@ +"""Deterministic Tank PCM across the node's own state changes. + + tank_state_probe.py audioverb + +`tank_probe.py` builds every network before anything plays and never moves +Tone through 0. This one does both, which is where the two 2026-09-28 fixes +live: the tilt's pole tracking while `tone_db` is 0 (audiodsp#168), and +`set(delays=..., taps=...)` re-cutting a playing node in place while it keeps +its source and the source frames it holds (audiodsp#169). Its own file, so +the probe above keeps rendering what it rendered. + +No oracle: what `verify_dsp.py` checks is that every interpreter renders it +identically. Two lines are not checksums: `peak` is the largest sample after +Tone comes back out of exact silence (0 since #168), and `wire-exact` says a +re-cut at `mix=0` on a 1024-frame source left the output the source, byte +for byte (True since #169). +""" + +import sys +from array import array + +import audiocore +import audiofilters + +MODULE = sys.argv[1] if len(sys.argv) > 1 else "audioverb" +verb = __import__(MODULE) + +SAMPLE_RATE = 8000 +FRAMES = 256 + + +def checksum(data): + value = 2166136261 + for byte in data: + value = ((value ^ byte) * 16777619) & 0xffffffff + return value + + +def noise(frames, channels, seed, level=11000): + values = array("h") + state = seed + span = 2 * level + 1 + for _index in range(frames * channels): + state = (state * 1103515245 + 12345) & 0x7fffffff + values.append(((state >> 8) % span) - level) + return values + + +def raw(values, channels): + return audiocore.RawSample(values, sample_rate=SAMPLE_RATE, + channel_count=channels) + + +def emit(tag, node, blocks): + for index in range(blocks): + data = bytes(audiocore.get_buffer(node)[1]) + print("tank-state", tag, index, len(data), sum(data), checksum(data)) + + +OPTIONS = {"decay": 0.7, "diffusion": 0.7, "damping_hz": 2500.0, + "bandwidth_hz": 3500.0, "mod_depth_ms": 0.3, "mod_rate_hz": 1.3, + "mix": 0.5, "max_predelay_ms": 20.0, "predelay_ms": 5.0} +LINES = [16, 12, 40, 28, 70, 460, 190, 380, 95, 430, 275, 330] +TAPS = [0, 9, 27, 0.6, 0, 5, 200, -0.6, 0, 7, 100, 0.6, + 1, 5, 36, 0.6, 1, 11, 12, -0.6, 1, 10, 90, -0.6] + + +def scaled(tenths): + """The network x tenths / 10, in integers: a float product truncated to + a frame count lands one frame apart on a single-precision build.""" + lines = [max(4, v * tenths // 10) for v in LINES] + taps = list(TAPS) + for index in range(0, len(taps), 4): + taps[index + 2] = min(taps[index + 2] * tenths // 10, + lines[taps[index + 1]] - 1) + return lines, taps + + +def node(channels, **extra): + options = dict(OPTIONS) + options.update(extra) + return verb.Tank(sample_rate=SAMPLE_RATE, channel_count=channels, + delays=LINES, taps=TAPS, **options) + + +# Tone out and back while the input plays (#168). +for channels in (2, 1): + tank = node(channels, tone_db=12.0) + tank.play(raw(noise(12 * FRAMES, channels, 17), channels)) + emit("tone-%d-in" % channels, tank, 2) + tank.set(tone_db=0.0) + emit("tone-%d-out" % channels, tank, 2) + tank.set(tone_db=-6.0) + emit("tone-%d-back" % channels, tank, 3) + +# Tone back out of exact silence (#168): the peak after the move. +for channels in (2, 1): + tank = node(channels, tone_db=12.0, decay=0.4) + tank.play(raw(noise(4 * FRAMES, channels, 23), channels)) + for _block in range(3): + audiocore.get_buffer(tank) + tank.set(tone_db=0.0) + audiocore.get_buffer(tank) + tank.play(raw(array("h", bytes(2 * FRAMES * channels)), channels)) + quiet = 0 + for _block in range(400): + quiet = quiet + 1 if not any(bytes(audiocore.get_buffer(tank)[1])) \ + else 0 + if quiet == 4: + break + tank.set(tone_db=12.0) + top = 0 + for _block in range(2): + for value in array("h", bytes(audiocore.get_buffer(tank)[1])): + top = max(top, abs(value)) + print("tank-state peak", channels, quiet, top) + +# Re-cut in place (#169): longer, shorter, the same size, and taps alone, +# with options in the same call. A RawSample handed whole, so the node holds +# the rest of it across every re-cut. +SAME = list(LINES) +SAME[5] -= 30 +SAME[9] += 30 +CUTS = (("longer", scaled(14)), ("shorter", scaled(3)), + ("same", (SAME, list(TAPS)))) +for channels in (2, 1): + for name, (lines, taps) in CUTS: + tank = node(channels, tone_db=3.0) + tank.play(raw(noise(14 * FRAMES, channels, 29), channels)) + emit("recut-%d-%s-a" % (channels, name), tank, 2) + tank.set(delays=lines, taps=taps, decay=0.5, mod_depth_ms=2.0) + emit("recut-%d-%s-b" % (channels, name), tank, 4) + tank = node(channels) + tank.play(raw(noise(10 * FRAMES, channels, 31), channels)) + emit("recut-%d-taps-a" % channels, tank, 2) + moved = list(TAPS) + moved[2] = 5 + tank.set(taps=moved) + emit("recut-%d-taps-b" % channels, tank, 3) + +# The wire across a re-cut at mix 0 on a 1024-frame source (#169). +for channels in (2, 1): + material = noise(10 * FRAMES, channels, 37) + adapter = audiofilters.Filter(filter=None, mix=1, + buffer_size=1024 * channels * 2, + sample_rate=SAMPLE_RATE, bits_per_sample=16, + samples_signed=True, channel_count=channels) + adapter.play(raw(material, channels), loop=False) + tank = node(channels, mix=0.0) + tank.play(adapter) + out = bytearray() + for block in range(9): + if block == 3: + tank.set(delays=scaled(13)[0], taps=scaled(13)[1]) + out += bytes(audiocore.get_buffer(tank)[1]) + print("tank-state wire-exact", channels, + bytes(out) == bytes(material)[:len(out)]) diff --git a/tests/parity/verify_dsp.py b/tests/parity/verify_dsp.py index 73b04bb..1666d91 100644 --- a/tests/parity/verify_dsp.py +++ b/tests/parity/verify_dsp.py @@ -151,6 +151,10 @@ # audiodsp#89. ("mixer_borrowed_block_probe.py", "audiomixer", {}, None), ("tank_probe.py", "audioverb", {}, None), + # The node's own state: Tone out and back through 0 (#168) and the + # network re-cut in place on a playing node (#169). Its own file, so the + # probe above keeps rendering what it rendered. + ("tank_state_probe.py", "audioverb", {}, None), ("flanger_probe.py", "audiodelays", {"circuitpython": "upstream's own Flanger overflows int32 in its wet " "interpolation on full-scale material and ours does " diff --git a/tests/test_cpython_audioverb.py b/tests/test_cpython_audioverb.py index bb9b5dd..5fb93bc 100644 --- a/tests/test_cpython_audioverb.py +++ b/tests/test_cpython_audioverb.py @@ -181,9 +181,10 @@ def test_unknown_option(self): audioverb.Tank(sample_rate=SAMPLE_RATE, roomsize=0.5) def test_set_refuses_what_construction_fixed(self): + """`delays` and `taps` re-cut in place (RecutTest); these three are + the node's shape and stay fixed.""" node = audioverb.Tank(sample_rate=SAMPLE_RATE) - for name in ("sample_rate", "channel_count", "max_predelay_ms", - "delays", "taps"): + for name in ("sample_rate", "channel_count", "max_predelay_ms"): with self.assertRaises(TypeError): node.set(**{name: 1}) @@ -198,6 +199,233 @@ def test_options_clamp_rather_than_raise(self): self.assertEqual(len(render(node, 2)), 2 * audioverb.FRAMES * 4) +def noise(frames, channels, seed=7, level=12000): + values = array("h") + state = seed + for _ in range(frames * channels): + state = (state * 1103515245 + 12345) & 0x7FFFFFFF + values.append(((state >> 8) % (2 * level + 1)) - level) + return values + + +class ToneStateTest(unittest.TestCase): + """T1-T2, audiodsp#168: the tilt's pole keeps tracking while `tone_db` + is 0, so Tone moved off 0 comes in from the signal and not from a state + frozen when it reached 0. + + T1 Tone out, silence until the wet is exact zero, Tone back in with + nothing playing: every output sample is 0. Main: 1 382 LSB stereo, + 707 mono at 48 kHz (tank_fix_repro.py); here at 8 kHz both widths + fail. + T2 Tone out and back in while the input plays: from the move on, the + node renders byte for byte what a node handed 2^-24 dB instead of 0 + renders (both tilt gains round to exactly 1 there, so that node is + flat and its pole never stops). Main differs for tens of frames; + so does a pole that follows the signal while out, and one held at 0. + """ + + RATE = 8000 + OPTIONS = dict(decay=0.7, diffusion=0.75, damping_hz=2500.0, + bandwidth_hz=3500.0, mod_depth_ms=0.2, mod_rate_hz=1.0, + mix=0.5, max_predelay_ms=20.0) + + def _node(self, channels, tone_db): + return audioverb.Tank(sample_rate=self.RATE, channel_count=channels, + tone_db=tone_db, **self.OPTIONS) + + def test_tone_back_in_after_silence_plays_nothing(self): + for channels in (2, 1): + for before, after in ((12.0, 12.0), (-12.0, 3.0)): + with self.subTest(channels=channels, before=before, + after=after): + node = self._node(channels, before) + node.play(audiocore.RawSample( + noise(4 * audioverb.FRAMES, channels), + sample_rate=self.RATE, channel_count=channels)) + render(node, 3) + node.set(tone_db=0.0) + render(node, 1) + node.play(audiocore.RawSample( + array("h", bytes(2 * audioverb.FRAMES * channels)), + sample_rate=self.RATE, channel_count=channels)) + quiet = 0 + for _ in range(400): + quiet = quiet + 1 if not any(render(node, 1)) else 0 + if quiet == 4: + break + self.assertEqual(quiet, 4, "the tail never died") + node.set(tone_db=after) + peak = max(abs(value) for value in + samples(render(node, 2))) + self.assertEqual(peak, 0) + + def test_tone_back_in_matches_a_pole_that_never_stopped(self): + for channels in (2, 1): + for before, after in ((12.0, 12.0), (-12.0, 6.0), (6.0, -3.0)): + with self.subTest(channels=channels, before=before, + after=after): + material = noise(12 * audioverb.FRAMES, channels, seed=11) + outputs = [] + for held in (0.0, 1.0 / 16777216.0): + node = self._node(channels, before) + node.play(audiocore.RawSample( + material, sample_rate=self.RATE, + channel_count=channels)) + render(node, 3) + node.set(tone_db=held) + render(node, 3) + node.set(tone_db=after) + outputs.append(render(node, 3)) + self.assertTrue(any(outputs[0])) + self.assertEqual(outputs[0], outputs[1]) + + +class RecutTest(unittest.TestCase): + """R1-R4, audiodsp#169: `set(delays=..., taps=...)` re-cuts a playing + node in place, so a class that changes a reverb's size or character no + longer builds a new node and loses the source frames the old one held. + + R1 At `mix=0` the output is the source byte for byte across a re-cut, + on a 1024-frame source (the node holds 768 frames between blocks) + and on a RawSample handed whole (it holds all of it). Main refuses + the keywords; a rebuilt node is 512 frames ahead of the source + (tank_fix_repro.py). + R2 From the re-cut on, the node renders byte for byte what a node built + on the new tables renders from the same source frame, with options + handed in the same call applied too: longer, shorter and same-size + networks, stereo and mono, with the modulation deep enough that the + new lines' ceiling holds it. Lines and filters that kept their + contents, a modulation oscillator that kept its phase, or a ceiling + from the old lines would each show. + R3 A refused re-cut (eleven lines, a tap past its line, a bad option in + the same call) raises and leaves the node exactly as it was. + R4 A re-cut on `taps` alone keeps the lines' lengths and still starts + the network empty. + """ + + RATE = 8000 + OPTIONS = dict(decay=0.7, diffusion=0.7, damping_hz=2500.0, + bandwidth_hz=3500.0, mod_depth_ms=0.3, mod_rate_hz=1.3, + tone_db=3.0, mix=0.5, max_predelay_ms=20.0, + predelay_ms=5.0) + LINES = [16, 12, 40, 28, 70, 460, 190, 380, 95, 430, 275, 330] + TAPS = [0, 9, 27, 0.6, 0, 5, 200, -0.6, 0, 7, 100, 0.6, + 1, 5, 36, 0.6, 1, 11, 12, -0.6, 1, 10, 90, -0.6] + + def _scaled(self, factor): + lines = [max(4, int(v * factor)) for v in self.LINES] + taps = list(self.TAPS) + for index in range(0, len(taps), 4): + taps[index + 2] = min(int(taps[index + 2] * factor), + lines[taps[index + 1]] - 1) + return lines, taps + + def _same_size(self): + """The same total, cut differently: the buffer is reused.""" + lines = list(self.LINES) + lines[5] -= 30 + lines[9] += 30 + return lines, list(self.TAPS) + + def test_the_dry_does_not_skip(self): + for block in (1024, 0): + for channels in (2, 1): + with self.subTest(block=block, channels=channels): + material = noise(10 * audioverb.FRAMES, channels, seed=5) + raw = audiocore.RawSample(material, sample_rate=self.RATE, + channel_count=channels) + source = raw + if block: + import audiofilters + source = audiofilters.Filter( + filter=None, mix=1, buffer_size=block * channels * 2, + sample_rate=self.RATE, bits_per_sample=16, + samples_signed=True, channel_count=channels) + source.play(raw, loop=False) + options = dict(self.OPTIONS, mix=0.0) + node = audioverb.Tank(sample_rate=self.RATE, + channel_count=channels, **options) + node.play(source) + head = render(node, 3) + lines, taps = self._scaled(1.3) + node.set(delays=lines, taps=taps) + tail = render(node, 6) + self.assertEqual(head + tail, + bytes(material)[:len(head + tail)]) + + def test_a_recut_node_is_a_new_node_on_the_same_source(self): + cuts = {"longer": self._scaled(1.4), "shorter": self._scaled(0.3), + "same size": self._same_size()} + for name, (lines, taps) in cuts.items(): + for channels in (2, 1): + with self.subTest(cut=name, channels=channels): + material = noise(12 * audioverb.FRAMES, channels, seed=9) + node = audioverb.Tank(sample_rate=self.RATE, + channel_count=channels, + delays=self.LINES, taps=self.TAPS, + **self.OPTIONS) + node.play(audiocore.RawSample( + material, sample_rate=self.RATE, + channel_count=channels)) + render(node, 4) + # 2 ms of modulation is 16 frames: past the shorter + # cut's ceiling (9.5 frames on its 21-frame line), inside + # the old lines' (34). + node.set(delays=lines, taps=taps, decay=0.5, + mod_depth_ms=2.0) + moved = render(node, 5) + + options = dict(self.OPTIONS, decay=0.5, mod_depth_ms=2.0) + fresh = audioverb.Tank(sample_rate=self.RATE, + channel_count=channels, + delays=lines, taps=taps, **options) + start = 4 * audioverb.FRAMES * channels + fresh.play(audiocore.RawSample( + material[start:], sample_rate=self.RATE, + channel_count=channels)) + self.assertTrue(any(moved)) + self.assertEqual(moved, render(fresh, 5)) + + def test_a_refused_recut_changes_nothing(self): + refusals = (dict(delays=[100] * 11), dict(taps=[0, 5, 10 ** 6, 0.5]), + dict(delays=self._scaled(1.4)[0], roomsize=0.5), + dict(delays=self._scaled(0.5)[0], taps=[0, 5, 999, 0.5])) + for refusal in refusals: + with self.subTest(refusal=sorted(refusal)): + material = noise(8 * audioverb.FRAMES, 2, seed=3) + renders = [] + for refuse in (True, False): + node = audioverb.Tank(sample_rate=self.RATE, + delays=self.LINES, taps=self.TAPS, + **self.OPTIONS) + node.play(audiocore.RawSample( + material, sample_rate=self.RATE, channel_count=2)) + out = render(node, 3) + if refuse: + with self.assertRaises((ValueError, TypeError)): + node.set(**refusal) + renders.append(out + render(node, 4)) + self.assertEqual(renders[0], renders[1]) + + def test_taps_alone_start_the_network_empty(self): + material = noise(8 * audioverb.FRAMES, 2, seed=4) + taps = list(self.TAPS) + taps[2] = 5 + node = audioverb.Tank(sample_rate=self.RATE, delays=self.LINES, + taps=self.TAPS, **self.OPTIONS) + node.play(audiocore.RawSample(material, sample_rate=self.RATE, + channel_count=2)) + render(node, 3) + node.set(taps=taps) + moved = render(node, 4) + fresh = audioverb.Tank(sample_rate=self.RATE, delays=self.LINES, + taps=taps, **self.OPTIONS) + fresh.play(audiocore.RawSample( + material[3 * audioverb.FRAMES * 2:], sample_rate=self.RATE, + channel_count=2)) + self.assertEqual(moved, render(fresh, 4)) + + if __name__ == "__main__": unittest.main()