From 8fae8bad869cbc4def6cc4de9a21f7638fe95b2f Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:46:49 -0600 Subject: [PATCH] audiograph: BIQUAD_RAMP sets how long a biquad walks to new coefficients 0 to 1000 ms, the nearest power of two frames, and 0 is one frame. The default is the 20 ms every biquad has walked, and a graph that never sets it renders the same bytes. --- docs/how-it-works/audiograph.md | 15 +++- src/audiograph/module.c | 1 + .../shared-bindings/audiograph/__init__.c | 1 + src/cpython/_audiodsp.c | 3 +- src/cpython/audiograph.py | 1 + src/shared/audiodsp_filter_f32.c | 14 ++-- src/shared/audiodsp_filter_f32.h | 13 ++- src/shared/audiodsp_graph.c | 3 +- tests/parity/graph_nodes_probe.py | 37 ++++++++- tests/parity/verify_graph.py | 3 +- tests/test_cpython_audiobiquad.py | 83 +++++++++++++++++++ tests/test_cpython_audiograph_nodes.py | 51 ++++++++++++ 12 files changed, 210 insertions(+), 15 deletions(-) diff --git a/docs/how-it-works/audiograph.md b/docs/how-it-works/audiograph.md index 3ab5e7f1..2236e832 100644 --- a/docs/how-it-works/audiograph.md +++ b/docs/how-it-works/audiograph.md @@ -77,7 +77,7 @@ rather than its last block. | `CONST` | | 0: the value, in counts | that value every sample | | `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 | +| `BIQUAD` | audio | `BIQUAD_MODE`, `BIQUAD_FREQUENCY`, `BIQUAD_Q`, `BIQUAD_GAIN_DB`, `BIQUAD_MIX`, `BIQUAD_RAMP` ms 0..1000 (default 20) | audiobiquad's float biquad, [walking to new coefficients](#biquad-moves) | | `FBDELAY` | audio | `DELAY_MS`, `DELAY_FEEDBACK`, `DELAY_MIX`, `DELAY_DAMPING_HZ`, `DELAY_WOW_HZ`, `DELAY_WOW_DEPTH_MS` and audioecho's other options by number; built with `arg` = the line's length in ms; `table(node, int16_array)` sets its wow shape | audioecho's FeedbackDelay | | `ENV` | gate | `ENV_ATTACK`, `ENV_DECAY`, `ENV_RELEASE` in seconds, `ENV_SUSTAIN` 0..1 | an ADSR, 0..`UNIT`, each stage a one-pole approach, the attack aimed past 1.0 so it arrives with a slope | | `LFO` | | `LFO_RATE` Hz, `LFO_AMPLITUDE` counts, `LFO_SHAPE` (`LFO_TRIANGLE`, `LFO_SINE`, `LFO_SAW`, `LFO_SQUARE`, `LFO_RANDOM`) | that shape, ±amplitude, [below](#lfo-shapes) | @@ -116,6 +116,19 @@ modules that wrap them (`audiooscillator`, `audioladder`, `audiobiquad`, An input left `None` is open: an oscillator without a pitch input plays `OSC_FREQUENCY`, a ladder without a cutoff input sits at `LADDER_CUTOFF_HZ`. +### Biquad moves + +A new frequency, Q or gain doesn't land on a `BIQUAD` at once: the filter walks +to it, so a jump doesn't thump. `BIQUAD_RAMP` says for how long, in milliseconds +from 0 to 1000, and the default of 20 is what `audiobiquad.Biquad` always does. +The walk is a power of two frames, the one nearest what you asked for: 20 ms at +48 kHz is 1024 frames, and 0 is one frame, the shortest there is. + +Set it low when a value moves every block. Each move starts the walk again from +where it had got to, so a filter envelope on a 20 ms walk trails behind what it +is told and opens slowly. One frame follows it the way `synthio.Biquad` does. +The new value takes effect at the next move, and setting it isn't a move. + ### LFO shapes Every shape comes from one phase that starts at 0 and has taken one step by diff --git a/src/audiograph/module.c b/src/audiograph/module.c index c455de01..46f36062 100644 --- a/src/audiograph/module.c +++ b/src/audiograph/module.c @@ -185,6 +185,7 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_BIQUAD_Q), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_Q) }, { MP_ROM_QSTR(MP_QSTR_BIQUAD_GAIN_DB), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_GAIN_DB) }, { MP_ROM_QSTR(MP_QSTR_BIQUAD_MIX), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_MIX) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_RAMP), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_RAMP) }, { MP_ROM_QSTR(MP_QSTR_DELAY_MS), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS) }, { MP_ROM_QSTR(MP_QSTR_DELAY_FEEDBACK), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_FEEDBACK) }, { MP_ROM_QSTR(MP_QSTR_DELAY_MIX), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_MIX) }, diff --git a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c index 5e2cd395..206dd837 100644 --- a/src/circuitpython_spike/shared-bindings/audiograph/__init__.c +++ b/src/circuitpython_spike/shared-bindings/audiograph/__init__.c @@ -203,6 +203,7 @@ static const mp_rom_map_elem_t audiograph_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_BIQUAD_Q), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_Q) }, { MP_ROM_QSTR(MP_QSTR_BIQUAD_GAIN_DB), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_GAIN_DB) }, { MP_ROM_QSTR(MP_QSTR_BIQUAD_MIX), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_MIX) }, + { MP_ROM_QSTR(MP_QSTR_BIQUAD_RAMP), MP_ROM_INT(AUDIODSP_BIQUAD_F32_OPT_RAMP) }, { MP_ROM_QSTR(MP_QSTR_DELAY_MS), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS) }, { MP_ROM_QSTR(MP_QSTR_DELAY_FEEDBACK), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_FEEDBACK) }, { MP_ROM_QSTR(MP_QSTR_DELAY_MIX), MP_ROM_INT(AUDIODSP_FEEDBACK_DELAY_OPT_MIX) }, diff --git a/src/cpython/_audiodsp.c b/src/cpython/_audiodsp.c index 7bd4961a..31c33d61 100644 --- a/src/cpython/_audiodsp.c +++ b/src/cpython/_audiodsp.c @@ -1394,7 +1394,7 @@ static PyObject *biquad_f32_state_configure( double value; if (!PyArg_ParseTuple(args, "id:configure", &option, &value)) return NULL; if (option < AUDIODSP_BIQUAD_F32_OPT_MODE || - option > AUDIODSP_BIQUAD_F32_OPT_MIX) { + option > AUDIODSP_BIQUAD_F32_OPT_RAMP) { PyErr_SetString(PyExc_ValueError, "unknown biquad option"); return NULL; } @@ -5117,6 +5117,7 @@ static int graph_add_constants(PyObject *module) { {"BIQUAD_Q", AUDIODSP_BIQUAD_F32_OPT_Q}, {"BIQUAD_GAIN_DB", AUDIODSP_BIQUAD_F32_OPT_GAIN_DB}, {"BIQUAD_MIX", AUDIODSP_BIQUAD_F32_OPT_MIX}, + {"BIQUAD_RAMP", AUDIODSP_BIQUAD_F32_OPT_RAMP}, {"DELAY_MS", AUDIODSP_FEEDBACK_DELAY_OPT_DELAY_MS}, {"DELAY_FEEDBACK", AUDIODSP_FEEDBACK_DELAY_OPT_FEEDBACK}, {"DELAY_MIX", AUDIODSP_FEEDBACK_DELAY_OPT_MIX}, diff --git a/src/cpython/audiograph.py b/src/cpython/audiograph.py index a52c69e7..b91a2aad 100644 --- a/src/cpython/audiograph.py +++ b/src/cpython/audiograph.py @@ -211,6 +211,7 @@ BIQUAD_Q = _K["BIQUAD_Q"] BIQUAD_GAIN_DB = _K["BIQUAD_GAIN_DB"] BIQUAD_MIX = _K["BIQUAD_MIX"] +BIQUAD_RAMP = _K["BIQUAD_RAMP"] DELAY_MS = _K["DELAY_MS"] DELAY_FEEDBACK = _K["DELAY_FEEDBACK"] DELAY_MIX = _K["DELAY_MIX"] diff --git a/src/shared/audiodsp_filter_f32.c b/src/shared/audiodsp_filter_f32.c index 35e2f890..ce4f9be2 100644 --- a/src/shared/audiodsp_filter_f32.c +++ b/src/shared/audiodsp_filter_f32.c @@ -74,6 +74,7 @@ void audiodsp_biquad_f32_config_init(audiodsp_biquad_f32_config_t *config, config->q = 0.7071067811865475f; config->gain_db = 0.0f; config->mix = 1.0f; + config->ramp_ms = (float)AUDIODSP_BIQUAD_F32_RAMP_MS; config->derived = false; } @@ -107,6 +108,9 @@ void audiodsp_biquad_f32_configure(audiodsp_biquad_f32_config_t *config, case AUDIODSP_BIQUAD_F32_OPT_MIX: config->mix = clampf(value, 0.0f, 1.0f); break; + case AUDIODSP_BIQUAD_F32_OPT_RAMP: + config->ramp_ms = clampf(value, 0.0f, 1000.0f); + break; default: break; } @@ -338,14 +342,13 @@ static void biquad_leave_svf(audiodsp_biquad_f32_state_t *state, } } -// Frames in a move: the power of two nearest AUDIODSP_BIQUAD_F32_RAMP_MS. +// Frames in a move: the power of two nearest `ramp_ms`, and at least one. // A power of two because the walk's per-frame ratio is then a chain of // square roots, which IEEE rounds correctly everywhere -- pow() is three // functions on three targets that nearly agree, and this has to render the // same bytes on all of them (see audiodsp_trig.h). -static uint32_t biquad_ramp_shift(uint32_t sample_rate) { - const double frames = (double)sample_rate * - (double)AUDIODSP_BIQUAD_F32_RAMP_MS / 1000.0; +static uint32_t biquad_ramp_shift(uint32_t sample_rate, float ramp_ms) { + const double frames = (double)sample_rate * (double)ramp_ms / 1000.0; uint32_t shift = 0; while (shift < 20u && (double)(1u << shift) * 1.4142135623730951 < frames) { @@ -411,7 +414,8 @@ static void biquad_begin_move(const audiodsp_biquad_f32_config_t *config, state->ramp_left = 0u; return; } - const uint32_t shift = biquad_ramp_shift(config->sample_rate); + const uint32_t shift = biquad_ramp_shift(config->sample_rate, + config->ramp_ms); const uint32_t frames = 1u << shift; state->g_step = (float)biquad_root_chain( (double)config->svf_g / (double)state->g, shift); diff --git a/src/shared/audiodsp_filter_f32.h b/src/shared/audiodsp_filter_f32.h index f6454868..184c06f9 100644 --- a/src/shared/audiodsp_filter_f32.h +++ b/src/shared/audiodsp_filter_f32.h @@ -85,6 +85,7 @@ typedef enum { AUDIODSP_BIQUAD_F32_OPT_Q, AUDIODSP_BIQUAD_F32_OPT_GAIN_DB, AUDIODSP_BIQUAD_F32_OPT_MIX, + AUDIODSP_BIQUAD_F32_OPT_RAMP, } audiodsp_biquad_f32_option_t; //: Everything derived from the constructor/`set()` arguments, held apart from @@ -99,6 +100,11 @@ typedef struct { // 0..1, a crossfade between the untouched signal and the filtered one -- // `audiofilters.Filter`'s convention (Filter.c:288), not `Echo`'s 0..2. float mix; + // 0..1000 ms: how long a coefficient move takes, rounded to the nearest + // power of two frames, and 0 is one frame. Read when a move begins, so a + // change takes effect at the next move, not during one. The default is + // AUDIODSP_BIQUAD_F32_RAMP_MS. + float ramp_ms; // Written by audiodsp_biquad_f32_config_finish() from everything above. float b0, b1, b2, a1, a2; // The same section as a state-variable filter (Simper's trapezoidal @@ -153,7 +159,7 @@ typedef struct { // do not mean the same thing under another: state a 20 kHz low-pass built, // read through a 20 Hz one, is a full-scale step that rings for 100 ms // (audiocomponents#162). So when the coefficients change, the kernel stops -// running transposed direct form II for `AUDIODSP_BIQUAD_F32_RAMP_MS`: it +// running transposed direct form II for the config's `ramp_ms`: it // carries the memory over into a state-variable filter's two integrators -- // whose words are the band and low outputs, and so mean the same thing at // every corner -- walks that SVF's corner and damping geometrically and its @@ -181,8 +187,9 @@ typedef struct { bool primed; } audiodsp_biquad_f32_state_t; -//: How long a coefficient move takes. 20 ms, the ramp -//: audiocomponents' docs/control-smoothing.md gives every control move. +//: How long a coefficient move takes unless AUDIODSP_BIQUAD_F32_OPT_RAMP says +//: otherwise. 20 ms, the ramp audiocomponents' docs/control-smoothing.md gives +//: every control move. #define AUDIODSP_BIQUAD_F32_RAMP_MS 20u void audiodsp_biquad_f32_config_init(audiodsp_biquad_f32_config_t *config, diff --git a/src/shared/audiodsp_graph.c b/src/shared/audiodsp_graph.c index 02fdf98c..8c014068 100644 --- a/src/shared/audiodsp_graph.c +++ b/src/shared/audiodsp_graph.c @@ -530,6 +530,7 @@ int audiodsp_graph_add(audiodsp_graph_t *graph, audiodsp_graph_kind_t kind, node->param[AUDIODSP_BIQUAD_F32_OPT_Q] = d->config.q; node->param[AUDIODSP_BIQUAD_F32_OPT_GAIN_DB] = d->config.gain_db; node->param[AUDIODSP_BIQUAD_F32_OPT_MIX] = d->config.mix; + node->param[AUDIODSP_BIQUAD_F32_OPT_RAMP] = d->config.ramp_ms; break; } case AUDIODSP_GRAPH_FBDELAY: { @@ -1295,7 +1296,7 @@ static void derive(audiodsp_graph_t *graph, audiodsp_graph_node_t *n) { } case AUDIODSP_GRAPH_BIQUAD: { graph_biquad_t *d = n->data; - for (uint32_t i = 0; i <= AUDIODSP_BIQUAD_F32_OPT_MIX; ++i) { + for (uint32_t i = 0; i <= AUDIODSP_BIQUAD_F32_OPT_RAMP; ++i) { audiodsp_biquad_f32_configure(&d->config, (audiodsp_biquad_f32_option_t)i, n->param[i]); } diff --git a/tests/parity/graph_nodes_probe.py b/tests/parity/graph_nodes_probe.py index 4813e337..55304f37 100644 --- a/tests/parity/graph_nodes_probe.py +++ b/tests/parity/graph_nodes_probe.py @@ -1,4 +1,4 @@ -"""audiograph's PHASER, LAG, LFO shapes, SAMPLE_HOLD, TWOPOLE, OVERDRIVE, PAN, WAVEFOLD, LPG, MODAL, STRUCK, PICKUP, TREMOLO, DECAY, PINK, CRUSH, ACID, SPRING, ADSR2, GLISS, FOLLOW, WAVETABLE and ROTARY, rendered for verify_graph.py. +"""audiograph's PHASER, LAG, LFO shapes, SAMPLE_HOLD, TWOPOLE, OVERDRIVE, PAN, WAVEFOLD, LPG, MODAL, STRUCK, PICKUP, TREMOLO, DECAY, PINK, CRUSH, ACID, SPRING, ADSR2, GLISS, FOLLOW, WAVETABLE, ROTARY and BIQUAD_RAMP, 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 @@ -24,6 +24,7 @@ graph_nodes_probe.py --fault follow # one release time a block late graph_nodes_probe.py --fault wavetable # one frequency change a block late graph_nodes_probe.py --fault rotary # one speed change a block late + graph_nodes_probe.py --fault ramp # one BIQUAD_RAMP 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 @@ -62,7 +63,10 @@ drawbar voices: one on a written pitch, gate and percussion line, its key coming down again while the last note still sounds, and one with no gate and its frequency stepped; and two rotary speakers, the sawtooth driven through -slow, fast and stop and the noise clean and fast, both sides printed. +slow, fast and stop and the noise clean and fast, both sides printed. And four +biquads on the sawtooth with a cutoff that moves each block: one that never +sets BIQUAD_RAMP, one that sets it to its default and must render the same, one +at the shortest, one frame, and one that changes its ramp part way through. 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. @@ -88,7 +92,7 @@ FAULTS = ("phaser", "lag", "lfo", "hold", "twopole", "overdrive", "pan", "wavefold", "lpg", "modal", "struck", "pickup", "tremolo", "decay", "pink", "crush", "acid", "spring", - "adsr2", "gliss", "follow", "wavetable", "rotary") + "adsr2", "gliss", "follow", "wavetable", "rotary", "ramp") FAULT = None if "--fault" in sys.argv: FAULT = sys.argv[sys.argv.index("--fault") + 1] @@ -135,6 +139,20 @@ def node(name, kind, inputs, arg=0.0): g.set(ph6, ag.PHASER_FEEDBACK, -0.375) g.set(ph6, ag.PHASER_MIX, 0.75) +# -- biquads with a cutoff moving each block, at the walks BIQUAD_RAMP gives +ramp_nodes = [] +for name, ramp in (("biquad_walk", None), ("biquad_twenty", 20.0), ("biquad_snap", 0.0), + ("biquad_moved", 20.0)): + index, _ = node(name, ag.BIQUAD, (saw_b,)) + g.set(index, ag.BIQUAD_Q, 2.0) + if ramp is not None: + g.set(index, ag.BIQUAD_RAMP, ramp) + ramp_nodes.append(index) +check(g.get(ramp_nodes[0], ag.BIQUAD_RAMP) == 20.0, "BIQUAD_RAMP starts at 20 ms") +walk_block = None +ramp_same = True +ramp_differs = False + # -- one LFO of each shape lfos = [] for name, shape, rate in (("triangle", ag.LFO_TRIANGLE, 5.5), @@ -438,6 +456,10 @@ def node(name, kind, inputs, arg=0.0): step = centre % 64 tri = step if step < 32 else 64 - step g.set(ph4, ag.PHASER_FREQUENCY, 100.0 + 125.0 * tri) + for index in ramp_nodes: + g.set(index, ag.BIQUAD_FREQUENCY, 250.0 + 125.0 * tri) + if b == (41 if FAULT == "ramp" else 40): + g.set(ramp_nodes[3], ag.BIQUAD_RAMP, 2.5) # the note line: a new note every few blocks, one mid-block; a bend that # moves every sample; legato on two blocks in three @@ -649,6 +671,12 @@ def node(name, kind, inputs, arg=0.0): for name, buffer in watched: g.read(buffer, block) print("node %d %s %s" % (b, name, " ".join([str(v) for v in block]))) + if name == "biquad_walk": + walk_block = list(block) + elif name == "biquad_twenty": + ramp_same = ramp_same and list(block) == walk_block + elif name == "biquad_snap": + ramp_differs = ramp_differs or list(block) != walk_block stats = seen.setdefault(name, [0, block[0], block[0], block[0]]) for v in block: if v != stats[3]: @@ -749,6 +777,9 @@ def node(name, kind, inputs, arg=0.0): check(seen[name][0] > 5000 and seen[name][2] > 1000, "the rotary speaker's %s renders sound" % name) +check(ramp_same, "BIQUAD_RAMP set to its default renders what no setting renders") +check(ramp_differs, "the shortest BIQUAD_RAMP moves the sound") + if failures: for what in failures: print("FAIL " + what) diff --git a/tests/parity/verify_graph.py b/tests/parity/verify_graph.py index d0c0c6f0..f621414d 100644 --- a/tests/parity/verify_graph.py +++ b/tests/parity/verify_graph.py @@ -8,6 +8,7 @@ verify_graph.py --micropython PATH --fault twopole # must FAIL (and overdrive, pan, wavefold, lpg, modal) verify_graph.py --micropython PATH --fault decay # must FAIL (and pink, crush, acid, spring, adsr2, gliss, follow) verify_graph.py --micropython PATH --fault wavetable # must FAIL (and rotary) + verify_graph.py --micropython PATH --fault ramp # must FAIL verify_graph.py --micropython PATH --fault drain # must FAIL (and split) verify_graph.py --micropython PATH --fault offbeat # must FAIL @@ -55,7 +56,7 @@ ("runner", HERE / "graph_parity_probe.py", ("lsb", "late")), ("nodes", HERE / "graph_nodes_probe.py", ("phaser", "lag", "lfo", "hold", "twopole", "overdrive", "pan", "wavefold", "lpg", "modal", "struck", "pickup", "tremolo", "decay", "pink", "crush", "acid", "spring", "adsr2", "gliss", "follow", - "wavetable", "rotary")), + "wavetable", "rotary", "ramp")), ("source", HERE / "graph_source_probe.py", ("drain", "split")), ("carry", HERE / "graph_carry_probe.py", ("offbeat",)), ) diff --git a/tests/test_cpython_audiobiquad.py b/tests/test_cpython_audiobiquad.py index 22d09186..3eb23723 100644 --- a/tests/test_cpython_audiobiquad.py +++ b/tests/test_cpython_audiobiquad.py @@ -45,6 +45,8 @@ | B14 | After that jump the output settles onto the new filter's | within 64 LSB inside 75 ms | | B15 | A slow sweep set once a block does not zipper | curvature spike under 4 LSB | | B16 | Nothing moves when the coefficients do not | exact, a block at a time against one call | +| B17 | `BIQUAD_RAMP` sets how long a move walks, and 0 is one frame | the new section's recursion holds from the frame the walk ends | +| B18 | `BIQUAD_RAMP` at its default is no setting at all, and changing it alone moves nothing | exact | **B11's recorded departure is CLOSED (audiodsp#64, 2026-09-09).** It had two causes and only one of them was the filter. The measurement ended before the @@ -371,6 +373,7 @@ def test_a_block_input_is_read_once_per_chunk(self): RAMP_RATE = 48000 OPT_MODE, OPT_FREQUENCY, OPT_Q = 0, 1, 2 +OPT_RAMP = 5 def _words(data): @@ -557,6 +560,86 @@ def test_every_corner_and_q_stays_bounded(self): "then %d" % (first, last)) + +def walk_frames(ramp=None, block=256): + """Frames after a move before the output obeys the new section's recursion. + + A low-pass moved from 500 Hz to 4 kHz, with `ramp` set if it is given. The + recursion reaches back two outputs and two inputs, and the memory taken + over at the end of a walk speaks for the output before it, so a walk of + `n` frames reads as `n + 2` here. A one-frame walk reads as 3 at most.""" + lead, after = 2048, 8192 + source = _triangle(lead + after, level=12000) + state = _kernel(audiobiquad.LOW_PASS, 500.0, 2.0) + if ramp is not None: + state.configure(OPT_RAMP, ramp) + out = _render(state, source, [(lead, 4000.0)], block) + b0, b1, b2, a1, a2 = state.coefficients() + last = -1 + for index in range(after): + n = lead + index + want = (b0 * source[n] + b1 * source[n - 1] + b2 * source[n - 2] + - a1 * out[n - 1] - a2 * out[n - 2]) + if abs(want - out[n]) > 4: + last = index + return last + 1 + + +class BiquadRampTest(unittest.TestCase): + """B17-B18: the option that shortens a move, on the kernel.""" + + def test_the_shortest_ramp_is_one_frame(self): + """B17, on a block of 256 frames and on one of 32.""" + self.assertLessEqual(walk_frames(0.0), 3) + self.assertLessEqual(walk_frames(0.0, block=32), 3) + + def test_the_ramp_sets_the_walk(self): + """B17. The frames are the power of two nearest the milliseconds, so + 5 ms at 48 kHz is 256. The bars are low because the last frames of a + walk are already close to where it ends.""" + for ramp, frames in ((None, 1024), (20.0, 1024), (5.0, 256), + (2.0, 128)): + with self.subTest(ramp=ramp): + walked = walk_frames(ramp) + self.assertGreater(walked, frames // 2) + self.assertLessEqual(walked, frames + 2) + + def test_past_the_ends_it_clamps(self): + """Below 0 is 0, and past 1000 ms is 1000.""" + source = _triangle(9600, level=12000) + rendered = {} + for ramp in (-1.0, 0.0, 1000.0, 1000000.0): + state = _kernel(audiobiquad.LOW_PASS, 500.0, 2.0) + state.configure(OPT_RAMP, ramp) + rendered[ramp] = _words(_render(state, source, [(2048, 4000.0)])) + self.assertEqual(rendered[-1.0], rendered[0.0]) + self.assertEqual(rendered[1000000.0], rendered[1000.0]) + self.assertNotEqual(rendered[0.0], rendered[1000.0]) + + def test_the_default_is_no_setting(self): + """B18. What makes the option safe to add: goldens that never set it + are still the bytes they were.""" + source = _triangle(9600, level=12000) + moves = [(start, 500.0 + start / 4) for start in range(0, 9600, 256)] + plain = _kernel(audiobiquad.LOW_PASS, 500.0, 2.0) + twenty = _kernel(audiobiquad.LOW_PASS, 500.0, 2.0) + twenty.configure(OPT_RAMP, 20.0) + self.assertEqual(_render(plain, source, moves), + _render(twenty, source, moves)) + + def test_setting_the_ramp_is_not_a_move(self): + """B18, a change of ramp with the coefficients still.""" + source = _triangle(9600, level=12000) + plain = _render(_kernel(audiobiquad.LOW_PASS, 500.0, 2.0), source) + state = _kernel(audiobiquad.LOW_PASS, 500.0, 2.0) + out = array("h") + for start in range(0, 9600, 256): + state.configure(OPT_RAMP, 0.0 if (start // 256) % 2 else 5.0) + state.finish() + out.extend(_words(state.process(source[start:start + 256]))) + self.assertEqual(plain, out) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cpython_audiograph_nodes.py b/tests/test_cpython_audiograph_nodes.py index 7a46fdfd..de7b2553 100644 --- a/tests/test_cpython_audiograph_nodes.py +++ b/tests/test_cpython_audiograph_nodes.py @@ -3,6 +3,7 @@ | node | held to | bar | |---|---|---| | `PHASER` | `audiobiquad.AllPass` on one channel, fed the same frequency, feedback and mix each 256-frame block | 0 counts | +| `BIQUAD`, `BIQUAD_RAMP` | the float kernel with the same ramp, a move every block | 0 counts | | `LAG`, linear | vrack's `dsp/glide.py` `GlideCore` (OFF, ON, AUTO with legato, a new note mid-glide, the bend on top) | 0 counts | | `LAG`, one-pole | vrack's `dsp/utility.py` `GlideCore` | 1 count | | `LFO` triangle | the runner's triangle before the shapes existed, arithmetic for arithmetic | 0 counts | @@ -41,6 +42,7 @@ import math import unittest +import _audiodsp import audiobiquad import audiocore import audiofilters @@ -1208,6 +1210,55 @@ def test_defaults_are_unity_and_centred(self): self.assertEqual(g.get(node, ag.PAN_PAN + i), 0.0) + +# ------------------------------------------------------------- BIQUAD_RAMP -- + +class BiquadRamp(unittest.TestCase): + """A move every block, as a filter envelope makes, at each ramp.""" + + SWEEP = [250.0 * 2.0 ** (block / 4.0) for block in range(16)] + + def node(self, ramp): + rig = Rig(256, ag.BIQUAD, 1) + rig.set(ag.BIQUAD_Q, 2.0) + if ramp is not None: + rig.set(ag.BIQUAD_RAMP, ramp) + source = program(len(self.SWEEP)) + out = [] + for index, hz in enumerate(self.SWEEP): + rig.set(ag.BIQUAD_FREQUENCY, hz) + out += rig.run(source[index * 256:(index + 1) * 256]) + return out + + def kernel(self, ramp): + state = _audiodsp.BiquadF32State(sample_rate=RATE, channel_count=1) + state.configure(ag.BIQUAD_Q, 2.0) + if ramp is not None: + state.configure(ag.BIQUAD_RAMP, ramp) + source = program(len(self.SWEEP)) + out = [] + for index, hz in enumerate(self.SWEEP): + state.configure(ag.BIQUAD_FREQUENCY, hz) + state.finish() + out += array("h", state.process(source[index * 256:(index + 1) * 256])) + return out + + def test_it_reads_back_and_starts_at_twenty(self): + rig = Rig(256, ag.BIQUAD, 1) + self.assertEqual(rig.g.get(rig.node, ag.BIQUAD_RAMP), 20.0) + rig.set(ag.BIQUAD_RAMP, 0.0) + self.assertEqual(rig.g.get(rig.node, ag.BIQUAD_RAMP), 0.0) + + def test_the_node_matches_the_kernel(self): + for ramp in (None, 20.0, 5.0, 0.0): + with self.subTest(ramp=ramp): + self.assertEqual(self.node(ramp), self.kernel(ramp)) + + def test_the_default_is_no_setting_and_the_shortest_is_not(self): + self.assertEqual(self.node(None), self.node(20.0)) + self.assertGreater(worst(self.node(None), self.node(0.0)), 200) + + if __name__ == "__main__": unittest.main()