Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/how-it-works/audiograph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/audiograph/module.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Expand Down
3 changes: 3 additions & 0 deletions src/circuitpython_spike/shared-bindings/audiograph/__init__.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Expand Down
3 changes: 3 additions & 0 deletions src/cpython/_audiodsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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},
Expand Down
3 changes: 3 additions & 0 deletions src/cpython/audiograph.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
SAMPLE_HOLD = _K["SAMPLE_HOLD"]
TWOPOLE = _K["TWOPOLE"]
OVERDRIVE = _K["OVERDRIVE"]
TANH = _K["TANH"]
ALWAYS = _K["ALWAYS"]
UNIT = _K["UNIT"]

Expand Down Expand Up @@ -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"]
Expand Down
22 changes: 22 additions & 0 deletions src/shared/audiodsp_graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/shared/audiodsp_graph.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
};
Expand Down
113 changes: 112 additions & 1 deletion tests/test_cpython_audiograph_nodes.py
Original file line number Diff line number Diff line change
@@ -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 |
|---|---|---|
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading