From 5cb4ba0d0d2e1b0d088145d5cec642cc409930db Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 5 Sep 2026 17:18:33 -0400 Subject: [PATCH] =?UTF-8?q?imab=20hitbaker:=20the=20pitch=20gate=20?= =?UTF-8?q?=E2=80=94=20measured=20targets,=20iterate=20until=20hit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pitched word layer now works like notecheck: measure the source's sung midi (cached .f0.npz beside the stem, else pyin via bin/wordgate.py), snap intent to the nearest chord tone at that bar, render the exact fractional shift (absorbing sung drift), measure the RENDER, correct once if >30 cents off. Gate prints the tally; this bake: 31/31 on target, 0 unmeasurable (first run exposed a cache-key format mismatch that made 28 layers unverifiable - keys normalized). Boundaries law: whisper only, never guessed - takes lacking a words.json get whisper-words.mjs run automatically at bake time. Claude-Session: https://claude.ai/code/session_01LaRrW1RHanS8it7SzgrqeV --- pop/imab/bin/hitbaker.mjs | 67 ++++++++++++++++++++++++++++++++++++--- pop/imab/bin/wordgate.py | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 pop/imab/bin/wordgate.py diff --git a/pop/imab/bin/hitbaker.mjs b/pop/imab/bin/hitbaker.mjs index 131a403777..b811d3bd49 100644 --- a/pop/imab/bin/hitbaker.mjs +++ b/pop/imab/bin/hitbaker.mjs @@ -555,9 +555,19 @@ if (haveVox) { if (/^imab-set-(.+)-words\.json$/.test(f)) takeWords[/^imab-set-(.+)-words\.json$/.exec(f)[1]] = JSON.parse(readFileSync(`${OUT}/${f}`, "utf8")); + const ensureTakeWords = (id) => { // whisper, never guess + if (takeWords[id]) return true; + const wav = `${OUT}/imab-set-${id}.wav`; + if (!existsSync(wav)) return false; + const out = `${OUT}/imab-set-${id}-words.json`; + sh("node", [`${REPO}/pop/bin/whisper-words.mjs`, wav, "--out", out]); + if (!existsSync(out)) return false; + takeWords[id] = JSON.parse(readFileSync(out, "utf8")); + return true; + }; const sliceCache = {}; const wordWav = (fromMs, toMs, stretch, pitch, srcWav = leadF) => { - const key = `${fromMs}-${toMs}-${stretch}-${pitch}-${srcWav.replace(/[^a-z0-9]/gi, "").slice(-24)}`; + const key = `${fromMs}-${toMs}-${stretch}-${(+pitch).toFixed(2)}-${srcWav.replace(/[^a-z0-9]/gi, "").slice(-24)}`; if (sliceCache[key]) return sliceCache[key]; const t0 = Math.max(0, fromMs / 1000 - 0.04); const t1 = toMs / 1000 + 0.08; @@ -576,6 +586,32 @@ if (haveVox) { } return (sliceCache[key] = existsSync(cut) ? readF32(cut) : null); }; + // ── THE PITCH GATE: measured, never guessed. Every pitched layer + // measures its source (cached f0 beside the stem, else pyin), snaps + // the target to the nearest chord tone at that bar, renders the + // exact fractional shift, VERIFIES the render, and corrects once if + // the residual exceeds 30 cents ────────────────────────────────── + const WG = `${HERE}/wordgate.py`; + const CHORD_PCS = { C: [0, 4, 7], F: [5, 9, 0], G: [7, 11, 2], Am: [9, 0, 4] }; + const midiCache = {}; + const measure = (args) => { + const key = args.join("|"); + if (key in midiCache) return midiCache[key]; + const r = spawnSync(PY, [WG, ...args], { encoding: "utf8" }); + const v = parseFloat(r.stdout); + return (midiCache[key] = Number.isFinite(v) ? v : null); + }; + const snapToChord = (raw, ch) => { + const pcs = CHORD_PCS[ch] ?? CHORD_PCS.C; + let best = Math.round(raw), bd = 99; + for (let off = -3; off <= 3; off++) { + const cand = Math.round(raw) + off; + if (!pcs.includes(((cand % 12) + 12) % 12)) continue; + if (Math.abs(cand - raw) < bd) { bd = Math.abs(cand - raw); best = cand; } + } + return best; + }; + const gate = { ok: 0, corrected: 0, missed: 0 }; const score = JSON.parse(readFileSync(SCOREF, "utf8")); let placed = 0; for (const ev of score.events ?? []) { @@ -583,7 +619,7 @@ if (haveVox) { let fromMs, toMs, srcWav = leadF; if (ev.src.startsWith("take:")) { // take::[#n] const m = /^take:([^:]+):(.+)$/.exec(ev.src); - if (!m || !takeWords[m[1]]) continue; + if (!m || !ensureTakeWords(m[1])) continue; const [word, nth] = m[2].split("#"); const hits = takeWords[m[1]].filter( (x) => x.text.toLowerCase() === word.toLowerCase()); @@ -602,16 +638,39 @@ if (haveVox) { fromMs = w.fromMs; toMs = w.toMs; } else continue; const t = T(ev.bar) + ((ev.beat ?? 1) - 1) * BEAT; + const barChord = chord(Math.min(ev.bar, BARS - 1)); const layers = [[pitch, 1], ...(ev.harm ?? []) .filter((h) => h !== 0).map((h) => [pitch + h, 0.55])]; for (const [pp, hg] of layers) { - const y = wordWav(fromMs, toMs, stretch, pp, srcWav); + let y; + if (pp === 0) { + y = wordWav(fromMs, toMs, stretch, 0, srcWav); // as sung, untouched + } else { + const srcMidi = measure(["src", srcWav, String(fromMs), String(toMs)]); + if (srcMidi === null) { + y = wordWav(fromMs, toMs, stretch, pp, srcWav); // unpitched word — raw shift + gate.missed++; + } else { + const target = snapToChord(srcMidi + pp, barChord); + let shift = target - srcMidi; + y = wordWav(fromMs, toMs, stretch, Number(shift.toFixed(2)), srcWav); + const cut = `${WORK}/hb-w-${fromMs}-${toMs}-${stretch}-${(+shift.toFixed(2)).toFixed(2)}-${srcWav.replace(/[^a-z0-9]/gi, "").slice(-24)}.wav`; + const got = y ? measure(["wav", cut]) : null; + if (got !== null && Math.abs(got - target) > 0.3) { + shift += target - got; // one correction pass + y = wordWav(fromMs, toMs, stretch, Number(shift.toFixed(2)), srcWav) ?? y; + gate.corrected++; + } else if (got !== null) gate.ok++; + else gate.missed++; + } + } if (!y) continue; put(stretch >= 2.5 ? choirStem : voxStem, y, t, (ev.gain ?? 1) * hg); placed++; } } - console.log(`word score: ${placed} placements from vocal-score.json`); + console.log(`word score: ${placed} placements · pitch gate ` + + `${gate.ok} on target, ${gate.corrected} corrected, ${gate.missed} unmeasurable`); } // halo: the lead through the church send, wide and behind diff --git a/pop/imab/bin/wordgate.py b/pop/imab/bin/wordgate.py new file mode 100644 index 0000000000..27e7b3a6f8 --- /dev/null +++ b/pop/imab/bin/wordgate.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""wordgate.py — pitch measurement for the hitbaker word score. + + wordgate.py src median sung midi in the + window (cached .f0.npz + beside the stem when + present, else pyin) + wordgate.py wav pyin median midi of a + rendered slice + +Prints one float (fractional midi) or "nan". The hitbaker uses this to +hit chord-tone targets exactly and to verify every rendered layer — +measured, never guessed. +""" +import sys +from pathlib import Path + +import numpy as np + + +def pyin_midi(path, offset=0.0, duration=None): + import librosa + y, sr = librosa.load(path, sr=22050, mono=True, + offset=offset, duration=duration) + if len(y) < 512: + return float("nan") + f0, voiced, _ = librosa.pyin( + y, fmin=float(librosa.note_to_hz("C2")), + fmax=float(librosa.note_to_hz("C6")), sr=sr) + v = f0[voiced.astype(bool)] if voiced is not None else np.array([]) + if v.size < 5: + return float("nan") + return float(librosa.hz_to_midi(np.median(v))) + + +def main(): + mode = sys.argv[1] + if mode == "src": + stem, from_ms, to_ms = sys.argv[2], float(sys.argv[3]), float(sys.argv[4]) + npz = Path(stem + ".f0.npz") + if npz.exists(): + d = np.load(npz) + t, m = d["t"], d["m"] + win = m[(t >= from_ms / 1000) & (t < to_ms / 1000)] + win = win[~np.isnan(win)] + if win.size >= 3: + print(round(float(np.median(win)), 2)) + return + print(round(pyin_midi(stem, from_ms / 1000, (to_ms - from_ms) / 1000), 2)) + elif mode == "wav": + print(round(pyin_midi(sys.argv[2]), 2)) + + +if __name__ == "__main__": + main() -- 2.51.2