diff --git a/fedac/native/tools/nsscore-bake-audio.mjs b/fedac/native/tools/nsscore-bake-audio.mjs index 58f35ecfbb..85c0ff667b 100644 --- a/fedac/native/tools/nsscore-bake-audio.mjs +++ b/fedac/native/tools/nsscore-bake-audio.mjs @@ -134,10 +134,16 @@ const azTable = new Float64Array(n), elTable = new Float64Array(n), diTable = ne } } -const osc = (wave, ph, seed) => +// Waveforms. `kick` is the hardcore one: a sine whose pitch collapses in +// the first few milliseconds, then gets driven into saturation — the +// distorted 909 that the whole genre is built on. +const osc = (wave, ph) => wave === "triangle" ? 2 * Math.abs(2 * (ph - Math.floor(ph + 0.5))) - 1 + : wave === "saw" ? 2 * (ph - Math.floor(ph + 0.5)) + : wave === "square" ? (ph - Math.floor(ph) < 0.5 ? 1 : -1) : wave === "noise" || wave === "click" ? Math.random() * 2 - 1 : Math.sin(2 * Math.PI * ph); +const sat = (x, drive) => drive > 1 ? Math.tanh(x * drive) / Math.tanh(drive) : x; // ── render ─────────────────────────────────────────────────────────── const BLOCK = 64; @@ -149,18 +155,26 @@ for (const lane of S.lanes) { if (len <= 0) continue; const atk = Math.max(1, Math.min(0.25 * e.dur, 0.8) * SR); const rel = Math.max(1, Math.min(0.35 * e.dur, 1.5) * SR); - // a click is a percussive burst: near-instant attack, fast decay + // percussive voices get an exponential body; sustained ones a raised + // cosine. `sweep` collapses the pitch over `sweepMs` (the kick), and + // `drive` saturates — both are what make a club voice hit rather than + // merely sound. const clicky = e.wave === "click" || e.wave === "noise"; + 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; const dry = new Float64Array(len); let ph = 0; for (let i = 0; i < len; i++) { - const env = clicky - ? Math.exp(-i / (SR * Math.max(0.004, e.dur * 0.35))) + const env = perc + ? Math.exp(-i / (SR * decay)) : 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; - ph += (e.hz || 220) / SR; - dry[i] = osc(e.wave, ph) * env * e.g * (clicky ? 0.5 : 0.62); + const f = (e.hz || 220) * (1 + (sweep - 1) * Math.exp(-(i / SR) / sweepMs)); + ph += f / SR; + const wv = e.wave === "kick" ? Math.sin(2 * Math.PI * ph) : osc(e.wave, ph); + 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 @@ -203,7 +217,22 @@ for (const lane of S.lanes) { } } -// ── one normalization at the end (never per-source) ────────────────── +// ── master: optional soft-clip glue, then ONE normalization ────────── +// A hardcore master lives on saturation, not on peak headroom; tanh on +// the summed ears keeps the loudness without shattering the binaural +// image the way a hard limiter would. +const MASTER_DRIVE = S.masterDrive || 1; +if (MASTER_DRIVE > 1) { + let rms = 0; + for (let i = 0; i < n; i++) rms += outL[i] * outL[i] + outR[i] * outR[i]; + rms = Math.sqrt(rms / (2 * n)) || 1; + const pre = (S.masterTarget || 0.30) / rms; // pre-clip RMS target + for (let i = 0; i < n; i++) { + outL[i] = Math.tanh(outL[i] * pre * MASTER_DRIVE) / Math.tanh(MASTER_DRIVE); + outR[i] = Math.tanh(outR[i] * pre * MASTER_DRIVE) / Math.tanh(MASTER_DRIVE); + } +} + let peak = 0; for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(outL[i]), Math.abs(outR[i])); const g = peak > 0 ? 0.89 / peak : 1; diff --git a/grants/culturehub-la-2026/bin/hardfield-gen.mjs b/grants/culturehub-la-2026/bin/hardfield-gen.mjs new file mode 100644 index 0000000000..14c6493ae7 --- /dev/null +++ b/grants/culturehub-la-2026/bin/hardfield-gen.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// hardfield-gen — the hardcore club version of the spatial test. 175 BPM, +// distorted kick, and everything that isn't the kick moves: the room +// becomes the effect rather than a place the effect happens in. +// +// The spatial arrangement is the arrangement: +// kick — pinned CENTRE and LOW, close. It never moves; it is the floor. +// bass — pinned centre, just under the kick. +// hats — pinned HIGH, front-left. Above your head, always. +// ride — pinned HIGH, front-right. The other half of the ceiling. +// stab — ORBITS. Short, hard, many — the thing that flies past you. +// lead — ORBITS. Short decays, lots of notes: an acid line in motion. +// siren — ORBITS overhead on the risers. +// +// node hardfield-gen.mjs [out.nsscore] [--bars 64] [--bpm 175] + +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 BPM = opt("bpm", 175); +const BARS = opt("bars", 64); +const B = 60 / BPM; // one beat +const BAR = 4 * B; +const S16 = B / 4; // sixteenth + +const kick = [], bass = [], hats = [], ride = [], stab = [], lead = [], siren = []; +const at = (bar, beat = 0) => bar * BAR + beat * B; + +// A minor pentatonic, two octaves — the acid ladder. +const ROOT = 55; // A1 +const SCALE = [0, 3, 5, 7, 10, 12, 15, 17, 19, 22, 24]; +const hz = (semi) => +(ROOT * Math.pow(2, semi / 12)).toFixed(2); + +// ── structure: eight-bar blocks, hardcore shape ────────────────────── +const SECTIONS = [ + { name: "I · Load in", sub: "kick alone, the floor arrives", bars: 8, kick: 1, hats: 0, stab: 0, lead: 0, drive: 2.2 }, + { name: "II · Hats up", sub: "the ceiling starts ticking", bars: 8, kick: 1, hats: 1, stab: 0, lead: 0.6, drive: 2.6 }, + { name: "III · Drop", sub: "everything, spinning", bars: 16, kick: 1, hats: 1, stab: 1, lead: 1, drive: 3.2 }, + { name: "IV · Break", sub: "no floor — only the room", bars: 8, kick: 0, hats: 0.5, stab: 1, lead: 1, drive: 2.4 }, + { name: "V · Riser", sub: "the siren comes overhead", bars: 8, kick: 1, hats: 1, stab: 0.6, lead: 1, drive: 3.4 }, + { name: "VI · Slam", sub: "the hardest pass, then nothing", bars: 16, kick: 1, hats: 1, stab: 1, lead: 1, drive: 4.0 }, +]; + +const movements = []; +let bar = 0; +for (const sec of SECTIONS) { + const t0 = at(bar); + for (let b = 0; b < sec.bars; b++, bar++) { + const barT = at(bar); + const last = b === sec.bars - 1; + + // KICK — four on the floor, hard. Every downbeat, plus a rolling + // sixteenth pair at the end of every fourth bar. + if (sec.kick) { + for (let beat = 0; beat < 4; beat++) + kick.push({ t: +(barT + beat * B).toFixed(4), dur: 0.26, hz: 46, wave: "kick", + sweep: 4.6, sweepMs: 18, decay: 0.085, drive: sec.drive, g: 0.95 }); + if (b % 4 === 3) + for (const off of [3.5, 3.75]) + kick.push({ t: +(barT + off * B).toFixed(4), dur: 0.18, hz: 46, wave: "kick", + sweep: 4.2, sweepMs: 14, decay: 0.06, drive: sec.drive, g: 0.8 }); + // BASS — offbeat eighths under it + for (let e = 0; e < 8; e++) + if (e % 2 === 1) + bass.push({ t: +(barT + e * B / 2).toFixed(4), dur: 0.14, hz: hz(SCALE[b % 3]), + wave: "square", decay: 0.05, drive: 1.8, g: 0.42 }); + } + + // HATS — offbeat sixteenths above you, with a closing roll + if (sec.hats) { + for (let s = 0; s < 16; s++) { + if (s % 2 === 0) continue; + hats.push({ t: +(barT + s * S16).toFixed(4), dur: 0.035, hz: 11000, wave: "click", + decay: 0.012, g: 0.4 * sec.hats }); + } + if (last) for (let s = 12; s < 16; s++) + ride.push({ t: +(barT + s * S16).toFixed(4), dur: 0.05, hz: 7000, wave: "click", + decay: 0.02, g: 0.5 }); + if (b % 2 === 1) + ride.push({ t: +(barT + 2 * B).toFixed(4), dur: 0.09, hz: 5200, wave: "click", decay: 0.04, g: 0.45 }); + } + + // STAB — hard offbeat chord hits that fly around the room + if (sec.stab) for (const beat of [1.5, 3.5]) { + for (const iv of [0, 7, 12]) + stab.push({ t: +(barT + beat * B).toFixed(4), dur: 0.13, hz: hz(24 + iv + (b % 4 === 3 ? 2 : 0)), + wave: "saw", decay: 0.045, drive: 2.4, g: 0.3 * sec.stab }); + } + + // LEAD — the acid ladder: sixteenths, short decays, many notes + if (sec.lead) { + for (let s = 0; s < 16; s++) { + if (Math.random() > 0.62 * sec.lead) continue; + const step = SCALE[(s * 3 + b * 2) % SCALE.length] + 24; + lead.push({ t: +(barT + s * S16).toFixed(4), dur: 0.075, hz: hz(step), + wave: "saw", decay: 0.028, drive: 2.0, g: 0.24 * sec.lead }); + } + } + } + + // SIREN — a rising sweep across the last two bars of the riser + if (sec.name.startsWith("V ·")) { + for (let i = 0; i < 24; i++) + siren.push({ t: +(at(bar - 2) + i * (BAR * 2 / 24)).toFixed(4), dur: 0.16, + hz: 300 + i * 95, wave: "saw", decay: 0.09, drive: 1.6, g: 0.3 }); + } + movements.push({ ...sec, t0: +t0.toFixed(3), t1: +at(bar).toFixed(3), level: sec.drive / 4 }); +} + +const dur = +at(bar).toFixed(3); + +// ── motion: the ribbons ────────────────────────────────────────────── +const N = 512; +const inM = (i, τ) => τ >= movements[i].t0 && τ < movements[i].t1; +const spinAt = (τ) => { + if (inM(0, τ)) return 0.2; + if (inM(1, τ)) return 0.6; + if (inM(2, τ)) return 1.6; // drop: hard rotation + if (inM(3, τ)) return 2.4; // break: fastest — the room is the drop + if (inM(4, τ)) return 1.2; + return 2.0; // slam +}; +const elevAt = (τ) => { + if (inM(3, τ)) { const u = (τ - movements[3].t0) / (movements[3].t1 - movements[3].t0); return Math.sin(Math.PI * u) * 0.8; } + if (inM(4, τ)) { const u = (τ - movements[4].t0) / (movements[4].t1 - movements[4].t0); return u * 0.9; } + if (inM(5, τ)) { const u = (τ - movements[5].t0) / (movements[5].t1 - movements[5].t0); return Math.cos(u * Math.PI * 3) * 0.45; } + return 0; +}; +const distAt = (τ) => (inM(2, τ) || inM(5, τ) ? 0.08 : 0.28); +const ramp = (fn) => Array.from({ length: N }, (_, i) => +fn((i / (N - 1)) * dur).toFixed(4)); + +const score = { + name: "hardfield", + bpm: BPM, + dur, + masterDrive: 2.1, // club glue on the summed ears + masterTarget: 0.25, // hardcore-loud, short of crushed + movements, + rotation: ramp(spinAt), + elevation: ramp(elevAt), + distance: ramp(distAt), + lanes: [ + // pinned: the floor and the ceiling never move + { name: "kick", color: [179, 64, 46], events: kick, az: 0, el: -0.25, dist: 0.5 }, + { name: "bass", color: [150, 90, 70], events: bass, az: 0, el: -0.1, dist: 0.7 }, + { name: "hats", color: [62, 124, 138], events: hats, az: -0.75, el: 0.85, dist: 0.9 }, + { name: "ride", color: [90, 150, 160], events: ride, az: 0.75, el: 0.8, dist: 0.9 }, + // orbiting: the things that fly past you + { name: "stab", color: [200, 120, 40], events: stab }, + { name: "lead", color: [230, 190, 60], events: lead, azOffset: Math.PI }, + { name: "siren", color: [120, 60, 150], events: siren, azOffset: Math.PI / 2 }, + ], +}; + +const dest = args.find(a => !a.startsWith("--") && a.endsWith(".nsscore")) || "hardfield.nsscore"; +writeFileSync(dest, JSON.stringify(score) + "\n"); +const total = [kick, bass, hats, ride, stab, lead, siren].reduce((a, l) => a + l.length, 0); +console.log(`${dest} — ${BPM} BPM, ${bar} bars, ${Math.floor(dur / 60)}m${Math.round(dur % 60)}s, ${total} events (kick ${kick.length}, hats ${hats.length}, lead ${lead.length}, stab ${stab.length})`); diff --git a/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs b/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs index a7e1094eb4..7b48fcb951 100644 --- a/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs +++ b/grants/culturehub-la-2026/bin/render-notespatial-diagram.mjs @@ -78,7 +78,7 @@ const seatPos = (i) => [Math.cos(seatAngle(i)) * RING, 0, Math.sin(seatAngle(i)) // ── choreography: lane → seat (the composition model) ──────────────── const M = S.movements; function laneSeat(lane, t) { - if (S.lanes.length <= 4) { // spatial-test scores: the seat you're passing + if (S.lanes.length <= 8) { // spatial scores: the seat you are passing const a = voiceAngle(lane) + spinAngle; return ((Math.round(((a + Math.PI / 2) / (Math.PI * 2)) * SEATS) % SEATS) + SEATS) % SEATS; } @@ -649,7 +649,7 @@ function drawFrame(t) { const p2 = project(p3, t); if (!p2 || gr <= 0.03) continue; const c = S.lanes[i].color; - const solo = S.lanes.length <= 4 ? 1.9 : 1; + const solo = S.lanes.length <= 8 ? 1.7 : 1; const r = Math.max(2, (4.5 + glow[i] * 8) * solo * p2.s / 140); items.push({ d: p2.d, draw: () => { ctx.fillStyle = rgba(c, 0.45 + glow[i] * 0.55);