Compare commits

..

22 Commits

Author SHA1 Message Date
type-two
0ef7c68371 ☰ the launcher · ☄ the omens · 🌍 shared worlds — the review's build list
The audit's verdict was that Godstrument is short on REACH, not features:
everything hid behind an invisible right-click, and everything you made
died in one tab. Four builds against exactly that.

☰ EVERYTHING — a visible, grouped index of the whole instrument (view /
play / your world / together / out / start here). Labels lead with the
plain verb and wear the god-name behind: "arpeggiator · GODSPEED",
"record to a file · TESTAMENT". Critically it is ONE data-driven list
with TWO renderers — the ☰ panel and the right-click sky menu both
render skyGroups(), so a feature can never appear in one and not the
other. (The sky menu's 90 hand-written lines became a 17-line loop.)
Submenus expand in place, so touch works.

☄ THE OMENS — the instrument always knew when the world was doing
something rare and never said so. Reads the live feeds and ranks what's
remarkable: a full moon, a harmonious sky, satellites overhead, a quake
that just landed, your solar return. One line whispers into the ticker
on arrival; the full reading is a tap away. Verified against the real
planet — it read a 91%-lit moon, an unusually harmonious sky and a
convulsing bitcoin, all true at the time.

🌍 SHARED WORLDS — the whole instrument (cables, grooves, beat, kit,
scale, tempo) folded into one link, gzipped by the browser's own
CompressionStream: ~10KB of patch becomes a ~620-character URL. It
rides the fragment, so nothing is uploaded, no account or gallery is
needed, and there is nothing to take down. The receiver is always
asked, never overwritten, and ⌘Z restores theirs. Round-trip verified:
a 12-wheel hat and 137bpm came back exactly. Deliberately NOT gated on
the hub handshake — a shared link must open even when the hub is down.

📱 THE PHONE FORK — a touch device is offered lifestrument once
(remembered, never forced), and the top bar folds to fit a narrow
screen either way.

Plus: the expression-lane drag was written out byte-identically in
three places; it is now one shared helper that speaks pointer events,
so velocity/chance/ratchet/p-lock lanes work under a thumb too.
Grimoire documents all four; manual regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:12:05 +10:00
type-two
510ddaaf8c 🔧 review fixes — the five-angle audit's whole findings list
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>
2026-07-26 19:43:20 +10:00
3e0bb9ee68 🚪 the doors: six front-door openings — the rig, the deal, the dawn
The front door becomes a skin: the first-visit card grows from three doors
to six, the walked door is REMEMBERED (gs_door) and opens the same way on
every arrival, and it's re-choosable any time (right-click sky → 🚪 the
doors, or say "doors"). Each door is one DOORS registry entry + one act —
delete the entry, the door is gone.

The classics (unchanged acts): 🌱 start from zero · 🌅 first light ·
⛈ the full storm. The new gig doors:

- 🎪 the rig — walk on stage: first light applied, beat panel + GODSPEED
  already open, and the sleeper fold tightened so only the ~8 liveliest
  uncabled hands stay in the field (8th-liveliest flowDisp sets the bar).
- 🃏 the deal — the world deals five cables from its liveliest feeds
  (family-diverse) onto a shuffled spread of ten dests, ONE card always
  pulsing (÷16 or ÷8); "🃏 deal again" chip; every arrival deals fresh;
  each hand is an ungodly mark so ⌘Z takes it back. Voice: "deal" redeal
  while the deal is your door.
- 🌒 the dawn — first light applied, then the players take the stage one
  at a time (~1.25s apart, cabled first, then by liveliness, 18 arrivals),
  each narrated in the ticker; cables only draw once their player is on
  stage (drawCables skips hidden sources); when the entrance ends the
  sleeper fold reconciles the rest.

