From 4c8ff12da104e936fe2c03383110bd445898ff15 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 12 Jul 2026 17:35:55 -0700 Subject: [PATCH] =?UTF-8?q?pads:=20+6=20new=20(batch=202/4)=20=E2=80=94=20?= =?UTF-8?q?flim=20spirograph=C3=97organ,=20brindo=20plasma=C3=97supersaw,?= =?UTF-8?q?=20cobo=20fireworks=C3=97orchestral,=20loxa=20lightning=C3=97za?= =?UTF-8?q?p,=20zim=20smoke=C3=97granular,=20gulmo=20terrain=C3=97sub-tech?= =?UTF-8?q?no=20=E2=80=94=20all=20adaptive=20~60fps=20native?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aesthetic.computer/disks/brindo.mjs | 263 ++++++++++++++++ .../public/aesthetic.computer/disks/cobo.mjs | 242 +++++++++++++++ .../public/aesthetic.computer/disks/flim.mjs | 247 +++++++++++++++ .../public/aesthetic.computer/disks/gulmo.mjs | 286 +++++++++++++++++ .../public/aesthetic.computer/disks/loxa.mjs | 293 ++++++++++++++++++ .../public/aesthetic.computer/disks/zim.mjs | 264 ++++++++++++++++ 6 files changed, 1595 insertions(+) create mode 100644 system/public/aesthetic.computer/disks/brindo.mjs create mode 100644 system/public/aesthetic.computer/disks/cobo.mjs create mode 100644 system/public/aesthetic.computer/disks/flim.mjs create mode 100644 system/public/aesthetic.computer/disks/gulmo.mjs create mode 100644 system/public/aesthetic.computer/disks/loxa.mjs create mode 100644 system/public/aesthetic.computer/disks/zim.mjs diff --git a/system/public/aesthetic.computer/disks/brindo.mjs b/system/public/aesthetic.computer/disks/brindo.mjs new file mode 100644 index 0000000000..860b99894e --- /dev/null +++ b/system/public/aesthetic.computer/disks/brindo.mjs @@ -0,0 +1,263 @@ +// brindo, 26.07.12 +// Demoscene PLASMA × TRANCE SUPERSAW — a thin wrapper over lib/pads.mjs (the +// shared pad engine: UTC-clock beat grid, `params[0]` rate override e.g. +// `brindo 0.5`, tap/XY "pump", audio polling, adaptive quality). This file only +// describes what makes brindo brindo: its euphoric supersaw score and its +// per-pixel plasma paint written DIRECTLY to screen.pixels. +// +// ALLEGORY — the plasma field IS the music: +// • ARP → shifts the palette HUE PHASE. Every arpeggio note advances the +// 256-entry hue LUT's rotation, so the color cycles with the melody. +// • BASS → WARPS the plasma. Each sub note (and the live sub-band amplitude) +// feeds a low-frequency ripple term that ripples the whole field. +// • BEAT → a bright BLOOM pulse. Each beat kicks `bloom`, lifting the plasma's +// brightness so the field glows brighter on the downbeat. +// • TAP → injects a bright travelling plasma ripple + a supersaw stab. +// +// Per-pixel plasma → screen.pixels, striding by ctx.quality to hold ~60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// Euphoric trance arpeggio in A minor (i–VI–III–VII feel), 16 steps. +const ARP = [ + "a3", "c4", "e4", "a4", "e4", "c4", "a3", "e4", + "f3", "a3", "c4", "f4", "c4", "a3", "f3", "c4", +]; +// Rolling pad chord tones (held, detuned) — swaps every 8 steps A-minor ↔ F. +const PAD_A = ["a3", "c4", "e4"]; +const PAD_F = ["f3", "a3", "c4"]; +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); + +// --- Supersaw ---------------------------------------------------------------- +// A trance supersaw = one note voiced as ~7 raw detuned sawtooth oscillators, +// each panned/detuned a touch, summed to a fat euphoric wall. +const DETUNE = [-0.22, -0.14, -0.06, 0, 0.06, 0.14, 0.22]; // semitone offsets +function midiToHz(m) { + return 440 * Math.pow(2, (m - 69) / 12); +} +function supersaw(synth, note, o = {}) { + const base = notePitch(note); // MIDI-ish + const beats = o.beats ?? 1.0; + const vol = (o.volume ?? 0.5) / DETUNE.length; // keep the sum in range + for (let i = 0; i < DETUNE.length; i++) { + const pan = ((i / (DETUNE.length - 1)) * 2 - 1) * (o.spread ?? 0.7); + synth({ + tone: midiToHz(base + DETUNE[i]), + type: "sawtooth", + beats, + attack: o.attack ?? 0.006, + decay: o.decay ?? 0.65, + volume: vol, + pan, + }); + } +} + +// --- Palette LUT (never call hslToRgb per pixel) ----------------------------- +// 256-entry euphoric hue ramp precomputed once; the arp rotates the read phase. +let LUT = null; // Uint8Array length 256*3 +function buildLUT(num) { + LUT = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + const hue = (i / 256) * 360; + // High-sat, bright liquid color; a light ripple keeps it glowing not flat. + const light = 52 + 14 * Math.sin((i / 256) * Math.PI * 2 * 2); + const [r, g, b] = num.hslToRgb(hue, 96, light); + LUT[i * 3] = r; + LUT[i * 3 + 1] = g; + LUT[i * 3 + 2] = b; + } +} + +// --- brindo state (engine owns pump/bursts/rhythm) --------------------------- +let huePhase = 0; // 0..255 palette rotation, advanced by each arp note +let huePhaseTarget = 0; // eased toward +let bloom = 0; // beat bloom pulse (brightness lift) +let bassWarp = 0; // decaying bass-driven warp amount +let padHandles = null; // held supersaw pad chord (started in onBeat) +let padIsA = true; +let ripples = []; // tap ripples: { x, y, r, life } in 0..1 space + +const CONFIG = { + bpm: 138, // classic uplifting-trance tempo + steps: ARP.length, + drawBursts: false, // brindo draws its own tap ripples into the plasma + hooks: { + onBoot({ sound, num }) { + buildLUT(num); + sound.room?.set?.({ enabled: true, mix: 0.28, feedback: 0.55 }); + }, + + // A new UTC beat crossed — fire the supersaw score + drive the allegory. + 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.6; + + // Start / swap the held supersaw PAD CHORD (rolling, detuned sustain). + const wantA = s < 8; + if (!padHandles || wantA !== padIsA) { + padHandles?.forEach?.((v) => v?.kill?.(0.4)); + padHandles = voices.padChord(synth, wantA ? PAD_A : PAD_F, { + type: "sawtooth", + volume: 0.05, + attack: 0.8, + decay: 0.9, + spread: 0.5, + }); + padIsA = wantA; + } + + // ARP note → a fat euphoric supersaw stab. + supersaw(synth, note, { beats: 0.85, volume: 0.42, decay: 0.6, pan }); + + // ALLEGORY: this note SHIFTS the palette hue phase (color cycles w/ arp). + huePhaseTarget = (huePhaseTarget + 14 + pn * 22) % 256; + + // Sub-bass root on the half-beat → WARPS the plasma + you feel it. + 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.55 }); + bassWarp = 1; + } + + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.14 }); + bloom = 1; // BEAT → bright bloom pulse + }, + + onSim() { + bloom *= 0.9; + bassWarp *= 0.92; + // Ease palette rotation toward target so hue shifts glide, not snap. + let d = huePhaseTarget - huePhase; + if (d > 128) d -= 256; + if (d < -128) d += 256; + huePhase = (huePhase + d * 0.15 + 256) % 256; + for (const rp of ripples) { + rp.r += 0.012; + rp.life -= 0.02; + } + ripples = ripples.filter((rp) => rp.life > 0); + if (ripples.length > 6) ripples = ripples.slice(-6); + }, + + // Tap = a bright plasma ripple at the tap + a supersaw stab (engine already + // bumped pump + pushed a burst). X→pitch/pan, Y→octave. + onTap({ x, y, synth }) { + ripples.push({ x, y, r: 0, life: 1 }); + bloom = Math.min(1.4, bloom + 0.7); + huePhaseTarget = (huePhaseTarget + 40) % 256; + const note = + ["a", "c", "d", "e", "g"][Math.floor(x * 5) % 5] + + (3 + Math.floor((1 - y) * 3)); + supersaw(synth, note, { beats: 0.7, volume: 0.5, decay: 0.55, pan: x * 2 - 1 }); + }, + + // PER-PIXEL PLASMA → screen.pixels. Summed sinusoids indexed into the hue + // LUT; bass warps it, arp rotates its color, beat blooms its brightness. + onPaint(api, s) { + const { screen } = api; + const { simMs, band, amp, quality } = s; + const { width: w, height: h, pixels } = screen; + if (!w || !h || !pixels || !LUT) return; + + // Adaptive cost: one computed pixel per stride×stride block. + const q = quality ?? 1; + const stride = q < 0.55 ? 3 : q < 0.8 ? 2 : 1; + + const t = simMs * 0.001; + const bass = Math.max(bassWarp, band("subBass") * 1.2); + const energy = 0.5 + amp * 0.6; // live loudness → plasma contrast + const warp = bass * 5.5; // bass amplitude → low-frequency ripple depth + const bloomLift = bloom * 60; // beat → brighter palette read + + // Precompute time-varying plasma coefficients (per frame, not per pixel). + const t1 = t * 1.7, + t2 = t * 1.1, + t3 = t * 0.6; + const cx = w * 0.5, + cy = h * 0.5; + const invW = 1 / w, + invH = 1 / h; + const hp = huePhase | 0; + + for (let y = 0; y < h; y += stride) { + const ny = y * invH; + // Bass warp: a low-frequency vertical ripple riding sub amplitude. + const wy = warp * Math.sin(ny * 3 + t3); + for (let x = 0; x < w; x += stride) { + const nx = x * invW; + // Classic summed-sinusoid plasma: a few sine layers + a radial term. + const dx = x - cx, + dy = y - cy; + const rad = Math.sqrt(dx * dx + dy * dy) * 0.03; + let v = + Math.sin(nx * 10 + t1) + + Math.sin(ny * 8 + t2 + wy) + + Math.sin((nx + ny) * 6 + t3) + + Math.sin(rad + t1 * 0.5) + + Math.sin(nx * 6 + ny * 6 + t2 * 0.7 + warp * 0.3); + + // Tap ripples: bright travelling rings added into the field. + for (let k = 0; k < ripples.length; k++) { + const rp = ripples[k]; + const ddx = nx - rp.x, + ddy = ny - rp.y; + const dd = Math.sqrt(ddx * ddx + ddy * ddy) - rp.r; + v += 2.2 * rp.life * Math.cos(dd * 22); + } + + // Map plasma value → palette index, rotated by the arp's hue phase. + // v ranges roughly -6..6; fold into 0..255 with contrast from energy. + let idx = ((v * 20 * energy + hp) % 256 + 256) % 256; + idx = idx | 0; + const li = idx * 3; + let R = LUT[li] + bloomLift; + let G = LUT[li + 1] + bloomLift; + let B = LUT[li + 2] + bloomLift; + if (R > 255) R = 255; + if (G > 255) G = 255; + if (B > 255) B = 255; + + // Fill the stride×stride block (clamped at edges). + const yEnd = y + stride < h ? y + stride : h; + const xEnd = x + stride < w ? x + stride : w; + for (let by = y; by < yEnd; by++) { + const row = by * w; + for (let bx = x; bx < xEnd; bx++) { + const i = (row + bx) * 4; + pixels[i] = R; + pixels[i + 1] = G; + pixels[i + 2] = B; + pixels[i + 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/cobo.mjs b/system/public/aesthetic.computer/disks/cobo.mjs new file mode 100644 index 0000000000..50ba967238 --- /dev/null +++ b/system/public/aesthetic.computer/disks/cobo.mjs @@ -0,0 +1,242 @@ +// cobo, 26.07.12 +// Fireworks × orchestral-hit stabs — a thin wrapper over lib/pads.mjs (the +// shared pad engine: UTC-clock beat grid, `params[0]` rate override e.g. +// `cobo 0.5`, the tap/XY "pump", audio polling, adaptive ctx.quality). This +// file only describes what makes cobo cobo: its skyward score, its cinematic +// stab, and its particle night-sky. +// ALLEGORY: each beat LAUNCHES a firework shell that arcs up and BURSTS into a +// shower of sparks (pitch → burst height + hue; high notes = high, bright, small +// bursts). The bass = the BOOM + a screen-flash on the big shells. Sparks fall +// with gravity and leave glittering trails. Every audible note fires a shell — +// the visual IS the score. Particle count scales by ctx.quality to hold 60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A rising/falling firework melody in D minor. Higher notes → higher, brighter, +// smaller bursts; low notes → low, wide, warm bursts. Even steps drop a big +// shell (boom + flash); odd steps are quick sparklers. +const MELODY = [ + "d3", "a3", "d4", "f4", "a4", "f4", "d4", "a3", + "g3", "d4", "g4", "a4", "c5", "a4", "g4", "d4", +]; +const BOOM = ["d2", "d2", "bb1", "bb1", "g1", "g1", "a1", "a1"]; // half-time root +const BIG_EVERY = 2; // a big shell every N steps + +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 = MELODY.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); + +// --- cobo-specific visual state (the engine owns pump/bursts/rhythm) -------- +let shells = []; // rising rockets: { x, y, vx, vy, hue, big, life, fuse } +let sparks = []; // exploded sparks: { x, y, vx, vy, hue, life, bright } +let flash = 0; // screen-flash on big shells, decays +let boomGlow = 0; // low horizon glow, kicked by the boom +const GRAVITY = 0.06; // downward pull per sim frame (screen units) +const DRAG = 0.985; // spark air-drag +const BASE_SPARKS = 44; // sparks per burst at quality 1 +const MAX_SPARKS = 440; // live-particle cap at quality 1 + +// Launch a shell from the ground toward a target height (0 low .. 1 high sky). +function launch(w, h, x0, height, hue, big, quality) { + const targetY = h * (0.68 - height * 0.5); // higher pitch → higher in frame + const groundY = h + 6; + const rise = groundY - targetY; + // vy chosen so the shell (under GRAVITY) tops out near targetY. + const vy = -Math.sqrt(2 * GRAVITY * rise); + shells.push({ + x: x0, + y: groundY, + vx: (Math.random() - 0.5) * w * 0.02, + vy, + hue, + big, + life: 1, + targetY, + }); +} + +// Burst a shell into a shower of sparks. Count scales with quality. +function burst(x, y, hue, big, quality) { + const n = Math.max(10, Math.round(BASE_SPARKS * (big ? 1.5 : 1) * quality)); + const spd = (big ? 3.4 : 2.2) * (1 + Math.random() * 0.25); + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2 + Math.random() * 0.3; + const s = spd * (0.4 + Math.random() * 0.6); + sparks.push({ + x, + y, + vx: Math.cos(a) * s, + vy: Math.sin(a) * s - 0.6, + hue: hue + (Math.random() - 0.5) * 40, + life: 0.8 + Math.random() * 0.4, + bright: 0.6 + Math.random() * 0.4, + }); + } +} + +const CONFIG = { + bpm: 120, + steps: MELODY.length, + drawBursts: false, // cobo draws its own firework visuals + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.28, feedback: 0.55 }); + }, + + // A new UTC beat crossed — fire the score: a cinematic orchestral-hit stab + // and the matching skyward firework. + onBeat({ idx, synth, screen }) { + const s = ((idx % MELODY.length) + MELODY.length) % MELODY.length; + const note = MELODY[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const pan = Math.sin((s / MELODY.length) * Math.PI * 2) * 0.7; + const big = s % BIG_EVERY === 0; + + // ORCHESTRAL-HIT STAB — a big layered stab with a fast attack: harp pluck + // body + shimmering bell top + a detuned saw brass + a sub thump. Cinematic. + voices.pluck(synth, note, { beats: 0.7, attack: 0.001, decay: 0.5, volume: 0.5, pan }); + voices.bell(synth, note, { beats: 0.55, volume: 0.22 + pn * 0.14, pan: -pan }); + // Detuned-saw brass swell (two saws, fast attack) — the "hit" grit. + synth({ tone: note, type: "sawtooth", beats: 0.35, attack: 0.001, decay: 0.4, volume: 0.16, pan: pan * 0.5 }); + synth({ tone: note, type: "sawtooth", beats: 0.35, attack: 0.001, decay: 0.4, volume: 0.13, pan: pan * 0.5 + 0.06 }); + voices.sub(synth, BOOM[((Math.floor(idx / 2) % BOOM.length) + BOOM.length) % BOOM.length], { beats: 0.6, attack: 0.001, decay: 0.4, volume: 0.28 }); + voices.hat(synth, { tone: 9000, beats: 0.1, volume: 0.13 + pn * 0.08 }); // sizzle + + // BIG SHELL = the BOOM + screen-flash + deep sub. + if (big) { + const bi = ((Math.floor(idx / BIG_EVERY) % BOOM.length) + BOOM.length) % BOOM.length; + voices.sub(synth, BOOM[bi], { beats: 1.6, attack: 0.002, decay: 0.55, volume: 0.55 }); + flash = Math.min(1, flash + 0.6); + boomGlow = 1; + } + + // ALLEGORY: launch a firework shell. Pitch → height + hue (high = high, + // bright, blue/white; low = low, warm, red/gold). Big shells are wider. + const w = screen?.width || 256, h = screen?.height || 256; + const x0 = w * (0.2 + (s / MELODY.length) * 0.6 + (Math.random() - 0.5) * 0.1); + const hue = 20 + pn * 300; // warm-low → cool-high + launch(w, h, x0, pn, hue, big, 1); + }, + + onSim({ quality }) { + flash *= 0.86; + boomGlow *= 0.9; + + // Advance rising shells; burst when they top out (vy turns downward past + // their target height) or their fuse runs out. + for (const sh of shells) { + sh.x += sh.vx; + sh.y += sh.vy; + sh.vy += GRAVITY; + sh.life -= 0.01; + } + const stillRising = []; + for (const sh of shells) { + if (sh.vy >= 0 || sh.y <= sh.targetY || sh.life <= 0) { + burst(sh.x, sh.y, sh.hue, sh.big, quality ?? 1); + if (sh.big) boomGlow = Math.max(boomGlow, 0.7); + } else stillRising.push(sh); + } + shells = stillRising; + + // Sparks fall with gravity + air drag, twinkle out. + for (const sp of sparks) { + sp.x += sp.vx; + sp.y += sp.vy; + sp.vy += GRAVITY; + sp.vx *= DRAG; + sp.vy *= DRAG; + sp.life -= 0.018; + } + sparks = sparks.filter((sp) => sp.life > 0); + + // Live-particle cap scales with quality (hold 60fps). + const cap = Math.max(120, Math.round(MAX_SPARKS * (quality ?? 1))); + if (sparks.length > cap) sparks = sparks.slice(-cap); + }, + + // Tap = launch an EXTRA firework from the tap point + a stab. X→pan/hue, + // Y→height/pitch (engine already bumped pump + pushed its burst). + onTap({ x, y, ex, ey, synth, screen }) { + const w = screen?.width || 256, h = screen?.height || 256; + const height = 1 - y; // top of screen → high, bright + const hue = x * 360; + launch(w, h, ex, height, hue, y < 0.5, 1); + // Immediate spark puff at the tap so it reads instantly. + burst(ex, ey, hue, y < 0.4, 1); + + const note = ["d", "f", "g", "a", "c"][Math.floor(x * 5)] + (2 + Math.floor((1 - y) * 4)); + voices.pluck(synth, note, { beats: 0.6, attack: 0.001, volume: 0.55, pan: x * 2 - 1 }); + voices.bell(synth, note, { beats: 0.4, volume: 0.3 * (1 - y), pan: x * 2 - 1 }); + synth({ tone: note, type: "sawtooth", beats: 0.3, attack: 0.001, decay: 0.4, volume: 0.14, pan: x * 2 - 1 }); + if (y > 0.5) voices.sub(synth, "d1", { beats: 0.9, volume: 0.5 }); // low tap = boom + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + + const bass = band("subBass"); + // NIGHT SKY — dark veil (trails glitter through the fade). Stronger fade so + // one veil box reads as motion-blur without extra passes. + ink(3, 4, 14, 210).box(0, 0, w, h); + // Horizon boom-glow (warm band rising from the ground on big shells) — a + // single band (was 3) to keep fixed per-frame cost low at native res. + const glow = Math.max(boomGlow, bass * 1.1); + if (glow > 0.02) + ink(120 + glow * 60, 40, 20, Math.round(40 * glow)).box(0, h * 0.62, w, h * 0.38); + + // RISING SHELLS — bright climbing dots with a fading tail. + for (const sh of shells) { + const [r, g, b] = num.hslToRgb(((sh.hue % 360) + 360) % 360, 90, 70); + ink(r, g, b, 180).line(sh.x, sh.y, sh.x - sh.vx * 3, sh.y - sh.vy * 3, sh.big ? 2 : 1); + ink(255, 255, 240, 230).circle(sh.x, sh.y, sh.big ? 2.5 : 1.6, true); + } + + // SPARKS — the shower. Bright twinkling head; glitter trail only on the + // freshest sparks (cheap: one hslToRgb + one draw for most). Twinkle + // brightness pulses so the sky sparkles. + const trails = quality > 0.75; // drop glitter trails when perf is tight + for (const sp of sparks) { + const l = 40 + sp.bright * 40 * sp.life; + const [r, g, b] = num.hslToRgb(((sp.hue % 360) + 360) % 360, 95, Math.min(90, l + sp.life * 20)); + const a = Math.round(200 * sp.life); + if (trails && sp.life > 0.6) + ink(r, g, b, Math.round(a * 0.5)).line(sp.x, sp.y, sp.x - sp.vx * 2, sp.y - sp.vy * 2, 1); + // twinkling head — occasional white flicker + const tw = sp.bright * (0.7 + 0.3 * Math.sin(sp.life * 40)); + ink(Math.min(255, r + 90 * tw), Math.min(255, g + 90 * tw), Math.min(255, b + 90 * tw), Math.min(255, a + 40)) + .circle(sp.x, sp.y, 1 + sp.life * 1.6, true); + } + + // TAP PUMP — a subtle upward shimmer haze when the user is interacting. + if (pump > 0.05 || amp > 0.1) { + const e = Math.min(1, pump * 0.3 + amp * 0.5); + ink(180, 200, 255, Math.round(20 * e)).box(0, 0, w, h); + } + + // BIG-SHELL SCREEN FLASH — the boom lights up the whole sky, briefly. + if (flash > 0.02) ink(255, 250, 235, Math.round(150 * flash)).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/flim.mjs b/system/public/aesthetic.computer/disks/flim.mjs new file mode 100644 index 0000000000..fca17c262f --- /dev/null +++ b/system/public/aesthetic.computer/disks/flim.mjs @@ -0,0 +1,247 @@ +// flim, 26.07.12 +// Spirograph epicycloid pad — a thin wrapper over lib/pads.mjs (shared pad +// engine: UTC beat grid, `flim 0.5` rate override, tap/XY pump, audio polling, +// adaptive quality). This file describes only what makes flim flim: a warm +// additive DRAWBAR-ORGAN drone and its glowing hypotrochoid curve. +// +// ALLEGORY — nested rotating gears trace one continuous neon curve. Every note +// sets a gear RATIO, so pitch → petal count (higher note = more petals). The +// beat advances the tracing angle; the bass note drives the overall rotation +// SPEED and swells a held organ drone. A phosphor veil keeps the trail glowing. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A slow modal organ progression (D dorian-ish) — each note becomes a gear. +const MELODY = [ + "d3", "a3", "d4", "f4", "a4", "f4", "d4", "a3", + "c4", "g3", "c4", "e4", "g4", "e4", "c4", "g3", +]; +const BASS = ["d2", "d2", "bb1", "bb1", "c2", "c2", "g1", "g1"]; // half-time root +const ORGAN_PARTIALS = [1, 2, 3, 4, 6, 8]; // drawbar harmonics (sine stack) + +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 semi = NOTE_SEMI[m[1]] - (m[2] ? 1 : 0); + return (parseInt(m[3], 10) + 1) * 12 + semi; +} +const MEL_PITCHES = MELODY.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); + +// Note → gear ratio → petal count. Low notes = few fat petals, high = many. +function petalsFor(n) { + return 3 + Math.round(pitchNorm(n) * 9); // 3..12 petals +} + +// --- flim-specific state (engine owns pump/bursts/rhythm/quality) ----------- +let ratio = 5; // current gear ratio (petals) — set by each note, eased toward +let targetRatio = 5; +let curveHue = 200; // curve hue, follows pitch +let targetHue = 200; +let traceAngle = 0; // tracing head angle, advanced by beat + bass speed +let spin = 0; // overall rotation, driven by bass +let droneSwell = 0; // organ drone swell, kicked by bass +let flourish = 0; // note flash (whole-curve brighten) +let perturb = 0; // tap perturbation (a wobbly burst petal) +let perturbAng = 0; +let organVoices = null; // held drawbar-organ chord handles (lazy) + +const CONFIG = { + bpm: 108, + steps: MELODY.length, + drawBursts: false, // flim draws its own tap flares in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.3, feedback: 0.55 }); + }, + + // A new UTC beat crossed — sound the note + turn the gears. + onBeat({ idx, synth }) { + const s = ((idx % MELODY.length) + MELODY.length) % MELODY.length; + const note = MELODY[s]; + const pn = pitchNorm(note); + const pan = Math.sin((s / MELODY.length) * Math.PI * 2) * 0.55; + + // ALLEGORY: this note sets the gear ratio → the curve's petal count. + targetRatio = petalsFor(note); + targetHue = 190 + pn * 150; // pitch → hue (cyan → magenta) + flourish = 1; + + // Lazily start the HELD additive drawbar-organ drone (onBoot has no + // top-level synth). A stack of raw sines at harmonic multiples = warm + // Hammond-ish tone. voices.padChord gives us live handles to retrigger. + if (!organVoices) { + const tones = ORGAN_PARTIALS.map((h) => note); // sung on the root note + organVoices = voices.padChord(synth, tones, { + type: "sine", + volume: 0.05, + attack: 0.8, + decay: 1.2, + spread: 0.25, + }); + } + + // Retrigger the organ chord voice for THIS note — additive sine partials. + ORGAN_PARTIALS.forEach((h, i) => { + synth({ + tone: note, + type: "sine", + beats: 1.4, + attack: 0.02 + i * 0.01, + decay: 0.7, + volume: (0.16 / (1 + i * 0.6)) * (0.7 + pn * 0.5), // drawbar taper + pan: pan * (i % 2 ? -1 : 1) * 0.5, + }); + }); + // A brighter triangle top adds the reedy organ shimmer on high notes. + if (pn > 0.5) + synth({ tone: note, type: "triangle", beats: 0.5, attack: 0.01, decay: 0.5, volume: 0.08 * pn, pan }); + + // BASS = rotation SPEED + a held sub drone swell on the half-beat. + 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 }); + // Low organ pedal (16' drawbar feel) — a deep sine an octave under. + synth({ tone: BASS[bi], type: "sine", beats: 2.0, attack: 0.1, decay: 0.9, volume: 0.22 }); + droneSwell = 1; + spin += 0.5 + pn * 0.4; // bass advances overall rotation + } + + // Soft tick keeps the pulse without breaking the hypnotic mood. + voices.hat(synth, { tone: 6000, beats: 0.1, volume: 0.1 }); + traceAngle += Math.PI * 0.5; // beat advances the tracing head + }, + + onSim() { + // Ease gear state so petal count MORPHS (gears mesh, not snap). + ratio += (targetRatio - ratio) * 0.12; + curveHue += (targetHue - curveHue) * 0.1; + droneSwell *= 0.94; + flourish *= 0.9; + perturb *= 0.9; + }, + + // Tap = perturb the gears (a wobbly burst petal) + an organ chord stab. + // X → pan/hue, Y → pitch. Engine already bumped pump + pushed a burst. + onTap({ x, y, ex, ey, synth, screen }) { + perturb = 1.2; + perturbAng = Math.atan2(ey - screen.height / 2, ex - screen.width / 2); + targetHue = x * 360; + // Y → pitch: pick a note up the modal scale. + const scale = ["d", "e", "f", "g", "a", "c"]; + const oct = 2 + Math.floor((1 - y) * 3); + const root = scale[Math.floor(x * scale.length) % scale.length] + oct; + // Organ chord stab — three additive sine partials, warm + immediate. + [0, 4, 7].forEach((semi, i) => { + const note = scale[(Math.floor(x * scale.length) + i * 2) % scale.length] + (oct + (i > 1 ? 1 : 0)); + synth({ tone: note, type: "sine", beats: 0.8, attack: 0.01, decay: 0.6, volume: 0.16 / (1 + i * 0.4), pan: x * 2 - 1 }); + }); + synth({ tone: root, type: "triangle", beats: 0.4, attack: 0.01, decay: 0.4, volume: 0.1, pan: x * 2 - 1 }); + targetRatio = 3 + Math.round((1 - y) * 9); + flourish = 1; + spin += 0.6; + }, + + onPaint(api, s) { + const { ink, screen, num, blur, zoom } = 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, droneSwell * 0.7 + bass * 1.1 + amp * 0.6 + pump * 0.5); + + // Phosphor trail veil — darken toward the head, keep the curve glowing. + // Slow feedback zoom gives the whole spiral a breathing drift. + zoom?.(1.004 + energy * 0.006, 0.5, 0.5); + ink(4, 3, 12, 40 + Math.round(30 * (1 - flourish))).box(0, 0, w, h); + + // Advance tracing continuously between beats + by bass-driven spin. + const head = traceAngle + beatProgress * Math.PI * 0.5 + spin * 0.15; + const R = Math.min(w, h) * 0.36 * (0.9 + energy * 0.18); + const k = ratio; // gear ratio → petal count (eased) + const r = R / k; // rolling gear radius + const d = r * (1.4 + Math.sin(spin * 0.2) * 0.3); // pen offset (hypotrochoid) + + // ADAPTIVE QUALITY: scale traced segments per frame by ctx.quality. + const BASE_SEG = 520; + const segs = Math.max(120, Math.round(BASE_SEG * quality)); + const span = Math.PI * 2 * Math.max(1, Math.round(k)); // close the curve + + // Hypotrochoid: point traced by a pen inside a gear of radius r rolling + // inside a fixed circle of radius R. Petal count follows (R-r)/r ≈ k-1. + const rot = spin * 0.1; + const [hr, hg, hb] = num.hslToRgb(((curveHue % 360) + 360) % 360, 100, 58 + flourish * 12); + const [gr, gg, gb] = num.hslToRgb(((curveHue + 40) % 360 + 360) % 360, 100, 65); + + let px = null, py = null; + for (let i = 0; i <= segs; i++) { + const t = (i / segs) * span + head * 0.15 + rot; + let x = (R - r) * Math.cos(t) + d * Math.cos(((R - r) / r) * t); + let y = (R - r) * Math.sin(t) - d * Math.sin(((R - r) / r) * t); + // Tap perturbation — a localized gear wobble near the tapped angle. + if (perturb > 0.01) { + const a = Math.atan2(y, x); + const nearness = Math.max(0, 1 - Math.abs(((a - perturbAng + Math.PI * 3) % (Math.PI * 2)) - Math.PI) / 0.9); + const bump = 1 + perturb * nearness * 0.5; + x *= bump; y *= bump; + } + const sx = cx + x, sy = cy + y; + if (px !== null) { + const glow = 120 + flourish * 100; + // Fatter warm underlay + bright core = neon. + ink(gr, gg, gb, Math.round(glow * 0.4)).line(px, py, sx, sy, 3); + ink(hr, hg, hb, glow).line(px, py, sx, sy, 1); + } + px = sx; py = sy; + } + + // Tracing HEAD — a bright spark where the pen currently is. + { + const t = span + head * 0.15 + rot; + const hx = cx + (R - r) * Math.cos(t) + d * Math.cos(((R - r) / r) * t); + const hy = cy + (R - r) * Math.sin(t) - d * Math.sin(((R - r) / r) * t); + const hr2 = 3 + flourish * 6 + pump * 4; + ink(255, 255, 255, 220).circle(hx, hy, hr2, true); + ink(hr, hg, hb, 140).circle(hx, hy, hr2 * 2.2, true); + } + + // Central gear hub — organ drone bloom, swells with bass. + if (droneSwell > 0.01 || bass > 0.04) { + const bloom = Math.max(droneSwell, bass * 1.1); + const bR = Math.min(w, h) * (0.05 + bloom * 0.16); + for (let i = 3; i > 0; i--) + ink(hr, hg, hb, Math.round(18 * bloom)).circle(cx, cy, bR * (i / 3), true); + } + + // TAP FLARES (engine-tracked bursts): quick neon rings by X-hue. + for (const b of bursts) { + const [r0, g0, b0] = num.hslToRgb(((b.hue % 360) + 360) % 360, 95, 62); + ink(r0, g0, b0, Math.round(200 * b.life)).circle(b.x, b.y, b.r, false, 2 + b.life * 3); + ink(255, 255, 255, Math.round(150 * b.life)).circle(b.x, b.y, b.r * 0.5, false, 2); + } + + if (flourish > 0.5 || pump > 1.4) blur?.(1); + + // Center pinlight heart. + const heartR = 3 + droneSwell * 8 + bass * 10 + pump * 6; + ink(hr, hg, hb, 90 + Math.round(droneSwell * 90)).circle(cx, cy, heartR * 1.6, true); + ink(255, 255, 255, 150 + Math.round(flourish * 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 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/gulmo.mjs b/system/public/aesthetic.computer/disks/gulmo.mjs new file mode 100644 index 0000000000..9238b05840 --- /dev/null +++ b/system/public/aesthetic.computer/disks/gulmo.mjs @@ -0,0 +1,286 @@ +// gulmo, 26.07.12 +// Terrain-heightfield scanlines × deep sub techno — a thin wrapper over +// lib/pads.mjs (the shared pad engine: UTC-clock beat grid, `params[0]` rate +// override e.g. `gulmo 0.5`, the tap/XY "pump", audio polling). This file only +// describes what makes gulmo gulmo: its dark techno score, its richened deep +// voices, and its neon-terrain paint. +// +// ALLEGORY — the LIVE AUDIO carves the mountains. Rows of ridge scanlines are +// drawn in perspective (nearer rows bigger); each row samples the frequency +// bands + waveform amplitude to set its ridge heights, so you literally SEE the +// sound as a scrolling 3D terrain. BASS = the tallest ridges + a valley glow +// pooled in the low ground; the four-on-floor BEAT = a fresh ridge row emitted +// at the horizon that scrolls toward you. Read the terrain and you read the +// track. Retro-wireframe neon (Tron/synthwave) look; vector lines only, and the +// row + point counts scale by ctx.quality to hold 60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// 16 steps @ 130 BPM. Four-on-floor sub kick; a filtered minor stab on the +// backbeat/syncopations; offbeat hats; a dark held pad chord underneath. +const STEPS = 16; +const KICK = "c1"; // deep sub thump root — four on the floor +// Filtered stab notes across the bar (C minor: c eb g bb), 0 = rest. +const STAB = [0, 0, "g2", 0, 0, "eb2", 0, "c3", 0, 0, "bb2", 0, "g2", 0, 0, "eb2"]; +const PAD_CHORD = ["c2", "eb2", "g2", "bb2"]; // dark Cm7 bed + +// --- gulmo-specific visual state (engine owns pump/bursts/rhythm) ----------- +// The terrain is a CONTINUOUS scrolling field of ridge rows: rows always fill +// the screen (a new one spawns at the horizon whenever the field scrolls up), +// so you never lose the mountain range. Each row carries a scroll offset `off` +// (0 = far horizon … 1 = nearest, then it recycles). Beats INJECT energy + +// color pulses that ride forward with the terrain, and emit a fresh bright +// "beat ridge" so the four-on-floor is visible marching toward you. +let rows = []; // { seed, energy, hue, peakX, peakAmt } — index i = distance +let scroll = 0; // continuous forward scroll (fractional rows) +let seedHead = 0; // running seed counter for freshly recycled rows +let bassGlow = 0; // valley glow, kicked by each sub kick +let beatFlash = 0; // horizon flash on the four-on-floor +let scanPhase = 0; // slow scroll phase for point sampling +let beatPulse = 0; // how many rows back the last beat's bright ridge is + +const BASE_ROWS = 30; // ridge rows at full quality +const BASE_PTS = 44; // points per ridge line at full quality + +// Deterministic value-noise-ish ridge shape so a row is stable as it scrolls +// but each row differs. Cheap hash → smooth-ish 1D field. +function ridgeAt(seed, t) { + // Layered sines with seed-shifted phases — a fake fractal ridge. + const a = Math.sin(t * 6.283 + seed * 1.7) * 0.5; + const b = Math.sin(t * 12.566 + seed * 3.1) * 0.28; + const c = Math.sin(t * 25.13 + seed * 5.9) * 0.14; + return Math.abs(a + b + c); // ridged (mountains rise from valley floor) +} + +const CONFIG = { + bpm: 130, + steps: STEPS, + drawBursts: false, // gulmo draws its own tap ridge-pulse in onPaint + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.24, feedback: 0.5 }); + // Pre-fill the terrain field so mountains are present from frame one. + rows = []; + scroll = 0; + seedHead = 0; + beatPulse = 999; + for (let i = 0; i < 44; i++) + rows.push({ seed: (seedHead++ % 997) * 0.618, energy: 0.15, hue: 200, peakX: -1, peakAmt: 0 }); + }, + + // A new UTC beat crossed — play the score + light up the terrain. + onBeat({ idx, synth }) { + const s = ((idx % STEPS) + STEPS) % STEPS; + + // ALLEGORY: the beat lights the horizon row bright and marks it as THE beat + // ridge, which then scrolls toward you row-by-row (beatPulse counts up as + // the terrain advances). The whole field is carved by the live bands each + // frame in onPaint, so you read the whole track as a moving mountain range. + if (rows.length) { + const head = rows[0]; + head.energy = 1; + head.hue = 190 + (s / STEPS) * 120; // cyan→magenta band sweep + } + beatPulse = 0; + beatFlash = 1; + + // Four-on-floor deep SUB kick — short thump you feel + see as valley glow. + if (s % 4 === 0) { + voices.sub(synth, KICK, { beats: 0.5, attack: 0.004, decay: 0.32, volume: 0.62 }); + bassGlow = 1; + } + + // Filtered minor STAB on the syncopations — the melodic ridge lift. + const stab = STAB[s]; + if (stab) { + const pan = Math.sin((s / STEPS) * Math.PI * 2) * 0.5; + voices.pluck(synth, stab, { beats: 0.5, decay: 0.4, volume: 0.34, pan }); + voices.bell(synth, stab, { beats: 0.4, volume: 0.12, pan: -pan }); + } + + // Offbeat HATS — the eighth-note shimmer between kicks. + if (s % 2 === 1) voices.hat(synth, { tone: 9000, beats: 0.08, volume: 0.13 }); + + // Dark PAD bed retriggered at the top of each bar (short, so it breathes). + if (s === 0) { + for (const t of PAD_CHORD) + voices.flute(synth, t, { beats: 3.2, attack: 0.4, decay: 1.4, volume: 0.07 }); + } + }, + + onSim() { + bassGlow *= 0.9; + beatFlash *= 0.85; + scanPhase += 0.006; + // Continuous scroll: the whole field advances toward the viewer. When it + // has moved a full row, RECYCLE — pop the nearest row (index 0 = closest) + // and unshift a fresh one at the horizon, so the range never empties. + scroll += 0.12; // rows-per-sim scroll speed (dense, smooth march) + while (scroll >= 1) { + scroll -= 1; + rows.pop(); // drop the nearest (it scrolled past the viewer) + rows.unshift({ + seed: (seedHead++ % 997) * 0.618, // decorrelated fresh horizon ridge + energy: 0.15, + hue: 200, + peakX: -1, + peakAmt: 0, + }); + beatPulse++; // the last beat ridge is now one row closer + } + for (const r of rows) { + r.energy *= 0.99; + r.peakAmt *= 0.95; + } + }, + + // Tap = raise a peak / send a ridge pulse from the tap x + a sub boom. + onTap({ x, y, synth }) { + // Push a peak into a swath of rows at the tapped x — a mountain rises and + // then rides the terrain toward the viewer (a scrolling ridge pulse). + const n = rows.length; + for (let i = 0; i < n; i++) { + const near = i / n; // 0 = nearest .. 1 = horizon + if (near > 0.1 && near < 0.75) { + const r = rows[i]; + r.peakX = x; + r.peakAmt = Math.max(r.peakAmt, 0.7 + (1 - y) * 0.7); + r.energy = Math.min(1, r.energy + 0.5); + } + } + bassGlow = 1; + beatFlash = Math.max(beatFlash, 0.7); + // Sub boom pitched by X, plus a stab for the strike. + const kick = ["c1", "d1", "eb1", "g1", "c2"][Math.floor(x * 5)]; + voices.sub(synth, kick, { beats: 0.6, attack: 0.004, decay: 0.4, volume: 0.7 }); + const stab = ["c3", "eb3", "g3", "bb3", "c4"][Math.floor(x * 5)]; + voices.pluck(synth, stab, { beats: 0.4, volume: 0.4, pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { ink, screen, num, blur } = api; + const { pump, beatProgress, amp, band, quality } = s; + const { width: w, height: h } = screen; + + const sub = band("subBass"); + const low = band("lowMid"); + const mid = band("mid"); + const air = band("air"); + const energy = Math.min(2, sub * 1.3 + amp * 0.7 + pump * 0.5 + beatFlash * 0.4); + + // --- Neon night backdrop (dark, low-alpha veil = subtle trails) -------- + ink(4, 2, 14, 255).box(0, 0, w, h); + + // Horizon line sits high; terrain fills below it toward the viewer. + const horizonY = h * 0.34; + // Valley-glow gradient pooled at the low ground (BASS pools in the valley). + const glow = Math.max(bassGlow, sub * 1.2); + if (glow > 0.02) { + const bands = 5; + for (let i = 0; i < bands; i++) { + const yy = h - (i / bands) * (h - horizonY); + const a = Math.round(30 * glow * (1 - i / bands)); + if (a > 1) ink(120, 20, 90, a).box(0, yy - 12, w, 24); + } + } + // Horizon beat-flash bar. + const hf = Math.round(60 + beatFlash * 140); + ink(80, 220, 255, hf).box(0, horizonY - 1, w, 2); + // Sun/afterglow disc on the horizon (synthwave motif), pulsing with bass. + const sunR = Math.min(w, h) * (0.11 + glow * 0.05); + ink(255, 60, 140, 40 + Math.round(glow * 60)).circle(w / 2, horizonY, sunR, true); + + // --- Terrain rows: nearer = bigger, brighter, wider -------------------- + // Scale row + point counts by quality to hold 60fps (vector lines only). + // rows[] index i: 0 = nearest (bottom), higher = toward horizon. The + // fractional `scroll` slides the whole grid smoothly between recycles. + const maxRows = Math.max(10, Math.round(BASE_ROWS * quality)); + const pts = Math.max(16, Math.round(BASE_PTS * quality)); + const rowCount = Math.min(maxRows, rows.length); + + // Draw HORIZON→NEAR (high index first) so nearer ridges paint over far. + for (let i = rowCount - 1; i >= 0; i--) { + const r = rows[i]; + // Distance param d: 0 = at viewer, 1 = at horizon. Add fractional scroll. + const d = Math.min(1, (i - scroll) / maxRows); + if (d < 0) continue; + const near = 1 - d; // 1 near .. 0 far + // Perspective: horizon rows bunch up top, near rows spread to the bottom. + const persp = Math.pow(near, 1.7); + const baseY = horizonY + persp * (h - horizonY); + // Ridge height budget grows toward the viewer (near mountains are tall). + const hgt = (h - horizonY) * (0.12 + persp * 0.75); + + // Audio carves this row: its own energy + the LIVE bands, so the whole + // range breathes with the current sound (bands = mountain heights). + const carve = 0.45 + r.energy * 0.6 + low * 0.7 + mid * 0.45 + sub * 0.6; + + // Is this the beat ridge? (the row the last downbeat lit, now scrolled + // `beatPulse` rows closer) — draw it extra bright/thick. + const isBeat = i === rowCount - 1 - beatPulse && beatPulse < rowCount; + + // Neon color: hue from row, lightness from nearness + energy. + const light = 48 + persp * 30 + r.energy * 26 + energy * 6; + const [cr, cg, cb] = num.hslToRgb( + (((r.hue + mid * 40) % 360) + 360) % 360, + 100, + Math.min(90, light), + ); + const alpha = Math.round(70 + persp * 165 + r.energy * 60); + const thick = isBeat ? 2 : 1; + + let px = null, py = null; + for (let j = 0; j <= pts; j++) { + const t = j / pts; + const x = t * w; + // Ridge silhouette from the fake-fractal field. Sample by seed so each + // row's mountains are stable as the grid scrolls under it. + let ridge = ridgeAt(r.seed, t) * hgt * carve; + // Tapped peak: a gaussian mountain rises at peakX and rides forward. + if (r.peakAmt > 0.01) { + const dx = t - r.peakX; + ridge += Math.exp(-(dx * dx) * 50) * hgt * r.peakAmt * 1.5; + } + // Waveform ripple riding the crest (air band = fine sparkle). + ridge += Math.sin(t * 40 + scanPhase * 6 + i) * hgt * air * 0.14; + const y = baseY - ridge; + if (px !== null) { + ink(cr, cg, cb, alpha).line(px, py, x, y, thick); + if (isBeat) ink(255, 255, 255, 120).line(px, py, x, y, 1); + } + px = x; py = y; + } + } + + // --- Tap pump flash on the near ground --------------------------------- + if (pump > 0.4) { + const a = Math.round(Math.min(120, pump * 50)); + ink(255, 120, 220, a).box(0, h - 40, w, 40); + } + + // Bass bloom on strong low frequency → soft blur for a glow bleed. + if (glow > 0.6 || beatFlash > 0.6) blur?.(1); + + // Foreground scan sparkle (air) — tiny neon dust on the near terrain. + if (air > 0.05) { + const n = Math.round(air * 20 * quality); + for (let i = 0; i < n; i++) { + const sx = (Math.sin(i * 12.9 + scanPhase * 10) * 0.5 + 0.5) * w; + const sy = horizonY + (Math.sin(i * 7.3 + scanPhase * 3) * 0.5 + 0.5) * (h - horizonY); + ink(180, 255, 255, 120).circle(sx, sy, 1, 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/loxa.mjs b/system/public/aesthetic.computer/disks/loxa.mjs new file mode 100644 index 0000000000..f4ad2fc3d2 --- /dev/null +++ b/system/public/aesthetic.computer/disks/loxa.mjs @@ -0,0 +1,293 @@ +// loxa, 26.07.12 +// Lightning-storm pad — a thin wrapper over lib/pads.mjs (the shared pad engine: +// UTC-clock beat grid, `params[0]` rate override e.g. `loxa 0.5`, the tap/XY +// "pump", audio polling). This file only describes what makes loxa loxa: its +// electric zap score, its richened FM/noise voices, and its recursive-branch +// lightning paint. +// ALLEGORY: every note STRIKES a jagged forked bolt — pitch picks the strike +// x-position, the branch angle, and the hue (electric blue→violet). Bolts flash +// bright then fade fast (strobe-like), leaving a brief afterglow. The BASS is a +// thunder FLASH filling the whole storm sky + a low rumble; the BEAT is the +// strike. The whole screen is a button: tap strikes a bolt from the tap point +// with a zap (X→pan/hue, Y→pitch). Recursion depth + branch + live-bolt counts +// scale by ctx.quality to hold 60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score ------------------------------------------------------------------ +// A driving E-minor storm loop. Each entry is a note that fires a bolt strike; +// low octaves double as thunder (flash + rumble + a felt downbeat). +const PATTERN = [ + "e3", "b3", "e4", "g4", // bar 1 — strikes climb + "e2", "b3", "d4", "b3", // bar 2 — thunder on the 1 + "a3", "e4", "a4", "e4", // bar 3 + "e2", "g4", "b4", "g4", // bar 4 — thunder + high crackle +]; +const BAR = 4; // beats per bar + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const oct = parseInt(n.slice(-1), 10) || 3; + const pc = NOTE_SEMI[n[0]] ?? 0; + return (oct + 1) * 12 + pc; // ~36..76 across this loop +} +const PATTERN_PITCHES = PATTERN.map(notePitch); +const PITCH_MIN = Math.min(...PATTERN_PITCHES); +const PITCH_MAX = Math.max(...PATTERN_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); + +// Deterministic hash → 0..1 jitter (avoids flaky raw Math.random in capture). +function hash01(x) { + const s = Math.sin(x * 127.1 + 311.7) * 43758.5453; + return s - Math.floor(s); +} + +// --- loxa-specific visual state (the engine owns pump/bursts/rhythm) --------- +let bolts = []; // active strikes: { x0,y0, x1,y1, hue, seed, life, born, width } +let flash = 0; // thunder FLASH envelope (0..1) — whole-sky white/violet fill +let rumble = 0; // eased low rumble level → sky glow +let boltId = 0; + +// Strike a forked bolt from (nx0,ny0) to (nx1,ny1) in normalized 0..1 coords. +function strike(nx0, ny0, nx1, ny1, hue, width) { + bolts.push({ + x0: nx0, y0: ny0, + x1: nx1, y1: ny1, + hue, + seed: boltId++ * 13.37 + 1, + life: 1, + born: 1, + width: width ?? 1, + }); + if (bolts.length > 10) bolts = bolts.slice(-10); +} + +const CONFIG = { + bpm: 138, + steps: PATTERN.length, + drawBursts: false, // loxa draws its own tap zaps as bolts + hooks: { + onBoot({ sound }) { + bolts = []; + flash = 0; + rumble = 0; + boltId = 0; + sound.room?.set?.({ enabled: true, mix: 0.28, feedback: 0.55 }); + }, + + // A new UTC beat crossed — fire the score + strike the matching bolt. + onBeat({ idx, synth, pump }) { + const s = ((idx % PATTERN.length) + PATTERN.length) % PATTERN.length; + const note = PATTERN[s]; + const pn = pitchNorm(note); // 0 low .. 1 high + const pan = Math.sin((s / PATTERN.length) * Math.PI * 2) * 0.7; + const boost = 1 + Math.min(1, pump * 0.3); + const isThunder = (parseInt(note.slice(-1), 10) || 3) <= 2; + + // ELECTRIC ZAP: detuned saw + square with a fast pitch-drop = FM-ish stab. + const p = notePitch(note); + const hz = 440 * Math.pow(2, (p - 69) / 12); + synth({ type: "sawtooth", tone: hz, beats: 0.18, attack: 0.001, decay: 0.32, volume: 0.34 * boost, pan }); + synth({ type: "sawtooth", tone: hz * 1.01, beats: 0.16, attack: 0.001, decay: 0.3, volume: 0.24 * boost, pan: -pan }); + synth({ type: "square", tone: hz * 2, beats: 0.09, attack: 0.001, decay: 0.2, volume: 0.14 * boost, pan }); + // Fast pitch-drop tail (the "zap"): a bright chirp at the top followed a hair + // later by an octave-down thump — reads as a rapid downward electric slide. + synth({ type: "sawtooth", tone: hz * 1.5, beats: 0.05, attack: 0.001, decay: 0.18, volume: 0.16 * boost, pan }); + synth({ type: "sawtooth", tone: hz * 0.5, beats: 0.16, attack: 0.02, decay: 0.28, volume: 0.2 * boost, pan }); + // Crackle: a burst of filtered white noise = the spark's air ionizing. + synth({ type: "noise-white", tone: 3000 + pn * 6000, beats: 0.08, attack: 0.001, decay: 0.16, volume: 0.16 * boost, pan: -pan }); + + // ALLEGORY: strike a forked bolt. Pitch → strike x-position + hue; the + // branch geometry comes from the bolt seed. High notes = tighter, whiter. + const sx = 0.12 + pn * 0.76; // strike x follows pitch left→right + const hue = 210 + pn * 70; // electric blue(210)→violet(280) + strike(sx, -0.02, sx + (hash01(s + 0.5) - 0.5) * 0.3, 1.02, hue, 1 + (1 - pn) * 0.8); + + if (isThunder) { + // THUNDER — a flash filling the sky + a low rumble you feel. + voices.sub(synth, note, { beats: 2.0, decay: 0.5, volume: 0.62 * boost }); + synth({ type: "noise-brown", tone: 90, beats: 1.4, attack: 0.02, decay: 0.6, volume: 0.3 * boost }); + flash = 1; + rumble = Math.min(1.5, rumble + 0.9); + } + }, + + onSim({ band }) { + flash *= 0.82; // fast strobe-like decay + if (flash < 0.002) flash = 0; + rumble *= 0.94; + if (!Number.isFinite(rumble)) rumble = 0; + rumble = Math.max(rumble, band("subBass") * 1.2); + for (const b of bolts) { + b.born *= 0.6; // the initial hot pop fades almost instantly + b.life -= 0.045; // bolts fade fast (strobe-like) but leave a visible afterglow + } + bolts = bolts.filter((b) => b.life > 0); + }, + + // Tap = strike a bolt from the tap point + a zap. X→pan/hue, Y→pitch. + onTap({ x, y, ex, ey, synth, screen, isDraw, pump }) { + const nx = ex / (screen?.width || 1); + const ny = ey / (screen?.height || 1); + const hue = 200 + x * 90; // blue→violet by X + // Y→pitch (top = higher). E-minor-ish scale for taps. + const scale = ["e", "g", "a", "b", "d"]; + const deg = Math.floor((1 - y) * scale.length); + const oct = 2 + Math.floor((1 - y) * 3); + const note = scale[Math.max(0, Math.min(scale.length - 1, deg))] + oct; + const p = notePitch(note); + const hz = 440 * Math.pow(2, (p - 69) / 12); + const pan = x * 2 - 1; + const boost = 1 + Math.min(1, pump * 0.3); + + // Strike DOWN from the tap point (a bolt reaching to ground below). + strike(nx, ny, nx + (hash01(nx * 7) - 0.5) * 0.24, 1.04, hue, 1.4); + // And a smaller fork reaching UP toward the sky. + strike(nx, ny, nx + (hash01(ny * 5) - 0.5) * 0.24, -0.04, hue, 0.9); + + // The zap. + synth({ type: "sawtooth", tone: hz, beats: isDraw ? 0.12 : 0.2, attack: 0.001, decay: 0.3, volume: (isDraw ? 0.22 : 0.4) * boost, pan }); + synth({ type: "square", tone: hz * 2, beats: 0.08, attack: 0.001, decay: 0.18, volume: 0.14 * boost, pan: -pan }); + synth({ type: "sawtooth", tone: hz * 1.5, beats: 0.05, attack: 0.001, decay: 0.16, volume: 0.14 * boost, pan }); + synth({ type: "sawtooth", tone: hz * 0.5, beats: 0.16, attack: 0.02, decay: 0.26, volume: 0.2 * boost, pan }); + synth({ type: "noise-white", tone: 4000 + (1 - y) * 5000, beats: 0.08, attack: 0.001, decay: 0.15, volume: 0.18 * boost, pan }); + if (!isDraw && y > 0.55) { + // Low tap → a little thunder. + voices.sub(synth, note, { beats: 1.2, decay: 0.4, volume: 0.45 * boost }); + flash = Math.max(flash, 0.6); + rumble = Math.min(1.5, rumble + 0.5); + } + }, + + onPaint(api, s) { + const { ink, screen, num, blur } = api; + const { pump, amp, quality } = s; + const { width: w, height: h } = screen; + const q = quality ?? 1; + + // Adaptive cost: recursion depth, segment count, and how many bolts we draw + // all scale with quality so we hold 60fps. + const depth = q >= 0.9 ? 4 : q >= 0.7 ? 3 : q >= 0.5 ? 2 : 1; + const segs = q >= 0.9 ? 9 : q >= 0.7 ? 7 : q >= 0.5 ? 5 : 4; + const maxDraw = q >= 0.7 ? bolts.length : Math.max(1, Math.round(bolts.length * q)); + + // --- Storm sky: dark, with a rumble glow + thunder flash veil ------------ + const glow = Math.min(1.5, rumble * 0.7 + amp * 0.4 + pump * 0.2); + // Deep storm gradient (a few horizontal bands, cheap). + const bands = 6; + for (let i = 0; i < bands; i++) { + const t = i / bands; + const r = 6 + t * 10 + glow * 14; + const g = 6 + t * 8 + glow * 10; + const b = 16 + t * 22 + glow * 30; + ink(r, g, b, 255).box(0, (h * i) / bands, w, h / bands + 1); + } + + // THUNDER FLASH — a bright violet-white veil filling the whole sky. + if (flash > 0.01) { + const [fr, fg, fb] = num.hslToRgb(270, 40, 70); + ink(fr, fg, fb, Math.round(200 * flash)).box(0, 0, w, h); + ink(255, 255, 255, Math.round(120 * flash * flash)).box(0, 0, w, h); + } + + // --- Bolts: recursive midpoint-displacement forked lightning ------------ + // Draw the most recent bolts first-priority; older ones fade out. + const drawList = bolts.slice(-maxDraw); + for (const bolt of drawList) { + const [r0, g0, b0] = num.hslToRgb(((bolt.hue % 360) + 360) % 360, 90, 62); + drawBolt( + api, + bolt.x0 * w, bolt.y0 * h, + bolt.x1 * w, bolt.y1 * h, + depth, segs, + bolt, r0, g0, b0, + Math.min(w, h), + ); + } + + // Bright flash veil / bloom when a big strike or thunder lands. + if (flash > 0.5 || pump > 1.4) blur?.(1); + + // --- Ground horizon shimmer (a felt "strike zone") ---------------------- + const horiz = h * 0.9; + const [hr, hg, hb] = num.hslToRgb(255, 60, 30 + glow * 20); + ink(hr, hg, hb, 60 + glow * 60).box(0, horiz, w, h - horiz); + }, + }, +}; + +// Recursive midpoint-displacement bolt with forking branches. Draws a jagged +// polyline from (x0,y0) to (x1,y1); at each recursion level it can spawn a child +// branch veering off at an angle. `depth`/`segs` are quality-scaled by caller. +function drawBolt(api, x0, y0, x1, y1, depth, segs, bolt, r, g, b, minWH) { + const { ink } = api; + const life = bolt.life; + const born = bolt.born; + // Build the jagged path via midpoint displacement over `segs` points. + const pts = jaggedPath(x0, y0, x1, y1, segs, bolt.seed, minWH * 0.06); + const coreA = Math.round(240 * life + 15 * born); + const haloA = Math.round(55 * life); + const glowA = Math.round(120 * life); + const haloW = (5 + bolt.width * 6) * (0.7 + born); + const glowW = (3 + bolt.width * 3) * (0.7 + born); + const coreW = Math.max(2, bolt.width * (born > 0.3 ? 3 : 2)); + + for (let i = 0; i < pts.length - 1; i++) { + const [ax, ay] = pts[i]; + const [bx, by] = pts[i + 1]; + // Three passes: a soft wide halo, a saturated glow, then the hot white core. + ink(r, g, b, haloA).line(ax, ay, bx, by, haloW); + ink(r, g, b, glowA).line(ax, ay, bx, by, glowW); + ink( + Math.min(255, r + 130), + Math.min(255, g + 130), + Math.min(255, b + 110), + coreA, + ).line(ax, ay, bx, by, coreW); + } + + // FORK: at deeper recursion, split a branch from a mid-vertex. + if (depth > 0 && pts.length > 2) { + const bi = 1 + Math.floor(hash01(bolt.seed + depth * 3.1) * (pts.length - 2)); + const [bx, by] = pts[bi]; + const [ex, ey] = pts[pts.length - 1]; + // Branch angle veers off (pitch already set base hue); jitter by seed. + const ang = Math.atan2(ey - by, ex - bx) + (hash01(bolt.seed + depth) - 0.5) * 1.4; + const len = Math.hypot(ex - bx, ey - by) * (0.35 + hash01(bolt.seed + depth * 2) * 0.35); + const cx = bx + Math.cos(ang) * len; + const cy = by + Math.sin(ang) * len; + drawBolt( + api, bx, by, cx, cy, + depth - 1, Math.max(3, segs - 2), + { ...bolt, width: bolt.width * 0.6, seed: bolt.seed * 1.7 + 5 }, + r, g, b, minWH, + ); + } +} + +// Midpoint-displacement jagged path: subdivide the segment `segs` times, jitter +// each interior point perpendicular to the line by a seeded amount. +function jaggedPath(x0, y0, x1, y1, segs, seed, amp) { + const pts = [[x0, y0]]; + const dx = x1 - x0, dy = y1 - y0; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len, ny = dx / len; // perpendicular unit + for (let i = 1; i < segs; i++) { + const t = i / segs; + const jitter = (hash01(seed + i * 2.3) - 0.5) * 2 * amp * (0.4 + Math.sin(t * Math.PI) * 0.9); + pts.push([x0 + dx * t + nx * jitter, y0 + dy * t + ny * jitter]); + } + pts.push([x1, y1]); + return pts; +} + +// 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/zim.mjs b/system/public/aesthetic.computer/disks/zim.mjs new file mode 100644 index 0000000000..49b08ea669 --- /dev/null +++ b/system/public/aesthetic.computer/disks/zim.mjs @@ -0,0 +1,264 @@ +// zim, 26.07.12 +// Volumetric smoke pad — a thin wrapper over lib/pads.mjs (the shared pad +// engine: UTC-clock beat grid, `params[0]` rate override e.g. `zim 0.5`, the +// tap/XY "pump", audio polling). This file only describes what makes zim zim: +// its ambient granular score, its softened voices, and its curl-noise smoke. +// +// ALLEGORY: soft colored smoke drifts and curls upward on a cheap flow field. +// Every audible NOTE injects a fresh puff (pitch → hue + inject height); the +// BASS sets the drift CURRENT (strength + direction) so the whole plume leans; +// ambient GRANULAR pads (held detuned chord + flute breaths + sparse bell +// grains + sub) shimmer under it. Dreamy, slow, volumetric — the smoke IS the +// score. Puff count scales by ctx.quality to hold 60fps. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score (ambient, slow, A-Dorian-ish drift) ------------------------------ +// Gently flowing melody — occasional rests read as breaths. "." = rest. +const MEL = [ + "a3", "e4", "c4", "g4", "e4", "d4", "b3", "e4", + "g3", "d4", "b3", "f4", "e4", "c4", "a3", "d4", +]; +const BASS = ["a1", "a1", "f1", "f1", "c2", "c2", "e1", "e1"]; // drift current root +const CHORD = ["a2", "e3", "a3", "b3"]; // held detuned granular bed + +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.filter((n) => n !== ".").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); + +// --- zim-specific smoke state (the engine owns pump/bursts/rhythm) ---------- +// Each puff: soft translucent blob advected by a curl-noise flow field, rising. +let puffs = []; // { x, y, vx, vy, hue, life, r, seed, bright } +let flowT = 0; // slow time for the flow field +let current = { dir: 0, strength: 0.2 }; // drift CURRENT set by the bass +let held = null; // held granular chord handles +let chordHue = 200; // slow-drifting bed hue + +// Cheap curl-of-a-sin-field flow (no fluid solver). Returns [fx, fy] at (nx,ny). +// curl(ψ) = (∂ψ/∂y, -∂ψ/∂x); ψ is a sum of sines → smooth swirling advection. +function flow(nx, ny, t) { + const psiDy = + Math.cos(nx * 3.1 + t * 0.6) * 0.9 + + Math.cos(ny * 2.3 - nx * 1.7 + t * 0.4) * 0.6; + const psiDx = + -Math.sin(ny * 2.7 - t * 0.5) * 0.9 - + Math.sin(nx * 2.1 + ny * 1.3 + t * 0.35) * 0.6; + return [psiDy, -psiDx]; +} + +function injectPuff(x, y, hue, r, bright, vy0 = -0.6) { + puffs.push({ + x, y, + vx: (Math.random() - 0.5) * 0.3, + vy: vy0 - Math.random() * 0.4, + hue, + life: 1, + r: r ?? 30 + Math.random() * 20, + seed: Math.random() * 1000, + bright: bright ?? 0.6, + }); +} + +const CONFIG = { + bpm: 84, // slow, dreamy — but enough puffs to keep the plume alive + steps: MEL.length, + drawBursts: false, // zim renders its own tap smoke + hooks: { + onBoot({ sound }) { + // Lush, long reverb for volumetric ambience. + sound.room?.set?.({ enabled: true, mix: 0.45, feedback: 0.72 }); + }, + + // A new UTC beat crossed — advance the ambient score + inject smoke. + onBeat({ idx, synth, screen }) { + const s = ((idx % MEL.length) + MEL.length) % MEL.length; + const note = MEL[s]; + const w = screen?.width || 256; + const h = screen?.height || 256; + + // Start the held granular chord on the first beat (onBoot has no synth). + if (!held) { + held = voices.padChord(synth, CHORD, { + type: "sine", + volume: 0.09, + attack: 1.2, + decay: 1.4, + spread: 0.4, + }); + } + + // BASS = drift CURRENT. Every half-beat set the current's direction from + // the bass root — the whole plume leans toward it. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + const broot = BASS[bi]; + voices.sub(synth, broot, { beats: 3.2, decay: 0.6, volume: 0.42 }); + const bpn = (notePitch(broot) - 20) / 30; // roughly 0..1 across a1..c2 + current.dir = (bpn - 0.5) * 1.4; // lean left/right by bass pitch + current.strength = 0.18 + bpn * 0.22; + } + + if (note === ".") return; // rest — a breath, no puff + + const pn = pitchNorm(note); // 0 low .. 1 high + const pan = Math.sin((s / MEL.length) * Math.PI * 2) * 0.6; + + // A soft flute breath carries the note (airy granular lead). + voices.flute(synth, note, { + beats: 2.4, + attack: 0.4, + decay: 0.6, + volume: 0.18, + pan, + }); + // Sparse bell grain shimmer on the higher notes. + if (pn > 0.55) + voices.bell(synth, note, { beats: 1.6, volume: 0.1, pan: -pan }); + + // ALLEGORY: inject a fresh colored puff — pitch → hue + inject HEIGHT + // (high notes puff near the top, low notes near the bottom). Wide hue + // span so low notes = warm amber smoke, high notes = cool teal/violet. + const hue = 30 + pn * 260; // amber → green → teal → violet as pitch rises + const injX = w * (0.3 + Math.sin(s) * 0.2 + Math.random() * 0.1); + const injY = h * (0.85 - pn * 0.5); + injectPuff(injX, injY, hue, 34 + pn * 16, 0.6 + pn * 0.4); + chordHue += 4; + }, + + onSim({ quality }) { + flowT += 0.012; + // Advect + fade every puff along the curl flow, plus the bass current. + const CUR = current; + for (const p of puffs) { + // normalize position into flow space (screen-independent-ish). + const nx = p.x / 220; + const ny = p.y / 220; + const [fx, fy] = flow(nx + p.seed, ny, flowT); + p.vx += fx * 0.5 + Math.cos(CUR.dir) * CUR.strength * 0.6; + p.vy += fy * 0.5 - 0.35; // buoyancy — smoke rises + p.vx *= 0.9; + p.vy *= 0.9; + p.x += p.vx; + p.y += p.vy; + p.r += 0.5; // slowly expand as it dissipates + p.life -= 0.012; + } + puffs = puffs.filter((p) => p.life > 0); + // Cap live puffs by quality to hold 60fps (each puff = a few filled + // circles, so this is the dominant cost knob). + const cap = Math.round(28 * quality) + 6; + if (puffs.length > cap) puffs = puffs.slice(-cap); + }, + + // Tap = puff a BURST of smoke at the tap point + a soft swelling tone. + // X → pan/hue, Y → pitch. (Engine already bumped pump + pushed a burst.) + onTap({ x, y, ex, ey, synth }) { + const hue = 180 + x * 160; + const n = 5 + Math.floor(x * 4); + for (let i = 0; i < n; i++) { + const ang = Math.random() * Math.PI * 2; + const rad = Math.random() * 26; + injectPuff( + ex + Math.cos(ang) * rad, + ey + Math.sin(ang) * rad, + hue + (Math.random() - 0.5) * 40, + 26 + Math.random() * 24, + 0.9, + -0.4, + ); + } + // Soft swelling tone — Y → pitch, X → pan. + const note = ["a", "c", "d", "e", "g"][Math.floor(x * 5) % 5] + + (2 + Math.floor((1 - y) * 4)); + voices.flute(synth, note, { + beats: 2.2, + attack: 0.5, + decay: 0.7, + volume: 0.24, + pan: x * 2 - 1, + }); + voices.bell(synth, note, { beats: 1.2, volume: 0.16, pan: x * 2 - 1 }); + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, amp, band, quality } = s; + const { width: w, height: h } = screen; + + const bass = band("subBass") + band("lowMid") * 0.5; + const energy = Math.min(1.6, bass * 1.4 + amp * 0.5 + pump * 0.4); + + // Soft dark veil — long trails so smoke lingers (dreamy, volumetric). + ink(6, 8, 18, 44 + (1 - quality) * 20).box(0, 0, w, h); + + // Faint drifting bed glow — the granular chord made visible, leaning with + // the current so you SEE the drift direction. Kept dim so puffs dominate. + const [br, bg, bb] = num.hslToRgb( + ((chordHue % 360) + 360) % 360, 35, 13 + energy * 7, + ); + const lean = Math.cos(current.dir) * current.strength; + // Smaller when quality is low (a big filled circle is O(r²) fill cost). + const bedR = Math.min(w, h) * (0.3 + energy * 0.14) * (0.7 + quality * 0.3); + ink(br, bg, bb, 16 + energy * 18).circle( + w * (0.5 + lean * 0.4), h * 0.55, bedR, true, + ); + + // SMOKE PUFFS — soft translucent layered blobs. Each drawn as a few + // concentric fading circles for a volumetric feel. Layers scale by quality. + const layers = quality < 0.6 ? 2 : 3; + for (const p of puffs) { + const [r0, g0, b0] = num.hslToRgb( + ((p.hue % 360) + 360) % 360, + 85, + 52 + p.bright * 22, + ); + // Volumetric: inner layers are brighter/denser, outer are soft haze. + for (let L = layers; L >= 1; L--) { + const rr = p.r * (L / layers); + const la = (90 * p.life * (layers - L + 1)) / layers; + ink(r0, g0, b0, la).circle(p.x, p.y, rr, true); + } + // Bright wisp core. + ink( + Math.min(255, r0 + 90), + Math.min(255, g0 + 90), + Math.min(255, b0 + 90), + 170 * p.life, + ).circle(p.x, p.y, 2 + p.bright * 4, true); + } + + // A drift indicator: faint current streaks (only when moving) — cheap. + if (quality > 0.6 && current.strength > 0.15) { + const cy = h * 0.9; + for (let i = 0; i < 5; i++) { + const sx = w * (0.15 + i * 0.18); + const dx = Math.cos(current.dir) * 20 * current.strength * 3; + ink(120, 160, 200, 24).line(sx, cy, sx + dx, cy - 6, 1); + } + } + + // Gentle bloom on strong beats / taps. + if (pump > 1.0 || bass > 0.35) { + ink(200, 220, 255, 20 + pump * 20).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's +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act }; -- 2.51.2