diff --git a/REVIVAL.md b/REVIVAL.md index 1740258f..a039ee12 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -1058,6 +1058,36 @@ GitHub Actions CI. network, eight voices* (the `tr808_*` headers — the bridged-T solved on its states, the calibration lesson), and *Time as a function of phase* (`step_seq.h` — the O(1) derivation, gate-hold look-ahead, armed-recall re-derivation). +- ✅ **Net-new object — `tap.vca~` (2026-07-18).** The 303's transistor VCA stage, lifted out + where it can amplify anything. Kernel: **`vca.h`** (`taptools::vca`) — the two-circuit `svf.h` + idiom applied to a gain stage: `clean` is the pure linear multiply (bit-identical to `*~`), + `warm` is the one-transistor class-A saturator `S(v) = (tanh(d·v+b) − tanh(b)) / (d·sech²(b))` + (stock `d=2.0`, `b=0.3` — the probe-calibrated 303 constants, now exposed as `drive`/`bias`), + applied to the post-gain signal so the even-harmonic warmth and compression **track the control + voltage** the way a discrete VCA does and a multiply cannot, plus an optional output-coupling DC + block (`dcblock`) for the shaper's signal-dependent offset. **`tb303_voice.h` was refactored to + compose `vca::shape()`** — one implementation shared between `tap.303~` and the standalone; the + voice keeps its own Open303 output high-pass and gain, so the extraction left it **bit-identical** + (pinned by `tests/vca_test.cpp`, which matches `shape()` to the old inline formula and re-runs the + 303 warm-vs-clean scenarios). Wrapper `tap.vca_tilde` (audio in + signal/float gain CV in), + `circuit clean|warm` + `drive`/`bias`/`dcblock`/`bypass`/`mute`, the full vertical slice (maxref + + help patcher). Remaining: runtime validation in Max. +- ✅ **The 808 swing-VCA harmonics — `vca.h` `swing` mode + the noise voices (2026-07-18).** The + first piece of the flagged 808 circuit-sim work, and the lowest-risk one: the swing-type VCA's + "many high harmonics" (Service Notes, RS/CL VCA), which `swing_vca()` had modeled as a flat + `x·env`. Kernel: **`vca.h` gains `mode_swing`** — a symmetric (odd-harmonic) `tanh(d·v)/d` + saturator, unity-slope-at-0 so quiet tails stay clean and hot transients pick up grit and + compression, no DC (symmetric → no coupling block needed). One implementation: a static + `vca::swing_shape(v, drive)` that both `mode_swing` and **`swing_vca(x, env, drive)`** route + through. **`drive` defaults to 0 → the exact linear passthru**, so every calibrated 808 voice + stays **bit-identical** until it opts in (the full existing `tr808_*` suite passes unchanged; the + snare test adds an explicit off-is-bit-identical / on-saturates-and-compresses scenario). Wired + into the three noise-path voices — **snare** (snappy), **clap** (CP + maracas output), **tom** + (noise "reverberation") — each exposing a `drive` circuit-bend attribute (0..12). `tap.vca~` gains + the matching `circuit swing`. The heavier WDF `@circuit` pass (the kick bridged-T etc.) stays + 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. --- diff --git a/docs/tap.808.clap~.maxref.xml b/docs/tap.808.clap~.maxref.xml index cc5634ac..4ad29374 100644 --- a/docs/tap.808.clap~.maxref.xml +++ b/docs/tap.808.clap~.maxref.xml @@ -90,6 +90,15 @@ it, leaving only the three teeth; above 1 exaggerates the wash. + + Circuit bend: swing-VCA drive on the output VCA (default 0 = linear) + + Swing-VCA drive on the output VCA, 0..12, for both the clap and maracas models. + 0 is the calibrated linear model (default); higher engages the swing VCA's + symmetric harmonic saturation — grit and compression that ride the envelope. + See tap.vca~ (the swing circuit). + + Noise seed (deterministic) diff --git a/docs/tap.808.snare~.maxref.xml b/docs/tap.808.snare~.maxref.xml index c0d31ce4..1511f121 100644 --- a/docs/tap.808.snare~.maxref.xml +++ b/docs/tap.808.snare~.maxref.xml @@ -99,6 +99,15 @@ (~238/476 Hz) — about 1.4 here. + + Circuit bend: swing-VCA drive on the snappy path (default 0 = linear) + + Swing-VCA drive on the snappy noise path, 0..12. 0 is the calibrated linear + model (default); higher engages the swing VCA's symmetric harmonic saturation — + grit and compression that ride the snappy envelope, hardest on the transient + crack. See tap.vca~ (the swing circuit). + + Noise seed (deterministic) diff --git a/docs/tap.808.tom~.maxref.xml b/docs/tap.808.tom~.maxref.xml index 67f1b1d1..b40e46fb 100644 --- a/docs/tap.808.tom~.maxref.xml +++ b/docs/tap.808.tom~.maxref.xml @@ -91,6 +91,15 @@ Output level Output level, 0..1 (default 1). + + Circuit bend: swing-VCA drive on the noise layer (default 0 = linear) + + Swing-VCA drive on the noise "reverberation" layer, 0..12 (toms only — congas + have no noise layer). 0 is the calibrated linear model (default); higher engages + the swing VCA's symmetric harmonic saturation riding the noise envelope. See + tap.vca~ (the swing circuit). + + Noise seed (toms; deterministic) Seed (>= 1) for the tom noise layer; give mc. instances different seeds. diff --git a/docs/tap.vca~.maxref.xml b/docs/tap.vca~.maxref.xml new file mode 100644 index 00000000..f8537490 --- /dev/null +++ b/docs/tap.vca~.maxref.xml @@ -0,0 +1,108 @@ + + + + + + + Voltage-controlled amplifier — clean multiply or warm transistor stage + + + + The tap.vca~ object is a voltage-controlled amplifier. The left inlet is the + audio; the right inlet is the gain / control voltage — a signal for true per-sample + modulation, or a float to set the gain attribute. The circuit + attribute chooses the character: clean is a pure linear multiply, bit-identical + to *~ — transparent, with no added harmonics or DC. warm is the Roland + TB-303's one-transistor class-A VCA stage — the same kernel tap.303~ runs — a + biased, slope-normalized tanh applied after the gain: quiet signals stay essentially + clean, while hot signals pick up even-harmonic warmth and gentle compression that track + the control voltage, the way a discrete VCA does and a plain multiply cannot. An output + DC block (the dcblock attribute) sheds the shaped signal's DC offset, as the + hardware's output coupling capacitor does. The drive and bias + attributes shape the warm circuit (stock at the 303's values). One channel per object; + wrap in mc. for multichannel. + + + + + + 74 Objects + TapTools + Audio + MSP Modifiers + Multichannel + 64-bit + + + + + + + + In the left inlet, the audio to amplify. In the right inlet, the gain / + control voltage (linear), applied per sample. + + + + + + In the right inlet, sets the gain attribute. Ignored while a + signal is connected to that inlet. + + + + Reset the stage's DC-block state. + + + + + + + + Amplifier circuit: clean (default, the pure linear multiply, + bit-identical to *~), warm (the TB-303 one-transistor class-A stage — asymmetric + biased-tanh saturation with even harmonics, plus the output DC block), or swing (the + TR-808 swing-type VCA — symmetric saturation with odd harmonics and no DC, the same + stage the 808 noise voices use). In warm and swing the warmth and compression ride the + control voltage; bias is ignored by swing. + + + Linear gain applied when no signal is connected to the right inlet. A + signal in the right inlet overrides this per sample. + + + Drive into the transistor stage's tanh for the warm and swing circuits + (0.1-12; default 2, the stock 303 value). Higher drive means more harmonics and more + compression. Ignored by the clean circuit. + + + Warm-circuit operating-point bias (-2..2; default 0.3, the stock 303 + value). The asymmetry that produces even harmonics; 0 is a symmetric (odd-only) + shaper. Ignored by the clean circuit. + + + Warm-circuit output DC block (default on). The biased shaper leaves a + signal-dependent DC offset on AC material; this one-pole coupling high-pass removes + it, as the hardware's output capacitor does. Turn off to hear the raw + asymmetry. + + + Pass the input through unprocessed. + + + Silence the output. + + + + + + + + + + + + + + + diff --git a/help/tap.vca~.maxhelp b/help/tap.vca~.maxhelp new file mode 100644 index 00000000..656eddf8 --- /dev/null +++ b/help/tap.vca~.maxhelp @@ -0,0 +1,285 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 8, + "minor" : 5, + "revision" : 0, + "architecture" : "x64", + "modernui" : 1 + } +, + "classnamespace" : "box", + "rect" : [ 84.0, 104.0, 640.0, 520.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, 420.0, 23.0 ], + "text" : "tap.vca~ : voltage-controlled amplifier (clean or warm)" + } + + } +, { + "box" : { + "id" : "obj-2", + "linecount" : 3, + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 38.0, 600.0, 49.0 ], + "text" : "Left inlet: audio. Right inlet: gain / control voltage (a signal for per-sample modulation, or a float). circuit clean is a pure multiply (like *~); circuit warm is the TB-303 transistor stage — even-harmonic warmth and compression that ride the control voltage, with an output DC block." + } + + } +, { + "box" : { + "id" : "obj-3", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 20.0, 110.0, 74.0, 22.0 ], + "text" : "cycle~ 220" + } + + } +, { + "box" : { + "id" : "obj-4", + "maxclass" : "newobj", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 150.0, 175.0, 60.0, 22.0 ], + "text" : "line~" + } + + } +, { + "box" : { + "id" : "obj-5", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 150.0, 138.0, 78.0, 22.0 ], + "text" : "1. 1000" + } + + } +, { + "box" : { + "id" : "obj-6", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 234.0, 138.0, 78.0, 22.0 ], + "text" : "0. 1000" + } + + } +, { + "box" : { + "id" : "obj-7", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 318.0, 141.0, 200.0, 20.0 ], + "text" : "a control-voltage envelope" + } + + } +, { + "box" : { + "id" : "obj-8", + "maxclass" : "newobj", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 20.0, 230.0, 130.0, 22.0 ], + "text" : "tap.vca~ @circuit warm" + } + + } +, { + "box" : { + "id" : "obj-9", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 165.0, 230.0, 96.0, 22.0 ], + "text" : "circuit clean" + } + + } +, { + "box" : { + "id" : "obj-10", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 165.0, 258.0, 96.0, 22.0 ], + "text" : "circuit warm" + } + + } +, { + "box" : { + "id" : "obj-11", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 270.0, 244.0, 250.0, 20.0 ], + "text" : "switch the amplifier circuit" + } + + } +, { + "box" : { + "id" : "obj-12", + "maxclass" : "newobj", + "numinlets" : 2, + "numoutlets" : 0, + "patching_rect" : [ 20.0, 340.0, 130.0, 22.0 ], + "text" : "ezdac~" + } + + } +, { + "box" : { + "id" : "obj-13", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 20.0, 290.0, 74.0, 22.0 ], + "text" : "*~ 0.5" + } + + } +, { + "box" : { + "id" : "obj-14", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 318.0, 290.0, 130.0, 130.0 ], + "text" : "scope~" + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "source" : [ "obj-3", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-4", 0 ], + "source" : [ "obj-5", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-4", 0 ], + "source" : [ "obj-6", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 1 ], + "source" : [ "obj-4", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "source" : [ "obj-9", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "source" : [ "obj-10", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-13", 0 ], + "source" : [ "obj-8", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-14", 0 ], + "source" : [ "obj-8", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-12", 0 ], + "source" : [ "obj-13", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-12", 1 ], + "source" : [ "obj-13", 0 ] + } + + } + ], + "dependency_cache" : [ { + "name" : "tap.vca~.mxo", + "type" : "iLaX" + } + ], + "autosave" : 0 + } + +} diff --git a/plans/tap.303.md b/plans/tap.303.md index beccecbe..72ff921c 100644 --- a/plans/tap.303.md +++ b/plans/tap.303.md @@ -252,7 +252,10 @@ each gated by A/B evidence (the 808 plan's go/no-go discipline) — all resolved slope-normalized biased saturator in the hardware order (post-envelope-gain, pre-output-coupling). Measured: 5.4% difference signal on quiet notes vs 11.5% on hot accents — the distortion tracks the envelope. `clean` stays the default, - bit-identical to phase 1. + bit-identical to phase 1. **Extracted to the shared `vca.h` kernel (`taptools::vca`) + (2026-07-18)** — the voice now composes `vca::shape()`, one implementation shared with + the standalone `tap.vca~` object; the refactor is bit-identical (pinned by the kernel's + `vca_test.cpp`). - **Square-shaper exactness** — ✅ **resolved in slice 4**: Open303's measured shaper constants adopted verbatim (−tanh(10^(36.9/20)·saw + 4.37)). - **WDF pass on the filter** — ✅ **documented no-go, author-approved diff --git a/plans/tap.808.md b/plans/tap.808.md index fb586bc4..6f5fbd2d 100644 --- a/plans/tap.808.md +++ b/plans/tap.808.md @@ -101,7 +101,11 @@ selecting between two related sounds sharing one circuit): rapid pulses — the "multiple hands" transient — plus a parallel slower noise decay), and MA (high-passed noise burst with a very fast envelope). 4. **Swing-type VCAs and RC decay envelopes** — the percussive gain shapes are simple - discharge curves and one-transistor VCAs, not ADSRs. + discharge curves and one-transistor VCAs, not ADSRs. The swing VCA's "many high + harmonics" (Service Notes) shipped 2026-07-18 as `vca.h`'s `swing` mode (a symmetric + `swing_shape`), wired into the noise voices (snare/clap/tom) behind an opt-in `drive` + attribute that defaults to 0 → the calibrated linear model, bit-identical. The heavier + WDF `@circuit` pass on the resonant voices remains A/B-gated (see the field guide). 5. **The accent bus** — a shared voltage, scaled by the AC level knob, that raises the trigger pulse amplitude of every voice sounding on an accented step (and thereby, on some voices, subtly shifts timbre — the excitation is hotter, not just louder). diff --git a/source/projects/tap.808.clap_tilde/tap.808.clap_tilde.cpp b/source/projects/tap.808.clap_tilde/tap.808.clap_tilde.cpp index e9fb2177..c7fb94ad 100644 --- a/source/projects/tap.808.clap_tilde/tap.808.clap_tilde.cpp +++ b/source/projects/tap.808.clap_tilde/tap.808.clap_tilde.cpp @@ -72,6 +72,16 @@ class clap808 : public object, public sample_operator<1, 1> { description{"Circuit bend (clap model): reverberation-tail level, 0..2. 1 is stock; 0 " "disconnects the wash, leaving only the three teeth."}}; + attribute drive{this, "drive", 0.0, setter{MIN_FUNCTION{ + const double v = MIN_CLAMP(static_cast(args[0]), 0.0, 12.0); + m_clap.set_drive(v); + return {v}; + }}, + description{"Circuit bend: swing-VCA drive on the output VCA (0..12), for both the " + "clap and maracas models. 0 is the calibrated linear model (default); " + "higher engages the swing VCA's symmetric harmonic saturation — grit and " + "compression that ride the envelope."}}; + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ const int v = std::max(1, static_cast(args[0])); m_clap.set_seed(static_cast(v)); diff --git a/source/projects/tap.808.snare_tilde/tap.808.snare_tilde.cpp b/source/projects/tap.808.snare_tilde/tap.808.snare_tilde.cpp index 9a133ca6..5368d43d 100644 --- a/source/projects/tap.808.snare_tilde/tap.808.snare_tilde.cpp +++ b/source/projects/tap.808.snare_tilde/tap.808.snare_tilde.cpp @@ -74,6 +74,16 @@ class snare808 : public object, public sample_operator<1, 1> { description{"Circuit bend: pitch as a ratio of the stock tuning (0.25..4), scaling " "both resonators together. 1 is the schematic (~173/336 Hz)."}}; + attribute drive{this, "drive", 0.0, setter{MIN_FUNCTION{ + const double v = MIN_CLAMP(static_cast(args[0]), 0.0, 12.0); + m_snare.set_drive(v); + return {v}; + }}, + description{"Circuit bend: swing-VCA drive on the snappy noise path (0..12). 0 is the " + "calibrated linear model (default); higher engages the swing VCA's " + "symmetric harmonic saturation — grit and compression that ride the snappy " + "envelope, hardest on the transient crack."}}; + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ const int v = std::max(1, static_cast(args[0])); m_snare.set_seed(static_cast(v)); diff --git a/source/projects/tap.808.tom_tilde/tap.808.tom_tilde.cpp b/source/projects/tap.808.tom_tilde/tap.808.tom_tilde.cpp index 05368e19..1b8d49c9 100644 --- a/source/projects/tap.808.tom_tilde/tap.808.tom_tilde.cpp +++ b/source/projects/tap.808.tom_tilde/tap.808.tom_tilde.cpp @@ -82,6 +82,16 @@ class tom808 : public object, public sample_operator<1, 1> { }}, description{"Output level, 0..1."}}; + attribute drive{this, "drive", 0.0, setter{MIN_FUNCTION{ + const double v = MIN_CLAMP(static_cast(args[0]), 0.0, 12.0); + m_tom.set_drive(v); + return {v}; + }}, + description{"Circuit bend: swing-VCA drive on the noise 'reverberation' layer (0..12; " + "toms only — congas have no noise layer). 0 is the calibrated linear model " + "(default); higher engages the swing VCA's symmetric harmonic saturation " + "riding the noise envelope."}}; + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ const int v = std::max(1, static_cast(args[0])); m_tom.set_seed(static_cast(v)); diff --git a/source/projects/tap.vca_tilde/CMakeLists.txt b/source/projects/tap.vca_tilde/CMakeLists.txt new file mode 100644 index 00000000..e32b752d --- /dev/null +++ b/source/projects/tap.vca_tilde/CMakeLists.txt @@ -0,0 +1,36 @@ +# 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/vca.h +) + +add_library( + ${PROJECT_NAME} + MODULE + ${SOURCES} +) + +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) + +# TapTools targets C++20. Min's posttarget pins the standard to C++17, so override it here. +set_target_properties(${PROJECT_NAME} PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON +) + +# 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.vca_tilde/tap.vca_tilde.cpp b/source/projects/tap.vca_tilde/tap.vca_tilde.cpp new file mode 100644 index 00000000..bc8a7fc8 --- /dev/null +++ b/source/projects/tap.vca_tilde/tap.vca_tilde.cpp @@ -0,0 +1,178 @@ +/// @file +/// @brief tap.vca~ — voltage-controlled amplifier: a clean multiply or a warm transistor stage. +/// @details A thin Min wrapper around the portable kernel in vca.h (all DSP lives there — see its +/// header comment for the model). The left inlet is the audio; the right inlet is the +/// gain / control voltage (a signal for true per-sample modulation, or a float to set +/// the gain attribute). Two circuits, the svf~ idiom: +/// +/// - circuit clean (default): the pure linear multiply, out = in * gain — bit-identical +/// to a *~. Transparent, cheap, no added harmonics or DC. +/// - circuit warm: the TB-303's one-transistor class-A VCA stage — the same code +/// tap.303~ runs (shared kernel, vca.h). A slope-normalized biased tanh applied to +/// the post-gain signal: unity gain through zero (quiet signals near-clean), even +/// harmonics and gentle compression that ride the control voltage on hot signals, and +/// an output-coupling DC block (the dcblock attribute) that sheds the shaper's +/// signal-dependent offset the way the hardware's coupling capacitor does. +/// +/// Single-channel: wrap in mc. for multichannel (the kernel is reusable outside Max). +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; + +class vca : public object, public vector_operator<> { + private: + // Constructed before the attributes below so their defaults can forward into it. + taptools::vca m_vca; + + public: + MIN_DESCRIPTION{"A voltage-controlled amplifier. The left inlet is the audio, the right inlet " + "is the gain / control voltage (a signal for per-sample modulation, or a float " + "for a fixed gain). The clean circuit is a pure linear multiply (bit-identical " + "to *~). The warm circuit is the TB-303's one-transistor class-A VCA stage — a " + "biased, slope-normalized tanh applied after the gain, so quiet signals stay " + "essentially clean while hot signals pick up even-harmonic warmth and gentle " + "compression that track the control voltage; an output DC block removes the " + "stage's signal-dependent offset. The swing circuit is the TR-808 swing-type " + "VCA — symmetric (odd-harmonic) saturation, the same stage the 808 noise voices " + "use. Single-channel: wrap in mc. for multichannel."}; + MIN_TAGS{"filters"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.303~, tap.808.snare~, tap.crossfade~, tap.pan~, *~, gain~, matrix~"}; + + inlet<> m_in{this, "(signal) audio input"}; + inlet<> m_in_gain{this, "(signal/float) gain / control voltage (linear)"}; + outlet<> m_out{this, "(signal) amplified output", "signal"}; + + vca(const atoms& args = {}) { m_vca.prepare(samplerate()); } + + attribute circuit{ + this, + "circuit", + "clean", + range{"clean", "warm", "swing"}, + setter{ MIN_FUNCTION { + int m = taptools::vca::mode_clean; + if (args[0] == "warm") { + m = taptools::vca::mode_warm; + } + else if (args[0] == "swing") { + m = taptools::vca::mode_swing; + } + m_vca.set_mode(m); + return args; + }}, + description{"clean: the pure linear multiply (transparent, bit-identical to *~). " + "warm: the TB-303 one-transistor class-A stage — asymmetric biased-tanh " + "saturation (even harmonics) applied after the gain, plus the output DC block " + "(see dcblock). swing: the TR-808 swing-type VCA — symmetric saturation " + "(odd harmonics, no DC), the same stage the 808 noise voices use. In warm and " + "swing the warmth and compression ride the control voltage; bias is ignored by " + "swing (symmetric by construction)."}}; + + attribute gain{this, "gain", 1.0, + description{"Linear gain applied when no signal is connected to the right " + "inlet. A signal in the right inlet overrides this per sample."}}; + + attribute drive{ + this, + "drive", + taptools::vca::k_default_drive, + range{0.1, 12.0}, + setter{ MIN_FUNCTION { + const double v = std::clamp(static_cast(args[0]), 0.1, 12.0); + m_vca.set_drive(v); + return {v}; + }}, + description{"Drive into the transistor stage's tanh for the warm and swing circuits " + "(0.1..12; default 2.0, the stock 303 value). Higher drive means more harmonics " + "and more compression. Ignored by the clean circuit."}}; + + attribute bias{ + this, + "bias", + taptools::vca::k_default_bias, + range{-2.0, 2.0}, + setter{ MIN_FUNCTION { + const double v = std::clamp(static_cast(args[0]), -2.0, 2.0); + m_vca.set_bias(v); + return {v}; + }}, + description{"Warm-circuit operating-point bias (-2..2; default 0.3, the stock 303 value). " + "The asymmetry that produces even harmonics; 0 is a symmetric (odd-only) shaper. " + "Ignored by the clean circuit."}}; + + attribute dcblock{this, "dcblock", true, setter{ MIN_FUNCTION { + m_vca.set_dc_block(static_cast(args[0])); + return args; + }}, + description{"Warm-circuit output DC block (default on). The biased shaper " + "leaves a signal-dependent DC offset on AC material; this " + "one-pole coupling high-pass removes it, as the hardware's " + "output capacitor does. Turn off to hear the raw asymmetry."}}; + + attribute bypass{this, "bypass", false, description{"Pass the input through unprocessed."}}; + + attribute mute{this, "mute", false, description{"Silence the output."}}; + + // -- messages ------------------------------------------------------------------------------- + + message<> m_number{this, "number", "A float in the right inlet sets the gain.", + MIN_FUNCTION { + if (inlet == 1) { + gain = args[0]; + } + return {}; + }}; + + message<> clear{this, "clear", "Reset the stage's DC-block state.", + MIN_FUNCTION { + m_vca.reset(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Reconfigure for the sample rate when DSP starts.", + MIN_FUNCTION { + m_vca.prepare(samplerate()); + 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 if (m_in_gain.has_signal_connection()) { + const double* g = input.samples(1); + for (long i = 0; i < n; ++i) { + out[i] = m_vca.process(in[i], g[i]); // true per-sample gain / CV modulation + } + } + else { + const double g = gain; + for (long i = 0; i < n; ++i) { + out[i] = m_vca.process(in[i], g); + } + } + } + + // Exposed for unit tests. + taptools::vca& stage() { return m_vca; } +}; + +MIN_EXTERNAL(vca); diff --git a/source/projects/tap.vca_tilde/tap.vca_tilde_test.cpp b/source/projects/tap.vca_tilde/tap.vca_tilde_test.cpp new file mode 100644 index 00000000..5a856834 --- /dev/null +++ b/source/projects/tap.vca_tilde/tap.vca_tilde_test.cpp @@ -0,0 +1,117 @@ +/// @file +/// @brief Unit tests for tap.vca~ and its vca.h kernel. +/// @details Kernel scenarios exercise taptools::vca directly; one scenario goes through the Min +/// mock for the wrapper's attribute defaults, forwarding, and clamping. The deep DSP +/// correctness tests live in the kernel repo (tests/vca_test.cpp); this pins the +/// Min-level behavior. The harness pins this translation unit to C++17. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include "c74_min_unittest.h" +#include "tap.vca_tilde.cpp" + +namespace { + + constexpr double k_sr = 48000.0; + + taptools::vca make_stage(int mode = taptools::vca::mode_clean) { + taptools::vca a; + a.prepare(k_sr); + a.set_mode(mode); + return a; + } + +} // namespace + +SCENARIO("the clean circuit is an exact linear multiply") { + auto a = make_stage(taptools::vca::mode_clean); + THEN("process(x, gain) == x * gain to the bit") { + for (double g : {0.0, 0.5, 1.0, 2.0, -0.5}) { + for (double x : {-0.8, 0.0, 0.42}) { + REQUIRE(a.process(x, g) == x * g); + } + } + } +} + +SCENARIO("the warm circuit adds tracking saturation but leaves quiet signals near-clean") { + auto a = make_stage(taptools::vca::mode_warm); + THEN("a tiny signal passes at ~unity gain") { + REQUIRE(a.shape(1e-4) == Approx(1e-4).epsilon(1e-3)); + } + THEN("a hot signal is compressed relative to clean") { + REQUIRE(std::abs(a.shape(1.5)) < 1.5); + } + THEN("the shaper is monotonic across the working range") { + double prev = a.shape(-2.0); + for (double v = -1.9; v <= 2.0; v += 0.1) { + REQUIRE(a.shape(v) > prev); + prev = a.shape(v); + } + } +} + +SCENARIO("the swing circuit is the TR-808 symmetric saturator (odd harmonics, no DC)") { + auto a = make_stage(taptools::vca::mode_swing); + a.set_drive(3.0); + THEN("drive 0 is the exact linear passthru; drive > 0 is a compressing odd function") { + REQUIRE(taptools::vca::swing_shape(0.6, 0.0) == 0.6); + REQUIRE(std::abs(a.shape(0.7) + a.shape(-0.7)) < 1e-12); // symmetric + REQUIRE(std::abs(a.shape(1.4)) < 1.4); // compresses hot signals + } +} + +SCENARIO("the Min wrapper instantiates and takes its attributes") { + ext_main(nullptr); + GIVEN("an instance of tap.vca~") { + test_wrapper an_instance; + vca& my_object = an_instance; + + THEN("the defaults are sane") { + REQUIRE(my_object.circuit == symbol("clean")); + REQUIRE(static_cast(my_object.gain) == 1.0); + REQUIRE(static_cast(my_object.drive) == taptools::vca::k_default_drive); + REQUIRE(static_cast(my_object.bias) == taptools::vca::k_default_bias); + REQUIRE(static_cast(my_object.dcblock) == true); + REQUIRE(my_object.stage().circuit() == taptools::vca::mode_clean); + } + + WHEN("the circuit is switched to warm") { + my_object.circuit = "warm"; + THEN("the stage follows") { + REQUIRE(my_object.circuit == symbol("warm")); + REQUIRE(my_object.stage().circuit() == taptools::vca::mode_warm); + } + } + + WHEN("the circuit is switched to swing") { + my_object.circuit = "swing"; + THEN("the stage follows (the TR-808 symmetric saturator)") { + REQUIRE(my_object.circuit == symbol("swing")); + REQUIRE(my_object.stage().circuit() == taptools::vca::mode_swing); + } + } + + WHEN("drive and bias are set out of range") { + my_object.drive = 100.0; // clamps to 12 + my_object.bias = -9.0; // clamps to -2 + THEN("they clamp to the documented range and reach the stage") { + REQUIRE(static_cast(my_object.drive) == 12.0); + REQUIRE(static_cast(my_object.bias) == -2.0); + REQUIRE(my_object.stage().drive() == 12.0); + REQUIRE(my_object.stage().bias() == -2.0); + } + } + + WHEN("dcblock is turned off") { + my_object.dcblock = false; + THEN("the stage follows") { + REQUIRE(my_object.stage().dc_block() == false); + } + } + } +} diff --git a/submodules/taptools b/submodules/taptools index fbcf3261..76dc6166 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit fbcf32617d3731b98327d575229298dd946f2159 +Subproject commit 76dc6166a51d7b4f1a4ae2de3d18fd6229b11f62