Commit 6351e0ae by PLN (Algolia)

feat(unwrapped): seeded vibe-search + find-similar + semantic vibe-map (#82,#87)

- build_unwrapped: 2D PCA of the CLAP embeds → per-sample 'vibe map' coords +
  16 seed-vibe chips (PLN's own words — the on-ramp, since users don't know what
  to type). vibe0/vibe1 join the axis picker as a semantic-space lens.
- unwrapped.html: VIBE SEARCH box (free text → /vibe) + clickable seed chips;
  results highlight on the map (dim misses, size hits by similarity, ring the top,
  faint-violet→magenta ramp) and auto-reveal the vibe map; result strip auditions.
  Shift-click any dot → /similar (nearest neighbours in embedding space). Null-safe
  plotting for vibe/raw axes. Graceful banner when the endpoint is absent.
parent ac4643d0
......@@ -34,9 +34,20 @@ import sample_meta as META
HERE = Path(__file__).resolve().parent
TOKENS = HERE.parent / "ui" / "src" / "tokens.json"
FAMILIES = HERE / "sample_families.json"
EMBEDS = HERE / "semantics_embeds.npz" # CLAP per-sample embeds (vibe map + search)
OUT = HERE / "unwrapped.json"
SAMPLES_LINK = HERE / "_samples" # relative symlink → Dirt-Samples (gitignored)
# Seed vibe-search chips — PLN's OWN words (users don't know what to type). Drawn from
# the sample_semantics ontology but phrased as natural queries. These are the on-ramp;
# the box takes any free text via the /vibe endpoint.
SEED_VIBES = [
"warm dusty rhodes", "lush rich pad", "shadowy ténébreux synth", "californian g-funk",
"nu jazz keys", "boom bap drum break", "acid bassline", "ethereal dreamy texture",
"punchy tight kick", "gritty distorted reese", "airy breathy vocal", "deep sub bass",
"glassy bell melody", "jungle amen break", "soulful brass stab", "hypnotic techno stab",
]
# Raw features worth exposing as direct axes (music-aficionado language, not just PCs).
RAW_AXES = [
("spectral_centroid", "Brightness", "spectral centroid (Hz) — dull → bright", "Hz"),
......@@ -110,6 +121,21 @@ def main():
fam_pal = {f["key"]: {"label": f["label"], "color": f["base"], "glyph": f["glyph"]}
for f in tok["sample"]}
# ── semantic vibe-map: 2D PCA of the CLAP embeddings (where SOUNDS cluster, not
# features). Joined by "folder/stem"; samples without an embed get null coords. ──
vibe_xy = {}
if EMBEDS.exists():
z = np.load(EMBEDS, allow_pickle=True)
Memb, enames = z["embeds"].astype(float), [str(x) for x in z["names"]]
vp = PCA(n_components=2).fit(Memb)
proj2 = vp.transform(Memb)
# orient deterministically: larger-variance spread on +x
for j in range(2):
if proj2[:, j].mean() < 0:
proj2[:, j] *= -1
vibe_xy = {enames[i]: [round(float(proj2[i, 0]), 3), round(float(proj2[i, 1]), 3)]
for i in range(len(enames))}
# ── per-folder kind / agreement + real .wav filenames ──────────────────────
famdoc = json.loads(FAMILIES.read_text())["families"]
folder_meta = {name: {"kind": v["kind"], "dominant": v["dominant"],
......@@ -136,6 +162,9 @@ def main():
"feat": {kk: round(float(X[i, fidx[kk]]), 4)
for kk in RAW_KEEP if kk in fidx},
}
vk = f"{r['folder']}/{r['file']}"
if vk in vibe_xy:
rec["vibe"] = vibe_xy[vk]
if wav:
rec["wav"] = f"_samples/{r['folder']}/{wav}"
samples.append(rec)
......@@ -199,6 +228,12 @@ def main():
"pc_axes": pc_axes,
"raw_axes": [{"key": kk, "label": lbl, "desc": desc, "unit": unit}
for kk, lbl, desc, unit in RAW_AXES if kk in fidx],
"vibe_axes": ([{"key": "vibe0", "label": "Vibe ① (semantic)",
"lo": "one timbral pole", "hi": "the other"},
{"key": "vibe1", "label": "Vibe ② (semantic)",
"lo": "one timbral pole", "hi": "the other"}] if vibe_xy else []),
"seed_vibes": SEED_VIBES,
"n_vibe": len(vibe_xy),
"rf_importance": rf,
"correlation": correlation,
"contingency": grid,
......
......@@ -85,6 +85,37 @@ input[type=search]{cursor:text;min-width:140px}
font-size:15px;transition:.15s ease}
.flip:hover{color:var(--ink);background:#ffffff12}
/* ── vibe search ──────────────────────────────────────────────────────────── */
.vibe{background:var(--raised);border:1px solid var(--hairline);border-radius:12px;padding:14px 16px}
.vibe-in{display:flex;align-items:center;gap:11px}
.vibe-in .vlabel{font-size:10.5px;color:var(--magenta);letter-spacing:.08em;flex:none}
.vibe-in input{flex:1;min-width:0;background:var(--overlay);color:var(--ink);
border:1px solid var(--hairline);border-radius:8px;padding:9px 13px;
font:400 14px/1 Geist,sans-serif}
.vibe-in input:focus{outline:2px solid #d900ff55;outline-offset:1px}
.vibe-in .vgo{flex:none;appearance:none;border:0;border-radius:8px;cursor:pointer;
background:var(--magenta);color:#0a0a0a;font:600 13px/1 Geist,sans-serif;padding:10px 15px}
.vibe-in .vgo:hover{filter:brightness(1.12)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:11px}
.chip{appearance:none;border:1px solid var(--hairline);background:var(--overlay);
color:var(--mute);cursor:pointer;border-radius:999px;padding:5px 11px;
font:500 12px/1 Geist,sans-serif;transition:.14s ease}
.chip:hover{color:var(--ink);border-color:#d900ff66;background:#d900ff14}
.vibebanner{display:none;align-items:center;gap:12px;margin-top:12px}
.vibebanner.on{display:flex}
.vibebanner .q{font:500 13px/1 Geist,sans-serif;color:var(--ink)}
.vibebanner .n{font-size:11.5px;color:var(--magenta)}
.vibebanner .err{font:500 12.5px/1 Geist,sans-serif;color:#ff8c8c}
.vibebanner .x{appearance:none;border:1px solid var(--hairline);background:transparent;
color:var(--faint);cursor:pointer;border-radius:6px;padding:4px 9px;font:500 11px/1 Geist;margin-left:auto}
.vibebanner .x:hover{color:var(--ink)}
.viberes{display:flex;flex-wrap:wrap;gap:5px 7px;margin-top:11px;max-height:128px;overflow-y:auto}
.viberes:empty{margin:0}
.vres{display:inline-flex;align-items:center;gap:7px;background:var(--overlay);
border:1px solid var(--hairline);border-radius:7px;padding:4px 9px;font-size:11.5px}
.vres:hover{border-color:#d900ff55}
.vres .g{font-size:.95em}.vres .rn{color:var(--mute)}.vres .rs{color:var(--magenta);font-size:.92em}
/* ── the map: canvas + overlay ────────────────────────────────────────────── */
.plot{position:relative;background:var(--raised);border:1px solid var(--hairline);
border-radius:12px;overflow:hidden}
......@@ -194,7 +225,10 @@ const el = (t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const fmt=(v)=>Math.abs(v)>=100?v.toFixed(0):Math.abs(v)>=10?v.toFixed(1):v.toFixed(2);
let D=null, AX=[], state={x:"pc0", y:"pc3", lens:"family", solo:new Set(), q:"", unl:false};
let D=null, AX=[], state={x:"pc0", y:"pc3", lens:"family", solo:new Set(), q:"", unl:false,
vibe:null /* {label, hits:Map(idx→sim), top:idx} */};
const keyOf=s=>`${s.folder}/${s.name}`;
let KEYIDX={}; // "folder/stem" → sample index, for vibe-hit joins
async function load(){
const inl=$("#data");
......@@ -211,6 +245,8 @@ function buildAxes(){
D.raw_axes.forEach(a=>AX.push({key:a.key,label:a.label,group:"feature",
lo:a.desc.split("—")[1]?.trim()?.split("→")[0]?.trim()||"low",
hi:a.desc.split("→")[1]?.trim()||"high",sub:a.unit||a.desc,get:s=>s.feat[a.key]}));
(D.vibe_axes||[]).forEach((a,i)=>AX.push({key:a.key,label:a.label,group:"vibe",
lo:a.lo,hi:a.hi,sub:"CLAP embedding",get:s=>s.vibe?s.vibe[i]:null}));
}
const axis=k=>AX.find(a=>a.key===k);
......@@ -244,13 +280,15 @@ function visible(s){
/* ── scatter (canvas) ────────────────────────────────────────────────────────*/
let cv,ctx,DPR=1,plotW=0,plotH=0,pts=[],sx=null,sy=null,hover=-1,playing=-1;
const PAD={l:46,r:14,t:18,b:44};
function makeScale(vals){let mn=Math.min(...vals),mx=Math.max(...vals);
function gv(ax,s){const v=ax.get(s);return (v==null||isNaN(v))?null:v;}
function makeScale(vals){vals=vals.filter(v=>v!=null);if(!vals.length)return[-1,1];
let mn=Math.min(...vals),mx=Math.max(...vals);
if(mn===mx){mn-=1;mx+=1;}const pad=(mx-mn)*0.04;return [mn-pad,mx+pad];}
function rescale(){
const ax=axis(state.x),ay=axis(state.y);
const vs=D.samples.filter(visible);
const base=vs.length?vs:D.samples;
sx=makeScale(base.map(ax.get)); sy=makeScale(base.map(ay.get));
sx=makeScale(base.map(s=>gv(ax,s))); sy=makeScale(base.map(s=>gv(ay,s)));
}
function px(v){return PAD.l+(v-sx[0])/(sx[1]-sx[0])*(plotW-PAD.l-PAD.r);}
function py(v){return plotH-PAD.b-(v-sy[0])/(sy[1]-sy[0])*(plotH-PAD.t-PAD.b);}
......@@ -274,22 +312,34 @@ function drawScatter(){
const samples=D.samples;
for(let i=0;i<samples.length;i++){
const s=samples[i]; if(!visible(s))continue;
const x=px(ax.get(s)),y=py(ay.get(s));
pts.push([x,y,i]);
const vx=gv(ax,s),vy=gv(ay,s); if(vx==null||vy==null)continue;
pts.push([px(vx),py(vy),i]);
}
/* faint pass first (dim) then bright — depth illusion; hovered/playing on top */
ctx.globalAlpha=0.82;
const vibe=state.vibe;
/* in vibe mode draw misses faintly first, hits on top sized by similarity */
for(const[x,y,i]of pts){
if(i===hover||i===playing)continue;
ctx.fillStyle=dotColor(samples[i]);
if(vibe){const sim=vibe.hits.get(i);
if(sim==null){ctx.globalAlpha=0.14;ctx.fillStyle="#5a5a62";
ctx.beginPath();ctx.arc(x,y,2,0,7);ctx.fill();continue;}
ctx.globalAlpha=0.92;ctx.fillStyle=vibeRamp(sim);
ctx.beginPath();ctx.arc(x,y,3+vibe.norm(sim)*5.5,0,7);ctx.fill();continue;}
ctx.globalAlpha=0.82;ctx.fillStyle=dotColor(samples[i]);
ctx.beginPath();ctx.arc(x,y,3.1,0,7);ctx.fill();
}
ctx.globalAlpha=1;
if(hover>=0){const s=samples[hover];const x=px(ax.get(s)),y=py(ay.get(s));
ctx.fillStyle=dotColor(s);ctx.beginPath();ctx.arc(x,y,5.4,0,7);ctx.fill();
ctx.strokeStyle="#fff";ctx.lineWidth=1.4;ctx.stroke();}
if(playing>=0){const s=samples[playing];const x=px(ax.get(s)),y=py(ay.get(s));
ctx.strokeStyle="#d900ff";ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,8.5,0,7);ctx.stroke();}
if(state.vibe&&state.vibe.top!=null){const s=samples[state.vibe.top];
const vx=gv(ax,s),vy=gv(ay,s);
if(vx!=null&&vy!=null){ctx.strokeStyle="#d900ff";ctx.lineWidth=2;
ctx.beginPath();ctx.arc(px(vx),py(vy),9,0,7);ctx.stroke();}}
if(hover>=0){const s=samples[hover];const vx=gv(ax,s),vy=gv(ay,s);
if(vx!=null&&vy!=null){const x=px(vx),y=py(vy);
ctx.fillStyle=state.vibe?vibeRamp(state.vibe.hits.get(hover)??0.3):dotColor(s);
ctx.beginPath();ctx.arc(x,y,5.4,0,7);ctx.fill();
ctx.strokeStyle="#fff";ctx.lineWidth=1.4;ctx.stroke();}}
if(playing>=0){const s=samples[playing];const vx=gv(ax,s),vy=gv(ay,s);
if(vx!=null&&vy!=null){ctx.strokeStyle="#d900ff";ctx.lineWidth=2;
ctx.beginPath();ctx.arc(px(vx),py(vy),8.5,0,7);ctx.stroke();}}
$("#count").textContent=`${pts.length} / ${samples.length} samples`;
/* axis labels */
$("#axx .nm").textContent=ax.label; $("#axx .po").textContent=`${ax.lo} ←→ ${ax.hi}`;
......@@ -311,7 +361,8 @@ function onMove(e){
+`<span style="color:var(--faint);font-weight:500;font-size:11px"> · ${s.kind||"?"}</span></div>`
+`<div class="nm">${s.folder} / ${s.name}</div>`
+`<div class="vals">${ax.label}: <b>${fmt(ax.get(s))}</b><br>${ay.label}: <b>${fmt(ay.get(s))}</b></div>`
+(s.wav?`<div class="hear">▶ click to hear</div>`:``);
+(state.vibe&&state.vibe.hits.get(i)!=null?`<div class="vals">vibe match: <b>${state.vibe.hits.get(i).toFixed(2)}</b></div>`:``)
+(s.wav?`<div class="hear">▶ click to hear · ⇧+click: find similar</div>`:``);
tip.classList.add("on");
const tw=tip.offsetWidth,th=tip.offsetHeight;
tip.style.left=clamp(e.clientX+14,4,innerWidth-tw-4)+"px";
......@@ -319,11 +370,62 @@ function onMove(e){
cv.style.cursor=s.wav?"pointer":"default";
}else{tip.classList.remove("on");cv.style.cursor="crosshair";}
}
function onClick(){
if(hover<0)return;const s=D.samples[hover];if(!s.wav)return;
function playIdx(i){const s=D.samples[i];if(!s||!s.wav)return;
const a=$("#aud");a.src=s.wav;a.currentTime=0;a.play().catch(()=>{});
a.onended=()=>{playing=-1;drawScatter();};
playing=hover;drawScatter();
a.onended=()=>{playing=-1;drawScatter();};playing=i;drawScatter();}
function onClick(e){
if(hover<0)return;
if(e&&e.shiftKey){findSimilar(hover);return;} // shift-click → find similar
playIdx(hover);
}
/* ── vibe search (CLAP /vibe + /similar endpoints) ───────────────────────────*/
function vibeRamp(sim){const t=state.vibe?state.vibe.norm(sim):0.5;
// faint violet → brand magenta as similarity rises
const r=Math.round(90+t*(217-90)),g=Math.round(58+t*(0-58)),b=Math.round(106+t*(255-106));
return `rgb(${r},${g},${b})`;}
async function runVibe(url,label){
const banner=$("#vibebanner");banner.innerHTML=`<span class="mono">searching…</span>`;
banner.classList.add("on");
let data;
try{ data=await fetch(url).then(r=>r.json()); }
catch(_){ data={error:"no /vibe endpoint — start serve.py (python3 ../serve.py --dir .)"}; }
if(data.error){banner.innerHTML=`<span class="err">⚠ ${data.error}</span>`
+`<button class="x" id="vclr">clear</button>`;$("#vclr").onclick=clearVibe;
state.vibe=null;return;}
const res=data.results||[];
const sims=res.map(r=>r.sim),mn=Math.min(...sims),mx=Math.max(...sims);
const norm=s=>mx>mn?clamp((s-mn)/(mx-mn),0,1):0.5;
const hits=new Map();let top=null;
for(const r of res){const i=KEYIDX[r.name];if(i!=null){hits.set(i,r.sim);if(top==null)top=i;}}
state.vibe={label,hits,top,norm};
// reveal the semantic map so the matches cluster visibly
if(D.vibe_axes?.length&&axis(state.x).group!=="vibe"){
state.x="vibe0";state.y="vibe1";$("#selx").value="vibe0";$("#sely").value="vibe1";}
rescale();drawScatter();
banner.innerHTML=`<span class="q">${label}</span><span class="n mono">${hits.size} matches</span>`
+`<button class="x" id="vclr">clear</button>`;
$("#vclr").onclick=clearVibe;
renderVibeResults(res);
}
function vibeSearch(q){if(!q.trim())return;
runVibe(`/vibe?q=${encodeURIComponent(q)}&n=40`,`“${q}”`);}
function findSimilar(i){const s=D.samples[i];
runVibe(`/similar?name=${encodeURIComponent(keyOf(s))}&n=40`,`like ${s.folder}/${s.name}`);
playIdx(i);}
function clearVibe(){state.vibe=null;$("#vibebanner").classList.remove("on");
$("#vibebanner").innerHTML="";$("#viberes").innerHTML="";rescale();drawScatter();}
function renderVibeResults(res){
const box=$("#viberes");box.innerHTML="";
res.slice(0,18).forEach(r=>{const i=KEYIDX[r.name];const s=i!=null?D.samples[i]:null;
const fam=s&&s.family?D.families[s.family]:null;
const row=el("div","vres");
row.innerHTML=`<span class="g" style="color:${fam?fam.color:'#888'}">${fam?fam.glyph:'○'}</span>`
+`<span class="rn mono">${r.name}</span><span class="rs mono">${r.sim.toFixed(2)}</span>`;
if(s&&s.wav){row.style.cursor="pointer";row.onclick=()=>playIdx(i);
row.onmouseenter=()=>{hover=i;drawScatter();};}
box.appendChild(row);
});
}
/* ── legend ──────────────────────────────────────────────────────────────────*/
......@@ -477,6 +579,16 @@ function render(){
composed PCA directions; the <b>raw features</b> are single measured quantities. Hover to
identify, click to audition.</p>
<div class="map-shell">
<div class="vibe">
<div class="vibe-in">
<span class="vlabel mono">VIBE SEARCH</span>
<input type="search" id="vq" placeholder="describe a sound — “warm dusty rhodes”, “shadowy ténébreux pad”…">
<button class="vgo" id="vgo">search </button>
</div>
<div class="chips" id="chips"></div>
<div class="vibebanner" id="vibebanner"></div>
<div class="viberes" id="viberes"></div>
</div>
<div class="controls">
<div class="ctl"><label>X axis</label><select id="selx"></select></div>
<button class="flip" id="flip" title="swap axes"></button>
......@@ -545,6 +657,7 @@ function render(){
// populate axis selects (grouped)
const opts=g=>AX.filter(a=>a.group===g).map(a=>`<option value="${a.key}">${a.label}</option>`).join("");
const grouped=`<optgroup label="Superfeatures">${opts("superfeature")}</optgroup>`
+(D.vibe_axes?.length?`<optgroup label="Vibe map (semantic)">${opts("vibe")}</optgroup>`:``)
+`<optgroup label="Raw features">${opts("feature")}</optgroup>`;
$("#selx").innerHTML=grouped;$("#sely").innerHTML=grouped;
$("#selx").value=state.x;$("#sely").value=state.y;
......@@ -567,6 +680,13 @@ function render(){
// search
$("#q").oninput=e=>{state.q=e.target.value;rescale();drawScatter();};
// vibe search: key index + seed chips + input
D.samples.forEach((s,i)=>KEYIDX[keyOf(s)]=i);
$("#chips").innerHTML=(D.seed_vibes||[]).map(v=>`<button class="chip">${v}</button>`).join("");
$("#chips").querySelectorAll(".chip").forEach(b=>b.onclick=()=>{$("#vq").value=b.textContent;vibeSearch(b.textContent);});
$("#vgo").onclick=()=>vibeSearch($("#vq").value);
$("#vq").addEventListener("keydown",e=>{if(e.key==="Enter")vibeSearch($("#vq").value);});
// canvas
cv=$("#scatter");ctx=cv.getContext("2d");
cv.addEventListener("mousemove",onMove);
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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