From 6ce4ffecf661cc96313fcea822471874f25fa4a6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 7 Sep 2026 23:48:57 -0400 Subject: [PATCH] =?UTF-8?q?native:=20`.npscore`=20=E2=80=94=20scores=20tha?= =?UTF-8?q?t=20carry=20melody=20and=20light?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu Band .mbscore files (both dialects: the app's flat auto-perform score and the fleet score's compact beat strings) translate through tools/mbscore-to-npscore.mjs into .npscore — seconds-normalized voices with GM programs plus a lights track of timed rgb cues. When a source has no lighting, cues derive one-per-note from the notepat pitch-class palette, so every existing mbscore is instantly light-capable; an .mbscore may also author its own "lights" array (Menu Band's decoder ignores the extra key). The `npscore` piece performs them on bare metal — notes down the GM synth lane, cues averaged out the DMX lane, backdrop mirroring the room — and scripts/score-push.sh puts a score on a device and plays it in one move (pointer file, since /jump can't carry colon params). First performance: prelude-in-c on the wedge tri. Claude-Session: https://claude.ai/code/session_01Xju7GpzP6o2JwxtqfKqFiG --- fedac/native/pieces/npscore.mjs | 157 ++++++++++++++++++++++ fedac/native/scripts/score-push.sh | 27 ++++ fedac/native/tools/mbscore-to-npscore.mjs | 90 +++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 fedac/native/pieces/npscore.mjs create mode 100755 fedac/native/scripts/score-push.sh create mode 100755 fedac/native/tools/mbscore-to-npscore.mjs diff --git a/fedac/native/pieces/npscore.mjs b/fedac/native/pieces/npscore.mjs new file mode 100644 index 0000000000..09db0086de --- /dev/null +++ b/fedac/native/pieces/npscore.mjs @@ -0,0 +1,157 @@ +// npscore — performs an .npscore (melody voices + light cues) on bare +// metal: notes through the GM synth lane, lights out the DMX lane, and +// the backdrop mirrors the room. Format doc lives in +// tools/mbscore-to-npscore.mjs, which translates Menu Band .mbscore files. +// +// Score selection: `npscore:name` colon param, else /pieces/npscore-current.txt +// (a pointer file — lanserv PUT can't pass colon params through /jump). +// Scores land beside pieces as /pieces/.npscore via lanserv PUT. + +// Same 16 GM family fallbacks as notepat — the C GM voice takes over where +// a program is implemented; these shape the `type` path for the rest. +const FAMILY = [ + { wave: "piano", attack: 0.002, decay: 0.9, volume: 0.55 }, + { wave: "triangle", attack: 0.001, decay: 0.35, volume: 0.5 }, + { wave: "square", attack: 0.004, decay: 0.2, volume: 0.4 }, + { wave: "harp", attack: 0.001, decay: 0.6, volume: 0.5 }, + { wave: "triangle", attack: 0.002, decay: 0.5, volume: 0.6 }, + { wave: "sawtooth", attack: 0.08, decay: 0.4, volume: 0.4 }, + { wave: "sawtooth", attack: 0.06, decay: 0.4, volume: 0.38 }, + { wave: "sawtooth", attack: 0.02, decay: 0.3, volume: 0.45 }, + { wave: "square", attack: 0.01, decay: 0.3, volume: 0.4 }, + { wave: "whistle", attack: 0.03, decay: 0.3, volume: 0.42 }, + { wave: "square", attack: 0.004, decay: 0.3, volume: 0.42 }, + { wave: "sine", attack: 0.12, decay: 0.5, volume: 0.4 }, + { wave: "sine", attack: 0.06, decay: 0.4, volume: 0.38 }, + { wave: "harp", attack: 0.001, decay: 0.6, volume: 0.5 }, + { wave: "triangle", attack: 0.001, decay: 0.18, volume: 0.5 }, + { wave: "noise", attack: 0.001, decay: 0.3, volume: 0.45 }, +]; + +// notepat pitch-class palette (sharps black) — the player's own fallback +// when a score ships without a lights track. +const PC_RGB = [ + [255, 50, 50], [0, 0, 0], [255, 160, 0], [0, 0, 0], [255, 230, 0], + [50, 200, 50], [0, 0, 0], [50, 120, 255], [0, 0, 0], [130, 50, 200], + [0, 0, 0], [180, 80, 255], +]; + +// House fixture map — Chauvet Wedge Tri, 3ch at d.490 (see notepat.mjs). +const dmxSlots = new Array(492).fill(0); +const dmxMap = (r, g, b) => { + dmxSlots[489] = r; dmxSlots[490] = g; dmxSlots[491] = b; + return dmxSlots; +}; + +let score = null; +let scoreName = ""; +let err = null; +let events = []; // [{ at, dur, hz, gm, vol }] sorted by at +let cursor = 0; +let t0 = 0; // Date.now() when playback began +let total = 0; +let state = "load"; // load | play | done +let dmxLast = [-1, -1, -1]; +let dmxStamp = 0; + +const hz = (m) => 440 * Math.pow(2, (m - 69) / 12); + +function loadScore(system) { + const raw = system?.readFile?.("/pieces/" + scoreName + ".npscore"); + if (!raw) { err = "no /pieces/" + scoreName + ".npscore"; return; } + try { score = JSON.parse(raw); } catch (e) { err = "bad json: " + e.message; return; } + const lead = score.leadSeconds || 0; + events = []; + for (const v of score.voices || []) { + const fam = FAMILY[Math.floor((v.program || 0) / 8)] || FAMILY[0]; + for (const n of v.notes || []) { + events.push({ + at: lead + n.start, dur: n.dur, midi: n.midi, hz: hz(n.midi), + gm: v.program || 0, fam, + vol: ((n.velocity ?? v.velocity ?? 100) / 127) * fam.volume, + }); + } + } + events.sort((a, b) => a.at - b.at); + if (!Array.isArray(score.lights) || !score.lights.length) { + score.lights = events.map(e => ({ + start: e.at, dur: e.dur, rgb: PC_RGB[((e.midi % 12) + 12) % 12], + })); + } else if (lead) { + score.lights = score.lights.map(l => ({ ...l, start: l.start + lead })); + } + total = events.reduce((m, e) => Math.max(m, e.at + e.dur), 0) + (score.tailSeconds ?? 0.5); + cursor = 0; + t0 = Date.now() + 400; // breath so note zero isn't late + state = "play"; +} + +function boot({ system, colon, params }) { + scoreName = colon?.[0] || params?.[0] || ""; + if (!scoreName) { + scoreName = (system?.readFile?.("/pieces/npscore-current.txt") || "").trim(); + } + if (!scoreName) { err = "no score named (npscore:name or npscore-current.txt)"; return; } + loadScore(system); +} + +function sim({ sound }) { + if (state !== "play") return; + const t = (Date.now() - t0) / 1000; + while (cursor < events.length && events[cursor].at <= t) { + const e = events[cursor++]; + if (e.at + e.dur > t) { // never fire a note the clock already passed + sound?.synth?.({ + type: e.fam.wave, tone: e.hz, duration: e.dur, volume: e.vol, + attack: e.fam.attack, decay: e.fam.decay, gmProgram: e.gm, + }); + } + } + if (t > total) state = "done"; +} + +function paint({ wipe, ink, write, screen, system }) { + if (err) { + wipe(40, 10, 10); + ink(255, 200, 200); + write("npscore: " + err, { x: 8, y: 8, size: 1 }); + return; + } + const t = (Date.now() - t0) / 1000; + + // Average every active light cue — notepat's held-tone blend, roomward. + let r = 0, g = 0, b = 0, w = 0; + for (const l of score.lights) { + if (l.start <= t && t < l.start + l.dur) { r += l.rgb[0]; g += l.rgb[1]; b += l.rgb[2]; w++; } + } + if (w) { r = Math.round(r / w); g = Math.round(g / w); b = Math.round(b / w); } + + const changed = r !== dmxLast[0] || g !== dmxLast[1] || b !== dmxLast[2]; + const now = Date.now(); + if (system?.dmxSend && ((changed && now - dmxStamp > 33) || now - dmxStamp > 1000)) { + if (system.dmxSend(dmxMap(r, g, b))) dmxLast = [r, g, b]; + dmxStamp = now; + } + + wipe(r, g, b); + const bright = r + g + b > 380; + ink(bright ? 0 : 255, bright ? 0 : 255, bright ? 0 : 255); + write(score?.name || scoreName, { x: 8, y: 8, size: 1 }); + if (state === "done") { + write("done — enter replays, esc leaves", { x: 8, y: 24, size: 1 }); + } else { + const pct = Math.max(0, Math.min(1, t / total)); + write(Math.round(pct * 100) + "%", { x: 8, y: 24, size: 1 }); + ink(bright ? 40 : 220, bright ? 40 : 220, bright ? 40 : 220); + write("#".repeat(Math.round(pct * 24)) || "-", { x: 8, y: 40, size: 1 }); + } +} + +function act({ event: e, system, sound }) { + if (e.is("keyboard:down:escape")) system?.jump?.("prompt"); + if (e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { + if (state === "done") loadScore(system); + } +} + +export { boot, paint, act, sim }; diff --git a/fedac/native/scripts/score-push.sh b/fedac/native/scripts/score-push.sh new file mode 100755 index 0000000000..9a12467fb4 --- /dev/null +++ b/fedac/native/scripts/score-push.sh @@ -0,0 +1,27 @@ +#!/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. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +NATIVE="$(dirname "$SCRIPT_DIR")" + +HOST="${1:-}"; FILE="${2:-}" +[ -z "$HOST" ] || [ -z "$FILE" ] && { echo "usage: score-push.sh " >&2; exit 1; } + +case "$FILE" in + *.mbscore) + OUT="${TMPDIR:-/tmp}/$(basename "${FILE%.mbscore}").npscore" + node "$NATIVE/tools/mbscore-to-npscore.mjs" "$FILE" "$OUT" + FILE="$OUT" ;; +esac + +NAME="$(basename "${FILE%.npscore}")" +BASE="http://$HOST" + +curl -fsS --max-time 10 -X PUT --data-binary "@$FILE" "$BASE/pieces/$NAME.npscore" > /dev/null +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, performing" diff --git a/fedac/native/tools/mbscore-to-npscore.mjs b/fedac/native/tools/mbscore-to-npscore.mjs new file mode 100755 index 0000000000..1dc2f131d0 --- /dev/null +++ b/fedac/native/tools/mbscore-to-npscore.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// mbscore → npscore. Menu Band scores come in two dialects: the app's flat +// auto-perform score ({ name, notes: [{midi,start,dur,velocity}] }, seconds) +// and the fleet score ({ title, bpm, voices: [{ program, velocity, +// notes: "60:0.25,r:1.5,…" }] }, durations in beats). Both normalize to: +// +// .npscore — the AC Native performance score +// { +// "name": "prelude-in-c", +// "bpm": 76, // informational +// "leadSeconds": 3, // silence before the first note +// "tailSeconds": 0.5, // hold after the last note-end +// "voices": [{ "name", "program", "velocity", +// "notes": [{ "midi", "start", "dur", "velocity"? }] }], +// "lights": [{ "start", "dur", "rgb": [r,g,b] }] +// } +// +// All npscore times are seconds. When the source carries no lighting, cues +// are derived one-per-note from the notepat pitch-class palette (sharps are +// black — the black-key idiom carries to the room); the player averages +// whatever cues overlap, exactly like notepat's held-tone backdrop. +// +// node mbscore-to-npscore.mjs [out.npscore] + +import { readFileSync, writeFileSync } from "node:fs"; +import { basename } from "node:path"; + +// notepat base palette by pitch class (c c# d … b); sharps black. +const PC_RGB = [ + [255, 50, 50], [0, 0, 0], [255, 160, 0], [0, 0, 0], [255, 230, 0], + [50, 200, 50], [0, 0, 0], [50, 120, 255], [0, 0, 0], [130, 50, 200], + [0, 0, 0], [180, 80, 255], +]; + +const [, , inPath, outPath] = process.argv; +if (!inPath) { + console.error("usage: mbscore-to-npscore.mjs [out.npscore]"); + process.exit(1); +} + +const src = JSON.parse(readFileSync(inPath, "utf8")); +const name = (src.name || src.title || basename(inPath).replace(/\.mbscore$/, "")) + .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + +let voices; +if (Array.isArray(src.voices)) { + // Fleet dialect: compact "midi:beats" strings, r = rest, sequential. + const spb = 60 / (src.bpm || 120); + voices = src.voices.map((v, i) => { + const notes = []; + let cursor = 0; + for (const tok of String(v.notes || "").split(",")) { + const [n, beats] = tok.trim().split(":"); + const dur = (parseFloat(beats) || 0) * spb; + if (n !== "r" && n !== "") notes.push({ midi: +n, start: +cursor.toFixed(4), dur: +dur.toFixed(4) }); + cursor += dur; + } + return { name: v.name || `voice-${i}`, program: v.program ?? 0, + velocity: v.velocity ?? 100, notes }; + }); +} else { + // App dialect: one flat pre-timed voice. + voices = [{ + name: "lead", program: src.program ?? 0, velocity: 100, + notes: (src.notes || []).map(n => ({ + midi: n.midi, start: n.start, dur: n.dur, + ...(n.velocity != null ? { velocity: n.velocity } : {}), + })), + }]; +} + +const lights = Array.isArray(src.lights) && src.lights.length + ? src.lights + : voices.flatMap(v => v.notes.map(n => ({ + start: n.start, dur: n.dur, rgb: PC_RGB[((n.midi % 12) + 12) % 12], + }))).sort((a, b) => a.start - b.start); + +const out = { + name, + ...(src.bpm ? { bpm: src.bpm } : {}), + ...(src.lead ? { leadSeconds: src.lead } : {}), + tailSeconds: src.tailSeconds ?? 0.5, + voices, + lights, +}; + +const dest = outPath || inPath.replace(/\.mbscore$/, "") + ".npscore"; +writeFileSync(dest, JSON.stringify(out, null, 1) + "\n"); +const noteCount = voices.reduce((s, v) => s + v.notes.length, 0); +console.log(`${dest} — ${voices.length} voice(s), ${noteCount} notes, ${out.lights.length} light cues`); -- 2.51.2