Commit 6f850128 by PLN (Algolia)

feat(foundry): auto-tune harness — search finder settings vs the grade rubric (#19)

PLN's closed feedback loop: generate candidate loops with a settings profile →
grade them on the full quality rubric → score the profile → search for the
settings that maximise quality. Auto-tune the composite weights instead of
hand-guessing them.

WHY: the finder/grader/correlator exist; the composite weights W were "provisional,
calibrate with #7". This builds the machine that calibrates them against measured
grade — and measures the lift honestly rather than asserting a tuning is better.

WHAT:
- engine/autotune.py — evaluate(stems, settings) runs the finder with a profile,
  grades every produced candidate on the FULL rubric (grade.py adds dc / level /
  bass-mono / bar-consistency — features the finder's own score does NOT use, so
  tuning pulls in signal beyond seam/zc), aggregates objective = mean_grade ×
  coverage. search() sweeps random|grid and always includes the current defaults
  (loops.W) as a baseline row so the report shows lift, not just a number.
- autotune.py — CLI (--stems glob --n --method --max-stems); PII-safe aggregated
  leaderboard (counts, never filenames).
- engine/loops.py — analyze_stem now accepts an optional `weights` override
  (defaults unchanged); the grid is preloaded once per stem (weight-independent)
  so the sweep is cheap — a scarcity-minded speedup.

VALIDATION:
- 4/4 mocked harness unit tests (aggregation, empty-set, ranked+baseline-included,
  weights threaded through to the finder).
- Real end-to-end run, 2 drums stems × 8 configs: baseline objective 0.690 → best
  0.763 (+10.5%); the defaults ranked LAST of 8 — the search found real lift.

NOT DONE ON PURPOSE: the finder defaults (loops.W) are UNCHANGED. The winning
profile (seam 0.4, zc 0.05, bars=(4,8)) is from a 2-stem sample AND seam/zc are in
both the finder score and the grade (a confound) — so it's suggestive, not a
mandate. Remaining for #19: recall-vs-provenance-GT as an anti-gaming 2nd
objective, per-stem-role tuning (vocals=chops want different weights than drums),
a full-corpus + demo-corpus campaign, an extract-once/score-many speedup, THEN
adopt a validated profile as the default.
parent 137fa8d6
#!/usr/bin/env python3
"""autotune — search loop-finder settings for max grade quality (#19).
python3 autotune.py --stems ~/Downloads/separated/htdemucs/*/drums.wav --n 24
python3 autotune.py --stems <dir> --method grid --max-stems 4 --role drums
Reports the current finder defaults vs the best-found profile (the quality lift)
and the top configs. Output is aggregated (counts, not filenames).
"""
import argparse
import glob
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from engine import autotune as AT # noqa: E402
def _collect(patterns, max_stems):
paths = []
for pat in patterns:
p = Path(pat)
if p.is_dir():
paths += [q for q in sorted(p.rglob("*.wav"))]
else:
paths += [Path(x) for x in sorted(glob.glob(pat))]
# de-dup, cap
seen, out = set(), []
for q in paths:
if q not in seen and q.is_file():
seen.add(q); out.append(q)
return out[:max_stems]
def _fmt(r):
s = r["settings"]
w = " ".join(f"{k[:4]}={s[k]}" for k in ("struct", "novel", "seam", "zc", "tempo_penalty") if k in s)
tag = " (baseline)" if r.get("is_baseline") else ""
return (f"obj {r['objective']:.3f} grade {r['mean_grade']:.3f} "
f"cov {r['coverage']:.2f} n={r['n_candidates']:3d} "
f"bars={s.get('bars')} top={s.get('top_n')} {w}{tag}")
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--stems", nargs="+", required=True, help="dirs / globs of stem .wav")
ap.add_argument("--method", choices=["random", "grid"], default="random")
ap.add_argument("--n", type=int, default=24, help="configs to try")
ap.add_argument("--max-stems", type=int, default=4, help="cap stems loaded (speed)")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--top", type=int, default=8, help="leaderboard rows to print")
a = ap.parse_args(argv)
paths = _collect(a.stems, a.max_stems)
if not paths:
raise SystemExit("no stem .wav found for the given --stems")
print(f"loading {len(paths)} stem(s)…", flush=True)
stems = []
for p in paths:
try:
stems.append(AT.load_stem(p))
except Exception as e: # noqa: BLE001
print(f" skip 1 stem ({type(e).__name__})")
if not stems:
raise SystemExit("no stems loaded")
print(f"searching {a.n} configs ({a.method}) over {len(stems)} stem(s)…\n", flush=True)
results = AT.search(stems, method=a.method, n=a.n, seed=a.seed)
base = next(r for r in results if r.get("is_baseline"))
best = results[0]
print("rank " + "-" * 70)
for i, r in enumerate(results[:a.top], 1):
print(f"{i:>2}. {_fmt(r)}")
lift = best["objective"] - base["objective"]
print("\n" + "=" * 76)
print(f"baseline objective {base['objective']:.3f} → best {best['objective']:.3f}"
f" (lift {lift:+.3f}, {100 * lift / max(base['objective'], 1e-6):+.1f}%)")
if best.get("is_baseline"):
print("→ defaults already win on this sample; widen the space or add stems.")
else:
print(f"→ winning profile: {best['settings']}")
if __name__ == "__main__":
main()
"""Auto-tune the loop finder's settings against the grade rubric (#19).
The closed feedback loop PLN asked for: generate candidate loops with a given
settings profile → grade them on the full quality rubric (grade.py: seam, zc, dc,
bar-consistency, level, bass-mono) → score the profile → search for the settings
that maximise quality. Auto-tune instead of hand-guessing the composite weights.
Objective (v1): the mean full-rubric grade of the finder's selected candidates.
The finder's OWN score uses struct/novel/seam/zc/tempo; the grade adds dc, level,
bass-mono and bar-consistency — features the finder doesn't see — so tuning the
weights to maximise grade pulls in signal beyond the finder's own metrics. (Caveat:
seam/zc appear in both, a partial confound; recall-vs-provenance-GT is the
anti-gaming complement — pluggable as a second objective later.)
Build-the-katana-first: validated on existing stems; runs over the demo corpus
once those are fetched+separated.
"""
import random
import statistics
from pathlib import Path
import numpy as np
from engine import grade as G
from engine import loops as L
# the knobs we sweep — composite weights (loops.W) + window shape
DEFAULT_SPACE = {
"struct": [0.2, 0.4, 0.6],
"novel": [0.1, 0.2, 0.3],
"seam": [0.2, 0.3, 0.4],
"zc": [0.05, 0.1, 0.2],
"tempo_penalty": [0.1, 0.2, 0.3],
"bars": [(2, 4), (4, 8), (2, 4, 8)],
"top_n": [6, 8],
}
_WEIGHT_KEYS = ("struct", "novel", "seam", "zc", "tempo_penalty")
def load_stem(path) -> tuple[np.ndarray, int, tuple]:
"""Preload (mono audio, sr, beat-grid) once — the grid is weight-independent,
so we pay PLP/onset detection a single time per stem, then sweep cheaply."""
import librosa
y, sr = librosa.load(str(path), sr=None, mono=True)
return y, sr, L.beat_grid(y, sr)
def evaluate(stems: list[tuple], settings: dict) -> dict:
"""Run the finder with `settings` over preloaded stems, grade every candidate,
return aggregate quality metrics. `stems` = list of (y, sr, grid)."""
weights = {k: settings[k] for k in _WEIGHT_KEYS if k in settings}
bars = tuple(settings.get("bars", (2, 4, 8)))
top_n = int(settings.get("top_n", 8))
grades, finder_scores, n_cand = [], [], 0
for y, sr, grid in stems:
cands = L.analyze_stem(y, sr, bars=bars, top_n=top_n, grid=grid, weights=weights)
n_cand += len(cands)
for c in cands:
sl = y[int(c.start_s * sr):int(c.end_s * sr)]
if sl.size < 8:
continue
grades.append(G.grade_array(sl, sr).grade)
finder_scores.append(c.score)
mean_g = float(statistics.fmean(grades)) if grades else 0.0
# coverage term: penalise profiles that surface almost nothing
coverage = min(1.0, n_cand / (len(stems) * 4 or 1))
return {
"objective": round(mean_g * (0.5 + 0.5 * coverage), 4),
"mean_grade": round(mean_g, 4),
"n_candidates": n_cand,
"n_graded": len(grades),
"coverage": round(coverage, 3),
}
def _sample(method: str, n: int, space: dict, rng: random.Random) -> list[dict]:
keys = list(space)
if method == "grid":
import itertools
combos = list(itertools.product(*(space[k] for k in keys)))
rng.shuffle(combos)
return [dict(zip(keys, c)) for c in combos[:n]]
return [{k: rng.choice(space[k]) for k in keys} for _ in range(n)]
def search(stems: list[tuple], *, method="random", n=20, space=None, seed=0) -> list[dict]:
"""Search the settings space; return configs ranked by objective (best first).
Always includes the current finder defaults (loops.W) as a baseline row."""
space = space or DEFAULT_SPACE
rng = random.Random(seed)
configs = _sample(method, n, space, rng)
baseline = {**L.W, "bars": (2, 4, 8), "top_n": 8}
results = []
for s in [baseline] + configs:
results.append({"settings": s, **evaluate(stems, s)})
results[0]["is_baseline"] = True
results.sort(key=lambda r: -r["objective"])
return results
...@@ -148,14 +148,18 @@ def _structural(ssm: np.ndarray, s: int, e: int) -> float: ...@@ -148,14 +148,18 @@ def _structural(ssm: np.ndarray, s: int, e: int) -> float:
def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8, def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
grid=None) -> list[LoopCandidate]: grid=None, weights=None) -> list[LoopCandidate]:
"""Rank loop candidates within one stem. """Rank loop candidates within one stem.
`grid` = a shared (peaks, times, bpm) beat grid (e.g. from the drums stem). Passing `grid` = a shared (peaks, times, bpm) beat grid (e.g. from the drums stem). Passing
it fixes the §7 finding that PLP on sparse-onset stems (vocals) yields a weak grid it fixes the §7 finding that PLP on sparse-onset stems (vocals) yields a weak grid
and poor windows: derive the grid once from a rhythmic source and reuse it here so and poor windows: derive the grid once from a rhythmic source and reuse it here so
every stem is cut on the same musical bar lines. None ⇒ compute per-stem (CLI case). every stem is cut on the same musical bar lines. None ⇒ compute per-stem (CLI case).
`weights` = optional override of the §3 composite weights W (struct/novel/seam/zc/
tempo_penalty); None ⇒ module defaults. The autotune harness sweeps these.
""" """
w = W if weights is None else {**W, **weights}
peaks, times, bpm = grid if grid is not None else beat_grid(y, sr) peaks, times, bpm = grid if grid is not None else beat_grid(y, sr)
nb = len(times) nb = len(times)
if nb < BEATS_PER_BAR * min(bars) + 1: if nb < BEATS_PER_BAR * min(bars) + 1:
...@@ -185,8 +189,8 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8, ...@@ -185,8 +189,8 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
bmean = float(np.mean(win_bpm)); bspread = float(np.std(win_bpm)) bmean = float(np.mean(win_bpm)); bspread = float(np.std(win_bpm))
unstable = bspread > 5.0 unstable = bspread > 5.0
tempo_pen = min(1.0, bspread / 20.0) tempo_pen = min(1.0, bspread / 20.0)
score = (W["struct"] * struct + W["novel"] * novel + score = (w["struct"] * struct + w["novel"] * novel +
W["seam"] * seam + W["zc"] * zc - W["tempo_penalty"] * tempo_pen) w["seam"] * seam + w["zc"] * zc - w["tempo_penalty"] * tempo_pen)
cands.append(LoopCandidate( cands.append(LoopCandidate(
start_s=round(times[i], 3), end_s=round(times[e], 3), bars=B, start_s=round(times[i], 3), end_s=round(times[e], 3), bars=B,
bpm=round(bmean, 1), tempo_unstable=unstable, bpm=round(bmean, 1), tempo_unstable=unstable,
......
"""Autotune harness mechanics (#19) — aggregation + ranking, DSP mocked out.
The real finder/grader are exercised elsewhere (test_loops/test_grade) and end-to-end
on stems; here we lock the harness logic deterministically and fast.
cd tools/foundry && python3 -m pytest tests/test_autotune.py -q
"""
import sys
import types
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine import autotune as AT # noqa: E402
def _cand(s, e, score):
return types.SimpleNamespace(start_s=s, end_s=e, score=score)
def _patch(monkeypatch, n_cands=2, grade=0.7):
monkeypatch.setattr(AT.L, "analyze_stem",
lambda y, sr, *, bars, top_n, grid, weights=None:
[_cand(i * 1.0, i * 1.0 + 1.0, 0.5) for i in range(n_cands)])
monkeypatch.setattr(AT.G, "grade_array",
lambda sl, sr, **k: types.SimpleNamespace(grade=grade))
def _stems(n=2, secs=4):
y = np.zeros(44100 * secs, dtype="float32")
return [(y, 44100, None) for _ in range(n)]
def test_evaluate_aggregates(monkeypatch):
_patch(monkeypatch, n_cands=2, grade=0.7)
m = AT.evaluate(_stems(2), {"struct": 0.4})
assert m["n_candidates"] == 4 # 2 stems × 2 candidates
assert m["n_graded"] == 4
assert m["mean_grade"] == 0.7
assert 0.0 <= m["objective"] <= 0.7 # objective = grade × coverage factor
def test_evaluate_empty_is_zero(monkeypatch):
_patch(monkeypatch, n_cands=0)
m = AT.evaluate(_stems(2), {})
assert m["n_candidates"] == 0 and m["objective"] == 0.0
def test_search_includes_baseline_and_is_ranked(monkeypatch):
_patch(monkeypatch, n_cands=3, grade=0.6)
res = AT.search(_stems(2), method="random", n=5, seed=0)
assert len(res) == 6 # baseline + 5
assert any(r.get("is_baseline") for r in res)
objs = [r["objective"] for r in res]
assert objs == sorted(objs, reverse=True) # best first
assert all("settings" in r and "mean_grade" in r for r in res)
def test_weights_threaded_through(monkeypatch):
# the weights subset must reach analyze_stem (else tuning is a no-op)
seen = {}
monkeypatch.setattr(AT.L, "analyze_stem",
lambda y, sr, *, bars, top_n, grid, weights=None:
(seen.update(weights or {}, bars=bars, top_n=top_n) or []))
monkeypatch.setattr(AT.G, "grade_array", lambda sl, sr, **k: types.SimpleNamespace(grade=0.0))
AT.evaluate(_stems(1), {"struct": 0.6, "seam": 0.25, "bars": (4, 8), "top_n": 6})
assert seen["struct"] == 0.6 and seen["seam"] == 0.25
assert seen["bars"] == (4, 8) and seen["top_n"] == 6
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment