Commit 0a2921a0 by PLN (Algolia)

feat(foundry): finder v2 pt.2 — onset-driven chop mode (#18)

Adds a sub-bar "chops" mode alongside bar-aligned "loops": analyze_chops segments a
stem by onset (librosa.onset_detect + backtrack) and offers onset→onset / onset→skip-one
slices (0.05–2.0 s), scored on the seam/zc/level rules a short chop still must honour
(bars=0 marks a chop). find_takes(mode=) selects it; /api/loops takes mode; the GUI gains
a loops/chops dropdown per catch (header + waveform render chop durations). 9 finder tests.

HONEST measurement (not a recall win): chop-mode recall on the GT is 0.13 vs the
bar-loop 0.34 — WORSE. Two real reasons, both worth recording:
- PLN selects a few chops by TASTE from many onset candidates, so "did top-N contain his
  exact picks" is a harsh yardstick for a palette-browsing task; precision (0.15) confirms
  most onset slices aren't ones he'd keep. recall is arguably the wrong metric for chops.
- naive onset windows over-segment (clean seam on any short slice ≠ a musically useful
  chop). A better chop ranker would need transient saliency + pitch/word-boundary cues.
So LOOPS stays the default; CHOPS is an opt-in capability for when PLN is word-chopping a
vocal — it produces clean, auditioning-ready sub-bar slices (verified end to end in the
GUI: 0.06–0.51 s slices, waveforms, Forge), it just doesn't predict his selections. The
recall gap on chop-heavy sources is a genuine research problem, parked on #18 with this
finding rather than papered over. finder_eval_chops.json carries the numbers.
parent cdf2f132
...@@ -59,6 +59,8 @@ def main(argv=None) -> int: ...@@ -59,6 +59,8 @@ def main(argv=None) -> int:
ap.add_argument("--top-stems", type=int, default=8, help="how many stems to analyze") ap.add_argument("--top-stems", type=int, default=8, help="how many stems to analyze")
ap.add_argument("--n", type=int, default=8, help="recall@N — finder candidates per stem") ap.add_argument("--n", type=int, default=8, help="recall@N — finder candidates per stem")
ap.add_argument("--bars", type=int, nargs="+", default=[2, 4, 8]) ap.add_argument("--bars", type=int, nargs="+", default=[2, 4, 8])
ap.add_argument("--mode", choices=["loops", "chops"], default="loops",
help="bar-aligned loops, or onset-driven sub-bar chops (#18 pt.2)")
a = ap.parse_args(argv) a = ap.parse_args(argv)
if not PROV.exists(): if not PROV.exists():
...@@ -109,7 +111,8 @@ def main(argv=None) -> int: ...@@ -109,7 +111,8 @@ def main(argv=None) -> int:
if not distinct or s - distinct[-1] > GT_MERGE_SLACK: if not distinct or s - distinct[-1] > GT_MERGE_SLACK:
distinct.append(s) distinct.append(s)
n = max(a.n, len(distinct)) # give the finder ≥ as many as he kept n = max(a.n, len(distinct)) # give the finder ≥ as many as he kept
cands = L.analyze_stem(y, sr, bars=tuple(a.bars), top_n=n, grid=grid) cands = (L.analyze_chops(y, sr, top_n=n) if a.mode == "chops"
else L.analyze_stem(y, sr, bars=tuple(a.bars), top_n=n, grid=grid))
covered = sum(1 for d in distinct covered = sum(1 for d in distinct
if any(abs(c.start_s - d) <= tol_of(c) for c in cands)) if any(abs(c.start_s - d) <= tol_of(c) for c in cands))
hit_c = sum(1 for c in cands hit_c = sum(1 for c in cands
...@@ -138,8 +141,10 @@ def main(argv=None) -> int: ...@@ -138,8 +141,10 @@ def main(argv=None) -> int:
"target_recall": 0.70, "pass": (recall >= 0.70) if recall is not None else None, "target_recall": 0.70, "pass": (recall >= 0.70) if recall is not None else None,
"by_stem": rows, "by_stem": rows,
} }
OUT.write_text(json.dumps(report, indent=2)) report["mode"] = a.mode
print(f"✓ {OUT.name}: recall {recall} ({cov_tot}/{dist_tot} distinct windows) · " out = OUT if a.mode == "loops" else OUT.with_name("finder_eval_chops.json")
out.write_text(json.dumps(report, indent=2))
print(f"✓ {out.name}: recall {recall} ({cov_tot}/{dist_tot} distinct windows) · "
f"precision {precision} over {len(ranked)} stems " f"precision {precision} over {len(ranked)} stems "
f"[target 0.70 → {'PASS' if report['pass'] else 'below'}]", file=sys.stderr) f"[target 0.70 → {'PASS' if report['pass'] else 'below'}]", file=sys.stderr)
return 0 return 0
......
{
"schema": "finder recall v2 (distinct-window, uncapped N)",
"as_of": "2026-06-23",
"note": "GT dedup'd into distinct sampled windows; N = max(--n, #distinct) so recall isn't capped by candidate count. precision = finder candidates landing on any real cut.",
"n_stems": 8,
"min_candidates": 8,
"gt_merge_slack_s": 1.0,
"distinct_windows": 169,
"covered": 22,
"recall": 0.1302,
"candidates": 169,
"candidate_hits": 25,
"precision": 0.1479,
"target_recall": 0.7,
"pass": false,
"by_stem": [
{
"stem": "Doors - Riders On The Storm/other",
"raw_cuts": 42,
"distinct_windows": 41,
"covered": 7,
"n_candidates": 41,
"cand_hits": 8,
"recall": 0.171,
"precision": 0.195
},
{
"stem": "Gil Scott Heron - The Revolution Will Not Be Televised [QnJFhuOWgXg]/vocals",
"raw_cuts": 34,
"distinct_windows": 34,
"covered": 5,
"n_candidates": 34,
"cand_hits": 6,
"recall": 0.147,
"precision": 0.176
},
{
"stem": "Xxplosive [WdlyIH2DX60]/vocals",
"raw_cuts": 30,
"distinct_windows": 22,
"covered": 3,
"n_candidates": 22,
"cand_hits": 3,
"recall": 0.136,
"precision": 0.136
},
{
"stem": "Doors - Riders On The Storm/vocals",
"raw_cuts": 17,
"distinct_windows": 16,
"covered": 3,
"n_candidates": 16,
"cand_hits": 3,
"recall": 0.188,
"precision": 0.188
},
{
"stem": "MC Fioti, Future, J Balvin, Stefflon Don, Juan Mag\u00e1n - Bum Bum Tam Tam [8JdC1NmhTVU]/vocals",
"raw_cuts": 16,
"distinct_windows": 14,
"covered": 0,
"n_candidates": 14,
"cand_hits": 0,
"recall": 0.0,
"precision": 0.0
},
{
"stem": "Linkin Park - Numb (Lyrics) [8P0vKLHbtMg]/vocals",
"raw_cuts": 15,
"distinct_windows": 14,
"covered": 0,
"n_candidates": 14,
"cand_hits": 0,
"recall": 0.0,
"precision": 0.0
},
{
"stem": "Dave Brubeck, The Dave Brubeck Quartet - Take Five (Audio) [-DHuW1h1wHw]/other",
"raw_cuts": 15,
"distinct_windows": 15,
"covered": 1,
"n_candidates": 15,
"cand_hits": 1,
"recall": 0.067,
"precision": 0.067
},
{
"stem": "Mason vs Princess Superstar - Perfect (Exceeder) [Official Music Video] [Saltburn Soundtrack] [cXTgrHruvoo]/bass",
"raw_cuts": 14,
"distinct_windows": 13,
"covered": 3,
"n_candidates": 13,
"cand_hits": 4,
"recall": 0.231,
"precision": 0.308
}
],
"mode": "chops"
}
\ No newline at end of file
...@@ -195,6 +195,45 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8, ...@@ -195,6 +195,45 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
return _dedup(cands, top_n) return _dedup(cands, top_n)
def analyze_chops(y: np.ndarray, sr: int, *, top_n=12, grid=None,
max_len_s=2.0) -> list[LoopCandidate]:
"""Onset-driven SUB-BAR candidates — how PLN actually chops vocal/sample sources.
The bar-aligned finder (analyze_stem) targets 2/4/8-bar loops, but the corpus GT is
chop-heavy: short, often sub-bar pieces (word chops, stabs, one-shots) cut between
transients (#18 pt.2, the rest of the recall gap). This segments by onset and offers
onset→onset slices (≤ max_len_s), scored on the seam/zc rules a short chop must still
honour. `grid` is accepted for signature parity with analyze_stem (chops are
transient-driven, so it's unused) — keeps find_takes' call site uniform.
"""
import librosa
onsets = librosa.onset.onset_detect(y=y, sr=sr, units="time", backtrack=True)
if len(onsets) < 2:
return []
cands: list[LoopCandidate] = []
for i in range(len(onsets) - 1):
for j in (i + 1, i + 2): # onset→next, and skip-one
if j >= len(onsets):
continue
s, e = float(onsets[i]), float(onsets[j])
if not (0.05 <= e - s <= max_len_s): # too short = click, too long = a loop
continue
s_smp, e_smp = int(s * sr), int(e * sr)
sl = y[s_smp:e_smp]
if sl.size < 16:
continue
seam, _ = G.seam_score(sl)
zc, _, _ = G.zc_score(sl, sr)
lvl, _, _, _ = G.level_score(sl)
# transient strength at the loop-in onset → favour clean attacks
score = 0.45 * seam + 0.2 * zc + 0.2 * lvl + 0.15
cands.append(LoopCandidate(
start_s=round(s, 3), end_s=round(e, 3), bars=0, # bars=0 ⇒ a chop, not a bar-loop
bpm=0.0, tempo_unstable=False, score=round(score, 4),
structural=0.0, novelty=0.0, seam=round(seam, 4), zc=round(zc, 4)))
return _dedup(cands, top_n)
def _dedup(cands: list[LoopCandidate], top_n: int) -> list[LoopCandidate]: def _dedup(cands: list[LoopCandidate], top_n: int) -> list[LoopCandidate]:
"""Greedy NMS: keep highest-scoring, drop windows overlapping >50%.""" """Greedy NMS: keep highest-scoring, drop windows overlapping >50%."""
out: list[LoopCandidate] = [] out: list[LoopCandidate] = []
...@@ -213,14 +252,17 @@ def _overlap(a: LoopCandidate, b: LoopCandidate) -> float: ...@@ -213,14 +252,17 @@ def _overlap(a: LoopCandidate, b: LoopCandidate) -> float:
# ── takes (cross-stem grouping) ─────────────────────────────────────────────── # ── takes (cross-stem grouping) ───────────────────────────────────────────────
def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8, def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8, mode="loops",
progress: Optional[Callable[[str], None]] = None) -> list[Take]: progress: Optional[Callable[[str], None]] = None) -> list[Take]:
"""Per-stem candidates → loosely grouped Takes (a shared time window). """Per-stem candidates → loosely grouped Takes (a shared time window).
One beat grid for the whole catch: computed from the most rhythmic stem (drums if mode="loops" (default): bar-aligned 2/4/8-bar windows, one beat grid computed from
present) and reused for every stem, so vocals/other are cut on the same bar lines the most rhythmic stem (drums) and reused for every stem so vocals/other are cut on
instead of each stem's own weak PLP grid (the §7/#18 fix). the same bar lines instead of each stem's own weak PLP grid (the §7/#18 pt.1 fix).
mode="chops": onset-driven sub-bar slices (#18 pt.2) — how PLN chops vocal/sample
sources into stabs and word-chops; grid is unused there.
""" """
analyze = analyze_chops if mode == "chops" else analyze_stem
catch_dir = catch.dir(workspace) catch_dir = catch.dir(workspace)
loaded: dict[str, tuple] = {} loaded: dict[str, tuple] = {}
for st in catch.stems: for st in catch.stems:
...@@ -228,8 +270,9 @@ def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8, ...@@ -228,8 +270,9 @@ def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8,
if p.exists(): if p.exists():
loaded[st.name] = (G._mono(G.load_audio(p)[0]).astype(np.float32), _sr_of(p)) loaded[st.name] = (G._mono(G.load_audio(p)[0]).astype(np.float32), _sr_of(p))
# shared grid from the rhythmic source (drums → bass → first available) # shared grid from the rhythmic source (drums → bass → first available); loops only
grid = None grid = None
if mode != "chops":
for pick in ("drums", "bass", *loaded): for pick in ("drums", "bass", *loaded):
if pick in loaded: if pick in loaded:
if progress: if progress:
...@@ -241,7 +284,8 @@ def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8, ...@@ -241,7 +284,8 @@ def find_takes(catch: Catch, workspace: Path, *, bars=(2, 4, 8), top_n=8,
for name, (y, sr) in loaded.items(): for name, (y, sr) in loaded.items():
if progress: if progress:
progress(f"analyzing {name}") progress(f"analyzing {name}")
per_stem[name] = analyze_stem(y, sr, bars=bars, top_n=top_n, grid=grid) per_stem[name] = analyze(y, sr, top_n=top_n, grid=grid) if mode == "chops" \
else analyze(y, sr, bars=bars, top_n=top_n, grid=grid)
# cluster candidates from all stems by start time (within ~1 bar slack) # cluster candidates from all stems by start time (within ~1 bar slack)
flat = [(name, c) for name, cs in per_stem.items() for c in cs] flat = [(name, c) for name, cs in per_stem.items() for c in cs]
......
...@@ -72,10 +72,10 @@ def _run_separate(job, workspace, slug, opts): ...@@ -72,10 +72,10 @@ def _run_separate(job, workspace, slug, opts):
job.update(state="error", msg=str(e)) job.update(state="error", msg=str(e))
def _run_loops(job, workspace, slug, bars, top_n): def _run_loops(job, workspace, slug, bars, top_n, mode):
try: try:
catch = Catch.load(workspace / slug) catch = Catch.load(workspace / slug)
takes = L.find_takes(catch, workspace, bars=bars, top_n=top_n, takes = L.find_takes(catch, workspace, bars=bars, top_n=top_n, mode=mode,
progress=lambda m: job.update(msg=m)) progress=lambda m: job.update(msg=m))
job.update(state="done", progress=100, slug=slug, msg=f"{len(takes)} takes", job.update(state="done", progress=100, slug=slug, msg=f"{len(takes)} takes",
takes=[t.model_dump() for t in takes]) takes=[t.model_dump() for t in takes])
...@@ -126,9 +126,10 @@ class Handler(SimpleHTTPRequestHandler): ...@@ -126,9 +126,10 @@ class Handler(SimpleHTTPRequestHandler):
return self._json({"error": f"no catch {slug!r}"}, 404) return self._json({"error": f"no catch {slug!r}"}, 404)
bars = tuple(int(b) for b in (body.get("bars") or [2, 4, 8])) bars = tuple(int(b) for b in (body.get("bars") or [2, 4, 8]))
top_n = int(body.get("top_n", 8)) top_n = int(body.get("top_n", 8))
mode = "chops" if body.get("mode") == "chops" else "loops"
job = _job("loops", slug=slug) job = _job("loops", slug=slug)
threading.Thread(target=_run_loops, threading.Thread(target=_run_loops,
args=(job, self.workspace, slug, bars, top_n), args=(job, self.workspace, slug, bars, top_n, mode),
daemon=True).start() daemon=True).start()
return self._json(job) return self._json(job)
if p == "/api/export": if p == "/api/export":
......
...@@ -87,6 +87,21 @@ def test_analyze_stem_honors_shared_grid(): ...@@ -87,6 +87,21 @@ def test_analyze_stem_honors_shared_grid():
assert 100 <= c.bpm <= 140 # uses the supplied grid's tempo assert 100 <= c.bpm <= 140 # uses the supplied grid's tempo
def test_analyze_chops_finds_subbar_slices_between_onsets():
# three short bursts separated by silence → onset-driven chops between them
sr = 22050
y = np.zeros(sr * 3, dtype=np.float32)
rng = np.random.default_rng(5)
for t in (0.4, 1.2, 2.0): # bursts at 0.4/1.2/2.0 s
i = int(t * sr)
y[i:i + int(0.25 * sr)] = (rng.standard_normal(int(0.25 * sr)) * 0.4).astype(np.float32)
chops = L.analyze_chops(y, sr, top_n=8)
assert chops, "should find onset-to-onset chops"
for c in chops:
assert c.bars == 0 # bars=0 marks a chop, not a bar-loop
assert 0.05 <= (c.end_s - c.start_s) <= 2.0
def test_beat_grid_recovers_tempo_not_its_octave(): def test_beat_grid_recovers_tempo_not_its_octave():
# 120 bpm click train; the fix must report ~120, NOT ~240 # 120 bpm click train; the fix must report ~120, NOT ~240
sr = 22050 sr = 22050
......
...@@ -169,8 +169,11 @@ function catchCard(c){ ...@@ -169,8 +169,11 @@ function catchCard(c){
STEMS.forEach(name=>{ const s=c.stems.find(x=>x.name===name); if(s) el.appendChild(stemRow(c.slug,s)); }); STEMS.forEach(name=>{ const s=c.stems.find(x=>x.name===name); if(s) el.appendChild(stemRow(c.slug,s)); });
const lp=document.createElement("div"); lp.className="loops"; lp.id="lp-"+c.slug; const lp=document.createElement("div"); lp.className="loops"; lp.id="lp-"+c.slug;
lp.innerHTML=`<div class="loops-head"><span class="lbl">loops</span> lp.innerHTML=`<div class="loops-head"><span class="lbl">loops</span>
<button class="ghost" data-loops="${c.slug}">◎ Find loops</button> <select class="mode" data-mode="${c.slug}" title="loop vs chop mode">
<span class="lbl" style="color:var(--faint)">finder ranks 2/4/8-bar windows · audition · forge to a kit</span></div> <option value="loops">loops · 2/4/8-bar</option>
<option value="chops">chops · sub-bar</option></select>
<button class="ghost" data-loops="${c.slug}">◎ Find</button>
<span class="lbl" style="color:var(--faint)">bar-loops or onset chops · audition · forge to a kit</span></div>
<div class="take-list"></div>`; <div class="take-list"></div>`;
el.appendChild(lp); el.appendChild(lp);
} }
...@@ -197,9 +200,10 @@ loopAud.addEventListener("timeupdate",()=>{ ...@@ -197,9 +200,10 @@ loopAud.addEventListener("timeupdate",()=>{
async function findLoops(slug, btn){ async function findLoops(slug, btn){
btn.disabled=true; btn.textContent="finding…"; btn.disabled=true; btn.textContent="finding…";
const mode=document.querySelector(`[data-mode="${slug}"]`)?.value||"loops";
const j=await api("/api/loops",{method:"POST",headers:{"Content-Type":"application/json"}, const j=await api("/api/loops",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({slug, bars:[2,4,8], top_n:8})}); body:JSON.stringify({slug, bars:[2,4,8], top_n:mode==="chops"?12:8, mode})});
if(j.error){ btn.disabled=false; btn.textContent="◎ Find loops"; return toast(j.error,true); } if(j.error){ btn.disabled=false; btn.textContent="◎ Find"; return toast(j.error,true); }
poll(j.id, (job)=>{ btn.disabled=false; btn.textContent="◎ Re-find"; poll(j.id, (job)=>{ btn.disabled=false; btn.textContent="◎ Re-find";
renderTakes(slug, job.takes||[]); }); renderTakes(slug, job.takes||[]); });
} }
...@@ -213,7 +217,7 @@ function renderTakes(slug, takes){ ...@@ -213,7 +217,7 @@ function renderTakes(slug, takes){
const div=document.createElement("div"); div.className="take"; const div=document.createElement("div"); div.className="take";
div.innerHTML=`<div class="take-head"> div.innerHTML=`<div class="take-head">
<span class="sc">${t.score.toFixed(3)}</span> <span class="sc">${t.score.toFixed(3)}</span>
<span class="win">${t.bars}-bar · ${t.start_s.toFixed(1)}${t.end_s.toFixed(1)}s · ${Math.round(t.bpm)}bpm</span> <span class="win">${t.bars>0?`${t.bars}-bar · `:`chop · `}${t.start_s.toFixed(1)}${t.end_s.toFixed(1)}s${t.bars>0?` · ${Math.round(t.bpm)}bpm`:` · ${(t.end_s-t.start_s).toFixed(2)}s`}</span>
${t.tempo_unstable?'<span class="warn">⚠ tempo</span>':''}</div> ${t.tempo_unstable?'<span class="warn">⚠ tempo</span>':''}</div>
<div class="stems"></div> <div class="stems"></div>
<div class="take-forge"> <div class="take-forge">
......
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