Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296#!/usr/bin/env node// bake-wobble.mjs — bakes three short dubstep sketches that show off the// new wobble bass (pop/dance/synths/wobble.mjs)://// wobblewoe — D minor lament, slow 1/4 triangle wob, supersaw pad// wobblebomp — E minor bounce, hard 1/8 square gate, supersaw stabs// wobblrow — A minor roll, 1/8-triplet sine wob, sinepower lead//// Bottom-up compositional (cf. the /pop rule): every voice is an AC// instrument driven through the buffer-synth bus — the wobble for the// bass, supersaw/sinepower for the toppings, hand-rolled kick/snare/hat// for the kit. One mono buffer per track → loudnorm → 320k mp3.//// Usage:// node pop/dance/bin/bake-wobble.mjs # all three → ~/Documents/Shelf/wobble-out// node pop/dance/bin/bake-wobble.mjs --only woe # just wobblewoe// node pop/dance/bin/bake-wobble.mjs --out ~/Desktop --no-open
import { writeFileSync, readFileSync, mkdirSync, unlinkSync, existsSync, statSync, mkdtempSync, rmSync } from "node:fs";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";import { spawnSync } from "node:child_process";import { homedir, tmpdir } from "node:os";
import { mixEventWobble, emitWobbleScore } from "../synths/wobble.mjs";import { mixEventSupersaw } from "../synths/supersaw.mjs";import { mixEventSinePower } from "../synths/sinepower.mjs";
const SR = 48_000;const C_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../c");const C_ENGINE = resolve(C_DIR, "wobble");
// Render a whole wobble-bass layer into `out`, either through the JS// reference (mixEventWobble per event) or the C engine (emitWobbleScore →// wobble.c → raw f32, added in). Both are sample-identical (compare.mjs).function renderWobbleLayer(events, out, opts, engine) { if (engine !== "c") { for (const ev of events) mixEventWobble(ev, out, opts); return; } if (!existsSync(C_ENGINE) || statSync(resolve(C_DIR, "wobble.c")).mtimeMs > statSync(C_ENGINE).mtimeMs) { console.log("[bake-wobble] building C wobble engine…"); const b = spawnSync("bash", [resolve(C_DIR, "build.sh")], { stdio: "inherit" }); if (b.status !== 0) throw new Error("C engine build failed"); } const tmp = mkdtempSync(resolve(tmpdir(), "wob-bake-")); const scorePath = resolve(tmp, "score.txt"); const rawPath = resolve(tmp, "out.f32"); writeFileSync(scorePath, emitWobbleScore(events, opts)); const r = spawnSync(C_ENGINE, [scorePath, "--raw", rawPath], { stdio: ["ignore", "ignore", "inherit"] }); if (r.status !== 0) { rmSync(tmp, { recursive: true, force: true }); throw new Error("C engine failed"); } const buf = readFileSync(rawPath); const seg = new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4)); for (let i = 0; i < seg.length && i < out.length; i++) out[i] += seg[i]; rmSync(tmp, { recursive: true, force: true });}
// ── tiny deterministic RNG (for hat/snare noise) ───────────────────────function makeRng(seed) { let s = seed >>> 0 || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s >>>= 0; s ^= s << 5; s >>>= 0; return (s >>> 0) / 0xffffffff; };}
// ── hand-rolled kit (kick/snare/hat) straight into the buffer ──────────function kick(out, t, gain = 1.0) { const start = Math.floor(t * SR); const dur = 0.34, decay = 0.30; const n = Math.ceil(dur * SR); let phase = 0; for (let i = 0; i < n; i++) { const dst = start + i; if (dst < 0 || dst >= out.length) continue; const k = i / SR; const env = Math.exp(-k / decay); // pitch drop 115 → 46 Hz over the first ~70 ms (the "thump"). const f = 46 + (115 - 46) * Math.exp(-k / 0.030); phase += f / SR; if (phase >= 1) phase -= 1; let s = Math.sin(2 * Math.PI * phase); s = Math.tanh(s * 1.8); // a touch of click/drive out[dst] += s * env * gain; }}
function snare(out, t, gain = 1.0, seed = 1) { const start = Math.floor(t * SR); const dur = 0.22, decay = 0.13; const n = Math.ceil(dur * SR); const rng = makeRng(seed * 2654435761); let low = 0, band = 0; const f = 2 * Math.sin(Math.PI * 1800 / SR), damp = 0.4; let body = 0; for (let i = 0; i < n; i++) { const dst = start + i; if (dst < 0 || dst >= out.length) continue; const k = i / SR; const env = Math.exp(-k / decay); // band-passed noise (the rattle) ... const w = rng() * 2 - 1; const high = w - low - damp * band; band += f * high; low += f * band; // ... over a short 190 Hz tonal body (the crack). body += 190 / SR; if (body >= 1) body -= 1; const tone = Math.sin(2 * Math.PI * body) * Math.exp(-k / 0.05); out[dst] += (band * 0.9 + tone * 0.5) * env * gain; }}
function hat(out, t, gain = 0.4, seed = 7) { const start = Math.floor(t * SR); const dur = 0.05, decay = 0.022; const n = Math.ceil(dur * SR); const rng = makeRng(seed * 40503); let prev = 0; for (let i = 0; i < n; i++) { const dst = start + i; if (dst < 0 || dst >= out.length) continue; const env = Math.exp(-(i / SR) / decay); const w = rng() * 2 - 1; const hp = w - prev; prev = w; // crude one-pole highpass → metallic out[dst] += hp * env * gain; }}
// ── track specs ────────────────────────────────────────────────────────// roots: one MIDI bass root per bar (a 4-bar progression, looped).// chord: semitone stack added above each root for the topping voice.const TRACKS = { woe: { title: "wobblewoe", bpm: 140, reps: 6, roots: [38, 36, 34, 33], // D2 C2 Bb1 A1 — minor lament descent chordOffsets: [12, 15, 19], // root+oct, +min3, +5 (above) wobble: { preset: "woe", lfo: "1/4" }, topping: "pad", // supersaw pad chords }, bomp: { title: "wobblebomp", bpm: 140, reps: 7, roots: [40, 40, 43, 38], // E2 E2 G2 D2 — Em bounce chordOffsets: [12, 15, 19], wobble: { preset: "bomp", lfo: "1/8" }, topping: "stab", // supersaw offbeat stabs }, row: { title: "wobblrow", bpm: 140, reps: 7, roots: [33, 33, 41, 43], // A1 A1 F2 G2 — Am roll chordOffsets: [12, 15, 19], wobble: { preset: "row", lfo: "1/8t" }, topping: "lead", // sinepower rolling arp },};
function arrange(spec, engine) { const beat = 60 / spec.bpm; const bar = beat * 4; const introBars = 2; const bodyBars = spec.roots.length * spec.reps; const totalBars = introBars + bodyBars; const tailSec = 1.2; const totalSec = totalBars * bar + tailSec; const out = new Float32Array(Math.ceil(totalSec * SR)); const bpm = spec.bpm;
const rootAt = (barIdx) => spec.roots[((barIdx % spec.roots.length) + spec.roots.length) % spec.roots.length];
// Collect the wobble-bass events so the whole layer renders through one // engine (JS or C) at the end — the rest of the kit/toppings is JS. const wobbleEvents = [];
for (let b = 0; b < totalBars; b++) { const t0 = b * bar; const isIntro = b < introBars; const root = rootAt(b - introBars);
// ── drums (half-time): kick on 1 + ghost, snare on 3, hats on 8ths ── if (!isIntro || b === introBars - 1) { kick(out, t0 + 0 * beat, 1.0); kick(out, t0 + 2.5 * beat, 0.55); // syncopated ghost snare(out, t0 + 2 * beat, 0.85, b + 1); // backbeat for (let h = 0; h < 8; h++) { const accent = h % 2 === 0 ? 0.32 : 0.5; // offbeat lift hat(out, t0 + h * 0.5 * beat, accent, b * 8 + h + 3); } }
// ── wobble bass: two half-bar notes so it re-attacks mid-bar ── const bassGain = isIntro ? 0.7 : 1.0; for (let half = 0; half < 2; half++) { wobbleEvents.push({ startSec: t0 + half * 2 * beat, midi: root, gain: bassGain, durSec: 2 * beat * 0.98, preset: spec.wobble.preset, lfo: spec.wobble.lfo, }); }
if (isIntro) continue;
// ── topping ── if (spec.topping === "pad") { // one slow chord per bar, low in the mix, an octave-ish up. for (const off of spec.chordOffsets) { mixEventSupersaw( { startSec: t0, midi: root + off + 12, gain: 0.16, durSec: bar * 0.98 }, out, { sampleRate: SR, preset: "pad" }, ); } } else if (spec.topping === "stab") { // supersaw stabs on the offbeats (the "& of 2" and "& of 4"). for (const beatPos of [1.5, 3.5]) { for (const off of spec.chordOffsets) { mixEventSupersaw( { startSec: t0 + beatPos * beat, midi: root + off + 12, gain: 0.22, durSec: beat * 0.5 }, out, { sampleRate: SR, preset: "stab" }, ); } } } else if (spec.topping === "lead") { // rolling 1/8-triplet arp over the chord tones — answers the wob. const tones = [root + 12, root + 15, root + 19, root + 24]; const tripletSec = beat / 3; for (let s = 0; s < 12; s++) { const midi = tones[s % tones.length]; mixEventSinePower( { startSec: t0 + s * tripletSec, midi, gain: 0.16, durSec: tripletSec * 0.9 }, out, { sampleRate: SR, preset: "lead" }, ); } } }
// Render the collected wobble-bass layer through the chosen engine. renderWobbleLayer(wobbleEvents, out, { sampleRate: SR, bpm }, engine);
return out;}
// ── master + encode ─────────────────────────────────────────────────────function bakeTrack(key, outDir, openIt, engine) { const spec = TRACKS[key]; console.log(`\n[bake-wobble] ${spec.title} · ${spec.bpm} BPM · wobble:${spec.wobble.preset} · engine:${engine}`); const buf = arrange(spec, engine);
// peak-normalize before handing to ffmpeg loudnorm. let peak = 0; for (let i = 0; i < buf.length; i++) { const a = Math.abs(buf[i]); if (a > peak) peak = a; } if (peak > 0) { const g = 0.89 / peak; for (let i = 0; i < buf.length; i++) buf[i] *= g; }
mkdirSync(outDir, { recursive: true }); const raw = resolve(outDir, `.${spec.title}.f32.raw`); const mp3 = resolve(outDir, `${spec.title}.mp3`); const bytes = Buffer.alloc(buf.length * 4); for (let i = 0; i < buf.length; i++) bytes.writeFloatLE(buf[i], i * 4); writeFileSync(raw, bytes);
// f32 → loudnorm -14 LUFS + gentle limit → 320k mp3 (Spotify-ready glue). const ff = spawnSync("ffmpeg", [ "-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", raw, "-af", "loudnorm=I=-14:TP=-1.5:LRA=11,alimiter=limit=0.94:attack=8:release=120:level=disabled", "-c:a", "libmp3lame", "-b:a", "320k", mp3, ], { stdio: "inherit" }); try { unlinkSync(raw); } catch {} if (ff.status !== 0) { console.error(`✗ ffmpeg failed for ${spec.title}`); return null; }
const dur = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", mp3], { encoding: "utf8" }); console.log(`✓ ${mp3} (${parseFloat(dur.stdout || "0").toFixed(1)}s)`); if (openIt) spawnSync("open", ["-a", "QuickTime Player", mp3]); return mp3;}
// ── CLI ──────────────────────────────────────────────────────────────────const argv = process.argv.slice(2);const flags = {};for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags[k] = next; i++; } else flags[k] = true; }}function expandHome(p) { if (!p || p === true) return undefined; if (p === "~") return homedir(); if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); return p;}
const outDir = expandHome(flags.out) || resolve(homedir(), "Documents/Shelf/wobble-out");const openIt = !flags["no-open"];const engine = flags.engine === "c" ? "c" : "js"; // --engine c routes the bass through wobble.cconst aliases = { woe: "woe", wobblewoe: "woe", bomp: "bomp", wobblebomp: "bomp", row: "row", wobblrow: "row" };const only = flags.only ? aliases[String(flags.only).toLowerCase()] : null;const keys = only ? [only] : ["woe", "bomp", "row"];
const made = [];for (const k of keys) { const p = bakeTrack(k, outDir, openIt, engine); if (p) made.push(p); }console.log(`\n[bake-wobble] done → ${made.length}/${keys.length} track(s) in ${outDir} (wobble engine: ${engine})`);