From 36fd8376c034b959e2db96d6094cd96bfbb84b27 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:10:29 -0500 Subject: [PATCH 1/2] Spike: audiograph, a C graph runner over the existing kernels (MicroPython only) One Graph object owns the block buffers and a list of nodes; run() renders every enabled node in order and writes the OUT node into a caller's buffer. The kernels are the oscillator, ladder, float biquad and feedback delay as they stand, plus small new nodes a voice needs: an ADSR (vrack's EnvelopeCore per sample), a triangle LFO, white noise, a mixer with ramped gains, a VCA and a constant. No existing node file changes. --- micropython.cmake | 3 + micropython.mk | 6 + src/audiograph/Graph.c | 268 ++++++++++++++ src/audiograph/Graph.h | 23 ++ src/audiograph/module.c | 75 ++++ src/shared/audiodsp_graph.c | 675 ++++++++++++++++++++++++++++++++++++ src/shared/audiodsp_graph.h | 188 ++++++++++ 7 files changed, 1238 insertions(+) create mode 100644 src/audiograph/Graph.c create mode 100644 src/audiograph/Graph.h create mode 100644 src/audiograph/module.c create mode 100644 src/shared/audiodsp_graph.c create mode 100644 src/shared/audiodsp_graph.h diff --git a/micropython.cmake b/micropython.cmake index d75f2de..b9e6bfc 100644 --- a/micropython.cmake +++ b/micropython.cmake @@ -128,6 +128,9 @@ target_sources(usermod_mpaudio INTERFACE ${MPAUDIO_SRC_DIR}/audiosampler/Sampler.c ${MPAUDIO_SRC_DIR}/audiosampler/SamplerVoice.c ${MPAUDIO_SRC_DIR}/audiosampler/module.c + ${MPAUDIO_SRC_DIR}/shared/audiodsp_graph.c + ${MPAUDIO_SRC_DIR}/audiograph/Graph.c + ${MPAUDIO_SRC_DIR}/audiograph/module.c ) target_include_directories(usermod_mpaudio INTERFACE ${MPAUDIO_SRC_DIR}) diff --git a/micropython.mk b/micropython.mk index a3cc900..94931a5 100644 --- a/micropython.mk +++ b/micropython.mk @@ -284,6 +284,12 @@ SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/audiosampler/Sampler.c \ $(MPAUDIO_SRC_DIR)/audiosampler/SamplerVoice.c \ $(MPAUDIO_SRC_DIR)/audiosampler/module.c +# audiograph: the graph runner (spike, 2026-10-21): one C call renders a +# whole patch per block from the kernels above. MicroPython only. +SRC_USERMOD_C += \ + $(MPAUDIO_SRC_DIR)/shared/audiodsp_graph.c \ + $(MPAUDIO_SRC_DIR)/audiograph/Graph.c \ + $(MPAUDIO_SRC_DIR)/audiograph/module.c # --- tier 5: audiomp3 (MP3Decoder) --- # diff --git a/src/audiograph/Graph.c b/src/audiograph/Graph.c new file mode 100644 index 0000000..843725f --- /dev/null +++ b/src/audiograph/Graph.c @@ -0,0 +1,268 @@ +// audiograph.Graph. See Graph.h. +// SPDX-License-Identifier: MIT + +#include "audiograph/Graph.h" + +#include + +#include "py/binary.h" +#include "py/runtime.h" + +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); + 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); + self->next_buffer = 0; + self->keep = mp_obj_new_list(0, NULL); + 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; + size_t size = audiodsp_graph_node_data_size((audiodsp_graph_kind_t)kind, + self->graph.sample_rate, arg); + void *data = size ? m_new0(uint8_t, size) : NULL; + int index = audiodsp_graph_add(&self->graph, (audiodsp_graph_kind_t)kind, + (uint32_t)group, inputs, (uint32_t)len, output, data, arg); + if (index < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("graph is full or a buffer id is out of range")); + } + 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/audiograph/Graph.h b/src/audiograph/Graph.h new file mode 100644 index 0000000..3d9bcb4 --- /dev/null +++ b/src/audiograph/Graph.h @@ -0,0 +1,23 @@ +// audiograph.Graph -- MicroPython binding over shared/audiodsp_graph.c. +// +// A spike's module (graph-runner, 2026-10-21): the runner that renders a +// whole patch in one call. MicroPython only for now. +// +// 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; + +extern const mp_obj_type_t audiograph_graph_type; diff --git a/src/audiograph/module.c b/src/audiograph/module.c new file mode 100644 index 0000000..3839342 --- /dev/null +++ b/src/audiograph/module.c @@ -0,0 +1,75 @@ +// audiograph module table: the graph runner and its node kinds. +// SPDX-License-Identifier: MIT + +#include "audiograph/Graph.h" + +#include "cp_compat/audiodsp_build.h" + +#include "py/obj.h" + +static const mp_rom_map_elem_t audiograph_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiograph) }, + AUDIODSP_BUILD_GLOBALS, + { 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/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c new file mode 100644 index 0000000..593f4ad --- /dev/null +++ b/src/shared/audiodsp_graph.c @@ -0,0 +1,675 @@ +// The graph runner. See audiodsp_graph.h. +// SPDX-License-Identifier: MIT + +#include "shared/audiodsp_graph.h" + +#include +#include + +// ---------------------------------------------------------------- nodes -- + +typedef struct { + audiodsp_oscillator_config_t config; + audiodsp_oscillator_state_t state; +} graph_osc_t; + +typedef struct { + audiodsp_ladder_config_t config; + audiodsp_ladder_state_t state; +} graph_ladder_t; + +typedef struct { + audiodsp_biquad_f32_config_t config; + audiodsp_biquad_f32_state_t state; +} graph_biquad_t; + +typedef struct { + audiodsp_feedback_delay_config_t config; + audiodsp_feedback_delay_state_t state; + uint32_t line_frames; + // The line follows, 2 * line_frames samples. + int16_t line[]; +} graph_fbdelay_t; + +enum { ENV_IDLE = 0, ENV_ATTACK, ENV_DECAY, ENV_SUSTAIN, ENV_RELEASE }; + +// vrack's EnvelopeCore: each stage a one-pole approach to a target, the +// attack aimed past 1.0 so it arrives with a slope. +#define ENV_ATTACK_TARGET 1.3f +#define ENV_DECAY_TOLERANCE 0.001f +#define ENV_RELEASE_TOLERANCE 0.0005f +#define ENV_GATE_THRESHOLD (0.1f * AUDIODSP_GRAPH_UNIT) + +typedef struct { + uint8_t stage; + bool high; + float val; + float a, d, r; +} graph_env_t; + +typedef struct { + float phase; +} graph_lfo_t; + +typedef struct { + uint32_t rng; +} graph_noise_t; + +typedef struct { + bool primed; + float gain_now[AUDIODSP_GRAPH_MAX_INPUTS]; +} graph_mix_t; + +size_t audiodsp_graph_node_data_size(audiodsp_graph_kind_t kind, + uint32_t sample_rate, float arg) { + switch (kind) { + case AUDIODSP_GRAPH_OSC: + return sizeof(graph_osc_t); + case AUDIODSP_GRAPH_LADDER: + return sizeof(graph_ladder_t); + case AUDIODSP_GRAPH_BIQUAD: + return sizeof(graph_biquad_t); + case AUDIODSP_GRAPH_FBDELAY: { + uint32_t line_frames = (uint32_t)((float)sample_rate * arg / 1000.0f); + if (line_frames < 2u) { + line_frames = 2u; + } + return sizeof(graph_fbdelay_t) + (size_t)line_frames * 2u * sizeof(int16_t); + } + case AUDIODSP_GRAPH_ENV: + return sizeof(graph_env_t); + case AUDIODSP_GRAPH_LFO: + return sizeof(graph_lfo_t); + case AUDIODSP_GRAPH_NOISE: + return sizeof(graph_noise_t); + case AUDIODSP_GRAPH_MIX: + return sizeof(graph_mix_t); + default: + return 0; + } +} + +void audiodsp_graph_init(audiodsp_graph_t *graph, uint32_t sample_rate, + uint32_t frames, uint32_t channel_count, int16_t *bufs, uint32_t nbufs, + audiodsp_graph_node_t *nodes, uint32_t max_nodes) { + graph->sample_rate = sample_rate; + graph->frames = frames; + graph->channel_count = channel_count; + graph->bufs = bufs; + graph->nbufs = nbufs; + graph->nodes = nodes; + graph->max_nodes = max_nodes; + graph->nnodes = 0; + graph->sounding = 0; + memset(bufs, 0, (size_t)nbufs * frames * sizeof(int16_t)); +} + +static float env_coeff(float seconds, float scale, uint32_t sample_rate) { + float steps = seconds * (float)sample_rate * scale; + if (steps < 1.0f) { + steps = 1.0f; + } + return expf(-1.0f / steps); +} + +int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, + uint32_t group, const int16_t *inputs, uint32_t ninputs, int16_t output, + void *data, float arg) { + if (graph->nnodes >= graph->max_nodes || kind >= AUDIODSP_GRAPH_KINDS || + ninputs > AUDIODSP_GRAPH_MAX_INPUTS) { + return -1; + } + if (output != AUDIODSP_GRAPH_NONE && (output < 0 || (uint32_t)output >= graph->nbufs)) { + return -1; + } + for (uint32_t i = 0; i < ninputs; ++i) { + if (inputs[i] != AUDIODSP_GRAPH_NONE && + (inputs[i] < 0 || (uint32_t)inputs[i] >= graph->nbufs)) { + return -1; + } + } + audiodsp_graph_node_t *node = &graph->nodes[graph->nnodes]; + memset(node, 0, sizeof(*node)); + node->kind = (uint8_t)kind; + node->group = (uint8_t)group; + node->ninputs = (uint8_t)ninputs; + for (uint32_t i = 0; i < AUDIODSP_GRAPH_MAX_INPUTS; ++i) { + node->inputs[i] = i < ninputs ? inputs[i] : AUDIODSP_GRAPH_NONE; + } + node->output = output; + node->noutputs = output == AUDIODSP_GRAPH_NONE ? 0 : 1; + node->data = data; + node->dirty = true; + const uint32_t rate = graph->sample_rate; + switch (kind) { + case AUDIODSP_GRAPH_OSC: { + graph_osc_t *d = data; + audiodsp_oscillator_config_init(&d->config, rate); + audiodsp_oscillator_set_channel_count(&d->config, 1); + audiodsp_oscillator_config_finish(&d->config); + audiodsp_oscillator_state_init(&d->state); + node->param[AUDIODSP_OSCILLATOR_OPT_FREQUENCY] = d->config.frequency; + node->param[AUDIODSP_OSCILLATOR_OPT_WAVEFORM] = d->config.waveform[0]; + node->param[AUDIODSP_OSCILLATOR_OPT_PULSE_WIDTH] = d->config.pulse_width; + node->param[AUDIODSP_OSCILLATOR_OPT_LEVEL] = d->config.level; + node->param[AUDIODSP_OSCILLATOR_OPT_PITCH_OCTAVES] = d->config.pitch_octaves; + node->param[AUDIODSP_OSCILLATOR_OPT_FM_DEPTH] = d->config.fm_depth; + node->param[AUDIODSP_OSCILLATOR_OPT_PWM_DEPTH] = d->config.pwm_depth; + break; + } + case AUDIODSP_GRAPH_LADDER: { + graph_ladder_t *d = data; + audiodsp_ladder_config_init(&d->config, rate); + audiodsp_ladder_set_channel_count(&d->config, 1); + audiodsp_ladder_config_finish(&d->config); + audiodsp_ladder_state_init(&d->state); + node->param[AUDIODSP_LADDER_OPT_CUTOFF_HZ] = d->config.cutoff_hz; + node->param[AUDIODSP_LADDER_OPT_RESONANCE] = d->config.resonance; + node->param[AUDIODSP_LADDER_OPT_DRIVE] = d->config.drive; + node->param[AUDIODSP_LADDER_OPT_POLES] = d->config.poles; + node->param[AUDIODSP_LADDER_OPT_PASSBAND_COMP] = d->config.passband_comp; + node->param[AUDIODSP_LADDER_OPT_OVERSAMPLE] = d->config.oversample; + node->param[AUDIODSP_LADDER_OPT_MIX] = d->config.mix; + node->param[AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES] = d->config.cutoff_octaves; + break; + } + case AUDIODSP_GRAPH_BIQUAD: { + graph_biquad_t *d = data; + audiodsp_biquad_f32_config_init(&d->config, rate, 1); + audiodsp_biquad_f32_state_init(&d->state); + node->param[AUDIODSP_BIQUAD_F32_OPT_MODE] = (float)d->config.mode; + node->param[AUDIODSP_BIQUAD_F32_OPT_FREQUENCY] = d->config.frequency; + node->param[AUDIODSP_BIQUAD_F32_OPT_Q] = d->config.q; + node->param[AUDIODSP_BIQUAD_F32_OPT_GAIN_DB] = d->config.gain_db; + node->param[AUDIODSP_BIQUAD_F32_OPT_MIX] = d->config.mix; + break; + } + case AUDIODSP_GRAPH_FBDELAY: { + graph_fbdelay_t *d = data; + uint32_t line_frames = (uint32_t)((float)rate * arg / 1000.0f); + if (line_frames < 2u) { + line_frames = 2u; + } + d->line_frames = line_frames; + audiodsp_feedback_delay_config_init(&d->config, rate, line_frames); + audiodsp_feedback_delay_set_channel_count(&d->config, 1); + audiodsp_feedback_delay_configure(&d->config, + AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS, arg * 0.5f); + audiodsp_feedback_delay_config_finish(&d->config); + audiodsp_feedback_delay_state_init(&d->state, d->line); + // What the config holds now, so get() answers before any set(). + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS] = arg * 0.5f; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_FEEDBACK] = d->config.feedback; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_MIX] = d->config.mix; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_DAMPING_HZ] = d->config.damping_hz; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_CUT_HZ] = d->config.cut_hz; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_WOW_HZ] = d->config.wow_hz; + break; + } + case AUDIODSP_GRAPH_ENV: { + graph_env_t *d = data; + d->stage = ENV_IDLE; + d->val = 0.0f; + node->param[AUDIODSP_GRAPH_ENV_ATTACK] = 0.01f; + node->param[AUDIODSP_GRAPH_ENV_DECAY] = 0.2f; + node->param[AUDIODSP_GRAPH_ENV_SUSTAIN] = 0.7f; + node->param[AUDIODSP_GRAPH_ENV_RELEASE] = 0.3f; + break; + } + case AUDIODSP_GRAPH_LFO: + node->param[AUDIODSP_GRAPH_LFO_RATE] = 1.0f; + node->param[AUDIODSP_GRAPH_LFO_AMPLITUDE] = 0.5f * AUDIODSP_GRAPH_UNIT; + break; + case AUDIODSP_GRAPH_NOISE: { + graph_noise_t *d = data; + d->rng = 0x5EED1234u ^ (graph->nnodes * 0x9E3779B9u); + node->param[0] = 0.5f * AUDIODSP_GRAPH_UNIT; + break; + } + case AUDIODSP_GRAPH_MIX: + for (uint32_t i = 0; i < AUDIODSP_GRAPH_MAX_INPUTS; ++i) { + node->param[i] = 1.0f; + } + break; + case AUDIODSP_GRAPH_VCA: + node->param[AUDIODSP_GRAPH_VCA_GAIN] = 1.0f; + break; + case AUDIODSP_GRAPH_OUT: + node->param[0] = 1.0f; + break; + default: + break; + } + return (int)graph->nnodes++; +} + +bool audiodsp_graph_set(audiodsp_graph_t *graph, uint32_t node, + uint32_t index, float value) { + if (node >= graph->nnodes || index >= AUDIODSP_GRAPH_MAX_PARAMS) { + return false; + } + audiodsp_graph_node_t *n = &graph->nodes[node]; + n->param[index] = value; + n->dirty = true; + return true; +} + +bool audiodsp_graph_get(const audiodsp_graph_t *graph, uint32_t node, + uint32_t index, float *value) { + if (node >= graph->nnodes || index >= AUDIODSP_GRAPH_MAX_PARAMS) { + return false; + } + *value = graph->nodes[node].param[index]; + return true; +} + +bool audiodsp_graph_set_table(audiodsp_graph_t *graph, uint32_t node, + const int16_t *table, uint32_t length) { + if (node >= graph->nnodes || graph->nodes[node].kind != AUDIODSP_GRAPH_FBDELAY) { + return false; + } + graph_fbdelay_t *d = graph->nodes[node].data; + return audiodsp_feedback_delay_set_wow_shape(&d->config, table, length); +} + +bool audiodsp_graph_reset(audiodsp_graph_t *graph, uint32_t node) { + if (node >= graph->nnodes) { + return false; + } + audiodsp_graph_node_t *n = &graph->nodes[node]; + switch (n->kind) { + case AUDIODSP_GRAPH_OSC: + audiodsp_oscillator_reset(&((graph_osc_t *)n->data)->state); + break; + case AUDIODSP_GRAPH_LADDER: + audiodsp_ladder_reset(&((graph_ladder_t *)n->data)->state); + break; + case AUDIODSP_GRAPH_BIQUAD: + audiodsp_biquad_f32_reset(&((graph_biquad_t *)n->data)->state); + break; + case AUDIODSP_GRAPH_FBDELAY: { + graph_fbdelay_t *d = n->data; + audiodsp_feedback_delay_reset(&d->state, &d->config); + break; + } + case AUDIODSP_GRAPH_ENV: { + graph_env_t *d = n->data; + d->stage = ENV_IDLE; + d->val = 0.0f; + d->high = false; + break; + } + case AUDIODSP_GRAPH_LFO: + ((graph_lfo_t *)n->data)->phase = 0.0f; + break; + case AUDIODSP_GRAPH_MIX: + ((graph_mix_t *)n->data)->primed = false; + break; + default: + break; + } + return true; +} + +// ------------------------------------------------------------- the run -- + +static void derive(audiodsp_graph_t *graph, audiodsp_graph_node_t *n) { + n->dirty = false; + switch (n->kind) { + case AUDIODSP_GRAPH_OSC: { + graph_osc_t *d = n->data; + for (uint32_t i = 0; i <= AUDIODSP_OSCILLATOR_OPT_PWM_DEPTH; ++i) { + if (i == AUDIODSP_OSCILLATOR_OPT_WAVEFORM_RIGHT) { + continue; + } + audiodsp_oscillator_configure(&d->config, + (audiodsp_oscillator_option_t)i, n->param[i]); + } + audiodsp_oscillator_config_finish(&d->config); + break; + } + case AUDIODSP_GRAPH_LADDER: { + graph_ladder_t *d = n->data; + for (uint32_t i = 0; i <= AUDIODSP_LADDER_OPT_CUTOFF_OCTAVES; ++i) { + audiodsp_ladder_configure(&d->config, + (audiodsp_ladder_option_t)i, n->param[i]); + } + break; + } + case AUDIODSP_GRAPH_BIQUAD: { + graph_biquad_t *d = n->data; + for (uint32_t i = 0; i <= AUDIODSP_BIQUAD_F32_OPT_MIX; ++i) { + audiodsp_biquad_f32_configure(&d->config, + (audiodsp_biquad_f32_option_t)i, n->param[i]); + } + audiodsp_biquad_f32_config_finish(&d->config); + break; + } + case AUDIODSP_GRAPH_FBDELAY: { + graph_fbdelay_t *d = n->data; + // Only the options a chorus line moves; DELAY_FRAMES (14) would + // override DELAY_MS, so the first ten and WOW_DEPTH_RAMP_MS. + for (uint32_t i = 0; i <= AUDIODSP_FEEDBACK_DELAY_OPT_INPUT_PAN; ++i) { + audiodsp_feedback_delay_configure(&d->config, + (audiodsp_feedback_delay_option_t)i, n->param[i]); + } + audiodsp_feedback_delay_config_finish(&d->config); + break; + } + case AUDIODSP_GRAPH_ENV: { + graph_env_t *d = n->data; + d->a = env_coeff(n->param[AUDIODSP_GRAPH_ENV_ATTACK], 0.35f, graph->sample_rate); + d->d = env_coeff(n->param[AUDIODSP_GRAPH_ENV_DECAY], 0.25f, graph->sample_rate); + d->r = env_coeff(n->param[AUDIODSP_GRAPH_ENV_RELEASE], 0.25f, graph->sample_rate); + break; + } + default: + break; + } +} + +static inline int16_t sat16(float v) { + if (v > 32767.0f) { + return 32767; + } + if (v < -32768.0f) { + return -32768; + } + return (int16_t)lrintf(v); +} + +static void run_env(audiodsp_graph_node_t *n, const int16_t *gate, int16_t *out, + uint32_t frames) { + graph_env_t *d = n->data; + const float sus = n->param[AUDIODSP_GRAPH_ENV_SUSTAIN]; + uint8_t stage = d->stage; + bool high = d->high; + float val = d->val; + for (uint32_t i = 0; i < frames; ++i) { + if (gate != NULL) { + bool g = (float)gate[i] > ENV_GATE_THRESHOLD; + if (g != high) { + high = g; + if (g) { + stage = ENV_ATTACK; + } else if (stage != ENV_IDLE) { + stage = ENV_RELEASE; + } + } + } + switch (stage) { + case ENV_ATTACK: + val = ENV_ATTACK_TARGET + (val - ENV_ATTACK_TARGET) * d->a; + if (val >= 1.0f) { + val = 1.0f; + stage = ENV_DECAY; + } + break; + case ENV_DECAY: + val = sus + (val - sus) * d->d; + if (fabsf(val - sus) <= ENV_DECAY_TOLERANCE) { + val = sus; + stage = ENV_SUSTAIN; + } + break; + case ENV_SUSTAIN: + val = sus; + break; + case ENV_RELEASE: + val *= d->r; + if (val <= ENV_RELEASE_TOLERANCE) { + val = 0.0f; + stage = ENV_IDLE; + } + break; + default: + val = 0.0f; + break; + } + out[i] = sat16(val * AUDIODSP_GRAPH_UNIT); + } + d->stage = stage; + d->high = high; + d->val = val; +} + +static void run_lfo(audiodsp_graph_node_t *n, int16_t *out, uint32_t frames, + uint32_t sample_rate) { + graph_lfo_t *d = n->data; + const float dt = n->param[AUDIODSP_GRAPH_LFO_RATE] / (float)sample_rate; + const float amp = n->param[AUDIODSP_GRAPH_LFO_AMPLITUDE]; + float phase = d->phase; + for (uint32_t i = 0; i < frames; ++i) { + phase += dt; + phase -= floorf(phase); + // 1 - |4p - 2| + float tri = 4.0f * phase - 2.0f; + tri = 1.0f - (tri < 0.0f ? -tri : tri); + out[i] = sat16(tri * amp); + } + d->phase = phase; +} + +static void run_noise(audiodsp_graph_node_t *n, int16_t *out, uint32_t frames) { + graph_noise_t *d = n->data; + const float amp = n->param[0]; + uint32_t x = d->rng; + for (uint32_t i = 0; i < frames; ++i) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + // uniform in -1..1 + float u = (float)(int32_t)x * (1.0f / 2147483648.0f); + out[i] = sat16(u * amp); + } + d->rng = x; +} + +static void run_mix(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, + int16_t *out, uint32_t frames, float *acc) { + graph_mix_t *d = n->data; + const float offset = n->param[AUDIODSP_GRAPH_MIX_OFFSET]; + if (!d->primed) { + for (uint32_t k = 0; k < AUDIODSP_GRAPH_MAX_INPUTS; ++k) { + d->gain_now[k] = n->param[k]; + } + d->primed = true; + } + for (uint32_t i = 0; i < frames; ++i) { + acc[i] = offset; + } + const float inv = 1.0f / (float)frames; + for (uint32_t k = 0; k < n->ninputs; ++k) { + if (n->inputs[k] == AUDIODSP_GRAPH_NONE) { + continue; + } + const int16_t *in = graph->bufs + (size_t)n->inputs[k] * graph->frames; + const float g1 = n->param[k]; + const float g0 = d->gain_now[k]; + d->gain_now[k] = g1; + if (g0 == g1) { + if (g1 == 0.0f) { + continue; + } + for (uint32_t i = 0; i < frames; ++i) { + acc[i] += g1 * (float)in[i]; + } + } else { + const float step = (g1 - g0) * inv; + float g = g0; + for (uint32_t i = 0; i < frames; ++i) { + g += step; + acc[i] += g * (float)in[i]; + } + } + } + for (uint32_t i = 0; i < frames; ++i) { + out[i] = sat16(acc[i]); + } +} + +static void run_vca(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, + int16_t *out, uint32_t frames) { + const int16_t *audio = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * graph->frames; + const uint32_t which = n->param[AUDIODSP_GRAPH_VCA_SELECT] < 0.5f ? 1u : 2u; + const int16_t *cv = n->inputs[which] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[which] * graph->frames; + const float k = n->param[AUDIODSP_GRAPH_VCA_GAIN] / AUDIODSP_GRAPH_UNIT; + if (audio == NULL || cv == NULL) { + memset(out, 0, frames * sizeof(int16_t)); + return; + } + for (uint32_t i = 0; i < frames; ++i) { + out[i] = sat16((float)audio[i] * (float)cv[i] * k); + } +} + +static void run_out(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, + int16_t *out, uint32_t frames) { + if (out == NULL) { + return; + } + const int16_t *left = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * graph->frames; + const int16_t *right = n->ninputs > 1 && n->inputs[1] != AUDIODSP_GRAPH_NONE + ? graph->bufs + (size_t)n->inputs[1] * graph->frames : left; + const float scale = n->param[0]; + if (left == NULL) { + memset(out, 0, (size_t)frames * graph->channel_count * sizeof(int16_t)); + return; + } + if (graph->channel_count == 2) { + for (uint32_t i = 0; i < frames; ++i) { + out[2 * i] = sat16((float)left[i] * scale); + out[2 * i + 1] = sat16((float)right[i] * scale); + } + } else { + for (uint32_t i = 0; i < frames; ++i) { + out[i] = sat16(((float)left[i] + (float)right[i]) * 0.5f * scale); + } + } +} + +uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, + uint32_t enable) { + const uint32_t frames = graph->frames; + float acc[AUDIODSP_GRAPH_MAX_FRAMES]; + uint32_t sounding = 0; + for (uint32_t index = 0; index < graph->nnodes; ++index) { + audiodsp_graph_node_t *n = &graph->nodes[index]; + int16_t *dst = n->output == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->output * frames; + if (n->group != AUDIODSP_GRAPH_ALWAYS && !((enable >> n->group) & 1u)) { + if (n->ran) { + n->ran = false; + if (dst != NULL) { + memset(dst, 0, frames * sizeof(int16_t)); + } + } + continue; + } + n->ran = true; + if (n->dirty) { + derive(graph, n); + } + const int16_t *in0 = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * frames; + switch (n->kind) { + case AUDIODSP_GRAPH_CONST: { + const int16_t v = sat16(n->param[0]); + for (uint32_t i = 0; i < frames; ++i) { + dst[i] = v; + } + break; + } + case AUDIODSP_GRAPH_OSC: { + graph_osc_t *d = n->data; + const int16_t *live[AUDIODSP_OSCILLATOR_INPUTS]; + uint32_t strides[AUDIODSP_OSCILLATOR_INPUTS]; + for (uint32_t k = 0; k < AUDIODSP_OSCILLATOR_INPUTS; ++k) { + live[k] = (k < n->ninputs && n->inputs[k] != AUDIODSP_GRAPH_NONE) + ? graph->bufs + (size_t)n->inputs[k] * frames : NULL; + strides[k] = 1; + } + audiodsp_oscillator_process_s16(&d->config, &d->state, dst, + frames, live, strides); + break; + } + case AUDIODSP_GRAPH_LADDER: { + graph_ladder_t *d = n->data; + const int16_t *cutoff = (n->ninputs > 1 && n->inputs[1] != AUDIODSP_GRAPH_NONE) + ? graph->bufs + (size_t)n->inputs[1] * frames : NULL; + if (in0 == NULL) { + memset(dst, 0, frames * sizeof(int16_t)); + in0 = dst; + } + audiodsp_ladder_process_cutoff_s16(&d->config, &d->state, dst, + in0, frames, cutoff, 1); + break; + } + case AUDIODSP_GRAPH_BIQUAD: { + graph_biquad_t *d = n->data; + if (in0 == NULL) { + memset(dst, 0, frames * sizeof(int16_t)); + in0 = dst; + } + audiodsp_biquad_f32_process_s16(&d->config, &d->state, dst, + in0, frames); + break; + } + case AUDIODSP_GRAPH_FBDELAY: { + graph_fbdelay_t *d = n->data; + if (in0 == NULL) { + memset(dst, 0, frames * sizeof(int16_t)); + in0 = dst; + } + audiodsp_feedback_delay_process_s16(&d->config, &d->state, dst, + in0, frames); + break; + } + case AUDIODSP_GRAPH_ENV: { + run_env(n, in0, dst, frames); + if (((graph_env_t *)n->data)->stage != ENV_IDLE && + n->group != AUDIODSP_GRAPH_ALWAYS) { + sounding |= 1u << n->group; + } + break; + } + case AUDIODSP_GRAPH_LFO: + run_lfo(n, dst, frames, graph->sample_rate); + break; + case AUDIODSP_GRAPH_NOISE: + run_noise(n, dst, frames); + break; + case AUDIODSP_GRAPH_MIX: + run_mix(graph, n, dst, frames, acc); + break; + case AUDIODSP_GRAPH_VCA: + run_vca(graph, n, dst, frames); + break; + case AUDIODSP_GRAPH_OUT: + run_out(graph, n, out, frames); + break; + default: + break; + } + } + graph->sounding = sounding; + return sounding; +} + +int32_t audiodsp_graph_peak(const audiodsp_graph_t *graph, uint32_t buffer) { + if (buffer >= graph->nbufs) { + return -1; + } + const int16_t *b = graph->bufs + (size_t)buffer * graph->frames; + int32_t peak = 0; + for (uint32_t i = 0; i < graph->frames; ++i) { + int32_t v = b[i] < 0 ? -(int32_t)b[i] : b[i]; + if (v > peak) { + peak = v; + } + } + return peak; +} diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h new file mode 100644 index 0000000..fe76588 --- /dev/null +++ b/src/shared/audiodsp_graph.h @@ -0,0 +1,188 @@ +// Runtime-neutral graph runner: one C call renders a whole patch per block. +// +// New code -- not a CircuitPython port. A spike (the graph-runner spike, +// 2026-10-21): the question is whether a polyphonic patch built from the +// kernels in this directory can run in real time on an ESP32-P4 once the +// per-node Python between them is gone. +// +// 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 +// writes, and holds the config and state of one kernel. The kernels are the +// ones the audio nodes already call -- the oscillator, the ladder, the float +// biquad, the feedback delay -- plus a few small ones a voice needs that only +// existed in Python: an ADSR, a triangle LFO, white noise, a mixer with +// ramped gains, a VCA and the output stage. +// +// Buffers carry signed 16-bit samples on the same wire the bridges use: +// 16384 counts is 1.0 (vrack's +10 V), so audio at +-0.5 is a quarter of +// full scale. Every node is mono. +// +// Nodes belong to groups (a voice each), and a run takes a mask of the +// groups to process. A node whose group is masked out is skipped, and its +// 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. +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "shared/audiodsp_filter_f32.h" +#include "shared/audiodsp_feedback_delay.h" +#include "shared/audiodsp_ladder.h" +#include "shared/audiodsp_oscillator.h" + +//: The longest block a run renders. The kernels' own block ceiling. +#define AUDIODSP_GRAPH_MAX_FRAMES 256u + +//: Buffer id for "nothing connected". +#define AUDIODSP_GRAPH_NONE (-1) + +//: The group that always runs. +#define AUDIODSP_GRAPH_ALWAYS 255u + +//: Counts per 1.0 on the wire (vrack's BRIDGE_SCALE). +#define AUDIODSP_GRAPH_UNIT 16384.0f + +//: Inputs a node may read, and float parameters it may hold. The feedback +//: delay has the most options (16); the mixer has 8 gains and an offset. +#define AUDIODSP_GRAPH_MAX_INPUTS 8u +#define AUDIODSP_GRAPH_MAX_PARAMS 16u + +typedef enum { + // out = param[0], every sample. A scalar CV (pitch, gate, velocity). + AUDIODSP_GRAPH_CONST = 0, + // inputs: pitch, fm, pwm, sync. params: audiodsp_oscillator_option_t. + AUDIODSP_GRAPH_OSC, + // inputs: audio, cutoff. params: audiodsp_ladder_option_t. + AUDIODSP_GRAPH_LADDER, + // inputs: audio. params: audiodsp_biquad_f32_option_t. + AUDIODSP_GRAPH_BIQUAD, + // inputs: audio. params: audiodsp_feedback_delay_option_t. The node + // is built with `arg` = max_delay_ms. + AUDIODSP_GRAPH_FBDELAY, + // inputs: gate. params: attack s, decay s, sustain 0..1, release s. + // out: 0..1 on the wire. vrack's EnvelopeCore, sample by sample. + AUDIODSP_GRAPH_ENV, + // params: rate Hz, amplitude (counts). out: a triangle, +-amplitude. + AUDIODSP_GRAPH_LFO, + // params: amplitude (counts). out: uniform white noise, +-amplitude. + AUDIODSP_GRAPH_NOISE, + // inputs: up to 8. params: gain[8], offset (counts). out = offset + + // sum(gain[i] * in[i]). A gain that changed since the last block ramps + // across this one, so a voice fades rather than steps. + AUDIODSP_GRAPH_MIX, + // inputs: audio, cv_a, cv_b. params: gain, select. out = audio * + // (select < 0.5 ? cv_a : cv_b) / UNIT * gain. + AUDIODSP_GRAPH_VCA, + // inputs: left, right. params: scale. Writes the run's output buffer: + // interleaved stereo, or the mean of the two when the run is mono. + AUDIODSP_GRAPH_OUT, + AUDIODSP_GRAPH_KINDS, +} audiodsp_graph_kind_t; + +enum { + AUDIODSP_GRAPH_ENV_ATTACK = 0, + AUDIODSP_GRAPH_ENV_DECAY, + AUDIODSP_GRAPH_ENV_SUSTAIN, + AUDIODSP_GRAPH_ENV_RELEASE, +}; + +enum { + AUDIODSP_GRAPH_LFO_RATE = 0, + AUDIODSP_GRAPH_LFO_AMPLITUDE, +}; + +enum { + AUDIODSP_GRAPH_MIX_OFFSET = 8, +}; + +enum { + AUDIODSP_GRAPH_VCA_GAIN = 0, + AUDIODSP_GRAPH_VCA_SELECT, +}; + +typedef struct { + uint8_t kind; + uint8_t group; + uint8_t ninputs; + uint8_t noutputs; + // Buffer ids; AUDIODSP_GRAPH_NONE for an input left open. + int16_t inputs[AUDIODSP_GRAPH_MAX_INPUTS]; + int16_t output; + float param[AUDIODSP_GRAPH_MAX_PARAMS]; + // A parameter moved: derive the kernel's config before the next run. + bool dirty; + // Ran last block. A node skipped after running zeroes its output once. + bool ran; + // The kernel's config and state, sized by audiodsp_graph_node_data_size. + void *data; +} audiodsp_graph_node_t; + +typedef struct { + uint32_t sample_rate; + uint32_t frames; + uint32_t channel_count; + uint32_t nbufs; + uint32_t nnodes; + uint32_t max_nodes; + // nbufs * frames samples, one block per buffer id. + int16_t *bufs; + audiodsp_graph_node_t *nodes; + // Written by run(): groups with an envelope still sounding. + uint32_t sounding; +} audiodsp_graph_t; + +// Bytes a node of `kind` needs for its config and state. `arg` is the +// kind's build-time argument (the feedback delay's line length in ms). +size_t audiodsp_graph_node_data_size(audiodsp_graph_kind_t kind, + uint32_t sample_rate, float arg); + +// `bufs` holds nbufs * frames samples and `nodes` holds max_nodes entries; +// both are the caller's. +void audiodsp_graph_init(audiodsp_graph_t *graph, uint32_t sample_rate, + uint32_t frames, uint32_t channel_count, int16_t *bufs, uint32_t nbufs, + audiodsp_graph_node_t *nodes, uint32_t max_nodes); + +// Appends a node. `data` is the caller's, of audiodsp_graph_node_data_size +// bytes, zeroed. Returns the node's index, or -1 when the graph is full or +// a buffer id is out of range. +int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, + uint32_t group, const int16_t *inputs, uint32_t ninputs, int16_t output, + void *data, float arg); + +// Stores one parameter. False for a bad node or index. +bool audiodsp_graph_set(audiodsp_graph_t *graph, uint32_t node, + uint32_t index, float value); + +// Reads one back. +bool audiodsp_graph_get(const audiodsp_graph_t *graph, uint32_t node, + uint32_t index, float *value); + +// The feedback delay's wow table; borrowed, as the kernel borrows it. +bool audiodsp_graph_set_table(audiodsp_graph_t *graph, uint32_t node, + const int16_t *table, uint32_t length); + +// Clears a node's running state (the kernel's reset). +bool audiodsp_graph_reset(audiodsp_graph_t *graph, uint32_t node); + +// Renders one block. `out` takes frames * channel_count samples from the +// OUT node (and is untouched when there is none). Nodes whose group bit is +// clear in `enable` are skipped; AUDIODSP_GRAPH_ALWAYS nodes always run. +// Returns the mask of groups whose envelopes are still sounding. +uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, + uint32_t enable); + +// 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); From 29b67f3cbbe938df818088196e10d622110a82e3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:48:46 -0500 Subject: [PATCH 2/2] audiograph: a C graph runner over the existing kernels (MicroPython only) One audiograph.Graph renders a whole patch per block: nodes over the oscillator, ladder, float biquad and feedback delay kernels, plus an ADSR, a triangle LFO, white noise, a mixer with ramped gains, a VCA and the output stage, wired through the graph's own int16 block buffers, each node in a group a run can mask out. Nothing in Python runs between the nodes, which is what lets a six-voice polysynth built from these kernels play in real time on an ESP32-P4 (the vrack Poly106: four voices held at factor 1.3 where the rack held none). tests/parity/graph_probe.py holds the runner to its wiring, its nodes, its groups, the mixer ramp and the output node, and each of its planted faults (a mixer added before the node it reads, a filter left out of the chain, a cutoff sent to a node no output reads) fails; clean-build.yml runs the faults first. docs/audiograph.md is the user's page. The CPython and CircuitPython bindings are on the roadmap. --- .github/workflows/clean-build.yml | 18 +- README.md | 14 +- ROADMAP.md | 3 + docs/audiograph.md | 117 +++++++++++ micropython.mk | 4 +- src/audiograph/Graph.c | 2 +- src/audiograph/Graph.h | 4 +- src/shared/audiodsp_graph.c | 5 + src/shared/audiodsp_graph.h | 13 +- tests/parity/graph_probe.py | 333 ++++++++++++++++++++++++++++++ 10 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 docs/audiograph.md create mode 100644 tests/parity/graph_probe.py diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 66e1b85..0f29a37 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -99,7 +99,7 @@ jobs: import audiocore, synthio, audiomixer, audiofilters, audiodelays import audiomp3, audiodynamics, audioroute, audiomath, audioecho import audiofreeverb, audiospeed, audioconvolve, audioshaper - import audiovorbis + import audiovorbis, audiograph s = synthio.Synthesizer(sample_rate=8000); s.press(69) r, b = audiocore.get_buffer(s) assert r == 1 and len(b) == 512 @@ -219,6 +219,22 @@ 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. + - name: The graph runner, and proof its probe can fail + run: | + MP=./micropython/ports/unix/build-standard/micropython + PROBE=audiodsp/tests/parity/graph_probe.py + for planted in wiring dropped ignored; do + if "$MP" "$PROBE" --fault $planted > /dev/null; then + echo "the planted fault passed, so this probe proves nothing: $planted" >&2 + exit 1 + fi + done + "$MP" "$PROBE" | tee /tmp/graph.txt + grep -q "GRAPH PASS" /tmp/graph.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/README.md b/README.md index 6a92329..ab8b65d 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ This gets you `audiocore`, `synthio`, `audiomixer`, `audiofilters`, `audiomath`, `audioecho`, `audioshaper`, `audioladder`, `audiooscillator`, `audioconvolve`, `audiobiquad`, `audioverb`, `audiomodal`, `audiosampler`, `audiometer`, `audiopump`, `audiovorbis`, and the `audiorender` package. -`audiomp3` remains firmware-only. The distribution declares no *required* +`audiomp3` and `audiograph` remain 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. @@ -444,6 +444,18 @@ not to be. See [docs/upstream-diff.md](docs/upstream-diff.md) for why this is a node rather than a chain of `audiobiquad.Biquad`, measured rather than argued. +### `audiograph.Graph` + +A whole patch rendered by one C call per block. You build a graph of nodes +over the kernels above (the oscillator, the ladder, the biquad, the feedback +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: +[docs/audiograph.md](docs/audiograph.md) has the nodes, the wire and the +limits. + ### `audiosampler.Sampler` Recorded notes, played at any pitch. `audiomixer.Mixer` plays sources at diff --git a/ROADMAP.md b/ROADMAP.md index b21ad47..66a5091 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,9 @@ 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/docs/audiograph.md b/docs/audiograph.md new file mode 100644 index 0000000..c9901a2 --- /dev/null +++ b/docs/audiograph.md @@ -0,0 +1,117 @@ +# The graph runner: `audiograph` + +`audiograph.Graph` renders a whole patch in one C call per block. You build +the graph once, from nodes that call the kernels audiodsp already has (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 call +`run()` once a block. Nothing in Python runs between the nodes, which is what +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). + +```python +import audiograph as ag + +g = ag.Graph(sample_rate=48000, frames=128, channel_count=2) +pitch, gate, env, saw, cv, vcf, out = (g.buffer() for _ in range(7)) + +p = g.add(ag.CONST, 0, (), pitch) # the key, in octaves from C4 +k = g.add(ag.CONST, 0, (), gate) +e = g.add(ag.ENV, 0, (gate,), env) +o = g.add(ag.OSC, 0, (pitch, None, None, None), saw) +g.set(o, ag.OSC_WAVEFORM, 1) +f = g.add(ag.LADDER, 0, (saw, env), vcf) # the envelope sweeps the cutoff +g.set(f, ag.LADDER_CUTOFF_HZ, 600.0) +v = g.add(ag.VCA, 0, (vcf, 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 +while True: + sounding = g.run(pcm, 0) # one block into pcm + ring.write(pcm) # an audiopump.Ring, say +``` + +## What a graph is + +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. `buffer()` hands out a fresh block's id; `add(kind, group, +inputs, output, arg=0.0)` appends a node and returns its index. Nodes run +in the order you added them, so add a node after the nodes it reads. + +The buffers carry one wire: 16384 counts (`UNIT`) is 1.0, the +10 V of a +modular rack, so audio at ±0.5 is a quarter of full scale. Every node is +mono; a stereo patch has two buffers where it needs them. A block is 256 +frames at most. + +`set(node, index, value)` stores a parameter and marks the node to derive +its kernel's settings before the next run; `get(node, index)` reads one +back. A parameter that has not changed costs nothing per block, so a held +note is one `run()` and nothing else. `reset(node)` clears a node's running +state (the kernel's reset); `read(buffer, dst)` and `write(buffer, src)` +copy a block out of or into the graph, so Python can feed a buffer from +something the graph has no node for; `peak(buffer)` is a block's peak. + +## Groups + +Nodes belong to groups 0 to 31, or `ALWAYS`. `run(out, block, enable)` runs +every node whose group bit is set in `enable` and every `ALWAYS` node, and +returns the mask of groups whose envelopes are still sounding. Put each +voice of a polysynth in a group of its own: a voice nobody is playing is +masked out and costs nothing, and the block its amplitude envelope goes +idle, the returned mask tells you to park it. A node skipped after it ran +zeroes its output once, so a mixer that reads a parked voice hears silence +rather than its last block. + +## The nodes + +| kind | inputs | parameters | out | +|---|---|---|---| +| `CONST` | | 0: the value, in counts | that value every sample | +| `OSC` | pitch, fm, pwm, sync | `OSC_FREQUENCY`, `OSC_WAVEFORM` (1 saw, 2 pulse, 3 both), `OSC_PULSE_WIDTH`, `OSC_LEVEL`, `OSC_PITCH_OCTAVES`, `OSC_FM_DEPTH`, `OSC_PWM_DEPTH` | audiooscillator's band-limited waveform | +| `LADDER` | audio, cutoff | `LADDER_CUTOFF_HZ`, `LADDER_RESONANCE`, `LADDER_DRIVE`, `LADDER_POLES`, `LADDER_PASSBAND_COMP`, `LADDER_OVERSAMPLE`, `LADDER_MIX`, `LADDER_CUTOFF_OCTAVES` | audioladder's filter, the cutoff input read every sample | +| `BIQUAD` | audio | `BIQUAD_MODE`, `BIQUAD_FREQUENCY`, `BIQUAD_Q`, `BIQUAD_GAIN_DB`, `BIQUAD_MIX` | audiobiquad's float biquad | +| `FBDELAY` | audio | `DELAY_MS`, `DELAY_FEEDBACK`, `DELAY_MIX`, `DELAY_DAMPING_HZ`, `DELAY_WOW_HZ`, `DELAY_WOW_DEPTH_MS` and audioecho's other options by number; built with `arg` = the line's length in ms; `table(node, int16_array)` sets its wow shape | audioecho's FeedbackDelay | +| `ENV` | gate | `ENV_ATTACK`, `ENV_DECAY`, `ENV_RELEASE` in seconds, `ENV_SUSTAIN` 0..1 | an ADSR, 0..`UNIT`, each stage a one-pole approach, the attack aimed past 1.0 so it arrives with a slope | +| `LFO` | | `LFO_RATE` Hz, `LFO_AMPLITUDE` counts | a triangle | +| `NOISE` | | 0: amplitude in counts | white noise | +| `MIX` | up to eight | 0..7: a gain per input, `MIX_OFFSET` in counts | offset + the gains times the inputs; a gain that moved ramps across the block | +| `VCA` | audio, cv_a, cv_b | `VCA_GAIN`, `VCA_SELECT` (cv_a below 0.5, else cv_b) | audio times the chosen CV over `UNIT`, times the gain | +| `OUT` | left, right | 0: scale | writes the run's output buffer: interleaved stereo, or the mean when the graph is mono | + +The kernel nodes take their parameters under the same numbers as the +modules that wrap them (`audiooscillator`, `audioladder`, `audiobiquad`, +`audioecho`), re-exported on `audiograph` so a caller needs no other module. +An input left `None` is open: an oscillator without a pitch input plays +`OSC_FREQUENCY`, a ladder without a cutoff input sits at `LADDER_CUTOFF_HZ`. + +## Output + +`run(out, block, enable)` writes the `OUT` node's block into `out` at block +`block` (each block is `frames * channel_count` samples), so one buffer +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. + +## 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. + +`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 +nodes doing their jobs (the ladder closes a sawtooth, a `set()` after the +first run lands), to its groups (sounding is reported while an envelope +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. diff --git a/micropython.mk b/micropython.mk index 94931a5..0673fab 100644 --- a/micropython.mk +++ b/micropython.mk @@ -284,8 +284,8 @@ SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/audiosampler/Sampler.c \ $(MPAUDIO_SRC_DIR)/audiosampler/SamplerVoice.c \ $(MPAUDIO_SRC_DIR)/audiosampler/module.c -# audiograph: the graph runner (spike, 2026-10-21): one C call renders a -# whole patch per block from the kernels above. MicroPython only. +# audiograph: the graph runner. One C call renders a whole patch per block +# from the kernels above. MicroPython only (docs/audiograph.md). SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/shared/audiodsp_graph.c \ $(MPAUDIO_SRC_DIR)/audiograph/Graph.c \ diff --git a/src/audiograph/Graph.c b/src/audiograph/Graph.c index 843725f..96da85b 100644 --- a/src/audiograph/Graph.c +++ b/src/audiograph/Graph.c @@ -106,7 +106,7 @@ static mp_obj_t audiograph_graph_add(size_t n_args, const mp_obj_t *args) { int index = audiodsp_graph_add(&self->graph, (audiodsp_graph_kind_t)kind, (uint32_t)group, inputs, (uint32_t)len, output, data, arg); if (index < 0) { - mp_raise_ValueError(MP_ERROR_TEXT("graph is full or a buffer id is out of range")); + 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); } diff --git a/src/audiograph/Graph.h b/src/audiograph/Graph.h index 3d9bcb4..82dfb8b 100644 --- a/src/audiograph/Graph.h +++ b/src/audiograph/Graph.h @@ -1,7 +1,7 @@ // audiograph.Graph -- MicroPython binding over shared/audiodsp_graph.c. // -// A spike's module (graph-runner, 2026-10-21): the runner that renders a -// whole patch in one call. MicroPython only for now. +// The runner that renders a whole patch in one call. MicroPython only for +// now; the CPython and CircuitPython bindings are on the roadmap. // // SPDX-License-Identifier: MIT diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index 593f4ad..8933f65 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -122,6 +122,9 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, if (output != AUDIODSP_GRAPH_NONE && (output < 0 || (uint32_t)output >= graph->nbufs)) { return -1; } + if (output == AUDIODSP_GRAPH_NONE && kind != AUDIODSP_GRAPH_OUT) { + return -1; + } for (uint32_t i = 0; i < ninputs; ++i) { if (inputs[i] != AUDIODSP_GRAPH_NONE && (inputs[i] < 0 || (uint32_t)inputs[i] >= graph->nbufs)) { @@ -204,6 +207,8 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, node->param[AUDIODSP_FEEDBACK_DELAY_OPT_DAMPING_HZ] = d->config.damping_hz; node->param[AUDIODSP_FEEDBACK_DELAY_OPT_CUT_HZ] = d->config.cut_hz; node->param[AUDIODSP_FEEDBACK_DELAY_OPT_WOW_HZ] = d->config.wow_hz; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_CROSS_FEED] = d->config.cross_feed; + node->param[AUDIODSP_FEEDBACK_DELAY_OPT_LOOP_DRIVE] = d->config.loop_drive; break; } case AUDIODSP_GRAPH_ENV: { diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index fe76588..0c3fea8 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -1,9 +1,9 @@ // Runtime-neutral graph runner: one C call renders a whole patch per block. // -// New code -- not a CircuitPython port. A spike (the graph-runner spike, -// 2026-10-21): the question is whether a polyphonic patch built from the -// kernels in this directory can run in real time on an ESP32-P4 once the -// per-node Python between them is gone. +// 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). // // 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 @@ -156,8 +156,9 @@ void audiodsp_graph_init(audiodsp_graph_t *graph, uint32_t sample_rate, audiodsp_graph_node_t *nodes, uint32_t max_nodes); // Appends a node. `data` is the caller's, of audiodsp_graph_node_data_size -// bytes, zeroed. Returns the node's index, or -1 when the graph is full or -// a buffer id is out of range. +// bytes, zeroed. Returns the node's index, or -1 when the graph is full, a +// buffer id is out of range, or a node other than OUT has no output buffer +// (every other kind writes one; OUT writes the caller's buffer instead). int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, uint32_t group, const int16_t *inputs, uint32_t ninputs, int16_t output, void *data, float arg); diff --git a/tests/parity/graph_probe.py b/tests/parity/graph_probe.py new file mode 100644 index 0000000..a03eefd --- /dev/null +++ b/tests/parity/graph_probe.py @@ -0,0 +1,333 @@ +"""audiograph: a graph renders what its nodes are wired to render, in one call. + + graph_probe.py # PASS, or FAIL and exit 1 + graph_probe.py --fault wiring # a node runs before its source: must FAIL + 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``. + +What it holds the runner to: + +- a value written into a buffer reaches the output in the same run, through + as many nodes as sit between them, so a graph in run order never lags; +- a node in the chain changes the sound (the ladder closes a sawtooth), and + a ``set()`` after the first run reaches the node it names and ``get()`` + reads it back; +- an envelope's group reports sounding while it sounds and not after, and a + group masked out of a run goes silent at the mixer that reads it; +- a mixer gain that moved ramps across the block rather than stepping; +- the output node writes the block it is asked for, stereo interleaved or + mono as the mean, and refuses a buffer too small for it; +- a graph refuses what it cannot run: too many frames, a bad buffer id, a + node with nothing to write, too many inputs, a full table. + +Every planted fault is a wiring or a parameter mistake a caller could make +and the runner must not hide: ``wiring`` adds a mixer before the node it +reads, ``dropped`` wires the VCA to the oscillator instead of the filter, and +``ignored`` sends the cutoff to a filter no output reads. Each has to fail. +""" + +import sys +from array import array + +try: + import audiograph as ag +except ImportError: + print("GRAPH 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 ("wiring", "dropped", "ignored"): + print("unknown fault %r" % FAULT) + sys.exit(2) + +RATE = 48000 +FRAMES = 128 +UNIT = ag.UNIT +failures = [] + + +def check(ok, what): + if not ok: + failures.append(what) + + +def digest(data): + value = 2166136261 + for byte in data: + value = ((value ^ byte) * 16777619) & 0xffffffff + return value + + +def ints(n): + return array("h", [0] * n) + + +def peak(x): + top = 0 + for v in x: + if v < 0: + v = -v + if v > top: + top = v + return top + + +def brightness(x): + """RMS of the second difference over the RMS level: a sawtooth's resets make it + large, and a low-pass that rounds them off makes it small. Zero for silence.""" + level = 0 + curve = 0 + for i in range(1, len(x) - 1): + a = x[i] + level += a * a + d = x[i + 1] - 2 * a + x[i - 1] + curve += d * d + return (curve / level) ** 0.5 if level else 0.0 + + +def new_graph(**kw): + kw.setdefault("sample_rate", RATE) + kw.setdefault("frames", FRAMES) + return ag.Graph(**kw) + + +def read(g, buffer): + dst = ints(FRAMES) + g.read(buffer, dst) + return dst + + +# -- 1. a value reaches the output in the run it was set -------------------------- + +def check_wiring(): + g = new_graph(channel_count=1, buffers=8, nodes=8) + src, a, b = g.buffer(), g.buffer(), g.buffer() + if FAULT == "wiring": + # the first mixer runs before the constant it reads: one block stale + g.add(ag.MIX, ag.ALWAYS, (src,), a) + const = g.add(ag.CONST, ag.ALWAYS, (), src) + else: + const = g.add(ag.CONST, ag.ALWAYS, (), src) + g.add(ag.MIX, ag.ALWAYS, (src,), a) + g.add(ag.MIX, ag.ALWAYS, (a,), b) + g.add(ag.OUT, ag.ALWAYS, (b,), None) + out = ints(FRAMES) + for k in range(1, 6): + g.set(const, 0, 100.0 * k) + g.run(out, 0) + check(out[0] == 100 * k and out[FRAMES - 1] == 100 * k, + "wiring: block %d carries %d, not %d" % (k, out[0], 100 * k)) + + +# -- 2. the filter in the chain closes the tone, and set() reaches it ----------- + +def voice(cutoff_hz): + """saw -> ladder -> VCA at full gain -> stereo out; (graph, ladder node, output).""" + g = new_graph(channel_count=2, buffers=8, nodes=8) + osc_b, vcf_b, cv_b, vca_b, spare_b = (g.buffer() for _ in range(5)) + osc = g.add(ag.OSC, ag.ALWAYS, (None, None, None, None), osc_b) + g.set(osc, ag.OSC_WAVEFORM, 1) + g.set(osc, ag.OSC_FREQUENCY, 220.0) + g.set(osc, ag.OSC_LEVEL, 0.5) + vcf = g.add(ag.LADDER, ag.ALWAYS, (osc_b, None), vcf_b) + g.set(vcf, ag.LADDER_CUTOFF_HZ, cutoff_hz) + cv = g.add(ag.CONST, ag.ALWAYS, (), cv_b) + g.set(cv, 0, UNIT) + audio = osc_b if FAULT == "dropped" else vcf_b + g.add(ag.VCA, ag.ALWAYS, (audio, cv_b, None), vca_b) + g.add(ag.OUT, ag.ALWAYS, (vca_b, vca_b), None) + spare = g.add(ag.LADDER, ag.ALWAYS, (None, None), spare_b) + if FAULT == "ignored": + vcf = spare # the cutoff goes to a filter nothing reads + return g, vcf + + +def render(g, blocks, skip=2): + """Blocks of the left channel after ``skip`` settling blocks, and the digest of all.""" + out = ints(FRAMES * 2 * blocks) + for k in range(blocks): + g.run(out, k) + left = array("h", [out[2 * i] for i in range(FRAMES * skip, FRAMES * blocks)]) + return left, digest(bytes(out)) + + +def check_filter(): + g_open, _ = voice(8000.0) + g_shut, _ = voice(150.0) + bright, d1 = render(g_open, 10) + dark, d2 = render(g_shut, 10) + print("graph digest open_voice", d1) + print("graph digest shut_voice", d2) + check(peak(bright) > 1000, "filter: the open voice is silent (peak %d)" % peak(bright)) + check(peak(dark) > 100, "filter: the shut voice is silent (peak %d)" % peak(dark)) + ratio = brightness(bright) / max(brightness(dark), 1e-9) + check(ratio > 10.0, "filter: closing the cutoff from 8 kHz to 150 Hz changed the brightness %.2fx" % ratio) + + g, vcf = voice(8000.0) + before, _ = render(g, 8) + g.set(vcf, ag.LADDER_CUTOFF_HZ, 150.0) + after, d3 = render(g, 8) + print("graph digest moved_voice", d3) + ratio = brightness(before) / max(brightness(after), 1e-9) + check(ratio > 10.0, "set: the cutoff moved to 150 Hz after 8 blocks changed the brightness %.2fx" % ratio) + _, chain = voice(8000.0) # the node in the chain, whatever the fault did + got = g.get(chain, ag.LADDER_CUTOFF_HZ) + check(abs(got - 150.0) < 1e-3, "get: the chain's cutoff reads %.1f after set(150)" % got) + + +# -- 3. envelopes, sounding groups, and a masked group's silence ---------------- + +def check_groups(): + g = new_graph(channel_count=1, buffers=8, nodes=8) + gate_b, env_b, k_b, mix_b = (g.buffer() for _ in range(4)) + gate = g.add(ag.CONST, 0, (), gate_b) + env = g.add(ag.ENV, 0, (gate_b,), env_b) + g.set(env, ag.ENV_ATTACK, 0.005) + g.set(env, ag.ENV_DECAY, 0.01) + g.set(env, ag.ENV_SUSTAIN, 0.5) + g.set(env, ag.ENV_RELEASE, 0.02) + k = g.add(ag.CONST, 1, (), k_b) + g.set(k, 0, 1000.0) + g.add(ag.MIX, ag.ALWAYS, (env_b, k_b), mix_b) + out = ints(FRAMES) + + check(g.run(None, 0, 0b11) == 0, "groups: an idle envelope reports sounding") + g.set(gate, 0, UNIT) + sounding = g.run(None, 0, 0b11) + check(sounding & 1, "groups: a gated envelope does not report its group") + for _ in range(20): + g.run(None, 0, 0b11) + level = read(g, env_b) + check(abs(level[FRAMES - 1] - 0.5 * UNIT) < 0.02 * UNIT, + "envelope: sustain reads %d, not %d" % (level[FRAMES - 1], int(0.5 * UNIT))) + mix = read(g, mix_b) + check(mix[FRAMES - 1] == level[FRAMES - 1] + 1000, "mixer: env + 1000 reads %d" % mix[FRAMES - 1]) + + # mask group 1 out: its constant is zeroed once, and the mixer hears silence from it + g.run(None, 0, 0b01) + mix = read(g, mix_b) + level = read(g, env_b) + check(mix[FRAMES - 1] == level[FRAMES - 1], "groups: a masked group still reaches the mixer (%d vs %d)" % (mix[FRAMES - 1], level[FRAMES - 1])) + g.run(None, 0, 0b11) + mix = read(g, mix_b) + check(mix[FRAMES - 1] == level[FRAMES - 1] + 1000, "groups: a group enabled again stays silent") + + g.set(gate, 0, 0.0) + for n in range(200): + if not g.run(None, 0, 0b11) & 1: + break + else: + n = None + check(n is not None and 2 < n < 100, "envelope: release ended after %s blocks" % n) + g.run(None, 0, 0b11) + check(peak(read(g, env_b)) == 0, "envelope: not silent once idle") + g.set(gate, 0, UNIT) + g.run(out, 0, 0b11) + check(g.run(None, 0, 0b11) & 1, "envelope: a second gate does not retrigger") + g.reset(env) + check(g.run(None, 0, 0b11) & 1, "envelope: reset with the gate held does not restart") + + +# -- 4. a moved mixer gain ramps across the block -------------------------------- + +def check_ramp(): + g = new_graph(channel_count=1, buffers=4, nodes=4) + src, dst = g.buffer(), g.buffer() + c = g.add(ag.CONST, ag.ALWAYS, (), src) + g.set(c, 0, 10000.0) + mix = g.add(ag.MIX, ag.ALWAYS, (src,), dst) + g.run(None) + check(read(g, dst)[FRAMES - 1] == 10000, "mixer: unity gain is not a wire") + g.set(mix, 0, 0.0) + g.run(None) + ramp = read(g, dst) + check(ramp[0] > 9000 and ramp[FRAMES - 1] == 0, + "ramp: first %d, last %d after a gain move to 0" % (ramp[0], ramp[FRAMES - 1])) + check(all(ramp[i] >= ramp[i + 1] for i in range(FRAMES - 1)), "ramp: not monotone") + g.run(None) + check(peak(read(g, dst)) == 0, "ramp: a gain of 0 still passes signal") + + +# -- 5. the output node ----------------------------------------------------------- + +def check_output(): + g = new_graph(channel_count=2, buffers=4, nodes=4) + lb, rb = g.buffer(), g.buffer() + g.set(g.add(ag.CONST, ag.ALWAYS, (), lb), 0, 1000.0) + g.set(g.add(ag.CONST, ag.ALWAYS, (), rb), 0, -2000.0) + g.add(ag.OUT, ag.ALWAYS, (lb, rb), None) + out = ints(FRAMES * 2 * 3) + g.run(out, 1) + block = FRAMES * 2 + check(out[0] == 0 and out[block - 1] == 0 and out[2 * block] == 0 and out[3 * block - 1] == 0, + "out: blocks 0 and 2 were written for a run at block 1") + check(out[block] == 1000 and out[block + 1] == -2000 and out[2 * block - 2] == 1000 + and out[2 * block - 1] == -2000, "out: block 1 is not interleaved L/R") + try: + g.run(out, 3) + except ValueError: + pass + else: + check(False, "out: a block past the buffer's end was accepted") + + m = new_graph(channel_count=1, buffers=4, nodes=4) + lb, rb = m.buffer(), m.buffer() + m.set(m.add(ag.CONST, ag.ALWAYS, (), lb), 0, 1000.0) + m.set(m.add(ag.CONST, ag.ALWAYS, (), rb), 0, -2000.0) + o = m.add(ag.OUT, ag.ALWAYS, (lb, rb), None) + out = ints(FRAMES) + m.run(out) + check(out[0] == -500 and out[FRAMES - 1] == -500, "out: mono is not the mean (%d)" % out[0]) + m.set(o, 0, 2.0) + m.run(out) + check(out[0] == -1000, "out: the scale parameter is ignored (%d)" % out[0]) + + +# -- 6. refusals --------------------------------------------------------------------- + +def refuses(what, fn): + try: + fn() + except ValueError: + return + check(False, "accepted " + what) + + +def check_refusals(): + refuses("frames=300", lambda: ag.Graph(frames=300)) + refuses("channel_count=3", lambda: ag.Graph(channel_count=3)) + g = new_graph(buffers=2, nodes=2) + b = g.buffer() + g.buffer() + refuses("a third buffer of two", g.buffer) + refuses("an input id past the buffers", lambda: g.add(ag.MIX, ag.ALWAYS, (7,), b)) + refuses("an output id past the buffers", lambda: g.add(ag.CONST, ag.ALWAYS, (), 7)) + refuses("a constant with no output", lambda: g.add(ag.CONST, ag.ALWAYS, (), None)) + refuses("nine inputs", lambda: g.add(ag.MIX, ag.ALWAYS, (b,) * 9, b)) + refuses("an unknown kind", lambda: g.add(ag.KINDS if hasattr(ag, "KINDS") else 99, ag.ALWAYS, (), b)) + refuses("group 32", lambda: g.add(ag.CONST, 32, (), b)) + n = g.add(ag.CONST, ag.ALWAYS, (), b) + g.add(ag.CONST, ag.ALWAYS, (), b) + refuses("a third node of two", lambda: g.add(ag.CONST, ag.ALWAYS, (), b)) + refuses("parameter 16", lambda: g.set(n, 16, 0.0)) + refuses("get of node 5", lambda: g.get(5, 0)) + refuses("reset of node 5", lambda: g.reset(5)) + refuses("a table on a constant", lambda: g.table(n, array("h", [0, 1]))) + refuses("peak of buffer 9", lambda: g.peak(9)) + refuses("a short read buffer", lambda: g.read(b, ints(3))) + + +for step in (check_wiring, check_filter, check_groups, check_ramp, check_output, check_refusals): + step() + +if failures: + print("GRAPH FAIL") + for line in failures: + print(" " + line) + sys.exit(1) +print("GRAPH PASS")