Compare commits

..

No commits in common. "main" and "pantheon/d-ungodly" have entirely different histories.

21 changed files with 84 additions and 3888 deletions

View File

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

View File

@ -1,189 +0,0 @@
# THE PANTHEON — build brief · GODSPEED · GODSQUASH · GODSPEAK · UNGODLY
> **You are an execution agent (Opus).** You have been assigned one lane of a four-feature build. Read **Part 0**, then execute ONLY your lane. Your output is reviewed by the planning model (Fable) before merge; end your run with the §0.8 report. This brief follows the GODRUM process (see `GODRUM_BRIEF.md` for the full history — GODRUM shipped through it in four staged, reviewed lanes).
---
## Part 0 — shared context (every agent reads this)
### 0.1 The four gods
| name | what | where it lives |
|---|---|---|
| **⚡ GODSPEED** | the arpeggiator — hold a chord, the machine runs with it, on godtime with the groove's swing | client, app scope |
| **🫀 GODSQUASH** | the master-bus compressor — one world-modulatable knob that makes the whole planet pump | client, Synth engine |
| **🗣 GODSPEAK** | the voice of god — the user's own wersperr (local MLX Whisper) speaks commands into the sky | Python worker + hub + client |
| **⏪ UNGODLY** | undo — *un-doing what god did*. ⌘Z time-travels the patch | client, app scope |
### 0.2 Ground truth
- Repo `/Users/m3ultra/Documents/godstrument`, branch base = **`main`** (post-GODRUM, ≥ `0fb6410`). The app is one file, `viz/index.html` (~9,100 lines): grimoire prose up top, one giant IIFE, the `Synth` IIFE inside it near the bottom. Vanilla JS + raw Web Audio, no libraries. Python side: `hub.py` (OSC 9000 in → normalize → WebSocket out, port from `config.json`), `run.py` (starts hub + workers; note the `--sports` opt-in-worker precedent), `workers/*.py` (house pattern: argparse `--host/--port`, **lazy imports with graceful "skipping (optional)" exit**, `SimpleUDPClient` to OSC 9000).
- **Line numbers drift — grep for names.** Verified anchors as of `main@0fb6410`: `OMNI.MODULES`/`LABELS` ~39473956 · `handleMessage` ~4357 · `mindStrike` ~5019 (resolves `mindKeys.notes[i]`, fires `Synth.pluck(note, vel)` + `midiPluck(2, note, vel, 320)` — the mind console's own MIDI channel) · `seqTick` ~2922 (16th-note tick, **swing already applied at the clock**, see the GODRUM `fireDrum` precedent inside it) · main keydown handler ~68906907 (taken: `p c o w z t b` + digits `15`; **`g` is free**; NOTE the single-letter handlers currently have **no meta/ctrl guards** — ⌘Z toggles zen today, a pre-existing quirk UNGODLY fixes) · `serializePatch`/`loadPatch` ~3160s · ctx menu `ctxItem(label, fn, kbd)` entries ~7100s · `Synth` IIFE opens ~8400s (`const Synth = (function () {`).
- **GODRUM conventions carry over verbatim**: worktree per agent, emoji commit style, surgical edits only, match the house voice in comments, params normalized 0..1, temporary verify hooks allowed but removed + grep-verified before commit, static-serve `viz/` to verify (`python3 -m http.server 89xx --directory viz`, dismiss `#landing` client-side), §0.7-style regression every time (**zero console errors; `♪ T O B G` and panels all work**), never deploy, never commit db/secrets/`patches/`, never touch `main`.
- **Hard territorial rule from GODRUM's lesson:** if your lane and a parallel lane could both insert at the same seam, they WILL conflict — your brief names your regions; stay inside them, and if you must cross, STOP and flag it in your report instead.
### 0.3 Git
```sh
cd /Users/m3ultra/Documents/godstrument
git worktree add ../godstrument-<LANE> -b pantheon/<branch> main
```
Branches: `pantheon/a-godspeed` · `pantheon/b-godsquash` · `pantheon/c-godspeak-worker` · `pantheon/d-ungodly` · `pantheon/e-closer`.
### 0.8 Report format (same as GODRUM)
1. Branch + worktree + commit(s). 2. What was built + decisions where the brief left room. 3. `git diff main --stat` + the full diff. 4. How you verified (deterministic tests + measurements + regression results). 5. Deviations & open questions. 6. Territory confirmation (hooks removed, grep-clean, nothing outside your regions).
### Stage map
| stage | lanes | parallel-safe because | status |
|---|---|---|---|
| 1 | **α GODSPEED ∥ β GODSQUASH** | α is app-scope-only; β is Synth-IIFE + three named one-line touches α never makes | ✅ APPROVED — α @ 0e17dbf, β @ ea13b2e; trial-merge clean, zero conflicts. β's quadratic makeup `1+sq²` is a reviewer-endorsed deviation (measured: reduction ≈ 5.95·sq² dB); the linear `1+sq·1.4` in B.2 below is superseded — do not re-implement it |
| 2 | **γ GODSPEAK-worker ∥ δ UNGODLY** | γ is Python-only (zero index.html); δ is index.html-only (zero Python) | ✅ APPROVED — γ @ e56e3bc, δ @ d2c8ad6; merged to main @ b8b1188. Reviewer live-verified undo/redo + ⌘Z-no-longer-zen + worker graceful-skip |
| 3 (current) | **ε the closer** | GODSPEAK client grammar + grimoire chapter for all four + manual + cross-feature QA + deploy checklist | full brief below |
Reviews between every stage. Do not start a later stage's work.
---
# Stage 1α — GODSPEED ⚡ (the arpeggiator)
**Territory:** viz/index.html app scope, OUTSIDE the Synth IIFE. You may touch: `mindStrike`, `seqTick` (additive hook, GODRUM's `fireDrum` style), the main keydown handler (the `G` key), one `ctxItem`, your own panel block + CSS block (place the panel function after `closeAmpler`/the AMPLER block; CSS after `#ampler`'s block). You may NOT touch: the Synth IIFE, `OMNI.MODULES`/`LABELS`, anything `squash` (that's β), exportMid, serializePatch.
**A.1 State & capture.**
```js
const arp = { on:false, held:[], rate:1, dir:"up", oct:1, gate:0.8, idx:0, up:true };
// rate: 4=1/4 · 2=1/8 · 1=1/16 · 0.5=1/32 dir: up|down|updown|random oct: 1..3 held: [{note, vel}] cap 8
```
Capture point is `mindStrike(i, vel)`: when `arp.on`, a strike **toggles** `{note: mindKeys.notes[i], vel}` in `arp.held` (latch — strike again to remove; keep the `mindKeys.flash[i] = 1` feedback) and does **not** pluck immediately. When `arp.on` is false, `mindStrike` is byte-identical to today. Since MIDI-in already routes note-ons through `mindStrike`, hardware gets latch capture for free — note that in your report; true hold-mode from MIDI note-offs is a flagged future upgrade, not v1.
**A.2 The clock.** Ride `seqTick()` — one additive call at its tail (like `beatRefresh`): `arpTick()`. Inside: bail unless `arp.on && arp.held.length`; a 16th-counter fires when `counter % arp.rate === 0` for rates ≥ 1; for 1/32, fire twice (immediate + `setTimeout(sub/2)`, `sub = 60000/bpm/4`, the ratchet idiom). Because seqTick fires at the swung onset, **GODSPEED inherits the groove** — that's the whole soul of the feature; verify it.
**A.3 Note selection.** Pool = `held` sorted ascending, expanded across `oct` octaves (`note + 12*o`). `up`/`down` cycle; `updown` ping-pongs without repeating the turnaround note; `random` picks uniformly. Fire via `Synth.pluck(note, vel)` + `midiPluck(2, note, vel, durMs)` where `durMs = sub * arp.rate * arp.gate` (clamped ≥ 30) — channel 2, the mind console's channel, exactly like `mindStrike`.
**A.4 UI.** `G` key + `ctxItem("⚡ GODSPEED — hold a chord, the machine runs", …)` toggle a compact `#godspeed` panel (house style, model on the beat header row): **on/off** (`.midibtn.on`) · rate select (1/4 1/8 1/16 1/32) · dir select · oct (13) · gate slider · a held-notes readout (note names — there's a `midiToName` in scope; grep it) · **clear** button. Arp toggled off → `held` cleared, `idx` reset. Add the `G` handler beside `B` (~6906); verify `g` is truly unbound first (grep, incl. the capture-phase keymap handler — digits precedent from GODRUM lane E).
**A.5 Verify.** Deterministic: drive `seqTick` (hook the internals temporarily), spy `Synth.pluck` — assert the exact note order for up/down/updown/random over 2 bars at each rate; 1/32 double-fires; gate scales `durMs`; swing: with "mpc 8-4" the off-16th fires late (assert via the seqTick call time, or by construction + note it). Live: open panel via `G` and the sky menu, latch three mind-mode notes, ▶ nothing needed — the arp runs on the clock; confirm audible + zero console errors + §0.7 regression + **GODRUM untouched** (beat panel plays, keys 15 still finger-drum — they must NOT feed the arp).
---
# Stage 1β — GODSQUASH 🫀 (the master squeeze)
**Territory:** inside the Synth IIFE, PLUS exactly three named external touches: (1) the `OMNI.MODULES` "fx rack" `keys` array — append `"squash"`; (2) `OMNI.LABELS` — add `"squash": "squash"`; (3) wherever `crush` is structurally registered as a destination in viz/index.html (grep `"crush"` and mirror EVERY structural appearance — dest declaration/registry, matrix nodes if any — so `squash` is routable by the world exactly like `crush`; `crush` is NOT in config.json, so config.json stays untouched unless your grep proves otherwise). Nothing else outside the IIFE.
**B.1 The chain.** In `build()`, where the master currently does `out.connect(lim)`: insert `out → comp → makeup → lim` (`comp = ctx.createDynamicsCompressor()`, `makeup = ctx.createGain()` at 1). Return `comp` and `makeup` on `N` (as `squash`, `squashMakeup`). Do NOT move the GODSONIQ tap (`N.out.connect(recNode)` happens later in `loadWorklets` and correctly stays pre-squash — captures are pre-limiter by design; say so in a comment). Set static: `knee 12`, `attack 0.004`.
**B.2 The one knob.** In `params()`:
```js
const sq = c01(d("squash", 0)); // 0 = transparent (no route, no squeeze)
S(N.squash.threshold, -6 - sq * 30, 0.1); // 6 → 36 dB
S(N.squash.ratio, 1 + sq * 7, 0.1); // 1:1 → 8:1
N.squash.release.setTargetAtTime(0.12 + sq * 0.13, t, 0.2);
S(N.squashMakeup.gain, 1 + sq * 1.4, 0.1); // gain-compensate the squeeze — tune this by measurement
```
Tune the makeup curve so perceived loudness stays roughly level while crest factor drops — measure, don't guess.
**B.3 Verify.** Offline renders (the house method): a busy program (pad + drums via `Synth.drum` if merged base has them — it does) at `squash` 0 / 0.5 / 1.0 → show **crest factor (peak/RMS) falls monotonically**, RMS roughly held by makeup, no clipping into the limiter (peak ≤ pre-squash peak + ε). **Null-ish test at 0:** ratio 1 → output within ~0.5 dB of a bypass render (DynamicsCompressor has fixed lookahead latency — compare envelopes, not sample-aligned nulls, and note the latency in your report). Live: OMNI shows the new `squash` knob in the fx rack; dragging it audibly pumps; a world route onto `squash` modulates it. §0.7 regression + GODRUM regression (drums still balanced — squash defaults to 0).
---
# Stage 2γ — GODSPEAK worker 🗣 (Python only — ZERO viz/index.html)
**Territory:** new file `workers/godspeak_worker.py`, a small addition to `hub.py`'s `_osc_handler` (+ broadcast), an opt-in flag in `run.py`. Nothing else. **This feature is local-rig only**: MLX Whisper is Apple-Silicon-only, so it runs where the user's Mac runs the hub — the VPS simply never has it (like the ToF/vision workers). Say this in the file's docstring.
**C.1 The worker.** Adapt the user's wersperr (`ssh://git@100.71.119.27:222/monster/wersperr.git`, 99 lines — clone it read-only and study it; model: `mlx-community/whisper-large-v3-turbo`, 16 kHz mono, hold **Right ⌥** via pynput, transcribe on release, ignore < ⅓ s). Changes from wersperr: instead of pbcopy/paste, send OSC to the hub — `/godspeak/rec 1` on key-down, `/godspeak/rec 0` on release, `/godspeak <text>` after transcription. House worker pattern throughout: argparse `--host 127.0.0.1 --port 9000`, **lazy imports** (`mlx_whisper`, `sounddevice`, `pynput`, `numpy`) with the graceful `"godspeak worker: … not available — skipping (optional)"` exit, warm-up transcribe of silence on boot, `--check` self-test. Deps do NOT go in requirements.txt (VPS must stay clean) — document `pip install mlx-whisper sounddevice pynput` (into the venv, Mac only) in the docstring.
**C.2 The hub.** In `_osc_handler`, special-case the `/godspeak` addresses (they're text/state, not norm signals — mirror how non-signal messages like the patch list reach clients): broadcast `{"godspeak": {"text": <str>}}` and `{"godspeak": {"rec": 0|1}}` to all ws clients immediately. Respect the hub's **read-only mode** (~line 114): in read-only, drop godspeak entirely (it's a command channel).
**C.3 run.py.** `--godspeak` opt-in flag exactly like the `--sports` precedent (~line 132): starts the worker, prints a one-liner. Never in the default set.
**C.4 Verify.** `--check` passes on this Mac (model loads, silence transcribes). With hub running locally: hold Right ⌥, say something, show the hub log/ws broadcast carrying the text (a 5-line ws test client is fine). Graceful-skip path: run with the imports missing (e.g. `python3 -m venv` bare env) → clean skip, exit 0. Confirm `git diff main --stat` touches ONLY the three Python files.
---
# Stage 2δ — UNGODLY ⏪ (undo — viz/index.html only, ZERO Python)
**Territory:** viz/index.html app scope, outside the Synth IIFE. Touches: a new snapshot module (place after `loadPatch`), `ungodlyMark(label)` calls at the mutation sites listed below, the keydown handler (⌘Z/⌘⇧Z **plus meta-guards on the existing single-letter keys** — today ⌘Z toggles zen; that quirk dies in this lane), one `ctxItem`. Do NOT touch: the Synth IIFE, GODSPEED's/AMPLER's blocks, exportMid.
**D.1 The engine.** Undo = time-travel over `serializePatch()``loadPatch` already restores everything (routes, tweaks, seqs incl. drums, arr, bpm, groove, scale, kit) and rebuilds open panels. So:
```js
const ungodly = { past: [], future: [], muted: false, MAX: 32, lastT: 0, lastLabel: "" };
function ungodlyMark(label) { // call BEFORE a mutation applies — the snapshot is the world as it was
if (ungodly.muted) return;
const s = JSON.stringify(serializePatch());
if (ungodly.past.length && ungodly.past[ungodly.past.length - 1].s === s) return; // no empty history
ungodly.past.push({ s, label }); if (ungodly.past.length > ungodly.MAX) ungodly.past.shift();
ungodly.future.length = 0;
}
function ungodlyGesture(label) { // continuous drags: one mark per gesture burst (same label within 800ms = same gesture)
const tNow = performance.now();
if (label === ungodly.lastLabel && tNow - ungodly.lastT < 800) { ungodly.lastT = tNow; return; }
ungodly.lastLabel = label; ungodly.lastT = tNow; ungodlyMark(label);
}
```
Undo pops `past` → pushes the CURRENT state onto `future``muted = true; loadPatch(JSON.parse(p.s)); muted = false` → ticker `"⏪ ungodly: " + p.label`. Redo mirrors. Empty → ticker `"⏪ nothing to take back — god has done nothing yet"`. Memory-only v1 (reload clears history — note it).
**D.2 The mark sites** (find each by name; `ungodlyMark` for discrete acts, `ungodlyGesture` for drags — always BEFORE the mutation):
route patch/unpatch via the UI (the cable actions + the ctx-menu route items — NOT `registerRoute` itself, which `loadPatch` calls) · `applyMood` entry (`"mood: " + name`) · dimension/patch load + vibe apply (`"loaded <name>"`) · zero enter and exit · groove preset clicks + groove sliders (gesture) in BOTH the beat panel and the arranger grooves tab · euclid fills (both panels) · beat-panel step toggles (gesture, `"beat edit"`) + expression-lane drags (gesture) + kit-knob drags (gesture) · arranger clip-cell cycles (gesture) · scale root/name changes · `resetTweaks` and mute/solo toggles (mark). Anything you find that mutates patch state and isn't listed: add it and report it.
**D.3 Keys & menu.** In the keydown handler: FIRST, `if (ev.metaKey || ev.ctrlKey) { if z → undo (shift → redo), preventDefault, return; }` — placed before the single-letter block so ⌘Z no longer reaches zen; then add `!ev.metaKey && !ev.ctrlKey` guards to the existing letter handlers (p/c/o/w/z/t/b/g — small, mechanical, in-territory). `ctxItem("⏪ UNGODLY — take it back (⌘Z)" + depth count, …)`.
**D.4 Verify.** Deterministic script: draw beat steps → apply a mood → patch a route → three undos restore each state exactly (compare `JSON.stringify(serializePatch())` to the recorded snapshots) → three redos return → new action after undo clears `future`. Gesture-debounce: a 20-event slider drag = ONE history entry. `muted` holds: undo itself creates no marks. ⌘Z does NOT toggle zen; plain `z` still does. Depth cap at 32. §0.7 + GODRUM regression, zero console errors.
---
# Stage 3ε — the closer (FINAL BRIEF)
> **You are Agent ε (Opus).** All four gods are built and merged to `main @ b8b1188`: GODSPEED (arp, `G`), GODSQUASH (`squash` dest + OMNI knob), the GODSPEAK worker (OSC `/godspeak` + `/godspeak/rec` → ws `{"godspeak":{text|rec}}`), and UNGODLY (⌘Z/⌘⇧Z, `ungodlyMark`/`ungodlyGesture`/`ungodlyUndo` in app scope). You close the pantheon: the voice grammar, the polish list, the grimoire chapter, the QA matrix, the deploy checklist. Branch `pantheon/e-closer` from `main` (confirm `arpTick`, `squash`, `godspeak_worker.py`, and `ungodlyUndo` are all in your base). Part 0 rules and §0.8 report bind.
## E.1 The GODSPEAK client (viz/index.html)
In `handleMessage` (grep it), add the `godspeak` branch:
- `msg.godspeak.rec` → a small 🗣 indicator pulses while recording (a fixed chip near the ♪ button, or a ticker line — house style, your call; it must appear on 1 and clear on 0).
- `msg.godspeak.text` → normalize (lowercase, strip punctuation, map number-words to digits over the 40180 range — a small word-number table: "ninety" → 90, "one twenty" → 120) and run the grammar. **First match wins; every executed command lands in the ticker as `🗣 <what happened>`; unmatched text falls through to the ticker as `🗣 "<text>"` — the word arrives in the world, never an error.**
Grammar (be forgiving with filler words — match on keywords, not exact phrases):
| utterance contains | action |
|---|---|
| `tempo`/`bpm` + N (digits or words) | `godtime.bpm = clamp(N, 40, 180)` unless `godtime.locked` (then ticker "godtime is locked") |
| `mood` + a fuzzy `MOODS` key (or a bare mood name) | `applyMood(match)` |
| `play`/`start` + `beat` · `stop` + `beat` | flip all five `drum.*` lanes on/off (reuse the beat panel's master-play logic — extract or mirror it) |
| `open`/`close` + `beat`\|`tracks`\|`omni`\|`ampler`\|`keys`\|`godspeed` | the matching panel toggle |
| `capture` (+ N `seconds`)? | `Synth.amplerArm(N || 3)` — if it returns false, ticker the same "press ♪ first" hint AMPLER uses |
| `chop` + `eight`/`sixteen`/8/16 | `Synth.amplerChop(n)` + re-render the AMPLER panel if open |
| `skin` + fuzzy skin name | `setSkin(match)` |
| `zen` | `setZen(!zenMode)` |
| `squash` + N `percent` | set the squash trim: `setParamLocal("squash","offset", N/100)` (register the dest first if needed) |
| `take it back` / `undo` | `ungodlyUndo()` — the voice of god, un-doing |
Fuzzy matching: lowercase-prefix or substring against the real key lists (`MOODS`, `SKINS`) — never invent names; no match → fall through to the ticker. **Voice commands are user acts: let the actions they call fire their existing `ungodlyMark`s naturally (applyMood already marks; don't double-mark).**
## E.2 The polish list (small, reviewed carry-overs — all in-scope)
1. Prune GODSPEED's dead `arp.up` state field (α flagged it).
2. Apply `groove.velo` velocity-deviation to arp notes in `arpFire` (mirror the drum branch's line) — the arp should breathe like the lanes do.
3. `ungodlyMark("tempo")` on the beat panel's BPM input commit (the one known unmarked mutation).
4. GODSPEAK worker docstring: add the two macOS one-time permissions (Microphone + Accessibility/Input Monitoring for pynput) — mirror wersperr's README note.
## E.3 The grimoire chapter (canon) + manual
Four `<li>` entries alongside the GODRUM chapter (same placement pattern Agent F used, before the stage; house voice; fact-check every claim against what you verified):
- **⚡ GODSPEED** — press `G`; enter a chakra, turn it on, strike keys to latch a chord; it runs on godtime and *swings with the groove*; rate/direction/octaves/gate; a MIDI keyboard latches too (it rides channel 2 out).
- **🫀 GODSQUASH** — the master squeeze in OMNI's fx rack; one knob, threshold dives and ratio climbs together, loudness stays level while the peaks kneel; route the world onto it and the planet pumps the mix.
- **🗣 GODSPEAK** — the voice of god (local rig only, Apple Silicon): `run.py --godspeak`, hold Right ⌥, speak — "tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "take it back"; unmatched words arrive in the ticker; nothing leaves the machine (the user's own local Whisper).
- **⏪ UNGODLY** — ⌘Z takes it back, ⌘⇧Z re-does; thirty-two acts deep; every cable, mood, groove, kit knob and beat step; memory-only (a reload forgives everything).
Then `python3 build_manual.py` and commit the regenerated `viz/manual.html` (the Stage-4/F exception applies to closers).
## E.4 Cross-feature QA matrix
1. **Voice→everything** (drive `handleMessage` directly with synthetic `{godspeak:{text}}` messages — the worker needs a mic+model, so the ws path is verified by γ already): tempo, mood, play/stop beat, open/close each panel, capture/chop (audio unlocked first), skin, zen, squash N percent, **"take it back" undoes the mood it just set**. Unmatched → ticker verbatim. Junk/number-word parsing table.
2. **Undo × the new gods:** arp latch isn't in the patch (transient — confirm undo doesn't clear held notes); squash trim set by voice → ⌘Z reverts it (it's a tweak).
3. **GODSPEED × GODRUM:** arp + beat playing together, both swung, keys 15 don't latch, `G`/`B` panels coexist.
4. **GODSQUASH × GODRUM:** squash at 0.5 while the beat plays — offline-render crest drop, no limiter clip.
5. Full §0.7 regression (`♪ T O B G` + ⌘Z/⌘⇧Z + zen-not-on-⌘Z) — zero console errors.
6. `rec` indicator appears/clears on synthetic `{godspeak:{rec:1|0}}`.
## E.5 Deploy checklist (prepare, do NOT execute) & deliverables
Same shape as GODRUM's F.4: merge main is already done for stages 12, so: your branch → `main` (reviewer does it) · rsync per CLAUDE.md (briefs auto-excluded; `manual.html` ships; `workers/godspeak_worker.py` ships but is inert on the VPS — graceful skip) · restart · cold-start wait · smoke-test godstrument.pro (`B`,`G`, ⌘Z, OMNI squash knob, manual chapter) · note GODSPEAK is local-rig-only so the live site's voice test is `run.py --godspeak` on the Mac. Commit(s) on `pantheon/e-closer`; §0.8 report + the E.4 matrix ✓/✗ + the E.2 list each done/deviated.

View File

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

View File

@ -1,180 +0,0 @@
# REVELATION — build brief · TESTAMENT · WHEELS · PSALM · CANON
> **You are an execution agent (Opus).** One lane of a four-feature wave. Read **Part 0**, execute ONLY your lane, end with the §0.8 report (defined in `GODRUM_BRIEF.md`, our process root — GODRUM and THE PANTHEON both shipped through this loop). The planning model (Fable) reviews every stage before merge.
---
## ⚠ AMENDMENTS — read BEFORE your lane (Fable, 2026-07-14, post-convergence)
This brief was drafted at `main@b8b1188`, **before** the pantheon merged with the
crossover wave (keyroll note-length · world_sats · OpenSky cache · the god's-eye
Spirit · 18 post-merge review fixes). The convergence landed as `19f1770`. These
amendments override anything they contradict below.
1. **GATE (replaces §0.2's):** branch from `main``19f1770`. Confirm your base
has BOTH waves: `grep -c godspeakCommand viz/index.html` ≥ 1 (ε) AND
`grep -c attachNoteLengths viz/index.html` ≥ 1 (keyroll) AND
`git log --oneline -3` includes the ⚭ convergence commit. If not, STOP.
2. **WHEELS — the `len` name is TAKEN.** Keyroll landed `seq.len` as a
**per-step ARRAY of note lengths** (see `seqFor`: `len: new Array(16).fill(1)`,
plus `migrateSeq`'s `len-default`/`len-badlen` self-checks). WHEELS's per-lane
pattern length must be a **different field: `wlen` (int 116, default 16)**.
Everywhere W.1W.5 below says `len`, read `wlen`. Your `migrateSeq` additions
must coexist with the existing len-array migration (add `wlen` clamping beside
it; new self-check assertions for `wlen`, don't touch the len ones).
3. **WHEELS × keyroll indexing:** the note branch of `seqTick` now reads
`sq.len[cur]` (per-step note length) — with wheels, `cur` becomes
`wheelStep(sq)` for BOTH the `steps[]` gate and the `len[]` lookup (one `cur`,
used twice — do not let them diverge).
4. **WHEELS × exportMid:** the drum block now has an `owned(v, sq)` gate (lanes
export only if playing or edited off `DRUM_SEED`) — your absolute-loop rewrite
in W.4 must keep that gate exactly. The note track is length-aware with a
same-pitch overlap clamp; don't disturb it.
5. **TESTAMENT:** the master chain gained nodes since your anchor was written
(squash from β; the Schumann layer predates). Grep the actual tail of
`build()` for the final limiter node and its name — do not trust `N.lim` as a
name, verify it. Also `Synth.pluck` now takes an optional `durMs` — irrelevant
to your tap, but don't be surprised by the signature.
6. **PSALM:** `Synth.pluck(midi, vel, durMs)` exists — sustained sung notes may
pass a duration instead of relying on the fixed decay; treat that as an
allowed (flag-it-in-report) enhancement, not scope creep. The mic-analysis
AudioContext isolation rule stands.
7. **CANON:** factory patches must survive `migrateSeq` with the NEW fields
(len arrays, `wlen` once WHEELS lands, `locked`, `kit`) — author them by
exporting real patches from a post-WHEELS build, never by hand-writing JSON.
8. **Regression additions to §0.7:** the groove self-check must stay green
(it now asserts len-array migration), and the god's-eye skin must still cycle
(right-click → skin) with zero console errors — it shares the overlay/render
path some of you will be near.
9. **Deploy note for ω (the closer):** the production box is currently TWO waves
behind and its venv still lacks `sgp4` — the deploy checklist you prepare must
start with `/home/humanjing/godstrument/.venv/bin/pip install sgp4`, then the
CLAUDE.md filtered rsync, then restart. GODSPEAK stays local-rig-only (inert
on the VPS); `mlx-whisper` must never enter `requirements.txt`.
---
## Part 0 — shared context
### 0.1 The four revelations
| name | what | maps to (the DAW-study gap) |
|---|---|---|
| **📼 TESTAMENT** | record the performance — the master mix to a file, one button | a live instrument that can't record itself |
| **☸ WHEELS** | per-lane pattern lengths (116) — polymeter, the wheel within a wheel | flexible MIDI/pattern editing |
| **🕊 PSALM** | sing to the instrument — mic pitch, quantized to the scale, played as notes (or latched into GODSPEED) | vocal integration, Godstrument-style |
| **✦ CANON** | factory dimensions — curated patches that ship with the instrument | Logic's stock-content advantage |
### 0.2 Ground truth & hard gate
- **GATE: do not branch until PANTHEON Stage 3ε has merged to `main`.** Confirm your base has ε's work (`git log --oneline -5` shows the ε commit; grep `godspeak` reaches a `handleMessage` branch). If ε hasn't landed, STOP and report. All line anchors below were verified at `main@b8b1188` (pre-ε) — **they WILL have drifted; grep for names, never trust raw numbers.**
- One-file app `viz/index.html` (~9.4k lines): app IIFE with the `Synth` IIFE near the bottom (`const Synth = (function () {`). Python side: `hub.py` (OSC→ws), `run.py`, `workers/`. Conventions, worktrees, commit style, §0.7 regression (zero console errors; `♪ T O B G` + ⌘Z all work), temp-hook discipline, never-deploy/never-main: all per `GODRUM_BRIEF.md` Part 0 — read it.
- Relevant machinery you inherit: the groove clock (`frame()` advances `seqPhase`; on each new 16th `seqCurStep = st16; seqTick()`), `seqs` (`{on, steps[16], vel, chance, rat, lock}` via `seqFor`/`drumSeqFor`, normalized by `migrateSeq`, serialized wholesale by `serializePatch`), the beat panel (`openBeat`, lanes, `beatRefresh`), the arranger grooves tab, `exportMid()` (ch-9 drum block added by GODRUM-E), `Synth.pluck(midi, vel)`, GODSPEED's `arp`/`arpLatch(note, vel)`, UNGODLY's `ungodlyMark`/`ungodlyGesture` (mark BEFORE mutating), the master chain `out → squash → squashMakeup → lim → destination` (~8585 pre-ε), the `Recorder` AudioWorklet (fixed-length captures — NOT what TESTAMENT uses), `gScale`/`SCALES`/`scaleRows`, and the hub's `_scan_patches` (serves named template patches from a dir to the client's template select).
- Branches: `revelation/t-testament` · `revelation/w-wheels` · `revelation/p-psalm` · `revelation/c-canon` · `revelation/o-closer` (ω), each from post-ε `main`.
### Stage map
| stage | lanes | parallel-safe because |
|---|---|---|
| 1 | **T 📼 ∥ W ☸** | T = Synth IIFE + one header button; W = app-scope sequencer/panels/exportMid — disjoint, and neither touches ε's `handleMessage` |
| 2 (after 1 merges) | **P 🕊 ∥ C ✦** | P = index.html only (new self-contained block); C = Python + a new `factory/` dir + JSON — file-disjoint |
| 3 (after 2 merges) | **ω the closer** | grimoire chapter (all four), manual, cross-feature QA, deploy checklist |
Reviews between stages. Do not start later-stage work.
---
# Stage 1T — 📼 TESTAMENT ("it is written")
**Territory:** inside the `Synth` IIFE, PLUS: one header button (`#testament`, created app-scope beside the ♪ button — note the ♪ button itself is DOM built *inside* the Synth IIFE at its tail; mirror that pattern and build yours there too, keeping you 100% IIFE-resident), one `ctxItem`, one small CSS block. Nothing else.
**T.1 The tap.** Recording captures the TRUE final mix — post-squash, post-limiter. In `build()`: expose `lim` on the returned `N` (one word added to the return object). The recorder itself is lazy:
```js
let testDest = null, testRec = null, testChunks = [], testT0 = 0, testTimer = null;
function testamentStart() {
if (!ctx) start(); // wake the graph — a recording can begin the session
if (testRec) return false; // already rolling
if (!testDest) { testDest = ctx.createMediaStreamDestination(); N.lim.connect(testDest); }
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]
.find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m));
if (!mime) return false; // (Safari fallback lands on audio/mp4)
testChunks = []; testRec = new MediaRecorder(testDest.stream, { mimeType: mime, audioBitsPerSecond: 256000 });
testRec.ondataavailable = e => { if (e.data && e.data.size) testChunks.push(e.data); };
testRec.start(1000); // timesliced — a crash loses ≤1s
testT0 = ctx.currentTime; return true;
}
function testamentStop() { ... } // → Promise<{blob, secs, ext}> resolved in onstop
function testamentState() { return { on: !!testRec, secs: testRec ? ctx.currentTime - testT0 : 0 }; }
```
Export `testamentStart/Stop/State` on the Synth return object. The worklet `Recorder` (GODSONIQ/AMPLER) is untouched — different machinery, different purpose.
**T.2 The button.** `#testament` next to `#sound` (♪): shows `⏺`; while rolling it turns red (`.on`), pulses gently, and shows elapsed `m:ss` (update via a 1s interval only while rolling). Click toggles. On stop: build the file name `godstrument-testament-<yyyymmdd-hhmm>.<ext>` (derive the timestamp from `new Date()` at stop time), trigger the download (the `a[download]` + `URL.createObjectURL` idiom), and ticker: `📼 testament: <m:ss> — it is written`. Failure (no MediaRecorder) → ticker `📼 this browser cannot bear witness`, button stays off. Add `ctxItem("📼 TESTAMENT — record what god does", …)` near GODSONIQ.
**T.3 Verify.** Live: record ~6s of the sounding world (drums playing), stop → blob size > 0, elapsed matches ±0.5s, and **prove the audio is real**: load the blob into an `<audio>` element and play it, or decode it back through `decodeAudioData`/an offline probe and measure nonzero RMS. Record-while-♪-off (graph wakes, captures the beat you then start). Two consecutive recordings (state resets). §0.7 regression, zero console errors. Note honestly what the headless env can't do (audition by ear).
---
# Stage 1W — ☸ WHEELS ("a wheel within a wheel")
**Territory:** viz/index.html app scope, OUTSIDE the Synth IIFE: the seq factories (`seqFor`, `drumSeqFor`), `migrateSeq` (+ its self-check), the clock lines in `frame()`, `seqTick`, the S&H block in `computeDests`, `applyGroove` call sites, the beat panel + arranger grooves tab (lane-length UI + playhead paint), `exportMid`'s drum block. NOT the Synth IIFE, NOT `handleMessage`, NOT GODSPEED's arp block.
**W.1 The data.** Every seq gains `len` (int 116, default 16): add to both factories; `migrateSeq` clamps/defaults it (`Number.isFinite` → round → clamp 1..16, else 16) and the self-check gains one assertion (a len-12 seq round-trips; a len-less legacy seq gets 16). It rides `serializePatch` automatically — verify, don't assume.
**W.2 The clock.** Add ONE global absolute step counter: `let seqAbsStep = 0;`, incremented exactly where `seqCurStep` is assigned in `frame()` (`seqCurStep = st16; seqAbsStep++; seqTick();`). Then one tiny helper next to `seqFor`:
```js
const wheelStep = (sq) => (sq && sq.len && sq.len < 16) ? seqAbsStep % sq.len : seqCurStep;
```
Replace the per-seq step index in: `seqTick`'s drum branch (`const cur = wheelStep(sq)`), `seqTick`'s note branch, the S&H sampling in `computeDests`, and every `applyGroove(sq, step, v)` call site (grep them — pass `wheelStep(sq)`). A full-length lane (`len` 16) must behave **bit-identically to today** (the helper returns `seqCurStep` — that's the regression guarantee).
**W.3 The UI.** Per-lane length control in BOTH grid editors, minimal and house-style:
- Beat panel: a small `16`-showing number chip in each lane's lead (beside ⬢); click-drag or click-to-edit 116; `ungodlyGesture("wheel")` before mutating. Cells at index ≥ `len` render dimmed (a `.off` class, ~35% opacity) and clicking them does nothing; the playhead for that lane paints at `wheelStep(sq)`.
- Arranger grooves tab: the same control in the euclid row for the selected lane; the big grid dims ≥ `len` and its playhead uses `wheelStep`.
- Euclid fills should fill within `len` (pass `len` as the default `len` argument where the lane's control is present — keep the explicit euclid-steps input working as an override).
**W.4 Export.** In `exportMid`'s ch-9 drum block: replace the `bar × 16-step` nesting with one absolute loop — `for (let sa = 0; sa < arr.bars * 16; sa++) { const s2 = sq.len < 16 ? sa % sq.len : sa % 16; … tick = sa * TICKS16 … }` — so a 12-wheel hat exports exactly as it plays. Note lanes stay as-is (they're arranged by clips; polymeter for note clips is a flagged future, not v1).
**W.5 Verify.** Deterministic: kick len 16 + hat len 12 → drive 48 ticks (LCM) and assert the hat's fire pattern wraps at 12 while kick wraps at 16 (spy `Synth.drum` or the branch internals via a temp hook); len 1 fires every step; legacy patch (no len) loads → all 16, identical behavior to pre-WHEELS (byte-level: same fires over 32 ticks). S&H on a source with len 8 samples on the 8-wheel. UI: dimmed cells, per-lane playheads diverge visibly, undo reverts a len change. Export: byte-parse a 12-wheel pattern → note-ons at ticks matching `sa % 12`. §0.7 + GODRUM/PANTHEON regression, zero console errors.
---
# Stage 2P — 🕊 PSALM ("sing, and the instrument answers")
**Territory:** viz/index.html only — one new self-contained block (place after the GODSPEED block), one `ctxItem`, one small CSS chip. Nothing in the Synth IIFE; you consume `Synth.pluck` and `arpLatch` (both exist).
**P.1 The listener.** Mic analysis lives in its OWN throwaway `AudioContext` — the mic never enters the music graph (zero feedback risk; say so in a comment). `getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } })``AnalyserNode` (fftSize 2048) → a `setInterval(…, 25)` loop (NOT rAF — hidden tabs freeze it) pulling time-domain frames.
**P.2 The pitch.** Autocorrelation in plain JS (house-idiomatic, no libraries): normalize the frame, compute ACF over lags spanning 801000 Hz, pick the first peak above 0.9 × max with parabolic interpolation; reject frames with RMS below the sensitivity threshold or ACF clarity < ~0.6. Convert `freq → midi` (float). Write the detector as a **pure function of a Float32Array** (`psalmDetect(buf, sampleRate) → {freq, clarity, rms}`) so it's unit-testable without a mic.
**P.3 The note.** Quantize the float midi to the global scale — reuse the instrument's own machinery (`gScale` + `scaleRows`/the quantize helpers — grep how the piano roll folds to scale and mirror it; nearest scale degree across octaves). Note-on logic: gate opens when RMS > threshold AND the same quantized note holds for 2 consecutive frames → fire ONCE; re-fire only when the quantized note *changes* (legato) or after the gate closes (RMS below threshold for ~150ms) and reopens. Velocity from RMS (clamped 0.31). Where it goes: **if `arp.on` → `arpLatch(note, vel)`** (sing your chord into GODSPEED, one note at a time — the killer demo); **else → `Synth.pluck(note, vel)`**.
**P.4 The UI.** `ctxItem("🕊 PSALM — sing, and the instrument answers", …)` toggles it (requests the mic on first enable; denial → ticker `🕊 the instrument cannot hear you — allow the microphone`, state off). While on: a small fixed chip (near the launcher stack) showing 🕊 + the live note name (`midiToName`), dimmed when the gate is closed; clicking the chip toggles off. A sensitivity slider on the chip's hover/click is optional — a sane fixed threshold with a `PSALM_SENS` const is acceptable v1. Privacy note in a comment: local analysis only, nothing leaves the page.
**P.5 Verify.** The detector is pure — unit-test it headlessly: synthesize Float32Array sines (220, 440, 554.37 Hz + a noisy one + silence) → assert freq within ±1%, clarity high, silence rejected; run the note-logic as a pure state machine on scripted `{freq, rms}` sequences → assert single-fire, legato re-fire, gate close/reopen, correct scale-fold (set `gScale` to minor pent, feed 445 Hz → the folded degree). Live mic = human lane; say so. UI chip renders/toggles; mic-denied path degrades cleanly. §0.7, zero console errors.
---
# Stage 2C — ✦ CANON (factory dimensions)
**Territory:** Python + data only: `hub.py` (`_scan_patches` region), a NEW git-tracked `factory/` directory of patch JSONs, `run.py` only if genuinely needed (it shouldn't be). ZERO index.html.
**C.1 The shelf.** Today `_scan_patches` serves user patches from the gitignored `patches/` dir. Add a second scan of `factory/` (git-tracked, ships with the deploy — confirm the CLAUDE.md rsync filter doesn't exclude it; it doesn't) whose entries appear in the same `patchList` with names prefixed `✦ ` (e.g. `✦ first light in stereo`). Factory patches are read-only: the hub's save path must never write into `factory/` (guard by name prefix). Client-side needs nothing — the template select already renders whatever `patchList` carries.
**C.2 The canon itself.** Author **four** patches as JSON (the `serializePatch` shape: `routes, tweaks, seqs, arr, bpm, groove, scale, kit`). Derive the exact shape by reading `serializePatch`/`loadPatch` and one existing saved patch if available. The four, each a one-line lesson in an instrument feature:
1. **`✦ the heartbeat`** — a GODRUM showcase: mpc 8-4 groove, a tasteful boom-bap kit (use the seeded pattern as a base, vary it), kick duck 0.5, bpm 88.
2. **`✦ the wheel within the wheel`** — WHEELS showcase: kick len 16, hat len 12, clap len 10, sparse, hypnotic, bpm 96. (Gate: only author this if WHEELS has merged — it has, you're Stage 2.)
3. **`✦ the squeeze`** — GODSQUASH showcase: a route from a pumping world source onto `squash` at high amount, drums under it, bpm 120.
4. **`✦ quiet arrival`** — a gentle first-light-style ambient patch: earth echo audible, sparse routes, no drums, slow bpm.
Validate each by round-tripping through the client's `migrateSeq` expectations (16-step arrays, sane ranges) — write a tiny `--check`-style python validator in the lane (a `test_factory.py`-named file so the deploy filter excludes it) that asserts shape.
**Taste disclaimer in your report:** these are structural drafts for the human to re-voice by ear; correctness of shape is your deliverable, beauty is his.
**C.3 Verify.** Run the hub locally (`--profile minimal`), confirm `patchList` carries the four `✦` names, load one via the client template select in a browser (full path), confirm the read-only guard (attempt a save named `✦ x` → rejected/redirected), and that a missing/empty `factory/` dir degrades silently. Zero index.html changes (`git diff --stat`).
---
# Stage 3ω — the closer (brief finalized after Stage 2 review)
Scope preview: grimoire chapter for all four (Ezekiel 1:16 belongs in the WHEELS entry — *"as it were a wheel in the middle of a wheel"*; PSALM's privacy line — the instrument listens locally, nothing leaves the machine; TESTAMENT's "it is written"; the CANON as the shelf of worked examples) · `build_manual.py` + commit manual · cross-feature QA (record a TESTAMENT of a WHEELS beat while PSALM latches the arp — the whole wave in one take) · deploy checklist. Do not start early.

46
auth.py
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

29
hub.py
View File

@ -122,7 +122,6 @@ class Hub:
self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t)
self._last_raw: dict[str, float] = {} # last value fed to the normalizer
self._event_q: "queue.Queue[tuple[str,float,float]]" = queue.Queue()
self._godspeak_q: "queue.Queue[dict]" = queue.Queue() # 🗣 GODSPEAK text/state → clients (a voice command channel, not a signal)
self.out = SimpleUDPClient(cfg.get("osc_out_host", "127.0.0.1"),
cfg.get("osc_out_port", 9001))
@ -147,24 +146,6 @@ class Hub:
# ---- OSC ingest (runs in the OSC server thread) --------------------
def _osc_handler(self, address: str, *args):
# 🗣 GODSPEAK — text/state, not a norm signal: relay it straight to clients.
# Muted entirely in read-only mode (voice is a command channel, and the
# ws control path has no auth). Queued here (OSC thread) → drained and
# broadcast in control_loop (asyncio thread), like every other OSC ingest.
if address == "/godspeak/rec" or address == "/godspeak":
if self.readonly:
return
if address == "/godspeak/rec":
try:
rec = 1 if (args and float(args[0]) >= 0.5) else 0
except (TypeError, ValueError):
rec = 0
self._godspeak_q.put({"rec": rec})
elif args:
text = str(args[0]).strip()
if text:
self._godspeak_q.put({"text": text})
return
if not args:
return
try:
@ -210,16 +191,6 @@ class Hub:
self.recent_events.append(ev)
self.recent_events = self.recent_events[-40:]
# 🗣 GODSPEAK — relay queued voice text/state to clients immediately.
# Always drain (so it can't grow unbounded); broadcast only if anyone's listening.
while True:
try:
obj = self._godspeak_q.get_nowait()
except queue.Empty:
break
if self.clients:
websockets.broadcast(self.clients, json.dumps({"godspeak": obj}))
# snapshot raw inbox
with self._raw_lock:
inbox = dict(self._raw_inbox)

View File

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

14
run.py
View File

@ -33,7 +33,6 @@ WORLD = [
"workers/world_air.py",
"workers/world_sun.py",
"workers/world_iss.py",
"workers/world_sats.py",
"workers/world_crypto.py",
"workers/world_fx.py",
"workers/world_planes.py",
@ -50,7 +49,7 @@ WORLD = [
]
# workers that retarget to the venue city
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py", "world_sats.py"}
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py"}
BBOX_WORKERS = {"world_planes.py"}
@ -132,8 +131,6 @@ def main():
help="also run the dealgod market worker (reads the warehouse)")
ap.add_argument("--sports", action="store_true",
help="also run the sports worker (TheSportsDB free tier — rate-limited shared key, so opt-in)")
ap.add_argument("--godspeak", action="store_true",
help="also run the GODSPEAK voice worker (local MLX Whisper — Apple-Silicon only, opt-in)")
ap.add_argument("--no-browser", action="store_true")
args = ap.parse_args()
@ -173,10 +170,6 @@ def main():
if start_worker("workers/world_sport.py"):
print(" sports worker -> the world's fixtures (thesportsdb, opt-in)")
if args.godspeak:
if start_worker("workers/godspeak_worker.py"):
print(" godspeak worker -> hold Right ⌥ to speak commands into the sky (local, Apple-Silicon only)")
if args.record:
if start_worker("recorder.py"):
print(" recording -> godstrument.db")
@ -204,11 +197,6 @@ def main():
now = time.time()
for w in workers: # revive any worker that died
if w["proc"].poll() is not None and now >= w["next_ok"]:
if w["proc"].returncode == 0: # exit 0 = a deliberate, polite goodbye
if not w.get("retired"): # (missing dep etc.) — don't churn it
w["retired"] = True
print(f" ({os.path.basename(w['path'])} exited cleanly; retired)")
continue
print(f" ({os.path.basename(w['path'])} died; restarting)")
np = launch(w["path"], w["extra"])
if np:

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,149 +0,0 @@
"""GODSPEAK — the voice of god. The user's own wersperr (local MLX Whisper),
wired into the sky: hold Right , speak a command, release. The transcript is
sent to the hub as OSC and reaches every client as a voice event Stage 3 (ε)
gives it a grammar ("tempo ninety", "storm mood", "take it back").
LOCAL-RIG ONLY. MLX Whisper is Apple-Silicon-only, so this worker runs only
where the user's Mac runs the hub — the VPS simply never has it, exactly like
the ToF / vision workers. It is opt-in (`run.py --godspeak`) and is never in
the default worker set.
Its dependencies are deliberately NOT in requirements.txt the VPS install must
stay clean. On a Mac, into the hub's venv:
pip install mlx-whisper sounddevice pynput # numpy rides along
Model: mlx-community/whisper-large-v3-turbo (~1.6 GB, downloaded on first run).
macOS one-time permissions (System Settings Privacy & Security) for the
terminal/app that launches this worker same as wersperr:
- Microphone to hear you (sounddevice input)
- Input Monitoring for pynput to see the Right hotkey held globally
(older macOS may list this under Accessibility instead)
python3 workers/godspeak_worker.py # hold Right ⌥ to speak
python3 workers/godspeak_worker.py --check # self-test: model loads + silence transcribes, then exit
OSC it sends (to the hub, udp/9000):
/godspeak/rec 1 on key-down (a 🗣 pulse can rise while recording)
/godspeak/rec 0 on release
/godspeak <text> after transcription
"""
import argparse
import sys
import threading
MODEL = "mlx-community/whisper-large-v3-turbo"
SAMPLE_RATE = 16000 # Whisper's native rate; mono float32
def main():
ap = argparse.ArgumentParser(
description="GODSPEAK worker — local voice commands into the sky (Apple-Silicon only, opt-in).")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--check", action="store_true",
help="self-test: load the model and transcribe silence, then exit")
args = ap.parse_args()
# Lazy import of the optional Apple-Silicon voice stack. Anywhere without MLX
# (the VPS, a bare venv) this skips cleanly so the rest of the rig runs — the
# house pattern shared with the vision / MIDI workers.
try:
import numpy as np
import sounddevice as sd
import mlx_whisper
from pynput import keyboard
except Exception:
print("godspeak worker: mlx-whisper/sounddevice/pynput not available — skipping (optional)")
sys.exit(0)
from pythonosc.udp_client import SimpleUDPClient
def transcribe(audio):
return mlx_whisper.transcribe(audio, path_or_hf_repo=MODEL)["text"].strip()
if args.check:
# Self-check: the model loads and transcribes silence without exploding.
result = transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32))
assert isinstance(result, str)
print("ok: godspeak model loaded, pipeline works")
sys.exit(0)
client = SimpleUDPClient(args.host, args.port)
HOTKEY = keyboard.Key.alt_r # Right Option — hold to dictate
def emit(addr, value):
# Never let a transient socket hiccup kill the hotkey listener.
try:
client.send_message(addr, value)
except Exception as exc:
print(f"godspeak worker: OSC send failed ({exc}); continuing")
frames = []
stream = {"s": None} # boxed so the key callbacks can rebind it
tx_lock = threading.Lock() # MLX isn't safe under concurrent eval — one utterance transcribes at a time
def start_recording():
if stream["s"]:
return
frames.clear()
s = None
try: # a busy / absent input device must not tear down the hotkey listener
s = sd.InputStream(
samplerate=SAMPLE_RATE, channels=1, dtype="float32",
callback=lambda data, *_: frames.append(data.copy()),
)
s.start()
except Exception as exc:
print(f"godspeak worker: mic open failed ({exc}); ignoring this press", flush=True)
if s is not None:
try: s.close()
except Exception: pass
return
stream["s"] = s
emit("/godspeak/rec", 1)
print("● recording...", flush=True)
def stop_recording():
s = stream["s"]
if not s:
return
s.stop(); s.close(); stream["s"] = None
emit("/godspeak/rec", 0)
if not frames:
return
audio = np.concatenate(frames).flatten()
if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps (< 1/3 s)
return
# transcribe off the hotkey thread so the keyboard stays responsive
threading.Thread(target=lambda: finish(audio), daemon=True).start()
def finish(audio):
with tx_lock: # serialize: back-to-back commands can't run two MLX graphs at once
text = transcribe(audio)
if text:
print(f"{text}", flush=True)
emit("/godspeak", text)
def on_press(key):
if key == HOTKEY:
start_recording()
def on_release(key):
if key == HOTKEY:
stop_recording()
print("loading model (first run downloads ~1.6GB)...", flush=True)
transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) # warm up so the first real utterance is fast
print(f"godspeak ready — hold Right ⌥ and speak (→ hub {args.host}:{args.port}); Ctrl+C to quit", flush=True)
with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
try:
listener.join()
except KeyboardInterrupt:
print("godspeak worker: shutting down")
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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