From c6733c143cf997867c107da2caf40ef0aface35b Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Tue, 19 May 2026 06:31:03 +0000 Subject: [PATCH] pop: new hippyhayzard lane (skrill/hoover/zitar synths) + media asset story - hippyhayzard lane: happy-hardcore×nightcore → earthbound sorrow-ballad (README/STUDY, 16-bar Bach chorale, render.mjs arc/harmony/song modes, sing.mjs jeffrey-pvc ballad pipeline, .np score + .txt lyric) - new bottom-up C-portable voices: skrill (FM+swept-formant talking bass), hoover (Alpha-Juno whoop), zitar (KS + jawari + taraf) - pop/ASSETS.md: code=source, media=assets-system backup; per-track archive.mjs + pop:assets:up/down subtree sync (cross-system, keeps the billable jeffrey-pvc vocal cache so re-renders stay free) - gitignore pop/*/out (rendered audio is media, never git) - SCORE.md: register lane #6 + assets pointer Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 +++++++ package.json | 2 ++ pop/ASSETS.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/SCORE.md | 14 +++++++++++++- pop/bin/archive.mjs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/dance/synths/skrill.mjs | 412 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/README.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/STUDY.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/bin/render.mjs | 578 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/bin/sing.mjs | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/hippyhayzard.np | 45 +++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/hippyhayzard.txt | 8 ++++++++ pop/hippyhayzard/synths/hoover.mjs | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pop/hippyhayzard/synths/zitar.mjs | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 14 file(s) changed, 2213 insertion(s)(+), 1 deletion(s)(-) diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -466,3 +466,10 @@ # Grant LaTeX build artifacts (sources/drafts live in the vault) grants/**/*.aux grants/**/*.out grants/lacma-2026/*.log + +# pop/ — code is source of truth; rendered audio + caches are media, +# backed up to the assets system (DO Spaces), never git. See pop/ASSETS.md. +pop/*/out/ +pop/.venv/ +pop/**/__pycache__/ +pop/voice/.cache/ diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -88,6 +88,8 @@ "reload-piece": "curl -X POST -H \"Content-Type: application/json\" -d '{\"piece\": \"@digitpain/hello\"}' http://localhost:8082/reload", "server:socket": "cd socket-server; npm run server", "assets:sync:down": "aws s3 sync s3://assets-aesthetic-computer system/public/assets --endpoint-url https://sfo3.digitaloceanspaces.com --exclude 'false.work/spiderlily-*.zip*' || echo 'Sync completed with some directory conflicts (safe to ignore)'", "assets:sync:up": "aws s3 sync system/public/assets s3://assets-aesthetic-computer --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --exclude 'false.work/spiderlily-*.zip*' --acl public-read", + "pop:assets:down": "aws s3 sync s3://assets-aesthetic-computer/pop system/public/assets/pop --endpoint-url https://sfo3.digitaloceanspaces.com", + "pop:assets:up": "aws s3 sync system/public/assets/pop s3://assets-aesthetic-computer/pop --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --acl public-read", "publish:m4l": "node ac-m4l/publish.mjs", "session:reset": "f() { cd session-server; npx jamsocket backend terminate $1 };f", "session:alive": "cd session-server; npx jamsocket backend list", diff --git a/pop/ASSETS.md b/pop/ASSETS.md new file mode 100644 --- /dev/null +++ b/pop/ASSETS.md @@ -0,0 +1,99 @@ +# pop — media & raw-asset management + +The question this answers: where do a track's **media files** (final +mix, instrumental bed, raw vocal stems, caches) live so we can work +**across machines**, reproduce stems from the repo later, and never +re-pay for something we already generated. + +## the principle: code is source, media is backup + +`pop/` is bottom-up ([SCORE.md](SCORE.md)). Almost every asset is +**reproducible at $0** from the repo: + +- synths (`skrill` / `hoover` / `zitar` / `supersaw` / `sinepower`) and + the bed builders (`bin/render.mjs`, `recap/bin/trance.mjs`) are pure + code. Re-running them reproduces the bed **bit-for-bit**. + +The *only* asset that is **not** free to regenerate is the **ElevenLabs +jeffrey-pvc vocal** — `say.mjs` bills real money. It is content-hash +cached locally (`.mp3` + `.mp3.hash`), so a second render +on the *same machine* is free. The job of the asset system is to make +that cache — and the finished media — survive **across machines and +time**. + +So: + +| asset | reproducible? | lives in | +|---|---|---| +| synths, render.mjs, sing.mjs, `.np`, `.txt` | — (it *is* the source) | **git** | +| instrumental bed mp3 | yes, $0 (`render.mjs`) | assets (convenience) | +| **raw vocal stem + `.hash` + words/alignment json** | **no — billable** | **assets (must preserve)** | +| pitched / stretched / `.tmp` intermediates | yes, $0 (from the vocal stem) | nowhere — gitignored | +| final mix mp3 (the deliverable) | from stem + bed | **assets** | + +## where media lives + +The AC assets system is DigitalOcean Spaces +(`s3://assets-aesthetic-computer`) mirrored to the gitignored +`system/public/assets/` tree (same system papers and the +trancenwaltz single already use). pop media lives under a structured +per-track path: + +``` +system/public/assets/pop/// + .mp3 # final mix — the deliverable + bed.mp3 # instrumental bed (cheap, kept for convenience) + vocal/ + -vocal.mp3 # RAW jeffrey-pvc stem ── billable original + -vocal.mp3.hash # say.mjs cache key ── restores = free re-render + -vocal-words.json # whisper word windows + -vocal-words.json.hash + -pitched-alignment.json +``` + +Intermediates (`*-pitched.mp3`, `*-stretched.mp3`, `out/.tmp/`) are +**not** stored — they regenerate locally from the vocal stem in seconds. + +## the per-track process + +After a track is rendered + (optionally) sung: + +```bash +# 1. render the bed (free, deterministic) +node pop/hippyhayzard/bin/render.mjs --mode song +# 2. (optional) sing it — bills ONCE, then cached +node pop/hippyhayzard/bin/sing.mjs +# 3. stage durable media into the assets tree +node pop/bin/archive.mjs hippyhayzard hippyhayzard +# 4. back up to Spaces (syncs ONLY system/public/assets/pop) +npm run pop:assets:up +``` + +On another machine (or later, fresh checkout): + +```bash +git pull +npm run pop:assets:down # restores beds, finals, vocal cache +node pop/hippyhayzard/bin/sing.mjs # re-render is FREE — say cache hit +``` + +`pop:assets:up` / `pop:assets:down` sync **only the +`system/public/assets/pop/` subtree** (fast — not the whole multi-GB +assets bucket), mirroring the same DO Spaces endpoint/flags as the +repo-wide `assets:sync`. + +`pop/bin/archive.mjs ` is the per-track stager: it copies +the lane's durable media (final, bed, vocal stem + hash + json) into +`system/public/assets/pop///` so step 4 has something to +push. Re-runnable; never touches intermediates. + +## why this shape + +- **git stays small** — no binaries; `pop/*/out/` is gitignored. +- **cross-system** — `git pull` + `pop:assets:down` reproduces a track, + and any further `sing.mjs` is free because the billable vocal cache + is restored alongside the audio. +- **future stems** — the raw vocal is preserved, so we can always + re-mix, re-pitch, or pull a clean stem out of an old track without + re-paying ElevenLabs or hoping a laptop still has the cache. +- **the bucket is the backup**, not git and not one machine's `~`. diff --git a/pop/SCORE.md b/pop/SCORE.md --- a/pop/SCORE.md +++ b/pop/SCORE.md @@ -28,6 +28,8 @@ Audio-only by default. No video, no chrome. If a track later becomes a video lane, that's a recap-side concern, not a `pop` concern. Shipped + in-flight singles (DistroKid status, masters, covers, videos) are tracked in **[RELEASES.md](RELEASES.md)**. +Media files (final mixes, beds, raw billable vocal stems) are **not in git** — code is the source of truth, media is backed up to the assets system per-track. See **[ASSETS.md](ASSETS.md)**. + ## Swimlanes ### 1. big pictures (`big-pictures/`) @@ -91,7 +93,17 @@ test. See [`jungle/README.md`](jungle/README.md) for the format spec. -### 6. (open) +### 6. hippyhayzard (`hippyhayzard/`) + +Audio-only **happy hardcore × nightcore** that the form lets turn into an **earthbound/mother sorrow-ballad** — the name *is* the spec: **hippy** (bright major euphoria) colliding with **hazard** (a minor rave-siren switch). ~152 BPM, half-time ballad feel, a 16-bar hand-voiced Bach chorale (borrowed iv, deceptive cadence), 1:28 with a drop-out **break**. + +Three new bottom-up voices were built for it (same module shape as the dance synths, pure-float for an eventual ac-native C port): `skrill` (Skrillex FM + swept-formant talking bass — `dance/synths/`), `hoover` (Alpha-Juno "Mentasm" with the pitch *whoop*), `zitar` (sitar = Karplus-Strong + jawari buzz + sympathetic-string bank). + +Voice posture: jeffrey-pvc as a **sung lead** (not rapped, not one-shot) — the lyric *is* the vision, tender and plain. `bin/sing.mjs` is the ballad-tuned vocal pipeline (say → align → WORLD pitch → stretch → intimate mix onto the bed). + +See [`hippyhayzard/README.md`](hippyhayzard/README.md) for the format spec and [`hippyhayzard/STUDY.md`](hippyhayzard/STUDY.md) for the genre study + the blend law. Status: scaffolded 2026-05-19; first sung 1:28 cut rendered, vocal↔section bar-lock is the open refinement. + +### 7. (open) More lanes will land here as they prove themselves. Candidates: kidlisp-as-instrument tracks, AC-native ensemble cuts, voice-memo-grade demo lane. None of them have earned a swimlane yet — they need a real track first. diff --git a/pop/bin/archive.mjs b/pop/bin/archive.mjs new file mode 100644 --- /dev/null +++ b/pop/bin/archive.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// archive.mjs — per-track media stager for pop/. +// +// Copies a track's DURABLE media out of pop//out/ into the +// assets-system mirror system/public/assets/pop/// so +// `npm run pop:assets:up` can back it to DigitalOcean Spaces. +// +// Durable = the deliverable + the bed + the BILLABLE jeffrey-pvc vocal +// stem and its say.mjs cache keys (restoring those makes future +// sing.mjs re-renders free on any machine). Intermediates +// (*-pitched.mp3, *-stretched.mp3, out/.tmp/) are NOT staged — they +// regenerate locally from the vocal stem in seconds. +// +// See pop/ASSETS.md for the full story. +// +// Usage: +// node pop/bin/archive.mjs +// node pop/bin/archive.mjs hippyhayzard hippyhayzard + +import { existsSync, mkdirSync, copyFileSync, statSync } from "node:fs"; +import { resolve, dirname, basename } from "node:path"; +import { fileURLToPath } from "node:url"; + +const [lane, slug] = process.argv.slice(2); +if (!lane || !slug) { + console.error("usage: node pop/bin/archive.mjs "); + process.exit(1); +} + +const HERE = dirname(fileURLToPath(import.meta.url)); // pop/bin +const POP = resolve(HERE, ".."); // pop +const REPO = resolve(POP, ".."); // repo root +const OUT = `${POP}/${lane}/out`; +const DEST = `${REPO}/system/public/assets/pop/${lane}/${slug}`; +const VDEST = `${DEST}/vocal`; + +if (!existsSync(OUT)) { + console.error(`✗ no out dir for lane '${lane}': ${OUT}`); + process.exit(1); +} +mkdirSync(DEST, { recursive: true }); + +let n = 0; +function take(srcName, destDir, destName = srcName) { + const src = `${OUT}/${srcName}`; + if (!existsSync(src)) return false; + mkdirSync(destDir, { recursive: true }); + const dst = `${destDir}/${destName}`; + copyFileSync(src, dst); + const kb = (statSync(dst).size / 1024).toFixed(0); + console.log(` + ${basename(destDir)}/${destName} (${kb} KB)`); + n++; + return true; +} + +console.log(`▸ archiving ${lane}/${slug} → ${DEST.replace(REPO + "/", "")}`); + +// final deliverable: prefer the sung song, else the bed itself +if (!take(`${slug}-song.mp3`, DEST, `${slug}.mp3`)) { + take(`${slug}.mp3`, DEST, `${slug}.mp3`); +} +// the instrumental bed (cheap, kept for convenience / remixing) +take(`${slug}.mp3`, DEST, "bed.mp3"); + +// the billable vocal stem + its cache keys (the must-preserve assets) +for (const f of [ + `${slug}-vocal.mp3`, + `${slug}-vocal.mp3.hash`, + `${slug}-vocal.mp3.alignment.json`, + `${slug}-vocal-words.json`, + `${slug}-vocal-words.json.hash`, + `${slug}-pitched-alignment.json`, +]) take(f, VDEST); + +if (n === 0) { + console.error(`✗ nothing staged — no known media in ${OUT}`); + process.exit(1); +} +console.log(`✓ staged ${n} file(s). next: npm run pop:assets:up`); diff --git a/pop/dance/synths/skrill.mjs b/pop/dance/synths/skrill.mjs new file mode 100644 --- /dev/null +++ b/pop/dance/synths/skrill.mjs @@ -0,0 +1,412 @@ +#!/usr/bin/env node +// skrill.mjs — the "skrill": a resynthesized Skrillex-style growl/talking +// bass for the dance lane. A verb you apply to a note line. +// +// Unlike supersaw.mjs / sinepower.mjs, the skrill CANNOT express itself +// as a fan-out of plain `sound.synth({type:...})` voices — the AC voice +// contract (see bus.mjs) is oscillator + AD envelope only, and the +// Skrillex sound is defined precisely by the two blocks AC lacks: +// +// 1. FM — a modulator osc driving a carrier, modulation INDEX swept +// hard, packing inharmonic partials into the 100–400 Hz +// growl band. This is the timbre. +// 2. A swept resonant FORMANT pair — two band-pass peaks (F1/F2) whose +// centers are morphed between vowels by a beat-synced LFO. +// The moving formant is what makes it *talk*. This is the +// signature. +// +// So the skrill hand-rolls its own per-sample DSP straight into the +// Float32 render buffer (the same way fx.mjs processes buffers), then +// glues with tanh saturation + optional bitcrush + a clean sine sub. +// +// The wub is BEAT-SYNCED: the LFO rate is a note division ("1/8", +// "1/4", "1/8t" triplet, "1/8d" dotted, or a raw Hz number), locked to +// `bpm`, so the talking pattern sits on the trance grid instead of +// drifting. That makes `skrill` composable as a verb in a .np line: +// +// F1 skrill:ow:1/8 → growl, vowel "ow", wub every 8th note +// A#1 skrill:talk:1/4 → 3-vowel talking bass, quarter-note wub +// +// Library: +// import { mixEventSkrill, renderSkrill, SKRILL_PRESETS } from "..."; +// mixEventSkrill(ev, out, opts) // node bed renderer (the /pop path) +// renderSkrill(ev, opts) -> Float32 // the bare DSP engine +// playSkrill(sound, ev, opts) // live-AC seam (custom voice) +// +// CLI demo: +// node pop/dance/synths/skrill.mjs # growl preset +// node pop/dance/synths/skrill.mjs --preset talk --lfo 1/4 +// node pop/dance/synths/skrill.mjs --preset screech --bpm 140 --out ~/sk.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"; + +const DEFAULT_SAMPLE_RATE = 48_000; +const DEFAULT_PRESET = "growl"; +const DEFAULT_BPM = 138; + +// ── vowel formant table (F1, F2 in Hz) ───────────────────────────────── +// The "talking" character is a morph between these resonant-peak pairs. +const VOWELS = { + ah: [ 700, 1220 ], // open — "aah" + ow: [ 500, 900 ], // round — "wow" + oo: [ 300, 870 ], // closed back — "ooo" + ee: [ 270, 2300 ], // closed front — "eee" (bright/screechy) + eh: [ 530, 1840 ], // mid front — "ehh" + uh: [ 640, 1190 ], // neutral — "uhh" +}; + +// ── presets ──────────────────────────────────────────────────────────── +// modRatio : modulator freq ÷ carrier freq (1 = same; 2 = octave-up bite) +// fmIndex : peak FM depth (carrier phase kick per modulator unit) +// vowelA/B : the two vowels the LFO morphs between (the wub) +// vowelSeq : if set, an S&H-stepped vowel sequence → talking bass +// lfoShape : "sine" | "tri" | "saw" | "square" | "sh" +// lfo : default note-division for the wub +// q : formant resonance (higher = sharper, more vocal) +// drive : tanh saturation amount (the grit) +// crush : 0 = off, else bit depth (e.g. 6 = harsh) +// subGain : clean unmodulated sine sub an octave down (body) +// edge : raw-saw blended into the FM carrier (cuts through a mix) +export const SKRILL_PRESETS = { + // Mid talking bass — the canonical Scary-Monsters wub. + growl: { + modRatio: 1.0, fmIndex: 6.0, vowelA: "ah", vowelB: "ow", + lfoShape: "sine", lfo: "1/8", q: 7, drive: 2.6, crush: 0, + subGain: 0.38, edge: 0.22, attack: 0.006, decay: 0.10, + }, + // 3-vowel sequenced talking bass ("yoi-yoi-yow"). + talk: { + modRatio: 1.0, fmIndex: 5.0, vowelSeq: ["ee", "ah", "ow"], + lfoShape: "sh", lfo: "1/8", q: 9, drive: 2.2, crush: 0, + subGain: 0.34, edge: 0.18, attack: 0.006, decay: 0.10, + }, + // Detuned Reese-ish — slower, fatter, less talk, more rumble. + reese: { + modRatio: 0.5, fmIndex: 2.2, vowelA: "uh", vowelB: "oo", + lfoShape: "tri", lfo: "1/4", q: 4, drive: 1.8, crush: 0, + subGain: 0.55, edge: 0.30, detune: 16, attack: 0.010, decay: 0.18, + }, + // High, fast, chopped — the screech/lead growl. + screech: { + modRatio: 2.0, fmIndex: 10.0, vowelA: "ee", vowelB: "ah", + lfoShape: "sh", lfo: "1/16", q: 8, drive: 4.0, crush: 7, + subGain: 0.12, edge: 0.35, attack: 0.004, decay: 0.07, + }, + // Clean foundation — minimal FM, no wub, just the sine sub + body. + sub: { + modRatio: 1.0, fmIndex: 0.6, vowelA: "oo", vowelB: "oo", + lfoShape: "sine", lfo: "1/4", q: 3, drive: 1.2, crush: 0, + subGain: 0.85, edge: 0.06, lfoDepth: 0.0, attack: 0.012, decay: 0.22, + }, +}; + +// ── helpers ──────────────────────────────────────────────────────────── +function midiToFreq(midi) { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +// Deterministic xorshift RNG (same pattern as supersaw.mjs) — keeps the +// sample-&-hold robotic stutter reproducible per event. +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; + }; +} + +// Resolve a beat-synced wub rate → cycles/sec. +// "1/n" → one LFO cycle every (4/n) quarter-note beats +// "1/nt" → triplet (n × 3/2 subdivisions per whole note) +// "1/nd" → dotted (n × 2/3 subdivisions per whole note) +// number → raw Hz (escape hatch; drifts off-grid) +export function resolveLfoHz(lfo, bpm) { + if (typeof lfo === "number" && Number.isFinite(lfo)) return Math.max(0.01, lfo); + const m = String(lfo).match(/^1\/(\d+(?:\.\d+)?)([td])?$/); + const beatSec = 60 / bpm; // one quarter-note + if (!m) return 1 / (beatSec * 2); // fallback: 1/8 + let n = parseFloat(m[1]); + if (m[2] === "t") n *= 1.5; // triplet feel + if (m[2] === "d") n /= 1.5; // dotted feel + const cycleSec = beatSec * (4 / n); // 4 = quarter-notes per whole note + return 1 / Math.max(1e-4, cycleSec); +} + +// One unipolar LFO sample in [0,1]. `rng`/`shStep` drive sample-&-hold. +function lfoValue(shape, phase, sh) { + switch (shape) { + case "tri": return phase < 0.5 ? phase * 2 : 2 - phase * 2; + case "saw": return phase; + case "square": return phase < 0.5 ? 0 : 1; + case "sh": return sh.value; // held per cycle + case "sine": + default: return 0.5 + 0.5 * Math.sin(2 * Math.PI * phase); + } +} + +// ── the DSP engine ───────────────────────────────────────────────────── +// Renders ONE skrill event into a fresh mono Float32Array. ev: { midi, +// durSec, gain?, preset?, lfo?, vowel? }. Per-note `preset`/`lfo`/`vowel` +// override opts so a .np verb token maps straight through. +export function renderSkrill(ev, opts = {}) { + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const bpm = opts.bpm ?? DEFAULT_BPM; + const presetName = ev.preset || opts.preset || DEFAULT_PRESET; + const P = { ...(SKRILL_PRESETS[presetName] || SKRILL_PRESETS[DEFAULT_PRESET]), ...opts.params }; + + if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) { + return new Float32Array(0); + } + const gain = Number.isFinite(ev.gain) ? ev.gain : 1.0; + if (gain === 0) return new Float32Array(0); + + const attack = opts.attack ?? P.attack ?? 0.006; + const decay = opts.decay ?? P.decay ?? 0.10; + // Match the bus.mjs / native envelope window: auto-extend so the + // linear decay completes to true silence without an end click. + const durS = Math.ceil(ev.durSec * sampleRate); + const attS = Math.max(1, Math.floor(attack * sampleRate)); + const decS = Math.max(1, Math.floor(decay * sampleRate)); + const ns = Math.max(durS, attS + decS); + const decayStart = ns - decS; + const out = new Float32Array(ns); + + const carrierF = midiToFreq(ev.midi); + const modF = carrierF * (P.modRatio ?? 1.0); + const lfoHz = resolveLfoHz(ev.lfo || opts.lfo || P.lfo, bpm); + const lfoDepth = P.lfoDepth ?? 1.0; + + // Vowel endpoints (per-note `vowel` pins one end for verb control). + const seq = P.vowelSeq && P.vowelSeq.length ? P.vowelSeq : null; + const vA = VOWELS[ev.vowel || P.vowelA] || VOWELS.ah; + const vB = VOWELS[P.vowelB] || vA; + + const rng = makeRng(`skrill:${presetName}:${ev.midi}:${(ev.startSec ?? 0).toFixed(4)}`); + + // State. + let modPhase = 0, carPhase = 0, lfoPhase = rng(); + // Two Chamberlin SVF band-pass filters (formant F1/F2). + let l1 = 0, b1 = 0, l2 = 0, b2 = 0; + const damp = 1 / Math.max(0.5, P.q ?? 6); // 1/Q → resonance + const sh = { value: rng(), seqIdx: 0 }; + const detune = (P.detune ?? 0) / 1200; // reese: extra detuned saw + const TWO_PI = 2 * Math.PI; + + for (let i = 0; i < ns; i++) { + // ── 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) break; } + + // ── beat-synced wub LFO ── + lfoPhase += lfoHz / sampleRate; + if (lfoPhase >= 1) { + lfoPhase -= 1; + sh.value = rng(); // new S&H value per cycle + if (seq) sh.seqIdx = (sh.seqIdx + 1) % seq.length; + } + let lv = lfoValue(P.lfoShape || "sine", lfoPhase, sh); + lv = 0.5 + (lv - 0.5) * lfoDepth; // depth around center + + // ── FM source ── modulator (saw) → carrier (sine), index swept. + modPhase += modF / sampleRate; if (modPhase >= 1) modPhase -= 1; + const mod = 2 * modPhase - 1; // bipolar saw modulator + // "Constant modulation": index breathes with the wub — the thing + // every Skrillex tutorial stresses. + const idx = (P.fmIndex ?? 5) * (0.35 + 0.65 * lv); + carPhase += carrierF / sampleRate; if (carPhase >= 1) carPhase -= 1; + let voice = Math.sin(TWO_PI * carPhase + idx * mod); + // Raw-saw edge so it cuts through a dense trance bed. + voice = voice * (1 - (P.edge ?? 0.2)) + (2 * carPhase - 1) * (P.edge ?? 0.2); + // Reese: a second detuned saw layer underneath. + if (detune) { + const dp = (carPhase + i * (carrierF * detune) / sampleRate) % 1; + voice += (2 * dp - 1) * 0.5; + } + + // ── swept formant pair (the talking) ── + let f1t, f2t; + if (seq) { // sequenced talking bass + const cv = VOWELS[seq[sh.seqIdx]] || vA; + f1t = cv[0]; f2t = cv[1]; + } else { // smooth A↔B morph + f1t = vA[0] + (vB[0] - vA[0]) * lv; + f2t = vA[1] + (vB[1] - vA[1]) * lv; + } + const fc1 = Math.min(Math.max(60, f1t), sampleRate / 6); + const fc2 = Math.min(Math.max(60, f2t), sampleRate / 6); + const k1 = 2 * Math.sin(Math.PI * fc1 / sampleRate); + const k2 = 2 * Math.sin(Math.PI * fc2 / sampleRate); + let h1 = voice - l1 - damp * b1; b1 += k1 * h1; l1 += k1 * b1; + let h2 = voice - l2 - damp * b2; b2 += k2 * h2; l2 += k2 * b2; + // F1 carries the body, F2 the brightness/intelligibility. + let s = b1 * 1.0 + b2 * 0.7; + + // ── grit: tanh saturation + optional bitcrush ── + s = Math.tanh(s * (P.drive ?? 2.5)); + if (P.crush && P.crush > 0) { + const steps = Math.pow(2, P.crush); + s = Math.round(s * steps) / steps; + } + + // ── clean sine sub an octave down (unmodulated body) ── + const sub = Math.sin(TWO_PI * carPhase * 0.5) * (P.subGain ?? 0.35); + + out[i] = (s * 0.78 + sub) * env * gain; + } + return out; +} + +// ── node-side buffer mixer (the /pop bed-render path) ────────────────── +export function mixEventSkrill(ev, out, opts = {}) { + if (!(out instanceof Float32Array)) return; + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const seg = renderSkrill(ev, { ...opts, sampleRate }); + const startIdx = Math.floor((ev.startSec ?? 0) * sampleRate); + for (let i = 0; i < seg.length; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= out.length) continue; + out[dst] += seg[i]; + } +} + +// ── live-AC seam ─────────────────────────────────────────────────────── +// supersaw/sinepower fan out plain sound.synth() voices; the skrill +// can't (no FM/formant in the voice contract). When the live runtime +// grows a buffer/custom voice this renders through it; until bed +// integration lands it is a documented no-op rather than a broken path. +export function playSkrill(sound, ev, opts = {}) { + if (!sound?.synth) return; + const seg = renderSkrill(ev, opts); + if (!seg.length) return; + // Custom-voice seam: AC's synth supports type:"custom" with a + // generator. Wired here for when dance-bed integration validates it. + try { + let pos = 0; + sound.synth({ + type: "custom", + tone: midiToFreq(ev.midi), + duration: seg.length / (opts.sampleRate ?? DEFAULT_SAMPLE_RATE), + volume: 1, + attack: 0, + decay: 0, + generator: () => (pos < seg.length ? seg[pos++] : 0), + }); + } catch { + /* live custom-voice path not yet validated — see header */ + } +} + +// ── 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 SR = 48_000; + const preset = flags.preset || DEFAULT_PRESET; + const bpm = Number(flags.bpm ?? DEFAULT_BPM); + const lfo = flags.lfo || undefined; + const outPath = expandHome(flags.out) || resolve(HERE, `skrill-${preset}.mp3`); + + // A harmonized A-minor hook so the wub talks ACROSS a melody, not one + // pedal note. Each step is a 3-note voicing: melody on top, a diatonic + // third under it, a fifth/bass below — three skrill voices growling in + // parallel + a clean sub on the bass. Notes are already in the growl + // pocket (A3–C5); `--transpose` (semitones) shifts the lot, formants + // stay fixed in Hz. `--mono` plays just the melody line + sub. + const transpose = Number(flags.transpose ?? 0); + const mono = !!flags.mono; + const beat = 60 / bpm; + // [ melody, third, bass ] , duration in beats — descending minor hook. + const HOOK = [ + { v: [69, 64, 57], b: 0.75 }, // Am A4 E4 A3 + { v: [72, 67, 60], b: 0.25 }, // C C5 G4 C4 + { v: [71, 67, 55], b: 0.50 }, // Em/G B4 G4 G3 + { v: [69, 64, 57], b: 0.50 }, // Am A4 E4 A3 + { v: [67, 62, 59], b: 0.50 }, // G/B G4 D4 B3 + { v: [64, 59, 52], b: 1.00 }, // Em E4 B3 E3 + { v: [65, 57, 50], b: 0.50 }, // Dm/F F4 A3 D3 + { v: [64, 59, 52], b: 1.50 }, // Em E4 B3 E3 + ]; + const VOICE_GAIN = mono ? [0.85] : [0.78, 0.5, 0.5]; + let t = 0; + const events = []; + let noteCount = 0; + for (let loop = 0; loop < 2; loop++) { + for (const step of HOOK) { + const dur = step.b * beat; + const voices = mono ? [step.v[0]] : step.v; + for (let n = 0; n < voices.length; n++) { + events.push({ + startSec: t, midi: voices[n] + transpose, durSec: dur, + gain: 0.95 * VOICE_GAIN[n], preset, lfo, + }); + noteCount++; + } + // Clean sub doubling the bass voice. + events.push({ + startSec: t, midi: step.v[2] + transpose - 12, durSec: dur, + gain: 0.7, preset: "sub", + }); + t += dur; + } + } + + const total = t + 0.6; + const out = new Float32Array(Math.ceil(total * SR)); + console.log( + `→ skrill demo · preset=${preset} · bpm=${bpm} · wub=${lfo || SKRILL_PRESETS[preset]?.lfo} · ${mono ? "mono" : "harmonized"} · ${noteCount} notes`, + ); + for (const ev of events) mixEventSkrill(ev, out, { sampleRate: SR, bpm, preset, lfo }); + + 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 n = 0.85 / peak; for (let i = 0; i < out.length; i++) out[i] *= n; } + + 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(SR), "-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/hippyhayzard/README.md b/pop/hippyhayzard/README.md new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/README.md @@ -0,0 +1,89 @@ +# hippyhayzard + +audio-only happy hardcore × nightcore, built bottom-up from AC instruments. the lane and its first track share a name — `hippyhayzard` — because the name *is* the form: **hippy** (bright major euphoria) colliding with **hazard** (a minor rave-siren switch). + +opens on the **hippyhayzard** sub-format. future sub-formats (UK hardcore, freeform, makina) land alongside if and when they earn a finished track. + +## the blend (decided @jeffrey, 2026-05-19) + +happy hardcore and nightcore are cousins — fast, major-key, pitched-up, euphoric — but they are not the same thing and the lane refuses to average them into mush. it switches between them on purpose: + +- **happy hardcore** is the *engine*: ~174 BPM, chunky 4-on-the-floor, reverse bass, the hoover, Korg-M1-style piano/bell stabs, ecstatic breakdowns. its own canon, its own comps. +- **nightcore** is the *sugar*: +octave glock/bell doubling, brightness, the sped-up "chipmunk" sheen. nightcore is a *treatment*, not a song — so here it is applied as a layer, never as the substrate (that would be top-down; see [SCORE.md](../SCORE.md) posture). +- **the hazard** is the *switch*: the track drops to half-time + relative minor, kills the glock, and brings the `skrill` talking-bass in as a rave siren. same theme, dark. then it springs back to hippy. the switch is the genre — without it this is just fast happy hardcore. + +"glocky bpm, swingy switchez": bright glockenspiel/bell melodic doubling + a swung 16th groove that *switches feel* (bouncy ↔ half-time). swing is structural, not decoration. + +## format spec — hippyhayzard + +- **length**: ~1:30 (radio edit). compress without losing the build → hippy drop → hazard switch → hippy return arc +- **tempo**: 174 BPM, 4/4 (range 168–178). the hazard switch is felt half-time (≈87 BPM feel), not a tempo change +- **key**: bright **major** for hippy (A major default) ↔ its **relative/parallel minor** for hazard (F# minor). euphoria that can turn +- **structure**: intro/build (8) → hippy drop 1 (16) → hazard (8) → hippy drop 2 (16) → build 2 (4) → hazard 2 (8) → hippy drop 3 (16) → outro (4) bars +- **bed**: chunky kick (4-on-floor; half-time in hazard) + reverse bass (off-beat swell) + swung 16th hats + hoover hook + glock octave-double (hippy only) + skrill siren (hazard only) +- **vocal**: optional. jeffrey-pvc as a pitched-up one-shot hook in the hippy drop (the nightcore "chipmunk") + a low spoken line in the hazard. instrumental is valid +- **output**: single mp3 in `out/hippyhayzard.mp3`. no video. ID3 album `pixsies` + +## source → track + +one AC vision per track, same as the other lanes — compressed to a *euphoric hook that can turn dark*, not a rap line or a trance theme. + +``` +papers/arxiv-/.tex + → pop/hippyhayzard/.txt (hook phrase / hazard spoken line, if any) + → pop/hippyhayzard/.np (notepat score: hippy theme + hazard variant + markers) + → pop/hippyhayzard/out/.mp3 (mix) +``` + +the `.np` carries the **hoover hook** as primary content (syllable-free pitch events). the hazard section is the same theme in the relative minor — scored as its own marked block, not a separate melody. + +## arrangement notation + +section markers as comments, so `bin/render.mjs` knows when to switch engine + feel: + +``` +# build 8 [glock-only, riser, no-kick] +69:- 76:- 73:- 69:- ... + +# hippy 16 [4-floor, reverse-bass, hoover, glock-oct, swing16] +69 76 73 69 71 74 76 ... + +# hazard 8 [half-time, minor, skrill-siren, glock-out, swing8] +54:- 50:- 45:- 49:- ... + +# hippy 16 [full] +... +``` + +bracket flags tell the renderer which voices/feel to wire per section. unflagged sections inherit their block-type default. + +## the one new voice + +this lane needed a **hoover** — the Roland Alpha Juno-2 "What The..." patch (Second Phase *Mentasm*, 1991): a detuned saw+pulse stack with PWM, a resonant honk, and the signature pitch **whoop**. it can't be a fan-out of plain `sound.synth()` voices (the whoop + PWM aren't in the AC voice contract), so it hand-rolls per-sample DSP like `skrill` does. + +three new bottom-up voices, all pure-float per-sample DSP (C-portable for an eventual ac-native port): + +- `synths/hoover.mjs` — presets `whoop` / `stab` / `hazard` / `pad` +- `synths/zitar.mjs` — synthesized **sitar**: Karplus-Strong + jawari bridge buzz + sympathetic-string (taraf) bank. presets `sitar` / `lead` / `drone` / `dry`. (C-port path: extend `fedac/native/src/audio.c` `generate_harp_sample`.) +- reused: `../dance/synths/skrill.mjs` (FM + swept-formant talking bass — the hazard siren *and* the harmonized counter-line + growl bass), `../dance/synths/sinepower.mjs` (clean sine stack → glock bells / soft pad). + +## pipeline + +`bin/render.mjs` — node buffer-mix, no Web Audio, every sample synthesized here. modes: `arc` (origin demo), `harmony` (Bach demo), **`song`** (the 1:28 ballad → `out/hippyhayzard.mp3`). inline drum synthesis + per-kick sidechain duck. + +`bin/sing.mjs` — the ballad-tuned jeffrey-pvc vocal pipeline (say → whisper align → WORLD pitch to `hippyhayzard.np` → rubberband stretch → intimate vocal-forward mix onto the bed → `out/hippyhayzard-song.mp3`). lyric in `hippyhayzard.txt`, score in `hippyhayzard.np` (word order/count must match). + +``` +node pop/hippyhayzard/bin/render.mjs --mode song # → out/hippyhayzard.mp3 (bed) +node pop/hippyhayzard/bin/sing.mjs # → out/hippyhayzard-song.mp3 (sung) +``` + +media (beds, finals, the billable vocal stem) are backed to the assets system, not git — see [../ASSETS.md](../ASSETS.md). + +## tracks + +- **hippyhayzard** — the eponymous track. earthbound/mother sorrow-ballad, 152 BPM half-time, A major / F♯ minor, 16-bar Bach chorale, 1:28 with a break. status: first sung cut rendered 2026-05-19 (jeffrey-pvc on the Bach bed, `loudnorm`-mastered — true-peak resolved). open refinement: bar-locking the sung stem to the bed's section grid (the break/refrain alignment). see [STUDY.md](STUDY.md) for the genre study + the blend law. + +--- + +*maintained by @jeffrey* diff --git a/pop/hippyhayzard/STUDY.md b/pop/hippyhayzard/STUDY.md new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/STUDY.md @@ -0,0 +1,116 @@ +# study · how to make a good hippyhayzard track + +**Last updated**: 2026-05-19 +**Author**: @jeffrey + +study-first, same as [`../dance/STUDY.md`](../dance/STUDY.md). pin down what makes the blend *work* before chasing the next mp3. the first bed (`bin/render.mjs`) exists, but it is a sketch against this document, not the other way round. + +## why this blend (and why a lane of its own) + +happy hardcore and nightcore keep getting filed together — fast, major, pitched-up, hyper-melodic. the nightcore originators literally said they were going for *"'happy hardcore' as they say"*. but one is a *genre with its own canon* and the other is a *speed/pitch treatment of someone else's song*. averaging them is mush. the lane exists to do the opposite: hold both in tension and **switch** between them as the structural argument — the way the dance lane's argument is breakdown → drop. + +- happy hardcore gives a real engine that can be specified and scored: 4-on-floor, reverse bass, hoover, M1 stabs, ecstatic breakdown. (history: rave sped 135→155 BPM by mid-'93; the *Mentasm* hoover came off a Roland Alpha Juno-2; classic comps run ~160–175 BPM over chunky kicks and Amen breaks — see sources below.) +- nightcore gives a treatment, not a substrate: +20–35% speed, +~3 semitones, melody/vocals pushed bright and high. applied as a *layer* (octave glock doubling, sheen), never as the song — keeping the bottom-up posture intact. +- the **hazard** is what makes it ours: a half-time, relative-minor switch where the `skrill` talking-bass becomes a rave siren. euphoria that can turn. that switch is the genre. + +## the anatomy of a good hippyhayzard track + +non-negotiable for the idiom. drop any one and it stops being this thing. + +### 1. the kick — chunky, not clicky +4-on-the-floor in hippy; **half-time (1 & 3) in hazard**. fundamental ~46–60 Hz with a hard transient. it has to clear before the reverse bass swells. inline-synthesized (sine pitch-drop 132→46 Hz + a 4 ms click). + +### 2. reverse bass — the rave engine +not an off-beat pluck (that's trance). a sub note that **swells in** then is hard-cut by the next kick. sits on the off-beat. without it the four-on-floor reads as house or hardstyle, not happy hardcore. + +### 3. the hoover — the hook +the load-bearing voice. detuned saw+pulse stack, PWM, resonant honk, and the **whoop** (pitch bends down into the note and springs back). if a listener can't hum the hoover hook after one drop, the track failed. `synths/hoover.mjs`, preset `stab` for 16th-note riffs, `whoop` for sustained calls, `hazard` for the dark switch. + +### 4. the glock octave-double — the nightcore sugar +bright bell/M1 layer doubling the hook **+12**. present in hippy, **gone in hazard**. this single on/off is most of the perceived genre-switch. `sinepower` `stab` preset, short decay. + +### 5. swung 16th hats — the "swingy switchez" +hats on a 16th grid with every off-16th delayed ~16%. in hazard the swing widens and thins to sparse 8ths. swing is structural: it is *how* the feel switches, not ear-candy. + +### 6. the hazard switch — the argument +8-bar block: half-time kick, relative minor, glock out, `skrill` siren in (slow descending minor talking-bass), hoover on `hazard`. ~3–4 dB hotter than the drop for impact — a *deliberate* lift, never a mix accident (an early render had it +7 dB; that's the failure mode to watch). resolve with a riser back into hippy. + +### 7. sidechain pump +every kick ducks the melodic bus ~0.42 for ~160 ms. the track breathes. same law as trance, faster. + +## the blend law + +``` +build 8 bars glock hook only + riser, no kick (nightcore, exposed) +hippy 1 16 bars full engine, glock-doubled (happy hardcore) +HAZARD 8 bars half-time, minor, skrill siren, glock out (the switch) +hippy 2 16 bars full engine (return, brighter) +build 2 4 bars short re-lift +HAZARD 2 8 bars the switch, varied +hippy 3 16 bars final drop +outro 4 bars +``` + +at 174 BPM this 80-bar radio edit ≈ **1:30**, inside the `pop/` spec. the listener must be able to hear the hippy drop alone and the hazard alone and recognize the *same theme* — one bright, one turned. that recognition is the whole track. + +## key, scale, emotional center + +- **major** for hippy — A major default. happy hardcore is not melancholy-resolving (that's trance); it is already euphoric and the lyric/theme rides that +- **relative or parallel minor** for hazard — F# minor (relative) reads as "same world, gone wrong"; A minor (parallel) reads as a harder slam. default relative; parallel for the heavier track +- simple looped progression, I–V–vi–IV in major flipping to i–VI–III–VII in the hazard. four chords. the simplicity is the point +- avoid: jazz chords, modal noodling, a *tempo* change at the switch (it's a *feel* change — keep 174) + +## vocal posture for jeffrey-pvc + +instrumental is valid and the fastest finish. when present, in order of difficulty: + +1. **pitched-up one-shot hook** (recommended first) — a 3–6 word phrase in the hippy drop, pitch-shifted up ~3 semitones (the literal nightcore move), heavy reverb. e.g. *"the computer is bright"* +2. **low hazard spoken line** — one slow line in the hazard, jeffrey-pvc's calm register, *not* pitched up. the contrast with (1) is the point +3. **sung hook** (hardest) — jeffrey-pvc sings the hoover hook, autotuned, per-syllable corrected (existing `vocal-post.mjs`). save for track 2+ + +start with 1 + 2: a bright chipmunk hook in hippy, a dark spoken line in hazard. same voice, switched — mirrors the music's own argument. + +## what to compose vs. what already exists + +| component | status | notes | +|---|---|---| +| hoover | ✅ built | `synths/hoover.mjs`, this lane's gating voice | +| skrill siren | ✅ built | `../dance/synths/skrill.mjs`, the hazard siren | +| glock bells | ✅ exists | `../dance/synths/sinepower.mjs` `stab` +12 | +| kick / snare / hat / reverse-bass / riser | ✅ inline | synthesized in `bin/render.mjs` | +| sidechain pump | ✅ built | per-kick duck on the melodic bus in `render.mjs` | +| `.np` → render wiring | 🛠 to build | renderer currently hardcodes the arrangement; should read `hippyhayzard.np` markers | +| true-peak limiter | 🛠 to build | demo decode-peaks ~1.09; needs a TP-safe limiter before release | +| jeffrey-pvc vocal | ⬜ not started | pitched-up hippy hook + low hazard line | + +## next steps + +1. land this study + the lane README — done in this commit +2. wire `bin/render.mjs` to actually parse `hippyhayzard.np` section markers instead of the hardcoded `SECTIONS` array +3. extend the arrangement to the full 80-bar radio edit (the demo is a 20-bar proof) +4. true-peak limiter on the master (the one real release blocker) +5. pick the first AC vision to compress into a hippyhayzard hook (candidate: the same vision the dance lane takes, to A/B euphoric-switch vs trance-arc on one source) +6. vocal pass — pitched-up one-shot hook + low hazard line, WhisperX align, duck under the kick + +## open questions + +- does the hazard want the *relative* (F# minor) or *parallel* (A minor) minor by default? relative is more musical, parallel is more brutal. demo uses relative — revisit per track +- glock as `sinepower` is clean but not very "bell". a short FM ratio-2 bell voice might read more M1. worth a `synths/glock.mjs` or a `sinepower` bell preset? +- the hoover whoop is a fixed down-up shape. real Alpha Juno hoovers vary the bend per player. parameterize whoop curve per `.np` note? +- nightcore is *defined* by being a remix. is an original "nightcore layer" honest, or is the octave-glock-doubling the only defensible nightcore gesture for a bottom-up lane? (current answer: the doubling + brightness is the gesture; speeding up someone else's song is not available to us and that's fine) + +## sources + +genre research, third-party, references only (not committed): + +- [History of happy hardcore and UK hardcore — toucanmusic](https://www.toucanmusic.com/articles/ukhardcore/) +- [Breakbeat hardcore — Wikipedia](https://en.wikipedia.org/wiki/Breakbeat_hardcore) +- [9 tips & production tricks for rave revivalists — MusicRadar](https://www.musicradar.com/tuition/tech/9-tips-and-production-tricks-for-rave-revivalists-636301) +- [Happy Hardcore — Melodigging](https://www.melodigging.com/genre/happy-hardcore) +- [Nightcore — Wikipedia](https://en.wikipedia.org/wiki/Nightcore) +- [What is Nightcore Music — Pond5](https://blog.pond5.com/80716-what-is-nightcore-music/) +- [Exploring Nightcore: sound, speed, appeal — SoundCy](https://soundcy.com/article/what-does-nightcore-sound-like) + +--- + +*maintained by @jeffrey — update when the lane proves or pivots* diff --git a/pop/hippyhayzard/bin/render.mjs b/pop/hippyhayzard/bin/render.mjs new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/bin/render.mjs @@ -0,0 +1,578 @@ +#!/usr/bin/env node +// render.mjs — the hippyhayzard bed builder. Mirrors the recap/bin +// trance.mjs / trap.mjs cli shape: read the arrangement, mix the bed in +// node from AC instruments, write one mp3. No Web Audio, no Suno — +// every sample is synthesized here (bottom-up posture, pop/SCORE.md). +// +// The lane is "happy hardcore × nightcore", blended intelligently: +// • HIPPY — bright major euphoria: chunky 4-on-floor, reverse bass, +// the hoover hook, glock/M1-bell doubling an octave up +// (the nightcore sugar), swung 16th hats. +// • HAZARD — the swingy switch: drop to half-time + relative minor, +// kill the glock, bring the skrill in as a rave siren / +// talking-bass. Same theme, dark. Then spring back to hippy. +// +// Voices: ../synths/hoover.mjs (new, gating), ../../dance/synths/ +// skrill.mjs (the talking bass we built), ../../dance/synths/ +// sinepower.mjs (clean sine stack → glock bells). Drums are synthesized +// inline (kick / reverse-bass / snare / swung hats / riser). +// +// node pop/hippyhayzard/bin/render.mjs # → out/hippyhayzard.mp3 +// node pop/hippyhayzard/bin/render.mjs --bpm 172 --out ~/hh.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 { mixEventHoover } from "../synths/hoover.mjs"; +import { mixEventZitar } from "../synths/zitar.mjs"; +import { mixEventSkrill } from "../../dance/synths/skrill.mjs"; +import { mixEventSinePower } from "../../dance/synths/sinepower.mjs"; + +const SR = 48_000; +const HERE = dirname(fileURLToPath(import.meta.url)); + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const k = a.slice(2), n = argv[i + 1]; + if (n !== undefined && !n.startsWith("--")) { flags[k] = n; i++; } else flags[k] = true; + } +} +function expandHome(p) { + if (!p) return p; + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +const BPM = Number(flags.bpm ?? 174); +const beat = 60 / BPM; +const bar = beat * 4; +const sx = beat / 4; // one 16th note +const SWING = 0.16; // delay every off-16th by 16% (the "swingy") + +// ── inline drum synthesis ────────────────────────────────────────────── +function add(buf, idx, v) { if (idx >= 0 && idx < buf.length) buf[idx] += v; } + +function kick(buf, startSec, g = 1.0) { + const dur = 0.20, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); + let ph = 0; + for (let i = 0; i < n; i++) { + const t = i / SR; + const f = 46 + (132 - 46) * Math.exp(-t * 40); // chunky pitch drop + ph += (2 * Math.PI * f) / SR; + const body = Math.sin(ph) * Math.exp(-t * 15); + const click = i < SR * 0.004 ? (Math.random() * 2 - 1) * 0.7 * (1 - i / (SR * 0.004)) : 0; + add(buf, s0 + i, (body + click) * g); + } +} + +function snare(buf, startSec, g = 0.8) { + const dur = 0.16, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); + let prev = 0, ph = 0; + for (let i = 0; i < n; i++) { + const t = i / SR; + const nz = Math.random() * 2 - 1; + const hp = nz - prev; prev = nz; // crude high-pass + ph += (2 * Math.PI * 185) / SR; + const tone = Math.sin(ph) * Math.exp(-t * 28) * 0.5; + add(buf, s0 + i, (hp * Math.exp(-t * 22) + tone) * g); + } +} + +function hat(buf, startSec, g = 0.32, len = 0.028) { + const n = Math.floor(len * SR), s0 = Math.floor(startSec * SR); + let prev = 0; + for (let i = 0; i < n; i++) { + const nz = Math.random() * 2 - 1; + const hp = nz - prev; prev = nz; + add(buf, s0 + i, hp * Math.exp(-(i / SR) * 220) * g); + } +} + +// Reverse bass: swells IN then is cut by the next kick — the rave +// off-beat engine. One note per off-beat, A1-ish. +function reverseBass(buf, startSec, durSec, midi, g = 0.7) { + const n = Math.floor(durSec * SR), s0 = Math.floor(startSec * SR); + const f = 440 * Math.pow(2, (midi - 69) / 12); + let ph = 0; + for (let i = 0; i < n; i++) { + const w = i / n; + const env = Math.pow(w, 1.7) * (1 - Math.pow(w, 8)); // ramp up, hard cut + ph += (2 * Math.PI * f) / SR; + const saw = (((ph / (2 * Math.PI)) % 1) * 2 - 1); + add(buf, s0 + i, (Math.sin(ph) * 0.6 + saw * 0.4) * env * g); + } +} + +function riser(buf, startSec, durSec, g = 0.5) { + const n = Math.floor(durSec * SR), s0 = Math.floor(startSec * SR); + let lp = 0; + for (let i = 0; i < n; i++) { + const w = i / n; + const nz = Math.random() * 2 - 1; + const a = 0.02 + 0.5 * w; // open the filter + lp += a * (nz - lp); + add(buf, s0 + i, (nz - lp) * Math.pow(w, 1.5) * g); // rising hiss + } +} + +// ── the arrangement (the .np mirror) ─────────────────────────────────── +// A-major "hippy" hook, 2-bar phrase = 32 sixteenths: [midi, 16ths]. +const HOOK = [ + [69, 2], [76, 2], [73, 2], [69, 2], [71, 2], [74, 2], [76, 4], + [78, 2], [76, 2], [73, 2], [69, 2], [71, 2], [69, 2], [64, 4], +]; + +// Diatonic harmonizer in A major — keeps the skrill consonant with the +// hoover instead of a clashing low siren. `steps` in scale degrees +// (-2 = a diatonic third below). +const AMAJ = [0, 2, 4, 5, 7, 9, 11]; +function diatonic(midi, steps) { + const semi = midi - 69; + const octs = Math.floor(semi / 12); + const pc = semi - octs * 12; + let idx = AMAJ.indexOf(pc); + if (idx < 0) { idx = 0; for (let k = 0; k < AMAJ.length; k++) if (AMAJ[k] <= pc) idx = k; } + const pos = octs * 7 + idx + steps; + const nOct = Math.floor(pos / 7); + const nIdx = ((pos % 7) + 7) % 7; + return 69 + nOct * 12 + AMAJ[nIdx]; +} + +// ── another demo: skrill harmonized + in right away + up in register ─── +const MODE = flags.mode || "arc"; +if (MODE === "harmony") { + // slower + held skipaloo whoop + growl bass + low long-decay zitar + // drone + a 16-bar Bach-ish chorale progression in A. + const BPMH = 152; + const hbeat = 60 / BPMH, hbar = hbeat * 4, hsx = hbeat / 4; + const PHRASE = 16; + const BARS = 38; // ≈ 60s at 152 BPM (phrase looped) + const Nh = Math.ceil((BARS * hbar + 3.0) * SR); // +tail for the long drone + const busH = new Float32Array(Nh), drmH = new Float32Array(Nh), kt = []; + + // Hand-voiced chorale. bass = stepwise chorale bass (the growl bass + + // the low zitar drone an octave under it). pad = 3 mid-register chord + // tones. lead = held/skippy top voice [offBeat, midi, durBeats]. + // I V6 vi iii6 IV I6 ii7 V7 | I V7/IV IV iv6 I64 V7 vi V7 + const PROG = [ + { bass: 45, pad: [57, 61, 64], lead: [[0, 69, 2.5], [2.5, 76, 1.5]] }, // A + { bass: 44, pad: [56, 59, 64], lead: [[0, 71, 4]] }, // E/G# + { bass: 42, pad: [57, 61, 66], lead: [[0, 73, 2.5], [2.5, 69, 1.5]] }, // F#m + { bass: 40, pad: [56, 61, 64], lead: [[0, 76, 4]] }, // C#m/E + { bass: 38, pad: [57, 62, 66], lead: [[0, 78, 2.5], [2.5, 74, 1.5]] }, // D + { bass: 37, pad: [57, 61, 64], lead: [[0, 76, 4]] }, // A/C# + { bass: 35, pad: [57, 62, 66], lead: [[0, 74, 2.5], [2.5, 78, 1.5]] }, // Bm7 + { bass: 40, pad: [56, 59, 62], lead: [[0, 74, 4]] }, // E7 + { bass: 45, pad: [57, 61, 64], lead: [[0, 73, 2.5], [2.5, 69, 1.5]] }, // A + { bass: 43, pad: [57, 61, 67], lead: [[0, 76, 4]] }, // A7/G + { bass: 38, pad: [57, 62, 66], lead: [[0, 78, 2.5], [2.5, 74, 1.5]] }, // D + { bass: 41, pad: [57, 62, 65], lead: [[0, 77, 4]] }, // Dm/F (iv6, F♮) + { bass: 40, pad: [57, 61, 64], lead: [[0, 76, 2.5], [2.5, 73, 1.5]] }, // A/E + { bass: 40, pad: [56, 59, 62], lead: [[0, 74, 4]] }, // E7 + { bass: 42, pad: [57, 61, 66], lead: [[0, 73, 2.5], [2.5, 69, 1.5]] }, // F#m (deceptive) + { bass: 40, pad: [56, 59, 62], lead: [[0, 71, 2], [2, 76, 2]] }, // E7 turnaround + ]; + + // Nearest chord tone a third-or-more below the lead — keeps the skrill + // consonant through every change (incl. the borrowed iv). + function harmUnder(lead, pad) { + let best = null; + for (const base of pad) { + for (let oct = -1; oct <= 2; oct++) { + const c = base + 12 * oct; + if (c <= lead - 3 && (best === null || c > best)) best = c; + } + } + return best === null ? lead - 4 : best; + } + + for (let b = 0; b < BARS; b++) { + const bt = b * hbar; + // loop the 16-bar phrase; final bar resolves to a held tonic A + const ch = b === BARS - 1 + ? { bass: 45, pad: [57, 61, 64], lead: [[0, 69, 4]] } + : PROG[b % PHRASE]; + + // low super-long-decay zitar drone — struck every 2 bars, an octave + // under the chorale bass, washing across the changes (tanpura-ish) + if (b % 2 === 0) { + mixEventZitar( + { startSec: bt, midi: ch.bass - 12, durSec: hbar * 2, gain: 0.32, preset: "drone" }, + busH, { sampleRate: SR, decay: 6.0 }, + ); + } + // growl bass on the chorale bass note + mixEventSkrill( + { startSec: bt, midi: ch.bass, durSec: hbar * 0.96, gain: 0.44, preset: "growl", lfo: "1/4" }, + busH, { sampleRate: SR, bpm: BPMH }, + ); + // chord pad — the Bach harmonic motion, behind everything + for (const pm of ch.pad) { + mixEventSinePower( + { startSec: bt, midi: pm, durSec: hbar * 1.08, gain: 0.13 }, + busH, { sampleRate: SR, preset: "pad" }, + ); + } + // lead voice — held skipaloo whoop + buzzing zitar + skrill 3rd + glock + for (const [offB, m, dB] of ch.lead) { + const t = bt + offB * hbeat, d = dB * hbeat; + mixEventHoover({ startSec: t, midi: m, durSec: d * 0.98, gain: 0.56 }, + busH, { sampleRate: SR, preset: "whoop" }); + mixEventZitar({ startSec: t, midi: m, durSec: d * 1.35, gain: 0.5 }, + busH, { sampleRate: SR, preset: "sitar" }); + mixEventSkrill({ startSec: t, midi: harmUnder(m, ch.pad), durSec: d * 0.98, + gain: 0.36, preset: "growl", lfo: "1/8" }, busH, { sampleRate: SR, bpm: BPMH }); + mixEventSinePower({ startSec: t, midi: m + 12, durSec: d * 0.5, gain: 0.18 }, + busH, { sampleRate: SR, preset: "stab" }); + } + + // drums + for (let q = 0; q < 4; q++) { + kick(drmH, bt + q * hbeat, 1.0); kt.push(bt + q * hbeat); + reverseBass(busH, bt + q * hbeat + hbeat * 0.5, hbeat * 0.5, 33, 0.34); + } + snare(drmH, bt + hbeat, 0.64); snare(drmH, bt + hbeat * 3, 0.64); + for (let e = 0; e < 16; e++) { + const sw = e % 2 === 1 ? SWING * hsx : 0; + if (e % 4 !== 0) hat(drmH, bt + e * hsx + sw, e % 2 ? 0.13 : 0.21); + } + } + const dk = new Float32Array(Nh).fill(1); + for (const k of kt) { + const s0 = Math.floor(k * SR), len = Math.floor(0.16 * SR); + for (let i = 0; i < len && s0 + i < Nh; i++) { + const d = 0.42 + 0.58 * (i / len); + if (d < dk[s0 + i]) dk[s0 + i] = d; + } + } + const mh = new Float32Array(Nh); + let pkh = 0; + for (let i = 0; i < Nh; i++) { mh[i] = Math.tanh((busH[i] * dk[i] + drmH[i]) * 1.05); const a = Math.abs(mh[i]); if (a > pkh) pkh = a; } + if (pkh > 0) { const nrm = 0.8 / pkh; for (let i = 0; i < Nh; i++) mh[i] *= nrm; } + const outH = expandHome(flags.out) || resolve(HERE, "../out/hippyhayzard-harmony.mp3"); + mkdirSync(dirname(outH), { recursive: true }); + const rawH = `${outH}.f32.raw`, bb = Buffer.alloc(Nh * 4); + for (let i = 0; i < Nh; i++) bb.writeFloatLE(mh[i], i * 4); + writeFileSync(rawH, bb); + console.log(`→ hippyhayzard [harmony] · ${BPMH} BPM · ${BARS} bars · ${(BARS * hbar).toFixed(0)}s · bachian · whoop+zitar+skrill3rd+growlbass+low-drone`); + const ffh = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", rawH, + "-c:a", "libmp3lame", "-q:a", "2", outH], { stdio: "inherit" }); + try { unlinkSync(rawH); } catch {} + if (ffh.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } + console.log(`✓ ${outH}`); + process.exit(0); +} + +// ── the 1:28 song: earthbound/mother sorrow-ballad with a break ──────── +if (MODE === "song") { + const BPMH = 152; // ~76 half-time ballad feel + const hbeat = 60 / BPMH, hbar = hbeat * 4, hsx = hbeat / 4; + + // Same 16-bar Bach chorale as the harmony demo (hand-voiced). + const PROG = [ + { bass: 45, pad: [57, 61, 64], lead: [[0, 69, 2.5], [2.5, 76, 1.5]] }, + { bass: 44, pad: [56, 59, 64], lead: [[0, 71, 4]] }, + { bass: 42, pad: [57, 61, 66], lead: [[0, 73, 2.5], [2.5, 69, 1.5]] }, + { bass: 40, pad: [56, 61, 64], lead: [[0, 76, 4]] }, + { bass: 38, pad: [57, 62, 66], lead: [[0, 78, 2.5], [2.5, 74, 1.5]] }, + { bass: 37, pad: [57, 61, 64], lead: [[0, 76, 4]] }, + { bass: 35, pad: [57, 62, 66], lead: [[0, 74, 2.5], [2.5, 78, 1.5]] }, + { bass: 40, pad: [56, 59, 62], lead: [[0, 74, 4]] }, + { bass: 45, pad: [57, 61, 64], lead: [[0, 73, 2.5], [2.5, 69, 1.5]] }, + { bass: 43, pad: [57, 61, 67], lead: [[0, 76, 4]] }, + { bass: 38, pad: [57, 62, 66], lead: [[0, 78, 2.5], [2.5, 74, 1.5]] }, + { bass: 41, pad: [57, 62, 65], lead: [[0, 77, 4]] }, // borrowed iv + { bass: 40, pad: [57, 61, 64], lead: [[0, 76, 2.5], [2.5, 73, 1.5]] }, + { bass: 40, pad: [56, 59, 62], lead: [[0, 74, 4]] }, + { bass: 42, pad: [57, 61, 66], lead: [[0, 73, 2.5], [2.5, 69, 1.5]] }, + { bass: 40, pad: [56, 59, 62], lead: [[0, 71, 2], [2, 76, 2]] }, + ]; + function harmUnder(lead, pad) { + let best = null; + for (const base of pad) for (let o = -1; o <= 2; o++) { + const c = base + 12 * o; + if (c <= lead - 3 && (best === null || c > best)) best = c; + } + return best === null ? lead - 4 : best; + } + + // structure ≈ 1:28: intro 4 · v1 12 · refrain 8 · v2 12 · break 8 · + // refrain 12 → 56 bars × 1.579s ≈ 88s, +tail for the drone ring-out. + const SONG = [ + { kind: "intro", bars: 4 }, + { kind: "verse", bars: 12 }, + { kind: "refrain", bars: 8 }, + { kind: "verse", bars: 12 }, + { kind: "break", bars: 8 }, + { kind: "refrain", bars: 12 }, + ]; + const totBars = SONG.reduce((a, s) => a + s.bars, 0); + const Nh = Math.ceil((totBars * hbar + 5.0) * SR); + const busH = new Float32Array(Nh), drmH = new Float32Array(Nh), kt = []; + + let bar0 = 0; + for (const sec of SONG) { + const full = sec.kind === "refrain"; + const sing = sec.kind === "verse" || sec.kind === "refrain"; + for (let i = 0; i < sec.bars; i++) { + const b = bar0 + i, bt = b * hbar, ch = PROG[b % 16]; + + // low super-long-decay zitar drone — the spine; loud + bare in break + if (i % 2 === 0) { + mixEventZitar( + { startSec: bt, midi: ch.bass - 12, durSec: hbar * 2, + gain: sec.kind === "break" ? 0.42 : 0.26, preset: "drone" }, + busH, { sampleRate: SR, decay: sec.kind === "break" ? 7.5 : 5.5 }, + ); + } + // soft pad everywhere — the Bach motion, quietest in the break + for (const pm of ch.pad) { + mixEventSinePower( + { startSec: bt, midi: pm, durSec: hbar * 1.1, + gain: sec.kind === "break" ? 0.085 : 0.12 }, + busH, { sampleRate: SR, preset: "pad" }, + ); + } + + if (sec.kind !== "break" && sec.kind !== "intro") { + // gentle growl bass on the chorale bass + mixEventSkrill( + { startSec: bt, midi: ch.bass, durSec: hbar * 0.95, + gain: full ? 0.34 : 0.28, preset: "growl", lfo: "1/4" }, + busH, { sampleRate: SR, bpm: BPMH }, + ); + } + + // instrumental lead voice (the sung line tracks these notes via + // the .np). quiet in verses, a touch fuller in refrains. silent + // in the break — that space is for the whispered vocal. + if (sec.kind !== "break") { + for (const [offB, m, dB] of ch.lead) { + const t = bt + offB * hbeat, d = dB * hbeat; + mixEventZitar({ startSec: t, midi: m, durSec: d * 1.35, + gain: sec.kind === "intro" ? 0.42 : full ? 0.46 : 0.34 }, + busH, { sampleRate: SR, preset: "sitar" }); + if (sec.kind !== "intro") { + mixEventHoover({ startSec: t, midi: m, durSec: d * 0.97, + gain: full ? 0.34 : 0.24 }, busH, { sampleRate: SR, preset: "whoop" }); + mixEventSkrill({ startSec: t, midi: harmUnder(m, ch.pad), + durSec: d * 0.97, gain: full ? 0.20 : 0.15, preset: "growl", lfo: "1/8" }, + busH, { sampleRate: SR, bpm: BPMH }); + } + } + } + + // soft half-time kit — only in verse/refrain, never intro/break + if (sing) { + kick(drmH, bt, 0.62); kt.push(bt); + kick(drmH, bt + hbeat * 2, 0.62); kt.push(bt + hbeat * 2); + if (full) snare(drmH, bt + hbeat * 2, 0.34); + for (let e = 0; e < 8; e++) { + const sw = e % 2 === 1 ? SWING * hsx * 1.2 : 0; + if (e % 2 === 0) hat(drmH, bt + e * (hbeat / 2) + sw, full ? 0.12 : 0.09); + } + } + } + bar0 += sec.bars; + } + + // gentle sidechain (ballad — shallow duck), soft master + const dk = new Float32Array(Nh).fill(1); + for (const k of kt) { + const s0 = Math.floor(k * SR), len = Math.floor(0.14 * SR); + for (let i = 0; i < len && s0 + i < Nh; i++) { + const d = 0.62 + 0.38 * (i / len); + if (d < dk[s0 + i]) dk[s0 + i] = d; + } + } + const mh = new Float32Array(Nh); + let pkh = 0; + for (let i = 0; i < Nh; i++) { mh[i] = Math.tanh((busH[i] * dk[i] + drmH[i]) * 1.0); const a = Math.abs(mh[i]); if (a > pkh) pkh = a; } + if (pkh > 0) { const n = 0.78 / pkh; for (let i = 0; i < Nh; i++) mh[i] *= n; } + const outS = expandHome(flags.out) || resolve(HERE, "../out/hippyhayzard.mp3"); + mkdirSync(dirname(outS), { recursive: true }); + const rawS = `${outS}.f32.raw`, bbs = Buffer.alloc(Nh * 4); + for (let i = 0; i < Nh; i++) bbs.writeFloatLE(mh[i], i * 4); + writeFileSync(rawS, bbs); + console.log(`→ hippyhayzard [song] · ${BPMH} BPM · ${totBars} bars · ${(totBars * hbar).toFixed(0)}s · earthbound ballad w/ break (instrumental bed — vocal via pop/dance/bin/sing.mjs)`); + const ffs = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", rawS, + "-c:a", "libmp3lame", "-q:a", "2", outS], { stdio: "inherit" }); + try { unlinkSync(rawS); } catch {} + if (ffs.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } + console.log(`✓ ${outS}`); + process.exit(0); +} + +const SECTIONS = [ + { name: "build", bars: 4, mode: "build" }, + { name: "hippy1", bars: 8, mode: "hippy" }, + { name: "hazard", bars: 4, mode: "hazard" }, + { name: "hippy2", bars: 4, mode: "hippy" }, +]; + +const totalBars = SECTIONS.reduce((a, s) => a + s.bars, 0); +const totalSec = totalBars * bar + 0.6; +const N = Math.ceil(totalSec * SR); + +const bus = new Float32Array(N); // melodic (ducked under kick) +const drm = new Float32Array(N); // drums (not ducked) +const kickTimes = []; + +function playHookInto(t0, opts, durMul, gain) { + let t = t0; + for (const [midi, s16] of HOOK) { + const d = sx * s16 * durMul; + opts.mix({ startSec: t, midi: midi + (opts.oct || 0), durSec: d, gain }, opts); + t += sx * s16; + } + return t; +} + +let cursor = 0; +for (const sec of SECTIONS) { + const t0 = cursor * bar; + const secSec = sec.bars * bar; + + if (sec.mode === "build") { + // glock bells alone (nightcore sugar) + riser + soft swung hats + let tt = t0; + let phr = 0; + while (tt < t0 + secSec - 1e-6 && phr < 2) { + for (const [midi, s16] of HOOK) { + mixEventSinePower( + { startSec: tt, midi: midi + 12, durSec: sx * s16 * 0.9, gain: 0.5 }, + bus, { sampleRate: SR, preset: "stab" }, + ); + tt += sx * s16; + } + phr++; + } + riser(drm, t0, secSec, 0.45); + for (let b = 0; b < sec.bars; b++) { + for (let e = 0; e < 8; e++) { // swung 8ths + const sw = e % 2 === 1 ? SWING * sx : 0; + hat(drm, t0 + b * bar + e * (beat / 2) + sw, 0.18); + } + } + kick(drm, t0 + secSec - sx * 0, 1.0); // one pickup kick + } + + else if (sec.mode === "hippy") { + const reps = Math.round(sec.bars / 2); + let tt = t0; + for (let r = 0; r < reps; r++) { + // hoover stab hook + playHookInto(tt, { mix: mixEventHoover, sampleRate: SR, preset: "stab" }, 0.95, 0.85); + // glock doubling an octave up — the nightcore shimmer; and the + // skrill harmonizing a diatonic third UNDER the hook, in register + // (the locked-in move from the harmony demo). + let g = tt; + for (const [midi, s16] of HOOK) { + mixEventSinePower( + { startSec: g, midi: midi + 12, durSec: sx * s16 * 0.6, gain: 0.42 }, + bus, { sampleRate: SR, preset: "stab" }, + ); + mixEventSkrill( + { startSec: g, midi: diatonic(midi, -2), durSec: sx * s16 * 0.95, + gain: 0.42, preset: "growl", lfo: "1/8" }, + bus, { sampleRate: SR, bpm: BPM }, + ); + g += sx * s16; + } + tt += 2 * bar; + } + for (let b = 0; b < sec.bars; b++) { + const bt = t0 + b * bar; + for (let q = 0; q < 4; q++) { // 4-on-the-floor + kick(drm, bt + q * beat, 1.0); + kickTimes.push(bt + q * beat); + reverseBass(bus, bt + q * beat + beat * 0.5, beat * 0.5, 33, 0.7); + } + snare(drm, bt + beat, 0.7); + snare(drm, bt + beat * 3, 0.7); + for (let e = 0; e < 16; e++) { // swung 16th hats + const sw = e % 2 === 1 ? SWING * sx : 0; + if (e % 4 !== 0) hat(drm, bt + e * sx + sw, e % 2 ? 0.16 : 0.26); + } + } + } + + else if (sec.mode === "hazard") { + // the switch: half-time, relative-minor siren. skrill talking-bass + // descends F#m; hoover holds the dark siren. glock drops out. + const minorRoots = [54, 50, 45, 49]; // F#3 D3 A2 C#3-ish + for (let b = 0; b < sec.bars; b++) { + const bt = t0 + b * bar; + mixEventSkrill( + { startSec: bt, midi: minorRoots[b % minorRoots.length], durSec: bar * 0.95, gain: 0.5, + preset: b % 2 ? "reese" : "growl", lfo: "1/8" }, + bus, { sampleRate: SR, bpm: BPM }, + ); + mixEventHoover( + { startSec: bt, midi: minorRoots[b % minorRoots.length] - 12, durSec: bar * 0.95, gain: 0.38 }, + bus, { sampleRate: SR, preset: "hazard" }, + ); + kick(drm, bt, 1.05); // half-time + kickTimes.push(bt); + kick(drm, bt + beat * 2, 1.05); + kickTimes.push(bt + beat * 2); + snare(drm, bt + beat * 2, 0.8); + for (let e = 0; e < 8; e++) { // sparse swung 8ths + const sw = e % 2 === 1 ? SWING * sx * 1.4 : 0; + if (e % 2 === 0) hat(drm, bt + e * (beat / 2) + sw, 0.14); + } + } + riser(drm, t0 + secSec - bar, bar, 0.4); // lift back to hippy + } + + cursor += sec.bars; +} + +// ── sidechain duck (the pump) + master ───────────────────────────────── +const duck = new Float32Array(N).fill(1); +for (const kt of kickTimes) { + const s0 = Math.floor(kt * SR), len = Math.floor(0.16 * SR); + for (let i = 0; i < len && s0 + i < N; i++) { + const w = i / len; + const d = 0.42 + 0.58 * w; // dip to .42, recover + if (d < duck[s0 + i]) duck[s0 + i] = d; + } +} + +const mix = new Float32Array(N); +for (let i = 0; i < N; i++) { + const s = bus[i] * duck[i] + drm[i]; + mix[i] = Math.tanh(s * 1.05); // gentle glue +} +let peak = 0; +for (let i = 0; i < N; i++) { const a = Math.abs(mix[i]); if (a > peak) peak = a; } +// 0.8 target leaves headroom so lame's inter-sample overshoot stays < 0 dBFS. +if (peak > 0) { const n = 0.8 / peak; for (let i = 0; i < N; i++) mix[i] *= n; } + +const outPath = expandHome(flags.out) || resolve(HERE, "../out/hippyhayzard.mp3"); +mkdirSync(dirname(outPath), { recursive: true }); +const rawPath = `${outPath}.f32.raw`; +const b = Buffer.alloc(N * 4); +for (let i = 0; i < N; i++) b.writeFloatLE(mix[i], i * 4); +writeFileSync(rawPath, b); +console.log( + `→ hippyhayzard · ${BPM} BPM · ${totalBars} bars · ${totalSec.toFixed(1)}s · ` + + `build→hippy→HAZARD→hippy`, +); +const ff = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", rawPath, + "-c:a", "libmp3lame", "-q:a", "2", 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/hippyhayzard/bin/sing.mjs b/pop/hippyhayzard/bin/sing.mjs new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/bin/sing.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// sing.mjs — jeffrey-pvc sung vocal for the hippyhayzard ballad. +// +// Mirrors the proven pop/dance/bin/sing.mjs steps 1-4 (say → align → +// score-pitch → score-stretch) but tuned for an earthbound/mother +// SORROW BALLAD, not a trance hook: +// • ballad TTS settings (higher stability locks jeffrey identity, +// low style = plain/tender, slightly slow) +// • gentle vibrato, slow stretch bpm so held notes really ring +// • step 5 is NOT the trance many-jeffreys racing stack — it's an +// intimate single-voice treatment (soft room + a quiet octave-down +// double) laid over the 1:28 instrumental bed after the intro. +// +// The vocal score is hippyhayzard.np; the lyric is hippyhayzard.txt +// (word order/count parity is required — both derive the same words). +// +// Usage: +// node pop/hippyhayzard/bin/sing.mjs # full sung song +// node pop/hippyhayzard/bin/sing.mjs --force # bypass say cache + +import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const argv = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) { + const k = a.slice(2), n = argv[i + 1]; + if (n !== undefined && !n.startsWith("--")) { flags[k] = n; i++; } else flags[k] = true; + } +} + +const HERE = dirname(fileURLToPath(import.meta.url)); // pop/hippyhayzard/bin +const HH = resolve(HERE, ".."); // pop/hippyhayzard +const POP = resolve(HH, ".."); // pop +const REL = (p) => p.replace(POP + "/", ""); + +const SCORE = `${HH}/hippyhayzard.np`; +const LYRIC = `${HH}/hippyhayzard.txt`; +const OUT_DIR = `${HH}/out`; +const TMP = `${OUT_DIR}/.tmp`; +mkdirSync(TMP, { recursive: true }); + +const VOCAL = `${OUT_DIR}/hippyhayzard-vocal.mp3`; +const VOCAL_ALIGN = `${VOCAL}.alignment.json`; +const WORDS = `${OUT_DIR}/hippyhayzard-vocal-words.json`; +const PITCHED = `${OUT_DIR}/hippyhayzard-pitched.mp3`; +const PITCHED_ALIGN = `${OUT_DIR}/hippyhayzard-pitched-alignment.json`; +const STRETCHED = `${OUT_DIR}/hippyhayzard-stretched.mp3`; +const BED = `${OUT_DIR}/hippyhayzard.mp3`; +const FINAL = resolve(process.cwd(), flags.out || `${OUT_DIR}/hippyhayzard-song.mp3`); +const FORCE = !!flags.force; + +for (const [p, what] of [[SCORE, "score"], [LYRIC, "lyric"]]) { + if (!existsSync(p)) { console.error(`✗ ${what} missing: ${p}`); process.exit(1); } +} +if (!existsSync(BED)) { + console.error(`✗ bed missing: ${BED}\n run: node pop/hippyhayzard/bin/render.mjs --mode song`); + process.exit(1); +} + +function run(cmd, args, cwd = POP, soft = false) { + const r = spawnSync(cmd, args, { cwd, stdio: ["ignore", "inherit", "inherit"] }); + if (r.status !== 0 && !soft) { + console.error(`✗ ${cmd} ${args.slice(0, 3).join(" ")}… failed (exit ${r.status})`); + process.exit(1); + } + return r.status === 0; +} +function step(name, fn) { + const t0 = Date.now(); + process.stdout.write(`▸ ${name}\n`); + fn(); + console.log(` ${((Date.now() - t0) / 1000).toFixed(1)}s`); +} + +console.log(`━━━ hippyhayzard · sing (jeffrey-pvc ballad) ━━━\n`); + +// ── 1. say (jeffrey-pvc TTS, ballad settings, with timestamps) ──────── +step("1 · say (jeffrey-pvc — tender ballad settings)", () => { + const base = ["bin/say.mjs", REL(LYRIC), + "--stability", "0.70", "--similarity", "0.92", "--style", "0.28", + "--speed", "0.92", "--out", REL(VOCAL)]; + const withTs = [...base, "--timestamps"]; + if (FORCE) { base.push("--force"); withTs.push("--force"); } + const r = spawnSync("node", withTs, { cwd: POP, stdio: ["ignore", "inherit", "inherit"] }); + if (r.status === 0) return; + console.log(" ↪ timestamps unavailable — plain TTS + whisper align"); + run("node", base); +}); + +// ── 2. align → words.json ───────────────────────────────────────────── +const lyricWords = readFileSync(LYRIC, "utf8") + .split("\n") + .filter((l) => l.trim() && !/^(verse \d+|hook|intro|outro|bridge|chorus)$/i.test(l.trim())) + .join(" ") + .split(/\s+/).map((w) => w.replace(/[^a-zA-Z0-9]/g, "")).filter(Boolean); +const expected = lyricWords.length; + +step(`2 · align (target ${expected} words)`, () => { + if (existsSync(VOCAL_ALIGN)) { + const a = JSON.parse(readFileSync(VOCAL_ALIGN, "utf8")); + if (Array.isArray(a.words) && a.words.length >= expected * 0.7) { + writeFileSync(WORDS, JSON.stringify(a.words, null, 0)); + console.log(` ✓ ${a.words.length} words from ElevenLabs timestamps`); + return; + } + } + const wargs = ["bin/align.mjs", REL(VOCAL)]; + if (FORCE) wargs.push("--force"); + const ok = run("node", wargs, POP, true); + if (ok && existsSync(WORDS)) { + const ww = JSON.parse(readFileSync(WORDS, "utf8")); + if (ww.length >= expected * 0.7) { console.log(` ✓ ${ww.length} words from whisper`); return; } + } + const probe = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", VOCAL], { encoding: "utf8" }); + const dur = parseFloat(probe.stdout.trim()) || 0; + if (dur <= 0) { console.error("✗ cannot measure vocal duration for fallback alignment"); process.exit(1); } + const span = (dur * 1000) / expected; + writeFileSync(WORDS, JSON.stringify( + lyricWords.map((w, i) => ({ text: w, fromMs: Math.floor(i * span), toMs: Math.floor((i + 1) * span) })), null, 0)); + console.log(` ✓ synthesized ${expected} uniform windows over ${dur.toFixed(1)}s`); +}); + +// ── 3. score-pitch (WORLD f0 → the .np melody, gentle vibrato) ──────── +step("3 · score-pitch (WORLD · gentle ballad vibrato)", () => { + run("node", ["bin/score-pitch.mjs", + "--slug", "hippyhayzard", "--section", "all", + "--score", REL(SCORE), "--vocal", REL(VOCAL), "--words", REL(WORDS), + "--transpose", "0", "--vibrato-hz", "5.0", "--vibrato-cents", "12", + "--out", REL(PITCHED)]); +}); + +// ── 4. score-stretch (hold the notes) ──────────────────────────────── +// 76 BPM made a 117s vocal — 1.5× the 88s bed. 112 BPM packs the same +// note weights into ~80s so the sung line fits inside the 1:28 bed +// while the *4–*8 sustains still ring balladically (a *8 ≈ 4.3s). +step("4 · score-stretch (rubberband · 112 BPM → fits the 1:28 bed)", () => { + run("node", ["bin/score-stretch.mjs", + "--slug", "hippyhayzard", "--section", "all", + "--score", REL(SCORE), "--in", REL(PITCHED), "--alignment", REL(PITCHED_ALIGN), + "--bpm", "112", "--max-stretch", "12.0", "--out", REL(STRETCHED)]); +}); + +// ── 5. intimate treatment + lay over the 1:28 bed ──────────────────── +step("5 · treat + mix onto the bed (after the 4-bar intro)", () => { + const lead = `${TMP}/lead.wav`; + run("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", "-i", STRETCHED, + "-ar", "48000", "-ac", "1", lead]); + + // intro = 4 bars @ 152 BPM half-time = 4 * (60/152*4) ≈ 6.32 s + const introMs = Math.round(4 * (60 / 152 * 4) * 1000); + + // The bed is the length authority — the song ends when the bed's + // resolved tonic + drone tail end, NOT whenever the vocal runs out. + const bp = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", BED], { encoding: "utf8" }); + const bedDur = parseFloat(bp.stdout.trim()) || 93; + const fadeStart = Math.max(0, bedDur - 2.5); + + // VOCAL-FORWARD mix. The earlier take buried + garbled the words: + // the octave-down double smeared consonants and the long reverb + // (fb 0.78) washed them out, under too-loud instruments. Now: + // • no double — single clean voice + // • near-dry (tiny 18ms slap only), so consonants survive + // • compress + presence EQ (+4dB ~3kHz) so jeffrey sits on top + // • bed cut to 0.42 AND hard-ducked (ratio 8) under the voice + const fc = + `[1:a]highpass=f=95,` + + `acompressor=threshold=-22dB:ratio=4:attack=8:release=180:makeup=4:knee=3,` + + `equalizer=f=3000:t=q:w=1.4:g=4,equalizer=f=200:t=q:w=1.2:g=-2,` + + `aecho=0.6:0.3:18:0.07,volume=1.5,` + + `adelay=${introMs}|${introMs}[vox];` + + `[vox]asplit=2[voxM][voxK];` + + `[0:a]volume=0.42[bedlo];` + + `[bedlo][voxK]sidechaincompress=threshold=0.02:ratio=8:attack=8:release=300:makeup=1[bedd];` + + `[bedd][voxM]amix=inputs=2:duration=longest:normalize=0,` + + `atrim=duration=${bedDur.toFixed(3)},` + + `afade=t=out:st=${fadeStart.toFixed(3)}:d=2.5,` + + `loudnorm=I=-14:TP=-1.0:LRA=11,aresample=48000[out]`; + + run("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", BED, "-i", lead, + "-filter_complex", fc, "-map", "[out]", + "-c:a", "libmp3lame", "-q:a", "2", FINAL]); +}); + +mkdirSync(dirname(FINAL), { recursive: true }); +const probe = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", FINAL], { encoding: "utf8" }); +const sz = (readFileSync(FINAL).length / 1024).toFixed(0); +console.log(`\n✓ ${FINAL} (${sz} KB · ${parseFloat(probe.stdout || 0).toFixed(1)}s · jeffrey-pvc sung ballad on the bed)`); +copyFileSync(FINAL, `${OUT_DIR}/hippyhayzard-song.mp3`); diff --git a/pop/hippyhayzard/hippyhayzard.np b/pop/hippyhayzard/hippyhayzard.np new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/hippyhayzard.np @@ -0,0 +1,45 @@ +# hippyhayzard — earthbound/mother sorrow-ballad. vocal score for +# jeffrey-pvc. notes in midi 56-69 (his singable range, per +# pop/dance/bin/sing.mjs), A-major chord tones so the sung line stays +# consonant against the Bach changes in bin/render.mjs --mode song. +# notation: NOTE:syllable*beats ('-' continues a held/melisma word) +# WORD ORDER strictly matches pop/hippyhayzard/hippyhayzard.txt. +# musical refrain/break live in the BED arrangement; the parser only +# segments by `verse N`, so the whole lyric is split across two verses. + +verse 1 +# verse 1 — everywhere people say / it will be okay / i dont always +# hear it / but i keep it anyway +E4:eve-*1 F#4:-ry-*1 E4:-where*2 +D4:peo-*1 D4:-ple*1 +E4:say*4 +A3:it*1 B3:will*1 C#4:be*2 +D4:o-*1 C#4:-kay*4 +E4:i*1 E4:dont*1 +D4:al-*1 C#4:-ways*2 +B3:hear*1 A3:it*3 +A3:but*1 B3:i*1 C#4:keep*1 D4:it*1 +C#4:a-*1 B3:-ny-*1 A3:-way*5 +# refrain — i'll come home one day (×2) +A4:ill*1 G#4:come*1 F#4:home*2 E4:one*1 F#4:day*4 +A4:ill*1 G#4:come*1 F#4:home*2 E4:one*1 A4:day*6 + +verse 2 +# verse 2 — the light stays on the landing / a small coin i dont pay / +# i save it for the doorway / for the long way home someday +E4:the*1 E4:light*1 F#4:stays*1 E4:on*1 D4:the*1 +E4:land-*1 D4:-ing*3 +C#4:a*1 C#4:small*1 E4:coin*1 D4:i*1 C#4:dont*1 +B3:pay*4 +A3:i*1 B3:save*1 C#4:it*1 D4:for*1 E4:the*1 +D4:door-*1 C#4:-way*3 +A3:for*1 B3:the*1 C#4:long*1 D4:way*1 E4:home*1 +D4:some-*1 C#4:-day*5 +# break — it will be okay (whispered, soft, long) +D4:it*2 C#4:will*2 A3:be*2 +A3:o-*2 G#3:-kay*8 +# final refrain — i'll come home one day / it will be okay / +# i'll come home one day +A4:ill*1 G#4:come*1 F#4:home*2 E4:one*1 F#4:day*4 +A3:it*1 C#4:will*1 E4:be*2 D4:o-*1 C#4:-kay*4 +A4:ill*1 G#4:come*2 F#4:home*3 E4:one*2 A4:day*8 diff --git a/pop/hippyhayzard/hippyhayzard.txt b/pop/hippyhayzard/hippyhayzard.txt new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/hippyhayzard.txt @@ -0,0 +1,8 @@ +verse 1 +everywhere. people. say. it. will. be. okay. i. dont. always. hear. it. but. i. keep. it. anyway. +ill. come. home. one. day. ill. come. home. one. day. + +verse 2 +the. light. stays. on. the. landing. a. small. coin. i. dont. pay. i. save. it. for. the. doorway. +for. the. long. way. home. someday. it. will. be. okay. +ill. come. home. one. day. it. will. be. okay. ill. come. home. one. day. diff --git a/pop/hippyhayzard/synths/hoover.mjs b/pop/hippyhayzard/synths/hoover.mjs new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/synths/hoover.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node +// hoover.mjs — the "hoover": the Roland Alpha Juno-2 "What The..." +// patch (Second Phase "Mentasm", 1991) that every rave / happy-hardcore +// track is legally required to contain. The gating new voice for the +// hippyhayzard lane, same contract shape as ../../dance/synths/skrill.mjs. +// +// Like skrill, the hoover CANNOT be a fan-out of plain sound.synth() +// voices — its identity is three things the AC voice contract (bus.mjs) +// has no slot for: +// +// 1. a detuned SAW+PULSE stack with PWM (pulse width swept slowly) — +// the buzzy chorused body. +// 2. the WHOOP — a per-note pitch envelope that bends down into the +// note and springs back up. Without the whoop it is just a saw +// stack; the whoop is the hoover. +// 3. a resonant low-pass (SVF) giving the honky vowel-ish formant. +// +// So it hand-rolls per-sample DSP straight into the render buffer, then +// soft-drives for the chunky rave grit. +// +// Library: +// import { mixEventHoover, renderHoover, HOOVER_PRESETS } from "..."; +// mixEventHoover(ev, out, opts) // node bed render path +// renderHoover(ev, opts) -> Float32 // bare DSP engine +// +// CLI demo: +// node pop/hippyhayzard/synths/hoover.mjs # whoop preset +// node pop/hippyhayzard/synths/hoover.mjs --preset stab --bpm 174 +// node pop/hippyhayzard/synths/hoover.mjs --preset hazard --out ~/h.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"; + +const DEFAULT_SAMPLE_RATE = 48_000; +const DEFAULT_PRESET = "whoop"; +const DEFAULT_BPM = 174; + +// ── presets ──────────────────────────────────────────────────────────── +// voices : detuned saw count (PWM pulse is added on top) +// detune : ± cents spread across the saw stack +// pulseGain : square/PWM layer level (the buzz) +// pwmRate : Hz, pulse-width sweep rate +// whoop : [startSemitones, settleSec] — pitch bends from start →0 +// over settleSec then a small overshoot up (the rave whoop) +// vibRate/Depth : post-whoop pitch vibrato (cents) +// cutoff : SVF low-pass base (× note freq, clamped) — the honk +// q : resonance +// drive : tanh grit +// subGain : clean sine sub for chunk +export const HOOVER_PRESETS = { + // Classic Mentasm hoover: big down-whoop, fat, mid honk. + whoop: { + voices: 5, detune: 22, pulseGain: 0.55, pwmRate: 1.3, + whoop: [-7, 0.11], vibRate: 5.5, vibDepth: 14, + cutoff: 7.0, q: 4.5, drive: 2.0, subGain: 0.34, + attack: 0.004, decay: 0.16, + }, + // Tight stab — short whoop, plucky, for 16th-note rave riffs. + stab: { + voices: 5, detune: 18, pulseGain: 0.5, pwmRate: 2.0, + whoop: [-4, 0.05], vibRate: 6.0, vibDepth: 8, + cutoff: 8.5, q: 4.0, drive: 2.2, subGain: 0.28, + attack: 0.003, decay: 0.09, + }, + // Hazard siren — deep slow whoop, narrow honk, the dark switch. + hazard: { + voices: 7, detune: 30, pulseGain: 0.7, pwmRate: 0.7, + whoop: [-12, 0.30], vibRate: 4.0, vibDepth: 26, + cutoff: 5.0, q: 6.5, drive: 2.8, subGain: 0.5, + attack: 0.010, decay: 0.40, + }, + // Sustained chord pad — almost no whoop, slow PWM, long tail. + pad: { + voices: 7, detune: 26, pulseGain: 0.4, pwmRate: 0.4, + whoop: [-1.5, 0.25], vibRate: 4.5, vibDepth: 6, + cutoff: 6.0, q: 3.0, drive: 1.4, subGain: 0.4, + attack: 0.20, decay: 1.6, + }, +}; + +// ── 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; + }; +} + +function buildDetune(voices, cents) { + const t = new Float64Array(voices); + const half = (voices - 1) / 2; + for (let i = 0; i < voices; i++) { + t[i] = ((i - half) / Math.max(1, half)) * cents; + } + return t; +} + +// ── the DSP engine ───────────────────────────────────────────────────── +export function renderHoover(ev, opts = {}) { + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const presetName = ev.preset || opts.preset || DEFAULT_PRESET; + const P = { ...(HOOVER_PRESETS[presetName] || HOOVER_PRESETS[DEFAULT_PRESET]), ...opts.params }; + + if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) { + return new Float32Array(0); + } + const gain = Number.isFinite(ev.gain) ? ev.gain : 1.0; + if (gain === 0) return new Float32Array(0); + + const attack = opts.attack ?? P.attack ?? 0.004; + const decay = opts.decay ?? P.decay ?? 0.16; + const durS = Math.ceil(ev.durSec * sampleRate); + const attS = Math.max(1, Math.floor(attack * sampleRate)); + const decS = Math.max(1, Math.floor(decay * sampleRate)); + const ns = Math.max(durS, attS + decS); + const decayStart = ns - decS; + const out = new Float32Array(ns); + + const baseF = midiToFreq(ev.midi); + const voices = P.voices ?? 5; + const detune = buildDetune(voices, P.detune ?? 22); + const sawPhase = new Float64Array(voices); + const rng = makeRng(`hoover:${presetName}:${ev.midi}:${(ev.startSec ?? 0).toFixed(4)}`); + for (let v = 0; v < voices; v++) sawPhase[v] = rng(); + + let pulPhase = rng(), pwmPhase = rng(), subPhase = 0, vibPhase = rng(); + let sLow = 0, sBand = 0; + const damp = 1 / Math.max(0.5, P.q ?? 4.5); + const [whoopSemi, whoopSec] = P.whoop ?? [-7, 0.11]; + const whoopSamp = Math.max(1, Math.floor(whoopSec * sampleRate)); + const ampNorm = 1 / Math.max(1, voices); + const TWO_PI = 2 * Math.PI; + + for (let i = 0; i < ns; i++) { + let env; + if (i < attS) env = i / attS; + else if (i < decayStart) env = 1; + else { env = 1 - (i - decayStart) / decS; if (env <= 0) break; } + + // ── the WHOOP: pitch bends from whoopSemi → 0 with a small spring + // overshoot, then settles into post-whoop vibrato. ── + let bendSemi; + if (i < whoopSamp) { + const w = i / whoopSamp; // 0..1 + // ease-out + a touch of overshoot past 0 (the spring up) + bendSemi = whoopSemi * (1 - w) + 1.2 * Math.sin(Math.PI * w) * (w > 0.6 ? 1 : 0); + } else { + bendSemi = 0; + } + vibPhase += (P.vibRate ?? 5.5) / sampleRate; if (vibPhase >= 1) vibPhase -= 1; + const vibSemi = (Math.sin(TWO_PI * vibPhase) * (P.vibDepth ?? 14)) / 100; + const pitchMul = Math.pow(2, (bendSemi + vibSemi) / 12); + + // ── detuned saw stack ── + let saw = 0; + for (let v = 0; v < voices; v++) { + const f = baseF * Math.pow(2, detune[v] / 1200) * pitchMul; + sawPhase[v] += f / sampleRate; if (sawPhase[v] >= 1) sawPhase[v] -= 1; + saw += 2 * sawPhase[v] - 1; + } + saw *= ampNorm; + + // ── PWM pulse layer (the buzz) ── + const f0 = baseF * pitchMul; + pulPhase += f0 / sampleRate; if (pulPhase >= 1) pulPhase -= 1; + pwmPhase += (P.pwmRate ?? 1.3) / sampleRate; if (pwmPhase >= 1) pwmPhase -= 1; + const width = 0.5 + 0.42 * Math.sin(TWO_PI * pwmPhase); // 0.08..0.92 + const pulse = (pulPhase < width ? 1 : -1) * (P.pulseGain ?? 0.55); + + let voice = saw + pulse; + + // ── resonant low-pass (Chamberlin SVF) — the honk ── + const fc = Math.min(Math.max(80, baseF * (P.cutoff ?? 7)), sampleRate / 6); + const k = 2 * Math.sin(Math.PI * fc / sampleRate); + const hi = voice - sLow - damp * sBand; + sBand += k * hi; + sLow += k * sBand; + let s = sLow + sBand * 0.3; // LP + a little band sheen + + // ── grit + clean sub ── + s = Math.tanh(s * (P.drive ?? 2.0)); + subPhase += f0 * 0.5 / sampleRate; if (subPhase >= 1) subPhase -= 1; + const sub = Math.sin(TWO_PI * subPhase) * (P.subGain ?? 0.34); + + out[i] = (s * 0.7 + sub) * env * gain; + } + return out; +} + +// ── node-side buffer mixer ───────────────────────────────────────────── +export function mixEventHoover(ev, out, opts = {}) { + if (!(out instanceof Float32Array)) return; + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const seg = renderHoover(ev, { ...opts, sampleRate }); + const startIdx = Math.floor((ev.startSec ?? 0) * sampleRate); + for (let i = 0; i < seg.length; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= out.length) continue; + out[dst] += seg[i]; + } +} + +// ── 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 SR = 48_000; + const preset = flags.preset || DEFAULT_PRESET; + const bpm = Number(flags.bpm ?? DEFAULT_BPM); + const outPath = expandHome(flags.out) || resolve(HERE, `hoover-${preset}.mp3`); + + // A bright major rave riff so the whoop + honk are obvious. + const beat = 60 / bpm; + const sx = beat / 4; // 16th + // A major pentatonic-ish hoover hook (the "hippy" euphoric side). + const seq = [ + [69, 2], [76, 2], [74, 2], [69, 2], + [72, 2], [76, 4], [81, 2], + [79, 2], [76, 2], [74, 4], [69, 2], + ]; + let t = 0; + const events = []; + for (const [midi, sixteenths] of seq) { + events.push({ startSec: t, midi, durSec: sx * sixteenths * 0.95, gain: 0.95, preset }); + t += sx * sixteenths; + } + const total = t + 0.5; + const out = new Float32Array(Math.ceil(total * SR)); + console.log(`→ hoover demo · preset=${preset} · bpm=${bpm} · ${seq.length} notes`); + for (const ev of events) mixEventHoover(ev, out, { sampleRate: SR, bpm, preset }); + + 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 n = 0.85 / peak; for (let i = 0; i < out.length; i++) out[i] *= n; } + + 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(SR), "-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/hippyhayzard/synths/zitar.mjs b/pop/hippyhayzard/synths/zitar.mjs new file mode 100644 --- /dev/null +++ b/pop/hippyhayzard/synths/zitar.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +// zitar.mjs — the "zitar": a synthesized sitar. Plucked Karplus-Strong +// string + the jawari bridge buzz + a sympathetic-string (taraf) +// resonator halo. Same module shape as ../synths/hoover.mjs and +// ../../dance/synths/skrill.mjs. +// +// WHY SYNTHESIZED, not a Freesound/sampled sitar: the pop/ posture is +// bottom-up (SCORE.md) — no top-down sampling, same reason the jungle +// lane synthesizes its break instead of looping the Amen. So this is a +// model, grounded in known research: +// +// • Karplus & Strong (1983), Jaffe & Smith EKS (1983) — the plucked +// string. AC already ships this as the `harp` voice, mirrored in +// JS (lib/sound/synth.mjs #harpBuf) AND C (fedac/native/src/ +// audio.c generate_harp_sample). The KS core below uses the SAME +// math + constants so JS↔C parity holds for the ac-native port. +// • The jawari (curved bone bridge): the string repeatedly buzzes +// against the bridge, an amplitude-dependent nonlinearity that +// also slightly shortens the effective delay (a signal-dependent +// fractional delay — Välimäki et al). That buzz IS the sitar. +// • Taraf (sympathetic strings) + chikari (drone): a bank of tuned +// resonators ringing along under the played string. STK's Sitar +// (Cook/Scavone) is the canonical FOSS model of all this. +// +// C-PORT PATH (ac-native): extend fedac/native/src/audio.c next to +// generate_harp_sample() → generate_zitar_sample(): same delay line, +// add the jawari foldback + delay-mod + a fixed taraf SVF bank. The +// JS mirror would live beside the `harp` case in synth.mjs. Every op +// below is pure float (no JS-only constructs) for exactly that reason. +// +// Library: +// import { mixEventZitar, renderZitar, ZITAR_PRESETS } from "..."; +// mixEventZitar(ev, out, opts) // node bed render path +// renderZitar(ev, opts) -> Float32 // bare DSP engine +// +// CLI demo: +// node pop/hippyhayzard/synths/zitar.mjs # sitar preset +// node pop/hippyhayzard/synths/zitar.mjs --preset drone --out ~/z.mp3 +// +// Per-event limitation: the taraf bank rings within each note (great on +// held notes — most of the sitar character). True cross-note sympathetic +// resonance needs a lane-level persistent bank; logged for later. + +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"; + +const DEFAULT_SAMPLE_RATE = 48_000; +const DEFAULT_PRESET = "sitar"; +const STRING_N = 2048; // matches AC harp delay-line cap + +// ── presets ──────────────────────────────────────────────────────────── +// stretch : KS decay multiplier (→1 = longer ring; sitar sustains) +// damp : KS one-pole averaging (lower = brighter/metallic) +// jawari : bridge-buzz blend 0..1 (the signature) +// jdrive : buzz waveshaper drive +// jmod : amplitude→delay shortening, samples (the buzzy pitch-zing) +// taraf : sympathetic pitches as semitone offsets from the note +// symGain : sympathetic halo level +// symQ : sympathetic resonance (higher = longer ring) +// chikari : drone-octave twang level (open rhythm strings) +export const ZITAR_PRESETS = { + // Full sitar — buzzing, shimmering, long ring. + sitar: { + stretch: 0.9985, damp: 0.18, jawari: 0.62, jdrive: 3.2, jmod: 0.7, + taraf: [-12, -5, 0, 4, 7, 12, 16], symGain: 0.20, symQ: 42, chikari: 0.10, + attack: 0.002, decay: 1.9, + }, + // Brighter melodic lead — less halo, more zing, shorter. + lead: { + stretch: 0.9975, damp: 0.12, jawari: 0.70, jdrive: 4.0, jmod: 0.9, + taraf: [-12, 0, 7, 12], symGain: 0.12, symQ: 30, chikari: 0.06, + attack: 0.002, decay: 1.1, + }, + // Drone — chikari-heavy, long tanpura-ish bed. + drone: { + stretch: 0.9990, damp: 0.22, jawari: 0.45, jdrive: 2.4, jmod: 0.4, + taraf: [-24, -12, -5, 0, 7, 12], symGain: 0.28, symQ: 55, chikari: 0.34, + attack: 0.004, decay: 3.2, + }, + // Dry KS only — no jawari, for A/B against the buzz. + dry: { + stretch: 0.9980, damp: 0.30, jawari: 0.0, jdrive: 1, jmod: 0, + taraf: [], symGain: 0, symQ: 20, chikari: 0, + attack: 0.002, decay: 1.2, + }, +}; + +// ── 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; + }; +} + +// ── the DSP engine ───────────────────────────────────────────────────── +export function renderZitar(ev, opts = {}) { + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const presetName = ev.preset || opts.preset || DEFAULT_PRESET; + const P = { ...(ZITAR_PRESETS[presetName] || ZITAR_PRESETS[DEFAULT_PRESET]), ...opts.params }; + + if (!Number.isFinite(ev.midi) || !Number.isFinite(ev.durSec) || ev.durSec <= 0) { + return new Float32Array(0); + } + const gain = Number.isFinite(ev.gain) ? ev.gain : 1.0; + if (gain === 0) return new Float32Array(0); + + const attack = opts.attack ?? P.attack ?? 0.002; + const decay = opts.decay ?? P.decay ?? 1.5; + const durS = Math.ceil(ev.durSec * sampleRate); + const attS = Math.max(1, Math.floor(attack * sampleRate)); + const decS = Math.max(1, Math.floor(decay * sampleRate)); + const ns = Math.max(durS, attS + decS); + const decayStart = ns - decS; + const out = new Float32Array(ns); + + const freq = midiToFreq(ev.midi); + let stringDelay = sampleRate / freq; + if (stringDelay > STRING_N - 2) stringDelay = STRING_N - 2; + + // ── KS pluck: prefill one wavelength with pre-smoothed noise ── + const rng = makeRng(`zitar:${presetName}:${ev.midi}:${(ev.startSec ?? 0).toFixed(4)}`); + const buf = new Float32Array(STRING_N); + const n0 = Math.max(2, Math.round(stringDelay)); + let smooth = 0; + for (let i = 0; i < n0; i++) { + const w = rng() * 2 - 1; + smooth = 0.5 * (w + smooth); // pre-smooth → softer pluck + buf[i] = smooth; + } + let widx = n0 % STRING_N; + let lp1 = 0; + + // ── sympathetic (taraf) resonator bank — Chamberlin SVF band-pass ── + const taraf = P.taraf || []; + const symN = taraf.length; + const symK = new Float64Array(symN); + const symL = new Float64Array(symN); + const symB = new Float64Array(symN); + const symDamp = 1 / Math.max(1, P.symQ ?? 40); + for (let s = 0; s < symN; s++) { + let fc = freq * Math.pow(2, taraf[s] / 12); + fc = Math.min(Math.max(40, fc), sampleRate / 6); + symK[s] = 2 * Math.sin(Math.PI * fc / sampleRate); + } + + const jawari = P.jawari ?? 0.6; + const jdrive = P.jdrive ?? 3.0; + const jmod = P.jmod ?? 0.7; + const stretch = P.stretch ?? 0.998; + const dampMix = P.damp ?? 0.2; // 0 = pure average (dark), →0.5 brighter + const chikari = P.chikari ?? 0.1; + const chF = freq * 2; // drone octave + let chPh = rng(); + + for (let i = 0; i < ns; i++) { + let env; + if (i < attS) env = i / attS; + else if (i < decayStart) env = 1; + else { env = 1 - (i - decayStart) / decS; if (env <= 0) break; } + + // ── jawari: the buzz shortens the effective delay with amplitude + // (signal-dependent fractional delay — the bridge contact) ── + let rd = widx - (stringDelay - jmod * Math.min(1, Math.abs(lp1) * 4)); + while (rd < 0) rd += STRING_N; + const i0 = Math.floor(rd) % STRING_N; + const i1 = (i0 + 1) % STRING_N; + const f = rd - Math.floor(rd); + const delayed = buf[i0] * (1 - f) + buf[i1] * f; + + // KS loop filter: average toward brightness (less damping than harp) + const filtered = (0.5 + dampMix) * delayed + (0.5 - dampMix) * lp1; + lp1 = delayed; + buf[widx] = filtered * stretch; + widx = (widx + 1) % STRING_N; + + // jawari waveshape: asymmetric foldback buzz blended over the string + const drv = filtered * jdrive; + const buzz = Math.tanh(drv) - 0.18 * drv * Math.exp(-drv * drv); + let s = filtered * (1 - jawari) + buzz * jawari; + + // sympathetic halo — string excites the taraf bank, sums back in + if (symN) { + let sym = 0; + for (let r = 0; r < symN; r++) { + const hi = filtered - symL[r] - symDamp * symB[r]; + symB[r] += symK[r] * hi; + symL[r] += symK[r] * symB[r]; + sym += symB[r]; + } + s += (sym / symN) * (P.symGain ?? 0.2); + } + + // chikari drone twang (open octave string), decays with the note + if (chikari > 0) { + chPh += chF / sampleRate; if (chPh >= 1) chPh -= 1; + s += (2 * chPh - 1) * chikari * Math.exp(-(i / sampleRate) * 3.0) * 0.5; + } + + out[i] = s * env * gain; + } + return out; +} + +// ── node-side buffer mixer ───────────────────────────────────────────── +export function mixEventZitar(ev, out, opts = {}) { + if (!(out instanceof Float32Array)) return; + const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE; + const seg = renderZitar(ev, { ...opts, sampleRate }); + const startIdx = Math.floor((ev.startSec ?? 0) * sampleRate); + for (let i = 0; i < seg.length; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= out.length) continue; + out[dst] += seg[i]; + } +} + +// ── 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 SR = 48_000; + const preset = flags.preset || DEFAULT_PRESET; + const outPath = expandHome(flags.out) || resolve(HERE, `zitar-${preset}.mp3`); + + // A slow raga-ish phrase in A so the buzz + taraf ring is obvious. + const seq = [ + [57, 0.9], [60, 0.5], [62, 0.9], [64, 1.4], + [62, 0.5], [60, 0.9], [57, 1.8], + [64, 0.6], [66, 0.6], [69, 2.4], + ]; + let t = 0; + const events = []; + for (const [midi, d] of seq) { events.push({ startSec: t, midi, durSec: d, gain: 0.9, preset }); t += d; } + const total = t + 1.5; + const out = new Float32Array(Math.ceil(total * SR)); + console.log(`→ zitar demo · preset=${preset} · ${seq.length} notes`); + for (const ev of events) mixEventZitar(ev, out, { sampleRate: SR, preset }); + + 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 nrm = 0.85 / peak; for (let i = 0; i < out.length; i++) out[i] *= nrm; } + mkdirSync(dirname(outPath), { recursive: true }); + const rawPath = `${outPath}.f32.raw`; + const b = Buffer.alloc(out.length * 4); + for (let i = 0; i < out.length; i++) b.writeFloatLE(out[i], i * 4); + writeFileSync(rawPath, b); + const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-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}`); +} -- tangled.sh