Every data feed at once: weather tunes the mode, ISS picks the root chord, earthquakes punctuate with low gongs, geomagnetic activity adds the shimmer. The whole snapshot of the moment becoming sound.
earth-previewimport type { ElementaryPatch } from '@/lib/engine/types'
import { MODES, scaleNotes } from '@/lib/generative'
/**
* Every world feed feeding music at once. The whole snapshot — weather,
* seismic activity, ISS position, geomagnetic state — becomes a single
* piece, four voices breathing together:
*
* weather.tempF → mode (cold = phrygian, warm = dorian, hot = lydian)
* weather.windMph → pad amplitude swell rate
* iss.lat → root pitch (south = low, north = high)
* iss.lon → which intervals form the chord
* earthquakes → low gong each cycle, magnitudes drive pitch
* spaceWeather.kp → high shimmer chaos
* spaceWeather.sw → LFO speed for the whole field
*
* Same snapshot for every listener in the same UTC hour.
*/
const earth: ElementaryPatch = {
id: 'earth',
title: 'Earth',
author: 'stuart',
year: 2026,
tags: ['ambient', 'data', 'composite'],
description:
'Every data feed at once: weather tunes the mode, ISS picks the root chord, earthquakes punctuate with low gongs, geomagnetic activity adds the shimmer. The whole snapshot of the moment becoming sound.',
engine: 'elementary',
worldFields: ['weather', 'earthquakes', 'iss', 'spaceWeather'],
audio: ({ el, rng, schedule, render, world }) => {
const tempF = world?.weather?.tempF ?? 55
const windMph = world?.weather?.windMph ?? 5
const lat = world?.iss?.lat ?? 0
const lon = world?.iss?.lon ?? 0
const recentMags = world?.earthquakes?.recentMags ?? [3, 4, 2.5, 5]
const kp = world?.spaceWeather?.kpIndex ?? 2
const sw = world?.spaceWeather?.solarWindSpeedKmS ?? 420
// Temperature → mode
let mode: readonly number[]
if (tempF < 40) mode = MODES.phrygian
else if (tempF < 60) mode = MODES.minor
else if (tempF < 75) mode = MODES.dorian
else mode = MODES.lydian
// ISS latitude → root frequency
const rootHz = 220 * Math.pow(2, lat / 60)
const palette = scaleNotes(rootHz, mode, 2)
// ISS longitude → chord shape (which scale degrees to use)
const lonNorm = (lon + 180) / 360
const shapes = [
[0, 2, 4, 6], // 1-3-5-7
[0, 2, 4, 5], // 1-3-5-6
[0, 2, 5, 7], // 1-3-6-9
[0, 4, 5, 7], // 1-5-6-9
[0, 1, 4, 6], // 1-b3-5-7 (mood-shift)
]
const shape = shapes[Math.floor(lonNorm * shapes.length) % shapes.length]
const chord = shape.map((s) => palette[s % palette.length])
// Solar wind → LFO speed (200 km/s = 0.05 Hz, 800 = 0.4 Hz)
const lfoHz = 0.05 + ((sw - 200) / 600) * 0.35
// Wind speed → pad swell rate (faster wind = faster swell)
const padSwellHz = 0.05 + windMph / 300
const interval = 6
schedule(interval, (tick) => {
// ── Pad (weather + ISS): chord at ISS pitch in weather mode ──
const padVoices = chord.map((f, i) => {
const a = el.cycle(el.const({ key: `e-${i}`, value: f }))
const b = el.cycle(el.const({ key: `eb-${i}`, value: f * 1.0035 }))
return el.mul(el.add(a, el.mul(b, 0.6)), 0.07)
})
const padRaw = el.add(...padVoices)
const padLfo = el.cycle(el.const({ key: 'padLfo', value: padSwellHz }))
const padAmp = el.add(0.7, el.mul(padLfo, 0.25))
const pad = el.mul(padRaw, padAmp)
// ── Sub (ISS root one octave below) ──
const sub = el.mul(
el.cycle(el.const({ key: 'sub', value: rootHz / 2 })),
0.22,
)
// ── Quake gong (USGS) — pitch from a recent magnitude ──
const mag = recentMags[tick % recentMags.length] ?? 3
const gongFreq = 110 * Math.pow(2, -(mag - 1) / 2)
const gongGate = el.metro({
name: 'earthGong',
interval: interval * 1000,
})
const gongEnv = el.adsr(0.005, Math.min(6, 1.5 + mag), 0, 0, gongGate)
const gongMod = el.mul(
el.cycle(el.const({ key: 'gongMod', value: gongFreq * 1.41 })),
el.mul(gongEnv, 60),
)
const gong = el.mul(
el.cycle(el.add(el.const({ key: 'gongCar', value: gongFreq }), gongMod)),
el.mul(
gongEnv,
el.const({ key: 'gongAmp', value: Math.min(0.3, 0.1 + mag / 20) }),
),
)
// ── ISS lead — high sustained note tracking latitude ──
const leadFreq = palette[5] // a high scale tone
const leadLfo = el.cycle(el.const({ key: 'leadLfo', value: lfoHz * 0.7 }))
const leadAmp = el.mul(el.add(0.5, el.mul(leadLfo, 0.3)), 0.05)
const lead = el.mul(
el.cycle(el.const({ key: 'leadF', value: leadFreq })),
leadAmp,
)
// ── Geomagnetic shimmer — noise + sine partial scaled by Kp ──
const chaos = Math.min(1, kp / 9)
const shimmer = el.mul(
el.cycle(el.const({ key: 'shim', value: leadFreq * 4 })),
el.const({ key: 'shimAmp', value: 0.02 + chaos * 0.06 }),
)
const stormNoise = el.mul(
el.bandpass(
el.const({ key: 'stormFc', value: leadFreq * 2 }),
1.2,
el.pinknoise(),
),
el.const({ key: 'stormAmp', value: 0.005 + chaos * 0.04 }),
)
// Mix → smoothed master → soft saturation
const mix = el.add(pad, sub, gong, lead, shimmer, stormNoise)
const wet = el.smooth(el.tau2pole(0.6), mix)
const out = el.mul(el.tanh(el.mul(wet, 0.55)), 0.18)
render(out, out)
})
},
visual: ({ ctx, t, width, height, mouseX, mouseY, mouseActive, getRms }) => {
// A horizon line with three bands of motion: stratospheric (top),
// surface (middle), seismic (bottom). Each band represents a different
// data source.
ctx.fillStyle = 'rgba(12, 12, 12, 0.10)'
ctx.fillRect(0, 0, width, height)
const rms = getRms()
const cx = width / 2
const cy = height / 2
// Horizon line
ctx.strokeStyle = 'rgba(232, 230, 225, 0.25)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, cy)
ctx.lineTo(width, cy)
ctx.stroke()
// Sky band (top half) — slow drifting curves like aurora
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = `rgba(150, 200, 230, ${0.15 + i * 0.06})`
ctx.lineWidth = 1
ctx.beginPath()
const yBase = cy * (0.3 + i * 0.2)
for (let x = 0; x <= width; x += 4) {
const wave = Math.sin(t * 0.3 + (x / width) * 4 + i) * 18
const y = yBase + wave
if (x === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
// Surface band — ISS-like dot orbiting
const orbAngle = t * 0.15
const orbX = cx + Math.cos(orbAngle) * width * 0.35
const orbY = cy + Math.sin(orbAngle) * 30
ctx.fillStyle = 'rgba(232, 230, 225, 0.7)'
ctx.beginPath()
ctx.arc(orbX, orbY, 4, 0, Math.PI * 2)
ctx.fill()
// Seismic band (bottom half) — audio-reactive trace
ctx.strokeStyle = `rgba(239, 68, 68, ${0.5 + Math.min(0.4, rms * 5)})`
ctx.lineWidth = 1.2 + Math.min(2, rms * 10)
ctx.beginPath()
for (let x = 0; x <= width; x += 2) {
const slow = Math.sin(t * 0.5 + (x / width) * 6) * (5 + rms * 30)
const y = cy + height * 0.3 + slow
if (x === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
if (mouseActive) {
ctx.fillStyle = 'rgba(232, 230, 225, 0.06)'
ctx.beginPath()
ctx.arc(mouseX * width, mouseY * height, 60, 0, Math.PI * 2)
ctx.fill()
}
},
}
export default earth