From 279a2ceef34f9f548c7fe234b5a6aa51a4455597 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 21 May 2026 21:10:59 -0400 Subject: [PATCH] =?UTF-8?q?pop:=20helpabeach=20=E2=80=94=20Rhizome=20Healt?= =?UTF-8?q?h=20clinic=20story,=20formant=20voice,=20+=20helpabeach-short?= =?UTF-8?q?=20cut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chillwave/undabeach reworked + renamed to helpabeach. render.mjs: square→sine intro rollers, sub-octave deep bells, in-render formant computer-voice on nonsense phonemes, --no-chimes, freesound calm-ocean bed, --no-waves/--no-sweeps. gen-illy.mjs: rebuilt as the Rhizome Health clinic extraction story (9 portrait panels) + network-retry hardening. preview-score.mjs: pastel section tints. helpabeach-short: a ~1:17 compressed story cut — narrate.mjs speaks a first-person jeffrey-pvc narration, beat-aligned per section, baked in. pop/lib/render-progress.mjs + pop/bin/renders.mjs: render progress heartbeats (~/.ac-pop-renders/), wired into render/gen-illy/preview-score. Co-Authored-By: Claude Opus 4.7 (1M context) --- pop/bin/renders.mjs | 32 + pop/chillwave/bin/gen-illy.mjs | 214 ++++--- pop/chillwave/bin/harmonize.mjs | 93 +++ pop/chillwave/bin/narrate.mjs | 151 +++++ pop/chillwave/bin/preview-score.mjs | 34 +- pop/chillwave/bin/preview.mjs | 2 +- pop/chillwave/bin/render.mjs | 589 ++++++++++++++++++- pop/chillwave/bin/sing.mjs | 34 +- pop/chillwave/helpabeach-short.narration.txt | 13 + pop/chillwave/helpabeach-short.np | 35 ++ pop/chillwave/helpabeach.illy.txt | 30 +- pop/chillwave/helpabeach.np | 77 ++- pop/chillwave/helpabeach.vocal-harmony.np | 25 + pop/chillwave/helpabeach.vocal.np | 24 +- pop/lib/menu.mjs | 4 +- pop/lib/render-progress.mjs | 97 +++ 16 files changed, 1273 insertions(+), 181 deletions(-) create mode 100644 pop/bin/renders.mjs create mode 100644 pop/chillwave/bin/harmonize.mjs create mode 100644 pop/chillwave/bin/narrate.mjs create mode 100644 pop/chillwave/helpabeach-short.narration.txt create mode 100644 pop/chillwave/helpabeach-short.np create mode 100644 pop/chillwave/helpabeach.vocal-harmony.np create mode 100644 pop/lib/render-progress.mjs diff --git a/pop/bin/renders.mjs b/pop/bin/renders.mjs new file mode 100644 index 000000000..bc5a48b9a --- /dev/null +++ b/pop/bin/renders.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// renders.mjs — list the /pop renders running right now. +// +// node pop/bin/renders.mjs # one-shot list +// node pop/bin/renders.mjs --watch # refresh every 2 s +// +// Reads the heartbeat files written by pop/lib/render-progress.mjs — +// the same source the Slab menubar progress bars poll. + +import { list } from "../lib/render-progress.mjs"; + +const WATCH = process.argv.includes("--watch"); + +function bar(pct) { + if (pct == null) return "····················"; + const n = Math.round(pct / 5); + return "▓".repeat(n) + "░".repeat(20 - n); +} + +function render() { + const rs = list(); + if (WATCH) process.stdout.write("\x1b[2J\x1b[H"); + if (!rs.length) { console.log("no /pop renders running"); return; } + for (const r of rs) { + const pct = r.pct == null ? " ··" : `${String(r.pct).padStart(3)}%`; + const age = Math.round((Date.now() - (r.startedAt || Date.now())) / 1000); + console.log(`${(r.type || "?").padEnd(6)} ${bar(r.pct)} ${pct} ${r.label} (${age}s)`); + } +} + +render(); +if (WATCH) setInterval(render, 2000); diff --git a/pop/chillwave/bin/gen-illy.mjs b/pop/chillwave/bin/gen-illy.mjs index 90cece491..47c945344 100644 --- a/pop/chillwave/bin/gen-illy.mjs +++ b/pop/chillwave/bin/gen-illy.mjs @@ -9,13 +9,14 @@ // Cached: if the cover already exists, --force to regen. // // Usage: -// node bin/gen-illy.mjs --slug wundabeach -// node bin/gen-illy.mjs --slug wundabeach --force -// node bin/gen-illy.mjs --slug wundabeach --size 1024x1536 +// node bin/gen-illy.mjs --slug helpabeach +// node bin/gen-illy.mjs --slug helpabeach --force +// node bin/gen-illy.mjs --slug helpabeach --size 1024x1536 import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import * as progress from "../../lib/render-progress.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const LANE = resolve(HERE, ".."); @@ -31,7 +32,7 @@ for (let i = 2; i < process.argv.length; i++) { else { flags[a.slice(2)] = next; i++; } } -const SLUG = flags.slug || "undabeach"; +const SLUG = flags.slug || "helpabeach"; // --portrait → vertical 9:16 set: gpt-image 1024x1536, "-p" filename // infix, and a tall-composition override appended to every prompt. const PORTRAIT = flags.portrait === true; @@ -41,12 +42,19 @@ const FORCE = flags.force === true; const PROMPT_PATH = `${LANE}/${SLUG}.illy.txt`; const OUT_PATH = `${LANE}/out/${SLUG}${TAG}-cover.png`; const PORTRAIT_NOTE = - "PORTRAIT OVERRIDE — recompose for a TALL vertical 9:16 frame (not square): " + - "jeffrey seated low in the lower third, the closed laptop near the bottom, " + - "and a large expanse of open beach sky filling the upper two-thirds. keep the " + - "rough-tooth colored-pencil medium, the no-outline volume modeling, the white " + - "paper scrap + penned whistlegraph butterfly on the lid, and the same light/age " + - "for this section — only the framing changes to tall vertical."; + "PORTRAIT OVERRIDE — recompose for a TALL vertical 9:16 frame (not square), " + + "and PULL THE CAMERA BACK and slightly UP — a WIDE, roomy shot with a generous " + + "band of loft CEILING showing above the windows: the WHOLE Rhizome Health clinic " + + "is visible around the figures — exam bench, counter, rolling cart, tall arched " + + "windows, ceiling, plants, pale wood floor — a complete room, the figures " + + "comfortably within it, NEVER cropped tight. CRUCIAL — the PROJECTED Rhizome " + + "Health logo sits HIGH and HORIZONTALLY CENTRED in the upper frame, inside the " + + "central safe zone with a wide cream margin on BOTH sides, never near an edge: " + + "the entire 'Rhizome Health' wordmark legible, both words complete, prominent, " + + "'Health' never cut off — clear billboard signage centred above the scene. keep " + + "the colored-pencil + gouache medium on cream paper, the no-outline volume " + + "modeling, diegetic light only, and the same story beat for this section — only " + + "the framing widens."; mkdirSync(`${LANE}/out`, { recursive: true }); @@ -65,20 +73,17 @@ const SELFIE_REFS = [ `${ARCHIVE_DIR}/2025-01-25_DFQ2lHPzN_W.jpg`, `${ARCHIVE_DIR}/2017-04-10_BStid5yjTHq.jpg`, ]; -// Beach + shirtless refs — used for chillwave beach gens specifically. -// Pulled from curated/jeffrey-described.jsonl by filtering on -// shirtless/beach/pool tags + is_jeffrey_confirmed=true + confidence>=0.85. -const BEACH_REFS = [ - `${ARCHIVE_DIR}/2020-07-01_CCFxuLdFeTR.jpg`, // shirtless outdoor sunny - `${ARCHIVE_DIR}/2019-07-13_Bz3cjM2lCsb.jpg`, // shirtless wet-hair beach/pool - `${ARCHIVE_DIR}/2021-08-18_CSuBwrPFtfN_2.jpg`, // shirtless retro (torso ref) - `${ARCHIVE_DIR}/2016-11-25_BNP3aLbj917.jpg`, // beach environment context -]; +// Rhizome Health logo lockup — handed to the model so the PROJECTED +// clinic logo (green radiating-roots mark + wordmark + cyan +// Aesthetic.Computer line + grey tagline) renders faithfully on the +// back wall. Same ref the /marketing rhizome-health campaign uses. +const RHIZOME_LOGO_REF = + `${REPO}/marketing/campaigns/rhizome-health/refs/rhizome-health-logo.png`; // The whistlegraph butterfly, given to the model as a REFERENCE so it // DRAWS it (hand-penned, in the colored-pencil medium) on a white -// paper scrap on the lid — never composited/pasted afterwards. +// paper scrap on jeffrey's closed Neo lid — never composited/pasted. const BUTTERFLY_REF = `${LANE}/assets/wg-scrap.png`; -const REFS = [...SHOOT_REFS, ...SELFIE_REFS, ...BEACH_REFS, BUTTERFLY_REF].filter((p) => { +const REFS = [...SHOOT_REFS, ...SELFIE_REFS, RHIZOME_LOGO_REF, BUTTERFLY_REF].filter((p) => { if (existsSync(p)) return true; console.warn(` ⚠ ref missing, dropping: ${p}`); return false; @@ -104,23 +109,35 @@ if (!existsSync(PROMPT_PATH)) { const SECTIONS_MODE = flags.sections === true; -// Per-section time-arc — a SUBTLE vacation day easing from bright -// midday into a soft evening. Gentle, natural, restful; no tech, no -// drama — only the light and warmth slowly shifting. Keys MUST match -// struct.sections names + ordering so preview-score.mjs's safeName() -// resolves the files. -const SECTION_ORDER = ["tide-in", "drift 1", "swell", "drift 2", "tide-out"]; +// Per-section arc — THE EXTRACTION STORY. The 9 song sections tell one +// satirical visit: jeffrey arrives, signs in, has his digital "cultural +// juices" extracted (a 3-panel arc — begins → scary flickering peak → +// calms), gets a gentle checkup, is paid $800 cash, a handshake, and +// leaves. Keys MUST match struct.sections names + ordering so +// preview-score.mjs's safeName() resolves the files. +const SECTION_ORDER = [ + "tide-in", "drift 1", "swell 1", "deep-current", "drift 2", + "undertow", "swell 2", "tide-out", "ebb", +]; const SECTION_VARIANTS = { "tide-in": - "SECTION OVERRIDE — tide-in (bright midday): clear easy overhead daylight, natural true colour, soft blue sky, calm empty sea. jeffrey upright and relaxed, hand in the wet sand. AGE: about 30 — smooth-faced, brown hair, lean and fit, a quiet natural soldier's strength. NO tattoos, NO jewelry. TRUNKS: plain SOLID navy swim trunks, no pattern at all. OBJECT (OVERRIDES THE BASE PROMPT AND THE BUTTERFLY REFERENCE FOR THIS PANEL ONLY): a BRAND-NEW, factory-fresh, perfectly clean chartreuse MacBook Neo — on the smooth lid ONLY the plain standard small Apple logo, nothing else; ABSOLUTELY NO paper, NO scrap, NO tape, NO butterfly, NO ink — ignore the butterfly reference here. FIGURE: VERY far down the empty beach, a lone WOMAN — a tiny faraway ambiguous silhouette, barely there, never identifiable. the most resolved, fully-modeled panel.", + "SECTION OVERRIDE — tide-in (jeffrey arrives from the street): the widest, most establishing panel — the Rhizome Health clinic seen almost from OUTSIDE, looking in through the open cast-iron loft entrance from the sunlit SoHo sidewalk; a sliver of street, pavement and a curbside planter frame the doorway. through it the whole clinic opens up — tall arched windows, a band of ceiling, white brick, exam bench, counter, rolling cart, plants, and the projected 'Rhizome Health' logo glowing HIGH and CENTRED on the back wall, the complete wordmark fully visible. jeffrey has just stepped through the doorway, mid-stride, fairly small in the frame, his chartreuse Neo CLOSED under one arm, looking into the room. the clinician across the room by the cart glances up to greet him. calm bright mid-morning, lights steady and warm. the OFFICE itself is the subject — jeffrey is simply arriving. this panel may break peer-framing: jeffrey small in the doorway, the room is the hero.", "drift 1": - "SECTION OVERRIDE — drift 1 (early afternoon): sun off overhead, light flattening and warming, faint summer haze. jeffrey gazing at the calm water, content. AGE: about 35 — a touch weathered, browned and stronger, an aging-into-strength soldier's build with a faint MILITARY bearing now. NO tattoos, NO jewelry. TRUNKS: plain SOLID forest-green trunks (a quiet colour shift from navy) — still NO pattern. OBJECT: still a chartreuse MacBook Neo but slightly OLDER-looking and lightly scuffed — a small torn WHITE PAPER SCRAP now stuck over the Apple logo with the hand-penned whistlegraph BUTTERFLY on it. it stays a laptop; NO book, NO pen. FIGURE: the woman is still FAR off, a soft distant shadow / silhouette, a little older in her walk — never clearly identifiable. bright and peaceful.", - "swell": - "SECTION OVERRIDE — swell (warm mid-afternoon): warmest, most golden panel — soft amber light, long gentle shadows, calm glinting sea, natural and dusty, never neon. AGE: about 40 — leaner, sun-worn, a few grey hairs, powerfully built like a veteran soldier; a clearly MILITARY hardness in posture, but he KEEPS his usual MEDIUM-LENGTH tousled brown hair — NOT short, NOT buzzed, NOT a crew cut, NOT a military haircut; same windblown medium hair as the other panels, just lightly greying. NO tattoos, NO jewelry. TRUNKS: plain SOLID faded brick-red trunks — solid colour only, NO pattern. OBJECT: the same laptop, now visibly DATED and worn — a clunkier older model, yellowed and scratched, the white scrap + butterfly grubbier. still a laptop; NO book, NO pen, NO gilding. FIGURE: the woman is still FAR off, a distant shadow — and now a small CHILD walks beside her holding her hand, also a tiny far shadow. both ambiguous, never identifiable. easy, drowsy.", + "SECTION OVERRIDE — drift 1 (he signs in): jeffrey has settled onto the edge of the exam bench, his chartreuse Neo now OPEN on his lap — one hand still typing on it as ever — while with his other hand he signs an intake / consent form on a clipboard the clinician holds out (a tiny green root-mark at the form's corner). the laminated '@jeffrey' handle wristband is on his wrist. calm, bright, even mid-morning daylight, lights steady. both relaxed, peers. the projected logo steady, its handle-count at its LOWEST. jeffrey faintly amused, half his attention still on the laptop. the signing hands and the clipboard carry the resolved modeling.", + "swell 1": + "SECTION OVERRIDE — swell 1 (the extraction begins): jeffrey reclines back on the exam bench, his open Neo still on his lap, still typing one-handed. the clinician gently clips a soft, pale, organic-tech extractor to his forearm — a faintly Cronenberg sci-fi soft-bodied apparatus, a flexible translucent conduit running from it to a clear empty glass vessel on the cart. the FIRST faint glowing matrixy bits — little green code-glyphs and colored pixels — begin to drift up out of jeffrey along the conduit. lights still normal and warm; the mood curious, not yet scary. jeffrey mildly intrigued, unbothered. the extraction is starting — building. NO blood, NO needle-in-skin, NO wound — only light and code leaving him.", + "deep-current": + "SECTION OVERRIDE — deep-current (the scary peak — lights flickering): the unsettling moment. the loft lights FLICKER and strobe, the room dims and jumps, long shadows leaping — an eerie, uncanny Cronenberg sci-fi beat. the extraction is in full flow: a thick streaming ribbon of matrixy bits — glowing green code, colored pixel-light, jeffrey's digital 'cultural juices' and art-soul — pulled out of him along the conduit into the glass vessel, which now glows bright with captured light. the soft apparatus pulses. jeffrey's expression is UNEASY — eyes wide, gripping the edge of the bench, the glow of his own laptop stuttering with the flicker. the deepest, darkest, scariest panel — built purely from flickering light and uncanny mood. still NO blood, NO gore, NO wound — jeffrey is whole, only light and code leaving him.", "drift 2": - "SECTION OVERRIDE — drift 2 (late afternoon, cooling): sun lower, light soft rose and pale gold, hazy and cooler, colour gently muting. jeffrey leaning slightly back, calmer. AGE: about 45 — greying, deeply weathered, lean and immensely strong; distinctly MILITARY now — a hardened veteran's bearing and discipline. NO tattoos, NO jewelry. TRUNKS: plain SOLID khaki / sand trunks (military-toned) — solid colour, NO pattern. OBJECT: an old DATED, battered laptop now — thick, yellowed, grimed, an outdated clunky model; the white scrap + butterfly faded and half rubbed away. still a laptop; NO book, NO pen. FIGURE: the woman + child are still FAR off, both distant soft silhouettes, visibly older, still impossible to identify. restful, the day winding down.", + "SECTION OVERRIDE — drift 2 (it calms — the chill-out): the scare passes. the loft lights are steady, even and warm again, the room settled and ordinary. the extraction is done — the conduit unhooked and coiled, the soft extractor set aside, and the clear glass vessel now FULL, glowing gently with jeffrey's captured cultural juices, standing on the rolling cart. jeffrey exhales, relaxes back, and returns to calmly typing on his open Neo — fine again, unshaken. relief, calm restored. soft daylight, gentle mood.", + "undertow": + "SECTION OVERRIDE — undertow (a gentle checkup): the quietest, stillest panel — an ordinary soothing wellness checkup now, the clinician making sure jeffrey is alright after the extraction: she listens with a stethoscope at his back, or takes his pulse at his wrist — calm, gentle, unhurried. jeffrey sits serene, half-absorbed again in his open Neo on his lap. the hushed restful beat — soft low light, the room calm, the glowing vessel of cultural juices quiet on the cart, the projected logo a soft glow. jeffrey's expression: calm, eyes half-lowered.", + "swell 2": + "SECTION OVERRIDE — swell 2 (paid — $800 cash): the payoff, the brightest fullest panel. the clinician hands jeffrey a plain unsealed envelope with a clear fan of US one-hundred-dollar bills at its mouth — eight of them, $800 cash, payment for his extracted cultural juices. jeffrey looks UP from his laptop at last to take the envelope, a real warm shared closed-mouth smile between the two peers. the projected Rhizome Health logo flares BRIGHTEST here, its handle-count ticked to its HIGHEST, a clear lime-and-cyan glow. bright, warm, the deal done well.", "tide-out": - "SECTION OVERRIDE — tide-out (NIGHT): full night now — a deep STARRY SKY, dark sea, the most dissolved panel. jeffrey sits by a small cool blue-white LED CAMPFIRE that lights him from below; far up in the night sky a single MISSILE streaks with a thin contrail. AGE: about 50 — distinctly grey, deeply weathered, an unbreakable old MILITARY veteran who aged entirely into strength, hard and calm, lit by the cold LED fire. NO tattoos, NO jewelry. TRUNKS: plain SOLID deep-teal trunks — solid colour, NO pattern. OBJECT: an ancient, very dated battlescarred laptop — old, yellowed, cracked, clunky; the whistlegraph-butterfly scrap brown and frayed. still a laptop; NO book, NO pen. FIGURE: the woman + child are STILL present but at their MOST DISTANT and faint — tiny soft silhouettes far off in the starlit dark, very old now, almost dissolving but unmistakably still there. fewest marks, jeffrey and the LED fire easing into the night.", + "SECTION OVERRIDE — tide-out (the handshake): the visit resolves on a firm, friendly two-person HANDSHAKE — jeffrey and the clinician shaking hands, the deal sealed, both pleased. the $800 cash envelope now tucked into jeffrey's shirt pocket; his chartreuse Neo CLOSED and held under his other arm. softer, settling late-morning light, lights warm and steady. both still peers at one eye-line — a contented, ordinary, slightly absurd goodbye.", + "ebb": + "SECTION OVERRIDE — ebb (out to the street): the long dissolving outro. jeffrey steps back out through the open loft door onto the sunlit SoHo sidewalk, his CLOSED Neo under one arm and the cash envelope in hand, half-turned for a last easy look back. behind him the clinician tidies the cart, and the glass vessel of his glowing extracted cultural juices stays there on it. the room eases to quiet — the FEWEST marks of the set, much bare cream paper, the projected Rhizome Health logo glowing gently on the near-empty wall. calm, restful, almost dissolving.", }; function safeName(n) { return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); @@ -130,14 +147,9 @@ const apiKey = loadOpenAIKey(); const basePrompt = readFileSync(PROMPT_PATH, "utf8").trim(); // One gpt-image-2 edit call → write outPath. Per-file cached; --force regens. -async function generate(promptText, outPath, label) { - const rel = outPath.replace(REPO + "/", ""); - if (existsSync(outPath) && !FORCE) { - console.log(`✓ cached → ${rel}`); - return; - } - console.log(`▸ ${label} · ${SIZE} · ${REFS.length} refs`); - const t0 = Date.now(); +// Build the multipart body fresh per attempt — a FormData/Blob body is +// consumed on send and can't be re-streamed on a retry. +function buildForm(promptText) { const fd = new FormData(); fd.append("model", "gpt-image-2"); fd.append("prompt", promptText); @@ -152,51 +164,103 @@ async function generate(promptText, outPath, label) { : "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) { - const err = await res.text(); - console.error(`✗ OpenAI ${res.status} (${label}): ${err.slice(0, 600)}`); + return fd; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// One gpt-image-2 edit call → write outPath. Per-file cached; --force +// regens. Network failures (ETIMEDOUT / EPIPE mid-upload) and transient +// 429/5xx are retried with backoff so one blip can't kill the batch; a +// hard 4xx (billing / moderation) fails fast. Never throws — a failed +// panel logs and returns so the rest of the run continues. +async function generate(promptText, outPath, label) { + const rel = outPath.replace(REPO + "/", ""); + if (existsSync(outPath) && !FORCE) { + console.log(`✓ cached → ${rel}`); return; } - const json = await res.json(); - const b64 = json.data?.[0]?.b64_json; - if (!b64) { - console.error(`✗ no image (${label}): ${JSON.stringify(json).slice(0, 280)}`); - return; + console.log(`▸ ${label} · ${SIZE} · ${REFS.length} refs`); + const MAX_TRIES = 4; + for (let attempt = 1; attempt <= MAX_TRIES; attempt++) { + const t0 = Date.now(); + try { + const res = await fetch("https://api.openai.com/v1/images/edits", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: buildForm(promptText), + }); + if (!res.ok) { + const err = await res.text(); + // 429 / 5xx are transient → retry; other 4xx (billing, moderation) → stop. + const transient = res.status === 429 || res.status >= 500; + if (transient && attempt < MAX_TRIES) { + const wait = 4000 * attempt; + console.warn(` ⚠ OpenAI ${res.status} (${label}) — retry ${attempt}/${MAX_TRIES - 1} in ${wait / 1000}s`); + await sleep(wait); + continue; + } + console.error(`✗ OpenAI ${res.status} (${label}): ${err.slice(0, 600)}`); + return; + } + const json = await res.json(); + const b64 = json.data?.[0]?.b64_json; + if (!b64) { + console.error(`✗ no image (${label}): ${JSON.stringify(json).slice(0, 280)}`); + return; + } + writeFileSync(outPath, Buffer.from(b64, "base64")); + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + const u = json.usage || {}; + const tok = u.input_tokens ? ` · tok in=${u.input_tokens} out=${u.output_tokens}` : ""; + console.log(`✓ ${elapsed}s${tok} → ${rel}`); + return; + } catch (e) { + // network-level failure (fetch threw) — ETIMEDOUT / EPIPE / DNS … + const cause = e?.cause?.code || e?.cause?.message || e?.message || "unknown"; + if (attempt < MAX_TRIES) { + const wait = 4000 * attempt; + console.warn(` ⚠ network fail (${label}: ${cause}) — retry ${attempt}/${MAX_TRIES - 1} in ${wait / 1000}s`); + await sleep(wait); + continue; + } + console.error(`✗ network fail (${label}): ${cause} — gave up after ${MAX_TRIES} tries`); + return; + } } - writeFileSync(outPath, Buffer.from(b64, "base64")); - const elapsed = ((Date.now() - t0) / 1000).toFixed(1); - const u = json.usage || {}; - const tok = u.input_tokens ? ` · tok in=${u.input_tokens} out=${u.output_tokens}` : ""; - console.log(`✓ ${elapsed}s${tok} → ${rel}`); } const portraitTail = PORTRAIT ? `\n\n${PORTRAIT_NOTE}` : ""; +// --only [,…] regenerates just those panels (others untouched). +const onlySet = SECTIONS_MODE && typeof flags.only === "string" + ? new Set(flags.only.split(",").map((x) => x.trim())) + : null; +const panelIdx = SECTIONS_MODE + ? SECTION_ORDER.map((_, i) => i).filter((i) => + !onlySet || onlySet.has(SECTION_ORDER[i]) || onlySet.has(String(i))) + : []; +// progress heartbeat → ~/.ac-pop-renders/ (Slab menubar reads it) +const totalPanels = 1 + panelIdx.length; +let donePanels = 0; +progress.begin({ type: "illy", label: `${SLUG}${TAG} · ${totalPanels} panels` }); + // Main hero cover (its own composition via the base prompt alone). await generate(basePrompt + portraitTail, OUT_PATH, `${SLUG}${TAG} cover`); +progress.update((++donePanels / totalPanels) * 100); // Per-section illys (sequential — keeps API pressure low, lets each be // individually cached/--force'd). The butterfly is drawn natively by // the model from BUTTERFLY_REF — no post-pass composite. -if (SECTIONS_MODE) { - // --only [,…] regenerates just those panels (others untouched). - const onlySet = typeof flags.only === "string" - ? new Set(flags.only.split(",").map((x) => x.trim())) - : null; - for (let i = 0; i < SECTION_ORDER.length; i++) { - const name = SECTION_ORDER[i]; - if (onlySet && !onlySet.has(name) && !onlySet.has(String(i))) continue; - const variant = SECTION_VARIANTS[name]; - const out = `${LANE}/out/${SLUG}${TAG}-sec-${i}-${safeName(name)}.png`; - await generate( - `${basePrompt}\n\n${variant}${portraitTail}`, - out, - `${SLUG}${TAG} §${i} ${name}`, - ); - } +for (const i of panelIdx) { + const name = SECTION_ORDER[i]; + const variant = SECTION_VARIANTS[name]; + const out = `${LANE}/out/${SLUG}${TAG}-sec-${i}-${safeName(name)}.png`; + await generate( + `${basePrompt}\n\n${variant}${portraitTail}`, + out, + `${SLUG}${TAG} §${i} ${name}`, + ); + progress.update((++donePanels / totalPanels) * 100); } +progress.end(); diff --git a/pop/chillwave/bin/harmonize.mjs b/pop/chillwave/bin/harmonize.mjs new file mode 100644 index 000000000..2d7c9314b --- /dev/null +++ b/pop/chillwave/bin/harmonize.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// chillwave/bin/harmonize.mjs — render a HARMONY vocal stem against an +// alternate .vocal.np (e.g. helpabeach.vocal-harmony.np = diatonic 3rd +// up). Reuses the cached lead vocal MP3 + words.json from sing.mjs; +// no ElevenLabs call. +// +// Pipeline: +// 1. score-pitch (WORLD f0 → helpabeach.vocal-harmony.np) +// 2. score-stretch (rubberband → 70 BPM beat grid) +// +// Output: out/helpabeach-vocal--sung.mp3 (suffix defaults to "harmony") +// +// Usage: +// node pop/chillwave/bin/harmonize.mjs # 3rd up default +// node pop/chillwave/bin/harmonize.mjs --score helpabeach.vocal-octave.np --suffix octave +// node pop/chillwave/bin/harmonize.mjs --transpose 12 --suffix octave-up +// +// Prereq: sing.mjs has run at least once (need cached vocal mp3 + words.json). + +import { existsSync } 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++) { + const a = process.argv[i]; + if (!a.startsWith("--")) continue; + const n = process.argv[i + 1]; + if (n === undefined || n.startsWith("--")) flags[a.slice(2)] = true; + else { flags[a.slice(2)] = n; i++; } +} + +const SCORE_FILE = flags.score || "helpabeach.vocal-harmony.np"; +const SUFFIX = flags.suffix || "harmony"; +const TRANSPOSE = String(flags.transpose ?? 0); +const BPM = String(flags.bpm ?? 84); + +const VOCAL_MP3 = `${LANE}/out/helpabeach-vocal.mp3`; +const WORDS_JSON = `${LANE}/out/helpabeach-vocal-words.json`; +if (!existsSync(VOCAL_MP3) || !existsSync(WORDS_JSON)) { + console.error(`✗ missing cached source — run sing.mjs first`); + console.error(` expected: ${VOCAL_MP3}`); + console.error(` ${WORDS_JSON}`); + process.exit(1); +} +const SCORE_PATH = resolve(LANE, SCORE_FILE); +if (!existsSync(SCORE_PATH)) { + console.error(`✗ score not found: ${SCORE_PATH}`); + process.exit(1); +} + +const PITCHED_MP3 = `${LANE}/out/helpabeach-vocal-${SUFFIX}-pitched.mp3`; +const PITCHED_ALIGN = `${LANE}/out/helpabeach-vocal-${SUFFIX}-pitched-alignment.json`; +const SUNG_MP3 = `${LANE}/out/helpabeach-vocal-${SUFFIX}-sung.mp3`; + +console.log(`▸ harmony stem · score=${SCORE_FILE} · suffix=${SUFFIX} · transpose=${TRANSPOSE}`); + +console.log(`▸ score-pitch (WORLD f0 → ${SCORE_FILE})`); +const r = spawnSync("node", [ + `${REPO}/pop/bin/score-pitch.mjs`, + "--slug", "helpabeach", "--section", "all", + "--score", SCORE_PATH, + "--vocal", VOCAL_MP3, + "--words", WORDS_JSON, + "--transpose", TRANSPOSE, + "--vibrato-hz", "5.0", + "--vibrato-cents", "14", + "--out", PITCHED_MP3, +], { cwd: `${REPO}/pop`, stdio: ["ignore", "inherit", "inherit"] }); +if (r.status !== 0) { console.error("✗ score-pitch failed"); process.exit(1); } + +console.log(`▸ score-stretch (rubberband → ${BPM} BPM beat grid)`); +const r2 = spawnSync("node", [ + `${REPO}/pop/bin/score-stretch.mjs`, + "--slug", "helpabeach", "--section", "all", + "--score", SCORE_PATH, + "--in", PITCHED_MP3, + "--alignment", PITCHED_ALIGN, + "--bpm", BPM, + "--max-stretch", "12.0", + "--out", SUNG_MP3, +], { cwd: `${REPO}/pop`, stdio: ["ignore", "inherit", "inherit"] }); +if (r2.status !== 0) { console.error("✗ score-stretch failed"); process.exit(1); } + +console.log(`\n✓ ${SUFFIX} stem → ${SUNG_MP3.replace(REPO + "/", "")}`); +console.log(` mix: node bin/render.mjs --slug helpabeach \\`); +console.log(` --vocal-stem out/helpabeach-vocal-sung.mp3 \\`); +console.log(` --vocal-harmony out/helpabeach-vocal-${SUFFIX}-sung.mp3`); diff --git a/pop/chillwave/bin/narrate.mjs b/pop/chillwave/bin/narrate.mjs new file mode 100644 index 000000000..7f6f59ed0 --- /dev/null +++ b/pop/chillwave/bin/narrate.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// narrate.mjs — jeffrey-pvc story narrator for a chillwave story cut. +// +// Says each line of .narration.txt in jeffrey-pvc (via the local +// say endpoint) and BEAT-ALIGNS it to its section's start in .np, +// producing a full-length narration stem out/-narration.mp3 that +// render.mjs mixes in with --vocal-stem (--vocal-start 0). +// +// One narration line per section, in section order. A light ring-mod +// gives jeffrey's voice a faint robotic edge. +// +// Needs the local say endpoint running: node bin/say-local.mjs +// +// Usage: +// node bin/narrate.mjs --slug helpabeach-short +// node bin/narrate.mjs --slug helpabeach-short --robot 0.2 --force + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import * as progress from "../../lib/render-progress.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); + +const flags = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (!a.startsWith("--")) continue; + const n = process.argv[i + 1]; + if (n === undefined || n.startsWith("--")) flags[a.slice(2)] = true; + else { flags[a.slice(2)] = n; i++; } +} + +const SLUG = flags.slug || "helpabeach-short"; +const BPM = Number(flags.bpm ?? 84); +const ROBOT = Number(flags.robot ?? 0.15); // 0 = dry jeffrey, ~0.15 = faint robot +const FORCE = flags.force === true; +const SR = 48000; +const SAY_URL = process.env.SAY_ENDPOINT || "http://127.0.0.1:8899/api/say"; + +const NP = `${LANE}/${SLUG}.np`; +const TXT = `${LANE}/${SLUG}.narration.txt`; +const OUT = `${LANE}/out/${SLUG}-narration.mp3`; +const TMP = `${LANE}/out/.tmp/narration`; +mkdirSync(TMP, { recursive: true }); + +if (!existsSync(NP) || !existsSync(TXT)) { + console.error(`✗ need ${SLUG}.np and ${SLUG}.narration.txt`); + process.exit(1); +} + +// ── section start times (seconds) from the .np ─────────────────────── +const beat = 60 / BPM; +function sectionStarts(npPath) { + // end-anchored — `# name [n] bars [flags]`, never a prose comment line + const HEADER = /^#\s*[a-z][a-z0-9-]*(?:\s+\d+)?\s+\d+(?:\s+\[[^\]]*\])?\s*$/i; + let pos = 0; + const starts = []; + for (const raw of readFileSync(npPath, "utf8").split("\n")) { + const t = raw.trim(); + if (!t) continue; + if (t.startsWith("#")) { if (HEADER.test(t)) starts.push(pos * beat); continue; } + for (const tok of t.split(/\s+/)) { + const m = tok.match(/^[A-Ga-g][#b]?\d(?:\+[A-Ga-g][#b]?\d)*:.+?(?:\*(\d+(?:\.\d+)?))?$/); + if (m) pos += Number(m[1] ?? 1); + } + } + return { starts, totalSec: pos * beat }; +} + +const lines = readFileSync(TXT, "utf8").split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); +const { starts, totalSec } = sectionStarts(NP); +if (lines.length !== starts.length) { + console.warn(` ⚠ ${lines.length} narration lines vs ${starts.length} sections — pairing by index`); +} +console.log(`▸ ${SLUG} narrator · ${lines.length} lines · ${starts.length} sections · ${totalSec.toFixed(1)}s`); + +// ── jeffrey-pvc say (content-hash cached — bills ElevenLabs once) ───── +async function sayLine(text) { + const hash = createHash("sha256").update(`jeffrey-pvc:${text}`).digest("hex").slice(0, 16); + const cached = `${TMP}/${hash}.mp3`; + if (!FORCE && existsSync(cached)) return cached; + const res = await fetch(SAY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: text, provider: "jeffrey", voice: "neutral:0", + stability: 0.6, similarity: 0.92, style: 0.2, + }), + }); + if (!res.ok) { + console.error(`✗ say ${res.status}: ${(await res.text()).slice(0, 240)}`); + console.error(" is say-local running? node bin/say-local.mjs"); + process.exit(1); + } + writeFileSync(cached, Buffer.from(await res.arrayBuffer())); + return cached; +} + +function decodeMono(mp3) { + const raw = `${mp3}.f32`; + const r = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", mp3, "-f", "f32le", "-ar", String(SR), "-ac", "1", raw]); + if (r.status !== 0) { console.error("✗ decode failed"); process.exit(1); } + const b = readFileSync(raw); + return new Float32Array(b.buffer, b.byteOffset, b.byteLength / 4); +} + +// ── say every line, beat-place it at its section start ─────────────── +progress.begin({ type: "audio", label: `${SLUG} narration` }); +const LEN = Math.ceil((totalSec + 3) * SR); +const out = new Float32Array(LEN); +for (let i = 0; i < lines.length; i++) { + const clip = decodeMono(await sayLine(lines[i])); + const at = Math.floor((starts[i] ?? (i * 8)) * SR); + for (let j = 0; j < clip.length; j++) { + const d = at + j; + if (d >= LEN) break; + let s = clip[j]; + // light ring-mod — a faint robotic edge, jeffrey still recognizable + if (ROBOT > 0) { + const ring = Math.sin(2 * Math.PI * 47 * (j / SR)); + s = s * (1 - ROBOT) + s * ring * (ROBOT * 1.5); + } + out[d] += s; + } + console.log(` §${i} @ ${(starts[i] ?? 0).toFixed(1)}s · "${lines[i]}"`); + progress.update(((i + 1) / lines.length) * 100); +} + +// peak-normalize the assembled stem +let pk = 1e-6; +for (let i = 0; i < LEN; i++) { const a = Math.abs(out[i]); if (a > pk) pk = a; } +const norm = 0.94 / pk; +for (let i = 0; i < LEN; i++) out[i] *= norm; + +const rawOut = `${TMP}/${SLUG}-narration.f32`; +writeFileSync(rawOut, Buffer.from(out.buffer, out.byteOffset, out.byteLength)); +const enc = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", rawOut, + "-c:a", "libmp3lame", "-q:a", "2", OUT]); +progress.end(); +if (enc.status !== 0) { console.error("✗ encode failed"); process.exit(1); } +console.log(`✓ ${OUT.replace(LANE + "/", "")} (${(LEN / SR).toFixed(1)}s · robot ${ROBOT})`); +console.log(` mix: node bin/render.mjs --slug ${SLUG} --no-chimes --no-waves --no-sweeps --no-highmel --no-voice \\`); +console.log(` --vocal-stem out/${SLUG}-narration.mp3 --vocal-start 0 --vocal-gain 1.4 --vocal-duck 0.5`); diff --git a/pop/chillwave/bin/preview-score.mjs b/pop/chillwave/bin/preview-score.mjs index b32395889..cd3033b2f 100644 --- a/pop/chillwave/bin/preview-score.mjs +++ b/pop/chillwave/bin/preview-score.mjs @@ -7,7 +7,7 @@ // in this file. // // Inputs: -// --slug wundabeach (default) +// --slug helpabeach (default) // --cover path-to.png (default: out/-cover.png) // --audio path-to.mp3 (default: out/.mp3) // --struct path-to.json (default: out/.struct.json) @@ -19,6 +19,7 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; +import * as progress from "../../lib/render-progress.mjs"; import { createCanvas, loadImage } from "canvas"; import { getNoteColorForOctave } from "../../../system/public/aesthetic.computer/lib/note-colors.mjs"; import { @@ -49,7 +50,7 @@ for (let i = 2; i < process.argv.length; i++) { else { flags[a.slice(2)] = next; i++; } } -const SLUG = flags.slug || "undabeach"; +const SLUG = flags.slug || "helpabeach"; // --portrait → vertical 9:16: 1080x1920, "-p" illustration set, // portrait output filename. const PORTRAIT = flags.portrait === true; @@ -139,20 +140,25 @@ const LANES = [ { key: "vox", color: "#ff4ad8", textColor: "#7a1f63" }, // 320° magenta ]; -// Title palette — high-saturation sunlight on water: vivid yellows, -// pure white, punchy lime/seaglass green, saturated gold. +// Title palette — soft red / white / blue / pink pastels, the Rhizome +// Health clinic register (no more beach-sunlight yellows). const TITLE_PALETTE = [ - "#ffe600", "#ffffff", "#9cff2e", "#ffc300", - "#fff24a", "#5bf77c", "#ffd400", "#eaff00", + "#f2a6ae", "#ffffff", "#a8c4e6", "#f0c8d2", + "#eef0f4", "#9fb8de", "#f4d0d8", "#c9d8ef", ]; -// Section tints — pure dayglo, like the notepat neon palette. +// Section tints — soft red/white/blue/pink pastels, one per section, +// tracking the checkup arc. Keys MUST match struct.sections names. const SECTION_TINTS = { - "tide-in": "rgba(255,255,80,1.0)", // electric neon yellow - "drift 1": "rgba(50,200,255,1.0)", // electric neon cyan - "swell": "rgba(255,140,0,1.0)", // blazing neon orange - "drift 2": "rgba(180,50,255,1.0)", // UV neon purple - "tide-out": "rgba(50,255,120,1.0)", // radioactive neon green + "tide-in": "rgba(151,193,233,1.0)", // pastel blue — arrival + "drift 1": "rgba(240,183,200,1.0)", // pastel pink — intake + "swell 1": "rgba(240,150,148,1.0)", // pastel coral-red — first peak + "deep-current": "rgba(240,236,226,1.0)", // pastel cream-white — eye chart + "drift 2": "rgba(164,191,228,1.0)", // pastel blue — handle typed + "undertow": "rgba(150,160,214,1.0)", // soft periwinkle — the listen + "swell 2": "rgba(244,168,176,1.0)", // warm pastel pink-red — covered + "tide-out": "rgba(244,240,233,1.0)", // pastel white — wrap-up + "ebb": "rgba(234,201,213,1.0)", // soft rose — the room quiets }; // ── pre-render YWFT title chars + lane labels + section labels ─────── @@ -1049,6 +1055,8 @@ const laneList = LANES.slice(); // Heavily-smoothed scene-motion state (EMA across frames) so the // verlet vibe DISTORTS the illustration smoothly and never jumps. let smPunch = 0; +// progress heartbeat → ~/.ac-pop-renders/ (Slab menubar reads it) +progress.begin({ type: "video", label: `${SLUG}${PORTRAIT ? " ·portrait" : ""}` }); for (let f = 0; f < FRAMES; f++) { const t = f / FPS; // Shift visual readout by AAC priming delay so blocks cross the @@ -1172,6 +1180,7 @@ for (let f = 0; f < FRAMES; f++) { await new Promise((res) => ff.stdin.once("drain", res)); } const pct = (f + 1) / FRAMES; + progress.update(pct * 100); if (pct >= nextLog) { const elapsed = (Date.now() - t0) / 1000; const eta = elapsed / pct - elapsed; @@ -1183,4 +1192,5 @@ process.stdout.write("\n"); ff.stdin.end(); await new Promise((res) => ff.on("close", res)); +progress.end(); console.log(`✓ ${OUT.replace(REPO + "/", "")}`); diff --git a/pop/chillwave/bin/preview.mjs b/pop/chillwave/bin/preview.mjs index 43ab063db..d220920d4 100644 --- a/pop/chillwave/bin/preview.mjs +++ b/pop/chillwave/bin/preview.mjs @@ -28,7 +28,7 @@ for (let i = 2; i < process.argv.length; i++) { else { flags[a.slice(2)] = next; i++; } } -const SLUG = flags.slug || "undabeach"; +const SLUG = flags.slug || "helpabeach"; const SIZE = flags.size || "1080x1080"; const ZOOM_END = Number(flags.zoom ?? 1.08); // final zoom factor const FPS = Number(flags.fps ?? 30); diff --git a/pop/chillwave/bin/render.mjs b/pop/chillwave/bin/render.mjs index 08092c79d..47f392604 100755 --- a/pop/chillwave/bin/render.mjs +++ b/pop/chillwave/bin/render.mjs @@ -23,10 +23,11 @@ // Output: pop/chillwave/out/.mp3 import { spawnSync } from "node:child_process"; -import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync, statSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; +import * as progress from "../../lib/render-progress.mjs"; import { applyBitcrush, applyFlange } from "../../dance/synths/fx.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -77,9 +78,9 @@ for (let i = 2; i < process.argv.length; i++) { else { flags[a.slice(2)] = next; i++; } } -const SLUG = flags.slug || "undabeach"; +const SLUG = flags.slug || "helpabeach"; const SCORE_PATH = `${LANE}/${SLUG}.np`; -const BPM = Number(flags.bpm ?? 70); +const BPM = Number(flags.bpm ?? 84); const TRANSPOSE = Number(flags.transpose ?? 0); const SAMPLE_RATE = 48_000; const OUT_PATH = flags.out @@ -89,8 +90,11 @@ const OUT_PATH = flags.out const SOLO = flags.layer ? String(flags.layer).toLowerCase() : null; const ONLY = (name) => !SOLO || SOLO === name; const SKIP = { - waves: flags["no-waves"] === true, - sweeps: flags["no-sweeps"] === true, + waves: flags["no-waves"] === true, + realwaves:flags["no-realwaves"] === true, + sweeps: flags["no-sweeps"] === true, + rollers: flags["no-rollers"] === true, + highmel: flags["no-highmel"] === true, bubbles: flags["no-bubbles"] === true, shakers: flags["no-shakers"] === true, birds: flags["no-birds"] === true, @@ -153,18 +157,24 @@ function parseScore(path, bpm) { } continue; } - // melody token line + // melody token line — single note `A4:_*2` or chord stack + // `A4+C5+E5:_*2` (notes joined by `+`, all start on the same beat, + // duration advances once). for (const tok of t.split(/\s+/)) { - const m = tok.match(/^([A-Ga-g][#b]?\d):(.+?)(?:\*(\d+(?:\.\d+)?))?$/); + const m = tok.match(/^([A-Ga-g][#b]?\d(?:\+[A-Ga-g][#b]?\d)*):(.+?)(?:\*(\d+(?:\.\d+)?))?$/); if (!m) continue; - const note = m[1]; + const notes = m[1].split("+"); const weight = Number(m[3] ?? 1); - events.push({ - startSec: beat_pos * beat_s, - midi: noteToMidi(note) + TRANSPOSE, - durSec: weight * beat_s, - section: curSection?.name ?? null, - }); + const start = beat_pos * beat_s; + for (const note of notes) { + events.push({ + startSec: start, + midi: noteToMidi(note) + TRANSPOSE, + durSec: weight * beat_s, + section: curSection?.name ?? null, + chord: notes.length > 1, + }); + } beat_pos += weight; } } @@ -315,6 +325,149 @@ function pinkSample(state) { return pink * 0.11; } +// ── 0. real waves layer (freesound CC0 ocean bed) ──────────────────── +// Loops a real wave recording across the entire track, gain-gated per +// section. Replaces the synthesized white-noise sweeps as the primary +// "ocean" bed; the synth wave events stay as tonal/harmonic colour on +// top. Sample: freesound#352356 "Gentle small waves lapping on shore" +// by Alex_hears_things (CC0) — close-mic sandy-shore lapping, the calm +// vacation-beach surf. Cached at pop/chillwave/out/.waves.wav +// (provenance: out/.sfx-credits.txt). +const BEACH_SAMPLE_PATHS = [ + `${LANE}/out/.waves.wav`, + `${REPO}/pop/dance/out/.waves.wav`, +]; +const BEACH_F32_CACHE = `${LANE}/out/.waves-48k.f32`; + +function loadBeachSampleMono() { + // Find a source .wav. + const src = BEACH_SAMPLE_PATHS.find((p) => existsSync(p)); + if (!src) { + console.warn(` ! no beach .wav found (looked: ${BEACH_SAMPLE_PATHS.map((p) => p.replace(REPO + "/", "")).join(", ")})`); + return null; + } + // Decode → f32 mono @ SAMPLE_RATE once; subsequent runs reuse the cache. + let needDecode = !existsSync(BEACH_F32_CACHE); + if (!needDecode) { + try { + const a = statSync(src); + const b = statSync(BEACH_F32_CACHE); + if (b.mtimeMs < a.mtimeMs) needDecode = true; + } catch (_e) { needDecode = true; } + } + if (needDecode) { + console.log(` beach decode → ${BEACH_F32_CACHE.replace(REPO + "/", "")}`); + const r = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-i", src, "-f", "f32le", "-ar", String(SAMPLE_RATE), "-ac", "1", + BEACH_F32_CACHE, + ]); + if (r.status !== 0 || !existsSync(BEACH_F32_CACHE)) { + console.warn(` ! beach decode failed (status ${r.status})`); + return null; + } + } + const buf = readFileSync(BEACH_F32_CACHE); + return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); +} + +function realWaveGainAt(tSec) { + // Section gain envelope — strong through drift/swell, fades on edges. + // The synthesized waves layer carries onset already, so this just + // honors the tide-in/tide-out fades. + const sec = sectionAt(tSec); + if (!sec) return 0; + // Prefix-matched so the expanded arrangement (swell 1/2, deep-current, + // undertow, ebb) all get a sensible bed level. + const nm = sec.name; + let base; + if (nm.startsWith("tide-in")) base = 0.85; + else if (nm.startsWith("tide-out")) base = 0.75; + else if (nm.startsWith("swell")) base = 1.15; // peaks loud + else if (nm.startsWith("drift")) base = 0.98; + else if (nm === "deep-current") base = 0.92; + else if (nm === "undertow") base = 0.90; + else if (nm === "ebb") base = 0.70; // outro recedes + else base = 0.85; + // 1.6s edge fades so section transitions glide. + const edgeIn = Math.min(1, (tSec - sec.startSec) / 1.6); + const edgeOut = Math.min(1, (sec.endSec - tSec) / 1.6); + const sectionEnv = Math.max(0, Math.min(edgeIn, edgeOut)); + // Honor tide-in/tide-out wave-amp curves so the very edges of the + // track ease in/out instead of starting hot. + return base * sectionEnv * waveAmpAt(tSec); +} + +function renderRealWaves() { + if (SKIP.realwaves) return; + if (SOLO && !ONLY("realwaves")) return; + console.log(" real waves …"); + const beach = loadBeachSampleMono(); + if (!beach) { console.warn(` ! skipped — no sample`); return; } + const beachLen = beach.length; + const beachDur = beachLen / SAMPLE_RATE; + console.log(` bed: ${beachDur.toFixed(1)}s sample, looped across ${LEN_SEC.toFixed(1)}s`); + + // Stereo widening: read the mono sample with a tiny left/right offset + // (~25 ms) for a Haas pseudo-stereo bed. Mono content stays mono-ish + // in the middle but ocean detail spreads across the field. + const HAAS_MS = 25; + const haasOff = Math.floor(HAAS_MS * 0.001 * SAMPLE_RATE); + // Pitch the playback ~0.94× so the sample is a touch slower / deeper, + // closer to gentle surf than to crashing rocks. + const PITCH = 0.94; + // Crossfade length when the loop wraps so the seam isn't audible. + const XFADE_S = 1.5; + const xfadeN = Math.floor(XFADE_S * SAMPLE_RATE); + // Master gain for the real bed — loud enough to read as THE ocean. + const BED_GAIN = Number(flags["beach-gain"] ?? 0.42); + + // March through outL/outR sample-by-sample. Read position in the + // sample wraps with a soft crossfade so the 24-second loop is + // imperceptible. + let srcPosL = 0; // float read index, left + let srcPosR = beachLen / 2; // offset half a sample for variety, right + for (let i = 0; i < LEN_SAMP; i++) { + const tSec = i / SAMPLE_RATE; + const g = realWaveGainAt(tSec); + if (g > 0) { + // bilinear lookup, left channel + const il = srcPosL | 0; + const fl = srcPosL - il; + const sl0 = beach[il % beachLen]; + const sl1 = beach[(il + 1) % beachLen]; + let sL = sl0 * (1 - fl) + sl1 * fl; + // crossfade near loop seam + const seamL = srcPosL - (beachLen - xfadeN); + if (seamL > 0 && seamL < xfadeN) { + const u = seamL / xfadeN; + const iw = Math.floor(seamL + haasOff) % beachLen; + const xw = beach[iw]; + sL = sL * (1 - u) + xw * u; + } + // right channel — read with tiny offset for Haas width + const ir = srcPosR | 0; + const fr = srcPosR - ir; + const sr0 = beach[(ir + haasOff) % beachLen]; + const sr1 = beach[(ir + haasOff + 1) % beachLen]; + let sR = sr0 * (1 - fr) + sr1 * fr; + const seamR = srcPosR - (beachLen - xfadeN); + if (seamR > 0 && seamR < xfadeN) { + const u = seamR / xfadeN; + const iw = Math.floor(seamR) % beachLen; + const xw = beach[iw]; + sR = sR * (1 - u) + xw * u; + } + outL[i] += sL * g * BED_GAIN; + outR[i] += sR * g * BED_GAIN; + } + srcPosL += PITCH; + srcPosR += PITCH; + if (srcPosL >= beachLen) srcPosL -= beachLen; + if (srcPosR >= beachLen) srcPosR -= beachLen; + } +} + // ── 1. waves layer (event-composed) ────────────────────────────────── // Discrete wave events of five types, overlapping in time. Each event // has its own envelope, noise color, resonant filter, and harmonic @@ -634,9 +787,11 @@ function renderSweeps() { } // Quieter overall + the rhythmic wub pump (keeps a floor so it's - // still a bed, but clearly pulses "wub wub"). + // still a bed, but clearly pulses "wub wub"). With the real-wave + // bed now carrying ocean colour, the synth white-noise sweeps drop + // to a thin tonal pad ~30% of the original level. const wubGate = 0.28 + 0.72 * wub; - const amp = (0.085 / gNorm) * onset * sectionEnv * wubGate; + const amp = (0.028 / gNorm) * onset * sectionEnv * wubGate; outL[i] += sumL * amp; outR[i] += sumR * amp; } @@ -1016,6 +1171,169 @@ function renderBells() { } } +// ── 5a. deep sine bells — sub-octave companion to the bell line ────── +// A darker, rounder sine bell voiced one octave below the score. Fires +// only on the held / structural notes (durSec ≥ MIN_DUR) so it stays +// sparse — a deep tidal undertone, never a second melody. Strong +// sub-octave partial, long ring, slow soft attack, near-centre with a +// very slow drift. Leans louder through the low sections (deep-current +// / undertow / ebb) and softer under the bright swells. +function renderDeepBells() { + if (SKIP.bells) return; + if (SOLO && !ONLY("bells")) return; + console.log(" deep bells …"); + + const DEEP_PARTIALS = [ + { ratio: 0.5, amp: 0.60, decayT60: 9.5 }, // sub-octave hum + { ratio: 1.0, amp: 1.00, decayT60: 7.5 }, // fundamental + { ratio: 2.0, amp: 0.20, decayT60: 3.2 }, + { ratio: 3.01, amp: 0.05, decayT60: 1.5 }, + ]; + const RING_TAIL = 6.5; + const ATTACK_S = 0.040; + const DEEP_GAIN = Number(flags["deep-bell-gain"] ?? 0.30); + const MIN_DUR = 1.40; // only the held / structural notes + + // One deep bell per onset — the LOWEST note of a chord cluster, so + // chords don't stack four sub-octave bells on the same beat. + const byStart = new Map(); + for (const ev of score.events) { + const k = ev.startSec.toFixed(4); + const g = byStart.get(k); + if (!g) byStart.set(k, { startSec: ev.startSec, midi: ev.midi, durSec: ev.durSec }); + else { + if (ev.midi < g.midi) g.midi = ev.midi; + if (ev.durSec > g.durSec) g.durSec = ev.durSec; + } + } + + function deepPan(tSec) { + return 0.30 * Math.sin((2 * Math.PI / 31) * tSec + 0.7); + } + function deepNarr(tSec) { + const sec = sectionAt(tSec); + if (!sec) return 0.80; + if (sec.name === "deep-current") return 1.15; + if (sec.name === "undertow") return 1.30; + if (sec.name === "ebb") return 1.05; + if (sec.name.startsWith("swell")) return 0.62; + return 0.85; + } + + for (const g of byStart.values()) { + if (g.durSec < MIN_DUR) continue; + const gateGain = bellGainAt(g.startSec); + if (gateGain <= 0.001) continue; + const f = midiToFreq(g.midi - 12); // one octave below + const pan = deepPan(g.startSec); + const narr = deepNarr(g.startSec); + const lG = Math.cos((pan + 1) * Math.PI / 4) * Math.SQRT2; + const rG = Math.sin((pan + 1) * Math.PI / 4) * Math.SQRT2; + const startIdx = Math.floor(g.startSec * SAMPLE_RATE); + const ringSamp = Math.floor((g.durSec + RING_TAIL) * SAMPLE_RATE); + const attackSamp = ATTACK_S * SAMPLE_RATE; + const partials = DEEP_PARTIALS.map((p) => ({ + omega: 2 * Math.PI * f * p.ratio / SAMPLE_RATE, + amp: p.amp, + decay: Math.exp(-Math.log(1000) / (p.decayT60 * SAMPLE_RATE)), + })); + eventLog.bells.push({ t: g.startSec, midi: g.midi - 12, dur: g.durSec, deep: true }); + for (let i = 0; i < ringSamp; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= LEN_SAMP) break; + let s = 0; + for (const p of partials) { + const env = p.amp * Math.pow(p.decay, i); + if (env < 1e-5) continue; + s += Math.sin(p.omega * i) * env; + } + let att = 1; + if (i < attackSamp) att = 0.5 - 0.5 * Math.cos((Math.PI * i) / attackSamp); + const v = s * att * DEEP_GAIN * gateGain * narr; + outL[dst] += v * lG; + outR[dst] += v * rG; + } + } +} + +// ── 5a-2. rollers — square-waves rolling into sines, intro only ───── +// Plays the tide-in descending breath as additive odd-harmonic tones +// that morph from a square (all harmonics) to a pure sine (fundamental +// only) across the opening. Upper harmonics "roll off" highest-first, +// like a filter slowly closing — squares rolling into sines. A slow +// tremolo gives the rolling motion; the layer fades out into drift 1 +// so the sinebells take over cleanly. Ignores the section's no-bells +// flag — it IS the intro voice. --no-rollers / --roller-gain to tune. +function renderRollers() { + if (SKIP.rollers) return; + if (SOLO && !ONLY("rollers")) return; + const sec = score.sections.find((s) => s.name.startsWith("tide-in")); + if (!sec) return; + console.log(" rollers …"); + + const ROLLER_GAIN = Number(flags["roller-gain"] ?? 0.15); + const secStart = sec.startSec; + const secEnd = sec.endSec; + const secDur = Math.max(0.001, secEnd - secStart); + // Only the low odd harmonics — a soft rounded pulse, not a harsh + // buzz, so overlapping intro notes don't pile into a strange cluster. + const HARMS = [1, 3, 5, 7]; // odd → soft square + const TREM_HZ = 0.22; + const RELEASE = 1.1; // short tail, less overlap + const ATTACK_S = 0.60; + + // harmonic k's amplitude at morph m∈[0,1]: 1/k square taper, and the + // harmonic fades out as m passes a death threshold — higher k die + // earlier, so m: 0→full square, 1→sine. + function harmAmp(k, m) { + if (k === 1) return 1; + const death = 1.05 - (k / 7) * 0.92; // k7≈0.13 … k3≈0.66 + const g = Math.max(0, Math.min(1, (death - m) / 0.22)); + return g / k; + } + + for (const ev of score.events) { + if (ev.section !== sec.name) continue; + const f = midiToFreq(ev.midi); + const startIdx = Math.floor(ev.startSec * SAMPLE_RATE); + const noteSamp = Math.floor(ev.durSec * SAMPLE_RATE); + const totSamp = Math.floor((ev.durSec + RELEASE) * SAMPLE_RATE); + const atkSamp = Math.floor(ATTACK_S * SAMPLE_RATE); + const pan = 0.35 * Math.sin((2 * Math.PI / 19) * ev.startSec + 0.4); + const lG = Math.cos((pan + 1) * Math.PI / 4) * Math.SQRT2; + const rG = Math.sin((pan + 1) * Math.PI / 4) * Math.SQRT2; + for (let i = 0; i < totSamp; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= LEN_SAMP) break; + const tAbs = dst / SAMPLE_RATE; + // morph rolls across the whole intro, smoothstepped + let m = Math.max(0, Math.min(1, (tAbs - secStart) / secDur)); + m = m * m * (3 - 2 * m); + let s = 0; + const ph = f * tAbs; + for (const k of HARMS) { + const a = harmAmp(k, m); + if (a <= 1e-4) continue; + s += Math.sin(2 * Math.PI * k * ph) * a; + } + s *= 0.62; // square peak → ~0.75 + let env; + if (i < atkSamp) env = 0.5 - 0.5 * Math.cos((Math.PI * i) / atkSamp); + else if (i > noteSamp) { + const r = Math.min(1, (i - noteSamp) / (totSamp - noteSamp)); + env = 0.5 + 0.5 * Math.cos(Math.PI * r); + } else env = 1; + const trem = 0.88 + 0.12 * Math.sin(2 * Math.PI * TREM_HZ * tAbs); + // fade the whole layer out just past the intro → hand off to bells + const handoff = Math.max(0, Math.min(1, (secEnd + 1.5 - tAbs) / 2.0)); + const v = s * env * trem * ROLLER_GAIN * handoff; + outL[dst] += v * lG; + outR[dst] += v * rG; + } + eventLog.bells.push({ t: ev.startSec, midi: ev.midi, dur: ev.durSec, roller: true }); + } +} + // ── 5b. high sung melody ───────────────────────────────────────────── // Very quiet, very high, long sustained "sung" tones — a slow // wandering A-minor-pentatonic line floating well above the bells. @@ -1105,8 +1423,8 @@ function renderKick() { console.log(" kick …"); const beat_s = 60.0 / BPM; - const KICK_GAIN = 0.32; - const SUB_GAIN = 0.18; + const KICK_GAIN = 0.52; + const SUB_GAIN = 0.22; const SUB_HZ = 55.0; // A1 const SUB_HZ2 = 41.2; // E1 (fifth-below for drift 2) @@ -1133,10 +1451,13 @@ function renderKick() { let env; if (i < attackS) env = i / attackS; else env = Math.pow(0.0015, (i - attackS) / (nSamp - attackS)); - // light sine + tiny harmonic for body - const s = (Math.sin(phase) + 0.12 * Math.sin(phase * 2)) * env; - outL[dst] += s * KICK_GAIN; - outR[dst] += s * KICK_GAIN; + // sine body + 2nd harmonic + soft drive for thicker thump, + // plus a 4 ms click transient at attack for presence. + let s = Math.sin(phase) + 0.16 * Math.sin(phase * 2); + s = Math.tanh(s * 1.4) * 0.85; + if (i < attackS) s += (1 - i / attackS) * 0.45; + outL[dst] += s * env * KICK_GAIN; + outR[dst] += s * env * KICK_GAIN; } eventLog.kick.push({ t, kind: "kick" }); kickCount++; @@ -1148,7 +1469,7 @@ function renderKick() { // then HOLDS into a long ~1.1s sub tail, soft-driven for a thick // thump. Lands under the half-time kick as a slow, heavy pulse and // visibly thumps the preview string (kick lane). - const BOOM_GAIN = 0.42; + const BOOM_GAIN = 0.66; let boomCount = 0; for (const sec of score.sections) { if (!sec.flags.has("kick")) continue; @@ -1330,17 +1651,164 @@ function renderZooSamples() { console.log(` zoo: ${n} sample hits`); } +// ── computer-synth voice — formant synthesis, nonsense phonemes ───── +// Ditches the ElevenLabs jeffrey-pvc stem entirely. A polyBLEP glottal +// sawtooth through three RBJ bandpass formant resonators makes a vowel; +// the voice sings the helpabeach.vocal.np note line on procedurally +// chosen NONSENSE phonemes — soft sonorant consonant onsets (n/m/l/w/ +// y/h/ng) into diphthong vowel glides, seeded deterministically so the +// gibberish is reproducible. Faint, and offset past the intro. +// Flags: --no-voice · --voice-gain · --voice-start. +const VOWELS = { // [F1, F2, F3] Hz + ah: [730, 1090, 2440], ee: [270, 2290, 3010], oo: [300, 870, 2240], + eh: [530, 1840, 2480], oh: [570, 840, 2410], ih: [400, 1990, 2550], + uh: [640, 1190, 2390], aa: [660, 1720, 2410], +}; +const VOWEL_KEYS = Object.keys(VOWELS); +const CONSONANTS = ["", "", "n", "m", "l", "w", "y", "h", "ng"]; + +function bandpassStep(st, x, f, Q) { + // RBJ bandpass biquad (constant 0 dB peak). Coeffs recomputed each + // sample so the formant can glide; the glide is slow → stable. + const w0 = 2 * Math.PI * f / SAMPLE_RATE; + const alpha = Math.sin(w0) / (2 * Q); + const a0 = 1 + alpha; + const y = (alpha * x - alpha * st.x2 + + 2 * Math.cos(w0) * st.y1 - (1 - alpha) * st.y2) / a0; + st.x2 = st.x1; st.x1 = x; + st.y2 = st.y1; st.y1 = y; + return y; +} + +function renderVoiceScore(npPath, gain, panBias, seedTag) { + if (!existsSync(npPath)) { + console.warn(` ! voice score missing: ${npPath.replace(REPO + "/", "")}`); + return; + } + const vscore = parseScore(npPath, BPM); + if (!vscore.events.length) return; + const beat = 60 / BPM; + const vStart = Math.round(Number(flags["voice-start"] ?? 47) / beat) * beat; + const vrng = makeRng(`${SLUG}:voice:${seedTag}`); + const fAmp = [1.0, 0.55, 0.26]; + + for (const ev of vscore.events) { + const startSec = ev.startSec + vStart; + const f0base = midiToFreq(ev.midi); + const dur = ev.durSec; + const startIdx = Math.floor(startSec * SAMPLE_RATE); + const noteSamp = dur * SAMPLE_RATE; + const total = Math.floor((dur + 0.40) * SAMPLE_RATE); + + // pick a nonsense phoneme: consonant onset + two vowels (diphthong) + const cons = CONSONANTS[(vrng() * CONSONANTS.length) | 0]; + const vA = VOWEL_KEYS[(vrng() * VOWEL_KEYS.length) | 0]; + let vB = VOWEL_KEYS[(vrng() * VOWEL_KEYS.length) | 0]; + if (vB === vA) vB = VOWEL_KEYS[(VOWEL_KEYS.indexOf(vA) + 3) % VOWEL_KEYS.length]; + let cVowel = null, nasal = false, breath = false, consDur = 0; + if (cons === "w") { cVowel = "oo"; consDur = 0.10; } + else if (cons === "y") { cVowel = "ee"; consDur = 0.09; } + else if (cons === "l") { cVowel = "eh"; consDur = 0.07; } + else if (cons === "n" || cons === "m" || cons === "ng") { nasal = true; consDur = 0.08; } + else if (cons === "h") { breath = true; consDur = 0.07; } + const consSamp = consDur * SAMPLE_RATE; + + const F = [{x1:0,x2:0,y1:0,y2:0},{x1:0,x2:0,y1:0,y2:0},{x1:0,x2:0,y1:0,y2:0}]; + const NZ = {x1:0,x2:0,y1:0,y2:0}; + let phase = 0, nz = 0; + const vibHz = 4.6 + vrng() * 0.9; + const pan = Math.max(-0.82, Math.min(0.82, + panBias + 0.26 * Math.sin((2 * Math.PI / 13) * startSec + 0.5))); + const lG = Math.cos((pan + 1) * Math.PI / 4) * Math.SQRT2; + const rG = Math.sin((pan + 1) * Math.PI / 4) * Math.SQRT2; + const cOrigin = VOWELS[cVowel || vA]; + + for (let i = 0; i < total; i++) { + const dst = startIdx + i; + if (dst < 0 || dst >= LEN_SAMP) break; + const tt = i / SAMPLE_RATE; + // pitch — gentle vibrato (delayed onset) + a faint slow drift + const vibOn = Math.min(1, tt / 0.5); + const f0 = f0base * (1 + 0.011 * vibOn * Math.sin(2 * Math.PI * vibHz * tt)); + // polyBLEP sawtooth glottal source (alias-suppressed) + const dt = f0 / SAMPLE_RATE; + phase += dt; + if (phase >= 1) phase -= 1; + let src = 2 * phase - 1; + if (phase < dt) { const t = phase / dt; src -= t + t - t * t - 1; } + else if (phase > 1 - dt) { const t = (phase - 1) / dt; src -= t * t + t + t + 1; } + // formant targets: consonant onset glide → vowel A → vowel B + let f1, f2, f3, amp; + if (i < consSamp) { + const cu = i / consSamp; + const base = nasal ? [270, 1350, 2400] : cOrigin; + const T = VOWELS[vA]; + f1 = base[0] + (T[0] - base[0]) * cu; + f2 = base[1] + (T[1] - base[1]) * cu; + f3 = base[2] + (T[2] - base[2]) * cu; + amp = (nasal ? 0.5 : 0.45) + (nasal ? 0.5 : 0.55) * cu; + } else { + const su = Math.min(1, (i - consSamp) / Math.max(1, total - consSamp)); + const gu = su * su * (3 - 2 * su); + const A = VOWELS[vA], B = VOWELS[vB]; + f1 = A[0] + (B[0] - A[0]) * gu; + f2 = A[1] + (B[1] - A[1]) * gu; + f3 = A[2] + (B[2] - A[2]) * gu; + amp = 1; + } + // a faint slow formant shimmer keeps long holds alive + const shim = 1 + 0.025 * Math.sin(2 * Math.PI * 0.7 * tt + 1.1); + f1 *= shim; + // amplitude envelope — soft attack, sustain, soft release + const atk = 0.06 * SAMPLE_RATE; + let env; + if (i < atk) env = i / atk; + else if (i > noteSamp) env = Math.max(0, 1 - (i - noteSamp) / (total - noteSamp)); + else env = 1; + // three parallel formant bandpasses + let v = bandpassStep(F[0], src, f1, f1 / 80) * fAmp[0] + + bandpassStep(F[1], src, f2, f2 / 110) * fAmp[1] + + bandpassStep(F[2], src, f3, f3 / 150) * fAmp[2]; + // nasal murmur pole / breath noise during the consonant onset + if (nasal && i < consSamp) { + v += bandpassStep(NZ, src, 270, 4.5) * 0.45 * (1 - i / consSamp); + } else if (breath && i < consSamp) { + nz = 0.96 * nz + 0.04 * (vrng() * 2 - 1); + v += nz * 5 * (1 - i / consSamp); + } + const s = v * env * amp * gain; + outL[dst] += s * lG; + outR[dst] += s * rG; + } + eventLog.vox.push({ t: startSec, name: `phon:${cons}${vA}-${vB}`, midi: ev.midi, dur }); + } +} + +function renderSynthVoice() { + if (flags["no-voice"] === true) return; + if (SOLO && !ONLY("vox")) return; + console.log(" synth voice (formant · nonsense phonemes) …"); + const g = Number(flags["voice-gain"] ?? 0.22); + renderVoiceScore(`${LANE}/${SLUG}.vocal.np`, g, 0.0, "lead"); + renderVoiceScore(`${LANE}/${SLUG}.vocal-harmony.np`, g * 0.5, 0.5, "harm"); +} + // ── render everything (per-lane buffers captured via renderLane) ──── // renderLane swaps in a fresh buffer per layer so we can save a // downsampled-mono copy for the preview-score visualizer to use as // each lane's actual mix volume display. -renderLane("waves", renderWaves); +// progress heartbeat → ~/.ac-pop-renders/ (Slab menubar reads it) +progress.begin({ type: "audio", label: SLUG }); +renderLane("waves", () => { renderRealWaves(); renderWaves(); }); renderLane("hat", () => { renderSweeps(); renderShakers(); }); renderLane("kick", () => { renderKick(); renderSnare(); }); // kick+sub+snare +progress.update(45); renderLane("sub", () => {}); // sub is mixed inside kick; placeholder -renderLane("bells", () => { renderPad(); renderBells(); renderHighMelody(); }); +renderLane("bells", () => { renderPad(); renderBells(); renderDeepBells(); renderRollers(); renderHighMelody(); }); renderLane("bubbles", renderBubbles); renderLane("birds", () => { renderBirds(); renderZooSamples(); }); +renderLane("vox", renderSynthVoice); +progress.update(80); // ── coming-in-from-the-sea FX ───────────────────────────────────────── // Bitcrush + flange ramp at the start (heavy → clean over ~3s) so the @@ -1425,9 +1893,12 @@ function mixChime(startSec, notes, dir) { } // With the typing intro the boot chime plays in the PRE-ROLL, BEFORE // the typing (see the pre-roll block). Without the intro it sits at -// the very start of the track as before. -if (flags["no-type-intro"] === true) mixChime(0.05, BOOT_NOTES, "boot"); -mixChime(Math.max(0, LEN_SEC - 2.2), SHUT_NOTES, "shutdown"); +// the very start of the track as before. --no-chimes drops both the +// boot and shutdown beeps entirely — chillwave/ambient masters want a +// clean ocean fade, no UI sfx. +const CHIMES = flags["no-chimes"] !== true; +if (CHIMES && flags["no-type-intro"] === true) mixChime(0.05, BOOT_NOTES, "boot"); +if (CHIMES) mixChime(Math.max(0, LEN_SEC - 2.2), SHUT_NOTES, "shutdown"); // ── jeffrey-pvc SUNG vocal (beat-aligned stem) — lead, ducks the bed ── // The stem is already score-pitch'd + score-stretched (held notes on @@ -1445,7 +1916,7 @@ if (flags["vocal-stem"]) { const vGain = Number(flags["vocal-gain"] ?? 1.5); // lead-loud const DUCK = Number(flags["vocal-duck"] ?? 0.62); // bed dips ~ -8dB const beat = 60 / BPM; - const reqStart = Number(flags["vocal-start"] ?? beat * 8); // ~bar 3 + const reqStart = Number(flags["vocal-start"] ?? 0); // ride the full track const vStart = Math.round(reqStart / beat) * beat; // snap to beat const vt = mkdtempSync(`${tmpdir()}/cw-vox-`); const vraw = `${vt}/vox.f32`; @@ -1493,6 +1964,59 @@ if (flags["vocal-stem"]) { } } +// ── jeffrey-pvc HARMONY vocals — parallel-3rd, octave-up, etc. ─────── +// Comma-separated list of stem paths. No bed-ducking (lead already +// ducks); each layer mixes at reduced gain with alternating pan offset +// so the harmony spreads around the lead instead of stacking centred. +if (flags["vocal-harmony"]) { + const harmList = String(flags["vocal-harmony"]).split(",").map((s) => s.trim()).filter(Boolean); + const hGainBase = Number(flags["harmony-gain"] ?? 0.75); + const beat = 60 / BPM; + const reqStart = Number(flags["vocal-start"] ?? beat * 8); + const vStart = Math.round(reqStart / beat) * beat; + harmList.forEach((rel, hi) => { + const cand = [resolve(process.cwd(), rel), `${LANE}/${rel}`, rel]; + const stemPath = cand.find((p) => existsSync(p)); + if (!stemPath) { console.error(`✗ --vocal-harmony[${hi}] not found: ${rel}`); return; } + const ht = mkdtempSync(`${tmpdir()}/cw-harm-`); + const hraw = `${ht}/harm.f32`; + const hr = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-i", stemPath, "-f", "f32le", "-ar", String(SAMPLE_RATE), "-ac", "1", + hraw, + ]); + if (hr.status === 0 && existsSync(hraw)) { + const rb = readFileSync(hraw); + const src = new Float32Array(rb.buffer, rb.byteOffset, rb.byteLength / 4); + let pk = 1e-6; + for (let i = 0; i < src.length; i++) { const a = Math.abs(src[i]); if (a > pk) pk = a; } + const norm = 0.97 / pk; + // alternate pans: harmony spreads wide around the centre lead. + const panAmt = 0.65 * (hi % 2 === 0 ? 1 : -1) * (1 + Math.floor(hi / 2) * 0.4); + const lG = Math.cos((panAmt + 1) * Math.PI / 4) * Math.SQRT2; + const rG = Math.sin((panAmt + 1) * Math.PI / 4) * Math.SQRT2; + const startIdx = Math.floor(vStart * SAMPLE_RATE); + const fadeS = Math.floor(0.10 * SAMPLE_RATE); + const hGain = hGainBase * Math.pow(0.85, hi); // each layer ~15% softer than the last + for (let i = 0; i < src.length; i++) { + const j = startIdx + i; + if (j < 0 || j >= LEN_SAMP) break; + const x = src[i] * norm; + let fade = 1; + if (i < fadeS) fade = i / fadeS; + else if (i > src.length - fadeS) fade = Math.max(0, (src.length - i) / fadeS); + const v = x * hGain * fade; + outL[j] += v * lG; + outR[j] += v * rG; + } + console.log(` vocal: HARMONY[${hi}] ${rel.split("/").pop()} · gain ${hGain.toFixed(2)} · pan ${panAmt.toFixed(2)} · ${(src.length / SAMPLE_RATE).toFixed(1)}s`); + } else { + console.error(`✗ harmony decode failed: ${rel}`); + } + rmSync(ht, { recursive: true, force: true }); + }); +} + // fade-in (0.4s) + fade-out (last 2.5s) so neither end clicks const FADE_IN_SEC = 0.4; const FADE_OUT_SEC = 2.5; @@ -1516,7 +2040,11 @@ for (let i = 0; i < fadeOutSamp; i++) { // event/section/lane-buffer shifts by PREROLL_SEC so nothing internal // needs rewriting. preview-score draws the prompt during // [0, PREROLL_SEC): beeps, then typing. -const TYPE_INTRO = flags["no-type-intro"] !== true; +// Typing/boot-chime preroll is for VIDEO cuts only — audio masters +// ship clean. Pass --type-intro to opt back in (e.g. when bin/preview-score.mjs +// is generating a video) or set DEFAULT_TYPE_INTRO=true env override. +const TYPE_INTRO = flags["type-intro"] === true + || (flags["no-type-intro"] !== true && process.env.DEFAULT_TYPE_INTRO === "true"); const PREROLL_SEC = TYPE_INTRO ? 3.0 : 0; const prerollSamp = Math.round(PREROLL_SEC * SAMPLE_RATE); let FINAL_SAMP = LEN_SAMP + prerollSamp; @@ -1686,3 +2214,4 @@ const struct = { const structPath = OUT_PATH.replace(/\.mp3$/, ".struct.json"); writeFileSync(structPath, JSON.stringify(struct, null, 2)); console.log(`✓ ${structPath} (${Object.values(eventLog).reduce((n, l) => n + l.length, 0)} events, ${Object.keys(laneBuffers).length} lane buffers)`); +progress.end(); diff --git a/pop/chillwave/bin/sing.mjs b/pop/chillwave/bin/sing.mjs index cc5796b2e..1253aa5dc 100644 --- a/pop/chillwave/bin/sing.mjs +++ b/pop/chillwave/bin/sing.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node -// chillwave/bin/sing.mjs — jeffrey-pvc SUNG vocal stem for undabeach. +// chillwave/bin/sing.mjs — jeffrey-pvc SUNG vocal stem for helpabeach. // // 1. POST the lyric to the LOCAL say endpoint (say-local.mjs) with // ElevenLabs /with-timestamps → vocal.mp3 + per-word alignment. // 2. alignment → words.json // 3. score-pitch.mjs (WORLD f0 replacement) snaps jeffrey's natural -// read onto undabeach.vocal.np → a soft sung performance layer. +// read onto helpabeach.vocal.np → a soft sung performance layer. // // Requires say-local.mjs running (node pop/chillwave/bin/say-local.mjs). // @@ -33,13 +33,13 @@ const SAY_URL = process.env.SAY_ENDPOINT || "http://127.0.0.1:8899/api/say"; const OUT = `${LANE}/out`; mkdirSync(OUT, { recursive: true }); -const VOCAL_MP3 = `${OUT}/undabeach-vocal.mp3`; -const WORDS_JSON = `${OUT}/undabeach-vocal-words.json`; -const PITCHED_MP3 = `${OUT}/undabeach-vocal-pitched.mp3`; -const VOCAL_NP = `${LANE}/undabeach.vocal.np`; +const VOCAL_MP3 = `${OUT}/helpabeach-vocal.mp3`; +const WORDS_JSON = `${OUT}/helpabeach-vocal-words.json`; +const PITCHED_MP3 = `${OUT}/helpabeach-vocal-pitched.mp3`; +const VOCAL_NP = `${LANE}/helpabeach.vocal.np`; -// Lyric = the content lines of undabeach.txt (skip the "whisper" tag). -const lyric = readFileSync(`${LANE}/undabeach.txt`, "utf8") +// Lyric = the content lines of helpabeach.txt (skip the "whisper" tag). +const lyric = readFileSync(`${LANE}/helpabeach.txt`, "utf8") .split("\n").map((l) => l.trim()) .filter((l) => l && l.toLowerCase() !== "whisper") .join(" "); @@ -84,10 +84,10 @@ if (FORCE || !existsSync(VOCAL_MP3) || !existsSync(WORDS_JSON)) { } // ── 3. score-pitch → sung stem ─────────────────────────────────────── -console.log(`▸ score-pitch (WORLD f0 → undabeach.vocal.np · transpose ${TRANSPOSE})`); +console.log(`▸ score-pitch (WORLD f0 → helpabeach.vocal.np · transpose ${TRANSPOSE})`); const r = spawnSync("node", [ `${REPO}/pop/bin/score-pitch.mjs`, - "--slug", "undabeach", "--section", "all", + "--slug", "helpabeach", "--section", "all", "--score", VOCAL_NP, "--vocal", VOCAL_MP3, "--words", WORDS_JSON, @@ -102,22 +102,22 @@ console.log(`✓ pitched → ${PITCHED_MP3.replace(REPO + "/", "")}`); // ── 4. score-stretch → HOLD notes on the 70 BPM beat grid ──────────── // This is what makes it actually SUNG and beat-aligned: each word is // rubberband-stretched (formant-preserving, no pitch change) to its -// target beats from undabeach.vocal.np at the track tempo, so the *5 +// target beats from helpabeach.vocal.np at the track tempo, so the *5 // phrase-ends sustain on pitch instead of reading as fast speech. -const BPM = String(flags.bpm ?? 70); -const PITCHED_ALIGN = `${OUT}/undabeach-vocal-pitched-alignment.json`; -const SUNG_MP3 = `${OUT}/undabeach-vocal-sung.mp3`; +const BPM = String(flags.bpm ?? 84); +const PITCHED_ALIGN = `${OUT}/helpabeach-vocal-pitched-alignment.json`; +const SUNG_MP3 = `${OUT}/helpabeach-vocal-sung.mp3`; console.log(`▸ score-stretch (rubberband → ${BPM} BPM beat grid · held notes)`); const r2 = spawnSync("node", [ `${REPO}/pop/bin/score-stretch.mjs`, - "--slug", "undabeach", "--section", "all", + "--slug", "helpabeach", "--section", "all", "--score", VOCAL_NP, "--in", PITCHED_MP3, "--alignment", PITCHED_ALIGN, "--bpm", BPM, - "--max-stretch", "8.0", + "--max-stretch", "12.0", "--out", SUNG_MP3, ], { cwd: `${REPO}/pop`, stdio: ["ignore", "inherit", "inherit"] }); if (r2.status !== 0) { console.error("✗ score-stretch failed"); process.exit(1); } console.log(`\n✓ SUNG (beat-aligned) → ${SUNG_MP3.replace(REPO + "/", "")}`); -console.log(` mix: node bin/render.mjs --slug undabeach --vocal-stem ${SUNG_MP3.replace(LANE + "/", "")}`); +console.log(` mix: node bin/render.mjs --slug helpabeach --vocal-stem ${SUNG_MP3.replace(LANE + "/", "")}`); diff --git a/pop/chillwave/helpabeach-short.narration.txt b/pop/chillwave/helpabeach-short.narration.txt new file mode 100644 index 000000000..382599051 --- /dev/null +++ b/pop/chillwave/helpabeach-short.narration.txt @@ -0,0 +1,13 @@ +# helpabeach-short — jeffrey-pvc narrator. ONE line per section, in +# section order (tide-in … ebb). narrate.mjs says each line in +# jeffrey-pvc and beat-aligns it to its section start. lines starting +# with # are ignored. +i came in today, and wow, i already feel welcome. +signing up was so simple. everyone here is so kind. +they're taking such good care of me. i can feel it working. +wow. i feel so great. +so calm, so light. i feel wonderful. +and healthy! they say i'm perfectly healthy. +everyone here is so generous. i feel lucky. +thank you, rhizome health. what a day. +i feel so much better. i feel brand new. diff --git a/pop/chillwave/helpabeach-short.np b/pop/chillwave/helpabeach-short.np new file mode 100644 index 000000000..d0bf87593 --- /dev/null +++ b/pop/chillwave/helpabeach-short.np @@ -0,0 +1,35 @@ +# helpabeach-short — compressed "story cut" of helpabeach (~1:17). +# +# Same 9 sections as helpabeach.np, each roughly half-length, so the +# 9 illustration panels still map 1:1 but the whole arc fits the +# IG-story format. The jeffrey-pvc narrator (see helpabeach-short. +# narration.txt) raps one beat-aligned line per section over this. +# +# Key: A minor pentatonic. Tempo: 84 BPM. 104 beats ≈ 74s music. + +# tide-in 12 [waves-fade-in, no-bells] +A4:_*3 G4:_*2 E4:_*2 D4:_*2 C4:_*3 + +# drift 1 14 [waves, sweep-slow, bubbles-sparse, kick] +A4:_*2 C5:_*2 E5:_*2 D5:_*1 C5:_*1 G5:_*2 E5:_*2 A4:_*2 + +# swell 1 10 [filter-sweep-open, bubbles-dense, pad-bloom, kick] +A4+C5+E5:_*3 G5:_*1 G4+B4+D5:_*3 A5:_*3 + +# deep-current 12 [waves, sweep-slow, bubbles-sparse, pad-bloom] +A3:_*4 E3:_*4 G3:_*4 + +# drift 2 12 [waves, sweep-slow, bubbles-sparse, kick] +E5:_*2 D5:_*1 C5:_*1 A4:_*4 F#4:_*2 G4:_*2 + +# undertow 12 [waves, sweep-slow, pad-bloom] +A3:_*4 G3:_*2 E3:_*2 D3:_*4 + +# swell 2 10 [filter-sweep-open, bubbles-dense, pad-bloom, kick] +A4+C5+E5:_*3 G5:_*1 C5+E5+G5:_*3 A4+C5+E5+A5:_*3 + +# tide-out 12 [waves-fade-out] +E4:_*1 A4:_*1 C5:_*2 D5:_*2 A3+A4+C5+E5+A5:_*6 + +# ebb 10 [waves-fade-out] +A3:_*4 E3:_*3 A2:_*3 diff --git a/pop/chillwave/helpabeach.illy.txt b/pop/chillwave/helpabeach.illy.txt index 024fed9f0..3ed8d566e 100644 --- a/pop/chillwave/helpabeach.illy.txt +++ b/pop/chillwave/helpabeach.illy.txt @@ -1,25 +1,29 @@ -hand-drawn colored-pencil drawing on heavily textured ROUGH-TOOTH paper — coarse cold-press / handmade-rag stock with a pronounced bumpy grain. NOT a photograph. NOT a digital cartoon. +a photograph of a hand-drawn colored-pencil AND gouache illustration on warm cream paper — the aesthetic.computer house style for satirical tableaux. NOT a photo of a real room, NOT a digital cartoon, NOT a cel-shaded render. -CRUCIAL — rough paper: the paper tooth is the loudest texture in the image. Every pencil stroke is broken up by the coarse grain, reading as a grainy, stippled band of pigment that only catches the raised tooth while bare paper sits in the pits between. Soft wax pigment dragged across rough watercolor paper — never smooth, never slick. +CRUCIAL — medium: confident colored-pencil hatching and striping builds every tone; gouache blocks in the broad flats. tapered pencil edges, visible cream-paper grain, optical mixing in the patterned surfaces — the exam-bench lining, jeffrey's wide-leg trousers, the pale wood floor. a print-work sensibility — perceptual, hand-made marks, hatching and moiré for tone, never a smooth digital gradient. the drawing dissolves softly to bare cream paper at the edges — no hard frame, no border, no UI chrome. -CRUCIAL — NO OUTLINES. No contour lines, no ink lines, no drawn edges, no cartoon linework anywhere. Every shape and edge exists only where one block of hatching meets a differently-colored or differently-valued block of hatching. Form is built entirely from VOLUME and tone — masses of directional pencil shading turning in light, like a colored-pencil tonal study. Mass and light, not line. +CRUCIAL — NO outlines as contour lines. form is built from VOLUME and tone — masses of directional hatching turning in light. every edge exists only where one hatch field meets a differently-coloured one. mass and light, not line. -CRUCIAL — colour is REALISTIC and gently muted, NOT dayglo and NOT a fantasy. A natural, believable, slightly soft summer palette: real skin, real sky, real water and sand. Saturation moderate and true-to-life. Calm, warm, a little hazy. +CRUCIAL — diegetic light ONLY. every bit of light traces to a real source — soft mid-morning daylight through the tall windows, a warm desk lamp, the faint lime-and-cyan glow of the projected logo, and (in the extraction panels) the glow of the matrixy bits themselves. NEVER a cinematic overlay glow, NEVER neon-noir, NEVER HDR. -CRUCIAL — hatching: PARALLEL strokes at angles snapped to 15-degree increments; within one tonal region one locked angle, adjacent regions different angles, so the figure reads as a mosaic of differently-oriented hatch fields describing a solid volume. Broad, confident, full-bodied strokes, each grained by the paper tooth. Never crosshatch, never scribble, never smudge. +CRUCIAL — peer horizontality. jeffrey and the intake clinician share the SAME eye-line, neither one centered as a hero. jeffrey is the visiting artist; the clinician is his peer, never a stiff authority. candid, unposed, deadpan — the comedy AND the unease both come from how ORDINARY it all looks. neither figure looks at the camera (the §0 establishing panel is the one exception — see its override). -CRUCIAL — curatorial restraint: the most resolved modeling sits on jeffrey, his hands, and the laptop. Outward the modeling thins — fewer hatch fields, more bare rough paper — until the periphery is open blocks of muted color dissolving into raw paper. +setting — a net-art clinic inside a SoHo cast-iron loft, downtown manhattan, mid-morning. tall arched industrial windows with slim black mullions, white-painted brick, a slender cast-iron column, a band of loft CEILING above the windows, beautiful soft daylight. warm minimalism: pale wood floor, soft white walls, leafy plants, a sleek modern exam bench lightly paper-lined, a low wood-and-white counter, a slim rolling cart. a chic current downtown space — NOT a dated 1990s doctor's office. CRUCIAL — framing is WIDE, roomy, and tilted slightly UP so a generous band of ceiling shows: the WHOLE clinic is visible — bench, counter, cart, windows, ceiling, plants, floor — a complete room around the figures, who are clear but NEVER cropped tight. -setting — a SUBTLE, peaceful, ordinary VACATION BEACH. Just sand, gentle surf, a soft open sky and a calm sea. NO city, NO buildings, NO skyline, NO oil tankers or ships, NO drones, NO shooting stars, NO technology of any kind in the environment. Maybe one distant low dune, a little beach grass, a far soft headland — nothing more. The laptop is the ONLY made object in the picture. Quiet, unpeopled, restful — somewhere you'd go to be away from everything. +CRUCIAL — Rhizome Health, the provider. the clinic is run by "Rhizome Health" — a (fictional, satirical) net-art health outfit that draws the digital "cultural juices" — the matrixy bits, the art-soul — out of handled aesthetic.computer artists and pays cash for them. one reference image is the Rhizome Health logo lockup — render its identity faithfully, hand-drawn in colored pencil, PROJECTED onto the white-brick wall behind the figures: a bright TRUE lime-green mark of thin radiating roots fanning from a corner, the "Rhizome Health" wordmark, a cyan "Aesthetic.Computer" line, a small grey tagline with a handle-count. it glows like real projected light, cast by a small white short-throw projector on the counter, a faint soft cone of light rising to the wall. keep the rhizome green a true bright lime-green. CRUCIAL — the projected logo is the clear anchor of the UPPER frame: HIGH and HORIZONTALLY CENTRED, sitting fully inside the central safe zone with a wide margin of cream wall on BOTH left and right — never near an edge. the ENTIRE "Rhizome Health" wordmark is legible, both words complete and prominent; "Health" is NEVER cut off. it reads like clear billboard signage centred above the scene. -subject: jeffrey sits cross-legged on damp packed sand a few feet back from the foam line. close three-quarter framing, waist-up. he is shirtless — bare shoulders and chest modeled as turning volumes of warm hatching, no outline on the body. plain navy or sage swim trunks. medium-length brown hair lightly tousled by the wind. his left hand is buried wrist-deep in the wet sand beside him, fingers spread under the grain. his right hand rests on top of a CLOSED chartreuse-green MacBook Neo laptop (the citrus-green model), lying flat on a folded grey hoodie on the sand. the laptop is CLOSED — only the smooth chartreuse lid shows; NO keyboard, NO screen, NO screens on the back or bottom. THE LID TREATMENT IS SET BY THE SECTION OVERRIDE and changes through the set: the FIRST panel is a brand-new machine with the plain standard small Apple logo; in every LATER panel there is instead a small torn SCRAP OF WHITE PAPER stuck over the logo with a hand-penned whistlegraph BUTTERFLY on it (one of the provided reference images is that butterfly doodle — a thick, simple, slightly wonky butterfly, rectangular smiling body, two big rounded wings; draw THAT exact butterfly by hand in the same rough colored-pencil/marker medium and paper tooth, genuinely part of the drawing, NOT a clean pasted sticker or crisp logo). It STAYS a laptop in every panel — it NEVER becomes a book, there is NO pen, NO manuscript, NO gilding. It simply gets older and more DATED across the panels (a clunkier, more outdated laptop model — thicker, yellowed, more beat-up). +subject — jeffrey, the visiting artist. use the SHOOT + SELFIE identity references for his likeness — his face must read clearly as the SAME real person. medium-length brown hair parted loose, slim build, clean-shaven. he wears his exact SHOOT outfit: a soft pale grey-blue long-sleeve button-down, slightly rumpled; wide-leg medium-cobalt-blue linen trousers; turquoise socks; black canvas low-top sneakers with white rubber soles; a bright yellow ballpoint pen clipped at his chest; red plastic glasses on a thin cord around his neck. on one wrist a laminated handle wristband, hand-lettered "@jeffrey" with a tiny green rhizome root-mark — CRUCIAL, the "@jeffrey" handle appears ONLY as that wristband worn ON him. jeffrey is off-center, a peer. NO tattoos, NO jewelry. -CRUCIAL — there is ALWAYS, in EVERY panel without exception, a single ambiguous WOMAN as a soft shadow/silhouette walking in the background — never omitted, never cropped. She sits comfortably INSIDE the frame within the safe zone (the mid-to-upper background band, well clear of all four edges), clearly visible but small and never identifiable. Her distance/age and whether a child holds her hand are set by the SECTION OVERRIDE. +CRUCIAL — jeffrey's chartreuse Neo, OPEN and IN USE. in almost every panel jeffrey has his own citrus-green chartreuse MacBook Neo OPEN, balanced on his lap or knee, and is TYPING on it — absorbed, working away, barely looking up even while the clinician works on him (the deadpan gag — he is always on his computer). the laptop's LID-BACK faces the camera so its decoration always shows; the SCREEN tilts toward jeffrey and is NEVER visible to us — no screen content, ever. -CRUCIAL — jeffrey has NO tattoos, EVER. NO jewelry of any kind, EVER — no chains, no necklaces, no rings, no bracelets, no earrings. Bare skin only. Any "gilded" or "cyberpunk" quality is purely a property of the LIGHT (a faint sheen on skin/edges), never an object worn on the body. +JEFFREY'S LAPTOP DOODLE (CRITICAL — READ CAREFULLY). on the Neo's lid-back, where an apple logo would be, a torn SCRAP OF WHITE PAPER is taped, with a wonky hand-inked doodle on it in thick dark marker. ONE OF THE ATTACHED REFERENCE IMAGES (wg-scrap.png) IS that exact doodle — COPY IT LINE FOR LINE. IMPORTANT: it is NOT a butterfly — do NOT draw a butterfly, do NOT put wings on a body. it is a wonky childlike STICK-FIGURE LITTLE PERSON: a roughly rectangular slightly-tilted smiling HEAD (two dot eyes + a small curved smile) on a tall thin rectangular BODY; from the upper body TWO clearly-drawn skinny ARMS — one on the LEFT, one on the RIGHT — reach UP AND OUTWARD like a child drawing someone with both arms raised, and EACH HAND ends in a big rounded puffy CLOUD shape (so it reads as a little person holding up / turning into two clouds, NOT a butterfly); at the bottom the body has TWO short stubby legs JOINED into one connected block split by a single short centre line. THE TWO RAISED ARMS ARE THE WHOLE POINT and THE SINGLE MOST IMPORTANT DETAIL: every prior render wrongly dropped the arms and fused the clouds straight onto the body like butterfly wings — that is the #1 mistake and is WRONG; there MUST be a visible skinny LEFT arm AND a symmetric skinny RIGHT arm between the body and each cloud; never armless, never one-armed, never lopsided, never butterfly-like. legs always ONE connected block split by a single centre line, never separate. render it BIG and clearly legible — a hand-drawn doodle on a real taped paper scrap, the torn paper edges showing, never a clean pasted sticker. this doodle is on JEFFREY'S green Neo ONLY. (in the entering / leaving / handshake panels the Neo is CLOSED and carried under his arm — the scrap doodle still shows, lid outward.) the clinic's own laptop is a SEPARATE plain dark clamshell on the rolling cart, lid facing away, a small green root-mark sticker on its matte back. -expression: relaxed, content, a little dreamy — soft easy smile, looking sidelong down toward the laptop. clean-shaven, no facial hair (matches refs). +the intake clinician — a DIFFERENT person, clearly not jeffrey: a woman in her 30s, dark curly hair pulled back, warm brown skin, a peer net artist. a soft white lab coat open over a printed tee and plain trousers, a lanyard with a small green root-mark badge. relaxed, friendly, focused on her work. -mood: mysterious, restful exploration. a calm vacation afternoon — a person and a small machine alone with the surf. +CRUCIAL — the extraction is DIGITAL, never medical gore. when a panel calls for it, "Rhizome Health" draws jeffrey's cultural juices out with a soft organic-tech apparatus — a pale, soft-bodied, faintly Cronenberg sci-fi extractor with a flexible translucent conduit gently CLIPPED to his forearm (NOT a needle into skin, NOT a wound) — and what flows out is purely DIGITAL: a stream of glowing matrixy bits — little green code-glyphs and colored pixels, ribbons of light — drawn along the conduit into a clear glass vessel that fills with luminous extracted "art-soul." ABSOLUTELY NO blood, NO needles-in-skin, NO syringes, NO wounds, NO injuries, NO gore — jeffrey is whole and unharmed throughout; the only thing leaving his body is light and code. a "scary" beat is built ONLY from eerie flickering light and uncanny mood, never from anything graphic. -square 1:1 composition. jeffrey occupies the lower two-thirds; the upper third is open soft sky. no typography, no title text, no watermark. +CRUCIAL — no recursive screens, no app / phone / social-media UI chrome, no progress bars or avatars — this is a drawing on cream paper, not a screenshot. a small USB-stick-shaped vape pen with a tiny LED may sit as a quiet varying prop on the counter. + +THE STORY BEAT IS SET BY THE SECTION OVERRIDE — jeffrey arrives, signs in, his cultural juices are extracted (a three-panel arc: it begins → a scary flickering peak → it calms), a gentle checkup, he is paid $800 cash, a handshake, he leaves. each panel's beat, gesture, light, mood and the projected logo's handle-count are set per section; everything above stays locked. + +tall vertical 9:16 portrait composition — a WIDE shot tilted slightly up with a generous band of ceiling showing; the whole clinic around the figures; the projected Rhizome Health logo HIGH and CENTRED in the upper frame, the complete wordmark prominent and unbroken. no typography, no title text, no watermark, no album text. diff --git a/pop/chillwave/helpabeach.np b/pop/chillwave/helpabeach.np index cd941b799..d0d9c8a88 100644 --- a/pop/chillwave/helpabeach.np +++ b/pop/chillwave/helpabeach.np @@ -1,31 +1,68 @@ -# wanderbeach — mysterious-exploration chillwave / ambient. +# helpabeach — mysterious-exploration chillwave / ambient (~2:30). # -# Key: A minor pentatonic (A C D E G) + competitive accidentals -# (D#/Eb as a blue-fifth pushing against the tonic, F# as a leading -# tone reaching up). The melody wanders continuously — eighth-note -# motion, faster than typical ambient but still gentle. +# Key: A minor pentatonic (A C D E G) + a single F# leading tone in +# drift 2 / undertow for tension before resolution. # -# Tempo: 70 BPM. Each *N is a beat (60/70 = ~0.857s). -# Eighth-note = *1; quarter = *2; half = *4. +# Tempo: 84 BPM. Each *N is a beat (60/84 ≈ 0.714s). +# Eighth-note ≈ *0.5; quarter = *1; half = *2; whole = *4; sustain = *6+. # -# Target total length: ~1:24 (96 beats + tail). +# Notation extension: chord tokens are notes joined by `+`, sharing +# one weight. Example: A4+C5+E5:_*2 → A-minor triad held for 2 beats. # # Section markers gate which bed layers fire (waves / sweeps / shakers -# / kick / bubbles / birds / pad / bells). +# / kick / bubbles / birds / pad / bells). Held / structural notes +# (durSec ≥ ~1.4s) also trigger the sub-octave DEEP bells. +# +# Narrative arc (instrumental — voice enters only as a faint texture +# from deep-current, never in the opening): +# tide-in — pure ocean, descending breath, low and slow, no bells +# drift 1 — climbing single line, ramping rhythmic density +# swell 1 — first peak: chord triads on downbeats +# deep-current — NEW: slow low register, deep bells surface +# drift 2 — climbing line, F# leading-tone tension +# undertow — NEW: deepest/darkest, octave-down, pulled-under +# swell 2 — NEW: return peak, brighter, fuller chords +# tide-out — resolution: ascending tonic flourish → big held Am +# ebb — NEW: long ambient outro, low notes dissolving out + +# tide-in 6 [waves-fade-in, no-bells] +A4:_*4 G4:_*3 E4:_*3 D4:_*4 C4:_*4 A3:_*4 -# tide-in 4 [waves-fade-in] -A4:_*1 C5:_*1 D5:_*1 E5:_*1 G5:_*1 E5:_*1 D5:_*1 C5:_*1 A4:_*2 G4:_*1 A4:_*1 C5:_*1 D5:_*1 E5:_*1 G5:_*1 A4:_*1 +# drift 1 8 [waves, sweep-slow, bubbles-sparse, kick] +A4:_*2 C5:_*2 D5:_*1 E5:_*1 G5:_*2 E5:_*2 +D5:_*1 C5:_*1 D5:_*2 E5:_*4 +G5:_*1 A5:_*1 G5:_*1 E5:_*1 D5:_*1 C5:_*1 A4:_*2 G4:_*2 -# drift 1 7 [waves, sweep-slow, bubbles-sparse, kick] -E5:_*1 D5:_*1 C5:_*1 D5:_*1 E5:_*1 G5:_*1 A5:_*2 G5:_*1 E5:_*1 D5:_*1 C5:_*1 D5:_*1 E5:_*2 -G5:_*1 E5:_*1 D#5:_*1 D5:_*1 C5:_*1 A4:_*1 G4:_*1 E5:_*1 D5:_*1 C5:_*1 D5:_*1 E5:_*2 A4:_*2 G4:_*2 +# swell 1 5 [filter-sweep-open, bubbles-dense, pad-bloom, kick] +A4+C5+E5:_*3 G5:_*1 +G4+B4+D5:_*3 E5:_*1 +C5+E5+G5:_*3 A5:_*1 +A4+C5+E5+A5:_*4 -# swell 3 [filter-sweep-open, bubbles-dense, pad-bloom, kick] -A5:_*1 G5:_*1 E5:_*1 G5:_*1 A5:_*1 G5:_*1 E5:_*1 D5:_*1 E5:_*1 G5:_*1 A5:_*1 G5:_*1 +# deep-current 7 [waves, sweep-slow, bubbles-sparse, pad-bloom] +A3:_*4 E3:_*4 G3:_*4 +A3:_*3 C4:_*3 D4:_*4 +E3:_*4 # drift 2 7 [waves, sweep-slow, bubbles-sparse, kick] -E5:_*1 D5:_*1 C5:_*1 D5:_*1 E5:_*1 G5:_*1 A5:_*1 G5:_*1 E5:_*1 D5:_*1 C5:_*1 A4:_*1 G4:_*1 A4:_*1 -F#4:_*1 G4:_*1 A4:_*1 C5:_*1 D5:_*1 E5:_*1 D5:_*1 C5:_*1 A4:_*1 G4:_*1 A4:_*1 C5:_*1 D5:_*1 A4:_*1 +E5:_*2 D5:_*1 C5:_*1 A4:_*4 +G4:_*1 A4:_*1 C5:_*1 D5:_*1 E5:_*2 D5:_*2 +F#4:_*2 G4:_*2 A4:_*2 G4:_*2 E4:_*2 + +# undertow 7 [waves, sweep-slow, pad-bloom] +A3:_*4 G3:_*3 E3:_*3 +D3:_*4 F#3:_*4 +A3:_*4 E3:_*4 + +# swell 2 5 [filter-sweep-open, bubbles-dense, pad-bloom, kick] +A4+C5+E5:_*3 G5:_*1 A5:_*2 +C5+E5+G5:_*3 A5:_*1 G5:_*2 +A4+C5+E5+G5+A5:_*6 + +# tide-out 6 [waves-fade-out] +E4:_*1 A4:_*1 C5:_*1 E5:_*1 D5:_*2 C5:_*2 +A4:_*2 G4:_*2 E4:_*4 +A3+A4+C5+E5+A5:_*8 -# tide-out 3 [waves-fade-out, no-bells-last-4] -E4:_*1 G4:_*1 A4:_*1 C5:_*1 D5:_*1 E4:_*1 G4:_*1 A4:_*1 A4:_*4 +# ebb 5 [waves-fade-out] +A3:_*6 E3:_*6 A2:_*10 diff --git a/pop/chillwave/helpabeach.vocal-harmony.np b/pop/chillwave/helpabeach.vocal-harmony.np new file mode 100644 index 000000000..ecc9296a1 --- /dev/null +++ b/pop/chillwave/helpabeach.vocal-harmony.np @@ -0,0 +1,25 @@ +# helpabeach — HARMONY voice line (diatonic 3rd up in A natural minor). +# +# Mapping (A natural minor: A B C D E F G): +# A → C (m3) B → D (m3) C → E (M3) D → F (m3) +# E → G (m3) F → A (M3) G → B (M3) +# +# renderSynthVoice() sings this with the formant synth at half gain, +# panned, on its own nonsense phonemes — the faint harmony to the lead +# voice. NOTE + weight only; syllables ignored. Same beat structure as +# helpabeach.vocal.np. + +verse 1 +G4:fin-*2 F4:-gers*1 E4:in*1 F4:the*1 C4:sand*10 + +verse 2 +B4:the*1 G4:tide*3 F4:re-*1 E4:-mem-*1 C4:-bers*10 + +verse 3 +G4:no-*1 F4:-thing*1 E4:in*1 F4:my*1 G4:poc-*1 F4:-ket*1 E4:but*1 C4:a*1 E4:small*2 F4:good*2 G4:thing*10 + +verse 4 +B4:the*1 G4:green*3 F4:ma-*1 E4:-chine*1 F4:is*1 E4:brea-*1 C4:-thing*10 + +verse 5 +F4:you*1 E4:can*2 C4:rest*4 B3:now*16 diff --git a/pop/chillwave/helpabeach.vocal.np b/pop/chillwave/helpabeach.vocal.np index 5c417dadd..52615784d 100644 --- a/pop/chillwave/helpabeach.vocal.np +++ b/pop/chillwave/helpabeach.vocal.np @@ -1,22 +1,24 @@ -# undabeach — jeffrey-pvc sung vocal melody. +# helpabeach — voice melody (note line for the formant synth voice). # notation: NOTE:syllable*weight (-prefix continues a word / melisma) -# key: A minor pentatonic (A C D E G), octave 3-4 — UP an octave from -# the first take so it reads clearly as SINGING in jeffrey-pvc's upper -# baritone, melodic and bright over the chillwave bed. score-pitch -# replaces f0 to these notes; score-stretch holds them on the 70 BPM -# beat grid. Sections are "verse N" for score-* --section all. +# key: A minor pentatonic (A C D E G), octave 3-4. +# +# The ElevenLabs jeffrey-pvc vocal was dropped — render.mjs now sings +# this line with renderSynthVoice() (formant synthesis). Only the NOTE +# + weight are read; the syllables are ignored, the voice sings +# procedurally-generated NONSENSE phonemes. The verse markers and the +# legacy score-pitch / score-stretch / sing.mjs path are unused now. verse 1 -E4:fin-*2 D4:-gers*1 C4:in*1 D4:the*1 A3:sand*5 +E4:fin-*2 D4:-gers*1 C4:in*1 D4:the*1 A3:sand*10 verse 2 -G4:the*1 E4:tide*3 D4:re-*1 C4:-mem-*1 A3:-bers*5 +G4:the*1 E4:tide*3 D4:re-*1 C4:-mem-*1 A3:-bers*10 verse 3 -E4:no-*1 D4:-thing*1 C4:in*1 D4:my*1 E4:poc-*1 D4:-ket*1 C4:but*1 A3:a*1 C4:small*2 D4:good*2 E4:thing*5 +E4:no-*1 D4:-thing*1 C4:in*1 D4:my*1 E4:poc-*1 D4:-ket*1 C4:but*1 A3:a*1 C4:small*2 D4:good*2 E4:thing*10 verse 4 -G4:the*1 E4:green*3 D4:ma-*1 C4:-chine*1 D4:is*1 C4:brea-*1 A3:-thing*5 +G4:the*1 E4:green*3 D4:ma-*1 C4:-chine*1 D4:is*1 C4:brea-*1 A3:-thing*10 verse 5 -D4:you*1 C4:can*2 A3:rest*3 G3:now*6 +D4:you*1 C4:can*2 A3:rest*4 G3:now*16 diff --git a/pop/lib/menu.mjs b/pop/lib/menu.mjs index 8b1a5f3ac..01e784f0f 100644 --- a/pop/lib/menu.mjs +++ b/pop/lib/menu.mjs @@ -257,11 +257,11 @@ export const MENU = { license: "CC0", url: "https://freesound.org/s/581467/", blurb: "sampled low tom — the 3/4 beat (replaced the synth kick)", }, - "undabeach-ocean": { + "helpabeach-ocean": { lane: "chillwave", file: "chillwave/out/.waves.wav (gitignored)", source: "freesound #352356 — “Gentle small waves lapping on shore.wav” by Alex_hears_things", license: "CC0", url: "https://freesound.org/s/352356/", - blurb: "close-mic calm sandy-shore lapping — the undabeach ocean bed", + blurb: "close-mic calm sandy-shore lapping — the helpabeach ocean bed", }, }, }, diff --git a/pop/lib/render-progress.mjs b/pop/lib/render-progress.mjs new file mode 100644 index 000000000..162e71c8d --- /dev/null +++ b/pop/lib/render-progress.mjs @@ -0,0 +1,97 @@ +// render-progress.mjs — progress heartbeats for long /pop renders. +// +// Each slow /pop render (audio = render.mjs, illy = gen-illy.mjs, video +// = preview-score.mjs) writes a tiny JSON heartbeat into +// ~/.ac-pop-renders/ so an external watcher — the Slab menubar — can +// show a live, temporary progress bar per render and drop it the moment +// the render ends. +// +// Lifecycle: begin({type,label}) → update(pct) → end() +// +// Heartbeat file ~/.ac-pop-renders/.json: +// { id, type, label, pct, pid, startedAt, updatedAt } +// type ∈ "audio" | "illy" | "video" +// pct ∈ 0..100, or null for an indeterminate render +// +// The reader (`pop renders` CLI, and the Swift menubar) treats a file +// as stale — and sweeps it — when its pid is dead or updatedAt is older +// than STALE_MS, so a crashed render never leaves a ghost bar behind. + +import { mkdirSync, writeFileSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const RENDERS_DIR = join(homedir(), ".ac-pop-renders"); +const STALE_MS = 120_000; + +function ensureDir() { + try { mkdirSync(RENDERS_DIR, { recursive: true }); } catch { /* ignore */ } +} + +// ── writer side (the render process) ───────────────────────────────── +let _id = null, _file = null, _type = null, _label = null; +let _started = 0, _lastWrite = 0; + +export function begin({ type, label } = {}) { + ensureDir(); + _type = type || "render"; + _label = label || _type; + _started = Date.now(); + _id = `${_type}-${process.pid}-${_started.toString(36)}`; + _file = join(RENDERS_DIR, `${_id}.json`); + _write(0); + // best-effort sweep if the process exits or is interrupted + const done = () => { try { rmSync(_file, { force: true }); } catch { /* ignore */ } }; + process.on("exit", done); + process.on("SIGINT", () => { done(); process.exit(130); }); + process.on("SIGTERM", () => { done(); process.exit(143); }); + return _id; +} + +export function update(pct) { + if (!_file) return; + // throttle to ~3 writes/s — the menubar only polls every 2 s + if (pct != null && pct < 100 && Date.now() - _lastWrite < 300) return; + _write(pct); +} + +export function end() { + if (!_file) return; + try { rmSync(_file, { force: true }); } catch { /* ignore */ } + _file = _id = null; +} + +function _write(pct) { + if (!_file) return; + _lastWrite = Date.now(); + const rec = { + id: _id, type: _type, label: _label, + pct: pct == null ? null : Math.max(0, Math.min(100, Math.round(pct))), + pid: process.pid, startedAt: _started, updatedAt: _lastWrite, + }; + try { writeFileSync(_file, JSON.stringify(rec)); } catch { /* ignore */ } +} + +// ── reader side (`pop renders` CLI; mirrors the Swift menubar) ──────── +function _alive(pid) { + if (!pid) return false; + try { process.kill(pid, 0); return true; } + catch (e) { return e.code === "EPERM"; } // alive but not ours +} + +export function list() { + ensureDir(); + const out = []; + let names = []; + try { names = readdirSync(RENDERS_DIR); } catch { return out; } + for (const n of names) { + if (!n.endsWith(".json")) continue; + const f = join(RENDERS_DIR, n); + let rec; + try { rec = JSON.parse(readFileSync(f, "utf8")); } catch { continue; } + const stale = Date.now() - (rec.updatedAt || 0) > STALE_MS || !_alive(rec.pid); + if (stale) { try { rmSync(f, { force: true }); } catch { /* ignore */ } continue; } + out.push(rec); + } + return out.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0)); +} -- 2.51.2