From 5b693f2b256f6fdc936d951aab5ef87b502e44cd Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 24 Sep 2026 13:57:26 -0700 Subject: [PATCH] Add one-call composition volume across native fleet and SUB --- .../notespatial-controls.mjs | 10 +++++-- .../notespatial-performance-optimized.mjs | 8 ++++++ .../sub-display/app.mjs | 10 +++++++ .../notespatial-2026-09-24/volume-server.mjs | 19 +++++++++++++ fedac/native/tools/composition-volume.mjs | 7 +++++ .../macneopolitan/fleet/CONNECTIONS.md | 27 +++++++++++++++++++ 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 fedac/native/candidates/notespatial-2026-09-24/volume-server.mjs create mode 100644 fedac/native/tools/composition-volume.mjs diff --git a/fedac/native/candidates/notespatial-2026-09-24/notespatial-controls.mjs b/fedac/native/candidates/notespatial-2026-09-24/notespatial-controls.mjs index 85c511392e..478626a7a7 100644 --- a/fedac/native/candidates/notespatial-2026-09-24/notespatial-controls.mjs +++ b/fedac/native/candidates/notespatial-2026-09-24/notespatial-controls.mjs @@ -2,7 +2,7 @@ import {createBatteryWatch} from './battery-watch-power-v2.mjs'; import {createScoreBrightness} from './score-brightness-audio-v2.mjs'; import {candlelightRgb} from './candlelight.mjs'; const brightnessControl=createScoreBrightness(); -import * as rehearsal from './notespatial-performance-optimized.mjs'; +import * as rehearsal from './notespatial-performance-volume-v2.mjs'; const batteryWatch = createBatteryWatch(); let batteryReportAt=-Infinity; @@ -29,12 +29,15 @@ function assign(system, seat, persist = false) { // Discard previous commands. A boot or seat selection must never start music. system.writeFile('/pieces/spatial-rehearsal-command.json', JSON.stringify({ id: 'boot-arm', action: 'arm' })); rehearsal.boot({ ...bootApi, colon: [], params: [] }); + const resume=read(system,'/pieces/volume-resume.json'); + system.writeFile('/pieces/volume-resume.json','null'); + if(resume)rehearsal.resumeForVolume(resume,bootApi.sound.time); selected = true; return true; } export function boot(api) { brightnessControl.boot(api.system); - api.sound.volume.setMix(.25); + api.sound.volume.setMix((read(api.system, '/pieces/composition-volume.json')?.percent ?? 25) / 100); bootApi = api; const display = read(api.system, DISPLAY_FILE) || {}; concert = display.concert !== false; flipY = display.flipY === true; @@ -110,6 +113,9 @@ let candleSystem, candleNext=0, lastRgb='', controlsAt=-Infinity, reportAt=-Infi export function paint(api) { if(api.sound.time-controlsAt>=.25){ controlsAt=api.sound.time; + const volume=read(api.system,'/pieces/composition-volume.json'); + if(Number.isFinite(volume?.percent)&&volume.percent>=0&&volume.percent<=100)api.sound.volume.setMix(volume.percent/100); + api.system.writeFile('/pieces/composition-volume-status.json',JSON.stringify({percent:api.sound.volume.mix*100,id:volume?.id,at:api.sound.time})); try{const controls=JSON.parse(api.system.readFile('/pieces/performance-controls.json'));rehearsal.setVisualMode(controls.noteLabels?'notes':'frames');}catch{} } paintOriginal(api); diff --git a/fedac/native/candidates/notespatial-2026-09-24/notespatial-performance-optimized.mjs b/fedac/native/candidates/notespatial-2026-09-24/notespatial-performance-optimized.mjs index 01fd04b86c..f98d5c91a5 100644 --- a/fedac/native/candidates/notespatial-2026-09-24/notespatial-performance-optimized.mjs +++ b/fedac/native/candidates/notespatial-2026-09-24/notespatial-performance-optimized.mjs @@ -422,3 +422,11 @@ export function paint({ wipe, ink, box, line, circle, write, overlayWrite, scree export function act({ event, system }) { if (event.is('keyboard:down:escape')) system.jump('prompt'); } export function leave() { stopVoices(); if(lastSound)clearFx(lastSound); } + +// One-use handoff for a controller-authorized volume-control reload. +export function resumeForVolume(saved, now) { + if(error || saved.phase!=='playing' || !Number.isFinite(saved.origin) || + !Number.isFinite(saved.audioTime) || now15 || + now-saved.origin<0 || now-saved.origin>=runDuration() || saved.seat!==config.seat)return false; + origin=saved.origin;runId=saved.runId;mode='score';phase='playing';seenCommand='boot-arm';return true; +} diff --git a/fedac/native/candidates/notespatial-2026-09-24/sub-display/app.mjs b/fedac/native/candidates/notespatial-2026-09-24/sub-display/app.mjs index 518c141189..0c4509d827 100644 --- a/fedac/native/candidates/notespatial-2026-09-24/sub-display/app.mjs +++ b/fedac/native/candidates/notespatial-2026-09-24/sub-display/app.mjs @@ -71,3 +71,13 @@ function setConcert(on){document.body.classList.toggle('concert',on);localStorag setConcert(localStorage.concertMode!=='false'); $('concert').onclick=()=>setConcert(!document.body.classList.contains('concert')); window.addEventListener('keydown',e=>{if(e.key.toLowerCase()==='c'&&!['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName)){e.preventDefault();setConcert(!document.body.classList.contains('concert'));}}); + +const volumeEndpoint=`http://${location.hostname}:8795/volume`; +let volumeId=''; +async function pollCompositionVolume(){ + try{const r=await fetch(volumeEndpoint,{cache:'no-store',signal:AbortSignal.timeout(700)});const v=await r.json(); + if(r.ok&&v.id!==volumeId&&Number.isFinite(v.percent)&&v.percent>=0&&v.percent<=100){volumeId=v.id;$('level').value=v.percent;$('levelLabel').textContent=v.percent+'%';apply();}} + catch{}setTimeout(pollCompositionVolume,200); +} +pollCompositionVolume(); +$('level').addEventListener('change',()=>fetch(volumeEndpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({percent:Number($('level').value)})}).catch(()=>{})); diff --git a/fedac/native/candidates/notespatial-2026-09-24/volume-server.mjs b/fedac/native/candidates/notespatial-2026-09-24/volume-server.mjs new file mode 100644 index 0000000000..a415ce6a59 --- /dev/null +++ b/fedac/native/candidates/notespatial-2026-09-24/volume-server.mjs @@ -0,0 +1,19 @@ +// One composition gain for the six native players and browser SUB. +import http from 'node:http'; +const hosts=[237,242,238,236,241,239].map(n=>`192.168.1.${n}`); +let state={percent:100,id:`volume-${Date.now()}`}; +http.createServer(async(req,res)=>{ + res.setHeader('Access-Control-Allow-Origin','*');res.setHeader('Access-Control-Allow-Headers','Content-Type');res.setHeader('Access-Control-Allow-Methods','GET,POST,OPTIONS');res.setHeader('Cache-Control','no-store'); + const send=(code,value)=>{res.writeHead(code,{'Content-Type':'application/json'});res.end(JSON.stringify(value));}; + if(req.method==='OPTIONS')return send(200,{}); + if(req.url!=='/volume')return send(404,{}); + if(req.method==='GET')return send(200,state); + if(req.method!=='POST')return send(405,{}); + try{ + let raw='';for await(const c of req){raw+=c;if(raw.length>1024)throw Error('Body too large');} + const {percent}=JSON.parse(raw);if(!Number.isFinite(percent)||percent<0||percent>100)return send(400,{error:'percent must be 0..100'}); + const next={percent,id:`volume-${Date.now()}`};state=next; + const nodes=await Promise.all(hosts.map(async host=>{try{const r=await fetch(`http://${host}/pieces/composition-volume.json`,{method:'PUT',body:JSON.stringify(next),signal:AbortSignal.timeout(1200)});return {host,accepted:r.ok};}catch{return {host,accepted:false};}})); + send(nodes.every(n=>n.accepted)?200:207,{...next,nodes}); + }catch(e){send(400,{error:e.message});} +}).listen(8795,'0.0.0.0',()=>console.log('Composition volume: POST :8795/volume {"percent":0..100}')); diff --git a/fedac/native/tools/composition-volume.mjs b/fedac/native/tools/composition-volume.mjs new file mode 100644 index 0000000000..44134e499f --- /dev/null +++ b/fedac/native/tools/composition-volume.mjs @@ -0,0 +1,7 @@ +// One call, six laptops + Windows SUB. The Blueberry volume service must be up. +const percent=Number(process.argv[2]); +if(process.argv[2]===undefined||!Number.isFinite(percent)||percent<0||percent>100)throw Error('Usage: node composition-volume.mjs <0..100>'); +const base=process.env.COMPOSITION_CONTROL||'http://127.0.0.1:8795'; +const response=await fetch(base+'/volume',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({percent}),signal:AbortSignal.timeout(2500)}); +const result=await response.json();console.log(JSON.stringify(result)); +if(!response.ok||result.nodes?.some(n=>!n.accepted))process.exitCode=1; diff --git a/grants/culturehub-la-2026/macneopolitan/fleet/CONNECTIONS.md b/grants/culturehub-la-2026/macneopolitan/fleet/CONNECTIONS.md index 7fd580129b..67e197d9ac 100644 --- a/grants/culturehub-la-2026/macneopolitan/fleet/CONNECTIONS.md +++ b/grants/culturehub-la-2026/macneopolitan/fleet/CONNECTIONS.md @@ -362,3 +362,30 @@ The original full score remains `/pieces/spatial-rehearsal.nsscore`, duration `d3ae52f7f427aa753734977c7079ec81c66167100107d88e2960e35cf5066d18`. Its sustained bass enters around 60 seconds; stronger/earlier sub arrangement work was discussed but was not part of the accepted short replay. + +## One-call composition volume + +Blueberry serves `:8795/volume`. One POST updates the desired SUB level and fans +out to all six native players concurrently, without a piece reload or cue: + +```sh +node fedac/native/tools/composition-volume.mjs 100 +# Or: curl -s http://127.0.0.1:8795/volume -H 'Content-Type: application/json' -d '{"percent":25}' +``` + +Range0–100 includes mute0. This is composition master gain; hardware mixers +remain at100%. Native players consume `/pieces/composition-volume.json` at4Hz; +the browser follows the same value at5Hz. Normal propagation is a few hundred +milliseconds, not sample-accurate. The response reports each native upload; +verify `/pieces/composition-volume-status.json` and the SUB heartbeat for actual +application. Partial failures are reported, not silently treated as success. +The SUB level slider also publishes the global setting on release. + +Start the controller with +`node fedac/native/candidates/notespatial-2026-09-24/volume-server.mjs`. +The live wrapper imports `notespatial-performance-volume-v2.mjs`, the versioned +copy of `notespatial-performance-optimized.mjs`, because native module imports +are cached across piece jumps. This installation interrupted the running test; +all six were restored at the current score position, and the room follower +restarted. Subsequent volume requests require no reload. All seven targets were +verified at100% (native smoothed readback approximately99.94%). -- 2.51.2