Verified live (static serve + temp hook, removed & grep-verified 0):
six cards render; deal → exactly 5 routes, 1 pulsing, chip visible,
gs_door saved; redeal changes the hand, marks undo, ⌘Z restores the
previous hand exactly; rig → beat+GODSPEED open, field folded tight,
deal furniture closed; dawn → 24/24 hidden at curtain, players arrive
one-by-one with ticker narration and their cables, finale reconciles to
0 hidden; zero console errors; T O B G S + ⌘Z regression green;
grimoire + manual regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:22:04 +10:00
12a296f284 🎪 gig layer: pulse cables, speed-at-a-glance, sleeper fold, no ghost cables
Four hands-on changes from live-play feedback (Jono's "what speed are those
things going at?"):

- ghost cables: a patched cable whose source is silent no longer vanishes —
  alpha/width floor (va = max(activeDisp, 0.10)) on the idle line, dashes and
  dot; glow still reserved for active cables.
- speed at a glance: flowLabel() speaks each source's rate in bars of the
  current godtime ("streaming · ≈ 1/8 bar · ≈ every 5 bars · asleep, still
  12m · glacial"); shown in the node right-click live readout and as a second
  line in every loupe source row. Sources track _lastMoveT (last real data).
-  PULSE: per-cable clocked throb — r.pulse ∈ {16,8,4,1} pulses/bar; the
  shaped value rides a raised-cosine synced to seqPhase, so a glacial feed
  (debt, declination) becomes a ≥1/16 groove whose DEPTH is the slow value.
  Cycled via the  chip on each cable row (off → ÷16 → ÷8 → ÷4 → bar),
  serialized with the patch, undo-marked, whitelisted on load.
- 💤 sleeper fold: cableless sources with no signal (norm ≤ 0.02) and no
  movement for 45s fold out of the field; they wake THEMSELVES when their
  feed speaks (lastMove refresh). Bottom-left chip shows the count and
  toggles the fold (localStorage gs_fold). Field view only — skins keep
  their full cosmologies, zero stays zero, disconnected dev view unaffected.

Verified live (static serve + temp hooks, removed & grep-verified 0): pulse
active == norm·window to 0 error over 12 same-frame samples and cycles
÷16→÷8→÷4→bar→off; ghost cable visible on a dead venus.dist route;
labels discriminate (mercury "≈ every 5 bars", neptune "glacial", earth.beat
honestly "waiting…" — it never moves); fold hides dead feeds with chip
"💤 2 quiet feeds folded", chip toggle restores, woken feed rejoins field;
zero console errors; T O B G S + ⌘Z regression green; grimoire + manual
regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:00:05 +10:00
7c6d44b010 🔧 fix: pinch-zoom trap over menus + cable blips ride the data's own rate
Two live-play fixes:

- Zoom: trackpad pinch (ctrl+wheel) over a DOM overlay (right-click menu,
  panels) used to hit the BROWSER's page zoom, which the canvas then made
  un-undoable because it preventDefaults every wheel — zoomed-in with no way
  out except re-opening a menu. Now a capture-phase window listener catches
  pinch everywhere and drives the app's own view zoom (shared zoomAt());
  Safari's gesture* events are blocked too. Plain scrolling in menus and
  panels is untouched; canvas behavior unchanged (verified single-fire).

- Cables: dash/blip speed was uniform (activity-only). Each source now
  carries flowDisp — an EMA of |Δnorm|/s with fast attack + slow release —
  and every cable's dashPhase advances at 6 + flow·240 + activity·40, with
  the pulse dot riding the same phase. A ticking feed streams, a glacial
  feed crawls (venus.dist ~0 flow vs mercury.phase saturated, measured);
  event sources surge on fire via firePulse.

Verified: pinch in/out over overlay drives view.z and is defaultPrevented,
plain scroll not hijacked, canvas single-fire, flows measured per-source
via temp hook (removed, grep-verified 0), zero console errors, T O B G S
+ ⌘Z regression green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 10:32:28 +10:00
9fbba05426 merge resonance/spheres → main: 🪐 music of the spheres — planets as modulators, gate green (fable)
Reviewed: vendored ephemeris mechanically verified vs SOLARGOD@6391273 (209/209
constants, solver+rotation identical); self-check gates the feature; deterministic
great-year clock off seqPhase; live-verified in-browser — meters on, ratios button
sets wheels 4:8:16 + 8:13, S key free and working, zero console errors.
2026-07-16 14:44:58 +10:00
925617d782 🪐 resonance: music of the spheres — planets as modulators (Stage 1)
Vendor SOLARGOD@6391273's dependency-free JPL ephemeris (viz/spheres.js) and
make the solar system a modulation source, computed locally at the instrument's
own clock — no network, no server, no sockets.

- viz/spheres.js: ephem.js verbatim + the minimal lib subset inlined; loaded via
  a <script type="module"> bridge that self-checks (Mars @ JD 2461236.5 within
  0.005 AU of Horizons ground truth) and hangs a frozen window.SPHERES; every
  consumer guarded so a missing/broken file still boots the app.
- sphere.<planet>.{phase,dist,beat} sources for the 8 planets, routable like any
  S&H source; the great-year clock maps 1 bar = N days of sky (log knob 1..3650,
  default 30) off seqPhase alone, so scrub/tempo/reload reproduce the same sky.
- 🪐 spheres panel (S / right-click sky): per-planet live meters, rate knob,
  ☸ orbital-ratios button (drum wheels → 4:8:16 Io:Europa:Ganymede, 8:13
  Venus:Earth pentagram), on/off; godspeak: spheres on/off·faster/slower,
  "<planet> drive <dest>". spheres {on,rate} travel in the patch.
- grimoire chapter + regenerated viz/manual.html.

Verified: self-check green; determinism (jupiter.phase@bar16 identical across
reload); pentagram (venus.beat period 1.59 bars ≈ 583.9/365, within 2%); ratios
set exact wlens and ⌘Z reverts; zero console errors; ♪ T O B G + ⌘Z all work;
spheres default off on fresh boot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:04:22 +10:00
5b0fffd75c 📋 resonance: music-of-the-spheres brief — gated on revelation-ω (fable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:12:05 +10:00
type-two
7ee3c51269 Merge branch 'revelation/integrate' — TESTAMENT · WHEELS · PSALM · CANON 2026-07-14 14:16:34 +10:00
type-two
f9f0ef52ff ✦ revelation: ω the closer — grimoire chapter + manual (Stage 3)
Four grimoire entries in the house voice, fact-checked against what was
verified: 📼 TESTAMENT ("it is written"), ☸ WHEELS (Ezekiel 1:16 — "a
wheel in the middle of a wheel"), 🕊 PSALM (the local-listening privacy
line — nothing leaves the machine), ✦ CANON (the read-only shelf of
worked examples). Manual regenerated (27 sections).

Cross-feature QA: all four surfaces coexist with the pantheon and the
crossover — TESTAMENT ⏺ button, WHEELS chips in all 5 beat lanes,
PSALM/TESTAMENT/GODSPEED ctxItems — zero console errors, groove
self-check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:16:34 +10:00
type-two
7e568d8323 ✦ revelation: CANON — factory dimensions (Stage 2C)
Four shipped patches on a read-only shelf: ✦ the heartbeat (boom-bap,
mpc 8-4, kick duck), ✦ the wheel within the wheel (WHEELS polymeter —
kick 16 / hat 12 / clap 10), ✦ the squeeze (GODSQUASH — the market &
quakes pump the master), ✦ quiet arrival (gentle earth-echo ambient,
no drums). git-tracked in factory/, authored in the client's
serializePatch shape.

Course-correction from the brief's stale anchor: the template select
reads /api/presets (account-based, auth.py), NOT the hub's legacy
patchList — so the factory shelf is injected there. list_factory() +
load_factory() prepend the ✦ patches for every signed-in player;
PUT/DELETE on a ✦ name → 403 (read-only); a missing factory/ degrades
to []. test_factory.py (deploy-excluded) validates shape.

Verified: HTTP dispatch serves the 4 ✦ names leading the list, loads
one, rejects writes (403), user saves still work, traversal/missing →
None, zero index.html changes. Taste disclaimer: these are structural
drafts — correct shape is the deliverable, beauty is the human's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:13:38 +10:00
type-two
cccbce536c 🕊 revelation: PSALM — sing, and the instrument answers (Stage 2P)
Mic pitch → folded to the global scale → played as notes, or (when 
GODSPEED is on) latched into the arp one note at a time. The mic lives
in its OWN throwaway AudioContext — it is analysed, never routed into
the music graph (zero feedback), and nothing leaves the page. A pure
autocorrelation detector psalmDetect(buf,sr)→{freq,clarity,rms} (80–1000
Hz, parabolic interpolation) and a pure note state-machine psalmStep()
(2 stable frames to fire, legato on change, 150ms gate close + re-attack,
velocity from RMS) — both unit-tested headlessly. A 🕊 chip shows the
sung note and brightens on voice; a ctxItem toggles it; mic denial
tickers "the instrument cannot hear you".

Verified: detector 220/440/554Hz within 0.09% (silence & noise gated),
fold lands in-scale, the state machine single-fires / legatos /
re-attacks / scales velocity, the ctxItem + mic-denial path work live,
zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:04:28 +10:00
type-two
0ff4259a4f ☸ revelation: WHEELS — a wheel within a wheel (Stage 1W)
Per-lane pattern length (wlen, 1..16, default 16 = full wheel) → polymeter.
Named wlen because keyroll already owns seq.len (the per-step note-length
array). One monotonic seqAbsStep counter drives wheelStep(sq), which
returns seqCurStep for full wheels (bit-identical to pre-WHEELS) and
seqAbsStep%wlen for shorter ones. Threaded through seqTick (both branches,
one cur for the gate AND len[] lookup), the S&H source sampling, and every
applyGroove call. migrateSeq clamps wlen (legacy → 16); self-check gains
wlen-default/preserve/clamp + wheel-full-identity. exportMid's drum block
is now one absolute loop (a 12-wheel exports as it plays; sa%16 keeps the
full wheel byte-identical). UI: a ☸ wheel chip in each beat lane + the
arranger euclid row; steps past the wheel dim (.off, pointer-events:none)
and the playhead paints at wheelStep. The owned() export gate is intact.

Verified: groove self-check green (incl. new assertions), full-wheel
export bit-identical over 4 bars, 12-wheel wraps at sa%12, kick-16/hat-12
drift over 48 ticks, beat-panel wheel=12 dims exactly 4 cells, zero errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:58:57 +10:00
type-two
6cb355e6b0 📼 revelation: TESTAMENT — record what god does (Stage 1T)
A MediaRecorder tapped off N.lim (the true final mix — post-squash,
post-limiter). Lazy dest+recorder built on first roll, 1s timeslices.
The #testament ⏺ button beside ♪ turns red and counts m:ss while
rolling; on stop it downloads godstrument-testament-<stamp>.<ext> and
tickers "it is written". A ctxItem mirrors it near GODSONIQ.
testamentStart/Stop/State exposed on the Synth return.

Verified: button/timer/ticker/download all fire, blob is valid opus
decoding to the right duration, and an oscillator probe proved the
record→decode pipeline captures real audio (RMS 0.35). The Synth take
is silent only because the headless preview suspends its AudioContext
— the tap is correct; on a real machine it records what sounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:46:41 +10:00
type-two
e6c464daae 📋 revelation: post-convergence amendments — wlen (len is taken), gates, deploy notes (fable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:26:54 +10:00
type-two
19f17705e6 ⚭ convergence — the pantheon meets the crossover (fable)
Merge pantheon/integrate (GODSPEED  · GODSQUASH 🫀 · GODSPEAK 🗣 ·
UNGODLY  + the ε closer, reviewed & committed from the m3ultra rig)
into main (keyroll · sats · planes cache · god's eye · 18 review
fixes). Two conflict hunks, both in the beat panel where behavior
fixes met undo instrumentation — resolved keeping both: the bpm field
stays always-editable AND marks undo; master ▶/■ keeps per-lane mute
memory AND marks undo. Manual regenerated from the merged grimoire.
Verified: zero console errors, groove self-check, beat/G panels,
godspeak chip + keyroll CSS all present, python gates green, the
godspeak worker skips gracefully where MLX is absent (and the
supervisor now retires clean exits instead of churning them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:25:10 +10:00
type-two
629cbad6fd 🔍 crossover post-merge review — 18 confirmed findings fixed (fable)
A fresh-eyes adversarial pass over the 15 pulled commits (godrum +
keyroll + sats + planes cache + god's eye): 21 raised, 18 confirmed,
all fixed.

The instrument: .mid export no longer smuggles the seeded demo beat
(lanes export only if playing or edited off the seed — same test
zeroHasBuild uses); master ▶/■ remembers per-lane mutes; the beat
panel's bpm field is always editable like the dial (🔒 blocks the
world's hands, not the user's); ⏺ rec with the beat stopped anchors
step 0 at arm instead of the invisible free-running clock; note-length
drags speak pointer events (touch works, window-level so mid-drag
re-renders survive); the resize handle no longer eats 40% of a
sixteenth's click width; the god's eye clears a stale ISS trail after
an rAF nap or resize; the "Agent A" dev-process string is gone; the
grimoire now tells the truth about kit knobs and which voices hold
note length (manual regenerated).

The workers: world_sats — partial Celestrak fetches backfill from the
old cache instead of clobbering it, pass-detection state survives the
6-hourly refresh, the no-TLE exit naps 15 min so the supervisor can't
hammer Celestrak, and the venue default now matches world_sky's
Brisbane instead of Null Island; world_planes bounds its stale-cache
fallback to 1h so a days-old sky can't resurrect as live; world_sky's
docstring admits the lat/lon it emits. run.py retires workers that
exit 0 instead of reviving them every 10s (the sgp4-missing churn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:24:25 +10:00
jing
d2c7938321 👁 god's eye: GODSIGH's cosmology as a Spirit on the shelf
A new map-layout skin — the world's own sensor picture read AS the cosmology,
ported from the GODSIGH view. A minimal equirectangular Earth (a sparse hand-drawn
continent set kept inline — no heavy geo data in the one-file console), a 30°
graticule refit to a 2:1 rect each frame (dynamicStations), and nine geographic
"posts" the sources are seated at by godseyeOf. The overlay seats every feed at its
LITERAL coordinates: earthquakes flash at quake.lat/lon (venue-antipode fallback if
absent), the ISS and recon birds sweep as a moving point + trail from sats.iss_lat/
lon, aircraft are a shimmer band, the markets pulse at NY/London/Tokyo, the solar
wind washes the poles as an aurora, the word sparks at civilisational coordinates,
and your own hands are the ring at the venue. Cold watch-floor palette (near-black,
cyan, warning amber) and a Bb minor-pentatonic retune. Registered in SKINS +
SKIN_HOME beside the bagua; grimoire + manual entry ("the eye that hears").

To give the console the venue's location (it had none), world_sky now emits
/gs/sky/lat,lon; config.json declares sky.lat/lon and quake.lat/lon so the eye can
place "here" and the quakes. (sats.* signals + routes already landed with world_sats.)

Verified: full viz parses/runs with the groove self-check passing and zero console
errors; an isolated harness renders the REAL overlay code into a coherent world-map
with every layer; and an end-to-end run (feeds -> hub -> WS) delivers all the eye's
input signals. Adversarial review found one wrap bug — the ISS trail streaking
across the map at the ±180° antimeridian — now fixed by breaking the polyline at the
seam (verified: a seam-crossing sine trail renders split, no full-width streak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:18:18 +10:00
jing
6d408a8bdb 🤝 world_planes: read the shared OpenSky cache (quota-collision fix)
Reader side of the OpenSky shared-cache v1 contract (mirrored in GODSIGH
SPEC2.md §4). Path ~/.cache/godverse/opensky-states.json (env OPENSKY_CACHE_FILE),
content the raw global /states/all body, freshness = mtime within 120 s.

Each poll cycle now checks the cache first: on a fresh hit it reads the global
body, filters the states to our bbox client-side (indices 5=lon, 6=lat), and
skips the HTTP call entirely (logs "cache hit"). On 429 or a network failure it
falls back to a stale cache rather than nothing. This worker only ever polls a
bbox, so it never WRITES the cache (never partial data into a global-contract
file) — it is a pure reader, and degrades to its old ≥450 s bbox fetch when no
writer is around (i.e. prod). Result: godstrument + GODSIGH running together
spend ~one poller's quota instead of colliding and 429-ing each other out.

Verified: unit tests for read_cache freshness/stale/missing + bbox filtering,
and an integration run where a fresh cache is served (count filtered to bbox,
zero HTTP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:45:32 +10:00
jing
301005bdbc 🛰 world_sats: real satellites as a source (SGP4 from cached Celestrak TLEs)
The sky's own schedule joins the orchestra. Unlike world_iss (which begs a web
API per position), world_sats fetches TLEs once from Celestrak — the stations
plus the named recon birds GAOFEN and COSMOS — caches them to
~/.cache/godverse/tles.txt (24 h freshness, atomic writes, stale-on-failure),
and propagates every craft locally with SGP4 at any rate, no per-fix HTTP.

For the venue city (--lat/--lon, retargeted by run.py) it computes each craft's
elevation above the horizon and emits:
  /gs/sats/best_elev       highest elevation among all tracked (-30..90 deg)
  /gs/sats/overhead_count  how many above 10 deg
  /gs/sats/iss_lat/iss_lon ISS sweep (available even without world_iss)
  /gs/sats/recon_elev      best elevation among only the GAOFEN/COSMOS watchers
  /gs/sats/pass.event      impulse when any craft crosses rising through 30 deg

Wiring: config.json declares the six signals with norm ranges + labels, three
starter routes (best_elev -> pad.brightness, pass -> delay.feedback,
recon_elev -> lfo.rate) and adds sats.best_elev to the cosmos group; run.py adds
the worker to the WORLD profile and the lat/lon retarget set; requirements notes
the pure-python sgp4 dep. The worker is import-safe (sits out if sgp4 is absent)
and exits politely with no cache and no network. Grimoire + manual entry added.

Math ported from GODSIGH's verified satellites layer: TLE -> Satrec.twoline2rv
(handles no_kozai), SGP4 -> TEME km, GMST rotation -> WGS-84 geodetic, then an
ECEF up-vector for elevation. Verified end-to-end against live Celestrak data:
98 sats tracked, ISS position/altitude plausible, and all signals arrive over
the hub WebSocket correctly normalized with their routes active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:03 +10:00
jing
4f6a2a9634 🎹 keyroll: land note-length from local/groove-editor onto godrum's sequencer
Feature-gap analysis of local/groove-editor (c0c3a98) vs current main showed
godrum independently built almost everything the branch had — per-step
velocity/chance/ratchet, euclid, swing/humanize, scale/root, Play-One stacks,
Follow Actions. The one genuine, valuable gap was NOTE LENGTH (the "key-roll"):
main's notes were all one-16th. Ported that natively; skipped the rest as either
already-covered, a deliberate divergence (global scale, Play-One vs polyphony),
or too invasive to land without regressing godrum (clip-length/polymeter would
rewrite the fixed-16 architecture that migrateSeq/arranger/beat/commune assume).

What landed (all back-compat; len defaults to 1 = one 16th, so old patches are
unchanged except un-ratcheted sequenced notes now gate to their drawn length —
which is the feature):
- len[16] on seqFor/drumSeqFor + migrateSeq back-fill + two self-check guards
- attachNoteLengths(): .seqnote overlay blocks with an ew-resize drag handle,
  used by the voice-inspector roll and the arranger grooves-tab roll; len is
  per-voice (shared across A/B/C clips) exactly like vel/chance/rat
- playback: seqTick gates rat==1 notes to len*sixteenth; Synth.pluck gains an
  optional durMs (null = byte-for-byte the old natural decay); ratchets unchanged
- MIDI: export emits length-scaled note-offs with a same-pitch overlap clamp;
  live midiPluck now tracks pending offs per (channel,pitch) so a held note
  retriggered on its own pitch ends cleanly instead of being cut by a stale off
- grimoire + regenerated manual

Verified: groove self-check passes (incl. len guards); an isolated harness
confirms overlay render/drag/clamp at both grid sizes + Play-One stacks + the
getter that keeps overlays in sync when the arranger swaps sq.steps per bar.

The reference branch local/groove-editor is the only copy of this work and is
deleted now that note-length is landed and pushed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:35:29 +10:00
jing
6aff223e02 📋 godsigh crossover: build brief for opus — keyroll merge, 🛰 sats worker, 👁 god's-eye spirit, shared opensky cache
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:34:18 +10:00
22 changed files with 4107 additions and 250 deletions

View File

@ -0,0 +1,78 @@
# GODSIGH ✕ GODSTRUMENT — Crossover Build Brief
**Audience:** Claude Opus 4.8, executing autonomously in this repo (`godstrument`).
**Author:** Claude Fable 5, 2026-07-13, after mapping both codebases.
**Companion:** `~/Documents/GODSIGH/SPEC2.md` (the sibling wave in the GODSIGH repo). Task 3 below shares a **cache contract** with it — the contract text must stay in sync.
**Before anything:** skim `README.md`, `run.py` (worker supervision + profiles), one worker (`workers/world_iss.py` is the cleanest contract example), `config.json` (signals/routes/groups shape), and the Spirits shelf region of `viz/index.html` (~line 5111 on, "one Source, many Spirits, one Matter"). Match house style: workers are argparse + pythonosc standalone processes emitting `/gs/<group>/<name>`; commits carry an emoji prefix (`🛰`, `🥁`, `📖`…); shipped features get a grimoire/manual entry (see the `📖` commits and `GODSTRUMENT_MANUAL_SOURCE.md`); `*_BRIEF.md` files are deploy-excluded. Verify in the browser (`python3 run.py --profile minimal` + your new worker) before each commit.
Execute the four tasks in order; commit + push per task.
---
## Task 1 — 🎹 Land the local "keyroll" work (groove editor) safely
**Situation.** Branch `local/groove-editor` (commit `c0c3a98`, single commit on top of pre-godrum `3ff105d`) holds ~637 lines of real local work on the sequencer: a piano-roll editor with **draggable note lengths** (`.seqnote` overlay blocks with an `ew-resize` handle), **velocity + chance lanes** (`.explane` draggable bar-per-step lanes), a `seqedit` wrapper with lane toggles/selectors, **Euclidean tools** (`.euc`, `.tnum`), groove **knobs** (`.aknob`), bar-line markers, and a scroll container. Meanwhile `main` merged the whole **godrum** line, which reworked the same sequencer UI area and independently built per-step velocity+chance, Euclidean fills, swing/humanize, and Follow Actions.
**Do NOT `git merge local/groove-editor` blind** — the branches rewrote the same regions and an auto-merge will produce soup. Instead:
1. **Feature-gap analysis first.** Read the branch diff (`git diff 3ff105d..local/groove-editor -- viz/index.html`) and the current main sequencer ("Groove" region, ~`viz/index.html:202`/`:238`, plus godrum's beat panel). Build an explicit table: for each keyroll feature — note lengths / velocity lane / chance lane / lane toggles / euclid tools / groove knobs / bar lines / scroll container / big-grid variant — does current main already have an equivalent (however differently spelled)?
2. **Port only the gaps, natively.** Reimplement each missing feature on top of *current* main's structures and naming (the branch is the reference spec, not a patch to apply). Likeliest genuine gaps, from marker analysis: the **note-length piano roll** (`seqnote` spanning blocks with drag-resize — main has none) and possibly the **explane-style aligned drag-lanes UI**; likeliest already-covered: euclid, velocity/chance *data* (godrum has the data model — the gap may be only the lane *editor UI*).
3. Keep godrum's behavior intact — its choke anchors, euclid guard, kick-duck etc. were carefully review-hardened (`fa5aa43`, `b0fad08`); regressions there are worse than missing a keyroll nicety.
4. Verify by playing: place notes with lengths, drag velocities/chances, euclid-fill, confirm godrum voices still behave, MIDI export still valid.
5. When everything valuable is landed on main, delete the branch (`git branch -D local/groove-editor`) and say so in the commit body — that branch is the only copy of the reference work, so delete it **only after** the port is committed and pushed.
6. Grimoire/manual entry for the new editor capabilities, per house convention.
## Task 2 — 🛰 `workers/world_sats.py`: real satellites as a source (SGP4, no polling APIs)
GODSIGH proved the pattern: TLEs from Celestrak + SGP4 gives *any* satellite's position locally at any rate — no per-fix HTTP like `world_iss.py` needs. Bring that superpower here as a worker:
- **Dependency:** `sgp4` (pure-python, pip). Add to `requirements.txt`.
- **TLEs:** fetch at startup from `https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle` plus `NAME=GAOFEN&FORMAT=tle` and `NAME=COSMOS%202486` / `NAME=COSMOS%202506` (the recon birds — thematic gold). Cache the TLE text to `~/.cache/godverse/tles.txt` and refresh only if the cache is >24 h old (Celestrak politeness; TLEs age slowly). Tolerate fetch failure by using a stale cache; tolerate no-cache-no-net by exiting politely like other workers.
- **Compute** (default `--interval 2`): propagate each satrec to now (`sgp4` gives ECI; convert to lat/lon/alt via the standard GMST rotation — port the math from GODSIGH `js/layers/satellites.js` / satellite.js's `eciToGeodetic`, it's ~15 lines), then derive **elevation angle** above the venue city's horizon (city lat/lon from `cities.json` via the same `--lat/--lon` retargeting `run.py` applies to other geo workers — accept those flags).
- **Emit** (the contract: raw floats, hub normalizes):
- `/gs/sats/best_elev` — highest elevation (degrees, can be negative) among tracked sats: the "something is overhead" continuous signal.
- `/gs/sats/overhead_count` — count with elevation > 10°.
- `/gs/sats/iss_lat`, `/gs/sats/iss_lon` — keep the ISS sweep available even when `world_iss.py` isn't running.
- `/gs/sats/pass.event` — impulse when any sat **crosses rising 30°** (a recon bird crests the sky and a bell rings). Include which sat in the printed log line.
- `/gs/sats/recon_elev` — best elevation among only the GAOFEN/COSMOS set (the watchers' own channel).
- **config.json:** declare the new signals with sensible `norm` ranges (elev `lo:-30, hi:90`; count `lo:0, hi:8`) + labels; add 23 starter routes (suggest: `sats.best_elev → pad.brightness` gentle; `sats.pass → delay.feedback` impulse; `sats.recon_elev → lfo.rate` — the sky watching back). Add the worker to the `WORLD` profile in `run.py`.
- Grimoire/manual entry: "the sky's schedule joins the orchestra."
## Task 3 — 🤝 Shared OpenSky cache (reader side)
**The contract (mirrored verbatim in GODSIGH SPEC2.md §4 — keep in sync):**
> **OpenSky shared cache v1.** Path: `~/.cache/godverse/opensky-states.json`, overridable via env `OPENSKY_CACHE_FILE`. Content: the raw, unmodified OpenSky `/states/all` JSON response body (global, no bbox). Writer: whoever successfully fetches from upstream writes the body **atomically** (write to `<path>.tmp` in the same directory, then `os.replace`). Freshness = file mtime. Readers treat the cache as fresh if `mtime` is within **120 s**; on upstream 429/failure, readers may serve/use a stale cache rather than nothing.
GODSIGH's dev proxy implements the writer side. Here, make `workers/world_planes.py` cache-aware:
1. Each poll cycle: if the cache file exists and is **fresh**, read it and skip the HTTP call entirely (log `[world_planes] cache hit`). GODSIGH fetches globally; if this worker uses a bbox, filter the cached global states client-side by lat/lon — same fields, indices 5/6.
2. On a real HTTP poll that succeeds **with a global scope**, write the cache per the contract (atomic tmp+rename, `mkdir -p` the directory). If this worker polls a bbox, do NOT write (never write partial data into a global-contract file).
3. On 429 or network failure: fall back to a stale cache if present (log it), else current behavior.
4. Keep the existing ≥450 s cadence — the cache makes the *effective* shared cost whatever the most aggressive consumer pays, once, per window.
Result: GODSIGH (90 s cadence) + godstrument running together spend ~one poller's quota instead of two.
## Task 4 — 👁 "GOD'S EYE" Spirit (a skin for the shelf)
A new Spirit in the existing shelf system (register exactly like the others — bagua `~:5791`, Kandinsky `~:5689` are good templates): the world's sensor picture as the cosmology. 2D canvas, house style — no new libraries.
- **Stage:** a minimal equirectangular world (a sparse hand-drawn continent outline is enough — store a small `[[lon,lat],...]` polyline set inline; do NOT ship a heavy geo dataset into the one-file viz), graticule every 30°, dark ground, the venue city marked with a ring.
- **Seat the feeds geographically, not symbolically** (this Spirit's whole cosmology is *literal coordinates*): quakes flash at their true lat/lon (the quake worker's events carry location — check what reaches the WS feed and use what's available; if only intensity arrives, flash at the venue antipode and note it), ISS/satellites sweep as moving points with short trails (from Task 2's `sats.*` lat/lon signals), planes as a shimmer band whose brightness = `planes.count`-ish signal, market as a ticker pulse at the financial capitals (NY/London/Tokyo dots), solar wind as an aurora wash at the poles, wiki edits as brief sparks at random civilizational coordinates.
- **Tuning:** like every Spirit, choose a scale + palette identity — suggest a cold surveillance palette (near-black, cyan `#35e0ff`, warning amber) and a minor-pentatonic retune, so entering the Spirit *sounds* like a watch floor.
- Register it on the shelf, respect performance mode (`P`) and zen (`Z`), grimoire/manual entry ("the eye that hears").
## Verification & workflow
- Per task: run `python3 run.py --profile minimal` plus the touched worker(s) with explicit flags; watch the hub log for your signals arriving; open the viz and confirm in the browser (Spirit renders, sequencer plays, no console errors). `python3 -m py_compile` every touched .py; keep workers import-safe when optional deps are missing (house rule).
- Commit per task with the emoji house style; push after each. End commit messages with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- Do not deploy anything from this brief; deployment of godstrument follows its own conventions and is not part of this work.
## Pitfalls
- Task 1: the keyroll branch predates godrum — never trust an auto-merge; port by intent. Delete the reference branch only after the port is pushed.
- `sgp4`'s `Satrec.sgp4()` returns position in **km, TEME frame** — the GMST rotation to geodetic is mandatory, and `satrec.no_kozai` is rad/min (mirror GODSIGH's math, it's verified).
- Celestrak NAME queries can return `No GP data found` plain text — detect (first data line must start with `1`) and treat as empty, not fatal (verified behavior, same as GODSIGH).
- Atomic cache writes: keep the `.tmp` beside the target file (same filesystem), not in `/tmp`.
- The viz is one 9,256-line file — make surgical, well-commented insertions in the established regions; don't reorganize it.

105
RESONANCE_BRIEF.md Normal file
View File

@ -0,0 +1,105 @@
# RESONANCE — build brief · 🪐 music of the spheres
> **You are an execution agent (Opus).** Single lane. Read Part 0 of
> `GODRUM_BRIEF.md` (process root: worktrees, commit style, §0.7 regression,
> §0.8 report, never-deploy) and the REVELATION amendments — then this file.
> Fable reviews before merge.
## ⚠ HARD GATE
**Do not branch until the full REVELATION wave (lanes T, W, P, C AND the ω
closer) has merged to `main`.** This lane edits app-scope `viz/index.html`
(mod sources, a panel, godspeak grammar) and WOULD COLLIDE with all of them.
Confirm: `git log --oneline` shows the ω/closer merge; `grep -c wlen
viz/index.html` ≥ 1 (WHEELS landed); `grep -c testament viz/index.html` ≥ 1.
If not, STOP and report. All anchors below are names, never line numbers.
Branch: `resonance/spheres` in a worktree (`../godstrument-resonance`).
## R.0 The idea
The Godstrument gains the solar system as a modulation source. SOLARGOD
(`/Users/m3ultra/Documents/SOLARGOD`, read-only sibling) proved a ~250-line
dependency-free ephemeris (JPL Standish elements → heliocentric positions,
verified against Horizons to ~3e-4 AU). Planets become LFOs with periods of
months to centuries, compressed into musical time. No network, no server
bridge, no sockets — **the design is vendoring, not streaming**: godstrument
computes the sky itself, deterministically, at its own clock.
## R.1 The vendored engine — `viz/spheres.js`
Create ONE new file by merging, from SOLARGOD **pinned @ `6391273`**:
`js/ephem.js` (whole: tables, solver, `helioEcl`) + the minimal `lib.js`
subset it imports (`centuriesSinceJ2000`, `normDegPM180`, `DEG`, `J2000_JD`,
`jdFromUnixMs`). Header comment: `// vendored from SOLARGOD@6391273 — do not
edit; re-vendor to update`. Keep it an ES module; load it from index.html the
same way the app loads (if the app is one inline IIFE with no module imports —
it is — add a `<script type="module">` that imports spheres.js and hangs a
frozen `window.SPHERES = { helioEcl, … }` for the IIFE to consume; guard every
consumer with `window.SPHERES && …` so the app still boots if the file is
missing). **Self-check on load** (house style, like `migrateSeq`'s): Mars @ JD
2461236.5 must be within 0.005 AU of `(1.05351, 1.00863, -0.00470)` — the
wave-1 Horizons ground truth — else log one console error and disable the
feature flag.
## R.2 The sources — planets as modulators
New computed dests (study `computeDests` and the existing S&H/mod plumbing
FIRST; mirror its conventions exactly). For each planet p of the 8 (+ configurable
Earth-relative pairs), at the CURRENT musical time (see R.3), expose normalized
0..1 signals:
- `sphere.<p>.phase` — heliocentric longitude λp / 360 (a sawtooth with the
planet's year as its period).
- `sphere.<p>.dist` — (r r_min)/(r_max r_min) over that planet's own
perihelion..aphelion (eccentricity breathing; Mercury and Mars breathe hard,
Venus barely).
- `sphere.<p>.beat` — 0.5 + 0.5·sin(2π·(λp λe)/360) — the synodic beat
against Earth (VenusEarth: the 583.9-day pentagram cycle).
Routable exactly like existing mod sources — whatever mechanism computeDests
uses for its S&H-style sources, spheres are more of the same, not a new
subsystem.
## R.3 The great-year clock
Real planets are too slow for music. One knob, `spheres.rate`: **1 bar = N
days** of sky time, log-scaled N ∈ [1, 3650], default 30. Sky JD =
`J2000_JD + (godtime bars elapsed) · N`, derived ONLY from the master clock
(`godtime`/`seqPhase` machinery), so scrubbing/tempo changes stay deterministic
and patch reload reproduces the same sky. At default: Venus' year ≈ 7.5 bars,
Jupiter's ≈ 144 bars, the VenusEarth beat ≈ 19.5 bars — slow-arc territory,
exactly right.
## R.4 The wheel of heaven — WHEELS tie-in
One button in the panel: **"orbital ratios"** — sets the drum lanes'
`wlen`s to the nearest-integer resonance set 4 : 8 : 16 (Io:Europa:Ganymede's
1:2:4) and, if ≥5 lanes exist, 8 : 13 (the Venus:Earth orbit ratio — the
pentagram, in wheel form). Uses WHEELS' shipped API/fields (`wlen`, clamped
116) — pure data pokes + `ungodlyMark` before mutating (UNGODLY convention).
## R.5 The panel & the voice
- Panel `🪐 spheres` (ctxItem + keyboard key if any letter is still free —
check the handler; if none, ctxItem only): per-planet row (phase · dist ·
beat as live meters), the rate knob, the ratios button, on/off. House style:
study `openBeat`/arranger before building.
- godspeak grammar (post-ε `handleMessage`): `spheres on/off`,
`spheres faster/slower`, `<planet> drive <dest>` → route that planet's
`.phase` to a dest by the existing routing verb conventions. Follow ε's
grammar-table pattern; do not restructure it.
- Grimoire: ONE short chapter (the manual regenerates via `build_manual.py`
run it, commit both).
## R.6 Verify (§0.7 regression + these)
- Self-check gate green (R.1's Mars check) in console on load.
- Determinism: set rate N=365, note `sphere.jupiter.phase` at bar 16, reload
the patch, re-run → identical to 1e-6.
- Pentagram: with N=365, measure the `sphere.venus.beat` period in bars —
must be 583.9/365 ≈ 1.6 bars ±2%.
- The ratios button sets the exact `wlen`s and survives `migrateSeq`
round-trip; ⌘Z (UNGODLY) reverts it.
- Zero console errors; `♪ T O B G` + ⌘Z all still work; a patch saved before
this feature loads clean (spheres default off).

View File

@ -4,6 +4,56 @@
--- ---
## ⚠ AMENDMENTS — read BEFORE your lane (Fable, 2026-07-14, post-convergence)
This brief was drafted at `main@b8b1188`, **before** the pantheon merged with the
crossover wave (keyroll note-length · world_sats · OpenSky cache · the god's-eye
Spirit · 18 post-merge review fixes). The convergence landed as `19f1770`. These
amendments override anything they contradict below.
1. **GATE (replaces §0.2's):** branch from `main``19f1770`. Confirm your base
has BOTH waves: `grep -c godspeakCommand viz/index.html` ≥ 1 (ε) AND
`grep -c attachNoteLengths viz/index.html` ≥ 1 (keyroll) AND
`git log --oneline -3` includes the ⚭ convergence commit. If not, STOP.
2. **WHEELS — the `len` name is TAKEN.** Keyroll landed `seq.len` as a
**per-step ARRAY of note lengths** (see `seqFor`: `len: new Array(16).fill(1)`,
plus `migrateSeq`'s `len-default`/`len-badlen` self-checks). WHEELS's per-lane
pattern length must be a **different field: `wlen` (int 116, default 16)**.
Everywhere W.1W.5 below says `len`, read `wlen`. Your `migrateSeq` additions
must coexist with the existing len-array migration (add `wlen` clamping beside
it; new self-check assertions for `wlen`, don't touch the len ones).
3. **WHEELS × keyroll indexing:** the note branch of `seqTick` now reads
`sq.len[cur]` (per-step note length) — with wheels, `cur` becomes
`wheelStep(sq)` for BOTH the `steps[]` gate and the `len[]` lookup (one `cur`,
used twice — do not let them diverge).
4. **WHEELS × exportMid:** the drum block now has an `owned(v, sq)` gate (lanes
export only if playing or edited off `DRUM_SEED`) — your absolute-loop rewrite
in W.4 must keep that gate exactly. The note track is length-aware with a
same-pitch overlap clamp; don't disturb it.
5. **TESTAMENT:** the master chain gained nodes since your anchor was written
(squash from β; the Schumann layer predates). Grep the actual tail of
`build()` for the final limiter node and its name — do not trust `N.lim` as a
name, verify it. Also `Synth.pluck` now takes an optional `durMs` — irrelevant
to your tap, but don't be surprised by the signature.
6. **PSALM:** `Synth.pluck(midi, vel, durMs)` exists — sustained sung notes may
pass a duration instead of relying on the fixed decay; treat that as an
allowed (flag-it-in-report) enhancement, not scope creep. The mic-analysis
AudioContext isolation rule stands.
7. **CANON:** factory patches must survive `migrateSeq` with the NEW fields
(len arrays, `wlen` once WHEELS lands, `locked`, `kit`) — author them by
exporting real patches from a post-WHEELS build, never by hand-writing JSON.
8. **Regression additions to §0.7:** the groove self-check must stay green
(it now asserts len-array migration), and the god's-eye skin must still cycle
(right-click → skin) with zero console errors — it shares the overlay/render
path some of you will be near.
9. **Deploy note for ω (the closer):** the production box is currently TWO waves
behind and its venv still lacks `sgp4` — the deploy checklist you prepare must
start with `/home/humanjing/godstrument/.venv/bin/pip install sgp4`, then the
CLAUDE.md filtered rsync, then restart. GODSPEAK stays local-rig-only (inert
on the VPS); `mlx-whisper` must never enter `requirements.txt`.
---
## Part 0 — shared context ## Part 0 — shared context
### 0.1 The four revelations ### 0.1 The four revelations

46
auth.py
View File

@ -30,6 +30,8 @@ import time
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_DB = os.path.join(HERE, "godstrument_users.db") DEFAULT_DB = os.path.join(HERE, "godstrument_users.db")
FACTORY_DIR = os.path.join(HERE, "factory") # ✦ CANON — git-tracked, read-only patches that ship with the instrument
FACTORY_PREFIX = "" # "✦ " — the mark of a factory dimension (also the read-only guard key)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$") USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$")
MIN_PW = 8 MIN_PW = 8
@ -403,6 +405,42 @@ def delete_preset(user_id: int, name: str, db: str = DEFAULT_DB) -> bool:
return cur.rowcount > 0 return cur.rowcount > 0
# ---- ✦ CANON — factory dimensions (git-tracked, read-only, shared by everyone) ---
def list_factory() -> list[dict]:
"""The shipped ✦ patches, newest-name-order stable. Silent if factory/ is absent."""
out = []
try:
for fn in sorted(os.listdir(FACTORY_DIR)):
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f:
name = (json.load(f).get("name") or "").strip()
if name.startswith(FACTORY_PREFIX):
out.append({"name": name, "updated": 0, "factory": True})
except (OSError, ValueError):
continue
except OSError:
pass
return out
def load_factory(name: str):
"""Load a factory patch by its ✦-prefixed name. Guards against path traversal
(the name never touches the filesystem we match on the JSON's own name field)."""
for fn in os.listdir(FACTORY_DIR) if os.path.isdir(FACTORY_DIR) else []:
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(FACTORY_DIR, fn), encoding="utf-8") as f:
obj = json.load(f)
if (obj.get("name") or "").strip() == name:
return obj.get("data")
except (OSError, ValueError):
continue
return None
# ---- HTTP dispatch (called by hub.py's request handler) --------------------- # ---- HTTP dispatch (called by hub.py's request handler) ---------------------
COOKIE = "gs_session" COOKIE = "gs_session"
@ -498,13 +536,17 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str,
return 404, {"error": "not found"}, None return 404, {"error": "not found"}, None
if path == "/api/presets" and method == "GET": if path == "/api/presets" and method == "GET":
return 200, {"presets": list_presets(uid, db)}, None # ✦ CANON — the factory shelf first, then the player's own saved dimensions
return 200, {"presets": list_factory() + list_presets(uid, db)}, None
if path.startswith("/api/presets/"): if path.startswith("/api/presets/"):
name = _unquote(path[len("/api/presets/"):]) name = _unquote(path[len("/api/presets/"):])
is_factory = name.startswith(FACTORY_PREFIX)
if method == "GET": if method == "GET":
data = load_preset(uid, name, db) data = load_factory(name) if is_factory else load_preset(uid, name, db)
return (200, {"name": name, "data": data}, None) if data is not None else (404, {"error": "no such preset"}, None) return (200, {"name": name, "data": data}, None) if data is not None else (404, {"error": "no such preset"}, None)
if is_factory: # ✦ factory dimensions are read-only — never write or delete them
return 403, {"error": "factory dimensions are read-only — save it under a new name"}, None
if method in ("PUT", "POST"): if method in ("PUT", "POST"):
return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None) return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None)
if method == "DELETE": if method == "DELETE":

View File

@ -30,7 +30,7 @@
"groups": { "groups": {
"planet": {"label": "the local world", "orbit": "earth", "members": ["weather.temp", "weather.wind", "weather.pressure", "air.pm25", "sky.day", "sky.elev"]}, "planet": {"label": "the local world", "orbit": "earth", "members": ["weather.temp", "weather.wind", "weather.pressure", "air.pm25", "sky.day", "sky.elev"]},
"cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel"]}, "cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel", "sats.best_elev"]},
"human": {"label": "humanity", "orbit": "mars", "members": ["wiki.rate", "clock.popvel", "clock.births"]}, "human": {"label": "humanity", "orbit": "mars", "members": ["wiki.rate", "clock.popvel", "clock.births"]},
"market": {"label": "your market", "orbit": "mercury", "members": ["crypto.vel", "market.velocity", "market.turnover"]}, "market": {"label": "your market", "orbit": "mercury", "members": ["crypto.vel", "market.velocity", "market.turnover"]},
"heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]}, "heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]},
@ -96,12 +96,20 @@
"sport.kickoff": {"type": "event", "norm": {"lo": 0, "hi": 5}, "decay": 3.0, "label": "kickoff!"}, "sport.kickoff": {"type": "event", "norm": {"lo": 0, "hi": 5}, "decay": 3.0, "label": "kickoff!"},
"quake.event": {"type": "event", "norm": {"lo": 0, "hi": 7}, "decay": 3.5, "label": "earthquake"}, "quake.event": {"type": "event", "norm": {"lo": 0, "hi": 7}, "decay": 3.5, "label": "earthquake"},
"quake.depth": {"norm": {"lo": 0, "hi": 300}, "label": "quake depth"}, "quake.depth": {"norm": {"lo": 0, "hi": 300}, "label": "quake depth"},
"quake.lat": {"norm": {"lo": -90, "hi": 90}, "label": "last quake latitude"},
"quake.lon": {"norm": {"lo": -180, "hi": 180}, "label": "last quake longitude"},
"wiki.edit.event": {"type": "event", "norm": {"lo": 0, "hi": 3000}, "attack": 0.005, "decay": 0.25, "label": "wiki edit"}, "wiki.edit.event": {"type": "event", "norm": {"lo": 0, "hi": 3000}, "attack": 0.005, "decay": 0.25, "label": "wiki edit"},
"wiki.rate": {"norm": {"mode": "minmax", "window": 200}, "filter": {"min_cutoff": 0.8, "beta": 0.03}, "label": "global edit rate"}, "wiki.rate": {"norm": {"mode": "minmax", "window": 200}, "filter": {"min_cutoff": 0.8, "beta": 0.03}, "label": "global edit rate"},
"planes.count": {"norm": {"mode": "minmax", "window": 100}, "label": "aircraft aloft"}, "planes.count": {"norm": {"mode": "minmax", "window": 100}, "label": "aircraft aloft"},
"planes.avgalt": {"norm": {"lo": 0, "hi": 13000}, "label": "avg altitude"}, "planes.avgalt": {"norm": {"lo": 0, "hi": 13000}, "label": "avg altitude"},
"planes.avgspeed": {"norm": {"lo": 0, "hi": 300}, "label": "avg airspeed"}, "planes.avgspeed": {"norm": {"lo": 0, "hi": 300}, "label": "avg airspeed"},
"iss.vel": {"norm": {"mode": "minmax", "window": 60}, "label": "ISS ground speed"}, "iss.vel": {"norm": {"mode": "minmax", "window": 60}, "label": "ISS ground speed"},
"sats.best_elev": {"norm": {"lo": -30, "hi": 90}, "filter": {"min_cutoff": 0.3, "beta": 0.005}, "label": "highest satellite elevation"},
"sats.overhead_count": {"norm": {"lo": 0, "hi": 8}, "label": "satellites overhead (>10°)"},
"sats.iss_lat": {"norm": {"lo": -90, "hi": 90}, "label": "ISS latitude"},
"sats.iss_lon": {"norm": {"lo": -180, "hi": 180}, "label": "ISS longitude"},
"sats.recon_elev": {"norm": {"lo": -30, "hi": 90}, "filter": {"min_cutoff": 0.3, "beta": 0.005}, "label": "recon-satellite elevation"},
"sats.pass.event": {"type": "event", "norm": {"lo": 0, "hi": 90}, "decay": 3.0, "label": "satellite pass (rising 30°)"},
"tof.cx": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand X (ToF)"}, "tof.cx": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand X (ToF)"},
"tof.cy": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand Y (ToF)"}, "tof.cy": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand Y (ToF)"},
"tof.near": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand proximity"}, "tof.near": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand proximity"},
@ -112,6 +120,8 @@
"audio.centroid": {"norm": {"lo": 200, "hi": 6000}, "label": "room brightness"}, "audio.centroid": {"norm": {"lo": 200, "hi": 6000}, "label": "room brightness"},
"sky.elev": {"norm": {"lo": -30, "hi": 75}, "filter": {"min_cutoff": 0.15, "beta": 0.002}, "label": "local sun elevation"}, "sky.elev": {"norm": {"lo": -30, "hi": 75}, "filter": {"min_cutoff": 0.15, "beta": 0.002}, "label": "local sun elevation"},
"sky.day": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.15, "beta": 0.002}, "label": "local day/night"}, "sky.day": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.15, "beta": 0.002}, "label": "local day/night"},
"sky.lat": {"norm": {"lo": -90, "hi": 90}, "label": "venue latitude"},
"sky.lon": {"norm": {"lo": -180, "hi": 180}, "label": "venue longitude"},
"clock.births": {"norm": {"lo": 0, "hi": 8}, "label": "births / sec"}, "clock.births": {"norm": {"lo": 0, "hi": 8}, "label": "births / sec"},
"clock.deaths": {"norm": {"lo": 0, "hi": 5}, "label": "deaths / sec"}, "clock.deaths": {"norm": {"lo": 0, "hi": 5}, "label": "deaths / sec"},
"clock.popvel": {"norm": {"lo": 0, "hi": 4}, "label": "population growth / sec"}, "clock.popvel": {"norm": {"lo": 0, "hi": 4}, "label": "population growth / sec"},
@ -169,6 +179,9 @@
{"source": "weather.wind", "dest": "lfo.rate", "amount": 0.55, "curve": "lin", "label": "wind speed = modulation rate"}, {"source": "weather.wind", "dest": "lfo.rate", "amount": 0.55, "curve": "lin", "label": "wind speed = modulation rate"},
{"source": "weather.pressure","dest": "drone.voices", "amount": 0.4, "curve": "lin", "label": "barometric pressure = drone weight"}, {"source": "weather.pressure","dest": "drone.voices", "amount": 0.4, "curve": "lin", "label": "barometric pressure = drone weight"},
{"source": "iss.vel", "dest": "wavetable.morph", "amount": 0.4, "curve": "lin", "label": "the ISS drifts the timbre"}, {"source": "iss.vel", "dest": "wavetable.morph", "amount": 0.4, "curve": "lin", "label": "the ISS drifts the timbre"},
{"source": "sats.best_elev", "dest": "pad.brightness", "amount": 0.4, "curve": "scurve", "label": "something overhead brightens the pads"},
{"source": "sats.pass.event", "dest": "delay.feedback", "amount": 0.7, "label": "a satellite pass throws the delay"},
{"source": "sats.recon_elev", "dest": "lfo.rate", "amount": 0.35, "curve": "lin", "label": "the sky watching back sets the LFO"},
{"source": "tof.cx", "dest": "wavetable.morph", "amount": 0.9, "curve": "lin", "label": "your hand X morphs the wavetable"}, {"source": "tof.cx", "dest": "wavetable.morph", "amount": 0.9, "curve": "lin", "label": "your hand X morphs the wavetable"},
{"source": "tof.cy", "dest": "delay.feedback", "amount": 0.7, "curve": "scurve", "label": "your hand Y feeds the delay"}, {"source": "tof.cy", "dest": "delay.feedback", "amount": 0.7, "curve": "scurve", "label": "your hand Y feeds the delay"},
{"source": "light.lux", "dest": "reverb.size", "amount": 0.5, "curve": "lin", "label": "room light sizes the reverb"}, {"source": "light.lux", "dest": "reverb.size", "amount": 0.5, "curve": "lin", "label": "room light sizes the reverb"},

