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 + +[![build](https://github.com/tap/TapTools-Max/actions/workflows/build.yml/badge.svg)](https://github.com/tap/TapTools-Max/actions/workflows/build.yml) +[![Tap House Style](https://github.com/tap/TapTools-Max/actions/workflows/style.yml/badge.svg)](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