diff --git a/pop/bell/c/bell.c b/pop/bell/c/bell.c --- a/pop/bell/c/bell.c +++ b/pop/bell/c/bell.c @@ -329,6 +329,7 @@ {"aluminum", 69e9, 2700.0, 0.33, 8.0e-4}, {"silver", 83e9, 10490.0, 0.37, 5.0e-4}, {"glass", 70e9, 2500.0, 0.22, 6.0e-5}, // long shimmering ring {"gold", 79e9, 19300.0, 0.42, 9.0e-4}, + {"stone", 60e9, 2750.0, 0.25, 1.2e-3}, // granite lithophone — short, clinking ring }; int N = (int)(sizeof(tbl) / sizeof(tbl[0])); for (int i = 0; i < N; i++) { diff --git a/pop/lib/bell.mjs b/pop/lib/bell.mjs --- a/pop/lib/bell.mjs +++ b/pop/lib/bell.mjs @@ -27,7 +27,7 @@ const CSRC = resolve(HERE, "..", "bell", "c", "bell.c"); const BUILD = resolve(HERE, "..", "bell", "c", "build.sh"); export const BELL_MATERIALS = [ - "bronze", "brass", "steel", "aluminum", "silver", "glass", "gold", + "bronze", "brass", "steel", "aluminum", "silver", "glass", "gold", "stone", ]; export const BELL_GEOMETRIES = ["church", "handbell", "tubular", "bowl", "glass"]; export const BELL_SR = 48_000; diff --git a/pop/wattajetta/README.md b/pop/wattajetta/README.md new file mode 100644 --- /dev/null +++ b/pop/wattajetta/README.md @@ -0,0 +1,36 @@ +# wattajetta + +a fighter jet made entirely of water. part of the *pixsies* body — +companion track to the water-jet image gens (jeffrey in a cockpit where +the fuselage, canopy and control stick are all clear liquid). + +## the law of this lane + +everything is water, so everything is one of exactly two materials: + +- **sine** — water holding a shape. phase-increment oscillators only. + the kick (pitch-swept, 12ms hole attack), the sub bass (fuselage), + the doppler flybys, the droplet pings — one material, different + vessels. +- **spray** — water losing its shape. white noise carved by a gliding + bandpass. wingtip streams, cloud-punch splashes, afterburner steam, + the veil bed. + +no samples, no other waveforms. the kick sidechains everything else — +water pushed aside, rushing back in. + +## arc + +138 BPM felt in halftime (kicks on 1+3). taxi → takeoff → climb (two +flybys) → canopy dissolve (kick out, sub rises an octave into vapor) → +afterburner (pentatonic droplet runs) → mist out. + +## run + +```bash +node pop/wattajetta/bin/render-wattajetta.mjs # → out/wattajetta.mp3 +node pop/wattajetta/bin/render-wattajetta.mjs --score # print the baked score +``` + +the composition bakes a text score; `c/wattajetta.c` renders it +(f32le stereo 48k) and `c/run-c.mjs` masters to mp3. diff --git a/pop/wattajetta/bin/render-wattajetta.mjs b/pop/wattajetta/bin/render-wattajetta.mjs new file mode 100644 --- /dev/null +++ b/pop/wattajetta/bin/render-wattajetta.mjs @@ -0,0 +1,473 @@ +#!/usr/bin/env node +// render-wattajetta.mjs — the composition. A fighter jet made entirely +// of water, ~3 minutes. Starts ON the drop. The bell runs are our +// physically-modeled FEM bells and over the flight the water hardens: +// glass → bronze → steel → stone (a granite lithophone material added +// to the engine for this track), decays tightening as it goes. A +// turntablist rides the whole thing — scratch gestures scrub slices of +// the track itself, a global warp pass drags the platter and wobbles +// the record out. +// +// Novel voices this cut: +// chimes — tubular-geometry bells in quick clusters (wind chimes) +// bloops — rising sine chirps, actual water-drip percussion +// water choir— formant-shaped sine stacks breathing "ooh" in the breaths +// underwater — bells resampled through a slow LFO, heard through water +// church — one huge church-geometry toll at the coda +// +// node pop/wattajetta/bin/render-wattajetta.mjs → out/wattajetta.mp3 +// node pop/wattajetta/bin/render-wattajetta.mjs --score → print the engine score +// +// Arc (bars): +// 0–11 drop A glass bells · silver bowls +// 12–15 breath 1 chimes · flyby · platter drag · baby-scratch at 11.5 +// 16–27 drop B glass + bloops · super scratch at 15 +// 28–31 breath 2 water choir enters · bronze toll · slow scrub +// 32–43 drop C bronze, tighter decays · brass bowls · bloops denser +// 44–47 breath 3 platter drag · steel toll · choir swells +// 48–59 drop D steel, tight runs · super scratch 2 at 47 · flyby +// 60–63 breath 4 underwater bells · stone toll · choir +// 64–75 drop E STONE — granite runs + stone bowls, everything in +// 76–79 coda church bell tolls · kick thins to downbeats +// 80–95 mist stone tolls · vinyl wobble · underwater scrub · +// one last glass bell closing the circle + +import { writeFileSync, readFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderBell } from "../../lib/bell.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(HERE, "../out"); +mkdirSync(OUT, { recursive: true }); + +const SR = 48000; +const TAU = 2 * Math.PI; +const BPM = 138; +const BEAT = 60 / BPM; +const BAR = BEAT * 4; +const BARS = 96; +const DUR = BARS * BAR + 5.5; // room for the last toll to settle + +// deterministic sprinkle — same track every bake +let _s = 0xa7757e77; +const rnd = () => ((_s = (_s * 1664525 + 1013904223) >>> 0) / 4294967296); + +// e minor pentatonic — the bell runs live here +const DROP_NOTES = ["E5", "G5", "A5", "B5", "D6", "E6"]; +const CHIME_NOTES = ["E6", "G6", "A6", "B6", "D7"]; +const BLOOP_HZ = [164.81, 196.0, 220.0, 246.94, 293.66]; // e3 pentatonic +// sub roots: e1 g1 a1 d2 under each 2-bar phrase +const ROOTS = [41.203, 48.999, 55.0, 73.416]; +const ROOT_BELLS = ["E4", "G4", "A4", "D5"]; + +const kicks = []; // t0 f0 f1 sweep ampDb hole decay +const sines = []; // t0 dur f0 f1 ampDb atk rel pan0 pan1 vibHz vibCents +const noises = []; // t0 dur f0 f1 q peakDb atk rel pan +const bells = []; // { t, note, vel, pan, gain, material, geometry, dur, warp? } + +const bar = (n) => n * BAR; + +// the flight plan: each drop hardens the water a little more +const DROPS = [ + { a: 0, z: 12, mat: "glass", bowl: "silver", durs: [3.2, 3.2, 1.4], density: 0.85, gainDb: -14, bloops: false }, + { a: 16, z: 28, mat: "glass", bowl: "silver", durs: [3.2, 1.4, 0.9], density: 0.9, gainDb: -13, bloops: 0.4 }, + { a: 32, z: 44, mat: "bronze", bowl: "brass", durs: [1.8, 1.8, 1.0], density: 0.9, gainDb: -13, bloops: 0.6 }, + { a: 48, z: 60, mat: "steel", bowl: "gold", durs: [1.2, 1.2, 0.7], density: 0.95, gainDb: -12.5, bloops: 0.6 }, + { a: 64, z: 76, mat: "stone", bowl: "stone", durs: [1.6, 1.0, 0.7], density: 0.95, gainDb: -11.5, bloops: 0.75 }, +]; +const BREATHS = [12, 28, 44, 60]; // 4-bar rests between drops + +// ── kicks: halftime 1+3 early (the part we loved), then the flight +// ramps — drop C flips to four-on-the-floor halfway, and the steel +// and stone drops drive full trance 4/4 ──────────────────────────── +const kickAt = (t) => kicks.push([t, 118, 41, 0.075, -2, 0.012, 0.28]); +for (const d of DROPS) + for (let b = d.a; b < d.z; b++) { + const fourFloor = d.a >= 48 || (d.a === 32 && b >= 38); + if (fourFloor) for (let k = 0; k < 4; k++) kickAt(bar(b) + k * BEAT); + else { kickAt(bar(b)); kickAt(bar(b) + 2 * BEAT); } + } +// coda: the downbeat only, letting the stone ring between hits +for (let b = 76; b < 80; b++) kickAt(bar(b)); + +// ── sub bass: the fuselage — root + quiet octave, one note per 2 bars ─ +function subNote(t, dur, freq, db) { + sines.push([t, dur, freq, freq, db, 0.06, 0.25, 0, 0, 0, 0]); + sines.push([t, dur, freq * 2, freq * 2, db - 7, 0.06, 0.25, 0, 0, 4.5, 6]); +} +for (const d of DROPS) + for (let b = d.a; b < d.z; b += 2) { + const root = ROOTS[((b - d.a) / 2) % 4]; + if (d.a >= 48) { + // trance gallop: offbeat eighth stabs — the sidechain makes the pump + for (let bb = b; bb < b + 2; bb++) + for (let k = 0; k < 4; k++) + sines.push([bar(bb) + (k + 0.5) * BEAT, 0.16, root * 2, root * 2, -8, 0.005, 0.08, 0, 0, 0, 0]); + sines.push([bar(b), 2 * BAR - 0.08, root, root, -9, 0.06, 0.25, 0, 0, 0, 0]); // sub floor stays + } else { + subNote(bar(b), 2 * BAR - 0.08, root, -7.5); + } + } +subNote(bar(76), 4 * BAR - 0.1, ROOTS[0], -9); // coda holds the root +// each breath: the sub lets go and rises an octave into vapor +for (const b of BREATHS) + sines.push([bar(b), 4 * BAR, 41.203, 82.407, -11, 1.0, 2.2, 0, 0, 0, 0]); + +// ── bell runs: eighth-note pentatonic random walks; decay varies per +// strike (a few land choked), material follows the flight plan ────── +function bellRun(b, d) { + let idx = Math.floor(rnd() * DROP_NOTES.length); + for (let e = 0; e < 8; e++) { + idx = Math.max(0, Math.min(DROP_NOTES.length - 1, idx + (rnd() < 0.5 ? -1 : 1))); + if (rnd() < d.density) + bells.push({ + t: bar(b) + e * 0.5 * BEAT, + note: DROP_NOTES[idx], + vel: 0.55 + rnd() * 0.3, + pan: (rnd() * 2 - 1) * 0.7, + gain: Math.pow(10, d.gainDb / 20), + material: d.mat, geometry: "glass", + dur: d.durs[Math.floor(rnd() * d.durs.length)], + }); + } +} +for (const d of DROPS) for (let b = d.a; b < d.z; b++) bellRun(b, d); + +// ── bloops: actual water-drip percussion — a sine chirping UP into its +// note the way a drip rings a pool, on the swung offbeats ──────────── +function bloop(t, hz, db) { + sines.push([t, 0.11, hz * 0.55, hz, db, 0.006, 0.07, (rnd() * 2 - 1) * 0.5, 0, 0, 0]); +} +for (const d of DROPS) { + if (!d.bloops) continue; + for (let b = d.a; b < d.z; b++) + for (const slot of [1.75, 3.25, 3.75]) + if (rnd() < d.bloops) bloop(bar(b) + slot * BEAT, BLOOP_HZ[Math.floor(rnd() * BLOOP_HZ.length)], -17); +} + +// ── water choir: formant-shaped sine stacks — an "ooh" breathed out of +// pure sines. Harmonic amplitudes follow /u/ vowel resonances; +// detuned pairs beat slowly like the surface of held water ────────── +const FORMANTS = [[300, 170], [870, 260], [2250, 430]]; // [center, bandwidth] — wide = breathy +function choirNote(t, dur, f0, db, pan) { + for (let h = 1; h <= 8; h++) { + const fh = f0 * h; + let a = 0; + for (const [fc, bw] of FORMANTS) a += Math.exp(-(((fh - fc) / bw) ** 2)); + a = Math.max(a, 0.04) / h; // spectral tilt keeps it breathy, not brassy + const hdb = db + 20 * Math.log10(a); + if (hdb < -60) continue; + for (const det of [-5, 0, 5]) // detuned trio — thick slow beating + sines.push([t, dur, fh * Math.pow(2, det / 1200), fh * Math.pow(2, det / 1200), + hdb - 9, dur * 0.35, dur * 0.4, pan, pan, 4.8, 7]); + } +} +function choirChord(t, dur, db) { + choirNote(t, dur, 82.407, db - 2, 0); // e2 + choirNote(t, dur, 123.47, db - 3, -0.35); // b2 + choirNote(t, dur, 164.81, db, 0.35); // e3 + choirNote(t, dur, 196.0, db - 4, -0.15); // g3 — the minor color +} +choirChord(bar(28), 4 * BAR, -19); +choirChord(bar(44), 4 * BAR, -17); +choirChord(bar(60), 4 * BAR, -17); +choirChord(bar(80), 8 * BAR, -20); // the mist hums it one last time + +// ── chimes: tubular bells in quick clusters — wind chimes on the +// canopy rail, breath sections and the mist ───────────────────────── +function chimeCluster(t, count, db) { + let tt = t; + for (let i = 0; i < count; i++) { + bells.push({ t: tt, note: CHIME_NOTES[Math.floor(rnd() * CHIME_NOTES.length)], + vel: 0.35 + rnd() * 0.25, pan: (rnd() * 2 - 1) * 0.8, + gain: Math.pow(10, db / 20), + material: "aluminum", geometry: "tubular", dur: 2.2 }); + tt += 0.03 + rnd() * 0.09; + } +} +for (const b of BREATHS) { chimeCluster(bar(b) + BEAT, 5, -20); chimeCluster(bar(b + 2) + 2 * BEAT, 4, -22); } +for (const b of [82, 86, 91]) chimeCluster(bar(b), 3, -23); + +// ── bowls: a low anchor each 2-bar downbeat, material morphing too ──── +for (const d of DROPS) + for (let b = d.a; b < d.z; b += 2) + bells.push({ t: bar(b), note: ROOT_BELLS[((b - d.a) / 2) % 4], + vel: 0.7, pan: (b / 2) % 2 === 0 ? -0.2 : 0.2, + gain: Math.pow(10, -16 / 20), + material: d.bowl, geometry: "bowl", dur: 4.5 }); + +// ── breaths, coda, mist: long bowls + tolls; the next material always +// tolls once before its drop arrives; breath 4 bells come through +// water (warp = slow LFO resample) ────────────────────────────────── +const longBowl = (t, note, mat, db, dur = 7, extra = {}) => + bells.push({ t, note, vel: 0.8, pan: 0, gain: Math.pow(10, db / 20), + material: mat, geometry: "bowl", dur, ...extra }); +longBowl(bar(12), "E4", "silver", -15); +longBowl(bar(44), "E4", "brass", -15); +longBowl(bar(60), "E4", "gold", -15, 7, { warp: { depth: 0.03, hz: 0.7 } }); +longBowl(bar(76), "E3", "stone", -14, 8); +longBowl(bar(80), "E4", "silver", -17, 9, { warp: { depth: 0.035, hz: 0.55 } }); +// foreshadow tolls — the ear learns each material before its drop +const foreshadow = [[30, "E5", "bronze"], [46, "B5", "steel"], [62, "G5", "stone"]]; +for (const [b, n, m] of foreshadow) + bells.push({ t: bar(b), note: n, vel: 0.6, pan: 0.3, gain: Math.pow(10, -16 / 20), + material: m, geometry: "glass", dur: 2.5 }); +// breath sparkles — breath 4's come through water +for (const b of [13.5, 14.5, 29, 45.5, 61, 62.5]) + bells.push({ t: bar(b), note: DROP_NOTES[Math.floor(rnd() * DROP_NOTES.length)], + vel: 0.5, pan: (rnd() * 2 - 1) * 0.5, gain: Math.pow(10, -17 / 20), + material: "glass", geometry: "glass", dur: 3.2, + ...(b >= 60 ? { warp: { depth: 0.04, hz: 0.8 } } : {}) }); +// coda: the church bell — one huge toll, then its echo +bells.push({ t: bar(76), note: "E3", vel: 0.9, pan: 0, gain: Math.pow(10, -13 / 20), + material: "bronze", geometry: "church", dur: 9 }); +bells.push({ t: bar(78), note: "E3", vel: 0.55, pan: 0.15, gain: Math.pow(10, -17 / 20), + material: "bronze", geometry: "church", dur: 7 }); +// mist: stone tolls slowing, underwater, one last glass bell full circle +for (const [b, n] of [[80, "E5"], [81, "G5"], [82.5, "A5"], [84, "E5"], [86, "D6"], [88.5, "G5"], [91, "A5"]]) + bells.push({ t: bar(b), note: n, vel: 0.55, pan: (rnd() * 2 - 1) * 0.4, + gain: Math.pow(10, (-16 - (b - 80) * 0.4) / 20), + material: "stone", geometry: "glass", dur: 2.0, + warp: { depth: 0.03, hz: 0.6 } }); +bells.push({ t: bar(93), note: "E6", vel: 0.45, pan: 0, gain: Math.pow(10, -19 / 20), + material: "glass", geometry: "glass", dur: 3.2, warp: { depth: 0.05, hz: 0.5 } }); + +// ── flybys: sine dopplers at the seams, quiet ───────────────────────── +function flyby(t, dir) { + const d = 2 * BAR; + sines.push([t, d, 760, 185, -17, d * 0.4, d * 0.45, -dir, dir, 0, 0]); +} +flyby(bar(13), 1); +flyby(bar(29), -1); +flyby(bar(53), 1); +flyby(bar(70), -1); + +// ── spray, whisper level: a steam breath at each drop, an exhale out ── +for (const d of DROPS) noises.push([bar(d.a), 2.0, 2000, 300, 0.8, -23, 0.015, 1.8, 0]); +noises.push([bar(80), 16 * BAR + 4, 3000, 500, 0.8, -27, 2.0, 14 * BAR, 0]); +noises.push([0, DUR, 1100, 1100, 0.6, -41, 4, 4, 0]); + +// ── bake + render the engine part ───────────────────────────────────── +const fmt = (rows) => rows.map((r) => " " + r.map((v) => +v.toFixed(5)).join(" ")).join("\n"); +const score = [ + `sr ${SR}`, + `dur ${DUR.toFixed(3)}`, + `normpeak 0.82`, + `fadein 0.004`, + `fadeout 4.0`, + `sidechain 0.015 0.2 -6`, + `kick ${kicks.length}`, fmt(kicks), + `sine ${sines.length}`, fmt(sines), + `noise ${noises.length}`, fmt(noises), +].join("\n") + "\n"; + +const scorePath = resolve(OUT, "wattajetta.score.txt"); +writeFileSync(scorePath, score); +if (process.argv.includes("--score")) { console.log(score); process.exit(0); } +console.log(`baked ${kicks.length} kicks, ${sines.length} sines, ${noises.length} sprays, ${bells.length} bells`); + +const rawPath = resolve(OUT, "wattajetta.f32.raw"); +const kickPath = resolve(OUT, "wattajetta.kick.f32.raw"); +const r = spawnSync("node", [resolve(HERE, "../c/run-c.mjs"), scorePath, "--raw", rawPath, "--kickraw", kickPath], { stdio: "inherit" }); +if (r.status !== 0) process.exit(1); + +// ── mix the bells over the engine render ────────────────────────────── +// The kick rides its own bus: scratches and ducks only ever touch the +// musical bus, so the instinctual rhythm is never destroyed — we layer, +// we don't overwrite. One FEM render per unique voicing (cached), +// copies mixed per strike with equal-power pan. A strike with `warp` is +// resampled through a slow sine LFO first — a bell heard through water. +const raw = readFileSync(rawPath); +const mix = new Float32Array(raw.buffer, raw.byteOffset, raw.length / 4); +const kraw = readFileSync(kickPath); +const kickBus = new Float32Array(kraw.buffer, kraw.byteOffset, kraw.length / 4); +const ns = mix.length / 2; + +const bank = new Map(); +const bellFor = ({ note, material, geometry, dur }) => { + const k = `${note}/${material}/${geometry}/${dur}`; + if (!bank.has(k)) { + console.log(` bell ${note} (${material}/${geometry} ×${dur}s)…`); + bank.set(k, renderBell({ note, material, geometry, dur })); + } + return bank.get(k); +}; + +function underwater(buf, depth, hz) { + const out = new Float32Array(buf.length); + let pos = 0; + for (let i = 0; i < out.length; i++) { + const j = Math.floor(pos); + if (j >= buf.length - 1) break; + const fr = pos - j; + out[i] = buf[j] * (1 - fr) + buf[j + 1] * fr; + pos += 1 + depth * Math.sin(TAU * hz * (i / SR)); + } + return out; +} + +for (const s of bells) { + let { L, R } = bellFor(s); + if (s.warp) { L = underwater(L, s.warp.depth, s.warp.hz); R = underwater(R, s.warp.depth, s.warp.hz); } + const at = Math.floor(s.t * SR); + const a = (s.pan + 1) * 0.25 * Math.PI; + const gl = Math.cos(a) * s.gain * s.vel; + const gr = Math.sin(a) * s.gain * s.vel; + const n = Math.min(L.length, ns - at); + for (let i = 0; i < n; i++) { + mix[2 * (at + i)] += L[i] * gl; + mix[2 * (at + i) + 1] += R[i] * gr; + } +} + +// ── super scratching: a hand scrubs the record — the playhead scrubs +// a slice of the track itself. Position follows smoothstep gestures +// (zero velocity at the turnarounds, like a real wrist), a +// transformer gate chops, and the mix ducks under the hand ────────── +const smooth = (p) => p * p * (3 - 2 * p); +function scratchAt(tOut, srcT, srcDur, gestures, gainDb) { + const src0 = Math.floor(srcT * SR); + const srcN = Math.floor(srcDur * SR); + const gain = Math.pow(10, gainDb / 20); + let t = tOut; + for (const g of gestures) { + const n = Math.floor(g.dur * SR); + const at = Math.floor(t * SR); + for (let i = 0; i < n; i++) { + const p = smooth(i / n); + const pos = (g.from + (g.to - g.from) * p) * srcN; + const j = src0 + Math.floor(pos); + if (j < 0 || j >= ns - 1 || at + i >= ns) continue; + const fr = pos - Math.floor(pos); + // 5ms gate ramps so the chop never clicks + const edge = Math.min(i, n - i) / (0.005 * SR); + const gate = (g.gate ?? 1) * Math.min(1, edge); + const duck = 1 - 0.65 * gate; + for (let ch = 0; ch < 2; ch++) { + const v = mix[2 * j + ch] * (1 - fr) + mix[2 * (j + 1) + ch] * fr; + mix[2 * (at + i) + ch] = mix[2 * (at + i) + ch] * duck + v * gate * gain; + } + } + t += g.dur; + } +} +const E = BEAT / 2; // an eighth +const SUPER = [ + { dur: E * 2, from: 0, to: 0.8 }, { dur: E * 2, from: 0.8, to: 0.05 }, + { dur: E, from: 0.05, to: 0.7 }, { dur: E, from: 0.7, to: 0.1, gate: 0.7 }, + { dur: E * 0.5, from: 0.1, to: 0.5 }, { dur: E * 0.5, from: 0.5, to: 0.15 }, + { dur: E * 0.5, from: 0.15, to: 0.6, gate: 0.8 }, { dur: E * 0.5, from: 0.6, to: 0.05 }, + { dur: E * 0.25, from: 0.05, to: 0.3 }, { dur: E * 0.25, from: 0.3, to: 0.1 }, + { dur: E * 0.25, from: 0.1, to: 0.35 }, { dur: E * 0.25, from: 0.35, to: 0 }, +]; +// end of drop A: quick baby-scratch fill on the opening slice +scratchAt(bar(11) + 2 * BEAT, bar(0), BEAT, [ + { dur: E, from: 0, to: 0.6 }, { dur: E, from: 0.6, to: 0.1 }, + { dur: E * 0.5, from: 0.1, to: 0.5 }, { dur: E * 0.5, from: 0.5, to: 0 }, + { dur: E, from: 0, to: 1 }, +], -4); +// the SUPER scratches: each one scrubs the NEXT drop's downbeat in +scratchAt(bar(15), bar(16), BEAT, SUPER, -3); +scratchAt(bar(47), bar(48), BEAT, SUPER, -3); +// breath 2: slow half-speed scrub, barely gated +scratchAt(bar(29.5), bar(16), 2 * BEAT, [ + { dur: BEAT, from: 0, to: 0.5, gate: 0.5 }, { dur: BEAT, from: 0.5, to: 0.15, gate: 0.4 }, +], -10); +// mist: one last underwater scrub of the stone drop +scratchAt(bar(84.5), bar(64), 2 * BEAT, [ + { dur: BEAT * 1.5, from: 0, to: 0.4, gate: 0.45 }, { dur: BEAT, from: 0.4, to: 0.1, gate: 0.35 }, +], -11); + +// ── the kick returns: layered back on top, untouched by every hand ──── +for (let i = 0; i < mix.length; i++) mix[i] += kickBus[i]; + +// ── crunch: the water hardens sonically too — a tanh waveshaper whose +// blend ramps in across drops C→E, eases off for the coda and mist. +// Distorted sines grow harmonics; the material morph gets teeth ───── +{ + const DRIVE = 2.4; + const norm = Math.tanh(DRIVE); + const blendAt = (t) => { + if (t < bar(24)) return 0; + if (t < bar(64)) return 0.85 * smooth((t - bar(24)) / (bar(64) - bar(24))); + if (t < bar(76)) return 0.85; + if (t < bar(80)) return 0.85 - 0.6 * smooth((t - bar(76)) / (4 * BAR)); + return 0.15; + }; + for (let f = 0; f < ns; f++) { + const m = blendAt(f / SR); + if (m <= 0) continue; + for (let ch = 0; ch < 2; ch++) { + const x = mix[2 * f + ch]; + mix[2 * f + ch] = x * (1 - m) + (Math.tanh(x * DRIVE) / norm) * m; + } + } +} + +// ── warping: the whole record is wet — a global variable-rate pass. +// The platter drags mid-breath (a hand on the vinyl), the tempo +// RAMPS ~6% faster through the back half (138 → ~146 effective BPM, +// kick and all — the rhythm stays locked because everything warps +// together), and the mist rides a warped-record wobble out ────────── +{ + const rate = (t) => { + let r = 1; + for (const c of [bar(13.5), bar(45.5)]) { + const d = (t - c) / 1.4; + if (d > -1 && d < 1) r *= 1 - 0.38 * (0.5 + 0.5 * Math.cos(Math.PI * d)) ** 2; + } + // the trance accelerando: ramp in across drop C, hold through stone, + // let go across the coda + if (t >= bar(28) && t < bar(48)) r *= 1 + 0.06 * smooth((t - bar(28)) / (bar(48) - bar(28))); + else if (t >= bar(48) && t < bar(76)) r *= 1.06; + else if (t >= bar(76) && t < bar(80)) r *= 1 + 0.06 * (1 - smooth((t - bar(76)) / (4 * BAR))); + if (t > bar(80)) { + const w = Math.min(1, (t - bar(80)) / (2 * BAR)); + r *= 1 + 0.012 * w * Math.sin(TAU * 0.8 * t); + } + return r; + }; + const warped = new Float32Array(mix.length); + let pos = 0; + for (let i = 0; i < ns; i++) { + const j = Math.floor(pos); + if (j >= ns - 1) break; + const fr = pos - j; + warped[2 * i] = mix[2 * j] * (1 - fr) + mix[2 * (j + 1)] * fr; + warped[2 * i + 1] = mix[2 * j + 1] * (1 - fr) + mix[2 * (j + 1) + 1] * fr; + pos += rate(i / SR); + } + mix.set(warped); +} + +// re-peak after the layers so the master sees a sane level +let peak = 0; +for (let i = 0; i < mix.length; i++) { const v = Math.abs(mix[i]); if (v > peak) peak = v; } +if (peak > 0.9) { const g = 0.9 / peak; for (let i = 0; i < mix.length; i++) mix[i] *= g; } + +const mixedPath = resolve(OUT, "wattajetta.mixed.f32.raw"); +writeFileSync(mixedPath, Buffer.from(mix.buffer, mix.byteOffset, mix.length * 4)); + +// ── master: firmer than the pure cut — the crunch ramp wants a spine, +// but the opening still breathes ───────────────────────────────────── +const MASTER = [ + "highpass=f=24", + "acompressor=threshold=-19dB:ratio=2.8:attack=10:release=150:makeup=2.2:knee=6", + "equalizer=f=50:t=q:w=1.2:g=2.5", // the boom under the kick + "treble=g=1.2:f=8500", + "alimiter=limit=0.96:attack=4:release=70", + // the accelerando consumes source faster than the clock — trim the + // dead air it leaves at the tail + "areverse", "silenceremove=start_periods=1:start_threshold=-70dB", "areverse", +]; +const mp3 = resolve(OUT, "wattajetta.mp3"); +const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", mixedPath, + "-af", MASTER.join(","), "-c:a", "libmp3lame", "-q:a", "2", + "-metadata", "title=wattajetta", "-metadata", "album=pixsies", + mp3], { stdio: "inherit" }); +for (const p of [rawPath, kickPath, mixedPath]) { try { unlinkSync(p); } catch {} } +if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } +console.log(`✓ ${mp3} (glass→bronze→steel→stone · chimes/bloops/choir/underwater · scratched + warped)`); diff --git a/pop/wattajetta/c/build.sh b/pop/wattajetta/c/build.sh new file mode 100644 --- /dev/null +++ b/pop/wattajetta/c/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# build.sh — compile wattajetta.c. No -ffast-math so the numerics stay +# stable across machines. +set -euo pipefail +HERE="$(cd -- "$(dirname -- "$0")" && pwd)" +cc -O3 -std=c11 -Wall -Wextra \ + -o "$HERE/wattajetta" \ + "$HERE/wattajetta.c" \ + -lm +echo "→ $HERE/wattajetta" diff --git a/pop/wattajetta/c/run-c.mjs b/pop/wattajetta/c/run-c.mjs new file mode 100644 --- /dev/null +++ b/pop/wattajetta/c/run-c.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// run-c.mjs — render a baked wattajetta score through the C engine and +// master to mp3. The composition lives in bin/render-wattajetta.mjs; +// this is the shared DSP + finalize path. +// +// Usage: +// node pop/wattajetta/c/run-c.mjs --out +// node pop/wattajetta/c/run-c.mjs --raw + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, unlinkSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENGINE = resolve(HERE, "wattajetta"); + +const args = process.argv.slice(2); +const score = args.find((a) => !a.startsWith("--")); +const _argi = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; }; +const outMp3 = _argi("--out"); +const rawOnly = _argi("--raw"); +if (!score || (!outMp3 && !rawOnly)) { + console.error("usage: run-c.mjs --out | --raw "); + process.exit(1); +} + +// build the engine if missing or stale +const cSrc = resolve(HERE, "wattajetta.c"); +if (!existsSync(ENGINE) || statSync(cSrc).mtimeMs > statSync(ENGINE).mtimeMs) { + console.log("[run-c] building wattajetta…"); + const b = spawnSync("bash", [resolve(HERE, "build.sh")], { stdio: "inherit" }); + if (b.status !== 0) process.exit(1); +} + +const kickRaw = _argi("--kickraw"); +const rawPath = rawOnly || `${outMp3}.f32.raw`; +mkdirSync(dirname(rawPath), { recursive: true }); +const engineArgs = [score, "--raw", rawPath]; +if (kickRaw) engineArgs.push("--kickraw", kickRaw); +const r = spawnSync(ENGINE, engineArgs, { stdio: "inherit" }); +if (r.status !== 0) { console.error("✗ wattajetta failed"); process.exit(1); } +if (rawOnly) process.exit(0); + +// water master — deep sub intact, spray sparkle up top, firm ceiling +const MASTER = [ + "highpass=f=24", + "acompressor=threshold=-19dB:ratio=3:attack=10:release=140:makeup=3:knee=5", + "equalizer=f=50:t=q:w=1.2:g=3.5", // the boom under the kick + "treble=g=1.8:f=8000", // caustic glint + "alimiter=limit=0.97:attack=3:release=60", +]; + +const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", "48000", "-ac", "2", "-i", rawPath, + "-af", MASTER.join(","), "-c:a", "libmp3lame", "-q:a", "2", + "-metadata", "title=wattajetta", "-metadata", "album=pixsies", + outMp3], { stdio: "inherit" }); +try { unlinkSync(rawPath); } catch {} +if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } +console.log(`✓ ${outMp3} (C engine · water-mastered)`); diff --git a/pop/wattajetta/c/wattajetta b/pop/wattajetta/c/wattajetta new file mode 100644 --- /dev/null +++ b/pop/wattajetta/c/wattajetta diff --git a/pop/wattajetta/c/wattajetta.c b/pop/wattajetta/c/wattajetta.c new file mode 100644 --- /dev/null +++ b/pop/wattajetta/c/wattajetta.c @@ -0,0 +1,287 @@ +// wattajetta.c — the water engine. Everything in this track is made of +// water: a fighter jet, its canopy, the control stick, the spray coming +// off the wingtips. So everything in this file is made of exactly two +// materials and nothing else: +// +// SINE — water holding a shape. Phase-increment oscillators only +// (never sin(TAU*f*t) — long tracks drift). Kicks, sub bass, +// doppler flybys, droplet pings: all the same material bent +// into different vessels. +// SPRAY — water losing its shape. White noise carved by a bandpass. +// Wingtip streams, cloud punches, afterburner steam. +// +// No samples, no other waveforms, no exceptions. +// +// Build: ./build.sh +// Run: ./wattajetta --raw out.f32 (f32le stereo 48k) +// +// Score format (text, one item per line): +// sr / dur / normpeak / fadein / fadeout — globals +// sidechain — kick ducks the rest +// kick then per event: +// t0 f0 f1 sweepSec ampDb holeSec decaySec +// sine then per event: +// t0 dur f0 f1 ampDb atk rel pan0 pan1 vibHz vibCents +// noise then per event: +// t0 dur f0 f1 q peakDb atk rel pan +// Events within a voice list may be in any order; each renders into +// its own sample range independently. + +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include + +#define TAU (2.0 * M_PI) +#define BLOCK 64 + +typedef struct { double t0, f0, f1, sweep, ampDb, hole, decay; } Kick; +typedef struct { double t0, dur, f0, f1, ampDb, atk, rel, pan0, pan1, vibHz, vibCents; } Sine; +typedef struct { double t0, dur, f0, f1, q, peakDb, atk, rel, pan; } Noise; + +static void die(const char *msg) { fprintf(stderr, "wattajetta: %s\n", msg); exit(1); } + +static inline double db2lin(double db) { return pow(10.0, db / 20.0); } + +// equal-power pan: pan in [-1, 1] -> left/right gains +static inline void pan_gains(double pan, double *gl, double *gr) { + double a = (pan + 1.0) * 0.25 * M_PI; + *gl = cos(a); + *gr = sin(a); +} + +// linear attack / sustain / linear release across [0, dur] +static inline double asr_env(double dt, double dur, double atk, double rel) { + if (dt < 0 || dt >= dur) return 0; + double v = 1.0; + if (atk > 0 && dt < atk) v = dt / atk; + double tail = dur - dt; + if (rel > 0 && tail < rel) { double r = tail / rel; if (r < v) v = r; } + return v; +} + +int main(int argc, char **argv) { + const char *scorePath = NULL, *rawPath = NULL, *kickPath = NULL; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--raw") && i + 1 < argc) rawPath = argv[++i]; + else if (!strcmp(argv[i], "--kickraw") && i + 1 < argc) kickPath = argv[++i]; + else scorePath = argv[i]; + } + if (!scorePath) die("usage: wattajetta [--raw out.f32] [--kickraw kick.f32]"); + + // ── parse the score ─────────────────────────────────────────────── + FILE *f = fopen(scorePath, "r"); + if (!f) die("cannot open score"); + int sr = 48000; + double dur = 0, normpeak = 0.9, fadein = 0.004, fadeout = 2.0; + double scAtk = 0.015, scRel = 0.18, scDepthDb = 0; // 0 = no sidechain + Kick *kicks = NULL; int nK = 0; + Sine *sines = NULL; int nS = 0; + Noise *noises = NULL; int nN = 0; + + char key[64]; + while (fscanf(f, "%63s", key) == 1) { + if (!strcmp(key, "sr")) { if (fscanf(f, "%d", &sr) != 1) die("bad sr"); } + else if (!strcmp(key, "dur")) { if (fscanf(f, "%lf", &dur) != 1) die("bad dur"); } + else if (!strcmp(key, "normpeak")) { if (fscanf(f, "%lf", &normpeak) != 1) die("bad normpeak"); } + else if (!strcmp(key, "fadein")) { if (fscanf(f, "%lf", &fadein) != 1) die("bad fadein"); } + else if (!strcmp(key, "fadeout")) { if (fscanf(f, "%lf", &fadeout) != 1) die("bad fadeout"); } + else if (!strcmp(key, "sidechain")) { + if (fscanf(f, "%lf %lf %lf", &scAtk, &scRel, &scDepthDb) != 3) die("bad sidechain"); + } + else if (!strcmp(key, "kick")) { + if (fscanf(f, "%d", &nK) != 1) die("bad kick count"); + kicks = malloc(sizeof(Kick) * nK); + for (int i = 0; i < nK; i++) { + Kick *k = &kicks[i]; + if (fscanf(f, "%lf %lf %lf %lf %lf %lf %lf", + &k->t0, &k->f0, &k->f1, &k->sweep, &k->ampDb, &k->hole, &k->decay) != 7) + die("bad kick event"); + } + } + else if (!strcmp(key, "sine")) { + if (fscanf(f, "%d", &nS) != 1) die("bad sine count"); + sines = malloc(sizeof(Sine) * nS); + for (int i = 0; i < nS; i++) { + Sine *s = &sines[i]; + if (fscanf(f, "%lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf", + &s->t0, &s->dur, &s->f0, &s->f1, &s->ampDb, &s->atk, &s->rel, + &s->pan0, &s->pan1, &s->vibHz, &s->vibCents) != 11) + die("bad sine event"); + } + } + else if (!strcmp(key, "noise")) { + if (fscanf(f, "%d", &nN) != 1) die("bad noise count"); + noises = malloc(sizeof(Noise) * nN); + for (int i = 0; i < nN; i++) { + Noise *n = &noises[i]; + if (fscanf(f, "%lf %lf %lf %lf %lf %lf %lf %lf %lf", + &n->t0, &n->dur, &n->f0, &n->f1, &n->q, &n->peakDb, + &n->atk, &n->rel, &n->pan) != 9) + die("bad noise event"); + } + } + else die("unknown score key"); + } + fclose(f); + if (dur <= 0) die("empty score"); + if (nK + nS + nN == 0) die("no events"); + + long ns = (long)ceil(dur * sr); + // two busses so the kick can duck everything that isn't the kick + float *kickBus[2], *mainBus[2]; + for (int ch = 0; ch < 2; ch++) { + kickBus[ch] = calloc(ns, sizeof(float)); + mainBus[ch] = calloc(ns, sizeof(float)); + } + double dt = 1.0 / sr; + + // ── kicks: pitch-swept sine, hole attack, exponential decay ─────── + // The hole (a short linear ramp from zero) keeps the transient from + // clicking and leaves a gap the sub breathes through. + for (int i = 0; i < nK; i++) { + Kick *k = &kicks[i]; + double amp = db2lin(k->ampDb); + double tail = k->hole + k->decay * 1.5; + long a = (long)(k->t0 * sr), z = (long)((k->t0 + tail) * sr); + if (a < 0) a = 0; if (z > ns) z = ns; + double phase = 0; + for (long j = a; j < z; j++) { + double t = (double)(j - a) / sr; + double p = k->sweep > 0 ? t / k->sweep : 1.0; + if (p > 1) p = 1; + double freq = k->f0 * pow(k->f1 / k->f0, p); + phase += freq * dt; + double env = (t < k->hole) ? t / k->hole + : exp(-(t - k->hole) * 6.9 / k->decay); + float v = (float)(sin(TAU * phase) * env * amp); + kickBus[0][j] += v; + kickBus[1][j] += v; + } + } + + // ── sines: the water itself — glide, vibrato, pan sweep ─────────── + for (int i = 0; i < nS; i++) { + Sine *s = &sines[i]; + double amp = db2lin(s->ampDb); + long a = (long)(s->t0 * sr), z = (long)((s->t0 + s->dur) * sr); + if (a < 0) a = 0; if (z > ns) z = ns; + double phase = 0; + for (long j = a; j < z; j++) { + double t = (double)(j - a) / sr; + double p = t / s->dur; + double freq = s->f0 * pow(s->f1 / s->f0, p); + if (s->vibCents > 0) + freq *= pow(2.0, s->vibCents * sin(TAU * s->vibHz * t) / 1200.0); + phase += freq * dt; + double env = asr_env(t, s->dur, s->atk, s->rel); + double pan = s->pan0 + (s->pan1 - s->pan0) * p; + double gl, gr; + pan_gains(pan, &gl, &gr); + double v = sin(TAU * phase) * env * amp; + mainBus[0][j] += (float)(v * gl); + mainBus[1][j] += (float)(v * gr); + } + } + + // ── spray: white noise through an RBJ bandpass, freq gliding ────── + // Constant-skirt-gain bandpass (peak = Q) normalized back down so + // peakDb means what it says. Coefficients update per 64-sample block. + for (int i = 0; i < nN; i++) { + Noise *n = &noises[i]; + double amp = db2lin(n->peakDb); + long a = (long)(n->t0 * sr), z = (long)((n->t0 + n->dur) * sr); + if (a < 0) a = 0; if (z > ns) z = ns; + uint32_t seed = 0x57A77E7 + (uint32_t)i * 2654435761u; + double z1 = 0, z2 = 0, gl, gr; + pan_gains(n->pan, &gl, &gr); + double b0c = 0, b1c = 0, b2c = 0, a1c = 0, a2c = 0; + for (long j = a; j < z; j++) { + double t = (double)(j - a) / sr; + if ((j - a) % BLOCK == 0) { + double p = t / n->dur; + double freq = n->f0 * pow(n->f1 / n->f0, p); + double fmax = sr * 0.45; + if (freq > fmax) freq = fmax; + double w0 = TAU * freq / sr; + double alpha = sin(w0) / (2.0 * n->q); + double a0 = 1.0 + alpha; + b0c = alpha / a0; b1c = 0.0; b2c = -alpha / a0; + a1c = (-2.0 * cos(w0)) / a0; + a2c = (1.0 - alpha) / a0; + } + seed = seed * 1664525u + 1013904223u; + double x = (((double)seed / 4294967296.0) * 2.0 - 1.0) * 2.4; // ≈ unity band level + double y = b0c * x + z1; + z1 = b1c * x - a1c * y + z2; + z2 = b2c * x - a2c * y; + double env = asr_env(t, n->dur, n->atk, n->rel); + double v = y * env * amp; + mainBus[0][j] += (float)(v * gl); + mainBus[1][j] += (float)(v * gr); + } + } + + // ── sidechain: every kick pushes the water aside, then it rushes + // back in — duck the main bus, never the kick bus ─────────────── + if (scDepthDb < 0 && nK > 0) { + double depth = db2lin(scDepthDb); + // kicks arrive in score order; sort by t0 so the scan below works + for (int i = 1; i < nK; i++) + for (int j = i; j > 0 && kicks[j].t0 < kicks[j - 1].t0; j--) { + Kick tmp = kicks[j]; kicks[j] = kicks[j - 1]; kicks[j - 1] = tmp; + } + int cur = 0; + for (long j = 0; j < ns; j++) { + double t = (double)j / sr; + while (cur + 1 < nK && kicks[cur + 1].t0 <= t) cur++; + double g = 1.0; + if (kicks[cur].t0 <= t) { + double d = t - kicks[cur].t0; + if (d < scAtk) g = 1.0 + (depth - 1.0) * (d / scAtk); + else if (d < scAtk + scRel) g = depth + (1.0 - depth) * ((d - scAtk) / scRel); + } + mainBus[0][j] *= (float)g; + mainBus[1][j] *= (float)g; + } + } + + // ── normalize + edge fades. The gain comes from the SUMMED peak so + // kick/main balance survives a split; with --kickraw the busses + // are written separately (kick stays pristine through the JS + // scratch layer and is summed back on top there) ──────────────── + float *out[2] = { mainBus[0], mainBus[1] }; + double peak = 0; + for (int ch = 0; ch < 2; ch++) + for (long i = 0; i < ns; i++) { + double sum = (double)out[ch][i] + kickBus[ch][i]; + if (!kickPath) out[ch][i] = (float)sum; + if (fabs(sum) > peak) peak = fabs(sum); + } + double g = peak > 0 ? normpeak / peak : 1.0; + long fi = (long)(fadein * sr), fo = (long)(fadeout * sr); + for (int ch = 0; ch < 2; ch++) { + for (long i = 0; i < ns; i++) { out[ch][i] = (float)(out[ch][i] * g); kickBus[ch][i] = (float)(kickBus[ch][i] * g); } + for (long i = 0; i < fi && i < ns; i++) { out[ch][i] *= (float)i / fi; kickBus[ch][i] *= (float)i / fi; } + for (long i = 0; i < fo && i < ns; i++) { out[ch][ns - 1 - i] *= (float)i / fo; kickBus[ch][ns - 1 - i] *= (float)i / fo; } + } + + FILE *o = rawPath ? fopen(rawPath, "wb") : stdout; + if (!o) die("cannot open output"); + float *inter = malloc(sizeof(float) * 2 * ns); + for (long i = 0; i < ns; i++) { inter[2 * i] = out[0][i]; inter[2 * i + 1] = out[1][i]; } + fwrite(inter, sizeof(float), 2 * ns, o); + if (rawPath) fclose(o); + if (kickPath) { + FILE *ko = fopen(kickPath, "wb"); + if (!ko) die("cannot open kick output"); + for (long i = 0; i < ns; i++) { inter[2 * i] = kickBus[0][i]; inter[2 * i + 1] = kickBus[1][i]; } + fwrite(inter, sizeof(float), 2 * ns, ko); + fclose(ko); + } + fprintf(stderr, "wattajetta: %d kicks, %d sines, %d sprays, %.1f s -> %s\n", + nK, nS, nN, (double)ns / sr, rawPath ? rawPath : "stdout"); + return 0; +}