|
19 | 19 | incommensurate loop bank tap.airport~ (`Airport`), the generative event |
20 | 20 | loop tap.garden~ (`Garden`), and tap.tune~'s pitch corrector (`Tune`, with |
21 | 21 | 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 |
23 | 27 | kernel classes mirror each kernel header's param_index enum. |
24 | 28 |
|
25 | 29 | Copyright 2003-2026 Timothy Place. MIT License. |
@@ -316,6 +320,59 @@ def load() -> ctypes.CDLL: |
316 | 320 | "taptools_garden_active_events": ([vp], ctypes.c_int), |
317 | 321 | "taptools_garden_active_voices": ([vp], ctypes.c_int), |
318 | 322 | "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), |
319 | 376 | "taptools_yin_create": ([ctypes.c_int, ctypes.c_int, ctypes.c_int], vp), |
320 | 377 | "taptools_yin_destroy": ([vp], None), |
321 | 378 | "taptools_yin_frame_size": ([vp], ctypes.c_int), |
@@ -1338,6 +1395,250 @@ def __del__(self): |
1338 | 1395 | self._h = None |
1339 | 1396 |
|
1340 | 1397 |
|
| 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 | + |
1341 | 1642 | class Yin: |
1342 | 1643 | """The shared DspTap pitch detector (tap::dsp::yin), passed through the C ABI |
1343 | 1644 | so the notebooks can track pitch with the same detector the corrector uses.""" |
|
0 commit comments