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
48 changes: 48 additions & 0 deletions REVIVAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,54 @@ identical tree, so the wrappers are byte-for-byte unaffected). Still
open: the on-Mac validation pass (open the three help/maxtest patchers once in Max), and the
in-Max audition.

**17. The Eno family, opened up (2026-08-15).** ✅ `tap.airport~` and `tap.garden~` were monoliths
by accident rather than design — the kernel already held the parts, but nothing outside the
monolith could reach one. Both were split kernel-side into components that are now objects, so the
block diagram is patchable: `tap.reel~` (one free-running tape loop — sum several at awkward
lengths and you have the airport, with every loop reachable for an insert, a varispeed, or a
different source), and `tap.chime~` / `tap.bloom` / `tap.scale` / `tap.gardener` (the rack, the
event ring, the entry quantizer, and the idle wind — chained, they are the garden). The monoliths
stay: they are the put-it-on-and-walk-away objects, and the split is additive.

The extractions are behavior-preserving and shown to be: multi-second renders of both kernels
through splices, punch-ins, mode changes and the seeded gardener hash bit-for-bit identical before
and after, and the kernel suite gained null-test scenarios requiring the hand-wired components to
match the monolith bitwise (a 1e-12 nudge on one lane fails the airport one). This repo's own
notebooks reach the components through new C ABI entry points.

Three decisions worth keeping. (1) `tap.chime~` is the whole sixteen-voice rack rather than a mono
voice you wrap in `poly~`: `poly~` steals round-robin and is Max-only, so delegating voice stealing
would have cost both the glide-not-click promise and every non-Max target the kernel is meant to
reach. (2) `tap.bloom` and `tap.gardener` run on Max's scheduler, not the audio clock, so returns
land within an `@interval` tick rather than on the exact sample — inaudible at loop lengths measured
in seconds, but a real difference from the monolith, and the reason the null tests live in the
kernel rather than in a patch. (3) Two name collisions the compiler caught and the next object
should avoid: a message named `samples` shadows Min's `samples<N>` return type, and a class named
`scale` collides with Min's own `scale()` utility badly enough to make the class template invalid.

Full vertical slice each: maxref + help patcher + mock-kernel unit tests, clang-tidy/format clean.

Then two follow-ups closed the gaps that split left. **`tap.chime.voices~`** is the same rack with
every bell on its own outlet — sixteen mono signals, each carrying its tube dry, before the seat in
the stereo image — so a patch can filter, gate, or place one voice without touching the other
fifteen. It is a separate object rather than a mode because outlet count is fixed when a Min object
is constructed; and it is sixteen discrete outlets rather than one mc outlet because min-api's mc
support is inlet-side only (it sets `Z_MC_INLETS` and provides no `multichanneloutputs`, which Max
requires before an external may declare a variable-channel mc outlet). If a later Min gains that,
this object is where to adopt it. Kernel side, `bell::process_mono` was factored out and `process`
rewritten in terms of it, so there is still one oscillator path — bit-identical, and pinned by a new
scenario requiring the per-voice taps put back through their seats to equal the stereo pair exactly.

**`tap.period`** closes the other one: the composite period needs every length at once, so it had
nowhere to live in a patch of independent reels. `composite_period_seconds` came out of `loop_bank`
as a free function and the bank now calls it, and the seconds-to-samples quantization is shared
rather than copied (`loop_samples_for`), so `tap.period` and the reels it is asked about cannot
drift apart. That detail carries weight: the lcm is over sample counts, and lengths that look
commensurate as decimals are not as samples.

76 ctest cases green. Still open: the on-Mac validation pass (open the seven help patchers and the
null-test patcher once in Max), and maxtest starters for the objects that do not have them yet.

Remaining (ongoing, now cross-repo — DSP lands in `tap/taptools`, then bump the submodule pin
here): lift the remaining simple inline-DSP objects' math into kernel headers opportunistically as
they're touched. Control/utility and Jitter objects never move — they are Max message-logic, not
Expand Down
96 changes: 96 additions & 0 deletions docs/tap.bloom.maxref.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<?xml-stylesheet href="./_c74_ref.xsl" type="text/xsl"?>

<c74object name="tap.bloom" module="max" category="TapTools, Max Timing">
<digest>The generative event loop — plant a note and it comes back</digest>
<description>
The <o>tap.bloom</o> object is the event ring <o>tap.garden~</o> recirculates its notes on. Plant a note and it returns at its own position on the loop, every pass a step quieter (<at>decay</at>) and a step purer (<at>soften</at>), until it falls below <at>floor</at> and retires. The arithmetic is the stability story: a plant at velocity v retires after exactly ceil(log(<at>floor</at>/v)/log(<at>decay</at>)) strikes, so the live population converges however fast you plant. Sixty-four blooms fit, and a full ring retires its oldest to make room, because a touch must always speak. It emits notes rather than sound, which is the point — send them to <o>tap.chime~</o> for the garden's own voice, or to <o>makenote</o>, <o>poly~</o>, a sampler, or MIDI out for something else entirely.
</description>

