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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
[![Tap House Style](https://github.com/tap/TapTools/actions/workflows/style.yml/badge.svg)](https://github.com/tap/TapTools/actions/workflows/style.yml)
[![Docs](https://github.com/tap/TapTools/actions/workflows/docs.yml/badge.svg)](https://github.com/tap/TapTools/actions/workflows/docs.yml)

**📖 [*Tools on Tap*](https://timothy.place/TapTools/)** — the field guide to these kernels,
one measured chapter per object family — is published from this repo on every merge.

The portable DSP library behind the TapTools Max package: header-only, plain C++20, **no Max SDK,
no min-api, no Jamoma**. One self-contained header per object under `include/taptools/`, each in
its own `tap::tools::<family>` namespace. The TapTools Max externals are thin
Expand Down
10 changes: 5 additions & 5 deletions book/figures/eno.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,23 +132,23 @@ def raster():

def staircase():
"""garden: the decay-0.5 return staircase, retiring below the floor."""
g = tap.Garden(fs, smooth_ms=0, idle_seconds=0, loop_seconds=0.5,
g = tap.Garden(fs, smooth_ms=0, idle_seconds=0, spread=0, loop_seconds=0.5,
decay=0.5, floor=0.05, bell=(0.002, 0.05, 1.0), scale=0)
g.note(69, 0.8)
y = g.process(int(3.5 * fs))
y, _ = g.process(int(3.5 * fs)) # spread 0: both busses identical, plot the left

t = np.arange(y.size) / fs
fig, ax = plt.subplots()
ax.plot(t, y, color=BLUE, lw=0.5)
for k in range(5):
v = 0.8 * 0.5 ** k
v = np.abs(y[int(k * 0.5 * fs): int((k + 1) * 0.5 * fs)]).max()
ax.plot([k * 0.5, k * 0.5 + 0.22], [v, v], color=AMBER, lw=1.6)
ax.text(k * 0.5 + 0.24, v, f"{v:g}", color=AMBER, va="center", fontsize=8.5)
ax.text(k * 0.5 + 0.24, v, f"{v:.2f}", color=AMBER, va="center", fontsize=8.5)
ax.axhline(0.05, color=RED, lw=0.9, ls=":")
ax.text(3.44, 0.075, "floor 0.05 — retirement", color=RED, ha="right", fontsize=8.5)
ax.set_xlabel("time (s)")
ax.set_ylabel("output")
ax.set_title("decay 0.5: each return half as loud, then the bloom retires")
ax.set_title("decay 0.5: each return half the previous peak, then the bloom retires")
fig.savefig(out_dir("garden") / "staircase.svg", bbox_inches="tight")
plt.close(fig)

Expand Down
91 changes: 69 additions & 22 deletions book/src/garden.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,22 @@ every claim below, and the `eno_render` tool's `garden_played` and
`garden_idle` scenarios, the listening copies. The Max wrapper lands in the
TapTools-Max package alongside the rest of the family.

![Signal-flow diagram of tap.garden~: notes through a scale quantizer into a 64-event ring, fired at their loop positions into a 16-voice FM bell pool, with a red per-pass path multiplying velocity by decay and brightness by soften back into the ring, and a dashed seeded gardener planting into the ring](images/garden/block-diagram.svg)
![Signal-flow diagram of tap.garden~: notes through a scale quantizer into a 64-event ring, fired at their loop positions into a 16-voice wind-chime pool that sums onto a stereo pair, with a red per-pass path multiplying velocity by decay and brightness by soften back into the ring, and a dashed seeded gardener planting into the ring](images/garden/block-diagram.svg)

*Events on a loop instead of audio on a tape — the same recirculation, one level of abstraction up.*

## Plant and return

`note(pitch, velocity)` plants: the pitch snaps to the current root and
scale *at entry*, a soft two-operator FM bell sounds on the next sample,
scale *at entry*, a small wind chime is struck on the next sample,
and the event takes a seat at the loop's current position. Every pass, it
fires again at `velocity × decay`, and below `floor` it retires. The
notebook's staircase is the whole contract in one figure: a plant at 0.8
with decay 0.5 returns at 0.795, 0.399, 0.2, 0.1, 0.05 — then silence, and
`active_events` reads zero.
with decay 0.5 returns with its fundamental at exactly half the last, four
times over (measured ratios 0.500, 0.500, 0.500, 0.500), then silence, and
`active_events` reads zero. The whole strike fades a shade faster than its
fundamental — quieter returns are also duller, because strike hardness
couples brightness to velocity.

![A rendered waveform showing five returns of one planted note, each half the height of the last, with the measured peak levels labeled and the retirement floor marked](images/garden/staircase.svg)

Expand All @@ -47,18 +50,48 @@ That arithmetic is also the stability story. The family's inversion —
degradation as the stabilizer — reaches its third form here: a bloom lives
exactly `ceil(log(floor/velocity) / log(decay))` passes, so the population
of live events *converges by construction* no matter how fast you plant.
And beneath the arithmetic sits a hard bound: sixteen bells in a fixed
pool, the quietest stolen when a seventeenth is needed, its envelope
And beneath the arithmetic sits a hard bound: sixteen chimes in a fixed
pool, the quietest stolen when a seventeenth is needed, its envelopes
re-aimed rather than reset so a steal glides instead of clicking.

## `soften` — returns get purer, not just quieter

Each pass also multiplies the event's *brightness* by `soften`, and
brightness is the bell's FM index: the upper partial fades while the
fundamental holds, so a bloom collapses toward a sine as it recedes — the
tape chapters' generation loss, restated in partials instead of passbands.
The notebook measures the sideband-to-fundamental ratio shrinking every
single return, and the pinned test requires it strictly.
The chime is four decaying mode *doublets* at the transverse ratios of the
chosen material — by default 1 : 2.756 : 5.404 : 8.933, the free-free-tube
physics in Fletcher & Rossing — with the upper modes softer, steeper in brightness
(b, b², b³), and dying roughly as f² faster, so the fourth mode is the
few-millisecond tick of clapper contact and every strike rings down to its
fundamental. Each mode pair is split a few cents, the way a real tube's
degenerate modes are, so the tail *beats* slowly instead of decaying like a
lab sine. Each pass multiplies the event's *brightness* by `soften`, and
brightness is the upper modes' level: a bloom collapses toward its
fundamental as it recedes, losing its tick first — the tape chapters'
generation loss, restated in modes instead of passbands. The notebook
measures the mode-two-to-fundamental ratio shrinking by exactly `soften`
every single return, and the pinned tests hold each piece separately: the
tick confined to the contact, the tail's beat dipping and returning, soft
strikes duller than hard ones, high tubes ringing shorter than low.

## The rack: material, flaws, and seats

`material` swaps what the tubes are made of — a mode, not a fader. At 0 the
rack is wind chimes, the free-free tube's 1 : 2.756 : 5.404 : 8.933; at 1 it
is a tuned bar, the mallet instrument's double-octave 1 : 4 : 10 : 20 (both
tables from Fletcher & Rossing). The table is read at strike time, so every
live bloom re-voices at its next return: the notebook measures the second
partial's energy moving cleanly from 2.756× to 4× when the material flips.

And the tube is the identity. Each pitch is a physical tube whose
imperfections are properties of the tube, not the strike: its upper modes
sit a fixed few cents off the ideal ratios (bounded by ±3 cents — the
fundamental stays true, because a maker tunes the fundamental and the
overtones land where the metal puts them), and it hangs at a fixed seat on
the stereo rack, width set by `spread` (0 collapses to center mono, bitwise
identical busses). Both draws come from a stateless hash of the pitch, so
the rack is the same rack in every instance and every return of a bloom
rings from the same place with the same flaws — the notebook's seat chart
is a bar per pitch, and the seed triad below is untouched because no
generator is ever consumed for it.

## The scale contract

Expand All @@ -74,9 +107,12 @@ seeds only.

## The gardener

`idle_seconds` is the patience: that long after your last plant, the
garden begins seeding itself, roughly one note per loop pass, uniformly
placed, on the scale, within two octaves. The randomness is the family's
`idle_seconds` is the patience: that long after your last plant, the wind
picks up. The gardener strikes on a calm/gust cycle — `gust` at 0 is a
still day, single strikes spaced about one per pass; raise it and strikes
arrive in flurries of up to five neighboring tubes within a fraction of a
second, with longer calms between, the average rate holding. The
randomness is the family's
seeded xorshift64* with the full tr808 contract, pinned as a triad: same
seed, bit-identical garden; different seed, a different garden; gardener
disabled (`idle_seconds 0`), the seed cannot matter at all, because the
Expand All @@ -93,8 +129,13 @@ that demands reproducibility.
no gardener, fast decay: each phrase you play unwinds itself to silence
in a few passes, a wind-up toy running down.
- **The endless install:** `@scale minorpentatonic @root 2 @idle 3.
@seed 2008 @level 0.35`, never touch it again. Same seed next year,
same garden.
@gust 0.6 @seed 2008 @level 0.35`, never touch it again. Same seed next
year, same garden — gusts and all.
- **The still day:** `@gust 0 @idle 10.` — no flurries, one unhurried
strike at a time, the original music-box gardener.
- **The marimba loft:** `@material 1 @spread 1. @decay 0.7 @soften 0.8` —
tuned bars instead of tubes, the rack thrown wide: drier, woodier blooms
that each speak from their own place in the image.
- **Duet:** `@idle 6.` and stay at the keyboard — every silence longer
than six seconds, the gardener answers you; every plant of yours resets
its patience.
Expand All @@ -107,15 +148,21 @@ that demands reproducibility.
- **Rhythm.** Events return on the loop grid, exactly, forever — no swing,
no humanization. For patterns as *rhythm*, `tap.808.seq~` is the
machine.
- **Any other timbre.** One soft bell family, on purpose. It is an
instrument, not a polysynth; for FM as a playground, patch oscillators.
- **Any other timbre.** Two materials, one chime family, on purpose. It is
an instrument, not a polysynth; for synthesis as a playground, patch
oscillators.
- **A stereo panner.** The image is a rack of fixed seats keyed by pitch —
there is no per-strike pan and no motion. For placement as a *parameter*,
pan the object's output.

## Checkpoint

Notes become events; events recirculate on a loop, quieter by `decay` and
purer by `soften` each pass, retiring below `floor`; a sixteen-bell pool
purer by `soften` each pass, retiring below `floor`; a sixteen-chime pool
bounds the sound and a sixty-four-seat ring bounds the score, oldest bloom
yielding first. The scale makes wrong notes unrepresentable, and a seeded
gardener keeps the piece alive exactly as long as you neglect it. Every
yielding first. Two materials share the rack, every tube keeps its own
flaws and its own stereo seat, the scale makes wrong notes unrepresentable,
and a seeded gardener keeps the piece alive exactly as long as you neglect
it. Every
number above lives twice: as an executed cell in `garden.ipynb` and as a
pinned scenario in `tests/garden_test.cpp`, which CI runs on every push.
Loading
Loading