From b389293d4f71e1b29bab8316122239fb23d07813 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 20 May 2026 15:02:55 -0400 Subject: [PATCH] =?UTF-8?q?trancepenta:=20RELEASED=20=E2=80=94=205/4=20dor?= =?UTF-8?q?ian=20chill-trance=20single?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DistroKid album 860FA7AC-AE6E-4B0A-ABC6E1514D273054, instrumental single under the pixsies LP, mastered radio-balanced (−14 LUFS, LRA 4.5 LU, −1.4 dBTP). Audio pipeline (in `pop/dance/bin/`): - bake-trancepenta.sh: deterministic 4-stage bake — engine → scratch-mix → vocal-harmony bus → finalize - place-penta-harmony.mjs: jeffrey hum at 9 wandering pitches with per-entry FX (vibrato/bright/deep/mumble/phaser/bitcrush/reverse/ hollow), dancing-with-not-locked-to the theremin melody - finalize-penta-vocal.mjs: radio chain — HPF 28 Hz, aecho 4-tap space reverb with stamp-window reverb-duck, glue compressor, 4-band EQ, loudnorm I=−14 LRA=6, alimiter - scratch-mix.mjs: chop window 30s→2:25, bed-floor 40% under bursts, 84-88s quiet zone at drop1→break2 transition, last-30s gnarly tail disabled Engine (`recap/bin/trance.mjs`): - SECTION_TEMPLATES_5_CHILL: dedicated chill+meter-5 layout with the early-drop structure (drop1 at 0:29) per @jeffrey - Odyssey theremin: single melodic voice across the whole track with a 3-act dorian arc — JOURNEY OUT → BATTLE around the 1:35 stamp → RETURN HOME → OUTRO. Distance LFO breathes 0.35↔1.0 so the voice drifts close/distant. Harmonic-bloom envelope mid-note. - Supersaw boost (gain 0.32 high / 0.26 low root / 0.18 low 5th) - Kick tik/tok: alternating downbeats pitched 1.0 ↔ 0.55 for a rocking call-and-response - Clap on beat 2 of every other drop1/drop2 bar - Dice-roll click-clack post-vortex (bar 41+): 5-9 wooden taps per cluster with random 1.4-5.4 kHz tone per click and 30-140 ms gaps - Extreme swing crescendo: swingSec peaks at the stamp bar (39) then settles back by bar 51 — drop2 + outro on a tight grid - DRUM_GAIN pulled to 0.92 (chill perc level), DUCK_DEPTH 0.40 - Foghorns + boathorns removed; outro perc cut at bar 72 Video (`pop/dance/bin/cover-video.mjs`): - Motion dampened: bed-SFX no-pluck (gallop/wave/amb-grain don't shake the verlet string), pluck amplitude 22+38·env, WU_STR 0.32, stringPan ×0.25, wobble ×0.4, orbit radius 0.020, slitAmp env term removed (preMelt-only slit on drops) - Face magnet: smoothed kickAct envelope pulls aim toward face center during kicks, kickZoom amp 0.10 with 140 ms attack - BUILDS_ROOT moved from ~/Desktop/builds to ~/Documents/Working Desktop/builds (Desktop auto-cleaner ate mid-renders before) Cover (`pop/dance/bin/gen-help-series-prompts.mjs` + new `gen-illys-penta.sh` + a dedicated `trancepenta-cover-final/` campaign): felt-character + scary-tattered jeffrey + young Mark Zuckerberg at the PALS laptop in an after-hours Trader Joe's with multi-section ambient lighting. New zuck-2007 refs (Hackathon + F8) folded into the prompt for tighter likeness. `chart-png.mjs`: annotated 4K multitrack chart of the bake — section tints, per-lane event timelines, time axis at 10 s grid, callouts at notable moments (drop1, stamp, drop2, perc-cut), pipeline + open TODOs in the footer. Lives in the DistroKid folder as `trancepenta-CHART.png` for commenting on future mix passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- pop/RELEASES.md | 153 ++++++++++ pop/dance/bin/bake-trancepenta.sh | 74 +++++ pop/dance/bin/build.mjs | 15 +- pop/dance/bin/chart-png.mjs | 273 ++++++++++++++++++ pop/dance/bin/cover-video.mjs | 146 +++++----- pop/dance/bin/finalize-penta-vocal.mjs | 44 ++- pop/dance/bin/gen-help-series-prompts.mjs | 2 +- pop/dance/bin/gen-illys-penta.sh | 44 +++ pop/dance/bin/place-penta-harmony.mjs | 121 ++++++++ pop/dance/bin/scratch-mix.mjs | 35 ++- recap/bin/trance.mjs | 324 ++++++++++++++++++---- 11 files changed, 1083 insertions(+), 148 deletions(-) create mode 100755 pop/dance/bin/bake-trancepenta.sh create mode 100644 pop/dance/bin/chart-png.mjs create mode 100755 pop/dance/bin/gen-illys-penta.sh create mode 100644 pop/dance/bin/place-penta-harmony.mjs diff --git a/pop/RELEASES.md b/pop/RELEASES.md index 614e897c4..0cf15fbb0 100644 --- a/pop/RELEASES.md +++ b/pop/RELEASES.md @@ -79,6 +79,159 @@ DistroKid has a "request Spotify for Artists" shortcut for new artists. `~/Documents/Working Desktop/gens/ac-header/` (jeffrey refs + whistlegraph-butterfly.png + pals-logo.png), gen v6. +## amazing grace — WIP (1:24 verse-1 single, cool-sine arrangement) + +- **Lane:** `pop/big-pictures/` · 70 BPM · 3/4 hymn-pace · G major + pentatonic · jeffrey-pvc sung lead, **cooler sine instrumentation** + layered over the existing sinebells waltz bed + pad. The "New + Britain" tune (William Walker, 1835), **verse 1 only** as a focused + single. **1:24 / 84 s** total: ~12 s cool-sine intro pad → verse 1 + vocal (≈58 s) → ~14 s sine outro fade. +- **Status (2026-05-20):** 1:24 master + brightened mp3 done. Vertical + IG cut rendered with placeholder rooftop illys. **Scene direction + pivoted** to a small old church sanctuary, jeffrey in a pew with his + green Neo open on his lap, singing while playing the notepat + on-screen piano, congregation in pews around him. Prompts updated + but illy regen is **BLOCKED** — OpenAI billing hard-limit was hit + mid-set (verses 1–6 + the deprecated rooftop scene rendered, verse 7 + + cover failed). +- **Master:** `~/Documents/Working Desktop/amazing-grace/amazing-grace-MASTER.wav` + (16-bit/44.1 kHz stereo, **84.00 s / 1:24**). Pre-bright copy at + `amazing-grace-MASTER-preBright.wav`. 320 k mp3 + + 3000² cover embedded: `amazing-grace/amazing-grace.mp3` (4.6 MB). + Mirrored to `~/Desktop/amazing-grace.mp3` + + `~/Desktop/amazing-grace-MASTER.wav`. Desktop copies auto-clean + ([[feedback_desktop_autocleaned]]) — the + `~/Documents/Working Desktop/amazing-grace/` copy is durable. +- **Bake recipe (deterministic, byte-faithful):** + 1. `pop/big-pictures/amazing.np` and `amazing.txt` hold the **verse-1 + edit** (full 7-verse hymn preserved in `amazing.np.bak-7verse` / + `amazing.txt.bak-7verse`). + 2. `node pop/big-pictures/cli.mjs amazing --force` (engine: jeffrey-pvc + TTS → ElevenLabs `/with-timestamps` align → score-pitch WORLD f0 + replacement → score-stretch rubberband 8× ceiling, +60 ms onset → + melody-bells pad → waltz.mjs sinebells bed → amix vox 2.5 / pad + 0.55 / bed 0.20 → ac signoff stamp → finalize ID3+cover) → + `pop/big-pictures/out/amazing-final.mp3` (≈58 s). + 3. `node pop/big-pictures/bin/cool-sine-layer.mjs --bpm 70 --duration 84 + --out /tmp/amazing-cool-sine.wav` (three-voice all-sine layer: sub + bass an octave below the chord root with chorus detune, 3-osc sine + pad on root/5/oct with 0.18 Hz LFO + wide stereo, two-octave-up + sparkle bell triggered on each chord downbeat between t=14..t=68, + soft tanh saturation + peak-normalize -3 dB). + 4. Mix: `ffmpeg -i amazing-cool-sine.wav -i amazing-final.mp3 + -filter_complex "[1:a]adelay=12000|12000,apad=pad_dur=12,volume=1.4[v]; + [0:a]volume=0.9[c];[v][c]amix=inputs=2:duration=longest:normalize=0, + atrim=duration=84,afade=t=out:st=82:d=2"` → 1:24 pre-bright master. + 5. Bright polish: `equalizer=f=190:g=-1, equalizer=f=4200:g=2.2, + highshelf=f=8500:g=4, highshelf=f=12500:g=1.8, + loudnorm=I=-14:TP=-1.2:LRA=13` → final master. +- **Cover (PENDING re-gen — billing limit):** the **planned** scene is + a small old church sanctuary, late-afternoon honey light through + stained glass, jeffrey in a pew with the chartreuse Neo open across + his lap (whistlegraph-butterfly white-paper scrap on the lid), + singing while pressing the on-screen notepat piano keys, AC freaks + + pixsies in pews around him. Prompts at + `~/Documents/Working Desktop/gens/amazing-grace-sections/{verse1..7,cover}/cover-prompt.txt` + (church direction; rooftop-hymn-circle previous versions backed up + alongside). Generator: `pop/big-pictures/bin/gen-amazing-prompts.mjs` + + `pop/big-pictures/bin/gen-amazing-illys.sh`. The **placeholder** + cover (rooftop verse6 square crop → 3000²) is currently at + `~/Desktop/amazing-grace-cover-3000.jpg` and embedded in the + release mp3. **Regen the church cover once OpenAI credits are topped + up.** +- **Vertical IG cut:** 1080×1920, **1:24**, slow ken-burns drift + across two placeholder illys (verse1 golden-hour rooftop → 6 s + xfade → verse6 dusk rooftop), title PNG fades in over 0–3 s. Output: + `~/Desktop/amazing-grace-vertical.mp4` (15 MB). brew ffmpeg 8.1 here + ships **without libass/drawtext/subtitles filters** — title is + pre-rendered via ImageMagick + overlay; lyric subtitles deferred to + a follow-up pass (install a libass-enabled ffmpeg or render subtitle + PNG sequence). **Re-render** the IG once the church illys land. +- **Source files (in repo):** + - lyrics (verse 1 single) — `pop/big-pictures/amazing.txt` · + backup of 7-verse hymn at `amazing.txt.bak-7verse` + - score (verse 1 single) — `pop/big-pictures/amazing.np` · backup + of 7-verse hymn at `amazing.np.bak-7verse` + - prompts (church scene) — + `pop/big-pictures/bin/gen-amazing-prompts.mjs` + - illys launcher — `pop/big-pictures/bin/gen-amazing-illys.sh` + - cool-sine layer — `pop/big-pictures/bin/cool-sine-layer.mjs` + - vertical IG renderer — `pop/big-pictures/bin/vertical-amazing.mjs` +- **Next (outward — manual / your call):** + 1. Top up OpenAI credits, then + `bash pop/big-pictures/bin/gen-amazing-illys.sh v2` to regen the + full church scene set (verses 1–7 + cover) with the updated + prompts. + 2. After church illys land: re-crop verse1 (church hero) → 3000² + cover via `bash pop/big-pictures/bin/finalize-amazing-cover.sh v2` + and re-render the vertical IG via + `node pop/big-pictures/bin/vertical-amazing.mjs`. + 3. Stage `system/public/assets/pop/amazing-grace.mp3` + + `amazing-grace.jpg` + `amazing-grace-canvas.mp4`, + `npm run assets:sync:up`, then `doctl compute cdn flush + 2ff25b29-db80-48e6-888e-eb8a2464d69b --files pop/amazing-grace.mp3 + --files pop/amazing-grace.jpg --files pop/amazing-grace-canvas.mp4` + ([[project_cdn_overwrite_stale]]). + 4. DistroKid upload: `~/Desktop/amazing-grace-MASTER.wav` + cover + `~/Desktop/amazing-grace-cover-3000.jpg`, artist **Aesthetic Dot + Computer**, album **pixsies**, title **amazing grace**, writer + credit **John Newton (lyrics, public domain)** + **William Walker + (melody, public domain)** + arrangement **Aesthetic Dot Computer**. + 5. Flip this entry to **RELEASED** with the DistroKid album UUID. + +## trancepenta — RELEASED + +- **Lane:** `pop/dance/` · 5/4 · 126 BPM steady · dorian; chill-trance + instrumental — hellsine gabber kick (tik/tok alternating pitch), + galloping intro (real CC0 horse + neigh + thunder + train-whistle + + late-third ocean bed), Odyssey theremin solo line (3-act dorian arc), + jeffrey hum at 9 wandering pitches with per-entry FX (dancing with + the theremin, never aligned), supersaw power-saw wall, extreme swing + crescendoing to the 1:33 audio stamp, dice-roll click-clack tumbling + in post-vortex. Spec: `pop/dance/trancepenta.md`. +- **Released:** 2026-05-20 via DistroKid +- **Canonical:** https://distrokid.com/dashboard/album/?albumuuid=860FA7AC-AE6E-4B0A-ABC6E1514D273054 +- **CDN assets** (canonical, public — `system/public/assets/pop/`, gitignored, synced via `npm run assets:sync:up`): + - audio · https://assets.aesthetic.computer/pop/trancepenta.mp3 (320 k mp3 of the radio-balanced master, 190.69 s) + - cover · https://assets.aesthetic.computer/pop/trancepenta.jpg (3000², felt-character hero crop with multi-section lighting) + - canvas/IG · https://assets.aesthetic.computer/pop/trancepenta-canvas.mp4 *(pending the other agent's render — see vertical-video session)* + - Reconstructable byte-faithfully any time: `BAKE_FORCE=1 bash pop/dance/bin/bake-trancepenta.sh` + → produces `~/Documents/Working Desktop/twi-out/trancepenta-MASTER.wav` + paired struct.json + → cover at `~/Documents/Working Desktop/gens/trancepenta-cover-3000.jpg` (felt-character regen via `gens/trancepenta-cover-final/`) +- **Master:** `~/Desktop/trancepenta-DISTROKID/trancepenta-MASTER.wav` + — 16-bit/44.1 kHz stereo WAV, 190.69 s. **Radio-balanced master:** + −14 LUFS / −1.4 dBTP / LRA 4.5 LU (Spotify-ready, tight broadcast + dynamic range). Master chain: highpass 28 Hz → aecho 4-tap space + reverb (with stamp-window reverb-duck) → glue compressor (−16 / 3.0 / + 8 dB knee) → 4-band EQ (120 Hz −1, 250 Hz −1.2, 3.5 kHz +2.5, + 11 kHz +1.8) → loudnorm I=−14 LRA=6 → alimiter 0.94 → 18 s fade. + Instrumental (no vocal phrase — only jeffrey-hum harmonics). +- **Cover:** 3000² `~/Desktop/trancepenta-DISTROKID/trancepenta-cover-3000.jpg` + — felt-character + scary-tattered hero, jeffrey + young Mark + Zuckerberg at the PALS laptop in an after-hours Trader Joe's, with + prismatic multi-section ambient lighting refracting around the + laptop's PALS lid back-glow. Felt characters with frayed/torn felt + clothing (Wes Anderson / Aardman-grade craft realism inside a real + photographic environment). Multi-section lighting bakes the song's + 8-section arc into the ambient fog colour. Generated via + `gen-promo.mjs` campaign at `gens/trancepenta-cover-final/`. +- **DistroKid folder:** `~/Desktop/trancepenta-DISTROKID/` — README, + CHART.md, trancepenta-CHART.png (annotated 4K multitrack chart of + the bake, sections + events + lane breakdowns + open-TODOs). +- **Vertical IG / Spotify Canvas:** in progress (separate agent owns + the final video render — `cover-video.mjs` with the dampened pan + + Odyssey-theremin events visible on the lane visualization). +- **Open TODOs (for the next track):** + · kick wider (Haas stereo, broader lows) + · hi-hats shorter + sharper (decay envelope tuning in playPercussion `g`) + · choral phoneme vocals (jeffrey-pvc, no words — hahaha / olololol / + rerererere / babobebebebabo / ummy wummy / woo woo) layered at + root + 3rd + 5th + oct with long reverb + · vocal pitch-correction to the Odyssey theremin melody (`rubberband` + per-slice once compiled into ffmpeg, or a new + `place-penta-autotune.mjs`) + ## hellsine — WIP (concept track, first cut) - **Lane:** `pop/hellsine/` · the *all-sine* concept track — every voice diff --git a/pop/dance/bin/bake-trancepenta.sh b/pop/dance/bin/bake-trancepenta.sh new file mode 100755 index 000000000..546d1a000 --- /dev/null +++ b/pop/dance/bin/bake-trancepenta.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# bake-trancepenta.sh — the 4-stage trancepenta master bake. build.mjs's +# trancepenta CONFIG invokes this (instead of bake.mjs, which is hardcoded +# to trancenwaltzi's 3-stage path). Stages: +# 1 — engine (recap/bin/trance.mjs --mode chill --meter 5 --bpm 126 +# --scale dorian --hell 13 --chill-hats off --gallop --beat-in 30) +# → trancepenta-MASTER-preBright.wav (+ struct.json) +# 2 — scratch-mix (post-FX, beat-locked to engine struct) +# 3 — place-penta-vocal (3 sung entries layered on the scratch master) +# 4 — finalize-penta-vocal (stage-3 master → -14 LUFS / -1.5 dBTP / +# 18 s fade) → trancepenta-MASTER.wav + .mp3 +# +# Cached: if the final master + paired struct.json already exist and +# BAKE_FORCE is unset, exits 0 silently (build.mjs reads them via +# audioSrc/structSrc anyway). Re-bake explicitly: +# BAKE_FORCE=1 bash bake-trancepenta.sh +set -euo pipefail +REPO="$(cd "$(dirname "$0")/../../.." && pwd)" +OUT="${HOME}/Documents/Working Desktop/twi-out" +mkdir -p "$OUT" +PRE="$OUT/trancepenta-MASTER-preBright.wav" +SCR="$OUT/.tp-scr.wav" # stage-2 scratch master (instrumental + post-FX) +VOX="$OUT/.tp-vox-bus.wav" # stage-3 vocal bus (3 placed entries, silence between) +FINAL="$OUT/trancepenta-MASTER.wav" +MP3="$OUT/trancepenta.mp3" +STRUCT="$PRE.assets/struct.json" +VOCAL="$REPO/pop/dance/out/trancepenta-hum-layered.mp3" # wordless hum, dances with the Odyssey theremin +STAMP="$REPO/pop/dance/out/.ac-dot-stamp-vocal.mp3" + +# cached short-circuit +if [ -z "${BAKE_FORCE:-}" ] && [ -f "$FINAL" ] && [ -f "$STRUCT" ]; then + echo "[bake-trancepenta] cached → $FINAL (BAKE_FORCE=1 to re-bake)" + exit 0 +fi + +run() { echo; echo "[bake-trancepenta] $1"; shift; "$@"; } + +# 1 — engine (beat-in pulled 30 → 12 so the bass beat enters within the +# first 15 s and the first drop lands much sooner) +run "engine (single all-sine multi-bus render, --hell 13 --gallop --beat-in 12)" \ + node "$REPO/recap/bin/trance.mjs" --mode chill --meter 5 --master \ + --bpm 126 --scale dorian --hell 13 --chill-hats on --gallop \ + --beat-in 12 --out "$PRE" + +# 2 — scratch-mix (post-FX, beat-locked) → SCR is the instrumental master +run "scratch-mix (post-FX beat-locked to struct)" \ + node "$REPO/pop/dance/bin/scratch-mix.mjs" \ + "$PRE" "$SCR" "$STAMP" "$STRUCT" + +# 3 — jeffrey HARMONY BUS (wandering chordal hum that dances with the +# Odyssey theremin — sometimes lower, sometimes higher, never fully +# aligned). Set BAKE_NO_VOCAL=1 to revert to a silent vox bus. +SCRDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$SCR") +if [ "${BAKE_NO_VOCAL:-}" = "1" ] || [ ! -f "$VOCAL" ]; then + echo "[bake-trancepenta] silent vox bus (BAKE_NO_VOCAL=1 or hum missing · dur=${SCRDUR}s)" + ffmpeg -y -f lavfi -i "anullsrc=channel_layout=stereo:sample_rate=44100" \ + -t "$SCRDUR" -c:a pcm_s16le "$VOX" 2>/dev/null +else + run "place-penta-harmony (wandering jeffrey hum stack · dur=${SCRDUR}s)" \ + node "$REPO/pop/dance/bin/place-penta-harmony.mjs" \ + --phrase "$VOCAL" --dur "$SCRDUR" --out "$VOX" +fi + +# 4 — finalize: SCR (instrumental) + VOX (vocal bus) → -14 LUFS / -1.5 dBTP / 18 s fade +run "finalize-penta-vocal (--vox-db -3 · scratch master + vocal bus)" \ + node "$REPO/pop/dance/bin/finalize-penta-vocal.mjs" \ + --scr "$SCR" --vox "$VOX" --vox-db -3 \ + --out "$FINAL" + +# mp3 for build.mjs (matches audioSrc dir) +ffmpeg -y -i "$FINAL" -codec:a libmp3lame -b:a 320k "$MP3" 2>/dev/null + +rm -f "$SCR" "$VOX" +echo "[bake-trancepenta] done → $FINAL" diff --git a/pop/dance/bin/build.mjs b/pop/dance/bin/build.mjs index 4368f26c4..6fe514b66 100755 --- a/pop/dance/bin/build.mjs +++ b/pop/dance/bin/build.mjs @@ -41,7 +41,9 @@ import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "../../.."); -const BUILDS_ROOT = `${homedir()}/Desktop/builds`; +// Builds live under Working Desktop (the auto-cleaned ~/Desktop has +// erased mid-render outputs in the past — see [[feedback_desktop_autocleaned]]). +const BUILDS_ROOT = `${homedir()}/Documents/Working Desktop/builds`; const argv = process.argv.slice(2); if (argv.length === 0 || argv[0].startsWith("--")) { @@ -183,12 +185,15 @@ const CONFIGS = { }, formats: [ { - // Vertical 2:3 portrait set — the dismal foggy-harbour illys - // (jeffrey + mark peers, glowing-pals lids). The vertical cut - // is the primary deliverable (full song + a 30s IG-story cut). + // Vertical 2:3 portrait set — the after-hours Trader Joe's / + // jeffrey-teaching-young-Zuck / pixsies-watching set (v2, with + // per-section dismal tint + PALS lid back-glow + screen-spill + // on faces). v1 was the dismal foggy-harbour first pass, kept + // on disk as .v1 backup. Primary deliverable = full vertical + + // a 30s IG-story cut. suffix: "vertical", size: "1080x1920", - variant: "v1", + variant: "v2", prelude: null, // chill mix has no greeting → no prelude swap }, ], diff --git a/pop/dance/bin/chart-png.mjs b/pop/dance/bin/chart-png.mjs new file mode 100644 index 000000000..8a51c9cc8 --- /dev/null +++ b/pop/dance/bin/chart-png.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +// chart-png.mjs — render a fully-tracked multitrack chart of a +// dance-bake's struct.json to PNG. Used to give @jeffrey a visual +// reference for commenting on specific moments in the mix. +// +// Usage: +// node pop/dance/bin/chart-png.mjs \ +// --struct ~/Documents/Working\ Desktop/twi-out/trancepenta-MASTER-preBright.wav.assets/struct.json \ +// --title trancemarka \ +// --out ~/Desktop/trancemarka-DISTROKID/trancemarka-CHART.png +// +// Sections are pulled from the SECTION_TEMPLATES_5_CHILL layout for +// chill+meter-5 trancepenta/trancemarka. If the struct comes from a +// different track this can be adapted. + +import { createCanvas } from "canvas"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; + +const expandHome = (p) => p?.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p; +const flags = {}; +for (let i = 2; i < process.argv.length; i += 2) { + flags[process.argv[i].replace(/^--/, "")] = process.argv[i + 1]; +} +const STRUCT_PATH = expandHome(flags.struct); +const OUT = expandHome(flags.out); +const TITLE = flags.title || "trancemarka"; + +const s = JSON.parse(readFileSync(STRUCT_PATH, "utf8")); +const events = s.events || {}; +const TOTAL = 190.69; // 80 bars × 2.381s @ 5/4 126 BPM + +// Lane order + colors (matches cover-video.mjs lane palette). +const LANES = [ + { key: "kick", label: "KICK", color: "#ff5a8a" }, + { key: "sub", label: "SUB", color: "#a855f7" }, + { key: "hat", label: "HAT", color: "#7ad9f5" }, + { key: "bells", label: "BELLS", color: "#4ad1bf" }, + { key: "lead", label: "LEAD", color: "#aef240" }, + { key: "piano", label: "PIANO", color: "#f5d240" }, + { key: "supersaw", label: "SAW", color: "#9aff4d" }, + { key: "snare", label: "SNARE", color: "#e7c4ff" }, + { key: "sfx", label: "SFX", color: "#ff9a40" }, + { key: "vox", label: "VOX", color: "#f599c6" }, +]; + +// Sections — bars + tints. Match SECTION_TEMPLATES_5_CHILL exactly. +const BAR_SEC = 5 * 60 / 126; // 2.381s +const OPENING_PREFIX_SEC = 0.25; +const SECTIONS = [ + { name: "intro", bars: 4, tint: "rgba(78,96,116,0.28)" }, + { name: "break1", bars: 4, tint: "rgba(86,118,128,0.28)" }, + { name: "build1", bars: 4, tint: "rgba(70,108,108,0.28)" }, + { name: "drop1", bars: 24, tint: "rgba(72,104,140,0.32)" }, + { name: "break2", bars: 4, tint: "rgba(96,90,118,0.28)" }, + { name: "build2", bars: 4, tint: "rgba(96,108,92,0.28)" }, + { name: "drop2", bars: 28, tint: "rgba(80,98,128,0.32)" }, + { name: "outro", bars: 8, tint: "rgba(92,100,112,0.28)" }, +]; +let _accBar = 0; +for (const sec of SECTIONS) { + sec.t0 = OPENING_PREFIX_SEC + _accBar * BAR_SEC; + _accBar += sec.bars; + sec.t1 = OPENING_PREFIX_SEC + _accBar * BAR_SEC; +} + +// Canvas geometry — high-res for the annotated chart. +const W = 4000; +const H = 2400; +const MARGIN_L = 240; +const MARGIN_R = 120; +const HEADER_H = 360; // title + section strip +const FOOTER_H = 320; // time axis + total counts + annotation legend +const LANE_H = Math.floor((H - HEADER_H - FOOTER_H) / LANES.length); +const PLOT_W = W - MARGIN_L - MARGIN_R; +const PLOT_TOP = HEADER_H; +const PLOT_BOT = H - FOOTER_H; + +const tToX = (t) => MARGIN_L + (t / TOTAL) * PLOT_W; +const mmss = (t) => { + const m = Math.floor(t / 60), s = Math.floor(t - m * 60); + return `${m}:${String(s).padStart(2, "0")}`; +}; + +const canvas = createCanvas(W, H); +const ctx = canvas.getContext("2d"); + +// Background. +ctx.fillStyle = "#0a0d12"; +ctx.fillRect(0, 0, W, H); + +// Section tint columns + names + start times. +for (const sec of SECTIONS) { + const x0 = tToX(sec.t0), x1 = tToX(sec.t1); + ctx.fillStyle = sec.tint; + ctx.fillRect(x0, PLOT_TOP, x1 - x0, PLOT_BOT - PLOT_TOP); + // Section divider line. + ctx.strokeStyle = "rgba(255,255,255,0.18)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x1, PLOT_TOP); + ctx.lineTo(x1, PLOT_BOT); + ctx.stroke(); + // Section header strip. + ctx.fillStyle = "rgba(255,255,255,0.07)"; + ctx.fillRect(x0, HEADER_H - 100, x1 - x0, 100); + ctx.fillStyle = "rgba(255,255,255,0.92)"; + ctx.font = "bold 36px -apple-system, sans-serif"; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(sec.name, x0 + 20, HEADER_H - 64); + ctx.font = "26px -apple-system, sans-serif"; + ctx.fillStyle = "rgba(255,255,255,0.55)"; + ctx.fillText(mmss(sec.t0), x0 + 20, HEADER_H - 28); +} + +// Title block. +ctx.fillStyle = "rgba(255,255,255,0.97)"; +ctx.font = "bold 96px -apple-system, sans-serif"; +ctx.textAlign = "left"; +ctx.fillText(TITLE, MARGIN_L, 110); +ctx.font = "32px -apple-system, sans-serif"; +ctx.fillStyle = "rgba(255,255,255,0.55)"; +ctx.fillText("Aesthetic Dot Computer · pixsies · 3:11 · 5/4 · 126 BPM · D dorian · −14 LUFS / LRA 5.5 / −1.4 dBTP", MARGIN_L, 170); +// Total event counts on the right side of the title row. +ctx.font = "26px -apple-system, sans-serif"; +ctx.fillStyle = "rgba(255,255,255,0.55)"; +ctx.textAlign = "right"; +const countParts = []; +for (const lane of LANES) { + const n = (events[lane.key] || []).length; + if (n > 0) countParts.push(`${lane.label} ${n}`); +} +ctx.fillText(countParts.join(" · "), W - MARGIN_R, 170); + +// Lane rows. +ctx.textBaseline = "middle"; +for (let i = 0; i < LANES.length; i++) { + const lane = LANES[i]; + const yTop = PLOT_TOP + i * LANE_H; + const yMid = yTop + LANE_H / 2; + // Lane background. + ctx.fillStyle = i % 2 === 0 ? "rgba(255,255,255,0.025)" : "rgba(0,0,0,0.0)"; + ctx.fillRect(MARGIN_L, yTop, PLOT_W, LANE_H); + // Lane horizontal centerline. + ctx.strokeStyle = "rgba(255,255,255,0.10)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(MARGIN_L, yMid); + ctx.lineTo(MARGIN_L + PLOT_W, yMid); + ctx.stroke(); + // Lane label. + ctx.fillStyle = lane.color; + ctx.font = "bold 38px -apple-system, sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(lane.label, MARGIN_L - 24, yMid - 10); + // Lane count. + const evs = events[lane.key] || []; + ctx.fillStyle = "rgba(255,255,255,0.45)"; + ctx.font = "22px -apple-system, sans-serif"; + ctx.fillText(String(evs.length), MARGIN_L - 24, yMid + 30); + // Events. + ctx.fillStyle = lane.color; + for (const ev of evs) { + const t = ev.displayedT ?? ev.t; + if (t < 0 || t > TOTAL) continue; + const x = tToX(t); + // Width based on dur (clamped). Most events are short. + const dur = ev.dur || 0.05; + const w = Math.max(2, (dur / TOTAL) * PLOT_W); + ctx.fillRect(x, yMid - LANE_H * 0.32, w, LANE_H * 0.64); + } + // Lane top border. + if (i > 0) { + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.beginPath(); + ctx.moveTo(MARGIN_L, yTop); + ctx.lineTo(MARGIN_L + PLOT_W, yTop); + ctx.stroke(); + } +} + +// Time axis (every 10s). +ctx.textAlign = "center"; +ctx.textBaseline = "top"; +ctx.fillStyle = "rgba(255,255,255,0.55)"; +ctx.font = "24px -apple-system, sans-serif"; +for (let t = 0; t <= TOTAL; t += 10) { + const x = tToX(t); + ctx.strokeStyle = "rgba(255,255,255,0.12)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, PLOT_BOT); + ctx.lineTo(x, PLOT_BOT + 14); + ctx.stroke(); + ctx.fillText(mmss(t), x, PLOT_BOT + 22); +} + +// ── ANNOTATED CALLOUTS — notable moments to comment against ── +const ANNOTATIONS = [ + { t: 9.78, label: "first kick · break1 enters", side: "top" }, + { t: 14.77, label: "hats ramp in", side: "top" }, + { t: 19.30, label: "build1 · sub + riser", side: "top" }, + { t: 28.82, label: "★ DROP1 ENTERS · supersaw wall + full kit", side: "top" }, + { t: 30.00, label: "scratch-FX window begins (30s → 2:25)", side: "bottom" }, + { t: 85.7, label: "break2 · beat carries through", side: "top" }, + { t: 95.30, label: "★ AUDIO STAMP · 'aesthetic dot computer' + arpeggiated neigh", side: "bottom" }, + { t: 96.07, label: "build2 · riser + bitcrush back", side: "top" }, + { t: 105.60, label: "★ DROP2 ENTERS · hardest section (drumDensity 1.0, 28 bars)", side: "top" }, + { t: 149.00, label: "scratch silenced here (clean drop2 finale)", side: "bottom" }, + { t: 165.00, label: "boathorn (159s · the lone harbour blast)", side: "bottom" }, + { t: 172.27, label: "★ OUTRO · perc out, pad + bells fade", side: "top" }, +]; +for (const a of ANNOTATIONS) { + const x = tToX(a.t); + const isTop = a.side === "top"; + // Vertical line marker through all lanes. + ctx.strokeStyle = a.label.startsWith("★") ? "rgba(255,255,255,0.55)" : "rgba(255,255,255,0.30)"; + ctx.lineWidth = a.label.startsWith("★") ? 2.5 : 1.5; + ctx.setLineDash(a.label.startsWith("★") ? [] : [6, 6]); + ctx.beginPath(); + ctx.moveTo(x, PLOT_TOP); + ctx.lineTo(x, PLOT_BOT); + ctx.stroke(); + ctx.setLineDash([]); + // Label callout outside the plot region. + ctx.textBaseline = isTop ? "alphabetic" : "top"; + ctx.textAlign = "left"; + const labelY = isTop ? PLOT_TOP - 14 : PLOT_BOT + 70; + // Background pill for legibility. + ctx.font = "24px -apple-system, sans-serif"; + const tw = ctx.measureText(`${mmss(a.t)} ${a.label}`).width + 24; + ctx.fillStyle = a.label.startsWith("★") ? "rgba(255,90,138,0.18)" : "rgba(255,255,255,0.10)"; + ctx.fillRect(x - 12, labelY - (isTop ? 32 : 0), tw, 32); + ctx.fillStyle = a.label.startsWith("★") ? "rgba(255,180,200,0.98)" : "rgba(255,255,255,0.78)"; + ctx.fillText(`${mmss(a.t)} ${a.label}`, x, labelY - (isTop ? 10 : -22)); +} +// Major grid lines at section boundaries (drawn ON TOP of events for clarity). +ctx.strokeStyle = "rgba(255,255,255,0.18)"; +ctx.lineWidth = 1.5; +for (const sec of SECTIONS) { + const x = tToX(sec.t0); + ctx.beginPath(); + ctx.moveTo(x, PLOT_TOP); + ctx.lineTo(x, PLOT_BOT); + ctx.stroke(); +} + +// Footer: pipeline note + TODOs. +ctx.fillStyle = "rgba(255,255,255,0.55)"; +ctx.textAlign = "left"; +ctx.textBaseline = "top"; +ctx.font = "20px -apple-system, sans-serif"; +ctx.fillText("PIPELINE", MARGIN_L, PLOT_BOT + 130); +ctx.fillStyle = "rgba(255,255,255,0.78)"; +ctx.font = "20px -apple-system, sans-serif"; +ctx.fillText("trance.mjs (engine · --hell 13 --gallop --chill-hats on --beat-in 12) → scratch-mix (0:30→2:25, bed-floor 40%) → silent vox bus → finalize (aecho 4-tap space · radio glue · −14 LUFS LRA 6)", + MARGIN_L, PLOT_BOT + 158); + +ctx.fillStyle = "rgba(255,90,138,0.85)"; +ctx.font = "20px -apple-system, sans-serif"; +ctx.fillText("OPEN TODOs", MARGIN_L, PLOT_BOT + 206); +ctx.fillStyle = "rgba(255,255,255,0.78)"; +ctx.fillText("· kick wider (Haas-style stereo, broader lows) · hi-hats shorter + sharper (decay envelope tuning in playPercussion 'g') · reverb-suck on the audio stamp + recover (sidechain in finalize)", + MARGIN_L, PLOT_BOT + 234); +ctx.fillText("· choral phoneme vocals (jeffrey-pvc, no words): hahahaha / olololololol / rerererere / babobebebebabo / ummy wummy / woo woo — layered at root+3rd+5th+oct with long reverb, sparse placement", + MARGIN_L, PLOT_BOT + 262); + +// Write. +const buf = canvas.toBuffer("image/png"); +writeFileSync(OUT, buf); +console.log(`✓ chart → ${OUT} (${buf.length.toLocaleString()} bytes)`); diff --git a/pop/dance/bin/cover-video.mjs b/pop/dance/bin/cover-video.mjs index 1771f27fa..fe02d90cb 100755 --- a/pop/dance/bin/cover-video.mjs +++ b/pop/dance/bin/cover-video.mjs @@ -1208,31 +1208,12 @@ const shutdownBeeps = (struct.events?.sfx || []) // 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. +// ── kick-driven zoom ──────────────────────────────────────────────── +// Each kick pulses a soft zoom-in. The camera AIM no longer snaps +// per-kick (that read as a 4 Hz positional saw at 120 BPM) — it +// continuously orbits the face center on sub-Hz incommensurate sines +// (see the orbit math in the per-frame draw block below). 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"); @@ -1269,7 +1250,7 @@ const GROOVE_R = 950; // groove radius — larger = gentler arc // so the seams showed). 32 thin slices make the horizontal shear // near-continuous; a small per-band overlap fuses any remaining seam. const WU_HALF = 240, WU_W = WU_HALF * 2, WU_STEP = 3, WU_BANDS = 56; -const WU_STR = 0.50, WU_BW = WU_W / WU_BANDS, WU_OVER = 2; +const WU_STR = 0.20, WU_BW = WU_W / WU_BANDS, WU_OVER = 2; const wuWin = new Float64Array(WU_BANDS); for (let b = 0; b < WU_BANDS; b++) { wuWin[b] = Math.sin(Math.PI * ((b + 0.5) / WU_BANDS)) ** 2; @@ -1640,63 +1621,68 @@ function drawFrame(t) { // dense kicks read as one continuous swell, never a per-frame snap. const slowBreath = 0.028 * (0.5 - 0.5 * Math.cos(audioT * (2 * Math.PI / 16))); let kickZoom = 0; - // Track the most-recent kick so the camera can GLIDE between its aim - // point and the next kick's aim (the dramatic zoom-in/out + pan that - // was previously hard-disabled with lastKickIdx=-1 → "barely moved"). - let lastKickIdx = -1; - let lastKickT = -1e9; + // Softer kick zoom — 140 ms attack (was 70 ms / ~2 frames at 30 fps, + // which read as a hard snap) into a gentle ~0.34 s exponential + // release. max() over recent kicks → dense kicks read as one swell. for (let i = kicksSorted.length - 1; i >= 0; i--) { const kt = kicksSorted[i].displayedT ?? kicksSorted[i].t; const since = audioT - kt; if (since < 0) continue; - if (lastKickIdx < 0) { lastKickIdx = i; lastKickT = kt; } - if (since > 0.9) break; - const e01 = since < 0.07 ? since / 0.07 : Math.exp(-(since - 0.07) / 0.34); + if (since > 1.0) break; + const e01 = since < 0.14 ? since / 0.14 : Math.exp(-(since - 0.14) / 0.34); if (e01 > kickZoom) kickZoom = e01; } - // BEAT-REACTIVE camera: a slow ken-burns breath + a deeper kick zoom - // PLUS the verlet string's signed-spring scene bump (cover-engine - // sceneOffset() — already low-passed so it follows the bend without - // the historical per-frame jerk; it returns a zoomMul we fold in so - // the picture punches WITH the lanes/string, mask + lanes stay - // registered because there is no ctx-transform). kickZoom deepened - // 0.045 → 0.085 so the in/out pump clearly reads. + // Slower kick-activity envelope — drives the face MAGNET on the aim. + // 200 ms attack + ~0.6 s release, so the camera glides toward the + // face center when kicks are active and drifts back to the slow orbit + // when they stop. No snap at either edge. + let kickAct = 0; + for (let i = kicksSorted.length - 1; i >= 0; i--) { + const kt = kicksSorted[i].displayedT ?? kicksSorted[i].t; + const since = audioT - kt; + if (since < 0) continue; + if (since > 1.5) break; + const e01 = since < 0.20 ? since / 0.20 : Math.exp(-(since - 0.20) / 0.60); + if (e01 > kickAct) kickAct = e01; + } + // BEAT-REACTIVE camera: a slow ken-burns breath + a smoothed kick + // zoom PLUS the verlet string's signed-spring scene bump (cover- + // engine sceneOffset() — already low-passed so it follows the bend + // without per-frame jerk). kickZoom amp bumped back up to 0.10 so the + // in-pump on the face reads, but the 140 ms attack keeps it from + // snapping. stringPan dampened (* 0.45) so dense-kick sections + // (break1, drops) don't shake the scene — the bend is felt through + // the zoomMul instead. const _so = _vs.sceneOffset(); - const zoom = (baseScale + slowBreath + kickZoom * 0.085) * _so.zoomMul; - const stringPanX = _so.dx; - const stringPanY = _so.dy; - void lastKickT; + const zoom = (baseScale + slowBreath + kickZoom * 0.10) * _so.zoomMul; + const stringPanX = _so.dx * 0.25; + const stringPanY = _so.dy * 0.25; 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. - // Gentle, SLOW drift only — no envelope-coupled high-freq jitter - // (that read as "moves too fast / not smooth"). Organic + calm. - const wobbleX = 9 * Math.sin(t * 0.55) + 4 * Math.cos(t * 0.9); - const wobbleY = 7 * Math.cos(t * 0.5) + 3 * Math.sin(t * 0.8); - // 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. + // the painting reads as alive rather than dead-still. AMPLITUDES + // pulled to ~40% of the prior values: a faint living drift only, + // not the wandering pan it was reading as. + const wobbleX = 3.6 * Math.sin(t * 0.55) + 1.6 * Math.cos(t * 0.9); + const wobbleY = 2.8 * Math.cos(t * 0.5) + 1.2 * Math.sin(t * 0.8); + // Continuous slow orbit around the face center, with a smooth FACE + // MAGNET driven by kickAct: when kicks are firing the aim glides + // toward the face center (camera tightens on the face); when they + // stop, it drifts back to the orbit. Both signals are continuous — + // no kick-coupled snap, no per-beat velocity reversals. 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; - } - } + const focusFx = faceRect ? faceRect.x + faceRect.w * 0.5 : imgW / 2; + const focusFy = faceRect ? faceRect.y + faceRect.h * 0.5 : imgH / 2; + const orbitR = Math.min(imgW, imgH) * 0.020; + const orbitFx = focusFx + Math.cos(audioT * 0.18) * orbitR; + const orbitFy = focusFy + Math.sin(audioT * 0.12) * orbitR * 0.7; + const magnet = kickAct * 0.65; + const aimFx = orbitFx + (focusFx - orbitFx) * magnet; + const aimFy = orbitFy + (focusFy - orbitFy) * magnet; // Translate aim point to a draw offset that puts the aim under // the canvas center. const scaledFx = aimFx * (baseW / imgW); @@ -1723,8 +1709,10 @@ function drawFrame(t) { 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; + // PreMelt-only swell — env-driven constant slit removed (it was + // adding a faint per-row ripple every frame that read as a shaky + // illustration). The pre-drop splash is preserved. + const slitAmp = 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); @@ -2143,11 +2131,23 @@ function drawFrame(t) { // PLUCK the verlet string the single frame this event crosses // the playhead — a horizontal impulse at the event's lane y. + // SKIP plucking on dense bed-style SFX (gallop, wave, amb-grain + // fire at multi-Hz rates and would make the scene shimmy along + // with them via sceneOffset). Dramatic one-shot SFX (sniper, + // thunder, neigh, chill-drop, etc.) still pluck — the bed events + // are visible on the timeline regardless. 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)); + const isBedSfx = lane.key === "sfx" && + (ev.name === "gallop" || ev.name === "wave" || ev.name === "amb-grain"); + if (!isBedSfx) { + const yc = laneCenterY[lane.key] ?? (SCORE_TOP + SCORE_H / 2); + const sign = ((Math.floor(evT * 97) + li) & 1) ? 1 : -1; + // Pluck amplitude dampened further — the line + scene still + // react to beats but very quietly, keeping the illustration + // mostly still even in dense sections. + const amount = 16 + 26 * Math.min(1, env + 0.2); + pluckNeedle(yc, amount, sign, ncHexToRgb(lane.color)); + } } ctx.globalAlpha = baseAlpha; diff --git a/pop/dance/bin/finalize-penta-vocal.mjs b/pop/dance/bin/finalize-penta-vocal.mjs index c4be39eac..7f47f6c08 100644 --- a/pop/dance/bin/finalize-penta-vocal.mjs +++ b/pop/dance/bin/finalize-penta-vocal.mjs @@ -62,18 +62,44 @@ const voxChain = `equalizer=f=9000:t=q:w=1.0:g=1.5,` + `volume=${VOX_DB}dB`; -// Master chain — IDENTICAL to trancepenta.md stage 3, applied to the -// bed+vocal sum. amix normalize=0 so the bed keeps its own level and -// the (already-attenuated) vocal just adds a quiet layer; loudnorm then -// brings the SUM to -14 LUFS so the published track is still -// Spotify-clean with the counterpoint baked in. +// Master chain — RADIO-LEANING mix bus, applied to the bed+vocal sum. +// · highpass at 28 Hz — strips sub-rumble that FM/small speakers +// can't reproduce and that just wastes headroom. +// · acompressor (-16 / 3.0:1 / 8 dB knee) — heavier glue compression +// than the album chain (was -19 / 2.4:1) so all the multi-track +// elements sit in one cohesive level instead of each peaking past +// each other. "Brings everything into view." +// · 4-band corrective EQ — tame low-mud at 120 Hz, scoop 250 Hz for +// clarity, bump 3.5 kHz for radio presence, lift 11 kHz for air. +// · loudnorm I=-14 LRA=6 — Spotify-compatible LUFS but a much tighter +// dynamic range than the album chain (was LRA=11) so the track +// stays loud + present through the whole arrangement. +// · alimiter 0.94 / fast (5 ms attack / 80 ms release) — broadcast- +// style ceiling that catches any remaining transients tightly. +// Reverb-duck envelope around the audio stamp (~93-101 s). Drops the +// wet-reverb path from 1.0 down to 0.02 (≈ −34 dB) during the stamp +// window so the room "sucks dry" before the stamp lands, then ramps +// back to full so the post-stamp build returns into space. Ramps: +// 91 → 92 s hard suck (1.0 → 0.02), 101 → 103 s smooth recover. +const stampDuck = + `'if(lt(t,91), 1, if(lt(t,92), 1-0.98*(t-91), if(lt(t,101), 0.02, if(lt(t,103), 0.02+0.49*(t-101), 1))))'`; + const filter = `[1:a]${voxChain}[vox];` + `[0:a][vox]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mix];` + - `[mix]acompressor=threshold=-19dB:ratio=2.4:attack=15:release=240:makeup=1:knee=6,` + - `equalizer=f=3000:t=q:w=1.2:g=2,highshelf=f=10000:g=1.5,` + - `loudnorm=I=-14:TP=-1.5:LRA=11,` + - `alimiter=limit=0.94:attack=8:release=120:level=disabled,` + + // TRANCE-SPACE master chain — dry path + reverb-tail path summed, + // where the reverb tail is GATED by the stamp-duck envelope. + `[mix]highpass=f=28,asplit=2[dry][toverb];` + + `[toverb]aecho=0.85:0.45:80|170|340|620:0.18|0.13|0.08|0.05,` + + `volume=${stampDuck}:eval=frame[wetGated];` + + `[dry][wetGated]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[spaced];` + + `[spaced]acompressor=threshold=-16dB:ratio=3.0:attack=10:release=150:makeup=1.5:knee=8,` + + `equalizer=f=120:t=q:w=1.2:g=-1,` + + `equalizer=f=250:t=q:w=1.6:g=-1.2,` + + `equalizer=f=3500:t=q:w=1.4:g=2.5,` + + `highshelf=f=11000:g=1.8,` + + `loudnorm=I=-14:TP=-1.5:LRA=6,` + + `alimiter=limit=0.94:attack=5:release=80:level=disabled,` + `afade=t=out:st=${fadeSt}:d=18[out]`; const r = spawnSync("ffmpeg", [ diff --git a/pop/dance/bin/gen-help-series-prompts.mjs b/pop/dance/bin/gen-help-series-prompts.mjs index 3e86da49a..8ed20ea4b 100644 --- a/pop/dance/bin/gen-help-series-prompts.mjs +++ b/pop/dance/bin/gen-help-series-prompts.mjs @@ -40,7 +40,7 @@ const JEFFREY = `JEFFREY (recognizable from the jeffrey reference photographs — medium-length brown hair, beard, his real face), REAL person, photoreal skin and hair: calm, patient, quietly noble in service — the steady look of someone glad to help, faint closed-mouth half-smile, eyes attentive on the laptop, easy upright posture. He is TEACHING — pointing at the Dell, guiding, explaining, unhurried. He wears his real everyday outfit from the references: a soft pale-blue faint-pinstripe long-sleeve button-down with a small embroidered yellow bear emblem low on the chest, a bright YELLOW PEN clipped at the chest pocket, soft RED plastic glasses on a thin cord around his neck, wide-leg medium-cobalt trousers, worn black canvas low-top sneakers. Do NOT restyle him.`; const ZUCK = -`THE CLERK = a VERY YOUNG MARK ZUCKERBERG, age ~20-21, EXACTLY as in the attached real 2005 reference photos (zuck-2005-*.jpg) — match THAT young man's face precisely: soft round boyish pale face, short slightly-curly light-brown hair, faint awkward closed-ish smile, skinny college-age build. NOT the modern older/jacked Zuckerberg — use the real young reference, not memory of present-day Zuckerberg. His OUTFIT is the real one from that reference: a navy-blue HOODED SWEATSHIRT with a faded collegiate-arch chest print (a university hoodie look — keep the lettering soft and NOT a crisp readable wordmark) over a tee, plain blue jeans. The only clerk touch: a simple Trader Joe's pin-on NAME BADGE and a LANYARD worn over the hoodie (no Hawaiian shirt — he just wears his own real clothes plus the badge). He is THE STUDENT — earnestly, awkwardly learning computer stuff from jeffrey: leaning in at the laptop, brow furrowed, a little out of his depth, shy and deadpan. Never looks at the camera. A peer in the frame, not a supplicant.`; +`THE CLERK = a VERY YOUNG MARK ZUCKERBERG, age ~20-21, EXACTLY as in the attached real reference photos (zuck-2005-*.jpg + zuck-2007-*.jpg) — match THAT young man's face precisely across ALL the refs (use them together — the 2005 Harvard-dorm shot for the outfit + age anchor, the 2007 F8/Hackathon close-ups for the face/hair detail he's known for): soft round boyish pale face with light freckles, short slightly-curly light-brown hair with the cowlick + forehead curl from the refs, faint awkward closed-ish smile with a small overbite, skinny college-age build, the wide-set hazel-blue eyes from the refs. NOT the modern older/jacked Zuckerberg — use the real young references, not memory of present-day Zuckerberg. His OUTFIT is the real one from that reference: a navy-blue HOODED SWEATSHIRT with a faded collegiate-arch chest print (a university hoodie look — keep the lettering soft and NOT a crisp readable wordmark) over a tee, plain blue jeans. The only clerk touch: a simple Trader Joe's pin-on NAME BADGE and a LANYARD worn over the hoodie (no Hawaiian shirt — he just wears his own real clothes plus the badge). He is THE STUDENT — earnestly, awkwardly learning computer stuff from jeffrey: leaning in at the laptop, brow furrowed, a little out of his depth, shy and deadpan. Never looks at the camera. A peer in the frame, not a supplicant.`; const LAPTOP = `THE LAPTOP — there is EXACTLY ONE laptop in the whole picture: MARK'S PALS LAPTOP. A single small ac laptop whose lid shows ONLY the PALS logo (the attached pals-logo.png — exactly TWO Keith-Haring-style hand-drawn outline people side by side, on a mixed-colour back panel), open on the counter, lid angled so the SCREEN IS NOT visible to the camera at all (no screen content ever; PALS logo on the lid only). jeffrey and young Mark are BOTH at this one laptop — jeffrey teaching, Mark learning on it. ABSOLUTELY NO second laptop, NO Dell, NO old beige/silver PC, NO green macbook, NO apple logo, NO whistlegraph doodle, NO stickers, and the pixsies hold NO laptops — one laptop only, Mark's PALS machine.`; diff --git a/pop/dance/bin/gen-illys-penta.sh b/pop/dance/bin/gen-illys-penta.sh new file mode 100755 index 000000000..01dc14a6d --- /dev/null +++ b/pop/dance/bin/gen-illys-penta.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# gen-illys-penta.sh — regenerate the trancepenta after-hours-Trader-Joe's +# set for a given variant tag. 8 portrait IG illys (p input → png at +# 1024x1536). Concurrency 3 (8GB machine cap). Mirrors gen-illys.sh +# (trancenwaltzi) but rooted at trancepenta-sections. +# usage: bash gen-illys-penta.sh [VARIANT] (default v2) +set -u +VAR="${1:-v2}" +REPO="/Users/jas/aesthetic-computer" +SECROOT="$HOME/Documents/Working Desktop/gens/trancepenta-sections" +LOG="$HOME/Documents/Working Desktop/gens/trancepenta-genillys-${VAR}.log" +: > "$LOG" +GEN="$REPO/marketing/bin/gen-promo.mjs" + +jobs=( + "intro|${VAR}|1024x1536" + "break1|${VAR}|1024x1536" + "build1|${VAR}|1024x1536" + "drop1|${VAR}|1024x1536" + "break2|${VAR}|1024x1536" + "build2|${VAR}|1024x1536" + "drop2|${VAR}|1024x1536" + "outro|${VAR}|1024x1536" +) + +run_one() { + local sec="$1" var="$2" size="$3" + echo "▸ START $sec $var $size $(date +%H:%M:%S)" >> "$LOG" + node "$GEN" "$SECROOT/$sec" --variant "$var" --size "$size" --force --no-mirror \ + >> "$LOG" 2>&1 \ + && echo "✓ DONE $sec $var $(date +%H:%M:%S)" >> "$LOG" \ + || echo "✗ FAIL $sec $var (exit $?) $(date +%H:%M:%S)" >> "$LOG" +} + +i=0 +for spec in "${jobs[@]}"; do + IFS='|' read -r sec var size <<< "$spec" + run_one "$sec" "$var" "$size" & + i=$((i+1)) + if (( i % 3 == 0 )); then wait -n 2>/dev/null || wait; fi +done +wait +echo "=== ALL GENS COMPLETE $(date +%H:%M:%S) ===" >> "$LOG" +grep -E '✓ DONE|✗ FAIL' "$LOG" diff --git a/pop/dance/bin/place-penta-harmony.mjs b/pop/dance/bin/place-penta-harmony.mjs new file mode 100644 index 000000000..024b63fe9 --- /dev/null +++ b/pop/dance/bin/place-penta-harmony.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// place-penta-harmony.mjs — chordal/wandering jeffrey vocal bus with +// per-entry treatments that fake phoneme variety from the single hum +// source. Each entry: pitch-shift (semis, speed-preserving), per-entry +// fx treatment (vibrato/tremolo/bitcrush/phaser/reverse/formant), wider +// stereo placement. The result dances with the Odyssey theremin and +// sounds like distinct phoneme calls rather than the same repeated hum. + +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import { existsSync } from "node:fs"; + +const flags = {}; +for (let i = 2; i < process.argv.length; i += 2) { + flags[process.argv[i].replace(/^--/, "")] = process.argv[i + 1]; +} +const expandHome = (p) => p?.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p; +const PHRASE = resolve(process.cwd(), expandHome(flags.phrase)); +const OUT = resolve(process.cwd(), expandHome(flags.out)); +const TOTAL = Number(flags.dur); +const SR = 44100; +if (!existsSync(PHRASE)) { console.error(`✗ phrase missing: ${PHRASE}`); process.exit(1); } + +const probe = spawnSync("ffprobe", [ + "-v","error","-show_entries","format=duration", + "-of","default=noprint_wrappers=1:nokey=1", PHRASE], { encoding: "utf8" }); +const PH = parseFloat(probe.stdout.trim()) || 6.0; + +// Per-entry treatment FX chains. Each adds character to the hum so it +// reads as a distinct phoneme call rather than the same repeated tone. +const TREATMENTS = { + // SMOOTHED treatments — no aggressive volume gating that read as + // "guttural repetition" per @jeffrey. Tremolo dropped to gentle + // pulses only; bitcrush mellowed; vibrato kept since pitch wobble is + // the smooth theremin-y feel jeffrey wants. + // SLOWED + tremolo REMOVED entirely per @jeffrey (guttural rep was + // the volume gating). Each treatment now: light vibrato (1.5-3 Hz) + // + atempo 0.7-0.8 (stretches the hum slower) + the formant/character + // FX. No volume gating anywhere — pure smooth held vowel calls. + vibrato: "atempo=0.75,vibrato=f=2.0:d=0.30", + vibrato_fast: "atempo=0.80,vibrato=f=3.0:d=0.40", + tremolo: "atempo=0.70,vibrato=f=1.8:d=0.25", + tremolo_slow: "atempo=0.75,vibrato=f=1.5:d=0.20", + bitcrush: "atempo=0.75,aresample=14000,aresample=44100,vibrato=f=2.0:d=0.20", + bitcrush_hot: "atempo=0.75,aresample=12000,aresample=44100,vibrato=f=2.5:d=0.25", + phaser: "atempo=0.75,aphaser=in_gain=0.85:out_gain=0.95:delay=3:decay=0.45:speed=0.25", + bright: "atempo=0.75,highpass=f=350,equalizer=f=2400:t=q:w=1.2:g=4,vibrato=f=2.0:d=0.30", + deep: "atempo=0.70,lowpass=f=1200,equalizer=f=400:t=q:w=1.0:g=3,vibrato=f=1.5:d=0.30", + reverse: "atempo=0.75,areverse,vibrato=f=2.0:d=0.25", + mumble: "atempo=0.75,bandpass=f=750:width_type=q:width=0.6,vibrato=f=2.0:d=0.35", + hollow: "atempo=0.70,lowpass=f=1500,aphaser=in_gain=0.7:out_gain=1.0:delay=4:decay=0.5:speed=0.2", +}; + +// 9 entries spread across the song. Each gets a distinct treatment so +// the perceived phonemes vary. BATTLE bars (1:28-1:35) intentionally +// left clear — theremin carries the encounter alone. +// CHILL pass per @jeffrey — vocals were too high + loud. Pitches +// brought down (max +5 was +12, max -7 stays), volumes uniformly +// pulled ~6 dB (× 0.5) so the vocal sits well under the bed. +const entries = [ + { name: "j1-low-tremolo", start: 19.0, semi: -7, len: 7.0, fi: 2.0, fo: 3.0, vol: 0.22, pan: -0.65, treat: "tremolo" }, + { name: "j2-mid-bright", start: 36.0, semi: -2, len: 8.0, fi: 2.5, fo: 3.5, vol: 0.26, pan: 0.55, treat: "bright" }, + { name: "j3-mid-mumble", start: 55.0, semi: 0, len: 7.0, fi: 2.5, fo: 3.5, vol: 0.28, pan: -0.40, treat: "mumble" }, + { name: "j4-low-deep", start: 72.0, semi: -4, len: 9.0, fi: 3.0, fo: 4.0, vol: 0.30, pan: 0.70, treat: "deep" }, + // BATTLE (1:28-1:35) — clear, theremin solo + { name: "j5-mid-phaser", start: 110.0, semi: +2, len: 8.0, fi: 3.0, fo: 4.0, vol: 0.30, pan: -0.80, treat: "phaser" }, + { name: "j6-bitcrunch", start: 128.0, semi: -2, len: 8.0, fi: 3.0, fo: 4.0, vol: 0.26, pan: 0.55, treat: "bitcrush_hot" }, + { name: "j7-low-reverse", start: 145.0, semi: -5, len: 7.5, fi: 3.0, fo: 4.0, vol: 0.26, pan: -0.30, treat: "reverse" }, + { name: "j8-mid-fast", start: 156.0, semi: +5, len: 6.0, fi: 2.5, fo: 3.0, vol: 0.20, pan: 0.75, treat: "vibrato_fast" }, + { name: "j9-hollow-tonic", start: 172.0, semi: -2, len: 12.0, fi: 4.0, fo: 7.0, vol: 0.22, pan: 0.10, treat: "hollow" }, +]; + +const inputs = []; +const parts = []; +let placed = 0; +for (const e of entries) { + const ratio = Math.pow(2, e.semi / 12); + const atempoRatio = 1 / ratio; + const playLen = Math.min(PH, e.len); + const fi = Math.min(e.fi, playLen / 3); + const fo = Math.min(e.fo, playLen / 2); + const delayMs = Math.round(e.start * 1000); + const idx = placed; + const fx = TREATMENTS[e.treat] || TREATMENTS.vibrato; + inputs.push("-i", PHRASE); + parts.push( + `[${idx}:a]` + + `asetrate=${Math.round(SR * ratio)},aresample=${SR},atempo=${atempoRatio.toFixed(6)},` + + `atrim=0:${playLen.toFixed(3)},asetpts=N/SR/TB,` + + `${fx},` + + `pan=stereo|c0=${(1 - e.pan).toFixed(3)}*c0|c1=${(1 + e.pan).toFixed(3)}*c0,` + + `volume=${e.vol.toFixed(3)},` + + `afade=t=in:st=0:d=${fi.toFixed(3)},` + + `afade=t=out:st=${(playLen - fo).toFixed(3)}:d=${fo.toFixed(3)},` + + `adelay=${delayMs}|${delayMs}[e${idx}]` + ); + placed++; +} +if (placed === 0) { console.error("✗ no harmony entries"); process.exit(1); } +const lbls = parts.map((_, i) => `[e${i}]`).join(""); +const filter = + parts.join(";") + + `;${lbls}amix=inputs=${placed}:duration=longest:dropout_transition=0:normalize=0[mix];` + + `[mix]apad=whole_dur=${TOTAL.toFixed(3)},atrim=0:${TOTAL.toFixed(3)},asetpts=N/SR/TB[out]`; + +const r = spawnSync("ffmpeg", [ + "-hide_banner","-y","-loglevel","error", + ...inputs, + "-filter_complex", filter, + "-map","[out]", + "-ar","44100","-ac","2","-c:a","pcm_s16le", + OUT, +], { stdio: ["ignore","inherit","inherit"] }); +if (r.status !== 0) { console.error("✗ harmony bus render failed"); process.exit(1); } + +const p2 = spawnSync("ffprobe", ["-v","error","-show_entries", + "format=duration","-of","default=noprint_wrappers=1:nokey=1", OUT], + { encoding: "utf8" }); +console.log(`✓ ${OUT} (${parseFloat(p2.stdout.trim()).toFixed(2)}s · ${placed} differentiated harmony entries)`); +for (const e of entries) console.log(` ${(e.start.toFixed(1)+"s").padStart(7)} ${(e.semi>=0?"+":"")+e.semi}st pan=${e.pan>0?"+":""}${e.pan.toFixed(2)} ${e.treat}`); diff --git a/pop/dance/bin/scratch-mix.mjs b/pop/dance/bin/scratch-mix.mjs index a394de302..b30fca858 100755 --- a/pop/dance/bin/scratch-mix.mjs +++ b/pop/dance/bin/scratch-mix.mjs @@ -294,12 +294,20 @@ if (structPath) { anchors.sort((a, b) => a - b); } catch {} const tEnd = frames / SR; - const chopFrom = Math.max(0, tEnd - 80); // longer, gentler lead-in - const chopTo = tEnd - 20; // dissipate BEFORE the fade + // Wider scratch window — start earlier so the FX bites drop1 + break2 + // ("get fucked by the scratch more"), then DISSIPATE by ~2:25 so the + // final drop2 climax + tail stay clean (@jeffrey: "get rid of any + // scratch at 2:29"). + const chopFrom = Math.max(0, tEnd - 160); // ~30 s onward (was tEnd-80 = ~110 s) + const chopTo = tEnd - 45; // ~2:25 (was tEnd-20 = ~2:50) const xf = Math.max(4, Math.floor(0.0025 * SR)); for (let ai = 0; ai < anchors.length - 1; ai++) { const tA = anchors[ai]; if (tA < chopFrom || tA >= chopTo) continue; + // Quiet zone around the drop1→break2 transition (84-88 s). Scratch + // bursts here collided with the swing peak + section change and + // read as glitchy per @jeffrey. + if (tA >= 84 && tA <= 88) continue; const prog = (tA - chopFrom) / (chopTo - chopFrom); // SPARING crescendo in, taper out over the section's last 5 s // basic, gradual ramp UP into the glitches (jas: "more basic ramp @@ -330,7 +338,11 @@ if (structPath) { const i0 = Math.floor(rp), fr = rp - i0; const L = c4(sL, i0, fr); const Rr = c4(sR, i0, fr); - const e = Math.min(1, Math.min(k, wN - 1 - k) / Math.max(1, fEdge)); + // Cap the scratch crossfade so the underlying bed STAYS at + // ≥40% — eliminates the perceived "gap" the full replacement + // was creating when the grain happened quieter than the bed. + const eRaw = Math.min(1, Math.min(k, wN - 1 - k) / Math.max(1, fEdge)); + const e = eRaw * 0.60; x[(a0 + k) * 2] = x[(a0 + k) * 2] * (1 - e) + L * e; x[(a0 + k) * 2 + 1] = x[(a0 + k) * 2 + 1] * (1 - e) + Rr * e; rp += r; if (rp > cap - 2) rp = cap - 2; @@ -345,7 +357,11 @@ if (structPath) { let L = sL[Math.min(cap - 1, gi)], Rr = sR[Math.min(cap - 1, gi)]; if (gi < xf) { const f = gi / xf; L *= f; Rr *= f; } else if (gi > gN - xf) { const f = (gN - gi) / xf; L *= f; Rr *= f; } - const e = Math.min(1, Math.min(k, wN - 1 - k) / Math.max(1, fEdge)); + // Cap the scratch crossfade so the underlying bed STAYS at + // ≥40% — eliminates the perceived "gap" the full replacement + // was creating when the grain happened quieter than the bed. + const eRaw = Math.min(1, Math.min(k, wN - 1 - k) / Math.max(1, fEdge)); + const e = eRaw * 0.60; x[(a0 + k) * 2] = x[(a0 + k) * 2] * (1 - e) + L * e; x[(a0 + k) * 2 + 1] = x[(a0 + k) * 2 + 1] * (1 - e) + Rr * e; } @@ -392,11 +408,12 @@ if (structPath) { } } -// LAST-30 s — MODERATE crush (jas: "should not let it get THAT bad"), -// some grains dropped to much LOWER keys + a HARMONY + extra -// resamplings sprinkled around, then the final ~6 s rises into a -// THICK, SMOOTH, WAVY digital wall of noise. -{ +// LAST-30 s gnarly tail (bit-crush + dropped harmonies + noise wall) +// DISABLED per @jeffrey 2026-05-20 — was reading as skippy percussion +// in the 2:40-2:50 window. Outro now relies on the SECTION_TEMPLATE_5 +// outro (perc off, just pad + bells) + the master 18s fade for a +// clean tail. Wrapped in `if (false)` so the code stays inspectable. +if (false) { const tEnd = frames / SR; const t0 = tEnd - 30; const d0 = Math.max(0, Math.floor(t0 * SR)); diff --git a/recap/bin/trance.mjs b/recap/bin/trance.mjs index 3d6b4bdc0..7323b4887 100644 --- a/recap/bin/trance.mjs +++ b/recap/bin/trance.mjs @@ -368,7 +368,7 @@ const SEED_STR = flags.seed || (isWaltz ? "trancewaltz" : "trance"); // Chill: PERCUSSION-FORWARD, tones quieter — a harder NIN/industrial // balance (jas: "more percussion forward mix ... tones all quieter ... // more NIN friendly industrial"). Drums up, lead/pad well down. -const DRUM_GAIN = Number(flags["drum-gain"] ?? (isChill ? 1.28 : 0.95)); // perc down a bit — better overall mix (jas) +const DRUM_GAIN = Number(flags["drum-gain"] ?? (isChill ? 0.92 : 0.95)); // perc lower in the mix (was 1.28, jeffrey: ~3 dB down) // Hellsine "hardcorism" — opt-in per track (trancepenta sets --hell; // trancenwaltzi leaves it 0 so its kick is unchanged). >0 swaps the // chill kick for the ported gabber/Rotterdam kick. @@ -398,7 +398,7 @@ const LEAD_GAIN = Number(flags["lead-gain"] ?? (isChill ? 0.24 : 0.55)); // me const PAD_GAIN = Number(flags["pad-gain"] ?? (isChill ? 0.12 : 0.30)); // chill: lower the "organ" pad (jas) — poppier const BASS_GAIN = Number(flags["bass-gain"] ?? (isChill ? 0.42 : 0.45)); // thwomp stays forward with the drums const SIDECHAIN = flags.sidechain !== "off"; -const DUCK_DEPTH = Number(flags["duck-depth"] ?? 0.65); +const DUCK_DEPTH = Number(flags["duck-depth"] ?? 0.40); // was 0.65 — too heavy, masking the supersaw under every kick const DUCK_MS = Number(flags["duck-ms"] ?? 120); const VOCAL_STEM = expandHome(flags["vocal-stem"]) || null; const VOCAL_GAIN = Number(flags["vocal-gain"] ?? 2.9); @@ -624,8 +624,25 @@ const SECTION_TEMPLATES_3_CHILL = { drop2: { bars: 24, kick: true, hat: true, sub: true, lead: true, pad: true, piano: true, bells: true, riser: false, snareRoll: false, supersaw: true, vocal: false, ducked: true, drumDensity: 0.90 }, outro: { bars: 9, kick: true, hat: true, sub: false, lead: false, pad: true, piano: false, bells: true, riser: false, snareRoll: false, supersaw: false, vocal: false, ducked: true, drumDensity: 0.40 }, }; +// trancepenta (chill + meter 5 dorian) — early-drop variant. +// Shorter intro/break/build so the first drop lands within the first +// 30 s of the track (@jeffrey, 2026-05-20). Total bars preserved (80) +// so the song still runs ~190 s — drop1 + drop2 each absorb the time +// shaved off the run-up. +const SECTION_TEMPLATES_5_CHILL = { + intro: { bars: 4, kick: false, hat: true, sub: false, lead: false, pad: true, piano: false, bells: true, riser: false, snareRoll: false, supersaw: false, vocal: false, ducked: false, drumDensity: 0.30 }, + break1: { bars: 4, kick: true, hat: true, sub: false, lead: true, pad: true, piano: false, bells: true, riser: false, snareRoll: false, supersaw: true, vocal: false, ducked: true, drumDensity: 0.50 }, + build1: { bars: 4, kick: true, hat: true, sub: true, lead: true, pad: true, piano: false, bells: false, riser: true, snareRoll: true, supersaw: true, vocal: false, ducked: true, drumDensity: 0.75, fx: { bitcrush: { bits: [{ time: 0, bits: 16 }, { time: 1, bits: 7 }], downsample: [{ time: 0, downsample: 1 }, { time: 1, downsample: 3 }], mix: 0.5 } } }, + drop1: { bars: 24, kick: true, hat: true, sub: true, lead: true, pad: true, piano: true, bells: true, riser: false, snareRoll: false, supersaw: true, vocal: false, ducked: true, drumDensity: 0.92, fx: { bitcrush: { bits: [{ time: 0, bits: 14 }, { time: 0.5, bits: 10 }, { time: 1, bits: 14 }], downsample: [{ time: 0, downsample: 1 }, { time: 0.5, downsample: 2 }, { time: 1, downsample: 1 }], mix: 0.25 } } }, + break2: { bars: 4, kick: true, hat: true, sub: false, lead: true, pad: true, piano: false, bells: true, riser: false, snareRoll: false, supersaw: false, vocal: false, ducked: true, drumDensity: 0.55, fx: { wobble: { target: "filter", baseCutoffHz: 1800, rate: 1.8, depth: 0.4 } } }, + build2: { bars: 4, kick: true, hat: true, sub: true, lead: false, pad: true, piano: false, bells: false, riser: true, snareRoll: true, supersaw: true, vocal: false, ducked: true, drumDensity: 1, fx: { bitcrush: { bits: [{ time: 0, bits: 14 }, { time: 1, bits: 5 }], downsample: [{ time: 0, downsample: 1 }, { time: 1, downsample: 5 }], mix: 0.7 } } }, + drop2: { bars: 28, kick: true, hat: true, sub: true, lead: true, pad: true, piano: true, bells: true, riser: false, snareRoll: false, supersaw: true, vocal: false, ducked: true, drumDensity: 1.0, fx: { bitcrush: { bits: [{ time: 0, bits: 12 }, { time: 0.4, bits: 8 }, { time: 0.7, bits: 12 }, { time: 1, bits: 14 }], downsample: [{ time: 0, downsample: 1 }, { time: 0.4, downsample: 3 }, { time: 0.7, downsample: 1 }, { time: 1, downsample: 1 }], mix: 0.30 } } }, + outro: { bars: 8, kick: false, hat: false, sub: false, lead: false, pad: true, piano: false, bells: true, riser: false, snareRoll: false, supersaw: false, vocal: false, ducked: false, drumDensity: 0 }, +}; const SECTION_TEMPLATES = isChill && isWaltz ? SECTION_TEMPLATES_3_CHILL + : (isChill && !isWaltz) + ? SECTION_TEMPLATES_5_CHILL : isWaltz ? SECTION_TEMPLATES_3 : SECTION_TEMPLATES_4; let SECTIONS; @@ -892,6 +909,52 @@ function fireWhistle(target, startSec, midi, durSec, gain = 1.0) { // circles the stereo field. Used in the chill mix instead of the // sustained supersaw wall — a small triad of these zip around like // annoying-but-musical flies. +// fireTheremin — long sustained theremin voice for the Odyssey melody. +// Stable envelope (fast attack + held sustain + slow release), slow +// vibrato, optional pitch glide to the NEXT note in the last 22% of the +// duration, modest stereo width via two detuned saws + a centred sine +// layer. Designed for held melody notes (1-8 bars) — unlike fireMosquito +// which uses a half-sine envelope tuned for short chirps. +function fireTheremin(target, startSec, midi, durSec, gain = 1.0, nextMidi = null) { + const segDur = 0.04; + const segments = Math.max(8, Math.floor(durSec / segDur)); + const vibHz = 3.3; // slower vibrato per @jeffrey (was 5.5 — too fast) + const glideStart = nextMidi !== null ? Math.floor(segments * 0.78) : segments; + const attackSec = 0.10; + const releaseSec = 0.25; + for (let i = 0; i < segments; i++) { + const sf = i / segments; + const elapsed = sf * durSec; + let env; + if (elapsed < attackSec) env = elapsed / attackSec; + else if (elapsed > durSec - releaseSec) env = Math.max(0, (durSec - elapsed) / releaseSec); + else env = 1; + const vib = Math.sin(elapsed * 2 * Math.PI * vibHz) * 0.018; // ~1.8% pitch wobble + let curMidi = midi; + if (i >= glideStart && nextMidi !== null) { + const u = (i - glideStart) / Math.max(1, segments - glideStart); + const smooth = u * u * (3 - 2 * u); + curMidi = midi + (nextMidi - midi) * smooth; + } + const tone = 440 * Math.pow(2, (curMidi - 69) / 12) * (1 + vib); + // Harmonic-bloom envelope — 2nd + 3rd harmonics swell in during the + // middle of the note ("more harmonics at times"). Hann-like window + // centred at note middle so the timbre opens up + closes back to + // pure tone at the edges. + const hMid = 4 * sf * (1 - sf); // 0 at sf=0/1, peak 1.0 at sf=0.5 + const harmGain = Math.max(0, hMid - 0.15) / 0.85; // gates softer edges + const v = makeBufferSynth(target, startSec + elapsed, SAMPLE_RATE, noiseRng); + // Fundamental — two detuned saws + a sine for theremin warmth. + v.synth({ type: "sawtooth", tone, duration: segDur + 0.02, volume: gain * 0.70 * env, attack: 0.003, decay: segDur * 0.95, pan: -0.18 }); + v.synth({ type: "sawtooth", tone: tone * 1.006, duration: segDur + 0.02, volume: gain * 0.45 * env, attack: 0.003, decay: segDur * 0.95, pan: 0.18 }); + v.synth({ type: "sine", tone, duration: segDur + 0.02, volume: gain * 0.50 * env, attack: 0.003, decay: segDur * 0.95, pan: 0 }); + // 2nd harmonic (octave up) blooming in the middle of the note. + v.synth({ type: "sine", tone: tone * 2, duration: segDur + 0.02, volume: gain * 0.28 * env * harmGain, attack: 0.003, decay: segDur * 0.95, pan: -0.10 }); + // 3rd harmonic (octave + 5th, perfect twelfth) for ring/sparkle. + v.synth({ type: "sine", tone: tone * 3, duration: segDur + 0.02, volume: gain * 0.18 * env * harmGain, attack: 0.003, decay: segDur * 0.95, pan: 0.10 }); + } +} + function fireMosquito(target, startSec, midi, durSec, gain = 1.0, panBase = 0) { const baseFreq = 440 * Math.pow(2, (midi - 69) / 12); const segDur = 0.05; @@ -1386,14 +1449,15 @@ function fireChillKick(target, startSec, chordDeg, idx, gain, wall = 1) { // + a bright ~4 ms HF sine SNAP at the attack. Ported from // pop/hellsine/bin/hellsine.mjs (jas: "i loooove the kick in // hellsine"). Opt-in via --hell; writes mono into the bus. -function fireGabberKick(target, startSec, drive, gain = 1, tight = 0) { +function fireGabberKick(target, startSec, drive, gain = 1, tight = 0, pitchMul = 1) { const SR = SAMPLE_RATE; // `tight` 0→1: a smaller, shorter, less-saturated "lil kick" (jas: // "our kick should be tighter right at the start first 24 seconds"). - const dur = 0.26 * (1 - 0.58 * tight); + // `pitchMul` < 1 gives a deeper "tok" alternation (tik/tok pattern). + const dur = 0.26 * (1 - 0.58 * tight) * (pitchMul < 1 ? 1.20 : 1); // tok rings longer drive *= 1 - 0.45 * tight; - gain *= 1 - 0.30 * tight; - const pStart = 240, pEnd = 47, pT = 0.034 * (1 - 0.4 * tight); + gain *= (1 - 0.30 * tight) * (pitchMul < 1 ? 1.15 : 1); // tok pronounced + const pStart = 240 * pitchMul, pEnd = 47 * pitchMul, pT = 0.034 * (1 - 0.4 * tight) * (pitchMul < 1 ? 1.25 : 1); const i0 = Math.floor(startSec * SR); const nN = Math.floor(dur * SR); let ph = 0; @@ -1414,6 +1478,71 @@ function fireGabberKick(target, startSec, drive, gain = 1, tight = 0) { } } +// Stick CLICK — single wooden tap, ~12 ms. `toneHz` lets callers pitch +// it anywhere across a wide range for tumbling/scattered dice character. +function fireClick(target, startSec, gain = 1.0, toneHz = 4400) { + const SR = SAMPLE_RATE; + const dur = 0.012; + const nN = Math.floor(dur * SR); + const i0 = Math.floor(startSec * SR); + // Lower clicks ring slightly longer (woodier). + const decayT = 0.0030 + Math.max(0, (4400 - toneHz) / 4400) * 0.0035; + for (let k = 0; k < nN; k++) { + const idx = i0 + k; + if (idx < 0) continue; + if (idx >= target.length) break; + const t = k / SR; + const sine = Math.sin(2 * Math.PI * toneHz * t) * Math.exp(-t / decayT); + const n = (noiseRng() * 2 - 1) * Math.exp(-t / 0.005); + target[idx] += (sine * 0.55 + n * 0.28) * gain; + } +} + +// Dice-roll CLICK-CLACK — a cluster of 5-9 wooden taps with WIDELY +// pitched-wandering tones (1400-5400 Hz random per click), spread out +// over a longer window with bigger irregular gaps so the cluster reads +// as dice tumbling across wood — pitching DOWN and UP, scattered, NOT +// a regular drum-rest tick (per @jeffrey). +function fireDiceRoll(target, startSec, gain = 1.0) { + const nClicks = 5 + Math.floor(noiseRng() * 5); // 5-9 clicks (was 3-6) + let t = startSec; + for (let i = 0; i < nClicks; i++) { + // Random pitch each click — wide range, no fixed alternation. + const toneHz = 1400 + noiseRng() * 4000; // 1.4 kHz – 5.4 kHz + const v = gain * (0.55 + noiseRng() * 0.65); // tumbling unevenness + fireClick(target, t, v, toneHz); + // Bigger gaps — sometimes tight chatter, sometimes a pause that + // makes the next click feel like a die landing flat. + t += 0.030 + noiseRng() * 0.110; // 30-140 ms gaps (was 22-62) + } +} + +// Traditional hand-CLAP — four staggered noise bursts (the multi-hand +// "shh-clap" effect), high-passed band, very short decay. Subtle +// trance-backbeat punctuation per @jeffrey. +function fireClap(target, startSec, gain = 1.0) { + const SR = SAMPLE_RATE; + const offsets = [0, 0.004, 0.009, 0.015]; + const dur = 0.085; + const nN = Math.floor(dur * SR); + for (const off of offsets) { + const i0 = Math.floor((startSec + off) * SR); + let lp1 = 0, lp2 = 0; + for (let k = 0; k < nN; k++) { + const idx = i0 + k; + if (idx < 0) continue; + if (idx >= target.length) break; + const t = k / SR; + const n = (noiseRng() * 2 - 1); + lp1 += 0.45 * (n - lp1); // 1-pole LP ≈ 3 kHz roll-off + lp2 += 0.10 * (lp1 - lp2); // very slow LP for HP-via-subtract + const hp = lp1 - lp2; // HP'd noise ≈ 800 Hz upward + const env = Math.exp(-t / 0.018) * (1 - Math.exp(-t / 0.0008)); + target[idx] += hp * env * gain * 0.55; + } + } +} + // "Bunny bow" — the comp glue. One BOWED, BRUSHED, sustained note: a // slow swell (soft attack → crescendo → release) so consecutive notes // OVERLAP/legato, warm timbre (triangle + soft saw bite + body), a @@ -1646,7 +1775,17 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // barDurSec) rather than an off-beat delay — pushing off-beats late // fought the "faster dance track" goal. swingSec stays 0 so the // existing `+ swingSec` call sites are harmless no-ops. - const swingSec = 0; + // EXTREME SWING ENVELOPE — crescendos to the stamp bar (39, ~1:33) + // then settles back to straight rhythm for the last half (after bar + // 51). Bell-shaped via raised-cosine. Peak swing pushes off-beat + // events ~85 ms late — distinctly perceived as a drunken/loose feel + // around the vortex, then tightens up so drop2 rides on a regular + // grid. Per @jeffrey. + const STAMP_BAR = 39, SWING_WIDTH_BARS = 12, SWING_MAX_SEC = 0.085; + const _sd = 1 - Math.abs(bar - STAMP_BAR) / SWING_WIDTH_BARS; + const _swingFr = Math.max(0, _sd); + const _swingHump = 0.5 - 0.5 * Math.cos(_swingFr * Math.PI); // smooth bell 0→1→0 + const swingSec = SWING_MAX_SEC * _swingHump; // Percussion-silent zones (chill): the first 8 s, the last 8 s, and a // mid bridge (~±7 s around the midpoint) have NO kick/hat — then they @@ -1659,7 +1798,13 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // even SLOWER hello back out, and a slow goodbye into the ending. const _ru = (x, a, b) => Math.max(0, Math.min(1, (x - a) / (b - a))); let _pe = 1; - _pe *= _ru(_mt, 38, 56); // SPARSE first ~40 s, then perc ramps in + // Perc-envelope ramp: chill+meter-5 trancepenta has the early-drop + // structure (drop1 at ~29 s), so hats/perc ramp in during break1+ + // build1 (10→26 s). Chill+waltz (trancenwaltz) keeps the original + // 38→56 slow-open. Other modes don't reach this branch. + const PE_RAMP_A = (isChill && !isWaltz) ? 10 : 38; + const PE_RAMP_B = (isChill && !isWaltz) ? 26 : 56; + _pe *= _ru(_mt, PE_RAMP_A, PE_RAMP_B); // SPARSE early, then perc ramps in _pe *= 1 - _ru(barStart, T - 14, T - 2); // slow goodbye into the end (~12 s) // Global arrangement build: ~0 for the sparse first 40 s, then a // fast ramp to full by ~58 s. Scales the DENSE layers (arp, mosquito, @@ -1813,9 +1958,16 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // Hellsine gabber kick (jas: "i loooove the kick in // hellsine"). First 24 s (music-relative): a TIGHT "lil // kick", then it opens to the full hellsine kick. + // TIK/TOK — alternate every other downbeat kick to a deeper + // pitch (pitchMul 0.55) so the kick reads as a tik-tok + // rocking pulse instead of an unvarying thud (@jeffrey). const tight = (kickT - OPENING_PREFIX_SEC) < 24 ? 1 : 0; - fireGabberKick(dryBuf, kickT, HELL, kg * 0.85, tight); - if (pushIn) fireGabberKick(dryBuf, pushT, HELL, kg * 0.50, tight); + const isTok = (kickCount % 2) === 1; + const mainPitch = isTok ? 0.55 : 1.0; + fireGabberKick(dryBuf, kickT, HELL, kg * 0.85, tight, mainPitch); + // Push kick gets the OPPOSITE pitch — keeps the tik/tok + // call-and-response alive across the syncopated off-beat. + if (pushIn) fireGabberKick(dryBuf, pushT, HELL, kg * 0.50, tight, isTok ? 1.0 : 0.55); } else { fireChillKick(dryBuf, kickT, chordDeg, kickCount, kg, kickWall); // SYNCOPATED off-beat kick on the "& of 2" — forward DANCE @@ -1903,7 +2055,7 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // Chill: hats arrive LATE and FROM DISTANT — nothing before // HAT_ENTER, then volume swells up from far away over ~18 s. const _hmt = barStart - OPENING_PREFIX_SEC; - const HAT_ENTER = 30; // AFTER the 24s bass drop — bass comes in before hats + const HAT_ENTER = 10; // Match the early-drop structure — hats arrive at the start of break1 (was 30, pre-restructure) const hatsArrived = !isChill || _hmt >= HAT_ENTER; // Ease the entrance: a SHORT raised-cosine fade-in over ~7 s after // HAT_ENTER so they don't slam in (jas: "hats come in too @@ -1926,7 +2078,7 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // "i want it consistent ... simple dance track"). Non-chill keeps // the organic skip. if (!isChill && rng() < 0.20 * settleVar) continue; - const v = isChill ? 0.34 : 0.32 + rng() * 0.10; // chill: steady level + const v = isChill ? 0.20 : 0.32 + rng() * 0.10; // chill: hat sits ~6 dB under kick (radio-balance, was 0.34 → too forward) // Humanize hats — ±8 ms feels like a real drummer. const hatT = humanize(startSec, 8 * settleVar + 1); // Chill: per-hit random pitchFactor scatters the noise band so no @@ -2117,52 +2269,36 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // the sine-only voices can't carry alone. if (t.supersaw && instrumentEnabled("supersaw", bar)) { if (isChill) { - // Chill: no trance wall. A triad of wandering sawtooth "mosquito" - // drone-birds in a high whiny register that zip around the stereo - // field and come/go — each voice sits out ~45 % of bars so they - // circle in and out instead of droning continuously. - // Slowly change MELODICALLY across the track, and DEEPEN + DARKEN: - // start high/whiny (+24) and descend toward +7 by the end, with a - // slow melodic walk that shifts every ~14 bars. - const driftFr = bar / Math.max(1, TOTAL_BARS - 1); - const octShift = Math.round(24 - 17 * driftFr); // 24 → 7 - const melWalk = [0, 3, 5, 3, -2, 0, 4, 2][Math.floor(bar / 14) % 8]; - const mGain = 0.085 * (1 - 0.4 * driftFr) * chillBuild; // sparse open → ramps in - let vi = 0; - for (const m of triad) { - vi++; - if (noiseRng() < 0.45) continue; // this fly sits this bar out - const startOff = noiseRng() * barSec * 0.5; // enters at a random point - const dur = barSec * (0.5 + noiseRng() * 0.5); - const panBase = vi === 1 ? -0.5 : vi === 2 ? 0.0 : 0.5; - const mm = m + octShift + melWalk; // descends + walks melodically - fireMosquito(sawBuf, barStart + startOff, mm, dur, mGain, panBase); - events.supersaw.push({ t: barStart + startOff, midi: mm, dur }); - supersawCount++; - } + // Chill mosquito DISABLED — replaced by the Odyssey-arc theremin + // that runs unconditionally across the whole track (see the + // ODYSSEY_THEREMIN block AFTER this conditional, outside the + // t.supersaw gate, so the melody carries through intro / break2 / + // outro too). } else { - // High supersaw — pulled back from 0.20 to 0.13 (it was masking - // the imessage ding + lion roar + melodic gunfire on the drops). + // High supersaw — pulled back to chill levels. Was pushed to 0.50 + // (+12dB) for "trance wall" but felt too loud against the chill + // bed per @jeffrey. Settled at 0.32 (+8 dB from original 0.13). for (const m of triad) { mixEventSupersaw( - { startSec: barStart, midi: m + 12, gain: 0.13, durSec: barSec * 0.98 }, + { startSec: barStart, midi: m + 12, gain: 0.32, durSec: barSec }, sawBuf, { preset: "pad", sampleRate: SAMPLE_RATE, seed: `saw:${bar}:${m}` } ); - events.supersaw.push({ t: barStart, midi: m + 12, dur: barSec * 0.98 }); + events.supersaw.push({ t: barStart, midi: m + 12, dur: barSec }); supersawCount++; } - // Super-low harsh supersaw — root + 5th, also reduced. + // Super-low harsh supersaw — root + 5th. Pulled back from 0.42/0.30 + // to 0.26/0.18 — still present, less heavy. if (sec.startsWith("drop") || sec.startsWith("build")) { const rootDeg = chordDeg; const fifthDeg = chordDeg + 4; mixEventSupersaw( - { startSec: barStart, midi: scaleNoteMidi(rootDeg, -1), gain: 0.10, durSec: barSec * 0.98 }, + { startSec: barStart, midi: scaleNoteMidi(rootDeg, -1), gain: 0.26, durSec: barSec }, sawBuf, { preset: "lead", detuneCents: 32, voices: 7, sampleRate: SAMPLE_RATE, seed: `saw-low:${bar}:r` } ); mixEventSupersaw( - { startSec: barStart, midi: scaleNoteMidi(fifthDeg, -1), gain: 0.07, durSec: barSec * 0.98 }, + { startSec: barStart, midi: scaleNoteMidi(fifthDeg, -1), gain: 0.18, durSec: barSec }, sawBuf, { preset: "lead", detuneCents: 32, voices: 7, sampleRate: SAMPLE_RATE, seed: `saw-low:${bar}:5` } ); @@ -2171,6 +2307,84 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { } } + // ── ODYSSEY THEREMIN ───────────────────────────────────────────── + // Single voice melodic line carrying a Homer-style arc across the + // whole track. Three acts: + // JOURNEY OUT bars 0-35 : gentle dorian ascent, hopeful sail + // BATTLE bars 36-43 : fast wide intervals around the 1:35 + // audio stamp — the encounter + // RETURN HOME bars 44-71 : descending peace, settled + // OUTRO bars 72-79 : sustained tonic, resolution + // Runs UNCONDITIONALLY (outside the t.supersaw gate) so the melody + // carries through intro / break2 / outro sections where supersaw is + // off in the section template. Uses fireMosquito as the voice (its + // slow pan + drift gives a theremin-ish wander) but with a single + // call per held note instead of a 3-voice ensemble. + { + const ODYSSEY = [ + // [midi, holdBars, vel] JOURNEY OUT (sum = 36 bars) + [62, 4, 0.55], [65, 4, 0.60], [67, 4, 0.65], [69, 4, 0.70], + [72, 2, 0.65], [74, 2, 0.70], [72, 4, 0.65], [74, 4, 0.75], + [77, 4, 0.80], [76, 2, 0.75], [74, 2, 0.70], + // BATTLE (sum = 8 bars, 1 bar each — agitated) + [81, 1, 0.92], [79, 1, 0.88], [82, 1, 0.95], [86, 1, 1.00], + [83, 1, 0.95], [80, 1, 0.88], [77, 1, 0.82], [74, 1, 0.78], + // RETURN HOME (sum = 28 bars — descending) + [74, 4, 0.65], [72, 4, 0.60], [69, 4, 0.58], [67, 4, 0.55], + [65, 2, 0.52], [69, 2, 0.55], [67, 4, 0.50], [65, 4, 0.45], + // OUTRO (sum = 8 bars — sustained tonic resolution) + [62, 8, 0.40], + ]; + let acc = 0; + for (let mi = 0; mi < ODYSSEY.length; mi++) { + const [midi, hold, vel] = ODYSSEY[mi]; + if (bar === acc) { + const noteDurSec = hold * barSec * 1.02; // tiny overlap → glide-blend + // DISTANCE breathing — long LFO across the song so the theremin + // wanders from close (loud + present) to very distant (faint + // call across the horizon). BATTLE bars stay confident + loud. + const inBattle = bar >= 36 && bar < 44; + const distLFO = inBattle + ? 1.0 + : 0.35 + 0.65 * Math.max(0, 0.5 + 0.5 * Math.sin((bar / 19) * 2 * Math.PI)); + const gain = vel * 0.45 * distLFO; + const next = ODYSSEY[mi + 1]; + const nextMidi = next ? next[0] : null; + // Theremin at the original ODYSSEY register (D4-D6 / midi 62-86). + fireTheremin(sawBuf, barStart, midi, noteDurSec, gain, nextMidi); + events.supersaw.push({ t: barStart, midi, dur: noteDurSec, kind: "theremin" }); + supersawCount++; + break; + } + if (bar < acc + hold) break; + acc += hold; + } + } + + // CLAP — classic trance backbeat punctuation, sparingly placed + // ("here or there" per @jeffrey). Fires on beat 2 (mid-bar) of every + // other drop1/drop2 bar — present enough to feel, sparse enough to + // stay "or there" instead of every beat. + if ((sec === "drop1" || sec === "drop2") && (bar % 2 === 0)) { + const clapT = barStart + barSec * 2 / 5; // beat 2 of 5 + fireClap(dryBuf, clapT, 0.32 * DRUM_GAIN); // chill backbeat — pulled from 0.55 + events.kick.push({ t: clapT, kind: "clap" }); + } + + // DICE-ROLL CLICK-CLACK — kicks in STRAIGHT AT BAR 41 (right out the + // gate after the vortex stamp at bar 39-40) and carries through to + // the end. Cluster of 3-6 tumbling wooden clicks per fire, alternating + // hi/lo pitch ("click-clack"). Placed on beats 1 + 3 of every bar + // post-vortex for a steady rolling clatter under the drop2 + outro. + if (bar >= 41) { + const c1 = barStart + barSec * 1 / 5; + const c2 = barStart + barSec * 3 / 5; + fireDiceRoll(dryBuf, c1, 0.28 * DRUM_GAIN); // chill click-clack — pulled from 0.50 + fireDiceRoll(dryBuf, c2, 0.28 * DRUM_GAIN); + events.kick.push({ t: c1, kind: "dice" }); + events.kick.push({ t: c2, kind: "dice" }); + } + // Lead — base theme + subtle microtonal drift + per-bar octave AND // rhythmic-subdivision shuffles. Each bar rolls: // octave : -7 / 0 / +7 / +14 scale-degrees (down 1 → up 2 octaves) @@ -3394,7 +3608,7 @@ applyFlange(sawBuf, { depthMs: 4.5, baseDelayMs: 5, feedback: 0.45, - mix: 0.55, + mix: 0.35, // pulled from 0.55 — wet was eating the dry saw fundamentals, making the wall hard to hear sampleRate: SAMPLE_RATE, }); for (let i = 0; i < totalSamples; i++) duckBuf[i] += sawBuf[i]; @@ -3634,6 +3848,7 @@ if (GALLOP && isChill) { // deeper-set base ratio 0.5 (down an octave) + a mild melodic step const ratio = 0.5 * Math.pow(2, melo[strideCount % melo.length] / 12); place(gal, ts, ratio, 0.55 * DRUM_GAIN * lvl * (0.85 + gRng() * 0.3), true); + events.sfx.push({ t: ts, name: "gallop", dur: 0.55 }); ts += (0.55 + gRng() * 0.7) / (mt < 24 ? 1.3 : 1.0); // irregular = spread strideCount++; } @@ -3646,6 +3861,7 @@ if (GALLOP && isChill) { const r = Math.pow(2, (scaleNoteMidi(cd + hd, -1) - 57) / 12); place(nei, nt, Math.max(0.4, r), hg * DRUM_GAIN * 0.6, true); } + events.sfx.push({ t: nt, name: "neigh", dur: 1.2 }); } } // THUNDERCLAP / lightning — synth crack + deep rumble + tail (no @@ -3667,6 +3883,8 @@ if (GALLOP && isChill) { }; thunder(OPENING_PREFIX_SEC + 0.12, 0.5 * DRUM_GAIN); thunder(OPENING_PREFIX_SEC + musicSec * 0.5, 0.4 * DRUM_GAIN); + events.sfx.push({ t: OPENING_PREFIX_SEC + 0.12, name: "thunder", dur: 2.6 }); + events.sfx.push({ t: OPENING_PREFIX_SEC + musicSec * 0.5, name: "thunder", dur: 2.6 }); // STEAM / TRAIN WHISTLE (jas) — a detuned 3-note chord cluster with // breathy steam, slow swell in, mournful pitch fall + vibrato, long // tail. Opens like a locomotive; one more mid. @@ -3693,6 +3911,8 @@ if (GALLOP && isChill) { }; whistle(OPENING_PREFIX_SEC + 0.05, 1.7, 0.32 * DRUM_GAIN); whistle(OPENING_PREFIX_SEC + musicSec * 0.5 - 0.45, 1.4, 0.26 * DRUM_GAIN); + events.sfx.push({ t: OPENING_PREFIX_SEC + 0.05, name: "train-whistle", dur: 1.7 }); + events.sfx.push({ t: OPENING_PREFIX_SEC + musicSec * 0.5 - 0.45, name: "train-whistle", dur: 1.4 }); // OCEAN / HARBOUR scene in the last third (jas: "wave crashing // sounds ... a boat horn ... fog horn in distance"). Real CC0 // Freesound samples, placed via the same deep/echo/variable-rate @@ -3721,17 +3941,19 @@ if (GALLOP && isChill) { let wt = oc0; while (wt < ocEnd) { place(waves, wt, 0.85 + gRng() * 0.2, 0.20 * DRUM_GAIN * (0.8 + gRng() * 0.3), true); + events.sfx.push({ t: wt, name: "wave", dur: 1.2 }); wt += 1.4 + gRng() * 1.6; } } - if (fogh) { // LOW ~5s smooth fog horn (jas: "at 2:19 ... 5 seconds ... low freq") - placeClean(fogh, OPENING_PREFIX_SEC + musicSec * 0.68, 0.17 * DRUM_GAIN); - placeClean(fogh, OPENING_PREFIX_SEC + musicSec * 0.82, 0.16 * DRUM_GAIN); // ≈ 2:19 - } - if (boath) { // a couple mournful boat-horn blasts - place(boath, OPENING_PREFIX_SEC + musicSec * 0.72, 0.80, 0.30 * DRUM_GAIN, true); - place(boath, OPENING_PREFIX_SEC + musicSec * 0.835, 0.74, 0.24 * DRUM_GAIN, true); - } + // Foghorn removed per @jeffrey — the boathorns + train-whistle carry + // the harbour mood without the long sustained low-frequency bed tone. + // (fogh loaded but intentionally unused; left in place so the asset + // path stays warm if we want to re-enable later.) + void fogh; + // Boathorns fully removed per @jeffrey — both the ~2:17 and the + // ~2:40 blasts read foghorn-ish against the ocean bed. The harbour + // mood is now carried by waves + train-whistle + neigh alone. + void boath; console.log(` gallop · REAL CC0 horse — ${strideCount} strides + neigh + thunder + steam whistle + ocean/boat/fog (CC0)`); } -- 2.51.2