leach.studio

Orbit

ambient · iss · data
↓ source · seed

about this piece

Tunes itself to where the ISS is right now. Latitude becomes pitch (south = low, north = high), longitude picks the chord intervals. As the station moves, the music slowly changes with it (next listener tunes in next hour to a new chord).

seed
orbit-preview
by
stuart
year
2026
tags
ambient · iss · data
permalink
/a/orbit/orbit-preview

source

import type { ElementaryPatch } from '@/lib/engine/types'

/**
 * ISS-driven pad. The International Space Station's current position
 * (from open-notify-style API, snapshotted each UTC hour) maps to:
 *
 *   - latitude  (-90..+90)  → main pitch (south-low, north-high)
 *   - longitude (-180..+180) → which intervals form the chord
 *
 * So when the ISS passes over the equator near 0°E, we land on a tonic
 * fifth; over the pacific or polar regions, the chord shifts. Two
 * listeners in the same hour share the snapshot → same chord.
 */
const orbit: ElementaryPatch = {
  id: 'orbit',
  title: 'Orbit',
  author: 'stuart',
  year: 2026,
  tags: ['ambient', 'iss', 'data'],
  description:
    'Tunes itself to where the ISS is right now. Latitude becomes pitch (south = low, north = high), longitude picks the chord intervals. As the station moves, the music slowly changes with it (next listener tunes in next hour to a new chord).',
  engine: 'elementary',
  worldFields: ['iss'],

  audio: ({ el, rng, schedule, render, world }) => {
    const lat = world?.iss?.lat ?? 0
    const lon = world?.iss?.lon ?? 0

    // Latitude → fundamental frequency. -52..52 is ISS's range; map to
    // ~110 Hz (deep south) to ~330 Hz (high north).
    const baseFreq = 220 * Math.pow(2, lat / 60)

    // Longitude → choose a set of intervals (in semitones) for the chord.
    // Six different chord shapes, picked by which 60° band of longitude.
    const intervalSets = [
      [0, 7, 12, 19, 24], // open fifths (Atlantic)
      [0, 4, 7, 11, 14], // major7add9 (Africa)
      [0, 3, 7, 10, 14], // minor7add9 (Asia)
      [0, 5, 7, 12, 17], // suspended (Pacific west)
      [0, 4, 7, 9, 14], // major6 add9 (Pacific central)
      [0, 3, 6, 10, 13], // half-diminished (Pacific east / Americas)
    ]
    const lonBand = Math.floor(((lon + 180) / 360) * intervalSets.length)
    const semis = intervalSets[Math.min(lonBand, intervalSets.length - 1)]
    const chord = semis.map((s) => baseFreq * Math.pow(2, s / 12))

    const interval = 7 // re-render every 7s

    schedule(interval, (tick) => {
      const detune = Math.pow(2, rng.range(-6, 6, tick, 0) / 1200)

      // Pad — sine voices with detuned partner
      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.6)), 0.07)
      })
      const pad = el.add(...padVoices)

      // Continuous earth-drone — sub at the lowest octave of the chord root
      const droneFreq = chord[0] / 2
      const drone = el.mul(
        el.add(
          el.mul(el.cycle(el.const({ key: 'dr1', value: droneFreq })), 0.4),
          el.mul(el.saw(el.const({ key: 'dr2', value: droneFreq })), 0.12),
        ),
        0.45,
      )

      // Satellite ping: a short FM bell once per render at chord[3] (a tension tone)
      const pingFreq = chord[3]
      const pingGate = el.metro({ name: 'pingM', interval: interval * 1000 })
      const pingEnv = el.adsr(0.005, 1.8, 0, 0, pingGate)
      const pingMod = el.mul(
        el.cycle(el.const({ key: 'pingMod', value: pingFreq * 5 })),
        25,
      )
      const ping = el.mul(
        el.cycle(el.add(el.const({ key: 'pingC', value: pingFreq * 2 }), pingMod)),
        el.mul(pingEnv, 0.09),
      )

      // Cosmic background — bandpass pink noise way up high
      const cosmic = el.mul(
        el.bandpass(
          el.const({ key: 'cosFc', value: chord[chord.length - 1] * 4 }),
          2,
          el.pinknoise(),
        ),
        0.014,
      )

      // Mix → smoothed → soft saturation
      const mix = el.add(pad, drone, ping, cosmic)
      const wet = el.smooth(el.tau2pole(2.5), 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 slowly-orbiting dot tracing the world's curve, with a faint
    // longitude/latitude grid behind it. Audio RMS pulses the dot.
    ctx.fillStyle = 'rgba(12, 12, 12, 0.10)'
    ctx.fillRect(0, 0, width, height)

    const cx = width / 2
    const cy = height / 2
    const r = Math.min(width, height) * 0.36

    // Equator + a couple of latitude bands
    ctx.strokeStyle = 'rgba(232, 230, 225, 0.10)'
    ctx.lineWidth = 1
    for (const f of [0, -0.5, 0.5]) {
      ctx.beginPath()
      ctx.ellipse(cx, cy + f * r * 0.8, r, r * 0.35, 0, 0, Math.PI * 2)
      ctx.stroke()
    }

    // Orbital ring
    ctx.strokeStyle = 'rgba(232, 230, 225, 0.22)'
    ctx.beginPath()
    ctx.arc(cx, cy, r, 0, Math.PI * 2)
    ctx.stroke()

    // Orbiting dot — represents the ISS-ish position over time
    const angle = t * 0.18
    const dotX = cx + Math.cos(angle) * r
    const dotY = cy + Math.sin(angle) * r * 0.6 // squashed for "perspective"
    const rms = getRms()
    const pulse = 6 + Math.min(rms * 80, 30)
    ctx.fillStyle = 'rgba(239, 68, 68, 0.85)'
    ctx.beginPath()
    ctx.arc(dotX, dotY, pulse, 0, Math.PI * 2)
    ctx.fill()
    // glow
    const grad = ctx.createRadialGradient(dotX, dotY, 0, dotX, dotY, pulse * 3)
    grad.addColorStop(0, 'rgba(239, 68, 68, 0.4)')
    grad.addColorStop(1, 'rgba(239, 68, 68, 0)')
    ctx.fillStyle = grad
    ctx.fillRect(
      dotX - pulse * 3,
      dotY - pulse * 3,
      pulse * 6,
      pulse * 6,
    )

    if (mouseActive) {
      ctx.fillStyle = 'rgba(232, 230, 225, 0.06)'
      ctx.beginPath()
      ctx.arc(mouseX * width, mouseY * height, 30, 0, Math.PI * 2)
      ctx.fill()
    }
  },
}

export default orbit
← leach.studio