From 8b486f81ebcb0a44bef4876df81ac25b5c0fbfac Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 16:12:28 -0700 Subject: [PATCH] =?UTF-8?q?notespatial:=20a=20ring=20score=20as=20a=20setl?= =?UTF-8?q?ist=20item=20=E2=80=94=20run-fleet-nsscore.py=20conducts=20any?= =?UTF-8?q?=20.nsscore=20folder=20for=20the=20venue=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copied into a song folder as run-fleet.py beside one .nsscore, it makes the folder a venue item: no arguments preflights the seats, --run stages the score into /pieces/spatial-rehearsal.nsscore with readback, re-jumps whatever piece each seat is on, cues through the rehearsal tool's clock probes, watches the run to its end, puts the previous score back (unless --keep) and writes run-.json for venue_result. First items: the two MIDI arrangements, Rush E and the Naruto bomb, at the front of the room queue as a MIDI support test. Co-Authored-By: Claude Fable 5.1 --- fedac/native/scripts/run-fleet-nsscore.py | 168 ++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 fedac/native/scripts/run-fleet-nsscore.py diff --git a/fedac/native/scripts/run-fleet-nsscore.py b/fedac/native/scripts/run-fleet-nsscore.py new file mode 100644 index 0000000000..1b37386374 --- /dev/null +++ b/fedac/native/scripts/run-fleet-nsscore.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""run-fleet-nsscore.py — a song folder's own conductor for a ring .nsscore. + +Copied into a folder as run-fleet.py beside one .nsscore, it makes that +folder a venue setlist item (toolchain/mcp/ac-venue-mcp.mjs prefers a +folder's run-fleet.py: no arguments preflights, --run performs). The seats +keep whatever piece they are on (blueberry's notespatial-controls wrapper +or the stock spatial-rehearsal piece): the score is put in +/pieces/spatial-rehearsal.nsscore, the piece is re-jumped so it loads, the +rehearsal tool cues it with clock probes, the run is watched to its end, +and the score that was there before is put back. A receipt run-.json +lands in the folder for venue_result. + + python3 run-fleet.py preflight: seats reachable, idle, mic closed + python3 run-fleet.py --run stage, cue, watch, restore, receipt + python3 run-fleet.py --run --keep leave the score on the seats afterwards + +Env: TRIO_FLEET (fleet.json; default ~/.ac-os/culturehub/fleet.json), +TRIO_REPO (the aesthetic-computer checkout; default ~/aesthetic-computer), +NSSCORE (the score; default the one .nsscore in this folder). +""" +import glob, hashlib, json, os, signal, subprocess, sys, time, urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +FLEET = Path(os.environ.get("TRIO_FLEET") or Path.home() / ".ac-os/culturehub/fleet.json") +REPO = Path(os.environ.get("TRIO_REPO") or Path.home() / "aesthetic-computer") +RUN = "--run" in sys.argv +KEEP = "--keep" in sys.argv +SCORE_FILE = os.environ.get("NSSCORE") +PIECE_FILE = "spatial-rehearsal.nsscore" +RUN_ID = f"nsscore-{int(time.time())}" +scores = [Path(SCORE_FILE)] if SCORE_FILE else [Path(p) for p in sorted(glob.glob(str(HERE / "*.nsscore")))] +if len(scores) != 1: sys.exit(f"expected one .nsscore in {HERE} (or NSSCORE=path), found {len(scores)}") +SCORE_PATH = scores[0] +SCORE_BYTES = SCORE_PATH.read_bytes() +SCORE = json.loads(SCORE_BYTES) +SCORE_HASH = hashlib.sha256(SCORE_BYTES).hexdigest() +EVENTS = sorted(e["t"] for lane in SCORE["lanes"] for e in lane["events"]) + +def http(host, path, body=None, timeout=8): + req = urllib.request.Request(f"http://{host}{path}", data=body, method="PUT" if body is not None else "GET") + with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() +def post(host, path, timeout=8): + req = urllib.request.Request(f"http://{host}{path}", data=b"", method="POST") + with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() +def status(host): + return json.loads(http(host, "/pieces/spatial-rehearsal-status.json", timeout=4)) +def piece(host): + return json.loads(http(host, "/status", timeout=4)).get("piece") +def log(msg): print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + +fleet = json.loads(FLEET.read_text()) +hosts = [row[0] for row in fleet] +labels = {row[0]: (row[2] if len(row) > 2 else row[0]) for row in fleet} + +def readiness(): + seats, problems = {}, [] + for h in hosts: + try: + st = status(h); pc = piece(h) + except Exception as e: problems.append(f"{h} ({labels[h]}): unreachable: {e}"); continue + seats[h] = {"seat": st.get("seat"), "seats": st.get("seats"), "phase": st.get("phase"), "piece": pc, "scoreDuration": st.get("scoreDuration")} + mic = st.get("microphone") or {} + if st.get("error"): problems.append(f"{h}: error {st['error']}") + if mic.get("hot") or mic.get("recording"): problems.append(f"{h}: microphone open") + if st.get("phase") not in ("ready", "idle", "stopped", "armed", None): problems.append(f"{h}: phase {st.get('phase')}") + seatnums = [s["seat"] for s in seats.values()] + if len(set(seatnums)) != len(seatnums): problems.append(f"duplicate seats: {seatnums}") + if len(seats) != SCORE.get("seats", len(hosts)): problems.append(f"{len(seats)} seats up, score wants {SCORE.get('seats')}") + return seats, problems + +seats, problems = readiness() +ordered = [h for h, s in sorted(seats.items(), key=lambda kv: (kv[1]["seat"] if kv[1]["seat"] is not None else 99))] +log(f"{SCORE.get('name')}: {SCORE['dur']:.1f} s, {len(EVENTS)} events, sha {SCORE_HASH[:12]}") +for h in ordered: log(f" seat {seats[h]['seat'] + 1 if seats[h]['seat'] is not None else '?'} {h} {labels[h]}: {seats[h]['piece']} {seats[h]['phase']} (score {seats[h]['scoreDuration']})") +for p in problems: log(" ! " + p) +if not RUN: + print("READY" if not problems else "NOT READY"); sys.exit(0 if not problems else 1) +if problems: sys.exit("not ready; refusing to run") + +receipt = {"runId": RUN_ID, "score": SCORE_PATH.name, "scoreHash": SCORE_HASH, "title": SCORE.get("name"), "duration": SCORE["dur"], "events": len(EVENTS), + "seats": [{"host": h, "seat": seats[h]["seat"], "label": labels[h], "piece": seats[h]["piece"]} for h in ordered], + "startedAt": time.time(), "completed": False, "error": None, "seatWarnings": [], "samples": [], "cleanup": {}} +def write_receipt(): (HERE / f"run-{RUN_ID}.json").write_text(json.dumps(receipt, indent=2) + "\n") +write_receipt() + +def put_verified(h, name, data): + for attempt in (1, 2): + http(h, f"/pieces/{name}", data, timeout=30) + back = http(h, f"/pieces/{name}", timeout=30) + if back == data: return + log(f"{h}: readback differs for {name} ({len(back)}/{len(data)} bytes), attempt {attempt}") + raise RuntimeError(f"{h}: could not stage {name}") + +# 1. what was there (put back at the end unless --keep) +previous = {} +for h in ordered: + try: previous[h] = http(h, f"/pieces/{PIECE_FILE}", timeout=30) + except Exception as e: log(f"{h}: no previous score ({e})"); previous[h] = None +prev_hashes = {h: hashlib.sha256(b).hexdigest()[:12] for h, b in previous.items() if b} +receipt["cleanup"]["previousScore"] = sorted(set(prev_hashes.values())) +if previous and any(b and b != SCORE_BYTES for b in previous.values()): + for hsh in set(prev_hashes.values()): + src = next(b for h, b in previous.items() if b and hashlib.sha256(b).hexdigest()[:12] == hsh) + (HERE / f"previous-{hsh}.nsscore").write_bytes(src) + +def restore(reason): + if KEEP: receipt["cleanup"]["restored"] = "kept"; return + restored = [] + for h in ordered: + b = previous.get(h) + if not b or b == SCORE_BYTES: continue + try: put_verified(h, PIECE_FILE, b); post(h, f"/jump/{seats[h]['piece'] or 'spatial-rehearsal'}"); restored.append(h) + except Exception as e: receipt["seatWarnings"].append(f"{h}: restore failed: {e}") + receipt["cleanup"]["restored"] = f"{len(restored)} seats ({reason})" + log(f"restored previous score on {len(restored)} seats ({reason})") + +def stop_all(): + for h in ordered: + try: http(h, "/pieces/spatial-rehearsal-command.json", json.dumps({"id": f"{RUN_ID}-stop", "action": "stop"}).encode()) + except Exception: pass + +def on_sigint(sig, frame): + log("interrupted: stopping seats"); stop_all(); receipt["error"] = "interrupted"; restore("interrupted"); receipt["finishedAt"] = time.time(); write_receipt(); sys.exit(130) +signal.signal(signal.SIGINT, on_sigint); signal.signal(signal.SIGTERM, on_sigint) + +try: + # 2. stage and reload + for h in ordered: put_verified(h, PIECE_FILE, SCORE_BYTES); log(f"{h}: staged {len(SCORE_BYTES)} bytes, readback verified") + for h in ordered: post(h, f"/jump/{seats[h]['piece'] or 'spatial-rehearsal'}") + deadline = time.time() + 40 + while time.time() < deadline: + loaded = {} + for h in ordered: + try: st = status(h); loaded[h] = abs(float(st.get("scoreDuration") or 0) - SCORE["dur"]) < 0.01 and st.get("phase") in ("ready", "armed", "idle") + except Exception: loaded[h] = False + if all(loaded.values()): break + time.sleep(1) + else: raise RuntimeError("seats did not report the new score: " + ", ".join(h for h, ok in loaded.items() if not ok)) + log("all seats loaded the score") + # 3. cue through the rehearsal tool (clock probes, prepare, play) + cue = subprocess.run(["node", str(REPO / "fedac/native/tools/spatial-rehearsal.mjs"), "cue", *ordered], capture_output=True, text=True, timeout=120) + receipt["cue"] = (cue.stdout + cue.stderr).strip().splitlines()[-8:] + if cue.returncode: raise RuntimeError("cue failed: " + " | ".join(receipt["cue"])) + log("cued: " + (receipt["cue"][0] if receipt["cue"] else "")) + # 4. watch to the end + t0 = time.time(); ended_at = None + while time.time() - t0 < SCORE["dur"] + 30: + time.sleep(5) + native = [] + for h in ordered: + try: + st = status(h); sct = float(st.get("scoreTime") or 0) + native.append({"receiverId": labels[h], "host": h, "phase": st.get("phase"), "scoreTime": round(sct, 2), "eventsStarted": sum(1 for t in EVENTS if t <= sct), "eventCount": len(EVENTS), "maxFrameGap": st.get("maxFrameGap", 0)}) + except Exception as e: native.append({"receiverId": labels[h], "host": h, "phase": "unreachable", "error": str(e)}) + receipt["samples"].append({"t": round(time.time() - t0, 1), "native": native}); write_receipt() + phases = {n["phase"] for n in native} + if native and phases <= {"ready", "idle", "stopped", "armed"} and time.time() - t0 > 20: ended_at = time.time(); break + receipt["completed"] = ended_at is not None + if not receipt["completed"]: receipt["error"] = "seats did not return to ready"; stop_all() + else: log(f"run ended after {ended_at - t0:.0f} s") +except Exception as e: + receipt["error"] = str(e); log("error: " + str(e)); stop_all() +finally: + restore("end of run"); receipt["finishedAt"] = time.time(); write_receipt() + log(f"receipt run-{RUN_ID}.json: completed={receipt['completed']} error={receipt['error']}") +sys.exit(0 if receipt["completed"] else 1) -- 2.51.2