Hits a low gong for each of today's top earthquakes. Magnitude picks the pitch (bigger = lower), depth shapes the dark, total daily count controls how often gongs land. Today's seismic activity, sounding.
tremor-previewimport type { ElementaryPatch } from '@/lib/engine/types'
/**
* Earthquake-driven punctuated drone. Pulls today's USGS quake feed at
* audio start. Each recent quake's MAGNITUDE picks the gong frequency
* (bigger quake = lower, longer note); average DEPTH controls reverb
* darkness; total COUNT in last 24h sets density of strikes.
*
* Same snapshot for every listener in the hour.
*/
const tremor: ElementaryPatch = {
id: 'tremor',
title: 'Tremor',
author: 'stuart',
year: 2026,
tags: ['ambient', 'usgs', 'data'],
description:
'Hits a low gong for each of today\'s top earthquakes. Magnitude picks the pitch (bigger = lower), depth shapes the dark, total daily count controls how often gongs land. Today\'s seismic activity, sounding.',
engine: 'elementary',
worldFields: ['earthquakes'],
audio: ({ el, rng, schedule, render, world }) => {
const quakes = world?.earthquakes
const recentMags = quakes?.recentMags ?? [2, 3, 2.5, 4, 1.8, 3.2, 2, 5, 2.5, 3]
const count24h = quakes?.count24h ?? 100
const avgDepth = quakes?.averageDepthKm ?? 30
// Magnitude → frequency (lowest gong for biggest mag)
// Mag 1 → ~110 Hz, Mag 6 → ~22 Hz (fundamental, but we'll keep audible via partials)
const magToFreq = (m: number) => 110 * Math.pow(2, -(m - 1) / 2)
// Density: more daily quakes = faster strike interval
const interval = Math.max(3, 12 - count24h / 80) // ~3-12s
// Depth → reverb pole (deep quakes = darker, longer)
const reverbPole = 1.5 + Math.min(avgDepth / 30, 4) // 1.5-5.5s smooth
// Drone underneath — low E-ish
const droneRoot = 41.2 // E1
const droneFifth = 61.7 // B1 (5th)
schedule(interval, (tick) => {
// Pick a mag from the recent feed for this tick (cycles through deterministically)
const mag = recentMags[tick % recentMags.length] ?? 3
const gongFreq = magToFreq(mag)
// Drone (continuous): sub root + fifth, slow LFO on amp
const lfo = el.cycle(el.const({ key: 'droneLfo', value: 0.05 }))
const droneAmp = el.mul(el.add(0.7, el.mul(lfo, 0.2)), 0.5)
const drone = el.mul(
el.add(
el.mul(el.cycle(el.const({ key: 'dr1', value: droneRoot })), 0.4),
el.mul(el.cycle(el.const({ key: 'dr2', value: droneFifth })), 0.25),
el.mul(el.saw(el.const({ key: 'dr3', value: droneRoot * 2 })), 0.08),
),
droneAmp,
)
// Gong: FM-synthesized, decays over 6+ seconds, partials inharmonic
const gongGate = el.metro({ name: 'gongM', interval: interval * 1000 })
const gongEnv = el.adsr(0.005, Math.min(8, 1.5 + mag), 0, 0, gongGate)
const modA = el.mul(
el.cycle(el.const({ key: 'gMa', value: gongFreq * 1.41 })),
el.mul(gongEnv, 80),
)
const modB = el.mul(
el.cycle(el.const({ key: 'gMb', value: gongFreq * 2.71 })),
el.mul(gongEnv, 60),
)
const gongVoice = el.add(
el.cycle(el.add(el.const({ key: 'gC1', value: gongFreq }), modA)),
el.mul(
el.cycle(el.add(el.const({ key: 'gC2', value: gongFreq * 2 }), modB)),
0.5,
),
el.mul(
el.cycle(el.const({ key: 'gC3', value: gongFreq * 3.6 })),
0.25,
),
)
// Gong amplitude scales with mag — bigger quakes hit louder
const gongAmp = Math.min(1, 0.4 + mag / 6) * 0.35
const gong = el.mul(
gongVoice,
el.mul(gongEnv, el.const({ key: 'gAmp', value: gongAmp })),
)
// Dark texture from filtered noise — wider when many quakes
const noiseAmp = 0.012 + Math.min(count24h, 500) / 100000
const texture = el.mul(
el.bandpass(
el.const({ key: 'texFc', value: 220 }),
1.2,
el.pinknoise(),
),
el.const({ key: 'texAmp', value: noiseAmp }),
)
// Mix → very smoothed (depth-driven darkness) → soft saturation
const mix = el.add(drone, gong, texture)
const wet = el.smooth(el.tau2pole(reverbPole), mix)
const filtered = el.lowpass(
el.const({ key: 'cut', value: 600 }),
0.6,
wet,
)
const out = el.mul(el.tanh(el.mul(filtered, 0.55)), 0.2)
render(out, out)
void mag // silence unused-warning if rng usage gets refactored
})
},
visual: ({ ctx, t, width, height, mouseX, mouseY, mouseActive, getWaveform, getRms }) => {
// Audio-reactive seismic trace. The waveform IS the trace — the
// music's actual sound becomes the earthquake graph.
ctx.fillStyle = 'rgba(12, 12, 12, 0.18)'
ctx.fillRect(0, 0, width, height)
const horizon = height * 0.66
ctx.strokeStyle = 'rgba(232, 230, 225, 0.35)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, horizon)
ctx.lineTo(width, horizon)
ctx.stroke()
const wave = getWaveform()
const rms = getRms()
// Trace: actual waveform when playing, slow oscillation when silent
ctx.strokeStyle = `rgba(239, 68, 68, ${0.55 + Math.min(0.4, rms * 5)})`
ctx.lineWidth = 1.4 + Math.min(2, rms * 12)
ctx.beginPath()
if (wave && wave.length > 0) {
const amp = Math.min(height * 0.4, height * 0.25 + rms * height)
for (let x = 0; x < width; x += 1) {
const idx = Math.floor((x / width) * wave.length)
const v = wave[idx] || 0
const y = horizon + v * amp
if (x === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
} else {
// Idle slow squiggle
for (let x = 0; x < width; x += 2) {
const slow = Math.sin(t * 0.6 + x * 0.01) * 6
const tiny = Math.sin(t * 4 + x * 0.05) * 2
const y = horizon + slow + tiny
if (x === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
}
ctx.stroke()
// Cursor heat
if (mouseActive) {
const cx = mouseX * width
const cy = mouseY * height
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 60)
grad.addColorStop(0, 'rgba(239, 68, 68, 0.3)')
grad.addColorStop(1, 'rgba(239, 68, 68, 0)')
ctx.fillStyle = grad
ctx.fillRect(cx - 60, cy - 60, 120, 120)
}
},
}
export default tremor