Compare commits
17 Commits
pantheon/i
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fbba05426 | |||
| 925617d782 | |||
| 5b0fffd75c | |||
|
|
7ee3c51269 | ||
|
|
f9f0ef52ff | ||
|
|
7e568d8323 | ||
|
|
cccbce536c | ||
|
|
0ff4259a4f | ||
|
|
6cb355e6b0 | ||
|
|
e6c464daae | ||
|
|
19f17705e6 | ||
|
|
629cbad6fd | ||
|
|
d2c7938321 | ||
|
|
6d408a8bdb | ||
|
|
301005bdbc | ||
|
|
4f6a2a9634 | ||
|
|
6aff223e02 |
78
GODSIGH_CROSSOVER_BRIEF.md
Normal file
78
GODSIGH_CROSSOVER_BRIEF.md
Normal 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 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.
|
||||||
105
RESONANCE_BRIEF.md
Normal file
105
RESONANCE_BRIEF.md
Normal 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 (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,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 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
|
## Part 0 — shared context
|
||||||
|
|
||||||
### 0.1 The four revelations
|
### 0.1 The four revelations
|
||||||
|
|||||||
46
auth.py
46
auth.py
@ -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":
|
||||||
|
|||||||
15
config.json
15
config.json
@ -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"},
|
||||||
|
|||||||
57
factory/quiet_arrival.json
Normal file
57
factory/quiet_arrival.json
Normal 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
479
factory/the_heartbeat.json
Normal 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
376
factory/the_squeeze.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
357
factory/the_wheel_within_the_wheel.json
Normal file
357
factory/the_wheel_within_the_wheel.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,9 @@
|
|||||||
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
|
||||||
|
|
||||||
# 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
8
run.py
@ -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
91
test_factory.py
Normal 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()
|
||||||
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
Normal file
203
viz/spheres.js
Normal 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 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,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,25 +147,42 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now >= next_poll:
|
if now >= next_poll:
|
||||||
try:
|
cached = read_cache(CACHE_FRESH) # a fresh global cache -> skip HTTP entirely
|
||||||
data = fetch(args.bbox)
|
if cached is not None:
|
||||||
last = reduce_states(data)
|
data, age = cached
|
||||||
|
last = reduce_states(filter_states_bbox(data, args.bbox))
|
||||||
if poll != POLL_OK:
|
if poll != POLL_OK:
|
||||||
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
|
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
|
||||||
poll = POLL_OK
|
poll = POLL_OK
|
||||||
except urllib.error.HTTPError as ex:
|
print(f"[{NAME}] cache hit ({age:.0f}s old) -> {int(last[0])} aircraft in bbox")
|
||||||
if ex.code == 429:
|
else:
|
||||||
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
|
try:
|
||||||
try:
|
data = fetch(args.bbox)
|
||||||
poll = max(POLL_429, float(retry_after))
|
last = reduce_states(data)
|
||||||
except (TypeError, ValueError):
|
if poll != POLL_OK:
|
||||||
poll = POLL_429
|
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
|
||||||
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
|
poll = POLL_OK
|
||||||
else:
|
except urllib.error.HTTPError as ex:
|
||||||
print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s")
|
if ex.code == 429:
|
||||||
except (urllib.error.URLError, TimeoutError, OSError,
|
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
|
||||||
json.JSONDecodeError, ValueError) as ex:
|
try:
|
||||||
print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s")
|
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)")
|
||||||
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.
|
||||||
|
|||||||
329
workers/world_sats.py
Normal file
329
workers/world_sats.py
Normal 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)
|
||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user