diff --git a/docs/how-it-works/audiograph.md b/docs/how-it-works/audiograph.md index 62dff6f..f4511ca 100644 --- a/docs/how-it-works/audiograph.md +++ b/docs/how-it-works/audiograph.md @@ -90,6 +90,7 @@ rather than its last block. | `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) | +| `TANH` | audio | `TANH_DRIVE` (a gain before the tanh, 1 is unity), `TANH_LEVEL` (a gain after it, 1 is unity) | `TANH_LEVEL · tanh(TANH_DRIVE · x)` with `x` in units of `UNIT`, [below](#tanh) | The kernel nodes take their parameters under the same numbers as the modules that wrap them (`audiooscillator`, `audioladder`, `audiobiquad`, @@ -176,6 +177,18 @@ in double precision, and the RP2040 build has no double-precision `pow` or CircuitPython, `audiofilters.Distortion` and `synthio.Biquad` are already built in, and you can chain them yourself. +### Tanh + +`TANH` is `level * tanh(drive * x)`, where `x` is the input over `UNIT`, so a +full-scale input at a drive of 1 is 0.76 of the way to the rail and a drive of 4 +or more squares a signal off. It is for a mixer that saturates, or the input stage +in front of a ladder, where `OVERDRIVE` (Q15 distortion and a shelf) is the wrong +shape. The tanh is the graph's own, the one `TWOPOLE` clips with: a rational +function good to a few float ulps, with no libm, so MicroPython, CircuitPython and +CPython render the same bytes. It is present on every target, `OVERDRIVE`'s +RP2040 limit not applying. A drive large enough to push the result past ±32767 is +held at the rail, and an open input is silence. + ## Output `run(out, block, enable)` writes the `OUT` node's block into `out` at block diff --git a/src/audiograph/module.c b/src/audiograph/module.c index 9db9f42..9553c06 100644 --- a/src/audiograph/module.c +++ b/src/audiograph/module.c @@ -28,6 +28,7 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_TANH), MP_ROM_INT(AUDIODSP_GRAPH_TANH) }, { 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 @@ -73,6 +74,8 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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) }, + { MP_ROM_QSTR(MP_QSTR_TANH_DRIVE), MP_ROM_INT(AUDIODSP_GRAPH_TANH_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_TANH_LEVEL), MP_ROM_INT(AUDIODSP_GRAPH_TANH_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/__init__.c b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c index 9bad4e1..6758881 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c @@ -41,6 +41,7 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_TANH), MP_ROM_INT(AUDIODSP_GRAPH_TANH) }, #if AUDIODSP_GRAPH_HAS_OVERDRIVE { MP_ROM_QSTR(MP_QSTR_OVERDRIVE), MP_ROM_INT(AUDIODSP_GRAPH_OVERDRIVE) }, #endif @@ -86,6 +87,8 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { 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_TANH_DRIVE), MP_ROM_INT(AUDIODSP_GRAPH_TANH_DRIVE) }, + { MP_ROM_QSTR(MP_QSTR_TANH_LEVEL), MP_ROM_INT(AUDIODSP_GRAPH_TANH_LEVEL) }, #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) }, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 2fbf27e..c5d1bfa 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -4204,6 +4204,7 @@ static int graph_add_constants(PyObject *module) { {"SAMPLE_HOLD", AUDIODSP_GRAPH_SAMPLE_HOLD}, {"TWOPOLE", AUDIODSP_GRAPH_TWOPOLE}, {"OVERDRIVE", AUDIODSP_GRAPH_OVERDRIVE}, + {"TANH", AUDIODSP_GRAPH_TANH}, {"ALWAYS", AUDIODSP_GRAPH_ALWAYS}, {"UNIT", 16384}, {"ENV_ATTACK", AUDIODSP_GRAPH_ENV_ATTACK}, @@ -4247,6 +4248,8 @@ static int graph_add_constants(PyObject *module) { {"OVERDRIVE_DRIVE", AUDIODSP_GRAPH_OVERDRIVE_DRIVE}, {"OVERDRIVE_TONE", AUDIODSP_GRAPH_OVERDRIVE_TONE}, {"OVERDRIVE_LEVEL", AUDIODSP_GRAPH_OVERDRIVE_LEVEL}, + {"TANH_DRIVE", AUDIODSP_GRAPH_TANH_DRIVE}, + {"TANH_LEVEL", AUDIODSP_GRAPH_TANH_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 340e8c5..2db27d6 100644 --- a/src/cpython/audiograph.py +++ b/src/cpython/audiograph.py @@ -53,6 +53,7 @@ SAMPLE_HOLD = _K["SAMPLE_HOLD"] TWOPOLE = _K["TWOPOLE"] OVERDRIVE = _K["OVERDRIVE"] +TANH = _K["TANH"] ALWAYS = _K["ALWAYS"] UNIT = _K["UNIT"] @@ -98,6 +99,8 @@ OVERDRIVE_DRIVE = _K["OVERDRIVE_DRIVE"] OVERDRIVE_TONE = _K["OVERDRIVE_TONE"] OVERDRIVE_LEVEL = _K["OVERDRIVE_LEVEL"] +TANH_DRIVE = _K["TANH_DRIVE"] +TANH_LEVEL = _K["TANH_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 12d1877..60daad7 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -393,6 +393,10 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, node->param[AUDIODSP_GRAPH_OVERDRIVE_TONE] = 0.5f; node->param[AUDIODSP_GRAPH_OVERDRIVE_LEVEL] = 1.0f; break; + case AUDIODSP_GRAPH_TANH: + node->param[AUDIODSP_GRAPH_TANH_DRIVE] = 1.0f; + node->param[AUDIODSP_GRAPH_TANH_LEVEL] = 1.0f; + break; case AUDIODSP_GRAPH_NOISE: { graph_noise_t *d = data; d->rng = 0x5EED1234u ^ (graph->nnodes * 0x9E3779B9u); @@ -1060,6 +1064,21 @@ static float graph_tanh(float x) { return p / q; } +static void run_tanh(const audiodsp_graph_t *graph, audiodsp_graph_node_t *n, + int16_t *out, uint32_t frames) { + const int16_t *in = n->inputs[0] == AUDIODSP_GRAPH_NONE + ? NULL : graph->bufs[n->inputs[0]]; + if (in == NULL) { + memset(out, 0, frames * sizeof(int16_t)); + return; + } + const float drive = n->param[AUDIODSP_GRAPH_TANH_DRIVE] / AUDIODSP_GRAPH_UNIT; + const float level = n->param[AUDIODSP_GRAPH_TANH_LEVEL] * AUDIODSP_GRAPH_UNIT; + for (uint32_t i = 0; i < frames; ++i) { + out[i] = sat16(graph_tanh((float)in[i] * drive) * level); + } +} + // 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. @@ -1407,6 +1426,9 @@ uint32_t audiodsp_graph_run(audiodsp_graph_t *graph, int16_t *out, case AUDIODSP_GRAPH_TWOPOLE: run_twopole(graph, n, dst, frames); break; + case AUDIODSP_GRAPH_TANH: + run_tanh(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 diff --git a/src/shared/audiodsp_graph.h b/src/shared/audiodsp_graph.h index b989bb7..77c1929 100644 --- a/src/shared/audiodsp_graph.h +++ b/src/shared/audiodsp_graph.h @@ -136,6 +136,11 @@ typedef enum { // (synthio.Biquad, CircuitPython's own Q15 arithmetic) whose gain tone // sets. AUDIODSP_GRAPH_OVERDRIVE, + // inputs: audio. params: drive, level. level * tanh(drive * x), x in + // units of AUDIODSP_GRAPH_UNIT, with the graph's own tanh so every target + // renders the same bytes. A saturating mixer, or the input stage of a + // ladder, where OVERDRIVE (Q15 distortion and a shelf) is the wrong shape. + AUDIODSP_GRAPH_TANH, AUDIODSP_GRAPH_KINDS, } audiodsp_graph_kind_t; @@ -234,6 +239,13 @@ enum { AUDIODSP_GRAPH_OVERDRIVE_LEVEL, }; +enum { + // Pre-gain on the input, in units of UNIT; 1 is unity. + AUDIODSP_GRAPH_TANH_DRIVE = 0, + // Linear gain after the tanh; 1 is unity (a full-scale +-UNIT). + AUDIODSP_GRAPH_TANH_LEVEL, +}; + enum { AUDIODSP_GRAPH_MIX_OFFSET = 8, }; diff --git a/tests/test_cpython_audiograph_nodes.py b/tests/test_cpython_audiograph_nodes.py index 4a19a0f..6323cda 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, SAMPLE_HOLD, TWOPOLE and OVERDRIVE, held to what they stand in for. +"""audiograph's PHASER, LAG, LFO shapes, SAMPLE_HOLD, TWOPOLE, OVERDRIVE and TANH, held to what they stand in for. | node | held to | bar | |---|---|---| @@ -15,6 +15,7 @@ | `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 | +| `TANH` | `level * UNIT * tanh(drive * x / UNIT)` in double precision, over a ramp through the clip, a program, and the knobs moving | 1 count (a few float ulps of tanh, which moves a half-count tie the other way) | 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 @@ -897,6 +898,116 @@ def test_reset_clears_the_shelf(self): self.assertEqual(first, again) +# -------------------------------------------------------------------- TANH -- + +def tanh_ref(block, drive, level): + """`level * UNIT * tanh(drive * x / UNIT)`, in double precision, rounded half to even.""" + return [sat(level * UNIT * math.tanh(drive * v / UNIT)) for v in block] + + +class Tanh(unittest.TestCase): + + def ramp(self, frames, blocks, amplitude): + # a triangle from -amplitude to +amplitude over the run, so every + # level from silence to the clip is met, with a little of a square + total = frames * blocks + values = [] + for i in range(total): + p = i / total + tri = 4.0 * p - 1.0 if p < 0.5 else 3.0 - 4.0 * p + values.append(max(-32768, min(32767, int(tri * amplitude) + + (300 if (i // 37) % 2 else -300)))) + return [values[b * frames:(b + 1) * frames] for b in range(blocks)] + + def compare(self, settings, frames=128, amplitude=32767): + rig = Rig(frames, ag.TANH, 1) + got, want = [], [] + for block, (drive, level) in zip(self.ramp(frames, len(settings), amplitude), + settings): + rig.set(ag.TANH_DRIVE, drive) + rig.set(ag.TANH_LEVEL, level) + got += rig.run(block) + want += tanh_ref(block, drive, level) + return got, want + + def test_matches_tanh(self): + # vrack's MonoSynth mixer drives 1.2 to 4.8 into it at a level of 0.65 + for drive, level in ((1.0, 1.0), (1.2, 0.65), (4.8, 0.43), (0.25, 1.0), + (8.0, 0.5), (1.0, 1.5)): + got, want = self.compare([(drive, level)] * 40) + self.assertLessEqual(worst(got, want), 1, (drive, level)) + + def test_it_saturates(self): + got, _ = self.compare([(4.0, 1.0)] * 20) + self.assertGreater(max(got), 16300) + self.assertLessEqual(max(got), 16384) + self.assertGreater(min(got), -16385) + # at unity gain a small signal passes nearly unchanged + small = Rig(128, ag.TANH, 1).run([v // 64 for v in range(-4096, 4096, 64)]) + self.assertLessEqual(worst(small, [v // 64 for v in range(-4096, 4096, 64)]), 1) + + def test_defaults_are_unity(self): + block = [(i * 211) % 4096 - 2048 for i in range(128)] + self.assertEqual(Rig(128, ag.TANH, 1).run(block), tanh_ref(block, 1.0, 1.0)) + + def test_matches_while_the_knobs_move(self): + settings = [(0.125 * (1 + b % 40), 0.0625 * (1 + b % 33)) for b in range(80)] + got, want = self.compare(settings) + self.assertLessEqual(worst(got, want), 1) + + def test_the_level_can_push_past_the_rail(self): + got, _ = self.compare([(8.0, 4.0)] * 10) + self.assertEqual(max(got), 32767) + self.assertEqual(min(got), -32768) + + def test_an_open_input_is_silence(self): + rig = Rig(64, ag.TANH, 1, open_=(0,)) + self.assertEqual(rig.run(), [0] * 64) + + def test_other_block_sizes(self): + for frames in (32, 64, 256): + got, want = self.compare([(1.2, 0.65)] * 20, frames=frames) + self.assertLessEqual(worst(got, want), 1, frames) + + def test_the_check_catches_a_wrong_tanh(self): + # planted faults in the reference, as a stand-in for them in the + # node: each must fail the 1-count bar this file holds the node to + frames, blocks = 128, 40 + settings = [(1.2, 0.65)] * blocks + got, _ = self.compare(settings) + blocks_in = self.ramp(frames, blocks, 32767) + wrong = { + "drive and level swapped": lambda b: tanh_ref(b, 0.65, 1.2), + "a clip, not a tanh": lambda b: [ + sat(0.65 * UNIT * max(-1.0, min(1.0, 1.2 * v / UNIT))) for v in b], + "the rational tanh in the rack's fast_tanh": lambda b: [ + sat(0.65 * UNIT * self.fast_tanh(1.2 * v / UNIT)) for v in b], + "drive off by 2%": lambda b: tanh_ref(b, 1.2 * 1.02, 0.65), + "level off by 1%": lambda b: tanh_ref(b, 1.2, 0.65 * 1.01), + } + for name, fault in wrong.items(): + want = [] + for block in blocks_in: + want += fault(block) + self.assertGreater(worst(got, want), 1, name) + + @staticmethod + def fast_tanh(x): + if x < -3.0: + return -1.0 + if x > 3.0: + return 1.0 + x2 = x * x + return x * (27.0 + x2) / (27.0 + 9.0 * x2) + + def test_reset_is_harmless(self): + rig = Rig(128, ag.TANH, 1) + block = [(i * 211) % 4096 - 2048 for i in range(128)] + first = rig.run(block) + rig.g.reset(rig.node) + self.assertEqual(first, rig.run(block)) + + # ------------------------------------------------------------- VCA curve -- class VcaCurve(unittest.TestCase):