Correctness (four were my own regressions from the REVELATION wave): · ☸ WHEELS polymeter permanently detuned after any skipped frame — seqAbsStep advanced by ++ per transition, but one frame can cross two 16ths (bpm ≳155, or the clamped dt on every tab refocus). Proven to drift 12 steps out at 174bpm; now advances by the true distance, so seqAbsStep % 16 === seqCurStep always holds. · Sample & hold with exactly one lit step froze on its first sample forever (guard keyed on the wrapped step, which never changed) — now keyed on the absolute step. · PSALM double-activation left the MICROPHONE HOT after "stop": the re-entrancy guard sat after the getUserMedia await, orphaning the first stream/interval/context. Guard is now synchronous, a stop during the prompt is honoured, and a second tap while starting cancels. Mic off means off — the promise the feature makes. · Live-recording a drum tap into a short wheel wrote steps past wlen that never played; the tap now folds onto the lane's own wheel. · Switching MIDI out port left notes droning on the old port (pending off-timers fired at the NEW one) — midiFlushOut() releases every held note + all-notes-off before the swap. · world_quakes' seen-set grew unbounded on long-running rigs. Security: stored XSS via the commune room name — the one untrusted string reaching innerHTML unescaped (hub only lower-cased it). Escaped client-side at both sinks, and the hub now strips markup chars too. Robustness: world_ephemeris/world_almanac imported swisseph unguarded, so a machine without pyswisseph crash-looped them every 10s forever against the supervisor's exit-0 contract — both now retire quietly (verified by simulating the missing dep), and pyswisseph is documented in requirements. Mobile: the top bar's five separately-pinned chips collided below ~500px — the first thing a phone visitor saw was a broken header. Now a clean row with the account chip on its own line and the source counter moved left. Housekeeping: dead measureSpaced(), dead #account .code selector, and an 888K stale worktree removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Live global seismic activity from the USGS earthquake feed.
|
|
Every real earthquake on Earth in the last hour fires a percussive "event" whose
|
|
magnitude, depth, and location become musical: the planet playing itself as a drum.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
from pythonosc.udp_client import SimpleUDPClient
|
|
|
|
NAME = "world_quakes"
|
|
FEED_URL = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
|
|
POLL_SECONDS = 60
|
|
USER_AGENT = "Godstrument/1.0 (live instrument; contact monsterrobotparty@gmail.com)"
|
|
|
|
|
|
def fetch_feed():
|
|
"""Fetch and parse the USGS GeoJSON feed. Returns the parsed dict or None on failure."""
|
|
req = urllib.request.Request(FEED_URL, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
raw = resp.read()
|
|
return json.loads(raw)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Emit live USGS earthquakes as OSC.")
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=9000)
|
|
args = parser.parse_args()
|
|
|
|
client = SimpleUDPClient(args.host, args.port)
|
|
print(f"[{NAME}] emitting /gs/quake/... every {POLL_SECONDS}s")
|
|
|
|
seen = set()
|
|
first_poll = True
|
|
backoff = 5
|
|
|
|
while True:
|
|
try:
|
|
data = fetch_feed()
|
|
backoff = 5 # reset backoff after a good fetch
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as e:
|
|
print(f"[{NAME}] warning: fetch failed ({e}); retrying in {backoff}s")
|
|
time.sleep(backoff)
|
|
backoff = min(backoff * 2, POLL_SECONDS)
|
|
continue
|
|
|
|
features = data.get("features") or []
|
|
|
|
if first_poll:
|
|
# Record existing quakes but do NOT fire them (no startup burst).
|
|
for feat in features:
|
|
fid = feat.get("id")
|
|
if fid is not None:
|
|
seen.add(fid)
|
|
first_poll = False
|
|
else:
|
|
for feat in features:
|
|
fid = feat.get("id")
|
|
if fid is None or fid in seen:
|
|
continue
|
|
seen.add(fid)
|
|
if len(seen) > 4000: # the all_hour feed ages ids out, so an unbounded
|
|
seen.clear() # set is a slow leak on a long-running rig; a reset
|
|
seen.add(fid) # can at worst re-fire a quake still in the window
|
|
|
|
props = feat.get("properties") or {}
|
|
mag = props.get("mag")
|
|
if mag is None:
|
|
continue # skip quakes with no magnitude
|
|
|
|
place = props.get("place") or "unknown location"
|
|
geom = feat.get("geometry") or {}
|
|
coords = geom.get("coordinates") or [None, None, None]
|
|
lon = coords[0] if len(coords) > 0 else None
|
|
lat = coords[1] if len(coords) > 1 else None
|
|
depth = coords[2] if len(coords) > 2 else None
|
|
|
|
try:
|
|
client.send_message("/gs/quake/event", float(mag))
|
|
if depth is not None:
|
|
client.send_message("/gs/quake/depth", float(depth))
|
|
if lat is not None:
|
|
client.send_message("/gs/quake/lat", float(lat))
|
|
if lon is not None:
|
|
client.send_message("/gs/quake/lon", float(lon))
|
|
except (OSError, ValueError, TypeError) as e:
|
|
print(f"[{NAME}] warning: OSC send failed ({e})")
|
|
continue
|
|
|
|
depth_str = f"{depth:.0f}km" if depth is not None else "?km"
|
|
print(f" quake M{float(mag):.1f} {place} (depth {depth_str})")
|
|
|
|
time.sleep(POLL_SECONDS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|