Commit 5ac5cd0f by PLN (Algolia)

feat(foundry): timeline candidate navigation + usable chop floor (#18 follow-up)

Two PLN UX fixes from auditioning the loops panel:

1. Chops were way too short (0.06 s slices = clicks, not cuts). analyze_chops now
   floors at min_len_s=0.25 and extends to onset→skip-two for phrase-length options;
   vocal chops now land 0.29–1.07 s (verified) — actual word/phrase chops.

2. A 10+ vertical list was impractical to navigate. Replaced it with a TIMELINE
   overview (the research §6 design): every candidate is placed on the track by its
   start/length, coloured by lead stem, height + opacity by score — so you SEE where
   the candidates are at a glance and click one to focus. Below sits a single detail
   panel (stems, waveforms, audition, keep-toggles, Forge) for the selected candidate,
   plus prev/next stepping and a "candidate N / M" counter. Scannable map + one
   focused editor instead of a scroll-wall.

Verified in Chromium: 8 segments render, click-to-focus (candidate 1→4), prev/next,
no console errors; stem durations now display (the HTTP/1.1 + preload audio fix shows
2:43). Works for loops and chops modes (header reads "N-bar" or "chop · Ns").
parent 14e69fe5
......@@ -196,7 +196,7 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
def analyze_chops(y: np.ndarray, sr: int, *, top_n=12, grid=None,
max_len_s=2.0) -> list[LoopCandidate]:
min_len_s=0.25, 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
......@@ -212,11 +212,11 @@ def analyze_chops(y: np.ndarray, sr: int, *, top_n=12, grid=None,
return []
cands: list[LoopCandidate] = []
for i in range(len(onsets) - 1):
for j in (i + 1, i + 2): # onset→next, and skip-one
for j in (i + 1, i + 2, i + 3): # onset→next … skip-two (phrase-length)
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
if not (min_len_s <= e - s <= max_len_s): # below min = a click, above = a loop
continue
s_smp, e_smp = int(s * sr), int(e * sr)
sl = y[s_smp:e_smp]
......
......@@ -66,6 +66,19 @@
.loops-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.loops-head .lbl{font-family:var(--mono);font-size:11px;color:var(--faint);
text-transform:uppercase;letter-spacing:.08em}
/* candidate timeline overview — see all candidates positioned on the track, click to focus */
.timeline{position:relative;height:54px;background:#0000004d;border:1px solid var(--hairline);
border-radius:8px;overflow:hidden}
.timeline .seg{position:absolute;bottom:0;border:0;padding:0;cursor:pointer;border-radius:2px 2px 0 0;
min-width:3px;transition:filter .12s,outline .12s}
.timeline .seg:hover{filter:brightness(1.3);outline:1px solid var(--ink);z-index:2}
.timeline .seg.on{outline:2px solid #fff;z-index:3;filter:brightness(1.2)}
.tl-axis{display:flex;justify-content:space-between;font-family:var(--mono);font-size:10px;
color:var(--faint);margin:4px 2px 10px}
.tk-nav{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-family:var(--mono);
font-size:12px;color:var(--mute)}
.tk-nav button{padding:4px 11px;background:var(--raised);border:1px solid var(--hairline);color:var(--ink)}
.tk-nav .ix{color:var(--ink);min-width:120px}
.take{background:var(--overlay);border:1px solid var(--hairline);border-radius:10px;
padding:12px 14px;margin-bottom:10px}
.take-head{display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:12px;
......@@ -211,12 +224,46 @@ async function findLoops(slug, btn){
renderTakes(slug, job.takes||[]); });
}
function fmtT(s){const m=Math.floor(s/60);return m+":"+String(Math.round(s%60)).padStart(2,"0");}
function renderTakes(slug, takes){
const list=document.querySelector("#lp-"+slug+" .take-list");
if(!list) return;
if(!takes.length){ list.innerHTML=`<div class="empty" style="padding:18px">no clean loops found</div>`; return; }
list.innerHTML="";
takes.forEach((t,ti)=>{
if(!takes.length){ list.innerHTML=`<div class="empty" style="padding:18px">no candidates found</div>`; return; }
const total=Math.max(...takes.map(t=>t.end_s))*1.03;
list._takes=takes; list._sel=0;
// timeline overview: every candidate placed on the track, height/opacity by score
let segs="";
takes.forEach((t,i)=>{
const lead=t.stems.reduce((a,b)=>b.score>a.score?b:a, t.stems[0]);
const col=ROLECOL[lead.name]||"#888";
const sc=Math.min(1,t.score);
segs+=`<button class="seg" data-i="${i}" aria-label="candidate ${i+1}"
title="#${i+1} · ${t.score.toFixed(2)} · ${t.start_s.toFixed(1)}s · ${lead.name}"
style="left:${t.start_s/total*100}%;width:${Math.max(0.6,(t.end_s-t.start_s)/total*100)}%;
height:${Math.round(34+60*sc)}%;background:${col};opacity:${(0.4+0.55*sc).toFixed(2)}"></button>`;
});
list.innerHTML=`<div class="timeline">${segs}</div>
<div class="tl-axis"><span>0:00</span><span>${takes.length} candidates · click one</span><span>${fmtT(total)}</span></div>
<div class="tk-nav"><button class="prev" aria-label="previous">‹ prev</button>
<span class="ix"></span><button class="next" aria-label="next">next ›</button></div>
<div class="detail"></div>`;
list.querySelectorAll(".seg").forEach(b=>b.onclick=()=>selectTake(slug,+b.dataset.i));
list.querySelector(".prev").onclick=()=>selectTake(slug,(list._sel-1+takes.length)%takes.length);
list.querySelector(".next").onclick=()=>selectTake(slug,(list._sel+1)%takes.length);
selectTake(slug,0);
}
function selectTake(slug,i){
const list=document.querySelector("#lp-"+slug+" .take-list");
const takes=list._takes; if(!takes||!takes[i]) return;
list._sel=i;
list.querySelectorAll(".seg").forEach((b,k)=>b.classList.toggle("on",k===i));
list.querySelector(".ix").textContent=`candidate ${i+1} / ${takes.length}`;
const d=list.querySelector(".detail"); d.innerHTML=""; d.appendChild(takeDetail(slug,takes[i]));
}
function takeDetail(slug,t){
const div=document.createElement("div"); div.className="take";
div.innerHTML=`<div class="take-head">
<span class="sc">${t.score.toFixed(3)}</span>
......@@ -230,20 +277,17 @@ function renderTakes(slug, takes){
t.stems.forEach(s=>{
const r=document.createElement("div"); r.className="tk-stem "+s.name;
r.innerHTML=`<input type="checkbox" checked aria-label="keep ${s.name}">
<span class="tag">${s.name}</span>
<canvas></canvas>
<span class="tag">${s.name}</span><canvas></canvas>
<button class="play" aria-label="audition ${s.name}">▶</button>
<span class="sc">${s.score.toFixed(2)}</span>`;
const cb=r.querySelector("input"), play=r.querySelector(".play"), cv=r.querySelector("canvas");
cb.onchange=()=>r.classList.toggle("off",!cb.checked);
play.onclick=()=>audition(slug, s.name, s.start_s, s.end_s, play);
decodeStem(slug, s.name).then(buf=>drawWave(cv, buf, s.start_s, s.end_s, ROLECOL[s.name]))
.catch(()=>{});
decodeStem(slug, s.name).then(buf=>drawWave(cv, buf, s.start_s, s.end_s, ROLECOL[s.name])).catch(()=>{});
sbox.appendChild(r);
});
div.querySelector(".forge").onclick=(e)=>forge(slug, t, div, e.target);
list.appendChild(div);
});
return div;
}
async function decodeStem(slug, name){
......
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