diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 312a264..2870f96 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -241,7 +241,8 @@ jobs: # one for each of the phaser, the glide, the LFO shapes, the # sample-and-hold, the two-pole filters, the overdrive, the pan, the wavefolder, the low-pass gate and the modal resonator, and two for a # graph pulled as a source: its queue - # drained after the render, and a batch published in halves) goes into + # drained after the render, and a batch published in halves, and one for + # a graph that takes a running one's nodes a block late) goes into # the MicroPython render only, and the comparison must FAIL first. # The three-way with the CircuitPython unix build is a local run, as # for verify_dsp. @@ -250,7 +251,7 @@ jobs: MP=$GITHUB_WORKSPACE/micropython/ports/unix/build-standard/micropython PY=$GITHUB_WORKSPACE/audiodsp/.ci-venv/bin/python cd audiodsp - for planted in lsb late phaser lag lfo hold twopole overdrive pan wavefold lpg modal drain split; do + for planted in lsb late phaser lag lfo hold twopole overdrive pan wavefold lpg modal drain split offbeat; do if "$PY" tests/parity/verify_graph.py --micropython "$MP" --fault $planted > /dev/null; then echo "the planted fault passed, so this comparison proves nothing: $planted" >&2 exit 1 diff --git a/.github/workflows/test-cpython.yml b/.github/workflows/test-cpython.yml index 19a57db..3126b30 100644 --- a/.github/workflows/test-cpython.yml +++ b/.github/workflows/test-cpython.yml @@ -49,6 +49,24 @@ jobs: # PyDevices/audiocomponents now. - run: python -m unittest discover -s tests -p "test_*.py" - run: python tests/parity/verify_acceptance.py + # carry() hands a running node to a graph built beside it. The two + # mistakes it could make in C are built in on purpose here, one at a + # time: the delay line left pointing at the source's, and the source's + # pending parameter change dropped. The probe must FAIL on each, then + # pass on the wheel (the unit tests above ran it). + - name: Graph.carry's planted faults are caught + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' + run: | + python -m pip install setuptools + for planted in 1 2; do + CFLAGS="-DAUDIODSP_GRAPH_CARRY_FAULT=$planted" python setup.py -q build_ext \ + --build-temp /tmp/carry$planted/t --build-lib /tmp/carry$planted/l + if PYTHONPATH=/tmp/carry$planted/l:src/cpython python tests/parity/graph_carry_probe.py > /tmp/carry$planted.txt; then + echo "the planted fault passed, so this probe proves nothing: $planted" >&2 + exit 1 + fi + tail -n 4 /tmp/carry$planted.txt + done # The CPython half of the deinit surface (the native half runs in # clean-build.yml, on a real MicroPython build). The planted fault runs # first and must fail. diff --git a/docs/how-it-works/audiograph.md b/docs/how-it-works/audiograph.md index 79271a6..ea8a7fa 100644 --- a/docs/how-it-works/audiograph.md +++ b/docs/how-it-works/audiograph.md @@ -338,6 +338,38 @@ With nobody else pulling, `run(out, block, enable)` still works as it always has, and drains the queue first, so a graph you drive yourself sees changes the same way. +## Changing a patch while it plays + +A graph's shape is fixed once it plays, so a patch change builds a second +graph beside the first and swaps it in. `carry(node, src_graph, src_node)` +hands the new graph a running node's state: its voice, envelope, oscillator +phase, filter memory and delay line continue where `src_graph` left them, +and the next `run()` renders what the old graph's next block would have. + +```python +new = ag.Graph(sample_rate=48000, frames=128, channel_count=2) +# ... build the changed patch, set its parameters from the panel ... +for old_node, new_node in matched: # the nodes both patches have + new.carry(new_node, old, old_node) +# the next block comes from new; drop old +``` + +Do it between two blocks: after the old graph's last `run()` and before the +new one's first. The new graph keeps its own wiring and parameters. A +parameter that differs from the old node's lands on the next block, as a +`set()` would, and so does a change the old graph had queued but not yet +rendered. A delay keeps its own wow table: an equal copy of the one playing +plays on without a break, and a different one takes over with the dip +`table()` gives. A table the old graph lent stays held while the new graph +might still read it. + +`carry()` raises `ValueError` when the two nodes are of different kinds, were +built with different `arg`s (a delay of another length), hold state of a +different size, or belong to graphs at different sample rates, and +`TypeError` when `src_graph` is not a graph. A node it refuses keeps the +fresh state it was built with. Neither graph may be playing from the pump +while you carry. + ## Limits, and what is checked A block is at most 256 frames, a node has at most eight inputs and sixteen diff --git a/src/audiograph/Graph.c b/src/audiograph/Graph.c index e0e7234..7970742 100644 --- a/src/audiograph/Graph.c +++ b/src/audiograph/Graph.c @@ -263,6 +263,54 @@ static mp_obj_t audiograph_graph_reset(mp_obj_t self_in, mp_obj_t node) { static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_reset_obj, audiograph_graph_reset); +// carry(node, src_graph, src_node): hands a node of another graph (or of +// this one) over to `node`, its running state and all, so a graph compiled +// beside a running one can take over at a block boundary. Both queues are +// drained first. Refuses a node of another kind, built with another arg, of +// another size, or from a graph at another sample rate. +static mp_obj_t audiograph_graph_carry(size_t n_args, const mp_obj_t *args) { + (void)n_args; + audiograph_graph_obj_t *self = graph_idle(args[0]); + if (!mp_obj_is_type(args[2], &audiograph_graph_type)) { + mp_raise_TypeError(MP_ERROR_TEXT("src_graph must be a Graph")); + } + audiograph_graph_obj_t *src = graph_idle(args[2]); + mp_int_t node = mp_obj_get_int(args[1]); + mp_int_t src_node = mp_obj_get_int(args[3]); + if (node < 0 || src_node < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("bad node")); + } + audiodsp_graph_drain(&src->graph); + audiodsp_graph_drain(&self->graph); + switch (audiodsp_graph_carry(&self->graph, (uint32_t)node, &src->graph, + (uint32_t)src_node)) { + case AUDIODSP_GRAPH_CARRY_OK: + break; + case AUDIODSP_GRAPH_CARRY_KIND: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the nodes are of different kinds")); + case AUDIODSP_GRAPH_CARRY_ARG: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the nodes were built with different args")); + case AUDIODSP_GRAPH_CARRY_SIZE: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the nodes' state differs in size")); + case AUDIODSP_GRAPH_CARRY_RATE: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the graphs run at different sample rates")); + default: + mp_raise_ValueError(MP_ERROR_TEXT("bad node")); + } + // a delay may still be playing the source's wow table: keep it alive here + if (src != self) { + size_t len = 0; + mp_obj_t *items = NULL; + mp_obj_list_get(src->keep, &len, &items); + for (size_t i = 0; i < len; ++i) { + mp_obj_list_append(self->keep, items[i]); + } + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_carry_obj, 4, 4, + audiograph_graph_carry); + // enable(mask): the groups a pull renders, queued like set(), so a note's // gate and its group's enable land in the same block. static mp_obj_t audiograph_graph_enable(mp_obj_t self_in, mp_obj_t mask) { @@ -477,6 +525,7 @@ static const mp_rom_map_elem_t audiograph_graph_locals_table[] = { { MP_ROM_QSTR(MP_QSTR_get), MP_ROM_PTR(&audiograph_graph_get_obj) }, { MP_ROM_QSTR(MP_QSTR_table), MP_ROM_PTR(&audiograph_graph_table_obj) }, { MP_ROM_QSTR(MP_QSTR_reset), MP_ROM_PTR(&audiograph_graph_reset_obj) }, + { MP_ROM_QSTR(MP_QSTR_carry), MP_ROM_PTR(&audiograph_graph_carry_obj) }, { MP_ROM_QSTR(MP_QSTR_run), MP_ROM_PTR(&audiograph_graph_run_obj) }, { MP_ROM_QSTR(MP_QSTR_peak), MP_ROM_PTR(&audiograph_graph_peak_obj) }, { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&audiograph_graph_read_obj) }, diff --git a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c index 96af70a..e1ea6be 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c @@ -122,6 +122,14 @@ //| """Clear a node's running state at the next block.""" //| ... //| +//| def carry(self, node: int, src_graph: Graph, src_node: int) -> None: +//| """Hand node ``src_node`` of ``src_graph`` over to ``node``: its running +//| state and its config, so this graph renders from here on what the +//| source would have. The node keeps its own wiring and parameters. +//| Both queues are drained first. Raises ``ValueError`` for nodes of +//| different kinds, args or sizes, or graphs at different rates.""" +//| ... +//| //| def run(self, out: Optional[WriteableBuffer] = None, block: int = 0, //| enable: int = 0xFFFFFFFF) -> int: //| """Render one block into ``out`` at block ``block`` and return the @@ -340,6 +348,50 @@ static mp_obj_t audiograph_graph_reset(mp_obj_t self_in, mp_obj_t node) { static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_reset_obj, audiograph_graph_reset); +// carry(node, src_graph, src_node). See audiodsp_graph_carry(). +static mp_obj_t audiograph_graph_carry(size_t n_args, const mp_obj_t *args) { + (void)n_args; + audiograph_graph_obj_t *self = graph_self(args[0]); + if (!mp_obj_is_type(args[2], &audiograph_graph_type)) { + mp_raise_TypeError(MP_ERROR_TEXT("src_graph must be a Graph")); + } + audiograph_graph_obj_t *src = graph_self(args[2]); + mp_int_t node = mp_obj_get_int(args[1]); + mp_int_t src_node = mp_obj_get_int(args[3]); + if (node < 0 || src_node < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("bad node")); + } + audiodsp_graph_drain(&src->graph); + audiodsp_graph_drain(&self->graph); + switch (audiodsp_graph_carry(&self->graph, (uint32_t)node, &src->graph, + (uint32_t)src_node)) { + case AUDIODSP_GRAPH_CARRY_OK: + break; + case AUDIODSP_GRAPH_CARRY_KIND: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the nodes are of different kinds")); + case AUDIODSP_GRAPH_CARRY_ARG: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the nodes were built with different args")); + case AUDIODSP_GRAPH_CARRY_SIZE: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the nodes' state differs in size")); + case AUDIODSP_GRAPH_CARRY_RATE: + mp_raise_ValueError(MP_ERROR_TEXT("carry: the graphs run at different sample rates")); + default: + mp_raise_ValueError(MP_ERROR_TEXT("bad node")); + } + // a delay may still be playing the source's wow table: keep it alive here + if (src != self) { + size_t len = 0; + mp_obj_t *items = NULL; + mp_obj_list_get(src->keep, &len, &items); + for (size_t i = 0; i < len; ++i) { + mp_obj_list_append(self->keep, items[i]); + } + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_carry_obj, 4, 4, + audiograph_graph_carry); + static mp_obj_t audiograph_graph_enable(mp_obj_t self_in, mp_obj_t mask) { audiograph_graph_obj_t *self = graph_self(self_in); graph_push(self, AUDIODSP_GRAPH_OP_ENABLE, 0, 0, 0.0f, @@ -500,6 +552,7 @@ static const mp_rom_map_elem_t audiograph_graph_locals_table[] = { { MP_ROM_QSTR(MP_QSTR_get), MP_ROM_PTR(&audiograph_graph_get_obj) }, { MP_ROM_QSTR(MP_QSTR_table), MP_ROM_PTR(&audiograph_graph_table_obj) }, { MP_ROM_QSTR(MP_QSTR_reset), MP_ROM_PTR(&audiograph_graph_reset_obj) }, + { MP_ROM_QSTR(MP_QSTR_carry), MP_ROM_PTR(&audiograph_graph_carry_obj) }, { MP_ROM_QSTR(MP_QSTR_run), MP_ROM_PTR(&audiograph_graph_run_obj) }, { MP_ROM_QSTR(MP_QSTR_peak), MP_ROM_PTR(&audiograph_graph_peak_obj) }, { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&audiograph_graph_read_obj) }, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index b49ae77..1894631 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -4148,6 +4148,76 @@ static PyObject *graph_state_reset(audiodsp_graph_object_t *self, Py_RETURN_NONE; } +// carry(node, src_graph, src_node): hands a node of another graph (or of +// this one) over to `node`, running state and all. See audiodsp_graph_carry(). +static PyObject *graph_state_carry_in(audiodsp_graph_object_t *self, long node, + audiodsp_graph_object_t *src, long src_node) { + if (!graph_live(self) || !graph_live(src)) { + return NULL; + } + if (node < 0 || src_node < 0) { + PyErr_SetString(PyExc_ValueError, "bad node"); + return NULL; + } + audiodsp_graph_drain(&src->graph); + audiodsp_graph_drain(&self->graph); + const char *refused = NULL; + switch (audiodsp_graph_carry(&self->graph, (uint32_t)node, &src->graph, + (uint32_t)src_node)) { + case AUDIODSP_GRAPH_CARRY_OK: + break; + case AUDIODSP_GRAPH_CARRY_KIND: + refused = "carry: the nodes are of different kinds"; + break; + case AUDIODSP_GRAPH_CARRY_ARG: + refused = "carry: the nodes were built with different args"; + break; + case AUDIODSP_GRAPH_CARRY_SIZE: + refused = "carry: the nodes' state differs in size"; + break; + case AUDIODSP_GRAPH_CARRY_RATE: + refused = "carry: the graphs run at different sample rates"; + break; + default: + refused = "bad node"; + break; + } + if (refused != NULL) { + PyErr_SetString(PyExc_ValueError, refused); + return NULL; + } + // a delay may still be playing the source's wow table: keep it alive here + if (src != self) { + Py_ssize_t n = PyList_GET_SIZE(src->keep); + for (Py_ssize_t i = 0; i < n; ++i) { + if (PyList_Append(self->keep, PyList_GET_ITEM(src->keep, i)) < 0) { + return NULL; + } + } + } + Py_RETURN_NONE; +} + +static PyObject *graph_state_carry_locked(PyObject *self, PyObject *args) { + long node, src_node; + PyObject *src; + if (!PyArg_ParseTuple(args, "lOl:carry", &node, &src, &src_node)) { + return NULL; + } + if (Py_TYPE(src) != Py_TYPE(self)) { + PyErr_SetString(PyExc_TypeError, "src_graph must be a Graph"); + return NULL; + } + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(self); + Py_BEGIN_CRITICAL_SECTION(src); + result = graph_state_carry_in((audiodsp_graph_object_t *)self, node, + (audiodsp_graph_object_t *)src, src_node); + Py_END_CRITICAL_SECTION(); + Py_END_CRITICAL_SECTION(); + return result; +} + // enable(mask): the groups a pull renders, queued like set(). static PyObject *graph_state_enable(audiodsp_graph_object_t *self, PyObject *arg) { @@ -4522,6 +4592,8 @@ static PyMethodDef graph_state_methods[] = { PyDoc_STR("table(node, int16_array): a feedback delay's wow shape, borrowed.")}, {"reset", (PyCFunction)graph_state_reset_locked, METH_O, PyDoc_STR("reset(node): clear a node's running state at the next block.")}, + {"carry", (PyCFunction)graph_state_carry_locked, METH_VARARGS, + PyDoc_STR("carry(node, src_graph, src_node): hand a node of src_graph over to node, its running state and config with it.")}, {"run", (PyCFunction)graph_state_run_locked, METH_VARARGS, PyDoc_STR("run(out=None, block=0, enable=0xFFFFFFFF): render one block; returns the sounding groups.")}, {"peak", (PyCFunction)graph_state_peak_locked, METH_O, diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index 36a96c6..51eded9 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -324,6 +324,7 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, node->noutputs = output == AUDIODSP_GRAPH_NONE ? 0 : 1; node->data = data; node->dirty = true; + node->arg = arg; const uint32_t rate = graph->sample_rate; switch (kind) { case AUDIODSP_GRAPH_OSC: { @@ -534,6 +535,95 @@ bool audiodsp_graph_set_table(audiodsp_graph_t *graph, uint32_t node, return audiodsp_feedback_delay_set_wow_shape(&d->config, table, length); } +// The planted faults that tests/parity/graph_carry_probe.py must catch, for a +// build made with -DAUDIODSP_GRAPH_CARRY_FAULT=n: 1 leaves a FBDELAY's line +// pointing at the source's, 2 drops the source's pending parameter change. +#ifndef AUDIODSP_GRAPH_CARRY_FAULT +#define AUDIODSP_GRAPH_CARRY_FAULT 0 +#endif + +int audiodsp_graph_carry(audiodsp_graph_t *dst, uint32_t node, + const audiodsp_graph_t *src, uint32_t src_node) { + if (node >= dst->nnodes || src_node >= src->nnodes) { + return AUDIODSP_GRAPH_CARRY_BAD_NODE; + } + audiodsp_graph_node_t *d = audiodsp_graph_node(dst, node); + const audiodsp_graph_node_t *s = audiodsp_graph_node(src, src_node); + if (d == s) { + return AUDIODSP_GRAPH_CARRY_OK; + } + if (d->kind != s->kind) { + return AUDIODSP_GRAPH_CARRY_KIND; + } + const audiodsp_graph_kind_t kind = (audiodsp_graph_kind_t)d->kind; + if (kind != AUDIODSP_GRAPH_PAN && !(d->arg == s->arg)) { + return AUDIODSP_GRAPH_CARRY_ARG; + } + const size_t size = audiodsp_graph_node_data_size(kind, dst->sample_rate, d->arg); + if (size != audiodsp_graph_node_data_size(kind, src->sample_rate, s->arg)) { + return AUDIODSP_GRAPH_CARRY_SIZE; + } + if (dst->sample_rate != src->sample_rate) { + return AUDIODSP_GRAPH_CARRY_RATE; + } + // What stays the destination's own: a PAN's right side, a delay's wow table. + int16_t right = 0; + const int16_t *wow = NULL; + uint32_t wow_shift = 0; + if (kind == AUDIODSP_GRAPH_PAN) { + right = ((const graph_pan_t *)d->data)->right; + } else if (kind == AUDIODSP_GRAPH_FBDELAY) { + wow = ((const graph_fbdelay_t *)d->data)->config.wow_shape; + wow_shift = ((const graph_fbdelay_t *)d->data)->config.wow_shape_shift; + } + if (size) { + memcpy(d->data, s->data, size); + } + switch (kind) { + case AUDIODSP_GRAPH_FBDELAY: { + // The line came with the state; the state must read this copy of it. + // A table that differs from the source's takes over as table() + // would hand it over: the depth dips, the table swaps at zero. + graph_fbdelay_t *f = d->data; + #if AUDIODSP_GRAPH_CARRY_FAULT != 1 + f->state.line = f->line; + #endif + f->config.wow_shape = wow; + f->config.wow_shape_shift = wow_shift; + // An equal copy of the table playing is the same table: no dip. + if (wow != NULL && f->state.wow_shape_playing != NULL && + f->state.wow_shape_playing != wow && + f->state.wow_shape_playing_shift == wow_shift && + memcmp(f->state.wow_shape_playing, wow, + sizeof(int16_t) << wow_shift) == 0) { + f->state.wow_shape_playing = wow; + } + break; + } + case AUDIODSP_GRAPH_PHASER: { + graph_phaser_t *ph = d->data; + ph->state.stage_state = ph->lanes; + break; + } + case AUDIODSP_GRAPH_MODAL: + modal_bind(d->data); + break; + case AUDIODSP_GRAPH_PAN: + ((graph_pan_t *)d->data)->right = right; + break; + default: + break; + } + d->ran = s->ran; + const bool moved = memcmp(d->param, s->param, sizeof(d->param)) != 0; + #if AUDIODSP_GRAPH_CARRY_FAULT == 2 + d->dirty = moved; + #else + d->dirty = s->dirty || moved; + #endif + return AUDIODSP_GRAPH_CARRY_OK; +} + bool audiodsp_graph_reset(audiodsp_graph_t *graph, uint32_t node) { if (node >= graph->nnodes) { return false; diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index 5adb7cd..7da0983 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -359,6 +359,8 @@ typedef struct { bool dirty; // Ran last block. A node skipped after running zeroes its output once. bool ran; + // The `arg` it was built with, which carry() holds a source node to. + float arg; // The kernel's config and state, sized by audiodsp_graph_node_data_size. void *data; } audiodsp_graph_node_t; @@ -498,6 +500,30 @@ bool audiodsp_graph_set_table(audiodsp_graph_t *graph, uint32_t node, // Clears a node's running state (the kernel's reset). bool audiodsp_graph_reset(audiodsp_graph_t *graph, uint32_t node); +// What audiodsp_graph_carry() returns. +enum { + AUDIODSP_GRAPH_CARRY_OK = 0, + AUDIODSP_GRAPH_CARRY_BAD_NODE, // a node index past its graph's nodes + AUDIODSP_GRAPH_CARRY_KIND, // the two nodes are of different kinds + AUDIODSP_GRAPH_CARRY_ARG, // built with different `arg`s + AUDIODSP_GRAPH_CARRY_SIZE, // their config and state differ in size + AUDIODSP_GRAPH_CARRY_RATE, // the graphs run at different sample rates +}; + +// Hands node `src_node` of `src` over to node `node` of `dst`: its running +// state (an oscillator's phase, an envelope's stage and level, a filter's or +// a delay line's contents, a generator's seed) and the config its parameters +// made, so `dst` renders from here on what `src` would have. `dst` keeps its +// own wiring, its parameters and its wow table: where a parameter differs +// from the source's, the node takes it as a set() would, at the next block. +// A PAN keeps its own right-side buffer, so its `arg` is not compared. A +// FBDELAY's line goes with it, repointed at `dst`'s copy; the wow table the +// source was playing stays borrowed, and the caller keeps it alive. Neither +// graph may be rendering, and both queues should be drained first. Returns +// one of AUDIODSP_GRAPH_CARRY_*. +int audiodsp_graph_carry(audiodsp_graph_t *dst, uint32_t node, + const audiodsp_graph_t *src, uint32_t src_node); + // Renders one block. `out` takes frames * channel_count samples from the // OUT node (and is untouched when there is none). Nodes whose group bit is // clear in `enable` are skipped; AUDIODSP_GRAPH_ALWAYS nodes always run. diff --git a/tests/parity/graph_carry_probe.py b/tests/parity/graph_carry_probe.py new file mode 100644 index 0000000..75089ef --- /dev/null +++ b/tests/parity/graph_carry_probe.py @@ -0,0 +1,367 @@ +"""audiograph's carry(): a graph built beside a running one takes over its nodes' state. + + graph_carry_probe.py # prints the render, then GRAPH CARRY PASS + graph_carry_probe.py --fault offbeat # the carry taken one block late + +The same file runs on CPython, MicroPython and CircuitPython. ``verify_graph.py`` +runs it on each interpreter and compares the renders sample for sample; this +file checks on its own that a carry hands every node kind over whole. + +One patch with every node kind in it (two oscillators, a ladder, a biquad, +three feedback delays with a wow table, an envelope on a gated voice group, +an LFO of each shape, noise, mixers, a VCA, a phaser, glides, a clocked and a +free sample-and-hold, a two-pole, a tanh, a pan, a wavefolder, a low-pass +gate, a modal resonator and, where the build has it, an overdrive) is built +three times: + +- R, the reference, renders 140 blocks. +- S, the source, renders the first 48 of them, and its block-48 changes are + queued (a gate, a cutoff, a delay's feedback), so they are pending when the + carry happens. +- D, the destination, is built fresh, handed every change S was handed (as a + patch compiled beside a running one is built from the panel as it stands), + and then takes each of S's nodes with ``carry()``. + +From block 48 on, D must render what R renders, on every node, byte for byte. +S keeps rendering for eight more blocks with other inputs, so a node that +still reads S's memory (a delay line, a phaser's stages, a modal bank) comes +out wrong; then S is dropped and the heap churned, so a wow table D borrowed +from S must have been kept alive. Of the three delays, the first shares one +wow table object across the graphs, the second has an equal copy in each, and +the third is handed a different table in D at the carry, which R is handed at +block 48 too: D must swap tables as R does, dipping the wow and coming back. + +A D that takes nothing must differ from R (the comparison sees state). And +carry() must refuse a node of another kind, one built with another arg, one +whose state differs in size and one from a graph at another sample rate. + +The planted faults of the C, for a build made with +``-DAUDIODSP_GRAPH_CARRY_FAULT=1`` (the delay line left pointing at the +source's) or ``=2`` (the source's pending parameter change dropped), make +this probe fail; CI builds both and runs it. ``--fault offbeat`` plants a +caller's mistake: D takes S's nodes one block after S rendered block 48, so +the carry is off the block boundary. +""" + +import gc +import sys +from array import array + +try: + import audiograph as ag + ag.MODAL + ag.Graph.carry +except (ImportError, AttributeError): + print("GRAPH PARITY SKIPPED: no audiograph carry here") + sys.exit(0) + +FAULTS = ("offbeat",) +FAULT = None +if "--fault" in sys.argv: + FAULT = sys.argv[sys.argv.index("--fault") + 1] + if FAULT not in FAULTS: + print("unknown fault %r" % FAULT) + sys.exit(2) + +RATE = 48000 +FRAMES = 128 +BLOCKS = 140 +SWAP = 48 +CHURN = 8 +UNIT = ag.UNIT +HAS_OVERDRIVE = hasattr(ag, "OVERDRIVE") +failures = [] +if not HAS_OVERDRIVE: + print("absent OVERDRIVE overdrive") + + +def check(ok, what): + if not ok: + failures.append(what) + + +# A triangle for the delays' wow, and a square for the third delay's new one. +WOW = [(i if i < 128 else 255 - i) * 256 - 16384 for i in range(256)] +WOW_NEW = [8192 if i < 32 else -8192 for i in range(64)] +SHARED_WOW = array("h", WOW) + + +class Patch: + """One build of the patch: its graph, its nodes by name and what it watches.""" + + def __init__(self, rate=RATE): + g = self.g = ag.Graph(sample_rate=rate, frames=FRAMES, channel_count=2, + buffers=64, nodes=64) + self.n = {} + self.watched = [] + self.wow_copy = array("h", WOW) # the second delay's table, this graph's own + self.wow_new = array("h", WOW_NEW) + + def node(name, kind, inputs, arg=0.0, group=ag.ALWAYS): + out = g.buffer() + self.n[name] = g.add(kind, group, inputs, out, arg) + self.watched.append((name, out)) + return out + + # what Python writes into the graph each block + self.note_b, self.clock_b, self.strike_b = g.buffer(), g.buffer(), g.buffer() + gate_b = node("gate", ag.CONST, ()) + lfo = {} + for name, shape in (("triangle", ag.LFO_TRIANGLE), ("sine", ag.LFO_SINE), + ("saw", ag.LFO_SAW), ("square", ag.LFO_SQUARE), + ("random", ag.LFO_RANDOM)): + lfo[name] = node("lfo_" + name, ag.LFO, ()) + g.set(self.n["lfo_" + name], ag.LFO_SHAPE, shape) + g.set(self.n["lfo_" + name], ag.LFO_AMPLITUDE, 4096.0) + g.set(self.n["lfo_triangle"], ag.LFO_RATE, 5.5) + g.set(self.n["lfo_sine"], ag.LFO_RATE, 3.25) + g.set(self.n["lfo_saw"], ag.LFO_RATE, 0.75) + g.set(self.n["lfo_square"], ag.LFO_RATE, 37.5) + g.set(self.n["lfo_random"], ag.LFO_RATE, 12.25) + noise_b = node("noise", ag.NOISE, ()) + g.set(self.n["noise"], 0, 4096.0) + glide_b = node("glide", ag.LAG, (self.note_b, lfo["sine"], None)) + g.set(self.n["glide"], ag.LAG_TIME, 0.0625) + node("glide_pole", ag.LAG, (self.note_b,)) + g.set(self.n["glide_pole"], ag.LAG_SHAPE, ag.LAG_ONE_POLE) + g.set(self.n["glide_pole"], ag.LAG_TIME, 0.03125) + saw_b = node("osc", ag.OSC, (glide_b, lfo["triangle"], None, None)) + g.set(self.n["osc"], ag.OSC_FREQUENCY, 110.0) + g.set(self.n["osc"], ag.OSC_WAVEFORM, 1) + g.set(self.n["osc"], ag.OSC_LEVEL, 0.5) + g.set(self.n["osc"], ag.OSC_FM_DEPTH, 0.125) + # the voice: its own group, gated, parked for some blocks + pulse_b = node("osc_voice", ag.OSC, (None, None, lfo["saw"], None), group=0) + g.set(self.n["osc_voice"], ag.OSC_FREQUENCY, 330.0) + g.set(self.n["osc_voice"], ag.OSC_WAVEFORM, 2) + g.set(self.n["osc_voice"], ag.OSC_PWM_DEPTH, 0.25) + env_b = node("env", ag.ENV, (gate_b,), group=0) + g.set(self.n["env"], ag.ENV_ATTACK, 0.015625) + g.set(self.n["env"], ag.ENV_DECAY, 0.125) + g.set(self.n["env"], ag.ENV_SUSTAIN, 0.5) + g.set(self.n["env"], ag.ENV_RELEASE, 0.0625) + voice_b = node("vca", ag.VCA, (pulse_b, env_b, None), group=0) + g.set(self.n["vca"], ag.VCA_GAIN, 0.75) + lad_b = node("ladder", ag.LADDER, (saw_b, lfo["sine"])) + g.set(self.n["ladder"], ag.LADDER_RESONANCE, 0.5) + g.set(self.n["ladder"], ag.LADDER_CUTOFF_OCTAVES, 1.0) + bq_b = node("biquad", ag.BIQUAD, (noise_b,)) + g.set(self.n["biquad"], ag.BIQUAD_MODE, 2) + g.set(self.n["biquad"], ag.BIQUAD_FREQUENCY, 1500.0) + g.set(self.n["biquad"], ag.BIQUAD_Q, 2.0) + mix_b = node("mix", ag.MIX, (lad_b, voice_b, bq_b)) + g.set(self.n["mix"], 2, 0.25) + for name, table in (("delay_shared", SHARED_WOW), ("delay_copy", self.wow_copy), + ("delay_new", SHARED_WOW)): + node(name, ag.FBDELAY, (mix_b,), 40.0) + d = self.n[name] + g.set(d, ag.DELAY_MS, 12.5) + g.set(d, ag.DELAY_FEEDBACK, 0.5) + g.set(d, ag.DELAY_MIX, 0.5) + g.set(d, ag.DELAY_DAMPING_HZ, 6000.0) + g.set(d, ag.DELAY_WOW_HZ, 2.0) + g.set(d, ag.DELAY_WOW_DEPTH_MS, 2.0) + g.table(d, table) + ph_b = node("phaser", ag.PHASER, (saw_b,), 6.0) + g.set(self.n["phaser"], ag.PHASER_FEEDBACK, 0.5) + g.set(self.n["phaser"], ag.PHASER_MIX, 0.5) + node("hold_clock", ag.SAMPLE_HOLD, (noise_b, self.clock_b)) + g.set(self.n["hold_clock"], ag.SH_LAG, 0.125) + node("hold_rate", ag.SAMPLE_HOLD, (saw_b, None)) + g.set(self.n["hold_rate"], ag.SH_RATE, 750.0) + node("twopole", ag.TWOPOLE, (saw_b, lfo["square"])) + g.set(self.n["twopole"], ag.TWOPOLE_RESONANCE, 0.75) + node("tanh", ag.TANH, (ph_b,)) + g.set(self.n["tanh"], ag.TANH_DRIVE, 4.0) + fold_b = node("fold", ag.WAVEFOLD, (saw_b, lfo["random"])) + g.set(self.n["fold"], ag.WAVEFOLD_FOLD, 0.5) + node("lpg", ag.LPG, (fold_b, None, self.strike_b)) + g.set(self.n["lpg"], ag.LPG_DECAY, 0.125) + node("modal", ag.MODAL, (noise_b, self.strike_b)) + g.set(self.n["modal"], ag.MODAL_FREQUENCY, 220.0) + g.set(self.n["modal"], ag.MODAL_MIX, 0.75) + left_b, right_b = g.buffer(), g.buffer() + self.n["pan"] = g.add(ag.PAN, ag.ALWAYS, + tuple(b for _, b in self.watched[-12:-4]), left_b, right_b) + self.watched += [("pan_l", left_b), ("pan_r", right_b)] + for i in range(8): + g.set(self.n["pan"], ag.PAN_PAN + i, (i - 3.5) / 4.0) + g.set(self.n["pan"], i, 0.25) + self.n["out"] = g.add(ag.OUT, ag.ALWAYS, (left_b, right_b), None) + g.set(self.n["out"], 0, 0.5) + # last, so a build without it numbers every other node (and seeds its + # noise) the same + if HAS_OVERDRIVE: + node("overdrive", ag.OVERDRIVE, (saw_b,)) + g.set(self.n["overdrive"], ag.OVERDRIVE_DRIVE, 0.75) + + self.pcm = array("h", bytes(4 * FRAMES)) + self.block = array("h", bytes(2 * FRAMES)) + + def drive(self, b, role="R"): + """Hand the graph block ``b``'s changes: sets, resets and written buffers.""" + g, n = self.g, self.n + if b % 24 in (0, 12): + g.set(n["gate"], 0, UNIT if b % 24 == 0 else 0.0) + if b % 3 == 0: + g.set(n["ladder"], ag.LADDER_CUTOFF_HZ, 250.0 + 125.0 * (b % 16)) + if b % 5 == 0: + g.set(n["osc_voice"], ag.OSC_FREQUENCY, 220.0 + 27.5 * (b % 7)) + if b == SWAP: + g.set(n["delay_shared"], ag.DELAY_FEEDBACK, 0.75) + g.set(n["mix"], 0, 0.5) + g.set(n["modal"], ag.MODAL_FREQUENCY, 330.0) + if role != "S": # the new table comes with the new graph + g.table(n["delay_new"], self.wow_new) + if b == 20 or b == SWAP + 10: + g.reset(n["biquad"]) + if b == 30: + g.set(n["lfo_random"], ag.LFO_SHAPE, ag.LFO_SINE) + if b == 36: + g.set(n["lfo_random"], ag.LFO_SHAPE, ag.LFO_RANDOM) + note = (0, 4096, -2731, 1365, 9557, -8192)[(b // 6) % 6] + blk = self.block + for i in range(FRAMES): + blk[i] = note + g.write(self.note_b, blk) + for i in range(FRAMES): + blk[i] = UNIT if (i // 24) % 2 == 1 else 0 + g.write(self.clock_b, blk) + for i in range(FRAMES): + blk[i] = UNIT if b % 9 == 4 else 0 + g.write(self.strike_b, blk) + + def run(self, b): + """Render block ``b``: the voice group is parked over blocks 66 to 71.""" + return self.g.run(self.pcm, 0, 0 if 66 <= b < 72 else 1) + + def outputs(self): + """Every watched buffer's block, and the output's, as lists.""" + got = [] + for name, buf in self.watched: + self.g.read(buf, self.block) + got.append((name, list(self.block))) + got.append(("out", list(self.pcm))) + return got + + +def carry_all(dst, src): + """D takes every one of S's nodes, as a recompiled patch would.""" + for name in src.n: + dst.g.carry(dst.n[name], src.g, src.n[name]) + + +# -- R, the reference, and S, the source, side by side up to the swap; S's +# block-48 changes queued. Each graph renders as the comparison goes, so +# nothing is kept but the counts (a whole render does not fit a small heap). +r = Patch() +s = Patch() +for b in range(SWAP): + r.drive(b) + r.run(b) + s.drive(b, "S") + s.run(b) +s.drive(SWAP, "S") + +# -- D, built beside it from the same changes, then given S's nodes +d = Patch() +for b in range(SWAP + 1): + d.drive(b, "D") +# a D that takes nothing, for the comparison's own sake +fresh = Patch() +for b in range(SWAP + 1): + fresh.drive(b, "D") +if FAULT == "offbeat": + s.run(SWAP) # the carry lands a block after S rendered the swap block +carry_all(d, s) + +differ = {} +untaken = None +churn = [] +for k, b in enumerate(range(SWAP, BLOCKS)): + if s is not None: + # S goes on with other inputs: D must read none of S's memory + s.drive(b + 500, "S") + s.run(b + 500) + if k == CHURN: + s = None + gc.collect() + churn = [array("h", [12345] * 256) for _ in range(64)] + r.drive(b) + r.run(b) + if b != SWAP: + d.drive(b, "D") + fresh.drive(b, "D") + d.run(b) + fresh.run(b) + want = r.outputs() + got = d.outputs() + for (name, a), (_, c) in zip(want, got): + print("node %d %s %s" % (b, name, " ".join([str(v) for v in c]))) + if a != c: + differ[name] = differ.get(name, 0) + 1 + if untaken is None: + untaken = set(name for name, _ in want) + for (name, a), (_, c) in zip(want, fresh.outputs()): + if a != c: + untaken.discard(name) + want = got = None +del churn + +# -- D against R, node by node +for name in sorted(differ): + print("differs %s %d blocks" % (name, differ[name])) +check(not differ, "the carried graph renders what the reference renders, on every node") + +# -- a D that took nothing differs from R wherever state matters +for name in ("osc", "osc_voice", "env", "ladder", "biquad", "delay_shared", "delay_copy", + "delay_new", "lfo_random", "noise", "glide", "glide_pole", "phaser", + "hold_clock", "twopole", "lpg", "modal"): + check(name not in untaken, "%s depends on carried state" % name) + +# -- refusals +REFUSALS = ( + ("kind", lambda: _pair(ag.OSC, 0.0, ag.LADDER, 0.0), "kind"), + ("arg", lambda: _pair(ag.FBDELAY, 20.0, ag.FBDELAY, 30.0), "arg"), + ("size", lambda: _pair(ag.FBDELAY, 20.0, ag.FBDELAY, 20.0, 44100), "size"), + ("rate", lambda: _pair(ag.OSC, 0.0, ag.OSC, 0.0, 44100), "rate"), +) + + +def _pair(kind_a, arg_a, kind_b, arg_b, rate_b=RATE): + a = ag.Graph(sample_rate=RATE, frames=FRAMES, buffers=2, nodes=2) + b = ag.Graph(sample_rate=rate_b, frames=FRAMES, buffers=2, nodes=2) + na = a.add(kind_a, ag.ALWAYS, (None,), a.buffer(), arg_a) + nb = b.add(kind_b, ag.ALWAYS, (None,), b.buffer(), arg_b) + a.carry(na, b, nb) + + +for name, attempt, word in REFUSALS: + try: + attempt() + check(False, "carry refuses a %s mismatch" % name) + print("refusal %s none" % name) + except ValueError as e: + check(word in str(e), "the %s refusal says so: %s" % (name, e)) + print("refusal %s ValueError" % name) +g1 = ag.Graph(frames=FRAMES, buffers=2, nodes=2) +g1.add(ag.CONST, ag.ALWAYS, (), g1.buffer()) +for bad in ((5, g1, 0), (0, g1, 5), (-1, g1, 0)): + try: + g1.carry(*bad) + check(False, "carry refuses node %r" % (bad,)) + except ValueError: + pass +try: + g1.carry(0, object(), 0) + check(False, "carry refuses a source that is no graph") +except TypeError: + pass +g1.carry(0, g1, 0) # a node onto itself changes nothing + +if failures: + for what in failures: + print("FAIL " + what) + print("GRAPH CARRY FAIL") + sys.exit(1) +print("GRAPH CARRY PASS") diff --git a/tests/parity/verify_graph.py b/tests/parity/verify_graph.py index 564077f..0af99bc 100644 --- a/tests/parity/verify_graph.py +++ b/tests/parity/verify_graph.py @@ -7,14 +7,17 @@ verify_graph.py --micropython PATH --fault phaser # must FAIL (and lag, lfo, hold) verify_graph.py --micropython PATH --fault twopole # must FAIL (and overdrive, pan, wavefold, lpg, modal) verify_graph.py --micropython PATH --fault drain # must FAIL (and split) + verify_graph.py --micropython PATH --fault offbeat # must FAIL -Runs two probes on this CPython (``audiograph`` from the installed wheel) and +Runs the probes on this CPython (``audiograph`` from the installed wheel) and on each interpreter named, and compares the renders sample for sample. ``graph_parity_probe.py`` is the runner: every output sample of every block, the buffers' peaks and a mono graph's blocks. ``graph_nodes_probe.py`` is the phaser, the glide, the LFO's shapes, the sample-and-hold, the two-pole filters, the overdrive, the pan, the wavefolder, the low-pass gate and the modal resonator: every sample each of them writes. ``graph_source_probe.py`` is the graph pulled as a source, -through its queue, which must render what ``run()`` renders. The block headers (which voices ran, which were still +through its queue, which must render what ``run()`` renders. ``graph_carry_probe.py`` is a +graph built beside a running one taking over its nodes with ``carry()``: every +sample every node writes after the swap. The block headers (which voices ran, which were still sounding), the parameters read back and the probes' own checks must match exactly. @@ -51,6 +54,7 @@ ("nodes", HERE / "graph_nodes_probe.py", ("phaser", "lag", "lfo", "hold", "twopole", "overdrive", "pan", "wavefold", "lpg", "modal")), ("source", HERE / "graph_source_probe.py", ("drain", "split")), + ("carry", HERE / "graph_carry_probe.py", ("offbeat",)), ) FAULTS = tuple(f for _, _, faults in PROBES for f in faults) #: Lines whose fields after the tag are samples, compared within the tolerance. diff --git a/tests/test_cpython_audiograph.py b/tests/test_cpython_audiograph.py index 2f3df1d..47478e8 100644 --- a/tests/test_cpython_audiograph.py +++ b/tests/test_cpython_audiograph.py @@ -11,6 +11,9 @@ | every method the MicroPython `Graph` has exists here | the same set | | `hold()` keeps a batch back until `commit()`, and `get()` reads the last `set()` | exact | | a queue too small for a batch splits it and still applies all of it | counted | +| `graph_carry_probe.py`: a graph that takes a running one's nodes renders what it would have | GRAPH CARRY PASS | +| `carry()` refuses a node of another kind, arg, state size or sample rate, a bad node and a non-graph | ValueError, TypeError | +| a wow table a carried delay borrowed from its source outlives the source | held | The renders themselves are held to MicroPython's and CircuitPython's by `tests/parity/verify_graph.py`, sample for sample; this file is what the wheel @@ -165,6 +168,68 @@ def test_a_bad_set_is_refused_before_it_is_queued(self): with self.assertRaises(ValueError): ag.Graph(queue=-1) + def test_a_carried_graph_renders_what_its_source_would_have(self): + result = subprocess.run( + [sys.executable, + str(ROOT / "tests" / "parity" / "graph_carry_probe.py")], + capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(result.returncode, 0, result.stdout[-2000:] + result.stderr) + self.assertIn("GRAPH CARRY PASS", result.stdout) + + @staticmethod + def _one(kind, arg=0.0, rate=48000): + g = ag.Graph(sample_rate=rate, frames=8, buffers=2, nodes=2) + return g, g.add(kind, ag.ALWAYS, (None,), g.buffer(), arg) + + def test_carry_refuses_another_kind(self): + a, na = self._one(ag.OSC) + b, nb = self._one(ag.LADDER) + with self.assertRaisesRegex(ValueError, "kind"): + a.carry(na, b, nb) + + def test_carry_refuses_another_arg(self): + a, na = self._one(ag.FBDELAY, 20.0) + b, nb = self._one(ag.FBDELAY, 30.0) + with self.assertRaisesRegex(ValueError, "arg"): + a.carry(na, b, nb) + + def test_carry_refuses_another_size(self): + a, na = self._one(ag.FBDELAY, 20.0) + b, nb = self._one(ag.FBDELAY, 20.0, rate=44100) + with self.assertRaisesRegex(ValueError, "size"): + a.carry(na, b, nb) + + def test_carry_refuses_another_rate(self): + a, na = self._one(ag.OSC) + b, nb = self._one(ag.OSC, rate=44100) + with self.assertRaisesRegex(ValueError, "rate"): + a.carry(na, b, nb) + + def test_carry_refuses_a_bad_node_or_a_non_graph(self): + a, na = self._one(ag.OSC) + b, nb = self._one(ag.OSC) + for bad in ((5, b, nb), (na, b, 5), (-1, b, nb)): + with self.assertRaises(ValueError): + a.carry(*bad) + with self.assertRaises(TypeError): + a.carry(na, object(), nb) + a.carry(na, a, na) # onto itself: nothing moves + + def test_a_carried_wow_table_outlives_its_source(self): + a, na = self._one(ag.FBDELAY, 20.0) + b, nb = self._one(ag.FBDELAY, 20.0) + wow = array("h", [0, 8000, 0, -8000]) + for g, n in ((a, na), (b, nb)): + g.set(n, ag.DELAY_WOW_HZ, 5.0) + g.set(n, ag.DELAY_WOW_DEPTH_MS, 2.0) + b.table(nb, wow) + b.run(None) + a.carry(na, b, nb) + del b + with self.assertRaises(BufferError): # a holds it now + wow.append(0) + a.run(None) + def test_a_deinited_graph_refuses_everything(self): g, a, _b = self._pair() g.deinit()