From 91e194b103dd3b267acf3938f003e93588a604f7 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:02:36 -0500 Subject: [PATCH] audiograph: a phaser, a glide, LFO shapes and a clocked sample-and-hold Four node kinds, added after OUT so the existing kinds keep their numbers, on CPython, MicroPython and CircuitPython alike. No node file outside audiograph changed. PHASER calls the all-pass kernel audiobiquad.AllPass uses, on one channel, with its frequency, feedback and mix as parameters and the stage count as the build argument. It matches AllPass at 0 counts fed the same settings each 256-frame block. LAG glides a pitch CV to each new note with the bend riding on top: a constant-time line with off, on and legato-only modes, or a one-pole. It matches vrack's two GlideCore classes at 0 counts and 1 count. LFO_SHAPE adds a sine, a falling saw, a square and a random level drawn at each cycle's start beside the triangle, all from the triangle's phase. The triangle renders the same bytes as before. The shapes match vrack's LFOCore on that phase (sine within 1 count, saw and square exact); the phase itself runs up to 0.06% off its rate, as it always has. SAMPLE_HOLD latches its signal at every rising clock edge with an optional one-pole lag (vrack's SampleAndHoldCore, 0 counts, 1 with a lag), or, with the clock open, refreshes at a rate through audioshaper's sample-and-hold kernel (0 counts against audioshaper.SampleHold). audiodsp_graph.c now includes audiodsp_fp_contract.h, as every other float file in src/shared does, so no target fuses its multiply-adds. tests/parity/graph_nodes_probe.py renders all four, and verify_graph.py compares it across interpreters beside the runner's probe: CPython, MicroPython and CircuitPython unix agree on all 307200 samples. Four new planted faults, one per node, each make it fail; CI runs them first. tests/test_cpython_audiograph_nodes.py holds each node to what it stands in for. --- .github/workflows/clean-build.yml | 10 +- README.md | 5 +- docs/audiograph.md | 80 ++- src/audiograph/module.c | 22 + .../shared-bindings/audiograph/__init__.c | 26 +- src/cpython/_audiodsp.c | 22 + src/cpython/audiograph.py | 22 + src/shared/audiodsp_graph.c | 400 ++++++++++++- src/shared/audiodsp_graph.h | 76 ++- tests/parity/graph_nodes_probe.py | 215 +++++++ tests/parity/verify_graph.py | 90 +-- tests/test_cpython_audiograph_nodes.py | 543 ++++++++++++++++++ 12 files changed, 1436 insertions(+), 75 deletions(-) create mode 100644 tests/parity/graph_nodes_probe.py create mode 100644 tests/test_cpython_audiograph_nodes.py diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index df2503e..51e7015 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -235,10 +235,12 @@ jobs: "$MP" "$PROBE" | tee /tmp/graph.txt grep -q "GRAPH PASS" /tmp/graph.txt - # The same graph on CPython (the wheel installed above) and on this + # The same graphs on CPython (the wheel installed above) and on this # MicroPython, compared sample for sample with a tolerance of 0 counts. - # Each planted fault (one input count off, one set() a block late) goes - # into the MicroPython render only, and the comparison must FAIL first. + # Each planted fault (one input count off, one set() a block late, and + # one for each of the phaser, the glide, the LFO shapes and the + # sample-and-hold) goes into the MicroPython render only, and the + # comparison must FAIL first. # The three-way with the CircuitPython unix build is a local run, as # for verify_dsp. - name: A graph renders the same samples on CPython and MicroPython @@ -246,7 +248,7 @@ jobs: MP=$GITHUB_WORKSPACE/micropython/ports/unix/build-standard/micropython PY=$GITHUB_WORKSPACE/audiodsp/.ci-venv/bin/python cd audiodsp - for planted in lsb late; do + for planted in lsb late phaser lag lfo hold; do if "$PY" tests/parity/verify_graph.py --micropython "$MP" --fault $planted > /dev/null; then echo "the planted fault passed, so this comparison proves nothing: $planted" >&2 exit 1 diff --git a/README.md b/README.md index 2810ba6..4186c59 100644 --- a/README.md +++ b/README.md @@ -491,8 +491,9 @@ rather than a chain of `audiobiquad.Biquad`, measured rather than argued. 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, +delay, the all-pass, the sample-and-hold) and a few small ones a voice needs +(an ADSR, an LFO of five shapes, noise, a mixer with ramped gains, a VCA, a +glide), 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. The same graph renders the same samples on CPython, diff --git a/docs/audiograph.md b/docs/audiograph.md index cca1cf2..2a244c2 100644 --- a/docs/audiograph.md +++ b/docs/audiograph.md @@ -2,9 +2,9 @@ `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 +oscillator, the ladder, the float biquad, the feedback delay, the all-pass +cascade, the sample-and-hold) and a few small ones a voice needs (an envelope, +an LFO, noise, a mixer, a VCA, a glide), 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. @@ -79,18 +79,62 @@ rather than its last block. | `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 | +| `LFO` | | `LFO_RATE` Hz, `LFO_AMPLITUDE` counts, `LFO_SHAPE` (`LFO_TRIANGLE`, `LFO_SINE`, `LFO_SAW`, `LFO_SQUARE`, `LFO_RANDOM`) | that shape, ±amplitude, [below](#lfo-shapes) | | `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 | +| `PHASER` | audio | `PHASER_FREQUENCY` Hz, `PHASER_FEEDBACK` −0.99..0.99, `PHASER_MIX` 0..1; built with `arg` = stages (0 is 4) | audiobiquad's `AllPass` on one channel: first-order all-pass stages, one feedback around the chain, a dry/wet mix | +| `LAG` | note, pitch, legato | `LAG_TIME` s, `LAG_MODE` (`LAG_OFF`, `LAG_ON`, `LAG_AUTO`), `LAG_SHAPE` (`LAG_LINEAR`, `LAG_ONE_POLE`) | the note glided, with the bend (pitch − note) on top, [below](#glide) | +| `SAMPLE_HOLD` | signal, clock | `SH_LAG` coefficient 0..1, `SH_RATE` Hz | the signal held at each rising edge of the clock, or `SH_RATE` times a second with the clock open, [below](#sample-and-hold) | 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. +`audioecho`, and `audiobiquad.AllPass` for `PHASER`), 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`. +### LFO shapes + +Every shape comes from one phase that starts at 0 and has taken one step by +the first sample, so LFOs of different shapes at one rate stay in step: a saw +and a square at 3 Hz are one LFO's two outputs. The triangle is `1 - |4p - 2|` +(its bottom at the start of the cycle), the sine `sin(2πp)`, the saw `1 - 2p` +(falling), the square +1 for the first half of the cycle and −1 for the +second. `LFO_RANDOM` draws a new level, uniform in ±amplitude, at each rising +edge of that square (the start of each cycle) and holds it; choosing it draws +one at once if the square is high. The sine is a polynomial rather than the +C library's, so every target computes the same one. + +The phase adds a float step each sample, which runs up to 0.06% off the rate +over a long render: at 1 Hz, 0.0065 of a cycle in ten seconds. + +### Glide + +`LAG` glides a pitch CV to each new note. The target is the note input's first +sample in each block, so a note that steps at a block boundary glides exactly. +With `LAG_LINEAR` (the default) the glide is a straight line that takes +`LAG_TIME` seconds wherever it starts from, and a new note in mid-glide starts +a new one from where the pitch is. `LAG_MODE` is `LAG_ON` (the default), +`LAG_OFF`, or `LAG_AUTO`, which glides only when the legato input is above 0.5 +at the start of the block. `LAG_ONE_POLE` is a one-pole approach instead, with +the coefficient `exp(-1 / (time * sample_rate * 0.35))`; it ignores the mode, +and passes the note through at a time of a millisecond or less. When the pitch +input is connected, the output is the glided note plus (pitch − note), so a +bend rides on top of the glide without being slowed by it. `reset(node)` makes +the next block start on its note. + +### Sample and hold + +`SAMPLE_HOLD` latches its signal at every rising edge of its clock (above 0.1 +on the wire, as an envelope's gate is) and holds it, two edges in one block +included. `SH_LAG` above 0 slews the output toward the held value with a +one-pole of that per-sample coefficient (0.999 is very slow). With the clock +input open and `SH_RATE` set, the node refreshes that many times a second +instead, through audioshaper's `SampleHold` kernel (whole hertz, exact for +ever); with neither, it holds. + ## Output `run(out, block, enable)` writes the `OUT` node's block into `out` at block @@ -118,12 +162,20 @@ ramp and to the output node. Each planted fault, a mixer added before the node it reads, a filter left out of the chain, a cutoff sent to a node no output reads, makes it fail; CI runs the faults first. -`tests/parity/verify_graph.py` renders one patch with every node kind in it -(two voices with their groups parking, a fed buffer, moved gains and -cutoffs, a delay reset mid-tail, a mono graph) on CPython and on each -interpreter you name, and compares every output sample. The tolerance is 0 -counts: the three compile the same C and are handed the same floats, so any -difference is a defect rather than rounding. Its two planted faults, one -input sample one count off and one `set()` a block late, each make it fail. -CI runs it against MicroPython in both float widths; the three-way with the -CircuitPython unix build is a local run. +`tests/parity/verify_graph.py` renders two probes on CPython and on each +interpreter you name, and compares every sample. `graph_parity_probe.py` is one +patch with the runner's first node kinds in it (two voices with their groups +parking, a fed buffer, moved gains and cutoffs, a delay reset mid-tail, a mono +graph); `graph_nodes_probe.py` is the phaser, the glide, every LFO shape and +the sample-and-hold. The tolerance is 0 counts: the three compile the same C +and are handed the same floats, so any difference is a defect rather than +rounding. Each planted fault makes it fail: one input sample one count off, one +`set()` a block late, a phaser's frequency a block late, a legato flag flipped, +a shape switched a block late, a clock edge a sample late. CI runs it against +MicroPython in both float widths; the three-way with the CircuitPython unix +build is a local run. + +`tests/test_cpython_audiograph_nodes.py` holds the newer nodes to what they +stand in for: `PHASER` to `audiobiquad.AllPass` at 0 counts, the glide, the +LFO's shapes and the sample-and-hold to the Python cores of the same name in +vrack, with each bar in the file's table. diff --git a/src/audiograph/module.c b/src/audiograph/module.c index 3839342..a094479 100644 --- a/src/audiograph/module.c +++ b/src/audiograph/module.c @@ -23,6 +23,9 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_PHASER), MP_ROM_INT(AUDIODSP_GRAPH_PHASER) }, + { MP_ROM_QSTR(MP_QSTR_LAG), MP_ROM_INT(AUDIODSP_GRAPH_LAG) }, + { MP_ROM_QSTR(MP_QSTR_SAMPLE_HOLD), MP_ROM_INT(AUDIODSP_GRAPH_SAMPLE_HOLD) }, { 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 @@ -36,6 +39,22 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SHAPE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SHAPE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_TRIANGLE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_TRIANGLE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SINE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SINE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SAW), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SAW) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SQUARE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SQUARE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_RANDOM), MP_ROM_INT(AUDIODSP_GRAPH_LFO_RANDOM) }, + { MP_ROM_QSTR(MP_QSTR_LAG_TIME), MP_ROM_INT(AUDIODSP_GRAPH_LAG_TIME) }, + { MP_ROM_QSTR(MP_QSTR_LAG_MODE), MP_ROM_INT(AUDIODSP_GRAPH_LAG_MODE) }, + { MP_ROM_QSTR(MP_QSTR_LAG_SHAPE), MP_ROM_INT(AUDIODSP_GRAPH_LAG_SHAPE) }, + { MP_ROM_QSTR(MP_QSTR_LAG_OFF), MP_ROM_INT(AUDIODSP_GRAPH_LAG_OFF) }, + { MP_ROM_QSTR(MP_QSTR_LAG_ON), MP_ROM_INT(AUDIODSP_GRAPH_LAG_ON) }, + { MP_ROM_QSTR(MP_QSTR_LAG_AUTO), MP_ROM_INT(AUDIODSP_GRAPH_LAG_AUTO) }, + { MP_ROM_QSTR(MP_QSTR_LAG_LINEAR), MP_ROM_INT(AUDIODSP_GRAPH_LAG_LINEAR) }, + { MP_ROM_QSTR(MP_QSTR_LAG_ONE_POLE), MP_ROM_INT(AUDIODSP_GRAPH_LAG_ONE_POLE) }, + { MP_ROM_QSTR(MP_QSTR_SH_LAG), MP_ROM_INT(AUDIODSP_GRAPH_SH_LAG) }, + { MP_ROM_QSTR(MP_QSTR_SH_RATE), MP_ROM_INT(AUDIODSP_GRAPH_SH_RATE) }, // 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) }, @@ -63,6 +82,9 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_PHASER_FREQUENCY), MP_ROM_INT(AUDIODSP_ALLPASS_F32_OPT_FREQUENCY) }, + { MP_ROM_QSTR(MP_QSTR_PHASER_FEEDBACK), MP_ROM_INT(AUDIODSP_ALLPASS_F32_OPT_FEEDBACK) }, + { MP_ROM_QSTR(MP_QSTR_PHASER_MIX), MP_ROM_INT(AUDIODSP_ALLPASS_F32_OPT_MIX) }, }; static MP_DEFINE_CONST_DICT(audiograph_module_globals, audiograph_module_globals_table); diff --git a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c index 14c8aec..ba0ada4 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c @@ -14,8 +14,8 @@ //| //| The `audiograph` module builds a graph from nodes that call audiodsp's //| kernels (the oscillator, the ladder, the float biquad, the feedback -//| delay) and a few small ones a voice needs (an envelope, an LFO, noise, a -//| mixer, a VCA), then renders it with one ``run()`` a block. It is not part +//| delay, the all-pass, the sample-and-hold) and a few small ones a voice +//| needs (an envelope, an LFO, noise, a mixer, a VCA, a glide), then renders it with one ``run()`` a block. It is not part //| of CircuitPython upstream; it comes from PyDevices' audiodsp, and a graph //| renders the same samples here as on MicroPython and CPython. //| @@ -36,6 +36,9 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_PHASER), MP_ROM_INT(AUDIODSP_GRAPH_PHASER) }, + { MP_ROM_QSTR(MP_QSTR_LAG), MP_ROM_INT(AUDIODSP_GRAPH_LAG) }, + { MP_ROM_QSTR(MP_QSTR_SAMPLE_HOLD), MP_ROM_INT(AUDIODSP_GRAPH_SAMPLE_HOLD) }, { 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 @@ -49,6 +52,22 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SHAPE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SHAPE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_TRIANGLE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_TRIANGLE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SINE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SINE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SAW), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SAW) }, + { MP_ROM_QSTR(MP_QSTR_LFO_SQUARE), MP_ROM_INT(AUDIODSP_GRAPH_LFO_SQUARE) }, + { MP_ROM_QSTR(MP_QSTR_LFO_RANDOM), MP_ROM_INT(AUDIODSP_GRAPH_LFO_RANDOM) }, + { MP_ROM_QSTR(MP_QSTR_LAG_TIME), MP_ROM_INT(AUDIODSP_GRAPH_LAG_TIME) }, + { MP_ROM_QSTR(MP_QSTR_LAG_MODE), MP_ROM_INT(AUDIODSP_GRAPH_LAG_MODE) }, + { MP_ROM_QSTR(MP_QSTR_LAG_SHAPE), MP_ROM_INT(AUDIODSP_GRAPH_LAG_SHAPE) }, + { MP_ROM_QSTR(MP_QSTR_LAG_OFF), MP_ROM_INT(AUDIODSP_GRAPH_LAG_OFF) }, + { MP_ROM_QSTR(MP_QSTR_LAG_ON), MP_ROM_INT(AUDIODSP_GRAPH_LAG_ON) }, + { MP_ROM_QSTR(MP_QSTR_LAG_AUTO), MP_ROM_INT(AUDIODSP_GRAPH_LAG_AUTO) }, + { MP_ROM_QSTR(MP_QSTR_LAG_LINEAR), MP_ROM_INT(AUDIODSP_GRAPH_LAG_LINEAR) }, + { MP_ROM_QSTR(MP_QSTR_LAG_ONE_POLE), MP_ROM_INT(AUDIODSP_GRAPH_LAG_ONE_POLE) }, + { MP_ROM_QSTR(MP_QSTR_SH_LAG), MP_ROM_INT(AUDIODSP_GRAPH_SH_LAG) }, + { MP_ROM_QSTR(MP_QSTR_SH_RATE), MP_ROM_INT(AUDIODSP_GRAPH_SH_RATE) }, // 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) }, @@ -76,6 +95,9 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_PHASER_FREQUENCY), MP_ROM_INT(AUDIODSP_ALLPASS_F32_OPT_FREQUENCY) }, + { MP_ROM_QSTR(MP_QSTR_PHASER_FEEDBACK), MP_ROM_INT(AUDIODSP_ALLPASS_F32_OPT_FEEDBACK) }, + { MP_ROM_QSTR(MP_QSTR_PHASER_MIX), MP_ROM_INT(AUDIODSP_ALLPASS_F32_OPT_MIX) }, }; static MP_DEFINE_CONST_DICT(audiograph_module_globals, audiograph_module_globals_table); diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index fc5db7f..8977adc 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -3869,6 +3869,9 @@ static int graph_add_constants(PyObject *module) { {"MIX", AUDIODSP_GRAPH_MIX}, {"VCA", AUDIODSP_GRAPH_VCA}, {"OUT", AUDIODSP_GRAPH_OUT}, + {"PHASER", AUDIODSP_GRAPH_PHASER}, + {"LAG", AUDIODSP_GRAPH_LAG}, + {"SAMPLE_HOLD", AUDIODSP_GRAPH_SAMPLE_HOLD}, {"ALWAYS", AUDIODSP_GRAPH_ALWAYS}, {"UNIT", 16384}, {"ENV_ATTACK", AUDIODSP_GRAPH_ENV_ATTACK}, @@ -3880,6 +3883,22 @@ static int graph_add_constants(PyObject *module) { {"MIX_OFFSET", AUDIODSP_GRAPH_MIX_OFFSET}, {"VCA_GAIN", AUDIODSP_GRAPH_VCA_GAIN}, {"VCA_SELECT", AUDIODSP_GRAPH_VCA_SELECT}, + {"LFO_SHAPE", AUDIODSP_GRAPH_LFO_SHAPE}, + {"LFO_TRIANGLE", AUDIODSP_GRAPH_LFO_TRIANGLE}, + {"LFO_SINE", AUDIODSP_GRAPH_LFO_SINE}, + {"LFO_SAW", AUDIODSP_GRAPH_LFO_SAW}, + {"LFO_SQUARE", AUDIODSP_GRAPH_LFO_SQUARE}, + {"LFO_RANDOM", AUDIODSP_GRAPH_LFO_RANDOM}, + {"LAG_TIME", AUDIODSP_GRAPH_LAG_TIME}, + {"LAG_MODE", AUDIODSP_GRAPH_LAG_MODE}, + {"LAG_SHAPE", AUDIODSP_GRAPH_LAG_SHAPE}, + {"LAG_OFF", AUDIODSP_GRAPH_LAG_OFF}, + {"LAG_ON", AUDIODSP_GRAPH_LAG_ON}, + {"LAG_AUTO", AUDIODSP_GRAPH_LAG_AUTO}, + {"LAG_LINEAR", AUDIODSP_GRAPH_LAG_LINEAR}, + {"LAG_ONE_POLE", AUDIODSP_GRAPH_LAG_ONE_POLE}, + {"SH_LAG", AUDIODSP_GRAPH_SH_LAG}, + {"SH_RATE", AUDIODSP_GRAPH_SH_RATE}, {"OSC_FREQUENCY", AUDIODSP_OSCILLATOR_OPT_FREQUENCY}, {"OSC_WAVEFORM", AUDIODSP_OSCILLATOR_OPT_WAVEFORM}, {"OSC_PULSE_WIDTH", AUDIODSP_OSCILLATOR_OPT_PULSE_WIDTH}, @@ -3906,6 +3925,9 @@ static int graph_add_constants(PyObject *module) { {"DELAY_DAMPING_HZ", AUDIODSP_FEEDBACK_DELAY_OPT_DAMPING_HZ}, {"DELAY_WOW_HZ", AUDIODSP_FEEDBACK_DELAY_OPT_WOW_HZ}, {"DELAY_WOW_DEPTH_MS", AUDIODSP_FEEDBACK_DELAY_OPT_WOW_DEPTH_MS}, + {"PHASER_FREQUENCY", AUDIODSP_ALLPASS_F32_OPT_FREQUENCY}, + {"PHASER_FEEDBACK", AUDIODSP_ALLPASS_F32_OPT_FEEDBACK}, + {"PHASER_MIX", AUDIODSP_ALLPASS_F32_OPT_MIX}, }; PyObject *table = PyDict_New(); if (table == NULL) { diff --git a/src/cpython/audiograph.py b/src/cpython/audiograph.py index f8c828f..c397115 100644 --- a/src/cpython/audiograph.py +++ b/src/cpython/audiograph.py @@ -48,6 +48,9 @@ MIX = _K["MIX"] VCA = _K["VCA"] OUT = _K["OUT"] +PHASER = _K["PHASER"] +LAG = _K["LAG"] +SAMPLE_HOLD = _K["SAMPLE_HOLD"] ALWAYS = _K["ALWAYS"] UNIT = _K["UNIT"] @@ -61,6 +64,22 @@ MIX_OFFSET = _K["MIX_OFFSET"] VCA_GAIN = _K["VCA_GAIN"] VCA_SELECT = _K["VCA_SELECT"] +LFO_SHAPE = _K["LFO_SHAPE"] +LFO_TRIANGLE = _K["LFO_TRIANGLE"] +LFO_SINE = _K["LFO_SINE"] +LFO_SAW = _K["LFO_SAW"] +LFO_SQUARE = _K["LFO_SQUARE"] +LFO_RANDOM = _K["LFO_RANDOM"] +LAG_TIME = _K["LAG_TIME"] +LAG_MODE = _K["LAG_MODE"] +LAG_SHAPE = _K["LAG_SHAPE"] +LAG_OFF = _K["LAG_OFF"] +LAG_ON = _K["LAG_ON"] +LAG_AUTO = _K["LAG_AUTO"] +LAG_LINEAR = _K["LAG_LINEAR"] +LAG_ONE_POLE = _K["LAG_ONE_POLE"] +SH_LAG = _K["SH_LAG"] +SH_RATE = _K["SH_RATE"] # the kernels' option numbers, so a caller needs no other module OSC_FREQUENCY = _K["OSC_FREQUENCY"] @@ -89,5 +108,8 @@ DELAY_DAMPING_HZ = _K["DELAY_DAMPING_HZ"] DELAY_WOW_HZ = _K["DELAY_WOW_HZ"] DELAY_WOW_DEPTH_MS = _K["DELAY_WOW_DEPTH_MS"] +PHASER_FREQUENCY = _K["PHASER_FREQUENCY"] +PHASER_FEEDBACK = _K["PHASER_FEEDBACK"] +PHASER_MIX = _K["PHASER_MIX"] del _K diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index 8933f65..c9a9bc5 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -1,6 +1,10 @@ // The graph runner. See audiodsp_graph.h. // SPDX-License-Identifier: MIT +// First, so no multiply-add here is fused on one target and not another: a +// graph renders the same samples on every board (audiodsp_fp_contract.h). +#include "shared/audiodsp_fp_contract.h" + #include "shared/audiodsp_graph.h" #include @@ -49,8 +53,58 @@ typedef struct { typedef struct { float phase; + // The random shape: the level held for this cycle (-1..1), the square's + // state at the last sample, the shape last run, and the generator. + float level; + bool high; + uint8_t shape; + uint32_t rng; } graph_lfo_t; +typedef struct { + audiodsp_allpass_f32_config_t config; + audiodsp_allpass_f32_state_t state; + // The stages' state follows, `config.stages` floats. + float lanes[]; +} graph_phaser_t; + +typedef struct { + // The linear shape (GlideCore in dsp/glide.py): the value has been seen, + // the note being glided to, the step per sample and the samples left. + bool primed; + float value; + float target; + float slope; + uint32_t left; + // The one-pole shape (GlideCore in dsp/utility.py), which starts at 0. + float pole; +} graph_lag_t; + +typedef struct { + // vrack's SampleAndHoldCore: the clock's state at the last sample, the + // value held and the output slewing toward it. + bool high; + float held; + float out; + // The free-running hold when the clock is open: audioshaper's kernel. + audiodsp_samplehold_config_t config; + audiodsp_samplehold_state_t state; + uint32_t rate; +} graph_sh_t; + +// audiobiquad.AllPass's default, for a PHASER built with `arg` 0. +#define PHASER_DEFAULT_STAGES 4u + +static uint32_t phaser_stages(float arg) { + if (!(arg >= 1.0f)) { + return PHASER_DEFAULT_STAGES; + } + if (arg > (float)AUDIODSP_FILTER_F32_MAX_STAGES) { + return AUDIODSP_FILTER_F32_MAX_STAGES; + } + return (uint32_t)arg; +} + typedef struct { uint32_t rng; } graph_noise_t; @@ -84,6 +138,12 @@ size_t audiodsp_graph_node_data_size(audiodsp_graph_kind_t kind, return sizeof(graph_noise_t); case AUDIODSP_GRAPH_MIX: return sizeof(graph_mix_t); + case AUDIODSP_GRAPH_PHASER: + return sizeof(graph_phaser_t) + (size_t)phaser_stages(arg) * sizeof(float); + case AUDIODSP_GRAPH_LAG: + return sizeof(graph_lag_t); + case AUDIODSP_GRAPH_SAMPLE_HOLD: + return sizeof(graph_sh_t); default: return 0; } @@ -221,10 +281,34 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, node->param[AUDIODSP_GRAPH_ENV_RELEASE] = 0.3f; break; } - case AUDIODSP_GRAPH_LFO: + case AUDIODSP_GRAPH_LFO: { + graph_lfo_t *d = data; + d->rng = 0x5EED1234u ^ (graph->nnodes * 0x9E3779B9u); node->param[AUDIODSP_GRAPH_LFO_RATE] = 1.0f; node->param[AUDIODSP_GRAPH_LFO_AMPLITUDE] = 0.5f * AUDIODSP_GRAPH_UNIT; + node->param[AUDIODSP_GRAPH_LFO_SHAPE] = (float)AUDIODSP_GRAPH_LFO_TRIANGLE; + break; + } + case AUDIODSP_GRAPH_PHASER: { + graph_phaser_t *d = data; + const uint32_t stages = phaser_stages(arg); + audiodsp_allpass_f32_config_init(&d->config, rate, 1, stages); + audiodsp_allpass_f32_config_finish(&d->config); + audiodsp_allpass_f32_state_init(&d->state, d->lanes, stages); + node->param[AUDIODSP_ALLPASS_F32_OPT_FREQUENCY] = d->config.frequency; + node->param[AUDIODSP_ALLPASS_F32_OPT_FEEDBACK] = d->config.feedback; + node->param[AUDIODSP_ALLPASS_F32_OPT_MIX] = d->config.mix; + break; + } + case AUDIODSP_GRAPH_LAG: + node->param[AUDIODSP_GRAPH_LAG_MODE] = (float)AUDIODSP_GRAPH_LAG_ON; + break; + case AUDIODSP_GRAPH_SAMPLE_HOLD: { + graph_sh_t *d = data; + audiodsp_samplehold_config_init(&d->config, 1u, 1u); + audiodsp_samplehold_state_init(&d->state, &d->config); break; + } case AUDIODSP_GRAPH_NOISE: { graph_noise_t *d = data; d->rng = 0x5EED1234u ^ (graph->nnodes * 0x9E3779B9u); @@ -304,12 +388,35 @@ bool audiodsp_graph_reset(audiodsp_graph_t *graph, uint32_t node) { d->high = false; break; } - case AUDIODSP_GRAPH_LFO: - ((graph_lfo_t *)n->data)->phase = 0.0f; + case AUDIODSP_GRAPH_LFO: { + graph_lfo_t *d = n->data; + d->phase = 0.0f; + d->level = 0.0f; + d->high = false; break; + } case AUDIODSP_GRAPH_MIX: ((graph_mix_t *)n->data)->primed = false; break; + case AUDIODSP_GRAPH_PHASER: + audiodsp_allpass_f32_reset(&((graph_phaser_t *)n->data)->state); + break; + case AUDIODSP_GRAPH_LAG: { + graph_lag_t *d = n->data; + d->primed = false; + d->left = 0; + d->slope = 0.0f; + d->pole = 0.0f; + break; + } + case AUDIODSP_GRAPH_SAMPLE_HOLD: { + graph_sh_t *d = n->data; + d->high = false; + d->held = 0.0f; + d->out = 0.0f; + audiodsp_samplehold_reset(&d->state, &d->config); + break; + } default: break; } @@ -361,6 +468,34 @@ static void derive(audiodsp_graph_t *graph, audiodsp_graph_node_t *n) { audiodsp_feedback_delay_config_finish(&d->config); break; } + case AUDIODSP_GRAPH_PHASER: { + graph_phaser_t *d = n->data; + for (uint32_t i = 0; i <= AUDIODSP_ALLPASS_F32_OPT_MIX; ++i) { + audiodsp_allpass_f32_configure(&d->config, + (audiodsp_allpass_f32_option_t)i, n->param[i]); + } + audiodsp_allpass_f32_config_finish(&d->config); + break; + } + case AUDIODSP_GRAPH_SAMPLE_HOLD: { + graph_sh_t *d = n->data; + // Whole hertz, at most the sample rate: the kernel's ratio is a + // pair of integers, so a rate it holds is exact for ever. + float hz = n->param[AUDIODSP_GRAPH_SH_RATE]; + uint32_t rate = 0; + if (hz >= 0.5f) { + rate = hz >= (float)graph->sample_rate + ? graph->sample_rate : (uint32_t)lrintf(hz); + } + if (rate != d->rate) { + d->rate = rate; + if (rate != 0 && + audiodsp_samplehold_config_set(&d->config, graph->sample_rate, rate)) { + audiodsp_samplehold_reset(&d->state, &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); @@ -438,21 +573,250 @@ static void run_env(audiodsp_graph_node_t *n, const int16_t *gate, int16_t *out, d->val = val; } +// sin(2 pi p) for a phase in [0, 1), in float and without libm: an odd +// polynomial (Taylor to x^11) over the quarter cycle each phase folds into. +// One polynomial in one order is one function on every target, where sinf() +// is a different one on each libm. Worst error about 6e-8, under a hundredth +// of a count at full scale. +static float lfo_sine(float phase) { + float t = phase; + if (t >= 0.75f) { + t -= 1.0f; + } else if (t >= 0.25f) { + t = 0.5f - t; + } + const float x = t * 6.28318530717958648f; + const float x2 = x * x; + float r = -2.50521083854417188e-8f; // -1/11! + r = r * x2 + 2.75573192239858907e-6f; // 1/9! + r = r * x2 - 1.98412698412698413e-4f; // -1/7! + r = r * x2 + 8.33333333333333333e-3f; // 1/5! + r = r * x2 - 1.66666666666666667e-1f; // -1/3! + r = r * x2 + 1.0f; + return r * x; +} + +static uint32_t xorshift(uint32_t x) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + return x; +} + 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]; + // The nearest shape; anything out of range is the triangle. + const float pick = n->param[AUDIODSP_GRAPH_LFO_SHAPE] + 0.5f; + const uint8_t shape = pick >= 1.0f && pick < (float)AUDIODSP_GRAPH_LFO_SHAPES + ? (uint8_t)pick : (uint8_t)AUDIODSP_GRAPH_LFO_TRIANGLE; 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); + switch (shape) { + case AUDIODSP_GRAPH_LFO_SINE: + for (uint32_t i = 0; i < frames; ++i) { + phase += dt; + phase -= floorf(phase); + out[i] = sat16(lfo_sine(phase) * amp); + } + break; + case AUDIODSP_GRAPH_LFO_SAW: + for (uint32_t i = 0; i < frames; ++i) { + phase += dt; + phase -= floorf(phase); + out[i] = sat16((1.0f - 2.0f * phase) * amp); + } + break; + case AUDIODSP_GRAPH_LFO_SQUARE: + for (uint32_t i = 0; i < frames; ++i) { + phase += dt; + phase -= floorf(phase); + out[i] = sat16(phase < 0.5f ? amp : -amp); + } + break; + case AUDIODSP_GRAPH_LFO_RANDOM: { + // A shape just chosen starts with the square low, so the first + // sample of a high half draws a level at once (Mono101LFO's + // switch to its random wave). + bool high = d->shape == AUDIODSP_GRAPH_LFO_RANDOM ? d->high : false; + float level = d->level; + uint32_t x = d->rng; + for (uint32_t i = 0; i < frames; ++i) { + phase += dt; + phase -= floorf(phase); + const bool up = phase < 0.5f; + if (up && !high) { + x = xorshift(x); + level = (float)(int32_t)x * (1.0f / 2147483648.0f); + } + high = up; + out[i] = sat16(level * amp); + } + d->high = high; + d->level = level; + d->rng = x; + break; + } + default: + 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); + } + break; } d->phase = phase; + d->shape = shape; +} + +static void run_lag(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, + int16_t *out, uint32_t frames) { + graph_lag_t *d = n->data; + const int16_t *note = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * graph->frames; + const int16_t *pitch = n->ninputs > 1 && n->inputs[1] != AUDIODSP_GRAPH_NONE + ? graph->bufs + (size_t)n->inputs[1] * graph->frames : NULL; + const int16_t *legato = n->ninputs > 2 && n->inputs[2] != AUDIODSP_GRAPH_NONE + ? graph->bufs + (size_t)n->inputs[2] * graph->frames : NULL; + const float time = n->param[AUDIODSP_GRAPH_LAG_TIME]; + const float target = note != NULL ? (float)note[0] : 0.0f; + float acc[AUDIODSP_GRAPH_MAX_FRAMES]; + if (n->param[AUDIODSP_GRAPH_LAG_SHAPE] >= 0.5f) { + // dsp/utility.py's GlideCore: a one-pole toward the block's first + // sample, or the input as it is for a time of a millisecond or less. + if (!(time > 0.001f)) { + for (uint32_t i = 0; i < frames; ++i) { + acc[i] = note != NULL ? (float)note[i] : 0.0f; + } + d->pole = acc[frames - 1]; + } else { + const float c = expf(-1.0f / (time * (float)graph->sample_rate * 0.35f)); + float v = d->pole; + for (uint32_t i = 0; i < frames; ++i) { + v = target + (v - target) * c; + acc[i] = v; + } + d->pole = v; + } + } else { + // dsp/glide.py's GlideCore: a straight line to each new note that + // takes `time` seconds wherever it starts from. + if (!d->primed) { + d->value = d->target = target; + d->primed = true; + } + if (target != d->target) { + d->target = target; + const float mode = n->param[AUDIODSP_GRAPH_LAG_MODE]; + const bool on = mode >= 0.5f && mode < 1.5f; + const bool aut = mode >= 1.5f; + const bool held = legato != NULL && + (float)legato[0] > 0.5f * AUDIODSP_GRAPH_UNIT; + const double span = (double)time * (double)graph->sample_rate; + const uint32_t steps = span >= 1.0 && span < 4294967295.0 + ? (uint32_t)span : (span >= 1.0 ? 4294967295u : 0u); + if (steps >= 1u && (on || (aut && held))) { + d->left = steps; + // In double, rounded once: the slope GlideCore computes. + d->slope = (float)(((double)target - (double)d->value) / (double)steps); + } else { + d->left = 0; + d->value = target; + } + } + if (d->left > 0u) { + const uint32_t k = d->left > frames ? frames : d->left; + const float start = d->value; + const float slope = d->slope; + float last = start; + for (uint32_t i = 0; i < k; ++i) { + last = (float)(i + 1u) * slope + start; + acc[i] = last; + } + d->left -= k; + if (d->left == 0u) { + last = target; + acc[k - 1u] = target; + } + d->value = last; + for (uint32_t i = k; i < frames; ++i) { + acc[i] = target; + } + } else { + for (uint32_t i = 0; i < frames; ++i) { + acc[i] = d->value; + } + } + } + if (pitch != NULL) { + // The bend rides on top unslid: + pitch - note, in that order. + for (uint32_t i = 0; i < frames; ++i) { + float v = acc[i] + (float)pitch[i]; + out[i] = sat16(v - (note != NULL ? (float)note[i] : 0.0f)); + } + } else { + for (uint32_t i = 0; i < frames; ++i) { + out[i] = sat16(acc[i]); + } + } +} + +static void run_sample_hold(const audiodsp_graph_t *graph, + audiodsp_graph_node_t *n, int16_t *out, uint32_t frames) { + graph_sh_t *d = n->data; + const int16_t *signal = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * graph->frames; + const int16_t *clock = n->ninputs > 1 && n->inputs[1] != AUDIODSP_GRAPH_NONE + ? graph->bufs + (size_t)n->inputs[1] * graph->frames : NULL; + float lag = n->param[AUDIODSP_GRAPH_SH_LAG]; + if (!(lag > 0.0f)) { + lag = 0.0f; + } else if (lag > 0.999999f) { + lag = 0.999999f; + } + int16_t staircase[AUDIODSP_GRAPH_MAX_FRAMES]; + float held = d->held; + if (clock != NULL) { + // Every rising edge latches the signal's sample at that edge: the + // clock above 0.1 on the wire is high, as an envelope's gate is. + bool high = d->high; + for (uint32_t i = 0; i < frames; ++i) { + const bool g = (float)clock[i] > ENV_GATE_THRESHOLD; + if (g && !high) { + held = signal != NULL ? (float)signal[i] : 0.0f; + } + high = g; + staircase[i] = (int16_t)held; + } + d->high = high; + } else if (d->rate != 0u && signal != NULL) { + audiodsp_samplehold_process(&d->config, &d->state, + (uint8_t *)staircase, (const uint8_t *)signal, frames, + sizeof(int16_t)); + held = (float)staircase[frames - 1]; + } else { + for (uint32_t i = 0; i < frames; ++i) { + staircase[i] = (int16_t)held; + } + } + d->held = held; + if (lag == 0.0f) { + memcpy(out, staircase, frames * sizeof(int16_t)); + d->out = held; + return; + } + // out slews toward what is held with a one-pole of coefficient `lag`. + float v = d->out; + for (uint32_t i = 0; i < frames; ++i) { + const float h = (float)staircase[i]; + v = h + (v - h) * lag; + out[i] = sat16(v); + } + d->out = v; } static void run_noise(audiodsp_graph_node_t *n, int16_t *out, uint32_t frames) { @@ -656,6 +1020,22 @@ uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, case AUDIODSP_GRAPH_OUT: run_out(graph, n, out, frames); break; + case AUDIODSP_GRAPH_PHASER: { + graph_phaser_t *d = n->data; + if (in0 == NULL) { + memset(dst, 0, frames * sizeof(int16_t)); + in0 = dst; + } + audiodsp_allpass_f32_process_s16(&d->config, &d->state, dst, + in0, frames); + break; + } + case AUDIODSP_GRAPH_LAG: + run_lag(graph, n, dst, frames); + break; + case AUDIODSP_GRAPH_SAMPLE_HOLD: + run_sample_hold(graph, n, dst, frames); + break; default: break; } diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index 6462bf9..c99de76 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -11,9 +11,10 @@ // 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. +// biquad, the feedback delay, the all-pass cascade, the sample-and-hold -- +// plus a few small ones a voice needs that only existed in Python: an ADSR, +// an LFO, white noise, a mixer with ramped gains, a VCA, a glide 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 @@ -44,6 +45,7 @@ #include "shared/audiodsp_feedback_delay.h" #include "shared/audiodsp_ladder.h" #include "shared/audiodsp_oscillator.h" +#include "shared/audiodsp_samplehold.h" //: The longest block a run renders. The kernels' own block ceiling. #define AUDIODSP_GRAPH_MAX_FRAMES 256u @@ -77,7 +79,8 @@ typedef enum { // 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. + // params: rate Hz, amplitude (counts), shape. out: +-amplitude, the + // shape one of AUDIODSP_GRAPH_LFO_TRIANGLE..RANDOM from one phase. AUDIODSP_GRAPH_LFO, // params: amplitude (counts). out: uniform white noise, +-amplitude. AUDIODSP_GRAPH_NOISE, @@ -91,6 +94,23 @@ typedef enum { // 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, + // Added after OUT so the kinds above keep their numbers. + // + // inputs: audio. params: audiodsp_allpass_f32_option_t (frequency, + // feedback, mix). audiobiquad.AllPass on one channel: first-order + // all-pass stages with one feedback around the chain and a dry/wet mix. + // Built with `arg` = stages (0 takes AllPass's default of 4). + AUDIODSP_GRAPH_PHASER, + // inputs: note, pitch, legato. params: time s, mode, shape. A glide on a + // pitch CV: the note's first sample each block is the target, and the + // bend (pitch - note) rides on top unslid. vrack's dsp/glide.py + // GlideCore (the linear shape) or dsp/utility.py's (the one-pole shape). + AUDIODSP_GRAPH_LAG, + // inputs: signal, clock. params: lag coefficient, rate Hz. Holds the + // signal at each rising edge of the clock (vrack's SampleAndHoldCore); + // with the clock open and a rate set, audioshaper's sample-and-hold + // kernel refreshes it that many times a second instead. + AUDIODSP_GRAPH_SAMPLE_HOLD, AUDIODSP_GRAPH_KINDS, } audiodsp_graph_kind_t; @@ -104,6 +124,54 @@ enum { enum { AUDIODSP_GRAPH_LFO_RATE = 0, AUDIODSP_GRAPH_LFO_AMPLITUDE, + AUDIODSP_GRAPH_LFO_SHAPE, +}; + +// LFO shapes, all from one phase that starts at 0 and has advanced one step +// by the first sample, so nodes of different shapes at one rate stay in step. +enum { + // 1 - |4p - 2|: the bottom at p = 0, the top at p = 0.5. + AUDIODSP_GRAPH_LFO_TRIANGLE = 0, + // sin(2 pi p). + AUDIODSP_GRAPH_LFO_SINE, + // 1 - 2p, falling. + AUDIODSP_GRAPH_LFO_SAW, + // +1 for the first half of the cycle, -1 for the second. + AUDIODSP_GRAPH_LFO_SQUARE, + // A new uniform level at each rising edge of the square (the start of + // each cycle), held for the cycle. + AUDIODSP_GRAPH_LFO_RANDOM, + AUDIODSP_GRAPH_LFO_SHAPES, +}; + +enum { + AUDIODSP_GRAPH_LAG_TIME = 0, + AUDIODSP_GRAPH_LAG_MODE, + AUDIODSP_GRAPH_LAG_SHAPE, +}; + +// LAG modes, GlideCore's numbers: the linear shape only. +enum { + AUDIODSP_GRAPH_LAG_OFF = 0, + AUDIODSP_GRAPH_LAG_ON, + // Glide only when the legato input is above 0.5 at the block's start. + AUDIODSP_GRAPH_LAG_AUTO, +}; + +// LAG shapes. +enum { + // Constant time: a straight line to each new note taking `time` seconds. + AUDIODSP_GRAPH_LAG_LINEAR = 0, + // A one-pole approach, the coefficient exp(-1 / (time * rate * 0.35)). + AUDIODSP_GRAPH_LAG_ONE_POLE, +}; + +enum { + // The per-sample one-pole coefficient the output slews with toward the + // held value: 0 none, 0.999 very slow. + AUDIODSP_GRAPH_SH_LAG = 0, + // Refreshes a second when the clock input is open; 0 holds for ever. + AUDIODSP_GRAPH_SH_RATE, }; enum { diff --git a/tests/parity/graph_nodes_probe.py b/tests/parity/graph_nodes_probe.py new file mode 100644 index 0000000..bd7d1ea --- /dev/null +++ b/tests/parity/graph_nodes_probe.py @@ -0,0 +1,215 @@ +"""audiograph's PHASER, LAG, LFO shapes and SAMPLE_HOLD, rendered for verify_graph.py. + + graph_nodes_probe.py # prints the render, then GRAPH NODES PASS + graph_nodes_probe.py --fault phaser # one phaser frequency a block late + graph_nodes_probe.py --fault lag # one legato flag flipped + graph_nodes_probe.py --fault lfo # one shape switch a block late + graph_nodes_probe.py --fault hold # one clock edge a sample late + +The same file runs on CPython, MicroPython and CircuitPython and prints every +sample each of these nodes writes, every block. ``verify_graph.py`` runs it on +each interpreter and compares the renders sample for sample. This file only +renders, and checks that what it rendered is worth comparing: a phaser that +moves the sound, glides that pass through pitches between notes, LFOs that +swing, holds that step. + +The patch: a sawtooth through two phasers (four stages with the centre swept +each block, six with a negative feedback); an LFO of each shape, one switched +to random and back mid-render and one reset; three glides on one written note +line with a bend that moves every sample (linear always, linear on legato +only, one-pole); and four sample-and-holds (noise clocked by the square LFO, +the same with a lag, a written clock with two edges in a block, and the +oscillator held at a rate with no clock). + +Every value handed to the graph is a short binary fraction or an integer, so a +single-precision MicroPython parses the same floats a double one does. + +Each fault is a mistake a binding or a caller could make in one of these nodes +without the node itself being wrong; verify_graph.py plants each on one +interpreter and must see it. +""" + +import sys +from array import array + +try: + import audiograph as ag + ag.PHASER +except (ImportError, AttributeError): + print("GRAPH PARITY SKIPPED: no audiograph nodes here") + sys.exit(0) + +FAULTS = ("phaser", "lag", "lfo", "hold") +FAULT = None +if "--fault" in sys.argv: + FAULT = sys.argv[sys.argv.index("--fault") + 1] + if FAULT not in FAULTS: + print("unknown fault %r" % FAULT) + sys.exit(2) + +RATE = 48000 +FRAMES = 128 +BLOCKS = 160 +UNIT = ag.UNIT +failures = [] + + +def check(ok, what): + if not ok: + failures.append(what) + + +g = ag.Graph(sample_rate=RATE, frames=FRAMES, channel_count=1, buffers=32, + nodes=32) +watched = [] + + +def node(name, kind, inputs, arg=0.0): + out = g.buffer() + index = g.add(kind, ag.ALWAYS, inputs, out, arg) + watched.append((name, out)) + return index, out + + +# -- phasers on a sawtooth +saw_b = g.buffer() +saw = g.add(ag.OSC, ag.ALWAYS, (None, None, None, None), saw_b) +g.set(saw, ag.OSC_FREQUENCY, 220.0) +g.set(saw, ag.OSC_WAVEFORM, 1) +g.set(saw, ag.OSC_LEVEL, 0.5) +watched.append(("saw", saw_b)) +ph4, _ = node("phaser4", ag.PHASER, (saw_b,), 4.0) +g.set(ph4, ag.PHASER_FEEDBACK, 0.5) +g.set(ph4, ag.PHASER_MIX, 0.5) +ph6, _ = node("phaser6", ag.PHASER, (saw_b,), 6.0) +g.set(ph6, ag.PHASER_FREQUENCY, 750.0) +g.set(ph6, ag.PHASER_FEEDBACK, -0.375) +g.set(ph6, ag.PHASER_MIX, 0.75) + +# -- one LFO of each shape +lfos = [] +for name, shape, rate in (("triangle", ag.LFO_TRIANGLE, 5.5), + ("sine", ag.LFO_SINE, 3.25), + ("saw", ag.LFO_SAW, 0.75), + ("square", ag.LFO_SQUARE, 37.5), + ("random", ag.LFO_RANDOM, 12.25)): + index, out = node("lfo_" + name, ag.LFO, ()) + g.set(index, ag.LFO_RATE, rate) + g.set(index, ag.LFO_AMPLITUDE, 8192.0) + g.set(index, ag.LFO_SHAPE, shape) + lfos.append((index, out)) +lfo_sine = lfos[1][0] +square_b = lfos[3][1] + +# -- glides on a written note line +note_b, pitch_b, legato_b = g.buffer(), g.buffer(), g.buffer() +lag_on, _ = node("lag_on", ag.LAG, (note_b, pitch_b, legato_b)) +g.set(lag_on, ag.LAG_TIME, 0.015625) +lag_auto, _ = node("lag_auto", ag.LAG, (note_b, pitch_b, legato_b)) +g.set(lag_auto, ag.LAG_TIME, 0.03125) +g.set(lag_auto, ag.LAG_MODE, ag.LAG_AUTO) +lag_pole, _ = node("lag_pole", ag.LAG, (note_b,)) +g.set(lag_pole, ag.LAG_TIME, 0.0625) +g.set(lag_pole, ag.LAG_SHAPE, ag.LAG_ONE_POLE) + +# -- sample-and-holds +noise_b = g.buffer() +g.add(ag.NOISE, ag.ALWAYS, (), noise_b) +clock_b = g.buffer() +sh_clock, _ = node("hold_clock", ag.SAMPLE_HOLD, (noise_b, square_b)) +sh_lag, _ = node("hold_lag", ag.SAMPLE_HOLD, (noise_b, square_b)) +g.set(sh_lag, ag.SH_LAG, 0.9375) +sh_written, _ = node("hold_written", ag.SAMPLE_HOLD, (saw_b, clock_b)) +sh_rate, _ = node("hold_rate", ag.SAMPLE_HOLD, (saw_b, None)) +g.set(sh_rate, ag.SH_RATE, 2000.0) + +NOTES = (0, 0, 4096, 4096, 4096, -2731, -2731, 1365, 9557, 9557, 9557, + 9557, -8192, -8192, 683, 683) +note = array("h", bytes(2 * FRAMES)) +pitch = array("h", bytes(2 * FRAMES)) +legato = array("h", bytes(2 * FRAMES)) +clock = array("h", bytes(2 * FRAMES)) +block = array("h", bytes(2 * FRAMES)) +#: Per node: samples that differ from the one before, the lowest, the highest. +seen = {} +saw_block = array("h", bytes(2 * FRAMES)) +moved = False + +for b in range(BLOCKS): + # the phaser's centre: a triangle sweep over 64 blocks, in whole hertz + centre = b if FAULT != "phaser" or b != 40 else b - 1 + step = centre % 64 + tri = step if step < 32 else 64 - step + g.set(ph4, ag.PHASER_FREQUENCY, 100.0 + 125.0 * tri) + + # the note line: a new note every few blocks, one mid-block; a bend that + # moves every sample; legato on two blocks in three + n = NOTES[(b // 3) % len(NOTES)] + for i in range(FRAMES): + note[i] = n + (2048 if b == 37 and i >= FRAMES // 2 else 0) + pitch[i] = note[i] + ((b * FRAMES + i) * 7) % 801 - 400 + flag = UNIT if b % 3 else 0 + if FAULT == "lag" and b == 21: + flag = UNIT - flag + for i in range(FRAMES): + legato[i] = flag + g.write(note_b, note) + g.write(pitch_b, pitch) + g.write(legato_b, legato) + + # a written clock: two edges in block 50, one in some others + late = 1 if FAULT == "hold" and b == 50 else 0 + for i in range(FRAMES): + high = False + if b == 50: + high = 10 + late <= i < 20 or 90 <= i < 100 + elif b % 9 == 0: + high = 30 <= i < 60 + clock[i] = UNIT if high else 0 + g.write(clock_b, clock) + + # the sine LFO turns random and back; the triangle starts again + switch = 60 if FAULT != "lfo" else 61 + if b == switch: + g.set(lfo_sine, ag.LFO_SHAPE, ag.LFO_RANDOM) + if b == 100: + g.set(lfo_sine, ag.LFO_SHAPE, ag.LFO_SAW) + if b == 80: + g.reset(lfos[0][0]) + if b == 120: + g.set(ph6, ag.PHASER_FEEDBACK, 0.625) + g.reset(sh_lag) + + g.run(None) + g.read(saw_b, saw_block) + for name, buffer in watched: + g.read(buffer, block) + print("node %d %s %s" % (b, name, " ".join([str(v) for v in block]))) + stats = seen.setdefault(name, [0, block[0], block[0], block[0]]) + for v in block: + if v != stats[3]: + stats[0] += 1 + stats[1] = min(stats[1], v) + stats[2] = max(stats[2], v) + stats[3] = v + if name == "phaser4" and block != saw_block: + moved = True + +check(seen["phaser4"][0] > 10000 and seen["phaser4"][2] > 4000, + "the swept phaser renders sound") +check(moved, "the phaser moves the sawtooth") +check(seen["lfo_random"][0] > 3, "the random LFO draws levels") +check(seen["lfo_sine"][2] > 8000 and seen["lfo_sine"][1] < -8000, + "the sine swings") +check(seen["lag_on"][0] > 1000, "a glide passes between notes") +check(seen["lag_pole"][0] > 1000, "the one-pole passes between notes") +check(seen["hold_clock"][0] > 10, "the clocked hold steps") +check(seen["hold_lag"][0] > 10 * seen["hold_clock"][0], "the lag slews") +check(seen["hold_written"][0] >= 10, "the written clock latches") +check(seen["hold_rate"][0] > 500, "the rate hold follows the sawtooth") + +if failures: + for what in failures: + print("FAIL " + what) + sys.exit(1) +print("GRAPH NODES PASS") diff --git a/tests/parity/verify_graph.py b/tests/parity/verify_graph.py index c4d6c21..3002321 100644 --- a/tests/parity/verify_graph.py +++ b/tests/parity/verify_graph.py @@ -4,12 +4,15 @@ verify_graph.py --micropython PATH [--circuitpython PATH] verify_graph.py --micropython PATH --fault lsb # must FAIL verify_graph.py --micropython PATH --fault late # must FAIL - -Runs ``graph_parity_probe.py`` on this CPython (``audiograph`` from the -installed wheel) and on each interpreter named, and compares the renders -sample for sample: every output sample of every block, the buffers' peaks and -a mono graph's blocks. The block headers (which voices ran, which were still -sounding), the parameters read back and the probe's own checks must match + verify_graph.py --micropython PATH --fault phaser # must FAIL (and lag, lfo, hold) + +Runs two probes on this CPython (``audiograph`` from the installed wheel) and +on each interpreter named, and compares the renders sample for sample. +``graph_parity_probe.py`` is the runner: every output sample of every block, +the buffers' peaks and a mono graph's blocks. ``graph_nodes_probe.py`` is the +phaser, the glide, the LFO's shapes and the sample-and-hold: every sample each +of them writes. The block headers (which voices ran, which were still +sounding), the parameters read back and the probes' own checks must match exactly. **The tolerance is 0 counts.** All three compile the same @@ -24,9 +27,9 @@ with itself. CI runs CPython against MicroPython; the three-way with the CircuitPython unix build is a local run, as for verify_dsp.py. -``--fault`` plants a mistake on every interpreter after the first (the probe's -docstring has what each one is), and the run must then fail. It is how this -gate shows it can. +``--fault`` plants a mistake on every interpreter after the first, in the +probe it belongs to (each probe's docstring has what its faults are), and the +run must then fail. It is how this gate shows it can. """ import argparse @@ -37,16 +40,22 @@ HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] -PROBE = HERE / "graph_parity_probe.py" +#: Each probe and the faults it can plant. +PROBES = ( + ("runner", HERE / "graph_parity_probe.py", ("lsb", "late")), + ("nodes", HERE / "graph_nodes_probe.py", ("phaser", "lag", "lfo", "hold")), +) +FAULTS = tuple(f for _, _, faults in PROBES for f in faults) #: Lines whose fields after the tag are samples, compared within the tolerance. -SAMPLE_LINES = ("pcm", "peaks", "mono") -#: Leading fields of a sample line that are not samples: a pcm line's block, -#: enable mask and sounding mask, compared exactly. -HEADER_FIELDS = {"pcm": 3, "peaks": 0, "mono": 0} +SAMPLE_LINES = ("pcm", "peaks", "mono", "node") +#: Leading fields of a sample line that are not samples, compared exactly: a +#: pcm line's block, enable mask and sounding mask; a node line's block and +#: node name. +HEADER_FIELDS = {"pcm": 3, "peaks": 0, "mono": 0, "node": 2} -def render(argv, fault, timeout): - command = argv + [str(PROBE)] + (["--fault", fault] if fault else []) +def render(argv, probe, fault, timeout): + command = argv + [str(probe)] + (["--fault", fault] if fault else []) environment = os.environ.copy() environment["MICROPYPATH"] = "%s:%s" % (ROOT, ROOT / "lib") result = subprocess.run(command, cwd=str(ROOT), env=environment, @@ -100,7 +109,7 @@ def main(): parser.add_argument("--circuitpython", help="a CircuitPython with audiograph") parser.add_argument("--tolerance", type=float, default=0.0, help="counts a sample may differ by (default 0)") - parser.add_argument("--fault", choices=("lsb", "late"), + parser.add_argument("--fault", choices=FAULTS, help="plant a mistake on every interpreter but the first") parser.add_argument("--timeout", type=float, default=300.0, help="seconds one render may take") @@ -117,30 +126,33 @@ def main(): raise SystemExit("this gate compares interpreters, so it needs at least " "two: pass --micropython PATH (and --circuitpython PATH)") - renders = {} failed = False - for position, (name, argv) in enumerate(interpreters): - fault = args.fault if position else None - code, lines = render(argv, fault, args.timeout) - renders[name] = lines - if code: - print("FAIL %-14s the probe failed its own checks (exit %d)" % (name, code)) - for line in lines[-6:]: - print(" " + line[:120]) - failed = True - reference = interpreters[0][0] - for name, _ in interpreters[1:]: - compared, differing, worst, first, mismatches = compare( - renders[reference], renders[name], args.tolerance) - verdict = "FAIL" if mismatches else "ok " - print("%s %s = %s: %d samples, %d differ, worst %g counts (tolerance %g)" - % (verdict, reference, name, compared, differing, worst, args.tolerance)) - if first: - print(" first difference at %s" % first) - for line in mismatches[:5]: - print(" " + line) - failed = failed or bool(mismatches) + for probe_name, probe, faults in PROBES: + renders = {} + for position, (name, argv) in enumerate(interpreters): + fault = args.fault if position and args.fault in faults else None + code, lines = render(argv, probe, fault, args.timeout) + renders[name] = lines + if code: + print("FAIL %-14s %s: the probe failed its own checks (exit %d)" + % (name, probe_name, code)) + for line in lines[-6:]: + print(" " + line[:120]) + failed = True + + for name, _ in interpreters[1:]: + compared, differing, worst, first, mismatches = compare( + renders[reference], renders[name], args.tolerance) + verdict = "FAIL" if mismatches else "ok " + print("%s %-6s %s = %s: %d samples, %d differ, worst %g counts " + "(tolerance %g)" % (verdict, probe_name, reference, name, + compared, differing, worst, args.tolerance)) + if first: + print(" first difference at %s" % first) + for line in mismatches[:5]: + print(" " + line) + failed = failed or bool(mismatches) if failed: print("GRAPH RENDERS DIFFER" + (" (fault planted: %s)" % args.fault diff --git a/tests/test_cpython_audiograph_nodes.py b/tests/test_cpython_audiograph_nodes.py new file mode 100644 index 0000000..822253b --- /dev/null +++ b/tests/test_cpython_audiograph_nodes.py @@ -0,0 +1,543 @@ +"""audiograph's PHASER, LAG, LFO shapes and SAMPLE_HOLD, held to what they stand in for. + +| node | held to | bar | +|---|---|---| +| `PHASER` | `audiobiquad.AllPass` on one channel, fed the same frequency, feedback and mix each 256-frame block | 0 counts | +| `LAG`, linear | vrack's `dsp/glide.py` `GlideCore` (OFF, ON, AUTO with legato, a new note mid-glide, the bend on top) | 0 counts | +| `LAG`, one-pole | vrack's `dsp/utility.py` `GlideCore` | 1 count | +| `LFO` triangle | the runner's triangle before the shapes existed, arithmetic for arithmetic | 0 counts | +| `LFO` sine, saw, square | vrack's `dsp/lfo.py` `LFOCore`'s shapes, on the runner's phase, over ten seconds | sine 1 count, saw and square 0 | +| `LFO` phase | `LFOCore`'s, at 0.1 to 30 Hz over ten seconds | 0.01 of a cycle (0.0065 measured) | +| `LFO` random | the square: a new level at each of its rising edges, held between | the same samples; levels in range and spread | +| `SAMPLE_HOLD`, clocked | vrack's `dsp/utility.py` `SampleAndHoldCore`, with no lag and with one | 0 counts; 1 count | +| `SAMPLE_HOLD`, two edges in a block | latches at each (`SampleAndHoldCore` keeps one a block) | the second edge's sample | +| `SAMPLE_HOLD`, rate | `audioshaper.SampleHold` at `num` = the sample rate, `den` = the rate | 0 counts | + +vrack's cores run on numpy float32 blocks, a whole block per vector operation. +They are transcribed below operation by operation, each rounded to float32 the +way numpy rounds it, so this file needs neither vrack nor numpy; where numpy +calls a transcendental (`sin`, `exp`) the transcription rounds the exact value +to float32, which numpy's own may miss by an ulp. The wire is 16384 counts to +1.0, a power of two, so a value scaled onto it rounds exactly as it did in +float32 and the comparison is in counts. +""" + +from array import array +import math +import unittest + +import audiobiquad +import audiocore +import audiograph as ag +import audioshaper + +RATE = 48000 +UNIT = ag.UNIT + +_F = array("f", [0.0]) + + +def f32(value): + """`value` rounded to float32, as a numpy float32 array stores it.""" + _F[0] = value + return _F[0] + + +def sat(value): + """A float on the wire as the runner stores it: rounded half to even.""" + value = round(value) + return max(-32768, min(32767, value)) + + +class Rig: + """A graph whose inputs Python writes each block and whose node it reads.""" + + def __init__(self, frames, kind, inputs, arg=0.0, open_=()): + self.frames = frames + self.g = ag.Graph(sample_rate=RATE, frames=frames, channel_count=1, + buffers=8, nodes=4) + self.ins = [None if i in open_ else self.g.buffer() for i in range(inputs)] + self.out = self.g.buffer() + self.node = self.g.add(kind, ag.ALWAYS, tuple(self.ins), self.out, arg) + self._block = array("h", bytes(2 * frames)) + + def set(self, index, value): + self.g.set(self.node, index, value) + + def run(self, *blocks): + for buffer, values in zip(self.ins, blocks): + if buffer is not None and values is not None: + self.g.write(buffer, array("h", values)) + self.g.run(None) + self.g.read(self.out, self._block) + return list(self._block) + + +def worst(one, two): + return max(abs(a - b) for a, b in zip(one, two)) + + +# ------------------------------------------------------------------ PHASER -- + +def program(blocks): + """A saw, a square and a little of each other, as integers, 0.45 FS.""" + values = array("h") + for i in range(blocks * 256): + saw = ((i * 211) % 4096) - 2048 + square = 2048 if (i // 37) % 2 else -2048 + values.append((saw * 3 + square * 2) * 14700 // 10240) + return values + + +class Phaser(unittest.TestCase): + + def render_both(self, stages, settings): + blocks = len(settings) + source = program(blocks) + ap = audiobiquad.AllPass(stages=stages, frequency=1000.0, feedback=0.0, + mix=0.5, sample_rate=RATE, channel_count=1) + ap.play(audiocore.RawSample(source, sample_rate=RATE, channel_count=1)) + rig = Rig(256, ag.PHASER, 1, float(stages)) + node_out, graph_out = [], [] + for index, (frequency, feedback, mix) in enumerate(settings): + ap.frequency, ap.feedback, ap.mix = frequency, feedback, mix + node_out += list(array("h", bytes(audiocore.get_buffer(ap)[1]))) + rig.set(ag.PHASER_FREQUENCY, frequency) + rig.set(ag.PHASER_FEEDBACK, feedback) + rig.set(ag.PHASER_MIX, mix) + graph_out += rig.run(source[index * 256:(index + 1) * 256]) + return node_out, graph_out + + def test_matches_allpass_through_a_sweep(self): + # Poly6's phase mode: a triangle sweep of the centre from 80 Hz up by + # its intensity's ratio, set once a 256-frame block, feedback up to + # 0.6 and the mix at 0.5, then a change of mind mid-render. + settings = [] + for block in range(120): + p = (block * 0.031) % 1.0 + tri = 2.0 * p if p < 0.5 else 2.0 * (1.0 - p) + frequency = 80.0 * ((4200.0 / 80.0) ** tri) + feedback = 0.6 if block < 80 else -0.35 + mix = 0.5 if block < 100 else 0.8 + settings.append((frequency, feedback, mix)) + node_out, graph_out = self.render_both(4, settings) + self.assertGreater(max(abs(v) for v in graph_out), 4000) + self.assertEqual(worst(node_out, graph_out), 0) + + def test_other_stage_counts(self): + for stages in (1, 6): + settings = [(300.0 + 40.0 * b, 0.25, 0.5) for b in range(20)] + node_out, graph_out = self.render_both(stages, settings) + self.assertEqual(worst(node_out, graph_out), 0, stages) + + def test_built_with_no_stages_is_four(self): + settings = [(700.0, 0.5, 0.5)] * 6 + node_out, _ = self.render_both(4, settings) + rig = Rig(256, ag.PHASER, 1) + rig.set(ag.PHASER_FREQUENCY, 700.0) + rig.set(ag.PHASER_FEEDBACK, 0.5) + rig.set(ag.PHASER_MIX, 0.5) + source = program(6) + out = [] + for index in range(6): + out += rig.run(source[index * 256:(index + 1) * 256]) + self.assertEqual(out, node_out) + + +# --------------------------------------------------------------------- LAG -- + +class GlideLinear: + """vrack's dsp/glide.py GlideCore, in counts, numpy's float32 rounding.""" + + def __init__(self, mode, time): + self.mode, self.time = mode, time + self.value = None + self.target = 0.0 + self.slope = 0.0 + self.left = 0 + + def process(self, note, pitch=None, legato=0.0): + n = len(note) + target = f32(note[0]) + if self.value is None: + self.value = self.target = target + if target != self.target: + self.target = target + steps = int(self.time * RATE) + if steps >= 1 and (self.mode == 1 or (self.mode == 2 and legato > 0.5)): + self.left = steps + self.slope = (target - self.value) / steps + else: + self.left = 0 + self.value = target + if self.left > 0: + k = min(n, self.left) + slope, start = f32(self.slope), f32(self.value) + dst = [f32(f32((i + 1) * slope) + start) for i in range(k)] + last = dst[k - 1] + self.left -= k + if self.left == 0: + last = target + dst[k - 1] = target + self.value = last + dst += [target] * (n - k) + else: + dst = [f32(self.value)] * n + if pitch is not None: + dst = [f32(f32(d + p) - m) for d, p, m in zip(dst, pitch, note)] + return dst + + +class GlideOnePole: + """vrack's dsp/utility.py GlideCore, in counts, float32 rounding.""" + + def __init__(self, time): + self.time = time + self.value = 0.0 + + def process(self, src): + n = len(src) + if self.time <= 0.001: + self.value = float(src[n - 1]) + return [float(v) for v in src] + coeff = math.exp(-1.0 / (self.time * RATE * 0.35)) + start, target = self.value, float(src[0]) + log = f32(math.log(coeff)) + dst = [] + for k in range(n): + v = f32(math.exp(f32((k + 1) * log))) + v = f32(f32(v * f32(start - target)) + f32(target)) + dst.append(v) + self.value = dst[n - 1] + return dst + + +def phrase(frames, blocks): + """(note, pitch, legato) blocks: notes that step at block boundaries, one + that steps mid-block, a bend that moves every sample, legato on some.""" + notes = [0, 0, 4096, 4096, 4096, 4096, -2731, -2731, 1365, 1365, 1365, + 9557, 9557, 9557, 9557, 9557, -8192, -8192, -8192, -8192] + out = [] + for b in range(blocks): + note = notes[b % len(notes)] + block_note = [note] * frames + if b == 13: + block_note[frames // 2:] = [note + 2048] * (frames - frames // 2) + bend = [((b * frames + i) * 7) % 801 - 400 for i in range(frames)] + pitch = [n + d for n, d in zip(block_note, bend)] + legato = [UNIT if b % 3 else 0] * frames + out.append((block_note, pitch, legato)) + return out + + +class Lag(unittest.TestCase): + + def glide(self, mode, time, frames=128, blocks=60, bend=True): + rig = Rig(frames, ag.LAG, 3) + rig.set(ag.LAG_MODE, mode) + rig.set(ag.LAG_TIME, time) + ref = GlideLinear(mode, time) + got, want = [], [] + for note, pitch, legato in phrase(frames, blocks): + got += rig.run(note, pitch if bend else note, legato) + want += [sat(v) for v in ref.process( + note, pitch if bend else None, legato[0] / UNIT)] + return got, want + + def test_glide_on_matches_glidecore(self): + # 0.015625 s is 750 samples: a glide spans blocks and ends mid-block, + # and several notes arrive before the last one arrived. + got, want = self.glide(ag.LAG_ON, 0.015625) + self.assertEqual(worst(got, want), 0) + self.assertNotEqual(got, self.glide(ag.LAG_OFF, 0.015625)[0]) + + def test_glide_off_and_auto_match_glidecore(self): + for mode in (ag.LAG_OFF, ag.LAG_AUTO): + for frames in (64, 128, 256): + got, want = self.glide(mode, 0.03125, frames=frames) + self.assertEqual(worst(got, want), 0, (mode, frames)) + + def test_glide_without_a_bend(self): + got, want = self.glide(ag.LAG_ON, 0.0078125, bend=False) + self.assertEqual(worst(got, want), 0) + + def test_one_pole_matches_utility_glidecore(self): + for time in (0.0, 0.004, 0.05, 0.4): + rig = Rig(128, ag.LAG, 1) + rig.set(ag.LAG_SHAPE, ag.LAG_ONE_POLE) + rig.set(ag.LAG_TIME, time) + ref = GlideOnePole(f32(time)) + got, want = [], [] + for note, _, _ in phrase(128, 80): + got += rig.run(note) + want += [sat(v) for v in ref.process(note)] + self.assertLessEqual(worst(got, want), 1, time) + + def test_reset_forgets_the_pitch(self): + rig = Rig(128, ag.LAG, 1) + rig.set(ag.LAG_TIME, 0.5) + rig.run([0] * 128) + self.assertNotEqual(rig.run([8192] * 128)[-1], 8192) + rig.g.reset(rig.node) + self.assertEqual(rig.run([8192] * 128), [8192] * 128) + + +# --------------------------------------------------------------------- LFO -- + +class LFORef: + """vrack's dsp/lfo.py LFOCore, numpy's float32 rounding, at +-0.5.""" + + TWO_PI = f32(2.0 * math.pi) + + def __init__(self, frames): + self.n = frames + self.phase = 0.0 + + def render(self, rate): + dt = f32(rate / float(RATE)) + start = f32(self.phase) + ph = [f32(f32((k + 1) * dt) + start) for k in range(self.n)] + ph = [f32(p - math.floor(p)) for p in ph] + self.phase = ph[-1] + return ph + + @classmethod + def sine(cls, p): + return f32(f32(math.sin(f32(p * cls.TWO_PI))) * 0.5) + + @staticmethod + def saw(p): + return f32(f32(f32(p * -2.0) + 1.0) * 0.5) + + @staticmethod + def square(p): + return f32(f32(f32(math.floor(f32(p * 2.0)) * -2.0) + 1.0) * 0.5) + + +def runner_phases(rate, samples): + """The runner's phase, as it has always advanced it: one step a sample, + wrapped each sample, in float32.""" + dt = f32(f32(rate) / f32(RATE)) + phase, out = 0.0, [] + for _ in range(samples): + phase = f32(phase + dt) + phase = f32(phase - math.floor(phase)) + out.append(phase) + return out + + +def runner_triangle(rate, amp, samples): + """The triangle as the runner computed it before it had other shapes.""" + out = [] + for phase in runner_phases(rate, samples): + tri = f32(f32(4.0 * phase) - 2.0) + tri = f32(1.0 - abs(tri)) + out.append(sat(f32(tri * amp))) + return out + + +class Lfo(unittest.TestCase): + """The shapes are LFOCore's, each a function of the phase. The phase is + the triangle's, which this change keeps to the bit, so a saw, a square and + a triangle at one rate stay one LFO. It is not quite LFOCore's phase: + LFOCore restarts a ramp from the phase each block and stays within 5e-5 + of a cycle of exact, where the runner adds a float32 step each sample and + runs up to 0.06% off its rate (0.0065 of a cycle over ten seconds at + 1 Hz). Matching LFOCore's phase would move the triangle.""" + + def render(self, shape, rate, frames=128, blocks=3750, amplitude=UNIT / 2): + rig = Rig(frames, ag.LFO, 0) + rig.set(ag.LFO_RATE, rate) + rig.set(ag.LFO_AMPLITUDE, amplitude) + if shape is not None: + rig.set(ag.LFO_SHAPE, shape) + out = [] + for _ in range(blocks): + out += rig.run() + return out + + def test_the_triangle_has_not_moved(self): + for rate, amp in ((5.0, 8192.0), (0.37, 1024.0), (23.0, 16384.0)): + got = self.render(None, rate, blocks=400, amplitude=amp) + self.assertEqual(got, runner_triangle(rate, amp, 128 * 400), rate) + same = self.render(ag.LFO_TRIANGLE, rate, blocks=400, amplitude=amp) + self.assertEqual(got, same) + + def test_sine_saw_and_square_are_lfocores_shapes(self): + for wave, shape, bar in ((LFORef.sine, ag.LFO_SINE, 1), + (LFORef.saw, ag.LFO_SAW, 0), + (LFORef.square, ag.LFO_SQUARE, 0)): + for rate in (0.1, 5.5, 30.0): + got = self.render(shape, rate) + want = [sat(f32(wave(p) * UNIT)) + for p in runner_phases(rate, len(got))] + self.assertLessEqual(worst(got, want), bar, (shape, rate)) + + def test_the_phase_tracks_lfocores(self): + for rate in (0.1, 0.37, 5.5, 30.0): + core, theirs = LFORef(128), [] + for _ in range(3750): + theirs += core.render(rate) + ours = runner_phases(rate, len(theirs)) + gap = max(min(abs(a - b), 1.0 - abs(a - b)) + for a, b in zip(ours, theirs)) + self.assertLess(gap, 0.01, rate) + + def test_random_changes_at_each_square_rising_edge(self): + rate = 29.75 + got = self.render(ag.LFO_RANDOM, rate, blocks=3000) + square = [LFORef.square(p) for p in runner_phases(rate, len(got))] + rises = [i for i in range(len(square)) + if square[i] > 0 and (i == 0 or square[i - 1] < 0)] + changes = [i for i in range(len(got)) if i == 0 or got[i] != got[i - 1]] + self.assertEqual(changes, rises) + levels = [got[i] for i in rises] + self.assertTrue(all(-8192 <= v <= 8192 for v in levels)) + # 238 draws from 16385 levels: a repeat or two is the birthday odds. + self.assertGreater(len(set(levels)), len(levels) * 95 // 100) + self.assertLess(abs(sum(levels) / len(levels)), 1200) + self.assertGreater(max(levels), 6000) + self.assertLess(min(levels), -6000) + + def test_random_draws_at_once_when_chosen(self): + rig = Rig(128, ag.LFO, 0) + rig.set(ag.LFO_RATE, 1.0) + rig.run() + rig.set(ag.LFO_SHAPE, ag.LFO_RANDOM) + first = rig.run() + self.assertNotEqual(first[0], 0) + self.assertEqual(set(first), {first[0]}) + + def test_shapes_share_one_phase(self): + # A saw and a square at one rate are one LFO's two outputs: the + # square is high exactly while the saw is above zero. + saw = self.render(ag.LFO_SAW, 3.0, blocks=200) + square = self.render(ag.LFO_SQUARE, 3.0, blocks=200) + for s, q in zip(saw, square): + if abs(s) > 2: + self.assertEqual(s > 0, q > 0) + + +# ------------------------------------------------------------- SAMPLE_HOLD -- + +class HoldRef: + """vrack's dsp/utility.py SampleAndHoldCore with its EdgeDetector, in + counts: one rising edge a block, the clock high above 0.1.""" + + def __init__(self): + self.high = False + self.held = 0.0 + self.out = 0.0 + + def render(self, signal, clock, lag): + n = len(clock) + step = [c > 0.1 * UNIT for c in clock] + edge = -1 + if step[0] and not self.high: + edge = 0 + else: + for i in range(1, n): + if step[i] and not step[i - 1]: + edge = i + break + self.high = step[n - 1] + dst = [0.0] * n + if edge < 0: + self.segment(dst, 0, n, lag) + return dst + self.segment(dst, 0, edge, lag) + self.held = float(signal[edge]) + self.segment(dst, edge, n, lag) + return dst + + def segment(self, dst, start, end, lag): + if end <= start: + return + if lag <= 0.0: + for i in range(start, end): + dst[i] = self.held + self.out = self.held + return + log = f32(math.log(lag)) + begin, target = self.out, self.held + for k in range(end - start): + v = f32(math.exp(f32((k + 1) * log))) + dst[start + k] = f32(f32(v * f32(begin - target)) + f32(target)) + self.out = dst[end - 1] + + +def held_signal(frames, blocks): + return [[((b * frames + i) * 389) % 30001 - 15000 for i in range(frames)] + for b in range(blocks)] + + +def clock_blocks(frames, blocks, period, width): + out = [] + for b in range(blocks): + out.append([UNIT if ((b * frames + i) % period) < width else 0 + for i in range(frames)]) + return out + + +class SampleHold(unittest.TestCase): + + def hold(self, lag, frames=128, blocks=80, period=301, width=40): + rig = Rig(frames, ag.SAMPLE_HOLD, 2) + rig.set(ag.SH_LAG, lag) + ref = HoldRef() + got, want = [], [] + for signal, clock in zip(held_signal(frames, blocks), + clock_blocks(frames, blocks, period, width)): + got += rig.run(signal, clock) + want += [sat(v) for v in ref.render(signal, clock, f32(lag))] + return got, want + + def test_matches_sampleandholdcore(self): + got, want = self.hold(0.0) + self.assertEqual(worst(got, want), 0) + self.assertGreater(len(set(got)), 30) + + def test_with_a_lag(self): + for lag in (0.5, 0.99, 0.999): + got, want = self.hold(lag) + self.assertLessEqual(worst(got, want), 1, lag) + + def test_every_edge_in_a_block_latches(self): + rig = Rig(128, ag.SAMPLE_HOLD, 2) + signal = list(range(0, 1280, 10)) + clock = [UNIT if 10 <= i < 20 or 70 <= i < 80 else 0 for i in range(128)] + out = rig.run(signal, clock) + self.assertEqual(out[9], 0) + self.assertEqual(out[10:70], [100] * 60) + self.assertEqual(out[70:], [700] * 58) + + def test_rate_matches_audioshaper(self): + for rate in (26040, 9000, 48000): + blocks = 24 + source = array("h", [v for block in held_signal(128, blocks) for v in block]) + node = audioshaper.SampleHold( + audiocore.RawSample(source, sample_rate=RATE, channel_count=1), + num=RATE, den=rate) + want = [] + while len(want) < len(source): + want += list(array("h", bytes(audiocore.get_buffer(node)[1]))) + rig = Rig(128, ag.SAMPLE_HOLD, 2, open_=(1,)) + rig.set(ag.SH_RATE, rate) + got = [] + for b in range(blocks): + got += rig.run(source[b * 128:(b + 1) * 128], None) + self.assertEqual(got, want[:len(got)], rate) + + def test_open_clock_and_no_rate_holds(self): + g = ag.Graph(sample_rate=RATE, frames=128, channel_count=1, buffers=2, nodes=1) + src, out = g.buffer(), g.buffer() + g.add(ag.SAMPLE_HOLD, ag.ALWAYS, (src, None), out) + g.write(src, array("h", [999] * 128)) + g.run(None) + block = array("h", bytes(256)) + g.read(out, block) + self.assertEqual(list(block), [0] * 128) + + +if __name__ == "__main__": + unittest.main()