From e38b28a5d669382aaa1a664060100006de35df21 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 8 Sep 2026 10:27:31 -0400 Subject: [PATCH] =?UTF-8?q?notespatial:=20the=20diagram=20comes=20home=20t?= =?UTF-8?q?o=20the=20paper=20=E2=80=94=20cream,=20ink,=20thinkpads,=20one?= =?UTF-8?q?=20globe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mp4 diagram redrawn in the graphic-score paper style (scorodeon's cream/ink/red, YWFT title, Menlo chrome) around the piece's real claim: one virtual acoustic space — a wireframe globe of the 13 voices, spinning by the composed rotation ribbon, assembled/dilated/contracted per movement — six AC OS thinkpad sprites as windows into it (each screen renders the space from its own seat), mini rigs on tripods carrying it into the room, sight-lines making the choreography visible, and the pal listener centered in the physical space that mediates it all via the actual computers. The full track data runs the bottom: thirteen lanes of event pills, movement doors, the spin sparkline, a red sweeping playhead. --still renders single frames for QA. Claude-Session: https://claude.ai/code/session_01Xju7GpzP6o2JwxtqfKqFiG --- .../bin/render-notespatial-diagram.mjs | 564 +++++++++++------- 1 file changed, 359 insertions(+), 205 deletions(-) diff --git a/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs b/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs index 727dcf67d9..7939533117 100644 --- a/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs +++ b/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs @@ -1,22 +1,37 @@ #!/usr/bin/env node -// render-notespatial-diagram.mjs — an mp4 diagram of *Note(s)pat(ial) -// Native*: six laptop+speaker sprites in a ring in 3D acoustic space, a -// listener at the center, and the 13 lanes of Special Sign as colored -// orbs that fly between machines following the live choreography modeled -// in ../NOTESPATIAL-COMPOSITION.md (enter / antiphony / spin / scatter / -// converge / rest). Events ripple across the floor from whichever -// machine sounds them. Frames stream raw to ffmpeg; the engine-rendered -// master lies underneath. +// render-notespatial-diagram.mjs — the mp4 diagram of *Note(s)pat(ial) +// Native*, drawn in the graphic-score paper style (cream, ink, red — the +// scorodeon world) as an ink technical drawing come alive: +// +// · one VIRTUAL ACOUSTIC SPACE — a wireframe globe holding the 13 +// voices of Special Sign, spinning and manipulated per movement +// · six AC OS ThinkPads ringed in the physical room, each a WINDOW +// into that space (every screen renders the globe from its own seat) +// · a mini speaker rig beside each machine — sound leaves the virtual +// space through the actual computers +// · the human listener, centered, in the room that mediates it all +// · sight-lines from voice to machine show the live choreography +// (enter / antiphony / orbit / scatter / converge / rest); the full +// track data runs along the bottom with a sweeping playhead +// +// Frames stream raw into ffmpeg; audio is the piece rebuilt from its own +// C engine, trimmed to release bounds. // // node render-notespatial-diagram.mjs +// node render-notespatial-diagram.mjs --still -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; -import { createCanvas } from "canvas"; +import { createCanvas, registerFont, loadImage } from "canvas"; +import { magickRenderText, YWFT_PATH } from "../../../pop/lib/preview-shared.mjs"; -const [, , scorePath, audioPath, outPath] = process.argv; -if (!outPath) { - console.error("usage: render-notespatial-diagram.mjs "); +const args = process.argv.slice(2); +const scorePath = args[0]; +const still = args.includes("--still") ? parseFloat(args[args.indexOf("--still") + 1]) : null; +const audioPath = still == null ? args[1] : null; +const outPath = still == null ? args[2] : args[args.indexOf("--still") + 2]; +if (!scorePath || !outPath) { + console.error("usage: render-notespatial-diagram.mjs | --still "); process.exit(1); } const S = JSON.parse(readFileSync(scorePath, "utf8")); @@ -24,20 +39,27 @@ const S = JSON.parse(readFileSync(scorePath, "utf8")); const W = 1920, H = 1080, FPS = 30; const DUR = S.dur + 2; const FRAMES = Math.ceil(DUR * FPS); -const SEATS = 6, RING = 2.8; +const SEATS = 6, RING = 2.9; + +// ── the graphic-score paper ────────────────────────────────────────── +const CREAM = "#FBFAFF", INK = "#1E1E1E", LINE_RED = "#B3402E"; +const ink = (a) => `rgba(30,30,30,${a})`; +const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; +try { registerFont("/System/Library/Fonts/Menlo.ttc", { family: "MenloX" }); } catch { /* default */ } +const MONO = (px, bold) => `${bold ? "bold " : ""}${px}px MenloX, monospace`; const canvas = createCanvas(W, H); const ctx = canvas.getContext("2d"); -// ---- camera: slow orbit, a touch above eye line ------------------------- +// ── camera ─────────────────────────────────────────────────────────── const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; const norm = (a) => { const l = Math.hypot(...a); return [a[0] / l, a[1] / l, a[2] / l]; }; function project(p, t) { - const az = 0.35 + t * 0.055, el = 0.38, dist = 7.2, fov = 900; // one lazy lap - const look = [0, 0.6, 0]; + const az = 0.4 + t * 0.05, el = 0.34, dist = 8.2, fov = 980; + const look = [0, 1.0, 0]; const cam = [Math.cos(az) * Math.cos(el) * dist, look[1] + Math.sin(el) * dist, Math.sin(az) * Math.cos(el) * dist]; const fwd = norm(sub(look, cam)); @@ -46,237 +68,369 @@ function project(p, t) { const d = sub(p, cam); const z = dot(d, fwd); if (z < 0.5) return null; - return { x: W / 2 + dot(d, right) * fov / z, y: H / 2 - dot(d, up) * fov / z, s: fov / z, d: z }; + return { x: W / 2 + dot(d, right) * fov / z, y: H / 2 - 80 - dot(d, up) * fov / z, s: fov / z, d: z }; } -const seatPos = (i) => { - const a = (i / SEATS) * Math.PI * 2 - Math.PI / 2; - return [Math.cos(a) * RING, 0, Math.sin(a) * RING]; -}; +const seatAngle = (i) => (i / SEATS) * Math.PI * 2 - Math.PI / 2; +const seatPos = (i) => [Math.cos(seatAngle(i)) * RING, 0, Math.sin(seatAngle(i)) * RING]; -// ---- choreography: lane index → seat, per the composition model --------- -const M = S.movements; // I..VI +// ── choreography: lane → seat (the composition model) ──────────────── +const M = S.movements; function laneSeat(lane, t) { const u = (m) => (t - m.t0) / (m.t1 - m.t0); - if (t < M[0].t1) { // I Assembly — enter: 1 → 3 → 6 machines + if (t < M[0].t1) { const k = u(M[0]); if (k < 0.34) return 0; if (k < 0.67) return [0, 2, 4][lane % 3]; return lane % SEATS; } - if (t < M[1].t1) { // II Signal — antiphony, question walks the ring - const q = Math.floor(u(M[1]) * 4) % SEATS; // four walks across the dwell - const QUESTION = [4, 5, 8, 9, 12]; // melody, echo, noses, vowels - if (QUESTION.includes(lane)) return q; - return (q + 3) % SEATS; // answers from across the ring + if (t < M[1].t1) { + const q = Math.floor(u(M[1]) * 4) % SEATS; + return [4, 5, 8, 9, 12].includes(lane) ? q : (q + 3) % SEATS; } - if (t < M[2].t1) { // III Super-Spin — orbit, doubling + if (t < M[2].t1) { const k = u(M[2]); - const turns = Math.floor(k * 8 * (1 + k)); // accelerating - return (lane + turns) % SEATS; + return (lane + Math.floor(k * 8 * (1 + k))) % SEATS; } - if (t < M[3].t1) return lane % SEATS; // IV Constellation — max dispersion - if (t < M[4].t1) { // V Home Sign — staggered convergence on seat 0 - const k = u(M[4]); - return k * 13 > lane ? 0 : lane % SEATS; - } - return 0; // VI Run-Down — one body + if (t < M[3].t1) return lane % SEATS; + if (t < M[4].t1) return u(M[4]) * 13 > lane ? 0 : lane % SEATS; + return 0; } -// smoothed seat positions per lane (orbs fly, not teleport) -const orbAt = S.lanes.map((_, i) => seatPos(laneSeat(i, 0)).slice()); -function orbTarget(lane, t) { - const s = seatPos(laneSeat(lane, t)); - const stack = lane * 0.55; // hover height ladder so orbs never overlap - return [s[0] * 0.86, 1.15 + (stack % 1.65), s[2] * 0.86]; +// ── the virtual acoustic space: globe spin + manipulation ──────────── +function globeState(t) { + const u = (m) => Math.max(0, Math.min(1, (t - m.t0) / (m.t1 - m.t0))); + let scale = 1, cy = 1.72, r = 1.05; + if (t < M[0].t1) scale = 0.25 + u(M[0]) * 0.75; // assembling + else if (t >= M[3].t0 && t < M[3].t1) r = 1.05 + u(M[3]) * 0.3; // dilate + else if (t >= M[4].t0 && t < M[4].t1) { scale = 1 - u(M[4]) * 0.3; cy = 1.72 - u(M[4]) * 0.5; } + else if (t >= M[4].t1) { scale = 0.7 - Math.min(1, (t - M[4].t1) / 6) * 0.55; cy = 1.22; } + return { scale, cy, r }; +} +let spinAngle = 0, lastT = 0; +function spinRate(t) { + const base = 0.25; + if (t >= M[2].t0 && t < M[2].t1) { // Super-Spin — the whirl + const k = (t - M[2].t0) / (M[2].t1 - M[2].t0); + return base + 2.6 * (0.4 + k * k * 2.2); + } + const i = Math.min((S.rotation?.length ?? 1) - 1, Math.floor((t / S.dur) * (S.rotation?.length ?? 1))); + return base + (S.rotation?.[Math.max(0, i)] ?? 0) * 1.4; +} +const voiceAngle = (i) => (i / S.lanes.length) * Math.PI * 2; +function voicePos(i, t) { + const g = globeState(t); + const a = voiceAngle(i) + spinAngle; + const ringR = (i % 2 ? 0.52 : 0.8) * g.r * g.scale; + const y = g.cy + Math.sin(voiceAngle(i) * 3 + i) * 0.28 * g.scale; + return [Math.cos(a) * ringR, y, Math.sin(a) * ringR]; } -// ---- event feed --------------------------------------------------------- +// ── event feed ─────────────────────────────────────────────────────── const cursors = S.lanes.map(() => 0); -const ripples = []; // { born, seat, color, g } -const glow = S.lanes.map(() => 0); // per-lane activity level +const ripples = []; +const glow = S.lanes.map(() => 0); -// ---- ffmpeg sink -------------------------------------------------------- -const ff = spawn("ffmpeg", [ - "-y", "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), - "-i", "-", "-i", audioPath, - "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "medium", "-crf", "19", - "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-shortest", outPath, -], { stdio: ["pipe", "ignore", "inherit"] }); +function circle3d(cx3, cy3, cz3, r, axis, t, style, width) { + ctx.strokeStyle = style; ctx.lineWidth = width; + ctx.beginPath(); + let started = false; + for (let a = 0; a <= 60; a++) { + const th = (a / 30) * Math.PI; + let p; + if (axis === "y") p = [cx3 + Math.cos(th) * r, cy3, cz3 + Math.sin(th) * r]; + else if (axis === "x") p = [cx3, cy3 + Math.cos(th) * r, cz3 + Math.sin(th) * r]; + else p = [cx3 + Math.cos(th) * r, cy3 + Math.sin(th) * r, cz3]; + const q = project(p, t); + if (!q) { started = false; continue; } + if (!started) { ctx.moveTo(q.x, q.y); started = true; } else ctx.lineTo(q.x, q.y); + } + ctx.stroke(); +} -const write = (buf) => new Promise(res => ff.stdin.write(buf) ? res() : ff.stdin.once("drain", res)); +// ── sprites ────────────────────────────────────────────────────────── +function thinkpadSprite(p2, seat, t, screenGlow) { + const u = Math.max(16, 52 * p2.s / 140); + ctx.save(); + ctx.translate(p2.x, p2.y); + // slab base — matte black parallelogram with ink outline + ctx.fillStyle = "#26262a"; + ctx.strokeStyle = ink(0.75); + ctx.lineWidth = Math.max(1, u * 0.05); + ctx.beginPath(); + ctx.moveTo(-u * 1.06, 0); ctx.lineTo(u * 1.06, 0); + ctx.lineTo(u * 1.3, u * 0.44); ctx.lineTo(-u * 1.3, u * 0.44); + ctx.closePath(); ctx.fill(); ctx.stroke(); + // keyboard dot grid + the red TrackPoint + ctx.fillStyle = "rgba(255,255,255,0.16)"; + for (let r = 0; r < 3; r++) + for (let c = -4; c <= 4; c++) + ctx.fillRect(c * u * 0.22 + r * u * 0.035 - u * 0.02, u * 0.09 + r * u * 0.1, u * 0.07, u * 0.045); + ctx.fillStyle = LINE_RED; + ctx.beginPath(); ctx.arc(0, u * 0.14, u * 0.045, 0, Math.PI * 2); ctx.fill(); + // lid: black bezel, near-black screen + ctx.fillStyle = "#1b1b1f"; + ctx.strokeStyle = ink(0.75); + ctx.fillRect(-u * 1.02, -u * 1.52, u * 2.04, u * 1.5); + ctx.strokeRect(-u * 1.02, -u * 1.52, u * 2.04, u * 1.5); + const sx = -u * 0.9, sy = -u * 1.42, sw = u * 1.8, sh = u * 1.28; + ctx.fillStyle = "#0a0a10"; + ctx.fillRect(sx, sy, sw, sh); + if (screenGlow) { + ctx.fillStyle = rgba(screenGlow.c, 0.10 + screenGlow.a * 0.2); + ctx.fillRect(sx, sy, sw, sh); + } + // the window: this seat's view INTO the virtual space — every voice + // drawn at its azimuth relative to this seat, nearer = larger + const sa = seatAngle(seat) + Math.PI; // machine faces the center + for (let i = 0; i < S.lanes.length; i++) { + const rel = voiceAngle(i) + spinAngle - sa; + const depth = Math.cos(rel); + if (depth < -0.35) continue; + const px = sx + sw / 2 + Math.sin(rel) * sw * 0.42; + const py = sy + sh * 0.24 + ((i * 37) % 100) / 100 * sh * 0.55; + const pr = Math.max(0.8, (0.5 + depth * 0.5) * u * 0.12) * (1 + glow[i] * 1.3); + ctx.fillStyle = rgba(S.lanes[i].color, 0.35 + glow[i] * 0.65); + ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); +} -function laptopSprite(p2, color, awake, flip) { - const u = Math.max(14, 46 * p2.s / 130); +function rigSprite(p2, color, active) { + const u = Math.max(10, 30 * p2.s / 140); ctx.save(); ctx.translate(p2.x, p2.y); - ctx.fillStyle = "#23252c"; - ctx.beginPath(); // keyboard base, slight parallelogram for depth - ctx.moveTo(-u, 0); ctx.lineTo(u, 0); ctx.lineTo(u * 1.25, u * 0.42); ctx.lineTo(-u * 1.25, u * 0.42); - ctx.closePath(); ctx.fill(); - // screen - ctx.fillStyle = "#31343d"; - ctx.fillRect(-u, -u * 1.45, u * 2, u * 1.42); - ctx.fillStyle = awake ? color : "#101014"; - ctx.fillRect(-u * 0.88, -u * 1.36, u * 1.76, u * 1.22); - // speaker box beside the machine - ctx.fillStyle = "#1c1e24"; - ctx.fillRect(u * 1.5, -u * 1.1, u * 0.72, u * 1.5); - ctx.strokeStyle = awake ? color : "#33363e"; - ctx.lineWidth = Math.max(1, u * 0.08); - ctx.beginPath(); ctx.arc(u * 1.86, -u * 0.55, u * 0.24, 0, Math.PI * 2); ctx.stroke(); - ctx.beginPath(); ctx.arc(u * 1.86, -u * 0.18, u * 0.13, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = ink(0.8); + ctx.lineWidth = Math.max(1, u * 0.07); + // splayed tripod legs + ctx.beginPath(); + ctx.moveTo(0, -u * 0.4); ctx.lineTo(-u * 0.7, u * 0.5); + ctx.moveTo(0, -u * 0.4); ctx.lineTo(u * 0.7, u * 0.5); + ctx.moveTo(0, -u * 0.4); ctx.lineTo(0, u * 0.58); + ctx.stroke(); + // cab + ctx.fillStyle = "#2c2c31"; + ctx.beginPath(); + ctx.roundRect(-u * 0.62, -u * 1.9, u * 1.24, u * 1.62, u * 0.12); + ctx.fill(); ctx.stroke(); + // drivers — woofer + tweeter, ring lit by the sounding lane + ctx.strokeStyle = active ? rgba(active.c, 0.5 + active.a * 0.5) : "rgba(255,255,255,0.25)"; + ctx.lineWidth = Math.max(1, u * 0.1); + ctx.beginPath(); ctx.arc(0, -u * 0.86, u * 0.34, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.arc(0, -u * 1.5, u * 0.16, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } -function listenerSprite(p2, t) { - const u = 40 * p2.s / 130; +function palSprite(p2, t) { + const u = Math.max(14, 46 * p2.s / 140); ctx.save(); - ctx.translate(p2.x, p2.y + Math.sin(t * 1.7) * 2); - ctx.fillStyle = "#d8d4c8"; - ctx.beginPath(); ctx.arc(0, -u * 1.9, u * 0.52, 0, Math.PI * 2); ctx.fill(); // head - ctx.beginPath(); // shoulders - ctx.moveTo(-u, 0); ctx.quadraticCurveTo(0, -u * 1.7, u, 0); ctx.closePath(); ctx.fill(); + ctx.translate(p2.x, p2.y + Math.sin(t * 1.6) * u * 0.04); + ctx.strokeStyle = ink(0.85); + ctx.lineWidth = Math.max(1.2, u * 0.06); + ctx.fillStyle = CREAM; + // round body, bigger round head — paper pal + ctx.beginPath(); ctx.ellipse(0, -u * 0.34, u * 0.62, u * 0.5, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.beginPath(); ctx.arc(0, -u * 1.28, u * 0.62, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + // ears — this pal is all listening + ctx.beginPath(); ctx.arc(-u * 0.62, -u * 1.32, u * 0.16, Math.PI * 0.4, Math.PI * 1.6); ctx.stroke(); + ctx.beginPath(); ctx.arc(u * 0.62, -u * 1.32, u * 0.16, -Math.PI * 0.6, Math.PI * 0.6); ctx.stroke(); + // face: dot eyes + small open-mouth wonder + ctx.fillStyle = INK; + ctx.beginPath(); ctx.arc(-u * 0.2, -u * 1.32, u * 0.055, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(u * 0.2, -u * 1.32, u * 0.055, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(0, -u * 1.06, u * 0.09, 0, Math.PI * 2); + ctx.strokeStyle = ink(0.85); ctx.lineWidth = Math.max(1, u * 0.045); ctx.stroke(); ctx.restore(); } -const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; +// ── track data strip (scorodeon-kin, whole piece at once) ──────────── +const STRIP_Y = 872, STRIP_H = 150, STRIP_X = 210, STRIP_W = W - STRIP_X - 60; +function drawStrip(t) { + ctx.strokeStyle = ink(0.25); + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(60, STRIP_Y - 26); ctx.lineTo(W - 60, STRIP_Y - 26); ctx.stroke(); + if (S.rotation) { // the composed spin, over the doors + ctx.strokeStyle = "rgba(62,124,138,0.7)"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < S.rotation.length; i++) { + const x = STRIP_X + (i / (S.rotation.length - 1)) * STRIP_W; + const y = STRIP_Y - 6 - S.rotation[i] * 16; + i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); + } + ctx.stroke(); + } + const laneH = STRIP_H / S.lanes.length; + for (let i = 0; i < S.lanes.length; i++) { + const y = STRIP_Y + i * laneH; + ctx.fillStyle = rgba(S.lanes[i].color, 0.9); + ctx.font = MONO(11); + ctx.textAlign = "right"; + ctx.fillText(S.lanes[i].name, STRIP_X - 10, y + laneH * 0.72); + ctx.textAlign = "left"; + for (const e of S.lanes[i].events) { + const x = STRIP_X + (e.t / S.dur) * STRIP_W; + const w = Math.max(1.5, (e.dur / S.dur) * STRIP_W); + ctx.fillStyle = rgba(S.lanes[i].color, 0.28 + Math.min(0.62, e.g)); + ctx.fillRect(x, y + laneH * 0.18, w, laneH * 0.64); + } + } + for (const m of M) { // movement doors + const x = STRIP_X + (m.t0 / S.dur) * STRIP_W; + ctx.strokeStyle = ink(0.5); + ctx.beginPath(); ctx.moveTo(x, STRIP_Y - 4); ctx.lineTo(x, STRIP_Y + STRIP_H + 4); ctx.stroke(); + ctx.fillStyle = ink(0.55); + ctx.font = MONO(13); + ctx.fillText(m.name.split(" ")[0], x + 5, STRIP_Y + STRIP_H + 20); + } + const px = STRIP_X + Math.min(1, Math.max(0, t / S.dur)) * STRIP_W; + ctx.strokeStyle = LINE_RED; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(px, STRIP_Y - 14); ctx.lineTo(px, STRIP_Y + STRIP_H + 6); ctx.stroke(); + ctx.fillStyle = LINE_RED; + ctx.font = MONO(13, true); + const mm = Math.max(0, t), tt = `${Math.floor(mm / 60)}:${String(Math.floor(mm % 60)).padStart(2, "0")}`; + ctx.fillText(tt, px + 6, STRIP_Y - 8); +} -async function main() { - console.log(`rendering ${FRAMES} frames…`); - for (let f = 0; f < FRAMES; f++) { - const t = f / FPS; +// ── frame ──────────────────────────────────────────────────────────── +let titleImg = null; +function drawFrame(t) { + spinAngle += spinRate(t) * (t - lastT); lastT = t; - // advance events → ripples + glow - for (let i = 0; i < S.lanes.length; i++) { - const evs = S.lanes[i].events; - while (cursors[i] < evs.length && evs[cursors[i]].t <= t) { - const e = evs[cursors[i]++]; - if (e.g > 0.06) ripples.push({ born: t, seat: laneSeat(i, t), color: S.lanes[i].color, g: e.g }); - glow[i] = Math.min(1, glow[i] + e.g); - } - glow[i] *= 0.90; + for (let i = 0; i < S.lanes.length; i++) { + const evs = S.lanes[i].events; + while (cursors[i] < evs.length && evs[cursors[i]].t <= t) { + const e = evs[cursors[i]++]; + if (e.g > 0.06) ripples.push({ born: t, seat: laneSeat(i, t), color: S.lanes[i].color, g: e.g }); + glow[i] = Math.min(1, glow[i] + e.g); } - while (ripples.length && t - ripples[0].born > 1.6) ripples.shift(); + glow[i] *= 0.90; + } + while (ripples.length && t - ripples[0].born > 1.5) ripples.shift(); - // orbs ease toward their choreographed seats - for (let i = 0; i < S.lanes.length; i++) { - const tgt = orbTarget(i, t); - for (let k = 0; k < 3; k++) orbAt[i][k] += (tgt[k] - orbAt[i][k]) * 0.08; - } + ctx.fillStyle = CREAM; + ctx.fillRect(0, 0, W, H); - // ---- draw ---- - ctx.fillStyle = "#07070b"; - ctx.fillRect(0, 0, W, H); + // floor: ink rings + seat ticks + circle3d(0, 0, 0, RING * 1.32, "y", t, ink(0.14), 1.5); + circle3d(0, 0, 0, RING, "y", t, ink(0.22), 1.5); + circle3d(0, 0, 0, RING * 0.45, "y", t, ink(0.1), 1); - // floor disc + ring - const drawFloorCircle = (r, style, width) => { - ctx.strokeStyle = style; ctx.lineWidth = width; - ctx.beginPath(); - let started = false; - for (let a = 0; a <= 64; a++) { - const p = project([Math.cos(a / 32 * Math.PI) * r, 0, Math.sin(a / 32 * Math.PI) * r], t); - if (!p) { started = false; continue; } - if (!started) { ctx.moveTo(p.x, p.y); started = true; } else ctx.lineTo(p.x, p.y); - } - ctx.stroke(); - }; - drawFloorCircle(RING * 1.35, "#16181f", 2); - drawFloorCircle(RING, "#1e2029", 2); - drawFloorCircle(RING * 0.5, "#14161c", 1); + // floor ripples — sound leaving the machines + for (const r of ripples) { + const age = (t - r.born) / 1.5; + const [sx, , sz] = seatPos(r.seat); + circle3d(sx * 0.92, 0.01, sz * 0.92, 0.12 + age * 1.7, "y", t, + rgba(r.color, (1 - age) * 0.5 * Math.min(1, r.g * 1.5)), 2); + } - // ripples (floor rings from sounding seats) - for (const r of ripples) { - const age = (t - r.born) / 1.6; - const rad = 0.15 + age * 2.1; - const [sx, , sz] = seatPos(r.seat); - ctx.strokeStyle = rgba(r.color, (1 - age) * 0.55 * Math.min(1, r.g * 1.6)); - ctx.lineWidth = 2.5; - ctx.beginPath(); - let started = false; - for (let a = 0; a <= 48; a++) { - const p = project([sx * 0.86 + Math.cos(a / 24 * Math.PI) * rad, 0.02, sz * 0.86 + Math.sin(a / 24 * Math.PI) * rad], t); - if (!p) { started = false; continue; } - if (!started) { ctx.moveTo(p.x, p.y); started = true; } else ctx.lineTo(p.x, p.y); - } + // the virtual acoustic space — wireframe globe above the room + const g = globeState(t); + const gr = g.r * g.scale; + if (gr > 0.03) { + circle3d(0, g.cy, 0, gr, "y", t, ink(0.30), 1.5); + circle3d(0, g.cy + gr * 0.55, 0, gr * 0.82, "y", t, ink(0.16), 1); + circle3d(0, g.cy - gr * 0.55, 0, gr * 0.82, "y", t, ink(0.16), 1); + circle3d(0, g.cy, 0, gr, "x", t, ink(0.10), 1); + circle3d(0, g.cy, 0, gr, "z", t, ink(0.10), 1); + } + + // sight-lines: voice → its machine (the choreography made visible) + for (let i = 0; i < S.lanes.length; i++) { + const vp = project(voicePos(i, t), t); + const seat = laneSeat(i, t); + const sp = project(seatPos(seat).map((v, k) => k === 1 ? 0.5 : v * 0.98), t); + if (!vp || !sp) continue; + ctx.strokeStyle = rgba(S.lanes[i].color, 0.05 + glow[i] * 0.4); + ctx.lineWidth = 1 + glow[i] * 1.5; + ctx.beginPath(); + ctx.moveTo(vp.x, vp.y); + ctx.quadraticCurveTo((vp.x + sp.x) / 2, Math.min(vp.y, sp.y) - 30, sp.x, sp.y); + ctx.stroke(); + } + + // depth-sorted: thinkpads + rigs + pal + voices + const items = []; + for (let s = 0; s < SEATS; s++) { + const pos = seatPos(s); + const p2 = project(pos, t); + if (!p2) continue; + let best = null, bl = 0.05; + for (let i = 0; i < S.lanes.length; i++) + if (laneSeat(i, t) === s && glow[i] > bl) { best = { c: S.lanes[i].color, a: glow[i] }; bl = glow[i]; } + items.push({ d: p2.d, draw: () => thinkpadSprite(p2, s, t, best) }); + const ra = seatAngle(s) + 0.42; + const rp = project([Math.cos(ra) * RING * 1.12, 0, Math.sin(ra) * RING * 1.12], t); + if (rp) items.push({ d: rp.d, draw: () => rigSprite(rp, null, best) }); + } + const lp = project([0, 0, 0], t); + if (lp) items.push({ d: lp.d, draw: () => palSprite(lp, t) }); + for (let i = 0; i < S.lanes.length; i++) { + const p3 = voicePos(i, t); + const p2 = project(p3, t); + if (!p2 || gr <= 0.03) continue; + const c = S.lanes[i].color; + const r = Math.max(2, (4.5 + glow[i] * 8) * p2.s / 140); + items.push({ d: p2.d, draw: () => { + ctx.fillStyle = rgba(c, 0.45 + glow[i] * 0.55); + ctx.beginPath(); ctx.arc(p2.x, p2.y, r, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = ink(0.35); + ctx.lineWidth = 1; ctx.stroke(); - } + if (glow[i] > 0.3) { + ctx.strokeStyle = rgba(c, glow[i] * 0.45); + ctx.beginPath(); ctx.arc(p2.x, p2.y, r * 1.8, 0, Math.PI * 2); ctx.stroke(); + } + }}); + } + items.sort((a, b) => b.d - a.d); + for (const it of items) it.draw(); - // depth-sorted sprites: laptops, listener, orbs - const items = []; - for (let s = 0; s < SEATS; s++) { - const pos = seatPos(s); - const p2 = project(pos, t); - if (!p2) continue; - // screen color = strongest active owned lane - let best = null, bl = 0.04; - for (let i = 0; i < S.lanes.length; i++) - if (laneSeat(i, t) === s && glow[i] > bl) { best = S.lanes[i].color; bl = glow[i]; } - const awake = best !== null || t < S.dur; - items.push({ d: p2.d, draw: () => laptopSprite(p2, best ? rgba(best, Math.min(1, 0.25 + bl)) : "#15161b", awake, pos[0] > 0) }); - } - const lp = project([0, 0, 0], t); - if (lp) items.push({ d: lp.d, draw: () => listenerSprite(lp, t) }); - for (let i = 0; i < S.lanes.length; i++) { - const p2 = project(orbAt[i], t); - if (!p2) continue; - const c = S.lanes[i].color; - const r = Math.max(2.5, (5 + glow[i] * 9) * p2.s / 130); - items.push({ d: p2.d, draw: () => { - ctx.fillStyle = rgba(c, 0.25 + glow[i] * 0.75); - ctx.beginPath(); ctx.arc(p2.x, p2.y, r, 0, Math.PI * 2); ctx.fill(); - if (glow[i] > 0.3) { - ctx.strokeStyle = rgba(c, glow[i] * 0.5); - ctx.beginPath(); ctx.arc(p2.x, p2.y, r * 1.9, 0, Math.PI * 2); ctx.stroke(); - } - }}); - } - items.sort((a, b) => b.d - a.d); - for (const it of items) it.draw(); + // ── chrome ── + if (titleImg) ctx.drawImage(titleImg, 60, 44, titleImg.width * 0.5, titleImg.height * 0.5); + const mv = M.find(m => t >= m.t0 && t < m.t1); + ctx.fillStyle = ink(0.85); + ctx.font = MONO(22, true); + if (mv) ctx.fillText(mv.name + " — " + mv.sub + " · " + + ["enter", "antiphony", "orbit", "scatter", "converge", "rest"][M.indexOf(mv)], 62, 150); + ctx.fillStyle = ink(0.45); + ctx.font = MONO(16); + ctx.textAlign = "right"; + ctx.fillText("one virtual acoustic space · six windows · the listener in the room", W - 60, 70); + ctx.fillText("that mediates it, via the actual computers", W - 60, 92); + ctx.fillText("special sign · 1:41 source → 8–12 min live via held movements", W - 60, 122); + ctx.textAlign = "left"; - // ---- HUD ---- - const mv = M.find(m => t >= m.t0 && t < m.t1); - ctx.fillStyle = "#e8e6df"; - ctx.font = "bold 44px Helvetica"; - ctx.fillText(mv ? mv.name : "·", 60, 92); - ctx.fillStyle = "#9a97a3"; - ctx.font = "26px Helvetica"; - if (mv) ctx.fillText(mv.sub + " · " + ["enter", "antiphony", "orbit", "scatter", "converge", "rest"][M.indexOf(mv)], 60, 130); - ctx.textAlign = "right"; - ctx.fillStyle = "#787580"; - ctx.font = "24px Helvetica"; - ctx.fillText("Note(s)pat(ial) Native — Special Sign, live spatial plan", W - 60, 84); - ctx.fillText("six machines · one listener · 8–12 min via held movements", W - 60, 116); - ctx.textAlign = "left"; + drawStrip(t); +} - // timeline with doors + playhead + rotation ribbon - const tx = 60, tw = W - 120, ty = H - 70; - ctx.fillStyle = "#1c1e26"; - ctx.fillRect(tx, ty, tw, 10); - if (S.rotation) { - ctx.strokeStyle = "#3E7C8A"; - ctx.lineWidth = 2; - ctx.beginPath(); - for (let i = 0; i < S.rotation.length; i++) { - const x = tx + (i / (S.rotation.length - 1)) * tw; - const y = ty - 6 - S.rotation[i] * 26; - i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); - } - ctx.stroke(); - } - for (const m of M) { - const x = tx + (m.t0 / S.dur) * tw; - ctx.fillStyle = "#4a4d59"; - ctx.fillRect(x, ty - 4, 2, 18); - ctx.fillStyle = "#6c6975"; - ctx.font = "20px Helvetica"; - ctx.fillText(m.name.split(" ")[0], x + 6, ty + 34); - } - ctx.fillStyle = "#e8e6df"; - ctx.fillRect(tx + Math.min(1, t / S.dur) * tw - 1.5, ty - 8, 3, 26); +async function main() { + titleImg = await magickRenderText("Note(s)pat(ial) Native", { + ptSize: 88, fill: INK, font: YWFT_PATH, outPath: "/tmp/notespatial-title.png", + }); + + if (still != null) { + // settle spin + glows by simulating up to the still time + for (let t = 0; t <= still; t += 1 / FPS) drawFrame(t); + writeFileSync(outPath, canvas.toBuffer("image/png")); + console.log("wrote " + outPath); + return; + } + const ff = spawn("ffmpeg", [ + "-y", "-f", "rawvideo", "-pix_fmt", "bgra", "-s", `${W}x${H}`, "-r", String(FPS), + "-i", "-", "-i", audioPath, + "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", "medium", "-crf", "19", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-shortest", outPath, + ], { stdio: ["pipe", "ignore", "inherit"] }); + const write = (buf) => new Promise(res => ff.stdin.write(buf) ? res() : ff.stdin.once("drain", res)); + + console.log(`rendering ${FRAMES} frames…`); + for (let f = 0; f < FRAMES; f++) { + drawFrame(f / FPS); await write(canvas.toBuffer("raw")); - if (f % 300 === 0) console.log(` ${f}/${FRAMES} (${(t).toFixed(0)}s)`); + if (f % 300 === 0) console.log(` ${f}/${FRAMES}`); } ff.stdin.end(); await new Promise(res => ff.on("close", res)); -- 2.51.2