diff --git a/grants/culturehub-la-2026/macneopolitan/bin/announce.mjs b/grants/culturehub-la-2026/macneopolitan/bin/announce.mjs index 66b03736ae..bde3d0e29f 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/announce.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/announce.mjs @@ -12,23 +12,37 @@ import {existsSync,mkdirSync,readFileSync,writeFileSync,statSync} from 'node:fs' import {join} from 'node:path'; import {homedir} from 'node:os'; import {spawnSync} from 'node:child_process'; -export async function announce(text,{voice='neutral:0',speed=1.0}={}) { +// `sing`: a melody (MIDI notes) — the spoken line is lifted onto it with WORLD +// f0 replacement on neo (bin/sing-proof.py under pop/.venv; the envelope stays +// Jeffrey's, so it is his voice singing the title as the piece begins). +export async function announce(text,{voice='neutral:0',speed=1.0,sing=null}={}) { const dir=join(homedir(),'Shelf','announce-cache');mkdirSync(dir,{recursive:true}); const body={from:text,provider:'jeffrey',voice,...(speed!==1?{speed}:{})}; - const hash=createHash('sha256').update(JSON.stringify(body)).digest('hex').slice(0,16); - const mp3=join(dir,`${hash}.mp3`),f32=join(dir,`${hash}.f32`); + const spokenHash=createHash('sha256').update(JSON.stringify(body)).digest('hex').slice(0,16); + const hash=sing?createHash('sha256').update(spokenHash+'|'+sing.join(',')).digest('hex').slice(0,16):spokenHash; + const mp3=join(dir,`${spokenHash}.mp3`),f32=join(dir,`${hash}.f32`); if(!existsSync(mp3)||statSync(mp3).size<1000){ const res=await fetch('https://aesthetic.computer/api/say',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),redirect:'follow'}); if(!res.ok)throw Error(`/api/say ${res.status}: ${(await res.text()).slice(0,200)}`); writeFileSync(mp3,Buffer.from(await res.arrayBuffer())); } + if(!existsSync(f32)&&sing){ + const wav=join(dir,`${spokenHash}.wav`),sung=join(dir,`${hash}.sung.wav`); + let r=spawnSync('ffmpeg',['-hide_banner','-loglevel','error','-y','-i',mp3,'-ac','1','-ar','44100','-sample_fmt','s16',wav]);if(r.status!==0)throw Error('ffmpeg failed: '+r.stderr); + r=spawnSync('scp',['-q',wav,`neo:/tmp/announce-${spokenHash}.wav`]);if(r.status!==0)throw Error('scp to neo failed'); + const cmd=`cd ~/aesthetic-computer/grants/culturehub-la-2026/macneopolitan && ../../../pop/.venv/bin/python bin/sing-proof.py /tmp/announce-${spokenHash}.wav /tmp/announce-${hash}.sung.wav ${sing.map(n=>String(n)).join(' ')}`; + r=spawnSync('ssh',['-o','BatchMode=yes','neo',`bash -lc ${JSON.stringify(cmd)}`],{encoding:'utf8'});if(r.status!==0)throw Error('sing-proof on neo failed: '+(r.stderr||r.stdout).slice(-300)); + r=spawnSync('scp',['-q',`neo:/tmp/announce-${hash}.sung.wav`,sung]);if(r.status!==0)throw Error('scp from neo failed'); + r=spawnSync('ffmpeg',['-hide_banner','-loglevel','error','-y','-i',sung,'-ac','1','-ar','44100','-f','f32le',f32]);if(r.status!==0)throw Error('ffmpeg failed: '+r.stderr); + } if(!existsSync(f32)){ const r=spawnSync('ffmpeg',['-hide_banner','-loglevel','error','-y','-i',mp3,'-ac','1','-ar','44100','-f','f32le',f32]); if(r.status!==0)throw Error('ffmpeg failed: '+r.stderr); } const seconds=statSync(f32).size/4/44100; - return {text,mp3,f32,seconds:+seconds.toFixed(3),hash}; + return {text,mp3,f32,seconds:+seconds.toFixed(3),hash,sung:!!sing,melody:sing||null}; } if(process.argv[1]&&process.argv[1].endsWith('announce.mjs')&&process.argv[2]){ - announce(process.argv.slice(2).join(' ')).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(String(e.message||e));process.exit(1);}); + const args=process.argv.slice(2),singArg=args.find(a=>a.startsWith('--sing=')); + announce(args.filter(a=>!a.startsWith('--')).join(' '),{sing:singArg?singArg.slice(7).split(',').map(Number):null}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(String(e.message||e));process.exit(1);}); } diff --git a/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs b/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs index ccc45c6c5e..c51e54833b 100644 --- a/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs +++ b/grants/culturehub-la-2026/macneopolitan/bin/fleet-trio.mjs @@ -16,7 +16,7 @@ const root=resolve(dirname(fileURLToPath(import.meta.url)),'..'); const args=process.argv.slice(2),command=args.shift(); if(!['plan','prepare','check'].includes(command))throw Error('Use fleet-trio.mjs plan|prepare|check [--out=DIR]. Playback remains held.'); const options=Object.fromEntries(args.map(a=>{if(!a.startsWith('--')||!a.includes('='))throw Error(`Invalid option ${a}`);const at=a.indexOf('=');return [a.slice(2,at),a.slice(at+1)];})); -for(const key of Object.keys(options))if(!['out','score','receipts','announce'].includes(key))throw Error(`Unknown option ${key}`); +for(const key of Object.keys(options))if(!['out','score','receipts','announce','sing'].includes(key))throw Error(`Unknown option ${key}`); const out=resolve(options.out??'/Users/jas/Shelf/culturehub-trio');mkdirSync(out,{recursive:true}); const scorePath=resolve(options.score??resolve(root,'scores/trio-chorus-doowop.mbscore')); const score={...JSON.parse(readFileSync(scorePath)),slug:scorePath.split('/').pop().replace(/\.mbscore$/,'')}; @@ -100,7 +100,10 @@ if(command==='plan') { // lead-in; the singers start after it (run-full-trio adds plan.leadIn). let lead=0,announced=null; if(options.announce){ - announced=await announce(options.announce);lead=+(announced.seconds+.6).toFixed(3); + // --sing: the title sung on the piece's own opening — the first lead line's notes + const firstLead=(plan.lyrics||[]).find(l=>l.role!=='hum'&&(l.syllables||[]).length); + const melody=options.sing&&firstLead?firstLead.syllables.slice(0,8).map(y=>y.note):null; + announced=await announce(options.announce,{sing:melody});lead=+(announced.seconds+.6).toFixed(3); const shift=e=>{e.t=+(e.t+lead).toFixed(4);}; for(const e of plan.events)shift(e); for(const l of plan.lyrics||[]){shift(l);for(const y of l.syllables||[])shift(y);} diff --git a/toolchain/mcp/ac-venue-mcp.mjs b/toolchain/mcp/ac-venue-mcp.mjs index 143b270b73..6e5754917f 100644 --- a/toolchain/mcp/ac-venue-mcp.mjs +++ b/toolchain/mcp/ac-venue-mcp.mjs @@ -92,7 +92,7 @@ async function status(out) { dmx, singers, display, lastReceipt: (() => { const r = latestReceipt(dir); return r && { runId: r.runId, completed: r.completed, error: r.error, seatWarnings: r.seatWarnings }; })() }; } -async function prepare({ score, out, stage = true, keepPiece = false, allowPieces = "", announce = null }) { +async function prepare({ score, out, stage = true, keepPiece = false, allowPieces = "", announce = null, sing = false }) { const dir = outDir(out); const steps = []; const scorePath = score ? (score.startsWith("/") ? score : join(LANE, "scores", score.endsWith(".mbscore") ? score : `${score}.mbscore`)) : null; if (scorePath) { @@ -101,7 +101,7 @@ async function prepare({ score, out, stage = true, keepPiece = false, allowPiece await new Promise((r) => setTimeout(r, 2000)); const plan = await sh("node", ["bin/fleet-trio.mjs", "plan", `--score=${scorePath}`, `--out=${dir}`]); steps.push({ plan: plan.out.trim().split("\n").pop(), err: plan.err.trim() }); if (plan.code) throw new Error(`plan failed: ${plan.err}`); - const prep = await sh("node", ["bin/fleet-trio.mjs", "prepare", `--score=${scorePath}`, `--out=${dir}`, ...(announce ? [`--announce=${announce}`] : [])]); steps.push({ prepare: prep.out.trim().split("\n").slice(-4), err: prep.err.trim() }); + const prep = await sh("node", ["bin/fleet-trio.mjs", "prepare", `--score=${scorePath}`, `--out=${dir}`, ...(announce ? [`--announce=${announce}`] : []), ...(sing ? ["--sing=1"] : [])]); steps.push({ prepare: prep.out.trim().split("\n").slice(-4), err: prep.err.trim() }); if (prep.code) throw new Error(`prepare failed: ${prep.err}`); } else steps.push({ note: `no score given: using the plan and bundle already in ${dir}` }); if (stage) { @@ -196,7 +196,7 @@ async function runQueueOnce() { 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" }); } + 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 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