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:

ObjectWhat it isWas
tap.reel~one free-running tape loopa lane of tap.airport~
tap.chime~the sixteen-bell wind-chime rackthe voice pool of tap.garden~
tap.chime.voices~the same rack, one bell per outlet—
tap.bloomthe event ring — plant, return, fade, retirethe recirculation of tap.garden~
tap.scalesnap a pitch to a root and scalethe entry quantizer
tap.gardenerthe idle wind, seededthe self-seeding half
tap.periodwhen a set of loops realignsthe 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.