diff --git a/REVIVAL.md b/REVIVAL.md
index 7d6d9fa3..930f2155 100644
--- a/REVIVAL.md
+++ b/REVIVAL.md
@@ -1436,6 +1436,19 @@ recipes-improvements plan §1 carries the reasoning. Kernel: eight Catch2 scenar
`adsr.ipynb`; wrapper shrunk to Min glue, maxref rewritten (retiring its phantom `int`/`float`
methods), wrapper tests updated. Remaining: in-Max validation.
+**15. The improvements wave (2026-08-05).** ✅ **Shipped in parallel** (four worktree agents,
+serial integration): kernel side — the vco performance section (vibrato in cents with
+per-note-re-armed onset delay, bend; formula-free at last), the vocoder sibilance path and
+carrier-dry mix (silent-carrier contract intact at defaults), the harmonizer 16-slot preset
+morph, and the delay-pair kernel (`taptools/delay.h`, Hermite taps, DC-blocked feedback,
+`interp 0` bit-compat pinned against the Jamoma source). Max side — ladder/diode symbolic
+`mode`/`solver` (the min-api enum finding is recorded in the plan §3), noise/verb `seed`
+determinism, the sustain `length` setter, crossfade `mode` deprecated in docs, six maxrefs
+rewritten, the vco/vocoder/harmony wrapper surfaces grown, and tap.delay~/tap.multitap~
+rebuilt (multitap now stereo with per-tap pan; the 0.0-time-signal trap retired as a
+documented breaking change). Still open in the plan: 5comb `notes`, the verb `clear`
+DC-blocker one-liner, help-patcher refreshes, and the on-Mac validation passes.
+
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.crossfade~.maxref.xml b/docs/tap.crossfade~.maxref.xml
index 9a52d012..df09f056 100644
--- a/docs/tap.crossfade~.maxref.xml
+++ b/docs/tap.crossfade~.maxref.xml
@@ -33,7 +33,10 @@
- Determines the algorithm to use. In most cases it is best to use the 512-point lookup table.
+ DEPRECATED — retained for legacy patcher compatibility only. In the
+ original Jamoma object this selected between a computed crossfade and a 512-point
+ lookup table; in the modern port both values compute the identical result, so the
+ attribute has no effect on the sound. Do not bother A/B-ing it.
diff --git a/docs/tap.delay~.maxref.xml b/docs/tap.delay~.maxref.xml
index 9c030194..36425827 100644
--- a/docs/tap.delay~.maxref.xml
+++ b/docs/tap.delay~.maxref.xml
@@ -4,36 +4,92 @@
- Sample-accurate delay
+ Feedback delay line
- A simple audio delay.
+ The tap.delay~ object is a feedback delay line. The input is delayed by a number
+ of milliseconds set via the right inlet (signal or float) or the delay
+ attribute, with a DC-blocked feedback path, an equal-power dry/wet
+ mix (default 100 — wet-only, matching the original object), and a choice of
+ fractional Hermite interpolation (interp 1, the default) or the legacy
+ bit-compatible integer-sample truncation (interp 0). Parameter changes ride
+ per-sample slews, so they are click-free. NOTE: a signal connected to the right inlet
+ always drives the delay time, including at 0.0 — unlike the pre-rebuild object, where
+ a 0.0 signal meant "use the attribute". The optional argument sets the size (ms) of
+ the delay buffer, which bounds the maximum delay. For multichannel signals, wrap the
+ object in an mc. operator.
-
+
74 Objects
TapTools
Audio
Time
- Obsolete
-
+
- An initial size of the sample buffer memory of the delay.
+ Size of the delay buffer in milliseconds (default 1000). This bounds the maximum delay time. Applied when the DSP chain starts.
-
-
+
+
+
+
+
+ Audio input.
+
+
+ Delay time in milliseconds. A connected signal always wins, including at 0.0.
+
+
+
+
+
+
+
+ The delayed audio output.
+
+
+
+
+
+
+
+ Clear the delay buffer and the feedback-path filter state.
+
+
+
+
+
+
+
+ Delay time in milliseconds, slewed (click-free). Clamped to the buffer size. Ignored while a signal is connected to the right inlet. Default 0.
+
+
+ Feedback amount (0..0.99). The feedback path is DC-blocked and capped so the loop always decays. Default 0.
+
+
+ Dry/wet mix in percent (0..100), equal-power. 0 is bitwise dry, 100 bitwise wet. Default 100, matching the old wet-only object.
+
+
+ Interpolation mode: 1 (default) reads fractional delays with a 4-point Hermite; 0 restores the legacy bit-compatible integer-sample truncation.
+
+
+
+
+
+
+
+
-
diff --git a/docs/tap.diode~.maxref.xml b/docs/tap.diode~.maxref.xml
index faa8c004..618b2bf8 100644
--- a/docs/tap.diode~.maxref.xml
+++ b/docs/tap.diode~.maxref.xml
@@ -98,11 +98,12 @@
Output gain in dB.
-
- Nonlinear solver for the coupled diode equations: 0 = fast
- (secant-linearized solve plus one corrective pass, the default), 1 = exact
+
+ Nonlinear solver for the coupled diode equations: fast
+ (secant-linearized solve plus one corrective pass, the default) or exact
(re-linearization iterated to convergence). Audibly different only when drive and
- resonance are both pushed hard.
+ resonance are both pushed hard. Accepts the symbol or the index (fast = 0,
+ exact = 1); queries report the symbol.
Internal oversampling factor (1, 2, or 4; default 2). Higher factors
diff --git a/docs/tap.harmony~.maxref.xml b/docs/tap.harmony~.maxref.xml
index e9908da4..457e8f99 100644
--- a/docs/tap.harmony~.maxref.xml
+++ b/docs/tap.harmony~.maxref.xml
@@ -51,6 +51,27 @@
and the parameter slews.
+
+
+
+
+
+ Store the current parameters (intervals, levels, dry, glide, formant) in a
+ preset slot (1..16).
+
+
+
+
+
+
+
+
+ Morph all parameters to a stored preset (1..16). Every continuous parameter
+ glides to the stored values over the morph time in milliseconds (default: the
+ interp attribute); the formant flag snaps at recall start. A recalled
+ preset holds until the DSP chain restarts, which re-applies the attribute values.
+
+
@@ -105,6 +126,20 @@
Default 1024.
+
+
+ Default preset-morph time in milliseconds used by the recall message
+ when no morph time is given. Default 500.
+
+
+
+
+ Pass the input through unprocessed (no harmonizer latency). Default 0 (off).
+
+
+
+ Silence the output. Default 0 (off).
+
diff --git a/docs/tap.ladder~.maxref.xml b/docs/tap.ladder~.maxref.xml
index fe163c00..fcb3d88d 100644
--- a/docs/tap.ladder~.maxref.xml
+++ b/docs/tap.ladder~.maxref.xml
@@ -95,14 +95,16 @@
modeling transistor mismatch. 0 is symmetric (odd harmonics only); raising it adds
even-harmonic warmth. May produce slight signal-dependent DC when engaged.
-
- Nonlinear feedback solver: 0 = fast (linear zero-delay prediction plus one
- corrective saturation pass, the default), 1 = exact (Newton iteration to convergence).
- Audibly different only when drive and resonance are both pushed hard.
+
+ Nonlinear feedback solver: fast (linear zero-delay prediction plus one
+ corrective saturation pass, the default) or exact (Newton iteration to convergence).
+ Audibly different only when drive and resonance are both pushed hard. Accepts the
+ symbol or the index (fast = 0, exact = 1); queries report the symbol.
-
- Filter response via pole mixing: 0 = lp24, 1 = lp12, 2 = bp12,
- 3 = bp24, 4 = hp12, 5 = hp24.
+
+ Filter response via pole mixing: lp24, lp12, bp12, bp24, hp12, or hp24.
+ Accepts the symbol or the index (lp24 = 0, lp12 = 1, bp12 = 2, bp24 = 3, hp12 = 4,
+ hp24 = 5); queries report the symbol.
Internal oversampling factor (1, 2, or 4; default 2). Higher factors
diff --git a/docs/tap.multitap~.maxref.xml b/docs/tap.multitap~.maxref.xml
index 00cec02e..3c42307e 100644
--- a/docs/tap.multitap~.maxref.xml
+++ b/docs/tap.multitap~.maxref.xml
@@ -1,19 +1,26 @@
-
+
- A self-contained multitap delay
+ A self-contained stereo multitap delay
- The tap.multitap~ object
- is like a set of tapin~ and tapout~ objects, but all contained within one external for
- improved efficiency.
+ The tap.multitap~ object is like a set of tapin~ and tapout~
+ objects contained within one external. The input is recorded into one shared buffer
+ and up to 99 taps are summed to a stereo output pair, each tap with its own delay time
+ (delay, ms), gain (gain, dB), and equal-power pan (pan,
+ -1..1). A center-panned tap (the default) feeds both outlets equally, so the old mono
+ output is simply the mix of the two. Taps read with fractional Hermite interpolation
+ (interp 1, the default) or the legacy bit-compatible integer-sample
+ truncation (interp 0), and every per-tap parameter is slewed (click-free).
+ Pure feedforward: no dry path, no feedback, no master gain — gain staging is the
+ patch's job. The optional argument sets the buffer size in ms.
-
+
74 Objects
@@ -22,41 +29,67 @@
Time
-
+
- Sets the initial buffersize in ms. The default is 2000ms.
+ Size of the delay buffer in milliseconds (default 1000). This bounds the maximum delay time. Applied when the DSP chain starts.
+
+
+
+ Audio input.
+
+
+
+
+
+
+
+ Summed delay taps, left.
+
+
+ Summed delay taps, right.
+
+
+
+
+
+
+
+ Clear the delay buffer.
+
+
+
+
-
- The number of taps in the multitap delay.
-
+ The number of active delay taps (1..99). Default 1.
+
+
+ A list of delay times in milliseconds, one per tap, slewed by the kernel.
-
-
- Sets the delay time for one of the taps.
- The first int specifies the tap, the second number specifies the delay time.
-
+
+ A list of gains in dB, one per tap, slewed by the kernel. Default 0 dB.
-
-
- Sets the gain for one of the taps.
- The first int specifies the tap, the second number specifies the gain.
-
+
+ A list of equal-power pan positions (-1 hard left .. 1 hard right), one per tap, slewed by the kernel. A center-panned tap (0, the default) feeds both outlets equally; the endpoints are exact — a hard-panned tap is absent from the far outlet.
+
+
+ Interpolation mode for all taps: 1 (default) reads fractional delays with a 4-point Hermite; 0 restores the legacy bit-compatible integer-sample truncation.
-
-
+
+
+
+
-
diff --git a/docs/tap.noise~.maxref.xml b/docs/tap.noise~.maxref.xml
index 0e8464b2..d463a0fa 100644
--- a/docs/tap.noise~.maxref.xml
+++ b/docs/tap.noise~.maxref.xml
@@ -1,6 +1,6 @@
-
+
@@ -8,11 +8,17 @@
- The tap.noise~ object
- generates various types of noise in the signal domain.
+ The tap.noise~ object generates various types of noise in the signal domain:
+ white, pink, brown, blue, or gaussian. The white/pink/brown/blue generators are
+ faithful ports of the original TapTools noise object (an LCG white source plus the
+ same coloring filters); gaussian draws from a normal distribution with adjustable
+ mean and deviation. All generators are deterministic per seed — the same seed
+ reproduces the identical sample sequence on every render, and different seeds
+ decorrelate multiple instances (e.g. under mc.). Mono: wrap in an mc. operator for
+ multichannel.
-
+
74 Objects
@@ -21,23 +27,58 @@
Generator
-
+
+
+
+
+
+ Reset the coloring-filter memory (the pink/brown/blue filter states).
+
+
+
+
-
+
- The color of the noise to generate. Options are white, pink, brown, and blue.
+ The color of the noise to generate: white, pink, brown, blue, or gauss.
+
+
+
+
+ Seed for the noise generators (1 or higher; default 1). The output is
+ deterministic per seed, so renders reproduce exactly; give mc. instances
+ different seeds to decorrelate them. Setting the seed restarts the sequence.
+ The default seed reproduces the legacy object's sequence.
+
+
+
+
+ Output gain in decibels (default 0). Does not apply to gaussian noise, whose
+ level is defined by mean and deviation.
+
+
+
+
+ Mean of the gaussian distribution (default 0). Used only when mode is gauss.
+
+
+
+
+ Standard deviation of the gaussian distribution (0-1, default 1). Used only
+ when mode is gauss.
-
+
-
+
+
-
+
diff --git a/docs/tap.sustain~.maxref.xml b/docs/tap.sustain~.maxref.xml
index 813ea996..735724e7 100644
--- a/docs/tap.sustain~.maxref.xml
+++ b/docs/tap.sustain~.maxref.xml
@@ -1,80 +1,97 @@
-
+
+
+
-
+
+ Capture recent audio into seamless sustaining loops
+
-
-
-
- Capture recent audio into seamless sustaining loops
- Continuously records its signal input. On a bang the most recently recorded material is captured into a voice, trimmed to significant-power zero-crossings at both ends, and played back as a crossfaded forward/backward loop so it sustains without clicks. A bank of voices is allocated round-robin so successive bangs stack overlapping loops, each rising in over the rise time.
+
+ The tap.sustain~ object continuously records its signal input into a ring buffer.
+ On a bang, the most recently recorded material is captured into a voice: trimmed to
+ significant-power rising zero-crossings at both ends, then played back as a crossfaded
+ forward/backward loop so it sustains without clicks. A bank of voices is allocated
+ round-robin, so successive bangs stack overlapping loops — up to the voices count —
+ before the oldest voice is recycled (round-robin doubles as an oldest-first
+ voice-stealing policy). Each freshly captured voice swells in over the rise time, so a
+ newly stacked loop grows in under the existing ones rather than clicking in. All
+ sounding voices are summed to the output.
+
-
- Timothy Place
- buffer
+ Timothy Place
+ TapTools
+ Audio
+ Sampling
-
+
+
+
+ Audio to record; bang captures a sustaining loop.
+
+
-
-
+
+
+
+ Sustaining loop output (all voices summed).
+
+
-
-
-
- Erase all captured loops
- Erase all captured loops (every voice) and reset the round-robin allocation cursor.
+
+
+ Capture the most recently recorded audio into a sustaining loop,
+ allocated into the next voice round-robin so it overlaps any voices already
+ sounding. When all voices are in use, the oldest one is stolen.
-
-
- Capture a sustaining loop
- Capture the most recently recorded audio into a sustaining loop, allocated into the next voice round-robin so it overlaps any voices already sounding.
+
+
+ Erase all captured loops (every voice) and reset the round-robin
+ allocation cursor.
-
-
-
-
- Length of crossfade across loop-points in milliseconds
- Length of crossfade across loop-points in milliseconds.
+
+ Maximum length of the captured loop, in milliseconds (1 or higher;
+ default 1000). The value is stored immediately, but the recording buffer re-sizes
+ to match at the next DSP start (restart audio to apply it); until then captures
+ keep using the previous length.
-
-
- Maximum length of the captured loop in milliseconds
- Maximum length of the captured loop in milliseconds. Changing this restarts the recording buffer.
+
+ Number of overlapping sustaining voices (1-5, default 5). Each bang
+ captures into the next voice round-robin, so up to this many loops can stack
+ before the oldest is recycled.
-
-
- Per-voice fade-in time in milliseconds
- Time over which each newly captured voice grows to full power, in milliseconds. Applied independently per voice as a one-shot fade-in when it starts sustaining, so a stacked voice swells in under the others rather than clicking in.
+
+ Length of the equal-power crossfade across the loop points, in
+ milliseconds (default 100).
-
-
- Number of overlapping sustaining voices
- Number of overlapping sustaining voices (1-5, default 5). Each bang captures into the next voice round-robin, so up to this many loops can stack before the oldest is recycled.
+
+ Time over which each newly captured voice grows to full power, in
+ milliseconds (default 100). Applied independently per voice as a one-shot fade-in
+ when it starts sustaining, so a stacked voice swells in under the others rather
+ than clicking in.
-
-
-
+
-
-
-
+
+
+
diff --git a/docs/tap.vco~.maxref.xml b/docs/tap.vco~.maxref.xml
index b46a7d93..b3bc383a 100644
--- a/docs/tap.vco~.maxref.xml
+++ b/docs/tap.vco~.maxref.xml
@@ -20,7 +20,9 @@
adds a slow random pitch drift, a fast pitch jitter, a static detune, a V/oct tracking
error, and a waveform-imperfection control — all in cents-calibrated units and all
deterministic per seed (give mc. instances different seeds: with imperfect raised, each
- becomes a slightly different unit off the production line). Sixteen preset slots can be
+ becomes a slightly different unit off the production line). A performance-modulation
+ section adds cents-calibrated vibrato with a delayed onset re-armed on every note change,
+ and a pitch-bend control that rides the smooth ramp. Sixteen preset slots can be
stored and morphed between. Pairs naturally with tap.ladder~.
@@ -106,6 +108,24 @@
Static detune in cents (-1200..1200).
+
+ Periodic pitch-modulation depth in cents (0-100, default 0) — the
+ performance companion to drift. The depth is cents-calibrated, so the musical
+ vibrato width stays constant across the keyboard with no per-note scaling of an FM
+ signal. 0 keeps the oscillator vibrato-free.
+
+
+ Vibrato rate in Hz (0.05-20, default 5).
+
+
+ Vibrato onset time constant in milliseconds (0-5000, default 0): after
+ a note change the vibrato swells in over roughly this time — the singing-vibrato
+ gesture. The onset is re-armed on every note change; 0 is instant.
+
+
+ Pitch bend in semitones (-24..24, default 0) — the wheel. Bend rides
+ the smooth ramp, so bends glide at the smooth time rather than stepping.
+
Output gain in dB.
diff --git a/docs/tap.verb~.maxref.xml b/docs/tap.verb~.maxref.xml
index 3d66dc41..56452ec5 100644
--- a/docs/tap.verb~.maxref.xml
+++ b/docs/tap.verb~.maxref.xml
@@ -1,20 +1,27 @@
-
- Simulate reverberation
+ Stereo algorithmic reverb
- The tap.verb~ object is a simulated reverb.
- It can provide a reasonable reverb with minimal processing overhead which is great for real time applications.
- Also, all of the parameters are accessible and infinitely abuse-able which leads to all sorts of sonic mayhem. Enjoy.
+ The tap.verb~ object is a stereo Moorer-style algorithmic reverb: an 18-tap
+ early-reflection pattern feeds six parallel comb filters (each with a damping lowpass in
+ its feedback loop and an LFO gently chorusing its delay), summed through a Schroeder
+ allpass and an output lowpass, then crossfaded with the dry signal and gain-scaled. Two
+ independent cores (left and right), decorrelated by deviating every delay to a nearby
+ prime, give the stereo image. The deviation is deterministic per seed, so the same seed
+ reproduces the identical room on every instantiation and DSP restart. The output stage
+ offers a DC blocker, a look-ahead limiter (on by default), an optional hard clip, and
+ optional internal oversampling. It provides a reasonable reverb with minimal processing
+ overhead — and all of the parameters are accessible and infinitely abuse-able, which
+ leads to all sorts of sonic mayhem. Enjoy.
-
+
74 Objects
@@ -23,43 +30,120 @@
Effect
-
+
+
+
+
+ Left audio input.
+
+
+ Right audio input.
+
+
+
+
+
+
+
+ Left audio output.
+
+
+ Right audio output.
+
+
+
+
+
+
+
+
+ Clear the reverb's internal state (buffers, filters, and the limiter).
+
+
+
+
-
- Delay time in milliseconds. Relates to room size / how quickly echo density builds up in the reverberator [default: 100 ms].
+
+ Dry/wet mix from 0 (dry) to 100 (wet), as an equal-power crossfade
+ [default: 100].
+
+
+ Output gain in decibels [default: 0].
+
+
+ Base comb delay time in milliseconds. Relates to room size / how quickly
+ echo density builds up in the reverberator [default: 100].
+
+
+ Reverb decay time in seconds: how long the reverberant sound takes to
+ decay by 60 dB [default: 3.5].
-
- Decay time in seconds. How long (in seconds) for the reverberant sound to decay 60 dB [default: 3.5 sec].
+
+ Cutoff in Hz of the lowpass inside each comb's feedback loop. Emulates
+ the effect air and walls have on sound as it travels and reflects
+ [default: 20000].
-
- Damping filter cutoff in Hz. Emulates the effect air and walls have on sound as it travels and reflects [default: 20000 Hz].
+
+ Output lowpass cutoff in Hz, controlling the overall brightness of the
+ reverb [default: 15000].
-
- Modulation frequency in hertz - applied to the delay times internally [default: 0.1 Hz].
+
+ Comb-delay modulation frequency in Hz [default: 0.1].
-
- Modulation depth for the above delay-time modulator [default: 0.1]
+
+ Comb-delay modulation depth in milliseconds [default: 0.1].
-
- Cutoff frequency for a final lowpass filter stage to control the brightness of the reverb [default 15000 Hz].
+
+ Include the 18-tap early-reflection stage [default: 1 (on)].
-
- Toggles on/off early-reflections [default = 1 (on)].
+
+ Seed for the deviated comb-delay/decay tuning (1 or higher; default 1).
+ The room is deterministic per seed: the same seed reproduces the identical reverb on
+ every instantiation and DSP restart, and different seeds give slightly different
+ rooms. Setting it re-deviates immediately.
-
- Internal oversampling factor (1, 2, 4, or 8). When greater than 1 the reverb core runs at that multiple of the host sample rate, with antialiasing up/downsampling filters around it, for cleaner alias-suppressed feedback at the cost of CPU. A value of 1 bypasses the resampler so the sound is unchanged. Values outside the allowed set round down to the nearest factor [default: 1 (off)].
+
+ Apply a DC blocker to the output [default: 1 (on)].
+
+
+ Apply a stereo look-ahead limiter to the output [default: 1 (on)].
+
+
+ Limiter threshold in decibels [default: 0].
+
+
+ Limiter look-ahead in samples (1-255) [default: 100].
+
+
+ Limiter release time in milliseconds [default: 1000].
+
+
+ Hard-clip the output to +/-1 [default: 0 (off)].
+
+
+ Internal oversampling factor (1, 2, 4, or 8). When greater than 1 the
+ reverb core runs at that multiple of the host sample rate, with antialiasing
+ up/downsampling filters around it, for cleaner alias-suppressed feedback at the cost
+ of CPU. A value of 1 bypasses the resampler so the sound is unchanged. Values outside
+ the allowed set round down to the nearest factor [default: 1 (off)].
+
+
+ Pass the input through unprocessed [default: 0 (off)].
+
+
+ Silence the output [default: 0 (off)].
-
+
-
-
-
-
+
+
+
+
-
+
diff --git a/docs/tap.vocoder~.maxref.xml b/docs/tap.vocoder~.maxref.xml
index 596f8806..b2afaffd 100644
--- a/docs/tap.vocoder~.maxref.xml
+++ b/docs/tap.vocoder~.maxref.xml
@@ -56,13 +56,33 @@
- The Q factor (resonance) shared by all of the resonant bandpass filters. Higher values give narrower, more "robotic" bands. Default 20.
+ The Q factor (resonance) shared by all of the resonant bandpass filters
+ (0.5-200, default 20). Higher values give narrower, more "robotic" bands. A number,
+ not a symbol (the original object registered it as a symbol by mistake).
- The envelope-follower analysis period for the excitation stage, in milliseconds. Shorter values track the modulator more sharply; longer values smooth it. Default 20.
+ The envelope-follower analysis period for the excitation stage, in
+ milliseconds (0.1-1000, default 20). Shorter values track the modulator more sharply;
+ longer values smooth it. A number, not a symbol (the original object registered it as
+ a symbol by mistake).
- Linear makeup gain applied to the summed output. Default 1.
+ Linear makeup gain applied to the summed output (0-100, default 1).
+
+
+ Sibilance amount (0..1). Blends a deterministic seeded noise source into the carrier path of the bands above ~4 kHz, so unvoiced consonants come through while the modulator gates them. At 0 the output is identical to the noise-free vocoder. Default 0.
+
+
+ Wet/dry mix in percent (0..100), equal-power. The dry side is the carrier (the program material); 100 is fully vocoded, 0 passes the carrier through untouched. Default 100.
+
+
+ Seed for the sibilance noise source (>= 1). The noise is deterministic per seed; give mc. instances different seeds to decorrelate them. Default 1.
+
+
+ Pass the carrier through unprocessed. Default 0 (off).
+
+
+ Silence the output. Default 0 (off).
diff --git a/plans/recipes-improvements.md b/plans/recipes-improvements.md
index 1c82a2cc..7bbc8520 100644
--- a/plans/recipes-improvements.md
+++ b/plans/recipes-improvements.md
@@ -1,6 +1,9 @@
# Plan — improvements surfaced by the Recipes chapters
-> **Status: planned, not started.** This is the design-of-record for the object improvements
+> **Status: executed.** §1–§6 and §8 shipped 2026-08-05 (§1–§2 kernel-first with wrappers;
+> §3–§8 in the parallel wave — see per-section status notes); still open: the `tap.5comb~`
+> MIDI `notes` message, the verb `clear`/DC-blocker one-liner, refreshed help patchers, and
+> the on-Mac validation passes. 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
@@ -75,6 +78,10 @@ awareness.
## 2. `tap.vco~` — a performance-modulation section (vibrato, bend)
+> **Status: shipped 2026-08-05** — kernel `p_vibrato`/`p_vibrato_rate`/`p_vibrato_delay`/
+> `p_bend` with four pinned scenarios (depth-0 bit-identity held), wrapper attributes and
+> maxref, and the famous-patches chapter's formula retired.
+
**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~`.
@@ -88,6 +95,12 @@ Depth 0 must keep the bit-identical-ideal-by-default promise the vco test suite
## 3. `tap.ladder~` — symbolic `mode`/`solver` (wrapper bug-fix, plus an audit)
+> **Status: shipped 2026-08-05** (ladder + diode; both spellings accepted, getters report
+> symbols; maxrefs + wrapper tests updated). Finding recorded: min-api's native enum
+> attributes store/report the *index*, and `attribute` is banned per REVIVAL
+> §9.5 — so the house pattern for symbolic enums is `attribute` + `range{}` with a
+> numeric-atom fallback in the setter.
+
**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
@@ -98,6 +111,10 @@ for the same pattern (`tap.diode~` is the likely sibling), and update the Part I
## 4. Determinism sweep — `tap.noise~` and `tap.verb~`
+> **Status: shipped 2026-08-05.** noise: `seed` with seed 1 bit-identical to the legacy
+> default sequence (pinned). verb: `deviate()` on the house LCG with a `seed` attribute;
+> right core offset-seeded (+0x9E3779B9) to preserve stereo decorrelation.
+
Two objects violate the "a seed is a serial number" doctrine:
- **`tap.noise~`** seeds from `std::random_device` — renders don't reproduce. Add `seed`
@@ -110,6 +127,13 @@ Two objects violate the "a seed is a serial number" doctrine:
## 5. `tap.delay~` / `tap.multitap~` — rebuild behind the same names (decision)
+> **Status: shipped 2026-08-05** — kernel `taptools/delay.h` (`line` + `multitap`, Hermite
+> taps cited to Laakso et al. 1996, DC-blocked feedback, exact-endpoint mix/pan, `interp 0`
+> verified bit-for-bit against the Jamoma-era `tt_delay.h` truncation) with six pinned
+> scenarios + capi/bridge; wrappers rebuilt (multitap now stereo out with per-tap pan; the
+> 0.0-signal trap is dead — a connected time signal always wins, documented breaking
+> change). Help patchers still show the old surfaces — flagged for the on-Mac pass.
+
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
@@ -136,6 +160,11 @@ including at zero. Wrapper defaults otherwise preserved.
## 6. `tap.vocoder~` — the missing conveniences
+> **Status: shipped 2026-08-05** — kernel `sibilance` (seeded Dudley-lineage unvoiced path
+> in the >4 kHz bands, silent-carrier contract intact at the default 0, pinned) and
+> equal-power carrier-dry `mix` with exact endpoints; wrapper adds both plus `seed`,
+> `bypass`, `mute`.
+
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
@@ -151,12 +180,16 @@ object has them). Low urgency, documented workarounds; batch with any vocoder re
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
+- **`tap.sustain~` `length`** — **shipped 2026-08-05**: the setter stores the value
+ truthfully and the ring re-sizes at the next `dspsetup`, documented in attribute and
+ maxref.
+- **`tap.crossfade~` `mode`** — **deprecated in the maxref 2026-08-05** (verified no-op;
+ code untouched for compatibility).
+- **`tap.harmony~` v1.1 — the house machinery.** **Shipped 2026-08-05**: kernel 16-slot
+ preset morph (params snapshot + timed recall via target ramps over the existing slews, so
+ every prior contract holds by construction; formant snaps at recall start), wrapper
+ `store`/`recall [ms]` + `interp` + `bypass`/`mute`. Original item text follows for the
+ record. 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
@@ -165,6 +198,10 @@ object has them). Low urgency, documented workarounds; batch with any vocoder re
## 8. Documentation debts (no DSP)
+> **Status: shipped 2026-08-05** — verb/vocoder/sustain/noise/delay/multitap maxrefs
+> rewritten or corrected (adsr was fixed earlier); the book's `@mode 0` workarounds are
+> restored to `@mode lp24` in the kernel repo alongside §3.
+
- 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
@@ -208,3 +245,7 @@ object has them). Low urgency, documented workarounds; batch with any vocoder re
| 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 |
+| 2026-08-05 | §4 sweep tests | `tap.verb~` `clear` resets cores + limiter but NOT the DC-blocker state (`m_dc_l/m_dc_r`) — pre-existing, one line | open, unscheduled |
+| 2026-08-05 | §3 implementation | min-api native enum attributes report indices; `attribute` banned (REVIVAL §9.5) → house symbolic-enum pattern is `attribute` + numeric fallback | recorded in §3 |
+| 2026-08-05 | wrapper wave | delay/multitap/vocoder/harmony help patchers still show pre-wave surfaces (JSON graphs, non-trivial edit) | on-Mac pass |
+| 2026-08-05 | (still open) | `tap.5comb~` MIDI `notes` message (§7) — not covered by the wave | open |
diff --git a/source/projects/tap.delay_tilde/CMakeLists.txt b/source/projects/tap.delay_tilde/CMakeLists.txt
index 8d1fc4cf..627faee9 100644
--- a/source/projects/tap.delay_tilde/CMakeLists.txt
+++ b/source/projects/tap.delay_tilde/CMakeLists.txt
@@ -1,4 +1,4 @@
-# Copyright 2008-2026 Timothy Place. Distributed under the New BSD License.
+# Copyright 1999-2026 Timothy Place. Distributed under the New BSD License.
cmake_minimum_required(VERSION 3.19)
@@ -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/delay.h
)
add_library(
diff --git a/source/projects/tap.delay_tilde/tap.delay_tilde.cpp b/source/projects/tap.delay_tilde/tap.delay_tilde.cpp
index 8ca6a94c..1b6b3295 100644
--- a/source/projects/tap.delay_tilde/tap.delay_tilde.cpp
+++ b/source/projects/tap.delay_tilde/tap.delay_tilde.cpp
@@ -1,36 +1,54 @@
/// @file
-/// @brief tap.delay~ — a simple sample-accurate audio delay line.
-/// @details A single-channel audio delay. The input signal is written into a circular buffer and
-/// read back a number of milliseconds later. The delay time (ms) is set via the right
-/// inlet or the @delay attribute. The buffer is sized at instantiation by the optional
-/// buffersize argument (ms), which also bounds the maximum usable delay.
+/// @brief tap.delay~ — a feedback delay line on the portable delay kernel.
+/// @details A single-channel delay rebuilt on tap::tools::delay::line (taptools/delay.h) — the
+/// kernel-first rebuild of the 1999-lineage object (improvements plan §5, decision:
+/// rebuild behind the same name). The input is written into a circular buffer sized at
+/// instantiation by the optional buffersize argument (ms, applied when the DSP chain
+/// starts) and read back @delay milliseconds later through a 4-point Hermite
+/// fractional read (@interp 1, the default) or the legacy bit-compatible integer-sample
+/// truncation (@interp 0). A @feedback path (DC-blocked, capped at 0.99) and an
+/// equal-power @mix (default 100 = wet-only, matching the old object's output) ride
+/// per-sample kernel slews, so parameter moves are click-free.
///
-/// This object was RECONSTRUCTED from its surviving maxref documentation
-/// (docs/tap.delay~.maxref.xml) and help/abstraction patchers — the original C++ source
-/// was lost. The documented behavior is intentionally minimal ("A simple audio delay"),
-/// so the implementation mirrors the established circular-buffer idiom used by
-/// tap.multitap~. DSP is plain portable C++; Min is used only for the Max plumbing. For
+/// BREAKING CHANGE (by design): a signal connected to the right inlet now *always*
+/// drives the delay time, including at 0.0. The old wrapper treated a 0.0 signal as
+/// "use the @delay attribute", which made a genuine zero-time modulation unreachable
+/// and turned silence into a mode switch — that trap dies here. With no signal
+/// connected, the right inlet takes floats (or the @delay attribute), slewed by the
+/// kernel. Note the kernel's structural floor: a zero-time tap is unreachable (1
+/// sample in truncation mode, 2.5 samples Hermite — see delay.h "Honest limits").
+///
+/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For
/// multichannel use, wrap the object in an mc. operator.
/// @author Timothy Place
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 1999-2026 Timothy Place.
#include
-#include
+
+#include
#include "c74_min.h"
using namespace c74::min;
+namespace kernel = tap::tools::delay;
class delay : public object, public sample_operator<2, 1> {
+ private:
+ // Constructed before the attributes below so their defaults can forward into it.
+ kernel::line m_line;
+
public:
- MIN_DESCRIPTION{"A simple sample-accurate audio delay. The input signal is delayed by a number "
- "of milliseconds set via the right inlet or the @delay attribute. The optional "
- "argument sets the size (ms) of the delay buffer, which bounds the maximum "
- "delay. For multichannel signals, wrap this object in an mc. operator."};
+ MIN_DESCRIPTION{"A feedback delay line. The input is delayed by a number of milliseconds set "
+ "via the right inlet (signal or float) or the delay attribute, with feedback, "
+ "an equal-power dry/wet mix, and a choice of fractional (Hermite) or legacy "
+ "integer-sample interpolation. A signal in the right inlet always drives the "
+ "delay time, including at 0.0. The optional argument sets the size (ms) of the "
+ "delay buffer, which bounds the maximum delay. For multichannel signals, wrap "
+ "this object in an mc. operator."};
MIN_TAGS{"delays"};
MIN_AUTHOR{"Timothy Place"};
- MIN_RELATED{"delay~, tapin~, tapout~, tap.multitap~"};
+ MIN_RELATED{"delay~, tapin~, tapout~, tap.multitap~, tap.comb~"};
inlet<> m_in{this, "(signal) audio input"};
inlet<> m_in_dt{this, "(signal/float) delay time in milliseconds"};
@@ -38,26 +56,61 @@ class delay : public object, public sample_operator<2, 1> {
attribute buffersize{
this, "buffersize", 1000.0,
- description{"Size of the delay buffer in milliseconds (fixed at instantiation). This sets "
- "the maximum possible delay time."}};
+ description{"Size of the delay buffer in milliseconds (set by the first object argument; "
+ "applied when the DSP chain starts). This bounds the maximum delay time."}};
attribute delaytime{this, "delay", 0.0, setter{MIN_FUNCTION{
- m_delay_ms = std::max(0.0, static_cast(args[0]));
- update_delay();
- return {m_delay_ms};
+ const double v = std::max(0.0, static_cast(args[0]));
+ m_line.set_time_ms(v);
+ return {v};
}},
- description{"Delay time in milliseconds. Clamped to the buffer size."}};
+ description{"Delay time in milliseconds, slewed by the kernel (click-free). Clamped "
+ "to the buffer size. Ignored while a signal is connected to the right "
+ "inlet — the signal always wins, including at 0.0."}};
+
+ attribute feedback{this, "feedback", 0.0, setter{MIN_FUNCTION{
+ const double v = std::clamp(static_cast(args[0]), 0.0, kernel::k_fb_max);
+ m_line.set_feedback(v);
+ return {v};
+ }},
+ description{"Feedback amount (0..0.99). The feedback path is DC-blocked and capped "
+ "so the loop always decays."}};
+
+ attribute mix{this, "mix", 100.0, setter{MIN_FUNCTION{
+ const double v = std::clamp(static_cast(args[0]), 0.0, 100.0);
+ m_line.set_mix(v);
+ return {v};
+ }},
+ description{"Dry/wet mix in percent (0..100), equal-power. 0 is bitwise dry, 100 (the "
+ "default, matching the old wet-only object) bitwise wet."}};
+
+ attribute interp{this, "interp", kernel::interp_hermite, setter{MIN_FUNCTION{
+ const int v = (static_cast(args[0]) == kernel::interp_trunc)
+ ? kernel::interp_trunc
+ : kernel::interp_hermite;
+ m_line.set_interp(v);
+ return {v};
+ }},
+ description{"Interpolation mode: 1 (default) reads fractional delays with a 4-point "
+ "Hermite; 0 restores the legacy bit-compatible integer-sample truncation."}};
+
+ message<> m_number{this, "number", "A float in the right inlet sets the delay time in milliseconds.",
+ MIN_FUNCTION{
+ if (inlet == 1) {
+ delaytime = args[0];
+ }
+ return {};
+ }};
- message<> clear{this, "clear", "Clear the contents of the delay buffer.",
+ message<> clear{this, "clear", "Clear the delay buffer and the feedback-path filter state.",
MIN_FUNCTION{
- std::fill(m_buffer.begin(), m_buffer.end(), 0.0);
+ m_line.clear();
return {};
}};
- message<> dspsetup{this, "dspsetup", "Allocate and recompute when the DSP chain starts.",
+ message<> dspsetup{this, "dspsetup", "Re-prepare for the sample rate and buffer size when the DSP chain starts.",
MIN_FUNCTION{
- allocate();
- update_delay();
+ m_line.prepare(samplerate(), buffersize);
return {};
}};
@@ -65,61 +118,14 @@ class delay : public object, public sample_operator<2, 1> {
if (!args.empty() && static_cast(args[0]) > 0.0) {
buffersize = args[0];
}
- allocate();
+ m_line.prepare(samplerate(), buffersize);
}
sample operator()(sample x, sample dt) {
- const long N = static_cast(m_buffer.size());
- if (N < 1) {
- return 0.0;
- }
-
- // A non-zero delay-time signal on the right inlet overrides the attribute value
- // sample-by-sample (mirroring delay~). 0.0 means "use the attribute".
- long delay_samples = m_delay_samples;
- if (dt != 0.0) {
- const double sr = samplerate();
- delay_samples = std::clamp(static_cast(static_cast(dt) * (sr * 0.001)), 0L, N - 1);
- }
-
- m_buffer[m_write] = x;
-
- long read = m_write - delay_samples;
- read %= N;
- if (read < 0) {
- read += N;
- }
- const sample out = m_buffer[read];
-
- if (++m_write >= N) {
- m_write = 0;
+ if (m_in_dt.has_signal_connection()) {
+ return m_line.process(x, dt); // the time signal always wins, including at 0.0
}
-
- return out;
- }
-
- private:
- std::vector m_buffer;
- long m_write{0};
- double m_delay_ms{0.0};
- long m_delay_samples{0};
-
- void allocate() {
- const double sr = samplerate();
- long n = static_cast(static_cast(buffersize) * (sr * 0.001));
- if (n < 1) {
- n = 1;
- }
- if (static_cast(m_buffer.size()) != n) {
- m_buffer.assign(n, 0.0);
- m_write = 0;
- }
- }
-
- void update_delay() {
- const double sr = samplerate();
- const long N = std::max(1L, static_cast(m_buffer.size()));
- m_delay_samples = std::clamp(static_cast(m_delay_ms * (sr * 0.001)), 0L, N - 1);
+ return m_line.process(x);
}
};
diff --git a/source/projects/tap.delay_tilde/tap.delay_tilde_test.cpp b/source/projects/tap.delay_tilde/tap.delay_tilde_test.cpp
index 1ebcdbb0..fa9fa2e2 100644
--- a/source/projects/tap.delay_tilde/tap.delay_tilde_test.cpp
+++ b/source/projects/tap.delay_tilde/tap.delay_tilde_test.cpp
@@ -1,8 +1,15 @@
/// @file
-/// @brief Unit tests for tap.delay~.
+/// @brief Unit tests for tap.delay~ (the kernel-backed rebuild).
+/// @details The kernel slews every parameter over 20 ms by default, so each scenario settles the
+/// ramps (~882 samples at the mock 44.1 kHz) before measuring. The signal-rate
+/// time-override path branches on `inlet::has_signal_connection()`, which the mock
+/// harness can only present as *unconnected* (see tap.autothru~'s test note) — so the
+/// "a time signal always wins, including at 0.0" contract needs a real patch cord and
+/// belongs in runtime-tests/. What is pinned here is the message-rate surface.
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 1999-2026 Timothy Place.
+#include
#include
#include "c74_min_unittest.h" // required unit-test header (defines main via Catch)
@@ -12,28 +19,20 @@ namespace {
constexpr double k_mock_sr = 44100.0; // the mock kernel's sys_getsr()
- // Feed an impulse followed by silence and return the whole output.
- // dt is the per-sample delay-time signal on the right inlet (0 = "use the attribute").
- std::vector impulse_response(delay& object, size_t n, double dt) {
- std::vector out(n, 0.0);
+ // Run silence through the object so the kernel's parameter ramps reach their targets.
+ void settle(delay& object, size_t n = 2048) {
for (size_t i = 0; i < n; ++i) {
- out[i] = object(i == 0 ? 1.0 : 0.0, dt);
+ object(0.0, 0.0);
}
- return out;
}
- // Index of the single non-zero sample, or -1 if there is not exactly one.
- long sole_tap(const std::vector& x) {
- long found = -1;
- for (size_t i = 0; i < x.size(); ++i) {
- if (x[i] != 0.0) {
- if (found >= 0) {
- return -1; // more than one
- }
- found = static_cast(i);
- }
+ // Feed an impulse followed by silence and return the whole output.
+ std::vector impulse_response(delay& object, size_t n) {
+ std::vector out(n, 0.0);
+ for (size_t i = 0; i < n; ++i) {
+ out[i] = object(i == 0 ? 1.0 : 0.0, 0.0);
}
- return found;
+ return out;
}
} // namespace
@@ -45,81 +44,133 @@ SCENARIO("tap.delay~ instantiates with the documented defaults") {
test_wrapper an_instance;
delay& my_object = an_instance;
- THEN("the buffer is 1000 ms and the delay is zero") {
+ THEN("buffer 1000 ms, delay 0, feedback 0, mix 100 (wet-only), Hermite interpolation") {
REQUIRE(static_cast(my_object.buffersize) == 1000.0);
REQUIRE(static_cast(my_object.delaytime) == 0.0);
+ REQUIRE(static_cast(my_object.feedback) == 0.0);
+ REQUIRE(static_cast(my_object.mix) == 100.0);
+ REQUIRE(static_cast(my_object.interp) == 1);
}
}
}
-SCENARIO("tap.delay~ passes the signal straight through at zero delay") {
+SCENARIO("tap.delay~ delays by the attribute value") {
ext_main(nullptr);
- GIVEN("a default instance") {
+ GIVEN("a 10 ms delay, ramps settled") {
+ test_wrapper an_instance;
+ delay& my_object = an_instance;
+ my_object.delaytime = 10.0;
+ settle(my_object);
+
+ WHEN("an impulse is processed") {
+ const std::vector out = impulse_response(my_object, 2048);
+
+ THEN("the tap comes out at unity, 441 samples later (a whole-sample delay reads exactly)") {
+ REQUIRE(out[static_cast(10.0 * k_mock_sr * 0.001)] == 1.0);
+ }
+ }
+ }
+
+ GIVEN("the same delay in legacy truncation mode") {
test_wrapper an_instance;
delay& my_object = an_instance;
+ my_object.interp = 0;
+ my_object.delaytime = 10.0;
+ settle(my_object);
- THEN("each sample comes out on the sample it went in") {
- REQUIRE(my_object(0.5, 0.0) == 0.5);
- REQUIRE(my_object(-0.25, 0.0) == -0.25);
- REQUIRE(my_object(1.0, 0.0) == 1.0);
+ WHEN("an impulse is processed") {
+ const std::vector out = impulse_response(my_object, 2048);
+
+ THEN("the tap lands at long(ms * sr / 1000) samples, exactly as the old object") {
+ REQUIRE(out[441] == 1.0);
+ }
}
}
}
-SCENARIO("tap.delay~ delays by the attribute value") {
+SCENARIO("tap.delay~ recirculates through the feedback path") {
ext_main(nullptr);
- GIVEN("a 10 ms delay") {
+ GIVEN("a 10 ms delay with 0.5 feedback, ramps settled") {
test_wrapper an_instance;
delay& my_object = an_instance;
my_object.delaytime = 10.0;
+ my_object.feedback = 0.5;
+ settle(my_object);
WHEN("an impulse is processed") {
- const std::vector out = impulse_response(my_object, 2048, 0.0);
+ const std::vector out = impulse_response(my_object, 4096);
- THEN("exactly one tap comes out, 441 samples later") {
- REQUIRE(sole_tap(out) == static_cast(10.0 * k_mock_sr * 0.001));
+ THEN("the echoes decay by ~the feedback coefficient (the loop DC blocker shaves a hair)") {
REQUIRE(out[441] == 1.0);
+ REQUIRE(std::abs(out[882] - 0.5) < 0.01);
+ REQUIRE(std::abs(out[1323] - 0.25) < 0.01);
}
}
}
}
-SCENARIO("tap.delay~ clamps the delay time to the documented bounds") {
+SCENARIO("tap.delay~ honours the equal-power mix endpoints") {
+ ext_main(nullptr);
+
+ GIVEN("mix 0, ramps settled") {
+ test_wrapper an_instance;
+ delay& my_object = an_instance;
+ my_object.delaytime = 10.0;
+ my_object.mix = 0.0;
+ settle(my_object);
+
+ THEN("the input passes through bitwise dry") {
+ REQUIRE(my_object(0.5, 0.0) == 0.5);
+ REQUIRE(my_object(-0.25, 0.0) == -0.25);
+ }
+ }
+}
+
+SCENARIO("tap.delay~ clamps its attributes to the documented ranges") {
ext_main(nullptr);
GIVEN("a default instance") {
test_wrapper an_instance;
delay& my_object = an_instance;
- THEN("a negative delay is floored at zero and echoed back") {
+ THEN("a negative delay is floored at zero") {
my_object.delaytime = -50.0;
REQUIRE(static_cast(my_object.delaytime) == 0.0);
}
- THEN("a delay past the buffer size is accepted but bounded by the buffer") {
- my_object.delaytime = 5000.0; // buffer is 1000 ms
- REQUIRE(static_cast(my_object.delaytime) == 5000.0);
- const std::vector out = impulse_response(my_object, 44100, 0.0);
- // Clamped to buffersize - 1 samples, so the tap lands at the very end of the buffer.
- REQUIRE(sole_tap(out) == static_cast(1000.0 * k_mock_sr * 0.001) - 1);
+ THEN("feedback is capped at 0.99") {
+ my_object.feedback = 2.0;
+ REQUIRE(static_cast(my_object.feedback) == 0.99);
+ my_object.feedback = -1.0;
+ REQUIRE(static_cast(my_object.feedback) == 0.0);
+ }
+ THEN("mix is clamped to 0..100") {
+ my_object.mix = 150.0;
+ REQUIRE(static_cast(my_object.mix) == 100.0);
+ my_object.mix = -10.0;
+ REQUIRE(static_cast(my_object.mix) == 0.0);
+ }
+ THEN("interp collapses to 0 or 1") {
+ my_object.interp = 7;
+ REQUIRE(static_cast(my_object.interp) == 1);
+ my_object.interp = 0;
+ REQUIRE(static_cast(my_object.interp) == 0);
}
}
-}
-
-SCENARIO("tap.delay~ lets a non-zero delay-time signal override the attribute") {
- ext_main(nullptr);
- GIVEN("an attribute delay of 10 ms") {
+ GIVEN("a delay past the buffer size, ramps settled") {
test_wrapper an_instance;
delay& my_object = an_instance;
- my_object.delaytime = 10.0;
+ my_object.interp = 0;
+ my_object.delaytime = 5000.0; // buffer is 1000 ms
+ settle(my_object, 4096);
- WHEN("a 20 ms delay-time signal is present on the right inlet") {
- const std::vector out = impulse_response(my_object, 4096, 20.0);
+ WHEN("an impulse is processed") {
+ const std::vector out = impulse_response(my_object, 46000);
- THEN("the signal wins — the tap lands at 882 samples, not 441") {
- REQUIRE(sole_tap(out) == static_cast(20.0 * k_mock_sr * 0.001));
+ THEN("the tap is bounded by the buffer instead of wrapping") {
+ REQUIRE(out[static_cast(1000.0 * k_mock_sr * 0.001)] == 1.0);
}
}
}
@@ -132,6 +183,7 @@ SCENARIO("tap.delay~ clears its buffer on demand") {
test_wrapper an_instance;
delay& my_object = an_instance;
my_object.delaytime = 10.0;
+ settle(my_object);
my_object(1.0, 0.0); // write the impulse
for (int i = 0; i < 100; ++i) {
my_object(0.0, 0.0);
diff --git a/source/projects/tap.diode_tilde/tap.diode_tilde.cpp b/source/projects/tap.diode_tilde/tap.diode_tilde.cpp
index d525d60f..bc0ddef4 100644
--- a/source/projects/tap.diode_tilde/tap.diode_tilde.cpp
+++ b/source/projects/tap.diode_tilde/tap.diode_tilde.cpp
@@ -14,6 +14,8 @@
/// @author Timothy Place
/// @copyright Copyright 2026 Timothy Place. Distributed under the New BSD License.
+#include
+
#include
#include "c74_min.h"
@@ -26,6 +28,24 @@ class diode : public object, public vector_operator<> {
// Constructed before the attributes below so attribute defaults can forward into it.
kernel::diode_filter m_filter;
+ // Map a symbol-or-number atom to an enum index: a symbol matches the name table, a number
+ // clamps to the valid index range — so '@solver exact' and '@solver 1' are equivalent
+ // spellings.
+ template
+ static int index_from_atom(const atom& a, const std::array& names) {
+ if (a.type() == message_type::symbol_argument) {
+ for (size_t i = 0; i < N; ++i) {
+ if (a == names[i]) {
+ return static_cast(i);
+ }
+ }
+ return 0; // unknown symbol: fall back to the default entry
+ }
+ return std::clamp(static_cast(a), 0, static_cast(N) - 1);
+ }
+
+ static constexpr std::array k_solver_names{"fast", "exact"};
+
public:
MIN_DESCRIPTION{"A virtual-analog diode-ladder filter — the TB-303 lowpass topology. Unlike "
"the transistor ladder (tap.ladder~), the diode ladder's stages load each "
@@ -79,15 +99,25 @@ class diode : public object, public vector_operator<> {
#undef TAP_DIODE_ATTR
- attribute solver{this, "solver", kernel::solver_fast, setter{ MIN_FUNCTION {
- const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_solvers - 1);
- m_filter.set_solver(v);
- return {v};
- }},
- description{"Nonlinear solver for the coupled diode equations: 0 = fast (secant-"
- "linearized solve with one corrective pass — the default), 1 = exact "
- "(re-linearization iterated to convergence). The two are audibly identical "
- "until drive and resonance are both pushed hard; exact costs more CPU."}};
+ // Symbolic enum stored as attribute with a numeric-compatible setter — the house
+ // pattern from tap.svf~ (type/circuit), extended: '@solver exact' and '@solver 1' are both
+ // accepted, and the getter reports the symbol. Not attribute (min-api's native
+ // enum_map pattern), which stores/reports the index and trips the tap.crossfade~/tap.pan~
+ // GCC lesson (REVIVAL.md 9.5).
+ attribute solver{this,
+ "solver",
+ "fast",
+ range{"fast", "exact"},
+ setter{ MIN_FUNCTION {
+ const int v = index_from_atom(args[0], k_solver_names);
+ m_filter.set_solver(v);
+ return {symbol(k_solver_names[v])};
+ }},
+ description{"Nonlinear solver for the coupled diode equations: fast (0) is a secant-"
+ "linearized solve with one corrective pass — the default; exact (1) is "
+ "re-linearization iterated to convergence. The two are audibly identical "
+ "until drive and resonance are both pushed hard; exact costs more CPU. "
+ "Accepts the symbol or the index."}};
attribute oversample{this, "oversample", 2, setter{ MIN_FUNCTION {
const int req = static_cast(args[0]);
diff --git a/source/projects/tap.diode_tilde/tap.diode_tilde_test.cpp b/source/projects/tap.diode_tilde/tap.diode_tilde_test.cpp
index 9f45f58a..935a0db9 100644
--- a/source/projects/tap.diode_tilde/tap.diode_tilde_test.cpp
+++ b/source/projects/tap.diode_tilde/tap.diode_tilde_test.cpp
@@ -60,7 +60,7 @@ SCENARIO("the Min wrapper instantiates with the documented defaults") {
REQUIRE(static_cast(my_object.drive) == 0.0);
REQUIRE(static_cast(my_object.fbhp) == kdf::k_fbhp_default_hz);
REQUIRE(static_cast(my_object.gain) == 0.0);
- REQUIRE(static_cast(my_object.solver) == kdf::solver_fast);
+ REQUIRE(my_object.solver == symbol{"fast"});
REQUIRE(static_cast(my_object.oversample) == 2);
REQUIRE(static_cast(my_object.smooth) == kdf::k_default_smooth_ms);
}
@@ -79,8 +79,14 @@ SCENARIO("the Min wrapper instantiates with the documented defaults") {
REQUIRE(static_cast(my_object.drive) == kdf::k_drive_range_db);
my_object.oversample = 3;
REQUIRE(static_cast(my_object.oversample) == 2);
- my_object.solver = 99;
- REQUIRE(static_cast(my_object.solver) == kdf::k_num_solvers - 1);
+ my_object.solver = atoms{99};
+ REQUIRE(my_object.solver == symbol{"exact"}); // clamps to the last solver
+ }
+ THEN("solver accepts both symbolic and numeric spellings and reports the symbol") {
+ my_object.solver = symbol("exact");
+ REQUIRE(my_object.solver == symbol{"exact"});
+ my_object.solver = atoms{kdf::solver_fast};
+ REQUIRE(my_object.solver == symbol{"fast"});
}
THEN("attribute changes reach the kernel") {
my_object.resonance = 0.75;
diff --git a/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp b/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp
index 5954fbf0..b1202708 100644
--- a/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp
+++ b/source/projects/tap.harmony_tilde/tap.harmony_tilde.cpp
@@ -12,6 +12,12 @@
/// 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.
+///
+/// Sixteen preset slots (the vco.h house pattern): 'store ' snapshots the
+/// kernel's parameter targets, 'recall [ms]' morphs every continuous
+/// parameter there over the given time (default: the interp attribute). Note a
+/// DSP restart re-applies the attribute values, so a recalled preset holds until
+/// the next dspsetup. bypass and mute are wrapper-side, per the house idiom.
/// @author Timothy Place
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 2026 Timothy Place.
@@ -120,6 +126,16 @@ class harmony : public object, public sample_operator<1, 1> {
description{"FFT frame size in samples (256..4096, rounded up to a power "
"of two). This is the latency. Applies when DSP restarts."}};
+ attribute interp{this, "interp", 500.0, setter{MIN_FUNCTION{
+ return {std::max(0.0, static_cast(args[0]))};
+ }},
+ description{"Default preset-morph time in ms used by the 'recall' message."}};
+
+ attribute bypass{this, "bypass", false,
+ description{"Pass the input through unprocessed (no harmonizer latency)."}};
+
+ attribute mute{this, "mute", false, description{"Silence the output."}};
+
/// 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.
@@ -139,6 +155,26 @@ class harmony : public object, public sample_operator<1, 1> {
return {};
}};
+ message<> store{this, "store", "Store the current parameters in a preset slot (1..16).",
+ MIN_FUNCTION{
+ if (!args.empty()) {
+ m_engine.store_preset(static_cast(args[0]) - 1);
+ }
+ return {};
+ }};
+
+ message<> recall{this, "recall",
+ "Morph all parameters to a stored preset (1..16). An optional second argument overrides "
+ "the morph time in ms (default: the interp attribute).",
+ MIN_FUNCTION{
+ if (!args.empty()) {
+ const double ms =
+ (args.size() > 1) ? static_cast(args[1]) : static_cast(interp);
+ m_engine.recall_preset(static_cast(args[0]) - 1, ms * 0.001);
+ }
+ return {};
+ }};
+
message<> clear{this, "clear", "Zero all running state (delay lines, phases, slews).",
MIN_FUNCTION{
m_engine.clear();
@@ -163,7 +199,15 @@ class harmony : public object, public sample_operator<1, 1> {
return {};
}};
- sample operator()(sample x) { return m_engine.process(x); }
+ sample operator()(sample x) {
+ if (mute) {
+ return 0.0;
+ }
+ if (bypass) {
+ return x; // raw input, no harmonizer latency
+ }
+ return m_engine.process(x);
+ }
};
MIN_EXTERNAL(harmony);
diff --git a/source/projects/tap.ladder_tilde/tap.ladder_tilde.cpp b/source/projects/tap.ladder_tilde/tap.ladder_tilde.cpp
index 2473ce06..d7aeaef3 100644
--- a/source/projects/tap.ladder_tilde/tap.ladder_tilde.cpp
+++ b/source/projects/tap.ladder_tilde/tap.ladder_tilde.cpp
@@ -12,6 +12,8 @@
/// @author Timothy Place
/// @copyright Copyright 2026 Timothy Place. Distributed under the New BSD License.
+#include
+
#include
#include "c74_min.h"
@@ -24,6 +26,25 @@ class ladder : public object, public vector_operator<> {
// Constructed before the attributes below so attribute defaults can forward into it.
kernel::ladder_filter m_filter;
+ // Map a symbol-or-number atom to an enum index: a symbol matches the name table, a number
+ // clamps to the valid index range — so '@mode lp24' and '@mode 0' are equivalent spellings.
+ template
+ static int index_from_atom(const atom& a, const std::array& names) {
+ if (a.type() == message_type::symbol_argument) {
+ for (size_t i = 0; i < N; ++i) {
+ if (a == names[i]) {
+ return static_cast(i);
+ }
+ }
+ return 0; // unknown symbol: fall back to the default entry
+ }
+ return std::clamp(static_cast(a), 0, static_cast(N) - 1);
+ }
+
+ static constexpr std::array k_solver_names{"fast", "exact"};
+ static constexpr std::array k_mode_names{"lp24", "lp12", "bp12",
+ "bp24", "hp12", "hp24"};
+
public:
MIN_DESCRIPTION{"A virtual-analog transistor-ladder filter: a zero-delay-feedback 4-pole "
"ladder with tanh saturation in every stage. The tuning is prewarped and "
@@ -73,25 +94,37 @@ class ladder : public object, public vector_operator<> {
#undef TAP_LADDER_ATTR
- attribute solver{this, "solver", kernel::solver_fast, setter{ MIN_FUNCTION {
- const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_solvers - 1);
- m_filter.set_solver(v);
- return {v};
- }},
- description{"Nonlinear feedback solver: 0 = fast (linear zero-delay prediction plus one "
- "corrective saturation pass — the default), 1 = exact (Newton iteration to "
- "convergence, circuit-simulation accuracy). The two are audibly identical "
- "until drive and resonance are both pushed hard; exact costs more CPU."}};
-
- // Mode as an int attribute with named index constants — not attribute, per the
- // tap.crossfade~/tap.pan~ GCC lesson (REVIVAL.md 9.5).
- attribute mode{this, "mode", kernel::mode_lp24, setter{ MIN_FUNCTION {
- const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_modes - 1);
- m_filter.set_mode(v);
- return {v};
- }},
- description{"Filter response via pole mixing: 0 = lp24, 1 = lp12, 2 = bp12, 3 = bp24, "
- "4 = hp12, 5 = hp24."}};
+ // Symbolic enums stored as attribute with a numeric-compatible setter — the house
+ // pattern from tap.svf~ (type/circuit), extended: '@solver exact' and '@solver 1' are both
+ // accepted, and the getter reports the symbol. Not attribute (min-api's native
+ // enum_map pattern), which stores/reports the index and trips the tap.crossfade~/tap.pan~
+ // GCC lesson (REVIVAL.md 9.5).
+ attribute solver{this,
+ "solver",
+ "fast",
+ range{"fast", "exact"},
+ setter{ MIN_FUNCTION {
+ const int v = index_from_atom(args[0], k_solver_names);
+ m_filter.set_solver(v);
+ return {symbol(k_solver_names[v])};
+ }},
+ description{"Nonlinear feedback solver: fast (0) is a linear zero-delay prediction plus "
+ "one corrective saturation pass — the default; exact (1) is Newton iteration "
+ "to convergence, circuit-simulation accuracy. The two are audibly identical "
+ "until drive and resonance are both pushed hard; exact costs more CPU. "
+ "Accepts the symbol or the index."}};
+
+ attribute mode{this,
+ "mode",
+ "lp24",
+ range{"lp24", "lp12", "bp12", "bp24", "hp12", "hp24"},
+ setter{ MIN_FUNCTION {
+ const int v = index_from_atom(args[0], k_mode_names);
+ m_filter.set_mode(v);
+ return {symbol(k_mode_names[v])};
+ }},
+ description{"Filter response via pole mixing: lp24 (0), lp12 (1), bp12 (2), bp24 (3), "
+ "hp12 (4), hp24 (5). Accepts the symbol or the index."}};
attribute oversample{this, "oversample", 2, setter{ MIN_FUNCTION {
const int req = static_cast(args[0]);
diff --git a/source/projects/tap.ladder_tilde/tap.ladder_tilde_test.cpp b/source/projects/tap.ladder_tilde/tap.ladder_tilde_test.cpp
index 981f7e74..8f00bf9c 100644
--- a/source/projects/tap.ladder_tilde/tap.ladder_tilde_test.cpp
+++ b/source/projects/tap.ladder_tilde/tap.ladder_tilde_test.cpp
@@ -334,17 +334,27 @@ SCENARIO("the Min wrapper instantiates with the documented defaults") {
REQUIRE(static_cast(my_object.resonance) == 0.0);
REQUIRE(static_cast(my_object.drive) == 0.0);
REQUIRE(static_cast(my_object.comp) == 0.0);
- REQUIRE(static_cast(my_object.mode) == klf::mode_lp24);
+ REQUIRE(my_object.mode == symbol{"lp24"});
REQUIRE(static_cast(my_object.oversample) == 2);
}
THEN("out-of-range values are clamped") {
my_object.resonance = 2.0;
REQUIRE(static_cast(my_object.resonance) == klf::k_res_max);
- my_object.mode = 99;
- REQUIRE(static_cast(my_object.mode) == klf::k_num_modes - 1);
+ my_object.mode = atoms{99};
+ REQUIRE(my_object.mode == symbol{"hp24"}); // clamps to the last mode
my_object.oversample = 3;
REQUIRE(static_cast(my_object.oversample) == 2);
}
+ THEN("mode and solver accept both symbolic and numeric spellings and report the symbol") {
+ my_object.mode = symbol("bp12");
+ REQUIRE(my_object.mode == symbol{"bp12"});
+ my_object.mode = atoms{klf::mode_hp12};
+ REQUIRE(my_object.mode == symbol{"hp12"});
+ my_object.solver = symbol("exact");
+ REQUIRE(my_object.solver == symbol{"exact"});
+ my_object.solver = atoms{klf::solver_fast};
+ REQUIRE(my_object.solver == symbol{"fast"});
+ }
THEN("preset and clear messages are callable") {
my_object.store(atoms{1});
my_object.recall(atoms{1});
diff --git a/source/projects/tap.multitap_tilde/CMakeLists.txt b/source/projects/tap.multitap_tilde/CMakeLists.txt
index 9c52c8b4..8c7513e3 100644
--- a/source/projects/tap.multitap_tilde/CMakeLists.txt
+++ b/source/projects/tap.multitap_tilde/CMakeLists.txt
@@ -1,14 +1,21 @@
-# Copyright 1999-2026 Timothy Place. Distributed under the New BSD License.
+# Copyright 2003-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/delay.h
)
add_library(
diff --git a/source/projects/tap.multitap_tilde/tap.multitap_tilde.cpp b/source/projects/tap.multitap_tilde/tap.multitap_tilde.cpp
index 7f81f93b..9f7e7fe5 100644
--- a/source/projects/tap.multitap_tilde/tap.multitap_tilde.cpp
+++ b/source/projects/tap.multitap_tilde/tap.multitap_tilde.cpp
@@ -1,93 +1,119 @@
/// @file
-/// @brief tap.multitap~ — a self-contained multitap delay line.
-/// @details Records the input into a circular buffer and sums any number of delayed taps, each
-/// with its own delay time (ms) and gain (dB). Faithful port of the ttblue tt_multitap —
-/// DSP is portable C++ (no Jamoma).
+/// @brief tap.multitap~ — a self-contained multitap delay line on the portable delay kernel.
+/// @details Records the input into one shared circular buffer and sums up to 99 taps, each with
+/// its own delay time (ms), gain (dB), and equal-power pan (-1..1), to a stereo bus.
+/// Rebuilt on tap::tools::delay::multitap (taptools/delay.h): fractional Hermite reads
+/// by default (@interp 1) with the legacy bit-compatible integer-sample truncation at
+/// @interp 0, and every per-tap parameter riding a per-sample kernel slew (no zippers).
+///
+/// The object now has TWO signal outlets (left/right). A center-panned tap (@pan 0,
+/// the default) contributes equally to both — so the old mono output is simply the
+/// mix of the two busses. Gain is set in dB (converted to linear for the kernel, as
+/// the old wrapper did); pure feedforward, no dry path, no master gain — gain staging
+/// is the patch's job (see delay.h "Honest limits").
/// @author Timothy Place
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 2003-2026 Timothy Place.
#include
-#include
#include
-#include
+
+#include
#include "c74_min.h"
using namespace c74::min;
+namespace kernel = tap::tools::delay;
-class multitap : public object, public sample_operator<1, 1> {
+class multitap : public object, public sample_operator<1, 2> {
private:
- // Cached state — declared before the attributes so it is initialized before their setters run.
- // Members are initialized in declaration order, so state declared *after* an attribute is
- // default-initialized again after that attribute's setter has already written to it. That is
- // what silently zeroed m_gain_lin here: the gain attribute's 0 dB default converts to a linear
- // 1.0, and a trailing `m_gain_lin{}` threw it away, leaving a fresh object silent. (tap.noise~
- // carries the same note; dspsetup does not recompute the gains, so nothing recovered it.)
- static constexpr int k_max_taps{100};
- static constexpr double k_master_gain{1.0};
-
- std::vector m_buffer;
- long m_write{0};
- int m_num_taps{1};
- std::array m_delay_ms{};
- std::array m_delay_samples{};
- std::array m_gain_lin{};
+ // Constructed before the attributes below so their defaults can forward into it.
+ kernel::multitap m_engine;
public:
- MIN_DESCRIPTION{"A self-contained multitap delay. Records the input into a buffer and sums any "
- "number of taps, each with its own delay time (ms) and gain (dB)."};
+ MIN_DESCRIPTION{"A self-contained multitap delay. Records the input into a buffer and sums up "
+ "to 99 taps, each with its own delay time (ms), gain (dB), and equal-power pan "
+ "(-1..1), to a stereo output pair. A center-panned tap feeds both outlets "
+ "equally; the old mono output is the mix of the two."};
MIN_TAGS{"delays"};
MIN_AUTHOR{"Timothy Place"};
- MIN_RELATED{"tapin~, tapout~, delay~, tap.procrastinate~"};
+ MIN_RELATED{"tapin~, tapout~, delay~, tap.delay~, tap.procrastinate~"};
inlet<> m_in{this, "(signal) audio input"};
- outlet<> m_out{this, "(signal) summed delay taps", "signal"};
+ outlet<> m_out_left{this, "(signal) summed delay taps, left", "signal"};
+ outlet<> m_out_right{this, "(signal) summed delay taps, right", "signal"};
- attribute buffersize{this, "buffersize", 1000.0,
- description{"Size of the delay buffer in milliseconds (fixed at instantiation)."}};
+ attribute buffersize{
+ this, "buffersize", 1000.0,
+ description{"Size of the delay buffer in milliseconds (set by the first object argument; "
+ "applied when the DSP chain starts). This bounds the maximum delay time."}};
attribute taps{this, "taps", 1, setter{MIN_FUNCTION{
- m_num_taps = std::clamp(static_cast(args[0]), 1, k_max_taps - 1);
- return {m_num_taps};
+ const int n = std::clamp(static_cast(args[0]), 1, kernel::k_max_taps - 1);
+ m_engine.set_taps(n);
+ return {n};
}},
- description{"Number of active delay taps."}};
+ description{"Number of active delay taps (1..99)."}};
attribute> delay{this,
"delay",
{0.0},
setter{MIN_FUNCTION{
- for (size_t i = 0; i < args.size() && i < static_cast(k_max_taps);
- ++i) {
- m_delay_ms[i] = args[i];
+ for (size_t i = 0;
+ i < args.size() && i < static_cast(kernel::k_max_taps); ++i) {
+ m_engine.set_time_ms(static_cast(i), args[i]);
}
- update_delays();
return args;
}},
- description{"Delay time (ms) for each tap."}};
+ description{"Delay time (ms) for each tap, slewed by the kernel."}};
attribute> gain{
this,
"gain",
{0.0},
setter{MIN_FUNCTION{
- for (size_t i = 0; i < args.size() && i < static_cast(k_max_taps); ++i) {
- m_gain_lin[i] = std::pow(10.0, static_cast(args[i]) * 0.05); // dB -> linear
+ for (size_t i = 0; i < args.size() && i < static_cast(kernel::k_max_taps); ++i) {
+ m_engine.set_gain(static_cast(i),
+ std::pow(10.0, static_cast(args[i]) * 0.05)); // dB -> linear
+ }
+ return args;
+ }},
+ description{"Gain (dB) for each tap, slewed by the kernel."}};
+
+ attribute> pan{
+ this,
+ "pan",
+ {0.0},
+ setter{MIN_FUNCTION{
+ for (size_t i = 0; i < args.size() && i < static_cast(kernel::k_max_taps); ++i) {
+ m_engine.set_pan(static_cast(i), std::clamp(static_cast(args[i]), -1.0, 1.0));
}
return args;
}},
- description{"Gain (dB) for each tap."}};
+ description{"Equal-power pan (-1 hard left .. 1 hard right, default 0 center) for each "
+ "tap, slewed by the kernel. A center-panned tap feeds both outlets equally; "
+ "the endpoints are exact (a hard-panned tap is absent from the far outlet)."}};
+
+ attribute interp{this, "interp", kernel::interp_hermite, setter{MIN_FUNCTION{
+ const int v = (static_cast(args[0]) == kernel::interp_trunc)
+ ? kernel::interp_trunc
+ : kernel::interp_hermite;
+ m_engine.set_interp(v);
+ return {v};
+ }},
+ description{"Interpolation mode for all taps: 1 (default) reads fractional delays with a "
+ "4-point Hermite; 0 restores the legacy bit-compatible integer-sample "
+ "truncation."}};
message<> clear{this, "clear", "Clear the delay buffer.",
MIN_FUNCTION{
- std::fill(m_buffer.begin(), m_buffer.end(), 0.0);
+ m_engine.clear();
return {};
}};
- message<> dspsetup{this, "dspsetup", "Allocate and recompute when the DSP chain starts.",
+ message<> dspsetup{this, "dspsetup", "Re-prepare for the sample rate and buffer size when the DSP chain starts.",
MIN_FUNCTION{
- allocate();
- update_delays();
+ m_engine.prepare(samplerate(), buffersize);
return {};
}};
@@ -95,53 +121,14 @@ class multitap : public object, public sample_operator<1, 1> {
if (!args.empty() && static_cast(args[0]) > 0.0) {
buffersize = args[0];
}
- allocate();
- }
-
- sample operator()(sample x) {
- const long N = static_cast(m_buffer.size());
- if (N < 1) {
- return 0.0;
- }
-
- m_buffer[m_write] = x;
-
- double out = 0.0;
- for (int i = 0; i < m_num_taps; ++i) {
- long read = m_write - m_delay_samples[i];
- read %= N;
- if (read < 0) {
- read += N;
- }
- out += m_buffer[read] * m_gain_lin[i];
- }
-
- if (++m_write >= N) {
- m_write = 0;
- }
-
- return out * k_master_gain;
- }
-
- private:
- void allocate() {
- const double sr = samplerate();
- long n = static_cast(static_cast(buffersize) * (sr * 0.001));
- if (n < 1) {
- n = 1;
- }
- if (static_cast(m_buffer.size()) != n) {
- m_buffer.assign(n, 0.0);
- m_write = 0;
- }
+ m_engine.prepare(samplerate(), buffersize);
}
- void update_delays() {
- const double sr = samplerate();
- const long N = std::max(1L, static_cast(m_buffer.size()));
- for (int i = 0; i < k_max_taps; ++i) {
- m_delay_samples[i] = std::clamp(static_cast(m_delay_ms[i] * (sr * 0.001)), 0L, N - 1);
- }
+ samples<2> operator()(sample x) {
+ double left = 0.0;
+ double right = 0.0;
+ m_engine.process(x, left, right);
+ return {left, right};
}
};
diff --git a/source/projects/tap.multitap_tilde/tap.multitap_tilde_test.cpp b/source/projects/tap.multitap_tilde/tap.multitap_tilde_test.cpp
index c0c395af..369307b2 100644
--- a/source/projects/tap.multitap_tilde/tap.multitap_tilde_test.cpp
+++ b/source/projects/tap.multitap_tilde/tap.multitap_tilde_test.cpp
@@ -1,9 +1,12 @@
/// @file
-/// @brief Unit tests for tap.multitap~.
+/// @brief Unit tests for tap.multitap~ (the kernel-backed stereo rebuild).
/// @details The list-valued attributes are assigned as `atoms`, not as `std::vector`:
/// min-api's `attribute>::operator=(const T)` wraps the whole vector in a
/// single atom via the catch-all `atom(T)` template, which recurses until the stack runs
/// out. The `atoms` overload takes the same path Max does.
+///
+/// The kernel slews every per-tap parameter over 20 ms by default, so each scenario
+/// settles the ramps (~882 samples at the mock 44.1 kHz) before measuring.
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 2003-2026 Timothy Place.
@@ -21,23 +24,29 @@ namespace {
return static_cast(ms * k_mock_sr * 0.001);
}
- // Feed an impulse followed by silence and return the whole output.
- std::vector impulse_response(multitap& object, size_t n) {
- std::vector out(n, 0.0);
+ // Run silence through the object so the kernel's parameter ramps reach their targets.
+ void settle(multitap& object, size_t n = 2048) {
for (size_t i = 0; i < n; ++i) {
- out[i] = object(i == 0 ? 1.0 : 0.0);
+ object(0.0);
}
- return out;
}
- size_t count_nonzero(const std::vector& x) {
- size_t n = 0;
- for (double s : x) {
- if (s != 0.0) {
- ++n;
- }
+ struct stereo_response {
+ std::vector left;
+ std::vector right;
+ };
+
+ // Feed an impulse followed by silence and return both output busses.
+ stereo_response impulse_response(multitap& object, size_t n) {
+ stereo_response out;
+ out.left.resize(n, 0.0);
+ out.right.resize(n, 0.0);
+ for (size_t i = 0; i < n; ++i) {
+ const auto y = object(i == 0 ? 1.0 : 0.0);
+ out.left[i] = y[0];
+ out.right[i] = y[1];
}
- return n;
+ return out;
}
} // namespace
@@ -49,15 +58,19 @@ SCENARIO("tap.multitap~ instantiates with the documented defaults") {
test_wrapper an_instance;
multitap& my_object = an_instance;
- THEN("one tap, a 1000 ms buffer, no delay and unity gain") {
+ THEN("one tap, a 1000 ms buffer, no delay, unity gain, center pan, Hermite interpolation") {
REQUIRE(static_cast(my_object.buffersize) == 1000.0);
REQUIRE(static_cast(my_object.taps) == 1);
const std::vector& delays = my_object.delay;
const std::vector& gains = my_object.gain;
+ const std::vector& pans = my_object.pan;
REQUIRE(delays.size() == 1);
REQUIRE(delays[0] == 0.0);
REQUIRE(gains.size() == 1);
REQUIRE(gains[0] == 0.0); // 0 dB
+ REQUIRE(pans.size() == 1);
+ REQUIRE(pans[0] == 0.0); // center
+ REQUIRE(static_cast(my_object.interp) == 1);
}
}
}
@@ -82,76 +95,89 @@ SCENARIO("tap.multitap~ clamps the tap count to its documented range") {
}
}
-SCENARIO("tap.multitap~ places a single tap at its delay time") {
+SCENARIO("tap.multitap~ places a center-panned tap equally on both outlets") {
ext_main(nullptr);
- GIVEN("one tap at 10 ms and 0 dB") {
+ GIVEN("one tap at 10 ms, 0 dB, center pan, ramps settled") {
test_wrapper an_instance;
multitap& my_object = an_instance;
my_object.delay = atoms{10.0};
+ settle(my_object);
WHEN("an impulse is processed") {
- const std::vector out = impulse_response(my_object, 2048);
+ const stereo_response out = impulse_response(my_object, 2048);
+ const long at = samples_for(10.0);
- THEN("exactly one tap comes out, at unity, 441 samples later") {
- REQUIRE(count_nonzero(out) == 1);
- REQUIRE(out[samples_for(10.0)] == 1.0);
+ THEN("both busses carry the tap at the equal-power center gain (cos 45 deg)") {
+ const double half = std::sqrt(0.5);
+ REQUIRE(std::abs(out.left[at] - half) < 1e-12);
+ REQUIRE(std::abs(out.right[at] - half) < 1e-12);
}
}
}
}
-SCENARIO("tap.multitap~ sums several taps, each with its own time and gain") {
+SCENARIO("tap.multitap~ pans a tap with exact endpoints") {
ext_main(nullptr);
- GIVEN("three taps at 10/20/30 ms and 0/-6/-12 dB") {
+ GIVEN("one tap at 10 ms, 0 dB, panned hard left, ramps settled") {
test_wrapper an_instance;
multitap& my_object = an_instance;
- my_object.taps = 3;
- my_object.delay = atoms{10.0, 20.0, 30.0};
- my_object.gain = atoms{0.0, -6.0, -12.0};
+ my_object.delay = atoms{10.0};
+ my_object.pan = atoms{-1.0};
+ settle(my_object);
WHEN("an impulse is processed") {
- const std::vector out = impulse_response(my_object, 4096);
+ const stereo_response out = impulse_response(my_object, 2048);
- THEN("all three taps appear, at their own times and levels") {
- REQUIRE(count_nonzero(out) == 3);
- REQUIRE(std::abs(out[samples_for(10.0)] - 1.0) < 1e-12);
- REQUIRE(std::abs(out[samples_for(20.0)] - std::pow(10.0, -6.0 * 0.05)) < 1e-12);
- REQUIRE(std::abs(out[samples_for(30.0)] - std::pow(10.0, -12.0 * 0.05)) < 1e-12);
+ THEN("the left bus carries the tap at unity and the right bus stays bitwise silent") {
+ REQUIRE(out.left[samples_for(10.0)] == 1.0);
+ for (double s : out.right) {
+ REQUIRE(s == 0.0);
+ }
}
}
}
+}
- GIVEN("three tap times configured but only two taps active") {
+SCENARIO("tap.multitap~ sums several taps, each with its own time and gain") {
+ ext_main(nullptr);
+
+ GIVEN("three taps at 10/20/30 ms and 0/-6/-12 dB, all hard left, ramps settled") {
test_wrapper an_instance;
multitap& my_object = an_instance;
- my_object.taps = 2;
+ my_object.taps = 3;
my_object.delay = atoms{10.0, 20.0, 30.0};
- my_object.gain = atoms{0.0, 0.0, 0.0};
+ my_object.gain = atoms{0.0, -6.0, -12.0};
+ my_object.pan = atoms{-1.0, -1.0, -1.0};
+ settle(my_object);
WHEN("an impulse is processed") {
- const std::vector out = impulse_response(my_object, 4096);
+ const stereo_response out = impulse_response(my_object, 4096);
- THEN("the third tap is inactive — taps bounds the loop, not the table") {
- REQUIRE(count_nonzero(out) == 2);
- REQUIRE(out[samples_for(30.0)] == 0.0);
+ THEN("all three taps appear on the left, at their own times and levels") {
+ REQUIRE(std::abs(out.left[samples_for(10.0)] - 1.0) < 1e-12);
+ REQUIRE(std::abs(out.left[samples_for(20.0)] - std::pow(10.0, -6.0 * 0.05)) < 1e-12);
+ REQUIRE(std::abs(out.left[samples_for(30.0)] - std::pow(10.0, -12.0 * 0.05)) < 1e-12);
}
}
}
- GIVEN("two taps at the same delay time") {
+ GIVEN("three tap times configured but only two taps active, ramps settled") {
test_wrapper an_instance;
multitap& my_object = an_instance;
my_object.taps = 2;
- my_object.delay = atoms{10.0, 10.0};
- my_object.gain = atoms{0.0, 0.0};
+ my_object.delay = atoms{10.0, 20.0, 30.0};
+ my_object.pan = atoms{-1.0, -1.0, -1.0};
+ settle(my_object);
WHEN("an impulse is processed") {
- const std::vector out = impulse_response(my_object, 2048);
+ const stereo_response out = impulse_response(my_object, 4096);
- THEN("the taps sum") {
- REQUIRE(std::abs(out[samples_for(10.0)] - 2.0) < 1e-12);
+ THEN("the third tap is inactive — taps bounds the loop, not the table") {
+ REQUIRE(out.left[samples_for(10.0)] == 1.0);
+ REQUIRE(out.left[samples_for(20.0)] == 1.0);
+ REQUIRE(out.left[samples_for(30.0)] == 0.0);
}
}
}
@@ -160,17 +186,19 @@ SCENARIO("tap.multitap~ sums several taps, each with its own time and gain") {
SCENARIO("tap.multitap~ bounds a tap by its buffer") {
ext_main(nullptr);
- GIVEN("one tap asked to delay well past the 1000 ms buffer") {
+ GIVEN("one tap asked to delay well past the 1000 ms buffer, ramps settled") {
test_wrapper an_instance;
multitap& my_object = an_instance;
+ my_object.interp = 0;
my_object.delay = atoms{5000.0};
+ my_object.pan = atoms{-1.0};
+ settle(my_object, 4096);
WHEN("an impulse is processed") {
- const std::vector out = impulse_response(my_object, 44100);
+ const stereo_response out = impulse_response(my_object, 46000);
THEN("the tap lands at the far end of the buffer instead of wrapping") {
- REQUIRE(count_nonzero(out) == 1);
- REQUIRE(out[samples_for(1000.0) - 1] == 1.0);
+ REQUIRE(out.left[samples_for(1000.0)] == 1.0);
}
}
}
@@ -183,6 +211,7 @@ SCENARIO("tap.multitap~ clears its buffer on demand") {
test_wrapper an_instance;
multitap& my_object = an_instance;
my_object.delay = atoms{10.0};
+ settle(my_object);
my_object(1.0);
for (int i = 0; i < 100; ++i) {
my_object(0.0);
@@ -193,7 +222,9 @@ SCENARIO("tap.multitap~ clears its buffer on demand") {
THEN("the buffered impulse never emerges") {
for (int i = 0; i < 2048; ++i) {
- REQUIRE(my_object(0.0) == 0.0);
+ const auto y = my_object(0.0);
+ REQUIRE(y[0] == 0.0);
+ REQUIRE(y[1] == 0.0);
}
}
}
diff --git a/source/projects/tap.noise_tilde/tap.noise_tilde.cpp b/source/projects/tap.noise_tilde/tap.noise_tilde.cpp
index 5373a8cd..1d129b86 100644
--- a/source/projects/tap.noise_tilde/tap.noise_tilde.cpp
+++ b/source/projects/tap.noise_tilde/tap.noise_tilde.cpp
@@ -2,13 +2,17 @@
/// @brief tap.noise~ — generate various colors of noise.
/// @details White, pink, brown, blue, or gaussian noise. The white/pink/brown/blue generators
/// are faithful ports of Jamoma's TTNoise (LCG white source + the same colouring
-/// filters); gaussian uses a standard normal distribution with mean/deviation. DSP is
+/// filters); gaussian uses a standard normal distribution with mean/deviation. All
+/// generators are deterministic per seed (seed attribute, default 1 — a seed is a
+/// serial number), so renders reproduce and mc. instances decorrelate by seed. DSP is
/// portable C++ (no min-lib). Mono — wrap in an mc. operator for multichannel.
/// @author Timothy Place
// SPDX-License-Identifier: BSD-3-Clause
// Copyright 2003-2026 Timothy Place.
+#include
#include
+#include
#include
#include "c74_min.h"
@@ -29,7 +33,7 @@ class noise : public object, public sample_operator<0, 1> {
double m_mean{0.0};
double m_deviation{1.0};
- std::mt19937 m_rng{std::random_device{}()};
+ std::mt19937 m_rng{1}; // deterministically re-seeded by the seed attribute
std::normal_distribution m_normal{0.0, 1.0};
public:
@@ -65,6 +69,22 @@ class noise : public object, public sample_operator<0, 1> {
}},
description{"The color of the noise."}};
+ // A seed is a serial number (the tap.vco~ doctrine): the generators are deterministic per
+ // seed, so renders reproduce and different seeds decorrelate (e.g. under mc.). Setting the
+ // seed restarts both the LCG white source and the gaussian generator. The LCG state is
+ // seed - 1 so the default (seed 1) reproduces the legacy TTNoise sequence bit for bit —
+ // the previous wrapper always started the LCG at 0.
+ attribute seed{this, "seed", 1, setter{MIN_FUNCTION{
+ const int v = std::max(1, static_cast(args[0]));
+ m_accum = v - 1;
+ m_rng.seed(static_cast(v));
+ m_normal.reset();
+ return {v};
+ }},
+ description{"Seed for the noise generators (>= 1, default 1). The output is deterministic "
+ "per seed; give mc. instances different seeds to decorrelate them. Setting the "
+ "seed restarts the sequence."}};
+
attribute gain{this, "gain", 0.0, setter{MIN_FUNCTION{
m_gain = std::pow(10.0, static_cast(args[0]) * 0.05); // dB -> linear
return args;
diff --git a/source/projects/tap.noise_tilde/tap.noise_tilde_test.cpp b/source/projects/tap.noise_tilde/tap.noise_tilde_test.cpp
index 7158a2ae..9aad6744 100644
--- a/source/projects/tap.noise_tilde/tap.noise_tilde_test.cpp
+++ b/source/projects/tap.noise_tilde/tap.noise_tilde_test.cpp
@@ -84,6 +84,7 @@ SCENARIO("tap.noise~ instantiates with the documented defaults") {
THEN("attribute defaults match the reference page") {
REQUIRE(my_object.mode == symbol{"white"});
+ REQUIRE(static_cast(my_object.seed) == 1);
REQUIRE(static_cast(my_object.gain) == 0.0);
REQUIRE(static_cast(my_object.mean) == 0.0);
REQUIRE(static_cast(my_object.deviation) == 1.0);
@@ -117,6 +118,68 @@ SCENARIO("tap.noise~ runs the documented white-noise LCG") {
}
}
+SCENARIO("tap.noise~ is deterministic per seed — a seed is a serial number") {
+ ext_main(nullptr);
+
+ GIVEN("two instances with the same seed") {
+ test_wrapper instance_a;
+ test_wrapper instance_b;
+ noise& a = instance_a;
+ noise& b = instance_b;
+ a.seed = 7;
+ b.seed = 7;
+
+ THEN("their white sequences are identical sample for sample") {
+ for (int i = 0; i < 1024; ++i) {
+ REQUIRE(a() == b());
+ }
+ }
+ THEN("their gaussian sequences are identical sample for sample") {
+ a.mode = "gauss";
+ b.mode = "gauss";
+ for (int i = 0; i < 1024; ++i) {
+ REQUIRE(a() == b());
+ }
+ }
+ }
+ GIVEN("two instances with different seeds") {
+ test_wrapper instance_a;
+ test_wrapper instance_b;
+ noise& a = instance_a;
+ noise& b = instance_b;
+ a.seed = 1;
+ b.seed = 2;
+
+ THEN("their white sequences differ (decorrelated serial numbers)") {
+ bool any_difference = false;
+ for (int i = 0; i < 1024; ++i) {
+ if (a() != b()) {
+ any_difference = true;
+ }
+ }
+ REQUIRE(any_difference);
+ }
+ }
+ GIVEN("one instance") {
+ test_wrapper an_instance;
+ noise& my_object = an_instance;
+
+ THEN("re-setting the seed restarts the sequence") {
+ my_object.seed = 5;
+ const std::vector first = generate(my_object, 256);
+ my_object.seed = 5;
+ const std::vector again = generate(my_object, 256);
+ REQUIRE(first == again);
+ }
+ THEN("the default seed (1) reproduces the legacy sequence that started the LCG at 0") {
+ long accum = 0;
+ for (int i = 0; i < 256; ++i) {
+ REQUIRE(my_object() == lcg_white(accum));
+ }
+ }
+ }
+}
+
SCENARIO("tap.noise~ scales its output by the gain attribute") {
ext_main(nullptr);
diff --git a/source/projects/tap.sustain_tilde/tap.sustain_tilde.cpp b/source/projects/tap.sustain_tilde/tap.sustain_tilde.cpp
index b8ef12bf..c41e7b52 100644
--- a/source/projects/tap.sustain_tilde/tap.sustain_tilde.cpp
+++ b/source/projects/tap.sustain_tilde/tap.sustain_tilde.cpp
@@ -253,10 +253,16 @@ class sustain : public object, public vector_operator<> {
return {};
}};
- attribute length{
- this, "length", 1000.0,
- description{"Maximum length of the captured loop, in milliseconds. (Changing this restarts "
- "the recording buffer.)"}};
+ attribute length{this, "length", 1000.0, setter{MIN_FUNCTION{
+ // Store the requested length truthfully (so queries report what was asked for); the
+ // recording ring itself re-sizes to match at the next dspsetup via update_samplerate()
+ // — re-sizing mid-vector would drop the recording history while audio runs.
+ return {std::max(1.0, static_cast(args[0]))};
+ }},
+ description{"Maximum length of the captured loop, in milliseconds (>= 1). The value is "
+ "stored immediately, but the recording buffer re-sizes to match at the next "
+ "DSP start (restart audio to apply it); until then captures keep using the "
+ "previous length."}};
attribute voices{this,
"voices",
diff --git a/source/projects/tap.vco_tilde/tap.vco_tilde.cpp b/source/projects/tap.vco_tilde/tap.vco_tilde.cpp
index 8482ca1c..c557fbef 100644
--- a/source/projects/tap.vco_tilde/tap.vco_tilde.cpp
+++ b/source/projects/tap.vco_tilde/tap.vco_tilde.cpp
@@ -32,7 +32,9 @@ class vco : public object, public vector_operator<> {
"an analog-character section, deterministic per seed: slow pitch drift, "
"fast jitter, static detune, V/oct tracking error, and a waveform "
"imperfection control that gives every seed the component tolerances of a "
- "different vintage unit. All parameters glide smoothly and sixteen preset "
+ "different vintage unit. A performance section adds cents-calibrated vibrato "
+ "with a delayed onset re-armed per note, and a pitch-bend control riding the "
+ "smooth ramp. All parameters glide smoothly and sixteen preset "
"slots can be stored and morphed between."};
MIN_TAGS{"generators"};
MIN_AUTHOR{"Timothy Place"};
@@ -78,6 +80,17 @@ class vco : public object, public vector_operator<> {
"V/oct calibration error in cents per octave (-10..10), measured from A440: the pitch offset "
"grows with distance from the trim point, like an analog exponential converter drifting out of "
"calibration.")
+ TAP_VCO_ATTR(vibrato, kernel::p_vibrato, 0.0,
+ "Periodic pitch-modulation depth in cents (0..100) — the performance companion to drift. "
+ "Cents-calibrated, so the musical depth stays constant across the keyboard (no per-note "
+ "scaling of an FM signal needed). 0 keeps the oscillator vibrato-free.")
+ TAP_VCO_ATTR(vibrato_rate, kernel::p_vibrato_rate, 5.0, "Vibrato rate in Hz (0.05..20).")
+ TAP_VCO_ATTR(vibrato_delay, kernel::p_vibrato_delay, 0.0,
+ "Vibrato onset time constant in ms (0..5000): after a note change the vibrato swells in over "
+ "roughly this time — the singing-vibrato gesture. Re-armed on every note change; 0 is instant.")
+ TAP_VCO_ATTR(bend, kernel::p_bend, 0.0,
+ "Pitch bend in semitones (-24..24) — the wheel. Rides the smooth ramp, so bends glide at the "
+ "smooth time rather than stepping.")
TAP_VCO_ATTR(gain, kernel::p_gain, 0.0, "Output gain in dB.")
#undef TAP_VCO_ATTR
diff --git a/source/projects/tap.vco_tilde/tap.vco_tilde_test.cpp b/source/projects/tap.vco_tilde/tap.vco_tilde_test.cpp
index 7052e853..e2901a65 100644
--- a/source/projects/tap.vco_tilde/tap.vco_tilde_test.cpp
+++ b/source/projects/tap.vco_tilde/tap.vco_tilde_test.cpp
@@ -311,6 +311,10 @@ SCENARIO("the Min wrapper instantiates with the documented defaults") {
REQUIRE(static_cast(my_object.pw) == 50.0);
REQUIRE(static_cast(my_object.drift) == 0.0);
REQUIRE(static_cast(my_object.seed) == 1);
+ REQUIRE(static_cast(my_object.vibrato) == 0.0);
+ REQUIRE(static_cast(my_object.vibrato_rate) == 5.0);
+ REQUIRE(static_cast(my_object.vibrato_delay) == 0.0);
+ REQUIRE(static_cast(my_object.bend) == 0.0);
}
THEN("the waveform message snaps the shape") {
my_object.waveform(atoms{symbol("sine")});
@@ -323,6 +327,10 @@ SCENARIO("the Min wrapper instantiates with the documented defaults") {
REQUIRE(static_cast(my_object.pw) == 1.0);
my_object.shape = 9.0;
REQUIRE(static_cast(my_object.shape) == 3.0);
+ my_object.vibrato = 500.0;
+ REQUIRE(static_cast(my_object.vibrato) == 100.0);
+ my_object.bend = 99.0;
+ REQUIRE(static_cast(my_object.bend) == 24.0);
}
THEN("preset and clear messages are callable") {
my_object.store(atoms{1});
diff --git a/source/projects/tap.verb_tilde/tap.verb_tilde.cpp b/source/projects/tap.verb_tilde/tap.verb_tilde.cpp
index 244aeae5..f86bc33d 100644
--- a/source/projects/tap.verb_tilde/tap.verb_tilde.cpp
+++ b/source/projects/tap.verb_tilde/tap.verb_tilde.cpp
@@ -10,6 +10,13 @@
/// look-ahead limiter, the clip stage, and internal oversampling. Oversampling runs the
/// reverb core at an integer multiple (1/2/4/8) of the host rate; it defaults to 1 (off),
/// so the default sound matches the legacy object exactly.
+/// @note Determinism deviation from the legacy wrapper: the original deviated its comb
+/// delays/decays through std::rand(), so every instantiation (and every re-prepare)
+/// re-randomized the room — renders were not reproducible. The deviation now runs a
+/// seeded LCG restarted from the seed attribute on every configure: the comb tuning is
+/// a pure function of (seed, parameters, sample rate) — a seed is a serial number, per
+/// the package doctrine. The right core is seeded at a fixed offset from the left so
+/// the stereo decorrelation survives.
/// @note Oversampling deviation from the legacy wrapper: the original tap.verb~ exposed a
/// "downsample" attribute that ran the core at a *lower* rate (sr/factor) using a crude
/// sample-and-hold decimator (tt_downsample) and a zero-order-hold reconstructor
@@ -26,7 +33,7 @@
#include
#include
#include
-#include