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, ...@@ -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, 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. """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 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, ...@@ -212,11 +212,11 @@ def analyze_chops(y: np.ndarray, sr: int, *, top_n=12, grid=None,
return [] return []
cands: list[LoopCandidate] = [] cands: list[LoopCandidate] = []
for i in range(len(onsets) - 1): 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): if j >= len(onsets):
continue continue
s, e = float(onsets[i]), float(onsets[j]) 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 continue
s_smp, e_smp = int(s * sr), int(e * sr) s_smp, e_smp = int(s * sr), int(e * sr)
sl = y[s_smp:e_smp] sl = y[s_smp:e_smp]
......
...@@ -66,6 +66,19 @@ ...@@ -66,6 +66,19 @@
.loops-head{display:flex;align-items:center;gap:10px;margin-bottom:10px} .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); .loops-head .lbl{font-family:var(--mono);font-size:11px;color:var(--faint);
text-transform:uppercase;letter-spacing:.08em} 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; .take{background:var(--overlay);border:1px solid var(--hairline);border-radius:10px;
padding:12px 14px;margin-bottom:10px} padding:12px 14px;margin-bottom:10px}
.take-head{display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:12px; .take-head{display:flex;align-items:center;gap:10px;font-family:var(--mono);font-size:12px;
...@@ -211,39 +224,70 @@ async function findLoops(slug, btn){ ...@@ -211,39 +224,70 @@ async function findLoops(slug, btn){
renderTakes(slug, job.takes||[]); }); 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){ function renderTakes(slug, takes){
const list=document.querySelector("#lp-"+slug+" .take-list"); const list=document.querySelector("#lp-"+slug+" .take-list");
if(!list) return; if(!list) return;
if(!takes.length){ list.innerHTML=`<div class="empty" style="padding:18px">no clean loops found</div>`; return; } if(!takes.length){ list.innerHTML=`<div class="empty" style="padding:18px">no candidates found</div>`; return; }
list.innerHTML=""; const total=Math.max(...takes.map(t=>t.end_s))*1.03;
takes.forEach((t,ti)=>{ list._takes=takes; list._sel=0;
const div=document.createElement("div"); div.className="take"; // timeline overview: every candidate placed on the track, height/opacity by score
div.innerHTML=`<div class="take-head"> let segs="";
<span class="sc">${t.score.toFixed(3)}</span> takes.forEach((t,i)=>{
<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> const lead=t.stems.reduce((a,b)=>b.score>a.score?b:a, t.stems[0]);
${t.tempo_unstable?'<span class="warn">⚠ tempo</span>':''}</div> const col=ROLECOL[lead.name]||"#888";
<div class="stems"></div> const sc=Math.min(1,t.score);
<div class="take-forge"> segs+=`<button class="seg" data-i="${i}" aria-label="candidate ${i+1}"
<input class="kitname" value="${slug}" aria-label="kit name"> title="#${i+1} · ${t.score.toFixed(2)} · ${t.start_s.toFixed(1)}s · ${lead.name}"
<button class="forge">Forge ⚒</button><span class="done"></span></div>`; style="left:${t.start_s/total*100}%;width:${Math.max(0.6,(t.end_s-t.start_s)/total*100)}%;
const sbox=div.querySelector(".stems"); height:${Math.round(34+60*sc)}%;background:${col};opacity:${(0.4+0.55*sc).toFixed(2)}"></button>`;
t.stems.forEach(s=>{ });
const r=document.createElement("div"); r.className="tk-stem "+s.name; list.innerHTML=`<div class="timeline">${segs}</div>
r.innerHTML=`<input type="checkbox" checked aria-label="keep ${s.name}"> <div class="tl-axis"><span>0:00</span><span>${takes.length} candidates · click one</span><span>${fmtT(total)}</span></div>
<span class="tag">${s.name}</span> <div class="tk-nav"><button class="prev" aria-label="previous">‹ prev</button>
<canvas></canvas> <span class="ix"></span><button class="next" aria-label="next">next ›</button></div>
<button class="play" aria-label="audition ${s.name}">▶</button> <div class="detail"></div>`;
<span class="sc">${s.score.toFixed(2)}</span>`; list.querySelectorAll(".seg").forEach(b=>b.onclick=()=>selectTake(slug,+b.dataset.i));
const cb=r.querySelector("input"), play=r.querySelector(".play"), cv=r.querySelector("canvas"); list.querySelector(".prev").onclick=()=>selectTake(slug,(list._sel-1+takes.length)%takes.length);
cb.onchange=()=>r.classList.toggle("off",!cb.checked); list.querySelector(".next").onclick=()=>selectTake(slug,(list._sel+1)%takes.length);
play.onclick=()=>audition(slug, s.name, s.start_s, s.end_s, play); selectTake(slug,0);
decodeStem(slug, s.name).then(buf=>drawWave(cv, buf, s.start_s, s.end_s, ROLECOL[s.name])) }
.catch(()=>{});
sbox.appendChild(r); function selectTake(slug,i){
}); const list=document.querySelector("#lp-"+slug+" .take-list");
div.querySelector(".forge").onclick=(e)=>forge(slug, t, div, e.target); const takes=list._takes; if(!takes||!takes[i]) return;
list.appendChild(div); 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>
<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>
<div class="stems"></div>
<div class="take-forge">
<input class="kitname" value="${slug}" aria-label="kit name">
<button class="forge">Forge ⚒</button><span class="done"></span></div>`;
const sbox=div.querySelector(".stems");
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>
<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(()=>{});
sbox.appendChild(r);
}); });
div.querySelector(".forge").onclick=(e)=>forge(slug, t, div, e.target);
return div;
} }
async function decodeStem(slug, name){ 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