Compare commits

...

30 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:12:05 +10:00
type-two
510ddaaf8c 🔧 review fixes — the five-angle audit's whole findings list
Correctness (four were my own regressions from the REVELATION wave):
· ☸ WHEELS polymeter permanently detuned after any skipped frame —
  seqAbsStep advanced by ++ per transition, but one frame can cross two
  16ths (bpm ≳155, or the clamped dt on every tab refocus). Proven to
  drift 12 steps out at 174bpm; now advances by the true distance, so
  seqAbsStep % 16 === seqCurStep always holds.
· Sample & hold with exactly one lit step froze on its first sample
  forever (guard keyed on the wrapped step, which never changed) — now
  keyed on the absolute step.
· PSALM double-activation left the MICROPHONE HOT after "stop": the
  re-entrancy guard sat after the getUserMedia await, orphaning the
  first stream/interval/context. Guard is now synchronous, a stop
  during the prompt is honoured, and a second tap while starting
  cancels. Mic off means off — the promise the feature makes.
· Live-recording a drum tap into a short wheel wrote steps past wlen
  that never played; the tap now folds onto the lane's own wheel.
· Switching MIDI out port left notes droning on the old port (pending
  off-timers fired at the NEW one) — midiFlushOut() releases every held
  note + all-notes-off before the swap.
· world_quakes' seen-set grew unbounded on long-running rigs.

Security: stored XSS via the commune room name — the one untrusted
string reaching innerHTML unescaped (hub only lower-cased it). Escaped
client-side at both sinks, and the hub now strips markup chars too.

Robustness: world_ephemeris/world_almanac imported swisseph unguarded,
so a machine without pyswisseph crash-looped them every 10s forever
against the supervisor's exit-0 contract — both now retire quietly
(verified by simulating the missing dep), and pyswisseph is documented
in requirements.

Mobile: the top bar's five separately-pinned chips collided below
~500px — the first thing a phone visitor saw was a broken header. Now
a clean row with the account chip on its own line and the source
counter moved left.

Housekeeping: dead measureSpaced(), dead #account .code selector, and
an 888K stale worktree removed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:25:10 +10:00
9adbafe342 📋 pantheon + revelation briefs — the process docs travel with the repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:21:57 +10:00
42c17f83b0 Merge branch 'pantheon/e-closer' — the pantheon is complete 2026-07-14 13:21:57 +10:00
18fd836456 🗣 pantheon: ε the closer — the voice becomes command (Stage 3ε)
The GODSPEAK grammar: tempo/bpm with spoken numbers (one twenty → 120),
squash N percent, AMPLER capture/chop, open/close every panel, play/stop
the beat, mood, skin, zen — and take it back, the voice of god un-doing.
First match wins; unmatched words arrive in the ticker, never an error.
A 🗣 chip pulses while the mic is held. Polish: arp.up pruned, the arp
breathes with groove.velo, tempo edits mark undo, serializePatch carries
godtime.locked so undoing a mood restores the lock. Grimoire chapter for
all four gods + regenerated manual + worker permission notes.

Reviewed by fable: two fixes folded in — matchSkin normalizes candidate
names (so god-s-eye matches post-convergence) and a virgin session
saying play-the-beat gets the seed pattern first.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:18:18 +10:00
d2c8ad6a6a pantheon: UNGODLY — undo, un-doing what god did (Stage 2δ)
Undo/redo as time-travel over serializePatch(): loadPatch already restores
everything and rebuilds open panels, so undo is a 32-deep ring of snapshots.
- ungodlyMark(label) snapshots BEFORE a mutation (dedup identical, cap 32,
  clears the redo future); ungodlyGesture(label) coalesces a drag burst
  (same label within 800ms) into one entry; undo/redo loadPatch under `muted`.
- ~47 mark sites at every patch mutation: mood, zero enter/exit, dimension +
  vibe load, route patch/unpatch, the beat panel (steps/groove/euclid/expr/
  kit/mute/play), the arranger (grooves/clips/scale/euclid/follow/expr/p-lock),
  and the mix (mute/solo/reset/level/trim/group). Boot restore is suppressed.
- ⌘Z undo · ⌘⇧Z redo, added before the letter shortcuts; every single-letter
  key (p/c/o/w/z/t/b/g) now guards !metaKey && !ctrlKey — so ⌘Z no longer
  toggles zen (the pre-existing quirk), while plain z still does.
- One ctxItem with a live depth count. Memory-only in v1 (reload clears it).
App scope only; nothing inside the Synth IIFE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:14:41 +10:00
e56e3bc28f 🗣 pantheon: GODSPEAK worker — the voice of god (Stage 2γ)
The user's wersperr, wired into the sky: hold Right ⌥, speak, release —
the transcript flows to the hub as OSC and reaches every client as a
voice event (Stage 3 gives it the grammar).

