#!/usr/bin/env python3
"""Render stemmap_take89.json → interactive stem-map validation instrument.

Time ↓, orbits d1..d12 →, brightness = orbit RMS activity. Audio player (mp3
proxy of the summed stems, stem-time aligned) with click-to-seek, a moving
playhead, and BOUNDARY-STEPPING (jump to each boundary, hear 5s before→after)
so boundaries are validated by EAR, not memory. Track names labelled per segment.
"""
import json
from pathlib import Path

HERE = Path(__file__).parent
data = json.loads((HERE / "stemmap_take89.json").read_text())

# Boundaries in PROXY/stem time. CALIBRATED via `locate`: known-good
# 09-PunkAChien split sits at proxy 2309.1s (= GT-master 2312 − ~3). The proxy
# IS ~master time, so stem ≈ GT_master − 3 (NOT +75 — earlier error). Concretely
# GT_master − 78 maps the old (wrongly +75'd) array onto true proxy time.
GT_MASTER = [362, 716, 1134, 1405, 1634, 1812.71, 2056.74, 2312, 2599.74,
             2925, 3171, 3429, 3742.65, 4176.14]
CAL = -3.0  # proxy = master + CAL (from locate: 2309.1 vs 2312)
GT_STEM = [round(g + CAL, 1) for g in GT_MASTER]
SETLIST = ["Quand on décolle", "Piment brésilien", "Take 5 Drops", "Super Sunny Side Up",
           "W.I.P. W.A.P.", "'Plosive", "Jeudi Drill", "Mafia Sans Serif", "PunkAChien",
           "You My Sunshine", "Desire", "L'or Bleu", "Premier Septembre", "Techno Orage",
           "La Révolution Sera Samplée"]

payload = {"orbits": data["orbits"], "bin_s": data["bin_s"], "times": data["times"],
           "rms_db": data["rms_db"], "gt": GT_STEM, "setlist": SETLIST}

