Skip to content

The Eno family, and the components it is made of - #32

Merged
tap merged 6 commits into
mainfrom
claude/airports-garden-components-ga28kb
Aug 15, 2026
Merged

The Eno family, and the components it is made of#32
tap merged 6 commits into
mainfrom
claude/airports-garden-components-ga28kb

Conversation

@tap

@tap tap commented Aug 15, 2026

Copy link
Copy Markdown
Owner

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 reached main yet — and then the decomposition of the last two into components you can patch.

airport::loop comes out of loop_bank; garden::rack, ring, gardener and scale_quantizer come out of garden::bed. Both monoliths become composition and nothing else. The monoliths keep their behaviour exactly; this is additive.

Why

tap.airport~ and tap.garden~ were monoliths by accident rather than by design. tape_loop.h was already a component library, airport.h already held an array of identical lanes and a summing loop, and garden.h's bell was already standalone with bed wiring four machines together. The parts existed; only the monolith could reach them. The package also already ships this way everywhere else — there is no tap.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

ctest green here, and the extractions are behaviour-preserving rather than merely believed to be:

  • The monoliths are unchanged. Every scenario that pinned them before the split passes unchanged after it. During development, throwaway fingerprint harnesses (FNV-1a over every output sample of multi-second renders through splices, punch-ins, mode changes and the seeded gardener) hashed identical before and after; those harnesses are not committed, so the committed evidence is the unchanged suite plus the null tests below.
  • The components are the monoliths. 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.
  • The per-voice taps rebuild the rack. "the per-voice taps summed through their seats are the stereo rack" — 20 strikes, four past the pool size, so stealing is exercised.
  • New arithmetic pinned exactly. "the ring's convergence theorem is exact when nothing sounds it" (four velocity/decay/floor triples against 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).
  • The C ABI carries it too. Checked against the built library: three Reel summed match an Airport bitwise 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

  • Notebooks were NOT re-executed, deliberately. Behaviour did not change, so the committed executed notebooks still stand. The new claims are structural — bitwise identity, exact arithmetic — which pinned tests carry better than measured cells. The notebooks can now reach the components through new C ABI entry points if you want to look at them there.
  • Consumer pin. tap/TapTools-Max has a matching PR whose submodules/taptools pin points at fac61d3 on this branch. If this merges by rebase or squash, that pin needs repointing at the identical tree on main before it is merged, or it goes unreachable when this branch is deleted.
  • A pre-existing quirk surfaced and left alone. decay_env::trigger() only sets a target, so value() reads zero until process() 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. Inside bed this 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.
  • Scope. The first fourteen commits are the Eno family itself, which was never PR'd; the last six are the decomposition. Reviewing by commit is probably kinder than reviewing the diff.

Generated by Claude Code

claude added 6 commits August 15, 2026 00:22
`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
tap merged commit d4cf28a into main Aug 15, 2026
12 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants