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