The Eno family, and the components it is made of - #32
Merged
Conversation
`tap.airport~` was a monolith by accident, not by design: `loop_bank` already held eight `loop_state`s and summed them, but nothing outside the bank could reach one. Promote that struct to a real class, `airport::loop` — one free-running reel with its own head, tape, shade, level, pan, and record gate — and let the bank be an array of them plus the count, the shared smoothing time, and the lcm arithmetic. Nothing about the sound changes. The per-sample operation order is preserved exactly, and `loop::process` accumulates onto the stereo busses (the garden.h bell idiom) so the bank sums lanes without a scratch buffer. A 3-second render of five lanes through splices, staggered punch-ins, hard pans, shaded and bypassed darken, and live ramps hashes bit-for-bit identical before and after. The point is that a lane is now usable alone — it is what `tap.reel~` will wrap — and that seven of them patched into a sum ARE the bank. That identity is pinned in CI rather than asserted: the new scenario configures a three-lane bank and three standalone lanes identically, drives both through the same punch schedule, and requires bitwise-equal stereo output across two seconds (a 1e-12 level nudge on one lane fails it). Two more scenarios carry the phase discipline down to a lone lane — the same setter storm, the same sacred head — and pin that an unprepared lane adds nothing to the busses it is handed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
`bed` was five machines in a trenchcoat: a scale quantizer, an event ring, a chime rack with its voice allocator, and a seeded gardener. Give each one a name and let `bed` be the wiring. The rack is the interesting one. Voice stealing had to come out as kernel code rather than being handed to the host: Max's poly~ steals round-robin and does not exist off Max, so delegating it would have cost both the glide-not-click promise and every non-Max target. `rack` therefore owns the pool and the quietest-first allocator, and `tap.chime~` will be the whole rack rather than a mono voice you instantiate sixteen times. `ring` is the piece with the most reach: it recirculates events and knows nothing about chimes, so it will drive whatever you point it at. `gardener` keeps the rng-consumption discipline the seed triad depends on, and emits raw pitches for the caller to quantize — the scale is the bed's field, not the wind's. Nothing about the sound changes. Two 12- and 30-second renders — one played by hand with the gardener disabled, one left entirely to the seeded wind — hash bit-for-bit identical before and after, and the idle render is the strict one: any change in rng consumption order would move it immediately. New scenarios pin what the split now makes reachable. The null test wires the four components by hand and requires bitwise-equal stereo against the bed over twenty seconds with the gardener running. The ring's convergence theorem is now countable with no envelope tail or detector threshold in the way, so four (velocity, decay, floor) triples are checked against ceil(log(f/v)/log(d)) exactly. The rack is shown taking the quietest bell and leaving a loud one alone, compared as retention ratios because a steal glides rather than cuts. The gardener is shown not drawing from its stream while idling is disabled. One thing the rack scenario surfaced, pre-existing and left alone: a bell's envelope reads zero until it has been processed once, so the allocator cannot distinguish a just-struck bell from an idle one. Strikes issued in the same sample therefore collide onto one voice. The bed only meets this when two blooms share a loop position; it is documented in the test rather than fixed, because fixing it would change the sound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
The C ABI and the ctypes bridge only knew about the monoliths, so nothing outside C++ could check the claim the split rests on. Add entry points for the tape lane (tap.reel~), the chime rack (tap.chime~), the event ring (tap.bloom), the idle wind (tap.gardener), and the entry quantizer (tap.scale), plus the Python classes that wrap them. Two shapes worth noting. `taptools_bloom_due` writes into caller arrays and returns a count, because the ring reports a variable number of strikes per sample and a C ABI should not hand back a view into kernel scratch. `taptools_gardener_tick` returns 1/0 for "the wind wants a strike" and writes the RAW pitch — quantizing is the caller's job, which is the same seam the kernel draws. Checked end to end against the built library: three Reels summed match an Airport bitwise (max |diff| 0.0 on both busses, heads in step), a Bloom plant at velocity 0.9 under decay 0.5 and floor 0.05 fires exactly the five strikes the theorem predicts and leaves no live events, the gardener plants about once per pass while idling and never once idling is disabled, and the quantizer snaps off-scale pitches to the nearest degree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
*The same machine, in pieces* — a Part IV chapter covering the decomposition, plus the drafting record and a short section in each of the airport and garden chapters pointing at it. The spine is the reading that made the split worth doing: the monoliths were monoliths by accident, not design. tape_loop.h was already a component library, airport.h already held an array of identical lanes and a summing loop, and the package already ships this way everywhere else — there is no tap.808~, there are eight voices and a sequencer. So the chapter frames this as promoting seams the code already had, and says in its first three sentences that the monoliths are staying. Every claim traces to a pinned scenario; all seven cited names were checked against the test sources. No notebook cells were added, because nothing the notebooks measure changed — the new claims are structural (bitwise identity, exact arithmetic), which tests carry better than measured cells. The section that earns the chapter is the one on where the seams show: the garden's patch is not sample-accurate and there is deliberately no in-Max null test for it; voice stealing had to stay in the kernel for two separate reasons; and a bell reads silent until processed once, so same-sample strikes collide. The chapter also names what the decomposition loses — composite_period has nowhere to live in a patch of independent reels — rather than pretending it is free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
Two additions the decomposition asked for once the parts were objects. The rack can now be read one bell at a time. `bell::process_mono` returns the raw mono sum and `process` is written in terms of it, so there is still one oscillator path and the seat still glides on both — the refactor is bit-identical, and the fingerprint renders confirm it. `rack::process_voices` hands back k_voices dry taps, and voice_hz/voice_level/voice_gain_* say which tube each slot is holding and what seat it would have been given, which is what makes the taps usable: the pool reassigns bells as it steals, so a slot is not a pitch. New scenario: the taps put back through their seats are the stereo rack, bitwise, across twenty strikes — four more than the pool holds, so stealing is under test too. `composite_period_seconds` comes out of `loop_bank` as a free function over a set of lengths, because a patch of independent reels has no bank to ask. The bank now calls it rather than keeping its own gcd. Alongside it, `loop_samples_for` — the seconds-to-samples quantization a reel applies — is shared rather than copied, so `tap.period` and the reels it is asked about cannot drift apart. That mattered more than it looks: the lcm is over sample counts, and lengths that look commensurate as decimals are not as samples. Both reachable from the verification layer. Checked through the built library: the per-voice taps rebuild the stereo pair exactly, and the lcm of 0.5 and 0.625 is 2.5 s while the book's seven airport-scale lengths report inf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
The chapter shipped naming two gaps: no way to reach a single bell, and composite_period with nowhere to live in a patch of reels. Both are closed, so the chapter should stop describing a decomposition with a known hole. tap.chime.voices~ goes in the "what you get for patching it" section, with the argument that earns it — filtering one voice is a different instrument from filtering the rack — and with the two wrapper constraints stated so nobody re-derives them: outlet count is fixed at construction, and min-api's mc is inlet-side only, so a variable-channel mc outlet is not available to a Min external today. That is a limitation of the wrapper we have, not of the idea, and the chapter says so. The paragraph that named composite_period as a loss now names tap.period instead, and keeps the detail that makes it trustworthy: it shares the reel's seconds-to-samples quantization rather than copying it, because the lcm is over sample counts and lengths that look commensurate written down are usually not. All three new citations checked against the test sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
tap
pushed a commit
to tap/TapTools-Max
that referenced
this pull request
Aug 15, 2026
tap/TapTools#32 merged by rebase, so the tree this was pinned at (fac61d3) now lives on main as d4cf28a — identical tree, verified by comparing the tree hashes, so nothing here is affected. Repointing before the topic branch is deleted keeps the pin reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
tap
pushed a commit
to tap/TapTools-Max
that referenced
this pull request
Aug 15, 2026
tap/TapTools#32 merged by rebase, so the tree this was pinned at (fac61d3) now lives on main as d4cf28a — identical tree, verified by comparing the tree hashes, so nothing here is affected. Repointing before the topic branch is deleted keeps the pin reachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018s67n9Z2ENnhQaFFWJKaVe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Two things, because the branch carries both: the Eno family kernels (
tape_loop.h,discreet.h,airport.h,garden.h) with their tests, notebooks, render tool and book chapters — none of which had reachedmainyet — and then the decomposition of the last two into components you can patch.airport::loopcomes out ofloop_bank;garden::rack,ring,gardenerandscale_quantizercome out ofgarden::bed. Both monoliths become composition and nothing else. The monoliths keep their behaviour exactly; this is additive.Why
tap.airport~andtap.garden~were monoliths by accident rather than by design.tape_loop.hwas already a component library,airport.halready held an array of identical lanes and a summing loop, andgarden.h'sbellwas already standalone withbedwiring four machines together. The parts existed; only the monolith could reach them. The package also already ships this way everywhere else — there is notap.808~, there are eight voices and a sequencer — so the Eno objects were the exception rather than the norm.Promoting those seams gets you an insert on one tape loop instead of all eight, a chime rack you can strike from anything, and — the piece with the most reach — an event ring that recirculates notes and has no opinion about what sounds them.
Verification
ctestgreen here, and the extractions are behaviour-preserving rather than merely believed to be:airport_test.cpp— "standalone lanes summed are the bank, bitwise" (3 lanes, 2 s, staggered punch schedule, both exact pan endpoints, one shaded darken corner and one bypassed).garden_test.cpp— "the bed is exactly its components wired together, bitwise" (20 s with the seeded gardener running, so the order of random draws is under test too). Both are bitwise, not tolerant. A 1e-12 level nudge on one lane fails the airport one — I mutation-checked it, so it is not vacuous.ceil(log(f/v)/log(d))); "the composite period is the same arithmetic whether a bank asks it or a patch does" (0.5 s and 0.625 s realign at 2.5 s; seven airport-scale lengths leave the 64-bit range).Reelsummed match anAirportbitwise on both busses, and the per-voice taps rebuild the stereo pair exactly.Not done: nothing here has been opened in Max. The Max-side gate lives in the consumer repo and is an on-Mac pass.
Notes for the reviewer
tap/TapTools-Maxhas a matching PR whosesubmodules/taptoolspin points atfac61d3on this branch. If this merges by rebase or squash, that pin needs repointing at the identical tree onmainbefore it is merged, or it goes unreachable when this branch is deleted.decay_env::trigger()only sets a target, sovalue()reads zero untilprocess()runs — the rack's allocator therefore cannot tell a just-struck bell from an idle one, and strikes issued in the same sample collide onto one voice. Insidebedthis only happens when two blooms share a loop position. It is documented in the rack scenario rather than fixed, because fixing it would change the sound and break the bit-identity this PR rests on. Worth a decision, but a separate one.Generated by Claude Code