diff --git a/system/public/aesthetic.computer/disks/frizmo.mjs b/system/public/aesthetic.computer/disks/frizmo.mjs new file mode 100644 index 0000000000..fdbcbb0c3f --- /dev/null +++ b/system/public/aesthetic.computer/disks/frizmo.mjs @@ -0,0 +1,372 @@ +// frizmo, 26.07.12 +// FIZZY-STATIC SPARKLE instrument — champagne fizz / effervescence. +// +// FOUR PRINCIPLES (see marketing/av-reels/button-sync-allegory.md): +// 1. NO resolution() — native res. The noise fizz is written DIRECTLY to +// screen.pixels (no paste-of-buffer, which can render black headless). +// Crisp vector sparks/bubbles are drawn on top. +// 2. THE WHOLE PIECE IS A BUTTON — tap / XY-drag erupts a FOUNTAIN of sparks +// + a fizzy burst at the tap point (X→pan/hue, Y→pitch). Repeated taps +// accumulate a decaying `pump` that makes the whole field fizz harder +// (denser + brighter grain, more sparks). +// 3. UTC-SYNCED RHYTHM — blip + kick onsets come from clock.time() inside +// sim() (NOT beat()). We compute the absolute global 16th-step from +// epoch-ms and index the pattern by floor(step)%len, so two instances +// opened anywhere lock to the same grid and each other. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY (timbre→texture is the key): +// • NOISE FIZZ = a live GRAIN SPRAY of shimmering pixels. Grain density +// & brightness ∝ the fizz amplitude — you literally SEE the noise as +// static/grain. +// • BLIP (pitched click) = a bright SPARK that pops and RISES like a +// bubble; height & hue ∝ pitch (high blip = high, cyan/white). +// • KICK = a BURST of bubbles ERUPTING from the bottom edge. +// So: rough noise reads as grainy static, pitched blips read as rising +// sparks, kick reads as an eruption from below. + +// --- UTC-synced groove grid ---------------------------------------------- +// 16th-note grid at a lively BPM. Absolute-from-epoch → every instance +// computes the identical step at the identical wall-clock ms. +const QUARTER_BPM = 132; // lively, sparkly feel +const STEP_MS = 60000 / QUARTER_BPM / 4; // ms per 16th-note step (~113ms) + +// KICK on the 4 downbeats (steps 0,4,8,12) → bubble eruption from the bottom. +// High BLIPS arpeggiate every step → rising sparks. Bright, effervescent scale. +const BLIP = [ + "c6", "g5", "e6", "a5", + "d6", "b5", "g6", "e6", + "c6", "a5", "d6", "g5", + "e6", "b5", "a6", "d6", +]; +// Pitch range spanned by BLIP, used to map note → spark launch height / hue. +const BLIP_LOW = 783.99; // g5 +const BLIP_HIGH = 1760.0; // a6 + +// Precomputed BLIP note → Hz (equal-temperament) — no sound.freq in hot path. +const BLIP_FREQ = { + g5: 783.99, a5: 880.0, b5: 987.77, + c6: 1046.5, d6: 1174.66, e6: 1318.51, g6: 1567.98, a6: 1760.0, +}; + +let lastStep = -1; // last integer 16th-step fired (UTC scheduler) +let stepProgress = 0; // 0..1 through the current 16th step + +let fizz = 0.35; // baseline fizz amplitude → grain density (0..~1.4) +let kick = 0; // decays; drives bottom bubble eruption + low pulse +let flash = 0; // decays; whole-field per-step sparkle + +// --- Tap / button energy ------------------------------------------------- +let pump = 0; // decaying global energy from taps/drags, 0..~3 +let bursts = []; // fizzy burst rings spawned by taps {x,y,r,vel,life,hue,r0,g0,b0} + +// --- Particles ----------------------------------------------------------- +let sparks = []; // rising bubbles/sparks (blip onsets + tap fountains) +let seed = 1234567; // frame-advanced hash seed (Math.random may be flaky headless) + +// Cheap deterministic hash PRNG — self-run identically, no Math.random reliance. +function rnd() { + // xorshift32 + seed ^= seed << 13; seed |= 0; + seed ^= seed >> 17; + seed ^= seed << 5; seed |= 0; + return ((seed >>> 0) % 100000) / 100000; // 0..1 +} + +// --- Lifecycle ----------------------------------------------------------- + +let bed = null; // sustained airy fizz bed (noise floor so the reel always sings) + +function boot({ sound, clock }) { + clock?.resync?.(); // fetch UTC offset (silent local fallback offline — fine) + sound.room?.set?.({ enabled: true, mix: 0.12, feedback: 0.35 }); + // A quiet sustained noise bed = the ever-present champagne hiss. Its volume + // breathes with fizz + tap energy in sim() so the grain always has a voice. + bed = sound.synth?.({ type: "noise-white", tone: 2400, duration: "🔁", + attack: 0.5, decay: 0.99, volume: 0.16 }); +} + +// Map a frequency (Hz) → normalized 0..1 across the BLIP pitch range. +function pitchNorm(freq) { + const t = (Math.log2(freq) - Math.log2(BLIP_LOW)) / + (Math.log2(BLIP_HIGH) - Math.log2(BLIP_LOW)); + return Math.max(0, Math.min(1, t)); +} + +// Fire everything for a single 16th step. Called from the UTC scheduler in sim +// so onsets align across instances. `synth` is sound.synth. +function fireStep(step, synth, screen) { + const bar = ((step % 16) + 16) % 16; // 0..15 + const STEP_S = STEP_MS / 1000; // ~0.113s + + // KICK on the 4 downbeats → punchy low thump + a burst of bubbles erupting + // from the bottom edge. This is the effervescence "pop" from below. + if (bar % 4 === 0) { + synth({ tone: "c2", type: "sine", duration: STEP_S * 3, attack: 0.004, + decay: 0.35, volume: 0.9 }); + synth({ tone: "c1", type: "sawtooth", duration: STEP_S * 2, attack: 0.003, + decay: 0.3, volume: 0.55 }); // body + // A short fizz "chsss" rides the kick — the cork pop. + synth({ type: "noise-white", tone: 1200, duration: STEP_S * 1.5, + attack: 0.002, decay: 0.4, volume: 0.5 }); + kick = 1; + fizz = Math.min(1.4, fizz + 0.55); // the kick sprays the grain harder + // Eruption of bubbles from random spots along the bottom edge. + const w = screen?.width || 360, h = screen?.height || 640; + const n = 26; + for (let i = 0; i < n && sparks.length < 380; i++) { + const bx = rnd() * w; + sparks.push({ + x: bx, y: h + 2, + vx: (rnd() - 0.5) * 2.2, vy: -(3.5 + rnd() * 6.5), // shoot upward + grav: -0.02 - rnd() * 0.03, // gentle rise / buoyancy + life: 1, decay: 0.012 + rnd() * 0.01, + hue: 0.5 + rnd() * 0.12, // cyan-ish champagne + big: rnd() < 0.35, kind: "bubble", + }); + } + } + + // BLIP on every step → a bright pitched click + a rising SPARK bubble whose + // height & hue ∝ pitch (high blip = high on screen, bright cyan/white). + const bn = BLIP[bar % BLIP.length]; + const pan = -0.7 + (bar % BLIP.length) / (BLIP.length - 1) * 1.4; + const accent = bar % 4 === 0; // downbeat blips a touch louder + synth({ tone: bn, type: "square", duration: STEP_S * 0.35, attack: 0.001, + decay: 0.25, volume: accent ? 0.42 : 0.3, pan }); + // A brighter sine ping doubles the click for sparkle. + synth({ tone: bn, type: "sine", duration: STEP_S * 0.5, attack: 0.001, + decay: 0.3, volume: 0.24, pan }); + // Tiny high fizz tick between blips → shimmer. + if (bar % 2 === 1) { + synth({ type: "noise-white", tone: 5000, duration: STEP_S * 0.25, + attack: 0.001, decay: 0.15, volume: 0.18 }); + fizz = Math.min(1.4, fizz + 0.12); + } + + const freq = typeof bn === "number" ? bn : BLIP_FREQ[bn]; + const pn = pitchNorm(freq); // 0 low .. 1 high + const w = screen?.width || 360, h = screen?.height || 640; + // Rising spark: born low, flies up toward a height ∝ pitch. Pitch → hue too. + sparks.push({ + x: w * (0.15 + (bar % BLIP.length) / BLIP.length * 0.7) + (rnd() - 0.5) * 20, + y: h * (0.86 - pn * 0.1), // starts a bit up for high notes + vx: (rnd() - 0.5) * 1.2, + vy: -(2.5 + pn * 4.5), // higher pitch rises faster/farther + grav: -0.015 - pn * 0.02, + life: 1, decay: 0.016 + (1 - pn) * 0.01, + hue: 0.5 + pn * 0.13, // pitch → hue (cyan→bright) + big: accent, kind: "spark", pn, + }); + + flash = 1; // whole-field sparkle pulse on every step +} + +function sim({ sound: { speaker, synth }, clock, screen }) { + speaker?.poll(); // mandatory for audio-reactive reads + + // --- UTC-synced scheduler: derive the beat grid from the shared clock --- + // ⚠️ clock.time() returns an *Invalid Date* (getTime()→NaN, not null) before + // the UTC offset resolves and offline. `?? new Date()` does NOT catch it — + // guard with isFinite or the step goes NaN → onset spam every tick. + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); // local fallback; prod uses UTC + const globalStep = ms / STEP_MS; + const idx = Math.floor(globalStep); + stepProgress = globalStep - idx; // 0..1 within this 16th step + if (idx !== lastStep) { + lastStep = idx; + fireStep(idx, synth, screen); + } + + // Advance sparks/bubbles: rise, buoyancy, gentle horizontal drift, fizz jitter. + const jit = 0.4 + pump * 0.3; + for (let i = 0; i < sparks.length; i++) { + const p = sparks[i]; + p.x += p.vx + (rnd() - 0.5) * jit; // fizzy wobble + p.y += p.vy; + p.vy += p.grav; // buoyancy pulls up (negative grav) + p.vx *= 0.985; + if (p.vy < -9) p.vy = -9; + p.life -= p.decay * (1 + pump * 0.15); + } + sparks = sparks.filter((p) => p.life > 0 && p.y > -20); + + // Advance tap burst rings. + for (let i = 0; i < bursts.length; i++) { + const b = bursts[i]; + b.r += b.vel * (1 + pump * 0.15); + b.vel *= 0.985; + b.life -= 0.02; + } + bursts = bursts.filter((b) => b.life > 0); + + // Decay pulses & tap energy; fizz relaxes toward a pump-boosted baseline. + flash *= 0.85; + kick *= 0.9; + pump *= 0.965; // slow decay so repeated taps accumulate + const fizzBase = 0.3 + pump * 0.35; + fizz += (fizzBase - fizz) * 0.12; // ease back toward baseline + if (fizz < 0.05) fizz = 0.05; + + // Bed breathes: the champagne hiss swells with fizz + tap energy. + bed?.update?.({ volume: 0.12 + fizz * 0.14 + pump * 0.05 }); +} + +// --- Button: the whole screen erupts a fountain on tap / XY-drag ---------- +function act({ event: e, sound: { synth }, screen, num }) { + if (!(e.is("touch") || e.is("draw"))) return; + + const nx = e.x / screen.width; // 0..1 + const ny = e.y / screen.height; // 0..1 + const drag = e.is("draw"); + + pump = Math.min(3, pump + (drag ? 0.14 : 0.95)); // taps punch, drags feed + fizz = Math.min(1.4, fizz + (drag ? 0.15 : 0.6)); + + const hue = 0.5 + nx * 0.2; // X → hue (champagne cyan→gold-ish) + const [br, bg, bb] = hue2rgb(num, hue, 0.9, 0.62); + + // Fizzy burst ring at the tap point. + bursts.push({ x: e.x, y: e.y, r: 0, vel: drag ? 8 : 15, life: 1, hue, + r0: br, g0: bg, b0: bb }); + + // Spark FOUNTAIN erupting from the tap point (upward-biased, denser on a tap). + const n = drag ? 8 : 26; + for (let i = 0; i < n && sparks.length < 400; i++) { + const ang = -Math.PI / 2 + (rnd() - 0.5) * Math.PI * 1.1; // upward fan + const sp = 2 + rnd() * (drag ? 4 : 9); + sparks.push({ + x: e.x, y: e.y, + vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, + grav: -0.02 - rnd() * 0.03, + life: 1, decay: 0.02 + rnd() * 0.02, + hue: hue + (rnd() - 0.5) * 0.1, big: rnd() < 0.3, kind: "spark", + }); + } + + // SONIC BURST — X→pan/hue (via note choice), Y→pitch (top = high). + const scale = ["c", "d", "e", "g", "a"]; + const oct = 4 + Math.floor((1 - ny) * 3); // 4..6, higher up = higher + const note = scale[Math.min(4, Math.floor(nx * 5))] + oct; + synth({ tone: note, type: "square", duration: drag ? 0.18 : 0.4, + attack: 0.002, decay: 0.4, volume: drag ? 0.35 : 0.6, pan: nx * 2 - 1 }); + // Bright fizzy chsss burst — brighter higher up the screen. + synth({ type: "noise-white", tone: 2000 + (1 - ny) * 4000, + duration: drag ? 0.15 : 0.35, attack: 0.001, decay: 0.35, + volume: (drag ? 0.22 : 0.4) * (0.6 + (1 - ny) * 0.6), pan: nx * 2 - 1 }); + // Sub thump so a full tap has body. + if (!drag) { + synth({ tone: "c2", type: "sine", duration: 0.4, attack: 0.003, + decay: 0.3, volume: 0.5 }); + } +} + +// hue 0..1, sat/light 0..1 → [r,g,b] 0..255. Wraps num.hslToRgb (wants hue in +// DEGREES + sat/light 0..100, and already returns 0..255 — do NOT ×255). +function hue2rgb(num, hue, sat, light) { + return num.hslToRgb(((hue % 1) + 1) % 1 * 360, sat * 100, light * 100); +} + +function paint({ ink, circle, line, plot, screen, sound, num, paintCount }) { + const w = screen.width, h = screen.height; + const pixels = screen.pixels; + const pc = Number(paintCount); + + // --- Live audio garnish (visuals never DEPEND on it) --- + const bands = sound.speaker?.frequencies?.left || []; + const air = bands.find((b) => b.name === "air")?.amplitude || 0; + const treble = bands.find((b) => b.name === "treble")?.amplitude || 0; + const amp = sound.speaker?.amplitudes?.left || 0; + + // === Dark fizzy ground: a deep-navy vertical wash, brightening toward the + // bottom (where bubbles are born) — written directly to screen.pixels. === + // The GRAIN density/brightness ∝ fizz amplitude → you SEE the noise as sparse + // BRIGHT speckles popping on a dark ground (discrete grains, not a wash). + // Kick brightens the lower band (eruption zone). + const density = Math.min(0.34, 0.06 + fizz * 0.16 + amp * 0.1 + pump * 0.04); + const speckBright = 150 + fizz * 90 + treble * 60; // grain brightness + const kb = kick; // 0..1 bottom eruption glow + // Advance the hash seed per frame so speckle animates (NOT Math.random). + seed = (seed ^ (pc * 2654435761)) | 0; + seed ^= seed << 13; seed |= 0; + + for (let y = 0; y < h; y++) { + const vy = y / h; // 0 top .. 1 bottom + // Base gradient: near-black top → deep teal-navy bottom, with an eruption + // brightening at the very bottom on the kick. Kept DARK so grains pop. + const bgR = 2 + vy * 3 + kb * (vy > 0.7 ? (vy - 0.7) * 55 : 0); + const bgG = 4 + vy * 9 + kb * (vy > 0.7 ? (vy - 0.7) * 85 : 0); + const bgB = 9 + vy * 20 + kb * (vy > 0.7 ? (vy - 0.7) * 115 : 0); + let row = y * w; + for (let x = 0; x < w; x++) { + const i = (row + x) * 4; + // Cheap per-pixel hash → 0..1 speckle, animated by frame via seed. + let hshr = (x * 374761393 + y * 668265263 + seed) | 0; + hshr = (hshr ^ (hshr >> 13)) * 1274126177; + const rr = ((hshr ^ (hshr >> 16)) >>> 0) / 4294967295; // 0..1 + if (rr < density) { + // A grain of champagne static — bright cyan/white speckle. Brightness + // varies per grain so the fizz shimmers rather than reading as a mask. + const b = speckBright * (0.35 + rr / density * 0.65); + pixels[i] = Math.min(255, bgR + b * 0.65); + pixels[i + 1] = Math.min(255, bgG + b * 0.92); + pixels[i + 2] = Math.min(255, bgB + b); + } else { + pixels[i] = bgR; + pixels[i + 1] = bgG; + pixels[i + 2] = bgB; + } + pixels[i + 3] = 255; + } + } + + // === BLIP/BUBBLE sparks = rising bubbles; height & hue read the pitch === + for (let i = 0; i < sparks.length; i++) { + const p = sparks[i]; + const light = 0.55 + (p.big ? 0.25 : 0.1) + flash * 0.1; + const [r, g, b] = hue2rgb(num, p.hue, 0.85, Math.min(0.9, light)); + const rad = (p.big ? 2.2 : 1.3) + p.life * (p.big ? 2.5 : 1.6); + // Soft glow then bright core → reads as a shimmering bubble. + ink(r, g, b, 90 * p.life).circle(p.x, p.y, rad + 1.5, true); + ink(Math.min(255, r + 70), Math.min(255, g + 70), Math.min(255, b + 70), + 240 * p.life).circle(p.x, p.y, rad, true); + // A tiny white highlight = the bubble's shine. + ink(255, 255, 255, 200 * p.life).plot((p.x - rad * 0.4) | 0, + (p.y - rad * 0.4) | 0); + } + + // === KICK = eruption glow band along the bottom edge === + if (kick > 0.02) { + for (let k = 0; k < 5; k++) { + const [r, g, b] = hue2rgb(num, 0.52, 0.9, 0.55); + ink(r, g, b, 40 * kick * (1 - k / 5)).box(0, h - (k + 1) * 8 * kick, + w, 8 * kick); + } + ink(220, 255, 255, 120 * kick).box(0, h - 3, w, 3); + } + + // === Tap FIZZY BURST rings — the whole-piece "button" made visible === + for (let i = 0; i < bursts.length; i++) { + const b = bursts[i]; + ink(b.r0, b.g0, b.b0, 190 * b.life).circle(b.x, b.y, b.r, false, + 2 + b.life * 5); + const [ir, ig, ib] = hue2rgb(num, b.hue + 0.08, 0.9, 0.72); + ink(ir, ig, ib, 140 * b.life * b.life).circle(b.x, b.y, b.r * 0.4, true); + ink(255, 255, 255, 220 * b.life * b.life).circle(b.x, b.y, 3 + b.life * 7, + true); + } + + // === Per-step sparkle pulse — a faint bright veil flash on each blip === + if (flash > 0.15 && treble > 0.02) { + ink(180, 255, 255, 18 * flash * (1 - stepProgress)).box(0, 0, w, h); + } +} + +function leave() { + bed?.kill?.(0.5); // fade the champagne hiss out on exit + bed = null; +} + +// Note: there is intentionally NO `beat` export. All onsets come from the UTC +// scheduler in sim() (indexed by floor(globalStep)) so two instances opened +// anywhere auto-align. +export { boot, sim, paint, act, leave }; diff --git a/system/public/aesthetic.computer/disks/gloob.mjs b/system/public/aesthetic.computer/disks/gloob.mjs new file mode 100644 index 0000000000..c94aef120f --- /dev/null +++ b/system/public/aesthetic.computer/disks/gloob.mjs @@ -0,0 +1,319 @@ +// gloob, 26.07.12 +// Gooey metaball-voices instrument: each blob is a VOICE; blobs that sound at the +// same time SWELL, brighten (hue = pitch) and physically MERGE into one gooey mass +// — you SEE the chord as goo gluing together, then splitting as notes release. A +// warm squelchy "gloob" bass sets the field viscosity; a soft kick jiggles the whole +// goo with a shockwave. Whole screen is a button: tap/drag spawns a hot new blob-voice +// (X→pan/hue, Y→pitch). Rhythm is UTC-synced (clock.time() in sim) so two instances +// glob in lockstep. Native-res per-pixel field written directly to screen.pixels. + +// ── UTC-synced rhythm ──────────────────────────────────────────────────────── +const BPM = 104; +const BEAT_MS = 60000 / BPM; // ms per beat + +// One warm gooey loop. Each entry is a note; its index picks which resident blob +// voice sounds. Chords happen when consecutive beats reuse/overlap voices → they +// merge. Bass anchors even beats; a soft kick fires on the downbeat of each bar. +// notes in a low-mid warm register; keeps the "deep, squishy, cute" identity. +const PATTERN = [ + "c2", "g2", "e3", "g2", // bar 1 — bass + a mid bloop + "a2", "e3", "c3", "g3", // bar 2 — voices climb, some overlap + "f2", "c3", "a3", "c3", // bar 3 + "g2", "d3", "g3", "e3", // bar 4 +]; +const BAR = 4; // beats per bar (kick lands on step % BAR === 0) + +// ── Resident blob voices ───────────────────────────────────────────────────── +// 6 gooey charges that drift; each is a "voice" that swells + glows when it sounds. +const VOICE_COUNT = 6; +let voices = []; // { hx, hy (0..1 home), x, y, vx, vy, base, charge (0..1 loud), hue } + +// Taps spawn extra transient blobs that decay away. +let sparks = []; // { x, y, vx, vy, charge, life, hue, note } + +let pump = 0; // decaying global energy from taps (0..~3) → heat/thicken the goo +let bass = 0; // eased low-end level → field viscosity/gloss +let kick = 0; // shockwave envelope (0..1), jiggles the whole goo +let kickWave = 0; // expanding radius of the current shockwave (0..1) + +let lastBeat = -1; +let beatProgress = 0; // 0..1 within current beat +let simMs = 0; +let bootedMs = 0; + +function boot({ clock, sound, screen, num }) { + clock?.resync?.(); // fetch UTC offset (silent no-op offline → local fallback) + sound?.bpm?.(BPM); + bootedMs = Date.now(); + const cx = 0.5; + voices = []; + for (let i = 0; i < VOICE_COUNT; i++) { + const a = (i / VOICE_COUNT) * Math.PI * 2; + const hx = cx + Math.cos(a) * 0.26; + const hy = 0.5 + Math.sin(a) * 0.3; + voices.push({ + hx, hy, + x: hx, y: hy, + vx: 0, vy: 0, + base: 0.5 + (i % 3) * 0.18, // resting blob size + charge: 0, + hue: 320 - (i / VOICE_COUNT) * 150, // spread along the magenta→teal candy ramp + }); + } +} + +// ── Audio + physics on the UTC grid (NOT beat()) ───────────────────────────── +function onBeat(step, idx, synth) { + const note = PATTERN[step]; + const voice = voices[step % VOICE_COUNT]; + if (!voice) return; + + // Hue tracks pitch across the WHOLE candy wheel so voices read as distinct colors. + // Map the chromatic pitch class + octave to a hue: low = magenta/red, high = cyan/blue. + const oct = parseInt(note.slice(-1), 10) || 3; + const names = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; + const pc = names[note[0]] ?? 0; + const midi = (oct + 1) * 12 + pc; // ~24..60 here + const tt = Math.max(0, Math.min(1, (midi - 24) / (60 - 24))); + const hue = ((320 + tt * (200 - 320)) + 360) % 360; // magenta(low)→teal(high) ramp + voice.hue = hue; + voice.charge = Math.min(1.6, voice.charge + 1.0); // SWELL + glow now + voice.note = note; + + const isBass = oct <= 2; + const boost = 1 + Math.min(1, pump * 0.35); + + // The gloob voice: sine core + soft triangle body → wet, rounded, squelchy. + synth({ + tone: note, + type: "sine", + beats: isBass ? 1.9 : 1.2, + attack: 0.02, + decay: isBass ? 0.5 : 0.62, + volume: (isBass ? 0.6 : 0.42) * boost, + pan: (voice.x - 0.5) * 1.4, + }); + synth({ + tone: note, + type: "triangle", + beats: 0.55, + attack: 0.01, + decay: 0.4, + volume: (isBass ? 0.22 : 0.3) * boost, + pan: (voice.x - 0.5) * 1.4, + }); + + if (isBass) bass = Math.min(1.4, bass + 0.7); // thicken/gloss the whole field + + // Soft kick on each bar downbeat → shockwave that jiggles the goo. + if (step % BAR === 0) { + kick = 1; + kickWave = 0; + synth({ tone: "c1", type: "sine", beats: 0.5, attack: 0.005, decay: 0.28, volume: 0.7 * boost }); + synth({ type: "noise-white", tone: 200, beats: 0.08, attack: 0.001, decay: 0.2, volume: 0.14 * boost }); + // shove every blob outward a touch for a squishy jiggle + for (const v of voices) { + v.vx += (v.x - 0.5) * 0.02; + v.vy += (v.y - 0.5) * 0.02; + } + } +} + +function sim({ sound: { speaker, synth }, clock, num }) { + speaker?.poll(); // mandatory before audio reads + + // UTC-synced beat grid. clock.time() is an Invalid Date (getTime()→NaN) before + // the offset resolves and offline; guard with isFinite or onBeat spams every tick. + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); // local fallback; prod uses synced UTC + simMs = ms; + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; + if (idx !== lastBeat) { + lastBeat = idx; + const step = ((idx % PATTERN.length) + PATTERN.length) % PATTERN.length; + onBeat(step, idx, synth); + } + + // Envelopes. + pump *= 0.965; + if (pump < 0.001) pump = 0; + kick *= 0.9; + if (kick < 0.001) kick = 0; + kickWave = Math.min(1.4, kickWave + 0.045); // shockwave expands + bass *= 0.97; + if (!Number.isFinite(bass)) bass = 0; + + // Read live low end to keep the field breathing even between scheduled bass notes. + const bands = speaker?.frequencies?.left || []; + const sub = bands.find((b) => b.name === "subBass")?.amplitude || 0; + bass = Math.max(bass, sub * 1.1); + + // Blob physics — drift toward home, wobble, gooey attraction so near voices merge. + const t = simMs * 0.001; + for (let i = 0; i < voices.length; i++) { + const v = voices[i]; + if (!Number.isFinite(v.x)) { v.x = v.hx; v.y = v.hy; v.vx = 0; v.vy = 0; } + // organic home orbit + const ho = 0.06 * (1 + pump * 0.2); + const tx = v.hx + Math.sin(t * 0.6 + i * 1.7) * ho; + const ty = v.hy + Math.cos(t * 0.5 + i * 2.3) * ho; + v.vx += (tx - v.x) * 0.02; + v.vy += (ty - v.y) * 0.02; + // gooey pull toward other charged voices → chords glue together + for (let j = 0; j < voices.length; j++) { + if (j === i) continue; + const o = voices[j]; + const dx = o.x - v.x, dy = o.y - v.y; + const d2 = dx * dx + dy * dy + 0.0004; + const pull = (v.charge + o.charge) * 0.0009 / d2; + v.vx += dx * pull; + v.vy += dy * pull; + } + v.vx *= 0.86; v.vy *= 0.86; + v.x += v.vx; v.y += v.vy; + v.charge *= 0.955; // release + if (!Number.isFinite(v.charge)) v.charge = 0; + } + + // Transient tap sparks. + for (let s = sparks.length - 1; s >= 0; s--) { + const sp = sparks[s]; + sp.x += sp.vx; sp.y += sp.vy; + sp.vx *= 0.9; sp.vy *= 0.9; + sp.charge *= 0.94; + sp.life -= 0.012; + if (sp.life <= 0 || sp.charge < 0.03) sparks.splice(s, 1); + } +} + +// ── Whole piece is a button ────────────────────────────────────────────────── +function act({ event: e, screen, sound: { synth } }) { + if (e.is("touch") || e.is("draw")) { + const x = e.x / screen.width; + const y = e.y / screen.height; + pump = Math.min(3, pump + (e.is("draw") ? 0.14 : 0.95)); // taps punch, drags feed + const hue = x * 360; + // Y → pitch (top = higher). Discretize to the warm scale for cohesion. + const scale = ["c", "d", "e", "g", "a"]; + 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; + sparks.push({ + x, y, + vx: (Math.random() - 0.5) * 0.004, + vy: (Math.random() - 0.5) * 0.004, + charge: e.is("draw") ? 0.8 : 1.5, + life: 1, + hue, + note, + }); + if (sparks.length > 10) sparks.shift(); + // heat the resident goo a touch too + bass = Math.min(1.5, bass + (1 - y) * 0.3); + + const pan = x * 2 - 1; + synth({ tone: note, type: "sine", beats: e.is("draw") ? 0.4 : 0.9, attack: 0.005, + decay: 0.55, volume: e.is("draw") ? 0.28 : 0.55, pan }); + synth({ tone: note, type: "triangle", beats: 0.3, attack: 0.002, decay: 0.32, + volume: 0.22 * (0.4 + (1 - y)), pan }); + } +} + +// ── Render: native-res metaball field straight to screen.pixels ────────────── +function paint({ screen, num, paintCount }) { + const { width: w, height: h, pixels } = screen; + if (!w || !h || !pixels) return; + + // Assemble all charges (resident voices + tap sparks) in pixel space once. + // Precompute each charge's RGB ONCE here (never call hslToRgb in the pixel loop — + // that's charges×pixels calls and tanks fps). + const minWH = Math.min(w, h); + const charges = []; + for (const v of voices) { + const rad = (v.base + v.charge * 0.9) * (1 + pump * 0.12); + const [cr, cg, cb] = num.hslToRgb(v.hue, 88, 46 + v.charge * 22); + charges.push({ + x: v.x * w, y: v.y * h, + r2: Math.max(1, (rad * minWH * 0.16) ** 2), + hot: v.charge, cr, cg, cb, + }); + } + for (const s of sparks) { + const rad = (0.35 + s.charge * 0.8); + const [cr, cg, cb] = num.hslToRgb(s.hue, 90, 52 + s.charge * 18); + charges.push({ + x: s.x * w, y: s.y * h, + r2: Math.max(1, (rad * minWH * 0.16) ** 2), + hot: s.charge * 1.3, cr, cg, cb, + }); + } + const nCharges = charges.length; + + // Kick shockwave: a moving ring that adds field + jiggle. + const shockR = kickWave * Math.min(w, h) * 0.7; + const shockOn = kick > 0.02; + const cx = w * 0.5, cy = h * 0.5; + + const visc = 1 + bass * 0.8 + pump * 0.15; // field viscosity/heat → thicker goo + const gloss = 0.55 + bass * 0.35; // rim brightness / glossiness + const jig = (kick * 6) | 0; // whole-goo jiggle in px + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // gooey jiggle displacement on kick + let sx = x, sy = y; + if (jig) { + sx = x + ((Math.sin(y * 0.09 + simMs * 0.02) * jig) | 0); + sy = y + ((Math.cos(x * 0.08 + simMs * 0.02) * jig) | 0); + } + + // Metaball field: sum inverse-square falloff of every charge, colored by hue. + let field = 0, r = 0, g = 0, b = 0, wsum = 0; + for (let c = 0; c < nCharges; c++) { + const ch = charges[c]; + const dx = sx - ch.x, dy = sy - ch.y; + const d2 = dx * dx + dy * dy + 1; + const weight = ch.r2 / d2; // 1 at radius edge, >1 inside + field += weight * visc; + // precomputed hue rgb, weighted; hotter voices contribute more color + const cw = weight * (0.6 + ch.hot); + r += ch.cr * cw; g += ch.cg * cw; b += ch.cb * cw; wsum += cw; + } + // shockwave ring adds field near its radius + if (shockOn) { + const dd = num.dist(sx, sy, cx, cy) - shockR; + field += kick * 3.2 * Math.exp(-(dd * dd) / 260); + } + + const i = (y * w + x) * 4; + if (field < 0.9 || wsum <= 0) { + // deep candy background — a dark warm gradient, not flat black + const bg = 8 + (y / h) * 14; + pixels[i] = bg + 6; pixels[i + 1] = bg * 0.4; pixels[i + 2] = bg + 18; + pixels[i + 3] = 255; + continue; + } + + // inside the iso-surface → glossy blob body + r /= wsum; g /= wsum; b /= wsum; + // brighten toward the core, add a bright rim right at the threshold (glossy edge) + const core = Math.min(1.6, field / 2.2); + const rim = field > 0.9 && field < 1.5 ? (1.5 - field) * gloss * 220 : 0; + let R = r * core + rim; + let G = g * core + rim * 0.9; + let B = b * core + rim; + // specular highlight where the field is very strong + if (field > 3) { const sp = Math.min(80, (field - 3) * 26); R += sp; G += sp; B += sp; } + // ordered dither for candy texture + const dith = ((x + y) & 1) * 8; + pixels[i] = Math.max(0, Math.min(255, R - dith)); + pixels[i + 1] = Math.max(0, Math.min(255, G - dith * 0.7)); + pixels[i + 2] = Math.max(0, Math.min(255, B - dith * 0.5)); + pixels[i + 3] = 255; + } + } +} + +export { boot, sim, act, paint }; diff --git a/system/public/aesthetic.computer/disks/murmo.mjs b/system/public/aesthetic.computer/disks/murmo.mjs new file mode 100644 index 0000000000..45219041d0 --- /dev/null +++ b/system/public/aesthetic.computer/disks/murmo.mjs @@ -0,0 +1,431 @@ +// murmo, 26.07.12 +// Self-running A/V loop: a MURMURING-SWARM instrument — a starling murmuration +// rendered as living sound. A low humming DRONE bed + a soft chorus of detuned +// mid "murmur" voices that swell and ebb + a slow breathing pulse drive a swarm +// of a few hundred lightweight agents flocking across the screen. Hypnotic, +// flocking, breathing. Zero input — autostarts on boot and loops seamlessly. +// +// FOUR PRINCIPLES (reel canon): +// 1. NO resolution() — native res; cheap vector agents (no per-pixel loop, no +// buffer). A shared sine-driven flow field moves the whole flock, so it's +// hundreds of dots but only a handful of trig calls per frame. +// 2. WHOLE PIECE IS A BUTTON — a tap/XY-drag scatters/ignites the swarm from +// the tap point: a shock that ripples outward through the flock + a swelling +// murmur voice (X→pan/hue, Y→pitch). Repeated taps accumulate a decaying +// `pump` that agitates the whole murmuration. +// 3. UTC-SYNCED RHYTHM — the murmur-voice ignitions + the breath pulse are +// derived from clock.time() in sim() (NOT beat()), indexed by +// floor(globalBeat) % len, so two instances anywhere auto-align. BPM 84, +// seamless 8-beat cycle. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — +// • the low DRONE = the big slow collective UNDULATION of the whole flock +// (the flock's overall wave motion is driven by the drone bed's phase); +// • each mid VOICE that sounds = a cluster of the swarm IGNITING BRIGHT at +// a height ∝ its pitch, HUE = pitch → you SEE the harmony as glowing +// bands rippling through the flock; +// • the PULSE = the whole swarm contracting/expanding (a breath); +// • amplitude → flock density/brightness. +// A viewer reads the drone as the flock's motion and the voices as bright +// pitch-height bands. + +// —— Harmony —— +// A slow murmuring chorus. One mid "voice" ignites per beat over an 8-beat cycle +// that resolves back to the start so the audio loops as cleanly as the visuals. +// A stack of pentatonic tones — soft, always-agreeing, twilight-warm. +const VOICES = ["a3", "c4", "e4", "d4", "g4", "e4", "c4", "d4"]; +// The drone bed: two low detuned sines held forever (the flock's collective body). +const DRONE = ["a1", "a2"]; // root + octave, gently detuned for a beating hum + +const BPM = 84; +const BEAT_MS = 60000 / BPM; // ms per beat +const LOOP = VOICES.length; // 8-beat cycle + +// —— UTC rhythm state (driven in sim, never beat) —— +let lastBeat = -1; // last integer global beat we fired +let beatCount = 0; // current step within the loop (0..LOOP-1) +let beatProgress = 0; // 0..1 through the current beat (smooth visuals) +let globalCycle = 0; // (globalBeat / LOOP) continuous — for seamless phase +let droneStarted = false; // held drone bed created once + +// —— live audio voices —— +let drone = []; // sustained drone voices (the flock's collective body) + +// —— the swarm —— +// Each agent is a lightweight dot advected by a SHARED sine flow field (cheap). +// It carries a little home-phase so the flock spreads across the frame and a +// per-agent "band" position so voice ignitions can light up a height slice. +const AGENTS = 320; // a few hundred — reasonable for smooth fps at density 3 +let agents = []; + +// A "band" is a voice ignition made visible: a bright height slice that ripples +// through the flock. pitch → y (top = high), pitch → hue. Spawned in onBeat and +// by taps; fades over ~1s so every audible onset has a visible birth. +let bands = []; // {y, hue, life, strength, pan} + +// —— tap "button" state —— +let pump = 0; // decaying global agitation energy, 0..~2.6 +let shocks = []; // radial ripples spawned by taps {x, y, r, life, hue} +let dragId = null; // active held-drag pointer id + +// —— flow-field phase (the drone's slow undulation) —— +// The whole flock's collective wave motion is driven by these phases; the drone +// pushes them so the low hum literally IS the big slow undulation you see. +let flowPhase = 0; // advances slowly — the collective undulation +let breath = 1; // whole-swarm contraction/expansion (the pulse), ~0.85..1.2 + +// Convert a note string to Hz for pitch→y/hue mapping (local, no deps). +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 octave = +m[3]; + const midi = semi + (octave + 1) * 12; + return 440 * Math.pow(2, (midi - 69) / 12); +} + +// Map a note's Hz into a normalized 0..1 pitch band (roughly a2..c6). +function pitchNorm(hz) { + const n = Math.log2(hz / noteToHz("a2")) / Math.log2(noteToHz("c6") / noteToHz("a2")); + return Math.max(0, Math.min(1, n)); +} + +function boot({ sound, clock, screen, num }) { + sound.bpm(BPM); // only affects the internal metronome; UTC below is authoritative + clock?.resync?.(); // fetch UTC offset (silent local fallback offline) + // A wide, airy hall so the murmur chorus blooms and blurs into a haze. + sound.room?.set?.({ enabled: true, mix: 0.48, feedback: 0.58 }); + + // Seed the swarm. Positions are re-derived from the flow field each frame, so + // we only need each agent's phase seed + a home spread. `num.randInt` is + // 0..n inclusive; keep it simple and cheap. + const w = screen?.width || 360; + const h = screen?.height || 640; + agents = []; + for (let i = 0; i < AGENTS; i++) { + agents.push({ + // A seed angle/phase so the flock spreads and each dot has its own wobble. + seed: (i / AGENTS) * Math.PI * 2, + spread: Math.random(), // 0..1 across the flow field + // Current on-screen position (advected + eased each frame). + x: Math.random() * w, + y: Math.random() * h, + wob: Math.random() * Math.PI * 2, // personal flap phase + // Ignition: raised when a band at this agent's height lights it up. + glow: 0, + hue: 0.6, + }); + } +} + +// Fire one beat's worth of sound. Called from sim on each UTC beat crossing so +// two instances anywhere sound the identical step at the identical wall-clock ms. +function onBeat(idx, synth) { + const step = ((idx % LOOP) + LOOP) % LOOP; + beatCount = step; + + // The murmur VOICE that ignites this beat. A soft detuned sine chorus (two + // slightly-detuned sines) → a breathy, flocking mid tone that swells and ebbs. + const note = VOICES[step]; + const hz = noteToHz(note); + const pN = pitchNorm(hz); // 0..1 → visual height + hue + const pan = Math.sin(idx * 0.7) * 0.4; // slow stereo drift + const hue = pN; // pitch → hue (0..1), twilight igniting warm at the top + + // Two detuned sines = the murmuring chorus body (soft swell, long ebb). + synth({ + tone: note, + type: "sine", + beats: 2.4, // overlap generously → a continuous murmuring haze + attack: 0.35, + decay: 0.7, + volume: 0.3, + pan, + }); + synth({ + tone: hz * 1.008, // gently detuned partner → beating shimmer + type: "sine", + beats: 2.2, + attack: 0.4, + decay: 0.65, + volume: 0.22, + pan: -pan, + }); + // A faint triangle an octave up on alternating beats — a high glint in the flock. + if (step % 2 === 0) { + synth({ + tone: hz * 2, + type: "triangle", + beats: 1.2, + attack: 0.25, + decay: 0.6, + volume: 0.1, + pan, + }); + } + + // The slow breath PULSE — a soft low thump every other beat: the whole swarm + // contracting/expanding. Sine so it's felt, not clicky. + if (step % 2 === 0) { + synth({ + tone: "a1", + type: "sine", + beats: 1.4, + attack: 0.06, + decay: 0.5, + volume: 0.34, + pan: 0, + }); + } + + // The VISIBLE counterpart of the voice: a bright height band (pitch → y, pitch + // → hue) that ripples through the flock and lights up agents at that height. + bands.push({ y: 1 - pN, hue, life: 1, strength: 1, pan }); + if (bands.length > 12) bands.shift(); +} + +// —— THE WHOLE PIECE IS A BUTTON —— +// A tap/XY-drag scatters/ignites the swarm from the tap point (a shock that +// ripples through the flock) + a swelling murmur voice. X → pan + hue, Y → pitch +// (top = high). Taps punch a bigger pump; drags feed it. +function act({ event: e, sound: { synth }, screen, num }) { + const w = screen.width || 1; + const h = screen.height || 1; + + if (e.is("touch") || e.is("draw")) { + const x = num.clamp(e.x / w, 0, 1); + const y = num.clamp(e.y / h, 0, 1); + const isDraw = e.is("draw"); + + // Accumulate a decaying agitation — taps punch the flock, drags stir it. + pump = Math.min(2.6, pump + (isDraw ? 0.06 : 0.85)); + + // A shock ripples outward from the touch point through the swarm. + const hue = (x * 0.5 + 0.55) % 1; // X → hue across the twilight arc + shocks.push({ x: e.x, y: e.y, r: 0, life: 1, hue, vol: isDraw ? 0.5 : 1 }); + if (shocks.length > 20) shocks.shift(); + + // A bright band at the tap height too, so the visible ignition matches the + // audible one exactly. + bands.push({ y: 1 - y, hue, life: 1, strength: isDraw ? 0.7 : 1, pan: x * 2 - 1 }); + if (bands.length > 12) bands.shift(); + + // SONIC BOOST — a swelling murmur voice. Pentatonic so it always agrees with + // the chorus. Y → pitch (top = high), X → pan/hue. Soft attack → a swell, a + // murmur igniting, not a click. Quieter on a drag so a sweep stays tender. + const scale = ["c", "d", "e", "g", "a"]; + const noteName = scale[Math.min(4, Math.floor(x * 5))]; + const oct = 3 + Math.round((1 - y) * 3); // 3..6 — higher up = higher note + const pan = x * 2 - 1; + synth({ + tone: noteName + oct, + type: "sine", + beats: 1.6, + attack: 0.05, + decay: 0.8, + volume: 0.32 * (isDraw ? 0.6 : 1), + pan, + }); + // A detuned partner → the same beating murmur shimmer as the chorus. + synth({ + tone: noteToHz(noteName + oct) * 1.008, + type: "sine", + beats: 1.3, + attack: 0.06, + decay: 0.7, + volume: 0.18 * (isDraw ? 0.6 : 1), + pan: -pan, + }); + + if (e.is("touch")) dragId = e.pointer ?? e.id ?? 0; + } + + if (e.is("lift")) dragId = null; +} + +// Poll live audio (mandatory) + advance the UTC beat grid + move the flock. +function sim({ sound: { speaker, synth }, clock, screen }) { + speaker?.poll(); + + // —— UTC-synced rhythm —— + // Absolute beat count from epoch ms → every instance computes the same step. + // clock.time() can momentarily return an invalid Date (offset mid-sync); the + // `??` won't catch that (it's a Date object, not null) so guard NaN explicitly + // and fall back to local wall-clock time — the grid stays continuous. + const ct = clock?.time?.(); + let ms = ct ? ct.getTime() : NaN; + if (!Number.isFinite(ms)) ms = Date.now(); + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; // 0..1 within the current beat + globalCycle = globalBeat / LOOP; // continuous cycle position (seamless phase) + + // Start the held DRONE bed once — the flock's collective body / low hum. Two + // slightly-detuned low sines beating together = the humming drone bed. + if (!droneStarted) { + droneStarted = true; + drone.push( + synth({ tone: DRONE[0], type: "sine", duration: "🔁", attack: 1.2, decay: 0.9, volume: 0.16, pan: -0.2 }), + synth({ tone: noteToHz(DRONE[0]) * 1.006, type: "sine", duration: "🔁", attack: 1.4, decay: 0.9, volume: 0.13, pan: 0.2 }), + synth({ tone: DRONE[1], type: "sine", duration: "🔁", attack: 1.6, decay: 0.9, volume: 0.1, pan: 0 }), + ); + } + + if (idx !== lastBeat) { + lastBeat = idx; + onBeat(idx, synth); // fire this beat's murmur voice + breath pulse + } + + // The DRONE undulation drives the flock: advance the shared flow phase slowly. + // Tie its speed to the seamless global cycle so it wraps with the loop and to + // pump so taps agitate the whole murmuration. + flowPhase = globalCycle * Math.PI * 2 * 2; // 2 undulations per 8-beat loop → seamless + + // The breath pulse: the whole swarm contracts/expands. Brightest/tightest on + // the beat, easing to the next; every-other beat has the audible thump. + const beatPulse = 1 - beatProgress; + breath = 1 + 0.14 * Math.sin(globalCycle * Math.PI * 2 * 2) + beatPulse * 0.04 + pump * 0.04; + + // Decay the tap agitation + advance/expire shocks + fade bands. + pump = Math.max(0, pump - 0.014); + for (const s of shocks) { + s.r += 5 + s.vol * 5 + pump * 3; // ripple races outward through the flock + s.life -= 0.02; + } + shocks = shocks.filter((s) => s.life > 0); + for (const b of bands) b.life -= 0.02; // ~1s ignition fade + bands = bands.filter((b) => b.life > 0); + + // —— move the flock —— + // A SHARED sine flow field (cheap): each agent samples a smooth vector field + // driven by flowPhase (the drone undulation) + its own seed/spread, plus a + // gentle pull toward the flock center scaled by breath (contraction/expansion), + // plus a shock push. Ease toward the target position for silky motion. + const w = screen.width || 360; + const h = screen.height || 640; + const cx = w / 2; + const cy = h / 2; + const spanX = w * 0.42; + const spanY = h * 0.34; + + for (const a of agents) { + a.wob += 0.06; + // Flow-field target: a broad, slowly-rolling murmuration ribbon. The whole + // pattern undulates with flowPhase (the drone) and breathes with `breath`. + const p = a.spread; // 0..1 across the flock + const angle = a.seed + flowPhase + p * Math.PI * 2; + // Big collective wave (the drone's undulation) — the flock's overall motion. + const wave = Math.sin(flowPhase + p * 3.4) * 0.6 + Math.sin(flowPhase * 0.6 + a.seed) * 0.4; + let tx = cx + Math.cos(angle) * spanX * (0.5 + 0.5 * Math.cos(flowPhase + p * 2)) / breath; + let ty = + cy + + wave * spanY * breath + // vertical undulation = the drone + Math.sin(a.wob) * 6; // tiny personal flap so it shimmers + + // Shock push — each ripple front shoves nearby agents outward (the scatter). + for (const s of shocks) { + const dx = a.x - s.x; + const dy = a.y - s.y; + const d = Math.hypot(dx, dy) || 1; + const front = Math.abs(d - s.r); + if (front < 40) { + const push = (1 - front / 40) * s.life * 26 * s.vol; + tx += (dx / d) * push; + ty += (dy / d) * push; + } + } + + // Ease toward the target → smooth, flocking motion. More agitation (pump) + // makes the flock snappier/more nervous. + const ease = 0.06 + pump * 0.05; + a.x += (tx - a.x) * ease; + a.y += (ty - a.y) * ease; + + // Ignition: if a bright band is near this agent's height, it lights up. This + // is how you SEE the harmony — voices become glowing height slices. + let g = 0; + let gh = a.hue; + for (const b of bands) { + const by = b.y * h; + const dy = Math.abs(a.y - by); + if (dy < 85) { + // Soft-shoulder falloff so agents right in the band light near-full. + const prox = 1 - dy / 85; + const lit = prox * prox * b.life * b.strength * 1.6; + if (lit > g) { + g = lit; + gh = b.hue; + } + } + } + a.glow += (Math.min(1, g) - a.glow) * 0.3; // ease glow so ignitions bloom & fade + a.hue += (gh - a.hue) * 0.25; + } +} + +function paint({ ink, box, circle, screen, sound, num, paintCount }) { + const { width: w, height: h } = screen; + + // Twilight wash — a translucent gradient veil each frame so the swarm leaves a + // silky motion-smear trail rather than snapping. Indigo→teal twilight bed. + ink("fade:midnightblue-teal:vertical", 40).box(0, 0, w, h); + + // —— audio reads (make the ambient field react too) —— + const bandsAudio = sound.speaker?.frequencies?.left || []; + const subBass = bandsAudio.find((b) => b.name === "subBass")?.amplitude || 0; + const lowMid = bandsAudio.find((b) => b.name === "lowMid")?.amplitude || 0; + const air = bandsAudio.find((b) => b.name === "air")?.amplitude || 0; + const amp = sound.speaker?.amplitudes?.left || 0; + + // Density/brightness of the whole flock rides amplitude + the low drone. + const density = 0.5 + amp * 1.2 + subBass * 0.8 + pump * 0.4; + + // —— draw the flock —— + // Each agent is a soft additive dot. Base twilight color (indigo/teal) blends + // toward its ignition hue (warm) by its glow → bright pitch-bands within the + // flock. Dots near the flock core are denser/brighter. + for (const a of agents) { + // Base twilight color: indigo↔teal, drifting slowly with the undulation. + const baseHueDeg = (200 + Math.sin(a.seed + flowPhase) * 40 + 360) % 360; // ~160..240 teal↔indigo + // Warp the glow so even a partial ignition tips decisively WARM (avoids the + // cool↔warm blend lingering in the green midpoint). + const glow = Math.pow(Math.min(1, a.glow), 0.5); + const [br, bg, bb] = num.hslToRgb(baseHueDeg, 65, 42 + amp * 12); // cool body + // Ignition color: a WARM igniting voice (a.hue 0..1 → amber↔rose ~35..70°). + // Blend in RGB space (NOT hue) so cool→warm never sweeps through green. + const litHueDeg = a.hue * 40 + 30; // 30..70 amber↔gold (kept off green) + const [lr, lg, lb] = num.hslToRgb(litHueDeg, 95, 62); // hot warm pop + const r = br * (1 - glow) + lr * glow; + const g = bg * (1 - glow) + lg * glow; + const b = bb * (1 - glow) + lb * glow; + + // Dot size + alpha swell with ignition, density, and the low end. + const size = 1.4 + glow * 3.4 + subBass * 1.6 + air * 1.4; + const alpha = Math.min(230, 60 + glow * 150 + density * 40); + // Soft additive halo for igniting agents so bands GLOW, not just recolor. + if (glow > 0.15) { + ink(r, g, b, alpha * 0.4).circle(a.x, a.y, size * 2.2, true); + } + ink(r, g, b, alpha).circle(a.x, a.y, size, true); + } + + // —— tap shocks: expanding rings rippling through the flock —— + for (const s of shocks) { + const [r, g, b] = num.hslToRgb(s.hue * 55 + 32, 88, 66); // warm, matches ignitions + ink(r, g, b, 170 * s.life).circle(s.x, s.y, s.r, false, 2 + s.vol * 2); + ink(r, g, b, 90 * s.life).circle(s.x, s.y, s.r * 0.7, false, 1); + } + + // —— pitch-band guide glow —— + // A soft warm horizontal wash at each live voice's height so you read the + // harmony as bands even in sparse flock regions (the "graphic notation" layer). + for (const b of bands) { + const by = b.y * h; + const [r, g, b2] = num.hslToRgb(b.hue * 55 + 32, 78, 60); + ink(r, g, b2, 34 * b.life * b.strength).box(0, by - 24, w, 48); + } +} + +export { boot, act, sim, paint }; diff --git a/system/public/aesthetic.computer/disks/plax.mjs b/system/public/aesthetic.computer/disks/plax.mjs new file mode 100644 index 0000000000..7e35dd392b --- /dev/null +++ b/system/public/aesthetic.computer/disks/plax.mjs @@ -0,0 +1,399 @@ +// plax, 26.07.12 +// Self-running glassy PRISM-SHARD instrument. A UTC-synced crystalline bell +// arpeggio scatters light through glass: every audible note FLASHES a triangular +// SHARD into existence the instant it sounds — the shard's ANGLE + vertical +// POSITION ∝ pitch, its HUE = the pitch's place in the spectrum (low = warm +// red/amber, high = violet), so the rising arpeggio literally fans out as a +// spray of colored light. A slow central rotating PRISM splits a persistent +// rainbow (the glassy pad). Accents fire a bright refraction burst. A gentle +// feedback trail leaves each shard a spectral streak on a near-black glass field +// — so a viewer READS the melody as the angle + color of the shards. +// +// FOUR PRINCIPLES (marketing/av-reels/button-sync-allegory.md): +// 1. NO resolution() — renders at native res (vector shards, no offscreen buf). +// 2. WHOLE PIECE IS A BUTTON — tap / XY-drag SHATTERS a burst of shards + a +// glassy chime at the tap point (X → hue/pan, Y → pitch); repeated taps +// accumulate decaying `pump` energy that intensifies the refraction. +// 3. UTC-SYNCED RHYTHM — arpeggio onsets are derived from clock.time() in sim +// (NOT beat()), indexed by floor(globalBeat) % len, so two instances opened +// anywhere auto-align to the same wall-clock grid. BPM ~136. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — the visual IS the score (notes = shards). +// +// Loops seamlessly: the score returns to its seam every 16 steps. + +// --- Score ------------------------------------------------------------------ +// A shimmering, icy arpeggio (16 steps). Step 0 == the seam. Rising fans of a +// D-major-ish glass scale so the spectrum sweeps warm→violet as pitch climbs. +const ARP = [ + "d3", "a3", "d4", "f#4", "a4", "f#4", "d4", "a3", + "e3", "b3", "e4", "g4", "b4", "g4", "e4", "b3", +]; +// A slow glassy pad root under it all — the prism's split rainbow hum. +const PAD = ["d2", "d2", "d2", "d2", "e2", "e2", "e2", "e2"]; + +// MIDI-ish pitch of each note, for pitch→hue / pitch→position / pitch→angle. +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; + const sharp = m[2] === "#" ? 1 : 0; + return (parseInt(m[3], 10) + 1) * 12 + NOTE_SEMI[m[1]] + sharp; +} +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); // 0..1 + +// Spectral hue: low pitch = warm red/amber (~10°) → high pitch = violet (~280°). +const pitchHue = (pn) => 10 + pn * 270; + +const BPM = 136; // brisk, icy, precise +const BEAT_MS = 60000 / BPM; // ms per beat — the UTC grid unit + +// --- Rhythm state (UTC-driven) --------------------------------------------- +let lastBeat = -1; // last integer global-beat we fired +let step = 0; // current arpeggio index (0..15), from UTC +let beatProgress = 0; // 0..1 within the current beat (smooth visuals) + +// --- Allegory state --------------------------------------------------------- +// Each fired note spawns a decaying SHARD (a triangular prism flash refracting a +// spectral streak) so every audible onset has a visible birth as it sounds. +let shards = []; // { angle, y, hue, life, big, bright, spin } +let refract = 0; // bright refraction burst energy (accents), decays +let padGlow = 0; // slow pad/rainbow-split glow, kicked by pad notes, decays + +// --- Button / pump state (§2) ---------------------------------------------- +let pump = 0; // decaying global energy from taps, 0..~3 +let bursts = []; // tap-spawned shard-shatter bursts { x, y, hue, shards:[...] } + +function boot({ sound, clock }) { + sound.bpm(BPM); // only affects the internal metronome; UTC scheduler is boss + clock?.resync?.(); // fetch UTC offset (silent local fallback offline) + // A cool glassy tail — light bouncing inside the crystal. + sound.room?.set?.({ enabled: true, mix: 0.34, feedback: 0.5 }); +} + +// Spawn a shard for a note. angle ∝ step (fans across), y ∝ pitch (high=up), +// hue = spectral pitch place. Shared by the arpeggio and by taps. +function spawnShard(pn, angle, life, bright, big, screenH) { + shards.push({ + angle, // radiating direction of the refracted streak + y: (1 - pn) * screenH, // high pitch = high on screen + hue: pitchHue(pn), // low→warm red, high→violet + life, + big, + bright, + spin: (Math.random() * 2 - 1) * 0.04, // faint glassy twist + }); +} + +// Fire one step of the score. Called the instant a new global beat crosses in +// sim, so every onset is UTC-aligned and every note also spawns its shard. +function fireStep(idx, synth, screenH) { + const s = ((idx % ARP.length) + ARP.length) % ARP.length; + step = s; + const note = ARP[s]; + const pn = pitchNorm(note); // 0 (low) .. 1 (high) + + // Pan fans with the arpeggio so the light sprays across the stereo field. + const pan = Math.sin((s / ARP.length) * Math.PI * 2) * 0.75; + + // Crystalline bell: fast attack, shimmering. Sine core + triangle overtone. + synth({ + tone: note, + type: "sine", + beats: 0.9, + attack: 0.003, + decay: 0.55, + volume: 0.42, + pan, + }); + synth({ + tone: note, + type: "triangle", + beats: 0.5, + attack: 0.002, + decay: 0.4, + volume: 0.22 * (0.5 + pn * 0.5), // brighter overtone climbs with pitch + pan, + }); + + // ALLEGORY: this note FLASHES a shard — angle ∝ step, y ∝ pitch, hue ∝ pitch. + const angle = (s / ARP.length) * Math.PI * 2 - Math.PI / 2; // fan from top + spawnShard(pn, angle, 1, 0.4 + pn * 0.6, 1 - pn * 0.5, screenH); + + // A delicate HIGH SPARKLE on the accent (top of each rising run). + const accent = pn > 0.72; + if (accent) { + synth({ + tone: note, + type: "sine", + beats: 0.3, + attack: 0.001, + decay: 0.35, + volume: 0.16, + pan: -pan, + }); + refract = 1; // ALLEGORY: accent = a bright refraction burst + } + + // The glassy pad root on the half-beat — the slow prism-split rainbow hum. + if (s % 2 === 0) { + const bi = (Math.floor(idx / 2) % PAD.length + PAD.length) % PAD.length; + synth({ + tone: PAD[bi], + type: "triangle", + beats: 1.7, + attack: 0.06, // soft glassy swell + decay: 0.5, + volume: 0.3, + }); + padGlow = 1; // ALLEGORY: pad = the central prism's rainbow glow + } +} + +// Drive audio + rhythm from UTC in sim (NOT beat()) so onsets align across +// instances. Also decay all the eased visual energies here. +function sim({ sound: { speaker, synth }, clock, screen }) { + speaker?.poll(); // mandatory before reading audio + + // clock.time() can return an *Invalid Date* before UTC sync completes (its + // getTime() is NaN, and it's not null so `??` won't catch it) — guard with + // Date.now() so the rhythm always runs, aligning across instances once synced. + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; // 0..1 through the current beat + + if (idx !== lastBeat) { + lastBeat = idx; + fireStep(idx, synth, screen?.height || 640); // new UTC beat → fire its note + } + + // Decays (per 120 Hz tick). + pump *= 0.965; + refract *= 0.9; + padGlow *= 0.94; + + // Advance / cull note shards. Slow decay (~1.2s) so several shards overlap + // into a lush fan of refracted light instead of blinking. + for (const sh of shards) { + sh.life -= 0.014; + sh.angle += sh.spin; // faint glassy twist as it fades + } + shards = shards.filter((sh) => sh.life > 0); + if (shards.length > 28) shards = shards.slice(-28); // safety cap + + // Advance / cull tap shatter bursts (each carries its own little shard spray). + for (const b of bursts) { + for (const p of b.shards) { + p.x += p.vx; + p.y += p.vy; + p.vx *= 0.96; + p.vy *= 0.96; + p.life -= 0.03; + p.rot += p.spin; + } + b.shards = b.shards.filter((p) => p.life > 0); + } + bursts = bursts.filter((b) => b.shards.length > 0); +} + +// THE WHOLE PIECE IS A BUTTON (§2): any tap / XY-drag SHATTERS a burst of shards +// + a glassy chime at the touch point — hue by X, pitch by X/Y. +function act({ event: e, sound: { synth }, screen }) { + if (e.is("touch") || e.is("draw")) { + const x = e.x / screen.width; // 0..1 + const y = e.y / screen.height; // 0..1 + const pn = 1 - y; // top of screen = high pitch (matches y-mapping) + const hue = pitchHue(pn); + + // Taps punch, drags feed — accumulate decaying pump energy → more refraction. + pump = Math.min(3, pump + (e.is("draw") ? 0.1 : 0.9)); + + // SHATTER: spray a burst of little glass shards flying out from the tap. + const spray = []; + const count = e.is("draw") ? 4 : 10; + for (let i = 0; i < count; i++) { + const a = (i / count) * Math.PI * 2 + Math.random() * 0.6; + const spd = 2 + Math.random() * (e.is("draw") ? 3 : 7); + spray.push({ + x: e.x, + y: e.y, + vx: Math.cos(a) * spd, + vy: Math.sin(a) * spd, + rot: Math.random() * Math.PI, + spin: (Math.random() * 2 - 1) * 0.3, + size: 4 + Math.random() * (10 + pump * 6), + hue: hue + (Math.random() * 2 - 1) * 40, // spectral spread of the shatter + life: 1, + }); + } + bursts.push({ x: e.x, y: e.y, hue, shards: spray }); + + // Also flash a big directional shard in the note geometry so the tap reads. + spawnShard(pn, Math.atan2(e.y - screen.height / 2, e.x - screen.width / 2), + 1.2, 1, 1, screen.height); + refract = Math.max(refract, 0.8); + + // GLASSY CHIME — X→pitch/pan, Y→octave/brightness, crystalline bell. + const note = + ["d", "e", "f#", "a", "b"][Math.floor(x * 5)] + (3 + Math.floor((1 - y) * 3)); + synth({ + tone: note, + type: "sine", + beats: 0.5, + attack: 0.002, + decay: 0.6, + volume: 0.5, + pan: x * 2 - 1, + }); + synth({ + tone: note, + type: "triangle", + beats: 0.25, + attack: 0.001, + decay: 0.3, + volume: 0.28 * (1 - y), // icier sparkle higher up + pan: x * 2 - 1, + }); + } +} + +// Draw a single triangular glass shard: a thin refracting sliver + a bright tip, +// tinted by its spectral hue. Additive-feeling translucent layering. +function drawShard(ink, num, cx, cy, angle, reach, halfW, hue, alpha, tipR, life) { + const [r0, g0, b0] = num.hslToRgb(((hue % 360) + 360) % 360, 100, 58); + const base = [cx, cy]; + const midR = reach * 0.5; + const l = [cx + Math.cos(angle - halfW) * midR, cy + Math.sin(angle - halfW) * midR]; + const rgt = [cx + Math.cos(angle + halfW) * midR, cy + Math.sin(angle + halfW) * midR]; + const tip = [cx + Math.cos(angle) * reach, cy + Math.sin(angle) * reach]; + // Outer refracting shard (translucent colored glass). + ink(r0, g0, b0, alpha).shape([base, l, tip, rgt]); + // Inner brighter core streak (the light passing through). + const l2 = [cx + Math.cos(angle - halfW * 0.4) * midR, cy + Math.sin(angle - halfW * 0.4) * midR]; + const r2 = [cx + Math.cos(angle + halfW * 0.4) * midR, cy + Math.sin(angle + halfW * 0.4) * midR]; + const tip2 = [cx + Math.cos(angle) * (reach * 0.9), cy + Math.sin(angle) * (reach * 0.9)]; + ink(Math.min(255, r0 + 80), Math.min(255, g0 + 80), Math.min(255, b0 + 80), alpha) + .shape([base, l2, tip2, r2]); + // Bright refracting tip — a spark of pure light where the glass focuses it. + ink(255, 255, 255, 220 * life).circle(tip[0], tip[1], tipR, true); +} + +function paint({ ink, box, line, circle, shape, screen, sound, num, zoom, spin, blur }) { + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + + // --- Live audio reads ----------------------------------------------------- + const bands = sound.speaker?.frequencies?.left || []; + const bandAmp = (name) => bands.find((b) => b.name === name)?.amplitude || 0; + const lowMid = bandAmp("lowMid"); + const presence = bandAmp("presence"); + const air = bandAmp("air"); + const amp = sound.speaker?.amplitudes?.left || 0; + + // Combined "energy" — refraction accents + live audio + tap pump. + const energy = Math.min( + 2, + refract * 0.6 + presence * 0.9 + air * 0.9 + amp * 0.5 + pump * 0.5, + ); + + // --- Glass field: near-black background so spectral colors POP. A gentle + // feedback drift (slow zoom out + faint spin) smears each shard into a + // spectral STREAK; a strong veil keeps the fresh fan of shards LEGIBLE (§4) + // rather than washing into mush. Refraction/taps kick the drift harder. + zoom?.(0.992 - energy * 0.006, 0.5, 0.42); // gentle inward drift → light streaks toward center + spin?.(0.06 + refract * 0.5 + pump * 0.4); // faint glassy twist, kicks on accents/taps + ink(3, 4, 10, 150).box(0, 0, w, h); // deep near-black glass veil — short trails, crisp shards + + const maxR = Math.min(w, h) * 0.62; + + // --- Central rotating PRISM (the pad): a slow triangular crystal that SPLITS + // a persistent rainbow. Its glow swells with the pad note (§4). Behind shards. + const prismSpin = (step + beatProgress) * 0.12; + const prismR = maxR * (0.16 + padGlow * 0.06 + lowMid * 0.4); + // The split rainbow: faint radiating spectral spokes fanning from the prism. + const spokes = 12; + for (let i = 0; i < spokes; i++) { + const a = (i / spokes) * Math.PI * 2 + prismSpin; + const hue = (i / spokes) * 360; + const [rr, gg, bb] = num.hslToRgb(hue, 90, 55); + const reach = prismR + maxR * (0.5 + padGlow * 0.3); + ink(rr, gg, bb, 40 + padGlow * 55).line( + cx, cy, + cx + Math.cos(a) * reach, cy + Math.sin(a) * reach, + 1 + (padGlow > 0.4 ? 1 : 0), + ); + } + // The prism body: a small rotating triangle, faintly rainbow-edged. + for (let k = 0; k < 2; k++) { + const rot = prismSpin * (k === 0 ? 1 : -1.4); + const kr = prismR * (0.7 + k * 0.35); + let px = null, py = null, first = null; + for (let i = 0; i <= 3; i++) { + const a = (i / 3) * Math.PI * 2 + rot; + const x = cx + Math.cos(a) * kr, y = cy + Math.sin(a) * kr; + if (i === 0) first = [x, y]; + const [rr, gg, bb] = num.hslToRgb((i / 3) * 360 + step * 20, 95, 62); + if (px !== null) ink(rr, gg, bb, 150 + padGlow * 60).line(px, py, x, y, 2); + px = x; py = y; + } + if (first) ink(255, 255, 255, 120).line(px, py, first[0], first[1], 2); + } + + // --- NOTE SHARDS (§4): every audible note FLASHED a shard the instant it + // sounded — angle ∝ step (fans across), y ∝ pitch (high notes up), hue ∝ + // pitch (low=warm red → high=violet). The rising arpeggio scatters as a fan of + // colored light. Newest few only, so the fan stays legible. + for (const sh of shards.slice(-14)) { + // Origin point: shards emit from a point set by pitch height, near the prism. + const oy = num.lerp(cy, sh.y, 0.6); + const ox = cx; + const reach = maxR * (0.4 + sh.big * 0.4) * (0.8 + energy * 0.35) * (0.6 + sh.life * 0.4); + const halfW = (0.1 + sh.big * 0.09) * (1 + energy * 0.2); // fatter jewel-like shard + const alpha = 150 + 90 * sh.life; + const tipR = 2 + sh.bright * 7 + sh.life * 4; + drawShard(ink, num, ox, oy, sh.angle, reach, halfW, sh.hue, alpha, tipR, sh.life); + // A faint mirrored twin on the opposite side → the prism's split symmetry. + drawShard(ink, num, ox, oy, sh.angle + Math.PI, reach * 0.5, halfW, + sh.hue + 30, alpha * 0.45, tipR * 0.5, sh.life * 0.7); + } + + // --- TAP SHATTER BURSTS (§2): little glass shards flying out from each tap, + // spectrally spread, tumbling as they fade. + for (const b of bursts) { + for (const p of b.shards) { + const [r0, g0, b0] = num.hslToRgb(((p.hue % 360) + 360) % 360, 95, 60); + const s = p.size * (0.4 + p.life * 0.6); + // A tiny tumbling triangular shard. + const pts = []; + for (let i = 0; i < 3; i++) { + const a = p.rot + (i / 3) * Math.PI * 2; + pts.push([p.x + Math.cos(a) * s, p.y + Math.sin(a) * s]); + } + ink(r0, g0, b0, 210 * p.life).shape(pts); + ink(255, 255, 255, 180 * p.life).circle(p.x, p.y, 1 + p.life * 2, true); + } + } + + // Refraction burst flash: a bright white bloom on accents/taps blooms the + // whole glass field for an instant. + if (refract > 0.5 || pump > 1.2) { + const bloom = Math.max(refract, pump * 0.6); + for (let i = 3; i > 0; i--) { + ink(220, 235, 255, 22 * bloom).circle(cx, cy, prismR * (1 + i * 0.5), true); + } + blur?.(1); + } + + // Bright refracting heart at the prism core — the pulse you can see. Small so + // the feedback drift doesn't bloom it into a blown-out disk. + const heartR = 3 + refract * 8 + padGlow * 6 + pump * 6; + ink(180, 210, 255, 90 + padGlow * 90).circle(cx, cy, heartR * 1.7, true); + ink(255, 255, 255, 170 + refract * 80).circle(cx, cy, heartR, true); +} + +export { boot, sim, act, paint }; diff --git a/system/public/aesthetic.computer/disks/sundo.mjs b/system/public/aesthetic.computer/disks/sundo.mjs new file mode 100644 index 0000000000..63d95ba9a6 --- /dev/null +++ b/system/public/aesthetic.computer/disks/sundo.mjs @@ -0,0 +1,461 @@ +// sundo, 26.07.12 +// Self-running A/V loop: a slow SUNRISE DRONE — warm evolving ambient PAD chords +// that shift every few beats over a deep sub foundation and soft airy shimmer. +// A sun disc breathes over a horizon of dawn-color bands. Meditative, spacious, +// luscious; loops seamlessly. Zero input — autostarts its own audio on boot. +// +// FOUR PRINCIPLES (reel canon): +// 1. NO resolution() — native res; pure vector box/poly/circle work, no buffer. +// 2. WHOLE PIECE IS A BUTTON — a soft tap/drag sends a warm light-ripple across +// the sky + a gentle swelling chord tone (X→pan/hue, Y→pitch). Repeated taps +// accumulate a soft decaying `pump` that brightens the whole dawn. +// 3. UTC-SYNCED RHYTHM — the slow chord changes + sub pulses are scheduled from +// clock.time() in sim() (NOT beat()), indexed floor(globalBeat) % len, so two +// instances anywhere auto-align. Slow BPM 56; seamless harmonic loop; the +// held pad is retuned on beat crossings. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — each PAD chord tone IS one horizon LIGHT +// BAND whose height ∝ its pitch (low tone = low warm band, high tone = high +// pale band) → the held chord literally IS the stacked dawn sky. The SUB is +// the SUN disc's size + glow. Chord changes = the sky re-coloring (slow +// gradient sweep). Airy shimmer = drifting light motes near the sun. +// Amplitude → overall glow/brightness. You read the harmony as the bands. + +// —— Harmony —— +// A slow-moving held triad that shifts every couple of beats over a deep sub +// pedal. Warm, drifting, dawn-hued; resolves back to the start so audio loops as +// cleanly as the visuals. One SUB note per beat (the sun's heartbeat pedal). +const SUB = ["a1", "a1", "a1", "a1", "e1", "e1", "e1", "e1"]; // deep foundation +// Pad chord tones (held triad), retuned on downbeats. Each tone becomes one +// horizon light band; band height ∝ that tone's pitch (low→low band). +const PAD = [ + ["a2", "c3", "e3"], // Am — the resting dawn + ["a2", "c3", "e3"], + ["c3", "e3", "g3"], // C — the sky warms + ["c3", "e3", "g3"], + ["e2", "g2", "b2"], // Em — deeper, slower + ["e2", "g2", "b2"], + ["d3", "f3", "a3"], // Dm — the horizon lifts + ["e2", "g2", "b2"], +]; + +const BPM = 56; // slow, meditative +const BEAT_MS = 60000 / BPM; // ms per beat +const LOOP = PAD.length; // 8-beat harmonic cycle + +// —— UTC rhythm state (driven in sim, never beat) —— +let lastBeat = -1; // last integer global beat we fired +let beatCount = 0; // current step within the loop (0..LOOP-1) +let beatProgress = 0; // 0..1 through the current beat (smooth visuals) +let globalCycle = 0; // (globalBeat / LOOP) continuous — seamless phase + +// —— live audio voices —— +let pad = []; // sustained pad voices (one per chord tone) +let chord = PAD[0]; // the chord currently sounding (drives the bands) +let prevChord = PAD[0]; // the chord we're easing away from (gradient sweep) +let chordBlend = 1; // 0..1 crossfade from prevChord→chord on each change +let subVoice = null; // sustained sub pedal (the sun's foundation) +let subFreq = 55; // current sub note frequency (drives sun size + glow) + +// —— tap "button" state —— +let pump = 0; // decaying global brightness energy, 0..~2.4 +let ripples = []; // soft light-ripples spawned by taps {x,y,r,life,hue,vol} +let dragId = null; // active held-drag pointer id + +// —— drifting light motes (the airy shimmer) —— +let motes = []; + +// Convert a note string to Hz for pitch→band-height/hue mapping (local, no deps). +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 octave = +m[3]; + const midi = semi + (octave + 1) * 12; // MIDI note number + return 440 * Math.pow(2, (midi - 69) / 12); +} + +// Normalize a chord's pitches to 0..1 band-height positions (low tone → low). +// Pitch drives the *position within the dawn arc* (absolute log2 window keeps it +// consistent across chords), then we spread the triad so its three tones read as +// three clearly separated horizontal strata (blend absolute pitch with rank). +function bandHeights(ch) { + const hz = ch.map(noteToHz); + const n = ch.length; + return hz.map((f, i) => { + // Absolute pitch position in a warm pad window (~a2..a4 fills 0..1). + const abs = Math.max(0, Math.min(1, Math.log2(f / 100) / 2.4)); + // Rank position (0 for lowest tone, 1 for highest) guarantees separation. + const rank = n > 1 ? i / (n - 1) : 0.5; + // Blend: keeps chord-to-chord pitch feel while always stacking 3 distinct + // bands. Compressed into the mid band so bands never touch the extremes. + return 0.12 + (0.45 * abs + 0.55 * rank) * 0.76; + }); +} + +function boot({ sound, clock }) { + sound.bpm(BPM); // only affects the internal metronome; UTC below is authoritative + clock?.resync?.(); // fetch UTC offset (silent local fallback offline) + // Big, spacious dawn reverb tail across everything. + sound.room?.set?.({ enabled: true, mix: 0.55, feedback: 0.66 }); + // Seed a scatter of drifting light motes near where the sun will be. + for (let i = 0; i < 26; i++) { + motes.push({ + a: Math.random() * Math.PI * 2, // orbit angle around the sun + rad: 0.2 + Math.random() * 0.9, // orbit radius (× sun radius) + spd: 0.05 + Math.random() * 0.14, // drift speed + ph: Math.random() * Math.PI * 2, // twinkle phase + size: 0.6 + Math.random() * 1.6, + }); + } +} + +// Fire one beat's worth of sound. Called from sim on each UTC beat crossing so +// two instances anywhere sound the identical step at the identical wall-clock ms. +function onBeat(idx, synth) { + const step = ((idx % LOOP) + LOOP) % LOOP; + beatCount = step; + + // —— deep SUB pedal (the sun's foundation) —— + // A soft held sine sub; retuned each beat. Create once, then update the held + // voice. Its pitch drives the sun disc's size + glow. + const subNote = SUB[step]; + subFreq = noteToHz(subNote); + if (!subVoice) { + subVoice = synth({ + tone: subNote, + type: "sine", + duration: "🔁", // held forever until updated/killed + attack: 1.2, // very slow swell in + decay: 0.95, + volume: 0.34, + pan: 0, + }); + } else { + subVoice.update?.({ tone: subNote }); + } + // A gentle re-articulation on the downbeat so the sub breathes audibly. + synth({ + tone: subNote, + type: "sine", + beats: 3.0, // long overlap → seamless legato swell + attack: 0.9, + decay: 0.8, + volume: 0.22, + pan: 0, + }); + + // —— the held PAD chord (the horizon bands) —— + // Only retune when the chord actually changes → trigger the sky re-color sweep. + const nextChord = PAD[step]; + const changed = nextChord.join() !== chord.join(); + if (changed) { + prevChord = chord; + chord = nextChord; + chordBlend = 0; // begin the slow gradient sweep to the new sky + } + if (pad.length === 0) { + for (let i = 0; i < chord.length; i++) { + pad.push( + synth({ + tone: chord[i], + type: "sine", + duration: "🔁", // held forever + attack: 1.4, // luscious slow fade-in + decay: 0.95, + volume: 0.13, + pan: (i - 1) * 0.4, // spread the triad across the field + }), + ); + } + } else if (changed) { + for (let i = 0; i < pad.length; i++) { + pad[i]?.update?.({ tone: chord[i % chord.length] }); + } + } + + // —— soft airy shimmer on top (the motes' sound) —— + // A very quiet high triangle a couple octaves above the top pad tone on the + // sky-changing beats — a pale glimmer, never a click. + if (changed) { + const top = chord[chord.length - 1]; + synth({ + tone: top.replace(/\d/, (d) => String(Math.min(7, +d + 2))), + type: "triangle", + beats: 4.0, + attack: 1.0, + decay: 0.7, + volume: 0.06, + pan: 0.2, + }); + } +} + +// —— THE WHOLE PIECE IS A BUTTON —— +// A soft tap/drag sends a warm light-ripple across the sky + a gentle swelling +// chord tone. X → pan + hue, Y → pitch (top = high). Taps punch a little pump; +// drags feed it. Keep every burst SOFT — a swell, never a stab. +function act({ event: e, sound: { synth }, screen, num }) { + const w = screen.width || 1; + const h = screen.height || 1; + + if (e.is("touch") || e.is("draw")) { + const x = num.clamp(e.x / w, 0, 1); + const y = num.clamp(e.y / h, 0, 1); + const isDraw = e.is("draw"); + + // Accumulate a soft, decaying brightness swell — taps punch gently, drags feed. + pump = Math.min(2.4, pump + (isDraw ? 0.05 : 0.5)); + + // A warm light-ripple spreads outward from the touch point (translucent, no + // hard edge). Hue rides X across the dawn arc (rose→amber→gold). + const hue = (x * 0.16 + 0.02) % 1; // ~violet-rose..amber-gold band + ripples.push({ + x: e.x, + y: e.y, + r: 0, + life: 1, + hue, + vol: isDraw ? 0.5 : 1, + }); + if (ripples.length > 20) ripples.shift(); + + // Gentle swelling chord tone — pentatonic so it always agrees with the pad. + // Y → pitch (top = high), X → pan. Slow attack, long decay → a soft swell. + const scale = ["c", "d", "e", "g", "a"]; + const noteName = scale[Math.min(4, Math.floor(x * 5))]; + const oct = 3 + Math.round((1 - y) * 3); // 3..6, higher up = higher note + const pan = x * 2 - 1; + synth({ + tone: noteName + oct, + type: "sine", + beats: 1.6, + attack: 0.15, // soft swell, not a stab + decay: 0.85, + volume: 0.3 * (isDraw ? 0.6 : 1), + pan, + }); + // A faint triangle shimmer an octave up — a little glimmer, softer high up. + synth({ + tone: noteName + Math.min(7, oct + 1), + type: "triangle", + beats: 0.9, + attack: 0.1, + decay: 0.6, + volume: 0.09 * (isDraw ? 0.5 : 1), + pan, + }); + + if (e.is("touch")) dragId = e.pointer ?? e.id ?? 0; + } + + if (e.is("lift")) dragId = null; +} + +// Poll live audio (mandatory) + advance the UTC beat grid + smooth motion. +function sim({ sound: { speaker, synth }, clock }) { + speaker?.poll(); + + // —— UTC-synced rhythm —— + // Absolute beat count from epoch ms → every instance computes the same step. + // clock.time() can momentarily return an invalid Date (offset mid-sync); the + // `??` won't catch that (it's a Date object, not null) so guard NaN explicitly + // and fall back to local wall-clock time — the grid stays continuous. + const ct = clock?.time?.(); + let ms = ct ? ct.getTime() : NaN; + if (!Number.isFinite(ms)) ms = Date.now(); + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; // 0..1 within the current beat + globalCycle = globalBeat / LOOP; // continuous cycle position (seamless phase) + + if (idx !== lastBeat) { + lastBeat = idx; + onBeat(idx, synth); // fire this beat's sub pedal + pad retune + shimmer + } + + // Slow crossfade of the sky when the chord changes (gradient re-color sweep). + chordBlend = Math.min(1, chordBlend + 0.02); + + // Soft decay of the tap swell + advance/expire the light-ripples. + pump = Math.max(0, pump - 0.01); + for (const r of ripples) { + r.r += 2.0 + r.vol * 1.8; // gentle, lingering outward spread + r.life -= 0.01; // slow fade so the ripple reads clearly + } + ripples = ripples.filter((r) => r.life > 0); + + // Drift the light motes slowly around the sun. + for (const m of motes) { + m.a += m.spd * 0.01; + m.ph += 0.02; + } +} + +function paint({ ink, box, screen, sound, num, paintCount }) { + const { width: w, height: h } = screen; + const base = Math.min(w, h); + + // —— audio reads —— + const bands = sound.speaker?.frequencies?.left || []; + const sub = bands.find((b) => b.name === "subBass")?.amplitude || 0; + const lowMid = bands.find((b) => b.name === "lowMid")?.amplitude || 0; + const air = bands.find((b) => b.name === "air")?.amplitude || 0; + const amp = sound.speaker?.amplitudes?.left || 0; + + // Seamless global phase from the UTC cycle (wraps every LOOP beats). + const cyclePhase = ((globalCycle % 1) + 1) % 1; // 0..1 + const drift = cyclePhase * Math.PI * 2; + + // Every beat = a gentle whole-sky breath: brightest right on the beat, easing + // to the next. Tap pump adds a soft extra brightening. + const beatPulse = 1 - beatProgress; // 1 on the beat → 0 just before next + const glow = 1 + 0.04 * beatPulse + pump * 0.14 + amp * 0.3; // overall brightness + + // The sun sits low on the screen, near the horizon — a rising dawn. Keep it + // low so the sky above has room for the horizon light bands to read clearly. + const horizonY = h * 0.74; // where sky meets the warm base bands + const sunX = w * 0.5; + // Sun breathes up/down gently with the cycle (the "rising" motion). + const sunY = horizonY + Math.sin(drift) * base * 0.025 - base * 0.015 * beatPulse; + + // —— DEEP SKY WASH (soft dawn gradient veil, feedback trails) —— + // A translucent vertical gradient each frame: deep indigo up top easing to a + // warm rose toward the horizon. The veil (not a hard wipe) lets bands + motes + // leave soft trails. Brightens subtly with the overall glow. + const veilA = 40 + amp * 22 + pump * 14; + ink("fade:midnightblue-indigo-mediumvioletred:vertical", veilA).box(0, 0, w, h); + + // —— THE HORIZON LIGHT BANDS (the pad chord made visible) —— + // Each of the three sustained chord tones is ONE horizontal light band. The + // band's vertical CENTER ∝ that tone's pitch: low tone → a low warm band near + // the horizon, high tone → a high pale band up in the sky. The held triad is + // literally the stacked dawn. Bands crossfade (prev→current) on chord change + // for a slow gradient re-color sweep. They breathe up/down with the cycle. + const curH = bandHeights(chord); // 0..1 per tone + const prvH = bandHeights(prevChord); + const heights = curH.map((c, i) => + num.lerp(prvH[i] ?? c, c, chordBlend), + ); + // Sort so we draw low bands (near horizon) first, pale bands on top. + const order = heights.map((v, i) => i).sort((a, b) => heights[a] - heights[b]); + + // The band stack spans from just above the horizon up into the mid-sky, so the + // three tones read as three clearly separated horizontal strata of dawn light. + const bandTop = horizonY - base * 0.62; // highest a band can sit + const bandBot = horizonY - base * 0.02; // lowest (nearest the horizon) + for (const i of order) { + const bh = heights[i]; // 0 (low/warm) .. 1 (high/pale) + // Band center Y: pitch 0 → just above horizon, pitch 1 → high in the sky. + const bandY = num.lerp(bandBot, bandTop, bh); + // Soft breathing thickness; a touch thicker for the low warm bands. + const thick = + base * (0.06 + (1 - bh) * 0.05) * + (1 + 0.12 * Math.sin(drift * 1.3 + i) + sub * 0.4 + air * 0.25) * + glow; + // Hue rides the pitch across the dawn arc: low = deep amber/rose (~14°), + // high = pale gold/cream (~50°). Higher band = paler + brighter. + const hueDeg = 12 + bh * 42; // ~amber → gold + const sat = 84 - bh * 36; // paler up high + const light = 50 + bh * 26 + amp * 8; // brighter up high + const [br, bg, bb] = num.hslToRgb(hueDeg, sat, Math.min(88, light)); + // Feathered band: a few stacked translucent boxes, widest+faintest first, + // so there's no hard edge — a soft glowing stratum of dawn light. A brighter + // crisp core box on top lets the stratum read as a distinct band. + const layers = 4; + for (let L = layers; L >= 1; L--) { + const f = L / layers; // 1 = widest/faintest + const th = thick * f; + const a = (34 + (1 - bh) * 28 + amp * 18 + pump * 14) * (1 - f * 0.42); + ink(br, bg, bb, a).box(0, bandY - th / 2, w, th); + } + // Crisp bright core stratum → the band is unmistakably readable. + ink(br, bg, bb, 120 + amp * 40 + pump * 20).box( + 0, bandY - thick * 0.16, w, thick * 0.32, + ); + } + + // —— THE SUN DISC (the sub pedal made visible) —— + // A soft glowing sun whose SIZE + GLOW ARE the sub note (lower sub = larger, + // warmer sun) and which flares gently on each beat — the heartbeat. Layered + // translucent circles (widest + faintest first) build a feathered corona with + // no hard edge; the innermost warms toward white-gold. Pure circles. + const subN = num.clamp(Math.log2(subFreq / 30) / 2.2, 0, 1); // 0=low..1=high + const sunR = + base * + (0.075 + (1 - subN) * 0.03) * // lower sub → larger sun (kept modest) + (1 + sub * 0.7) * + glow + + beatPulse * base * 0.012; + // Sun hue: warm amber-gold, warming a touch with the sub energy. + const sunHue = 40 + sub * 8; + const [sr, sg, sb] = num.hslToRgb(sunHue, 82, 62); // 0..255, no ×255 + // Soft corona/haze first (contained so it doesn't swamp the bands), then the + // disc, then a hot core. + const CORONA = 7; + for (let i = CORONA; i >= 1; i--) { + const f = i / CORONA; // 1 = outermost/faintest corona ring + // Warm gold at the edge, fading toward cream-white at the middle. + const wr = sr + (255 - sr) * (1 - f); + const wg = sg + (250 - sg) * (1 - f); + const wb = sb + (225 - sb) * (1 - f); + const a = (11 + beatPulse * 12 + sub * 20 + amp * 14 + pump * 12) * (1 - f * 0.62); + ink(wr, wg, wb, a).circle(sunX, sunY, sunR * (0.55 + f * 1.15), true); + } + // The hot little core. + ink(255, 250, 236, 130 + beatPulse * 30 + amp * 40).circle( + sunX, sunY, sunR * 0.5, true, + ); + + // —— DRIFTING LIGHT MOTES (the airy shimmer made visible) —— + // Soft round glows orbiting the sun, twinkling with the air band + amp. Never + // hard points — feathered little suns. + for (const m of motes) { + const orb = sunR * (1.1 + m.rad * 1.4); + const mx = sunX + Math.cos(m.a) * orb; + const my = sunY + Math.sin(m.a) * orb * 0.7; // flatter orbit (over horizon) + const tw = 0.5 + 0.5 * Math.sin(m.ph); // twinkle 0..1 + const mr = m.size * (1 + air * 3 + amp * 2) * (0.6 + tw * 0.8); + const a = (18 + air * 60 + amp * 30 + pump * 16) * (0.4 + tw * 0.6); + ink(255, 244, 214, a * 0.5).circle(mx, my, mr * 2.2, true); + ink(255, 250, 232, a).circle(mx, my, mr, true); + } + + // —— tap light-ripples (the "button" made visible) —— + // Each tap's warm ripple expands across the sky and fades — translucent rings + // + a soft warm center, never a hard box. Hue rides where you touched. + for (const r of ripples) { + const rHueDeg = 8 + ((r.hue % 1) + 1) % 1 * 52; // amber→gold arc + const [rr, rg, rb] = num.hslToRgb(rHueDeg, 74, 66); + const la = r.life * r.life; // ease-out fade + // Two expanding soft rings — a warm light-ripple spreading outward. + ink(rr, rg, rb, 90 * la * r.vol).circle(r.x, r.y, r.r, false, 3); + ink(rr, rg, rb, 50 * la * r.vol).circle(r.x, r.y, r.r * 0.62, false, 2); + // Feathered warm glow at the source (widest+faintest first → no hard edge). + const srcR = 14 + r.r * 0.1; + ink(255, 240, 210, 42 * la * r.vol).circle(r.x, r.y, srcR, true); + ink(255, 246, 222, 78 * la * r.vol).circle(r.x, r.y, srcR * 0.55, true); + ink(255, 251, 236, 140 * la * r.vol).circle(r.x, r.y, srcR * 0.28, true); + } + + // A single slow drifting high mote, wrapping every 600 frames so the top-layer + // motion also loops cleanly. A soft round glow, not a hard box. + const dt = (Number(paintCount) % 600) / 600; // paintCount is a Number + const da = dt * Math.PI * 2; + const gx = sunX + Math.cos(da) * base * 0.34; + const gy = sunY - base * 0.18 + Math.sin(da * 1.5) * base * 0.1; + const gr2 = (4 + air * 18 + sub * 10) * glow; + ink(255, 248, 224, 24 + amp * 44).circle(gx, gy, gr2 * 1.9, true); + ink(255, 252, 238, 60 + amp * 80).circle(gx, gy, gr2, true); +} + +function leave() { + pad.forEach((v) => v?.kill?.(0.8)); + pad = []; + subVoice?.kill?.(0.8); + subVoice = null; +} + +export { boot, sim, paint, act, leave }; diff --git a/system/public/aesthetic.computer/disks/vindle.mjs b/system/public/aesthetic.computer/disks/vindle.mjs new file mode 100644 index 0000000000..aeeb2c6076 --- /dev/null +++ b/system/public/aesthetic.computer/disks/vindle.mjs @@ -0,0 +1,433 @@ +// vindle, 26.07.12 +// A GROWING-VINE instrument — the melody literally DRAWS a plant. +// +// FOUR PRINCIPLES (see marketing/av-reels/button-sync-allegory.md): +// 1. NO resolution() — renders at native res, fills any screen. +// 2. THE WHOLE PIECE IS A BUTTON — tap or XY-drag sprouts a NEW SHOOT from +// the touch point that grows + plucks a note (X→pan/hue, Y→pitch; top = +// high). Repeated taps accumulate a decaying `pump` that makes the whole +// plant grow FASTER + LUSHER (thicker stems, more buds). +// 3. UTC-SYNCED RHYTHM — the growth (one new vine segment per arp note) + the +// trunk bass are scheduled from clock.time() inside sim() (NOT beat()). We +// compute the absolute global step from epoch-ms and index the pattern by +// floor(globalStep)%len, so two instances opened anywhere grow in lockstep. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — the score IS the drawn plant: +// • ARP note = a new VINE SEGMENT. Its TURN ANGLE ∝ pitch (high note +// bends the vine UP, low note bends it DOWN) and its HUE = +// pitch. So the melodic contour is literally the shape + +// color of the growing vine — you read the melody as the plant. +// • ACCENT = a LEAF / FLOWER buds at the growing tip. +// • BASS = the trunk thickens + a root pulse blooms from the base. +// • HAT = a tiny leaf-flicker flecks off the vine. +// • THE LOOP = at the pattern seam the vine gracefully fades and regrows +// → seamless (the vine resets/regrows on every loop). + +// --- UTC-synced growth grid ---------------------------------------------- +// 8th-note grid. A musical BPM (quarter-notes); the 8th step advances at 2×. +// Absolute-from-epoch → every instance computes the same step at the same ms. +const QUARTER_BPM = 108; // patient, hypnotic +const STEP_MS = 60000 / QUARTER_BPM / 2; // ms per 8th-note step (~278ms) + +// One woody plucked note per 8th step → one new vine segment. The vine REGROWS +// every PATTERN.length steps (the loop seam), so the whole phrase is one plant. +// D minor pentatonic-ish, rising then settling → the vine climbs then curls. +const ARP = [ + "d3", "a3", "d4", "f4", + "a4", "g4", "e4", "c4", + "d4", "f4", "a4", "d5", + "c5", "a4", "f4", "d4", +]; +const PATTERN_LEN = ARP.length; // 16 segments per grown vine + +// Accents (buds sprout here): the phrase's peaks. +const ACCENT_STEPS = new Set([4, 11, 12]); + +// Warm woody trunk bass — one note per bar (every 4 steps). +const BASS = ["d2", "d2", "f2", "a2"]; + +// Pitch range across ARP, used to map note → turn angle + hue. +const ARP_LOW = 146.83; // d3 +const ARP_HIGH = 587.33; // d5 + +// Precomputed note → Hz (equal temperament) so we don't touch sound.freq in the +// hot path. Only the notes we actually use. +const NOTE_FREQ = { + d2: 73.42, f2: 87.31, a2: 110.0, + c4: 261.63, d3: 146.83, e4: 329.63, f4: 349.23, g4: 392.0, a3: 220.0, + a4: 440.0, d4: 293.66, c5: 523.25, d5: 587.33, +}; + +let lastStep = -1; // last integer 8th-step fired (UTC scheduler) +let stepProgress = 0; // 0..1 through the current 8th step (smooth easing) +let seedStep = 0; // step index of the current vine's first segment + +// --- Vine state ---------------------------------------------------------- +// The vine is an array of segments appended on each UTC step. Each segment +// records its endpoints, thickness, hue and any bud — so older growth persists +// and the whole melody is legible as the drawn plant. +let vine = []; // [{ x0,y0,x1,y1, hue, thick, born, bud, budHue, pan }] +let tipX = 0, tipY = 0; // current growing tip (screen coords) +let tipAngle = -Math.PI / 2; // heading; -90° = straight up +let vineFade = 1; // 1 = solid, → 0 as the vine fades at the loop seam +let rootPulse = 0; // decays; blooms from the base on the bass/trunk beat +let trunkThick = 0; // decays; extra base-stem girth on the bass beat + +// --- Shoots (tap-sprouted mini vines) ------------------------------------ +let shoots = []; // [{ segs:[...], tipX,tipY,angle, hue,pan, grow, life }] + +// --- Button energy ------------------------------------------------------- +let pump = 0; // decaying global energy from taps/drags, 0..~3 +let flecks = []; // tiny leaf-flicker flecks (hats + tap sparkle) + +// --- Lifecycle ----------------------------------------------------------- +let drone = null; // sustained woody floor so the reel always sings +let drone2 = null; + +function boot({ sound, clock }) { + clock?.resync?.(); // fetch UTC offset (silent local fallback offline — fine) + sound.room?.set?.({ enabled: true, mix: 0.18, feedback: 0.45 }); // woody room + // Two sustained low drones = the warm woody bed + the trunk's body, so the + // capture always has present bass wherever the UTC phase lands. + drone = sound.synth?.({ tone: "d2", type: "sine", duration: "🔁", + attack: 0.6, decay: 0.99, volume: 0.7, pan: -0.12 }); + drone2 = sound.synth?.({ tone: "a2", type: "triangle", duration: "🔁", + attack: 0.8, decay: 0.99, volume: 0.45, pan: 0.12 }); +} + +// note (Hz) → 0..1 across the ARP pitch range (log/pitch space). +function pitchNorm(freq) { + const t = (Math.log2(freq) - Math.log2(ARP_LOW)) / + (Math.log2(ARP_HIGH) - Math.log2(ARP_LOW)); + return Math.max(0, Math.min(1, t)); +} + +// hue 0..1, sat/light 0..1 → [r,g,b] 0..255. Wraps num.hslToRgb (wants hue in +// DEGREES + sat/light 0..100, and already returns 0..255 — do NOT ×255). +function hue2rgb(num, hue, sat, light) { + return num.hslToRgb(((hue % 1) + 1) % 1 * 360, sat * 100, light * 100); +} + +// Reset the vine to a fresh sprout at the base — called at each loop seam. +function resprout(w, h) { + vine = []; + tipX = w / 2; + tipY = h * 0.92; // grow up from near the bottom + tipAngle = -Math.PI / 2; // straight up + vineFade = 1; +} + +// Fire one 8th step: append a vine segment (angle+hue ∝ pitch), bud on accents, +// trunk bass every bar, hat on off-steps. Called from the UTC scheduler in sim +// so growth + onsets align across instances. `w,h` = screen for geometry. +function fireStep(idx, synth, num, w, h) { + const local = ((idx - seedStep) % PATTERN_LEN + PATTERN_LEN) % PATTERN_LEN; + + // At the seam (local wraps to 0) the previous vine has faded — resprout. + if (local === 0) resprout(w, h); + + const note = ARP[local]; + const freq = typeof note === "number" ? note : (NOTE_FREQ[note] || 220); + const pn = pitchNorm(freq); // 0 low .. 1 high + const accent = ACCENT_STEPS.has(local); + const STEP_S = STEP_MS / 1000; + + // --- SONIC: woody plucked note (triangle body + sine sub-pluck), quick decay. + const pan = -0.5 + (local / (PATTERN_LEN - 1)) * 1.0; + synth({ tone: note, type: "triangle", duration: STEP_S * 2.2, attack: 0.004, + decay: 0.42, volume: 0.82, pan }); + synth({ tone: note, type: "sine", duration: STEP_S * 1.6, attack: 0.003, + decay: 0.5, volume: 0.4, pan }); // rounder woody body + if (accent) { + // A brighter bloom pluck when a leaf/flower buds. + synth({ tone: note, type: "triangle", duration: STEP_S * 2.6, attack: 0.002, + decay: 0.4, volume: 0.5, pan }); + } + + // --- TRUNK BASS every bar (every 4 steps) → root pulse + trunk thickens. + if (local % 4 === 0) { + const bn = BASS[(local / 4) % BASS.length]; + synth({ tone: bn, type: "sine", duration: STEP_S * 3.4, attack: 0.006, + decay: 0.35, volume: 0.85, pan: 0 }); + synth({ tone: bn, type: "triangle", duration: STEP_S * 2.4, attack: 0.008, + decay: 0.3, volume: 0.5, pan: 0 }); + rootPulse = 1; + trunkThick = 1; + } + + // --- HAT on off-steps → soft leaf-rustle + a tiny flicker fleck. + if (local % 2 === 1) { + synth({ type: "noise-white", tone: 1200, duration: STEP_S * 0.4, + attack: 0.001, decay: 0.18, volume: local % 4 === 3 ? 0.22 : 0.14 }); + flecks.push({ x: tipX, y: tipY, vx: (Math.random() - 0.5) * 1.6, + vy: -Math.random() * 1.2, life: 1, hue: 0.28 }); + } + + // --- GRAPHIC: extend the vine by one segment. TURN ANGLE ∝ pitch: a high + // note bends the vine UP (toward -90°), a low note bends it DOWN. Centered on + // straight-up so the melodic contour becomes the vine's shape. + const turn = (pn - 0.5) * (Math.PI * 0.9); // -0.45π (low)..+0.45π (high) + // Bias the heading toward the pitch-implied angle, plus a gentle sway. + const targetAngle = -Math.PI / 2 + -turn; // high pitch → more upward + tipAngle = tipAngle * 0.55 + targetAngle * 0.45; + tipAngle += Math.sin(idx * 0.7) * 0.12; // organic sway + + const segLen = Math.min(w, h) * (0.05 + pn * 0.03) * (1 + pump * 0.35); + const nx = tipX + Math.cos(tipAngle) * segLen; + const ny = tipY + Math.sin(tipAngle) * segLen; + const hue = 0.22 + pn * 0.22; // green (low) → chartreuse/gold (high) + const thick = (2 + pn * 3) * (1 + pump * 0.5); + + vine.push({ + x0: tipX, y0: tipY, x1: nx, y1: ny, + hue, thick, born: idx, pan, + bud: accent || pump > 1.2, // buds on accents (and lushly when pumped) + budHue: accent ? 0.06 + pn * 0.08 : hue + 0.05, // gold/coral flower vs leaf + }); + tipX = nx; + tipY = ny; + + // If the tip wanders off-screen, nudge it back toward center so the plant + // stays legible in-frame. + if (tipX < w * 0.12) tipAngle += 0.25; + if (tipX > w * 0.88) tipAngle -= 0.25; + if (tipY < h * 0.12) tipAngle = -tipAngle * 0.3 + 0.4; // ceiling → curl down + + stepProgress = 0; +} + +function sim({ sound: { speaker, synth }, clock, screen, num }) { + speaker?.poll(); // mandatory for audio-reactive reads + const w = screen.width, h = screen.height; + + // Lazily seed the first sprout once we know the screen size. + if (vine.length === 0 && tipX === 0 && tipY === 0) resprout(w, h); + + // --- UTC-synced scheduler: derive the growth grid from the shared clock --- + // clock.time() returns an *Invalid Date* (getTime() → NaN) before the offset + // resolves and offline. `?? new Date()` does NOT catch it. Guard with isFinite + // or the step index goes NaN → fireStep every tick (audio spam). + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); // local fallback; prod uses UTC + const globalStep = ms / STEP_MS; + const idx = Math.floor(globalStep); + stepProgress = globalStep - idx; // 0..1 within this 8th step + + if (idx !== lastStep) { + // On the very first real step, align the seam so the vine sprouts from step 0 + // of its pattern (a clean plant), rather than mid-phrase. + if (lastStep === -1) seedStep = idx; + lastStep = idx; + fireStep(idx, synth, num, w, h); + } + + // Fade the vine across the last part of its life so it gracefully dissolves + // just before the seam → seamless regrow. local phase 0..1 across the pattern. + const local = ((idx - seedStep) % PATTERN_LEN + PATTERN_LEN) % PATTERN_LEN; + const phase = (local + stepProgress) / PATTERN_LEN; // 0..1 through this vine + vineFade = phase > 0.86 ? Math.max(0, 1 - (phase - 0.86) / 0.14) : 1; + + // Advance flecks (leaf flickers). + for (let i = 0; i < flecks.length; i++) { + const f = flecks[i]; + f.x += f.vx; + f.y += f.vy; + f.vy += 0.08; // gentle gravity + f.vx *= 0.97; + f.life -= 0.035; + } + flecks = flecks.filter((f) => f.life > 0); + + // Advance tap-sprouted shoots — each grows its own little vine over time. + for (let i = 0; i < shoots.length; i++) { + const s = shoots[i]; + s.grow += (0.06 + pump * 0.05); // grow faster when pumped + // Append a segment as the shoot grows past each integer step. + while (s.segs.length < Math.floor(s.grow) && s.segs.length < 9) { + const segLen = Math.min(w, h) * 0.045 * (1 + pump * 0.3); + s.angle += (Math.random() - 0.5) * 0.5 - 0.12; // curl upward-ish + const nx = s.tipX + Math.cos(s.angle) * segLen; + const ny = s.tipY + Math.sin(s.angle) * segLen; + s.segs.push({ x0: s.tipX, y0: s.tipY, x1: nx, y1: ny, + bud: s.segs.length === 8 }); + s.tipX = nx; + s.tipY = ny; + } + s.life -= 0.006; + } + shoots = shoots.filter((s) => s.life > 0); + + // Decay pulses & tap energy. + rootPulse *= 0.9; + trunkThick *= 0.92; + pump *= 0.968; // slow decay so repeated taps accumulate + + // Drones breathe with the trunk pulse + tap energy → a living woody bed. + drone?.update?.({ volume: 0.7 + rootPulse * 0.2 + pump * 0.1 }); + drone2?.update?.({ volume: 0.45 + rootPulse * 0.15 + pump * 0.08 }); +} + +// --- Button: tap / XY-drag sprouts a NEW SHOOT that grows + plucks a note ---- +function act({ event: e, sound: { synth }, screen, num }) { + if (!(e.is("touch") || e.is("draw"))) return; + + const nx = e.x / screen.width; // 0..1 + const ny = e.y / screen.height; // 0..1 + const drag = e.is("draw"); + + // Accumulate energy: taps punch, drags feed. + pump = Math.min(3, pump + (drag ? 0.12 : 0.9)); + + const hue = 0.22 + (1 - ny) * 0.24; // higher tap → brighter/golder shoot + // A new shoot sprouts from the tap point and grows upward from there. + shoots.push({ + segs: [], tipX: e.x, tipY: e.y, + angle: -Math.PI / 2 + (nx - 0.5) * 1.0, // X biases the initial lean + hue, pan: nx * 2 - 1, grow: 0, + life: 1, + }); + // A little burst of leaf flecks at the sprout point. + const n = drag ? 3 : 9; + for (let i = 0; i < n && flecks.length < 200; i++) { + const ang = Math.random() * Math.PI * 2; + const sp = 1 + Math.random() * (drag ? 2 : 4); + flecks.push({ x: e.x, y: e.y, vx: Math.cos(ang) * sp, + vy: Math.sin(ang) * sp - 1, life: 1, hue }); + } + + // SONIC pluck — X→pan/hue, Y→pitch (top = high). Woody triangle + sine body. + const scale = ["d", "e", "f", "a", "c"]; + const oct = 2 + Math.floor((1 - ny) * 3); // 2..4, higher tap = higher + const note = scale[Math.min(4, Math.floor(nx * 5))] + oct; + synth({ tone: note, type: "triangle", duration: drag ? 0.22 : 0.5, + attack: 0.004, decay: 0.45, volume: drag ? 0.42 : 0.78, pan: nx * 2 - 1 }); + synth({ tone: note, type: "sine", duration: drag ? 0.16 : 0.4, attack: 0.003, + decay: 0.5, volume: 0.35, pan: nx * 2 - 1 }); // woody body +} + +let grounded = false; // has the deep-forest ground been laid solid yet? +function paint({ ink, line, circle, box, screen, sound, num, paintCount }) { + const w = screen.width, h = screen.height; + const baseX = w / 2, baseY = h * 0.92; + + // Lay a SOLID deep-forest ground on the first frames so the field reads dark + // green (a translucent veil alone converges to neutral gray over the default + // canvas). After that, a translucent green veil each frame → older growth + // persists then fades softly (trails). Vertical gradient reads as depth. + if (!grounded || Number(paintCount) < 3) { + ink("fade:#06180c-#010604:vertical").box(0, 0, w, h); + grounded = true; + } + // Two-part veil: a stronger opaque-ish dark forest wash keeps the ground deep + // green (a thin veil alone drifts to gray), plus a lighter tint for long trails. + ink(5, 20, 11, Math.max(80, 110 - pump * 8)).box(0, 0, w, h); + ink("fade:#06180c-#010604:vertical", 40).box(0, 0, w, h); + + // --- Live audio garnish (visuals never DEPEND on it) --- + const bands = sound.speaker?.frequencies?.left || []; + const bass = bands.find((b) => b.name === "subBass")?.amplitude || 0; + const air = bands.find((b) => b.name === "air")?.amplitude || 0; + + // === ROOT PULSE — the bass/trunk beat blooms from the base of the plant === + const bloom = Math.max(rootPulse, 0) * (1 + pump * 0.3); + if (bloom > 0.02) { + const R = Math.min(w, h) * (0.1 + bass * 0.4) * bloom; + const [r0, g0, b0] = hue2rgb(num, 0.11, 0.7, 0.35); // warm earthy glow + for (let i = 4; i > 0; i--) { + ink(r0, g0, b0, 34 * bloom).circle(baseX, baseY, R * (i / 4), true); + } + } + + // === THE TRUNK — a thick woody stem rising from the base into the vine === + const trunkH = Math.min(h, w) * 0.08; + const trunkW = 4 + trunkThick * 5 + pump * 2; + const [tr, tg, tb] = hue2rgb(num, 0.09, 0.55, 0.28); // deep brown-green + ink(tr, tg, tb, 235 * vineFade) + .line(baseX, baseY, baseX, baseY - trunkH, trunkW); + + // === THE VINE — the drawn melody. Each segment's angle+hue ∝ its note. === + // Draw oldest→newest so the tip sits on top. Segments born recently glow. + const now = lastStep; + for (let i = 0; i < vine.length; i++) { + const s = vine[i]; + const age = now - s.born; // steps since it grew + const fresh = Math.max(0, 1 - age * 0.16); // recent segments glow brighter + const [r, g, b] = hue2rgb(num, s.hue, 0.85, 0.42 + fresh * 0.28); + const a = 240 * vineFade; + const th = s.thick * (1 + fresh * 0.6 + bass * 0.5); + ink(r, g, b, a).line(s.x0, s.y0, s.x1, s.y1, Math.max(1, th)); + // A soft highlight core down the stem. + if (th > 2) { + const [hr, hg, hb] = hue2rgb(num, s.hue, 0.5, 0.72); + ink(hr, hg, hb, 120 * vineFade).line(s.x0, s.y0, s.x1, s.y1, Math.max(1, th * 0.4)); + } + + // === LEAF / FLOWER bud at the tip of accented segments === + if (s.bud) { + const budGrow = Math.min(1, 0.3 + fresh); // pops as it's born + const br = (3 + budGrow * 5) * (1 + pump * 0.3); + const isFlower = ACCENT_STEPS.has(((s.born - seedStep) % PATTERN_LEN + PATTERN_LEN) % PATTERN_LEN); + const [pr, pg, pb] = hue2rgb(num, s.budHue, isFlower ? 0.9 : 0.7, + isFlower ? 0.6 : 0.45); + if (isFlower) { + // A little flower: petals around the tip. + for (let p = 0; p < 5; p++) { + const ang = (p / 5) * Math.PI * 2 + now * 0.05; + ink(pr, pg, pb, 220 * vineFade) + .circle(s.x1 + Math.cos(ang) * br, s.y1 + Math.sin(ang) * br, + br * 0.6 + budGrow * 2, true); + } + ink(255, 230, 120, 230 * vineFade).circle(s.x1, s.y1, br * 0.7, true); // pollen + } else { + ink(pr, pg, pb, 220 * vineFade).circle(s.x1, s.y1, br, true); // leaf + } + } + } + + // === The growing TIP — a bright bud that swells within each step === + if (vine.length > 0 && vineFade > 0.02) { + const tipR = (3 + stepProgress * 4 + bass * 8) * (1 + pump * 0.4); + ink(230, 255, 180, 220 * vineFade).circle(tipX, tipY, tipR, true); + ink(255, 255, 255, 160 * vineFade).circle(tipX, tipY, tipR * 0.45, true); + } + + // === TAP SHOOTS — the whole-piece "button" made visible as new plants === + for (let i = 0; i < shoots.length; i++) { + const s = shoots[i]; + const [r, g, b] = hue2rgb(num, s.hue, 0.85, 0.5); + for (let j = 0; j < s.segs.length; j++) { + const sg = s.segs[j]; + ink(r, g, b, 235 * s.life).line(sg.x0, sg.y0, sg.x1, sg.y1, + Math.max(1, (2 + pump) * s.life)); + if (sg.bud) { + const [br2, bg2, bb2] = hue2rgb(num, s.hue + 0.05, 0.9, 0.6); + ink(br2, bg2, bb2, 230 * s.life).circle(sg.x1, sg.y1, 4 + pump, true); + } + } + // Growing tip glow. + ink(230, 255, 180, 200 * s.life).circle(s.tipX, s.tipY, 3 + pump, true); + } + + // === HAT flecks = tiny leaf flickers === + for (let i = 0; i < flecks.length; i++) { + const f = flecks[i]; + const [r, g, b] = hue2rgb(num, f.hue, 0.7, 0.55); + ink(r, g, b, 220 * f.life).circle(f.x, f.y, 1 + f.life * 2, true); + } + + // Air-band shimmer accent (garnish when audio is live) — dew on the leaves. + if (air > 0.05) { + ink(200, 255, 210, air * 90).circle(baseX, baseY - trunkH, Math.min(w, h) * 0.2, false, 1); + } +} + +function leave() { + drone?.kill?.(0.5); // fade the woody drones out on exit + drone2?.kill?.(0.5); + drone = null; + drone2 = null; +} + +// Note: there is intentionally NO `beat` export. The metronome is not the audio +// source — all onsets + growth come from the UTC scheduler in sim() (indexed by +// floor(globalStep)) so two instances opened anywhere auto-align. +export { boot, sim, paint, act, leave }; diff --git a/system/public/aesthetic.computer/disks/voop.mjs b/system/public/aesthetic.computer/disks/voop.mjs new file mode 100644 index 0000000000..491fe082ac --- /dev/null +++ b/system/public/aesthetic.computer/disks/voop.mjs @@ -0,0 +1,434 @@ +// voop, 26.07.12 +// Self-running bouncing-droplet A/V instrument: a UTC-synced pentatonic bounce +// pattern makes a glossy droplet BOUNCE on a floor line. Every audible "voop" is +// one BOUNCE IMPACT — the instant a note sounds the droplet hits the floor, +// emits a ripple ring, and its post-impact arc HEIGHT ∝ the note's pitch (high +// note = high arc), its HUE ∝ pitch. So a viewer literally READS the melody as +// the height + color of the bounces. A soft sub thump = the floor shockwave; +// sparkles = tiny droplets flung up on impact; a motion-blur ghost traces the +// melodic contour. +// +// FOUR PRINCIPLES (marketing/av-reels/button-sync-allegory.md): +// 1. NO resolution() — renders at native res (vector + alpha veil trail). +// 2. WHOLE PIECE IS A BUTTON — tap / XY-drag FLINGS an extra droplet from the +// tap point that bounces + voops (X→pan/hue, Y→pitch/launch-height); taps +// accumulate decaying `pump` that makes bounces bigger/glossier + flashes +// the floor. +// 3. UTC-SYNCED RHYTHM — bounce impacts are scheduled from clock.time() in sim +// (NOT beat()), indexed by floor(globalBeat) % len, so two instances opened +// anywhere bounce in lockstep on the shared wall-clock grid. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — the visual IS the score (see above). +// +// BPM ~132. Loops seamlessly (the bounce pattern returns to its seam every bar). + +// --- Score ------------------------------------------------------------------ +// One looping bar of a bright pentatonic bounce (12 steps). Step 0 == the seam. +const BOUNCE = [ + "c3", "e3", "g3", "a3", "g3", "c4", + "a3", "g3", "e3", "d3", "g3", "e3", +]; +const SUB = ["c2", "c2", "g1", "a1", "g1", "c2"]; // half-time floor thump root + +// MIDI-ish pitch of each note, for pitch→arc-height / pitch→hue mapping. +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 BOUNCE_PITCHES = BOUNCE.map(notePitch); +const PITCH_MIN = Math.min(...BOUNCE_PITCHES); +const PITCH_MAX = Math.max(...BOUNCE_PITCHES); +const pitchNorm = (n) => + (notePitch(n) - PITCH_MIN) / Math.max(1, PITCH_MAX - PITCH_MIN); // 0..1 + +const BPM = 132; // springy, hypnotic +const BEAT_MS = 60000 / BPM; // ms per beat — the UTC grid unit + +// --- Rhythm state (UTC-driven) --------------------------------------------- +let lastBeat = -1; // last integer global-beat we fired +let step = 0; // current bounce index, from UTC +let beatProgress = 0; // 0..1 within the current beat (smooth arc motion) + +// --- The main droplet (the score's ball) ------------------------------------ +// It flies a parabolic arc between impacts. On each impact we set its launch +// velocity so it reaches an apex whose height ∝ the just-fired note's pitch, and +// it lands exactly one beat later — so the arc is the melody, drawn live. +let dropX = 0.5; // 0..1 horizontal (sweeps across on each bounce) +let dropXNext = 0.5; // target X for the current flight (lerp) +let dropXPrev = 0.5; +let arcHeight = 0.4; // 0..1 apex height of the current flight (pitch) +let dropHue = 180; // hue of the droplet this flight (pitch) +let squash = 0; // impact squash, 1 at impact, decays — glossy bounce feel + +// --- Allegory state --------------------------------------------------------- +let ripples = []; // floor impact rings { x, r, life, hue } +let sparkles = []; // tiny droplets flung up on impact { x, y, vx, vy, life, hue } +let ghost = []; // motion-blur trail of the droplet { x, y, r, hue, life } +let ghostTick = 0; // throttle counter for ghost sampling +let floorFlash = 0; // floor line flash on sub-thump / taps, decays +let subThump = 0; // central low shockwave from bass, decays + +// --- Button / pump state (§2) ---------------------------------------------- +let pump = 0; // decaying global energy from taps, 0..~3 +// Extra tap-flung droplets: independent bouncing balls the viewer flings in. +let flung = []; // { x, y, vx, vy, r, hue, life, bounces } + +function boot({ sound, clock }) { + sound.bpm(BPM); // only affects the internal metronome; UTC scheduler is boss + clock?.resync?.(); // fetch UTC offset (silent local fallback offline) + // A gentle rubbery room so each "voop" has a springy tail. + sound.room?.set?.({ enabled: true, mix: 0.28, feedback: 0.5 }); +} + +// Fire one step of the score. Called the instant a new global beat crosses in +// sim, so every onset is UTC-aligned and every note IS a bounce impact. +function fireStep(idx, synth, w, h) { + const s = ((idx % BOUNCE.length) + BOUNCE.length) % BOUNCE.length; + step = s; + const note = BOUNCE[s]; + const pn = pitchNorm(note); // 0 (low) .. 1 (high) + + // The droplet just LANDED here (end of previous flight). Impact! + const impactX = dropXNext; // where we are now + dropXPrev = impactX; + + // ALLEGORY: bounce HEIGHT after impact ∝ pitch; HUE ∝ pitch. + arcHeight = 0.22 + pn * 0.6; // high note = high arc + dropHue = 200 - pn * 200; // low→cyan/blue, high→warm pink (candy) + if (dropHue < 0) dropHue += 360; + + // Next landing X: step across the floor so the arcs march left↔right, reading + // as a bouncing melody scanning the width. Deterministic from step for loop. + dropXNext = 0.12 + (s / (BOUNCE.length - 1)) * 0.76; + dropX = impactX; + squash = 1; // fat impact squash → gloss + + // The rubbery "voop": a plucky sine+triangle blip pitched to the note. + const pan = impactX * 2 - 1; + synth({ + tone: note, + type: "sine", + beats: 0.55, + attack: 0.004, + decay: 0.62, + volume: 0.5, + pan, + }); + synth({ + tone: note, + type: "triangle", + beats: 0.28, + attack: 0.003, + decay: 0.4, + volume: 0.24, + pan, + }); + + // Floor ripple ring born exactly at the impact point. + ripples.push({ x: impactX * w, r: 6, life: 1, hue: dropHue }); + + // Sparkle droplets flung up on impact — a few tiny secondary droplets. + const nSpark = 4 + Math.round(pn * 5); + for (let i = 0; i < nSpark; i++) { + const ang = -Math.PI / 2 + (Math.random() - 0.5) * 1.6; + const spd = 2 + Math.random() * (3 + pn * 4); + sparkles.push({ + x: impactX * w, + y: h * 0.82, + vx: Math.cos(ang) * spd, + vy: Math.sin(ang) * spd, + life: 1, + hue: dropHue, + }); + } + + // A bright high sparkle harmonic on high steps — the flung droplets' shimmer. + if (pn > 0.6) { + synth({ + tone: note, + type: "sine", + beats: 0.35, + attack: 0.002, + decay: 0.35, + volume: 0.16, + pan: -pan, + }); + } + + // Sub thump on the half-beat — the floor shockwave you can feel + SEE. + if (s % 2 === 0) { + const bi = ((Math.floor(idx / 2) % SUB.length) + SUB.length) % SUB.length; + synth({ + tone: SUB[bi], + type: "sine", + beats: 1.4, + attack: 0.008, + decay: 0.5, + volume: 0.5, + }); + // Soft rubber body under the sub for that bouncing-bass droplet weight. + synth({ + tone: SUB[bi], + type: "triangle", + beats: 0.7, + attack: 0.005, + decay: 0.45, + volume: 0.18, + }); + subThump = 1; // ALLEGORY: bass = central floor shockwave + floorFlash = Math.max(floorFlash, 0.8); + } +} + +// Drive audio + rhythm from UTC in sim (NOT beat()) so onsets align across +// instances. Also advance the droplet's parabolic flight + decay energies. +function sim({ sound: { speaker, synth }, clock, screen }) { + speaker?.poll(); // mandatory before reading audio + + const w = screen?.width || 360; + const h = screen?.height || 640; + + // clock.time() can return an *Invalid Date* before UTC sync completes (its + // getTime() is NaN, and it's not null so `??` won't catch it) — guard with + // Date.now() so the rhythm always runs, aligning across instances once synced. + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; // 0..1 through the current beat (arc phase) + + if (idx !== lastBeat) { + lastBeat = idx; + fireStep(idx, synth, w, h); // a new UTC beat crossed → a bounce impact + } + + // Interpolate the droplet's horizontal position across the flight (impact→next). + dropX = dropXPrev + (dropXNext - dropXPrev) * beatProgress; + + // Ghost trail: sample the droplet's current position each tick (throttled). + // Only every few ticks so the trail is a comet, not a solid line. + ghostTick = (ghostTick + 1) % 2; + if (ghostTick === 0) { + ghost.push({ x: dropX, yPhase: beatProgress, hue: dropHue, life: 1 }); + if (ghost.length > 40) ghost.shift(); + } + + // Decays (per 120 Hz tick). + pump *= 0.965; + squash *= 0.86; + subThump *= 0.9; + floorFlash *= 0.9; + + // Advance / cull floor ripples. + for (const rp of ripples) { + rp.r += 5 + rp.life * 7; + rp.life -= 0.05; + } + ripples = ripples.filter((rp) => rp.life > 0); + + // Advance sparkle droplets under gravity. + for (const sp of sparkles) { + sp.x += sp.vx; + sp.y += sp.vy; + sp.vy += 0.35; // gravity + sp.life -= 0.035; + } + sparkles = sparkles.filter((sp) => sp.life > 0 && sp.y < h + 20); + + // Fade the ghost trail. + for (const g of ghost) g.life -= 0.03; + ghost = ghost.filter((g) => g.life > 0); + + // Advance tap-flung droplets (§2): full bouncing physics on the floor line. + const floorY = h * 0.82; + for (const f of flung) { + f.x += f.vx; + f.y += f.vy; + f.vy += 0.45; // gravity + if (f.y >= floorY && f.vy > 0) { + f.y = floorY; + f.vy *= -0.72; // bouncy restitution + f.vx *= 0.94; + f.bounces++; + // Each landing = a voop + a ripple, so flung droplets are audible+visible. + ripples.push({ x: f.x, r: 5, life: 1, hue: f.hue }); + floorFlash = Math.max(floorFlash, 0.5); + if (Math.abs(f.vy) > 1.2 && f.bounces < 6) { + const pn = 1 - Math.min(1, f.y / h); + const note = ["c", "e", "g", "a", "c"][Math.min(4, f.bounces)] + 3; + synth({ + tone: note, + type: "sine", + beats: 0.4, + attack: 0.003, + decay: 0.5, + volume: 0.4, + pan: (f.x / w) * 2 - 1, + }); + } + } + f.life -= 0.006; + } + // Keep a flung droplet while it still has life AND is either still bouncing + // with energy or hasn't settled yet (few bounces). + flung = flung.filter( + (f) => f.life > 0 && (Math.abs(f.vy) > 0.2 || f.bounces < 3), + ); + if (flung.length > 8) flung = flung.slice(-8); +} + +// THE WHOLE PIECE IS A BUTTON (§2): any tap / XY-drag FLINGS an extra droplet +// from the touch point that bounces + voops — X→pan/hue, Y→pitch/launch-height. +function act({ event: e, sound: { synth }, screen }) { + if (e.is("touch") || e.is("draw")) { + const x = e.x / screen.width; // 0..1 + const y = e.y / screen.height; // 0..1 + // Taps punch, drags feed — accumulate decaying pump energy. + pump = Math.min(3, pump + (e.is("draw") ? 0.1 : 0.9)); + floorFlash = Math.max(floorFlash, 0.7); + + const hue = x * 300; // candy hue across the width + // Launch height ∝ (1 - y): tapping high flings hard, low = gentle bounce. + const launch = -(4 + (1 - y) * 12); + flung.push({ + x: e.x, + y: e.y, + vx: (Math.random() - 0.5) * 4, + vy: launch, + r: 6 + (1 - y) * 8, + hue, + life: 1, + bounces: 0, + }); + + // SONIC BOOST — X→pitch/pan, Y→octave, pentatonic candy palette. + const note = + ["c", "d", "e", "g", "a"][Math.floor(x * 5)] + (2 + Math.floor((1 - y) * 4)); + synth({ + tone: note, + type: "sine", + beats: 0.5, + attack: 0.004, + decay: 0.6, + volume: 0.55, + pan: x * 2 - 1, + }); + synth({ + tone: note, + type: "triangle", + beats: 0.25, + attack: 0.002, + decay: 0.35, + volume: 0.3 * (1 - y), // brighter flick higher up + pan: x * 2 - 1, + }); + } +} + +// Parabolic arc height for a flight: 0 at the impacts (t=0,1), 1 at apex (t=.5). +function arcParab(t) { + return 4 * t * (1 - t); // 0..1..0 +} + +function paint({ ink, box, circle, oval, screen, sound, num }) { + const { width: w, height: h } = screen; + const floorY = h * 0.82; + + // --- Live audio reads ----------------------------------------------------- + const bands = sound.speaker?.frequencies?.left || []; + const bandAmp = (name) => bands.find((b) => b.name === name)?.amplitude || 0; + const bass = bandAmp("subBass"); + const amp = sound.speaker?.amplitudes?.left || 0; + + const energy = Math.min(2, subThump * 0.6 + bass * 1.1 + amp * 0.5 + pump * 0.5); + + // --- Background gradient (solid) ------------------------------------------ + // Deep candy gradient, repainted opaque each frame — the gooey comet trail is + // carried by the `ghost` object list (below), so we DON'T rely on a partial + // veil (which accumulates to muddy gray). Hand-rolled vertical scanline ramp + // for guaranteed deep, saturated candy tones (named/hex fade came up gray). + { + const bandH = Math.max(2, Math.ceil(h / 48)); + for (let by = 0; by < h; by += bandH) { + const t = by / h; // 0 top .. 1 bottom + const r = 14 + t * 30; // deep indigo → violet + const g = 6 + t * 8; + const bl = 40 + t * 50; + ink(r, g, bl).box(0, by, w, bandH); + } + } + + // --- Floor line ----------------------------------------------------------- + // The floor the droplet bounces on. Flashes on sub-thumps + taps. + const ff = Math.min(1, floorFlash + subThump * 0.6); + const [fr, fg, fb] = num.hslToRgb((dropHue + 180) % 360, 80, 55 + ff * 30); + ink(fr, fg, fb, 90 + ff * 130).box(0, floorY - 1 - ff * 2, w, 2 + ff * 4); + // A soft glow band under the floor line. + ink(fr, fg, fb, 24 + ff * 40).box(0, floorY, w, h - floorY); + + // Central floor shockwave from the sub-thump (§4: bass = shockwave). + if (subThump > 0.02) { + const sr = w * (0.1 + subThump * 0.55); + for (let i = 3; i > 0; i--) { + ink(fr, fg, fb, 22 * subThump).oval(w / 2, floorY, sr * (i / 3), sr * (i / 3) * 0.28, true); + } + } + + // --- Motion-blur ghost trail (melodic contour) ---------------------------- + // The droplet's recent arc positions, fading — a gooey comet tracing the + // melody's shape (halo + brighter core per sample). + for (const g of ghost) { + const gy = floorY - arcParab(g.yPhase) * arcHeight * (h * 0.62); + const gx = g.x * w; + const [r0, g0, b0] = num.hslToRgb(((g.hue % 360) + 360) % 360, 95, 62); + ink(r0, g0, b0, 45 * g.life).circle(gx, gy, 5 + g.life * 9, true); // gooey halo + ink(Math.min(255, r0 + 40), Math.min(255, g0 + 40), Math.min(255, b0 + 40), 110 * g.life) + .circle(gx, gy, 2 + g.life * 5, true); // brighter core + } + + // --- Floor ripples (§4: each note = one impact ripple) -------------------- + for (const rp of ripples) { + const [r0, g0, b0] = num.hslToRgb(((rp.hue % 360) + 360) % 360, 95, 62); + // Flattened ellipse ripple hugging the floor plane. + ink(r0, g0, b0, 180 * rp.life).oval(rp.x, floorY, rp.r, rp.r * 0.32, false); + ink(255, 255, 255, 120 * rp.life).oval(rp.x, floorY, rp.r * 0.55, rp.r * 0.18, false); + } + + // --- The main droplet (the score's ball) ---------------------------------- + // Its height this frame is the parabola of the current flight, scaled by the + // just-fired note's arcHeight (pitch). Squash flattens it at impact for gloss. + const arc = arcParab(beatProgress); + const dy = floorY - arc * arcHeight * (h * 0.62); + const dx = dropX * w; + const [dr, dg, db] = num.hslToRgb(((dropHue % 360) + 360) % 360, 95, 62); + const baseR = (12 + pump * 5 + energy * 4) * (1 + subThump * 0.2); + // Squash & stretch: wide + flat at impact (arc~0), round at apex. + const sq = squash * (1 - arc); // most squash right at the floor + const rw = baseR * (1 + sq * 0.9); + const rh = baseR * (1 - sq * 0.55); + + // Glossy droplet: soft halo, body, bright specular highlight. + ink(dr, dg, db, 70).oval(dx, dy, rw * 1.9, rh * 1.9, true); // halo + ink(dr, dg, db, 235).oval(dx, dy, rw, rh, true); // body + ink(Math.min(255, dr + 70), Math.min(255, dg + 70), Math.min(255, db + 70), 220) + .oval(dx - rw * 0.25, dy - rh * 0.3, rw * 0.45, rh * 0.4, true); // inner sheen + ink(255, 255, 255, 230).oval(dx - rw * 0.3, dy - rh * 0.35, rw * 0.2, rh * 0.2, true); // specular + + // --- Sparkle droplets flung up on impact ---------------------------------- + for (const sp of sparkles) { + const [r0, g0, b0] = num.hslToRgb(((sp.hue % 360) + 360) % 360, 100, 70); + ink(r0, g0, b0, 220 * sp.life).circle(sp.x, sp.y, 1 + sp.life * 3, true); + } + + // --- Tap-flung droplets (§2) ---------------------------------------------- + for (const f of flung) { + const [r0, g0, b0] = num.hslToRgb(((f.hue % 360) + 360) % 360, 95, 62); + ink(r0, g0, b0, 80 * f.life).circle(f.x, f.y, f.r * 1.7, true); // halo + ink(r0, g0, b0, 240 * f.life).circle(f.x, f.y, f.r, true); // body + ink(255, 255, 255, 220 * f.life).circle(f.x - f.r * 0.3, f.y - f.r * 0.3, f.r * 0.28, true); + } + +} + +export { boot, sim, act, paint }; diff --git a/system/public/aesthetic.computer/disks/vroon.mjs b/system/public/aesthetic.computer/disks/vroon.mjs new file mode 100644 index 0000000000..a65271a21d --- /dev/null +++ b/system/public/aesthetic.computer/disks/vroon.mjs @@ -0,0 +1,283 @@ +// vroon, 26.07.12 +// Self-running warp-tunnel A/V instrument. A big SWEEPING saw/PWM DRONE rises +// and falls ("vrooon") over a deep sub; rhythmic stabs mark the grid. The +// sweep is the star — and it IS the speed of the warp: as the sweep rises in +// pitch the tunnel of radial light-streaks rushes FASTER outward and the hue +// shifts up (low sweep = slow warm rush, high sweep = fast bright rush). The +// sub is the tunnel's core glow; accents/stabs blast shockwave rings outward. +// You literally SEE the sweep as acceleration through a warp tunnel. +// +// FOUR PRINCIPLES (marketing/av-reels/button-sync-allegory.md): +// 1. NO resolution() — renders at native res (vector + one feedback zoom). +// 2. WHOLE PIECE IS A BUTTON — tap / XY-drag blasts a shockwave ring from the +// tap point + a rev of the drone. X→pan/hue, Y→pitch/sweep target. Repeated +// taps accumulate decaying `pump` that throttles the tunnel faster+brighter. +// 3. UTC-SYNCED RHYTHM — the sweep PHASE + the accent grid derive from +// clock.time() in sim (NOT beat()), so two instances warp in lock-step; +// accents index floor(globalBeat) % len. BPM 128. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — sweep→speed+hue, sub→core glow, amp→ +// streak brightness/length, stabs→shockwave rings. The visual IS the sound. + +const BPM = 128; // brisk, propulsive +const BEAT_MS = 60000 / BPM; // ms per beat — the UTC grid unit +// The SWEEP cycles over a whole musical bar (4 beats) so it loops seamlessly: +// phase 0 == the seam. One full "vrooon" per bar. +const SWEEP_BARS = 1; +const SWEEP_MS = BEAT_MS * 4 * SWEEP_BARS; // ms per full sweep rise+fall + +// Accent stabs (one bar, 8 steps). Root motion under the sweep. Step 0 = seam. +const STABS = ["e1", "e1", "e2", "e1", "c2", "c2", "g1", "b1"]; +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 36; + return (parseInt(m[2], 10) + 1) * 12 + NOTE_SEMI[m[1]]; +} + +// --- Rhythm / sweep state (all UTC-driven) ---------------------------------- +let lastBeat = -1; // last integer global-beat we fired a stab on +let sweep = 0; // 0..1, the sweep's normalized rise/fall value (the STAR) +let sweepTarget = 0.5; // tap-set bias the sweep drifts toward (Y of last tap) +let beatProgress = 0; // 0..1 within the current beat (smooth visuals) + +// --- The sustained drone voices (created once, mutated live every sim) ------ +let droneSaw = null; // the sweeping saw — the "vrooon" +let dronePwm = null; // a square layer for PWM-ish grit +let subVoice = null; // the deep sub under it all +let started = false; // guard so we build the drone exactly once + +// --- Allegory / visual state ------------------------------------------------ +let streaks = []; // radial light-streaks rushing outward { ang, dist, len, hue, life } +let rings = []; // shockwave rings from stabs + taps { x, y, r, life, hue, thick } +let coreGlow = 0; // central tunnel-core glow, kicked by the sub, decays +let spawnAcc = 0; // fractional accumulator so streak spawn rate ∝ speed + +// --- Button / pump state (§2) ----------------------------------------------- +let pump = 0; // decaying tap energy, 0..~3 — throttles the tunnel +let lastPan = 0; // last tap's pan, biases the drone pan on a rev + +function boot({ sound, clock }) { + sound.bpm(BPM); // only affects the internal metronome; UTC scheduler is boss + clock?.resync?.(); // fetch UTC offset (silent local fallback offline) + // Cavernous tail so the warp feels deep + endless. + sound.room?.set?.({ enabled: true, mix: 0.3, feedback: 0.62 }); +} + +// Build the three sustained drone voices once (AC is silent without a voice). +function startDrone(synth) { + if (started) return; + started = true; + droneSaw = synth({ tone: "e2", type: "sawtooth", duration: "🔁", + attack: 0.4, decay: 0.9, volume: 0.4, pan: 0 }); + dronePwm = synth({ tone: "e3", type: "square", duration: "🔁", + attack: 0.4, decay: 0.9, volume: 0.16, pan: 0 }); + subVoice = synth({ tone: "e1", type: "sine", duration: "🔁", + attack: 0.5, decay: 0.9, volume: 0.5, pan: 0 }); +} + +// Fire one accent stab. Called the instant a new global beat crosses in sim, so +// every onset is UTC-aligned. Also blasts a shockwave ring (§4 allegory). +function fireStab(idx, synth, screen) { + const s = ((idx % STABS.length) + STABS.length) % STABS.length; + const note = STABS[s]; + synth({ tone: note, type: "sawtooth", beats: 0.4, attack: 0.004, + decay: 0.45, volume: 0.42, pan: Math.sin((s / STABS.length) * Math.PI * 2) * 0.5 }); + // A pulse/tick keeps the grid crisp (noise → type, NOT tone). + synth({ type: "noise-white", tone: 6000, beats: 0.1, attack: 0.001, + decay: 0.18, volume: 0.14 }); + // Sub thumps on the downbeats → drives the tunnel CORE GLOW. + if (s % 2 === 0) { + synth({ tone: STABS[s], type: "sine", beats: 1.2, attack: 0.008, + decay: 0.5, volume: 0.4 }); + coreGlow = 1; + } + // ALLEGORY: the stab BLASTS a shockwave ring outward from the vanishing point. + const cx = screen.width / 2, cy = screen.height / 2; + rings.push({ x: cx, y: cy, r: Math.min(screen.width, screen.height) * 0.05, + life: 1, hue: 200 + (notePitch(note) % 12) * 10, thick: 3 }); +} + +// Drive audio + rhythm + the sweep from UTC in sim (NOT beat()) so everything +// aligns across instances. Also decay all the eased visual energies here. +function sim({ sound: { speaker, synth }, screen, clock }) { + speaker?.poll(); // mandatory before reading audio + startDrone(synth); // build the sustained drone on the first sim tick + + // clock.time() can return an *Invalid Date* before UTC sync completes (its + // getTime() is NaN, and it's not null so `??` won't catch it) — guard with + // Date.now() so the sweep + rhythm always run and align once synced. + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); + + // --- THE SWEEP (the star): a UTC-phase-locked rise+fall, 0..1. Absolute from + // epoch → every instance computes the identical sweep at the same wall ms. --- + const sweepPhase = (ms % SWEEP_MS) / SWEEP_MS; // 0..1 across the bar + // A smooth asymmetric rise (fast up) then fall — a satisfying "vrooon". Bias + // toward the last tap's Y (sweepTarget) so taps steer the warp. + const raw = 0.5 - 0.5 * Math.cos(sweepPhase * Math.PI * 2); // 0→1→0 smooth + sweep = Math.min(1, raw * 0.7 + sweepTarget * 0.3 + pump * 0.12); + + // --- Beat grid: fire accent stabs on integer beats --- + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; // 0..1 through the current beat + if (idx !== lastBeat) { + lastBeat = idx; + fireStab(idx, synth, screen); + } + + // --- Steer the sustained drone LIVE from the sweep. The saw pitch rises with + // the sweep (the audible "vrooon"); PWM layer brightens; volume swells at the + // peak. This is the sweep you HEAR that you also SEE as tunnel speed. --- + if (droneSaw) { + // Sweep the saw across ~1.5 octaves of E; pump nudges it up on a rev. + const semis = sweep * 18; // 0..18 semitones above E2 + const sawHz = 82.41 * Math.pow(2, semis / 12); // E2 = 82.41 Hz + droneSaw.update?.({ tone: sawHz, volume: 0.32 + sweep * 0.16, + pan: lastPan * 0.4 }); + dronePwm?.update?.({ tone: sawHz * 2, volume: 0.1 + sweep * 0.14, + pan: -lastPan * 0.4 }); + subVoice?.update?.({ volume: 0.42 + coreGlow * 0.14 }); + } + + // --- Spawn radial streaks at a rate ∝ warp SPEED (sweep + pump). Faster + // sweep → more streaks born per tick → the tunnel visibly RUSHES faster. --- + const speed = 0.5 + sweep * 2.5 + pump * 1.2; // the warp speed + spawnAcc += speed * 2.2; // dense streaks — the tunnel is MADE of them + while (spawnAcc >= 1) { + spawnAcc -= 1; + const ang = Math.random() * Math.PI * 2; + streaks.push({ + ang, + dist: 0.02 + Math.random() * 0.04, // born near the vanishing point + len: 0, + hue: 200 + sweep * 130, // low sweep = warm blue/violet, high = bright cyan/white-hot + life: 1, + }); + } + + // --- Advance streaks OUTWARD at the warp speed (this is the rush you read as + // the sweep). Higher sweep = they accelerate away faster. --- + for (const st of streaks) { + st.dist *= 1 + 0.05 + speed * 0.03; // exponential rush outward (tunnel perspective) + st.len = Math.min(0.25, st.len + 0.01 + speed * 0.004); + st.life -= 0.02 + speed * 0.004; + } + streaks = streaks.filter((st) => st.life > 0 && st.dist < 1.4); + if (streaks.length > 420) streaks = streaks.slice(-420); // safety cap + + // --- Advance shockwave rings + tap bursts --- + for (const r of rings) { + r.r += 5 + r.life * 12 + pump * 4; + r.life -= 0.03; + } + rings = rings.filter((r) => r.life > 0); + + // Decays (per 120 Hz tick). + pump *= 0.965; + coreGlow *= 0.9; +} + +// THE WHOLE PIECE IS A BUTTON (§2): any tap / XY-drag blasts a shockwave ring +// from the tap point + revs the drone. X→pan/hue, Y→pitch/sweep target. +function act({ event: e, sound: { synth }, screen }) { + if (e.is("touch") || e.is("draw")) { + const x = e.x / screen.width; // 0..1 + const y = e.y / screen.height; // 0..1 + // Taps punch, drags feed — accumulate decaying pump (throttles the tunnel). + pump = Math.min(3, pump + (e.is("draw") ? 0.1 : 0.9)); + // Y steers the sweep target (top = high/fast, bottom = low/slow). + sweepTarget = 1 - y; + lastPan = x * 2 - 1; + + // SHOCKWAVE RING blasting outward FROM the tap point (§2 + §4). + rings.push({ x: e.x, y: e.y, r: 4, life: 1.2, + hue: 200 + x * 160, thick: 4 }); + // Seed a burst of streaks radiating from the tap → a visible rev. + for (let i = 0; i < 10; i++) { + streaks.push({ ang: Math.random() * Math.PI * 2, + dist: 0.03 + Math.random() * 0.05, len: 0, hue: 200 + x * 160, life: 1.2 }); + } + + // SONIC BOOST — a REV of the drone. X→pan, Y→pitch/brightness. + const note = ["e", "g", "a", "b", "d"][Math.floor(x * 5)] + (2 + Math.floor((1 - y) * 3)); + synth({ tone: note, type: "sawtooth", beats: 0.6, attack: 0.006, + decay: 0.55, volume: 0.5, pan: x * 2 - 1 }); + synth({ tone: note, type: "square", beats: 0.3, attack: 0.003, + decay: 0.35, volume: 0.22 * (1 - y), pan: x * 2 - 1 }); + } +} + +function paint({ ink, box, line, circle, screen, sound, num, zoom, blur }) { + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + const maxR = Math.max(w, h) * 0.72; // tunnel reaches past the corners + + // --- Live audio reads ----------------------------------------------------- + const bands = sound.speaker?.frequencies?.left || []; + const bandAmp = (name) => bands.find((b) => b.name === name)?.amplitude || 0; + const sub = bandAmp("subBass"); + const amp = sound.speaker?.amplitudes?.left || 0; + + // Warp SPEED (must match sim's read so the picture tracks the sweep). + const speed = 0.5 + sweep * 2.5 + pump * 1.2; + + // --- TRANSFORM-FEEDBACK TUNNEL (§4): zoom the previous frame OUTWARD from the + // vanishing point every frame — faster sweep = stronger zoom = the frame's + // light rushes past you faster. A balanced veil recycles it into an endless + // tunnel without blowing out to white. This is the core allegory: the sweep + // (which you HEAR rising) directly sets the zoom (the speed you SEE). --- + const zoomAmt = 1.03 + sweep * 0.06 + pump * 0.04; // >1 → outward rush + zoom?.(zoomAmt, 0.5, 0.5); + // Deep-space veil — alpha tuned so streaks smear into a legible tunnel but the + // frame keeps decaying (never accumulates to white). Higher alpha = the core + // glow can't smear into a big soft blob under the feedback zoom. + ink(4, 3, 14, 150).box(0, 0, w, h); + + // --- CORE GLOW: the sub = the tunnel's core light at the vanishing point. + // A small hot pinpoint + a tight soft halo — kept small/dim so the STREAKS are + // the star, not a big blob (the feedback zoom would otherwise bloom it huge). --- + const glow = Math.max(coreGlow, sub * 1.4); + const coreR = Math.min(w, h) * (0.012 + glow * 0.05 + sweep * 0.02); + for (let i = 3; i > 0; i--) { + const t = i / 3; + ink(150, 180, 255, 22 * glow + 8).circle(cx, cy, coreR * t * 2.4, true); + } + // The hot pinpoint core. + ink(235, 248, 255, 220).circle(cx, cy, coreR, true); + + // --- RADIAL SPEED-LINES / STREAKS rushing outward (§4). Each streak is a line + // segment from its inner point toward the rim; length + brightness ∝ amp and + // speed. Hue climbs with the sweep (warm→bright). Together they read as a warp + // tunnel accelerating with the drone. --- + for (const st of streaks) { + const d0 = st.dist; + const d1 = Math.min(1.4, st.dist + st.len * (1 + speed * 0.3)); + const x0 = cx + Math.cos(st.ang) * d0 * maxR; + const y0 = cy + Math.sin(st.ang) * d0 * maxR; + const x1 = cx + Math.cos(st.ang) * d1 * maxR; + const y1 = cy + Math.sin(st.ang) * d1 * maxR; + const hue = ((st.hue + sweep * 40) % 360 + 360) % 360; + const [r0, g0, b0] = num.hslToRgb(hue, 95, 58 + sweep * 22 + amp * 15); + // Brightness/thickness ∝ life * amplitude — louder = brighter, longer trails. + const a = 235 * st.life * (0.75 + amp * 0.8 + sweep * 0.4); + const thick = 1.2 + st.dist * (2.2 + speed * 0.6); + ink(r0, g0, b0, Math.min(255, a)).line(x0, y0, x1, y1, thick); + // A brighter leading tip near the outer end for a comet feel. + ink(Math.min(255, r0 + 80), Math.min(255, g0 + 80), 255, + Math.min(255, 220 * st.life)).circle(x1, y1, 1 + st.dist * 2.5, true); + } + + // --- SHOCKWAVE RINGS (§2 + §4): stabs + taps blast expanding rings outward. --- + for (const r of rings) { + const hue = ((r.hue % 360) + 360) % 360; + const [rr, gg, bb] = num.hslToRgb(hue, 95, 62); + ink(rr, gg, bb, 190 * r.life).circle(r.x, r.y, r.r, false, r.thick * r.life + 1); + ink(255, 255, 255, 120 * r.life).circle(r.x, r.y, r.r * 0.6, false, 1); + } + + // --- Bloom on loud peaks so the tunnel feels like it's punching through. --- + if (amp > 0.4 || pump > 1) blur?.(1); +} + +export { boot, sim, act, paint }; diff --git a/system/public/aesthetic.computer/disks/wobbo.mjs b/system/public/aesthetic.computer/disks/wobbo.mjs new file mode 100644 index 0000000000..6a324a726a --- /dev/null +++ b/system/public/aesthetic.computer/disks/wobbo.mjs @@ -0,0 +1,297 @@ +// wobbo, 26.07.12 +// Self-running WOBBLE-BASS OSCILLOSCOPE instrument — you literally SEE the bass. +// A fat detuned wobble bass whose timbre is pumped by an LFO, a punchy grid kick, +// and off-beat stabs. The CENTER of the screen is one big rubbery WAVEFORM line +// driven by the live audio; the wobble LFO visibly stretches/squashes the line's +// amplitude in time with the bass → the membrane bulges as the bass wobbles. +// The KICK punches a shockwave outward across the membrane; STABS flash bright +// spikes on the line; bass energy = the membrane's girth + neon glow. +// +// FOUR PRINCIPLES (marketing/av-reels/button-sync-allegory.md): +// 1. NO resolution() — renders at native res (vector polyline, no buffer). +// 2. WHOLE PIECE IS A BUTTON — tap / XY-drag PUNCHES the membrane at the tap +// point (a bulge + a bass boom, pitch by Y, pan by X); repeated taps +// accumulate decaying `pump` that fattens the wobble + brightens the line. +// 3. UTC-SYNCED RHYTHM — the wobble LFO PHASE + kick + stabs are all derived +// from clock.time() in sim (NOT beat()), indexed by floor(globalBeat) % len, +// so two instances opened anywhere wobble in perfect lock-step. +// 4. STRONG GRAPHIC↔SONIC ALLEGORY — the oscilloscope line IS the score. +// +// BPM ~140, half-time dubstep feel. Loops seamlessly (score seams every 8 steps). + +// --- Score ------------------------------------------------------------------ +const BPM = 140; +const BEAT_MS = 60000 / BPM; // ms per beat — the UTC grid unit +const WOBBLE_RATE = 2; // LFO cycles per beat (the wobble you SEE + HEAR) + +// One looping bar (8 steps). Root notes for the wobble bass; step 0 == the seam. +const BASS_NOTES = ["e1", "e1", "g1", "e1", "a1", "a1", "d1", "b1"]; +// Off-beat stab notes (only fire on certain steps) — bright rubbery accents. +const STAB_NOTES = [null, "e3", null, "b3", null, "g3", null, "d3"]; +const KICK_STEPS = [0, 2, 4, 6]; // punchy four-on-the-grid kick (half-time feel) + +// --- Rhythm state (UTC-driven) --------------------------------------------- +let lastBeat = -1; // last integer global-beat we fired +let step = 0; // current step index (0..7), from UTC +let beatProgress = 0; // 0..1 within the current beat (smooth visuals) +let wobblePhase = 0; // 0..1 LFO phase within a wobble cycle (UTC-derived) +let simTime = 0; // last UTC ms + +// --- Voices ---------------------------------------------------------------- +let bassA = null; // detuned saw voice A (sustained wobble bass) +let bassB = null; // detuned saw voice B +let bassSub = null; // clean sub sine under it + +// --- Allegory state --------------------------------------------------------- +let kickShock = 0; // shockwave punch on the membrane, kicked by kicks, decays +let stabFlash = 0; // bright spike flash, kicked by stabs, decays +let bassBloom = 0; // membrane girth/glow, driven by bass energy, decays +let wobbleAmt = 0; // eased wobble depth for the visual squash + +// --- Button / pump state (§2) ---------------------------------------------- +let pump = 0; // decaying tap energy, 0..~3 → fattens wobble + brightens line +let bulges = []; // tap-spawned membrane bulges { x, y, r, life, hue, punch } + +function boot({ sound, clock }) { + sound.bpm(BPM); // only the internal metronome; the UTC scheduler is boss + clock?.resync?.(); // fetch UTC offset (silent local fallback offline) + sound.room?.set?.({ enabled: true, mix: 0.22, feedback: 0.5 }); // rubbery tail + + // Autostart the sustained wobble-bass stack (detuned saws + clean sub) so the + // reel has sound from frame 0. Their tone/volume are wobbled live in sim. + bassA = sound.synth({ tone: "e1", type: "sawtooth", duration: "🔁", attack: 0.02, decay: 0.9, volume: 0.0 }); + bassB = sound.synth({ tone: "e1", type: "sawtooth", duration: "🔁", attack: 0.02, decay: 0.9, volume: 0.0, pan: 0.1 }); + bassSub = sound.synth({ tone: "e1", type: "sine", duration: "🔁", attack: 0.02, decay: 0.9, volume: 0.0 }); +} + +// Fire one step of the score — called the instant a new global beat crosses in +// sim, so every onset is UTC-aligned and every sonic event spawns its visual. +function fireStep(idx, synth) { + const s = ((idx % BASS_NOTES.length) + BASS_NOTES.length) % BASS_NOTES.length; + step = s; + + // Retarget the sustained wobble bass to this step's root (voice stays alive). + const root = BASS_NOTES[s]; + bassA?.update?.({ tone: root }); + bassB?.update?.({ tone: root }); + bassSub?.update?.({ tone: root }); + + // KICK — a punchy grid thump that PUNCHES the membrane outward (§4). + if (KICK_STEPS.includes(s)) { + synth({ tone: "c1", type: "sine", beats: 0.5, attack: 0.001, decay: 0.28, volume: 0.6 }); + synth({ type: "noise-white", tone: 200, beats: 0.05, attack: 0.001, decay: 0.1, volume: 0.35 }); // click + kickShock = 1; // ALLEGORY: kick = shockwave across the membrane + } + + // STAB — a bright off-beat rubbery accent → a spike flash on the line (§4). + const stab = STAB_NOTES[s]; + if (stab) { + const pan = Math.sin((s / BASS_NOTES.length) * Math.PI * 2) * 0.6; + synth({ tone: stab, type: "square", beats: 0.35, attack: 0.003, decay: 0.4, volume: 0.28, pan }); + synth({ tone: stab, type: "triangle", beats: 0.2, attack: 0.002, decay: 0.3, volume: 0.16, pan: -pan }); + stabFlash = 1; // ALLEGORY: stab = bright spike flash on the line + } + + bassBloom = 1; // ALLEGORY: each step re-energizes the membrane girth/glow +} + +// Drive audio + rhythm + the wobble LFO from UTC in sim (NOT beat()) so onsets +// and the wobble itself align across instances. Also decay eased visual energy. +function sim({ sound: { speaker, synth }, clock, num }) { + speaker?.poll(); // mandatory before reading audio + + // clock.time() can return an *Invalid Date* before UTC sync completes (its + // getTime() is NaN, and it's not null so `??` won't catch it) — guard with + // Date.now() so the rhythm + wobble always run, locking once synced. + let ms = clock?.time?.()?.getTime?.(); + if (!Number.isFinite(ms)) ms = Date.now(); + simTime = ms; + + const globalBeat = ms / BEAT_MS; + const idx = Math.floor(globalBeat); + beatProgress = globalBeat - idx; // 0..1 through the current beat + + // The WOBBLE LFO — deterministic from UTC time so two instances wobble in + // perfect sync. Phase runs WOBBLE_RATE cycles per beat. + wobblePhase = (globalBeat * WOBBLE_RATE) % 1; + const lfo = 0.5 + 0.5 * Math.sin(wobblePhase * Math.PI * 2); // 0..1 wobble + + if (idx !== lastBeat) { + lastBeat = idx; + fireStep(idx, synth); // a new UTC beat crossed → fire its step + } + + // WOBBLE the sustained bass: LFO modulates volume + a slight detune spread so + // you HEAR the filter-ish wobble. Pump fattens it; bloom keeps it present. + const drive = 0.32 + pump * 0.14; + const wob = 0.28 + lfo * 0.72; // never fully silent → rubbery, not gated + const vA = drive * wob; + bassA?.update?.({ volume: vA }); + bassB?.update?.({ volume: vA * 0.9, pan: (lfo - 0.5) * 0.5 }); // detune-pan sway + bassSub?.update?.({ volume: (0.22 + pump * 0.05) * (0.6 + lfo * 0.4) }); + + // Eased wobble depth for the VISUAL squash (so the membrane bulge matches). + wobbleAmt = num.lerp(wobbleAmt, lfo, 0.4); + + // Decays (per 120 Hz tick). + pump *= 0.965; + kickShock *= 0.86; + stabFlash *= 0.8; + bassBloom *= 0.94; + + // Advance / cull tap bulges. + for (const b of bulges) { + b.r += 4 + b.life * 8; + b.life -= 0.04; + } + bulges = bulges.filter((b) => b.life > 0); +} + +// THE WHOLE PIECE IS A BUTTON (§2): any tap / XY-drag PUNCHES the membrane at the +// tap point — a bulge + a bass boom whose pitch is set by Y and pan by X. +function act({ event: e, sound: { synth }, screen }) { + if (e.is("touch") || e.is("draw")) { + const x = e.x / screen.width; // 0..1 + const y = e.y / screen.height; // 0..1 + // Taps punch, drags feed — accumulate decaying pump energy (fattens wobble). + pump = Math.min(3, pump + (e.is("draw") ? 0.1 : 0.9)); + + // A membrane bulge at the tap point, hue by X, punch by tap-vs-drag. + bulges.push({ x: e.x, y: e.y, r: 0, life: 1, hue: x * 360, punch: e.is("draw") ? 0.4 : 1 }); + kickShock = Math.min(1.2, kickShock + (e.is("draw") ? 0.2 : 0.7)); + + // SONIC BOOST — a bass boom: pitch by Y (top = higher), pan by X. + const note = ["e1", "g1", "a1", "b1", "d2"][Math.floor((1 - y) * 5)] || "e1"; + synth({ tone: note, type: "sawtooth", beats: 0.6, attack: 0.005, decay: 0.5, volume: 0.5, pan: x * 2 - 1 }); + synth({ tone: note, type: "sine", beats: 0.5, attack: 0.002, decay: 0.4, volume: 0.35, pan: x * 2 - 1 }); + // A bright rubbery tip an octave up, brighter higher on the screen. + synth({ tone: ["e3", "g3", "a3", "b3", "d4"][Math.floor(x * 5)] || "e3", + type: "square", beats: 0.25, attack: 0.002, decay: 0.3, volume: 0.2 * (1 - y * 0.6), pan: x * 2 - 1 }); + stabFlash = 1; + } +} + +function paint({ ink, box, line, circle, screen, sound, num, help, paintCount }) { + const { width: w, height: h } = screen; + const cx = w / 2, cy = h / 2; + + // --- Live audio reads ----------------------------------------------------- + const bands = sound.speaker?.frequencies?.left || []; + const bandAmp = (name) => bands.find((b) => b.name === name)?.amplitude || 0; + const bass = bandAmp("subBass"); + const lowMid = bandAmp("lowMid"); + const air = bandAmp("air"); + const amp = sound.speaker?.amplitudes?.left || 0; + const wf = sound.speaker?.waveforms?.left || []; + + // Combined "energy" — bass + wobble LFO + tap pump. Drives girth + glow. + const energy = Math.min(2, bass * 1.4 + amp * 0.6 + pump * 0.5 + bassBloom * 0.4); + + // --- Trail veil (near-black) → glowing neon trails on the line ------------ + // Slightly translucent so the membrane leaves a rubbery ghost behind it. + ink(4, 2, 10, 150).box(0, 0, w, h); + + // --- The WOBBLE OSCILLOSCOPE MEMBRANE (the graphic score, §4) ------------- + // A thick glowing polyline across the full width. Its base amplitude is the + // live waveform; the wobble LFO (wobbleAmt) stretches/squashes that amplitude + // in time → you SEE the bass wobble as the membrane bulging. The kick punches + // a travelling shockwave; the tap bulges deform it locally. + const N = 128; // points across the membrane + const half = h * 0.32; // vertical reach of the line + // Wobble envelope: 0.35..1.0, pumped by taps → the visible squash. + const wobEnv = 0.35 + wobbleAmt * 0.65 * (1 + pump * 0.3); + // Girth/glow of the line, driven by bass energy. + const girth = 2 + energy * 6 + bassBloom * 4; + + // Precompute waveform samples fit to the line width. + const scope = wf.length ? help.resampleArray(wf, N) : null; + + // Build the membrane points. + const pts = []; + const tphase = (paintCount % 240) / 240; // wrapping phase (seamless drift) + for (let i = 0; i < N; i++) { + const fx = i / (N - 1); // 0..1 across + const x = fx * w; + + // Base: live waveform (or a gentle idle sine so it's never dead). + let v = scope ? scope[i] : Math.sin(fx * Math.PI * 6 + tphase * Math.PI * 2) * 0.2; + + // WOBBLE: a slow spatial sine tied to the LFO phase bulges the whole line, + // squashing/stretching its amplitude in time with the wobble you HEAR. + const spatial = Math.sin(fx * Math.PI * 2 + wobblePhase * Math.PI * 2); + v *= wobEnv; // amplitude breathes with the wobble + v += spatial * 0.14 * wobbleAmt * (0.6 + energy * 0.7); // rubbery membrane sway + + // KICK shockwave: a gaussian pulse travelling outward from center punches + // the membrane at kick time. + if (kickShock > 0.01) { + const front = kickShock; // 1 → center, 0 → edges + const d = Math.abs(fx - 0.5) * 2; // 0 center .. 1 edge + const near = Math.exp(-((d - (1 - front)) ** 2) / 0.02); + v += near * kickShock * 0.55; // outward punch travelling from center + } + + // TAP bulges: local gaussian deformation at each active bulge's x. + for (const b of bulges) { + const bfx = b.x / w; + const d = Math.abs(fx - bfx); + const g = Math.exp(-(d * d) / 0.004); + v += g * b.life * b.punch * 0.7 * ((b.y < cy) ? -1 : 1); + } + + const y = cy + v * half; + pts.push([x, y]); + } + + // Draw the membrane: a fat neon-magenta core with an acid-green glow halo. + // Girth + brightness scale with bass energy so louder = fatter + brighter. + const glowA = Math.min(220, 60 + energy * 120); + // Halo pass (acid green), thick, translucent → the glow. + for (let g = 0; g < N - 1; g++) { + ink(80, 255, 120, glowA * 0.35).line(pts[g][0], pts[g][1], pts[g + 1][0], pts[g + 1][1], girth + 5); + } + // Core pass (neon magenta / hot pink), tight + bright. + const [cr, cg, cb] = num.hslToRgb((310 + air * 40 + wobbleAmt * 20) % 360, 90, 62); + for (let g = 0; g < N - 1; g++) { + ink(cr, cg, cb, 235).line(pts[g][0], pts[g][1], pts[g + 1][0], pts[g + 1][1], Math.max(1, girth * 0.5)); + } + // Bright white crest on stabs → the spike flash. + if (stabFlash > 0.05) { + for (let g = 0; g < N - 1; g++) { + ink(255, 255, 255, 200 * stabFlash).line(pts[g][0], pts[g][1], pts[g + 1][0], pts[g + 1][1], 1); + } + } + + // --- Bass girth bloom behind the line (the membrane's "body", §4) --------- + if (bassBloom > 0.02 || bass > 0.04) { + const bloom = Math.max(bassBloom, bass * 1.3); + const bH = half * (0.4 + bloom * 0.6); + ink(120, 40, 200, 22 * bloom).box(cx, cy, w, bH * 2, "*center"); + } + + // --- Kick shockwave rings from center (§4) -------------------------------- + if (kickShock > 0.03) { + const kr = (1 - kickShock) * Math.max(w, h) * 0.7; + ink(80, 255, 160, 160 * kickShock).circle(cx, cy, kr, false, 2 + kickShock * 4); + } + + // --- Tap bulge rings at their punch points -------------------------------- + for (const b of bulges) { + const [br, bg, bb] = num.hslToRgb(b.hue, 90, 60); + ink(br, bg, bb, 180 * b.life).circle(b.x, b.y, b.r, false, 1 + b.life * 3); + } + + // --- UTC wobble phase marker: a subtle pumping side-glow that breathes with + // the LFO so the whole frame reads as "wobbling" even on a still glance. ----- + const sideA = 24 + wobbleAmt * 40; + ink(80, 255, 120, sideA).box(0, cy, 6, half * 2 * wobEnv, "*center"); + ink(255, 40, 180, sideA).box(w - 3, cy, 6, half * 2 * wobEnv, "*center"); +} + +function leave() { + bassA?.kill?.(0.3); + bassB?.kill?.(0.3); + bassSub?.kill?.(0.3); +} + +export { boot, sim, act, paint, leave };