diff --git a/pop/dance/.gitignore b/pop/dance/.gitignore new file mode 100644 index 0000000000..02bb8323c1 --- /dev/null +++ b/pop/dance/.gitignore @@ -0,0 +1,3 @@ +# Generated audio + render scratch — regenerable, large, never committed. +out/ +*.mp4 diff --git a/pop/dance/README.md b/pop/dance/README.md new file mode 100644 index 0000000000..27cc8507ea --- /dev/null +++ b/pop/dance/README.md @@ -0,0 +1,79 @@ +# dance + +audio-only dance tracks built bottom-up from AC instruments. one AC vision per track, compressed to a 4-bar melodic theme over a four-on-the-floor bed. + +opens on **trance** as the first sub-format. future sub-formats (house, techno, dnb, hyperpop) land alongside if and when they earn a finished track. + +## format spec — trance + +- **length**: ~1:30 (radio edit). canonical trance club edits are 6–8 minutes; we compress without sacrificing the breakdown → build → drop arc +- **tempo**: 138 BPM, 4/4 (range 132–142) +- **key**: minor — A minor / E minor / F# minor by default +- **structure**: intro (8) → break 1 (16) → build 1 (8) → drop 1 (16) → break 2 (8) → build 2 (4) → drop 2 (16) → outro (4) bars +- **bed**: kick (4-on-the-floor) + off-beat sub bass + closed hat + supersaw lead + saw pad, sidechained +- **vocal**: jeffrey-pvc — one-shot hook phrase in the drop + spoken word in break 1. not rapped, not fully sung +- **output**: single mp3 per track in `out/.mp3`. no video + +## source → track + +each track corresponds to one AC vision from the platter, same as the big-pictures lane — but compressed to a *melodic theme* rather than a rap hook. + +``` +papers/arxiv-/.tex + → pop/dance/.txt (hook phrase + breakdown spoken word, if any) + → pop/dance/.np (notepat score: theme + arrangement markers) + → pop/dance/out/.mp3 (mix) +``` + +the `.np` file carries the **supersaw theme** as the primary content — every note in the theme is a syllable-free pitch event. when vocal lines exist, they get their own lines in the file. + +## arrangement notation + +the `.np` file gets section markers as comments, so the bed builder knows when to drop the kick, open the filter, drop the snare roll: + +``` +# intro 8 +g g g g g g g g + +# break 1 16 +A4:- E5:- D5:- C5:- ... + +# build 1 8 [riser, snare-roll, kick-out-last-bar] +... + +# drop 1 16 [full-mix] +A4 E5 D5 C5 ... + +# break 2 8 +... +``` + +bracket flags tell `recap/bin/trance.mjs` which engine pieces to wire in for each section. unflagged sections inherit the default for their block type. + +## pipeline (planned) + +`recap/bin/trance.mjs` — mirrors the `trap.mjs` cli pattern. cached per step. + +``` +read paper + → distill one AC vision → 4-bar theme + (optional) hook phrase + → write .np score with arrangement markers + → render trance bed (recap/bin/trance.mjs) + → vocal stem (if any): /api/say with jeffrey-pvc + → WhisperX align (only if vocal exists) + → sidechain compress pad/bass under the kick + → mix (bed + vocal) + → mp3 +``` + +## the one new voice + +trance needs a **supersaw** — 5–7 detuned sawtooth voices in unison. it doesn't exist in AC yet. landing this voice is the prerequisite for any track in this lane. likely path: extend `recap/bin/`'s synth helpers (or add `pop/dance/synths/supersaw.mjs`) with a parameterized `{ voices, detuneCents, mix }` saw stack. + +## vocal source + +trance vocal isn't rap. jeffrey-pvc's calm/descriptive register fits the breakdown spoken word and the one-shot hook drop. WhisperX alignment is only used when there *is* a vocal — instrumental trance tracks skip the alignment step entirely. + +## tracks + +none yet. see [`STUDY.md`](STUDY.md) for the genre study + arrangement law. first candidate paper TBD. diff --git a/pop/dance/STUDY.md b/pop/dance/STUDY.md new file mode 100644 index 0000000000..8d96f07581 --- /dev/null +++ b/pop/dance/STUDY.md @@ -0,0 +1,167 @@ +# study · how to make a good trance track + +**Last updated**: 2026-05-14 +**Author**: @jeffrey + +study-first. no bed yet. the goal of this file is to pin down what makes a trance track *work* before any mp3 lands in `pop/dance/out/`. once these rules are written down, the bed (`recap/bin/trance.mjs`) and the score format follow naturally. + +## why trance (and why first for the `dance/` lane) + +- four-on-the-floor + off-beat bass + supersaw lead is the **most legible** dance idiom — every part of the machinery is audible to a first-time listener +- the genre lives or dies on a *single repeated theme*. that maps directly onto AC's posture: one vision per track, compressed to a hook that survives looping +- the tension/release engine (breakdown → build → drop) is a structural argument, not a vibe. it can be specified, scored, and tested — which is what we want from a research-lane track +- jeffrey-pvc's "calm + descriptive" register fits trance better than trap. trance vocals are often spoken or one-shot phrases, not 16-bar rap cadence. lower risk to land +- canonical trance lives at ~138 BPM. that's close enough to the trap bed's 140 BPM that the existing `percussion.mjs` kit and `recap/bin/` tooling carry over with minor tweaks + +## the anatomy of a good trance track + +the body of this document. each piece below is non-negotiable for the idiom — drop any one of them and the track stops sounding like trance. + +### 1. kick + +- four-on-the-floor — kick on every beat, no swing, no ghost notes +- fundamental at ~55–65 Hz with a tight transient click around 1.5–2 kHz +- short tail (~150–200ms). the kick has to *clear* before the off-beat bass hits +- AC primitive: `percussion.mjs` letter `c` (TR-808-leaning) — already correct. mix it hot + +### 2. off-beat bass — the engine + +- hits on the "and" of every beat: positions 3, 7, 11, 15 of a 16-step grid +- short, plucked, single sub-octave note. usually root of the chord with occasional 5th +- **sidechain-ducked by the kick** — this is what creates the pumping "breathing" feel +- without the off-beat bass, four-on-the-floor sounds like house, not trance + +### 3. supersaw lead — the song + +- 5–7 detuned sawtooth voices, unison, slight chorus/ensemble +- carries the **melodic theme** — usually a 4-bar or 8-bar repeating phrase +- the theme is the song. if a listener can hum it after one breakdown, the track worked +- *AC gap*: no supersaw voice exists yet. `sinebells` is too clean, the piano bank is wrong tonally. **first new voice to build for this lane** (`pop/dance/synths/supersaw.mjs`) + +### 4. pad + +- long sustained chord pad, sidechain-ducked to the kick +- warm analog character — string ensemble, choir, soft saw stack +- carries the harmonic motion underneath the lead theme +- can substitute for the supersaw during the breakdown — same chord, different texture +- *AC primitive*: `sinebells` with long release approximates it; a proper "saw pad" voice would be better + +### 5. hi-hats + +- closed hat on the off-beats (between kicks), 8th-note resolution at minimum +- 16th-note hats in the build sections only — saving the density for impact +- open hat hits on the "and of 4" or "and of 2-and-4" to mark phrase ends +- AC primitives: `g` (closed hat), `a` (open hat) + +### 6. white-noise riser & snare roll — the connective tissue + +- white-noise riser sweeps from low to high frequency over 8–16 bars during builds +- snare roll *doubles in subdivision* each bar: 8ths → 16ths → 32nds → silence +- kick drops out for the last 1–2 bars of the build for maximum anticipation +- the drop on bar 1 of the next section is the **payoff** — full mix, no filter, kick back + +### 7. sidechain compression + +- not negotiable. pad + bass + sometimes lead duck under every kick +- creates the "engine" feel — the track *breathes* with the kick +- mechanically: every kick triggers a ~150ms volume duck on the pad/bass bus + +## the arrangement law + +a good trance track is an *argument*: introduce the theme stripped, then prove it in the drop. the radio edit and the club edit follow the same law at different scales. + +``` +club edit (6–8 minutes) radio edit (~1:30 — our format) +───────────────────── ─────────────────────────────── +intro 32 bars intro 8 bars +break 1 32 bars break 1 16 bars +build 1 16 bars build 1 8 bars +drop 1 32 bars drop 1 16 bars +break 2 32 bars break 2 8 bars +build 2 16 bars build 2 4 bars +drop 2 32 bars drop 2 16 bars +outro 32 bars outro 4 bars +``` + +at 138 BPM, our 80-bar radio edit ≈ **1:23**, which is inside the `pop/` ~1:30 spec. + +the breakdown is where the *theme* lives — exposed, no drums, pad + supersaw alone. the drop is where the *engine* lives — kick, off-beat bass, supersaw, pad, all sidechained, full mix. the listener should be able to hear the breakdown alone and the drop alone and recognize they're the same song. + +## key, scale, and emotional center + +- **minor key** — A minor, E minor, F# minor most common. trance is melancholy-resolving-to-euphoria, not happy-major-key +- **simple chord progression** — i / VI / III / VII (Am / F / C / G in A minor) or i / VII / VI / VII. four chords, looped. the simplicity is the point +- the supersaw theme can use the natural minor or melodic minor — both work +- avoid: cheerful major-key resolutions, complex jazz chords, modal mixture. trance is not about chord substitution + +## vocal posture for jeffrey-pvc + +trance vocal is rarely rap. options for jeffrey-pvc, in order of difficulty: + +1. **one-shot hook phrases** (easiest, recommended first) — 4–8 word phrases dropped into the breakdown and the drop. e.g. *"the music is real"* over the supersaw theme. heavy reverb tail, low in the mix, possibly pitch-shifted up an octave +2. **spoken breakdown** — short prose section over the pad during break 1, before the drums return. jeffrey-pvc's calm/descriptive register works here without modification +3. **sung melodic line** (hardest) — jeffrey-pvc tries to *sing* the supersaw theme. likely needs heavy autotune + per-syllable pitch correction (the existing `vocal-post.mjs` infrastructure). save for track 2 or 3 +4. **instrumental** — no vocal at all. valid for trance. fastest path to a finished track, but loses jeffrey's voice as the AC signature + +start with option 1 + 2 combined. that's the standard trance arrangement: spoken/sung verse in the breakdown, hook phrase repeated in the drop. + +## the theme — the actual song + +the lead theme is what people remember. canonical examples to study (third-party, references only, not committed): + +- **Robert Miles — "Children"** (1995): the piano phrase. 4 bars, ascending, minor key. the entire track exists to set up that line +- **Sasha & Emerson — "Scorchio"** (2000): supersaw lead theme, 8 bars, breakdown exposes it +- **Above & Beyond — "Sun & Moon"** (2011): vocal-led trance, but the supersaw theme between vocal phrases is the load-bearing element +- **Eric Prydz — "Opus"** (2015): 9-minute build, theme is the entire track +- **Anjunabeats catalog** generally: progressive trance with strong melodic themes + +the theme for an AC trance track should compress *one AC vision* into a 4-bar singable phrase, same as the big-pictures hook compresses one paper into one rapped line. the lyric (if any) sits on top of the theme. the theme is the actual song. + +## what to compose vs. what already exists + +| component | status | notes | +|-----------|--------|-------| +| kick (TR-808) | ✅ exists | `percussion.mjs` letter `c` | +| closed hat | ✅ exists | `percussion.mjs` letter `g` | +| open hat | ✅ exists | `percussion.mjs` letter `a` | +| snare (for roll) | ✅ exists | `percussion.mjs` letter `d` | +| sidechain bus | 🛠 to build | duck pad/bass on every kick — ffmpeg sidechaincompress or buffer-level ducking in node | +| supersaw lead | ❌ to build | 5–7 detuned saws, unison, ~10–25 cents detune spread. new voice | +| saw pad | ❌ to build | saw stack with long attack/release, low-pass filtered. could share code with supersaw | +| off-beat sub bass | ⚠ partial | sinebells can fake it; a proper saw bass voice would be better | +| white-noise riser | 🛠 to build | filtered noise with rising cutoff over N bars | +| filter automation | 🛠 to build | low-pass cutoff sweep during breakdown → build | + +**the one new instrument worth writing**: the supersaw. everything else is either already in `percussion.mjs` or assemblable from existing voices + ffmpeg filtering. + +## ~1:30 compression — what we sacrifice + +the radio edit above gives us 80 bars at 138 BPM = ~1:23. compared to a 6-minute club edit we lose: + +- the long intro that lets DJs blend tracks (we don't need it) +- the second breakdown's depth (we keep it but cut from 32 → 8 bars — still a breath) +- repetition. a club track loops the drop 32 bars to fill the dance floor. we only need 16 bars per drop to make the point + +what we **keep**: the breakdown → build → drop arc, the theme, the engine, both drops. losing any one of those breaks the genre. + +## next steps + +1. land this study + the lane README (`pop/dance/README.md`) — done in this commit +2. pick the first AC vision to compress into a trance theme. candidate: same as plork, but expressed as a 4-bar minor-key supersaw phrase instead of a rap hook. lets us A/B the two beds on the same source +3. write the supersaw voice (`pop/dance/synths/supersaw.mjs` or extend `recap/bin/`). 5-saw unison, parameterized detune cents, parameterized voice count +4. write the trance bed builder (`recap/bin/trance.mjs`). same shape as `trap.mjs`: read a score, mix the bed, output bed-only mp3 +5. write the first track's score: `pop/dance/.np` with the theme + a score of the arrangement (intro/break/build/drop block markers) +6. vocal pass — jeffrey-pvc one-shot hook + breakdown spoken word. WhisperX align. sidechain duck under the kick +7. mix → `pop/dance/out/.mp3` + +## open questions + +- supersaw via 7 detuned sawtooth oscillators sums to a fairly aliased signal at higher pitches. do we need band-limited synthesis (PolyBLEP), or is the aliasing part of the character? (probably keep it — vintage hardware aliases too) +- sidechain in node: ffmpeg `sidechaincompress` works at file level, but in-the-mix sidechain (per-kick ducking on the pad bus) is cleaner done at buffer-mixing time. `trap.mjs` already mixes buffers in node — extend that path +- key choice for track 1: A minor is the most-canonical, F# minor is more emotional. pick after the theme is sketched, not before +- do we want a "psy-trance" sub-sub-genre at 142 BPM with the rolling bassline? probably not for track 1. revisit after the standard trance template is proven +- vocal-as-instrument: should the supersaw theme *be* a pitch-shifted jeffrey-pvc vowel formant stack? interesting research thread — punts to `pop/voice/` + +--- + +*maintained by @jeffrey — update when the trance lane proves or pivots* diff --git a/pop/dance/bin/build.mjs b/pop/dance/bin/build.mjs new file mode 100755 index 0000000000..879753d616 --- /dev/null +++ b/pop/dance/bin/build.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +// build.mjs — versioned build orchestrator for the dance pop tracks. +// +// Each invocation picks the NEXT build number for a given track and +// writes ALL produced artifacts into an isolated build directory: +// +// ~/Desktop/builds//b/ +// .mp3 — rendered audio +// .assets/struct.json — composition struct (always paired) +// .assets/... — lane previews + title chars +// -cover.mp4 — rendered cover video +// manifest.json — what params were used (CLI + paths + git sha) +// +// Build numbers are zero-padded 3-digit (b001, b002, …) and auto- +// increment per track. Nothing is overwritten — every build is a +// distinct directory on disk. The renderer scripts (trance.mjs + +// cover-video.mjs) are invoked with --out pointing into the build dir +// so they write directly to the right place. +// +// Usage: +// node pop/dance/bin/build.mjs trancenwaltz +// node pop/dance/bin/build.mjs trancenwaltzi --note "war progression v8" +// node pop/dance/bin/build.mjs trancenwaltz --audio-only +// node pop/dance/bin/build.mjs trancenwaltz --video-only --reuse-audio b007 +// +// Flags: +// --note "..." free-form label saved into manifest.json +// --audio-only skip the video render (just produce the MP3 + struct) +// --video-only skip the audio render — uses the audio from --reuse-audio +// --reuse-audio bNNN point the video render at a prior build's audio +// --no-prelude skip the head-down/laptops-closed prelude overlay +// +// Per-track CONFIG below defines: audio-renderer flags (meter, mode), +// section illustration set, prelude path, hide-lanes, BPM, title. + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, writeFileSync, copyFileSync } from "node:fs"; +import { resolve, dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../.."); +const BUILDS_ROOT = `${homedir()}/Desktop/builds`; + +const argv = process.argv.slice(2); +if (argv.length === 0 || argv[0].startsWith("--")) { + console.error("usage: build.mjs [flags]"); + console.error(" = trancenwaltz | trancenwaltzi"); + process.exit(1); +} +const NAME = argv.shift(); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } +} + +// ── per-track configuration ────────────────────────────────────────── +const SECDIR_ROOT = `${homedir()}/Documents/Working Desktop/gens`; +function illusMap(secDir, variant = "v8") { + const pairs = []; + for (const sec of ["intro", "break1", "build1", "drop1", "break2", "build2", "drop2", "outro"]) { + const p = join(secDir, sec, "gens", `${variant}.png`); + if (existsSync(p)) pairs.push(`${sec}=${p}`); + } + return pairs.join(","); +} +// Each video format declares its own canvas size, the variant suffix +// of the section illustrations to use (square gets a 1:1 native set, +// vertical gets the 2:3 portrait set), and the prelude image at the +// matching aspect. The first entry is the default cover; later entries +// suffix the output filename (e.g. -cover-vertical.mp4). +const CONFIGS = { + trancenwaltz: { + title: "trancenwaltz", + bpm: "137.143", + // Sung-vox stem is pre-rendered via pop/dance/bin/vocal.mjs and + // saved to ~/Desktop/trance-sung-vox.mp3. trance.mjs mixes it in + // via --vocal-stem when present. + audio: [ + "--meter", "3", + "--vocal-stem", `${homedir()}/Desktop/trance-sung-vox.mp3`, + ], + sectionsDir: `${SECDIR_ROOT}/trancenwaltz-sections`, + hideLanes: null, + formats: [ + { + suffix: null, // default cover — no suffix → -cover.mp4 + size: "1500x1500", + variant: "v10", // 1024x1024 native square illustrations (diverse pixie casting) + prelude: `${SECDIR_ROOT}/trancenwaltz-sections/intro-prelude/gens/v10.png`, + }, + { + suffix: "vertical", + size: "1080x1920", + variant: "v8", // 1024x1536 native portrait illustrations + prelude: `${SECDIR_ROOT}/trancenwaltz-sections/intro-prelude/gens/v1.png`, + }, + ], + }, + trancenwaltzi: { + title: "trancenwaltzi", + bpm: "137.143", + audio: ["--mode", "chill", "--meter", "3"], + sectionsDir: `${SECDIR_ROOT}/trancenwaltzi-sections`, + hideLanes: "piano", + formats: [ + { + suffix: null, + size: "1500x1500", + variant: "v9", + prelude: null, // chill mix has no greeting → no prelude swap + }, + ], + }, +}; +const CFG = CONFIGS[NAME]; +if (!CFG) { console.error(`unknown track '${NAME}'. options: ${Object.keys(CONFIGS).join(", ")}`); process.exit(1); } + +// ── pick next build number ─────────────────────────────────────────── +const trackRoot = `${BUILDS_ROOT}/${NAME}`; +mkdirSync(trackRoot, { recursive: true }); +const existingBuilds = readdirSync(trackRoot) + .filter((d) => /^b\d{3}$/.test(d)) + .map((d) => parseInt(d.slice(1), 10)) + .sort((a, b) => a - b); +const nextNum = (existingBuilds[existingBuilds.length - 1] ?? 0) + 1; +const buildId = `b${String(nextNum).padStart(3, "0")}`; +const buildDir = `${trackRoot}/${buildId}`; +mkdirSync(buildDir, { recursive: true }); + +const audioOut = `${buildDir}/${NAME}.mp3`; +const videoOut = `${buildDir}/${NAME}-cover.mp4`; + +console.log(`▸ build ${NAME}/${buildId} → ${buildDir}`); + +// ── git sha for the manifest ───────────────────────────────────────── +let gitSha = null; +try { + gitSha = execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: REPO, encoding: "utf8" }).trim(); +} catch {} + +// ── audio render ────────────────────────────────────────────────────── +let audioCmd = null; +if (!flags["video-only"]) { + audioCmd = ["node", `${REPO}/recap/bin/trance.mjs`, ...CFG.audio, "--out", audioOut]; + console.log(`▸ audio · ${audioCmd.join(" ")}`); + const r = spawnSync(audioCmd[0], audioCmd.slice(1), { stdio: "inherit" }); + if (r.status !== 0) { console.error("✗ audio render failed"); process.exit(r.status || 1); } +} else if (flags["reuse-audio"]) { + const srcDir = `${trackRoot}/${flags["reuse-audio"]}`; + if (!existsSync(`${srcDir}/${NAME}.mp3`)) { + console.error(`✗ --reuse-audio ${flags["reuse-audio"]} not found at ${srcDir}/${NAME}.mp3`); + process.exit(1); + } + // Copy the audio + struct into this build dir so the video render + // sees them locally and the build dir is self-contained. + copyFileSync(`${srcDir}/${NAME}.mp3`, audioOut); + mkdirSync(`${buildDir}/${NAME}.assets`, { recursive: true }); + copyFileSync(`${srcDir}/${NAME}.assets/struct.json`, `${buildDir}/${NAME}.assets/struct.json`); + console.log(`▸ audio · REUSED from ${flags["reuse-audio"]}`); +} else { + console.error("✗ --video-only requires --reuse-audio "); + process.exit(1); +} + +// ── video render(s) — one per declared format, each with its own +// variant (square vs portrait illustrations) ──── +const videoCmds = []; +const extraOutputs = {}; +if (!flags["audio-only"]) { + for (const fmt of CFG.formats || []) { + const ILLUS_MAP = illusMap(CFG.sectionsDir, fmt.variant); + const introIllus = `${CFG.sectionsDir}/intro/gens/${fmt.variant}.png`; + if (!existsSync(introIllus)) { + console.error(`✗ missing intro illustration for variant ${fmt.variant}: ${introIllus}`); + process.exit(1); + } + const out = fmt.suffix + ? videoOut.replace(/\.mp4$/, `-${fmt.suffix}.mp4`) + : videoOut; + if (fmt.suffix) extraOutputs[fmt.suffix] = out; + const cmd = [ + "node", `${REPO}/pop/dance/bin/cover-video.mjs`, + "--track", audioOut, + "--illustration", introIllus, + "--illustrations", ILLUS_MAP, + "--title", CFG.title, + "--bpm", CFG.bpm, + "--size", fmt.size, + "--out", out, + ]; + if (fmt.prelude && !flags["no-prelude"] && existsSync(fmt.prelude)) { + cmd.push("--prelude", fmt.prelude); + } + if (CFG.hideLanes) { + cmd.push("--hide-lanes", CFG.hideLanes); + } + videoCmds.push({ kind: fmt.suffix || "square", out, cmd, fmt }); + } + for (const job of videoCmds) { + console.log(`▸ video [${job.kind} · ${job.fmt.size} · variant=${job.fmt.variant}] · ${job.cmd.join(" ")}`); + const r = spawnSync(job.cmd[0], job.cmd.slice(1), { stdio: "inherit" }); + if (r.status !== 0) { console.error(`✗ video [${job.kind}] render failed`); process.exit(r.status || 1); } + } +} else { + console.log(`▸ video · SKIPPED (--audio-only)`); +} + +// ── manifest ────────────────────────────────────────────────────────── +const manifest = { + name: NAME, + buildId, + buildNum: nextNum, + builtAt: new Date().toISOString(), + gitSha, + note: flags.note || null, + config: CFG, + formats: (CFG.formats || []).map((f) => ({ ...f, prelude: f.prelude && !flags["no-prelude"] ? f.prelude : null })), + audioCmd, + videoCmds: videoCmds.map((j) => ({ kind: j.kind, out: j.out, cmd: j.cmd, ok: existsSync(j.out) })), + reuseAudio: flags["reuse-audio"] || null, + outputs: { + audio: existsSync(audioOut) ? audioOut : null, + video: existsSync(videoOut) ? videoOut : null, + struct: existsSync(`${buildDir}/${NAME}.assets/struct.json`) ? `${buildDir}/${NAME}.assets/struct.json` : null, + ...Object.fromEntries(Object.entries(extraOutputs).map(([k, v]) => [`video_${k}`, existsSync(v) ? v : null])), + }, +}; +writeFileSync(`${buildDir}/manifest.json`, JSON.stringify(manifest, null, 2)); + +console.log(`✓ ${NAME}/${buildId} complete`); +if (manifest.outputs.audio) console.log(` audio · ${manifest.outputs.audio}`); +if (manifest.outputs.video) console.log(` video · ${manifest.outputs.video}`); diff --git a/pop/dance/bin/cover-video.mjs b/pop/dance/bin/cover-video.mjs new file mode 100755 index 0000000000..ae84eca3c6 --- /dev/null +++ b/pop/dance/bin/cover-video.mjs @@ -0,0 +1,1964 @@ +#!/usr/bin/env node +// cover-video.mjs — per-frame canvas-rendered "score-train" music +// video. Draws each frame programmatically via node-canvas and pipes +// raw RGBA into ffmpeg for encoding. This replaces the pre-rendered +// PNG approach so we get pixel-perfect alignment AND can animate +// events (note flashes on trigger, etc.) without resampling. +// +// Layout (top → bottom): +// • illustration (ken-burns drift) — top 1000 px +// • char-by-char bouncing title typography (floats over the scene) +// • score panel (1000 → 1500 px): multi-lane piano-roll-style +// timeline that scrolls horizontally past a fixed center playhead. +// Notes flash brighter at their trigger time, fading back to the +// base color over ~120 ms so the rhythm reads visually. +// • duration label at bottom-left +// +// Inputs: +// --illustration path to raw illustration PNG (no typography) +// --track path to the rendered MP3 +// --struct path to .struct.json (defaults to .assets/struct.json) +// --title title string +// --bpm beats per minute +// --out output mp4 path + +import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import { createCanvas, loadImage } from "canvas"; + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } +} + +function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +const ILLUSTRATION = expandHome(flags.illustration); +// Optional per-section illustrations — comma-separated `name=path` +// pairs. The visualizer crossfades between them as the song's +// musical section shifts. Sections not mapped fall back to the +// default --illustration. +const SECTION_ILLUSTRATIONS = (() => { + const m = new Map(); + const raw = flags["illustrations"]; + if (!raw) return m; + for (const pair of String(raw).split(",")) { + const [k, v] = pair.split("="); + if (k && v) m.set(k.trim(), expandHome(v.trim())); + } + return m; +})(); +const TRACK = expandHome(flags.track); +function locateStruct(track) { + if (!track) return null; + const stem = track.replace(/\.mp3$/, ""); + const sub = `${stem}.assets/struct.json`; + const legacy = `${stem}.struct.json`; + if (existsSync(sub)) return sub; + if (existsSync(legacy)) return legacy; + return sub; +} +const STRUCT = expandHome(flags.struct) || locateStruct(TRACK); +const OUT = expandHome(flags.out) || (TRACK ? TRACK.replace(/\.mp3$/, "-cover.mp4") : null); +const TITLE = (flags.title || "track").toString(); +const BPM = Number(flags.bpm ?? 137.143); +const FPS = Number(flags.fps ?? 30); +// Comma-separated lane keys to omit from the timeline (e.g. piano +// for tracks where it only fires in a small slice of the song). +const HIDE_LANES = new Set( + (flags["hide-lanes"] || "").toString().split(",").map((s) => s.trim()).filter(Boolean) +); + +if (!ILLUSTRATION || !existsSync(ILLUSTRATION)) { + console.error(`✗ --illustration is required and must exist (got: ${ILLUSTRATION})`); + process.exit(1); +} +if (!TRACK || !existsSync(TRACK)) { + console.error(`✗ --track is required and must exist`); + process.exit(1); +} +if (!STRUCT || !existsSync(STRUCT)) { + console.error(`✗ --struct is required and must exist (default: .assets/struct.json)`); + process.exit(1); +} + +// Probe track duration. +const probe = spawnSync("ffprobe", [ + "-v", "error", + "-show_entries", "format=duration", + "-of", "csv=p=0", + TRACK, +], { encoding: "utf8" }); +const duration = parseFloat(probe.stdout.trim()) || 60; +const struct = JSON.parse(readFileSync(STRUCT, "utf8")); + +// ── pre-decode raw audio + amplitude envelope ──────────────────────── +// Decode the audio at AUDIO_SR Hz mono. We keep BOTH: +// • raw audio samples — used to draw actual waveform shapes inside +// each SFX event rectangle so the timeline reads as a mini-DAW. +// • per-60Hz normalized RMS — drives the title bounce + scene wobble. +const AUDIO_SR = 4000; // 4 kHz mono — gives ~125 µs resolution for the SFX waveform display +console.log(`▸ extracting audio @ ${AUDIO_SR} Hz`); +const decRaw = spawnSync("ffmpeg", [ + "-hide_banner", "-loglevel", "error", + "-i", TRACK, + "-f", "f32le", "-ar", String(AUDIO_SR), "-ac", "1", + "-", +], { encoding: "buffer", maxBuffer: 1024 * 1024 * 256 }); +let envelope = new Float32Array(Math.ceil(duration * 60) + 4); +let audioRaw = new Float32Array(0); +let audioPeak = 1; +if (decRaw.status === 0 && decRaw.stdout) { + audioRaw = new Float32Array(decRaw.stdout.buffer, decRaw.stdout.byteOffset, decRaw.stdout.byteLength / 4); + // Track peak so the waveform display can normalize amplitudes. + for (let i = 0; i < audioRaw.length; i++) { + const a = Math.abs(audioRaw[i]); + if (a > audioPeak) audioPeak = a; + } + // Compute per-60-Hz RMS over a ~30 ms window centered on each frame. + const ENV_OUT_SR = 60; + const winSamples = Math.floor(0.030 * AUDIO_SR); + const stepSamples = AUDIO_SR / ENV_OUT_SR; + envelope = new Float32Array(Math.ceil(duration * ENV_OUT_SR) + 4); + for (let i = 0; i < envelope.length; i++) { + const center = Math.floor(i * stepSamples); + let sum = 0, n = 0; + for (let j = -winSamples; j <= winSamples; j++) { + const idx = center + j; + if (idx < 0 || idx >= audioRaw.length) continue; + sum += audioRaw[idx] * audioRaw[idx]; + n++; + } + envelope[i] = Math.sqrt(sum / Math.max(1, n)); + } + // Normalize envelope so peak = 1.0 (relative loudness across track). + let envMax = 0; + for (let i = 0; i < envelope.length; i++) if (envelope[i] > envMax) envMax = envelope[i]; + if (envMax > 0) for (let i = 0; i < envelope.length; i++) envelope[i] /= envMax; +} +function envAt(t) { + const idx = Math.floor(t * 60); + if (idx < 0 || idx >= envelope.length) return 0; + return envelope[idx]; +} + +// YWFT-Processing's TTF only resolves correctly under ImageMagick's +// freetype path — node-canvas (cairo + fontconfig) renders it as +// missing-glyph tofu boxes. So we pre-rasterize all YWFT text via +// magick to transparent PNGs at startup and composite the resulting +// images per-frame instead of using canvas text APIs. The rest of +// the labels (section names, lane gutters, duration) use the +// default system sans which node-canvas DOES render fine. +const YWFT_PATH = `${homedir()}/Library/Fonts/ywft-processing-bold.ttf`; +// Auto-detect face rect for the illustration if a sidecar exists. +// Run detect-face.py once via: +// recap/.venv/bin/python3 pop/dance/bin/detect-face.py +let faceRect = null; +if (ILLUSTRATION) { + const facePath = `${ILLUSTRATION}.face.json`; + if (existsSync(facePath)) { + try { + const f = JSON.parse(readFileSync(facePath, "utf8")); + if (f.detected !== false && f.w && f.h) { + faceRect = f; + } + } catch {} + } +} + +// Per-section face + synthetic laptop bboxes, in IMAGE coords. Used +// by the lane-driven backlight (kicks light up the face region, hats +// light up the laptop region). Loaded from .face.json +// sidecars. Laptop bbox is SYNTHESIZED below the face — color-based +// detection on the painterly green laptop is too noisy across mixed +// scenes; "below the face" is reliable for all our compositions. +function loadRectsForIllustration(path) { + if (!path || !existsSync(path)) return null; + const facePath = `${path}.face.json`; + if (!existsSync(facePath)) return null; + try { + const f = JSON.parse(readFileSync(facePath, "utf8")); + if (f.detected === false || !f.w || !f.h) return { face: null, laptop: null, imgW: f.imgW, imgH: f.imgH }; + const face = { x: f.x, y: f.y, w: f.w, h: f.h }; + // Synthetic laptop just below the face. + const lx = Math.max(0, f.x - f.w * 0.30); + const ly = Math.min(f.imgH - 1, f.y + f.h * 1.25); + const lw = Math.min(f.imgW - lx, f.w * 1.60); + const lh = Math.min(f.imgH - ly, f.h * 0.85); + const laptop = { x: lx, y: ly, w: lw, h: lh }; + return { face, laptop, imgW: f.imgW, imgH: f.imgH }; + } catch { return null; } +} + +// ── canvas layout ──────────────────────────────────────────────────── +// The illustration fills the entire 1500×1500 canvas (NO banding). +// Score panel + title + duration all render ON TOP of it. The score +// occupies a translucent strip across the bottom so the illustration +// shows through behind the lanes. +// Canvas size — default 1500x1500 square. Pass --size 1080x1920 for +// a vertical 9:16 render (the same v8 illustrations cover-fit at this +// aspect with a small horizontal crop, no new gens needed). +function parseSize(s) { + const m = /^(\d+)x(\d+)$/.exec(String(s).trim()); + if (!m) return [1500, 1500]; + return [Number(m[1]), Number(m[2])]; +} +const [W, H] = parseSize(flags.size || "1500x1500"); +// Score panel spans the FULL canvas height — events overlay the +// illustration directly with NO dark backing strip. Each event gets +// a strong outline so it reads against varied illustration colors. +// Title/progress/timecode chrome floats at the very top and bottom +// edges. +const SCORE_TOP = 40; +const SCORE_BOTTOM = H - 40; +const SCORE_H = SCORE_BOTTOM - SCORE_TOP; + +// Lanes — one strip per instrument category. Every fired sound in +// the rendered track is represented in some lane; macro-textures +// (pad) are conveyed via the per-section background tints instead. +const LANES = [ + { key: "sfx", label: "SFX", color: "#ff9a40", lo: 0, hi: 0 }, // boot/chime/sniper/impact/zoo/guns/drones/whistle + { key: "vox", label: "VOX", color: "#f599c6", lo: 0, hi: 0 }, // greeting/booty/screams/bye/vocal stem + { key: "sub", label: "SUB", color: "#a855f7", lo: 30, hi: 60 }, + { key: "kick", label: "KICK", color: "#ff5a8a", lo: 30, hi: 50 }, + { key: "snare", label: "SNARE", color: "#e7c4ff", lo: 0, hi: 0 }, // snare-roll subdivisions (build crescendo) + { key: "hat", label: "HAT", color: "#7ad9f5", lo: 60, hi: 78 }, + { key: "bells", label: "BELLS", color: "#4ad1bf", lo: 30, hi: 90 }, + { key: "lead", label: "LEAD", color: "#aef240", lo: 40, hi: 84 }, + { key: "piano", label: "PIANO", color: "#f5d240", lo: 50, hi: 90 }, + { key: "supersaw", label: "SAW", color: "#9aff4d", lo: 40, hi: 84 }, +]; +// laneH is computed lazily inside drawFrame() so empty-lane filtering +// (which happens later, once event arrays are loaded) sizes lanes to +// the populated count, not the declared-lane count. +let laneH; +// Per-section timeline zoom — drop sections STRETCH out so each +// climactic beat gets visual space; intros/outros SQUASH so you +// see further ahead/behind. Transitioned smoothly at section +// boundaries (1.5 s ease). The base PX_PER_SEC is used as a +// fallback for sections not listed here. +const PX_PER_SEC = 820; +const SECTION_PX_PER_SEC = { + intro: 600, // squashed — see the whole opening at once + break1: 760, + build1: 1000, // stretched — anticipation builds + drop1: 1180, // peak stretch — drop detail + break2: 760, + build2: 1000, + drop2: 1300, // most stretched — final climax + outro: 650, // squashed — retrospective wind-down +}; +// Audio-sync compensation — the AAC encoder inserts ~21 ms priming +// at the start of the audio stream, so audio playback lags the +// visual playhead by that amount. Shifting the visual readout +// LATER by this delay realigns the visualization with what the +// listener actually hears. +const AUDIO_DELAY_SEC = Number(flags["audio-delay"] ?? 0.045); +const playheadX = Math.round(W / 2); + +// Section tints — DESATURATED palette for the progress bar / dividers. +// Each section gets a muted version of its color family so the bar +// reads as a soft pastel painting of the song structure rather than +// loud neon. Alpha here is the baseline; rendering further modulates +// per-section play state. +const SECTION_TINTS = { + intro: "rgba(46,210,118,0.20)", // vivid emerald + break1: "rgba(54,150,240,0.20)", // saturated azure + build1: "rgba(245,168,40,0.20)", // bright amber + drop1: "rgba(240,64,156,0.20)", // hot magenta-pink + break2: "rgba(54,150,240,0.20)", + build2: "rgba(245,168,40,0.20)", + drop2: "rgba(240,64,156,0.20)", + outro: "rgba(150,120,205,0.20)", // dusk violet +}; + +// ── pre-sort events by lane so per-frame culling is fast ───────────── +// Each lane's events also get a `stackRow` index — events that overlap +// in time within the same lane get assigned to different vertical +// rows so doubled hits don't visually merge into a single block. +const laneEvents = {}; +for (const lane of LANES) { + const evs = (struct.events?.[lane.key] || []).slice(); + // Sort by displayedT/t so the tape-stop remap is respected. + evs.sort((a, b) => (a.displayedT ?? a.t) - (b.displayedT ?? b.t)); + // Greedy interval-graph coloring — pick the lowest row whose + // previous event has already ended (with a small gap window). + // Events that overlap go on different rows so the timeline shows + // doubled hits as visually separated marks. + const rowEndT = []; + const STACK_GAP = 0.02; // 20 ms cluster threshold + for (const ev of evs) { + const startT = ev.displayedT ?? ev.t; + const endT = startT + Math.max(0.02, ev.dur || 0.06); + let row = 0; + while (row < rowEndT.length && rowEndT[row] > startT - STACK_GAP) row++; + if (row === rowEndT.length) rowEndT.push(endT); + else rowEndT[row] = endT; + ev.stackRow = row; + } + lane.maxStackRows = Math.max(1, rowEndT.length); + laneEvents[lane.key] = evs; +} + +// ── lane-population validation ─────────────────────────────────────── +// Confirm every declared lane has the data we expect. Prints a table +// to stderr so missing/empty lanes are obvious BEFORE we spend 6 min +// rendering. Pitched lanes also show their MIDI range so it's clear +// whether the events will land inside or get clamped outside the lane. +// Empty lanes are DROPPED from the active LANES array so the +// visualization only shows lanes with actual data (so chill mode's +// empty SNARE/VOX slots don't sit blank). +const allLanes = LANES.slice(); +console.log(`▸ lane populations:`); +for (const lane of allLanes) { + const evs = laneEvents[lane.key]; + const note = evs.length === 0 ? "·· EMPTY ··" + : ev => ev.midi !== undefined + ? `midi[${Math.min(...evs.map(e => e.midi))}-${Math.max(...evs.map(e => e.midi))}]` + : ""; + const pitchInfo = (lane.hi - lane.lo) > 0 && evs.length > 0 + ? ` pitch range ${Math.min(...evs.map(e => e.midi ?? 0))}-${Math.max(...evs.map(e => e.midi ?? 0))} (lane ${lane.lo}-${lane.hi})` + : ""; + console.log(` · ${lane.label.padEnd(6)} (${lane.key.padEnd(8)}) → ${String(evs.length).padStart(4)} events, stack=${lane.maxStackRows}${evs.length === 0 ? " ·· EMPTY ·· (dropped)" : pitchInfo}`); + void note; +} +// Filter LANES down to populated lanes (and respect --hide-lanes). +// The visualization no longer reserves space for empty slots or for +// lanes the user explicitly hid. +{ + const populated = allLanes.filter((l) => (laneEvents[l.key] || []).length > 0 && !HIDE_LANES.has(l.key)); + LANES.length = 0; + for (const l of populated) LANES.push(l); +} +console.log(` → rendering ${LANES.length} populated lane${LANES.length === 1 ? "" : "s"}${HIDE_LANES.size ? ` (hidden: ${[...HIDE_LANES].join(",")})` : ""}`); + +// Now that LANES is finalized, compute per-lane height. +laneH = Math.floor(SCORE_H / Math.max(1, LANES.length)); +const dropImpacts = (struct.events?.dropImpact || []).slice().sort((a, b) => a.t - b.t); + +// ── verlet-string playhead (ported from the wanderbeach video) ────── +// The playhead is a pinned vertical segmented string. Each event +// "plucks" it at its lane's y as it crosses the centre — a horizontal +// impulse — then it oscillates and settles (leftover ring) via verlet +// damping + string-stiffness smoothing. +const NS_N = 56; +const NS_Y0 = 0; +const NS_Y1 = H; // runs full height THROUGH the progress bar +const nsY = new Float32Array(NS_N); +const nsX = new Float32Array(NS_N); +const nsPX = new Float32Array(NS_N); +const nsHot = new Float32Array(NS_N); +for (let i = 0; i < NS_N; i++) { + nsY[i] = NS_Y0 + (NS_Y1 - NS_Y0) * (i / (NS_N - 1)); + nsX[i] = playheadX; nsPX[i] = playheadX; +} +const laneCenterY = {}; +for (let li = 0; li < LANES.length; li++) { + laneCenterY[LANES[li].key] = SCORE_TOP + li * laneH + laneH / 2; +} +// Per-node colour — the string segment glows in the colour of the +// waveform/lane that last plucked it (held while that node is hot). +const nsCR = new Float32Array(NS_N); +const nsCG = new Float32Array(NS_N); +const nsCB = new Float32Array(NS_N); +nsCR.fill(255); nsCG.fill(232); nsCB.fill(150); // default nylon cream +function ncHexToRgb(hex) { + const h = String(hex).replace("#", ""); + return [parseInt(h.slice(0, 2), 16) || 0, + parseInt(h.slice(2, 4), 16) || 0, + parseInt(h.slice(4, 6), 16) || 0]; +} +function pluckNeedle(yPix, amount, sign, rgb) { + let idx = Math.round(((yPix - NS_Y0) / (NS_Y1 - NS_Y0)) * (NS_N - 1)); + idx = Math.max(2, Math.min(NS_N - 3, idx)); + nsX[idx] += sign * amount; + nsX[idx - 1] += sign * amount * 0.55; + nsX[idx + 1] += sign * amount * 0.55; + nsHot[idx] = 1; + nsHot[idx - 1] = Math.max(nsHot[idx - 1], 0.7); + nsHot[idx + 1] = Math.max(nsHot[idx + 1], 0.7); + if (rgb) { + for (const j of [idx - 1, idx, idx + 1]) { + nsCR[j] = rgb[0]; nsCG[j] = rgb[1]; nsCB[j] = rgb[2]; + } + } +} +function stepNeedle() { + for (let i = 1; i < NS_N - 1; i++) { + const v = (nsX[i] - nsPX[i]) * 0.90; // damping → ring + nsPX[i] = nsX[i]; + nsX[i] = nsX[i] + v + (playheadX - nsX[i]) * 0.020; // tension home + } + for (let pass = 0; pass < 2; pass++) { // string stiffness + for (let i = 1; i < NS_N - 1; i++) { + nsX[i] = (nsX[i] * 2 + nsX[i - 1] + nsX[i + 1]) * 0.25; + } + } + nsX[0] = nsX[NS_N - 1] = playheadX; + for (let i = 0; i < NS_N; i++) nsHot[i] *= 0.90; +} +function drawNeedleString() { + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + // 1) sharp dark drop shadow — whole string, hard offset, no blur. + ctx.strokeStyle = "rgba(0,0,0,0.55)"; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.moveTo(nsX[0] + 3, nsY[0] + 4); + for (let i = 1; i < NS_N; i++) ctx.lineTo(nsX[i] + 3, nsY[i] + 4); + ctx.stroke(); + // 2) per-segment nylon string: dark casing → warm body → specular. + for (let i = 1; i < NS_N; i++) { + const h = Math.max(nsHot[i], nsHot[i - 1]); + const dev = Math.abs(((nsX[i] + nsX[i - 1]) / 2) - playheadX); + const act = Math.min(1, Math.max(h, dev / 26)); + const x0 = nsX[i - 1], y0 = nsY[i - 1], x1 = nsX[i], y1 = nsY[i]; + ctx.strokeStyle = `rgba(60,46,32,${(0.10 + act * 0.40).toFixed(3)})`; + ctx.lineWidth = 4 + act * 4; + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); + // Body tinted by the colour of the waveform that plucked this + // segment, brightening toward white at the peak of the pluck. + const cr = (nsCR[i] + nsCR[i - 1]) / 2; + const cg = (nsCG[i] + nsCG[i - 1]) / 2; + const cb = (nsCB[i] + nsCB[i - 1]) / 2; + const wb = 0.16 * act; // keep the waveform's colour dominant + const br = Math.round(cr + (255 - cr) * wb); + const bg = Math.round(cg + (255 - cg) * wb); + const bbl = Math.round(cb + (255 - cb) * wb); + ctx.strokeStyle = `rgba(${br},${bg},${bbl},${(0.13 + act * 0.80).toFixed(3)})`; + ctx.lineWidth = 2.2 + act * 3.4; + ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke(); + const specA = act * 0.85; + if (specA > 0.02) { + ctx.strokeStyle = `rgba(255,255,246,${specA.toFixed(3)})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x0 - 1.2, y0); ctx.lineTo(x1 - 1.2, y1); + ctx.stroke(); + } + } + ctx.restore(); +} + +// ── title setup ────────────────────────────────────────────────────── +const TITLE_PALETTE = [ + "#aef240", "#f54aa6", "#ffffff", "#4ad1bf", + "#f599c6", "#9aff4d", "#a44af5", "#7fe05a", +]; +const titleFontSize = 95; // smaller — chrome lives in a single flush-bottom strip +const titleChars = TITLE.toLowerCase().split(""); +const beatHz = BPM / 60; + +// Measure each prefix width via magick (the only renderer that +// understands this YWFT TTF correctly). +function magickMeasureWidth(text) { + const r = spawnSync("magick", [ + "-font", YWFT_PATH, + "-pointsize", String(titleFontSize), + `label:${text}`, + "-format", "%w", + "info:", + ], { encoding: "utf8" }); + if (r.status !== 0) return 0; + return parseInt(r.stdout.trim(), 10) || 0; +} +const prefixWidths = [0]; +let cum = ""; +for (const ch of titleChars) { + cum += ch; + prefixWidths.push(magickMeasureWidth(cum) || prefixWidths[prefixWidths.length - 1] + titleFontSize * 0.45); +} +// Title sits bottom-left, just inside a small inset. +const titleBaseX = 30; + +// ── pre-render YWFT title chars via magick → load as Images ───────── +const charBoxW = Math.ceil(titleFontSize * 1.4); +const charBoxH = Math.ceil(titleFontSize * 1.7); +const charBaselineY = Math.ceil(titleFontSize * 1.25); +const charImgs = new Array(titleChars.length).fill(null); +{ + const assetsDir = TRACK.replace(/\.mp3$/, ".assets"); + mkdirSync(assetsDir, { recursive: true }); + for (let i = 0; i < titleChars.length; i++) { + const ch = titleChars[i]; + if (ch.trim().length === 0) continue; + const color = TITLE_PALETTE[i % TITLE_PALETTE.length]; + const out = `${assetsDir}/titlechar.${i}.png`; + const r = spawnSync("magick", [ + "-size", `${charBoxW}x${charBoxH}`, + "xc:none", + "-font", YWFT_PATH, + "-pointsize", String(titleFontSize), + "-fill", "rgba(0,0,0,0.78)", + "-annotate", `+8+${charBaselineY + 5}`, ch, + "-fill", color, + "-annotate", `+3+${charBaselineY}`, ch, + out, + ]); + if (r.status !== 0) { + console.error(`✗ magick failed to render title char '${ch}'`); + process.exit(1); + } + charImgs[i] = await loadImage(out); + } +} + +// ── pre-render section labels + lane labels via magick ─────────────── +const sectionLabelImgs = new Map(); +const sectionFontSize = 32; +for (const sec of struct.sections) { + const assetsDir = TRACK.replace(/\.mp3$/, ".assets"); + const out = `${assetsDir}/section.${sec.name}.png`; + const r = spawnSync("magick", [ + "-background", "none", + "-fill", "rgba(255,255,255,0.85)", + "-font", YWFT_PATH, + "-pointsize", String(sectionFontSize), + `label:${sec.name}`, + out, + ]); + if (r.status === 0) { + sectionLabelImgs.set(sec.name, await loadImage(out)); + } +} +const laneLabelImgs = new Map(); +const laneFontSize = 22; +for (const lane of LANES) { + const assetsDir = TRACK.replace(/\.mp3$/, ".assets"); + const out = `${assetsDir}/lane.${lane.key}.png`; + const r = spawnSync("magick", [ + "-background", "rgba(0,0,0,0.75)", + "-fill", lane.color, + "-font", YWFT_PATH, + "-pointsize", String(laneFontSize), + `label:${lane.label}`, + "-bordercolor", "rgba(0,0,0,0.75)", + "-border", "8x4", + out, + ]); + if (r.status === 0) { + laneLabelImgs.set(lane.key, await loadImage(out)); + } +} + +// ── pre-render the timecode like the wanderbeach video ───────────── +// The whole "mm:ss / mm:ss" string is rasterised ONCE per second as a +// near-WHITE YWFT plate plus a matching solid-BLACK plate (the drop +// shadow). At draw time the white plate is recoloured to the current +// section tint (brightened by section progress) and the black plate +// is stamped twice behind it — identical compositing to wanderbeach. +const tcFontSize = titleFontSize; // timecode matches the title size +const tcCache = new Map(); // "mm:ss / mm:ss" → { img, shadow } +{ + const assetsDir = TRACK.replace(/\.mp3$/, ".assets"); + mkdirSync(assetsDir, { recursive: true }); + const renderPlate = async (text, fill, outPath) => { + const r = spawnSync("magick", [ + "-background", "none", + "-fill", fill, + "-font", YWFT_PATH, + "-pointsize", String(tcFontSize), + `label:${text}`, + outPath, + ]); + if (r.status !== 0) { console.error(`✗ magick failed on timecode '${text}'`); process.exit(1); } + return await loadImage(outPath); + }; + const totMm = Math.floor(duration / 60); + const totSs = Math.floor(duration - totMm * 60).toString().padStart(2, "0"); + for (let s = 0; s <= Math.ceil(duration); s++) { + const mm = Math.floor(s / 60); + const ss = (s - mm * 60).toString().padStart(2, "0"); + const text = `${mm}:${ss} / ${totMm}:${totSs}`; + if (tcCache.has(text)) continue; + const safe = text.replace(/[^0-9]/g, "_"); + const img = await renderPlate(text, "rgba(255,253,242,0.97)", `${assetsDir}/tc.${safe}.png`); + const shadow = await renderPlate(text, "rgba(0,0,0,1)", `${assetsDir}/tc.${safe}.shadow.png`); + tcCache.set(text, { img, shadow }); + } +} +// Offscreen used to recolour the white timecode plate to the section +// tint via source-in (matches wanderbeach). +const tcTint = createCanvas(8, 8); +const tcTintCtx = tcTint.getContext("2d"); +function tintRgb(s) { + const m = String(s).match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + return m ? [+m[1], +m[2], +m[3]] : [200, 200, 200]; +} +// Section tint brightened toward white by how far through the section +// we are — the timecode reports WHICH section and HOW FAR. +function sectionTcRgb(audioT) { + let s = struct.sections[struct.sections.length - 1]; + for (const sec of struct.sections) { + if (audioT >= sec.startSec && audioT < sec.endSec) { s = sec; break; } + } + let [r, g, b] = tintRgb(SECTION_TINTS[s.name] || "rgba(255,253,242,1)"); + const span = Math.max(0.001, s.endSec - s.startSec); + const lp = Math.max(0, Math.min(1, (audioT - s.startSec) / span)); + const k = 0.30 * lp; + r = Math.round(r + (255 - r) * k); + g = Math.round(g + (255 - g) * k); + b = Math.round(b + (255 - b) * k); + return [r, g, b]; +} + +// ── main canvas + load illustrations ───────────────────────────────── +// We load one image per section if mapped, else the default applies +// to every section. Crossfade between adjacent illustrations over a +// 1.5-second window at each section boundary. +console.log(`▸ loading illustration: ${ILLUSTRATION}`); +const defaultIllus = await loadImage(ILLUSTRATION); +const sectionIllusImgs = new Map(); // section.name → Image +const sectionRects = new Map(); // section.name → { face, laptop, imgW, imgH } +const defaultRects = loadRectsForIllustration(ILLUSTRATION); +for (const [secName, path] of SECTION_ILLUSTRATIONS) { + if (path && existsSync(path)) { + sectionIllusImgs.set(secName, await loadImage(path)); + const r = loadRectsForIllustration(path); + if (r) sectionRects.set(secName, r); + console.log(` · section "${secName}" illustration: ${path}${r?.face ? " (face+laptop bboxes loaded)" : ""}`); + } +} +function illusForSection(secName) { + return sectionIllusImgs.get(secName) || defaultIllus; +} +function rectsForSection(secName) { + return sectionRects.get(secName) || defaultRects; +} +// Optional INTRO PRELUDE — a pre-music "everyone is quiet, laptops +// closed, jeffrey head-down" illustration shown ONLY during the +// opening voice prefix. Swaps to the regular intro at the SNIPER hit +// (the "first gunshot"). Wired via the --prelude flag. +const PRELUDE_PATH = expandHome(flags.prelude); +const preludeImg = PRELUDE_PATH && existsSync(PRELUDE_PATH) + ? await loadImage(PRELUDE_PATH) : null; +// Sniper event marks the gunshot moment where laptops fly open. +const sniperEv = (struct.events?.sfx || []).find((e) => e.name === "sniper"); +const PRELUDE_END_SEC = sniperEv ? sniperEv.t : 2.625; + +// ── Doom-style strip melt scaffolding ──────────────────────────────── +// Each vertical column gets a random "delay" (0..1). During a +// transition, the OLD illustration's column slides DOWNWARD off the +// canvas, revealing the NEW illustration underneath. Columns with +// smaller delays start moving sooner, larger delays last. Like the +// classic Doom screen-melt. +const MELT_COL_W = 25; // 25-px wide columns → 60 columns on 1500-wide canvas +const MELT_COLS = Math.ceil(W / MELT_COL_W); +const meltDelays = new Float32Array(MELT_COLS); +{ + let s = 999331; + const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; }; + for (let i = 0; i < MELT_COLS; i++) meltDelays[i] = rng() * 0.55; // up to 0.55 of melt window delay +} +// Coarse columns for the ALWAYS-ON ambient slitscan (cheaper than the +// fine melt columns — the ambient displacement is only a few px so it +// doesn't need fine resolution). Each gets a random phase so the slit +// shimmers organically rather than as one rolling wave. +const SLIT_COL_W = Math.max(40, Math.round(W / 18)); +const SLIT_COLS = Math.ceil(W / SLIT_COL_W); +const slitPhase = new Float32Array(SLIT_COLS); +const slitFreq = new Float32Array(SLIT_COLS); +{ + let s = 20240517; + const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; }; + for (let i = 0; i < SLIT_COLS; i++) { + slitPhase[i] = rng() * Math.PI * 2; + slitFreq[i] = 1.1 + rng() * 1.8; + } +} + +// HSL → "r,g,b" string (0..360 / 0..1 / 0..1). Used so the backlight +// hue can drift liberally over time instead of being locked to the +// section colour. +function hslStr(h, s, l) { + h = ((h % 360) + 360) % 360; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return `${Math.round((r + m) * 255)},${Math.round((g + m) * 255)},${Math.round((b + m) * 255)}`; +} + +// Single reusable offscreen canvas for the OLD image when melting. +const meltCanvas = createCanvas(W, H); +const meltCtx = meltCanvas.getContext("2d"); +// A second offscreen for the parallax BACK layer / ambient-slit source. +const backCanvas = createCanvas(W, H); +const backCtx = backCanvas.getContext("2d"); + +// Reusable offscreen for the shutdown CRT-squeeze pass. +const shutdownCanvas = createCanvas(W, H); +const shutdownCtx = shutdownCanvas.getContext("2d"); + +// ── STAINED-GLASS BACKLIGHT plumbing ──────────────────────────────── +// The lane backlight is rendered as light coming from BEHIND the +// illustration, not a glow stamped on top. To do that we treat each +// illustration as a stained-glass panel: a per-image TRANSMISSION +// MASK says how much light passes through each pixel. Bright / lightly +// pencilled regions transmit (glow with the light colour); dark +// linework + deep shadows act as the leaded came — they block the +// light and stay punchy, so darks keep (even gain) contrast. +// +// The mask is computed ONCE per illustration (8 of them, not per +// frame): luminance → a steep contrast curve crushed hard in the +// shadows. Stored as an opaque-white canvas whose ALPHA is the +// transmission amount, so it can gate the glow via `destination-in`. +const glowCanvas = createCanvas(W, H); +const glowCtx = glowCanvas.getContext("2d"); +const _transmissionMasks = new WeakMap(); // Image → mask Canvas +function getTransmissionMask(img) { + let m = _transmissionMasks.get(img); + if (m) return m; + const iw = img.width, ih = img.height; + const tmp = createCanvas(iw, ih); + const tctx = tmp.getContext("2d"); + tctx.drawImage(img, 0, 0, iw, ih); + const id = tctx.getImageData(0, 0, iw, ih); + const px = id.data; + // Transmission curve: smoothstep across [LO, HI] in luminance, then + // raised to GAMMA so the dark half is crushed toward zero (the lead + // lines fully block light → high contrast on darks). A small FLOOR + // lets a whisper of colour bleed through near-blacks so the panel + // doesn't read as dead matte. + const LO = 0.30, HI = 0.92, GAMMA = 1.7, FLOOR = 0.04; + for (let i = 0; i < px.length; i += 4) { + const L = (0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]) / 255; + let s = (L - LO) / (HI - LO); + s = s < 0 ? 0 : s > 1 ? 1 : s; + s = s * s * (3 - 2 * s); // smoothstep + const tA = FLOOR + (1 - FLOOR) * Math.pow(s, GAMMA); + px[i] = 255; px[i + 1] = 255; px[i + 2] = 255; + px[i + 3] = Math.round(tA * 255); + } + tctx.putImageData(id, 0, 0); + _transmissionMasks.set(img, tmp); + return tmp; +} + +// Detect whether this track has the tape-stop ending — drives the +// video shutdown fade in drawFrame. Only normal-mode tracks have it. +const hasTapeStop = (struct.events?.sfx || []).some((e) => e.name === "tape-stop"); + +// Boot + shutdown beep events — rendered as FULLSCREEN dinks (radial +// flash + thin ring) on the canvas regardless of whether they're +// in the regular per-lane event loop or skipped (shutdown beeps live +// inside the tape-stop region but their audio still plays at original +// wall-clock time). +const bootBeeps = (struct.events?.sfx || []) + .filter((e) => /^boot-beep-\d+$/.test(e.name)) + .map((e) => ({ t: e.t, kind: "boot" })); +const shutdownBeeps = (struct.events?.sfx || []) + .filter((e) => /^shutdown-beep-\d+$/.test(e.name)) + .map((e) => ({ t: e.t, kind: "shutdown" })); +// (shutdownBeeps are visualized by the CRT-collapse shutdown +// sequence at the end of the video, not by a separate ring dink.) +void shutdownBeeps; + +// ── kick-driven zoom + alternating face-spot / center aim ─────────── +// Each kick pulses a dramatic zoom-in toward an aim point that +// ALTERNATES between a random spot inside the face bbox and the +// raw center of the illustration. Gives the camera a jumpy, beat- +// driven "zoom in / zoom out / zoom in" feel. +const kicksSorted = (struct.events?.kick || []).slice().sort((a, b) => (a.displayedT ?? a.t) - (b.displayedT ?? b.t)); +const kickAims = []; // per-kick aim coords [{ fx, fy, isFace }] +{ + let s = 12345; + const rng = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; }; + for (let i = 0; i < kicksSorted.length; i++) { + const isFace = (i % 2 === 0); // alternate even=face, odd=center + if (isFace && faceRect) { + // Random point INSIDE the face bbox in source-image pixels. + const fx = faceRect.x + faceRect.w * (0.25 + rng() * 0.50); + const fy = faceRect.y + faceRect.h * (0.25 + rng() * 0.50); + kickAims.push({ fx, fy, isFace: true }); + } else { + // Center of the illustration (in source-image pixels). + const imgW = faceRect ? faceRect.imgW : defaultIllus.width; + const imgH = faceRect ? faceRect.imgH : defaultIllus.height; + kickAims.push({ fx: imgW / 2, fy: imgH / 2, isFace: false }); + } + } +} + +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); + +// ── one-frame renderer ─────────────────────────────────────────────── +// `t` is the visual-frame time. `audioT` is the audio time currently +// hitting the listener's ears (shifted later by AAC encoder delay). +// All event positioning + envelope readouts use audioT so the +// timeline animation lines up with what's playing. +// Hoisted bottom-chrome constants. Layout (top-to-bottom): +// …illustration + score-train events fill from y=0 down to chromeTop +// chromeTop = H - chromeH ← bottom strip begins +// title bouncing (left) ← within strip +// timecode (right) ← within strip +// progressY = H - progressH ← progress bar flush to the very bottom +const progressH = 22; // matches the wanderbeach progress bar height +const progressY = H - progressH; +// Bottom-chrome strip — holds the bouncing title + the timecode just +// above the progress bar. +const chromeH = 130; // enough room for ~95-px title bouncing +const chromeTop = H - progressH - chromeH; +// Title is anchored TOP-LEFT now. titleY is the glyph baseline; with +// a charBaselineY-tall box drawn from (y = titleY - charBaselineY) +// this puts the cap height ~24 px below the top edge. The per-char +// bounce is clamped so an upward swing never clips off the top. +const titleTopInset = 24; +const titleY = titleTopInset + charBaselineY; +function drawFrame(t) { + const audioT = t - AUDIO_DELAY_SEC; + const env = envAt(audioT); + + // ── compute per-section PX_PER_SEC with smooth crossfade ────────── + // Lerp from current section's px/sec to the next at section + // boundaries over a 1.5 s window. Outside the boundary, the value + // stays at the current section's level. + let pps = PX_PER_SEC; + { + let cur = null, nxt = null; + for (let i = 0; i < struct.sections.length; i++) { + if (audioT >= struct.sections[i].startSec && audioT < struct.sections[i].endSec) { + cur = struct.sections[i]; + nxt = struct.sections[i + 1] || null; + break; + } + } + if (cur) { + const curPPS = SECTION_PX_PER_SEC[cur.name] ?? PX_PER_SEC; + const nxtPPS = nxt ? (SECTION_PX_PER_SEC[nxt.name] ?? PX_PER_SEC) : curPPS; + const tillNext = nxt ? (cur.endSec - audioT) : 1e9; + const xfWindow = 1.5; + if (tillNext < xfWindow) { + const k = 1 - tillNext / xfWindow; // 0 → 1 + // Smoothstep ease for a gentle squash/stretch transition. + const e = k * k * (3 - 2 * k); + pps = curPPS + (nxtPPS - curPPS) * e; + } else { + pps = curPPS; + } + } + } + + // ── FULL illustration as background ── face-focused ken-burns ──── + // Per-section illustrations crossfade at boundaries (1.5 s window + // before each section change). When --illustrations isn't passed, + // every section uses the same default illustration so no crossfade + // is visible. + // Musically-timed Doom-melt transition — centered ON each section + // boundary, runs for exactly 1 bar (half before / half after). + // crossfadeT = 0 at start of melt → 1 at end. + // We HOLD currentSec at the OLD section throughout the melt so the + // OLD illustration is what falls away. As soon as the melt ends, + // currentSec advances to the new one normally. + const beatSecLocal = 60 / BPM; + const barSecLocal = beatSecLocal * (struct.meter || 3); + const meltWindow = barSecLocal; + // The melt RESOLVES exactly on the musical landing — the section + // boundary, or a dropImpact within ~0.45 bar of it (snap to the + // drop for sample-tight sync). The whole fall happens across the + // bar BEFORE the landing, so the NEW illustration "lands" on the + // drop instead of the old symmetric ±half-bar straddle. + const DROP_SNAP = barSecLocal * 0.45; + const anchorFor = (i) => { + const bnd = struct.sections[i].endSec; + let a = bnd, best = DROP_SNAP; + for (const di of dropImpacts) { + const d = Math.abs(di.t - bnd); + if (d < best) { best = d; a = di.t; } + } + return a; + }; + let currentSec = struct.sections[0]; + let nextSec = struct.sections[1] || null; + let crossfadeT = 0; + let meltAnchor = null; // resolved landing time of the NEXT transition + // Default section lookup. + if (audioT >= struct.sections[struct.sections.length - 1].endSec) { + currentSec = struct.sections[struct.sections.length - 1]; + nextSec = null; + } else { + for (let i = 0; i < struct.sections.length; i++) { + if (audioT >= struct.sections[i].startSec && audioT < struct.sections[i].endSec) { + currentSec = struct.sections[i]; + nextSec = struct.sections[i + 1] || null; + break; + } + } + } + // Soonest upcoming landing → drives the pre-melt slit ramp; and if + // we're inside its 1-bar melt window, drive the crossfade + hold OLD. + for (let i = 0; i < struct.sections.length - 1; i++) { + const a = anchorFor(i); + if (a <= audioT) continue; // already past this landing + meltAnchor = a; // the nearest future landing + if (audioT >= a - meltWindow) { + currentSec = struct.sections[i]; + nextSec = struct.sections[i + 1]; + crossfadeT = Math.max(0, Math.min(1, (audioT - (a - meltWindow)) / meltWindow)); + } + break; + } + let illusImg = illusForSection(currentSec.name); + let nextIllus = nextSec ? illusForSection(nextSec.name) : null; + // PRELUDE OVERRIDE — show the "head-down, all-laptops-closed" frame + // from t=0 up to the sniper hit (the "first gunshot"). Then DOOM-melt + // into the regular intro illustration over 0.6 s, peaking with a + // white-flash punch right on the sniper. + let preludeFlash = 0; + if (preludeImg) { + const meltDur = 0.6; + if (audioT < PRELUDE_END_SEC - meltDur * 0.5) { + illusImg = preludeImg; + nextIllus = null; + crossfadeT = 0; + } else if (audioT < PRELUDE_END_SEC + meltDur * 0.5) { + // Force a melt with prelude → real intro section. + illusImg = preludeImg; + nextIllus = sectionIllusImgs.get("intro") || defaultIllus; + const localT = (audioT - (PRELUDE_END_SEC - meltDur * 0.5)) / meltDur; + crossfadeT = Math.max(0, Math.min(1, localT)); + // White-flash punch peaks AT the sniper instant and decays. + const dt = audioT - PRELUDE_END_SEC; + if (dt >= -0.05 && dt < 0.35) { + preludeFlash = Math.max(0, 1 - Math.abs(dt) / 0.35); + } + } + } + + const aspect = illusImg.width / illusImg.height; + // ── smooth slow camera ────────────────────────────────────────── + // Cover-fit so portrait or square illustrations always fully cover + // the canvas. baseScale is the LARGER of width-fit / height-fit, so + // the illustration overhangs at least one axis. Then a 20 % zoom + // factor on top gives ken-burns slack. + const coverScale = Math.max(W / illusImg.width, H / illusImg.height); + const baseScale = 1.20; + // Slow zoom oscillation 1.20 → 1.32 across an ~18s cycle. + const zoomBreath = 0.06 * (0.5 - 0.5 * Math.cos(audioT * (2 * Math.PI / 18))); + const zoom = baseScale + zoomBreath; + // No kick pulse — placeholder so later code that referenced it + // doesn't break. + const lastKickIdx = -1; + const lastKickT = -1e9; + void lastKickT; + void aspect; + const baseW = illusImg.width * coverScale * zoom; + const baseH = illusImg.height * coverScale * zoom; + // Wobble — frame-by-frame motion at multiple cross-frequencies so + // the painting reads as alive rather than smoothly drifting. + const wobbleX = 14 * Math.sin(t * 1.35) + 6 * Math.cos(t * 2.7) + env * 18 * Math.sin(t * 4.0); + const wobbleY = 11 * Math.cos(t * 1.55) + 5 * Math.sin(t * 2.3) + env * 14 * Math.cos(t * 3.3); + // Kick-driven aim — interpolate from the previous kick's aim to + // the next kick's aim across the time between them. So the camera + // glides between face spots and image center on each kick. + let centerX = (W - baseW) / 2; + let centerY = (H - baseH) / 2; + const imgW = faceRect ? faceRect.imgW : illusImg.width; + const imgH = faceRect ? faceRect.imgH : illusImg.height; + let aimFx = imgW / 2; + let aimFy = imgH / 2; + if (lastKickIdx >= 0 && kickAims[lastKickIdx]) { + aimFx = kickAims[lastKickIdx].fx; + aimFy = kickAims[lastKickIdx].fy; + // Glide to next kick if it exists. + if (lastKickIdx + 1 < kicksSorted.length) { + const nextKickT = kicksSorted[lastKickIdx + 1].displayedT ?? kicksSorted[lastKickIdx + 1].t; + const span = Math.max(0.001, nextKickT - lastKickT); + const glide = Math.min(1, Math.max(0, (audioT - lastKickT) / span)); + const next = kickAims[lastKickIdx + 1]; + aimFx = aimFx + (next.fx - aimFx) * glide; + aimFy = aimFy + (next.fy - aimFy) * glide; + } + } + // Translate aim point to a draw offset that puts the aim under + // the canvas center. + const scaledFx = aimFx * (baseW / imgW); + const scaledFy = aimFy * (baseH / imgH); + centerX = W / 2 - scaledFx; + centerY = H / 2 - scaledFy; + let drawX = centerX + wobbleX; + let drawY = centerY + wobbleY; + // Clamp so the illustration always covers the canvas — no black + // bars on the sides. For a baseW × baseH image to cover W × H, the + // draw position must satisfy: W - baseW ≤ drawX ≤ 0 (and same for Y). + drawX = Math.min(0, Math.max(W - baseW, drawX)); + drawY = Math.min(0, Math.max(H - baseH, drawY)); + // ── parallax / depth / slit drivers ──────────────────────────── + // Layer separation gives depth + a tight shake. The slitscan is a + // MUSIC-DRIVEN horizontal-row tear: near-silent when quiet, growing + // with amplitude, and exploding into a FINE per-pixel-row scan in + // the bar before each drop (preMelt). + const tillAnchor = meltAnchor !== null ? meltAnchor - audioT : Infinity; + const preMelt = tillAnchor > 0 && tillAnchor < barSecLocal * 1.6 + ? 1 - tillAnchor / (barSecLocal * 1.6) : 0; + // amplitude term (env²) + a hard pre-drop swell (preMelt²) + const slitAmp = env * env * 7 + preMelt * preMelt * 72; + const parScale = 1.055 + env * 0.03; + const pX = 9 * Math.sin(t * 0.55) + env * 16 * Math.sin(t * 2.6); + const pY = 7 * Math.cos(t * 0.50) + env * 13 * Math.cos(t * 2.2); + // Horizontal-row slitscan. Row height shrinks toward 2 px as the + // drop approaches → "much finer grain individual pixel rows pre + // transition". Each row's shift rides the BEAT (musical) plus a + // per-row chaotic comb that tears it apart right before the landing. + const beatPhase = audioT * beatHz * 2 * Math.PI; + const paintSlit = (img, dx, dy, dw, dh, amp) => { + if (amp < 3) { ctx.drawImage(img, dx, dy, dw, dh); return; } + backCtx.clearRect(0, 0, W, H); + backCtx.drawImage(img, dx, dy, dw, dh); + const grain = Math.min(1, preMelt + env * 0.3); + const rowH = Math.max(2, Math.round(16 - 14 * grain)); + for (let y = 0; y < H; y += rowH) { + const n = Math.sin(y * 12.9898 + 7.233) * 43758.5453; + const rnd = n - Math.floor(n); // stable per-row 0..1 + const wave = Math.sin(beatPhase * (0.5 + rnd) + rnd * 6.283); + const chaos = preMelt * (rnd - 0.5) * 2; // torn rows near the drop + const off = amp * (wave * 0.6 + chaos); + ctx.drawImage(backCanvas, 0, y, W, rowH, off, y, W, rowH); + } + }; + // Dim parallax BACK plate behind the crisp front — the differential + // motion (front fixed, back swimming on pX/pY) reads as depth. + const drawDepth = (img, fx, fy, fw, fh, parMul) => { + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, W, H); + ctx.drawImage(img, + fx - pX * parMul, fy - pY * parMul, fw * parScale, fh * parScale); + ctx.fillStyle = "rgba(0,0,0,0.40)"; + ctx.fillRect(0, 0, W, H); + }; + + if (nextIllus && crossfadeT > 0 && nextIllus !== illusImg) { + // ── DOOM melt WITH two-layer depth ───────────────────────────── + // NEW is the incoming plane: its own parallax back-plate + a + // slit-displaced front (so the two layers + slit carry through + // the whole transition). OLD then melts down in fine columns on + // top, each column also carrying the ambient slit so the melt + // grows organically out of it. + const nextCoverScale = Math.max(W / nextIllus.width, H / nextIllus.height); + const nextBaseW2 = nextIllus.width * nextCoverScale * zoom; + const nextBaseH2 = nextIllus.height * nextCoverScale * zoom; + let nextX = (W - nextBaseW2) / 2 + wobbleX; + let nextY = (H - nextBaseH2) / 2 + wobbleY; + nextX = Math.min(0, Math.max(W - nextBaseW2, nextX)); + nextY = Math.min(0, Math.max(H - nextBaseH2, nextY)); + drawDepth(nextIllus, nextX, nextY, nextBaseW2, nextBaseH2, 0.55); + paintSlit(nextIllus, nextX, nextY, nextBaseW2, nextBaseH2, + Math.max(slitAmp, 4)); + // OLD column-melt on top. + meltCtx.clearRect(0, 0, W, H); + meltCtx.drawImage(illusImg, drawX, drawY, baseW, baseH); + for (let c = 0; c < MELT_COLS; c++) { + const colX = c * MELT_COL_W; + const localT = (crossfadeT - meltDelays[c]) / Math.max(0.01, 1 - meltDelays[c]); + if (localT >= 1) continue; + const sc = Math.floor(colX / SLIT_COL_W) % SLIT_COLS; + const jitter = slitAmp * 0.6 * Math.sin(t * slitFreq[sc] * 1.6 + slitPhase[sc]); + const fall = Math.max(0, localT) * H * 1.05 + jitter; + ctx.drawImage(meltCanvas, + colX, 0, MELT_COL_W, H, + colX, fall, MELT_COL_W, H); + } + } else { + // No transition — depth back-plate + slit-displaced front. + drawDepth(illusImg, drawX, drawY, baseW, baseH, 1.0); + paintSlit(illusImg, drawX, drawY, baseW, baseH, slitAmp); + } + + // PRELUDE → INTRO punch — a screen-blended white flash that peaks + // right on the sniper hit, mirrors the "laptops fly open / we here" + // moment in the audio. + if (preludeFlash > 0.01) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = preludeFlash * 0.85; + ctx.fillStyle = "rgba(255,255,240,1)"; + ctx.fillRect(0, 0, W, H); + ctx.restore(); + } + + // ── LANE BACKLIGHT — kicks light up the face region; hats light up + // the laptop region. Both render as soft radial + // glows in lane colors, anchored to the current + // section's face/laptop bboxes (transformed from + // image space to live canvas space using the + // ken-burns scale + offset). Each event fades over + // BACKLIGHT_DECAY seconds. + { + const rects = rectsForSection(currentSec.name); + if (rects && (rects.face || rects.laptop)) { + const sx = baseW / illusImg.width; + const sy = baseH / illusImg.height; + // Image-rect → live canvas rect. + const imgToCanvas = (r) => r ? { + cx: drawX + (r.x + r.w / 2) * sx, + cy: drawY + (r.y + r.h / 2) * sy, + rw: r.w * sx, + rh: r.h * sy, + } : null; + const faceCanvas = imgToCanvas(rects.face); + const laptopCanvas = imgToCanvas(rects.laptop); + // FAST + TIGHT: a very short decay so each hit snaps on and + // clears before the next, locked hard to event timing. + const BACKLIGHT_DECAY = 0.12; // seconds + // Most-recent kick / hat intensity inside the decay window. + const recentMax = (lane) => { + let m = 0; + for (const ev of (struct.events?.[lane] || [])) { + const evT = ev.displayedT ?? ev.t; + const since = audioT - evT; + if (since >= 0 && since < BACKLIGHT_DECAY) { + const k = 1 - since / BACKLIGHT_DECAY; + if (k > m) m = k; + } + } + return m; + }; + // Amplitude-reactive + sensitive: the live envelope gates the + // flash so loud passages flare and quiet ones barely register, + // plus a whisper of always-on env shimmer so the panel breathes. + const ampGate = 0.18 + 1.10 * env; + const ambient = 0.10 * env; + const maxK = faceCanvas ? Math.max(recentMax("kick") * ampGate, ambient) : 0; + const maxH = laptopCanvas ? Math.max(recentMax("hat") * ampGate, ambient) : 0; + // Hue drifts LIBERALLY over time + amplitude — not locked to the + // section colour. Kick + hat ride different points of the wheel. + const kickHue = audioT * 23 + env * 90; + const hatHue = audioT * 17 + 140 + env * 70; + const kHi = hslStr(kickHue, 0.95, 0.60), kLo = hslStr(kickHue, 0.95, 0.16); + const hHi = hslStr(hatHue, 0.95, 0.58), hLo = hslStr(hatHue, 0.95, 0.15); + + if (maxK > 0.008 || maxH > 0.008) { + // Paint a hot radial into the offscreen glow buffer. Additive + // (`lighter`) with a near-white core so it reads as an actual + // emitter, not a tint — overlapping face+laptop glows sum. + const stamp = (region, rgb, k0) => { + if (!region || k0 <= 0.008) return; + // Punchy attack, very tight tail. + const k = Math.pow(Math.min(1, k0), 0.45); + const radius = Math.max(region.rw, region.rh) * 1.25; + const g = glowCtx.createRadialGradient( + region.cx, region.cy, 0, region.cx, region.cy, radius); + // hot core → saturated mid → transparent edge (subtler) + g.addColorStop(0.0, `rgba(255,255,255,${0.80 * k})`); + g.addColorStop(0.16, `rgba(${rgb},${0.85 * k})`); + g.addColorStop(0.34, `rgba(${rgb},${0.55 * k})`); + g.addColorStop(0.66, `rgba(${rgb},${0.26 * k})`); + g.addColorStop(1.0, `rgba(${rgb},0)`); + glowCtx.globalCompositeOperation = "lighter"; + glowCtx.fillStyle = g; + glowCtx.fillRect( + Math.max(0, region.cx - radius), + Math.max(0, region.cy - radius), + Math.min(W, radius * 2), + Math.min(H, radius * 2)); + }; + const mask = getTransmissionMask(illusImg); + // ── PASS 1: transmitted light ────────────────────────────── + // Light that passes THROUGH the panel. Gate the glow by the + // transmission mask (keep it only where the illustration is + // light/translucent) then add it to the scene. Dark linework + // has ~0 transmission so it stays untouched & punchy. + glowCtx.globalCompositeOperation = "source-over"; + glowCtx.clearRect(0, 0, W, H); + stamp(faceCanvas, kHi, maxK); + stamp(laptopCanvas, hHi, maxH); + glowCtx.globalCompositeOperation = "destination-in"; + glowCtx.drawImage(mask, drawX, drawY, baseW, baseH); + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + ctx.globalAlpha = 0.6; + ctx.drawImage(glowCanvas, 0, 0); + ctx.restore(); + // ── PASS 2: leaded contrast ──────────────────────────────── + // Where the panel BLOCKS the light (the dark came-lines near + // the source) deepen it with a saturated multiply so the + // darks bruise toward the light's hue instead of washing out + // — stained-glass leading, high contrast retained. + glowCtx.globalCompositeOperation = "source-over"; + glowCtx.clearRect(0, 0, W, H); + stamp(faceCanvas, kLo, maxK); + stamp(laptopCanvas, hLo, maxH); + glowCtx.globalCompositeOperation = "destination-out"; + glowCtx.drawImage(mask, drawX, drawY, baseW, baseH); + ctx.save(); + ctx.globalCompositeOperation = "multiply"; + ctx.globalAlpha = 0.28 * Math.max(maxK, maxH); + ctx.drawImage(glowCanvas, 0, 0); + ctx.restore(); + } + } + } + + // No dark backdrop — events render directly over the illustration. + + // Section tints — visible only for sections within the viewport. + // PER-LANE TINTING: in addition to the overall section band tint, + // we color the BACKGROUND of each lane that is ACTIVE in this + // section. So a section like break1 (no kick, no sub) leaves + // those lanes uncolored, while drop1 (kick, hat, sub, bells, lead, + // piano all active) lights up every lane background. The event + // blocks then render on top as the foreground. + // Section dividers — thin dashed verticals at section boundaries. + // Section text labels REMOVED per user direction; the section + // colors are conveyed by the per-section illustration crossfade + // + the colored progress bar at the top of the canvas. + for (const sec of struct.sections) { + const x0 = playheadX + (sec.startSec - audioT) * pps; + if (x0 < 0 || x0 > W) continue; + ctx.strokeStyle = "rgba(255,255,255,0.35)"; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 6]); + ctx.beginPath(); + ctx.moveTo(x0, 0); + ctx.lineTo(x0, H); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Bar-grid ticks. + const beatSec = 60 / BPM; + const barSec = beatSec * (struct.meter || 3); + const introStart = struct.sections[0]?.startSec ?? 2.7; + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + for (let bar = 0; bar < struct.totalBars; bar++) { + const tBar = introStart + bar * barSec; + const x = playheadX + (tBar - audioT) * pps; + if (x < 0 || x > W) continue; + ctx.beginPath(); + ctx.moveTo(x, SCORE_TOP); + ctx.lineTo(x, SCORE_BOTTOM); + ctx.stroke(); + } + + // ── per-lane events ──────────────────────────────────────────────── + // Viewport time window: audioT ± (W/2) / pps seconds. + const viewportPad = W / pps; + const tMin = audioT - viewportPad; + const tMax = audioT + viewportPad; + for (let li = 0; li < LANES.length; li++) { + const lane = LANES[li]; + const laneY0 = SCORE_TOP + li * laneH; + // Lane labels removed — the timeline reads as pure colored + // event lanes overlaid on the illustration with no extra chrome. + + const evs = laneEvents[lane.key]; + for (const ev of evs) { + if (ev.skipped) continue; // beyond tape-stop max coverage + const evT = ev.displayedT ?? ev.t; // tape-stop remap if present + if (evT > tMax) break; // sorted → can short-circuit + const evDur = ev.dur || 0.06; + if (evT + evDur < tMin) continue; + // Cap visible width so a long-sustain bell note doesn't render + // as a 900-pixel slab. Drum hits keep their natural short + // width; pitched lanes get capped to a tight 0.18 s marker. + // SFX events show the actual mini-waveform of their underlying + // audio so their visualization length matches the sample's true + // duration, capped to keep on-screen footprint reasonable. + // Cap visual duration so SFX/VOX pills/waveforms never exceed a + // sane width. Long vocal stems (greeting: 3.7s, booty: 9.8s) at + // section pps=600 produced pills 2200-5900 px wide — Cairo's + // wide-pill draw path (gradient + shadow + screen-blend at + // megapixel scale) corrupts the canvas backing buffer mid-render + // and the canvas stops updating from that point onward. Capping + // to 1.5s keeps every visualization within reach of the playhead + // viewport without poisoning Cairo. + const SAMPLE_MAX_VIS_DUR = 1.5; + const visualDur = ev.midi !== undefined + ? Math.min(evDur, 0.18) + : Math.min(evDur, SAMPLE_MAX_VIS_DUR); + const w = Math.max(3, visualDur * pps); + const x = playheadX + (evT - audioT) * pps; + + // UNIFORM event height across all lanes — drums and pitched + // events both use ~40% of the lane height, centered vertically + // (pitched events shift up/down by midi within a narrower + // band). Keeps the visualization visually consistent: no + // "big drum slab" vs "tiny pitched note" mismatch. + const baseNoteH = Math.max(8, Math.floor(laneH * 0.82)); + let y0 = laneY0 + Math.floor((laneH - baseNoteH) / 2); + let yh = baseNoteH; + if (ev.midi !== undefined) { + const t01 = Math.min(1, Math.max(0, (ev.midi - lane.lo) / Math.max(1, lane.hi - lane.lo))); + // Pitched lanes — same height as drums, but the y center + // shifts by ±35% of lane height based on midi position so + // pitch contour reads while heights stay uniform. + const pitchOffset = Math.floor((t01 - 0.5) * (laneH - baseNoteH)); + y0 = laneY0 + Math.floor((laneH - baseNoteH) / 2) - pitchOffset; + } else if (lane.maxStackRows > 1) { + // Stacked drum hits (pre-roll vs in-bar hats etc.) — divide + // the centered note-band into rows. Cap rendered depth at 3 + // to avoid crushing. + const visibleRows = Math.min(lane.maxStackRows, 3); + const rowH = baseNoteH / visibleRows; + const rowIdx = Math.min(ev.stackRow, visibleRows - 1); + y0 = laneY0 + Math.floor((laneH - baseNoteH) / 2) + rowIdx * rowH; + yh = Math.max(4, rowH - 1); + } + + // Trigger flash — when the event's start is just left of the + // playhead, FLASH brighter and grow a brief halo. Reads as + // a visual "the note is firing right now." Longer fade-tail + // (180 ms) than before so the visual stays alive after the + // hit, and the halo size scales with the flash energy. + const sinceTrigger = audioT - evT; + const flashWindow = 0.18; + let flash = 0; + if (sinceTrigger >= 0 && sinceTrigger < flashWindow) { + flash = 1 - sinceTrigger / flashWindow; + } + // Future / upcoming events get dimmed; played events stay + // saturated but fade slightly as they leave the playhead. + const isFuture = evT > audioT + 0.02; + const isPlayed = sinceTrigger >= flashWindow; + let baseAlpha = 1.0; + if (isFuture) baseAlpha = 0.55; // dimmer upcoming, but visible + else if (isPlayed) baseAlpha = 0.82; // mostly-saturated played + // ACTIVE pulse — when flash > 0 the bubble pumps to FULL opacity + // (and a touch more saturated alpha for the spike) so the + // playhead-aligned event reads as the loudest moment on screen. + if (flash > 0) baseAlpha = Math.min(1.0, baseAlpha + flash * 0.4); + + // PLUCK the verlet string the single frame this event crosses + // the playhead — a horizontal impulse at the event's lane y. + if (sinceTrigger >= 0 && sinceTrigger < (1 / FPS) + 1e-4) { + const yc = laneCenterY[lane.key] ?? (SCORE_TOP + SCORE_H / 2); + const sign = ((Math.floor(evT * 97) + li) & 1) ? 1 : -1; + const amount = 30 + 52 * Math.min(1, env + 0.2); + pluckNeedle(yc, amount, sign, ncHexToRgb(lane.color)); + } + + ctx.globalAlpha = baseAlpha; + ctx.fillStyle = lane.color; + // SFX / VOX lanes: draw the ACTUAL waveform of the audio at + // this event's time region. Per visible pixel column, sample + // the audio peaks in a tiny window and draw a vertical + // excursion from the lane's center. Gives the timeline a + // mini-DAW feel for any time-extended sample-driven event + // (drop impacts, guns, drones, vocals, screams, etc). + // + // Events tagged `point: true` are DECORRELATED markers — they + // don't draw a waveform (the underlying audio is already shown + // by the span event they sit inside). They render as a small + // outlined diamond on top so the user can spot the discrete + // chime/event even though it lives inside a bigger span. + const isPoint = ev.point === true; + // BEACH TRACK MODEL — every non-point event is drawn as the + // waveform of the underlying audio at its time region (no pills). + const showWaveform = !isPoint && audioRaw.length > 0; + if (isPoint) { + const cx = x + w / 2; + const cy = y0 + yh / 2; + const size = Math.min(yh / 2 + 2, 14); + ctx.globalAlpha = baseAlpha; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx, cy - size); + ctx.lineTo(cx + size, cy); + ctx.lineTo(cx, cy + size); + ctx.lineTo(cx - size, cy); + ctx.closePath(); + ctx.stroke(); + ctx.fillStyle = lane.color; + ctx.globalAlpha = baseAlpha * 0.5; + ctx.fill(); + ctx.globalAlpha = 1.0; + } else if (showWaveform) { + // Beach-style: the waveform itself IS the event — a THICK + // filled envelope, ADDITIVELY blended so when several triggers + // overlap their waveforms COMBINE (sum + brighten) instead of + // stacking as separate thin strips. No pill shape. + const centerY = y0 + yh / 2; + const halfH = yh / 2 - 1; + const samplesPerPx = AUDIO_SR / pps; + const xEnd = Math.min(W, x + w); + const xStart = Math.max(0, x); + if (xEnd > xStart) { + // sample the envelope once, reuse for fill + outline. + const cols = []; + for (let px = xStart; px <= xEnd; px += 1) { + const audioAt = evT + (px - x) / pps; + const idxC = Math.floor(audioAt * AUDIO_SR); + const win = Math.max(1, Math.floor(samplesPerPx)); + let maxAmp = 0; + for (let s = -win; s <= win; s++) { + const idx = idxC + s; + if (idx < 0 || idx >= audioRaw.length) continue; + const a = Math.abs(audioRaw[idx]); + if (a > maxAmp) maxAmp = a; + } + // taller scaling (2.6×) + a floor so it always has body. + cols.push(Math.min(1, (maxAmp / audioPeak) * 2.6) * 0.92 + 0.06); + } + ctx.save(); + ctx.globalCompositeOperation = "lighter"; // overlaps SUM + // filled mirrored body + ctx.beginPath(); + ctx.moveTo(xStart, centerY - cols[0] * halfH); + for (let k = 1; k < cols.length; k++) { + ctx.lineTo(xStart + k, centerY - cols[k] * halfH); + } + for (let k = cols.length - 1; k >= 0; k--) { + ctx.lineTo(xStart + k, centerY + cols[k] * halfH); + } + ctx.closePath(); + ctx.globalAlpha = Math.min(1, baseAlpha * 0.55 + flash * 0.4); + ctx.fillStyle = lane.color; + ctx.fill(); + // bright outline so the shape pops + ctx.globalAlpha = Math.min(1, baseAlpha + flash * 0.5); + ctx.strokeStyle = lane.color; + ctx.lineWidth = 1.6 + flash * 2.8; + ctx.stroke(); + // specular core when freshly fired + if (flash > 0.05) { + ctx.globalAlpha = flash * 0.85; + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.lineWidth = 1.2; + ctx.stroke(); + } + ctx.restore(); + } + } else { + // DIMENSIONAL PILL — the body uses a vertical color gradient + // (lighter at top, darker at bottom) so each capsule reads + // as a 3D candy. Plus a soft drop shadow below, and a thin + // highlight stroke just inside the top so the specular pop + // reads on the curved edge. Screen blend so events glow + // against the illustration. + const r = Math.min(yh / 2, w / 2); + const drawPill = (px, py, pw, ph, pr) => { + ctx.beginPath(); + ctx.moveTo(px + pr, py); + ctx.arcTo(px + pw, py, px + pw, py + ph, pr); + ctx.arcTo(px + pw, py + ph, px, py + ph, pr); + ctx.arcTo(px, py + ph, px, py, pr); + ctx.arcTo(px, py, px + pw, py, pr); + }; + // 1) Drop shadow — soft dark band ~2 px below. + ctx.save(); + ctx.globalAlpha = baseAlpha * 0.45; + ctx.shadowBlur = 4; + ctx.shadowColor = "rgba(0,0,0,0.7)"; + ctx.shadowOffsetY = 2; + ctx.fillStyle = "rgba(0,0,0,0.0)"; // shadow is the visible part + ctx.fillStyle = "rgba(0,0,0,0.5)"; + drawPill(x, y0, w, yh, r); + ctx.fill(); + ctx.restore(); + // 2) Main pill body — vertical gradient, screen blend. + ctx.save(); + ctx.globalCompositeOperation = "screen"; + const bodyGrad = ctx.createLinearGradient(x, y0, x, y0 + yh); + // Lighter at top → core color → slightly darker at bottom. + // Use rgba blends of the lane color with white/black. + bodyGrad.addColorStop(0, lane.color + "ff"); // top — full sat + bodyGrad.addColorStop(0.5, lane.color + "cc"); // mid — slight pull-down + bodyGrad.addColorStop(1, lane.color + "55"); // bottom — darker for depth + ctx.fillStyle = bodyGrad; + drawPill(x, y0, w, yh, r); + ctx.fill(); + ctx.restore(); + // 3) Top inner highlight — thin white arc just inside the top. + ctx.save(); + ctx.globalAlpha = baseAlpha * 0.55; + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); + // Draw only the upper half of the pill outline (the top arc). + const inset = 1.2; + const ix = x + inset, iy = y0 + inset; + const iw = w - 2 * inset, ih = yh - 2 * inset; + const ir = Math.min(ih / 2, iw / 2); + // Move along the top edge: top-left curve to top-right curve. + ctx.moveTo(ix, iy + ir); + ctx.arcTo(ix, iy, ix + ir, iy, ir); + ctx.lineTo(ix + iw - ir, iy); + ctx.arcTo(ix + iw, iy, ix + iw, iy + ir, ir); + ctx.stroke(); + ctx.restore(); + } + + // ACTIVE accent — NO pill. A soft screen-blended vertical glow + // column over the event's waveform span when it fires, so the + // hit reads as the waveform lighting up rather than a capsule. + if (flash > 0.05 && !isPoint) { + const gx = Math.max(0, x); + const gw = Math.min(W, x + w) - gx; + if (gw > 0) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + const cx = gx + gw / 2; + const grad = ctx.createLinearGradient(gx, 0, gx + gw, 0); + grad.addColorStop(0, lane.color + "00"); + grad.addColorStop(0.5, lane.color + "ff"); + grad.addColorStop(1, lane.color + "00"); + ctx.globalAlpha = flash * 0.6; + ctx.fillStyle = grad; + const gy0 = laneY0 + 1; + ctx.fillRect(gx, gy0, gw, laneH - 2); + // bright hot core line on the strongest column + ctx.globalAlpha = flash * 0.8; + ctx.fillStyle = "rgba(255,255,255,0.9)"; + ctx.fillRect(cx - 1, gy0, 2, laneH - 2); + ctx.restore(); + } + ctx.globalAlpha = 1; + } + // ── NOTATION POP — particle burst on activation ─────────────── + // For freshly-fired events (flash > 0) spray ~8 small dots + // outward from the pill's center, fading over a 240 ms window. + // Deterministic angles seeded by lane index + event time so the + // burst doesn't shimmer between frames. + if (flash > 0.05 && !isPoint) { + const burstAge = (audioT - evT) / 0.24; + if (burstAge >= 0 && burstAge < 1) { + const cx = x + w / 2; + const cy = y0 + yh / 2; + const NUM = 8; + const seed = Math.floor(evT * 1000) + li * 31; + let s = seed >>> 0; + const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return (s & 0xffffff) / 0xffffff; }; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + for (let p = 0; p < NUM; p++) { + const baseAng = (p / NUM) * Math.PI * 2; + const jitter = (rnd() - 0.5) * 0.8; + const ang = baseAng + jitter; + const reach = 14 + rnd() * 26; // max distance from center + const dist = reach * burstAge; + const px = cx + Math.cos(ang) * dist; + const py = cy + Math.sin(ang) * dist; + const radius = Math.max(0.5, 2.8 * (1 - burstAge)); + ctx.globalAlpha = (1 - burstAge) * 0.95; + ctx.fillStyle = lane.color; + ctx.beginPath(); + ctx.arc(px, py, radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + } + ctx.globalAlpha = 1.0; + } + } + + // ── drop-impact markers — vertical white-on-pink bars ───────────── + for (const imp of dropImpacts) { + const impT = imp.displayedT ?? imp.t; + if (impT < tMin || impT > tMax) continue; + const x = playheadX + (impT - audioT) * pps; + ctx.strokeStyle = "rgba(255,255,255,0.9)"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(x, SCORE_TOP); + ctx.lineTo(x, SCORE_BOTTOM); + ctx.stroke(); + ctx.strokeStyle = "#ff5a8a"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, SCORE_TOP); + ctx.lineTo(x, SCORE_BOTTOM); + ctx.stroke(); + } + + // ── COLOR BLASTS — full-canvas flashes on dropImpact + section + // boundaries. SUPPRESSED during the shutdown + // window so the ending is a calm fade-to-black + // without strobing. + const inShutdown = hasTapeStop && audioT > duration - 5; + if (!inShutdown) { + let blastAlpha = 0; + let blastColor = "rgba(255,255,255,0.6)"; + if (nextSec) { + const sinceBoundary = audioT - currentSec.endSec; + if (sinceBoundary >= -0.04 && sinceBoundary < 0.18) { + const t01 = Math.max(0, sinceBoundary + 0.04) / 0.22; + blastAlpha = Math.max(blastAlpha, 0.55 * (1 - t01)); + const tint = SECTION_TINTS[nextSec.name] || "rgba(255,255,255,0.5)"; + blastColor = tint.replace(/,([0-9.]+)\)/, ",1)").replace("rgba", "rgba"); + } + } + for (const imp of dropImpacts) { + const impT = imp.displayedT ?? imp.t; + const dt = audioT - impT; + if (dt >= 0 && dt < 0.30) { + const t01 = dt / 0.30; + const a = 0.65 * (1 - t01); + if (a > blastAlpha) { + blastAlpha = a; + blastColor = "rgba(255,90,160,1)"; + } + } + } + if (blastAlpha > 0.01) { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.globalAlpha = blastAlpha; + ctx.fillStyle = blastColor; + ctx.fillRect(0, 0, W, H); + ctx.restore(); + } + } + + // ── playhead — verlet physics string (ported from wanderbeach) ─── + // Events plucked it as they crossed (above); advance the sim one + // step then draw the oscillating/settling nylon string. + stepNeedle(); + drawNeedleString(); + + // ── bouncing title — pre-rendered chars composited per frame ───── + // Bounce amplitude has three components: + // • a small per-beat sin (always pulses with rhythm) + // • a multiplier tied to envelope so loud passages bounce more + // • per-char phase offset so letters bob in a traveling wave + const baseBounce = 8; + const envBounce = env * 36; + for (let i = 0; i < titleChars.length; i++) { + const img = charImgs[i]; + if (!img) continue; + const x = titleBaseX + prefixWidths[i] - 3; + const phase = (i / titleChars.length) * 2 * Math.PI; + const beatWave = Math.sin(2 * Math.PI * beatHz * audioT + phase); + let y = titleY - charBaselineY + (baseBounce + envBounce) * beatWave; + // Hard ceiling: an upward bounce may never clip off the top edge. + if (y < 2) y = 2; + ctx.drawImage(img, x, y); + } + + // ── segmented progress bar — identical to the wanderbeach video ── + // FULL WIDTH: each section paints a near-black tinted TRACK across + // its width; the PLAYED portion is the bright saturated tint on top. + // The LAST section runs all the way to the right edge (sections end + // a couple seconds before the padded total) so the bar spans the + // entire display with no short gap. + { + const playedX = (W * Math.max(0, audioT)) / duration; + const lastI = struct.sections.length - 1; + for (let si = 0; si < struct.sections.length; si++) { + const sec = struct.sections[si]; + // First section pinned to the LEFT edge, last to the RIGHT edge + // (sections start/end a little inside the padded total) so the + // bar spans the entire display with no gaps either side. + const segX0 = si === 0 ? 0 : (sec.startSec / duration) * W; + const segX1 = si === lastI ? W : (sec.endSec / duration) * W; + const [r, g, b] = tintRgb(SECTION_TINTS[sec.name] || "rgba(200,200,200,1)"); + ctx.fillStyle = `rgba(${Math.round(r * 0.16)},${Math.round(g * 0.16)},${Math.round(b * 0.16)},0.85)`; + ctx.fillRect(segX0, progressY, segX1 - segX0, progressH); + const filledX1 = Math.min(segX1, playedX); + if (filledX1 > segX0) { + ctx.fillStyle = `rgba(${r},${g},${b},0.96)`; + ctx.fillRect(segX0, progressY, filledX1 - segX0, progressH); + } + ctx.fillStyle = "rgba(255,253,242,0.35)"; + ctx.fillRect(segX1 - 1, progressY, 1, progressH); + } + } + + // ── timecode — identical compositing to the wanderbeach video ──── + { + const totMm = Math.floor(duration / 60); + const totSs = Math.floor(duration - totMm * 60).toString().padStart(2, "0"); + const sec = Math.min(Math.max(0, Math.floor(audioT)), Math.ceil(duration)); + const mm = Math.floor(sec / 60); + const ss = (sec - mm * 60).toString().padStart(2, "0"); + const entry = tcCache.get(`${mm}:${ss} / ${totMm}:${totSs}`); + if (entry) { + const { img, shadow } = entry; + // bottom-right, above the progress bar; bounces with the audio + // envelope plus a small breathing wobble. + const bounce = 16 * env; + const wobble = 2.2 * Math.sin(audioT * 5.5); + const x = W - img.width - 32; + const y = progressY - img.height - 14 - bounce + wobble; + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.95; + ctx.drawImage(shadow, x + 3, y + 4); + ctx.drawImage(shadow, x + 2, y + 3); + ctx.globalAlpha = 1; + const [tr, tg, tb] = sectionTcRgb(audioT); + tcTint.width = img.width; + tcTint.height = img.height; + tcTintCtx.clearRect(0, 0, img.width, img.height); + tcTintCtx.globalCompositeOperation = "source-over"; + tcTintCtx.drawImage(img, 0, 0); + tcTintCtx.globalCompositeOperation = "source-in"; + tcTintCtx.fillStyle = `rgb(${tr},${tg},${tb})`; + tcTintCtx.fillRect(0, 0, img.width, img.height); + ctx.drawImage(tcTint, x, y); + ctx.restore(); + } + } + + // ── boot beeps → CRT TURN-ON / DOOR-OPENING REVEAL ─────────────── + // The 3 boot beeps (C5-E5-G5) snap a centered horizontal slit OPEN + // in stages: pre-beep-1 the canvas is BLACK, beep-1 cracks it open + // (33% of H), beep-2 widens it (66%), beep-3 fully reveals (100%). + // Each step glows bright white at the slit's leading edges as if + // the doors are sliding apart. After beep-3 the reveal stays fully + // open for the rest of the song (illustration always visible). + // Shutdown beeps are NOT wired here — the CRT-collapse sequence at + // the end already handles the 3-beep door-closing. + if (bootBeeps.length > 0) { + const firstT = bootBeeps[0].t; + const lastT = bootBeeps[bootBeeps.length - 1].t; + // Once we're well past the last beep (and the reveal has settled + // to 100 %), skip the mask entirely — full canvas shows through. + if (audioT < lastT + 0.18) { + // Find the most recent beep that has fired. + let lastBeepIdx = -1; + for (let i = 0; i < bootBeeps.length; i++) { + if (audioT >= bootBeeps[i].t) lastBeepIdx = i; else break; + } + const stepFracs = bootBeeps.map((_, i) => (i + 1) / bootBeeps.length); // [0.33, 0.66, 1.0] + let openFrac = 0; + if (lastBeepIdx < 0) { + // Pre-beep-1 — fully closed. + openFrac = 0; + } else { + const prevFrac = lastBeepIdx > 0 ? stepFracs[lastBeepIdx - 1] : 0; + const targetFrac = stepFracs[lastBeepIdx]; + const since = audioT - bootBeeps[lastBeepIdx].t; + // Snap open in 90 ms with quadratic ease. + const u = Math.min(1, Math.max(0, since / 0.09)); + const e = u * u * (3 - 2 * u); + openFrac = prevFrac + (targetFrac - prevFrac) * e; + } + const slitH = H * openFrac; + const slitTop = (H - slitH) / 2; + const slitBottom = slitTop + slitH; + // Black out the area outside the slit so the illustration is + // only revealed inside it. + ctx.save(); + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, W, Math.max(0, slitTop)); + ctx.fillRect(0, Math.min(H, slitBottom), W, Math.max(0, H - slitBottom)); + // Bright white glow line at top + bottom edges of the slit — + // brightest right after each beep, decaying in 200 ms. Reads as + // the doors physically sliding apart. + if (lastBeepIdx >= 0 && audioT > firstT) { + const since = audioT - bootBeeps[lastBeepIdx].t; + const glowAlpha = Math.max(0, 1 - since / 0.20); + if (glowAlpha > 0.01 && slitH > 2) { + ctx.globalAlpha = glowAlpha; + ctx.fillStyle = "#aef240"; + // Two bright bars (top + bottom) traveling outward. + ctx.fillRect(0, slitTop - 3, W, 6); + ctx.fillRect(0, slitBottom - 3, W, 6); + // Plus a soft white spill BEYOND the slit edges so the + // outward expansion reads as light leaking. + ctx.globalAlpha = glowAlpha * 0.35; + ctx.fillStyle = "rgba(255,255,255,1)"; + const spillH = 30; + ctx.fillRect(0, slitTop - spillH, W, spillH); + ctx.fillRect(0, slitBottom, W, spillH); + } + } + ctx.restore(); + } + } + + // ── shutdown sequence — CRT-style collapse over the tape-stop tail. + // Starts ~3s before duration ends. Cinematic destroy: scanline + // sweep, vertical squeeze of the entire frame toward a thin + // horizontal line, then the line itself fades to a single white + // dot, then black. Destroys EVERYTHING (illustration, events, + // title, timecode, progress bar). + if (hasTapeStop) { + const shutdownStart = duration - 3.5; + if (audioT > shutdownStart) { + const f = Math.min(1, (audioT - shutdownStart) / 3.5); + ctx.save(); + // Phase 1 (f 0..0.55): scanline glitch + RGB shift starts subtle + // and intensifies. Implemented as a translucent horizontal line + // sweep + a few magenta+cyan offset strips. + if (f < 0.85) { + const scanFreq = 12 + f * 120; // dense scanlines + const scanAlpha = 0.15 + f * 0.55; + ctx.globalAlpha = scanAlpha; + ctx.fillStyle = "rgba(0,0,0,1)"; + const stripeH = 2; + const period = Math.max(stripeH * 2, Math.floor(H / scanFreq)); + for (let y = 0; y < H; y += period) { + ctx.fillRect(0, y, W, stripeH); + } + // Bright sweeping scanline that rakes down then up faster as + // f grows. + const sweepY = ((audioT * (60 + f * 360)) % H); + ctx.globalAlpha = 0.6 * (1 - f); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.fillRect(0, sweepY, W, 2); + } + ctx.restore(); + // Phase 2 (f 0.55..0.9): vertical CRT-squeeze. Copy what's + // currently on the canvas (background → bottom strip) and + // re-draw it scaled vertically toward the center. + if (f > 0.55) { + const sq = Math.min(1, (f - 0.55) / 0.30); + // Squeeze ratio: 1 → 0.05 (almost a line) + const squeezeR = 1 - sq * 0.95; + const cy = H / 2; + const newH = Math.max(2, H * squeezeR); + const newY = cy - newH / 2; + // Snapshot what's there, blank, then redraw squeezed. + const snap = canvas.toBuffer("raw"); + const imageData = shutdownCtx.createImageData(W, H); + imageData.data.set(snap); + shutdownCtx.putImageData(imageData, 0, 0); + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, W, H); + ctx.drawImage(shutdownCanvas, 0, 0, W, H, 0, newY, W, newH); + // Bright horizontal afterglow line at center + ctx.save(); + ctx.globalAlpha = 0.4 + sq * 0.6; + ctx.strokeStyle = "rgba(255,255,255,1)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, cy); + ctx.lineTo(W, cy); + ctx.stroke(); + ctx.restore(); + } + // Phase 3 (f 0.9..1.0): center dot fades. Already squeezed to + // a line — now collapse that line to a tiny dot at center, then + // fade to black. + if (f > 0.9) { + const dot = Math.min(1, (f - 0.9) / 0.10); + ctx.fillStyle = `rgba(0,0,0,${(0.5 + dot * 0.5).toFixed(3)})`; + ctx.fillRect(0, 0, W, H); + const dotR = Math.max(0, 6 * (1 - dot)); + if (dotR > 0) { + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.beginPath(); + ctx.arc(W / 2, H / 2, dotR, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } +} + +// ── assets dir for any future intermediate files ───────────────────── +const assetsDir = TRACK.replace(/\.mp3$/, ".assets"); +mkdirSync(assetsDir, { recursive: true }); + +// ── ffmpeg encoder via stdin pipe ──────────────────────────────────── +const totalFrames = Math.ceil(duration * FPS); +console.log(`▸ rendering ${OUT}`); +console.log(` ${totalFrames} frames @ ${FPS}fps · ${W}x${H} canvas · ${LANES.length} lanes`); + +// ── PROBE MODE — render a handful of still frames and exit ─────────── +// `--probe-frames "5.2,18.7,46.0"` renders just those audio seconds to +// PNGs next to --out (…-probe-.png) and skips ffmpeg entirely. Lets +// the stained-glass / chrome look be judged in seconds, not a 10-min +// full encode. +if (flags["probe-frames"]) { + const ts = String(flags["probe-frames"]).split(",") + .map((s) => Number(s.trim())).filter((n) => Number.isFinite(n)); + const base = OUT.replace(/\.mp4$/i, ""); + for (const sec of ts) { + drawFrame(sec + AUDIO_DELAY_SEC); // undo the in-fn audio offset + const p = `${base}-probe-${sec.toFixed(2)}.png`; + writeFileSync(p, canvas.toBuffer("image/png")); + console.log(` ▸ probe ${sec.toFixed(2)}s → ${p}`); + } + console.log(`✓ probe complete (${ts.length} frame(s)) — no video encoded`); + process.exit(0); +} + +// node-canvas `toBuffer('raw')` emits BGRA in native byte order on +// little-endian systems (Apple silicon, x86_64). Feeding ffmpeg with +// -pix_fmt rgba was swapping the red and blue channels — switching +// to bgra resolves it. +const ff = spawn("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-f", "rawvideo", + "-pix_fmt", "bgra", + "-s", `${W}x${H}`, + "-r", String(FPS), + "-i", "pipe:0", + "-i", TRACK, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "medium", + "-crf", "20", + // Re-encode to AAC for MP4 container compatibility (mp3-in-mp4 + // ships but QuickTime + most players drop the audio). The + // encoder delay is small (~43 ms) and gapless metadata keeps it + // imperceptible in playback. + "-c:a", "aac", "-b:a", "192k", + "-r", String(FPS), + "-t", String(duration), + "-shortest", + OUT, +], { stdio: ["pipe", "inherit", "inherit"] }); + +ff.on("error", (e) => { + console.error("ffmpeg spawn error:", e); + process.exit(1); +}); + +// Stream frames. Pause-on-backpressure pattern so we don't OOM. +const t0 = Date.now(); +let lastLog = t0; +async function streamFrames() { + for (let f = 0; f < totalFrames; f++) { + const t = f / FPS; + drawFrame(t); + // node-canvas's toBuffer("raw") returns a Buffer that shares + // memory with the canvas pixel backing store. ffmpeg.stdin.write + // is async — without an explicit copy the next drawFrame call + // clobbers buffers still waiting to be consumed by ffmpeg, which + // surfaces as pixel-identical "frozen" chunks in the output mp4. + const buf = Buffer.from(canvas.toBuffer("raw")); + if (!ff.stdin.write(buf)) { + await new Promise((res) => ff.stdin.once("drain", res)); + } + const now = Date.now(); + if (now - lastLog > 2000) { + const pct = ((f / totalFrames) * 100).toFixed(1); + const fps = (f / ((now - t0) / 1000)).toFixed(1); + console.log(` · frame ${f}/${totalFrames} (${pct}%) · ${fps} fps`); + lastLog = now; + } + } + ff.stdin.end(); +} + +streamFrames().catch((e) => { + console.error("frame streaming error:", e); + ff.kill(); + process.exit(1); +}); + +ff.on("exit", (code) => { + if (code !== 0) { + console.error(`✗ ffmpeg exited with code ${code}`); + process.exit(1); + } + console.log(`✓ ${OUT}`); +}); diff --git a/pop/dance/bin/cover.mjs b/pop/dance/bin/cover.mjs new file mode 100644 index 0000000000..798c6e43c7 --- /dev/null +++ b/pop/dance/bin/cover.mjs @@ -0,0 +1,613 @@ +#!/usr/bin/env node +// cover.mjs — generate a dance-lane cover PNG and optionally embed it +// into an mp3 as the ID3v2 attached picture (same shape big-pictures +// uses via pop/bin/finalize.mjs). +// +// Usage: +// node pop/dance/bin/cover.mjs --slug trance-default \ +// --title "trance" --subtitle "138 bpm · a minor" \ +// --out ~/Desktop/trance-cover.png +// +// node pop/dance/bin/cover.mjs --slug trance-default \ +// --title "trance" \ +// --embed ~/Desktop/trance-full.mp3 # embed into the mp3 +// +// Cover style: SVG-rendered radial gradient + sawtooth horizon line + +// big title + handle. SVG → PNG via `magick`. + +import { + writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, renameSync, +} from "node:fs"; +import { resolve, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; +// homedir is used inside FONT_TTFS for the @font-face base64 embed. + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } +} +function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +const SLUG = flags.slug || "trance"; +const TITLE = flags.title || SLUG; +const SUBTITLE = flags.subtitle || ""; +const HANDLE = flags.handle || "@jeffrey"; +const LANE = flags.lane || "dance"; +const OUT_PATH = expandHome(flags.out) || `/tmp/${SLUG}-cover.png`; +const EMBED = expandHome(flags.embed) || null; +const HUE = flags.hue !== undefined ? Number(flags.hue) : null; // explicit hue override +const COLOR = flags.color || null; // named: "yellow" | "cyan" | ... +const FONT = flags.font || "YWFT Processing"; // serif/sans family +const WAVEFORM = expandHome(flags.waveform) || null; // audio file to render +const ILLUSTRATION = expandHome(flags.illustration) || null; // background image + +// ── palette ────────────────────────────────────────────────────────── +// Priority: --color name > --hue value > slug-hashed default +const NAMED_HUES = { red:0, orange:30, yellow:54, green:120, cyan:185, blue:220, purple:275, pink:325 }; +const slugHash = createHash("sha256").update(SLUG).digest(); +let baseHue; +if (COLOR && NAMED_HUES[COLOR] !== undefined) baseHue = NAMED_HUES[COLOR]; +else if (HUE !== null && Number.isFinite(HUE)) baseHue = HUE; +else baseHue = slugHash[0] * 360 / 256; +function hsl(hueDeg, sat, light) { + return `hsl(${hueDeg.toFixed(1)}, ${(sat * 100).toFixed(0)}%, ${(light * 100).toFixed(0)}%)`; +} +// Yellow center, magenta/cyan edges — multi-hue cover with bands. +const isYellow = COLOR === "yellow" || (baseHue >= 45 && baseHue <= 65); +const colors = isYellow ? { + bgInner: hsl(50, 0.95, 0.58), // pure yellow center + bgMid: hsl(15, 0.85, 0.50), // orange-red ring + bgOuter: hsl(290, 0.75, 0.20), // dark magenta edge + accent: hsl(190, 0.85, 0.22), // teal-on-yellow ink + fg: hsl(20, 0.85, 0.08), // near-black + dim: hsl(28, 0.70, 0.25), + stripe1: hsl(190, 0.85, 0.50), // cyan + stripe2: hsl(15, 0.95, 0.55), // orange + stripe3: hsl(290, 0.75, 0.55), // magenta + stripe4: hsl(120, 0.70, 0.40), // green +} : { + bgInner: hsl(baseHue, 0.85, 0.25), + bgMid: hsl((baseHue + 60) % 360, 0.75, 0.20), + bgOuter: hsl((baseHue + 200) % 360, 0.55, 0.06), + accent: hsl((baseHue + 40) % 360, 0.95, 0.62), + fg: hsl(baseHue, 0.12, 0.97), + dim: hsl(baseHue, 0.30, 0.72), + stripe1: hsl((baseHue + 30) % 360, 0.85, 0.50), + stripe2: hsl((baseHue + 120) % 360, 0.85, 0.50), + stripe3: hsl((baseHue + 210) % 360, 0.85, 0.50), + stripe4: hsl((baseHue + 300) % 360, 0.85, 0.50), +}; + +const W = 1500; +const H = 1500; +const parts = []; +const textOverlays = []; +parts.push(``); +parts.push(``); + +// Embed the YWFT font as base64 so rsvg-convert (which doesn't read +// ~/Library/Fonts via macOS Core Text) can still render it. +const FONT_TTFS = { + "YWFT Processing": { + bold: `${homedir()}/Library/Fonts/ywft-processing-bold.ttf`, + light: `${homedir()}/Library/Fonts/ywft-processing-light.ttf`, + }, +}; +const ttfs = FONT_TTFS[FONT]; +if (ttfs && existsSync(ttfs.bold)) { + const fontStyle = [``); + parts.push(fontStyle.join("\n")); +} + +// Multi-hue radial gradient: yellow center → orange ring → magenta edge. +parts.push(``); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(` `); +parts.push(``); +// If an illustration was passed, embed it as the base layer (covering the +// full canvas) and skip the radial gradient. The illustration prompt +// leaves the top quarter of the frame open for title text and the +// bottom for handle/waveform — darken bands target those zones for +// readability without covering jeffrey. +const hasIllustration = ILLUSTRATION && existsSync(ILLUSTRATION); +if (hasIllustration) { + const imgBuf = readFileSync(ILLUSTRATION); + const ext = ILLUSTRATION.toLowerCase().endsWith(".jpg") || ILLUSTRATION.toLowerCase().endsWith(".jpeg") + ? "jpeg" : "png"; + const b64 = imgBuf.toString("base64"); + parts.push(``); + // Top darken — under title text band (y 0-500, fading by y=600). + parts.push(``); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(` `); + parts.push(``); + parts.push(``); + parts.push(``); +} else { + parts.push(``); +} + +// No top/bottom bars — the cover keeps the colored gradient clean. +const stripeColors = [colors.stripe1, colors.stripe2, colors.stripe3, colors.stripe4]; + +// Scatter sprites — bullet holes for the geometric cover variant, +// rainbow sparkles when an illustration is the background. Sprites +// are seeded by the slug hash so positions are deterministic and +// reproducible across rebuilds. +const spriteRng = (() => { + let s = slugHash[10] * 256 * 256 + slugHash[11] * 256 + slugHash[12] + 1; + return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return (s >>> 0) / 0xffffffff; }; +})(); +// Safe-zone test: when an illustration is the background, the face +// occupies the center-right of the frame; sprites/text must live in +// the upper-left and lower-right corner negative-space the prompt +// engineered. The geometric cover keeps its broader exclusion list. +function isInExclusion(_x, y) { + if (hasIllustration) { + // Image-gen can land the face anywhere along the horizontal axis; + // exclude the central vertical band (cx 380-1120, cy 200-1200) so + // sparkles cluster in the four corner regions regardless of where + // the model placed jeffrey. The top + bottom darken bands handle + // text readability separately. + if (_x > 380 && _x < 1120 && y > 200 && y < 1200) return true; + return false; + } + // Geometric cover — title (380-700), waveform (760-1200), handle (1280-1420). + if (y >= 380 && y <= 700) return true; + if (y >= 760 && y <= 1200) return true; + if (y >= 1280 && y <= 1420) return true; + return false; +} +// For the illustrated cover we skip sparkle overlays entirely — the +// painted illustration carries enough visual interest on its own. +// The geometric cover still gets its bullet-hole scatter (9 holes). +const SPRITE_COUNT = hasIllustration ? 0 : 9; +const sprites = []; +for (let attempts = 0; attempts < 300 && sprites.length < SPRITE_COUNT; attempts++) { + const cx = 60 + spriteRng() * (W - 120); + const cy = 60 + spriteRng() * (H - 120); + if (isInExclusion(cx, cy)) continue; + const r = hasIllustration ? 12 + spriteRng() * 28 : 24 + spriteRng() * 38; + let tooClose = false; + for (const ex of sprites) { + const dx = ex.cx - cx, dy = ex.cy - cy; + if (Math.sqrt(dx*dx + dy*dy) < r + ex.r + 30) { tooClose = true; break; } + } + if (tooClose) continue; + sprites.push({ cx, cy, r }); +} +if (hasIllustration) { + // Rainbow sparkles — 8-point star bursts in saturated hues, with a + // bright core and tapered radial spokes. Cycle through a rainbow + // palette so each sparkle is its own color. + const sparklePalette = [ + "hsl(0, 95%, 65%)", // red + "hsl(28, 95%, 60%)", // orange + "hsl(50, 95%, 60%)", // yellow + "hsl(120, 80%, 55%)", // green + "hsl(180, 80%, 60%)", // cyan + "hsl(220, 90%, 70%)", // blue + "hsl(275, 85%, 70%)", // purple + "hsl(325, 90%, 70%)", // pink + ]; + for (let i = 0; i < sprites.length; i++) { + const sp = sprites[i]; + const color = sparklePalette[i % sparklePalette.length]; + const spokes = 8; + for (let k = 0; k < spokes; k++) { + const angle = (k / spokes) * Math.PI * 2; + const len = sp.r * (k % 2 === 0 ? 1.6 : 0.8); + const x2 = sp.cx + Math.cos(angle) * len; + const y2 = sp.cy + Math.sin(angle) * len; + parts.push(``); + } + // Bright core dot. + parts.push(``); + parts.push(``); + } +} else { + for (const hole of sprites) { + const cracks = 5 + Math.floor(spriteRng() * 4); + for (let c = 0; c < cracks; c++) { + const angle = (c / cracks) * Math.PI * 2 + spriteRng() * 0.5; + const len = hole.r * (1.5 + spriteRng() * 1.5); + const x2 = hole.cx + Math.cos(angle) * len; + const y2 = hole.cy + Math.sin(angle) * len; + parts.push(``); + } + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + } +} +void stripeColors; + +// Horizon — actual song waveform if --waveform provided, else the +// generic sawtooth glyph. When an illustration is the background, the +// waveform spans the FULL width at the bottom of the cover, vertically +// centered on the @jeffrey handle baseline. Cooler rendering: thicker +// cyan-rainbow bars with a subtle glow rim. +// Handle text baseline = 1465, size 130 → visible midpoint ≈ 1400. +// Center waveform vertically on that midpoint so it threads through +// the @jeffrey glyphs like a clean oscilloscope baseline. +const horizonY = hasIllustration ? 1370 : 980; +const horizonW = hasIllustration ? W : W - 80; // FULL width when illustrated +const horizonX0 = hasIllustration ? 0 : 120; + +if (WAVEFORM && existsSync(WAVEFORM)) { + // Decode to f32 mono via ffmpeg, then bin into N peak-amplitude bars. + const probe = spawnSync("ffmpeg", [ + "-hide_banner", "-loglevel", "error", + "-i", WAVEFORM, + "-f", "f32le", "-ar", "8000", "-ac", "1", "-", + ], { encoding: "buffer", maxBuffer: 1024 * 1024 * 256 }); + if (probe.status !== 0) { + console.warn(`✗ waveform decode failed, falling back to sawtooth glyph`); + } else { + const f32 = new Float32Array(probe.stdout.buffer, probe.stdout.byteOffset, probe.stdout.byteLength / 4); + const BARS = 120; // fewer/wider bars so the waveform reads clearly + const samplesPerBar = Math.floor(f32.length / BARS); + const peaks = new Float32Array(BARS); + for (let b = 0; b < BARS; b++) { + let max = 0; + const start = b * samplesPerBar; + const end = start + samplesPerBar; + for (let i = start; i < end; i++) { + const a = Math.abs(f32[i]); + if (a > max) max = a; + } + peaks[b] = max; + } + // Normalize peaks so the loudest bar = 1.0 + let maxPeak = 0; + for (let b = 0; b < BARS; b++) if (peaks[b] > maxPeak) maxPeak = peaks[b]; + if (maxPeak > 0) for (let b = 0; b < BARS; b++) peaks[b] /= maxPeak; + + const barW = horizonW / BARS; + const maxAmp = hasIllustration ? 70 : 180; // pixels above & below center + if (hasIllustration) { + // Illustrated waveform — smooth catmull-rom curves through the + // peak amplitudes (top + mirrored bottom) drawn with a thick + // colored-pencil-style stroke, plus sparse vertical "rib" + // lines so it reads as an oscilloscope SHAPE rather than a + // rectangle grid. Way more drawing-like than the bar fill. + const sampleXs = []; + const sampleYsTop = []; + const sampleYsBot = []; + for (let b = 0; b < BARS; b++) { + const x = horizonX0 + (b + 0.5) * barW; + const h = Math.max(3, peaks[b] * maxAmp); + sampleXs.push(x); + sampleYsTop.push(horizonY - h); + sampleYsBot.push(horizonY + h); + } + // Catmull-Rom (uniform) → cubic Bezier path. Smooths the polyline. + function smoothPath(xs, ys) { + if (xs.length < 2) return ""; + let d = `M ${xs[0].toFixed(1)} ${ys[0].toFixed(1)}`; + for (let i = 0; i < xs.length - 1; i++) { + const p0x = xs[Math.max(0, i - 1)], p0y = ys[Math.max(0, i - 1)]; + const p1x = xs[i], p1y = ys[i]; + const p2x = xs[i + 1], p2y = ys[i + 1]; + const p3x = xs[Math.min(xs.length - 1, i + 2)], p3y = ys[Math.min(ys.length - 1, i + 2)]; + const c1x = p1x + (p2x - p0x) / 6; + const c1y = p1y + (p2y - p0y) / 6; + const c2x = p2x - (p3x - p1x) / 6; + const c2y = p2y - (p3y - p1y) / 6; + d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2x.toFixed(1)} ${p2y.toFixed(1)}`; + } + return d; + } + const topPath = smoothPath(sampleXs, sampleYsTop); + // Vertical rib lines — sparse, every 5th sample, faint. + for (let b = 0; b < BARS; b += 5) { + const x = sampleXs[b]; + parts.push(``); + } + // Filled body — segmented with a per-section color gradient so + // the waveform itself shows the track's progression. Each + // segment between adjacent samples is its own colored path. + // Colors cycle through the title palette across the timeline. + const WAVE_PALETTE = ["#aef240", "#f54aa6", "#ffffff", "#4ad1bf", "#f599c6", "#9aff4d", "#a44af5", "#7fe05a"]; + // Filled body using one wide path — for shape; stroked color + // segments are drawn over the top so we get a single coherent + // bubble with a multi-color outline. + let reversedBot = ""; + for (let i = sampleXs.length - 1; i >= 0; i--) { + reversedBot += `L ${sampleXs[i].toFixed(1)} ${sampleYsBot[i].toFixed(1)} `; + } + const filledBody = topPath + " " + reversedBot + " Z"; + parts.push(``); + // Bolder, COLOR-SHIFTING pencil strokes — each chunk of the + // waveform takes the next color in the palette so the line + // reads as a moving rainbow ribbon across the track length. + // Stroke width bumped 3.5 → 7 so it sits as visually prominent + // as the title typography. + const segLen = Math.max(1, Math.floor(BARS / WAVE_PALETTE.length / 2)); + function segPath(arrXs, arrYs, from, to) { + let d = `M ${arrXs[from].toFixed(1)} ${arrYs[from].toFixed(1)}`; + for (let i = from; i < to; i++) { + const p0x = arrXs[Math.max(0, i - 1)], p0y = arrYs[Math.max(0, i - 1)]; + const p1x = arrXs[i], p1y = arrYs[i]; + const p2x = arrXs[i + 1], p2y = arrYs[i + 1]; + const p3x = arrXs[Math.min(arrXs.length - 1, i + 2)], p3y = arrYs[Math.min(arrYs.length - 1, i + 2)]; + const c1x = p1x + (p2x - p0x) / 6; + const c1y = p1y + (p2y - p0y) / 6; + const c2x = p2x - (p3x - p1x) / 6; + const c2y = p2y - (p3y - p1y) / 6; + d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2x.toFixed(1)} ${p2y.toFixed(1)}`; + } + return d; + } + for (let s = 0; s < BARS - 1; s += segLen) { + const end = Math.min(s + segLen + 1, BARS - 1); + const color = WAVE_PALETTE[Math.floor((s / BARS) * WAVE_PALETTE.length) % WAVE_PALETTE.length]; + parts.push(``); + parts.push(``); + } + // No timestamp on the static cover — duration appears only in + // the video visualizer. + } else { + // Geometric cover — unchanged plain accent-color bars. + for (let b = 0; b < BARS; b++) { + const x = horizonX0 + b * barW; + const h = peaks[b] * maxAmp; + parts.push(``); + } + parts.push(``); + } + } +} else if (!hasIllustration) { + // Fallback: original sawtooth silhouette — only on the GEOMETRIC + // cover. The illustration cover stays clean if no --waveform is + // provided; the yellow sawtooth was reading as a stray graphic + // overlayed on the painted scene. + const sawAmp = 110; + const sawTeeth = 14; + let sawPath = `M ${horizonX0} ${horizonY}`; + for (let i = 0; i < sawTeeth; i++) { + const x2 = horizonX0 + ((i + 0.5) / sawTeeth) * horizonW; + const x3 = horizonX0 + ((i + 1) / sawTeeth) * horizonW; + sawPath += ` L ${x2.toFixed(1)} ${(horizonY - sawAmp).toFixed(1)} L ${x3.toFixed(1)} ${horizonY}`; + } + const glowPath = sawPath + ` L ${horizonX0 + horizonW} ${horizonY + 220} L ${horizonX0} ${horizonY + 220} Z`; + parts.push(``); + parts.push(``); +} + +// Text is rendered by magick in a post-pass (rsvg-convert can't load +// YWFT from ~/Library/Fonts on macOS even with @font-face data URIs). +// Store text layout here; the magick overlay step consumes it. +const titleStr = TITLE.toLowerCase(); +// textOverlays is declared up near the SVG canvas init so the waveform +// block can push the track-length label into it. + +if (hasIllustration) { + // Larger title — bumped max to 220 so the YWFT glyphs read big. + // Rainbow per-character via magick with measured kerning + dark + // drop shadow (glow removed so the YWFT pixel edges stay crisp). + const illustTitleSize = Math.min(220, Math.floor((W - 240) / (titleStr.length * 0.45))); + textOverlays.push({ text: titleStr, x: W / 2, y: 280, size: illustTitleSize, color: "white", weight: 800, anchor: "middle", rainbow: true }); + // No subtitle, no LANE — photo + fairies + title + handle are enough. + // @jeffrey: larger (130), pushed further into the bottom-right + // corner with more margin (90 px from each edge). + // Track-length label goes near the waveform (pushed below after the + // waveform code computes the duration). No byline under the title. +} else { + // Geometric cover — original centered layout. + const titleFontSize = Math.min(280, Math.floor((W - 240) / (titleStr.length * 0.5))); + textOverlays.push({ text: titleStr, x: W / 2, y: 550, size: titleFontSize, color: colors.fg, weight: 800, anchor: "middle" }); + if (SUBTITLE) { + textOverlays.push({ text: SUBTITLE, x: W / 2, y: 650, size: 38, color: colors.dim, weight: 500, anchor: "middle" }); + } + textOverlays.push({ text: LANE, x: W / 2, y: 1180, size: 56, color: colors.fg, weight: 700, anchor: "middle" }); + textOverlays.push({ text: HANDLE, x: W / 2, y: 1350, size: 120, color: colors.accent, weight: 800, anchor: "middle" }); +} + +parts.push(``); + +const svgPath = OUT_PATH.replace(/\.png$/, ".svg"); +const bgPngPath = OUT_PATH.replace(/\.png$/, ".bg.png"); +mkdirSync(dirname(OUT_PATH), { recursive: true }); +writeFileSync(svgPath, parts.join("\n")); + +// Step 1: rsvg-convert renders the SVG (graphics only) → bg.png +const conv = spawnSync("rsvg-convert", ["-w", String(W), "-o", bgPngPath, svgPath], { stdio: "inherit" }); +if (conv.status !== 0 || !existsSync(bgPngPath)) { + console.error("✗ rsvg-convert failed — leaving SVG only:", svgPath); + process.exit(1); +} +try { unlinkSync(svgPath); } catch {} + +// Step 2: magick draws text on top using the explicit YWFT TTF path. +// rsvg can't find user-Library fonts; magick can take a -font path. +const ttfPath = ttfs?.bold || null; + +// Measure a string's rendered width at a given font size using the +// supplied TTF. Returns 0 on failure. Used for accurate per-char +// kerning in the rainbow title render. +function magickMeasureWidth(text, fontSize) { + const r = spawnSync("magick", [ + "-font", ttfPath, + "-pointsize", String(fontSize), + `label:${text}`, + "-format", "%w", + "info:", + ], { encoding: "utf8" }); + if (r.status !== 0) return 0; + return parseInt(r.stdout.trim(), 10) || 0; +} + +// Per-character title palette — yellow dropped per the trancenwaltz +// cover direction. Chartreuse-leaning greens + pinks + cyan + warm +// off-white carry the chartreuse-album palette without the heavy +// yellow notes the rainbow set kept landing on the "a"s and "t"s. +const RAINBOW = [ + "#aef240", // chartreuse-green + "#f54aa6", // hot pink + "#ffffff", // warm white + "#4ad1bf", // cyan + "#f599c6", // bubblegum pink + "#9aff4d", // bright chartreuse + "#a44af5", // purple + "#7fe05a", // mid green +]; + +if (ttfPath && existsSync(ttfPath)) { + const magickArgs = [bgPngPath]; + for (const ov of textOverlays) { + const fontSize = Math.round(ov.size); + const drawY = Math.round(ov.y); + + if (ov.rainbow) { + // Measure cumulative widths of each prefix so each character's + // x position respects the font's actual kerning. Then draw per + // character: dark drop shadow → blurred colored glow → solid + // colored glyph on top. + const chars = [...ov.text]; + const prefixWidths = [0]; + let cumulative = ""; + for (const ch of chars) { + cumulative += ch; + prefixWidths.push(magickMeasureWidth(cumulative, fontSize) || prefixWidths[prefixWidths.length - 1] + fontSize * 0.45); + } + const totalWidth = prefixWidths[prefixWidths.length - 1]; + let baseX; + if (ov.anchor === "middle") baseX = Math.round(ov.x - totalWidth / 2); + else if (ov.anchor === "end") baseX = Math.round(ov.x - totalWidth); + else baseX = Math.round(ov.x); + + // Sharp YWFT — drop shadow + solid colored letter only. The + // previous halo glow softened the pixel-style edges; removing + // it keeps the letterforms crisp. + let hueIdx = 0; + for (let i = 0; i < chars.length; i++) { + const ch = chars[i]; + const x = baseX + prefixWidths[i]; + // Drop shadow — sharp 5px offset, near-black, no blur. + magickArgs.push("-font", ttfPath, "-pointsize", String(fontSize)); + magickArgs.push("-fill", "rgba(0,0,0,0.78)"); + magickArgs.push("-annotate", `+${x + 5}+${drawY + 5}`, ch); + if (ch.trim()) { + const color = RAINBOW[hueIdx % RAINBOW.length]; + // Solid colored letter on top — no glow halo so YWFT edges stay sharp. + magickArgs.push("-font", ttfPath, "-pointsize", String(fontSize)); + magickArgs.push("-fill", color); + magickArgs.push("-annotate", `+${x}+${drawY}`, ch); + hueIdx++; + } + } + } else if (ov.shadow) { + // Single-color overlay with sharp drop shadow (no glow halo). + // The colored glow was softening YWFT's pixel edges — dropped + // so the letterforms read crisply on the cover. + const charW = fontSize * 0.5; + const totalWidth = ov.text.length * charW; + let baseX; + if (ov.anchor === "middle") baseX = Math.round(ov.x - totalWidth / 2); + else if (ov.anchor === "end") baseX = Math.round(ov.x - totalWidth); + else baseX = Math.round(ov.x); + magickArgs.push("-font", ttfPath, "-pointsize", String(fontSize)); + magickArgs.push("-fill", "rgba(0,0,0,0.78)"); + magickArgs.push("-annotate", `+${baseX + 6}+${drawY + 6}`, ov.text); + magickArgs.push("-font", ttfPath, "-pointsize", String(fontSize)); + magickArgs.push("-fill", ov.color); + magickArgs.push("-annotate", `+${baseX}+${drawY}`, ov.text); + } else { + // Plain single-color annotate — original path for the geometric + // cover. + const charW = fontSize * 0.5; + const totalWidth = ov.text.length * charW; + let baseX; + if (ov.anchor === "middle") baseX = Math.round(ov.x - totalWidth / 2); + else if (ov.anchor === "end") baseX = Math.round(ov.x - totalWidth); + else baseX = Math.round(ov.x); + magickArgs.push("-font", ttfPath, "-pointsize", String(fontSize)); + magickArgs.push("-fill", ov.color); + magickArgs.push("-annotate", `+${baseX}+${drawY}`, ov.text); + } + } + magickArgs.push(OUT_PATH); + const mr = spawnSync("magick", magickArgs, { stdio: "inherit" }); + if (mr.status !== 0) { + console.error("✗ magick text overlay failed — copying bg only"); + spawnSync("cp", [bgPngPath, OUT_PATH]); + } +} else { + spawnSync("cp", [bgPngPath, OUT_PATH]); +} +try { unlinkSync(bgPngPath); } catch {} +console.log(`✓ ${OUT_PATH}`); + +// ── optionally embed into the mp3 ───────────────────────────────────── +if (EMBED) { + if (!existsSync(EMBED)) { + console.error(`✗ --embed target not found: ${EMBED}`); + process.exit(1); + } + console.log(`→ embedding cover into ${EMBED}`); + const tmpOut = `${EMBED}.with-cover.mp3`; + const args = [ + "-hide_banner", "-y", "-loglevel", "error", + "-i", EMBED, + "-i", OUT_PATH, + "-map", "0:a", + "-map", "1", + "-c", "copy", + "-id3v2_version", "3", + "-write_id3v1", "1", + "-disposition:v", "attached_pic", + "-metadata:s:v", "title=Album cover", + "-metadata:s:v", "comment=Cover (front)", + "-metadata", `title=${TITLE}`, + "-metadata", `artist=aesthetic.computer`, + "-metadata", `album=pixsies`, + tmpOut, + ]; + const ff = spawnSync("ffmpeg", args, { stdio: "inherit" }); + if (ff.status !== 0) { + console.error("✗ ffmpeg embed failed"); + try { unlinkSync(tmpOut); } catch {} + process.exit(1); + } + // Atomic swap + renameSync(tmpOut, EMBED); + console.log(`✓ cover embedded in ${EMBED}`); +} diff --git a/pop/dance/bin/detect-face.py b/pop/dance/bin/detect-face.py new file mode 100644 index 0000000000..a4f62a317f --- /dev/null +++ b/pop/dance/bin/detect-face.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""detect-face.py — find the dominant face bbox in an image and write + a sidecar JSON. Used by cover-video.mjs to ken-burns into jeffrey's + face for a more dynamic illustration animation. + +Usage: + recap/.venv/bin/python3 pop/dance/bin/detect-face.py + +Writes .face.json next to the input image, with shape: + {"x": int, "y": int, "w": int, "h": int, "imgW": int, "imgH": int} +or {"detected": false, "imgW": int, "imgH": int} when no face is found. +""" + +from __future__ import annotations +import json +import sys +from pathlib import Path + +import cv2 + +if len(sys.argv) < 2: + print("usage: detect-face.py ", file=sys.stderr) + sys.exit(1) + +img_path = Path(sys.argv[1]).expanduser().resolve() +if not img_path.exists(): + print(f"image not found: {img_path}", file=sys.stderr) + sys.exit(1) + +img = cv2.imread(str(img_path)) +if img is None: + print(f"failed to read: {img_path}", file=sys.stderr) + sys.exit(1) + +h, w = img.shape[:2] +gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) +gray = cv2.equalizeHist(gray) + +cascade_path = Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml" +cascade = cv2.CascadeClassifier(str(cascade_path)) +if cascade.empty(): + print(f"failed to load haar cascade: {cascade_path}", file=sys.stderr) + sys.exit(2) + +faces = cascade.detectMultiScale( + gray, + scaleFactor=1.10, + minNeighbors=5, + minSize=(int(0.08 * w), int(0.08 * h)), +) + +out_path = img_path.with_suffix(img_path.suffix + ".face.json") + +if len(faces) == 0: + out_path.write_text(json.dumps({"detected": False, "imgW": w, "imgH": h}, indent=2)) + print(f"no face detected · wrote {out_path}", file=sys.stderr) + sys.exit(0) + +# Pick the largest face (likely the subject in a portrait). +faces_sorted = sorted(faces, key=lambda f: int(f[2]) * int(f[3]), reverse=True) +fx, fy, fw, fh = [int(v) for v in faces_sorted[0]] +out_path.write_text( + json.dumps({"x": fx, "y": fy, "w": fw, "h": fh, "imgW": w, "imgH": h}, indent=2) +) +print(f"face at ({fx},{fy}) size ({fw}x{fh}) · wrote {out_path}", file=sys.stderr) diff --git a/pop/dance/bin/detect-laptop.py b/pop/dance/bin/detect-laptop.py new file mode 100644 index 0000000000..b25f9178df --- /dev/null +++ b/pop/dance/bin/detect-laptop.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""detect-laptop.py — find jeffrey's chartreuse-green macbook neo bbox + in an illustration and write a sidecar JSON. Used by cover-video.mjs + for the lane-driven backlight effect (hats power the laptop region). + +Strategy: + 1. Convert to HSV. + 2. Mask the chartreuse-green range (roughly H 50-85, S 100-255, + V 100-255 — narrow band around the AC laptop color #aef240). + 3. Morphology (close → open) to consolidate the laptop body and + drop small specks (pixie laptop doodles, leaves, fabric noise). + 4. Find the largest connected component → its bounding box is the + laptop. Filter: area >= 1% of frame, aspect roughly horizontal + or square (lid open vs closed). + 5. Sidecar JSON shape: + {"x": int, "y": int, "w": int, "h": int, "imgW": int, "imgH": int} + or {"detected": false, "imgW": int, "imgH": int} on miss. + +Usage: + recap/.venv/bin/python3 pop/dance/bin/detect-laptop.py +""" + +from __future__ import annotations +import json +import sys +from pathlib import Path + +import cv2 +import numpy as np + +if len(sys.argv) < 2: + print("usage: detect-laptop.py ", file=sys.stderr) + sys.exit(1) + +img_path = Path(sys.argv[1]).expanduser().resolve() +if not img_path.exists(): + print(f"image not found: {img_path}", file=sys.stderr) + sys.exit(1) + +img = cv2.imread(str(img_path)) +if img is None: + print(f"failed to read: {img_path}", file=sys.stderr) + sys.exit(1) + +h, w = img.shape[:2] +hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + +# Chartreuse-green band — H tuned to #aef240's hue (~75 in OpenCV's +# 0-180 range). Two-pass tighter band: the laptop body is a HIGH- +# saturation chartreuse-yellow-green; foliage tends to be lower +# saturation OR more pure green. We bias toward the yellow-leaning +# chartreuse range and require high S+V to skip both shadowed leaves +# and pale highlights. +H_LOW, H_HIGH = 35, 75 +S_LOW, V_LOW = 140, 150 +lower = np.array([H_LOW, S_LOW, V_LOW], dtype=np.uint8) +upper = np.array([H_HIGH, 255, 255], dtype=np.uint8) +mask = cv2.inRange(hsv, lower, upper) + +# Morphology: close gaps inside the laptop body, drop tiny specks. +kClose = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) +kOpen = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) +mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kClose, iterations=2) +mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kOpen, iterations=1) + +# Connected components → keep big-enough green blobs. +n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S) + +min_area = int(0.005 * w * h) +max_area = int(0.30 * w * h) # bigger than this = sky / foliage, not a laptop +candidates = [] +for i in range(1, n): + x, y, ww, hh, area = stats[i] + if area < min_area or area > max_area: + continue + if ww < 40 or hh < 30: + continue + aspect = ww / max(1, hh) + if aspect < 0.55 or aspect > 5.5: + continue + # Bias toward laptop-positioned blobs: jeffrey holds the laptop in + # the lower 2/3 of the frame, mid-horizontally. A blob whose top + # edge is in the upper 15 % is almost certainly background/sky. + if y < int(0.15 * h): + continue + candidates.append((area, x, y, ww, hh)) + +out_path = img_path.with_suffix(img_path.suffix + ".laptop.json") + +if not candidates: + out_path.write_text(json.dumps({"detected": False, "imgW": w, "imgH": h}, indent=2)) + print(f"no laptop detected · wrote {out_path}", file=sys.stderr) + sys.exit(0) + +# Largest qualifying green blob = jeffrey's laptop. +candidates.sort(key=lambda c: c[0], reverse=True) +_, x, y, ww, hh = candidates[0] +out_path.write_text( + json.dumps({"x": int(x), "y": int(y), "w": int(ww), "h": int(hh), + "imgW": w, "imgH": h}, indent=2) +) +print(f"laptop at ({x},{y}) size ({ww}x{hh}) · wrote {out_path}", file=sys.stderr) diff --git a/pop/dance/bin/diagram.mjs b/pop/dance/bin/diagram.mjs new file mode 100644 index 0000000000..0e98e34521 --- /dev/null +++ b/pop/dance/bin/diagram.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// diagram.mjs — render a track structure PNG from a trance.struct.json. +// +// Reads the struct JSON emitted by recap/bin/trance.mjs and produces a +// 1600x900 PNG that shows: title bar (meter, BPM, scale, total length), +// section blocks colored by type, a layer activity grid (one row per +// instrument), and any per-section fx markers (wobble / bitcrush). +// +// Output is SVG first, converted to PNG via `magick`. +// +// Usage: +// node pop/dance/bin/diagram.mjs [--out path.png] + +import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname, basename } from "node:path"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; + +const argv = process.argv.slice(2); +const flags = {}; +const positional = []; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } else positional.push(a); +} +function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +const structPath = positional[0]; +if (!structPath) { console.error("usage: diagram.mjs [--out path.png]"); process.exit(2); } +const outPath = expandHome(flags.out) || structPath.replace(/\.struct\.json$/, ".struct.png"); + +const struct = JSON.parse(readFileSync(structPath, "utf8")); +const W = 1600; +const H = 900; +const margin = { l: 140, r: 60, t: 100, b: 60 }; + +// ── colors per section type ────────────────────────────────────────── +function sectionColor(name) { + if (name.startsWith("intro")) return "#3a3a44"; + if (name.startsWith("outro")) return "#3a3a44"; + if (name.startsWith("break")) return "#2c4f7a"; + if (name.startsWith("build")) return "#c97a2a"; + if (name.startsWith("drop")) return "#b8345a"; + return "#555"; +} + +const LAYERS = ["kick", "hat", "sub", "pad", "lead", "piano", "bells", "vocal", "riser", "snareRoll"]; +const LAYER_LABELS = { + kick: "kick", + hat: "hi-hat", + sub: "sub bass", + pad: "pad (saw)", + lead: "lead (saw)", + piano: "piano", + bells: "sinebells", + vocal: "vocal", + riser: "riser", + snareRoll: "snare roll", +}; + +// ── layout ──────────────────────────────────────────────────────────── +const innerW = W - margin.l - margin.r; +const innerH = H - margin.t - margin.b; +const sectionH = 80; +const sectionY = margin.t; +const gridY = sectionY + sectionH + 24; +const gridH = innerH - sectionH - 24 - 28; // leave room for time axis +const rowH = gridH / LAYERS.length; +const totalSec = struct.totalSec || 1; + +function xForSec(s) { + return margin.l + (s / totalSec) * innerW; +} + +// ── SVG body ────────────────────────────────────────────────────────── +const parts = []; +parts.push(``); +parts.push(``); + +// Background +parts.push(``); + +// Title block +const meterLabel = struct.meter === 3 ? "3/4 · trancewaltz" : "4/4 · trance"; +const minutes = Math.floor(totalSec / 60); +const seconds = Math.round(totalSec - minutes * 60).toString().padStart(2, "0"); +parts.push(`${meterLabel}`); +parts.push(`${struct.bpm} BPM · ${struct.scale} · ${struct.totalBars} bars · ${minutes}:${seconds}`); + +// Right-side stats +const c = struct.counts; +let statsTxt = `kick ${c.kickCount} · hat ${c.hatCount} · sub ${c.subCount} · pad ${c.padCount} · lead ${c.leadCount}`; +if (c.pianoCount) statsTxt += ` · piano ${c.pianoCount}`; +if (c.bellsCount) statsTxt += ` · bells ${c.bellsCount}`; +if (c.riserCount) statsTxt += ` · riser ${c.riserCount}`; +if (c.snareRollCount) statsTxt += ` · snare-roll ${c.snareRollCount}`; +parts.push(`${statsTxt}`); + +// Section blocks +for (const s of struct.sections) { + const x = xForSec(s.startSec); + const w = Math.max(2, xForSec(s.endSec) - x); + parts.push(``); + + // Section label + const labelTxt = s.name; + const subTxt = `${s.endBar - s.startBar}b`; + if (w > 50) { + parts.push(`${labelTxt}`); + parts.push(`${subTxt}`); + } + + // FX marker + if (s.fx && s.fx.length) { + const fxTxt = s.fx.map((f) => f === "wobble" ? "~wob" : f === "bitcrush" ? "⌐crush" : f).join(" "); + parts.push(`${fxTxt}`); + } +} + +// Layer grid +for (let li = 0; li < LAYERS.length; li++) { + const layer = LAYERS[li]; + const y = gridY + li * rowH; + // Row label + parts.push(`${LAYER_LABELS[layer]}`); + // Background row + parts.push(``); + // Per-section activity + for (const s of struct.sections) { + if (!s.layers[layer]) continue; + const x = xForSec(s.startSec); + const w = Math.max(2, xForSec(s.endSec) - x); + const fill = sectionColor(s.name); + parts.push(``); + } +} + +// Time axis ticks at 10s intervals +const axisY = gridY + gridH + 12; +parts.push(``); +const tickStep = totalSec > 90 ? 20 : totalSec > 30 ? 10 : 5; +for (let s = 0; s <= totalSec; s += tickStep) { + const x = xForSec(s); + parts.push(``); + const mm = Math.floor(s / 60); + const ss = Math.round(s - mm * 60).toString().padStart(2, "0"); + parts.push(`${mm}:${ss}`); +} + +// Footer: filename +const stem = basename(structPath).replace(/\.struct\.json$/, ""); +parts.push(`${stem}`); + +parts.push(``); + +const svgPath = outPath.replace(/\.png$/, ".svg"); +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(svgPath, parts.join("\n")); + +// macOS qlmanage outputs .png next to its -o dir. Convert there +// then rename to the requested outPath. +const conv = spawnSync("rsvg-convert", ["-w", String(W), "-o", outPath, svgPath], { stdio: "inherit" }); +if (conv.status !== 0 || !existsSync(outPath)) { + console.error("✗ rsvg-convert failed — leaving SVG only:", svgPath); + process.exit(1); +} +try { unlinkSync(svgPath); } catch {} +console.log(`✓ ${outPath}`); diff --git a/pop/dance/bin/extract-whistlegraph-final.py b/pop/dance/bin/extract-whistlegraph-final.py new file mode 100644 index 0000000000..a59d8a775f --- /dev/null +++ b/pop/dance/bin/extract-whistlegraph-final.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""extract-whistlegraph-final.py — find the LAST drawing in a + whistlegraph score and crop tightly to its bounding box. + +Steps per score image: + 1. grayscale + threshold (anything below ~190 = "ink") + 2. dilate vertically to fuse drawing strokes that are close + 3. connected components → bounding boxes + 4. filter out tiny boxes (lyric / handwriting text fragments) by + keeping only boxes whose height >= 220 px (drawings are tall) + and area >= 30k px (drawings are big) + 5. drop the very-top row (title + composer-name) and very-bottom + row (composer credit) by ignoring boxes near the page edges + 6. sort surviving boxes by (y, x) — top-to-bottom, left-to-right + 7. take the LAST one — the canonical final drawing + 8. crop with a small white-border margin and write to + +Usage: + python3 extract-whistlegraph-final.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import cv2 +import numpy as np + +if len(sys.argv) < 3: + print("usage: extract-whistlegraph-final.py ", file=sys.stderr) + sys.exit(1) + +src = Path(sys.argv[1]).expanduser().resolve() +dst = Path(sys.argv[2]).expanduser().resolve() +img = cv2.imread(str(src)) +if img is None: + print(f"failed to read {src}", file=sys.stderr) + sys.exit(1) +H, W = img.shape[:2] + +gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) +# Ink mask — pixels darker than ~190 (most drawings are pencil/red lines) +_, ink = cv2.threshold(gray, 190, 255, cv2.THRESH_BINARY_INV) +# Dilate to fuse drawing strokes that are close to each other, but +# keep the kernel skinny vertically so we don't accidentally merge +# a drawing with its lyric label below it (loner / etc.). We keep +# the un-dilated `ink` around for the post-crop row-gap scan since +# dilation closes the small gap between figure and label. +kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (12, 4)) +mask = cv2.dilate(ink, kernel, iterations=1) + +# Connected components. +n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S) + +candidates = [] +for i in range(1, n): + x, y, w, h, area = stats[i] + # Drop title row (very top) and composer credit (very bottom). + if y < int(H * 0.05): + continue + if y + h > int(H * 0.95): + continue + # Drawings are tall + biggish. + if h < 200: + continue + if area < 25000: + continue + # Skip absurdly thin components (likely a single horizontal line of text). + if w / h < 0.30: + continue + candidates.append((x, y, w, h, area)) + +if not candidates: + print("no candidate drawings found", file=sys.stderr) + sys.exit(2) + +# Sort top-to-bottom, then left-to-right (with some y-tolerance for row +# grouping — components within 100 px vertically are "same row"). +candidates.sort(key=lambda b: (b[1] // 200, b[0])) +# The LAST one is the canonical final drawing. +fx, fy, fw, fh, _ = candidates[-1] + +# Add a small margin (5% of the drawing's max dim). +margin = int(max(fw, fh) * 0.08) +x0 = max(0, fx - margin) +y0 = max(0, fy - margin) +x1 = min(W, fx + fw + margin) +y1 = min(H, fy + fh + margin) + +# Keep the entire bbox from the connected-component pass — labels +# that fall inside the bbox are tolerated. Earlier trim attempts +# were also lopping off legs/feet of figures (butterfly, mommy-wow +# WOW lettering, people-pleaser bottom). The OpenAI prompt instructs +# the model to read the glyph and ignore any visible label text. +crop = img[y0:y1, x0:x1] + +# White-border padding for cleaner ref-image presentation. +pad = 60 +crop = cv2.copyMakeBorder(crop, pad, pad, pad, pad, cv2.BORDER_CONSTANT, value=(255, 255, 255)) +cv2.imwrite(str(dst), crop) +print(f" cropped to {x1-x0}x{y1-y0} → {dst}", file=sys.stderr) diff --git a/pop/dance/bin/hover-loop.lua b/pop/dance/bin/hover-loop.lua new file mode 100644 index 0000000000..010509162c --- /dev/null +++ b/pop/dance/bin/hover-loop.lua @@ -0,0 +1,25 @@ +-- hover-loop.lua — mpv script for the cover-video preview windows. +-- +-- • Play ONLY while the mouse cursor is over the window; pause the +-- moment it leaves (saves CPU when you're not looking at a clip). +-- • Ctrl+w closes EVERY preview window at once (plain Cmd/Ctrl+w in +-- mpv only closes the focused one — these are separate processes). +-- +-- Wired in by preview.sh via --script=. + +local function on_mouse(_, val) + if val == nil then return end + -- val.hover is true while the pointer is inside the video area. + mp.set_property_bool("pause", not val.hover) +end + +mp.observe_property("mouse-pos", "native", on_mouse) + +-- Belt-and-suspenders: also react to focus loss (e.g. cmd-tab away). +mp.observe_property("focused", "bool", function(_, focused) + if focused == false then mp.set_property_bool("pause", true) end +end) + +mp.add_key_binding("ctrl+w", "quit-all-previews", function() + mp.commandv("run", "pkill", "-x", "mpv") +end) diff --git a/pop/dance/bin/preview.sh b/pop/dance/bin/preview.sh new file mode 100755 index 0000000000..b4efee284e --- /dev/null +++ b/pop/dance/bin/preview.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# preview.sh — open one or more rendered cover videos in a clean, +# chrome-free mpv window: borderless (no title bar, so the window +# hugs the exact video aspect — ZERO black bars), autoplay, loop +# forever, no on-screen controls/OSD. Each file gets its own window, +# tiled with a small offset so multiple aspects show side by side. +# +# Usage: +# pop/dance/bin/preview.sh file1.mp4 [file2.mp4 ...] +# pop/dance/bin/preview.sh # defaults to latest tw + twi builds +# +# Press q (or Esc) in a window to close just that one. + +set -euo pipefail + +MPV="$(command -v mpv || echo /opt/homebrew/bin/mpv)" +if [[ ! -x "$MPV" ]]; then + echo "mpv not found — install with: brew install mpv" >&2 + exit 1 +fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOVER_LUA="$SCRIPT_DIR/hover-loop.lua" + +# No args → grab the newest build of each known track. +if [[ $# -eq 0 ]]; then + TW="$HOME/Desktop/builds/trancenwaltz" + TWI="$HOME/Desktop/builds/trancenwaltzi" + args=() + if [[ -d "$TW" ]]; then + b="$(ls -1d "$TW"/b??? 2>/dev/null | sort | tail -1)" + [[ -n "${b:-}" ]] && for v in "$b"/trancenwaltz-cover.mp4 "$b"/trancenwaltz-cover-vertical.mp4; do + [[ -f "$v" ]] && args+=("$v") + done + fi + if [[ -d "$TWI" ]]; then + b="$(ls -1d "$TWI"/b??? 2>/dev/null | sort | tail -1)" + [[ -n "${b:-}" && -f "$b/trancenwaltzi-cover.mp4" ]] && args+=("$b/trancenwaltzi-cover.mp4") + fi + set -- "${args[@]}" +fi + +if [[ $# -eq 0 ]]; then + echo "no videos to preview (pass paths, or build first)" >&2 + exit 1 +fi + +ox=40; oy=40 +for f in "$@"; do + if [[ ! -f "$f" ]]; then echo "skip (missing): $f" >&2; continue; fi + "$MPV" \ + --loop-file=inf \ + --no-border \ + --no-osc \ + --osd-level=0 \ + --no-osd-bar \ + --pause=yes \ + --script="$HOVER_LUA" \ + --autofit-larger=46%x46% \ + --geometry="+${ox}+${oy}" \ + --title="${f##*/}" \ + --quiet \ + "$f" >/dev/null 2>&1 & + ox=$((ox + 60)); oy=$((oy + 60)) +done + +echo "opened $# clean looping window(s) in mpv (borderless · autoplay · loop · no controls)" +# detached — windows live independently; script returns immediately +disown -a 2>/dev/null || true diff --git a/pop/dance/bin/sing.mjs b/pop/dance/bin/sing.mjs new file mode 100644 index 0000000000..4a7d76258e --- /dev/null +++ b/pop/dance/bin/sing.mjs @@ -0,0 +1,454 @@ +#!/usr/bin/env node +// sing.mjs — produce an actually-sung jeffrey-pvc vocal stem for the +// dance lane, by mirroring the amazing-grace big-pictures pipeline: +// +// 1. say.mjs --timestamps → vocal.mp3 + alignment.json +// 2. alignment → words.json +// 3. score-pitch.mjs (WORLD) → pitched.mp3 (f0 replaced from .np) +// +// The trance vocal sits in midi 57–69 (jeffrey baritone + upper iconic +// range) so WORLD's pitch-shift reads as singing, not artifacted speech. +// +// Outputs the sung stem at --out (default ~/Desktop/trance-sung-vox.mp3), +// suitable as input to recap/bin/trance.mjs --vocal-stem. +// +// Usage: +// node pop/dance/bin/sing.mjs # default settings +// node pop/dance/bin/sing.mjs --transpose 0 --out ~/Desktop/vox.mp3 +// node pop/dance/bin/sing.mjs --score pop/dance/trance-hook.np + +import { + writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, +} from "node:fs"; +import { resolve, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } +} +function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const DANCE = resolve(HERE, ".."); +const POP = resolve(DANCE, ".."); + +const SCORE_PATH = expandHome(flags.score) || `${DANCE}/trance-hook.np`; +// 64 items across 2 shuffled verses. Full alphabet A-Z, numbers 1-10, +// 15 thematic words (i, love, computer, death, driving, sex, infinite, +// silliness, talking, fighting, calling, singing, playing, writing, +// crying), plus long numbers 30456 + 8888. +// Order matches trance-hook.np strictly (word index → score position). +const LYRIC = flags.lyric || [ + // Verse 1 — "computer" appears 3× (bumped from 1×) + "i.","A.","love.","computer.","computer.","C.","talking.","death.", // Am (B → computer, then bonus computer) + "D.","fighting.","E.","F.","driving.","calling.","G.","2.", // F + "H.","sex.","singing.","J.","playing.","K.","infinite.","computer.", // C (L → computer) + "writing.","M.","3.","silliness.","crying.","N.","30456.","O.", // G + // Verse 2 — "computer" appears 3× (bumped from 1×) + "silliness.","30456.","8888.","crying.","writing.","playing.","singing.","calling.", // Am + "fighting.","talking.","P.","Q.","R.","S.","T.","U.", // F + "V.","computer.","X.","Y.","Z.","4.","5.","6.", // C (W → computer) + "7.","8.","computer.","10.","i.","love.","computer.","death.", // G (9 → computer) +].join(" "); +const TRANSPOSE = Number(flags.transpose ?? 0); +const BPM = Number(flags.bpm ?? 92); // score-stretch tempo +const MAX_STRETCH = Number(flags["max-stretch"] ?? 8.0); +const OUT_PATH = expandHome(flags.out) || `${homedir()}/Desktop/trance-sung-vox.mp3`; +const FORCE = !!flags.force; + +const OUT_DIR = `${DANCE}/out`; +mkdirSync(OUT_DIR, { recursive: true }); + +const LYRIC_TXT = `${OUT_DIR}/trance-hook.txt`; +const VOCAL_MP3 = `${OUT_DIR}/trance-hook-vocal.mp3`; +const VOCAL_ALIGN = `${VOCAL_MP3}.alignment.json`; +const WORDS_JSON = `${OUT_DIR}/trance-hook-vocal-words.json`; +const PITCHED_MP3 = `${OUT_DIR}/trance-hook-pitched.mp3`; +const STRETCHED_MP3 = `${OUT_DIR}/trance-hook-stretched.mp3`; +const STRETCHED_RAP_MP3 = `${OUT_DIR}/trance-hook-stretched-rap.mp3`; +const PITCHED_ALIGN = `${OUT_DIR}/trance-hook-pitched-alignment.json`; +const LAYERED_MP3 = `${OUT_DIR}/trance-hook-layered.mp3`; +const TMP_DIR = `${OUT_DIR}/.tmp`; +mkdirSync(TMP_DIR, { recursive: true }); + +if (!existsSync(SCORE_PATH)) { console.error(`✗ score missing: ${SCORE_PATH}`); process.exit(1); } + +// Write the lyric to a file (say.mjs reads from a path). +writeFileSync(LYRIC_TXT, LYRIC + "\n"); + +function step(name, fn) { + const t0 = Date.now(); + process.stdout.write(`▸ ${name}\n`); + fn(); + console.log(` ${((Date.now() - t0) / 1000).toFixed(1)}s`); +} + +function run(cmd, args, cwd = POP) { + const r = spawnSync(cmd, args, { cwd, stdio: ["ignore", "inherit", "inherit"] }); + if (r.status !== 0) { + console.error(`✗ ${cmd} ${args.join(" ")} failed (exit ${r.status})`); + process.exit(1); + } +} + +console.log(`━━━ dance · sing ━━━ score: ${SCORE_PATH.replace(POP + "/", "")} · transpose ${TRANSPOSE >= 0 ? "+" : ""}${TRANSPOSE}st\n`); + +// ── 1. say (jeffrey-pvc TTS with /with-timestamps alignment) ───────── +step("1 · say (TTS — stronger performance settings)", () => { + // Settings tuned for intelligibility — higher stability locks the + // jeffrey identity (memory: ≥0.5 to avoid drift) and the lower style + // value gives clearer enunciation of consonants so words like + // "computer" survive the supersaw wall in the bed. Tradeoff: a touch + // less performative expression vs the previous style=0.6 take. + const baseArgs = ["bin/say.mjs", LYRIC_TXT.replace(POP + "/", ""), + "--stability", "0.65", "--similarity", "0.92", "--style", "0.40", + "--out", VOCAL_MP3.replace(POP + "/", "")]; + const argsWithTs = [...baseArgs, "--timestamps"]; + if (FORCE) { baseArgs.push("--force"); argsWithTs.push("--force"); } + const r = spawnSync("node", argsWithTs, { cwd: POP, stdio: ["ignore", "inherit", "pipe"] }); + if (r.status === 0) return; + console.log(" ↪ falling back to plain TTS + whisper alignment"); + run("node", baseArgs); +}); + +// ── 2. align → words.json ──────────────────────────────────────────── +// Letters/numbers content fools whisper — it transcribes "A. B. C." as +// one mega-word, which breaks per-word slicing downstream. We try +// (1) ElevenLabs timestamps, (2) whisper, (3) uniform-spacing fallback +// over ffprobe-measured duration. Whichever produces enough words to +// match the lyric wins. +const lyricWords = LYRIC.split(/\s+/) + .map((w) => w.replace(/[^a-zA-Z0-9]/g, "")) + .filter(Boolean); +const expectedWords = lyricWords.length; + +step(`2 · align (target: ${expectedWords} words from lyric)`, () => { + // Try 1: ElevenLabs /with-timestamps + if (existsSync(VOCAL_ALIGN)) { + const a = JSON.parse(readFileSync(VOCAL_ALIGN, "utf8")); + if (Array.isArray(a.words) && a.words.length >= expectedWords * 0.7) { + writeFileSync(WORDS_JSON, JSON.stringify(a.words, null, 0)); + console.log(` ✓ ${a.words.length} words from ElevenLabs (exact)`); + return; + } + } + // Try 2: whisper + const wargs = ["bin/align.mjs", VOCAL_MP3.replace(POP + "/", "")]; + if (FORCE) wargs.push("--force"); + const wr = spawnSync("node", wargs, { cwd: POP, stdio: ["ignore", "pipe", "pipe"] }); + if (wr.status === 0 && existsSync(WORDS_JSON)) { + const ww = JSON.parse(readFileSync(WORDS_JSON, "utf8")); + if (ww.length >= expectedWords * 0.7) { + console.log(` ✓ ${ww.length} words from whisper`); + return; + } + console.log(` ⚠ whisper found ${ww.length} words; need ~${expectedWords} → uniform spacing`); + } + // Try 3: uniform spacing across ffprobe-measured duration + const probe = spawnSync("ffprobe", [ + "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", VOCAL_MP3, + ], { encoding: "utf8" }); + const durSec = parseFloat(probe.stdout.trim()) || 0; + if (durSec <= 0) { + console.error(`✗ ffprobe failed to read duration; cannot synthesize alignment`); + process.exit(1); + } + const durMs = durSec * 1000; + const span = durMs / expectedWords; + const synth = lyricWords.map((w, i) => ({ + text: w, + fromMs: Math.floor(i * span), + toMs: Math.floor((i + 1) * span), + })); + writeFileSync(WORDS_JSON, JSON.stringify(synth, null, 0)); + console.log(` ✓ synthesized ${synth.length} uniform-spaced word windows over ${durSec.toFixed(2)}s (${(span / 1000).toFixed(2)}s each)`); +}); + +// ── 3. score-pitch (WORLD f0 replacement → sung) ──────────────────── +step(`3 · score-pitch (WORLD · transpose ${TRANSPOSE >= 0 ? "+" : ""}${TRANSPOSE}st · vibrato)`, () => { + run("node", [ + "bin/score-pitch.mjs", + "--slug", "trance-hook", + "--section", "all", + "--score", SCORE_PATH, + "--vocal", VOCAL_MP3, + "--words", WORDS_JSON, + "--transpose", String(TRANSPOSE), + "--vibrato-hz", "5.2", + "--vibrato-cents", "18", + "--out", PITCHED_MP3, + ]); +}); + +// ── 4. score-stretch (rubberband per-word, formant-preserving) ─────── +// This is what HOLDS the notes — each word's source duration is +// stretched to its target beats from the .np. Sustained notes (*4, *5) +// actually hold; melisma stays inside the same word window. +step(`4 · score-stretch (rubberband · ${BPM} BPM · max ${MAX_STRETCH}×)`, () => { + run("node", [ + "bin/score-stretch.mjs", + "--slug", "trance-hook", + "--section", "all", + "--score", SCORE_PATH, + "--in", PITCHED_MP3, + "--alignment", PITCHED_ALIGN, + "--bpm", String(BPM), + "--max-stretch", String(MAX_STRETCH), + "--out", STRETCHED_MP3, + ]); +}); + +// 4b: a SECOND stretch at the bed's tempo (138 BPM) so the rap layer +// is actually beat-locked — each word lands on a beat rather than +// drifting through whatever the rubberband free-time tile happens to do. +step("4b · score-stretch RAP (115 BPM — slower beat-locked rap)", () => { + run("node", [ + "bin/score-stretch.mjs", + "--slug", "trance-hook", + "--section", "all", + "--score", SCORE_PATH, + "--in", PITCHED_MP3, + "--alignment", PITCHED_ALIGN, + "--bpm", "115", + "--max-stretch", "5.0", + "--out", STRETCHED_RAP_MP3, + ]); +}); + +// ── 5. layer: many-jeffreys-racing harmony stack ───────────────────── +// Take the stretched stem and stack faster + harmony-shifted copies so +// it sounds like multiple jeffreys racing against each other inside +// the same bar. Each layer at lower gain so the main vocal stays lead. +// main ×1.0 time, +0 st (gain 1.0) the lead voice +// fast ×0.5 time, +0 st (gain 0.40) same notes, twice the speed +// faster ×0.33 time, +12 st (gain 0.28) octave up, 3× speed +// low ×1.0 time, −7 st (gain 0.32) perfect fifth below +// high ×1.0 time, +12 st (gain 0.30) octave above +// All layers loop within the main stem's duration so they cascade. +step("5 · layer (many-jeffreys: pitched + raw + rap + harmonies)", () => { + // Three source wavs: + // baseWav = stretched + WORLD-pitched stem (sung) + // rawWav = original TTS, no pitching, no stretch + // whisperWav = close-mic whisper treatment of rawWav (proximity + // effect + heavy compression + brightness cut + tiny + // room reverb → intimate ASMR-adjacent character) + const baseWav = `${TMP_DIR}/base.wav`; + const rawWav = `${TMP_DIR}/raw.wav`; + const rapWav = `${TMP_DIR}/rap.wav`; + const whisperWav = `${TMP_DIR}/whisper.wav`; + // Force 48 kHz on every decode — /api/say returns 44.1 kHz, intermediate + // tools then process at that rate and trance.mjs resamples at decode. + // Resampling at every hop adds artifacts; pinning 48 k throughout fixes it. + run("ffmpeg", ["-hide_banner","-y","-loglevel","error","-i",STRETCHED_MP3,"-ar","48000","-c:a","pcm_s16le",baseWav]); + run("ffmpeg", ["-hide_banner","-y","-loglevel","error","-i",STRETCHED_RAP_MP3,"-ar","48000","-c:a","pcm_s16le",rapWav]); + run("ffmpeg", ["-hide_banner","-y","-loglevel","error","-i",VOCAL_MP3,"-ar","48000","-c:a","pcm_s16le",rawWav]); + + // Slice layer: take the rap layer and gate it with an 8th-note tremolo + // so each word gets sliced into rhythmic chops. 138 BPM means 8th note + // ≈ 0.217 s → tremolo at ~4.6 Hz. Heavy depth = full gating. + const sliceWav = `${TMP_DIR}/slice.wav`; + run("ffmpeg", [ + "-hide_banner","-y","-loglevel","error", + "-i", STRETCHED_RAP_MP3, + "-af", "tremolo=f=4.6:d=0.85,highpass=f=200,equalizer=f=1500:t=q:w=1.5:g=3", + "-ar","48000","-c:a","pcm_s16le", sliceWav, + ]); + + // Computer-stamp layer: extract ONLY the "computer" word windows + // from the stretched stem, gate everything else, and auto-pan the + // result. User wants jeffrey's "computer" littered throughout the + // mix bouncing left-right like a stamp, NOT a master sweep on + // every voice. Compute target word timings from the .np score. + const scoreText = readFileSync(SCORE_PATH, "utf8"); + const scoreWords = []; + let cumBeats = 0; + let cur = null; + for (const raw of scoreText.split("\n")) { + const ln = raw.trim(); + if (!ln || ln.startsWith("#")) continue; + if (/^[a-z]+(?: \d+)?$/i.test(ln)) continue; // section header + for (const tok of ln.split(/\s+/)) { + const m = tok.match(/^([A-Ga-g][#b]?-?\d):(.+?)(?:\*(\d+(?:\.\d+)?))?$/); + if (!m) continue; + const text = m[2]; + const beats = Number(m[3] || 1); + if (text.startsWith("-") && cur) { + cur.beats += beats; + } else { + if (cur) scoreWords.push(cur); + cur = { text: text.replace(/-$/, "").toLowerCase(), startBeats: cumBeats, beats }; + } + cumBeats += beats; + } + } + if (cur) scoreWords.push(cur); + + // Find computer windows in seconds (stretched at BPM). + const beatSec = 60 / BPM; + const computerWindows = scoreWords + .filter((w) => w.text.startsWith("com")) + .map((w) => ({ startSec: w.startBeats * beatSec, endSec: (w.startBeats + w.beats) * beatSec })); + console.log(` computer stamps: ${computerWindows.length} × {${computerWindows.map((w) => w.startSec.toFixed(1) + "-" + w.endSec.toFixed(1) + "s").join(", ")}}`); + + // Build gate expression: 1.6x volume in computer windows, 0 outside. + // Slight fade ±50ms via 'between' boundaries. + const gateExpr = computerWindows + .map((w) => `between(t,${w.startSec.toFixed(3)},${w.endSec.toFixed(3)})`) + .join("+"); + const stampWav = `${TMP_DIR}/computer-stamp.wav`; + if (gateExpr) { + run("ffmpeg", [ + "-hide_banner","-y","-loglevel","error", + "-i", STRETCHED_MP3, + "-af", + `volume=eval=frame:volume='1.6*(${gateExpr})',` + + `pan=stereo|c0=c0|c1=c0,` + + `apulsator=hz=0.7:width=0.95`, + "-ar","48000","-c:a","pcm_s16le", stampWav, + ]); + } + + // Whisper filter chain: + // acompressor — crush dynamics so soft phonemes come up + // highpass 120 — remove distant rumble + // lowpass 4500 — soften any harsh top end (whispers are dark) + // eq +5 @ 100 — proximity bass boost (the "close to the mic" warmth) + // eq -4 @ 2.5k — pull mid harshness, leave breathy quality + // eq +3 @ 8k — "air band" — keeps the breath audible + // aecho — tight 30 ms close-room reflection + // volume +6 dB — make the small dynamics actually present + const whisperFilter = + "acompressor=threshold=-30dB:ratio=6:attack=2:release=80:makeup=5:knee=4," + + "highpass=f=120,lowpass=f=4500," + + "equalizer=f=100:t=q:w=1.5:g=5," + + "equalizer=f=2500:t=q:w=1.0:g=-4," + + "equalizer=f=8000:t=q:w=1.0:g=3," + + "aecho=0.6:0.3:30:0.18," + + "volume=2.0"; + run("ffmpeg", [ + "-hide_banner","-y","-loglevel","error", + "-i", rawWav, "-af", whisperFilter, + "-ar","48000","-c:a","pcm_s16le", whisperWav, + ]); + + // Six layers total: + // fast/faster — racing pitched copies + // low/high — pitched harmonies + // raw — UNALTERED jeffrey TTS in the mix (the user wanted this) + // rap — sped-up TTS, word-per-beat delivery + const layers = [ + { name: "fast", time: 0.50, pitch: 0, gain: 0.22, src: baseWav }, + { name: "faster", time: 0.33, pitch: 12, gain: 0.16, src: baseWav }, + { name: "low", time: 1.00, pitch: -7, gain: 0.20, src: baseWav }, + { name: "high", time: 1.00, pitch: 12, gain: 0.18, src: baseWav }, + { name: "raw", time: 1.00, pitch: 0, gain: 0.32, src: rawWav }, + // Rap layer is the second score-stretch pass at 138 BPM. Already + // tempo-locked, so time=1.0 (no rubberband retime); pitch=0 to keep + // the WORLD-applied melody on every word. + { name: "rap", time: 1.00, pitch: 0, gain: 0.32, src: rapWav }, + // Close-mic whisper: intimate, breathy. Slightly pitched down so + // jeffrey sounds confessional rather than declamatory. + { name: "whisper", time: 1.00, pitch: -2, gain: 0.40, src: whisperWav }, + // Slice: 8th-note-gated chop of the rap layer — adds rhythmic + // staccato vocal movement. + { name: "slice", time: 1.00, pitch: 0, gain: 0.28, src: sliceWav }, + ]; + + // Rubberband each layer → wav from its specific source. + const layerWavs = []; + for (const l of layers) { + const wav = `${TMP_DIR}/${l.name}.wav`; + const args = ["-t", String(l.time), "-p", String(l.pitch), "--formant", l.src, wav]; + run("rubberband", args); + layerWavs.push({ ...l, wav }); + } + + // Mix base + layers + computer-stamp via ffmpeg. Loop the + // time-stretched layers so they fill the main stem's duration. + // All inputs upmix to stereo so the panned stamp survives amix. + const inputs = ["-i", STRETCHED_MP3]; + for (const l of layerWavs) inputs.push("-i", l.wav); + const hasStamp = !!gateExpr && existsSync(stampWav); + if (hasStamp) inputs.push("-i", stampWav); + // Get main duration via ffprobe so aloop can target it. + const probe = spawnSync("ffprobe", [ + "-v","error","-show_entries","format=duration", + "-of","default=noprint_wrappers=1:nokey=1", STRETCHED_MP3, + ], { encoding: "utf8" }); + const mainSec = parseFloat(probe.stdout.trim()) || 21; + const mainSamples = Math.floor(mainSec * 48000); + + // Per-layer build-up envelope: most layers fade in progressively + // across the first half of the stem so the opening is lean and the + // mix grows richer toward the middle of the track. + // Format: {start: seconds_when_fade_begins, dur: seconds_of_fade} + const fadeMap = { + fast: { start: 22, dur: 8 }, + faster: { start: 28, dur: 8 }, + low: { start: 30, dur: 10 }, + high: { start: 34, dur: 10 }, + raw: { start: 12, dur: 6 }, + rap: { start: 24, dur: 8 }, + whisper: { start: 16, dur: 6 }, + slice: { start: 30, dur: 10 }, + }; + + const parts = []; + // Main stem: tiny fade-in just to avoid a click; full by ~2s. + parts.push(`[0:a]pan=stereo|c0=c0|c1=c0,volume=eval=frame:volume='min(max(t/2,0),1)'[m]`); + layerWavs.forEach((l, i) => { + const idx = i + 1; + const fm = fadeMap[l.name] || { start: 0, dur: 1 }; + // Volume = layer.gain × ramp(0..1) over [fm.start, fm.start+fm.dur] + const volExpr = `${l.gain}*min(max((t-${fm.start})/${fm.dur},0),1)`; + if (l.time < 1.0) { + parts.push(`[${idx}:a]pan=stereo|c0=c0|c1=c0,volume=eval=frame:volume='${volExpr}',aloop=loop=-1:size=2147483647,atrim=duration=${mainSec.toFixed(3)},asetpts=N/SR/TB[l${i}]`); + } else { + parts.push(`[${idx}:a]pan=stereo|c0=c0|c1=c0,volume=eval=frame:volume='${volExpr}'[l${i}]`); + } + }); + const labels = ["m", ...layerWavs.map((_, i) => `l${i}`)]; + let totalInputs = layers.length + 1; + if (hasStamp) { + // Stamp comes in around break1 — 14s fade start. + const stampIdx = layerWavs.length + 1; + parts.push(`[${stampIdx}:a]volume=eval=frame:volume='0.85*min(max((t-14)/6,0),1)',atrim=duration=${mainSec.toFixed(3)}[stamp]`); + labels.push("stamp"); + totalInputs++; + } + const mixInputs = labels.map((n) => `[${n}]`).join(""); + parts.push(`${mixInputs}amix=inputs=${totalInputs}:duration=longest:dropout_transition=0:normalize=0,atrim=duration=${mainSec.toFixed(3)}[out]`); + const filter = parts.join(";"); + void mainSamples; + + run("ffmpeg", [ + "-hide_banner","-y","-loglevel","error", + ...inputs, + "-filter_complex", filter, + "-map", "[out]", + "-c:a","libmp3lame","-q:a","3", + LAYERED_MP3, + ]); +}); + +// ── output ─────────────────────────────────────────────────────────── +mkdirSync(dirname(OUT_PATH), { recursive: true }); +copyFileSync(LAYERED_MP3, OUT_PATH); +const sz = (readFileSync(OUT_PATH).length / 1024).toFixed(0); +console.log(`\n✓ ${OUT_PATH} (${sz} KB · sung jeffrey-pvc stem)`); diff --git a/pop/dance/bin/vocal.mjs b/pop/dance/bin/vocal.mjs new file mode 100644 index 0000000000..89cfe59212 --- /dev/null +++ b/pop/dance/bin/vocal.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// vocal.mjs — generate a jeffrey-pvc vocal stem for the dance lane. +// +// Posts the lyric to /api/say (the same ElevenLabs proxy the recap +// pipeline uses), then applies optional post-processing that's typical +// for trance vocal hooks: a soft octave-up shift, a long reverb tail, +// and a gentle highpass to keep it from muddying the supersaw. +// +// Output stem is meant to be mixed into trance.mjs via --vocal-stem. +// +// Usage: +// node pop/dance/bin/vocal.mjs --lyric "the music is real" \ +// --out ~/Desktop/trance-vox.mp3 +// +// # with octave-up + reverb (anthemic hook) +// node pop/dance/bin/vocal.mjs --lyric "the music is real" \ +// --pitch-shift 0 --reverb 0.6 --out ~/Desktop/trance-vox.mp3 +// +// # cached: if the lyric + voice config hash matches, reuse stem. + +import { + writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync, +} from "node:fs"; +import { resolve, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } +} +function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +const LYRIC = flags.lyric || "A. B. C. D. one. two. three. four. C. E. G. A."; +const PROVIDER = flags.provider || "jeffrey"; +const VOICE_ID = flags.voice || "neutral:0"; +const OUT_PATH = expandHome(flags.out) || `/tmp/trance-vocal.mp3`; +const PITCH_SHIFT = Number(flags["pitch-shift"] ?? 0); // semitones +const REVERB = Number(flags.reverb ?? 0.5); // 0..1 +const HIGHPASS_HZ = Number(flags.highpass ?? 200); +const FORCE = !!flags.force; + +// ── cache key ───────────────────────────────────────────────────────── +const inputHash = createHash("sha256") + .update(JSON.stringify({ LYRIC, PROVIDER, VOICE_ID, PITCH_SHIFT, REVERB, HIGHPASS_HZ })) + .digest("hex") + .slice(0, 16); +const hashFile = `${OUT_PATH}.hash`; + +if (!FORCE && existsSync(OUT_PATH) && existsSync(hashFile)) { + const cached = readFileSync(hashFile, "utf8").trim(); + if (cached === inputHash) { + const sz = (readFileSync(OUT_PATH).length / 1024).toFixed(0); + console.log(`✓ ${OUT_PATH} cached (${sz} KB · ${inputHash}) — skipping /api/say`); + process.exit(0); + } +} + +// ── POST /api/say ───────────────────────────────────────────────────── +console.log(`→ POST /api/say · "${LYRIC}" · ${PROVIDER}:${VOICE_ID}`); +const res = await fetch("https://aesthetic.computer/api/say", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from: LYRIC, provider: PROVIDER, voice: VOICE_ID }), + redirect: "follow", +}); +if (!res.ok) { + console.error(`✗ /api/say returned ${res.status}: ${await res.text()}`); + process.exit(1); +} +const raw = Buffer.from(await res.arrayBuffer()); +mkdirSync(dirname(OUT_PATH), { recursive: true }); +const dryPath = `${OUT_PATH}.dry.mp3`; +writeFileSync(dryPath, raw); +console.log(` TTS · ${(raw.length / 1024).toFixed(0)} KB`); + +// ── post-process: pitch shift + reverb + highpass + loudnorm ──────── +// /api/say returns ~-19 dBFS peak audio. Without normalization, the +// vocal disappears under any bed. Apply loudnorm (target -8 LUFS) so +// the stem is loud on disk; trance.mjs ALSO peak-normalizes after +// decoding for belt-and-suspenders. +const filters = []; +filters.push(`highpass=f=${HIGHPASS_HZ}`); +if (PITCH_SHIFT !== 0) { + // asetrate changes both pitch + tempo; counter with atempo to keep speed. + const factor = Math.pow(2, PITCH_SHIFT / 12); + // Per ffmpeg docs asetrate works on sample rate. We'll do it as a chain: + // asetrate=R*factor,aresample=R,atempo=1/factor + filters.push(`asetrate=48000*${factor.toFixed(6)}`); + filters.push(`aresample=48000`); + // atempo only supports [0.5, 2.0]; chain if needed. + let remain = 1 / factor; + while (remain > 2) { filters.push(`atempo=2.0`); remain /= 2; } + while (remain < 0.5) { filters.push(`atempo=0.5`); remain *= 2; } + filters.push(`atempo=${remain.toFixed(6)}`); +} +if (REVERB > 0) { + const r = Math.max(0.05, Math.min(0.95, REVERB)); + filters.push(`aecho=0.8:0.9:60|180|360|720:${(r * 0.6).toFixed(2)}|${(r * 0.45).toFixed(2)}|${(r * 0.3).toFixed(2)}|${(r * 0.2).toFixed(2)}`); +} +// Loudness normalize the result — push the stem to a much higher +// average level so it sits in front of the bed. +filters.push(`loudnorm=I=-8:TP=-1.5:LRA=11`); +const filterStr = filters.join(","); + +const args = [ + "-hide_banner", "-y", "-loglevel", "error", + "-i", dryPath, + "-af", filterStr, + "-c:a", "libmp3lame", "-q:a", "3", + OUT_PATH, +]; +console.log(`→ post · ${filterStr}`); +const ff = spawnSync("ffmpeg", args, { stdio: "inherit" }); +try { unlinkSync(dryPath); } catch {} +if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } + +writeFileSync(hashFile, inputHash + "\n"); +const finalSize = (readFileSync(OUT_PATH).length / 1024).toFixed(0); +console.log(`✓ ${OUT_PATH} (${finalSize} KB · ${inputHash})`); diff --git a/pop/dance/synths/bus.mjs b/pop/dance/synths/bus.mjs new file mode 100644 index 0000000000..fc0c5b8a18 --- /dev/null +++ b/pop/dance/synths/bus.mjs @@ -0,0 +1,102 @@ +// bus.mjs — buffer-mock-synth that implements the AC `sound.synth(...)` +// contract over a Float32Array buffer. Lets server-side renderers drive +// the same synthesis calls that the web AudioContext + fedac/native +// audio.c implement, so a single voice description plays identically +// everywhere. +// +// Contract (matches the AC web/native runtime): +// sound.synth({ +// type: "sine" | "square" | "triangle" | "sawtooth" | "noise" +// tone: Hz +// duration: seconds +// volume: scalar (0..1+) +// attack: seconds (linear) +// decay: seconds (exponential tail after attack) +// pan: -1..1 (ignored in mono out) +// phase: optional 0..1 initial phase (additive AC extension — +// native runtimes that don't honor it default to 0) +// }) +// +// Total event length = max(duration, attack + decay). + +const DEFAULT_SAMPLE_RATE = 48_000; + +export function makeBufferSynth(out, startSec, sampleRate = DEFAULT_SAMPLE_RATE, noiseRng = Math.random) { + return { + synth: (params) => { + const type = params?.type || "sine"; + const tone = Number(params?.tone) || 440; + const duration = Number(params?.duration); + const volume = Number(params?.volume ?? 1); + const attack = Math.max(0, Number(params?.attack ?? 0.001)); + const decay = Number.isFinite(params?.decay) + ? Math.max(0, Number(params.decay)) + : Math.max(0.001, (Number.isFinite(duration) ? duration : 0.05) - attack); + const initPhase = Number.isFinite(params?.phase) ? params.phase : 0; + if (!Number.isFinite(volume) || volume === 0) return null; + if (!Number.isFinite(duration) || duration <= 0) return null; + + const durSamples = Math.ceil(duration * sampleRate); + const attS = Math.max(1, Math.floor(attack * sampleRate)); + const decS = Math.max(1, Math.floor(decay * sampleRate)); + // Match fedac/native/src/audio.c compute_envelope(): + // linear attack (0 → 1 over `attack` seconds) + // hold at 1 until `duration - decay` + // linear decay (1 → 0 over `decay` seconds ending at duration) + // When attack + decay > duration, native clamps decay_start to 0 + // (so the decay starts immediately after attack, truncated at + // duration). We instead AUTO-EXTEND the window to attack+decay so + // the envelope completes to true silence without clicks at end — + // matches the AC web AudioContext + ADSR-style releases. + const ns = Math.max(durSamples, attS + decS); + const decayStart = ns - decS; // >= attS by construction (attS + decS <= ns) + const startIdx = Math.floor(startSec * sampleRate); + const omega = (2 * Math.PI * tone) / sampleRate; + const phaseInc = tone / sampleRate; + + // SVF bandpass for noise voices (matches trap.mjs / percussion fan-out). + const fcSafe = Math.min(Math.max(40, tone), sampleRate / 6); + const fParam = 2 * Math.sin(Math.PI * fcSafe / sampleRate); + const damp = 1 / 4; + let svfLow = 0, svfBand = 0; + + let phaseAcc = initPhase; + + for (let i = 0; i < ns; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= out.length) continue; + + // Native-portable envelope: linear attack → hold → linear decay. + let env; + if (i < attS) { + env = i / attS; + } else if (i < decayStart) { + env = 1; + } else { + env = 1 - (i - decayStart) / decS; + if (env <= 0) continue; + } + + let s; + switch (type) { + case "sine": s = Math.sin(omega * i + 2 * Math.PI * initPhase); break; + case "square": s = Math.sin(omega * i + 2 * Math.PI * initPhase) >= 0 ? 1 : -1; break; + case "triangle": { const ph = (phaseAcc + i * phaseInc) % 1; s = ph < 0.5 ? 4 * ph - 1 : 3 - 4 * ph; break; } + case "sawtooth": { const ph = (phaseAcc + i * phaseInc) % 1; s = 2 * ph - 1; break; } + case "noise": + case "noise-white": { + const w = noiseRng() * 2 - 1; + const high = w - svfLow - damp * svfBand; + svfBand += fParam * high; + svfLow += fParam * svfBand; + s = svfBand * 1.5; + break; + } + default: s = 0; + } + out[dst] += s * env * volume; + } + return null; + }, + }; +} diff --git a/pop/dance/synths/fx.mjs b/pop/dance/synths/fx.mjs new file mode 100644 index 0000000000..fa791bd403 --- /dev/null +++ b/pop/dance/synths/fx.mjs @@ -0,0 +1,203 @@ +// fx.mjs — buffer post-effects for the dance lane. Wobble (LFO mod) and +// bitcrush (depth + sample-rate reduction), both modulated by envelopes +// so a section can ramp the effect in or out. +// +// All effects mutate the buffer in place over [startSec, endSec]. If +// you want a dry/wet blend, render to a temp buffer first and mix. +// +// Envelopes are { time: secondsFromStart, ...params }[] breakpoints, +// linearly interpolated. A single static value also works. + +const DEFAULT_SAMPLE_RATE = 48_000; + +function resolveEnv(envOrValue, key, t) { + if (typeof envOrValue === "number") return envOrValue; + if (!Array.isArray(envOrValue) || envOrValue.length === 0) return undefined; + if (t <= envOrValue[0].time) return envOrValue[0][key]; + if (t >= envOrValue[envOrValue.length - 1].time) return envOrValue[envOrValue.length - 1][key]; + for (let i = 0; i < envOrValue.length - 1; i++) { + const a = envOrValue[i], b = envOrValue[i + 1]; + if (t >= a.time && t <= b.time) { + const f = (t - a.time) / Math.max(1e-9, b.time - a.time); + return a[key] + (b[key] - a[key]) * f; + } + } + return envOrValue[envOrValue.length - 1][key]; +} + +// ── wobble ──────────────────────────────────────────────────────────── +// LFO that modulates either amplitude ("amp") or one-pole lowpass cutoff +// ("filter"). Trance + dubstep crossover effect — slow sine LFO at +// ~0.5–8 Hz creates the "wub wub" feel. Both rate (Hz) and depth (0..1) +// can be enveloped over the section. +// +// opts: { +// target: "amp" | "filter" (default "filter") +// rate: number | env breakpoints (Hz, default 2) +// depth: number | env breakpoints (0..1, default 0.6) +// baseCutoffHz: number (filter mode only; default 2000) +// waveform: "sine" | "tri" | "square" (default "sine") +// sampleRate: number (default 48000) +// startSec, endSec: optional region (default whole buffer) +// } +// +// Envelope rows: { time, rate, depth } +export function applyWobble(buf, opts = {}) { + if (!(buf instanceof Float32Array)) return; + const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const target = opts.target ?? "filter"; + const waveform = opts.waveform ?? "sine"; + const startSec = opts.startSec ?? 0; + const endSec = opts.endSec ?? (buf.length / sr); + const startIdx = Math.max(0, Math.floor(startSec * sr)); + const endIdx = Math.min(buf.length, Math.floor(endSec * sr)); + if (endIdx <= startIdx) return; + + const baseCutoff = opts.baseCutoffHz ?? 2000; + const dt = 1 / sr; + + // One-pole LP state for filter mode. + let lp = 0; + let phase = 0; + + for (let i = startIdx; i < endIdx; i++) { + const t = (i - startIdx) / sr; + const rate = resolveEnv(opts.rate ?? 2.0, "rate", t); + const depth = resolveEnv(opts.depth ?? 0.6, "depth", t); + + phase += rate * dt; + if (phase >= 1) phase -= Math.floor(phase); + let lfo; + switch (waveform) { + case "tri": lfo = Math.abs(phase * 2 - 1) * 2 - 1; break; + case "square": lfo = phase < 0.5 ? 1 : -1; break; + default: lfo = Math.sin(phase * 2 * Math.PI); + } + // Map LFO from [-1,1] → [0,1] for depth (so depth=1 fully closes the gate) + const modUnit = (lfo + 1) * 0.5; + + if (target === "amp") { + // amp gain swings from (1-depth) to 1. + const gain = 1 - depth + depth * modUnit; + buf[i] *= gain; + } else { + // filter cutoff swings exponentially across baseCutoff*[1-depth..1] + // — closing the filter when depth is high. + const minCut = baseCutoff * (1 - depth); + const cutoff = minCut + (baseCutoff - minCut) * modUnit; + const rc = 1 / (2 * Math.PI * Math.max(20, cutoff)); + const alpha = dt / (rc + dt); + lp += alpha * (buf[i] - lp); + buf[i] = lp; + } + } +} + +// ── bitcrush ────────────────────────────────────────────────────────── +// Quantize to `bits` and hold each sample for `downsample` samples +// (sample-rate reduction). Both can be enveloped — e.g. start clean, +// crush hard at the build climax, return to clean on the drop. +// +// opts: { +// bits: int | env breakpoints (1..16, default 8) +// downsample: int | env breakpoints (1..N, default 1) +// mix: 0..1 (dry/wet, default 1) +// sampleRate: number (default 48000) +// startSec, endSec: optional region +// } +// +// Envelope rows: { time, bits, downsample } +export function applyBitcrush(buf, opts = {}) { + if (!(buf instanceof Float32Array)) return; + const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const startSec = opts.startSec ?? 0; + const endSec = opts.endSec ?? (buf.length / sr); + const startIdx = Math.max(0, Math.floor(startSec * sr)); + const endIdx = Math.min(buf.length, Math.floor(endSec * sr)); + if (endIdx <= startIdx) return; + const mix = Math.max(0, Math.min(1, opts.mix ?? 1)); + + let held = 0; + let holdCount = 0; + + for (let i = startIdx; i < endIdx; i++) { + const t = (i - startIdx) / sr; + const bits = Math.max(1, Math.min(16, Math.round(resolveEnv(opts.bits ?? 8, "bits", t)))); + const ds = Math.max(1, Math.floor(resolveEnv(opts.downsample ?? 1, "downsample", t))); + + if (holdCount <= 0) { + const levels = Math.pow(2, bits - 1); + const q = Math.round(buf[i] * levels) / levels; + held = Math.max(-1, Math.min(1, q)); + holdCount = ds; + } + holdCount--; + buf[i] = buf[i] * (1 - mix) + held * mix; + } +} + +// ── flange ──────────────────────────────────────────────────────────── +// Short delay-line modulated by an LFO, mixed back with the dry signal. +// Fast flange = LFO rate ~4-8 Hz with depth swinging ±3 ms; classic +// trance/jet sweep on supersaw + lead chords. +// +// opts: { +// rate: Hz (default 5) +// depthMs: ms delay swing (peak) (default 3) +// baseDelayMs: ms center delay (default 4) +// feedback: -0.95..0.95 (default 0.45) +// mix: 0..1 (dry/wet) (default 0.55) +// sampleRate: number (default 48000) +// startSec, endSec: optional region +// } +export function applyFlange(buf, opts = {}) { + if (!(buf instanceof Float32Array)) return; + const sr = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const rate = opts.rate ?? 5; + const depthMs = opts.depthMs ?? 3; + const baseDelayMs = opts.baseDelayMs ?? 4; + const feedback = Math.max(-0.95, Math.min(0.95, opts.feedback ?? 0.45)); + const mix = Math.max(0, Math.min(1, opts.mix ?? 0.55)); + const startIdx = Math.max(0, Math.floor((opts.startSec ?? 0) * sr)); + const endIdx = Math.min(buf.length, Math.floor((opts.endSec ?? (buf.length / sr)) * sr)); + if (endIdx <= startIdx) return; + + // Delay-line large enough for base + depth + slack. + const maxDelaySamples = Math.ceil((baseDelayMs + depthMs + 1) * 0.001 * sr) + 4; + const delayLine = new Float32Array(maxDelaySamples); + let writeIdx = 0; + let phase = 0; + const dt = 1 / sr; + + for (let i = startIdx; i < endIdx; i++) { + phase += rate * dt; + if (phase >= 1) phase -= Math.floor(phase); + const lfo = Math.sin(phase * 2 * Math.PI); // -1..1 + const delaySamples = ((baseDelayMs + lfo * depthMs) * 0.001 * sr); + + // Linear interpolation read from the delay line. + const readPos = writeIdx - delaySamples; + const readPosWrapped = (readPos + maxDelaySamples * 2) % maxDelaySamples; + const r0 = Math.floor(readPosWrapped); + const r1 = (r0 + 1) % maxDelaySamples; + const frac = readPosWrapped - r0; + const delayed = delayLine[r0] * (1 - frac) + delayLine[r1] * frac; + + // Write input + feedback to the delay line, then output dry+wet mix. + const input = buf[i]; + delayLine[writeIdx] = input + delayed * feedback; + writeIdx = (writeIdx + 1) % maxDelaySamples; + + buf[i] = input * (1 - mix) + delayed * mix; + } +} + +// ── tape-style soft clip (gentle saturation) ────────────────────────── +// Bonus — useful for taming peaks after sidechain + wobble + bitcrush +// without harsh digital clipping. +export function softClip(buf, drive = 1.0) { + if (!(buf instanceof Float32Array)) return; + for (let i = 0; i < buf.length; i++) { + buf[i] = Math.tanh(buf[i] * drive); + } +} diff --git a/pop/dance/synths/sinepower.mjs b/pop/dance/synths/sinepower.mjs new file mode 100644 index 0000000000..5307c66290 --- /dev/null +++ b/pop/dance/synths/sinepower.mjs @@ -0,0 +1,94 @@ +// sinepower.mjs — stacked harmonic sines for the dance lane. +// Drop-in replacement for supersaw at the lead/pad/stab slots. +// All voices resolve to sound.synth({type:"sine", ...}) — same contract +// percussion.mjs uses, native-portable via AC's audio runtime. +// +// Voice = fundamental + octave + fifth + (optional) sub-octave + (pad) +// third + (pad) high brilliance. All sine, all phase-locked relative +// to the source clock — no aliasing, smooth crossfades. +// +// Library: +// import { playSinePower, mixEventSinePower, SINEPOWER_PRESETS } from "..."; +// playSinePower(sound, { midi, durSec, gain }, opts) // any AC runtime +// mixEventSinePower(ev, out, opts) // node buffer + +import { makeBufferSynth } from "./bus.mjs"; + +export const SINEPOWER_PRESETS = { + // Lead — bright + cutting but smooth. Fundamental forward, octave + + // fifth for body, sub for low end. + lead: { + partials: [ + { ratio: 1.0, amp: 1.00 }, // fundamental + { ratio: 2.0, amp: 0.45 }, // octave up + { ratio: 1.5, amp: 0.28 }, // perfect fifth + { ratio: 0.5, amp: 0.18 }, // sub-octave + ], + attack: 0.015, + decay: 0.45, + }, + // Pad — wide, slow attack, more partials for a richer chord pad. + pad: { + partials: [ + { ratio: 1.0, amp: 1.00 }, + { ratio: 2.0, amp: 0.35 }, + { ratio: 1.5, amp: 0.30 }, + { ratio: 1.25, amp: 0.20 }, // major third tinge + { ratio: 0.5, amp: 0.25 }, // sub + { ratio: 4.0, amp: 0.08 }, // 2-octave brilliance + ], + attack: 0.40, + decay: 1.80, + }, + // Stab — quick, percussive, mid-only. + stab: { + partials: [ + { ratio: 1.0, amp: 1.00 }, + { ratio: 2.0, amp: 0.40 }, + { ratio: 1.5, amp: 0.25 }, + ], + attack: 0.005, + decay: 0.18, + }, +}; + +const DEFAULT_PRESET = "lead"; +const DEFAULT_SAMPLE_RATE = 48_000; + +function midiToFreq(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +export function playSinePower(sound, ev, opts = {}) { + if (!sound?.synth) return; + const preset = SINEPOWER_PRESETS[opts.preset || DEFAULT_PRESET] || SINEPOWER_PRESETS[DEFAULT_PRESET]; + const attack = opts.attack ?? preset.attack; + const decay = opts.decay ?? preset.decay; + const eventGain = Number.isFinite(ev.gain) ? ev.gain : 1.0; + if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) return; + if (eventGain === 0) return; + + const partials = opts.partials || preset.partials; + let ampSum = 0; + for (const p of partials) ampSum += p.amp; + const norm = 1 / Math.max(0.001, ampSum); + + const fund = midiToFreq(ev.midi); + for (const p of partials) { + sound.synth({ + type: "sine", + tone: fund * p.ratio, + duration: ev.durSec, + volume: eventGain * p.amp * norm, + attack, + decay, + }); + } +} + +export function mixEventSinePower(ev, out, opts = {}) { + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + if (!(out instanceof Float32Array)) return; + const sound = makeBufferSynth(out, ev.startSec, sampleRate); + playSinePower(sound, ev, opts); +} diff --git a/pop/dance/synths/supersaw.mjs b/pop/dance/synths/supersaw.mjs new file mode 100644 index 0000000000..4114477616 --- /dev/null +++ b/pop/dance/synths/supersaw.mjs @@ -0,0 +1,217 @@ +#!/usr/bin/env node +// supersaw.mjs — JP-8000-style detuned sawtooth stack, native-portable. +// +// Voice is expressed as N parallel `sound.synth({type:"sawtooth", ...})` +// calls — exactly the same contract `percussion.mjs` uses to talk to +// AC's web AudioContext + fedac/native audio.c. The supersaw is just +// "play seven detuned saws at once" — no buffer-side custom DSP. Means +// it ports to native by definition. +// +// Library: +// import { playSupersaw, mixEventSupersaw, SUPERSAW_PRESETS } from "..."; +// +// // In any AC runtime (web/native/node): +// playSupersaw(sound, { midi, durSec, gain }, opts) +// +// // Node-side bed renderer (Float32Array buffer): +// mixEventSupersaw(ev, out, opts) +// +// CLI demo: +// node pop/dance/synths/supersaw.mjs # lead preset +// node pop/dance/synths/supersaw.mjs --preset pad +// node pop/dance/synths/supersaw.mjs --voices 9 --detune 28 --out ~/Desktop/saw.mp3 + +import { + writeFileSync, + mkdirSync, + unlinkSync, +} 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 { makeBufferSynth } from "./bus.mjs"; + +// ── presets ──────────────────────────────────────────────────────────── +// Envelope is AD only (linear attack → exponential decay) — that's what +// the AC native sound.synth contract supports. "Pad" gets a long decay +// so the tail rings; "lead" gets a short crisp tail; "stab" is plucky. +export const SUPERSAW_PRESETS = { + lead: { voices: 7, detuneCents: 18, centerGain: 0.65, attack: 0.015, decay: 0.50 }, + pad: { voices: 7, detuneCents: 28, centerGain: 0.55, attack: 0.40, decay: 2.00 }, + stab: { voices: 5, detuneCents: 12, centerGain: 0.70, attack: 0.005, decay: 0.18 }, +}; + +const DEFAULT_PRESET = "lead"; +const DEFAULT_SAMPLE_RATE = 48_000; + +// ── helpers ──────────────────────────────────────────────────────────── +function midiToFreq(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +function makeRng(seedStr) { + let s = 2166136261 >>> 0; + for (let i = 0; i < seedStr.length; i++) { + s ^= seedStr.charCodeAt(i); + s = Math.imul(s, 16777619); + } + s = s >>> 0 || 1; + return () => { + s ^= s << 13; s >>>= 0; + s ^= s >>> 17; s >>>= 0; + s ^= s << 5; s >>>= 0; + return (s >>> 0) / 0xffffffff; + }; +} + +// Symmetric detune spread around the center voice. +function buildDetuneTable(voices, detuneCents) { + const t = new Float64Array(voices); + const half = (voices - 1) / 2; + for (let i = 0; i < voices; i++) { + const offset = (i - half) / Math.max(1, half); + t[i] = offset * detuneCents; + } + return t; +} + +// ── runtime-agnostic play (web / native / node) ─────────────────────── +// Takes an AC `sound` object and an event, fires N sawtooth voices. +// Same calling convention as `playPercussion(sound, letter, opts)`. +export function playSupersaw(sound, ev, opts = {}) { + if (!sound?.synth) return; + const presetName = opts.preset || DEFAULT_PRESET; + const preset = SUPERSAW_PRESETS[presetName] || SUPERSAW_PRESETS[DEFAULT_PRESET]; + + const voices = opts.voices ?? preset.voices; + const detuneCents = opts.detuneCents ?? preset.detuneCents; + const centerGain = opts.centerGain ?? preset.centerGain; + const attack = opts.attack ?? preset.attack; + const decay = opts.decay ?? preset.decay; + const seed = opts.seed ?? `${ev.midi}:${(ev.startSec ?? 0).toFixed(4)}`; + const eventGain = Number.isFinite(ev.gain) ? ev.gain : 1.0; + if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) return; + if (eventGain === 0) return; + + const detune = buildDetuneTable(voices, detuneCents); + const fundFreq = midiToFreq(ev.midi); + const half = (voices - 1) / 2; + const centerIndex = voices % 2 === 1 ? half : -1; + + // Compute normalization: sum of voice amplitudes so total loudness + // stays sane regardless of voice count. + let ampSum = 0; + const amps = new Array(voices); + for (let v = 0; v < voices; v++) { + amps[v] = v === centerIndex ? centerGain : 1.0; + ampSum += amps[v]; + } + const norm = 1 / Math.max(0.001, ampSum); + + // Per-voice random phase so unison doesn't briefly cohere on attack. + // Deterministic via seed → same input always yields same waveform. + const rng = makeRng(`supersaw:${seed}`); + + for (let v = 0; v < voices; v++) { + const tone = fundFreq * Math.pow(2, detune[v] / 1200); + sound.synth({ + type: "sawtooth", + tone, + duration: ev.durSec, + volume: eventGain * amps[v] * norm, + attack, + decay, + phase: rng(), + }); + } +} + +// ── node-side buffer mixer ───────────────────────────────────────────── +// Wraps playSupersaw with a buffer-mock-synth bound to ev.startSec, so +// the bed renderer can call it like any other event mixer. +export function mixEventSupersaw(ev, out, opts = {}) { + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + if (!(out instanceof Float32Array)) return; + const sound = makeBufferSynth(out, ev.startSec, sampleRate); + playSupersaw(sound, ev, opts); +} + +// ── CLI demo ────────────────────────────────────────────────────────── +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); +if (isMain) { + const argv = process.argv.slice(2); + const flags = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } + else flags[key] = true; + } + } + + function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; + } + + const HERE = dirname(fileURLToPath(import.meta.url)); + const SAMPLE_RATE = 48_000; + const preset = flags.preset || "lead"; + const voices = flags.voices !== undefined ? Number(flags.voices) : undefined; + const detuneCents = flags.detune !== undefined ? Number(flags.detune) : undefined; + const bpm = Number(flags.bpm ?? 138); + const outPath = expandHome(flags.out) || resolve(HERE, `supersaw-${preset}.mp3`); + + const opts = { + preset, + sampleRate: SAMPLE_RATE, + ...(voices !== undefined ? { voices } : {}), + ...(detuneCents !== undefined ? { detuneCents } : {}), + }; + + const beatSec = 60 / bpm; + const noteScale = preset === "pad" ? 1.6 : preset === "stab" ? 0.35 : 0.7; + const arp = [57, 60, 64, 67, 64, 60, 57, 64]; + const events = []; + for (let i = 0; i < arp.length; i++) { + events.push({ startSec: i * beatSec * 0.5, midi: arp[i], gain: 0.9, durSec: beatSec * noteScale }); + } + const stabStart = arp.length * beatSec * 0.5 + beatSec * 0.25; + for (const m of [45, 57, 60, 64]) { + events.push({ startSec: stabStart, midi: m, gain: 0.7, durSec: beatSec * 2.0 }); + } + + const tailSec = preset === "pad" ? 2.5 : 1.0; + const totalSec = stabStart + beatSec * 2.0 + tailSec; + const out = new Float32Array(Math.ceil(totalSec * SAMPLE_RATE)); + + console.log(`→ supersaw demo · preset=${preset} · bpm=${bpm} · ${events.length} notes`); + for (const ev of events) mixEventSupersaw(ev, out, opts); + + let peak = 0; + for (let i = 0; i < out.length; i++) { const a = Math.abs(out[i]); if (a > peak) peak = a; } + if (peak > 0) { const norm = 0.7 / peak; for (let i = 0; i < out.length; i++) out[i] *= norm; } + + mkdirSync(dirname(outPath), { recursive: true }); + const rawPath = `${outPath}.f32.raw`; + const buf = Buffer.alloc(out.length * 4); + for (let i = 0; i < out.length; i++) buf.writeFloatLE(out[i], i * 4); + writeFileSync(rawPath, buf); + + const ff = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SAMPLE_RATE), "-ac", "1", + "-i", rawPath, + "-c:a", "libmp3lame", "-q:a", "3", + outPath, + ], { stdio: "inherit" }); + try { unlinkSync(rawPath); } catch {} + if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } + console.log(`✓ ${outPath}`); +} diff --git a/pop/dance/trance-hook.np b/pop/dance/trance-hook.np new file mode 100644 index 0000000000..89d0bdcc5a --- /dev/null +++ b/pop/dance/trance-hook.np @@ -0,0 +1,87 @@ +# trance hook — 64 words. "computer" appears 6× total (3 per verse) +# at varied octaves so each occurrence has its own melodic character. +# Chord regions: Am (1-8), F (9-16), C (17-24), G (25-32) per verse. +# Word ORDER strictly matches the lyric in pop/dance/bin/sing.mjs. +# notation: NOTE:syllable*beats + +verse 1 +# Am — i, A, love, computer, computer, C, surviving, death +A3:i-*1 C4:-i-*1 E4:-i*2 +A3:A*2 +C4:love-*1 E4:-love-*1 A3:-love*2 +C4:com-*1 E4:-pu-*1 A3:-ter*2 +A4:com-*1 C5:-pu-*1 E5:-ter*2 +E4:C*2 +A3:sur-*1 C4:-vi-*1 E4:-ving*2 +G3:death-*1 A3:-death*3 + +# F — D, fighting, E, F, driving, calling, G, 2 +F4:D*2 +A4:fight-*1 C5:-ing*3 +F4:E*2 +A4:F*2 +C5:dri-*1 A4:-ving*3 +F4:call-*1 A4:-ing*3 +C5:G*2 +F4:2*2 + +# C — H, sex, singing, J, playing, K, infinite, computer +C5:H*2 +E5:sex-*1 G5:-sex*3 +C5:sing-*1 E5:-ing*3 +G5:J*2 +E5:play-*1 C5:-ing*3 +G5:K*2 +C5:in-*1 E5:-fi-*1 G5:-nite*3 +C5:com-*1 E5:-pu-*1 G5:-ter*2 + +# G — writing, M, 3, silliness, crying, N, 30456, O +G4:writ-*1 B4:-ing*3 +D5:M*2 +B4:3*2 +G4:sil-*1 B4:-li-*1 D5:-ness*3 +B4:cry-*1 G4:-ing*3 +D5:N*2 +G4:30456-*1 B4:-30456-*1 D5:-30456*2 +G4:O*2 + +verse 2 +# Am — silliness, 30456, 8888, crying, writing, playing, singing, calling +A3:sil-*1 C4:-li-*1 E4:-ness*3 +A3:30456-*1 C4:-30456-*1 E4:-30456*2 +G3:8888-*1 A3:-8888*3 +C4:cry-*1 E4:-ing*3 +A3:writ-*1 C4:-ing*3 +E4:play-*1 A3:-ing*3 +C4:sing-*1 E4:-ing*3 +A3:call-*1 C4:-ing*3 + +# F — fighting, surviving, P, Q, R, S, T, U +F4:fight-*1 A4:-ing*3 +C5:sur-*1 A4:-vi-*1 F4:-ving*2 +F4:P*2 +A4:Q*2 +C5:R*2 +A4:S*2 +F4:T*2 +A4:U*2 + +# C — V, computer, X, Y, Z, 4, 5, 6 +C5:V*2 +E5:com-*1 G5:-pu-*1 C5:-ter*2 +G5:X*2 +E5:Y*2 +C5:Z*2 +G5:4*2 +E5:5*2 +C5:6*2 + +# G — 7, 8, computer, 10, i, love, computer, death +G4:7*2 +B4:8*2 +D5:com-*1 G4:-pu-*1 B4:-ter*2 +G4:10*2 +B4:i-*1 D5:-i-*1 G4:-i*2 +B4:love-*1 D5:-love-*1 G4:-love*2 +D5:com-*1 G4:-pu-*1 B4:-ter*2 +G4:death-*1 D4:-death*3