TEMPLATE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Take89 Stem-Map — validate boundaries by ear</title>
<style>
 body{margin:0;background:#0c0d11;color:#e6e4de;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
 header{padding:12px 16px;border-bottom:1px solid #232733;position:sticky;top:0;background:#0c0d11f2;z-index:9;backdrop-filter:blur(4px);}
 h1{font-size:17px;margin:0 0 4px;} .dim{color:#8b8a85;font-size:12px;}
 b.cyan{color:#46c8f6;} b.red{color:#ff5a78;} b.amber{color:#ffd166;}
 .bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px;}
 button{background:#1b1e28;color:#e6e4de;border:1px solid #2c3140;border-radius:7px;padding:7px 11px;cursor:pointer;font-size:13px;}
 button:hover{background:#262b38;} button.go{background:#46c8b6;color:#06231f;border:0;font-weight:700;}
 #now{font:12px ui-monospace,monospace;color:#ffd166;min-width:150px;}
 .ctl{display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:8px;font-size:11px;color:#9fb;}
 .ctl input[type=range]{width:96px;vertical-align:middle;}
 label.loop{color:#ffd166;}
 .orbhdr{display:flex;margin-left:34px;font:11px ui-monospace,monospace;color:#9fb;margin-top:8px;}
 .orbhdr span{text-align:center;}
 .wrap{display:flex;align-items:flex-start;}
 #ruler{width:50px;position:relative;font:11px ui-monospace,monospace;color:#8b8a85;}
 #stage{position:relative;cursor:crosshair;}
 canvas{display:block;image-rendering:pixelated;}
 #overlay{position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;}
 .gtline{position:absolute;left:0;right:0;border-top:2px dashed #ff5a78;}
 .gtline span{position:absolute;right:2px;top:-15px;font:10px ui-monospace;color:#ff5a78;background:#0c0d11cc;padding:0 3px;}
 .detline{position:absolute;left:0;right:0;border-top:2px solid #46c8f6;opacity:.8;}
 #playhead{position:absolute;left:0;right:0;border-top:2px solid #ffd166;display:none;}
 .userline{position:absolute;left:0;right:0;border-top:2px solid #7CFC00;}
 .userline span{position:absolute;left:2px;top:-15px;font:10px ui-monospace;color:#7CFC00;background:#0c0d11cc;padding:0 3px;}
 #tracks{position:relative;width:230px;margin-left:8px;font-size:12px;}
 .trk{position:absolute;left:0;right:0;border-left:3px solid #46c8b6;padding:1px 0 1px 8px;}
 .trk b{color:#e6e4de;} .trk .t{color:#8b8a85;font:10px ui-monospace;}
 .trk.cur{background:#173a35;} .trk:hover{background:#1b1e28;cursor:pointer;}
</style></head><body>
<header>
 <h1>🛰️ Take89 stem-map — <span class="dim">validate every cut by ear</span></h1>
 <div class="dim">time ↓ · orbits d1–d12 → · brightness = activity.
   <b class="red">red dashed</b>=current boundaries (silence GT) · <b class="cyan">cyan</b>=auto-detected · <b class="amber">amber</b>=playhead.
   Click the map to seek. Step boundaries to hear 5s before→after each cut.</div>
 <div class="bar">
   <button class="go" id=play>▶ play</button>
   <button id=prev>◀ prev cut</button>
   <button id=next>next cut ▶</button>
   <span id=now>–:– / –:–</span>
   <label>zoom <input id=zoom type=range min=1 max=16 step=1 value=2></label>
   <span class=dim>(Space=play/pause · click map=seek)</span>
   <label class="loop"><input type=checkbox id=loopck checked> loop ±<input id=pad type=number value=5 min=2 max=15 style="width:38px;background:#1b1e28;color:#ffd166;border:1px solid #2c3140;border-radius:4px"> s around cut</label>
   <label>boundaries: <select id=bsrc><option value=gt>silence GT (validate these)</option><option value=det>auto-detected</option></select></label>
 </div>
 <div class="bar" id=valbar><span id=cutinfo class=dim>▸ step to a cut, hear it, then mark it</span>
   <button id=expb>⬇ export verdicts</button></div>
 <div class="ctl">auto-detect tuning:
  <label>win <input id=win type=range min=4 max=40 step=2 value=16><span id=winv>16</span></label>
  <label>present% <input id=pct type=range min=10 max=80 step=5 value=30><span id=pctv>30</span></label>
  <label>active dB <input id=adb type=range min=-60 max=-30 step=1 value=-45><span id=adbv>-45</span></label>
  <label>min trk s <input id=mt type=range min=20 max=180 step=5 value=70><span id=mtv>70</span></label>
  <label>Δorbits <input id=ck type=range min=1 max=6 step=1 value=3><span id=ckv>3</span></label>
  <span>detected: <b id=count>–</b> (set has 15 tracks)</span>
 </div>
 <div class="orbhdr" id=orbhdr></div>
</header>
<audio id=player src="proxy_take89.mp3" preload="auto"></audio>
<div class="wrap">
 <div id=ruler></div>
 <div id=stage><canvas id=cv></canvas><div id=overlay></div></div>
 <div id=tracks></div>
</div>
<script>
const D = DATA_PLACEHOLDER, $=id=>document.getElementById(id);
const O=D.orbits.length, B=D.times.length, bin=D.bin_s, mat=D.rms_db;
const CW=46; let ROWH=2, H=B*ROWH;
const cv=$('cv'), ctx=cv.getContext('2d'); cv.width=O*CW;
$('orbhdr').innerHTML=D.orbits.map(n=>`<span style="width:${CW}px">d${+n.split('-').pop()}</span>`).join('');
const fmt=t=>{t=Math.max(0,t||0);const m=Math.floor(t/60),s=Math.floor(t%60);return m+':'+String(s).padStart(2,'0');};
const Y=t=>t/bin*ROWH;   // time(s) → pixel y
// layout: (re)draw heatmap + ruler at current ROWH (zoom), then overlay
function layout(){
 H=B*ROWH; cv.height=H;
 $('overlay').style.height=H+'px'; $('ruler').style.height=H+'px'; $('tracks').style.height=H+'px';
 for(let b=0;b<B;b++)for(let o=0;o<O;o++){let v=mat[o][b],x=Math.max(0,Math.min(1,(v+55)/43));
  ctx.fillStyle=v<-100?'#070809':`rgb(${Math.round(x*60)},${Math.round(40+x*200)},${Math.round(30+x*90)})`;
  ctx.fillRect(o*CW,b*ROWH,CW-1,Math.max(1,ROWH));}
 const every=ROWH>=6?10:30; let rh='';
 for(let b=0;b<B;b++){const t=D.times[b];if(Math.round(t)%every===0){
  rh+=`<div style="position:absolute;top:${b*ROWH-6}px;right:5px">${fmt(t)}</div>`;}}
 $('ruler').innerHTML=rh;
 render();
}
// ---- boundary detection ----
function popcount(x){let c=0;while(x){c+=x&1;x>>=1;}return c;}
function detect(){const win=+$('win').value,pct=+$('pct').value/100,adb=+$('adb').value,mt=+$('mt').value,ck=+$('ck').value;
 const bw=Math.max(1,Math.round(win/bin)),blocks=[];
 for(let s=0;s<B;s+=bw){let m=0;for(let o=0;o<O;o++){let a=0,c=0;for(let i=s;i<Math.min(s+bw,B);i++){c++;if(mat[o][i]>adb)a++;}if(c&&a/c>=pct)m|=(1<<o);}blocks.push({t:D.times[s],m});}
 const bd=[];let seg=blocks[0].m,last=-1e9;
 for(let k=1;k<blocks.length-1;k++){if(popcount(seg^blocks[k].m)>=ck&&popcount(seg^blocks[k+1].m)>=ck&&blocks[k].t-last>=mt){bd.push(blocks[k].t);last=blocks[k].t;seg=blocks[k].m;}}
 return bd;}
// ---- render overlay + track labels ----
let BOUNDS=[], CURSEG=-1;
function boundsList(){return $('bsrc').value==='gt'?D.gt.slice():detect();}
function render(){
 ['win','pct','adb','mt','ck'].forEach(id=>$(id+'v').textContent=$(id).value);
 let html='';
 // GT red (always shown for reference)
 D.gt.forEach((t,i)=>{html+=`<div class=gtline style="top:${Y(t)}px"><span>GT ${i+2}</span></div>`;});
 // detected cyan (when selected)
 const det=detect();$('count').textContent=det.length;
 if($('bsrc').value==='det')det.forEach(t=>{html+=`<div class=detline style="top:${Y(t)}px"></div>`;});
 // user-validated green lines (verdict/nudge) — visible confirmation of your edits
 Object.keys(VERD).forEach(k=>{const v=VERD[k];const t=v.t!=null?v.t:v.gt;if(t==null)return;
  const tag=v.t!=null?`✎ ${fmt(t)}`:(v.verdict==='clean'?'✓':(v.verdict==='bleed'?'🚫':''));
  html+=`<div class=userline style="top:${Y(t)}px"><span>${tag}</span></div>`;});
 html+='<div id=playhead></div>';
 $('overlay').innerHTML=html;
 BOUNDS=boundsList();
 // track labels in GT segments (authoritative names)
 const segs=[0,...D.gt, D.times[B-1]];const tr=$('tracks');tr.innerHTML='';
 for(let i=0;i<D.setlist.length;i++){const a=segs[i],b=segs[i+1]||D.times[B-1];
  tr.innerHTML+=`<div class="trk" data-t="${a}" style="top:${Y(a)}px;height:${Y(b-a)}px">
   <b>${i+1}. ${D.setlist[i]}</b><div class="t">${fmt(a)}–${fmt(b)}</div></div>`;}
 [...document.querySelectorAll('.trk')].forEach(e=>e.onclick=()=>seek(+e.dataset.t+1));
}
['win','pct','adb','mt','ck'].forEach(id=>$(id).addEventListener('input',render));
$('bsrc').addEventListener('change',render);
$('zoom').addEventListener('input',()=>{const c=A.currentTime;ROWH=+$('zoom').value;layout();
 window.scrollTo({top:$('stage').offsetTop+Y(c)-window.innerHeight*0.45});});
// Space = play/pause (not page scroll)
document.addEventListener('keydown',e=>{if(e.code==='Space'&&e.target.tagName!=='INPUT'&&e.target.tagName!=='SELECT'&&e.target.tagName!=='TEXTAREA'){e.preventDefault();$('play').click();}});
// ---- audio + playhead ----
const A=$('player');let loopEnd=null;
function seek(t){A.currentTime=Math.max(0,t);}
$('stage').addEventListener('click',e=>{const y=e.offsetY;seek(y/ROWH*bin);if(A.paused)A.play();});
$('play').onclick=()=>{loopEnd=null;if(A.paused){A.play();$('play').textContent='⏸ pause';}else{A.pause();$('play').textContent='▶ play';}};
const VERD=JSON.parse(localStorage.getItem('take89_bounds')||'{}');
function saveV(){localStorage.setItem('take89_bounds',JSON.stringify(VERD));}
function gotoCut(dir){const list=BOUNDS;if(!list.length)return;
 const t=A.currentTime;let idx=list.findIndex(b=>b>t+0.3);
 if(dir<0){const before=list.map((b,i)=>b<t-0.3?i:-1).filter(i=>i>=0);idx=before.length?before.pop():0;}
 else if(idx<0)idx=list.length-1;
 const b=list[idx];const pad=+$('pad').value;
 CURSEG=idx; showCut(idx,b);
 window.scrollTo({top:$('stage').offsetTop + b/bin*ROWH - window.innerHeight*0.45, behavior:'smooth'});
 seek(b-pad); loopEnd=$('loopck').checked?b+pad:null; A.play();$('play').textContent='⏸ pause';}
$('next').onclick=()=>gotoCut(1); $('prev').onclick=()=>gotoCut(-1);
function showCut(idx,b){
 const key=String(idx), v=VERD[key]||{};
 const after=D.setlist[idx+1]||'?', before=D.setlist[idx]||'?';
 const nudged=v.t!=null?` → ${fmt(v.t)}`:'';
 $('cutinfo').innerHTML=`<b style="color:#46c8f6">cut ${idx+1}</b> @ ${fmt(b)}${nudged} `+
  `<span class=dim>(${before} ▸ ${after})</span> &nbsp; `+
  `<button onclick="mark('${key}','clean')">${v.verdict==='clean'?'✅':'✓'} clean</button>`+
  `<button onclick="mark('${key}','bleed')">${v.verdict==='bleed'?'🚫':'🚫'} bleed</button>`+
  `<button onclick="nudge('${key}',-2)">↤ −2s</button>`+
  `<button onclick="nudge('${key}',2)">+2s ↦</button>`+
  `<button onclick="setHere('${key}')">⌖ set = playhead</button>`;
 if(v.verdict)$('cutinfo').style.color=v.verdict==='clean'?'#46c8b6':'#ff5a78';else $('cutinfo').style.color='';
}
window.mark=(k,verd)=>{VERD[k]={...(VERD[k]||{}),verdict:verd,gt:BOUNDS[+k]};saveV();showCut(+k,BOUNDS[+k]);};
window.nudge=(k,d)=>{const cur=(VERD[k]&&VERD[k].t!=null)?VERD[k].t:BOUNDS[+k];VERD[k]={...(VERD[k]||{}),t:Math.round((cur+d)*10)/10,gt:BOUNDS[+k]};saveV();seek(VERD[k].t-(+$('pad').value));A.play();showCut(+k,BOUNDS[+k]);};
window.setHere=(k)=>{VERD[k]={...(VERD[k]||{}),t:Math.round(A.currentTime*10)/10,gt:BOUNDS[+k]};saveV();showCut(+k,BOUNDS[+k]);};
$('expb').onclick=()=>{const blob=new Blob([JSON.stringify({proxy_time:true,verdicts:VERD},null,2)],{type:'application/json'});
 const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='take89_boundary_verdicts.json';a.click();};
A.addEventListener('timeupdate',()=>{const ph=$('playhead');if(ph){ph.style.display='block';ph.style.top=(A.currentTime/bin*ROWH)+'px';}
 $('now').textContent=`${fmt(A.currentTime)} / ${fmt(A.duration||0)}`;
 if(loopEnd!=null&&A.currentTime>=loopEnd){A.pause();$('play').textContent='▶ play';loopEnd=null;}});
A.addEventListener('loadedmetadata',()=>{$('now').textContent=`0:00 / ${fmt(A.duration)}`;});
layout();
</script></body></html>"""

out = HERE / "stemmap.html"
out.write_text(TEMPLATE.replace("DATA_PLACEHOLDER", json.dumps(payload)))
print(f"Wrote {out} ({out.stat().st_size//1024} KB)")
