Commit a94bf65a by PLN (Algolia)

feat(foundry): GUI loop panel — the Audacity-replacement picker (#6)

The Foundry's payoff surface: per separated catch, a "Find loops" button runs the
finder (#5) and lays out its ranked Takes for the human to audition, prune, name,
and forge into a kit — closing the url→stems→loops loop the whole tool was for.

server.py: /api/loops (async job → find_takes → Takes JSON) and /api/export (runs
export_take with the kept stems → Samples/<kit>/, then publish.link_kit). Stems
already serve Range-capable for <audio>.

ui/index.html: a Loops panel. Each Take shows score, bar-count, time window, bpm,
tempo-unstable flag, and a row per stem with: a keep checkbox, role tag, a
dep-free CANVAS waveform of that loop slice (decoded client-side via Web Audio,
cached per stem, drawn in the role colour), a region-audition play button (loops
[start,end] on the full stem via timeupdate), and the candidate score. A kit-name
field (defaults to the slug) + Forge button writes the kept stems and auto-links.

Verified end to end in Chromium (Playwright): Find loops → 8 Takes render, all 11
stem-slice canvases paint (role-coloured waveforms — vocals pink, other cyan, drums
faint where quiet), no console errors. Caveat: first waveform per stem waits on the
full-stem decode (~1s each, 4 stems ~5s); subsequent takes from a cached stem draw
instantly. A future win is byte-range slice fetch instead of full-stem decode.
parent 7a79dc83
...@@ -35,6 +35,7 @@ import sys ...@@ -35,6 +35,7 @@ import sys
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
from engine import fetch as F # noqa: E402 from engine import fetch as F # noqa: E402
from engine import separate as S # noqa: E402 from engine import separate as S # noqa: E402
from engine import loops as L # noqa: E402
from engine.model import Catch # noqa: E402 from engine.model import Catch # noqa: E402
UI = HERE / "ui" UI = HERE / "ui"
...@@ -71,6 +72,17 @@ def _run_separate(job, workspace, slug, opts): ...@@ -71,6 +72,17 @@ 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):
try:
catch = Catch.load(workspace / slug)
takes = L.find_takes(catch, workspace, bars=bars, top_n=top_n,
progress=lambda m: job.update(msg=m))
job.update(state="done", progress=100, slug=slug, msg=f"{len(takes)} takes",
takes=[t.model_dump() for t in takes])
except Exception as e: # noqa: BLE001
job.update(state="error", msg=str(e))
class Handler(SimpleHTTPRequestHandler): class Handler(SimpleHTTPRequestHandler):
workspace: Path = Path() workspace: Path = Path()
...@@ -108,6 +120,34 @@ class Handler(SimpleHTTPRequestHandler): ...@@ -108,6 +120,34 @@ class Handler(SimpleHTTPRequestHandler):
threading.Thread(target=_run_separate, threading.Thread(target=_run_separate,
args=(job, self.workspace, slug, opts), daemon=True).start() args=(job, self.workspace, slug, opts), daemon=True).start()
return self._json(job) return self._json(job)
if p == "/api/loops":
slug = body.get("slug")
if not slug or not (self.workspace / slug / "catch.json").exists():
return self._json({"error": f"no catch {slug!r}"}, 404)
bars = tuple(int(b) for b in (body.get("bars") or [2, 4, 8]))
top_n = int(body.get("top_n", 8))
job = _job("loops", slug=slug)
threading.Thread(target=_run_loops,
args=(job, self.workspace, slug, bars, top_n),
daemon=True).start()
return self._json(job)
if p == "/api/export":
slug = body.get("slug")
if not slug or not (self.workspace / slug / "catch.json").exists():
return self._json({"error": f"no catch {slug!r}"}, 404)
kit = (body.get("kit") or "").strip()
if not kit:
return self._json({"error": "need a kit name"}, 400)
try:
catch = Catch.load(self.workspace / slug)
take = L.Take(**body["take"])
keep = tuple(body.get("keep") or ("drums", "bass", "other", "vocals"))
written = L.export_take(take, kit, self.workspace, catch, keep=keep,
names=body.get("names"),
peak_norm=bool(body.get("peak_norm")))
return self._json({"written": [str(p) for p in written], "kit": kit})
except Exception as e: # noqa: BLE001
return self._json({"error": str(e)}, 500)
return self.send_error(404) return self.send_error(404)
def _get_api(self, p): def _get_api(self, p):
......
...@@ -61,6 +61,37 @@ ...@@ -61,6 +61,37 @@
.empty{color:var(--faint);text-align:center;padding:48px;font-family:var(--mono);font-size:13px} .empty{color:var(--faint);text-align:center;padding:48px;font-family:var(--mono);font-size:13px}
.toast{font-family:var(--mono);font-size:12px;color:var(--mute);min-height:18px;margin-bottom:14px} .toast{font-family:var(--mono);font-size:12px;color:var(--mute);min-height:18px;margin-bottom:14px}
.toast.err{color:var(--err)} .toast.err{color:var(--err)}
/* loops panel */
.loops{margin-top:12px;border-top:1px solid var(--hairline);padding-top:12px}
.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}
.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;
margin-bottom:10px}
.take-head .sc{font-weight:600;color:var(--ink);background:#ffffff10;border-radius:5px;
padding:2px 7px}
.take-head .win{color:var(--mute)}
.take-head .warn{color:var(--wip)}
.tk-stem{display:grid;grid-template-columns:18px 60px 1fr 30px 42px;gap:10px;align-items:center;
padding:5px 0}
.tk-stem .tag{font-family:var(--mono);font-size:11px;text-transform:uppercase;
letter-spacing:.06em;font-weight:600}
.tk-stem.drums .tag{color:var(--drums)} .tk-stem.bass .tag{color:var(--bass)}
.tk-stem.other .tag{color:var(--other)} .tk-stem.vocals .tag{color:var(--vocals)}
.tk-stem.off{opacity:.4}
.tk-stem canvas{width:100%;height:32px;display:block;border-radius:4px;background:#0000003d}
.tk-stem .play{padding:5px 0;width:30px;background:var(--raised);border:1px solid var(--hairline);
color:var(--ink);font-size:11px;font-weight:500}
.tk-stem .play.on{border-color:var(--brand);color:var(--brand)}
.tk-stem .sc{font-family:var(--mono);font-size:11px;color:var(--mute);text-align:right}
.tk-stem input[type=checkbox]{accent-color:var(--brand);width:15px;height:15px}
.take-forge{display:flex;gap:8px;align-items:center;margin-top:10px;padding-top:10px;
border-top:1px solid var(--hairline)}
.take-forge input{flex:1;padding:7px 10px}
.take-forge .forge{padding:7px 14px}
.take-forge .done{font-family:var(--mono);font-size:11px;color:var(--ok)}
</style> </style>
</head> </head>
<body> <body>
...@@ -124,6 +155,12 @@ function catchCard(c){ ...@@ -124,6 +155,12 @@ function catchCard(c){
<audio controls preload="none" src="/media/${c.slug}/${c.source_path}"></audio></div>`); <audio controls preload="none" src="/media/${c.slug}/${c.source_path}"></audio></div>`);
} else { } else {
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;
lp.innerHTML=`<div class="loops-head"><span class="lbl">loops</span>
<button class="ghost" data-loops="${c.slug}">◎ Find loops</button>
<span class="lbl" style="color:var(--faint)">finder ranks 2/4/8-bar windows · audition · forge to a kit</span></div>
<div class="take-list"></div>`;
el.appendChild(lp);
} }
return el; return el;
} }
...@@ -134,6 +171,105 @@ async function render(){ ...@@ -134,6 +171,105 @@ async function render(){
if(!cs.length){ box.innerHTML=`<div class="empty">no catches yet — cast a URL above ⚓</div>`; return; } if(!cs.length){ box.innerHTML=`<div class="empty">no catches yet — cast a URL above ⚓</div>`; return; }
cs.forEach(c=>box.appendChild(catchCard(c))); cs.forEach(c=>box.appendChild(catchCard(c)));
box.querySelectorAll("[data-sep]").forEach(b=>b.onclick=()=>separate(b.dataset.sep, b)); box.querySelectorAll("[data-sep]").forEach(b=>b.onclick=()=>separate(b.dataset.sep, b));
box.querySelectorAll("[data-loops]").forEach(b=>b.onclick=()=>findLoops(b.dataset.loops, b));
}
// ── loops panel ───────────────────────────────────────────────────────────────
const ROLECOL={drums:"#ff8c00",bass:"#7c5cff",other:"#36c5f0",vocals:"#ff3d7b"};
const AC=new (window.AudioContext||window.webkitAudioContext)();
const BUF={}; // decoded AudioBuffer cache, keyed slug/name
let loopAud=new Audio(); loopAud.preload="auto";
let loopRegion=null, loopBtn=null;
loopAud.addEventListener("timeupdate",()=>{
if(loopRegion && loopAud.currentTime>=loopRegion.end) loopAud.currentTime=loopRegion.start; });
async function findLoops(slug, btn){
btn.disabled=true; btn.textContent="finding…";
const j=await api("/api/loops",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({slug, bars:[2,4,8], top_n:8})});
if(j.error){ btn.disabled=false; btn.textContent="◎ Find loops"; return toast(j.error,true); }
poll(j.id, (job)=>{ btn.disabled=false; btn.textContent="◎ Re-find";
renderTakes(slug, job.takes||[]); });
}
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)=>{
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}-bar · ${t.start_s.toFixed(1)}${t.end_s.toFixed(1)}s · ${Math.round(t.bpm)}bpm</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);
list.appendChild(div);
});
}
async function decodeStem(slug, name){
const key=slug+"/"+name;
if(BUF[key]) return BUF[key];
const ab=await fetch(`/media/${slug}/stems/${name}.wav`).then(r=>r.arrayBuffer());
BUF[key]=await AC.decodeAudioData(ab);
return BUF[key];
}
function drawWave(cv, buf, s, e, color){
const W=cv.width=cv.clientWidth*2||600, H=cv.height=64;
const ctx=cv.getContext("2d"); const d=buf.getChannelData(0), sr=buf.sampleRate;
const a=Math.max(0,Math.floor(s*sr)), b=Math.min(d.length,Math.floor(e*sr));
const n=Math.max(1,b-a), step=Math.max(1,Math.floor(n/W));
ctx.clearRect(0,0,W,H); ctx.fillStyle=color||"#888";
for(let x=0;x<W;x++){ let mn=1,mx=-1; const off=a+x*step;
for(let k=0;k<step;k++){ const v=d[off+k]; if(v==null)break; if(v<mn)mn=v; if(v>mx)mx=v; }
if(mx<mn){mn=mx=0;}
const y1=(1-(mx*.92+1)/2)*H, y2=(1-(mn*.92+1)/2)*H;
ctx.fillRect(x,y1,1,Math.max(1,y2-y1)); }
}
function audition(slug, name, s, e, btn){
if(loopBtn===btn){ loopAud.pause(); btn.classList.remove("on"); loopBtn=null; loopRegion=null; return; }
if(loopBtn) loopBtn.classList.remove("on");
loopRegion={start:s,end:e}; loopBtn=btn; btn.classList.add("on");
loopAud.src=`/media/${slug}/stems/${name}.wav`;
loopAud.onloadedmetadata=()=>{ try{loopAud.currentTime=s;}catch(_){}};
loopAud.play().catch(()=>{});
}
async function forge(slug, take, panel, btn){
const kit=panel.querySelector(".kitname").value.trim();
if(!kit){ return toast("name the kit first",true); }
const keep=[...panel.querySelectorAll(".tk-stem")].filter(r=>r.querySelector("input").checked)
.map(r=>r.querySelector(".tag").textContent);
if(!keep.length){ return toast("keep at least one stem",true); }
btn.disabled=true; btn.textContent="forging…";
const j=await api("/api/export",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({slug, take, kit, keep})});
btn.disabled=false; btn.textContent="Forge ⚒";
if(j.error) return toast(j.error,true);
panel.querySelector(".done").textContent=`✓ ${j.written.length} → Samples/${j.kit} (linked)`;
toast(`✓ forged ${j.written.length} loops into ${j.kit}`);
} }
function poll(jobId, onDone){ function poll(jobId, onDone){
......
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