leach.studio

Tremor

ambient · usgs · data
↓ source · seed

about this piece

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.

seed
tremor-preview
by
stuart
year
2026
tags
ambient · usgs · data
permalink
/a/tremor/tremor-preview

source

import 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
← leach.studio