diff --git a/REVIVAL.md b/REVIVAL.md index 09a33500..7836f5e1 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -1512,6 +1512,341 @@ 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. +**18. The tape echo, and the start of the Radiohead family (2026-08-15).** ✅ `tap.tapecho~` — a +multi-head tape echo in the Copicat / Space Echo tradition, and the first object of a new family +planned in the kernel repo's `book/PLAN-radiohead-family.md` (drawn from Radiohead's *performed* +electronics: the tape echoes, the live Max stutter rigs, the Ondes Martenot, the Kaoss-pad +scrubbing). The family's spine, where the Eno family's was "degradation is the stability +mechanism": **the control is the instrument** — these are objects you ride, so the no-zipper rule +is the feature rather than the hygiene. + +The object is one record head, a span of moving tape, and up to four playback heads along it, +summed to stereo. `@span` is the motor — the delay of a head at the far end of the path — and each +head sits at `@span` times its `@ratios` entry, so the motor moves the whole layout together and +bends pitch on the way, the `tap.discreet~` doppler contract. The default layout is four evenly +spaced heads; that spacing is nominal, deliberately *not* claimed as measured from any unit, and +`@ratios` builds a Copicat-style three in one message. + +It carries the tape family's inversion one step further, which is the reason the object exists. +`tap.delay~` caps feedback at 0.99; `tap.discreet~` reaches exactly 1.0 because the wear path is +the stabilizer; here `@regen` goes **past** 1.0 into deliberate sound-on-sound self-oscillation, +bounded by the `@drive` saturator (≤ 1/drive) rather than by a gain cap. Because that bound only +exists while the saturator is engaged, the effective regeneration is capped back to 1.0 *per +sample* whenever `@drive` is 0 — the attribute keeps its value and it returns when drive does, so +dropping drive mid-howl lands the loop rather than letting it run away. + +The claim that mattered most was structural, and it is measured rather than asserted: with the tape +path neutral (no transport error, no regeneration) a one-head echo is **bitwise** `delay.h`'s +Hermite multitap — pinned in the kernel suite and again across the C ABI in the notebook. That is +what makes "`tape_loop.h` is a library and this kernel is only composition" a measurement; the +shared machinery needed no changes at all to serve a second topology. Also measured at ship: +per-pass generation loss within 0.2% of the analytic wear transfer, wow at 10.91 cents against +10.88 predicted, and every past-unity drive setting plateauing under its analytic ceiling. + +Kernel side (`tap/taptools`): `tapecho.h`, eleven Catch2 scenarios, the C ABI plus ctypes surface +(`TapEcho`), the executed `notebooks/tapecho.ipynb`, and `radiohead_render` with four *performed* +scenarios — the controls move while rendering, including a self-oscillation take that pushes past +unity, drops the input, rides the tone control and brings it home. Here: the wrapper, six min-api +scenarios, the reference page and the help patcher, and the submodule pin bumped to match. Still +open for this object: the on-Mac validation pass (open the help patcher once in Max), a maxtest +starter, and the book chapter. + +**19. The stutter rig (2026-08-15).** ✅ `tap.stammer~` — the second Radiohead-family object, and +an **original design** rather than a port: the input is captured continuously and, on a `@step` +grid, the machine rolls dice and re-fires a slice of what just went past. `@density` is how often +it grabs, `@divisions` how finely it chops, `@repeats` how many passes it holds on for, `@reverse` +the per-repeat chance of running backwards, `@jump` how far further back it may reach. It sits in +the brassage/granular tradition (Roads, *Microsound*); the band's own Max rigs are known from +published interviews and broadcast films, and that record informs what the object is *for* and +nothing about what the code does. This is the object with the most direct lineage to this package — +a Max stutter patch, arriving as a Max object. + +Two things are worth carrying forward from building it. The first is the **pinned-dice identity** +the kernel suite is built on: set density 1, whole-step slices, one forward pass and no flank, and +only one outcome is possible, so the machine must reduce to *exactly* a one-step delay, bitwise. +That single identity pins the grid countdown, the slice origin arithmetic and the playback head +together — much stronger than chasing three off-by-ones separately, and it is the technique to +reach for the next time an object has several interacting integer clocks. The second is that the +**material contract got measured at its premise** rather than asserted: slices of a sustained sine +are 1.000 alike by magnitude spectrum and slices of a plucked phrase 0.286, so "feed it transients" +stops being taste and becomes the observation that re-ordering interchangeable things cannot do +much. + +The seeded-randomness convention carries over from the garden intact and is now a family habit: +every draw comes from the shared xorshift64* in a fixed order, so the same seed and the same moves +give the same render bit for bit (a different seed changes 89% of samples), and at `@density` 0 the +dice are never rolled at all — the seed provably cannot matter and the object is a bitwise bypass +at any mix. That last one is the contract a patcher can check without a scope, and it is pinned +here as well as in the kernel. + +Kernel side (`tap/taptools`): `stammer.h` with the planned `capture` + `slicer` split under a thin +`machine`, nine Catch2 scenarios, the C ABI plus ctypes surface (`Stammer`), the executed +`notebooks/stammer.ipynb`, and three more `radiohead_render` scenarios including a forty-second +performed disintegration. Here: the wrapper, five min-api scenarios, the reference page, the help +patcher, and the pin bumped to match. Still open for this object: the on-Mac validation pass, a +maxtest starter, and the book chapter — the same three that `tap.tapecho~` is waiting on. + +**20. The fuzz, and a house pattern that measured wrong (2026-08-15).** ✅ `tap.fuzz~` — two +cascaded clipping stages and a bass/contrast/treble voicing section: the OK Computer-era dirt, +and the harder, more scooped school beside `tap.overdrive~`'s feedback soft-clipper. It is a +recreation of a circuit *class*, not a component model of any one pedal — no value or corner is +claimed as measured from a unit, and the control names follow the class's conventional layout. + +The method is Yeh, Abel & Smith's DAFx-07 **simplified cascade** (conditioning filter → +memoryless nonlinearity → equalization filter, twice), which supplies the architecture *and* +the justification for the central simplification: the diode limiter is really a lowpass whose +pole moves with voltage, its exact ODE is expensive, and approximating it as a static curve +between fixed filters is defended and measured there against real pedals. The same paper's note +that a real op-amp stage clips asymmetrically — producing the even harmonics an odd-only model +predicts away — is why `@asymmetry` exists. + +Two findings worth carrying, both caught by measurement and neither by listening. + +The first is about **gain staging in a cascade**. The tanh family's small-signal slope is +`knee/tanh(knee)`, which is greater than one and grows with the knee. The first cut put a fixed +×2.2 in front of a knee-3 curve, so the second stage saw an effective ×6.6 and was already fully +clipped with the gain knob at zero — the knob did nothing over most of its travel, and it +sounded like a distortion the whole way, which is exactly why ears did not catch it. Retuned, +the harmonic-to-fundamental ratio now sweeps 0.010 → 0.358 across the knob. + +The second is a **correction to a house pattern**, and it is the one to remember. The +oversampling chain in `tap.ladder~` / `tap.svf~` / `overdrive.h` uses a 4th-order Butterworth +anti-image/anti-alias pair. In this kernel that is not steep enough: alias energy at 4× measured +*worse* than at 2×. Eighth order improves 4× about sixfold — but it does **not** make the +sequence monotone, and an earlier draft of this entry wrongly said it did. Measured against a +3733 Hz tone, fold energy runs 1.2e-1 / 2.7e-5 / 7.4e-4 / 1.8e-3 at 1× / 2× / 4× / 8×, so +**every factor is worth having over none and 2× is the best of them** — which is why +`@oversample` defaults to 2 rather than to the largest available factor. The cause is open: the +obvious suspect, biquads going ill-conditioned at the low normalized cutoffs a high factor +needs, was tested and ruled out by an impulse-response check showing clean decay to denormal at +every factor; the untested hypothesis is imaging from zero-stuff upsampling intermodulating in +the clipper, which would point at cascaded 2× resampling as the real fix. Whether `overdrive.h` +is owed the 8th-order change is a separate live question — different nonlinearity, different +gain structure, so it needs its own measurement rather than this one's conclusion. + +Two test-design errors are recorded in the kernel suite because both are easy to repeat: an +alias test whose tone divided the sample rate (every fold lands on a harmonic and is invisible), +and probe frequencies close enough to the fundamental to measure window leakage instead of +aliasing. + +Kernel side (`tap/taptools`): `fuzz.h`, nine Catch2 scenarios, the C ABI plus ctypes surface +(`Fuzz`), the executed `notebooks/fuzz.ipynb`, and three more `radiohead_render` scenarios. Here: +the wrapper, five min-api scenarios, the reference page, the help patcher, and the pin bumped to +match. Still open: the on-Mac validation pass and a maxtest starter — the same two every object +in this family is waiting on. + +**21. The first piece of the Ondes Martenot (2026-08-15).** ✅ `tap.touche~` — the *touche +d'intensité*, the pressure key an ondes player's left hand rides, as a standalone gain object. +It is the first part of `tap.ondes~` to land and it ships alone deliberately: a measured +expressive gain law is useful on anything, so there is no reason to hold it back until the rest +of the instrument exists. + +What makes this object different from everything else in the package is its contract. Every +other kernel here makes design choices and then measures them; **this one is obliged to +reproduce someone else's measurement.** Quartier, Meurisse, Colmars, Frelat & Vaiedelich (*Acta +Acustica united with Acustica* 101(2), 421–428, 2015) measured finger force, key displacement +and the resulting sound simultaneously on ondes No. 320 and published the boundaries of the six +musical nuances across the key's travel. Those seven points are the specification. The kernel +interpolates them with monotone cubic (Fritsch–Carlson) segments and reproduces them to within +6e-5 dB — monotone cubic rather than a spline because an overshoot here is a non-monotone gain, +which is audible as a dip while you press *harder*. + +Interpolating rather than fitting is the load-bearing decision. The published dB steps are equal +by construction and the displacement steps are not — 1.0, 0.6, 0.5, 0.4, 0.5, 1.5 mm — so the +law steepens through the middle of the travel and flattens hard at the top, and a straight line +in dB-against-millimetres departs from it by 8.3 dB. That shape is the entire reason the key is +famous, and fitting would have thrown it away. + +Two things the reproduce-the-table test caught, both worth carrying. **The normalized domain has +to be the physical travel, not the measured band.** An early cut mapped `@position` 0..1 onto +4.3–8.8 mm, which put position 0 exactly on the first published point while returning silence +there — contradicting the measurement it was supposed to honour. The paper puts playable +gestures at roughly 3–9.5 mm with the measured band inside, so position now spans 9.5 mm and the +bottom 45 % is genuinely silent. That dead travel is the key's own first phase, bending before +it reaches the powder bag, and it is why the instrument can be played with such sharp attacks: +the useful 50 dB is packed into the 4.5 mm immediately after. Expect to explain that to patchers +who think the object is broken — the help patcher says it in as many words. **And the dead zone +belongs in the lookup, not the table**: zeroing dense-table entries below the floor put a cliff +next to it, so a query landing exactly on the floor lerped toward zero and read 4.2 dB low. + +Kernel side (`tap/taptools`): `touche.h`, eleven Catch2 scenarios, the C ABI plus ctypes surface +(`Touche`), the executed `notebooks/touche.ipynb`, and a `radiohead_render` scenario putting the +curve against the two laws anyone would otherwise reach for — a linear fade and a fade linear in +dB. It is audibly neither. Here: the wrapper (signal or attribute position), four min-api +scenarios, the reference page, the help patcher, and the pin bumped to match. + +The rest of `tap.ondes~` is designed but unbuilt — see the kernel repo's `book/PLAN-ondes.md`, +which was written against the sources *after* they were read and which corrects the family +plan's original assumption that the voice was a `vco.h` descendant. It is not: the instrument is +heterodyne, its oscillators measure as essentially pure, and the timbre comes from the triode +stages and the diffuseur downstream. + +**22. The diffuseurs, and the scrub pad (2026-08-17).** ✅ `tap.metallique~`, `tap.palme~` and +`tap.scrub~` — three objects in one pass, two of them the second and third pieces of the Ondes +Martenot and the third the last of the Radiohead family's original five. + +**The diffuseurs.** The Ondes Martenot does not have a loudspeaker, it has a rack of them, and +choosing between them is part of playing it. `tap.metallique~` is the gong (1944–45, patented +1947), a plate driven by a motor transducer; `tap.palme~` is the electromagnet driving twelve +metal strings on a lyre-shaped soundboard (1949–50). Both ship as standalone effects because +that is where their value is: run a guitar through the palme. + +Three things the build settled, all of them worth carrying. + +**Driven, not struck** — the correction that shaped the file. `garden.h`'s modal machinery +carries over intact (mode ratios, doublet splitting, per-mode decay) but its strike envelopes do +not. There is no `decay_env` here and no trigger: the input excites the body continuously and +the body rings at its own rates, which is `grm_comb.h`'s situation rather than the chime's. + +**The order is a claim, so it gets a null test.** The signal reaches the transducer first and +the transducer's motion excites the body, so the nonlinearity is *upstream* of the resonator. A +Catch2 scenario pins that the cabinet is exactly `transducer -> plate`, bitwise, while the +reversed wiring differs by 28 % of peak — the order is a real choice, not a notional one. + +**Two numbers that are not what you would guess.** The mode filter is Steiglitz's +constant-peak-gain two-pole resonator, whose peak magnitude is 1 at *any* Q and whose zeros at +±1 null DC and Nyquist exactly — so with weights that sum to 1 the body is bounded by its input +and needs neither a limiter nor a DC blocker. And the transducer's output is bounded by +**2/saturation, not 1/saturation**: a hard-driven squared law is a nearly-constant positive +waveform, and removing its DC doubles the worst-case swing (measured 1.49 where the naive +argument predicts 1.25). + +Honest about what these are. The instruments, their dates, their excitation and their +moving-iron transducer come from the peer-reviewed sources; the *mode data does not*, because no +ondes-specific measurement of either body exists in any of them. The ratios are Fletcher & +Rossing's free circular plate and the harmonic series, the string tuning is a design choice, and +both nonlinear coefficients are voiced by ear — the source establishes *that* the driver is +nonlinear without handing over a curve. All of that is stated in the kernel header, the +reference pages and the help patchers rather than left to be discovered. The palme gets twelve +strings per the peer-reviewed source, not the twenty-four the widely copied hobbyist build pages +claim. + +**The scrub pad.** `tap.scrub~` records continuously and rakes a granular playhead through the +recording, with **position and pitch as two independent signals** — the two axes of a pad. Its +tape is `tap.stammer~`'s capture class *itself*, shared rather than copied, which is what that +object's header promised when it shipped; the only addition the stutter needed was the +fractional read its ±1-rate slices never used. + +The object rests on a null: Hann overlap-adds to exactly 1 at hop = size/2, so held still at +pitch 0 with no spray, `tap.scrub~` is the input delayed and nothing else — 4.4e-16 in the +kernel suite, and re-pinned at the wrapper so a plumbing mistake cannot hide it. + +**One real defect, found by measurement, and one measurement trap, worth both entries.** The +first cut anchored every grain at the position. Grain origins then advance at the *write head's* +speed while each grain plays at the pitch ratio, so the transposition applied only inside a +grain, the average read rate came back to 1, and a steady tone emerged at its **original** pitch +with a comb of grain-rate sidebands around it. The pitch knob did nothing but add texture, and +no other test on the page could see it. The fix is a phase-continuous read head, wrapped back +toward the position only after it has wandered ±1.5 grains — a bound chosen by sweep (band +energy retained 0.933 / 0.958 / 0.965 / 0.990 / 0.993 at ±0.5 / ±1 / ±2 / ±3 / ±4 grains, flat +past 3, and every extra grain of wander is a grain of position error). + +Then the trap: **a single-bin probe reads the fixed kernel as badly broken.** The wraps spread +the transposed partial into a comb a few Hz wide, and a rectangular-window Goertzel on one line +sees whichever comb tooth happens to sit there — 0.02 where the band figure was 0.43. Measured +properly, 98.8 % of a clean shifter's energy lands within ±15 Hz of the transposed pitch (worst +91.7 %); what the wraps cost is *concentration*, 92.0 % as focused as a clean shift and 75.0 % +at worst, which is audibly a warble. Measure the band, not the bin. + +That measurement also turned up something about a shipped object, recorded rather than acted on: +on the same sweep, **`tap.pitchaccum~` returns mean 0.908 and worst 0.004** — a near-total +cancellation at 311 Hz up nineteen semitones, where its ratio is exactly 3 and its two taps land +a half-window apart. Fixing that is its own job with its own tests and its own consumers, and it +should not ride along on an unrelated kernel; it is logged as an open question in the kernel +repo's `book/PLAN-radiohead-family.md`. + +Kernel side (`tap/taptools`): `diffuseur.h` and `scrub.h`, 31 Catch2 scenarios between them, the +C ABI plus ctypes surfaces (`Metallique`, `Palme`, `Scrub`, and the bare `Plate` and +`Transducer` components — the family's rule is that parts get reachability from the start), the +executed `notebooks/diffuseur.ipynb` and `notebooks/scrub.ipynb`, and four more +`radiohead_render` scenarios. Here: three wrappers, fourteen min-api scenarios, three reference +pages, three help patchers, and the pin bumped to match. + +Still open for this family: the on-Mac validation pass and maxtest starters for every object in +it, and book chapters for `tap.touche~`, the diffuseurs and `tap.scrub~` — the first two +probably belong inside an Ondes-family chapter once the `triode` and heterodyne `source` stages +exist, and the scrub's beside the stammer's, since they share a tape. + +**23. The Ondes Martenot, finished (2026-08-17).** ✅ `tap.triode~` and `tap.ondes~` — the last +two pieces of the instrument, and the two the family plan had wrong. + +**The plan assumed a `vco.h` descendant with waveform registers.** The Ondes Martenot is nothing +of the kind: it is **heterodyne**. Two oscillators near 80 kHz, one fixed and one moved by the +ribbon, sum into an amplitude-modulated signal whose envelope is the note — and Najnudel, Hélie, +Roze & Boutin (*IEEE/ACM TASLP* **28**, 2651–2660, 2020), who model instrument No. 169 as five +port-Hamiltonian stages, measure those oscillators at about **0.03 % second harmonic** even +coupled to the rest of the circuit. All the character is downstream. + +**`tap.triode~` is almost entirely a citation.** The design pass had framed the valve as a choice +between "a published grid-conduction curve" and "the tanh family with an asymmetry bias, decided +by listening". It is neither, because the circuit paper does not merely mention a tube model — it +names one (the **enhanced Norman Koren** law: Koren, *Glass Audio* 8(5), 1996, with Cohen & +Hélie's grid-current extension, AES 129, 2010), writes out its equations, and publishes parameter +sets **fitted to the valves actually in No. 169** — 6F5, 6C5, 2A3 — alongside every stage's supply +voltage, cathode resistor and plate load. So there was nothing to voice by ear. A stage is the +static solution of its load line, which is a memoryless nonlinearity in the DAFx-07 sense, so +tabulating it is not an approximation of the model: it *is* the model. The fitted 6C5 lands at +8.85 mA against its datasheet's 8 mA typical, the published operating points bias sanely, and the +curve is asymmetric in a 2.17 : 1 ratio — which is where a triode's even harmonics live. + +**The plan's instruction for the source was wrong, and catching it was the most valuable thing in +this entry.** "Synthesize the difference tone directly as a sinusoid" would have thrown away the +instrument's largest single source of harmonics. The paper's licence to substitute a sinewave +generator applies to the **oscillators**; the demodulator is not a mixer handing you a difference +tone, it is an envelope detector, and the envelope of two summed oscillators is `2|cos|` — whose +series puts H2 at −14.0 dB and H3 at −21.3 dB **before any valve touches the signal**. + +**What replaces the carrier turned out to be an identity rather than a simplification.** For +amplitudes 1 and *d* the envelope of `cos(Φ) + d·cos(Φ−φ)` is exactly `sqrt(1 + d² + 2d cos φ)`, +so the 80 kHz carrier drops out of the arithmetic instead of being approximated away. Running the +published 200 µs detector (R4·C21) on that closed form reproduces the full +heterodyne-plus-diode-plus-RC simulation to **within 0.10 dB on every harmonic at every pitch +tried**, with one systematic difference — a uniform 3 % level offset, because a follower chasing +real carrier half-cycles never quite reaches the peak between them. The detector's characteristic +pitch dependence comes free (H2 runs −14.0 dB at A2 to −19.3 dB at A6, and the level falls 2.0 dB +across those five octaves), and because the closed form is parameterized by the two oscillator +amplitudes, **oscillator balance becomes a real physical timbre control** rather than an invented +knob. The ribbon law is the paper's Eq. 7 and it is simple: the ribbon is **linear in semitones**, +which is exactly why an ondes glissando sounds the way it does. + +**Three defects found by measurement.** A stage that quietly un-inverted itself applied the +valve's asymmetry to the wrong side of the waveform, so the drive knob *reduced* harmonics as it +was turned up — a real common-cathode stage inverts, and the sign is load-bearing rather than +cosmetic. An aliasing probe measured its own spectral leakage instead of aliasing (the same trap +the fuzz and scrub suites already record). And the voice's anti-zipper setting never reached the +intensity key, which keeps its own slew — so a key set to rest kept sounding for 20 ms nobody had +asked for. That last one was caught by a **wrapper** test in this repo and fixed in the kernel, +which is the two-layer test split doing exactly what it is for. + +**Two things the sources do not settle are switches, not silent guesses**, because both measure as +audible: where the intensity key sits in the chain (`@keyplacement` — the paper's five stages do +not include it, and placing it before the valves makes pressure mean dirt rather than level; worth +about 0.09 of total harmonic content at a half-press) and the winding sense of the transformer +between the two valves (`@polarity` — worth about 0.12). + +**And a data point for `fuzz.h`'s open oversampler question.** That kernel measured 4× coming out +*worse* than 2× and left an untested hypothesis behind: imaging, since zero-stuffing by N leaves +N−1 images for one filter to suppress and their residuals intermodulate in the clipper. +`tap.ondes~` runs the same 8th-order chain around a comparably hard nonlinearity, but as a +**source** — nothing is zero-stuffed on the way up, the detector simply runs fast, so there are no +images at all. Its sequence never reverses: about 12 dB per doubling to 4×, and 7–12 dB more at 8× +in the top octave. Same filters, no upsampler, no reversal. Evidence rather than proof, since the +nonlinearity differs too, but it is the first evidence either way and it points at the upsampler. + +Kernel side (`tap/taptools`): `ondes.h` (`triode`, `detector`, `voice`), 18 Catch2 scenarios, the +C ABI plus ctypes surfaces (`Triode`, `Detector`, `Ondes`, and the bare tube law so a notebook can +plot the published valve curves from the shipping code), the executed `notebooks/ondes.ipynb`, and +four more `radiohead_render` scenarios — including `ondes_diffuseurs`, which is the whole +instrument wired together for the first time: the voice through the principal, the palme and the +métallique in turn. Here: two wrappers, nine min-api scenarios, two reference pages, two help +patchers, and the pin bumped to match. + +**What is still missing from the instrument, and deliberately: the waveform registers.** The real +ondes has switchable timbres whose filter shapes are in none of the four sources obtained. Leipp +(*Bulletin du GAM* n°60, 1972) and Laurendeau's monograph are where to look next. Until then the +header says they are absent rather than filling the gap with invention — which, for an object +whose whole claim is that its numbers are published, is the only defensible answer. + 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.fuzz~.maxref.xml b/docs/tap.fuzz~.maxref.xml new file mode 100644 index 00000000..bc9c97d6 --- /dev/null +++ b/docs/tap.fuzz~.maxref.xml @@ -0,0 +1,80 @@ + + + + + A two-stage tone-stacked fuzz + + The tap.fuzz~ object is a two-stage distortion with a bass / contrast / treble voicing section — the harder, more scooped school that sits beside tap.overdrive~ rather than replacing it (that object is a feedback soft-clipper chasing the Tube Screamer lineage). gain sweeps the first stage's drive from a clean boost to wide open, edge sharpens the second stage's knee from a soft limiter toward a hard corner, and asymmetry brings in the even harmonics that a symmetric curve structurally cannot produce. The architecture is the simplified cascade of Yeh, Abel and Smith (DAFx-07): conditioning filter, memoryless nonlinearity, equalization filter — twice. It is a recreation of a circuit class: no component value or corner frequency is claimed as measured from any pedal, and the control names follow that class's conventional layout rather than asserting what any particular unit does. + + + + + 74 Objects + TapTools + Audio + Effects + + + + + + Audio input. + + + + + + + The distorted output. + + + + + + + Audio to distort. + + + Flush the filters and the oversampling chain. Parameters are kept. + + + + + + + Drive into the first clipping stage (0..1). 0 is a lit-up clean boost, 1 is the pedal wide open. The floor sits below unity on purpose — the second stage carries its own small-signal gain, and a hotter floor would arrive there already saturated, which would leave the knob doing nothing over most of its travel. + + + Knee sharpness of the second stage (0..1): a soft limiter at 0, close to a hard corner at 1. High settings are where a static curve aliases worst — try oversample before blaming the tone controls. + + + Clipping asymmetry (0..1) — the even-harmonic control. A symmetric curve is an odd function and produces odd harmonics only; a real op-amp stage clips lopsided, which is where a pedal's even harmonics come from. It costs no DC: silence stays exactly silent. + + + Low shelf, -1 to 1 (full travel is 12 dB either way). Linear, and entirely outside the nonlinearity. + + + High shelf, -1 to 1 (full travel is 12 dB either way). + + + Mid-scoop depth (0..1, up to 14 dB at 620 Hz). The scoop is this object's own curve; the control name is the pedal class's. + + + Output level in dB (-24..24). + + + Oversampling factor for the clipper pair: 1, 2, 4 or 8. The default is 4. The clipper runs inside a cascade of 2× resampling stages, one doubling and one 8th-order filter each, which is what makes more oversampling mean less aliasing here — an earlier single-stage chain measured worse at 4× and 8× than at 2×. 2 is kept but is only safe below about 6 kHz, above which one doubling stops moving the clipper's harmonics out of the way; 8 earns its keep on bright material. Changing it reconfigures the filters and is not real-time-safe. + + + Anti-zipper ramp time for the setters, in milliseconds (0 = instant). + + + + + + + + + + + diff --git a/docs/tap.metallique~.maxref.xml b/docs/tap.metallique~.maxref.xml new file mode 100644 index 00000000..55bea41c --- /dev/null +++ b/docs/tap.metallique~.maxref.xml @@ -0,0 +1,83 @@ + + + + + The Ondes Martenot's gong diffuseur, as a driven resonator + + The tap.metallique~ object is one of the Ondes Martenot's resonating loudspeakers. The instrument does not have a loudspeaker, it has a rack of them, and choosing between them is part of playing it; the métallique (1944–45, patented 1947) is a gong driven by a motor transducer, so everything sent through it picks up the body of a large metal plate. It is driven, not struck — there is no trigger and no strike envelope, and whatever you send in excites the plate continuously while the plate rings at its own rates. The signal order is the instrument's: it reaches the transducer first and the transducer's motion excites the body, so drive, asymmetry and saturation sit upstream of the plate, because driving a distorted waveform into a gong is a different sound from distorting a gong. What is published and what is not: the instrument, its date, its excitation and its moving-iron transducer come from the peer-reviewed sources (Najnudel and colleagues, IEEE/ACM TASLP 28, 2020; Wijnand and colleagues, Forum Acusticum 2023), but no ondes-specific measurement of the body exists in any of them, so the mode ratios are Fletcher and Rossing's free circular plate. This is a recreation of the general physics rather than a model of Martenot's gong, and the kernel header says so too. For multichannel signals, wrap the object in an mc. operator. + + + + + 74 Objects + TapTools + Audio + Effects + + + + + + Audio input — drives the transducer, which drives the body. + + + + + + + The body, balanced against the input. + + + + + + + Audio to drive the diffuseur with. + + + Silence the body and reset the driver. Parameters are kept. + + + + + + + Frequency of the plate's (2,0) mode in Hz — the body's perceived pitch. The other seven modes follow at the published free-plate ratios (1 : 1.730 : 2.328 : 3.910 : 4.110 : 6.300 : 6.710 : 7.340), each split into a slowly beating doublet. Retuning moves sixteen resonators, so this is set-and-hold rather than a knob to sweep fast. + + + Ring time of the fundamental in seconds (T60 — the time to fall 60 dB). Long values are a drone; short ones are a plate reverb. + + + How much faster the upper modes die than the fundamental: their ring time is decay divided by ratio raised to tilt. 0 rings everything equally; 2 is roughly the f-squared radiation damping of a struck bar. The default is 1, because a driven gong's shimmer lives in its upper modes. + + + Weight on the upper modes, 0 to 1, steepening per mode so the highest partials go first. 1 is the full published weight table; 0 leaves only the fundamental doublet. + + + Linear gain into the transducer, before the body. This is where the object gets loud and dirty, and it is upstream of the plate on purpose. + + + The moving-iron squared term, 0 to 1. In a moving-iron motor the force follows the square of the gap flux, so with a bias current the residual squared term puts a second harmonic on the signal in proportion to level and nothing at the third. 0 is a linear driver — legitimate, but then a documented stage of the instrument is missing. The coefficient is not fitted to a measurement; the source establishes that the driver is nonlinear without handing over a curve. + + + Soft-clip amount after the squared term; 0 is exactly linear. It is here because a squared law is expansive and something has to bound it — a modelling necessity rather than a measured stage. The output is bounded by 2 divided by this value, not 1 divided by it: removing the DC from a hard-driven squared law doubles the worst-case swing. + + + Balance between the dry input and the diffuseur, 0 to 100, equal-power. The ends are exact: 0 is the input bit for bit, 100 is the body alone. + + + Output level, linear. + + + Anti-zipper ramp time for the transducer, mix and level setters, in milliseconds (0 = instant). The body's own parameters are not ramped. + + + + + + + + + + + diff --git a/docs/tap.ondes~.maxref.xml b/docs/tap.ondes~.maxref.xml new file mode 100644 index 00000000..bd872744 --- /dev/null +++ b/docs/tap.ondes~.maxref.xml @@ -0,0 +1,89 @@ + + + + + The Ondes Martenot voice, minus its loudspeaker + + The tap.ondes~ object is the Ondes Martenot's voice: a heterodyne envelope detector into the demodulator triode into the preamplifier triode into the intensity key. It is a source, not an effect — patch a tap.palme~ or a tap.metallique~ after it and you have the instrument. It is not a VCO with waveform switches. The Ondes Martenot is heterodyne: two oscillators near 80 kHz, one fixed and one moved by the ribbon, summed into an amplitude-modulated signal whose envelope is the note. Najnudel, Hélie, Roze and Boutin (IEEE/ACM TASLP 28, 2020) measure those oscillators at about 0.03 % second harmonic even coupled to the rest of the circuit, so the character is entirely in what comes after them — and the biggest single part of it is the demodulator, before any valve. The envelope of two equal oscillators is 2|cos|, which puts the second harmonic 14 dB below the fundamental and the third 21 dB down. That is why this object does not synthesize a difference tone; doing so would throw the instrument's timbre away. The carrier is not simulated either: the envelope has a closed form, and running the published 200 µs detector on it matches a full 80 kHz simulation to within a tenth of a dB on every harmonic. The performance surface is the instrument's — ribbon is semitones above A1, because the published ribbon law is linear in semitones, so a hand moving at constant speed makes a constant-rate glissando and nothing quantizes; key is the published intensity-key curve from tap.touche~, whose bottom 45 % of travel is silent because that is the key bending before it reaches the powder bag. Both take signals. What is deliberately missing is the waveform registers: the real instrument has switchable timbres whose filter shapes are in none of the sources obtained, and inventing them is the one thing this object will not do. + + + + + 74 Objects + TapTools + Audio + Synthesis + + + + + + Ribbon position, semitones above A1. Overrides the ribbon attribute while connected. + + + Intensity key, 0 to 1 of the travel. Overrides the key attribute while connected. + + + + + + + The voice, before any diffuseur. + + + + + + + The ribbon position (left inlet) or the intensity key (right inlet). + + + Silence the detector and every filter. Parameters are kept. + + + + + + + The note, in semitones above A1 (55 Hz). The circuit paper's Eq. 7 makes the ribbon linear in semitones, so this really is where the hand is: move it linearly and you get a linear glissando. Fractional and unquantized, because the instrument is. Ignored while a signal is connected to the left inlet. + + + The intensity key, 0 to 1 over its physical travel — the published curve from tap.touche~. Roughly the bottom 45 % is silent, which is the key bending before it compresses the powder bag rather than a dead spot in the object. Ignored while a signal is connected to the right inlet. + + + Relative amplitude of the second oscillator, 0 to 1. At 1 (the published case, equal amplitudes) the envelope closes completely and the harmonic series is full; below that it never closes and the tone thins toward a sinusoid. A real mismatch between two oscillators, and the cheapest timbre control here. + + + Envelope-detector time constant in milliseconds. The published value is 0.2, from R4 × C21 = 1 MΩ × 200 pF. Longer smooths the envelope and takes the harmonics with it; it is also why the instrument gets purer and quieter as it goes up, since the RC cannot follow a fast envelope back down. + + + Grid drive into the two valve stages, as a multiple of the published nominal — the harmonics control. The circuit paper's own plugin exposes demodulator input gain the same way, a knob the real instrument does not have. Normalized, so it changes the distortion and not the level. Note that at 0 the tone is still harmonically rich, because the demodulator made those harmonics. + + + Where the intensity key sits: 0 after the valves (a clean output law — pressure is level), 1 before them (pressure drives the valves, so soft is clean and hard is dirty). The circuit paper's five stages do not include the key, so this is a choice rather than a reconstruction — and a real one, worth about 0.09 of total harmonic content at a half-press. + + + Sign of the coupling between the two valve stages, 1 or −1. They are coupled through a transformer whose winding sense is not in the source, and the sign decides which side of the waveform the preamplifier's asymmetry acts on. Audible: the two settings differ by about 0.12 of total harmonic content. + + + Run the 2A3 power stage. Off by default, following the circuit paper: they measure almost 5 % second harmonic there but report its contribution as much less important than the two stages before it, and drop it for real-time. Measured here, switching it on moves total harmonic content by about 0.003 — so they were right, and it is a switch rather than a deletion. + + + Output level, linear. + + + Oversampling for the nonlinear chain: 1, 2, 4 or 8. Every doubling is worth about 12 dB of alias rejection up to 4×, and 7–12 dB more at 8× in the top octave. 4× is the default because that is where the cost stops buying uniformly. + + + Anti-zipper ramp time for the attribute-driven drive, level and key, in milliseconds (0 = instant). The signal inlets are not ramped — a control signal is assumed smooth already. + + + + + + + + + + + diff --git a/docs/tap.palme~.maxref.xml b/docs/tap.palme~.maxref.xml new file mode 100644 index 00000000..d0d24254 --- /dev/null +++ b/docs/tap.palme~.maxref.xml @@ -0,0 +1,86 @@ + + + + + The Ondes Martenot's string diffuseur, as a driven resonator + + The tap.palme~ object is the strangest loudspeaker in music: the Ondes Martenot's palme (1949–50), an electromagnet driving twelve metal strings stretched on a lyre-shaped soundboard. Anything sent through it comes back with a halo, because whichever strings share partials with the signal ring in sympathy and the rest stay quiet. That is the reason the object exists on its own rather than only inside an ondes — sympathetic resonance is useful on any pitched material, and the twelve chromatic strings mean the board answers whatever key you are in. Run a guitar through it. It is driven, not struck, and the driver is upstream: the signal reaches the electromagnet first and its motion excites the strings, so drive, asymmetry and saturation sit before the body. Twelve strings, per the peer-reviewed source (Wijnand and colleagues, Forum Acusticum 2023); widely copied hobbyist build pages say twenty-four, and this follows the peer-reviewed number. Their tuning is not published anywhere found, so tuning is a parameter rather than a reconstruction. For multichannel signals, wrap the object in an mc. operator. + + + + + 74 Objects + TapTools + Audio + Effects + + + + + + Audio input — drives the electromagnet, which drives the strings. + + + + + + + The strings, balanced against the input. + + + + + + + Audio to drive the board with. + + + Damp every string and reset the driver. Parameters are kept. + + + + + + + Pitch of the lowest string in Hz. The other eleven are laid out from it by tuning. + + + How the twelve strings are laid out: 0 chromatic across an octave from the root (a string for every pitch class, so the board answers whatever you play), 1 the harmonic series on the root (a drone that answers one key). Not a published detail of the instrument — a design choice, offered as a choice. + + + Ring time of a string in seconds (T60). Not independent of damping: the loop gain is derived from this and then compensated for what the damping filter takes out, but the compensation is capped just under unity to keep the loop stable, and past that point the damping decides how long the string actually rings. + + + In-loop damping corner in Hz: how quickly a string loses its upper partials. Low values are felt cloth on the strings. + + + Depth in cents of the fixed per-string scatter — no two strings on a real board are in perfect relation. Deterministic, drawn from an index-keyed hash rather than a generator, so the board is the same board in every instance. This is scatter between strings, not stiffness within one: the partials of each string stay exactly harmonic, which a real steel string's do not. + + + Linear gain into the electromagnet, before the strings. + + + The moving-iron squared term, 0 to 1. In a moving-iron motor the force follows the square of the gap flux, so the residual squared term puts a second harmonic on the signal in proportion to level. 0 is a linear driver — legitimate, but then a documented stage of the instrument is missing. The coefficient is not fitted to a measurement. + + + Soft-clip amount after the squared term; 0 is exactly linear. A modelling necessity rather than a measured stage — the squared law is expansive and something has to bound it. + + + Balance between the dry input and the diffuseur, 0 to 100, equal-power. The ends are exact: 0 is the input bit for bit, 100 is the strings alone. + + + Output level, linear. Twelve resonant loops add up — a driven board can be much louder than what went into it. + + + Anti-zipper ramp time for the driver, mix and level setters, in milliseconds (0 = instant). The strings' own parameters are not ramped. + + + + + + + + + + + diff --git a/docs/tap.scrub~.maxref.xml b/docs/tap.scrub~.maxref.xml new file mode 100644 index 00000000..674516f6 --- /dev/null +++ b/docs/tap.scrub~.maxref.xml @@ -0,0 +1,102 @@ + + + + + A granular scrub pad over live capture + + The tap.scrub~ object records its input continuously and puts a granular playhead on the recording whose position and pitch are two independent performable signals. Drag the position and you rake back and forth through the last few seconds of the performance; hold it still and you have a granular freeze; move the pitch and the material transposes without the position moving at all. That decoupling is the object — a tape head cannot do it, because on tape moving the playhead is the pitch change. The two signal inlets are the two axes of a pad, which is the whole patching story: position in the middle inlet, pitch in the right, both at signal rate. Either falls back to its attribute when nothing is connected. What it is built on: grains are Hann-windowed and fired every size divided by overlap samples, and Hann overlap-adds to exactly 1 at those hops, so held still at pitch 0 with spray 0 the object is a plain delay of position milliseconds — measured at 4.4e-16 in the kernel's own suite. Everything else it does is a departure from that, which is only trustworthy because the identity is exact. The tape is the same class tap.stammer~ records into, shared rather than copied. It wants transient material; on a sustained pad a granular playhead is barely distinguishable from a tremolo. For multichannel signals, wrap the object in an mc. operator. + + + + + 74 Objects + TapTools + Audio + Effects + + + + + + Captured history in seconds (default 4). Bought once at DSP start; it is also the ceiling on position. + + + + + + + Audio input — captured continuously unless freeze is on. + + + Playhead position, milliseconds behind the live edge. Overrides the position attribute while connected. + + + Transposition in semitones. Overrides the pitch attribute while connected. + + + + + + + The scrub, balanced against the input. + + + + + + + Audio to capture (left inlet), the playhead position (middle inlet), or the transposition (right inlet). + + + Erase the tape, kill every grain, rewind the drift, and restart the seeded stream. Parameters are kept. + + + + + + + Captured history in seconds (set by the first object argument; bought at DSP start). It is also the ceiling on position. Changing it erases the tape on the next DSP restart. + + + Where the playhead sits, as a lag behind the live edge in milliseconds. 0 is the newest sample; the ceiling is maxhistory. This is the scrub gesture, so it is slewed by smooth. Ignored while a signal is connected to the middle inlet. + + + Transposition in semitones, plus or minus two octaves, independent of the position. A granular texture rather than a clean shift: transposing means reading the tape at a rate the write head does not share, so the read pointer has to be wrapped back periodically and each wrap is a splice. Measured, 98.8 % of a clean shifter's energy still lands at the transposed pitch, but spread into a narrow comb rather than one line, which is audibly a warble. For clean transposition reach for tap.pitchaccum~ or tap.shift~. Ignored while a signal is connected to the right inlet. + + + The playhead's own motion through the tape, in playback-rate units: positive runs forward toward the live edge, negative backwards, 0 holds station. It wraps around the captured history rather than clamping, so a slow drift is a loop. Most useful with freeze on. + + + Stop the recorder. The playhead keeps going, so the position now addresses fixed tape — a granular hold you can still scrub, transpose and drift through. It does not stop time inside a grain already in flight: the tail of one scheduled before the freeze plays out. + + + Grain length in milliseconds. Grains in flight keep the length they were born with. Sizes that divide evenly by overlap have an exactly flat window sum; others leave a small periodic ripple, inaudible at musical sizes. + + + How many grains overlap: the hop is size divided by this. 1 leaves gaps between grains, which is a chopped texture rather than a defect; 2 and up hold a constant level, and 2 is the setting the delay identity depends on. Shrinking size sharply at high overlap can leave every grain slot busy when the next grain is due; that grain is dropped rather than stealing a slot mid-window, since a steal would click. + + + Random scatter of each grain's origin, in milliseconds back from the position. At exactly 0 the dice are never rolled, so the seed cannot matter and the object is deterministic without it. Also the way to trade the transposition's comb for a broadband smear. + + + The spray seed. Deterministic: the same seed and the same moves give the same render, bit for bit, and different instances decorrelate by seed. Only consumed while spray is above 0. + + + Balance between the live input and the scrub, 0 to 100, equal-power. The ends are exact: 0 is the input bit for bit, 100 is the scrub alone. + + + Output level, linear. + + + Anti-zipper ramp time for the attribute-driven position, pitch, drift, mix and level, in milliseconds (0 = instant). The signal inlets are not ramped — a control signal is assumed smooth already. + + + + + + + + + + + diff --git a/docs/tap.stammer~.maxref.xml b/docs/tap.stammer~.maxref.xml new file mode 100644 index 00000000..6d5770be --- /dev/null +++ b/docs/tap.stammer~.maxref.xml @@ -0,0 +1,96 @@ + + + + + A live buffer-stutter rig + + The tap.stammer~ object captures its input continuously and, on a step grid, rolls dice and re-fires a slice of what just went past. density is how often it grabs, divisions how finely it chops (a slice is step divided by a number drawn from 1 to divisions), repeats how many passes it holds on for, reverse the per-repeat chance of running backwards, and jump how far further back it may reach. Riding those while a part plays is the instrument. seed is a real contract rather than a suggestion: every draw comes from a seeded generator in a fixed order, so the same seed and the same moves give the same performance, bit for bit, and two instances decorrelate by seed. At density 0 the dice are never rolled at all — the seed cannot matter and the object is a bitwise bypass. It wants transient material: on a sustained pad a stutter is barely distinguishable from a tremolo, because it re-articulates rhythm that is already in the sound rather than inventing it. + + + + + 74 Objects + TapTools + Audio + Time + + + + + + Captured history in seconds (default 4). Bought at DSP start — about 1.5 MB per 4 s at 48 kHz. A repeat train longer than this reads fresher material as the write head laps it. + + + + + + + Audio input — captured continuously. + + + + + + + The input, or the slice while one is firing. + + + + + + + Audio to be captured, at the input level. + + + Erase the capture, drop the slice in flight, and rewind the seeded stream — the same seed replays the same performance from here. Parameters are kept. + + + + + + + Captured history in seconds (set by the first object argument). Changing it erases the capture when the DSP chain restarts. + + + The rhythmic grid in milliseconds: how often the machine may decide to grab. Takes effect at the next grid point — it is a rhythm, not a level, so there is nothing to zipper. + + + Chance of firing at an idle grid point (0..1). At exactly 0 the dice are never rolled, so the seed cannot matter and the object is a bitwise bypass. + + + How finely the grid may be chopped (1..8): a slice is step divided by a number drawn from 1 to this. 1 means whole-step slices only. + + + Upper bound on passes per fired slice (1..16); the count is drawn from 1 to this. A slice in flight is never interrupted, so this — not density — is what decides how long the machine stays busy. + + + Chance that any given repeat plays backwards (0..1). Drawn per repeat, so one train can stagger forwards and back. + + + How far back beyond the material just past a slice may reach, in milliseconds; the actual reach is drawn from 0 to this. 0 is the classic stutter — open it and the machine starts quoting older material. + + + Raised-sine flank per repeat in milliseconds — the anti-click. Clamped per slice to half its length. Repeats are sequential, so each junction dips to zero: that is the articulation of a stutter, not a crossfade that failed. + + + The performance seed. Same seed and same moves, same render, bit for bit; different instances decorrelate by seed. 0 folds to the stock seed. + + + Input level into the capture, linear, slewed. + + + Balance between the live input and the slice in percent (0..100), equal-power — and it only bites while a slice is firing. When the machine is idle the input passes through bitwise at any mix. + + + Anti-zipper ramp time for the input and mix setters, in milliseconds (0 = instant). + + + + + + + + + + + diff --git a/docs/tap.tapecho~.maxref.xml b/docs/tap.tapecho~.maxref.xml new file mode 100644 index 00000000..dbfe5c5b --- /dev/null +++ b/docs/tap.tapecho~.maxref.xml @@ -0,0 +1,105 @@ + + + + + A multi-head tape echo + + The tap.tapecho~ object is a tape echo in the Copicat / Space Echo tradition: one record head, a span of moving tape, and up to four playback heads at settable positions along it, summed to stereo. span is the motor — the delay of a head at the far end of the path — and each head sits at span times its ratios entry, so moving span moves the whole layout together and bends pitch while it moves, which is what a tape speed change is. It shares its tape machinery with tap.discreet~ and carries that object's inversion further: where tap.delay~ caps feedback below 1 and tap.discreet~ reaches exactly 1.0, here regen goes past 1.0 into deliberate sound-on-sound self-oscillation, bounded by the drive saturator rather than by a gain cap — and capped back to 1.0 whenever drive is 0, since the saturator is the only thing holding that regime. + + + + + 74 Objects + TapTools + Audio + Time + + + + + + Worst-case tape span in seconds (default 4). The tape is bought at DSP start — about 1.5 MB per 4 s at 48 kHz — and bounds the span attribute. + + + + + + + Audio input — the send into the record head. + + + + + + + The head sum plus dry, left. + + + The head sum plus dry, right. + + + + + + + Audio to be recorded onto the tape, at the input send level. + + + Erase the tape and the transport and wear state — the eject button, and the fastest way to stop a self-oscillating loop. Parameters are kept. + + + + + + + Worst-case tape span in seconds (set by the first object argument). Changing it erases the tape when the DSP chain restarts. + + + The motor: the delay in milliseconds of a head at the far end of the path (ratio 1.0), so every head moves with it. Slewed — and the slew is tape-speed doppler: the pitch bends while the motor changes speed. smooth sets how long that takes. + + + Number of active playback heads (0 to 4). A mute, not a freeze: inactive heads keep ramping, so bringing one back mid-glide does not jump. + + + Per-head position along the tape path as a fraction of span, 0 to 1, slewed. The default is four evenly spaced heads (0.25 0.5 0.75 1.) — a nominal layout, not measured from any machine. Set 0.333 0.667 1. with heads 3 for a Copicat-style three. + + + Per-head linear level, slewed. Unclamped (negative flips polarity, like a mixer). This is also the head selector: a head's level is its send into the regeneration path too. + + + Per-head equal-power pan, -1 (hard left) to 1 (hard right), slewed. Endpoints are exact: a hard-panned head is bitwise absent from the far bus. + + + Regeneration into the record head (0..1.5). Past 1.0 the loop self-oscillates, bounded by the drive saturator; values above 1.0 only take effect while drive is above zero. + + + Per-pass darkening corner in Hz (20..20000). Every trip through the regeneration path runs through this one-pole lowpass: the repeats lose treble first. Riding it while the loop howls is a performance control. + + + Record-head saturation drive (0 or more). Above zero the loop is absolutely bounded (the returned signal can never exceed 1/drive), which is what makes regen above 1.0 safe; 0 is exactly linear and caps the effective regeneration back to 1.0. + + + Input level into the record head, linear, slewed. Fading this to zero while the loop self-oscillates is the sound-on-sound move: the machine keeps playing what you already gave it. + + + Dry/wet mix in percent (0..100), equal-power. 0 is bitwise dry on both outlets, 100 bitwise the head sum. + + + Wow as a depth/rate pair: excursion in milliseconds of tape position, rate in Hz (up to 5). One motor moves the whole path, so every head is displaced together. Deterministic and periodic; 0 0 parks the transport. + + + Flutter as a depth/rate pair — the faster, shallower partner (rate up to 30 Hz). + + + Anti-zipper ramp time for the setters in milliseconds (0 = instant). For span moves this is how long the motor takes to change speed, and therefore how deep the doppler bend is. + + + + + + + + + + + diff --git a/docs/tap.touche~.maxref.xml b/docs/tap.touche~.maxref.xml new file mode 100644 index 00000000..a98f29b7 --- /dev/null +++ b/docs/tap.touche~.maxref.xml @@ -0,0 +1,68 @@ + + + + + The Ondes Martenot intensity key as a gain law + + The tap.touche~ object is the Ondes Martenot's touche d'intensité — the pressure key the player's left hand rides, which Messiaen called the instrument's greatest invention — reduced to what it is underneath the mechanism: a gain curve of unusual quality, 50 dB of range across about 4.5 mm of key travel, shaped so a player can place a crescendo where they want it rather than where the taper puts it. The curve is not modelled. It is the published measurement of Quartier, Meurisse, Colmars, Frelat and Vaiedelich (Acta Acustica united with Acustica 101(2), 421–428, 2015), who measured finger force, key displacement and the resulting sound simultaneously on instrument No. 320; this object interpolates their seven measured points with monotone cubic segments and does not wander between them. It is the first piece of the Ondes Martenot in the package, and it is here on its own because a measured expressive gain law is useful on anything — as a swell pedal on a guitar, or as the last VCA in any chain. + + + + + 74 Objects + TapTools + Audio + Effects + + + + + + Audio input. + + + Key position, 0 to 1 of the travel. Overrides the position attribute while connected. + + + + + + + The input, scaled by the key. + + + + + + + Audio to scale (left inlet), or the key position (right inlet). + + + Return the key to rest (silent). + + + + + + + Key position, 0 to 1 over the physical 9.5 mm travel. Roughly the first 45 % is silent — that is the key bending before it compresses the powder bag, not a dead spot in the object, and it is why the instrument can be played with such sharp attacks: the useful 50 dB is packed into the 4.5 mm right after it. Ignored while a signal is connected to the right inlet. + + + The same control in the published unit: millimetres of key travel. The measured band runs from 4.3 mm (the instrument's own noise floor) to 8.8 mm (full press). + + + Which measured column drives the gain: 0 displacement, 1 finger force. Displacement is the primary map — the paper reports that the sound depends on where the key is, and notably not on how fast the gesture was. The force axis is far from uniform (six of the seven measured points sit below 1.4 N and the seventh is at 9.6 N), so it is offered for completeness rather than as a good controller. + + + Anti-zipper ramp time for attribute-driven position moves, in milliseconds (0 = instant). The signal inlet is not ramped — a control signal is assumed smooth already. + + + + + + + + + + + diff --git a/docs/tap.triode~.maxref.xml b/docs/tap.triode~.maxref.xml new file mode 100644 index 00000000..40990cf2 --- /dev/null +++ b/docs/tap.triode~.maxref.xml @@ -0,0 +1,68 @@ + + + + + One triode gain stage, from a published tube model + + The tap.triode~ object is a common-cathode valve stage, solved on its load line: a conditioning highpass, drive into the grid, the tube's own transfer curve, an equalization lowpass. The architecture is Yeh, Abel and Smith's DAFx-07 simplified cascade — the same one tap.fuzz~ uses — but where that object reaches for a tanh, this one solves an actual valve. Almost nothing here is voiced by ear: the tube is the enhanced Norman Koren model (Koren, Glass Audio 8(5), 1996, with the grid-current branch from Cohen and Hélie, AES 129, 2010), and the parameter sets are fitted to the valves in ondes Martenot No. 169 by Najnudel, Hélie, Roze and Boutin (IEEE/ACM TASLP 28, 2020, Table II), along with the supply voltage, cathode resistor and plate load of each stage. Two properties matter before you patch it. The stage inverts, as a real common-cathode stage does — and that is not cosmetic, because the valve's asymmetry acts on whichever side of the waveform reaches its grid. And it is strongly asymmetric: at the demodulator's operating point, equal grid swings either way give plate swings in a 2.17 : 1 ratio, which is where a triode's even harmonics come from. drive is normalized out of the level, so turning it up gets dirtier rather than louder. For multichannel signals, wrap the object in an mc. operator. + + + + + 74 Objects + TapTools + Audio + Effects + + + + + + Audio input — the grid. + + + + + + + The plate, inverted and normalized to unity small-signal gain. + + + + + + + Audio to drive the grid with. + + + Reset the stage's filters. Parameters are kept. + + + + + + + Which valve: 0 the 6F5 (the ondes oscillators'), 1 the 6C5 (the demodulator's and preamplifier's), 2 the 2A3 (the power amplifier's). A mode rather than a fader — it rebuilds the stage's curve table, which is sub-millisecond but not something to automate at audio rate. + + + Which published operating point: 0 demodulator (100 V supply, 1 k cathode resistor, 4 k plate load), 1 preamplifier (180 V), 2 power amplifier (230 V, 750 R, 1.5 k). Also a mode. The pairings the instrument uses are tube 1 with stages 0 and 1, and tube 2 with stage 2 — but any combination will solve, and some of the wrong ones are interesting. + + + Peak grid volts for a full-scale input — the distortion control. It is normalized out of the level: the small-signal gain stays at unity whatever you set, so turning it up gets dirtier rather than louder. That is the gain-staging lesson tap.fuzz~ learned the hard way, applied here from the start. + + + Conditioning highpass in Hz — the coupling capacitor and grid leak, and the first filter of the DAFx-07 cascade. + + + Equalization lowpass in Hz — Miller capacitance and the plate load's own pole, and the last filter of the cascade. + + + + + + + + + + + diff --git a/help/tap.fuzz~.maxhelp b/help/tap.fuzz~.maxhelp new file mode 100644 index 00000000..afa8a0d8 --- /dev/null +++ b/help/tap.fuzz~.maxhelp @@ -0,0 +1,815 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 720.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": "A two-stage tone-stacked fuzz: cascaded clipping stages with a bass / contrast / treble voicing section. gain drives the first stage, edge sharpens the second stage's knee, and asymmetry brings in the even harmonics a symmetric curve cannot make.", + "digest": "A two-stage tone-stacked fuzz", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.fuzz~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "A two-stage tone-stacked fuzz", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 44 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Two cascaded clipping stages and a voicing section \u2014 the harder, more scooped school beside tap.overdrive~. gain drives the first stage, edge sharpens the second stage's knee toward a hard corner, and asymmetry brings in the even harmonics a symmetric curve cannot make. Built on the Yeh/Abel/Smith DAFx-07 cascade; a recreation of a circuit class, not a model of any one pedal.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20, + 175, + 340, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a source (a plucky tone shows the knee best):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20, + 200, + 150, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "saw~ 110" + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "gain~", + "patching_rect": [ + 20, + 230, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "comment", + "patching_rect": [ + 60, + 245, + 300, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "how hard you hit the front end", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "patching_rect": [ + 20, + 330, + 330, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "tap.fuzz~ @gain 0.6 @contrast 0.4" + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "gain~", + "patching_rect": [ + 20, + 380, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 460, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "comment", + "patching_rect": [ + 400, + 175, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the dirt:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "attrui", + "patching_rect": [ + 400, + 200, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "gain" + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "attrui", + "patching_rect": [ + 400, + 225, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "edge" + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "attrui", + "patching_rect": [ + 400, + 250, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "asymmetry" + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "attrui", + "patching_rect": [ + 400, + 275, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "level" + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "comment", + "patching_rect": [ + 400, + 320, + 250, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "aliasing (2 is the measured best):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "attrui", + "patching_rect": [ + 400, + 345, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "oversample" + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "comment", + "patching_rect": [ + 400, + 372, + 270, + 48 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "every factor beats 1 by orders of magnitude, but the sequence is not monotone here \u2014 bigger is not automatically better.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "comment", + "patching_rect": [ + 670, + 175, + 250, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the voicing:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "attrui", + "patching_rect": [ + 670, + 200, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "bass" + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "attrui", + "patching_rect": [ + 670, + 225, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "treble" + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "attrui", + "patching_rect": [ + 670, + 250, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "contrast" + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "comment", + "patching_rect": [ + 670, + 295, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a few places to start:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "message", + "patching_rect": [ + 670, + 320, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "gain 0.3, edge 0.2, contrast 0., bass 0." + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "message", + "patching_rect": [ + 670, + 345, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "gain 0.8, edge 0.6, contrast 1., bass 0.4" + } + }, + { + "box": { + "id": "obj-28", + "maxclass": "message", + "patching_rect": [ + 670, + 370, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "gain 0.9, edge 1., asymmetry 0.7, oversample 8" + } + }, + { + "box": { + "id": "obj-29", + "maxclass": "message", + "patching_rect": [ + 670, + 395, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear" + } + }, + { + "box": { + "id": "obj-30", + "maxclass": "comment", + "patching_rect": [ + 20, + 530, + 920, + 50 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: start clean and walk gain up \u2014 the second stage is what saturates, so most of the change happens in the first half of the knob. Then bring edge up for the corner, and asymmetry for the even harmonics that make it sound lopsided rather than merely loud. contrast is the scoop this class of pedal is known for; it is a real mid cut, not a fader.", + "fontsize": 12.0, + "fontname": "Arial" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-27", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-28", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-29", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.metallique~.maxhelp b/help/tap.metallique~.maxhelp new file mode 100644 index 00000000..a7aa6cf3 --- /dev/null +++ b/help/tap.metallique~.maxhelp @@ -0,0 +1,769 @@ +{ + "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 Ondes Martenot's motor-driven gong, as a bank of plate modes excited continuously by whatever you send in. The transducer sits upstream of the body, which is the order the instrument wires them.", + "digest": "The Ondes Martenot's gong diffuseur, as a driven resonator", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + "id": "obj-1" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.metallique~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "id": "obj-2" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The Ondes Martenot's gong diffuseur, as a driven resonator", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ], + "id": "obj-3" + } + }, + { + "box": { + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat", + "id": "obj-4" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 58 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Driven, not struck: there is no trigger here. Whatever goes in excites the plate continuously and the plate rings at its own rates \u2014 eight modes at the free circular plate's transverse ratios, each split into a slowly beating doublet. The moving-iron transducer (drive / asymmetry / saturation) is UPSTREAM of the body, because that is where the instrument puts it: driving a distorted waveform into a gong is a different sound from distorting a gong.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-5" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 190, + 400, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "an impulse, the quickest way to hear the modes:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-6" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 20, + 215, + 60, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "bang", + "id": "obj-7" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 245, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "click~", + "outlettype": [ + "signal" + ], + "id": "obj-8" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 230, + 240, + 320, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "\u2190 click it. But the object is DRIVEN: put something sustained in and it keeps ringing while the input does", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-9" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 330, + 420, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.metallique~ @pitch 146 @decay 7 @mix 70 @drive 1.6 @asymmetry 0.4", + "outlettype": [ + "signal" + ], + "id": "obj-10" + } + }, + { + "box": { + "maxclass": "gain~", + "patching_rect": [ + 20, + 390, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0, + "id": "obj-11" + } + }, + { + "box": { + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 470, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "id": "obj-12" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 190, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the body:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-13" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 215, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "pitch", + "id": "obj-14" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 240, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "decay", + "id": "obj-15" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 265, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "tilt", + "id": "obj-16" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 290, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "brightness", + "id": "obj-17" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 320, + 240, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "pitch retunes sixteen resonators, so it is set-and-hold rather than a knob to sweep", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-18" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 710, + 190, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the driver, and the balance:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-19" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 215, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "drive", + "id": "obj-20" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 240, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "asymmetry", + "id": "obj-21" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 265, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "saturation", + "id": "obj-22" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 290, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "mix", + "id": "obj-23" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 315, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "level", + "id": "obj-24" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 340, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "smooth", + "id": "obj-25" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 710, + 375, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear", + "id": "obj-26" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 710, + 400, + 240, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "silence the body without disturbing any of the settings", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-27" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 545, + 920, + 78 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: set mix to 100 and asymmetry to 0 \u2014 that is a linear resonator, and it is missing a documented stage of the instrument. Bring asymmetry up and the driver starts adding a second harmonic in proportion to level, which is the moving-iron principle (force follows the square of the gap flux). Push drive hard and the saturator holds it bounded. Then put something that is not an ondes through it: a snare, a whole mix at a low mix setting. The mode ratios are Fletcher & Rossing's general plate physics rather than a measurement of Martenot's gong, so this is a recreation \u2014 stated here, in the reference page, and in the kernel header.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-28" + } + } + ], + "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-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + } + ] + } +} \ No newline at end of file diff --git a/help/tap.ondes~.maxhelp b/help/tap.ondes~.maxhelp new file mode 100644 index 00000000..70cc0482 --- /dev/null +++ b/help/tap.ondes~.maxhelp @@ -0,0 +1,893 @@ +{ + "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": "A heterodyne envelope detector into two triode stages into the intensity key. A source: patch tap.palme~ or tap.metallique~ after it for the rest of the instrument. The ribbon is linear in semitones and nothing quantizes.", + "digest": "The Ondes Martenot voice, minus its loudspeaker", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + "id": "obj-1" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.ondes~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "id": "obj-2" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The Ondes Martenot voice, minus its loudspeaker", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ], + "id": "obj-3" + } + }, + { + "box": { + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat", + "id": "obj-4" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 72 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Not a VCO with waveform switches. The instrument is HETERODYNE: two oscillators near 80 kHz, one moved by the ribbon, summed \u2014 and the envelope of that sum is the note. Those oscillators measure as essentially pure, so all the character is downstream: the envelope of two equal oscillators is 2|cos|, which already puts the 2nd harmonic 14 dB down and the 3rd 21 dB down before any valve touches it. The carrier is never simulated \u2014 the envelope has a closed form that matches a full 80 kHz simulation to a tenth of a dB.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-5" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 205, + 300, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the two hands:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-6" + } + }, + { + "box": { + "maxclass": "slider", + "patching_rect": [ + 20, + 230, + 22, + 200 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "size": 1001.0, + "orientation": 1, + "id": "obj-7" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 60, + 230, + 170, + 22 + ], + "numinlets": 6, + "numoutlets": 1, + "text": "scale 0 1000 0. 48.", + "outlettype": [ + "" + ], + "id": "obj-8" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 60, + 260, + 170, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "sig~ 24.", + "outlettype": [ + "signal" + ], + "id": "obj-9" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 60, + 290, + 200, + 48 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the RIBBON, in semitones above A1 \u2014 linear in semitones, so this slider is a glissando", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-10" + } + }, + { + "box": { + "maxclass": "slider", + "patching_rect": [ + 280, + 230, + 22, + 200 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "size": 1001.0, + "orientation": 1, + "id": "obj-11" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 320, + 230, + 170, + 22 + ], + "numinlets": 6, + "numoutlets": 1, + "text": "scale 0 1000 0. 1.", + "outlettype": [ + "" + ], + "id": "obj-12" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 320, + 260, + 170, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "sig~ 1.", + "outlettype": [ + "signal" + ], + "id": "obj-13" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 320, + 290, + 200, + 62 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the intensity KEY. Its bottom 45% is silent \u2014 that is the key bending before it reaches the powder bag, not a broken object", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-14" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 460, + 470, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "tap.ondes~ @drive 2.5 @level 0.5 @smooth 4", + "outlettype": [ + "signal" + ], + "id": "obj-15" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 500, + 470, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.palme~ @root 110 @mix 55 @level 0.5", + "outlettype": [ + "signal" + ], + "id": "obj-16" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 530, + 470, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the diffuseur is a separate object, exactly as it is a separate cabinet on the real instrument \u2014 swap in tap.metallique~, or delete it for the principal", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-17" + } + }, + { + "box": { + "maxclass": "gain~", + "patching_rect": [ + 20, + 580, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0, + "id": "obj-18" + } + }, + { + "box": { + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 660, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "id": "obj-19" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 540, + 205, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the circuit:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-20" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 540, + 230, + 230, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "depth", + "id": "obj-21" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 540, + 255, + 230, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "detect", + "id": "obj-22" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 540, + 280, + 230, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "drive", + "id": "obj-23" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 540, + 305, + 230, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "level", + "id": "obj-24" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 540, + 330, + 230, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "oversample", + "id": "obj-25" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 540, + 360, + 250, + 62 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "depth is the oscillator balance: at 1 the envelope closes and the tone is full, below that it thins. detect is the published 200 us RC \u2014 lengthen it and the harmonics go with it.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-26" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 800, + 205, + 220, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the choices, and the switch:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-27" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 800, + 230, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "keyplacement", + "id": "obj-28" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 800, + 255, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "polarity", + "id": "obj-29" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 800, + 280, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "power", + "id": "obj-30" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 800, + 315, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear", + "id": "obj-31" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 800, + 345, + 220, + 76 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "keyplacement and polarity are things the sources do not settle \u2014 whether pressure means level or dirt, and which way the transformer is wound. Both are audible, so both are switches rather than silent guesses.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-32" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 690, + 980, + 92 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: hold the key up and drag the ribbon slowly. It glides, because it is a ribbon \u2014 there are no notes to snap to. Then set keyplacement to 1 and play the key again: pressure now drives the valves, so soft is clean and hard is dirty, which is a very different instrument under the hand. Turn drive all the way down and notice the tone is still not a sine \u2014 the demodulator made those harmonics, not the valves. And what is missing on purpose: the waveform registers. The real instrument has switchable timbres whose filter shapes are in none of the sources, and inventing them is the one thing this object will not do.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-33" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-9", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-13", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-15", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-16", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-18", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-19", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-19", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-28", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-29", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-30", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-31", + 0 + ], + "destination": [ + "obj-15", + 0 + ] + } + } + ] + } +} \ No newline at end of file diff --git a/help/tap.palme~.maxhelp b/help/tap.palme~.maxhelp new file mode 100644 index 00000000..5ea555ae --- /dev/null +++ b/help/tap.palme~.maxhelp @@ -0,0 +1,861 @@ +{ + "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": "An electromagnet driving twelve metal strings on a soundboard. Whichever strings share partials with the input ring in sympathy and the rest stay quiet. Run a guitar through it.", + "digest": "The Ondes Martenot's string diffuseur, as a driven resonator", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + "id": "obj-1" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.palme~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "id": "obj-2" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The Ondes Martenot's string diffuseur, as a driven resonator", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ], + "id": "obj-3" + } + }, + { + "box": { + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat", + "id": "obj-4" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 58 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Twelve metal strings on a lyre-shaped soundboard, driven by an electromagnet. Anything sent through comes back with a halo, because the strings that share partials with the signal ring and the rest stay quiet. Twelve, per the peer-reviewed source \u2014 the widely copied figure of twenty-four is not followed. Their tuning is not published anywhere found, so it is a parameter rather than a reconstruction: chromatic answers every key, harmonic answers one.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-5" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 190, + 340, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "play it something pitched:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-6" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 20, + 215, + 200, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "0, 1 5 0 400", + "id": "obj-7" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 245, + 200, + 22 + ], + "numinlets": 2, + "numoutlets": 2, + "text": "line~", + "outlettype": [ + "signal", + "bang" + ], + "id": "obj-8" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 275, + 200, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "cycle~ 130.81", + "outlettype": [ + "signal" + ], + "id": "obj-9" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 305, + 200, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "*~", + "outlettype": [ + "signal" + ], + "id": "obj-10" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 230, + 218, + 300, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "\u2190 a short note on D3, which IS one of the strings at root 110", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-11" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 350, + 420, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.palme~ @root 110 @tuning 0 @decay 5 @mix 55 @level 0.5", + "outlettype": [ + "signal" + ], + "id": "obj-12" + } + }, + { + "box": { + "maxclass": "gain~", + "patching_rect": [ + 20, + 400, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0, + "id": "obj-13" + } + }, + { + "box": { + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 480, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "id": "obj-14" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 190, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the board:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-15" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 215, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "root", + "id": "obj-16" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 240, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "tuning", + "id": "obj-17" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 265, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "decay", + "id": "obj-18" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 290, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "damping", + "id": "obj-19" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 315, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "detune", + "id": "obj-20" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 345, + 240, + 48 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "decay and damping are not independent \u2014 a heavily damped string cannot ring for the time you ask, and past that point the damping decides", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-21" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 710, + 190, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the driver, and the balance:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-22" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 215, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "drive", + "id": "obj-23" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 240, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "asymmetry", + "id": "obj-24" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 265, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "saturation", + "id": "obj-25" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 290, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "mix", + "id": "obj-26" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 315, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "level", + "id": "obj-27" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 710, + 340, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "smooth", + "id": "obj-28" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 710, + 375, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear", + "id": "obj-29" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 710, + 400, + 240, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "damp every string without disturbing the settings", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-30" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 555, + 920, + 78 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: play the note above, then retune the cycle~ a quarter-tone sharp (135) and play it again. On a string the board rings for seconds after the note stops; a quarter-tone off it barely answers at all, and the difference grows steeply toward the top of the board because a loop's Q scales with frequency at a fixed ring time. Then switch tuning to 1 (harmonic on the root) and hear it stop answering anything but A. The real point of the object is on other material: a guitar, a voice, a snare.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-31" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-13", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-14", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-27", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-28", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-29", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + } + ] + } +} \ No newline at end of file diff --git a/help/tap.scrub~.maxhelp b/help/tap.scrub~.maxhelp new file mode 100644 index 00000000..4210233c --- /dev/null +++ b/help/tap.scrub~.maxhelp @@ -0,0 +1,879 @@ +{ + "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": "Record continuously and rake a granular playhead through it. Position and pitch are two independent signals \u2014 the two axes of a pad \u2014 and freeze stops the recorder so the position addresses fixed tape.", + "digest": "A granular scrub pad over live capture", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + "id": "obj-1" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.scrub~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "id": "obj-2" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "A granular scrub pad over live capture", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ], + "id": "obj-3" + } + }, + { + "box": { + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat", + "id": "obj-4" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 58 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The input is recorded continuously and a granular playhead reads it. POSITION (the middle inlet) is how far behind the live edge the head sits; PITCH (the right inlet) transposes without the position moving. Those two hands are the object \u2014 a tape head cannot do it, because on tape moving the playhead IS the pitch change. Held still at pitch 0 with no spray, this is exactly a delay.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-5" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 190, + 340, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "something with transients to scrub:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-6" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 215, + 240, + 22 + ], + "numinlets": 1, + "numoutlets": 2, + "text": "adc~", + "outlettype": [ + "signal", + "signal" + ], + "id": "obj-7" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 270, + 218, + 280, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "\u2190 or swap in a play~ / a drum loop; a sustained pad has nothing to bite on", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-8" + } + }, + { + "box": { + "maxclass": "toggle", + "patching_rect": [ + 20, + 250, + 24, + 24 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ], + "parameter_enable": 0, + "id": "obj-9" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 50, + 253, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "audio on", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-10" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 390, + 480, + 22 + ], + "numinlets": 3, + "numoutlets": 1, + "text": "tap.scrub~ 4. @size 70 @overlap 2 @mix 100 @smooth 8", + "outlettype": [ + "signal" + ], + "id": "obj-11" + } + }, + { + "box": { + "maxclass": "gain~", + "patching_rect": [ + 20, + 440, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0, + "id": "obj-12" + } + }, + { + "box": { + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 520, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "id": "obj-13" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 300, + 190, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "position \u2014 ms behind the live edge:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-14" + } + }, + { + "box": { + "maxclass": "slider", + "patching_rect": [ + 300, + 215, + 22, + 150 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "size": 1001.0, + "orientation": 1, + "id": "obj-15" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 340, + 215, + 150, + 22 + ], + "numinlets": 6, + "numoutlets": 1, + "text": "scale 0 1000 0. 2000.", + "outlettype": [ + "" + ], + "id": "obj-16" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 340, + 245, + 150, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "sig~ 0.", + "outlettype": [ + "signal" + ], + "id": "obj-17" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 340, + 275, + 200, + 48 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "drag it. the head rakes through the last two seconds; the grains hide the seams", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-18" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 560, + 190, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "pitch \u2014 semitones, independently:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-19" + } + }, + { + "box": { + "maxclass": "slider", + "patching_rect": [ + 560, + 215, + 22, + 150 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "size": 1001.0, + "orientation": 1, + "id": "obj-20" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 600, + 215, + 150, + 22 + ], + "numinlets": 6, + "numoutlets": 1, + "text": "scale 0 1000 -12. 12.", + "outlettype": [ + "" + ], + "id": "obj-21" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 600, + 245, + 150, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "sig~ 0.", + "outlettype": [ + "signal" + ], + "id": "obj-22" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 600, + 275, + 220, + 48 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a granular texture, not a hi-fi shift \u2014 use tap.pitchaccum~ when you want the transposition clean", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-23" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 560, + 330, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "and the rest:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-24" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 560, + 355, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "freeze", + "id": "obj-25" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 560, + 380, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "drift", + "id": "obj-26" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 560, + 405, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "size", + "id": "obj-27" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 560, + 430, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "overlap", + "id": "obj-28" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 560, + 455, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "spray", + "id": "obj-29" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 560, + 480, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "seed", + "id": "obj-30" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 560, + 510, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear", + "id": "obj-31" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 670, + 513, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "erase the tape and rewind the seed", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-32" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 595, + 920, + 92 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: with audio running, drag the position slider slowly and then quickly. Then turn FREEZE on \u2014 the recorder stops, but the playhead does not, so everything you hear from that moment is made out of the tape you already have. Scrub it, transpose it, or set drift to a small value and let the head crawl through on its own. Bring spray up and the grain origins scatter, which turns the transposition's comb into a broadband smear. With spray at 0 the dice are never rolled at all, so the seed provably cannot matter and two instances stay identical.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-33" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-16", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-17", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-11", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-21", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-22", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-11", + 2 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-13", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-13", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-27", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-28", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-29", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-30", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-31", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + } + ] + } +} \ No newline at end of file diff --git a/help/tap.stammer~.maxhelp b/help/tap.stammer~.maxhelp new file mode 100644 index 00000000..ea70ce2e --- /dev/null +++ b/help/tap.stammer~.maxhelp @@ -0,0 +1,891 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 720.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": "A live buffer-stutter rig: the input is captured continuously and, on a step grid, the machine rolls dice and re-fires a slice of what just went past. The seed is a contract \u2014 same seed, same performance, bit for bit; at density 0 the dice are never rolled and the object is a bitwise bypass.", + "digest": "A live buffer-stutter rig", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.stammer~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "A live buffer-stutter rig", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 44 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The input is captured continuously; on a step grid the machine rolls dice and re-fires a slice of what just went past. Ride density, divisions, repeats and reverse and the part comes apart in your hands. The seed is a contract, not a suggestion: same seed, same performance, bit for bit. Feed it transients \u2014 on a sustained pad a stutter is barely a tremolo.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20, + 175, + 340, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a source (transients \u2014 the documented material):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20, + 200, + 120, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "train~ 1000" + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "gain~", + "patching_rect": [ + 20, + 230, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "comment", + "patching_rect": [ + 60, + 245, + 300, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the send into the capture", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "patching_rect": [ + 20, + 330, + 340, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "tap.stammer~ 4. @step 250 @density 0.6" + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "gain~", + "patching_rect": [ + 20, + 380, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 460, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "comment", + "patching_rect": [ + 420, + 175, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the dice:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "attrui", + "patching_rect": [ + 420, + 200, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "step" + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "attrui", + "patching_rect": [ + 420, + 225, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "density" + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "attrui", + "patching_rect": [ + 420, + 250, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "divisions" + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "attrui", + "patching_rect": [ + 420, + 275, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "repeats" + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "attrui", + "patching_rect": [ + 420, + 300, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "reverse" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "attrui", + "patching_rect": [ + 420, + 325, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "jump" + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "comment", + "patching_rect": [ + 690, + 175, + 250, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the performance seed:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "attrui", + "patching_rect": [ + 690, + 200, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "seed" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "message", + "patching_rect": [ + 690, + 230, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "seed 1" + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "message", + "patching_rect": [ + 800, + 230, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "seed 2" + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "comment", + "patching_rect": [ + 690, + 258, + 260, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "same seed, same performance \u2014 bit for bit. Switch and switch back.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "comment", + "patching_rect": [ + 690, + 305, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "switched off:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "message", + "patching_rect": [ + 690, + 330, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "density 0." + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "comment", + "patching_rect": [ + 690, + 357, + 260, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the dice are never rolled: a bitwise bypass, and the seed cannot matter.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-28", + "maxclass": "comment", + "patching_rect": [ + 420, + 365, + 250, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "come apart:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-29", + "maxclass": "message", + "patching_rect": [ + 420, + 390, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "density 0.2, divisions 1, repeats 1" + } + }, + { + "box": { + "id": "obj-30", + "maxclass": "message", + "patching_rect": [ + 420, + 415, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "density 0.9, divisions 8, repeats 10" + } + }, + { + "box": { + "id": "obj-31", + "maxclass": "message", + "patching_rect": [ + 420, + 440, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "jump 1500, reverse 0.6" + } + }, + { + "box": { + "id": "obj-32", + "maxclass": "message", + "patching_rect": [ + 690, + 410, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear" + } + }, + { + "box": { + "id": "obj-33", + "maxclass": "comment", + "patching_rect": [ + 20, + 530, + 920, + 50 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: start from the gentle setting and walk to the hard one while the source plays \u2014 density decides how often it grabs, but repeats is what actually holds it, because a slice in flight is never interrupted. Then open jump so it starts quoting material from seconds ago rather than the bar just played. clear erases the capture and rewinds the seeded stream, so the same seed replays from there.", + "fontsize": 12.0, + "fontname": "Arial" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-15", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-29", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-30", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-31", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-32", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.tapecho~.maxhelp b/help/tap.tapecho~.maxhelp new file mode 100644 index 00000000..6072a1f5 --- /dev/null +++ b/help/tap.tapecho~.maxhelp @@ -0,0 +1,982 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 720.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": "A multi-head tape echo: one record head, a span of moving tape, and up to four playback heads along it. span is the motor and moves every head together, bending pitch as it goes. Past 1.0 the regeneration self-oscillates, bounded by the drive saturator rather than by a feedback cap.", + "digest": "A multi-head tape echo", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.tapecho~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "A multi-head tape echo", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 44 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "One record head, a span of moving tape, and up to four playback heads along it. span is the motor \u2014 the delay of the head at the far end \u2014 so moving it moves every head together and bends pitch on the way, which is what a tape speed change is. Past 1.0, regen self-oscillates: the drive saturator is what bounds it, not a feedback cap.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20, + 175, + 330, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a source (clicks show the head layout plainly):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20, + 200, + 120, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "train~ 2000" + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "gain~", + "patching_rect": [ + 20, + 230, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "comment", + "patching_rect": [ + 60, + 245, + 300, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the send fader \u2014 or ride the input attribute", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "patching_rect": [ + 20, + 330, + 370, + 22 + ], + "numinlets": 1, + "numoutlets": 2, + "outlettype": [ + "signal", + "signal" + ], + "text": "tap.tapecho~ 4. @span 400 @regen 0.5 @mix 50" + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "gain~", + "patching_rect": [ + 20, + 380, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "gain~", + "patching_rect": [ + 60, + 380, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "comment", + "patching_rect": [ + 100, + 395, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "left / right \u2014 the heads are panned", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 460, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "comment", + "patching_rect": [ + 420, + 175, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the machine:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "attrui", + "patching_rect": [ + 420, + 200, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "span" + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "attrui", + "patching_rect": [ + 420, + 225, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "heads" + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "attrui", + "patching_rect": [ + 420, + 250, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "regen" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "attrui", + "patching_rect": [ + 420, + 275, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "mix" + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "comment", + "patching_rect": [ + 420, + 320, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the head layout (ratios of span):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "message", + "patching_rect": [ + 420, + 345, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "ratios 0.25 0.5 0.75 1., heads 4" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "message", + "patching_rect": [ + 420, + 370, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "ratios 0.333 0.667 1., heads 3" + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "message", + "patching_rect": [ + 420, + 395, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "pans -0.7 0.5 -0.35 0.8" + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "message", + "patching_rect": [ + 420, + 420, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "pans 0. 0. 0. 0." + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "comment", + "patching_rect": [ + 690, + 175, + 250, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the wear (the stabilizer):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "attrui", + "patching_rect": [ + 690, + 200, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "darken" + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "attrui", + "patching_rect": [ + 690, + 225, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "drive" + } + }, + { + "box": { + "id": "obj-28", + "maxclass": "comment", + "patching_rect": [ + 690, + 255, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the transport (depth ms, rate Hz):", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-29", + "maxclass": "message", + "patching_rect": [ + 690, + 280, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "wow 1. 0.9, flutter 0.06 13." + } + }, + { + "box": { + "id": "obj-30", + "maxclass": "message", + "patching_rect": [ + 690, + 305, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "wow 0. 0., flutter 0. 0." + } + }, + { + "box": { + "id": "obj-31", + "maxclass": "comment", + "patching_rect": [ + 690, + 345, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "past unity \u2014 sound-on-sound:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-32", + "maxclass": "message", + "patching_rect": [ + 690, + 370, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "drive 0.7, regen 1.35" + } + }, + { + "box": { + "id": "obj-33", + "maxclass": "message", + "patching_rect": [ + 690, + 395, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "input 0." + } + }, + { + "box": { + "id": "obj-34", + "maxclass": "message", + "patching_rect": [ + 690, + 420, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "regen 0.5, input 1." + } + }, + { + "box": { + "id": "obj-35", + "maxclass": "message", + "patching_rect": [ + 690, + 445, + 220, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear" + } + }, + { + "box": { + "id": "obj-36", + "maxclass": "comment", + "patching_rect": [ + 20, + 525, + 920, + 60 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: start the clicks and set the head layout \u2014 four evenly spaced heads, then the Copicat-style three. Then push past unity (drive 0.7, regen 1.35) and pull your hands off with input 0.: the loop keeps building on what you already gave it, bounded by the saturator rather than by a gain cap. Ride darken while it howls, then bring it home \u2014 or hit clear, the eject button. Drop drive to 0 mid-howl and the effective regen falls back to 1.0: the attribute keeps its value and returns when drive does.", + "fontsize": 12.0, + "fontname": "Arial" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-26", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-27", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-29", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-30", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-32", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-33", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-34", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-35", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 1 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-14", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.touche~.maxhelp b/help/tap.touche~.maxhelp new file mode 100644 index 00000000..12a1ede9 --- /dev/null +++ b/help/tap.touche~.maxhelp @@ -0,0 +1,734 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 8, + "minor": 5, + "revision": 5, + "architecture": "x64", + "modernui": 1 + }, + "classnamespace": "box", + "rect": [ + 59.0, + 106.0, + 980.0, + 740.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 Ondes Martenot intensity key as a gain law: the published measurement, 50 dB across 4.5 mm of key travel, interpolated through all seven measured points. The bottom of the throw is silent because the key is still bending.", + "digest": "The Ondes Martenot intensity key as a gain law", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.touche~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "The Ondes Martenot intensity key as a gain law", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ] + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 58 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Not a model \u2014 the published measurement (Quartier et al., Acta Acustica 2015, instrument No. 320): 50 dB of range across 4.5 mm of key travel, interpolated through all seven measured points. Roughly the first 45% of the throw is silent, which is the key bending before it reaches the powder bag; the useful 50 dB is packed into the 4.5 mm right after. That is what lets the instrument be played with very sharp attacks from a slow-looking gesture.", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "comment", + "patching_rect": [ + 20, + 190, + 340, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "a steady tone, so only the key is moving:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "patching_rect": [ + 20, + 215, + 140, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "cycle~ 220" + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "newobj", + "patching_rect": [ + 20, + 320, + 290, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "tap.touche~ @smooth 5" + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "gain~", + "patching_rect": [ + 20, + 375, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0 + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 455, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [] + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "comment", + "patching_rect": [ + 400, + 190, + 260, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "ride the key here:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-12", + "maxclass": "slider", + "patching_rect": [ + 400, + 215, + 22, + 220 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "size": 1001.0, + "orientation": 1 + } + }, + { + "box": { + "id": "obj-13", + "maxclass": "newobj", + "patching_rect": [ + 440, + 215, + 150, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "sig~ 0." + } + }, + { + "box": { + "id": "obj-14", + "maxclass": "newobj", + "patching_rect": [ + 440, + 250, + 180, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "text": "scale 0 1000 0. 1." + } + }, + { + "box": { + "id": "obj-15", + "maxclass": "comment", + "patching_rect": [ + 440, + 285, + 240, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the slider drives the right inlet as a signal \u2014 that is the performance surface", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-16", + "maxclass": "comment", + "patching_rect": [ + 690, + 190, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "or set it as an attribute:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-17", + "maxclass": "attrui", + "patching_rect": [ + 690, + 215, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "position" + } + }, + { + "box": { + "id": "obj-18", + "maxclass": "attrui", + "patching_rect": [ + 690, + 240, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "mm" + } + }, + { + "box": { + "id": "obj-19", + "maxclass": "attrui", + "patching_rect": [ + 690, + 265, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "mode" + } + }, + { + "box": { + "id": "obj-20", + "maxclass": "attrui", + "patching_rect": [ + 690, + 290, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "smooth" + } + }, + { + "box": { + "id": "obj-21", + "maxclass": "comment", + "patching_rect": [ + 690, + 335, + 250, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the published points:", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-22", + "maxclass": "message", + "patching_rect": [ + 690, + 360, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "mm 8.8" + } + }, + { + "box": { + "id": "obj-23", + "maxclass": "message", + "patching_rect": [ + 690, + 385, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "mm 7.3" + } + }, + { + "box": { + "id": "obj-24", + "maxclass": "message", + "patching_rect": [ + 690, + 410, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "mm 6.4" + } + }, + { + "box": { + "id": "obj-25", + "maxclass": "message", + "patching_rect": [ + 690, + 435, + 250, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "mm 4.3" + } + }, + { + "box": { + "id": "obj-26", + "maxclass": "comment", + "patching_rect": [ + 690, + 462, + 250, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "full press, then f, mf, and the noise floor \u2014 the nuance boundaries the paper measured", + "fontsize": 12.0, + "fontname": "Arial" + } + }, + { + "box": { + "id": "obj-27", + "maxclass": "comment", + "patching_rect": [ + 20, + 530, + 920, + 64 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: sweep the slider slowly from the bottom. Nothing happens for the first stretch \u2014 do not adjust your set, that dead travel is the instrument. Then the whole 50 dB arrives in a short distance, steepening through the middle and flattening at the top. Compare against a plain gain~ fader on the same tone: the difference is where in the gesture the loudness actually changes, which is the whole reason the key is famous. It works on anything, not just an ondes \u2014 try it on a guitar as a swell pedal.", + "fontsize": 12.0, + "fontname": "Arial" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-12", + 0 + ], + "destination": [ + "obj-14", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-14", + 0 + ], + "destination": [ + "obj-13", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-13", + 0 + ], + "destination": [ + "obj-8", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-18", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-19", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-24", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-25", + 0 + ], + "destination": [ + "obj-8", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-9", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 1 + ] + } + } + ] + } +} diff --git a/help/tap.triode~.maxhelp b/help/tap.triode~.maxhelp new file mode 100644 index 00000000..acfb02f3 --- /dev/null +++ b/help/tap.triode~.maxhelp @@ -0,0 +1,641 @@ +{ + "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": "A common-cathode valve stage solved on its load line from the enhanced Norman Koren model, with parameters fitted to the valves in ondes Martenot No. 169. It inverts, it is strongly asymmetric, and drive changes the dirt rather than the level.", + "digest": "One triode gain stage, from a published tube model", + "tags": "TapTools", + "style": "", + "subpatcher_template": "", + "assistshowspatchername": 0, + "boxes": [ + { + "box": { + "maxclass": "panel", + "patching_rect": [ + 20, + 15, + 640, + 95 + ], + "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, + "id": "obj-1" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 20, + 400, + 36 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tap.triode~", + "fontsize": 24.0, + "fontname": "Lato Light", + "textcolor": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "id": "obj-2" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 30, + 55, + 620, + 22 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "One triode gain stage, from a published tube model", + "fontsize": 13.0, + "fontname": "Lato Light", + "textcolor": [ + 0.92, + 0.92, + 0.95, + 1.0 + ], + "id": "obj-3" + } + }, + { + "box": { + "maxclass": "bpatcher", + "patching_rect": [ + 680, + 20, + 225.0, + 67.0 + ], + "numinlets": 0, + "numoutlets": 0, + "name": "tap.badge.maxpat", + "id": "obj-4" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 120, + 920, + 58 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Not a tanh with a bias on it. The tube is the enhanced Norman Koren model and the parameter sets are fitted to the valves in ondes Martenot No. 169 \u2014 6F5, 6C5, 2A3 \u2014 together with each stage's supply voltage, cathode resistor and plate load. The stage is then the static solution of its load line, which is a memoryless nonlinearity in the DAFx-07 sense, so tabulating it is not an approximation of the model: it IS the model.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-5" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 20, + 190, + 380, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "something to push through it:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-6" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 215, + 200, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "text": "cycle~ 220", + "outlettype": [ + "signal" + ], + "id": "obj-7" + } + }, + { + "box": { + "maxclass": "toggle", + "patching_rect": [ + 230, + 215, + 24, + 24 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "int" + ], + "parameter_enable": 0, + "id": "obj-8" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 260, + 218, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "audio on", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-9" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 20, + 300, + 400, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "text": "tap.triode~ @tube 1 @stage 0 @drive 1.", + "outlettype": [ + "signal" + ], + "id": "obj-10" + } + }, + { + "box": { + "maxclass": "gain~", + "patching_rect": [ + 20, + 350, + 30, + 60 + ], + "numinlets": 2, + "numoutlets": 2, + "outlettype": [ + "signal", + "" + ], + "parameter_enable": 0, + "id": "obj-11" + } + }, + { + "box": { + "maxclass": "ezdac~", + "patching_rect": [ + 20, + 430, + 45, + 45 + ], + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "id": "obj-12" + } + }, + { + "box": { + "maxclass": "newobj", + "patching_rect": [ + 80, + 350, + 220, + 130 + ], + "numinlets": 2, + "numoutlets": 0, + "text": "scope~ @range -1. 1.", + "id": "obj-13" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 80, + 490, + 260, + 34 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "watch the waveform go asymmetric as drive comes up \u2014 that asymmetry is the even harmonics", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-14" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 190, + 240, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "the valve and its operating point:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-15" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 215, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "tube", + "id": "obj-16" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 470, + 240, + 220, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "stage", + "id": "obj-17" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 270, + 250, + 62 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "tube: 0 = 6F5 (the oscillators'), 1 = 6C5 (demodulator and preamp), 2 = 2A3 (power). stage: 0/1/2 for the demodulator, preamplifier and power operating points. Both rebuild the curve \u2014 set them, do not sweep them.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-18" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 760, + 190, + 200, + 20 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "and the knobs:", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-19" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 760, + 215, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "drive", + "id": "obj-20" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 760, + 240, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "highpass", + "id": "obj-21" + } + }, + { + "box": { + "maxclass": "attrui", + "patching_rect": [ + 760, + 265, + 200, + 22 + ], + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "parameter_enable": 0, + "attr": "lowpass", + "id": "obj-22" + } + }, + { + "box": { + "maxclass": "message", + "patching_rect": [ + 760, + 300, + 100, + 22 + ], + "numinlets": 2, + "numoutlets": 1, + "outlettype": [ + "" + ], + "text": "clear", + "id": "obj-23" + } + }, + { + "box": { + "maxclass": "comment", + "patching_rect": [ + 470, + 350, + 480, + 120 + ], + "numinlets": 1, + "numoutlets": 0, + "text": "Try: leave drive at 1 and sweep it up to 20. The level barely moves \u2014 the stage is normalized by its own small-signal gain, so drive is a distortion control and not a volume control. Then switch tube to 2 (the 2A3 power triode) at stage 2 and hear how much more headroom it has before it does anything at all. The wrong pairings solve too: a 6F5 at the power amplifier's operating point is not a thing that ever existed, and it sounds like it.", + "fontsize": 12.0, + "fontname": "Arial", + "id": "obj-24" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-7", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-13", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-11", + 0 + ], + "destination": [ + "obj-12", + 1 + ] + } + }, + { + "patchline": { + "source": [ + "obj-16", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-17", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-20", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-21", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-22", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + }, + { + "patchline": { + "source": [ + "obj-23", + 0 + ], + "destination": [ + "obj-10", + 0 + ] + } + } + ] + } +} \ No newline at end of file diff --git a/source/projects/tap.fuzz_tilde/CMakeLists.txt b/source/projects/tap.fuzz_tilde/CMakeLists.txt new file mode 100644 index 00000000..0ae8a89e --- /dev/null +++ b/source/projects/tap.fuzz_tilde/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/fuzz.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.fuzz_tilde/tap.fuzz_tilde.cpp b/source/projects/tap.fuzz_tilde/tap.fuzz_tilde.cpp new file mode 100644 index 00000000..00d16fe4 --- /dev/null +++ b/source/projects/tap.fuzz_tilde/tap.fuzz_tilde.cpp @@ -0,0 +1,157 @@ +/// @file +/// @brief tap.fuzz~ — the two-stage tone-stacked fuzz on the portable kernel. +/// @details Wraps tap::tools::fuzz::pedal (taptools/fuzz.h): two cascaded clipping stages and +/// a @bass / @contrast / @treble voicing section — the harder, more scooped school +/// that sits beside tap.overdrive~ rather than replacing it (that object is a +/// feedback soft-clipper chasing the TS lineage). +/// +/// The architecture is the simplified cascade of Yeh, Abel & Smith (DAFx-07): +/// conditioning filter, memoryless nonlinearity, equalization filter — twice. @gain +/// sweeps the first stage's drive, @edge sharpens the second stage's knee from a +/// soft limiter toward a hard corner, and @asymmetry buys the even harmonics an +/// odd-only curve structurally cannot make. It is a recreation of a circuit *class*: +/// no component value or corner here is claimed as measured from any pedal, and the +/// control names follow that class's conventional layout rather than asserting what +/// any particular unit does. +/// +/// @oversample is 1, 2, 4 or 8 and defaults to **4**. The clipper pair runs inside a +/// cascade of 2x resampling stages — one doubling and one 8th-order filter each — +/// which is what makes more oversampling mean less aliasing here; an earlier +/// single-stage chain measured *worse* at 4x and 8x than at 2x. 2 is kept but is only +/// safe below about 6 kHz, above which one doubling stops moving the clipper's +/// harmonics out of the way. 8 earns its keep on bright material. The kernel header +/// carries the measured table. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::fuzz; + +class fuzz : public object, public sample_operator<1, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::pedal m_pedal; + + public: + MIN_DESCRIPTION{"A two-stage, tone-stacked fuzz. Two cascaded clipping stages with a bass / " + "contrast / treble voicing section — the harder, more scooped school beside " + "tap.overdrive~'s feedback soft-clipper. gain sweeps the first stage's " + "drive, edge sharpens the second stage's knee toward a hard corner, and " + "asymmetry brings in the even harmonics a symmetric curve cannot make. Built " + "on the Yeh/Abel/Smith DAFx-07 simplified cascade; a recreation of a circuit " + "class, not a model of any one pedal. For multichannel signals, wrap this " + "object in an mc. operator."}; + MIN_TAGS{"effects"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.overdrive~, tap.tapecho~, tap.filter~, overdrive~"}; + + inlet<> m_in{this, "(signal) audio input"}; + outlet<> m_out{this, "(signal) the distorted output", "signal"}; + + attribute gain{this, "gain", 0.5, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_pedal.set_gain(v); + return {v}; + }}, + description{"Drive into the first clipping stage (0..1). 0 is a lit-up clean boost, 1 is " + "the pedal wide open. The floor sits below unity on purpose — the second " + "stage carries its own small-signal gain, and a hotter floor would arrive " + "there already saturated."}}; + + attribute edge{this, "edge", 0.5, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_pedal.set_edge(v); + return {v}; + }}, + description{"Knee sharpness of the second stage (0..1): a soft limiter at 0, close to a " + "hard corner at 1. High settings are where a static curve aliases worst — if " + "it sounds gritty in the wrong way, try oversample before the tone controls."}}; + + attribute asymmetry{this, "asymmetry", 0.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_pedal.set_asymmetry(v); + return {v}; + }}, + description{"Clipping asymmetry (0..1) — the even-harmonic control. A symmetric " + "curve is an odd function and produces odd harmonics only; a real " + "op-amp stage clips lopsided, which is where a pedal's even harmonics " + "come from. Costs no DC: silence stays exactly silent."}}; + + attribute bass{this, "bass", 0.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), -1.0, 1.0); + m_pedal.set_bass(v); + return {v}; + }}, + description{"Low shelf, -1 to 1 (full travel is 12 dB either way). Linear, and entirely " + "outside the nonlinearity."}}; + + attribute treble{this, "treble", 0.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), -1.0, 1.0); + m_pedal.set_treble(v); + return {v}; + }}, + description{"High shelf, -1 to 1 (full travel is 12 dB either way)."}}; + + attribute contrast{this, "contrast", 0.35, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_pedal.set_contrast(v); + return {v}; + }}, + description{"Mid-scoop depth (0..1, up to 14 dB at 620 Hz). The scoop is this " + "object's own curve; the control name is the pedal class's."}}; + + attribute level{this, "level", 0.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), -24.0, 24.0); + m_pedal.set_level_db(v); + return {v}; + }}, + description{"Output level in dB (-24..24)."}}; + + attribute oversample{this, "oversample", kernel::k_default_os, setter{MIN_FUNCTION{ + const int in = static_cast(args[0]); + const int v = (in >= 8) ? 8 : (in >= 4) ? 4 : (in >= 2) ? 2 : 1; + m_pedal.set_oversample(v); + return {v}; + }}, + description{"Oversampling factor for the clipper pair: 1, 2, 4 or 8. Default 4. The " + "clipper runs inside a cascade of 2x stages, so more is never worse; 2 is " + "only safe below about 6 kHz, and 8 is worth it on bright material. " + "Changing this reconfigures the filters and is not real-time-safe."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_pedal.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the setters, in ms (0 = instant)."}}; + + message<> clear{this, "clear", "Flush the filters and the oversampling chain. Parameters are kept.", + MIN_FUNCTION{ + m_pedal.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Prepare the pedal for the current sample rate.", + MIN_FUNCTION{ + if (!m_pedal.prepared() || samplerate() != m_pedal.samplerate()) { + m_pedal.prepare(samplerate()); + } + return {}; + }}; + + fuzz(const atoms& args = {}) { m_pedal.prepare(samplerate()); } + + sample operator()(sample x) { return m_pedal.process(x); } +}; + +MIN_EXTERNAL(fuzz); diff --git a/source/projects/tap.fuzz_tilde/tap.fuzz_tilde_test.cpp b/source/projects/tap.fuzz_tilde/tap.fuzz_tilde_test.cpp new file mode 100644 index 00000000..fec4b837 --- /dev/null +++ b/source/projects/tap.fuzz_tilde/tap.fuzz_tilde_test.cpp @@ -0,0 +1,142 @@ +/// @file +/// @brief Unit tests for tap.fuzz~ (the two-stage tone-stacked fuzz). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/fuzz_test.cpp) +/// carries the DSP promises. Here: documented defaults (including the oversample +/// default of 2, which is a measured choice rather than a typo and is therefore +/// worth pinning), the attribute clamps, the oversample snapping, and the one +/// contract a patcher can check without a scope — silence in, silence out, exactly, +/// even wide open and asymmetric. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.fuzz_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.fuzz~ instantiates with the documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + fuzz& my_object = an_instance; + + THEN("mid gain and edge, no asymmetry, a flat pair of shelves and a little scoop") { + REQUIRE(static_cast(my_object.gain) == 0.5); + REQUIRE(static_cast(my_object.edge) == 0.5); + REQUIRE(static_cast(my_object.asymmetry) == 0.0); + REQUIRE(static_cast(my_object.bass) == 0.0); + REQUIRE(static_cast(my_object.treble) == 0.0); + REQUIRE(static_cast(my_object.contrast) == 0.35); + REQUIRE(static_cast(my_object.level) == 0.0); + } + THEN("oversample defaults to 4 — measured, and not the largest") { + REQUIRE(static_cast(my_object.oversample) == kernel::k_default_os); + REQUIRE(static_cast(my_object.oversample) == 4); + } + } +} + +SCENARIO("tap.fuzz~ clamps its controls to their documented ranges") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + fuzz& my_object = an_instance; + + THEN("the 0..1 controls are probabilities-shaped") { + my_object.gain = 4.0; + REQUIRE(static_cast(my_object.gain) == 1.0); + my_object.edge = -2.0; + REQUIRE(static_cast(my_object.edge) == 0.0); + my_object.asymmetry = 9.0; + REQUIRE(static_cast(my_object.asymmetry) == 1.0); + my_object.contrast = -1.0; + REQUIRE(static_cast(my_object.contrast) == 0.0); + } + THEN("the shelves travel both ways") { + my_object.bass = -9.0; + REQUIRE(static_cast(my_object.bass) == -1.0); + my_object.treble = 9.0; + REQUIRE(static_cast(my_object.treble) == 1.0); + } + THEN("level stops at the kernel's range") { + my_object.level = 99.0; + REQUIRE(static_cast(my_object.level) == 24.0); + } + } +} + +SCENARIO("tap.fuzz~ snaps oversample to a legal factor") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + fuzz& my_object = an_instance; + + THEN("in-between values snap down to the next legal factor") { + my_object.oversample = 3; + REQUIRE(static_cast(my_object.oversample) == 2); + my_object.oversample = 7; + REQUIRE(static_cast(my_object.oversample) == 4); + my_object.oversample = 99; + REQUIRE(static_cast(my_object.oversample) == 8); + my_object.oversample = 0; + REQUIRE(static_cast(my_object.oversample) == 1); + } + } +} + +// The bias that buys even harmonics lives inside the curve and is corrected at the stage +// output, so an asymmetric pedal must still be exactly quiet on silence — no DC pedestal. +SCENARIO("tap.fuzz~ is exactly silent on silence, even wide open and asymmetric") { + ext_main(nullptr); + + GIVEN("the pedal wide open and lopsided") { + test_wrapper an_instance; + fuzz& my_object = an_instance; + my_object.smooth = 0.0; + my_object.gain = 1.0; + my_object.edge = 1.0; + my_object.asymmetry = 1.0; + + THEN("every output sample is bitwise zero") { + bool silent = true; + for (int i = 0; i < 8000; ++i) { + silent = silent && (my_object(0.0) == 0.0); + } + REQUIRE(silent); + } + } +} + +SCENARIO("tap.fuzz~ distorts more as the gain rises") { + ext_main(nullptr); + + GIVEN("a fixed tone at two gain settings") { + constexpr double k_mock_sr = 44100.0; + auto peak_of = [&](double g) { + test_wrapper an_instance; + fuzz& my_object = an_instance; + my_object.smooth = 0.0; + my_object.contrast = 0.0; + my_object.gain = g; + double top = 0.0; + for (int i = 0; i < 8000; ++i) { + const double x = 0.3 * std::sin(2.0 * 3.14159265358979323846 * 220.0 * i / k_mock_sr); + const double y = my_object(x); + if (i > 4000) { + top = std::max(top, std::abs(y)); + } + } + return top; + }; + + THEN("a hotter gain makes a hotter output") { + const double quiet = peak_of(0.0); + const double loud = peak_of(1.0); + INFO("peak at gain 0 = " << quiet << ", at gain 1 = " << loud); + REQUIRE(loud > quiet * 2.0); + } + } +} diff --git a/source/projects/tap.metallique_tilde/CMakeLists.txt b/source/projects/tap.metallique_tilde/CMakeLists.txt new file mode 100644 index 00000000..83cfc1f3 --- /dev/null +++ b/source/projects/tap.metallique_tilde/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/diffuseur.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.metallique_tilde/tap.metallique_tilde.cpp b/source/projects/tap.metallique_tilde/tap.metallique_tilde.cpp new file mode 100644 index 00000000..8757f703 --- /dev/null +++ b/source/projects/tap.metallique_tilde/tap.metallique_tilde.cpp @@ -0,0 +1,174 @@ +/// @file +/// @brief tap.metallique~ — the Ondes Martenot's gong diffuseur, as a driven resonator. +/// @details Wraps tap::tools::diffuseur::metallique (taptools/diffuseur.h). The Ondes Martenot +/// does not have a loudspeaker, it has a rack of them, and choosing between them is +/// part of playing the instrument. The *métallique* (1944–45, patented 1947) is a gong +/// driven by a motor transducer: everything sent through it picks up the body of a +/// large metal plate. +/// +/// **Driven, not struck.** There is no trigger here and no strike envelope. Whatever +/// you send in excites the plate continuously and the plate rings at its own rates — +/// which is why this is worth reaching for on material that has nothing to do with an +/// ondes. Put a snare through it. Put a whole mix through it at a low @mix. +/// +/// **The order matters, and it is the instrument's.** The signal reaches the +/// transducer first and the transducer's motion excites the body, so @drive, +/// @asymmetry and @saturation sit *upstream* of the plate. Driving a distorted +/// waveform into a gong is a different sound from distorting a gong, and this object +/// does the former because that is what the machine does. +/// +/// **Honest about what it is.** The instrument, its date, its excitation and its +/// moving-iron transducer come from the peer-reviewed sources (Najnudel et al., IEEE/ +/// ACM TASLP 28, 2020; Wijnand et al., Forum Acusticum 2023). The *mode data does +/// not* — no ondes-specific measurement of the body exists in any of them — so the +/// ratios are Fletcher & Rossing's free circular plate. This is a recreation of the +/// general physics, not a model of Martenot's gong, and it is labelled that way in +/// the kernel header too. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::diffuseur; + +class metallique : public object, public sample_operator<1, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::metallique m_machine; + + public: + MIN_DESCRIPTION{"The Ondes Martenot's gong diffuseur as a driven resonator. Eight plate modes " + "at the free circular plate's transverse ratios, each a slowly beating " + "doublet, excited continuously by whatever you send in — there is no strike. " + "A moving-iron transducer sits upstream of the body, which is the order the " + "instrument wires them: drive hard and you are driving a distorted waveform " + "into a gong. The mode ratios are Fletcher & Rossing's general plate physics, " + "not a measurement of Martenot's instrument, so this is a recreation. Useful " + "on anything that wants a metal body, not just on an ondes. For multichannel " + "signals, wrap this object in an mc. operator."}; + MIN_TAGS{"effects"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.palme~, tap.touche~, tap.chime~, tap.5comb~"}; + + inlet<> m_in{this, "(signal) audio input — drives the transducer"}; + outlet<> m_out{this, "(signal) the body, balanced against the input", "signal"}; + + attribute pitch{this, "pitch", kernel::k_default_pitch_hz, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_pitch_hz, + kernel::k_max_pitch_hz); + m_machine.set_pitch_hz(v); + return {v}; + }}, + description{"Frequency of the plate's (2,0) mode in Hz — the body's perceived pitch. " + "The other seven modes follow at the published ratios. This retunes " + "sixteen resonators, so it is set-and-hold rather than a knob to sweep " + "fast."}}; + + attribute decay{this, "decay", kernel::k_default_decay_s, setter{MIN_FUNCTION{ + const double v = + std::clamp(static_cast(args[0]), kernel::k_min_t60, kernel::k_max_t60); + m_machine.set_decay(v); + return {v}; + }}, + description{"Ring time of the fundamental in seconds (T60 — the time to fall 60 dB). " + "Long values are a drone; short ones are a plate reverb."}}; + + attribute tilt{this, "tilt", kernel::k_default_tilt, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 3.0); + m_machine.set_tilt(v); + return {v}; + }}, + description{"How much faster the upper modes die than the fundamental: their ring time " + "is decay / ratio^tilt. 0 rings everything equally; 2 is the roughly-f^2 " + "radiation damping of a struck bar. 1 is the default because a driven " + "gong's shimmer lives in its upper modes."}}; + + attribute brightness{this, "brightness", kernel::k_default_bright, + setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_machine.set_brightness(v); + return {v}; + }}, + description{"Weight on the upper modes, 0 to 1, steepening per mode so the " + "highest partials go first. 1 is the full published weight table; 0 " + "leaves only the fundamental doublet."}}; + + attribute drive{this, "drive", kernel::k_default_drive, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_drive(v); + return {v}; + }}, + description{"Linear gain into the transducer, before the body. This is where the " + "object gets loud and dirty, and it is upstream of the plate on purpose."}}; + + attribute asymmetry{this, "asymmetry", kernel::k_default_asymmetry, + setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_machine.set_asymmetry(v); + return {v}; + }}, + description{"The moving-iron squared term, 0 to 1: force follows the square of the " + "gap flux, so this puts a second harmonic on the signal in proportion " + "to level and nothing at the third. 0 is a linear driver — legitimate, " + "but then a documented stage of the instrument is missing."}}; + + attribute saturation{this, "saturation", kernel::k_default_sat, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_saturation(v); + return {v}; + }}, + description{"Soft-clip amount after the squared term; 0 is exactly linear. It is " + "here because a squared law is expansive and something has to bound " + "it — a modelling necessity, not a measured stage. Output is bounded " + "by 2/saturation."}}; + + attribute mix{this, "mix", kernel::k_default_mix, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 100.0); + m_machine.set_mix(v); + return {v}; + }}, + description{"Balance between the dry input and the diffuseur, 0 to 100, equal-power. " + "The ends are exact: 0 is the input bit for bit, 100 is the body alone."}}; + + attribute level{this, "level", kernel::k_default_level, setter{MIN_FUNCTION{ + const double v = static_cast(args[0]); + m_machine.set_level(v); + return {v}; + }}, + description{"Output level, linear."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the transducer, mix and level setters, in ms " + "(0 = instant). The body's own parameters are not ramped."}}; + + message<> clear{this, "clear", "Silence the body and reset the driver. Parameters are kept.", + MIN_FUNCTION{ + m_machine.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Prepare the cabinet for the current sample rate.", + MIN_FUNCTION{ + m_machine.prepare(samplerate()); + return {}; + }}; + + metallique(const atoms& args = {}) { m_machine.prepare(samplerate()); } + + sample operator()(sample x) { return m_machine.process(x); } +}; + +MIN_EXTERNAL(metallique); diff --git a/source/projects/tap.metallique_tilde/tap.metallique_tilde_test.cpp b/source/projects/tap.metallique_tilde/tap.metallique_tilde_test.cpp new file mode 100644 index 00000000..bd486c52 --- /dev/null +++ b/source/projects/tap.metallique_tilde/tap.metallique_tilde_test.cpp @@ -0,0 +1,138 @@ +/// @file +/// @brief Unit tests for tap.metallique~ (the Ondes Martenot's gong diffuseur). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/diffuseur_test.cpp) +/// carries the modal maths, the boundedness argument and the transducer's law. Here: +/// the documented defaults, the clamps, and the two behaviours a patcher would be +/// surprised by if they were wrong — a fully dry cabinet is the input untouched, and +/// a driven one is not silent. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.metallique_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.metallique~ instantiates with its documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + metallique& my_object = an_instance; + + THEN("the body and the driver are where the header says") { + REQUIRE(static_cast(my_object.pitch) == kernel::k_default_pitch_hz); + REQUIRE(static_cast(my_object.decay) == kernel::k_default_decay_s); + REQUIRE(static_cast(my_object.tilt) == kernel::k_default_tilt); + REQUIRE(static_cast(my_object.brightness) == kernel::k_default_bright); + REQUIRE(static_cast(my_object.drive) == kernel::k_default_drive); + REQUIRE(static_cast(my_object.asymmetry) == kernel::k_default_asymmetry); + REQUIRE(static_cast(my_object.saturation) == kernel::k_default_sat); + REQUIRE(static_cast(my_object.mix) == kernel::k_default_mix); + REQUIRE(static_cast(my_object.level) == kernel::k_default_level); + } + } +} + +SCENARIO("tap.metallique~ clamps its body to the legal ranges") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + metallique& my_object = an_instance; + + THEN("pitch stays inside the audible band the modes were sized for") { + my_object.pitch = 100000.0; + REQUIRE(static_cast(my_object.pitch) == kernel::k_max_pitch_hz); + my_object.pitch = -5.0; + REQUIRE(static_cast(my_object.pitch) == kernel::k_min_pitch_hz); + } + THEN("decay stays inside the ring-time range") { + my_object.decay = 1e6; + REQUIRE(static_cast(my_object.decay) == kernel::k_max_t60); + my_object.decay = 0.0; + REQUIRE(static_cast(my_object.decay) == kernel::k_min_t60); + } + THEN("the normalized controls are fractions") { + my_object.brightness = 4.0; + REQUIRE(static_cast(my_object.brightness) == 1.0); + my_object.asymmetry = -1.0; + REQUIRE(static_cast(my_object.asymmetry) == 0.0); + my_object.mix = 1000.0; + REQUIRE(static_cast(my_object.mix) == 100.0); + my_object.tilt = 99.0; + REQUIRE(static_cast(my_object.tilt) == 3.0); + } + THEN("drive and saturation cannot go negative") { + my_object.drive = -2.0; + REQUIRE(static_cast(my_object.drive) == 0.0); + my_object.saturation = -1.0; + REQUIRE(static_cast(my_object.saturation) == 0.0); + } + } +} + +// The two things a patcher would otherwise file as bugs. +SCENARIO("tap.metallique~ is a bitwise passthrough fully dry, and audible fully wet") { + ext_main(nullptr); + + GIVEN("a cabinet with the balance hard dry") { + test_wrapper an_instance; + metallique& my_object = an_instance; + my_object.smooth = 0.0; + my_object.mix = 0.0; + my_object.level = 1.0; + + THEN("what comes out is exactly what went in") { + bool identical = true; + for (int i = 0; i < 2000; ++i) { + const sample x = 0.5 * std::sin(6.2831853071795865 * 220.0 * i / 48000.0); + identical = identical && (my_object(x) == x); + } + REQUIRE(identical); + } + } + + GIVEN("a cabinet with the balance hard wet") { + test_wrapper an_instance; + metallique& my_object = an_instance; + my_object.smooth = 0.0; + my_object.mix = 100.0; + my_object.pitch = 220.0; + my_object.decay = 4.0; + + THEN("the body speaks") { + double energy = 0.0; + for (int i = 0; i < 48000; ++i) { + const sample x = 0.5 * std::sin(6.2831853071795865 * 220.0 * i / 48000.0); + const sample y = my_object(x); + if (i > 24000) { + energy += static_cast(y) * static_cast(y); + } + } + REQUIRE(energy > 0.0); + } + } +} + +SCENARIO("tap.metallique~ clears without disturbing its settings") { + ext_main(nullptr); + + GIVEN("a rung cabinet") { + test_wrapper an_instance; + metallique& my_object = an_instance; + my_object.pitch = 240.0; + my_object.mix = 70.0; + for (int i = 0; i < 4800; ++i) { + my_object(0.5); + } + + WHEN("it is cleared") { + my_object.clear(); + THEN("the parameters are untouched") { + REQUIRE(static_cast(my_object.pitch) == 240.0); + REQUIRE(static_cast(my_object.mix) == 70.0); + } + } + } +} diff --git a/source/projects/tap.ondes_tilde/CMakeLists.txt b/source/projects/tap.ondes_tilde/CMakeLists.txt new file mode 100644 index 00000000..43de8402 --- /dev/null +++ b/source/projects/tap.ondes_tilde/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/ondes.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.ondes_tilde/tap.ondes_tilde.cpp b/source/projects/tap.ondes_tilde/tap.ondes_tilde.cpp new file mode 100644 index 00000000..828ea4e5 --- /dev/null +++ b/source/projects/tap.ondes_tilde/tap.ondes_tilde.cpp @@ -0,0 +1,225 @@ +/// @file +/// @brief tap.ondes~ — the Ondes Martenot voice, minus its loudspeaker. +/// @details Wraps tap::tools::ondes::voice (taptools/ondes.h). A **source**, not an effect: +/// the heterodyne envelope detector into the demodulator triode into the +/// preamplifier triode into the intensity key. Patch a tap.palme~ or a +/// tap.metallique~ after it and you have the instrument. +/// +/// **It is not a VCO with waveform switches.** The Ondes Martenot is heterodyne: two +/// oscillators near 80 kHz, one fixed and one moved by the ribbon, summed into an +/// amplitude-modulated signal whose envelope is the note. Najnudel, Hélie, Roze & +/// Boutin (IEEE/ACM TASLP 28, 2020) measure those oscillators at about 0.03 % second +/// harmonic even coupled to the rest of the circuit, so the character is entirely in +/// what comes after them. +/// +/// **The demodulator is the biggest source of harmonics, before any valve.** The +/// envelope of two equal oscillators is 2|cos|, which puts the second harmonic 14 dB +/// below the fundamental and the third 21 dB down. That is why this object does not +/// synthesize a difference tone: it would throw the instrument's timbre away. The +/// carrier is not simulated either — the envelope has a closed form, and running the +/// published 200 µs detector on it matches a full 80 kHz simulation to within a tenth +/// of a dB on every harmonic. +/// +/// The performance surface is the instrument's. @ribbon is **semitones above A1**, +/// because the published ribbon law is linear in semitones — a hand moving at +/// constant speed makes a constant-rate glissando, and nothing here quantizes. +/// @key is the intensity key (see tap.touche~, whose published curve this +/// uses): the bottom 45 % of its travel is silent, which is the key bending before it +/// reaches the powder bag. Both take signals. +/// +/// Two controls exist because the sources do not settle them, and both measure as +/// audible: @keyplacement (the paper's five stages do not include the intensity key, +/// so whether pressure means level or dirt is a choice) and @polarity (the winding +/// sense of the transformer between the two valves, which decides which side of the +/// waveform the preamplifier's asymmetry acts on). +/// +/// What is missing, and deliberately: the **waveform registers**. The real instrument +/// has switchable timbres whose filter shapes are in none of the sources obtained, +/// and inventing them is the one thing this object will not do. +/// +/// 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::ondes; + +class ondes : public object, public sample_operator<2, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::voice m_voice; + + public: + MIN_DESCRIPTION{"The Ondes Martenot voice, minus its loudspeaker: a heterodyne envelope " + "detector into two triode stages into the intensity key. Not a VCO — the " + "instrument is heterodyne, its oscillators measure as essentially pure, and " + "the harmonics come from the demodulator and the valves. A source: patch " + "tap.palme~ or tap.metallique~ after it for the rest of the instrument. " + "ribbon is semitones above A1 because the published ribbon law is linear in " + "semitones; key is the published intensity-key curve, whose bottom 45% is " + "silent. No waveform registers — no source obtained describes them."}; + MIN_TAGS{"synth"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.touche~, tap.palme~, tap.metallique~, tap.triode~"}; + + inlet<> m_in_ribbon{this, "(signal) ribbon position, semitones above A1"}; + inlet<> m_in_key{this, "(signal) intensity key, 0 to 1 of the travel"}; + outlet<> m_out{this, "(signal) the voice, before any diffuseur", "signal"}; + + attribute ribbon{this, "ribbon", 24.0, setter{MIN_FUNCTION{ + const double v = + std::clamp(static_cast(args[0]), 0.0, kernel::k_max_semitones); + m_voice.set_ribbon(v); + return {v}; + }}, + description{"The note, in semitones above A1 (55 Hz). The published ribbon law is " + "linear in semitones, so this really is where the hand is — move it " + "linearly and you get a linear glissando. Fractional and unquantized, " + "because the instrument is. Ignored while a signal is connected to the " + "left inlet."}}; + + attribute key{this, "key", 1.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_voice.set_key(v); + return {v}; + }}, + description{"The intensity key, 0 to 1 over its physical travel — the published curve " + "from tap.touche~. Roughly the bottom 45% is silent, which is the key " + "bending before it reaches the powder bag rather than a dead spot in the " + "object. Ignored while a signal is connected to the right inlet."}}; + + attribute depth{this, "depth", kernel::k_default_depth, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_voice.set_depth(v); + return {v}; + }}, + description{"Relative amplitude of the second oscillator, 0 to 1. At 1 (the published " + "case, equal amplitudes) the envelope closes completely and the harmonic " + "series is full; below that it never closes and the tone thins toward a " + "sinusoid. A real mismatch between two oscillators, and the cheapest " + "timbre control here."}}; + + attribute detect{this, "detect", kernel::k_detect_ms, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_detect_ms, + kernel::k_max_detect_ms); + m_voice.set_detect_ms(v); + return {v}; + }}, + description{"Envelope-detector time constant in ms. The published value is 0.2 " + "(R4 x C21 = 1 M x 200 pF). Longer smooths the envelope and takes the " + "harmonics with it; it is also why the instrument gets purer and quieter " + "as it goes up, since the RC cannot follow a fast envelope back down."}}; + + attribute drive{this, "drive", 1.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 8.0); + m_voice.set_drive(v); + return {v}; + }}, + description{"Grid drive into the two valve stages, as a multiple of the published " + "nominal. The harmonics control — the circuit paper's own plugin exposes " + "demodulator input gain the same way, a knob the real instrument does not " + "have. Normalized, so it changes the distortion and not the level. Note " + "that at 0 the tone is still harmonically rich, because the demodulator " + "made those harmonics."}}; + + attribute keyplacement{this, "keyplacement", kernel::key_after, setter{MIN_FUNCTION{ + const int v = + std::clamp(static_cast(args[0]), 0, kernel::k_num_key_placements - 1); + m_voice.set_key_placement(v); + return {v}; + }}, + description{"Where the intensity key sits: 0 after the valves (a clean output law " + "— pressure is level), 1 before them (pressure drives the valves, so " + "soft is clean and hard is dirty). The circuit paper's five stages do " + "not include the key, so this is a choice rather than a " + "reconstruction — and it is a real one, worth about 0.09 of total " + "harmonic content at a half-press."}}; + + attribute polarity{this, "polarity", 1, setter{MIN_FUNCTION{ + const int v = (static_cast(args[0]) < 0) ? -1 : 1; + m_voice.set_polarity(v); + return {v}; + }}, + description{"Sign of the coupling between the two valve stages, 1 or -1. They are " + "coupled through a transformer whose winding sense is not in the source, " + "and the sign decides which side of the waveform the preamplifier's " + "asymmetry acts on. Audible: the two settings differ by about 0.12 of " + "total harmonic content."}}; + + attribute power{this, "power", false, setter{MIN_FUNCTION{ + const bool v = static_cast(args[0]); + m_voice.set_power_stage(v); + return {v}; + }}, + description{"Run the 2A3 power stage. Off by default, following the circuit paper: they " + "measure almost 5% second harmonic there but report its contribution as " + "much less important than the two stages before it, and drop it for " + "real-time. Measured here, switching it on moves total harmonic content by " + "about 0.003 — so they were right, and it is a switch rather than a " + "deletion."}}; + + attribute level{this, "level", kernel::k_default_level, setter{MIN_FUNCTION{ + const double v = static_cast(args[0]); + m_voice.set_level(v); + return {v}; + }}, + description{"Output level, linear."}}; + + attribute oversample{this, "oversample", kernel::k_default_os, setter{MIN_FUNCTION{ + const int raw = std::clamp(static_cast(args[0]), 1, kernel::k_max_oversample); + const int v = (raw >= 8) ? 8 : (raw >= 4) ? 4 : (raw >= 2) ? 2 : 1; + m_voice.set_oversample(v); + return {v}; + }}, + description{"Oversampling for the nonlinear chain: 1, 2, 4 or 8. Every doubling is " + "worth about 12 dB of alias rejection up to 4x, and 7-12 dB more at 8x " + "in the top octave. 4x is the default because that is where the cost " + "stops buying uniformly."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_voice.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the attribute-driven drive, level and key, in " + "ms (0 = instant). The signal inlets are not ramped — a control signal is " + "assumed smooth already."}}; + + message<> clear{this, "clear", "Silence the detector and every filter. Parameters are kept.", + MIN_FUNCTION{ + m_voice.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Prepare the voice for the current sample rate.", + MIN_FUNCTION{ + m_voice.prepare(samplerate()); + return {}; + }}; + + ondes(const atoms& args = {}) { m_voice.prepare(samplerate()); } + + sample operator()(sample rib, sample k) { + // Signals on the performance inlets play the instrument; otherwise the attributes do, + // through their ramps. The signal path bypasses those ramps, because re-targeting a 20 ms + // slew every sample would make the key lag the hand. With one inlet connected and the + // other not, the unconnected side falls back to its attribute target — the simple, + // predictable reading of a mixed patch. + const bool rib_signal = m_in_ribbon.has_signal_connection(); + const bool key_signal = m_in_key.has_signal_connection(); + if (!rib_signal && !key_signal) { + return m_voice.process(); + } + return m_voice.process(rib_signal ? static_cast(rib) : static_cast(ribbon), + key_signal ? static_cast(k) : static_cast(key)); + } +}; + +MIN_EXTERNAL(ondes); diff --git a/source/projects/tap.ondes_tilde/tap.ondes_tilde_test.cpp b/source/projects/tap.ondes_tilde/tap.ondes_tilde_test.cpp new file mode 100644 index 00000000..2b922e9d --- /dev/null +++ b/source/projects/tap.ondes_tilde/tap.ondes_tilde_test.cpp @@ -0,0 +1,185 @@ +/// @file +/// @brief Unit tests for tap.ondes~ (the Ondes Martenot voice). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/ondes_test.cpp) +/// carries the tube model, the closed-form detector's equivalence to the full +/// heterodyne simulation, and the harmonic measurements. Here: the documented +/// defaults, the clamps, and the two behaviours a patcher would file as bugs — a key +/// at rest is exactly silent, and the object makes sound with nothing plugged into it, +/// because it is a source. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.ondes_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.ondes~ instantiates with its documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + ondes& my_object = an_instance; + + THEN("the performance surface and the circuit are where the header says") { + REQUIRE(static_cast(my_object.ribbon) == 24.0); + REQUIRE(static_cast(my_object.key) == 1.0); + REQUIRE(static_cast(my_object.depth) == kernel::k_default_depth); + REQUIRE(static_cast(my_object.detect) == kernel::k_detect_ms); + REQUIRE(static_cast(my_object.drive) == 1.0); + REQUIRE(static_cast(my_object.keyplacement) == kernel::key_after); + REQUIRE(static_cast(my_object.polarity) == 1); + REQUIRE(static_cast(my_object.power) == false); + REQUIRE(static_cast(my_object.oversample) == kernel::k_default_os); + } + THEN("the published detector time constant is the default, in ms") { + REQUIRE(std::abs(static_cast(my_object.detect) - 0.2) < 1e-12); + } + } +} + +SCENARIO("tap.ondes~ clamps its performance surface") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + ondes& my_object = an_instance; + + THEN("the ribbon stays on the instrument") { + my_object.ribbon = 500.0; + REQUIRE(static_cast(my_object.ribbon) == kernel::k_max_semitones); + my_object.ribbon = -5.0; + REQUIRE(static_cast(my_object.ribbon) == 0.0); + } + THEN("the key stays on its travel") { + my_object.key = 4.0; + REQUIRE(static_cast(my_object.key) == 1.0); + my_object.key = -1.0; + REQUIRE(static_cast(my_object.key) == 0.0); + } + THEN("depth is a fraction and drive is bounded") { + my_object.depth = 3.0; + REQUIRE(static_cast(my_object.depth) == 1.0); + my_object.drive = 99.0; + REQUIRE(static_cast(my_object.drive) == 8.0); + my_object.drive = -1.0; + REQUIRE(static_cast(my_object.drive) == 0.0); + } + THEN("oversample snaps to a power of two inside the range") { + my_object.oversample = 7; + REQUIRE(static_cast(my_object.oversample) == 4); + my_object.oversample = 99; + REQUIRE(static_cast(my_object.oversample) == 8); + my_object.oversample = 0; + REQUIRE(static_cast(my_object.oversample) == 1); + } + THEN("polarity is a sign, and key placement one of the two readings offered") { + my_object.polarity = -7; + REQUIRE(static_cast(my_object.polarity) == -1); + my_object.polarity = 3; + REQUIRE(static_cast(my_object.polarity) == 1); + my_object.keyplacement = 9; + REQUIRE(static_cast(my_object.keyplacement) == kernel::k_num_key_placements - 1); + } + } +} + +// The two things a patcher would otherwise file as bugs. +SCENARIO("tap.ondes~ is a source, and it is exactly silent with the key at rest") { + ext_main(nullptr); + + GIVEN("a key at rest") { + test_wrapper an_instance; + ondes& my_object = an_instance; + my_object.smooth = 0.0; + my_object.key = 0.0; + + THEN("nothing comes out at all — the bottom of the key's travel is true silence") { + bool silent = true; + for (int i = 0; i < 8000; ++i) { + silent = silent && (my_object(0.0, 0.0) == 0.0); + } + REQUIRE(silent); + } + } + + GIVEN("a key at full press and nothing patched into the inlets") { + test_wrapper an_instance; + ondes& my_object = an_instance; + my_object.smooth = 0.0; + my_object.key = 1.0; + my_object.level = 1.0; + + THEN("it plays anyway, because it is an instrument rather than an effect") { + double peak = 0.0; + for (int i = 0; i < 24000; ++i) { + peak = std::max(peak, std::abs(static_cast(my_object(0.0, 0.0)))); + } + INFO("peak with no input at all: " << peak); + REQUIRE(peak > 0.05); + } + } +} + +SCENARIO("tap.ondes~ plays the note the ribbon asks for") { + ext_main(nullptr); + + GIVEN("the ribbon two octaves up from A1") { + test_wrapper an_instance; + ondes& my_object = an_instance; + my_object.smooth = 0.0; + my_object.key = 1.0; + my_object.level = 1.0; + my_object.ribbon = 24.0; + + THEN("the strongest partial is A3, and it is stronger than its own harmonics") { + const double sr = my_object.samplerate(); + const double f0 = 55.0 * 4.0; + const int n = static_cast(sr); + std::vector y(static_cast(n)); + for (int i = 0; i < n; ++i) { + y[static_cast(i)] = static_cast(my_object(0.0, 0.0)); + } + auto bin = [&](double hz) { + const double w = 2.0 * 3.14159265358979323846 * hz / sr; + const double c = 2.0 * std::cos(w); + double s1 = 0.0, s2 = 0.0; + for (size_t i = y.size() / 2; i < y.size(); ++i) { + const double s = y[i] + c * s1 - s2; + s2 = s1; + s1 = s; + } + return std::sqrt(std::max(0.0, s1 * s1 + s2 * s2 - c * s1 * s2)) * 2.0 + / static_cast(y.size() / 2); + }; + const double h1 = bin(f0); + INFO("fundamental " << h1 << ", H2 " << bin(2 * f0) << ", H3 " << bin(3 * f0)); + REQUIRE(h1 > 0.05); + REQUIRE(bin(2 * f0) < h1); + REQUIRE(bin(3 * f0) < h1); + } + } +} + +SCENARIO("tap.ondes~ clears without disturbing its settings") { + ext_main(nullptr); + + GIVEN("a sounding voice") { + test_wrapper an_instance; + ondes& my_object = an_instance; + my_object.ribbon = 31.0; + my_object.drive = 3.0; + for (int i = 0; i < 4800; ++i) { + my_object(0.0, 0.0); + } + + WHEN("it is cleared") { + my_object.clear(); + THEN("the parameters are untouched") { + REQUIRE(static_cast(my_object.ribbon) == 31.0); + REQUIRE(static_cast(my_object.drive) == 3.0); + } + } + } +} diff --git a/source/projects/tap.palme_tilde/CMakeLists.txt b/source/projects/tap.palme_tilde/CMakeLists.txt new file mode 100644 index 00000000..83cfc1f3 --- /dev/null +++ b/source/projects/tap.palme_tilde/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/diffuseur.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.palme_tilde/tap.palme_tilde.cpp b/source/projects/tap.palme_tilde/tap.palme_tilde.cpp new file mode 100644 index 00000000..e3c8c4fb --- /dev/null +++ b/source/projects/tap.palme_tilde/tap.palme_tilde.cpp @@ -0,0 +1,181 @@ +/// @file +/// @brief tap.palme~ — the Ondes Martenot's string diffuseur, as a driven resonator. +/// @details Wraps tap::tools::diffuseur::palme (taptools/diffuseur.h). The *palme* (1949–50) +/// is the strangest loudspeaker in music: an electromagnet driving twelve metal +/// strings stretched on a lyre-shaped soundboard. Anything sent through it comes back +/// with a halo, because whichever strings share partials with the signal ring in +/// sympathy and the rest stay quiet. +/// +/// **Run a guitar through it.** That is the reason this object exists on its own +/// rather than only inside an ondes: sympathetic resonance is useful on any pitched +/// material, and the twelve chromatic strings mean it answers whatever key you are in. +/// +/// **Driven, not struck**, and the driver is upstream: the signal reaches the +/// electromagnet first and its motion excites the strings, so @drive, @asymmetry and +/// @saturation sit before the body, which is the order the instrument wires them. +/// +/// **Twelve strings**, per the peer-reviewed source (Wijnand et al., Forum Acusticum +/// 2023). Widely copied hobbyist build pages say twenty-four; this follows the +/// peer-reviewed number. Their *tuning* is not published anywhere found, so @tuning +/// is a parameter rather than a reconstruction — chromatic across an octave (a string +/// for every pitch class) or the harmonic series on the root (a drone that answers +/// one key). +/// +/// Note that @decay and @damping are not independent: a heavily damped string cannot +/// ring for the time you ask, and past that point the damping wins. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::diffuseur; + +class palme : public object, public sample_operator<1, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::palme m_machine; + + public: + MIN_DESCRIPTION{"The Ondes Martenot's string diffuseur: an electromagnet driving twelve metal " + "strings on a soundboard, here twelve damped waveguide loops behind a " + "moving-iron driver. Whichever strings share partials with the input ring in " + "sympathy and the rest stay quiet, which is the halo the instrument is known " + "for. Twelve strings per the peer-reviewed source, not the twenty-four of the " + "hobbyist pages; their tuning is not published anywhere found, so it is a " + "parameter. Run a guitar through it. For multichannel signals, wrap this " + "object in an mc. operator."}; + MIN_TAGS{"effects"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.metallique~, tap.touche~, tap.5comb~, tap.chime~"}; + + inlet<> m_in{this, "(signal) audio input — drives the electromagnet"}; + outlet<> m_out{this, "(signal) the strings, balanced against the input", "signal"}; + + attribute root{this, "root", kernel::k_default_root_hz, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_pitch_hz, + kernel::k_max_pitch_hz); + m_machine.set_root_hz(v); + return {v}; + }}, + description{"Pitch of the lowest string in Hz. The other eleven are laid out from it " + "by the tuning attribute."}}; + + attribute tuning{this, "tuning", kernel::tuning_chromatic, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_tunings - 1); + m_machine.set_tuning(v); + return {v}; + }}, + description{"How the twelve strings are laid out: 0 chromatic across an octave from the " + "root (a string for every pitch class, so the board answers whatever you " + "play), 1 the harmonic series on the root (a drone that answers one key). " + "Not a published detail of the instrument — a design choice, offered as a " + "choice."}}; + + attribute decay{this, "decay", kernel::k_default_decay_s, setter{MIN_FUNCTION{ + const double v = + std::clamp(static_cast(args[0]), kernel::k_min_t60, kernel::k_max_t60); + m_machine.set_decay(v); + return {v}; + }}, + description{"Ring time of a string in seconds (T60). Not independent of damping: a " + "heavily damped string cannot ring for the time you ask, and past that " + "point the damping decides."}}; + + attribute damping{this, "damping", kernel::k_default_damp_hz, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_damp_hz, + kernel::k_max_damp_hz); + m_machine.set_damping(v); + return {v}; + }}, + description{"In-loop damping corner in Hz: how quickly a string loses its upper " + "partials. Low values are felt cloth on the strings."}}; + + attribute detune{this, "detune", kernel::k_default_detune_c, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 50.0); + m_machine.set_detune(v); + return {v}; + }}, + description{"Depth in cents of the fixed per-string scatter — no two strings on a " + "real board are in perfect relation. Deterministic, so the board is the " + "same board in every instance. This is scatter between strings, not " + "stiffness within one: the partials of each string stay exactly " + "harmonic."}}; + + attribute drive{this, "drive", kernel::k_default_drive, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_drive(v); + return {v}; + }}, + description{"Linear gain into the electromagnet, before the strings."}}; + + attribute asymmetry{this, "asymmetry", kernel::k_default_asymmetry, + setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_machine.set_asymmetry(v); + return {v}; + }}, + description{"The moving-iron squared term, 0 to 1: force follows the square of the " + "gap flux, so this puts a second harmonic on the signal in proportion " + "to level. 0 is a linear driver — legitimate, but then a documented " + "stage of the instrument is missing."}}; + + attribute saturation{this, "saturation", kernel::k_default_sat, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_saturation(v); + return {v}; + }}, + description{"Soft-clip amount after the squared term; 0 is exactly linear. A " + "modelling necessity rather than a measured stage — the squared law " + "is expansive and something has to bound it."}}; + + attribute mix{this, "mix", kernel::k_default_mix, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 100.0); + m_machine.set_mix(v); + return {v}; + }}, + description{"Balance between the dry input and the diffuseur, 0 to 100, equal-power. " + "The ends are exact: 0 is the input bit for bit, 100 is the strings alone."}}; + + attribute level{this, "level", kernel::k_default_level, setter{MIN_FUNCTION{ + const double v = static_cast(args[0]); + m_machine.set_level(v); + return {v}; + }}, + description{"Output level, linear. Twelve resonant loops add up — a driven board can " + "be much louder than what went into it."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the driver, mix and level setters, in ms " + "(0 = instant). The strings' own parameters are not ramped."}}; + + message<> clear{this, "clear", "Damp every string and reset the driver. Parameters are kept.", + MIN_FUNCTION{ + m_machine.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Prepare the board for the current sample rate.", + MIN_FUNCTION{ + m_machine.prepare(samplerate()); + return {}; + }}; + + palme(const atoms& args = {}) { m_machine.prepare(samplerate()); } + + sample operator()(sample x) { return m_machine.process(x); } +}; + +MIN_EXTERNAL(palme); diff --git a/source/projects/tap.palme_tilde/tap.palme_tilde_test.cpp b/source/projects/tap.palme_tilde/tap.palme_tilde_test.cpp new file mode 100644 index 00000000..2a4938b8 --- /dev/null +++ b/source/projects/tap.palme_tilde/tap.palme_tilde_test.cpp @@ -0,0 +1,129 @@ +/// @file +/// @brief Unit tests for tap.palme~ (the Ondes Martenot's string diffuseur). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/diffuseur_test.cpp) +/// carries the sympathetic selectivity and the ring-time calibration. Here: the +/// documented defaults, the clamps, the two tunings, and the passthrough contract. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.palme_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.palme~ instantiates with its documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + palme& my_object = an_instance; + + THEN("the board and the driver are where the header says") { + REQUIRE(static_cast(my_object.root) == kernel::k_default_root_hz); + REQUIRE(static_cast(my_object.tuning) == kernel::tuning_chromatic); + REQUIRE(static_cast(my_object.decay) == kernel::k_default_decay_s); + REQUIRE(static_cast(my_object.damping) == kernel::k_default_damp_hz); + REQUIRE(static_cast(my_object.detune) == kernel::k_default_detune_c); + REQUIRE(static_cast(my_object.mix) == kernel::k_default_mix); + } + } +} + +SCENARIO("tap.palme~ clamps its board to the legal ranges") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + palme& my_object = an_instance; + + THEN("the root stays inside the range the loops were bought for") { + my_object.root = 100000.0; + REQUIRE(static_cast(my_object.root) == kernel::k_max_pitch_hz); + my_object.root = 0.0; + REQUIRE(static_cast(my_object.root) == kernel::k_min_pitch_hz); + } + THEN("damping stays inside the audible band") { + my_object.damping = 1e9; + REQUIRE(static_cast(my_object.damping) == kernel::k_max_damp_hz); + my_object.damping = 1.0; + REQUIRE(static_cast(my_object.damping) == kernel::k_min_damp_hz); + } + THEN("tuning only accepts the layouts that exist") { + my_object.tuning = 9; + REQUIRE(static_cast(my_object.tuning) == kernel::k_num_tunings - 1); + my_object.tuning = -3; + REQUIRE(static_cast(my_object.tuning) == 0); + } + THEN("detune is bounded, so the board cannot be scattered into noise") { + my_object.detune = 400.0; + REQUIRE(static_cast(my_object.detune) == 50.0); + my_object.detune = -1.0; + REQUIRE(static_cast(my_object.detune) == 0.0); + } + } +} + +SCENARIO("tap.palme~ is a bitwise passthrough fully dry, and rings fully wet") { + ext_main(nullptr); + + GIVEN("a board with the balance hard dry") { + test_wrapper an_instance; + palme& my_object = an_instance; + my_object.smooth = 0.0; + my_object.mix = 0.0; + my_object.level = 1.0; + + THEN("what comes out is exactly what went in") { + bool identical = true; + for (int i = 0; i < 2000; ++i) { + const sample x = 0.4 * std::sin(6.2831853071795865 * 330.0 * i / 48000.0); + identical = identical && (my_object(x) == x); + } + REQUIRE(identical); + } + } + + GIVEN("a board driven on one of its own strings, then left alone") { + test_wrapper an_instance; + palme& my_object = an_instance; + my_object.smooth = 0.0; + my_object.mix = 100.0; + my_object.root = 110.0; + my_object.decay = 6.0; + my_object.detune = 0.0; + + THEN("it is still sounding after the drive stops") { + for (int i = 0; i < 48000; ++i) { + my_object(0.3 * std::sin(6.2831853071795865 * 110.0 * i / 48000.0)); + } + double energy = 0.0; + for (int i = 0; i < 24000; ++i) { + const sample y = my_object(0.0); + energy += static_cast(y) * static_cast(y); + } + REQUIRE(energy > 0.0); + } + } +} + +SCENARIO("tap.palme~ clears without disturbing its settings") { + ext_main(nullptr); + + GIVEN("a ringing board") { + test_wrapper an_instance; + palme& my_object = an_instance; + my_object.root = 150.0; + my_object.tuning = kernel::tuning_harmonic; + for (int i = 0; i < 4800; ++i) { + my_object(0.5); + } + + WHEN("it is cleared") { + my_object.clear(); + THEN("the parameters are untouched") { + REQUIRE(static_cast(my_object.root) == 150.0); + REQUIRE(static_cast(my_object.tuning) == kernel::tuning_harmonic); + } + } + } +} diff --git a/source/projects/tap.scrub_tilde/CMakeLists.txt b/source/projects/tap.scrub_tilde/CMakeLists.txt new file mode 100644 index 00000000..7d4b029b --- /dev/null +++ b/source/projects/tap.scrub_tilde/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/scrub.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.scrub_tilde/tap.scrub_tilde.cpp b/source/projects/tap.scrub_tilde/tap.scrub_tilde.cpp new file mode 100644 index 00000000..42da9a36 --- /dev/null +++ b/source/projects/tap.scrub_tilde/tap.scrub_tilde.cpp @@ -0,0 +1,230 @@ +/// @file +/// @brief tap.scrub~ — a granular scrub pad over live capture. +/// @details Wraps tap::tools::scrub::machine (taptools/scrub.h). Record the input +/// continuously, then put a granular playhead on it whose **position** and **pitch** +/// are two independent performable signals. Drag the position and you rake back and +/// forth through the last few seconds of the performance; hold it still and you have +/// a granular freeze; move the pitch and the material transposes without the position +/// moving at all. +/// +/// The two signal inlets are the two axes of a pad, which is the whole Max-side story: +/// @position in the middle inlet, @pitch in the right, both at signal rate, and the +/// gesture is the instrument. Either falls back to its attribute when nothing is +/// connected. +/// +/// **The null it is built on.** Grains are Hann-windowed and fired every +/// @size / @overlap samples, and Hann overlap-adds to exactly 1 at those hops. So +/// held still at @pitch 0 with @spray 0, this object is a plain delay of @position +/// milliseconds — measured at 4.4e-16 in the kernel's suite. Everything else it does +/// is a departure from that, which is only trustworthy because the identity is exact. +/// +/// **@freeze stops the recorder, not the playhead.** Frozen, the position addresses +/// fixed tape and the grains loop the same window, so you can keep scrubbing, +/// transposing and drifting through a moment that has already gone past. +/// +/// **@pitch is a granular texture, not a hi-fi shift.** Transposing means reading the +/// tape at a rate the write head does not share, so the read pointer has to be +/// wrapped back periodically and each wrap is a splice. Measured, 98.8 % of a clean +/// shifter's energy still lands at the transposed pitch, but spread into a narrow +/// comb rather than one line — audibly a warble. For clean transposition reach for +/// tap.pitchaccum~ or tap.shift~. +/// +/// The tape is the same class tap.stammer~ records into, shared rather than copied. +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::scrub; + +class scrub : public object, public sample_operator<3, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::machine m_machine; + bool m_geometry_dirty{false}; + + public: + MIN_DESCRIPTION{"A granular scrub pad over live capture. The input is recorded continuously " + "and a Hann-windowed granular playhead reads it, with position and pitch as " + "two independent signals — the two axes of a pad. Held still at pitch 0 the " + "object is exactly a delay; move the position and you rake through the last " + "few seconds; freeze stops the recorder so the position addresses fixed tape. " + "Pitch here is a granular texture rather than a hi-fi shift — use " + "tap.pitchaccum~ for clean transposition. Wants transient material. For " + "multichannel signals, wrap this object in an mc. operator."}; + MIN_TAGS{"effects"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.stammer~, tap.pitchaccum~, tap.tapecho~, tap.reel~"}; + + inlet<> m_in{this, "(signal) audio input — captured continuously"}; + inlet<> m_in_pos{this, "(signal) playhead position, ms behind the live edge"}; + inlet<> m_in_pitch{this, "(signal) transposition in semitones"}; + outlet<> m_out{this, "(signal) the scrub, balanced against the input", "signal"}; + + attribute maxhistory{ + this, "maxhistory", kernel::k_default_max_history_ms * 0.001, setter{MIN_FUNCTION{ + const double v = std::max(kernel::k_min_size_ms * 0.001, static_cast(args[0])); + m_geometry_dirty = true; // re-bought (and the tape erased) when the DSP chain starts + return {v}; + }}, + description{"Captured history in seconds (set by the first object argument; bought at " + "DSP start). It is also the ceiling on position. Changing it erases the tape " + "on the next DSP restart."}}; + + attribute position{this, "position", kernel::k_default_position_ms, + setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_position_ms(v); + return {v}; + }}, + description{"Where the playhead sits, as a lag behind the live edge in ms. 0 is the " + "newest sample; the ceiling is maxhistory. This is the scrub gesture, " + "so it is slewed by smooth. Ignored while a signal is connected to the " + "middle inlet."}}; + + attribute pitch{this, "pitch", kernel::k_default_pitch_st, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), -kernel::k_max_pitch_st, + kernel::k_max_pitch_st); + m_machine.set_pitch(v); + return {v}; + }}, + description{"Transposition in semitones, plus or minus two octaves. Independent of " + "position — that independence is the object. A granular texture rather " + "than a clean shift: the wraps leave a warble. Ignored while a signal is " + "connected to the right inlet."}}; + + attribute drift{this, "drift", kernel::k_default_drift, setter{MIN_FUNCTION{ + const double v = + std::clamp(static_cast(args[0]), -kernel::k_max_drift, kernel::k_max_drift); + m_machine.set_drift(v); + return {v}; + }}, + description{"The playhead's own motion through the tape, in playback-rate units: " + "positive runs forward toward the live edge, negative backwards, 0 holds " + "station. It wraps around the captured history rather than clamping, so a " + "slow drift is a loop. Most useful with freeze on."}}; + + attribute freeze{this, "freeze", false, setter{MIN_FUNCTION{ + const bool v = static_cast(args[0]); + m_machine.set_freeze(v); + return {v}; + }}, + description{"Stop the recorder. The playhead keeps going, so the position now addresses " + "fixed tape — a granular hold you can still scrub, transpose and drift " + "through. It does not stop time inside a grain already in flight."}}; + + attribute size{this, "size", kernel::k_default_size_ms, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_size_ms, + kernel::k_max_size_ms); + m_machine.set_size_ms(v); + return {v}; + }}, + description{"Grain length in ms. Grains in flight keep the length they were born with. " + "Sizes that divide evenly by overlap have an exactly flat window sum; " + "others leave a small ripple."}}; + + attribute overlap{this, "overlap", kernel::k_default_overlap, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 1, kernel::k_max_overlap); + m_machine.set_overlap(v); + return {v}; + }}, + description{"How many grains overlap: the hop is size / overlap. 1 leaves gaps between " + "grains, which is a chopped texture rather than a defect; 2 and up hold a " + "constant level, and 2 is the setting the delay null depends on."}}; + + attribute spray{this, "spray", kernel::k_default_spray_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_spray_ms(v); + return {v}; + }}, + description{"Random scatter of each grain's origin, in ms back from the position. At " + "exactly 0 the dice are never rolled, so the seed cannot matter and the " + "object is deterministic without it. Also the way to trade the " + "transposition's comb for a broadband smear."}}; + + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ + const int v = static_cast(args[0]); + m_machine.set_seed(static_cast(v)); + return {v}; + }}, + description{"The spray seed. Deterministic: same seed and same moves, same render, bit " + "for bit; different instances decorrelate by seed. Only consumed while spray " + "is above 0."}}; + + attribute mix{this, "mix", kernel::k_default_mix, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 100.0); + m_machine.set_mix(v); + return {v}; + }}, + description{"Balance between the live input and the scrub, 0 to 100, equal-power. The " + "ends are exact: 0 is the input bit for bit, 100 is the scrub alone."}}; + + attribute level{this, "level", kernel::k_default_level, setter{MIN_FUNCTION{ + const double v = static_cast(args[0]); + m_machine.set_level(v); + return {v}; + }}, + description{"Output level, linear."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the attribute-driven position, pitch, drift, " + "mix and level, in ms (0 = instant). The signal inlets are not ramped — " + "a control signal is assumed smooth already."}}; + + message<> clear{this, "clear", + "Erase the tape, kill every grain, rewind the drift, and restart the seeded stream. " + "Parameters are kept.", + MIN_FUNCTION{ + m_machine.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", + "Re-prepare only when the sample rate or the captured history changed — a DSP chain " + "restart must not silently throw away what has been recorded.", + MIN_FUNCTION{ + if (m_geometry_dirty || !m_machine.prepared() || samplerate() != m_machine.samplerate()) { + m_machine.prepare(samplerate(), static_cast(maxhistory) * 1000.0); + m_geometry_dirty = false; + } + return {}; + }}; + + scrub(const atoms& args = {}) { + if (!args.empty() && static_cast(args[0]) > 0.0) { + maxhistory = args[0]; + } + m_machine.prepare(samplerate(), static_cast(maxhistory) * 1000.0); + m_geometry_dirty = false; + } + + sample operator()(sample x, sample pos, sample semitones) { + // Signals on the performance inlets drive the playhead directly; otherwise the + // attributes do, through their ramps. With one inlet connected and the other not, the + // unconnected side falls back to its attribute TARGET rather than its ramp, so a change + // there lands immediately instead of slewing — the mixed case is a patching choice, and + // this is the simple, predictable reading of it. + const bool pos_signal = m_in_pos.has_signal_connection(); + const bool pitch_signal = m_in_pitch.has_signal_connection(); + if (!pos_signal && !pitch_signal) { + return m_machine.process(x); + } + return m_machine.process(x, pos_signal ? static_cast(pos) : static_cast(position), + pitch_signal ? static_cast(semitones) : static_cast(pitch)); + } +}; + +MIN_EXTERNAL(scrub); diff --git a/source/projects/tap.scrub_tilde/tap.scrub_tilde_test.cpp b/source/projects/tap.scrub_tilde/tap.scrub_tilde_test.cpp new file mode 100644 index 00000000..1b925761 --- /dev/null +++ b/source/projects/tap.scrub_tilde/tap.scrub_tilde_test.cpp @@ -0,0 +1,156 @@ +/// @file +/// @brief Unit tests for tap.scrub~ (the granular scrub pad). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/scrub_test.cpp) +/// carries the delay null, the transposition, the freeze and the seed contract. Here: +/// the documented defaults, the clamps, the maxhistory argument, and the one thing a +/// patcher would file as a bug if it were wrong — held still at pitch 0 the object is +/// a plain delay, so it must not colour the signal at all. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.scrub_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.scrub~ instantiates with its documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + scrub& my_object = an_instance; + + THEN("the playhead and the grains are where the header says") { + REQUIRE(static_cast(my_object.position) == kernel::k_default_position_ms); + REQUIRE(static_cast(my_object.pitch) == kernel::k_default_pitch_st); + REQUIRE(static_cast(my_object.drift) == kernel::k_default_drift); + REQUIRE(static_cast(my_object.freeze) == false); + REQUIRE(static_cast(my_object.size) == kernel::k_default_size_ms); + REQUIRE(static_cast(my_object.overlap) == kernel::k_default_overlap); + REQUIRE(static_cast(my_object.spray) == kernel::k_default_spray_ms); + REQUIRE(static_cast(my_object.mix) == kernel::k_default_mix); + } + THEN("the captured history matches the kernel's default buy") { + REQUIRE(static_cast(my_object.maxhistory) == kernel::k_default_max_history_ms * 0.001); + } + } +} + +SCENARIO("tap.scrub~ clamps its performance surface") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + scrub& my_object = an_instance; + + THEN("pitch stays inside two octaves either way") { + my_object.pitch = 99.0; + REQUIRE(static_cast(my_object.pitch) == kernel::k_max_pitch_st); + my_object.pitch = -99.0; + REQUIRE(static_cast(my_object.pitch) == -kernel::k_max_pitch_st); + } + THEN("drift is bounded both ways") { + my_object.drift = 100.0; + REQUIRE(static_cast(my_object.drift) == kernel::k_max_drift); + my_object.drift = -100.0; + REQUIRE(static_cast(my_object.drift) == -kernel::k_max_drift); + } + THEN("overlap stays inside the grain pool") { + my_object.overlap = 99; + REQUIRE(static_cast(my_object.overlap) == kernel::k_max_overlap); + my_object.overlap = 0; + REQUIRE(static_cast(my_object.overlap) == 1); + } + THEN("grain size stays inside the range the window sum was designed for") { + my_object.size = 1e6; + REQUIRE(static_cast(my_object.size) == kernel::k_max_size_ms); + my_object.size = 0.0; + REQUIRE(static_cast(my_object.size) == kernel::k_min_size_ms); + } + THEN("position and spray cannot go negative") { + my_object.position = -10.0; + REQUIRE(static_cast(my_object.position) == 0.0); + my_object.spray = -10.0; + REQUIRE(static_cast(my_object.spray) == 0.0); + } + } +} + +// The min-api test harness has no argument-taking constructor, so the object-argument path is +// exercised in Max rather than here; what the wrapper can pin is the attribute it writes into. +SCENARIO("tap.scrub~'s captured history is settable and floored") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + scrub& my_object = an_instance; + + THEN("it can be retargeted, and cannot be asked for nothing") { + my_object.maxhistory = 2.0; + REQUIRE(static_cast(my_object.maxhistory) == 2.0); + my_object.maxhistory = -1.0; + REQUIRE(static_cast(my_object.maxhistory) == kernel::k_min_size_ms * 0.001); + } + } +} + +// The claim the whole object is built on, checked at the wrapper so a plumbing mistake cannot +// hide it: held still at pitch 0 with no spray, this is a delay and nothing else. +SCENARIO("tap.scrub~ held still at unity pitch is a plain delay") { + ext_main(nullptr); + + GIVEN("a scrub with a whole-sample position and overlap 2") { + test_wrapper an_instance; + scrub& my_object = an_instance; + // Everything here is expressed in SAMPLES and converted, because the mock harness does + // not necessarily report 48 kHz and the null only holds on whole samples. + const double sr = my_object.samplerate(); + const double pos_ms = 480.0 * 1000.0 / sr; + my_object.smooth = 0.0; + my_object.overlap = 2; + my_object.size = 96.0 * 1000.0 / sr; // 96 samples, divides by 2 + my_object.position = pos_ms; + my_object.mix = 100.0; + my_object.level = 1.0; + + THEN("the output is the input, 480 samples late") { + // The position is handed in on the signal inlet AND set as the attribute, to the same + // value: the mock harness does not model patch cords, so whichever path the wrapper + // takes must land on the same playhead. + std::vector in(20000), out(20000); + for (int i = 0; i < 20000; ++i) { + in[i] = 0.5 * std::sin(6.2831853071795865 * 220.0 * i / sr) + * (0.5 + 0.5 * std::sin(6.2831853071795865 * 3.0 * i / sr)); + out[i] = static_cast(my_object(in[i], pos_ms, 0.0)); + } + double worst = 0.0; + for (int i = 2000; i < 20000; ++i) { + worst = std::max(worst, std::abs(out[i] - in[i - 480])); + } + INFO("largest departure from a 480-sample delay: " << worst); + REQUIRE(worst < 1e-12); + } + } +} + +SCENARIO("tap.scrub~ is a bitwise passthrough fully dry") { + ext_main(nullptr); + + GIVEN("a scrub with the balance hard dry") { + test_wrapper an_instance; + scrub& my_object = an_instance; + my_object.smooth = 0.0; + my_object.mix = 0.0; + my_object.level = 1.0; + + THEN("what comes out is exactly what went in") { + bool identical = true; + for (int i = 0; i < 4000; ++i) { + const sample x = 0.4 * std::sin(6.2831853071795865 * 300.0 * i / 48000.0); + identical = identical && (my_object(x, 0.0, 0.0) == x); + } + REQUIRE(identical); + } + } +} diff --git a/source/projects/tap.stammer_tilde/CMakeLists.txt b/source/projects/tap.stammer_tilde/CMakeLists.txt new file mode 100644 index 00000000..f83c3b97 --- /dev/null +++ b/source/projects/tap.stammer_tilde/CMakeLists.txt @@ -0,0 +1,17 @@ +# 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/stammer.h + ${TAPTOOLS_KERNEL_DIR}/include/taptools/tape_loop.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.stammer_tilde/tap.stammer_tilde.cpp b/source/projects/tap.stammer_tilde/tap.stammer_tilde.cpp new file mode 100644 index 00000000..b15b842f --- /dev/null +++ b/source/projects/tap.stammer_tilde/tap.stammer_tilde.cpp @@ -0,0 +1,199 @@ +/// @file +/// @brief tap.stammer~ — the live buffer-stutter rig on the portable kernel. +/// @details Wraps tap::tools::stammer::machine (taptools/stammer.h): the input is captured +/// continuously, and on a @step grid the machine rolls dice and re-fires a slice of +/// what just went past. @density is how often it grabs, @divisions how finely it +/// chops (a slice is @step divided by 1..@divisions), @repeats how many passes it +/// holds on for, @reverse the per-repeat chance of running backwards, and @jump how +/// far further back it may reach. Riding those while a part plays is the instrument — +/// none of them is a set-and-forget. +/// +/// @seed makes it a contract rather than a suggestion: every draw comes from the +/// family's seeded generator in a fixed order, so the same seed is the same +/// performance, bit for bit, and two instances decorrelate by seed. At @density 0 the +/// dice are never rolled at all — the seed cannot matter, and the object is a bitwise +/// bypass. +/// +/// An original design in the brassage tradition (Roads, *Microsound*), not a port of +/// anyone's patch. +/// +/// The optional argument sets the worst-case capture in seconds (default 4). A slice +/// reads from the ring rather than a copy, so a repeat train longer than the captured +/// history will read fresher material as the write head laps it — size the argument +/// to the longest train you intend to fire. `clear` erases the capture, drops the +/// slice in flight and rewinds the seeded stream. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::stammer; + +class stammer : public object, public sample_operator<1, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::machine m_machine; + bool m_geometry_dirty{false}; + + public: + MIN_DESCRIPTION{"A live buffer-stutter rig. The input is captured continuously; on a step " + "grid the machine rolls dice and re-fires a slice of what just went past. " + "density is how often it grabs, divisions how finely it chops, repeats how " + "long it holds on, reverse how often a repeat runs backwards, jump how far " + "back it may reach — ride them and the part comes apart in your hands. The " + "seed is a real contract: same seed, same performance, bit for bit; at " + "density 0 the dice are never rolled and the object is a bitwise bypass. The " + "optional argument sets the captured history in seconds. Wants transient " + "material — on a sustained pad a stutter is barely a tremolo. For " + "multichannel signals, wrap this object in an mc. operator."}; + MIN_TAGS{"delays"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.tapecho~, tap.sustain~, tap.multitap~, tap.shift~"}; + + inlet<> m_in{this, "(signal) audio input — captured continuously"}; + outlet<> m_out{this, "(signal) the input, or the slice while one is firing", "signal"}; + + attribute maxhistory{ + this, "maxhistory", kernel::k_default_max_history_ms * 0.001, setter{MIN_FUNCTION{ + const double v = std::max(kernel::k_min_step_ms * 0.001, static_cast(args[0])); + m_geometry_dirty = true; // re-bought (and the capture erased) when the DSP chain starts + return {v}; + }}, + description{"Captured history in seconds (set by the first object argument; bought at " + "DSP start). A repeat train longer than this reads fresher material as the " + "write head laps it. Changing it erases the capture on the next DSP restart."}}; + + attribute step{this, "step", kernel::k_default_step_ms, setter{MIN_FUNCTION{ + const double v = std::max(kernel::k_min_step_ms, static_cast(args[0])); + m_machine.set_step_ms(v); + return {v}; + }}, + description{"The rhythmic grid in ms: how often the machine may decide to grab. Takes " + "effect at the next grid point — it is a rhythm, not a level, so there is " + "nothing to zipper."}}; + + attribute density{this, "density", kernel::k_default_density, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_machine.set_density(v); + return {v}; + }}, + description{"Chance of firing at an idle grid point (0..1). At exactly 0 the dice are " + "never rolled, so the seed cannot matter and the object is a bitwise " + "bypass."}}; + + attribute divisions{this, "divisions", kernel::k_default_divisions, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 1, kernel::k_max_divisions); + m_machine.set_divisions(v); + return {v}; + }}, + description{"How finely the grid may be chopped (1..8): a slice is step divided by a " + "number drawn from 1 to this. 1 means whole-step slices only."}}; + + attribute repeats{this, "repeats", kernel::k_default_repeats, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 1, kernel::k_max_repeats); + m_machine.set_repeats(v); + return {v}; + }}, + description{"Upper bound on passes per fired slice (1..16); the count is drawn from 1 to " + "this. A slice in flight is never interrupted, so this — not density — is " + "what decides how long the machine stays busy."}}; + + attribute reverse{this, "reverse", kernel::k_default_reverse, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_machine.set_reverse(v); + return {v}; + }}, + description{"Chance that any given repeat plays backwards (0..1). Drawn per repeat, " + "so one train can stagger forwards and back."}}; + + attribute jump{this, "jump", kernel::k_default_jump_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_jump_ms(v); + return {v}; + }}, + description{"How far back beyond the material just past a slice may reach, in ms; the " + "actual reach is drawn from 0 to this. 0 is the classic stutter — open it " + "and the machine starts quoting older material."}}; + + attribute fade{this, "fade", kernel::k_default_fade_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_fade_ms(v); + return {v}; + }}, + description{"Raised-sine flank per repeat in ms — the anti-click. Clamped per slice to " + "half its length. Repeats are sequential, so each junction dips to zero: " + "that is the articulation of a stutter, not a crossfade that failed."}}; + + attribute seed{this, "seed", 1, setter{MIN_FUNCTION{ + const int v = static_cast(args[0]); + m_machine.set_seed(static_cast(v)); + return {v}; + }}, + description{"The performance seed. Deterministic: same seed and same moves, same render, " + "bit for bit; different instances decorrelate by seed. 0 folds to the stock " + "seed."}}; + + attribute input{this, "input", 1.0, setter{MIN_FUNCTION{ + const double v = static_cast(args[0]); + m_machine.set_input_level(v); + return {v}; + }}, + description{"Input level into the capture, linear, slewed."}}; + + attribute mix{this, "mix", kernel::k_default_mix, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 100.0); + m_machine.set_mix(v); + return {v}; + }}, + description{"Balance between the live input and the slice in percent (0..100), " + "equal-power — and it only bites while a slice is firing. When the machine " + "is idle the input passes through bitwise at any mix."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the input and mix setters, in ms (0 = " + "instant)."}}; + + message<> clear{this, "clear", + "Erase the capture, drop the slice in flight, and rewind the seeded stream — the same seed " + "replays the same performance from here. Parameters are kept.", + MIN_FUNCTION{ + m_machine.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", + "Re-prepare only when the sample rate or the captured history changed — a DSP chain " + "restart must not silently throw away a performance in progress.", + MIN_FUNCTION{ + if (m_geometry_dirty || !m_machine.prepared() || samplerate() != m_machine.samplerate()) { + m_machine.prepare(samplerate(), static_cast(maxhistory) * 1000.0); + m_geometry_dirty = false; + } + return {}; + }}; + + stammer(const atoms& args = {}) { + if (!args.empty() && static_cast(args[0]) > 0.0) { + maxhistory = args[0]; + } + m_machine.prepare(samplerate(), static_cast(maxhistory) * 1000.0); + m_geometry_dirty = false; + } + + sample operator()(sample x) { return m_machine.process(x); } +}; + +MIN_EXTERNAL(stammer); diff --git a/source/projects/tap.stammer_tilde/tap.stammer_tilde_test.cpp b/source/projects/tap.stammer_tilde/tap.stammer_tilde_test.cpp new file mode 100644 index 00000000..8575f0b6 --- /dev/null +++ b/source/projects/tap.stammer_tilde/tap.stammer_tilde_test.cpp @@ -0,0 +1,191 @@ +/// @file +/// @brief Unit tests for tap.stammer~ (the live buffer-stutter rig). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/stammer_test.cpp) +/// carries the DSP promises, including the pinned-dice one-step-delay identity. Here: +/// documented defaults, the attribute clamps forwarding into the kernel, the two +/// contracts a patcher can actually observe (density 0 is a bitwise bypass whatever +/// the seed and whatever the mix; the same seed replays the same performance through +/// the wrapper), and clear rewinding the stream. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.stammer_tilde.cpp" // include the object source so we can instantiate it + +namespace { + + constexpr double k_mock_sr = 44100.0; // the mock kernel's sys_getsr() + + /// The documented material: transients, not a sine. + std::vector plucks(long n) { + std::vector x(static_cast(n), 0.0); + for (long i = 0; i < n; ++i) { + const double t = static_cast(i) / k_mock_sr; + const double phi = std::fmod(t, 0.31); + double sum = 0.0; + for (int k = 1; k <= 5; ++k) { + const double kk = static_cast(k); + sum += (1.0 / kk) * std::exp(-phi * (4.0 + 3.0 * kk)) + * std::sin(2.0 * 3.14159265358979323846 * 196.0 * kk * phi); + } + x[static_cast(i)] = 0.5 * sum; + } + return x; + } + +} // namespace + +SCENARIO("tap.stammer~ instantiates with the documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + stammer& my_object = an_instance; + + THEN("a 4 s capture on a 250 ms grid, chopping into quarters, holding up to four") { + REQUIRE(static_cast(my_object.maxhistory) == 4.0); + REQUIRE(static_cast(my_object.step) == 250.0); + REQUIRE(static_cast(my_object.density) == 0.5); + REQUIRE(static_cast(my_object.divisions) == 4); + REQUIRE(static_cast(my_object.repeats) == 4); + REQUIRE(static_cast(my_object.reverse) == 0.25); + REQUIRE(static_cast(my_object.jump) == 0.0); + REQUIRE(static_cast(my_object.fade) == 3.0); + REQUIRE(static_cast(my_object.mix) == 100.0); + } + } +} + +SCENARIO("tap.stammer~ clamps the dice to their documented ranges") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + stammer& my_object = an_instance; + + THEN("density and reverse are probabilities") { + my_object.density = 4.0; + REQUIRE(static_cast(my_object.density) == 1.0); + my_object.density = -1.0; + REQUIRE(static_cast(my_object.density) == 0.0); + my_object.reverse = 9.0; + REQUIRE(static_cast(my_object.reverse) == 1.0); + } + THEN("divisions and repeats stop at the kernel's slots") { + my_object.divisions = 99; + REQUIRE(static_cast(my_object.divisions) == 8); + my_object.divisions = 0; + REQUIRE(static_cast(my_object.divisions) == 1); + my_object.repeats = 99; + REQUIRE(static_cast(my_object.repeats) == 16); + my_object.repeats = 0; + REQUIRE(static_cast(my_object.repeats) == 1); + } + } +} + +// The contract a patcher can check without a scope: switched off, this object is not "nearly" +// transparent, it is the input. +SCENARIO("tap.stammer~ at density 0 is a bitwise bypass, whatever the seed and the mix") { + ext_main(nullptr); + + GIVEN("the dice switched off and a mid mix") { + test_wrapper an_instance; + stammer& my_object = an_instance; + my_object.smooth = 0.0; + my_object.density = 0.0; + my_object.mix = 50.0; + my_object.seed = 4242; + + THEN("the output is the input, bitwise") { + const std::vector x = plucks(8000); + bool exact = true; + for (double v : x) { + exact = exact && (my_object(v) == v); + } + REQUIRE(exact); + } + } +} + +SCENARIO("tap.stammer~ replays the same performance for the same seed") { + ext_main(nullptr); + + GIVEN("a busy machine driven twice from the same seed") { + const std::vector x = plucks(20000); + + auto run = [&](int seed) { + test_wrapper an_instance; + stammer& my_object = an_instance; + my_object.smooth = 0.0; + my_object.step = 70.0; + my_object.density = 0.8; + my_object.divisions = 4; + my_object.repeats = 6; + my_object.reverse = 0.4; + my_object.seed = seed; + std::vector y; + y.reserve(x.size()); + for (double v : x) { + y.push_back(my_object(v)); + } + return y; + }; + + const std::vector a = run(12345); + const std::vector b = run(12345); + const std::vector c = run(999); + + THEN("the same seed is bit-identical") { + bool same = true; + for (size_t i = 0; i < a.size(); ++i) { + same = same && (a[i] == b[i]); + } + REQUIRE(same); + } + THEN("and a different seed is a different performance") { + bool differs = false; + for (size_t i = 0; i < a.size(); ++i) { + differs = differs || (a[i] != c[i]); + } + REQUIRE(differs); + } + } +} + +SCENARIO("tap.stammer~ clear rewinds the seeded stream") { + ext_main(nullptr); + + GIVEN("a machine run once, cleared, then run again on the same material") { + test_wrapper an_instance; + stammer& my_object = an_instance; + my_object.smooth = 0.0; + my_object.step = 60.0; + my_object.density = 0.7; + my_object.repeats = 6; + my_object.seed = 7; + + const std::vector x = plucks(16000); + std::vector a, b; + a.reserve(x.size()); + b.reserve(x.size()); + for (double v : x) { + a.push_back(my_object(v)); + } + my_object.clear(); + for (double v : x) { + b.push_back(my_object(v)); + } + + THEN("the second run is the first, bit for bit") { + bool same = true; + for (size_t i = 0; i < a.size(); ++i) { + same = same && (a[i] == b[i]); + } + REQUIRE(same); + } + } +} diff --git a/source/projects/tap.tapecho_tilde/CMakeLists.txt b/source/projects/tap.tapecho_tilde/CMakeLists.txt new file mode 100644 index 00000000..efdc7068 --- /dev/null +++ b/source/projects/tap.tapecho_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/tapecho.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.tapecho_tilde/tap.tapecho_tilde.cpp b/source/projects/tap.tapecho_tilde/tap.tapecho_tilde.cpp new file mode 100644 index 00000000..3d9e9d17 --- /dev/null +++ b/source/projects/tap.tapecho_tilde/tap.tapecho_tilde.cpp @@ -0,0 +1,261 @@ +/// @file +/// @brief tap.tapecho~ — the multi-head tape echo on the portable kernel. +/// @details A Copicat / Space Echo-school tape echo wrapping tap::tools::tapecho::machine +/// (taptools/tapecho.h): one record head, a span of moving tape, up to four playback +/// heads at settable positions along it, and a regeneration path from the heads back +/// to the record head. @span is the motor — the delay of a head at the far end of the +/// path — and each head sits at @span times its @ratios entry, so moving @span moves +/// the whole layout together, as a tape speed does (and bends pitch while it moves: +/// that is what varispeed is; @smooth sets how fast). +/// +/// It shares its tape machinery with tap.discreet~ (taptools/tape_loop.h) and carries +/// that object's inversion one step further. tap.delay~ caps feedback at 0.99; +/// tap.discreet~ lets regeneration reach exactly 1.0 because the wear path is the +/// stabilizer; here @regen goes *past* 1.0 into deliberate sound-on-sound +/// self-oscillation — the reason anyone reaches for this machine live — bounded by +/// the @drive saturator rather than by a gain cap. Because that bound only exists +/// while the saturator is engaged, the effective regeneration is capped back to 1.0 +/// per sample whenever @drive is 0; the attribute keeps its value and it takes effect +/// again when drive returns. +/// +/// The head layout defaults to four evenly spaced heads (0.25, 0.5, 0.75, 1.0 of the +/// span). That spacing is nominal, not measured from any unit — set @ratios for a +/// three-head Copicat-style layout or anything else. @levels doubles as the head +/// selector: a head's level is also its send into the regeneration path, as it is on +/// the machines. +/// +/// The optional argument sets the worst-case tape buy in seconds (default 4 — +/// ~1.5 MB of double tape at 48 kHz). The tape survives DSP chain restarts: the +/// wrapper re-prepares (which erases the tape) only when the sample rate or the buy +/// actually changes. `clear` is the eject button, and the fastest way to stop a +/// self-oscillating loop. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::tapecho; + +class tapecho : public object, public sample_operator<1, 2> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::machine m_machine; + bool m_geometry_dirty{false}; + + public: + MIN_DESCRIPTION{"A multi-head tape echo. One record head, a span of moving tape, and up to " + "four playback heads at settable positions along it, summed to stereo. span " + "is the motor (the delay of a head at the far end of the path) and moves " + "every head together, bending pitch as it goes — honest varispeed. " + "Regeneration may pass 1.0 into deliberate self-oscillation, bounded by the " + "drive saturator rather than a feedback cap, and capped back to 1.0 whenever " + "drive is 0. Shares its tape machinery with tap.discreet~. The optional " + "argument sets the maximum span in seconds (the tape bought at DSP start). " + "For multichannel signals, wrap this object in an mc. operator."}; + MIN_TAGS{"delays"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.delay~, tap.multitap~, tap.discreet~, tap.comb~"}; + + inlet<> m_in{this, "(signal) audio input — the send into the record head"}; + outlet<> m_out_left{this, "(signal) the head sum plus dry, left", "signal"}; + outlet<> m_out_right{this, "(signal) the head sum plus dry, right", "signal"}; + + attribute maxspan{ + this, "maxspan", kernel::k_default_max_seconds, setter{MIN_FUNCTION{ + const double v = std::max(kernel::k_min_span_ms * 0.001, static_cast(args[0])); + m_geometry_dirty = true; // re-bought (and the tape erased) when the DSP chain starts + return {v}; + }}, + description{"Worst-case tape span in seconds (set by the first object argument; the " + "tape is bought at DSP start and bounds the span attribute). Changing it " + "erases the tape when the DSP chain restarts."}}; + + attribute span{this, "span", kernel::k_default_span_ms, setter{MIN_FUNCTION{ + const double v = std::max(kernel::k_min_span_ms, static_cast(args[0])); + m_machine.set_span_ms(v); + return {v}; + }}, + description{"The motor: the delay in ms of a head at the far end of the path (ratio 1.0), " + "so every head moves with it. Slewed — and the slew is honest tape-speed " + "doppler: the pitch bends while the motor changes speed. smooth sets how " + "long that takes."}}; + + attribute heads{this, "heads", kernel::k_max_heads, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, kernel::k_max_heads); + m_machine.set_heads(v); + return {v}; + }}, + description{"Number of active playback heads (0 to 4). A mute, not a freeze: inactive heads " + "keep ramping, so bringing one back mid-glide does not jump."}}; + + attribute> ratios{ + this, + "ratios", + {0.25, 0.5, 0.75, 1.0}, + setter{MIN_FUNCTION{ + for (size_t i = 0; i < args.size() && i < static_cast(kernel::k_max_heads); ++i) { + m_machine.set_head_ratio(static_cast(i), static_cast(args[i])); + } + return args; + }}, + description{"Per-head position along the tape path as a fraction of span, 0 to 1, slewed. The default is " + "four evenly spaced heads — a nominal layout, not measured from any machine. 0.333 0.667 1. " + "with heads 3 is a Copicat-style three."}}; + + attribute> levels{ + this, + "levels", + {1.0}, + setter{MIN_FUNCTION{ + for (size_t i = 0; i < args.size() && i < static_cast(kernel::k_max_heads); ++i) { + m_machine.set_head_level(static_cast(i), static_cast(args[i])); + } + return args; + }}, + description{"Per-head linear level, slewed. Unclamped (negative flips polarity, like a mixer). This is also " + "the head selector: a head's level is its send into the regeneration path too."}}; + + attribute> pans{ + this, + "pans", + {0.0}, + setter{MIN_FUNCTION{ + for (size_t i = 0; i < args.size() && i < static_cast(kernel::k_max_heads); ++i) { + m_machine.set_head_pan(static_cast(i), static_cast(args[i])); + } + return args; + }}, + description{"Per-head equal-power pan, -1 (hard left) to 1 (hard right), slewed. Endpoints are exact: a " + "hard-panned head is bitwise absent from the far bus."}}; + + attribute regen{this, "regen", kernel::k_default_regen, setter{MIN_FUNCTION{ + const double v = + std::clamp(static_cast(args[0]), 0.0, kernel::k_regen_max_driven); + m_machine.set_regen(v); + return {v}; + }}, + description{"Regeneration into the record head (0 to 1.5). Past 1.0 the loop " + "self-oscillates — bounded by the drive saturator, not by a gain cap — and " + "values above 1.0 only take effect while drive is above zero."}}; + + attribute darken{this, "darken", kernel::k_default_darken_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_machine.set_darken_hz(v); + return {v}; + }}, + description{"Per-pass darkening corner in Hz (20..20000). Every trip through the " + "regeneration path runs through this one-pole lowpass: the repeats lose " + "treble first. Riding it while the loop howls is a performance control."}}; + + attribute drive{this, "drive", kernel::k_default_drive, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_drive(v); + return {v}; + }}, + description{"Record-head saturation drive (0 or more). Any value above zero bounds the " + "loop absolutely (the returned signal can never exceed 1/drive) and is what " + "makes regen above 1.0 safe; 0 is exactly linear and caps the effective " + "regen back to 1.0."}}; + + attribute input{this, "input", 1.0, setter{MIN_FUNCTION{ + const double v = static_cast(args[0]); + m_machine.set_input_level(v); + return {v}; + }}, + description{"Input level into the record head, linear, slewed. Fading this to zero while " + "the loop self-oscillates is the sound-on-sound move: the machine keeps " + "playing what you already gave it."}}; + + attribute mix{this, "mix", kernel::k_default_mix, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 100.0); + m_machine.set_mix(v); + return {v}; + }}, + description{"Dry/wet mix in percent (0..100), equal-power. 0 is bitwise dry on both " + "outlets, 100 bitwise the head sum."}}; + + attribute> wow{this, + "wow", + {kernel::k_default_wow_ms, kernel::k_default_wow_hz}, + setter{MIN_FUNCTION{ + const double depth = + (!args.empty()) ? std::max(0.0, static_cast(args[0])) : 0.0; + const double rate = (args.size() > 1) ? static_cast(args[1]) : 0.0; + m_machine.set_wow(depth, rate); + return {depth, rate}; + }}, + description{"Wow as a depth/rate pair: excursion in ms of tape position, rate " + "in Hz (up to 5). One motor moves the whole path, so every head " + "is displaced together. Deterministic and periodic; 0 0 parks " + "the transport."}}; + + attribute> flutter{this, + "flutter", + {kernel::k_default_flutter_ms, kernel::k_default_flutter_hz}, + setter{MIN_FUNCTION{ + const double depth = + (!args.empty()) ? std::max(0.0, static_cast(args[0])) : 0.0; + const double rate = + (args.size() > 1) ? static_cast(args[1]) : 0.0; + m_machine.set_flutter(depth, rate); + return {depth, rate}; + }}, + description{"Flutter as a depth/rate pair — the faster, shallower partner " + "(rate up to 30 Hz)."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_machine.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for the setters in ms (0 = instant). For span moves " + "this is how long the motor takes to change speed, and therefore how deep " + "the doppler bend is."}}; + + message<> clear{this, "clear", + "Erase the tape and the transport/wear state — the eject button, and the fastest way to stop a " + "self-oscillating loop. Parameters are kept.", + MIN_FUNCTION{ + m_machine.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 sustaining loop.", + MIN_FUNCTION{ + if (m_geometry_dirty || !m_machine.prepared() || samplerate() != m_machine.samplerate()) { + m_machine.prepare(samplerate(), maxspan); + m_geometry_dirty = false; + } + return {}; + }}; + + tapecho(const atoms& args = {}) { + if (!args.empty() && static_cast(args[0]) > 0.0) { + maxspan = args[0]; + } + m_machine.prepare(samplerate(), maxspan); + m_geometry_dirty = false; + } + + samples<2> operator()(sample x) { + double left = 0.0; + double right = 0.0; + m_machine.process(x, left, right); + return {left, right}; + } +}; + +MIN_EXTERNAL(tapecho); diff --git a/source/projects/tap.tapecho_tilde/tap.tapecho_tilde_test.cpp b/source/projects/tap.tapecho_tilde/tap.tapecho_tilde_test.cpp new file mode 100644 index 00000000..b521bc37 --- /dev/null +++ b/source/projects/tap.tapecho_tilde/tap.tapecho_tilde_test.cpp @@ -0,0 +1,189 @@ +/// @file +/// @brief Unit tests for tap.tapecho~ (the multi-head tape echo). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/tapecho_test.cpp) +/// carries the DSP promises, including the bitwise null test against delay.h. Here: +/// documented defaults, the attribute clamps forwarding into the kernel (in +/// particular that regen is allowed *past* unity, unlike tap.discreet~), the head +/// layout reaching the tape through the wrapper, and clear as the eject button. The +/// kernel slews over 20 ms by default, so scenarios zero the smooth attribute first. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.tapecho_tilde.cpp" // include the object source so we can instantiate it + +namespace { + + constexpr double k_mock_sr = 44100.0; // the mock kernel's sys_getsr() + + long samples_for_ms(double ms) { + return static_cast(ms * 0.001 * k_mock_sr); + } + +} // namespace + +SCENARIO("tap.tapecho~ instantiates with the documented defaults") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + tapecho& my_object = an_instance; + + THEN("a 4 s tape, a 375 ms motor, four heads, and the stock wear and transport") { + REQUIRE(static_cast(my_object.maxspan) == 4.0); + REQUIRE(static_cast(my_object.span) == 375.0); + REQUIRE(static_cast(my_object.heads) == 4); + REQUIRE(static_cast(my_object.regen) == 0.35); + REQUIRE(static_cast(my_object.darken) == 4000.0); + REQUIRE(static_cast(my_object.drive) == 0.5); + REQUIRE(static_cast(my_object.input) == 1.0); + REQUIRE(static_cast(my_object.mix) == 35.0); + } + THEN("the default head layout is four evenly spaced ratios") { + const std::vector& r = my_object.ratios; + REQUIRE(r.size() == 4); + REQUIRE(r[0] == 0.25); + REQUIRE(r[1] == 0.5); + REQUIRE(r[2] == 0.75); + REQUIRE(r[3] == 1.0); + } + } +} + +// The wrapper-visible half of the kernel's design statement: unlike tap.discreet~, which clamps +// regen at exactly 1.0, this object accepts values past unity — that is the self-oscillating +// regime, and the drive saturator is what makes it legal. +SCENARIO("tap.tapecho~ accepts regeneration past unity, unlike tap.discreet~") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + tapecho& my_object = an_instance; + + THEN("1.4 is kept, not clamped to 1") { + my_object.regen = 1.4; + REQUIRE(static_cast(my_object.regen) == 1.4); + } + THEN("the ceiling is the kernel's driven maximum") { + my_object.regen = 9.0; + REQUIRE(static_cast(my_object.regen) == 1.5); + } + THEN("a negative regen floors at 0") { + my_object.regen = -0.3; + REQUIRE(static_cast(my_object.regen) == 0.0); + } + } +} + +SCENARIO("tap.tapecho~ clamps the head count to the four slots") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + tapecho& my_object = an_instance; + + THEN("more heads than slots reports back as four") { + my_object.heads = 9; + REQUIRE(static_cast(my_object.heads) == 4); + } + THEN("none is a legal count") { + my_object.heads = 0; + REQUIRE(static_cast(my_object.heads) == 0); + } + } +} + +SCENARIO("tap.tapecho~ puts a head's return where its ratio says, through the wrapper") { + ext_main(nullptr); + + GIVEN("one head at half a 200 ms span, wet-only, transport parked") { + test_wrapper an_instance; + tapecho& my_object = an_instance; + my_object.smooth = 0.0; + my_object.wow = atoms{0.0, 0.0}; + my_object.flutter = atoms{0.0, 0.0}; + my_object.regen = 0.0; + my_object.heads = 1; + my_object.ratios = atoms{0.5}; + my_object.pans = atoms{-1.0}; // hard left: the return lands on one bus only + my_object.span = 200.0; + my_object.mix = 100.0; + + WHEN("an impulse is processed") { + const long at = samples_for_ms(100.0); // 0.5 of a 200 ms span + std::vector left(static_cast(at) + 8, 0.0); + std::vector right(static_cast(at) + 8, 0.0); + for (size_t i = 0; i < left.size(); ++i) { + const auto y = my_object(i == 0 ? 1.0 : 0.0); + left[i] = y[0]; + right[i] = y[1]; + } + + THEN("the return is the recorded impulse itself, bit for bit, at 100 ms") { + REQUIRE(left[static_cast(at)] == 1.0); + } + THEN("and the far bus is bitwise silent throughout") { + bool silent = true; + for (double v : right) { + silent = silent && (v == 0.0); + } + REQUIRE(silent); + } + } + } +} + +SCENARIO("tap.tapecho~ mix 0 is bitwise dry on both outlets") { + ext_main(nullptr); + + GIVEN("mix 0 with the echo otherwise live") { + test_wrapper an_instance; + tapecho& my_object = an_instance; + my_object.smooth = 0.0; + my_object.mix = 0.0; + my_object.regen = 0.9; + + THEN("both outputs are the input, bitwise") { + bool exact = true; + for (int i = 0; i < 512; ++i) { + const double x = 0.001 * static_cast(i % 100) - 0.05; + const auto y = my_object(x); + exact = exact && (y[0] == x) && (y[1] == x); + } + REQUIRE(exact); + } + } +} + +SCENARIO("tap.tapecho~ clear is the eject button") { + ext_main(nullptr); + + GIVEN("a short span holding an impulse") { + test_wrapper an_instance; + tapecho& my_object = an_instance; + my_object.smooth = 0.0; + my_object.wow = atoms{0.0, 0.0}; + my_object.flutter = atoms{0.0, 0.0}; + my_object.heads = 1; + my_object.ratios = atoms{1.0}; + my_object.span = 150.0; + my_object.mix = 100.0; + my_object(1.0); + + WHEN("clear is sent before the return comes due") { + my_object.clear(); + + THEN("the tape is silent") { + bool silent = true; + for (long i = 0; i < samples_for_ms(300.0); ++i) { + const auto y = my_object(0.0); + silent = silent && (y[0] == 0.0) && (y[1] == 0.0); + } + REQUIRE(silent); + } + } + } +} diff --git a/source/projects/tap.touche_tilde/CMakeLists.txt b/source/projects/tap.touche_tilde/CMakeLists.txt new file mode 100644 index 00000000..3f19222d --- /dev/null +++ b/source/projects/tap.touche_tilde/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/touche.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.touche_tilde/tap.touche_tilde.cpp b/source/projects/tap.touche_tilde/tap.touche_tilde.cpp new file mode 100644 index 00000000..202a20fa --- /dev/null +++ b/source/projects/tap.touche_tilde/tap.touche_tilde.cpp @@ -0,0 +1,127 @@ +/// @file +/// @brief tap.touche~ — the Ondes Martenot intensity key as a gain law. +/// @details Wraps tap::tools::touche::key (taptools/touche.h). The *touche d'intensité* is +/// the pressure key the Ondes Martenot player's left hand rides — Messiaen called it +/// the instrument's greatest invention — and underneath the mechanism it is a gain +/// curve of unusual quality: 50 dB of range in about 4.5 mm of travel, shaped so a +/// player can place a crescendo where they want it. +/// +/// **The curve here is not modelled. It is a published measurement.** Quartier, +/// Meurisse, Colmars, Frelat & Vaiedelich, "Intensity Key of the Ondes Martenot: An +/// Early Mechanical Haptic Device" (Acta Acustica united with Acustica 101(2), +/// 421–428, 2015) measured force, displacement and sound simultaneously on +/// instrument No. 320 and published the boundaries of the six musical nuances across +/// the key's travel. This object interpolates those seven points with monotone cubic +/// segments and does not wander between them. +/// +/// @position spans the *physical* throw (0 to 9.5 mm), not the measured band inside +/// it — so roughly the first 45 % is silent. That dead zone is the key's own first +/// phase, bending before it reaches the powder bag, and it is why the instrument can +/// be played with such sharp attacks: the useful 50 dB is packed into the 4.5 mm +/// right after it. Drive @position from a signal in the right inlet for a pedal or a +/// sensor, or set it as an attribute. +/// +/// It is the first piece of the Ondes Martenot to land, and it is here on its own +/// because a measured expressive gain law is useful on anything — try it as a +/// swell pedal on a guitar, or as the VCA at the end of any chain. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::touche; + +class touche : public object, public sample_operator<2, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::key m_key; + + public: + MIN_DESCRIPTION{"The Ondes Martenot intensity key as a gain law. Not a model — the published " + "measurement (Quartier et al., Acta Acustica 2015): 50 dB of range across " + "4.5 mm of key travel, interpolated through all seven measured points. " + "position spans the physical 9.5 mm throw, so the bottom 45% is silent, " + "which is the key's own first phase before it reaches the powder bag. Drive " + "position from a signal in the right inlet for a pedal or sensor. Useful on " + "anything that wants an expressive swell, not just on an ondes. For " + "multichannel signals, wrap this object in an mc. operator."}; + MIN_TAGS{"effects"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.vca~, tap.adsr~, tap.elixir~, gain~"}; + + inlet<> m_in{this, "(signal) audio input"}; + inlet<> m_in_pos{this, "(signal) key position, 0 to 1 of the travel"}; + outlet<> m_out{this, "(signal) the input, scaled by the key", "signal"}; + + attribute position{this, "position", 0.0, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), 0.0, 1.0); + m_key.set_position(v); + return {v}; + }}, + description{"Key position, 0 to 1 over the physical 9.5 mm travel. Roughly the first " + "45% is silent — that is the key bending before it compresses the powder " + "bag, not a dead spot in the object. Ignored while a signal is connected " + "to the right inlet."}}; + + attribute mm{this, "mm", 0.0, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_key.set_position_mm(v); + return {v}; + }}, + description{"The same control in the published unit: millimetres of key travel. The " + "measured band runs 4.3 mm (the instrument's noise floor) to 8.8 mm (full " + "press)."}}; + + attribute mode{this, "mode", 0, setter{MIN_FUNCTION{ + const int v = (static_cast(args[0]) == 1) ? 1 : 0; + m_key.set_mode(v); + return {v}; + }}, + description{"Which measured column drives the gain: 0 displacement (the primary map, and " + "what the paper says the sound actually depends on), 1 finger force. The force " + "axis is far from uniform — six of the seven measured points sit below 1.4 N " + "and the seventh is at 9.6 N — so it is offered for completeness rather than " + "as a good controller."}}; + + attribute smooth{this, "smooth", kernel::k_default_smooth_ms, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_key.set_smooth_ms(v); + return {v}; + }}, + description{"Anti-zipper ramp time for attribute-driven position moves, in ms (0 = " + "instant). The signal inlet is not ramped — a control signal is assumed " + "smooth already."}}; + + message<> clear{this, "clear", "Return the key to rest (silent).", + MIN_FUNCTION{ + m_key.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Prepare the key for the current sample rate.", + MIN_FUNCTION{ + m_key.prepare(samplerate()); + return {}; + }}; + + touche(const atoms& args = {}) { m_key.prepare(samplerate()); } + + sample operator()(sample x, sample pos) { + // A signal on the right inlet drives the key directly; otherwise the attribute does. + if (m_in_pos.has_signal_connection()) { + return m_key.process(x, pos); + } + return m_key.process(x); + } +}; + +MIN_EXTERNAL(touche); diff --git a/source/projects/tap.touche_tilde/tap.touche_tilde_test.cpp b/source/projects/tap.touche_tilde/tap.touche_tilde_test.cpp new file mode 100644 index 00000000..5f92230a --- /dev/null +++ b/source/projects/tap.touche_tilde/tap.touche_tilde_test.cpp @@ -0,0 +1,103 @@ +/// @file +/// @brief Unit tests for tap.touche~ (the Ondes Martenot intensity key). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/touche_test.cpp) +/// carries the curve's fidelity to the published measurement. Here: the documented +/// defaults, the clamps, and the two behaviours a patcher would be surprised by if +/// they were wrong — a key at rest is exactly silent, and the bottom of the throw is +/// silent too (which looks like a bug until you know it is the instrument's). +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.touche_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.touche~ instantiates at rest") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + touche& my_object = an_instance; + + THEN("the key starts up, silent, driven by displacement") { + REQUIRE(static_cast(my_object.position) == 0.0); + REQUIRE(static_cast(my_object.mode) == 0); + } + } +} + +SCENARIO("tap.touche~ clamps position to the travel") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + touche& my_object = an_instance; + + THEN("position is a fraction of the throw") { + my_object.position = 4.0; + REQUIRE(static_cast(my_object.position) == 1.0); + my_object.position = -1.0; + REQUIRE(static_cast(my_object.position) == 0.0); + } + THEN("mode only accepts the two measured columns") { + my_object.mode = 7; + REQUIRE(static_cast(my_object.mode) == 0); + my_object.mode = 1; + REQUIRE(static_cast(my_object.mode) == 1); + } + } +} + +// The two things a patcher would otherwise file as bugs. +SCENARIO("tap.touche~ is exactly silent at rest, and through the dead zone") { + ext_main(nullptr); + + GIVEN("a key at rest") { + test_wrapper an_instance; + touche& my_object = an_instance; + my_object.smooth = 0.0; + my_object.position = 0.0; + + THEN("nothing comes out at all") { + bool silent = true; + for (int i = 0; i < 4000; ++i) { + silent = silent && (my_object(0.7, 0.0) == 0.0); + } + REQUIRE(silent); + } + } + + GIVEN("the key pressed part-way, but not as far as the powder bag") { + test_wrapper an_instance; + touche& my_object = an_instance; + my_object.smooth = 0.0; + + THEN("everything below 4.3 mm is still silent — the key is only bending") { + for (double travel : {0.0, 1.0, 2.5, 4.0}) { + my_object.mm = travel; + INFO(travel << " mm"); + REQUIRE(my_object(0.7, 0.0) == 0.0); + } + } + THEN("and just past it, sound arrives") { + my_object.mm = 4.5; + REQUIRE(std::abs(my_object(0.7, 0.0)) > 0.0); + } + } +} + +SCENARIO("tap.touche~ reaches unity at full press") { + ext_main(nullptr); + + GIVEN("the key fully pressed") { + test_wrapper an_instance; + touche& my_object = an_instance; + my_object.smooth = 0.0; + my_object.position = 1.0; + + THEN("the input passes at unity") { + REQUIRE(std::abs(my_object(0.5, 0.0) - 0.5) < 1e-9); + } + } +} diff --git a/source/projects/tap.triode_tilde/CMakeLists.txt b/source/projects/tap.triode_tilde/CMakeLists.txt new file mode 100644 index 00000000..43de8402 --- /dev/null +++ b/source/projects/tap.triode_tilde/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/ondes.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.triode_tilde/tap.triode_tilde.cpp b/source/projects/tap.triode_tilde/tap.triode_tilde.cpp new file mode 100644 index 00000000..7c531831 --- /dev/null +++ b/source/projects/tap.triode_tilde/tap.triode_tilde.cpp @@ -0,0 +1,142 @@ +/// @file +/// @brief tap.triode~ — one triode gain stage, from a published tube model. +/// @details Wraps tap::tools::ondes::triode (taptools/ondes.h). A common-cathode valve stage, +/// solved on its load line: conditioning highpass, drive into the grid, the tube's own +/// transfer curve, equalization lowpass. The architecture is Yeh, Abel & Smith's +/// DAFx-07 simplified cascade, the same one tap.fuzz~ uses — but where that +/// object reaches for a tanh, this one solves an actual valve. +/// +/// **Almost nothing here is voiced by ear.** The tube is the enhanced Norman Koren +/// model (Koren, Glass Audio 8(5), 1996; grid-current branch from Cohen & Hélie, AES +/// 129, 2010), and the parameter sets are fitted to the valves in ondes Martenot +/// No. 169 by Najnudel, Hélie, Roze & Boutin (IEEE/ACM TASLP 28, 2020, Table II), +/// along with the supply voltage, cathode resistor and plate load of every stage. +/// @tube picks the valve; @stage picks one of the three published operating points. +/// +/// Two properties worth knowing before patching it. The stage **inverts**, because a +/// real common-cathode stage does — and that is not cosmetic here, since the valve's +/// asymmetry acts on whichever side of the waveform reaches its grid. And it is +/// **strongly asymmetric**: at the demodulator's operating point, equal grid swings +/// either way give plate swings in a 2.17 : 1 ratio, which is where a triode's even +/// harmonics come from. +/// +/// @drive is normalized out of the level, so turning it up changes the distortion and +/// not the volume. @tube and @stage rebuild the stage's curve table, so they are +/// modes to set rather than knobs to sweep at audio rate. +/// +/// All DSP lives in the Min-free kernel; this file is only the Max plumbing. For +/// multichannel use, wrap the object in an mc. operator. +/// @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::ondes; + +class triode : public object, public sample_operator<1, 1> { + private: + // Constructed before the attributes below so their defaults can forward into it. + kernel::triode m_stage; + + static kernel::operating_point op_for(int stage) { + switch (stage) { + case 1: + return kernel::k_op_preamp; + case 2: + return kernel::k_op_power; + default: + return kernel::k_op_demod; + } + } + + public: + MIN_DESCRIPTION{"One triode gain stage, solved on its load line from a published tube model. " + "The enhanced Norman Koren law with parameter sets fitted to the valves in " + "ondes Martenot No. 169 (6F5, 6C5, 2A3) and their published operating points " + "— supply, cathode resistor and plate load. The stage inverts and is strongly " + "asymmetric, which is where a triode's even harmonics come from. drive is " + "normalized out of the level, so it changes the distortion rather than the " + "volume. For multichannel signals, wrap this object in an mc. operator."}; + MIN_TAGS{"effects"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.ondes~, tap.fuzz~, tap.overdrive~, tap.vca~"}; + + inlet<> m_in{this, "(signal) audio input — the grid"}; + outlet<> m_out{this, "(signal) the plate, inverted and normalized", "signal"}; + + attribute tube{this, "tube", kernel::tube_6c5, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, kernel::k_num_tubes - 1); + m_stage.set_tube(v); + return {v}; + }}, + description{"Which valve: 0 the 6F5 (the oscillators'), 1 the 6C5 (the demodulator's and " + "preamplifier's), 2 the 2A3 (the power amplifier's). A mode, not a fader — it " + "rebuilds the stage's curve table, which is sub-millisecond but not something " + "to automate at audio rate."}}; + + attribute stage{this, "stage", 0, setter{MIN_FUNCTION{ + const int v = std::clamp(static_cast(args[0]), 0, 2); + m_stage.set_operating_point(op_for(v)); + return {v}; + }}, + description{"Which published operating point: 0 demodulator (100 V supply, 1 k cathode, " + "4 k plate load), 1 preamplifier (180 V), 2 power amplifier (230 V, 750 R, " + "1.5 k). Also a mode. The pairings the instrument uses are tube 1 with stages " + "0 and 1, and tube 2 with stage 2 — but any combination will solve, and some " + "of the wrong ones are interesting."}}; + + attribute drive{this, "drive", kernel::k_default_drive_v, setter{MIN_FUNCTION{ + const double v = std::clamp(static_cast(args[0]), kernel::k_min_drive_v, + kernel::k_max_drive_v); + m_stage.set_drive(v); + return {v}; + }}, + description{"Peak grid volts for a full-scale input. This is the distortion control, " + "and it is normalized out of the level: the small-signal gain stays at " + "unity whatever you set, so turning it up gets dirtier rather than " + "louder."}}; + + attribute highpass{this, "highpass", 20.0, setter{MIN_FUNCTION{ + const double v = std::max(0.0, static_cast(args[0])); + m_stage.set_corners(v, static_cast(lowpass)); + return {v}; + }}, + description{"Conditioning highpass in Hz — the coupling capacitor and grid leak. " + "The first filter of the DAFx-07 cascade."}}; + + attribute lowpass{this, "lowpass", 12000.0, setter{MIN_FUNCTION{ + const double v = std::max(1.0, static_cast(args[0])); + m_stage.set_corners(static_cast(highpass), v); + return {v}; + }}, + description{"Equalization lowpass in Hz — Miller capacitance and the plate load's " + "own pole. The last filter of the cascade."}}; + + message<> clear{this, "clear", "Reset the stage's filters. Parameters are kept.", + MIN_FUNCTION{ + m_stage.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Prepare the stage for the current sample rate.", + MIN_FUNCTION{ + m_stage.prepare(samplerate()); + m_stage.set_tube(static_cast(tube)); + m_stage.set_operating_point(op_for(static_cast(stage))); + m_stage.set_drive(static_cast(drive)); + m_stage.set_corners(static_cast(highpass), static_cast(lowpass)); + return {}; + }}; + + triode(const atoms& args = {}) { m_stage.prepare(samplerate()); } + + sample operator()(sample x) { return m_stage.process(x); } +}; + +MIN_EXTERNAL(triode); diff --git a/source/projects/tap.triode_tilde/tap.triode_tilde_test.cpp b/source/projects/tap.triode_tilde/tap.triode_tilde_test.cpp new file mode 100644 index 00000000..2c1c06bf --- /dev/null +++ b/source/projects/tap.triode_tilde/tap.triode_tilde_test.cpp @@ -0,0 +1,112 @@ +/// @file +/// @brief Unit tests for tap.triode~ (one triode stage from a published tube model). +/// @details Wrapper-level pins only — the kernel's own suite (taptools tests/ondes_test.cpp) +/// carries the tube model against its datasheet and the load-line solve. Here: the +/// documented defaults, the clamps, and the two properties a patcher would file as +/// bugs if they were wrong — the stage inverts, and turning drive up does not make it +/// louder. +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.triode_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.triode~ instantiates on the demodulator's published valve and operating point") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + triode& my_object = an_instance; + + THEN("it is the 6C5 at the demodulator's point, as the header says") { + REQUIRE(static_cast(my_object.tube) == kernel::tube_6c5); + REQUIRE(static_cast(my_object.stage) == 0); + REQUIRE(static_cast(my_object.drive) == kernel::k_default_drive_v); + } + } +} + +SCENARIO("tap.triode~ only accepts the valves and operating points that exist") { + ext_main(nullptr); + + GIVEN("a default instance") { + test_wrapper an_instance; + triode& my_object = an_instance; + + THEN("the tube index stays inside the published set") { + my_object.tube = 9; + REQUIRE(static_cast(my_object.tube) == kernel::k_num_tubes - 1); + my_object.tube = -2; + REQUIRE(static_cast(my_object.tube) == 0); + } + THEN("so does the stage") { + my_object.stage = 9; + REQUIRE(static_cast(my_object.stage) == 2); + my_object.stage = -1; + REQUIRE(static_cast(my_object.stage) == 0); + } + THEN("drive stays inside the grid swing the curve table covers") { + my_object.drive = 1e6; + REQUIRE(static_cast(my_object.drive) == kernel::k_max_drive_v); + my_object.drive = 0.0; + REQUIRE(static_cast(my_object.drive) == kernel::k_min_drive_v); + } + } +} + +// The two things a patcher would otherwise file as bugs. +SCENARIO("tap.triode~ inverts, and drive does not make it louder") { + ext_main(nullptr); + + GIVEN("a stage with its filters opened up so only the curve is in play") { + test_wrapper an_instance; + triode& my_object = an_instance; + my_object.highpass = 0.1; + my_object.lowpass = 20000.0; + + THEN("a small positive input comes out negative") { + // Settle the conditioning highpass on silence first, so the DC step is not measured. + for (int i = 0; i < 200; ++i) { + my_object(0.0); + } + const sample y = my_object(0.01); + INFO("0.01 in gives " << y); + REQUIRE(y < 0.0); + } + + THEN("the small-signal gain is unity at every drive setting") { + for (double d : {0.1, 1.0, 8.0}) { + my_object.drive = d; + my_object.clear(); + for (int i = 0; i < 400; ++i) { + my_object(0.0); + } + const double y = static_cast(my_object(0.001)); + INFO("drive " << d << ": 0.001 in gives " << y); + REQUIRE(std::abs(y / -0.001 - 1.0) < 0.1); + } + } + } +} + +SCENARIO("tap.triode~ is quiet on silence whichever valve it is running") { + ext_main(nullptr); + + GIVEN("each published valve in turn") { + test_wrapper an_instance; + triode& my_object = an_instance; + + for (int t = 0; t < kernel::k_num_tubes; ++t) { + my_object.tube = t; + my_object.clear(); + double worst = 0.0; + for (int i = 0; i < 4800; ++i) { + worst = std::max(worst, std::abs(static_cast(my_object(0.0)))); + } + INFO("tube " << t << ": worst output on silence " << worst); + REQUIRE(worst < 1e-6); + } + } +} diff --git a/submodules/taptools b/submodules/taptools index d4cf28ab..015e1e98 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit d4cf28ab00a13f221d4e3acfd0067f61be4403a2 +Subproject commit 015e1e98354f645d604916c44e5bc95bfcdc1d95