diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index fabf9ef..df2503e 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -219,9 +219,9 @@ jobs: "$MP" "$PROBE" audiodelays | tee /tmp/pitchshift_freeze.txt grep -q "PITCHSHIFT FREEZE PASS" /tmp/pitchshift_freeze.txt - # The graph runner: one C call over the kernels above. MicroPython only, - # so this is the only place it runs. Each planted fault is a wiring or a - # parameter mistake the runner must not hide, and must FAIL first. + # The graph runner: one C call over the kernels above. Each planted + # fault is a wiring or a parameter mistake the runner must not hide, and + # must FAIL first. - name: The graph runner, and proof its probe can fail run: | MP=./micropython/ports/unix/build-standard/micropython @@ -235,6 +235,26 @@ jobs: "$MP" "$PROBE" | tee /tmp/graph.txt grep -q "GRAPH PASS" /tmp/graph.txt + # The same graph on CPython (the wheel installed above) and on this + # MicroPython, compared sample for sample with a tolerance of 0 counts. + # Each planted fault (one input count off, one set() a block late) goes + # into the MicroPython render only, and the comparison must FAIL first. + # The three-way with the CircuitPython unix build is a local run, as + # for verify_dsp. + - name: A graph renders the same samples on CPython and MicroPython + run: | + MP=$GITHUB_WORKSPACE/micropython/ports/unix/build-standard/micropython + PY=$GITHUB_WORKSPACE/audiodsp/.ci-venv/bin/python + cd audiodsp + for planted in lsb late; 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 + fi + done + "$PY" tests/parity/verify_graph.py --micropython "$MP" | tee /tmp/graph_parity.txt + grep -q "GRAPH RENDERS AGREE" /tmp/graph_parity.txt + # The pump. Everything above proves the DSP nodes; these prove the # thing that pulls them, and they can only run here, because they # need a MicroPython build and this is the job that makes one. diff --git a/AGENTS.md b/AGENTS.md index ac5ce9a..0b8b4c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,10 +40,11 @@ for source compatibility; only this repo's own name differs. - `circuitpython.mk` + `src/circuitpython_spike/` — build `audiodynamics`, `audioroute`, `audiomath`, `audioecho`, `audioshaper`, `audioladder`, `audioconvolve`, `audiobiquad`, `audioverb`, `audiomodal`, `audiosampler`, - `audiooscillator` and `audiovorbis` into CircuitPython 11 as a user C module - (`micropython.mk` hands a CircuitPython tree to `circuitpython.mk`). None - of the thirteen is a CircuitPython port: the first two come from - micropython-vst3's `vstaudio` engine and the last eleven are audiodsp's own, + `audiooscillator`, `audiovorbis`, `audiopump` and `audiograph` into + CircuitPython 11 as a user C module (`micropython.mk` hands a CircuitPython + tree to `circuitpython.mk`). None of the fifteen is a CircuitPython port: the + first two come from micropython-vst3's `vstaudio` engine and the last + thirteen are audiodsp's own, so CircuitPython gains them here rather than the other way round. - `docs/porting-plan.md` — the full phased porting history, architecture, diff --git a/README.md b/README.md index bd0285c..2810ba6 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ This gets you `audiocore`, `synthio`, `audiomixer`, `audiofilters`, `audiodelays`, `audiofreeverb`, `audiospeed`, `audiodynamics`, `audioroute`, `audiomath`, `audioecho`, `audioshaper`, `audioladder`, `audiooscillator`, `audioconvolve`, `audiobiquad`, `audioverb`, `audiomodal`, `audiosampler`, `audiometer`, -`audiopump`, `audiovorbis`, and the `audiorender` package. -`audiomp3` and `audiograph` remain firmware-only. The distribution declares no *required* +`audiopump`, `audiovorbis`, `audiograph`, and the `audiorender` package. +`audiomp3` remains firmware-only. The distribution declares no *required* runtime dependencies and does not itself publish an `audiodsp` import; its version is the `VERSION` file, which is also what `_audiodsp.__version__` reports. @@ -495,7 +495,9 @@ delay) and a few small ones a voice needs (an ADSR, an LFO, noise, a mixer with ramped gains, a VCA), wire them through the graph's own block buffers, put each voice in a group, and call `run()` once a block; nothing in Python runs between the nodes. That is what lets a six-voice polysynth play in real -time on an ESP32-P4. MicroPython only for now: +time on an ESP32-P4. The same graph renders the same samples on CPython, +MicroPython and CircuitPython, so a patch you build on the desktop sounds +there as it will on the board: [docs/audiograph.md](docs/audiograph.md) has the nodes, the wire and the limits. diff --git a/ROADMAP.md b/ROADMAP.md index 66a5091..b21ad47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,9 +10,6 @@ audio pump holding up under live use such as a stompbox or a stage rig. [docs/pump-ports.md](docs/pump-ports.md) for how a port plugs in. - 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. -- `audiograph` on CPython and CircuitPython: the graph runner's core is - runtime-neutral C, so each is a shim over the same calls, and parity tests - across the three can then pin a graph's render on the desktop to the board's. - A level ramp on `audiomixer` voices: a level move ramps over a set number of frames instead of stepping, so an effect can fade its wet path in or out without a click when you switch it in mid-note. diff --git a/circuitpython.mk b/circuitpython.mk index 2020854..d4197a1 100644 --- a/circuitpython.mk +++ b/circuitpython.mk @@ -32,7 +32,7 @@ CFLAGS_USERMOD += -I$(AUDIODSP_CP_SPIKE) -I$(AUDIODSP_CP_SRC) AUDIODSP_CP_MODULES := audiodynamics audioroute audiomath audioecho audioshaper \ audioladder audiooscillator audioconvolve audiobiquad audioverb audiomodal \ - audiosampler audiovorbis audiopump + audiosampler audiovorbis audiopump audiograph AUDIODSP_CP_SOURCES := \ $(foreach m,$(AUDIODSP_CP_MODULES),$(sort $(wildcard $(AUDIODSP_CP_SPIKE)/shared-bindings/$(m)/*.c))) \ @@ -59,6 +59,7 @@ AUDIODSP_CP_SOURCES += $(addprefix $(AUDIODSP_CP_SRC)/shared/, \ audiodsp_sampler.c \ audiodsp_vorbis.c \ audiodsp_port.c \ + audiodsp_graph.c \ ) SRC_USERMOD_C += $(AUDIODSP_CP_SOURCES) diff --git a/docs/audiograph.md b/docs/audiograph.md index c9901a2..cca1cf2 100644 --- a/docs/audiograph.md +++ b/docs/audiograph.md @@ -8,8 +8,10 @@ ones a voice needs (an envelope, an LFO, noise, a mixer, a VCA), then call lets a six-voice polysynth built from these kernels play in real time on an ESP32-P4, where the same patch run node by node from Python could not. -It is MicroPython only for now; the CPython and CircuitPython bindings are -on the [roadmap](../ROADMAP.md). +It is the same module on CPython (in the `pydevices-audiodsp` wheel), +MicroPython and CircuitPython, and the same C underneath, so a graph renders +the same samples on all three: you can build and listen to a patch on the +desktop and get the board's render, sample for sample. ```python import audiograph as ag @@ -115,3 +117,13 @@ sounds and a masked group goes silent at the mixer), to the mixer's gain ramp and to the output node. Each planted fault, a mixer added before the node it reads, a filter left out of the chain, a cutoff sent to a node no output reads, makes it fail; CI runs the faults first. + +`tests/parity/verify_graph.py` renders one patch with every node kind in it +(two voices with their groups parking, a fed buffer, moved gains and +cutoffs, a delay reset mid-tail, a mono graph) on CPython and on each +interpreter you name, and compares every output sample. The tolerance is 0 +counts: the three compile the same C and are handed the same floats, so any +difference is a defect rather than rounding. Its two planted faults, one +input sample one count off and one `set()` a block late, each make it fail. +CI runs it against MicroPython in both float widths; the three-way with the +CircuitPython unix build is a local run. diff --git a/pyproject.toml b/pyproject.toml index 8af1cc0..c94ef84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ Repository = "https://github.com/PyDevices/audiodsp" Issues = "https://github.com/PyDevices/audiodsp/issues" [tool.setuptools] -py-modules = ["audiocore", "synthio", "audiomixer", "audiofilters", "audiodelays", "audiofreeverb", "audiospeed", "audiodynamics", "audioroute", "audiomath", "audioecho", "audioconvolve", "audiobiquad", "audioshaper", "audioladder", "audiooscillator", "audioverb", "audiomodal", "audiosampler", "audiometer", "audiopump", "audiovorbis"] +py-modules = ["audiocore", "synthio", "audiomixer", "audiofilters", "audiodelays", "audiofreeverb", "audiospeed", "audiodynamics", "audioroute", "audiomath", "audioecho", "audioconvolve", "audiobiquad", "audioshaper", "audioladder", "audiooscillator", "audioverb", "audiomodal", "audiosampler", "audiometer", "audiopump", "audiovorbis", "audiograph"] # Keep py-modules explicit: src/cpython is the root package directory now, and # an automatic scan there would also pick up anything else that lands in it. packages = ["audiorender", "audiodsp_util"] diff --git a/setup.py b/setup.py index 6ecb40f..cbf7162 100644 --- a/setup.py +++ b/setup.py @@ -70,6 +70,7 @@ "src/shared/audiodsp_modal.c", "src/shared/audiodsp_sampler.c", "src/shared/audiodsp_meter.c", + "src/shared/audiodsp_graph.c", # Xiph's Tremor, included into this one file from # third_party/tremor; docs/vorbis.md. "src/shared/audiodsp_vorbis.c", diff --git a/src/audiograph/Graph.h b/src/audiograph/Graph.h index 82dfb8b..0df6266 100644 --- a/src/audiograph/Graph.h +++ b/src/audiograph/Graph.h @@ -1,7 +1,8 @@ // audiograph.Graph -- MicroPython binding over shared/audiodsp_graph.c. // -// The runner that renders a whole patch in one call. MicroPython only for -// now; the CPython and CircuitPython bindings are on the roadmap. +// The runner that renders a whole patch in one call. The CircuitPython twin +// is src/circuitpython_spike/shared-bindings/audiograph/, the CPython one the +// Graph type in src/cpython/_audiodsp.c; the three take the same arguments. // // SPDX-License-Identifier: MIT diff --git a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c new file mode 100644 index 0000000..81ff85e --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c @@ -0,0 +1,335 @@ +// audiograph.Graph bindings for CircuitPython. +// +// The same calls, arguments and refusals as the MicroPython binding +// (src/audiograph/Graph.c), so a graph written for one runs on the other and +// renders the same samples. +// +// SPDX-License-Identifier: MIT + +#include +#include + +#include "shared-bindings/audiograph/Graph.h" + +#include "py/binary.h" +#include "py/runtime.h" + +//| class Graph: +//| """A whole patch rendered by one C call per block. +//| +//| A graph owns ``buffers`` blocks of ``frames`` signed 16-bit samples +//| 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.""" +//| +//| def __init__( +//| self, +//| *, +//| sample_rate: int = 48000, +//| frames: int = 128, +//| channel_count: int = 2, +//| buffers: int = 64, +//| nodes: int = 64, +//| ) -> None: +//| """Create an empty graph. ``frames`` is the block, 1 to 256.""" +//| ... +//| +//| def buffer(self) -> int: +//| """A fresh block buffer's id.""" +//| ... +//| +//| def add(self, kind: int, group: int, inputs: Sequence[Optional[int]], +//| output: Optional[int], arg: float = 0.0) -> int: +//| """Append a node and return its index. ``group`` is 0 to 31 or +//| `ALWAYS`; an input left ``None`` is open.""" +//| ... +//| +//| def set(self, node: int, index: int, value: float) -> None: +//| """Store a parameter; the node derives its settings before the next +//| run.""" +//| ... +//| +//| def get(self, node: int, index: int) -> float: +//| """Read a parameter back.""" +//| ... +//| +//| def table(self, node: int, table: array.array) -> None: +//| """A feedback delay's wow shape, an int16 array, borrowed.""" +//| ... +//| +//| def reset(self, node: int) -> None: +//| """Clear a node's running state.""" +//| ... +//| +//| def run(self, out: Optional[WriteableBuffer] = None, block: int = 0, +//| enable: int = 0xFFFFFFFF) -> int: +//| """Render one block into ``out`` at block ``block`` and return the +//| mask of groups whose envelopes are still sounding.""" +//| ... +//| +//| def peak(self, buffer: int) -> int: +//| """A block's peak.""" +//| ... +//| +//| def read(self, buffer: int, dst: WriteableBuffer) -> None: +//| """Copy a block out into an int16 buffer of ``frames``.""" +//| ... +//| +//| def write(self, buffer: int, src: ReadableBuffer) -> None: +//| """Fill a block from an int16 buffer of ``frames``.""" +//| ... +//| + +static audiograph_graph_obj_t *graph_self(mp_obj_t self_in) { + return MP_OBJ_TO_PTR(self_in); +} + +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 }; + 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_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, + MP_ARRAY_SIZE(allowed_args), allowed_args, args); + mp_int_t frames = args[ARG_frames].u_int; + if (frames < 1 || frames > (mp_int_t)AUDIODSP_GRAPH_MAX_FRAMES) { + mp_raise_ValueError(MP_ERROR_TEXT("frames must be from 1 to 256")); + } + mp_int_t channels = args[ARG_channel_count].u_int; + if (channels < 1 || channels > 2) { + mp_raise_ValueError(MP_ERROR_TEXT("channel_count must be 1 or 2")); + } + mp_int_t buffers = args[ARG_buffers].u_int; + mp_int_t nodes = args[ARG_nodes].u_int; + if (buffers < 1 || buffers > 4096 || nodes < 1 || nodes > 4096) { + mp_raise_ValueError(MP_ERROR_TEXT("buffers and nodes must be from 1 to 4096")); + } + if (args[ARG_sample_rate].u_int < 1) { + mp_raise_ValueError(MP_ERROR_TEXT("sample_rate must be at least 1")); + } + 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); + return MP_OBJ_FROM_PTR(self); +} + +// 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); + if (self->next_buffer >= self->graph.nbufs) { + mp_raise_ValueError(MP_ERROR_TEXT("no buffers left")); + } + return MP_OBJ_NEW_SMALL_INT(self->next_buffer++); +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiograph_graph_buffer_obj, + audiograph_graph_buffer); + +static int16_t buffer_id(mp_obj_t obj) { + if (obj == mp_const_none) { + return AUDIODSP_GRAPH_NONE; + } + mp_int_t v = mp_obj_get_int(obj); + if (v < 0 || v > 32767) { + mp_raise_ValueError(MP_ERROR_TEXT("bad buffer id")); + } + return (int16_t)v; +} + +// add(kind, group, inputs, output, arg=0.0): the node's index. `inputs` is +// 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]); + 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")); + } + mp_int_t group = mp_obj_get_int(args[2]); + if (group < 0 || (group > 31 && group != AUDIODSP_GRAPH_ALWAYS)) { + mp_raise_ValueError(MP_ERROR_TEXT("group must be 0..31 or ALWAYS")); + } + size_t len = 0; + mp_obj_t *items = NULL; + mp_obj_get_array(args[3], &len, &items); + if (len > AUDIODSP_GRAPH_MAX_INPUTS) { + mp_raise_ValueError(MP_ERROR_TEXT("too many inputs")); + } + int16_t inputs[AUDIODSP_GRAPH_MAX_INPUTS]; + for (size_t i = 0; i < len; ++i) { + inputs[i] = buffer_id(items[i]); + } + int16_t output = buffer_id(args[4]); + float arg = n_args > 5 ? (float)mp_obj_get_float(args[5]) : 0.0f; + int index = common_hal_audiograph_graph_add(self, + (audiodsp_graph_kind_t)kind, (uint32_t)group, inputs, (uint32_t)len, + output, arg); + if (index < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("graph is full, a buffer id is out of range, or the node has no output")); + } + return MP_OBJ_NEW_SMALL_INT(index); +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audiograph_graph_add_obj, 5, 6, + audiograph_graph_add); + +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_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_obj, 4, 4, + audiograph_graph_set); + +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_raise_ValueError(MP_ERROR_TEXT("bad node or parameter index")); + } + return mp_obj_new_float(value); +} +static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_get_obj, + audiograph_graph_get); + +// 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); + mp_buffer_info_t info; + mp_get_buffer_raise(table, &info, MP_BUFFER_READ); + if (info.typecode != 'h' || info.len % 2) { + mp_raise_TypeError(MP_ERROR_TEXT("table must be an int16 array")); + } + if (!audiodsp_graph_set_table(&self->graph, (uint32_t)mp_obj_get_int(node), + (const int16_t *)info.buf, (uint32_t)(info.len / 2))) { + mp_raise_ValueError(MP_ERROR_TEXT("not a delay node, or a bad table length")); + } + mp_obj_list_append(self->keep, table); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_table_obj, + audiograph_graph_table); + +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_raise_ValueError(MP_ERROR_TEXT("bad node")); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_reset_obj, + audiograph_graph_reset); + +// 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]); + int16_t *out = NULL; + if (n_args > 1 && args[1] != mp_const_none) { + mp_buffer_info_t info; + mp_get_buffer_raise(args[1], &info, MP_BUFFER_WRITE); + mp_int_t block = n_args > 2 ? mp_obj_get_int(args[2]) : 0; + size_t block_bytes = (size_t)self->graph.frames * self->graph.channel_count * 2u; + if (block < 0 || (size_t)(block + 1) * block_bytes > info.len) { + mp_raise_ValueError(MP_ERROR_TEXT("out is too small for that block")); + } + out = (int16_t *)((uint8_t *)info.buf + (size_t)block * block_bytes); + } + uint32_t enable = 0xFFFFFFFFu; + if (n_args > 3) { + enable = (uint32_t)mp_obj_get_int_truncated(args[3]); + } + 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, + audiograph_graph_run); + +static mp_obj_t audiograph_graph_peak(mp_obj_t self_in, mp_obj_t buffer) { + audiograph_graph_obj_t *self = graph_self(self_in); + int32_t peak = audiodsp_graph_peak(&self->graph, (uint32_t)mp_obj_get_int(buffer)); + if (peak < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("bad buffer id")); + } + return MP_OBJ_NEW_SMALL_INT(peak); +} +static MP_DEFINE_CONST_FUN_OBJ_2(audiograph_graph_peak_obj, + audiograph_graph_peak); + +static int16_t *block_of(audiograph_graph_obj_t *self, mp_obj_t buffer) { + mp_int_t id = mp_obj_get_int(buffer); + if (id < 0 || (uint32_t)id >= self->graph.nbufs) { + mp_raise_ValueError(MP_ERROR_TEXT("bad buffer id")); + } + return self->graph.bufs + (size_t)id * self->graph.frames; +} + +// read(buffer, dst): copy a block out into an int16 buffer of `frames`. +static mp_obj_t audiograph_graph_read(mp_obj_t self_in, mp_obj_t buffer, + mp_obj_t dst) { + audiograph_graph_obj_t *self = graph_self(self_in); + const int16_t *src = block_of(self, buffer); + mp_buffer_info_t info; + mp_get_buffer_raise(dst, &info, MP_BUFFER_WRITE); + if (info.len != (size_t)self->graph.frames * 2u) { + mp_raise_ValueError(MP_ERROR_TEXT("dst must hold one block of int16")); + } + memcpy(info.buf, src, info.len); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(audiograph_graph_read_obj, + audiograph_graph_read); + +// 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); + int16_t *dst = block_of(self, buffer); + mp_buffer_info_t info; + mp_get_buffer_raise(src, &info, MP_BUFFER_READ); + if (info.len != (size_t)self->graph.frames * 2u) { + mp_raise_ValueError(MP_ERROR_TEXT("src must hold one block of int16")); + } + memcpy(dst, info.buf, info.len); + return mp_const_none; +} +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_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_get), MP_ROM_PTR(&audiograph_graph_get_obj) }, + { MP_ROM_QSTR(MP_QSTR_table), MP_ROM_PTR(&audiograph_graph_table_obj) }, + { MP_ROM_QSTR(MP_QSTR_reset), MP_ROM_PTR(&audiograph_graph_reset_obj) }, + { MP_ROM_QSTR(MP_QSTR_run), MP_ROM_PTR(&audiograph_graph_run_obj) }, + { MP_ROM_QSTR(MP_QSTR_peak), MP_ROM_PTR(&audiograph_graph_peak_obj) }, + { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&audiograph_graph_read_obj) }, + { MP_ROM_QSTR(MP_QSTR_write), MP_ROM_PTR(&audiograph_graph_write_obj) }, +}; +static MP_DEFINE_CONST_DICT(audiograph_graph_locals, + audiograph_graph_locals_table); + +MP_DEFINE_CONST_OBJ_TYPE( + audiograph_graph_type, + MP_QSTR_Graph, + MP_TYPE_FLAG_NONE, + make_new, audiograph_graph_make_new, + locals_dict, &audiograph_graph_locals + ); diff --git a/src/circuitpython_spike/shared-bindings/audiograph/Graph.h b/src/circuitpython_spike/shared-bindings/audiograph/Graph.h new file mode 100644 index 0000000..453a44d --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiograph/Graph.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/audiograph/Graph.h" + +extern const mp_obj_type_t audiograph_graph_type; diff --git a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c new file mode 100644 index 0000000..14c8aec --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c @@ -0,0 +1,88 @@ +// audiograph module table for CircuitPython. +// +// SPDX-License-Identifier: MIT + +#include + +#include "py/obj.h" +#include "py/runtime.h" + +#include "shared-bindings/audiograph/__init__.h" +#include "shared-bindings/audiograph/Graph.h" + +//| """The graph runner: a whole patch rendered by one C call per block +//| +//| The `audiograph` module builds a graph from nodes that call audiodsp's +//| kernels (the oscillator, the ladder, the float biquad, the feedback +//| delay) and a few small ones a voice needs (an envelope, an LFO, noise, a +//| mixer, a VCA), then renders it with one ``run()`` a block. It is not part +//| of CircuitPython upstream; it comes from PyDevices' audiodsp, and a graph +//| renders the same samples here as on MicroPython and CPython. +//| +//| """ + +static const mp_rom_map_elem_t audiograph_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiograph) }, + { MP_ROM_QSTR(MP_QSTR_Graph), MP_ROM_PTR(&audiograph_graph_type) }, + // node kinds + { MP_ROM_QSTR(MP_QSTR_CONST), MP_ROM_INT(AUDIODSP_GRAPH_CONST) }, + { MP_ROM_QSTR(MP_QSTR_OSC), MP_ROM_INT(AUDIODSP_GRAPH_OSC) }, + { MP_ROM_QSTR(MP_QSTR_LADDER), MP_ROM_INT(AUDIODSP_GRAPH_LADDER) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD), MP_ROM_INT(AUDIODSP_GRAPH_BIQUAD) }, + { MP_ROM_QSTR(MP_QSTR_FBDELAY), MP_ROM_INT(AUDIODSP_GRAPH_FBDELAY) }, + { MP_ROM_QSTR(MP_QSTR_ENV), MP_ROM_INT(AUDIODSP_GRAPH_ENV) }, + { MP_ROM_QSTR(MP_QSTR_LFO), MP_ROM_INT(AUDIODSP_GRAPH_LFO) }, + { MP_ROM_QSTR(MP_QSTR_NOISE), MP_ROM_INT(AUDIODSP_GRAPH_NOISE) }, + { MP_ROM_QSTR(MP_QSTR_MIX), MP_ROM_INT(AUDIODSP_GRAPH_MIX) }, + { MP_ROM_QSTR(MP_QSTR_VCA), MP_ROM_INT(AUDIODSP_GRAPH_VCA) }, + { MP_ROM_QSTR(MP_QSTR_OUT), MP_ROM_INT(AUDIODSP_GRAPH_OUT) }, + { MP_ROM_QSTR(MP_QSTR_ALWAYS), MP_ROM_INT(AUDIODSP_GRAPH_ALWAYS) }, + { MP_ROM_QSTR(MP_QSTR_UNIT), MP_ROM_INT(16384) }, + // parameter indices of the new nodes; the kernels' nodes take their + // option numbers (audiooscillator, audioladder, audiobiquad, audioecho) + { MP_ROM_QSTR(MP_QSTR_ENV_ATTACK), MP_ROM_INT(AUDIODSP_GRAPH_ENV_ATTACK) }, + { MP_ROM_QSTR(MP_QSTR_ENV_DECAY), MP_ROM_INT(AUDIODSP_GRAPH_ENV_DECAY) }, + { MP_ROM_QSTR(MP_QSTR_ENV_SUSTAIN), MP_ROM_INT(AUDIODSP_GRAPH_ENV_SUSTAIN) }, + { MP_ROM_QSTR(MP_QSTR_ENV_RELEASE), MP_ROM_INT(AUDIODSP_GRAPH_ENV_RELEASE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_RATE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_RATE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_AMPLITUDE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_AMPLITUDE) }, + { MP_ROM_QSTR(MP_QSTR_MIX_OFFSET), MP_ROM_INT(AUDIODSP_GRAPH_MIX_OFFSET) }, + { MP_ROM_QSTR(MP_QSTR_VCA_GAIN), MP_ROM_INT(AUDIODSP_GRAPH_VCA_GAIN) }, + { MP_ROM_QSTR(MP_QSTR_VCA_SELECT), MP_ROM_INT(AUDIODSP_GRAPH_VCA_SELECT) }, + // the kernels' option numbers, so a caller needs no other module + { MP_ROM_QSTR(MP_QSTR_OSC_FREQUENCY), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_FREQUENCY) }, + { MP_ROM_QSTR(MP_QSTR_OSC_WAVEFORM), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_WAVEFORM) }, + { MP_ROM_QSTR(MP_QSTR_OSC_PULSE_WIDTH), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_PULSE_WIDTH) }, + { MP_ROM_QSTR(MP_QSTR_OSC_LEVEL), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_LEVEL) }, + { MP_ROM_QSTR(MP_QSTR_OSC_PITCH_OCTAVES), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_PITCH_OCTAVES) }, + { MP_ROM_QSTR(MP_QSTR_OSC_FM_DEPTH), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_FM_DEPTH) }, + { MP_ROM_QSTR(MP_QSTR_OSC_PWM_DEPTH), MP_ROM_INT(AUDIODSP_OSCILLATOR_OPT_PWM_DEPTH) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_CUTOFF_HZ), MP_ROM_INT(AUDIODSP_LADDER_OPT_CUTOFF_HZ) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_RESONANCE), MP_ROM_INT(AUDIODSP_LADDER_OPT_RESONANCE) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_DRIVE), MP_ROM_INT(AUDIODSP_LADDER_OPT_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_POLES), MP_ROM_INT(AUDIODSP_LADDER_OPT_POLES) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_PASSBAND_COMP), MP_ROM_INT(AUDIODSP_LADDER_OPT_PASSBAND_COMP) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_OVERSAMPLE), MP_ROM_INT(AUDIODSP_LADDER_OPT_OVERSAMPLE) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_MIX), MP_ROM_INT(AUDIODSP_LADDER_OPT_MIX) }, + { MP_ROM_QSTR(MP_QSTR_LADDER_CUTOFF_OCTAVES), MP_ROM_INT(AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_MODE), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_MODE) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_FREQUENCY), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_FREQUENCY) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_Q), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_Q) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_GAIN_DB), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_GAIN_DB) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_MIX), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_MIX) }, + { MP_ROM_QSTR(MP_QSTR_DELAY_MS), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS) }, + { MP_ROM_QSTR(MP_QSTR_DELAY_FEEDBACK), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_FEEDBACK) }, + { MP_ROM_QSTR(MP_QSTR_DELAY_MIX), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_MIX) }, + { MP_ROM_QSTR(MP_QSTR_DELAY_DAMPING_HZ), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_DAMPING_HZ) }, + { MP_ROM_QSTR(MP_QSTR_DELAY_WOW_HZ), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_WOW_HZ) }, + { MP_ROM_QSTR(MP_QSTR_DELAY_WOW_DEPTH_MS), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_WOW_DEPTH_MS) }, +}; +static MP_DEFINE_CONST_DICT(audiograph_module_globals, + audiograph_module_globals_table); + +const mp_obj_module_t audiograph_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&audiograph_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_audiograph, audiograph_module); diff --git a/src/circuitpython_spike/shared-bindings/audiograph/__init__.h b/src/circuitpython_spike/shared-bindings/audiograph/__init__.h new file mode 100644 index 0000000..05e767e --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiograph/__init__.h @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MIT + +#pragma once diff --git a/src/circuitpython_spike/shared-module/audiograph/Graph.c b/src/circuitpython_spike/shared-module/audiograph/Graph.c new file mode 100644 index 0000000..1646ad5 --- /dev/null +++ b/src/circuitpython_spike/shared-module/audiograph/Graph.c @@ -0,0 +1,35 @@ +// audiograph.Graph for CircuitPython: the memory around +// shared/audiodsp_graph.c. See Graph.h. +// +// SPDX-License-Identifier: MIT + +#include "shared-module/audiograph/Graph.h" + +#include "py/runtime.h" + +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) { + 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); + self->next_buffer = 0; + self->keep = mp_obj_new_list(0, NULL); +} + +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) { + size_t size = audiodsp_graph_node_data_size(kind, self->graph.sample_rate, + arg); + // On the heap the graph's table points into, so the collector keeps it + // for as long as the graph. + void *data = size ? m_new0(uint8_t, size) : NULL; + int index = audiodsp_graph_add(&self->graph, kind, group, inputs, ninputs, + output, data, arg); + if (index < 0 && data != NULL) { + m_del(uint8_t, data, size); + } + return index; +} diff --git a/src/circuitpython_spike/shared-module/audiograph/Graph.h b/src/circuitpython_spike/shared-module/audiograph/Graph.h new file mode 100644 index 0000000..a17bcba --- /dev/null +++ b/src/circuitpython_spike/shared-module/audiograph/Graph.h @@ -0,0 +1,37 @@ +// audiograph.Graph for CircuitPython. See audiodsp's src/audiograph/Graph.h +// 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. +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +#include "py/obj.h" + +#include "shared/audiodsp_graph.h" + +typedef struct { + mp_obj_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; +} 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); + +// 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); diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 682e946..fc5db7f 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -36,6 +36,7 @@ #include "shared/audiodsp_meter.h" #include "shared/audiodsp_filter_f32.h" #include "shared/audiodsp_vorbis.h" +#include "shared/audiodsp_graph.h" // setup.py defines this from the VERSION file; the fallback is only for // someone compiling this source by hand. @@ -130,6 +131,7 @@ typedef struct { PyObject *tank_state_type; PyObject *modal_state_type; PyObject *sampler_state_type; + PyObject *graph_type; } audiodsp_state_t; typedef struct { @@ -3400,6 +3402,530 @@ static PyType_Spec meter_state_spec = { .slots = meter_state_slots, }; +// --- Graph: audiograph's runner (shared/audiodsp_graph.c) ------------------ +// +// The same calls as the MicroPython binding (src/audiograph/Graph.c), with +// the same arguments and the same refusals, so a graph built on the desktop +// is the graph a board runs. audiograph.py re-exports the type as is: there +// is nothing for Python to add between a caller and the runner. + +typedef struct { + PyObject_HEAD + audiodsp_graph_t graph; + uint32_t next_buffer; + // 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; +} 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}; + 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)) { + return -1; + } + if (self->graph.nodes != NULL) { + PyErr_SetString(PyExc_RuntimeError, "Graph is already initialised"); + return -1; + } + if (frames < 1 || frames > (long)AUDIODSP_GRAPH_MAX_FRAMES) { + PyErr_SetString(PyExc_ValueError, "frames must be from 1 to 256"); + return -1; + } + if (channels < 1 || channels > 2) { + PyErr_SetString(PyExc_ValueError, "channel_count must be 1 or 2"); + return -1; + } + if (buffers < 1 || buffers > 4096 || nodes < 1 || nodes > 4096) { + PyErr_SetString(PyExc_ValueError, + "buffers and nodes must be from 1 to 4096"); + return -1; + } + if (sample_rate < 1 || sample_rate > 0x7FFFFFFFL) { + PyErr_SetString(PyExc_ValueError, "sample_rate must be at least 1"); + 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)); + PyObject *keep = PyList_New(0); + if (bufs == NULL || table == NULL || keep == NULL) { + PyMem_Free(bufs); + PyMem_Free(table); + Py_XDECREF(keep); + if (!PyErr_Occurred()) { + PyErr_NoMemory(); + } + return -1; + } + audiodsp_graph_init(&self->graph, (uint32_t)sample_rate, (uint32_t)frames, + (uint32_t)channels, bufs, (uint32_t)buffers, table, (uint32_t)nodes); + self->next_buffer = 0; + self->keep = keep; + return 0; +} + +static void graph_state_dealloc(audiodsp_graph_object_t *self) { + PyTypeObject *type = Py_TYPE(self); + if (self->graph.nodes != NULL) { + for (uint32_t i = 0; i < self->graph.nnodes; ++i) { + PyMem_Free(self->graph.nodes[i].data); + } + } + PyMem_Free(self->graph.nodes); + PyMem_Free(self->graph.bufs); + self->graph.nodes = NULL; + self->graph.bufs = NULL; + Py_CLEAR(self->keep); + type->tp_free((PyObject *)self); + Py_DECREF(type); +} + +static bool graph_live(audiodsp_graph_object_t *self) { + if (self->graph.nodes == NULL) { + PyErr_SetString(PyExc_RuntimeError, "Graph.__init__ was not called"); + return false; + } + return true; +} + +// buffer(): a fresh block buffer's id. +static PyObject *graph_state_buffer(audiodsp_graph_object_t *self, + PyObject *unused) { + (void)unused; + if (!graph_live(self)) { + return NULL; + } + if (self->next_buffer >= self->graph.nbufs) { + PyErr_SetString(PyExc_ValueError, "no buffers left"); + return NULL; + } + return PyLong_FromUnsignedLong(self->next_buffer++); +} + +// A buffer id, or AUDIODSP_GRAPH_NONE for None. False with an exception set. +static bool graph_buffer_id(PyObject *obj, int16_t *id) { + if (obj == Py_None) { + *id = AUDIODSP_GRAPH_NONE; + return true; + } + long v = PyLong_AsLong(obj); + if (v == -1 && PyErr_Occurred()) { + return false; + } + if (v < 0 || v > 32767) { + PyErr_SetString(PyExc_ValueError, "bad buffer id"); + return false; + } + *id = (int16_t)v; + return true; +} + +// add(kind, group, inputs, output, arg=0.0): the node's index. +static PyObject *graph_state_add(audiodsp_graph_object_t *self, + PyObject *args) { + long kind, group; + PyObject *inputs_obj, *output_obj; + double arg = 0.0; + if (!PyArg_ParseTuple(args, "llOO|d:add", &kind, &group, &inputs_obj, + &output_obj, &arg)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + if (kind < 0 || kind >= (long)AUDIODSP_GRAPH_KINDS) { + PyErr_SetString(PyExc_ValueError, "unknown node kind"); + return NULL; + } + if (group < 0 || (group > 31 && group != (long)AUDIODSP_GRAPH_ALWAYS)) { + PyErr_SetString(PyExc_ValueError, "group must be 0..31 or ALWAYS"); + return NULL; + } + PyObject *seq = PySequence_Fast(inputs_obj, "inputs must be a sequence"); + if (seq == NULL) { + return NULL; + } + Py_ssize_t len = PySequence_Fast_GET_SIZE(seq); + if (len > (Py_ssize_t)AUDIODSP_GRAPH_MAX_INPUTS) { + Py_DECREF(seq); + PyErr_SetString(PyExc_ValueError, "too many inputs"); + return NULL; + } + int16_t inputs[AUDIODSP_GRAPH_MAX_INPUTS]; + for (Py_ssize_t i = 0; i < len; ++i) { + if (!graph_buffer_id(PySequence_Fast_GET_ITEM(seq, i), &inputs[i])) { + Py_DECREF(seq); + return NULL; + } + } + Py_DECREF(seq); + int16_t output; + if (!graph_buffer_id(output_obj, &output)) { + return NULL; + } + size_t size = audiodsp_graph_node_data_size((audiodsp_graph_kind_t)kind, + self->graph.sample_rate, (float)arg); + void *data = NULL; + if (size) { + data = PyMem_Calloc(1, size); + if (data == NULL) { + return PyErr_NoMemory(); + } + } + int index = audiodsp_graph_add(&self->graph, (audiodsp_graph_kind_t)kind, + (uint32_t)group, inputs, (uint32_t)len, output, data, (float)arg); + if (index < 0) { + PyMem_Free(data); + PyErr_SetString(PyExc_ValueError, "graph is full, a buffer id is out " + "of range, or the node has no output"); + return NULL; + } + return PyLong_FromLong(index); +} + +static PyObject *graph_state_set(audiodsp_graph_object_t *self, + PyObject *args) { + long node, index; + double value; + if (!PyArg_ParseTuple(args, "lld:set", &node, &index, &value)) { + return NULL; + } + 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"); + return NULL; + } + Py_RETURN_NONE; +} + +static PyObject *graph_state_get(audiodsp_graph_object_t *self, + PyObject *args) { + long node, index; + if (!PyArg_ParseTuple(args, "ll:get", &node, &index)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + float value = 0.0f; + if (!audiodsp_graph_get(&self->graph, (uint32_t)node, (uint32_t)index, + &value)) { + PyErr_SetString(PyExc_ValueError, "bad node or parameter index"); + return NULL; + } + return PyFloat_FromDouble((double)value); +} + +// table(node, int16_array): a feedback delay's wow shape, borrowed. +static PyObject *graph_state_table(audiodsp_graph_object_t *self, + PyObject *args) { + long node; + PyObject *table; + if (!PyArg_ParseTuple(args, "lO:table", &node, &table)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + // A memoryview holds the export, so the array cannot be resized under + // the delay while the graph keeps it. + PyObject *view = PyMemoryView_FromObject(table); + if (view == NULL) { + return NULL; + } + Py_buffer *info = PyMemoryView_GET_BUFFER(view); + const char *format = info->format ? info->format : "B"; + if (strcmp(format, "h") != 0 || info->itemsize != 2 || + !PyBuffer_IsContiguous(info, 'C')) { + Py_DECREF(view); + PyErr_SetString(PyExc_TypeError, "table must be an int16 array"); + return NULL; + } + if (!audiodsp_graph_set_table(&self->graph, (uint32_t)node, + (const int16_t *)info->buf, (uint32_t)(info->len / 2))) { + Py_DECREF(view); + PyErr_SetString(PyExc_ValueError, + "not a delay node, or a bad table length"); + return NULL; + } + int failed = PyList_Append(self->keep, view); + Py_DECREF(view); + if (failed) { + return NULL; + } + Py_RETURN_NONE; +} + +static PyObject *graph_state_reset(audiodsp_graph_object_t *self, + PyObject *arg) { + long node = PyLong_AsLong(arg); + if (node == -1 && PyErr_Occurred()) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + if (!audiodsp_graph_reset(&self->graph, (uint32_t)node)) { + PyErr_SetString(PyExc_ValueError, "bad node"); + return NULL; + } + Py_RETURN_NONE; +} + +// 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 PyObject *graph_state_run(audiodsp_graph_object_t *self, + PyObject *args) { + PyObject *out_obj = Py_None, *enable_obj = NULL; + long block = 0; + if (!PyArg_ParseTuple(args, "|OlO:run", &out_obj, &block, &enable_obj)) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + uint32_t enable = 0xFFFFFFFFu; + if (enable_obj != NULL) { + // Truncated to 32 bits, as the MicroPython binding takes it. + unsigned long mask = PyLong_AsUnsignedLongMask(enable_obj); + if (mask == (unsigned long)-1 && PyErr_Occurred()) { + return NULL; + } + enable = (uint32_t)mask; + } + Py_buffer view = {0}; + int16_t *out = NULL; + if (out_obj != Py_None) { + if (PyObject_GetBuffer(out_obj, &view, PyBUF_WRITABLE) < 0) { + return NULL; + } + size_t block_bytes = (size_t)self->graph.frames * + self->graph.channel_count * 2u; + if (block < 0 || (size_t)(block + 1) * block_bytes > (size_t)view.len) { + PyBuffer_Release(&view); + PyErr_SetString(PyExc_ValueError, "out is too small for that block"); + return NULL; + } + out = (int16_t *)(void *)((uint8_t *)view.buf + + (size_t)block * block_bytes); + } + uint32_t sounding = audiodsp_graph_run(&self->graph, out, enable); + if (out != NULL) { + PyBuffer_Release(&view); + } + return PyLong_FromUnsignedLong(sounding); +} + +static PyObject *graph_state_peak(audiodsp_graph_object_t *self, + PyObject *arg) { + long id = PyLong_AsLong(arg); + if (id == -1 && PyErr_Occurred()) { + return NULL; + } + if (!graph_live(self)) { + return NULL; + } + int32_t peak = audiodsp_graph_peak(&self->graph, (uint32_t)id); + if (id < 0 || peak < 0) { + PyErr_SetString(PyExc_ValueError, "bad buffer id"); + return NULL; + } + return PyLong_FromLong(peak); +} + +static int16_t *graph_block_of(audiodsp_graph_object_t *self, long id) { + if (id < 0 || (uint32_t)id >= self->graph.nbufs) { + PyErr_SetString(PyExc_ValueError, "bad buffer id"); + return NULL; + } + return self->graph.bufs + (size_t)id * self->graph.frames; +} + +// read(buffer, dst): copy a block out into an int16 buffer of `frames`. +static PyObject *graph_state_read(audiodsp_graph_object_t *self, + PyObject *args) { + long id; + Py_buffer dst; + if (!PyArg_ParseTuple(args, "lw*:read", &id, &dst)) { + return NULL; + } + const int16_t *src = graph_live(self) ? graph_block_of(self, id) : NULL; + if (src == NULL) { + PyBuffer_Release(&dst); + return NULL; + } + if ((size_t)dst.len != (size_t)self->graph.frames * 2u) { + PyBuffer_Release(&dst); + PyErr_SetString(PyExc_ValueError, "dst must hold one block of int16"); + return NULL; + } + memcpy(dst.buf, src, (size_t)dst.len); + PyBuffer_Release(&dst); + Py_RETURN_NONE; +} + +// write(buffer, src): fill a block from an int16 buffer of `frames`. +static PyObject *graph_state_write(audiodsp_graph_object_t *self, + PyObject *args) { + long id; + Py_buffer src; + if (!PyArg_ParseTuple(args, "ly*:write", &id, &src)) { + return NULL; + } + int16_t *dst = graph_live(self) ? graph_block_of(self, id) : NULL; + if (dst == NULL) { + PyBuffer_Release(&src); + return NULL; + } + if ((size_t)src.len != (size_t)self->graph.frames * 2u) { + PyBuffer_Release(&src); + PyErr_SetString(PyExc_ValueError, "src must hold one block of int16"); + return NULL; + } + memcpy(dst, src.buf, (size_t)src.len); + PyBuffer_Release(&src); + Py_RETURN_NONE; +} + +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_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) +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) +static PyMethodDef graph_state_methods[] = { + {"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.")}, + {"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.")}, + {"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, + PyDoc_STR("peak(buffer): a block's peak.")}, + {"read", (PyCFunction)graph_state_read_locked, METH_VARARGS, + PyDoc_STR("read(buffer, dst): copy a block out.")}, + {"write", (PyCFunction)graph_state_write_locked, METH_VARARGS, + PyDoc_STR("write(buffer, src): fill a block.")}, + {NULL, NULL, 0, NULL}, +}; + +AUDIODSP_LOCKED_INIT(graph_state_init, audiodsp_graph_object_t) +static PyType_Slot graph_state_slots[] = { + {Py_tp_new, PyType_GenericNew}, + {Py_tp_init, graph_state_init_locked}, + {Py_tp_dealloc, graph_state_dealloc}, + {Py_tp_methods, graph_state_methods}, + {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.")}, + {0, NULL}, +}; + +static PyType_Spec graph_state_spec = { + // audiograph.py re-exports this type as audiograph.Graph. + .name = "audiograph.Graph", + .basicsize = sizeof(audiodsp_graph_object_t), + .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HEAPTYPE, + .slots = graph_state_slots, +}; + +// The node kinds and parameter numbers audiograph exports, by name, from +// the same enums the MicroPython module table reads. +static int graph_add_constants(PyObject *module) { + static const struct { + const char *name; + long value; + } constants[] = { + {"CONST", AUDIODSP_GRAPH_CONST}, + {"OSC", AUDIODSP_GRAPH_OSC}, + {"LADDER", AUDIODSP_GRAPH_LADDER}, + {"BIQUAD", AUDIODSP_GRAPH_BIQUAD}, + {"FBDELAY", AUDIODSP_GRAPH_FBDELAY}, + {"ENV", AUDIODSP_GRAPH_ENV}, + {"LFO", AUDIODSP_GRAPH_LFO}, + {"NOISE", AUDIODSP_GRAPH_NOISE}, + {"MIX", AUDIODSP_GRAPH_MIX}, + {"VCA", AUDIODSP_GRAPH_VCA}, + {"OUT", AUDIODSP_GRAPH_OUT}, + {"ALWAYS", AUDIODSP_GRAPH_ALWAYS}, + {"UNIT", 16384}, + {"ENV_ATTACK", AUDIODSP_GRAPH_ENV_ATTACK}, + {"ENV_DECAY", AUDIODSP_GRAPH_ENV_DECAY}, + {"ENV_SUSTAIN", AUDIODSP_GRAPH_ENV_SUSTAIN}, + {"ENV_RELEASE", AUDIODSP_GRAPH_ENV_RELEASE}, + {"LFO_RATE", AUDIODSP_GRAPH_LFO_RATE}, + {"LFO_AMPLITUDE", AUDIODSP_GRAPH_LFO_AMPLITUDE}, + {"MIX_OFFSET", AUDIODSP_GRAPH_MIX_OFFSET}, + {"VCA_GAIN", AUDIODSP_GRAPH_VCA_GAIN}, + {"VCA_SELECT", AUDIODSP_GRAPH_VCA_SELECT}, + {"OSC_FREQUENCY", AUDIODSP_OSCILLATOR_OPT_FREQUENCY}, + {"OSC_WAVEFORM", AUDIODSP_OSCILLATOR_OPT_WAVEFORM}, + {"OSC_PULSE_WIDTH", AUDIODSP_OSCILLATOR_OPT_PULSE_WIDTH}, + {"OSC_LEVEL", AUDIODSP_OSCILLATOR_OPT_LEVEL}, + {"OSC_PITCH_OCTAVES", AUDIODSP_OSCILLATOR_OPT_PITCH_OCTAVES}, + {"OSC_FM_DEPTH", AUDIODSP_OSCILLATOR_OPT_FM_DEPTH}, + {"OSC_PWM_DEPTH", AUDIODSP_OSCILLATOR_OPT_PWM_DEPTH}, + {"LADDER_CUTOFF_HZ", AUDIODSP_LADDER_OPT_CUTOFF_HZ}, + {"LADDER_RESONANCE", AUDIODSP_LADDER_OPT_RESONANCE}, + {"LADDER_DRIVE", AUDIODSP_LADDER_OPT_DRIVE}, + {"LADDER_POLES", AUDIODSP_LADDER_OPT_POLES}, + {"LADDER_PASSBAND_COMP", AUDIODSP_LADDER_OPT_PASSBAND_COMP}, + {"LADDER_OVERSAMPLE", AUDIODSP_LADDER_OPT_OVERSAMPLE}, + {"LADDER_MIX", AUDIODSP_LADDER_OPT_MIX}, + {"LADDER_CUTOFF_OCTAVES", AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES}, + {"BIQUAD_MODE", AUDIODSP_BIQUAD_F32_OPT_MODE}, + {"BIQUAD_FREQUENCY", AUDIODSP_BIQUAD_F32_OPT_FREQUENCY}, + {"BIQUAD_Q", AUDIODSP_BIQUAD_F32_OPT_Q}, + {"BIQUAD_GAIN_DB", AUDIODSP_BIQUAD_F32_OPT_GAIN_DB}, + {"BIQUAD_MIX", AUDIODSP_BIQUAD_F32_OPT_MIX}, + {"DELAY_MS", AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS}, + {"DELAY_FEEDBACK", AUDIODSP_FEEDBACK_DELAY_OPT_FEEDBACK}, + {"DELAY_MIX", AUDIODSP_FEEDBACK_DELAY_OPT_MIX}, + {"DELAY_DAMPING_HZ", AUDIODSP_FEEDBACK_DELAY_OPT_DAMPING_HZ}, + {"DELAY_WOW_HZ", AUDIODSP_FEEDBACK_DELAY_OPT_WOW_HZ}, + {"DELAY_WOW_DEPTH_MS", AUDIODSP_FEEDBACK_DELAY_OPT_WOW_DEPTH_MS}, + }; + PyObject *table = PyDict_New(); + if (table == NULL) { + return -1; + } + for (size_t i = 0; i < sizeof(constants) / sizeof(constants[0]); ++i) { + PyObject *value = PyLong_FromLong(constants[i].value); + if (value == NULL || + PyDict_SetItemString(table, constants[i].name, value) < 0) { + Py_XDECREF(value); + Py_DECREF(table); + return -1; + } + Py_DECREF(value); + } + int result = PyModule_AddObjectRef(module, "GRAPH_CONSTANTS", table); + Py_DECREF(table); + return result; +} + // --- VorbisStream: audiovorbis's decoder (shared/audiodsp_vorbis.c) -------- // // The decoder reads the Python file object through three callbacks. A read @@ -4742,6 +5268,11 @@ static int audiodsp_exec(PyObject *module) { AUDIODSP_TANK_LINES) < 0) return -1; if (PyModule_AddIntConstant(module, "TANK_MAX_TAPS", AUDIODSP_TANK_MAX_TAPS) < 0) return -1; + state->graph_type = PyType_FromModuleAndSpec(module, + &graph_state_spec, NULL); + if (state->graph_type == NULL) return -1; + if (PyModule_AddObjectRef(module, "Graph", state->graph_type) < 0) return -1; + if (graph_add_constants(module) < 0) return -1; if (PyModule_AddStringConstant(module, "__version__", AUDIODSP_VERSION) < 0) return -1; // The commit this was built from, so the CPython target answers the same @@ -4776,6 +5307,7 @@ static int audiodsp_traverse(PyObject *module, visitproc visit, void *arg) { Py_VISIT(state->tank_state_type); Py_VISIT(state->modal_state_type); Py_VISIT(state->sampler_state_type); + Py_VISIT(state->graph_type); return 0; } @@ -4802,6 +5334,7 @@ static int audiodsp_clear(PyObject *module) { Py_CLEAR(state->tank_state_type); Py_CLEAR(state->modal_state_type); Py_CLEAR(state->sampler_state_type); + Py_CLEAR(state->graph_type); return 0; } diff --git a/src/cpython/audiograph.py b/src/cpython/audiograph.py new file mode 100644 index 0000000..f8c828f --- /dev/null +++ b/src/cpython/audiograph.py @@ -0,0 +1,93 @@ +"""The graph runner: a whole patch rendered by one C call per block. + +audiodsp's own, not a CircuitPython module. The runner is +``src/shared/audiodsp_graph.c``, the same C the MicroPython and CircuitPython +builds compile, so a graph built here renders the samples a board renders. + + import audiograph as ag + + g = ag.Graph(sample_rate=48000, frames=128, channel_count=2) + gate, env, saw, out = (g.buffer() for _ in range(4)) + k = g.add(ag.CONST, 0, (), gate) + g.add(ag.ENV, 0, (gate,), env) + o = g.add(ag.OSC, 0, (None, None, None, None), saw) + g.set(o, ag.OSC_WAVEFORM, 1) + g.add(ag.VCA, 0, (saw, env, None), out) + g.add(ag.OUT, ag.ALWAYS, (out, out), None) + + pcm = bytearray(128 * 2 * 2) + g.set(k, 0, ag.UNIT) # key down + sounding = g.run(pcm, 0) # one block into pcm + +``docs/audiograph.md`` has the nodes, the wire, the groups and the limits. +""" + +import _audiodsp + +#: The audiodsp this was built from (src/cp_compat/audiodsp_build.h). +__version__ = _audiodsp.__version__ +__revision__ = _audiodsp.__revision__ + +#: The runner itself, the C type as it is: nothing in Python sits between a +#: caller and run(). +Graph = _audiodsp.Graph + +# Read by name from the C enums, so the numbers cannot drift from the ones +# the board builds export. +_K = _audiodsp.GRAPH_CONSTANTS + +# node kinds +CONST = _K["CONST"] +OSC = _K["OSC"] +LADDER = _K["LADDER"] +BIQUAD = _K["BIQUAD"] +FBDELAY = _K["FBDELAY"] +ENV = _K["ENV"] +LFO = _K["LFO"] +NOISE = _K["NOISE"] +MIX = _K["MIX"] +VCA = _K["VCA"] +OUT = _K["OUT"] +ALWAYS = _K["ALWAYS"] +UNIT = _K["UNIT"] + +# parameter indices of the runner's own nodes +ENV_ATTACK = _K["ENV_ATTACK"] +ENV_DECAY = _K["ENV_DECAY"] +ENV_SUSTAIN = _K["ENV_SUSTAIN"] +ENV_RELEASE = _K["ENV_RELEASE"] +LFO_RATE = _K["LFO_RATE"] +LFO_AMPLITUDE = _K["LFO_AMPLITUDE"] +MIX_OFFSET = _K["MIX_OFFSET"] +VCA_GAIN = _K["VCA_GAIN"] +VCA_SELECT = _K["VCA_SELECT"] + +# the kernels' option numbers, so a caller needs no other module +OSC_FREQUENCY = _K["OSC_FREQUENCY"] +OSC_WAVEFORM = _K["OSC_WAVEFORM"] +OSC_PULSE_WIDTH = _K["OSC_PULSE_WIDTH"] +OSC_LEVEL = _K["OSC_LEVEL"] +OSC_PITCH_OCTAVES = _K["OSC_PITCH_OCTAVES"] +OSC_FM_DEPTH = _K["OSC_FM_DEPTH"] +OSC_PWM_DEPTH = _K["OSC_PWM_DEPTH"] +LADDER_CUTOFF_HZ = _K["LADDER_CUTOFF_HZ"] +LADDER_RESONANCE = _K["LADDER_RESONANCE"] +LADDER_DRIVE = _K["LADDER_DRIVE"] +LADDER_POLES = _K["LADDER_POLES"] +LADDER_PASSBAND_COMP = _K["LADDER_PASSBAND_COMP"] +LADDER_OVERSAMPLE = _K["LADDER_OVERSAMPLE"] +LADDER_MIX = _K["LADDER_MIX"] +LADDER_CUTOFF_OCTAVES = _K["LADDER_CUTOFF_OCTAVES"] +BIQUAD_MODE = _K["BIQUAD_MODE"] +BIQUAD_FREQUENCY = _K["BIQUAD_FREQUENCY"] +BIQUAD_Q = _K["BIQUAD_Q"] +BIQUAD_GAIN_DB = _K["BIQUAD_GAIN_DB"] +BIQUAD_MIX = _K["BIQUAD_MIX"] +DELAY_MS = _K["DELAY_MS"] +DELAY_FEEDBACK = _K["DELAY_FEEDBACK"] +DELAY_MIX = _K["DELAY_MIX"] +DELAY_DAMPING_HZ = _K["DELAY_DAMPING_HZ"] +DELAY_WOW_HZ = _K["DELAY_WOW_HZ"] +DELAY_WOW_DEPTH_MS = _K["DELAY_WOW_DEPTH_MS"] + +del _K diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index 0c3fea8..6462bf9 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -3,7 +3,9 @@ // New code -- not a CircuitPython port. A polyphonic patch built from the // kernels in this directory runs in real time on an ESP32-P4 once the // per-node Python between them is gone: this is the C that runs between -// them instead. The binding is src/audiograph/ (MicroPython). +// them instead. The bindings are src/audiograph/ (MicroPython), +// src/circuitpython_spike/shared-bindings/audiograph/ (CircuitPython) and the +// Graph type in src/cpython/_audiodsp.c (CPython). // // The runner owns the block buffers. A graph is a list of nodes in the // order they run; each node names the buffers it reads and the buffer it diff --git a/tests/parity/graph_parity_probe.py b/tests/parity/graph_parity_probe.py new file mode 100644 index 0000000..969ddeb --- /dev/null +++ b/tests/parity/graph_parity_probe.py @@ -0,0 +1,242 @@ +"""audiograph: one patch, rendered block by block, for verify_graph.py to compare. + + graph_parity_probe.py # prints the render, then GRAPH PARITY PASS + graph_parity_probe.py --fault lsb # one input sample one count off + graph_parity_probe.py --fault late # one set() a block late + +The same file runs on CPython, MicroPython and CircuitPython, and prints every +output sample of every block. ``verify_graph.py`` runs it on each and compares +the renders sample for sample; this file only renders, and checks that what +it rendered is worth comparing (sound, voices that start and park), because +three interpreters agreeing on silence would prove nothing. + +The patch is two voices of a small polysynth with every node kind in it: per +voice a pitch and a gate, two envelopes, a pitch mixer taking the LFO, the +oscillator (saw and pulse, the LFO on its pulse width), noise in a group of its +own, the oscillator-and-noise mix, a cutoff mix, the ladder with its cutoff +input, and the VCA (the first voice on its envelope, the second on its gate); +then once, the LFO, two voice mixers whose gains move mid-render, a high-pass +biquad, two feedback delays with a wow table, and the stereo output. Python +feeds one buffer with ``write()`` every block, the voices are masked out of the +run as their envelopes go quiet, a delay is reset mid-tail, and a second graph +renders mono into the later blocks of one buffer. + +Every value handed to the graph is a short binary fraction or an integer, and +the probe's own arithmetic is integer, so a single-precision MicroPython +parses the same floats a double one does: what is compared is the runner, not +how each interpreter rounds a decimal. + +The faults are what a binding could get wrong without any node being wrong: +``lsb`` puts one count into the fed buffer that the others do not, and +``late`` applies a cutoff one block after the others do. verify_graph.py +plants each on one interpreter and must see it. +""" + +import sys +from array import array + +try: + import audiograph as ag +except ImportError: + print("GRAPH PARITY SKIPPED: no audiograph here") + sys.exit(0) + +FAULT = None +if "--fault" in sys.argv: + FAULT = sys.argv[sys.argv.index("--fault") + 1] + if FAULT not in ("lsb", "late"): + print("unknown fault %r" % FAULT) + sys.exit(2) + +RATE = 48000 +FRAMES = 128 +BLOCKS = 160 +UNIT = ag.UNIT +failures = [] + + +def check(ok, what): + if not ok: + failures.append(what) + + +def line(tag, values): + print(tag + " " + " ".join([str(v) for v in values])) + + +g = ag.Graph(sample_rate=RATE, frames=FRAMES, channel_count=2, buffers=48, + nodes=48) + +lfo_b = g.buffer() +ext_b = g.buffer() +lfo = g.add(ag.LFO, ag.ALWAYS, (), lfo_b) +g.set(lfo, ag.LFO_RATE, 5.0) +g.set(lfo, ag.LFO_AMPLITUDE, 1024.0) + +voices = [] +for v in range(2): + pitch_b, gate_b, aenv_b, fenv_b, pmix_b, osc_b, noise_b, dmix_b, cmix_b, \ + vcf_b, vca_b = [g.buffer() for _ in range(11)] + pitch = g.add(ag.CONST, v, (), pitch_b) + gate = g.add(ag.CONST, v, (), gate_b) + aenv = g.add(ag.ENV, v, (gate_b,), aenv_b) + g.set(aenv, ag.ENV_ATTACK, 0.015625) + g.set(aenv, ag.ENV_DECAY, 0.25) + g.set(aenv, ag.ENV_SUSTAIN, 0.625) + g.set(aenv, ag.ENV_RELEASE, 0.015625) + fenv = g.add(ag.ENV, v, (gate_b,), fenv_b) + g.set(fenv, ag.ENV_ATTACK, 0.0078125) + g.set(fenv, ag.ENV_DECAY, 0.125) + g.set(fenv, ag.ENV_SUSTAIN, 0.25) + g.set(fenv, ag.ENV_RELEASE, 0.03125) + pmix = g.add(ag.MIX, v, (pitch_b, lfo_b), pmix_b) + g.set(pmix, 1, 0.125) + osc = g.add(ag.OSC, v, (pmix_b, None, lfo_b, None), osc_b) + g.set(osc, ag.OSC_WAVEFORM, 3) + g.set(osc, ag.OSC_FREQUENCY, 110.0 * (v + 1)) + g.set(osc, ag.OSC_LEVEL, 0.5) + g.set(osc, ag.OSC_PITCH_OCTAVES, 2.0) + g.set(osc, ag.OSC_PWM_DEPTH, 0.25) + noise = g.add(ag.NOISE, 8 + v, (), noise_b) + g.set(noise, 0, 512.0) + dmix = g.add(ag.MIX, v, (osc_b, noise_b), dmix_b) + g.set(dmix, 0, 0.75) + cmix = g.add(ag.MIX, v, (fenv_b,), cmix_b) + g.set(cmix, 0, 0.5) + g.set(cmix, ag.MIX_OFFSET, -2048.0) + vcf = g.add(ag.LADDER, v, (dmix_b, cmix_b), vcf_b) + g.set(vcf, ag.LADDER_CUTOFF_HZ, 800.0) + g.set(vcf, ag.LADDER_RESONANCE, 2.5) + g.set(vcf, ag.LADDER_CUTOFF_OCTAVES, 4.0) + vca = g.add(ag.VCA, v, (vcf_b, aenv_b, gate_b), vca_b) + g.set(vca, ag.VCA_SELECT, float(v)) + g.set(vca, ag.VCA_GAIN, 1.5) + voices.append({"pitch": pitch, "gate": gate, "vcf": vcf, "out": vca_b, + "on": False, "held": False}) + +suml_b, sumr_b, hp_b, dlyl_b, dlyr_b = [g.buffer() for _ in range(5)] +suml = g.add(ag.MIX, ag.ALWAYS, (voices[0]["out"], voices[1]["out"], ext_b), + suml_b) +g.set(suml, 0, 0.75) +g.set(suml, 1, 0.5) +sumr = g.add(ag.MIX, ag.ALWAYS, (voices[0]["out"], voices[1]["out"]), sumr_b) +g.set(sumr, 0, 0.5) +g.set(sumr, 1, 0.75) +hp = g.add(ag.BIQUAD, ag.ALWAYS, (suml_b,), hp_b) +g.set(hp, ag.BIQUAD_MODE, 1) +g.set(hp, ag.BIQUAD_FREQUENCY, 60.0) +g.set(hp, ag.BIQUAD_Q, 0.75) +# A triangle for the delays' wow, in integers. +wow = array("h", [0] * 256) +for i in range(256): + wow[i] = (i if i < 128 else 255 - i) * 256 - 16384 +dlys = [] +for src, dst, ms in ((hp_b, dlyl_b, 7.5), (sumr_b, dlyr_b, 11.25)): + d = g.add(ag.FBDELAY, ag.ALWAYS, (src,), dst, 20.0) + g.set(d, ag.DELAY_MS, ms) + g.set(d, ag.DELAY_FEEDBACK, 0.375) + g.set(d, ag.DELAY_MIX, 0.5) + g.set(d, ag.DELAY_DAMPING_HZ, 4000.0) + g.set(d, ag.DELAY_WOW_HZ, 0.5) + g.set(d, ag.DELAY_WOW_DEPTH_MS, 1.0) + g.table(d, wow) + dlys.append(d) +g.add(ag.OUT, ag.ALWAYS, (dlyl_b, dlyr_b), None) + +# What happens when: (block, what, voice or node, value). +EVENTS = { + 0: [("key", 0, 0.0)], + 12: [("key", 1, 2048.0)], + 30: [("gain", suml, 0.25)], + 45: [("cutoff", 0, 2000.0), ("hp", hp, 120.0)], + 60: [("off", 0, None)], + 80: [("reset", dlys[1], None)], + 85: [("key", 0, -1024.0)], + 95: [("off", 1, None)], + 110: [("off", 0, None)], +} +if FAULT == "late": + EVENTS[46] = [e for e in EVENTS.pop(45) if e[0] == "cutoff"] + EVENTS[45] = [("hp", hp, 120.0)] + + +def apply(what, who, value): + if what == "key": + voice = voices[who] + g.set(voice["pitch"], 0, value) + g.set(voice["gate"], 0, float(UNIT)) + voice["on"] = voice["held"] = True + elif what == "off": + voice = voices[who] + g.set(voice["gate"], 0, 0.0) + voice["held"] = False + elif what == "cutoff": + g.set(voices[who]["vcf"], ag.LADDER_CUTOFF_HZ, value) + elif what == "gain": + g.set(who, 0, value) + elif what == "hp": + g.set(who, ag.BIQUAD_FREQUENCY, value) + elif what == "reset": + g.reset(who) + + +pcm = array("h", [0] * (FRAMES * 2)) +ext = array("h", [0] * FRAMES) +loudest = 0 +parked = 0 +masks = [] +for k in range(BLOCKS): + for event in EVENTS.get(k, ()): + apply(*event) + for i in range(FRAMES): + ext[i] = (k * FRAMES + i) * 37 % 2001 - 1000 + if FAULT == "lsb" and k == 50: + ext[64] += 1 + g.write(ext_b, ext) + enable = 0 + for v, voice in enumerate(voices): + if voice["on"]: + enable |= (1 << v) | (1 << (8 + v)) + sounding = g.run(pcm, 0, enable) + masks.append(enable) + for v, voice in enumerate(voices): + # Park a voice the block its amplitude envelope goes quiet. + if voice["on"] and not voice["held"] and not sounding & (1 << v): + voice["on"] = False + parked += 1 + for s in pcm: + if s > loudest: + loudest = s + elif -s > loudest: + loudest = -s + line("pcm %d %d %d" % (k, enable, sounding), pcm) + +line("peaks", [g.peak(b) for b in (suml_b, sumr_b, hp_b, dlyl_b, dlyr_b)]) +line("params", [g.get(voices[0]["vcf"], ag.LADDER_CUTOFF_HZ), + g.get(hp, ag.BIQUAD_FREQUENCY), g.get(suml, 0)]) + +# A mono graph, rendered into the later blocks of one buffer. +m = ag.Graph(sample_rate=RATE, frames=64, channel_count=1, buffers=4, nodes=4) +a, b = m.buffer(), m.buffer() +o = m.add(ag.OSC, ag.ALWAYS, (None, None, None, None), a) +m.set(o, ag.OSC_WAVEFORM, 1) +m.set(o, ag.OSC_FREQUENCY, 440.0) +m.set(m.add(ag.CONST, ag.ALWAYS, (), b), 0, 1000.0) +m.add(ag.OUT, ag.ALWAYS, (a, b), None) +mono = array("h", [0] * (64 * 4)) +for k in range(1, 4): + m.run(mono, k) +line("mono", mono) + +check(loudest > 2000, "the render is too quiet to compare (peak %d)" % loudest) +check(loudest < 32767, "the render clips (peak %d)" % loudest) +check(parked == 3, "voices parked %d times, not 3" % parked) +check(masks[11] == 0x101 and masks[12] == 0x303, "the voices' groups did not start where the keys did") +check(masks[-1] == 0, "a voice is still running at the end (mask %#x)" % masks[-1]) + +if failures: + print("GRAPH PARITY FAIL") + for f in failures: + print(" " + f) + sys.exit(1) +print("GRAPH PARITY PASS") diff --git a/tests/parity/graph_probe.py b/tests/parity/graph_probe.py index a03eefd..49fdbb8 100644 --- a/tests/parity/graph_probe.py +++ b/tests/parity/graph_probe.py @@ -5,8 +5,9 @@ graph_probe.py --fault dropped # a node left out of the chain: must FAIL graph_probe.py --fault ignored # a parameter set where no node reads it: must FAIL -MicroPython only: audiograph has no CPython binding yet (ROADMAP.md). The -probe needs nothing but the module and ``array``. +It runs unchanged on CPython, MicroPython and CircuitPython, and needs +nothing but the module and ``array``. verify_graph.py is the comparison +between them. What it holds the runner to: diff --git a/tests/parity/verify_graph.py b/tests/parity/verify_graph.py new file mode 100644 index 0000000..c4d6c21 --- /dev/null +++ b/tests/parity/verify_graph.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""A graph renders the same samples on every interpreter. + + verify_graph.py --micropython PATH [--circuitpython PATH] + verify_graph.py --micropython PATH --fault lsb # must FAIL + verify_graph.py --micropython PATH --fault late # must FAIL + +Runs ``graph_parity_probe.py`` on this CPython (``audiograph`` from the +installed wheel) and on each interpreter named, and compares the renders +sample for sample: every output sample of every block, the buffers' peaks and +a mono graph's blocks. The block headers (which voices ran, which were still +sounding), the parameters read back and the probe's own checks must match +exactly. + +**The tolerance is 0 counts.** All three compile the same +``src/shared/audiodsp_graph.c`` over the same kernels, and the probe hands +every interpreter the same floats, so there is no difference of intent for a +tolerance to absorb: a sample that differs by one count is a binding that +passed something differently, a width, or a compiler's choice, and that is +the finding. ``--tolerance`` exists to measure a divergence, not to accept +one. + +Two interpreters are the minimum, and fewer is refused: one cannot disagree +with itself. CI runs CPython against MicroPython; the three-way with the +CircuitPython unix build is a local run, as for verify_dsp.py. + +``--fault`` plants a mistake on every interpreter after the first (the probe's +docstring has what each one is), and the run must then fail. It is how this +gate shows it can. +""" + +import argparse +import os +from pathlib import Path +import subprocess +import sys + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +PROBE = HERE / "graph_parity_probe.py" +#: Lines whose fields after the tag are samples, compared within the tolerance. +SAMPLE_LINES = ("pcm", "peaks", "mono") +#: Leading fields of a sample line that are not samples: a pcm line's block, +#: enable mask and sounding mask, compared exactly. +HEADER_FIELDS = {"pcm": 3, "peaks": 0, "mono": 0} + + +def render(argv, fault, timeout): + command = argv + [str(PROBE)] + (["--fault", fault] if fault else []) + environment = os.environ.copy() + environment["MICROPYPATH"] = "%s:%s" % (ROOT, ROOT / "lib") + result = subprocess.run(command, cwd=str(ROOT), env=environment, + capture_output=True, check=False, timeout=timeout) + text = result.stdout.decode("utf-8", "replace").replace("\r\n", "\n") + if "GRAPH PARITY SKIPPED" in text: + raise SystemExit("%s has no audiograph" % argv[0]) + return result.returncode, text.splitlines() + + +def compare(reference, other, tolerance): + """(samples compared, samples differing, worst delta, first difference, + structural mismatches).""" + compared = differing = worst = 0 + first = None + mismatches = [] + if len(reference) != len(other): + mismatches.append("%d lines against %d" % (len(reference), len(other))) + for number, (one, two) in enumerate(zip(reference, other)): + a, b = one.split(), two.split() + tag = a[0] if a else "" + if tag not in SAMPLE_LINES or not b or b[0] != tag: + if one != two: + mismatches.append("line %d: %r against %r" + % (number + 1, one[:80], two[:80])) + continue + head = 1 + HEADER_FIELDS[tag] + if a[:head] != b[:head] or len(a) != len(b): + mismatches.append("line %d: %r against %r" + % (number + 1, " ".join(a[:head]), + " ".join(b[:head]))) + continue + for index in range(head, len(a)): + delta = abs(float(a[index]) - float(b[index])) + compared += 1 + if delta: + differing += 1 + if first is None: + first = "%s, field %d" % (" ".join(a[:head]), index - head) + worst = max(worst, delta) + if worst > tolerance: + mismatches.append("a sample differs by %g counts, over the tolerance " + "of %g" % (worst, tolerance)) + return compared, differing, worst, first, mismatches + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--micropython", help="a MicroPython with audiograph") + parser.add_argument("--circuitpython", help="a CircuitPython with audiograph") + parser.add_argument("--tolerance", type=float, default=0.0, + help="counts a sample may differ by (default 0)") + parser.add_argument("--fault", choices=("lsb", "late"), + help="plant a mistake on every interpreter but the first") + parser.add_argument("--timeout", type=float, default=300.0, + help="seconds one render may take") + args = parser.parse_args() + + interpreters = [("cpython", [sys.executable])] + for name, path in (("micropython", args.micropython), + ("circuitpython", args.circuitpython)): + if path: + if not Path(path).exists(): + raise SystemExit("%s was named but is not built at %s" % (name, path)) + interpreters.append((name, [str(path)])) + if len(interpreters) < 2: + raise SystemExit("this gate compares interpreters, so it needs at least " + "two: pass --micropython PATH (and --circuitpython PATH)") + + renders = {} + failed = False + for position, (name, argv) in enumerate(interpreters): + fault = args.fault if position else None + code, lines = render(argv, fault, args.timeout) + renders[name] = lines + if code: + print("FAIL %-14s the probe failed its own checks (exit %d)" % (name, code)) + for line in lines[-6:]: + print(" " + line[:120]) + failed = True + + reference = interpreters[0][0] + for name, _ in interpreters[1:]: + compared, differing, worst, first, mismatches = compare( + renders[reference], renders[name], args.tolerance) + verdict = "FAIL" if mismatches else "ok " + print("%s %s = %s: %d samples, %d differ, worst %g counts (tolerance %g)" + % (verdict, reference, name, compared, differing, worst, args.tolerance)) + if first: + print(" first difference at %s" % first) + for line in mismatches[:5]: + print(" " + line) + failed = failed or bool(mismatches) + + if failed: + print("GRAPH RENDERS DIFFER" + (" (fault planted: %s)" % args.fault + if args.fault else "")) + raise SystemExit(1) + print("GRAPH RENDERS AGREE: %s" % " = ".join(name for name, _ in interpreters)) + + +main() diff --git a/tests/test_binding_parity.py b/tests/test_binding_parity.py index ada3162..1c83b99 100644 --- a/tests/test_binding_parity.py +++ b/tests/test_binding_parity.py @@ -44,12 +44,18 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent -#: The thirteen modules that are ours. Upstream CircuitPython has no counterpart to +#: The fourteen modules that are ours. Upstream CircuitPython has no counterpart to #: any of them, which is why we carry two bindings rather than one. MODULES = ("audiobiquad", "audioconvolve", "audiodynamics", "audioecho", "audioladder", "audiomath", "audiomodal", "audiooscillator", "audioroute", "audiosampler", "audioshaper", "audioverb", - "audiovorbis") + "audiovorbis", "audiograph") + +#: Types that are not audio nodes, so the deinit rule below is not theirs. +#: `audiograph.Graph` is never played and never pulled: it renders into a +#: buffer its caller owns, holds nothing another object reads, and is freed by +#: the collector like any other object. +NOT_NODES = {"audiograph_graph"} #: Both spellings are in use: audiodsp's MicroPython bindings mostly say #: `_locals_table` and the CircuitPython ones `_locals_dict_table`. A regex that @@ -106,14 +112,14 @@ def test_the_tables_are_found_at_all(self): The surface is the same, which is all this file compares. Seventeen with `audiovorbis.VorbisDecoder`, whose two bindings differ the same way. Twenty with `audiosampler.SampleBuffer`, twenty-one with - `audiooscillator.Oscillator`.""" + `audiooscillator.Oscillator`, twenty-two with `audiograph.Graph`.""" micropython = {} circuitpython = {} for module in MODULES: micropython.update(micropython_surfaces(module)) circuitpython.update(circuitpython_surfaces(module)) - self.assertEqual(len(micropython), 21, sorted(micropython)) - self.assertEqual(len(circuitpython), 21, sorted(circuitpython)) + self.assertEqual(len(micropython), 22, sorted(micropython)) + self.assertEqual(len(circuitpython), 22, sorted(circuitpython)) def test_every_type_exists_on_both_sides(self): for module in MODULES: @@ -150,6 +156,8 @@ def test_every_node_type_has_deinit_on_both_sides(self): ("CircuitPython", circuitpython_surfaces(module))): for name, keys in sorted(surface.items()): + if name in NOT_NODES: + continue with self.subTest(target=label, type=name): self.assertIn("deinit", keys) self.assertIn("__enter__", keys) diff --git a/tests/test_cpython_audiograph.py b/tests/test_cpython_audiograph.py new file mode 100644 index 0000000..6e28910 --- /dev/null +++ b/tests/test_cpython_audiograph.py @@ -0,0 +1,92 @@ +"""audiograph on CPython: the board's module, with the board's names. + +| trait | bar | +|---|---| +| `graph_probe.py`, the runner's own probe, passes here | GRAPH PASS | +| every name the MicroPython module exports exists here | the same set | +| 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 | + +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 +alone can check. +""" + +from array import array +import pathlib +import re +import subprocess +import sys +import tracemalloc +import unittest + +import audiograph as ag + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +class TheModule(unittest.TestCase): + + def test_the_runner_passes_its_own_probe(self): + result = subprocess.run( + [sys.executable, str(ROOT / "tests" / "parity" / "graph_probe.py")], + capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("GRAPH PASS", result.stdout) + + def test_every_name_the_board_exports(self): + table = (ROOT / "src" / "audiograph" / "module.c").read_text() + names = set(re.findall(r"MP_ROM_QSTR\(MP_QSTR_(\w+)\)", table)) + names -= {"__name__", "audiograph"} + missing = sorted(n for n in names if not hasattr(ag, n)) + self.assertEqual(missing, []) + self.assertIsInstance(ag.Graph(), ag.Graph) + + def test_a_borrowed_table_is_held(self): + g = ag.Graph(buffers=2, nodes=2) + src, dst = g.buffer(), g.buffer() + d = g.add(ag.FBDELAY, ag.ALWAYS, (src,), dst, 10.0) + wow = array("h", [0, 100, 0, -100]) + g.table(d, wow) + with self.assertRaises(BufferError): + wow.append(0) + with self.assertRaises(TypeError): + g.table(d, bytearray(8)) + g.run(None) + + def test_init_twice_is_refused(self): + g = ag.Graph() + with self.assertRaises(RuntimeError): + g.__init__() + + def test_keyword_only_like_the_board(self): + with self.assertRaises(TypeError): + ag.Graph(48000) + + def test_a_dropped_graph_frees_its_memory(self): + def build(): + g = ag.Graph(buffers=16, nodes=16) + bufs = [g.buffer() for _ in range(8)] + g.add(ag.OSC, 0, (None, None, None, None), bufs[0]) + g.add(ag.LADDER, 0, (bufs[0], None), bufs[1]) + g.add(ag.FBDELAY, ag.ALWAYS, (bufs[1],), bufs[2], 100.0) + g.add(ag.OUT, ag.ALWAYS, (bufs[2], bufs[2]), None) + g.run(bytearray(128 * 4)) + + build() + tracemalloc.start() + try: + base = tracemalloc.get_traced_memory()[0] + for _ in range(200): + build() + grown = tracemalloc.get_traced_memory()[0] - base + finally: + tracemalloc.stop() + # One graph with a 100 ms line is tens of kilobytes; 200 kept would be + # megabytes. + self.assertLess(grown, 64 * 1024) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cpython_free_threading.py b/tests/test_cpython_free_threading.py index aea7f80..c9b1643 100644 --- a/tests/test_cpython_free_threading.py +++ b/tests/test_cpython_free_threading.py @@ -51,7 +51,7 @@ "audioroute", "audiomath", "audioecho", "audioshaper", "audioladder", "audiooscillator", "audioconvolve", "audiobiquad", "audioverb", "audiomodal", "audiosampler", "audiometer", "audiopump", - "audiovorbis", "audiodsp_util") + "audiovorbis", "audiograph", "audiodsp_util") INSTRUMENTS = 16 BLOCKS = 60 RATES = (48000, 44100)