diff --git a/pop/SCORE.md b/pop/SCORE.md index f085442280..36b4d1c66c 100644 --- a/pop/SCORE.md +++ b/pop/SCORE.md @@ -284,7 +284,8 @@ seeds; the ear decides; the videos prove it. 5. **Prove** — two videos, judged by eye and ear together: - the **lyric check** (`score-video.mjs --lyrics`): every word a block on its own lane over her waveform, bars numbered in playing order with `bar.beat` under every beat, so any moment - can be named ("28.2"); + can be named ("28.2"); with `--click` the same lanes play over her stem on a bare click + kick + (the clip-lane click video — loner's review-score4 language on lyricline's audio); - the **lyricline** (`pop/bin/lyricline.mjs`): her stem on a bare click + kick from the chart's real beat times, each word flashing at the instant its audio lands, the `bar.beat` counter running. Render only the seam in question (`--from/--to`). Born on imab, fixed-BPM; this diff --git a/pop/bin/lyricline.mjs b/pop/bin/lyricline.mjs index 0366aa2b39..fd148dfc9f 100755 --- a/pop/bin/lyricline.mjs +++ b/pop/bin/lyricline.mjs @@ -14,6 +14,8 @@ // --offset record start on the cut clock (or --receipt to read startSec) // --from/to render only this record window (seconds) — the seam you are judging // --sync-ms display latency compensation (default 50, measured by loner's synccal) +// --audio-only write just the click + kick + vocal mix (record clock, from --from) and stop — +// score-video.mjs --lyrics --click uses this to put the lyric check's clip lanes on a click import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { resolve, dirname, basename } from "node:path"; @@ -35,7 +37,7 @@ const SR = 48_000; const words = JSON.parse(readFileSync(WORDS, "utf8")).filter((w) => !w.muted); const bars = JSON.parse(readFileSync(BARS, "utf8")).bars.slice().sort((a, b) => a.t - b.t); const end = TO ?? Math.max(...words.map((w) => w.toMs / 1000)) + 2; -const PRE = 1.0; // a second of grid before the window +const PRE = FROM > 0 ? 1.0 : 0; // a second of grid before the window const t0 = FROM - PRE, dur = end - t0; // ── audio: the vocal slice (cut clock = record + OFFSET) over click + kick on the chart's beats ── @@ -65,7 +67,9 @@ 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`]); +const lineWav = arg("audio-only") ? resolve(arg("audio-only")) : `${WORK}/.line.wav`; +sh("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", `${WORK}/.line.f32`, "-c:a", "pcm_s16le", lineWav]); +if (arg("audio-only")) { console.log(`✓ ${lineWav} (click + kick + vocal, record ${t0.toFixed(2)}–${end.toFixed(2)}s)`); process.exit(0); } // ── video: word labels + bar.beat counter as PNGs (no drawtext in this ffmpeg), beat/bar flashes ── const inWin = words.filter((w) => w.toMs / 1000 >= t0 && w.fromMs / 1000 <= end); @@ -90,7 +94,7 @@ d.text((40, 8), S["title"], font=small, fill=(255, 255, 255, 110)); img.save(f"{ `], { 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=60:d=${dur.toFixed(2)}`, "-i", `${WORK}/.line.wav`, "-i", `${WORK}/labels/title.png`]; +const inputs = ["-f", "lavfi", "-i", `color=c=0x101018:s=1920x1080:r=60:d=${dur.toFixed(2)}`, "-i", lineWav, "-i", `${WORK}/labels/title.png`]; inWin.forEach((_, i) => inputs.push("-i", `${WORK}/labels/w${String(i).padStart(3, "0")}.png`)); counter.forEach((_, i) => inputs.push("-i", `${WORK}/labels/c${String(i).padStart(3, "0")}.png`)); let fc = `[0:v][2:v]overlay=0:20[b0]`; let k = 0; diff --git a/pop/sailor-song/README.md b/pop/sailor-song/README.md index bb4f0b009e..08fdb8aa3b 100644 --- a/pop/sailor-song/README.md +++ b/pop/sailor-song/README.md @@ -48,6 +48,7 @@ pop/.venv/bin/python pop/sailor-song/bin/bounds-study.py [--apply] # the ea pop/.venv/bin/python pop/sailor-song/bin/word-times.py --fa node pop/sailor-song/bin/splice.mjs && bash pop/sailor-song/bin/bake.sh node pop/sailor-song/bin/score-video.mjs --lyrics --audio pop/sailor-song/out/sailor-song-v19.mp3 +node pop/sailor-song/bin/score-video.mjs --lyrics --click --audio pop/sailor-song/out/sailor-song-v19.mp3 # same lanes, her stem on a click + kick node pop/bin/lyricline.mjs --vocal pop/sailor-song/src/vox/cut/vocals-natural.wav \ --words pop/sailor-song/src/words-record.json --bars pop/sailor-song/measures.cut.json \ --receipt pop/sailor-song/out/sailor-song-v19.events.json --from 28 --to 40 \ diff --git a/pop/sailor-song/bin/score-video.mjs b/pop/sailor-song/bin/score-video.mjs index 382cb833e6..e4ba7d46ca 100644 --- a/pop/sailor-song/bin/score-video.mjs +++ b/pop/sailor-song/bin/score-video.mjs @@ -39,9 +39,17 @@ function newestCut() { return mp3s.map((f) => resolve(OUT, f)) .sort((a, b) => execFileSync("stat", ["-f%m", b]) - execFileSync("stat", ["-f%m", a]))[0]; } -const audio = resolve(arg("audio") || newestCut()); -const stem = basename(audio).replace(/\.(mp3|wav|flac)$/, "").replace(/-master$/, ""); +const base = resolve(arg("audio") || newestCut()); +const stem = basename(base).replace(/\.(mp3|wav|flac)$/, "").replace(/-master$/, ""); const receiptPath = [arg("events"), resolve(OUT, `${stem}.events.json`)].filter(Boolean).map((p) => resolve(p)).find(existsSync); +// v20: --click — the lyric check's clip lanes over her stem on a bare click + kick (pop/bin/lyricline.mjs +// builds the mix from the chart's real beats) instead of the full record: the timing proof, with clips +const CLICK = !!arg("click"); +const clickWav = resolve(OUT, `.${stem}-click.wav`); +if (CLICK) execFileSync("node", [resolve(LANE, "../bin/lyricline.mjs"), "--vocal", resolve(LANE, "src/vox/cut/vocals-natural.wav"), + "--words", resolve(LANE, "src/words-record.json"), "--bars", resolve(LANE, "measures.cut.json"), "--receipt", receiptPath, + "--from", "0", "--audio-only", clickWav], { stdio: "inherit" }); +const audio = CLICK ? clickWav : base; const R = receiptPath ? JSON.parse(readFileSync(receiptPath, "utf8")) : {}; if (!receiptPath) console.warn("! no events receipt found — drawing audio only"); const wordsPath = resolve(LANE, "src/words-record.json"); @@ -52,7 +60,7 @@ const lyricLines = existsSync(resolve(LANE, "src/lyrics-sung.txt")) const W = Number(arg("width", 1280)), H = Number(arg("height", 720)); const FPS = Number(arg("fps", 30)); const LYRICS = !!arg("lyrics"); // v19: the lyric check — only the words, over her own waveform -const outPath = resolve(arg("out") || resolve(OUT, `${stem}-${LYRICS ? "lyrics" : "score"}.mp4`)); +const outPath = resolve(arg("out") || resolve(OUT, `${stem}-${LYRICS ? "lyrics" : "score"}${CLICK ? "-click" : ""}.mp4`)); const probe = (p, k) => execFileSync("ffprobe", ["-v", "error", "-show_entries", `format=${k}`, "-of", "csv=p=0", p], { encoding: "utf8" }).trim(); const DUR = Number(probe(audio, "duration"));