diff --git a/book/PLAN-eno-components.md b/book/PLAN-eno-components.md new file mode 100644 index 0000000..6487606 --- /dev/null +++ b/book/PLAN-eno-components.md @@ -0,0 +1,117 @@ +# 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. + +## 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 + +- 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..51dfc8d --- /dev/null +++ b/book/src/components.md @@ -0,0 +1,161 @@ +# 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.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. + +## "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. + +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 +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. + +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 + +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 +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; diff --git a/include/taptools/airport.h b/include/taptools/airport.h index cb8dc39..90dda1c 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,229 @@ 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 { + /// 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 + /// 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 loop_samples_for(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 +301,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 @@ -200,21 +375,17 @@ 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(m_loops[static_cast(i)].tape.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 --------------------------------------------------------------------------- - /// 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 +393,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,34 +405,13 @@ 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; - a = b; - b = t; - } - 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/include/taptools/garden.h b/include/taptools/garden.h index 1fd35b5..e8c6e0f 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). @@ -270,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 = @@ -298,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]; @@ -311,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}; @@ -331,68 +370,198 @@ 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); + } + } + + /// 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) { + n += (v.level() > k_gain_epsilon) ? 1 : 0; + } + 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; } + double spread() const { return m_spread; } + 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}; + 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 +574,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 +583,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); } + /// 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); } - /// 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); } + /// 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); } - // -- introspection ------------------------------------------------------------------- - - 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 +655,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 +669,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 +691,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. A caller - /// plant closes the idle gate mid-gust; the gust resumes if the garden idles again. - void tend() { + /// 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}; + }; + + 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 +777,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/notebooks/taptools_py.py b/notebooks/taptools_py.py index 29f1f60..acd5a55 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,65 @@ 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_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), @@ -1338,6 +1401,286 @@ 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 + + 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") + + 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 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 + 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/tests/airport_test.cpp b/tests/airport_test.cpp index f84e803..2855e43 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,134 @@ 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); +} + +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 28d7330..c165e4a 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,302 @@ 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 +} + +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 e84c48c..4bbfbf2 100644 --- a/tools/capi/taptools_capi.cpp +++ b/tools/capi/taptools_capi.cpp @@ -5,6 +5,10 @@ #include "taptools_capi.h" +#include +#include +#include + // The DSP cores are the same headers the Max externals compile — no Max/Min dependency. #include #include @@ -1313,4 +1317,336 @@ 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); +} + +// ---- 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 47054e0..0ee4b5a 100644 --- a/tools/capi/taptools_capi.h +++ b/tools/capi/taptools_capi.h @@ -420,6 +420,129 @@ 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); + +// ---- 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