leach.studio

Aurora

ambient · space-weather · data
↓ source · seed

about this piece

Listens to today's geomagnetic activity. Quiet K-index = calm 5-tone shimmer. Storm K-index (5+) = the chord starts pulling itself apart. Solar wind speed sets the LFO rate. Real space weather, sounding.

seed
aurora-preview
by
stuart
year
2026
tags
ambient · space-weather · data
permalink
/a/aurora/aurora-preview

source

import type { ElementaryPatch } from '@/lib/engine/types'

/**
 * Geomagnetically-driven shimmer. NOAA SWPC supplies:
 *
 *   - Kp index (0..9)            → "chaos" parameter
 *   - solar wind speed (km/s)    → LFO speed (slow at 300, fast at 800)
 *
 * On a calm day (Kp 0-2) the patch is a steady 5-tone chord with light
 * shimmer. On a storm day (Kp 5+) the chord detunes wildly, the noise
 * thickens, and pitches drift.
 */
const aurora: ElementaryPatch = {
  id: 'aurora',
  title: 'Aurora',
  author: 'stuart',
  year: 2026,
  tags: ['ambient', 'space-weather', 'data'],
  description:
    "Listens to today's geomagnetic activity. Quiet K-index = calm 5-tone shimmer. Storm K-index (5+) = the chord starts pulling itself apart. Solar wind speed sets the LFO rate. Real space weather, sounding.",
  engine: 'elementary',
  worldFields: ['spaceWeather'],

  audio: ({ el, rng, schedule, render, world }) => {
    const kp = world?.spaceWeather?.kpIndex ?? 2
    const sw = world?.spaceWeather?.solarWindSpeedKmS ?? 420

    // Chaos: 0 (calm) to 1 (storm)
    const chaos = Math.min(1, kp / 9)

    // LFO speed scales with solar wind
    const lfoHz = 0.05 + ((sw - 200) / 600) * 0.25 // 0.05 to 0.3

    // Base chord in C lydian — bright, ethereal
    const baseChord = [261.63, 329.63, 369.99, 493.88, 587.33] // C E F# B D

    const interval = 5

    schedule(interval, (tick) => {
      // At higher chaos, each voice gets randomized detuning each tick
      const voices = baseChord.map((f, i) => {
        const detuneCents = rng.range(-50 * chaos, 50 * chaos, tick, i + 1)
        const driftedF = f * Math.pow(2, detuneCents / 1200)
        const sine = el.cycle(el.const({ key: `aur-${i}`, value: driftedF }))
        const tri = el.mul(
          el.triangle(el.const({ key: `aurT-${i}`, value: driftedF * 2 })),
          0.25,
        )
        return el.mul(el.add(sine, tri), 0.08)
      })
      const chord = el.add(...voices)

      // Two LFOs — one for filter, one for amp; speed scales with solar wind
      const lfoA = el.cycle(el.const({ key: 'lfoA', value: lfoHz }))
      const lfoB = el.cycle(el.const({ key: 'lfoB', value: lfoHz * 1.3 }))
      const cutoff = el.add(1500, el.mul(lfoA, 800))
      const ampMod = el.add(0.85, el.mul(lfoB, 0.12))

      // Storm noise — bandpass pink, thicker at high Kp
      const stormNoise = el.mul(
        el.bandpass(
          el.const({ key: 'stormFc', value: 800 }),
          1.2,
          el.pinknoise(),
        ),
        el.const({ key: 'stormAmp', value: 0.005 + chaos * 0.04 }),
      )

      // Shimmering top partial that flickers at LFO rate
      const shimmer = el.mul(
        el.cycle(el.const({ key: 'shimF', value: baseChord[3] * 4 })),
        el.add(0.05, el.mul(lfoA, 0.05)),
      )

      // Filtered + LFO'd amp
      const filtered = el.lowpass(cutoff, 0.6, el.add(chord, stormNoise, shimmer))
      const swelling = el.mul(filtered, ampMod)
      const out = el.mul(el.tanh(el.mul(swelling, 0.55)), 0.18)
      render(out, out)
    })
  },

  visual: ({ ctx, rng, t, width, height, mouseX, mouseY, mouseActive, getRms }) => {
    // Vertical aurora curtain — wavy ribbons of light. Audio amplitude
    // brightens the curtain. Cursor adds a subtle local heat.
    ctx.fillStyle = 'rgba(12, 12, 12, 0.10)'
    ctx.fillRect(0, 0, width, height)

    const ribbonCount = 5
    const rms = getRms()
    const intensity = 0.4 + Math.min(rms * 5, 0.6)

    for (let r = 0; r < ribbonCount; r++) {
      const baseX = (width / (ribbonCount + 1)) * (r + 1)
      const phaseShift = r * 1.3
      const colorShift = (r * 30) % 100
      const grad = ctx.createLinearGradient(baseX - 60, 0, baseX + 60, 0)
      const a = intensity * (0.6 + (r / ribbonCount) * 0.4)
      grad.addColorStop(0, `rgba(150, 200, 230, 0)`)
      grad.addColorStop(0.5, `rgba(${100 + colorShift}, 220, 200, ${a * 0.5})`)
      grad.addColorStop(1, `rgba(150, 200, 230, 0)`)
      ctx.fillStyle = grad
      ctx.beginPath()
      const segments = 30
      // Draw the curving ribbon as a polygon
      for (let s = 0; s <= segments; s++) {
        const y = (s / segments) * height
        const wave = Math.sin(t * 0.4 + phaseShift + (s / segments) * 4) * 40
        const x = baseX + wave
        if (s === 0) ctx.moveTo(x - 30, y)
        else ctx.lineTo(x - 30, y)
      }
      for (let s = segments; s >= 0; s--) {
        const y = (s / segments) * height
        const wave = Math.sin(t * 0.4 + phaseShift + (s / segments) * 4) * 40
        const x = baseX + wave
        ctx.lineTo(x + 30, y)
      }
      ctx.closePath()
      ctx.fill()
    }

    if (mouseActive) {
      const mx = mouseX * width
      const my = mouseY * height
      const grad = ctx.createRadialGradient(mx, my, 0, mx, my, 100)
      grad.addColorStop(0, 'rgba(232, 230, 225, 0.08)')
      grad.addColorStop(1, 'rgba(232, 230, 225, 0)')
      ctx.fillStyle = grad
      ctx.fillRect(mx - 100, my - 100, 200, 200)
    }

    // Random "flares" — quick bright dots that fade
    const tickFlare = Math.floor(t * 2)
    if (rng.at(tickFlare, 99) > 0.7) {
      const fx = rng.range(0, width, tickFlare, 100)
      const fy = rng.range(0, height, tickFlare, 101)
      const fr = rng.range(2, 5, tickFlare, 102)
      ctx.fillStyle = 'rgba(232, 230, 225, 0.6)'
      ctx.beginPath()
      ctx.arc(fx, fy, fr, 0, Math.PI * 2)
      ctx.fill()
    }
  },
}

export default aurora
← leach.studio