diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index b0781e0..d101996 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -238,8 +238,9 @@ jobs: # 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, and - # one for each of the phaser, the glide, the LFO shapes and the - # sample-and-hold, and two for a graph pulled as a source: its queue + # one for each of the phaser, the glide, the LFO shapes, the + # sample-and-hold, the two-pole filters and the overdrive, and two for a + # graph pulled as a source: its queue # drained after the render, and a batch published in halves) 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 @@ -249,7 +250,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 phaser lag lfo hold drain split; do + for planted in lsb late phaser lag lfo hold twopole overdrive drain split; 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/circuitpython.mk b/circuitpython.mk index d4197a1..59e3f67 100644 --- a/circuitpython.mk +++ b/circuitpython.mk @@ -62,6 +62,10 @@ AUDIODSP_CP_SOURCES += $(addprefix $(AUDIODSP_CP_SRC)/shared/, \ audiodsp_graph.c \ ) +# The graph's OVERDRIVE node needs double-precision libm the RP2040 build +# cannot link (audiodsp_graph_overdrive.h), so CircuitPython leaves it out. +CFLAGS_USERMOD += -DAUDIODSP_GRAPH_HAS_OVERDRIVE=0 + SRC_USERMOD_C += $(AUDIODSP_CP_SOURCES) # CircuitPython's board ports append -Werror=float-equal and diff --git a/docs/how-it-works/audiograph.md b/docs/how-it-works/audiograph.md index d3c7248..62dff6f 100644 --- a/docs/how-it-works/audiograph.md +++ b/docs/how-it-works/audiograph.md @@ -4,7 +4,8 @@ the graph once, from nodes that call the kernels audiodsp already has (the 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 +an LFO, noise, a mixer, a VCA, a glide, a clipping two-pole filter, an +overdrive), 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. @@ -74,7 +75,7 @@ rather than its last block. | kind | inputs | parameters | out | |---|---|---|---| | `CONST` | | 0: the value, in counts | that value every sample | -| `OSC` | pitch, fm, pwm, sync | `OSC_FREQUENCY`, `OSC_WAVEFORM` (1 saw, 2 pulse, 3 both), `OSC_PULSE_WIDTH`, `OSC_LEVEL`, `OSC_PITCH_OCTAVES`, `OSC_FM_DEPTH`, `OSC_PWM_DEPTH` | audiooscillator's band-limited waveform | +| `OSC` | pitch, fm, pwm, sync | `OSC_FREQUENCY`, `OSC_WAVEFORM` (1 saw, 2 pulse, 4 triangle, 8 sine; a sum plays those added), `OSC_PULSE_WIDTH`, `OSC_LEVEL`, `OSC_PITCH_OCTAVES`, `OSC_FM_DEPTH`, `OSC_PWM_DEPTH` | audiooscillator's band-limited waveform | | `LADDER` | audio, cutoff | `LADDER_CUTOFF_HZ`, `LADDER_RESONANCE`, `LADDER_DRIVE`, `LADDER_POLES`, `LADDER_PASSBAND_COMP`, `LADDER_OVERSAMPLE`, `LADDER_MIX`, `LADDER_CUTOFF_OCTAVES` | audioladder's filter, the cutoff input read every sample | | `BIQUAD` | audio | `BIQUAD_MODE`, `BIQUAD_FREQUENCY`, `BIQUAD_Q`, `BIQUAD_GAIN_DB`, `BIQUAD_MIX` | audiobiquad's float biquad | | `FBDELAY` | audio | `DELAY_MS`, `DELAY_FEEDBACK`, `DELAY_MIX`, `DELAY_DAMPING_HZ`, `DELAY_WOW_HZ`, `DELAY_WOW_DEPTH_MS` and audioecho's other options by number; built with `arg` = the line's length in ms; `table(node, int16_array)` sets its wow shape | audioecho's FeedbackDelay | @@ -82,11 +83,13 @@ rather than its last block. | `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 | +| `VCA` | audio, cv_a, cv_b | `VCA_GAIN`, `VCA_SELECT` (cv_a below 0.5, else cv_b), `VCA_CURVE` (`VCA_LINEAR`, `VCA_EXPONENTIAL`) | audio times the chosen CV over `UNIT`, times the gain; exponential is 60 dB over the CV, `10^(3·(cv/UNIT − 1))`, unity at `UNIT` | | `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) | +| `TWOPOLE` | audio, cutoff CV | `TWOPOLE_TYPE` (`TWOPOLE_SK_LOWPASS`, `TWOPOLE_SK_HIGHPASS`, `TWOPOLE_CMOS_LOWPASS`, `TWOPOLE_CMOS_BANDPASS`, `TWOPOLE_CMOS_HIGHPASS`), `TWOPOLE_CUTOFF_HZ`, `TWOPOLE_DEPTH`, `TWOPOLE_RESONANCE` 0..1, `TWOPOLE_DRIVE` | a 12 dB/octave filter with its nonlinearity inside the loop, [below](#two-pole-filters) | +| `OVERDRIVE` | audio | `OVERDRIVE_DRIVE` 0..1, `OVERDRIVE_TONE` 0..1, `OVERDRIVE_LEVEL` (linear, 1 is unity) | `audiofilters.Distortion`'s soft-clipped overdrive, then a high shelf, [below](#overdrive) | The kernel nodes take their parameters under the same numbers as the modules that wrap them (`audiooscillator`, `audioladder`, `audiobiquad`, @@ -135,6 +138,44 @@ 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. +### Two-pole filters + +`TWOPOLE` is three filters that keep their character in a clipper inside the +feedback loop, so a biquad cannot stand in for them: the Sallen-Key low-pass +(`TWOPOLE_SK_LOWPASS`) and high-pass (`TWOPOLE_SK_HIGHPASS`), whose peak feedback +runs through a diode clipper and whose output goes through `tanh`; and the CMOS +state-variable filter, with a clipping inverter in every stage, as a low-pass, +band-pass or high-pass (`TWOPOLE_CMOS_*`). They are vrack's `SallenKeyLPFCore`, +`SallenKeyHPFCore` and `CMOSFilterCore`, one node with a type so a patch can +change the type of a running filter and the cutoff code is one function. + +`TWOPOLE_RESONANCE` is the Sallen-Key's peak or the CMOS filter's resonance, +both 0..1. `TWOPOLE_DRIVE` multiplies the input before the filter. The cutoff +is `TWOPOLE_CUTOFF_HZ` times two to the power of the cutoff CV over `UNIT` +times `TWOPOLE_DEPTH` times ten, so a depth of 1 is an octave per 1638.4 counts +of CV, 0 ignores the input, and the result is held between 20 Hz and 0.45 of +the sample rate. The CV is read at the block's first and last samples, and the +filter's coefficient walks in a straight line between them, as these filters +have always taken it: what lies between those two samples does not move the +cutoff. An open CV input leaves the cutoff where it is. + +### Overdrive + +`OVERDRIVE` is `audiofilters.Distortion` in overdrive mode with soft clipping, +followed by a high shelf at 1.5 kHz, the way vrack's `OverdriveCore` chains them. +`OVERDRIVE_DRIVE` 0..1 sets a pre-gain of up to 20 dB, `OVERDRIVE_LEVEL` is the +linear gain after the clipper, and `OVERDRIVE_TONE` 0..1 is the shelf's gain, +from −12 dB through flat at 0.5 to +12 dB. The shelf is `synthio.Biquad`'s: the +node runs CircuitPython's own Q15 arithmetic, as `audiofilters.Filter` does, and +squeezes a sample past ±28000 toward the rail as that filter's last step does, +so a loud signal tops out at 28521. + +CircuitPython's `audiograph` has no `OVERDRIVE`. The node computes its gains +in double precision, and the RP2040 build has no double-precision `pow` or +`log10` to link against. Check with `hasattr(audiograph, "OVERDRIVE")`. On +CircuitPython, `audiofilters.Distortion` and `synthio.Biquad` are already built +in, and you can chain them yourself. + ## Output `run(out, block, enable)` writes the `OUT` node's block into `out` at block @@ -233,16 +274,22 @@ output reads, makes it fail; CI runs the faults first. 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 +graph); `graph_nodes_probe.py` is the phaser, the glide, every LFO shape, the +sample-and-hold, the five two-pole types and the overdrive. 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 +a shape switched a block late, a clock edge a sample late, a cutoff CV a block +late, an overdrive's tone a block 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`](../../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. +LFO's shapes, the sample-and-hold and the two-pole filters to the Python cores of +the same name in vrack, and `OVERDRIVE` to `OverdriveCore`'s own chain of +`audiofilters` nodes, with each bar in the file's table. The CMOS filter and +the overdrive match at 0 counts. The Sallen-Key pair matches within one count, +on under one sample in a hundred: the node's `tanh` is a rational function in +float that is the same on every target, where vrack's is the C library's in +double. diff --git a/micropython.cmake b/micropython.cmake index 8ee9007..0d0be95 100644 --- a/micropython.cmake +++ b/micropython.cmake @@ -129,6 +129,7 @@ target_sources(usermod_mpaudio INTERFACE ${MPAUDIO_SRC_DIR}/audiosampler/SamplerVoice.c ${MPAUDIO_SRC_DIR}/audiosampler/module.c ${MPAUDIO_SRC_DIR}/shared/audiodsp_graph.c + ${MPAUDIO_SRC_DIR}/shared/audiodsp_graph_overdrive.c ${MPAUDIO_SRC_DIR}/audiograph/Graph.c ${MPAUDIO_SRC_DIR}/audiograph/module.c ) diff --git a/micropython.mk b/micropython.mk index a09605d..411e1e7 100644 --- a/micropython.mk +++ b/micropython.mk @@ -288,6 +288,7 @@ SRC_USERMOD_C += \ # from the kernels above. MicroPython only (docs/how-it-works/audiograph.md). SRC_USERMOD_C += \ $(MPAUDIO_SRC_DIR)/shared/audiodsp_graph.c \ + $(MPAUDIO_SRC_DIR)/shared/audiodsp_graph_overdrive.c \ $(MPAUDIO_SRC_DIR)/audiograph/Graph.c \ $(MPAUDIO_SRC_DIR)/audiograph/module.c diff --git a/setup.py b/setup.py index aa6605b..03c4730 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,7 @@ "src/shared/audiodsp_sampler.c", "src/shared/audiodsp_meter.c", "src/shared/audiodsp_graph.c", + "src/shared/audiodsp_graph_overdrive.c", # Xiph's Tremor, included into this one file from # third_party/tremor; docs/reference/vorbis.md. "src/shared/audiodsp_vorbis.c", diff --git a/src/audiograph/module.c b/src/audiograph/module.c index a094479..9db9f42 100644 --- a/src/audiograph/module.c +++ b/src/audiograph/module.c @@ -26,6 +26,8 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_TWOPOLE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE) }, + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE) }, { 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 @@ -39,6 +41,9 @@ 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_VCA_CURVE), MP_ROM_INT(AUDIODSP_GRAPH_VCA_CURVE) }, + { MP_ROM_QSTR(MP_QSTR_VCA_LINEAR), MP_ROM_INT(AUDIODSP_GRAPH_VCA_LINEAR) }, + { MP_ROM_QSTR(MP_QSTR_VCA_EXPONENTIAL), MP_ROM_INT(AUDIODSP_GRAPH_VCA_EXPONENTIAL) }, { 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) }, @@ -55,6 +60,19 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_TYPE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_TYPE) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CUTOFF_HZ), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CUTOFF_HZ) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_DEPTH), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_DEPTH) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_RESONANCE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_RESONANCE) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_DRIVE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_SK_LOWPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_SK_HIGHPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_SK_HIGHPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CMOS_LOWPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CMOS_LOWPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CMOS_BANDPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CMOS_BANDPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CMOS_HIGHPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CMOS_HIGHPASS) }, + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE_DRIVE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE_TONE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE_TONE) }, + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE_LEVEL), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE_LEVEL) }, // 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) }, diff --git a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c index 878ec60..b9c231e 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/Graph.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/Graph.c @@ -230,7 +230,7 @@ static int16_t buffer_id(mp_obj_t obj) { static mp_obj_t audiograph_graph_add(size_t n_args, const mp_obj_t *args) { audiograph_graph_obj_t *self = graph_self(args[0]); mp_int_t kind = mp_obj_get_int(args[1]); - if (kind < 0 || kind >= (mp_int_t)AUDIODSP_GRAPH_KINDS) { + if (kind < 0 || !audiodsp_graph_kind_built((audiodsp_graph_kind_t)kind)) { mp_raise_ValueError(MP_ERROR_TEXT("unknown node kind")); } mp_int_t group = mp_obj_get_int(args[2]); diff --git a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c index ba0ada4..9bad4e1 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c @@ -9,6 +9,7 @@ #include "shared-bindings/audiograph/__init__.h" #include "shared-bindings/audiograph/Graph.h" +#include "shared/audiodsp_graph_overdrive.h" //| """The graph runner: a whole patch rendered by one C call per block //| @@ -39,6 +40,10 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_TWOPOLE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE) }, + #if AUDIODSP_GRAPH_HAS_OVERDRIVE + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE) }, + #endif { 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 @@ -52,6 +57,9 @@ 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_VCA_CURVE), MP_ROM_INT(AUDIODSP_GRAPH_VCA_CURVE) }, + { MP_ROM_QSTR(MP_QSTR_VCA_LINEAR), MP_ROM_INT(AUDIODSP_GRAPH_VCA_LINEAR) }, + { MP_ROM_QSTR(MP_QSTR_VCA_EXPONENTIAL), MP_ROM_INT(AUDIODSP_GRAPH_VCA_EXPONENTIAL) }, { 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) }, @@ -68,6 +76,21 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_TYPE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_TYPE) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CUTOFF_HZ), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CUTOFF_HZ) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_DEPTH), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_DEPTH) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_RESONANCE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_RESONANCE) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_DRIVE), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_SK_LOWPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_SK_HIGHPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_SK_HIGHPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CMOS_LOWPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CMOS_LOWPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CMOS_BANDPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CMOS_BANDPASS) }, + { MP_ROM_QSTR(MP_QSTR_TWOPOLE_CMOS_HIGHPASS), MP_ROM_INT(AUDIODSP_GRAPH_TWOPOLE_CMOS_HIGHPASS) }, + #if AUDIODSP_GRAPH_HAS_OVERDRIVE + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE_DRIVE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE_TONE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE_TONE) }, + { MP_ROM_QSTR(MP_QSTR_OVERDRIVE_LEVEL), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE_LEVEL) }, + #endif // 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) }, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index fdc6de0..124ea3f 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -4167,6 +4167,8 @@ static int graph_add_constants(PyObject *module) { {"PHASER", AUDIODSP_GRAPH_PHASER}, {"LAG", AUDIODSP_GRAPH_LAG}, {"SAMPLE_HOLD", AUDIODSP_GRAPH_SAMPLE_HOLD}, + {"TWOPOLE", AUDIODSP_GRAPH_TWOPOLE}, + {"OVERDRIVE", AUDIODSP_GRAPH_OVERDRIVE}, {"ALWAYS", AUDIODSP_GRAPH_ALWAYS}, {"UNIT", 16384}, {"ENV_ATTACK", AUDIODSP_GRAPH_ENV_ATTACK}, @@ -4178,6 +4180,9 @@ 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}, + {"VCA_CURVE", AUDIODSP_GRAPH_VCA_CURVE}, + {"VCA_LINEAR", AUDIODSP_GRAPH_VCA_LINEAR}, + {"VCA_EXPONENTIAL", AUDIODSP_GRAPH_VCA_EXPONENTIAL}, {"LFO_SHAPE", AUDIODSP_GRAPH_LFO_SHAPE}, {"LFO_TRIANGLE", AUDIODSP_GRAPH_LFO_TRIANGLE}, {"LFO_SINE", AUDIODSP_GRAPH_LFO_SINE}, @@ -4194,6 +4199,19 @@ static int graph_add_constants(PyObject *module) { {"LAG_ONE_POLE", AUDIODSP_GRAPH_LAG_ONE_POLE}, {"SH_LAG", AUDIODSP_GRAPH_SH_LAG}, {"SH_RATE", AUDIODSP_GRAPH_SH_RATE}, + {"TWOPOLE_TYPE", AUDIODSP_GRAPH_TWOPOLE_TYPE}, + {"TWOPOLE_CUTOFF_HZ", AUDIODSP_GRAPH_TWOPOLE_CUTOFF_HZ}, + {"TWOPOLE_DEPTH", AUDIODSP_GRAPH_TWOPOLE_DEPTH}, + {"TWOPOLE_RESONANCE", AUDIODSP_GRAPH_TWOPOLE_RESONANCE}, + {"TWOPOLE_DRIVE", AUDIODSP_GRAPH_TWOPOLE_DRIVE}, + {"TWOPOLE_SK_LOWPASS", AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS}, + {"TWOPOLE_SK_HIGHPASS", AUDIODSP_GRAPH_TWOPOLE_SK_HIGHPASS}, + {"TWOPOLE_CMOS_LOWPASS", AUDIODSP_GRAPH_TWOPOLE_CMOS_LOWPASS}, + {"TWOPOLE_CMOS_BANDPASS", AUDIODSP_GRAPH_TWOPOLE_CMOS_BANDPASS}, + {"TWOPOLE_CMOS_HIGHPASS", AUDIODSP_GRAPH_TWOPOLE_CMOS_HIGHPASS}, + {"OVERDRIVE_DRIVE", AUDIODSP_GRAPH_OVERDRIVE_DRIVE}, + {"OVERDRIVE_TONE", AUDIODSP_GRAPH_OVERDRIVE_TONE}, + {"OVERDRIVE_LEVEL", AUDIODSP_GRAPH_OVERDRIVE_LEVEL}, {"OSC_FREQUENCY", AUDIODSP_OSCILLATOR_OPT_FREQUENCY}, {"OSC_WAVEFORM", AUDIODSP_OSCILLATOR_OPT_WAVEFORM}, {"OSC_PULSE_WIDTH", AUDIODSP_OSCILLATOR_OPT_PULSE_WIDTH}, diff --git a/src/cpython/audiograph.py b/src/cpython/audiograph.py index 3aa9b7b..340e8c5 100644 --- a/src/cpython/audiograph.py +++ b/src/cpython/audiograph.py @@ -51,6 +51,8 @@ PHASER = _K["PHASER"] LAG = _K["LAG"] SAMPLE_HOLD = _K["SAMPLE_HOLD"] +TWOPOLE = _K["TWOPOLE"] +OVERDRIVE = _K["OVERDRIVE"] ALWAYS = _K["ALWAYS"] UNIT = _K["UNIT"] @@ -64,6 +66,9 @@ MIX_OFFSET = _K["MIX_OFFSET"] VCA_GAIN = _K["VCA_GAIN"] VCA_SELECT = _K["VCA_SELECT"] +VCA_CURVE = _K["VCA_CURVE"] +VCA_LINEAR = _K["VCA_LINEAR"] +VCA_EXPONENTIAL = _K["VCA_EXPONENTIAL"] LFO_SHAPE = _K["LFO_SHAPE"] LFO_TRIANGLE = _K["LFO_TRIANGLE"] LFO_SINE = _K["LFO_SINE"] @@ -80,6 +85,19 @@ LAG_ONE_POLE = _K["LAG_ONE_POLE"] SH_LAG = _K["SH_LAG"] SH_RATE = _K["SH_RATE"] +TWOPOLE_TYPE = _K["TWOPOLE_TYPE"] +TWOPOLE_CUTOFF_HZ = _K["TWOPOLE_CUTOFF_HZ"] +TWOPOLE_DEPTH = _K["TWOPOLE_DEPTH"] +TWOPOLE_RESONANCE = _K["TWOPOLE_RESONANCE"] +TWOPOLE_DRIVE = _K["TWOPOLE_DRIVE"] +TWOPOLE_SK_LOWPASS = _K["TWOPOLE_SK_LOWPASS"] +TWOPOLE_SK_HIGHPASS = _K["TWOPOLE_SK_HIGHPASS"] +TWOPOLE_CMOS_LOWPASS = _K["TWOPOLE_CMOS_LOWPASS"] +TWOPOLE_CMOS_BANDPASS = _K["TWOPOLE_CMOS_BANDPASS"] +TWOPOLE_CMOS_HIGHPASS = _K["TWOPOLE_CMOS_HIGHPASS"] +OVERDRIVE_DRIVE = _K["OVERDRIVE_DRIVE"] +OVERDRIVE_TONE = _K["OVERDRIVE_TONE"] +OVERDRIVE_LEVEL = _K["OVERDRIVE_LEVEL"] # the kernels' option numbers, so a caller needs no other module OSC_FREQUENCY = _K["OSC_FREQUENCY"] diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index 03aadbf..6aee952 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -7,6 +7,8 @@ #include "shared/audiodsp_graph.h" +#include "shared/audiodsp_graph_overdrive.h" + #include #include @@ -135,6 +137,12 @@ static uint32_t phaser_stages(float arg) { return (uint32_t)arg; } +typedef struct { + // The two integrator states of dsp/twopole.py, carried across blocks. + float s1; + float s2; +} graph_twopole_t; + typedef struct { uint32_t rng; } graph_noise_t; @@ -174,6 +182,12 @@ size_t audiodsp_graph_node_data_size(audiodsp_graph_kind_t kind, return sizeof(graph_lag_t); case AUDIODSP_GRAPH_SAMPLE_HOLD: return sizeof(graph_sh_t); + case AUDIODSP_GRAPH_TWOPOLE: + return sizeof(graph_twopole_t); +#if AUDIODSP_GRAPH_HAS_OVERDRIVE + case AUDIODSP_GRAPH_OVERDRIVE: + return audiodsp_graph_overdrive_size(); +#endif default: return 0; } @@ -218,10 +232,19 @@ static float env_coeff(float seconds, float scale, uint32_t sample_rate) { return expf(-1.0f / steps); } +bool audiodsp_graph_kind_built(audiodsp_graph_kind_t kind) { + #if !AUDIODSP_GRAPH_HAS_OVERDRIVE + if (kind == AUDIODSP_GRAPH_OVERDRIVE) { + return false; + } + #endif + return kind < AUDIODSP_GRAPH_KINDS; +} + int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, uint32_t group, const int16_t *inputs, uint32_t ninputs, int16_t output, void *data, float arg) { - if (graph->nnodes >= graph->max_nodes || kind >= AUDIODSP_GRAPH_KINDS || + if (graph->nnodes >= graph->max_nodes || !audiodsp_graph_kind_built(kind) || ninputs > AUDIODSP_GRAPH_MAX_INPUTS) { return -1; } @@ -355,6 +378,19 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, audiodsp_samplehold_state_init(&d->state, &d->config); break; } + case AUDIODSP_GRAPH_TWOPOLE: + node->param[AUDIODSP_GRAPH_TWOPOLE_TYPE] = (float)AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS; + node->param[AUDIODSP_GRAPH_TWOPOLE_CUTOFF_HZ] = 1000.0f; + node->param[AUDIODSP_GRAPH_TWOPOLE_DEPTH] = 1.0f; + node->param[AUDIODSP_GRAPH_TWOPOLE_RESONANCE] = 0.0f; + node->param[AUDIODSP_GRAPH_TWOPOLE_DRIVE] = 1.0f; + break; + case AUDIODSP_GRAPH_OVERDRIVE: + // OverdriveCore.render's defaults. + node->param[AUDIODSP_GRAPH_OVERDRIVE_DRIVE] = 0.5f; + node->param[AUDIODSP_GRAPH_OVERDRIVE_TONE] = 0.5f; + node->param[AUDIODSP_GRAPH_OVERDRIVE_LEVEL] = 1.0f; + break; case AUDIODSP_GRAPH_NOISE: { graph_noise_t *d = data; d->rng = 0x5EED1234u ^ (graph->nnodes * 0x9E3779B9u); @@ -467,6 +503,17 @@ bool audiodsp_graph_reset(audiodsp_graph_t *graph, uint32_t node) { audiodsp_samplehold_reset(&d->state, &d->config); break; } + case AUDIODSP_GRAPH_TWOPOLE: { + graph_twopole_t *d = n->data; + d->s1 = 0.0f; + d->s2 = 0.0f; + break; + } +#if AUDIODSP_GRAPH_HAS_OVERDRIVE + case AUDIODSP_GRAPH_OVERDRIVE: + audiodsp_graph_overdrive_reset(n->data); + break; +#endif default: break; } @@ -546,6 +593,11 @@ static void derive(audiodsp_graph_t *graph, audiodsp_graph_node_t *n) { } break; } +#if AUDIODSP_GRAPH_HAS_OVERDRIVE + case AUDIODSP_GRAPH_OVERDRIVE: + audiodsp_graph_overdrive_configure(n->data, n->param, graph->sample_rate); + break; +#endif 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); @@ -939,6 +991,15 @@ static void run_vca(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, memset(out, 0, frames * sizeof(int16_t)); return; } + if (n->param[AUDIODSP_GRAPH_VCA_CURVE] >= 0.5f) { + // 10^(3 * (x - 1)) = e^(3 ln 10 * (x - 1)) + const float a = 6.907755279f / AUDIODSP_GRAPH_UNIT; + const float g = n->param[AUDIODSP_GRAPH_VCA_GAIN]; + for (uint32_t i = 0; i < frames; ++i) { + out[i] = sat16((float)audio[i] * expf(a * (float)cv[i] - 6.907755279f) * g); + } + return; + } for (uint32_t i = 0; i < frames; ++i) { out[i] = sat16((float)audio[i] * (float)cv[i] * k); } @@ -970,6 +1031,261 @@ static void run_out(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, } } + +// ------------------------------------------------------------ TWOPOLE -- + +// tanh, the same on every target: libm's differs in the last place between +// glibc, newlib and MicroPython's own. A rational function (an odd degree-13 +// polynomial over an even degree-6 one in x*x, Horner, no fused multiply-add) +// good to a few float ulps, which is under a hundredth of a count at full +// scale. Beyond 7.9 it has reached 1 to the last bit. +static float graph_tanh(float x) { + x = x > 7.90531110763549805f ? 7.90531110763549805f : x; + x = x < -7.90531110763549805f ? -7.90531110763549805f : x; + const float x2 = x * x; + float p = -2.76076847742355e-16f; + p = p * x2 + 2.00018790482477e-13f; + p = p * x2 + -8.60467152213735e-11f; + p = p * x2 + 5.12229709037114e-08f; + p = p * x2 + 1.48572235717979e-05f; + p = p * x2 + 6.37261928875436e-04f; + p = p * x2 + 4.89352455891786e-03f; + p = x * p; + float q = 1.19825839466702e-06f; + q = q * x2 + 1.18534705686654e-04f; + q = q * x2 + 2.26843463243900e-03f; + q = q * x2 + 4.89352518554385e-03f; + return p / q; +} + +// 2^y in double without libm, for the cutoff: the integer part into the +// scale, the fraction (within half) through a Taylor series to x^14 of +// e^(f ln 2), good to about 1e-19. Once or twice a block, so it stays double. +static double graph_exp2d(double y) { + y = y > 60.0 ? 60.0 : (y < -60.0 ? -60.0 : y); + const int32_t whole = y >= 0.0 ? (int32_t)(y + 0.5) : -(int32_t)(0.5 - y); + const double x = (y - (double)whole) * 0.693147180559945309417232121458; + double r = 1.0 / 87178291200.0; // 1/14! + r = r * x + 1.0 / 6227020800.0; // 1/13! + r = r * x + 1.0 / 479001600.0; // 1/12! + r = r * x + 1.0 / 39916800.0; // 1/11! + r = r * x + 1.0 / 3628800.0; // 1/10! + r = r * x + 1.0 / 362880.0; // 1/9! + r = r * x + 1.0 / 40320.0; // 1/8! + r = r * x + 1.0 / 5040.0; // 1/7! + r = r * x + 1.0 / 720.0; // 1/6! + r = r * x + 1.0 / 120.0; // 1/5! + r = r * x + 1.0 / 24.0; // 1/4! + r = r * x + 1.0 / 6.0; // 1/3! + r = r * x + 0.5; + r = r * x + 1.0; + r = r * x + 1.0; + double scale = 1.0; + double step = whole >= 0 ? 2.0 : 0.5; + uint32_t k = (uint32_t)(whole >= 0 ? whole : -whole); + while (k) { + if (k & 1u) { + scale *= step; + } + step *= step; + k >>= 1; + } + return r * scale; +} + +// min(tan(theta), 0.95) in double without libm, for the filter coefficient +// g = tan(pi fc / sr). tan reaches 0.95 at 0.7595 rad, so a larger angle is +// the clamp and a smaller one takes the sine and cosine series to x^17 / x^16 +// (about 1e-19 at 0.77). +static double graph_tan_clamped(double theta) { + if (!(theta < 0.77)) { + return 0.95; + } + const double x2 = theta * theta; + double s = 1.0 / 355687428096000.0; // 1/17! + s = s * x2 - 1.0 / 1307674368000.0; // 1/15! + s = s * x2 + 1.0 / 6227020800.0; // 1/13! + s = s * x2 - 1.0 / 39916800.0; // 1/11! + s = s * x2 + 1.0 / 362880.0; // 1/9! + s = s * x2 - 1.0 / 5040.0; // 1/7! + s = s * x2 + 1.0 / 120.0; // 1/5! + s = s * x2 - 1.0 / 6.0; // 1/3! + s = s * x2 + 1.0; + s *= theta; + double c = 1.0 / 20922789888000.0; // 1/16! + c = c * x2 - 1.0 / 87178291200.0; // 1/14! + c = c * x2 + 1.0 / 479001600.0; // 1/12! + c = c * x2 - 1.0 / 3628800.0; // 1/10! + c = c * x2 + 1.0 / 40320.0; // 1/8! + c = c * x2 - 1.0 / 720.0; // 1/6! + c = c * x2 + 1.0 / 24.0; // 1/4! + c = c * x2 - 0.5; + c = c * x2 + 1.0; + const double t = s / c; + return t > 0.95 ? 0.95 : t; +} + +// dsp/twopole.py's _coefficients() at one end of the block: the cutoff CV +// (counts, UNIT to 1.0) moves cutoff_hz by depth * 10 octaves per unit, held +// between 20 Hz and 0.45 of the sample rate, and g = min(tan(pi fc / sr), 0.95). +// +// vrack reads the CV from a numpy float32 block, so with a CV connected every +// step of that arithmetic stays in float32 (a float32 scalar beats a Python +// float): the product with the depth, the times ten, the power of two, the +// cutoff, and the angle. Done in double instead, g differs in the seventh +// digit, and these filters clip hard enough in the loop to turn that into +// several counts. So this does the same float32 steps, with the power of two +// from the double series above (powf's value to the float) and the tangent +// from double, as math.tan is. With no CV connected it is all double, as there. +static double twopole_g(float cutoff_hz, bool has_cv, int32_t cv, float depth, + uint32_t sample_rate) { + const double rate = (double)sample_rate; + const double top = rate * 0.45; + double fc_d = (double)cutoff_hz; + float fc_f = 0.0f; + bool is_float = false; + if (has_cv) { + float t = (float)cv * (1.0f / 16384.0f); + t = t * depth; + t = t * 10.0f; + fc_f = cutoff_hz * (float)graph_exp2d((double)t); + fc_d = (double)fc_f; + is_float = true; + } + if (fc_d > top) { + fc_d = top; + is_float = false; + } + if (!(fc_d > 20.0)) { + fc_d = 20.0; + is_float = false; + } + if (is_float) { + const float a = 3.14159274f * fc_f; + const float theta = a * (float)(1.0 / rate); + return graph_tan_clamped((double)theta); + } + return graph_tan_clamped(3.14159265358979323846 * fc_d * (1.0 / rate)); +} + +// _diode_clipper(): tanh(1.5 v) / 1.5. +static inline float twopole_diode(float v) { + return graph_tanh(v * 1.5f) / 1.5f; +} + +// _cmos_clip(): v (1.2 - 0.2 v^2), held to +-1. +static inline float twopole_cmos(float v) { + const float w = v * (1.2f - 0.2f * (v * v)); + return w > 1.0f ? 1.0f : (w < -1.0f ? -1.0f : w); +} + +static void run_twopole(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, + int16_t *out, uint32_t frames) { + graph_twopole_t *d = n->data; + const int16_t *in = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * graph->frames; + const int16_t *cv = n->ninputs > 1 && n->inputs[1] != AUDIODSP_GRAPH_NONE + ? graph->bufs + (size_t)n->inputs[1] * graph->frames : NULL; + // The nearest type; anything out of range is the Sallen-Key low-pass. + const float pick = n->param[AUDIODSP_GRAPH_TWOPOLE_TYPE] + 0.5f; + const uint32_t type = pick >= 1.0f && pick < (float)AUDIODSP_GRAPH_TWOPOLE_TYPES + ? (uint32_t)pick : (uint32_t)AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS; + const float cutoff = n->param[AUDIODSP_GRAPH_TWOPOLE_CUTOFF_HZ]; + const float depth = n->param[AUDIODSP_GRAPH_TWOPOLE_DEPTH]; + const double res = (double)n->param[AUDIODSP_GRAPH_TWOPOLE_RESONANCE]; + const float drive = n->param[AUDIODSP_GRAPH_TWOPOLE_DRIVE]; + // The cutoff CV is read at the block's first and last samples; g walks + // from the first's value, a step a sample, to the last's. + const int32_t c0 = cv != NULL ? cv[0] : 0; + const int32_t c1 = cv != NULL ? cv[frames - 1u] : 0; + const double g0 = twopole_g(cutoff, cv != NULL, c0, depth, graph->sample_rate); + const bool walks = c0 != c1; + const float gf0 = (float)g0; + // The walk is in double, a step added a sample at a time, as + // dsp/twopole.py does it: in float it drifts by an ulp or two, and + // these filters turn that into counts. + double gd = g0; + double gstep = 0.0; + if (walks) { + gstep = (twopole_g(cutoff, true, c1, depth, graph->sample_rate) - g0) / + (double)frames; + } + float s1 = d->s1; + float s2 = d->s2; + const float scale = 1.0f / 16384.0f; + if (type == AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS || + type == AUDIODSP_GRAPH_TWOPOLE_SK_HIGHPASS) { + const double kd = res * 2.15; + const float k = (float)kd; + if (type == AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS) { + float h = (float)(g0 / (1.0 + g0)); + for (uint32_t i = 0; i < frames; ++i) { + const float x = (in != NULL ? (float)in[i] * scale : 0.0f) * drive; + if (walks) { + gd += gstep; + h = (float)(gd / (1.0 + gd)); + } + const float fb = twopole_diode(s2 * k); + const float v1 = (x + fb - s1) * h; + const float node1 = v1 + s1; + s1 = node1 + v1; + const float v2 = (node1 - s2) * h; + const float lp = v2 + s2; + s2 = lp + v2; + out[i] = sat16(graph_tanh(lp) * 16384.0f); + } + } else { + const float k_term = (float)(2.0 - kd * 0.5); + float g = gf0; + float denom = (float)(1.0 / (1.0 + 2.0 * g0 + g0 * g0)); + for (uint32_t i = 0; i < frames; ++i) { + const float x = (in != NULL ? (float)in[i] * scale : 0.0f) * drive; + if (walks) { + gd += gstep; + g = (float)gd; + denom = (float)(1.0 / (1.0 + 2.0 * gd + gd * gd)); + } + const float fb = twopole_diode(s2 * k); + const float hp = (x + fb - k_term * s1 - s2) * denom; + const float v1 = g * hp; + const float node1 = v1 + s1; + s1 = node1 + v1; + const float v2 = g * node1; + const float lp = v2 + s2; + s2 = lp + v2; + out[i] = sat16(graph_tanh(hp) * 16384.0f); + } + } + } else { + double rd = 1.0 - res * 0.96; + rd = rd < 0.02 ? 0.02 : rd; + float g = gf0; + float rg = (float)(rd + g0); + float denom = (float)(1.0 / (1.0 + g0 * (g0 + rd))); + for (uint32_t i = 0; i < frames; ++i) { + const float x = twopole_cmos((in != NULL ? (float)in[i] * scale : 0.0f) * drive); + if (walks) { + gd += gstep; + g = (float)gd; + rg = (float)(rd + gd); + denom = (float)(1.0 / (1.0 + gd * (gd + rd))); + } + const float hp = twopole_cmos((x - rg * s1 - s2) * denom); + const float v1 = g * hp; + const float bp = twopole_cmos(v1 + s1); + s1 = bp + v1; + const float v2 = g * bp; + const float lp = twopole_cmos(v2 + s2); + s2 = lp + v2; + const float y = type == AUDIODSP_GRAPH_TWOPOLE_CMOS_LOWPASS ? lp + : (type == AUDIODSP_GRAPH_TWOPOLE_CMOS_BANDPASS ? bp : hp); + out[i] = sat16(y * 16384.0f); + } + } + d->s1 = s1; + d->s2 = s2; +} + uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, uint32_t enable) { const uint32_t frames = graph->frames; @@ -1086,6 +1402,15 @@ uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, case AUDIODSP_GRAPH_SAMPLE_HOLD: run_sample_hold(graph, n, dst, frames); break; + case AUDIODSP_GRAPH_TWOPOLE: + run_twopole(graph, n, dst, frames); + break; +#if AUDIODSP_GRAPH_HAS_OVERDRIVE + case AUDIODSP_GRAPH_OVERDRIVE: + audiodsp_graph_overdrive_run(n->data, n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs + (size_t)n->inputs[0] * graph->frames, dst, frames); + break; +#endif default: break; } diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index 99d7549..1a078d3 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -124,6 +124,18 @@ typedef enum { // 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, + // inputs: audio, cutoff CV. params: type, cutoff Hz, depth, resonance, + // drive. The three two-pole filters of vrack's dsp/twopole.py, which + // clip inside their loops: the Sallen-Key low-pass and high-pass with + // diode-clipped peak feedback, and the CMOS state-variable filter in + // low-pass, band-pass and high-pass. The cutoff CV is read at the + // block's first and last samples and the coefficient walks between them. + AUDIODSP_GRAPH_TWOPOLE, + // inputs: audio. params: drive, tone, level. vrack's OverdriveCore: + // audiofilters.Distortion's soft-clipped overdrive, then a high shelf + // (synthio.Biquad, CircuitPython's own Q15 arithmetic) whose gain tone + // sets. + AUDIODSP_GRAPH_OVERDRIVE, AUDIODSP_GRAPH_KINDS, } audiodsp_graph_kind_t; @@ -187,6 +199,41 @@ enum { AUDIODSP_GRAPH_SH_RATE, }; +enum { + AUDIODSP_GRAPH_TWOPOLE_TYPE = 0, + // Hz at a cutoff CV of 0. + AUDIODSP_GRAPH_TWOPOLE_CUTOFF_HZ, + // vrack's mod_depth: the cutoff is CUTOFF_HZ * 2^(cv / UNIT * depth * + // 10), so 1 is an octave per 1638.4 counts of CV and 0 ignores it. + AUDIODSP_GRAPH_TWOPOLE_DEPTH, + // The Sallen-Key's peak, or the CMOS filter's resonance: 0..1. + AUDIODSP_GRAPH_TWOPOLE_RESONANCE, + // Gain ahead of the filter, as a multiplier. + AUDIODSP_GRAPH_TWOPOLE_DRIVE, +}; + +// TWOPOLE types. +enum { + // SallenKeyLPFCore: 12 dB/oct, the output through tanh. + AUDIODSP_GRAPH_TWOPOLE_SK_LOWPASS = 0, + // SallenKeyHPFCore. + AUDIODSP_GRAPH_TWOPOLE_SK_HIGHPASS, + // CMOSFilterCore's modes: CMOS inverter clipping in every stage. + AUDIODSP_GRAPH_TWOPOLE_CMOS_LOWPASS, + AUDIODSP_GRAPH_TWOPOLE_CMOS_BANDPASS, + AUDIODSP_GRAPH_TWOPOLE_CMOS_HIGHPASS, + AUDIODSP_GRAPH_TWOPOLE_TYPES, +}; + +enum { + // 0..1; drive 0..1 is a pre-gain of 0..20 dB. + AUDIODSP_GRAPH_OVERDRIVE_DRIVE = 0, + // 0..1: a high shelf from -12 dB through flat at 0.5 to +12 dB. + AUDIODSP_GRAPH_OVERDRIVE_TONE, + // A linear gain after the clipper, down to 1e-4. + AUDIODSP_GRAPH_OVERDRIVE_LEVEL, +}; + enum { AUDIODSP_GRAPH_MIX_OFFSET = 8, }; @@ -194,6 +241,14 @@ enum { enum { AUDIODSP_GRAPH_VCA_GAIN = 0, AUDIODSP_GRAPH_VCA_SELECT, + AUDIODSP_GRAPH_VCA_CURVE, +}; + +// VCA_CURVE: the gain is the CV over UNIT (linear), or 60 dB of it, +// 10^(3 * (cv / UNIT - 1)), unity at UNIT (exponential). +enum { + AUDIODSP_GRAPH_VCA_LINEAR = 0, + AUDIODSP_GRAPH_VCA_EXPONENTIAL = 1, }; typedef struct { @@ -272,6 +327,11 @@ typedef struct { float *shadow; } audiodsp_graph_t; +// False for a kind this build leaves out (OVERDRIVE where +// AUDIODSP_GRAPH_HAS_OVERDRIVE is 0; see audiodsp_graph_overdrive.h) and for +// a number past the last kind. audiodsp_graph_add() refuses either. +bool audiodsp_graph_kind_built(audiodsp_graph_kind_t kind); + // Bytes a node of `kind` needs for its config and state. `arg` is the // kind's build-time argument (the feedback delay's line length in ms). size_t audiodsp_graph_node_data_size(audiodsp_graph_kind_t kind, diff --git a/src/shared/audiodsp_graph_overdrive.c b/src/shared/audiodsp_graph_overdrive.c new file mode 100644 index 0000000..8534a2e --- /dev/null +++ b/src/shared/audiodsp_graph_overdrive.c @@ -0,0 +1,82 @@ +// The graph's OVERDRIVE node. See audiodsp_graph_overdrive.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_overdrive.h" + +#include "shared/audiodsp_biquad.h" +#include "shared/audiodsp_distortion.h" +#include "shared/audiodsp_graph.h" +#include "shared/audiodsp_synth_dsp.h" + +#include + +// OverdriveCore's tone control: synthio.Biquad(HIGH_SHELF, 1500.0, Q=0.7071). +#define OVERDRIVE_SHELF_MODE 6 +#define OVERDRIVE_SHELF_HZ 1500.0 +#define OVERDRIVE_SHELF_Q 0.7071 + +typedef struct { + // audiofilters.Distortion's gains, linear, and the shelf after it: the + // coefficients synthio.Biquad would hold and its filter state. + double pre_gain; + double post_gain; + audiodsp_biquad_cp_coefficients_t shelf; + audiodsp_biquad_state_t state; +} graph_overdrive_t; + +size_t audiodsp_graph_overdrive_size(void) { + return sizeof(graph_overdrive_t); +} + +void audiodsp_graph_overdrive_reset(void *data) { + audiodsp_biquad_reset(&((graph_overdrive_t *)data)->state); +} + +void audiodsp_graph_overdrive_configure(void *data, const float *param, + uint32_t sample_rate) { + graph_overdrive_t *d = data; + // OverdriveCore.render, term for term, in double as it is: drive sets a + // pre-gain of up to 20 dB, level a post-gain in dB, and tone the shelf's + // gain from -12 dB to +12 dB. The two gains are clamped as + // audiofilters.Distortion clamps them. + const double drive = (double)param[AUDIODSP_GRAPH_OVERDRIVE_DRIVE]; + const double tone = (double)param[AUDIODSP_GRAPH_OVERDRIVE_TONE]; + double level = (double)param[AUDIODSP_GRAPH_OVERDRIVE_LEVEL]; + if (!(level > 1e-4)) { + level = 1e-4; + } + double pre_db = 20.0 * drive; + pre_db = pre_db < -60.0 ? -60.0 : (pre_db > 60.0 ? 60.0 : pre_db); + double post_db = 20.0 * log10(level); + post_db = post_db < -80.0 ? -80.0 : (post_db > 24.0 ? 24.0 : post_db); + d->pre_gain = exp(pre_db * 0.11512925464970228420089957273422); + d->post_gain = exp(post_db * 0.11512925464970228420089957273422); + const double t = tone < 0.0 ? 0.0 : (tone > 1.0 ? 1.0 : tone); + const double db = (t - 0.5) * 24.0; + audiodsp_biquad_cp_configure(&d->shelf, OVERDRIVE_SHELF_MODE, + audiodsp_biquad_cp_w0(OVERDRIVE_SHELF_HZ, sample_rate), + OVERDRIVE_SHELF_Q, pow(10.0, db / 40.0)); +} + +void audiodsp_graph_overdrive_run(void *data, const int16_t *in, int16_t *out, + uint32_t frames) { + graph_overdrive_t *d = data; + // audiofilters.Filter's last step: a sample past +-28000 is squeezed + // toward the rail (SYNTHIO_MIX_DOWN_SCALE(2)). + const int32_t knee = 0x0fffffff / (32768 * 2 - 28000); + for (uint32_t i = 0; i < frames; ++i) { + const int32_t sample = in != NULL ? in[i] : 0; + // audiofilters.Distortion in OVERDRIVE mode with soft clipping and + // the mix at 1; the drive is not read in this mode. + const int32_t clipped = (int16_t)audiodsp_distortion_sample(sample, 0.0, + d->pre_gain, d->post_gain, AUDIODSP_DISTORTION_OVERDRIVE, true, + 1.0, 0u); + const int32_t shelved = audiodsp_biquad_cp_sample(&d->shelf, &d->state, + clipped); + out[i] = audiodsp_mix_down_sample(shelved, knee, -28000, 28000); + } +} diff --git a/src/shared/audiodsp_graph_overdrive.h b/src/shared/audiodsp_graph_overdrive.h new file mode 100644 index 0000000..fe5b479 --- /dev/null +++ b/src/shared/audiodsp_graph_overdrive.h @@ -0,0 +1,29 @@ +// The graph's OVERDRIVE node. See audiodsp_graph.h. +// SPDX-License-Identifier: MIT +// +// It lives apart from the rest of the runner because it needs +// double-precision pow() and log10(), and through audiodsp_distortion.c and +// audiodsp_biquad.c more of libm, which CircuitPython's RP2040 build cannot +// link. A build that leaves this file out defines +// AUDIODSP_GRAPH_HAS_OVERDRIVE to 0, and the graph then refuses the kind. + +#ifndef AUDIODSP_GRAPH_OVERDRIVE_H +#define AUDIODSP_GRAPH_OVERDRIVE_H + +#include +#include + +#ifndef AUDIODSP_GRAPH_HAS_OVERDRIVE +#define AUDIODSP_GRAPH_HAS_OVERDRIVE (1) +#endif + +size_t audiodsp_graph_overdrive_size(void); +void audiodsp_graph_overdrive_reset(void *data); +// `param` is the node's AUDIODSP_GRAPH_OVERDRIVE_* parameters. +void audiodsp_graph_overdrive_configure(void *data, const float *param, + uint32_t sample_rate); +// `in` is NULL for an unconnected input, which reads as silence. +void audiodsp_graph_overdrive_run(void *data, const int16_t *in, int16_t *out, + uint32_t frames); + +#endif diff --git a/tests/parity/graph_nodes_probe.py b/tests/parity/graph_nodes_probe.py index bd7d1ea..200f016 100644 --- a/tests/parity/graph_nodes_probe.py +++ b/tests/parity/graph_nodes_probe.py @@ -1,10 +1,12 @@ -"""audiograph's PHASER, LAG, LFO shapes and SAMPLE_HOLD, rendered for verify_graph.py. +"""audiograph's PHASER, LAG, LFO shapes, SAMPLE_HOLD, TWOPOLE and OVERDRIVE, 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 + graph_nodes_probe.py --fault twopole # one block's cutoff CV a block late + graph_nodes_probe.py --fault overdrive # one tone change a block 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 @@ -19,7 +21,10 @@ 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). +oscillator held at a rate with no clock); the five two-pole types on a +sawtooth and on noise, with a cutoff CV that holds still for some blocks and +runs through others, and one with no CV connected; and three overdrives (the +sawtooth, hot, with the tone swept). 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. @@ -39,7 +44,7 @@ print("GRAPH PARITY SKIPPED: no audiograph nodes here") sys.exit(0) -FAULTS = ("phaser", "lag", "lfo", "hold") +FAULTS = ("phaser", "lag", "lfo", "hold", "twopole", "overdrive") FAULT = None if "--fault" in sys.argv: FAULT = sys.argv[sys.argv.index("--fault") + 1] @@ -59,8 +64,8 @@ def check(ok, what): failures.append(what) -g = ag.Graph(sample_rate=RATE, frames=FRAMES, channel_count=1, buffers=32, - nodes=32) +g = ag.Graph(sample_rate=RATE, frames=FRAMES, channel_count=1, buffers=48, + nodes=48) watched = [] @@ -123,6 +128,47 @@ def node(name, kind, inputs, arg=0.0): sh_rate, _ = node("hold_rate", ag.SAMPLE_HOLD, (saw_b, None)) g.set(sh_rate, ag.SH_RATE, 2000.0) +# -- two-pole filters: each type on the sawtooth, two on noise, one with no CV +cv_b = g.buffer() +tp_sk_lp, _ = node("tp_sk_lp", ag.TWOPOLE, (saw_b, cv_b)) +g.set(tp_sk_lp, ag.TWOPOLE_TYPE, ag.TWOPOLE_SK_LOWPASS) +g.set(tp_sk_lp, ag.TWOPOLE_CUTOFF_HZ, 1000.0) +g.set(tp_sk_lp, ag.TWOPOLE_RESONANCE, 0.625) +g.set(tp_sk_lp, ag.TWOPOLE_DRIVE, 2.0) +tp_sk_hp, _ = node("tp_sk_hp", ag.TWOPOLE, (saw_b, cv_b)) +g.set(tp_sk_hp, ag.TWOPOLE_TYPE, ag.TWOPOLE_SK_HIGHPASS) +g.set(tp_sk_hp, ag.TWOPOLE_CUTOFF_HZ, 500.0) +g.set(tp_sk_hp, ag.TWOPOLE_DEPTH, 0.5) +g.set(tp_sk_hp, ag.TWOPOLE_RESONANCE, 0.875) +tp_cmos_lp, _ = node("tp_cmos_lp", ag.TWOPOLE, (saw_b, cv_b)) +g.set(tp_cmos_lp, ag.TWOPOLE_TYPE, ag.TWOPOLE_CMOS_LOWPASS) +g.set(tp_cmos_lp, ag.TWOPOLE_RESONANCE, 0.75) +g.set(tp_cmos_lp, ag.TWOPOLE_DRIVE, 3.0) +tp_cmos_bp, _ = node("tp_cmos_bp", ag.TWOPOLE, (noise_b, cv_b)) +g.set(tp_cmos_bp, ag.TWOPOLE_TYPE, ag.TWOPOLE_CMOS_BANDPASS) +g.set(tp_cmos_bp, ag.TWOPOLE_CUTOFF_HZ, 750.0) +g.set(tp_cmos_bp, ag.TWOPOLE_RESONANCE, 0.5) +tp_cmos_hp, _ = node("tp_cmos_hp", ag.TWOPOLE, (noise_b, cv_b)) +g.set(tp_cmos_hp, ag.TWOPOLE_TYPE, ag.TWOPOLE_CMOS_HIGHPASS) +g.set(tp_cmos_hp, ag.TWOPOLE_CUTOFF_HZ, 2000.0) +g.set(tp_cmos_hp, ag.TWOPOLE_RESONANCE, 0.25) +g.set(tp_cmos_hp, ag.TWOPOLE_DRIVE, 1.5) +tp_open, _ = node("tp_open", ag.TWOPOLE, (saw_b, None)) +g.set(tp_open, ag.TWOPOLE_TYPE, ag.TWOPOLE_SK_LOWPASS) +g.set(tp_open, ag.TWOPOLE_CUTOFF_HZ, 1250.0) +g.set(tp_open, ag.TWOPOLE_RESONANCE, 0.5) + +# -- overdrives: the sawtooth hot, a quiet one and one with the tone swept +od_a, _ = node("od_a", ag.OVERDRIVE, (saw_b,)) +g.set(od_a, ag.OVERDRIVE_DRIVE, 0.75) +g.set(od_a, ag.OVERDRIVE_TONE, 0.25) +g.set(od_a, ag.OVERDRIVE_LEVEL, 0.5) +od_b, _ = node("od_b", ag.OVERDRIVE, (saw_b,)) +g.set(od_b, ag.OVERDRIVE_DRIVE, 1.0) +g.set(od_b, ag.OVERDRIVE_TONE, 1.0) +g.set(od_b, ag.OVERDRIVE_LEVEL, 2.0) +od_c, _ = node("od_c", ag.OVERDRIVE, (noise_b,)) + NOTES = (0, 0, 4096, 4096, 4096, -2731, -2731, 1365, 9557, 9557, 9557, 9557, -8192, -8192, 683, 683) note = array("h", bytes(2 * FRAMES)) @@ -133,6 +179,8 @@ def node(name, kind, inputs, arg=0.0): #: Per node: samples that differ from the one before, the lowest, the highest. seen = {} saw_block = array("h", bytes(2 * FRAMES)) +cv = array("h", bytes(2 * FRAMES)) +last_cv = array("h", bytes(2 * FRAMES)) moved = False for b in range(BLOCKS): @@ -180,6 +228,38 @@ def node(name, kind, inputs, arg=0.0): g.set(ph6, ag.PHASER_FEEDBACK, 0.625) g.reset(sh_lag) + # the cutoff CV: still for a few blocks, then a ramp through the block, a + # fast wobble, and a jump between blocks; the two-poles read its first and + # last samples + for i in range(FRAMES): + phase = (b // 10) % 4 + if phase == 0: + cv[i] = 1500 + elif phase == 1: + cv[i] = -1200 + (b % 10) * 240 + (i * 3) // 4 + elif phase == 2: + cv[i] = 2400 if i % 8 < 4 else -900 + else: + cv[i] = (b % 10) * 400 - 2000 + if FAULT == "twopole" and b == 57: + cv[:] = last_cv + last_cv[:] = cv + g.write(cv_b, cv) + if b == 100: + g.set(tp_sk_lp, ag.TWOPOLE_RESONANCE, 0.875) + g.set(tp_cmos_lp, ag.TWOPOLE_TYPE, ag.TWOPOLE_CMOS_BANDPASS) + if b == 125: + g.reset(tp_cmos_bp) + + # the tone of one overdrive swept in steps, a level moved mid-render + tone_at = b if FAULT != "overdrive" or b != 72 else b - 1 + g.set(od_c, ag.OVERDRIVE_TONE, ((tone_at // 8) % 17) / 16.0) + g.set(od_c, ag.OVERDRIVE_DRIVE, 0.625) + if b == 90: + g.set(od_c, ag.OVERDRIVE_LEVEL, 0.25) + if b == 140: + g.reset(od_a) + g.run(None) g.read(saw_b, saw_block) for name, buffer in watched: @@ -207,6 +287,14 @@ def node(name, kind, inputs, arg=0.0): 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") +for name in ("tp_sk_lp", "tp_sk_hp", "tp_cmos_lp", "tp_cmos_bp", "tp_cmos_hp", + "tp_open"): + check(seen[name][0] > 5000 and seen[name][2] > 500, + "the two-pole %s renders sound" % name) +for name in ("od_a", "od_b", "od_c"): + check(seen[name][0] > 5000 and seen[name][2] > 500, + "the overdrive %s renders sound" % name) +check(seen["od_b"][2] > 20000, "the hot overdrive reaches the knee") if failures: for what in failures: diff --git a/tests/parity/verify_graph.py b/tests/parity/verify_graph.py index c87bf7c..9edd6b0 100644 --- a/tests/parity/verify_graph.py +++ b/tests/parity/verify_graph.py @@ -5,14 +5,15 @@ verify_graph.py --micropython PATH --fault lsb # must FAIL verify_graph.py --micropython PATH --fault late # must FAIL verify_graph.py --micropython PATH --fault phaser # must FAIL (and lag, lfo, hold) + verify_graph.py --micropython PATH --fault twopole # must FAIL (and overdrive) verify_graph.py --micropython PATH --fault drain # must FAIL (and split) 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. ``graph_source_probe.py`` is the graph pulled as a source, +phaser, the glide, the LFO's shapes, the sample-and-hold, the two-pole filters +and the overdrive: every sample each of them writes. ``graph_source_probe.py`` is the graph pulled as a source, through its queue, which must render what ``run()`` renders. The block headers (which voices ran, which were still sounding), the parameters read back and the probes' own checks must match exactly. @@ -45,7 +46,7 @@ #: 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")), + ("nodes", HERE / "graph_nodes_probe.py", ("phaser", "lag", "lfo", "hold", "twopole", "overdrive")), ("source", HERE / "graph_source_probe.py", ("drain", "split")), ) FAULTS = tuple(f for _, _, faults in PROBES for f in faults) diff --git a/tests/test_cpython_audiograph_nodes.py b/tests/test_cpython_audiograph_nodes.py index 822253b..4a19a0f 100644 --- a/tests/test_cpython_audiograph_nodes.py +++ b/tests/test_cpython_audiograph_nodes.py @@ -1,4 +1,4 @@ -"""audiograph's PHASER, LAG, LFO shapes and SAMPLE_HOLD, held to what they stand in for. +"""audiograph's PHASER, LAG, LFO shapes, SAMPLE_HOLD, TWOPOLE and OVERDRIVE, held to what they stand in for. | node | held to | bar | |---|---|---| @@ -12,6 +12,9 @@ | `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 | +| `TWOPOLE`, CMOS low-pass, band-pass, high-pass | vrack's `dsp/twopole.py` `CMOSFilterCore`, with the cutoff CV still, stepping between blocks, running through a block, and not connected | 0 counts | +| `TWOPOLE`, Sallen-Key low-pass and high-pass | `SallenKeyLPFCore` and `SallenKeyHPFCore`, the same four CV cases | 1 count, on under 1 sample in 100 (the float `tanh`) | +| `OVERDRIVE` | vrack's `OverdriveCore`: `audiofilters.Distortion` in overdrive mode with soft clipping, then `audiofilters.Filter` with a `synthio.Biquad` high shelf, driven the way vrack's bridge drives them | 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 @@ -28,8 +31,10 @@ import audiobiquad import audiocore +import audiofilters import audiograph as ag import audioshaper +import synthio RATE = 48000 UNIT = ag.UNIT @@ -539,5 +544,395 @@ def test_open_clock_and_no_rate_holds(self): self.assertEqual(list(block), [0] * 128) +# ---------------------------------------------------------------- TWOPOLE -- +# +# dsp/twopole.py reads a numpy float32 block, and numpy 2 keeps a float32 +# scalar float32 beside a Python float: so with a cutoff CV connected the +# coefficient is worked out in float32 steps (the CV times the depth, times +# ten, the power of two, the cutoff, the angle), and the loop is float32 apart +# from the Python-float coefficients it multiplies by, which are rounded to +# float32 as they meet a float32. Each operation below is rounded that way. +# `math.tanh` is the double one, rounded when it lands in a float32. + +PI32 = f32(math.pi) + + +def m(a, b): + return f32(f32(a) * f32(b)) + + +def a_(a, b): + return f32(f32(a) + f32(b)) + + +def s_(a, b): + return f32(f32(a) - f32(b)) + + +def twopole_coefficients(frames, cutoff, cv, depth): + """dsp/twopole.py `_coefficients`: (g, per-sample step), both double.""" + inv_sr = 1.0 / RATE + top = RATE * 0.45 + + def g_at(c): + if cv is None: + fc, narrow = cutoff * 2.0 ** (c * depth * 10.0), False + else: + t = m(m(c, depth), 10.0) + fc, narrow = m(cutoff, f32(2.0 ** t)), True + if not fc <= top: + fc, narrow = top, False + if not fc > 20.0: + fc, narrow = 20.0, False + if narrow: + return min(math.tan(m(m(PI32, fc), f32(inv_sr))), 0.95) + return min(math.tan(math.pi * fc * inv_sr), 0.95) + + c0 = cv[0] / UNIT if cv is not None else 0.0 + c1 = cv[frames - 1] / UNIT if cv is not None else 0.0 + g = g_at(c0) + if c0 != c1: + return g, (g_at(c1) - g) / float(frames) + return g, 0.0 + + +def diode(v): + return math.tanh(m(v, 1.5)) / 1.5 + + +def cmos_clip(v): + v = f32(v) + w = m(v, s_(1.2, m(0.2, m(v, v)))) + return max(-1.0, min(1.0, w)) + + +class TwoPoleRef: + """`SallenKeyLPFCore`, `SallenKeyHPFCore` and `CMOSFilterCore`, one + sample at a time. `kind` is 0 and 1 for the Sallen-Key pair, 2, 3 and 4 + for the CMOS filter's LP, BP and HP.""" + + def __init__(self, kind): + self.kind = kind + self.s1 = self.s2 = 0.0 + + def render(self, src, cv, cutoff, depth, resonance, drive): + frames = len(src) + g, step = twopole_coefficients(frames, cutoff, cv, depth) + out = [] + s1, s2 = self.s1, self.s2 + x_in = [0.0 if v is None else v / UNIT for v in src] + if self.kind == 0: + k = resonance * 2.15 + h = g / (1.0 + g) + for i in range(frames): + x = m(x_in[i], drive) + if step != 0.0: + g += step + h = g / (1.0 + g) + fb = diode(m(s2, k)) + v1 = m(s_(a_(x, fb), s1), h) + node1 = a_(v1, s1) + s1 = a_(node1, v1) + v2 = m(s_(node1, s2), h) + lp = a_(v2, s2) + s2 = a_(lp, v2) + out.append(f32(math.tanh(lp))) + elif self.kind == 1: + k = resonance * 2.15 + k_term = 2.0 - k * 0.5 + denom = 1.0 / (1.0 + 2.0 * g + g * g) + for i in range(frames): + x = m(x_in[i], drive) + if step != 0.0: + g += step + denom = 1.0 / (1.0 + 2.0 * g + g * g) + fb = diode(m(s2, k)) + hp = m(s_(s_(a_(x, fb), m(k_term, s1)), s2), denom) + v1 = m(g, hp) + node1 = a_(v1, s1) + s1 = a_(node1, v1) + v2 = m(g, node1) + lp = a_(v2, s2) + s2 = a_(lp, v2) + out.append(f32(math.tanh(hp))) + else: + r = max(0.02, 1.0 - resonance * 0.96) + denom = 1.0 / (1.0 + g * (g + r)) + for i in range(frames): + x = cmos_clip(m(x_in[i], drive)) + if step != 0.0: + g += step + denom = 1.0 / (1.0 + g * (g + r)) + hp = cmos_clip(m(s_(s_(x, m(r + g, s1)), s2), denom)) + v1 = m(g, hp) + bp = cmos_clip(a_(v1, s1)) + s1 = a_(bp, v1) + v2 = m(g, bp) + lp = cmos_clip(a_(v2, s2)) + s2 = a_(lp, v2) + out.append(lp if self.kind == 2 else (bp if self.kind == 3 else hp)) + self.s1, self.s2 = s1, s2 + return [sat(v * UNIT) for v in out] + + +def twopole_input(frames, blocks, amplitude=9000): + return [[((b * frames + i) * 211) % 4096 * amplitude // 2048 - amplitude + + (amplitude // 3 if ((b * frames + i) // 37) % 2 else 0) + for i in range(frames)] for b in range(blocks)] + + +def cv_still(frames, blocks): + return [[(b // 6) * 700 - 1400] * frames for b in range(blocks)] + + +def cv_stepping(frames, blocks): + """A new value every block, none inside one.""" + return [[((b * 977) % 5400) - 2700] * frames for b in range(blocks)] + + +def cv_running(frames, blocks): + """Different at the block's two ends, and a wobble between.""" + return [[int(3000 * math.sin((b * frames + i) * 0.011)) for i in range(frames)] + for b in range(blocks)] + + +class TwoPole(unittest.TestCase): + + CASES = (("still", cv_still), ("stepping", cv_stepping), + ("running", cv_running), ("open", None)) + + def compare(self, kind, cv_case, frames=128, blocks=60, cutoff=1000.0, + depth=1.0, resonance=0.625, drive=1.5): + rig = Rig(frames, ag.TWOPOLE, 2, open_=(1,) if cv_case is None else ()) + rig.set(ag.TWOPOLE_TYPE, kind) + rig.set(ag.TWOPOLE_CUTOFF_HZ, cutoff) + rig.set(ag.TWOPOLE_DEPTH, depth) + rig.set(ag.TWOPOLE_RESONANCE, resonance) + rig.set(ag.TWOPOLE_DRIVE, drive) + ref = TwoPoleRef(kind) + signal = twopole_input(frames, blocks) + cvs = cv_case(frames, blocks) if cv_case else [None] * blocks + got, want = [], [] + for x, cv in zip(signal, cvs): + got += rig.run(x, cv) + want += ref.render(x, cv, cutoff, depth, resonance, drive) + return got, want + + def test_cmos_filter_matches_at_zero_counts(self): + for kind in (2, 3, 4): + for name, case in self.CASES: + for resonance, drive in ((0.625, 1.5), (0.9375, 3.0)): + got, want = self.compare(kind, case, resonance=resonance, + drive=drive) + self.assertGreater(max(abs(v) for v in got), 1000, (kind, name)) + self.assertEqual(worst(got, want), 0, (kind, name, resonance)) + + def test_sallen_key_filters_match_within_a_count(self): + for kind in (0, 1): + for name, case in self.CASES: + for resonance, drive in ((0.5, 1.0), (0.875, 2.0)): + got, want = self.compare(kind, case, resonance=resonance, + drive=drive, + cutoff=1000.0 if kind == 0 else 500.0) + self.assertGreater(max(abs(v) for v in got), 1000, (kind, name)) + self.assertLessEqual(worst(got, want), 1, (kind, name, resonance)) + differ = sum(1 for a, b in zip(got, want) if a != b) + self.assertLess(differ, len(got) // 100, (kind, name, resonance)) + + def test_other_depths_and_rates_of_cutoff(self): + for cutoff, depth in ((20.0, 1.0), (4000.0, 0.5), (11000.0, 1.0), + (250.0, 1.75)): + got, want = self.compare(2, cv_running, cutoff=cutoff, depth=depth) + self.assertEqual(worst(got, want), 0, (cutoff, depth)) + + def test_only_the_ends_of_the_cv_are_read(self): + # the cutoff walks from the first sample's CV to the last's: what + # lies between them does not move it + frames = 128 + rig_a = Rig(frames, ag.TWOPOLE, 2) + rig_b = Rig(frames, ag.TWOPOLE, 2) + for rig in (rig_a, rig_b): + rig.set(ag.TWOPOLE_TYPE, ag.TWOPOLE_CMOS_LOWPASS) + rig.set(ag.TWOPOLE_RESONANCE, 0.75) + signal = twopole_input(frames, 8) + for x in signal: + ramp = [i * 20 for i in range(frames)] + wobble = [ramp[0]] + [ramp[i] + (700 if i % 5 else -700) + for i in range(1, frames - 1)] + [ramp[-1]] + self.assertEqual(rig_a.run(x, ramp), rig_b.run(x, wobble)) + + def test_reset_starts_the_filter_again(self): + frames = 128 + signal = twopole_input(frames, 4) + cvs = cv_running(frames, 4) + for kind in range(5): + rig = Rig(frames, ag.TWOPOLE, 2) + rig.set(ag.TWOPOLE_TYPE, kind) + rig.set(ag.TWOPOLE_RESONANCE, 0.75) + first = [rig.run(x, cv) for x, cv in zip(signal, cvs)] + rig.g.reset(rig.node) + again = [rig.run(x, cv) for x, cv in zip(signal, cvs)] + self.assertEqual(first, again, kind) + + def test_an_unknown_type_is_the_sallen_key_low_pass(self): + frames = 128 + signal = twopole_input(frames, 3) + want = Rig(frames, ag.TWOPOLE, 2, open_=(1,)) + got = Rig(frames, ag.TWOPOLE, 2, open_=(1,)) + got.set(ag.TWOPOLE_TYPE, 9) + for x in signal: + self.assertEqual(want.run(x, None), got.run(x, None)) + + def test_low_pass_and_high_pass_split_a_signal(self): + frames = 128 + signal = twopole_input(frames, 30) + lows, highs = [], [] + for kind, bucket in ((ag.TWOPOLE_CMOS_LOWPASS, lows), (ag.TWOPOLE_CMOS_HIGHPASS, highs)): + rig = Rig(frames, ag.TWOPOLE, 2, open_=(1,)) + rig.set(ag.TWOPOLE_TYPE, kind) + rig.set(ag.TWOPOLE_CUTOFF_HZ, 300.0) + for x in signal: + bucket += rig.run(x, None) + # a 220 Hz-ish saw with its harmonics: the high-pass keeps more of the + # top, the low-pass more of the bottom + def edge(values): + return sum(abs(b - a) for a, b in zip(values, values[1:])) + self.assertGreater(edge(highs), edge(lows)) + + +# --------------------------------------------------------------- OVERDRIVE -- + +class OverdriveRef: + """vrack's `OverdriveCore` on audiodsp's own nodes, built and pulled the + way vrack's `NodeBridge` does: a looping `RawSample` over one block, a + pass-through `audiofilters.Filter`, `audiofilters.Distortion`, and the + shelf's `audiofilters.Filter`, one pull a block.""" + + def __init__(self, frames): + self.frames = frames + self.input = array("h", bytes(2 * frames)) + source = audiocore.RawSample(self.input, channel_count=1, sample_rate=RATE) + kwargs = dict(buffer_size=frames * 2, sample_rate=RATE, bits_per_sample=16, + samples_signed=True, channel_count=1) + self.dist = audiofilters.Distortion( + drive=0.5, pre_gain=0.0, post_gain=0.0, + mode=audiofilters.DistortionMode.OVERDRIVE, soft_clip=True, mix=1.0, + **kwargs) + self.shelf = synthio.Biquad(synthio.FilterMode.HIGH_SHELF, 1500.0, + Q=0.7071, A=1.0) + tone = audiofilters.Filter(filter=self.shelf, **kwargs) + through = audiofilters.Filter(filter=None, **kwargs) + through.play(source, loop=True) + self.dist.play(through) + tone.play(self.dist) + self.tail = tone + audiocore.get_buffer(self.tail) # the bridge's throwaway first pull + + def render(self, block, drive, tone, level): + self.dist.drive = max(0.0, min(1.0, drive)) + self.dist.pre_gain = 20.0 * drive + db = (max(0.0, min(1.0, tone)) - 0.5) * 24.0 + self.shelf.A = 10.0 ** (db / 40.0) + self.dist.post_gain = 20.0 * math.log10(max(1e-4, level)) + self.input[:] = array("h", block) + return list(array("h", bytes(audiocore.get_buffer(self.tail)[1]))) + + +class Overdrive(unittest.TestCase): + + def signal(self, frames, blocks, amplitude): + return [[(((b * frames + i) * 211) % 4096 - 2048) * amplitude // 2048 + + (amplitude // 4 if ((b * frames + i) // 37) % 2 else 0) + for i in range(frames)] for b in range(blocks)] + + def compare(self, settings, frames=128, amplitude=6000): + rig = Rig(frames, ag.OVERDRIVE, 1) + ref = OverdriveRef(frames) + got, want = [], [] + for block, (drive, tone, level) in zip( + self.signal(frames, len(settings), amplitude), settings): + rig.set(ag.OVERDRIVE_DRIVE, drive) + rig.set(ag.OVERDRIVE_TONE, tone) + rig.set(ag.OVERDRIVE_LEVEL, level) + got += rig.run(block) + want += ref.render(block, drive, tone, level) + return got, want + + def test_matches_overdrivecore(self): + for drive, tone, level in ((0.5, 0.5, 1.0), (0.75, 0.25, 0.5), + (1.0, 1.0, 2.0), (0.0, 0.0, 0.125), + (0.875, 0.75, 1.5)): + got, want = self.compare([(drive, tone, level)] * 40) + self.assertGreater(max(abs(v) for v in got), 200) + self.assertEqual(worst(got, want), 0, (drive, tone, level)) + + def test_matches_while_the_knobs_move(self): + # every block a new drive, tone and level, past the ends of their + # ranges: the clamps are OverdriveCore's and audiofilters' + settings = [((b % 9) / 8.0 - 0.0625, (b % 13) / 12.0 + 0.0625, + 0.0625 * (1 + b % 40)) for b in range(80)] + got, want = self.compare(settings) + self.assertEqual(worst(got, want), 0) + + def test_past_the_knee_the_level_is_squeezed(self): + # a loud, hot signal reaches the mix-down's knee at 28000 and goes + # no higher than 28521, as the Filter's last step leaves it + got, want = self.compare([(1.0, 1.0, 2.0)] * 30, amplitude=16000) + self.assertEqual(worst(got, want), 0) + self.assertGreater(max(got), 28000) + self.assertLessEqual(max(got), 28521) + + def test_other_block_sizes(self): + for frames in (32, 64, 256): + got, want = self.compare([(0.625, 0.375, 1.0)] * 20, frames=frames) + self.assertEqual(worst(got, want), 0, frames) + + def test_reset_clears_the_shelf(self): + rig = Rig(128, ag.OVERDRIVE, 1) + rig.set(ag.OVERDRIVE_TONE, 1.0) + blocks = self.signal(128, 4, 6000) + first = [rig.run(b) for b in blocks] + rig.g.reset(rig.node) + again = [rig.run(b) for b in blocks] + self.assertEqual(first, again) + + +# ------------------------------------------------------------- VCA curve -- + +class VcaCurve(unittest.TestCase): + + def run_vca(self, audio, cv, curve, gain=1.0): + rig = Rig(len(audio), ag.VCA, 3, open_=(2,)) + rig.set(ag.VCA_CURVE, curve) + rig.set(ag.VCA_GAIN, gain) + return rig.run(audio, cv) + + def test_linear_is_the_default(self): + audio = [((i * 211) % 4096 - 2048) * 6 for i in range(128)] + cv = [(i * 257) % 32768 for i in range(128)] + rig = Rig(128, ag.VCA, 3, open_=(2,)) + got = rig.run(audio, cv) + self.assertEqual(got, self.run_vca(audio, cv, ag.VCA_LINEAR)) + self.assertEqual(got, [sat(a * c / UNIT) for a, c in zip(audio, cv)]) + + def test_exponential_is_sixty_db_over_the_unit(self): + audio = [((i * 211) % 4096 - 2048) * 6 for i in range(256)] + cv = [(i * 131) % 32768 for i in range(256)] + for gain in (1.0, 1.5, 0.25): + got = self.run_vca(audio, cv, ag.VCA_EXPONENTIAL, gain) + # AmplifierCore's exponential response: 10 ** (3 * (cv - 1)) + want = [sat(a * 10.0 ** (3.0 * (c / UNIT - 1.0)) * gain) + for a, c in zip(audio, cv)] + self.assertLessEqual(worst(got, want), 1, gain) + self.assertGreater(max(abs(v) for v in got), 1000) + + def test_exponential_ends(self): + audio = [12000, -12000, 30000, -30000] * 8 + # at UNIT, unity exactly; at zero, -60 dB + self.assertEqual(self.run_vca(audio, [UNIT] * 32, ag.VCA_EXPONENTIAL), audio) + self.assertEqual(self.run_vca(audio, [0] * 32, ag.VCA_EXPONENTIAL), + [sat(a / 1000.0) for a in audio]) + + if __name__ == "__main__": unittest.main()