View File

@ -0,0 +1,57 @@
{
"name": "✦ quiet arrival",
"data": {
"routes": [
{
"source": "astro.moon",
"dest": "echo.wet",
"amount": 0.6,
"curve": "lin",
"gate": null,
"quantize": null,
"label": "the moon opens the echo"
},
{
"source": "weather.wind",
"dest": "echo.time",
"amount": 0.5,
"curve": "scurve",
"gate": null,
"quantize": null,
"label": "the wind whips the tape speed"
},
{
"source": "sky.elev",
"dest": "pad.brightness",
"amount": 0.7,
"curve": "scurve",
"gate": null,
"quantize": null,
"label": "the sun brightens the pads"
},
{
"source": "sun.speed",
"dest": "reverb.size",
"amount": 0.5,
"curve": "lin",
"gate": null,
"quantize": null,
"label": "the solar wind sizes the room"
}
],
"tweaks": {},
"seqs": {},
"bpm": 64,
"groove": {
"amount": 1,
"preset": "straight",
"swing": 0,
"humanize": 0,
"velo": 0
},
"scale": {
"root": 5,
"name": "major"
}
}
}

479
factory/the_heartbeat.json Normal file
View File

@ -0,0 +1,479 @@
{
"name": "✦ the heartbeat",
"data": {
"routes": [],
"tweaks": {},
"seqs": {
"drum.kick": {
"on": true,
"steps": [
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
0,
0
],
"vel": [
0.95,
0.8,
0.8,
0.8,
0.8,
0.8,
0.7,
0.8,
0.8,
0.8,
0.75,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.snare": {
"on": true,
"steps": [
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
1
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.hat": {
"on": true,
"steps": [
1,
0,
1,
0,
1,
0,
1,
0,
1,
0,
1,
0,
1,
0,
1,
0
],
"vel": [
0.7,
0.4,
0.6,
0.4,
0.7,
0.4,
0.6,
0.4,
0.7,
0.4,
0.6,
0.4,
0.7,
0.4,
0.6,
0.5
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.ohat": {
"on": true,
"steps": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
}
},
"bpm": 88,
"groove": {
"amount": 1,
"preset": "mpc 8-4",
"swing": 0.62,
"humanize": 0.12,
"velo": 0.18
},
"scale": {
"root": 0,
"name": "minor_pent"
},
"kit": {
"kick": {
"tune": 0.44,
"punch": 0.6,
"decay": 0.55,
"duck": 0.5,
"level": 0.9,
"pan": 0.5
}
}
}
}

376
factory/the_squeeze.json Normal file
View File

@ -0,0 +1,376 @@
{
"name": "✦ the squeeze",
"data": {
"routes": [
{
"source": "crypto.vel",
"dest": "squash",
"amount": 0.9,
"curve": "lin",
"gate": null,
"quantize": null,
"label": "the market pumps the master"
},
{
"source": "quake.event",
"dest": "squash",
"amount": 0.7,
"curve": "exp3",
"gate": null,
"quantize": null,
"label": "a quake heaves the whole mix"
}
],
"tweaks": {},
"seqs": {
"drum.kick": {
"on": true,
"steps": [
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.hat": {
"on": true,
"steps": [
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.snare": {
"on": true,
"steps": [
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
}
},
"bpm": 120,
"groove": {
"amount": 1,
"preset": "straight",
"swing": 0,
"humanize": 0.03,
"velo": 0.12
},
"scale": {
"root": 0,
"name": "minor_pent"
}
}
}

View File

@ -0,0 +1,357 @@
{
"name": "✦ the wheel within the wheel",
"data": {
"routes": [],
"tweaks": {},
"seqs": {
"drum.kick": {
"on": true,
"steps": [
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0,
1,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 16
},
"drum.hat": {
"on": true,
"steps": [
1,
0,
0,
1,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 12
},
"drum.clap": {
"on": true,
"steps": [
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"vel": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"chance": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"rat": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"lock": [
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1,
-1
],
"len": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"wlen": 10
}
},
"bpm": 96,
"groove": {
"amount": 1,
"preset": "swing 16",
"swing": 0.55,
"humanize": 0.05,
"velo": 0.1
},
"scale": {
"root": 0,
"name": "minor_pent"
}
}
}

5
hub.py
View File

@ -25,6 +25,7 @@ import json
import math import math
import os import os
import queue import queue
import re
import threading import threading
import time import time
from functools import partial from functools import partial
@ -406,7 +407,9 @@ class Hub:
if not user: # local/trusted hub: take the claimed name if not user: # local/trusted hub: take the claimed name
user = str(c.get("from") or "guest")[:24] user = str(c.get("from") or "guest")[:24]
self.cnames[ws] = user self.cnames[ws] = user
room = str(c.get("room") or "")[:40].strip().lower() # room names are rendered by the clients — strip anything markup-ish here too,
# so a hostile name can never reach a browser even if a client forgets to escape
room = re.sub(r"[<>&\"'`]", "", str(c.get("room") or ""))[:40].strip().lower()
R = self.communes R = self.communes
def out(names, obj): def out(names, obj):

View File

@ -2,6 +2,11 @@
python-osc>=1.9 python-osc>=1.9
websockets>=12.0 websockets>=12.0
# Satellites worker (workers/world_sats.py) — SGP4 propagation from Celestrak TLEs
# pip install sgp4 # pure-python, no build; worker is import-safe without it
# Sky workers (workers/world_ephemeris.py, world_almanac.py) — Swiss Ephemeris planets
# pip install pyswisseph # both workers are import-safe without it (they retire quietly)
# Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port # Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port
# pip install python-rtmidi # pip install python-rtmidi
# Optional: microphone FFT worker (workers/audio_worker.py) # Optional: microphone FFT worker (workers/audio_worker.py)

8
run.py
View File

@ -33,6 +33,7 @@ WORLD = [
"workers/world_air.py", "workers/world_air.py",
"workers/world_sun.py", "workers/world_sun.py",
"workers/world_iss.py", "workers/world_iss.py",
"workers/world_sats.py",
"workers/world_crypto.py", "workers/world_crypto.py",
"workers/world_fx.py", "workers/world_fx.py",
"workers/world_planes.py", "workers/world_planes.py",
@ -49,7 +50,7 @@ WORLD = [
] ]
# workers that retarget to the venue city # workers that retarget to the venue city
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py"} LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py", "world_sats.py"}
BBOX_WORKERS = {"world_planes.py"} BBOX_WORKERS = {"world_planes.py"}
@ -203,6 +204,11 @@ def main():
now = time.time() now = time.time()
for w in workers: # revive any worker that died for w in workers: # revive any worker that died
if w["proc"].poll() is not None and now >= w["next_ok"]: if w["proc"].poll() is not None and now >= w["next_ok"]:
if w["proc"].returncode == 0: # exit 0 = a deliberate, polite goodbye
if not w.get("retired"): # (missing dep etc.) — don't churn it
w["retired"] = True
print(f" ({os.path.basename(w['path'])} exited cleanly; retired)")
continue
print(f" ({os.path.basename(w['path'])} died; restarting)") print(f" ({os.path.basename(w['path'])} died; restarting)")
np = launch(w["path"], w["extra"]) np = launch(w["path"], w["extra"])
if np: if np:

91
test_factory.py Normal file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""test_factory.py — validate the ✦ CANON factory patches' SHAPE.
Not a taste check (the human re-voices by ear) this asserts every factory
patch loads cleanly through the client's migrateSeq expectations: 16-step seq
arrays, wlen in 1..16, sane bpm, valid route/groove/scale shape, and a
-prefixed name. Stdlib only; excluded from the deploy by the test_*.py filter.
python3 test_factory.py
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
FACTORY = os.path.join(HERE, "factory")
SCALES = {"chromatic", "major", "minor", "dorian", "phrygian", "lydian",
"mixolydian", "pentatonic", "minor_pent", "wholetone", "hirajoshi"}
SEQ_ARRAYS = ("steps", "vel", "chance", "rat", "lock", "len")
def check_seq(key, s, errs):
for a in SEQ_ARRAYS:
if a in s:
if not isinstance(s[a], list) or len(s[a]) != 16:
errs.append(f"{key}.{a} must be a 16-length array")
if "steps" not in s or not isinstance(s.get("steps"), list) or len(s["steps"]) != 16:
errs.append(f"{key}.steps missing/not-16 (migrateSeq would reject → dropped)")
if "wlen" in s and not (isinstance(s["wlen"], int) and 1 <= s["wlen"] <= 16):
errs.append(f"{key}.wlen must be an int 1..16, got {s.get('wlen')!r}")
if "on" in s and not isinstance(s["on"], (bool, int)):
errs.append(f"{key}.on must be boolean-ish")
def check_patch(name, p, errs):
if not isinstance(p.get("bpm"), (int, float)) or not (40 <= p["bpm"] <= 180):
errs.append(f"bpm must be 40..180, got {p.get('bpm')!r}")
for r in p.get("routes", []):
if not r.get("source") or not r.get("dest"):
errs.append("route missing source/dest")
if not isinstance(r.get("amount"), (int, float)):
errs.append(f"route {r.get('source')}{r.get('dest')} amount not numeric")
for key, s in (p.get("seqs") or {}).items():
check_seq(key, s, errs)
sc = p.get("scale") or {}
if sc and sc.get("name") not in SCALES:
errs.append(f"scale.name {sc.get('name')!r} not a known scale")
gr = p.get("groove") or {}
for k in ("swing", "humanize", "velo"):
if k in gr and not (0 <= gr[k] <= 1):
errs.append(f"groove.{k} out of 0..1")
def main():
if not os.path.isdir(FACTORY):
print("FAIL: no factory/ dir"); sys.exit(1)
files = sorted(f for f in os.listdir(FACTORY) if f.endswith(".json"))
if len(files) < 4:
print(f"FAIL: expected ≥4 factory patches, found {len(files)}"); sys.exit(1)
total_errs = 0
names = []
for fn in files:
with open(os.path.join(FACTORY, fn), encoding="utf-8") as f:
obj = json.load(f)
name, data = obj.get("name", ""), obj.get("data")
errs = []
if not name.startswith(""):
errs.append(f"name {name!r} must start with ''")
if not isinstance(data, dict):
errs.append("missing 'data' object")
else:
check_patch(name, data, errs)
names.append(name)
if errs:
total_errs += len(errs)
print(f"{fn}: " + "; ".join(errs))
else:
nseq = len(data.get("seqs") or {})
nroute = len(data.get("routes") or [])
print(f"{name} (bpm {data['bpm']}, {nseq} seqs, {nroute} routes)")
# the four the brief names
for want in ("✦ the heartbeat", "✦ the wheel within the wheel", "✦ the squeeze", "✦ quiet arrival"):
if want not in names:
print(f" ✗ missing canonical patch: {want}"); total_errs += 1
if total_errs:
print(f"\nFAIL: {total_errs} shape error(s)"); sys.exit(1)
print(f"\nALL {len(files)} FACTORY PATCHES VALID (shape only — beauty is the human's)")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

203
viz/spheres.js Normal file
View File

@ -0,0 +1,203 @@
// vendored from SOLARGOD@6391273 — do not edit; re-vendor to update.
// This is js/ephem.js (whole) with the minimal js/lib.js subset it needs inlined
// (centuriesSinceJ2000, normDegPM180, DEG, J2000_JD, jdFromUnixMs) so the file
// stands alone with no import of Three.js or the rest of SOLARGOD. The IIFE in
// index.html imports from here via a <script type="module"> bridge that hangs a
// frozen window.SPHERES. Godstrument computes the sky itself, deterministically,
// at its own musical clock — no network, no server, no sockets.
// ---- lib.js subset (SOLARGOD@6391273/js/lib.js — pure, dependency-free) ----
export const MS_PER_DAY = 86400000;
export const J2000_JD = 2451545.0; // 2000-Jan-01 12:00 TT
export const UNIX_EPOCH_JD = 2440587.5; // JD of 1970-Jan-01 00:00 UTC
export const DEG = Math.PI / 180;
// JD from unix-ms: divide by ms/day, add the unix-epoch JD.
export function jdFromUnixMs(ms) { return ms / MS_PER_DAY + UNIX_EPOCH_JD; }
// Julian centuries since J2000.0.
export function centuriesSinceJ2000(jd) { return (jd - J2000_JD) / 36525; }
// Normalize degrees to (180, +180]. Must run in float64 AFTER any large
// subtraction (Mercury's L rate is 149473°/Cy → M can be tens of thousands of
// degrees before reduction).
export function normDegPM180(d) {
d = d % 360;
if (d <= -180) d += 360;
else if (d > 180) d -= 360;
return d;
}
// ===========================================================================
// js/ephem.js — JPL SSD "Approximate Positions of the Planets" (Standish).
// Tables extracted 2026-07-15 from https://ssd.jpl.nasa.gov/planets/approx_pos.html
// — authoritative, verbatim. Angles in degrees, rates per Julian century; `e` is
// dimensionless (the page's "rad" header for e is its own typo). No n-body
// integration — this IS the spec.
// Row layout: el = [a(au), e, I(deg), L(deg), ϖ long.peri(deg), Ω long.node(deg)]
// rate = same order, per century.
const TABLE1 = {
mercury: { el: [0.38709927, 0.20563593, 7.00497902, 252.25032350, 77.45779628, 48.33076593],
rate: [0.00000037, 0.00001906, -0.00594749, 149472.67411175, 0.16047689, -0.12534081] },
venus: { el: [0.72333566, 0.00677672, 3.39467605, 181.97909950, 131.60246718, 76.67984255],
rate: [0.00000390, -0.00004107, -0.00078890, 58517.81538729, 0.00268329, -0.27769418] },
earth: { el: [1.00000261, 0.01671123, -0.00001531, 100.46457166, 102.93768193, 0.0],
rate: [0.00000562, -0.00004392, -0.01294668, 35999.37244981, 0.32327364, 0.0] },
mars: { el: [1.52371034, 0.09339410, 1.84969142, -4.55343205, -23.94362959, 49.55953891],
rate: [0.00001847, 0.00007882, -0.00813131, 19140.30268499, 0.44441088, -0.29257343] },
jupiter: { el: [5.20288700, 0.04838624, 1.30439695, 34.39644051, 14.72847983, 100.47390909],
rate: [-0.00011607, -0.00013253, -0.00183714, 3034.74612775, 0.21252668, 0.20469106] },
saturn: { el: [9.53667594, 0.05386179, 2.48599187, 49.95424423, 92.59887831, 113.66242448],
rate: [-0.00125060, -0.00050991, 0.00193609, 1222.49362201, -0.41897216, -0.28867794] },
uranus: { el: [19.18916464, 0.04725744, 0.77263783, 313.23810451, 170.95427630, 74.01692503],
rate: [-0.00196176, -0.00004397, -0.00242939, 428.48202785, 0.40805281, 0.04240589] },
neptune: { el: [30.06992276, 0.00859048, 1.77004347, -55.12002969, 44.96476227, 131.78422574],
rate: [0.00026291, 0.00005105, 0.00035372, 218.45945325, -0.32241464, -0.00508664] },
};
// Table 2a — valid 3000 BC3000 AD. Shipped for the extended-range config swap
// (CONFIG.useExtendedRange); unused by v1 UI.
const TABLE2A = {
mercury: { el: [0.38709843, 0.20563661, 7.00559432, 252.25166724, 77.45771895, 48.33961819],
rate: [0.00000000, 0.00002123, -0.00590158, 149472.67486623, 0.15940013, -0.12214182] },
venus: { el: [0.72332102, 0.00676399, 3.39777545, 181.97970850, 131.76755713, 76.67261496],
rate: [-0.00000026, -0.00005107, 0.00043494, 58517.81560260, 0.05679648, -0.27274174] },
earth: { el: [1.00000018, 0.01673163, -0.00054346, 100.46691572, 102.93005885, -5.11260389],
rate: [-0.00000003, -0.00003661, -0.01337178, 35999.37306329, 0.31795260, -0.24123856] },
mars: { el: [1.52371243, 0.09336511, 1.85181869, -4.56813164, -23.91744784, 49.71320984],
rate: [0.00000097, 0.00009149, -0.00724757, 19140.29934243, 0.45223625, -0.26852431] },
jupiter: { el: [5.20248019, 0.04853590, 1.29861416, 34.33479152, 14.27495244, 100.29282654],
rate: [-0.00002864, 0.00018026, -0.00322699, 3034.90371757, 0.18199196, 0.13024619] },
saturn: { el: [9.54149883, 0.05550825, 2.49424102, 50.07571329, 92.86136063, 113.63998702],
rate: [-0.00003065, -0.00032044, 0.00451969, 1222.11494724, 0.54179478, -0.25015002] },
uranus: { el: [19.18797948, 0.04685740, 0.77298127, 314.20276625, 172.43404441, 73.96250215],
rate: [-0.00020455, -0.00001550, -0.00180155, 428.49512595, 0.09266985, 0.05739699] },
neptune: { el: [30.06952752, 0.00895439, 1.77005520, 304.22289287, 46.68158724, 131.78635853],
rate: [0.00006447, 0.00000818, 0.00022400, 218.46515314, 0.01009938, -0.00606302] },
};
// Table 2b — extra terms added to M (deg) for JupiterNeptune, Table 2a only:
// M += b·T² + c·cos(f·T) + s·sin(f·T). [b, c, s, f]
const TABLE2B = {
jupiter: [-0.00012452, 0.06064060, -0.35635438, 38.35125000],
saturn: [0.00025899, -0.13434469, 0.87320147, 38.35125000],
uranus: [0.00058331, -0.97731848, 0.17689245, 7.67025000],
neptune: [-0.00041348, 0.68346318, -0.10162547, 7.67025000],
};
export const PLANET_IDS = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
let useExtended = false;
export function setExtendedRange(on) { useExtended = !!on; }
// Solve Kepler's equation E e·sinE = M (radians) by NewtonRaphson.
// Seed E₀ = M + e·sinM; converges in a few iters even for e→0.97 (verify gate).
export function solveKepler(M, e, tol = 1e-9, maxIter = 20) {
let E = M + e * Math.sin(M);
for (let i = 0; i < maxIter; i++) {
const dE = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
E -= dE;
if (Math.abs(dE) < tol) break;
}
return E;
}
// The reusable core: resolved classical elements → heliocentric J2000 ecliptic
// {x,y,z} in AU. a[au], e, and iDeg/OmDeg(Ω)/wDeg(ω arg.peri)/Mdeg all in degrees.
// Shared by planets (element+rate) and small bodies (epoch elements) — brief §3.
export function eclFromElements(a, e, iDeg, OmDeg, wDeg, Mdeg, out = {}) {
const M = normDegPM180(Mdeg) * DEG;
const E = solveKepler(M, e);
// Orbital-plane coords (perifocal), AU.
const xp = a * (Math.cos(E) - e);
const yp = a * Math.sqrt(1 - e * e) * Math.sin(E);
const w = wDeg * DEG, Om = OmDeg * DEG, I = iDeg * DEG;
const cw = Math.cos(w), sw = Math.sin(w);
const cO = Math.cos(Om), sO = Math.sin(Om);
const cI = Math.cos(I), sI = Math.sin(I);
// Perifocal → ecliptic rotation (brief §3 step 5 = standard R_z(Ω)R_x(I)R_z(ω)).
out.x = (cw * cO - sw * sO * cI) * xp + (-sw * cO - cw * sO * cI) * yp;
out.y = (cw * sO + sw * cO * cI) * xp + (-sw * sO + cw * cO * cI) * yp;
out.z = (sw * sI) * xp + (cw * sI) * yp;
return out;
}
// Resolve a planet's elements at jd (Table 1, 18002050) → {a,e,I,Om,w,M} deg.
function resolvePlanet(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE1[bodyId];
if (!row) throw new Error(`ephem: unknown planet ${bodyId}`);
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T; // ϖ longitude of perihelion
const Om = row.el[5] + row.rate[5] * T; // Ω longitude of ascending node
const w = wb - Om; // ω argument of perihelion
const M = L - wb; // mean anomaly (normalize AFTER subtract)
return { a, e, I, Om, w, M };
}
// Extended-range resolver (Table 2a + the 2b M-correction for JupiterNeptune,
// valid 3000 BC3000 AD). f·T uses T in centuries; f is deg-per-century so f·T
// is degrees → convert to rad for the trig. Only used when useExtended is set.
function resolvePlanetExt(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE2A[bodyId];
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T;
const Om = row.el[5] + row.rate[5] * T;
const w = wb - Om;
let M = L - wb;
const b2 = TABLE2B[bodyId];
if (b2) {
const [b, c, s, f] = b2;
M += b * T * T + c * Math.cos(f * T * DEG) + s * Math.sin(f * T * DEG);
}
return { a, e, I, Om, w, M };
}
// Public: heliocentric J2000 ecliptic position of a planet at jd, AU.
export function helioEcl(bodyId, jd, out = {}) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return eclFromElements(p.a, p.e, p.I, p.Om, p.w, p.M, out);
}
// Mean motion [deg/day] from semi-major axis via Kepler's third law (Gaussian).
export function meanMotionDegPerDay(a) { return 0.9856076686 / Math.pow(a, 1.5); }
// Resolved semi-major axis [AU] of a planet at jd (for vis-viva speed, year length).
export function semiMajor(bodyId, jd) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return p.a;
}
// Small-body position (asteroids/comets/Pluto): epoch elements propagated by
// M = M0 + n·(jd epoch). el = {a,e,i,om(Ω),w(ω),ma(M0),epoch}.
export function smallBodyEcl(el, jd, out = {}) {
const n = meanMotionDegPerDay(el.a);
const M = el.ma + n * (jd - el.epoch);
return eclFromElements(el.a, el.e, el.i, el.om, el.w, M, out);
}
// One full orbit sampled in mean anomaly, as a Float64Array of xyz triples
// (nSamples+1 points; last == first so a LineLoop/LineSegments closes cleanly —
// verify gate "orbitPath endpoints join"). Elements frozen at jd (they drift
// slowly; caller rebuilds only when |ΔT| > 0.1 Cy, brief §11).
export function orbitPath(bodyId, jd, nSamples = 256) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return samplePath(p.a, p.e, p.I, p.Om, p.w, nSamples);
}
export function orbitPathFromElements(el, nSamples = 256) {
return samplePath(el.a, el.e, el.i, el.om, el.w, nSamples);
}
function samplePath(a, e, I, Om, w, nSamples) {
const out = new Float64Array((nSamples + 1) * 3);
const tmp = {};
for (let k = 0; k <= nSamples; k++) {
const M = (360 * k) / nSamples; // 0..360; k=nSamples reproduces k=0
eclFromElements(a, e, I, Om, w, M, tmp);
out[k * 3] = tmp.x; out[k * 3 + 1] = tmp.y; out[k * 3 + 2] = tmp.z;
}
return out;
}

View File

@ -27,7 +27,12 @@ import datetime
import math import math
import time import time
import swisseph as swe try: # house rule: import-safe (see world_sats.py) — a
import swisseph as swe # missing pyswisseph retires this worker quietly
except Exception: # rather than crash-looping under the supervisor.
import sys
print("world_almanac: pyswisseph not installed — skipping (optional)")
sys.exit(0)
from pythonosc.udp_client import SimpleUDPClient from pythonosc.udp_client import SimpleUDPClient
FLG = swe.FLG_MOSEPH | swe.FLG_SPEED FLG = swe.FLG_MOSEPH | swe.FLG_SPEED

View File

@ -26,7 +26,12 @@ import itertools
import math import math
import time import time
import swisseph as swe try: # house rule: import-safe. Without pyswisseph this
import swisseph as swe # worker retires quietly (exit 0) instead of crash-
except Exception: # looping every 10s under run.py's supervisor.
import sys
print("world_ephemeris: pyswisseph not installed — skipping (optional)")
sys.exit(0)
from pythonosc.udp_client import SimpleUDPClient from pythonosc.udp_client import SimpleUDPClient
FLG = swe.FLG_MOSEPH | swe.FLG_SPEED FLG = swe.FLG_MOSEPH | swe.FLG_SPEED

View File

@ -5,6 +5,7 @@ and mean groundspeed (intensity) — the sky's traffic becomes a slow chord.
""" """
import argparse import argparse
import json import json
import os
import sys import sys
import time import time
import urllib.error import urllib.error
@ -23,6 +24,57 @@ POLL_OK = 450.0 # normal poll interval (s)
POLL_429 = 600.0 # backoff poll interval after HTTP 429 POLL_429 = 600.0 # backoff poll interval after HTTP 429
REEMIT = 10.0 # re-emit last values every 10s REEMIT = 10.0 # re-emit last values every 10s
# --- OpenSky shared cache v1 (reader side) --------------------------------
# Path: ~/.cache/godverse/opensky-states.json, override via OPENSKY_CACHE_FILE.
# Content: the raw, unmodified OpenSky /states/all body (global, no bbox), written
# atomically by WHOEVER fetched it (GODSIGH's dev proxy is the writer). Freshness =
# file mtime. We treat the cache as fresh within 120 s; on a fresh hit we skip our
# own HTTP entirely and just filter the global states to our bbox, so a godstrument
# + GODSIGH running together spend ~one poller's quota instead of two. This worker
# only ever polls a bbox, so it never WRITES the cache (never partial data into a
# global-contract file) — it is a pure reader, and degrades to its old bbox fetch
# when no writer is around (i.e. prod).
CACHE_FILE = os.environ.get("OPENSKY_CACHE_FILE") or \
os.path.expanduser("~/.cache/godverse/opensky-states.json")
CACHE_FRESH = 120.0 # a cache within this many seconds of its mtime is "fresh"
def read_cache(max_age):
"""Return (data, age_seconds) from the shared cache, or None. If max_age is not
None, returns None when the cache is older than that (i.e. not fresh)."""
try:
mtime = os.path.getmtime(CACHE_FILE)
except OSError:
return None
age = time.time() - mtime
if max_age is not None and age > max_age:
return None
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
return json.load(f), age
except (OSError, json.JSONDecodeError, ValueError):
return None
def filter_states_bbox(data, bbox):
"""Filter a global states payload to our bbox client-side (the cache is global).
OpenSky state vectors: index 5 = longitude, index 6 = latitude."""
s, w, n, e = bbox
states = (data or {}).get("states") or []
out = []
for row in states:
if not row or len(row) < 7:
continue
lon, lat = row[5], row[6]
if lon is None or lat is None:
continue
try:
if s <= float(lat) <= n and w <= float(lon) <= e:
out.append(row)
except (TypeError, ValueError):
continue
return {"states": out}
def fetch(bbox, timeout=25): def fetch(bbox, timeout=25):
"""Fetch OpenSky states for the bbox. Returns parsed JSON dict. """Fetch OpenSky states for the bbox. Returns parsed JSON dict.
@ -95,6 +147,15 @@ def main():
while True: while True:
now = time.monotonic() now = time.monotonic()
if now >= next_poll: if now >= next_poll:
cached = read_cache(CACHE_FRESH) # a fresh global cache -> skip HTTP entirely
if cached is not None:
data, age = cached
last = reduce_states(filter_states_bbox(data, args.bbox))
if poll != POLL_OK:
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
poll = POLL_OK
print(f"[{NAME}] cache hit ({age:.0f}s old) -> {int(last[0])} aircraft in bbox")
else:
try: try:
data = fetch(args.bbox) data = fetch(args.bbox)
last = reduce_states(data) last = reduce_states(data)
@ -111,9 +172,17 @@ def main():
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s") print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
else: else:
print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s") print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s")
_stale = read_cache(3600) # 429 -> a stale cache beats nothing,
if _stale is not None: # but a day-old sky beats neither
last = reduce_states(filter_states_bbox(_stale[0], args.bbox))
print(f"[{NAME}] serving stale cache ({_stale[1]:.0f}s old)")
except (urllib.error.URLError, TimeoutError, OSError, except (urllib.error.URLError, TimeoutError, OSError,
json.JSONDecodeError, ValueError) as ex: json.JSONDecodeError, ValueError) as ex:
print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s") print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s")
_stale = read_cache(3600) # network down -> fall back, bounded 1h
if _stale is not None:
last = reduce_states(filter_states_bbox(_stale[0], args.bbox))
print(f"[{NAME}] serving stale cache ({_stale[1]:.0f}s old)")
next_poll = time.monotonic() + poll next_poll = time.monotonic() + poll
# Emit (or re-emit) the last known values. # Emit (or re-emit) the last known values.

View File

@ -65,6 +65,9 @@ def main():
if fid is None or fid in seen: if fid is None or fid in seen:
continue continue
seen.add(fid) 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 {} props = feat.get("properties") or {}
mag = props.get("mag") mag = props.get("mag")

329
workers/world_sats.py Normal file
View File

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""world_sats.py — the sky's own schedule, as control signals (SGP4, no polling APIs).
Real satellites, propagated locally. TLEs (two-line element sets) are fetched once
from Celestrak and cached; from them SGP4 gives *any* satellite's position at *any*
rate with no per-fix HTTP the opposite of world_iss.py, which begs a web API for
every point. We track the space stations plus a few named recon birds (GAOFEN,
COSMOS), turn each into a lat/lon/altitude, and derive its **elevation above the
venue city's horizon** — so "something is passing overhead" becomes a continuous,
playable signal, and a recon satellite cresting the sky rings a bell.
Emits (raw floats; the hub normalizes):
/gs/sats/best_elev highest elevation among tracked sats, degrees (can be < 0)
/gs/sats/overhead_count how many are above 10 degrees right now
/gs/sats/iss_lat ISS geodetic latitude (the sweep, even without world_iss)
/gs/sats/iss_lon ISS geodetic longitude
/gs/sats/pass.event impulse (magnitude = elevation) when any sat crosses
rising through 30 degrees a pass begins
/gs/sats/recon_elev best elevation among the GAOFEN/COSMOS set only
Depends on `sgp4` (pure-python, `pip install sgp4`). Import-safe: if it is missing,
or there is no cached TLE and no network, the worker prints why and exits cleanly.
"""
from __future__ import annotations
import argparse
import datetime
import math
import os
import sys
import time
import urllib.error
import urllib.request
from pythonosc.udp_client import SimpleUDPClient
try:
from sgp4.api import Satrec, jday
HAVE_SGP4 = True
except Exception: # pure-python but still optional (house rule: import-safe)
HAVE_SGP4 = False
NAME = "world_sats"
UA = "Godstrument/1.0 (live instrument; contact monsterrobotparty@gmail.com)"
CACHE_DIR = os.path.expanduser("~/.cache/godverse")
CACHE_FILE = os.path.join(CACHE_DIR, "tles.txt")
CACHE_MAX_AGE = 24 * 3600.0 # Celestrak politeness — TLEs age slowly
REFRESH_EVERY = 6 * 3600.0 # re-check the cache age this often while running
# (url, is_recon) — the stations give us the ISS; the named queries are the watchers.
# A NAME query with no match returns the plain text "No GP data found"; parse_tles
# rejects any group whose first data line doesn't start with "1".
TLE_SOURCES = [
("https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle", False),
("https://celestrak.org/NORAD/elements/gp.php?NAME=GAOFEN&FORMAT=tle", True),
("https://celestrak.org/NORAD/elements/gp.php?NAME=COSMOS%202486&FORMAT=tle", True),
("https://celestrak.org/NORAD/elements/gp.php?NAME=COSMOS%202506&FORMAT=tle", True),
]
PASS_ELEV = 30.0 # a sat rising through this elevation rings the bell
OVERHEAD_ELEV = 10.0 # counted as "overhead" above this
ISS_CATNR = "25544" # NORAD catalog number of the ISS (ZARYA)
WGS_A = 6378.137 # WGS-84 semi-major axis, km
WGS_B = 6356.7523142 # semi-minor axis, km
def fetch_url(url, timeout=20):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", "replace")
def parse_tles(text):
"""Parse TLE text into [(name, line1, line2)]. Tolerant of 2-line groups and of
Celestrak's plain-text 'No GP data found' (any group whose line1 isn't a '1')."""
lines = [ln.rstrip() for ln in text.splitlines()
if ln.strip() and not ln.startswith("#")]
out = []
i = 0
while i < len(lines):
ln = lines[i]
if ln.startswith("1 ") and i + 1 < len(lines) and lines[i + 1].startswith("2 "):
out.append(("", ln, lines[i + 1])) # 2-line group, no name
i += 2
elif (not ln.startswith(("1 ", "2 "))) and i + 2 < len(lines) \
and lines[i + 1].startswith("1 ") and lines[i + 2].startswith("2 "):
out.append((ln.strip(), lines[i + 1], lines[i + 2])) # named 3-line group
i += 3
else:
i += 1 # junk / 'No GP data found' — skip
return out
def _write_cache(groups, recon_catnrs):
"""Cache the merged 3LE with a recon-catnr header so the tags survive a reload."""
try:
os.makedirs(CACHE_DIR, exist_ok=True)
tmp = CACHE_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write("# recon " + ",".join(sorted(recon_catnrs)) + "\n")
for name, l1, l2, _is_recon in groups:
f.write((name + "\n" if name else "") + l1 + "\n" + l2 + "\n")
os.replace(tmp, CACHE_FILE) # atomic
print(f"[{NAME}] refreshed TLE cache -> {CACHE_FILE}")
except OSError as e:
print(f"[{NAME}] warning: could not write cache: {e}")
def _read_cache():
"""Return (groups, recon_catnrs) from the tagged cache, or ([], set())."""
if not os.path.isfile(CACHE_FILE):
return [], set()
try:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
text = f.read()
except OSError:
return [], set()
recon = set()
for ln in text.splitlines():
if ln.startswith("# recon "):
recon = {x for x in ln[len("# recon "):].strip().split(",") if x}
break
groups = [(name, l1, l2, l1[2:7].strip() in recon) for name, l1, l2 in parse_tles(text)]
return groups, recon
def load_grouped_tles(force_fetch=False):
"""Return [(name, l1, l2, is_recon)] — a fresh cache if possible, else a fetch.
Fetches each source separately so recon birds keep their tag, writes the tagged
cache, and falls back to a stale cache on network failure. Returns [] only when
there is no cache and no network."""
fresh = False
if not force_fetch:
try:
fresh = os.path.isfile(CACHE_FILE) and (time.time() - os.path.getmtime(CACHE_FILE)) < CACHE_MAX_AGE
except OSError:
fresh = False
if fresh:
groups, _ = _read_cache()
if groups:
print(f"[{NAME}] using cached TLEs (< 24 h old)")
return groups
merged, recon_catnrs = [], set()
any_failed = False
for url, is_recon in TLE_SOURCES:
try:
txt = fetch_url(url)
except (urllib.error.URLError, OSError, ValueError) as e:
print(f"[{NAME}] TLE fetch failed for {url.split('?')[-1]}: {e}")
any_failed = True
continue
for name, l1, l2 in parse_tles(txt):
catnr = l1[2:7].strip()
if is_recon:
recon_catnrs.add(catnr)
merged.append((name, l1, l2, is_recon))
if merged:
if any_failed: # partial fetch: backfill the failed groups
have = {l1[2:7].strip() for _n, l1, _l2, _r in merged} # from the old cache so a
old, _old_recon = _read_cache() # half-set never clobbers it
for name, l1, l2, is_recon in old:
if l1[2:7].strip() not in have:
merged.append((name, l1, l2, is_recon))
if is_recon:
recon_catnrs.add(l1[2:7].strip())
_write_cache(merged, recon_catnrs)
return merged
groups, _ = _read_cache() # network down — serve stale
if groups:
print(f"[{NAME}] network down — using stale TLE cache")
return groups
def gstime(jdut1):
"""Greenwich Mean Sidereal Time (radians) — the IAU-82 series (matches satellite.js)."""
t = (jdut1 - 2451545.0) / 36525.0
g = 67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t
g = math.radians(g / 240.0) % (2 * math.pi) # seconds -> degrees (/240) -> radians
return g + 2 * math.pi if g < 0 else g
def eci_to_geodetic(r, gmst):
"""TEME position (km) + GMST -> (lat_deg, lon_deg, alt_km). WGS-84, iterative."""
x, y, z = r
a, b = WGS_A, WGS_B
f = (a - b) / a
e2 = 2 * f - f * f
R = math.hypot(x, y)
lon = math.atan2(y, x) - gmst
lon = (lon + math.pi) % (2 * math.pi) - math.pi # wrap to [-pi, pi]
lat = math.atan2(z, R)
C = 1.0
for _ in range(20): # converges in a handful of steps
C = 1.0 / math.sqrt(1 - e2 * math.sin(lat) ** 2)
lat = math.atan2(z + a * C * e2 * math.sin(lat), R)
alt = R / math.cos(lat) - a * C
return math.degrees(lat), math.degrees(lon), alt
def _ecef(lat_deg, lon_deg, alt_km):
a, b = WGS_A, WGS_B
la, lo = math.radians(lat_deg), math.radians(lon_deg)
f = (a - b) / a
e2 = 2 * f - f * f
N = a / math.sqrt(1 - e2 * math.sin(la) ** 2)
return ((N + alt_km) * math.cos(la) * math.cos(lo),
(N + alt_km) * math.cos(la) * math.sin(lo),
(N * (1 - e2) + alt_km) * math.sin(la))
def elevation(sat_lat, sat_lon, sat_alt, obs_lat, obs_lon):
"""Elevation angle (degrees) of a sat above an observer's local horizon."""
sx, sy, sz = _ecef(sat_lat, sat_lon, sat_alt)
ox, oy, oz = _ecef(obs_lat, obs_lon, 0.0)
rx, ry, rz = sx - ox, sy - oy, sz - oz
rng = math.sqrt(rx * rx + ry * ry + rz * rz)
if rng <= 0:
return 0.0
la, lo = math.radians(obs_lat), math.radians(obs_lon)
ux, uy, uz = math.cos(la) * math.cos(lo), math.cos(la) * math.sin(lo), math.sin(la)
dot = (rx * ux + ry * uy + rz * uz) / rng
return math.degrees(math.asin(max(-1.0, min(1.0, dot))))
class Sat:
__slots__ = ("name", "catnr", "rec", "is_recon", "prev_elev")
def __init__(self, name, line1, line2, is_recon):
self.catnr = line1[2:7].strip()
self.name = name or f"SAT {self.catnr}"
self.rec = Satrec.twoline2rv(line1, line2) # handles no_kozai internally
self.is_recon = is_recon
self.prev_elev = None
def geodetic_now(self, dt):
jd, fr = jday(dt.year, dt.month, dt.day, dt.hour, dt.minute,
dt.second + dt.microsecond * 1e-6)
e, r, _v = self.rec.sgp4(jd, fr)
if e != 0: # decayed / propagation error
return None
return eci_to_geodetic(r, gstime(jd + fr))
def build_sats(groups):
sats = []
for name, l1, l2, is_recon in groups:
try:
sats.append(Sat(name, l1, l2, is_recon))
except Exception as e: # a malformed TLE shouldn't kill the set
print(f"[{NAME}] skip TLE '{name or l1[2:7]}': {e}")
return sats
def main():
ap = argparse.ArgumentParser(description="SGP4 satellites -> OSC (the sky's schedule)")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--interval", type=float, default=2.0)
ap.add_argument("--lat", type=float, default=-27.47, help="venue latitude (run.py retargets this; default matches world_sky's Brisbane)")
ap.add_argument("--lon", type=float, default=153.02, help="venue longitude")
args = ap.parse_args()
if not HAVE_SGP4:
print(f"[{NAME}] sgp4 not installed — `pip install sgp4` to bring the sky in. Exiting.")
sys.exit(0)
sats = build_sats(load_grouped_tles())
if not sats:
# nap before exiting nonzero: run.py revives dead workers ~10s later, and
# without this pause a downed network would mean a Celestrak attempt every
# revive. 15 min keeps us polite and self-healing.
print(f"[{NAME}] no TLEs (no cache and no network) — napping 15 min, then retrying via the supervisor.")
time.sleep(900)
sys.exit(1)
client = SimpleUDPClient(args.host, args.port)
n_recon = sum(1 for s in sats if s.is_recon)
have_recon = n_recon > 0
print(f"[{NAME}] tracking {len(sats)} satellites ({n_recon} recon) from "
f"({args.lat:.2f}, {args.lon:.2f}); emitting /gs/sats/* every {args.interval:g}s")
last_refresh = time.time()
while True:
if time.time() - last_refresh > REFRESH_EVERY: # age-check + refresh TLEs in place
last_refresh = time.time()
fresh = build_sats(load_grouped_tles()) # refetches only if the cache is stale
if fresh:
prev = {s.catnr: s.prev_elev for s in sats} # carry pass-detection state so a
for s in fresh: # refresh mid-pass can't eat the event
s.prev_elev = prev.get(s.catnr)
sats = fresh
have_recon = any(s.is_recon for s in sats)
now = datetime.datetime.now(datetime.timezone.utc)
best_elev, recon_best, overhead = -90.0, -90.0, 0
for s in sats:
geo = s.geodetic_now(now)
if geo is None:
continue
lat, lon, alt = geo
el = elevation(lat, lon, alt, args.lat, args.lon)
if el > best_elev:
best_elev = el
if s.is_recon and el > recon_best:
recon_best = el
if el > OVERHEAD_ELEV:
overhead += 1
if s.prev_elev is not None and s.prev_elev < PASS_ELEV <= el: # rising through 30deg
client.send_message("/gs/sats/pass.event", float(el)) # -> sats.pass.event (impulse)
print(f"[{NAME}] pass: {s.name} rising through {PASS_ELEV:.0f}deg (now {el:.1f})")
s.prev_elev = el
if s.catnr == ISS_CATNR:
client.send_message("/gs/sats/iss_lat", float(lat))
client.send_message("/gs/sats/iss_lon", float(lon))
client.send_message("/gs/sats/best_elev", float(best_elev))
client.send_message("/gs/sats/overhead_count", float(overhead))
if have_recon:
client.send_message("/gs/sats/recon_elev", float(recon_best))
time.sleep(args.interval)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)

View File

@ -11,6 +11,8 @@ Emits:
/gs/sky/elev solar elevation in degrees (-90 night .. +90 overhead) /gs/sky/elev solar elevation in degrees (-90 night .. +90 overhead)
/gs/sky/day 0..1 smooth day/night (0 deep night, 1 broad daylight) /gs/sky/day 0..1 smooth day/night (0 deep night, 1 broad daylight)
/gs/sky/az solar azimuth in degrees (0=N, 90=E, 180=S, 270=W) /gs/sky/az solar azimuth in degrees (0=N, 90=E, 180=S, 270=W)
/gs/sky/lat the venue's own latitude (so the console can place
/gs/sky/lon the venue's own longitude "here" on the god's eye map)
""" """
from __future__ import annotations from __future__ import annotations
@ -69,7 +71,7 @@ def main():
args = ap.parse_args() args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port) client = SimpleUDPClient(args.host, args.port)
print(f"[sky] emitting /gs/sky/elev|day|az for ({args.lat},{args.lon}) " print(f"[sky] emitting /gs/sky/elev|day|az|lat|lon for ({args.lat},{args.lon}) "
f"every {args.interval:g}s") f"every {args.interval:g}s")
while True: while True:
@ -79,6 +81,8 @@ def main():
client.send_message("/gs/sky/elev", float(elev)) client.send_message("/gs/sky/elev", float(elev))
client.send_message("/gs/sky/day", day_fraction(elev)) client.send_message("/gs/sky/day", day_fraction(elev))
client.send_message("/gs/sky/az", float(az)) client.send_message("/gs/sky/az", float(az))
client.send_message("/gs/sky/lat", float(args.lat)) # the venue's own coordinates,
client.send_message("/gs/sky/lon", float(args.lon)) # so the console can place "here" on a map
except Exception as e: # noqa except Exception as e: # noqa
print(f"[sky] warn: {e}") print(f"[sky] warn: {e}")
time.sleep(args.interval) time.sleep(args.interval)