From 31e9da2ea36579df7e7271c02adb9fc76288cf07 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 16:44:02 -0700 Subject: [PATCH] Fleet: every sounding layer rides the stem; a sync meter; staging over the rehearsal piece Preparation now bakes the sine beds, harmonies, music box, pad and percussion into each seat's stem (approximated on the Mac), so nothing on a seat is fired from a simulation frame: the deck plays it all sample-locked from the downbeat. The plan marks those events baked and native staging keeps them off the live list; the notation still shows them. measure-sync.py samples every seat's audio clock against the wall clock while a piece plays: rate, jitter, seat-to-seat spread. Staging accepts seats showing the stock spatial-rehearsal or notespatial-controls pieces. Co-Authored-By: Claude Fable 5.1 --- .../macneopolitan/bin/fleet-trio.mjs | 28 +++++++++++++- .../macneopolitan/bin/measure-sync.py | 38 +++++++++++++++++++ .../macneopolitan/bin/prepare-native-trio.py | 4 +- toolchain/mcp/ac-venue-mcp.mjs | 2 +- 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 grants/culturehub-la-2026/macneopolitan/bin/measure-sync.py diff --git a/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs b/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs index 975836c49c..8068b12759 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs @@ -95,7 +95,7 @@ if(command==='plan') { // an absolute gain) mixed from that member's actual raw phrase. Seat 5's // stem doubles as `centerMix` for the older readiness checks. const phraseOf=(member,k)=>{const sg=singers.find(s=>s.member===member),p=sg?.phrases[k];if(!p)throw Error(`${member}: phrase ${k} not prepared`);return {p,dir:dirname(sg.manifest)};}; - const length=Math.max(...plan.routes.map(r=>{const {p}=phraseOf(r.member,r.phrase);return Math.round((p.spanOffset+r.delay)*rate)+p.frames;})); + const length=Math.max(Math.round((plan.duration+1)*rate),...plan.routes.map(r=>{const {p}=phraseOf(r.member,r.phrase);return Math.round((p.spanOffset+r.delay)*rate)+p.frames;})); const seatMix={};for(const n of plan.nodes)seatMix[n.id]=new Float32Array(length); for(const r of plan.routes){ const {p,dir}=phraseOf(r.member,r.phrase); @@ -104,6 +104,32 @@ if(command==='plan') { if(raw.length!==p.frames*4)throw Error('PCM frame count mismatch'); for(let n=0;n{ + const start=Math.round(e.t*rate),len=Math.round(Math.min(e.dur,12)*rate)+Math.round((e.release??.12)*rate); + const att=Math.max(1,Math.round((e.attack??.015)*rate)),rel=Math.max(1,Math.round((e.release??.12)*rate)),hold=Math.max(0,Math.round(e.dur*rate)-att); + const f=e.frequency,g=e.gain,gm=e.gmProgram;let ph=0,ph2=0,seed=(start%9973)+1; + for(let n=0;n1 rushing), its jitter, and the seat-to-seat spread of +score position. Read-only: only GETs the seats' status files. + + python3 bin/measure-sync.py [seconds=20] [--fleet ~/.ac-os/culturehub/fleet.json] +""" +import json,os,sys,time,statistics,urllib.request,concurrent.futures as cf +secs=float(next((a for a in sys.argv[1:] if not a.startswith('--')),20)) +fleet=json.load(open(os.path.expanduser(next((sys.argv[i+1] for i,a in enumerate(sys.argv) if a=='--fleet'),'~/.ac-os/culturehub/fleet.json')))) +seats=[(h,p,l) for h,p,l,*_ in fleet] +def get(seat): + h,p,l=seat + try: + with urllib.request.urlopen('http://%s:%s/pieces/trio-fleet-status.json'%(h,p),timeout=1.5) as r:j=json.loads(r.read()) + return l,j + except Exception:return l,None +rows=[];t_end=time.time()+secs +while time.time()=2:spread.append(max(pos)-min(pos)) +if spread:print('seat-to-seat spread of score position (playing seats): median %.3f s max %.3f s (includes ~%d ms of poll latency)'%(statistics.median(spread),max(spread),40)) +else:print('no seats reported playing during the window') diff --git a/grants/culturehub-la-2026/macneopolitan/bin/prepare-native-trio.py b/grants/culturehub-la-2026/macneopolitan/bin/prepare-native-trio.py index 44d7271d08..04c9443f4a 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/prepare-native-trio.py +++ b/grants/culturehub-la-2026/macneopolitan/bin/prepare-native-trio.py @@ -10,7 +10,7 @@ its wedge cues. Never sends prepare/play. import concurrent.futures,hashlib,json,os,struct,time,urllib.request from pathlib import Path ROOT=Path(__file__).resolve().parents[1];OUT=Path(os.environ.get('TRIO_OUT','/Users/jas/Shelf/culturehub-one-big-voice')) # TRIO_OUT=… picks the song's shelf folder -ALLOW=['culturehub-rehearsal','culturehub-concert','trio-fleet']+[p for p in os.environ.get('TRIO_ALLOW_PIECES','').split(',') if p] # pieces a seat may be showing before we load +ALLOW=['culturehub-rehearsal','culturehub-concert','trio-fleet','spatial-rehearsal','notespatial-controls']+[p for p in os.environ.get('TRIO_ALLOW_PIECES','').split(',') if p] # pieces a seat may be showing before we load plan=json.loads((OUT/'plan.json').read_text());assets=json.loads((OUT/'prepared.json').read_text()) assert assets['arrangementHash']==plan['arrangementHash'] stems=assets.get('stems') or {'seat-5':assets['centerMix']} # v1 bundles carry only the Center mix @@ -40,7 +40,7 @@ def load(node): cfg={'schema':'trio-native-v1','receiverId':node['id'],'arrangementHash':plan['arrangementHash'],'bpm':plan['bpm'],'duration':plan['duration'], 'color':[[143,209,63],[90,87,211],[242,167,185]][node['seat']%3],'seat':node['seat'],'label':node.get('label'),'heldCenter':node['seat']==5, 'mix':plan.get('levels',{}).get('mix',.25), - 'events':[e for e in plan['events'] if mine(e) and e['layer'] not in ('dmx','voice','light')], + 'events':[e for e in plan['events'] if mine(e) and e['layer'] not in ('dmx','voice','light') and not e.get('baked')], # baked layers ride the stem 'routes':[{k:e[k] for k in ('t','dur','member','phrase','text','gain','role','delay','rgb')} for e in plan['events'] if mine(e) and e['layer']=='voice'], 'lyrics':plan.get('lyrics',[]), 'lightCues':[{'t':e['t'],'dur':e['dur'],'rgb':e['rgb']} for e in plan['events'] if mine(e) and e['layer']=='light'], diff --git a/toolchain/mcp/ac-venue-mcp.mjs b/toolchain/mcp/ac-venue-mcp.mjs index c22ceda734..e47a67442e 100644 --- a/toolchain/mcp/ac-venue-mcp.mjs +++ b/toolchain/mcp/ac-venue-mcp.mjs @@ -182,7 +182,7 @@ async function runQueueOnce() { try { 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" }); } + 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 || "spatial-rehearsal,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.08 }); // venue Wi-Fi: ±40 ms clocks, printed as a warning mark("playing", { runId: r.runId, startedAt: Date.now() / 1000 }); -- 2.51.2