leach.studio

Atmosphere

ambient · weather · noaa
↓ source · seed

about this piece

Listens to the current NOAA observation for NYC and tunes itself to it. Temperature picks the mode, wind picks the density, humidity wets the reverb, cloud cover lowers the brightness. Same snapshot for every listener in the hour.

seed
atmosphere-preview
by
stuart
year
2026
tags
ambient · weather · noaa
permalink
/a/atmosphere/atmosphere-preview

source

import type { ElementaryPatch } from '@/lib/engine/types'
import { chance, MODES, scaleNotes } from '@/lib/generative'

/**
 * Weather-driven Eno-ish ambient. The current NYC observation pulled
 * from NOAA shapes the piece at start time:
 *
 *   - temperature  → key (cold = phrygian; cool = minor; warm = dorian; hot = major)
 *   - wind speed   → event density (calm = sparse; gusty = dense)
 *   - humidity     → reverb tail length proxy (low = dry; high = wet)
 *   - cloud cover  → overall lowpass cutoff (clear = bright; overcast = dim)
 *
 * The same NOAA snapshot is served to every listener in the same UTC hour,
 * so the synced-clock contract holds.
 */
const atmosphere: ElementaryPatch = {
  id: 'atmosphere',
  title: 'Atmosphere',
  author: 'stuart',
  year: 2026,
  tags: ['ambient', 'weather', 'noaa'],
  description:
    'Listens to the current NOAA observation for NYC and tunes itself to it. Temperature picks the mode, wind picks the density, humidity wets the reverb, cloud cover lowers the brightness. Same snapshot for every listener in the hour.',
  engine: 'elementary',
  worldFields: ['weather'],

  audio: ({ el, rng, schedule, render, world }) => {
    // Read weather, with sensible defaults if the fetch failed
    const tempF = world?.weather?.tempF ?? 55
    const windMph = world?.weather?.windMph ?? 6
    const humidity = world?.weather?.humidity ?? 50
    const conditions = (world?.weather?.conditions ?? '').toLowerCase()

    // Temperature → mode selection
    // < 32°F: phrygian (icy minor with b2)
    // < 50°F: minor
    // < 65°F: dorian (slightly warm minor)
    // < 80°F: pentatonic major
    // ≥ 80°F: lydian (bright, raised 4th)
    let mode: readonly number[]
    let modeName: string
    if (tempF < 32) { mode = MODES.phrygian; modeName = 'phrygian' }
    else if (tempF < 50) { mode = MODES.minor; modeName = 'minor' }
    else if (tempF < 65) { mode = MODES.dorian; modeName = 'dorian' }
    else if (tempF < 80) { mode = MODES.pentatonicMajor; modeName = 'pentatonic-major' }
    else { mode = MODES.lydian; modeName = 'lydian' }

    // Root note shifts subtly with temperature too — colder = lower
    const rootHz = 220 * Math.pow(2, (tempF - 55) / 240) // ±~quarter-octave over 60°F range
    const palette = scaleNotes(rootHz, mode, 2)

    // Wind → event density (probability per tick), faster interval
    const density = Math.min(0.9, 0.2 + windMph / 30) // 0.2 to 0.9
    const interval = Math.max(2, 7 - windMph / 6) // calm: 7s, breezy: 5s, gusty: 2s

    // Humidity → reverb wetness on master (longer smooth = wetter feel)
    const reverbSmooth = 1 + humidity / 30 // 1s to ~4s pole

    // Cloud cover → lowpass cutoff
    const cloudy = /(cloud|overcast|fog|rain|snow|drizzle|mist)/i.test(conditions)
    const baseCutoff = cloudy ? 1100 : 2200

    schedule(interval, (tick) => {
      // Pick chord tones from the scale
      const root = palette[0]
      const third = palette[2]
      const fifth = palette[4]
      const seventh = palette[6 % palette.length]
      const ninth = palette[8 % palette.length]
      const chord = [root, third, fifth, seventh, ninth]

      // Each tick: regenerate detuning and amplitude profile
      const detune = Math.pow(2, rng.range(-7, 7, tick, 0) / 1200)
      const padAmp = rng.range(0.6, 1.0, tick, 1)

      // Pad voices: sine + slightly detuned sine
      const padVoices = chord.map((f, i) => {
        const a = el.cycle(el.const({ key: `pa-${i}`, value: f }))
        const b = el.cycle(el.const({ key: `pb-${i}`, value: f * detune }))
        return el.mul(el.add(a, el.mul(b, 0.55)), 0.075)
      })
      const pad = el.mul(el.add(...padVoices), padAmp)

      // Sub continuously present
      const sub = el.mul(
        el.cycle(el.const({ key: 'sub', value: root / 2 })),
        0.18,
      )

      // Wind-driven plinks: probability gate per tick
      const plinkOn = chance(density, rng, tick, 2)
      const plinkNote = rng.pick(palette, tick, 3)
      const plinkGate = el.metro({ name: 'plinkM', interval: interval * 1000 })
      const plinkEnv = el.adsr(0.003, 0.5, 0, 0, plinkGate)
      const plink = el.mul(
        el.triangle(el.const({ key: 'plinkF', value: plinkNote * 2 })),
        el.mul(plinkEnv, el.const({ key: 'plinkAmp', value: plinkOn ? 0.08 : 0 })),
      )

      // Air noise — tied to wind speed
      const airAmp = 0.005 + windMph / 1500
      const air = el.mul(
        el.bandpass(
          el.const({ key: 'airFc', value: root * 4 }),
          1.5,
          el.pinknoise(),
        ),
        el.const({ key: 'airAmp', value: airAmp }),
      )

      // Mix → smoothed lowpass (cloud-cover-driven) → wet smooth (humidity) → master
      const mix = el.add(pad, sub, plink, air)
      const cutSm = el.smooth(
        el.tau2pole(2),
        el.const({ key: 'cut', value: baseCutoff }),
      )
      const filtered = el.lowpass(cutSm, 0.6, mix)
      const wet = el.smooth(
        el.tau2pole(reverbSmooth),
        filtered,
      )
      const out = el.mul(el.tanh(el.mul(wet, 0.55)), 0.18)

      // Tag the modeName so it shows up in the source comment if anyone reads it
      void modeName
      render(out, out)
    })
  },

  visual: ({ ctx, t, width, height, mouseX, mouseY, mouseActive }) => {
    // Slow horizontal bands of light — like sky strata
    ctx.fillStyle = 'rgba(12, 12, 12, 0.08)'
    ctx.fillRect(0, 0, width, height)

    const bandCount = 7
    for (let i = 0; i < bandCount; i++) {
      const baseY = (height / bandCount) * (i + 0.5)
      const wave = Math.sin(t * (0.05 + i * 0.02) + i) * 30
      const grad = ctx.createLinearGradient(0, baseY - 40, 0, baseY + 40)
      const a = 0.05 + (i / bandCount) * 0.12
      grad.addColorStop(0, `rgba(232, 230, 225, 0)`)
      grad.addColorStop(0.5, `rgba(232, 230, 225, ${a})`)
      grad.addColorStop(1, `rgba(232, 230, 225, 0)`)
      ctx.fillStyle = grad
      ctx.fillRect(0, baseY + wave - 40, width, 80)
    }

    // Cursor leaves a faint ember
    if (mouseActive) {
      const cx = mouseX * width
      const cy = mouseY * height
      const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80)
      grad.addColorStop(0, 'rgba(239, 68, 68, 0.25)')
      grad.addColorStop(1, 'rgba(239, 68, 68, 0)')
      ctx.fillStyle = grad
      ctx.fillRect(cx - 80, cy - 80, 160, 160)
    }
  },
}

export default atmosphere
← leach.studio