- workers/godspeak_worker.py: wersperr-derived (MLX Whisper, 16kHz mono,
  Right ⌥ via pynput). Sends /godspeak/rec 1|0 on key edges and
  /godspeak <text> after transcription instead of pbcopy/paste. House
  worker pattern: argparse --host/--port, lazy imports with graceful
  "not available — skipping (optional)" exit, --check self-test, warm-up.
  MLX is Apple-Silicon-only so it's local-rig-only + opt-in; deps stay
  OUT of requirements.txt (documented pip install in the docstring).
  Transcription serialized (one MLX graph at a time); a busy mic can't
  kill the hotkey listener.
- hub.py: _osc_handler special-cases /godspeak (text/state, not a float
  signal), queues it, and control_loop relays {"godspeak":{...}} to ws
  clients — dropped entirely in read-only mode (it's a command channel).
  Bridged OSC-thread → asyncio via a queue, like every other ingest.
- run.py: --godspeak opt-in flag mirroring --sports; never a default.

Python-only — zero viz/index.html, zero requirements.txt/config.json.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:35:29 +10:00
3d913b97fd Merge branch 'pantheon/b-godsquash' 2026-07-13 15:52:02 +10:00
ea13b2efd6 🫀 godsquash: master-bus compressor the world can pump
Stage 1β. A DynamicsCompressor + makeup between the master fade and the
safety limiter, driven by one normalized `squash` dest.
- build(): out → squash → makeup → lim (knee 12, attack 0.004; baseline is
  the sq=0 transparent state so it's clean before params() first runs). The
  GODSONIQ N.out tap stays pre-squash. squash/squashMakeup returned on N.
- params(): sq=c01(d("squash",0)); threshold −6→−36, ratio 1:1→8:1, release
  0.12→0.25; makeup = 1 + sq·sq — a QUADRATIC curve tuned by offline
  measurement (the compressor's RMS drop is ~5.95·sq² dB, so 1+sq² holds
  loudness level ±0.5 dB while crest factor falls monotonically).
- Routable exactly like crush: added to OMNI.MODULES fx-rack keys,
  OMNI.LABELS, and ZERO_RACK — nothing else outside the IIFE.

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

View File

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

189
PANTHEON_BRIEF.md Normal file
View File

@ -0,0 +1,189 @@
# 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.

105
RESONANCE_BRIEF.md Normal file
View File

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

180
REVELATION_BRIEF.md Normal file
View File

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

View File

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

View File

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

479
factory/the_heartbeat.json Normal file
View File

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

376
factory/the_squeeze.json Normal file
View File

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

View File

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

34
hub.py
View File

@ -25,6 +25,7 @@ import json
import math import math
import os import os
import queue import queue
import re
import threading import threading
import time import time
from functools import partial from functools import partial
@ -122,6 +123,7 @@ class Hub:
self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t) self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t)
self._last_raw: dict[str, float] = {} # last value fed to the normalizer self._last_raw: dict[str, float] = {} # last value fed to the normalizer
self._event_q: "queue.Queue[tuple[str,float,float]]" = queue.Queue() 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"), self.out = SimpleUDPClient(cfg.get("osc_out_host", "127.0.0.1"),
cfg.get("osc_out_port", 9001)) cfg.get("osc_out_port", 9001))
@ -146,6 +148,24 @@ class Hub:
# ---- OSC ingest (runs in the OSC server thread) -------------------- # ---- OSC ingest (runs in the OSC server thread) --------------------
def _osc_handler(self, address: str, *args): 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: if not args:
return return
try: try:
@ -191,6 +211,16 @@ class Hub:
self.recent_events.append(ev) self.recent_events.append(ev)
self.recent_events = self.recent_events[-40:] 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 # snapshot raw inbox
with self._raw_lock: with self._raw_lock:
inbox = dict(self._raw_inbox) inbox = dict(self._raw_inbox)
@ -377,7 +407,9 @@ class Hub:
if not user: # local/trusted hub: take the claimed name if not user: # local/trusted hub: take the claimed name
user = str(c.get("from") or "guest")[:24] user = str(c.get("from") or "guest")[:24]
self.cnames[ws] = user self.cnames[ws] = user
room = str(c.get("room") or "")[:40].strip().lower() # room names are rendered by the clients — strip anything markup-ish here too,
# so a hostile name can never reach a browser even if a client forgets to escape
room = re.sub(r"[<>&\"'`]", "", str(c.get("room") or ""))[:40].strip().lower()
R = self.communes R = self.communes
def out(names, obj): def out(names, obj):

View File

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

14
run.py
View File

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

91
test_factory.py Normal file
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

203
viz/spheres.js Normal file
View File

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

149
workers/godspeak_worker.py Normal file
View File

@ -0,0 +1,149 @@
"""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

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

View File

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

View File

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

View File

@ -65,6 +65,9 @@ def main():
if fid is None or fid in seen: if fid is None or fid in seen:
continue continue
seen.add(fid) seen.add(fid)
if len(seen) > 4000: # the all_hour feed ages ids out, so an unbounded
seen.clear() # set is a slow leak on a long-running rig; a reset
seen.add(fid) # can at worst re-fire a quake still in the window
props = feat.get("properties") or {} props = feat.get("properties") or {}
mag = props.get("mag") mag = props.get("mag")

329
workers/world_sats.py Normal file
View File

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

View File

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