diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 51e70154..4835266d 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -239,8 +239,9 @@ jobs: # MicroPython, compared sample for sample with a tolerance of 0 counts. # Each planted fault (one input count off, one set() a block late, and # one for each of the phaser, the glide, the LFO shapes and the - # sample-and-hold) goes into the MicroPython render only, and the - # comparison must FAIL first. + # sample-and-hold, and two for a graph pulled as a source: its queue + # drained after the render, and a batch published in halves) 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. - name: A graph renders the same samples on CPython and MicroPython @@ -248,7 +249,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; do + for planted in lsb late phaser lag lfo hold drain split; 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 @@ -285,6 +286,9 @@ jobs: "pump_probe.py writes --fault unlocked" \ "pump_probe.py tear --fault split" \ "pump_probe.py fade --fault cut" \ + "pump_probe.py graph --fault split" \ + "pump_probe.py graph --fault late" \ + "pump_probe.py graph --fault unqueued" \ "route_probe.py ring --fault flip" \ "route_probe.py events --fault drop" \ "route_probe.py tap --fault short" \ diff --git a/.github/workflows/test-cpython.yml b/.github/workflows/test-cpython.yml index b1e84224..d1d47820 100644 --- a/.github/workflows/test-cpython.yml +++ b/.github/workflows/test-cpython.yml @@ -75,6 +75,9 @@ jobs: "pump_probe.py identity --fault reuse" \ "pump_probe.py storm --fault stall" \ "pump_probe.py unpumpable --fault unpumpable" \ + "pump_probe.py graph --fault split" \ + "pump_probe.py graph --fault late" \ + "pump_probe.py graph --fault unqueued" \ "route_probe.py ring --fault flip" \ "route_probe.py events --fault drop" \ "route_probe.py tap --fault short" \ diff --git a/ROADMAP.md b/ROADMAP.md index 5e790b22..fa6953a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,10 +8,8 @@ audio pump holding up under live use such as a stompbox or a stage rig. - An RP2 pump driver: the pump on the second core with an I2S (PIO) sink, so RP2 boards get the same off-thread audio as ESP32. See [docs/pump-ports.md](docs/pump-ports.md) for how a port plugs in. -- `audiograph` as a source the audio pump pulls, so a graph renders on the - pump's core instead of the interpreter's, with `set()` changes carried to it - on the pump's event queue. An app's screen and a graph instrument can then - hold real time together on a dual-core board. +- `audiograph`: `set()` at a frame inside a block, so a note lands on its own + sample rather than at the top of the next block. - An LC3 codec module beside `audiomp3`, frame by frame: encode PCM to one frame, decode a frame (or a lost one) to PCM. Leaves room for LE Audio later. - A level ramp on `audiomixer` voices: a level move ramps over a set number of diff --git a/docs/audiograph.md b/docs/audiograph.md index 2a244c2c..f711a3df 100644 --- a/docs/audiograph.md +++ b/docs/audiograph.md @@ -143,15 +143,82 @@ can collect several blocks before it goes to a ring. `out` may be `None` to run the graph for its state alone. It raises `ValueError` when `out` cannot hold that block. +## Playing a graph from the pump + +A graph is also an audio sample. Hand it to the pump, to an output's +`play()`, or to a mixer voice, and it renders a block each time one is pulled, +on whichever thread or core pulls it. On a board whose pump has a core of its +own, the whole patch then renders there, and your code only changes it: + +```python +import audiopump + +g = ag.Graph(sample_rate=48000, frames=128, channel_count=2) +# ... build it as above ... +status = bytearray(audiopump.STATUS_BYTES) +audiopump.spawn(g, 1 << 30, status, ring=ring) # the pump pulls it now + +g.hold() # a patch change, as one +g.set(f, ag.LADDER_CUTOFF_HZ, 900.0) +g.set(f, ag.LADDER_RESONANCE, 0.6) +g.enable(0b11) # groups 0 and 1 play +g.commit() # all of it on one block +``` + +`set()`, `reset()` and `enable()` don't touch the running graph. Each one +goes into the graph's queue, and the renderer applies everything waiting at +the top of the next block it renders, so a change never lands halfway through +a block. Between `hold()` and `commit()` nothing is released, so a batch (a +patch change, a chord) lands on one block together. A change made without +`hold()` is released at once and lands on the next block. + +`get()` reads back the last value `set()` stored, whether or not a block has +heard it yet. `sounding()` returns `(sounding, frame, applied, pushed)`: the +groups still sounding after the last block, the frames rendered so far, and +how many changes have been applied and queued. A change counted in `pushed` +but not yet in `applied` hasn't been heard. Read `sounding` and `frame` +together and you know which block the mask belongs to, so code that times +notes against the audio can count blocks from `frame`. + +The queue holds `queue` changes (`Graph(queue=...)`; by default the larger of +256 and four per node). When it fills, the control side waits for the pump to +finish the block it is rendering and applies what is waiting itself, so +nothing is dropped; a held batch that overflowed lands across two blocks +instead of one. `stats()` counts both, as `overflows` and `splits`, with the +queue's `high_water`. Size the queue for your biggest batch and they stay 0. + +While the pump is pulling a graph, the calls that change its shape (`buffer()`, +`add()`, `table()`, `write()`) and a `run()` of your own raise +`RuntimeError`. Build the graph before you hand it over. `read()` and `peak()` +still work, but they can catch a buffer while the pump is rendering into it, +which is fine for a meter and not for anything that needs the whole block. +`deinit()` takes the pump's lock, so a graph released while the pump is +pulling it stops cleanly. + +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. + ## Limits, and what is checked A block is at most 256 frames, a node has at most eight inputs and sixteen parameters, and a group is 0 to 31. A graph refuses a node with nothing to write (every kind but `OUT` writes a buffer), an input or output id past -its buffers, and a node past its table. The runner takes no lock: its -buffers and state are touched only by the thread that calls `run()`, and -its output goes into a buffer you own, so an `audiopump.Ring` downstream -keeps its own synchronisation. +its buffers, and a node past its table. + +`tests/parity/graph_source_probe.py` renders one script of changes three +ways: through `run()`, pulled as a sample with batches held and committed, +and pulled with a queue so small that every patch change overflows it. The +three must agree to the sample, block by block, and `verify_graph.py` +compares the pulled render across interpreters as well. Its planted faults +drain the queue after the render instead of before and publish a held batch +in two halves. `tests/pump/pump_probe.py graph` hands a graph to the pump and +changes it from another thread: every block holds one value per channel from +one batch, a held batch is silent until `commit()`, and a committed one sounds +no later than the block after the one in flight. Where the pump has a thread +of its own, 3000 batches are committed while it pulls, and none may tear. +The graph also goes through the pump's identity and heap-locked allocation +gates. `tests/parity/graph_probe.py` holds the runner to its wiring (a value set reaches the output in the same run, through every node between), to its diff --git a/src/audiograph/Graph.c b/src/audiograph/Graph.c index beceea9a..ab85f162 100644 --- a/src/audiograph/Graph.c +++ b/src/audiograph/Graph.c @@ -5,23 +5,47 @@ #include +#include "audiopump/audiopump.h" +#include "cp_compat/context_manager_helpers.h" #include "py/binary.h" #include "py/runtime.h" +#include "shared/audiodsp_pump_lock.h" +// A live graph, or a raise. static audiograph_graph_obj_t *graph_self(mp_obj_t self_in) { - return MP_OBJ_TO_PTR(self_in); + audiograph_graph_obj_t *self = MP_OBJ_TO_PTR(self_in); + audiosample_check_for_deinit(&self->base); + return self; +} + +// The same, for a call that renders or rewires the graph: two renderers, or +// a node added while the pump walks the table, would race. +static audiograph_graph_obj_t *graph_idle(mp_obj_t self_in) { + audiograph_graph_obj_t *self = graph_self(self_in); + if (self->pumped && audiopump_is_running()) { + mp_raise_msg(&mp_type_RuntimeError, + MP_ERROR_TEXT("the pump is rendering this graph")); + } + return self; +} + +static void graph_push(audiograph_graph_obj_t *self, uint8_t op, + uint32_t node, uint32_t index, float value, uint32_t mask) { + audiodsp_graph_enqueue(&self->graph, op, node, index, value, mask, + audiodsp_pump_lock_acquire, audiodsp_pump_lock_release); } static mp_obj_t audiograph_graph_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { enum { ARG_sample_rate, ARG_frames, ARG_channel_count, ARG_buffers, - ARG_nodes }; + ARG_nodes, ARG_queue }; static const mp_arg_t allowed_args[] = { { MP_QSTR_sample_rate, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 48000} }, { MP_QSTR_frames, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 128} }, { MP_QSTR_channel_count, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 2} }, { MP_QSTR_buffers, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 64} }, { MP_QSTR_nodes, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 64} }, + { MP_QSTR_queue, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, @@ -42,12 +66,32 @@ static mp_obj_t audiograph_graph_make_new(const mp_obj_type_t *type, if (args[ARG_sample_rate].u_int < 1) { mp_raise_ValueError(MP_ERROR_TEXT("sample_rate must be at least 1")); } + mp_int_t queue = args[ARG_queue].u_int; + if (queue == 0) { + queue = 4 * nodes > (mp_int_t)AUDIODSP_GRAPH_QUEUE_MIN + ? 4 * nodes : (mp_int_t)AUDIODSP_GRAPH_QUEUE_MIN; + } + if (queue < 1 || queue > 65536) { + mp_raise_ValueError(MP_ERROR_TEXT("queue must be from 1 to 65536")); + } audiograph_graph_obj_t *self = mp_obj_malloc(audiograph_graph_obj_t, type); int16_t *bufs = m_new0(int16_t, (size_t)buffers * (size_t)frames); audiodsp_graph_node_t *table = m_new0(audiodsp_graph_node_t, (size_t)nodes); audiodsp_graph_init(&self->graph, (uint32_t)args[ARG_sample_rate].u_int, (uint32_t)frames, (uint32_t)channels, bufs, (uint32_t)buffers, table, (uint32_t)nodes); + audiodsp_graph_queue_init(&self->graph, + m_new0(audiodsp_graph_record_t, (size_t)queue), (uint32_t)queue, + m_new0(float, (size_t)nodes * AUDIODSP_GRAPH_MAX_PARAMS)); + self->out = m_new0(int16_t, 2u * (size_t)frames * (size_t)channels); + self->which = 0; + self->pumped = false; + self->base.sample_rate = (uint32_t)args[ARG_sample_rate].u_int; + self->base.max_buffer_length = (uint32_t)frames * (uint32_t)channels * 2u; + self->base.bits_per_sample = 16; + self->base.channel_count = (uint8_t)channels; + self->base.samples_signed = 1; + self->base.single_buffer = false; self->next_buffer = 0; self->keep = mp_obj_new_list(0, NULL); return MP_OBJ_FROM_PTR(self); @@ -55,7 +99,7 @@ static mp_obj_t audiograph_graph_make_new(const mp_obj_type_t *type, // buffer(): a fresh block buffer's id. static mp_obj_t audiograph_graph_buffer(mp_obj_t self_in) { - audiograph_graph_obj_t *self = graph_self(self_in); + audiograph_graph_obj_t *self = graph_idle(self_in); if (self->next_buffer >= self->graph.nbufs) { mp_raise_ValueError(MP_ERROR_TEXT("no buffers left")); } @@ -79,7 +123,7 @@ static int16_t buffer_id(mp_obj_t obj) { // a sequence of buffer ids (None for an open input), `output` a buffer id // or None. static mp_obj_t audiograph_graph_add(size_t n_args, const mp_obj_t *args) { - audiograph_graph_obj_t *self = graph_self(args[0]); + audiograph_graph_obj_t *self = graph_idle(args[0]); mp_int_t kind = mp_obj_get_int(args[1]); if (kind < 0 || kind >= (mp_int_t)AUDIODSP_GRAPH_KINDS) { mp_raise_ValueError(MP_ERROR_TEXT("unknown node kind")); @@ -113,24 +157,49 @@ static mp_obj_t audiograph_graph_add(size_t n_args, const mp_obj_t *args) { static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_add_obj, 5, 6, audiograph_graph_add); +// set(node, index, value): queued; the renderer applies it at the top of its +// next block, or with the rest of a held batch. static mp_obj_t audiograph_graph_set(size_t n_args, const mp_obj_t *args) { audiograph_graph_obj_t *self = graph_self(args[0]); (void)n_args; - if (!audiodsp_graph_set(&self->graph, (uint32_t)mp_obj_get_int(args[1]), - (uint32_t)mp_obj_get_int(args[2]), (float)mp_obj_get_float(args[3]))) { + mp_int_t node = mp_obj_get_int(args[1]); + mp_int_t index = mp_obj_get_int(args[2]); + float value = (float)mp_obj_get_float(args[3]); + if (node < 0 || index < 0 || + !audiodsp_graph_valid(&self->graph, (uint32_t)node, (uint32_t)index)) { mp_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); } + graph_push(self, AUDIODSP_GRAPH_OP_SET, (uint32_t)node, (uint32_t)index, + value, 0); return mp_const_none; } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_set_obj, 4, 4, audiograph_graph_set); +// _set_now(node, index, value): writes the parameter straight into the node, +// past the queue, racing whatever renders. Not for use: it exists so the +// pump probe can show its check catches a write that skips the queue. +static mp_obj_t audiograph_graph_set_now(size_t n_args, const mp_obj_t *args) { + audiograph_graph_obj_t *self = graph_self(args[0]); + (void)n_args; + if (!audiodsp_graph_set(&self->graph, (uint32_t)mp_obj_get_int(args[1]), + (uint32_t)mp_obj_get_int(args[2]), (float)mp_obj_get_float(args[3]))) { + mp_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_set_now_obj, 4, 4, + audiograph_graph_set_now); + +// get(node, index): the value set() last stored, whoever renders. static mp_obj_t audiograph_graph_get(mp_obj_t self_in, mp_obj_t node, mp_obj_t index) { audiograph_graph_obj_t *self = graph_self(self_in); float value = 0.0f; - if (!audiodsp_graph_get(&self->graph, (uint32_t)mp_obj_get_int(node), - (uint32_t)mp_obj_get_int(index), &value)) { + mp_int_t n = mp_obj_get_int(node); + mp_int_t i = mp_obj_get_int(index); + if (n < 0 || i < 0 || + !audiodsp_graph_get_shadow(&self->graph, (uint32_t)n, (uint32_t)i, &value)) { mp_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); } return mp_obj_new_float((mp_float_t)value); @@ -141,7 +210,7 @@ static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_get_obj, // table(node, int16_array): a feedback delay's wow shape, borrowed. static mp_obj_t audiograph_graph_table(mp_obj_t self_in, mp_obj_t node, mp_obj_t table) { - audiograph_graph_obj_t *self = graph_self(self_in); + audiograph_graph_obj_t *self = graph_idle(self_in); mp_buffer_info_t info; mp_get_buffer_raise(table, &info, MP_BUFFER_READ); if (info.typecode != 'h' || info.len % 2) { @@ -157,21 +226,86 @@ static mp_obj_t audiograph_graph_table(mp_obj_t self_in, mp_obj_t node, static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_table_obj, audiograph_graph_table); +// reset(node): queued, like set(). static mp_obj_t audiograph_graph_reset(mp_obj_t self_in, mp_obj_t node) { audiograph_graph_obj_t *self = graph_self(self_in); - if (!audiodsp_graph_reset(&self->graph, (uint32_t)mp_obj_get_int(node))) { + mp_int_t n = mp_obj_get_int(node); + if (n < 0 || !audiodsp_graph_valid(&self->graph, (uint32_t)n, 0)) { mp_raise_ValueError(MP_ERROR_TEXT("bad node")); } + graph_push(self, AUDIODSP_GRAPH_OP_RESET, (uint32_t)n, 0, 0.0f, 0); return mp_const_none; } static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_reset_obj, audiograph_graph_reset); +// 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) { + audiograph_graph_obj_t *self = graph_self(self_in); + graph_push(self, AUDIODSP_GRAPH_OP_ENABLE, 0, 0, 0.0f, + (uint32_t)mp_obj_get_int_truncated(mask)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_enable_obj, + audiograph_graph_enable); + +// hold() and commit(): what is queued between them lands in one block. +static mp_obj_t audiograph_graph_hold(mp_obj_t self_in) { + audiodsp_graph_hold(&graph_self(self_in)->graph); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_hold_obj, + audiograph_graph_hold); + +static mp_obj_t audiograph_graph_commit(mp_obj_t self_in) { + audiodsp_graph_commit(&graph_self(self_in)->graph); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_commit_obj, + audiograph_graph_commit); + +// sounding(): (groups sounding, frames rendered, records applied, records +// queued), as the renderer last published them; the last is this side's. +static mp_obj_t audiograph_graph_sounding(mp_obj_t self_in) { + audiograph_graph_obj_t *self = graph_self(self_in); + uint32_t sounding, frame, applied; + audiodsp_graph_published(&self->graph, &sounding, &frame, &applied); + mp_obj_t items[4] = { + mp_obj_new_int_from_uint(sounding), + mp_obj_new_int_from_uint(frame), + mp_obj_new_int_from_uint(applied), + mp_obj_new_int_from_uint(self->graph.pushed), + }; + return mp_obj_new_tuple(4, items); +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_sounding_obj, + audiograph_graph_sounding); + +// stats(): the queue's counts, as a dict. +static mp_obj_t audiograph_graph_stats(mp_obj_t self_in) { + audiograph_graph_obj_t *self = graph_self(self_in); + const audiodsp_graph_t *g = &self->graph; + uint32_t sounding, frame, applied; + audiodsp_graph_published(g, &sounding, &frame, &applied); + mp_obj_t d = mp_obj_new_dict(7); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_capacity), mp_obj_new_int_from_uint(g->capacity)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_pushed), mp_obj_new_int_from_uint(g->pushed)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_applied), mp_obj_new_int_from_uint(applied)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_overflows), mp_obj_new_int_from_uint(g->overflows)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_splits), mp_obj_new_int_from_uint(g->splits)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_high_water), mp_obj_new_int_from_uint(g->high_water)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_frame), mp_obj_new_int_from_uint(frame)); + return d; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_stats_obj, + audiograph_graph_stats); + // run(out=None, block=0, enable=0xFFFFFFFF): renders one block into `out` // at block `block` (frames * channel_count samples each), and returns the // mask of groups whose envelopes are still sounding. static mp_obj_t audiograph_graph_run(size_t n_args, const mp_obj_t *args) { - audiograph_graph_obj_t *self = graph_self(args[0]); + audiograph_graph_obj_t *self = graph_idle(args[0]); int16_t *out = NULL; if (n_args > 1 && args[1] != mp_const_none) { mp_buffer_info_t info; @@ -183,11 +317,13 @@ static mp_obj_t audiograph_graph_run(size_t n_args, const mp_obj_t *args) { } out = (int16_t *)((uint8_t *)info.buf + (size_t)block * block_bytes); } - uint32_t enable = 0xFFFFFFFFu; + uint32_t sounding; if (n_args > 3) { - enable = (uint32_t)mp_obj_get_int_truncated(args[3]); + sounding = audiodsp_graph_render_mask(&self->graph, out, + (uint32_t)mp_obj_get_int_truncated(args[3])); + } else { + sounding = audiodsp_graph_render(&self->graph, out); } - uint32_t sounding = audiodsp_graph_run(&self->graph, out, enable); return mp_obj_new_int_from_uint(sounding); } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_run_obj, 1, 4, @@ -231,7 +367,7 @@ static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_read_obj, // write(buffer, src): fill a block from an int16 buffer of `frames`. static mp_obj_t audiograph_graph_write(mp_obj_t self_in, mp_obj_t buffer, mp_obj_t src) { - audiograph_graph_obj_t *self = graph_self(self_in); + audiograph_graph_obj_t *self = graph_idle(self_in); int16_t *dst = block_of(self, buffer); mp_buffer_info_t info; mp_get_buffer_raise(src, &info, MP_BUFFER_READ); @@ -244,7 +380,73 @@ static mp_obj_t audiograph_graph_write(mp_obj_t self_in, mp_obj_t buffer, static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_write_obj, audiograph_graph_write); +// deinit(): marks the graph released, under the pump lock, so a pump pulling +// it stops on it with fault=deinited rather than rendering a freed graph. +static mp_obj_t audiograph_graph_deinit(mp_obj_t self_in) { + audiograph_graph_obj_t *self = MP_OBJ_TO_PTR(self_in); + AUDIODSP_PUMP_SWAP(audiosample_mark_deinit(&self->base)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_deinit_obj, + audiograph_graph_deinit); + +// --- the audiosample protocol ------------------------------------------- + +static void audiograph_graph_reset_buffer(mp_obj_t self_in, + bool single_channel_output, uint8_t channel) { + (void)self_in; + (void)single_channel_output; + (void)channel; +} + +// One block per pull, never DONE: a graph does not end. Called inside the +// pull funnel, so under the pump lock, and never raises. +static audioio_get_buffer_result_t audiograph_graph_get_buffer( + mp_obj_t self_in, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length) { + audiograph_graph_obj_t *self = MP_OBJ_TO_PTR(self_in); + const uint32_t samples = self->graph.frames * self->graph.channel_count; + if (audiodsp_pump_on_pump_thread()) { + self->pumped = true; + } + if (!single_channel_output || channel == 0) { + self->which ^= 1u; + audiodsp_graph_render(&self->graph, self->out + self->which * samples); + } + int16_t *block = self->out + self->which * samples; + if (single_channel_output && channel < self->graph.channel_count) { + block += channel; + } + *buffer = (uint8_t *)block; + *buffer_length = samples * 2u; + return GET_BUFFER_MORE_DATA; +} + +// Nothing behind a graph: the walk ends here. +static mp_obj_t audiograph_graph_sources(mp_obj_t self_in, uint8_t index) { + (void)self_in; + (void)index; + return MP_OBJ_NULL; +} + +static const audiosample_p_t audiograph_graph_proto = { + MP_PROTO_IMPLEMENT(MP_QSTR_protocol_audiosample) + .reset_buffer = audiograph_graph_reset_buffer, + .get_buffer = audiograph_graph_get_buffer, + .sources = audiograph_graph_sources, +}; + static const mp_rom_map_elem_t audiograph_graph_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiograph_graph_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, + { MP_ROM_QSTR(MP_QSTR_enable), MP_ROM_PTR(&audiograph_graph_enable_obj) }, + { MP_ROM_QSTR(MP_QSTR_hold), MP_ROM_PTR(&audiograph_graph_hold_obj) }, + { MP_ROM_QSTR(MP_QSTR_commit), MP_ROM_PTR(&audiograph_graph_commit_obj) }, + { MP_ROM_QSTR(MP_QSTR_sounding), MP_ROM_PTR(&audiograph_graph_sounding_obj) }, + { MP_ROM_QSTR(MP_QSTR_stats), MP_ROM_PTR(&audiograph_graph_stats_obj) }, + { MP_ROM_QSTR(MP_QSTR__set_now), MP_ROM_PTR(&audiograph_graph_set_now_obj) }, + AUDIOSAMPLE_FIELDS, { MP_ROM_QSTR(MP_QSTR_buffer), MP_ROM_PTR(&audiograph_graph_buffer_obj) }, { MP_ROM_QSTR(MP_QSTR_add), MP_ROM_PTR(&audiograph_graph_add_obj) }, { MP_ROM_QSTR(MP_QSTR_set), MP_ROM_PTR(&audiograph_graph_set_obj) }, @@ -262,7 +464,9 @@ static MP_DEFINE_CONST_DICT(audiograph_graph_locals, MP_DEFINE_CONST_OBJ_TYPE( audiograph_graph_type, MP_QSTR_Graph, - MP_TYPE_FLAG_NONE, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, make_new, audiograph_graph_make_new, - locals_dict, &audiograph_graph_locals + attr, cp_compat_attr, + locals_dict, &audiograph_graph_locals, + protocol, &audiograph_graph_proto ); diff --git a/src/audiograph/Graph.h b/src/audiograph/Graph.h index 0df6266e..b86f3ba5 100644 --- a/src/audiograph/Graph.h +++ b/src/audiograph/Graph.h @@ -10,15 +10,28 @@ #include +#include "audiocore/__init__.h" #include "py/obj.h" #include "shared/audiodsp_graph.h" +// A graph is an audiosample: an output, a Mixer voice or audiopump.spawn() +// pulls it, and each pull renders one block into one of the two output +// blocks the graph owns. set(), reset() and enable() reach the renderer +// through the graph's queue (shared/audiodsp_graph.h), whichever thread +// renders. typedef struct { - mp_obj_base_t base; + audiosample_base_t base; audiodsp_graph_t graph; uint32_t next_buffer; // Objects a node borrows memory from (wow tables), kept alive here. mp_obj_t keep; + // Two output blocks of frames * channel_count samples; a pull renders + // into the one the last pull did not hand out. + int16_t *out; + uint8_t which; + // A pull has come from the pump's own thread. While that pump runs, + // the calls that would render or rewire beside it refuse. + volatile bool pumped; } audiograph_graph_obj_t; extern const mp_obj_type_t audiograph_graph_type; diff --git a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c index 384474cd..878ec602 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c @@ -10,9 +10,11 @@ #include #include "shared-bindings/audiograph/Graph.h" +#include "shared-bindings/audiocore/__init__.h" #include "py/binary.h" #include "py/runtime.h" +#include "shared/runtime/context_manager_helpers.h" //| class Graph: //| """A whole patch rendered by one C call per block. @@ -21,7 +23,12 @@ //| and a table of up to ``nodes`` nodes. Every node reads some buffers, //| writes one, belongs to a group, and holds one kernel's settings //| behind up to sixteen float parameters. Nodes run in the order they -//| were added. 16384 counts (`UNIT`) is 1.0 on every buffer.""" +//| were added. 16384 counts (`UNIT`) is 1.0 on every buffer. +//| +//| A graph is also an audio sample: hand it to an output's ``play()`` or +//| to a mixer voice and it renders a block each time one is pulled. +//| Changes made with `set`, `reset` and `enable` wait in the graph's +//| queue and land at the top of the next block.""" //| //| def __init__( //| self, @@ -31,8 +38,60 @@ //| channel_count: int = 2, //| buffers: int = 64, //| nodes: int = 64, +//| queue: int = 0, //| ) -> None: -//| """Create an empty graph. ``frames`` is the block, 1 to 256.""" +//| """Create an empty graph. ``frames`` is the block, 1 to 256. +//| ``queue`` is how many changes can wait for the next block, 1 to +//| 65536; 0 picks the larger of 256 and four per node.""" +//| ... +//| +//| def deinit(self) -> None: +//| """Release the graph. It cannot be used after this.""" +//| ... +//| +//| def __enter__(self) -> Graph: +//| """No-op used by Context Managers.""" +//| ... +//| +//| def __exit__(self) -> None: +//| """Automatically deinitializes when exiting a context.""" +//| ... +//| +//| sample_rate: int +//| """The sample rate the graph renders at. (read-only)""" +//| +//| channel_count: int +//| """1 or 2. (read-only)""" +//| +//| bits_per_sample: int +//| """Always 16. (read-only)""" +//| +//| def enable(self, mask: int) -> None: +//| """Which groups render while the graph is played as a sample, one +//| bit per group, from the next block. `ALWAYS` nodes always do.""" +//| ... +//| +//| def hold(self) -> None: +//| """Keep changes back until `commit`, so a batch of them lands on +//| one block together.""" +//| ... +//| +//| def commit(self) -> None: +//| """Release the changes made since `hold` to the next block.""" +//| ... +//| +//| def sounding(self) -> Tuple[int, int, int, int]: +//| """``(sounding, frame, applied, pushed)``: the mask of groups whose +//| envelopes were still sounding after the last block rendered, the +//| frames rendered so far, and how many changes have been applied +//| and queued. A change counted in ``pushed`` but not ``applied`` has +//| not been heard yet.""" +//| ... +//| +//| def stats(self) -> Dict[str, int]: +//| """The queue's counts: ``capacity``, ``pushed``, ``applied``, +//| ``overflows``, ``splits`` (held batches a full queue broke in two), +//| ``high_water`` and ``frame``.""" //| ... //| //| def buffer(self) -> int: @@ -46,12 +105,13 @@ //| ... //| //| def set(self, node: int, index: int, value: float) -> None: -//| """Store a parameter; the node derives its settings before the next -//| run.""" +//| """Queue a parameter for the next block; the node derives its +//| settings before it renders.""" //| ... //| //| def get(self, node: int, index: int) -> float: -//| """Read a parameter back.""" +//| """The value `set` last stored, whether or not a block has heard +//| it yet.""" //| ... //| //| def table(self, node: int, table: array.array) -> None: @@ -59,7 +119,7 @@ //| ... //| //| def reset(self, node: int) -> None: -//| """Clear a node's running state.""" +//| """Clear a node's running state at the next block.""" //| ... //| //| def run(self, out: Optional[WriteableBuffer] = None, block: int = 0, @@ -82,19 +142,30 @@ //| static audiograph_graph_obj_t *graph_self(mp_obj_t self_in) { - return MP_OBJ_TO_PTR(self_in); + audiograph_graph_obj_t *self = MP_OBJ_TO_PTR(self_in); + audiosample_check_for_deinit(&self->base); + return self; +} + +// No lock: the output pulls on this same thread, between bytecodes, so a +// full queue drains inline. +static void graph_push(audiograph_graph_obj_t *self, uint8_t op, + uint32_t node, uint32_t index, float value, uint32_t mask) { + audiodsp_graph_enqueue(&self->graph, op, node, index, value, mask, + NULL, NULL); } static mp_obj_t audiograph_graph_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { enum { ARG_sample_rate, ARG_frames, ARG_channel_count, ARG_buffers, - ARG_nodes }; + ARG_nodes, ARG_queue }; static const mp_arg_t allowed_args[] = { { MP_QSTR_sample_rate, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 48000} }, { MP_QSTR_frames, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 128} }, { MP_QSTR_channel_count, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 2} }, { MP_QSTR_buffers, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 64} }, { MP_QSTR_nodes, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 64} }, + { MP_QSTR_queue, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, @@ -115,10 +186,19 @@ static mp_obj_t audiograph_graph_make_new(const mp_obj_type_t *type, if (args[ARG_sample_rate].u_int < 1) { mp_raise_ValueError(MP_ERROR_TEXT("sample_rate must be at least 1")); } + mp_int_t queue = args[ARG_queue].u_int; + if (queue == 0) { + queue = 4 * nodes > (mp_int_t)AUDIODSP_GRAPH_QUEUE_MIN + ? 4 * nodes : (mp_int_t)AUDIODSP_GRAPH_QUEUE_MIN; + } + if (queue < 1 || queue > 65536) { + mp_raise_ValueError(MP_ERROR_TEXT("queue must be from 1 to 65536")); + } audiograph_graph_obj_t *self = mp_obj_malloc(audiograph_graph_obj_t, type); common_hal_audiograph_graph_construct(self, (uint32_t)args[ARG_sample_rate].u_int, (uint32_t)frames, - (uint32_t)channels, (uint32_t)buffers, (uint32_t)nodes); + (uint32_t)channels, (uint32_t)buffers, (uint32_t)nodes, + (uint32_t)queue); return MP_OBJ_FROM_PTR(self); } @@ -183,21 +263,45 @@ static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_add_obj, 5, 6, static mp_obj_t audiograph_graph_set(size_t n_args, const mp_obj_t *args) { audiograph_graph_obj_t *self = graph_self(args[0]); (void)n_args; - if (!audiodsp_graph_set(&self->graph, (uint32_t)mp_obj_get_int(args[1]), - (uint32_t)mp_obj_get_int(args[2]), (float)mp_obj_get_float(args[3]))) { + mp_int_t node = mp_obj_get_int(args[1]); + mp_int_t index = mp_obj_get_int(args[2]); + float value = (float)mp_obj_get_float(args[3]); + if (node < 0 || index < 0 || + !audiodsp_graph_valid(&self->graph, (uint32_t)node, (uint32_t)index)) { mp_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); } + graph_push(self, AUDIODSP_GRAPH_OP_SET, (uint32_t)node, (uint32_t)index, + value, 0); return mp_const_none; } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_set_obj, 4, 4, audiograph_graph_set); +// _set_now(node, index, value): writes the parameter straight into the node, +// past the queue. Not for use: it exists so the pump probe can show its check +// catches a write that skips the queue. +static mp_obj_t audiograph_graph_set_now(size_t n_args, const mp_obj_t *args) { + audiograph_graph_obj_t *self = graph_self(args[0]); + (void)n_args; + mp_int_t node = mp_obj_get_int(args[1]); + mp_int_t index = mp_obj_get_int(args[2]); + if (node < 0 || index < 0 || !audiodsp_graph_set(&self->graph, + (uint32_t)node, (uint32_t)index, (float)mp_obj_get_float(args[3]))) { + mp_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_set_now_obj, 4, 4, + audiograph_graph_set_now); + static mp_obj_t audiograph_graph_get(mp_obj_t self_in, mp_obj_t node, mp_obj_t index) { audiograph_graph_obj_t *self = graph_self(self_in); float value = 0.0f; - if (!audiodsp_graph_get(&self->graph, (uint32_t)mp_obj_get_int(node), - (uint32_t)mp_obj_get_int(index), &value)) { + mp_int_t n = mp_obj_get_int(node); + mp_int_t i = mp_obj_get_int(index); + if (n < 0 || i < 0 || + !audiodsp_graph_get_shadow(&self->graph, (uint32_t)n, (uint32_t)i, &value)) { mp_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); } return mp_obj_new_float(value); @@ -226,14 +330,80 @@ static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_table_obj, static mp_obj_t audiograph_graph_reset(mp_obj_t self_in, mp_obj_t node) { audiograph_graph_obj_t *self = graph_self(self_in); - if (!audiodsp_graph_reset(&self->graph, (uint32_t)mp_obj_get_int(node))) { + mp_int_t n = mp_obj_get_int(node); + if (n < 0 || !audiodsp_graph_valid(&self->graph, (uint32_t)n, 0)) { mp_raise_ValueError(MP_ERROR_TEXT("bad node")); } + graph_push(self, AUDIODSP_GRAPH_OP_RESET, (uint32_t)n, 0, 0.0f, 0); return mp_const_none; } static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_reset_obj, audiograph_graph_reset); +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, + (uint32_t)mp_obj_get_int_truncated(mask)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_enable_obj, + audiograph_graph_enable); + +static mp_obj_t audiograph_graph_hold(mp_obj_t self_in) { + audiodsp_graph_hold(&graph_self(self_in)->graph); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_hold_obj, + audiograph_graph_hold); + +static mp_obj_t audiograph_graph_commit(mp_obj_t self_in) { + audiodsp_graph_commit(&graph_self(self_in)->graph); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_commit_obj, + audiograph_graph_commit); + +static mp_obj_t audiograph_graph_sounding(mp_obj_t self_in) { + audiograph_graph_obj_t *self = graph_self(self_in); + uint32_t sounding, frame, applied; + audiodsp_graph_published(&self->graph, &sounding, &frame, &applied); + mp_obj_t items[4] = { + mp_obj_new_int_from_uint(sounding), + mp_obj_new_int_from_uint(frame), + mp_obj_new_int_from_uint(applied), + mp_obj_new_int_from_uint(self->graph.pushed), + }; + return mp_obj_new_tuple(4, items); +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_sounding_obj, + audiograph_graph_sounding); + +static mp_obj_t audiograph_graph_stats(mp_obj_t self_in) { + audiograph_graph_obj_t *self = graph_self(self_in); + const audiodsp_graph_t *g = &self->graph; + uint32_t sounding, frame, applied; + audiodsp_graph_published(g, &sounding, &frame, &applied); + mp_obj_t d = mp_obj_new_dict(7); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_capacity), mp_obj_new_int_from_uint(g->capacity)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_pushed), mp_obj_new_int_from_uint(g->pushed)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_applied), mp_obj_new_int_from_uint(applied)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_overflows), mp_obj_new_int_from_uint(g->overflows)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_splits), mp_obj_new_int_from_uint(g->splits)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_high_water), mp_obj_new_int_from_uint(g->high_water)); + mp_obj_dict_store(d, MP_OBJ_NEW_QSTR(MP_QSTR_frame), mp_obj_new_int_from_uint(frame)); + return d; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_stats_obj, + audiograph_graph_stats); + +static mp_obj_t audiograph_graph_deinit(mp_obj_t self_in) { + audiograph_graph_obj_t *self = MP_OBJ_TO_PTR(self_in); + audiosample_mark_deinit(&self->base); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_deinit_obj, + audiograph_graph_deinit); + // run(out=None, block=0, enable=0xFFFFFFFF): renders one block into `out` // at block `block` (frames * channel_count samples each), and returns the // mask of groups whose envelopes are still sounding. @@ -250,11 +420,13 @@ static mp_obj_t audiograph_graph_run(size_t n_args, const mp_obj_t *args) { } out = (int16_t *)(void *)((uint8_t *)info.buf + (size_t)block * block_bytes); } - uint32_t enable = 0xFFFFFFFFu; + uint32_t sounding; if (n_args > 3) { - enable = (uint32_t)mp_obj_get_int_truncated(args[3]); + sounding = audiodsp_graph_render_mask(&self->graph, out, + (uint32_t)mp_obj_get_int_truncated(args[3])); + } else { + sounding = audiodsp_graph_render(&self->graph, out); } - uint32_t sounding = audiodsp_graph_run(&self->graph, out, enable); return mp_obj_new_int_from_uint(sounding); } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_run_obj, 1, 4, @@ -312,9 +484,19 @@ static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_write_obj, audiograph_graph_write); static const mp_rom_map_elem_t audiograph_graph_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiograph_graph_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, + { MP_ROM_QSTR(MP_QSTR_enable), MP_ROM_PTR(&audiograph_graph_enable_obj) }, + { MP_ROM_QSTR(MP_QSTR_hold), MP_ROM_PTR(&audiograph_graph_hold_obj) }, + { MP_ROM_QSTR(MP_QSTR_commit), MP_ROM_PTR(&audiograph_graph_commit_obj) }, + { MP_ROM_QSTR(MP_QSTR_sounding), MP_ROM_PTR(&audiograph_graph_sounding_obj) }, + { MP_ROM_QSTR(MP_QSTR_stats), MP_ROM_PTR(&audiograph_graph_stats_obj) }, + AUDIOSAMPLE_FIELDS, { MP_ROM_QSTR(MP_QSTR_buffer), MP_ROM_PTR(&audiograph_graph_buffer_obj) }, { MP_ROM_QSTR(MP_QSTR_add), MP_ROM_PTR(&audiograph_graph_add_obj) }, { MP_ROM_QSTR(MP_QSTR_set), MP_ROM_PTR(&audiograph_graph_set_obj) }, + { MP_ROM_QSTR(MP_QSTR__set_now), MP_ROM_PTR(&audiograph_graph_set_now_obj) }, { 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) }, @@ -326,10 +508,19 @@ static const mp_rom_map_elem_t audiograph_graph_locals_table[] = { static MP_DEFINE_CONST_DICT(audiograph_graph_locals, audiograph_graph_locals_table); +static const audiosample_p_t audiograph_graph_proto = { + MP_PROTO_IMPLEMENT(MP_QSTR_protocol_audiosample) + .reset_buffer = (audiosample_reset_buffer_fun) + audiograph_graph_reset_buffer, + .get_buffer = (audiosample_get_buffer_fun) + audiograph_graph_get_buffer, +}; + MP_DEFINE_CONST_OBJ_TYPE( audiograph_graph_type, MP_QSTR_Graph, - MP_TYPE_FLAG_NONE, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, make_new, audiograph_graph_make_new, - locals_dict, &audiograph_graph_locals + locals_dict, &audiograph_graph_locals, + protocol, &audiograph_graph_proto ); diff --git a/src/circuitpython_spike/shared-module/audiograph/Graph.c b/src/circuitpython_spike/shared-module/audiograph/Graph.c index 1646ad58..2909e02f 100644 --- a/src/circuitpython_spike/shared-module/audiograph/Graph.c +++ b/src/circuitpython_spike/shared-module/audiograph/Graph.c @@ -9,11 +9,22 @@ void common_hal_audiograph_graph_construct(audiograph_graph_obj_t *self, uint32_t sample_rate, uint32_t frames, uint32_t channel_count, - uint32_t buffers, uint32_t nodes) { + uint32_t buffers, uint32_t nodes, uint32_t queue) { int16_t *bufs = m_new0(int16_t, (size_t)buffers * (size_t)frames); audiodsp_graph_node_t *table = m_new0(audiodsp_graph_node_t, (size_t)nodes); audiodsp_graph_init(&self->graph, sample_rate, frames, channel_count, bufs, buffers, table, nodes); + audiodsp_graph_queue_init(&self->graph, + m_new0(audiodsp_graph_record_t, (size_t)queue), queue, + m_new0(float, (size_t)nodes * AUDIODSP_GRAPH_MAX_PARAMS)); + self->out = m_new0(int16_t, 2u * (size_t)frames * channel_count); + self->which = 0; + self->base.sample_rate = sample_rate; + self->base.max_buffer_length = frames * channel_count * 2u; + self->base.bits_per_sample = 16; + self->base.channel_count = (uint8_t)channel_count; + self->base.samples_signed = true; + self->base.single_buffer = false; self->next_buffer = 0; self->keep = mp_obj_new_list(0, NULL); } @@ -33,3 +44,27 @@ int common_hal_audiograph_graph_add(audiograph_graph_obj_t *self, } return index; } + +void audiograph_graph_reset_buffer(audiograph_graph_obj_t *self, + bool single_channel_output, uint8_t channel) { + (void)self; + (void)single_channel_output; + (void)channel; +} + +audioio_get_buffer_result_t audiograph_graph_get_buffer( + audiograph_graph_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length) { + const uint32_t samples = self->graph.frames * self->graph.channel_count; + if (!single_channel_output || channel == 0) { + self->which ^= 1u; + audiodsp_graph_render(&self->graph, self->out + self->which * samples); + } + int16_t *block = self->out + self->which * samples; + if (single_channel_output && channel < self->graph.channel_count) { + block += channel; + } + *buffer = (uint8_t *)block; + *buffer_length = samples * 2u; + return GET_BUFFER_MORE_DATA; +} diff --git a/src/circuitpython_spike/shared-module/audiograph/Graph.h b/src/circuitpython_spike/shared-module/audiograph/Graph.h index a17bcba3..de1e762d 100644 --- a/src/circuitpython_spike/shared-module/audiograph/Graph.h +++ b/src/circuitpython_spike/shared-module/audiograph/Graph.h @@ -2,9 +2,10 @@ // for the MicroPython twin; the runner is the same shared/audiodsp_graph.c // in both, built into CircuitPython by audiodsp's circuitpython.mk. // -// Not an audiosample: a graph renders into a buffer its caller owns, one -// block per run(), and plays through whatever that caller hands the buffer -// to. +// An audiosample: I2SOut.play(graph) or a Mixer voice pulls it, one block +// per pull, from CircuitPython's background tasks on the interpreter's own +// core. set(), reset() and enable() go through the graph's queue as on every +// other build; with no second thread, a full queue drains inline. // // SPDX-License-Identifier: MIT @@ -13,25 +14,36 @@ #include #include "py/obj.h" +#include "shared-module/audiocore/__init__.h" #include "shared/audiodsp_graph.h" typedef struct { - mp_obj_base_t base; + audiosample_base_t base; audiodsp_graph_t graph; uint32_t next_buffer; // Objects a node borrows memory from (wow tables), kept alive here. mp_obj_t keep; + // Two output blocks; a pull renders into the one the last did not hand out. + int16_t *out; + uint8_t which; } audiograph_graph_obj_t; // Allocates the graph's block buffers and node table. The arguments are // already checked. void common_hal_audiograph_graph_construct(audiograph_graph_obj_t *self, uint32_t sample_rate, uint32_t frames, uint32_t channel_count, - uint32_t buffers, uint32_t nodes); + uint32_t buffers, uint32_t nodes, uint32_t queue); // Appends a node with its kernel's memory. The node's index, or -1 when the // runner refuses it (see audiodsp_graph_add). int common_hal_audiograph_graph_add(audiograph_graph_obj_t *self, audiodsp_graph_kind_t kind, uint32_t group, const int16_t *inputs, uint32_t ninputs, int16_t output, float arg); + +// The audiosample protocol: one block per pull, never DONE. +void audiograph_graph_reset_buffer(audiograph_graph_obj_t *self, + bool single_channel_output, uint8_t channel); +audioio_get_buffer_result_t audiograph_graph_get_buffer( + audiograph_graph_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length); diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 19bd1643..6693b1f5 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -3416,16 +3416,23 @@ typedef struct { // Memoryviews of the wow tables the delay nodes borrow, kept alive (and // their arrays kept from resizing) for as long as the graph is. PyObject *keep; + // The queue's records and get()'s shadow (shared/audiodsp_graph.h). + audiodsp_graph_record_t *records; + float *shadow; + // Two output blocks for _get_buffer, used in turn. + int16_t *out; + uint8_t which; + bool deinited; } audiodsp_graph_object_t; static int graph_state_init(audiodsp_graph_object_t *self, PyObject *args, PyObject *kwargs) { static char *keywords[] = {"sample_rate", "frames", "channel_count", - "buffers", "nodes", NULL}; + "buffers", "nodes", "queue", NULL}; long sample_rate = 48000, frames = 128, channels = 2, buffers = 64, - nodes = 64; - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|$lllll:Graph", keywords, - &sample_rate, &frames, &channels, &buffers, &nodes)) { + nodes = 64, queue = 0; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|$llllll:Graph", keywords, + &sample_rate, &frames, &channels, &buffers, &nodes, &queue)) { return -1; } if (self->graph.nodes != NULL) { @@ -3449,14 +3456,32 @@ static int graph_state_init(audiodsp_graph_object_t *self, PyObject *args, PyErr_SetString(PyExc_ValueError, "sample_rate must be at least 1"); return -1; } + if (queue == 0) { + queue = 4 * nodes > (long)AUDIODSP_GRAPH_QUEUE_MIN + ? 4 * nodes : (long)AUDIODSP_GRAPH_QUEUE_MIN; + } + if (queue < 1 || queue > 65536) { + PyErr_SetString(PyExc_ValueError, "queue must be from 1 to 65536"); + return -1; + } int16_t *bufs = PyMem_Calloc((size_t)buffers * (size_t)frames, sizeof(int16_t)); audiodsp_graph_node_t *table = PyMem_Calloc((size_t)nodes, sizeof(audiodsp_graph_node_t)); + audiodsp_graph_record_t *records = PyMem_Calloc((size_t)queue, + sizeof(audiodsp_graph_record_t)); + float *shadow = PyMem_Calloc((size_t)nodes * AUDIODSP_GRAPH_MAX_PARAMS, + sizeof(float)); + int16_t *out = PyMem_Calloc(2u * (size_t)frames * (size_t)channels, + sizeof(int16_t)); PyObject *keep = PyList_New(0); - if (bufs == NULL || table == NULL || keep == NULL) { + if (bufs == NULL || table == NULL || records == NULL || shadow == NULL || + out == NULL || keep == NULL) { PyMem_Free(bufs); PyMem_Free(table); + PyMem_Free(records); + PyMem_Free(shadow); + PyMem_Free(out); Py_XDECREF(keep); if (!PyErr_Occurred()) { PyErr_NoMemory(); @@ -3465,6 +3490,12 @@ static int graph_state_init(audiodsp_graph_object_t *self, PyObject *args, } audiodsp_graph_init(&self->graph, (uint32_t)sample_rate, (uint32_t)frames, (uint32_t)channels, bufs, (uint32_t)buffers, table, (uint32_t)nodes); + audiodsp_graph_queue_init(&self->graph, records, (uint32_t)queue, shadow); + self->records = records; + self->shadow = shadow; + self->out = out; + self->which = 0; + self->deinited = false; self->next_buffer = 0; self->keep = keep; return 0; @@ -3479,8 +3510,14 @@ static void graph_state_dealloc(audiodsp_graph_object_t *self) { } PyMem_Free(self->graph.nodes); PyMem_Free(self->graph.bufs); + PyMem_Free(self->records); + PyMem_Free(self->shadow); + PyMem_Free(self->out); self->graph.nodes = NULL; self->graph.bufs = NULL; + self->records = NULL; + self->shadow = NULL; + self->out = NULL; Py_CLEAR(self->keep); type->tp_free((PyObject *)self); Py_DECREF(type); @@ -3491,6 +3528,29 @@ static bool graph_live(audiodsp_graph_object_t *self) { PyErr_SetString(PyExc_RuntimeError, "Graph.__init__ was not called"); return false; } + if (self->deinited) { + PyErr_SetString(PyExc_ValueError, + "Object has been deinitialized and can no longer be used. Create a new object."); + return false; + } + return true; +} + +// The queue, from the control side. No lock to pass: CPython's pump pulls on +// this thread, inside the same critical section as every call here. +static void graph_push(audiodsp_graph_object_t *self, uint8_t op, long node, + long index, float value, uint32_t mask) { + audiodsp_graph_enqueue(&self->graph, op, (uint32_t)node, (uint32_t)index, + value, mask, NULL, NULL); +} + +static bool graph_check(audiodsp_graph_object_t *self, long node, long index, + const char *message) { + if (node < 0 || index < 0 || !audiodsp_graph_valid(&self->graph, + (uint32_t)node, (uint32_t)index)) { + PyErr_SetString(PyExc_ValueError, message); + return false; + } return true; } @@ -3599,11 +3659,10 @@ static PyObject *graph_state_set(audiodsp_graph_object_t *self, if (!graph_live(self)) { return NULL; } - if (!audiodsp_graph_set(&self->graph, (uint32_t)node, (uint32_t)index, - (float)value)) { - PyErr_SetString(PyExc_ValueError, "bad node or parameter index"); + if (!graph_check(self, node, index, "bad node or parameter index")) { return NULL; } + graph_push(self, AUDIODSP_GRAPH_OP_SET, node, index, (float)value, 0); Py_RETURN_NONE; } @@ -3617,8 +3676,8 @@ static PyObject *graph_state_get(audiodsp_graph_object_t *self, return NULL; } float value = 0.0f; - if (!audiodsp_graph_get(&self->graph, (uint32_t)node, (uint32_t)index, - &value)) { + if (node < 0 || index < 0 || !audiodsp_graph_get_shadow(&self->graph, + (uint32_t)node, (uint32_t)index, &value)) { PyErr_SetString(PyExc_ValueError, "bad node or parameter index"); return NULL; } @@ -3674,13 +3733,203 @@ static PyObject *graph_state_reset(audiodsp_graph_object_t *self, if (!graph_live(self)) { return NULL; } - if (!audiodsp_graph_reset(&self->graph, (uint32_t)node)) { - PyErr_SetString(PyExc_ValueError, "bad node"); + if (!graph_check(self, node, 0, "bad node")) { return NULL; } + graph_push(self, AUDIODSP_GRAPH_OP_RESET, node, 0, 0.0f, 0); Py_RETURN_NONE; } +// enable(mask): the groups a pull renders, queued like set(). +static PyObject *graph_state_enable(audiodsp_graph_object_t *self, + PyObject *arg) { + unsigned long mask = PyLong_AsUnsignedLongMask(arg); + if (mask == (unsigned long)-1 && PyErr_Occurred()) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + graph_push(self, AUDIODSP_GRAPH_OP_ENABLE, 0, 0, 0.0f, (uint32_t)mask); + Py_RETURN_NONE; +} + +static PyObject *graph_state_hold(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + if (!graph_live(self)) { + return NULL; + } + audiodsp_graph_hold(&self->graph); + Py_RETURN_NONE; +} + +static PyObject *graph_state_commit(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + if (!graph_live(self)) { + return NULL; + } + audiodsp_graph_commit(&self->graph); + Py_RETURN_NONE; +} + +static PyObject *graph_state_sounding(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + if (!graph_live(self)) { + return NULL; + } + uint32_t sounding, frame, applied; + audiodsp_graph_published(&self->graph, &sounding, &frame, &applied); + return Py_BuildValue("(kkkk)", (unsigned long)sounding, + (unsigned long)frame, (unsigned long)applied, + (unsigned long)self->graph.pushed); +} + +static PyObject *graph_state_stats(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + if (!graph_live(self)) { + return NULL; + } + const audiodsp_graph_t *g = &self->graph; + uint32_t sounding, frame, applied; + audiodsp_graph_published(g, &sounding, &frame, &applied); + return Py_BuildValue("{s:k,s:k,s:k,s:k,s:k,s:k,s:k}", + "capacity", (unsigned long)g->capacity, + "pushed", (unsigned long)g->pushed, + "applied", (unsigned long)applied, + "overflows", (unsigned long)g->overflows, + "splits", (unsigned long)g->splits, + "high_water", (unsigned long)g->high_water, + "frame", (unsigned long)frame); +} + +static PyObject *graph_state_deinit(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + self->deinited = true; + Py_RETURN_NONE; +} + +static PyObject *graph_state_enter(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + if (!graph_live(self)) { + return NULL; + } + return Py_NewRef((PyObject *)self); +} + +static PyObject *graph_state_exit(audiodsp_graph_object_t *self, + PyObject *args) { + (void)args; + self->deinited = true; + Py_RETURN_NONE; +} + +// _set_now(node, index, value): writes the parameter straight into the node, +// past the queue. Not for use: the MicroPython twin exists so the pump probe +// can show its check catches a write that skips the queue, and this one keeps +// the two method sets the same. +static PyObject *graph_state_set_now(audiodsp_graph_object_t *self, + PyObject *args) { + long node, index; + double value; + if (!PyArg_ParseTuple(args, "lld:_set_now", &node, &index, &value)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + if (node < 0 || index < 0 || !audiodsp_graph_set(&self->graph, + (uint32_t)node, (uint32_t)index, (float)value)) { + PyErr_SetString(PyExc_ValueError, "bad node or parameter index"); + return NULL; + } + Py_RETURN_NONE; +} + +static PyObject *graph_state_reset_buffer(audiodsp_graph_object_t *self, + PyObject *args, PyObject *kwargs) { + int single_channel_output = 0; + unsigned int audio_channel = 0; + static char *keywords[] = { + "single_channel_output", "audio_channel", NULL + }; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|pI:_reset_buffer", + keywords, &single_channel_output, &audio_channel)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + Py_RETURN_NONE; +} + +// One block per pull, rendered from the queue, and never DONE. +static PyObject *graph_state_get_buffer(audiodsp_graph_object_t *self, + PyObject *args, PyObject *kwargs) { + int single_channel_output = 0; + unsigned int audio_channel = 0; + static char *keywords[] = { + "single_channel_output", "audio_channel", NULL + }; + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|pI:_get_buffer", + keywords, &single_channel_output, &audio_channel)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + const uint32_t channels = self->graph.channel_count; + const uint32_t frames = self->graph.frames; + const uint32_t samples = frames * channels; + if (!single_channel_output || audio_channel == 0) { + self->which ^= 1u; + audiodsp_graph_render(&self->graph, self->out + self->which * samples); + } + const int16_t *block = self->out + self->which * samples; + PyObject *data; + if (single_channel_output && channels > 1) { + const uint32_t channel = audio_channel < channels ? audio_channel : 0; + data = PyBytes_FromStringAndSize(NULL, (Py_ssize_t)frames * 2); + if (data != NULL) { + int16_t *dst = (int16_t *)(void *)PyBytes_AS_STRING(data); + for (uint32_t i = 0; i < frames; ++i) { + dst[i] = block[i * channels + channel]; + } + } + } else { + data = PyBytes_FromStringAndSize((const char *)block, + (Py_ssize_t)samples * 2); + } + if (data == NULL) { + return NULL; + } + return Py_BuildValue("(iN)", 1 /* GET_BUFFER_MORE_DATA */, data); +} + +#define GRAPH_FORMAT_GETTER(name, expr) \ + static PyObject *graph_get_##name(audiodsp_graph_object_t *self, \ + void *closure) { \ + (void)closure; \ + if (!graph_live(self)) { \ + return NULL; \ + } \ + return expr; \ + } +GRAPH_FORMAT_GETTER(sample_rate, + PyLong_FromUnsignedLong(self->graph.sample_rate)) +GRAPH_FORMAT_GETTER(channel_count, + PyLong_FromUnsignedLong(self->graph.channel_count)) +GRAPH_FORMAT_GETTER(bits_per_sample, PyLong_FromLong(16)) +GRAPH_FORMAT_GETTER(samples_signed, Py_NewRef(Py_True)) +GRAPH_FORMAT_GETTER(single_buffer, Py_NewRef(Py_False)) +GRAPH_FORMAT_GETTER(max_buffer_length, PyLong_FromUnsignedLong( + self->graph.frames * self->graph.channel_count * 2u)) + // run(out=None, block=0, enable=0xFFFFFFFF): renders one block into `out` // at block `block` (frames * channel_count samples each), and returns the // mask of groups whose envelopes are still sounding. @@ -3694,7 +3943,7 @@ static PyObject *graph_state_run(audiodsp_graph_object_t *self, if (!graph_live(self)) { return NULL; } - uint32_t enable = 0xFFFFFFFFu; + uint32_t enable = 0; if (enable_obj != NULL) { // Truncated to 32 bits, as the MicroPython binding takes it. unsigned long mask = PyLong_AsUnsignedLongMask(enable_obj); @@ -3719,7 +3968,9 @@ static PyObject *graph_state_run(audiodsp_graph_object_t *self, out = (int16_t *)(void *)((uint8_t *)view.buf + (size_t)block * block_bytes); } - uint32_t sounding = audiodsp_graph_run(&self->graph, out, enable); + uint32_t sounding = enable_obj != NULL + ? audiodsp_graph_render_mask(&self->graph, out, enable) + : audiodsp_graph_render(&self->graph, out); if (out != NULL) { PyBuffer_Release(&view); } @@ -3800,6 +4051,9 @@ static PyObject *graph_state_write(audiodsp_graph_object_t *self, AUDIODSP_LOCKED_ARGS(graph_state_buffer, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_add, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_set, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_set_now, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_enter, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_exit, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_get, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_table, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_reset, audiodsp_graph_object_t) @@ -3807,19 +4061,59 @@ AUDIODSP_LOCKED_ARGS(graph_state_run, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_peak, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_read, audiodsp_graph_object_t) AUDIODSP_LOCKED_ARGS(graph_state_write, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_enable, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_hold, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_commit, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_sounding, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_stats, audiodsp_graph_object_t) +AUDIODSP_LOCKED_ARGS(graph_state_deinit, audiodsp_graph_object_t) +AUDIODSP_LOCKED_KW(graph_state_reset_buffer, audiodsp_graph_object_t) +AUDIODSP_LOCKED_KW(graph_state_get_buffer, audiodsp_graph_object_t) +AUDIODSP_LOCKED_GET(graph_get_sample_rate, audiodsp_graph_object_t) +AUDIODSP_LOCKED_GET(graph_get_channel_count, audiodsp_graph_object_t) +AUDIODSP_LOCKED_GET(graph_get_bits_per_sample, audiodsp_graph_object_t) +AUDIODSP_LOCKED_GET(graph_get_samples_signed, audiodsp_graph_object_t) +AUDIODSP_LOCKED_GET(graph_get_single_buffer, audiodsp_graph_object_t) +AUDIODSP_LOCKED_GET(graph_get_max_buffer_length, audiodsp_graph_object_t) +static PyGetSetDef graph_state_getset[] = { + {"sample_rate", (getter)graph_get_sample_rate_locked, NULL, NULL, NULL}, + {"channel_count", (getter)graph_get_channel_count_locked, NULL, NULL, NULL}, + {"bits_per_sample", (getter)graph_get_bits_per_sample_locked, NULL, NULL, NULL}, + {"samples_signed", (getter)graph_get_samples_signed_locked, NULL, NULL, NULL}, + {"single_buffer", (getter)graph_get_single_buffer_locked, NULL, NULL, NULL}, + {"max_buffer_length", (getter)graph_get_max_buffer_length_locked, NULL, NULL, NULL}, + {NULL, NULL, NULL, NULL, NULL}, +}; static PyMethodDef graph_state_methods[] = { + {"enable", (PyCFunction)graph_state_enable_locked, METH_O, + PyDoc_STR("enable(mask): the groups a pull renders, queued like set().")}, + {"hold", (PyCFunction)graph_state_hold_locked, METH_NOARGS, + PyDoc_STR("hold(): keep what is queued until commit(), so it lands in one block.")}, + {"commit", (PyCFunction)graph_state_commit_locked, METH_NOARGS, + PyDoc_STR("commit(): publish what was queued since hold().")}, + {"sounding", (PyCFunction)graph_state_sounding_locked, METH_NOARGS, + PyDoc_STR("sounding(): (groups sounding, frames rendered, records applied, records queued).")}, + {"stats", (PyCFunction)graph_state_stats_locked, METH_NOARGS, + PyDoc_STR("stats(): the queue's counts.")}, + {"deinit", (PyCFunction)graph_state_deinit_locked, METH_NOARGS, + PyDoc_STR("deinit(): release the graph.")}, + {"__enter__", (PyCFunction)graph_state_enter_locked, METH_NOARGS, NULL}, + {"__exit__", (PyCFunction)graph_state_exit_locked, METH_VARARGS, NULL}, + {"_set_now", (PyCFunction)graph_state_set_now_locked, METH_VARARGS, NULL}, + {"_reset_buffer", (PyCFunction)graph_state_reset_buffer_locked, METH_VARARGS | METH_KEYWORDS, NULL}, + {"_get_buffer", (PyCFunction)graph_state_get_buffer_locked, METH_VARARGS | METH_KEYWORDS, NULL}, {"buffer", (PyCFunction)graph_state_buffer_locked, METH_NOARGS, PyDoc_STR("buffer(): a fresh block buffer's id.")}, {"add", (PyCFunction)graph_state_add_locked, METH_VARARGS, PyDoc_STR("add(kind, group, inputs, output, arg=0.0): append a node; returns its index.")}, {"set", (PyCFunction)graph_state_set_locked, METH_VARARGS, - PyDoc_STR("set(node, index, value): store a parameter.")}, + PyDoc_STR("set(node, index, value): queue a parameter for the next block.")}, {"get", (PyCFunction)graph_state_get_locked, METH_VARARGS, PyDoc_STR("get(node, index): read a parameter back.")}, {"table", (PyCFunction)graph_state_table_locked, METH_VARARGS, 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.")}, + PyDoc_STR("reset(node): clear a node's running state at the next block.")}, {"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, @@ -3837,6 +4131,7 @@ static PyType_Slot graph_state_slots[] = { {Py_tp_init, graph_state_init_locked}, {Py_tp_dealloc, graph_state_dealloc}, {Py_tp_methods, graph_state_methods}, + {Py_tp_getset, graph_state_getset}, {Py_tp_doc, (void *)PyDoc_STR("Graph(*, sample_rate=48000, frames=128, " "channel_count=2, buffers=64, nodes=64): a whole patch rendered by " "one C call per block. See docs/audiograph.md.")}, diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index 6a5f5022..03aadbf4 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -10,6 +10,17 @@ #include #include +// The queue's and the published words' ordering: the acquire/release pair +// audiopump.c uses, and a volatile fallback for compilers without +// __atomic_* (MSVC, the Windows wheel), where x86's ordering does the rest. +#if defined(__GNUC__) || defined(__clang__) +#define GRAPH_LOAD_ACQ(p) __atomic_load_n((p), __ATOMIC_ACQUIRE) +#define GRAPH_STORE_REL(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE) +#else +#define GRAPH_LOAD_ACQ(p) (*(volatile const uint32_t *)(p)) +#define GRAPH_STORE_REL(p, v) (*(volatile uint32_t *)(p) = (v)) +#endif + // Round to the nearest integer, ties to even: what lrintf gives in the default // rounding mode, without calling it. CircuitPython's RP2350 port links no // lrintf, so a call to it leaves the firmware unable to link (the same trap @@ -180,6 +191,22 @@ void audiodsp_graph_init(audiodsp_graph_t *graph, uint32_t sample_rate, graph->max_nodes = max_nodes; graph->nnodes = 0; graph->sounding = 0; + graph->enable = 0xFFFFFFFFu; + graph->pub_sounding = 0; + graph->pub_frame = 0; + graph->pub_applied = 0; + graph->applied = 0; + graph->records = NULL; + graph->capacity = 0; + graph->head = 0; + graph->published = 0; + graph->tail = 0; + graph->held = false; + graph->pushed = 0; + graph->overflows = 0; + graph->splits = 0; + graph->high_water = 0; + graph->shadow = NULL; memset(bufs, 0, (size_t)nbufs * frames * sizeof(int16_t)); } @@ -348,6 +375,10 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, default: break; } + if (graph->shadow != NULL) { + memcpy(graph->shadow + (size_t)graph->nnodes * AUDIODSP_GRAPH_MAX_PARAMS, + node->param, sizeof(node->param)); + } return (int)graph->nnodes++; } @@ -1077,3 +1108,150 @@ int32_t audiodsp_graph_peak(const audiodsp_graph_t *graph, uint32_t buffer) { } return peak; } + +// --- the control queue ------------------------------------------------------ + +void audiodsp_graph_queue_init(audiodsp_graph_t *graph, + audiodsp_graph_record_t *records, uint32_t capacity, float *shadow) { + graph->records = records; + graph->capacity = capacity; + graph->shadow = shadow; + if (shadow != NULL) { + for (uint32_t i = 0; i < graph->nnodes; ++i) { + memcpy(shadow + (size_t)i * AUDIODSP_GRAPH_MAX_PARAMS, + graph->nodes[i].param, sizeof(graph->nodes[i].param)); + } + } +} + +bool audiodsp_graph_valid(const audiodsp_graph_t *graph, uint32_t node, + uint32_t index) { + return node < graph->nnodes && index < AUDIODSP_GRAPH_MAX_PARAMS; +} + +static void apply(audiodsp_graph_t *graph, const audiodsp_graph_record_t *r) { + switch (r->op) { + case AUDIODSP_GRAPH_OP_SET: + audiodsp_graph_set(graph, r->node, r->index, r->arg.value); + break; + case AUDIODSP_GRAPH_OP_RESET: + audiodsp_graph_reset(graph, r->node); + break; + case AUDIODSP_GRAPH_OP_ENABLE: + graph->enable = r->arg.mask; + break; + default: + break; + } +} + +uint32_t audiodsp_graph_drain(audiodsp_graph_t *graph) { + const uint32_t end = GRAPH_LOAD_ACQ(&graph->published); + uint32_t at = graph->tail; + const uint32_t n = end - at; + for (; at != end; ++at) { + apply(graph, &graph->records[at % graph->capacity]); + } + if (n) { + graph->applied += n; + GRAPH_STORE_REL(&graph->tail, end); + } + return n; +} + +static void publish(audiodsp_graph_t *graph) { + GRAPH_STORE_REL(&graph->published, graph->head); +} + +void audiodsp_graph_enqueue(audiodsp_graph_t *graph, uint8_t op, + uint32_t node, uint32_t index, float value, uint32_t mask, + void (*lock)(void), void (*unlock)(void)) { + uint32_t waiting = graph->head - GRAPH_LOAD_ACQ(&graph->tail); + if (waiting >= graph->capacity) { + // Full. Publish what is waiting (a held batch splits here) and drain + // it from this side, with the renderer kept out. + graph->overflows++; + if (graph->held) { + graph->splits++; + } + publish(graph); + if (lock != NULL) { + lock(); + } + audiodsp_graph_drain(graph); + if (unlock != NULL) { + unlock(); + } + waiting = graph->head - GRAPH_LOAD_ACQ(&graph->tail); + } + audiodsp_graph_record_t *r = &graph->records[graph->head % graph->capacity]; + r->op = op; + r->index = (uint8_t)index; + r->node = (uint16_t)node; + if (op == AUDIODSP_GRAPH_OP_ENABLE) { + r->arg.mask = mask; + } else { + r->arg.value = value; + } + graph->head++; + graph->pushed++; + if (waiting + 1u > graph->high_water) { + graph->high_water = waiting + 1u; + } + if (op == AUDIODSP_GRAPH_OP_SET && graph->shadow != NULL) { + graph->shadow[(size_t)node * AUDIODSP_GRAPH_MAX_PARAMS + index] = value; + } + if (!graph->held) { + publish(graph); + } +} + +void audiodsp_graph_hold(audiodsp_graph_t *graph) { + graph->held = true; +} + +void audiodsp_graph_commit(audiodsp_graph_t *graph) { + graph->held = false; + publish(graph); +} + +static uint32_t render(audiodsp_graph_t *graph, int16_t *out, bool own, + uint32_t enable) { + if (graph->records != NULL) { + audiodsp_graph_drain(graph); + } + const uint32_t sounding = audiodsp_graph_run(graph, out, + own ? graph->enable : enable); + GRAPH_STORE_REL(&graph->pub_sounding, sounding); + GRAPH_STORE_REL(&graph->pub_frame, graph->pub_frame + graph->frames); + GRAPH_STORE_REL(&graph->pub_applied, graph->applied); + return sounding; +} + +uint32_t audiodsp_graph_render(audiodsp_graph_t *graph, int16_t *out) { + return render(graph, out, true, 0); +} + +uint32_t audiodsp_graph_render_mask(audiodsp_graph_t *graph, int16_t *out, + uint32_t enable) { + return render(graph, out, false, enable); +} + +void audiodsp_graph_published(const audiodsp_graph_t *graph, + uint32_t *sounding, uint32_t *frame, uint32_t *applied) { + *applied = GRAPH_LOAD_ACQ(&graph->pub_applied); + *frame = GRAPH_LOAD_ACQ(&graph->pub_frame); + *sounding = GRAPH_LOAD_ACQ(&graph->pub_sounding); +} + +bool audiodsp_graph_get_shadow(const audiodsp_graph_t *graph, uint32_t node, + uint32_t index, float *value) { + if (!audiodsp_graph_valid(graph, node, index)) { + return false; + } + if (graph->shadow == NULL) { + return audiodsp_graph_get(graph, node, index, value); + } + *value = graph->shadow[(size_t)node * AUDIODSP_GRAPH_MAX_PARAMS + index]; + return true; +} diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index c99de76d..99d7549d 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -25,14 +25,27 @@ // outputs are zeroed once so the mixers read silence from it. The run // returns the mask of groups with an envelope still sounding. // -// No pump lock in here. The runner's buffers and node state are touched only -// by whoever calls run(), set() and reset(), and nothing in this file is -// reachable from the pump's pull funnel: the output goes into a buffer the -// caller owns (an audiopump.Ring takes it from there, under its own -// synchronisation). The kernels themselves never lock either -- the locks -// in the bindings guard the bindings' own {source, pending} words, which the -// pull funnel reads from the pump thread, and this runner has no such words. +// Two ways to drive a graph. A caller can render it where it likes with +// run(), into a buffer it owns. Or the bindings hand it to an audio output as +// an audiosample, and the output's pump renders it on its own thread, one +// block per pull. Either way every block goes through audiodsp_graph_render, +// so the two paths are the same code. // +// The control side never writes a node's parameters directly. set(), reset() +// and enable() push fixed records onto a single-producer, single-consumer +// queue, and the renderer drains the queue at the top of each block. So each +// change lands on a block boundary, a batch held and committed together lands +// in one block, and the producer never waits for a render to finish. The +// renderer publishes, after each block, the groups still sounding, a frame +// counter and how many records it has applied (32-bit words, release +// stores), which the control side reads without a lock. +// +// No pump lock in here: this file builds into CircuitPython, which has none. +// When the queue is full the producer drains it itself, and the binding says +// how to exclude the renderer while it does (audiodsp_graph_enqueue's `lock` +// and `unlock`; the pump lock on MicroPython and CPython, nothing on +// CircuitPython, where the output pulls on the interpreter's own thread). + // SPDX-License-Identifier: MIT #pragma once @@ -200,6 +213,23 @@ typedef struct { void *data; } audiodsp_graph_node_t; +// The queue's records. 8 bytes each. +enum { + AUDIODSP_GRAPH_OP_SET = 0, + AUDIODSP_GRAPH_OP_RESET, + AUDIODSP_GRAPH_OP_ENABLE, +}; + +typedef struct { + uint8_t op; + uint8_t index; + uint16_t node; + union { + float value; + uint32_t mask; + } arg; +} audiodsp_graph_record_t; + typedef struct { uint32_t sample_rate; uint32_t frames; @@ -212,6 +242,34 @@ typedef struct { audiodsp_graph_node_t *nodes; // Written by run(): groups with an envelope still sounding. uint32_t sounding; + // The groups audiodsp_graph_render runs. Only the renderer writes it, + // from an ENABLE record; 0xFFFFFFFF until one arrives. + uint32_t enable; + // Published after each render, release-stored in this order: sounding, + // then frame, then applied. A reader that loads applied first and + // sounding last gets a mask at least as new as the count. + uint32_t pub_sounding; + uint32_t pub_frame; + uint32_t pub_applied; + // Records applied so far; the renderer's own copy of pub_applied. + uint32_t applied; + // The queue, attached by audiodsp_graph_queue_init. Free-running + // indices; a record lives at index % capacity. + audiodsp_graph_record_t *records; + uint32_t capacity; + uint32_t head; // producer only: written, published or not + uint32_t published; // producer release-stores, renderer acquire-loads + uint32_t tail; // renderer release-stores, producer acquire-loads + bool held; // producer only: hold() is in force + // Producer-side counts: records pushed, drains forced by a full queue, + // held batches split by one, and the most records ever waiting. + uint32_t pushed; + uint32_t overflows; + uint32_t splits; + uint32_t high_water; + // max_nodes * AUDIODSP_GRAPH_MAX_PARAMS floats: what set() last stored, + // so get() reads back the same value whoever renders. NULL for none. + float *shadow; } audiodsp_graph_t; // Bytes a node of `kind` needs for its config and state. `arg` is the @@ -257,3 +315,53 @@ uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, // A buffer's peak, for the caller's silence detection and for tests. int32_t audiodsp_graph_peak(const audiodsp_graph_t *graph, uint32_t buffer); + +// --- the control queue ------------------------------------------------------ + +//: The queue's size when the caller names none: max(256, 4 * nodes). +#define AUDIODSP_GRAPH_QUEUE_MIN 256u + +// Attaches the queue's `capacity` records and the shadow table (max_nodes * +// AUDIODSP_GRAPH_MAX_PARAMS floats, or NULL), both the caller's. Call once, +// after audiodsp_graph_init; the shadow takes each node's defaults as it is +// added. +void audiodsp_graph_queue_init(audiodsp_graph_t *graph, + audiodsp_graph_record_t *records, uint32_t capacity, float *shadow); + +// True when (node, index) names a parameter set() may store, and +// (node) a node reset() may clear when `index` is 0. +bool audiodsp_graph_valid(const audiodsp_graph_t *graph, uint32_t node, + uint32_t index); + +// Pushes one record from the control side. The caller has validated it. +// When the queue is full, publishes what is waiting, takes `lock` (NULL for +// none), drains the queue itself, and drops it: stores only, so it holds +// the pump lock's contract. Unless hold() is in force, the record is +// published at once. A SET also writes the shadow. +void audiodsp_graph_enqueue(audiodsp_graph_t *graph, uint8_t op, + uint32_t node, uint32_t index, float value, uint32_t mask, + void (*lock)(void), void (*unlock)(void)); + +// hold() and commit(): records pushed between the two are published +// together, so they land in one block. commit() without hold() is harmless. +void audiodsp_graph_hold(audiodsp_graph_t *graph); +void audiodsp_graph_commit(audiodsp_graph_t *graph); + +// The renderer's side: applies every published record. Returns how many. +uint32_t audiodsp_graph_drain(audiodsp_graph_t *graph); + +// Drains, renders one block with the graph's own enable mask, and publishes +// sounding, the frame counter and the applied count. Returns sounding. +uint32_t audiodsp_graph_render(audiodsp_graph_t *graph, int16_t *out); + +// The same, with the caller's mask for this block (run()'s `enable`). +uint32_t audiodsp_graph_render_mask(audiodsp_graph_t *graph, int16_t *out, + uint32_t enable); + +// The published words, loaded applied first and sounding last. +void audiodsp_graph_published(const audiodsp_graph_t *graph, + uint32_t *sounding, uint32_t *frame, uint32_t *applied); + +// get() for the bindings: the shadow's value when there is a shadow. +bool audiodsp_graph_get_shadow(const audiodsp_graph_t *graph, uint32_t node, + uint32_t index, float *value); diff --git a/tests/parity/graph_source_probe.py b/tests/parity/graph_source_probe.py new file mode 100644 index 00000000..23e98dcb --- /dev/null +++ b/tests/parity/graph_source_probe.py @@ -0,0 +1,250 @@ +"""A graph pulled as a source renders what run() renders, for verify_graph.py. + + graph_source_probe.py # prints the render, then GRAPH SOURCE PASS + graph_source_probe.py --fault drain # the queue drained after the render: must FAIL + graph_source_probe.py --fault split # commit() publishes half a batch: must FAIL + +One script of changes (notes, groups switched on and off, a reset, and patch +changes of dozens of sets at once) is rendered three ways from three copies of +one graph: + +``run`` ``set()`` and ``reset()``, then ``run(out, 0, enable)`` per block, + with the enable mask passed in: the way a caller drove a graph + before it could be pulled. +``pull`` ``hold()``, the same calls plus ``enable(mask)``, ``commit()``, + then ``audiocore.get_buffer(graph)`` per block: the queue, drained by + the renderer at the top of each block, with the graph's own mask. +``tiny`` ``pull`` with a queue of 16 records, so each patch change overflows + it and is drained from the control side, splitting the batch. + +The three must agree to the sample, with the same sounding mask every block. +The ``pull`` render is printed for verify_graph.py, which compares it across +interpreters. Every value is a short binary fraction or an integer, so a +single-precision MicroPython parses the same floats a double one does. + +Faults: ``drain`` pulls each block before queueing its changes, which is a +renderer that drains after it renders; ``split`` commits half of each batch, +pulls, and commits the rest after, which is a commit() that publishes half. +""" + +import sys +from array import array + +try: + import audiograph as ag + import audiocore + ag.Graph.hold + audiocore.get_buffer +except (ImportError, AttributeError): + print("GRAPH PARITY SKIPPED: no audiograph source here") + sys.exit(0) + +FAULTS = ("drain", "split") +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 = 40 +UNIT = ag.UNIT +failures = [] + + +def check(ok, what): + if not ok: + failures.append(what) + + +def make(queue=0): + """Two voices (saw, ladder, envelope, VCA) in groups 0 and 1, mixed to a + stereo pair through a delay. Returns the graph and its node indices.""" + kw = {"sample_rate": RATE, "frames": FRAMES, "channel_count": 2, + "buffers": 24, "nodes": 24} + if queue: + kw["queue"] = queue + g = ag.Graph(**kw) + n = {} + voices = [] + for v in range(2): + gate_b, env_b, saw_b, vcf_b, amp_b = (g.buffer() for _ in range(5)) + gate = g.add(ag.CONST, v, (), gate_b) + env = g.add(ag.ENV, v, (gate_b,), env_b) + osc = g.add(ag.OSC, v, (None, None, None, None), saw_b) + g.set(osc, ag.OSC_WAVEFORM, 1) + g.set(osc, ag.OSC_LEVEL, 0.5) + vcf = g.add(ag.LADDER, v, (saw_b, None), vcf_b) + vca = g.add(ag.VCA, v, (vcf_b, env_b, None), amp_b) + voices.append(amp_b) + n["gate%d" % v] = gate + n["env%d" % v] = env + n["osc%d" % v] = osc + n["vcf%d" % v] = vcf + n["vca%d" % v] = vca + left_b, right_b, echo_b = g.buffer(), g.buffer(), g.buffer() + n["left"] = g.add(ag.MIX, ag.ALWAYS, (voices[0], voices[1]), left_b) + n["right"] = g.add(ag.MIX, ag.ALWAYS, (voices[0], voices[1]), right_b) + g.set(n["right"], 0, 0.5) + n["echo"] = g.add(ag.FBDELAY, ag.ALWAYS, (left_b,), echo_b, 64.0) + g.set(n["echo"], ag.DELAY_MS, 20.0) + g.set(n["echo"], ag.DELAY_MIX, 0.25) + g.add(ag.OUT, ag.ALWAYS, (echo_b, right_b), None) + return g, n + + +def patch(n, bright): + """A patch change: every voice's filter, envelope and oscillator.""" + ops = [] + for v in range(2): + ops.append(("set", "vcf%d" % v, ag.LADDER_CUTOFF_HZ, + 4096.0 if bright else 512.0)) + ops.append(("set", "vcf%d" % v, ag.LADDER_RESONANCE, + 0.25 if bright else 0.75)) + ops.append(("set", "vcf%d" % v, ag.LADDER_DRIVE, 1.5)) + ops.append(("set", "env%d" % v, ag.ENV_ATTACK, 0.0078125)) + ops.append(("set", "env%d" % v, ag.ENV_DECAY, 0.125)) + ops.append(("set", "env%d" % v, ag.ENV_SUSTAIN, 0.5 if bright else 0.75)) + ops.append(("set", "env%d" % v, ag.ENV_RELEASE, 0.0625)) + ops.append(("set", "osc%d" % v, ag.OSC_WAVEFORM, 2 if bright else 1)) + ops.append(("set", "osc%d" % v, ag.OSC_PULSE_WIDTH, 0.25)) + ops.append(("set", "vca%d" % v, ag.VCA_GAIN, 0.75 if bright else 1.0)) + # Moved twice in one batch: only the last value may sound. + for value in (0.125, 0.5, 0.375): + ops.append(("set", "echo", ag.DELAY_FEEDBACK, value)) + ops.append(("set", "left", 1, 0.75 if bright else 0.5)) + return ops + + +def script(n): + """{block: [op, ...]}; an op is ("set", node, index, value), + ("reset", node) or ("enable", mask).""" + s = {} + s[0] = [("enable", 0b01), ("set", "osc0", ag.OSC_FREQUENCY, 220.0), + ("set", "gate0", 0, UNIT)] + s[3] = patch(n, True) + s[5] = [("enable", 0b11), ("set", "osc1", ag.OSC_FREQUENCY, 330.0), + ("set", "gate1", 0, UNIT)] + s[8] = [("set", "gate0", 0, 0.0)] + s[11] = [("reset", "osc1"), ("set", "osc1", ag.OSC_FREQUENCY, 440.0)] + s[14] = patch(n, False) + [("set", "gate0", 0, UNIT), + ("set", "osc0", ag.OSC_FREQUENCY, 165.0)] + s[19] = [("set", "gate1", 0, 0.0), ("reset", "echo")] + s[24] = [("enable", 0b01)] + s[27] = patch(n, True) + [("set", "gate0", 0, 0.0)] + s[33] = [("enable", 0b00)] + return s + + +def apply(g, n, op, queued): + if op[0] == "set": + g.set(n[op[1]], op[2], op[3]) + elif op[0] == "reset": + g.reset(n[op[1]]) + elif queued: + g.enable(op[1]) + + +def render_run(): + g, n = make() + events = script(n) + pcm = bytearray(FRAMES * 2 * 2) + mask = 0xFFFFFFFF + out = [] + for k in range(BLOCKS): + for op in events.get(k, ()): + if op[0] == "enable": + mask = op[1] + apply(g, n, op, False) + sounding = g.run(pcm, 0, mask) + out.append((mask, sounding, array("h", pcm))) + return out + + +def render_pull(queue=0, fault=None): + g, n = make(queue) + events = script(n) + mask = 0xFFFFFFFF + out = [] + for k in range(BLOCKS): + ops = events.get(k, ()) + for op in ops: + if op[0] == "enable": + mask = op[1] + if fault == "drain": + data = audiocore.get_buffer(g)[1] + g.hold() + half = len(ops) // 2 if fault == "split" else len(ops) + for op in ops[:half]: + apply(g, n, op, True) + g.commit() + if fault == "split": + g.hold() + for op in ops[half:]: + apply(g, n, op, True) + if fault != "drain": + data = audiocore.get_buffer(g)[1] + if fault == "split": + g.commit() + sounding = g.sounding()[0] + out.append((mask, sounding, array("h", bytes(data)))) + stats = g.stats() + return out, stats, g, n + + +def same(a, b): + worst = 0 + for x, y in zip(a, b): + d = x - y if x > y else y - x + if d > worst: + worst = d + return worst + + +reference = render_run() +pulled, stats, g, n = render_pull(fault=FAULT) +tiny, tiny_stats, _, _ = render_pull(queue=16, fault=FAULT) + +for k in range(BLOCKS): + mask, sounding, pcm = pulled[k] + print("pcm %d %d %d %s" % (k, mask, sounding, " ".join(str(s) for s in pcm))) + +loudest = 0 +for name, render in (("pull", pulled), ("tiny", tiny)): + for k in range(BLOCKS): + want, got = reference[k], render[k] + worst = same(want[2], got[2]) + check(worst == 0, "%s: block %d differs from run() by %d counts" + % (name, k, worst)) + check(want[1] == got[1], "%s: block %d sounds %d where run() sounds %d" + % (name, k, got[1], want[1])) +for _, _, pcm in reference: + for s in pcm: + if s > loudest: + loudest = s +check(loudest > 1000, "the render is too quiet to compare (peak %d)" % loudest) + +frames = BLOCKS * FRAMES +check(stats["pushed"] == stats["applied"], + "pull: %d records queued, %d applied" % (stats["pushed"], stats["applied"])) +check(stats["frame"] == frames, + "pull: the frame counter reads %d after %d frames" % (stats["frame"], frames)) +check(stats["overflows"] == 0, "pull: the default queue overflowed") +check(tiny_stats["splits"] > 0, "tiny: no batch overflowed a 16-record queue") +check(tiny_stats["pushed"] == tiny_stats["applied"], + "tiny: %d records queued, %d applied" + % (tiny_stats["pushed"], tiny_stats["applied"])) +check(abs(g.get(n["echo"], ag.DELAY_FEEDBACK) - 0.375) < 1e-6, + "get: reads %r, not the last value set" % g.get(n["echo"], ag.DELAY_FEEDBACK)) +print("stats pushed %d applied %d frame %d overflows %d tiny_splits %d" + % (stats["pushed"], stats["applied"], stats["frame"], stats["overflows"], + tiny_stats["splits"])) + +if failures: + for what in failures[:12]: + print("FAIL", what) + print("GRAPH SOURCE FAIL: %d checks" % len(failures)) + sys.exit(1) +print("GRAPH SOURCE PASS") diff --git a/tests/parity/verify_graph.py b/tests/parity/verify_graph.py index 30023217..c87bf7c5 100644 --- a/tests/parity/verify_graph.py +++ b/tests/parity/verify_graph.py @@ -5,13 +5,15 @@ verify_graph.py --micropython PATH --fault lsb # must FAIL verify_graph.py --micropython PATH --fault late # must FAIL verify_graph.py --micropython PATH --fault phaser # must FAIL (and lag, lfo, hold) + verify_graph.py --micropython PATH --fault drain # must FAIL (and split) Runs two 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 and the sample-and-hold: every sample each -of them writes. The block headers (which voices ran, which were still +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 sounding), the parameters read back and the probes' own checks must match exactly. @@ -44,6 +46,7 @@ PROBES = ( ("runner", HERE / "graph_parity_probe.py", ("lsb", "late")), ("nodes", HERE / "graph_nodes_probe.py", ("phaser", "lag", "lfo", "hold")), + ("source", HERE / "graph_source_probe.py", ("drain", "split")), ) 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/pump/pump_probe.py b/tests/pump/pump_probe.py index 6df4dd07..359d75bf 100644 --- a/tests/pump/pump_probe.py +++ b/tests/pump/pump_probe.py @@ -3,11 +3,12 @@ tests/pump/pump_probe.py [case ...] [--fault WHICH] Cases: ``identity``, ``alloc``, ``storm``, ``driver``, ``unpumpable``, -``swap``, ``writes``, ``tear``, ``fade`` (all by default). +``swap``, ``writes``, ``tear``, ``fade``, ``graph`` (all by default). Faults: ``short`` and ``reuse`` (identity), ``alloc`` (alloc), ``stall`` (storm), ``unpumpable`` (unpumpable), ``unlocked`` (swap, writes), -``split`` (tear), ``cut`` (fade). Each one must make this exit non-zero; a probe whose -failing mode is never run is not a gate. +``split`` (tear, graph), ``cut`` (fade), ``late`` and ``unqueued`` (graph). +Each one must make this exit non-zero; a probe whose failing mode is never +run is not a gate. Runs on a MicroPython build carrying this repository as a usermod -- the one ``clean-build.yml`` makes -- and on CPython with this repository's wheel @@ -98,6 +99,21 @@ run dry, the audio must ramp down, stay silent and ramp back up, and a park that ends before the deadline must be inaudible -- the same audio as no park at all. ``--fault cut`` asks for neither fade, and must fail. + +``graph`` An ``audiograph.Graph`` is a source the pump pulls, and + ``set()`` from this thread reaches it through the graph's own queue, which + the renderer drains at the top of a block. Left and right are two + constants, always set together to +v and -v inside ``hold()`` and + ``commit()``. Every block must hold one value per channel, from one batch; + a block pulled while the batch is held must still play the old pair; and + the new pair must sound no later than the block after the one in flight at + ``commit()``. Where the pump has a thread, 3000 batches are then committed + as fast as this thread can while it pulls, and no block may be torn. The + graph is also among the graphs ``identity`` and ``alloc`` pull, so a + graph pulled by the pump is the same bytes and allocates nothing. Faults: + ``split`` commits half a batch and lets the pump pull before the rest, + ``late`` commits two blocks after the bound, and ``unqueued`` writes past + the queue with ``_set_now()``. Each must fail. """ import gc @@ -112,6 +128,7 @@ import audioconvolve import audiocore +import audiograph import audiodelays import audioecho import audiofilters @@ -234,9 +251,29 @@ def graph_mixer(keep): return mixer +def graph_compiled(keep): + """An audiograph.Graph pulled as a source: a saw through a ladder and a + feedback delay, so the second pull of one graph differs from the first.""" + g = audiograph.Graph(sample_rate=RATE, frames=BLOCK_FRAMES, + channel_count=CHANNELS, buffers=8, nodes=8) + saw, vcf, echo = g.buffer(), g.buffer(), g.buffer() + osc = g.add(audiograph.OSC, audiograph.ALWAYS, (None, None, None, None), + saw) + g.set(osc, audiograph.OSC_WAVEFORM, 1) + g.set(osc, audiograph.OSC_FREQUENCY, 110.0) + g.set(osc, audiograph.OSC_LEVEL, 0.5) + g.add(audiograph.LADDER, audiograph.ALWAYS, (saw, None), vcf) + delay = g.add(audiograph.FBDELAY, audiograph.ALWAYS, (vcf,), echo, 64.0) + g.set(delay, audiograph.DELAY_MS, 30.0) + g.set(delay, audiograph.DELAY_MIX, 0.5) + g.add(audiograph.OUT, audiograph.ALWAYS, (echo, vcf), None) + keep.append(g) + return g + + GRAPHS = (("synth", graph_synth), ("echo", graph_echo), ("filter", graph_filter), ("reverb", graph_reverb), - ("mixer", graph_mixer)) + ("mixer", graph_mixer), ("graph", graph_compiled)) def say(name, ok, detail): @@ -1024,9 +1061,209 @@ def fade(fault): return ok +# --- graph ----------------------------------------------------------------- + + +class _Pair: + """A graph whose left channel is one CONST and right channel another, with + the pump pulling it into a ring this thread drains. Every block it puts out + should hold one value per channel; the probe sets left to +v and right to + -v together, so a block where right is not -left heard half a change.""" + + def __init__(self, queue=0): + kw = {"sample_rate": RATE, "frames": BLOCK_FRAMES, + "channel_count": CHANNELS, "buffers": 4, "nodes": 4} + if queue: + kw["queue"] = queue + g = audiograph.Graph(**kw) + left, right = g.buffer(), g.buffer() + self.left = g.add(audiograph.CONST, audiograph.ALWAYS, (), left) + self.right = g.add(audiograph.CONST, audiograph.ALWAYS, (), right) + g.add(audiograph.OUT, audiograph.ALWAYS, (left, right), None) + g.set(self.left, 0, 1.0) + g.set(self.right, 0, -1.0) + self.g = g + self.block = status() + self.ring = bytearray(BLOCK_FRAMES * CHANNELS * 2 * 16) + self.out = bytearray(len(self.ring)) + self.got = bytearray() + audiopump.spawn(g, 1 << 30, self.block, ring=self.ring) + self.threaded = bool(audiopump.threaded()) + + def pulled(self): + return read(self.block)[BLOCKS_AT] + + def take(self, blocks=1): + """Let the pump pull about `blocks` more and keep what it put out.""" + if not self.threaded: + audiopump.service(blocks) + n = audiopump.drain(self.out) + self.got.extend(self.out[:n]) + + def wait(self, blocks): + want = self.pulled() + blocks + spins = 0 + while self.pulled() < want and spins < 200000: + self.take(1) + spins += 1 + + def stop(self): + audiopump.stop() + audiopump.join() + audiopump.shutdown() + n = audiopump.drain(self.out) + self.got.extend(self.out[:n]) + stats = self.g.stats() + self.g.deinit() + return stats + + def blocks(self): + """[(left values, right values)] per block, each a set.""" + samples = array("h", bytes(self.got)) + per = BLOCK_FRAMES * CHANNELS + out = [] + for at in range(0, len(samples) - per + 1, per): + out.append((set(samples[i] for i in range(at, at + per, 2)), + set(samples[i] for i in range(at + 1, at + per, 2)))) + return out + + +def _graph_set(pair, fault, v): + g = pair.g + if fault == "unqueued": + g._set_now(pair.left, 0, v) + g._set_now(pair.right, 0, -v) + return + g.hold() + g.set(pair.left, 0, v) + if fault == "split": + # A commit that publishes half a batch, with the pump landing in + # between: the shape a renderer that drained mid-batch would have. + g.commit() + pair.wait(1) + g.hold() + g.set(pair.right, 0, -v) + g.commit() + + +def _graph_steps(fault): + """Each step: sets under hold(), a block pulled while they are held (it + must still play the old pair), then commit() and the block after it. + Returns [(block pulled at commit, value)] and the pair.""" + pair = _Pair() + pair.wait(2) + marks = [] + for step in range(48): + v = 2.0 + step + g = pair.g + if fault == "unqueued": + g._set_now(pair.left, 0, v) + g._set_now(pair.right, 0, -v) + else: + g.hold() + g.set(pair.left, 0, v) + if fault == "split": + g.commit() + pair.wait(1) + g.hold() + g.set(pair.right, 0, -v) + # Held: the pump keeps playing the old pair through this block. + held_at = pair.pulled() + pair.wait(1) + at = pair.pulled() + if fault == "late": + # A renderer that drains two blocks after the commit it was + # published by: shown by committing that late, against the + # bound of a commit made now. + after = pair.pulled() + pair.wait(2) + g.commit() + else: + if fault != "unqueued": + g.commit() + # The pump may finish a block between reading the count and the + # commit, so the bound is taken after it: a block counted then + # was in flight, and the one after it must carry the change. + after = pair.pulled() + marks.append((held_at, at, after, v)) + pair.wait(2) + pair.wait(4) # the last batch drained before the stop + return marks, pair + + +def _graph_race(fault, rounds=3000): + """Where the pump has a thread: batches committed as fast as this thread + can, while the pump pulls. Every block still holds one pair.""" + pair = _Pair() + for round_ in range(rounds): + _graph_set(pair, fault, 2.0 + (round_ % 500)) + if round_ % 8 == 7: + pair.take() + stats = pair.stop() + return pair.blocks(), stats + + +def graph(fault): + """An audiograph.Graph pulled by the pump, changed from this thread. + + set() lands on the next block the pump pulls, never inside one: one value + per channel per block, both channels of a batch changing in the same + block, nothing sounding while hold() is in force, and nothing later than + the block after the one in flight at commit().""" + ok = True + marks, pair = _graph_steps(fault) + stats = pair.stop() + blocks = pair.blocks() + torn = sum(1 for left, right in blocks + if len(left) != 1 or len(right) != 1 + or next(iter(right)) != -next(iter(left))) + # Where each value first sounds, as a block index into the stream. + first = {} + for index, (left, _right) in enumerate(blocks): + if len(left) == 1: + value = next(iter(left)) + if value not in first: + first[value] = index + early = late = missing = 0 + for _held_at, at, after, v in marks: + where = first.get(int(v)) + if where is None: + missing += 1 + elif where < at: + early += 1 # sounded while held + elif where > after + 1: + late += 1 # later than the next block + ok = say("pair", torn == 0 and len(blocks) > 0, + "%d of %d blocks with more than one value on a channel, or " + "left and right from different batches" % (torn, len(blocks))) \ + and ok + ok = say("hold", early == 0 and missing == 0, + "%d of %d batches sounded before commit(), %d never sounded" + % (early, len(marks), missing)) and ok + ok = say("when", late == 0, + "%d of %d batches landed later than the block after commit()" + % (late, len(marks))) and ok + ok = say("queue", stats["pushed"] == stats["applied"] + and stats["overflows"] == 0, + "%d records queued, %d applied, %d overflows" + % (stats["pushed"], stats["applied"], stats["overflows"])) and ok + if audiopump.threaded(): + blocks, stats = _graph_race(fault) + torn = sum(1 for left, right in blocks + if len(left) != 1 or len(right) != 1 + or next(iter(right)) != -next(iter(left))) + ok = say("race", torn == 0 and len(blocks) > 0, + "%d of %d blocks torn, 3000 batches committed while the " + "pump pulled; %d records, %d high water" + % (torn, len(blocks), stats["pushed"], + stats["high_water"])) and ok + return ok + + CASES = (("identity", identity), ("alloc", alloc), ("storm", storm), ("driver", driver), ("unpumpable", unpumpable), ("swap", swap), - ("writes", writes), ("tear", tear), ("fade", fade)) + ("writes", writes), ("tear", tear), ("fade", fade), + ("graph", graph)) def main(): diff --git a/tests/test_cpython_audiograph.py b/tests/test_cpython_audiograph.py index 6e289109..a4217292 100644 --- a/tests/test_cpython_audiograph.py +++ b/tests/test_cpython_audiograph.py @@ -7,6 +7,10 @@ | a wow table handed to a delay cannot be resized under it | BufferError | | a graph refuses a second `__init__` | RuntimeError | | a graph built and dropped many times frees what it took | no growth | +| `graph_source_probe.py`: pulled as a source, it renders what `run()` does | GRAPH SOURCE PASS | +| 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 | 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 @@ -87,6 +91,72 @@ def build(): # megabytes. self.assertLess(grown, 64 * 1024) + def test_pulled_as_a_source_it_renders_what_run_does(self): + result = subprocess.run( + [sys.executable, + str(ROOT / "tests" / "parity" / "graph_source_probe.py")], + capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("GRAPH SOURCE PASS", result.stdout) + + def test_every_method_the_board_has(self): + table = (ROOT / "src" / "audiograph" / "Graph.c").read_text() + names = set(re.findall(r"MP_ROM_QSTR\(MP_QSTR_(\w+)\)", table)) + missing = sorted(n for n in names if not hasattr(ag.Graph, n)) + self.assertEqual(missing, []) + + def _pair(self, **kw): + g = ag.Graph(frames=4, channel_count=2, buffers=2, nodes=3, **kw) + left, right = g.buffer(), g.buffer() + a = g.add(ag.CONST, ag.ALWAYS, (), left) + b = g.add(ag.CONST, ag.ALWAYS, (), right) + g.add(ag.OUT, ag.ALWAYS, (left, right), None) + return g, a, b + + @staticmethod + def _pull(g): + return list(array("h", bytes(g._get_buffer(False, 0)[1]))) + + def test_hold_keeps_a_batch_back_until_commit(self): + g, a, b = self._pair() + g.hold() + g.set(a, 0, 100.0) + g.set(b, 0, -100.0) + self.assertEqual(g.get(a, 0), 100.0) # the shadow: already set + self.assertEqual(self._pull(g), [0] * 8) # but not yet heard + g.commit() + self.assertEqual(self._pull(g), [100, -100] * 4) + sounding, frame, applied, pushed = g.sounding() + self.assertEqual((frame, applied, pushed), (8, 2, 2)) + + def test_a_small_queue_splits_a_batch_and_keeps_it_whole(self): + g, a, b = self._pair(queue=2) + g.hold() + for v in range(1, 6): + g.set(a, 0, float(v)) + g.set(b, 0, float(-v)) + g.commit() + self.assertEqual(self._pull(g), [5, -5] * 4) + stats = g.stats() + self.assertEqual(stats["capacity"], 2) + self.assertEqual(stats["pushed"], stats["applied"]) + self.assertGreater(stats["splits"], 0) + + def test_a_bad_set_is_refused_before_it_is_queued(self): + g, a, _b = self._pair() + with self.assertRaises((IndexError, ValueError)): + g.set(99, 0, 1.0) + self.assertEqual(g.stats()["pushed"], 0) + with self.assertRaises(ValueError): + ag.Graph(queue=-1) + + def test_a_deinited_graph_refuses_everything(self): + g, a, _b = self._pair() + g.deinit() + with self.assertRaises(ValueError): + g.set(a, 0, 1.0) + g.deinit() # twice is fine + if __name__ == "__main__": unittest.main()