From cc686a0fda51e5cd24281f31e3221e6b95239ca5 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 8 Sep 2026 00:00:21 -0400 Subject: [PATCH] =?UTF-8?q?native:=20npscore=20v2=20=E2=80=94=20velocity?= =?UTF-8?q?=20breathes=20the=20room,=20drums=20strike=20it=20white?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Velocity now shapes both lanes (volume and light gain), voices may be kind:percussion (fleet drum tokens k/s/c/h/o map through the shared kit), and the light model splits in two: sustained cues average like notepat's backdrop while flash cues (decay, no dur) ADD and fade — a kick punches white over the held chord color. DMX sends run at the 40Hz wire ceiling. tools/npscore-gen.mjs composes long-form scores (seeded pad/bass/arp/lead/percussion over i-VI-III-VII, arranged intro/groove/break/build/drop) whose dynamics exercise all of it. Two lessons carved in: system.readFile tails at 64KB (it is a log reader) so scores load via the uncapped readFileBytes lane, and lanserv can 200 a PUT while dropping the body — score-push verifies the round-trip size and retries. First long performance: vesper-engine, 4m40s, 3120 events. Claude-Session: https://claude.ai/code/session_01Xju7GpzP6o2JwxtqfKqFiG --- fedac/native/pieces/npscore.mjs | 126 +++++++++++++++------ fedac/native/scripts/score-push.sh | 13 ++- fedac/native/tools/mbscore-to-npscore.mjs | 59 ++++++---- fedac/native/tools/npscore-gen.mjs | 131 ++++++++++++++++++++++ 4 files changed, 276 insertions(+), 53 deletions(-) create mode 100644 fedac/native/tools/npscore-gen.mjs diff --git a/fedac/native/pieces/npscore.mjs b/fedac/native/pieces/npscore.mjs index 09db0086de..b88d7cf90d 100644 --- a/fedac/native/pieces/npscore.mjs +++ b/fedac/native/pieces/npscore.mjs @@ -1,11 +1,17 @@ -// 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. +// npscore — performs an .npscore (melody + percussion + light cues) on +// bare metal: notes down the GM synth lane, drums through the shared +// percussion kit, lights out the DMX lane. Format doc lives in +// tools/mbscore-to-npscore.mjs; tools/npscore-gen.mjs composes long ones. +// +// Light model (notepat's, roomward): sustained cues average, weighted by +// their velocity gain; flash cues (decay, no dur) ADD on top and fade — +// a kick punches white over the held chord color. DMX sends cap at 40Hz, +// the honest wire ceiling (a 492-slot frame is ~22ms of 250kbaud). // // 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. +// (pointer file — lanserv /jump can't carry colon params). + +import { playPercussion } from "/lib/percussion.mjs"; // 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. @@ -28,14 +34,19 @@ const FAMILY = [ { 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. +// notepat pitch-class palette (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], ]; +// Drum name → percussion-kit letter (lib/percussion.mjs speaks letters). +const DRUM_LETTER = { + kick: "c", snare: "d", clap: "e", snap: "f", "hat-c": "g", "hat-o": "a", + ride: "b", crash: "c#", splash: "d#", cowbell: "f#", block: "g#", tambo: "a#", +}; + // 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) => { @@ -46,9 +57,11 @@ const dmxMap = (r, g, b) => { let score = null; let scoreName = ""; let err = null; -let events = []; // [{ at, dur, hz, gm, vol }] sorted by at +let events = []; // [{ at, kind, … }] sorted by at +let lights = []; // [{ start, dur?, decay?, rgb, gain }] sorted by start let cursor = 0; -let t0 = 0; // Date.now() when playback began +let liteLo = 0; +let t0 = 0; let total = 0; let state = "load"; // load | play | done let dmxLast = [-1, -1, -1]; @@ -57,31 +70,61 @@ let dmxStamp = 0; const hz = (m) => 440 * Math.pow(2, (m - 69) / 12); function loadScore(system) { - const raw = system?.readFile?.("/pieces/" + scoreName + ".npscore"); + // readFile tails at 64KB (it's a log reader) — scores go through the + // uncapped byte lane and decode here; score JSON is ASCII by design. + let raw = null; + const buf = system?.readFileBytes?.("/pieces/" + scoreName + ".npscore"); + if (buf) { + 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))); + raw = parts.join(""); + } else { + 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 || []) { + if (v.kind === "percussion") { + for (const n of v.notes || []) { + const letter = DRUM_LETTER[n.drum] || n.drum; + events.push({ + at: lead + n.start, kind: "drum", letter, + vol: (n.velocity ?? v.velocity ?? 100) / 127, dur: 0.05, + }); + } + continue; + } const fam = FAMILY[Math.floor((v.program || 0) / 8)] || FAMILY[0]; for (const n of v.notes || []) { + const vel = (n.velocity ?? v.velocity ?? 100) / 127; 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, + at: lead + n.start, kind: "tone", dur: n.dur, midi: n.midi, + hz: hz(n.midi), gm: v.program || 0, fam, vel, vol: vel * 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], + + if (Array.isArray(score.lights) && score.lights.length) { + lights = score.lights.map(l => ({ + ...l, start: l.start + lead, gain: l.gain ?? 1, })); - } else if (lead) { - score.lights = score.lights.map(l => ({ ...l, start: l.start + lead })); + } else { + // Derive: tones hold their pitch color at their velocity; drums flash + // white and fade — kicks a beat longer than hats. + lights = events.map(e => e.kind === "tone" + ? { start: e.at, dur: e.dur, rgb: PC_RGB[((e.midi % 12) + 12) % 12], gain: e.vel } + : { start: e.at, decay: e.letter === "c" ? 0.18 : 0.1, rgb: [255, 255, 255], gain: e.vol }); } + lights.sort((a, b) => a.start - b.start); + total = events.reduce((m, e) => Math.max(m, e.at + e.dur), 0) + (score.tailSeconds ?? 0.5); cursor = 0; + liteLo = 0; t0 = Date.now() + 400; // breath so note zero isn't late state = "play"; } @@ -100,7 +143,10 @@ function sim({ sound }) { 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 + if (e.at + Math.max(e.dur, 0.1) <= t) continue; // clock already passed it + if (e.kind === "drum") { + playPercussion(sound, e.letter, { volume: e.vol }); + } else { 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, @@ -110,7 +156,7 @@ function sim({ sound }) { if (t > total) state = "done"; } -function paint({ wipe, ink, write, screen, system }) { +function paint({ wipe, ink, write, system }) { if (err) { wipe(40, 10, 10); ink(255, 200, 200); @@ -119,22 +165,38 @@ function paint({ wipe, ink, write, screen, system }) { } 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++; } + // Base layer: velocity-weighted average of active sustained cues. + // Flash layer: decaying additive bursts. Head pointer skips spent cues. + while (liteLo < lights.length && + lights[liteLo].start + (lights[liteLo].dur ?? lights[liteLo].decay ?? 0.2) < t) liteLo++; + let r = 0, g = 0, b = 0, w = 0, fr = 0, fg = 0, fb = 0; + for (let i = liteLo; i < lights.length; i++) { + const l = lights[i]; + if (l.start > t) break; + if (l.dur != null) { + if (t < l.start + l.dur) { + r += l.rgb[0] * l.gain; g += l.rgb[1] * l.gain; b += l.rgb[2] * l.gain; + w += l.gain; + } + } else { + const k = Math.max(0, 1 - (t - l.start) / (l.decay ?? 0.12)) * l.gain; + fr += l.rgb[0] * k; fg += l.rgb[1] * k; fb += l.rgb[2] * k; + } } - if (w) { r = Math.round(r / w); g = Math.round(g / w); b = Math.round(b / w); } + if (w) { r /= w; g /= w; b /= w; } + const R = Math.min(255, Math.round(r + fr)); + const G = Math.min(255, Math.round(g + fg)); + const B = Math.min(255, Math.round(b + fb)); - const changed = r !== dmxLast[0] || g !== dmxLast[1] || b !== dmxLast[2]; + 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]; + if (system?.dmxSend && ((changed && now - dmxStamp >= 25) || 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; + 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") { @@ -147,7 +209,7 @@ function paint({ wipe, ink, write, screen, system }) { } } -function act({ event: e, system, sound }) { +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")) { if (state === "done") loadScore(system); diff --git a/fedac/native/scripts/score-push.sh b/fedac/native/scripts/score-push.sh index 9a12467fb4..bbafbdacb0 100755 --- a/fedac/native/scripts/score-push.sh +++ b/fedac/native/scripts/score-push.sh @@ -21,7 +21,16 @@ esac NAME="$(basename "${FILE%.npscore}")" BASE="http://$HOST" -curl -fsS --max-time 10 -X PUT --data-binary "@$FILE" "$BASE/pieces/$NAME.npscore" > /dev/null +# 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 ' ')" + [ "$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, performing" +echo "→ $NAME.npscore on $HOST ($WANT bytes verified), performing" diff --git a/fedac/native/tools/mbscore-to-npscore.mjs b/fedac/native/tools/mbscore-to-npscore.mjs index 1dc2f131d0..854060c4f2 100755 --- a/fedac/native/tools/mbscore-to-npscore.mjs +++ b/fedac/native/tools/mbscore-to-npscore.mjs @@ -2,7 +2,9 @@ // 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: +// notes: "60:0.25,r:1.5,k:1,…" }] }, durations in beats; letter tokens are +// drums — k kick, s snare, c clap, h closed hat, o open hat, t tambo, +// b ride, x crash). Both normalize to: // // .npscore — the AC Native performance score // { @@ -10,28 +12,40 @@ // "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] }] +// "voices": [ +// { "name", "program", "velocity", +// "notes": [{ "midi", "start", "dur", "velocity"? }] }, +// { "name", "kind": "percussion", "velocity", +// "notes": [{ "drum": "kick", "start", "velocity"? }] } +// ], +// "lights": [ +// { "start", "dur", "rgb": [r,g,b], "gain": 0..1 }, // sustained +// { "start", "decay": 0.15, "rgb", "gain" } // flash (no dur) +// ] // } // -// 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. +// All npscore times are seconds; velocity shapes BOTH volume and light gain. +// When the source carries no lighting, sustained cues derive one-per-note +// from the notepat pitch-class palette (sharps black — the black-key idiom +// carries to the room) and drums become white flashes; the player averages +// sustained cues and adds flashes on top, exactly like notepat's 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 DRUM_TOKEN = { + k: "kick", s: "snare", c: "clap", h: "hat-c", o: "hat-o", + t: "tambo", b: "ride", x: "crash", +}; + const [, , inPath, outPath] = process.argv; if (!inPath) { console.error("usage: mbscore-to-npscore.mjs [out.npscore]"); @@ -44,19 +58,22 @@ const name = (src.name || src.title || basename(inPath).replace(/\.mbscore$/, "" let voices; if (Array.isArray(src.voices)) { - // Fleet dialect: compact "midi:beats" strings, r = rest, sequential. + // Fleet dialect: compact "tok:beats" strings, r = rest, sequential. const spb = 60 / (src.bpm || 120); voices = src.voices.map((v, i) => { - const notes = []; + const tones = [], drums = []; let cursor = 0; - for (const tok of String(v.notes || "").split(",")) { - const [n, beats] = tok.trim().split(":"); + for (const raw of String(v.notes || "").split(",")) { + const [tok, beats] = raw.trim().split(":"); const dur = (parseFloat(beats) || 0) * spb; - if (n !== "r" && n !== "") notes.push({ midi: +n, start: +cursor.toFixed(4), dur: +dur.toFixed(4) }); + if (DRUM_TOKEN[tok]) drums.push({ drum: DRUM_TOKEN[tok], start: +cursor.toFixed(4) }); + else if (tok !== "r" && tok !== "") tones.push({ midi: +tok, 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 }; + const base = { name: v.name || `voice-${i}`, velocity: v.velocity ?? 100 }; + return drums.length + ? { ...base, kind: "percussion", notes: drums } + : { ...base, program: v.program ?? 0, notes: tones }; }); } else { // App dialect: one flat pre-timed voice. @@ -69,11 +86,15 @@ if (Array.isArray(src.voices)) { }]; } +const gain = (n, v) => +(((n.velocity ?? v.velocity ?? 100) / 127).toFixed(3)); 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); + : voices.flatMap(v => v.notes.map(n => v.kind === "percussion" + ? { start: n.start, decay: n.drum === "kick" ? 0.18 : 0.1, + rgb: [255, 255, 255], gain: gain(n, v) } + : { start: n.start, dur: n.dur, + rgb: PC_RGB[((n.midi % 12) + 12) % 12], gain: gain(n, v) })) + .sort((a, b) => a.start - b.start); const out = { name, diff --git a/fedac/native/tools/npscore-gen.mjs b/fedac/native/tools/npscore-gen.mjs new file mode 100644 index 0000000000..95bd2a8b92 --- /dev/null +++ b/fedac/native/tools/npscore-gen.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// npscore-gen — composes a long-form .npscore (see mbscore-to-npscore.mjs +// for the format): pad, bass, arpeggio, lead, and a percussion voice over +// an i-VI-III-VII cycle, arranged intro → (groove → break → build → drop) +// × N → outro. Velocity carries the arrangement's breath — soft breaks dim +// the room, builds ramp it, kicks flash white — since the player maps +// velocity to both volume and light gain. +// +// node npscore-gen.mjs [--minutes 4] [--seed 7] [--bpm 122] [out.npscore] + +import { writeFileSync } from "node:fs"; + +const arg = (k, d) => { + const i = process.argv.indexOf("--" + k); + return i > 0 ? parseFloat(process.argv[i + 1]) : d; +}; +const minutes = arg("minutes", 4); +const seed = arg("seed", 7); +const bpm = arg("bpm", 122); +const outArg = process.argv.slice(2).filter(a => !a.startsWith("--") && + a !== String(minutes) && a !== String(seed) && a !== String(bpm))[0]; + +let s = seed >>> 0 || 1; +const rnd = () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32; +const pick = (a) => a[Math.floor(rnd() * a.length)]; + +const NAMES1 = ["umber", "vesper", "cobalt", "ember", "sable", "aurora", "quartz", "willow"]; +const NAMES2 = ["cascade", "orbit", "meadow", "signal", "harbor", "lattice", "drift", "engine"]; +const name = pick(NAMES1) + "-" + pick(NAMES2); + +const spb = 60 / bpm; +const bar = 4 * spb; +// A minor: i VI III VII — triads + bass roots. +const CHORDS = [ + { root: 33, triad: [57, 60, 64] }, // Am + { root: 29, triad: [53, 57, 60] }, // F + { root: 36, triad: [60, 64, 67] }, // C + { root: 31, triad: [55, 59, 62] }, // G +]; + +// One cycle = groove 16 + break 8 + build 8 + drop 16 = 48 bars. +// intro 8 + cycles + outro 8 fill the asked minutes. +const cycleBars = 48; +const askedBars = Math.max(24, Math.round((minutes * 60) / bar)); +const cycles = Math.max(1, Math.round((askedBars - 16) / cycleBars)); + +const pad = [], bass = [], arp = [], lead = [], drums = []; +const tone = (v, midi, start, dur, vel) => + v.push({ midi, start: +start.toFixed(4), dur: +dur.toFixed(4), velocity: Math.round(vel) }); +const hit = (drum, start, vel) => + drums.push({ drum, start: +start.toFixed(4), velocity: Math.round(vel) }); + +let t = 0; +const chordAt = (barIdx) => CHORDS[barIdx % 4]; + +function padBar(b, vel) { + const c = chordAt(b); + for (const m of c.triad) tone(pad, m, t, bar * 0.98, vel); +} +function bassBar(b, vel) { + const c = chordAt(b); + for (let e = 0; e < 8; e++) // offbeat eighths, techno lope + if (e % 2 === 1) tone(bass, c.root + 12, t + e * spb / 2, spb * 0.45, vel + rnd() * 8); +} +function arpBar(b, vel) { + const c = chordAt(b); + const tones = [...c.triad, c.triad[0] + 12, c.triad[1] + 12, c.triad[2] + 12]; + for (let x = 0; x < 16; x++) { + const accent = x % 4 === 0 ? 14 : 0; // downbeat accents breathe the light + tone(arp, tones[(x * 3 + b) % tones.length], t + x * spb / 4, spb * 0.22, vel + accent); + } +} +function leadBar(b, vel) { + const c = chordAt(b); + const steps = [0, 2, 1, 2]; // slow motif over the triad + for (let q = 0; q < 4; q++) + if (rnd() < 0.7) tone(lead, c.triad[steps[(q + b) % 4]] + 12, t + q * spb, spb * 0.9, vel); +} +function drumBar(kind, ramp = 0) { + for (let e = 0; e < 8; e++) { + const et = t + e * spb / 2; + if (kind !== "break") { + if (e % 2 === 0 && kind !== "buildup") hit("kick", et, 115); + if (e % 2 === 1) hit("hat-c", et, 55 + (e === 7 ? 25 : rnd() * 12)); + } + if ((e === 2 || e === 6) && (kind === "full" || kind === "drop")) hit("clap", et, 100); + } + if (kind === "drop") hit("hat-o", t + 3.5 * spb, 70); + if (kind === "buildup") // snare ladder — 8ths then 16ths, velocity climbing + for (let x = 0; x < (ramp < 0.5 ? 8 : 16); x++) + hit("snare", t + x * bar / (ramp < 0.5 ? 8 : 16), 60 + ramp * 60); +} + +function section(bars, fn) { for (let b = 0; b < bars; b++) { fn(b); t += bar; } } + +section(8, b => { padBar(b, 45 + b * 2); if (b >= 4) bassBar(b, 80); }); // intro +for (let cy = 0; cy < cycles; cy++) { + hit("crash", t, 120); + section(16, b => { // groove + padBar(b, 58); bassBar(b, 96); drumBar(b < 8 ? "full" : "drop"); + if (b >= 8) arpBar(b, 62); + }); + section(8, b => { padBar(b, 40 - b * 2); arpBar(b, 48); drumBar("break"); }); // break + section(8, b => { // build + padBar(b, 45 + b * 4); bassBar(b, 90 + b * 3); arpBar(b, 55 + b * 5); + drumBar("buildup", b / 8); + }); + hit("crash", t, 127); + section(16, b => { // drop + padBar(b, 66); bassBar(b, 108); arpBar(b, 84); leadBar(b, 100); + drumBar("drop"); + }); +} +section(8, b => { padBar(b, 55 - b * 6); arpBar(b, Math.max(20, 50 - b * 6)); }); // outro + +const score = { + name, bpm, leadSeconds: 1, tailSeconds: 2, + voices: [ + { name: "pad", program: 89, velocity: 60, notes: pad }, + { name: "bass", program: 38, velocity: 100, notes: bass }, + { name: "arp", program: 4, velocity: 80, notes: arp }, + { name: "lead", program: 80, velocity: 100, notes: lead }, + { name: "drums", kind: "percussion", velocity: 110, notes: drums }, + ], + // no lights: the player derives velocity-gained colors + white drum flashes +}; + +const dest = outArg || name + ".npscore"; +writeFileSync(dest, JSON.stringify(score) + "\n"); +const n = pad.length + bass.length + arp.length + lead.length + drums.length; +console.log(`${dest} — "${name}" ${Math.round(t / 60)}m${Math.round(t % 60)}s, ${n} events (${drums.length} drum hits), ${cycles} cycle(s)`); -- 2.51.2