diff --git a/README.md b/README.md
new file mode 100644
index 00000000..cf613b9e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+# TapTools
+
+[](https://github.com/tap/TapTools-Max/actions/workflows/build.yml)
+[](https://github.com/tap/TapTools-Max/actions/workflows/style.yml)
+
+A potpourri of objects for [Max](https://cycling74.com/products/max) — roots back to 1999,
+rebuilt in 2026 on a modern toolchain: the [Min SDK](https://github.com/Cycling74/min-api),
+CMake, GitHub Actions, macOS universal (arm64 + x86_64) and Windows x64 binaries. All DSP
+lives in a portable, Max-free kernel library — [TapTools](https://github.com/tap/TapTools),
+pinned here as a submodule — where it is unit-tested, benchmarked, and measured in executed
+verification notebooks; the externals in this repository are thin wrappers over those
+kernels.
+
+## The objects
+
+Around eighty objects across MSP, control, and Jitter. The headliners, by family:
+
+- **Sources** — `tap.vco~` (polyBLEP virtual-analog oscillator with a seeded,
+ measured "analog section"), `tap.noise~` (five colors).
+- **Filters** — `tap.svf~` (the morphing Simper SVF), `tap.ladder~` (nonlinear ZDF
+ transistor ladder), `tap.fourpole~`, `tap.diode~` (the 303's diode ladder),
+ `tap.autowah~` (Snow White envelope filter model), `tap.filter~`.
+- **The rhythm machines** — `tap.303~` / `tap.303.seq~` (circuit-informed TB-303 voice and
+ sequencer), and the `tap.808.*` family: eight TR-808 voice channels recreated circuit
+ block by circuit block, calibrated against a real unit, plus `tap.808.seq~`.
+- **Pitch and voice** — `tap.tune~` (real-time pitch correction), `tap.harmony~`
+ (formant-preserving multi-voice harmonizer), `tap.shift~`, `tap.pitchaccum~` (the GRM
+ shimmer loop), `tap.vocoder~` (24-band channel vocoder).
+- **Rooms and strings** — `tap.convolve~` (exact true-stereo partitioned convolution),
+ `tap.verb~` (algorithmic Moorer reverb), `tap.5comb~` (the GRM tuned comb bank),
+ `tap.comb~`, `tap.delay~`, `tap.multitap~`.
+- **The pedalboard and the spectral set** — `tap.overdrive~` (voiced feedback overdrive),
+ `tap.nr~` (per-bin spectral gate), `tap.spectra~` (bin remapper), `tap.fft.*`.
+- **Dynamics, utility, Jitter** — `tap.limi~`, `tap.vca~`, `tap.adsr~`, `tap.crossfade~`,
+ `tap.pan~`, and a set of control and `tap.jit.*` objects.
+
+Every DSP object ships its vertical slice: a reference page in `docs/`, a help patcher in
+`help/`, and — for the kernel-backed objects — tests and measurements in the kernel repo.
+
+## Documentation
+
+- **[*Tools on Tap*](https://tap.github.io/TapTools/)** — the field guide: one chapter per
+ object family, machine appendices deriving each kernel, and a Recipes part that patches
+ the objects at famous records. Its standing promise: every performance claim is
+ measured, not remembered.
+- **Reference pages and help patchers** — in Max, as usual.
+- **[`REVIVAL.md`](REVIVAL.md)** — the authoritative roadmap and progress log of the 2026
+ revival, object by object.
+
+## Installation
+
+Clone (or download) into your Max Packages folder and build the externals (below), or grab
+a built release when releases are cut. Requires Max 9+.
+
+## Building from source
+
+```sh
+git clone https://github.com/tap/TapTools-Max.git
+cd TapTools-Max
+git submodule update --init --recursive
+cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
+cmake --build build --config Release
+```
+
+Built externals land in `externals/` (`.mxo` on macOS — universal by default — and
+`.mxe64` on Windows; on Windows configure with `-A x64`). CI builds both platforms on
+every push and fails if a macOS binary is not universal.
+
+The DSP kernels build and test standalone in their own repo:
+`cmake -S submodules/taptools -B build-kernel && ctest --test-dir build-kernel`.
+
+## Testing
+
+Three layers: the kernel repo's Catch2 batteries (DSP correctness, oracle-measured),
+min-api wrapper tests here (attributes and message plumbing, run via `ctest`), and a
+`runtime-tests/` harness that loads the real externals in a licensed Max for in-Max
+validation. See `REVIVAL.md` for per-object status.
+
+## License
+
+[BSD 3-Clause](LICENSE). © 2002–2026 Timothy Place and the TapTools contributors. The
+kernel library and its DspTap primitives are MIT-licensed in their own repositories.
diff --git a/REVIVAL.md b/REVIVAL.md
index fe20d156..7d6d9fa3 100644
--- a/REVIVAL.md
+++ b/REVIVAL.md
@@ -1411,6 +1411,31 @@ maxtest** shipped (`runtime-tests/patchers/tap.tune~.maxtest.maxpat`, generated
this object: the in-Max validation pass itself (help patcher + maxtest under a licensed
Max), like the rest of the DSP set.
+**13. Net-new object — `tap.harmony~` (2026-08-05).** ✅ **v1 shipped** — formant-preserving
+multi-voice harmonizer (the keyboard-harmonizer effect: up to four pvoc voices at fractional-
+semitone intervals over a kernel-aligned dry path, formants preserved by default, `chord`
+message, `glide` portamento). Kernel: `taptools/harmonizer.h` (`tap::tools::harmony`),
+composing DspTap's `pvoc` (Laroche–Dolson peak locking + LPC formant correction) — seven
+Catch2 scenarios under the yin oracle, capi + ctypes bridge. Origin story: the *Tools on Tap*
+Recipes drafting flagged the gap ("Hide and Seek" is a harmonizer, not a vocoder — see
+`plans/recipes-improvements.md` §10). Build verification landed the same day: CI built the
+wrapper green on both platforms (macOS universal, lipo-checked, and Windows x64) alongside
+the full external set, with clang-format/clang-tidy clean. Remaining for this object: the
+in-Max validation pass and a runtime maxtest (licensed-Max, on-Mac), and the v1.1
+house-machinery items in the improvements plan (mute/bypass, 16-slot preset morph).
+
+**14. `tap.adsr~` rebuilt as virtual-analog (2026-08-05).** ✅ **Shipped** — the envelope's DSP
+moved to the kernel repo (`taptools/adsr.h`, `tap::tools::adsr::generator`) and gained a
+circuit-model `analog` default: truncated RC attack toward a 1.4× overshoot target (CEM 3310
+architecture), asymptotic decay/release (95 % closure at the knob time, pinned), retrigger from
+the current level. The Jamoma TTAdsr curves survive verbatim as `hybrid`/`linear`/`exponential`
+modes. The family trigger contract lands: `threshold` (default 0.005 — a seq row's plain 0.01
+now registers; the hard-coded 0.5 gate is retired) and `velocity` sensitivity (gate amplitude
+scales the hit; 0 = legacy). **The default mode change is deliberate and documented** — the
+recipes-improvements plan §1 carries the reasoning. Kernel: eight Catch2 scenarios + executed
+`adsr.ipynb`; wrapper shrunk to Min glue, maxref rewritten (retiring its phantom `int`/`float`
+methods), wrapper tests updated. Remaining: in-Max validation.
+
Remaining (ongoing, now cross-repo — DSP lands in `tap/taptools`, then bump the submodule pin
here): lift the remaining simple inline-DSP objects' math into kernel headers opportunistically as
they're touched. Control/utility and Jitter objects never move — they are Max message-logic, not
diff --git a/docs/tap.adsr~.maxref.xml b/docs/tap.adsr~.maxref.xml
index 592deae2..79bc1d8c 100644
--- a/docs/tap.adsr~.maxref.xml
+++ b/docs/tap.adsr~.maxref.xml
@@ -4,103 +4,105 @@
- An ADSR envelope generator
+ Virtual-analog ADSR envelope generator
- The tap.adsr~ object
- generates a signal-rate envelope and may be triggered at either signal rate or control rate.
- More information about ADSR envelopes is available on this Wikipedia article.
+ The tap.adsr~ object generates an attack/decay/sustain/release envelope. The
+ default mode, analog, is a circuit model: the attack stage is an RC
+ charge toward an overshoot target above full scale, truncated at the peak — the classic
+ analog punch — and the decay and release stages are true RC discharges that taper into
+ their targets instead of stopping dead. Retrigger always rises from the current level,
+ like the capacitor it models. The legacy TapTools curves are preserved as the
+ hybrid, linear, and exponential modes.
+
+ A signal at the inlet gates the envelope: it opens above threshold (whose
+ default hears a tap.808.seq~ row's plain hits) and closes below it, and the
+ gate's amplitude is velocity under the velocity sensitivity — a 303-style
+ accented 2.0 gate hits up to twice as hard. With no signal connected, the
+ trigger attribute drives the gate.
-
+
74 Objects
TapTools
Audio
- Time
Generator
- 64-bit
-
-
-
-
- Trigger an envelope to begin.
-
-
-
-
-
-
- Audio signal: the generated envelope.
-
-
- Dumpout: for accessing object state.
-
-
-
-
-
-
-
-
-
- The absolute(non-negative) value of the input is sent out the output.
-
-
-
-
-
-
- Converted to int, unless abs has a float argument.
+
+
+ Reset the envelope to silence and idle; parameter values are kept.
+
-
- -->
-
+
+
-
- Attack time in milliseconds
+
+ Attack time in milliseconds (1..60000). In analog mode this is the time to reach
+ full scale on the truncated RC charge. Default 50.
+
-
- Decay time in milliseconds
+
+ Decay time in milliseconds (1..60000). In analog mode the stage closes 95% of
+ the way to the sustain level in this time and keeps tapering. Default 100.
+
-
- Sustain time in milliseconds
+ Sustain level in decibels. Default -6.
-
- Release time in milliseconds
+
+ Release time in milliseconds (1..60000). In analog mode the stage closes 95% of
+ the way to silence in this time, then ends at exactly zero. Default 500.
+
-
- Determine the shape of the envelope
- Determines the shape of the envelope. The two options are linear and exponential.
+
+ Envelope curve: analog (the circuit model, default), or the legacy
+ hybrid (linear attack, dB-linear decay/release), linear, and
+ exponential curves, preserved verbatim from the original object.
+
-
- Trigger a note on or off when no signal is connected to the inlet
- Triggers a note to on or off when no signal is connected to the inlet. If a signal is connected (also a 0 or 1) it overrides this attribute.
+
+ Open the envelope (attack) while on; release it while off. Ignored while a
+ signal is connected to the inlet. Default off.
+
+
+
+
+ Gate-open level for the signal inlet (0..1). The default (0.005) sits above the
+ trigger bus's noise floor and below a sequencer row's plain 0.01 level, so
+ un-accented hits register. Default 0.005.
+
+
+
+
+ Velocity sensitivity (0..1). Peak and sustain scale by 1 + velocity × (gate
+ amplitude − 1): at 1, an accented 2.0 gate hits twice as hard and a half-level
+ gate lands soft; at 0 (default) the envelope ignores gate amplitude — the
+ legacy behavior.
+
-
-
+
-
-
-
+
+
+
+
-
diff --git a/docs/tap.harmony~.maxref.xml b/docs/tap.harmony~.maxref.xml
new file mode 100644
index 00000000..e9908da4
--- /dev/null
+++ b/docs/tap.harmony~.maxref.xml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+ Formant-preserving multi-voice harmonizer
+
+
+
+ The tap.harmony~ object is the keyboard-harmonizer effect: up to four
+ pitch-shifted copies of a monophonic input, each holding a musical interval set in
+ fractional semitones (interval1..interval4, ±24), summed with a dry
+ path that is delayed inside the kernel to align with the voices — so chords land as
+ chords, not slapback. Formant preservation (formant, on by default) keeps the
+ singer's spectral envelope on every shifted voice, which is what separates a harmonizer
+ from a chipmunk chorus; turning it off is the bend. Interval changes glide through the
+ glide time constant, click-free at the 10 ms default and an audible portamento
+ in the hundreds. The chord message sets up to four intervals and enables exactly
+ those voices in one gesture. Each voice is a peak-locked phase vocoder with LPC formant
+ correction from the shared DspTap library. Latency is one FFT frame (fftsize
+ samples, dry path included). The source should be monophonic for musical results; wrap
+ the object in an mc. operator for multichannel work.
+
+
+
+
+
+ 74 Objects
+ TapTools
+ Audio
+ Effect
+
+
+
+
+
+
+
+
+
+
+ Set up to four intervals (in semitones, fractional allowed) and enable exactly
+ those voices at level 1; voices beyond the list length are silenced. A one-message
+ chord change.
+
+
+
+
+ Zero all running state: the voices' analysis buffers, the dry-alignment delay,
+ and the parameter slews.
+
+
+
+
+
+
+
+
+ Voice 1 interval in semitones (-24..24, fractional allowed). Default 0.
+
+
+ Voice 2 interval in semitones (-24..24, fractional allowed). Default 0.
+
+
+ Voice 3 interval in semitones (-24..24, fractional allowed). Default 0.
+
+
+ Voice 4 interval in semitones (-24..24, fractional allowed). Default 0.
+
+
+ Voice 1 level, linear 0..2. 0 disables the voice. Default 0.
+
+
+ Voice 2 level, linear 0..2. 0 disables the voice. Default 0.
+
+
+ Voice 3 level, linear 0..2. 0 disables the voice. Default 0.
+
+
+ Voice 4 level, linear 0..2. 0 disables the voice. Default 0.
+
+
+
+ Dry level, linear 0..2. The dry path is delayed inside the kernel to stay
+ sample-aligned with the shifted voices. Default 1.
+
+
+
+
+ LPC formant preservation on every voice. On (default) keeps the singer's
+ envelope while the pitch moves; off shifts envelope and all.
+
+
+
+
+ Interval glide time constant in milliseconds (0..2000). Chord changes slew
+ click-free at the 10 ms default; hundreds of ms is an audible portamento.
+
+
+
+
+ FFT frame size in samples (256..4096, rounded up to a power of two). This is
+ the object's latency, dry path included. Applies when the DSP chain restarts.
+ Default 1024.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/help/tap.harmony~.maxhelp b/help/tap.harmony~.maxhelp
new file mode 100644
index 00000000..d4da8ce5
--- /dev/null
+++ b/help/tap.harmony~.maxhelp
@@ -0,0 +1,295 @@
+{
+ "patcher": {
+ "fileversion": 1,
+ "appversion": {
+ "major": 9,
+ "minor": 0,
+ "revision": 0,
+ "architecture": "x64",
+ "modernui": 1
+ },
+ "classnamespace": "box",
+ "rect": [ 85.0, 104.0, 640.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,
+ "boxes": [
+ {
+ "box": {
+ "id": "obj-1",
+ "maxclass": "comment",
+ "numinlets": 1,
+ "numoutlets": 0,
+ "patching_rect": [ 30.0, 20.0, 440.0, 27.0 ],
+ "fontsize": 18.0,
+ "text": "tap.harmony~ — formant-preserving multi-voice harmonizer"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-2",
+ "maxclass": "comment",
+ "numinlets": 1,
+ "numoutlets": 0,
+ "patching_rect": [ 30.0, 50.0, 560.0, 47.0 ],
+ "text": "Up to four pitch-shifted copies of a monophonic input hold a chord (intervals in semitones, fractional allowed) over a latency-aligned dry path. Formant preservation keeps the singer's envelope — turn it off for the chipmunk bend. Latency is one FFT frame (fftsize samples)."
+ }
+ },
+ {
+ "box": {
+ "id": "obj-3",
+ "maxclass": "ezadc~",
+ "numinlets": 1,
+ "numoutlets": 2,
+ "outlettype": [ "signal", "signal" ],
+ "patching_rect": [ 30.0, 120.0, 45.0, 45.0 ]
+ }
+ },
+ {
+ "box": {
+ "id": "obj-5",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 120.0, 120.0, 90.0, 22.0 ],
+ "text": "chord 3 7 12"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-6",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 220.0, 120.0, 100.0, 22.0 ],
+ "text": "chord -12 4 7"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-7",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 330.0, 120.0, 70.0, 22.0 ],
+ "text": "chord 12"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-8",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 120.0, 155.0, 70.0, 22.0 ],
+ "text": "formant 0"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-9",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 200.0, 155.0, 70.0, 22.0 ],
+ "text": "formant 1"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-10",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 280.0, 155.0, 70.0, 22.0 ],
+ "text": "glide 400"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-11",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 360.0, 155.0, 62.0, 22.0 ],
+ "text": "glide 10"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-12",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 432.0, 155.0, 44.0, 22.0 ],
+ "text": "clear"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-13",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 120.0, 190.0, 60.0, 22.0 ],
+ "text": "dry 0."
+ }
+ },
+ {
+ "box": {
+ "id": "obj-14",
+ "maxclass": "message",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "" ],
+ "patching_rect": [ 190.0, 190.0, 60.0, 22.0 ],
+ "text": "dry 1."
+ }
+ },
+ {
+ "box": {
+ "id": "obj-4",
+ "maxclass": "newobj",
+ "numinlets": 1,
+ "numoutlets": 1,
+ "outlettype": [ "signal" ],
+ "patching_rect": [ 30.0, 250.0, 130.0, 22.0 ],
+ "text": "tap.harmony~ @dry 1"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-15",
+ "maxclass": "newobj",
+ "numinlets": 2,
+ "numoutlets": 1,
+ "outlettype": [ "signal" ],
+ "patching_rect": [ 30.0, 300.0, 55.0, 22.0 ],
+ "text": "*~ 0.7"
+ }
+ },
+ {
+ "box": {
+ "id": "obj-16",
+ "maxclass": "ezdac~",
+ "numinlets": 2,
+ "numoutlets": 0,
+ "patching_rect": [ 30.0, 350.0, 45.0, 45.0 ]
+ }
+ },
+ {
+ "box": {
+ "id": "obj-17",
+ "maxclass": "comment",
+ "numinlets": 1,
+ "numoutlets": 0,
+ "patching_rect": [ 30.0, 420.0, 560.0, 60.0 ],
+ "text": "Recipes: chord 3 7 12 over a sung line is the classic keyboard-harmonizer stack; chord -12 4 7 with dry 0 is a choir that replaces the singer; glide 400 turns chord changes into portamento. See the Tools on Tap book's harmonizer recipe (A choir of one) for the Bon Iver patches."
+ }
+ }
+ ],
+ "lines": [
+ {
+ "patchline": {
+ "source": [ "obj-3", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-5", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-6", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-7", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-8", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-9", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-10", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-11", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-12", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-13", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-14", 0 ],
+ "destination": [ "obj-4", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-4", 0 ],
+ "destination": [ "obj-15", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-15", 0 ],
+ "destination": [ "obj-16", 0 ]
+ }
+ },
+ {
+ "patchline": {
+ "source": [ "obj-15", 0 ],
+ "destination": [ "obj-16", 1 ]
+ }
+ }
+ ]
+ }
+}
diff --git a/plans/recipes-improvements.md b/plans/recipes-improvements.md
new file mode 100644
index 00000000..1c82a2cc
--- /dev/null
+++ b/plans/recipes-improvements.md
@@ -0,0 +1,210 @@
+# Plan — improvements surfaced by the Recipes chapters
+
+> **Status: planned, not started.** This is the design-of-record for the object improvements
+> discovered while drafting *Tools on Tap* Part IX (Recipes) — see `book/PLAN-recipes.md` in
+> the kernel repo for the chapters themselves. The recipe-drafting method (check every knob
+> against the wrapper source before it goes in print) doubles as an audit; this file collects
+> what the audit found. **Append to the discovery log (§10) as further chapters land.**
+
+The recurring tell: every place a recipe had to print a *formula or a workaround* instead of
+an attribute is a candidate hole. Items are ordered by how loudly the writing complained.
+
+---
+
+## 1. `tap.adsr~` — full rewrite as a virtual-analog envelope (kernel-first)
+
+> **Status: shipped 2026-08-05** — kernel `taptools/adsr.h` + eight-scenario battery +
+> capi/bridge + executed `adsr.ipynb`; wrapper shrunk to Min glue with `analog` as the new
+> default and `threshold`/`velocity` exposed; maxref rewritten; REVIVAL §14. The design
+> below was followed as written, with one refinement: the decay/release knob contract is
+> "95 % of the gap closed at the knob time" (τ = t/3), documented and pinned.
+
+**What it is today.** A faithful port of Jamoma's `TTAdsr` (2003): piecewise segments that
+are straight lines in amplitude (`linear`) or straight lines in dB (`exponential`/`hybrid`),
+with hard state switches at the targets. The DSP lives in the wrapper `.cpp` — it predates
+the kernel-header rule. Trigger is a level gate (`x > 0.5`) with no velocity or accent
+awareness.
+
+**Why rewrite rather than patch.** Three independent reasons:
+
+- **It is not virtual-analog, and the difference is audible.** An analog EG is an RC
+ circuit: the attack charges toward an *overshoot* target (well above full scale) and is
+ cut at full scale — that truncated-exponential shape is where the perceived punch lives;
+ decay and release are true exponentials that *taper into* sustain and zero
+ asymptotically. Straight lines in dB hit their targets at constant rate and stop dead.
+ The curve family and its constants come from published sources (the CEM3310 and SSM2056
+ datasheets and the standard Electronotes treatment — the same provenance discipline as
+ every other kernel).
+- **It ignores the package's trigger contract.** The whole family speaks
+ amplitude-as-accent (808 voices, both sequencer rows, the 303's gate at 1.0/2.0), but
+ `tap.adsr~` reads a bare level gate at exactly 0.5 — which is *precisely* the sequencer's
+ default `accented` level, so the obvious cross-family patch fails silently at defaults
+ (`0.5 > 0.5` is false).
+- **The DSP is in the wrong repo.** House rule: substantial DSP is a kernel header with its
+ own Catch2 battery, capi exposure, and notebook verification. An envelope kernel is also
+ reusable — the 303/808 voices carry envelope circuits of their own today, and future
+ kernels shouldn't re-derive RC segments.
+
+**Target design.**
+
+- `taptools/adsr.h`, `tap::tools::adsr`, house shape: `prepare(sr)`, per-sample
+ `process(gate)`, allocation-free setters.
+- **Analog core:** one-pole RC toward per-stage targets. Attack → overshoot target
+ (datasheet-derived constant, on the order of 1.3–1.6× full scale), switch at 1.0. Decay →
+ sustain, release → 0, both true exponentials with time constants defined so the knob's
+ milliseconds mean the measured 10–90 % / T60-style figure the header documents (contract
+ numbers, pinned by test — decide and document the exact convention, don't inherit
+ ambiguity).
+- **Retrigger from current level** (analog behavior; no reset click), and a documented
+ legato rule.
+- **Trigger contract:** gate opens above a `threshold` attribute (default well below 0.5 —
+ align with the seq's `plain`/`accented` levels), and **gate amplitude is velocity**:
+ peak and sustain scale by a `velocity` sensitivity attribute (0 = today's behavior,
+ 1 = full scaling). The 303 convention (1.0 plain / 2.0 accented) and the 808 rows'
+ amplitudes both land meaningfully without adapters.
+- **Compatibility:** keep `mode linear|exponential|hybrid` as the faithful Jamoma curves
+ (they are a screenful); add `analog` and make it the default. The wrapper's own header
+ note shows the project already chose "what users actually heard" over struct-reported
+ defaults once — this is the same call in the other direction, and the release notes say
+ so plainly.
+- **Verification:** Catch2 scenarios pinning the curve contract numbers and the
+ velocity/threshold behavior; capi + `taptools_py` exposure; an executed notebook cell
+ plotting analog vs. hybrid attacks (the book's future machine chapter cites it).
+- **Rollout:** kernel first (TapTools repo), then the wrapper here shrinks to Min glue and
+ the submodule pin bumps — the standard release flow.
+
+## 2. `tap.vco~` — a performance-modulation section (vibrato, bend)
+
+**The tell in print:** the famous-patches chapter has to teach `cycle~ 5.5` scaled by
+`0.006 × f` because the FM inlet is calibrated in Hz and constant-*cents* vibrato needs
+per-note depth scaling — then hand-rolls the delayed onset with `line~`.
+
+**Target:** extend the oscillator's analog section (already cents-calibrated, already
+seeded) with a periodic sibling: `vibrato` (cents, default 0), `vibrato_rate` (Hz),
+`vibrato_delay` (ms onset fade — the "singing" control). Optionally `bend` (semitones,
+riding the existing ramp machinery) for wheel work. Kernel-first in `vco.h`; the hardware
+precedent (the Minimoog's oscillator-bank modulation section) is the design argument.
+Depth 0 must keep the bit-identical-ideal-by-default promise the vco test suite pins.
+
+## 3. `tap.ladder~` — symbolic `mode`/`solver` (wrapper bug-fix, plus an audit)
+
+**Confirmed bug-adjacent:** the wrapper's `mode` and `solver` are `attribute`, but the
+shipped book chapters (ladder chapter's recipes; vco chapter's Moog recipe) print
+`@mode lp24` — which the object does not accept (and lp24 happens to be index 0, so the
+mistake can *appear* to work). Fix: symbolic enum attributes (`lp24|lp12|bp12|bp24|hp12|hp24`,
+`fast|exact`), keeping numeric acceptance for patch compatibility. Then: audit every object
+for the same pattern (`tap.diode~` is the likely sibling), and update the Part IX recipes'
+`@mode 0` workaround back to the symbolic spelling. Wrapper-only; no kernel change.
+
+## 4. Determinism sweep — `tap.noise~` and `tap.verb~`
+
+Two objects violate the "a seed is a serial number" doctrine:
+
+- **`tap.noise~`** seeds from `std::random_device` — renders don't reproduce. Add `seed`
+ (int ≥ 1, default 1, deterministic) with the family semantics; an *unseeded* mode is not
+ worth keeping (no other object has one).
+- **`tap.verb~`** calls `std::rand()` in `deviate()`, so its comb delays/decays
+ re-randomize on every `prepare`/`configure` — the reverb is not bit-reproducible across
+ instantiations. Same fix, same doctrine: a `seed` attribute, deterministic default,
+ behavior change in the release notes.
+
+## 5. `tap.delay~` / `tap.multitap~` — rebuild behind the same names (decision)
+
+The second-wave sweep found the delay pair is the weakest DSP in the package: **integer-
+sample delays with no interpolation** (modulating the time zipper-steps — while `tap.5comb~`
+and `tap.pitchaccum~` Hermite-interpolate as a point of pride), no feedback, no `mix`, no
+`bypass`/`mute`, and a trap on `tap.delay~`'s time inlet (a signal value of exactly 0.0
+means "use the attribute," not "zero delay").
+
+**Trash-or-rebuild was considered; the decision is rebuild, keeping the names.** The
+reasoning, recorded so it doesn't get re-litigated:
+
+- *Deleting* breaks every legacy patcher that instantiates them — the names date to the
+ 1999 package, and the revival's promise is continuity. A missing-object box is the worst
+ outcome the package can hand an old user.
+- *Keeping them as-is* is also indefensible: stock Max (`delay~`, `tapin~`/`tapout~`)
+ already covers bare uninterpolated delay, so the un-upgraded objects duplicate the host
+ with less. The only reason for a `tap.` delay to exist is the house treatment.
+- The substrate is already written: `grm_comb.h` carries the Hermite fractional tap and
+ the DC-blocked feedback loop. A kernel `delay.h` extracting that is small.
+
+Target: kernel-first rebuild — Hermite taps, `feedback` (delay~), `mix`, `bypass`/`mute`,
+per-tap gain *and pan* on multitap, house `smooth` ramps on times. Compatibility: keep an
+`interp 0` integer mode for bit-faithful legacy behavior (non-default), and **kill the
+0.0-signal trap** as a documented breaking change — a signal on the time inlet always wins,
+including at zero. Wrapper defaults otherwise preserved.
+
+## 6. `tap.vocoder~` — the missing conveniences
+
+The robot-voice recipe works around three absences: no unvoiced/**sibilance path** (the
+recipe's fix — ride 10 % noise in the carrier full-time — is the classic patch, but a
+detector-switched noise path is the classic *hardware* answer and a good kernel exercise
+from published vocoder literature), no `mix`, no `bypass`/`mute` (every comparable effect
+object has them). Low urgency, documented workarounds; batch with any vocoder revisit.
+
+## 7. Small QoL and bug notes
+
+- **`tap.5comb~`:** tuning is Hz-only; a `notes ` message would make the voicing
+ tables in the comb-drones recipe one message instead of five attributes. (Fractional
+ MIDI for JI intervals, as the 303 pair already does.)
+- **`tap.vco~` driven by a pitch signal loses glide** — the frequency signal inlet
+ bypasses `smooth` by design, so sequencer-driven patches need an external slew
+ (`slide~`) to get portamento. Fold into §2: either a documented `glide` that applies to
+ the signal inlet, or a MIDI-note signal input mode with the 303-style RC.
+- **`tap.sustain~` `length`** has no setter — runtime changes silently wait for a DSP
+ restart. Either wire the setter (re-size at next `dspsetup`, documented) or document the
+ restriction in the maxref (which currently documents *nothing* — see §8).
+- **`tap.crossfade~` `mode`** is a legacy no-op (both values compute identically) —
+ deprecate in docs so nobody A/Bs a placebo.
+- **`tap.harmony~` v1.1 — the house machinery.** The shipped v1 lacks `mute`/`bypass` and
+ the 16-slot preset-morph engine its effect siblings carry (autowah/pitchaccum/5comb/303).
+ A timed morph between two stored *chords* is very much in the recipes' spirit; the kernel
+ already slews intervals and gains, so this is wrapper plumbing plus the store/recall
+ pattern. Also fixed post-ship: the `chord` message now writes through the attributes, so
+ queries stay truthful and a DSP restart no longer reverts the last chord.
+
+## 8. Documentation debts (no DSP)
+
+- The vco chapter should say once that `smooth` is a per-object ramp time shared by every
+ parameter — 280 ms of Lucky-Man portamento also slows `pw`/`gain` changes on that object.
+- After §3 lands, sweep the book for `@mode 0`-style numeric workarounds and restore the
+ symbolic spellings.
+- **Maxref drift found by the sweep:** `tap.verb~` XML documents `use_early_reflections`
+ (wrapper: `er`) and omits most of the real surface; `tap.vocoder~` XML types `q` /
+ `response_interval` as symbols (wrapper: number); `tap.sustain~` XML documents no
+ attributes or methods at all; `tap.adsr~` XML lists `int`/`float` methods the wrapper
+ doesn't define. The wrapper is the source of truth — regenerate or hand-fix the four.
+- **Fixed already (book repo, this branch):** the pitchaccum chapter's `pitch1`/`feedback1`
+ spellings → `trans1`/`fb1` with the 0–99 feedback scale.
+
+## 9. Sequencing and priorities
+
+1. §3 (small, self-contained, un-breaks shipped documentation), §4 (small), and the §7
+ bug notes (`tap.sustain~` setter, `tap.crossfade~` deprecation).
+2. §1 (kernel `adsr.h` + tests + notebook, then wrapper + pin bump) — the big one, and the
+ gate for any recipe that leans on envelopes driven from the sequencer family.
+3. §2 (kernel `vco.h` + tests, wrapper attribute plumbing, then simplify the
+ famous-patches chapter's vibrato plumbing to the new attributes), absorbing the §7
+ pitch-signal glide note.
+4. §5 (the delay pair rebuild) — real work, worth its own design pass and plan file when
+ picked up.
+5. §6 and the §8 doc sweep ride along with whichever PR touches each object or page.
+
+## 10. Discovery log — append as further recipes land
+
+| date | chapter being drafted | finding | disposition |
+|---|---|---|---|
+| 2026-08-05 | famous Moog patches | FM-inlet vibrato needs a printed formula | → §2 |
+| 2026-08-05 | (drum scaffold audit) | `tap.adsr~` level-gate vs. family contract; not VA; DSP in wrapper | → §1 |
+| 2026-08-05 | Moog voice | book's `@mode lp24` spelling doesn't match `attribute` | → §3 |
+| 2026-08-05 | (kit audit) | `tap.noise~` unseeded | → §4 |
+| 2026-08-05 | shimmer | pitchaccum chapter's `pitch1`/`feedback1` don't match wrapper `trans1`/`fb1` (0–99) | fixed in book; → §8 |
+| 2026-08-05 | sequenced modular | vco frequency-signal inlet bypasses `smooth` — sequenced pitch can't glide without external `slide~` | → §2 / §7 |
+| 2026-08-05 | sequenced modular | `tap.delay~`/`tap.multitap~`: integer-sample, no feedback/mix; 0.0-signal trap | → §5 |
+| 2026-08-05 | shimmer | `tap.verb~` uses `std::rand()` — not reproducible | → §4 |
+| 2026-08-05 | robot voice | vocoder has no sibilance path, no mix/bypass | → §6 |
+| 2026-08-05 | comb drones | `tap.5comb~` tunes in Hz only — voicing tables want a MIDI list message | → §7 |
+| 2026-08-05 | (sweep) | maxref drift: verb `er`, vocoder attr types, sustain XML empty, adsr phantom methods | → §8 |
+| 2026-08-05 | (sweep) | `tap.sustain~` `length` setter missing; `tap.crossfade~` `mode` is a no-op | → §7 |
+| 2026-08-05 | robot voice (songbook) | the "Hide and Seek" mechanism is a *formant-corrected multi-voice harmonizer* — no package object does formant-true shifting (`tap.shift~` moves formants with pitch; the recipe caps its stack at ±7 st for that reason). Candidate new object on the DspTap LPC substrate (`pvoc.h` already does envelope-preserving shifts) | **shipped 2026-08-05** as `tap.harmony~` (kernel `harmonizer.h` + wrapper; REVIVAL.md §13) |
+| 2026-08-05 | delay-pair decision | trash-or-rebuild resolved: rebuild behind the same names, `interp 0` legacy mode, kill the 0.0-signal trap | → §5 |
diff --git a/source/projects/tap.adsr_tilde/CMakeLists.txt b/source/projects/tap.adsr_tilde/CMakeLists.txt
index 61060008..a50c596a 100644
--- a/source/projects/tap.adsr_tilde/CMakeLists.txt
+++ b/source/projects/tap.adsr_tilde/CMakeLists.txt
@@ -10,9 +10,12 @@ include(${C74_MIN_API_DIR}/script/min-pretarget.cmake)
#############################################################
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/adsr.h
)
add_library(
diff --git a/source/projects/tap.adsr_tilde/tap.adsr_tilde.cpp b/source/projects/tap.adsr_tilde/tap.adsr_tilde.cpp
index f4ceed7c..ddbbcd8e 100644
--- a/source/projects/tap.adsr_tilde/tap.adsr_tilde.cpp
+++ b/source/projects/tap.adsr_tilde/tap.adsr_tilde.cpp
@@ -1,274 +1,128 @@
/// @file
-/// @brief tap.adsr~ — attack/decay/sustain/release envelope generator.
-/// @details Faithful port of Jamoma's TTAdsr. The envelope is triggered either by the `trigger`
-/// attribute (control rate) or, when a signal is connected to the inlet, by that signal
-/// crossing 0.5 (signal rate). Three curve modes are provided:
-/// - linear: linear ramps for every stage
-/// - exponential: exponential (dB-linear) ramps for every stage
-/// - hybrid: linear attack with exponential decay/release
-/// DSP is plain portable C++ — Min only wires the object into Max.
-/// @note The original Max wrapper's struct reported a default mode of "linear", but the
-/// underlying TTAdsr always defaulted to "hybrid" — which is what users actually heard —
-/// so this port defaults to hybrid to preserve the original sound.
+/// @brief tap.adsr~ — virtual-analog attack/decay/sustain/release envelope generator.
+/// @details Rebuilt on the portable kernel (taptools/adsr.h): the default `analog` mode is a
+/// circuit model — an RC attack charging toward a 1.4× overshoot target and truncated
+/// at full scale (the CEM 3310 architecture), decay and release as true RC discharges
+/// that taper into their targets — and the 2003 Jamoma TTAdsr curves are preserved
+/// verbatim as the `hybrid` / `linear` / `exponential` compatibility modes. This is a
+/// deliberate default change: the analog curve is the sound the knobs always implied,
+/// and the old curves remain one attribute away.
+///
+/// Triggering follows the family contract at last: a signal gate opens above
+/// `threshold` (default 0.005, so a `tap.808.seq~` row's plain 0.01 level registers —
+/// the old hard-coded 0.5 is retired), and the gate's amplitude is velocity under the
+/// `velocity` sensitivity (0 = amplitude-blind legacy behavior; 1 = a 303-style 2.0
+/// accent hits twice as hard). Without a signal connection the `trigger` attribute
+/// drives the gate. This file is only the Max plumbing.
/// @author Timothy Place, Dave Watson, Trond Lossius
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 2003-2026 Timothy Place.
-#include
-#include
-
#include "c74_min.h"
+#include "taptools/adsr.h"
using namespace c74::min;
class adsr : public object, public sample_operator<1, 1> {
private:
- static constexpr double k_noise_floor{-120.0}; ///< envelope basement, in dB
-
- enum eg_state { eg_inactive = 0, eg_attack, eg_decay, eg_sustain, eg_release };
- enum mode_type { mode_linear = 0, mode_exponential, mode_hybrid };
+ // Constructed before the attributes below so their defaults can forward into it.
+ tap::tools::adsr::generator m_engine;
public:
- MIN_DESCRIPTION{"An attack/decay/sustain/release envelope generator. Triggered by the trigger "
- "attribute, or by a signal (crossing 0.5) connected to the inlet. Linear, "
- "exponential, or hybrid curves."};
+ MIN_DESCRIPTION{"A virtual-analog ADSR envelope generator. The default analog mode models the "
+ "classic RC circuit (overshoot-target attack, asymptotic decay/release); the "
+ "legacy hybrid/linear/exponential curves remain as modes. Triggered by a signal "
+ "gate (amplitude is velocity) or the trigger attribute."};
MIN_TAGS{"generators"};
MIN_AUTHOR{"Timothy Place"};
- MIN_RELATED{"adsr~, function, line~, curve~"};
+ MIN_RELATED{"adsr~, function, line~, curve~, tap.vca~"};
- inlet<> m_in{this, "(signal/anything) signal trigger (>0.5) or control messages"};
+ inlet<> m_in{this, "(signal/anything) gate signal (opens above threshold) or control messages"};
outlet<> m_out{this, "(signal) envelope output", "signal"};
- attribute trigger{this, "trigger", false, setter{MIN_FUNCTION{
- m_trigger = args[0];
- return args;
- }},
- description{"Open the envelope (attack) while on; release it while off."}};
+ adsr(const atoms& args = {}) { m_engine.prepare(samplerate()); }
+
+ attribute trigger{this, "trigger", false,
+ description{"Open the envelope (attack) while on; release it while off. "
+ "Ignored while a signal is connected to the inlet."}};
attribute attack{this, "attack", 50.0, setter{MIN_FUNCTION{
- m_attack_ms = MIN_CLAMP(static_cast(args[0]), 1.0, 60000.0);
- update_steps();
- return {m_attack_ms};
+ m_engine.set_attack_ms(args[0]);
+ return {m_engine.attack_ms()};
}},
- description{"Attack time in milliseconds."}};
+ description{"Attack time in milliseconds (1..60000)."}};
attribute decay{this, "decay", 100.0, setter{MIN_FUNCTION{
- m_decay_ms = MIN_CLAMP(static_cast(args[0]), 1.0, 60000.0);
- update_steps();
- return {m_decay_ms};
+ m_engine.set_decay_ms(args[0]);
+ return {m_engine.decay_ms()};
}},
- description{"Decay time in milliseconds."}};
+ description{"Decay time in milliseconds (1..60000)."}};
attribute sustain{this, "sustain", -6.0, setter{MIN_FUNCTION{
- m_sustain_db = args[0];
- m_sustain_amp = decibels_to_gain(m_sustain_db);
+ m_engine.set_sustain_db(args[0]);
return args;
}},
description{"Sustain level in decibels."}};
attribute release{this, "release", 500.0, setter{MIN_FUNCTION{
- m_release_ms = MIN_CLAMP(static_cast(args[0]), 1.0, 60000.0);
- update_steps();
- return {m_release_ms};
+ m_engine.set_release_ms(args[0]);
+ return {m_engine.release_ms()};
}},
- description{"Release time in milliseconds."}};
+ description{"Release time in milliseconds (1..60000)."}};
attribute mode{this,
"mode",
- "hybrid",
- range{"hybrid", "linear", "exponential"},
+ "analog",
+ range{"analog", "hybrid", "linear", "exponential"},
setter{MIN_FUNCTION{
- if (args[0] == "linear") {
- m_mode = mode_linear;
+ using kmode = tap::tools::adsr::mode;
+ if (args[0] == "hybrid") {
+ m_engine.set_mode(kmode::hybrid);
+ }
+ else if (args[0] == "linear") {
+ m_engine.set_mode(kmode::linear);
}
else if (args[0] == "exponential") {
- m_mode = mode_exponential;
+ m_engine.set_mode(kmode::exponential);
}
else {
- m_mode = mode_hybrid;
+ m_engine.set_mode(kmode::analog);
}
return args;
}},
- description{"Envelope curve: hybrid, linear, or exponential."}};
-
- message<> dspsetup{this, "dspsetup", "Recompute step sizes for the current sample rate.",
+ description{"Envelope curve: analog (the circuit model, default), or the "
+ "legacy hybrid, linear, exponential."}};
+
+ attribute threshold{this, "threshold", tap::tools::adsr::k_default_threshold,
+ setter{MIN_FUNCTION{
+ m_engine.set_threshold(args[0]);
+ return {m_engine.threshold()};
+ }},
+ description{"Gate-open level (0..1). The default hears a sequencer "
+ "row's plain 0.01 hits."}};
+
+ attribute velocity{this, "velocity", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_velocity(args[0]);
+ return {m_engine.velocity()};
+ }},
+ description{"Velocity sensitivity (0..1): peak and sustain scale by "
+ "1 + velocity x (gate amplitude - 1). 0 ignores amplitude."}};
+
+ message<> clear{this, "clear", "Reset the envelope to silence and idle.",
+ MIN_FUNCTION{
+ m_engine.clear();
+ return {};
+ }};
+
+ message<> dspsetup{this, "dspsetup", "Recompute the stage coefficients for the current sample rate.",
MIN_FUNCTION{
- update_steps();
+ m_engine.prepare(samplerate());
return {};
}};
sample operator()(sample x) {
- if (m_in.has_signal_connection()) {
- m_trigger = (x > 0.5);
- }
-
- // Shared state transitions.
- if (m_trigger) {
- if (m_state == eg_inactive || m_state == eg_release) {
- m_state = eg_attack;
- }
- }
- else {
- if (m_state != eg_inactive && m_state != eg_release) {
- m_state = eg_release;
- }
- }
-
- switch (m_mode) {
- case mode_linear:
- process_linear();
- break;
- case mode_exponential:
- process_exponential();
- break;
- case mode_hybrid:
- default:
- process_hybrid();
- break;
- }
- return m_output;
- }
-
- private:
- // Parameters (durations in ms; sustain in dB / linear).
- double m_attack_ms{50.0};
- double m_decay_ms{100.0};
- double m_release_ms{500.0};
- double m_sustain_db{-6.0};
- double m_sustain_amp{0.5011872336272722}; // -6 dB
-
- // Per-sample step sizes (recomputed from the sample rate).
- double m_attack_step{0.0};
- double m_decay_step{0.0};
- double m_release_step{0.0};
- double m_attack_step_db{0.0};
- double m_decay_step_db{0.0};
- double m_release_step_db{0.0};
-
- // Running state.
- double m_output{0.0};
- double m_output_db{k_noise_floor};
- int m_state{eg_inactive};
- int m_mode{mode_hybrid};
- bool m_trigger{false};
-
- static double decibels_to_gain(double db) { return std::pow(10.0, db * 0.05); }
-
- static double gain_to_decibels(double amp) { return (amp <= 0.0) ? k_noise_floor : 20.0 * std::log10(amp); }
-
- void update_steps() {
- const double sr = samplerate();
-
- const long attack_samples = std::max(1L, static_cast((m_attack_ms / 1000.0) * sr));
- const long decay_samples = std::max(1L, static_cast((m_decay_ms / 1000.0) * sr));
- const long release_samples = std::max(1L, static_cast((m_release_ms / 1000.0) * sr));
-
- m_attack_step = 1.0 / attack_samples;
- m_decay_step = 1.0 / decay_samples;
- m_release_step = 1.0 / release_samples;
- m_attack_step_db = -(k_noise_floor / attack_samples);
- m_decay_step_db = -(k_noise_floor / decay_samples);
- m_release_step_db = -(k_noise_floor / release_samples);
- }
-
- void process_linear() {
- switch (m_state) {
- case eg_attack:
- m_output += m_attack_step;
- if (m_output >= 1.0) {
- m_output = 1.0;
- m_state = eg_decay;
- }
- break;
- case eg_decay:
- m_output -= m_decay_step;
- if (m_output <= m_sustain_amp) {
- m_state = eg_sustain;
- m_output = m_sustain_amp;
- }
- break;
- case eg_sustain:
- break;
- case eg_release:
- m_output -= m_release_step;
- if (m_output <= 0.0) {
- m_state = eg_inactive;
- m_output = 0.0;
- }
- break;
- }
- }
-
- void process_exponential() {
- switch (m_state) {
- case eg_attack:
- m_output_db += m_attack_step_db;
- if (m_output_db >= 0.0) {
- m_state = eg_decay;
- m_output = 1.0;
- }
- else {
- m_output = decibels_to_gain(m_output_db);
- }
- break;
- case eg_decay:
- m_output_db -= m_decay_step_db;
- m_output = decibels_to_gain(m_output_db);
- if (m_output <= m_sustain_amp) {
- m_state = eg_sustain;
- m_output = m_sustain_amp;
- }
- break;
- case eg_sustain:
- break;
- case eg_release:
- m_output_db -= m_release_step_db;
- if (m_output_db <= k_noise_floor) {
- m_state = eg_inactive;
- m_output = 0.0;
- }
- else {
- m_output = decibels_to_gain(m_output_db);
- }
- break;
- }
- }
-
- // Hybrid: linear attack (good for short times) + exponential decay/release.
- void process_hybrid() {
- switch (m_state) {
- case eg_attack:
- m_output += m_attack_step;
- if (m_output >= 1.0) {
- m_output = 1.0;
- m_output_db = 0.0;
- m_state = eg_decay;
- }
- else {
- m_output_db = gain_to_decibels(m_output);
- }
- break;
- case eg_decay:
- m_output_db -= m_decay_step_db;
- m_output = decibels_to_gain(m_output_db);
- if (m_output <= m_sustain_amp) {
- m_state = eg_sustain;
- m_output = m_sustain_amp;
- }
- break;
- case eg_sustain:
- break;
- case eg_release:
- m_output_db -= m_release_step_db;
- if (m_output_db <= k_noise_floor) {
- m_state = eg_inactive;
- m_output = 0.0;
- }
- else {
- m_output = decibels_to_gain(m_output_db);
- }
- break;
- }
+ const double gate = m_in.has_signal_connection() ? static_cast(x) : (trigger ? 1.0 : 0.0);
+ return m_engine.process(gate);
}
};
diff --git a/source/projects/tap.adsr_tilde/tap.adsr_tilde_test.cpp b/source/projects/tap.adsr_tilde/tap.adsr_tilde_test.cpp
index ec6f36db..3c4f208c 100644
--- a/source/projects/tap.adsr_tilde/tap.adsr_tilde_test.cpp
+++ b/source/projects/tap.adsr_tilde/tap.adsr_tilde_test.cpp
@@ -18,14 +18,16 @@ SCENARIO("tap.adsr~ instantiates with the documented defaults") {
test_wrapper an_instance;
adsr& my_object = an_instance;
- THEN("attack 50, decay 100, sustain -6, release 500, mode hybrid, trigger off") {
+ THEN("attack 50, decay 100, sustain -6, release 500, mode analog, trigger off") {
REQUIRE(static_cast(my_object.attack) == 50.0);
REQUIRE(static_cast(my_object.decay) == 100.0);
REQUIRE(static_cast(my_object.sustain) == -6.0);
REQUIRE(static_cast(my_object.release) == 500.0);
const symbol m = my_object.mode;
- REQUIRE(std::string(m.c_str()) == "hybrid");
+ REQUIRE(std::string(m.c_str()) == "analog");
REQUIRE(static_cast(my_object.trigger) == false);
+ REQUIRE(static_cast(my_object.velocity) == 0.0);
+ REQUIRE(static_cast(my_object.threshold) == tap::tools::adsr::k_default_threshold);
}
}
}
diff --git a/source/projects/tap.harmony_tilde/CMakeLists.txt b/source/projects/tap.harmony_tilde/CMakeLists.txt
new file mode 100644
index 00000000..88a5f33c
--- /dev/null
+++ b/source/projects/tap.harmony_tilde/CMakeLists.txt
@@ -0,0 +1,31 @@
+# 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/harmonizer.h
+)
+
+add_library(
+ ${PROJECT_NAME}
+ MODULE
+ ${SOURCES}
+)
+
+include(${C74_MIN_API_DIR}/script/min-posttarget.cmake)
+
+# The harmonizer kernel composes the DspTap phase vocoder (tap::dsp): link it into the external.
+# (Needs C++20 for DspTap's headers; the root sets C++20 on every object target centrally.)
+target_link_libraries(${PROJECT_NAME} PRIVATE tap::dsp)
diff --git a/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp b/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp
new file mode 100644
index 00000000..5954fbf0
--- /dev/null
+++ b/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp
@@ -0,0 +1,169 @@
+/// @file
+/// @brief tap.harmony~ — formant-preserving multi-voice harmonizer.
+/// @details The keyboard-harmonizer effect (the DigiTech Vocalist lineage): up to four
+/// pitch-shifted copies of a monophonic source, each holding a musical interval in
+/// fractional semitones, summed with a dry path the kernel delays to the voices'
+/// emission time so chords land as chords rather than slapback. Formant
+/// preservation is on by default — shifted voices keep the singer's spectral
+/// envelope, which is what separates a harmonizer from a chipmunk chorus.
+///
+/// All DSP lives in the portable kernel (taptools/harmonizer.h, composing the
+/// DspTap phase vocoder's peak-locked shifting and LPC source-filter formant
+/// preservation — published-literature implementations only); this file is only
+/// the Max plumbing. Latency is one FFT frame (fftsize samples, dry included).
+/// Monophonic source material by design; wrap in mc. for multichannel.
+/// @author Timothy Place
+// SPDX-License-Identifier: BSD-3-Clause
+// Copyright 2026 Timothy Place.
+
+#include "c74_min.h"
+#include "taptools/harmonizer.h"
+
+using namespace c74::min;
+
+class harmony : public object, public sample_operator<1, 1> {
+ private:
+ // Constructed before the attributes below so their defaults can forward into it.
+ tap::tools::harmony::harmonizer m_engine;
+ int m_fft{static_cast(tap::tools::harmony::k_default_fft)};
+
+ public:
+ MIN_DESCRIPTION{"Formant-preserving multi-voice harmonizer. Up to four pitch-shifted copies "
+ "of a monophonic input hold musical intervals (fractional semitones) over a "
+ "latency-aligned dry path. Formant preservation keeps the singer's envelope; "
+ "latency is one FFT frame."};
+ MIN_TAGS{"audio, effects, pitch"};
+ MIN_AUTHOR{"Timothy Place"};
+ MIN_RELATED{"tap.tune~, tap.shift~, tap.pitchaccum~, tap.vocoder~"};
+
+ inlet<> m_in{this, "(signal) audio input"};
+ outlet<> m_out{this, "(signal) harmonized output", "signal"};
+
+ harmony(const atoms& args = {}) { m_engine.prepare(samplerate(), static_cast(m_fft)); }
+
+ attribute interval1{this, "interval1", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_interval(0, args[0]);
+ return args;
+ }},
+ description{"Voice 1 interval in semitones (-24..24, fractional allowed)."}};
+ attribute interval2{this, "interval2", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_interval(1, args[0]);
+ return args;
+ }},
+ description{"Voice 2 interval in semitones (-24..24, fractional allowed)."}};
+ attribute interval3{this, "interval3", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_interval(2, args[0]);
+ return args;
+ }},
+ description{"Voice 3 interval in semitones (-24..24, fractional allowed)."}};
+ attribute interval4{this, "interval4", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_interval(3, args[0]);
+ return args;
+ }},
+ description{"Voice 4 interval in semitones (-24..24, fractional allowed)."}};
+
+ attribute level1{this, "level1", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_gain(0, args[0]);
+ return args;
+ }},
+ description{"Voice 1 level, linear 0..2. 0 disables the voice."}};
+ attribute level2{this, "level2", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_gain(1, args[0]);
+ return args;
+ }},
+ description{"Voice 2 level, linear 0..2. 0 disables the voice."}};
+ attribute level3{this, "level3", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_gain(2, args[0]);
+ return args;
+ }},
+ description{"Voice 3 level, linear 0..2. 0 disables the voice."}};
+ attribute level4{this, "level4", 0.0, setter{MIN_FUNCTION{
+ m_engine.set_gain(3, args[0]);
+ return args;
+ }},
+ description{"Voice 4 level, linear 0..2. 0 disables the voice."}};
+
+ attribute dry{this, "dry", 1.0, setter{MIN_FUNCTION{
+ m_engine.set_dry(args[0]);
+ return args;
+ }},
+ description{"Dry level, linear 0..2. The dry path is delayed inside the "
+ "kernel to align with the voices."}};
+
+ attribute formant{this, "formant", true, setter{MIN_FUNCTION{
+ m_engine.set_formant(args[0]);
+ return args;
+ }},
+ description{"LPC formant preservation on every voice. On keeps the "
+ "singer's envelope; off is the chipmunk-chorus bend."}};
+
+ attribute glide{this, "glide", tap::tools::harmony::k_default_glide_ms,
+ setter{MIN_FUNCTION{
+ m_engine.set_glide(args[0]);
+ return args;
+ }},
+ description{"Interval glide time constant in ms (0..2000). Click-free "
+ "at the default; an audible portamento in the hundreds."}};
+
+ attribute fftsize{this, "fftsize", static_cast(tap::tools::harmony::k_default_fft),
+ setter{MIN_FUNCTION{
+ int n = args[0];
+ n = std::clamp(n, 256, 4096);
+ // round up to a power of two, per the kernel's contract
+ int p = 256;
+ while (p < n) {
+ p <<= 1;
+ }
+ m_fft = p;
+ return {m_fft};
+ }},
+ description{"FFT frame size in samples (256..4096, rounded up to a power "
+ "of two). This is the latency. Applies when DSP restarts."}};
+
+ /// Set up to four intervals at once and enable exactly those voices at level 1.
+ /// Writes through the attributes (not the engine directly) so queries stay truthful
+ /// and a DSP restart's re-apply preserves the chord instead of reverting it.
+ message<> chord{this, "chord", "Set up to four intervals (semitones) and enable those voices.",
+ MIN_FUNCTION{
+ attribute* intervals[] = {&interval1, &interval2, &interval3, &interval4};
+ attribute* levels[] = {&level1, &level2, &level3, &level4};
+ for (int v = 0; v < 4; ++v) {
+ if (v < static_cast(args.size())) {
+ *intervals[v] = static_cast(args[v]);
+ *levels[v] = 1.0;
+ }
+ else {
+ *levels[v] = 0.0;
+ }
+ }
+ return {};
+ }};
+
+ message<> clear{this, "clear", "Zero all running state (delay lines, phases, slews).",
+ MIN_FUNCTION{
+ m_engine.clear();
+ return {};
+ }};
+
+ message<> dspsetup{this, "dspsetup", "Re-prepare for the current sample rate and FFT size.",
+ MIN_FUNCTION{
+ m_engine.prepare(samplerate(), static_cast(m_fft));
+ // re-apply the current attribute state to the fresh engine
+ m_engine.set_interval(0, interval1);
+ m_engine.set_interval(1, interval2);
+ m_engine.set_interval(2, interval3);
+ m_engine.set_interval(3, interval4);
+ m_engine.set_gain(0, level1);
+ m_engine.set_gain(1, level2);
+ m_engine.set_gain(2, level3);
+ m_engine.set_gain(3, level4);
+ m_engine.set_dry(dry);
+ m_engine.set_formant(formant);
+ m_engine.set_glide(glide);
+ return {};
+ }};
+
+ sample operator()(sample x) { return m_engine.process(x); }
+};
+
+MIN_EXTERNAL(harmony);
diff --git a/submodules/taptools b/submodules/taptools
index 492cfe90..37c6d331 160000
--- a/submodules/taptools
+++ b/submodules/taptools
@@ -1 +1 @@
-Subproject commit 492cfe90a40f3e9b1067780c19ecb3a64b27a4be
+Subproject commit 37c6d3314187bca7e492a06e4eaca83c72b9b827