Compare commits
No commits in common. "main" and "pantheon/integrate" have entirely different histories.
main
...
pantheon/i
@ -1,78 +0,0 @@
|
||||
# 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 2–3 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.
|
||||
@ -1,105 +0,0 @@
|
||||
# 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 (Venus–Earth: 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 Venus–Earth 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
|
||||
1–16) — 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).
|
||||
@ -4,56 +4,6 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚠ 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 1–16, default 16)**.
|
||||
Everywhere W.1–W.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
|
||||
|
||||
### 0.1 The four revelations
|
||||
|
||||
46
auth.py
46
auth.py
@ -30,8 +30,6 @@ import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
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]+$")
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,24}$")
|
||||
MIN_PW = 8
|
||||
@ -405,42 +403,6 @@ def delete_preset(user_id: int, name: str, db: str = DEFAULT_DB) -> bool:
|
||||
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) ---------------------
|
||||
COOKIE = "gs_session"
|
||||
|
||||
@ -536,17 +498,13 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str,
|
||||
return 404, {"error": "not found"}, None
|
||||
|
||||
if path == "/api/presets" and method == "GET":
|
||||
# ✦ CANON — the factory shelf first, then the player's own saved dimensions
|
||||
return 200, {"presets": list_factory() + list_presets(uid, db)}, None
|
||||
return 200, {"presets": list_presets(uid, db)}, None
|
||||
|
||||
if path.startswith("/api/presets/"):
|
||||
name = _unquote(path[len("/api/presets/"):])
|
||||
is_factory = name.startswith(FACTORY_PREFIX)
|
||||
if method == "GET":
|
||||
data = load_factory(name) if is_factory else load_preset(uid, name, db)
|
||||
data = load_preset(uid, name, db)
|
||||
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"):
|
||||
return (200, {"ok": True}, None) if save_preset(uid, name, payload.get("data"), db) else (400, {"error": "bad preset"}, None)
|
||||
if method == "DELETE":
|
||||
|
||||
15
config.json
15
config.json
@ -30,7 +30,7 @@
|
||||
|
||||
"groups": {
|
||||
"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", "sats.best_elev"]},
|
||||
"cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel"]},
|
||||
"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"]},
|
||||
"heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]},
|
||||
@ -96,20 +96,12 @@
|
||||
"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.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.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.avgalt": {"norm": {"lo": 0, "hi": 13000}, "label": "avg altitude"},
|
||||
"planes.avgspeed": {"norm": {"lo": 0, "hi": 300}, "label": "avg airspeed"},
|
||||
"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.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"},
|
||||
@ -120,8 +112,6 @@
|
||||
"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.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.deaths": {"norm": {"lo": 0, "hi": 5}, "label": "deaths / sec"},
|
||||
"clock.popvel": {"norm": {"lo": 0, "hi": 4}, "label": "population growth / sec"},
|
||||
@ -179,9 +169,6 @@
|
||||
{"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": "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.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"},
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,479 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,376 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,357 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,6 @@
|
||||
python-osc>=1.9
|
||||
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
|
||||
|
||||
# Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port
|
||||
# pip install python-rtmidi
|
||||
# Optional: microphone FFT worker (workers/audio_worker.py)
|
||||
|
||||
8
run.py
8
run.py
@ -33,7 +33,6 @@ WORLD = [
|
||||
"workers/world_air.py",
|
||||
"workers/world_sun.py",
|
||||
"workers/world_iss.py",
|
||||
"workers/world_sats.py",
|
||||
"workers/world_crypto.py",
|
||||
"workers/world_fx.py",
|
||||
"workers/world_planes.py",
|
||||
@ -50,7 +49,7 @@ WORLD = [
|
||||
]
|
||||
|
||||
# workers that retarget to the venue city
|
||||
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py", "world_sats.py"}
|
||||
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py"}
|
||||
BBOX_WORKERS = {"world_planes.py"}
|
||||
|
||||
|
||||
@ -204,11 +203,6 @@ def main():
|
||||
now = time.time()
|
||||
for w in workers: # revive any worker that died
|
||||
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)")
|
||||
np = launch(w["path"], w["extra"])
|
||||
if np:
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
#!/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()
|
||||
920
viz/index.html
920
viz/index.html
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
203
viz/spheres.js
@ -1,203 +0,0 @@
|
||||
// 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 BC–3000 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 Jupiter–Neptune, 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 Newton–Raphson.
|
||||
// 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, 1800–2050) → {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 Jupiter–Neptune,
|
||||
// valid 3000 BC–3000 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;
|
||||
}
|
||||
@ -5,7 +5,6 @@ and mean groundspeed (intensity) — the sky's traffic becomes a slow chord.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@ -24,57 +23,6 @@ POLL_OK = 450.0 # normal poll interval (s)
|
||||
POLL_429 = 600.0 # backoff poll interval after HTTP 429
|
||||
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):
|
||||
"""Fetch OpenSky states for the bbox. Returns parsed JSON dict.
|
||||
@ -147,42 +95,25 @@ def main():
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
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))
|
||||
try:
|
||||
data = fetch(args.bbox)
|
||||
last = reduce_states(data)
|
||||
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:
|
||||
data = fetch(args.bbox)
|
||||
last = reduce_states(data)
|
||||
if poll != POLL_OK:
|
||||
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
|
||||
poll = POLL_OK
|
||||
except urllib.error.HTTPError as ex:
|
||||
if ex.code == 429:
|
||||
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
|
||||
try:
|
||||
poll = max(POLL_429, float(retry_after))
|
||||
except (TypeError, ValueError):
|
||||
poll = POLL_429
|
||||
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
|
||||
else:
|
||||
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,
|
||||
json.JSONDecodeError, ValueError) as ex:
|
||||
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)")
|
||||
except urllib.error.HTTPError as ex:
|
||||
if ex.code == 429:
|
||||
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
|
||||
try:
|
||||
poll = max(POLL_429, float(retry_after))
|
||||
except (TypeError, ValueError):
|
||||
poll = POLL_429
|
||||
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
|
||||
else:
|
||||
print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s")
|
||||
except (urllib.error.URLError, TimeoutError, OSError,
|
||||
json.JSONDecodeError, ValueError) as ex:
|
||||
print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s")
|
||||
next_poll = time.monotonic() + poll
|
||||
|
||||
# Emit (or re-emit) the last known values.
|
||||
|
||||
@ -1,329 +0,0 @@
|
||||
#!/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)
|
||||
@ -11,8 +11,6 @@ Emits:
|
||||
/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/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
|
||||
@ -71,7 +69,7 @@ def main():
|
||||
args = ap.parse_args()
|
||||
|
||||
client = SimpleUDPClient(args.host, args.port)
|
||||
print(f"[sky] emitting /gs/sky/elev|day|az|lat|lon for ({args.lat},{args.lon}) "
|
||||
print(f"[sky] emitting /gs/sky/elev|day|az for ({args.lat},{args.lon}) "
|
||||
f"every {args.interval:g}s")
|
||||
|
||||
while True:
|
||||
@ -81,8 +79,6 @@ def main():
|
||||
client.send_message("/gs/sky/elev", float(elev))
|
||||
client.send_message("/gs/sky/day", day_fraction(elev))
|
||||
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
|
||||
print(f"[sky] warn: {e}")
|
||||
time.sleep(args.interval)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user