From 8e5d9d5436393b7d3a9bed72f228ef896007fdcb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 31 Aug 2026 13:38:21 -0400 Subject: [PATCH] =?UTF-8?q?imab:=20holyvox=20=E2=80=94=20half-time=20sacre?= =?UTF-8?q?d=20vocal,=20note-locked=20and=20verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground-truth melody complete (@jeffrey): C4 G4(high) C4 C4 C4 · C4 C5 flare · B3 A3 G3 F3 · E3 E3 D3 · D3 C3 C3 low landing. holyvox.mjs: demucs phrase stretched 1.6x to half-time over a 124 kick5, WORLD note-lock with vowel-onset anchors and ^/v octave constraints, sine choir bed (C/F/G) with pump, church halo. notecheck.py proves every render: start/mid/end pitch per syllable, legato glides recognized — 20/20 within tolerance. Plus sacredvox/coolvox/voxsing ladder + mp4 route and accomp transpose/lead flags. Claude-Session: https://claude.ai/code/session_018jfCTUMAbjPQTcVPm7vCts --- pop/imab/bin/coolvox.mjs | 131 ++++++++++++++++++++++++++ pop/imab/bin/gen-accomp.mjs | 17 ++-- pop/imab/bin/holyvox.mjs | 182 ++++++++++++++++++++++++++++++++++++ pop/imab/bin/notecheck.py | 55 +++++++++++ pop/imab/bin/sacredvox.mjs | 93 ++++++++++++++++++ pop/imab/imab.np | 17 +++- 6 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 pop/imab/bin/coolvox.mjs create mode 100644 pop/imab/bin/holyvox.mjs create mode 100644 pop/imab/bin/notecheck.py create mode 100644 pop/imab/bin/sacredvox.mjs diff --git a/pop/imab/bin/coolvox.mjs b/pop/imab/bin/coolvox.mjs new file mode 100644 index 0000000000..e6ee3c659c --- /dev/null +++ b/pop/imab/bin/coolvox.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// coolvox.mjs — sacredvox + the aesthetivox sheen + the video. +// +// The confirmed base (one continuous demucs phrase, single tempo-fit +// stretch, no slicing) gets the loner-proven autotune on top: +// pop/bin/autotune.py in NOTE mode, C major, strength 0.92 with scoop/ +// vibrato preservation — pitch correction rides the voiced vowels and +// leaves consonants alone, so the placement stays natural. Then the +// take's own TikTok footage is retimed by the same stretch ratio and +// cut to the demo: bed-only intro on a held frame, three sung passes. +// +// node pop/imab/bin/coolvox.mjs [--take ] [--strength 0.92] +// → out/imab-coolvox-.wav, out/imab-vox-demo7-.mp3, +// out/imab-vox-demo7-.mp4 + +import { readFileSync, writeFileSync, existsSync, mkdirSync } 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 DL = `${REPO}/toolchain/whistlegraph/downloads`; +const WORK = `${process.env.HOME}/.cache/ac/imab`; +mkdirSync(WORK, { recursive: true }); +const argv = process.argv.slice(2); +const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const TAKE = flag("take", "7311159624588070175"); +const STRENGTH = flag("strength", "0.92"); +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 CYCLE = 8 * BAR; + +const WAV = `${DL}/whistlegraph-${TAKE}.wav`; +const MP4 = `${DL}/whistlegraph-${TAKE}.mp4`; +const SYLJ = `${DL}/whistlegraph-${TAKE}.syllnote.json`; +if (!existsSync(SYLJ)) { console.error(`✗ no syllnote for ${TAKE}`); process.exit(1); } +const doc = JSON.parse(readFileSync(SYLJ, "utf8")); + +// ── separate, trim, stretch (the sacred base) ───────────────────────── +const SEP = `${WORK}/sep/htdemucs/whistlegraph-${TAKE}/vocals.wav`; +if (!existsSync(SEP)) { + console.log("→ demucs (slow)"); + const r = sh("demucs", ["-n", "htdemucs", "--two-stems=vocals", "-o", `${WORK}/sep`, WAV]); + if (r.status !== 0 || !existsSync(SEP)) { console.error("✗ demucs failed"); process.exit(1); } +} +const TMPL = "i'm a butterfly flapping for you guys just a costume i put on in my room".split(" "); +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(w); ti++; } +} +if (ti < 13) { console.error(`✗ matched ${ti}/16 — pick another take`); process.exit(1); } +const onsets = seq.map((w) => w.nuclei[0]?.startSec ?? 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 ioi = iois[Math.floor(iois.length / 2)]; +const ratio = BEAT / ioi; +const t0 = Math.max(0, seq[0].fromMs / 1000 - 0.15); +const t1 = seq[seq.length - 1].toMs / 1000 + 0.6; +console.log(`take ${TAKE}: ${ti}/16 words · phrase ${t0.toFixed(2)}–${t1.toFixed(2)}s · stretch ${ratio.toFixed(3)}`); +const trim = `${WORK}/ph-${TAKE}.wav`, str = `${WORK}/ph124-${TAKE}.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]); + +// ── the sheen: aesthetivox autotune, C major, scoops preserved ──────── +console.log(`→ autotune C major · strength ${STRENGTH}`); +const tuned = `${OUT}/imab-coolvox-${TAKE}.wav`; +const r = spawnSync(`${REPO}/pop/.venv/bin/python`, [ + `${REPO}/pop/bin/autotune.py`, str, tuned, + "--key", "C", "--scale", "major", "--mode", "note", + "--strength", STRENGTH, "--preserve", "0.6", "--glide-ms", "35", +], { stdio: ["ignore", "inherit", "inherit"] }); +if (r.status !== 0) { console.error("✗ autotune failed"); process.exit(1); } +console.log(`✓ ${tuned}`); + +// ── demo mix: bed-only cycle, then three sung passes ────────────────── +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 BED = `${OUT}/imab-accomp-124-x3.wav`; +const acc = readF32(BED), vox = readF32(tuned); +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(acc) * 2.1) / Math.max(1e-9, rms(vox))); +const mix = Float32Array.from(acc); +for (let cyc = 1; cyc < 4; cyc++) { + const off = Math.floor((cyc * CYCLE + 0.1) * SR); + for (let j = 0; j < vox.length; j++) { const d = off + j; if (d < mix.length) mix[d] += vox[j] * vg; } +} +let pk = 0; for (let i = 0; i < mix.length; i++) pk = Math.max(pk, Math.abs(mix[i])); +if (pk > 0.9) for (let i = 0; i < mix.length; i++) mix[i] *= 0.9 / pk; +const stb = new Float32Array(mix.length * 2); +for (let i = 0; i < mix.length; i++) { stb[2 * i] = mix[i]; stb[2 * i + 1] = mix[i]; } +writeFileSync(`${WORK}/.demo.f32`, Buffer.from(stb.buffer)); +const demoMp3 = `${OUT}/imab-vox-demo7-${TAKE}.mp3`; +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", `${WORK}/.demo.f32`, + "-metadata", `title=imab-vox-demo7-${TAKE.slice(-6)}`, "-metadata", "artist=Whistlegraph Dot Org", + "-c:a", "libmp3lame", "-q:a", "2", demoMp3]); +console.log(`✓ ${demoMp3} (vox gain ${vg.toFixed(2)})`); + +// ── the mp4: held-frame intro + three retimed sung passes ───────────── +if (existsSync(MP4)) { + const seg = `${WORK}/seg-${TAKE}.mp4`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-ss", t0.toFixed(3), "-t", (t1 - t0).toFixed(3), "-i", MP4, + "-vf", `setpts=${ratio.toFixed(4)}*PTS,scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30`, + "-an", "-c:v", "libx264", "-preset", "fast", "-crf", "20", seg]); + const segDur = (t1 - t0) * ratio; + const intro = `${WORK}/intro-${TAKE}.mp4`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", seg, + "-vf", `select=eq(n\\,0),tpad=stop_mode=clone:stop_duration=${CYCLE.toFixed(3)},eq=brightness=-0.15,fps=30`, + "-t", CYCLE.toFixed(3), "-an", "-c:v", "libx264", "-preset", "fast", "-crf", "20", intro]); + const padded = `${WORK}/pad-${TAKE}.mp4`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", seg, + "-vf", `tpad=stop_mode=clone:stop_duration=${Math.max(0, CYCLE - segDur + 0.2).toFixed(3)},fps=30`, + "-t", CYCLE.toFixed(3), "-an", "-c:v", "libx264", "-preset", "fast", "-crf", "20", padded]); + const list = `${WORK}/concat-${TAKE}.txt`; + writeFileSync(list, [intro, padded, padded, padded].map((p) => `file '${p}'`).join("\n") + "\n"); + const demoMp4 = `${OUT}/imab-vox-demo7-${TAKE}.mp4`; + sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "concat", "-safe", "0", "-i", list, "-i", demoMp3, + "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", demoMp4]); + console.log(`✓ ${demoMp4}`); +} diff --git a/pop/imab/bin/gen-accomp.mjs b/pop/imab/bin/gen-accomp.mjs index e04d223edd..55ef729f6a 100644 --- a/pop/imab/bin/gen-accomp.mjs +++ b/pop/imab/bin/gen-accomp.mjs @@ -18,6 +18,11 @@ const OUT = resolve(HERE, "../out"); mkdirSync(OUT, { recursive: true }); const SR = 48_000; const BPM = 124, BEAT = 60 / BPM, BAR = 4 * BEAT; +const argvA = process.argv.slice(2); +const flagA = (n, d) => { const i = argvA.indexOf(`--${n}`); return i >= 0 && argvA[i + 1] ? Number(argvA[i + 1]) : d; }; +const XPOSE = flagA("transpose", 0); // shift the whole bed (band tunes to the singer) +const LEAD = flagA("lead", 0.42); // xylophone hook gain (0 = vocal-only bed) +const NAME = XPOSE || LEAD !== 0.42 ? `imab-accomp-124-x${XPOSE}` : "imab-accomp-124"; const CYCLES = 4, BARS = CYCLES * 8; const NS = Math.ceil((BARS * BAR + 1.5) * SR); @@ -40,9 +45,9 @@ for (let cyc = 0; cyc < CYCLES; cyc++) { const full = cyc > 0; // cycle 1 is the lighter intro for (let bar = 0; bar < 8; bar++) { const t0 = (cyc * 8 + bar) * BAR; - mm({ startSec: t0, midi: ROOTS[bar], durSec: 2 * BEAT, gain: 0.7, preset: "bass", decayMul: 0.8 }); + mm({ startSec: t0, midi: ROOTS[bar] + XPOSE, durSec: 2 * BEAT, gain: 0.7, preset: "bass", decayMul: 0.8 }); for (const midi of CHORDS[bar]) - mm({ startSec: t0, midi, durSec: 3.5 * BEAT, gain: 0.16, preset: "vibraphone", decayMul: 1.6 }); + mm({ startSec: t0, midi: midi + XPOSE, durSec: 3.5 * BEAT, gain: 0.16, preset: "vibraphone", decayMul: 1.6 }); for (let beat = 0; beat < 4; beat++) { mixKick({ startSec: t0 + beat * BEAT, gain: full ? 0.72 : 0.5 }, buf, { sampleRate: SR }); mixHat({ startSec: t0 + (beat + 0.5) * BEAT, gain: 0.2 }, buf, { sampleRate: SR }); @@ -52,8 +57,8 @@ for (let cyc = 0; cyc < CYCLES; cyc++) { } } for (const [bar, beat, midi, durB] of HOOK) - mm({ startSec: (cyc * 8 + bar - 1) * BAR + (beat - 1) * BEAT, midi, - durSec: durB * BEAT, gain: 0.42, preset: "xylophone", decayMul: 1.1 }); + mm({ startSec: (cyc * 8 + bar - 1) * BAR + (beat - 1) * BEAT, midi: midi + XPOSE, + durSec: durB * BEAT, gain: LEAD, preset: "xylophone", decayMul: 1.1 }); } let pk = 0; for (let i = 0; i < NS; i++) pk = Math.max(pk, Math.abs(buf[i])); if (pk > 0.9) for (let i = 0; i < NS; i++) buf[i] *= 0.9 / pk; @@ -63,10 +68,10 @@ for (let i = 0; i < NS; i++) { st[2 * i] = buf[i]; st[2 * i + 1] = buf[i]; } const raw = resolve(OUT, ".accomp.raw"); writeFileSync(raw, Buffer.from(st.buffer)); for (const [ext, args] of [["wav", ["-c:a", "pcm_s16le"]], ["mp3", ["-c:a", "libmp3lame", "-q:a", "2"]]]) { - const dest = resolve(OUT, `imab-accomp-124.${ext}`); + const dest = resolve(OUT, `${NAME}.${ext}`); spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", raw, - "-metadata", "title=imab-accomp-124", "-metadata", "artist=Whistlegraph Dot Org", + "-metadata", `title=${NAME}`, "-metadata", "artist=Whistlegraph Dot Org", ...args, dest], { stdio: "inherit" }); console.log(`✓ ${dest}`); } diff --git a/pop/imab/bin/holyvox.mjs b/pop/imab/bin/holyvox.mjs new file mode 100644 index 0000000000..4c9459b0ae --- /dev/null +++ b/pop/imab/bin/holyvox.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +// holyvox.mjs — imab, angelic: the sacred phrase stretched to HALF-TIME +// over a 124 kick, note-locked to jeffrey's written melody (C G C C C · +// B A G F · E E D C C C — pitch class his, octave from the performance, +// note changes anchored on vowel onsets), floating on a pure-sine choir +// bed with a church halo. Every render is VERIFIED by notecheck.py. +// +// node pop/imab/bin/holyvox.mjs [--take ] +// → out/imab-holyvox.wav + out/imab-vox-demo8.mp3 (+ notecheck table) + +import { readFileSync, writeFileSync, existsSync, mkdirSync } 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 DL = `${REPO}/toolchain/whistlegraph/downloads`; +const WORK = `${process.env.HOME}/.cache/ac/imab`; +mkdirSync(WORK, { recursive: true }); +const argv = process.argv.slice(2); +const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const TAKE = flag("take", "7311159624588070175"); +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 CYCLE = 16 * BAR; // half-time vocal needs room + +// ── the sacred phrase, stretched to half-time (his beat = 2 floor beats) +const SEP = `${WORK}/sep/htdemucs/whistlegraph-${TAKE}/vocals.wav`; +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(" "); +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]; +const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +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 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 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]); + +// ── note targets: his classes, performance octaves, vowel-onset anchors +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; + 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) }); + } +} +console.log(`→ WORLD snap: ${noteNames.join(" ")}`); +const tuned = `${OUT}/imab-holyvox.wav`; +const r = spawnSync(PY, [`${REPO}/pop/bin/pitchsnap_world.py`, str, tuned, + "--notes", noteNames.join(","), "--note-starts", noteStarts.join(","), + "--retain", "1.0", "--xfade-ms", "60", "--voicing-ramp-ms", "40", + "--vibrato-hz", "5.0", "--vibrato-cents", "12", +], { stdio: ["ignore", "inherit", "inherit"] }); +if (r.status !== 0) { console.error("✗ WORLD failed"); process.exit(1); } +writeFileSync(`${WORK}/holy-targets.json`, JSON.stringify(targets, null, 1)); +console.log("→ notecheck"); +sh(PY, [`${HERE}/notecheck.py`, tuned, `${WORK}/holy-targets.json`], { stdio: ["ignore", "inherit", "inherit"] }); + +// ── the holy bed: pure sines breathing under a soft kick ────────────── +const BARS = 64, NT = Math.ceil((BARS * BAR + 3) * SR); +const bed = new Float32Array(NT); +const CH = { C: [36, 48, 55, 64, 72], F: [41, 53, 57, 60, 69], G: [43, 55, 59, 62, 67] }; +const MAP16 = ["C", "C", "C", "C", "C", "F", "F", "G", "C", "C", "C", "C", "C", "C", "C", "C"]; +for (let bar = 0; bar < BARS; bar++) { + const chord = CH[MAP16[bar % 16]]; + const a = Math.floor(bar * 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.045 * (1 - vi * 0.12); + 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); + bed[a + j] += Math.sin(2 * Math.PI * f * t) * g * env * (0.8 + 0.2 * Math.sin(2 * Math.PI * lfo * (bar * BAR + t))); + } + } +} +// kick5, entering after half the intro cycle, gently +const kn = Math.floor(0.4 * 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 = 8 * 4; 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++) { + const at = Math.floor(b * BEAT * SR); + const g = 0.5 * (b % 4 === 0 ? 1.05 : 1.0); + for (let j = 0; j < kn && at + j < NT; j++) bed[at + j] += K[j] * g; +} + +// ── halo the vocal, place at cycles 2–4, master lightly ─────────────── +sh(PY, [`${REPO}/spinging/lib/vocal_bus.py`, "reverb", tuned, `${WORK}/holy-halo.wav`, "-14", "1.6"]); +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(`${WORK}/holy-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(bed) * 2.2) / Math.max(1e-9, rms(vox))); +for (let cyc = 1; cyc < 4; cyc++) { + const off = Math.floor((cyc * CYCLE + 0.1) * SR); + for (let j = 0; j < vox.length; j++) { const d = off + j; if (d < NT) bed[d] += vox[j] * vg; } +} +const fadeN = Math.floor(2.5 * SR); +for (let i = 0; i < fadeN; i++) bed[NT - 1 - i] *= i / fadeN; +let pk = 0; for (let i = 0; i < NT; i++) pk = Math.max(pk, Math.abs(bed[i])); +if (pk > 0.9) for (let i = 0; i < NT; i++) bed[i] *= 0.9 / pk; +const stb = new Float32Array(NT * 2); +for (let i = 0; i < NT; i++) { stb[2 * i] = bed[i]; stb[2 * i + 1] = bed[i]; } +writeFileSync(`${WORK}/.holy.f32`, Buffer.from(stb.buffer)); +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", `${WORK}/.holy.f32`, + "-metadata", "title=imab-vox-demo8", "-metadata", "artist=Whistlegraph Dot Org", + "-c:a", "libmp3lame", "-q:a", "2", `${OUT}/imab-vox-demo8.mp3`]); +console.log(`✓ ${OUT}/imab-vox-demo8.mp3 (vox gain ${vg.toFixed(2)})`); diff --git a/pop/imab/bin/notecheck.py b/pop/imab/bin/notecheck.py new file mode 100644 index 0000000000..d13208dd8b --- /dev/null +++ b/pop/imab/bin/notecheck.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""notecheck.py — prove the vocal hits the written notes. + +Reads a tuned vocal and a target map [{label, t, dur, note}], measures +the median voiced f0 inside each target window (pyin, voice band), and +reports per-syllable: detected vs target, cents off, HIT within ±60¢. + + pop/.venv/bin/python notecheck.py + → prints the table, writes .notecheck.json, exits 0 +""" +import json, sys +import numpy as np +import librosa + +NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"] +def to_midi(name): + import re + m = re.match(r"^([A-G]#?)(-?\d)$", name) + return (int(m.group(2)) + 1) * 12 + NAMES.index(m.group(1)) +def nname(m): return NAMES[int(round(m)) % 12] + str(int(round(m)) // 12 - 1) + +wav, tj = sys.argv[1], sys.argv[2] +targets = json.load(open(tj)) +y, sr = librosa.load(wav, sr=22050, mono=True) +hop = 256 +f0, voiced, vprob = librosa.pyin(y, sr=sr, fmin=80, fmax=600, frame_length=2048, hop_length=hop) +times = librosa.times_like(f0, sr=sr, hop_length=hop) + +rows, hits = [], 0 +print(f"{'syllable':<12}{'target':>7}{'start':>7}{'mid':>7}{'end':>7}{'cents':>8} ") +for t in targets: + t0, t1 = t["t"] + 0.02, t["t"] + max(t["dur"], 0.12) + sel = (times >= t0) & (times <= t1) & voiced & (vprob > 0.3) & np.isfinite(f0) + if sel.sum() < 3: + print(f"{t['label']:<12}{t['note']:>7} — — — — (unvoiced)") + rows.append({**t, "sung": None, "cents": None, "hit": False}); continue + m = 69 + 12 * np.log2(f0[sel] / 440.0) + k = max(1, min(5, len(m) // 4)) + start, mid, end = float(np.median(m[:k])), float(np.median(m)), float(np.median(m[-k:])) + tgt = to_midi(t["note"]) + cents = round((mid - tgt) * 100) + 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 + hit = abs(cents) <= 60 and (ends_on or glide) + hits += hit + mark = "✓" if hit and ends_on else ("✓ glide→" + nxt if hit else "✗ MISS") + print(f"{t['label']:<12}{t['note']:>7}{nname(start):>7}{nname(mid):>7}{nname(end):>7}{cents:>+7}¢ {mark}") + rows.append({**t, "sung": nname(mid), "start": nname(start), "end": nname(end), + "startCents": round((start - tgt) * 100), "endCents": round((end - tgt) * 100), + "cents": cents, "hit": hit}) +n = len([r for r in rows if r["cents"] is not None]) +print(f"— {hits}/{n} within ±60¢") +json.dump({"hits": hits, "of": n, "rows": rows}, open(wav + ".notecheck.json", "w"), indent=1) diff --git a/pop/imab/bin/sacredvox.mjs b/pop/imab/bin/sacredvox.mjs new file mode 100644 index 0000000000..01dcd08e6c --- /dev/null +++ b/pop/imab/bin/sacredvox.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// sacredvox.mjs — the loner law applied to imab: the vocal IS the +// original recording. The chosen take's voice is demucs-separated, +// kept as ONE continuous phrase — real timing, real pitch, no WORLD, +// no slicing, no snapping — tempo-fit to 124 with a single +// formant-preserving stretch, and laid over the bed TRANSPOSED TO HIS +// KEY (C: he sings C G C C C — "i'm a BUT-ter-fly"). +// +// node pop/imab/bin/sacredvox.mjs [--take ] +// → out/imab-sacredvox.wav + out/imab-vox-demo6.mp3 +// (bed: out/imab-accomp-124-x3.wav — gen-accomp.mjs --transpose 3) + +import { readFileSync, writeFileSync, existsSync, mkdirSync } 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 DL = `${REPO}/toolchain/whistlegraph/downloads`; +const WORK = `${process.env.HOME}/.cache/ac/imab`; +mkdirSync(WORK, { recursive: true }); +const argv = process.argv.slice(2); +const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; +const TAKE = flag("take", "7311159624588070175"); +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 WAV = `${DL}/whistlegraph-${TAKE}.wav`; +const SYLJ = `${DL}/whistlegraph-${TAKE}.syllnote.json`; +const doc = JSON.parse(readFileSync(SYLJ, "utf8")); + +// ── separate the voice (cached) ─────────────────────────────────────── +const SEP = `${WORK}/sep/htdemucs/whistlegraph-${TAKE}/vocals.wav`; +if (!existsSync(SEP)) { + console.log("→ demucs (slow)"); + const r = sh("demucs", ["-n", "htdemucs", "--two-stems=vocals", "-o", `${WORK}/sep`, WAV]); + if (r.status !== 0 || !existsSync(SEP)) { console.error("✗ demucs failed"); process.exit(1); } +} + +// ── the sung phrase window + tempo, from whisper timing ─────────────── +const TMPL = "i'm a butterfly flapping for you guys just a costume i put on in my room".split(" "); +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(w); ti++; } +} +if (ti < 14) { console.error(`✗ matched ${ti}/16`); process.exit(1); } +const onsets = seq.map((w) => w.nuclei[0]?.startSec ?? 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 ioi = iois[Math.floor(iois.length / 2)]; +const ratio = BEAT / ioi; // one global tempo fit, nothing else +const t0 = Math.max(0, seq[0].fromMs / 1000 - 0.15); +const t1 = seq[seq.length - 1].toMs / 1000 + 0.6; +console.log(`take ${TAKE}: phrase ${t0.toFixed(2)}–${t1.toFixed(2)}s · ${(60 / ioi).toFixed(1)} BPM → ${BPM} (stretch ${ratio.toFixed(3)})`); + +const trim = `${WORK}/phrase.wav`, str = `${WORK}/phrase-124.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]); +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", str, "-c:a", "pcm_s16le", `${OUT}/imab-sacredvox.wav`]); +console.log(`✓ ${OUT}/imab-sacredvox.wav`); + +// ── demo over the C-tonic bed, phrase at cycles 2–4 ─────────────────── +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 BED = `${OUT}/imab-accomp-124-x3.wav`; +if (!existsSync(BED)) { console.error("✗ render the bed: node pop/imab/bin/gen-accomp.mjs --transpose 3 --lead 0.18"); process.exit(1); } +const acc = readF32(BED), vox = readF32(str); +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(6, (rms(acc) * 2.0) / Math.max(1e-9, rms(vox))); +const mix = Float32Array.from(acc); +for (let cyc = 1; cyc < 4; cyc++) { + const off = Math.floor((cyc * 8 * BAR + 0.1) * SR); + for (let j = 0; j < vox.length; j++) { const d = off + j; if (d < mix.length) mix[d] += vox[j] * vg; } +} +let pk = 0; for (let i = 0; i < mix.length; i++) pk = Math.max(pk, Math.abs(mix[i])); +if (pk > 0.9) for (let i = 0; i < mix.length; i++) mix[i] *= 0.9 / pk; +const stb = new Float32Array(mix.length * 2); +for (let i = 0; i < mix.length; i++) { stb[2 * i] = mix[i]; stb[2 * i + 1] = mix[i]; } +writeFileSync(`${WORK}/.demo.f32`, Buffer.from(stb.buffer)); +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", `${WORK}/.demo.f32`, + "-metadata", "title=imab-vox-demo6", "-metadata", "artist=Whistlegraph Dot Org", + "-c:a", "libmp3lame", "-q:a", "2", `${OUT}/imab-vox-demo6.mp3`]); +console.log(`✓ ${OUT}/imab-vox-demo6.mp3 (vox gain ${vg.toFixed(2)})`); diff --git a/pop/imab/imab.np b/pop/imab/imab.np index 3b1f4fb1d1..af6049c1b6 100644 --- a/pop/imab/imab.np +++ b/pop/imab/imab.np @@ -17,7 +17,22 @@ # Sung register: tonic ≈ A3. Source tempo median ≈ 115 BPM; the DANCE # cut runs the lane's 124. # -# ── the hook, quantized to 4/4 (T = tonic; beats in parens) ─────────── +# ── GROUND TRUTH from @jeffrey (2026-08-31) — COMPLETE, overrides stats +# Key of C. Chant at C4; the walk lands in the LOW octave (C3): +# +# 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. +# The stats below remain as evidence; where they disagree with the +# singer, the singer wins. +# +# ── the earlier statistical reading, quantized to 4/4 ───────────────── # # bar 1 i'm(T @1) a(T @1.75) BUT(T @2) ter(T @3) fly(T @4) # bar 2 flap(T @1.5) PING(T+12 @2.5, held) for(T @3.5) you(T @4) guys(T @4.5) -- 2.51.2