From e502b4eb2aa0ddf4892cf378628b64e8db5c897e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 18:00:56 -0700 Subject: [PATCH] =?UTF-8?q?ac-venue:=20the=20evening's=20screens=20and=20A?= =?UTF-8?q?ct=20I=20chain=20=E2=80=94=20starfield=20with=20a=20sine=20bed?= =?UTF-8?q?=20and=20the=20oskiewartime=20card=20on=20the=20seats,=20ring?= =?UTF-8?q?=20lights=20on=20neo's=20PARs,=20the=20SUB=20reset=20and=20the?= =?UTF-8?q?=20seat=20lib=20patch=20before=20the=20ring=20score,=20a=20clos?= =?UTF-8?q?ing=20Notepat=20item;=20the=20Ballad=20of=20neo=20arranged=20se?= =?UTF-8?q?ction=20by=20section;=20the=20ring=20conductor=20tolerates=20lo?= =?UTF-8?q?st=20clock=20probes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- fedac/native/tools/spatial-rehearsal.mjs | 10 +- .../macneopolitan/bin/ring-lights.mjs | 44 ++ .../macneopolitan/bin/trio-fleet-plan.mjs | 133 +++++- .../macneopolitan/fleet/venue-screen.mjs | 41 ++ .../macneopolitan/scores/fleet-layers.json | 410 +++++++++++++++++- .../macneopolitan/scores/setlist-evening.json | 70 ++- toolchain/mcp/ac-venue-mcp.mjs | 50 ++- 7 files changed, 717 insertions(+), 41 deletions(-) create mode 100644 grants/culturehub-la-2026/macneopolitan/bin/ring-lights.mjs create mode 100644 grants/culturehub-la-2026/macneopolitan/fleet/venue-screen.mjs diff --git a/fedac/native/tools/spatial-rehearsal.mjs b/fedac/native/tools/spatial-rehearsal.mjs index c043fd12e2..ea5e1dfe64 100644 --- a/fedac/native/tools/spatial-rehearsal.mjs +++ b/fedac/native/tools/spatial-rehearsal.mjs @@ -102,19 +102,21 @@ if (action === 'deploy') { throw Error('Supply the complete ensemble with unique seats'); if (allowMissing) console.log('Missing seats stay silent at their assigned positions: ' + expected.filter(i => !before.some(s => s.seat === i)).map(i => i + 1).join(', ')); const clocks = await Promise.all(hosts.map(async host => { - let best = null; + let best = null, lost = 0; for (let i = 0; i < 7; i++) { const probeId = `${id}-clock-${i}`; const sent = performance.now() / 1000; await request(host, '/pieces/spatial-rehearsal-command.json', JSON.stringify({ id: probeId, action: 'clock' })); - let reply; - const deadline = sent + 3; + let reply, timedOut = false; + const deadline = sent + 4; do { try { reply = JSON.parse(await request(host, '/pieces/spatial-rehearsal-clock.json')); } catch (_) { /* retry */ } if (reply?.id === probeId) break; - if (performance.now() / 1000 >= deadline) throw Error(`${host}: clock probe timed out`); + if (performance.now() / 1000 >= deadline) { timedOut = true; break; } await delay(10); } while (true); + // a seat that has just parsed a large score answers late once or twice; venue Wi-Fi drops a probe now and then + if (timedOut) { if (++lost > 4) throw Error(`${host}: clock probe timed out (${lost} of ${i + 1})`); console.log(`${host}: clock probe ${i} lost, retrying`); i--; continue; } const received = performance.now() / 1000; const rtt = received - sent; if (!best || rtt < best.rtt) best = { rtt, offset: reply.audioTime - (sent + received) / 2 }; diff --git a/grants/culturehub-la-2026/macneopolitan/bin/ring-lights.mjs b/grants/culturehub-la-2026/macneopolitan/bin/ring-lights.mjs new file mode 100644 index 0000000000..6dbda9fb00 --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/bin/ring-lights.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Ring lights for a native ring score (Act I): follows LEFT FRONT's rehearsal +// status over the LAN and drives neo's DMX bridge (TRIO_DMX, tunnelled to +// 127.0.0.1:8790) — each seat's fixture in that seat's score colour, a slow +// candle-like breath under it, and a walk around the ring on the beat. +// Runs until the seat leaves `playing` (or --for seconds), then cancels. +// node bin/ring-lights.mjs [--seat=192.168.1.237] [--level=72] [--for=60] +import { writeFileSync } from 'node:fs'; +const arg = (k, d) => { const m = process.argv.find((a) => a.startsWith(`--${k}=`)); return m ? m.slice(k.length + 3) : d; }; +const SEAT = arg('seat', '192.168.1.237'), LEVEL = +arg('level', 72), FOR = +arg('for', 0), DMX = process.env.TRIO_DMX || 'http://127.0.0.1:8790'; +const get = async (url) => { const r = await fetch(url, { signal: AbortSignal.timeout(1500) }); if (!r.ok) throw Error(`HTTP ${r.status} ${url}`); return r.json(); }; +const post = async (path, data) => { const r = await fetch(DMX + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(1500) }); if (!r.ok) throw Error(`HTTP ${r.status} ${path}`); return r.text(); }; +const clamp = (v) => Math.max(0, Math.min(128, Math.round(v))); +function noise(t, period, seed) { const c = Math.floor(t / period), p = t / period - c, s = p * p * (3 - 2 * p); const v = (i) => { const n = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453; return (n - Math.floor(n)) * 2 - 1; }; return v(c) * (1 - s) + v(c + 1) * s; } +let run = null, commands = 0, skipped = 0, started = Date.now(), fixtures = [], colors = []; +try { + const st = await get(DMX + '/state'); if (Date.now() / 1000 - st.bridgeSeen > 3) throw Error('DMX bridge not seen'); fixtures = st.fixtures.filter((f) => Number.isFinite(f.targetSeat)); + console.log(`ring lights: ${fixtures.length} fixtures (${fixtures.map((f) => `${f.address}→seat ${f.targetSeat}`).join(', ')}); following ${SEAT}`); + const t0 = Date.now(); let step = -1; + while (true) { + if (FOR && Date.now() - t0 > FOR * 1000) break; + let s; try { s = await get(`http://${SEAT}/pieces/spatial-rehearsal-status.json`); } catch { await new Promise((r) => setTimeout(r, 250)); continue; } + const playing = s.phase === 'playing' && Number.isFinite(s.scoreTime); + if (!playing) { if (run) break; if (Date.now() - started > 120000) throw Error('no ring score started within two minutes'); await new Promise((r) => setTimeout(r, 250)); continue; } + if (run && run !== s.runId) break; run = s.runId; + if (!colors.length) colors = s.seatColors || [[255, 110, 110], [255, 180, 70], [120, 220, 130], [95, 170, 255], [200, 130, 255], [255, 240, 200]]; + const t = s.scoreTime, dur = s.scoreDuration || 774, fade = Math.min(1, t / 2, Math.max(0, (dur - t) / 3)); + const beat = 60 / (s.bpm || 96), k = Math.floor(t / beat); + const bridge = await get(DMX + '/state'); + if (bridge.queueDepth === 0 && k !== step) { // one fixture per beat, walking the ring; the others breathe underneath + step = k; const f = fixtures[k % fixtures.length]; const c = colors[f.targetSeat % colors.length] || [255, 255, 255]; + const breath = 0.55 + 0.25 * noise(t, 1.9, f.address) + 0.08 * noise(t, 0.31, f.address + 7); + const rgb = c.map((v) => clamp(v / 2 * breath * fade)); + await post('/command', { address: f.address, color: 'rgb', rgb, level: LEVEL, duration: Math.max(0.25, beat * 1.6) }); commands++; + } else if (bridge.queueDepth) skipped++; + await new Promise((r) => setTimeout(r, 60)); + } +} catch (e) { console.error('ring lights:', e.message || e); } +finally { + try { await post('/cancel', {}); } catch {} + const receipt = { run, commands, skipped, seconds: Math.round((Date.now() - started) / 1000) }; + try { writeFileSync(process.env.RING_LIGHTS_RECEIPT || '/dev/null', JSON.stringify(receipt)); } catch {} + console.log(JSON.stringify(receipt)); +} diff --git a/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs b/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs index 8727857e49..a2977b731a 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/trio-fleet-plan.mjs @@ -35,9 +35,12 @@ import {resolve as _resolve,dirname as _dirname} from 'node:path'; import {fileURLToPath as _furl} from 'node:url'; const LAYER_FILE=_resolve(_dirname(_furl(import.meta.url)),'../scores/fleet-layers.json'); let LAYERS={};try{LAYERS=JSON.parse(_read(LAYER_FILE,'utf8'));}catch{} -export function layerSources(score) { +export function layerEntry(score) { const slug=String(score.slug??'').replace(/\.mbscore$/,''); - const f=score.fleet??LAYERS[slug]??{}; + return score.fleet??LAYERS[slug]??{}; +} +export function layerSources(score) { + const f=layerEntry(score); const meter=/^(\d+)\s*\/\s*\d+$/.exec(String(score.arrangement?.meter??'')); const beatsPerBar=f.beatsPerBar??(meter?Number(meter[1]):4); if(!Number.isInteger(beatsPerBar)||beatsPerBar<1||beatsPerBar>12)throw Error('Invalid beats per bar'); @@ -51,6 +54,113 @@ export const hexRgb=h=>{const m=/^#?([0-9a-f]{6})$/i.exec(String(h||''));const v // (0 left front … 5 held center) one phrase at a time; its echoes follow on // the next two seats of the walk, half a beat and a beat later. export const BOUNCE=[0,1,2,4,3,5]; +// An authored arrangement. A fleet-layers entry may carry `sections` +// ([{beat,name,kind}]) and a `kinds` table (verse, refrain, bridge, last…): +// then every backing layer is shaped section by section instead of the flat +// defaults in buildPlan. The pulse grid restarts at each section so the +// downbeat lands with each entry; a kind says how loud and how busy each layer +// is (multipliers of the room's levels), which colours the four PARs breathe, +// chase or pulse, and when the held center's wedge lights. Pieces without +// `sections` are untouched: their plans stay byte for byte what they were. +function authoredSections(f,bpm,totalBeats) { + const secs=f.sections.map((s,i)=>{ + const k=f.kinds?.[s.kind];if(!k)throw Error(`fleet section kind "${s.kind}" is not in kinds`); + if(!Number.isFinite(s.beat)||s.beat<0)throw Error('fleet sections need a beat'); + const endBeat=f.sections[i+1]?.beat??f.arrangement?.total??totalBeats; + return {name:s.name,beat:s.beat,kind:s.kind,k,t:s.beat*60/bpm,end:Math.min(endBeat,totalBeats)*60/bpm}; + }); + for(let i=1;i{let s=authored[0];for(const x of authored){if(t>=x.t-1e-4)s=x;else break;}return s;}; + const rootAt=t=>{const inBar=lead.find(e=>e.t>=t-1e-4&&e.tMath.min(.25,+x.toFixed(5)); // no event past the receivers' gain ceiling + const lamp=(rgb,k)=>rgb.map(v=>Math.max(0,Math.min(128,Math.round(v*levels.lights*k)))); + const bars=[]; + for(const sec of authored)for(let i=0,t=sec.t;t{const note=n.note+off;emit('harmony',`seat-${(hk+j)%5}`,{t:n.t,dur:n.dur,note,frequency:hz(note),gain:g(levels.harmony*h.gain*swell*n.gain),attack:.08,release:.18,wave:'sine'});}); + } + // Instruments: the music box doubling the line from its lead seat; the warm pad per bar from the bar's root. + for(const n of lead){ + const m=secAt(n.t).k.inst;if(!m?.gain)continue; + const k=phrases.findIndex(p=>p.memberIndex===0&&p.phrase===n.line),seat=k<0?1:BOUNCE[k%BOUNCE.length]; + emit('inst',`seat-${seat}`,{t:n.t,dur:Math.min(n.dur,1.2),note:n.note+12,frequency:hz(n.note+12),gain:g(levels.inst*m.gain*n.gain),attack:.005,release:.35,wave:'sine',gmProgram:11,name:'music box'}); + } + for(const b of bars){ + const {pad,perc,sub,bed}=b.sec.k,root=rootAt(b.t),onBar=b.i%2?3:1; + if(pad?.gain){ + emit('inst','seat-4',{t:b.t,dur:b.len*.95,note:root,frequency:hz(root),gain:g(levels.pad*pad.gain),attack:.4,release:.5,wave:'sine',gmProgram:89,name:'warm pad'}); + if(pad.fifth)emit('inst','seat-4',{t:b.t,dur:b.len*.95,note:root+7,frequency:hz(root+7),gain:g(levels.pad*pad.gain*pad.fifth),attack:.5,release:.5,wave:'sine',gmProgram:89,name:'warm pad'}); + } + // Percussion: the heartbeat (a taiko and its softer second thump) on the one, woodblock ticks keeping time, a brush on the last and. + if(perc?.gain){ + const thump=(t,k)=>{emit('perc',`seat-${onBar}`,{t,dur:.35,note:33,frequency:55,gain:g(levels.perc*perc.gain*k),attack:.002,release:.3,wave:'sine',gmProgram:116,name:'taiko'}); + emit('perc',`seat-${onBar}`,{t:t+.28,dur:.28,note:31,frequency:49,gain:g(levels.perc*perc.gain*k*.6),attack:.002,release:.25,wave:'sine',gmProgram:116,name:'taiko'});}; + thump(b.t,1); + const beats=Math.max(1,Math.round(b.len/beat)); + if(perc.third&&beats>2)thump(b.t+2*beat,perc.third); + if(perc.ticks)for(let q=1;q1?1:beats-1);q40)low-=12;while(low<28)low+=12; + if(sub.thump)emit('sub','sub',{t:b.t,dur:.45,note:low,frequency:hz(low),gain:g(levels.sub*sub.thump),attack:.01,release:.18,wave:'sine'}); + if(sub.third&&b.len>2.5*beat)emit('sub','sub',{t:b.t+2*beat,dur:.4,note:low,frequency:hz(low),gain:g(levels.sub*sub.third),attack:.01,release:.16,wave:'sine'}); + if(sub.sustain)emit('sub','sub',{t:b.t+.05,dur:b.len*.9,note:low,frequency:hz(low),gain:g(levels.sub*sub.sustain),attack:.3,release:.3,wave:'sine'}); + } + // The bed at center rear: the root an octave down, a fifth beside it when the kind asks. + if(bed?.gain){ + emit('bed','seat-0',{t:b.t,dur:Math.min(b.len*.92,dur-b.t),note:root-12,frequency:hz(root-12),gain:g(levels.bed*bed.gain),attack:bed.attack??.2,release:.22,wave:'sine'}); + if(bed.fifth)emit('bed','seat-0',{t:b.t,dur:Math.min(b.len*.92,dur-b.t),note:root-5,frequency:hz(root-5),gain:g(levels.bed*bed.gain*bed.fifth),attack:(bed.attack??.2)+.1,release:.22,wave:'sine'}); + } + } + // Ornaments answering the lead lines: a phrase's last notes an octave up, from across the ring, in the breath before the next line. + const leadPhrases=phrases.filter(p=>p.memberIndex===0); + leadPhrases.forEach((ph,k)=>{ + const o=secAt(ph.t).k.ornament;if(!o?.gain)return; + const next=leadPhrases[k+1];if(!next)return; + const step=beat*.5,gap=next.t-(ph.t+ph.dur),count=Math.min(o.notes||3,ph.notes.length,Math.floor((gap-.1)/step)); // as many notes as the breath holds + if(count<1)return; + const seat=BOUNCE[(k+3)%BOUNCE.length],tail=ph.notes.slice(-count); + tail.forEach((note,j)=>emit('ornament',`seat-${seat}`,{t:ph.t+ph.dur+j*step,dur:step*.9,note:note+12,frequency:hz(note+12),gain:g(levels.ornament*o.gain*(1-j*.15)),attack:.01,release:.2,wave:'sine'})); + }); + // neo's PARs: per section a colour arc — verses breathe one lamp a bar around the ring, refrains chase the beat, the last refrain pulses all four. + const cue=(t,address,rgb,attack,decay,d0)=>{const d=Math.max(.05,Math.min(d0,dur-t));emit('dmx','dmx',{t,dur:d,note:0,address,rgb,level:Math.max(...rgb),attack,release:decay, + command:{address,color:'rgb',rgb,level:Math.max(...rgb),duration:+d.toFixed(3),envelope:{attack:+attack.toFixed(3),decay:+decay.toFixed(3)}}});}; + for(const sec of authored){ + const d=sec.k.dmx;if(!d)continue; + const colors=d.colors,level=d.level??1; + if(d.entry)for(const a of DMX_WALK)cue(sec.t,a,lamp(colors[0],level*1.15),.02,1.5,1.8); + bars.filter(b=>b.sec===sec).forEach(b=>{ + const rgb=lamp(colors[b.i%colors.length],level),beats=Math.max(1,Math.round(b.len/beat)); + if(d.mode==='breathe')cue(b.t,DMX_WALK[b.i%4],rgb,b.len*.45,b.len*.45,b.len*.95); + else if(d.mode==='chase')for(let q=0;qMath.max(0,Math.min(255,Math.round(v*(l.gain??.35))))); + if(l.per==='beat'){const beats=Math.max(1,Math.round(b.len/beat));for(let q=0;qtypeof v.lyrics==='string'&&v.lyrics.trim()))throw Error('Expected three member parts, at least one sung'); const isSung=v=>typeof v.lyrics==='string'&&v.lyrics.trim().length>0; @@ -113,8 +223,14 @@ export function buildPlan(score,profiles,fleet,levels={}) { emit('voice',`seat-${seat}`,{t:ph.t+delay,dur:ph.dur,member:ph.member,phrase:ph.phrase,text:ph.text,gain,role,delay,rgb:colors[ph.memberIndex]}); }); }); + const entry=layerEntry(score),src0=layerSources(score),bar=src0.beatsPerBar*beat; + const authored=Array.isArray(entry.sections)?authoredSections(entry,score.bpm,dur*score.bpm/60):null; + const droneGain=t=>{if(!authored)return 1;let s=authored[0];for(const x of authored){if(t>=x.t-1e-4)s=x;else break;}return s.k.drone??1;}; score.voices.forEach((v,i)=>{if(isSung(v))return;const seat=i===1?4:i===2?0:2; - for(const n of parts[i])emit('drone',`seat-${seat}`,{t:n.t,dur:n.dur,note:n.note,frequency:hz(n.note),gain:.045*n.gain,attack:.3,release:.45,wave:'sine',name:members[i]+' drone'});}); + for(const n of parts[i])emit('drone',`seat-${seat}`,{t:n.t,dur:n.dur,note:n.note,frequency:hz(n.note),gain:Math.min(.25,.045*n.gain*droneGain(n.t)),attack:.3,release:.45,wave:'sine',name:members[i]+' drone'});}); + const addresses=[1,11,21,31]; // neo's PARs: left front, right front, left rear, right rear + if(authored)authoredLayers({authored,parts,phrases,levels,beat,bar,dur,emit}); + else { // Harmony: a quiet sine a fifth above or an octave below each of neo's and // frisbee's notes, walking the ring, so the room hums the tune's shadow. let hk=0; @@ -132,14 +248,13 @@ export function buildPlan(score,profiles,fleet,levels={}) { for(const n of parts[1])if(n.dur>=beat*1.5)emit('inst','seat-4',{t:n.t,dur:n.dur,note:n.note,frequency:hz(n.note),gain:levels.pad*n.gain,attack:.4,release:.5,wave:'sine',gmProgram:89,name:'warm pad'}); // Percussion, in three: a soft taiko on the one (left rear / right front by // turns), a woodblock tick on two and three, a brush of noise on three. - const src0=layerSources(score),bar=src0.beatsPerBar*beat; for(let b=0;b*bar2)emit('perc','seat-0',{t:t+(src0.beatsPerBar-1)*beat+beat*.5,dur:.06,note:0,frequency:8000,gain:.012,attack:.001,release:.05,wave:'noise',name:'brush'}); } - const src=layerSources(score); + const src=src0; for(const e of parts[src.bass])emit('sub','sub',{t:e.t,dur:e.dur,note:e.note-12,frequency:440*2**((e.note-12-69)/12),gain:levels.sub*e.gain,attack:.015,release:.12,sourceNote:e.index,wave:'sine'}); const barSeconds=src.beatsPerBar*60/score.bpm; for(let bar=0;bar*barSecondse.t>=t-1e-4&&e.te.layer==='sub'||e.layer==='ornament'); musical.forEach((e,i)=>{ const address=addresses[i%4],rgb=palette[((e.note%3)+3)%3].map(v=>Math.round(v*levels.lights)); emit('dmx','dmx',{t:e.t,dur:e.dur,note:e.note,sourceEvent:e.id,address,rgb,level:Math.max(...rgb),attack:e.attack,release:e.release, command:{address,color:'rgb',rgb,level:Math.max(...rgb),duration:e.dur,envelope:{attack:e.attack,decay:e.release}}}); }); + } for(const e of events.filter(e=>e.layer==='voice'&&e.receiver==='seat-5')) emit('light','seat-5',{t:e.t,dur:e.dur,rgb:e.rgb.map(v=>Math.round(v*Math.min(1,e.gain/levels.voice))),sourceEvent:e.id}); events.sort((a,b)=>a.t-b.t||a.id.localeCompare(b.id)); const plan={schema:'trio-fleet-plan-v1',title:score.title,bpm:score.bpm,duration:dur,levels,payloads,nodes,events, requiredReceivers:[...members.map(m=>`singer-${m}`),...nodes.map(n=>n.id),'sub','dmx'], - layers:src,routes,colors:Object.fromEntries(members.map((m,i)=>[m,colors[i]])), + layers:src0,routes,colors:Object.fromEntries(members.map((m,i)=>[m,colors[i]])), lyrics:phrases.map(p=>({t:p.t,dur:p.dur,text:p.text,member:p.member,rgb:colors[p.memberIndex],role:p.role,...(p.answer?{answer:true}:{}),syllables:p.syllables})), - sections:(score.arrangement?.sections??[]).map(s=>({name:s.name,beat:s.beat})),arrangement:{total:score.arrangement?.total??null,meter:score.arrangement?.meter??null}, + sections:score.arrangement?.sections?score.arrangement.sections.map(s=>({name:s.name,beat:s.beat})):authored?authored.map(s=>({name:s.name,beat:s.beat,kind:s.kind})):[], + arrangement:{total:score.arrangement?.total??entry.arrangement?.total??null,meter:score.arrangement?.meter??entry.arrangement?.meter??null}, dmx:{host:'192.168.1.235',port:8790,activeAddresses:addresses,inactiveAddresses:[41,511]}, sub:{host:'192.168.1.67',port:8788,transport:'rustdesk-local-bridge'}, center:{receiver:'seat-5',actualVocals:true,preSlideEffects:true},playbackHeld:true}; diff --git a/grants/culturehub-la-2026/macneopolitan/fleet/venue-screen.mjs b/grants/culturehub-la-2026/macneopolitan/fleet/venue-screen.mjs new file mode 100644 index 0000000000..6b277439b5 --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/fleet/venue-screen.mjs @@ -0,0 +1,41 @@ +// Venue screen, 26.09.24 — what a seat shows between pieces. +// Two modes, read from /pieces/venue-screen-config.json: +// starfield — a classic starfield in the seat's colour, before people arrive +// card — a huge word edge to edge in the seat's colour ("oskiewartime!") +// The starfield hums: each seat swells its ring note (cfg.hz) as a slow sine bed, +// with an octave or fifth now and then — the preshow ambient mix. Mic closed; backlight up. +let cfg={mode:'starfield',text:''},color=[143,209,63],brightSteps=14,cfgAt=-Infinity,api=null,pulse=0,nextSwell=0,voices=[]; +const N=160,SPREAD=40;const sx=new Float64Array(N),sy=new Float64Array(N),sz=new Float64Array(N); +const FONT='6x10'; +function textWidth(s,size){return s.length*6*size;} +function fitSize(s,maxW,maxSize){let size=maxSize;while(size>1&&textWidth(s,size)>maxW)size--;return size;} +function centered(write,s,y,size,w){write(s,{x:Math.round((w-textWidth(s,size))/2),y,font:FONT,size});} +function reset(i){sx[i]=2*(Math.random()-.5)*SPREAD;sy[i]=2*(Math.random()-.5)*SPREAD;sz[i]=(Math.random()+.00001)*SPREAD;} +function readCfg(system){try{const c=JSON.parse(system.readFile('/pieces/venue-screen-config.json'));if(c&&typeof c==='object')cfg=c;}catch{}} +export function boot(a){api=a;try{a.sound.microphone.close();a.sound.volume.setMono(true);a.sound.volume.setMonoOutput('left');let mix=1;try{const v=JSON.parse(a.system.readFile('/pieces/composition-volume.json'));if(Number.isFinite(v.percent))mix=v.percent/100;}catch{}a.sound.volume.setMix(mix);/* the room's master (composition-volume.json, 100 % tonight): the rehearsal piece after us inherits it */}catch{}readCfg(a.system);cfgAt=a.sound.time;nextSwell=a.sound.time+1+(cfg.seat||0)*1.7; + color=[[143,209,63],[90,87,211],[242,167,185]][(cfg.seat||0)%3];if(Array.isArray(cfg.color))color=cfg.color; + for(let i=0;i0){brightSteps--;try{system.brightnessAdjust(1);}catch{}} + if(sound.time-cfgAt>1){cfgAt=sound.time;const was=cfg.mode+'|'+(cfg.text||'');readCfg(system);if(cfg.mode+'|'+(cfg.text||'')!==was)try{system.writeFile('/pieces/venue-screen-status.json',JSON.stringify({mode:cfg.mode,text:cfg.text||'',at:Date.now()/1000}));}catch{}} + pulse+=1/60; + if(cfg.mode==='starfield'&&cfg.bed!==false&&sound.time>=nextSwell){const hz=cfg.hz||261.63;const pick=Math.random();const tone=pick<.55?hz:pick<.8?hz*1.5:pick<.92?hz*2:hz/2;const dur=7+Math.random()*7; + try{const v=sound.synth({type:'sine',tone,volume:(cfg.bedGain||.07)*(tone>hz?.7:1),duration:dur,attack:dur*.45,decay:dur*.45});voices.push(v);if(voices.length>8)voices.shift();}catch{} + nextSwell=sound.time+4+Math.random()*7;} + if(cfg.mode==='starfield'){const speed=cfg.speed||2.1,w=screen.width,h=screen.height; + for(let i=0;i=w||y<0||y>=h)reset(i);}}} +export function paint({wipe,ink,plot,write,screen}){ + const w=screen.width,h=screen.height; + if(cfg.mode==='card'){wipe(...color);const lum=(0.2126*color[0]+0.7152*color[1]+0.0722*color[2])/255;const inkRGB=lum>.5?[8,8,12]:[250,250,250]; + const text=cfg.text||'oskiewartime!';const size=fitSize(text,w*.92,16);const bob=Math.round(Math.sin(pulse*2.2)*4); + ink(...inkRGB);centered(write,text,Math.round(h/2-size*5)+bob,size,w); + if(cfg.sub){ink(Math.round(inkRGB[0]*.5+color[0]*.5),Math.round(inkRGB[1]*.5+color[1]*.5),Math.round(inkRGB[2]*.5+color[2]*.5));centered(write,cfg.sub,Math.round(h/2+size*5+16),2,w);} + return;} + wipe(0,0,0); + for(let i=0;i.8){plot(Math.round(x)+1,Math.round(y));plot(Math.round(x),Math.round(y)+1);}} + if(cfg.text){ink(Math.round(color[0]*.7),Math.round(color[1]*.7),Math.round(color[2]*.7));centered(write,cfg.text,h-28,2,w);}} +export function act(){} +export function leave(){for(const v of voices)try{v?.kill?.(.05);}catch{}voices=[];} diff --git a/grants/culturehub-la-2026/macneopolitan/scores/fleet-layers.json b/grants/culturehub-la-2026/macneopolitan/scores/fleet-layers.json index 32327ef87f..dd006767f4 100644 --- a/grants/culturehub-la-2026/macneopolitan/scores/fleet-layers.json +++ b/grants/culturehub-la-2026/macneopolitan/scores/fleet-layers.json @@ -1,14 +1,400 @@ { - "_": "Per-piece backing sources for the room planner (bin/trio-fleet-plan.mjs): which member feeds bass, bed and ornaments, and beats per bar. A score's own `fleet` block wins over this file.", - "trio-open-me": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-spell-sophia": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-abc": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-what-starts-with": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-sums": { "bass": "blueberry", "bed": "blueberry", "ornament": "frisbee" }, - "trio-take-away": { "bass": "blueberry", "bed": "blueberry", "ornament": "frisbee" }, - "trio-thirteen-days": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-vocalise": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-lights-out": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-lullaby": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" }, - "trio-the-record": { "bass": "blueberry", "bed": "blueberry", "ornament": "neo" } + "_": "Per-piece backing sources for the room planner (bin/trio-fleet-plan.mjs): which member feeds bass, bed and ornaments, and beats per bar. A score's own `fleet` block wins over this file. An entry may also author the arrangement: `sections` [{beat,name,kind}] and a `kinds` table, each kind a multiplier per layer (harmony, inst, pad, perc, sub, bed, ornament, drone), a `dmx` colour arc for neo's four PARs (breathe | chase | pulse, colours 0-255 scaled by the lights level, `entry` flashes all four at the section start) and a `light` cue for the held center's wedge (per bar or per beat).", + "trio-open-me": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-spell-sophia": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-abc": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-what-starts-with": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-sums": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "frisbee" + }, + "trio-take-away": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "frisbee" + }, + "trio-thirteen-days": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-vocalise": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-lights-out": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-lullaby": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-the-record": { + "bass": "blueberry", + "bed": "blueberry", + "ornament": "neo" + }, + "trio-iv-ballad": { + "_": "The Ballad of neo (6:16, 100 bpm in four): eleven verses and a refrain sung by neo; the siblings hold their drone. Warm sustained bed and a heartbeat under the verses, the refrain swells in harmony with the pulse kept on the SUB, the slow eleventh verse thins to the heartbeat, and the last refrain is everything at once.", + "bass": "neo", + "bed": "neo", + "ornament": "neo", + "beatsPerBar": 4, + "arrangement": { + "meter": "4/4", + "total": 626.5 + }, + "sections": [ + { + "beat": 0, + "name": "I. April nineteenth", + "kind": "verse" + }, + { + "beat": 40, + "name": "II. The first word", + "kind": "verse" + }, + { + "beat": 74, + "name": "Refrain", + "kind": "refrain" + }, + { + "beat": 104, + "name": "III. The Scarlett", + "kind": "verse" + }, + { + "beat": 138, + "name": "IV. A piano in the menu bar", + "kind": "verse" + }, + { + "beat": 180, + "name": "Refrain", + "kind": "refrain" + }, + { + "beat": 210, + "name": "V. Ten browsers", + "kind": "verse" + }, + { + "beat": 253.5, + "name": "VI. The note", + "kind": "verse" + }, + { + "beat": 290, + "name": "Refrain", + "kind": "refrain" + }, + { + "beat": 320, + "name": "VII. Blue", + "kind": "verse" + }, + { + "beat": 359, + "name": "VIII. The shelf", + "kind": "verse" + }, + { + "beat": 391, + "name": "Refrain", + "kind": "refrain" + }, + { + "beat": 421, + "name": "IX. Eighty-two commits", + "kind": "verse" + }, + { + "beat": 477.5, + "name": "X. Los Angeles", + "kind": "verse" + }, + { + "beat": 521, + "name": "Refrain", + "kind": "refrain" + }, + { + "beat": 551, + "name": "XI. The beat starts here", + "kind": "bridge" + }, + { + "beat": 596.5, + "name": "Last refrain", + "kind": "last" + } + ], + "kinds": { + "verse": { + "harmony": { + "gain": 0.6, + "every": 2, + "intervals": [ + -12 + ] + }, + "inst": { + "gain": 0.6 + }, + "perc": { + "gain": 0.85, + "ticks": 0.3 + }, + "sub": { + "thump": 1.1, + "sustain": 0.5 + }, + "bed": { + "gain": 1 + }, + "ornament": { + "gain": 0.8, + "notes": 3 + }, + "drone": 0.8, + "dmx": { + "mode": "breathe", + "colors": [ + [ + 255, + 150, + 40 + ], + [ + 255, + 110, + 20 + ], + [ + 235, + 90, + 60 + ], + [ + 255, + 130, + 30 + ] + ], + "level": 0.9 + } + }, + "refrain": { + "harmony": { + "gain": 1.4, + "every": 1, + "intervals": [ + 7, + -12 + ], + "swell": 0.5 + }, + "inst": { + "gain": 1 + }, + "pad": { + "gain": 1, + "fifth": 0.7 + }, + "perc": { + "gain": 1, + "ticks": 0.45, + "brush": 1 + }, + "sub": { + "thump": 1.25, + "sustain": 0.7, + "third": 0.9 + }, + "bed": { + "gain": 1.2, + "fifth": 0.6 + }, + "ornament": { + "gain": 1, + "notes": 3 + }, + "drone": 1, + "dmx": { + "mode": "chase", + "colors": [ + [ + 143, + 209, + 63 + ], + [ + 90, + 87, + 211 + ], + [ + 242, + 167, + 185 + ] + ], + "level": 1.2, + "entry": true + }, + "light": { + "rgb": [ + 242, + 167, + 185 + ], + "per": "bar", + "gain": 0.35 + } + }, + "bridge": { + "harmony": { + "gain": 0.5, + "every": 2, + "intervals": [ + -12 + ] + }, + "inst": { + "gain": 0.4 + }, + "pad": { + "gain": 0.6 + }, + "perc": { + "gain": 0.6 + }, + "sub": { + "thump": 0.9, + "sustain": 0.4 + }, + "bed": { + "gain": 0.9, + "attack": 0.5 + }, + "ornament": { + "gain": 0.6, + "notes": 2 + }, + "drone": 0.6, + "dmx": { + "mode": "breathe", + "colors": [ + [ + 140, + 80, + 255 + ], + [ + 60, + 60, + 220 + ] + ], + "level": 0.6 + } + }, + "last": { + "harmony": { + "gain": 1.6, + "both": true, + "intervals": [ + 7, + -12 + ] + }, + "inst": { + "gain": 1.2 + }, + "pad": { + "gain": 1.3, + "fifth": 0.8 + }, + "perc": { + "gain": 1.1, + "ticks": 0.5, + "brush": 2, + "third": 0.8 + }, + "sub": { + "thump": 1.35, + "sustain": 0.75, + "third": 1.1 + }, + "bed": { + "gain": 1.4, + "fifth": 0.7 + }, + "ornament": { + "gain": 1.1, + "notes": 3 + }, + "drone": 1.15, + "dmx": { + "mode": "pulse", + "colors": [ + [ + 255, + 220, + 180 + ], + [ + 143, + 209, + 63 + ], + [ + 90, + 87, + 211 + ], + [ + 242, + 167, + 185 + ] + ], + "level": 1.6, + "entry": true + }, + "light": { + "rgb": [ + 255, + 220, + 180 + ], + "per": "beat", + "gain": 0.35 + } + } + } + } } diff --git a/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json b/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json index 4b5a385545..047e7951fd 100644 --- a/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json +++ b/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json @@ -1,13 +1,59 @@ { - "title": "CultureHub LA, 2026-09-24, 7:00 pm — three acts", - "gap": 8, - "items": [ - { "out": "set-00-notepat-spatial", "label": "Act I — Note(s)pat(ial)", "announce": null }, - { "hold": 600, "label": "Open oskiewar — hang time (10 min)" }, - { "score": "trio-toxic-sketch", "out": "act3-00-sketch", "label": "Act III intro — Toxic sketch", "announce": "The MacNeoPolitan Trio." }, - { "score": "trio-wake", "out": "act3-01-wake", "label": "Good morning, Sophia", "announce": "Good morning, Sophia." }, - { "score": "trio-rowboat", "out": "act3-02-rowboat", "label": "Row, row, row your boat", "announce": "Row, row, row your boat." }, - { "score": "trio-iv-ballad", "out": "act3-03-ballad", "label": "The Ballad of neo", "announce": "The ballad of neo." }, - { "score": "trio-vocalise", "out": "act3-04-vocalise", "label": "Vocalise", "announce": "Vocalise." } - ] -} + "title": "CultureHub LA, 2026-09-24, 7:00 pm \u2014 three acts", + "gap": 8, + "items": [ + { + "screen": "starfield", + "hold": 1800, + "label": "Preshow \u2014 starfield + sine bed (skip file ends it)" + }, + { + "out": "set-00-notepat-spatial", + "label": "Act I \u2014 Note(s)pat(ial)", + "announce": null + }, + { + "screen": "card", + "text": "oskiewartime!", + "sub": "the room is open", + "hold": 600, + "stopAfter": true, + "label": "Act II \u2014 oskiewartime! (10 min; runner parks; Jeffrey triggers Act III)" + }, + { + "score": "trio-toxic-sketch", + "out": "act3-00-sketch", + "label": "Act III intro \u2014 Toxic sketch", + "announce": "The MacNeoPolitan Trio." + }, + { + "score": "trio-wake", + "out": "act3-01-wake", + "label": "Good morning, Sophia", + "announce": "Good morning, Sophia." + }, + { + "score": "trio-rowboat", + "out": "act3-02-rowboat", + "label": "Row, row, row your boat", + "announce": "Row, row, row your boat." + }, + { + "score": "trio-iv-ballad", + "out": "act3-03-ballad", + "label": "The Ballad of neo", + "announce": "The ballad of neo." + }, + { + "score": "trio-vocalise", + "out": "act3-04-vocalise", + "label": "Vocalise", + "announce": "Vocalise." + }, + { + "piece": "notepat", + "stopAfter": true, + "label": "After the set \u2014 Notepat on all six laptops (come up and play)" + } + ] +} \ No newline at end of file diff --git a/toolchain/mcp/ac-venue-mcp.mjs b/toolchain/mcp/ac-venue-mcp.mjs index c6425e8a94..90e63279b1 100644 --- a/toolchain/mcp/ac-venue-mcp.mjs +++ b/toolchain/mcp/ac-venue-mcp.mjs @@ -170,6 +170,18 @@ async function stop() { const queue = () => readJson(QUEUE, { items: [], autoplay: false, history: [] }); const saveQueue = (q) => { writeFileSync(QUEUE, JSON.stringify({ ...q, updatedAt: Date.now() / 1000 }, null, 2) + "\n"); return q; }; let runnerBusy = false, runnerTimer = null, runnerNote = ""; +const SCREEN_PIECE = join(LANE, "fleet/venue-screen.mjs"); +async function showScreen(mode, text, sub) { // stage fleet/venue-screen.mjs on every seat with its config and jump to it + const fleet = readJson(ENV.TRIO_FLEET, []); const code = readFileSync(SCREEN_PIECE); let shown = 0; + await Promise.all(fleet.map(async ([host, port, label, note, hz], i) => { const url = `http://${host}:${port}`; try { + const cfg = { mode, text, sub, seat: i, label, hz: Number.isFinite(hz) ? hz : 261.63, at: Date.now() / 1000 }; + await fetch(`${url}/pieces/venue-screen-config.json`, { method: "PUT", body: JSON.stringify(cfg) }); + await fetch(`${url}/pieces/venue-screen.mjs`, { method: "PUT", body: code }); + await fetch(`${url}/jump/venue-screen`, { method: "PUT", body: "" }); shown++; + } catch (e) { console.error(`screen ${host}: ${e.message || e}`); } })); + await new Promise((r) => setTimeout(r, 3000)); + return shown; +} async function runQueueOnce() { if (runnerBusy) return; const q = queue(); const item = q.items.find((i) => i.status === "queued" || i.status === "ready"); @@ -181,11 +193,26 @@ async function runQueueOnce() { const mark = (status, extra = {}) => { const qq = queue(); const it = qq.items.find((i) => i.id === item.id); if (it) Object.assign(it, { status, ...extra }); saveQueue(qq); }; try { setRig({ owner: local, since: new Date().toISOString(), note: `setlist: ${item.label || item.out}` }); + if (item.piece) { // hand every laptop to a stock piece (notepat after the set: come up and play) + mark("preparing"); runnerNote = `jumping the seats to ${item.piece}`; const fleet = readJson(ENV.TRIO_FLEET, []); let jumped = 0; + await Promise.all(fleet.map(async ([host, port]) => { try { await fetch(`http://${host}:${port}/jump/${item.piece}`, { method: "PUT", body: "" }); jumped++; } catch (e) { console.error(`jump ${host}: ${e.message || e}`); } })); + runnerNote = `${item.piece} on ${jumped} seats`; mark("done", { finishedAt: Date.now() / 1000 }); + if (item.stopAfter) { try { writeFileSync(join(SHELF, "venue-runner.stop"), `after ${item.label}\n`); } catch {} } + return; + } + if (item.screen) { // a seat screen (starfield / a card) on every laptop, then a hold + mark("preparing"); runnerNote = `putting ${item.screen} on the seats`; + const shown = await showScreen(item.screen, item.text || null, item.sub || null); + runnerNote = `${item.screen} on ${shown} seats`; + if (!item.hold) { mark("done", { finishedAt: Date.now() / 1000 }); return; } + } if (item.hold) { // a timed rest: the room is open (oskiewar hang time), nothing is cued mark("playing", { startedAt: Date.now() / 1000 }); runnerNote = `holding ${item.label} for ${item.hold} s`; const until = Date.now() + item.hold * 1000; while (Date.now() < until) { if (existsSync(join(SHELF, "venue-runner.skip"))) { try { unlinkSync(join(SHELF, "venue-runner.skip")); } catch {} break; } await new Promise((r) => setTimeout(r, 2000)); } - mark("done", { finishedAt: Date.now() / 1000 }); runnerNote = `finished hold ${item.label}`; return; + mark("done", { finishedAt: Date.now() / 1000 }); runnerNote = `finished hold ${item.label}`; + if (item.stopAfter) { try { writeFileSync(join(SHELF, "venue-runner.stop"), `after ${item.label}\n`); } catch {} runnerNote += "; runner parked (stopAfter)"; } + return; } // A folder with its own conductor stages itself — unless it also carries a // prepared bundle (stems) that our staging knows how to put on the seats. @@ -198,8 +225,18 @@ async function runQueueOnce() { } if (ownConductor) { // a ring-score conductor expects the seats on the stock rehearsal piece, not on trio-fleet const fleet = readJson(ENV.TRIO_FLEET, []); let jumped = 0; - await Promise.all(fleet.map(async ([host, port]) => { try { const st = await fetchJson(`http://${host}:${port}/status`, {}, 2500); if (st.piece === "trio-fleet") { await fetch(`http://${host}:${port}/jump/spatial-rehearsal`, { method: "PUT", body: "" }); jumped++; } } catch {} })); + // The seats' OS image carries an older lib/spatial-rehearsal.mjs (no eventGain) and their runtime keeps the + // old module cached under its name, so the current piece is staged importing a freshly named copy of the lib. + try { const lib = readFileSync(join(REPO, "fedac/native/lib/spatial-rehearsal.mjs")); const piece = readFileSync(join(REPO, "fedac/native/pieces/spatial-rehearsal.mjs"), "utf8").replace("'../lib/spatial-rehearsal.mjs'", "'../lib/spatial-rehearsal-v2.mjs'") + .replace("export function sim({ sound, system, screen, wifi }) {", "let __volAt = -Infinity;\nexport function sim({ sound, system, screen, wifi }) {\n if (sound.time - __volAt > .25) { __volAt = sound.time; try { const v = JSON.parse(system.readFile('/pieces/composition-volume.json')); if (Number.isFinite(v.percent) && v.percent >= 0 && v.percent <= 100 && Math.abs(sound.volume.mix - v.percent / 100) > .002) sound.volume.setMix(v.percent / 100); } catch {} } // the room volume file, as trio-fleet follows it"); + if (!piece.includes("__volAt")) console.error("seat patch: the volume follower did not apply (sim signature changed)"); + await Promise.all(fleet.map(async ([host, port]) => { try { await fetch(`http://${host}:${port}/lib/spatial-rehearsal-v2.mjs`, { method: "PUT", body: lib }); await fetch(`http://${host}:${port}/pieces/spatial-rehearsal.mjs`, { method: "PUT", body: piece }); } catch (e) { console.error(`seat patch ${host}: ${e.message || e}`); } })); } catch (e) { console.error(`seat patch: ${e.message || e}`); } + await Promise.all(fleet.map(async ([host, port]) => { try { const st = await fetchJson(`http://${host}:${port}/status`, {}, 2500); if (st.piece === "trio-fleet" || st.piece === "venue-screen") { await fetch(`http://${host}:${port}/jump/spatial-rehearsal`, { method: "PUT", body: "" }); jumped++; } } catch {} })); if (jumped) { runnerNote = `moved ${jumped} seats to spatial-rehearsal for ${item.label}`; await new Promise((r) => setTimeout(r, 14000)); } + if (item.subReset !== false) { // blueberry's SUB server only leaves Trio mode by restarting; its seat clocks must be fresh (< 30 min) + runnerNote = `resetting the SUB server for ${item.label}`; + try { const r = await pexec("bash", [join(SHELF, "sub-reset.sh")], { timeout: 60000 }); console.error("sub reset: " + (r.stdout || "").trim().slice(-200)); } catch (e) { console.error(`sub reset failed: ${e.message || e}`); } + } } if (item.status !== "ready" && !ownConductor && !already) { mark("preparing"); runnerNote = `preparing ${item.label || item.out}`; await prepare({ score: item.score, out: item.out, stage: true, keepPiece: false, announce: item.score ? item.announce : null, sing: !!item.sing, 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 @@ -207,7 +244,10 @@ async function runQueueOnce() { const baked = !!readJson(join(outDir(item.out), "plan.json"))?.leadIn; // the announcement rides the stems: no Mac TTS mark("cueing"); const r = await cue({ out: item.out, announce: baked ? null : 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})`; + let lights = null; + if (ownConductor && item.lights !== false) { try { lights = spawn("node", [join(LANE, "bin/ring-lights.mjs")], { env: { ...process.env, TRIO_DMX: ENV.TRIO_DMX, RING_LIGHTS_RECEIPT: join(outDir(item.out), "ring-lights-receipt.json") }, stdio: ["ignore", "ignore", "inherit"] }); } catch (e) { console.error(`ring lights: ${e.message || e}`); } } const run = runs.get(r.runId); await new Promise((done) => run.child.on("close", done)); + if (lights) setTimeout(() => { try { lights.kill(); } catch {} }, 8000); const rc = result(r.runId, item.out); mark(rc.receipt?.completed ? "done" : "failed", { finishedAt: Date.now() / 1000, receipt: rc.receipt?.runId, error: rc.receipt?.error || null }); const qq = queue(); qq.history = [...(qq.history || []), { ...qq.items.find((i) => i.id === item.id) }].slice(-50); saveQueue(qq); @@ -220,7 +260,7 @@ function setAutoplay(on) { const q = saveQueue({ ...queue(), autoplay: !!on }); if (queue().autoplay) setAutoplay(true); const TOOLS = [ - { name: "venue_enqueue", description: "Add a piece to the room's setlist — or a timed rest with `hold` seconds (the room stays open, nothing is cued; `touch ~/Shelf/venue-runner.skip` ends it early): `out` (song folder under ~/Shelf or absolute; with `score` it is planned and prepared first, otherwise the folder's plan/bundle is staged as is), optional `label`, `announce` (spoken before the cue), `gap` seconds of rest after, `allowPieces`. Items play in order when autoplay is on and the rig is free. `setlist` loads every item of a scores/*.json setlist file at once.", inputSchema: { type: "object", properties: { out: { type: "string" }, hold: { type: "number" }, score: { type: "string" }, label: { type: "string" }, announce: { type: "string" }, gap: { type: "number" }, allowPieces: { type: "string" }, rttMax: { type: "number" }, sing: { type: "boolean" }, setlist: { type: "string", description: "path or name of a setlist json in the lane's scores/ (items: [{score,out,label,announce}])" } } } }, + { name: "venue_enqueue", description: "Add a piece to the room's setlist — or a timed rest with `hold` seconds (the room stays open, nothing is cued; `touch ~/Shelf/venue-runner.skip` ends it early): `out` (song folder under ~/Shelf or absolute; with `score` it is planned and prepared first, otherwise the folder's plan/bundle is staged as is), optional `label`, `announce` (spoken before the cue), `gap` seconds of rest after, `allowPieces`. Items play in order when autoplay is on and the rig is free. `setlist` loads every item of a scores/*.json setlist file at once.", inputSchema: { type: "object", properties: { out: { type: "string" }, hold: { type: "number" }, screen: { type: "string", description: "starfield | card — shown on every seat (with `hold` seconds; `text`/`sub` for the card)" }, text: { type: "string" }, sub: { type: "string" }, piece: { type: "string", description: "jump every seat to this stock piece (e.g. notepat after the set) and mark done" }, stopAfter: { type: "boolean", description: "park the runner after this item (Jeffrey triggers the next by hand)" }, score: { type: "string" }, label: { type: "string" }, announce: { type: "string" }, gap: { type: "number" }, allowPieces: { type: "string" }, rttMax: { type: "number" }, sing: { type: "boolean" }, setlist: { type: "string", description: "path or name of a setlist json in the lane's scores/ (items: [{score,out,label,announce}])" } } } }, { name: "venue_setlist", description: "The setlist: every item with its status (queued, preparing, checking, cueing, playing, done, failed), autoplay, what the runner is waiting on, rig ownership, and the last plays.", inputSchema: { type: "object", properties: {} } }, { name: "venue_dequeue", description: "Remove an item by id, or every queued item (`all: true`). A playing item is not removed; use venue_stop.", inputSchema: { type: "object", properties: { id: { type: "string" }, all: { type: "boolean" } } } }, { name: "venue_autoplay", description: "Turn the setlist runner on or off. On, it advances whenever the rig is ours or unowned and nothing is playing: prepare, check, cue, wait for the receipt, rest, next; it releases the rig when the queue drains.", inputSchema: { type: "object", properties: { on: { type: "boolean" } }, required: ["on"] } }, @@ -243,9 +283,9 @@ async function callTool(name, args = {}) { case "venue_stop": return text(await stop()); case "venue_enqueue": { const q = queue(); const added = []; - const push = (it) => { const id = `q-${Date.now().toString(36)}-${added.length}`; q.items.push({ id, out: it.hold ? null : outDir(it.out), score: it.score || null, hold: it.hold || null, label: it.label || it.score || it.out || `hold ${it.hold}s`, announce: it.announce || null, gap: it.gap ?? 8, allowPieces: it.allowPieces || null, rttMax: it.rttMax || null, sing: it.sing || null, status: "queued", addedAt: Date.now() / 1000 }); added.push(id); }; + const push = (it) => { const id = `q-${Date.now().toString(36)}-${added.length}`; q.items.push({ id, out: it.hold ? null : outDir(it.out), score: it.score || null, hold: it.hold || null, label: it.label || it.score || it.out || `hold ${it.hold}s`, announce: it.announce || null, gap: it.gap ?? 8, allowPieces: it.allowPieces || null, rttMax: it.rttMax || null, sing: it.sing || null, screen: it.screen || null, piece: it.piece || null, text: it.text || null, sub: it.sub || null, stopAfter: !!it.stopAfter, status: "queued", addedAt: Date.now() / 1000 }); added.push(id); }; if (args.setlist) { const p = args.setlist.startsWith("/") ? args.setlist : join(LANE, "scores", args.setlist.endsWith(".json") ? args.setlist : `${args.setlist}.json`); const list = readJson(p); if (!list?.items) throw new Error(`no setlist at ${p}`); for (const it of list.items) push({ gap: list.gap, ...it }); } - else { if (!args.out && !args.hold) throw new Error("out, hold, or setlist is required"); push(args); } + else { if (!args.out && !args.hold && !args.screen && !args.piece) throw new Error("out, hold, screen, or setlist is required"); push(args); } saveQueue(q); return text({ added, queued: q.items.filter((i) => i.status === "queued").length, autoplay: q.autoplay, note: q.autoplay ? "the runner takes them when the rig is free" : "autoplay is off: venue_autoplay on, or venue_next" }); } case "venue_setlist": { const q = queue(); return text({ autoplay: q.autoplay, runner: runnerNote || (q.autoplay ? "idle" : "off"), rig: rig(), items: q.items.map((i) => ({ id: i.id, label: i.label, status: i.status, out: i.out, score: i.score, announce: i.announce, runId: i.runId, error: i.error })), history: (q.history || []).slice(-5).map((i) => ({ label: i.label, status: i.status, receipt: i.receipt })) }); } -- 2.51.2