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.
atmosphere-previewimport 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