diff --git a/fedac/native/pieces/notespatial.mjs b/fedac/native/pieces/notespatial.mjs new file mode 100644 index 0000000000..8cdae214b4 --- /dev/null +++ b/fedac/native/pieces/notespatial.mjs @@ -0,0 +1,178 @@ +// notespatial — Note(s)pat(ial) Native: performs an .nsscore (Special +// Sign's lanes, movements, and rotation ribbon — see +// tools/scorodeon-to-nsscore.mjs) on bare metal. One box can carry the +// whole piece; six carry it spatially, each sounding its assigned lanes. +// +// Conducting happens through /pieces/notespatial-state.txt, polled ~1Hz +// and PUT-able over LAN by any machine (this is the leader protocol): +// { "lanes": [0,3,7] | "all", "rate": 1.0, "seek": ? } +// rate changes rebase the clock so time stays continuous — speed and +// trajectory move live without a click. Lanes not owned still draw as +// ghosts so every screen shows the whole field. +// +// Score selection: `notespatial:name` colon param, else +// /pieces/notespatial-current.txt. Loops by default (installation form); +// esc leaves. + +const dmxSlots = new Array(512).fill(0); // house light joins at d.510 +const dmxMap = (r, g, b) => { + dmxSlots[509] = r; dmxSlots[510] = g; dmxSlots[511] = b; + return dmxSlots; +}; + +let score = null; +let scoreName = ""; +let err = null; +let laneEvents = []; // per lane: sorted events with cursor +let owned = null; // Set of lane indices, or null = all +let rate = 1; +let base = 0; // wall ms at t=0 (rebased on rate change / seek / loop) +let stateStamp = 0; +let dmxLast = [-1, -1, -1]; +let dmxStamp = 0; + +function now() { return Date.now(); } +function scoreTime() { return ((now() - base) / 1000) * rate; } +function rebase(t) { base = now() - (t / rate) * 1000; } + +function loadScore(system) { + const buf = system?.readFileBytes?.("/pieces/" + scoreName + ".nsscore"); + if (!buf) { err = "no /pieces/" + scoreName + ".nsscore"; return; } + const bytes = new Uint8Array(buf); + const parts = []; + for (let i = 0; i < bytes.length; i += 4096) + parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + 4096))); + try { score = JSON.parse(parts.join("")); } catch (e) { err = "bad json: " + e.message; return; } + laneEvents = score.lanes.map(l => ({ cursor: 0, events: l.events })); + rebase(-1); // one-second breath before t=0 +} + +function applyState(system) { + const raw = system?.readFile?.("/pieces/notespatial-state.txt"); + if (!raw) return; + try { + const st = JSON.parse(raw); + if (Array.isArray(st.lanes)) owned = new Set(st.lanes); + else if (st.lanes === "all") owned = null; + if (typeof st.rate === "number" && st.rate > 0 && st.rate !== rate) { + const t = scoreTime(); + rate = st.rate; + rebase(t); + } + if (typeof st.seek === "number") { + rebase(st.seek); + for (const l of laneEvents) l.cursor = 0; + } + } catch (_) { /* half-written PUT — next poll wins */ } +} + +function boot({ system, colon, params }) { + scoreName = colon?.[0] || params?.[0] || ""; + if (!scoreName) { + scoreName = (system?.readFile?.("/pieces/notespatial-current.txt") || "").trim(); + } + if (!scoreName) { err = "no score named"; return; } + loadScore(system); + applyState(system); +} + +function sim({ sound, system }) { + if (err || !score) return; + const t = scoreTime(); + + if (now() - stateStamp > 1000) { stateStamp = now(); applyState(system); } + + if (t > score.dur + 2) { // loop — the installation form + rebase(-1); + for (const l of laneEvents) l.cursor = 0; + return; + } + + for (let i = 0; i < laneEvents.length; i++) { + const L = laneEvents[i]; + const mine = !owned || owned.has(i); + while (L.cursor < L.events.length && L.events[L.cursor].t <= t) { + const e = L.events[L.cursor++]; + if (!mine) continue; + if (e.t + e.dur <= t) continue; // clock already passed it + sound?.synth?.({ + type: e.wave, tone: e.hz ?? 220, duration: e.dur / rate, + volume: e.g * 0.8, attack: e.wave === "noise" ? 0.001 : 0.01, decay: 0.3, + }); + } + } +} + +function paint({ wipe, ink, box, write, screen, system }) { + if (err) { + wipe(40, 10, 10); + ink(255, 200, 200); + write("notespatial: " + err, { x: 8, y: 8, size: 1 }); + return; + } + const t = scoreTime(); + const W = screen.width, H = screen.height; + wipe(8, 8, 12); + + // Lane bands — owned lanes glow with their active events, ghosts stay dim. + // Also blend the active field into the house light. + const bandH = Math.max(8, Math.floor((H - 40) / score.lanes.length)); + let lr = 0, lg = 0, lb = 0, lw = 0; + for (let i = 0; i < score.lanes.length; i++) { + const lane = score.lanes[i]; + const mine = !owned || owned.has(i); + const y = 20 + i * bandH; + let level = 0; + const L = laneEvents[i]; + for (let j = Math.max(0, L.cursor - 12); j < L.events.length; j++) { + const e = L.events[j]; + if (e.t > t) break; + if (t < e.t + e.dur) level = Math.max(level, e.g * (1 - (t - e.t) / e.dur)); + } + const [cr, cg, cb] = lane.color; + const amb = mine ? 0.22 : 0.07; // ghosts show the whole field + const k = amb + level * (mine ? 0.78 : 0.15); + ink(Math.round(cr * k), Math.round(cg * k), Math.round(cb * k)); + box(0, y, W, bandH - 1); + if (mine && level > 0.02) { lr += cr * level; lg += cg * level; lb += cb * level; lw += level; } + ink(220, 220, 225); + write(lane.name, { x: 4, y, size: 1 }); + } + if (system?.dmxSend) { + let R = 0, G = 0, B = 0; + if (lw) { + const lvl = Math.min(1, lw); + R = Math.min(255, Math.round(lr / lw * lvl)); + G = Math.min(255, Math.round(lg / lw * lvl)); + B = Math.min(255, Math.round(lb / lw * lvl)); + } + const changed = R !== dmxLast[0] || G !== dmxLast[1] || B !== dmxLast[2]; + if ((changed && now() - dmxStamp >= 25) || now() - dmxStamp > 1000) { + if (system.dmxSend(dmxMap(R, G, B))) dmxLast = [R, G, B]; + dmxStamp = now(); + } + } + + // Movement + clock + rotation readout along the bottom. + const mv = (score.movements || []).find(m => t >= m.t0 && t < m.t1); + ink(200, 200, 210); + write((mv ? mv.name + " — " + mv.sub : "…") + + " " + (t < 0 ? "-" : "") + Math.abs(t).toFixed(1) + "s" + + (rate !== 1 ? " ×" + rate.toFixed(2) : ""), + { x: 4, y: H - 14, size: 1 }); + if (score.rotation && t >= 0) { + const u = Math.min(1, t / score.dur) * (score.rotation.length - 1); + const spin = score.rotation[Math.floor(u)] ?? 0; + if (spin > 0.01) write("↻ " + spin.toFixed(2), { x: W - 60, y: H - 14, size: 1 }); + } +} + +function act({ event: e, system }) { + if (e.is("keyboard:down:escape")) system?.jump?.("prompt"); + if (e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { + rebase(-1); + for (const l of laneEvents) l.cursor = 0; + } +} + +export { boot, paint, act, sim }; diff --git a/fedac/native/scripts/score-push.sh b/fedac/native/scripts/score-push.sh index bbafbdacb0..f6c87618fc 100755 --- a/fedac/native/scripts/score-push.sh +++ b/fedac/native/scripts/score-push.sh @@ -1,8 +1,9 @@ #!/bin/sh -# score-push.sh — put a score on a -# device and perform it. .mbscore translates on the way (tools/ -# mbscore-to-npscore.mjs); the pointer file names the score because -# lanserv's /jump can't carry colon params. +# score-push.sh — put a +# score on a device and perform it. .mbscore translates on the way +# (tools/mbscore-to-npscore.mjs); .nsscore performs via notespatial; the +# pointer file names the score because lanserv's /jump can't carry colon +# params. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -18,19 +19,23 @@ case "$FILE" in FILE="$OUT" ;; esac -NAME="$(basename "${FILE%.npscore}")" +case "$FILE" in + *.nsscore) EXT="nsscore"; PIECE="notespatial" ;; + *) EXT="npscore"; PIECE="npscore" ;; +esac +NAME="$(basename "$FILE")"; NAME="${NAME%.$EXT}" BASE="http://$HOST" # lanserv has been seen dropping a PUT body while answering 200 — verify # the round-trip size and retry once before performing a truncated score. WANT="$(wc -c < "$FILE" | tr -d ' ')" for attempt in 1 2; do - curl -fsS --max-time 20 -X PUT --data-binary "@$FILE" "$BASE/pieces/$NAME.npscore" > /dev/null - GOT="$(curl -fsS --max-time 20 "$BASE/pieces/$NAME.npscore" | wc -c | tr -d ' ')" + curl -fsS --max-time 20 -X PUT --data-binary "@$FILE" "$BASE/pieces/$NAME.$EXT" > /dev/null + GOT="$(curl -fsS --max-time 20 "$BASE/pieces/$NAME.$EXT" | wc -c | tr -d ' ')" [ "$GOT" = "$WANT" ] && break echo "score-push: verify failed ($GOT/$WANT bytes), attempt $attempt" >&2 [ "$attempt" = 2 ] && exit 1 done -printf '%s' "$NAME" | curl -fsS --max-time 10 -X PUT --data-binary @- "$BASE/pieces/npscore-current.txt" > /dev/null -curl -fsS --max-time 10 -X POST "$BASE/jump/npscore" > /dev/null -echo "→ $NAME.npscore on $HOST ($WANT bytes verified), performing" +printf '%s' "$NAME" | curl -fsS --max-time 10 -X PUT --data-binary @- "$BASE/pieces/$PIECE-current.txt" > /dev/null +curl -fsS --max-time 10 -X POST "$BASE/jump/$PIECE" > /dev/null +echo "→ $NAME.$EXT on $HOST ($WANT bytes verified), performing via $PIECE" diff --git a/fedac/native/tools/scorodeon-to-nsscore.mjs b/fedac/native/tools/scorodeon-to-nsscore.mjs new file mode 100644 index 0000000000..fb3dc55baf --- /dev/null +++ b/fedac/native/tools/scorodeon-to-nsscore.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// scorodeon → nsscore. Special Sign's release score (13 named+colored +// lanes of {t, dur, pitch, g, kind} events, six movements, ribbons — the +// spatial-rotation envelope included) becomes the Notepat Spatial +// performance score: +// +// .nsscore +// { +// "name", "dur", +// "movements": [{ "name", "sub", "t0", "t1", "level" }], +// "rotation": [..], // spatial-rotation ribbon env, verbatim +// "lanes": [{ "name", "color": [r,g,b], +// "events": [{ "t", "dur", "hz", "wave", "g" }] }] +// } +// +// Pitch may be fractional midi (microtonal) — converted to hz here so the +// player stays dumb. Per-event synthesis kinds resolve to native waves. +// +// node scorodeon-to-nsscore.mjs [out.nsscore] + +import { readFileSync, writeFileSync } from "node:fs"; + +const WAVE = { + tone: "sine", triangle: "triangle", whistle: "whistle", + resonator: "triangle", noise: "noise", rotor: "noise", +}; + +const [, , inPath, outPath] = process.argv; +if (!inPath) { + console.error("usage: scorodeon-to-nsscore.mjs [out.nsscore]"); + process.exit(1); +} + +const s = JSON.parse(readFileSync(inPath, "utf8")); +const hz = (m) => +(440 * Math.pow(2, (m - 69) / 12)).toFixed(3); +const hexRgb = (h) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)); + +const lanes = (s.lanes || []).map(l => ({ + name: l.name, + color: hexRgb(l.color || "#808080"), + events: (l.events || []).map(e => ({ + t: +e.t.toFixed(4), dur: +(e.dur ?? 0.2).toFixed(4), + ...(e.pitch != null ? { hz: hz(e.pitch) } : {}), + wave: WAVE[e.kind] || "sine", + g: +(e.g ?? 0.5).toFixed(3), + })), +})); + +const rotation = (s.ribbons || []).find(r => /rotation/i.test(r.name || "")); +const out = { + name: (s.title || "untitled").toLowerCase().replace(/[^a-z0-9]+/g, "-"), + dur: s.dur, + movements: s.movements || [], + ...(rotation ? { rotation: rotation.env } : {}), + lanes, +}; + +const dest = outPath || out.name + ".nsscore"; +writeFileSync(dest, JSON.stringify(out) + "\n"); +const n = lanes.reduce((a, l) => a + l.events.length, 0); +console.log(`${dest} — ${lanes.length} lanes, ${n} events, ${Math.round(s.dur)}s, ${out.movements.length} movements${rotation ? ", rotation ribbon" : ""}`);