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()