diff --git a/pop/imab/bin/gen-piano.mjs b/pop/imab/bin/gen-piano.mjs new file mode 100644 index 0000000000..85370eca1b --- /dev/null +++ b/pop/imab/bin/gen-piano.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// gen-piano.mjs — the grand piano accompaniment for imabclub: a real +// GM grand (FluidSynth + GeneralUser GS) reading a MIDI this script +// writes. Arpeggios in the intro and the break, sustained chords with +// a beat-3 restrike through the passes, fuller after the drop, sparse +// whole notes peeling out. Chord map = the track's C/F/G per bar. +// +// node pop/imab/bin/gen-piano.mjs +// → ~/.cache/ac/imab/piano.wav (imabclub.mjs mixes it) + +import { writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const WORK = `${process.env.HOME}/.cache/ac/imab`; +const SF2 = `${process.env.HOME}/.cache/ac/soundfonts/GeneralUserGS.sf2`; +const BARS = 96, TPQN = 480, BPM = 124; +const CH = { C: [48, 60, 64, 67], F: [41, 57, 60, 65], G: [43, 55, 59, 62] }; +const MAP16 = ["C", "C", "C", "C", "C", "F", "F", "G", "C", "C", "C", "C", "C", "C", "C", "C"]; +const act = (b) => (b < 16 ? "arp" : b < 56 ? "chords" : b < 64 ? "arp-high" : b < 80 ? "full" : "sparse"); + +const ev = []; // {tick, bytes[]} +const on = (t, n, v) => ev.push({ t, b: [0x90, n, v] }); +const off = (t, n) => ev.push({ t, b: [0x80, n, 0] }); +const note = (tick, n, durTicks, vel) => { on(tick, n, vel); off(tick + durTicks, n); }; +const B = 4 * TPQN; // one bar in ticks +for (let bar = 0; bar < BARS; bar++) { + const chord = CH[MAP16[bar % 16]]; + const t0 = bar * B, a = act(bar); + if (a === "arp" || a === "arp-high") { + const lift = a === "arp-high" ? 12 : 0; + const seq = [...chord, chord[2], chord[1]]; // up and part-way back + for (let k = 0; k < 8; k++) + note(t0 + (k * TPQN) / 2, seq[k % seq.length] + lift + (k >= 4 ? 12 : 0) * (a === "arp-high" ? 1 : 0), + TPQN * 0.9, 48 + (k % 4 === 0 ? 12 : 0)); + if (bar % 4 === 0) note(t0, chord[0] - 12, B * 0.95, 52); + } else if (a === "chords") { + note(t0, chord[0] - 12, B * 0.95, 58); + for (const n of chord.slice(1)) note(t0, n, 2 * TPQN * 0.95, 62); + for (const n of chord.slice(1)) note(t0 + 2 * TPQN, n, 2 * TPQN * 0.9, 50); + } else if (a === "full") { + note(t0, chord[0] - 12, B * 0.95, 68); + for (const n of chord.slice(1)) { note(t0, n, TPQN * 1.8, 72); note(t0 + 2 * TPQN, n + 12, TPQN * 1.8, 60); } + note(t0 + 3 * TPQN, chord[3] + 12, TPQN * 0.9, 55); + } else if (bar % 2 === 0) { + note(t0, chord[0] - 12, 2 * B * 0.95, 50); + for (const n of chord.slice(1)) note(t0, n, 2 * B * 0.9, 46); + } +} +ev.sort((x, y) => x.t - y.t); +const vlq = (n) => { const out = []; let v = n & 0x7f; while ((n >>= 7)) out.unshift((n & 0x7f) | 0x80); out.push(v); return out.length > 1 ? [...out.slice(0, -1), v] : [v]; }; +const trk = [0, 0xff, 0x51, 3, ...[(60000000 / BPM) >> 16 & 0xff, (60000000 / BPM) >> 8 & 0xff, (60000000 / BPM) & 0xff], 0, 0xc0, 0]; +let last = 0; +for (const e of ev) { trk.push(...vlq(e.t - last), ...e.b); last = e.t; } +trk.push(0, 0xff, 0x2f, 0); +const u32 = (n) => [n >> 24 & 255, n >> 16 & 255, n >> 8 & 255, n & 255]; +const mid = Buffer.from([0x4d, 0x54, 0x68, 0x64, ...u32(6), 0, 0, 0, 1, TPQN >> 8, TPQN & 255, + 0x4d, 0x54, 0x72, 0x6b, ...u32(trk.length), ...trk]); +writeFileSync(`${WORK}/piano.mid`, mid); +const r = spawnSync("fluidsynth", ["-ni", "-g", "0.7", "-F", `${WORK}/piano-raw.wav`, "-r", "48000", SF2, `${WORK}/piano.mid`], + { stdio: ["ignore", "ignore", "inherit"] }); +if (r.status !== 0) { console.error("✗ fluidsynth failed"); process.exit(1); } +spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-i", `${WORK}/piano-raw.wav`, + "-ac", "1", "-ar", "48000", "-af", "highpass=f=60", `${WORK}/piano.wav`], { stdio: ["ignore", "ignore", "inherit"] }); +console.log(`✓ ${WORK}/piano.wav`); diff --git a/pop/imab/bin/holyvox.mjs b/pop/imab/bin/holyvox.mjs index 354c86bee1..44e91b49eb 100644 --- a/pop/imab/bin/holyvox.mjs +++ b/pop/imab/bin/holyvox.mjs @@ -72,18 +72,41 @@ 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]); -// ── targets: absolute melody on the mapped timeline ─────────────────── +// ── targets: absolute melody on the mapped timeline; note changes land +// on the REAL vowel onsets inside each word (the PING flare starts where +// the sung vowel actually starts, not at an even split) const noteNames = [], noteStarts = [], targets = []; +const sylByIdx = {}; +{ + let ti2 = 0; + for (const w of doc.words) + if (ti2 < TMPL.length && fuzzy(TMPL[ti2], norm(w.text))) sylByIdx[ti2++] = w; +} for (const wo of wordsOut) { const notes = GT[wo.i].split(","); - const per = wo.outDur / notes.length; + const b = BOUNDS[wo.i]; + const srcDur = (b.toMs - b.fromMs) / 1000; + const toOut = (srcSec) => wo.outStart + Math.max(0, Math.min(1, (srcSec - b.fromMs / 1000) / srcDur)) * wo.outDur; + const nucs = (sylByIdx[wo.i]?.nuclei ?? []) + .filter((n) => n.startSec >= b.fromMs / 1000 - 0.05 && n.startSec <= b.toMs / 1000) + .sort((x, y) => x.startSec - y.startSec); + let ats; + if (wo.i === 3) // flap-PING: the flare owns the word — + ats = [wo.outStart + 0.03, // "flap" is one floor beat, C5 after + wo.outStart + Math.min(BEAT, wo.outDur * 0.3)]; + else if (notes.length === 1) ats = [wo.outStart + 0.03]; + else if (nucs.length >= notes.length) + ats = nucs.slice(0, notes.length).map((n) => toOut(n.startSec)); + else if (nucs.length === notes.length - 1) + ats = [wo.outStart + 0.03, ...nucs.map((n) => toOut(n.startSec))]; + else ats = notes.map((_, k) => wo.outStart + (k * wo.outDur) / notes.length + (k === 0 ? 0.03 : 0)); for (let k = 0; k < notes.length; k++) { const midi = toMidi(notes[k]); - const at = wo.outStart + k * per + (k === 0 ? 0.03 : 0); + const end = k + 1 < notes.length ? ats[k + 1] : wo.outStart + wo.outDur; noteNames.push(nname(midi)); - noteStarts.push(at.toFixed(3)); - 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) }); + noteStarts.push(ats[k].toFixed(3)); + targets.push({ label: wo.text + (notes.length > 1 ? `·${k + 1}` : ""), t: +ats[k].toFixed(3), + dur: +Math.max(0.1, end - ats[k]).toFixed(3), note: nname(midi) }); } } console.log(`→ WORLD snap: ${noteNames.join(" ")}`); diff --git a/pop/imab/bin/imabclub.mjs b/pop/imab/bin/imabclub.mjs index 634f670280..b01461cfd9 100644 --- a/pop/imab/bin/imabclub.mjs +++ b/pop/imab/bin/imabclub.mjs @@ -62,6 +62,12 @@ for (let bar = 0; bar < BARS; bar++) { } } +// ── grand piano accompaniment (gen-piano.mjs → FluidSynth GM grand) ─── +if (existsSync(`${WORK}/piano.wav`)) { + const piano = readF32(`${WORK}/piano.wav`); + for (let j = 0; j < piano.length && j < NT; j++) mixL[j] += piano[j] * 0.5; +} else console.log(" (no piano stem — run gen-piano.mjs)"); + // ── kick5 ───────────────────────────────────────────────────────────── const kn = Math.floor(0.42 * SR), K = new Float32Array(kn); { diff --git a/pop/imab/bin/imabreview.mjs b/pop/imab/bin/imabreview.mjs new file mode 100644 index 0000000000..ca67e47ea7 --- /dev/null +++ b/pop/imab/bin/imabreview.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// imabreview.mjs — the track-based review mp4, loner-style: the full +// imabclub mix under a scrolling bar ruler (numbers every 4 bars, a +// fixed center playhead), section labels, beat/bar pulse boxes, and +// every vocal utterance flashing its word+note at the exact placed +// moment in all three passes. This is the alignment-perfecting tool: +// if a word flashes off its bar, the placement is wrong. +// +// node pop/imab/bin/imabreview.mjs +// → out/imabclub-review.mp4 + +import { readFileSync, 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 BPM = 124, BEAT = 60 / BPM, BAR = 4 * BEAT; +const BARS = 96, PASS = [16, 40, 64]; +const PXB = 120; // pixels per bar on the ruler + +const MP3 = `${OUT}/imabclub-draft1.mp3`; +if (!existsSync(MP3)) { console.error("✗ render imabclub first"); process.exit(1); } +const targets = JSON.parse(readFileSync(`${WORK}/holy-targets.json`, "utf8")); +const dur = BARS * BAR + 4; + +// ── PNG assets: ruler strip, section labels, word 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 + "/review", exist_ok=True) +big = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 130) +med = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 64) +sml = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 40) +PXB, BARS = ${PXB}, ${BARS} +ruler = Image.new("RGBA", (PXB * BARS + 1920, 150), (0, 0, 0, 0)) +d = ImageDraw.Draw(ruler) +for b in range(BARS + 1): + x = 960 + b * PXB + major = b % 4 == 0 + d.rectangle([x, 70 if major else 95, x + 3, 148], fill=(255, 255, 255, 230 if major else 110)) + if major and b < BARS: + d.text((x + 10, 8), str(b + 1), font=med, fill=(255, 255, 255, 220)) + for q in range(1, 4): + xq = x + q * PXB // 4 + d.rectangle([xq, 118, xq + 2, 148], fill=(255, 255, 255, 60)) +ruler.save(f"{W}/review/ruler.png") +for name, txt in [("s0", "INTRO"), ("s1", "PASS 1"), ("s2", "LIFT"), ("s3", "PASS 2"), + ("s4", "BREAK"), ("s5", "DROP · PASS 3"), ("s6", "OUTRO")]: + img = Image.new("RGBA", (1920, 110), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + wpx = d.textlength(txt, font=med) + d.text(((1920 - wpx) / 2, 20), txt, font=med, fill=(140, 190, 255, 235)) + img.save(f"{W}/review/{name}.png") +targets = json.load(open(W + "/holy-targets.json")) +for i, t in enumerate(targets): + label = t["label"].split("\\u00b7")[0].replace("'", "\\u2019") + " " + t["note"] + img = Image.new("RGBA", (1920, 220), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + wpx = d.textlength(label, font=big) + d.text(((1920 - wpx) / 2, 30), label, font=big, fill=(255, 255, 255, 255)) + img.save(f"{W}/review/w{i:02d}.png") +print("review assets done") +`], { stdio: ["ignore", "inherit", "inherit"] }); +if (gen.status !== 0) process.exit(1); + +// ── compose ─────────────────────────────────────────────────────────── +const R = `${WORK}/review`; +const inputs = ["-f", "lavfi", "-i", `color=c=0x0d0d16:s=1920x1080:r=30:d=${dur.toFixed(2)}`, "-i", MP3, "-i", `${R}/ruler.png`]; +const SECT = [[0, "s0"], [16, "s1"], [32, "s2"], [40, "s3"], [56, "s4"], [64, "s5"], [80, "s6"]]; +SECT.forEach(([, n]) => inputs.push("-i", `${R}/${n}.png`)); +targets.forEach((_, i) => inputs.push("-i", `${R}/w${String(i).padStart(2, "0")}.png`)); +// the ruler scrolls right-to-left so the playhead (center) reads the bar +let fc = `[0:v][2:v]overlay=x='960-${PXB / BAR}*t-t*0':y=60[b0]`; +fc = `[0:v][2:v]overlay=x='960 - (t/${BAR.toFixed(5)})*${PXB} - 960':y=60[b0]`; +let bi = 0; +SECT.forEach(([bar, _], i) => { + const t0 = bar * BAR, t1 = (SECT[i + 1]?.[0] ?? BARS) * BAR; + fc += `;[b${bi}][${3 + i}:v]overlay=0:250:enable='between(t,${t0.toFixed(2)},${t1.toFixed(2)})'[b${bi + 1}]`; bi++; +}); +targets.forEach((t, i) => { + const wins = PASS.map((door) => [door * BAR + 0.05 + t.t, door * BAR + 0.05 + t.t + Math.max(t.dur, 0.45)]); + const en = wins.map(([a, z]) => `between(t,${a.toFixed(3)},${z.toFixed(3)})`).join("+"); + fc += `;[b${bi}][${3 + SECT.length + i}:v]overlay=0:460:enable='${en}'[b${bi + 1}]`; bi++; +}); +fc += `;[b${bi}]drawbox=x=956:y=40:w=8:h=190:color=0xffcc55@0.95:t=fill,` + + `drawbox=x=40:y=40:w=50:h=50:color=white@0.55:t=fill:enable='lt(mod(t,${BEAT.toFixed(4)}),0.08)',` + + `drawbox=x=110:y=40:w=50:h=50:color=0x77bbff@0.85:t=fill:enable='lt(mod(t,${BAR.toFixed(4)}),0.1)'[v]`; +const mp4 = `${OUT}/imabclub-review.mp4`; +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...inputs, + "-filter_complex", fc, "-map", "[v]", "-map", "1:a", + "-c:v", "libx264", "-preset", "fast", "-crf", "21", "-c:a", "aac", "-b:a", "192k", "-shortest", mp4]); +console.log(`✓ ${mp4}`);