diff --git a/book/src/recipes/minimoog.md b/book/src/recipes/minimoog.md index d3673fa..de4e3aa 100644 --- a/book/src/recipes/minimoog.md +++ b/book/src/recipes/minimoog.md @@ -64,9 +64,8 @@ patches alone: All three: `@shape 2` (saw), `@jitter 3 @track 2 @imperfect 0.3`, and `smooth` per the patch below. Sum through `*~ 0.36` (≈ 1/2.8, headroom for -three voices), then `tap.ladder~` at the chapter's voicing: `@mode 0` (the -lp24 response — the mode attribute is the numeric pole-mix index) -`@resonance 0.35 @drive 9 @asym 0.45 @comp 0.25`. Keeping `comp` low +three voices), then `tap.ladder~` at the chapter's voicing: +`@mode lp24 @resonance 0.35 @drive 9 @asym 0.45 @comp 0.25`. Keeping `comp` low preserves the authentic passband droop; `drive 9` sits where the ladder notebook measures the tanh stages just starting to thicken (3.5 % THD at 8 dB). Spend the character budget in the filter first — the chapter's diff --git a/book/src/recipes/moog-classics.md b/book/src/recipes/moog-classics.md index ba6f029..cbd5b3c 100644 --- a/book/src/recipes/moog-classics.md +++ b/book/src/recipes/moog-classics.md @@ -16,11 +16,15 @@ Every patch below is a delta against the wiring and tables of [Three oscillators into a ladder](minimoog.md) — build that voice first. Two performance tools recur, so here they are once: -- **Vibrato** goes into each `tap.vco~`'s FM inlet, which is calibrated in - Hz. For vibrato that reads as a constant musical width, scale the LFO by - the note: `cycle~ 5.5` multiplied by 0.006 × the note's frequency is - about ±10 cents. Fade the LFO in with `line~` a beat after note-on — the - delayed vibrato is most of what makes a lead "sing." +- **Vibrato** is the oscillator's own now: `@vibrato` (depth in cents, so + the musical width holds in every register), `@vibrato_rate` (Hz), and + `@vibrato_delay` (ms) — the onset fades in through that time constant + and re-arms on every new note, which is most of what makes a lead + "sing." ±10 cents at 5.5 Hz with a few hundred milliseconds of delay is + the classic setting. (This chapter's first draft had to print a + scaling formula into the Hz-calibrated FM inlet here; that formula + became the improvements plan's §2, and §2 became these attributes — + the audit worked.) - **Sequenced lines**: `tap.303.seq~` emits pitch as a MIDI-note signal and a gate at 1.0/2.0 — `mtof~` turns the pitch into Hz for the oscillators' signal inlets, and the gate drives `tap.adsr~` directly (it opens above @@ -96,9 +100,9 @@ Deltas from the lead patch: | filter contour | `@attack 30 @decay 900 @sustain -10 @release 700`, amount 3000 Hz, base 250 Hz | | loudness contour | `@attack 8 @decay 300 @sustain -2 @release 500` | -Then spend all your effort on the vibrato: 5.5 Hz, ±10 cents (the intro's -formula), faded in over ~400 ms after each phrase begins, and *not* on -every note. The patch is deliberately close to the ideal oscillator — +Then spend all your effort on the vibrato: `@vibrato 10 @vibrato_rate 5.5 +@vibrato_delay 400` — ten cents, arriving late, re-arming on each new +note so held phrase-endings bloom while passing notes stay plain. The patch is deliberately close to the ideal oscillator — `imperfect 0.2`, drift at the polite end — because the expressive load is carried by the hands, and everything the analog section adds here it adds to sustained exposed notes. diff --git a/book/src/recipes/robot-voice.md b/book/src/recipes/robot-voice.md index 0dec2b2..235c4e5 100644 --- a/book/src/recipes/robot-voice.md +++ b/book/src/recipes/robot-voice.md @@ -28,11 +28,12 @@ tap.noise~ (white) ─ *~ 0.1┘ the [Moog recipe's](minimoog.md) stack values; skip the octave-down voice — vocoded speech reads clearest with the energy above the fundamental. -- **A tenth of white noise** (`tap.noise~ @mode white` through `*~ 0.1`): - this is the *s* and *t* budget. The object has no unvoiced/sibilance - path of its own, so the noise rides the carrier full-time and the - modulator's high-band envelopes gate it into consonants exactly when - needed. +- **The *s* and *t* budget.** `@sibilance 0.3` is the built-in version — + a seeded noise source in the top bands' carrier, gated by the + modulator's high-band envelopes, arriving exactly when consonants do. + The manual alternative (a tenth of `tap.noise~` summed into the + carrier) remains the craftier option when you want to choose the noise + color yourself. - **Pitch is the performance.** The vocoder never changes the carrier's pitch, so the carrier's notes are the melody. Held chords (an `mc.` stack of carriers) make the robot a choir; a single line makes it a diff --git a/book/src/recipes/sequenced-modular.md b/book/src/recipes/sequenced-modular.md index 3b6fc73..7637895 100644 --- a/book/src/recipes/sequenced-modular.md +++ b/book/src/recipes/sequenced-modular.md @@ -75,17 +75,16 @@ Then the two moves that carry twenty minutes: |---|---| | loudness `tap.adsr~` | `@attack 2 @decay 180 @sustain -12 @release 120` | | filter `tap.adsr~` | `@attack 2 @decay 160 @sustain -20 @release 160`, amount 1800 Hz, base 150 Hz | -| ladder | `@mode 0 @resonance 0.3 @drive 6` | +| ladder | `@mode lp24 @resonance 0.3 @drive 6` | | slew (`slide~`) | short; raise it only for deliberate swoops | | seq | `@swing 0` — the genre is a grid, and the delay does the humanizing | Two period tricks worth their lines: pan alternate notes (a `length 8` row of accents driving `tap.pan~` recreates the famous ping-pong doubling), and -put an eighth-note delay after the voice — the echo, not the sequencer, is -where these records' motion lives. Note `tap.delay~` is a plain -integer-sample delay (no feedback, no interpolation), so use it for the -single slap and patch feedback around it, or reach for Max's delay objects -for modulated regeneration. +put an eighth-note `tap.delay~` after the voice (`@feedback 40 @mix 30`) — +the echo, not the sequencer, is where these records' motion lives. Since +its rebuild the delay interpolates (Hermite) and regenerates through a +DC-blocked loop; `@interp 0` remains the bit-faithful legacy mode. Glue: an 808 closed-hat row in 16ths from the drum scaffold, mixed low. Accents land in this scaffold too: turn up `tap.adsr~`'s `velocity` diff --git a/book/src/vco.md b/book/src/vco.md index 45f308e..1e0548e 100644 --- a/book/src/vco.md +++ b/book/src/vco.md @@ -199,6 +199,32 @@ tolerances** so each seed misbehaves in its own direction: point to in a mix; 1.0 is a unit with a story. At 0, every seed is the same ideal machine — the analog section never costs you the reference oscillator. +## The performance section + +Where the analog section models what the circuit does on its own, these +controls model what a hand does to it — added after the Recipes chapters +had to teach a scaling formula to get constant-width vibrato out of the +Hz-calibrated FM inlet. + +- **`vibrato` / `vibrato_rate`** — a sine LFO on the pitch, depth in + **cents** (0–100) and rate in Hz, so ten cents is ten cents in every + register. Measured: at a commanded ±100 cents the peak cycle-to-cycle + deviation reads 90–110 cents, and the modulation crosses its mean at + exactly twice the commanded rate (pinned by test). +- **`vibrato_delay`** — the singing control: the vibrato fades in through + a one-pole with this time constant (ms), **re-armed on every new note** + (every frequency-target change), so held notes bloom and passing notes + stay plain. Pinned: early deviation under 60 % of settled, and shallow + again right after a note change. The signal-rate frequency inlet + deliberately does not re-arm — there, you are the modulation. +- **`bend`** — pitch bend in semitones (±24), riding the standard `smooth` + ramp: the wheel, as an attribute. Pinned within 5 cents of the + commanded interval. + +All of it is deterministic with no randomness — and at depth 0 the output +is **bit-identical** to the ideal oscillator (pinned), so the reference +instrument is still free. + ## The Moog recipe, honestly The sound everyone wants from this object is three oscillators into a ladder. diff --git a/book/src/vocoder.md b/book/src/vocoder.md index 5ba40c0..f5a1642 100644 --- a/book/src/vocoder.md +++ b/book/src/vocoder.md @@ -62,6 +62,24 @@ pads — the "choir" setting. This knob is the vocoder's attack *and* release; Makeup level, since a band-multiplied signal usually lands quieter than either input. Linear, boring, necessary. +### `sibilance` — the built-in *s* and *t* budget + +The classic channel-vocoder unvoiced path (Dudley's lineage): a seeded +internal noise source blended into the **carrier** of the bands above +~4 kHz, still gated by the modulator's envelopes — so consonants articulate +even over a dull carrier, and only when the modulator actually has +high-band energy (pinned: a silent carrier with an HF-rich modulator +speaks at `sibilance 1`; a low-only modulator stays quiet). At the default +0 the original silent-carrier contract holds exactly, bit-identical — +turning it up deliberately relaxes that contract for the top bands. The +noise is deterministic per `seed`, family doctrine. + +### `mix` — the synth under its own robot voice + +Equal-power blend of the dry **carrier** against the vocoded output — the +classic parallel move (the pad fades in under itself talking). Endpoints +are exact: 100 is bit-identical wet, 0 returns the carrier untouched. + ## Choosing the two signals (the actual craft) - **The carrier must have energy where the modulator has bands.** The eternal diff --git a/include/taptools/delay.h b/include/taptools/delay.h new file mode 100644 index 0000000..284d270 --- /dev/null +++ b/include/taptools/delay.h @@ -0,0 +1,419 @@ +/// @file +/// @brief Portable delay kernels for tap.delay~ (line) and tap.multitap~ (multitap) — no +/// Max/Min dependency. +/// @details A kernel-first rebuild of the 1999-lineage delay pair. The legacy objects wrapped +/// ttblue's tt_delay / tt_multitap: integer-sample delays computed by *truncation* +/// (`delay_samples = long(delay_ms * sr / 1000)`), control-rate parameter stepping, +/// a hard-clipped feedback path, and a mono tap sum. This rebuild keeps the two +/// surfaces but modernizes the plumbing to the house pattern: +/// +/// - Delays are FRACTIONAL by default (4-point, 3rd-order Hermite — the same read the +/// family uses in grm_comb.h), standard fractional-delay practice (Laakso, Valimaki, +/// Karjalainen, Laine, "Splitting the Unit Delay", IEEE SP Mag. 1996). `set_interp(0)` +/// restores the legacy integer-sample truncation bit-for-bit for A/B against the old +/// binaries; `set_interp(1)` (default) is the modern path. +/// - Every continuous parameter (time, feedback, mix, per-tap time/gain/pan) rides a +/// per-sample linear ramp — no zippers (the param ramp house pattern, grm_comb.h). +/// - The feedback loop carries a DC blocker normalized to unity peak gain (the +/// grm_comb.h house pattern: the raw (1 - z^-1)/(1 - R z^-1) peaks at 2/(1+R) > 1 at +/// Nyquist), and feedback is capped at k_fb_max = 0.99, so the loop is strictly +/// contractive at every setting: a DC step recirculates once and then decays instead +/// of accumulating. +/// - line's dry/wet mix (0..100) and multitap's per-tap pan (-1..1) are equal-power +/// (cos/sin quarter-cycle), with the endpoints snapped exactly (mix 0 is bitwise dry, +/// mix 100 bitwise wet; pan -1 is bitwise silent on the right). +/// +/// Geometry: prepare(sr, max_ms) buys the worst case — one buffer of +/// ceil(max_ms * sr / 1000) + 4 samples per line (one shared buffer serves all 100 +/// multitap taps). No later call allocates; setters only retarget ramps and are safe +/// while audio runs. All processing is double-precision, per-sample. +/// +/// Honest limits: +/// - The structure reads before it writes (feedback needs the read first), so a +/// zero-time tap is unreachable: truncation mode floors at 1 sample, Hermite mode at +/// k_min_frac_delay (2.5) samples (~52 us at 48 kHz — the interpolator needs its +/// youngest support point already written). +/// - The signal-rate override process(in, time_ms) snaps the time ramp to the incoming +/// value each sample (the host's signal is assumed already smooth); it cancels any +/// pending set_time_ms slew. +/// - set_interp switches instantly and can click mid-signal; it is a configuration +/// choice, not a performance control. +/// - Truncation mode with a moving time zipper-steps by whole samples *by design* +/// (that is the bit-compat contract); Hermite mode is the fix, not a smoother +/// truncation. +/// - Feedback is a plain repeat-echo coefficient (cap 0.99), not a calibrated ring +/// time — for RT60-mapped resonance see grm_comb.h. +/// - multitap sums up to 100 unity-capable taps with no dry path and no master gain; +/// output can exceed unity and gain staging is the caller's job. +/// @author Timothy Place +// SPDX-License-Identifier: MIT +// Copyright 2003-2026 Timothy Place. + +#pragma once + +#include +#include +#include +#include +#include + +namespace tap::tools { + namespace delay { + + constexpr int k_max_taps = 100; // tap.multitap~'s MAX_NUM_TAPS since 2004 + constexpr double k_fb_max = 0.99; // feedback cap (loop strictly contractive) + constexpr double k_min_frac_delay = 2.5; // Hermite floor: support points must be written + constexpr long k_min_int_delay = 1; // truncation floor: read-before-write structure + constexpr double k_dc_block_r = 0.999; // in-loop DC blocker pole (~7 Hz corner @ 48k) + // Normalized to unity peak gain so the loop gain is bounded by fb alone (grm_comb.h). + constexpr double k_dc_block_norm = (1.0 + k_dc_block_r) * 0.5; + constexpr double k_default_smooth_ms = 20.0; // anti-zipper ramp for setters + constexpr double k_pi = 3.14159265358979323846; + + enum interp_mode : int { + interp_trunc = 0, // legacy integer-sample truncation (bit-compat with tt_delay) + interp_hermite = 1 // 4-point 3rd-order Hermite fractional taps (default) + }; + + /// Per-sample linear parameter ramp — the anti-zipper unit every setter retargets. + class ramp { + public: + void snap(double v) { + m_current = m_target = v; + m_inc = 0.0; + m_remaining = 0; + } + + void to(double tgt, long nsamples) { + if (nsamples < 1 || tgt == m_current) { + snap(tgt); + } + else { + m_target = tgt; + m_inc = (tgt - m_current) / static_cast(nsamples); + m_remaining = nsamples; + } + } + + double tick() { + if (m_remaining > 0) { + m_current += m_inc; + if (--m_remaining == 0) { + m_current = m_target; + } + } + return m_current; + } + + double current() const { return m_current; } + double target() const { return m_target; } + + private: + double m_current{0.0}; + double m_target{0.0}; + double m_inc{0.0}; + long m_remaining{0}; + }; + + /// Circular delay buffer shared by both kernels: write head plus Hermite / truncated reads. + class delay_buffer { + public: + /// Allocate for the worst case; +4 samples of headroom for the Hermite support points. + void prepare(double sr, double max_ms) { + m_max_samples = std::max(8.0, std::ceil(std::max(0.0, max_ms) * 0.001 * sr)); + m_buffer.assign(static_cast(m_max_samples) + 4, 0.0); + m_write = 0; + } + + void clear() { std::fill(m_buffer.begin(), m_buffer.end(), 0.0); } + + bool prepared() const { return !m_buffer.empty(); } + double max_samples() const { return m_max_samples; } + + void write(double x) { + m_buffer[m_write] = x; + if (++m_write >= m_buffer.size()) { + m_write = 0; + } + } + + /// 4-point, 3rd-order Hermite, d samples behind the write head; needs d > 2 strictly + /// (clamped to k_min_frac_delay by the callers). Same read as grm_comb.h. + double read_hermite(double d) const { + const double pos = static_cast(m_write) - d; + const double fpos = std::floor(pos); + const double frac = pos - fpos; + const long base = static_cast(fpos); + const double xm1 = m_buffer[wrap(base - 1)]; + const double x0 = m_buffer[wrap(base)]; + const double x1 = m_buffer[wrap(base + 1)]; + const double x2 = m_buffer[wrap(base + 2)]; + const double c = (x1 - xm1) * 0.5; + const double v = x0 - x1; + const double w = c + v; + const double a = w + v + (x2 - x0) * 0.5; + const double b = w + a; + return (((a * frac - b) * frac + c) * frac + x0); + } + + /// Integer-sample read, d whole samples behind the write head (legacy truncation path). + double read_int(long d) const { return m_buffer[wrap(static_cast(m_write) - d)]; } + + private: + size_t wrap(long i) const { + const long n = static_cast(m_buffer.size()); + return static_cast(((i % n) + n) % n); + } + + std::vector m_buffer; + size_t m_write{0}; + double m_max_samples{0.0}; + }; + + /// Single feedback delay — the kernel behind tap.delay~. + class line { + public: + // -- lifecycle ----------------------------------------------------------------------- + + /// (Re)allocate the buffer for `max_ms` at `sr`, snap all ramps (a DSP restart is not + /// a parameter move), and clear signal state. Not real-time-safe. + void prepare(double sr, double max_ms) { + m_sr = (sr > 0.0) ? sr : 48000.0; + m_buffer.prepare(m_sr, max_ms); + m_time.snap(m_time.target()); + m_feedback.snap(m_feedback.target()); + m_mix.snap(m_mix.target()); + clear(); + } + + /// Zero the delay line and the loop filter state; parameters are untouched. + void clear() { + m_buffer.clear(); + m_dc_x1 = m_dc_y1 = 0.0; + } + + // -- parameter targets (click-free; safe while audio runs) --------------------------- + + /// Delay time in ms, slewed. Floors at 1 sample (truncation) / 2.5 samples (Hermite); + /// ceiling is the prepared max_ms. + void set_time_ms(double ms) { m_time.to(std::max(0.0, ms), smooth_samples()); } + + /// Feedback coefficient, clamped to [0, k_fb_max], slewed. + void set_feedback(double fb) { m_feedback.to(std::clamp(fb, 0.0, k_fb_max), smooth_samples()); } + + /// Dry/wet mix 0..100, equal-power, slewed. 0 is bitwise dry, 100 bitwise wet. + void set_mix(double pct) { m_mix.to(std::clamp(pct, 0.0, 100.0), smooth_samples()); } + + /// interp_trunc (0) or interp_hermite (1, default). Switches instantly — may click. + void set_interp(int mode) { m_interp = (mode == interp_trunc) ? interp_trunc : interp_hermite; } + + /// Anti-zipper ramp time for the setters, in ms. 0 = instant (useful for tests). + void set_smooth_ms(double ms) { m_smooth_ms = std::max(0.0, ms); } + + // -- introspection ------------------------------------------------------------------- + + double time_ms() const { return m_time.target(); } + double feedback() const { return m_feedback.target(); } + double mix() const { return m_mix.target(); } + int interp() const { return m_interp; } + double smooth_ms() const { return m_smooth_ms; } + double max_ms() const { return m_buffer.max_samples() * 1000.0 / m_sr; } + double samplerate() const { return m_sr; } + + // -- audio --------------------------------------------------------------------------- + + /// Message-rate time: the slewed set_time_ms target drives the tap. + double process(double in) { return tick(in, m_time.tick()); } + + /// Signal-rate time override: `time_ms` drives the tap directly this sample (and snaps + /// the time ramp so a later message-rate move continues from here without a jump). + double process(double in, double time_ms) { + m_time.snap(std::max(0.0, time_ms)); + return tick(in, m_time.current()); + } + + private: + double tick(double in, double time_ms) { + if (!m_buffer.prepared()) { + return in; + } + const double fb = m_feedback.tick(); + const double mix = m_mix.tick(); + + const double d_samples = time_ms * 0.001 * m_sr; + double delayed = 0.0; + if (m_interp == interp_hermite) { + delayed = m_buffer.read_hermite(std::clamp(d_samples, k_min_frac_delay, m_buffer.max_samples())); + } + else { + // Legacy truncation: long(ms * sr / 1000), exactly tt_delay's k_delay_ms map. + const long d = std::clamp(static_cast(d_samples), k_min_int_delay, + static_cast(m_buffer.max_samples())); + delayed = m_buffer.read_int(d); + } + + // Feedback path: delayed -> normalized DC blocker -> * fb -> back into the line. + const double dc_out = k_dc_block_norm * (delayed - m_dc_x1) + k_dc_block_r * m_dc_y1; + m_dc_x1 = delayed; + m_dc_y1 = anti_denormal(dc_out); + + m_buffer.write(anti_denormal(in + fb * dc_out)); + + // Equal-power dry/wet with exact endpoints (mix 0 == in bitwise, 100 == wet bitwise). + if (mix <= 0.0) { + return in; + } + if (mix >= 100.0) { + return delayed; + } + const double theta = mix * 0.01 * (k_pi * 0.5); + return std::cos(theta) * in + std::sin(theta) * delayed; + } + + static double anti_denormal(double x) { return (std::abs(x) < 1e-15) ? 0.0 : x; } + + long smooth_samples() const { return static_cast(m_smooth_ms * 0.001 * m_sr); } + + double m_sr{48000.0}; + double m_smooth_ms{k_default_smooth_ms}; + int m_interp{interp_hermite}; + delay_buffer m_buffer; + ramp m_time; // ms + ramp m_feedback; // 0..k_fb_max + ramp m_mix; // 0..100 + double m_dc_x1{0.0}, m_dc_y1{0.0}; // DC blocker: y = norm*(x - x1) + R*y1 + }; + + /// Up to 100 feedforward taps off one shared line, each with slewed time / linear gain / + /// equal-power pan, summed to stereo — the kernel behind tap.multitap~. + class multitap { + public: + multitap() { + for (auto& t : m_taps) { + t.gain.snap(1.0); + } + } + + // -- lifecycle ----------------------------------------------------------------------- + + /// (Re)allocate the shared buffer for `max_ms` at `sr`, snap all ramps, clear state. + /// Not real-time-safe. + void prepare(double sr, double max_ms) { + m_sr = (sr > 0.0) ? sr : 48000.0; + m_buffer.prepare(m_sr, max_ms); + for (auto& t : m_taps) { + t.time.snap(t.time.target()); + t.gain.snap(t.gain.target()); + t.pan.snap(t.pan.target()); + } + clear(); + } + + void clear() { m_buffer.clear(); } + + // -- parameter targets (click-free; safe while audio runs) --------------------------- + + /// Number of active taps, clamped to [0, k_max_taps]. Newly activated taps come in at + /// their stored targets (defaults: time 0 -> the delay floor, gain 1, pan center). + void set_taps(int count) { m_num_taps = std::clamp(count, 0, k_max_taps); } + + /// Per-tap delay time in ms, slewed; same floors/ceiling as line. `tap` is 0-based. + void set_time_ms(int tap, double ms) { + if (valid_tap(tap)) { + m_taps[static_cast(tap)].time.to(std::max(0.0, ms), smooth_samples()); + } + } + + /// Per-tap linear gain, slewed. Unclamped (negative flips polarity, like a mixer). + void set_gain(int tap, double gain) { + if (valid_tap(tap)) { + m_taps[static_cast(tap)].gain.to(gain, smooth_samples()); + } + } + + /// Per-tap equal-power pan, -1 (hard left) .. 1 (hard right), slewed. Endpoints are + /// exact: pan -1 contributes bitwise zero to the right bus (and mirrored). + void set_pan(int tap, double pan) { + if (valid_tap(tap)) { + m_taps[static_cast(tap)].pan.to(std::clamp(pan, -1.0, 1.0), smooth_samples()); + } + } + + /// interp_trunc (0) or interp_hermite (1, default), for all taps. May click on switch. + void set_interp(int mode) { m_interp = (mode == interp_trunc) ? interp_trunc : interp_hermite; } + + void set_smooth_ms(double ms) { m_smooth_ms = std::max(0.0, ms); } + + // -- introspection ------------------------------------------------------------------- + + int taps() const { return m_num_taps; } + double time_ms(int tap) const { + return valid_tap(tap) ? m_taps[static_cast(tap)].time.target() : 0.0; + } + double gain(int tap) const { return valid_tap(tap) ? m_taps[static_cast(tap)].gain.target() : 0.0; } + double pan(int tap) const { return valid_tap(tap) ? m_taps[static_cast(tap)].pan.target() : 0.0; } + int interp() const { return m_interp; } + double smooth_ms() const { return m_smooth_ms; } + double samplerate() const { return m_sr; } + + // -- audio --------------------------------------------------------------------------- + + /// Sum the active taps to the stereo bus. Pure feedforward — no dry path, no feedback. + void process(double in, double& out_left, double& out_right) { + out_left = 0.0; + out_right = 0.0; + if (!m_buffer.prepared()) { + return; + } + for (int i = 0; i < m_num_taps; ++i) { + tap_state& t = m_taps[static_cast(i)]; + const double d_samples = t.time.tick() * 0.001 * m_sr; + double delayed = 0.0; + if (m_interp == interp_hermite) { + delayed = + m_buffer.read_hermite(std::clamp(d_samples, k_min_frac_delay, m_buffer.max_samples())); + } + else { + const long d = std::clamp(static_cast(d_samples), k_min_int_delay, + static_cast(m_buffer.max_samples())); + delayed = m_buffer.read_int(d); + } + const double g = t.gain.tick() * delayed; + const double pan = t.pan.tick(); + // Equal-power with exact endpoints (a hard-panned tap is bitwise absent from + // the far bus). + if (pan <= -1.0) { + out_left += g; + } + else if (pan >= 1.0) { + out_right += g; + } + else { + const double theta = (pan + 1.0) * 0.25 * k_pi; + out_left += std::cos(theta) * g; + out_right += std::sin(theta) * g; + } + } + m_buffer.write(in); + } + + private: + struct tap_state { + ramp time; // ms + ramp gain; // linear + ramp pan; // -1..1 + }; + + bool valid_tap(int tap) const { return tap >= 0 && tap < k_max_taps; } + long smooth_samples() const { return static_cast(m_smooth_ms * 0.001 * m_sr); } + + double m_sr{48000.0}; + double m_smooth_ms{k_default_smooth_ms}; + int m_interp{interp_hermite}; + int m_num_taps{0}; + delay_buffer m_buffer; + std::array m_taps; + }; + + } // namespace delay +} // namespace tap::tools diff --git a/include/taptools/harmonizer.h b/include/taptools/harmonizer.h index 43c0af6..471d0c1 100644 --- a/include/taptools/harmonizer.h +++ b/include/taptools/harmonizer.h @@ -16,6 +16,17 @@ /// default 10 ms and become an audible portamento at hundreds of ms. Gains ride /// their own short slews (no zippers, the house rule). /// +/// A 16-slot preset-morph engine (the house pattern shared with vco.h): +/// `store_preset(slot)` snapshots the parameter *targets* — the four intervals, the +/// four gains, the dry gain, and the glide time, plus the formant flag — and +/// `recall_preset(slot, seconds)` glides every continuous parameter to the stored +/// values over `seconds` via per-sample linear ramps on the targets. The ordinary +/// one-pole slews keep smoothing underneath the moving targets, so chord-change +/// glide semantics outside morphs are untouched, and a voice whose gain rises from +/// zero during a morph still takes the cold re-entry path below. The formant flag +/// is a bool: it *snaps* at recall start rather than morphing. An explicit setter +/// called mid-morph cancels that one parameter's morph ramp — the performer wins. +/// /// Honest limits, stated where the next reader will look: /// - Latency is exactly one FFT frame (`latency()` samples, fft_size; 1024 at /// 48 kHz ≈ 21 ms). The dry path is delayed inside the kernel to match, so the @@ -24,7 +35,7 @@ /// wrong material. The formant model (LPC order 48) is speech/voice-oriented. /// - A voice whose gain sits at zero is skipped entirely to save CPU and its /// engine is cleared on re-entry: expect one frame of silence, then the gain -/// slew, when a voice is enabled mid-performance. +/// slew, when a voice is enabled mid-performance (by a setter or by a morph). /// - The source should be monophonic for musical results — polyphonic input /// shifts, but the formant estimate and the intervals stop meaning anything. /// @author Timothy Place @@ -46,6 +57,7 @@ namespace tap::tools { namespace harmony { constexpr int k_max_voices = 4; + constexpr int k_presets = 16; constexpr double k_max_interval_st = 24.0; // == the pvoc ratio clamp [1/4, 4] constexpr double k_max_gain = 2.0; // linear; wrappers speak dB constexpr double k_default_glide_ms = 10.0; // click-free, not yet audible @@ -54,14 +66,71 @@ namespace tap::tools { constexpr double k_gain_epsilon = 1e-4; // below this a voice is "off" constexpr size_t k_default_fft = 1024; // the pvoc desktop operating point + enum param_index : int { + p_interval_0 = 0, // voice intervals, fractional semitones, ±24 + p_interval_1, + p_interval_2, + p_interval_3, + p_gain_0, // voice gains, linear [0, 2] + p_gain_1, + p_gain_2, + p_gain_3, + p_dry, // dry-path gain, linear [0, 2] + p_glide_ms, // interval glide time constant, ms [0, 2000] + k_num_params + }; + + /// One full parameter snapshot — a preset slot, and the unit the morph engine + /// interpolates. The formant flag rides along but is not morphable: a bool has no + /// middle, so it snaps at recall start. + struct params { + std::array v{}; + bool formant = true; + + static params defaults() { + params p; // intervals and gains default to 0 + p.v[p_dry] = 1.0; + p.v[p_glide_ms] = k_default_glide_ms; + return p; + } + }; + + /// Clamp a value to the legal range of a parameter. + inline double clamp_param(int index, double value) { + switch (index) { + case p_interval_0: + case p_interval_1: + case p_interval_2: + case p_interval_3: + return std::clamp(value, -k_max_interval_st, k_max_interval_st); + case p_gain_0: + case p_gain_1: + case p_gain_2: + case p_gain_3: + case p_dry: + return std::clamp(value, 0.0, k_max_gain); + case p_glide_ms: + return std::clamp(value, 0.0, k_max_glide_ms); + default: + return value; + } + } + /// Formant-preserving multi-voice harmonizer. House shape: prepare(sr) buys all /// geometry, process(in) is per-sample and allocation-free, every setter is safe /// while audio runs. class harmonizer { public: + harmonizer() { + const params d = params::defaults(); + m_target = d.v; + m_presets.fill(d); + } + /// Allocate the voice engines and the dry-alignment delay. @p fft_size follows /// the pvoc contract (power of two, >= 64); the default is the intended - /// desktop operating point. + /// desktop operating point. Any pending preset morph completes instantly — + /// its sample counts belonged to the previous rate. void prepare(double sr, size_t fft_size = k_default_fft) { m_sr = sr; m_fft = fft_size; @@ -76,15 +145,17 @@ namespace tap::tools { m_dry_ring.assign(fft_size, 0.0); m_dry_write = 0; + finish_morphs(); m_gain_coeff = slew_coeff(k_gain_slew_ms); - set_glide(m_glide_ms); // recompute the glide coefficient for this rate + refresh_glide_coeff(); for (int v = 0; v < k_max_voices; ++v) { - m_current_st[static_cast(v)] = m_target_st[static_cast(v)]; - m_current_gain[static_cast(v)] = m_target_gain[static_cast(v)]; - m_active[static_cast(v)] = m_target_gain[static_cast(v)] > k_gain_epsilon; + const size_t i = static_cast(v); + m_current_st[i] = m_target[static_cast(p_interval_0 + v)]; + m_current_gain[i] = m_target[static_cast(p_gain_0 + v)]; + m_active[i] = m_current_gain[i] > k_gain_epsilon; } - m_current_dry = m_target_dry; + m_current_dry = m_target[p_dry]; } bool prepared() const { return !m_voices.empty(); } @@ -98,11 +169,11 @@ namespace tap::tools { if (voice < 0 || voice >= k_max_voices) { return; } - m_target_st[static_cast(voice)] = std::clamp(semitones, -k_max_interval_st, k_max_interval_st); + set_target(p_interval_0 + voice, semitones); } double interval(int voice) const { - return (voice >= 0 && voice < k_max_voices) ? m_target_st[static_cast(voice)] : 0.0; + return (voice >= 0 && voice < k_max_voices) ? m_target[static_cast(p_interval_0 + voice)] : 0.0; } /// Set a voice's linear gain, clamped to [0, 2]. 0 disables the voice (its @@ -111,19 +182,20 @@ namespace tap::tools { if (voice < 0 || voice >= k_max_voices) { return; } - m_target_gain[static_cast(voice)] = std::clamp(gain, 0.0, k_max_gain); + set_target(p_gain_0 + voice, gain); } double gain(int voice) const { - return (voice >= 0 && voice < k_max_voices) ? m_target_gain[static_cast(voice)] : 0.0; + return (voice >= 0 && voice < k_max_voices) ? m_target[static_cast(p_gain_0 + voice)] : 0.0; } /// Dry-path gain (latency-aligned inside the kernel), linear [0, 2], default 1. - void set_dry(double gain) { m_target_dry = std::clamp(gain, 0.0, k_max_gain); } - double dry() const { return m_target_dry; } + void set_dry(double gain) { set_target(p_dry, gain); } + double dry() const { return m_target[p_dry]; } /// LPC formant preservation on every voice (see tap::dsp::basic_pvoc). On by /// default — it is the point of the object; off is the chipmunk-chorus bend. + /// A bool: preset recalls snap it at recall start, never morph it. void set_formant(bool on) { m_formant = on; for (auto& voice : m_voices) { @@ -133,26 +205,79 @@ namespace tap::tools { bool formant() const { return m_formant; } /// Interval glide time constant, ms, clamped [0, 2000]. 0 snaps. - void set_glide(double ms) { - m_glide_ms = std::clamp(ms, 0.0, k_max_glide_ms); - m_glide_coeff = (m_glide_ms <= 0.0 || m_sr <= 0.0) ? 1.0 : slew_coeff(m_glide_ms); + void set_glide(double ms) { set_target(p_glide_ms, ms); } + double glide() const { return m_target[p_glide_ms]; } + + // -- presets / morph (16 slots, the vco.h house pattern) ------------------------------- + + /// Snapshot the current parameter *targets* (and the formant flag) into a slot. + bool store_preset(int slot) { + if (!valid_slot(slot)) { + return false; + } + m_presets[static_cast(slot)] = snap_targets(); + return true; } - double glide() const { return m_glide_ms; } - /// Zero all running state. Slews jump to their targets so nothing fades in - /// from stale values after a transport reset. + /// Glide every continuous parameter to the slot's values over @p seconds via + /// per-sample linear ramps on the targets; the one-pole slews keep smoothing + /// underneath. 0 s applies the preset immediately — exactly as if every setter + /// had been called at once, so the ordinary glide/gain slews still de-click the + /// landing. The formant bool snaps at recall start. A voice whose gain rises + /// from zero mid-morph re-enters through the cold path, as always. + bool recall_preset(int slot, double seconds) { + if (!valid_slot(slot)) { + return false; + } + const params& p = m_presets[static_cast(slot)]; + set_formant(p.formant); + const long n = (m_sr > 0.0) ? static_cast(std::max(0.0, seconds) * m_sr) : 0; + for (int i = 0; i < k_num_params; ++i) { + morph_to(i, clamp_param(i, p.v[static_cast(i)]), n); + } + return true; + } + + /// Write a slot directly (e.g. from a wrapper's saved state). + bool set_preset(int slot, const params& p) { + if (!valid_slot(slot)) { + return false; + } + m_presets[static_cast(slot)] = p; + return true; + } + + const params& preset(int slot) const { + return m_presets[static_cast(std::clamp(slot, 0, k_presets - 1))]; + } + + bool morphing() const { return m_morph_active > 0; } + + /// The current parameter targets as a params snapshot (what store_preset saves). + params snap_targets() const { + params p; + p.v = m_target; + p.formant = m_formant; + return p; + } + + /// Zero all running state. Slews jump to their targets and pending preset + /// morphs complete instantly, so nothing fades in from stale values after a + /// transport reset. void clear() { for (auto& voice : m_voices) { voice.clear(); } std::fill(m_dry_ring.begin(), m_dry_ring.end(), 0.0); m_dry_write = 0; + finish_morphs(); for (int v = 0; v < k_max_voices; ++v) { - m_current_st[static_cast(v)] = m_target_st[static_cast(v)]; - m_current_gain[static_cast(v)] = m_target_gain[static_cast(v)]; - m_active[static_cast(v)] = m_target_gain[static_cast(v)] > k_gain_epsilon; + const size_t i = static_cast(v); + m_current_st[i] = m_target[static_cast(p_interval_0 + v)]; + m_current_gain[i] = m_target[static_cast(p_gain_0 + v)]; + m_active[i] = m_current_gain[i] > k_gain_epsilon; } - m_current_dry = m_target_dry; + m_current_dry = m_target[p_dry]; } /// Consume one input sample; produce the harmonized sample for time @@ -162,17 +287,21 @@ namespace tap::tools { return in; } + tick_morph(); + // dry path, delayed to the voices' emission time const double dry = m_dry_ring[m_dry_write]; m_dry_ring[m_dry_write] = in; m_dry_write = (m_dry_write + 1) % m_dry_ring.size(); - m_current_dry += m_gain_coeff * (m_target_dry - m_current_dry); + m_current_dry += m_gain_coeff * (m_target[p_dry] - m_current_dry); double out = m_current_dry * dry; for (int v = 0; v < k_max_voices; ++v) { - const size_t i = static_cast(v); - const bool wanted = m_target_gain[i] > k_gain_epsilon; + const size_t i = static_cast(v); + const double target_st = m_target[static_cast(p_interval_0 + v)]; + const double target_gain = m_target[static_cast(p_gain_0 + v)]; + const bool wanted = target_gain > k_gain_epsilon; if (!wanted && m_current_gain[i] <= k_gain_epsilon) { m_active[i] = false; @@ -184,8 +313,8 @@ namespace tap::tools { m_active[i] = true; } - m_current_st[i] += m_glide_coeff * (m_target_st[i] - m_current_st[i]); - m_current_gain[i] += m_gain_coeff * (m_target_gain[i] - m_current_gain[i]); + m_current_st[i] += m_glide_coeff * (target_st - m_current_st[i]); + m_current_gain[i] += m_gain_coeff * (target_gain - m_current_gain[i]); const double ratio = std::exp2(m_current_st[i] / 12.0); out += m_current_gain[i] * m_voices[i].process(in, ratio); @@ -194,8 +323,93 @@ namespace tap::tools { } private: + struct morph_ramp { + double goal{0.0}; + double inc{0.0}; + long remaining{0}; + }; + + static bool valid_slot(int s) { return s >= 0 && s < k_presets; } + double slew_coeff(double ms) const { return 1.0 - std::exp(-1.0 / (ms * 0.001 * m_sr)); } + void refresh_glide_coeff() { + const double ms = m_target[p_glide_ms]; + m_glide_coeff = (ms <= 0.0 || m_sr <= 0.0) ? 1.0 : slew_coeff(ms); + } + + /// A setter wins over a running morph: cancel that parameter's ramp, set the target. + void set_target(int index, double value) { + cancel_morph(index); + m_target[static_cast(index)] = clamp_param(index, value); + if (index == p_glide_ms) { + refresh_glide_coeff(); + } + } + + void cancel_morph(int index) { + morph_ramp& m = m_morph[static_cast(index)]; + if (m.remaining > 0) { + m.remaining = 0; + m.inc = 0.0; + --m_morph_active; + } + } + + void morph_to(int index, double goal, long nsamples) { + morph_ramp& m = m_morph[static_cast(index)]; + const bool was = m.remaining > 0; + if (nsamples < 1 || goal == m_target[static_cast(index)]) { + m_target[static_cast(index)] = goal; + m.inc = 0.0; + m.remaining = 0; + if (index == p_glide_ms) { + refresh_glide_coeff(); + } + } + else { + m.goal = goal; + m.inc = (goal - m_target[static_cast(index)]) / static_cast(nsamples); + m.remaining = nsamples; + } + m_morph_active += static_cast(m.remaining > 0) - static_cast(was); + } + + void finish_morphs() { + if (m_morph_active <= 0) { + return; + } + for (int i = 0; i < k_num_params; ++i) { + morph_ramp& m = m_morph[static_cast(i)]; + if (m.remaining > 0) { + m_target[static_cast(i)] = m.goal; + m.inc = 0.0; + m.remaining = 0; + } + } + m_morph_active = 0; + refresh_glide_coeff(); + } + + void tick_morph() { + if (m_morph_active <= 0) { + return; + } + for (int i = 0; i < k_num_params; ++i) { + morph_ramp& m = m_morph[static_cast(i)]; + if (m.remaining > 0) { + m_target[static_cast(i)] += m.inc; + if (--m.remaining == 0) { + m_target[static_cast(i)] = m.goal; + --m_morph_active; + } + if (i == p_glide_ms) { + refresh_glide_coeff(); + } + } + } + } + double m_sr = 0.0; size_t m_fft = 0; @@ -203,16 +417,19 @@ namespace tap::tools { std::vector m_dry_ring; size_t m_dry_write = 0; - std::array m_target_st{}; + // parameter targets (indexed by param_index) and their morph ramps; the slewed + // running values live below + std::array m_target{}; + std::array m_morph{}; + std::array m_presets{}; + int m_morph_active = 0; + std::array m_current_st{}; - std::array m_target_gain{}; std::array m_current_gain{}; std::array m_active{}; - double m_target_dry = 1.0; double m_current_dry = 1.0; bool m_formant = true; - double m_glide_ms = k_default_glide_ms; double m_glide_coeff = 1.0; double m_gain_coeff = 1.0; }; diff --git a/include/taptools/taptools.h b/include/taptools/taptools.h index 0c08bb9..4d4983c 100644 --- a/include/taptools/taptools.h +++ b/include/taptools/taptools.h @@ -10,6 +10,7 @@ #include "autowah.h" #include "bridged_t.h" #include "conv_engine.h" +#include "delay.h" #include "diode_ladder.h" #include "grm_comb.h" #include "grm_pitchaccum.h" diff --git a/include/taptools/vco.h b/include/taptools/vco.h index f69c2c3..a52b64b 100644 --- a/include/taptools/vco.h +++ b/include/taptools/vco.h @@ -39,6 +39,17 @@ /// `track` (-10..10 cents/octave) — V/oct calibration error relative to A440: /// the pitch offset grows with the distance from the calibration center, like a /// real exponential converter drifting away from its trim point. +/// - Performance modulation (deterministic, no RNG; depth 0 stays bit-identical to +/// the ideal oscillator): +/// `vibrato` (0..100 cents) / `vibrato_rate` (0.05..20 Hz) — a sine LFO on the +/// pitch, calibrated in cents so the musical width is register-independent +/// (the FM inlet, calibrated in Hz, is not). +/// `vibrato_delay` (0..5000 ms) — the singing control: the vibrato fades in +/// through a one-pole with this time constant, re-armed on every +/// frequency-target change (a new note). Signal-rate frequency (process_at) +/// does not re-arm — there, the caller is the modulation. +/// `bend` (-24..24 semitones) — pitch bend riding the standard smooth ramp: the +/// wheel, as a parameter. /// /// As in the other TapTools kernels: per-sample linear ramps on every parameter, a /// 16-slot preset-morph engine, allocation-free processing, setters safe while audio @@ -65,15 +76,19 @@ namespace tap::tools { constexpr double k_pi = 3.14159265358979323846; enum param_index : int { - p_gain = 0, // output gain, dB - p_frequency, // Hz - p_shape, // 0 sine .. 1 triangle .. 2 saw .. 3 pulse (continuous morph) - p_pw, // pulse width, 1..99 % - p_drift, // slow random pitch walk depth, cents - p_detune, // static detune, cents - p_imperfect, // 0..1 waveform imperfection (Model-D-ish curvature/rounding/asymmetry) - p_jitter, // fast pitch noise depth, cents - p_track, // V/oct calibration error, cents per octave from A440 + p_gain = 0, // output gain, dB + p_frequency, // Hz + p_shape, // 0 sine .. 1 triangle .. 2 saw .. 3 pulse (continuous morph) + p_pw, // pulse width, 1..99 % + p_drift, // slow random pitch walk depth, cents + p_detune, // static detune, cents + p_imperfect, // 0..1 waveform imperfection (Model-D-ish curvature/rounding/asymmetry) + p_jitter, // fast pitch noise depth, cents + p_track, // V/oct calibration error, cents per octave from A440 + p_vibrato, // periodic pitch modulation depth, cents + p_vibrato_rate, // vibrato rate, Hz + p_vibrato_delay, // vibrato onset time constant after a note change, ms (0 = instant) + p_bend, // pitch bend, semitones (rides the smooth ramp — the wheel) k_num_params }; @@ -91,15 +106,19 @@ namespace tap::tools { static params defaults() { params p; - p.v[p_gain] = 0.0; - p.v[p_frequency] = 220.0; - p.v[p_shape] = static_cast(wave_saw); - p.v[p_pw] = 50.0; - p.v[p_drift] = 0.0; - p.v[p_detune] = 0.0; - p.v[p_imperfect] = 0.0; - p.v[p_jitter] = 0.0; - p.v[p_track] = 0.0; + p.v[p_gain] = 0.0; + p.v[p_frequency] = 220.0; + p.v[p_shape] = static_cast(wave_saw); + p.v[p_pw] = 50.0; + p.v[p_drift] = 0.0; + p.v[p_detune] = 0.0; + p.v[p_imperfect] = 0.0; + p.v[p_jitter] = 0.0; + p.v[p_track] = 0.0; + p.v[p_vibrato] = 0.0; + p.v[p_vibrato_rate] = 5.0; + p.v[p_vibrato_delay] = 0.0; + p.v[p_bend] = 0.0; return p; } }; @@ -125,6 +144,14 @@ namespace tap::tools { return std::clamp(value, 0.0, 20.0); case p_track: return std::clamp(value, -10.0, 10.0); + case p_vibrato: + return std::clamp(value, 0.0, 100.0); + case p_vibrato_rate: + return std::clamp(value, 0.05, 20.0); + case p_vibrato_delay: + return std::clamp(value, 0.0, 5000.0); + case p_bend: + return std::clamp(value, -24.0, 24.0); default: return value; } @@ -164,6 +191,8 @@ namespace tap::tools { m_jit_lp = 0.0; m_jit_count = 0; m_round_lp = 0.0; + m_vib_phase = 0.0; + m_vib_env = 0.0; } void snap() { @@ -196,7 +225,14 @@ namespace tap::tools { if (index < 0 || index >= k_num_params) { return; } - ramp_to(index, clamp_param(index, value), static_cast(m_smooth_ms * 0.001 * m_sr)); + const double tgt = clamp_param(index, value); + // A new note re-arms the vibrato onset envelope (the delayed-vibrato behavior of + // a played instrument). Signal-rate frequency (process_at) deliberately does not + // re-arm — there, you are the modulation. + if (index == p_frequency && tgt != m_ramp[p_frequency].target) { + m_vib_env = 0.0; + } + ramp_to(index, tgt, static_cast(m_smooth_ms * 0.001 * m_sr)); } void set_gain(double db) { set_param(p_gain, db); } @@ -208,6 +244,10 @@ namespace tap::tools { void set_imperfect(double x) { set_param(p_imperfect, x); } void set_jitter(double cents) { set_param(p_jitter, cents); } void set_track(double cents_per_oct) { set_param(p_track, cents_per_oct); } + void set_vibrato(double cents) { set_param(p_vibrato, cents); } + void set_vibrato_rate(double hz) { set_param(p_vibrato_rate, hz); } + void set_vibrato_delay(double ms) { set_param(p_vibrato_delay, ms); } + void set_bend(double semitones) { set_param(p_bend, semitones); } /// Snap the shape to one of the classic waveforms. void set_waveform(int w) { set_shape(static_cast(std::clamp(w, 0, k_num_waveforms - 1))); } @@ -330,6 +370,31 @@ namespace tap::tools { return (m_rng / 2147483648.0) - 1.0; } + // Periodic pitch modulation in cents: a sine LFO scaled by the onset envelope — a + // one-pole rise toward 1 whose time constant is the vibrato_delay parameter, + // re-armed by each frequency-target change (the played instrument's delayed + // vibrato). Phase and envelope advance deterministically regardless of depth, so + // depth 0 stays bit-identical to the ideal oscillator. + double tick_vibrato() { + const double rate = m_ramp[p_vibrato_rate].current; + m_vib_phase = wrap01(m_vib_phase + rate / m_sr); + + const double delay_ms = m_ramp[p_vibrato_delay].current; + if (delay_ms <= 0.0) { + m_vib_env = 1.0; + } + else { + const double a = 1.0 - std::exp(-1.0 / (delay_ms * 0.001 * m_sr)); + m_vib_env += a * (1.0 - m_vib_env); + } + + const double depth = m_ramp[p_vibrato].current; + if (depth <= 0.0) { + return 0.0; + } + return depth * m_vib_env * std::sin(2.0 * k_pi * m_vib_phase); + } + // Slow random pitch walk in cents: ~2 Hz sample-and-hold through a ~0.5 Hz one-pole. double tick_drift(double depth_cents) { if (depth_cents <= 0.0) { @@ -445,7 +510,8 @@ namespace tap::tools { const double imp = m_ramp[p_imperfect].current; double cents = m_ramp[p_detune].current + tick_drift(m_ramp[p_drift].current) - + tick_jitter(m_ramp[p_jitter].current) + imp * m_tol_cents; + + tick_jitter(m_ramp[p_jitter].current) + tick_vibrato() + m_ramp[p_bend].current * 100.0 + + imp * m_tol_cents; const double track = m_ramp[p_track].current; if (track != 0.0 && base_hz > 0.0) { cents += track * std::log2(base_hz / 440.0); // V/oct error from the trim point @@ -542,6 +608,8 @@ namespace tap::tools { double m_jit_sh{0.0}; double m_jit_lp{0.0}; int m_jit_count{0}; + double m_vib_phase{0.0}; + double m_vib_env{0.0}; double m_round_lp{0.0}; double m_round_a{1.0}; double m_round_imp{-1.0}; diff --git a/include/taptools/vocoder.h b/include/taptools/vocoder.h index 0ab3c56..bf1f2a6 100644 --- a/include/taptools/vocoder.h +++ b/include/taptools/vocoder.h @@ -9,6 +9,26 @@ /// Reconstructed from tap.vocoder~'s reference documentation. Time-domain, no FFT. /// Follows the svf.h / ladder.h idiom: prepare(samplerate), then per-sample process(). /// Plain C++17, Min-free. +/// +/// Two conveniences beyond the original object: +/// +/// - **Sibilance path** (`set_sibilance`, default 0): a deterministic seeded white-noise +/// source blended into the *carrier* path of the bands whose centre frequency lies +/// above ~4 kHz. Because the modulator envelopes still gate every band, the noise is +/// heard only while the modulator carries high-band energy — the channel vocoder's +/// classic unvoiced/sibilance excitation, in the lineage of Dudley's original design, +/// which substitutes an aperiodic (noise) source for the periodic one during unvoiced +/// speech (H. Dudley, "Remaking Speech", JASA 11(2), 1939; "The Vocoder", Bell Labs +/// Record 18, 1939; see also U. Zölzer (ed.), *DAFX: Digital Audio Effects*, 2nd ed., +/// Wiley 2011, ch. 8 — vocoder-based effects). **Contract note:** with sibilance > 0 +/// the pinned "a silent carrier yields silence" contract is deliberately relaxed for +/// those top bands (the noise *is* carrier there); at the default 0 the original +/// contract holds and the output is bit-identical to the pre-sibilance kernel. +/// - **Mix** (`set_mix`, default 100 = full wet): an equal-power blend of the dry +/// *carrier* against the vocoded output. The carrier is the dry side because it is +/// the program material you hear — blending it back under the vocoded signal is the +/// classic parallel move (synth pad under robot voice); the modulator is an analysis +/// control input, and bleeding raw speech through would defeat the effect. /// @author Timothy Place // SPDX-License-Identifier: MIT // Copyright 2001-2026 Timothy Place. @@ -18,6 +38,7 @@ #include #include #include +#include namespace tap::tools { namespace vocoder { @@ -29,6 +50,18 @@ namespace tap::tools { static constexpr double k_fmin = 50.0; // lowest band centre (Hz) static constexpr double k_fmax = 12000.0; // highest band centre (Hz) + // Bands whose nominal centre frequency (band_frequency, sample-rate independent) lies + // above this receive the sibilance noise in their carrier path. With the 24-band + // 50 Hz..12 kHz log spacing that is bands 19..23 (centres ~4.63 / 5.87 / 7.45 / 9.45 / + // 12 kHz; band 18 sits at ~3.65 kHz and stays clean). + static constexpr double k_sibilance_hz = 4000.0; + + bank() { + for (int i = 0; i < k_bands; ++i) { + m_sib_band[i] = band_frequency(i) > k_sibilance_hz; + } + } + // Set the sample rate and (re)compute all coefficients. Call before processing and whenever // the sample rate changes (the Min wrapper calls this from dspsetup). void prepare(double samplerate) { @@ -52,7 +85,42 @@ namespace tap::tools { // Linear makeup gain applied to the summed output. void set_gain(double g) { m_gain = g; } - // Reset all filter and envelope-follower state. + // Sibilance amount, 0..1 (default 0): how much seeded white noise is blended into the + // carrier path of the bands above k_sibilance_hz. At 0 the output is bit-identical to + // the noise-free kernel and the silent-carrier contract holds; above 0 that contract is + // deliberately relaxed for the top bands (see the file docstring). + void set_sibilance(double amount) { m_sibilance = std::clamp(amount, 0.0, 1.0); } + + // Reseed the sibilance noise source (deterministic; same seed renders bit-identically, + // different seeds decorrelate instances). Seed 0 is mapped to 1, as in vco.h. + void set_seed(uint32_t seed) { + m_seed = (seed == 0) ? 1u : seed; + m_rng = m_seed; + } + + // Wet/dry mix in percent, 0 (dry carrier) .. 100 (fully vocoded, the default), as an + // equal-power crossfade. The endpoints are snapped exactly, so 100 is bit-identical to + // the pre-mix kernel and 0 passes the carrier through untouched (makeup gain applies to + // the vocoded side only). + void set_mix(double pct) { + const double wet = std::clamp(pct, 0.0, 100.0) / 100.0; + if (wet <= 0.0) { + m_mix_dry = 1.0; + m_mix_wet = 0.0; + } + else if (wet >= 1.0) { + m_mix_dry = 0.0; + m_mix_wet = 1.0; + } + else { + const double theta = wet * (k_pi / 2.0); + m_mix_dry = std::cos(theta); + m_mix_wet = std::sin(theta); + } + } + + // Reset all filter, envelope-follower, and noise state (the noise source rewinds to its + // seed, so a cleared bank replays its exact sibilance sequence). void clear() { for (auto& b : m_mod) { b.clear(); @@ -61,20 +129,30 @@ namespace tap::tools { b.clear(); } m_env.fill(0.0); + m_rng = m_seed; } - // Process one sample: shape the carrier by the modulator's per-band envelope. + // Process one sample: shape the carrier by the modulator's per-band envelope, feeding + // the top bands' carrier path with the sibilance noise, then mix against the dry carrier. double process(double modulator, double carrier) { + // The noise generator advances every sample regardless of amount (like vco.h's + // vibrato phase), so changing the amount never shifts the sequence. + const double noise = uniform(); + const bool sib_on = m_sibilance > 0.0; + const double sib = m_sibilance * noise; + double out = 0.0; for (int i = 0; i < k_bands; ++i) { const double m = m_mod[i].process(modulator); const double rect = std::fabs(m); m_env[i] = m_env_coef * m_env[i] + (1.0 - m_env_coef) * rect; - const double c = m_car[i].process(carrier); + const double cin = (sib_on && m_sib_band[i]) ? carrier + sib : carrier; + const double c = m_car[i].process(cin); out += c * m_env[i]; } - return out * m_gain; + out *= m_gain; + return (m_mix_dry > 0.0) ? m_mix_dry * carrier + m_mix_wet * out : m_mix_wet * out; } private: @@ -140,15 +218,26 @@ namespace tap::tools { m_env_coef = std::exp(-1.0 / (tau * m_sr)); } - double m_sr{0.0}; - double m_q{20.0}; - double m_response_ms{20.0}; - double m_env_coef{0.0}; - double m_gain{1.0}; + double uniform() { // deterministic white noise in [-1, 1] (LCG, as in vco.h) + m_rng = m_rng * 1664525u + 1013904223u; + return (m_rng / 2147483648.0) - 1.0; + } + + double m_sr{0.0}; + double m_q{20.0}; + double m_response_ms{20.0}; + double m_env_coef{0.0}; + double m_gain{1.0}; + double m_sibilance{0.0}; + double m_mix_dry{0.0}; + double m_mix_wet{1.0}; + uint32_t m_seed{1u}; + uint32_t m_rng{1u}; std::array m_mod{}; std::array m_car{}; std::array m_env{}; + std::array m_sib_band{}; }; } // namespace vocoder diff --git a/notebooks/taptools_py.py b/notebooks/taptools_py.py index 5d5409b..15b07fd 100644 --- a/notebooks/taptools_py.py +++ b/notebooks/taptools_py.py @@ -13,9 +13,10 @@ (`Convolver`), tap.svf~ (`Svf`), tap.ladder~ (`Ladder`), tap.diode~ (`Diode`), tap.303~ (`TB303`), tap.vco~ (`Vco`), tap.autowah~ (`Wah`), tap.overdrive~ (`Overdrive`), the step-sequencer rows behind tap.808.seq~ / -tap.303.seq~ (`TriggerRow`, `NoteRow`), tap.808.kick~ (`Kick`), and -tap.tune~'s pitch corrector (`Tune`, with the shared DspTap detector passed -through as `Yin` for the notebooks' pitch tracking). Parameter names on the +tap.303.seq~ (`TriggerRow`, `NoteRow`), tap.808.kick~ (`Kick`), +tap.delay~ (`Delay`), tap.multitap~ (`Multitap`), and tap.tune~'s pitch +corrector (`Tune`, with the shared DspTap detector passed through as `Yin` +for the notebooks' pitch tracking). Parameter names on the kernel classes mirror each kernel header's param_index enum. Copyright 2003-2026 Timothy Place. MIT License. @@ -239,6 +240,28 @@ def load() -> ctypes.CDLL: "taptools_harmonizer_set_glide": ([vp, ctypes.c_double], ctypes.c_int), "taptools_harmonizer_latency": ([vp], ctypes.c_int), "taptools_harmonizer_process": ([vp, f64p, f64p, ctypes.c_int], ctypes.c_int), + "taptools_delay_create": ([], vp), + "taptools_delay_destroy": ([vp], None), + "taptools_delay_prepare": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int), + "taptools_delay_set_time_ms": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_delay_set_feedback": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_delay_set_mix": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_delay_set_interp": ([vp, ctypes.c_int], ctypes.c_int), + "taptools_delay_set_smooth_ms": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_delay_clear": ([vp], ctypes.c_int), + "taptools_delay_process": ([vp, f64p, f64p, ctypes.c_int], ctypes.c_int), + "taptools_delay_process_mod": ([vp, f64p, f64p, f64p, ctypes.c_int], ctypes.c_int), + "taptools_multitap_create": ([], vp), + "taptools_multitap_destroy": ([vp], None), + "taptools_multitap_prepare": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int), + "taptools_multitap_set_taps": ([vp, ctypes.c_int], ctypes.c_int), + "taptools_multitap_set_time_ms": ([vp, ctypes.c_int, ctypes.c_double], ctypes.c_int), + "taptools_multitap_set_gain": ([vp, ctypes.c_int, ctypes.c_double], ctypes.c_int), + "taptools_multitap_set_pan": ([vp, ctypes.c_int, ctypes.c_double], ctypes.c_int), + "taptools_multitap_set_interp": ([vp, ctypes.c_int], ctypes.c_int), + "taptools_multitap_set_smooth_ms": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_multitap_clear": ([vp], ctypes.c_int), + "taptools_multitap_process": ([vp, f64p, f64p, f64p, ctypes.c_int], ctypes.c_int), "taptools_yin_create": ([ctypes.c_int, ctypes.c_int, ctypes.c_int], vp), "taptools_yin_destroy": ([vp], None), "taptools_yin_frame_size": ([vp], ctypes.c_int), @@ -523,7 +546,8 @@ class Vco(_Kernel): PREFIX = "taptools_vco" PARAMS = {"gain": 0, "frequency": 1, "shape": 2, "pw": 3, "drift": 4, "detune": 5, - "imperfect": 6, "jitter": 7, "track": 8} + "imperfect": 6, "jitter": 7, "track": 8, "vibrato": 9, "vibrato_rate": 10, + "vibrato_delay": 11, "bend": 12} SINE, TRIANGLE, SAW, PULSE = range(4) def process(self, n: int | None = None, fm_hz=None, sync=None) -> np.ndarray: @@ -937,6 +961,109 @@ def __del__(self): self._h = None +class Delay: + """tap.delay~'s kernel (tap::tools::delay::line): a single slewed feedback + delay with an equal-power dry/wet mix. interp 1 (default) is 4-point + Hermite fractional; interp 0 is the legacy integer-sample truncation. + Feedback is clamped to 0.99 and the loop is DC-blocked.""" + + def __init__(self, sr: float = 48000.0, max_ms: float = 2000.0, **params): + self._h = _LIB.taptools_delay_create() + _check(_LIB.taptools_delay_prepare(self._h, float(sr), float(max_ms)), "prepare") + self.set(**params) + + def set(self, *, time_ms=None, feedback=None, mix=None, interp=None, + smooth_ms=None) -> "Delay": + # configuration first, so ramped targets in the same call honor the new slew + if smooth_ms is not None: + _check(_LIB.taptools_delay_set_smooth_ms(self._h, float(smooth_ms)), "smooth_ms") + if interp is not None: + _check(_LIB.taptools_delay_set_interp(self._h, int(interp)), "interp") + if time_ms is not None: + _check(_LIB.taptools_delay_set_time_ms(self._h, float(time_ms)), "time_ms") + if feedback is not None: + _check(_LIB.taptools_delay_set_feedback(self._h, float(feedback)), "feedback") + if mix is not None: + _check(_LIB.taptools_delay_set_mix(self._h, float(mix)), "mix") + return self + + def process(self, x, time_ms=None) -> np.ndarray: + """Run the line. `time_ms` may be a per-sample array (the signal-rate + override, which bypasses the time slew); omit it to use the slewed + set() target.""" + x = _f64(x) + out = np.zeros_like(x) + if time_ms is None: + _check(_LIB.taptools_delay_process(self._h, _p64(x), _p64(out), x.size), "process") + else: + t = _f64(np.broadcast_to(np.asarray(time_ms, dtype=np.float64), x.shape)) + _check(_LIB.taptools_delay_process_mod(self._h, _p64(x), _p64(t), _p64(out), x.size), + "process_mod") + return out + + def clear(self) -> None: + _check(_LIB.taptools_delay_clear(self._h), "clear") + + def __del__(self): + h = getattr(self, "_h", None) + if h: + _LIB.taptools_delay_destroy(h) + self._h = None + + +class Multitap: + """tap.multitap~'s kernel (tap::tools::delay::multitap): up to 100 slewed + feedforward taps off one line, each with time (ms), linear gain, and + equal-power pan (-1..1), summed to stereo. No dry path, no feedback.""" + + def __init__(self, sr: float = 48000.0, max_ms: float = 2000.0, **params): + self._h = _LIB.taptools_multitap_create() + _check(_LIB.taptools_multitap_prepare(self._h, float(sr), float(max_ms)), "prepare") + self.set(**params) + + def set(self, *, taps=None, times=None, gains=None, pans=None, interp=None, + smooth_ms=None) -> "Multitap": + """`times`/`gains`/`pans` are per-tap sequences; if `taps` (the active + count) is omitted and `times` is given, the count follows len(times).""" + # configuration first, so ramped targets in the same call honor the new slew + if smooth_ms is not None: + _check(_LIB.taptools_multitap_set_smooth_ms(self._h, float(smooth_ms)), "smooth_ms") + if interp is not None: + _check(_LIB.taptools_multitap_set_interp(self._h, int(interp)), "interp") + if times is not None: + for i, ms in enumerate(times): + _check(_LIB.taptools_multitap_set_time_ms(self._h, i, float(ms)), "time_ms") + if taps is None: + taps = len(list(times)) + if gains is not None: + for i, g in enumerate(gains): + _check(_LIB.taptools_multitap_set_gain(self._h, i, float(g)), "gain") + if pans is not None: + for i, p in enumerate(pans): + _check(_LIB.taptools_multitap_set_pan(self._h, i, float(p)), "pan") + if taps is not None: + _check(_LIB.taptools_multitap_set_taps(self._h, int(taps)), "taps") + return self + + def process(self, x): + """Returns the stereo tap sum as an (outL, outR) pair.""" + x = _f64(x) + out_l = np.zeros_like(x) + out_r = np.zeros_like(x) + _check(_LIB.taptools_multitap_process(self._h, _p64(x), _p64(out_l), _p64(out_r), x.size), + "process") + return out_l, out_r + + def clear(self) -> None: + _check(_LIB.taptools_multitap_clear(self._h), "clear") + + def __del__(self): + h = getattr(self, "_h", None) + if h: + _LIB.taptools_multitap_destroy(h) + self._h = None + + class Yin: """The shared DspTap pitch detector (tap::dsp::yin), passed through the C ABI so the notebooks can track pitch with the same detector the corrector uses.""" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e1a148c..c6cc13f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ FetchContent_MakeAvailable(Catch2) add_executable(taptools_kernel_tests adsr_test.cpp autowah_test.cpp + delay_test.cpp diode_ladder_test.cpp grm_comb_test.cpp harmonizer_test.cpp diff --git a/tests/delay_test.cpp b/tests/delay_test.cpp new file mode 100644 index 0000000..7325c55 --- /dev/null +++ b/tests/delay_test.cpp @@ -0,0 +1,238 @@ +/// @file +/// @brief Unit tests for the delay pair (tap.delay~ / tap.multitap~ kernels). +/// @details Pins the rebuild's contract: Hermite fractional taps are phase-accurate against the +/// analytic shift; interp 0 reproduces the legacy integer truncation bit-for-bit; +/// feedback is capped at k_fb_max with a DC-blocked loop (a DC step recirculates once +/// and decays instead of accumulating); the equal-power mix and pan laws have exact +/// endpoints; and a modulated time under Hermite stays smooth where truncation +/// zipper-steps by whole samples. +// SPDX-License-Identifier: MIT +// Copyright 2026 Timothy Place. + +#include +#include +#include + +#include +#include + +namespace { + + constexpr double k_sr = 48000.0; + constexpr double k_pi = 3.14159265358979323846; + + namespace dly = tap::tools::delay; + + dly::line make_line(double max_ms = 1000.0) { + dly::line d; + d.prepare(k_sr, max_ms); + d.set_smooth_ms(0.0); + return d; + } + + // Deterministic noise in about [-1, 1], never denormal-small (LCG granularity ~4.7e-10). + struct noise { + uint32_t state{2463534242u}; + double operator()() { + state = state * 1664525u + 1013904223u; + return (static_cast(state) / 2147483648.0) - 1.0; + } + }; + +} // namespace + +SCENARIO("a fractional Hermite tap of a sine is phase-accurate against the analytic shift") { + // 440 Hz through a 123.37-sample tap: once the line is charged, every output sample must sit + // on the analytically delayed sine to interpolator precision. + const double d_samples = 123.37; + const double w = 2.0 * k_pi * 440.0 / k_sr; + + auto d = make_line(); + d.set_mix(100.0); + d.set_feedback(0.0); + d.set_interp(dly::interp_hermite); + d.set_time_ms(d_samples * 1000.0 / k_sr); + + const int n = 4000; + double max_err = 0.0; + for (int i = 0; i < n; ++i) { + const double y = d.process(std::sin(w * i)); + if (i > 200) { // past the charge-up + max_err = std::max(max_err, std::abs(y - std::sin(w * (i - d_samples)))); + } + } + INFO("max deviation from the analytic shift: " << max_err); + REQUIRE(max_err < 1e-4); +} + +SCENARIO("interp 0 reproduces the legacy integer truncation exactly") { + // 7.9 ms at 48 kHz is 379.2 samples; the legacy map long(ms * sr / 1000) truncates to 379. + // With mix 100 (exact wet endpoint) and no feedback the kernel must be bit-identical to a + // plain x[n - 379] reference. + auto d = make_line(); + d.set_mix(100.0); + d.set_feedback(0.0); + d.set_interp(dly::interp_trunc); + d.set_time_ms(7.9); + + noise rng; + const int n = 2000; + const long k = 379; + std::vector x(n); + for (auto& v : x) { + v = rng(); + } + + bool exact = true; + for (int i = 0; i < n; ++i) { + const double y = d.process(x[static_cast(i)]); + const double ref = (i >= k) ? x[static_cast(i - k)] : 0.0; + exact = exact && (y == ref); // bitwise, not approximately + } + REQUIRE(exact); +} + +SCENARIO("feedback is capped at 0.99 and the loop is DC-blocked") { + // The cap is a hard contract point. + auto d = make_line(); + d.set_feedback(2.0); + REQUIRE(d.feedback() == dly::k_fb_max); + + // Drive a DC step through a 5 ms loop at the cap. Without the blocker the wet output would + // integrate toward 1/(1 - 0.99) = 100 (past 5.0 within ~25 ms); with it, DC passes the first + // tap once and every recirculation is high-passed. The recirculating step *edge* does overlap + // itself (the blocker's ~1000-sample tail is longer than the 240-sample loop), so the driven + // peak measures ~3.2 — bounded, not integrating. When the step is released, the DC caught in + // the loop must decay away. + d.set_mix(100.0); + d.set_time_ms(5.0); + + const int drive = static_cast(1.0 * k_sr); + const int tail = static_cast(2.5 * k_sr); + + double driven_max = 0.0; + for (int i = 0; i < drive; ++i) { + driven_max = std::max(driven_max, std::abs(d.process(1.0))); + } + INFO("max driven output: " << driven_max); + REQUIRE(driven_max < 5.0); // an unblocked loop passes this within ~25 ms and never comes back + + double late = 0.0; + for (int i = 0; i < tail; ++i) { + const double y = d.process(0.0); + if (i >= tail - static_cast(0.1 * k_sr)) { + late += std::abs(y); + } + } + late /= 0.1 * k_sr; + INFO("mean |out| over the last 100 ms of the tail: " << late); + REQUIRE(late < 0.05); // the step has decayed out of the loop +} + +SCENARIO("the mix law endpoints are exact: 0 is dry only, 100 is wet only") { + noise rng; + + // mix 0: output is the input, bitwise. + { + auto d = make_line(); + d.set_mix(0.0); + d.set_feedback(0.5); + d.set_time_ms(3.0); + bool exact = true; + for (int i = 0; i < 1000; ++i) { + const double x = rng(); + exact = exact && (d.process(x) == x); + } + REQUIRE(exact); + } + + // mix 100: an impulse produces nothing until the tap, then exactly the impulse. + { + auto d = make_line(); + d.set_mix(100.0); + d.set_feedback(0.0); + d.set_time_ms(10.0); // 480 samples exactly + bool pre_silent = true; + for (int i = 0; i < 480; ++i) { + pre_silent = pre_silent && (d.process(i == 0 ? 1.0 : 0.0) == 0.0); + } + REQUIRE(pre_silent); + REQUIRE(d.process(0.0) == 1.0); + } +} + +SCENARIO("the multitap pan law is equal-power with exact endpoints") { + dly::multitap m; + m.prepare(k_sr, 1000.0); + m.set_smooth_ms(0.0); + m.set_taps(1); + m.set_time_ms(0, 5.0); // 240 samples exactly + m.set_gain(0, 1.0); + m.set_pan(0, -1.0); + + // Full left: the right bus never sees the tap, bitwise; the left bus gets the impulse whole. + const int n = 600; + double l = 0.0, r = 0.0; + bool right_silent = true; + double left_at_tap = 0.0; + for (int i = 0; i < n; ++i) { + m.process(i == 0 ? 1.0 : 0.0, l, r); + right_silent = right_silent && (r == 0.0); + if (i == 240) { + left_at_tap = l; + } + } + REQUIRE(right_silent); + REQUIRE(left_at_tap == 1.0); + + // Center: both buses at cos(pi/4) = 1/sqrt(2). + m.clear(); + m.set_pan(0, 0.0); + double center_l = 0.0, center_r = 0.0; + for (int i = 0; i <= 240; ++i) { + m.process(i == 0 ? 1.0 : 0.0, l, r); + if (i == 240) { + center_l = l; + center_r = r; + } + } + REQUIRE(std::abs(center_l - 1.0 / std::sqrt(2.0)) < 1e-12); + REQUIRE(std::abs(center_r - 1.0 / std::sqrt(2.0)) < 1e-12); +} + +SCENARIO("modulating the time under Hermite keeps sample deltas bounded where truncation steps") { + // Sweep the tap from 15 ms down to 5 ms (480 samples of travel) under a 2 kHz sine, via the + // signal-rate override. Hermite output is a mild doppler (deltas within a few percent of the + // static sine's analytic bound); truncation skips a whole input sample at each integer + // crossing, roughly doubling the worst delta. This is the promise interp 1 exists to keep. + const double freq = 2000.0; + const double w = 2.0 * k_pi * freq / k_sr; + const double delta_clean = 2.0 * std::sin(0.5 * w); // analytic max per-sample delta of the sine + const int n_fill = 2000; + const int n_sweep = 24000; + + const auto max_delta = [&](int interp) { + auto d = make_line(); + d.set_mix(100.0); + d.set_feedback(0.0); + d.set_interp(interp); + double worst = 0.0; + double prev = 0.0; + for (int i = 0; i < n_fill + n_sweep; ++i) { + const double t = + (i < n_fill) ? 15.0 : 15.0 - 10.0 * static_cast(i - n_fill) / static_cast(n_sweep); + const double y = d.process(std::sin(w * i), t); + if (i > n_fill + 100) { + worst = std::max(worst, std::abs(y - prev)); + } + prev = y; + } + return worst; + }; + + const double hermite = max_delta(dly::interp_hermite); + const double trunc = max_delta(dly::interp_trunc); + INFO("analytic clean delta " << delta_clean << ", hermite " << hermite << ", trunc " << trunc); + REQUIRE(hermite < 1.25 * delta_clean); // smooth doppler, no steps + REQUIRE(trunc > 1.5 * delta_clean); // the legacy whole-sample zipper, preserved on purpose +} diff --git a/tests/harmonizer_test.cpp b/tests/harmonizer_test.cpp index 31d3e78..eab1c6b 100644 --- a/tests/harmonizer_test.cpp +++ b/tests/harmonizer_test.cpp @@ -240,3 +240,100 @@ SCENARIO("the harmonizer passes input through before prepare") { REQUIRE(hz.process(0.25) == 0.25); REQUIRE(hz.latency() == 0); } + +SCENARIO("a stored preset recalls its targets exactly") { + tap::tools::harmony::harmonizer hz; + hz.prepare(k_sr); + + hz.set_interval(0, 4.0); + hz.set_interval(1, 7.0); + hz.set_interval(2, -5.0); + hz.set_interval(3, 12.0); + hz.set_gain(0, 1.0); + hz.set_gain(1, 0.5); + hz.set_gain(2, 0.25); + hz.set_gain(3, 0.75); + hz.set_dry(0.5); + hz.set_glide(250.0); + hz.set_formant(false); + REQUIRE(hz.store_preset(5)); + + // scribble over everything + for (int v = 0; v < 4; ++v) { + hz.set_interval(v, 0.0); + hz.set_gain(v, 0.0); + } + hz.set_dry(1.0); + hz.set_glide(10.0); + hz.set_formant(true); + + REQUIRE(hz.recall_preset(5, 0.0)); + REQUIRE_FALSE(hz.morphing()); + CHECK(hz.interval(0) == 4.0); + CHECK(hz.interval(1) == 7.0); + CHECK(hz.interval(2) == -5.0); + CHECK(hz.interval(3) == 12.0); + CHECK(hz.gain(0) == 1.0); + CHECK(hz.gain(1) == 0.5); + CHECK(hz.gain(2) == 0.25); + CHECK(hz.gain(3) == 0.75); + CHECK(hz.dry() == 0.5); + CHECK(hz.glide() == 250.0); + CHECK(hz.formant() == false); + + // slot bounds are refused, not wrapped + REQUIRE_FALSE(hz.store_preset(-1)); + REQUIRE_FALSE(hz.store_preset(16)); + REQUIRE_FALSE(hz.recall_preset(16, 0.0)); +} + +SCENARIO("a timed recall glides an interval through the middle") { + tap::tools::harmony::harmonizer hz; + hz.prepare(k_sr); + hz.set_dry(0.0); + hz.set_gain(0, 1.0); + hz.set_interval(0, 0.0); + + auto settle = run_saw(hz, 220.0, 0.75); // land the first chord + + auto chord = hz.snap_targets(); // the second chord: an octave up + chord.v[tap::tools::harmony::p_interval_0] = 12.0; + REQUIRE(hz.set_preset(1, chord)); + REQUIRE(hz.recall_preset(1, 0.7)); + REQUIRE(hz.morphing()); + + // Mid-morph the pitch must sit strictly between the start and the target. + auto mid = run_saw(hz, 220.0, 0.35); + const double mid_hz = measure_hz(mid); + REQUIRE(mid_hz > 240.0); + REQUIRE(mid_hz < 425.0); + + // And well past the morph it must settle on the octave. + auto end = run_saw(hz, 220.0, 2.0); + REQUIRE_FALSE(hz.morphing()); + REQUIRE(std::abs(cents(measure_hz(end), 440.0)) < 10.0); + (void)settle; +} + +SCENARIO("recall with zero seconds lands immediately") { + tap::tools::harmony::harmonizer hz; + hz.prepare(k_sr); + hz.set_dry(0.0); + hz.set_gain(0, 1.0); + hz.set_interval(0, 0.0); + + auto chord = hz.snap_targets(); + chord.v[tap::tools::harmony::p_interval_0] = 7.0; + REQUIRE(hz.set_preset(2, chord)); + + auto settle = run_saw(hz, 220.0, 0.5); + REQUIRE(hz.recall_preset(2, 0.0)); + REQUIRE_FALSE(hz.morphing()); + REQUIRE(hz.interval(0) == 7.0); // the target is there at once ... + + // ... and the ordinary 10 ms glide slew de-clicks the landing, so the pitch is + // on the fifth well within the run. + auto out = run_saw(hz, 220.0, 1.0); + REQUIRE(std::abs(cents(measure_hz(out), 220.0 * std::exp2(7.0 / 12.0))) < 10.0); + (void)settle; +} diff --git a/tests/vco_test.cpp b/tests/vco_test.cpp index ed34955..67f0ae6 100644 --- a/tests/vco_test.cpp +++ b/tests/vco_test.cpp @@ -227,3 +227,141 @@ SCENARIO("with imperfection up, different seeds are different units off the line } REQUIRE(maxdiff > 1e-3); // different seed, audibly different tolerances } + +namespace { + + /// Per-cycle instantaneous periods (seconds) with their start times, via rising zero crossings. + std::vector> cycle_periods(const std::vector& y) { + std::vector> out; + double prev = -1.0; + for (size_t i = 1; i < y.size(); ++i) { + if (y[i - 1] <= 0.0 && y[i] > 0.0) { + const double t = (static_cast(i - 1) + y[i - 1] / (y[i - 1] - y[i])) / k_sr; + if (prev >= 0.0) { + out.emplace_back(prev, t - prev); + } + prev = t; + } + } + return out; + } + + /// Peak pitch deviation from the mean, in cents, over cycles within [t0, t1). + double peak_cents_in(const std::vector>& cycles, double t0, double t1) { + double mean = 0.0; + int n = 0; + for (const auto& [t, p] : cycles) { + if (t >= t0 && t < t1) { + mean += p; + ++n; + } + } + if (n == 0) { + return 0.0; + } + mean /= n; + double peak = 0.0; + for (const auto& [t, p] : cycles) { + if (t >= t0 && t < t1) { + peak = std::max(peak, std::abs(1200.0 * std::log2(mean / p))); + } + } + return peak; + } + +} // namespace + +SCENARIO("vibrato at depth 0 leaves the ideal oscillator bit-identical") { + auto a = make(5), b = make(5); + for (auto* o : {&a, &b}) { + o->set_frequency(440.0); + o->set_shape(vco::wave_saw); + } + b.set_vibrato_rate(7.0); + b.set_vibrato_delay(300.0); + // depth stays 0 — phase and onset machinery must not perturb the output + REQUIRE(render(a, 48000) == render(b, 48000)); +} + +SCENARIO("vibrato depth is calibrated in cents and moves at the commanded rate") { + auto o = make(3); + o.set_frequency(220.0); + o.set_shape(vco::wave_sine); + o.set_vibrato_rate(2.0); + o.set_vibrato(100.0); // +/- one semitone + const auto y = render(o, 3 * 48000); + const auto cycles = cycle_periods(y); + + // Depth: the peak deviation across a settled stretch reads the commanded cents. + const double peak = peak_cents_in(cycles, 1.0, 3.0); + INFO("peak deviation " << peak << " cents"); + REQUIRE(peak > 90.0); + REQUIRE(peak < 110.0); + + // Rate: the period track crosses its mean 2 x rate times per second. + double mean = 0.0; + int n = 0; + for (const auto& [t, p] : cycles) { + if (t >= 1.0) { + mean += p; + ++n; + } + } + mean = mean / n; + int crossings = 0; + double prev = 0.0; + bool first = true; + for (const auto& [t, p] : cycles) { + if (t < 1.0) { + continue; + } + const double d = p - mean; + if (!first && ((prev <= 0.0 && d > 0.0) || (prev >= 0.0 && d < 0.0))) { + ++crossings; + } + prev = d; + first = false; + } + INFO("mean crossings over 2 s: " << crossings << " (expect ~8 at 2 Hz)"); + REQUIRE(crossings >= 6); + REQUIRE(crossings <= 10); +} + +SCENARIO("vibrato_delay fades the vibrato in and re-arms on a new note") { + auto o = make(4); + o.set_frequency(220.0); + o.set_shape(vco::wave_sine); + o.set_vibrato(100.0); + o.set_vibrato_rate(6.0); + o.set_vibrato_delay(400.0); + + const auto y1 = render(o, 3 * 48000); + const auto c1 = cycle_periods(y1); + const double early = peak_cents_in(c1, 0.0, 0.2); + const double settled = peak_cents_in(c1, 2.0, 3.0); + INFO("early " << early << " cents, settled " << settled << " cents"); + REQUIRE(early < 0.6 * settled); // still fading in + REQUIRE(settled > 90.0); + + // A new note re-arms the onset: right after the frequency change the vibrato is shallow again. + o.set_frequency(330.0); + const auto y2 = render(o, 48000); + const auto c2 = cycle_periods(y2); + const double rearmed = peak_cents_in(c2, 0.02, 0.2); // skip the frequency ramp itself + INFO("re-armed early deviation " << rearmed << " cents"); + REQUIRE(rearmed < 0.6 * settled); +} + +SCENARIO("bend is calibrated in semitones and rides the ramp") { + auto o = make(2); + o.set_frequency(220.0); + o.set_shape(vco::wave_sine); + render(o, 4800); + o.set_bend(2.0); + const auto y = render(o, 48000); + const auto tail = std::vector(y.begin() + 24000, y.end()); + const double f = measure_f0(tail); + const double expected = 220.0 * std::exp2(2.0 / 12.0); + INFO("bent to " << f << " Hz, expected " << expected); + REQUIRE(std::abs(1200.0 * std::log2(f / expected)) < 5.0); +} diff --git a/tests/vocoder_test.cpp b/tests/vocoder_test.cpp index a937304..f219deb 100644 --- a/tests/vocoder_test.cpp +++ b/tests/vocoder_test.cpp @@ -1,12 +1,15 @@ /// @file /// @brief Unit tests for the channel-vocoder kernel (tap::tools::vocoder::bank). -/// @details Checks the structural invariants: a silent carrier yields silence; makeup gain -/// scales the output linearly; a silent modulator lets the per-band envelopes decay to -/// silence; and processing is deterministic. +/// @details Checks the structural invariants: a silent carrier yields silence (at the default +/// sibilance of 0); makeup gain scales the output linearly; a silent modulator lets +/// the per-band envelopes decay to silence; processing is deterministic; the sibilance +/// path excites only when the modulator has high-band energy; the mix control's +/// endpoints are exact; and the seeded noise renders bit-identically per seed. // SPDX-License-Identifier: MIT // Copyright 2001-2026 Timothy Place. #include +#include #include #include @@ -23,17 +26,130 @@ namespace { } // namespace SCENARIO("a silent carrier yields a silent output regardless of the modulator") { + // The pinned contract at the default sibilance of 0 (set explicitly below so the pin survives + // any future default change): with no carrier there is nothing to shape, even with an HF-rich + // modulator charging the top-band envelopes. tap::tools::vocoder::bank v; v.prepare(48000.0); + v.set_sibilance(0.0); double peak = 0.0; for (int t = 0; t < 8000; ++t) { - const double out = v.process(sine(t, 220.0, 48000.0), 0.0); + const double mod = sine(t, 220.0, 48000.0) + sine(t, 6000.0, 48000.0); + const double out = v.process(mod, 0.0); peak = std::max(peak, std::abs(out)); } REQUIRE(peak < 1e-12); } +SCENARIO("the sibilance path excites the top bands only when the modulator has high-band energy") { + const double sr = 48000.0; + + // Sibilance full up, carrier silent: any output can only come from the noise fed to the >4 kHz + // carrier bands, gated by the modulator envelopes. + auto peak_with_modulator = [&](double mod_hz) { + tap::tools::vocoder::bank v; + v.prepare(sr); + v.set_sibilance(1.0); + double peak = 0.0; + for (int t = 0; t < 48000; ++t) { + const double out = v.process(sine(t, mod_hz, sr), 0.0); + if (t > 8000) { // past the envelope charge-up + peak = std::max(peak, std::abs(out)); + } + } + return peak; + }; + + const double hf_peak = peak_with_modulator(6000.0); // inside the sibilance bands + const double lf_peak = peak_with_modulator(200.0); // far below every sibilance band + + // The LF case is not mathematically zero — the Q=20 skirts leak a little 200 Hz energy into + // the top-band envelopes (~0.0006 peak vs ~0.14 for the HF case, measured) — so pin the two + // regimes with an order-of-magnitude gap rather than absolute silence. + REQUIRE(hf_peak > 0.05); // HF modulator energy opens the noise path + REQUIRE(lf_peak < 0.005); // an LF-only modulator leaves it essentially shut + REQUIRE(lf_peak < hf_peak / 20.0); // and far below the open state +} + +SCENARIO("at sibilance 0 the output is bit-identical to the noise-free kernel") { + // Same input, one bank at the default, one with the sibilance setter touched at 0 and a + // different seed: the noise path must contribute nothing at all. + tap::tools::vocoder::bank a, b; + a.prepare(48000.0); + b.prepare(48000.0); + b.set_sibilance(0.0); + b.set_seed(12345u); + + for (int t = 0; t < 8000; ++t) { + const double mod = sine(t, 300.0, 48000.0) + sine(t, 7000.0, 48000.0); + const double car = sine(t, 1700.0, 48000.0); + REQUIRE(a.process(mod, car) == b.process(mod, car)); + } +} + +SCENARIO("mix 0 returns the dry carrier and mix 100 matches the full-wet kernel exactly") { + const double sr = 48000.0; + + GIVEN("mix 0") { + tap::tools::vocoder::bank v; + v.prepare(sr); + v.set_mix(0.0); + v.set_gain(3.0); // makeup gain is wet-side only; the dry carrier must pass untouched + for (int t = 0; t < 8000; ++t) { + const double car = sine(t, 1700.0, sr); + REQUIRE(v.process(sine(t, 300.0, sr), car) == car); + } + } + + GIVEN("mix 100 against a default (never-touched) bank") { + tap::tools::vocoder::bank a, b; + a.prepare(sr); + b.prepare(sr); + b.set_mix(100.0); + for (int t = 0; t < 8000; ++t) { + const double mod = sine(t, 300.0, sr); + const double car = sine(t, 1700.0, sr); + REQUIRE(a.process(mod, car) == b.process(mod, car)); + } + } + + GIVEN("an intermediate mix, which blends with equal power") { + // At 50 % both gains are cos/sin(pi/4); spot-check one sample stream against the formula. + tap::tools::vocoder::bank wet, mixed; + wet.prepare(sr); + mixed.prepare(sr); + mixed.set_mix(50.0); + const double g = std::sqrt(0.5); + double maxerr = 0.0; + for (int t = 0; t < 8000; ++t) { + const double mod = sine(t, 300.0, sr); + const double car = sine(t, 1700.0, sr); + const double yw = wet.process(mod, car); + const double ym = mixed.process(mod, car); + maxerr = std::max(maxerr, std::abs(ym - (g * car + g * yw))); + } + REQUIRE(maxerr < 1e-12); + } +} + +SCENARIO("the sibilance noise renders bit-identically for the same seed") { + auto run = [](uint32_t seed) { + tap::tools::vocoder::bank v; + v.prepare(48000.0); + v.set_seed(seed); + v.set_sibilance(0.7); + std::vector out; + for (int t = 0; t < 8000; ++t) { + out.push_back(v.process(sine(t, 6000.0, 48000.0), 0.0)); + } + return out; + }; + const std::vector a = run(42u), b = run(42u), c = run(43u); + REQUIRE(a == b); // same seed: bit-identical + REQUIRE(a != c); // different seed: a different noise sequence +} + SCENARIO("makeup gain scales the output linearly") { tap::tools::vocoder::bank a, b; a.prepare(48000.0); diff --git a/tools/capi/taptools_capi.cpp b/tools/capi/taptools_capi.cpp index a5bacc6..29d3a62 100644 --- a/tools/capi/taptools_capi.cpp +++ b/tools/capi/taptools_capi.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -908,6 +909,129 @@ int taptools_yin_track(taptools_yin h, const double* x, int n, int hop, double* return count; } +// ---- tap.delay~ ---------------------------------------------------------------------------------- + +using delay_line = tap::tools::delay::line; + +taptools_delay taptools_delay_create(void) { + return static_cast(new delay_line()); +} + +void taptools_delay_destroy(taptools_delay h) { + delete static_cast(h); +} + +int taptools_delay_prepare(taptools_delay h, double sr, double max_ms) { + if (max_ms <= 0.0) { + return -1; + } + return with(h, [&](delay_line& d) { d.prepare(sr, max_ms); }); +} + +int taptools_delay_set_time_ms(taptools_delay h, double ms) { + return with(h, [&](delay_line& d) { d.set_time_ms(ms); }); +} + +int taptools_delay_set_feedback(taptools_delay h, double fb) { + return with(h, [&](delay_line& d) { d.set_feedback(fb); }); +} + +int taptools_delay_set_mix(taptools_delay h, double pct) { + return with(h, [&](delay_line& d) { d.set_mix(pct); }); +} + +int taptools_delay_set_interp(taptools_delay h, int mode) { + return with(h, [&](delay_line& d) { d.set_interp(mode); }); +} + +int taptools_delay_set_smooth_ms(taptools_delay h, double ms) { + return with(h, [&](delay_line& d) { d.set_smooth_ms(ms); }); +} + +int taptools_delay_clear(taptools_delay h) { + return with(h, [&](delay_line& d) { d.clear(); }); +} + +int taptools_delay_process(taptools_delay h, const double* in, double* out, int n) { + if (!in || !out || n < 0) { + return -1; + } + return with(h, [&](delay_line& d) { + for (int i = 0; i < n; ++i) { + out[i] = d.process(in[i]); + } + }); +} + +int taptools_delay_process_mod(taptools_delay h, const double* in, const double* time_ms, double* out, int n) { + if (!in || !time_ms || !out || n < 0) { + return -1; + } + return with(h, [&](delay_line& d) { + for (int i = 0; i < n; ++i) { + out[i] = d.process(in[i], time_ms[i]); + } + }); +} + +// ---- tap.multitap~ ------------------------------------------------------------------------------- + +using multitap_kernel = tap::tools::delay::multitap; + +taptools_multitap taptools_multitap_create(void) { + return static_cast(new multitap_kernel()); +} + +void taptools_multitap_destroy(taptools_multitap h) { + delete static_cast(h); +} + +int taptools_multitap_prepare(taptools_multitap h, double sr, double max_ms) { + if (max_ms <= 0.0) { + return -1; + } + return with(h, [&](multitap_kernel& m) { m.prepare(sr, max_ms); }); +} + +int taptools_multitap_set_taps(taptools_multitap h, int count) { + return with(h, [&](multitap_kernel& m) { m.set_taps(count); }); +} + +int taptools_multitap_set_time_ms(taptools_multitap h, int tap, double ms) { + return with(h, [&](multitap_kernel& m) { m.set_time_ms(tap, ms); }); +} + +int taptools_multitap_set_gain(taptools_multitap h, int tap, double gain) { + return with(h, [&](multitap_kernel& m) { m.set_gain(tap, gain); }); +} + +int taptools_multitap_set_pan(taptools_multitap h, int tap, double pan) { + return with(h, [&](multitap_kernel& m) { m.set_pan(tap, pan); }); +} + +int taptools_multitap_set_interp(taptools_multitap h, int mode) { + return with(h, [&](multitap_kernel& m) { m.set_interp(mode); }); +} + +int taptools_multitap_set_smooth_ms(taptools_multitap h, double ms) { + return with(h, [&](multitap_kernel& m) { m.set_smooth_ms(ms); }); +} + +int taptools_multitap_clear(taptools_multitap h) { + return with(h, [&](multitap_kernel& m) { m.clear(); }); +} + +int taptools_multitap_process(taptools_multitap h, const double* in, double* outL, double* outR, int n) { + if (!in || !outL || !outR || n < 0) { + return -1; + } + return with(h, [&](multitap_kernel& m) { + for (int i = 0; i < n; ++i) { + m.process(in[i], outL[i], outR[i]); + } + }); +} + // ---- tap.overdrive~ ------------------------------------------------------------------------------ using tap::tools::od::overdrive; diff --git a/tools/capi/taptools_capi.h b/tools/capi/taptools_capi.h index 6429155..40c147a 100644 --- a/tools/capi/taptools_capi.h +++ b/tools/capi/taptools_capi.h @@ -296,6 +296,43 @@ TAPTOOLS_API int taptools_yin_frame_size(taptools_yin h); /// (0 where unvoiced). Returns the number written, or -1 on error. TAPTOOLS_API int taptools_yin_track(taptools_yin h, const double* x, int n, int hop, double* periods, int max_out); +// ---- tap.delay~ (tap::tools::delay::line) -------------------------------------------------------- + +typedef void* taptools_delay; + +TAPTOOLS_API taptools_delay taptools_delay_create(void); +TAPTOOLS_API void taptools_delay_destroy(taptools_delay h); +/// Allocate the line for `max_ms` at `sr`; snaps ramps and clears state. +TAPTOOLS_API int taptools_delay_prepare(taptools_delay h, double sr, double max_ms); +TAPTOOLS_API int taptools_delay_set_time_ms(taptools_delay h, double ms); +TAPTOOLS_API int taptools_delay_set_feedback(taptools_delay h, double fb); // clamped to [0, 0.99] +TAPTOOLS_API int taptools_delay_set_mix(taptools_delay h, double pct); // 0..100, equal-power +TAPTOOLS_API int taptools_delay_set_interp(taptools_delay h, int mode); // 0 trunc, 1 Hermite +TAPTOOLS_API int taptools_delay_set_smooth_ms(taptools_delay h, double ms); +TAPTOOLS_API int taptools_delay_clear(taptools_delay h); +TAPTOOLS_API int taptools_delay_process(taptools_delay h, const double* in, double* out, int n); +/// Per-sample signal-rate delay time (ms) — the external's right-inlet path. +TAPTOOLS_API int taptools_delay_process_mod(taptools_delay h, const double* in, const double* time_ms, double* out, + int n); + +// ---- tap.multitap~ (tap::tools::delay::multitap) ------------------------------------------------- + +typedef void* taptools_multitap; + +TAPTOOLS_API taptools_multitap taptools_multitap_create(void); +TAPTOOLS_API void taptools_multitap_destroy(taptools_multitap h); +TAPTOOLS_API int taptools_multitap_prepare(taptools_multitap h, double sr, double max_ms); +TAPTOOLS_API int taptools_multitap_set_taps(taptools_multitap h, int count); // 0..100 active taps +/// Per-tap setters; `tap` is 0-based. Gain is linear; pan is -1 (left) .. 1 (right), equal-power. +TAPTOOLS_API int taptools_multitap_set_time_ms(taptools_multitap h, int tap, double ms); +TAPTOOLS_API int taptools_multitap_set_gain(taptools_multitap h, int tap, double gain); +TAPTOOLS_API int taptools_multitap_set_pan(taptools_multitap h, int tap, double pan); +TAPTOOLS_API int taptools_multitap_set_interp(taptools_multitap h, int mode); // 0 trunc, 1 Hermite +TAPTOOLS_API int taptools_multitap_set_smooth_ms(taptools_multitap h, double ms); +TAPTOOLS_API int taptools_multitap_clear(taptools_multitap h); +/// Process n samples; the stereo tap sum lands in outL/outR (no dry path). +TAPTOOLS_API int taptools_multitap_process(taptools_multitap h, const double* in, double* outL, double* outR, int n); + // ---- tap.overdrive~ (tap::tools::od::overdrive, mono) -------------------------------------------- typedef void* taptools_od;