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>
94 lines
3.9 KiB
Python
94 lines
3.9 KiB
Python
"""
|
|
world_almanac.py — the planting calendar. Sowing by the moon.
|
|
|
|
For millennia people planted by an almanac: the moon's phase and the zodiac sign
|
|
it's passing through told you what to sow. It's not fetched data — it's computed
|
|
from the same sky world_ephemeris uses. The zodiac's four elements cycle in a
|
|
fixed order (fire, earth, air, water), and biodynamic tradition maps them to a
|
|
crop type — a slow four-step sequencer the moon walks through, changing every
|
|
~2.25 days:
|
|
|
|
earth signs -> ROOT days water signs -> LEAF days
|
|
air signs -> FLOWER days fire signs -> FRUIT/SEED days
|
|
|
|
Musically it's a modal calendar: the instrument's character can follow the moon
|
|
through the garden.
|
|
|
|
Emits:
|
|
/gs/almanac/element 0..1 stepped — the day's element (root/leaf/flower/fruit)
|
|
/gs/almanac/waxing 1 waxing (sow above-ground), 0 waning (sow roots)
|
|
/gs/almanac/dec 0..1 — Moon's declination (ascending/descending, ~27.3d)
|
|
/gs/almanac/fertile 0..1 — a simple biodynamic favourability score
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import argparse
|
|
import datetime
|
|
import math
|
|
import time
|
|
|
|
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
|
|
|
|
FLG = swe.FLG_MOSEPH | swe.FLG_SPEED
|
|
# element index by sign (Aries..Pisces): fire,earth,air,water repeating.
|
|
# biodynamic crop: 0 fire=fruit, 1 earth=root, 2 air=flower, 3 water=leaf
|
|
ELEMENTS = ["fruit", "root", "flower", "leaf"]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--host", default="127.0.0.1")
|
|
ap.add_argument("--port", type=int, default=9000)
|
|
ap.add_argument("--interval", type=float, default=30.0)
|
|
args = ap.parse_args()
|
|
|
|
client = SimpleUDPClient(args.host, args.port)
|
|
print(f"[almanac] emitting /gs/almanac/* (root/leaf/flower/fruit by the moon) "
|
|
f"every {args.interval:g}s")
|
|
|
|
last_label = None
|
|
while True:
|
|
try:
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
jd = swe.julday(now.year, now.month, now.day,
|
|
now.hour + now.minute / 60 + now.second / 3600)
|
|
moon, _ = swe.calc_ut(jd, swe.MOON, FLG)
|
|
sun, _ = swe.calc_ut(jd, swe.SUN, FLG)
|
|
eq, _ = swe.calc_ut(jd, swe.MOON,
|
|
swe.FLG_MOSEPH | swe.FLG_EQUATORIAL | swe.FLG_SPEED)
|
|
|
|
sign = int(moon[0] // 30) % 12
|
|
elem = sign % 4 # 0 fire,1 earth,2 air,3 water
|
|
phase_angle = (moon[0] - sun[0]) % 360
|
|
waxing = 1.0 if phase_angle < 180 else 0.0
|
|
illum = (1 - math.cos(math.radians(phase_angle))) / 2
|
|
dec = eq[1] # Moon declination (deg)
|
|
dec01 = max(0.0, min(1.0, (dec + 28.5) / 57.0))
|
|
ascending = 1.0 if eq[4] > 0 else 0.0
|
|
fertile = 0.45 * illum + 0.35 * ascending + 0.20 * (1.0 if elem in (1, 3) else 0.0)
|
|
|
|
client.send_message("/gs/almanac/element", elem / 3.0)
|
|
client.send_message("/gs/almanac/waxing", waxing)
|
|
client.send_message("/gs/almanac/dec", dec01)
|
|
client.send_message("/gs/almanac/fertile", fertile)
|
|
|
|
label = ELEMENTS[elem]
|
|
if label != last_label:
|
|
print(f" 🌱 {label} day (moon in sign {sign + 1}, "
|
|
f"{'waxing' if waxing else 'waning'}, "
|
|
f"{'ascending' if ascending else 'descending'})")
|
|
last_label = label
|
|
except Exception as e: # noqa
|
|
print(f"[almanac] warn: {e}")
|
|
time.sleep(args.interval)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|