Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/clean-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/test-cpython.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions docs/how-it-works/audiograph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/audiograph/Graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) },
Expand Down
53 changes: 53 additions & 0 deletions src/circuitpython_spike/shared-bindings/audiograph/Graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) },
Expand Down
72 changes: 72 additions & 0 deletions src/cpython/_audiodsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading