Skip to content

Commit c3daeb5

Browse files
committed
Reach the components from the verification layer
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
1 parent 30d6aa7 commit c3daeb5

3 files changed

Lines changed: 678 additions & 1 deletion

File tree

notebooks/taptools_py.py

Lines changed: 302 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
incommensurate loop bank tap.airport~ (`Airport`), the generative event
2020
loop tap.garden~ (`Garden`), and tap.tune~'s pitch corrector (`Tune`, with
2121
the shared DspTap detector passed through as `Yin` for the notebooks'
22-
pitch tracking). Parameter names on the
22+
pitch tracking). The components those last two are built from are reachable
23+
too — one tape lane (`Reel`), the chime rack (`Chime`), the event ring
24+
(`Bloom`), the idle wind (`Gardener`), and the entry quantizer
25+
(`scale_quantize`) — so a notebook can null-test the patch against the
26+
object through one ABI. Parameter names on the
2327
kernel classes mirror each kernel header's param_index enum.
2428
2529
Copyright 2003-2026 Timothy Place. MIT License.
@@ -316,6 +320,59 @@ def load() -> ctypes.CDLL:
316320
"taptools_garden_active_events": ([vp], ctypes.c_int),
317321
"taptools_garden_active_voices": ([vp], ctypes.c_int),
318322
"taptools_garden_process": ([vp, f64p, f64p, ctypes.c_int], ctypes.c_int),
323+
# the components the monoliths are made of
324+
"taptools_reel_create": ([], vp),
325+
"taptools_reel_destroy": ([vp], None),
326+
"taptools_reel_prepare": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int),
327+
"taptools_reel_set_length_seconds": ([vp, ctypes.c_double], ctypes.c_int),
328+
"taptools_reel_record": ([vp, ctypes.c_int], ctypes.c_int),
329+
"taptools_reel_set_level": ([vp, ctypes.c_double], ctypes.c_int),
330+
"taptools_reel_set_pan": ([vp, ctypes.c_double], ctypes.c_int),
331+
"taptools_reel_set_darken_hz": ([vp, ctypes.c_double], ctypes.c_int),
332+
"taptools_reel_set_smooth_ms": ([vp, ctypes.c_double], ctypes.c_int),
333+
"taptools_reel_clear": ([vp], ctypes.c_int),
334+
"taptools_reel_phase": ([vp], ctypes.c_double),
335+
"taptools_reel_length_seconds": ([vp], ctypes.c_double),
336+
"taptools_reel_loop_samples": ([vp], ctypes.c_int),
337+
"taptools_reel_process": ([vp, f64p, f64p, f64p, ctypes.c_int], ctypes.c_int),
338+
"taptools_chime_create": ([], vp),
339+
"taptools_chime_destroy": ([vp], None),
340+
"taptools_chime_prepare": ([vp, ctypes.c_double], ctypes.c_int),
341+
"taptools_chime_set_times": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int),
342+
"taptools_chime_set_material": ([vp, ctypes.c_int], ctypes.c_int),
343+
"taptools_chime_set_spread": ([vp, ctypes.c_double], ctypes.c_int),
344+
"taptools_chime_clear": ([vp], ctypes.c_int),
345+
"taptools_chime_strike": ([vp, ctypes.c_double, ctypes.c_double, ctypes.c_double],
346+
ctypes.c_int),
347+
"taptools_chime_strike_hz": ([vp, ctypes.c_double, ctypes.c_double, ctypes.c_double],
348+
ctypes.c_int),
349+
"taptools_chime_active_voices": ([vp], ctypes.c_int),
350+
"taptools_chime_process": ([vp, f64p, f64p, ctypes.c_int], ctypes.c_int),
351+
"taptools_bloom_create": ([], vp),
352+
"taptools_bloom_destroy": ([vp], None),
353+
"taptools_bloom_prepare": ([vp, ctypes.c_double], ctypes.c_int),
354+
"taptools_bloom_set_loop_seconds": ([vp, ctypes.c_double], ctypes.c_int),
355+
"taptools_bloom_set_decay": ([vp, ctypes.c_double], ctypes.c_int),
356+
"taptools_bloom_set_soften": ([vp, ctypes.c_double], ctypes.c_int),
357+
"taptools_bloom_set_floor": ([vp, ctypes.c_double], ctypes.c_int),
358+
"taptools_bloom_set_brightness": ([vp, ctypes.c_double], ctypes.c_int),
359+
"taptools_bloom_clear": ([vp], ctypes.c_int),
360+
"taptools_bloom_plant": ([vp, ctypes.c_double, ctypes.c_double], ctypes.c_int),
361+
"taptools_bloom_due": ([vp, f64p, f64p, f64p, ctypes.c_int], ctypes.c_int),
362+
"taptools_bloom_step": ([vp], ctypes.c_int),
363+
"taptools_bloom_active_events": ([vp], ctypes.c_int),
364+
"taptools_bloom_loop_samples": ([vp], ctypes.c_int),
365+
"taptools_gardener_create": ([], vp),
366+
"taptools_gardener_destroy": ([vp], None),
367+
"taptools_gardener_prepare": ([vp, ctypes.c_double], ctypes.c_int),
368+
"taptools_gardener_set_idle_seconds": ([vp, ctypes.c_double], ctypes.c_int),
369+
"taptools_gardener_set_gust": ([vp, ctypes.c_double], ctypes.c_int),
370+
"taptools_gardener_set_seed": ([vp, ctypes.c_ulonglong], ctypes.c_int),
371+
"taptools_gardener_notice_plant": ([vp], ctypes.c_int),
372+
"taptools_gardener_clear": ([vp], ctypes.c_int),
373+
"taptools_gardener_tick": ([vp, ctypes.c_int, f64p, f64p], ctypes.c_int),
374+
"taptools_scale_quantize": ([ctypes.c_double, ctypes.c_int, ctypes.c_int],
375+
ctypes.c_double),
319376
"taptools_yin_create": ([ctypes.c_int, ctypes.c_int, ctypes.c_int], vp),
320377
"taptools_yin_destroy": ([vp], None),
321378
"taptools_yin_frame_size": ([vp], ctypes.c_int),
@@ -1338,6 +1395,250 @@ def __del__(self):
13381395
self._h = None
13391396

13401397

1398+
class Reel:
1399+
"""One lane of tap.airport~ (tap::tools::airport::loop) — a single
1400+
free-running tape loop with one head that both plays and records. A bank
1401+
is an array of these and nothing more, so summing N of them reproduces
1402+
`Airport` exactly; that is what the null-test cells check."""
1403+
1404+
def __init__(self, sr: float = 48000.0, max_loop_seconds: float = 30.0, **params):
1405+
self._h = _LIB.taptools_reel_create()
1406+
_check(_LIB.taptools_reel_prepare(self._h, float(sr), float(max_loop_seconds)), "prepare")
1407+
self.set(**params)
1408+
1409+
def set(self, *, length=None, level=None, pan=None, darken=None, smooth_ms=None) -> "Reel":
1410+
# configuration first, so ramped targets in the same call honor the new slew
1411+
if smooth_ms is not None:
1412+
_check(_LIB.taptools_reel_set_smooth_ms(self._h, float(smooth_ms)), "smooth_ms")
1413+
if length is not None:
1414+
_check(_LIB.taptools_reel_set_length_seconds(self._h, float(length)), "length")
1415+
if level is not None:
1416+
_check(_LIB.taptools_reel_set_level(self._h, float(level)), "level")
1417+
if pan is not None:
1418+
_check(_LIB.taptools_reel_set_pan(self._h, float(pan)), "pan")
1419+
if darken is not None:
1420+
_check(_LIB.taptools_reel_set_darken_hz(self._h, float(darken)), "darken")
1421+
return self
1422+
1423+
def record(self, on: bool) -> "Reel":
1424+
"""Punch the process() input onto the tape (True) or freeze it
1425+
bit-exactly (False). Recording starts wherever the head happens to be."""
1426+
_check(_LIB.taptools_reel_record(self._h, 1 if on else 0), "record")
1427+
return self
1428+
1429+
@property
1430+
def phase(self) -> float:
1431+
"""The head position as a fraction of the loop length, 0..1."""
1432+
return float(_LIB.taptools_reel_phase(self._h))
1433+
1434+
@property
1435+
def length_seconds(self) -> float:
1436+
"""The loop length, as quantized to a whole number of samples."""
1437+
return float(_LIB.taptools_reel_length_seconds(self._h))
1438+
1439+
@property
1440+
def loop_samples(self) -> int:
1441+
return int(_LIB.taptools_reel_loop_samples(self._h))
1442+
1443+
def process(self, x):
1444+
x = _f64(x)
1445+
out_l = np.zeros_like(x)
1446+
out_r = np.zeros_like(x)
1447+
_check(_LIB.taptools_reel_process(self._h, _p64(x), _p64(out_l), _p64(out_r), x.size),
1448+
"process")
1449+
return out_l, out_r
1450+
1451+
def clear(self) -> None:
1452+
"""Erase the tape and rewind the head; parameters are untouched."""
1453+
_check(_LIB.taptools_reel_clear(self._h), "clear")
1454+
1455+
def __del__(self):
1456+
h = getattr(self, "_h", None)
1457+
if h:
1458+
_LIB.taptools_reel_destroy(h)
1459+
self._h = None
1460+
1461+
1462+
class Chime:
1463+
"""tap.garden~'s voice rack (tap::tools::garden::rack) — the fixed pool of
1464+
16 wind chimes plus the quietest-first allocator, which steals by re-aiming
1465+
rather than resetting so a steal glides instead of clicking. The allocator
1466+
is kernel code on purpose: Max's poly~ steals round-robin and does not
1467+
exist off Max."""
1468+
1469+
def __init__(self, sr: float = 48000.0, **params):
1470+
self._h = _LIB.taptools_chime_create()
1471+
_check(_LIB.taptools_chime_prepare(self._h, float(sr)), "prepare")
1472+
self.set(**params)
1473+
1474+
def set(self, *, attack=None, decay=None, material=None, spread=None) -> "Chime":
1475+
if attack is not None or decay is not None:
1476+
a = 0.004 if attack is None else float(attack)
1477+
d = 4.0 if decay is None else float(decay)
1478+
_check(_LIB.taptools_chime_set_times(self._h, a, d), "times")
1479+
if material is not None:
1480+
_check(_LIB.taptools_chime_set_material(self._h, int(material)), "material")
1481+
if spread is not None:
1482+
_check(_LIB.taptools_chime_set_spread(self._h, float(spread)), "spread")
1483+
return self
1484+
1485+
def strike(self, pitch: float, velocity: float = 1.0, brightness: float = 1.0) -> "Chime":
1486+
"""Strike the tube at a MIDI pitch (fractional pitches are distinct
1487+
tubes, with their own scatter and their own seat on the rack)."""
1488+
_check(_LIB.taptools_chime_strike(self._h, float(pitch), float(velocity), float(brightness)),
1489+
"strike")
1490+
return self
1491+
1492+
def strike_hz(self, freq_hz: float, velocity: float = 1.0, brightness: float = 1.0) -> "Chime":
1493+
_check(_LIB.taptools_chime_strike_hz(self._h, float(freq_hz), float(velocity),
1494+
float(brightness)), "strike_hz")
1495+
return self
1496+
1497+
@property
1498+
def active_voices(self) -> int:
1499+
return int(_LIB.taptools_chime_active_voices(self._h))
1500+
1501+
def process(self, n: int):
1502+
"""A source: render n samples of the stereo rack."""
1503+
out_l = np.zeros(int(n))
1504+
out_r = np.zeros(int(n))
1505+
_check(_LIB.taptools_chime_process(self._h, _p64(out_l), _p64(out_r), out_l.size), "process")
1506+
return out_l, out_r
1507+
1508+
def clear(self) -> None:
1509+
_check(_LIB.taptools_chime_clear(self._h), "clear")
1510+
1511+
def __del__(self):
1512+
h = getattr(self, "_h", None)
1513+
if h:
1514+
_LIB.taptools_chime_destroy(h)
1515+
self._h = None
1516+
1517+
1518+
class Bloom:
1519+
"""tap.garden~'s event ring (tap::tools::garden::ring) — plant a bloom and
1520+
it returns at its own loop position every pass, velocity times `decay` and
1521+
brightness times `soften`, retiring below `floor`. It knows nothing about
1522+
chimes: it emits strikes, and what sounds them is your business. A plant at
1523+
velocity v lives exactly ceil(log(floor/v)/log(decay)) strikes."""
1524+
1525+
_MAX_EVENTS = 64
1526+
1527+
def __init__(self, sr: float = 48000.0, **params):
1528+
self._h = _LIB.taptools_bloom_create()
1529+
_check(_LIB.taptools_bloom_prepare(self._h, float(sr)), "prepare")
1530+
self._pitch = np.zeros(self._MAX_EVENTS)
1531+
self._vel = np.zeros(self._MAX_EVENTS)
1532+
self._bright = np.zeros(self._MAX_EVENTS)
1533+
self.set(**params)
1534+
1535+
def set(self, *, loop_seconds=None, decay=None, soften=None, floor=None,
1536+
brightness=None) -> "Bloom":
1537+
if loop_seconds is not None:
1538+
_check(_LIB.taptools_bloom_set_loop_seconds(self._h, float(loop_seconds)), "loop")
1539+
if decay is not None:
1540+
_check(_LIB.taptools_bloom_set_decay(self._h, float(decay)), "decay")
1541+
if soften is not None:
1542+
_check(_LIB.taptools_bloom_set_soften(self._h, float(soften)), "soften")
1543+
if floor is not None:
1544+
_check(_LIB.taptools_bloom_set_floor(self._h, float(floor)), "floor")
1545+
if brightness is not None:
1546+
_check(_LIB.taptools_bloom_set_brightness(self._h, float(brightness)), "brightness")
1547+
return self
1548+
1549+
def plant(self, pitch: float, velocity: float) -> "Bloom":
1550+
"""Plant at the current loop position; it fires on the next due()."""
1551+
_check(_LIB.taptools_bloom_plant(self._h, float(pitch), float(velocity)), "plant")
1552+
return self
1553+
1554+
def due(self):
1555+
"""The strikes due on this sample as a list of (pitch, velocity,
1556+
brightness). Fires and wears them, but does NOT advance — call step()."""
1557+
n = _LIB.taptools_bloom_due(self._h, _p64(self._pitch), _p64(self._vel),
1558+
_p64(self._bright), self._MAX_EVENTS)
1559+
_check(0 if n >= 0 else -1, "due")
1560+
return [(self._pitch[i], self._vel[i], self._bright[i]) for i in range(n)]
1561+
1562+
def step(self) -> None:
1563+
"""Advance the loop one sample."""
1564+
_check(_LIB.taptools_bloom_step(self._h), "step")
1565+
1566+
@property
1567+
def active_events(self) -> int:
1568+
return int(_LIB.taptools_bloom_active_events(self._h))
1569+
1570+
@property
1571+
def loop_samples(self) -> int:
1572+
return int(_LIB.taptools_bloom_loop_samples(self._h))
1573+
1574+
def clear(self) -> None:
1575+
_check(_LIB.taptools_bloom_clear(self._h), "clear")
1576+
1577+
def __del__(self):
1578+
h = getattr(self, "_h", None)
1579+
if h:
1580+
_LIB.taptools_bloom_destroy(h)
1581+
self._h = None
1582+
1583+
1584+
class Gardener:
1585+
"""tap.garden~'s idle wind (tap::tools::garden::gardener) — after
1586+
`idle_seconds` without a caller plant, strikes arrive on a calm/gust cycle.
1587+
The rng is drawn from ONLY while idling, which is what makes the seed triad
1588+
hold. Pitches come out RAW: quantizing them is the caller's job, because the
1589+
scale is the caller's field, not the wind's."""
1590+
1591+
def __init__(self, sr: float = 48000.0, **params):
1592+
self._h = _LIB.taptools_gardener_create()
1593+
_check(_LIB.taptools_gardener_prepare(self._h, float(sr)), "prepare")
1594+
self._pitch = np.zeros(1)
1595+
self._vel = np.zeros(1)
1596+
self.set(**params)
1597+
1598+
def set(self, *, idle_seconds=None, gust=None, seed=None) -> "Gardener":
1599+
if idle_seconds is not None:
1600+
_check(_LIB.taptools_gardener_set_idle_seconds(self._h, float(idle_seconds)), "idle")
1601+
if gust is not None:
1602+
_check(_LIB.taptools_gardener_set_gust(self._h, float(gust)), "gust")
1603+
if seed is not None:
1604+
_check(_LIB.taptools_gardener_set_seed(self._h, int(seed)), "seed")
1605+
return self
1606+
1607+
def notice_plant(self) -> "Gardener":
1608+
"""A caller planted: close the idle gate."""
1609+
_check(_LIB.taptools_gardener_notice_plant(self._h), "notice_plant")
1610+
return self
1611+
1612+
def tick(self, loop_samples: int):
1613+
"""Advance one sample. Returns (pitch, velocity) if the wind wants a
1614+
strike this sample, else None."""
1615+
n = _LIB.taptools_gardener_tick(self._h, int(loop_samples), _p64(self._pitch),
1616+
_p64(self._vel))
1617+
_check(0 if n >= 0 else -1, "tick")
1618+
return (self._pitch[0], self._vel[0]) if n == 1 else None
1619+
1620+
def clear(self) -> None:
1621+
"""Re-seed the rng and restart the idle clock and the wind."""
1622+
_check(_LIB.taptools_gardener_clear(self._h), "clear")
1623+
1624+
def __del__(self):
1625+
h = getattr(self, "_h", None)
1626+
if h:
1627+
_LIB.taptools_gardener_destroy(h)
1628+
self._h = None
1629+
1630+
1631+
def scale_quantize(pitch, root: int = 0, scale: int = 3):
1632+
"""tap.garden~'s entry quantizer (tap::tools::garden::scale_quantizer):
1633+
snap MIDI semitones to the nearest pitch in root/scale. `scale` indexes
1634+
garden::scale_index — 0 chromatic, 1 major, 2 minor, 3 major pentatonic,
1635+
4 minor pentatonic. Accepts a scalar or an array."""
1636+
if np.isscalar(pitch):
1637+
return float(_LIB.taptools_scale_quantize(float(pitch), int(root), int(scale)))
1638+
return np.array([float(_LIB.taptools_scale_quantize(float(p), int(root), int(scale)))
1639+
for p in np.asarray(pitch, dtype=float)])
1640+
1641+
13411642
class Yin:
13421643
"""The shared DspTap pitch detector (tap::dsp::yin), passed through the C ABI
13431644
so the notebooks can track pitch with the same detector the corrector uses."""

0 commit comments

Comments
 (0)