diff --git a/pop/dance/bin/bake-wobble.mjs b/pop/dance/bin/bake-wobble.mjs index 16d8176c26..16354afff7 100644 --- a/pop/dance/bin/bake-wobble.mjs +++ b/pop/dance/bin/bake-wobble.mjs @@ -16,16 +16,44 @@ // node pop/dance/bin/bake-wobble.mjs --only woe # just wobblewoe // node pop/dance/bin/bake-wobble.mjs --out ~/Desktop --no-open -import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; -import { resolve } from "node:path"; +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 } from "node:os"; +import { homedir, tmpdir } from "node:os"; -import { mixEventWobble } from "../synths/wobble.mjs"; +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) { @@ -118,7 +146,7 @@ const TRACKS = { }, }; -function arrange(spec) { +function arrange(spec, engine) { const beat = 60 / spec.bpm; const bar = beat * 4; const introBars = 2; @@ -131,6 +159,10 @@ function arrange(spec) { 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; @@ -150,11 +182,10 @@ function arrange(spec) { // ── 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++) { - mixEventWobble( - { startSec: t0 + half * 2 * beat, midi: root, gain: bassGain, durSec: 2 * beat * 0.98, - preset: spec.wobble.preset, lfo: spec.wobble.lfo }, - out, { sampleRate: SR, bpm }, - ); + 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; @@ -192,14 +223,17 @@ function arrange(spec) { } } + // 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) { +function bakeTrack(key, outDir, openIt, engine) { const spec = TRACKS[key]; - console.log(`\n[bake-wobble] ${spec.title} · ${spec.bpm} BPM · wobble:${spec.wobble.preset}`); - const buf = arrange(spec); + 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; @@ -251,10 +285,11 @@ function expandHome(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.c const 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); if (p) made.push(p); } -console.log(`\n[bake-wobble] done → ${made.length}/${keys.length} track(s) in ${outDir}`); +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})`); diff --git a/pop/dance/c/.gitignore b/pop/dance/c/.gitignore new file mode 100644 index 0000000000..89b81d5584 --- /dev/null +++ b/pop/dance/c/.gitignore @@ -0,0 +1,7 @@ +# Compiled engine binary (rebuild via build.sh) +wobble + +# Engine isolation / debug renders +*.f32 +*.raw +out/ diff --git a/pop/dance/c/build.sh b/pop/dance/c/build.sh new file mode 100755 index 0000000000..530714e2ea --- /dev/null +++ b/pop/dance/c/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# build.sh — compile wobble.c. No -ffast-math so the numerics stay stable +# across machines and match the JS reference (pop/dance/synths/wobble.mjs). +set -euo pipefail +HERE="$(cd -- "$(dirname -- "$0")" && pwd)" +cc -O3 -std=c11 -Wall -Wextra \ + -o "$HERE/wobble" \ + "$HERE/wobble.c" \ + -lm +echo "→ $HERE/wobble" diff --git a/pop/dance/c/compare.mjs b/pop/dance/c/compare.mjs new file mode 100755 index 0000000000..067e751d34 --- /dev/null +++ b/pop/dance/c/compare.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// compare.mjs — prove the C wobble engine matches the JS reference +// (pop/dance/synths/wobble.mjs) sample-for-sample, within libm ulps. +// +// For each preset it builds the same bassline, renders it two ways: +// JS — renderWobble per event, summed into a Float32 buffer +// C — emitWobbleScore → wobble.c → raw f32 +// then reports max |Δ| and RMS Δ. PASS if max |Δ| < 1e-4 (float storage + +// libm ulps). Same posture as pop/nullabye/c/compare.mjs. +// +// Usage: node pop/dance/c/compare.mjs [--preset bomp] [--keep] + +import { spawnSync } from "node:child_process"; +import { existsSync, statSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +import { renderWobble, emitWobbleScore } from "../synths/wobble.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENGINE = resolve(HERE, "wobble"); +const SR = 48_000; +const BPM = 140; +const PASS_EPS = 1e-4; + +const args = process.argv.slice(2); +const argi = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; }; +const onlyPreset = argi("--preset"); +const keep = args.includes("--keep"); + +// build engine if missing or stale +const cSrc = resolve(HERE, "wobble.c"); +if (!existsSync(ENGINE) || statSync(cSrc).mtimeMs > statSync(ENGINE).mtimeMs) { + console.log("[compare] building wobble…"); + const b = spawnSync("bash", [resolve(HERE, "build.sh")], { stdio: "inherit" }); + if (b.status !== 0) process.exit(1); +} + +// a representative bassline per preset (covers detune, all LFO shapes, crush=0) +function lineFor(preset) { + const beat = 60 / BPM; + const roots = { woe: [38, 36, 34, 33], bomp: [40, 40, 43, 38], row: [33, 33, 41, 43], reese: [38, 38, 41, 36] }; + const r = roots[preset] || roots.woe; + const events = []; + for (let bar = 0; bar < 4; bar++) { + for (let half = 0; half < 2; half++) { + events.push({ + startSec: (bar * 4 + half * 2) * beat, midi: r[bar], gain: 0.95, + durSec: 2 * beat * 0.98, preset, + }); + } + } + return events; +} + +function renderJS(events) { + const score = emitWobbleScore(events, { sampleRate: SR, bpm: BPM }); + const ns = Math.ceil(parseFloat(score.match(/^dur (\S+)/m)[1]) * SR); + const out = new Float32Array(ns); + for (const ev of events) { + const seg = renderWobble(ev, { sampleRate: SR, bpm: BPM }); + const start = Math.floor((ev.startSec ?? 0) * SR); + for (let i = 0; i < seg.length; i++) { + const dst = start + i; if (dst >= 0 && dst < out.length) out[dst] += seg[i]; + } + } + return { out, score }; +} + +function renderC(score, tmp) { + const scorePath = resolve(tmp, "score.txt"); + const rawPath = resolve(tmp, "out.f32"); + writeFileSync(scorePath, score); + const r = spawnSync(ENGINE, [scorePath, "--raw", rawPath], { stdio: ["ignore", "ignore", "inherit"] }); + if (r.status !== 0) { console.error("✗ C engine failed"); process.exit(1); } + const buf = readFileSync(rawPath); + const out = new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4)); + return out; +} + +const presets = onlyPreset ? [onlyPreset] : ["woe", "bomp", "row", "reese"]; +const tmp = mkdtempSync(resolve(tmpdir(), "wobble-cmp-")); +let allPass = true; + +for (const p of presets) { + const events = lineFor(p); + const { out: js, score } = renderJS(events); + const c = renderC(score, tmp); + + const n = Math.min(js.length, c.length); + let maxAbs = 0, sumSq = 0; + for (let i = 0; i < n; i++) { + const d = Math.abs(js[i] - c[i]); + if (d > maxAbs) maxAbs = d; + sumSq += d * d; + } + const rms = Math.sqrt(sumSq / Math.max(1, n)); + const lenOk = js.length === c.length; + const pass = lenOk && maxAbs < PASS_EPS; + allPass = allPass && pass; + console.log( + `${pass ? "✓" : "✗"} ${p.padEnd(6)} samples=${n} (js ${js.length}/c ${c.length}) ` + + `maxΔ=${maxAbs.toExponential(3)} rmsΔ=${rms.toExponential(3)}`, + ); +} + +if (!keep) rmSync(tmp, { recursive: true, force: true }); +console.log(allPass ? "\nPASS — C engine matches JS reference." : "\nFAIL — divergence above threshold."); +process.exit(allPass ? 0 : 1); diff --git a/pop/dance/c/run-c.mjs b/pop/dance/c/run-c.mjs new file mode 100755 index 0000000000..beee3cb30f --- /dev/null +++ b/pop/dance/c/run-c.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// run-c.mjs — render a wobble bassline through the C engine (wobble.c) +// and master to mp3. Shared DSP + finalize path for the C wobble; the +// composition lives in the JS lane (emitWobbleScore from a note list). +// +// Usage: +// node pop/dance/c/run-c.mjs --demo bomp --out ~/Desktop/wob-c.mp3 +// node pop/dance/c/run-c.mjs --score score.txt --raw out.f32 (engine only) + +import { spawnSync } from "node:child_process"; +import { existsSync, statSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +import { emitWobbleScore } from "../synths/wobble.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENGINE = resolve(HERE, "wobble"); +const SR = 48_000; +const BPM = 140; + +const args = process.argv.slice(2); +const argi = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; }; +const demo = argi("--demo"); +let scorePath = argi("--score"); +const outMp3 = argi("--out"); +const rawOnly = argi("--raw"); +const expand = (p) => (!p ? p : p.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p); + +if (!demo && !scorePath) { console.error("usage: run-c.mjs --demo |--score --out |--raw "); process.exit(1); } +if (!outMp3 && !rawOnly) { console.error("need --out or --raw "); process.exit(1); } + +// build engine if missing or stale +const cSrc = resolve(HERE, "wobble.c"); +if (!existsSync(ENGINE) || statSync(cSrc).mtimeMs > statSync(ENGINE).mtimeMs) { + console.log("[run-c] building wobble…"); + const b = spawnSync("bash", [resolve(HERE, "build.sh")], { stdio: "inherit" }); + if (b.status !== 0) process.exit(1); +} + +// build a demo score from a preset if --demo was given +if (demo) { + const beat = 60 / BPM; + const roots = { woe: [38, 36, 34, 33], bomp: [40, 40, 43, 38], row: [33, 33, 41, 43], reese: [38, 38, 41, 36] }; + const r = roots[demo] || roots.woe; + const events = []; + for (let bar = 0; bar < 4; bar++) + for (let half = 0; half < 2; half++) + events.push({ startSec: (bar * 4 + half * 2) * beat, midi: r[bar], gain: 0.95, durSec: 2 * beat * 0.98, preset: demo }); + scorePath = resolve(HERE, `.demo-${demo}.score.txt`); + writeFileSync(scorePath, emitWobbleScore(events, { sampleRate: SR, bpm: BPM })); +} + +const rawPath = expand(rawOnly) || `${expand(outMp3)}.f32.raw`; +mkdirSync(dirname(rawPath), { recursive: true }); +const r = spawnSync(ENGINE, [scorePath, "--raw", rawPath], { stdio: "inherit" }); +if (demo) { try { unlinkSync(scorePath); } catch {} } +if (r.status !== 0) { console.error("✗ wobble engine failed"); process.exit(1); } +if (rawOnly) process.exit(0); + +// f32 mono → loudnorm + limit → 320k mp3 (same finalize as the JS bake) +const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", rawPath, + "-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", expand(outMp3)], { stdio: "inherit" }); +try { unlinkSync(rawPath); } catch {} +if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } +console.log(`✓ ${expand(outMp3)} (C engine)`); diff --git a/pop/dance/c/wobble.c b/pop/dance/c/wobble.c new file mode 100644 index 0000000000..2bec6c2361 --- /dev/null +++ b/pop/dance/c/wobble.c @@ -0,0 +1,209 @@ +// wobble.c — the wobble bass, in C. A faithful port of renderWobble in +// pop/dance/synths/wobble.mjs: a fat detuned Reese swept by a beat-synced +// RESONANT LOWPASS (the dubstep "wub wub"). +// +// Numerics mirror the JS reference op-for-op (same xorshift32 RNG stream, +// same Chamberlin SVF, same tanh/crush glue, same envelope window), so +// the C output matches the JS within libm ulps — verify with compare.mjs. +// There is no preset table here: the JS side (emitWobbleScore) pre- +// resolves every parameter and bakes the exact per-event seed into the +// score, so this engine is a dumb, exact replayer. +// +// Build: ./build.sh +// Run: ./wobble --raw out.f32 (f32le MONO 48k) +// +// Score format (text, whitespace-separated tokens): +// sr +// dur +// note +// then event lines, each 19 fields: +// t0 midi durSec gain voices detuneCents lfoHz shape lfoDepth +// cutLo cutHi q drive crush subGain edge attack decay seed +// shape: 0 sine | 1 tri | 2 saw | 3 square | 4 sh seed: uint32 + +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +typedef struct { + double t0, midi, durSec, gain; + int voices; + double detuneCents, lfoHz; + int shape; + double lfoDepth, cutLo, cutHi, q, drive; + int crush; + double subGain, edge, attack, decay; + uint32_t seed; +} Note; + +static void die(const char *msg) { fprintf(stderr, "wobble: %s\n", msg); exit(1); } + +static double midi_to_freq(double midi) { return 440.0 * pow(2.0, (midi - 69.0) / 12.0); } + +// xorshift32 — byte-for-byte the JS makeRngFromSeed stream. +static inline double rng_next(uint32_t *s) { + uint32_t x = *s; + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + *s = x; + return (double)x / 4294967295.0; // 0xffffffff, matches JS +} + +// One unipolar LFO sample in [0,1]; `sh` is the held S&H value. +static inline double lfo_value(int shape, double phase, double sh) { + switch (shape) { + case 1: return phase < 0.5 ? phase * 2.0 : 2.0 - phase * 2.0; // tri + case 2: return phase; // saw + case 3: return phase < 0.5 ? 0.0 : 1.0; // square + case 4: return sh; // sh + case 0: + default: return 0.5 + 0.5 * sin(2.0 * M_PI * phase); // sine + } +} + +// Render ONE note additively into the mono output buffer. +static void render_note(const Note *e, float *out, long ns_total, int sr) { + if (!(e->durSec > 0) || e->gain == 0.0) return; + + long durS = (long)ceil(e->durSec * sr); + long attS = (long)floor(e->attack * sr); if (attS < 1) attS = 1; + long decS = (long)floor(e->decay * sr); if (decS < 1) decS = 1; + long ns = durS > attS + decS ? durS : attS + decS; + long decayStart = ns - decS; + long startIdx = (long)floor(e->t0 * sr); + + double fund = midi_to_freq(e->midi); + int voices = e->voices < 1 ? 1 : e->voices; + double edge = e->edge; + + uint32_t rs = e->seed ? e->seed : 1; + + // Per-voice phase + increment (same rng order as the JS engine). + double *phase = malloc(sizeof(double) * voices); + double *inc = malloc(sizeof(double) * voices); + double half = (voices - 1) / 2.0; + for (int v = 0; v < voices; v++) { + phase[v] = rng_next(&rs); + double offset = half == 0.0 ? 0.0 : (v - half) / half; + double cents = offset * e->detuneCents; + inc[v] = (fund * pow(2.0, cents / 1200.0)) / sr; + } + double norm = 1.0 / voices; + + double lfoPhase = rng_next(&rs); + double shVal = rng_next(&rs); + double low = 0.0, band = 0.0; + double damp = 1.0 / (e->q > 0.5 ? e->q : 0.5); + double cutLo = e->cutLo, cutHi = e->cutHi; + double ratio = cutHi / cutLo; + double subInc = (fund * 0.5) / sr; + double subPhase = rng_next(&rs); + const double TWO_PI = 2.0 * M_PI; + double fcMax = (double)sr / 6.0; + + for (long i = 0; i < ns; i++) { + double env; + if (i < attS) env = (double)i / attS; + else if (i < decayStart) env = 1.0; + else { env = 1.0 - (double)(i - decayStart) / decS; if (env <= 0.0) break; } + + lfoPhase += e->lfoHz / sr; + if (lfoPhase >= 1.0) { lfoPhase -= 1.0; shVal = rng_next(&rs); } + double lv = lfo_value(e->shape, lfoPhase, shVal) * e->lfoDepth; + + double src = 0.0; + for (int v = 0; v < voices; v++) { + double ph = phase[v] + inc[v]; + if (ph >= 1.0) ph -= floor(ph); + phase[v] = ph; + double saw = 2.0 * ph - 1.0; + double sq = ph < 0.5 ? 1.0 : -1.0; + src += saw * (1.0 - edge) + sq * edge; + } + src *= norm; + + double fc = cutLo * pow(ratio, lv); + if (fc < 40.0) fc = 40.0; if (fc > fcMax) fc = fcMax; + double f = 2.0 * sin(M_PI * fc / sr); + double high = src - low - damp * band; + band += f * high; + low += f * band; + double s = low; + + s = tanh(s * e->drive); + if (e->crush > 0) { + double steps = pow(2.0, e->crush); + s = round(s * steps) / steps; + } + + subPhase += subInc; if (subPhase >= 1.0) subPhase -= 1.0; + double sub = sin(TWO_PI * subPhase) * e->subGain; + + double mix = s * 0.8 + sub; + if (!isfinite(mix)) { low = 0.0; band = 0.0; mix = 0.0; } + + long dst = startIdx + i; + if (dst >= 0 && dst < ns_total) out[dst] += (float)(mix * env * e->gain); + } + + free(phase); free(inc); +} + +int main(int argc, char **argv) { + const char *scorePath = NULL, *rawPath = NULL; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--raw") && i + 1 < argc) rawPath = argv[++i]; + else scorePath = argv[i]; + } + if (!scorePath) die("usage: wobble --raw out.f32"); + + FILE *f = fopen(scorePath, "r"); + if (!f) die("cannot open score"); + + int sr = 48000; + double dur = 0; + Note *notes = NULL; int nNotes = 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, "note")) { + if (fscanf(f, "%d", &nNotes) != 1) die("bad note count"); + notes = malloc(sizeof(Note) * (nNotes > 0 ? nNotes : 1)); + for (int i = 0; i < nNotes; i++) { + Note *e = ¬es[i]; + if (fscanf(f, "%lf %lf %lf %lf %d %lf %lf %d %lf %lf %lf %lf %lf %d %lf %lf %lf %lf %u", + &e->t0, &e->midi, &e->durSec, &e->gain, &e->voices, + &e->detuneCents, &e->lfoHz, &e->shape, &e->lfoDepth, + &e->cutLo, &e->cutHi, &e->q, &e->drive, &e->crush, + &e->subGain, &e->edge, &e->attack, &e->decay, &e->seed) != 19) + die("bad note line"); + } + } else die("unknown score key"); + } + fclose(f); + if (dur <= 0) die("empty score (dur <= 0)"); + + long ns = (long)ceil(dur * sr); + float *out = calloc(ns, sizeof(float)); + if (!out) die("oom"); + + for (int i = 0; i < nNotes; i++) render_note(¬es[i], out, ns, sr); + + FILE *o = rawPath ? fopen(rawPath, "wb") : stdout; + if (!o) die("cannot open output"); + fwrite(out, sizeof(float), ns, o); + if (rawPath) fclose(o); + fprintf(stderr, "wobble: %d notes, %.1f s -> %s\n", nNotes, (double)ns / sr, + rawPath ? rawPath : "stdout"); + + free(out); free(notes); + return 0; +} diff --git a/pop/dance/synths/wobble.mjs b/pop/dance/synths/wobble.mjs index 4daaf41d24..c1f015599e 100644 --- a/pop/dance/synths/wobble.mjs +++ b/pop/dance/synths/wobble.mjs @@ -95,15 +95,21 @@ function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } -// Deterministic xorshift RNG (same pattern as skrill/supersaw) — keeps -// per-voice phase + S&H reproducible per event. -function makeRng(seedStr) { +// FNV-1a → uint32 seed. Exported via wobbleSeed so the C engine +// (pop/dance/c/wobble.c) can reproduce the exact per-event RNG stream. +function fnv1a(str) { let s = 2166136261 >>> 0; - for (let i = 0; i < seedStr.length; i++) { - s ^= seedStr.charCodeAt(i); + for (let i = 0; i < str.length; i++) { + s ^= str.charCodeAt(i); s = Math.imul(s, 16777619); } - s = s >>> 0 || 1; + return (s >>> 0) || 1; +} + +// Deterministic xorshift32 from a uint32 seed — keeps per-voice phase + +// S&H reproducible per event (the same stream the C port replays). +function makeRngFromSeed(seed) { + let s = (seed >>> 0) || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s >>>= 0; @@ -112,6 +118,15 @@ function makeRng(seedStr) { }; } +// The exact uint32 seed renderWobble uses for an event — the C engine +// takes this per note so its RNG stream matches op-for-op. +export function wobbleSeed(presetName, midi, startSec) { + return fnv1a(`wobble:${presetName}:${midi}:${(startSec ?? 0).toFixed(4)}`); +} + +// LFO shape → integer code, shared with the C engine's score format. +export const LFO_SHAPE_CODES = { sine: 0, tri: 1, saw: 2, square: 3, sh: 4 }; + // One unipolar LFO sample in [0,1]. `sh` holds a value per cycle for S&H. function lfoValue(shape, phase, sh) { switch (shape) { @@ -135,41 +150,65 @@ function buildDetune(voices, detuneCents) { return t; } +// ── shared param resolver ────────────────────────────────────────────── +// The SINGLE source of truth for how an event + opts collapse to concrete +// DSP params. renderWobble (JS) and emitWobbleScore (the C feed) both go +// through this, so the JS reference and the C engine can never drift on +// preset/override precedence. Returns fully-resolved scalars only. +export function resolveWobbleParams(ev, opts = {}) { + const bpm = opts.bpm ?? DEFAULT_BPM; + const presetName = ev.preset || opts.preset || DEFAULT_PRESET; + const P = { ...(WOBBLE_PRESETS[presetName] || WOBBLE_PRESETS[DEFAULT_PRESET]), ...opts.params }; + const cutLo = Math.max(40, P.cutLo ?? 120); + return { + presetName, + attack: opts.attack ?? P.attack ?? 0.008, + decay: opts.decay ?? P.decay ?? 0.14, + voices: Math.max(1, P.voices ?? 3), + detuneCents: P.detuneCents ?? 16, + lfoHz: resolveLfoHz(ev.lfo || opts.lfo || P.lfo, bpm), + lfoShape: P.lfoShape || "sine", + lfoDepth: P.lfoDepth ?? 1.0, + cutLo, + cutHi: Math.max(cutLo + 1, P.cutHi ?? 2200), + q: P.q ?? 6, + drive: P.drive ?? 2.5, + crush: P.crush ?? 0, + subGain: P.subGain ?? 0.4, + edge: P.edge ?? 0.2, + }; +} + // ── the DSP engine ───────────────────────────────────────────────────── // Renders ONE wobble event into a fresh mono Float32Array. ev: { midi, // durSec, gain?, preset?, lfo? }. Per-note `preset`/`lfo` override opts so // a .np verb token maps straight through. export function renderWobble(ev, opts = {}) { const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; - const bpm = opts.bpm ?? DEFAULT_BPM; - const presetName = ev.preset || opts.preset || DEFAULT_PRESET; - const P = { ...(WOBBLE_PRESETS[presetName] || WOBBLE_PRESETS[DEFAULT_PRESET]), ...opts.params }; - if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) { return new Float32Array(0); } const gain = Number.isFinite(ev.gain) ? ev.gain : 1.0; if (gain === 0) return new Float32Array(0); - const attack = opts.attack ?? P.attack ?? 0.008; - const decay = opts.decay ?? P.decay ?? 0.14; + const pp = resolveWobbleParams(ev, opts); // Match bus.mjs / native envelope window: auto-extend so the linear // decay completes to true silence without an end click. const durS = Math.ceil(ev.durSec * sampleRate); - const attS = Math.max(1, Math.floor(attack * sampleRate)); - const decS = Math.max(1, Math.floor(decay * sampleRate)); + const attS = Math.max(1, Math.floor(pp.attack * sampleRate)); + const decS = Math.max(1, Math.floor(pp.decay * sampleRate)); const ns = Math.max(durS, attS + decS); const decayStart = ns - decS; const out = new Float32Array(ns); const fund = midiToFreq(ev.midi); - const lfoHz = resolveLfoHz(ev.lfo || opts.lfo || P.lfo, bpm); - const lfoDepth = P.lfoDepth ?? 1.0; - const voices = Math.max(1, P.voices ?? 3); - const detune = buildDetune(voices, P.detuneCents ?? 16); - const edge = P.edge ?? 0.2; + const lfoHz = pp.lfoHz; + const lfoDepth = pp.lfoDepth; + const voices = pp.voices; + const detune = buildDetune(voices, pp.detuneCents); + const edge = pp.edge; - const rng = makeRng(`wobble:${presetName}:${ev.midi}:${(ev.startSec ?? 0).toFixed(4)}`); + const rng = makeRngFromSeed(wobbleSeed(pp.presetName, ev.midi, ev.startSec)); // Per-voice saw phase (random start so unison doesn't cohere on attack) // and per-sample phase increment. @@ -185,9 +224,9 @@ export function renderWobble(ev, opts = {}) { let lfoPhase = rng(); const sh = { value: rng() }; let low = 0, band = 0; - const damp = 1 / Math.max(0.5, P.q ?? 6); // 1/Q → resonance - const cutLo = Math.max(40, P.cutLo ?? 120); - const cutHi = Math.max(cutLo + 1, P.cutHi ?? 2200); + const damp = 1 / Math.max(0.5, pp.q); // 1/Q → resonance + const cutLo = pp.cutLo; + const cutHi = pp.cutHi; const ratio = cutHi / cutLo; // exponential sweep span const subInc = (fund * 0.5) / sampleRate; // sub an octave down let subPhase = rng(); @@ -203,7 +242,7 @@ export function renderWobble(ev, opts = {}) { // ── beat-synced wub LFO ── lfoPhase += lfoHz / sampleRate; if (lfoPhase >= 1) { lfoPhase -= 1; sh.value = rng(); } - const lv = lfoValue(P.lfoShape || "sine", lfoPhase, sh) * lfoDepth; + const lv = lfoValue(pp.lfoShape, lfoPhase, sh) * lfoDepth; // ── Reese source: detuned saws (+ optional square edge) ── let src = 0; @@ -227,15 +266,15 @@ export function renderWobble(ev, opts = {}) { let s = low; // ── grit: tanh saturation + optional bitcrush ── - s = Math.tanh(s * (P.drive ?? 2.5)); - if (P.crush && P.crush > 0) { - const steps = Math.pow(2, P.crush); + s = Math.tanh(s * pp.drive); + if (pp.crush && pp.crush > 0) { + const steps = Math.pow(2, pp.crush); s = Math.round(s * steps) / steps; } // ── clean sine sub an octave down (unmodulated body) ── subPhase += subInc; if (subPhase >= 1) subPhase -= 1; - const sub = Math.sin(TWO_PI * subPhase) * (P.subGain ?? 0.4); + const sub = Math.sin(TWO_PI * subPhase) * pp.subGain; let mix = s * 0.8 + sub; // Trap NaN/Inf from a runaway resonant filter (cf. gm_synth lesson) — @@ -246,6 +285,38 @@ export function renderWobble(ev, opts = {}) { return out; } +// ── score emitter (the C-engine feed) ────────────────────────────────── +// Serializes events → the text score pop/dance/c/wobble.c parses. Every +// field is pre-resolved here (via resolveWobbleParams) and the per-event +// seed is baked in, so the C engine is a dumb, faithful replayer. +export function emitWobbleScore(events, opts = {}) { + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const lines = []; + let dur = 0; + for (const ev of events) { + if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) continue; + const gain = Number.isFinite(ev.gain) ? ev.gain : 1.0; + if (gain === 0) continue; + const pp = resolveWobbleParams(ev, opts); + const start = ev.startSec ?? 0; + const noteLen = Math.max(ev.durSec, pp.attack + pp.decay); + dur = Math.max(dur, start + noteLen); + const seed = wobbleSeed(pp.presetName, ev.midi, ev.startSec); + // Emit every float at full round-trippable precision (default toString) + // so the C engine's strtod yields the IDENTICAL double — sample-accurate + // start/length placement, not toFixed-quantized (which can shift a note + // by a sample at certain bar times and break bit-parity). + lines.push([ + start, ev.midi, ev.durSec, gain, + pp.voices, pp.detuneCents, pp.lfoHz, LFO_SHAPE_CODES[pp.lfoShape] ?? 0, + pp.lfoDepth, pp.cutLo, pp.cutHi, pp.q, pp.drive, pp.crush, pp.subGain, pp.edge, + pp.attack, pp.decay, seed, + ].join(" ")); + } + const header = [`sr ${sampleRate}`, `dur ${(dur + 0.05).toFixed(6)}`, `note ${lines.length}`]; + return header.concat(lines).join("\n") + "\n"; +} + // ── node-side buffer mixer (the /pop bed-render path) ────────────────── export function mixEventWobble(ev, out, opts = {}) { if (!(out instanceof Float32Array)) return;