diff --git a/system/public/aesthetic.computer/disks/glibo.mjs b/system/public/aesthetic.computer/disks/glibo.mjs new file mode 100644 index 0000000000..4eb77dfd34 --- /dev/null +++ b/system/public/aesthetic.computer/disks/glibo.mjs @@ -0,0 +1,238 @@ +// glibo, 26.07.12 +// Wave-interference ripple tank × marimba/soft-mallet — a thin wrapper over +// lib/pads.mjs (the shared pad engine: UTC-clock beat grid, `params[0]` rate +// override e.g. `glibo 0.5`, the tap/XY "pump", audio polling). This file only +// describes what makes glibo glibo: its score, its soft-mallet voices, and its +// per-pixel interference paint. +// +// ALLEGORY: each note DROPS a ripple SOURCE onto a still pond — pitch → x +// position AND wavelength (high notes = tight fast ripples on the right, low = +// long slow swells on the left). Concentric waves spread and INTERFERE, so a +// chord is a beautiful moiré where its sources overlap: you SEE the harmony. +// Bass = one big slow swell source in the middle. The beat is the ripple +// EMISSION. Interference is summed per-pixel across a handful of live sources, +// strided by ctx.quality to hold 60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A gentle marimba figure in F pentatonic (warm, non-clashing → the moiré stays +// pretty). Chord steps stack two sources at once so interference reads. +const MEL = [ + "f3", "a3", "c4", "a3", "d4", "c4", "a3", "f3", + "g3", "bb3", "d4", "bb3", "e4", "d4", "bb3", "g3", +]; +const CHORD_STEPS = new Set([2, 4, 10, 12]); // steps that stack a 2nd source +const BASS = ["f2", "f2", "bb1", "bb1", "c2", "c2", "g1", "g1"]; // half-time swell + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(b?)(\d)$/.exec(n); + if (!m) return 48; + return (parseInt(m[3], 10) + 1) * 12 + NOTE_SEMI[m[1]] + (m[2] ? -1 : 0); +} +const MEL_PITCHES = MEL.map(notePitch); +const PITCH_MIN = Math.min(...MEL_PITCHES); +const PITCH_MAX = Math.max(...MEL_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// --- glibo-specific pond state (engine owns pump/bursts/rhythm) -------------- +// Each source: { x, y (0..1), k (wavenumber, from wavelength), age (seconds), +// life (0..1 for color/kill), amp, hue }. Concentric wave = amp * sin(k*r - +// speed*age) * envelope(r, age) — summed per pixel for interference moiré. +let sources = []; +const SPEED = 4.2; // ripple travel speed (radians of phase per second-ish) +let bassSwell = 0; // central slow swell strength + +const MAX_SOURCES = 7; // hard cap on summed sources per pixel (perf) + +function dropSource(xn, yn, pn, amp, hue) { + sources.push({ + x: xn, + y: yn, + // wavelength ∝ pitch: high notes → short wavelength → high wavenumber. + k: 0.05 + pn * 0.16, + age: 0, + life: 1, + amp, + hue, + }); + if (sources.length > MAX_SOURCES) sources = sources.slice(-MAX_SOURCES); +} + +const CONFIG = { + bpm: 96, // marimba lilt + steps: MEL.length, + drawBursts: false, // glibo draws its own ripple burst rings in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.28, feedback: 0.5 }); + }, + + // A new UTC beat crossed — strike the mallet + drop the ripple source. + onBeat({ idx, synth }) { + const s = ((idx % MEL.length) + MEL.length) % MEL.length; + const note = MEL[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const xn = 0.12 + pn * 0.76; // pitch → x position (low left, high right) + const pan = xn * 2 - 1; + + // Warm soft-mallet marimba: bell body + a short-decay pluck attack. + voices.bell(synth, note, { beats: 0.7, decay: 0.55, volume: 0.24, pan }); + voices.pluck(synth, note, { + beats: 0.35, + decay: 0.3, + volume: 0.18, + pan, + }); + + // ALLEGORY: this note drops a ripple source (x ∝ pitch, k ∝ wavelength). + dropSource(xn, 0.32 + pn * 0.36, pn, 1, 20 + pn * 300); + + // Chord step: stack a 5th-above source right away → interference moiré. + if (CHORD_STEPS.has(s)) { + voices.bell(synth, note, { beats: 0.6, volume: 0.14, pan: -pan }); + dropSource(xn * 0.5 + 0.25, 0.5, Math.min(1, pn + 0.25), 0.8, 60 + pn * 260); + } + + // Sub swell root on the half-beat — a big slow source you feel + SEE. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.8, decay: 0.5, volume: 0.42 }); + dropSource(0.5, 0.5, 0.0, 1.3, 200); // big long-wavelength swell + bassSwell = 1; + } + + voices.hat(synth, { tone: 6000, beats: 0.1, volume: 0.1 }); + }, + + onSim() { + bassSwell *= 0.92; + const dt = 1 / 60; + for (const src of sources) { + src.age += dt; + src.life -= 0.01; // ~1.6s visible life + } + sources = sources.filter((src) => src.life > 0); + }, + + // Tap = drop a ripple source + strike a mallet at the tap point. + onTap({ x, y, synth }) { + const pn = 1 - y; // higher tap = higher pitch + const note = ["f", "g", "a", "c", "d"][Math.floor(x * 5) % 5] + (2 + Math.floor(pn * 3)); + voices.bell(synth, note, { beats: 0.7, decay: 0.5, volume: 0.4, pan: x * 2 - 1 }); + voices.pluck(synth, note, { beats: 0.4, decay: 0.3, volume: 0.3, pan: x * 2 - 1 }); + dropSource(x, y, pn, 1.5, x * 360); // strong bright source at the tap + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + + const bass = band("subBass"); + const energy = Math.min(2, bassSwell * 0.5 + bass * 1.0 + amp * 0.5 + pump * 0.5); + + // —— DIRECT PER-PIXEL INTERFERENCE (native screen.pixels, no draw calls) —— + // Cost is a handful of sin/sqrt per pixel (strided), written straight into + // the RGBA byte buffer — the same fast path molten uses. The summed field + // is a PURE FUNCTION of the live sources: each is a concentric wave + // sin(k·r − speed·age) radially damped; overlaps INTERFERE → moiré = chord. + const pix = screen.pixels; + const stride = quality < 0.6 ? 3 : quality < 0.85 ? 2 : 1; + const src = sources.slice(-MAX_SOURCES); + const n = src.length; + + // Precompute source pixel centers + rolling phase (speed·age) + amp. + const sx = new Float32Array(n); + const sy = new Float32Array(n); + const sk = new Float32Array(n); + const sph = new Float32Array(n); + const sa = new Float32Array(n); + const shue = new Float32Array(n); + for (let i = 0; i < n; i++) { + sx[i] = src[i].x * w; + sy[i] = src[i].y * h; + sk[i] = src[i].k; + sph[i] = SPEED * src[i].age; + sa[i] = src[i].amp * src[i].life; + shue[i] = ((src[i].hue % 360) + 360) % 360; + } + const globalAmp = 1 + energy * 0.3; + + for (let y = 0; y < h; y += stride) { + const rowBase = y * w; + for (let x = 0; x < w; x += stride) { + // Deep-water base color for every cell (still pond). + let cr = 4, cg = 10, cb = 26; + if (n > 0) { + let sum = 0; + let dom = 0; + let domW = -1; + for (let i = 0; i < n; i++) { + const dx = x - sx[i]; + const dy = y - sy[i]; + const r = Math.sqrt(dx * dx + dy * dy) + 0.001; + const damp = sa[i] / (1 + r * 0.006); + sum += Math.sin(sk[i] * r - sph[i]) * damp; + if (damp > domW) { + domW = damp; + dom = i; + } + } + let lum = 0.5 + sum * globalAmp * 0.5; + if (!(lum >= 0)) lum = 0; // NaN guard + else if (lum > 1) lum = 1; + if (lum >= 0.06) { + const [r0, g0, b0] = num.hslToRgb(shue[dom], 55 + lum * 35, 18 + lum * 62); + cr = r0; cg = g0; cb = b0; + } + } + // Fill the stride×stride block from this computed pixel. + const yEnd = y + stride < h ? y + stride : h; + const xEnd = x + stride < w ? x + stride : w; + for (let by = y; by < yEnd; by++) { + const bBase = by * w; + for (let bx = x; bx < xEnd; bx++) { + const di = (bBase + bx) * 4; + pix[di] = cr; + pix[di + 1] = cg; + pix[di + 2] = cb; + pix[di + 3] = 255; + } + } + } + } + + // Ripple emission ring at each fresh source (the "drop" splash). + for (const so of src) { + if (so.age > 0.9) continue; + const rr = so.age * SPEED * (10 / Math.max(0.05, so.k)); + const hue = ((so.hue % 360) + 360) % 360; + const [rc, gc, bc] = num.hslToRgb(hue, 90, 62); + const a = Math.round(160 * (1 - so.age / 0.9) * so.life); + ink(rc, gc, bc, a).circle(so.x * w, so.y * h, rr, false, 2); + ink(255, 255, 255, a).circle(so.x * w, so.y * h, 3 + so.amp * 3, true); + } + + // Central bass swell glow. + if (bassSwell > 0.02 || bass > 0.05) { + const bloom = Math.max(bassSwell, bass * 1.1); + const bR = Math.min(w, h) * (0.06 + bloom * 0.22); + for (let i = 3; i > 0; i--) + ink(60, 120, 220, 14 * bloom).circle(w / 2, h / 2, bR * (i / 3), true); + } + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/nibbo.mjs b/system/public/aesthetic.computer/disks/nibbo.mjs new file mode 100644 index 0000000000..6dfaed0dad --- /dev/null +++ b/system/public/aesthetic.computer/disks/nibbo.mjs @@ -0,0 +1,219 @@ +// nibbo, 26.07.12 +// Circle-packing gamelan pad — a thin wrapper over lib/pads.mjs (the shared pad +// engine: UTC-clock beat grid, `params[0]` rate override e.g. `nibbo 0.5`, the +// tap/XY "pump", audio polling). This file only describes what makes nibbo nibbo: +// its pentatonic mallet score, its layered inharmonic bell voices, and its +// jewel-like circle packing. +// ALLEGORY: every note SPAWNS a circle that grows into an empty gap until it +// touches a neighbor or edge — pitch sets its final size + hue (high notes = small +// bright circles). The BEAT shimmers a pulse across all circles. BASS breathes a +// gentle global scale. Read the melody as the packing: the frame tessellates. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score (pelog-ish pentatonic mallet run) -------------------------------- +const MEL = [ + "e3", "g3", "a3", "b3", "d4", "b3", "a3", "g3", + "e4", "d4", "b3", "a3", "g3", "a3", "b3", "d4", +]; +const BASS = ["e2", "e2", "a1", "a1", "d2", "d2", "g1", "g1"]; // half-time root + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(\d)$/.exec(n); + if (!m) return 48; + return (parseInt(m[2], 10) + 1) * 12 + NOTE_SEMI[m[1]]; +} +const MEL_PITCHES = MEL.map(notePitch); +const PITCH_MIN = Math.min(...MEL_PITCHES); +const PITCH_MAX = Math.max(...MEL_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); +// midi → hz (A4=440, midi 69), so we can DETUNE a partial for gamelan shimmer. +const noteHz = (n) => 440 * Math.pow(2, (notePitch(n) - 69) / 12); + +// --- nibbo-specific visual state (engine owns pump/bursts/rhythm) ----------- +// A packed circle: normalized center (0..1), grown radius, target radius, hue, +// brightness, spawn-in progress, and life (loop-fade of the oldest). +let circles = []; // { nx, ny, r, target, hue, bright, grow, life } +let shimmer = 0; // beat pulse across all circles +let breathe = 1; // bass-driven global breathing scale +const BASE_COUNT = 46; // max live circles at quality 1 (scaled by ctx.quality) + +// Try to seat a new circle at an empty spot: sample random points, keep the one +// whose gap to the nearest neighbor is largest, then set its target so it grows +// to just touch that neighbor (or the frame). Simple, cheap, satisfying. +function seatCircle(targetR, hue, bright) { + let best = null; + let bestGap = -1; + for (let t = 0; t < 26; t++) { + const nx = 0.05 + Math.random() * 0.9; + const ny = 0.05 + Math.random() * 0.9; + // gap in normalized units to nearest existing circle surface + to edges. + let gap = Math.min(nx, 1 - nx, ny, 1 - ny); + for (const c of circles) { + const d = Math.hypot(nx - c.nx, ny - c.ny) - c.target; + if (d < gap) gap = d; + } + if (gap > bestGap) { + bestGap = gap; + best = { nx, ny }; + } + } + if (!best) return; + // Final radius = pitch-desired size, capped so it just kisses its neighbor + // (tiny margin so jewels touch instead of overlapping — tessellation reads). + const target = Math.max(0.012, Math.min(targetR, Math.max(0.012, bestGap - 0.004))); + circles.push({ + nx: best.nx, + ny: best.ny, + r: 0.002, + target, + hue, + bright, + grow: 0.5, + life: 1, + }); +} + +const CONFIG = { + bpm: 116, + steps: MEL.length, + drawBursts: false, // nibbo draws its own tap circles in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.34, feedback: 0.62 }); + }, + + // A new UTC beat crossed — fire the mallet + spawn the matching circle. + onBeat({ idx, synth }) { + const s = ((idx % MEL.length) + MEL.length) % MEL.length; + const note = MEL[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const pan = Math.sin((s / MEL.length) * Math.PI * 2) * 0.6; + + // Gamelan mallet: layered inharmonic bell (slightly detuned) + soft pluck + // body — bright, metallic, shimmering. High notes get an octave sparkle. + voices.bell(synth, note, { beats: 1.3, volume: 0.34, pan }); + // Inharmonic detuned partial (~+2.7% ≈ +46¢) — the shimmering "gong" + // beating that makes bells read as bronze gamelan, not clean sine. + synth({ tone: noteHz(note) * 1.027, type: "sine", beats: 0.9, + attack: 0.003, decay: 0.85, volume: 0.12, pan: -pan * 0.5 }); + voices.pluck(synth, note, { beats: 0.5, decay: 0.4, volume: 0.22, pan }); + if (pn > 0.6) + voices.bell(synth, note, { beats: 0.6, volume: 0.14 * pn, pan: -pan }); + + // ALLEGORY: this note packs a new circle — pitch → size (high = small) + + // hue (low = warm amber, high = cool cyan/violet) + brightness. + const targetR = 0.16 - pn * 0.11; // high notes = smaller jewels + const hue = 40 + pn * 260; // amber → cyan/violet + seatCircle(targetR, hue, 0.4 + pn * 0.6); + + // Sub-bass root on the half-beat — the downbeat you feel + SEE (breathe). + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.7, decay: 0.55, volume: 0.5 }); + breathe = 1.05; + } + + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.1 }); + shimmer = 1; + }, + + onSim({ quality }) { + shimmer *= 0.9; + breathe += (1 - breathe) * 0.06; // ease back to rest + for (const c of circles) { + c.r += (c.target - c.r) * 0.12 * c.grow; // grow to fit the gap + c.life -= 0.0022; // slow loop fade of the oldest + } + circles = circles.filter((c) => c.life > 0); + // Cap live count by adaptive quality — fade oldest first (vector = cheap, + // but keep counts sane to hold 60fps). + const max = Math.max(8, Math.round(BASE_COUNT * (quality ?? 1))); + if (circles.length > max) circles = circles.slice(circles.length - max); + }, + + // Tap = drop a growing circle at the tap point + a bell strike (engine + // already bumped pump; X→hue/pan, Y→size/brightness). + onTap({ x, y, synth }) { + const targetR = 0.06 + (1 - y) * 0.12; + // Seat directly at the tap: shrink target so it kisses its nearest neighbor. + let gap = Math.min(x, 1 - x, y, 1 - y); + for (const c of circles) { + const d = Math.hypot(x - c.nx, y - c.ny) - c.target; + if (d < gap) gap = d; + } + circles.push({ + nx: x, + ny: y, + r: 0.002, + target: Math.max(0.02, Math.min(targetR, Math.max(0.02, gap))), + hue: x * 360, + bright: 0.6 + (1 - y) * 0.4, + grow: 1.6, // taps grow faster — a satisfying pop + life: 1.2, + }); + const note = ["e", "g", "a", "b", "d"][Math.floor(x * 5)] + (2 + Math.floor((1 - y) * 3)); + voices.bell(synth, note, { beats: 0.9, volume: 0.4, pan: x * 2 - 1 }); + voices.pluck(synth, note, { beats: 0.4, volume: 0.3, pan: x * 2 - 1 }); + shimmer = 1; + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band } = s; + const { width: w, height: h } = screen; + + const bass = band("subBass"); + const glob = breathe * (1 + bass * 0.06 + pump * 0.02); // global breathing + const shim = Math.min(1.4, shimmer * 0.8 + amp * 0.5 + pump * 0.3); + + // Dark jewel-box backdrop (subtle veil so packing accretes, not smears). + ink(6, 8, 16, 255).box(0, 0, w, h); + + const px = (nx) => (nx - 0.5) * glob + 0.5; // breathe about center + const py = (ny) => (ny - 0.5) * glob + 0.5; + const scale = Math.min(w, h); + + // Packed circles, oldest first so new jewels sit on top. + for (const c of circles) { + const cx = px(c.nx) * w; + const cy = py(c.ny) * h; + const rad = c.r * scale * glob; + if (rad < 0.6) continue; + const light = Math.min(95, 45 + c.bright * 22 + shim * 18 * c.life); + const [r0, g0, b0] = num.hslToRgb(((c.hue % 360) + 360) % 360, 82, light); + const a = Math.max(0, Math.min(255, Math.round(210 * c.life))); + // Body fill. + ink(r0, g0, b0, a).circle(cx, cy, rad, true); + // Facet rim (brighter ring = the "touch" edge that makes packing read). + const [rr, rg, rb] = num.hslToRgb(((c.hue % 360) + 360) % 360, 90, Math.min(100, light + 22)); + ink(rr, rg, rb, Math.round(a * (0.7 + shim * 0.3))).circle(cx, cy, rad, false, 2); + // Specular highlight (offset white dot) — jewel glint, brighter on beat. + const hlR = rad * 0.28; + if (hlR > 0.8) { + ink(255, 255, 255, Math.round((90 + shim * 120) * c.life)) + .circle(cx - rad * 0.3, cy - rad * 0.3, hlR, true); + } + } + + // Bass breathing ghost ring at center — the felt downbeat, seen. + if (bass > 0.04 || breathe > 1.005) { + const bloom = Math.max(bass, (breathe - 1) * 6); + const bR = scale * (0.06 + bloom * 0.16); + ink(120, 90, 210, Math.round(50 * bloom)).circle(w / 2, h / 2, bR, false, 2); + } + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/plimo.mjs b/system/public/aesthetic.computer/disks/plimo.mjs new file mode 100644 index 0000000000..3e971e5f82 --- /dev/null +++ b/system/public/aesthetic.computer/disks/plimo.mjs @@ -0,0 +1,232 @@ +// plimo, 26.07.12 +// Voronoi / cellular-shatter pad — a thin wrapper over lib/pads.mjs (the shared +// pad engine: UTC-clock beat grid, `params[0]` rate override e.g. `plimo 0.5`, +// the tap/XY "pump", audio polling, adaptive quality). This file only describes +// what makes plimo plimo: its score, its glassy FM voices, and its stained-glass +// Voronoi paint. +// +// ALLEGORY: the plane is a mosaic of Voronoi cells (nearest of N sites per +// pixel). Every audible note LIGHTS / CRACKS a cell — pitch → cell hue, high +// notes → small bright cells. The beat RE-SEEDS the sites so the mosaic breathes +// and drifts. BASS = the crack lines (cell borders) glowing. Tap SHATTERS a new +// bright cell at the touch point + a glassy stab. The visual IS the score. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// Glassy D-minor-ish stab pattern; high steps = small bright cells. +const ARP = [ + "d3", "a3", "d4", "f4", "a4", "f4", "d4", "a3", + "c4", "g4", "c5", "e5", "g5", "e5", "c5", "g4", +]; +const BASS = ["d2", "d2", "bb1", "bb1", "g1", "g1", "a1", "a1"]; // half-time root +const N_SITES = 10; // Voronoi site count — keep small so per-pixel scan is cheap + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(b?)(\d)$/.exec(n); + if (!m) return 48; + const flat = m[2] === "b" ? -1 : 0; + return (parseInt(m[3], 10) + 1) * 12 + NOTE_SEMI[m[1]] + flat; +} +const ARP_PITCHES = ARP.map(notePitch); +const PITCH_MIN = Math.min(...ARP_PITCHES); +const PITCH_MAX = Math.max(...ARP_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// --- plimo-specific visual state (engine owns pump/bursts/rhythm) ------------ +// Each site is a Voronoi seed with a live cell color/energy. Notes light a cell, +// beats nudge the seeds (re-seed drift). A tap can spawn an extra transient cell. +let sites = []; // { x, y, tx, ty, hue, lit, weight } — normalized 0..1 coords +let crackGlow = 0; // bass-driven border glow, 0..1 +let beatFlash = 0; // whole-mosaic re-seed flash, kicked each beat +let seeded = false; + +function seedSites() { + sites = []; + for (let i = 0; i < N_SITES; i++) { + const x = Math.random(), y = Math.random(); + sites.push({ x, y, tx: x, ty: y, hue: (i / N_SITES) * 360, lit: 0.2, weight: 1 }); + } + seeded = true; +} + +const CONFIG = { + bpm: 120, + steps: ARP.length, + drawBursts: false, // plimo shatters its own cells on tap in onPaint/onTap + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.3, feedback: 0.55 }); + seedSites(); + }, + + // A new UTC beat crossed — fire the score + light the matching cell. + onBeat({ idx, synth }) { + if (!seeded) seedSites(); + const s = ((idx % ARP.length) + ARP.length) % ARP.length; + const note = ARP[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const pan = Math.sin((s / ARP.length) * Math.PI * 2) * 0.7; + + // Glassy FM stab: bell + flute + detuned sine layer. + voices.bell(synth, note, { beats: 0.7, volume: 0.34, pan }); + voices.flute(synth, note, { beats: 0.5, volume: 0.14, pan: -pan * 0.5 }); + synth({ tone: note, type: "sine", beats: 0.4, attack: 0.003, decay: 0.7, + volume: 0.16, pan: pan * 0.4 }); + synth({ tone: note, type: "sine", beats: 0.4, attack: 0.003, decay: 0.7, + volume: 0.11, pan: -pan * 0.4 }); // detuned-feel doubling + + // ALLEGORY: this note lights a cell — pitch → hue, high notes → small & + // bright (small weight = smaller Voronoi cell). + const site = sites[s % sites.length]; + if (site) { + site.hue = 200 + pn * 200; // cyan→violet glass palette + site.lit = 1; + site.weight = 1.35 - pn * 0.7; // high notes = tighter cell + // nudge toward a fresh drift target (the mosaic breathes) + site.tx = Math.min(1, Math.max(0, site.x + (Math.random() - 0.5) * 0.18)); + site.ty = Math.min(1, Math.max(0, site.y + (Math.random() - 0.5) * 0.18)); + } + + // Sub-bass root on the half-beat — the downbeat you feel + SEE as cracks. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.6, decay: 0.5, volume: 0.5 }); + crackGlow = 1; + } + + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.13 }); + beatFlash = 1; + }, + + onSim() { + crackGlow *= 0.9; + beatFlash *= 0.9; + for (const st of sites) { + st.lit *= 0.955; + // drift each seed toward its target (re-seed animation) + st.x += (st.tx - st.x) * 0.06; + st.y += (st.ty - st.y) * 0.06; + st.weight += (1 - st.weight) * 0.02; // relax back toward even + } + // expire transient tap-spawned cells (weight tagged via .transient) + for (const st of sites) if (st.transient) st.transientLife -= 0.02; + sites = sites.filter((st) => !st.transient || st.transientLife > 0); + }, + + // Tap = SHATTER a new bright cell at the touch point + a glassy stab. + onTap({ x, y, synth }) { + const note = ["d", "f", "a", "c", "e"][Math.floor(x * 5)] + + (3 + Math.floor((1 - y) * 3)); + voices.bell(synth, note, { beats: 0.5, volume: 0.4, pan: x * 2 - 1 }); + voices.flute(synth, note, { beats: 0.4, volume: 0.18, pan: 1 - x * 2 }); + synth({ tone: note, type: "sine", beats: 0.35, attack: 0.002, decay: 0.6, + volume: 0.2, pan: x * 2 - 1 }); + + // A fresh, tight, very bright cell cracks open right where you touched. + sites.push({ + x, y, tx: x, ty: y, + hue: 40 + x * 300, lit: 1.4, weight: 0.55, + transient: true, transientLife: 1.2, + }); + if (sites.length > N_SITES + 8) sites.shift(); + crackGlow = Math.min(1, crackGlow + 0.5); + }, + + onPaint(api, s) { + const { ink, screen, num, blur } = api; + const { pump, beatProgress, band, amp, quality } = s; + const { width: w, height: h } = screen; + if (!seeded || sites.length === 0) seedSites(); + + const bass = band("subBass"); + const air = band("air"); + const energy = Math.min(2, beatFlash * 0.5 + bass * 1.2 + amp * 0.6 + pump * 0.5); + + // Dark glass backdrop veil (slight trail). + ink(4, 6, 14, 200).box(0, 0, w, h); + + // --- Voronoi mosaic (nearest weighted site per pixel), STRIDED by quality. + // Immediate-mode per-block fill is the cost driver, so we work on a fixed + // ~COLS-wide grid (block size scales with screen) and STRIDE that grid by + // quality — block-filled so the whole scan stays inside the 60fps budget. + const COLS = quality < 0.55 ? 40 : quality < 0.8 ? 55 : 72; + const stride = Math.max(1, Math.round(w / COLS)); + const n = sites.length; + // Precompute pixel-space site coords + palette once per frame. + const sx = new Float32Array(n), sy = new Float32Array(n), sw = new Float32Array(n); + const cr = new Uint8Array(n), cg = new Uint8Array(n), cb = new Uint8Array(n); + const lit = new Float32Array(n); + for (let i = 0; i < n; i++) { + const st = sites[i]; + sx[i] = st.x * w; sy[i] = st.y * h; + sw[i] = st.weight > 0.05 ? st.weight : 0.05; + const L = 22 + Math.min(1, st.lit) * 46; // lit cells glow bright + const [r, g, b] = num.hslToRgb(((st.hue % 360) + 360) % 360, 85, L); + cr[i] = r; cg[i] = g; cb[i] = b; lit[i] = st.lit; + } + + // Scan block-grid; coalesce contiguous same-color blocks per row into ONE + // box (Voronoi cells are contiguous → long runs → far fewer draw calls, + // which is what actually holds 60fps in immediate mode). + const shimmerAmt = air * 40; + const glow = crackGlow * (0.6 + bass); + for (let py = 0; py < h; py += stride) { + let runR = -1, runG = 0, runB = 0, runX = 0; + for (let px = 0; px < w; px += stride) { + let best = 0, bestD = Infinity, second = Infinity; + for (let i = 0; i < n; i++) { + const dx = px - sx[i], dy = py - sy[i]; + const d = (dx * dx + dy * dy) / (sw[i] * sw[i]); + if (d < bestD) { second = bestD; bestD = d; best = i; } + else if (d < second) second = d; + } + const rim = bestD / (second + 1); // ~1 at borders, 0 at centers + let r, g, b; + if (rim > 0.78) { // dark leaded crack line, glows with bass + r = 8 + 240 * glow; g = 8 + 120 * glow; b = 30 + 255 * glow; + } else { + const s2 = lit[best] * shimmerAmt; + r = Math.min(255, cr[best] + s2); + g = Math.min(255, cg[best] + s2); + b = Math.min(255, cb[best] + s2); + } + r |= 0; g |= 0; b |= 0; + if (r !== runR || g !== runG || b !== runB) { + if (runR >= 0) ink(runR, runG, runB, 235).box(runX, py, px - runX, stride); + runR = r; runG = g; runB = b; runX = px; + } + } + if (runR >= 0) ink(runR, runG, runB, 235).box(runX, py, w - runX, stride); + } + + // Site cores: tiny bright specks at each seed (the light source of a cell). + for (let i = 0; i < n; i++) { + const L = lit[i]; + if (L > 0.06) { + ink(255, 255, 255, Math.min(255, 120 * L)).circle(sx[i], sy[i], 1 + L * 3, true); + } + } + + if (beatFlash > 0.6 || pump > 1.3) blur?.(1); + + // A faint central glass heart pulse tying the mosaic together. + const cx = w / 2, cy = h / 2; + const heartR = 3 + beatFlash * 8 + bass * 10 + pump * 6; + ink(120, 200, 255, 70 + beatFlash * 70).circle(cx, cy, heartR * 1.6, true); + ink(255, 255, 255, 120 + beatFlash * 70).circle(cx, cy, heartR, true); + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/quilo.mjs b/system/public/aesthetic.computer/disks/quilo.mjs new file mode 100644 index 0000000000..021a085fa5 --- /dev/null +++ b/system/public/aesthetic.computer/disks/quilo.mjs @@ -0,0 +1,237 @@ +// quilo, 26.07.12 +// Matrix glyph-rain × chiptune arps — a thin wrapper over lib/pads.mjs (the +// shared pad engine: UTC-clock beat grid, `params[0]` rate override e.g. +// `quilo 0.5`, the tap/XY "pump", audio polling). This file only describes what +// makes quilo quilo: its chiptune score, its 8-bit voices, and its cascade. +// ALLEGORY: columns of falling green glyphs. Every arp note LIGHTS A COLUMN — +// pitch → column x (low = left, high = right); high notes fall faster & brighter. +// The bass drives the whole rain's SPEED + DENSITY. The beat flashes a sweep. +// A viewer reads the arp directly as the lit columns cascading down. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score (chiptune, D-minor pentatonic-ish arp) --------------------------- +const ARP = [ + "d3", "a3", "d4", "f4", "a4", "f4", "d4", "a3", + "c3", "g3", "c4", "e4", "g4", "e4", "c4", "g3", +]; +const BASS = ["d2", "d2", "c2", "c2", "a1", "a1", "f1", "f1"]; // half-time root +const TYPES = ["square", "triangle", "square", "sawtooth"]; // pulse-ish rotation + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(\d)$/.exec(n); + if (!m) return 48; + return (parseInt(m[2], 10) + 1) * 12 + NOTE_SEMI[m[1]]; +} +const ARP_PITCHES = ARP.map(notePitch); +const PITCH_MIN = Math.min(...ARP_PITCHES); +const PITCH_MAX = Math.max(...ARP_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// Glyph alphabet — katakana-ish + ASCII for a legible cascade. +const GLYPHS = "アカサタナabcdef0123456789+*=<>#$%".split(""); + +// --- quilo-specific visual state (engine owns pump/bursts/rhythm) ------------ +// Each column is a single falling STREAK: a head position + a short glyph tail. +// This keeps the per-frame `write` count bounded (~COLS × TAIL) so it holds +// 60fps — the cascade legibility comes from the head being white-hot. +const COLS = 20; // logical column count +let columns = []; // per-column streaks +let rainSpeed = 1; // global fall speed, driven by bass +let flashSweep = 0; // beat flash sweep 0..1 +let flashY = 0; + +// Tiny deterministic hash for frame-seeded glyph choice (no Math.random flake). +function hash(a, b) { + let h = (a * 374761393 + b * 668265263) >>> 0; + h = (h ^ (h >>> 13)) * 1274126177; + return (h >>> 0) / 4294967296; +} +let frame = 0; +const TAIL = 13; // glyphs trailing each streak head + +function ensureColumns() { + if (columns.length === COLS) return; + columns = []; + for (let c = 0; c < COLS; c++) { + const glyphs = []; + for (let i = 0; i < TAIL; i++) glyphs.push(Math.floor(hash(c, i + 11) * GLYPHS.length)); + columns.push({ + head: hash(c, 3) * 2 - 1, // normalized -1..1 head position + glyphs, + lit: 0, // brightness pulse when this column's arp note fires + litHue: 120, + speed: 0.7 + hash(c, 5) * 0.6, + }); + } +} + +const CONFIG = { + bpm: 128, + steps: ARP.length, + drawBursts: false, // quilo draws its own column highlights + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.14, feedback: 0.35 }); + ensureColumns(); + }, + + // A new UTC beat crossed — fire the chiptune arp + light the matching column. + onBeat({ idx, synth }) { + ensureColumns(); + const s = ((idx % ARP.length) + ARP.length) % ARP.length; + const note = ARP[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const col = Math.min(COLS - 1, Math.floor(pn * (COLS - 1) + 0.5)); // pitch → x + const pan = (col / (COLS - 1)) * 2 - 1; + + // 8-bit chiptune arp: raw square/triangle/pulse, hard attack, short decay. + const type = TYPES[s % TYPES.length]; + synth({ tone: note, type, beats: 0.42, attack: 0.001, decay: 0.55, volume: 0.34, pan }); + // Octave-up sparkle blip on the high half of the arp. + if (pn > 0.5) + synth({ tone: note, type: "square", beats: 0.18, attack: 0.001, decay: 0.4, volume: 0.12 * pn, pan: -pan }); + + // ALLEGORY: light the pitch's column bright; high notes = faster + brighter. + const cc = columns[col]; + cc.lit = 1; + cc.litHue = 110 + pn * 40; // green → chartreuse for highs + cc.speed = 0.9 + pn * 1.5; // high notes fall faster + cc.head = -1.1; // relaunch its streak from the top + + // Sub-bass root on the half-beat — drives the whole rain's speed + density. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.3, decay: 0.5, volume: 0.42 }); + rainSpeed = 2.1; // bass surges the rain + } + + // Beat flash sweep + chiptune hat. + voices.hat(synth, { tone: 9000, beats: 0.08, volume: 0.12 }); + flashSweep = 1; + flashY = 0; + }, + + onSim() { + frame++; + rainSpeed += (1 - rainSpeed) * 0.06; // relax toward baseline between bass hits + flashSweep *= 0.9; + const fall = 0.02 * rainSpeed; + for (const c of columns) { + c.lit *= 0.9; + c.head += fall * c.speed; + if (c.head > 1.3) { + c.head = -1.15; + // Reshuffle a couple glyphs so the streak reads as living text. + const k = frame % TAIL; + c.glyphs[k] = Math.floor(hash(c.speed * 1000 + frame, k) * GLYPHS.length); + } + } + }, + + // Tap = ignite a bright column burst at tap x + an arcade blip. + onTap({ x, y, synth }) { + ensureColumns(); + const col = Math.min(COLS - 1, Math.max(0, Math.floor(x * COLS))); + const cc = columns[col]; + cc.lit = 1.7; // over-bright ignite + cc.litHue = 90 + (1 - y) * 80; // green→yellow toward top + cc.speed = 1.6 + (1 - y) * 1.6; + cc.head = -1.1; + // Light the two neighbours too, for a visible splash. + for (const d of [-1, 1]) { + const nb = columns[col + d]; + if (nb) { nb.lit = Math.max(nb.lit, 0.9); nb.litHue = cc.litHue; } + } + // Arcade blip: rising square + saw tick + hat. + const note = ["c", "e", "g", "a", "c"][Math.floor(x * 5)] + (3 + Math.floor((1 - y) * 3)); + synth({ tone: note, type: "square", beats: 0.22, attack: 0.001, decay: 0.5, volume: 0.42, pan: x * 2 - 1 }); + synth({ tone: note, type: "sawtooth", beats: 0.12, attack: 0.001, decay: 0.3, volume: 0.18, pan: x * 2 - 1 }); + voices.hat(synth, { tone: 7000, beats: 0.06, volume: 0.14 }); + }, + + onPaint(api, s) { + const { ink, screen, num, write } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + ensureColumns(); + + // Trailing veil (near-black with a green tint) → glyph trails linger. + ink(0, 8, 3, 240).box(0, 0, w, h); + + const bass = band("subBass") + band("lowMid") * 0.5; + const energy = Math.min(1.5, bass * 1.4 + amp * 0.5 + pump * 0.4); + + const colW = w / COLS; + const glyphSize = Math.max(1, Math.round(Math.min(3, colW / 6))); + const cellH = Math.max(6, glyphSize * 7); + // ADAPTIVE QUALITY: tail length drawn scales with quality (fewer glyphs + // per streak when the engine asks us to cut cost). + const tailN = Math.max(4, Math.round(TAIL * quality)); + + for (let ci = 0; ci < COLS; ci++) { + const c = columns[ci]; + const cx = ci * colW + colW * 0.5; + const lit = c.lit; + const headPix = ((c.head + 1.2) / 2.4) * (h + cellH) - cellH; + + // Lit column highlight bar (the ARP note reads as a bright column). + if (lit > 0.08) { + const [r, g, b] = num.hslToRgb(((c.litHue % 360) + 360) % 360, 90, 55); + ink(r, g, b, Math.min(150, Math.round(85 * lit))).box(ci * colW, 0, Math.max(1, colW - 1), h); + ink(255, 255, 255, Math.min(110, Math.round(70 * lit))).box(Math.round(cx) - 1, 0, 2, h); + } + + // The streak: white-hot head, fading green glyph tail upward. + for (let t = 0; t < tailN; t++) { + const py = headPix - t * cellH; + if (py < -cellH || py > h) continue; + const trail = 1 - t / tailN; + const glyph = GLYPHS[c.glyphs[(t + ((frame >> 2) % TAIL)) % TAIL]]; + const gx = cx - glyphSize * 2; + if (t === 0) { + // White-hot cascade leader. + ink(220, 255, 230, 255).write(glyph, { x: gx, y: py, size: glyphSize }); + } else { + const light = Math.min(90, 40 + trail * 30 + lit * 20 + energy * 10); + const [r, g, b] = num.hslToRgb( + ((c.litHue - trail * 8) % 360 + 360) % 360, + Math.min(100, 75 + lit * 20), + Math.min(100, light), + ); + const a = Math.max(0, Math.min(255, Math.round((70 + trail * 170) * (0.55 + lit * 0.5)))); + ink(r, g, b, a).write(glyph, { x: gx, y: py, size: glyphSize }); + } + } + } + + // BEAT FLASH SWEEP — a bright horizontal band travels top→bottom. + if (flashSweep > 0.06) { + flashY += h * 0.06 * (0.6 + rainSpeed * 0.4); + if (flashY > h) flashY = 0; + const bandH = Math.max(2, Math.round(h * 0.05)); + ink(120, 255, 160, Math.min(140, Math.round(120 * flashSweep))).box(0, flashY, w, bandH); + ink(255, 255, 255, Math.min(90, Math.round(70 * flashSweep))).box(0, flashY, w, 2); + } + + // Bass floor glow — density surges pulse the bottom edge. + if (rainSpeed > 1.2 || bass > 0.1) { + const gl = Math.min(1, (rainSpeed - 1) * 0.8 + bass); + const gh = Math.round(h * 0.08 * gl); + if (gh > 0) ink(40, 200, 90, Math.round(45 * gl)).box(0, h - gh, w, gh); + } + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/torva.mjs b/system/public/aesthetic.computer/disks/torva.mjs new file mode 100644 index 0000000000..0f04df7487 --- /dev/null +++ b/system/public/aesthetic.computer/disks/torva.mjs @@ -0,0 +1,315 @@ +// torva, 26.07.12 +// Delaunay/triangulation-web × ACID 303 — a thin wrapper over lib/pads.mjs (the +// shared pad engine: UTC-clock beat grid, `params[0]` seconds-per-beat override +// e.g. `torva 0.25`, the tap/XY "pump", audio polling). This file only describes +// what makes torva torva: its acid score, its squelchy 303 voices, and its +// low-poly neon mesh paint. +// +// ALLEGORY — a shifting triangulated web of nodes. Every audible note PULSES a +// node bright (pitch → node height/hue) and lights its connected edges. The +// acid bassline SLIDES → the whole web warps/breathes. The beat = a wave of +// light travelling out through the mesh. The visual IS the score. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A classic acid pattern in A: root-heavy with octave jumps + a couple of +// gliding accents. Each step targets a node (step % NODE_COUNT). +const ACID = [ + "a1", "a2", "a1", "c2", "e2", "a1", "g2", "a1", + "a1", "a2", "c3", "a2", "e2", "g1", "a1", "b1", +]; +// Slide targets: where each step GLIDES toward (null = plain pluck). The 303's +// signature squelch is the portamento between notes. +const SLIDE = [ + null, "e2", null, "e2", null, null, "a2", null, + null, "c3", null, "e2", null, "a1", null, "e2", +]; + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(#?)(\d)$/.exec(n); + if (!m) return 33; + return (parseInt(m[3], 10) + 1) * 12 + NOTE_SEMI[m[1]] + (m[2] ? 1 : 0); +} +const ACID_PITCHES = ACID.map(notePitch); +const PITCH_MIN = Math.min(...ACID_PITCHES); +const PITCH_MAX = Math.max(...ACID_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// --- Mesh -------------------------------------------------------------------- +// A fixed set of nodes drifting on gentle sinusoids; each frame we (re)link each +// node to its K nearest neighbours → a shifting low-poly web (no true Delaunay +// needed). Node COUNT scales with ctx.quality to hold 60fps. +const BASE_NODES = 26; // at quality 1 +const K = 3; // edges per node (nearest-k) +let nodes = []; // { bx, by, phx, phy, spx, spy, pulse, hue, height } +let webWarp = 0; // acid-bass warp amount (breathing) +let beatWave = 0; // 0..1 radius of the light wave through the mesh (from center) +let beatWaveLife = 0; + +function buildNodes(n) { + nodes = []; + for (let i = 0; i < n; i++) { + // Deterministic scatter so the mesh is stable frame-to-frame. + const r = (i * 2654435761) >>> 0; + const rx = (r & 0xffff) / 0xffff; + const ry = ((r >>> 16) & 0xffff) / 0xffff; + nodes.push({ + bx: 0.08 + rx * 0.84, // normalized home position + by: 0.08 + ry * 0.84, + phx: rx * Math.PI * 2, + phy: ry * Math.PI * 2, + spx: 0.4 + rx * 0.7, // drift speed + spy: 0.4 + ry * 0.7, + pulse: 0, // 0..1 note flash + hue: 180 + rx * 120, + height: 0, // pitch → 0..1 (raises node + shifts hue) + }); + } +} + +const CONFIG = { + bpm: 138, // driving acid tempo + steps: ACID.length, + drawBursts: false, // torva draws its own tap stabs + hooks: { + onBoot({ sound }) { + buildNodes(BASE_NODES); + sound.room?.set?.({ enabled: true, mix: 0.22, feedback: 0.5 }); + }, + + // A new UTC beat crossed — fire the acid step + pulse the matching node. + onBeat({ idx, synth }) { + const s = ((idx % ACID.length) + ACID.length) % ACID.length; + const note = ACID[s]; + const slide = SLIDE[s]; + const pn = pitchNorm(note); + const pan = Math.sin((s / ACID.length) * Math.PI * 2) * 0.5; + + // ACID 303: raw resonant sawtooth. Emulate portamento/squelch by firing a + // short quick sequence of stacked saws gliding from note → slide target, + // each with a fast decay (the classic sliding, squelchy bite). + acid303(synth, note, slide, { pan, accent: pn > 0.5 }); + + // ALLEGORY: pulse the node this step targets; pitch → height + hue shift. + const node = nodes[s % nodes.length]; + if (node) { + node.pulse = 1; + node.height = pn; + node.hue = 300 - pn * 220; // low = magenta/red, high = cyan/green + } + + // Sub-bass root every other step — the floor you feel + the web warp. + if (s % 2 === 0) { + voices.sub(synth, note[0] + "1", { beats: 1.0, decay: 0.4, volume: 0.42 }); + webWarp = 1; // acid bass → warp the whole web + } + + // Ticking hat drives the acid pulse. + voices.hat(synth, { tone: 9000, beats: 0.08, volume: 0.13 }); + + // Every step launches a wave of light from the mesh center. + beatWave = 0; + beatWaveLife = 1; + }, + + onSim() { + webWarp *= 0.9; + for (const nd of nodes) { + nd.pulse *= 0.88; + nd.height *= 0.94; + } + if (beatWaveLife > 0) { + beatWave += 0.06; + beatWaveLife -= 0.04; + } + }, + + // Tap = pulse the NEAREST node + a squelchy 303 stab at the tap (X→pitch, + // Y→brightness/slide). Engine already bumped pump. + onTap({ x, y, ex, ey, synth, screen }) { + const w = screen?.width || 1; + const h = screen?.height || 1; + // Find nearest node to the tap. + let best = null; + let bd = Infinity; + for (const nd of nodes) { + const nx = nd.bx * w; + const ny = nd.by * h; + const d = (nx - ex) ** 2 + (ny - ey) ** 2; + if (d < bd) { bd = d; best = nd; } + } + if (best) { + best.pulse = 1.3; + best.height = 1 - y; + best.hue = x * 300; + } + webWarp = Math.min(1.5, webWarp + 0.8); + beatWave = 0; + beatWaveLife = 1; + + // Squelchy stab: X picks the root, gliding up when tapped high. + const roots = ["a1", "c2", "d2", "e2", "g2"]; + const note = roots[Math.floor(x * 5) % 5]; + const target = y < 0.5 ? note[0] + String(parseInt(note.slice(-1)) + 1) : null; + acid303(synth, note, target, { pan: x * 2 - 1, accent: true, vol: 0.6 }); + }, + + onPaint(api, s) { + const { ink, screen, num, blur } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + + const bass = band("subBass"); + const energy = Math.min(2, webWarp * 0.7 + bass * 1.2 + amp * 0.6 + pump * 0.5); + + // Scale node count by quality to hold 60fps (rebuild only when it changes). + const want = Math.max(8, Math.round(BASE_NODES * quality)); + if (nodes.length !== want) { + const old = nodes; + buildNodes(want); + // preserve pulse/height across resize where indices overlap + for (let i = 0; i < Math.min(old.length, nodes.length); i++) { + nodes[i].pulse = old[i].pulse; + nodes[i].height = old[i].height; + nodes[i].hue = old[i].hue; + } + } + + // Neon dark base with a faint trail (feedback breath). + ink(4, 3, 12, 200).box(0, 0, w, h); + + const t = (s.simMs || 0) / 1000; + const warp = webWarp; // 0..~1.5 + + // Resolve live node screen positions (drift + acid-bass warp toward center). + const pos = new Array(nodes.length); + for (let i = 0; i < nodes.length; i++) { + const nd = nodes[i]; + let nx = nd.bx + Math.sin(t * nd.spx + nd.phx) * 0.05; + let ny = nd.by + Math.cos(t * nd.spy + nd.phy) * 0.05; + // Acid warp: the whole web breathes in/out around center. + const breathe = 1 + warp * 0.12 * Math.sin(t * 2 + nd.phx); + nx = 0.5 + (nx - 0.5) * breathe; + ny = 0.5 + (ny - 0.5) * breathe; + // Pitch height lifts a pulsed node upward. + const lift = nd.height * 0.06; + pos[i] = [nx * w, (ny - lift) * h, i]; + } + + // Nearest-K edges (bounded: N*K lines). Compute per node. + const maxDim = Math.max(w, h); + for (let i = 0; i < pos.length; i++) { + const [ax, ay] = pos[i]; + // find K nearest + const dists = []; + for (let j = 0; j < pos.length; j++) { + if (j === i) continue; + const dx = pos[j][0] - ax, dy = pos[j][1] - ay; + dists.push([dx * dx + dy * dy, j]); + } + dists.sort((p, q) => p[0] - q[0]); + const kk = Math.min(K, dists.length); + for (let e = 0; e < kk; e++) { + const j = dists[e][1]; + if (j < i) continue; // draw each undirected edge once + const [bx, by] = pos[j]; + const na = nodes[i], nb = nodes[j]; + const glow = Math.max(na.pulse, nb.pulse); // lit if either endpoint fired + // beat wave: edges near the wave radius flash white. + const mx = (ax + bx) / 2, my = (ay + by) / 2; + const md = Math.hypot(mx - cx, my - cy) / (maxDim * 0.6); + const waveHit = beatWaveLife > 0 ? Math.max(0, 1 - Math.abs(md - beatWave) * 6) * beatWaveLife : 0; + const lit = Math.min(1, glow + waveHit); + const baseA = 26 + lit * 200; + const hue = ((na.hue + nb.hue) / 2 + energy * 20) % 360; + const [r, g, b] = num.hslToRgb(((hue % 360) + 360) % 360, 90, 40 + lit * 35); + const wgt = 1 + lit * 2; + ink(r, g, b, baseA).line(ax, ay, bx, by, wgt); + if (waveHit > 0.3) + ink(255, 255, 255, 180 * waveHit).line(ax, ay, bx, by, 1); + } + } + + // Nodes on top: bright pulsing dots; height/hue from pitch. + for (let i = 0; i < pos.length; i++) { + const [x, y] = pos[i]; + const nd = nodes[i]; + const p = nd.pulse; + const [r, g, b] = num.hslToRgb(((nd.hue % 360) + 360) % 360, 100, 55 + p * 25); + const rad = 2 + p * 8 + nd.height * 4; + if (p > 0.05) { + ink(r, g, b, 60 + 90 * p).circle(x, y, rad * 2.2, true); // halo + } + ink(r, g, b, 200).circle(x, y, rad, true); + if (p > 0.2) ink(255, 255, 255, 200 * p).circle(x, y, rad * 0.5, true); + } + + // TAP STABS: engine-tracked pump gives a central flash; also draw a soft + // core so the mesh has a beating heart. + const heartR = 3 + warp * 8 + bass * 12 + pump * 8; + ink(120, 255, 220, 60 + warp * 80).circle(cx, cy, heartR * 1.8, true); + ink(255, 255, 255, 120 + warp * 60).circle(cx, cy, heartR, true); + + if (warp > 0.6 || pump > 1.3) blur?.(1); + }, + }, +}; + +// --- Acid 303 voice ---------------------------------------------------------- +// Raw resonant sawtooth with an emulated slide + squelch. We stack a couple of +// detuned saws and, when a slide target exists, fire a fast micro-sequence of +// short saw grains stepping from the source pitch to the target — the ear reads +// that rapid step-glide as portamento. Short decay + a bright detuned partial +// gives the resonant "squelch". No gm. +function acid303(synth, note, slide, o = {}) { + const pan = o.pan ?? 0; + const vol = o.vol ?? (o.accent ? 0.5 : 0.4); + const p0 = notePitch(note); + if (slide) { + const p1 = notePitch(slide); + const STEPS = 5; + for (let i = 0; i < STEPS; i++) { + const semi = p0 + ((p1 - p0) * i) / (STEPS - 1); + const freq = 440 * Math.pow(2, (semi - 57) / 12); // A3=57 → 440Hz + synth({ + type: "sawtooth", + tone: freq, + beats: 0.16, + attack: 0.001, + decay: 0.5, + volume: (vol * (0.5 + (i / STEPS) * 0.5)) / 1.4, + pan, + }); + // Bright resonant partial (the squelchy "wah"), a 5th up, quick. + synth({ + type: "sawtooth", + tone: freq * 1.5, + beats: 0.1, + attack: 0.001, + decay: 0.7, + volume: vol * 0.14, + pan, + }); + } + } else { + // Plain accented saw hit + detuned twin + resonant partial. + synth({ type: "sawtooth", tone: note, beats: 0.28, attack: 0.001, decay: 0.4, volume: vol, pan }); + const f = 440 * Math.pow(2, (p0 - 57) / 12); + synth({ type: "sawtooth", tone: f * 1.008, beats: 0.24, attack: 0.001, decay: 0.4, volume: vol * 0.5, pan }); + synth({ type: "sawtooth", tone: f * 2.02, beats: 0.14, attack: 0.001, decay: 0.6, volume: vol * (o.accent ? 0.22 : 0.12), pan }); + } +} + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert this pad's config before the engine's +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/vunn.mjs b/system/public/aesthetic.computer/disks/vunn.mjs new file mode 100644 index 0000000000..306a49d5c8 --- /dev/null +++ b/system/public/aesthetic.computer/disks/vunn.mjs @@ -0,0 +1,364 @@ +// vunn, 26.07.12 +// A GROWING FRACTAL TREE/FERN instrument — the melody RECURSIVELY BRANCHES a +// plant. A thin wrapper over lib/pads.mjs (the shared pad engine: UTC-clock beat +// grid, `params[0]` rate override e.g. `vunn 0.5`, the tap/XY "pump", audio +// polling). This file only describes what makes vunn vunn: its koto/harp score, +// its self-similar L-system tree, and its botanical paint. +// +// ALLEGORY — every note GROWS A BRANCH GENERATION. The tree is built one +// generation per beat: each new note takes the tips of the previous generation +// and sprouts a self-similar pair of child branches from each (fractal +// subdivision). PITCH → the branch split ANGLE + child LENGTH ratio, so the +// melodic contour literally shapes the tree's silhouette. The trunk is the bass +// — it thickens and sways on the sub-bass beat. At the loop seam the whole tree +// fades and REGROWS from the seed. Distinct from vindle (a single wandering +// vine): vunn is a RECURSIVE, self-similar branching fractal — a fern/tree. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// Koto/harp pentatonic. One plucked note per beat → one branch generation. The +// phrase rises then folds, so the tree first reaches, then bushes out. The tree +// REGROWS every LOOP_LEN beats (the loop seam) — one phrase, one whole tree. +const ARP = [ + "e2", "b2", "e3", "g3", // seed + trunk reach (low → the tree grows tall) + "b3", "d4", "e4", "g4", // mid canopy branches open + "b4", "g4", "e4", "d4", // high buds shimmer, then settle + "b3", "g3", "e3", "b2", // fold back down toward the seed +]; +const LOOP_LEN = ARP.length; // 16 generations per grown tree +const BASS = ["e1", "e1", "c2", "c2", "a1", "a1", "b1", "b1"]; // trunk, half-time +const ACCENT_STEPS = new Set([8, 9, 10]); // bell buds at the canopy's peak + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(\d)$/.exec(n); + if (!m) return 48; + return (parseInt(m[2], 10) + 1) * 12 + NOTE_SEMI[m[1]]; +} +const ARP_PITCHES = ARP.map(notePitch); +const PITCH_MIN = Math.min(...ARP_PITCHES); +const PITCH_MAX = Math.max(...ARP_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// hue 0..1, sat/light 0..1 → [r,g,b] 0..255. Wraps num.hslToRgb (wants hue in +// DEGREES + sat/light 0..100, and already returns 0..255 — do NOT ×255). +function hue2rgb(num, hue, sat, light) { + return num.hslToRgb((((hue % 1) + 1) % 1) * 360, sat * 100, light * 100); +} + +// --- vunn-specific visual state (the engine owns pump/bursts/rhythm) --------- +// The tree is a list of GENERATIONS. Each generation is a list of branches, and +// each branch is grown from a parent tip in the previous generation. Growing one +// generation per beat means the whole melody is legible as the tree's structure. +// +// A branch: { x0,y0,x1,y1, angle, len, gen, hue, born, bud, budHue } +// The frontier = the tips of the newest generation (where the next split grows). +let generations = []; // [[branch,...], ...] oldest → newest +let frontier = []; // [{ x,y, angle, len }] — tips awaiting the next split +let treeFade = 1; // 1 = solid, → 0 as the tree fades at the loop seam +let trunkThick = 0; // decays; extra trunk girth on the bass beat +let trunkSway = 0; // decays; sway target, swings the whole tree +let rootPulse = 0; // decays; earthy bloom at the seed on the bass beat +let beatCount = 0; // total beats (for the recently-born branch glow) +let baseX = 0, baseY = 0; // seed position (screen coords) + +let buds = []; // tap-sprouted buds: { x,y, hue, grow, life, segs } +let flecks = []; // pollen/leaf flecks (accents + taps) + +// The engine's pump lives in ctx; onBeat fires inside sim, so we stash it. +let curPump = 0; +const pump = () => curPump; +let maxDepth = 6; // adaptive: how many child branches per split (quality-gated) +let grounded = false; + +// Reseed the tree — a single upward trunk tip, called at each loop seam. +function reseed(w, h) { + baseX = w / 2; + baseY = h * 0.9; // grow up from near the bottom + const trunkLen = Math.min(w, h) * 0.16; + const tipX = baseX; + const tipY = baseY - trunkLen; + // Generation 0 is the trunk itself (one branch straight up). + generations = [[{ + x0: baseX, y0: baseY, x1: tipX, y1: tipY, + angle: -Math.PI / 2, len: trunkLen, gen: 0, + hue: 0.08, born: beatCount, bud: false, budHue: 0.1, + }]]; + frontier = [{ x: tipX, y: tipY, angle: -Math.PI / 2, len: trunkLen }]; + treeFade = 1; +} + +// Grow ONE generation: from each frontier tip, sprout a self-similar PAIR of +// child branches, split by ±spread and scaled by lenRatio (both ∝ pitch). This +// is the L-system subdivision — the recursion is unrolled across beats so each +// note literally adds one fractal level. Returns the new frontier. +function growGeneration(pn, gen, accent) { + const spread = 0.32 + pn * 0.55; // high pitch → wider fan + const lenRatio = 0.62 + pn * 0.16; // high pitch → longer children + const sway = trunkSway * 0.4; // whole tree leans with the bass sway + const next = []; + // Cap branch count by quality so deep generations stay cheap (60fps). + const cap = 2 << Math.min(9, maxDepth); // generous ceiling + for (const t of frontier) { + if (generations.reduce((n, g) => n + g.length, 0) > cap) break; + const childLen = t.len * lenRatio; + for (const side of [-1, 1]) { + const a = t.angle + side * spread + sway + + Math.sin((gen + side) * 1.3) * 0.06; // organic jitter + const nx = t.x + Math.cos(a) * childLen; + const ny = t.y + Math.sin(a) * childLen; + const hue = 0.24 + pn * 0.2 + gen * 0.006; // green → chartreuse w/ height + const branch = { + x0: t.x, y0: t.y, x1: nx, y1: ny, + angle: a, len: childLen, gen, hue, born: beatCount, + bud: accent || pump() > 1.3, + budHue: accent ? 0.02 + pn * 0.06 : 0.12 + pn * 0.06, + }; + generations[gen] = generations[gen] || []; + generations[gen].push(branch); + next.push({ x: nx, y: ny, angle: a, len: childLen }); + } + } + // Keep the frontier bounded so growth cost never explodes. + frontier = next.length > 96 ? next.slice(0, 96) : next; +} + +const CONFIG = { + bpm: 96, // patient, hypnotic koto tempo (one generation per beat) + steps: LOOP_LEN, + drawBursts: false, // vunn draws its own bud sprouts in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.24, feedback: 0.5 }); // airy hall + }, + + // A new UTC beat crossed — grow one branch generation + pluck the note. Use + // idx to detect the loop seam (idx % LOOP_LEN === 0) → reseed the tree. + onBeat({ idx, synth, screen }) { + const w = screen.width, h = screen.height; + const s = ((idx % LOOP_LEN) + LOOP_LEN) % LOOP_LEN; + + // At the seam (or first-ever beat) reseed a fresh trunk. + if (s === 0 || generations.length === 0) reseed(w, h); + + const note = ARP[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const accent = ACCENT_STEPS.has(s); + const pan = -0.55 + (s / (LOOP_LEN - 1)) * 1.1; + + // --- SONIC: koto/harp plucked note (Karplus–Strong harp + sine body). + voices.pluck(synth, note, { beats: 1.0, decay: 0.55, volume: 0.55, pan }); + synth({ tone: note, type: "sine", beats: 0.7, attack: 0.003, decay: 0.5, + volume: 0.24, pan }); // rounded koto body + if (accent) { + // Bright bell shimmer when the canopy buds. + voices.bell(synth, note, { beats: 1.1, volume: 0.26, pan }); + for (let i = 0; i < 6 && flecks.length < 220; i++) { + const ang = Math.random() * Math.PI * 2; + const sp = 1 + Math.random() * 2.5; + flecks.push({ x: baseX, y: baseY - h * 0.4, vx: Math.cos(ang) * sp, + vy: Math.sin(ang) * sp - 0.5, life: 1, hue: 0.13 }); + } + } + + // --- TRUNK BASS on the half-beat → sub, root pulse, trunk thickens+sways. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.7, decay: 0.5, volume: 0.52 }); + rootPulse = 1; + trunkThick = 1; + trunkSway = ((idx / 2) % 2 === 0 ? 1 : -1) * (0.12 + pn * 0.1); + } + + // Soft koto-hall tick on off-beats → a leaf-rustle fleck. + if (s % 2 === 1) { + voices.hat(synth, { tone: 6500, beats: 0.1, volume: 0.12 }); + } + + // --- GRAPHIC: grow the NEXT fractal generation from the frontier tips. + // gen index = how many generations already exist (trunk = gen 0). + const gen = generations.length; + growGeneration(pn, gen, accent); + beatCount++; + }, + + onSim({ pump: p, step, beatProgress, screen }) { + const w = screen.width, h = screen.height; + if (generations.length === 0) reseed(w, h); + curPump = p; // stash engine pump for onBeat geometry (fires inside sim) + + // Fade the tree across the last slice of the loop → seamless regrow. + const phase = (step + beatProgress) / LOOP_LEN; // 0..1 through this tree + treeFade = phase > 0.88 ? Math.max(0, 1 - (phase - 0.88) / 0.12) : 1; + + // Advance flecks (pollen / leaves). + for (const f of flecks) { + f.x += f.vx; f.y += f.vy; f.vy += 0.06; f.vx *= 0.98; f.life -= 0.03; + } + flecks = flecks.filter((f) => f.life > 0); + + // Advance tap-sprouted buds — each grows a tiny 2-branch tuft over time. + for (const b of buds) { + b.grow += 0.05 + p * 0.04; + while (b.segs.length < Math.floor(b.grow) * 2 && b.segs.length < 6) { + const parent = b.segs.length < 2 + ? { x: b.x, y: b.y, a: -Math.PI / 2 } + : b.segs[b.segs.length - 2]; + const side = b.segs.length % 2 === 0 ? -1 : 1; + const a = parent.a + side * 0.5; + const len = Math.min(w, h) * 0.035 * (1 + p * 0.3); + const nx = parent.x + Math.cos(a) * len; + const ny = parent.y + Math.sin(a) * len; + b.segs.push({ x0: parent.x, y0: parent.y, x1: nx, y1: ny, x: nx, y: ny, a, + bud: b.segs.length >= 4 }); + } + b.life -= 0.006; + } + buds = buds.filter((b) => b.life > 0); + + // Decay pulses (the engine decays pump itself). + rootPulse *= 0.9; + trunkThick *= 0.92; + trunkSway *= 0.9; + }, + + // Tap = a NEW BUD sprouts from the touch point + a plucked boost (engine + // already bumped pump + pushed the burst; X→pan/hue, Y→pitch, top = high). + onTap({ x, y, ex, ey, synth, isDraw }) { + const drag = isDraw; + const hue = 0.2 + (1 - y) * 0.22; // higher tap → golder bud + buds.push({ x: ex, y: ey, hue, grow: 0, life: 1, segs: [] }); + const n = drag ? 3 : 8; + for (let i = 0; i < n && flecks.length < 220; i++) { + const ang = Math.random() * Math.PI * 2; + const sp = 1 + Math.random() * (drag ? 2 : 4); + flecks.push({ x: ex, y: ey, vx: Math.cos(ang) * sp, + vy: Math.sin(ang) * sp - 1, life: 1, hue }); + } + + // SONIC pluck — X→pan/hue, Y→pitch (top = high). Koto harp + sine body. + const scale = ["e", "g", "a", "b", "d"]; // E minor pentatonic + const oct = 2 + Math.floor((1 - y) * 3); // 2..4, higher tap = higher + const note = scale[Math.min(4, Math.floor(x * 5))] + oct; + voices.pluck(synth, note, { beats: drag ? 0.5 : 1.0, decay: 0.55, + volume: drag ? 0.42 : 0.7, pan: x * 2 - 1 }); + if (!drag) voices.bell(synth, note, { beats: 0.6, volume: 0.22, pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { ink, box, screen, num, paintCount } = api; + const { pump, band, quality } = s; + const w = screen.width, h = screen.height; + + // Quality → recursion cap. More quality = deeper/bushier fractal. + maxDepth = quality < 0.6 ? 4 : quality < 0.85 ? 5 : 6; + const bx = baseX || w / 2; + const by = baseY || h * 0.9; + + // Lay a SOLID dusk-botanical ground on the first frames (a translucent veil + // alone converges to gray). Then a soft veil each frame so growth persists. + if (!grounded || Number(paintCount) < 3) { + ink("fade:#0a1420-#04070c:vertical").box(0, 0, w, h); + grounded = true; + } + ink(6, 16, 20, Math.max(70, 100 - pump * 8)).box(0, 0, w, h); + ink("fade:#0a1420-#04070c:vertical", 32).box(0, 0, w, h); + + const bass = band("subBass"); + const air = band("air"); + + // === ROOT PULSE — earthy bloom at the seed on the trunk/bass beat === + const bloom = Math.max(rootPulse, 0) * (1 + pump * 0.3); + if (bloom > 0.02) { + const R = Math.min(w, h) * (0.09 + bass * 0.35) * bloom; + const [r0, g0, b0] = hue2rgb(num, 0.1, 0.65, 0.32); + for (let i = 4; i > 0; i--) { + ink(r0, g0, b0, 30 * bloom).circle(bx, by, R * (i / 4), true); + } + } + + // === THE TREE — draw oldest→newest so tips sit on top. Recently-born + // branches glow. Deeper generations render thinner (self-similar taper). === + const now = beatCount; + // How many generations to render (quality-gated for perf). + const genLimit = generations.length; // grow-cap already bounds branch count + for (let g = 0; g < genLimit; g++) { + const genBranches = generations[g]; + if (!genBranches) continue; + for (const br of genBranches) { + const age = now - br.born; + const fresh = Math.max(0, 1 - age * 0.14); + const light = 0.3 + (1 - br.gen / (LOOP_LEN + 1)) * 0.18 + fresh * 0.24; + const [r, gc, b] = hue2rgb(num, br.hue, 0.8, Math.min(0.85, light)); + const a = 235 * treeFade; + // Thickness tapers with generation depth (trunk thick → twigs thin). + const baseTh = Math.max(1, (7 - br.gen * 0.9)) * (1 + trunkThick * 0.4); + const th = baseTh * (1 + fresh * 0.5 + bass * 0.4); + ink(r, gc, b, a).line(br.x0, br.y0, br.x1, br.y1, Math.max(1, th)); + // Highlight core down thicker limbs. + if (th > 2.5) { + const [hr, hg, hb] = hue2rgb(num, br.hue, 0.45, 0.72); + ink(hr, hg, hb, 110 * treeFade) + .line(br.x0, br.y0, br.x1, br.y1, Math.max(1, th * 0.4)); + } + // === Bud / flower at accented (or pumped) branch tips === + if (br.bud) { + const budGrow = Math.min(1, 0.3 + fresh); + const rad = (2.5 + budGrow * 4) * (1 + pump * 0.3); + const [pr, pg, pb] = hue2rgb(num, br.budHue, 0.9, 0.6); + ink(pr, pg, pb, 220 * treeFade).circle(br.x1, br.y1, rad, true); + ink(255, 240, 150, 200 * treeFade * fresh) + .circle(br.x1, br.y1, rad * 0.4, true); // pollen glint + } + } + } + + // === Growing TIPS — the frontier buds swell within each beat === + if (treeFade > 0.02 && frontier.length) { + const tipR = (2 + s.beatProgress * 3 + bass * 6) * (1 + pump * 0.35); + for (const t of frontier) { + ink(210, 255, 180, 200 * treeFade).circle(t.x, t.y, tipR, true); + ink(255, 255, 255, 150 * treeFade).circle(t.x, t.y, tipR * 0.45, true); + } + } + + // === TAP BUDS — the whole-piece "button" made visible as new tufts === + for (const b of buds) { + const [r, gc, bl] = hue2rgb(num, b.hue, 0.85, 0.55); + for (const sg of b.segs) { + ink(r, gc, bl, 235 * b.life).line(sg.x0, sg.y0, sg.x1, sg.y1, + Math.max(1, (2 + pump) * b.life)); + if (sg.bud) { + const [br2, bg2, bb2] = hue2rgb(num, b.hue + 0.05, 0.9, 0.62); + ink(br2, bg2, bb2, 230 * b.life).circle(sg.x1, sg.y1, 3 + pump, true); + } + } + ink(220, 255, 180, 200 * b.life).circle(b.x, b.y, 3 + pump, true); + } + + // === Flecks = pollen / leaf flickers === + for (const f of flecks) { + const [r, gc, b] = hue2rgb(num, f.hue, 0.7, 0.6); + ink(r, gc, b, 220 * f.life).circle(f.x, f.y, 1 + f.life * 2, true); + } + + // Air-band dew shimmer over the canopy (garnish when audio is live). + if (air > 0.05) { + ink(200, 255, 220, air * 80) + .circle(bx, by - Math.min(w, h) * 0.35, Math.min(w, h) * 0.22, false, 1); + } + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared singleton +// — each entry must re-assert THIS pad's config before the engine boot/sim/paint/ +// act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/wispo.mjs b/system/public/aesthetic.computer/disks/wispo.mjs new file mode 100644 index 0000000000..b5557dfa61 --- /dev/null +++ b/system/public/aesthetic.computer/disks/wispo.mjs @@ -0,0 +1,238 @@ +// wispo, 26.07.12 +// Lissajous / harmonograph pad — a thin wrapper over lib/pads.mjs (the shared +// pad engine: UTC-clock beat grid, `params[0]` rate override e.g. `wispo 0.5`, +// the tap/XY "pump", audio polling). This file only says what makes wispo wispo: +// its FM-bell score and its phosphor-curve paint. +// +// ALLEGORY: every audible note DRAWS a glowing Lissajous figure whose x/y +// frequency-ratio IS the note's interval over the root — so the curve's SHAPE +// literally is the harmony you hear. Bass = a slow global rotation + scale of +// the whole field; the beat = a fresh curve tracing on; neon phosphor trails +// (a translucent veil) let curves persist and fade. The visual IS the score. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A minor pentatonic climb + fall; each step's interval-over-root sets the +// Lissajous frequency ratio, so consonant intervals draw simple closed loops +// and wider intervals draw denser knots. +const SCORE = [ + "a2", "c3", "e3", "g3", "a3", "g3", "e3", "d3", + "a2", "c3", "e3", "a3", "b3", "a3", "g3", "e3", +]; +const BASS = ["a1", "a1", "f1", "f1", "c2", "c2", "e1", "e1"]; // half-time root + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(\d)$/.exec(n); + if (!m) return 48; + return (parseInt(m[2], 10) + 1) * 12 + NOTE_SEMI[m[1]]; +} +const ROOT = notePitch("a2"); +const SCORE_PITCHES = SCORE.map(notePitch); +const PITCH_MIN = Math.min(...SCORE_PITCHES); +const PITCH_MAX = Math.max(...SCORE_PITCHES); +const pitchNorm = (p) => (p - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// interval-over-root (semitones) → small integer freq ratio a:b for Lissajous. +// Map the interval to the nearest simple just-ratio so consonances draw simple +// curves. Fall back to (1 + semis mod 5) : 3 for anything exotic. +const JUST = { + 0: [1, 1], 3: [6, 5], 4: [5, 4], 5: [4, 3], 7: [3, 2], + 9: [5, 3], 10: [9, 5], 12: [2, 1], 14: [9, 4], 15: [12, 5], + 16: [5, 2], 17: [8, 3], 19: [3, 1], +}; +function lissRatio(pitch) { + const semis = ((pitch - ROOT) % 24 + 24) % 24; + const r = JUST[semis]; + if (r) return r; + return [1 + (semis % 5), 3]; +} + +// --- wispo-specific visual state (engine owns pump/bursts/rhythm) ----------- +// Each curve: { a, b, delta, hue, life, big, bright, spin, tap } — one per note. +let curves = []; +let fieldRot = 0; // slow global rotation, driven by bass +let fieldScale = 1; // slow global scale breath, driven by bass +let bassKick = 0; // per-bass-note flash + +const CONFIG = { + bpm: 96, + steps: SCORE.length, + drawBursts: false, // wispo draws its own tap glow in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.34, feedback: 0.62 }); + }, + + // A new UTC beat crossed — fire the FM-bell + trace its Lissajous curve. + onBeat({ idx, synth }) { + const s = ((idx % SCORE.length) + SCORE.length) % SCORE.length; + const note = SCORE[s]; + const pitch = notePitch(note); + const pn = pitchNorm(pitch); // 0 low .. 1 high + const pan = Math.sin((s / SCORE.length) * Math.PI * 2) * 0.7; + const [a, b] = lissRatio(pitch); + + // FM-ish bell: layered bell (sine+triangle) + a detuned pair of sines a + // few cents apart for shimmer beating + a soft harp attack transient. + voices.bell(synth, note, { beats: 1.4, volume: 0.32, pan }); + synth({ tone: note, type: "sine", beats: 1.1, attack: 0.004, decay: 0.85, volume: 0.14, pan: pan * 0.6 }); + synth({ tone: note, type: "triangle", beats: 0.7, attack: 0.002, decay: 0.6, volume: 0.1, pan: -pan * 0.6 }); + voices.pluck(synth, note, { beats: 0.5, decay: 0.4, volume: 0.16, pan }); + + // ALLEGORY: this note traces its Lissajous figure — ratio a:b = interval, + // hue ∝ pitch, size ∝ pitch (low = wide field, high = tight knot). + curves.push({ + a, b, + delta: (s / SCORE.length) * Math.PI, // phase → orientation + hue: 180 + pn * 200, + life: 1, + big: 1 - pn * 0.55, + bright: 0.4 + pn * 0.6, + spin: (s % 2 === 0 ? 1 : -1) * (0.15 + pn * 0.2), + tap: 0, + }); + + // Sub-bass root on the half-beat — the downbeat you feel AND see (it + // rotates + scales the whole field). + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.8, decay: 0.55, volume: 0.5 }); + bassKick = 1; + } + + voices.hat(synth, { tone: 7200, beats: 0.1, volume: 0.13 }); + }, + + onSim() { + bassKick *= 0.9; + fieldRot += 0.004 + bassKick * 0.02; // bass drives the slow global spin + fieldScale = 0.94 + 0.1 * Math.sin(fieldRot * 0.7) + bassKick * 0.08; + for (const c of curves) { + c.life -= 0.011; + c.delta += c.spin * 0.02; + c.tap *= 0.92; + } + curves = curves.filter((c) => c.life > 0); + if (curves.length > 20) curves = curves.slice(-20); + }, + + // Tap = a bespoke bright Lissajous knot at the tap point + an FM-bell boost + // (engine already bumped pump + pushed the burst; X→ratio/pitch, Y→size). + onTap({ x, y, ex, ey, synth, screen }) { + const a = 2 + Math.floor(x * 5); // X picks the x-frequency + const b = 2 + Math.floor((1 - y) * 4); // Y picks the y-frequency + curves.push({ + a, b, + delta: Math.atan2(ey - screen.height / 2, ex - screen.width / 2), + hue: x * 360, + life: 1.3, + big: 0.4 + (1 - y) * 0.5, + bright: 1, + spin: (x < 0.5 ? -1 : 1) * 0.35, + tap: 1, // marks it as a tap knot → drawn centered on the tap point + px: ex, + py: ey, + }); + const note = ["a", "c", "d", "e", "g"][Math.floor(x * 5)] + (2 + Math.floor((1 - y) * 4)); + voices.bell(synth, note, { beats: 0.9, volume: 0.4, pan: x * 2 - 1 }); + synth({ tone: note, type: "sine", beats: 0.7, attack: 0.003, decay: 0.7, volume: 0.2 * (1 - y), pan: x * 2 - 1 }); + voices.pluck(synth, note, { beats: 0.4, volume: 0.28, pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { ink, screen, num, blur } = api; + const { pump, beatProgress, bursts, amp, band, quality } = s; + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + + const bass = band("subBass"); + const energy = Math.min(2, bassKick * 0.7 + bass * 1.2 + amp * 0.6 + pump * 0.5); + + // Phosphor veil: translucent dark box → prior curves persist and fade + // (neon trails). Lighter veil when high-energy so trails linger a touch. + const veil = 40 - Math.min(22, energy * 16 + pump * 8); + ink(4, 3, 14, veil).box(0, 0, w, h); + + // BASS = a faint central bloom you can see swell with the sub. + if (bassKick > 0.02 || bass > 0.05) { + const bloom = Math.max(bassKick, bass * 1.2); + const bR = Math.min(w, h) * (0.08 + bloom * 0.22); + for (let i = 3; i > 0; i--) + ink(60, 30, 150, 14 * bloom).circle(cx, cy, bR * (i / 3), true); + } + + // ADAPTIVE QUALITY: scale BOTH the number of curves drawn AND the points + // per curve by ctx.quality so we hold 60fps. At quality=1 → full detail. + const q = quality; + const maxCurves = Math.max(3, Math.round(14 * q)); + const basePts = Math.max(28, Math.round(180 * q)); + const R = Math.min(w, h) * 0.42 * fieldScale; + + const drawn = curves.slice(-maxCurves); + for (const c of drawn) { + // tap knots sit on the tap point; score curves fill the rotating field. + const ox = c.tap ? c.px : cx; + const oy = c.tap ? c.py : cy; + const scale = (c.tap ? Math.min(w, h) * 0.16 : R) * (0.55 + c.big * 0.6) * + (0.9 + energy * 0.18); + // point count scales with life (fresh curves get more detail) + quality. + const pts = Math.max(20, Math.round(basePts * (0.5 + c.life * 0.5))); + const [r0, g0, b0] = num.hslToRgb( + ((c.hue % 360) + 360) % 360, + 100, + 52 + c.bright * 20, + ); + const alpha = 60 + 150 * c.life + c.tap * 40; + const rot = c.tap ? c.delta : fieldRot + c.delta; + const cr = Math.cos(rot), sr = Math.sin(rot); + + let px = null, py = null; + for (let i = 0; i <= pts; i++) { + const t = (i / pts) * Math.PI * 2; + // Lissajous: x = sin(a·t + δ), y = sin(b·t) — a:b is the interval. + let lx = Math.sin(c.a * t + c.delta) * scale; + let ly = Math.sin(c.b * t) * scale; + // rotate the whole figure (field spin for score, orient for taps). + const rx = lx * cr - ly * sr; + const ry = lx * sr + ly * cr; + const x = ox + rx, y = oy + ry; + if (px !== null) ink(r0, g0, b0, alpha).line(px, py, x, y); + px = x; py = y; + } + // bright leading dot at the trace head (a glowing phosphor tip). + const th = beatProgress * Math.PI * 2 * c.a + c.delta; + const hx = ox + (Math.sin(c.a * th + c.delta) * scale) * cr - + (Math.sin(c.b * th) * scale) * sr; + const hy = oy + (Math.sin(c.a * th + c.delta) * scale) * sr + + (Math.sin(c.b * th) * scale) * cr; + ink(255, 255, 255, 200 * c.life).circle(hx, hy, 2 + c.bright * 3 + c.tap * 3, true); + } + + // TAP GLOW (engine-tracked bursts): soft expanding halo at each tap. + for (const b of bursts) { + const [r0, g0, b0] = num.hslToRgb(((b.hue % 360) + 360) % 360, 95, 62); + ink(r0, g0, b0, 170 * b.life).circle(b.x, b.y, b.r, false, 2 + b.life * 3); + ink(255, 255, 255, 120 * b.life).circle(b.x, b.y, 3 + b.life * 10, true); + } + + if (energy > 1.1 || pump > 1.3) blur?.(1); + + // Center heartbeat — the pulse of the field. + const heartR = 3 + bassKick * 8 + bass * 10 + pump * 6; + ink(120, 90, 255, 80 + bassKick * 90).circle(cx, cy, heartR * 1.7, true); + ink(255, 255, 255, 150 + bassKick * 80).circle(cx, cy, heartR, true); + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/zorb.mjs b/system/public/aesthetic.computer/disks/zorb.mjs new file mode 100644 index 0000000000..ffa3b6d5c2 --- /dev/null +++ b/system/public/aesthetic.computer/disks/zorb.mjs @@ -0,0 +1,329 @@ +// zorb, 26.07.12 +// Reaction-diffusion DUB-TECHNO pad — a thin wrapper over lib/pads.mjs (the shared +// pad engine: UTC-clock beat grid, `params[0]` rate override e.g. `zorb 0.5`, the +// tap/XY "pump", audio polling). This file only describes what makes zorb zorb: its +// deep dub score, its richened voices, and its Gray-Scott Turing-pattern paint. +// +// ALLEGORY — the pattern is ALIVE and the music GROWS it. The sub-bass rides the +// feed rate F: louder bass = more nutrient = the spots/stripes bloom and spread. +// Every note SEEDS a fresh blot of chemical V at a scored spot — you watch it +// diffuse outward and fight the field. The kick is a DIFFUSION PULSE: a ring of +// agitation that ripples the whole tank, stirring the reaction. Deep, organic, +// hypnotic — the visual IS the reaction, and the reaction IS the groove. The whole +// screen is a button: tap injects a spreading blot + a sub thump at the tapped spot. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// Deep dub techno in F minor. Sub root walks slowly; a filtered stab answers +// off the sub; a chord swell breathes under it; hats tick the offbeats. Each +// step names a note; its index also picks WHERE on the field the blot is seeded +// (so the pattern grows from a repeatable, musical set of sites). +const SUB = ["f1", "f1", "f1", "f1", "ab1", "ab1", "c2", "c2"]; // half-time root walk +const STAB = [ + null, "f3", null, "ab3", null, "c4", null, "eb4", + null, "f3", null, "ab3", null, "eb4", null, "c4", +]; // dubby off-sub stab (rests = null) +const CHORD = ["f2", "ab2", "c3"]; // held Fm swell, breathes across the loop +const STEPS = 16; +const BAR = 4; // kick lands on step % BAR === 0 + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + if (!n) return 36; + const m = /^([a-g])(b?)(\d)$/.exec(n); + if (!m) return 36; + const semi = NOTE_SEMI[m[1]] - (m[2] ? 1 : 0); + return (parseInt(m[3], 10) + 1) * 12 + semi; +} +const pitchNorm = (n) => Math.max(0, Math.min(1, (notePitch(n) - 24) / (60 - 24))); + +// --- Reaction-diffusion field (Gray-Scott) ---------------------------------- +// Two chemicals U (substrate) + V (activator) on a small grid, scaled by quality. +// U starts full, V empty; seeds inject V. classic spot/worm params. +const DU = 0.16; // U diffusion +const DV = 0.08; // V diffusion +const F_BASE = 0.035; // base feed rate (bass rides this) +const K = 0.062; // kill rate (fixed → Turing spots/worms) + +let GW = 0, GH = 0; // current grid dims +let U = null, V = null, Un = null, Vn = null; +let gridQ = -1; // quality bucket the grid was allocated at +let chordVoices = null; // held pad-chord handles + +// zorb-specific reactive state (engine owns pump/bursts/rhythm) +let bass = 0; // eased low end → feed-rate lift (growth) +let kick = 0; // diffusion-pulse envelope (0..1) +let kickWave = 0; // expanding agitation ring (0..1.5) +let seedQueue = []; // pending seeds to stamp into the grid on next sim + +// Allocate / resize the RD grid. Coarser grid at low quality = big fps win. +function ensureGrid(q) { + // grid resolution buckets: high q = finer chemistry, low q = coarse. + const bucket = q >= 0.85 ? 2 : q >= 0.6 ? 1 : 0; + if (bucket === gridQ && U) return; + gridQ = bucket; + const target = bucket === 2 ? 150 : bucket === 1 ? 110 : 80; // longest side cells + const aspect = 9 / 16; // portrait-ish reels; harmless if square + GW = target; + GH = Math.max(40, Math.round(target * (aspect < 1 ? 1.4 : 1))); + const n = GW * GH; + U = new Float32Array(n); + V = new Float32Array(n); + Un = new Float32Array(n); + Vn = new Float32Array(n); + for (let i = 0; i < n; i++) { U[i] = 1; V[i] = 0; } + // Starter blots spread across the WHOLE field so the pattern reads rich from the + // opening (RD grows to fill from these, but a sparse start looks empty for bars). + for (let s = 0; s < 16; s++) { + stampSeed(0.08 + 0.84 * Math.random(), 0.06 + 0.88 * Math.random(), 2 + ((Math.random() * 3) | 0), 0.6); + } +} + +// Inject a disc of activator V (a "blot") at normalized (nx, ny). +function stampSeed(nx, ny, rad, strength) { + if (!V) return; + const gx = Math.round(nx * (GW - 1)); + const gy = Math.round(ny * (GH - 1)); + const r = Math.max(1, rad | 0); + for (let dy = -r; dy <= r; dy++) { + const yy = gy + dy; + if (yy < 0 || yy >= GH) continue; + for (let dx = -r; dx <= r; dx++) { + const xx = gx + dx; + if (xx < 0 || xx >= GW) continue; + if (dx * dx + dy * dy > r * r) continue; + const i = yy * GW + xx; + V[i] = Math.min(1, V[i] + strength); + U[i] = Math.max(0, U[i] - strength * 0.5); + } + } +} + +// One Gray-Scott step over the whole grid (wrap-around Laplacian, 5-point). +function stepRD(feed, kill, agitate) { + if (!U) return; + const f = Number.isFinite(feed) ? feed : F_BASE; + const kk = Number.isFinite(kill) ? kill : K; + for (let y = 0; y < GH; y++) { + const yl = ((y - 1 + GH) % GH) * GW; + const yr = ((y + 1) % GH) * GW; + const yc = y * GW; + for (let x = 0; x < GW; x++) { + const xl = (x - 1 + GW) % GW; + const xr = (x + 1) % GW; + const i = yc + x; + const u = U[i], v = V[i]; + // 5-point Laplacian (weights approximating the standard GS kernel). + const lapU = + U[yc + xl] + U[yc + xr] + U[yl + x] + U[yr + x] - 4 * u; + const lapV = + V[yc + xl] + V[yc + xr] + V[yl + x] + V[yr + x] - 4 * v; + const uvv = u * v * v; + let nu = u + (DU * lapU - uvv + f * (1 - u)); + let nv = v + (DV * lapV + uvv - (kk + f) * v); + // agitation from the kick pulse gives the field a little diffusive kick. + if (agitate) { nu += lapU * agitate; nv += lapV * agitate; } + if (!(nu >= 0)) nu = 0; else if (nu > 1) nu = 1; // NaN-safe clamp + if (!(nv >= 0)) nv = 0; else if (nv > 1) nv = 1; + Un[i] = nu; Vn[i] = nv; + } + } + const tu = U; U = Un; Un = tu; + const tv = V; V = Vn; Vn = tv; +} + +const CONFIG = { + bpm: 122, // classic dub-techno tempo + steps: STEPS, + drawBursts: false, // zorb renders its own tap blots into the field + hooks: { + onBoot({ sound }) { + sound?.room?.set?.({ enabled: true, mix: 0.45, feedback: 0.72 }); // deep dub space + bass = 0; kick = 0; kickWave = 0; seedQueue = []; + gridQ = -1; U = V = Un = Vn = null; + chordVoices = null; + ensureGrid(1); + }, + + // A new UTC beat crossed — fire the dub score + seed a fresh blot per note. + onBeat({ idx, synth }) { + const step = ((idx % STEPS) + STEPS) % STEPS; + + // Held Fm chord swell — a slow breathing bed. Start lazily (synth is only + // valid inside beat/tap hooks); retrigger each loop, killing the prior + // (infinite 🔁) handles first so voices don't stack. + if (!chordVoices || step === 0) { + if (chordVoices) for (const v of chordVoices) v?.kill?.(0.6); + chordVoices = voices.padChord(synth, CHORD, { + type: "sawtooth", volume: 0.05, attack: 1.2, decay: 1.0, spread: 0.4, + }); + } + + // SUB BASS (half-time) — the growth engine. Louder sub = more feed = spread. + if (step % 2 === 0) { + const bi = ((Math.floor(idx / 2) % SUB.length) + SUB.length) % SUB.length; + const sn = SUB[bi]; + voices.sub(synth, sn, { beats: 1.9, attack: 0.02, decay: 0.6, volume: 0.62 }); + bass = Math.min(1.4, bass + 0.85); + // sub seeds a low, wide blot near the base of the field + seedQueue.push({ nx: 0.5 + Math.sin(idx * 1.3) * 0.42, ny: 0.55 + Math.cos(idx * 0.7) * 0.32, rad: 4, str: 0.7 }); + } + + // FILTERED DUB STAB — off the sub, panned, short & resonant. + const stab = STAB[step]; + if (stab) { + const pn = pitchNorm(stab); + const pan = Math.sin((step / STEPS) * Math.PI * 2) * 0.6; + voices.pluck(synth, stab, { beats: 0.7, attack: 0.004, decay: 0.4, volume: 0.34, pan }); + voices.bell(synth, stab, { beats: 0.5, volume: 0.12, pan: -pan }); + // stab seeds a bright blot; hue-height maps to pitch (high = top). + seedQueue.push({ nx: 0.5 + pan * 0.5, ny: 0.2 + (1 - pn) * 0.5, rad: 3, str: 0.85 }); + } + + // KICK = a DIFFUSION PULSE that ripples the whole tank (dub 4-on-floor-ish). + if (step % BAR === 0) { + kick = 1; kickWave = 0; + voices.sub(synth, "f1", { beats: 0.5, attack: 0.005, decay: 0.26, volume: 0.7 }); + synth({ type: "noise-white", tone: 150, beats: 0.06, attack: 0.001, decay: 0.18, volume: 0.12 }); + } + + // Offbeat dub hats — the tick that swings the groove. + if (step % 2 === 1) { + voices.hat(synth, { tone: 7500, beats: 0.1, volume: 0.14, pan: (step % 4 === 1 ? 0.4 : -0.4) }); + } + }, + + onSim({ band, simMs }) { + // Envelopes: kick pulse + agitation ring, bass ease toward live low end. + kick *= 0.9; if (kick < 0.001) kick = 0; + kickWave = Math.min(1.5, kickWave + 0.05); + bass *= 0.95; if (!Number.isFinite(bass)) bass = 0; + bass = Math.max(bass, band("subBass") * 1.2); // live sub keeps growth breathing + + // Stamp any queued musical seeds into the grid before stepping. + for (const sd of seedQueue) stampSeed(sd.nx, sd.ny, sd.rad, sd.str); + seedQueue.length = 0; + + // ALLEGORY: bass rides the feed rate → louder low end grows the pattern. + const feed = F_BASE + bass * 0.014; // 0.035 (quiet) .. ~0.055 (booming) + // Kick injects a brief burst of extra diffusion → the whole field ripples. + const agitate = kick * 0.14; + // Two RD substeps per frame → the reaction actually evolves at tempo. + stepRD(feed, K, agitate); + stepRD(feed, K, agitate * 0.6); + }, + + // Tap = inject a spreading blot at the tapped point + a sub thump. + onTap({ x, y, synth, isDraw }) { + stampSeed(x, y, isDraw ? 3 : 5, isDraw ? 0.7 : 1.0); + bass = Math.min(1.5, bass + (isDraw ? 0.2 : 0.6)); + const scale = ["f", "ab", "c", "eb", "f"]; + const deg = Math.max(0, Math.min(scale.length - 1, Math.floor(x * scale.length))); + const oct = 1 + Math.floor((1 - y) * 2); + const note = scale[deg] + oct; + const pan = x * 2 - 1; + voices.sub(synth, note, { beats: isDraw ? 0.5 : 1.1, attack: 0.006, decay: 0.4, volume: (isDraw ? 0.4 : 0.65), pan }); + if (!isDraw) voices.pluck(synth, scale[deg] + (oct + 2), { beats: 0.5, volume: 0.28, pan }); + }, + + // Render the V (activator) field to screen.pixels — the Turing pattern IS the + // picture. Strided blocks + a coarser grid at low quality hold 60fps. hslToRgb + // is only ever called to build a small ramp LUT — NEVER per pixel. + onPaint(api, s) { + const { screen, num } = api; + const { pump, quality } = s; + const { width: w, height: h, pixels } = screen; + if (!w || !h || !pixels || !V) return; + + const q = quality ?? 1; + ensureGrid(q); // may swap the grid to a coarser/finer resolution + if (!V) return; + + // Pixel stride: bigger blocks when the engine trims quality. + const stride = q >= 0.98 ? 1 : q >= 0.85 ? 2 : 3; + + // Deep dub color: hue drifts slowly with bass; build a 64-entry ramp LUT so + // hslToRgb runs 64× per frame, not per pixel. + const baseHue = (180 + bass * 60) % 360; // teal→magenta as the bass swells + const LUT_N = 64; + const lutR = new Uint8Array(LUT_N); + const lutG = new Uint8Array(LUT_N); + const lutB = new Uint8Array(LUT_N); + for (let i = 0; i < LUT_N; i++) { + const t = i / (LUT_N - 1); // 0 = substrate/dark .. 1 = dense activator/hot + const hue = (baseHue + t * 90) % 360; // ramp across the tank + const light = 6 + t * t * 66; // dark background → bright vein + const sat = 60 + t * 35; + const [r, g, b] = num.hslToRgb(hue, sat, light); + lutR[i] = r; lutG[i] = g; lutB[i] = b; + } + + // Kick pulse ring: brighten a moving ring so the diffusion pulse READS. + const shockR = kickWave * Math.min(w, h) * 0.75; + const shockOn = kick > 0.02; + const cx = w * 0.5, cy = h * 0.5; + const glow = 1 + pump * 0.2 + bass * 0.25; + + const gx1 = GW - 1, gy1 = GH - 1; + for (let y = 0; y < h; y += stride) { + const gy = (y / h) * gy1; + const gyi = gy | 0; + const gyf = gy - gyi; + const row0 = gyi * GW; + const row1 = (gyi < gy1 ? gyi + 1 : gyi) * GW; + for (let x = 0; x < w; x += stride) { + const gxf0 = (x / w) * gx1; + const gxi = gxf0 | 0; + const gxf = gxf0 - gxi; + const gxr = gxi < gx1 ? gxi + 1 : gxi; + // Bilinear sample of V so the coarse grid reads smooth on screen. + const v00 = V[row0 + gxi], v10 = V[row0 + gxr]; + const v01 = V[row1 + gxi], v11 = V[row1 + gxr]; + const vt = v00 + (v10 - v00) * gxf; + const vb = v01 + (v11 - v01) * gxf; + let vv = vt + (vb - vt) * gyf; + if (!(vv >= 0)) vv = 0; else if (vv > 1) vv = 1; + + // Map activator density → LUT (a little gamma to fatten the veins). + let t = Math.pow(vv, 0.75) * glow; + if (t > 1) t = 1; + const li = (t * (LUT_N - 1)) | 0; + let R = lutR[li], G = lutG[li], B = lutB[li]; + + // Kick ring: add a bright agitation halo where the pulse is passing. + if (shockOn) { + const dd = num.dist(x, y, cx, cy) - shockR; + const ring = kick * 150 * Math.exp(-(dd * dd) / 520); + R = R + ring; G = G + ring * 0.9; B = B + ring; + } + + // Fill the stride×stride block (clamped at edges) with an ordered dither. + const yEnd = y + stride < h ? y + stride : h; + const xEnd = x + stride < w ? x + stride : w; + for (let by = y; by < yEnd; by++) { + const prow = by * w; + for (let bx = x; bx < xEnd; bx++) { + const idx = (prow + bx) * 4; + const dith = ((bx + by) & 1) * 6; + pixels[idx] = R > 255 ? 255 : R < dith ? 0 : R - dith; + pixels[idx + 1] = G > 255 ? 255 : G < dith ? 0 : G - dith; + pixels[idx + 2] = B > 255 ? 255 : B < dith ? 0 : B - dith; + pixels[idx + 3] = 255; + } + } + } + } + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act };