From 6c68caec1eda1bed4013d9ffb36bdad4164b11c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 12:51:48 +0000 Subject: [PATCH 1/6] Repoint the taptools pin at main after the kernel PR merged tap/TapTools#27 landed on main by rebase; main's head d878622 carries the identical tree to the branch commit this pin referenced, so the pin moves there per the release flow and stays reachable after branch cleanup. No content change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6CC6kpK5Lx2YkJWuNNQpw --- submodules/taptools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/taptools b/submodules/taptools index 37c6d331..d8786222 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit 37c6d3314187bca7e492a06e4eaca83c72b9b827 +Subproject commit d8786222c43022b5aad6433af0ab5f3ebe740ceb From c9df879972370fbaa0da3beaa7f3d537cd2b7aed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:30:13 +0000 Subject: [PATCH 2/6] Recipes-audit sweep: symbolic enums, determinism seeds, QoL fixes, maxref drift, vco performance attrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements improvements-plan §3, §4, §7, §8 plus the pending tap.vco~ performance attributes: - §3 tap.ladder~ / tap.diode~: mode/solver become attribute with a numeric-compatible setter (the tap.svf~ house pattern) — '@mode lp24' and '@mode 0' both work, getters report the symbol. min-api's native attribute (enum_map) pattern stores/reports the index and trips the tap.crossfade~/tap.pan~ GCC lesson (REVIVAL.md 9.5), so it is deliberately not used. Maxrefs updated; wrapper tests extended to pin both spellings. - §4 determinism: tap.noise~ drops std::random_device for a seed attribute (int >= 1, default 1; seed 1 reproduces the legacy TTNoise LCG sequence bit for bit, and the gaussian path is seeded too). tap.verb~ replaces std::rand() in deviate() with a seeded LCG (state*1664525+1013904223) restarted from the new seed attribute on every configure, making the comb tuning a pure function of (seed, parameters, sample rate); the right core seeds at a golden-ratio offset so stereo decorrelation survives. New determinism test scenarios pin same-seed identity, different-seed decorrelation, and re-seed reproducibility. - §7: tap.sustain~ length gets a real setter (stores truthfully; the ring re-sizes at the next dspsetup via update_samplerate(), documented in the attribute description). tap.crossfade~ mode marked deprecated/no-op in its maxref (legacy compatibility only); wrapper code untouched. - §8 maxref drift: tap.verb~ rewritten to the wrapper's real surface (er, not use_early_reflections; all 19 attributes with ranges/defaults); tap.vocoder~ q/response_interval documented with the wrapper's numeric ranges; tap.sustain~ rewritten to the real surface (length, voices 1-5, fade, rise, bang capture, clear, round-robin stealing); tap.noise~ brought up to the full surface while adding seed. - tap.vco~: vibrato (cents 0..100), vibrato_rate (Hz 0.05..20), vibrato_delay (ms 0..5000, onset re-armed per note change), and bend (semitones -24..24, rides the smooth ramp) forwarded to the kernel's set_vibrato/set_vibrato_rate/set_vibrato_delay/set_bend (param indices 9-12); maxref updated. Requires the kernel pin bump that carries those setters. All six touched wrapper test suites built and passed against the mock kernel (GCC, C++20, sibling kernel checkout). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6CC6kpK5Lx2YkJWuNNQpw --- docs/tap.crossfade~.maxref.xml | 5 +- docs/tap.diode~.maxref.xml | 9 +- docs/tap.ladder~.maxref.xml | 16 +- docs/tap.noise~.maxref.xml | 61 ++++++-- docs/tap.sustain~.maxref.xml | 109 ++++++++------ docs/tap.vco~.maxref.xml | 22 ++- docs/tap.verb~.maxref.xml | 142 ++++++++++++++---- docs/tap.vocoder~.maxref.xml | 11 +- .../tap.diode_tilde/tap.diode_tilde.cpp | 48 ++++-- .../tap.diode_tilde/tap.diode_tilde_test.cpp | 12 +- .../tap.ladder_tilde/tap.ladder_tilde.cpp | 71 ++++++--- .../tap.ladder_tilde_test.cpp | 16 +- .../tap.noise_tilde/tap.noise_tilde.cpp | 24 ++- .../tap.noise_tilde/tap.noise_tilde_test.cpp | 63 ++++++++ .../tap.sustain_tilde/tap.sustain_tilde.cpp | 14 +- .../projects/tap.vco_tilde/tap.vco_tilde.cpp | 15 +- .../tap.vco_tilde/tap.vco_tilde_test.cpp | 8 + .../tap.verb_tilde/tap.verb_tilde.cpp | 65 ++++++-- .../tap.verb_tilde/tap.verb_tilde_test.cpp | 84 +++++++++-- 19 files changed, 631 insertions(+), 164 deletions(-) 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.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.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.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..01005781 100644 --- a/docs/tap.vocoder~.maxref.xml +++ b/docs/tap.vocoder~.maxref.xml @@ -56,13 +56,18 @@ - 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). 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.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.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]); + } + } + } +} From 26186f63d6865e92d49afab68eac652635f52158 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:32:03 +0000 Subject: [PATCH 3/6] Bump the taptools pin to the kernel improvements wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel branch head carries the vco performance section (p_vibrato through p_bend), the vocoder sibilance/mix path, the harmonizer preset morph, and the new delay pair — everything the wrapper wave in flight compiles against. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6CC6kpK5Lx2YkJWuNNQpw --- submodules/taptools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/taptools b/submodules/taptools index d8786222..615baf23 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit d8786222c43022b5aad6433af0ab5f3ebe740ceb +Subproject commit 615baf23873f082de5bd08d90ed7b1e7c8e40959 From b0f9d905b3b18dc892d0342d615735b595519f94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:36:46 +0000 Subject: [PATCH 4/6] Rebuild tap.delay~/tap.multitap~ on the delay kernel; grow tap.vocoder~/tap.harmony~ surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tap.delay~: rebuilt on tap::tools::delay::line — feedback (0..0.99, DC-blocked), equal-power mix (default 100 = the old wet-only output), interp (1 Hermite default / 0 legacy bit-compat truncation), kernel slews on every parameter. BREAKING by design: a time signal in the right inlet always wins, including at 0.0 (the old "0.0 means use the attribute" trap dies); float in the right inlet now works too. tap.multitap~: rebuilt on tap::tools::delay::multitap — same buffersize/taps/delay/gain(dB) surface plus per-tap pan (-1..1, default center) and interp, now summing to a stereo outlet pair (a center-panned tap feeds both equally; the old mono out is the mix of the two). tap.vocoder~: sibilance (0..1), mix (0..100), seed (>=1) forwarded to the kernel; bypass/mute wrapper-side per the house idiom. tap.harmony~: store/recall preset messages (slots 1..16, optional morph time defaulting to the new interp attribute, the tap.vco~ idiom); bypass/mute wrapper-side. Docs updated for all four surfaces (vocoder: attribute entries appended only — the q/response_interval fix is in flight elsewhere). Wrapper tests rewritten for the new kernel-backed semantics; the signal-wins path needs a real patch cord and is deferred to runtime-tests (mock presents inlets as unconnected). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6CC6kpK5Lx2YkJWuNNQpw --- docs/tap.delay~.maxref.xml | 74 +++++++- docs/tap.harmony~.maxref.xml | 35 ++++ docs/tap.multitap~.maxref.xml | 81 ++++++--- docs/tap.vocoder~.maxref.xml | 15 ++ .../projects/tap.delay_tilde/CMakeLists.txt | 5 +- .../tap.delay_tilde/tap.delay_tilde.cpp | 162 ++++++++--------- .../tap.delay_tilde/tap.delay_tilde_test.cpp | 148 ++++++++++------ .../tap.harmony_tilde/tap.harmony_tilde.cpp | 46 ++++- .../tap.multitap_tilde/CMakeLists.txt | 9 +- .../tap.multitap_tilde/tap.multitap_tilde.cpp | 163 ++++++++---------- .../tap.multitap_tilde_test.cpp | 129 ++++++++------ .../tap.vocoder_tilde/tap.vocoder_tilde.cpp | 55 +++++- .../tap.vocoder_tilde_test.cpp | 33 ++++ 13 files changed, 655 insertions(+), 300 deletions(-) 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.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.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.vocoder~.maxref.xml b/docs/tap.vocoder~.maxref.xml index 01005781..b2afaffd 100644 --- a/docs/tap.vocoder~.maxref.xml +++ b/docs/tap.vocoder~.maxref.xml @@ -69,6 +69,21 @@ 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/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.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.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.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); + } + } } } From fd8cf30ddd5b06f5c82efce2587aacc422cb64bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:38:36 +0000 Subject: [PATCH 5/6] Close out the improvements-wave records The plan's sections flip to shipped with their findings recorded (the min-api enum-attribute limitation, the noise seed-1 bit-identity, the verb offset-seeded right core, the delay breaking change), the discovery log gains the wave's new rows (verb clear misses the DC-blocker state; help patchers pending; 5comb notes message still open), and REVIVAL gains entry 15 summarizing the wave. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6CC6kpK5Lx2YkJWuNNQpw --- REVIVAL.md | 13 +++++++++ plans/recipes-improvements.md | 55 ++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 7 deletions(-) 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/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 | From 8233d6ba22dc0235f799bf0ab094e8d8d23d5a92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:11:02 +0000 Subject: [PATCH 6/6] Repoint the taptools pin at main after the kernel wave merged tap/TapTools#28 landed on main by rebase; main's head d64e8c4 carries the identical tree to the branch commit this pin referenced (verified by tree hash), so the pin moves there per the release flow and stays reachable after branch cleanup. No content change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6CC6kpK5Lx2YkJWuNNQpw --- submodules/taptools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/taptools b/submodules/taptools index 615baf23..d64e8c4a 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit 615baf23873f082de5bd08d90ed7b1e7c8e40959 +Subproject commit d64e8c4a438383411631dd4fd1c4f31e5727655d