From c7cd85620b392571d5d1252c4acf8ecb4b08a4d5 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 8 Sep 2026 09:22:18 -0400 Subject: [PATCH] =?UTF-8?q?native:=20`notespatial`=20=E2=80=94=20Special?= =?UTF-8?q?=20Sign=20steps=20out=20of=20the=20recording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CultureHub piece begins: scorodeon-to-nsscore.mjs carries Special Sign's 13 lanes, six movements, and the spatial-rotation ribbon into .nsscore (fractional-midi pitches resolved to hz, per-event kinds to native waves), and the `notespatial` piece performs it — one box plays the whole field today, six will split it in LA. Every screen draws all thirteen lane bands (owned lanes glow, the rest ghost) and the house light blends whatever this box sounds. Conducting is already wired: /pieces/notespatial-state.txt, polled ~1Hz and PUT-able over LAN, moves lanes between machines and bends the clock (rate rebases so time never clicks) — the leader protocol before there is a leader. score-push.sh learned .nsscore. Claude-Session: https://claude.ai/code/session_01Xju7GpzP6o2JwxtqfKqFiG --- fedac/native/pieces/notespatial.mjs | 178 ++++++++++++++++++++ fedac/native/scripts/score-push.sh | 25 +-- fedac/native/tools/scorodeon-to-nsscore.mjs | 61 +++++++ 3 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 fedac/native/pieces/notespatial.mjs create mode 100644 fedac/native/tools/scorodeon-to-nsscore.mjs 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" : ""}`); -- 2.51.2