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})`);