<!--METADATA-->
<metadatalist>
<metadata name="author">74 Objects</metadata>
<metadata name="tag">TapTools</metadata>
<metadata name="tag">Max</metadata>
<metadata name="tag">Timing</metadata>
</metadatalist>

<!--ARGUMENTS-->
<objarglist>
<objarg name="loop" type="float" optional="1">
<description>Loop length in seconds (default 8).</description>
</objarg>
</objarglist>

<!--INLETS-->
<inletlist>
<inlet id="0" type="list">
<digest>plant (pitch, velocity), or a bare pitch/velocity pair.</digest>
</inlet>
</inletlist>

<!--OUTLETS-->
<outletlist>
<outlet id="0" type="list">
<digest>note (pitch, velocity, brightness) — a bloom firing.</digest>
</outlet>
<outlet id="1" type="list">
<digest>Reports: events (n live).</digest>
</outlet>
</outletlist>

<!--MESSAGES-->
<methodlist>
<method name="plant">
<arglist>
<arg name="pitch" type="float" optional="0"/>
<arg name="velocity" type="float" optional="0"/>
</arglist>
<description>Plant a bloom at the current loop position. It fires on the next tick and then every pass until it retires. Pitches are not quantized here — put <o>tap.scale</o> in front if you want a scale.</description>
</method>
<method name="list">
<arglist>
<arg name="pitch" type="float" optional="0"/>
<arg name="velocity" type="float" optional="0"/>
</arglist>
<description>The same as <m>plant</m>.</description>
</method>
<method name="events">
<description>Report how many blooms are live, out the right outlet.</description>
</method>
<method name="clear">
<description>Uproot everything: kill every bloom and rewind the loop. Parameters are kept.</description>
</method>
</methodlist>

<!--ATTRIBUTES-->
<attributelist>
<attribute name="loop" get="1" set="1" type="float" size="1">
<description>Loop length in seconds (0.25 to 120). Instant — the loop is a counter, so live blooms keep their positions modulo the new length.</description>
</attribute>
<attribute name="decay" get="1" set="1" type="float" size="1">
<description>Velocity multiplier per pass (0 to 1) — the stabiliser.</description>
</attribute>
<attribute name="soften" get="1" set="1" type="float" size="1">
<description>Brightness multiplier per pass (0 to 1): each return is purer than the last, so a bloom does not merely fade, it loses its edge first.</description>
</attribute>
<attribute name="floor" get="1" set="1" type="float" size="1">
<description>Retirement threshold (0.0001 to 1): a bloom whose velocity falls below this is done and leaves the ring.</description>
</attribute>
<attribute name="brightness" get="1" set="1" type="float" size="1">
<description>The brightness a new plant starts at (0 to 1); it softens from there.</description>
</attribute>
<attribute name="interval" get="1" set="1" type="float" size="1">
<description>Scheduler tick in milliseconds (0.1 to 100). This is both how often the ring is advanced and its time resolution, so returns land within a tick rather than on the exact sample — inside <o>tap.garden~</o> the same ring is sample-accurate. Changing it re-prepares the ring, which uproots every live bloom.</description>
</attribute>
</attributelist>

<!--SEEALSO-->
<seealsolist>
<seealso name="tap.garden~"/>
<seealso name="tap.chime~"/>
<seealso name="tap.gardener"/>
<seealso name="tap.scale"/>
<seealso name="makenote"/>
</seealsolist>
</c74object>
148 changes: 148 additions & 0 deletions docs/tap.chime.voices~.maxref.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<?xml-stylesheet href="./_c74_ref.xsl" type="text/xsl"?>

<c74object name="tap.chime.voices~" module="msp" category="TapTools, MSP Synthesis">
<digest>The wind-chime rack with every bell on its own outlet</digest>
<description>
The <o>tap.chime.voices~</o> object is the same rack <o>tap.chime~</o> wraps, tapped per voice instead of summed to stereo: sixteen mono signal outlets, one per bell, each carrying its tube dry — before the seat in the stereo image is applied. Put them back through the seats (which <m>voice</m> reports) and you have <o>tap.chime~</o> again to the bit; the point is that you do not have to. Place, filter, gate, or send each tube wherever you like. Note that the pool reassigns bells as it steals, so a slot holds whatever tube was last put there rather than a fixed pitch — <m>voice</m> is how you find out which.
</description>

<!--METADATA-->
<metadatalist>
<metadata name="author">74 Objects</metadata>
<metadata name="tag">TapTools</metadata>
<metadata name="tag">Audio</metadata>
<metadata name="tag">Synthesis</metadata>
</metadatalist>

<!--ARGUMENTS-->
<objarglist>
<objarg name="material" type="int" optional="1">
<description>What the tubes are made of: 0 the free-free chime tube (the default), 1 the tuned bar.</description>
</objarg>
</objarglist>

<!--INLETS-->
<inletlist>
<inlet id="0" type="list">
<digest>Strike a tube: note (pitch, velocity, brightness), or a bare list.</digest>
</inlet>
</inletlist>

