Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions book/PLAN-eno-components.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions book/src/airport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions book/src/components.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions book/src/garden.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading