diff --git a/pop/maytrax/bin/fetch-kit.mjs b/pop/maytrax/bin/fetch-kit.mjs new file mode 100644 --- /dev/null +++ b/pop/maytrax/bin/fetch-kit.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// fetch-kit.mjs — pull a matrix / big-beat sampled-INSTRUMENT kit from +// Freesound for maytrax. Two kinds of role: +// • percussive one-shots (taiko, gong) — played at native pitch as accents +// • pitchable instruments (choir "aah", brass hit, strings, bass) — these +// are resampled per-note in maytrax.mjs to play the F-minor lines over +// the .np / chord data ("sampled insts over MIDI"). +// +// node pop/maytrax/bin/fetch-kit.mjs # fetch all roles +// node pop/maytrax/bin/fetch-kit.mjs --list # show 5 candidates per role +// node pop/maytrax/bin/fetch-kit.mjs --pick "choir=1,brass=0" # pin picks +// +// Writes pop/maytrax/kit.json. CC0 / CC-BY only (every track ships to +// DistroKid — no copyrighted film/break audio). + +import { searchSounds, downloadPreview } from "../../lib/freesound.mjs"; +import { writeFileSync, existsSync, readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const KIT_PATH = resolve(HERE, "..", "kit.json"); + +// role → query + duration filter + (for pitchable insts) an assumed root +// note so the resampler knows the transpose origin. f0 is re-detected at +// load time in maytrax.mjs, so `root` is only a fallback. +// JUNGLE kit — a chopped breakbeat assembled from CC0 one-shots (no real +// amen break — it's a copyrighted recording), deep sub + reese bass, a jazzy +// sampled flute lead (the "main voice"), pads, orchestra hits. +const ROLES = { + // break / drums (played at native pitch) + kick: { query: "breakbeat kick drum", filter: "duration:[0.1 TO 1.0]" }, + snare: { query: "breakbeat snare", filter: "duration:[0.1 TO 1.2]" }, + ghost: { query: "ghost snare drum", filter: "duration:[0.05 TO 0.6]" }, + hat: { query: "closed hihat", filter: "duration:[0.02 TO 0.5]" }, + ohat: { query: "open hihat", filter: "duration:[0.05 TO 1.0]" }, + ride: { query: "jazz ride cymbal", filter: "duration:[0.3 TO 4]" }, + shaker: { query: "shaker percussion", filter: "duration:[0.03 TO 1]" }, + cowbell: { query: "cowbell", filter: "duration:[0.05 TO 1]" }, + tom: { query: "tom drum", filter: "duration:[0.1 TO 1.5]" }, + click: { query: "click percussion", filter: "duration:[0.01 TO 0.4]" }, + gong: { query: "gong hit", filter: "duration:[1 TO 6]" }, + // ambience beds (played raw, long) — rainstorm + jungle for the opener + rain: { query: "rain storm ambience", filter: "duration:[5 TO 40]" }, + jungle: { query: "jungle birds ambience", filter: "duration:[5 TO 40]" }, + cricket: { query: "crickets meadow night", filter: "duration:[5 TO 40]" }, + // pitchable instruments (resampled per-note over the F-minor MIDI) + sub: { query: "sub bass note sine", filter: "duration:[0.3 TO 3]", root: "C1", pitched: true }, + reese: { query: "reese bass dnb", filter: "duration:[0.3 TO 4]", root: "C2", pitched: true }, + lead: { query: "flute single note", filter: "duration:[0.5 TO 4]", root: "C4", pitched: true }, + pad: { query: "warm synth pad", filter: "duration:[2 TO 8]", root: "C3", pitched: true }, + choir: { query: "choir aah voice", filter: "duration:[1 TO 6]", root: "C4", pitched: true }, + // John Williams-style cinematic legato string swell (Fm chord pads) + strings: { query: "cinematic orchestral strings", filter: "duration:[2 TO 9]", root: "C4", pitched: true }, + // animal "horn" — replaces the cheesy orchestra-hit stab. A tonal big-cat + // roar pitched to the Fm walk = a jungle creature singing the stabs. + animal: { query: "jaguar growl roar", filter: "duration:[1 TO 5]", root: "C3", pitched: true }, + wolf: { query: "wolf howling", filter: "duration:[1 TO 8] license:\"Creative Commons 0\"", root: "A3", pitched: true }, +}; + +const argv = process.argv.slice(2); +const LIST = argv.includes("--list"); +const pins = {}; +for (let i = 0; i < argv.length; i++) { + if (!argv[i].startsWith("--pick")) continue; + const specs = []; + if (argv[i].includes("=")) specs.push(...argv[i].replace(/^--pick=?/, "").split(",")); + while (argv[i + 1] && !argv[i + 1].startsWith("--")) specs.push(...argv[++i].split(",")); + for (const s of specs) { const [r, n] = s.split("="); if (r && n !== undefined) pins[r.trim()] = parseInt(n, 10) || 0; } +} + +const kit = existsSync(KIT_PATH) ? JSON.parse(readFileSync(KIT_PATH, "utf8")) : {}; + +for (const [role, spec] of Object.entries(ROLES)) { + try { + const data = await searchSounds({ query: spec.query, filter: spec.filter, pageSize: 5 }); + const results = data.results || []; + if (LIST) { + console.log(`\n${role} (${spec.query})`); + results.forEach((s, i) => console.log(` [${i}] ${s.name} · ${s.duration.toFixed(2)}s · ${s.username} · ${s.license.split("/").slice(-3, -1).join("/")}`)); + continue; + } + const idx = pins[role] ?? 0; + const chosen = results[idx] || results[0]; + if (!chosen) { console.warn(`! no result for ${role}`); continue; } + const mp3 = await downloadPreview(chosen); + const wav = mp3.replace(/\.mp3$/, ".wav"); + kit[role] = { + path: existsSync(wav) ? wav : mp3, + id: chosen.id, name: chosen.name, username: chosen.username, + license: chosen.license, duration: chosen.duration, + pitched: !!spec.pitched, root: spec.root || null, + }; + console.log(`✓ ${role.padEnd(8)} ${chosen.name} (#${chosen.id} · ${chosen.username})`); + } catch (e) { + console.error(`✗ ${role}: ${e.message}`); + } +} + +if (!LIST) { + writeFileSync(KIT_PATH, JSON.stringify(kit, null, 2) + "\n"); + console.log(`\n✓ wrote ${KIT_PATH} (${Object.keys(kit).length} roles)`); +} diff --git a/pop/maytrax/bin/gen-illy.mjs b/pop/maytrax/bin/gen-illy.mjs new file mode 100644 --- /dev/null +++ b/pop/maytrax/bin/gen-illy.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// gen-illy.mjs — generate the maytrax album cover via gpt-image-2, then +// embed it into the rendered mp3 as ID3 art. +// +// PHOTOGRAPHIC jeffrey cover: same identity-ref path as +// pop/marimba/bin/gen-illy.mjs + pop/chillwave/bin/gen-illy.mjs (jeffrey +// SHOOT + IG-archive refs + the whistlegraph-butterfly scrap), so maytrax +// sits in the same jeffrey singles series. The prompt +// (pop/maytrax/maytrax.illy.txt) drives a summer-jam stoop scene — +// jeffrey + a panther cub + a strawberry milkshake — in a real +// photographic register (golden-hour, 35mm grain), NOT the old +// illustrated panther emblem. +// +// Output: pop/maytrax/out/maytrax-cover.png (1024x1024) +// Embed: pop/maytrax/out/maytrax.mp3 (ID3v2 attached picture) +// +// node pop/maytrax/bin/gen-illy.mjs # cached if cover exists +// node pop/maytrax/bin/gen-illy.mjs --force # regenerate +// node pop/maytrax/bin/gen-illy.mjs --embed-only # skip gen, just embed + +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const REPO = resolve(LANE, "..", ".."); + +const flags = {}; +for (let i = 2; i < process.argv.length; i++) { + if (process.argv[i].startsWith("--")) flags[process.argv[i].slice(2)] = true; +} +const FORCE = flags.force === true; +const SIZE = "1024x1024"; + +const _af = (k, d) => { const i = process.argv.indexOf(k); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; }; +const PROMPT_PATH = resolve(process.cwd(), _af("--prompt", `${LANE}/maytrax.illy.txt`)); +const OUT_PATH = resolve(process.cwd(), _af("--cover", `${LANE}/out/maytrax-cover.png`)); +const MP3_PATH = resolve(process.cwd(), _af("--mp3", `${LANE}/out/maytrax.mp3`)); +mkdirSync(`${LANE}/out`, { recursive: true }); + +// ── identity refs (mirrors marimba/chillwave gen-illy.mjs) ─────────── +const SHOOT_DIR = `${REPO}/portraits/jeffrey/corpus/shoot-2k`; +const ARCHIVE_DIR = `${REPO}/portraits/jeffrey/ig-archive/whistlegraph`; +const REFS = [ + `${SHOOT_DIR}/jeffery-av--07.jpg`, + `${SHOOT_DIR}/jeffery-av--01.jpg`, + `${SHOOT_DIR}/jeffery-av--04.jpg`, + `${ARCHIVE_DIR}/2018-12-02_Bq4ckGFFNtW.jpg`, + `${ARCHIVE_DIR}/2020-09-02_CEpxlO2FOvD.jpg`, + `${ARCHIVE_DIR}/2021-07-10_CRI095Vl7AO_1.jpg`, + `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, + // the whistlegraph butterfly scrap — model DRAWS it, never composites + `${REPO}/pop/chillwave/assets/wg-scrap.png`, +].filter((p) => { + if (existsSync(p)) return true; + console.warn(` ⚠ ref missing, dropping: ${p}`); + return false; +}); + +function loadOpenAIKey() { + if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; + const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`; + if (existsSync(vault)) { + for (const line of readFileSync(vault, "utf8").split("\n")) { + if (line.startsWith("OPENAI_API_KEY=")) { + return line.slice("OPENAI_API_KEY=".length).trim().replace(/^['"]|['"]$/g, ""); + } + } + } + throw new Error("OPENAI_API_KEY not set and not found in vault"); +} + +// ── embed the cover into the mp3 as ID3v2 attached picture ─────────── +function embedCover() { + if (!existsSync(OUT_PATH) || !existsSync(MP3_PATH)) { + console.warn(` ⚠ skip embed — need both ${OUT_PATH} and ${MP3_PATH}`); + return; + } + const tmp = `${MP3_PATH}.embed.mp3`; + const r = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", MP3_PATH, "-i", OUT_PATH, + "-map", "0:a", "-map", "1:v", "-c", "copy", "-id3v2_version", "3", + "-metadata:s:v", "title=cover", "-metadata:s:v", "comment=Cover (front)", + "-disposition:v", "attached_pic", tmp], { stdio: "inherit" }); + if (r.status !== 0 || !existsSync(tmp)) { console.error("✗ embed failed"); return; } + renameSync(tmp, MP3_PATH); + console.log(`✓ cover embedded → ${MP3_PATH.replace(REPO + "/", "")}`); +} + +if (flags["embed-only"]) { embedCover(); process.exit(0); } + +if (existsSync(OUT_PATH) && !FORCE) { + console.log(`✓ cached cover → ${OUT_PATH.replace(REPO + "/", "")} (use --force to regen)`); + embedCover(); + process.exit(0); +} + +const apiKey = loadOpenAIKey(); +const prompt = readFileSync(PROMPT_PATH, "utf8").trim(); +console.log(`▸ maytrax cover · ${SIZE} · ${REFS.length} jeffrey refs`); +const t0 = Date.now(); + +const fd = new FormData(); +fd.append("model", "gpt-image-2"); +fd.append("prompt", prompt); +fd.append("size", SIZE); +fd.append("quality", "high"); +fd.append("n", "1"); +for (const ref of REFS) { + const buf = readFileSync(ref); + const ext = ref.toLowerCase().endsWith(".png") ? "png" + : ref.toLowerCase().endsWith(".webp") ? "webp" : "jpeg"; + fd.append("image[]", new Blob([buf], { type: `image/${ext}` }), ref.split("/").pop()); +} +const res = await fetch("https://api.openai.com/v1/images/edits", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: fd, +}); +if (!res.ok) { + console.error(`✗ OpenAI ${res.status}: ${(await res.text()).slice(0, 600)}`); + process.exit(1); +} +const json = await res.json(); +const b64 = json.data?.[0]?.b64_json; +if (!b64) { + console.error(`✗ no image: ${JSON.stringify(json).slice(0, 280)}`); + process.exit(1); +} +writeFileSync(OUT_PATH, Buffer.from(b64, "base64")); +console.log(`✓ ${((Date.now() - t0) / 1000).toFixed(1)}s → ${OUT_PATH.replace(REPO + "/", "")}`); + +embedCover(); diff --git a/pop/maytrax/bin/gen-shouts.mjs b/pop/maytrax/bin/gen-shouts.mjs new file mode 100644 --- /dev/null +++ b/pop/maytrax/bin/gen-shouts.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// gen-shouts.mjs — render the maytrax drop SHOUTS in jeffrey-pvc (ElevenLabs) +// as short percussive clips, one per phrase, cached under out/shouts/. +// +// The phrases come from maytrax.np ("wake up / it's real / hold on / let go", +// "follow the white rabbit / now"). Each is rendered with a punchy, slightly +// driven jeffrey-pvc setting (style up for shout energy, stability at 0.5 so +// the voice identity holds — see feedback_jeffrey_pvc_settings). maytrax.mjs +// loads these and slams them on the drop downbeats. +// +// node pop/maytrax/bin/gen-shouts.mjs # cached; --force to redo +// +// Output: pop/maytrax/out/shouts/.mp3 (+ kit-shouts.json index) + +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const POP = resolve(LANE, ".."); +const SAY = resolve(POP, "bin", "say.mjs"); +const OUT_DIR = resolve(LANE, "out", "shouts"); +const FORCE = process.argv.includes("--force"); +mkdirSync(OUT_DIR, { recursive: true }); + +const PHRASES = ["wake up", "it's real", "hold on", "let go", "follow the white rabbit", "now"]; +const slug = (s) => s.replace(/[^a-z0-9]+/gi, "_").toLowerCase(); + +// Apple `say` synth voice layered alongside jeffrey (man + machine chorus). +const SAY_VOICE = process.env.MAYTRAX_SAY_VOICE || "Daniel"; + +const index = {}; +for (const phrase of PHRASES) { + const out = resolve(OUT_DIR, `${slug(phrase)}.mp3`); + const wav = out.replace(/\.mp3$/, ".wav"); + const sayWav = resolve(OUT_DIR, `${slug(phrase)}-say.wav`); + index[slug(phrase)] = { phrase, path: wav, sayPath: sayWav }; + // Apple `say` variant (cheap/offline) — always (re)render unless cached. + if (!existsSync(sayWav) || FORCE) { + const aiff = resolve(tmpdir(), `maytrax-say-${slug(phrase)}.aiff`); + if (spawnSync("say", ["-v", SAY_VOICE, "-o", aiff, phrase], { stdio: "ignore" }).status === 0) + spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", aiff, "-ar", "48000", "-ac", "1", sayWav], { stdio: "ignore" }); + } + if (existsSync(wav) && !FORCE) { console.log(`✓ cached ${slug(phrase)} (+say)`); continue; } + const tmp = resolve(tmpdir(), `maytrax-shout-${slug(phrase)}.txt`); + writeFileSync(tmp, phrase + "\n"); + console.log(`• say "${phrase}" → ${slug(phrase)}.wav`); + const r = spawnSync("node", [SAY, tmp, + "--provider", "jeffrey", "--voice", "neutral:0", + "--style", "0.7", "--stability", "0.5", "--similarity", "0.9", "--speed", "1.05", + "--out", out], { stdio: ["ignore", "inherit", "inherit"] }); + if (r.status !== 0) { console.error(`✗ failed: ${phrase}`); process.exit(1); } + // maytrax.mjs reads WAV one-shots — transcode to 48k mono float wav. + const cv = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", out, "-ar", "48000", "-ac", "1", wav], { stdio: "inherit" }); + if (cv.status !== 0) { console.error(`✗ wav convert failed: ${phrase}`); process.exit(1); } +} + +writeFileSync(resolve(LANE, "shouts.json"), JSON.stringify(index, null, 2) + "\n"); +console.log(`✓ wrote ${LANE}/shouts.json (${PHRASES.length} shouts)`); diff --git a/pop/maytrax/bin/maytrax.mjs b/pop/maytrax/bin/maytrax.mjs --- a/pop/maytrax/bin/maytrax.mjs +++ b/pop/maytrax/bin/maytrax.mjs @@ -29,7 +29,7 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { homedir } from "node:os"; -import { mixEventHoover } from "../../hippyhayzard/synths/hoover.mjs"; +import { mixEventHoover as _hooverImpl } from "../../hippyhayzard/synths/hoover.mjs"; const SR = 48_000; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -50,7 +50,7 @@ if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); return p; } -const BPM = Number(flags.bpm ?? 140); +const BPM = Number(flags.bpm ?? 160); // classic jungle const beat = 60 / BPM; const bar = beat * 4; const sx = beat / 4; // 16th note @@ -110,7 +110,9 @@ while (lead < out.length && Math.abs(out[lead]) < 0.005) lead++; return { samples: out.subarray(lead), sampleRate: fmt.sampleRate }; } -function playSampleStereo(L, R, smp, startSec, gain = 1, rate = 1, panSpread = 0) { +// maxDurSec truncates the played sample (with a short release fade) so the +// whole track can be tightened up — short decays = punchy. +function playSampleStereo(L, R, smp, startSec, gain = 1, rate = 1, panSpread = 0, maxDurSec = Infinity) { if (!smp) return; const s0 = Math.floor(startSec * SR); const src = smp.samples; @@ -119,22 +121,110 @@ const lg = gain * (1 - panSpread * 0.5); const rg = gain * (1 + panSpread * 0.5); // light haas widening when panSpread > 0 const haasDelay = panSpread > 0 ? Math.floor(0.012 * SR) : 0; + const outMax = maxDurSec === Infinity ? Infinity : Math.floor(maxDurSec * SR); + const relN = Math.floor(0.02 * SR); for (let i = 0; i < L.length - s0; i++) { const p = i * step; const j = Math.floor(p); - if (j >= src.length - 1) break; + if (j >= src.length - 1 || i >= outMax) break; const f = p - j; - const v = src[j] * (1 - f) + src[j + 1] * f; + let v = src[j] * (1 - f) + src[j + 1] * f; + if (outMax !== Infinity && i > outMax - relN) v *= (outMax - i) / relN; addStereo(L, R, s0 + i, v * lg, 0); if (haasDelay > 0) addStereo(L, R, s0 + i + haasDelay, 0, v * rg); else addStereo(L, R, s0 + i, 0, v * rg); } } -// ── drum synthesis ───────────────────────────────────────────────────── +// chiptune square-wave voice — for the pirate-shanty opener. Slightly +// detuned + a little pulse-width wobble so it reads as a playful 8-bit lead. +function squareNote(L, R, midi, startSec, durSec, g = 0.2, pan = 0) { + const f = 440 * Math.pow(2, (midi - 69) / 12); + const n = Math.floor(durSec * SR), s0 = Math.floor(startSec * SR); + const lg = g * (1 - pan * 0.5), rg = g * (1 + pan * 0.5); + const atk = Math.floor(0.004 * SR), rel = Math.floor(0.04 * SR); + for (let i = 0; i < n; i++) { + const t = i / SR; + const pw = 0.5 + 0.06 * Math.sin(TAU * 5 * t); // pulse-width wobble + const ph = (f * t) % 1, ph2 = (f * 1.003 * t) % 1; // tiny detune + let s = (ph < pw ? 1 : -1) * 0.6 + (ph2 < 0.5 ? 1 : -1) * 0.4; + let env = 1; + if (i < atk) env = i / atk; else if (i > n - rel) env = Math.max(0, (n - i) / rel); + const v = s * env; + addStereo(L, R, s0 + i, v * lg, v * rg); + } +} + +// ── SAMPLE INSTRUMENT ENGINE ───────────────────────────────────────── +// Jungle mode (default; --no-samples disables): every voice is a Freesound +// one-shot from kit.json. Drums play at native pitch; pitchable roles +// (sub/reese/lead/pad/choir/brass) are resampled per-note over the F-minor +// data, with the root auto-detected (f0) so transposition lands in tune. +// The synth bodies below stay as a fallback when a role is missing. +const JUNGLE = !flags["no-samples"]; +// global decay scaler — shorter = tighter, punchier track. --decay 0.7 etc. +const DEC = Number(flags.decay ?? 0.65); // tight, poppy, minimal-techno +const LAZY = Number(flags.lazy ?? 0.45); // subtle laid-back drag + swing +const HUMAN = Number(flags.human ?? 0.4); // gentle micro-timing jitter +const hz = () => (Math.random() * 2 - 1) * 0.004 * HUMAN; // ±~1.6 ms jitter (tight) +const KIT_PATH = resolve(HERE, "..", "kit.json"); +const KIT = existsSync(KIT_PATH) ? JSON.parse(readFileSync(KIT_PATH, "utf8")) : {}; +const NN = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }; +const noteToMidi = (s) => { const m = /^([A-G])([#b]?)(-?\d)$/.exec(s || "C4"); return m ? NN[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0) + (parseInt(m[3], 10) + 1) * 12 : 60; }; +function detectF0(s, sr) { + let pk = 0, pidx = 0; for (let i = 0; i < s.length; i++) { const a = Math.abs(s[i]); if (a > pk) { pk = a; pidx = i; } } + const w = Math.min(8192, s.length), st = Math.max(0, Math.min(s.length - w, pidx - (w >> 1))); + const minLag = Math.floor(sr / 1200), maxLag = Math.floor(sr / 45); + let best = -1, bestLag = 0; + for (let lag = minLag; lag <= maxLag; lag++) { let sum = 0; for (let i = 0; i + lag < w; i++) sum += s[st + i] * s[st + i + lag]; if (sum > best) { best = sum; bestLag = lag; } } + return bestLag > 0 ? sr / bestLag : 0; +} +const SMP = {}; +if (JUNGLE) for (const role of Object.keys(KIT)) { + const wv = loadWav(KIT[role].path); if (!wv) continue; + let rootMidi = noteToMidi(KIT[role].root || "C4"); + if (KIT[role].pitched) { const f = detectF0(wv.samples, wv.sampleRate); if (f > 30 && f < 2000) rootMidi = Math.round(69 + 12 * Math.log2(f / 440)); } + SMP[role] = { w: wv, rootMidi }; +} +if (JUNGLE) console.log(`• jungle sample voices: ${Object.keys(SMP).join(", ")}`); +// in jungle mode the synth hoover lead is silenced — a sampled flute lead +// (added in the sampled-instrument pass) carries the main voice instead. +const mixEventHoover = JUNGLE ? () => {} : _hooverImpl; +// mono sample player (native pitch unless `rate` given); linear resample, +// optionally truncated to maxDurSec with a short release fade so rapidly +// re-triggered tonal samples (sub/reese) don't pile into mud. +function smpMono(buf, role, startSec, gain, rate = 1, maxDurSec = Infinity) { + const e = SMP[role]; if (!e) return false; + const src = e.w.samples, step = (e.w.sampleRate / SR) * rate, s0 = Math.floor(startSec * SR); + const outMax = maxDurSec === Infinity ? Infinity : Math.floor(maxDurSec * SR); + const relN = Math.floor(0.02 * SR); + let i = 0; + for (let p = 0; p < src.length - 1 && i < outMax; i++, p += step) { + const idx = p | 0, fr = p - idx; let v = (src[idx] * (1 - fr) + src[idx + 1] * fr) * gain; + if (outMax !== Infinity && i > outMax - relN) v *= (outMax - i) / relN; + add(buf, s0 + i, v); + } + return true; +} +// pitched mono player — resample a tonal role to a MIDI note. +function smpNote(buf, role, note, startSec, gain, maxDurSec = Infinity) { const e = SMP[role]; return e ? smpMono(buf, role, startSec, gain, Math.pow(2, (note - e.rootMidi) / 12), maxDurSec) : false; } + +// ── drum synthesis (sample-backed in jungle mode) ────────────────────── // the prodigy 909-kick: pitch-enveloped sine driven hard through tanh, // with an extra sub layer underneath for weight. function kick909(buf, startSec, g = 1.0) { + if (smpMono(buf, "kick", startSec, g * 0.9, 1, 0.20 * DEC)) { + // deep, matrix-cinematic sub-boom under the sampled kick — a pitch- + // dropping sine that settles at ~40 Hz for weight you feel in the chest. + const n = Math.floor(0.20 * SR), s0 = Math.floor(startSec * SR); + let ph = 0; + for (let i = 0; i < n; i++) { + const t = i / SR, f = 110 * Math.exp(-t * 26) + 40; + ph += (TAU * f) / SR; + add(buf, s0 + i, Math.tanh(Math.sin(ph) * 1.3) * Math.exp(-t * 6.5) * 0.72 * g); + } + return; + } const dur = 0.28, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); let ph = 0, sub = 0; for (let i = 0; i < n; i++) { @@ -152,6 +242,7 @@ } // chopped breakbeat snare (high noise burst + body sine). function snareBrk(buf, startSec, g = 0.8) { + if (smpMono(buf, "snare", startSec, g * 0.95, 1, 0.18 * DEC)) return; const dur = 0.18, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); let prev = 0, ph = 0; for (let i = 0; i < n; i++) { @@ -167,6 +258,7 @@ } // closed hi-hat (high-pass noise burst). function hat(buf, startSec, g = 0.18) { + if (smpMono(buf, "hat", startSec, g * 1.4, 1, 0.05 * DEC)) return; const dur = 0.045, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); let prev = 0; for (let i = 0; i < n; i++) { @@ -179,6 +271,7 @@ } // open hat (longer decay). function openHat(buf, startSec, g = 0.22) { + if (smpMono(buf, "ohat", startSec, g * 1.3, 1, 0.13 * DEC)) return; const dur = 0.22, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); let prev = 0; for (let i = 0; i < n; i++) { @@ -193,6 +286,7 @@ // shaker / maraca — a two-pole high-passed noise burst with a soft 3 ms // attack and a longer decay than the closed hat, so it reads as an airy // "shh" rather than a "tk". The backbone of the sunk-in perc layer. function shaker(buf, startSec, g = 0.10, decay = 52) { + if (smpMono(buf, "shaker", startSec, g * 1.6, 1, 0.06 * DEC)) return; const dur = 0.09, n = Math.floor(dur * SR), s0 = Math.floor(startSec * SR); let prev = 0, prev2 = 0; for (let i = 0; i < n; i++) { @@ -227,19 +321,37 @@ // funky-drummer-shaped break, one bar (16th-grid). K=kick, S=snare, // h=hat, H=openhat, .=rest. function breakBar(busL, busR, drm, t0, energy = 1, hatPan = 0.35) { - const kP = ["K", ".", ".", "K", ".", ".", ".", ".", ".", ".", "K", ".", ".", ".", ".", "."]; - const sP = [".", ".", ".", ".", "S", ".", ".", "s", ".", ".", ".", ".", "S", ".", ".", "."]; + // four chopped-break variations, cycled per bar so the break keeps moving + const KV = [ + ["K", ".", ".", "K", ".", ".", ".", ".", ".", ".", "K", ".", ".", ".", ".", "."], + ["K", ".", ".", ".", ".", ".", "K", ".", ".", "K", ".", ".", ".", ".", "K", "."], + ["K", ".", ".", "K", ".", ".", ".", "K", ".", ".", "K", ".", ".", ".", ".", "."], + ["K", ".", "K", ".", ".", ".", ".", ".", "K", ".", ".", ".", "K", ".", ".", "."], + ]; + const SV = [ + [".", ".", ".", ".", "S", ".", ".", "s", ".", ".", ".", ".", "S", ".", ".", "."], + [".", ".", ".", ".", "S", ".", ".", ".", ".", "s", ".", ".", "S", ".", ".", "s"], + [".", ".", "s", ".", "S", ".", ".", ".", ".", ".", "s", ".", "S", ".", ".", "."], + [".", ".", ".", "s", "S", ".", ".", "s", ".", ".", ".", "s", "S", ".", "s", "."], + ]; + // change pattern per PHRASE (every 4 bars), not every bar, so it stays + // regular and groovy instead of shuffling around every beat. + const vi = (Math.floor(Math.round(t0 / bar) / 4) % KV.length + KV.length) % KV.length; + const kP = KV[vi], sP = SV[vi]; const hP = ["h", "h", "h", "H", "h", "h", "h", "h", "h", "H", "h", "h", "h", "h", "H", "h"]; + const swing = (e) => (e % 2 === 1 ? 0.020 * LAZY : 0); // lazy swing on the offbeats + const drag = 0.010 * LAZY; // whole-kit laid-back drag for (let e = 0; e < 16; e++) { const t = t0 + e * sx; - if (kP[e] === "K") kick909(drm, t, 1.05 * energy); - if (sP[e] === "S") snareBrk(drm, t, 0.78 * energy); - if (sP[e] === "s") snareBrk(drm, t, 0.34 * energy); + // kick stays tightest; snare drags laziest behind the beat; humanized + if (kP[e] === "K") kick909(drm, t + drag * 0.4 + hz() * 0.5, 1.05 * energy); + if (sP[e] === "S") snareBrk(drm, t + drag + swing(e) + hz(), (0.7 + 0.16 * Math.random()) * energy); + if (sP[e] === "s") snareBrk(drm, t + drag + swing(e) + hz(), (0.3 + 0.1 * Math.random()) * energy); // hats: ping-pong slightly so the kit breathes in stereo - const pan = (e % 2 === 0 ? -1 : 1) * hatPan; + const pan = (e % 2 === 0 ? -1 : 1) * hatPan, th = t + drag + swing(e) + hz(); const tmp = new Float32Array(busL.length); - if (hP[e] === "h") hat(tmp, t, 0.115 * energy); - if (hP[e] === "H") openHat(tmp, t, 0.145 * energy); + if (hP[e] === "h") hat(tmp, th, (0.10 + 0.04 * Math.random()) * energy); + if (hP[e] === "H") openHat(tmp, th, (0.13 + 0.04 * Math.random()) * energy); // mix tmp into stereo with pan const s0 = Math.floor(t * SR); const end = Math.min(busL.length, s0 + Math.floor(0.25 * SR)); @@ -299,6 +411,7 @@ // ── sub-bass voice ───────────────────────────────────────────────────── // the deep undercarriage — pure sine with a soft attack so it sits // under the kick (sidechain ducks it on each kick). function subBass(buf, startSec, midi, durSec, g = 0.42) { + if (smpNote(buf, "sub", midi, startSec, g * 1.1, durSec)) return; const f = 440 * Math.pow(2, (midi - 69) / 12); const n = Math.floor(durSec * SR); const s0 = Math.floor(startSec * SR); @@ -320,6 +433,11 @@ // ── dark pad ────────────────────────────────────────────────────────── // detuned-saws into a dark resonant lowpass; stereo by rendering twice // with mirrored detune. Sits as the matrix-noir undertone. function darkPad(L, R, startSec, midiArr, durSec, g = 0.18) { + if (JUNGLE && SMP.pad) { + const e = SMP.pad; + midiArr.forEach((m, k) => playSampleStereo(L, R, e.w, startSec, g * 0.6, Math.pow(2, (m - e.rootMidi) / 12), (k / Math.max(1, midiArr.length - 1) - 0.5) * 0.6)); + return; + } const n = Math.floor(durSec * SR); const s0 = Math.floor(startSec * SR); const baseF = midiArr.map((m) => 440 * Math.pow(2, (m - 69) / 12)); @@ -366,6 +484,8 @@ // ── 303 acid line ────────────────────────────────────────────────────── function acid303(buf, t0, midi, durSec, opts = {}) { const { gain = 0.28, accent = false, slideFrom = null } = opts; + // jungle: the reese bass is the mid-bass squelch (the genre signature) + if (smpNote(buf, "reese", midi, t0, gain * (accent ? 1.3 : 1.0), durSec)) return; const n = Math.floor(durSec * SR); const s0 = Math.floor(t0 * SR); const f0 = 440 * Math.pow(2, (midi - 69) / 12); @@ -462,7 +582,7 @@ drop3: 16, outro: 12, }; const totalBars = Object.values(SECTION_BARS).reduce((s, v) => s + v, 0); // 88 -const totalSec = totalBars * bar + 1.5; // +tail for reverb-ish decays +const totalSec = totalBars * bar + 4.0; // +tail for reverb/strings/healing ring-out const N = Math.floor(totalSec * SR); // stereo buses — bus is everything ducked by sidechain; drm is the @@ -473,6 +593,10 @@ const busR = new Float32Array(N); const drm = new Float32Array(N); const revL = new Float32Array(N); const revR = new Float32Array(N); +// vocal bus — NOT ducked by the sidechain, summed clean after the pump so +// jeffrey + the apple say layer cut through the dense jungle mix. +const vocL = new Float32Array(N); +const vocR = new Float32Array(N); const kickTimes = []; // ── fanfare helpers ────────────────────────────────────────────────── @@ -480,6 +604,7 @@ // Multi-note "ta-da-DAAAH" pattern using a brass/flugel one-shot // pitched up to outline the F minor triad (F-Ab-C). Hits dry on // busL/busR + sends to the reverb bus for a hall tail. function brassFanfare(L, R, rL, rR, smp, t0, gain) { + if (JUNGLE) return; // cheesy orchestra-hit fanfares replaced by animal stabs // Brass sample = E2; rates: F=+1st, Ab=+4st, C=+8st const rF = Math.pow(2, 1 / 12); const rAb = Math.pow(2, 4 / 12); @@ -498,6 +623,7 @@ // Flugel fanfare — uses the flugelhorn-asharp sample (A#2). Plays the // same triad ascent but at a slightly different timing for variation. function flugelFanfare(L, R, rL, rR, smp, t0, gain) { + if (JUNGLE) return; // replaced by animal stabs in jungle mode // Flugel = A#2. Triad ascent over F minor: F-Ab-C means relative to A#: // F = -4st, Ab = -1st, C = +3st. To keep direction ascending, use // Ab-C-F (= -1, +3, +8 from A#). @@ -936,6 +1062,268 @@ playSampleStereo(revL, revR, bell, t0 + bar * 10, 0.45, 0.95, 0); } } +// ── SAMPLED-INSTRUMENT LAYER — Freesound one-shots played over the +// F-minor note data ("sampled insts over MIDI"). Loads pop/maytrax/kit.json +// (fetch-kit.mjs): taiko under the kicks, gong on drop downbeats, the iconic +// rave ORCHESTRA HIT pitched to the FM_ROOTS stab walk, and a choir "aah" +// pad on the breaks + final drop. Additive into the buses; toggle with +// --no-samples. Pitched roles transpose by resampling from their kit `root`. +if (!flags["no-samples"]) { + const KIT_PATH = resolve(HERE, "..", "kit.json"); + const kit = existsSync(KIT_PATH) ? JSON.parse(readFileSync(KIT_PATH, "utf8")) : {}; + const SMP = {}; + for (const role of Object.keys(kit)) { const s = loadWav(kit[role].path); if (s) SMP[role] = { ...kit[role], smp: s }; } + // reversed variants (genre-confusing risers): reverse-wolf + reverse-animal + const reverseSamples = (sm) => { const o = new Float32Array(sm.samples.length); for (let i = 0; i < o.length; i++) o[i] = sm.samples[sm.samples.length - 1 - i]; return { samples: o, sampleRate: sm.sampleRate }; }; + if (SMP.wolf) SMP.wolf_rev = { ...SMP.wolf, smp: reverseSamples(SMP.wolf.smp) }; + if (SMP.animal) SMP.animal_rev = { ...SMP.animal, smp: reverseSamples(SMP.animal.smp) }; + const have = Object.keys(SMP); + if (have.length) { + console.log(`• sampled insts: ${have.join(", ")}`); + const NN = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }; + const noteToMidi = (s) => { const m = /^([A-G])([#b]?)(-?\d)$/.exec(s || "C4"); if (!m) return 60; return NN[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0) + (parseInt(m[3], 10) + 1) * 12; }; + const rateFor = (role, note) => Math.pow(2, (note - noteToMidi(SMP[role]?.root || "C4")) / 12); + // section start bars (derived from SECTION_BARS so it stays in sync) + const order = ["intro", "buildA", "drop1", "breakA", "drop2", "breakB", "drop3", "outro"]; + const startBar = {}; { let c = 0; for (const k of order) { startBar[k] = c; c += SECTION_BARS[k]; } } + const secOfBar = []; for (const k of order) for (let i = 0; i < SECTION_BARS[k]; i++) secOfBar.push(k); + + // ── FAINT AMBIENCE BED — rainstorm + jungle + crickets (meadow). Loud-ish + // and lonely at the very start, then ducked to a faint bed under the + // track, and back up at the tail so it loops into the quiet opening. + { + const amb = (role, g, t) => SMP[role] && playSampleStereo(busL, busR, SMP[role].smp, t, g, 1, (Math.random() * 2 - 1) * 0.3); + // opener — present (the track emerges from a rainy jungle meadow) + amb("rain", 0.22, 0); amb("jungle", 0.16, 0); amb("cricket", 0.12, 0.5); + // faint bed re-triggered across the track so it never disappears + for (let t = 8; t < totalBars * bar; t += 18) { amb("rain", 0.05, t); if (Math.random() < 0.5) amb("cricket", 0.04, t + 4); } + // tail — bring the meadow back for the loop point + amb("rain", 0.2, (totalBars - 10) * bar); amb("cricket", 0.14, (totalBars - 9) * bar); amb("jungle", 0.12, (totalBars - 8) * bar); + } + + // ── PERC + HATS THROUGHOUT — cowbell, toms, clicks, closed/open hats run + // across the WHOLE track (humanized + lazy), sparser in quiet sections so + // the groove keeps ticking even between the drops. + { + const lag = 0.010 * LAZY; + for (let bAbs = 0; bAbs < totalBars; bAbs++) { + const sec = secOfBar[bAbs] || "drop1"; + const inDrop = sec.startsWith("drop"), quiet = sec === "intro" || sec === "outro"; + const dens = inDrop ? 1 : sec.startsWith("break") ? 0.55 : quiet ? 0.3 : 0.7; + const sw = (s) => (s % 2 ? 0.020 * LAZY : 0); + for (let s = 0; s < 16; s++) { + const t = bAbs * bar + s * sx + lag + sw(s) + hz(); + // steady closed hats on the offbeats (regular groove), light accent + if (SMP.hat && s % 2 === 1) + playSampleStereo(busL, busR, SMP.hat.smp, t, (s % 4 === 3 ? 0.075 : 0.05) * dens, 1, 0.2, 0.05 * DEC); + // sparse clicks on a fixed grid (no random scatter) + if (SMP.click && inDrop && s % 8 === 5) + playSampleStereo(busL, busR, SMP.click.smp, t, 0.05 * dens, 1, 0.4, 0.04 * DEC); + } + if (SMP.ohat && Math.random() < 0.7 * dens) + playSampleStereo(busL, busR, SMP.ohat.smp, bAbs * bar + 6 * sx + lag + hz(), 0.07 * dens, 1, 0.25, 0.12 * DEC); + if (SMP.cowbell && inDrop) for (const s of [7, 14]) if (Math.random() < 0.22 * dens) + playSampleStereo(busL, busR, SMP.cowbell.smp, bAbs * bar + s * sx + lag + hz(), 0.07 * dens, 1, (Math.random() * 2 - 1) * 0.4, 0.14 * DEC); + if (SMP.tom && bAbs % 4 === 3 && !quiet) // descending tom fill at phrase ends + [10, 12, 14].forEach((s, k) => playSampleStereo(busL, busR, SMP.tom.smp, bAbs * bar + s * sx + lag + hz(), 0.2 * dens, Math.pow(2, -k / 6), (k - 1) * 0.3, 0.6 * DEC)); + } + } + const drops = [["drop1", 16], ["drop2", 16], ["drop3", 16]]; + const breaks = [["breakA", 8], ["breakB", 8]]; + + for (const [name, bars] of drops) { + const s0 = startBar[name]; + // gong swell on the very first downbeat of the drop (wet, tightened) + if (SMP.gong) { const t = s0 * bar; playSampleStereo(busL, busR, SMP.gong.smp, t, 0.34, 1, 0.5, 1.2 * DEC); playSampleStereo(revL, revR, SMP.gong.smp, t, 0.30, 1, 0, 1.2 * DEC); } + for (let b = 0; b < bars; b++) { + const t0 = (s0 + b) * bar, root = FM_ROOTS[b % 4]; + // taiko reinforcing beats 1 & 3 (the thump), native pitch, tight + if (SMP.taiko) { playSampleStereo(busL, busR, SMP.taiko.smp, t0, 0.30, 1, 0.15, 0.35 * DEC); playSampleStereo(busL, busR, SMP.taiko.smp, t0 + 2 * beat, 0.24, 1, -0.15, 0.35 * DEC); } + // FLUTEY BLEEP-BLOPS — the RIFF, but processed: harmonized flute + // chord-blips pitched up high + tight, mixed LOW (hidden under the + // jeffrey vocals). A faint reversed-animal blip adds genre-confusing + // texture, and a reversed-wolf howl swells UP into each phrase start. + const phraseStart = b % 4 === 0; + if (SMP.lead) { + const steps = name === "drop3" ? [0, 3, 6, 10, 13] : [0, 6, 10]; + const BLEEP = [[12, 0.5], [15, 0.3], [19, 0.4], [24, 0.2]]; // Fm chord, up high + for (const st of steps) { + const t = t0 + st * sx, g = (st === 0 ? 0.22 : 0.15); // hidden in the mix + for (const [semi, hg] of BLEEP) + playSampleStereo(busL, busR, SMP.lead.smp, t, g * hg, rateFor("lead", root + semi), (Math.random() * 2 - 1) * 0.55, 0.10 * DEC); + playSampleStereo(revL, revR, SMP.lead.smp, t, g * 0.3, rateFor("lead", root + 12), 0, 0.13 * DEC); + // faint reversed-animal blip (texture/confusion) + if (SMP.animal_rev && Math.random() < 0.5) + playSampleStereo(busL, busR, SMP.animal_rev.smp, t, 0.1, rateFor("animal", root + 12), (Math.random() * 2 - 1) * 0.6, 0.12 * DEC); + } + } + // REVERSE WOLF HOWL — pitched to the lead, swelling UP so it lands on + // the phrase-start downbeat (a haunting genre-bending riser). + if (SMP.wolf_rev && phraseStart) { + const hold = name === "drop3" ? 2.6 : 2.0, lead = root + 12; + playSampleStereo(busL, busR, SMP.wolf_rev.smp, t0 - hold, 0.32, rateFor("wolf_rev", lead), 0.2, hold); + playSampleStereo(revL, revR, SMP.wolf_rev.smp, t0 - hold, 0.2, rateFor("wolf_rev", lead), 0, hold); + } + // ANIMAL ROAR is BACK — the reverse-wolf swells up, then a big low + // jaguar roar LANDS on the phrase downbeat, plus the odd forward + // roar-stab for the rock factor (flutey blips stay as texture). + if (SMP.animal) { + if (phraseStart) { + // pitched 2 octaves down → a long, deep, stretched-out roar + const rl = rateFor("animal", root - 24), hold = name === "drop3" ? 5.5 : 4.5; + playSampleStereo(busL, busR, SMP.animal.smp, t0, 0.54, rl, 0.2, hold); + playSampleStereo(revL, revR, SMP.animal.smp, t0, 0.24, rl, 0, hold); + playSampleStereo(busL, busR, SMP.animal.smp, t0, 0.34, rateFor("animal", root), -0.2, 0.9 * DEC); // mid body + } else if (Math.random() < 0.45) { + playSampleStereo(busL, busR, SMP.animal.smp, t0 + 6 * sx, 0.42, rateFor("animal", root), (Math.random() * 2 - 1) * 0.4, 0.5 * DEC); + } + } + } + // choir pad sustained through the biggest drop + if (SMP.choir && name === "drop3") { + for (let b = 0; b < bars; b += 2) { + const t0 = (s0 + b) * bar; + for (const n of [FM_PAD[1], FM_PAD[2], FM_PAD[3]]) { + playSampleStereo(busL, busR, SMP.choir.smp, t0, 0.07, rateFor("choir", n), 0.4); + playSampleStereo(revL, revR, SMP.choir.smp, t0, 0.05, rateFor("choir", n), 0); + } + } + } + } + // choir "aah" pad over the breaks (the matrix vowel wash) + if (SMP.choir) for (const [name, bars] of breaks) { + const s0 = startBar[name]; + for (let b = 0; b < bars; b += 2) { + const t0 = (s0 + b) * bar; + for (const n of [FM_PAD[1], FM_PAD[2], FM_PAD[3]]) { + playSampleStereo(busL, busR, SMP.choir.smp, t0, 0.08, rateFor("choir", n), 0.45); + playSampleStereo(revL, revR, SMP.choir.smp, t0, 0.06, rateFor("choir", n), 0); + } + } + } + + // ── PIRATE-SHANTY OPENER — the whole thing opens with a jaunty chiptune + // square-wave melody in F minor (genre bait-and-switch before the jungle + // hits). 4-bar phrase, played twice across the 8-bar intro. + { + const s0 = startBar["intro"]; + const SHANTY = [ + [0, 65, 0.5], [0.5, 65, 0.5], [1, 68, 0.5], [1.5, 72, 0.5], [2, 72, 1], [3, 70, 1], + [4, 68, 0.5], [4.5, 68, 0.5], [5, 67, 0.5], [5.5, 65, 0.5], [6, 63, 1.5], [7.5, 65, 0.5], + [8, 65, 0.5], [8.5, 68, 0.5], [9, 72, 0.5], [9.5, 75, 0.5], [10, 73, 1], [11, 72, 1], + [12, 68, 0.5], [12.5, 67, 0.5], [13, 65, 0.5], [13.5, 63, 0.5], [14, 65, 2], + ]; + // lead with the FLUTE carrying the shanty — but SLOWER (timing ×2, + // spanning the whole intro) and an octave LOWER, with reverb + space so + // it's a slow, low, atmospheric opener. Square = faint chiptune ghost. + for (const [bt, midi, dur] of SHANTY) { + const t = (s0 * bar) + bt * 2 * beat + 0.012 * LAZY + hz(); + const m = midi - 12, ring = Math.max(1.1, dur * 2 * beat); + if (SMP.lead) { + playSampleStereo(busL, busR, SMP.lead.smp, t, 0.3, rateFor("lead", m), 0.25, ring * 0.95); + playSampleStereo(revL, revR, SMP.lead.smp, t, 0.26, rateFor("lead", m), 0, ring); // wash/space + } + squareNote(busL, busR, m, t, dur * 2 * beat * 0.9, 0.05, (Math.random() * 2 - 1) * 0.25); // faint ghost + } + // a low flute drone under it grounds the key (matches the strings/pads) + if (SMP.lead) for (const bo of [0, 4]) { + const t = (s0 + bo) * bar; + playSampleStereo(busL, busR, SMP.lead.smp, t, 0.16, rateFor("lead", 41), 0.1, 3.0); // F2 pad-ish + playSampleStereo(revL, revR, SMP.lead.smp, t, 0.12, rateFor("lead", 41), 0, 3.0); + } + } + + // ── JOHN WILLIAMS STRINGS — long, deep, cinematic Fm swells. They live + // on the ducked music bus, so they're side-chained to the kick (pump). + if (SMP.strings) { + const strSegs = [["intro", 8], ["breakA", 8], ["drop2", 16], ["breakB", 8], ["drop3", 16], ["outro", 12]]; + const chord = [41, 48, 56]; // F2 C3 Ab3 — deep Fm spread + for (const [name, bars] of strSegs) { + const s0 = startBar[name]; + for (let b = 0; b < bars; b += 4) { + const t0 = (s0 + b) * bar, g = name.startsWith("drop") ? 0.13 : 0.16; + for (const n of chord) { + playSampleStereo(busL, busR, SMP.strings.smp, t0, g, rateFor("strings", n), 0.5, 6.0); + playSampleStereo(revL, revR, SMP.strings.smp, t0, g * 0.5, rateFor("strings", n), 0, 6.0); + } + } + } + } + + // ── SAMPLED FLUTE LEAD — the MAIN VOICE. A jazzy F-minor melody played + // on the Freesound flute, harmonized into octave + fifth + minor-third + // stacks (humanized with a little timing/pitch jitter) so it sings like + // a small flute section over the drops. + if (SMP.lead) { + // 4-bar phrase: [barInPhrase, beatOffset, midi] in F4–Eb5 (Fm-ish) + const LEAD = [ + [0, 0, 65], [0, 2, 72], // F4 … C5 + [1, 0, 75], [1, 1, 72], [1, 2, 68], // Eb5 C5 Ab4 + [2, 0, 65], [2, 2, 68], // F4 Ab4 + [3, 0, 72], // C5 (hold) + ]; + const HARM = [[0, 0.5], [12, 0.34], [7, 0.26], [3, 0.2]]; // unison + 8ve + 5th + m3 + const flute = (note, t, g, pan) => { + for (const [semi, hg] of HARM) { + const jt = (Math.random() * 2 - 1) * 0.012, jp = (Math.random() * 2 - 1) * 0.02; // humanize + playSampleStereo(busL, busR, SMP.lead.smp, t + jt, g * hg, rateFor("lead", note + semi) * (1 + jp / 12 * 0.05), pan, 0.6 * DEC); + playSampleStereo(revL, revR, SMP.lead.smp, t + jt, g * hg * 0.3, rateFor("lead", note + semi), 0, 0.6 * DEC); + } + }; + for (const [name] of drops) { + const s0 = startBar[name], gain = name === "drop3" ? 0.34 : 0.28; + for (let phr = 0; phr < 4; phr++) for (const [bo, beatO, midi] of LEAD) + flute(midi, (s0 + phr * 4 + bo) * bar + beatO * beat, gain, (Math.random() * 2 - 1) * 0.3); + } + } + } +} + +// ── JEFFREY-PVC SHOUTS — slammed on the drop downbeats. Loads +// shouts.json (gen-shouts.mjs); ElevenLabs clips run quiet so each is +// peak-normalized, then thrown dry on the buses with a short reverb send. +// Toggle with --no-vocals. +if (!flags["no-vocals"]) { + const SH_PATH = resolve(HERE, "..", "shouts.json"); + if (existsSync(SH_PATH)) { + const sh = JSON.parse(readFileSync(SH_PATH, "utf8")); + // load + peak-normalize a clip; `variant` picks jeffrey (.wav) or the + // Apple-say sung layer (-say-sung.wav). + const loadShout = (key, variant = "jeffrey") => { + const e = sh[key]; if (!e) return null; + const path = variant === "apple" ? e.path.replace(/\.wav$/, "-say-sung.wav") : e.path; + if (!existsSync(path)) return null; + const w = loadWav(path); if (!w) return null; + let pk = 0; for (let i = 0; i < w.samples.length; i++) pk = Math.max(pk, Math.abs(w.samples[i])); + const g = pk > 0 ? 0.97 / pk : 1, out = new Float32Array(w.samples.length); + for (let i = 0; i < w.samples.length; i++) out[i] = w.samples[i] * g; + return { samples: out, sampleRate: w.sampleRate }; + }; + const keyOf = { wake: "wake_up", real: "it_s_real", hold: "hold_on", let: "let_go", rabbit: "follow_the_white_rabbit", now: "now" }; + const J = {}, A = {}; + for (const [k, slug] of Object.entries(keyOf)) { J[k] = loadShout(slug, "jeffrey"); A[k] = loadShout(slug, "apple"); } + console.log(`• shouts: jeffrey ${Object.values(J).filter(Boolean).length}/6 + apple ${Object.values(A).filter(Boolean).length}/6 → clean vocal bus`); + const order = ["intro", "buildA", "drop1", "breakA", "drop2", "breakB", "drop3", "outro"]; + const startBar = {}; { let c = 0; for (const k of order) { startBar[k] = c; c += SECTION_BARS[k]; } } + // route to the UN-DUCKED vocal bus, loud + present. jeffrey leads centre; + // the Apple-say twin sits a touch quieter, panned + an octave context. + // jeffrey leads LOUD + centre; the apple twin is a quieter side colour. + const sing = (k, bAbs, g = 1.35, pan = 0) => { + const t = bAbs * bar; + if (J[k]) { playSampleStereo(vocL, vocR, J[k], t, g, 1, pan); playSampleStereo(revL, revR, J[k], t, g * 0.18, 1, 0); } + if (A[k]) { playSampleStereo(vocL, vocR, A[k], t, g * 0.32, 1, -pan - 0.2); playSampleStereo(revL, revR, A[k], t, g * 0.14, 1, 0); } + }; + // every 2 bars now (more frequent words), cycling the phrase + for (const name of ["drop1", "drop3"]) { + const s0 = startBar[name]; + ["wake", "real", "hold", "let", "wake", "real", "hold", "let"].forEach((k, i) => sing(k, s0 + i * 2, 1.35, i % 2 ? 0.12 : -0.12)); + } + { const s0 = startBar["drop2"]; + ["rabbit", "now", "rabbit", "now", "rabbit", "now", "rabbit", "now"].forEach((k, i) => sing(k, s0 + i * 2, 1.35, i % 2 ? 0.12 : -0.12)); + } + } +} + // ── reverb (Schroeder: 6 parallel feedback combs → 2 allpass diffusers) // Process the reverb send and add the wet to the bus. Hall-ish: ~2 s // decay, low-passed for a darker tail (warm not splashy). @@ -1008,11 +1396,128 @@ } } // ── pre-master sum: bus (ducked) + drm (dry) → stereo float ────────── -const outL = new Float32Array(N); -const outR = new Float32Array(N); +let outL = new Float32Array(N); +let outR = new Float32Array(N); +let outN = N; // grows when the tape-stop ending stretches the tail +// ── INDUSTRIAL FILTER SWEEP + CRUNCH (skrillex-leaning) ─────────────── +// A resonant state-variable lowpass on the MUSIC bus whose cutoff sweeps +// with the arrangement (rising through the intro, a dramatic down-up sweep +// across the breaks) and WOBBLES on the drops (LFO-modulated cutoff — the +// dubstep wobble). Before the filter the music is crunched (drive + soft +// fold + mild bitcrush) for grit. Vocals are summed AFTER, clean, so they +// stay present over the wobble. Tune with --crunch / --wobble / --wobhz. +const CRUNCH = Number(flags.crunch ?? 1); +const WOB = Number(flags.wobble ?? 0.4); // subtle — just a gentle breathing +const wobHz = Number(flags.wobhz ?? (BPM / 60) / 2); // half-note wobble +const Q = 0.8, qc = 1 / Q, fcMax = SR * 0.16; // gentle resonance (not peaky) +const segOrder = ["intro", "buildA", "drop1", "breakA", "drop2", "breakB", "drop3", "outro"]; +const segs = []; { let c = 0; for (const k of segOrder) { segs.push({ k, s: c * bar, e: (c + SECTION_BARS[k]) * bar }); c += SECTION_BARS[k]; } } +// opening swell — keep the intro + build QUIET, ramp up into the first drop +const drop1T = (SECTION_BARS.intro + SECTION_BARS.buildA) * bar; +function crunch(x) { + let y = Math.tanh(x * (1.5 + 1.6 * CRUNCH)); // drive + y = y - 0.14 * CRUNCH * y * y * y; // asymmetric-ish fold + const lv = 56; return Math.round(y * lv) / lv; // mild bitcrush grit +} +let lowL = 0, bandL = 0, lowR = 0, bandR = 0, si = 0; for (let i = 0; i < N; i++) { - outL[i] = Math.tanh((busL[i] * duck[i] + drm[i] * 0.96) * 1.05); - outR[i] = Math.tanh((busR[i] * duck[i] + drm[i] * 0.96) * 1.05); + const t = i / SR; + while (si < segs.length - 1 && t >= segs[si].e) si++; + const sg = segs[si], p = (t - sg.s) / Math.max(1e-6, sg.e - sg.s); + let cut; + if (sg.k.startsWith("drop")) { // GENTLE breathing — stays open + const lfo = 0.5 + 0.5 * Math.sin(TAU * wobHz * t); + cut = 13000 - WOB * 6000 * lfo; // ~13k → ~10.6k at WOB 0.4 (subtle) + } else if (sg.k.startsWith("break")) { // dramatic down-up sweep + cut = 9000 - 8500 * Math.sin(Math.PI * p); + } else if (sg.k === "intro") { + cut = 6000; // open — the shanty rings clear + } else if (sg.k === "buildA") { + cut = 1200 * Math.pow(24000 / 1200, p); // rising build into drop 1 + } else { cut = 12000 - 9500 * p; } // outro close + cut = Math.max(220, Math.min(fcMax, cut)); + const f = 2 * Math.sin(Math.PI * cut / SR); + let mL = crunch((busL[i] * duck[i] + drm[i] * 0.96) * 1.05); + let mR = crunch((busR[i] * duck[i] + drm[i] * 0.96) * 1.05); + const hL = mL - lowL - qc * bandL; bandL += f * hL; lowL += f * bandL; + const hR = mR - lowR - qc * bandR; bandR += f * hR; lowR += f * bandR; + // vocals: clean, post-filter, LOUD, lightly side-chained to the kick so + // they pump/breathe with the beat without losing audibility. + // opening swell — starts near-silent and fades in very slowly, staying + // quiet for most of the intro before swelling up into the first drop. + const swell = t < drop1T ? 0.04 + 0.96 * Math.pow(t / drop1T, 2.6) : 1; + const vduck = 0.74 + 0.26 * duck[i]; + outL[i] = lowL * swell + vocL[i] * 1.5 * vduck; + outR[i] = lowR * swell + vocR[i] * 1.5 * vduck; +} + +// ── SPACEY HEALING DRONE — evolving Solfeggio-ish tones (F minor) that +// slowly GLIDE in pitch, drift in pan, and shimmer in amplitude — clean, +// post-filter, a deep-space healing bed. Phase accumulators per voice so +// the pitch glides are smooth. +{ + const durS = N / SR; + const H = [ + { f: 174.61, pr: 0.013, pd: 2.0, panr: 0.05, ampr: 0.06 }, // F3 (Solfeggio 174) + { f: 261.63, pr: 0.017, pd: 1.5, panr: 0.07, ampr: 0.04 }, // C4 + { f: 392.00, pr: 0.011, pd: 3.0, panr: 0.09, ampr: 0.05 }, // G4 + { f: 523.25, pr: 0.019, pd: 2.0, panr: 0.06, ampr: 0.07 }, // C5 (~528) + ]; + const ph = H.map(() => 0); + for (let i = 0; i < N; i++) { + const t = i / SR; + const fade = Math.min(1, t / 6) * Math.min(1, (durS - t) / 6); + for (let k = 0; k < H.length; k++) { + const h = H[k]; + const f = h.f * Math.pow(2, (h.pd / 12) * Math.sin(TAU * h.pr * t + k)); // slow pitch glide + ph[k] += TAU * f / SR; + const amp = 0.019 * (0.5 + 0.5 * Math.sin(TAU * h.ampr * t + k * 1.7)) * fade; + const pan = Math.sin(TAU * h.panr * t + k * 2.1); // wide evolving pan + const v = Math.sin(ph[k]) * amp; + outL[i] += v * (0.5 - pan * 0.5); + outR[i] += v * (0.5 + pan * 0.5); + } + } +} + +// ── SINE "TINGE" DROPLETS — cute high descending sine pings scattered +// through the track (clean, post-filter), random pan, quick decay. +{ + const dropF = [523.25, 587.33, 659.25, 783.99, 880.0]; // C D E G A, high + sweet + for (let b = 4; b < totalBars; b++) { + if (Math.random() < 0.55) continue; // sparse + const t0 = b * bar + Math.random() * bar; + const f0 = dropF[Math.floor(Math.random() * dropF.length)]; + const dur = 0.4, n = Math.floor(dur * SR), s0 = Math.floor(t0 * SR), pan = Math.random() * 2 - 1; + let p = 0; + for (let i = 0; i < n; i++) { + const t = i / SR, f = f0 * Math.pow(2, -1.6 * t); // pitch drops + p += TAU * f / SR; + const v = Math.sin(p) * Math.exp(-t * 7) * 0.085; + if (s0 + i < N) { outL[s0 + i] += v * (0.5 - pan * 0.5); outR[s0 + i] += v * (0.5 + pan * 0.5); } + } + } +} + +// ── PAYPHONE INTRO — the first ~6s come through a tinny telephone (narrow +// 400–3000 Hz band, quiet, a touch of crush), then ALL the harmonics fade +// in full over ~1.5s. The track "picks up off the line". +{ + const PHONE = 6.0, XF = 1.6, endI = Math.floor((PHONE + XF) * SR); + let s1L = 0, s2L = 0, s1R = 0, s2R = 0; + const aLo = 1 - Math.exp(-2 * Math.PI * 400 / SR); // HP corner + const aHi = 1 - Math.exp(-2 * Math.PI * 3000 / SR); // LP corner + for (let i = 0; i < endI && i < N; i++) { + const t = i / SR; + const xL = outL[i], xR = outR[i]; + s1L += aLo * (xL - s1L); let bL = xL - s1L; s2L += aHi * (bL - s2L); bL = s2L; + s1R += aLo * (xR - s1R); let bR = xR - s1R; s2R += aHi * (bR - s2R); bR = s2R; + const crush = (x) => Math.round(x * 28) / 28; // gritty phone-line bitcrush + const wetL = crush(bL) * 0.42, wetR = crush(bR) * 0.42; + const mix = t < PHONE ? 0 : (t - PHONE) / XF; // 0 = full phone, 1 = full dry + outL[i] = wetL * (1 - mix) + xL * mix; + outR[i] = wetR * (1 - mix) + xR * mix; + } } // normalize headroom so the master chain has room to work @@ -1026,11 +1531,60 @@ const n = 0.78 / peak; for (let i = 0; i < N; i++) { outL[i] *= n; outR[i] *= n; } } -// ── trim trailing silence (keep 0.6s tail for the bell) ────────────── -let tailEnd = N - 1; -const TAIL_THRESH = 0.0005; +// ── TAPE-STOP ENDING — over the last ~24s the BPM eases DOWN from 160 to +// ~80 (rate 0.5) and just keeps humming there, pitch sagging — not a full +// death-to-zero, more a "settling into a slow heartbeat". Resamples the tail. +{ + const TAPE = 24.0; + const ts0 = Math.max(0, Math.floor((totalBars * bar - TAPE) * SR)); + const srcLen = N - ts0; + const tailL = [], tailR = []; + let pos = 0; + while (ts0 + pos < N - 1) { + const prog = pos / srcLen; + const rate = Math.max(0.5, 1 - prog * prog * 0.5); // eases to ~80 BPM, keeps humming + const sp = ts0 + pos, j = Math.floor(sp), fr = sp - j; + tailL.push(outL[j] * (1 - fr) + outL[j + 1] * fr); + tailR.push(outR[j] * (1 - fr) + outR[j + 1] * fr); + pos += rate; + } + outN = ts0 + tailL.length; + const nL = new Float32Array(outN), nR = new Float32Array(outN); + nL.set(outL.subarray(0, ts0)); nR.set(outR.subarray(0, ts0)); + for (let i = 0; i < tailL.length; i++) { nL[ts0 + i] = tailL[i]; nR[ts0 + i] = tailR[i]; } + outL = nL; outR = nR; +} + +// ── trim trailing silence, keep a generous tail ────────────────────── +let tailEnd = outN - 1; +const TAIL_THRESH = 0.0003; while (tailEnd > 0 && Math.abs(outL[tailEnd]) < TAIL_THRESH && Math.abs(outR[tailEnd]) < TAIL_THRESH) tailEnd--; -const trimN = Math.min(N, tailEnd + Math.floor(0.6 * SR)); +const trimN = Math.min(outN, tailEnd + Math.floor(1.0 * SR)); + +// ── PHONE-WORLD LOOP — over the last ~6s the track morphs BACK into the +// quiet telephone band (matching the intro), so it loops seamlessly from +// the dying tail straight into the payphone opening. +{ + const PH = 6.0, ph0 = Math.max(0, trimN - Math.floor(PH * SR)); + let s1L = 0, s2L = 0, s1R = 0, s2R = 0; + const aLo = 1 - Math.exp(-2 * Math.PI * 400 / SR), aHi = 1 - Math.exp(-2 * Math.PI * 3000 / SR); + for (let i = ph0; i < trimN; i++) { + const xL = outL[i], xR = outR[i]; + s1L += aLo * (xL - s1L); let bL = xL - s1L; s2L += aHi * (bL - s2L); bL = s2L; + s1R += aLo * (xR - s1R); let bR = xR - s1R; s2R += aHi * (bR - s2R); bR = s2R; + const cr = (x) => Math.round(x * 28) / 28; + const mix = (i - ph0) / (trimN - ph0); // 0 = dry, 1 = full phone + outL[i] = xL * (1 - mix) + cr(bL) * 0.42 * mix; + outR[i] = xR * (1 - mix) + cr(bR) * 0.42 * mix; + } +} +// gentle fade on the last 1.2s so the loop seam doesn't click +const fadeN = Math.min(trimN, Math.floor(1.2 * SR)); +for (let i = 0; i < fadeN; i++) { + const g = Math.cos((Math.PI / 2) * (i / fadeN)); + const idx = trimN - fadeN + i; + outL[idx] *= g; outR[idx] *= g; +} // ── write interleaved stereo f32 raw ───────────────────────────────── const outPath = expandHome(flags.out) || resolve(HERE, "../out/maytrax.mp3"); diff --git a/pop/maytrax/bin/sing-shouts.mjs b/pop/maytrax/bin/sing-shouts.mjs new file mode 100644 --- /dev/null +++ b/pop/maytrax/bin/sing-shouts.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// sing-shouts.mjs — turn the dry jeffrey-pvc shout clips (gen-shouts.mjs) +// into LONG, AUTOTUNED, HARMONIZED sustains — jeffrey's voice stretched out +// and stacked into a flute-like harmony choir, pitched to the maytrax.np +// F-minor notes. +// +// For each phrase: +// 1. WORLD pitch-lock (bin/pitchsnap_world.py) onto the target note(s) +// from maytrax.np — full clamp + a little vibrato (emo autotune). +// 2. Time-stretch a lot longer (rubberband) so the vowels ring as held +// notes across the drop. +// 3. HARMONIZE — sum formant-preserving pitch-shifted copies (octave-up +// airy "flute", fifth, minor third) so each sustain blooms into a +// harmonized flute-choir. +// +// Rewrites out/shouts/.wav in place (maytrax.mjs loads the same paths). +// +// node pop/maytrax/bin/sing-shouts.mjs # hold ≈ 3 s +// node pop/maytrax/bin/sing-shouts.mjs --hold 4 --harmonies "12:0.5,7:0.35,3:0.3" + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const POP = resolve(LANE, ".."); +const PY = resolve(POP, ".venv", "bin", "python"); +const PSNAP = resolve(POP, "bin", "pitchsnap_world.py"); +const SR = 48000; + +const argv = process.argv.slice(2); +const takeFlag = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const HOLD = parseFloat(takeFlag("--hold", "4.2")); +const VIB_CENTS = takeFlag("--vibrato-cents", "22"); +const RETAIN = takeFlag("--retain", "1.0"); +// flute-choir harmony stack: "semis:gain,…" relative to the autotuned lead +const HARMONIES = takeFlag("--harmonies", "12:0.5,7:0.35,3:0.28") + .split(",").map((s) => s.trim()).filter(Boolean) + .map((s) => { const [semi, g] = s.split(":"); return { semi: parseFloat(semi), gain: parseFloat(g) }; }); + +const NOTES = { + wake_up: ["F4", "F4"], + it_s_real: ["G#4", "G4"], + hold_on: ["F4", "F4"], + let_go: ["C5", "C5"], + follow_the_white_rabbit: ["F4", "F4", "G#4", "G4", "F4", "F4"], + now: ["C5"], +}; +const HOLD_FOR = { follow_the_white_rabbit: HOLD * 1.7, now: HOLD * 1.5 }; + +const SH_PATH = resolve(LANE, "shouts.json"); +if (!existsSync(SH_PATH)) { console.error("✗ run gen-shouts.mjs first (no shouts.json)"); process.exit(1); } +const sh = JSON.parse(readFileSync(SH_PATH, "utf8")); +const run = (cmd, args) => spawnSync(cmd, args, { stdio: ["ignore", "ignore", "inherit"] }); + +// ── f32 wav read/write + rubberband helpers (mono) ─────────────────── +function readF32(path) { + const r = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-i", path, "-f", "f32le", "-ar", String(SR), "-ac", "1", "-"], { maxBuffer: 1 << 30 }); + return new Float32Array(r.stdout.buffer, r.stdout.byteOffset, Math.floor(r.stdout.length / 4)); +} +function writeF32Wav(buf, path) { + const n = buf.length, b = Buffer.alloc(44 + n * 4); + b.write("RIFF", 0); b.writeUInt32LE(36 + n * 4, 4); b.write("WAVE", 8); + b.write("fmt ", 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(3, 20); b.writeUInt16LE(1, 22); + b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(32, 34); + b.write("data", 36); b.writeUInt32LE(n * 4, 40); + for (let i = 0; i < n; i++) b.writeFloatLE(buf[i], 44 + i * 4); + writeFileSync(path, b); +} +function pitchShift(buf, semis) { + if (semis === 0) return buf.slice(); + const pin = resolve(tmpdir(), "maytrax-harm-in.wav"), pout = resolve(tmpdir(), `maytrax-harm-${semis}.wav`); + writeF32Wav(buf, pin); + const r = spawnSync("rubberband", ["-p", String(semis), "-F", "--pitch-hq", pin, pout], { stdio: "ignore" }); + if (r.status !== 0 || !existsSync(pout)) return buf.slice(); + return readF32(pout); +} + +// transpose note names by `semis` (deeper voice = negative) +const NM2 = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const NNm = { C: 0, "C#": 1, D: 2, "D#": 3, E: 4, F: 5, "F#": 6, G: 7, "G#": 8, A: 9, "A#": 10, B: 11 }; +function transpose(note, semis) { + const m = /^([A-G]#?)(-?\d)$/.exec(note); if (!m) return note; + const midi = NNm[m[1]] + (parseInt(m[2], 10) + 1) * 12 + semis; + return NM2[((midi % 12) + 12) % 12] + (Math.floor(midi / 12) - 1); +} +// autotune (WORLD) → stretch (rubberband) → harmonize (flute stack) → write +function singClip(key, notes, srcWav, outWav, label, semis = 0) { + if (!existsSync(srcWav)) return; + if (semis) notes = notes.map((n) => transpose(n, semis)); + const dur = parseFloat(spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", srcWav]).stdout.toString().trim()) || 0.6; + const tuned = resolve(tmpdir(), `maytrax-${key}-${label}-tuned.wav`); + const stretched = resolve(tmpdir(), `maytrax-${key}-${label}-stretch.wav`); + if (run(PY, [PSNAP, srcWav, tuned, "--notes", notes.join(","), "--retain", RETAIN, "--xfade-ms", "60", + "--vibrato-hz", "5.5", "--vibrato-cents", VIB_CENTS, "--vibrato-onset-ms", "180"]).status !== 0 || !existsSync(tuned)) { console.warn(`! pitch-lock failed ${key}/${label}`); return; } + const target = HOLD_FOR[key] ?? HOLD, ratio = Math.max(1.5, target / dur); + if (run("rubberband", ["-t", ratio.toFixed(3), "-c", "4", tuned, stretched]).status !== 0) { console.warn(`! stretch failed ${key}/${label}`); return; } + const lead = readF32(stretched), mix = new Float32Array(lead.length); + for (let i = 0; i < lead.length; i++) mix[i] = lead[i]; + for (const h of HARMONIES) { const v = pitchShift(lead, h.semi); for (let i = 0; i < lead.length && i < v.length; i++) mix[i] += v[i] * h.gain; } + const atk = Math.floor(0.04 * SR), rel = Math.floor(0.12 * SR); + for (let i = 0; i < atk && i < mix.length; i++) mix[i] *= i / atk; + for (let i = 0; i < rel && i < mix.length; i++) mix[mix.length - 1 - i] *= i / rel; + let pk = 0; for (let i = 0; i < mix.length; i++) pk = Math.max(pk, Math.abs(mix[i])); + if (pk > 0.97) { const g = 0.97 / pk; for (let i = 0; i < mix.length; i++) mix[i] *= g; } + writeF32Wav(mix, outWav); + console.log(`✓ ${key.padEnd(22)} ${label.padEnd(7)} ${notes.join(",")} · ${dur.toFixed(2)}s → ${(lead.length / SR).toFixed(1)}s + ${HARMONIES.length}v harmony`); +} + +for (const [key, notes] of Object.entries(NOTES)) { + const entry = sh[key]; if (!entry) { console.warn(`! missing ${key}`); continue; } + // jeffrey-pvc: immutable source = the dry .mp3 → decode → process → .wav + const drySrc = entry.path.replace(/\.wav$/, ".mp3"); + if (existsSync(drySrc)) { + const dry = resolve(tmpdir(), `maytrax-${key}-dry.wav`); + if (run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", drySrc, "-ar", String(SR), "-ac", "1", dry]).status === 0) + singClip(key, notes, dry, entry.path, "jeffrey", -12); // deeper voiced + } + // Apple say: immutable source = -say.wav → process → -say-sung.wav + const saySrc = entry.path.replace(/\.wav$/, "-say.wav"); + singClip(key, notes, saySrc, entry.path.replace(/\.wav$/, "-say-sung.wav"), "apple"); +} +console.log("✓ shouts (jeffrey + apple say) autotuned + stretched + harmonized — re-run maytrax.mjs"); diff --git a/pop/maytrax/kit.json b/pop/maytrax/kit.json new file mode 100644 --- /dev/null +++ b/pop/maytrax/kit.json @@ -0,0 +1,252 @@ +{ + "taiko": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/82712-taiko_drum_001_wav.wav", + "id": 82712, + "name": "TAIKO DRUM 001.wav", + "username": "sandyrb", + "license": "https://creativecommons.org/licenses/by/4.0/", + "duration": 2.43889, + "pitched": false, + "root": null + }, + "gong": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/518292-gong_2_wav.wav", + "id": 518292, + "name": "gong 2.wav", + "username": "Logicogonist", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 5.82755, + "pitched": false, + "root": null + }, + "choir": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/162168-aah2_wav.wav", + "id": 162168, + "name": "aah2.wav", + "username": "HuntersCrossbow", + "license": "http://creativecommons.org/licenses/by/3.0/", + "duration": 2.69351, + "pitched": true, + "root": "C4" + }, + "brass": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/820070-cm_orch_hit_3_infernal_dance_2_207s.wav", + "id": 820070, + "name": "Cm Orch Hit +3 Infernal Dance [2.207s]", + "username": "astro_denticle", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 2.2071, + "pitched": true, + "root": "C3" + }, + "strings": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/758483-arc_c.wav", + "id": 758483, + "name": "Arc - C", + "username": "James_KuKu", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 8.25, + "pitched": true, + "root": "C4" + }, + "bass": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/560208-layered_bass_05_quick_synth_wav.wav", + "id": 560208, + "name": "layered bass 05 quick synth.wav", + "username": "johnnypanic", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 1.87372, + "pitched": true, + "root": "C2" + }, + "ghost": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/46567-ludwig_black_beauty_snare_ghost_notes_unprocesse.wav", + "id": 46567, + "name": "Ludwig Black Beauty Snare Ghost Notes Unprocessed.wav", + "username": "pjcohen", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.423855, + "pitched": false, + "root": null + }, + "hat": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/509971-hi_hat_closed_9_mp3.wav", + "id": 509971, + "name": "Hi-Hat Closed 9.mp3", + "username": "blakengouda", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.144739, + "pitched": false, + "root": null + }, + "ohat": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/509984-hi_hat_open_1_mp3.wav", + "id": 509984, + "name": "Hi-Hat Open 1.mp3", + "username": "blakengouda", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.667188, + "pitched": false, + "root": null + }, + "ride": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/799423-ride_bell_1_long.wav", + "id": 799423, + "name": "ride bell 1 long", + "username": "Logicogonist", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 1.73696, + "pitched": false, + "root": null + }, + "shaker": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/375636-shaker_shake5_wav.wav", + "id": 375636, + "name": "Shaker (shake5.wav)", + "username": "sgossner", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.159955, + "pitched": false, + "root": null + }, + "sub": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/6291-ppg_016_subfrequent_g_2_wav.wav", + "id": 6291, + "name": "PPG 016 Subfrequent G#2.wav", + "username": "Jovica", + "license": "https://creativecommons.org/licenses/by/4.0/", + "duration": 2.6705, + "pitched": true, + "root": "C1" + }, + "reese": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/424432-angryreesebassf0160bpm_wav.wav", + "id": 424432, + "name": "AngryReeseBassF0160bpm.wav", + "username": "__stone__", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 3.375, + "pitched": true, + "root": "C2" + }, + "lead": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/257363-overall_quality_of_single_note_flute_c4.wav", + "id": 257363, + "name": "overall quality of single note - flute - C4", + "username": "yano1", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 3.959, + "pitched": true, + "root": "C4" + }, + "pad": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/46127-k_wstr_wav.wav", + "id": 46127, + "name": "k_wSTR.wav", + "username": "k0wax", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 5.31574, + "pitched": true, + "root": "C3" + }, + "kick": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/337827-kick_short_tail_174_bpm_02_wav.wav", + "id": 337827, + "name": "Kick_Short_Tail_174_Bpm_02.wav", + "username": "hardwareshaba", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.34483, + "pitched": false, + "root": null + }, + "snare": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/118312-lr_sn_0029_wav.wav", + "id": 118312, + "name": "LR_SN_0029.wav", + "username": "choomaque-crispydinner", + "license": "http://creativecommons.org/licenses/by/3.0/", + "duration": 0.273492, + "pitched": false, + "root": null + }, + "animal": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/571287-jaguar_growl_roar_wav.wav", + "id": 571287, + "name": "jaguar_growl_roar.wav", + "username": "Lewis.B.M", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 2.73996, + "pitched": true, + "root": "C3" + }, + "wolf": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/429109-15_wolfcrying_wav.wav", + "id": 429109, + "name": "15-WolfCrying.wav", + "username": "cazadordoblekatana", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 3.38993, + "pitched": true, + "root": "A3" + }, + "cowbell": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/75338-cowbell_wav.wav", + "id": 75338, + "name": "Cowbell.wav", + "username": "Neotone", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.349887, + "pitched": false, + "root": null + }, + "tom": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/581462-fractanimal_acoustic_drum_kit_mid_tom_2_wav.wav", + "id": 581462, + "name": "Fractanimal_Acoustic_Drum_Kit_Mid_Tom_2.wav", + "username": "johnnydekk", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 1.36662, + "pitched": false, + "root": null + }, + "click": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/375399-snare_sfx_click_wav.wav", + "id": 375399, + "name": "Snare (sfx_click.wav)", + "username": "sgossner", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 0.195102, + "pitched": false, + "root": null + }, + "rain": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/371710-raining.wav", + "id": 371710, + "name": "Raining", + "username": "Jofae", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 22.3864, + "pitched": false, + "root": null + }, + "jungle": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/644989-jungle_forest_ambience.wav", + "id": 644989, + "name": "Jungle / Forest Ambience", + "username": "seventhsamurai", + "license": "http://creativecommons.org/publicdomain/zero/1.0/", + "duration": 39.5312, + "pitched": false, + "root": null + }, + "cricket": { + "path": "/Users/jas/aesthetic-computer-vault/personal/pop/freesound-cache/710399-landscape_nl_march_1131am_230302_0566.wav", + "id": 710399, + "name": "landscape NL March 1131AM 230302_0566", + "username": "klankbeeld", + "license": "https://creativecommons.org/licenses/by/4.0/", + "duration": 34.448, + "pitched": false, + "root": null + } +} diff --git a/pop/maytrax/maytrax.illy.txt b/pop/maytrax/maytrax.illy.txt new file mode 100644 --- /dev/null +++ b/pop/maytrax/maytrax.illy.txt @@ -0,0 +1,1 @@ +a photographic, dreamlike night portrait of jeffrey in a misty jungle meadow in a warm rain — genre-bending and a little surreal, but real (a true photo, not an illustration). it is night; the only light is the green-and-cyan glow of his open citrus-green macbook neo screen lighting his face and the rain from below, plus distant heat-lightning. tall wet meadow grass and dripping jungle leaves around him, fireflies and a faint mist. a torn white-paper scrap taped over the laptop lid carries a thick hand-penned whistlegraph butterfly (drawn, never a real apple or brand logo). half-hidden in the dark foliage behind him, the eyeshine of a big cat and a lone wolf catching the screen glow. an old payphone handset rests in the grass beside him, cord trailing off. he leans into the screen, calm and absorbed, rain beading on a dark hoodie. fine 35mm film grain, deep shadows, soft green screen-glow as the key light, droplets catching the light. NOT neon-noir, NOT a movie poster — a grounded, slightly uncanny night photograph. square album-cover composition, top quarter darker for a title. no text, no wordmarks, no captions. diff --git a/pop/maytrax/shouts.json b/pop/maytrax/shouts.json new file mode 100644 --- /dev/null +++ b/pop/maytrax/shouts.json @@ -0,0 +1,32 @@ +{ + "wake_up": { + "phrase": "wake up", + "path": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/wake_up.wav", + "sayPath": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/wake_up-say.wav" + }, + "it_s_real": { + "phrase": "it's real", + "path": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/it_s_real.wav", + "sayPath": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/it_s_real-say.wav" + }, + "hold_on": { + "phrase": "hold on", + "path": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/hold_on.wav", + "sayPath": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/hold_on-say.wav" + }, + "let_go": { + "phrase": "let go", + "path": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/let_go.wav", + "sayPath": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/let_go-say.wav" + }, + "follow_the_white_rabbit": { + "phrase": "follow the white rabbit", + "path": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/follow_the_white_rabbit.wav", + "sayPath": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/follow_the_white_rabbit-say.wav" + }, + "now": { + "phrase": "now", + "path": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/now.wav", + "sayPath": "/Users/jas/aesthetic-computer/pop/maytrax/out/shouts/now-say.wav" + } +}