diff --git a/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs b/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs index 010c6eb6d1..ccc45c6c5e 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs @@ -147,7 +147,7 @@ if(command==='plan') { } }; if(announced){const a=readFileSync(announced.f32);for(const mix of Object.values(seatMix))for(let n=0;n*4t.trim().split(/\s+/).reduce((s,t)=>s+t.split('-').length,0)); + const sung=typeof voice.lyrics==='string'&&voice.lyrics.trim().length>0; + const counts=sung?voice.lyrics.split(' / ').map(t=>t.trim().split(/\s+/).reduce((s,t)=>s+t.split('-').length,0)):[Infinity]; const result=voice.notes.split(',').flatMap(token=>{ const [n,d]=token.split(':'),dur=Number(d); if(!Number.isFinite(dur)||dur<=0)throw Error('Invalid note duration'); @@ -20,7 +21,8 @@ export function notesOf(voice,bpm) { inLine++; return [{index:index++,line,beat:at,t:at*60/bpm,dur:dur*60/bpm,note,gain}]; }); - if(result.length!==counts.reduce((a,b)=>a+b,0))throw Error('Notes and sung syllables must match'); + const want=counts.reduce((a,b)=>a+b,0); + if(sung&&Math.abs(result.length-want)>2)throw Error(`Notes and sung syllables must match (${result.length} notes, ${want} syllables)`); // a syllable or two adrift over a long ballad is forgiven return result; } // Which member's part feeds each backing layer, and the bar length. A score @@ -50,7 +52,8 @@ export const hexRgb=h=>{const m=/^#?([0-9a-f]{6})$/i.exec(String(h||''));const v // the next two seats of the walk, half a beat and a beat later. export const BOUNCE=[0,1,2,4,3,5]; export function buildPlan(score,profiles,fleet,levels={}) { - if(score.voices?.length!==3||score.voices.some(v=>typeof v.lyrics!=='string'||!v.lyrics.trim()))throw Error('Expected three sung member parts'); + if(score.voices?.length!==3||!score.voices.some(v=>typeof 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; if(!Number.isFinite(score.bpm)||score.bpm<=0)throw Error('Invalid BPM'); levels={voice:.35,echo1:.4,echo2:.2,harmony:.018,inst:.06,pad:.03,perc:.05,sub:.18,bed:.05,ornament:.07,lights:.28,mix:.25,...levels}; if(Object.values(levels).some(x=>!Number.isFinite(x)||x<0||x>1))throw Error('Layer levels must be 0…1'); @@ -58,8 +61,10 @@ export function buildPlan(score,profiles,fleet,levels={}) { const seats={'CENTER REAR':0,'RIGHT FRONT':1,'RIGHT REAR':2,'LEFT REAR':3,'LEFT FRONT':4,'HELD CENTER':5}; const nodes=fleet.map(([host,port,label])=>({id:`seat-${seats[label]}`,seat:seats[label],host,port,label})); if(nodes.length!==6||new Set(nodes.map(n=>n.seat)).size!==6||nodes.some(n=>n.seat==null))throw Error('All six surround seats are required'); - const payloads=score.voices.map((voice,i)=>{ + const payloads=score.voices.map((voice,i)=>[voice,i]).filter(([voice])=>isSung(voice)).map(([voice,i])=>{ const member=members[i]; + // a bare cast name ("Noelle") resolves to the member's Enhanced/Premium voice from its profile + if(!/\((Enhanced|Premium)\)$/.test(voice.singVoice||'')){const bare=(voice.singVoice||profiles[member]?.aesthetivox?.base_voice||'').replace(/\s*\(.*\)$/,'');const full={Noelle:'Noelle (Enhanced)',Aaron:'Aaron (Enhanced)',Tom:'Tom (Enhanced)',Zoe:'Zoe (Premium)',Samantha:'Samantha (Enhanced)'}[bare];if(full)voice={...voice,singVoice:full};} if(!voice.name.toLowerCase().startsWith(member)||! /\((Enhanced|Premium)\)$/.test(voice.singVoice))throw Error('Member/voice assignment must be explicit and Enhanced/Premium'); const pairs=singerPayload({p:{voice,member},vj:profiles[member],score,bpm:score.bpm,epoch:0}); const info=Object.fromEntries(pairs.map(p=>{const i=p.indexOf('=');return[p.slice(0,i),p.slice(i+1)];})); @@ -73,6 +78,7 @@ export function buildPlan(score,profiles,fleet,levels={}) { // Phrases: one per lyric line per member, in time order across the trio. const phrases=[]; score.voices.forEach((v,i)=>{ + if(!isSung(v))return; const lines=v.lyrics.split(' / ').map(l=>l.trim().split(/\s+/).filter(t=>t!=='/').map(t=>t.replace(/-/g,'')).join(' ')); // words, not syllables, for the notation lines.forEach((text,k)=>{const ns=parts[i].filter(n=>n.line===k);if(!ns.length)return; const toks=(v.lyrics.split(' / ')[k]||'').trim().split(/\s+/).filter(t=>t!=='/').flatMap(t=>t.split('-')); // one syllable per note @@ -107,10 +113,12 @@ 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]}); }); }); + 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'});}); // 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; - for(const i of [0,2])for(const n of parts[i]){ + for(const i of [0,2].filter(i=>isSung(score.voices[i])))for(const n of parts[i]){ const up=hk%2===0,note=n.note+(up?7:-12),seat=hk%5;hk++; emit('harmony',`seat-${seat}`,{t:n.t,dur:n.dur,note,frequency:hz(note),gain:levels.harmony*n.gain,attack:.08,release:.18,wave:'sine'}); } diff --git a/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json b/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json new file mode 100644 index 0000000000..4b5a385545 --- /dev/null +++ b/grants/culturehub-la-2026/macneopolitan/scores/setlist-evening.json @@ -0,0 +1,13 @@ +{ + "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." } + ] +} diff --git a/toolchain/mcp/ac-venue-mcp.mjs b/toolchain/mcp/ac-venue-mcp.mjs index 96a9dc129a..143b270b73 100644 --- a/toolchain/mcp/ac-venue-mcp.mjs +++ b/toolchain/mcp/ac-venue-mcp.mjs @@ -9,7 +9,7 @@ // Rig ownership is a file on the Shelf that every session can read. import { spawn, execFile } from "node:child_process"; import { promisify } from "node:util"; -import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -181,10 +181,22 @@ 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.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; + } // 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. const ownConductor = existsSync(join(outDir(item.out), "run-fleet.py")) && !existsSync(join(outDir(item.out), "prepared.json")); - 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, announce: item.score ? item.announce : null, allowPieces: item.allowPieces || "spatial-rehearsal,notespatial-controls,culturehub-rehearsal,red,connection-check,connection-controls,say" }); } + let already = false; + if (item.status !== "ready" && !ownConductor && item.score && existsSync(join(outDir(item.out), "prepared.json"))) { + mark("checking"); runnerNote = `checking a previous preparation of ${item.label || item.out}`; + const c0 = await check(item.out); already = c0.ready; // singers still hold it, seats and SUB still match: no render needed + if (!already) runnerNote = `re-preparing ${item.label || item.out}: ${c0.tail.slice(-1)[0] || "stale"}`; + } + 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, 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(" | ")}`); const baked = !!readJson(join(outDir(item.out), "plan.json"))?.leadIn; // the announcement rides the stems: no Mac TTS @@ -203,7 +215,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: `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" }, score: { type: "string" }, label: { type: "string" }, announce: { type: "string" }, gap: { type: "number" }, allowPieces: { type: "string" }, rttMax: { type: "number" }, 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" }, 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"] } }, @@ -226,9 +238,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: outDir(it.out), score: it.score || null, label: it.label || it.score || it.out, announce: it.announce || null, gap: it.gap ?? 8, allowPieces: it.allowPieces || null, rttMax: it.rttMax || 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, 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) throw new Error("out (or setlist) is required"); push(args); } + else { if (!args.out && !args.hold) throw new Error("out, hold, 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 })) }); }