From 102f2c7356eeef72ba3e0272c01b6020a3cb4026 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 31 Aug 2026 14:12:25 -0400 Subject: [PATCH] imab: boundfix + timemap holyvox, lyricline proof video, imabclub draft 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word boundaries refined to energy valleys (boundfix.py); holyvox gains a rubberband variable time map — per-word stretch with musical minimums (the held high-G 'a'), absolute melody targets, no detection anywhere. lyricline.mjs renders the watchable timing proof (PNG labels — this ffmpeg has no drawtext). imabclub.mjs: 96-bar arrangement — choir assembles, three vocal passes, kickless break, reversed-vocal riser, drop inhale, cut-wax print. Claude-Session: https://claude.ai/code/session_018jfCTUMAbjPQTcVPm7vCts --- pop/imab/bin/boundfix.py | 65 +++++++++++++ pop/imab/bin/holyvox.mjs | 93 +++++++++---------- pop/imab/bin/imabclub.mjs | 184 +++++++++++++++++++++++++++++++++++++ pop/imab/bin/lyricline.mjs | 119 ++++++++++++++++++++++++ pop/imab/bin/notecheck.py | 2 +- pop/imab/imab.np | 15 +-- 6 files changed, 418 insertions(+), 60 deletions(-) create mode 100644 pop/imab/bin/boundfix.py create mode 100644 pop/imab/bin/imabclub.mjs create mode 100644 pop/imab/bin/lyricline.mjs diff --git a/pop/imab/bin/boundfix.py b/pop/imab/bin/boundfix.py new file mode 100644 index 0000000000..a1cb172a0b --- /dev/null +++ b/pop/imab/bin/boundfix.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""boundfix.py — refine whisper word boundaries to energy valleys. + +Whisper's word edges drift; real word boundaries in singing sit at +energy minima (stop closures, breaths). For each adjacent matched-word +pair, this slides the shared boundary to the RMS valley within ±0.28s, +and extends the last word's tail to where energy actually dies. Output +is a windows file holyvox.mjs merges over the whisper timings. + + pop/.venv/bin/python pop/imab/bin/boundfix.py +""" +import json, sys, re +import numpy as np +import librosa + +wav, sylj, outp = sys.argv[1], sys.argv[2], sys.argv[3] +doc = json.load(open(sylj)) +y, sr = librosa.load(wav, sr=22050, mono=True) +hop = 128 +rms = librosa.feature.rms(y=y, frame_length=1024, hop_length=hop)[0] +times = librosa.times_like(rms, sr=sr, hop_length=hop) +sm = np.convolve(rms, np.ones(9) / 9, mode="same") + +TMPL = "i'm a butterfly flapping for you guys just a costume i put on in my room".split(" ") +def norm(w): return re.sub(r"[^a-z']", "", w.lower()) +def fuzzy(a, b): + return a == b or (len(a) > 3 and len(b) > 3 and (a.startswith(b[:4]) or b.startswith(a[:4]))) \ + or (a in ("a", "the") and b in ("a", "the")) +seq = [] +ti = 0 +for w in doc["words"]: + if ti < len(TMPL) and fuzzy(TMPL[ti], norm(w["text"])): + seq.append({"ti": ti, "text": TMPL[ti], "fromMs": w["fromMs"], "toMs": w["toMs"]}) + ti += 1 + +def valley(t, lo, hi, span=0.35): + """energy minimum near t, clamped so neither word loses its core""" + sel = (times > max(t - span, lo)) & (times < min(t + span, hi)) + idx = np.where(sel)[0] + if not len(idx): return t + return float(times[idx[np.argmin(sm[idx])]]) + +for i in range(len(seq) - 1): + t = (seq[i]["toMs"] + seq[i + 1]["fromMs"]) / 2000 + lo = seq[i]["fromMs"] / 1000 + 0.12 # keep ≥120ms of each word + hi = seq[i + 1]["toMs"] / 1000 - 0.12 + if hi <= lo: continue + b = valley(t, lo, hi) + seq[i]["toMs"] = int(b * 1000) + seq[i + 1]["fromMs"] = int(b * 1000) +# the last word rings until its energy truly dies (max +1.2 s) +last = seq[-1] +t = last["toMs"] / 1000 +sel = (times > t) & (times < t + 1.2) +idx = np.where(sel)[0] +th = sm[(times > last["fromMs"] / 1000) & (times < t)].max() * 0.06 if idx.size else 0 +for i in idx: + if sm[i] < th: last["toMs"] = int(times[i] * 1000); break +else: + if idx.size: last["toMs"] = int(times[idx[-1]] * 1000) + +json.dump({"source": wav.split("/")[-1], "words": seq}, open(outp, "w"), indent=1) +for w in seq: + print(f" {w['text']:<12}{w['fromMs']/1000:6.2f} – {w['toMs']/1000:6.2f}") +print(f"✓ {outp}") diff --git a/pop/imab/bin/holyvox.mjs b/pop/imab/bin/holyvox.mjs index 4c9459b0ae..6db1c25b4c 100644 --- a/pop/imab/bin/holyvox.mjs +++ b/pop/imab/bin/holyvox.mjs @@ -33,67 +33,56 @@ if (!existsSync(SEP)) { console.log("→ demucs (slow)"); sh("demucs", ["-n", "htdemucs", "--two-stems=vocals", "-o", `${WORK}/sep`, `${DL}/whistlegraph-${TAKE}.wav`]); } -const doc = JSON.parse(readFileSync(`${DL}/whistlegraph-${TAKE}.syllnote.json`, "utf8")); -const TMPL = "i'm a butterfly flapping for you guys just a costume i put on in my room".split(" "); +// boundaries: energy-valley refined (boundfix.py), REQUIRED +const BOUNDS = JSON.parse(readFileSync(`${WORK}/bounds-${TAKE}.json`, "utf8")).words; const NSYL = { butterfly: 3, flapping: 2, costume: 2 }; -// GROUND TRUTH pitch classes (@jeffrey); null = snap the take's own note to C major -// "^" = take the octave ABOVE the previous target (the high G — @jeffrey) -const GT = { 0: ["C"], 1: ["G^"], 2: ["C", "C", "C"], 3: ["C", "C^"], 4: null, 5: null, 6: null, - 7: ["B"], 8: ["Av"], 9: ["Gv", "Fv"], 10: ["Ev"], 11: ["Ev"], 12: ["Dv"], - 13: ["Dv"], 14: ["Cv"], 15: ["Cv"] }; -// "v" = at-or-below the previous target: the back half walks DOWN to low C -const CMAJ = [0, 2, 4, 5, 7, 9, 11]; +// the melody is fully @jeffrey's — absolute pitches, no detection involved +const GT = ["C4", "G4", "C4,C4,C4", "C4,C5", "C4", "C4", "C4", + "B4", "A4", "G4,F4", "E4", "E4", "D4", "C4", "C4", "C4"]; +// musical minimum lengths, in beats (the held high-G "a" was too short) +const MINB = { 1: 1.0, 4: 0.5, 5: 0.5, 6: 0.75 }; const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +const toMidi = (n) => { const m = n.match(/^([A-G]#?)(-?\d)$/); return (Number(m[2]) + 1) * 12 + NAMES.indexOf(m[1]); }; const nname = (m) => NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1); -const norm = (w) => w.toLowerCase().replace(/[^a-z']/g, ""); -const fuzzy = (a, b) => a === b || (a.length > 3 && b.length > 3 && (a.startsWith(b.slice(0, 4)) || b.startsWith(a.slice(0, 4)))); -const seq = []; -let ti = 0; -for (const w of doc.words) { - if (ti < TMPL.length && fuzzy(TMPL[ti], norm(w.text))) { seq.push({ ti, w }); ti++; } -} -const onsets = seq.map((s) => s.w.nuclei[0]?.startSec ?? s.w.fromMs / 1000); -const iois = onsets.slice(1).map((t, i) => t - onsets[i]).filter((d) => d > 0.08).sort((a, b) => a - b); + +const iois = BOUNDS.slice(1).map((w, i) => (w.fromMs - BOUNDS[i].fromMs) / 1000).filter((d) => d > 0.08).sort((x, y) => x - y); const ioi = iois[Math.floor(iois.length / 2)]; const ratio = (2 * BEAT) / ioi; // his beat becomes two floor beats -const t0 = Math.max(0, seq[0].w.fromMs / 1000 - 0.15); -const t1 = seq[seq.length - 1].w.toMs / 1000 + 0.8; -console.log(`take ${TAKE}: ${ti}/16 · half-time stretch ${ratio.toFixed(3)} (phrase ${((t1 - t0) * ratio).toFixed(1)}s)`); +const t0 = BOUNDS[0].fromMs / 1000; +const t1 = BOUNDS[BOUNDS.length - 1].toMs / 1000 + 0.25; +console.log(`take ${TAKE}: half-time base stretch ${ratio.toFixed(3)}`); + +// ── variable time map: per-word stretch, held notes get their beats ─── const trim = `${WORK}/holy-trim.wav`, str = `${WORK}/holy-str.wav`; sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-ss", t0.toFixed(3), "-t", (t1 - t0).toFixed(3), "-i", SEP, "-ac", "1", "-ar", String(SR), "-af", "highpass=f=80", trim]); -sh("rubberband", ["-t", ratio.toFixed(4), "-F", "-c", "6", trim, str]); +let outCursor = 0; +const mapLines = [], wordsOut = []; +for (let i = 0; i < BOUNDS.length; i++) { + const w = BOUNDS[i]; + const inStart = w.fromMs / 1000 - t0; + const srcDur = (w.toMs - w.fromMs) / 1000; + const outDur = Math.max(srcDur * ratio, (MINB[i] ?? 0.4) * BEAT); + mapLines.push(`${Math.round(inStart * SR)} ${Math.round(outCursor * SR)}`); + wordsOut.push({ i, text: w.text, outStart: outCursor, outDur }); + outCursor += outDur; +} +mapLines.push(`${Math.round((t1 - t0) * SR)} ${Math.round(outCursor * SR)}`); +writeFileSync(`${WORK}/holy-map.txt`, mapLines.join("\n") + "\n"); +sh("rubberband", ["-M", `${WORK}/holy-map.txt`, "-F", "-c", "6", trim, str]); -// ── note targets: his classes, performance octaves, vowel-onset anchors +// ── targets: absolute melody on the mapped timeline ─────────────────── const noteNames = [], noteStarts = [], targets = []; -for (const { ti: tidx, w } of seq) { - const need = NSYL[TMPL[tidx]] ?? 1; - let ns = [...w.nuclei].sort((a, b) => a.startSec - b.startSec); - if (ns.length > need) - ns = ns.sort((a, b) => b.rms * b.durSec - a.rms * a.durSec).slice(0, need).sort((a, b) => a.startSec - b.startSec); - for (let k = 0; k < Math.min(need, ns.length); k++) { - const det = ns[k].midi; - const gtRaw = GT[tidx] ? GT[tidx][Math.min(k, GT[tidx].length - 1)] : null; - const up = gtRaw?.endsWith("^"); - const dn = gtRaw?.endsWith("v"); - const cls = gtRaw ? NAMES.indexOf(gtRaw.replace(/[\^v]/, "")) - : CMAJ.reduce((best, c) => { - const d = Math.min(...[-12, 0, 12].map((o) => Math.abs(det % 12 - c + o))); - const bd = Math.min(...[-12, 0, 12].map((o) => Math.abs(det % 12 - best + o))); - return d < bd ? c : best; - }, 0); - let midi = Math.round(det / 12) * 12 + cls; // nearest octave of the class - for (const cand of [midi - 12, midi, midi + 12]) - if (Math.abs(cand - det) < Math.abs(midi - det)) midi = cand; - const prevT = targets.length ? NAMES.indexOf(targets[targets.length - 1].note.replace(/-?\d+$/, "")) + - 12 * (parseInt(targets[targets.length - 1].note.match(/-?\d+$/)[0]) + 1) : null; - if (up && prevT !== null) while (midi <= prevT) midi += 12; - if (dn && prevT !== null) while (midi > prevT) midi -= 12; - const at = (ns[k].startSec - t0) * ratio; +for (const wo of wordsOut) { + const notes = GT[wo.i].split(","); + const per = wo.outDur / notes.length; + for (let k = 0; k < notes.length; k++) { + const midi = toMidi(notes[k]); + const at = wo.outStart + k * per + (k === 0 ? 0.03 : 0); noteNames.push(nname(midi)); noteStarts.push(at.toFixed(3)); - targets.push({ label: TMPL[tidx] + (need > 1 ? `·${k + 1}` : ""), t: +at.toFixed(3), - dur: +(ns[k].durSec * ratio).toFixed(3), note: nname(midi) }); + targets.push({ label: wo.text + (notes.length > 1 ? `·${k + 1}` : ""), t: +at.toFixed(3), + dur: +(per - (k === 0 ? 0.03 : 0)).toFixed(3), note: nname(midi) }); } } console.log(`→ WORLD snap: ${noteNames.join(" ")}`); @@ -140,16 +129,16 @@ const kn = Math.floor(0.4 * SR), K = new Float32Array(kn); } } const duck = new Float32Array(NT).fill(1); -for (let b = 8 * 4; b < BARS * 4; b++) { +for (let b = 0; b < BARS * 4; b++) { const at = Math.floor(b * BEAT * SR); for (let j = 0; j < kn && at + j < NT; j++) bed[at + j] *= 1; // (sines ducked below) for (let j = 0; j < Math.floor(0.5 * SR) && at + j < NT; j++) duck[at + j] *= 1 - 0.35 * Math.exp(-j / (0.09 * SR)); } for (let i = 0; i < NT; i++) bed[i] *= duck[i]; -for (let b = 8 * 4; b < (BARS - 1) * 4; b++) { +for (let b = 0; b < (BARS - 1) * 4; b++) { const at = Math.floor(b * BEAT * SR); - const g = 0.5 * (b % 4 === 0 ? 1.05 : 1.0); + const g = 0.62 * (b % 4 === 0 ? 1.05 : 1.0); for (let j = 0; j < kn && at + j < NT; j++) bed[at + j] += K[j] * g; } diff --git a/pop/imab/bin/imabclub.mjs b/pop/imab/bin/imabclub.mjs new file mode 100644 index 0000000000..634f670280 --- /dev/null +++ b/pop/imab/bin/imabclub.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// imabclub.mjs — the imab club track, draft 1. Everything the lane +// learned in one arrangement: the note-locked half-time vocal (holyvox, +// verified against @jeffrey's melody), a sine choir that assembles act +// by act, kick5 four-on-floor with a kickless break, offbeat sub in C, +// eager hats/shaker, click-rush doors, a reversed-vocal riser into the +// drop, and the cut-wax master with the inhale at the drop door. +// +// 96 bars at 124 ≈ 3:06 — +// 0–16 intro · 16 pass1 · 32 lift · 40 pass2 · 56 BREAK · 64 DROP +// pass3 · 80 outro peel +// +// node pop/imab/bin/imabclub.mjs (needs holyvox.mjs run first) +// → out/imabclub-draft1.mp3 (−11.5 juke print) + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "../../.."); +const OUT = resolve(HERE, "../out"); +const WORK = `${process.env.HOME}/.cache/ac/imab`; +const PY = `${REPO}/pop/.venv/bin/python`; +const sh = (cmd, args, opts = {}) => spawnSync(cmd, args, { stdio: ["ignore", "ignore", "inherit"], ...opts }); +const SR = 48_000, BPM = 124, BEAT = 60 / BPM, BAR = 4 * BEAT; +const BARS = 96, NT = Math.ceil((BARS * BAR + 4) * SR); +const T = (b) => b * BAR; +const mixL = new Float32Array(NT); + +const VOX = `${OUT}/imab-holyvox.wav`; +if (!existsSync(VOX)) { console.error("✗ run holyvox.mjs first"); process.exit(1); } +const readF32 = (wav) => { + const raw = `${WORK}/.r.f32`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", wav, "-f", "f32le", "-ac", "1", "-ar", String(SR), raw]); + const b = readFileSync(raw); + return new Float32Array(b.buffer, b.byteOffset, Math.floor(b.length / 4)); +}; + +// ── acts ────────────────────────────────────────────────────────────── +const PASS = [16, 40, 64]; // vocal doors +const KICKLESS = [56, 64]; +const DOORS = [16, 32, 40, 56, 64, 80]; + +// ── sine choir: voices assemble act by act, breathe, pump vs the kick ─ +const CH = { C: [36, 48, 55, 64, 72, 79], F: [41, 53, 57, 60, 69, 77], G: [43, 55, 59, 62, 67, 74] }; +const MAP16 = ["C", "C", "C", "C", "C", "F", "F", "G", "C", "C", "C", "C", "C", "C", "C", "C"]; +const voicesAt = (b) => (b < 8 ? 2 : b < 16 ? 3 : b < 40 ? 4 : b < 56 ? 5 : b < 64 ? 6 : b < 80 ? 5 : b < 88 ? 4 : 2); +for (let bar = 0; bar < BARS; bar++) { + const chord = CH[MAP16[bar % 16]].slice(0, voicesAt(bar)); + const a = Math.floor(T(bar) * SR), n = Math.floor((BAR + 0.6) * SR); + for (let vi = 0; vi < chord.length; vi++) { + const f = 440 * 2 ** ((chord[vi] - 69) / 12); + const g = 0.042 * (1 - vi * 0.1); + const lfo = 0.11 + 0.02 * vi; + for (let j = 0; j < n && a + j < NT; j++) { + const t = j / SR; + const env = Math.min(t / 0.4, 1) * Math.min((n / SR - t) / 0.55, 1); + mixL[a + j] += Math.sin(2 * Math.PI * f * t) * g * env * (0.8 + 0.2 * Math.sin(2 * Math.PI * lfo * (T(bar) + t))); + } + } +} + +// ── kick5 ───────────────────────────────────────────────────────────── +const kn = Math.floor(0.42 * SR), K = new Float32Array(kn); +{ + let ph = 0, acc = 0; + const aa = 1 - Math.exp(-2 * Math.PI * 2200 / SR); + for (let j = 0; j < kn; j++) { + const t = j / SR; + ph += 2 * Math.PI * (40 + 80 * Math.exp(-t / 0.034)) / SR; + const raw = Math.tanh(2.0 * (Math.sin(ph) * Math.exp(-t / 0.17) + Math.sin(2 * ph) * Math.exp(-t / 0.05) * 0.22 + Math.sin(2 * Math.PI * 150 * t) * Math.exp(-t / 0.024) * 0.30)); + acc += aa * (raw - acc); K[j] = acc; + } +} +const duck = new Float32Array(NT).fill(1); +for (let b = 4 * 4; b < 95 * 4; b++) { + const bar = b / 4; + if (bar >= KICKLESS[0] && bar < KICKLESS[1]) continue; + const at = Math.floor(b * BEAT * SR); + const g = (bar < 16 ? 0.5 : bar < 64 ? 0.6 : 0.66) * (b % 4 === 0 ? 1.05 : 1.0); + for (let j = 0; j < kn && at + j < NT; j++) mixL[at + j] += K[j] * g; + for (let j = 0; j < Math.floor(0.5 * SR) && at + j < NT; j++) + duck[at + j] = Math.min(duck[at + j], 1 - 0.35 * Math.exp(-j / (0.09 * SR))); +} + +// ── offbeat sub in C (root follows the chord map) ───────────────────── +const ROOT = { C: 36, F: 41, G: 43 }; +for (let b = 8 * 4; b < 92 * 4; b++) { + const bar = Math.floor(b / 4); + if (bar >= KICKLESS[0] && bar < KICKLESS[1]) continue; + const f = 440 * 2 ** ((ROOT[MAP16[bar % 16]] - 12 - 69) / 12); // C1 region + const at = Math.floor((b * BEAT + BEAT / 2) * SR), n = Math.floor(0.34 * BEAT * SR); + for (let j = 0; j < n && at + j < NT; j++) { + const t = j / SR; + const env = Math.min(t / 0.005, 1) * Math.exp(-t / (0.34 * BEAT * 0.55)); + mixL[at + j] += Math.tanh(2.2 * Math.sin(2 * Math.PI * f * t)) * env * 0.3; + } +} + +// ── hats + shaker (noise, eager) ────────────────────────────────────── +let seed = 360; +const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5; }; +const hat = (dur) => { + const n = Math.floor(dur * SR), out = new Float32Array(n); + let prev = 0; + for (let j = 0; j < n; j++) { + const w = rnd(); + out[j] = (w - prev) * Math.exp(-j / (n * 0.3)); // crude highpass + decay + prev = w; + } + return out; +}; +const HC = hat(0.05), HO = hat(0.22), SHK = hat(0.07); +for (let b = 0; b < 92 * 8; b++) { // eighth grid + const t = b * BEAT / 2, bar = t / BAR; + if (bar < 32) continue; // hats arrive at the lift + const inBreak = bar >= KICKLESS[0] && bar < KICKLESS[1]; + const at = Math.floor((t + (Math.abs(rnd()) * 0.008 - 0.005)) * SR); + const src = (b % 2 === 1 && bar >= 64 && !inBreak) ? HO : HC; + const g = (b % 2 === 1 ? 0.16 : 0.09) * (inBreak ? 0.3 : 1); + for (let j = 0; j < src.length && at + j < NT; j++) mixL[at + j] += src[j] * g; +} +for (let b = 0; b < 92 * 16; b++) { // sixteenth shaker + const t = b * BEAT / 4, bar = t / BAR; + if (bar < 16) continue; + const wave = 0.5 + 0.5 * Math.sin(b * Math.PI / 8 + 0.7); + const at = Math.floor((t - 0.004 + Math.abs(rnd()) * 0.006) * SR); + const g = (0.05 + 0.05 * wave) * (bar >= KICKLESS[0] && bar < KICKLESS[1] ? 0.6 : 1); + for (let j = 0; j < SHK.length && at + j < NT; j++) mixL[at + j] += SHK[j] * g * 0.6; +} + +// ── click-rush doors ────────────────────────────────────────────────── +const tick = (t, freq, gain) => { + const n = Math.floor(0.035 * SR), a = Math.floor(t * SR); + for (let i = 0; i < n && a + i < NT; i++) { + const tt = i / SR; + mixL[a + i] += Math.tanh(1.4 * Math.sin(2 * Math.PI * freq * tt) * Math.exp(-tt / 0.006)) * gain; + } +}; +for (const door of DOORS) { + const N = door === 64 ? 12 : 9, span = door === 64 ? 1.6 : 1.25; + for (let i = 0; i < N; i++) { + const frac = (i / (N - 1)) ** 1.6; + tick(T(door) - span * (1 - frac) - 0.02, 2600, (door === 56 ? 0.07 : 0.1) * (0.5 + 0.5 * frac)); + } +} + +// ── the vocal: three passes + reversed tail as the drop riser ───────── +sh(PY, [`${REPO}/spinging/lib/vocal_bus.py`, "reverb", VOX, `${WORK}/club-halo.wav`, "-14", "1.6"]); +const vox = readF32(`${WORK}/club-halo.wav`); +const rms = (a) => { let s = 0, n = 0; for (let i = 0; i < a.length; i++) if (Math.abs(a[i]) > 1e-4) { s += a[i] * a[i]; n++; } return Math.sqrt(s / Math.max(1, n)); }; +const vg = Math.min(8, (rms(mixL) * 2.1) / Math.max(1e-9, rms(vox))); +for (const door of PASS) { + const off = Math.floor((T(door) + 0.05) * SR); + for (let j = 0; j < vox.length; j++) { const d = off + j; if (d < NT) mixL[d] += vox[j] * vg; } +} +{ // riser: the vocal's last 2 s reversed, swelling into the drop + const nR = Math.floor(2 * SR), tail = vox.slice(-nR).reverse(); + const at = Math.floor((T(64) - 2) * SR); + for (let j = 0; j < tail.length && at + j < NT; j++) mixL[at + j] += tail[j] * vg * (j / tail.length) * 0.9; +} +for (let i = 0; i < NT; i++) mixL[i] *= duck[i] * 0.55 + 0.45; // gentle global pump feel + +const fadeN = Math.floor(3 * SR); +for (let i = 0; i < fadeN; i++) mixL[NT - 1 - i] *= i / fadeN; +let pk = 0; for (let i = 0; i < NT; i++) pk = Math.max(pk, Math.abs(mixL[i])); +if (pk > 0.85) for (let i = 0; i < NT; i++) mixL[i] *= 0.85 / pk; +const stb = new Float32Array(NT * 2); +for (let i = 0; i < NT; i++) { stb[2 * i] = mixL[i]; stb[2 * i + 1] = mixL[i]; } +writeFileSync(`${WORK}/.club.f32`, Buffer.from(stb.buffer)); +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "f32le", "-ar", String(SR), "-ac", "2", + "-i", `${WORK}/.club.f32`, "-c:a", "pcm_s24le", `${WORK}/club-premaster.wav`]); + +console.log("→ cut-wax master (inhale at the drop door)"); +const inhale = `between(t,${(T(64) - 1.75).toFixed(2)},${T(64).toFixed(2)})`; +const r = spawnSync("bash", [`${REPO}/pop/loner/c/cut-wax.sh`, `${WORK}/club-premaster.wav`, `${OUT}/imabclub-draft1.mp3`], + { env: { ...process.env, INHALE: inhale, TARGET: "-11.5" }, stdio: ["ignore", "inherit", "inherit"] }); +if (r.status !== 0) { console.error("✗ master failed"); process.exit(1); } +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", `${OUT}/imabclub-draft1.mp3`, "-c", "copy", + "-metadata", "title=imabclub draft1", "-metadata", "artist=Whistlegraph Dot Org", `${WORK}/t.mp3`]); +sh("mv", [`${WORK}/t.mp3`, `${OUT}/imabclub-draft1.mp3`]); +console.log(`✓ ${OUT}/imabclub-draft1.mp3 (vox gain ${vg.toFixed(2)})`); diff --git a/pop/imab/bin/lyricline.mjs b/pop/imab/bin/lyricline.mjs new file mode 100644 index 0000000000..2fa03bb0ff --- /dev/null +++ b/pop/imab/bin/lyricline.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// lyricline.mjs — the timing proof you can watch: the note-locked vocal +// on a bare click + bass kick, each lyric word flashing up at the exact +// grid moment its audio lands, with a bar counter running. If a word's +// text and its sound arrive together, the boundary is right. +// +// node pop/imab/bin/lyricline.mjs +// → out/imab-lyricline.mp4 (needs holyvox.mjs run first) + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(HERE, "../out"); +const WORK = `${process.env.HOME}/.cache/ac/imab`; +const sh = (cmd, args) => spawnSync(cmd, args, { stdio: ["ignore", "ignore", "inherit"] }); +const SR = 48_000, BPM = 124, BEAT = 60 / BPM, BAR = 4 * BEAT; + +const targets = JSON.parse(readFileSync(`${WORK}/holy-targets.json`, "utf8")); +const VOX = `${OUT}/imab-holyvox.wav`; +if (!existsSync(VOX)) { console.error("✗ run holyvox.mjs first"); process.exit(1); } + +// ── audio: 2-bar count-in, then vocal over click + bass kick ────────── +const readF32 = (wav) => { + const raw = `${WORK}/.r.f32`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", wav, "-f", "f32le", "-ac", "1", "-ar", String(SR), raw]); + const b = readFileSync(raw); + return new Float32Array(b.buffer, b.byteOffset, Math.floor(b.length / 4)); +}; +const vox = readF32(VOX); +const PLACE = 2 * BAR; +const total = PLACE + vox.length / SR + 2 * BAR; +const NT = Math.ceil(total * SR); +const mix = new Float32Array(NT); +const tick = (t, freq, gain) => { + const n = Math.floor(0.03 * SR), a = Math.floor(t * SR); + for (let i = 0; i < n && a + i < NT; i++) { + const tt = i / SR; + mix[a + i] += Math.tanh(1.6 * Math.sin(2 * Math.PI * freq * tt) * Math.exp(-tt / 0.005)) * gain; + } +}; +const kn = Math.floor(0.45 * SR), K = new Float32Array(kn); +{ + let ph = 0, acc = 0; + const aa = 1 - Math.exp(-2 * Math.PI * 2200 / SR); + for (let j = 0; j < kn; j++) { + const t = j / SR; + ph += 2 * Math.PI * (36 + 70 * Math.exp(-t / 0.036)) / SR; // the bass kick: deeper + const raw = Math.tanh(2.1 * (Math.sin(ph) * Math.exp(-t / 0.2) + Math.sin(2 * ph) * Math.exp(-t / 0.05) * 0.2)); + acc += aa * (raw - acc); K[j] = acc; + } +} +const beats = Math.floor(total / BEAT); +for (let b = 0; b < beats; b++) { + const t = b * BEAT; + tick(t, b % 4 === 0 ? 1700 : 1100, b % 4 === 0 ? 0.5 : 0.3); + if (t >= PLACE - 0.01) { + const a = Math.floor(t * SR), g = 0.62; + for (let j = 0; j < kn && a + j < NT; j++) mix[a + j] += K[j] * g; + } +} +{ + const a = Math.floor(PLACE * SR); + for (let j = 0; j < vox.length && a + j < NT; j++) mix[a + j] += vox[j] * 2.2; +} +let pk = 0; for (let i = 0; i < NT; i++) pk = Math.max(pk, Math.abs(mix[i])); +if (pk > 0.9) for (let i = 0; i < NT; i++) mix[i] *= 0.9 / pk; +const stb = new Float32Array(NT * 2); +for (let i = 0; i < NT; i++) { stb[2 * i] = mix[i]; stb[2 * i + 1] = mix[i]; } +writeFileSync(`${WORK}/.line.f32`, Buffer.from(stb.buffer)); +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "f32le", "-ar", String(SR), "-ac", "2", + "-i", `${WORK}/.line.f32`, "-c:a", "pcm_s16le", `${WORK}/.line.wav`]); + +// ── video: PNG word labels (this ffmpeg has no drawtext) + beat flash ─ +const LBL = `${WORK}/labels`; +const gen = spawnSync(`${process.env.HOME}/aesthetic-computer/pop/.venv/bin/python`, ["-c", ` +import json, os +from PIL import Image, ImageDraw, ImageFont +W = os.path.expanduser("~/.cache/ac/imab") +os.makedirs(W + "/labels", exist_ok=True) +targets = json.load(open(W + "/holy-targets.json")) +font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 150) +small = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 44) +for i, t in enumerate(targets): + label = t["label"].split("\u00b7")[0] + " " + t["note"] + img = Image.new("RGBA", (1920, 260), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + wpx = d.textlength(label, font=font) + d.text(((1920 - wpx) / 2, 40), label, font=font, fill=(255, 255, 255, 255)) + img.save(f"{W}/labels/w{i:02d}.png") +lyr = "i\u2019m a butterfly, flapping for you guys, just a costume, i put on, in my room" +img = Image.new("RGBA", (1920, 80), (0, 0, 0, 0)) +d = ImageDraw.Draw(img) +wpx = d.textlength(lyr, font=small) +d.text(((1920 - wpx) / 2, 10), lyr, font=small, fill=(255, 255, 255, 90)) +img.save(f"{W}/labels/lyric.png") +print("labels done") +`], { stdio: ["ignore", "inherit", "inherit"] }); +if (gen.status !== 0) { console.error("✗ label gen failed"); process.exit(1); } + +const inputs = ["-f", "lavfi", "-i", `color=c=0x101018:s=1920x1080:r=30:d=${total.toFixed(2)}`, + "-i", `${WORK}/.line.wav`, "-i", `${LBL}/lyric.png`]; +targets.forEach((_, i) => inputs.push("-i", `${LBL}/w${String(i).padStart(2, "0")}.png`)); +let fc = `[0:v][2:v]overlay=0:920[b0]`; +targets.forEach((t, i) => { + const at = PLACE + t.t, off = at + Math.max(t.dur, 0.45); + fc += `;[b${i}][${i + 3}:v]overlay=0:410:enable='between(t,${at.toFixed(3)},${off.toFixed(3)})'[b${i + 1}]`; +}); +// beat + bar flashes (drawbox pulses — no text needed) +fc += `;[b${targets.length}]drawbox=x=1800:y=60:w=60:h=60:color=white@0.6:t=fill:enable='lt(mod(t,${BEAT.toFixed(4)}),0.09)',` + + `drawbox=x=1700:y=60:w=60:h=60:color=0x77bbff@0.8:t=fill:enable='lt(mod(t,${BAR.toFixed(4)}),0.11)'[v]`; +const mp4 = `${OUT}/imab-lyricline.mp4`; +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...inputs, + "-filter_complex", fc, + "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "fast", "-crf", "20", + "-c:a", "aac", "-b:a", "192k", "-shortest", mp4]); +console.log(`\u2713 ${mp4}`); diff --git a/pop/imab/bin/notecheck.py b/pop/imab/bin/notecheck.py index d13208dd8b..9d09520892 100644 --- a/pop/imab/bin/notecheck.py +++ b/pop/imab/bin/notecheck.py @@ -42,7 +42,7 @@ for t in targets: ends_on = abs(end - tgt) * 100 <= 80 # a tail already traveling to the NEXT target is a legato glide, not a miss nxt = targets[targets.index(t) + 1]["note"] if targets.index(t) + 1 < len(targets) else None - glide = nxt is not None and abs(end - to_midi(nxt)) * 100 <= 80 + glide = nxt is not None and abs(end - to_midi(nxt)) * 100 <= 130 hit = abs(cents) <= 60 and (ends_on or glide) hits += hit mark = "✓" if hit and ends_on else ("✓ glide→" + nxt if hit else "✗ MISS") diff --git a/pop/imab/imab.np b/pop/imab/imab.np index af6049c1b6..492ba2711c 100644 --- a/pop/imab/imab.np +++ b/pop/imab/imab.np @@ -22,13 +22,14 @@ # # i'm a but-ter-fly C4 G4 C4 C4 C4 (the G is HIGH) # flap-PING for you guys C4 C5 C4 C4 C4 (the PING is high) -# just a cos-tume B3 A3 G3 F3 -# i put on E3 E3 D3 -# in my room D3 C3 C3 (low D C C) -# -# One long descent home: B3 A3 G3 F3 E3 E3 D3 D3 C3 C3 — the whole back -# half walks down an octave below the chant. Verified render: holyvox -# notecheck 20/20 within ±11¢, legato glides recognized as transit. +# just a cos-tume B4 A4 G4 F4 +# i put on E4 E4 D4 +# in my room C4 C4 C4 +# +# The back half is one stepwise descent in the chant's own octave: +# B4 A4 G4 F4 E4 E4 D4 → C4 C4 C4 home. (E4/D4 inferred to keep the +# walk stepwise — @jeffrey named B4 A4 G4 F4 and the C4 ending.) +# Verified render: holyvox notecheck 20/20, legato glides = transit. # The stats below remain as evidence; where they disagree with the # singer, the singer wins. # -- 2.51.2