From 23f0b2243363dd28bcf1bf76087d96d68252f2a6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 8 Sep 2026 20:55:40 -0400 Subject: [PATCH] =?UTF-8?q?notespatial:=20wildfield=20=E2=80=94=20natural?= =?UTF-8?q?=20sound,=20derived=20rather=20than=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in this piece is a sample. Struck bodies ring as mode banks built from material constants the way pop/nullabye/c/ac_mesh_acoustics.h derives them — wave speed sqrt(E/rho) over the object's characteristic lengths, damping from the loss factor — so wood knocks and stone clacks because of what they are made of and how big they are. Water drops are small cavities whose pitch rises as the bubble closes, wind and leaves are bandpassed noise, birds are swept vibrato whistles through a resonant filter. The engine grew what that needs: a Chamberlin state-variable filter per event, vibrato, and modal voices. Two things make the mix: early reflections (four wall taps, each spatialized from its own direction — a dry binaural image collapses inside the head and plausible reflections push it out into the room), and the master glue going optional, so a piece that lives on dynamics keeps them (-21.4 LUFS here against hardfield's -7.9). Scores also gain GLOBAL gestures beside per-voice motion: fieldShift swings the entire scene around the listener, fieldTilt lifts or drops it, fieldScale pushes it near and far — pinned lanes included, because the room moves and the furniture does not. wildfield's narrative is made of them: far off before dawn, drawing close through waking, tipping overhead in the rain, scaling away as it passes. --- fedac/native/tools/nsscore-bake-audio.mjs | 126 +++++++++++++- .../bin/render-notespatial-diagram.mjs | 32 +++- .../culturehub-la-2026/bin/wildfield-gen.mjs | 161 ++++++++++++++++++ 3 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 grants/culturehub-la-2026/bin/wildfield-gen.mjs diff --git a/fedac/native/tools/nsscore-bake-audio.mjs b/fedac/native/tools/nsscore-bake-audio.mjs index 85c0ff667b..7f5f1405b6 100644 --- a/fedac/native/tools/nsscore-bake-audio.mjs +++ b/fedac/native/tools/nsscore-bake-audio.mjs @@ -118,7 +118,13 @@ function hrirAt(azRad, elRad) { } // ── position tables ────────────────────────────────────────────────── +// Beyond each voice's own motion, the whole FIELD can move: fieldShift +// swings everything (pinned lanes included) around the listener, +// fieldTilt raises or drops the entire scene, fieldScale pushes it near +// or far. These are the global gestures — a whole room turning, rather +// than one thing flying. const azTable = new Float64Array(n), elTable = new Float64Array(n), diTable = new Float64Array(n); +const fShift = new Float64Array(n), fTilt = new Float64Array(n), fScale = new Float64Array(n); { const spin = S.rotation || [0]; const elev = S.elevation || null; @@ -131,6 +137,9 @@ const azTable = new Float64Array(n), elTable = new Float64Array(n), diTable = ne azTable[i] = a; elTable[i] = elev ? sample(elev, τ) * (Math.PI / 2) : 0; diTable[i] = dist ? 0.6 + sample(dist, τ) * 2.4 : 1.2; + fShift[i] = S.fieldShift ? sample(S.fieldShift, τ) * Math.PI : 0; + fTilt[i] = S.fieldTilt ? sample(S.fieldTilt, τ) * (Math.PI / 2) : 0; + fScale[i] = S.fieldScale ? Math.max(0.15, sample(S.fieldScale, τ)) : 1; } } @@ -145,6 +154,51 @@ const osc = (wave, ph) => : Math.sin(2 * Math.PI * ph); const sat = (x, drive) => drive > 1 ? Math.tanh(x * drive) / Math.tanh(drive) : x; +// ── modal voices ───────────────────────────────────────────────────── +// A struck object rings as a sum of decaying sinusoids. Frequencies and +// dampings come from material constants the way pop/nullabye/c/ +// ac_mesh_acoustics.h derives them: wave speed sqrt(E/rho) over the +// object's characteristic lengths, damping from the loss factor. Give an +// event {wave:"modal", material, size} and it sounds like the thing it +// is made of; {modes:[...]} overrides with explicit partials. +const MATERIALS = { + wood: { density: 690, young: 10.5e9, loss: 0.034 }, + aluminum: { density: 2700, young: 69e9, loss: 0.006 }, + glass: { density: 2500, young: 70e9, loss: 0.012 }, + stone: { density: 2700, young: 45e9, loss: 0.022 }, + // softer, lossier bodies for organic sound — same derivation, damper + shell: { density: 1800, young: 22e9, loss: 0.018 }, + reed: { density: 400, young: 4.0e9, loss: 0.055 }, + water: { density: 1000, young: 2.2e9, loss: 0.10 }, +}; +const MODE_FACTOR = [0.42, 0.47, 0.53, 0.71, 0.89, 1.17]; +function modesFor(e) { + if (Array.isArray(e.modes)) return e.modes; + const m = MATERIALS[e.material] || MATERIALS.wood; + const L = Math.max(0.02, e.size || 0.12); + const wave = Math.sqrt(m.young / m.density); + const lens = [L, L * 0.82, L * 0.67, L * 0.55, L * 0.44, L * 0.36]; + return MODE_FACTOR.map((f, k) => { + const hz = Math.max(22, (wave / (2 * Math.PI * lens[k])) * f); + return { hz, decay: 1 / (m.loss * 2 * Math.PI * hz + 1e-6), g: 1 / (1 + k * 0.85) }; + }); +} + +// ── the room ───────────────────────────────────────────────────────── +// Four early reflections off notional walls, each spatialized from its +// own direction. Not decoration: a dry HRTF image tends to collapse +// inside the head, and plausible early reflections are what push it out +// into the room. Off by default; scores opt in with `room`. +const ROOM = S.room ? { + mix: S.room.mix ?? 0.3, + taps: [ + { az: -1.9, el: 0.15, ms: 13, g: 0.60 }, + { az: 1.9, el: 0.15, ms: 17, g: 0.55 }, + { az: -2.7, el: -0.1, ms: 23, g: 0.42 }, + { az: 2.7, el: -0.1, ms: 29, g: 0.38 }, + ], +} : null; + // ── render ─────────────────────────────────────────────────────────── const BLOCK = 64; for (const lane of S.lanes) { @@ -163,7 +217,38 @@ for (const lane of S.lanes) { const perc = clicky || e.wave === "kick"; const decay = e.decay ?? (clicky ? Math.max(0.004, e.dur * 0.35) : e.dur * 0.5); const sweep = e.sweep || 1, sweepMs = (e.sweepMs || 22) / 1000; + // Chamberlin state-variable filter: wind is bandpassed noise, a + // droplet is a resonant ping, rustle is noise with the top rolled + // off. Natural sound is mostly filtered noise with an envelope. + const fc = e.bp || e.lp || 0; + const useBp = !!e.bp; + const fCo = fc ? 2 * Math.sin(Math.PI * Math.min(0.24, fc / SR)) : 0; + const q1 = 1 / (e.q || 1.2); + let flow = 0, fband = 0; + const vib = e.vib || 0, vibDepth = e.vibDepth || 0; const dry = new Float64Array(len); + + if (e.wave === "modal") { // struck: excite the mode bank, let it ring + const modes = modesFor(e).filter(m => m.hz < SR * 0.45); + const exLen = Math.max(2, Math.floor(SR * (e.strike || 0.0015))); + for (const m of modes) { + const w = 2 * Math.PI * m.hz / SR; + const dec = Math.exp(-1 / (SR * Math.max(0.004, m.decay * (e.ring || 1)))); + let a = 0, b = 0; // resonator state as a rotating pair + for (let i = 0; i < len; i++) { + const drive = i < exLen ? (Math.random() * 2 - 1) * (1 - i / exLen) : 0; + const na = (a * Math.cos(w) - b * Math.sin(w) + drive) * dec; + const nb = (a * Math.sin(w) + b * Math.cos(w)) * dec; + a = na; b = nb; + dry[i] += a * m.g; + } + } + let pk = 0; + for (let i = 0; i < len; i++) pk = Math.max(pk, Math.abs(dry[i])); + const norm = pk > 0 ? e.g * 0.62 / pk : 0; + for (let i = 0; i < len; i++) dry[i] = sat(dry[i] * norm, e.drive || 1); + } else { + let ph = 0; for (let i = 0; i < len; i++) { const env = perc @@ -171,11 +256,19 @@ for (const lane of S.lanes) { : i < atk ? 0.5 - 0.5 * Math.cos(Math.PI * i / atk) : i > len - rel ? 0.5 - 0.5 * Math.cos(Math.PI * (len - i) / rel) : 1; - const f = (e.hz || 220) * (1 + (sweep - 1) * Math.exp(-(i / SR) / sweepMs)); + let f = (e.hz || 220) * (1 + (sweep - 1) * Math.exp(-(i / SR) / sweepMs)); + if (vib) f *= 1 + vibDepth * Math.sin(2 * Math.PI * vib * i / SR); ph += f / SR; - const wv = e.wave === "kick" ? Math.sin(2 * Math.PI * ph) : osc(e.wave, ph); + let wv = e.wave === "kick" ? Math.sin(2 * Math.PI * ph) : osc(e.wave, ph); + if (fCo) { + flow += fCo * fband; + const high = wv - flow - q1 * fband; + fband += fCo * high; + wv = useBp ? fband : flow; + } dry[i] = sat(wv * env, e.drive || 1) * e.g * (clicky ? 0.5 : 0.62); } + } if (!HR) { // modeled fallback — see git history for the full parametric path for (let i = 0; i < len && s0 + i < n; i++) { @@ -191,14 +284,15 @@ for (const lane of S.lanes) { let prev = null; for (let b = 0; b < len; b += BLOCK) { const idx = Math.min(n - 1, s0 + b); - const azRaw = pinned ? lane.az : azTable[idx]; + const azRaw = (pinned ? lane.az : azTable[idx] + (lane.azOffset || 0)) + fShift[idx]; const az = Math.atan2(Math.sin(azRaw) * SPREAD, Math.cos(azRaw)); - const el = pinned ? (lane.el || 0) * (Math.PI / 2) : elTable[idx]; - const dist = pinned ? (lane.dist ?? 1.2) : diTable[idx]; + const elRaw = (pinned ? (lane.el || 0) * (Math.PI / 2) : elTable[idx]) + fTilt[idx]; + const el = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, elRaw)); + const dist = (pinned ? (lane.dist ?? 1.2) : diTable[idx]) * fScale[idx] * (lane.gainDist || 1); const cur = hrirAt(az, el); if (!prev) prev = cur; // 1/r with a near-field floor, plus gentle air absorption with range - const gain = 1 / Math.max(0.35, dist); + const gain = (lane.mix ?? 1) / Math.max(0.35, dist); const blockEnd = Math.min(len, b + BLOCK); for (let i = b; i < blockEnd; i++) { const x = dry[i] * gain; @@ -214,6 +308,26 @@ for (const lane of S.lanes) { } prev = cur; } + + // reflections: the same dry signal, delayed, from the walls + if (ROOM && HR) { + for (const tap of ROOM.taps) { + const ir = hrirAt(tap.az, tap.el * (Math.PI / 2)); + const d = Math.floor(tap.ms / 1000 * SR); + const gain = ROOM.mix * tap.g; + for (let i = 0; i < len; i++) { + const x = dry[i] * gain; + if (x === 0) continue; + const o = s0 + i + d; + for (let k = 0; k < TAPS; k++) { + const t = o + k; + if (t >= n) break; + outL[t] += x * ir.L[k]; + outR[t] += x * ir.R[k]; + } + } + } + } } } diff --git a/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs b/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs index 7b48fcb951..80909db1f1 100644 --- a/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs +++ b/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs @@ -134,11 +134,25 @@ function elevationAt(t) { if (!e) return 0; return e[Math.max(0, Math.min(e.length - 1, Math.round((t / S.dur) * (e.length - 1))))] || 0; } +// The global gestures: the whole field swings, tips, and breathes near +// and far around a listener who stays put. The machines never move — +// they are furniture — so a field transform is visible as everything +// else moving relative to them. +function fieldAt(t, key, dflt) { + const a = S[key]; + if (!a) return dflt; + return a[Math.max(0, Math.min(a.length - 1, Math.round((t / S.dur) * (a.length - 1))))] ?? dflt; +} function voicePos(i, t) { const g = globeState(t); - const a = voiceAngle(i) + spinAngle; - const el = isPinned(i) ? (S.lanes[i].el || 0) : elevationAt(t); - const ringR = RING * g.r * g.scale * Math.cos(el * Math.PI / 2 * 0.8); + const a = voiceAngle(i) + spinAngle + fieldAt(t, "fieldShift", 0) * Math.PI; + const el = Math.max(-1, Math.min(1, + (isPinned(i) ? (S.lanes[i].el || 0) : elevationAt(t)) + fieldAt(t, "fieldTilt", 0))); + const scale = fieldAt(t, "fieldScale", 1); + const lane = S.lanes[i]; + const near = isPinned(i) ? (lane.dist ?? 1.2) : 1.2; + const ringR = RING * g.r * g.scale * Math.cos(el * Math.PI / 2 * 0.8) + * Math.max(0.25, Math.min(2.1, scale * near / 1.2)); const y = g.cy + Math.sin(voiceAngle(i) * 3 + i) * 0.34 + el * 2.3; return [Math.cos(a) * ringR, y, Math.sin(a) * ringR]; } @@ -244,11 +258,12 @@ function rigSprite(p2, color, active) { // uses (pan = sin(angle) — see nsscore-bake-audio.mjs), so the picture // tells you what your headphones should be doing. L and R are labeled // from the listener's own head, not the page. +let lastPaintT = 0; function earMix() { const out = { l: [0, 0, 0, 0], r: [0, 0, 0, 0] }; for (let i = 0; i < S.lanes.length; i++) { if (glow[i] < 0.02) continue; - const pan = Math.sin(voiceAngle(i) + spinAngle); + const pan = Math.sin(voiceAngle(i) + spinAngle + fieldAt(lastPaintT, "fieldShift", 0) * Math.PI); const wr = (pan + 1) / 2, wl = 1 - wr; const c = S.lanes[i].color; for (const [side, w] of [["l", wl], ["r", wr]]) { @@ -462,8 +477,9 @@ function drawPlan(t, cx, cy, r) { // voices at their true angles; the emitter drags a short trail const g = globeState(t); for (let i = 0; i < S.lanes.length; i++) { - const a = voiceAngle(i) + spinAngle; - const vr = r * 0.86 * g.r * g.scale / 0.94; + const a = voiceAngle(i) + spinAngle + fieldAt(t, "fieldShift", 0) * Math.PI; + const vr = r * 0.86 * g.r * g.scale / 0.94 + * Math.max(0.3, Math.min(1.6, fieldAt(t, "fieldScale", 1))); const vx = cx + Math.cos(a) * vr, vy = cy + Math.sin(a) * vr; ctx.strokeStyle = rgba(S.lanes[i].color, 0.3); ctx.lineWidth = 2; @@ -576,7 +592,7 @@ function drawLegend(x, y) { // ── frame ──────────────────────────────────────────────────────────── let titleImg = null; function drawFrame(t) { - spinAngle += spinRate(t) * (t - lastT); lastT = t; + spinAngle += spinRate(t) * (t - lastT); lastT = t; lastPaintT = t; for (let i = 0; i < S.lanes.length; i++) { const evs = S.lanes[i].events; @@ -607,7 +623,7 @@ function drawFrame(t) { // the virtual acoustic field — a dome registered on the room itself const g = globeState(t); - const gr = RING * g.r * g.scale; + const gr = RING * g.r * g.scale * Math.max(0.3, Math.min(2.0, fieldAt(t, "fieldScale", 1))); if (gr > 0.1) { circle3d(0, g.cy, 0, gr, "y", t, ink(0.26), 1.5); // the orbit circle3d(0, g.cy + 0.75, 0, gr * 0.8, "y", t, ink(0.13), 1); diff --git a/grants/culturehub-la-2026/bin/wildfield-gen.mjs b/grants/culturehub-la-2026/bin/wildfield-gen.mjs new file mode 100644 index 0000000000..1ed06b95b8 --- /dev/null +++ b/grants/culturehub-la-2026/bin/wildfield-gen.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// wildfield-gen — natural sound in the spatial field, physically modelled. +// Nothing here is a recording: every strike is a mode bank derived from +// material constants (see modesFor in nsscore-bake-audio.mjs, which +// follows pop/nullabye/c/ac_mesh_acoustics.h), and every texture is +// filtered noise with an envelope. Wood knocks because wood is stiff and +// lossy; glass rings because glass is stiff and isn't. +// +// The narrative is a night that arrives and passes: +// +// I Before dawn — wind alone, the field wide and far +// II First drops — water underfoot, one at a time +// III Waking — birds enter above, the field draws in close +// IV Full canopy — everything, and the whole scene turns +// V Rain — density peaks, the field tilts overhead +// VI Passing — it moves off; the field scales away +// VII Still — one last drop, far off +// +// Global gestures ride under it: fieldShift swings the entire room, +// fieldTilt lifts or drops it, fieldScale pushes it near and far. +// +// node wildfield-gen.mjs [out.nsscore] [--minutes 2.5] + +import { writeFileSync } from "node:fs"; + +const args = process.argv.slice(2); +const opt = (k, d) => { const i = args.indexOf("--" + k); return i >= 0 ? parseFloat(args[i + 1]) : d; }; +const MINUTES = opt("minutes", 2.5); + +let seed = 20260908; +const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32; +const pick = (a) => a[Math.floor(rnd() * a.length)]; +const range = (a, b) => a + rnd() * (b - a); + +const wind = [], drops = [], birds = [], wood = [], stones = [], leaves = [], thunder = []; + +// ── the arc ────────────────────────────────────────────────────────── +const SECTIONS = [ + { name: "I · Before dawn", sub: "wind alone, the field far off", secs: 26, + wind: 1, drop: 0, bird: 0, wood: 0, leaf: 0.2, scale: 2.6, tilt: 0.1 }, + { name: "II · First drops", sub: "water arrives underfoot", secs: 24, + wind: 0.8, drop: 0.5, bird: 0, wood: 0.2, leaf: 0.3, scale: 1.8, tilt: 0 }, + { name: "III · Waking", sub: "birds above, the field draws close", secs: 26, + wind: 0.6, drop: 0.8, bird: 0.5, wood: 0.5, leaf: 0.5, scale: 1.1, tilt: 0.15 }, + { name: "IV · Full canopy", sub: "everything, and the room turns", secs: 30, + wind: 0.7, drop: 1, bird: 1, wood: 0.8, leaf: 0.8, scale: 0.85, tilt: 0.2 }, + { name: "V · Rain", sub: "density peaks, the sky tips over", secs: 26, + wind: 1, drop: 1.6, bird: 0.4, wood: 0.3, leaf: 1, scale: 0.7, tilt: 0.55 }, + { name: "VI · Passing", sub: "it moves off", secs: 24, + wind: 0.8, drop: 0.7, bird: 0.6, wood: 0.2, leaf: 0.6, scale: 1.9, tilt: 0.1 }, + { name: "VII · Still", sub: "one drop, far away", secs: 14, + wind: 0.35, drop: 0.12, bird: 0.15, wood: 0, leaf: 0.15, scale: 3.4, tilt: 0 }, +]; +const total = SECTIONS.reduce((a, s) => a + s.secs, 0); +const SCALE_T = (MINUTES * 60) / total; + +const movements = []; +let t = 0; +for (const sec of SECTIONS) { + const t0 = t, t1 = t + sec.secs * SCALE_T; + + // WIND — long bandpassed noise swells, overlapping so it never gaps + for (let x = t0; x < t1; x += range(2.4, 4.2)) { + const dur = range(4.5, 8); + wind.push({ t: +x.toFixed(3), dur: +dur.toFixed(3), hz: 1, wave: "noise", + bp: range(280, 900), q: range(0.7, 1.6), g: 0.30 * sec.wind }); + } + + // LEAVES — dense short rustles, a bright bandpass + const leafEvery = 0.55 / Math.max(0.05, sec.leaf); + for (let x = t0; x < t1; x += range(leafEvery * 0.5, leafEvery * 1.5)) + leaves.push({ t: +x.toFixed(3), dur: range(0.10, 0.26), hz: 1, wave: "noise", + bp: range(2200, 5200), q: 1.1, decay: 0.05, g: 0.16 * sec.leaf }); + + // DROPS — a small water cavity: resonant ping with the pitch rising as + // the bubble closes. The classic droplet, and it is honest physics. + const dropEvery = 0.9 / Math.max(0.03, sec.drop); + for (let x = t0; sec.drop > 0 && x < t1; x += range(dropEvery * 0.3, dropEvery * 1.7)) { + const f = range(680, 2100); + drops.push({ t: +x.toFixed(3), dur: range(0.09, 0.2), hz: f, wave: "sine", + sweep: range(0.45, 0.7), sweepMs: range(9, 22), + decay: range(0.02, 0.05), g: range(0.28, 0.5) }); + } + + // BIRDS — swept, vibrato'd whistles through a resonant bandpass + const birdEvery = 2.2 / Math.max(0.03, sec.bird); + for (let x = t0; sec.bird > 0 && x < t1; x += range(birdEvery * 0.4, birdEvery * 1.8)) { + const n = Math.floor(range(2, 5)); + const base = range(1900, 3600); + for (let k = 0; k < n; k++) + birds.push({ t: +(x + k * range(0.07, 0.15)).toFixed(3), dur: range(0.07, 0.14), + hz: base * range(0.9, 1.25), wave: "sine", + sweep: pick([1.5, 0.65, 1.25]), sweepMs: range(18, 45), + vib: range(9, 22), vibDepth: range(0.02, 0.06), + bp: base * 1.1, q: 2.2, decay: 0.05, g: range(0.2, 0.36) * sec.bird }); + } + + // WOOD + STONE — struck bodies, modal. Wood is lossy and knocks; stone + // is stiff and clacks. Both are derived, not sampled. + const woodEvery = 3.0 / Math.max(0.03, sec.wood); + for (let x = t0; sec.wood > 0 && x < t1; x += range(woodEvery * 0.5, woodEvery * 1.6)) { + wood.push({ t: +x.toFixed(3), dur: range(0.35, 0.7), wave: "modal", + material: "wood", size: range(0.10, 0.30), ring: range(0.7, 1.3), g: 0.42 }); + if (rnd() < 0.4) + stones.push({ t: +(x + range(0.1, 0.5)).toFixed(3), dur: range(0.2, 0.45), wave: "modal", + material: "stone", size: range(0.05, 0.14), ring: 0.8, g: 0.3 }); + } + + // THUNDER — one deep modal roll at the height of the rain + if (sec.name.startsWith("V ·")) + thunder.push({ t: +(t0 + sec.secs * SCALE_T * 0.42).toFixed(3), dur: 4.5, wave: "modal", + material: "stone", size: 3.2, ring: 3.5, drive: 1.4, g: 0.75 }); + + movements.push({ name: sec.name, sub: sec.sub, t0: +t0.toFixed(3), t1: +t1.toFixed(3), + level: 0.25 + sec.drop * 0.3 + sec.bird * 0.2, scale: sec.scale, tilt: sec.tilt }); + t = t1; +} +const dur = +t.toFixed(3); + +// ── the global gestures ────────────────────────────────────────────── +const N = 512; +const secAt = (τ) => movements.find(m => τ >= m.t0 && τ < m.t1) || movements[movements.length - 1]; +const lerpSec = (τ, key) => { // smooth across section seams, never step + const i = Math.max(0, movements.findIndex(m => τ >= m.t0 && τ < m.t1)); + const m = movements[i], nx = movements[Math.min(movements.length - 1, i + 1)]; + const u = Math.min(1, Math.max(0, (τ - m.t0) / (m.t1 - m.t0))); + const e = u > 0.75 ? (u - 0.75) / 0.25 : 0; // ease into the next + return m[key] * (1 - e) + nx[key] * e; +}; +const ramp = (fn) => Array.from({ length: N }, (_, i) => +fn((i / (N - 1)) * dur).toFixed(4)); + +const score = { + name: "wildfield", + dur, + masterDrive: 1.0, // no glue: this piece lives on its + // dynamics, not on loudness + room: { mix: 0.36 }, // early reflections: it externalizes + movements, + // per-voice orbit stays slow — this field turns as a whole instead + rotation: ramp(() => 0.12), + elevation: ramp((τ) => Math.sin(τ * 0.06) * 0.25), + distance: ramp((τ) => 0.25 + 0.1 * Math.sin(τ * 0.11)), + // GLOBAL: the whole room swings, tips and breathes near/far + fieldShift: ramp((τ) => Math.sin(τ * 0.021) * 0.8 + τ / dur * 0.5), + fieldTilt: ramp((τ) => lerpSec(τ, "tilt")), + fieldScale: ramp((τ) => lerpSec(τ, "scale")), + lanes: [ + { name: "wind", color: [150, 165, 175], events: wind, az: -2.6, el: 0.35, dist: 2.2, mix: 0.9 }, + { name: "leaves", color: [120, 160, 110], events: leaves, az: 2.2, el: 0.25, dist: 1.5, mix: 0.85 }, + { name: "drops", color: [80, 140, 190], events: drops, az: 0.4, el: -0.55, dist: 0.8 }, + { name: "birds", color: [235, 190, 70], events: birds, azOffset: 0 }, // orbiting, overhead + { name: "wood", color: [150, 105, 60], events: wood, az: -1.2, el: -0.1, dist: 1.1 }, + { name: "stones", color: [130, 130, 140], events: stones, az: 1.4, el: -0.25, dist: 1.0 }, + { name: "thunder", color: [90, 80, 110], events: thunder, az: 3.0, el: 0.5, dist: 3.2, mix: 1.2 }, + ], +}; + +const dest = args.find(a => !a.startsWith("--") && a.endsWith(".nsscore")) || "wildfield.nsscore"; +writeFileSync(dest, JSON.stringify(score) + "\n"); +const n = score.lanes.reduce((a, l) => a + l.events.length, 0); +console.log(`${dest} — ${Math.floor(dur / 60)}m${Math.round(dur % 60)}s, ${n} events (wind ${wind.length}, drops ${drops.length}, birds ${birds.length}, leaves ${leaves.length}, modal ${wood.length + stones.length + thunder.length})`); -- 2.51.2