diff --git a/REVIVAL.md b/REVIVAL.md index 799249d6..09a33500 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -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` 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 diff --git a/docs/tap.bloom.maxref.xml b/docs/tap.bloom.maxref.xml new file mode 100644 index 00000000..6607475e --- /dev/null +++ b/docs/tap.bloom.maxref.xml @@ -0,0 +1,96 @@ + + + + + The generative event loop — plant a note and it comes back + + The tap.bloom object is the event ring tap.garden~ recirculates its notes on. Plant a note and it returns at its own position on the loop, every pass a step quieter (decay) and a step purer (soften), until it falls below floor and retires. The arithmetic is the stability story: a plant at velocity v retires after exactly ceil(log(floor/v)/log(decay)) 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 tap.chime~ for the garden's own voice, or to makenote, poly~, a sampler, or MIDI out for something else entirely. + + + + + 74 Objects + TapTools + Max + Timing + + + + + + Loop length in seconds (default 8). + + + + + + + plant (pitch, velocity), or a bare pitch/velocity pair. + + + + + + + note (pitch, velocity, brightness) — a bloom firing. + + + Reports: events (n live). + + + + + + + + + + + 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 tap.scale in front if you want a scale. + + + + + + + The same as plant. + + + Report how many blooms are live, out the right outlet. + + + Uproot everything: kill every bloom and rewind the loop. Parameters are kept. + + + + + + + Loop length in seconds (0.25 to 120). Instant — the loop is a counter, so live blooms keep their positions modulo the new length. + + + Velocity multiplier per pass (0 to 1) — the stabiliser. + + + 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. + + + Retirement threshold (0.0001 to 1): a bloom whose velocity falls below this is done and leaves the ring. + + + The brightness a new plant starts at (0 to 1); it softens from there. + + + 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 tap.garden~ the same ring is sample-accurate. Changing it re-prepares the ring, which uproots every live bloom. + + + + + + + + + + + + diff --git a/docs/tap.chime.voices~.maxref.xml b/docs/tap.chime.voices~.maxref.xml new file mode 100644 index 00000000..e6442d8b --- /dev/null +++ b/docs/tap.chime.voices~.maxref.xml @@ -0,0 +1,148 @@ + + + + + The wind-chime rack with every bell on its own outlet + + The tap.chime.voices~ object is the same rack tap.chime~ 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 voice reports) and you have tap.chime~ 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 — voice is how you find out which. + + + + + 74 Objects + TapTools + Audio + Synthesis + + + + + + What the tubes are made of: 0 the free-free chime tube (the default), 1 the tuned bar. + + + + + + + Strike a tube: note (pitch, velocity, brightness), or a bare list. + + + + + + + Voice 0 — the bell in pool slot 0, dry (before its seat). + + + Voice 1 — the bell in pool slot 1, dry (before its seat). + + + Voice 2 — the bell in pool slot 2, dry (before its seat). + + + Voice 3 — the bell in pool slot 3, dry (before its seat). + + + Voice 4 — the bell in pool slot 4, dry (before its seat). + + + Voice 5 — the bell in pool slot 5, dry (before its seat). + + + Voice 6 — the bell in pool slot 6, dry (before its seat). + + + Voice 7 — the bell in pool slot 7, dry (before its seat). + + + Voice 8 — the bell in pool slot 8, dry (before its seat). + + + Voice 9 — the bell in pool slot 9, dry (before its seat). + + + Voice 10 — the bell in pool slot 10, dry (before its seat). + + + Voice 11 — the bell in pool slot 11, dry (before its seat). + + + Voice 12 — the bell in pool slot 12, dry (before its seat). + + + Voice 13 — the bell in pool slot 13, dry (before its seat). + + + Voice 14 — the bell in pool slot 14, dry (before its seat). + + + Voice 15 — the bell in pool slot 15, dry (before its seat). + + + Reports: voices (n ringing), voice (slot, hz, level, gainL, gainR). + + + + + + + + + + + + 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. + + + The same as note — which is what arrives from route downstream of tap.bloom. + + + + + + + + Strike at a raw frequency rather than a MIDI pitch. + + + + + + Report that outlet's tube — its frequency in Hz (0 if never struck), its current level, and the seat gains tap.chime~ would have used — out the right outlet. With no argument, reports every slot in turn. + + + Report how many bells are currently ringing, out the right outlet. + + + Silence every bell at once. Parameters are kept. + + + + + + + Strike attack time in seconds — a clapper, not a bow. + + + Ring time in seconds for the fundamental. Each strike scales this by sqrt(440/f), so small high tubes ring shorter. + + + 0 the free-free chime tube, 1 the tuned bar. Read at strike time, so every later strike re-voices. + + + The rack's stereo width, 0 to 1. It does not affect these outlets, which are always dry — it sets the seat gains that voice reports, so a patch rebuilding the image downstream can honour it. + + + Default upper-mode weight (0 to 1) for strikes that do not carry their own. + + + + + + + + + + + diff --git a/docs/tap.chime~.maxref.xml b/docs/tap.chime~.maxref.xml new file mode 100644 index 00000000..76d32e1b --- /dev/null +++ b/docs/tap.chime~.maxref.xml @@ -0,0 +1,105 @@ + + + + + A rack of sixteen wind chimes — tap.garden~'s voice + + The tap.chime~ object is the voice tap.garden~ sounds its blooms on. Each strike is four decaying mode doublets at the transverse-vibration ratios of the chosen material — the free-free tube's 1 : 2.756 : 5.404 : 8.933 or the tuned bar's double-octave 1 : 4 : 10 : 20 (Fletcher and Rossing, The Physics of Musical Instruments, 2nd ed.) — with the upper modes softer, faster-dying, and scattered a fixed few cents by the struck tube's own identity. Every pitch is its own tube: the same flaws and the same seat in the stereo field, in every instance, forever. Polyphony is the object's own rather than something you wrap in poly~: an idle bell is used if there is one, otherwise the quietest ringing bell is stolen and re-aimed — its phases keep free-running and its seat glides — so a steal sounds like a chime struck again, not a voice cut off. + + + + + 74 Objects + TapTools + Audio + Synthesis + + + + + + What the tubes are made of: 0 the free-free chime tube (the default), 1 the tuned bar. + + + + + + + Strike a tube: note (pitch, velocity, brightness), or a bare list. + + + + + + + The rack, left. + + + The rack, right. + + + Reports: voices (n ringing). + + + + + + + + + + + + Strike the tube at that MIDI pitch. Fractional pitches are accepted and are distinct tubes, with their own scatter and their own seat. Velocity is 0 to 1; brightness defaults to the brightness attribute. A soft strike is a duller strike regardless. + + + + + + + + The same as note — which is what arrives from route downstream of tap.bloom. + + + + + + + + Strike at a raw frequency rather than a MIDI pitch. + + + Report how many bells are currently ringing, out the right outlet. + + + Silence every bell at once. Parameters are kept. + + + + + + + Strike attack time in seconds — a clapper, not a bow. Applies to future strikes; a ringing chime keeps its envelope until retriggered. + + + Ring time in seconds for the fundamental. The upper modes die much faster (radiation damping), and each strike scales this by sqrt(440/f) — small high tubes ring shorter. + + + 0 the free-free chime tube, 1 the tuned bar. A mode, not a fader — it is read at strike time, so every later strike re-voices. + + + The rack's stereo width, 0 to 1. Each tube's seat comes from its pitch, so the image is a fixed rack rather than a moving one; 0 collapses it to centre mono, bitwise equal on both busses. + + + Default upper-mode weight (0 to 1) for strikes that do not carry their own. + + + + + + + + + + + diff --git a/docs/tap.gardener.maxref.xml b/docs/tap.gardener.maxref.xml new file mode 100644 index 00000000..6d30815a --- /dev/null +++ b/docs/tap.gardener.maxref.xml @@ -0,0 +1,79 @@ + + + + + The idle wind that plants the garden when you stop + + The tap.gardener object is the self-seeding half of tap.garden~. After idle seconds with nobody planting, it starts planting for you: strikes arrive on a calm and gust cycle, where gust sizes the clusters — up to five neighbouring tubes within a fraction of a second — with the calms between them stretched so the average rate stays near one strike per loop pass at any setting. It is wind, not a sequence. The random stream is drawn from only while idling, which is what makes seed a contract: the same seed gives the same wind every run, a different seed decorrelates two instances, and with idle 0 the seed cannot matter at all because the stream is never touched. Pitches come out raw and unquantized on purpose — the scale is the patch's field, not the wind's, so put tap.scale after it. + + + + + 74 Objects + TapTools + Max + Timing + + + + + + The wind's seed (default 0). + + + + + + + planted or bang — close the idle gate, something else just planted. + + + + + + + plant (pitch, velocity) — raw and unquantized. + + + + + + + Close the idle gate: something else just planted, so the wind drops and waits out another idle period before it starts again. + + + The same as planted. + + + Re-seed the random stream and restart both the idle clock and the wind. + + + + + + + Seconds of quiet before the wind picks up. 0 disables it entirely — and then the seed cannot matter, because the random stream is never consumed. + + + The wind, 0 to 1. At 0 strikes arrive singly and evenly, about one per pass; up from there they cluster into gusts with longer calms between, at the same average rate. + + + The wind's seed. Same seed, same wind, every run — so a render reproduces, and two instances decorrelate by seeding them differently. + + + The loop length in seconds this wind is blowing across — match it to the tap.bloom it feeds. It sizes the gusts and the calms. + + + Scheduler tick in milliseconds (0.1 to 100) — both how often the wind is advanced and its time resolution. Changing it re-seeds and restarts the wind. + + + + + + + + + + + + diff --git a/docs/tap.period.maxref.xml b/docs/tap.period.maxref.xml new file mode 100644 index 00000000..c6fc09de --- /dev/null +++ b/docs/tap.period.maxref.xml @@ -0,0 +1,55 @@ + + + + + How long until a set of free-running loops realigns + + The tap.period object reports the composite period of a set of loop lengths — their least common multiple, in seconds. It is the one thing a patch of independent tap.reel~ genuinely loses next to tap.airport~, since the arithmetic needs every length at once and so has nowhere to live inside a single reel. Send it a list of lengths in seconds and it reports the period. Each length is first quantized to whole samples exactly as a reel quantizes it, because the lcm is over sample counts and not over decimals — 6.3 and 7.7 seconds look commensurate written down and are nothing of the kind as sample counts. inf is a correct and desirable answer rather than an error: it means the period left the 64-bit range, so the piece will not repeat on any timescale you care about, which is the whole idea of the Music for Airports system. + + + + + 74 Objects + TapTools + Max + Utilities + + + + + + Loop lengths in seconds, or samples (n, n, ...). + + + + + + + The composite period in seconds; inf means it will not repeat. + + + + + + + A list of loop lengths in seconds. Each is floored at 0.5 s and rounded up to whole samples, exactly as tap.reel~ does, then the lcm of those sample counts is reported in seconds. + + + The same question asked in whole samples — what tap.reel~'s loopsamples message reports. Exact, since it skips the quantization entirely. + + + + + + + The sample rate the loops are running at. The lcm is over sample counts, so this must match the reels or the answer is for a different machine. Defaults to the current global rate. + + + + + + + + + + diff --git a/docs/tap.reel~.maxref.xml b/docs/tap.reel~.maxref.xml new file mode 100644 index 00000000..a5b484e4 --- /dev/null +++ b/docs/tap.reel~.maxref.xml @@ -0,0 +1,96 @@ + + + + + One free-running tape loop — the lane tap.airport~ is made of + + The tap.reel~ object is a single tape loop with one head that both plays and records. tap.airport~ is up to eight of these summed and nothing more, so several tap.reel~ at deliberately awkward lengths patched into a stereo sum are the Music for Airports system — with every loop reachable. That gets you what the monolith cannot: an insert on one phrase, a varispeed on one reel, more than eight loops, and tape bought per reel rather than eight worst-case reels whether you use them or not. The discipline is the same one loop deep: nothing resets the phase — not record, not a setter, not a length change (a splice: the head re-wraps modulo the new length and never rewinds). + + + + + 74 Objects + TapTools + Audio + Time + + + + + + Worst-case loop length in seconds (default 30). The reel is bought at DSP start — about 11 MB of tape at 48 kHz at the default; size it to the piece. + + + + + + + Audio input — punched onto the tape while recording. + + + + + + + Playback, left. + + + Playback, right. + + + Reports: phase (0..1), length (seconds), samples (n). + + + + + + + Audio to be punched onto the tape while the record gate is open. + + + + + + Punch the input onto the tape at wherever the head happens to be (1), or freeze the tape bit-exactly (0). Recording replaces — no overdub; there is no quantized punch-in, and the phase is never reset. + + + Report the head position as a fraction of the loop length (0..1) out the right outlet. + + + Report the loop length in samples, and in seconds as quantized to the sample grid, out the right outlet — what you need to work out when a set of reels realigns. + + + Erase the tape and rewind the head. Parameters are kept. + + + + + + + Worst-case loop length in seconds (set by the first object argument). Changing it erases the tape when the DSP chain restarts. + + + Loop length in seconds (0.5 up to maxloop). Across several reels the lengths are the score: keep them incommensurate and the coincidences never repeat. A change is a splice — the head re-wraps and never rewinds. It can click; splices do. + + + Linear playback level, slewed. Unclamped (negative flips polarity, like a mixer). + + + Equal-power pan, -1 (hard left) to 1 (hard right), slewed. Endpoints are exact: a hard-panned reel is bitwise absent from the far bus. + + + Playback darkening corner in Hz. A static tone, not generation loss (a frozen loop replays the same imprint every pass); at 20000 (the default) the stage is bypassed and playback is bit-transparent. + + + Anti-zipper ramp time for level, pan, and darken moves, in milliseconds (0 = instant). + + + + + + + + + + + diff --git a/docs/tap.scale.maxref.xml b/docs/tap.scale.maxref.xml new file mode 100644 index 00000000..13dbfc8f --- /dev/null +++ b/docs/tap.scale.maxref.xml @@ -0,0 +1,83 @@ + + + + + Snap a MIDI pitch to the nearest degree of a root and scale + + The tap.scale object is the entry quantizer behind tap.garden~'s promise that anything you plant sounds consonant. It is a nearest-allowed search: any non-empty scale has a degree within a tritone, so nothing is ever left unsnapped, and it looks up before it looks down when a pitch sits exactly between two degrees. A float in gives the snapped pitch out; a pitch and velocity pair passes the velocity through untouched, so it drops straight into a note chain. In a patched garden it goes between tap.gardener and tap.bloom, because the wind emits raw pitches on purpose. Quantizing happens at entry, the same contract the monolith keeps: changing root or scale re-pitches nothing already planted downstream, only what passes through next. + + + + + 74 Objects + TapTools + Max + Utilities + + + + + + Root pitch class, 0 to 11 (default 0 = C). + + + Scale preset (default 3, major pentatonic). + + + + + + + A MIDI pitch, or a (pitch, velocity) list. + + + + + + + The snapped pitch; velocity passed through when given a list. + + + + + + + + + + A MIDI pitch (fractional accepted) — the snapped pitch comes out. + + + + + + As float. + + + + + + + The pitch is snapped; the velocity, and anything after it, passes through untouched, so this drops into a note chain without rebuilding the message. + + + + + + + Root pitch class, 0 to 11 (0 = C). Values outside the range wrap. + + + 0 chromatic, 1 major, 2 minor, 3 major pentatonic (the default — it is the one where nothing can sound wrong), 4 minor pentatonic. Public-domain scale theory, not any app's preset list. + + + + + + + + + + + + diff --git a/help/tap.bloom.maxhelp b/help/tap.bloom.maxhelp new file mode 100644 index 00000000..b50f1519 --- /dev/null +++ b/help/tap.bloom.maxhelp @@ -0,0 +1,729 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "Every pass a bloom returns a step quieter and a step purer, until it drops below the floor and retires \u2014 so the population converges however fast you plant. It emits notes, not sound, so it will drive tap.chime~, makenote, poly~, or MIDI out just as happily.", + "digest": "The generative event loop \u2014 plant a note and it comes back", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.bloom", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The generative event loop \u2014 plant a note and it comes back", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Every pass a bloom returns a step quieter and a step purer, until it drops below the floor and retires \u2014 so the population converges however fast you plant. It emits notes, not sound, so it will drive tap.chime~, makenote, poly~, or MIDI out just as happily." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 320.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "plant a note \u2014 pitch and velocity:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "message", + "patching_rect": [ + 20.0, + 195.0, + 110.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "plant 60 0.9", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "message", + "patching_rect": [ + 20.0, + 222.0, + 110.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "plant 67 0.8", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "message", + "patching_rect": [ + 20.0, + 249.0, + 110.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "plant 72 0.7", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "comment", + "patching_rect": [ + 145.0, + 195.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "each one returns at its own place on the loop," + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "comment", + "patching_rect": [ + 145.0, + 215.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "every pass, until it fades below the floor" + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 290.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the shape of the fading:" + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "message", + "patching_rect": [ + 20.0, + 315.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "decay 0.85", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "message", + "patching_rect": [ + 115.0, + 315.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "decay 0.5", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "message", + "patching_rect": [ + 20.0, + 342.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "soften 0.9", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "message", + "patching_rect": [ + 115.0, + 342.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "loop 4.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "message", + "patching_rect": [ + 20.0, + 372.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "events", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "message", + "patching_rect": [ + 95.0, + 372.0, + 60.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "clear", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 410.0, + 300.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 2, + "text": "tap.bloom @loop 8. @decay 0.85", + "outlettype": [ + "", + "" + ] + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "newobj", + "patching_rect": [ + 340.0, + 440.0, + 150.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "print tap.bloom", + "outlettype": [] + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 445.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the notes come out here \u2014 send them anywhere:" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 470.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 2, + "text": "route note", + "outlettype": [ + "", + "" + ] + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 505.0, + 330.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 3, + "text": "tap.chime~ @decay 5. @spread 0.7", + "outlettype": [ + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "gain~", + "patching_rect": [ + 20.0, + 545.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "gain~", + "patching_rect": [ + 60.0, + 545.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "ezdac~", + "patching_rect": [ + 20.0, + 625.0, + 45.0, + 45.0 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 690.0, + 900.0, + 50.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: plant all three, then set decay 0.5 and watch the texture thin out fast; set decay 0.85 and it lingers. The arithmetic is exact \u2014 a plant at velocity v under decay d gets ceil(log(floor/v)/log(d)) strikes and not one more." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 1 + ], + "destination": [ + "obj-20", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-23", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-24", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 1 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-26", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-26", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.chime.voices~.maxhelp b/help/tap.chime.voices~.maxhelp new file mode 100644 index 00000000..dfb36e3d --- /dev/null +++ b/help/tap.chime.voices~.maxhelp @@ -0,0 +1,833 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "The same rack tap.chime~ wraps, tapped per voice: sixteen mono outlets, each carrying its tube dry, before the seat in the stereo image. The pool reassigns bells as it steals, so a slot holds whatever tube was last put there \u2014 ask `voice` which.", + "digest": "The wind-chime rack with every bell on its own outlet", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.chime.voices~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The wind-chime rack with every bell on its own outlet", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The same rack tap.chime~ wraps, tapped per voice: sixteen mono outlets, each carrying its tube dry, before the seat in the stereo image. The pool reassigns bells as it steals, so a slot holds whatever tube was last put there \u2014 ask `voice` which." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "strike some tubes \u2014 more than sixteen, so stealing happens:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 195.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "metro 400", + "outlettype": [ + "bang" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "toggle", + "patching_rect": [ + 120.0, + 195.0, + 22.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 225.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "random 30", + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 255.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "+ 52", + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 285.0, + 170.0, + 22.0 + ], + "numinlets": 4, + "numoutlets": 1, + "text": "pack note 60 0.8 0.9", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "comment", + "patching_rect": [ + 230.0, + 170.0, + 380.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "ask which tube a slot is holding, and what seat it would have had:" + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "message", + "patching_rect": [ + 230.0, + 195.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "voice 0", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "message", + "patching_rect": [ + 305.0, + 195.0, + 60.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "voice", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "message", + "patching_rect": [ + 375.0, + 195.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "voices", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "newobj", + "patching_rect": [ + 230.0, + 225.0, + 200.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "print tap.chime.voices~", + "outlettype": [] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 325.0, + 420.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 17, + "text": "tap.chime.voices~ @decay 5. @spread 0.7", + "outlettype": [ + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 365.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "voice 0 gets its own treatment \u2014 this is the whole point:" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 390.0, + 190.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.svf~ @mode 1 @freq 600.", + "outlettype": [ + "signal" + ] + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "gain~", + "patching_rect": [ + 20.0, + 425.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "comment", + "patching_rect": [ + 230.0, + 365.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the other fifteen, summed dry:" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "gain~", + "patching_rect": [ + 230.0, + 425.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "ezdac~", + "patching_rect": [ + 20.0, + 510.0, + 45.0, + 45.0 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 580.0, + 900.0, + 60.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: start the metro, then watch `voice` report a slot changing tube as the pool steals. The filter on voice 0 only ever touches whichever bell happens to be in that slot \u2014 which is a genuinely different instrument from filtering the whole rack, and something tap.chime~ cannot give you. Sum all sixteen back through their reported seat gains and you have tap.chime~ again, to the bit." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-9", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 16 + ], + "destination": [ + "obj-16", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-20", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 1 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 2 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 3 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 4 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 5 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 6 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 7 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 8 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 9 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 10 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 11 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 12 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 13 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 14 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 15 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-23", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-23", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.chime~.maxhelp b/help/tap.chime~.maxhelp new file mode 100644 index 00000000..d01defbc --- /dev/null +++ b/help/tap.chime~.maxhelp @@ -0,0 +1,786 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "Four decaying mode doublets per strike at the transverse ratios of the chosen material. Every pitch is its own tube: same flaws, same seat in the stereo field, every time. Voice stealing takes the quietest bell and re-aims it, so a steal glides rather than cuts.", + "digest": "A rack of sixteen wind chimes \u2014 tap.garden~'s voice", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.chime~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "A rack of sixteen wind chimes \u2014 tap.garden~'s voice", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Four decaying mode doublets per strike at the transverse ratios of the chosen material. Every pitch is its own tube: same flaws, same seat in the stereo field, every time. Voice stealing takes the quietest bell and re-aims it, so a steal glides rather than cuts." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "strike a tube \u2014 pitch, velocity, brightness:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "message", + "patching_rect": [ + 20.0, + 195.0, + 130.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "note 60 0.9 1.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "message", + "patching_rect": [ + 20.0, + 222.0, + 130.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "note 67 0.6 0.7", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "message", + "patching_rect": [ + 20.0, + 249.0, + 130.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "note 74 0.4 0.4", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "comment", + "patching_rect": [ + 160.0, + 195.0, + 320.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "fractional pitches are distinct tubes," + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "comment", + "patching_rect": [ + 160.0, + 215.0, + 320.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "with their own scatter and their own seat" + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 290.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "or drive it from anything that makes notes:" + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 315.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "metro 700", + "outlettype": [ + "bang" + ] + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 345.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "random 24", + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 375.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "+ 55", + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 405.0, + 160.0, + 22.0 + ], + "numinlets": 4, + "numoutlets": 1, + "text": "pack note 60 0.7 0.8", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "toggle", + "patching_rect": [ + 130.0, + 315.0, + 22.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "comment", + "patching_rect": [ + 350.0, + 290.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "what it is made of \u2014 the tuned bar is the other material:" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "message", + "patching_rect": [ + 350.0, + 315.0, + 100.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "material 0", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "message", + "patching_rect": [ + 350.0, + 342.0, + 100.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "material 1", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "message", + "patching_rect": [ + 350.0, + 369.0, + 100.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "spread 0.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "message", + "patching_rect": [ + 350.0, + 396.0, + 100.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "spread 1.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "message", + "patching_rect": [ + 350.0, + 430.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "voices", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "newobj", + "patching_rect": [ + 430.0, + 430.0, + 150.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "print tap.chime~", + "outlettype": [] + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 460.0, + 330.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 3, + "text": "tap.chime~ @decay 4. @spread 0.7", + "outlettype": [ + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "gain~", + "patching_rect": [ + 20.0, + 500.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "gain~", + "patching_rect": [ + 60.0, + 500.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-28", + "maxclass": "ezdac~", + "patching_rect": [ + 20.0, + 580.0, + 45.0, + 45.0 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-29", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 650.0, + 900.0, + 50.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: hit the three note messages fast enough to run past sixteen ringing bells and listen to the steal \u2014 it re-aims a chime rather than cutting one off. Then set spread 0 and hear the rack collapse to the centre." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-13", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-16", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 2 + ], + "destination": [ + "obj-24", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-26", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 1 + ], + "destination": [ + "obj-27", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-28", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-27", + 0 + ], + "destination": [ + "obj-28", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.gardener.maxhelp b/help/tap.gardener.maxhelp new file mode 100644 index 00000000..945f4fc4 --- /dev/null +++ b/help/tap.gardener.maxhelp @@ -0,0 +1,744 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "After idle seconds with nobody planting, this starts planting for you \u2014 gusts of up to five neighbouring tubes, then calms, at about one strike per loop pass. Below is the whole of tap.garden~, patched: wind into scale into ring into rack.", + "digest": "The idle wind that plants the garden when you stop", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.gardener", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The idle wind that plants the garden when you stop", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "After idle seconds with nobody planting, this starts planting for you \u2014 gusts of up to five neighbouring tubes, then calms, at about one strike per loop pass. Below is the whole of tap.garden~, patched: wind into scale into ring into rack." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 480.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the wind \u2014 raw pitches, because the scale is the patch's field, not the wind's:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 200.0, + 300.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.gardener @idle 3. @gust 0.5 @loop 8.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "comment", + "patching_rect": [ + 340.0, + 200.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "seed it and the same wind blows every run;" + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "comment", + "patching_rect": [ + 340.0, + 220.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "set idle 0 and the seed cannot matter at all" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "message", + "patching_rect": [ + 20.0, + 240.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "idle 3.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "message", + "patching_rect": [ + 95.0, + 240.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "idle 0.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "message", + "patching_rect": [ + 170.0, + 240.0, + 80.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "gust 0.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "message", + "patching_rect": [ + 255.0, + 240.0, + 80.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "gust 1.", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "message", + "patching_rect": [ + 20.0, + 267.0, + 80.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "seed 12345", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 310.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "give the wind a field to blow across:" + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 335.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 2, + "text": "route plant", + "outlettype": [ + "", + "" + ] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 365.0, + 220.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.scale @root 2 @scale 4", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 400.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "recirculate what it plants:" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 425.0, + 300.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 2, + "text": "tap.bloom @loop 8. @decay 0.85", + "outlettype": [ + "", + "" + ] + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 460.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 2, + "text": "route note", + "outlettype": [ + "", + "" + ] + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "comment", + "patching_rect": [ + 340.0, + 425.0, + 400.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "anything you plant by hand closes the idle gate:" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "message", + "patching_rect": [ + 340.0, + 450.0, + 110.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "plant 60 0.9", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 495.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "and sound it:" + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 520.0, + 330.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 3, + "text": "tap.chime~ @decay 5. @spread 0.7", + "outlettype": [ + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "gain~", + "patching_rect": [ + 20.0, + 560.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "gain~", + "patching_rect": [ + 60.0, + 560.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "ezdac~", + "patching_rect": [ + 20.0, + 640.0, + 45.0, + 45.0 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-28", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 700.0, + 900.0, + 60.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "This chain is tap.garden~ with the lid off. Leave it alone for three seconds and the wind starts; plant by hand and it drops and waits again. One difference worth knowing: here the ring runs on Max's scheduler, so returns land within a millisecond of the grid rather than exactly on it, as they do inside the monolith." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-16", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-20", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-24", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 1 + ], + "destination": [ + "obj-26", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-27", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-27", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.period.maxhelp b/help/tap.period.maxhelp new file mode 100644 index 00000000..cbeca66c --- /dev/null +++ b/help/tap.period.maxhelp @@ -0,0 +1,416 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "The least common multiple of a set of loop lengths, in seconds \u2014 the arithmetic tap.airport~ reports for its own lanes, for a patch of tap.reel~ that has no bank to ask. inf is a correct answer: the piece will not repeat.", + "digest": "How long until a set of free-running loops realigns", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.period", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "How long until a set of free-running loops realigns", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The least common multiple of a set of loop lengths, in seconds \u2014 the arithmetic tap.airport~ reports for its own lanes, for a patch of tap.reel~ that has no bank to ask. inf is a correct answer: the piece will not repeat." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "two lengths that share a factor once they are sample counts:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "message", + "patching_rect": [ + 20.0, + 195.0, + 110.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "0.5 0.625", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "comment", + "patching_rect": [ + 145.0, + 195.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "\u2014 24000 and 30000 samples, so they realign at 2.5 s" + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 240.0, + 460.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the terminal: seven airport-scale lengths in awkward ratios \u2014" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "message", + "patching_rect": [ + 20.0, + 265.0, + 320.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "17.8 19.1 21.3 23.9 26.2 28.7 30.9", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "comment", + "patching_rect": [ + 350.0, + 265.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "\u2014 inf, which is the whole idea" + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 310.0, + 460.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "or ask in whole samples, what tap.reel~'s loopsamples reports:" + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "message", + "patching_rect": [ + 20.0, + 335.0, + 220.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "samples 24000 30000 41000", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 380.0, + 220.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.period @sr 48000", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "flonum", + "patching_rect": [ + 20.0, + 415.0, + 100.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 2, + "outlettype": [ + "", + "bang" + ] + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "newobj", + "patching_rect": [ + 130.0, + 415.0, + 150.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "print tap.period", + "outlettype": [] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 470.0, + 900.0, + 60.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The lcm is over SAMPLE COUNTS, not decimals \u2014 which is why lengths that look commensurate written down usually are not. @sr must match the rate your reels are actually running at, or the quantization, and so the answer, is for a different machine. Feed it the same lengths you gave a row of tap.reel~ and it tells you when the whole system comes back around; with well-chosen lengths, it will not." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-16", + 0 + ] + } + } + ] + } +} diff --git a/help/tap.reel~.maxhelp b/help/tap.reel~.maxhelp new file mode 100644 index 00000000..9905c3c8 --- /dev/null +++ b/help/tap.reel~.maxhelp @@ -0,0 +1,836 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "Three reels at deliberately awkward lengths, summed. That is tap.airport~, patched \u2014 and because each loop is its own object you can reach into one: insert a filter, run one through tap.discreet~, or give one a length nothing else shares.", + "digest": "One free-running tape loop \u2014 the lane tap.airport~ is made of", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.reel~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "One free-running tape loop \u2014 the lane tap.airport~ is made of", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Three reels at deliberately awkward lengths, summed. That is tap.airport~, patched \u2014 and because each loop is its own object you can reach into one: insert a filter, run one through tap.discreet~, or give one a length nothing else shares." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 320.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a phrase source to punch onto the reels:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 195.0, + 110.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "cycle~ 330", + "outlettype": [ + "signal" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "gain~", + "patching_rect": [ + 20.0, + 225.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 300.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "three lanes \u2014 the lengths are the score, keep them incommensurate:" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 325.0, + 300.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 3, + "text": "tap.reel~ 12. @length 6.3 @pan -0.7", + "outlettype": [ + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 355.0, + 300.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 3, + "text": "tap.reel~ 12. @length 7.7 @pan 0.", + "outlettype": [ + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 385.0, + 300.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 3, + "text": "tap.reel~ 12. @length 9.1 @pan 0.7", + "outlettype": [ + "signal", + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "comment", + "patching_rect": [ + 350.0, + 300.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the record gates \u2014 one per reel:" + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "toggle", + "patching_rect": [ + 350.0, + 325.0, + 22.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "message", + "patching_rect": [ + 378.0, + 325.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "record $1", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "toggle", + "patching_rect": [ + 350.0, + 355.0, + 22.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "message", + "patching_rect": [ + 378.0, + 355.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "record $1", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "toggle", + "patching_rect": [ + 350.0, + 385.0, + 22.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ] + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "message", + "patching_rect": [ + 378.0, + 385.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "record $1", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "comment", + "patching_rect": [ + 460.0, + 325.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "recording starts wherever the head is \u2014" + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "comment", + "patching_rect": [ + 460.0, + 345.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "there is no downbeat, and there never was" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "comment", + "patching_rect": [ + 560.0, + 385.0, + 340.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "ask a reel where its head is, or how long it really is:" + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "message", + "patching_rect": [ + 560.0, + 410.0, + 60.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "phase", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "message", + "patching_rect": [ + 625.0, + 410.0, + 100.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "loopsamples", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "newobj", + "patching_rect": [ + 560.0, + 440.0, + 150.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "print tap.reel~", + "outlettype": [] + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 430.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the sum \u2014 this is the whole of tap.airport~:" + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "gain~", + "patching_rect": [ + 20.0, + 455.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-28", + "maxclass": "gain~", + "patching_rect": [ + 60.0, + 455.0, + 30.0, + 60.0 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ] + } + }, + { + "box": { + "id": "obj-29", + "maxclass": "ezdac~", + "patching_rect": [ + 20.0, + 530.0, + 45.0, + 45.0 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-30", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 600.0, + 900.0, + 60.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: open the first record gate, let a phrase go by, close it; repeat on the other two. The three lengths never line up, so the texture keeps rearranging itself. Then do what the monolith cannot let you do \u2014 put a filter between one reel and the sum, or feed one reel a different source entirely." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-27", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 1 + ], + "destination": [ + "obj-28", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-27", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 1 + ], + "destination": [ + "obj-28", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-27", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 1 + ], + "destination": [ + "obj-28", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 2 + ], + "destination": [ + "obj-25", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-27", + 0 + ], + "destination": [ + "obj-29", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-28", + 0 + ], + "destination": [ + "obj-29", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.scale.maxhelp b/help/tap.scale.maxhelp new file mode 100644 index 00000000..39b014ec --- /dev/null +++ b/help/tap.scale.maxhelp @@ -0,0 +1,569 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 760.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Arial", + "gridonopen": 1, + "gridsize": [ + 15.0, + 15.0 + ], + "gridsnaponopen": 1, + "objectsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "lefttoolbarpinned": 0, + "toptoolbarpinned": 0, + "righttoolbarpinned": 0, + "bottomtoolbarpinned": 0, + "toolbars_unpinned_last_save": 0, + "tallnewobj": 0, + "boxanimatetime": 200, + "enablehscroll": 1, + "enablevscroll": 1, + "devicewidth": 0.0, + "description": "The entry quantizer behind tap.garden~'s promise that anything you plant sounds consonant. A float in gives the snapped pitch out; a pitch/velocity pair passes the velocity through untouched, so it drops straight into a note chain.", + "digest": "Snap a MIDI pitch to the nearest degree of a root and scale", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20.0, + 15.0, + 640.0, + 95.0 + ], + "numinlets": 1, + "numoutlets": 0, + "mode": 0, + "proportion": 0.5, + "bgmode": 0, + "bgfillcolor_type": "gradient", + "bgfillcolor_color1": [ + 0.29, + 0.31, + 0.373, + 1.0 + ], + "bgfillcolor_color2": [ + 0.2, + 0.2, + 0.26, + 1.0 + ], + "bgfillcolor_angle": 270.0 + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 20.0, + 400.0, + 36.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.scale", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30.0, + 55.0, + 620.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Snap a MIDI pitch to the nearest degree of a root and scale", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680.0, + 20.0, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 120.0, + 900.0, + 34.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The entry quantizer behind tap.garden~'s promise that anything you plant sounds consonant. A float in gives the snapped pitch out; a pitch/velocity pair passes the velocity through untouched, so it drops straight into a note chain." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 170.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "drag through the chromatic scale:" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "flonum", + "patching_rect": [ + 20.0, + 195.0, + 60.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 2, + "outlettype": [ + "", + "bang" + ] + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 230.0, + 200.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.scale @root 0 @scale 3", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "flonum", + "patching_rect": [ + 20.0, + 265.0, + 60.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 2, + "outlettype": [ + "", + "bang" + ] + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "comment", + "patching_rect": [ + 95.0, + 265.0, + 400.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "\u2014 only the degrees of the scale ever come out" + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "comment", + "patching_rect": [ + 260.0, + 170.0, + 300.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the field:" + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "message", + "patching_rect": [ + 260.0, + 195.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "root 0", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "message", + "patching_rect": [ + 335.0, + 195.0, + 70.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "root 7", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "message", + "patching_rect": [ + 260.0, + 222.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "scale 0", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "message", + "patching_rect": [ + 355.0, + 222.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "scale 3", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "message", + "patching_rect": [ + 260.0, + 249.0, + 90.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "scale 4", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "comment", + "patching_rect": [ + 360.0, + 249.0, + 320.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "0 chromatic, 1 major, 2 minor," + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "comment", + "patching_rect": [ + 360.0, + 269.0, + 320.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "3 major pentatonic, 4 minor pentatonic" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 320.0, + 420.0, + 20.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "in a note chain the velocity rides through untouched:" + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "message", + "patching_rect": [ + 20.0, + 345.0, + 130.0, + 22.0 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "63.5 0.8", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 380.0, + 200.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.scale @root 2 @scale 4", + "outlettype": [ + "" + ] + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "newobj", + "patching_rect": [ + 20.0, + 415.0, + 130.0, + 22.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "print tap.scale", + "outlettype": [] + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "comment", + "patching_rect": [ + 20.0, + 470.0, + 900.0, + 50.0 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Quantizing happens at entry, which is the same contract tap.garden~ keeps: changing the root or the scale re-pitches nothing already planted downstream, only what passes through next. Put this between tap.gardener and tap.bloom to turn raw wind into music." + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-9", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-21", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-22", + 0 + ] + } + } + ] + } +} diff --git a/runtime-tests/README.md b/runtime-tests/README.md index 893c3a08..b155b49c 100644 --- a/runtime-tests/README.md +++ b/runtime-tests/README.md @@ -31,6 +31,34 @@ instead of `snapshot~`), `test.equals` (tolerant float compare), `max-test/patchers/` for the upstream reference examples (the audio example `2087-bitxor~.maxtest.maxpat` is the topology our generator mirrors). +## Null tests: proving a patch is an object + +`tap.airport~` and `tap.garden~` were split into components you can patch +(`tap.reel~`; `tap.chime~` / `tap.bloom` / `tap.scale` / `tap.gardener`). That +split makes a claim — *these parts wired together ARE that object* — and a claim +like that should be measured rather than asserted. + +`patchers/tap.reel~-is-airport.maxtest.maxpat` measures it: one `sig~` drives +both a `tap.airport~` and three `tap.reel~` at the same lengths, the component +lanes are summed, the monolith is subtracted from that sum, and the result must +be zero. The record gates open while the DSP chain is still **off**, which is +what keeps the two sides sample-aligned — no audio has been processed yet, so +every tape head is still at zero when the gates open. + +The kernel repo already pins the same identity bitwise in C++ +(`airport_test.cpp`, "standalone lanes summed are the bank, bitwise"). This +patcher covers what that test cannot see: the wrappers. A mis-forwarded +attribute, or a `dspsetup` that re-prepares one side and not the other, would +pass in the kernel and fail here. + +**There is deliberately no garden equivalent.** `tap.bloom` and `tap.gardener` +run on Max's scheduler rather than the audio clock, so their returns land within +an `@interval` tick of the grid instead of exactly on it. The patched garden is +the same machine but not the same sample stream, and a null test would be +asserting something that is not true. That identity is pinned in the kernel +instead (`garden_test.cpp`, "the bed is exactly its components wired together, +bitwise"), where both sides can share one clock. + ## First-time setup (one-time, on your Mac) The harness needs the **Max application** installed (it can't run in a headless diff --git a/runtime-tests/make_maxtest.py b/runtime-tests/make_maxtest.py index c7dfc3be..8a48ef38 100644 --- a/runtime-tests/make_maxtest.py +++ b/runtime-tests/make_maxtest.py @@ -8,7 +8,7 @@ headless (no Max), each new patcher should be opened once in Max to confirm the timing/assert wiring before trusting it in CI. See runtime-tests/README.md. -Two topologies are supported: +Three topologies are supported: audio_test(...) sig~ IN -> OBJECT -> round~ tol -> test.sample~ -> test.equals EXPECTED -> test.assert NAME @@ -18,8 +18,14 @@ control_test(...) loadbang -> delay -> trigger MSG -> OBJECT -> test.equals EXPECTED -> test.assert NAME -> test.terminate + null_test(...) sig~ IN -> MONOLITH ------------------\ + sig~ IN -> COMPONENT chain -> +~ -> -~ -> abs~ -> ... -> assert 0 + For the objects that were split into components: proves the patch + and the object are the same machine by subtracting one from the + other. Audio-clock objects only — see the note on the garden below. + Usage: python3 make_maxtest.py # (re)generates the bundled examples - # or import and call audio_test()/control_test() from your own script. + # or import and call audio_test()/control_test()/null_test() from your own script. """ import json @@ -86,6 +92,85 @@ def _write(name, doc): return path +def null_test(filename, monolith, components, assert_name, input_value="0.5", + setup_messages=(), settle_ms=1500, tolerance=0.000001, description="", + monolith_outlets=3, component_outlets=3): + """A null test: drive a monolith and a hand-wired chain of its components from one + source, subtract the two left busses, and assert the difference is zero. + + This is how "the patch IS the object" stops being a claim and becomes a measurement. + It only applies where both sides run on the audio clock — see the note in + runtime-tests/README.md about why there is no garden equivalent. + + `setup_messages` are (message_text, [target_indices]) pairs sent BEFORE DSP starts, + where index 0 is the monolith and 1..n are the components. Sending them with the DSP + chain off is what keeps the two sides sample-aligned: no audio has been processed, so + every head is still at zero when the gates open. + """ + b = _Builder() + loadbang = b.box("loadbang", x=40, y=20, w=70) + + # Phase 1 (immediately on load, DSP still off): open the record gates. + setup_trigger = b.box("t b b", numoutlets=2, outlettype=["bang", "bang"], x=40, y=60, w=70) + + src = b.box(f"sig~ {input_value}", numoutlets=1, outlettype=["signal"], x=300, y=100, w=110) + mono = b.box(monolith, numinlets=1, numoutlets=monolith_outlets, + outlettype=["signal"] * (monolith_outlets - 1) + [""], x=300, y=150, w=380) + + comp_ids = [] + for i, text in enumerate(components): + comp_ids.append(b.box(text, numinlets=1, numoutlets=component_outlets, + outlettype=["signal"] * (component_outlets - 1) + [""], + x=300, y=200 + 30 * i, w=380)) + + # Sum the component lanes, then subtract the monolith's left bus from the sum. + summed = b.box("+~", numinlets=2, numoutlets=1, outlettype=["signal"], + x=300, y=210 + 30 * len(components), w=60) + diff = b.box("-~", numinlets=2, numoutlets=1, outlettype=["signal"], + x=300, y=250 + 30 * len(components), w=60) + mag = b.box("abs~", numoutlets=1, outlettype=["signal"], x=300, y=290 + 30 * len(components), w=60) + rnd = b.box(f"round~ {tolerance}", numoutlets=1, outlettype=["signal"], + x=300, y=330 + 30 * len(components), w=110) + sample = b.box("test.sample~", numoutlets=1, outlettype=[""], x=300, y=370 + 30 * len(components), w=90) + equals = b.box("test.equals 0.", x=300, y=410 + 30 * len(components), w=150) + assert_ = b.box(f"test.assert {assert_name}", x=300, y=450 + 30 * len(components), w=220) + + # Phase 2 (after the gates are open): start DSP, let it settle, then sample once. + delay_dsp = b.box("delay 20", numoutlets=1, outlettype=["bang"], x=40, y=110, w=70) + msg_on = b.box("1", maxclass="message", x=40, y=150, w=30) + dac = b.box("dac~", numinlets=2, numoutlets=0, outlettype=[], x=40, y=190, w=45) + delay_settle = b.box(f"delay {settle_ms}", numoutlets=1, outlettype=["bang"], x=120, y=150, w=90) + terminate = b.box("test.terminate", x=120, y=230, w=110) + + b.link(loadbang, 0, setup_trigger, 0) + # right outlet fires first in a trigger: the gates open before DSP is asked for + for text, targets in setup_messages: + msg = b.box(text, maxclass="message", x=700, y=100 + 30 * len(b.boxes) % 300, w=110) + b.link(setup_trigger, 1, msg, 0) + for t in targets: + b.link(msg, 0, mono if t == 0 else comp_ids[t - 1], 0) + + b.link(setup_trigger, 0, delay_dsp, 0) + b.link(delay_dsp, 0, msg_on, 0) + b.link(msg_on, 0, dac, 0) + b.link(delay_dsp, 0, delay_settle, 0) + b.link(delay_settle, 0, sample, 0) + + b.link(src, 0, mono, 0) + for c in comp_ids: + b.link(src, 0, c, 0) + b.link(c, 0, summed, 0) + b.link(mono, 0, diff, 1) + b.link(summed, 0, diff, 0) + b.link(diff, 0, mag, 0) + b.link(mag, 0, rnd, 0) + b.link(rnd, 0, sample, 0) + b.link(sample, 0, equals, 0) + b.link(equals, 0, assert_, 0) + b.link(assert_, 0, terminate, 0) + return _write(filename, b.patcher(description)) + + def audio_test(filename, object_text, input_value, expected, assert_name, tolerance=0.000001, description="", numinlets=1, numoutlets=1): """A signal-rate test: feed `sig~ input_value` into `object_text`, sample the @@ -213,3 +298,39 @@ def control_test(filename, object_text, trigger, expected, assert_name, assert_name="tap.overdrive~-bypass-passthrough", description="tap.overdrive~ @bypass 1: sig~ 0.5 passes unprocessed.", ) + # The null test: three tap.reel~ summed must BE tap.airport~. The kernel suite + # already pins this bitwise (airport_test.cpp, "standalone lanes summed are the + # bank, bitwise"); this is the same claim made against the real externals loaded + # in Max, which is where wrapper-level mistakes — a mis-forwarded attribute, a + # dspsetup that re-prepares one side and not the other — would show up instead. + # + # All three lanes are hard-panned left so the left bus carries the whole sum, and + # the record gates open while the DSP chain is still off, which is what keeps the + # two sides sample-aligned: no audio has been processed, so every head is at zero. + # + # There is deliberately no tap.garden~ equivalent. tap.bloom and tap.gardener run + # on Max's scheduler rather than the audio clock, so their returns land within a + # tick of the grid instead of exactly on it — the patched garden is the same + # machine but not the same sample stream, and a null test would be asserting + # something untrue. That claim is pinned in the kernel instead (garden_test.cpp, + # "the bed is exactly its components wired together, bitwise"). + null_test( + "tap.reel~-is-airport.maxtest.maxpat", + monolith=("tap.airport~ 12. @loops 3 @lengths 6.3 7.7 9.1 " + "@pans -1. -1. -1. @levels 1. 1. 1. @smooth 0."), + components=[ + "tap.reel~ 12. @length 6.3 @pan -1. @level 1. @smooth 0.", + "tap.reel~ 12. @length 7.7 @pan -1. @level 1. @smooth 0.", + "tap.reel~ 12. @length 9.1 @pan -1. @level 1. @smooth 0.", + ], + assert_name="tap.reel~-sum-equals-tap.airport~", + input_value="0.5", + setup_messages=[ + ("record 0 1", [0]), + ("record 1 1", [0]), + ("record 2 1", [0]), + ("record 1", [1, 2, 3]), + ], + description=("Null test: three tap.reel~ summed against tap.airport~ with the same " + "lengths. The difference must be zero — the patch IS the object."), + ) diff --git a/runtime-tests/patchers/tap.reel~-is-airport.maxtest.maxpat b/runtime-tests/patchers/tap.reel~-is-airport.maxtest.maxpat new file mode 100644 index 00000000..7ca4ceec --- /dev/null +++ b/runtime-tests/patchers/tap.reel~-is-airport.maxtest.maxpat @@ -0,0 +1,891 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 7, + "minor": 0, + "revision": 4, + "architecture": "x64", + "modernui": 1 + }, + "rect": [ + 66.0, + 79.0, + 1070.0, + 480.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Helvetica Neue Light", + "gridonopen": 1, + "gridsize": [ + 5.0, + 5.0 + ], + "gridsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "description": "Null test: three tap.reel~ summed against tap.airport~ with the same lengths. The difference must be zero \u2014 the patch IS the object.", + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 40, + 20, + 70, + 22.0 + ], + "text": "loadbang" + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 2, + "outlettype": [ + "bang", + "bang" + ], + "patching_rect": [ + 40, + 60, + 70, + 22.0 + ], + "text": "t b b" + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 100, + 110, + 22.0 + ], + "text": "sig~ 0.5" + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 3, + "outlettype": [ + "signal", + "signal", + "" + ], + "patching_rect": [ + 300, + 150, + 380, + 22.0 + ], + "text": "tap.airport~ 12. @loops 3 @lengths 6.3 7.7 9.1 @pans -1. -1. -1. @levels 1. 1. 1. @smooth 0." + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 3, + "outlettype": [ + "signal", + "signal", + "" + ], + "patching_rect": [ + 300, + 200, + 380, + 22.0 + ], + "text": "tap.reel~ 12. @length 6.3 @pan -1. @level 1. @smooth 0." + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 3, + "outlettype": [ + "signal", + "signal", + "" + ], + "patching_rect": [ + 300, + 230, + 380, + 22.0 + ], + "text": "tap.reel~ 12. @length 7.7 @pan -1. @level 1. @smooth 0." + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 3, + "outlettype": [ + "signal", + "signal", + "" + ], + "patching_rect": [ + 300, + 260, + 380, + 22.0 + ], + "text": "tap.reel~ 12. @length 9.1 @pan -1. @level 1. @smooth 0." + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "newobj", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 300, + 60, + 22.0 + ], + "text": "+~" + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "newobj", + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 340, + 60, + 22.0 + ], + "text": "-~" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 380, + 60, + 22.0 + ], + "text": "abs~" + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 420, + 110, + 22.0 + ], + "text": "round~ 1e-06" + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 460, + 90, + 22.0 + ], + "text": "test.sample~" + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 500, + 150, + 22.0 + ], + "text": "test.equals 0." + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 540, + 220, + 22.0 + ], + "text": "test.assert tap.reel~-sum-equals-tap.airport~" + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "bang" + ], + "patching_rect": [ + 40, + 110, + 70, + 22.0 + ], + "text": "delay 20" + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 40, + 150, + 30, + 22.0 + ], + "text": "1" + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "newobj", + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "patching_rect": [ + 40, + 190, + 45, + 22.0 + ], + "text": "dac~" + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "bang" + ], + "patching_rect": [ + 120, + 150, + 90, + 22.0 + ], + "text": "delay 1500" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 120, + 230, + 110, + 22.0 + ], + "text": "test.terminate" + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 700, + 370, + 110, + 22.0 + ], + "text": "record 0 1" + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 700, + 100, + 110, + 22.0 + ], + "text": "record 1 1" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 700, + 130, + 110, + 22.0 + ], + "text": "record 2 1" + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 700, + 160, + 110, + 22.0 + ], + "text": "record 1" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-1", + 0 + ], + "destination": [ + "obj-2", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 1 + ], + "destination": [ + "obj-20", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-4", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 1 + ], + "destination": [ + "obj-21", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-4", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 1 + ], + "destination": [ + "obj-22", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-4", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 1 + ], + "destination": [ + "obj-23", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-5", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-6", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-7", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 0 + ], + "destination": [ + "obj-15", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-16", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-17", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-18", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-12", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-3", + 0 + ], + "destination": [ + "obj-4", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-3", + 0 + ], + "destination": [ + "obj-5", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-5", + 0 + ], + "destination": [ + "obj-8", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-3", + 0 + ], + "destination": [ + "obj-6", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-6", + 0 + ], + "destination": [ + "obj-8", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-3", + 0 + ], + "destination": [ + "obj-7", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-4", + 0 + ], + "destination": [ + "obj-9", + 1 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-9", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-13", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-14", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-19", + 0 + ], + "hidden": 0, + "disabled": 0 + } + } + ] + } +} diff --git a/source/projects/tap.bloom/CMakeLists.txt b/source/projects/tap.bloom/CMakeLists.txt new file mode 100644 index 00000000..bb667889 --- /dev/null +++ b/source/projects/tap.bloom/CMakeLists.txt @@ -0,0 +1,15 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/garden.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.bloom/tap.bloom.cpp b/source/projects/tap.bloom/tap.bloom.cpp new file mode 100644 index 00000000..30f00272 --- /dev/null +++ b/source/projects/tap.bloom/tap.bloom.cpp @@ -0,0 +1,186 @@ +/// @file +/// @brief tap.bloom — the event ring tap.garden~ recirculates its notes on. +/// @details Plant a note and it comes back at its own position on a loop, every pass a step +/// quieter (`decay`) and a step purer (`soften`), until it falls below `floor` and +/// retires. That is the whole of tap::tools::garden::ring (taptools/garden.h), and it +/// is the piece of tap.garden~ with the most reach, because it knows nothing about +/// chimes: it emits notes, and what sounds them is yours to choose. Point it at +/// tap.chime~ and you have the garden's voice; point it at makenote, poly~, a +/// sampler, or MIDI out and you have the same generative principle driving something +/// else entirely. +/// +/// The stabiliser and its theorem: a plant at velocity v under decay d retires after +/// exactly ceil(log(floor/v)/log(d)) strikes, so the live population converges no +/// matter how fast you plant. Sixty-four blooms fit; a full ring retires its OLDEST to +/// make room, because a touch must always speak and the oldest is the quietest. +/// +/// Honest limit — the one real cost of patching this rather than using tap.garden~: +/// the ring runs on MAX'S SCHEDULER here, not the audio clock, so returns land within +/// an @interval tick (1 ms by default) rather than on the exact sample. Inside +/// tap.garden~ the same ring is sample-accurate. For loops measured in seconds this is +/// inaudible, but it is a difference, and it is why the null test against tap.garden~ +/// is run on the kernel rather than on the patch. +/// +/// @interval also sets the ring's time resolution: the loop is counted in ticks of +/// that length. Changing it re-prepares the ring, which uproots everything. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::garden; + +class bloom : public object { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::ring m_ring; + std::array m_fired{}; + // Attributes are constructed in declaration order and their setters run as they go, so + // reprepare() must not read attributes that do not exist yet. The constructor flips this. + bool m_ready{false}; + + public: + MIN_DESCRIPTION{"The generative event loop from tap.garden~, on its own. Plant a note and it " + "returns at its own loop position every pass, a step quieter and a step " + "purer, until it falls below the floor and retires — so the live population " + "converges however fast you plant. It emits notes rather than sound, so it " + "will drive tap.chime~, makenote, poly~, or MIDI out equally well. Note that " + "the loop runs on Max's scheduler here: returns land within an interval tick " + "rather than on the exact sample, as they do inside tap.garden~."}; + MIN_TAGS{"time"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.garden~, tap.chime~, tap.gardener, tap.scale, seq, makenote"}; + + inlet<> m_in{this, "(list) plant , or a bare pitch/velocity pair"}; + outlet<> m_out_note{this, "(list) note — a bloom firing"}; + outlet<> m_out_info{this, "(list) reports: events "}; + + attribute interval{this, "interval", 1.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.1, 100.0); + reprepare(v); + return {v}; + }}, + description{"Scheduler tick in ms (0.1..100). This is both how often the ring is " + "advanced and its time resolution — the loop is counted in ticks of " + "this length. Changing it re-prepares the ring, which uproots every " + "live bloom."}}; + + attribute loop{this, "loop", kernel::k_default_loop_seconds, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_loop_seconds, + kernel::k_max_loop_seconds); + m_ring.set_loop_seconds(v); + return {v}; + }}, + description{"Loop length in seconds (0.25..120). Instant — the loop is a counter, so " + "live blooms keep their positions modulo the new length."}}; + + attribute decay{this, "decay", kernel::k_default_decay, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_ring.set_decay(v); + return {v}; + }}, + description{"Velocity multiplier per pass (0..1) — the stabiliser. A plant at velocity " + "v retires after exactly ceil(log(floor/v)/log(decay)) strikes."}}; + + attribute soften{this, "soften", kernel::k_default_soften, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_ring.set_soften(v); + return {v}; + }}, + description{"Brightness multiplier per pass (0..1): each return is purer than the " + "last, so a bloom does not merely fade, it loses its edge first."}}; + + attribute floor{this, "floor", kernel::k_default_floor, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 1e-4, 1.0); + m_ring.set_floor(v); + return {v}; + }}, + description{"Retirement threshold (1e-4..1): a bloom whose velocity falls below this " + "is done and leaves the ring."}}; + + attribute brightness{this, "brightness", kernel::k_default_brightness, + setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_ring.set_brightness(v); + return {v}; + }}, + description{"The brightness a new plant starts at (0..1); it softens from there."}}; + + message<> plant{this, "plant", + "plant : 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 tap.scale " + "in front if you want a scale.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[1]) > 0.0) { + m_ring.plant(args[0], args[1]); + } + return {}; + }}; + + message<> list{this, "list", "A bare pair is the same as plant.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[1]) > 0.0) { + m_ring.plant(args[0], args[1]); + } + return {}; + }}; + + message<> events{this, "events", "Report how many blooms are live, out the right outlet.", + MIN_FUNCTION{ + m_out_info.send("events", m_ring.active_events()); + return {}; + }}; + + message<> clear{this, "clear", "Uproot everything: kill every bloom and rewind the loop. Parameters are kept.", + MIN_FUNCTION{ + m_ring.clear(); + return {}; + }}; + + // The ring is advanced from Max's scheduler, one kernel step per tick — see the honest + // limit in the file header. Output is a list per strike, newest last. + timer<> m_clock{this, + MIN_FUNCTION{ + const int n = m_ring.due(m_fired.data(), kernel::k_max_events); + m_ring.step(); + for (int i = 0; i < n; ++i) { + const kernel::strike& s = m_fired[static_cast(i)]; + m_out_note.send("note", s.pitch, s.velocity, s.brightness); + } + m_clock.delay(interval); + return {}; + }}; + + bloom(const atoms& args = {}) { + if (!args.empty() && static_cast(args[0]) > 0.0) { + loop = args[0]; + } + m_ready = true; + reprepare(interval); + m_clock.delay(interval); + } + + private: + /// The ring counts in ticks, so its "sample rate" is the tick rate. Re-preparing clears it — + /// there is no way to rescale live bloom positions honestly when the grid itself changes. + void reprepare(double interval_ms) { + if (!m_ready) { + return; // still building the attributes; the constructor prepares once at the end + } + m_ring.prepare(1000.0 / interval_ms); + m_ring.set_loop_seconds(loop); + m_ring.set_decay(decay); + m_ring.set_soften(soften); + m_ring.set_floor(floor); + m_ring.set_brightness(brightness); + } +}; + +MIN_EXTERNAL(bloom); diff --git a/source/projects/tap.bloom/tap.bloom_test.cpp b/source/projects/tap.bloom/tap.bloom_test.cpp new file mode 100644 index 00000000..97db3023 --- /dev/null +++ b/source/projects/tap.bloom/tap.bloom_test.cpp @@ -0,0 +1,55 @@ +/// @file +/// @brief Unit tests for tap.bloom. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.bloom.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.bloom instantiates with the kernel's defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + bloom& my_object = an_instance; + + THEN("the ring's parameters match the kernel") { + REQUIRE(static_cast(my_object.loop) == kernel::k_default_loop_seconds); + REQUIRE(static_cast(my_object.decay) == kernel::k_default_decay); + REQUIRE(static_cast(my_object.soften) == kernel::k_default_soften); + REQUIRE(static_cast(my_object.floor) == kernel::k_default_floor); + } + } +} + +SCENARIO("tap.bloom clamps its parameters to the kernel's ranges") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + bloom& my_object = an_instance; + + THEN("the loop is held between the kernel's bounds") { + my_object.loop = 0.01; + REQUIRE(static_cast(my_object.loop) == kernel::k_min_loop_seconds); + my_object.loop = 9999.0; + REQUIRE(static_cast(my_object.loop) == kernel::k_max_loop_seconds); + } + THEN("decay and soften are per-pass multipliers, so they stay in 0..1") { + my_object.decay = 3.0; + REQUIRE(static_cast(my_object.decay) == 1.0); + my_object.soften = -1.0; + REQUIRE(static_cast(my_object.soften) == 0.0); + } + THEN("the floor never reaches zero, so a bloom always retires") { + my_object.floor = 0.0; + REQUIRE(static_cast(my_object.floor) > 0.0); + } + THEN("the scheduler tick stays in a range Max can actually service") { + my_object.interval = 0.0; + REQUIRE(static_cast(my_object.interval) == 0.1); + my_object.interval = 1e6; + REQUIRE(static_cast(my_object.interval) == 100.0); + } + } +} diff --git a/source/projects/tap.chime.voices_tilde/CMakeLists.txt b/source/projects/tap.chime.voices_tilde/CMakeLists.txt new file mode 100644 index 00000000..3acfc97a --- /dev/null +++ b/source/projects/tap.chime.voices_tilde/CMakeLists.txt @@ -0,0 +1,16 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/garden.h + ${TAPTOOLS_KERNEL_DIR}/include/taptools/swing_vca.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.chime.voices_tilde/tap.chime.voices_tilde.cpp b/source/projects/tap.chime.voices_tilde/tap.chime.voices_tilde.cpp new file mode 100644 index 00000000..ac75543a --- /dev/null +++ b/source/projects/tap.chime.voices_tilde/tap.chime.voices_tilde.cpp @@ -0,0 +1,201 @@ +/// @file +/// @brief tap.chime.voices~ — the wind-chime rack with every bell on its own outlet. +/// @details The same tap::tools::garden::rack that tap.chime~ wraps, tapped per voice instead +/// of summed to stereo: sixteen mono signal outlets, one per bell, carrying each tube +/// RAW — before its seat in the stereo image is applied. Put them back through the +/// seats (which `voice` reports) and you have tap.chime~ again to the bit, pinned in +/// the kernel by "the per-voice taps summed through their seats are the stereo rack". +/// The point, of course, is that you do not have to: place, filter, gate, or send each +/// tube wherever you like. +/// +/// Why a separate object rather than a mode on tap.chime~: outlet count is fixed when +/// a Min object is constructed, so it cannot be switched at runtime. And why sixteen +/// discrete outlets rather than one multichannel outlet: min-api's mc support is +/// inlet-side only — it sets Z_MC_INLETS and provides no multichanneloutputs, which is +/// the method Max requires before an external may declare a variable-channel mc outlet. +/// If that lands in a later Min, this object is the natural place to adopt it. +/// +/// The pool reassigns bells as it steals, so slot 3 is whatever tube was last put +/// there, not a fixed pitch. `voice ` reports which tube a slot is holding, how +/// loudly, and the seat gains tap.chime~ would have used — enough to follow a strike +/// to its outlet, or to rebuild the rack image downstream. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::garden; + +class chimevoices : public object, public sample_operator<0, kernel::k_voices> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::rack m_rack; + + public: + MIN_DESCRIPTION{"The tap.chime~ rack with every bell on its own outlet: sixteen mono signals, " + "one per voice, each carrying its tube dry — before the seat in the stereo " + "image is applied. Place, filter, or gate each one yourself. The pool " + "reassigns bells as it steals, so a slot holds whatever tube was last put " + "there; voice reports which, how loudly, and the seat gains tap.chime~ " + "would have used."}; + MIN_TAGS{"synthesis"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.chime~, tap.garden~, tap.bloom, poly~"}; + + inlet<> m_in{this, "(list) note [brightness] — strike a tube"}; + outlet<> m_out_0{this, "(signal) voice 0 — the bell in pool slot 0, dry", "signal"}; + outlet<> m_out_1{this, "(signal) voice 1 — the bell in pool slot 1, dry", "signal"}; + outlet<> m_out_2{this, "(signal) voice 2 — the bell in pool slot 2, dry", "signal"}; + outlet<> m_out_3{this, "(signal) voice 3 — the bell in pool slot 3, dry", "signal"}; + outlet<> m_out_4{this, "(signal) voice 4 — the bell in pool slot 4, dry", "signal"}; + outlet<> m_out_5{this, "(signal) voice 5 — the bell in pool slot 5, dry", "signal"}; + outlet<> m_out_6{this, "(signal) voice 6 — the bell in pool slot 6, dry", "signal"}; + outlet<> m_out_7{this, "(signal) voice 7 — the bell in pool slot 7, dry", "signal"}; + outlet<> m_out_8{this, "(signal) voice 8 — the bell in pool slot 8, dry", "signal"}; + outlet<> m_out_9{this, "(signal) voice 9 — the bell in pool slot 9, dry", "signal"}; + outlet<> m_out_10{this, "(signal) voice 10 — the bell in pool slot 10, dry", "signal"}; + outlet<> m_out_11{this, "(signal) voice 11 — the bell in pool slot 11, dry", "signal"}; + outlet<> m_out_12{this, "(signal) voice 12 — the bell in pool slot 12, dry", "signal"}; + outlet<> m_out_13{this, "(signal) voice 13 — the bell in pool slot 13, dry", "signal"}; + outlet<> m_out_14{this, "(signal) voice 14 — the bell in pool slot 14, dry", "signal"}; + outlet<> m_out_15{this, "(signal) voice 15 — the bell in pool slot 15, dry", "signal"}; + outlet<> m_out_info{this, "(list) reports: voices , voice "}; + + attribute attack{this, "attack", kernel::k_default_attack_s, setter{MIN_FUNCTION{ + const double v = std::max(1e-6, static_cast(args[0])); + m_rack.set_times(v, m_rack.decay_s()); + return {v}; + }}, + description{"Strike attack time in SECONDS — a clapper, not a bow."}}; + + attribute decay{this, "decay", kernel::k_default_decay_s, setter{MIN_FUNCTION{ + const double v = std::max(1e-6, static_cast(args[0])); + m_rack.set_times(m_rack.attack_s(), v); + return {v}; + }}, + description{"Ring time in SECONDS for the fundamental. Each strike scales this by " + "sqrt(440/f) — small high tubes ring shorter."}}; + + attribute material{this, + "material", + kernel::material_chime, + setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_materials - 1); + m_rack.set_material(v); + return {v}; + }}, + range{0, kernel::k_num_materials - 1}, + description{"0 the free-free chime tube, 1 the tuned bar. Read at strike time, so " + "every later strike re-voices."}}; + + attribute spread{this, "spread", kernel::k_default_spread, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_rack.set_spread(v); + return {v}; + }}, + description{"The rack's stereo width, 0 to 1. It does NOT affect these outlets, which " + "are always dry — it only sets the seat gains that `voice` reports, so a " + "patch rebuilding the image downstream can honour it."}}; + + attribute brightness{ + this, "brightness", kernel::k_default_brightness, + setter{MIN_FUNCTION{ return {std::clamp(static_cast(args[0]), 0.0, 1.0)}; }}, + description{"Default upper-mode weight (0..1) for strikes that do not carry their own."}}; + + message<> note{this, "note", + "note [brightness]: strike the tube at that MIDI pitch. Which outlet it " + "lands on is the pool's choice — an idle bell if there is one, else the quietest is stolen.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[1]) > 0.0) { + const double b = + (args.size() >= 3) ? static_cast(args[2]) : static_cast(brightness); + m_rack.strike(args[0], args[1], b); + } + return {}; + }}; + + message<> list{this, "list", "A bare [brightness] list is the same as note.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[1]) > 0.0) { + const double b = + (args.size() >= 3) ? static_cast(args[2]) : static_cast(brightness); + m_rack.strike(args[0], args[1], b); + } + return {}; + }}; + + message<> hz{ + this, "hz", "hz [brightness]: strike at a raw frequency rather than a MIDI pitch.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[0]) > 0.0 && static_cast(args[1]) > 0.0) { + const double b = (args.size() >= 3) ? static_cast(args[2]) : static_cast(brightness); + m_rack.strike_hz(args[0], args[1], b); + } + return {}; + }}; + + message<> voice{this, "voice", + "voice : report that outlet's tube — hz, level, and the seat gains tap.chime~ would " + "have used — out the right outlet. With no argument, reports every slot in turn.", + MIN_FUNCTION{ + if (args.empty()) { + for (int i = 0; i < kernel::k_voices; ++i) { + report(i); + } + } + else { + report(static_cast(args[0])); + } + return {}; + }}; + + message<> voices{this, "voices", "Report how many bells are currently ringing, out the right outlet.", + MIN_FUNCTION{ + m_out_info.send("voices", m_rack.active_voices()); + return {}; + }}; + + message<> clear{this, "clear", "Silence every bell at once. Parameters are kept.", + MIN_FUNCTION{ + m_rack.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Re-prepare the rack when the sample rate changes.", + MIN_FUNCTION{ + m_rack.prepare(samplerate()); + return {}; + }}; + + chimevoices(const atoms& args = {}) { + if (!args.empty()) { + material = args[0]; + } + m_rack.prepare(samplerate()); + } + + samples operator()() { + samples v{}; // samples is std::array, so this is the buffer + m_rack.process_voices(v.data(), kernel::k_voices); + return v; + } + + private: + void report(int slot) { + if (slot < 0 || slot >= kernel::k_voices) { + return; + } + m_out_info.send("voice", slot, m_rack.voice_hz(slot), m_rack.voice_level(slot), m_rack.voice_gain_left(slot), + m_rack.voice_gain_right(slot)); + } +}; + +MIN_EXTERNAL(chimevoices); diff --git a/source/projects/tap.chime.voices_tilde/tap.chime.voices_tilde_test.cpp b/source/projects/tap.chime.voices_tilde/tap.chime.voices_tilde_test.cpp new file mode 100644 index 00000000..4e537f08 --- /dev/null +++ b/source/projects/tap.chime.voices_tilde/tap.chime.voices_tilde_test.cpp @@ -0,0 +1,48 @@ +/// @file +/// @brief Unit tests for tap.chime.voices~. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.chime.voices_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.chime.voices~ instantiates with the rack's defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + chimevoices& my_object = an_instance; + + THEN("it is the same rack tap.chime~ wraps, at the same settings") { + REQUIRE(static_cast(my_object.material) == kernel::material_chime); + REQUIRE(static_cast(my_object.spread) == kernel::k_default_spread); + REQUIRE(static_cast(my_object.attack) == kernel::k_default_attack_s); + REQUIRE(static_cast(my_object.decay) == kernel::k_default_decay_s); + } + THEN("there is one signal outlet per bell in the pool") { + REQUIRE(my_object.outlets().size() == static_cast(kernel::k_voices) + 1); // + the report outlet + } + } +} + +SCENARIO("tap.chime.voices~ clamps what the rack clamps") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + chimevoices& my_object = an_instance; + + THEN("material is held to the tables the kernel actually has") { + my_object.material = 99; + REQUIRE(static_cast(my_object.material) == kernel::k_num_materials - 1); + } + THEN("spread is a 0..1 width even though these outlets are always dry") { + my_object.spread = 4.0; + REQUIRE(static_cast(my_object.spread) == 1.0); + } + THEN("envelope times stay strictly positive, as decay_env requires") { + my_object.attack = 0.0; + REQUIRE(static_cast(my_object.attack) > 0.0); + } + } +} diff --git a/source/projects/tap.chime_tilde/CMakeLists.txt b/source/projects/tap.chime_tilde/CMakeLists.txt new file mode 100644 index 00000000..3acfc97a --- /dev/null +++ b/source/projects/tap.chime_tilde/CMakeLists.txt @@ -0,0 +1,16 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/garden.h + ${TAPTOOLS_KERNEL_DIR}/include/taptools/swing_vca.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.chime_tilde/tap.chime_tilde.cpp b/source/projects/tap.chime_tilde/tap.chime_tilde.cpp new file mode 100644 index 00000000..5cdaf529 --- /dev/null +++ b/source/projects/tap.chime_tilde/tap.chime_tilde.cpp @@ -0,0 +1,177 @@ +/// @file +/// @brief tap.chime~ — the wind-chime rack tap.garden~ sounds its blooms on. +/// @details A polyphonic rack of sixteen small wind chimes wrapping tap::tools::garden::rack +/// (taptools/garden.h). Each strike is four decaying mode doublets at the transverse- +/// vibration ratios of the chosen material — the free-free tube's 1 : 2.756 : 5.404 : +/// 8.933 or the tuned bar's double-octave 1 : 4 : 10 : 20 (Fletcher & Rossing, The +/// Physics of Musical Instruments, 2nd ed.) — with the upper modes softer, faster- +/// dying, and scattered a fixed few cents by the struck tube's own identity. Ring time +/// scales with sqrt(440/f), so small high tubes ring shorter. +/// +/// The rack is a rack: each tube hangs at a fixed seat in the stereo field drawn from +/// a stateless hash of its pitch, so the same note always rings from the same place +/// with the same flaws, in every instance of the object, forever. @spread scales the +/// width; 0 collapses it to bitwise mono. +/// +/// Polyphony is the object's own, deliberately, rather than something you wrap in +/// `poly~`: an idle bell is used if there is one, otherwise the QUIETEST ringing bell +/// is stolen and RE-AIMED — its phases keep free-running and its seat glides — so a +/// steal sounds like a chime being struck again, not like a voice being cut. `poly~` +/// steals round-robin and cannot do that, and the kernel has to run off Max too. +/// Sixteen bells is also a hard bound on the audio: however fast you strike it, that +/// is all that can ever be ringing. +/// +/// Drive it from `tap.bloom` and you have the garden's voice; drive it from `makenote` +/// or a sequencer and it is simply a chime rack. All DSP lives in the Min-free kernel; +/// this file is only the Max plumbing. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::garden; + +class chime : public object, public sample_operator<0, 2> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::rack m_rack; + + public: + MIN_DESCRIPTION{"A rack of sixteen wind chimes. Each strike is four decaying mode doublets at " + "the transverse ratios of the chosen material, with the upper modes softer, " + "faster-dying, and scattered a few cents by the struck tube's own identity — " + "so a given note always rings from the same seat in the stereo field with the " + "same flaws. Voice stealing is the object's own and takes the QUIETEST bell, " + "re-aiming it rather than cutting it, so a steal glides. Strike it with " + "note [brightness], or drive it from tap.bloom to get " + "tap.garden~'s voice."}; + MIN_TAGS{"synthesis"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.garden~, tap.bloom, tap.808.cowbell~, tap.808.cymbal~"}; + + inlet<> m_in{this, "(list) note [brightness] — strike a tube"}; + outlet<> m_out_left{this, "(signal) the rack, left", "signal"}; + outlet<> m_out_right{this, "(signal) the rack, right", "signal"}; + outlet<> m_out_info{this, "(list) reports: voices "}; + + attribute attack{this, "attack", kernel::k_default_attack_s, setter{MIN_FUNCTION{ + const double v = std::max(1e-6, static_cast(args[0])); + m_rack.set_times(v, m_rack.decay_s()); + return {v}; + }}, + description{"Strike attack time in SECONDS — a clapper, not a bow. Applies to future " + "strikes; a ringing chime keeps its envelope until retriggered."}}; + + attribute decay{this, "decay", kernel::k_default_decay_s, setter{MIN_FUNCTION{ + const double v = std::max(1e-6, static_cast(args[0])); + m_rack.set_times(m_rack.attack_s(), v); + return {v}; + }}, + description{"Ring time in SECONDS for the fundamental. The upper modes die much faster " + "(radiation damping), and each strike scales this by sqrt(440/f) — small " + "high tubes ring shorter."}}; + + attribute material{this, + "material", + kernel::material_chime, + setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_materials - 1); + m_rack.set_material(v); + return {v}; + }}, + range{0, kernel::k_num_materials - 1}, + description{"What the tubes are made of: 0 the free-free chime tube (1 : 2.756 : 5.404 " + ": 8.933), 1 the tuned bar (double octaves, 1 : 4 : 10 : 20). A mode, not " + "a fader — read at strike time, so every later strike re-voices."}}; + + attribute spread{this, "spread", kernel::k_default_spread, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_rack.set_spread(v); + return {v}; + }}, + description{"The rack's stereo width, 0 to 1. Each tube's seat comes from its pitch, " + "so the image is fixed rather than moving; 0 collapses the rack to centre " + "mono, bitwise equal on both busses."}}; + + attribute brightness{ + this, "brightness", kernel::k_default_brightness, + setter{MIN_FUNCTION{ return {std::clamp(static_cast(args[0]), 0.0, 1.0)}; }}, + description{"Default upper-mode weight (0..1) for strikes that do not carry their " + "own. A soft strike is duller regardless: effective brightness scales " + "with velocity."}}; + + message<> note{this, "note", + "note [brightness]: strike the tube at that MIDI pitch (fractional " + "accepted — fractional pitches are distinct tubes with their own flaws and their own seat). " + "Velocity 0..1; brightness defaults to the attribute.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[1]) > 0.0) { + const double b = + (args.size() >= 3) ? static_cast(args[2]) : static_cast(brightness); + m_rack.strike(args[0], args[1], b); + } + return {}; + }}; + + // Without this a bare list would not reach the rack, and a bare list is exactly what comes + // out of `route note` downstream of tap.bloom — the patched garden's own wiring. + message<> list{this, "list", "A bare [brightness] list is the same as note.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[1]) > 0.0) { + const double b = + (args.size() >= 3) ? static_cast(args[2]) : static_cast(brightness); + m_rack.strike(args[0], args[1], b); + } + return {}; + }}; + + message<> hz{ + this, "hz", "hz [brightness]: strike at a raw frequency rather than a MIDI pitch.", + MIN_FUNCTION{ + if (args.size() >= 2 && static_cast(args[0]) > 0.0 && static_cast(args[1]) > 0.0) { + const double b = (args.size() >= 3) ? static_cast(args[2]) : static_cast(brightness); + m_rack.strike_hz(args[0], args[1], b); + } + return {}; + }}; + + message<> voices{this, "voices", "Report how many bells are currently ringing, out the right outlet.", + MIN_FUNCTION{ + m_out_info.send("voices", m_rack.active_voices()); + return {}; + }}; + + message<> clear{this, "clear", "Silence every bell at once. Parameters are kept.", + MIN_FUNCTION{ + m_rack.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Re-prepare the rack when the sample rate changes.", + MIN_FUNCTION{ + m_rack.prepare(samplerate()); + return {}; + }}; + + chime(const atoms& args = {}) { + if (!args.empty()) { + material = args[0]; + } + m_rack.prepare(samplerate()); + } + + samples<2> operator()() { + double left = 0.0; + double right = 0.0; + m_rack.process(left, right); // the kernel accumulates, so start from zero + return {left, right}; + } +}; + +MIN_EXTERNAL(chime); diff --git a/source/projects/tap.chime_tilde/tap.chime_tilde_test.cpp b/source/projects/tap.chime_tilde/tap.chime_tilde_test.cpp new file mode 100644 index 00000000..6b7e1856 --- /dev/null +++ b/source/projects/tap.chime_tilde/tap.chime_tilde_test.cpp @@ -0,0 +1,53 @@ +/// @file +/// @brief Unit tests for tap.chime~. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.chime_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.chime~ instantiates with the kernel's defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + chime& my_object = an_instance; + + THEN("it is a chime rack, not a bar rack, at the kernel's default width") { + REQUIRE(static_cast(my_object.material) == kernel::material_chime); + REQUIRE(static_cast(my_object.spread) == kernel::k_default_spread); + } + THEN("the strike is a clapper, not a bow") { + REQUIRE(static_cast(my_object.attack) == kernel::k_default_attack_s); + REQUIRE(static_cast(my_object.decay) == kernel::k_default_decay_s); + } + } +} + +SCENARIO("tap.chime~ clamps its parameters to the kernel's ranges") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + chime& my_object = an_instance; + + THEN("material is held to the tables the kernel actually has") { + my_object.material = 99; + REQUIRE(static_cast(my_object.material) == kernel::k_num_materials - 1); + my_object.material = -5; + REQUIRE(static_cast(my_object.material) == 0); + } + THEN("spread is a 0..1 width") { + my_object.spread = 4.0; + REQUIRE(static_cast(my_object.spread) == 1.0); + my_object.spread = -4.0; + REQUIRE(static_cast(my_object.spread) == 0.0); + } + THEN("envelope times stay strictly positive, as decay_env requires") { + my_object.attack = 0.0; + REQUIRE(static_cast(my_object.attack) > 0.0); + my_object.decay = -1.0; + REQUIRE(static_cast(my_object.decay) > 0.0); + } + } +} diff --git a/source/projects/tap.gardener/CMakeLists.txt b/source/projects/tap.gardener/CMakeLists.txt new file mode 100644 index 00000000..bb667889 --- /dev/null +++ b/source/projects/tap.gardener/CMakeLists.txt @@ -0,0 +1,15 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/garden.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.gardener/tap.gardener.cpp b/source/projects/tap.gardener/tap.gardener.cpp new file mode 100644 index 00000000..dfba7fa4 --- /dev/null +++ b/source/projects/tap.gardener/tap.gardener.cpp @@ -0,0 +1,155 @@ +/// @file +/// @brief tap.gardener — the idle wind that plants tap.garden~'s notes when you stop. +/// @details Wrapping tap::tools::garden::gardener (taptools/garden.h): after @idle seconds +/// without anyone planting, this starts planting for you. Strikes arrive on a +/// calm/gust cycle — @gust sizes the clusters, up to five neighbouring tubes within a +/// fraction of a second, with the calms between them stretched so the average rate +/// stays near one strike per loop pass at any gust setting. It is wind, not a +/// sequence. +/// +/// The rng is drawn from ONLY while idling. That discipline is what makes @seed a +/// contract rather than a suggestion: the same seed gives the same wind every time, a +/// different seed decorrelates two instances, and with @idle 0 the seed cannot matter +/// at all because the stream is never touched. Send `planted` (or patch anything that +/// plants into the left inlet) to close the idle gate; the gardener stops, and the +/// gust resumes only if the garden goes quiet again. +/// +/// Pitches come out RAW and unquantized, deliberately — the scale is the patch's +/// field, not the wind's. Put tap.scale between this and tap.bloom. +/// +/// Like tap.bloom this runs on Max's scheduler rather than the audio clock, so +/// @interval sets both the tick and the time resolution. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::garden; + +class gardener : public object { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::gardener m_wind; + // Attributes are constructed in declaration order and their setters run as they go, so + // reprepare() must not read attributes that do not exist yet. The constructor flips this. + bool m_ready{false}; + + public: + MIN_DESCRIPTION{"The idle wind from tap.garden~, on its own. After idle seconds with nobody " + "planting, it starts planting: strikes arrive in gusts of up to five " + "neighbouring tubes, separated by calms stretched so the average rate holds " + "near one strike per loop pass. Its rng is drawn from only while idling, so " + "seed is a real contract — same seed, same wind; idle 0, and the seed cannot " + "matter. Pitches come out raw: put tap.scale after it. Send planted to close " + "the idle gate when something else plants."}; + MIN_TAGS{"time"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.garden~, tap.bloom, tap.scale, tap.chime~, tap.random"}; + + inlet<> m_in{this, "(planted) close the idle gate — something else just planted"}; + outlet<> m_out_note{this, "(list) plant — raw, unquantized"}; + + attribute interval{this, "interval", 1.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.1, 100.0); + reprepare(v); + return {v}; + }}, + description{"Scheduler tick in ms (0.1..100) — both how often the wind is advanced " + "and its time resolution. Changing it re-seeds and restarts the wind."}}; + + attribute loop{ + this, "loop", kernel::k_default_loop_seconds, setter{MIN_FUNCTION{ + return {std::clamp(static_cast(args[0]), kernel::k_min_loop_seconds, kernel::k_max_loop_seconds)}; + }}, + description{"The loop length in seconds this wind is blowing across — match it to the " + "tap.bloom it feeds. It sizes the gusts and the calms, so that the average " + "rate stays near one strike per pass."}}; + + attribute idle{this, "idle", kernel::k_default_idle_seconds, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_wind.set_idle_seconds(v); + return {v}; + }}, + description{"Seconds of quiet before the wind picks up. 0 disables it entirely — and " + "then the seed cannot matter, because the rng is never consumed."}}; + + attribute gust{this, "gust", kernel::k_default_gust, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_wind.set_gust(v); + return {v}; + }}, + description{"The wind, 0 to 1. At 0 strikes arrive singly and evenly, about one per " + "pass; up from there they cluster into gusts with longer calms between, at " + "the same average rate."}}; + + attribute seed{this, "seed", 0, setter{MIN_FUNCTION{ + const int v = static_cast(args[0]); + m_wind.set_seed(static_cast(v)); + return {v}; + }}, + description{"The wind's seed. Same seed, same wind, every run — so a render reproduces " + "and two instances decorrelate by seeding them differently."}}; + + message<> planted{this, "planted", + "Close the idle gate: something else just planted, so the wind should drop and wait out " + "another idle period before it starts again.", + MIN_FUNCTION{ + m_wind.notice_plant(); + return {}; + }}; + + message<> bang{this, "bang", "Same as planted — close the idle gate.", + MIN_FUNCTION{ + m_wind.notice_plant(); + return {}; + }}; + + message<> clear{this, "clear", "Re-seed the rng and restart both the idle clock and the wind.", + MIN_FUNCTION{ + m_wind.clear(); + return {}; + }}; + + // Advanced from Max's scheduler, one kernel tick per fire — see the header's honest limit. + timer<> m_clock{this, + MIN_FUNCTION{ + const long ticks = + static_cast(static_cast(loop) * 1000.0 / static_cast(interval)); + const kernel::gardener::request req = m_wind.tick(std::max(1L, ticks)); + if (req.wanted) { + m_out_note.send("plant", req.pitch, req.velocity); + } + m_clock.delay(interval); + return {}; + }}; + + gardener(const atoms& args = {}) { + if (!args.empty()) { + seed = args[0]; + } + m_ready = true; + reprepare(interval); + m_clock.delay(interval); + } + + private: + /// The wind counts in ticks, so its "sample rate" is the tick rate. Re-preparing re-seeds and + /// restarts it; there is no honest way to carry a gust across a change of grid. + void reprepare(double interval_ms) { + if (!m_ready) { + return; // still building the attributes; the constructor prepares once at the end + } + m_wind.prepare(1000.0 / interval_ms); + m_wind.set_idle_seconds(idle); + m_wind.set_gust(gust); + m_wind.set_seed(static_cast(static_cast(seed))); + } +}; + +MIN_EXTERNAL(gardener); diff --git a/source/projects/tap.gardener/tap.gardener_test.cpp b/source/projects/tap.gardener/tap.gardener_test.cpp new file mode 100644 index 00000000..49f19514 --- /dev/null +++ b/source/projects/tap.gardener/tap.gardener_test.cpp @@ -0,0 +1,45 @@ +/// @file +/// @brief Unit tests for tap.gardener. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.gardener.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.gardener instantiates with the kernel's defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + gardener& my_object = an_instance; + + THEN("the wind's patience and gustiness match the kernel") { + REQUIRE(static_cast(my_object.idle) == kernel::k_default_idle_seconds); + REQUIRE(static_cast(my_object.gust) == kernel::k_default_gust); + } + } +} + +SCENARIO("tap.gardener clamps the wind and lets idling be switched off") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + gardener& my_object = an_instance; + + THEN("gust is a 0..1 amount") { + my_object.gust = 5.0; + REQUIRE(static_cast(my_object.gust) == 1.0); + my_object.gust = -5.0; + REQUIRE(static_cast(my_object.gust) == 0.0); + } + THEN("idle 0 is allowed — it is how the wind is disabled entirely") { + my_object.idle = 0.0; + REQUIRE(static_cast(my_object.idle) == 0.0); + } + THEN("a negative idle is not, since it would mean the wind never waits") { + my_object.idle = -3.0; + REQUIRE(static_cast(my_object.idle) == 0.0); + } + } +} diff --git a/source/projects/tap.period/CMakeLists.txt b/source/projects/tap.period/CMakeLists.txt new file mode 100644 index 00000000..f62030c5 --- /dev/null +++ b/source/projects/tap.period/CMakeLists.txt @@ -0,0 +1,15 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/airport.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.period/tap.period.cpp b/source/projects/tap.period/tap.period.cpp new file mode 100644 index 00000000..40aeb181 --- /dev/null +++ b/source/projects/tap.period/tap.period.cpp @@ -0,0 +1,101 @@ +/// @file +/// @brief tap.period — how long until a set of free-running loops realigns. +/// @details The one thing a patch of independent tap.reel~ genuinely loses next to +/// tap.airport~: the composite period, the least common multiple of the loop lengths, +/// which needs every length at once and so has nowhere to live inside a single reel. +/// This object is that arithmetic on its own, wrapping the free function +/// tap::tools::airport::composite_period_seconds (taptools/airport.h) — the same code +/// the bank calls, so a patch and the monolith cannot disagree. +/// +/// Give it a list of loop lengths in seconds and it reports the period in seconds. +/// Each length is first quantized to a whole number of samples exactly as a reel +/// quantizes it (the 0.5 s floor, then ceil to samples), because the lcm is over +/// sample counts and not over real numbers — 6.3 and 7.7 seconds are commensurate as +/// decimals and wildly not as sample counts. That quantization is a shared kernel +/// function rather than a copy, which is what keeps the agreement honest. +/// +/// `inf` is a correct and desirable answer, not an error: it means the lcm left the +/// 64-bit range, so the piece will not repeat in any timescale you care about. Seven +/// airport-scale lengths in awkward ratios reach it easily, which is the whole idea. +/// +/// The @sr attribute must match the sample rate the reels are actually running at, or +/// the quantization — and therefore the answer — will be for a different machine. It +/// defaults to the current global rate. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::airport; + +class lcmperiod : public object { + public: + MIN_DESCRIPTION{"How long until a set of free-running loops realigns: the least common " + "multiple of their lengths, in seconds. Send a list of loop lengths in " + "seconds — each is quantized to samples exactly as tap.reel~ quantizes it, " + "because the lcm is over sample counts, not decimals. inf is a correct " + "answer, not an error: it means the period left the 64-bit range and the " + "piece will not repeat. This is the arithmetic tap.airport~ reports for its " + "own lanes, for a patch of tap.reel~ that has no bank to ask."}; + MIN_TAGS{"utilities"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.reel~, tap.airport~, tap.prime"}; + + inlet<> m_in{this, "(list) loop lengths in seconds, or samples ..."}; + outlet<> m_out{this, "(float) the composite period in seconds; inf means it will not repeat"}; + + attribute sr{this, "sr", c74::max::sys_getsr(), setter{MIN_FUNCTION{ + const double v = (static_cast(args[0]) > 0.0) ? static_cast(args[0]) + : c74::max::sys_getsr(); + return {v}; + }}, + description{"The sample rate the loops are running at. The lcm is over sample counts, so " + "this must match the reels or the answer is for a different machine. Defaults " + "to the current global rate."}}; + + message<> list{this, "list", + "A list of loop lengths in SECONDS. Each is floored at 0.5 s and rounded up to whole samples, " + "exactly as tap.reel~ does, then the lcm of those sample counts is reported in seconds.", + MIN_FUNCTION{ + if (args.empty()) { + return {}; + } + std::vector lengths; + lengths.reserve(args.size()); + for (const auto& a : args) { + lengths.push_back(kernel::loop_samples_for(static_cast(a), sr)); + } + emit(lengths); + return {}; + }}; + + message<> samples{this, "samples", + "samples ...: the same question asked in whole samples — what tap.reel~'s " + "loopsamples message reports. Exact, since it skips the quantization entirely.", + MIN_FUNCTION{ + if (args.empty()) { + return {}; + } + std::vector lengths; + lengths.reserve(args.size()); + for (const auto& a : args) { + lengths.push_back(static_cast(static_cast(a))); + } + emit(lengths); + return {}; + }}; + + private: + void emit(const std::vector& lengths) { + m_out.send(kernel::composite_period_seconds(lengths.data(), static_cast(lengths.size()), sr)); + } +}; + +MIN_EXTERNAL(lcmperiod); diff --git a/source/projects/tap.period/tap.period_test.cpp b/source/projects/tap.period/tap.period_test.cpp new file mode 100644 index 00000000..2b6a5f97 --- /dev/null +++ b/source/projects/tap.period/tap.period_test.cpp @@ -0,0 +1,57 @@ +/// @file +/// @brief Unit tests for tap.period. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.period.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.period defaults its rate to the global one") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + lcmperiod& my_object = an_instance; + + THEN("the sample rate follows the host") { // the mock kernel's sys_getsr() is 44100 + REQUIRE(static_cast(my_object.sr) == 44100.0); + } + THEN("a nonsensical rate falls back rather than dividing by it") { + my_object.sr = 0.0; + REQUIRE(static_cast(my_object.sr) > 0.0); + my_object.sr = -48000.0; + REQUIRE(static_cast(my_object.sr) > 0.0); + } + } +} + +SCENARIO("tap.period is the same arithmetic the bank reports for its own lanes") { + ext_main(nullptr); + + GIVEN("an instance at 48 kHz") { + test_wrapper an_instance; + lcmperiod& my_object = an_instance; + my_object.sr = 48000.0; + + THEN("two lengths whose sample counts share a factor realign at their lcm") { + // 0.5 s = 24000 samples, 0.625 s = 30000; gcd 6000, so lcm 120000 = 2.5 s. + const long pair[2] = {24000, 30000}; + REQUIRE(kernel::composite_period_seconds(pair, 2, 48000.0) == 2.5); + } + THEN("a length is quantized to samples exactly as tap.reel~ quantizes it") { + // The floor and the rounding are the shared kernel function, not a copy here. + REQUIRE(kernel::loop_samples_for(0.5, 48000.0) == 24000); + REQUIRE(kernel::loop_samples_for(0.01, 48000.0) + == kernel::loop_samples_for(kernel::k_min_loop_seconds, 48000.0)); + } + THEN("airport-scale lengths in awkward ratios leave the 64-bit range, which is the point") { + std::vector lengths; + for (const double s : {17.8, 19.1, 21.3, 23.9, 26.2, 28.7, 30.9}) { + lengths.push_back(kernel::loop_samples_for(s, 48000.0)); + } + const double p = + kernel::composite_period_seconds(lengths.data(), static_cast(lengths.size()), 48000.0); + REQUIRE(std::isinf(p)); + } + } +} diff --git a/source/projects/tap.reel_tilde/CMakeLists.txt b/source/projects/tap.reel_tilde/CMakeLists.txt new file mode 100644 index 00000000..a2b65489 --- /dev/null +++ b/source/projects/tap.reel_tilde/CMakeLists.txt @@ -0,0 +1,16 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/airport.h + ${TAPTOOLS_KERNEL_DIR}/include/taptools/tape_loop.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.reel_tilde/tap.reel_tilde.cpp b/source/projects/tap.reel_tilde/tap.reel_tilde.cpp new file mode 100644 index 00000000..f16a1165 --- /dev/null +++ b/source/projects/tap.reel_tilde/tap.reel_tilde.cpp @@ -0,0 +1,176 @@ +/// @file +/// @brief tap.reel~ — one free-running tape loop: the lane tap.airport~ is built from. +/// @details A single reel wrapping tap::tools::airport::loop (taptools/airport.h): one head +/// that both plays and records, a length, a level, an equal-power pan, and a playback +/// darken corner. `tap.airport~` is up to eight of these summed and nothing more, so +/// seven `tap.reel~` into a stereo sum ARE the airport — patched, where you can see +/// the lengths and reach into any single loop. That identity is pinned bitwise in the +/// kernel's own test suite, not merely asserted here. +/// +/// What you get by patching it rather than using the monolith: an insert on ONE loop +/// (a filter, a reverse, `tap.discreet~` for tape breath on just that phrase), a +/// varispeed on one reel, more than eight loops, and per-reel tape sizing — the bank +/// buys all eight worst-case reels at DSP start whether you use them or not, while N +/// of these buy exactly N. +/// +/// The discipline is the same one loop deep: NOTHING resets the phase — not `record`, +/// not a setter, not a length change (an honest splice: the head re-wraps modulo the +/// new length, never rewinds), not a DSP chain restart (the wrapper re-prepares, which +/// erases the tape and rewinds, only when the sample rate or the tape buy actually +/// changes). `record 1` punches the input onto the tape at wherever the head happens +/// to be, replacing — no overdub; `record 0` freezes it bit-exactly. +/// +/// The optional argument sets the worst-case tape buy in seconds (default 30 — ~11 MB +/// of double tape at 48 kHz for this one reel; size it to the piece). +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::airport; + +class reel : public object, public sample_operator<1, 2> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::loop m_loop; + bool m_geometry_dirty{false}; + + public: + MIN_DESCRIPTION{"One free-running tape loop — the lane tap.airport~ is made of. A single head " + "both plays and records, and nothing ever resets its phase: not record, not a " + "setter, not a length change (a splice — the head re-wraps, never rewinds). " + "record 1 punches the input onto the tape at wherever the head is; record 0 " + "freezes it bit-exactly. Sum several of these, each at an awkward length, and " + "you have the Music for Airports system with every loop reachable — patch an " + "insert into one of them, or run one through tap.discreet~ for tape breath. " + "The optional argument sets the maximum loop length in seconds."}; + MIN_TAGS{"delays"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.airport~, tap.discreet~, tap.multitap~, groove~"}; + + inlet<> m_in{this, "(signal) audio input — punched onto the tape while recording"}; + outlet<> m_out_left{this, "(signal) playback, left", "signal"}; + outlet<> m_out_right{this, "(signal) playback, right", "signal"}; + outlet<> m_out_info{this, "(list) reports: phase <0..1>, length , samples "}; + + attribute maxloop{this, "maxloop", kernel::k_default_max_seconds, + setter{MIN_FUNCTION{ + const double v = std::max(kernel::k_min_loop_seconds, static_cast(args[0])); + m_geometry_dirty = true; // re-bought (tape erased) when the DSP chain starts + return {v}; + }}, + description{"Worst-case loop length in seconds (set by the first object argument; the " + "reel is bought at DSP start — 30 s is ~11 MB of tape at 48 kHz). Changing " + "it erases the tape when the DSP chain restarts."}}; + + attribute length{this, "length", 1.0, setter{MIN_FUNCTION{ + m_loop.set_length_seconds(args[0]); + return {m_loop.length_seconds()}; // report what the sample grid actually allows + }}, + description{"Loop length in seconds (0.5 up to maxloop). Across several reels the " + "lengths are the score: keep them incommensurate and the coincidences " + "never repeat. A change is a splice — the head re-wraps and never " + "rewinds. It can click; splices do."}}; + + attribute level{this, "level", 1.0, setter{MIN_FUNCTION{ + m_loop.set_level(args[0]); + return args; + }}, + description{"Linear playback level, slewed. Unclamped (negative flips polarity)."}}; + + attribute pan{this, "pan", 0.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), -1.0, 1.0); + m_loop.set_pan(v); + return {v}; + }}, + description{"Equal-power pan, -1 (hard left) to 1 (hard right), slewed. Endpoints are " + "exact: a hard-panned reel is bitwise absent from the far bus."}}; + + attribute darken{ + this, "darken", tap::tools::tape::k_darken_ceil_hz, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), tap::tools::tape::k_darken_floor_hz, + tap::tools::tape::k_darken_ceil_hz); + m_loop.set_darken_hz(v); + return {v}; + }}, + description{"Playback darkening corner in Hz. A static tone, not generation loss (a " + "frozen loop replays the same imprint every pass); at 20000 (the default) " + "the stage is bypassed and playback is bit-transparent."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_loop.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for level/pan/darken moves, in ms (0 = instant)."}}; + + message<> record{this, "record", + "record <0|1>: punch the input onto the tape at wherever the head happens to be (1), or " + "freeze the tape bit-exactly (0). Recording replaces — no overdub.", + MIN_FUNCTION{ + if (!args.empty()) { + m_loop.record(static_cast(args[0]) != 0); + } + return {}; + }}; + + message<> phase{this, "phase", + "Report the head position as a fraction of the loop length (0..1) out the right outlet.", + MIN_FUNCTION{ + m_out_info.send("phase", m_loop.phase()); + return {}; + }}; + + // Not named `samples`: that would shadow Min's samples<2> return type inside this class. + message<> loopsamples{this, "loopsamples", + "Report the loop length in samples, and in seconds as quantized to the sample grid, " + "out the right outlet — what tap.period needs to work out when a set of reels " + "realigns.", + MIN_FUNCTION{ + m_out_info.send("samples", static_cast(m_loop.loop_samples())); + m_out_info.send("length", m_loop.length_seconds()); + return {}; + }}; + + message<> clear{this, "clear", "Erase the tape and rewind the head. Parameters are kept.", + MIN_FUNCTION{ + m_loop.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", + "Re-prepare only when the sample rate or the tape buy changed — a DSP chain restart must " + "not erase a running loop.", + MIN_FUNCTION{ + if (m_geometry_dirty || !m_loop.prepared() || samplerate() != m_loop.samplerate()) { + m_loop.prepare(samplerate(), maxloop); + m_geometry_dirty = false; + } + return {}; + }}; + + reel(const atoms& args = {}) { + if (!args.empty() && static_cast(args[0]) > 0.0) { + maxloop = args[0]; + } + m_loop.prepare(samplerate(), maxloop); + m_geometry_dirty = false; + } + + samples<2> operator()(sample x) { + double left = 0.0; + double right = 0.0; + m_loop.process(x, left, right); // the kernel accumulates, so start from zero + return {left, right}; + } +}; + +MIN_EXTERNAL(reel); diff --git a/source/projects/tap.reel_tilde/tap.reel_tilde_test.cpp b/source/projects/tap.reel_tilde/tap.reel_tilde_test.cpp new file mode 100644 index 00000000..df907e00 --- /dev/null +++ b/source/projects/tap.reel_tilde/tap.reel_tilde_test.cpp @@ -0,0 +1,55 @@ +/// @file +/// @brief Unit tests for tap.reel~. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.reel_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.reel~ instantiates with the expected defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + reel& my_object = an_instance; + + THEN("the reel is transparent: unity level, centred, shade bypassed") { + REQUIRE(static_cast(my_object.level) == 1.0); + REQUIRE(static_cast(my_object.pan) == 0.0); + REQUIRE(static_cast(my_object.darken) == tap::tools::tape::k_darken_ceil_hz); + } + THEN("the tape buy defaults to the kernel's worst case") { + REQUIRE(static_cast(my_object.maxloop) == kernel::k_default_max_seconds); + } + } +} + +SCENARIO("tap.reel~ clamps what the kernel clamps") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + reel& my_object = an_instance; + + THEN("pan is held to the equal-power range") { + my_object.pan = 4.0; + REQUIRE(static_cast(my_object.pan) == 1.0); + my_object.pan = -4.0; + REQUIRE(static_cast(my_object.pan) == -1.0); + } + THEN("darken is held to the audible band") { + my_object.darken = 1.0; + REQUIRE(static_cast(my_object.darken) == tap::tools::tape::k_darken_floor_hz); + my_object.darken = 1e9; + REQUIRE(static_cast(my_object.darken) == tap::tools::tape::k_darken_ceil_hz); + } + THEN("a length below the floor comes back as the floor, quantized to the sample grid") { + my_object.length = 0.01; + REQUIRE(static_cast(my_object.length) >= kernel::k_min_loop_seconds); + } + THEN("smoothing cannot go negative") { + my_object.smooth = -5.0; + REQUIRE(static_cast(my_object.smooth) == 0.0); + } + } +} diff --git a/source/projects/tap.scale/CMakeLists.txt b/source/projects/tap.scale/CMakeLists.txt new file mode 100644 index 00000000..bb667889 --- /dev/null +++ b/source/projects/tap.scale/CMakeLists.txt @@ -0,0 +1,15 @@ +# Copyright 2026 Timothy Place. Distributed under the New BSD License. +cmake_minimum_required(VERSION 3.19) +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/garden.h +) +add_library(${PROJECT_NAME} MODULE ${SOURCES}) +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) diff --git a/source/projects/tap.scale/tap.scale.cpp b/source/projects/tap.scale/tap.scale.cpp new file mode 100644 index 00000000..9d1b82a2 --- /dev/null +++ b/source/projects/tap.scale/tap.scale.cpp @@ -0,0 +1,108 @@ +/// @file +/// @brief tap.scale — snap a MIDI pitch to the nearest degree of a root and scale. +/// @details Wrapping tap::tools::garden::scale_quantizer (taptools/garden.h): the entry +/// quantizer that makes tap.garden~'s promise — anything you plant sounds consonant — +/// work. It is the nearest-allowed search (any non-empty scale has a degree within a +/// tritone, so nothing is ever left unsnapped), and it looks UP before it looks down +/// when a pitch sits exactly between two degrees. +/// +/// In a patched garden it goes between tap.gardener and tap.bloom, because the wind +/// emits raw pitches on purpose: the scale is the patch's field, not the wind's. On +/// its own it is simply a pitch quantizer, and it will happily sit in front of any +/// note source. +/// +/// Quantizing is at ENTRY, which is the same contract the monolith keeps: changing +/// @root or @scale re-pitches nothing already planted downstream, only what passes +/// through next. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include + +#include "c74_min.h" + +using namespace c74::min; +namespace kernel = tap::tools::garden; + +// Not named `scale`: Min already has a scale() utility, and the collision makes the whole +// class template-invalid. The Max-side name comes from the project folder, not from this. +class scalesnap : public object { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::scale_quantizer m_quantizer; + + public: + MIN_DESCRIPTION{"Snap a MIDI pitch to the nearest degree of a root and scale — the entry " + "quantizer behind tap.garden~'s promise that anything you plant sounds " + "consonant. A float in gives the snapped pitch out; a pitch/velocity pair " + "passes the velocity through untouched, so it drops straight into a note " + "chain. Between tap.gardener and tap.bloom it is what turns raw wind into " + "music; on its own it is a pitch quantizer for any note source."}; + MIN_TAGS{"utilities"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.garden~, tap.bloom, tap.gardener, tap.tune~, tap.semitone2ratio"}; + + inlet<> m_in{this, "(float) a MIDI pitch, or a (list) pair"}; + outlet<> m_out{this, "(float/list) the snapped pitch, velocity passed through"}; + + attribute root{this, + "root", + 0, + setter{MIN_FUNCTION{ + const int v = ((static_cast(args[0]) % 12) + 12) % 12; + m_quantizer.set_root(v); + return {v}; + }}, + range{0, 11}, + description{"Root pitch class, 0..11 (0 = C). Values outside the range wrap."}}; + + attribute scalemode{this, + "scale", + kernel::scale_major_pentatonic, + setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_scales - 1); + m_quantizer.set_scale(v); + return {v}; + }}, + range{0, kernel::k_num_scales - 1}, + description{"Scale: 0 chromatic, 1 major, 2 minor, 3 major pentatonic (the default — " + "it is the one where nothing can sound wrong), 4 minor pentatonic. " + "Public-domain scale theory, not any app's preset list."}}; + + message<> number{this, "number", "A MIDI pitch (fractional accepted) — the snapped pitch comes out.", + MIN_FUNCTION{ + if (!args.empty()) { + m_out.send(m_quantizer.quantize(args[0])); + } + return {}; + }}; + + message<> list{this, "list", + " : the pitch is snapped, the velocity (and anything after it) passes " + "through untouched, so this drops into a note chain without rebuilding the message.", + MIN_FUNCTION{ + if (args.empty()) { + return {}; + } + atoms out{m_quantizer.quantize(args[0])}; + for (size_t i = 1; i < args.size(); ++i) { + out.push_back(args[i]); + } + m_out.send(out); + return {}; + }}; + + scalesnap(const atoms& args = {}) { + if (!args.empty()) { + root = args[0]; + } + if (args.size() > 1) { + scalemode = args[1]; + } + } +}; + +MIN_EXTERNAL(scalesnap); diff --git a/source/projects/tap.scale/tap.scale_test.cpp b/source/projects/tap.scale/tap.scale_test.cpp new file mode 100644 index 00000000..de5deac3 --- /dev/null +++ b/source/projects/tap.scale/tap.scale_test.cpp @@ -0,0 +1,39 @@ +/// @file +/// @brief Unit tests for tap.scale. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.scale.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.scale instantiates with the expected defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + scalesnap& my_object = an_instance; + + THEN("it defaults to C major pentatonic — the scale where nothing sounds wrong") { + REQUIRE(static_cast(my_object.root) == 0); + REQUIRE(static_cast(my_object.scalemode) == kernel::scale_major_pentatonic); + } + } +} + +SCENARIO("tap.scale wraps the root and clamps the scale") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + scalesnap& my_object = an_instance; + + THEN("a root outside 0..11 wraps into the pitch classes") { + my_object.root = 14; + REQUIRE(static_cast(my_object.root) == 2); + } + THEN("an out-of-range scale is clamped rather than left dangling") { + my_object.scalemode = 99; + REQUIRE(static_cast(my_object.scalemode) == kernel::k_num_scales - 1); + } + } +} diff --git a/submodules/taptools b/submodules/taptools index 64724b86..d4cf28ab 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit 64724b86ebe1098f8e5c1cfdf77281cf8d309b05 +Subproject commit d4cf28ab00a13f221d4e3acfd0067f61be4403a2