From c5385374eb8b5fc9b712d6d3fda417000a9d6304 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 12 Jul 2026 17:57:06 -0700 Subject: [PATCH] =?UTF-8?q?pads:=20+6=20new=20(batch=203/4)=20=E2=80=94=20?= =?UTF-8?q?trido=20boids=C3=97flute,=20varo=20moire=C3=97pad,=20nuxo=20str?= =?UTF-8?q?ing-art=C3=97harp,=20pledo=20LED-sequencer=C3=97house,=20sork?= =?UTF-8?q?=20iso-voxel=C3=97arcade,=20mibo=20texture-tunnel=C3=97psy=20?= =?UTF-8?q?=E2=80=94=20adaptive,=20onPaint=20work=20well=20under=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/aesthetic.computer/disks/mibo.mjs | 289 +++++++++++++ .../public/aesthetic.computer/disks/nuxo.mjs | 185 ++++++++ .../public/aesthetic.computer/disks/pledo.mjs | 238 +++++++++++ .../public/aesthetic.computer/disks/sork.mjs | 280 ++++++++++++ .../public/aesthetic.computer/disks/trido.mjs | 403 ++++++++++++++++++ .../public/aesthetic.computer/disks/varo.mjs | 213 +++++++++ 6 files changed, 1608 insertions(+) create mode 100644 system/public/aesthetic.computer/disks/mibo.mjs create mode 100644 system/public/aesthetic.computer/disks/nuxo.mjs create mode 100644 system/public/aesthetic.computer/disks/pledo.mjs create mode 100644 system/public/aesthetic.computer/disks/sork.mjs create mode 100644 system/public/aesthetic.computer/disks/trido.mjs create mode 100644 system/public/aesthetic.computer/disks/varo.mjs diff --git a/system/public/aesthetic.computer/disks/mibo.mjs b/system/public/aesthetic.computer/disks/mibo.mjs new file mode 100644 index 0000000000..9cf627eae6 --- /dev/null +++ b/system/public/aesthetic.computer/disks/mibo.mjs @@ -0,0 +1,289 @@ +// mibo, 26.07.12 +// Texture-tunnel psy-trance pad — a thin wrapper over lib/pads.mjs (the shared +// pad engine: UTC-clock beat grid, `params[0]` rate override e.g. `mibo 0.5`, +// the tap/XY "pump", audio polling, adaptive ctx.quality). This file only +// describes what makes mibo mibo: its rolling psy score and its true PER-PIXEL +// raycast tunnel (angle/depth → a checker/spiral texture, flying forward). +// +// ALLEGORY — you fly forward through an infinite patterned tunnel: +// • ARP (rolling 16th-note psy line) = the tunnel's RING BANDS lighting up. +// Each note lights a band at a depth; PITCH → the band's HUE. The arp also +// drives the tunnel SPEED (a running note flies you faster). +// • BASS = the tunnel WIDTH PULSE — each root breathes the walls in/out. +// • BEAT = a BRIGHT RING rushing past you (a light band accelerating outward). +// • TAP = a bright ring pulse punched down the tunnel + a lead stab at the tap +// (X → pan + hue, Y → pitch). +// Distinct from vroon (feedback-zoom radial streaks): here every pixel is a true +// angle/depth texture lookup with a checker/spiral pattern — a real tunnel. +// +// PER-PIXEL + 60fps: angle/depth are precomputed per pixel ONCE (rebuilt only on +// resize) and hue is a precomputed LUT (never hslToRgb per pixel). The paint loop +// STRIDES by ctx.quality and fills stride×stride blocks to hold 60fps native. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// 150 BPM psy roll. 16 sixteenth-steps per bar; the arp rolls the classic +// psy shape (root pedal + offbeat climbs). Pitch → ring-band hue. +const BPM = 150; +const ARP = [ + "e2", "e3", "e2", "b2", "e2", "g3", "e2", "b2", + "e2", "d3", "e2", "a2", "e2", "c3", "e2", "fs3", +]; +const BASS = ["e1", "e1", "c1", "g1"]; // per-bar root motion (width pulse) +const LEAD = ["e4", "g4", "b4", "d5", "e5", "d5", "b4", "g4"]; // squelch accents + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(s?)(\d)$/.exec(n); + if (!m) return 48; + return (parseInt(m[3], 10) + 1) * 12 + NOTE_SEMI[m[1]] + (m[2] ? 1 : 0); +} +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); + +// --- mibo-specific state (the engine owns pump/bursts/rhythm) --------------- +// Ring bands lit by notes: each rides OUTWARD (toward the viewer) down the +// tunnel over its life. { depth (0 far..1 near), hue, life, bright } +let bands = []; +let widthPulse = 0; // bass breathing the tunnel walls, decays +let speedBoost = 0; // extra fly-forward speed from the arp/taps, decays +let scrollPhase = 0; // accumulated depth-scroll (fly-forward), advances in onSim +let bright = 0; // whole-tunnel flash on the beat, decays + +// --- Precomputed per-pixel geometry + hue LUT (built once / on resize) ------- +let geoW = 0, geoH = 0; +let angLUT = null; // per-pixel tunnel angle (0..1 around) +let depLUT = null; // per-pixel tunnel depth (0 near center-edge..big at center) +let radLUT = null; // per-pixel normalized radius (0 center..1 corner) for width pulse +const HUE_LUT = new Array(360); // hue(0..359) -> [r,g,b] (bright, saturated) + +function buildHueLUT(num) { + for (let h = 0; h < 360; h++) HUE_LUT[h] = num.hslToRgb(h, 95, 58); +} + +function buildGeometry(w, h) { + geoW = w; geoH = h; + const n = w * h; + angLUT = new Float32Array(n); + depLUT = new Float32Array(n); + radLUT = new Float32Array(n); + const cx = w / 2, cy = h / 2; + const invTwoPi = 1 / (Math.PI * 2); + const maxR = Math.hypot(cx, cy); + const SCALE = maxR * 0.5; // depth = SCALE/r → concentric rings dense at center + let i = 0; + for (let y = 0; y < h; y++) { + const dy = y - cy; + for (let x = 0; x < w; x++, i++) { + const dx = x - cx; + const r = Math.hypot(dx, dy) + 0.0001; + // angle around the tunnel, 0..1 + const a = Math.atan2(dy, dx) * invTwoPi + 0.5; // 0..1 + angLUT[i] = a; + // classic raycast tunnel: DEPTH = SCALE / r. Small near the walls (large r), + // LARGE toward the vanishing point (small r) — concentric depth rings that + // bunch up at the center, exactly like flying down a pipe. + depLUT[i] = SCALE / r; + radLUT[i] = r / maxR; // 0 center .. 1 corner (wall) + } + } +} + +const CONFIG = { + bpm: BPM, + steps: ARP.length, + drawBursts: false, // mibo draws its own tunnel rings + hooks: { + onBoot({ sound, num }) { + sound.room?.set?.({ enabled: true, mix: 0.22, feedback: 0.5 }); + buildHueLUT(num); + }, + + // A new UTC beat crossed — fire the psy score + light a ring band. + onBeat({ idx, synth }) { + const s = ((idx % ARP.length) + ARP.length) % ARP.length; + const note = ARP[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const isRoot = s % 2 === 0; // the pedal-root 16ths + + // ROLLING PSY ARP: raw detuned saws (16th-note roll), pitch → hue band. + const pan = Math.sin((s / ARP.length) * Math.PI * 2) * 0.5; + synth({ tone: note, type: "sawtooth", beats: 0.14, attack: 0.002, + decay: 0.32, volume: 0.3, pan }); + synth({ tone: note, type: "sawtooth", beats: 0.12, attack: 0.002, + decay: 0.3, volume: 0.16, pan: -pan }); // detuned partner via opposite pan + + // ALLEGORY: this note lights a RING BAND far down the tunnel (depth ~0), + // which then flies outward toward the viewer. Pitch → hue. + bands.push({ depth: 0.04, hue: (20 + pn * 300) % 360, life: 1, bright: 0.5 + pn * 0.5 }); + speedBoost = Math.min(1.6, speedBoost + 0.22); // running note → fly faster + + // Squelchy lead accent on the offbeats (the psy "acid" bites). + if (!isRoot) { + const li = ((Math.floor(idx / 2) % LEAD.length) + LEAD.length) % LEAD.length; + voices.pluck(synth, LEAD[li], { beats: 0.22, decay: 0.4, volume: 0.22, pan: -pan }); + } + + // BASS = tunnel WIDTH PULSE, once per beat of the bar (every 4 steps). + if (s % 4 === 0) { + const bi = ((Math.floor(idx / 4) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 0.9, decay: 0.5, volume: 0.5 }); + widthPulse = 1; + } + + // BEAT = bright ring rushing past + driving hat. + voices.hat(synth, { tone: 9000, beats: 0.08, volume: 0.14 }); + if (isRoot) voices.hat(synth, { tone: 4000, beats: 0.05, volume: 0.08, pan }); + bright = 1; + }, + + onSim(api) { + const { simMs } = api; + // Fly forward: depth-scroll advances with a base speed + arp/tap boost. + // Tie the base rate to simMs delta so it's frame-rate independent. + const dt = 1 / 60; // paint/sim ~60fps; boost dominates the feel anyway + scrollPhase += (0.5 + speedBoost) * dt; + speedBoost *= 0.92; + widthPulse *= 0.9; + bright *= 0.88; + for (const b of bands) { + b.depth += 0.02 + speedBoost * 0.03; // fly the band toward the viewer + b.life -= 0.02; + } + bands = bands.filter((b) => b.life > 0 && b.depth < 1.4); + if (bands.length > 20) bands = bands.slice(-20); + }, + + // Tap = a bright ring PUNCHED down the tunnel + a lead stab at the tap. + // X → pan + hue, Y → pitch. + onTap({ x, y, synth }) { + bands.push({ depth: 0.02, hue: (x * 360) % 360, life: 1.3, bright: 1 }); + speedBoost = Math.min(1.8, speedBoost + 0.5); + bright = 1; + const note = ["e", "g", "a", "b", "d"][Math.floor(x * 5) % 5] + (3 + Math.floor((1 - y) * 3)); + voices.pluck(synth, note, { beats: 0.5, decay: 0.45, volume: 0.5, pan: x * 2 - 1 }); + voices.bell(synth, note, { beats: 0.3, volume: 0.28 * (1 - y), pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { screen, num } = api; + const { quality, amp, pump } = s; + const bass = s.band("subBass"); + const w = screen.width, h = screen.height; + const pix = screen.pixels; // Uint8ClampedArray RGBA + if (!pix) return; + + // (Re)build per-pixel geometry + hue LUT if the screen resized. + if (w !== geoW || h !== geoH || !angLUT) buildGeometry(w, h); + if (!HUE_LUT[0]) buildHueLUT(num); + + // STRIDE by quality — the whole cost is per-pixel, so this is the knob. + const stride = quality < 0.55 ? 3 : quality < 0.8 ? 2 : 1; + + // Tunnel params. Width pulse (bass) breathes the walls: scale depth so the + // rings squeeze in/out (the pipe gets wider/narrower). Beat lifts brightness. + const scroll = scrollPhase; // fly-forward: rings stream OUT from the center + const widthMod = 1 + widthPulse * 0.55 + bass * 0.7; // walls breathe + const RINGS = 5.0; // concentric ring frequency down the tunnel + const SEGMENTS = 10; // angular checker segments (spiral bricks) + const flash = bright; + + // BAND LIGHTING: each fired note lit a ring at b.depth (0 far..~1.3 near); + // it flies outward. In tunnel coords the pixel's ring index is + // frac(v)≈position along the pipe, and the ring's screen position tracks its + // depth. We key band glow on the SAME concentric ring index the texture uses, + // so a lit band appears as a colored concentric ring rushing outward. + // Precompute a small LUT keyed on ring-index bucket (cheap per-pixel read). + const NB = 48; + const bandHue = new Float32Array(NB); + const bandLit = new Float32Array(NB); + for (const b of bands) { + // b.depth 0..~1.3 maps to a ring index that decreases (moves outward) as + // it approaches. Use scroll-relative index so it aligns with the texture. + const ringIdx = (scroll - b.depth * RINGS * 1.4); + let bk = (((ringIdx % 1) + 1) % 1) * NB | 0; + for (let k = -2; k <= 2; k++) { + const idx = ((bk + k) % NB + NB) % NB; + const fall = 1 - Math.abs(k) * 0.35; + const contrib = b.life * b.bright * fall; + if (contrib > bandLit[idx]) { bandLit[idx] = contrib; bandHue[idx] = b.hue; } + } + } + + for (let y = 0; y < h; y += stride) { + const rowBase = y * w; + for (let x = 0; x < w; x += stride) { + const i = rowBase + x; + const ang = angLUT[i]; + const dep = depLUT[i] * widthMod; // SCALE/r, breathing — big at center + // v = depth coordinate down the pipe (grows toward vanishing point); + // scroll ADDS so rings stream outward (fly forward). u = angular bricks + // that twist with depth → the classic spiral. + const v = dep * RINGS + scroll; + const u = ang * SEGMENTS + dep * 1.5; + + // CHECKER/SPIRAL texture: xor of ring parity and angular parity. + const checker = ((v | 0) ^ (u | 0)) & 1; + + // Tunnel fog: WALLS (large r → radLUT→1, small dep) are NEAR and BRIGHT; + // the vanishing point (center, small r, huge dep) is FAR and DARK. + const wall = radLUT[i]; // 0 center .. 1 wall + const near = wall * wall; // brightness toward the walls (viewer) + const texBase = checker ? 1.0 : 0.32; + const lum = (0.12 + near * 0.95) * texBase * (0.72 + flash * 0.6); + + // Which concentric ring bucket is this pixel in? + const bk = (((v % 1) + 1) % 1) * NB | 0; + const lit = bandLit[bk]; + let r, g, bl; + if (lit > 0.06) { + const c = HUE_LUT[((bandHue[bk] | 0) % 360 + 360) % 360] || HUE_LUT[0]; + const glow = Math.min(1.6, lit * (0.5 + near)); + r = Math.min(255, c[0] * glow * (0.5 + lum) + 25); + g = Math.min(255, c[1] * glow * (0.5 + lum) + 12); + bl = Math.min(255, c[2] * glow * (0.5 + lum) + 32); + } else { + // untinted pipe wall — cool teal/indigo checker, warm rim at the walls. + r = Math.min(255, (18 + 90 * near) * lum * 2.0); + g = Math.min(255, (14 + 40 * near) * lum * 2.0); + bl = Math.min(255, (44 + 120 * near) * lum * 2.0); + } + + // Center bloom — the vanishing point glows/pulses with beat + pump. + if (wall < 0.16) { + const core = 1 - wall / 0.16; + const cg = core * core * (0.25 + flash * 0.6 + pump * 0.18 + amp * 0.5); + r = Math.min(255, r + cg * 210); + g = Math.min(255, g + cg * 170); + bl = Math.min(255, bl + cg * 255); + } + + // Fill the stride×stride block with this sample. + const yMax = Math.min(h, y + stride); + const xMax = Math.min(w, x + stride); + const R = r | 0, G = g | 0, B = bl | 0; + for (let yy = y; yy < yMax; yy++) { + let p = (yy * w + x) * 4; + for (let xx = x; xx < xMax; xx++, p += 4) { + pix[p] = R; pix[p + 1] = G; pix[p + 2] = B; pix[p + 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 }; diff --git a/system/public/aesthetic.computer/disks/nuxo.mjs b/system/public/aesthetic.computer/disks/nuxo.mjs new file mode 100644 index 0000000000..34e551caa7 --- /dev/null +++ b/system/public/aesthetic.computer/disks/nuxo.mjs @@ -0,0 +1,185 @@ +// nuxo, 26.07.12 +// String-art harp pad — a thin wrapper over lib/pads.mjs (the shared pad engine: +// UTC-clock beat grid, `params[0]` rate override e.g. `nuxo 0.5`, tap/XY "pump", +// audio polling). This file only describes what makes nuxo nuxo: its pentatonic +// harp score, its richened pluck/bell/sub voices, and its nail-and-thread paint. +// ALLEGORY: pegs ring the border; every arpeggio note strings a NEW colored +// thread between two pegs (pitch → peg-pair offset). The accumulating straight +// lines envelope a curve (cardioid/astroid). Bass = global thread TENSION +// (brightness/hue shift); the beat makes threads shimmer. Threads accumulate +// then fade at the loop so it seams. The visual IS the score. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// Pentatonic (A minor pent: a c d e g), elegant, harp-plucked. +const ARP = [ + "a3", "c4", "e4", "g4", "a4", "e4", "c4", "d4", + "a3", "d4", "g4", "a4", "c5", "a4", "g4", "e4", +]; +const BASS = ["a1", "a1", "f1", "f1", "c2", "c2", "e1", "e1"]; // half-time root +const PEGS = 36; // nails evenly spaced around the border ring + +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); + +// --- nuxo-specific visual state (engine owns pump/bursts/rhythm) ------------- +let threads = []; // { a, b, hue, life, bright } — one straight thread per note +let tension = 0; // global bass tension: brightens/shifts every thread +let shimmer = 0; // whole-web shimmer, kicked each beat +const BASE_THREADS = 44; // full-quality live thread cap (scaled by ctx.quality) + +const CONFIG = { + bpm: 120, + steps: ARP.length, + drawBursts: false, // nuxo strings its own tap thread + glow in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.3, feedback: 0.55 }); + }, + + // A new UTC beat crossed — pluck the harp + string the matching thread. + onBeat({ idx, synth }) { + 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; + + // Harp: Karplus–Strong pluck, the string being strung. + voices.pluck(synth, note, { beats: 1.0, decay: 0.6, volume: 0.5, pan }); + + // ALLEGORY: string a thread between two pegs. Peg A walks the ring by step; + // peg B is offset by pitch, so higher notes reach farther across — the set + // of chords carves the enveloping curve. + const a = s * Math.round(PEGS / ARP.length); + const b = (a + 5 + Math.round(pn * (PEGS - 12))) % PEGS; + threads.push({ a, b, hue: 30 + pn * 280, life: 1, bright: 0.4 + pn * 0.6 }); + + // Shimmer bell an octave feel on the high notes. + if (pn > 0.6) voices.bell(synth, note, { beats: 0.5, volume: 0.16, pan: -pan }); + + // Sub-bass root on the half-beat = the tension 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.6, decay: 0.5, volume: 0.5 }); + tension = 1; + } + + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.13 }); + shimmer = 1; + }, + + onSim({ quality }) { + tension *= 0.9; + shimmer *= 0.88; + // Fade threads SLOWLY so a full loop's worth accumulates into a visible + // envelope curve; fade FASTER when over the quality-scaled cap so the + // oldest clear out and the web loops cleanly. + const cap = Math.max(10, Math.round(BASE_THREADS * (quality ?? 1))); + const fade = threads.length > cap ? 0.03 : 0.0016; + for (const t of threads) t.life -= fade; + threads = threads.filter((t) => t.life > 0); + if (threads.length > cap) threads = threads.slice(-cap); + }, + + // Tap = string a bright thread from the NEAREST peg to the tap point + a harp + // pluck (engine already bumped pump). X→pan/hue, Y→pitch. + onTap({ x, y, ex, ey, synth, screen }) { + // Find nearest peg to the tap, string a thread from it to the tap point. + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + const rx = w * 0.44, ry = h * 0.44; + let best = 0, bestD = Infinity; + for (let i = 0; i < PEGS; i++) { + const ang = (i / PEGS) * Math.PI * 2 - Math.PI / 2; + const px = cx + Math.cos(ang) * rx, py = cy + Math.sin(ang) * ry; + const d = (px - ex) * (px - ex) + (py - ey) * (py - ey); + if (d < bestD) { bestD = d; best = i; } + } + threads.push({ a: best, tap: [ex, ey], hue: x * 360, life: 1.3, bright: 1 }); + + const note = ["a", "c", "d", "e", "g"][Math.floor(x * 5)] + (3 + Math.floor((1 - y) * 3)); + voices.pluck(synth, note, { beats: 0.7, volume: 0.55, pan: x * 2 - 1 }); + voices.bell(synth, note, { beats: 0.35, volume: 0.3 * (1 - y), pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band } = s; + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + const rx = w * 0.44, ry = h * 0.44; + + const bass = band("subBass"); + const energy = Math.min(2, tension * 0.7 + bass * 1.1 + amp * 0.6 + pump * 0.5); + + // Peg positions around the elliptical border ring. + const peg = (i) => { + const ang = (i / PEGS) * Math.PI * 2 - Math.PI / 2; + return [cx + Math.cos(ang) * rx, cy + Math.sin(ang) * ry]; + }; + + // Dark ground. + ink(6, 5, 14).box(0, 0, w, h); + + // The nails: faint dots around the border, brighter with tension. + const nailA = 40 + tension * 90 + shimmer * 60; + for (let i = 0; i < PEGS; i++) { + const [px, py] = peg(i); + ink(120, 130, 200, nailA).circle(px, py, 1.5 + shimmer * 1.5, true); + } + + // The threads: straight lines between pegs. Bass tension brightens + shifts + // hue; beat shimmer flickers alpha. Their accumulation envelopes a curve. + for (const t of threads) { + const [ax, ay] = peg(t.a); + const [bx, by] = t.tap ? t.tap : peg(t.b); + const hue = ((t.hue + tension * 40) % 360 + 360) % 360; + const light = 52 + t.bright * 18 + tension * 12; + const [r, g, b] = num.hslToRgb(hue, 95, Math.min(90, light)); + const a = (60 + 150 * t.life) * (0.75 + shimmer * 0.25); + ink(r, g, b, a).line(ax, ay, bx, by); + // Bright core over-stroke for the freshest threads → crisp line art. + if (t.life > 0.6) { + const [r2, g2, b2] = num.hslToRgb(hue, 60, 88); + ink(r2, g2, b2, 90 * t.life).line(ax, ay, bx, by); + } + // Glinting endpoints where the thread meets the nail. + ink(255, 255, 255, 160 * t.life).circle(ax, ay, 1.5 + t.bright, true); + ink(255, 255, 255, 130 * t.life).circle(bx, by, 1.5, true); + } + + // Central tension bloom — the whole web pulls toward here on the bass. + if (tension > 0.02 || bass > 0.04) { + const bloom = Math.max(tension, bass * 1.1); + const bR = Math.min(w, h) * (0.02 + bloom * 0.08); + ink(150, 90, 210, 30 * bloom).circle(cx, cy, bR, true); + } + + // Tap glow handled inline via the tap threads above; add a soft flash on pump. + if (pump > 0.05) { + const [r, g, b] = num.hslToRgb((pump * 60) % 360, 90, 65); + ink(r, g, b, 40 * Math.min(1, pump)).circle(cx, cy, Math.min(w, h) * 0.5, 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/pledo.mjs b/system/public/aesthetic.computer/disks/pledo.mjs new file mode 100644 index 0000000000..786a077947 --- /dev/null +++ b/system/public/aesthetic.computer/disks/pledo.mjs @@ -0,0 +1,238 @@ +// pledo, 26.07.12 +// Dot-matrix / LED-grid step SEQUENCER × house piano + four-on-the-floor — a +// thin wrapper over lib/pads.mjs (the shared pad engine: UTC-clock beat grid, +// `params[0]` rate override e.g. `pledo 0.25`, tap/XY "pump", audio polling). +// ALLEGORY: the glowing LED grid IS the sequencer. A playhead COLUMN sweeps +// left→right locked to the beat (ctx.step = which column; ctx.beatProgress = +// smooth sub-column glide). Lit cells in each column ARE the notes scheduled +// there — rows = pitches (top = high, bottom = low). The bottom row is the kick +// LED strip pulsing four-on-the-floor. You READ the sequence right off the grid. +// Chunky retro LEDs (rounded dots + bloom). Vector grid = cheap → holds 60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Grid dimensions -------------------------------------------------------- +const STEPS = 16; // columns (one 4/4 bar of 16th notes) +const ROWS = 8; // pitch rows (top row = high, bottom row = kick strip) + +// --- Score: a classic-house A-minor pattern read as grid rows --------------- +// Rows are indexed 0 (top / highest) .. ROWS-1 (bottom / kick). Each row maps +// to a pitch; the sequencer PATTERN below lights cells and those exact notes +// fire. Piano rows (0..ROWS-2) hold a Rhodes/piano stab voicing on the off-beat +// "and" of each beat (the house piano bounce); the bottom row is the kick. +const ROW_NOTES = [ + "e5", // 0 top — shimmer + "c5", // 1 + "a4", // 2 + "g4", // 3 + "e4", // 4 + "c4", // 5 + "a3", // 6 — chord bass + "a1", // 7 bottom = KICK strip (pitch only used for the sub thump) +]; +const KICK_ROW = ROWS - 1; + +// PATTERN[col] = array of row indices lit in that column. +// Kick (row 7) on every downbeat → four-on-the-floor (cols 0,4,8,12). +// Piano stabs land on the classic house off-beat "&" (cols 2,6,10,14) as an +// Am chord voicing, with a small ii/IV colour move across the bar so it grooves. +const PATTERN = Array.from({ length: STEPS }, () => []); +// four-on-the-floor kick +for (let c = 0; c < STEPS; c += 4) PATTERN[c].push(KICK_ROW); +// house piano off-beat stabs (rows spelling the chord), colour-shifted per hit +const STAB_COLS = [2, 6, 10, 14]; +const CHORD_A = [2, 4, 5, 6]; // a4 e4 c4 a3 (Am) +const CHORD_F = [1, 3, 5, 6]; // c5 g4 c4 a3 (F add) +const CHORD_G = [0, 3, 4, 6]; // e5 g4 e4 a3 (G-ish) +const CHORD_E = [2, 3, 4, 5]; // a4 g4 e4 c4 (Em/Am7) +const STAB_CHORDS = [CHORD_A, CHORD_F, CHORD_G, CHORD_E]; +STAB_COLS.forEach((c, i) => PATTERN[c].push(...STAB_CHORDS[i])); + +// Row → pitch class → hue (top rows warm/bright, low rows cool/deep). +const rowHue = (r) => 20 + (1 - r / (ROWS - 1)) * 280; // 20 (low) .. 300 (high) + +// --- pledo-specific visual state (engine owns pump/bursts/rhythm) ----------- +// cells[col][row] = glow 0..1. onBeat lights a column's pattern cells; taps +// light individual cells; glow decays each sim so lit notes shimmer + fade. +const cells = Array.from({ length: STEPS }, () => new Float32Array(ROWS)); +let kickFlash = 0; // whole-bottom-strip flash on each kick +let barFlash = 0; // faint frame flash on the downbeat + +const CONFIG = { + bpm: 124, // classic house tempo + steps: STEPS, + drawBursts: false, // pledo draws its own tap glow in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.22, feedback: 0.5 }); + }, + + // A new UTC beat crossed → this is the playhead landing on column `s`. + // Fire exactly the notes lit in that column, and light those same cells so + // the grid always shows the truth of what's sounding. + onBeat({ idx, synth }) { + const s = ((idx % STEPS) + STEPS) % STEPS; + const lit = PATTERN[s]; + if (!lit.length) return; + + const isKick = lit.includes(KICK_ROW); + if (isKick) { + // Four-on-the-floor: warm sub kick + the bottom LED strip flashes. + voices.sub(synth, ROW_NOTES[KICK_ROW], { beats: 0.9, decay: 0.42, volume: 0.6 }); + cells[s][KICK_ROW] = 1; + kickFlash = 1; + barFlash = Math.max(barFlash, 0.6); + // Closed hat on every downbeat, open hat on the back half of the bar. + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.14 }); + if (s === 8) voices.hat(synth, { tone: 6500, beats: 0.4, volume: 0.12 }); // open hat + } + + // House piano stab: the lit chord rows ring together as a Rhodes-ish stab + // (bell shimmer + a detuned sine/triangle body), panned by column. + const chordRows = lit.filter((r) => r !== KICK_ROW); + if (chordRows.length) { + const pan = ((s / STEPS) * 2 - 1) * 0.5; + chordRows.forEach((r, i) => { + const note = ROW_NOTES[r]; + const v = 0.16 - i * 0.02; + // Rhodes/piano body: detuned sine + triangle (warm, quick decay). + voices.bell(synth, note, { beats: 0.5, volume: v, pan }); + synth({ tone: note, type: "triangle", beats: 0.42, attack: 0.003, decay: 0.6, volume: v * 0.7, pan }); + synth({ tone: note, type: "sine", beats: 0.5, attack: 0.002, decay: 0.55, volume: v * 0.5, pan: -pan }); + cells[s][r] = 1; + }); + // Offbeat open-hat tick riding the stabs → that house shuffle. + voices.hat(synth, { tone: 5000, beats: 0.16, volume: 0.08 }); + } + }, + + onSim() { + kickFlash *= 0.86; + barFlash *= 0.9; + for (let c = 0; c < STEPS; c++) + for (let r = 0; r < ROWS; r++) if (cells[c][r] > 0.001) cells[c][r] *= 0.9; + }, + + // Tap = light the cell under the finger + play that cell's note. + // X → column (which step), Y → row (which pitch). You're punching the + // sequencer with your finger; the grid remembers the glow, the note rings. + onTap({ x, y, synth }) { + const col = Math.max(0, Math.min(STEPS - 1, Math.floor(x * STEPS))); + const row = Math.max(0, Math.min(ROWS - 1, Math.floor(y * ROWS))); + cells[col][row] = 1.4; + const note = ROW_NOTES[row]; + const pan = x * 2 - 1; + if (row === KICK_ROW) { + voices.sub(synth, note, { beats: 0.9, decay: 0.42, volume: 0.7 }); + kickFlash = 1; + } else { + voices.bell(synth, note, { beats: 0.6, volume: 0.4, pan }); + synth({ tone: note, type: "triangle", beats: 0.5, attack: 0.003, decay: 0.6, volume: 0.24, pan }); + } + }, + + onPaint(api, s) { + const { ink, screen, num, blur } = api; + const { step, beatProgress, pump, amp, quality } = s; + const { width: w, height: h } = screen; + + // Dark LED-panel backdrop (faint blue veil = phosphor persistence). + ink(4, 5, 12).box(0, 0, w, h); + if (barFlash > 0.02) ink(30, 20, 50, 40 * barFlash).box(0, 0, w, h); + + // Grid geometry: centred panel with margins. Dot-matrix LEDs are SQUARE + // boxes (cheap immediate-mode fills) so the whole 16×8 panel is nearly + // free — only the handful of LIT cells pay for round bloom halos. This is + // what lets pledo hold 60fps native (the socket floor was the killer). + const marginX = w * 0.06; + const marginY = h * 0.08; + const gw = w - marginX * 2; + const gh = h - marginY * 2; + const cellW = gw / STEPS; + const cellH = gh / ROWS; + const dotS = Math.min(cellW, cellH) * 0.6; // LED box side + const half = dotS / 2; + const energy = Math.min(1.6, amp * 1.2 + pump * 0.4); + + // Precompute row colors + centers once (not per cell). + const rowRGB = []; + const rowCY = []; + for (let r = 0; r < ROWS; r++) { + rowRGB[r] = num.hslToRgb(rowHue(r), 90, 55); + rowCY[r] = marginY + r * cellH + cellH / 2; + } + + // Smooth playhead position (column + sub-column glide) for a swept beam. + const playX = marginX + (step + beatProgress) * cellW + cellW / 2; + + // --- Playhead COLUMN beam (draw first, behind the dots) --------------- + // A bright vertical bar sweeping L→R IS the beat. Bloom scales with q. + const bloomLayers = quality > 0.7 ? 3 : quality > 0.5 ? 2 : 1; + for (let i = bloomLayers; i > 0; i--) { + const bw = cellW * (0.5 + i * 0.55); + const a = (18 + energy * 22) / i; + ink(120, 180, 255, a).box(playX - bw / 2, marginY - cellH * 0.3, bw, gh + cellH * 0.6); + } + ink(200, 230, 255, 60 + energy * 40).box(playX - 1.5, marginY, 3, gh); + + // --- The LED cells (square boxes = cheap; lit cells get round bloom) --- + const litHalo = quality > 0.5; // only draw halos when we have headroom + const bigHalo = quality > 0.78; + for (let r = 0; r < ROWS; r++) { + const cy = rowCY[r]; + const isKickRow = r === KICK_ROW; + const [hr, hg, hb] = rowRGB[r]; + for (let c = 0; c < STEPS; c++) { + const cx = marginX + c * cellW + cellW / 2; + const glow = cells[c][r]; + const under = c === step; // this column is under the playhead now + const scheduled = PATTERN[c].includes(r); + + if (glow < 0.02) { + // Unlit socket = dim square LED. Scheduled cells glow a touch + // brighter so the empty grid still reads as a sequencer pattern; + // the cell under the playhead gets a scheduled-note tint. + const lit = scheduled ? (under ? 40 : 24) : 12; + ink(lit * 0.5, lit * 0.6, lit + 8, 255).box(cx - half, cy - half, dotS, dotS); + continue; + } + + // Lit LED: round bloom halo (only when q allows) + square core + + // white hot-spot. Playhead column boosts brightness. + const g = Math.min(1.5, glow + (under ? 0.5 : 0)); + const kb = isKickRow ? 1 + kickFlash * 0.6 : 1; + if (litHalo) { + ink(hr, hg, hb, 55 * g * kb).circle(cx, cy, dotS * (0.9 + g * 0.35), true); + if (bigHalo) ink(hr, hg, hb, 26 * g).circle(cx, cy, dotS * 1.4, true); + } + const cs = dotS * (1 + g * 0.18); + ink(hr, hg, hb, Math.min(255, 210 * g)).box(cx - cs / 2, cy - cs / 2, cs, cs); // core + const hs = dotS * 0.42; + ink(255, 255, 255, Math.min(255, 210 * g)).box(cx - hs / 2, cy - hs / 2, hs, hs); // hot-spot + } + } + + // --- Bottom kick strip underline (reads as the sequencer's kick lane) -- + if (kickFlash > 0.03) { + const ly = rowCY[KICK_ROW] + dotS * 0.9; + ink(255, 120, 60, 120 * kickFlash).box(marginX, ly, gw, 2 + kickFlash * 3); + } + + // --- Playhead head glow at the top rail (the "now" marker) ------------ + ink(220, 240, 255, 160).circle(playX, marginY - cellH * 0.25, 3 + energy * 4, true); + ink(120, 180, 255, 90 + energy * 60).circle(playX, marginY - cellH * 0.25, 6 + energy * 8, true); + + if (energy > 1.2 && quality > 0.85) blur?.(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/sork.mjs b/system/public/aesthetic.computer/disks/sork.mjs new file mode 100644 index 0000000000..5f177b754d --- /dev/null +++ b/system/public/aesthetic.computer/disks/sork.mjs @@ -0,0 +1,280 @@ +// sork, 26.07.12 +// Isometric voxel-city pad — a thin wrapper over lib/pads.mjs (the shared pad +// engine: UTC-clock beat grid, `params[0]` rate override e.g. `sork 0.5`, the +// tap/XY "pump", audio polling, adaptive quality). This file only describes what +// makes sork sork: its arcade-blip score, its 8-bit voices, and its iso paint. +// +// ALLEGORY — the melody BUILDS a little 3D city. Each note STACKS a voxel on a +// tower (pitch → which tower column + how tall/what hue), drawn as an iso cube +// (top + 2 shaded side faces) back-to-front so towers overlap correctly. The +// bass = a ripple wave that LIFTS/drops rows of towers. The beat = a bright +// highlight sweep crossing the grid. Read the growing city and you read the tune. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Grid ------------------------------------------------------------------ +const COLS = 5; // iso city footprint (COLS × ROWS towers) +const ROWS = 5; +const NTOWERS = COLS * ROWS; +const MAXH = 6; // tallest a tower can grow before it topples back to 1 + +// --- Score ----------------------------------------------------------------- +// A bright arcade arpeggio in A-minor pentatonic; each step targets a tower. +const ARP = [ + "a3", "c4", "e4", "g4", "a4", "e4", "d4", "c4", + "a3", "e4", "g4", "b4", "a4", "g4", "e4", "d4", +]; +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 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); + +// --- 8-bit arcade voices (raw synth — bright, short, instant) ---------------- +// A hard square/triangle blip: fast attack, fast decay. This is the melody. +const blip = (synth, tone, o = {}) => + synth({ + tone, + type: o.type ?? "square", + beats: o.beats ?? 0.16, + attack: o.attack ?? 0.001, + decay: o.decay ?? 0.4, + volume: o.volume ?? 0.3, + pan: o.pan ?? 0, + }); +// A coin-y bell: a quick low square blip then an octave-up ring (Mario-pickup +// "bling" — two stacked square tones, the second higher & held a touch longer). +const octUp = (n) => { + const m = /^([a-g])(\d)$/.exec(n); + return m ? `${m[1]}${Math.min(9, parseInt(m[2], 10) + 1)}` : n; +}; +const coin = (synth, tone, o = {}) => { + const v = o.volume ?? 0.2; + const pan = o.pan ?? 0; + blip(synth, tone, { type: "square", beats: 0.06, decay: 0.2, volume: v, pan }); + blip(synth, octUp(tone), { type: "square", beats: 0.16, decay: 0.28, volume: v, pan }); +}; + +// --- sork-specific state (engine owns pump/bursts/rhythm) ------------------ +let heights = new Array(NTOWERS).fill(1); // voxel count per tower +let hues = new Array(NTOWERS).fill(180); // per-tower hue (last note that hit it) +let pop = new Array(NTOWERS).fill(0); // per-tower "just stacked" bounce, 0..1 +let ripple = 0; // bass ripple phase (advances each bass note) +let rippleAmp = 0; // decaying bass ripple lift +let sweep = -1; // beat highlight sweep position across grid diagonal +let sweepLife = 0; // decaying sweep brightness + +const CONFIG = { + bpm: 140, + steps: ARP.length, + drawBursts: false, // sork draws its own tap feedback in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.18, feedback: 0.4 }); + }, + + // A new UTC beat crossed — fire the blip + stack a voxel on its tower. + onBeat({ idx, synth }) { + const s = ((idx % ARP.length) + ARP.length) % ARP.length; + const note = ARP[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + // Pitch picks the tower column; step-phase picks the row → snakes across city. + const col = Math.floor(pn * (COLS - 0.001)); + const row = ((Math.floor(s / 2) % ROWS) + ROWS) % ROWS; + const t = row * COLS + col; + const pan = (col / (COLS - 1)) * 1.4 - 0.7; + + // STACK a voxel on that tower (topple back to 1 when it overflows). + heights[t] = heights[t] >= MAXH ? 1 : heights[t] + 1; + hues[t] = 20 + pn * 300; + pop[t] = 1; + + // Arcade blip: bright short square (raw synth), fast attack/decay. + blip(synth, note, { type: "square", beats: 0.16, volume: 0.34, pan }); + // Triangle harmonic sparkle a touch quieter for body. + blip(synth, note, { type: "triangle", beats: 0.1, volume: 0.14, pan }); + + // Coin-y bell on the tallest / high-pitch accents. + if (pn > 0.6 || heights[t] >= MAXH - 1) + coin(synth, note, { volume: 0.2, pan: -pan }); + + // Sub-bass root on the half-beat → a ripple that lifts a whole row. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.2, decay: 0.5, volume: 0.5 }); + ripple += 1; + rippleAmp = 1; + } + + voices.hat(synth, { tone: 9000, beats: 0.08, volume: 0.12 }); + + // Beat highlight sweep starts at the far corner, crosses the diagonal. + sweep = 0; + sweepLife = 1; + }, + + onSim() { + rippleAmp *= 0.92; + sweepLife *= 0.9; + if (sweep >= 0) { + sweep += 0.14; + if (sweep > COLS + ROWS) sweep = -1; + } + for (let i = 0; i < NTOWERS; i++) pop[i] *= 0.85; + }, + + // Tap = stack a voxel on the tapped tower + a blip. X → column, Y → height/pitch. + onTap({ x, y, synth }) { + const col = Math.max(0, Math.min(COLS - 1, Math.floor(x * COLS))); + const row = Math.max(0, Math.min(ROWS - 1, Math.floor(y * ROWS))); + const t = row * COLS + col; + const bump = 1 + Math.floor((1 - y) * 3); // higher tap → taller stack + heights[t] = Math.min(MAXH, heights[t] + bump); + const pn = 1 - y; + hues[t] = 20 + pn * 300; + pop[t] = 1.4; + const note = ["a", "c", "d", "e", "g"][col % 5] + (3 + Math.floor(pn * 2)); + blip(synth, note, { type: "square", beats: 0.2, volume: 0.4, pan: x * 2 - 1 }); + coin(synth, note, { volume: 0.28, pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + + // Sky wash (dark candy night). Cheap flat fill each frame. + ink(14, 12, 30, 255).box(0, 0, w, h); + + const bass = band("subBass"); + const energy = Math.min(1.6, rippleAmp * 0.7 + bass * 1.1 + amp * 0.5 + pump * 0.4); + + // Adaptive footprint: scale how many towers we DRAW by quality (iso cubes + // are cheap but bounded — always draw at least a 3×3 core). + const drawCols = Math.max(3, Math.round(COLS * quality)); + const drawRows = Math.max(3, Math.round(ROWS * quality)); + + // Iso projection: cell (col,row) → screen. Tile "radius" scales to screen. + const tile = Math.min(w, h) * 0.11; + const tw = tile; // half tile width (x screen offset per +col / +row) + const th = tile * 0.5; // half tile height (y screen offset per +col / +row) + const vox = tile * 0.62; // voxel height in screen px + const cx = w / 2; + const cy = h / 2 - drawRows * th * 0.5 + tile * 0.5; + + const iso = (col, row) => [ + cx + (col - row) * tw, + cy + (col + row) * th, + ]; + + // BEAT SWEEP: a diagonal band (col+row ≈ sweep) glowing bright. + const sweepPos = sweep >= 0 ? sweep : -99; + + // Draw towers BACK-TO-FRONT: farthest (small col+row) first so nearer + // towers overlap correctly. Painter's order = ascending (col+row), then col. + const order = []; + for (let row = 0; row < drawRows; row++) + for (let col = 0; col < drawCols; col++) order.push([col, row]); + order.sort((a, b) => (a[0] + a[1]) - (b[0] + b[1]) || a[0] - b[0]); + + for (const [col, row] of order) { + const t = row * COLS + col; + // Bass ripple lifts rows in a travelling sine wave (gentle, so the + // city stays legible while the wave visibly rolls through). + const lift = + rippleAmp * th * 1.4 * + Math.sin(ripple * 0.9 + (col + row) * 0.7); + const [bx, by0] = iso(col, row); + const by = by0 + lift; + + const height = heights[t]; + const bounce = pop[t] * vox * 0.4; // fresh-stack squash-and-pop + const baseHue = hues[t]; + + // Sweep proximity (0..1) — brightens the row the highlight is on. + const sd = Math.abs((col + row) - sweepPos); + const sweepGlow = sweepLife * Math.max(0, 1 - sd * 0.8); + + // Draw the WHOLE tower as one column (3 iso faces = 6 triangles), + // regardless of height — bounded cost. Filled quads route through the + // slow scanline fill, so we split each face into two triangles (the + // engine fast-paths 3-point shapes via fillTri). + const towerH = height * vox + bounce; // total column height in px + const topY = by - towerH; // top-face center y (higher = smaller y) + const light = 46 + height * 3 + sweepGlow * 34 + energy * 7; + const hue = ((baseHue % 360) + 360) % 360; + + // TOP diamond corners. + const tN = [bx, topY - th]; + const tE = [bx + tw, topY]; + const tS = [bx, topY + th]; + const tW = [bx - tw, topY]; + // BASE (ground) corners east/south/west (walls run top→base). + const gE = [bx + tw, by]; + const gS = [bx, by + th]; + const gW = [bx - tw, by]; + + // LEFT wall (west→south) — darkest. Two tris. + const [lr, lg, lb] = num.hslToRgb(hue, 68, Math.max(6, light - 24)); + ink(lr, lg, lb, 255).shape([tW, tS, gS]); + ink(lr, lg, lb, 255).shape([tW, gS, gW]); + // RIGHT wall (south→east) — mid. Two tris. + const [rr, rg, rb] = num.hslToRgb(hue, 72, Math.max(9, light - 11)); + ink(rr, rg, rb, 255).shape([tS, tE, gE]); + ink(rr, rg, rb, 255).shape([tS, gE, gS]); + // TOP face — brightest. Two tris. + const [tr, tg, tb] = num.hslToRgb(hue, 80, Math.min(92, light + 16)); + ink(tr, tg, tb, 255).shape([tN, tE, tS]); + ink(tr, tg, tb, 255).shape([tN, tS, tW]); + + // Blocky 8-bit read: cheap level seams on the two visible walls (lines + // only — no fills). Only when reasonably close / lifted. + if (height > 1 && (col + row) >= drawCols + drawRows - 8) { + const [sr, sg, sb] = num.hslToRgb(hue, 60, Math.max(4, light - 34)); + for (let lv = 1; lv < height; lv++) { + const yy = by - lv * vox; + ink(sr, sg, sb, 150).line(bx - tw, yy, bx, yy + th); // west→south + ink(sr, sg, sb, 150).line(bx, yy + th, bx + tw, yy); // south→east + } + } + + // Bright top outline for the front two edges (sweep highlights it). + const oa = 50 + sweepGlow * 150; + ink(255, 255, 255, oa).line(tW[0], tW[1], tS[0], tS[1]); + ink(255, 255, 255, oa).line(tS[0], tS[1], tE[0], tE[1]); + + // Coin sparkle atop a freshly-popped or maxed tower. + if (pop[t] > 0.4 || height >= MAXH) { + const py = topY - vox * 0.5; + const [cr, cg, cb] = num.hslToRgb((baseHue + 60) % 360, 90, 70); + ink(cr, cg, cb, 190 * Math.max(pop[t], height >= MAXH ? 0.5 : 0)) + .circle(bx, py, 3 + pop[t] * 4, true); + } + } + + // Bass ripple ground-glow hint at the front edge. + if (rippleAmp > 0.05) { + const gy = cy + (drawCols + drawRows) * th + tile * 0.4; + ink(120, 60, 220, 40 * rippleAmp).box(0, gy, w, 4); + } + }, + }, +}; + +// 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/trido.mjs b/system/public/aesthetic.computer/disks/trido.mjs new file mode 100644 index 0000000000..cc66f94355 --- /dev/null +++ b/system/public/aesthetic.computer/disks/trido.mjs @@ -0,0 +1,403 @@ +// trido, 26.07.12 +// Boids-schooling pad — a thin wrapper over lib/pads.mjs (the shared pad engine: +// UTC-clock beat grid, `params[0]` rate override e.g. `trido 0.5`, the tap/XY +// "pump", audio polling). This file only describes what makes trido trido: a +// school of oriented ARROW/FISH agents that flock (alignment + cohesion + +// separation) while a flute/whistle waveguide voices its allegory. +// +// STRONG GRAPHIC↔SONIC ALLEGORY — +// • the held DRONE bed STEERS the whole school's heading — the bass note maps +// to a compass direction (a UTC-continuous "current") that every fish leans +// into, so the flock's overall drift IS the drone; +// • each mid NOTE that sounds SPAWNS A BRIGHT LEADER the flock darts toward +// (pitch → the leader's HEIGHT and HUE) → you SEE the melody as glowing bait +// the school chases; +// • the BEAT = a synchronized FLASH + TURN (the whole school snaps + brightens); +// • amplitude → school tightness/brightness. +// +// Distinct from murmo (a flow-field dot murmuration): trido is TRUE boids with +// visible oriented triangle-fish agents that dart after leaders. Cheap O(n): each +// fish steers off a few sampled neighbors + a shared flock average, never O(n²). + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A whistled pentatonic line over an 8-beat loop that resolves to the start so +// the audio loops as cleanly as the school's drift. Warm, airy, always-agreeing. +const MELODY = ["e4", "g4", "a4", "b4", "d5", "b4", "a4", "g4"]; +const BASS = ["e2", "e2", "c2", "c2", "g2", "g2", "a2", "a2"]; // steers heading +const BPM = 96; + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function noteToHz(note) { + const m = /^([a-g])(#|s|b)?(\d)$/.exec(note.toLowerCase()); + if (!m) return 220; + let semi = NOTE_SEMI[m[1]]; + if (m[2] === "#" || m[2] === "s") semi += 1; + else if (m[2] === "b") semi -= 1; + const midi = semi + (+m[3] + 1) * 12; + return 440 * Math.pow(2, (midi - 69) / 12); +} +// Map a note's Hz into a normalized 0..1 pitch band (~e2..d6). +function pitchNorm(hz) { + const lo = noteToHz("e2"), hi = noteToHz("d6"); + const n = Math.log2(hz / lo) / Math.log2(hi / lo); + return Math.max(0, Math.min(1, n)); +} +// Map a bass note to a compass heading (0..2π) — the current the school steers. +function bassHeading(note) { + const hz = noteToHz(note); + const pn = pitchNorm(hz); + // Full sweep of the compass across the bass range → a moving current. + return pn * Math.PI * 2; +} + +// --- trido-specific state (engine owns pump/bursts/rhythm/clock) ------------- +const BASE_AGENTS = 220; // scaled by ctx.quality → holds 60fps +let fish = []; // { x, y, vx, vy } — position + velocity (heading = atan2(vy,vx)) +let leaders = []; // { x, y, hue, life, strength } — bright bait the school darts to +let heading = 0; // current drone heading (radians) — steers the whole school +let headingTarget = 0; // where the drone wants the school to point +let flashT = 0; // beat flash/turn envelope (decays) +let droneStarted = false; +let seeded = false; +let W = 360, H = 640; + +function seedSchool(w, h, n) { + W = w; H = h; + fish = []; + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const sp = 0.6 + Math.random() * 0.6; + fish.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp, + }); + } +} + +const CONFIG = { + bpm: BPM, + steps: MELODY.length, // seamless 8-beat cycle + drawBursts: false, // trido draws its own tap wakes in onPaint + hooks: { + onBoot({ sound, screen }) { + // A wide airy hall — the whistle line blooms and the school hisses along. + sound.room?.set?.({ enabled: true, mix: 0.42, feedback: 0.56 }); + + // Held DRONE bed = the CURRENT. A detuned low pad chord that literally is + // the steering bass — root + faint beat partner + fifth for body. + if (!droneStarted) { + droneStarted = true; + voices.padChord( + sound.synth, + [BASS[0], noteToHz(BASS[0]) * 1.007, "b2"], + { volume: 0.11, attack: 1.5, decay: 0.9, spread: 0.4 }, + ); + } + + const w = screen?.width || 360; + const h = screen?.height || 640; + seedSchool(w, h, Math.round(BASE_AGENTS)); + seeded = true; + }, + + // A new UTC beat crossed — voice the whistle line + spawn the leader/bait. + onBeat({ idx, synth }) { + const step = ((idx % MELODY.length) + MELODY.length) % MELODY.length; + const note = MELODY[step]; + const hz = noteToHz(note); + const pN = pitchNorm(hz); // 0..1 → leader height + hue + const pan = Math.sin(idx * 0.6) * 0.5; // slow stereo drift + + // Richened WHISTLE lead: a breathy waveguide flute + a detuned sine partner + // (airy beating) + a soft octave-up glisten on high steps → true flute/pipe. + voices.flute(synth, note, { + beats: 1.8, + attack: 0.08, + decay: 0.55, + volume: 0.3, + pan, + }); + synth({ + tone: hz * 1.006, // gently detuned breathy partner → whistle shimmer + type: "sine", + beats: 1.6, + attack: 0.12, + decay: 0.5, + volume: 0.16, + pan: -pan, + }); + if (pN > 0.55) + voices.bell(synth, hz * 2, { beats: 0.9, volume: 0.07, pan: -pan }); + + // The STEERING bass on the half-beat — sets the current the school leans + // into. New heading = where this bass note points. + if (step % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.6, attack: 0.05, decay: 0.6, volume: 0.34 }); + headingTarget = bassHeading(BASS[bi]); + } + + // Airy breath tick — the school's collective hiss/turn. + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.1 }); + + // The VISIBLE counterpart: a bright LEADER the school darts to. pitch → y + // (top = high) + hue; brighter/bigger the higher it flies. + leaders.push({ + x: W * (0.2 + 0.6 * ((step + 0.5) / MELODY.length)), + y: H * (1 - pN) * 0.9 + H * 0.05, + hue: 20 + pN * 300, + life: 1, + strength: 1, + }); + if (leaders.length > 6) leaders.shift(); + + flashT = 1; // synchronized FLASH + TURN + }, + + onSim(api) { + const { simMs, pump } = api; + + // Ease the school heading toward the drone's target current (a gentle turn, + // not a snap) — angle-wrapped so it takes the short way around. + let d = headingTarget - heading; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + heading += d * 0.04; + + // A slow UTC-continuous wander added to the current so the drift breathes + // and loops with the cycle (keeps two instances broadly aligned). + const beatMs = api.beatSeconds * 1000; + const cyclePos = (simMs / (beatMs * MELODY.length)) % 1; + heading += Math.sin(cyclePos * Math.PI * 2) * 0.006; + + flashT *= 0.9; + for (const L of leaders) L.life -= 0.016; // ~1s bait fade + leaders = leaders.filter((L) => L.life > 0); + + void pump; + }, + + // Tap = drop a LEADER at the tap point the school chases + a whistle note. + // X → pan/hue, Y → pitch (top = high). Engine already bumped pump + burst. + onTap({ x, y, ex, ey, isDraw, synth, burst }) { + const hue = (x * 300 + 20) % 360; + burst.hue = hue; + burst.grow = 5 + (isDraw ? 4 : 9); + + // A bright leader exactly at the tap → the visible bait matches the audible. + leaders.push({ + x: ex, + y: ey, + hue, + life: 1.2, + strength: isDraw ? 0.7 : 1.2, + }); + if (leaders.length > 8) leaders.shift(); + + // Whistle note — pentatonic so it agrees with the line. Y→pitch, X→pan. + const scale = ["e", "g", "a", "b", "d"]; + const noteName = scale[Math.min(4, Math.floor(x * 5))]; + const oct = 3 + Math.round((1 - y) * 3); // higher tap = higher whistle + const pan = x * 2 - 1; + const g = isDraw ? 0.6 : 1; + voices.flute(synth, noteName + oct, { + beats: 1.4, + attack: 0.06, + decay: 0.55, + volume: 0.34 * g, + pan, + }); + synth({ + tone: noteToHz(noteName + oct) * 1.006, + type: "sine", + beats: 1.1, + attack: 0.08, + decay: 0.5, + volume: 0.16 * g, + pan: -pan, + }); + flashT = Math.max(flashT, 0.6); + }, + + onPaint(api, s) { + const { ink, box, screen, num } = api; + const { pump, band, amp, quality, bursts } = s; + const { width: w, height: h } = screen; + W = w; H = h; + + // Re-seed / re-scale the school to quality (particle pads scale counts). + const want = Math.max(60, Math.round(BASE_AGENTS * quality)); + if (!seeded) { seedSchool(w, h, want); seeded = true; } + if (Math.abs(fish.length - want) > 40) { + if (fish.length < want) + for (let i = fish.length; i < want; i++) { + const a = Math.random() * Math.PI * 2; + fish.push({ x: Math.random() * w, y: Math.random() * h, vx: Math.cos(a), vy: Math.sin(a) }); + } + else fish.length = want; + } + + // —— audio reads (make the field react) —— + const bass = band("subBass") + band("lowMid") * 0.6; + const air = band("air") + band("treble") * 0.6; + const energy = Math.min(2, flashT * 0.7 + bass * 1.1 + amp * 0.6 + pump * 0.4); + + // Deep-water twilight wash — a translucent gradient veil each frame → silky + // wakes (indigo→teal depth). (Diagnostics showed the named gradient is not + // the fps bottleneck; the aesthetic is worth it.) + ink("fade:midnightblue-teal:vertical", 44).box(0, 0, w, h); + + // Current arrow HINT — a faint drift indicator so the drone's steer reads. + const cx = w / 2, cy = h / 2; + const chx = Math.cos(heading), chy = Math.sin(heading); + ink(120, 180, 220, 30 + energy * 25).line( + cx - chx * w * 0.4, cy - chy * h * 0.4, + cx + chx * w * 0.4, cy + chy * h * 0.4, 2, + ); + + // —— flock advance (cheap O(n) boids) —— + // Shared flock average (cohesion + alignment centroid) computed once per + // frame; each fish also samples a couple of RANDOM neighbors for separation. + let mx = 0, my = 0, mvx = 0, mvy = 0; + for (const f of fish) { mx += f.x; my += f.y; mvx += f.vx; mvy += f.vy; } + const n = fish.length || 1; + mx /= n; my /= n; mvx /= n; mvy /= n; + const avgSpeed = Math.hypot(mvx, mvy) || 1; + + // Speed scales with energy so the school surges on loud beats. + const cruise = 1.3 + energy * 0.9 + pump * 0.5; + const turnBoost = 1 + flashT * 2; // FLASH beat = a sharp collective turn + + // Pick the strongest live leader as the primary bait target (bright bait). + let bait = null, baitS = 0; + for (const L of leaders) { + const sVal = L.strength * L.life; + if (sVal > baitS) { baitS = sVal; bait = L; } + } + + // Precompute a small hue→RGB palette ONCE per frame (hslToRgb is costly; + // 12 buckets is plenty for a shimmering teal school warmed toward the bait). + const light = Math.min(80, 58 + energy * 12 + flashT * 20); + const baitHue = bait ? bait.hue : 175; + const PAL = 12; + const pal = new Array(PAL); + for (let p = 0; p < PAL; p++) { + // base teal-cyan (155..195) blended toward bait hue when chasing. + let hue = 155 + (p / (PAL - 1)) * 40; + if (bait) hue = hue * (1 - baitS * 0.4) + baitHue * baitS * 0.4; + pal[p] = num.hslToRgb(((hue % 360) + 360) % 360, 85, light); + } + const fishAlpha = 190 + flashT * 60; + + for (let i = 0; i < fish.length; i++) { + const f = fish[i]; + let ax = 0, ay = 0; + + // COHESION → toward flock centroid. + ax += (mx - f.x) * 0.0009; + ay += (my - f.y) * 0.0009; + + // ALIGNMENT → match flock mean heading. + ax += (mvx - f.vx) * 0.03; + ay += (mvy - f.vy) * 0.03; + + // SEPARATION → push off a couple of sampled neighbors (cheap, not O(n²)). + for (let k = 0; k < 2; k++) { + const g = fish[(i + 1 + ((Math.random() * (n - 1)) | 0)) % n]; + const dx = f.x - g.x, dy = f.y - g.y; + const d2 = dx * dx + dy * dy; + if (d2 < 900 && d2 > 0.01) { + const inv = 1 / d2; + ax += dx * inv * 24; + ay += dy * inv * 24; + } + } + + // DRONE CURRENT → the whole school leans into the drone heading. + ax += chx * 0.05 * turnBoost; + ay += chy * 0.05 * turnBoost; + + // LEADER DART → the fish accelerate toward the brightest bait (pitch bait). + if (bait) { + const dx = bait.x - f.x, dy = bait.y - f.y; + const dd = Math.hypot(dx, dy) || 1; + const pullW = 0.14 * baitS * turnBoost; + ax += (dx / dd) * pullW; + ay += (dy / dd) * pullW; + } + + f.vx += ax; + f.vy += ay; + + // Clamp toward cruise speed. + const sp = Math.hypot(f.vx, f.vy) || 1; + const target = cruise; + f.vx = (f.vx / sp) * (sp + (target - sp) * 0.1); + f.vy = (f.vy / sp) * (sp + (target - sp) * 0.1); + + f.x += f.vx; + f.y += f.vy; + + // Wrap the pond edges (toroidal so the school streams through). + if (f.x < -8) f.x += w + 16; else if (f.x > w + 8) f.x -= w + 16; + if (f.y < -8) f.y += h + 16; else if (f.y > h + 8) f.y -= h + 16; + + // Draw the oriented FISH as a cheap CHEVRON/ARROW (two lines) along + // velocity — a filled triangle per fish (scanline .shape) is far too + // costly at hundreds of agents; a chevron reads as an oriented arrow and + // holds 60fps. Unit-vector form avoids extra cos/sin per fish. + const spd = Math.hypot(f.vx, f.vy) || 1; + const ux = f.vx / spd, uy = f.vy / spd; // forward + const px = -uy, py = ux; // perpendicular + const len = 5 + spd * 1.6; + const wid = 2.6; + const nx = f.x + ux * len, ny = f.y + uy * len; // nose + const bx = f.x - ux * 2, by = f.y - uy * 2; // body base + const lx = bx + px * wid, ly = by + py * wid; // left fin + const rx = bx - px * wid, ry = by - py * wid; // right fin + + // Palette bucket by position (cheap shimmer) — no per-fish HSL. + const pi = (((f.x * 0.03 + f.y * 0.03) | 0) % PAL + PAL) % PAL; + const c = pal[pi]; + ink(c[0], c[1], c[2], fishAlpha) + .line(nx, ny, lx, ly) + .line(nx, ny, rx, ry); + } + + // —— LEADERS: bright bait the school chases (glowing lures) —— + for (const L of leaders) { + const [r, gc, b] = num.hslToRgb(((L.hue % 360) + 360) % 360, 95, 62); + const rad = 4 + L.strength * 8 + L.life * 6; + ink(r, gc, b, 60 * L.life).circle(L.x, L.y, rad * 2.2, true); + ink(r, gc, b, 200 * L.life).circle(L.x, L.y, rad, true); + ink(255, 255, 255, 220 * L.life).circle(L.x, L.y, rad * 0.4, true); + } + + // —— TAP WAKES (engine-tracked bursts): expanding hollow rings —— + for (const bst of bursts) { + const [r, gc, b] = num.hslToRgb(((bst.hue % 360) + 360) % 360, 90, 60); + ink(r, gc, b, 170 * bst.life).circle(bst.x, bst.y, bst.r, false, 2); + } + + // Beat FLASH veil — the whole pond brightens + snaps on the beat. + if (flashT > 0.05 || air > 0.1) { + const fa = Math.min(90, flashT * 70 + air * 40); + ink(160, 220, 255, fa).box(0, 0, w, h); + } + }, + }, +}; + +// 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/varo.mjs b/system/public/aesthetic.computer/disks/varo.mjs new file mode 100644 index 0000000000..738f444676 --- /dev/null +++ b/system/public/aesthetic.computer/disks/varo.mjs @@ -0,0 +1,213 @@ +// varo, 26.07.12 +// Moiré interference × detuned pad swell — a thin wrapper over lib/pads.mjs +// (the shared pad engine: UTC-clock beat grid, `params[0]` rate override e.g. +// `varo 0.5`, the tap/XY "pump", audio polling, adaptive ctx.quality). This +// file only describes what makes varo varo: its detuned-pad score and its +// op-art moiré paint. +// +// ALLEGORY — two overlapping ring/line grids slowly slide over each other; the +// shimmer between them IS the BEAT-FREQUENCY of two slightly-detuned synth +// voices. You SEE the beating you HEAR. Each note offsets/rotates one grid +// (pitch → grid spacing & angle); the BASS = the slide speed; amplitude = +// contrast. Two concentric ring families drawn as cheap vector strokes, count +// scaled by ctx.quality so it holds 60fps native. Hypnotic, detuned, hypnagogic. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A slow, hovering detuned pad in D-dorian-ish territory. Each step nudges the +// second grid's spacing/angle. Long attacks = swelling. 8 steps, half-time bass. +const NOTES = ["d3", "a3", "f3", "c4", "d4", "a3", "g3", "e3"]; +const BASS = ["d2", "d2", "bb1", "bb1", "c2", "c2", "a1", "a1"]; // half-time slide 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])(b?)(\d)$/.exec(n); + if (!m) return 48; + const acc = m[2] === "b" ? -1 : 0; + return (parseInt(m[3], 10) + 1) * 12 + NOTE_SEMI[m[1]] + acc; +} +const PITCHES = NOTES.map(notePitch); +const PITCH_MIN = Math.min(...PITCHES); +const PITCH_MAX = Math.max(...PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// --- varo-specific visual state (engine owns pump/bursts/rhythm) ------------ +// Two ring grids. gridA is the "anchor"; gridB slides & detunes off it. The +// visible gap between their spacings is the moiré — mirroring the audible beat +// frequency between the two detuned pad layers. +let heldPads = null; // the two detuned held-pad layers (the beating) +let spacingB = 1.0; // gridB spacing multiplier (nudged per note = pitch→spacing) +let angleB = 0; // gridB rotation (nudged per note = pitch→angle) +let spacingTarget = 1.0; // eased target for spacingB +let angleTarget = 0; // eased target for angleB +let slide = 0; // accumulated slide phase (bass = slide speed) +let slideSpeed = 0.006; // current slide speed, kicked by bass +let swell = 0; // overall swell/contrast, kicked each beat +let ripples = []; // tap ripples: { x, y, r, life, hue } — a moiré ping from a tap + +const CONFIG = { + bpm: 60, // slow, hovering pad (1s/beat) + steps: NOTES.length, + drawBursts: false, // varo draws its own moiré ripples in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.4, feedback: 0.7 }); + }, + + // A new UTC beat crossed — swell the detuned pad + nudge grid B. + onBeat({ idx, synth }) { + const s = ((idx % NOTES.length) + NOTES.length) % NOTES.length; + const note = NOTES[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const pan = Math.sin((s / NOTES.length) * Math.PI * 2) * 0.6; + + // THE BEATING: (re)start two slightly-detuned held pad layers — the sonic + // moiré. padChord returns handles; kill the previous swell first so tails + // don't pile up. One layer at the note, one a few cents sharp → they beat. + const detuneRatio = 1.006; // ~10 cents sharp → slow audible beat freq + const hz = 440 * Math.pow(2, (notePitch(note) - 69) / 12); + try { heldPads?.forEach?.((v) => v?.kill?.(0.4)); } catch (e) {} + heldPads = [ + ...voices.padChord(synth, [hz], { type: "sine", volume: 0.1, attack: 0.7, decay: 1.2, spread: 0.25, pan }), + ...voices.padChord(synth, [hz * detuneRatio], { type: "triangle", volume: 0.08, attack: 0.8, decay: 1.2, spread: 0.25, pan: -pan }), + ]; + + // Airy flute top swells over the pad on higher notes. + if (pn > 0.4) + voices.flute(synth, note, { beats: 1.6, attack: 0.4, decay: 0.8, volume: 0.16, pan: -pan }); + + // NOTE → GRID: pitch sets grid B's spacing & angle target (the offset). + spacingTarget = 0.82 + pn * 0.42; // higher pitch → wider grid gap → tighter moiré + angleTarget = (s / NOTES.length) * Math.PI * 0.5 + pn * 0.4; + swell = 1; + + // BASS = the SLIDE SPEED of grid B. Fires half-time; deeper root = faster slide. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + const bn = BASS[bi]; + voices.sub(synth, bn, { beats: 2.0, attack: 0.06, decay: 0.7, volume: 0.5 }); + const bpn = pitchNorm(bn.replace(/1$/, "3")); // rough low-ness + slideSpeed = 0.004 + (1 - Math.min(1, bpn)) * 0.012; + } + }, + + onSim() { + // Ease grid B toward its per-note targets → smooth moiré breathing. + spacingB += (spacingTarget - spacingB) * 0.06; + angleB += (angleTarget - angleB) * 0.05; + // Bass drives the slide; slide speed itself decays back to a gentle drift. + slide += slideSpeed; + slideSpeed += (0.005 - slideSpeed) * 0.02; + swell *= 0.94; + for (const rp of ripples) { + rp.r += 3.2; + rp.life -= 0.02; + } + ripples = ripples.filter((rp) => rp.life > 0); + if (ripples.length > 8) ripples = ripples.slice(-8); + }, + + // Tap = a moiré ripple from the tap + a swelling detuned tone. + // X → pan/hue, Y → pitch. The ripple locally shoves grid B (a moiré pulse). + onTap({ x, y, ex, ey, synth }) { + const midi = 40 + Math.floor((1 - y) * 34); // Y → pitch + const hz = 440 * Math.pow(2, (midi - 69) / 12); + const pan = x * 2 - 1; + // A brief swelling detuned pair (the tap's own little beat). + voices.padChord(synth, [hz], { type: "sine", volume: 0.16, attack: 0.15, decay: 0.9, pan }); + voices.padChord(synth, [hz * 1.008], { type: "triangle", volume: 0.12, attack: 0.2, decay: 0.9, pan: -pan }); + ripples.push({ x: ex, y: ey, r: 4, life: 1.2, hue: x * 360 }); + // Nudge grid B so the whole field ripples in response to the tap. + angleTarget += (x - 0.5) * 0.5; + spacingTarget = 0.8 + (1 - y) * 0.5; + swell = Math.min(1.4, swell + 0.6); + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + + const bass = band("subBass") + band("lowMid") * 0.5; + const contrast = Math.min(1, 0.35 + amp * 1.2 + swell * 0.5 + pump * 0.25); // amplitude = contrast + const energy = Math.min(2, swell * 0.6 + bass * 1.1 + amp * 0.6 + pump * 0.5); + + // Dark veil (leave a faint trail so the moiré shimmers rather than strobes). + ink(5, 4, 12, 200).box(0, 0, w, h); + + const maxR = Math.hypot(w, h) * 0.55; + // Grid A base spacing; grid B slides & detunes off it → moiré. + const baseGap = Math.max(6, Math.min(w, h) * 0.045); + // ctx.quality scales ring count → holds 60fps native. Base ~34 rings/grid. + const BASE = 34; + const nRings = Math.max(8, Math.round(BASE * quality)); + + // Two hue poles that drift with the slide → the shimmer takes on color. + const hueA = (slide * 22) % 360; + const hueB = (slide * 22 + 40 + angleB * 60) % 360; + + // --- GRID A: concentric rings from center, fixed anchor --------------- + // Offset by the slide so the whole anchor drifts (bass = slide speed). + const slideOff = (slide * baseGap * 0.5) % baseGap; + const [ar, ag, ab] = num.hslToRgb(((hueA % 360) + 360) % 360, 70, 42 + contrast * 22); + for (let i = 1; i <= nRings; i++) { + const r = i * baseGap - slideOff; + if (r < 2 || r > maxR) continue; + const a = Math.round((60 + contrast * 90) * (1 - r / maxR)); + ink(ar, ag, ab, a).circle(cx, cy, r, false, 1); + } + + // --- GRID B: rings off a slightly-shifted center, detuned spacing ----- + // The DETUNE: gapB ≠ gapA (spacingB), and the center is nudged along the + // slide/angle. Where the two ring families cross, you get moiré fringes — + // the visual beat frequency of the two detuned pad layers. + const gapB = baseGap * spacingB; + const shift = baseGap * 1.4 * Math.min(1.5, 0.5 + swell * 0.8); + const bx = cx + Math.cos(angleB + slide * 0.3) * shift; + const by = cy + Math.sin(angleB + slide * 0.3) * shift; + const slideOffB = (slide * gapB * 0.5) % gapB; + const [br, bg, bb] = num.hslToRgb(((hueB % 360) + 360) % 360, 80, 46 + contrast * 22); + for (let i = 1; i <= nRings; i++) { + const r = i * gapB - slideOffB; + if (r < 2 || r > maxR) continue; + const a = Math.round((60 + contrast * 90) * (1 - r / maxR)); + ink(br, bg, bb, a).circle(bx, by, r, false, 1); + } + + // --- BEAT-FREQUENCY MARKER: a soft bloom pulsing at the moiré node ---- + // Midpoint between the two grid centers = where the fringe is strongest; + // it breathes with the swell (the audible beat you can point at). + const mx = (cx + bx) / 2, my = (cy + by) / 2; + const bloomR = (baseGap * 1.2) * (1 + swell * 1.4 + bass * 1.5); + const [hr, hg, hb] = num.hslToRgb((((hueA + hueB) / 2) % 360 + 360) % 360, 90, 62); + for (let k = 3; k > 0; k--) + ink(hr, hg, hb, Math.round(24 * swell + 10 * bass)).circle(mx, my, bloomR * (k / 3), true); + + // Faint interference cross-lines between the two centers reinforce the beat. + ink(hr, hg, hb, Math.round(30 + contrast * 60)).line(cx, cy, bx, by, 2); + + // --- TAP RIPPLES: expanding moiré pings, hue by X -------------------- + for (const rp of ripples) { + const [rr, rg, rb] = num.hslToRgb(((rp.hue % 360) + 360) % 360, 95, 62); + ink(rr, rg, rb, Math.round(180 * rp.life)).circle(rp.x, rp.y, rp.r, false, 2); + ink(255, 255, 255, Math.round(120 * rp.life)).circle(rp.x, rp.y, rp.r * 0.6, false, 1); + } + + // Center dot — the anchor of grid A. + ink(230, 230, 255, 140 + swell * 90).circle(cx, cy, 3 + energy * 2, 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 }; -- 2.51.2