From 2dfc389c5a2ad7190d29350466370bae80a9d7a2 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 16:28:19 -0700 Subject: [PATCH] Fleet: the Trio runner publishes transport and the lyric being sung to the display feed; the gate settles and retries Co-Authored-By: Claude Fable 5.1 --- .../macneopolitan/bin/run-full-trio.py | 27 ++++++++++++++++++- toolchain/mcp/ac-venue-mcp.mjs | 5 ++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/grants/culturehub-la-2026/macneopolitan/bin/run-full-trio.py b/grants/culturehub-la-2026/macneopolitan/bin/run-full-trio.py index 53431b75ce..5edda09daf 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/run-full-trio.py +++ b/grants/culturehub-la-2026/macneopolitan/bin/run-full-trio.py @@ -11,6 +11,24 @@ LOCAL=(subprocess.run(['scutil','--get','LocalHostName'],capture_output=True,tex SUB=os.environ.get('TRIO_SUB','http://127.0.0.1:8788').rstrip('/');DMX=os.environ.get('TRIO_DMX','http://127.0.0.1:8790').rstrip('/') def islocal(h):return h.lower()==LOCAL RTT_MAX=float(os.environ.get('TRIO_CLOCK_RTT_MAX','0.04')) # seconds; the 40 ms contract unless explicitly widened +# The display feed (blueberry:8796 → neo's stage service → Xbox / ac7): a +# transport heartbeat with the lyric being sung. Best effort, never fatal. +VIS=os.environ.get('TRIO_VIS','http://192.168.1.234:8796').rstrip('/') +def lyric_at(t): + cur=None;nxt=None + for l in plan.get('lyrics',[]): + if t>=l['t']-.3 and tt and nxt is None:nxt=l + return cur,nxt +def visuals(playing,elapsed): + cur,nxt=lyric_at(elapsed) if playing else (None,None) + body={'playing':playing,'elapsed':max(-1,min(plan['duration']+1,elapsed)),'title':plan.get('title'),'dance':'trio-round-v1','bpm':plan['bpm'],'duration':plan['duration'], + 'lyric':cur and {'text':cur['text'],'member':cur['member'],'rgb':cur['rgb'],'t':cur['t'],'dur':cur['dur']},'next':nxt and {'text':nxt['text'],'member':nxt['member'],'rgb':nxt['rgb'],'in':round(nxt['t']-elapsed,2)}} + try: + raw=json.dumps(body).encode() + with urllib.request.urlopen(urllib.request.Request(VIS+'/api/transport',raw,{'Content-Type':'application/json','Origin':VIS},method='POST'),timeout=1) as r:r.read() + return True + except Exception:return False plan=json.loads((OUT/'plan.json').read_text());nodes=json.loads((OUT/'native-loaded.json').read_text()) bundle=json.loads((OUT/'prepared.json').read_text()) if (OUT/'prepared.json').exists() else {'id':None,'singers':[],'stems':{}} # a piece without singers has no bundle members=[p['member'] for p in plan.get('payloads',[])];runid='full-trio-'+uuid.uuid4().hex[:10];errors=[];quit=threading.Event();record={'runId':runid,'arrangementHash':plan['arrangementHash'],'timing':'Native simulation-frame dispatch; acoustic alignment not calibrated','checks':{},'samples':[]} @@ -62,7 +80,11 @@ def ack(n,phase): time.sleep(.02) raise RuntimeError((n['id'],'No '+phase+' acknowledgment')) def nativecheck(n): - s=status(n);time.sleep(.07);s2=status(n) + # a seat mid-stall reads the same audioTime twice: try again a few times before calling it stale + for attempt in range(6): + s=status(n);time.sleep(.07);s2=status(n) + if s2['audioTime']>s['audioTime'] and s2['instance']==s['instance']:break + time.sleep(.3) assert s2['audioTime']>s['audioTime'] and s2['instance']==s['instance'],n['id']+' stale' assert s2['phase']=='ready' and not s2['error'] and s2['arrangementHash']==plan['arrangementHash'] and s2['receiverId']==n['id'],s2 assert s2['mono'] and s2['monoOutput']=='left' and not s2['microphoneHot'] and s2['centerReady'],s2 @@ -105,6 +127,7 @@ def keepalive(): try: parallel(lambda n:command(n,'keepalive',runId=runid),nodes) request(SUB+'/api/trio/keepalive',{'runId':runid}) + t=time.monotonic()-downbeat;record['visuals']=visuals(0<=t l.trim() && !/^\s+File|^\s{4}/.test(l)); return { ready: r.code === 0, runner: R.args[0], tail: lines.slice(-4), receipt: latestReceipt(dir)?.runId }; } @@ -181,8 +181,9 @@ async function runQueueOnce() { setRig({ owner: local, since: new Date().toISOString(), note: `setlist: ${item.label || item.out}` }); const ownConductor = existsSync(join(outDir(item.out), "run-fleet.py")); // a folder with its own conductor stages itself if (item.status !== "ready" && !ownConductor) { mark("preparing"); runnerNote = `preparing ${item.label || item.out}`; await prepare({ score: item.score, out: item.out, stage: true, keepPiece: false, allowPieces: item.allowPieces || "notespatial-controls,culturehub-rehearsal,red,connection-check,connection-controls,say" }); } + await new Promise((r) => setTimeout(r, 4000)); // the seats settle after a jump before the gate reads them mark("checking"); runnerNote = `checking ${item.label || item.out}`; const c = await check(item.out); if (!c.ready) throw new Error(`not ready: ${c.tail.join(" | ")}`); - mark("cueing"); const r = await cue({ out: item.out, announce: item.announce, rttMax: item.rttMax || 0.05 }); mark("playing", { runId: r.runId, startedAt: Date.now() / 1000 }); + mark("cueing"); const r = await cue({ out: item.out, announce: item.announce, rttMax: item.rttMax || 0.08 }); // venue Wi-Fi: ±40 ms clocks, printed as a warning mark("playing", { runId: r.runId, startedAt: Date.now() / 1000 }); runnerNote = `playing ${item.label || item.out} (${r.runId})`; const run = runs.get(r.runId); await new Promise((done) => run.child.on("close", done)); const rc = result(r.runId, item.out); -- 2.51.2