diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 66e1b851..0f29a371 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 6a923294..ab8b65d6 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 b21ad47d..66a5091d 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 00000000..c9901a27 --- /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.cmake b/micropython.cmake index d75f2de8..b9e6bfcf 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 a3cc9002..0673faba 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. 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 \ + $(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 00000000..96da85ba --- /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, 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/audiograph/Graph.h b/src/audiograph/Graph.h new file mode 100644 index 00000000..82dfb8bc --- /dev/null +++ b/src/audiograph/Graph.h @@ -0,0 +1,23 @@ +// 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. +// +// 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 00000000..3839342a --- /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 00000000..8933f65b --- /dev/null +++ b/src/shared/audiodsp_graph.c @@ -0,0 +1,680 @@ +// 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; + } + 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)) { + 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; + 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: { + 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 00000000..0c3fea81 --- /dev/null +++ b/src/shared/audiodsp_graph.h @@ -0,0 +1,189 @@ +// Runtime-neutral graph runner: one C call renders a whole patch per block. +// +// 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 +// 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, 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); + +// 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); diff --git a/tests/parity/graph_probe.py b/tests/parity/graph_probe.py new file mode 100644 index 00000000..a03eefda --- /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")