<!--OUTLETS-->
<outletlist>
<outlet id="0" type="signal">
<digest>Voice 0 — the bell in pool slot 0, dry (before its seat).</digest>
</outlet>
<outlet id="1" type="signal">
<digest>Voice 1 — the bell in pool slot 1, dry (before its seat).</digest>
</outlet>
<outlet id="2" type="signal">
<digest>Voice 2 — the bell in pool slot 2, dry (before its seat).</digest>
</outlet>
<outlet id="3" type="signal">
<digest>Voice 3 — the bell in pool slot 3, dry (before its seat).</digest>
</outlet>
<outlet id="4" type="signal">
<digest>Voice 4 — the bell in pool slot 4, dry (before its seat).</digest>
</outlet>
<outlet id="5" type="signal">
<digest>Voice 5 — the bell in pool slot 5, dry (before its seat).</digest>
</outlet>
<outlet id="6" type="signal">
<digest>Voice 6 — the bell in pool slot 6, dry (before its seat).</digest>
</outlet>
<outlet id="7" type="signal">
<digest>Voice 7 — the bell in pool slot 7, dry (before its seat).</digest>
</outlet>
<outlet id="8" type="signal">
<digest>Voice 8 — the bell in pool slot 8, dry (before its seat).</digest>
</outlet>
<outlet id="9" type="signal">
<digest>Voice 9 — the bell in pool slot 9, dry (before its seat).</digest>
</outlet>
<outlet id="10" type="signal">
<digest>Voice 10 — the bell in pool slot 10, dry (before its seat).</digest>
</outlet>
<outlet id="11" type="signal">
<digest>Voice 11 — the bell in pool slot 11, dry (before its seat).</digest>
</outlet>
<outlet id="12" type="signal">
<digest>Voice 12 — the bell in pool slot 12, dry (before its seat).</digest>
</outlet>
<outlet id="13" type="signal">
<digest>Voice 13 — the bell in pool slot 13, dry (before its seat).</digest>
</outlet>
<outlet id="14" type="signal">
<digest>Voice 14 — the bell in pool slot 14, dry (before its seat).</digest>
</outlet>
<outlet id="15" type="signal">
<digest>Voice 15 — the bell in pool slot 15, dry (before its seat).</digest>
</outlet>
<outlet id="16" type="list">
<digest>Reports: voices (n ringing), voice (slot, hz, level, gainL, gainR).</digest>
</outlet>
</outletlist>

<!--MESSAGES-->
<methodlist>
<method name="note">
<arglist>
<arg name="pitch" type="float" optional="0"/>
<arg name="velocity" type="float" optional="0"/>
<arg name="brightness" type="float" optional="1"/>
</arglist>
<description>Strike the tube at that MIDI pitch. Which outlet it lands on is the pool's choice — an idle bell if there is one, otherwise the quietest is stolen and re-aimed.</description>
</method>
<method name="list">
<description>The same as <m>note</m> — which is what arrives from <o>route</o> downstream of <o>tap.bloom</o>.</description>
</method>
<method name="hz">
<arglist>
<arg name="frequency" type="float" optional="0"/>
<arg name="velocity" type="float" optional="0"/>
<arg name="brightness" type="float" optional="1"/>
</arglist>
<description>Strike at a raw frequency rather than a MIDI pitch.</description>
</method>
<method name="voice">
<arglist>
<arg name="slot" type="int" optional="1"/>
</arglist>
<description>Report that outlet's tube — its frequency in Hz (0 if never struck), its current level, and the seat gains <o>tap.chime~</o> would have used — out the right outlet. With no argument, reports every slot in turn.</description>
</method>
<method name="voices">
<description>Report how many bells are currently ringing, out the right outlet.</description>
</method>
<method name="clear">
<description>Silence every bell at once. Parameters are kept.</description>
</method>
</methodlist>

<!--ATTRIBUTES-->
<attributelist>
<attribute name="attack" get="1" set="1" type="float" size="1">
<description>Strike attack time in seconds — a clapper, not a bow.</description>
</attribute>
<attribute name="decay" get="1" set="1" type="float" size="1">
<description>Ring time in seconds for the fundamental. Each strike scales this by sqrt(440/f), so small high tubes ring shorter.</description>
</attribute>
<attribute name="material" get="1" set="1" type="int" size="1">
<description>0 the free-free chime tube, 1 the tuned bar. Read at strike time, so every later strike re-voices.</description>
</attribute>
<attribute name="spread" get="1" set="1" type="float" size="1">
<description>The rack's stereo width, 0 to 1. It does not affect these outlets, which are always dry — it sets the seat gains that <m>voice</m> reports, so a patch rebuilding the image downstream can honour it.</description>
</attribute>
<attribute name="brightness" get="1" set="1" type="float" size="1">
<description>Default upper-mode weight (0 to 1) for strikes that do not carry their own.</description>
</attribute>
</attributelist>

<!--SEEALSO-->
<seealsolist>
<seealso name="tap.chime~"/>
<seealso name="tap.garden~"/>
<seealso name="tap.bloom"/>
<seealso name="poly~"/>
</seealsolist>
</c74object>
Loading
Loading