diff --git a/REVIVAL.md b/REVIVAL.md index e6a74831..6ca212e4 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -1088,6 +1088,34 @@ GitHub Actions CI. where the field guide left it — gated on an A/B showing an audible delta the informed model misses, which the DAFx-14 finding suggests may never open. Remaining: A/B calibration of the swing `drive` against reference, and runtime validation in Max. +- ✅ **Net-new object — `tap.overdrive~` (2026-07-22).** The spiritual successor to the Jamoma-era + `tap.overdrive~`, deliberately *not* a port (per the overdrive handoff brief): TTOverdrive's two + memoryless odd-function curves — the sine shaper's hard ±1 plateau and its aliasing were the + "digital" tell — are dropped, replaced by a voiced feedback clipper chasing the *class* of + TS-lineage pedals (Mad Professor Little Green Wonder as the listening reference). Kernel: + **`overdrive.h`** (`tap::tools::od`) — the nonlinearity sits inside a lowpass **feedback loop** + solved zero-delay (the `svf.h` driven-circuit / `ladder.h` solver_fast one-pass scheme), with the + LF loop gain pinned so bass stays tight and nearly clean at any drive while mids take the full + gain — the frequency-dependent clipping a static curve cannot do, and most of the perceptual + distance to the reference. Even harmonics via an `asymmetry` bias (0..1), a unity clean-through so + the transfer never flattens, an always-on DC blocker (`R = 0.9997`, the TTDCBlock constant — this + time actually in the signal path, unlike TTOverdrive's vestigial one), `body` (−1..+1) sliding the + pre-clipper HP corner + CW upper-mid push / CCW treble lift (all linear voicing EQ, placeholders + pending the by-ear pass against LGW demos), 1/2/4/8× oversampling (default 4×), and normalized + 0..1 parameters throughout (mappable, and Q15/Q31-ready for a future fixed-point M33 target). + Kernel Catch2 tests pin the design goals: gain-tilt-grows-with-drive, even-harmonic emergence, + DC blocking, never-flat slope, alias-floor improvement with oversampling, body tilt, decay to + silence, determinism. Wrapper `tap.overdrive_tilde` (thin `vector_operator`), attributes + `drive`/`body`/`asymmetry`/`preamp`/`output`/`oversample`/`smooth`/`bypass`/`mute`, min-api + attribute tests, the full vertical slice (maxref + help patcher + a bypass-passthrough + `maxtest` starter). Same-day follow-up: the rest of the kernel-repo house treatment — a + `taptools_od_*` C ABI + `Overdrive` ctypes class, the **executed verification notebook** + (`overdrive.ipynb`: tilt +5/+16.3/+17.2 dB at drive 0/0.5/0.9; H2 −151 → −26 dB across + asymmetry; folded H7 −22 → −36 dB at 4×; DC means ~1e-10), and two field-guide chapters — + *Distortion with a memory* (new book Part VII, The pedalboard — after `tap.tune~`'s Part VI, + Staying in tune, landed the same week) + *The clipper in the loop: overdrive.h* (machine part, + now VIII). Remaining: the in-Max voicing pass (the + `k_voice_*` constants are the sound of the object) and runtime validation. --- diff --git a/docs/tap.overdrive~.maxref.xml b/docs/tap.overdrive~.maxref.xml new file mode 100644 index 00000000..af05c82e --- /dev/null +++ b/docs/tap.overdrive~.maxref.xml @@ -0,0 +1,96 @@ + + + + + + + Voiced feedback overdrive / saturation + + + + The tap.overdrive~ object is a soft-clipping overdrive whose nonlinearity sits + inside a lowpass feedback loop — the topology of TS-lineage feedback pedals (voiced + against the Mad Professor Little Green Wonder) — rather than a static waveshaping curve + applied to the whole spectrum. The result is frequency-dependent clipping: bass stays + tight and nearly clean at any drive while mids and highs take the full gain and break up + first, and the tilt grows as the drive rises. The asymmetry attribute biases the clipper + for even-harmonic warmth (a built-in DC blocker removes the offset this creates), and a + unity clean path is summed around the clipper so the transfer keeps rising with reduced + slope instead of flattening into a hard digital plateau. The body attribute is the + signature voicing control: toward -1 fuller lows reach the clipper and the top gets a + slight lift; toward +1 the lows tighten and thin and the upper mids push forward. The + clipping stage runs oversampled (default 4x) to keep aliasing down, and all parameters + glide smoothly. This object is the spiritual successor to the original Jamoma-era + tap.overdrive~, replacing its memoryless waveshaper modes. Single-channel: wrap in mc. + for multichannel. + + + + + + 74 Objects + TapTools + Audio + Distortion + + + + + + + + Reset the filter, feedback, and resampler state. + + + + + + + + Drive (0-1). 0 is edge-of-breakup — a pedal's gain knob full + counterclockwise, still warm — and 1 is heavily saturated. The sweep is perceptual + (dB of gain into the clipper); use bypass for a true clean signal. + + + Body voicing (-1..+1, 0 neutral). Toward -1: fuller lows into the + clipper and a slight treble lift. Toward +1: thinner, tighter lows and an upper-mid + push. Linear pre/post voicing around the clipper, not part of the + nonlinearity. + + + Asymmetry (0-1): even-harmonic amount. 0 is a symmetric clip (odd + harmonics only); raising it biases the clipper for even-order warmth. The DC this + generates is removed by the built-in DC blocker. + + + Input gain in dB (-24..24), applied before the clipper. + + + Makeup gain in dB (-24..24), applied at the output. + + + Internal oversampling factor for the clipping stage (1, 2, 4, or 8; + default 4). Reduces aliasing from the clipper's harmonics. + + + Anti-zipper interpolation time in milliseconds applied to parameter changes. + + + Pass the input through unprocessed. + + + Silence the output. + + + + + + + + + + + + + + diff --git a/help/tap.overdrive~.maxhelp b/help/tap.overdrive~.maxhelp new file mode 100644 index 00000000..d93bd3d9 --- /dev/null +++ b/help/tap.overdrive~.maxhelp @@ -0,0 +1,311 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 8, + "minor" : 5, + "revision" : 0, + "architecture" : "x64", + "modernui" : 1 + } +, + "classnamespace" : "box", + "rect" : [ 84.0, 104.0, 700.0, 560.0 ], + "bglocked" : 0, + "openinpresentation" : 0, + "default_fontsize" : 12.0, + "default_fontface" : 0, + "default_fontname" : "Arial", + "gridonopen" : 1, + "gridsize" : [ 15.0, 15.0 ], + "gridsnaponopen" : 1, + "objectsnaponopen" : 1, + "statusbarvisible" : 2, + "toolbarvisible" : 1, + "lefttoolbarpinned" : 0, + "toptoolbarpinned" : 0, + "righttoolbarpinned" : 0, + "bottomtoolbarpinned" : 0, + "toolbars_unpinned_last_save" : 0, + "tallnewobj" : 0, + "boxanimatetime" : 200, + "enablehscroll" : 1, + "enablevscroll" : 1, + "devicewidth" : 0.0, + "description" : "", + "digest" : "", + "tags" : "", + "style" : "", + "subpatcher_template" : "", + "boxes" : [ { + "box" : { + "fontface" : 1, + "fontsize" : 14.0, + "id" : "obj-1", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 12.0, 480.0, 23.0 ], + "text" : "tap.overdrive~ : voiced feedback overdrive / saturation" + } + + } +, { + "box" : { + "id" : "obj-2", + "linecount" : 4, + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 38.0, 660.0, 62.0 ], + "text" : "The clipper sits inside a lowpass feedback loop, so bass stays tight and nearly clean while mids and highs break up first — try a low note and raise the drive. asymmetry adds even-harmonic warmth (watch the spectroscope). body voices the pedal: toward -1 fuller lows and a slight treble lift, toward +1 tighter lows and an upper-mid push. The clipping stage runs oversampled (default 4x) with an always-on DC blocker." + } + + } +, { + "box" : { + "id" : "obj-3", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 20.0, 120.0, 74.0, 22.0 ], + "text" : "cycle~ 110" + } + + } +, { + "box" : { + "id" : "obj-4", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 100.0, 123.0, 220.0, 20.0 ], + "text" : "a low note shows the bass staying tight" + } + + } +, { + "box" : { + "attr" : "drive", + "id" : "obj-5", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 155.0, 200.0, 22.0 ] + } + + } +, { + "box" : { + "attr" : "body", + "id" : "obj-6", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 183.0, 200.0, 22.0 ] + } + + } +, { + "box" : { + "attr" : "asymmetry", + "id" : "obj-7", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 211.0, 200.0, 22.0 ] + } + + } +, { + "box" : { + "attr" : "oversample", + "id" : "obj-8", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 239.0, 200.0, 22.0 ] + } + + } +, { + "box" : { + "attr" : "output", + "id" : "obj-9", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 267.0, 200.0, 22.0 ] + } + + } +, { + "box" : { + "id" : "obj-10", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 20.0, 310.0, 96.0, 22.0 ], + "text" : "tap.overdrive~" + } + + } +, { + "box" : { + "id" : "obj-11", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 130.0, 275.0, 42.0, 22.0 ], + "text" : "clear" + } + + } +, { + "box" : { + "id" : "obj-12", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 20.0, 355.0, 74.0, 22.0 ], + "text" : "*~ 0.5" + } + + } +, { + "box" : { + "id" : "obj-13", + "maxclass" : "newobj", + "numinlets" : 2, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 415.0, 130.0, 22.0 ], + "text" : "ezdac~" + } + + } +, { + "box" : { + "id" : "obj-14", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 250.0, 330.0, 180.0, 100.0 ], + "text" : "scope~" + } + + } +, { + "box" : { + "id" : "obj-15", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 450.0, 330.0, 220.0, 100.0 ], + "text" : "spectroscope~" + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-3", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-5", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-6", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-7", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-8", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-9", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-10", 0 ], + "source" : [ "obj-11", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-12", 0 ], + "source" : [ "obj-10", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-14", 0 ], + "source" : [ "obj-10", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-15", 0 ], + "source" : [ "obj-10", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-13", 0 ], + "source" : [ "obj-12", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-13", 1 ], + "source" : [ "obj-12", 0 ] + } + + } + ], + "dependency_cache" : [ { + "name" : "tap.overdrive~.mxo", + "type" : "iLaX" + } + ], + "autosave" : 0 + } + +} diff --git a/runtime-tests/make_maxtest.py b/runtime-tests/make_maxtest.py index 6301d69e..c7dfc3be 100644 --- a/runtime-tests/make_maxtest.py +++ b/runtime-tests/make_maxtest.py @@ -202,3 +202,14 @@ def control_test(filename, object_text, trigger, expected, assert_name, description="tap.tune~: unpitched DC passes the grain engine at unity (no correction applies).", numoutlets=2, ) + # tap.overdrive~: bypass is a pure passthrough regardless of the drive settings — + # pins that the external loads and the signal path is wired (the DSP itself is + # covered by the kernel repo's overdrive_test.cpp). + audio_test( + "tap.overdrive~.maxtest.maxpat", + object_text="tap.overdrive~ @bypass 1 @drive 1.", + input_value="0.5", + expected="0.5", + assert_name="tap.overdrive~-bypass-passthrough", + description="tap.overdrive~ @bypass 1: sig~ 0.5 passes unprocessed.", + ) diff --git a/runtime-tests/patchers/tap.overdrive~.maxtest.maxpat b/runtime-tests/patchers/tap.overdrive~.maxtest.maxpat new file mode 100644 index 00000000..f38f050f --- /dev/null +++ b/runtime-tests/patchers/tap.overdrive~.maxtest.maxpat @@ -0,0 +1,371 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 7, + "minor": 0, + "revision": 4, + "architecture": "x64", + "modernui": 1 + }, + "rect": [ + 66.0, + 79.0, + 1070.0, + 480.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Helvetica Neue Light", + "gridonopen": 1, + "gridsize": [ + 5.0, + 5.0 + ], + "gridsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "description": "tap.overdrive~ @bypass 1: sig~ 0.5 passes unprocessed.", + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 40, + 40, + 70, + 22.0 + ], + "text": "loadbang" + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "button", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "bang" + ], + "patching_rect": [ + 40, + 90, + 24, + 24 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 40, + 140, + 30, + 22.0 + ], + "text": "1" + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "newobj", + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "patching_rect": [ + 40, + 190, + 45, + 22.0 + ], + "text": "dac~" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 120, + 140, + 110, + 22.0 + ], + "text": "test.terminate" + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 40, + 110, + 22.0 + ], + "text": "sig~ 0.5" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 100, + 160, + 22.0 + ], + "text": "tap.overdrive~ @bypass 1 @drive 1." + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 160, + 110, + 22.0 + ], + "text": "round~ 1e-06" + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 220, + 90, + 22.0 + ], + "text": "test.sample~" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 280, + 150, + 22.0 + ], + "text": "test.equals 0.5" + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 340, + 180, + 22.0 + ], + "text": "test.assert tap.overdrive~-bypass-passthrough" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-1", + 0 + ], + "destination": [ + "obj-2", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 0 + ], + "destination": [ + "obj-3", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-3", + 0 + ], + "destination": [ + "obj-4", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 0 + ], + "destination": [ + "obj-5", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-6", + 0 + ], + "destination": [ + "obj-7", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-9", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-2", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ], + "hidden": 0, + "disabled": 0 + } + } + ] + } +} diff --git a/source/projects/tap.overdrive_tilde/CMakeLists.txt b/source/projects/tap.overdrive_tilde/CMakeLists.txt new file mode 100644 index 00000000..63dc6a88 --- /dev/null +++ b/source/projects/tap.overdrive_tilde/CMakeLists.txt @@ -0,0 +1,30 @@ +# Copyright 1999-2026 Timothy Place. Distributed under the New BSD License. + +cmake_minimum_required(VERSION 3.19) + +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) + +############################################################# +# MAX EXTERNAL +############################################################# + +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") + +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/overdrive.h +) + +add_library( + ${PROJECT_NAME} + MODULE + ${SOURCES} +) + +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) + +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.overdrive_tilde/tap.overdrive_tilde.cpp b/source/projects/tap.overdrive_tilde/tap.overdrive_tilde.cpp new file mode 100644 index 00000000..f4152c31 --- /dev/null +++ b/source/projects/tap.overdrive_tilde/tap.overdrive_tilde.cpp @@ -0,0 +1,134 @@ +/// @file +/// @brief tap.overdrive~ — voiced feedback overdrive / saturation. +/// @details A thin Min wrapper around the portable kernel in overdrive.h (all DSP lives there — +/// see its header comment for the model). The spiritual successor to the old Jamoma +/// TTOverdrive-based tap.overdrive~, not a port: instead of a memoryless waveshaping +/// curve applied to the full spectrum, the clipper sits inside a lowpass feedback loop +/// (solved zero-delay), so low frequencies stay tight and nearly clean while mids and +/// highs take the full drive and break up first — the frequency-dependent behavior of +/// TS-lineage feedback pedals (voiced against the Mad Professor Little Green Wonder). +/// An asymmetry control adds even-harmonic warmth, a unity clean path keeps the +/// transfer from ever flattening, the body control voices the lows/mids tilt, and the +/// clipping stage runs oversampled with an always-on DC blocker behind it. +/// Single-channel: wrap in mc. for multichannel (the kernel itself is N-channel for +/// reuse outside Max). +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2004-2026 Timothy Place. + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::od; + +class overdrive : public object, public vector_operator<> { + private: + // Constructed before the attributes below so attribute defaults can forward into it. + kernel::overdrive m_od; + + public: + MIN_DESCRIPTION{"Voiced feedback overdrive. The clipper sits inside a lowpass feedback loop, " + "so bass stays tight and nearly clean at any drive while mids and highs break " + "up first — the frequency-dependent gain of a TS-lineage feedback pedal, " + "which a static waveshaper cannot do. Asymmetry adds even-harmonic warmth, a " + "unity clean path keeps the transfer from ever flattening into a hard " + "plateau, and the body control voices the low/mid balance (CCW fuller lows " + "with a slight treble lift, CW tighter lows with an upper-mid push). The " + "clipping stage runs oversampled (default 4x) with an always-on DC blocker " + "behind it. Single-channel: wrap in mc. for multichannel."}; + MIN_TAGS{"audio, distortion"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"overdrive~, tap.vca~, tap.svf~, tap.dcblock~"}; + + inlet<> m_in{this, "(signal) audio input"}; + outlet<> m_out{this, "(signal) overdriven output", "signal"}; + + overdrive(const atoms& args = {}) { m_od.prepare(samplerate(), 1); } + +#define TAP_OD_ATTR(NAME, INDEX, DEFAULT, DESC) \ + attribute NAME{this, #NAME, DEFAULT, \ + setter{MIN_FUNCTION{const double v = kernel::clamp_param(INDEX, args[0]); \ + m_od.set_param(INDEX, v); \ + return {v}; \ + } \ + } \ + , description { DESC } \ + } \ + ; + + TAP_OD_ATTR(drive, kernel::p_drive, 0.35, + "Drive (0..1). 0 is edge-of-breakup — a pedal's gain knob full counterclockwise, still " + "warm — and 1 is heavily saturated. The sweep is perceptual (dB of gain into the " + "clipper); use bypass for a true clean signal.") + TAP_OD_ATTR(body, kernel::p_body, 0.0, + "Body voicing (-1..+1, 0 neutral). Toward -1: fuller lows into the clipper and a " + "slight treble lift. Toward +1: thinner, tighter lows and an upper-mid push. This is " + "linear pre/post voicing around the clipper, not part of the nonlinearity.") + TAP_OD_ATTR(asymmetry, kernel::p_asymmetry, 0.15, + "Asymmetry (0..1): even-harmonic amount. 0 is a symmetric clip (odd harmonics only); " + "raising it biases the clipper for even-order warmth. The DC this generates is removed " + "by the built-in DC blocker.") + TAP_OD_ATTR(preamp, kernel::p_preamp, 0.0, "Input gain in dB (-24..24), applied before the clipper.") + TAP_OD_ATTR(output, kernel::p_output, 0.0, "Makeup gain in dB (-24..24), applied at the output.") + +#undef TAP_OD_ATTR + + attribute oversample{this, "oversample", 4, setter{ MIN_FUNCTION { + const int req = static_cast(args[0]); + const int v = (req >= 8) ? 8 : (req >= 4) ? 4 : (req >= 2) ? 2 : 1; + m_od.set_oversample(v); + return {v}; + }}, + description{"Internal oversampling factor for the clipping stage (1, 2, 4, or 8; " + "default 4). Reduces aliasing from the clipper's harmonics."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{ MIN_FUNCTION { + const double v = std::max(0.0, static_cast(args[0])); + m_od.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper interpolation time in ms applied to parameter changes."}}; + + attribute bypass{this, "bypass", false, description{"Pass the input through unprocessed."}}; + + attribute mute{this, "mute", false, description{"Silence the output."}}; + + // -- messages ------------------------------------------------------------------------------- + + message<> clear{this, "clear", "Reset the filter, feedback, and resampler state.", + MIN_FUNCTION { + m_od.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Reconfigure for the sample rate when DSP starts.", + MIN_FUNCTION { + m_od.prepare(samplerate(), 1); + return {}; + }}; + + // -- perform -------------------------------------------------------------------------------- + + void operator()(audio_bundle input, audio_bundle output) override { + const long n = input.frame_count(); + const double* in = input.samples(0); + double* out = output.samples(0); + + if (mute) { + std::fill_n(out, n, 0.0); + } + else if (bypass) { + std::copy_n(in, n, out); + } + else { + m_od.process(in, out, static_cast(n)); + } + } + + // Exposed for unit tests. + kernel::overdrive& od() { return m_od; } +}; + +MIN_EXTERNAL(overdrive); diff --git a/source/projects/tap.overdrive_tilde/tap.overdrive_tilde_test.cpp b/source/projects/tap.overdrive_tilde/tap.overdrive_tilde_test.cpp new file mode 100644 index 00000000..9d2c30f8 --- /dev/null +++ b/source/projects/tap.overdrive_tilde/tap.overdrive_tilde_test.cpp @@ -0,0 +1,77 @@ +/// @file +/// @brief Unit tests for tap.overdrive~ (Min-level behavior; DSP correctness lives in the +/// kernel repo's overdrive_test.cpp). +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2004-2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.overdrive_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.overdrive~ instantiates with the expected defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + overdrive& my_object = an_instance; + + THEN("the attributes have the documented defaults") { + REQUIRE(static_cast(my_object.drive) == 0.35); + REQUIRE(static_cast(my_object.body) == 0.0); + REQUIRE(static_cast(my_object.asymmetry) == 0.15); + REQUIRE(static_cast(my_object.preamp) == 0.0); + REQUIRE(static_cast(my_object.output) == 0.0); + REQUIRE(static_cast(my_object.oversample) == 4); + REQUIRE(static_cast(my_object.smooth) == tap::tools::od::k_default_smooth_ms); + REQUIRE(static_cast(my_object.bypass) == false); + REQUIRE(static_cast(my_object.mute) == false); + } + } +} + +SCENARIO("tap.overdrive~ clamps attribute values to their documented ranges") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + overdrive& my_object = an_instance; + + WHEN("normalized attributes are set out of range") { + my_object.drive = 2.0; + my_object.body = -5.0; + my_object.asymmetry = 1.5; + + THEN("they clamp to their ranges") { + REQUIRE(static_cast(my_object.drive) == 1.0); + REQUIRE(static_cast(my_object.body) == -1.0); + REQUIRE(static_cast(my_object.asymmetry) == 1.0); + } + } + + WHEN("the gain attributes are set out of range") { + my_object.preamp = 100.0; + my_object.output = -100.0; + + THEN("they clamp to +-24 dB") { + REQUIRE(static_cast(my_object.preamp) == tap::tools::od::k_gain_range_db); + REQUIRE(static_cast(my_object.output) == -tap::tools::od::k_gain_range_db); + } + } + + WHEN("the oversample factor is set to a non-power-of-two") { + my_object.oversample = 3; + + THEN("it snaps down to a legal factor") { + REQUIRE(static_cast(my_object.oversample) == 2); + REQUIRE(my_object.od().oversample() == 2); + } + } + + WHEN("the smooth time is set negative") { + my_object.smooth = -5.0; + + THEN("it clamps to zero") { + REQUIRE(static_cast(my_object.smooth) == 0.0); + } + } + } +} diff --git a/submodules/taptools b/submodules/taptools index d5d3dd4c..492cfe90 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit d5d3dd4ccd74e9736ffb7851c52f2c1687af9e63 +Subproject commit 492cfe90a40f3e9b1067780c19ecb3a64b27a4be