Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500#!/usr/bin/env node// notespatial-native-render — hear a ring .nsscore from the middle of the// room, and watch the notation fly in.//// Sound: feeds use the spatial-rehearsal routing (voicePosition + sourceGain),// native linear envelopes and the actual gm_synth.c core for GM programs.// Other native instruments use harmonic sketches. A mono model of audio.c's// effects processes each seat before each feed is placed at// its seat around a listener at the center: seat 1 ahead, numbers// clockwise, a held laptop at the center if the score has one. Placement// is measured KEMAR HRTF (tools/hrir, via ffmpeg afir) or, with --fast, a// parametric head computed here.//// Picture: a radial timeline seen from above. Every note starts far// outside the ring and flies inward along the radial of the laptop that// will sound it, as a bar whose length is its duration, arriving on the// machine at the moment it sounds and flowing into it; the held laptop's// stream comes up from the bottom gap. Melodic notes carry their name// and frequency. Each laptop box shows a miniature of its own screen:// its notes approaching the front. Tempo, note rate and voice count read// at the right.//// node notespatial-native-render.mjs [score.nsscore] [--out x.mp4]// [--from 7:20] [--to 8:00] [--section 5] [--fps 30] [--size 960x540]// [--audio-only] [--fast] [--light | --dark] (theme follows macOS unless given)// [--plan] top-down instead of the isometric view// [--solo 3] hear one laptop alone (C is 6)// [--sub] add the Windows SUB feed and cabinet (front by default)// [--sub-az 0] [--sub-level .25] [--sub-cutoff 80] [--solo sub]// [--frame-out review.ppm --frame-at 420] save one rendered frame for QA// [--wav-out mix.wav] keep the video render's full-quality audio too// [--float] write 32-bit float WAVs instead of 16-bit (a mastering source)
import { readFileSync, writeFileSync, mkdtempSync, existsSync, copyFileSync } from 'node:fs';import { spawn, spawnSync } from 'node:child_process';import { tmpdir } from 'node:os';import { join, dirname, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { voicePosition, sourceGain, ringSeats, noteColor, eventGain } from '../lib/spatial-rehearsal.mjs';import { renderSubFeed } from './notespatial-room-audio.mjs';import { renderGmBank } from './notespatial-gm-audio.mjs';import { renderSeatEffects } from './notespatial-fx-audio.mjs';import { GM_NAMES } from './notespatial-orchestra.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));const args = process.argv.slice(2);const FLAGS = ['audio-only', 'fast', 'light', 'dark', 'plan', 'iso', 'sub', 'float'];const flag = k => args.includes('--' + k);const opt = (k, d) => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : d; };const positional = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--') && !FLAGS.includes(args[i - 1].slice(2))));const scorePath = positional[0] || join(HERE, '../scores/notespatial-native.nsscore');const score = JSON.parse(readFileSync(scorePath, 'utf8'));const SEATS = score.seats || 6, CENTER = Number.isInteger(score.center) ? score.center : -1, RING = ringSeats(score, SEATS);const SUB = flag('sub'), OUTPUTS = SEATS + (SUB ? 1 : 0);const SUB_AZ = +opt('sub-az', 0), SUB_LEVEL = +opt('sub-level', .25), SUB_CUTOFF = +opt('sub-cutoff', 80);const SOLO = opt('solo') === 'sub' ? SEATS : opt('solo') !== undefined ? +opt('solo') - 1 : -1;if (!Number.isFinite(SUB_AZ) || !Number.isInteger(SOLO) || SOLO < -1 || SOLO >= OUTPUTS) throw Error('Invalid output or SUB position');const ringIndex = k => k > CENTER && CENTER >= 0 ? k - 1 : k;const mmss = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;const parseT = s => s === undefined ? undefined : s.includes(':') ? s.split(':').reduce((a, b) => a * 60 + +b, 0) : +s;
let from = parseT(opt('from')) ?? 0, to = parseT(opt('to')) ?? score.dur;if (opt('section')) { const m = score.movements?.[+opt('section') - 1]; if (!m) throw Error('no such section'); from = m.t0; to = Math.min(score.dur, m.t1 + 1.5);}const FPS = +opt('fps', 30), [W, H] = opt('size', '960x540').split('x').map(Number);const suffix = opt('section') ? '-' + opt('section') : (from || to !== score.dur) ? `-${mmss(from).replace(':', 'm')}-${mmss(to).replace(':', 'm')}` : '';const outPath = resolve(opt('out', join(HERE, '../../../grants/culturehub-la-2026/notespatial-native-sim' + suffix + (flag('audio-only') ? '.wav' : '.mp4'))));const work = mkdtempSync(join(tmpdir(), 'notespatial-render-'));const tick = (() => { let t = performance.now(); return label => { const n = performance.now(); console.log(`${label.padEnd(30)} ${((n - t) / 1000).toFixed(1)} s`); t = n; }; })();
// ── 1 · the feeds ────────────────────────────────────────────────────const SR = 44100, TAIL = 1.5;const span = to - from, N = Math.ceil((span + TAIL) * SR);const feeds = Array.from({ length: SEATS }, () => new Float32Array(N));let seed = 20260924;const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296 * 2 - 1;const BLOCK = 256;let voiced = 0;const gainsAt = (li, t) => { const p = voicePosition(score, li, t); return Array.from({ length: SEATS }, (_, k) => sourceGain(score, p, k, SEATS)); };const gmBank = await renderGmBank(score.lanes.flatMap(l => l.events).filter(e => Number.isInteger(e.gm) && e.t + e.dur > from && e.t < to), { work, sampleRate: SR });score.lanes.forEach((lane, li) => { const still = lane.center || (Number.isFinite(lane.az) && !score.fieldShift); const fixed = still ? gainsAt(li, 0) : null; for (const e of lane.events) { if (e.t + e.dur <= from || e.t >= to) continue; voiced++; const g = eventGain(score, e); const s0 = Math.round((e.t - from) * SR), len = Math.round(e.dur * SR); const attack = (e.attack ?? .01) * SR, decay = (e.decay ?? .06) * SR, decayStart = Math.max(0, len - decay); const inc = (e.hz || 220) / SR, wave = e.wave; const gmPcm = gmBank.read(e); let phase = 0; for (let b = 0; b < len; b += BLOCK) { const bl = Math.min(BLOCK, len - b), gk = fixed || gainsAt(li, e.t + b / SR); const buf = new Float32Array(bl); for (let i = 0; i < bl; i++) { const n = b + i; let env = attack > 0 && n < attack ? n / attack : 1; if (decay > 0 && n > decayStart) env *= Math.max(0, 1 - (n - decayStart) / decay); if (gmPcm) { buf[i] = gmPcm[n] * g; continue; } // core already applies the envelope let v; if (wave === 'noise') v = rnd(); else { const f = phase - Math.floor(phase); const P = phase * Math.PI * 2; // Native instrument sketches; modal mallets use the exact score partials. v = wave === 'triangle' ? 2 * Math.abs(2 * f - 1) - 1 : wave === 'sawtooth' ? 2 * f - 1 : wave === 'square' ? (f < .5 ? 1 : -1) : wave === 'whistle' ? (Math.sin(P) + .22 * Math.sin(2 * P)) / 1.2 + .03 * rnd() : wave === 'harp' ? (Math.sin(P) + .5 * Math.sin(2 * P) + .3 * Math.sin(3 * P) + .15 * Math.sin(4 * P)) / 1.6 : wave === 'piano' ? (Math.sin(P) + .6 * Math.sin(2 * P) + .35 * Math.sin(3 * P) + .2 * Math.sin(4 * P) + .1 * Math.sin(5 * P)) / 1.8 : Math.sin(P); phase += inc; } buf[i] = v * env * g; } for (let k = 0; k < SEATS; k++) { const gain = gk[k]; if (gain <= 0) continue; const F = feeds[k]; for (let i = 0, n = s0 + b; i < bl; i++, n++) if (n >= 0 && n < N) F[n] += buf[i] * gain; } } }});gmBank.close();for (let k = 0; k < SEATS; k++) { renderSeatEffects(feeds[k], score, k, { sampleRate: SR, from }); if (score.fxRoom || score.seatFx) console.log(`seat ${k + 1} effects rendered`);}const subModel = SUB ? renderSubFeed(score, { sampleRate: SR, from, to, tail: TAIL, level: SUB_LEVEL, cutoff: SUB_CUTOFF }) : null;if (SUB) feeds.push(subModel.feed);let peak = 0;for (const F of feeds) for (let i = 0; i < N; i += 7) peak = Math.max(peak, Math.abs(F[i]));const norm = peak > 0 ? .5 / peak : 1;for (const F of feeds) for (let i = 0; i < N; i++) F[i] *= norm;if (SOLO >= 0) for (let k = 0; k < OUTPUTS; k++) if (k !== SOLO) feeds[k].fill(0);{ // what each laptop is asked to play, before any head: RMS of its feed const row = feeds.map((F, k) => { let a = 0; for (let i = 0; i < N; i += 3) a += F[i] * F[i]; const rms = Math.sqrt(a / (N / 3)); return `${k === SEATS ? 'SUB' : k === CENTER ? 'C' : k + 1}:${(20 * Math.log10(rms + 1e-9)).toFixed(1)}`; }); console.log('feed RMS dBFS per laptop ' + row.join(' '));}tick(`feeds: ${voiced} events, ${mmss(span)}${SOLO >= 0 ? `, laptop ${SOLO === CENTER ? 'C' : SOLO + 1} alone` : ''}`);
// ── 2 · the head ─────────────────────────────────────────────────────// Ring seats sit at their azimuth; a held center laptop is a small speaker// just ahead of the listener, so it comes from straight in front, close.const seatAzDeg = k => k === SEATS ? SUB_AZ : k === CENTER ? 0 : ringIndex(k) / RING * 360;function writeStereoWav(path, L, R, n) { let pk = 0; for (let i = 0; i < n; i++) pk = Math.max(pk, Math.abs(L[i]), Math.abs(R[i])); const g = pk > 0 ? .95 / pk : 1; if (flag('float')) { // 32-bit float print for mastering (--float); same 0.95 peak const pcm = Buffer.alloc(44 + n * 8); pcm.write('RIFF', 0); pcm.writeUInt32LE(36 + n * 8, 4); pcm.write('WAVE', 8); pcm.write('fmt ', 12); pcm.writeUInt32LE(16, 16); pcm.writeUInt16LE(3, 20); pcm.writeUInt16LE(2, 22); pcm.writeUInt32LE(SR, 24); pcm.writeUInt32LE(SR * 8, 28); pcm.writeUInt16LE(8, 32); pcm.writeUInt16LE(32, 34); pcm.write('data', 36); pcm.writeUInt32LE(n * 8, 40); for (let i = 0; i < n; i++) { pcm.writeFloatLE(L[i] * g, 44 + i * 8); pcm.writeFloatLE(R[i] * g, 48 + i * 8); } writeFileSync(path, pcm); return; } const pcm = Buffer.alloc(44 + n * 4); pcm.write('RIFF', 0); pcm.writeUInt32LE(36 + n * 4, 4); pcm.write('WAVE', 8); pcm.write('fmt ', 12); pcm.writeUInt32LE(16, 16); pcm.writeUInt16LE(1, 20); pcm.writeUInt16LE(2, 22); pcm.writeUInt32LE(SR, 24); pcm.writeUInt32LE(SR * 4, 28); pcm.writeUInt16LE(4, 32); pcm.writeUInt16LE(16, 34); pcm.write('data', 36); pcm.writeUInt32LE(n * 4, 40); for (let i = 0; i < n; i++) { pcm.writeInt16LE(Math.round(L[i] * g * 32767), 44 + i * 4); pcm.writeInt16LE(Math.round(R[i] * g * 32767), 46 + i * 4); } writeFileSync(path, pcm);}const binPath = join(HERE, 'hrir/kemar-compact.bin'), idxPath = join(HERE, 'hrir/kemar-compact.json');const useHrtf = !flag('fast') && existsSync(binPath) && existsSync(idxPath);const binaural = join(work, 'binaural.wav');if (useHrtf) { const idx = JSON.parse(readFileSync(idxPath, 'utf8')), raw = readFileSync(binPath), taps = idx.taps; const el0 = idx.elevations.find(e => e.el === 0); const ir = azDeg => { let az = ((azDeg % 360) + 360) % 360, swap = false; if (az > 180) { az = 360 - az; swap = true; } const a = el0.azimuths.reduce((b, c) => Math.abs(c.az - az) < Math.abs(b.az - az) ? c : b); const L = new Float32Array(taps), R = new Float32Array(taps); for (let i = 0; i < taps; i++) { L[i] = raw.readInt16LE(a.off + i * 2) / 32768; R[i] = raw.readInt16LE(a.off + taps * 2 + i * 2) / 32768; } // The measured set carries the head's own shadow: a rear source reaches // the ears with about 4 dB less energy than a side one. In the room every // laptop is fed equally, so equalize each seat's total energy here and // keep only the direction cues (delay, spectrum, left-right balance). let e = 0; for (let i = 0; i < taps; i++) e += L[i] * L[i] + R[i] * R[i]; const gEq = Math.sqrt(1.84 / e); // 1.84 is the front (az 0) pair's energy for (let i = 0; i < taps; i++) { L[i] *= gEq; R[i] *= gEq; } return swap ? [R, L] : [L, R]; }; const inputs = [], graph = []; for (let k = 0; k < OUTPUTS; k++) { writeFileSync(join(work, `seat${k}.f32`), Buffer.from(feeds[k].buffer)); const [L, R] = ir(seatAzDeg(k)); writeFileSync(join(work, `ir${k}L.f32`), Buffer.from(L.buffer)); writeFileSync(join(work, `ir${k}R.f32`), Buffer.from(R.buffer)); inputs.push('-f', 'f32le', '-ar', String(SR), '-ac', '1', '-i', join(work, `seat${k}.f32`)); } for (let k = 0; k < OUTPUTS; k++) for (const ear of 'LR') inputs.push('-f', 'f32le', '-ar', String(SR), '-ac', '1', '-i', join(work, `ir${k}${ear}.f32`)); for (let k = 0; k < OUTPUTS; k++) { const w = k === CENTER ? 1.1 : 1; // the small speaker is nearer than the ring graph.push(`[${k}:a]asplit[s${k}a][s${k}b]`); graph.push(`[s${k}a][${OUTPUTS + k * 2}:a]afir=gtype=none:dry=1:wet=${w}[l${k}]`); graph.push(`[s${k}b][${OUTPUTS + k * 2 + 1}:a]afir=gtype=none:dry=1:wet=${w}[r${k}]`); } graph.push(Array.from({ length: OUTPUTS }, (_, k) => `[l${k}]`).join('') + `amix=inputs=${OUTPUTS}:normalize=0[L]`); graph.push(Array.from({ length: OUTPUTS }, (_, k) => `[r${k}]`).join('') + `amix=inputs=${OUTPUTS}:normalize=0[R]`); graph.push('[L][R]join=inputs=2:channel_layout=stereo[out]'); const mixed = join(work, 'binaural.f32'); const r = spawnSync('ffmpeg', ['-y', '-v', 'error', ...inputs, '-filter_complex', graph.join(';'), '-map', '[out]', '-f', 'f32le', '-c:a', 'pcm_f32le', mixed], { stdio: 'inherit' }); if (r.status !== 0) throw Error('ffmpeg binaural mix failed'); const st = new Float32Array(readFileSync(mixed).buffer), n = st.length >> 1; const L = new Float32Array(n), R = new Float32Array(n); for (let i = 0; i < n; i++) { L[i] = st[i * 2]; R[i] = st[i * 2 + 1]; } writeStereoWav(binaural, L, R, n); tick('head: measured KEMAR HRTF');} else { const L = new Float32Array(N), R = new Float32Array(N); for (let k = 0; k < OUTPUTS; k++) { const az = seatAzDeg(k) * Math.PI / 180, s = Math.sin(az), c = Math.cos(az); const itd = Math.round(Math.abs(s) * .00066 * SR); const farGain = 10 ** (-(4 * Math.abs(s)) / 20), rear = c < 0 ? .78 : 1, near = k === CENTER ? 1.1 : 1; const cutoff = 1 - Math.exp(-2 * Math.PI * (7000 - 4500 * Math.abs(s)) / SR); const [nearEar, farEar] = s >= 0 ? [R, L] : [L, R]; let lp = 0; const F = feeds[k]; for (let i = 0; i < N; i++) { nearEar[i] += F[i] * rear * near; lp += cutoff * (F[i] - lp); const j = i + itd; if (j < N) farEar[j] += lp * farGain * rear * near; } } writeStereoWav(binaural, L, R, N); tick('head: parametric');}if (opt('wav-out')) copyFileSync(binaural, resolve(opt('wav-out')));if (flag('audio-only')) { copyFileSync(binaural, outPath); console.log(outPath); process.exit(0); }
// ── 3 · the picture ──────────────────────────────────────────────────const fontSrc = readFileSync(join(HERE, '../src/font-6x10.h'), 'utf8');const GLYPHS = [...fontSrc.matchAll(/\{((?:0x[0-9A-F]{2},?\s*){10})\}/g)].map(m => m[1].match(/0x[0-9A-F]{2}/g).map(h => parseInt(h, 16)));const frame = Buffer.alloc(W * H * 3);const px = (x, y, r, g, b) => { if (x < 0 || y < 0 || x >= W || y >= H) return; const o = (y * W + x) * 3; frame[o] = r; frame[o + 1] = g; frame[o + 2] = b; };const blend = (x, y, c, a) => { if (x < 0 || y < 0 || x >= W || y >= H) return; const o = ((y | 0) * W + (x | 0)) * 3; frame[o] += (c[0] - frame[o]) * a; frame[o + 1] += (c[1] - frame[o + 1]) * a; frame[o + 2] += (c[2] - frame[o + 2]) * a; };const rect = (x, y, w, h, c) => { for (let j = y | 0; j < (y + h) | 0; j++) for (let i = x | 0; i < (x + w) | 0; i++) px(i, j, ...c); };const disc = (cx, cy, r, c, a = 1) => { const R2 = r * r; for (let j = -r; j <= r; j++) for (let i = -r; i <= r; i++) if (i * i + j * j <= R2) blend(cx + i, cy + j, c, a); };const ring = (cx, cy, r, c, a = 1) => { const n = Math.max(24, Math.ceil(r * 5)); for (let i = 0; i < n; i++) { const t = i / n * Math.PI * 2; blend(cx + Math.cos(t) * r, cy + Math.sin(t) * r, c, a); } };const line = (x0, y0, x1, y1, c, a = 1) => { const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0))); for (let i = 0; i <= n; i++) blend(x0 + (x1 - x0) * i / n, y0 + (y1 - y0) * i / n, c, a); };const text = (s, x, y, c, scale = 1) => { for (let ci = 0; ci < s.length; ci++) { const g = GLYPHS[s.charCodeAt(ci) - 32]; if (g) for (let r = 0; r < 10; r++) for (let b = 0; b < 6; b++) if (g[r] & (0x80 >> b)) rect(x + (ci * 6 + b) * scale, y + r * scale, scale, scale, c); }};const ascii = s => s.replace(/·/g, '-').replace(/—/g, '-').replace(/[^\x20-\x7e]/g, '');const mix = (a, b, u) => a.map((v, i) => Math.round(v + (b[i] - v) * u));const NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];const noteOf = hz => { const m = Math.round(69 + 12 * Math.log2(hz / 440)); return NAMES[m % 12] + (Math.floor(m / 12) - 1); };const MELODIC = /voice|walk|theme|answer|top/;
// geometry: ring centered a little left, panel at the right// Isometric by default: the floor is an ellipse seen from a raised seat// behind the audience, streams run along the floor to each laptop, and the// held laptop's stream drops straight down from above into the hands. The// plan is turned half a step so the vertical axis falls in a gap.const ISO = !flag('plan');const cx = Math.round(W * .38), cy = Math.round(H * (ISO ? .6 : .52)), R = Math.round(H * (ISO ? .3 : .24)), R_OUT = Math.round(H * (ISO ? .62 : .5)) + 8, LOOK = 3.2;const SQ = ISO ? .5 : 1, ROT = ISO ? Math.PI / RING : 0;const floor = (a, r, h = 0) => [cx + Math.sin(a + ROT) * r, cy - Math.cos(a + ROT) * r * SQ - h];const seatAngle = k => ringIndex(k) / RING * Math.PI * 2;const CENTER_STREAM = Math.PI; // in plan view the held laptop's stream comes up from the bottom gapconst CENTER_DOCK = ISO ? 16 : 18, CENTER_TOP = ISO ? cy - 28 : null;// a point `r` along a stream: ring streams lie on the floor, the center stream stands up// the performer wanders a little with the held laptop; the center stream follows the handslet hx = cx, hy = cy;const wander = t => { hx = cx + (ISO ? 26 : 22) * Math.sin(t * .13) + 9 * Math.sin(t * .31 + 2); hy = cy + (ISO ? 9 : 12) * Math.sin(t * .17 + 1) + 4 * Math.sin(t * .41); };const streamPoint = (pos, r) => pos.center ? (ISO ? [hx, hy - 14 - (r - CENTER_DOCK) * .78] : floor(CENTER_STREAM, r).map((v, i) => v + (i ? hy - cy : hx - cx))) : floor(pos.angle, r);function figure(x, y, ink) { // a stick figure holding the laptop at chest height const headY = y - 34, hipY = y - 6, footY = y + 12; ring(x, headY, 4, ink); disc(x, headY, 3, ink, .35); bar(x, headY + 4, x, hipY, 1.5, ink, 1); bar(x, hipY, x - 6, footY, 1.5, ink, 1); bar(x, hipY, x + 6, footY, 1.5, ink, 1); bar(x, headY + 10, x - 14, headY + 20, 1.5, ink, 1); bar(x, headY + 10, x + 14, headY + 20, 1.5, ink, 1); return [x, headY + 20]; // where the hands are}// theme: light or dark; follows the system appearance unless toldconst systemDark = (() => { try { return spawnSync('defaults', ['read', '-g', 'AppleInterfaceStyle'], { encoding: 'utf8' }).stdout.trim() === 'Dark'; } catch { return true; } })();const LIGHT = flag('light') || (!flag('dark') && !systemDark);const T = LIGHT ? { bg: [247, 245, 239], ink: [28, 30, 38], dim: [118, 122, 136], accent: [176, 108, 0], ringLine: [150, 154, 168], box: [226, 223, 214], boxTint: .22, seg: [[214, 211, 202], [200, 197, 188]], play: [220, 60, 60], streamTint: .18, shade: .82 } : { bg: [9, 9, 13], ink: [235, 238, 245], dim: [120, 130, 150], accent: [255, 222, 110], ringLine: [70, 76, 96], box: [30, 34, 46], boxTint: .18, seg: [[62, 70, 90], [48, 54, 70]], play: [255, 90, 90], streamTint: .22, shade: 1 };const tone = c => c.map(v => Math.round(v * T.shade)); // seat colors sit a little darker on paperconst frames = Math.ceil((span + TAIL) * FPS), perFrame = SR / FPS, dt = 1 / FPS;const all = score.lanes.flatMap((l, i) => l.events.map(e => ({ ...e, lane: i }))).sort((a, b) => a.t - b.t);let lo = 0; // sliding window over `all`const tempoAt = t => { let b = null; for (const x of score.tempo || []) { if (x.t <= t) b = x.bpm; else break; } return b; };// anti-aliased thick segmentfunction bar(x0, y0, x1, y1, w, c, a) { const minx = Math.floor(Math.min(x0, x1) - w), maxx = Math.ceil(Math.max(x0, x1) + w), miny = Math.floor(Math.min(y0, y1) - w), maxy = Math.ceil(Math.max(y0, y1) + w); const dx = x1 - x0, dy = y1 - y0, L2 = dx * dx + dy * dy || 1e-6, hw = w / 2; for (let y = Math.max(0, miny); y <= Math.min(H - 1, maxy); y++) for (let x = Math.max(0, minx); x <= Math.min(W - 1, maxx); x++) { const u = Math.max(0, Math.min(1, ((x + .5 - x0) * dx + (y + .5 - y0) * dy) / L2)); const d = Math.hypot(x + .5 - (x0 + dx * u), y + .5 - (y0 + dy * u)); const cov = Math.max(0, Math.min(1, hw + .5 - d)); if (cov > 0) blend(x, y, c, a * cov); }}const light = new Array(SEATS).fill(0), flash = new Array(SEATS).fill(0), landed = new Set();const kickEvents = score.lanes.find(l => l.name === 'kick')?.events || [];let subLight = 0;const SEAT_COLORS = score.seatColors || Array.from({ length: SEATS }, (_, k) => k === CENTER ? [255, 240, 200] : [[255, 110, 110], [255, 180, 70], [120, 220, 130], [95, 170, 255], [200, 130, 255], [110, 220, 230]][ringIndex(k) % 6]);// a note wears the color of where it lands; between two seats it blends by routed powerconst destColor = (li, t) => { const gk = gainsAt(li, t); let c = [0, 0, 0], sum = 0; for (let k = 0; k < SEATS; k++) { const w = gk[k] * gk[k]; if (w > 0) { sum += w; c = c.map((v, i) => v + SEAT_COLORS[k][i] * w); } } return sum > 0 ? c.map(v => Math.round(v / sum)) : [200, 200, 200]; };const ff = spawn('ffmpeg', ['-y', '-v', 'error', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-s', `${W}x${H}`, '-r', String(FPS), '-i', 'pipe:0', '-i', binaural, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '21', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-shortest', outPath], { stdio: ['pipe', 'inherit', 'inherit'] });const done = new Promise((resolve, reject) => { ff.on('error', reject); ff.on('close', code => code === 0 ? resolve() : reject(Error(`ffmpeg video failed (${code})`)));});done.catch(() => {}); // observed again after the frame stream closesconst write = buf => new Promise((resolve, reject) => ff.stdin.write(buf, error => error ? reject(error) : resolve()));ff.stdin.on('error', () => {}); // write callbacks carry stream failures
for (let f = 0; f < frames; f++) { const t = from + f / FPS; wander(t); for (let i = 0; i < W * H; i++) { const o = i * 3; frame[o] = T.bg[0]; frame[o + 1] = T.bg[1]; frame[o + 2] = T.bg[2]; } // faint streams and the two circles for (let k = 0; k < SEATS; k++) { const pos = k === CENTER ? { center: true } : { angle: seatAngle(k) }, r0 = k === CENTER ? CENTER_DOCK : R + 18; const [x0, y0] = streamPoint(pos, r0), [x1, y1] = streamPoint(pos, R_OUT); bar(x0, y0, x1, y1, 1, mix(T.bg, SEAT_COLORS[k], T.streamTint), 1); } { const n = 160; for (let i = 0; i < n; i++) { const [x0, y0] = floor(i / n * Math.PI * 2, R), [x1, y1] = floor((i + 1) / n * Math.PI * 2, R); bar(x0, y0, x1, y1, 1, T.ringLine, .9); } } // seat levels from the feeds, smoothed: fast up, slow down const s0 = Math.floor(f * perFrame), s1 = Math.min(N, Math.floor((f + 1) * perFrame)); for (let k = 0; k < SEATS; k++) { let acc = 0; const F = feeds[k]; for (let i = s0; i < s1; i++) acc += F[i] * F[i]; const inst = Math.min(1, Math.sqrt(Math.sqrt(acc / Math.max(1, s1 - s0)) / norm) * 2.6); light[k] = inst > light[k] ? inst : light[k] + (inst - light[k]) * (1 - Math.exp(-dt / .22)); flash[k] *= Math.exp(-dt / .18); } // the notes in flight while (lo < all.length && all[lo].t + all[lo].dur + .5 < t) { landed.delete(all[lo]); lo++; } let sounding = 0, recent = 0; const labels = [], labelCount = new Map(), labelGroups = new Map(); const speed = (R_OUT - R - 6) / LOOK; // px per second along a stream for (let i = lo; i < all.length && all[i].t <= t + LOOK; i++) { const e = all[i], lane = score.lanes[e.lane]; const until = e.t - t, end = until + e.dur; if (until <= 0 && end > 0) sounding++; if (until <= 0 && until > -2) recent++; if (end <= 0) continue; if (until <= 0 && !landed.has(e)) { landed.add(e); const gk = gainsAt(e.lane, e.t); for (let k = 0; k < SEATS; k++) if (gk[k] * gk[k] >= .5) flash[k] = Math.min(1, flash[k] + Math.sqrt(e.g) * 1.6); } const pos = voicePosition(score, e.lane, e.t), color = tone(noteColor(e.note) || destColor(e.lane, e.t)); const a = pos.center ? CENTER_STREAM : pos.angle, dock = pos.center ? CENTER_DOCK : R + 6; const head = dock + Math.max(0, until) * speed, tail = Math.min(R_OUT + 20, dock + end * speed); if (tail <= head + .5) continue; const [x0, y0] = streamPoint(pos, head), [x1, y1] = streamPoint(pos, tail); const w = Math.max(1.5, Math.sqrt(e.g) * 5.5); const born = Math.min(1, (LOOK - until) / .45); // fade in as it enters the field const alpha = (until > 0 ? .3 + .6 * (1 - until / LOOK) : .95) * born; bar(x0, y0, x1, y1, w, color, alpha); if (until <= 0) disc(x0 | 0, y0 | 0, Math.round(w * .9 + 1.5), color, .8); if (e.note && (pos.center || MELODIC.test(lane.name)) && until > 0 && until < LOOK * .9) { // every incoming melodic note is labeled, so a player can read ahead; // notes landing together on one stream share one label (a chord), and // successive labels on a stream alternate sides so they do not pile up const key = `${Math.round(a * 100)}:${Math.round(until * 16)}`; let L = labelGroups.get(key); if (!L) { const streamKey = Math.round(a * 100), n = (labelCount.get(streamKey) || 0); labelCount.set(streamKey, n + 1); const [lx, ly] = streamPoint(pos, head); const outward = pos.center ? [1, 0] : [Math.sin(a + ROT), -Math.cos(a + ROT) * SQ]; const perp = [-outward[1], outward[0]], side = n % 2 ? 1 : -1, off = w + 7; L = { x: lx + perp[0] * off * side, y: ly + perp[1] * off * side - 5, side, notes: [], hz: e.hz, color, alpha: Math.min(1, (LOOK * .9 - until) / .35) * (.55 + .45 * (1 - until / LOOK)) }; labelGroups.set(key, L); labels.push(L); } if (!L.notes.includes(e.note)) L.notes.push(e.note); L.hz = Math.min(L.hz, e.hz); } } for (const l of labels) { const s = l.notes.length > 1 ? l.notes.join('+') : `${l.notes[0]} ${Math.round(l.hz)}`; text(s, (l.x - (l.side < 0 ? s.length * 6 : 0)) | 0, l.y | 0, mix(T.bg, mix(T.dim, l.color, .75), Math.max(0, Math.min(1, l.alpha)))); } // the machines const order = Array.from({ length: SEATS }, (_, k) => k).sort((p, q) => (p === CENTER ? hy : floor(seatAngle(p), R)[1]) - (q === CENTER ? hy : floor(seatAngle(q), R)[1])); for (const k of order) { const isC = k === CENTER; let [x, y] = isC ? [hx, hy] : floor(seatAngle(k), R); if (isC) { const [fx, fy] = figure(hx, hy + 14, mix(T.ink, T.dim, .3)); x = fx; y = fy - 6; } const bw = isC ? 44 : 66, bh = isC ? 28 : 42, lit = light[k]; const sc = tone(SEAT_COLORS[k]); if (flash[k] > .02) for (let o = 1; o <= 4; o++) { const al = flash[k] * (1 - o / 5) * .5; bar(x - bw / 2 - o, y - bh / 2 - o, x + bw / 2 + o, y - bh / 2 - o, 1, sc, al); bar(x - bw / 2 - o, y + bh / 2 + o, x + bw / 2 + o, y + bh / 2 + o, 1, sc, al); bar(x - bw / 2 - o, y - bh / 2 - o, x - bw / 2 - o, y + bh / 2 + o, 1, sc, al); bar(x + bw / 2 + o, y - bh / 2 - o, x + bw / 2 + o, y + bh / 2 + o, 1, sc, al); } // the machine's actual screen, as pieces/spatial-rehearsal.mjs paints it: a dark // field tinted by the glow, a horizon, a front line, and this laptop's notes // flying from the horizon to the front, arriving as they sound const glow = lit, screenBg = mix([12, 15, 23], sc.map(v => v * .3), glow); rect(x - bw / 2, y - bh / 2, bw, bh, screenBg); // notes are frames in the display's own aspect: small and centered far // off, filling the display exactly as they sound, fading with the note const mine = []; for (let i = lo; i < all.length && all[i].t <= t + LOOK; i++) { const e = all[i], until = e.t - t; if (e.t + e.dur + .3 < t) continue; const g = sourceGain(score, voicePosition(score, e.lane, e.t), k, SEATS); if (g * g < .5) continue; mine.push({ e, until }); } mine.sort((p, q) => q.until - p.until); for (const { e, until } of mine) { const base = noteColor(e.note) || sc, sharp = e.note && e.note.includes('#'); const sounding = until <= 0, held = Math.max(.3, e.dur), left = sounding ? Math.max(0, 1 - (-until) / held) : 1; const near = sounding ? left : 1 - Math.max(0, Math.min(1, until / LOOK)); const scl = sounding && -until < .08 ? 1 : .06 + .94 * Math.pow(near, sounding ? 1.6 : 2.2); const fw = bw * scl, fh = bh * scl, x0 = x - fw / 2, y0 = y - fh / 2; const bright = sounding ? .35 + .65 * left : .3 + .7 * near; const col = base.map(v => v * bright), dark = base.map(v => v * bright * .45); if (sounding && -until < .08) { rect(x0, y0, fw, fh, col); for (let yy = y0 + 2; yy < y0 + fh; yy += 4) bar(x0, yy, x0 + fw, yy, 1, dark, .8); } else { if (scl > .12) for (let yy = y0 + 1.5; yy < y0 + fh - 1; yy += sounding ? 3 : 2) bar(x0 + 1, yy, x0 + fw - 1, yy, .8, dark, .5); const oc = sharp ? [225, 225, 235] : col; bar(x0, y0, x0 + fw, y0, 1, oc, 1); bar(x0, y0 + fh, x0 + fw, y0 + fh, 1, oc, 1); bar(x0, y0, x0, y0 + fh, 1, oc, 1); bar(x0 + fw, y0, x0 + fw, y0 + fh, 1, oc, 1); if (scl > .3) { bar(x0 + 2, y0 + 2, x0 + fw - 2, y0 + 2, 1, dark, 1); bar(x0 + 2, y0 + fh - 2, x0 + fw - 2, y0 + fh - 2, 1, dark, 1); } } } rect(x - bw / 2 - 3, y + bh / 2 + 1, bw + 6, 2, T.dim); // the number sits beside the machine, on the side away from the audience if (isC) text('C', x + bw / 2 + 5, y - 5, T.ink, 2); else { const [ox, oy] = floor(seatAngle(k), R + bw * .5 + 14); text(String(k + 1), ox - 6, oy - 10, T.ink, 2); } } // The SUB is its own stationary output, outside the rotating laptop field. // Its cone follows filtered audio, while the kick icon follows kit attacks. if (SUB) { let energy = 0; for (let i = s0; i < s1; i++) energy += feeds[SEATS][i] ** 2; const level = Math.min(1, Math.sqrt(energy / Math.max(1, s1 - s0)) / norm * 35); subLight = Math.max(level, subLight * Math.exp(-dt / .18)); const [x, y] = floor(SUB_AZ * Math.PI / 180, R + 130); const c = mix(T.dim, [235, 120, 55], subLight); rect(x - 26, y - 63, 52, 60, T.box); bar(x - 26, y - 63, x + 26, y - 63, 2, c, 1); bar(x - 26, y - 63, x - 26, y - 3, 2, c, 1); bar(x + 26, y - 63, x + 26, y - 3, 2, c, 1); bar(x - 26, y - 3, x + 26, y - 3, 2, c, 1); disc(x | 0, (y - 32) | 0, 18, T.ink); ring(x, y - 32, 12 + subLight * 4, c); disc(x | 0, (y - 32) | 0, 5, c); text('SUB', x - 18, y + 5, T.ink, 2); } if (kickEvents.length) { const [x, y] = floor(0, R * .53); const hit = kickEvents.filter(e => e.wave !== 'noise' && t >= e.t && t < e.t + .22) .reduce((a, e) => Math.max(a, Math.sqrt(e.g) * Math.exp(-(t - e.t) / .09)), 0); const c = mix(T.dim, [245, 90, 110], Math.min(1, hit * 2)); disc(x | 0, y | 0, 15, T.box); ring(x, y, 15, c); ring(x, y, 11 + hit * 3, c); bar(x, y + 4, x + 5, y + 18, 2, T.ink, 1); text('KICK', x + 22, y - 4, T.ink, 2); } // panel const X = Math.round(W * .68), col0 = T.ink, dim = T.dim; rect(X - 8, 0, W - X + 8, H, T.bg); // keep flying notes out of the instrument list text(ascii(score.name.split(' · ')[0]).toUpperCase(), X, 26, col0, 2); const mv = (score.movements || []).find(m => t >= m.t0 && t < m.t1) || (score.movements || []).at(-1); if (mv) { text(ascii(mv.name), X, 64, T.accent, 2); const words = ascii(mv.sub || '').split(' '); let row = '', y = 92; for (const w of words) { if ((row + ' ' + w).length > Math.floor((W - X - 16) / 6)) { text(row, X, y, mix(T.ink, T.dim, .4)); y += 13; row = w; } else row = row ? row + ' ' + w : w; } text(row, X, y, mix(T.ink, T.dim, .4)); } text(`${mmss(Math.min(Math.max(0, t), score.dur))} / ${mmss(score.dur)}`, X, 156, col0, 2); const bpm = tempoAt(t); text(bpm ? `${+bpm.toFixed(1)} BPM` : 'FREE TIME', X, 190, T.accent, 2); text(`${(recent / 2).toFixed(1).padStart(5)} notes/s ${String(sounding).padStart(2)} voices`, X, 214, dim); text(`ring of ${RING}${CENTER >= 0 ? ' + held center' : ''} ${useHrtf ? 'KEMAR HRTF' : 'parametric head'}`, X, 230, dim); for (let k = 0; k < SEATS; k++) { const isC = k === CENTER; disc(X + 4, 262 + k * 13, 3, tone(SEAT_COLORS[k])); text(isC ? 'C held, center, small speaker' : `${k + 1} ${ringIndex(k) === 0 ? 'front' : 'at ' + Math.round(ringIndex(k) / RING * 360) + ' deg'}`, X + 14, 257 + k * 13, dim); } if (SUB) text(`SUB ${Math.round(SUB_LEVEL * 100)}% ${SUB_CUTOFF} HZ ${SUB_AZ} DEG`, X, 342, T.accent); const instruments = score.orchestra ? [...new Set(score.lanes.flatMap(l => l.events.filter(e => Number.isInteger(e.gm) && e.t <= t && e.t + e.dur > t).map(e => e.gm)))].slice(0, 5) : null; if (instruments) instruments.forEach((p, i) => text(`${p + 1} ${GM_NAMES[p]}`.toUpperCase(), X, 370 + i * 24, col0, 2)); else Object.entries(score.voicing || {}).forEach(([family, recipe], i) => { const col = i >= 5 ? 1 : 0, row = i % 5; text(`${family} ${recipe}`.toUpperCase(), X + col * 150, 370 + row * 17, col0); }); // timeline const TX = X, TW = W - X - 16, TY = H - 40; (score.movements || []).forEach((m, i) => rect(TX + m.t0 / score.dur * TW, TY, Math.max(1, (m.t1 - m.t0) / score.dur * TW - 1), 10, T.seg[i % 2])); if (from > 0 || to < score.dur) rect(TX + from / score.dur * TW, TY - 4, Math.max(1, (to - from) / score.dur * TW), 2, T.accent); bar(TX + Math.min(Math.max(0, t), score.dur) / score.dur * TW, TY - 3, TX + Math.min(Math.max(0, t), score.dur) / score.dur * TW, TY + 13, 2, T.play, 1); (score.movements || []).forEach((m, i) => { const label = ascii(m.name).split(' ')[0], wseg = (m.t1 - m.t0) / score.dur * TW; if (wseg >= label.length * 6 + 4) text(label, TX + m.t0 / score.dur * TW + 1, TY + 14, dim); else if (i % 2) text(label, TX + m.t0 / score.dur * TW + 1, TY + 26, dim); else text(label, TX + m.t0 / score.dur * TW + 1, TY + 14, dim); }); if (opt('frame-out') && f === Math.round(((parseT(opt('frame-at')) ?? from) - from) * FPS)) { writeFileSync(resolve(opt('frame-out')), Buffer.concat([Buffer.from(`P6\n${W} ${H}\n255\n`), frame])); } await write(Buffer.from(frame));}ff.stdin.end();await done;tick(`video: ${frames} frames at ${FPS} fps`);console.log(outPath);