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 +#include #include #include "c74_min.h" @@ -53,9 +60,17 @@ namespace { } } + // Deterministic uniform in [0, 1) from a Numerical Recipes LCG (state * 1664525 + 1013904223), + // replacing the former std::rand(): the deviate sequence is a pure function of the seed, so the + // comb tuning is bit-reproducible across instantiations and prepares. + double lcg_uniform(std::uint32_t& state) { + state = state * 1664525u + 1013904223u; + return static_cast(state) * (1.0 / 4294967296.0); + } + // Randomize a millisecond value by +/-1 ms and snap it to the nearest prime number of samples. - double deviate(double value_ms, double sr) { - double v = value_ms + (2.0 * (static_cast(std::rand()) / static_cast(RAND_MAX)) - 1.0); + double deviate(double value_ms, double sr, std::uint32_t& state) { + double v = value_ms + (2.0 * lcg_uniform(state) - 1.0); v = v * 0.001 * sr; // ms -> samples v = static_cast(nearest_prime(static_cast(v))); return (v / sr) * 1000.0; // samples -> ms @@ -127,9 +142,12 @@ namespace { m_lp_coef = std::clamp(hz * 2.0 / m_sr, 0.0, 1.0); } void set_modfreq(double hz) { - for (auto& c : m_comb) { - c.lfo_inc = deviate_or(hz) / m_sr; - } + m_modfreq = hz; + configure(); + } + void set_seed(std::uint32_t s) { + m_seed = s; + configure(); // re-deviate deterministically from the new seed } void set_moddepth(double ms) { for (auto& c : m_comb) { @@ -243,8 +261,11 @@ namespace { double m_sr{44100.0}; double m_delay{100.0}, m_decay{3.5}, m_damping{20000.0}, m_lowpass_hz{15000.0}; + double m_modfreq{0.1}; double m_damp_coef{0.8}, m_lp_coef{0.5}; + std::uint32_t m_seed{1}; // LCG seed for the deviate sequence; set via set_seed() + std::vector m_er_buffer; long m_er_write{0}; @@ -256,16 +277,19 @@ namespace { double m_lp_state{0.0}; - double deviate_or(double v) { return deviate(v, m_sr); } - void configure() { m_damp_coef = std::clamp(m_damping * 2.0 / m_sr, 0.0, 1.0); m_lp_coef = std::clamp(m_lowpass_hz * 2.0 / m_sr, 0.0, 1.0); + // Restart the LCG from the seed on every configure so the deviated prime layout is a + // pure function of (seed, parameters, sample rate): bit-reproducible across + // instantiations, prepares, and parameter changes. + std::uint32_t state = m_seed; for (int i = 0; i < 6; ++i) { - m_comb[i].delay_base = deviate(m_delay * k_delay_mult[i], m_sr); - const double decay_s = deviate(m_decay * 1000.0, m_sr) * 0.001; // deviate works in ms + m_comb[i].delay_base = deviate(m_delay * k_delay_mult[i], m_sr, state); + const double decay_s = deviate(m_decay * 1000.0, m_sr, state) * 0.001; // deviate works in ms const double delay_s = m_comb[i].delay_base * 0.001; m_comb[i].fb_coef = (decay_s > 0.0) ? std::pow(10.0, ((delay_s / decay_s) * -60.0) / 20.0) : 0.0; + m_comb[i].lfo_inc = deviate(m_modfreq, m_sr, state) / m_sr; } } @@ -400,6 +424,18 @@ class verb : public object, public sample_operator<2, 2> { description{"Internal oversampling factor (1, 2, 4, or 8). When >1 the reverb core runs at " "that multiple of the host sample rate, with antialiasing up/downsampling around " "it [default: 1 = off]."}}; + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ + const int v = std::max(1, static_cast(args[0])); + if (m_ready) { // skip during attribute construction; push_all() applies it + apply_seed(static_cast(v)); + } + return {v}; + }}, + description{"Seed for the deviated comb-delay/decay tuning (>= 1, default 1). The room is " + "deterministic per seed — the same seed reproduces the identical reverb on every " + "instantiation and DSP restart; different seeds give slightly different rooms. " + "Setting it re-deviates immediately."}}; + attribute bypass{this, "bypass", false, description{"Pass the input through unprocessed."}}; attribute mute{this, "mute", false, description{"Silence the output."}}; @@ -523,7 +559,16 @@ class verb : public object, public sample_operator<2, 2> { return y; } + // Seed the two cores' deviate generators. The right core is seeded at a fixed golden-ratio + // offset from the left so the L/R prime layouts always differ — the stereo image depends on + // the two cores deviating differently (the old std::rand() got this by shared-state accident). + void apply_seed(std::uint32_t s) { + m_l.set_seed(s); + m_r.set_seed(s + 0x9E3779B9u); + } + void push_all() { + apply_seed(static_cast(std::max(1, static_cast(seed)))); for (auto* c : {&m_l, &m_r}) { c->set_delay(delay); c->set_decay(decay); diff --git a/source/projects/tap.verb_tilde/tap.verb_tilde_test.cpp b/source/projects/tap.verb_tilde/tap.verb_tilde_test.cpp index 445c3d49..15945db5 100644 --- a/source/projects/tap.verb_tilde/tap.verb_tilde_test.cpp +++ b/source/projects/tap.verb_tilde/tap.verb_tilde_test.cpp @@ -1,17 +1,17 @@ /// @file -/// @brief Unit tests for tap.verb~ (focus: the oversampling stage). +/// @brief Unit tests for tap.verb~ (focus: the oversampling stage and seeded determinism). // SPDX-License-Identifier: BSD-3-Clause // Copyright 2003-2026 Timothy Place. -#include #include #include "c74_min_unittest.h" // required unit-test header (defines main via Catch) #include "tap.verb_tilde.cpp" // include the object source so we can instantiate it // Run `count` samples of an impulse (first sample = 1.0, rest = 0.0) through the object's -// per-sample operator and collect the left-channel output. The reverb cores randomize their -// delays via std::rand(), so seed before constructing to make the run reproducible. +// per-sample operator and collect the left-channel output. The reverb cores deviate their +// delays through a seeded LCG (seed attribute, default 1), so runs are reproducible by +// construction — no global RNG state to arrange. static std::vector run_impulse(verb& v, int count) { std::vector out; out.reserve(count); @@ -27,26 +27,27 @@ SCENARIO("tap.verb~ oversampling defaults to off and 1x is unchanged") { ext_main(nullptr); // configure the class (required once per test executable) GIVEN("a default instance") { - std::srand(12345); test_wrapper an_instance; verb& my_object = an_instance; THEN("oversampling defaults to 1 (off)") { REQUIRE(static_cast(my_object.oversampling) == 1); } + THEN("seed defaults to 1") { + REQUIRE(static_cast(my_object.seed) == 1); + } } - GIVEN("two default instances built from the same RNG seed") { - // With oversampling == 1 the resampler is bypassed entirely, so two identically-seeded - // instances must produce bit-identical output for the same input. + GIVEN("two default instances") { + // The deviated comb tuning is a pure function of (seed, parameters, sample rate), so two + // default instances must produce bit-identical output for the same input — no srand() + // arrangement required (the pre-seed wrapper drew from std::rand()). // Run long enough to clear the 100-sample limiter look-ahead latency and the ~4 ms first // early reflection so the reverb tail actually reaches the output. - std::srand(999); test_wrapper wrap_a; verb& obj_a = wrap_a; auto out_a = run_impulse(obj_a, 2048); - std::srand(999); test_wrapper wrap_b; verb& obj_b = wrap_b; auto out_b = run_impulse(obj_b, 2048); @@ -67,7 +68,6 @@ SCENARIO("tap.verb~ oversampling defaults to off and 1x is unchanged") { } GIVEN("an instance with oversampling raised to 4") { - std::srand(2024); test_wrapper an_instance; verb& my_object = an_instance; my_object.oversampling = 4; @@ -84,7 +84,6 @@ SCENARIO("tap.verb~ oversampling defaults to off and 1x is unchanged") { } GIVEN("an instance fed an out-of-range oversampling value") { - std::srand(7); test_wrapper an_instance; verb& my_object = an_instance; my_object.oversampling = 3; // not in {1,2,4,8} @@ -94,3 +93,64 @@ SCENARIO("tap.verb~ oversampling defaults to off and 1x is unchanged") { } } } + +SCENARIO("tap.verb~ is deterministic per seed — a seed is a serial number") { + ext_main(nullptr); + + GIVEN("two instances with the same non-default seed") { + test_wrapper wrap_a; + verb& obj_a = wrap_a; + obj_a.seed = 42; + test_wrapper wrap_b; + verb& obj_b = wrap_b; + obj_b.seed = 42; + + THEN("their impulse responses are bit-identical") { + auto out_a = run_impulse(obj_a, 2048); + auto out_b = run_impulse(obj_b, 2048); + for (size_t i = 0; i < out_a.size(); ++i) { + REQUIRE(out_a[i] == out_b[i]); + } + } + } + + GIVEN("two instances with different seeds") { + test_wrapper wrap_a; + verb& obj_a = wrap_a; + obj_a.seed = 1; + test_wrapper wrap_b; + verb& obj_b = wrap_b; + obj_b.seed = 2; + + THEN("their rooms differ (the deviated prime layouts are decorrelated)") { + auto out_a = run_impulse(obj_a, 4096); + auto out_b = run_impulse(obj_b, 4096); + bool any_difference = false; + for (size_t i = 0; i < out_a.size(); ++i) { + if (out_a[i] != out_b[i]) { + any_difference = true; + break; + } + } + REQUIRE(any_difference); + } + } + + GIVEN("one instance whose seed is re-set to the same value after processing") { + test_wrapper wrap_a; + verb& obj_a = wrap_a; + obj_a.seed = 7; + obj_a.dcblock = false; // clear() resets the reverb + limiter but not the DC + // blocker's one-pole state, so bit-compare without it + auto first = run_impulse(obj_a, 2048); + + THEN("clearing and re-seeding reproduces the identical tail") { + obj_a.seed = 7; // re-deviates from the same serial number + obj_a.clear(); + auto again = run_impulse(obj_a, 2048); + for (size_t i = 0; i < first.size(); ++i) { + REQUIRE(first[i] == again[i]); + } + } + } +} diff --git a/source/projects/tap.vocoder_tilde/tap.vocoder_tilde.cpp b/source/projects/tap.vocoder_tilde/tap.vocoder_tilde.cpp index 04999d03..740a0403 100644 --- a/source/projects/tap.vocoder_tilde/tap.vocoder_tilde.cpp +++ b/source/projects/tap.vocoder_tilde/tap.vocoder_tilde.cpp @@ -14,6 +14,13 @@ /// how they actually behave (a Q value and a millisecond time). A practical `gain` /// (linear makeup) attribute is added for level staging. /// +/// Two conveniences beyond the original object (see vocoder.h for the full contract): +/// `sibilance` blends a deterministic seeded noise source into the carrier path of the +/// bands above ~4 kHz (the classic unvoiced/sibilance excitation; `seed` decorrelates +/// instances), and `mix` is an equal-power blend of the dry *carrier* against the +/// vocoded output (the carrier is the program material; the modulator is an analysis +/// input). `bypass` and `mute` are wrapper-side, per the house idiom. +/// /// The DSP lives in the portable, Min-free kernel `tap::tools::vocoder::bank` (vocoder.h) /// — RBJ constant-0 dB-peak bandpass biquads with per-band envelope followers. This file /// is the Min wrapper. @@ -79,6 +86,44 @@ class vocoder : public object, public sample_operator<2, 1> { }}, description{"Linear makeup gain applied to the summed output."}}; + attribute sibilance{ + this, + "sibilance", + 0.0, + range{0.0, 1.0}, + setter{MIN_FUNCTION{ + m_bank.set_sibilance(args[0]); + return {args[0]}; + }}, + description{"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 (default) the output is identical to the " + "noise-free vocoder."}}; + + attribute mix{this, + "mix", + 100.0, + range{0.0, 100.0}, + setter{MIN_FUNCTION{ + m_bank.set_mix(args[0]); + return {args[0]}; + }}, + description{"Wet/dry mix in percent (0..100), equal-power. The dry side is the carrier " + "(the program material); 100 (default) is fully vocoded, 0 passes the " + "carrier through untouched."}}; + + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ + const int v = std::max(1, static_cast(args[0])); + m_bank.set_seed(static_cast(v)); + return {v}; + }}, + description{"Seed for the sibilance noise source (>= 1). The noise is deterministic per " + "seed; give mc. instances different seeds to decorrelate them."}}; + + attribute bypass{this, "bypass", false, description{"Pass the carrier through unprocessed."}}; + + attribute mute{this, "mute", false, description{"Silence the output."}}; + message<> clear{this, "clear", "Reset all filter and envelope-follower state.", MIN_FUNCTION{ m_bank.clear(); @@ -91,7 +136,15 @@ class vocoder : public object, public sample_operator<2, 1> { return {}; }}; - sample operator()(sample modulator, sample carrier) { return m_bank.process(modulator, carrier); } + sample operator()(sample modulator, sample carrier) { + if (mute) { + return 0.0; + } + if (bypass) { + return carrier; // the carrier is the program material — the vocoder's "input" + } + return m_bank.process(modulator, carrier); + } }; MIN_EXTERNAL(vocoder); diff --git a/source/projects/tap.vocoder_tilde/tap.vocoder_tilde_test.cpp b/source/projects/tap.vocoder_tilde/tap.vocoder_tilde_test.cpp index 8703ac13..fac1e0b3 100644 --- a/source/projects/tap.vocoder_tilde/tap.vocoder_tilde_test.cpp +++ b/source/projects/tap.vocoder_tilde/tap.vocoder_tilde_test.cpp @@ -20,6 +20,39 @@ SCENARIO("tap.vocoder~ has the documented defaults") { REQUIRE(static_cast(my_object.response_interval) == 20.0); REQUIRE(static_cast(my_object.gain) == 1.0); } + THEN("sibilance defaults to 0, mix to 100 (fully vocoded), seed to 1, bypass and mute off") { + REQUIRE(static_cast(my_object.sibilance) == 0.0); + REQUIRE(static_cast(my_object.mix) == 100.0); + REQUIRE(static_cast(my_object.seed) == 1); + REQUIRE(static_cast(my_object.bypass) == false); + REQUIRE(static_cast(my_object.mute) == false); + } + } +} + +SCENARIO("tap.vocoder~ bypasses and mutes wrapper-side") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + vocoder& my_object = an_instance; + + WHEN("bypass is on") { + my_object.bypass = true; + + THEN("the carrier passes through bitwise, whatever the modulator does") { + REQUIRE(my_object(1.0, 0.5) == 0.5); + REQUIRE(my_object(0.0, -0.25) == -0.25); + } + } + WHEN("mute is on") { + my_object.mute = true; + + THEN("the output is silent") { + REQUIRE(my_object(1.0, 0.5) == 0.0); + REQUIRE(my_object(0.5, -1.0) == 0.0); + } + } } } diff --git a/submodules/taptools b/submodules/taptools index 37c6d331..d64e8c4a 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit 37c6d3314187bca7e492a06e4eaca83c72b9b827 +Subproject commit d64e8c4a438383411631dd4fd1c4f31e5727655d