From 5633cdb14c2730120b05f3ddc11476457f3ea1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 00:22:46 +0000 Subject: [PATCH 1/6] Split the airport bank into lanes you can patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tap.airport~` was a monolith by accident, not by design: `loop_bank` already held eight `loop_state`s and summed them, but nothing outside the bank could reach one. Promote that struct to a real class, `airport::loop` — one free-running reel with its own head, tape, shade, level, pan, and record gate — and let the bank be an array of them plus the count, the shared smoothing time, and the lcm arithmetic. Nothing about the sound changes. The per-sample operation order is preserved exactly, and `loop::process` accumulates onto the stereo busses (the garden.h bell idiom) so the bank sums lanes without a scratch buffer. A 3-second render of five lanes through splices, staggered punch-ins, hard pans, shaded and bypassed darken, and live ramps hashes bit-for-bit identical before and after. The point is that a lane is now usable alone — it is what `tap.reel~` will wrap — and that seven of them patched into a sum ARE the bank. That identity is pinned in CI rather than asserted: the new scenario configures a three-lane bank and three standalone lanes identically, drives both through the same punch schedule, and requires bitwise-equal stereo output across two seconds (a 1e-12 level nudge on one lane fails it). Two more scenarios carry the phase discipline down to a lone lane — the same setter storm, the same sacred head — and pin that an unprepared lane adds nothing to the busses it is handed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe --- include/taptools/airport.h | 382 +++++++++++++++++++++++-------------- tests/airport_test.cpp | 113 +++++++++++ 2 files changed, 351 insertions(+), 144 deletions(-) diff --git a/include/taptools/airport.h b/include/taptools/airport.h index cb8dc39..6daf9a1 100644 --- a/include/taptools/airport.h +++ b/include/taptools/airport.h @@ -8,22 +8,31 @@ /// coincidence and the piece never repeats on a human timescale. The composition IS /// the phase system; the machine just keeps the loops turning. /// -/// Each of up to k_max_loops loops is a tape_loop.h reel with a single free-running -/// head that both plays and records. `record(loop, true)` punches the input onto that -/// loop's tape at wherever its head happens to be — the phase is NEVER reset, by -/// record or by any setter, because the free-run is the piece — and `record(loop, -/// false)` freezes the tape bit-exactly. Playback is read-before-write, so while -/// recording you hear the previous generation under the head. Per-loop level and -/// equal-power pan (exact endpoints, the delay.h multitap law) place each phrase in -/// the stereo field; a per-loop `darken` corner shades its playback tone (a -/// tape_loop.h wear stage with drive fixed at 0 — a real loop replays the *same* -/// magnetic imprint every pass, so there is no per-pass generation loss to model, and -/// pretending otherwise would be dishonest; at the band ceiling the stage is bypassed -/// entirely and playback is bit-transparent). +/// Two classes, because the piece is a system built from a trivial part: +/// - `loop` — ONE free-running reel: a tape_loop.h spool with a single head that both +/// plays and records, its playback shaded, leveled, and placed on the stereo field. +/// Everything a lane does lives here, so a lane is usable on its own (it is what +/// tap.reel~ wraps) and the bank is not the only way to reach it. +/// - `loop_bank` — up to k_max_loops of those and nothing else: it owns the count, the +/// shared smoothing time, and the composite-period arithmetic, and its process() is +/// a sum over the lanes. Seven lanes patched into a sum ARE the bank; that identity +/// is the point of the split, and it is what the null test pins. /// -/// Geometry: prepare(sr, max_loop_seconds) buys k_max_loops worst-case reels — the -/// family's largest buy (8 loops x 30 s at 48 kHz is ~92 MB of double tape); size -/// max_loop_seconds to the piece. No later call allocates. +/// `record(loop, true)` punches the input onto that loop's tape at wherever its head +/// happens to be — the phase is NEVER reset, by record or by any setter, because the +/// free-run is the piece — and `record(loop, false)` freezes the tape bit-exactly. +/// Playback is read-before-write, so while recording you hear the previous generation +/// under the head. Per-loop level and equal-power pan (exact endpoints, the delay.h +/// multitap law) place each phrase in the stereo field; a per-loop `darken` corner +/// shades its playback tone (a tape_loop.h wear stage with drive fixed at 0 — a real +/// loop replays the *same* magnetic imprint every pass, so there is no per-pass +/// generation loss to model, and pretending otherwise would be dishonest; at the band +/// ceiling the stage is bypassed entirely and playback is bit-transparent). +/// +/// Geometry: prepare(sr, max_loop_seconds) buys the worst-case reel — one per lane, so +/// the bank makes the family's largest buy (8 loops x 30 s at 48 kHz is ~92 MB of +/// double tape) while a standalone lane buys exactly one. Size max_loop_seconds to the +/// piece. No later call allocates. /// /// Honest limits: /// - A length change is a splice: the tape keeps its content and the head re-wraps @@ -49,6 +58,7 @@ #include #include #include +#include #include #include "tape_loop.h" // tap::tools::tape — reel / wear / ramp, the shared machinery @@ -61,47 +71,189 @@ namespace tap::tools { constexpr double k_default_max_seconds = 30.0; // worst case per loop (~92 MB total @ 48k) constexpr double k_default_smooth_ms = 20.0; // anti-zipper ramp for level/pan/darken - /// Up to eight free-running tape loops of unequal lengths, summed to stereo. - class loop_bank { + /// One free-running tape loop: a single head that both plays and records, its playback + /// shaded, leveled, and panned to a seat. A `loop_bank` is an array of these and nothing + /// more; one on its own is a complete instrument (tap.reel~), and the head is just as + /// sacred alone as it is in the bank — nothing but prepare()/clear() ever resets it. + class loop { public: - loop_bank() { - for (auto& l : m_loops) { - l.level.snap(1.0); - l.darken_hz.snap(tape::k_darken_ceil_hz); // transparent until asked to shade + loop() { + m_level.snap(1.0); + m_darken_hz.snap(tape::k_darken_ceil_hz); // transparent until asked to shade + } + + // -- lifecycle ----------------------------------------------------------------------- + + /// Buy the reel for `max_loop_seconds` at `sr`, apply the stored length, snap the + /// ramps, erase the tape, and rewind the head — a DSP restart is the one thing allowed + /// to touch the phase. Not real-time-safe. + void prepare(double sr, double max_loop_seconds = k_default_max_seconds) { + m_sr = (sr > 0.0) ? sr : 48000.0; + m_tape.prepare(m_sr, std::max(k_min_loop_seconds, max_loop_seconds)); + m_tape.set_loop_samples(seconds_to_samples(m_length_seconds)); + m_length_seconds = static_cast(m_tape.loop_samples()) / m_sr; + m_shade.prepare(m_sr); + m_level.snap(m_level.target()); + m_pan.snap(m_pan.target()); + m_darken_hz.snap(m_darken_hz.target()); + m_shade.set_cutoff_hz(m_darken_hz.current()); + clear(); + } + + /// Erase the tape and rewind the head; parameters (length, level, pan, darken, the + /// record gate) are untouched. + void clear() { + m_tape.clear(); + m_shade.clear(); + m_phase = 0.0; + } + + bool prepared() const { return m_tape.prepared(); } + + // -- structure (instant; never touches the phase) ------------------------------------ + + /// Length in seconds, clamped to [k_min_loop_seconds, the prepared max]. A splice: + /// content kept, head re-wraps modulo the new length, never rewinds. + void set_length_seconds(double s) { + m_length_seconds = std::max(k_min_loop_seconds, s); + if (m_tape.prepared()) { + m_tape.set_loop_samples(seconds_to_samples(m_length_seconds)); + m_length_seconds = static_cast(m_tape.loop_samples()) / m_sr; + const double n = static_cast(m_tape.loop_samples()); + m_phase = m_phase - std::floor(m_phase / n) * n; // re-wrap, no rewind } } + /// Punch the input onto the tape (true) or freeze it bit-exactly (false). Recording + /// replaces — no overdub sum; Eno recorded each phrase once. + void record(bool on) { m_recording = on; } + + // -- parameter targets (click-free; safe while audio runs) --------------------------- + + /// Linear playback level, slewed. Unclamped (negative flips polarity). + void set_level(double lin) { m_level.to(lin, smooth_samples()); } + + /// Equal-power pan, -1 (hard left) .. 1 (hard right), slewed. Endpoints are exact: a + /// hard-panned loop is bitwise absent from the far bus (delay.h law). + void set_pan(double pan) { m_pan.to(std::clamp(pan, -1.0, 1.0), smooth_samples()); } + + /// Playback darkening corner in Hz, slewed. At the band ceiling (the default) the + /// stage is bypassed and playback is bit-transparent. + void set_darken_hz(double hz) { + m_darken_hz.to(std::clamp(hz, tape::k_darken_floor_hz, tape::k_darken_ceil_hz), smooth_samples()); + } + + void set_smooth_ms(double ms) { m_smooth_ms = std::max(0.0, ms); } + + // -- introspection ------------------------------------------------------------------- + + double length_seconds() const { return m_length_seconds; } + bool recording() const { return m_recording; } + double level() const { return m_level.target(); } + double pan() const { return m_pan.target(); } + double darken_hz() const { return m_darken_hz.target(); } + double smooth_ms() const { return m_smooth_ms; } + double samplerate() const { return m_sr; } + double max_loop_seconds() const { return prepared() ? static_cast(m_tape.capacity()) / m_sr : 0.0; } + + /// The active loop length in samples — what the bank's lcm arithmetic reads. + long loop_samples() const { return m_tape.loop_samples(); } + + /// The head position as a fraction of the length, 0..1 — read-only, so tests can pin + /// the promise that nothing but prepare()/clear() ever resets it. + double phase() const { return prepared() ? m_phase / static_cast(m_tape.loop_samples()) : 0.0; } + + // -- audio --------------------------------------------------------------------------- + + /// Play the head onto the stereo busses, punch `in` onto the tape if recording, then + /// advance. ACCUMULATES onto the busses (the garden.h bell idiom) so a bank can sum + /// lanes without a scratch buffer; a standalone caller zeroes them first. + void process(double in, double& out_left, double& out_right) { + if (!prepared()) { + return; + } + const double played = m_tape.read_hermite(m_phase); + const double shade_hz = m_darken_hz.tick(); + double toned = played; + if (shade_hz < tape::k_darken_ceil_hz) { // ceiling = bypass, bit-transparent + if (shade_hz != m_shade.cutoff_hz()) { + m_shade.set_cutoff_hz(shade_hz); + } + toned = m_shade.process(played); + } + const double g = m_level.tick() * toned; + const double pan = m_pan.tick(); + // Equal-power with exact endpoints — same law as delay.h multitap. + if (pan <= -1.0) { + out_left += g; + } + else if (pan >= 1.0) { + out_right += g; + } + else { + const double theta = (pan + 1.0) * 0.25 * tape::k_pi; + out_left += std::cos(theta) * g; + out_right += std::sin(theta) * g; + } + if (m_recording) { // read-before-write: you hear the old pass under the head + m_tape.write(static_cast(std::floor(m_phase)), in); + } + m_phase += 1.0; + if (m_phase >= static_cast(m_tape.loop_samples())) { + m_phase -= static_cast(m_tape.loop_samples()); + } + } + + /// Block form for a lane used on its own: ASSIGNS, because the scalar form + /// accumulates. A bank sums the scalar form instead. + void process(const double* in, double* out_left, double* out_right, size_t n) { + for (size_t i = 0; i < n; ++i) { + out_left[i] = 0.0; + out_right[i] = 0.0; + process(in[i], out_left[i], out_right[i]); + } + } + + private: + long smooth_samples() const { return static_cast(m_smooth_ms * 0.001 * m_sr); } + long seconds_to_samples(double s) const { return static_cast(std::ceil(s * m_sr)); } + + tape::reel m_tape; + tape::wear m_shade; // playback tone only: drive stays 0, bypassed at ceiling + double m_phase{0.0}; // samples into the loop; the piece lives here + double m_sr{48000.0}; + double m_smooth_ms{k_default_smooth_ms}; + double m_length_seconds{k_min_loop_seconds}; + bool m_recording{false}; + tape::ramp m_level; // linear + tape::ramp m_pan; // -1..1 + tape::ramp m_darken_hz; // Hz + }; + + /// Up to eight free-running tape loops of unequal lengths, summed to stereo — an array of + /// `loop` plus the count, the shared smoothing time, and the lcm arithmetic. + class loop_bank { + public: // -- lifecycle ----------------------------------------------------------------------- - /// Buy k_max_loops reels for `max_loop_seconds` at `sr`, apply the stored lengths, - /// snap all ramps, erase all tape, and rewind every head — a DSP restart is the one - /// thing allowed to touch the phases. Not real-time-safe. + /// Buy k_max_loops reels for `max_loop_seconds` at `sr` and restart every lane — a DSP + /// restart is the one thing allowed to touch the phases. Not real-time-safe. void prepare(double sr, double max_loop_seconds = k_default_max_seconds) { m_sr = (sr > 0.0) ? sr : 48000.0; for (auto& l : m_loops) { - l.tape.prepare(m_sr, std::max(k_min_loop_seconds, max_loop_seconds)); - l.tape.set_loop_samples(seconds_to_samples(l.length_seconds)); - l.length_seconds = static_cast(l.tape.loop_samples()) / m_sr; - l.shade.prepare(m_sr); - l.level.snap(l.level.target()); - l.pan.snap(l.pan.target()); - l.darken_hz.snap(l.darken_hz.target()); - l.shade.set_cutoff_hz(l.darken_hz.current()); + l.prepare(m_sr, max_loop_seconds); } - clear(); } /// Erase every tape and rewind every head; parameters (lengths, levels, pans, darken, /// record gates) are untouched. void clear() { for (auto& l : m_loops) { - l.tape.clear(); - l.shade.clear(); - l.phase = 0.0; + l.clear(); } } - bool prepared() const { return m_loops[0].tape.prepared(); } + bool prepared() const { return m_loops[0].prepared(); } // -- structure (instant; never touches a phase) -------------------------------------- @@ -109,89 +261,72 @@ namespace tap::tools { /// at their stored settings, their heads wherever they last were. void set_loops(int count) { m_num_loops = std::clamp(count, 0, k_max_loops); } - /// Per-loop length in seconds, clamped to [k_min_loop_seconds, the prepared max]. - /// A splice: content kept, head re-wraps modulo the new length, never rewinds. - void set_length_seconds(int loop, double s) { - if (!valid_loop(loop)) { - return; - } - loop_state& l = m_loops[static_cast(loop)]; - l.length_seconds = std::max(k_min_loop_seconds, s); - if (l.tape.prepared()) { - l.tape.set_loop_samples(seconds_to_samples(l.length_seconds)); - l.length_seconds = static_cast(l.tape.loop_samples()) / m_sr; - const double n = static_cast(l.tape.loop_samples()); - l.phase = l.phase - std::floor(l.phase / n) * n; // re-wrap, no rewind + /// Per-loop length in seconds. A splice — see loop::set_length_seconds. + void set_length_seconds(int index, double s) { + if (valid_loop(index)) { + lane_ref(index).set_length_seconds(s); } } /// Punch the input onto this loop's tape (true) or freeze it bit-exactly (false). - /// Recording replaces — no overdub sum; Eno recorded each phrase once. - void record(int loop, bool on) { - if (valid_loop(loop)) { - m_loops[static_cast(loop)].recording = on; + void record(int index, bool on) { + if (valid_loop(index)) { + lane_ref(index).record(on); } } // -- parameter targets (click-free; safe while audio runs) --------------------------- - /// Per-loop linear playback level, slewed. Unclamped (negative flips polarity). - void set_level(int loop, double lin) { - if (valid_loop(loop)) { - m_loops[static_cast(loop)].level.to(lin, smooth_samples()); + void set_level(int index, double lin) { + if (valid_loop(index)) { + lane_ref(index).set_level(lin); } } - /// Per-loop equal-power pan, -1 (hard left) .. 1 (hard right), slewed. Endpoints are - /// exact: a hard-panned loop is bitwise absent from the far bus (delay.h law). - void set_pan(int loop, double pan) { - if (valid_loop(loop)) { - m_loops[static_cast(loop)].pan.to(std::clamp(pan, -1.0, 1.0), smooth_samples()); + void set_pan(int index, double pan) { + if (valid_loop(index)) { + lane_ref(index).set_pan(pan); } } - /// Per-loop playback darkening corner in Hz, slewed. At the band ceiling (the - /// default) the stage is bypassed and playback is bit-transparent. - void set_darken_hz(int loop, double hz) { - if (valid_loop(loop)) { - m_loops[static_cast(loop)].darken_hz.to( - std::clamp(hz, tape::k_darken_floor_hz, tape::k_darken_ceil_hz), smooth_samples()); + void set_darken_hz(int index, double hz) { + if (valid_loop(index)) { + lane_ref(index).set_darken_hz(hz); } } - void set_smooth_ms(double ms) { m_smooth_ms = std::max(0.0, ms); } + /// The anti-zipper window, shared by every lane. + void set_smooth_ms(double ms) { + m_smooth_ms = std::max(0.0, ms); + for (auto& l : m_loops) { + l.set_smooth_ms(m_smooth_ms); + } + } + + // -- lanes --------------------------------------------------------------------------- + + /// Direct access to one lane — the same object a standalone tap.reel~ holds, so a + /// caller (or a null test) can drive bank and lane through one code path. + loop& lane(int index) { return lane_ref(index); } + const loop& lane(int index) const { + return m_loops[static_cast(std::clamp(index, 0, k_max_loops - 1))]; + } // -- introspection ------------------------------------------------------------------- int loops() const { return m_num_loops; } - double length_seconds(int loop) const { - return valid_loop(loop) ? m_loops[static_cast(loop)].length_seconds : 0.0; - } - bool recording(int loop) const { return valid_loop(loop) && m_loops[static_cast(loop)].recording; } - double level(int loop) const { - return valid_loop(loop) ? m_loops[static_cast(loop)].level.target() : 0.0; - } - double pan(int loop) const { - return valid_loop(loop) ? m_loops[static_cast(loop)].pan.target() : 0.0; - } - double darken_hz(int loop) const { - return valid_loop(loop) ? m_loops[static_cast(loop)].darken_hz.target() : 0.0; - } + double length_seconds(int index) const { return valid_loop(index) ? lane(index).length_seconds() : 0.0; } + bool recording(int index) const { return valid_loop(index) && lane(index).recording(); } + double level(int index) const { return valid_loop(index) ? lane(index).level() : 0.0; } + double pan(int index) const { return valid_loop(index) ? lane(index).pan() : 0.0; } + double darken_hz(int index) const { return valid_loop(index) ? lane(index).darken_hz() : 0.0; } double smooth_ms() const { return m_smooth_ms; } double samplerate() const { return m_sr; } - double max_loop_seconds() const { - return prepared() ? static_cast(m_loops[0].tape.capacity()) / m_sr : 0.0; - } + double max_loop_seconds() const { return m_loops[0].max_loop_seconds(); } /// This loop's head position as a fraction of its length, 0..1 — read-only, so tests /// can pin the promise that nothing but prepare()/clear() ever resets it. - double phase(int loop) const { - if (!valid_loop(loop) || !prepared()) { - return 0.0; - } - const loop_state& l = m_loops[static_cast(loop)]; - return l.phase / static_cast(l.tape.loop_samples()); - } + double phase(int index) const { return valid_loop(index) ? lane(index).phase() : 0.0; } /// Least common multiple of the active loop lengths, in seconds — how long until the /// whole system realigns. Informational; +inf on 64-bit overflow (incommensurate @@ -202,7 +337,7 @@ namespace tap::tools { } long long acc = 1; for (int i = 0; i < m_num_loops; ++i) { - const long long n = static_cast(m_loops[static_cast(i)].tape.loop_samples()); + const long long n = static_cast(lane(i).loop_samples()); const long long g = gcd_ll(acc, n); if (acc / g > std::numeric_limits::max() / n) { return std::numeric_limits::infinity(); @@ -214,7 +349,8 @@ namespace tap::tools { // -- audio --------------------------------------------------------------------------- - /// Sum the active loops to the stereo bus; punch `in` onto any recording loop. + /// Sum the active loops to the stereo bus; punch `in` onto any recording loop. This is + /// the whole of the bank's DSP: the lanes do the rest. void process(double in, double& out_left, double& out_right) { out_left = 0.0; out_right = 0.0; @@ -222,37 +358,7 @@ namespace tap::tools { return; } for (int i = 0; i < m_num_loops; ++i) { - loop_state& l = m_loops[static_cast(i)]; - const double played = l.tape.read_hermite(l.phase); - const double shade_hz = l.darken_hz.tick(); - double toned = played; - if (shade_hz < tape::k_darken_ceil_hz) { // ceiling = bypass, bit-transparent - if (shade_hz != l.shade.cutoff_hz()) { - l.shade.set_cutoff_hz(shade_hz); - } - toned = l.shade.process(played); - } - const double g = l.level.tick() * toned; - const double pan = l.pan.tick(); - // Equal-power with exact endpoints — same law as delay.h multitap. - if (pan <= -1.0) { - out_left += g; - } - else if (pan >= 1.0) { - out_right += g; - } - else { - const double theta = (pan + 1.0) * 0.25 * tape::k_pi; - out_left += std::cos(theta) * g; - out_right += std::sin(theta) * g; - } - if (l.recording) { // read-before-write: you hear the old pass under the head - l.tape.write(static_cast(std::floor(l.phase)), in); - } - l.phase += 1.0; - if (l.phase >= static_cast(l.tape.loop_samples())) { - l.phase -= static_cast(l.tape.loop_samples()); - } + m_loops[static_cast(i)].process(in, out_left, out_right); } } @@ -264,17 +370,6 @@ namespace tap::tools { } private: - struct loop_state { - tape::reel tape; - tape::wear shade; // playback tone only: drive stays 0, bypassed at ceiling - double phase{0.0}; // samples into the loop; the piece lives here - double length_seconds{k_min_loop_seconds}; - bool recording{false}; - tape::ramp level; // linear - tape::ramp pan; // -1..1 - tape::ramp darken_hz; // Hz - }; - static long long gcd_ll(long long a, long long b) { while (b != 0) { const long long t = a % b; @@ -284,14 +379,13 @@ namespace tap::tools { return a; } - bool valid_loop(int loop) const { return loop >= 0 && loop < k_max_loops; } - long smooth_samples() const { return static_cast(m_smooth_ms * 0.001 * m_sr); } - long seconds_to_samples(double s) const { return static_cast(std::ceil(s * m_sr)); } + bool valid_loop(int index) const { return index >= 0 && index < k_max_loops; } + loop& lane_ref(int index) { return m_loops[static_cast(std::clamp(index, 0, k_max_loops - 1))]; } - double m_sr{48000.0}; - double m_smooth_ms{k_default_smooth_ms}; - int m_num_loops{0}; - std::array m_loops; + double m_sr{48000.0}; + double m_smooth_ms{k_default_smooth_ms}; + int m_num_loops{0}; + std::array m_loops; }; } // namespace airport diff --git a/tests/airport_test.cpp b/tests/airport_test.cpp index f84e803..35d4a18 100644 --- a/tests/airport_test.cpp +++ b/tests/airport_test.cpp @@ -21,6 +21,8 @@ namespace { constexpr double k_sr = 48000.0; using tap::tools::airport::loop_bank; + // Spelled `lane` here: several scenarios use `loop` as a local sample count. + using lane = tap::tools::airport::loop; loop_bank make(double max_loop_seconds = 2.0) { loop_bank b; @@ -29,6 +31,11 @@ namespace { return b; } + void make_lane(lane& l, double max_loop_seconds = 2.0) { + l.prepare(k_sr, max_loop_seconds); + l.set_smooth_ms(0.0); + } + size_t at(double seconds) { return static_cast(seconds * k_sr); } @@ -239,3 +246,109 @@ SCENARIO("unprepared, the bank emits silence") { REQUIRE(l == 0.0); REQUIRE(r == 0.0); } + +SCENARIO("standalone lanes summed are the bank, bitwise") { + // The decomposition's load-bearing claim: tap.reel~ patched N times into a sum IS + // tap.airport~. Bitwise, because every stage the claim passes through (transparent + // playback, exact pan endpoints, the bypassed shade) is a bitwise promise already. + constexpr int n_lanes = 3; + const double len[n_lanes] = {0.53, 0.61, 0.71}; // incommensurate, all above the floor + const double lvl[n_lanes] = {0.4, 0.7, 0.55}; + const double pn[n_lanes] = {-1.0, 0.25, 1.0}; // both exact endpoints and one interior + const double drk[n_lanes] = {1000.0, tap::tools::tape::k_darken_ceil_hz, 4000.0}; // shaded, bypassed, shaded + + loop_bank b = make(1.5); + b.set_loops(n_lanes); + for (int i = 0; i < n_lanes; ++i) { + b.set_length_seconds(i, len[i]); + b.set_level(i, lvl[i]); + b.set_pan(i, pn[i]); + b.set_darken_hz(i, drk[i]); + } + + std::vector lanes(n_lanes); + for (int i = 0; i < n_lanes; ++i) { + make_lane(lanes[static_cast(i)], 1.5); + lanes[static_cast(i)].set_length_seconds(len[i]); + lanes[static_cast(i)].set_level(lvl[i]); + lanes[static_cast(i)].set_pan(pn[i]); + lanes[static_cast(i)].set_darken_hz(drk[i]); + } + + bool exact = true; + double peak = 0.0; + const size_t n = at(2.0); + for (size_t i = 0; i < n; ++i) { + // Punch each lane in and out at staggered, unquantized points — identical schedules. + for (int k = 0; k < n_lanes; ++k) { + const size_t on = 500 + 1300 * static_cast(k); + const size_t off = 20000 + 4100 * static_cast(k); + if (i == on) { + b.record(k, true); + lanes[static_cast(k)].record(true); + } + if (i == off) { + b.record(k, false); + lanes[static_cast(k)].record(false); + } + } + + const double t = static_cast(i) / k_sr; + const double x = 0.6 * std::sin(2.0 * 3.14159265358979323846 * 220.0 * t) + + 0.3 * std::sin(2.0 * 3.14159265358979323846 * 987.0 * t); + + double lb = 0.0, rb = 0.0; + b.process(x, lb, rb); + + double ls = 0.0, rs = 0.0; // the patch: zero the busses, sum the lanes + for (auto& one : lanes) { + one.process(x, ls, rs); + } + + exact = exact && (ls == lb) && (rs == rb); + peak = std::max(peak, std::max(std::abs(lb), std::abs(rb))); + } + REQUIRE(exact); + REQUIRE(peak > 0.1); // and it was carrying the phrases, not agreeing about silence +} + +SCENARIO("a lone lane's head is as sacred as one in the bank") { + lane ln; + make_lane(ln); + ln.set_length_seconds(0.5); + ln.set_pan(-1.0); // hard left: the left bus carries the lane bitwise + + double l = 0.0, r = 0.0; + ln.record(true); + ln.process(1.0, l, r); // plant a click wherever the head is + ln.record(false); + + const size_t loop = at(0.5); + std::vector yl(4 * loop, 0.0); + for (size_t i = 0; i < yl.size(); ++i) { + if (i == loop + 100) { // the same storm the bank scenario fires, one level down + ln.set_level(1.0); + ln.set_darken_hz(tap::tools::tape::k_darken_ceil_hz); + ln.record(false); + ln.set_length_seconds(0.5); + } + double rr = 0.0; + ln.process(0.0, yl[i], rr); // process accumulates; yl[i] starts at zero + } + + for (size_t k = 1; k <= 3; ++k) { + INFO("return " << k); + CHECK(yl[k * loop - 1] == 1.0); + } + INFO("phase after 4 loops + 1 planted sample: " << ln.phase()); + CHECK(std::abs(ln.phase() - 1.0 / static_cast(loop)) < 1e-9); +} + +SCENARIO("unprepared, a lone lane is silent and leaves the busses alone") { + lane ln; + double l = 1.0, r = -1.0; // process() accumulates, so an unprepared lane must add nothing + ln.process(0.7, l, r); + REQUIRE(l == 1.0); + REQUIRE(r == -1.0); + REQUIRE(ln.phase() == 0.0); +} From 30d6aa7a8c584357f46658c5499c8f6ad866e7cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 00:34:06 +0000 Subject: [PATCH 2/6] Split the garden bed into parts worth having alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bed` was five machines in a trenchcoat: a scale quantizer, an event ring, a chime rack with its voice allocator, and a seeded gardener. Give each one a name and let `bed` be the wiring. The rack is the interesting one. Voice stealing had to come out as kernel code rather than being handed to the host: Max's poly~ steals round-robin and does not exist off Max, so delegating it would have cost both the glide-not-click promise and every non-Max target. `rack` therefore owns the pool and the quietest-first allocator, and `tap.chime~` will be the whole rack rather than a mono voice you instantiate sixteen times. `ring` is the piece with the most reach: it recirculates events and knows nothing about chimes, so it will drive whatever you point it at. `gardener` keeps the rng-consumption discipline the seed triad depends on, and emits raw pitches for the caller to quantize — the scale is the bed's field, not the wind's. Nothing about the sound changes. Two 12- and 30-second renders — one played by hand with the gardener disabled, one left entirely to the seeded wind — hash bit-for-bit identical before and after, and the idle render is the strict one: any change in rng consumption order would move it immediately. New scenarios pin what the split now makes reachable. The null test wires the four components by hand and requires bitwise-equal stereo against the bed over twenty seconds with the gardener running. The ring's convergence theorem is now countable with no envelope tail or detector threshold in the way, so four (velocity, decay, floor) triples are checked against ceil(log(f/v)/log(d)) exactly. The rack is shown taking the quietest bell and leaving a loud one alone, compared as retention ratios because a steal glides rather than cuts. The gardener is shown not drawing from its stream while idling is disabled. One thing the rack scenario surfaced, pre-existing and left alone: a bell's envelope reads zero until it has been processed once, so the allocator cannot distinguish a just-struck bell from an idle one. Strikes issued in the same sample therefore collide onto one voice. The bed only meets this when two blooms share a loop position; it is documented in the test rather than fixed, because fixing it would change the sound. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe --- include/taptools/garden.h | 708 ++++++++++++++++++++++++++------------ tests/garden_test.cpp | 236 +++++++++++++ 2 files changed, 720 insertions(+), 224 deletions(-) diff --git a/include/taptools/garden.h b/include/taptools/garden.h index 1fd35b5..e986d8f 100644 --- a/include/taptools/garden.h +++ b/include/taptools/garden.h @@ -61,6 +61,24 @@ /// Geometry: everything is fixed arrays — k_max_events events, k_voices bells — /// so prepare(sr) allocates nothing at all and no later call ever does. /// +/// Five classes, because a garden is a system and the parts are worth having alone: +/// - `bell` — one wind chime, four decaying mode doublets (see below). +/// - `rack` — the fixed pool of k_voices bells plus the allocator: an idle bell if +/// there is one, else the quietest is stolen and RE-AIMED (free-running phases, a +/// gliding seat) rather than reset. That allocator lives here, in portable C++, +/// rather than being delegated to a host's polyphony container — Max's poly~ steals +/// round-robin and is Max-only, so a kernel that leaned on it would lose both the +/// glide-not-click promise and every non-Max target. This is what tap.chime~ wraps. +/// - `ring` — the event recirculation: plant, fire on the loop grid, multiply velocity +/// by `decay` and brightness by `soften` each pass, retire below `floor`. It carries +/// the convergence theorem and knows nothing about chimes, which is the point: point +/// it at any voice you like (tap.bloom). +/// - `gardener` — the seeded wind: gusts and calms, emitting plant requests once the +/// garden has been idle. Consumes its rng ONLY while idling (tap.gardener). +/// - `scale_quantizer` — the root/scale snap applied at entry (tap.scale). +/// `bed` is those four wired together and a master level, and nothing else; the wiring +/// is what the null test pins. +/// /// Honest limits: /// - Pitch is quantized AT ENTRY: changing root or scale re-pitches nothing already /// planted, only future plants (replants pick up the new field). @@ -331,68 +349,171 @@ namespace tap::tools { std::array m_env; }; - /// The garden bed: plant notes, they bloom on the loop, fade, and retire; left alone, - /// the gardener plants for you. - class bed { + /// The root/scale snap applied at entry — the tune.h nearest-allowed search (any non-empty + /// mask has a note within a tritone). Copied, not included: tune.h reaches into tap::dsp. + /// A mode, not a fader: changing it re-pitches nothing already planted, only future plants. + class scale_quantizer { public: - // -- lifecycle ----------------------------------------------------------------------- + /// Root pitch class, 0..11 (0 = C). + void set_root(int semitone) { m_root = ((semitone % 12) + 12) % 12; } - /// Set the rate everywhere and start an empty garden. Allocation-free by construction - /// (fixed arrays); still not real-time-safe by the house contract. + /// Scale preset (scale_index). + void set_scale(int scale) { m_scale = std::clamp(scale, 0, k_num_scales - 1); } + + int root() const { return m_root; } + int scale() const { return m_scale; } + + /// Snap MIDI semitones to the nearest pitch in the current root/scale. + double quantize(double pitch) const { + const unsigned mask = k_scale_masks[static_cast(m_scale)]; + const int p = static_cast(std::lround(pitch)); + for (int off = 0; off <= 6; ++off) { + for (const int cand : {p + off, p - off}) { + const int pc = (((cand - m_root) % 12) + 12) % 12; + if ((mask & (1u << pc)) != 0u) { + return static_cast(cand); + } + } + } + return static_cast(p); // unreachable for any non-empty mask + } + + private: + int m_root{0}; + int m_scale{scale_major_pentatonic}; // anything you plant sounds consonant + }; + + /// The chime rack: a fixed pool of k_voices bells and the allocator that hands them out. + /// An idle bell if there is one, else the QUIETEST is stolen — and stolen by re-aiming, + /// not resetting, so its phases keep free-running and its seat glides instead of clicking. + /// + /// The allocator lives here rather than in the host on purpose. Max's poly~ steals + /// round-robin, and it does not exist off Max at all; a kernel that delegated voice + /// stealing would lose the glide-not-click promise and every non-Max target with it. The + /// pool is also the hard bound on the audio: however fast strikes arrive, k_voices chimes + /// is all that can ever be ringing. + class rack { + public: void prepare(double sr) { m_sr = (sr > 0.0) ? sr : 48000.0; for (auto& v : m_bells) { v.prepare(m_sr); v.set_times(m_attack_s, m_decay_s); } - m_prepared = true; clear(); } - /// Uproot everything: kill all events and voices, rewind the loop, re-seed the - /// gardener, restart the idle clock. Parameters are untouched. + /// Silence every bell. void clear() { - for (auto& e : m_events) { - e.alive = false; - } for (auto& v : m_bells) { v.reset(); } - m_rng.reset(); - m_pos = 0; - m_planted = 0; - m_since_note = 0; - m_gust_wait = -1; - m_gust_left = 0; - m_gust_size = 1; - m_gust_pitch = 69.0; - m_level_current = m_level_target; } - bool prepared() const { return m_prepared; } + /// Envelope times in SECONDS (decay_env contract). Ringing voices keep their envelope + /// until retriggered; the strike scales decay by sqrt(440/f) on top of this. + void set_times(double attack_s, double decay_s) { + m_attack_s = std::max(attack_s, 1e-6); + m_decay_s = std::max(decay_s, 1e-6); + for (auto& v : m_bells) { + v.set_times(m_attack_s, m_decay_s); + } + } - // -- events -------------------------------------------------------------------------- + /// What the tubes are made of (material_index). A mode, not a fader: instant, and read + /// at strike time, so every later strike re-voices. + void set_material(int material) { m_material = std::clamp(material, 0, k_num_materials - 1); } - /// Plant a note: MIDI pitch (semitones, fractional accepted), velocity in (0, 1]. - /// The pitch snaps to the current root/scale, the bell sounds on the next processed - /// sample, and the bloom returns at this loop position every pass until it fades - /// below the floor. Resets the gardener's idle clock. A full garden retires its - /// oldest bloom to make room. - void note(double pitch, double velocity) { - if (!m_prepared || velocity <= 0.0) { - return; + /// The rack's stereo width, [0, 1]. Each tube hangs at a fixed seat drawn from its + /// pitch (the same stateless hash as its scatter), scaled by spread; 0 collapses the + /// rack to center mono, bitwise equal on both busses. + void set_spread(double amount) { m_spread = std::clamp(amount, 0.0, 1.0); } + + /// Strike the tube at a MIDI pitch (fractional accepted — fractional pitches are + /// distinct tubes with their own flaws and their own seat). + void strike(double pitch, double velocity, double brightness) { + strike_hz(440.0 * std::exp2((pitch - 69.0) / 12.0), velocity, brightness); + } + + /// Strike the tube at a frequency: allocate an idle bell if there is one, else steal + /// the quietest and re-aim it. The seat comes from the tube's own hash, so the rack is + /// the same rack in every instance and a returning bloom rings from the same place. + void strike_hz(double freq_hz, double velocity, double brightness) { + bell* voice = &m_bells[0]; + for (auto& v : m_bells) { + if (v.level() <= k_gain_epsilon) { + voice = &v; + break; + } + if (v.level() < voice->level()) { + voice = &v; + } } - event& e = allocate(); - e.pitch = quantize(pitch); - e.velocity = std::min(velocity, 1.0); - e.brightness = m_brightness; - e.offset = m_pos; // process() fires it this coming sample, then every pass - e.alive = true; - e.seq = m_planted++; - m_since_note = 0; + const double pan = m_spread * tube_unit(tube_key(freq_hz), 0); // index 0: the seat + voice->trigger(freq_hz, velocity, brightness, m_material, pan); } - // -- parameter targets (safe while audio runs) --------------------------------------- + /// Sum every ringing chime onto the stereo busses. ACCUMULATES, like bell::process. + void process(double& out_left, double& out_right) { + for (auto& v : m_bells) { + v.process(out_left, out_right); + } + } + + int active_voices() const { + int n = 0; + for (const auto& v : m_bells) { + n += (v.level() > k_gain_epsilon) ? 1 : 0; + } + return n; + } + + double attack_s() const { return m_attack_s; } + double decay_s() const { return m_decay_s; } + int material() const { return m_material; } + double spread() const { return m_spread; } + double samplerate() const { return m_sr; } + + private: + double m_sr{48000.0}; + double m_attack_s{k_default_attack_s}; + double m_decay_s{k_default_decay_s}; + int m_material{material_chime}; + double m_spread{k_default_spread}; + std::array m_bells; + }; + + /// One strike falling out of the ring: what to hit, how hard, how bright. + struct strike { + double pitch{0.0}; // MIDI semitones, already quantized by whoever planted it + double velocity{0.0}; // this pass's velocity, before the pass's decay is applied + double brightness{0.0}; // this pass's brightness, likewise + }; + + /// The event ring: plant a bloom and it returns at its own position every pass, a little + /// quieter (`decay`) and a little purer (`soften`), until it falls below `floor` and + /// retires. It knows nothing about chimes — it emits strikes, and what sounds them is the + /// caller's business, which is the whole reason it is worth having on its own. + /// + /// The stabilizer and its theorem: with floor f and a plant at velocity v, a bloom lives + /// exactly ceil(log(f/v)/log(decay)) passes, so the live population converges no matter + /// how fast you plant. A full ring retires its OLDEST bloom to make room for a new plant — + /// a touch must always speak, and the oldest is the quietest. + class ring { + public: + void prepare(double sr) { + m_sr = (sr > 0.0) ? sr : 48000.0; + clear(); + } + + /// Uproot everything: kill every event and rewind the loop. Parameters are untouched. + void clear() { + for (auto& e : m_events) { + e.alive = false; + } + m_pos = 0; + m_planted = 0; + } /// Loop length in seconds, clamped to [k_min_loop_seconds, k_max_loop_seconds]. /// Instant (the loop is a counter): blooms keep their positions modulo the new length. @@ -405,8 +526,7 @@ namespace tap::tools { } } - /// Velocity multiplier per pass, [0, 1]. The stabilizer: with floor f and a plant at - /// velocity v, a bloom lives ceil(log(f/v)/log(decay)) passes, always. + /// Velocity multiplier per pass, [0, 1] — the stabilizer. void set_decay(double per_pass) { m_decay = std::clamp(per_pass, 0.0, 1.0); } /// Brightness multiplier per pass, [0, 1]: each return is purer, collapsing to sine. @@ -415,125 +535,68 @@ namespace tap::tools { /// Retirement threshold, [1e-4, 1]. void set_floor(double v) { m_floor = std::clamp(v, 1e-4, 1.0); } - /// The bell: envelope times in SECONDS (decay_env contract) and base brightness - /// (0..1 scale on the modulation index). Applies to future blooms; ringing voices - /// keep their envelope times until retriggered. - void set_bell(double attack_s, double decay_s, double brightness) { - m_attack_s = std::max(attack_s, 1e-6); - m_decay_s = std::max(decay_s, 1e-6); - m_brightness = std::clamp(brightness, 0.0, 1.0); - for (auto& v : m_bells) { - v.set_times(m_attack_s, m_decay_s); - } - } - - /// What the tubes are made of (material_index): the free-free chime rack or the - /// tuned-bar plank. A mode, not a fader: instant, and read at strike time, so every - /// live bloom re-voices at its next return. - void set_material(int material) { m_material = std::clamp(material, 0, k_num_materials - 1); } - - /// The rack's stereo width, [0, 1]. Each tube hangs at a fixed seat drawn from its - /// pitch (the same stateless hash as its scatter), scaled by spread; 0 collapses - /// the rack to center mono, bitwise equal on both busses. - void set_spread(double amount) { m_spread = std::clamp(amount, 0.0, 1.0); } - - /// Root pitch class, 0..11 (0 = C). A mode: instant, affects future plants only. - void set_root(int semitone) { m_root = ((semitone % 12) + 12) % 12; } - - /// Scale preset (scale_index). A mode: instant, affects future plants only. - void set_scale(int scale) { m_scale = std::clamp(scale, 0, k_num_scales - 1); } - - /// Seconds of silence before the gardener starts planting; 0 disables self-seeding - /// (and then the seed cannot matter at all — pinned by test). - void set_idle_seconds(double s) { m_idle_seconds = std::max(0.0, s); } - - /// The wind, 0..1: at 0 the gardener strikes singly and evenly (about one per pass); - /// up from there, strikes arrive in gusts — clusters of up to five on neighboring - /// tubes within a fraction of a second, then longer calms, same average rate. - void set_gust(double amount) { m_gust = std::clamp(amount, 0.0, 1.0); } - - /// The gardener's seed — deterministic per seed, house triad contract. Instant. - void set_seed(uint64_t seed) { m_rng.set_seed(seed); } - - /// Master linear output level, one-pole slewed over smooth_ms. - void set_level(double lin) { m_level_target = lin; } - - void set_smooth_ms(double ms) { m_smooth_ms = std::max(0.0, ms); } + /// The brightness a new plant starts at, [0, 1]; it softens from there. + void set_brightness(double b) { m_brightness = std::clamp(b, 0.0, 1.0); } - // -- introspection ------------------------------------------------------------------- + /// Plant a bloom at the current loop position. Velocity is clamped to (0, 1]. It fires + /// on the next due() — the coming sample — and then every pass until it retires. + void plant(double pitch, double velocity) { plant_event(pitch, velocity); } - int active_events() const { + /// The blooms due on this sample: each is reported and then worn by one pass. Writes + /// at most `max` strikes into `out` and returns how many. Does NOT advance the loop — + /// call step() once the gardener has had its turn, which is the order bed keeps. + int due(strike* out, int max) { int n = 0; - for (const auto& e : m_events) { - n += e.alive ? 1 : 0; - } - return n; - } - int active_voices() const { - int n = 0; - for (const auto& v : m_bells) { - n += (v.level() > k_gain_epsilon) ? 1 : 0; - } - return n; - } - double loop_seconds() const { return m_loop_seconds; } - double decay() const { return m_decay; } - double soften() const { return m_soften; } - double floor_level() const { return m_floor; } - double attack_s() const { return m_attack_s; } - double decay_s() const { return m_decay_s; } - double brightness() const { return m_brightness; } - int material() const { return m_material; } - double spread() const { return m_spread; } - int root() const { return m_root; } - int scale() const { return m_scale; } - double idle_seconds() const { return m_idle_seconds; } - double gust() const { return m_gust; } - uint64_t seed() const { return m_rng.seed(); } - double level() const { return m_level_target; } - double smooth_ms() const { return m_smooth_ms; } - double samplerate() const { return m_sr; } - - // -- audio --------------------------------------------------------------------------- - - /// A source: no input. Advance the loop one sample, fire any blooms whose position - /// this is, let the gardener plant if the garden has been idle, and sum the bells - /// onto the stereo busses, each at its tube's seat. - void process(double& out_left, double& out_right) { - if (!m_prepared) { - out_left = 0.0; - out_right = 0.0; - return; - } for (auto& e : m_events) { if (e.alive && e.offset == m_pos) { - fire(e); + if (n < max) { + out[n].pitch = e.pitch; + out[n].velocity = e.velocity; + out[n].brightness = e.brightness; + ++n; + } bloom(e); } } - tend(); + return n; + } + + /// Plant at the current position and take its first strike immediately — the + /// gardener's door, which opens after due() has already run for this sample. + strike plant_now(double pitch, double velocity) { + event& e = plant_event(pitch, velocity); + strike s; + s.pitch = e.pitch; + s.velocity = e.velocity; + s.brightness = e.brightness; + bloom(e); + return s; + } + + /// Advance the loop one sample. + void step() { if (++m_pos >= loop_samples()) { m_pos = 0; } - - double sum_l = 0.0; - double sum_r = 0.0; - for (auto& v : m_bells) { - v.process(sum_l, sum_r); - } - const double coeff = (m_smooth_ms > 0.0) ? 1.0 - std::exp(-1.0 / (m_smooth_ms * 0.001 * m_sr)) : 1.0; - m_level_current += coeff * (m_level_target - m_level_current); - out_left = sum_l * m_level_current; - out_right = sum_r * m_level_current; } - /// Block form: the trivial loop over the scalar path. - void process(double* out_left, double* out_right, size_t n) { - for (size_t i = 0; i < n; ++i) { - process(out_left[i], out_right[i]); + int active_events() const { + int n = 0; + for (const auto& e : m_events) { + n += e.alive ? 1 : 0; } + return n; } + long loop_samples() const { return static_cast(m_loop_seconds * m_sr); } + long position() const { return m_pos; } + double loop_seconds() const { return m_loop_seconds; } + double decay() const { return m_decay; } + double soften() const { return m_soften; } + double floor_level() const { return m_floor; } + double brightness() const { return m_brightness; } + double samplerate() const { return m_sr; } + private: struct event { double pitch{0.0}; // MIDI semitones, already quantized @@ -544,24 +607,6 @@ namespace tap::tools { bool alive{false}; }; - long loop_samples() const { return static_cast(m_loop_seconds * m_sr); } - - /// Snap MIDI semitones to the nearest pitch in the current root/scale — the tune.h - /// nearest-allowed search (any non-empty mask has a note within a tritone). - double quantize(double pitch) const { - const unsigned mask = k_scale_masks[static_cast(m_scale)]; - const int p = static_cast(std::lround(pitch)); - for (int off = 0; off <= 6; ++off) { - for (const int cand : {p + off, p - off}) { - const int pc = (((cand - m_root) % 12) + 12) % 12; - if ((mask & (1u << pc)) != 0u) { - return static_cast(cand); - } - } - } - return static_cast(p); // unreachable for any non-empty mask - } - /// Find a slot for a new plant: a dead one if any, else the oldest live bloom yields. event& allocate() { event* oldest = &m_events[0]; @@ -576,21 +621,17 @@ namespace tap::tools { return *oldest; } - /// Strike this event now on the pool: an idle voice if any, else steal the quietest. - void fire(event& e) { - bell* voice = &m_bells[0]; - for (auto& v : m_bells) { - if (v.level() <= k_gain_epsilon) { - voice = &v; - break; - } - if (v.level() < voice->level()) { - voice = &v; - } - } - const double freq = 440.0 * std::exp2((e.pitch - 69.0) / 12.0); - const double pan = m_spread * tube_unit(tube_key(freq), 0); // index 0: the seat - voice->trigger(freq, e.velocity, e.brightness, m_material, pan); + /// Take a slot and stamp a plant into it, handing back the slot so a caller that + /// wants the first strike immediately (plant_now) does not have to hunt for it. + event& plant_event(double pitch, double velocity) { + event& e = allocate(); + e.pitch = pitch; + e.velocity = std::min(velocity, 1.0); + e.brightness = m_brightness; + e.offset = m_pos; + e.alive = true; + e.seq = m_planted++; + return e; } /// One pass of wear, one level up: quieter, purer, and gone below the floor. @@ -602,29 +643,83 @@ namespace tap::tools { } } - double uniform() { return 0.5 * (m_rng.process() + 1.0); } // [0, 1), the gardener's die + double m_sr{48000.0}; + double m_loop_seconds{k_default_loop_seconds}; + double m_decay{k_default_decay}; + double m_soften{k_default_soften}; + double m_floor{k_default_floor}; + double m_brightness{k_default_brightness}; + long m_pos{0}; + uint32_t m_planted{0}; + std::array m_events; + }; + + /// The idle gardener as wind: after idle_seconds without a caller plant, strikes arrive on + /// a calm/gust cycle. Each gust catches 1 to 5 neighboring tubes (sized by `gust`) within a + /// fraction of a second; calms between gusts stretch so the average rate stays near one + /// strike per loop pass at any gust setting. + /// + /// The rng is consumed ONLY while idling — the seed-triad contract depends on that + /// discipline, and with idling disabled the seed cannot matter at all. A caller plant + /// closes the idle gate mid-gust; the gust resumes if the garden idles again. + class gardener { + public: + /// What the wind wants planted this sample. `pitch` is RAW — the caller quantizes, + /// because the scale is the caller's field, not the wind's. + struct request { + double pitch{0.0}; + double velocity{0.0}; + bool wanted{false}; + }; - /// The idle gardener as wind: after idle_seconds without a caller plant, strikes - /// arrive on a calm/gust cycle. Each gust catches 1 to 5 neighboring tubes (sized by - /// `gust`) within a fraction of a second; calms between gusts stretch so the average - /// rate stays near one strike per loop pass at any gust setting. The rng is consumed - /// only while idling — the seed-triad contract depends on that discipline. A caller - /// plant closes the idle gate mid-gust; the gust resumes if the garden idles again. - void tend() { + void prepare(double sr) { + m_sr = (sr > 0.0) ? sr : 48000.0; + clear(); + } + + /// Re-seed the rng and restart the idle clock and the wind. + void clear() { + m_rng.reset(); + m_since_note = 0; + m_gust_wait = -1; + m_gust_left = 0; + m_gust_size = 1; + m_gust_pitch = 69.0; + } + + /// Seconds of silence before the gardener starts planting; 0 disables self-seeding + /// (and then the seed cannot matter at all — pinned by test). + void set_idle_seconds(double s) { m_idle_seconds = std::max(0.0, s); } + + /// The wind, 0..1: at 0 the gardener strikes singly and evenly (about one per pass); + /// up from there, strikes arrive in gusts — clusters of up to five on neighboring + /// tubes within a fraction of a second, then longer calms, same average rate. + void set_gust(double amount) { m_gust = std::clamp(amount, 0.0, 1.0); } + + /// The gardener's seed — deterministic per seed, house triad contract. Instant. + void set_seed(uint64_t seed) { m_rng.set_seed(seed); } + + /// A caller planted: close the idle gate. + void notice_plant() { m_since_note = 0; } + + /// Advance the idle clock one sample and report whether the wind wants a strike. + /// `loop_samples` is the ring's current loop length, which sizes gusts and calms. + request tick(long loop_samples) { + request req; ++m_since_note; if (m_idle_seconds <= 0.0) { - return; // disabled: the rng is never consumed, so the seed cannot matter + return req; // disabled: the rng is never consumed, so the seed cannot matter } if (static_cast(m_since_note) < m_idle_seconds * m_sr) { - return; + return req; } if (m_gust_wait < 0) { // the wind arriving: the first strike lands within half a loop - m_gust_wait = static_cast(0.5 * uniform() * static_cast(loop_samples())); + m_gust_wait = static_cast(0.5 * uniform() * static_cast(loop_samples)); m_gust_left = 0; } if (m_gust_wait > 0) { --m_gust_wait; - return; + return req; } if (m_gust_left <= 0) { // a fresh gust: how many tubes does this one catch? m_gust_size = 1 + static_cast(uniform() * (1.0 + 4.0 * m_gust)); @@ -634,57 +729,222 @@ namespace tap::tools { else { // the clapper swings on to a neighboring tube m_gust_pitch = std::clamp(m_gust_pitch + std::floor(9.0 * uniform()) - 4.0, 48.0, 90.0); } - const double velocity = 0.3 + 0.4 * uniform(); - event& e = allocate(); - e.pitch = quantize(m_gust_pitch); - e.velocity = velocity; - e.brightness = m_brightness; - e.offset = m_pos; - e.alive = true; - e.seq = m_planted++; - fire(e); - bloom(e); + req.velocity = 0.3 + 0.4 * uniform(); + req.pitch = m_gust_pitch; + req.wanted = true; --m_gust_left; if (m_gust_left > 0) { // within a gust: strikes tumble 30..280 ms apart m_gust_wait = static_cast((0.03 + 0.25 * uniform()) * m_sr); } else { // calm, stretched by the gust just spent: the average rate holds m_gust_wait = static_cast((0.5 + uniform()) * static_cast(m_gust_size) - * static_cast(loop_samples())); + * static_cast(loop_samples)); } // Deliberately does NOT reset m_since_note's gate below the threshold: once the // gardener starts, it keeps tending until the caller plants again. + return req; } + double idle_seconds() const { return m_idle_seconds; } + double gust() const { return m_gust; } + uint64_t seed() const { return m_rng.seed(); } + double samplerate() const { return m_sr; } + + private: + double uniform() { return 0.5 * (m_rng.process() + 1.0); } // [0, 1), the gardener's die + + double m_sr{48000.0}; + double m_idle_seconds{k_default_idle_seconds}; + double m_gust{k_default_gust}; + long long m_since_note{0}; + long m_gust_wait{-1}; + int m_gust_left{0}; + int m_gust_size{1}; + double m_gust_pitch{69.0}; + tr808::white_noise m_rng; + }; + + /// The garden bed: plant notes, they bloom on the loop, fade, and retire; left alone, + /// the gardener plants for you. A quantizer, a ring, a rack, and a gardener wired + /// together, plus a master level — the wiring is all this class is. + class bed { + public: + // -- lifecycle ----------------------------------------------------------------------- + + /// Set the rate everywhere and start an empty garden. Allocation-free by construction + /// (fixed arrays); still not real-time-safe by the house contract. + void prepare(double sr) { + m_sr = (sr > 0.0) ? sr : 48000.0; + m_rack.prepare(m_sr); + m_ring.prepare(m_sr); + m_gardener.prepare(m_sr); + m_prepared = true; + clear(); + } + + /// Uproot everything: kill all events and voices, rewind the loop, re-seed the + /// gardener, restart the idle clock. Parameters are untouched. + void clear() { + m_ring.clear(); + m_rack.clear(); + m_gardener.clear(); + m_level_current = m_level_target; + } + + bool prepared() const { return m_prepared; } + + // -- events -------------------------------------------------------------------------- + + /// Plant a note: MIDI pitch (semitones, fractional accepted), velocity in (0, 1]. + /// The pitch snaps to the current root/scale, the bell sounds on the next processed + /// sample, and the bloom returns at this loop position every pass until it fades + /// below the floor. Resets the gardener's idle clock. A full garden retires its + /// oldest bloom to make room. + void note(double pitch, double velocity) { + if (!m_prepared || velocity <= 0.0) { + return; + } + m_ring.plant(m_quantizer.quantize(pitch), velocity); + m_gardener.notice_plant(); + } + + // -- parameter targets (safe while audio runs) --------------------------------------- + + /// Loop length in seconds — see ring::set_loop_seconds. + void set_loop_seconds(double s) { m_ring.set_loop_seconds(s); } + + /// Velocity multiplier per pass, [0, 1]. The stabilizer: with floor f and a plant at + /// velocity v, a bloom lives ceil(log(f/v)/log(decay)) passes, always. + void set_decay(double per_pass) { m_ring.set_decay(per_pass); } + + /// Brightness multiplier per pass, [0, 1]: each return is purer, collapsing to sine. + void set_soften(double per_pass) { m_ring.set_soften(per_pass); } + + /// Retirement threshold, [1e-4, 1]. + void set_floor(double v) { m_ring.set_floor(v); } + + /// The bell: envelope times in SECONDS (decay_env contract) and base brightness + /// (0..1 scale on the modulation index). Applies to future blooms; ringing voices + /// keep their envelope times until retriggered. + void set_bell(double attack_s, double decay_s, double brightness) { + m_rack.set_times(attack_s, decay_s); + m_ring.set_brightness(brightness); + } + + /// What the tubes are made of (material_index). A mode, not a fader: instant, and read + /// at strike time, so every live bloom re-voices at its next return. + void set_material(int material) { m_rack.set_material(material); } + + /// The rack's stereo width, [0, 1] — see rack::set_spread. + void set_spread(double amount) { m_rack.set_spread(amount); } + + /// Root pitch class, 0..11 (0 = C). A mode: instant, affects future plants only. + void set_root(int semitone) { m_quantizer.set_root(semitone); } + + /// Scale preset (scale_index). A mode: instant, affects future plants only. + void set_scale(int scale) { m_quantizer.set_scale(scale); } + + /// Seconds of silence before the gardener starts planting; 0 disables self-seeding. + void set_idle_seconds(double s) { m_gardener.set_idle_seconds(s); } + + /// The wind, 0..1 — see gardener::set_gust. + void set_gust(double amount) { m_gardener.set_gust(amount); } + + /// The gardener's seed — deterministic per seed, house triad contract. Instant. + void set_seed(uint64_t seed) { m_gardener.set_seed(seed); } + + /// Master linear output level, one-pole slewed over smooth_ms. + void set_level(double lin) { m_level_target = lin; } + + void set_smooth_ms(double ms) { m_smooth_ms = std::max(0.0, ms); } + + // -- components ---------------------------------------------------------------------- + + /// Direct access to the parts, so a caller (or a null test) can drive the bed and the + /// components it is made of through one code path. Named for the part rather than the + /// type, so the accessors do not shadow the class names inside this scope. + ring& event_ring() { return m_ring; } + const ring& event_ring() const { return m_ring; } + rack& chime_rack() { return m_rack; } + const rack& chime_rack() const { return m_rack; } + gardener& wind() { return m_gardener; } + const gardener& wind() const { return m_gardener; } + scale_quantizer& quantizer() { return m_quantizer; } + const scale_quantizer& quantizer() const { return m_quantizer; } + + // -- introspection ------------------------------------------------------------------- + + int active_events() const { return m_ring.active_events(); } + int active_voices() const { return m_rack.active_voices(); } + double loop_seconds() const { return m_ring.loop_seconds(); } + double decay() const { return m_ring.decay(); } + double soften() const { return m_ring.soften(); } + double floor_level() const { return m_ring.floor_level(); } + double attack_s() const { return m_rack.attack_s(); } + double decay_s() const { return m_rack.decay_s(); } + double brightness() const { return m_ring.brightness(); } + int material() const { return m_rack.material(); } + double spread() const { return m_rack.spread(); } + int root() const { return m_quantizer.root(); } + int scale() const { return m_quantizer.scale(); } + double idle_seconds() const { return m_gardener.idle_seconds(); } + double gust() const { return m_gardener.gust(); } + uint64_t seed() const { return m_gardener.seed(); } + double level() const { return m_level_target; } + double smooth_ms() const { return m_smooth_ms; } + double samplerate() const { return m_sr; } + + // -- audio --------------------------------------------------------------------------- + + /// A source: no input. Advance the loop one sample, fire any blooms whose position + /// this is, let the gardener plant if the garden has been idle, and sum the bells + /// onto the stereo busses, each at its tube's seat. + void process(double& out_left, double& out_right) { + if (!m_prepared) { + out_left = 0.0; + out_right = 0.0; + return; + } + const int n = m_ring.due(m_fired.data(), k_max_events); + for (int i = 0; i < n; ++i) { + m_rack.strike(m_fired[static_cast(i)].pitch, m_fired[static_cast(i)].velocity, + m_fired[static_cast(i)].brightness); + } + const gardener::request req = m_gardener.tick(m_ring.loop_samples()); + if (req.wanted) { // the wind plants raw; the bed's scale is what it lands on + const strike s = m_ring.plant_now(m_quantizer.quantize(req.pitch), req.velocity); + m_rack.strike(s.pitch, s.velocity, s.brightness); + } + m_ring.step(); + + double sum_l = 0.0; + double sum_r = 0.0; + m_rack.process(sum_l, sum_r); + const double coeff = (m_smooth_ms > 0.0) ? 1.0 - std::exp(-1.0 / (m_smooth_ms * 0.001 * m_sr)) : 1.0; + m_level_current += coeff * (m_level_target - m_level_current); + out_left = sum_l * m_level_current; + out_right = sum_r * m_level_current; + } + + /// Block form: the trivial loop over the scalar path. + void process(double* out_left, double* out_right, size_t n) { + for (size_t i = 0; i < n; ++i) { + process(out_left[i], out_right[i]); + } + } + + private: double m_sr{48000.0}; bool m_prepared{false}; - double m_loop_seconds{k_default_loop_seconds}; - double m_decay{k_default_decay}; - double m_soften{k_default_soften}; - double m_floor{k_default_floor}; - double m_attack_s{k_default_attack_s}; - double m_decay_s{k_default_decay_s}; - double m_brightness{k_default_brightness}; - int m_material{material_chime}; - double m_spread{k_default_spread}; - int m_root{0}; - int m_scale{scale_major_pentatonic}; // anything you plant sounds consonant - double m_idle_seconds{k_default_idle_seconds}; - double m_gust{k_default_gust}; double m_level_target{1.0}; double m_level_current{1.0}; double m_smooth_ms{k_default_smooth_ms}; - long m_pos{0}; - uint32_t m_planted{0}; - long long m_since_note{0}; - long m_gust_wait{-1}; - int m_gust_left{0}; - int m_gust_size{1}; - double m_gust_pitch{69.0}; - tr808::white_noise m_rng; - std::array m_events; - std::array m_bells; + scale_quantizer m_quantizer; + ring m_ring; + rack m_rack; + gardener m_gardener; + std::array m_fired; // due() scratch: a member, so no per-sample cost }; } // namespace garden diff --git a/tests/garden_test.cpp b/tests/garden_test.cpp index 28d7330..95e4ce5 100644 --- a/tests/garden_test.cpp +++ b/tests/garden_test.cpp @@ -24,9 +24,16 @@ namespace { constexpr double k_sr = 48000.0; using tap::tools::garden::bed; + using tap::tools::garden::gardener; + using tap::tools::garden::k_max_events; using tap::tools::garden::k_mode_ratio; + using tap::tools::garden::k_voices; using tap::tools::garden::material_bar; using tap::tools::garden::material_chime; + using tap::tools::garden::rack; + using tap::tools::garden::ring; + using tap::tools::garden::scale_quantizer; + using tap::tools::garden::strike; /// A quiet, instrument-neutral bed: idle gardener off, instant level, percussive bell so /// grid promises are sharp, spread 0 so mono measurements read either bus. Tests opt into @@ -584,3 +591,232 @@ SCENARIO("the rack is stereo: every tube keeps its seat, and spread 0 collapses CHECK(std::abs(a - b) > 0.1); // a different tube hangs somewhere else CHECK(std::abs(a - 0.5) > 0.05); // and a full-spread seat is audibly off center } + +SCENARIO("the bed is exactly its components wired together, bitwise") { + // The decomposition's load-bearing claim, one level up from airport's: tap.scale into + // tap.bloom into tap.chime~, with tap.gardener planting when idle, IS tap.garden~. The + // gardener runs here on purpose — it puts the rng consumption order under test too, which + // is the part a careless split would silently move. + bed g; + g.prepare(k_sr); + g.set_smooth_ms(0.0); // level 1 through an un-slewed master stage: an exact no-op + g.set_level(1.0); + g.set_loop_seconds(1.5); + g.set_decay(0.75); + g.set_soften(0.85); + g.set_floor(0.04); + g.set_bell(0.003, 2.0, 0.9); + g.set_material(material_bar); + g.set_spread(0.6); + g.set_root(3); + g.set_scale(tap::tools::garden::scale_minor_pentatonic); + g.set_seed(0xBEEFULL); + g.set_idle_seconds(0.3); + g.set_gust(0.6); + + // The same machine, wired by hand — this is the patch. + scale_quantizer q; + ring rg; + rack rk; + gardener gd; + rk.prepare(k_sr); // bed::prepare's order: rack, ring, gardener, then clear + rg.prepare(k_sr); + gd.prepare(k_sr); + rg.clear(); + rk.clear(); + gd.clear(); + rg.set_loop_seconds(1.5); + rg.set_decay(0.75); + rg.set_soften(0.85); + rg.set_floor(0.04); + rk.set_times(0.003, 2.0); + rg.set_brightness(0.9); + rk.set_material(material_bar); + rk.set_spread(0.6); + q.set_root(3); + q.set_scale(tap::tools::garden::scale_minor_pentatonic); + gd.set_seed(0xBEEFULL); + gd.set_idle_seconds(0.3); + gd.set_gust(0.6); + + std::array fired{}; + bool exact = true; + double pk = 0.0; + for (size_t i = 0; i < at(20.0); ++i) { + if (i == 1000 || i == 40000 || i == 150000) { // caller plants, closing the idle gate + const double pitch = 60.0 + static_cast(i % 7); + g.note(pitch, 0.8); + rg.plant(q.quantize(pitch), 0.8); + gd.notice_plant(); + } + + double lb = 0.0, rb = 0.0; + g.process(lb, rb); + + const int n = rg.due(fired.data(), k_max_events); + for (int k = 0; k < n; ++k) { + rk.strike(fired[static_cast(k)].pitch, fired[static_cast(k)].velocity, + fired[static_cast(k)].brightness); + } + const gardener::request req = gd.tick(rg.loop_samples()); + if (req.wanted) { + const strike s = rg.plant_now(q.quantize(req.pitch), req.velocity); + rk.strike(s.pitch, s.velocity, s.brightness); + } + rg.step(); + double lp = 0.0, rp = 0.0; + rk.process(lp, rp); + + exact = exact && (lp == lb) && (rp == rb); + pk = std::max(pk, std::max(std::abs(lb), std::abs(rb))); + } + REQUIRE(exact); + REQUIRE(pk > 0.05); // and the two agreed about a garden, not about silence + REQUIRE(g.active_events() == rg.active_events()); +} + +SCENARIO("the ring's convergence theorem is exact when nothing sounds it") { + // Separating the ring from the chime makes the population arithmetic directly countable: + // a plant at velocity v under decay d retires after ceil(log(floor/v)/log(d)) strikes, + // with no envelope tail or detector threshold in the way. + struct trial { + double velocity; + double decay; + double floor; + }; + const trial trials[] = {{0.9, 0.5, 0.05}, {1.0, 0.8, 0.01}, {0.6, 0.25, 0.02}, {0.75, 0.9, 0.1}}; + + for (const trial& t : trials) { + ring rg; + rg.prepare(k_sr); + rg.set_loop_seconds(tap::tools::garden::k_min_loop_seconds); + rg.set_decay(t.decay); + rg.set_floor(t.floor); + rg.plant(60.0, t.velocity); + + std::array out{}; + int strikes = 0; + const long period = rg.loop_samples(); + for (long pass = 0; pass < 64; ++pass) { // far more passes than any trial needs + for (long i = 0; i < period; ++i) { + strikes += rg.due(out.data(), k_max_events); + rg.step(); + } + } + + const int expected = static_cast(std::ceil(std::log(t.floor / t.velocity) / std::log(t.decay))); + INFO("v " << t.velocity << " decay " << t.decay << " floor " << t.floor); + CHECK(strikes == expected); + CHECK(rg.active_events() == 0); // and the population really did converge to nothing + } +} + +SCENARIO("the rack fills idle bells first, then steals the quietest") { + rack rk; + rk.prepare(k_sr); + rk.set_spread(0.0); // mono: either bus carries the whole rack + rk.set_times(0.001, 6.0); // long ring, so nothing retires on its own during the test + rk.set_material(material_chime); + + auto render = [&](size_t n) { + std::vector y(n, 0.0); + for (size_t i = 0; i < n; ++i) { + double r = 0.0; + rk.process(y[i], r); + } + return y; + }; + + // Strikes are separated by a short render on purpose. A bell's envelope reads zero until + // it has been processed at least once (decay_env::trigger only aims the target), so the + // allocator cannot tell a just-struck bell from an idle one — strikes issued back to back + // in the same sample all land on the same voice. That is the rack's real contract, and the + // bed meets it: blooms are separated by the loop grid. + const double quiet_pitch = 48.0; + const double loud_pitch = 61.0; + const size_t gap = at(0.01); + rk.strike(quiet_pitch, 0.05, 1.0); // one deliberately faint tube + render(gap); + rk.strike(loud_pitch, 1.0, 1.0); + render(gap); + for (int i = 2; i < k_voices; ++i) { // fill the rest of the pool loudly + rk.strike(60.0 + static_cast(i), 1.0, 1.0); + render(gap); + } + REQUIRE(rk.active_voices() == k_voices); + + const size_t win = at(0.25); + const std::vector before = render(win); + const double quiet_before = goertzel(before, midi_hz(quiet_pitch), 0, win); + const double loud_before = goertzel(before, midi_hz(loud_pitch), 0, win); + REQUIRE(quiet_before > 0.0); + + rk.strike(90.0, 1.0, 1.0); // the seventeenth strike: somebody has to yield + REQUIRE(rk.active_voices() == k_voices); + + const std::vector after = render(win); + const double quiet_after = goertzel(after, midi_hz(quiet_pitch), 0, win); + const double loud_after = goertzel(after, midi_hz(loud_pitch), 0, win); + const double newcomer = goertzel(after, midi_hz(90.0), 0, win); + + // The stolen bell is RE-AIMED, not reset, so its old partial glides away over a few + // milliseconds rather than vanishing — that residual is the promise, not a leak. So the + // claim is comparative: the taken tube must lose its partial far faster than an untouched + // one, which separates "the quietest was stolen" from "something was stolen". + const double quiet_kept = quiet_after / quiet_before; + const double loud_kept = loud_after / loud_before; + INFO("quiet kept " << quiet_kept << ", loud kept " << loud_kept); + CHECK(quiet_kept < 0.5 * loud_kept); // the faint tube was the one taken + CHECK(loud_kept > 0.5); // and the loud one was left alone + CHECK(newcomer > 0.0); // the newcomer is ringing on the stolen bell +} + +SCENARIO("the gardener touches its rng only while idling") { + const long period = static_cast(k_sr); // a one-second loop, the units tick() expects + + // Idling disabled: no requests ever, and the seed cannot matter, because the stream is + // never drawn from. Two gardeners seeded differently must agree sample for sample. + gardener off_a, off_b; + off_a.prepare(k_sr); + off_b.prepare(k_sr); + off_a.set_idle_seconds(0.0); + off_b.set_idle_seconds(0.0); + off_a.set_seed(1ULL); + off_b.set_seed(0xFFFFFFFFULL); + bool agreed = true; + bool asked = false; + for (size_t i = 0; i < at(5.0); ++i) { + const gardener::request ra = off_a.tick(period); + const gardener::request rb = off_b.tick(period); + agreed = agreed && (ra.wanted == rb.wanted); + asked = asked || ra.wanted || rb.wanted; + } + REQUIRE(agreed); + REQUIRE_FALSE(asked); + + // Idling enabled: the same seed is bit-exact, a different seed goes its own way. + gardener same_a, same_b, other; + for (gardener* p : {&same_a, &same_b, &other}) { + p->prepare(k_sr); + p->set_idle_seconds(0.1); + p->set_gust(0.5); + } + same_a.set_seed(0x5EEDULL); + same_b.set_seed(0x5EEDULL); + other.set_seed(0x5EED0001ULL); + + bool twins_match = true; + bool other_ever_differs = false; + int plants = 0; + for (size_t i = 0; i < at(30.0); ++i) { + const gardener::request a = same_a.tick(period); + const gardener::request b = same_b.tick(period); + const gardener::request c = other.tick(period); + twins_match = twins_match && (a.wanted == b.wanted) && (a.pitch == b.pitch) && (a.velocity == b.velocity); + other_ever_differs = other_ever_differs || (c.wanted != a.wanted) || (c.pitch != a.pitch); + plants += a.wanted ? 1 : 0; + } + REQUIRE(twins_match); + REQUIRE(other_ever_differs); + REQUIRE(plants > 0); // the wind really did blow +} From c3daeb5496cc044af474fb2d2415be9be2e23570 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 00:38:54 +0000 Subject: [PATCH 3/6] Reach the components from the verification layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C ABI and the ctypes bridge only knew about the monoliths, so nothing outside C++ could check the claim the split rests on. Add entry points for the tape lane (tap.reel~), the chime rack (tap.chime~), the event ring (tap.bloom), the idle wind (tap.gardener), and the entry quantizer (tap.scale), plus the Python classes that wrap them. Two shapes worth noting. `taptools_bloom_due` writes into caller arrays and returns a count, because the ring reports a variable number of strikes per sample and a C ABI should not hand back a view into kernel scratch. `taptools_gardener_tick` returns 1/0 for "the wind wants a strike" and writes the RAW pitch — quantizing is the caller's job, which is the same seam the kernel draws. Checked end to end against the built library: three Reels summed match an Airport bitwise (max |diff| 0.0 on both busses, heads in step), a Bloom plant at velocity 0.9 under decay 0.5 and floor 0.05 fires exactly the five strikes the theorem predicts and leaves no live events, the gardener plants about once per pass while idling and never once idling is disabled, and the quantizer snaps off-scale pitches to the nearest degree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe --- notebooks/taptools_py.py | 303 ++++++++++++++++++++++++++++++++++- tools/capi/taptools_capi.cpp | 276 +++++++++++++++++++++++++++++++ tools/capi/taptools_capi.h | 100 ++++++++++++ 3 files changed, 678 insertions(+), 1 deletion(-) diff --git a/notebooks/taptools_py.py b/notebooks/taptools_py.py index 29f1f60..630c800 100644 --- a/notebooks/taptools_py.py +++ b/notebooks/taptools_py.py @@ -19,7 +19,11 @@ incommensurate loop bank tap.airport~ (`Airport`), the generative event loop tap.garden~ (`Garden`), 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 +pitch tracking). The components those last two are built from are reachable +too — one tape lane (`Reel`), the chime rack (`Chime`), the event ring +(`Bloom`), the idle wind (`Gardener`), and the entry quantizer +(`scale_quantize`) — so a notebook can null-test the patch against the +object through one ABI. Parameter names on the kernel classes mirror each kernel header's param_index enum. Copyright 2003-2026 Timothy Place. MIT License. @@ -316,6 +320,59 @@ def load() -> ctypes.CDLL: "taptools_garden_active_events": ([vp], ctypes.c_int), "taptools_garden_active_voices": ([vp], ctypes.c_int), "taptools_garden_process": ([vp, f64p, f64p, ctypes.c_int], ctypes.c_int), + # the components the monoliths are made of + "taptools_reel_create": ([], vp), + "taptools_reel_destroy": ([vp], None), + "taptools_reel_prepare": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int), + "taptools_reel_set_length_seconds": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_reel_record": ([vp, ctypes.c_int], ctypes.c_int), + "taptools_reel_set_level": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_reel_set_pan": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_reel_set_darken_hz": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_reel_set_smooth_ms": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_reel_clear": ([vp], ctypes.c_int), + "taptools_reel_phase": ([vp], ctypes.c_double), + "taptools_reel_length_seconds": ([vp], ctypes.c_double), + "taptools_reel_loop_samples": ([vp], ctypes.c_int), + "taptools_reel_process": ([vp, f64p, f64p, f64p, ctypes.c_int], ctypes.c_int), + "taptools_chime_create": ([], vp), + "taptools_chime_destroy": ([vp], None), + "taptools_chime_prepare": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_chime_set_times": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int), + "taptools_chime_set_material": ([vp, ctypes.c_int], ctypes.c_int), + "taptools_chime_set_spread": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_chime_clear": ([vp], ctypes.c_int), + "taptools_chime_strike": ([vp, ctypes.c_double, ctypes.c_double, ctypes.c_double], + ctypes.c_int), + "taptools_chime_strike_hz": ([vp, ctypes.c_double, ctypes.c_double, ctypes.c_double], + ctypes.c_int), + "taptools_chime_active_voices": ([vp], ctypes.c_int), + "taptools_chime_process": ([vp, f64p, f64p, ctypes.c_int], ctypes.c_int), + "taptools_bloom_create": ([], vp), + "taptools_bloom_destroy": ([vp], None), + "taptools_bloom_prepare": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_bloom_set_loop_seconds": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_bloom_set_decay": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_bloom_set_soften": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_bloom_set_floor": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_bloom_set_brightness": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_bloom_clear": ([vp], ctypes.c_int), + "taptools_bloom_plant": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int), + "taptools_bloom_due": ([vp, f64p, f64p, f64p, ctypes.c_int], ctypes.c_int), + "taptools_bloom_step": ([vp], ctypes.c_int), + "taptools_bloom_active_events": ([vp], ctypes.c_int), + "taptools_bloom_loop_samples": ([vp], ctypes.c_int), + "taptools_gardener_create": ([], vp), + "taptools_gardener_destroy": ([vp], None), + "taptools_gardener_prepare": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_gardener_set_idle_seconds": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_gardener_set_gust": ([vp, ctypes.c_double], ctypes.c_int), + "taptools_gardener_set_seed": ([vp, ctypes.c_ulonglong], ctypes.c_int), + "taptools_gardener_notice_plant": ([vp], ctypes.c_int), + "taptools_gardener_clear": ([vp], ctypes.c_int), + "taptools_gardener_tick": ([vp, ctypes.c_int, f64p, f64p], ctypes.c_int), + "taptools_scale_quantize": ([ctypes.c_double, ctypes.c_int, ctypes.c_int], + ctypes.c_double), "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), @@ -1338,6 +1395,250 @@ def __del__(self): self._h = None +class Reel: + """One lane of tap.airport~ (tap::tools::airport::loop) — a single + free-running tape loop with one head that both plays and records. A bank + is an array of these and nothing more, so summing N of them reproduces + `Airport` exactly; that is what the null-test cells check.""" + + def __init__(self, sr: float = 48000.0, max_loop_seconds: float = 30.0, **params): + self._h = _LIB.taptools_reel_create() + _check(_LIB.taptools_reel_prepare(self._h, float(sr), float(max_loop_seconds)), "prepare") + self.set(**params) + + def set(self, *, length=None, level=None, pan=None, darken=None, smooth_ms=None) -> "Reel": + # configuration first, so ramped targets in the same call honor the new slew + if smooth_ms is not None: + _check(_LIB.taptools_reel_set_smooth_ms(self._h, float(smooth_ms)), "smooth_ms") + if length is not None: + _check(_LIB.taptools_reel_set_length_seconds(self._h, float(length)), "length") + if level is not None: + _check(_LIB.taptools_reel_set_level(self._h, float(level)), "level") + if pan is not None: + _check(_LIB.taptools_reel_set_pan(self._h, float(pan)), "pan") + if darken is not None: + _check(_LIB.taptools_reel_set_darken_hz(self._h, float(darken)), "darken") + return self + + def record(self, on: bool) -> "Reel": + """Punch the process() input onto the tape (True) or freeze it + bit-exactly (False). Recording starts wherever the head happens to be.""" + _check(_LIB.taptools_reel_record(self._h, 1 if on else 0), "record") + return self + + @property + def phase(self) -> float: + """The head position as a fraction of the loop length, 0..1.""" + return float(_LIB.taptools_reel_phase(self._h)) + + @property + def length_seconds(self) -> float: + """The loop length, as quantized to a whole number of samples.""" + return float(_LIB.taptools_reel_length_seconds(self._h)) + + @property + def loop_samples(self) -> int: + return int(_LIB.taptools_reel_loop_samples(self._h)) + + def process(self, x): + x = _f64(x) + out_l = np.zeros_like(x) + out_r = np.zeros_like(x) + _check(_LIB.taptools_reel_process(self._h, _p64(x), _p64(out_l), _p64(out_r), x.size), + "process") + return out_l, out_r + + def clear(self) -> None: + """Erase the tape and rewind the head; parameters are untouched.""" + _check(_LIB.taptools_reel_clear(self._h), "clear") + + def __del__(self): + h = getattr(self, "_h", None) + if h: + _LIB.taptools_reel_destroy(h) + self._h = None + + +class Chime: + """tap.garden~'s voice rack (tap::tools::garden::rack) — the fixed pool of + 16 wind chimes plus the quietest-first allocator, which steals by re-aiming + rather than resetting so a steal glides instead of clicking. The allocator + is kernel code on purpose: Max's poly~ steals round-robin and does not + exist off Max.""" + + def __init__(self, sr: float = 48000.0, **params): + self._h = _LIB.taptools_chime_create() + _check(_LIB.taptools_chime_prepare(self._h, float(sr)), "prepare") + self.set(**params) + + def set(self, *, attack=None, decay=None, material=None, spread=None) -> "Chime": + if attack is not None or decay is not None: + a = 0.004 if attack is None else float(attack) + d = 4.0 if decay is None else float(decay) + _check(_LIB.taptools_chime_set_times(self._h, a, d), "times") + if material is not None: + _check(_LIB.taptools_chime_set_material(self._h, int(material)), "material") + if spread is not None: + _check(_LIB.taptools_chime_set_spread(self._h, float(spread)), "spread") + return self + + def strike(self, pitch: float, velocity: float = 1.0, brightness: float = 1.0) -> "Chime": + """Strike the tube at a MIDI pitch (fractional pitches are distinct + tubes, with their own scatter and their own seat on the rack).""" + _check(_LIB.taptools_chime_strike(self._h, float(pitch), float(velocity), float(brightness)), + "strike") + return self + + def strike_hz(self, freq_hz: float, velocity: float = 1.0, brightness: float = 1.0) -> "Chime": + _check(_LIB.taptools_chime_strike_hz(self._h, float(freq_hz), float(velocity), + float(brightness)), "strike_hz") + return self + + @property + def active_voices(self) -> int: + return int(_LIB.taptools_chime_active_voices(self._h)) + + def process(self, n: int): + """A source: render n samples of the stereo rack.""" + out_l = np.zeros(int(n)) + out_r = np.zeros(int(n)) + _check(_LIB.taptools_chime_process(self._h, _p64(out_l), _p64(out_r), out_l.size), "process") + return out_l, out_r + + def clear(self) -> None: + _check(_LIB.taptools_chime_clear(self._h), "clear") + + def __del__(self): + h = getattr(self, "_h", None) + if h: + _LIB.taptools_chime_destroy(h) + self._h = None + + +class Bloom: + """tap.garden~'s event ring (tap::tools::garden::ring) — plant a bloom and + it returns at its own loop position every pass, velocity times `decay` and + brightness times `soften`, retiring below `floor`. It knows nothing about + chimes: it emits strikes, and what sounds them is your business. A plant at + velocity v lives exactly ceil(log(floor/v)/log(decay)) strikes.""" + + _MAX_EVENTS = 64 + + def __init__(self, sr: float = 48000.0, **params): + self._h = _LIB.taptools_bloom_create() + _check(_LIB.taptools_bloom_prepare(self._h, float(sr)), "prepare") + self._pitch = np.zeros(self._MAX_EVENTS) + self._vel = np.zeros(self._MAX_EVENTS) + self._bright = np.zeros(self._MAX_EVENTS) + self.set(**params) + + def set(self, *, loop_seconds=None, decay=None, soften=None, floor=None, + brightness=None) -> "Bloom": + if loop_seconds is not None: + _check(_LIB.taptools_bloom_set_loop_seconds(self._h, float(loop_seconds)), "loop") + if decay is not None: + _check(_LIB.taptools_bloom_set_decay(self._h, float(decay)), "decay") + if soften is not None: + _check(_LIB.taptools_bloom_set_soften(self._h, float(soften)), "soften") + if floor is not None: + _check(_LIB.taptools_bloom_set_floor(self._h, float(floor)), "floor") + if brightness is not None: + _check(_LIB.taptools_bloom_set_brightness(self._h, float(brightness)), "brightness") + return self + + def plant(self, pitch: float, velocity: float) -> "Bloom": + """Plant at the current loop position; it fires on the next due().""" + _check(_LIB.taptools_bloom_plant(self._h, float(pitch), float(velocity)), "plant") + return self + + def due(self): + """The strikes due on this sample as a list of (pitch, velocity, + brightness). Fires and wears them, but does NOT advance — call step().""" + n = _LIB.taptools_bloom_due(self._h, _p64(self._pitch), _p64(self._vel), + _p64(self._bright), self._MAX_EVENTS) + _check(0 if n >= 0 else -1, "due") + return [(self._pitch[i], self._vel[i], self._bright[i]) for i in range(n)] + + def step(self) -> None: + """Advance the loop one sample.""" + _check(_LIB.taptools_bloom_step(self._h), "step") + + @property + def active_events(self) -> int: + return int(_LIB.taptools_bloom_active_events(self._h)) + + @property + def loop_samples(self) -> int: + return int(_LIB.taptools_bloom_loop_samples(self._h)) + + def clear(self) -> None: + _check(_LIB.taptools_bloom_clear(self._h), "clear") + + def __del__(self): + h = getattr(self, "_h", None) + if h: + _LIB.taptools_bloom_destroy(h) + self._h = None + + +class Gardener: + """tap.garden~'s idle wind (tap::tools::garden::gardener) — after + `idle_seconds` without a caller plant, strikes arrive on a calm/gust cycle. + The rng is drawn from ONLY while idling, which is what makes the seed triad + hold. Pitches come out RAW: quantizing them is the caller's job, because the + scale is the caller's field, not the wind's.""" + + def __init__(self, sr: float = 48000.0, **params): + self._h = _LIB.taptools_gardener_create() + _check(_LIB.taptools_gardener_prepare(self._h, float(sr)), "prepare") + self._pitch = np.zeros(1) + self._vel = np.zeros(1) + self.set(**params) + + def set(self, *, idle_seconds=None, gust=None, seed=None) -> "Gardener": + if idle_seconds is not None: + _check(_LIB.taptools_gardener_set_idle_seconds(self._h, float(idle_seconds)), "idle") + if gust is not None: + _check(_LIB.taptools_gardener_set_gust(self._h, float(gust)), "gust") + if seed is not None: + _check(_LIB.taptools_gardener_set_seed(self._h, int(seed)), "seed") + return self + + def notice_plant(self) -> "Gardener": + """A caller planted: close the idle gate.""" + _check(_LIB.taptools_gardener_notice_plant(self._h), "notice_plant") + return self + + def tick(self, loop_samples: int): + """Advance one sample. Returns (pitch, velocity) if the wind wants a + strike this sample, else None.""" + n = _LIB.taptools_gardener_tick(self._h, int(loop_samples), _p64(self._pitch), + _p64(self._vel)) + _check(0 if n >= 0 else -1, "tick") + return (self._pitch[0], self._vel[0]) if n == 1 else None + + def clear(self) -> None: + """Re-seed the rng and restart the idle clock and the wind.""" + _check(_LIB.taptools_gardener_clear(self._h), "clear") + + def __del__(self): + h = getattr(self, "_h", None) + if h: + _LIB.taptools_gardener_destroy(h) + self._h = None + + +def scale_quantize(pitch, root: int = 0, scale: int = 3): + """tap.garden~'s entry quantizer (tap::tools::garden::scale_quantizer): + snap MIDI semitones to the nearest pitch in root/scale. `scale` indexes + garden::scale_index — 0 chromatic, 1 major, 2 minor, 3 major pentatonic, + 4 minor pentatonic. Accepts a scalar or an array.""" + if np.isscalar(pitch): + return float(_LIB.taptools_scale_quantize(float(pitch), int(root), int(scale))) + return np.array([float(_LIB.taptools_scale_quantize(float(p), int(root), int(scale))) + for p in np.asarray(pitch, dtype=float)]) + + 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/tools/capi/taptools_capi.cpp b/tools/capi/taptools_capi.cpp index e84c48c..13389c1 100644 --- a/tools/capi/taptools_capi.cpp +++ b/tools/capi/taptools_capi.cpp @@ -5,6 +5,9 @@ #include "taptools_capi.h" +#include +#include + // The DSP cores are the same headers the Max externals compile — no Max/Min dependency. #include #include @@ -1313,4 +1316,277 @@ int taptools_garden_process(taptools_garden h, double* outL, double* outR, int n return with(h, [&](garden_bed& g) { g.process(outL, outR, static_cast(n)); }); } +// ---- tap.reel~ ----------------------------------------------------------------------------------- + +using airport_lane = tap::tools::airport::loop; + +taptools_reel taptools_reel_create(void) { + return static_cast(new airport_lane()); +} + +void taptools_reel_destroy(taptools_reel h) { + delete static_cast(h); +} + +int taptools_reel_prepare(taptools_reel h, double sr, double max_loop_seconds) { + if (max_loop_seconds <= 0.0) { + return -1; + } + return with(h, [&](airport_lane& l) { l.prepare(sr, max_loop_seconds); }); +} + +int taptools_reel_set_length_seconds(taptools_reel h, double s) { + return with(h, [&](airport_lane& l) { l.set_length_seconds(s); }); +} + +int taptools_reel_record(taptools_reel h, int on) { + return with(h, [&](airport_lane& l) { l.record(on != 0); }); +} + +int taptools_reel_set_level(taptools_reel h, double lin) { + return with(h, [&](airport_lane& l) { l.set_level(lin); }); +} + +int taptools_reel_set_pan(taptools_reel h, double pan) { + return with(h, [&](airport_lane& l) { l.set_pan(pan); }); +} + +int taptools_reel_set_darken_hz(taptools_reel h, double hz) { + return with(h, [&](airport_lane& l) { l.set_darken_hz(hz); }); +} + +int taptools_reel_set_smooth_ms(taptools_reel h, double ms) { + return with(h, [&](airport_lane& l) { l.set_smooth_ms(ms); }); +} + +int taptools_reel_clear(taptools_reel h) { + return with(h, [&](airport_lane& l) { l.clear(); }); +} + +double taptools_reel_phase(taptools_reel h) { + if (!h) { + return -1.0; + } + return static_cast(h)->phase(); +} + +double taptools_reel_length_seconds(taptools_reel h) { + if (!h) { + return -1.0; + } + return static_cast(h)->length_seconds(); +} + +int taptools_reel_loop_samples(taptools_reel h) { + if (!h) { + return -1; + } + return static_cast(static_cast(h)->loop_samples()); +} + +int taptools_reel_process(taptools_reel h, const double* in, double* outL, double* outR, int n) { + if (!in || !outL || !outR || n < 0) { + return -1; + } + return with(h, [&](airport_lane& l) { l.process(in, outL, outR, static_cast(n)); }); +} + +// ---- tap.chime~ ---------------------------------------------------------------------------------- + +using garden_rack = tap::tools::garden::rack; + +taptools_chime taptools_chime_create(void) { + return static_cast(new garden_rack()); +} + +void taptools_chime_destroy(taptools_chime h) { + delete static_cast(h); +} + +int taptools_chime_prepare(taptools_chime h, double sr) { + return with(h, [&](garden_rack& r) { r.prepare(sr); }); +} + +int taptools_chime_set_times(taptools_chime h, double attack_s, double decay_s) { + return with(h, [&](garden_rack& r) { r.set_times(attack_s, decay_s); }); +} + +int taptools_chime_set_material(taptools_chime h, int material) { + return with(h, [&](garden_rack& r) { r.set_material(material); }); +} + +int taptools_chime_set_spread(taptools_chime h, double amount) { + return with(h, [&](garden_rack& r) { r.set_spread(amount); }); +} + +int taptools_chime_clear(taptools_chime h) { + return with(h, [&](garden_rack& r) { r.clear(); }); +} + +int taptools_chime_strike(taptools_chime h, double pitch, double velocity, double brightness) { + return with(h, [&](garden_rack& r) { r.strike(pitch, velocity, brightness); }); +} + +int taptools_chime_strike_hz(taptools_chime h, double freq_hz, double velocity, double brightness) { + if (freq_hz <= 0.0) { + return -1; + } + return with(h, [&](garden_rack& r) { r.strike_hz(freq_hz, velocity, brightness); }); +} + +int taptools_chime_active_voices(taptools_chime h) { + if (!h) { + return -1; + } + return static_cast(h)->active_voices(); +} + +int taptools_chime_process(taptools_chime h, double* outL, double* outR, int n) { + if (!outL || !outR || n < 0) { + return -1; + } + return with(h, [&](garden_rack& r) { + for (int i = 0; i < n; ++i) { // rack::process accumulates, so a standalone caller zeroes + outL[i] = 0.0; + outR[i] = 0.0; + r.process(outL[i], outR[i]); + } + }); +} + +// ---- tap.bloom ----------------------------------------------------------------------------------- + +using garden_ring = tap::tools::garden::ring; + +taptools_bloom taptools_bloom_create(void) { + return static_cast(new garden_ring()); +} + +void taptools_bloom_destroy(taptools_bloom h) { + delete static_cast(h); +} + +int taptools_bloom_prepare(taptools_bloom h, double sr) { + return with(h, [&](garden_ring& r) { r.prepare(sr); }); +} + +int taptools_bloom_set_loop_seconds(taptools_bloom h, double s) { + return with(h, [&](garden_ring& r) { r.set_loop_seconds(s); }); +} + +int taptools_bloom_set_decay(taptools_bloom h, double per_pass) { + return with(h, [&](garden_ring& r) { r.set_decay(per_pass); }); +} + +int taptools_bloom_set_soften(taptools_bloom h, double per_pass) { + return with(h, [&](garden_ring& r) { r.set_soften(per_pass); }); +} + +int taptools_bloom_set_floor(taptools_bloom h, double v) { + return with(h, [&](garden_ring& r) { r.set_floor(v); }); +} + +int taptools_bloom_set_brightness(taptools_bloom h, double b) { + return with(h, [&](garden_ring& r) { r.set_brightness(b); }); +} + +int taptools_bloom_clear(taptools_bloom h) { + return with(h, [&](garden_ring& r) { r.clear(); }); +} + +int taptools_bloom_plant(taptools_bloom h, double pitch, double velocity) { + if (velocity <= 0.0) { + return -1; + } + return with(h, [&](garden_ring& r) { r.plant(pitch, velocity); }); +} + +int taptools_bloom_due(taptools_bloom h, double* pitch, double* velocity, double* brightness, int max) { + if (!h || !pitch || !velocity || !brightness || max < 0) { + return -1; + } + std::array fired{}; + const int limit = std::min(max, tap::tools::garden::k_max_events); + const int n = static_cast(h)->due(fired.data(), limit); + for (int i = 0; i < n; ++i) { + pitch[i] = fired[static_cast(i)].pitch; + velocity[i] = fired[static_cast(i)].velocity; + brightness[i] = fired[static_cast(i)].brightness; + } + return n; +} + +int taptools_bloom_step(taptools_bloom h) { + return with(h, [&](garden_ring& r) { r.step(); }); +} + +int taptools_bloom_active_events(taptools_bloom h) { + if (!h) { + return -1; + } + return static_cast(h)->active_events(); +} + +int taptools_bloom_loop_samples(taptools_bloom h) { + if (!h) { + return -1; + } + return static_cast(static_cast(h)->loop_samples()); +} + +// ---- tap.gardener -------------------------------------------------------------------------------- + +using garden_wind = tap::tools::garden::gardener; + +taptools_gardener taptools_gardener_create(void) { + return static_cast(new garden_wind()); +} + +void taptools_gardener_destroy(taptools_gardener h) { + delete static_cast(h); +} + +int taptools_gardener_prepare(taptools_gardener h, double sr) { + return with(h, [&](garden_wind& g) { g.prepare(sr); }); +} + +int taptools_gardener_set_idle_seconds(taptools_gardener h, double s) { + return with(h, [&](garden_wind& g) { g.set_idle_seconds(s); }); +} + +int taptools_gardener_set_gust(taptools_gardener h, double amount) { + return with(h, [&](garden_wind& g) { g.set_gust(amount); }); +} + +int taptools_gardener_set_seed(taptools_gardener h, unsigned long long seed) { + return with(h, [&](garden_wind& g) { g.set_seed(static_cast(seed)); }); +} + +int taptools_gardener_notice_plant(taptools_gardener h) { + return with(h, [&](garden_wind& g) { g.notice_plant(); }); +} + +int taptools_gardener_clear(taptools_gardener h) { + return with(h, [&](garden_wind& g) { g.clear(); }); +} + +int taptools_gardener_tick(taptools_gardener h, int loop_samples, double* pitch, double* velocity) { + if (!h || !pitch || !velocity || loop_samples < 1) { + return -1; + } + const garden_wind::request req = static_cast(h)->tick(static_cast(loop_samples)); + *pitch = req.pitch; + *velocity = req.velocity; + return req.wanted ? 1 : 0; +} + +// ---- tap.scale ----------------------------------------------------------------------------------- + +double taptools_scale_quantize(double pitch, int root, int scale) { + tap::tools::garden::scale_quantizer q; + q.set_root(root); + q.set_scale(scale); + return q.quantize(pitch); +} + } // extern "C" diff --git a/tools/capi/taptools_capi.h b/tools/capi/taptools_capi.h index 47054e0..045a3c3 100644 --- a/tools/capi/taptools_capi.h +++ b/tools/capi/taptools_capi.h @@ -420,6 +420,106 @@ TAPTOOLS_API int taptools_garden_active_voices(taptools_garden h); // ringing be /// A source: renders n samples of the stereo rack into outL/outR. TAPTOOLS_API int taptools_garden_process(taptools_garden h, double* outL, double* outR, int n); +// ---- the components the monoliths are made of ---------------------------------------------------- +// +// tap.airport~ is a sum of tap.reel~ lanes; tap.garden~ is tap.scale into tap.bloom into +// tap.chime~ with tap.gardener planting when idle. These entry points reach the same classes the +// monoliths hold, so a notebook can null-test the patch against the object through one ABI. + +// ---- tap.reel~ (tap::tools::airport::loop) ------------------------------------------------------- + +typedef void* taptools_reel; + +TAPTOOLS_API taptools_reel taptools_reel_create(void); +TAPTOOLS_API void taptools_reel_destroy(taptools_reel h); + +TAPTOOLS_API int taptools_reel_prepare(taptools_reel h, double sr, double max_loop_seconds); +TAPTOOLS_API int taptools_reel_set_length_seconds(taptools_reel h, double s); +TAPTOOLS_API int taptools_reel_record(taptools_reel h, int on); // 1 punch, 0 freeze +TAPTOOLS_API int taptools_reel_set_level(taptools_reel h, double lin); +TAPTOOLS_API int taptools_reel_set_pan(taptools_reel h, double pan); // -1..1 equal-power +TAPTOOLS_API int taptools_reel_set_darken_hz(taptools_reel h, double hz); +TAPTOOLS_API int taptools_reel_set_smooth_ms(taptools_reel h, double ms); +TAPTOOLS_API int taptools_reel_clear(taptools_reel h); + +TAPTOOLS_API double taptools_reel_phase(taptools_reel h); // 0..1 (-1 on bad handle) +TAPTOOLS_API double taptools_reel_length_seconds(taptools_reel h); // as quantized to samples +TAPTOOLS_API int taptools_reel_loop_samples(taptools_reel h); // what the lcm arithmetic reads + +/// Renders n samples. ASSIGNS outL/outR (the lane's block form), so this is one reel on its own. +TAPTOOLS_API int taptools_reel_process(taptools_reel h, const double* in, double* outL, double* outR, int n); + +// ---- tap.chime~ (tap::tools::garden::rack) ------------------------------------------------------- + +typedef void* taptools_chime; + +TAPTOOLS_API taptools_chime taptools_chime_create(void); +TAPTOOLS_API void taptools_chime_destroy(taptools_chime h); + +TAPTOOLS_API int taptools_chime_prepare(taptools_chime h, double sr); +TAPTOOLS_API int taptools_chime_set_times(taptools_chime h, double attack_s, double decay_s); +TAPTOOLS_API int taptools_chime_set_material(taptools_chime h, int material); // garden::material_index +TAPTOOLS_API int taptools_chime_set_spread(taptools_chime h, double amount); // 0 mono .. 1 +TAPTOOLS_API int taptools_chime_clear(taptools_chime h); + +/// Strike a tube: MIDI pitch (fractional accepted) or a raw frequency. Velocity and brightness 0..1. +TAPTOOLS_API int taptools_chime_strike(taptools_chime h, double pitch, double velocity, double brightness); +TAPTOOLS_API int taptools_chime_strike_hz(taptools_chime h, double freq_hz, double velocity, double brightness); + +TAPTOOLS_API int taptools_chime_active_voices(taptools_chime h); // ringing bells (-1 on bad handle) + +/// A source: renders n samples of the stereo rack, ASSIGNING outL/outR. +TAPTOOLS_API int taptools_chime_process(taptools_chime h, double* outL, double* outR, int n); + +// ---- tap.bloom (tap::tools::garden::ring) -------------------------------------------------------- + +typedef void* taptools_bloom; + +TAPTOOLS_API taptools_bloom taptools_bloom_create(void); +TAPTOOLS_API void taptools_bloom_destroy(taptools_bloom h); + +TAPTOOLS_API int taptools_bloom_prepare(taptools_bloom h, double sr); +TAPTOOLS_API int taptools_bloom_set_loop_seconds(taptools_bloom h, double s); +TAPTOOLS_API int taptools_bloom_set_decay(taptools_bloom h, double per_pass); +TAPTOOLS_API int taptools_bloom_set_soften(taptools_bloom h, double per_pass); +TAPTOOLS_API int taptools_bloom_set_floor(taptools_bloom h, double v); +TAPTOOLS_API int taptools_bloom_set_brightness(taptools_bloom h, double b); +TAPTOOLS_API int taptools_bloom_clear(taptools_bloom h); + +/// Plant at the current loop position; it fires on the next due() and every pass after. +TAPTOOLS_API int taptools_bloom_plant(taptools_bloom h, double pitch, double velocity); + +/// The strikes due on this sample, written into caller arrays of at least `max` entries. Returns +/// how many were written, or -1 on a bad handle. Does NOT advance the loop — call step() after. +TAPTOOLS_API int taptools_bloom_due(taptools_bloom h, double* pitch, double* velocity, double* brightness, int max); +TAPTOOLS_API int taptools_bloom_step(taptools_bloom h); + +TAPTOOLS_API int taptools_bloom_active_events(taptools_bloom h); // live blooms (-1 on bad handle) +TAPTOOLS_API int taptools_bloom_loop_samples(taptools_bloom h); + +// ---- tap.gardener (tap::tools::garden::gardener) ------------------------------------------------- + +typedef void* taptools_gardener; + +TAPTOOLS_API taptools_gardener taptools_gardener_create(void); +TAPTOOLS_API void taptools_gardener_destroy(taptools_gardener h); + +TAPTOOLS_API int taptools_gardener_prepare(taptools_gardener h, double sr); +TAPTOOLS_API int taptools_gardener_set_idle_seconds(taptools_gardener h, double s); // 0 disables +TAPTOOLS_API int taptools_gardener_set_gust(taptools_gardener h, double amount); +TAPTOOLS_API int taptools_gardener_set_seed(taptools_gardener h, unsigned long long seed); +TAPTOOLS_API int taptools_gardener_notice_plant(taptools_gardener h); // a caller plant closes the gate +TAPTOOLS_API int taptools_gardener_clear(taptools_gardener h); + +/// Advance the idle clock one sample. Returns 1 if the wind wants a strike (writing the RAW, +/// unquantized pitch and the velocity), 0 if not, -1 on a bad handle. +TAPTOOLS_API int taptools_gardener_tick(taptools_gardener h, int loop_samples, double* pitch, double* velocity); + +// ---- tap.scale (tap::tools::garden::scale_quantizer) --------------------------------------------- + +/// Stateless enough to need no handle: snap MIDI semitones to the nearest pitch in root/scale. +TAPTOOLS_API double taptools_scale_quantize(double pitch, int root, int scale); + #ifdef __cplusplus } #endif From fe70e20990b8770cebc2937dbbe415480443979f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 01:08:04 +0000 Subject: [PATCH 4/6] Write the components chapter, and point the two family chapters at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *The same machine, in pieces* — a Part IV chapter covering the decomposition, plus the drafting record and a short section in each of the airport and garden chapters pointing at it. The spine is the reading that made the split worth doing: the monoliths were monoliths by accident, not design. tape_loop.h was already a component library, airport.h already held an array of identical lanes and a summing loop, and the package already ships this way everywhere else — there is no tap.808~, there are eight voices and a sequencer. So the chapter frames this as promoting seams the code already had, and says in its first three sentences that the monoliths are staying. Every claim traces to a pinned scenario; all seven cited names were checked against the test sources. No notebook cells were added, because nothing the notebooks measure changed — the new claims are structural (bitwise identity, exact arithmetic), which tests carry better than measured cells. The section that earns the chapter is the one on where the seams show: the garden's patch is not sample-accurate and there is deliberately no in-Max null test for it; voice stealing had to stay in the kernel for two separate reasons; and a bell reads silent until processed once, so same-sample strikes collide. The chapter also names what the decomposition loses — composite_period has nowhere to live in a patch of independent reels — rather than pretending it is free. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe --- book/PLAN-eno-components.md | 99 +++++++++++++++++++++++++++ book/src/SUMMARY.md | 1 + book/src/airport.md | 11 +++ book/src/components.md | 133 ++++++++++++++++++++++++++++++++++++ book/src/garden.md | 14 ++++ 5 files changed, 258 insertions(+) create mode 100644 book/PLAN-eno-components.md create mode 100644 book/src/components.md diff --git a/book/PLAN-eno-components.md b/book/PLAN-eno-components.md new file mode 100644 index 0000000..96695b0 --- /dev/null +++ b/book/PLAN-eno-components.md @@ -0,0 +1,99 @@ +# Plan — *The same machine, in pieces* (`src/components.md`) + +Drafting record for the chapter covering the decomposition of `tap.airport~` and +`tap.garden~` into patchable components. Kept after shipping, per the house convention. + +## Where it came from + +Not a roadmap item. The question was asked directly: the airport and garden objects are +nice as monolithic blocks, but would breaking them into components patched together in Max +both reveal the inner structure and allow customization? + +The answer that made it worth doing was not "yes, that would be nice" but a reading of the +code: **the components already existed as classes and only the monolith could reach them.** +`tape_loop.h` was already a component library; `airport.h` held an array of `loop_state` +and a summing loop; `garden.h`'s `bell` was already standalone and `bed` was four machines +wired together. And the package already ships this way everywhere else — there is no +`tap.808~`, there are eight voices and a `tap.808.seq~`. The Eno objects were the +exception, not the norm. + +That reframing is the chapter's spine: this is not a redesign, it is promoting seams the +code already had. + +## What shipped + +Kernel: `airport::loop` extracted from `loop_bank`; `garden::rack` / `garden::ring` / +`garden::gardener` / `garden::scale_quantizer` extracted from `garden::bed`. Both +monoliths become composition and nothing else. C ABI + ctypes bindings for each component. +Max: `tap.reel~`, `tap.chime~`, `tap.bloom`, `tap.scale`, `tap.gardener`, full vertical +slice each. + +## Evidence the chapter is allowed to cite + +Everything in the chapter traces to a pinned scenario. No notebook cells were added — the +kernels' behaviour did not change, so the executed notebooks still stand as they are, and +the new claims are structural rather than measured-from-audio. + +- `tests/airport_test.cpp` — "standalone lanes summed are the bank, bitwise" (3 lanes, + 2 s, staggered punch schedule, both pan endpoints, shaded and bypassed darken). Mutation + check during development: a 1e-12 level nudge on one lane fails it, so it is not vacuous. +- `tests/airport_test.cpp` — "a lone lane's head is as sacred as one in the bank"; + "unprepared, a lone lane is silent and leaves the busses alone". +- `tests/garden_test.cpp` — "the bed is exactly its components wired together, bitwise" + (20 s, gardener running, so rng consumption order is under test). +- `tests/garden_test.cpp` — "the ring's convergence theorem is exact when nothing sounds + it" (4 triples against `ceil(log(f/v)/log(d))`). +- `tests/garden_test.cpp` — "the rack fills idle bells first, then steals the quietest"; + "the gardener touches its rng only while idling". +- `TapTools-Max/runtime-tests/patchers/tap.reel~-is-airport.maxtest.maxpat` — the same + airport identity against the real externals in Max (on-Mac gate, not CI). + +Behaviour-preservation of the extractions themselves was verified during development with +throwaway fingerprint harnesses (FNV-1a over every output sample of multi-second renders +through splices, punch-ins, mode changes, and the seeded gardener; identical before and +after). Those are **not** committed and the chapter does not cite them — the committed +evidence is that every pre-existing scenario passes unchanged, plus the null tests. + +## Structure + +1. The monoliths were monoliths by accident — the parts were already there. Table of the + five objects and what each one was. +2. "The patch is the object" as a measurement, not a slogan — the two bitwise null tests. + Why bitwise is available at all (the objects' own promises are already bitwise). +3. What patching buys: airport's four (insert on one loop, varispeed, >8 loops, tape you + actually use), and `tap.bloom` as the most portable idea in the family. +4. Where the seams show — three honest costs. +5. Checkpoint. + +## The three honest costs (the section that earns the chapter) + +- **The garden's patch is not sample-accurate.** `tap.bloom`/`tap.gardener` run on Max's + scheduler; returns land within an `@interval` tick. Stated plainly, with the consequence: + there is deliberately no in-Max null test for the garden, because asserting a null that + cannot hold is worse than not asserting one. +- **Voice stealing had to stay in the kernel.** The obvious `poly~` answer is wrong twice + — round-robin stealing loses the glide-not-click promise, and `poly~` does not exist off + Max. This is the decision the chapter should be clearest about, because it looks like + over-engineering until you know both halves of the reason. +- **A bell reads silent until processed once**, so same-sample strikes collide onto one + voice. Pre-existing, surfaced by the new rack test, documented rather than fixed — + fixing it would change the sound. + +## Deliberately left out + +- `composite_period` has nowhere to live in a patch of independent reels (it needs all the + lengths at once). `tap.reel~` reports `loopsamples`, which is the raw material; a + `tap.period` utility is the obvious follow-up and is flagged in `REVIVAL.md` rather than + quietly dropped. The chapter names the loss instead of pretending the decomposition is + free. +- No new notebook sections. The claims here are structural (bitwise identity, exact + arithmetic), which pinned tests carry better than measured cells. + +## Voice notes + +- Same as the family chapters: the person patching, not the person marketing. +- Resist "modular is better". The monoliths are the put-it-on-and-walk-away objects and the + chapter should say so in its first three sentences, so the split reads as additive. +- Titles considered and rejected: "Taking the lid off" (cute, says nothing), "The + decomposition" (an engineering word for a musical book), "Components" (a category, not a + title). diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 2e0cd65..271cb21 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -23,6 +23,7 @@ - [The tape that forgets slowly](discreet.md) - [Loops that never line up](airport.md) - [The garden that plays itself](garden.md) +- [The same machine, in pieces](components.md) # Part V — The spectral set diff --git a/book/src/airport.md b/book/src/airport.md index 276d4a0..021d276 100644 --- a/book/src/airport.md +++ b/book/src/airport.md @@ -94,6 +94,17 @@ like tape, run it through `tap.discreet~` on the way in. regen 0) before the record gate — tape transport on the way in, stable free-run once captured. +## The same machine, in pieces + +There was never a loop bank doing loop-bank things in here — there is an +array of eight identical lanes and a summing loop. That lane is now an +object of its own, `tap.reel~`, and three of them summed are a +`tap.airport~` bitwise (pinned in `tests/airport_test.cpp`). Patch it +instead of using this object when you want an insert on *one* loop, a +varispeed on one reel, more than eight loops, or tape you actually use — +the bank buys all eight worst-case reels at DSP start regardless. See +[The same machine, in pieces](components.md). + ## When it is not the right tool - **Synchronized looping.** This machine never lines up *by design*. A diff --git a/book/src/components.md b/book/src/components.md new file mode 100644 index 0000000..3427378 --- /dev/null +++ b/book/src/components.md @@ -0,0 +1,133 @@ +# The same machine, in pieces + +The two chapters before this one describe instruments you switch on and +walk away from. `tap.airport~` turns seven loops; `tap.garden~` tends +itself. That is the right shape for what they do, and neither is going +anywhere. + +But both were monoliths by accident rather than by design. Open +`airport.h` and there was never a loop bank doing loop-bank things — there +was an array of eight identical lanes and a summing loop. Open `garden.h` +and there was a quantizer, an event ring, a chime rack, and a seeded +gardener, wired together by a class that did nothing else. The parts were +already there. Nothing outside the monolith could reach one. + +So they were promoted. The lanes and the parts are objects now, and the +block diagrams at the top of the last two chapters are patchable: + +| Object | What it is | Was | +|---|---|---| +| `tap.reel~` | one free-running tape loop | a lane of `tap.airport~` | +| `tap.chime~` | the sixteen-bell wind-chime rack | the voice pool of `tap.garden~` | +| `tap.bloom` | the event ring — plant, return, fade, retire | the recirculation of `tap.garden~` | +| `tap.scale` | snap a pitch to a root and scale | the entry quantizer | +| `tap.gardener` | the idle wind, seeded | the self-seeding half | + +The monoliths remain exactly what they were. This is additive: the same +kernel classes, reached two ways. + +## "The patch is the object" is a measurement, not a slogan + +It would be easy to say that three `tap.reel~` summed are a +`tap.airport~` and leave it there. The house rule is that claims of that +kind get measured, so this one is pinned in CI like any performance +number. + +The scenario *"standalone lanes summed are the bank, bitwise"* in +`tests/airport_test.cpp` configures a three-lane bank and three standalone +lanes identically — incommensurate lengths, both exact pan endpoints and +one interior pan, one shaded darken corner and one bypassed — drives both +through the same staggered punch-in schedule for two seconds, and requires +the two stereo outputs to be equal **to the bit**, not to a tolerance. +Nudging one lane's level by 1e-12 fails it. + +The garden's version, *"the bed is exactly its components wired +together, bitwise"* in `tests/garden_test.cpp`, does the same across +twenty seconds with the seeded gardener running — which puts the order of +random draws under test too, since that is the part a careless split moves +without anyone noticing. + +Bitwise is available here because the objects' own promises are already +bitwise: transparent playback of a frozen loop, exact pan endpoints, a +darken stage that is genuinely bypassed at the band ceiling. A +decomposition can be held to the same standard the object is. + +## What you get for patching it + +For the airport, four things the monolith cannot give you: + +- **An insert on one loop.** A filter, a reverse, a `tap.discreet~` for + tape breath on one phrase and not the others. Inside the bank every + loop gets the same treatment, which is to say none. +- **A varispeed on one reel** — the bank has one shared clock by + construction. +- **More than eight loops.** Eight was a number, not a principle. +- **Tape you actually use.** The bank buys all eight worst-case reels at + DSP start whether you use them or not: about 92 MB of double tape at + the 30-second default. Three `tap.reel~` buy three, about 11 MB each. + +For the garden, the interesting one is `tap.bloom`. Separated from the +chime it turns out to be the most portable idea in the family, because it +recirculates *notes* and has no opinion about what sounds them. Point it +at `makenote`, at a sampler, at MIDI out, and Eno's principle — a touch +becomes a note, the note returns a little quieter each pass until it is +gone — drives an instrument that has nothing to do with wind chimes. + +Splitting also made two promises directly testable that were previously +only reachable through audio. The ring's arithmetic is now countable with +no envelope tail in the way: *"the ring's convergence theorem is exact +when nothing sounds it"* checks four different velocity/decay/floor +triples against `ceil(log(floor/velocity)/log(decay))` exactly. And the +rack's allocator can be watched directly — *"the rack fills idle bells +first, then steals the quietest"* fills the pool, strikes a seventeenth +tube, and measures that the faint tube lost its partial while a loud one +kept its own. + +## Where the seams show + +Three honest costs, none of them hidden. + +**The garden's patch is not sample-accurate.** `tap.bloom` and +`tap.gardener` run on Max's scheduler rather than the audio clock, so a +return lands within an `@interval` tick — a millisecond by default — +instead of exactly on the sample. Inside `tap.garden~` the same ring is +sample-accurate. At loop lengths measured in seconds nobody will hear the +difference, but it is a difference, and it is why the garden's null test +lives in the kernel where both sides can share one clock, and why there is +deliberately no in-Max null test for it. Asserting a null that cannot hold +would be worse than not asserting one. + +**Voice stealing had to stay in the kernel.** The obvious Max answer to a +sixteen-voice rack is one voice in a `poly~`. That answer is wrong twice: +`poly~` steals round-robin, which loses the whole point — this rack steals +the *quietest* bell and re-aims it, so its phases keep free-running and its +seat glides rather than clicking — and `poly~` does not exist off Max, +while the kernel is meant to run anywhere. So `tap.chime~` is the whole +rack, and its polyphony is its own. + +**A bell reads silent until it has been processed once.** The allocator +asks each bell for its level, and a bell that has been struck but not yet +processed still reports zero. Strikes issued in the same sample therefore +land on the same voice instead of spreading across the pool. Inside the +bed this only happens when two blooms share a loop position; it is +pre-existing behaviour, and it is documented in the rack scenario rather +than fixed, because fixing it would change how the object sounds. + +One thing the airport decomposition genuinely loses: `composite_period`, +the report of when the whole system realigns, needs every length at once +and so has nowhere to live in a patch of independent reels. `tap.reel~` +answers `loopsamples` with its own length in samples, which is the raw +material for that arithmetic, but the lcm itself is still the monolith's. + +## Checkpoint + +Five objects, no new DSP: the same kernel classes the monoliths hold, given +names and inlets. Three `tap.reel~` summed are a `tap.airport~` bitwise; +`tap.gardener` into `tap.scale` into `tap.bloom` into `tap.chime~` is a +`tap.garden~` bitwise, gardener and all. Both identities are pinned +scenarios in `tests/airport_test.cpp` and `tests/garden_test.cpp`, which +CI runs on every push, and the airport's is checked again against the real +externals loaded in Max by +`runtime-tests/patchers/tap.reel~-is-airport.maxtest.maxpat`. The +monoliths still do what they did — every scenario that pinned them before +the split passes unchanged after it. diff --git a/book/src/garden.md b/book/src/garden.md index e4ddf7c..151e2e9 100644 --- a/book/src/garden.md +++ b/book/src/garden.md @@ -140,6 +140,20 @@ that demands reproducibility. than six seconds, the gardener answers you; every plant of yours resets its patience. +## The same machine, in pieces + +The four machines inside this one — the entry quantizer, the event ring, +the chime rack, the seeded gardener — are objects too: `tap.scale`, +`tap.bloom`, `tap.chime~`, `tap.gardener`. Chained, they are this object +bitwise, gardener and all (pinned in `tests/garden_test.cpp`). The one +worth reaching for on its own is `tap.bloom`: separated from the chime it +recirculates *notes* and has no opinion about what sounds them, so the +principle will drive a sampler or MIDI out just as happily. One difference +to know before you patch it — out here the ring runs on Max's scheduler +rather than the audio clock, so returns land within a millisecond of the +grid instead of exactly on it. See +[The same machine, in pieces](components.md). + ## When it is not the right tool - **Melodies with wrong notes in them.** Quantization is always on; From fb864bee8786ca89bd984e0533121bf961b0f090 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 01:40:03 +0000 Subject: [PATCH 5/6] Tap the rack per voice, and free the lcm from the bank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions the decomposition asked for once the parts were objects. The rack can now be read one bell at a time. `bell::process_mono` returns the raw mono sum and `process` is written in terms of it, so there is still one oscillator path and the seat still glides on both — the refactor is bit-identical, and the fingerprint renders confirm it. `rack::process_voices` hands back k_voices dry taps, and voice_hz/voice_level/voice_gain_* say which tube each slot is holding and what seat it would have been given, which is what makes the taps usable: the pool reassigns bells as it steals, so a slot is not a pitch. New scenario: the taps put back through their seats are the stereo rack, bitwise, across twenty strikes — four more than the pool holds, so stealing is under test too. `composite_period_seconds` comes out of `loop_bank` as a free function over a set of lengths, because a patch of independent reels has no bank to ask. The bank now calls it rather than keeping its own gcd. Alongside it, `loop_samples_for` — the seconds-to-samples quantization a reel applies — is shared rather than copied, so `tap.period` and the reels it is asked about cannot drift apart. That mattered more than it looks: the lcm is over sample counts, and lengths that look commensurate as decimals are not as samples. Both reachable from the verification layer. Checked through the built library: the per-voice taps rebuild the stereo pair exactly, and the lcm of 0.5 and 0.625 is 2.5 s while the book's seven airport-scale lengths report inf. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe --- include/taptools/airport.h | 62 ++++++++++++++++++++++---------- include/taptools/garden.h | 66 +++++++++++++++++++++++++++++----- notebooks/taptools_py.py | 42 ++++++++++++++++++++++ tests/airport_test.cpp | 25 +++++++++++++ tests/garden_test.cpp | 70 ++++++++++++++++++++++++++++++++++++ tools/capi/taptools_capi.cpp | 60 +++++++++++++++++++++++++++++++ tools/capi/taptools_capi.h | 23 ++++++++++++ 7 files changed, 321 insertions(+), 27 deletions(-) diff --git a/include/taptools/airport.h b/include/taptools/airport.h index 6daf9a1..90dda1c 100644 --- a/include/taptools/airport.h +++ b/include/taptools/airport.h @@ -71,6 +71,46 @@ namespace tap::tools { constexpr double k_default_max_seconds = 30.0; // worst case per loop (~92 MB total @ 48k) constexpr double k_default_smooth_ms = 20.0; // anti-zipper ramp for level/pan/darken + /// The sample count a reel uses for a length in seconds — the same floor and the same + /// rounding the reel itself applies. Anything that needs to reason about a reel's grid + /// without holding one (tap.period, asked about a patch of reels it cannot see) goes + /// through here, so the two can never quietly disagree. + inline long loop_samples_for(double seconds, double sr) { + const double rate = (sr > 0.0) ? sr : 48000.0; + return static_cast(std::ceil(std::max(k_min_loop_seconds, seconds) * rate)); + } + + /// Least common multiple of a set of loop lengths (in SAMPLES), expressed in seconds — + /// how long until a bank of free-running loops realigns. Informational; returns +inf on + /// 64-bit overflow, which incommensurate lengths reach fast, and that is the point of the + /// piece rather than a failure. Free-standing so anything holding a set of lengths can ask + /// the question — a loop_bank asks it of its own lanes, and tap.period asks it of a patch + /// of independent tap.reel~ that has no bank to ask. + inline double composite_period_seconds(const long* loop_samples, int count, double sr) { + if (loop_samples == nullptr || count < 1 || sr <= 0.0) { + return 0.0; + } + long long acc = 1; + for (int i = 0; i < count; ++i) { + const long long n = static_cast(loop_samples[i]); + if (n < 1) { + return 0.0; + } + long long a = acc; + long long b = n; + while (b != 0) { // gcd + const long long t = a % b; + a = b; + b = t; + } + if (acc / a > std::numeric_limits::max() / n) { + return std::numeric_limits::infinity(); + } + acc = acc / a * n; + } + return static_cast(acc) / sr; + } + /// One free-running tape loop: a single head that both plays and records, its playback /// shaded, leveled, and panned to a seat. A `loop_bank` is an array of these and nothing /// more; one on its own is a complete instrument (tap.reel~), and the head is just as @@ -216,7 +256,7 @@ namespace tap::tools { private: long smooth_samples() const { return static_cast(m_smooth_ms * 0.001 * m_sr); } - long seconds_to_samples(double s) const { return static_cast(std::ceil(s * m_sr)); } + long seconds_to_samples(double s) const { return loop_samples_for(s, m_sr); } tape::reel m_tape; tape::wear m_shade; // playback tone only: drive stays 0, bypassed at ceiling @@ -335,16 +375,11 @@ namespace tap::tools { if (!prepared() || m_num_loops < 1) { return 0.0; } - long long acc = 1; + std::array lengths{}; for (int i = 0; i < m_num_loops; ++i) { - const long long n = static_cast(lane(i).loop_samples()); - const long long g = gcd_ll(acc, n); - if (acc / g > std::numeric_limits::max() / n) { - return std::numeric_limits::infinity(); - } - acc = acc / g * n; + lengths[static_cast(i)] = lane(i).loop_samples(); } - return static_cast(acc) / m_sr; + return airport::composite_period_seconds(lengths.data(), m_num_loops, m_sr); } // -- audio --------------------------------------------------------------------------- @@ -370,15 +405,6 @@ namespace tap::tools { } private: - static long long gcd_ll(long long a, long long b) { - while (b != 0) { - const long long t = a % b; - a = b; - b = t; - } - return a; - } - bool valid_loop(int index) const { return index >= 0 && index < k_max_loops; } loop& lane_ref(int index) { return m_loops[static_cast(std::clamp(index, 0, k_max_loops - 1))]; } diff --git a/include/taptools/garden.h b/include/taptools/garden.h index e986d8f..e8c6e0f 100644 --- a/include/taptools/garden.h +++ b/include/taptools/garden.h @@ -288,13 +288,14 @@ namespace tap::tools { m_gain_l = m_gain_l_target; m_gain_r = m_gain_r_target; } - const size_t mat = static_cast(std::clamp(material, 0, k_num_materials - 1)); - const uint64_t tube = tube_key(freq_hz); - const double hardness = k_hardness_floor + (1.0 - k_hardness_floor) * std::clamp(level, 0.0, 1.0); - const double b = std::clamp(brightness, 0.0, 1.0) * hardness; - const double ring = std::clamp(std::sqrt(440.0 / freq_hz), k_ring_scale_min, k_ring_scale_max); - const double split = std::exp2(k_doublet_cents / 2400.0); // half the split, up and down - double shine = 1.0; // b^0, b^1, b^2, b^3 per mode + const size_t mat = static_cast(std::clamp(material, 0, k_num_materials - 1)); + const uint64_t tube = tube_key(freq_hz); + m_freq_hz = freq_hz; // which tube this bell is holding, for per-voice callers + const double hardness = k_hardness_floor + (1.0 - k_hardness_floor) * std::clamp(level, 0.0, 1.0); + const double b = std::clamp(brightness, 0.0, 1.0) * hardness; + const double ring = std::clamp(std::sqrt(440.0 / freq_hz), k_ring_scale_min, k_ring_scale_max); + const double split = std::exp2(k_doublet_cents / 2400.0); // half the split, up and down + double shine = 1.0; // b^0, b^1, b^2, b^3 per mode for (int m = 0; m < k_modes; ++m) { const size_t i = static_cast(m); const double scatter = @@ -316,8 +317,11 @@ namespace tap::tools { return sum; } - /// Sum this chime, panned to its seat, into the running busses. - void process(double& out_left, double& out_right) { + /// Advance this chime one sample and return its RAW mono sum, before the seat is + /// applied — the tube as it would sound with your ear against it. The seat still + /// glides on this call, so the two process paths stay in step and a caller that + /// wants the dry voice does not have to give up the panning state. + double process_mono() { double sum = 0.0; for (size_t i = 0; i < static_cast(k_modes); ++i) { m_phase_a[i] += m_inc_a[i]; @@ -329,15 +333,32 @@ namespace tap::tools { } m_gain_l += m_pan_coeff * (m_gain_l_target - m_gain_l); m_gain_r += m_pan_coeff * (m_gain_r_target - m_gain_r); + return sum; + } + + /// Sum this chime, panned to its seat, into the running busses. + void process(double& out_left, double& out_right) { + const double sum = process_mono(); out_left += sum * m_gain_l; out_right += sum * m_gain_r; } + /// The seat gains this chime is currently sounding at — what process() multiplies + /// the mono sum by, so a caller holding the dry voice can rebuild the rack image. + double gain_left() const { return m_gain_l; } + double gain_right() const { return m_gain_r; } + + /// The fundamental this chime was last struck at, in Hz (0 before any strike). The + /// pool reassigns bells as it steals, so this is how a caller knows which tube a + /// given voice is currently holding. + double frequency() const { return m_freq_hz; } + private: double m_sr{48000.0}; double m_attack_s{k_default_attack_s}; double m_decay_s{k_default_decay_s}; double m_pan_coeff{1.0}; + double m_freq_hz{0.0}; double m_gain_l{0.0}; double m_gain_r{0.0}; double m_gain_l_target{0.0}; @@ -460,6 +481,20 @@ namespace tap::tools { } } + /// Per-voice mono taps: one sample per bell, RAW — before each bell's seat is + /// applied — written into `out`, which must hold at least `count` doubles (extra + /// entries beyond k_voices are zeroed). This is the same advance as process(); a + /// caller takes one or the other on a given sample, never both. + /// + /// Summing these back through voice_gain_left/right reproduces process() exactly, + /// which is what the pinned scenario checks. The point of taking them apart is that + /// you do not have to: place, filter, or gate each tube yourself. + void process_voices(double* out, int count) { + for (int i = 0; i < count; ++i) { + out[static_cast(i)] = (i < k_voices) ? m_bells[static_cast(i)].process_mono() : 0.0; + } + } + int active_voices() const { int n = 0; for (const auto& v : m_bells) { @@ -468,6 +503,17 @@ namespace tap::tools { return n; } + /// Which tube a given voice is currently holding, and how loudly — the pool + /// reassigns bells as it steals, so voice i is whatever was last put there. + double voice_hz(int i) const { return valid_voice(i) ? m_bells[static_cast(i)].frequency() : 0.0; } + double voice_level(int i) const { return valid_voice(i) ? m_bells[static_cast(i)].level() : 0.0; } + double voice_gain_left(int i) const { + return valid_voice(i) ? m_bells[static_cast(i)].gain_left() : 0.0; + } + double voice_gain_right(int i) const { + return valid_voice(i) ? m_bells[static_cast(i)].gain_right() : 0.0; + } + double attack_s() const { return m_attack_s; } double decay_s() const { return m_decay_s; } int material() const { return m_material; } @@ -475,6 +521,8 @@ namespace tap::tools { double samplerate() const { return m_sr; } private: + static bool valid_voice(int i) { return i >= 0 && i < k_voices; } + double m_sr{48000.0}; double m_attack_s{k_default_attack_s}; double m_decay_s{k_default_decay_s}; diff --git a/notebooks/taptools_py.py b/notebooks/taptools_py.py index 630c800..acd5a55 100644 --- a/notebooks/taptools_py.py +++ b/notebooks/taptools_py.py @@ -373,6 +373,12 @@ def load() -> ctypes.CDLL: "taptools_gardener_tick": ([vp, ctypes.c_int, f64p, f64p], ctypes.c_int), "taptools_scale_quantize": ([ctypes.c_double, ctypes.c_int, ctypes.c_int], ctypes.c_double), + "taptools_chime_process_voices": ([vp, f64p, ctypes.c_int, ctypes.c_int], ctypes.c_int), + "taptools_chime_voice_hz": ([vp, ctypes.c_int], ctypes.c_double), + "taptools_chime_voice_level": ([vp, ctypes.c_int], ctypes.c_double), + "taptools_chime_voice_gain_left": ([vp, ctypes.c_int], ctypes.c_double), + "taptools_chime_voice_gain_right": ([vp, ctypes.c_int], ctypes.c_double), + "taptools_composite_period_seconds": ([f64p, ctypes.c_int, ctypes.c_double], ctypes.c_double), "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), @@ -1505,6 +1511,31 @@ def process(self, n: int): _check(_LIB.taptools_chime_process(self._h, _p64(out_l), _p64(out_r), out_l.size), "process") return out_l, out_r + VOICES = 16 + + def process_voices(self, n: int, voices: int = 16): + """Render n samples of each voice, RAW — before each bell's seat is + applied. Returns a (voices, n) array. This is the same advance as + process(); take one or the other for a given span, never both.""" + out = np.zeros((int(voices), int(n))) + _check(_LIB.taptools_chime_process_voices(self._h, _p64(out), int(voices), int(n)), + "process_voices") + return out + + def voice_hz(self, voice: int) -> float: + """Which tube this voice is holding, in Hz (0 if never struck). The pool + reassigns bells as it steals, so voice i is whatever was last put there.""" + return float(_LIB.taptools_chime_voice_hz(self._h, int(voice))) + + def voice_level(self, voice: int) -> float: + return float(_LIB.taptools_chime_voice_level(self._h, int(voice))) + + def voice_gains(self, voice: int): + """The seat gains process() would multiply this voice's mono sum by — + enough to rebuild the stereo rack from the per-voice taps.""" + return (float(_LIB.taptools_chime_voice_gain_left(self._h, int(voice))), + float(_LIB.taptools_chime_voice_gain_right(self._h, int(voice)))) + def clear(self) -> None: _check(_LIB.taptools_chime_clear(self._h), "clear") @@ -1628,6 +1659,17 @@ def __del__(self): self._h = None +def composite_period_seconds(loop_seconds, sr: float = 48000.0) -> float: + """How long until a set of free-running loops realigns, in seconds — the lcm + of their lengths once each is quantized to the sample grid exactly as a reel + would quantize it. Returns inf once the lcm leaves the 64-bit range, which + incommensurate lengths reach fast; that is the point, not a failure. This is + what `tap.period` wraps, and what `Airport.composite_period_seconds` reports + for a bank.""" + x = _f64(np.asarray(loop_seconds, dtype=float).ravel()) + return float(_LIB.taptools_composite_period_seconds(_p64(x), x.size, float(sr))) + + def scale_quantize(pitch, root: int = 0, scale: int = 3): """tap.garden~'s entry quantizer (tap::tools::garden::scale_quantizer): snap MIDI semitones to the nearest pitch in root/scale. `scale` indexes diff --git a/tests/airport_test.cpp b/tests/airport_test.cpp index 35d4a18..2855e43 100644 --- a/tests/airport_test.cpp +++ b/tests/airport_test.cpp @@ -352,3 +352,28 @@ SCENARIO("unprepared, a lone lane is silent and leaves the busses alone") { REQUIRE(r == -1.0); REQUIRE(ln.phase() == 0.0); } + +SCENARIO("the composite period is the same arithmetic whether a bank asks it or a patch does") { + // tap.period exists because a patch of independent tap.reel~ has no bank to ask when the + // whole system realigns. It must be the SAME arithmetic, so the free function is pinned on + // the cases the bank scenario already establishes. + const long pair[2] = {24000, 30000}; // gcd 6000 -> lcm 120000 samples = 2.5 s at 48k + REQUIRE(tap::tools::airport::composite_period_seconds(pair, 2, k_sr) == 2.5); + + // And it agrees with a bank configured to those same lengths, lane for lane. + loop_bank b = make(); + b.set_loops(2); + b.set_length_seconds(0, 0.5); + b.set_length_seconds(1, 0.625); + const long from_lanes[2] = {b.lane(0).loop_samples(), b.lane(1).loop_samples()}; + REQUIRE(tap::tools::airport::composite_period_seconds(from_lanes, 2, k_sr) == b.composite_period_seconds()); + + // Incommensurate lengths leave the 64-bit range, and that is the point of the piece. + const long awkward[7] = {854401, 916801, 1022401, 1147201, 1257601, 1377601, 1483201}; + REQUIRE(std::isinf(tap::tools::airport::composite_period_seconds(awkward, 7, k_sr))); + + // Degenerate input answers zero rather than dividing by something. + REQUIRE(tap::tools::airport::composite_period_seconds(pair, 0, k_sr) == 0.0); + REQUIRE(tap::tools::airport::composite_period_seconds(nullptr, 2, k_sr) == 0.0); + REQUIRE(tap::tools::airport::composite_period_seconds(pair, 2, 0.0) == 0.0); +} diff --git a/tests/garden_test.cpp b/tests/garden_test.cpp index 95e4ce5..c165e4a 100644 --- a/tests/garden_test.cpp +++ b/tests/garden_test.cpp @@ -820,3 +820,73 @@ SCENARIO("the gardener touches its rng only while idling") { REQUIRE(other_ever_differs); REQUIRE(plants > 0); // the wind really did blow } + +SCENARIO("the per-voice taps summed through their seats are the stereo rack") { + // tap.chime.voices~ hands you the sixteen bells raw, before their seats are applied. The + // claim that costs nothing to make and something to check: put them back through the seats + // and you have tap.chime~ again, to the bit. Two racks driven identically, one asked for + // its stereo pair and one for its voices. + rack stereo; + rack split; + stereo.prepare(k_sr); + split.prepare(k_sr); + for (rack* r : {&stereo, &split}) { + r->set_times(0.002, 3.0); + r->set_material(material_chime); + r->set_spread(0.8); // a wide rack, so the seats are doing real work + } + + std::array taps{}; + bool exact = true; + double pk = 0.0; + const size_t gap = at(0.02); + for (int strike = 0; strike < 20; ++strike) { // past sixteen, so stealing is under test too + const double pitch = 52.0 + 2.7 * static_cast(strike); + const double vel = 0.3 + 0.03 * static_cast(strike % 8); + stereo.strike(pitch, vel, 0.9); + split.strike(pitch, vel, 0.9); + + for (size_t i = 0; i < gap; ++i) { + double ls = 0.0, rs = 0.0; + stereo.process(ls, rs); + + split.process_voices(taps.data(), k_voices); + double lv = 0.0, rv = 0.0; + for (int v = 0; v < k_voices; ++v) { + lv += taps[static_cast(v)] * split.voice_gain_left(v); + rv += taps[static_cast(v)] * split.voice_gain_right(v); + } + + exact = exact && (lv == ls) && (rv == rs); + pk = std::max(pk, std::abs(ls)); + } + } + REQUIRE(exact); + REQUIRE(pk > 0.05); // and the two agreed about a rack, not about silence +} + +SCENARIO("a voice reports which tube it is holding") { + rack rk; + rk.prepare(k_sr); + rk.set_times(0.002, 4.0); + rk.set_spread(0.0); + + REQUIRE(rk.voice_hz(0) == 0.0); // nothing struck yet + rk.strike(69.0, 0.9, 1.0); // A440 lands on the first idle bell + CHECK(std::abs(rk.voice_hz(0) - 440.0) < 1e-9); + + // Out-of-range indices answer zero rather than reading past the pool. + CHECK(rk.voice_hz(-1) == 0.0); + CHECK(rk.voice_hz(k_voices) == 0.0); + CHECK(rk.voice_level(k_voices) == 0.0); + + // process_voices zeroes anything asked for beyond the pool rather than leaving it stale. + std::array taps{}; + for (auto& t : taps) { + t = 1.0; + } + rk.process_voices(taps.data(), k_voices + 4); + for (int i = k_voices; i < k_voices + 4; ++i) { + CHECK(taps[static_cast(i)] == 0.0); + } +} diff --git a/tools/capi/taptools_capi.cpp b/tools/capi/taptools_capi.cpp index 13389c1..4bbfbf2 100644 --- a/tools/capi/taptools_capi.cpp +++ b/tools/capi/taptools_capi.cpp @@ -7,6 +7,7 @@ #include #include +#include // The DSP cores are the same headers the Max externals compile — no Max/Min dependency. #include @@ -1589,4 +1590,63 @@ double taptools_scale_quantize(double pitch, int root, int scale) { return q.quantize(pitch); } +// ---- per-voice taps ------------------------------------------------------------------------------ + +int taptools_chime_process_voices(taptools_chime h, double* out, int voices, int n) { + if (!out || voices < 1 || n < 0) { + return -1; + } + return with(h, [&](garden_rack& r) { + std::vector frame(static_cast(voices), 0.0); + for (int i = 0; i < n; ++i) { + r.process_voices(frame.data(), voices); + for (int v = 0; v < voices; ++v) { // voice-major, so each voice is a contiguous block + out[static_cast(v) * static_cast(n) + static_cast(i)] = + frame[static_cast(v)]; + } + } + }); +} + +double taptools_chime_voice_hz(taptools_chime h, int voice) { + if (!h) { + return -1.0; + } + return static_cast(h)->voice_hz(voice); +} + +double taptools_chime_voice_level(taptools_chime h, int voice) { + if (!h) { + return -1.0; + } + return static_cast(h)->voice_level(voice); +} + +double taptools_chime_voice_gain_left(taptools_chime h, int voice) { + if (!h) { + return -1.0; + } + return static_cast(h)->voice_gain_left(voice); +} + +double taptools_chime_voice_gain_right(taptools_chime h, int voice) { + if (!h) { + return -1.0; + } + return static_cast(h)->voice_gain_right(voice); +} + +// ---- tap.period ---------------------------------------------------------------------------------- + +double taptools_composite_period_seconds(const double* loop_seconds, int count, double sr) { + if (!loop_seconds || count < 1 || sr <= 0.0) { + return 0.0; + } + std::vector samples(static_cast(count)); + for (int i = 0; i < count; ++i) { + samples[static_cast(i)] = tap::tools::airport::loop_samples_for(loop_seconds[i], sr); + } + return tap::tools::airport::composite_period_seconds(samples.data(), count, sr); +} + } // extern "C" diff --git a/tools/capi/taptools_capi.h b/tools/capi/taptools_capi.h index 045a3c3..0ee4b5a 100644 --- a/tools/capi/taptools_capi.h +++ b/tools/capi/taptools_capi.h @@ -520,6 +520,29 @@ TAPTOOLS_API int taptools_gardener_tick(taptools_gardener h, int loop_samples, d /// Stateless enough to need no handle: snap MIDI semitones to the nearest pitch in root/scale. TAPTOOLS_API double taptools_scale_quantize(double pitch, int root, int scale); +// ---- per-voice taps (tap.chime.voices~) ---------------------------------------------------------- + +/// Render n samples of each of the rack's voices, RAW — before each bell's seat is applied. +/// `out` is voice-major and must hold voices*n doubles: voice v's block starts at out[v*n]. +/// `voices` beyond the rack's pool are filled with silence. This is the same advance as +/// taptools_chime_process; take one or the other for a given span, never both. +TAPTOOLS_API int taptools_chime_process_voices(taptools_chime h, double* out, int voices, int n); + +/// Which tube a voice is holding (Hz, 0 if never struck), how loudly, and the seat gains that +/// taptools_chime_process would multiply its mono sum by. +TAPTOOLS_API double taptools_chime_voice_hz(taptools_chime h, int voice); +TAPTOOLS_API double taptools_chime_voice_level(taptools_chime h, int voice); +TAPTOOLS_API double taptools_chime_voice_gain_left(taptools_chime h, int voice); +TAPTOOLS_API double taptools_chime_voice_gain_right(taptools_chime h, int voice); + +// ---- tap.period (tap::tools::airport::composite_period_seconds) ---------------------------------- + +/// How long until a set of free-running loops realigns, in seconds — the lcm of their lengths +/// once each is quantized to the sample grid exactly as a reel would quantize it. Returns +inf +/// when the lcm leaves the 64-bit range (which incommensurate lengths reach fast, and which is +/// the point of the piece), and 0 on degenerate input. +TAPTOOLS_API double taptools_composite_period_seconds(const double* loop_seconds, int count, double sr); + #ifdef __cplusplus } #endif From fac61d3d25e1c973df8f3d45791381e240069a86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 01:50:07 +0000 Subject: [PATCH 6/6] Fold the two follow-ups into the components chapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chapter shipped naming two gaps: no way to reach a single bell, and composite_period with nowhere to live in a patch of reels. Both are closed, so the chapter should stop describing a decomposition with a known hole. tap.chime.voices~ goes in the "what you get for patching it" section, with the argument that earns it — filtering one voice is a different instrument from filtering the rack — and with the two wrapper constraints stated so nobody re-derives them: outlet count is fixed at construction, and min-api's mc is inlet-side only, so a variable-channel mc outlet is not available to a Min external today. That is a limitation of the wrapper we have, not of the idea, and the chapter says so. The paragraph that named composite_period as a loss now names tap.period instead, and keeps the detail that makes it trustworthy: it shares the reel's seconds-to-samples quantization rather than copying it, because the lcm is over sample counts and lengths that look commensurate written down are usually not. All three new citations checked against the test sources. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe --- book/PLAN-eno-components.md | 28 ++++++++++++++++++++----- book/src/components.md | 42 ++++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/book/PLAN-eno-components.md b/book/PLAN-eno-components.md index 96695b0..6487606 100644 --- a/book/PLAN-eno-components.md +++ b/book/PLAN-eno-components.md @@ -79,13 +79,31 @@ evidence is that every pre-existing scenario passes unchanged, plus the null tes voice. Pre-existing, surfaced by the new rack test, documented rather than fixed — fixing it would change the sound. +## The two follow-ups (shipped after the first draft) + +Both were flagged as open in the first pass and then closed, so the chapter now reads as a +finished decomposition rather than one with a known hole. + +- **`tap.chime.voices~`** — the rack with each bell on its own outlet, dry. Wanted because + filtering one voice is a different instrument from filtering the rack. Kernel side this + is `bell::process_mono` factored out with `process` rewritten on top of it (one + oscillator path, bit-identical), plus `rack::process_voices` and the per-slot reporting + that makes the taps usable at all — the pool reassigns bells as it steals, so a slot is + not a pitch. *Evidence: "the per-voice taps summed through their seats are the stereo + rack" (20 strikes, four past the pool size), "a voice reports which tube it is holding".* + Two wrapper constraints are recorded in the chapter because they will otherwise be + re-derived: outlet count is fixed at construction (hence a separate object), and + min-api's `mc` is inlet-side only — `Z_MC_INLETS`, no `multichanneloutputs` — so a + variable-channel `mc` outlet is not available to a Min external today. +- **`tap.period`** — the composite period as its own object. `composite_period_seconds` + came out of `loop_bank` as a free function the bank now calls, and `loop_samples_for` + shares the reel's quantization rather than copying it. That sharing is the point worth + writing down: the lcm is over sample counts, so lengths that look commensurate as + decimals are not as samples. *Evidence: "the composite period is the same arithmetic + whether a bank asks it or a patch does".* + ## Deliberately left out -- `composite_period` has nowhere to live in a patch of independent reels (it needs all the - lengths at once). `tap.reel~` reports `loopsamples`, which is the raw material; a - `tap.period` utility is the obvious follow-up and is flagged in `REVIVAL.md` rather than - quietly dropped. The chapter names the loss instead of pretending the decomposition is - free. - No new notebook sections. The claims here are structural (bitwise identity, exact arithmetic), which pinned tests carry better than measured cells. diff --git a/book/src/components.md b/book/src/components.md index 3427378..51dfc8d 100644 --- a/book/src/components.md +++ b/book/src/components.md @@ -19,9 +19,11 @@ block diagrams at the top of the last two chapters are patchable: |---|---|---| | `tap.reel~` | one free-running tape loop | a lane of `tap.airport~` | | `tap.chime~` | the sixteen-bell wind-chime rack | the voice pool of `tap.garden~` | +| `tap.chime.voices~` | the same rack, one bell per outlet | — | | `tap.bloom` | the event ring — plant, return, fade, retire | the recirculation of `tap.garden~` | | `tap.scale` | snap a pitch to a root and scale | the entry quantizer | | `tap.gardener` | the idle wind, seeded | the self-seeding half | +| `tap.period` | when a set of loops realigns | the bank's `period` message | The monoliths remain exactly what they were. This is additive: the same kernel classes, reached two ways. @@ -66,6 +68,25 @@ For the airport, four things the monolith cannot give you: DSP start whether you use them or not: about 92 MB of double tape at the 30-second default. Three `tap.reel~` buy three, about 11 MB each. +The rack has a second form worth knowing about. `tap.chime.voices~` is the +same sixteen bells with each one on its own outlet, carrying its tube dry — +before the seat in the stereo image. Filter one voice and you are filtering +whichever bell happens to be in that slot, not the rack; it is a different +instrument, and there is no way to ask `tap.chime~` for it. Because the pool +reassigns bells as it steals, a slot is not a pitch, so the object will tell +you which tube it is holding and what seat it would have been given. Sum the +sixteen back through those seats and you have `tap.chime~` again, bitwise — +pinned by *"the per-voice taps summed through their seats are the stereo +rack"*, across twenty strikes, four more than the pool holds, so stealing is +under test too. + +It is a separate object rather than a switch because outlet count is fixed +when a Min object is built, and it is sixteen discrete outlets rather than one +multichannel outlet because min-api's `mc` support is inlet-side only: it sets +`Z_MC_INLETS` and offers no `multichanneloutputs`, which is what Max requires +before an external may declare a variable-channel `mc` outlet. That is a +limitation of the wrapper we have, not of the idea. + For the garden, the interesting one is `tap.bloom`. Separated from the chime it turns out to be the most portable idea in the family, because it recirculates *notes* and has no opinion about what sounds them. Point it @@ -113,16 +134,23 @@ bed this only happens when two blooms share a loop position; it is pre-existing behaviour, and it is documented in the rack scenario rather than fixed, because fixing it would change how the object sounds. -One thing the airport decomposition genuinely loses: `composite_period`, -the report of when the whole system realigns, needs every length at once -and so has nowhere to live in a patch of independent reels. `tap.reel~` -answers `loopsamples` with its own length in samples, which is the raw -material for that arithmetic, but the lcm itself is still the monolith's. +The one thing the airport decomposition looked like it would lose is +`composite_period` — the report of when the whole system realigns, which +needs every length at once and so has nowhere to live inside a single reel. +That arithmetic came out of the bank as a free function instead, and +`tap.period` is it: hand it the lengths and it answers in seconds, `inf` +included. The detail that makes it trustworthy rather than merely +convenient is that it shares the reel's seconds-to-samples quantization +rather than copying it. The lcm is over *sample counts*, and lengths that +look commensurate written down are usually nothing of the kind once +rounded to samples — 0.5 and 0.625 seconds realign at 2.5 s, while the +terminal recipe's seven lengths leave the 64-bit range entirely. Both are +pinned in `tests/airport_test.cpp`. ## Checkpoint -Five objects, no new DSP: the same kernel classes the monoliths hold, given -names and inlets. Three `tap.reel~` summed are a `tap.airport~` bitwise; +Seven objects, almost no new DSP: the same kernel classes the monoliths +hold, given names and inlets. Three `tap.reel~` summed are a `tap.airport~` bitwise; `tap.gardener` into `tap.scale` into `tap.bloom` into `tap.chime~` is a `tap.garden~` bitwise, gardener and all. Both identities are pinned scenarios in `tests/airport_test.cpp` and `tests/garden_test.cpp`, which