PMI_Calculator_GPU / transcription-editor.html
luna0805's picture
Update transcription-editor.html
d209a0d verified
Raw
History Blame Contribute Delete
141 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PMI Calculator</title>
<style>
:root{
--bg:#FFFFFF; --ink:#1C1C1C; --muted:#6B6B6B; --faint:#9A9A9A;
--line:#E3E3E1; --grid:#EFEFEE; --panel:#FFFFFF; --hover:#F4F4F2;
--note:#9DAAA4; --edit:#2E4F57; --warn:#C2685F; --sel:#3B6FB5; --play:#2B2B2B;
--infer:#8A63A8; /* notes the user completed, vs the model output */
--key-black:#3A3A3A;
--sans:-apple-system,BlinkMacSystemFont,"Helvetica Neue","Segoe UI",Arial,sans-serif;
}
*{box-sizing:border-box; margin:0; padding:0}
[hidden]{display:none !important}
html,body{min-height:100%}
body{background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.55;
padding:clamp(16px,3vw,40px); display:flex; flex-direction:column; gap:16px}
header{padding-bottom:6px}
header h1{font-weight:700; font-size:clamp(22px,3.4vw,32px); letter-spacing:-.01em}
header p{color:var(--muted); font-size:13px; margin-top:4px}
header .disclaimer{color:var(--faint); font-size:11.5px; margin-top:8px}
/* ---- two excerpts side by side ---- */
.excerpts{display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; align-items:start}
.pair-bar{display:flex; align-items:center; gap:12px; margin:14px 0; flex-wrap:wrap}
#dualSlot{margin-bottom:14px}
.restore-bar{display:flex; align-items:center; gap:10px; flex-wrap:wrap;
background:#fff8e1; border:1px solid #e8d9a0; border-radius:8px;
padding:9px 12px; margin-bottom:14px; font-size:13px}
@media (max-width:1080px){ .excerpts{grid-template-columns:minmax(0,1fr)} }
.excerpt{min-width:0; border:1px solid var(--line); border-radius:10px; padding:14px;
display:flex; flex-direction:column; gap:12px; background:var(--panel)}
.excerpt.active{border-color:#BFBFBD; box-shadow:0 0 0 2px rgba(46,79,87,.07)}
.stage{display:flex; flex-direction:column; gap:12px}
.ex-head{display:flex; align-items:baseline; gap:9px; flex-wrap:wrap}
.ex-badge{font-size:11px; font-weight:700; letter-spacing:.06em; color:#fff; background:var(--edit);
border-radius:4px; padding:2px 8px; line-height:1.5}
.ex-head b{font-size:14px}
/* deliberately quiet: a shortcut for repeat comparisons, not the main path */
.reuse{margin-left:auto; display:flex; align-items:center; gap:6px; flex-wrap:wrap}
.reuse-lbl{font-size:10.5px; color:var(--faint)}
.mid-in em{font-style:normal; color:var(--faint); font-size:10px}
.mid-in{font-size:11px; color:var(--faint); cursor:pointer;
border:1px solid var(--line); border-radius:5px; padding:2px 7px; white-space:nowrap}
.mid-in:hover{color:var(--edit); border-color:var(--edit)}
.ex-sub{color:var(--faint); font-size:11.5px; margin-left:auto; text-align:right;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:55%}
.slot{background:var(--panel); border:1px solid var(--line); border-radius:6px;
padding:12px 15px; cursor:pointer; transition:border-color .15s; display:flex; flex-direction:column; gap:2px}
.slot:hover,.slot.drag{border-color:#BFBFBD}
.slot .tag{font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--edit); font-weight:600}
.slot .fname{color:var(--ink); font-size:13px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.slot .hint{color:var(--faint); font-size:11px}
input[type=file]{display:none}
.transport,.edittools{display:flex; gap:8px; flex-wrap:wrap; align-items:center;
background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:9px 12px}
.play{font-family:inherit; font-size:13px; font-weight:600; color:#fff; background:var(--edit);
border:none; border-radius:5px; padding:8px 16px; cursor:pointer; min-width:86px}
.play:hover{background:#264249} .play:focus-visible{outline:2px solid var(--ink); outline-offset:2px}
.btn{font-family:inherit; font-size:12px; background:var(--panel); border:1px solid var(--line);
color:var(--ink); border-radius:5px; padding:7px 11px; cursor:pointer; transition:.15s}
.btn:hover:not(:disabled){background:var(--hover); border-color:#BFBFBD}
.btn:disabled{opacity:.38; cursor:default}
.btn:focus-visible,.play:focus-visible{outline:2px solid var(--sel); outline-offset:2px}
.btn.dl{border-color:var(--edit); color:var(--edit)}
.btn.dl:hover:not(:disabled){background:var(--edit); color:#fff}
.btn.sm{padding:4px 9px; font-size:12px}
.btn.lg{padding:10px 20px; font-size:15px}
/* Primary = the action the page exists for. Solid fill, so it reads as the
obvious next step rather than competing with the outlined buttons. */
.btn.primary{background:var(--edit); border-color:var(--edit); color:#fff; font-weight:600}
.btn.primary:hover:not(:disabled){filter:brightness(1.12)}
/* Destructive actions are deliberately the SMALLEST thing in their row: size
was previously signalling the opposite of intent, with "start over" the
biggest control on screen and the PMI calculation the smallest. */
.btn.danger{border-color:var(--warn); color:var(--warn); background:transparent;
font-size:12px; padding:5px 10px; font-weight:400}
.btn.danger:hover:not(:disabled){background:var(--warn); color:#fff}
.btn.on{background:rgba(20,140,150,0.16); border-color:rgba(20,140,150,0.5); color:#0d6b73}
.sep{width:1px; align-self:stretch; background:var(--line); margin:0 2px}
.vol,.thr{display:flex; align-items:center; gap:7px; font-size:12px; color:var(--muted)}
.vol input[type=range],.thr input[type=range]{-webkit-appearance:none; appearance:none; height:3px;
border-radius:999px; background:var(--line); cursor:pointer}
.vol input[type=range]{width:64px} .thr input[type=range]{width:74px}
.thr b{color:var(--warn); font-weight:600; font-variant-numeric:tabular-nums}
.vol input[type=range]::-webkit-slider-thumb{-webkit-appearance:none; appearance:none; width:13px; height:13px; border-radius:50%; background:var(--edit); cursor:pointer}
.vol input[type=range]::-moz-range-thumb{width:13px; height:13px; border:none; border-radius:50%; background:var(--edit); cursor:pointer}
.thr input[type=range]::-webkit-slider-thumb{-webkit-appearance:none; appearance:none; width:13px; height:13px; border-radius:50%; background:var(--warn); cursor:pointer}
.thr input[type=range]::-moz-range-thumb{width:13px; height:13px; border:none; border-radius:50%; background:var(--warn); cursor:pointer}
.hint2{font-size:11px; color:var(--faint); flex:1 1 100%; line-height:1.7}
.hint2 b{color:var(--muted); font-weight:600}
.note-txt{font-size:11px; color:var(--faint)}
/* note-completion hint popup */
.cand-pop{position:absolute; z-index:60; background:#fff; border:1px solid var(--line);
border-radius:9px; padding:5px; box-shadow:0 6px 22px rgba(0,0,0,.16); font-size:12px}
.cand-pop .hd{font-size:10.5px; color:var(--faint); padding:3px 7px 5px; letter-spacing:.02em}
.cand-pop .cand{display:flex; align-items:center; gap:8px; padding:5px 7px; cursor:pointer;
border-radius:6px; white-space:nowrap}
.cand-pop .cand:hover{background:#eef1f6}
.cand-pop .cand b{min-width:26px; font-size:12.5px}
.cand-pop .bar{height:6px; background:#5a78c8; border-radius:3px; min-width:2px}
.cand-pop .why{color:var(--muted); font-size:10.5px}
.cand-pop .rule{border-top:1px solid var(--line); margin:4px 2px}
.cand-pop .v-ok{color:#3f7d4e}
.cand-pop .v-warn{color:#9a6a1f}
.cand-pop .v-bad{color:var(--warn)}
.roll-wrap{position:relative; height:290px; background:var(--panel); border:1px solid var(--line);
border-radius:6px; overflow:hidden}
.lyric-tip{position:absolute; z-index:30; display:none; max-width:240px; font-family:var(--sans);
font-size:11px; line-height:1.5; color:#4a2f68; background:#fff; border:1px solid var(--sel);
border-radius:6px; padding:5px 8px; box-shadow:0 4px 14px rgba(0,0,0,.16); pointer-events:none}
.lyric-tip.on{display:block}
.lyric-box{position:absolute; z-index:20; display:none; align-items:center; gap:3px; padding:3px;
background:#fff; border:1px solid var(--sel); border-radius:6px; box-shadow:0 3px 12px rgba(0,0,0,.14)}
.lyric-box.on{display:flex}
.lyric-box input{font-family:var(--sans); font-size:11px; padding:2px 5px; border:1px solid var(--line);
border-radius:4px; background:#fff; color:#5a3f78; width:96px; outline:none}
.lyric-box input:focus{border-color:var(--sel)}
.lyric-box .lyric-ok{font-family:inherit; font-size:12px; line-height:1; color:#fff; background:var(--edit);
border:none; border-radius:4px; padding:4px 7px; cursor:pointer}
.lyric-box .lyric-ok:hover{background:#264249}
.lyric-box .lyric-x{font-family:inherit; font-size:13px; line-height:1; color:var(--muted); background:none;
border:none; padding:2px 4px; cursor:pointer}
.lyric-box .lyric-x:hover{color:var(--warn)}
canvas.roll{display:block; width:100%; height:100%; cursor:crosshair; touch-action:none}
.readout{display:flex; gap:7px; flex-wrap:wrap; font-size:12px; color:var(--muted); align-items:center}
.chip{display:inline-flex; align-items:center; gap:6px; background:#F7F7F5; border:1px solid var(--line);
border-radius:999px; padding:4px 10px; font-size:11.5px}
.dot{width:9px; height:9px; border-radius:2px; flex:0 0 auto}
.chip b{color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums}
.roll-legend{font-size:11px; color:var(--faint)}
.roll-legend .sw{display:inline-block; width:10px; height:10px; border-radius:2px;
vertical-align:middle; margin:0 4px 0 10px}
.roll-legend .sw:first-child{margin-left:0}
/* per-excerpt key row */
.keyrow{display:flex; gap:9px; align-items:center; flex-wrap:wrap;
border-top:1px solid var(--line); padding-top:11px}
.keyrow label{font-size:12px; color:var(--muted); display:inline-flex; gap:6px; align-items:center}
.keysel{font:inherit; font-size:12px; padding:3px 6px; border:1px solid var(--line); border-radius:6px; background:#fff}
.keycand{font-size:11px; color:var(--faint); flex:1 1 100%}
.keycand b{color:var(--muted); font-weight:600}
/* segment selector */
.seg-wrap{border:1px solid var(--line); border-radius:10px; padding:12px 14px; background:#fff}
.seg-head{display:flex; flex-direction:column; gap:3px; margin-bottom:10px}
.seg-head b{font-size:13px}
.seg-canvas-wrap{position:relative; height:92px; background:#f7f7f5; border-radius:8px; overflow:hidden;
user-select:none; cursor:crosshair}
canvas.segWave{position:absolute; inset:0; width:100%; height:100%; display:block}
.seg-sel{position:absolute; top:0; bottom:0; background:rgba(90,120,200,.18);
border-left:2px solid #5a78c8; border-right:2px solid #5a78c8; pointer-events:none}
.seg-handle{position:absolute; top:0; bottom:0; width:12px; margin-left:-6px; cursor:ew-resize; z-index:3}
.seg-handle::after{content:""; position:absolute; left:4px; top:0; bottom:0; width:4px; background:#5a78c8; border-radius:2px}
.seg-playhead{position:absolute; top:0; bottom:0; width:1px; background:#e2483d; pointer-events:none; z-index:4}
.seg-row{display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:10px}
.inst-pick{font-size:12px; color:var(--muted); display:flex; align-items:center; gap:5px}
.inst-pick select{font:inherit; color:var(--ink); padding:3px 5px; border:1px solid var(--line);
border-radius:5px; background:#fff}
.seg-time{font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums; margin-right:auto}
.seg-status{font-size:12px; color:var(--faint); margin-top:8px; min-height:1em}
.hscroll{overflow-x:auto; overflow-y:hidden; height:13px; margin-top:3px}
.hscroll>div{height:1px}
.hscroll::-webkit-scrollbar{height:9px}
.hscroll::-webkit-scrollbar-track{background:#F2F2EF; border-radius:5px}
.hscroll::-webkit-scrollbar-thumb{background:#CBCBC5; border-radius:5px}
.hscroll::-webkit-scrollbar-thumb:hover{background:#B4B4AE}
.zoomrow{display:flex; gap:6px; align-items:center; margin-top:6px; font-size:11.5px; color:var(--muted)}
.zoomrow .zlabel{font-variant-numeric:tabular-nums; min-width:54px; text-align:center}
.zoomrow .note-txt{margin-left:auto}
.seg-hint{font-size:11px; color:var(--faint); margin-top:8px}
/* PMI workbench */
.pmi-wrap{padding:14px 16px; border:1px solid var(--line); border-radius:12px; background:#FAFAF8}
.pmi-head{display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; margin-bottom:10px}
.pmi-head b{font-size:14px}
.pmi-head .note-txt{flex:1 1 320px}
.pmi-row{display:flex; gap:10px; flex-wrap:wrap; align-items:center}
.pmi-slot{font-size:12px; color:var(--muted); font-variant-numeric:tabular-nums;
border:1px solid var(--line); border-radius:999px; padding:4px 12px; background:#fff}
.pmi-slot.filled{color:#2a7; border-color:#bfe0cd; font-weight:600}
.pmi-result{margin-top:14px; font-size:13px; color:var(--ink)}
.pmi-result.stale{opacity:.5}
.pmi-result.stale::before{content:"An excerpt changed since this was computed \2014 compute again to update it";
display:block; margin-bottom:8px; font-size:11.5px; color:#9a6a1f; opacity:1}
.pmi-score{font-size:22px; font-weight:700; font-variant-numeric:tabular-nums}
.pmi-keys{font-size:12px; color:var(--muted); margin:4px 0 10px}
/* aligned grid: equal-width cells, identical scale degrees in red */
.aln-grid{margin-top:12px; overflow-x:auto; padding-bottom:4px}
.aln-grid .arow{display:flex; align-items:center; gap:0; white-space:nowrap}
.aln-grid .arow+.arow{margin-top:3px}
.aln-grid .arow.lyr+.arow{margin-top:9px}
.aln-grid .albl{flex:0 0 auto; width:20px; font-size:11px; color:var(--faint);
font-family:ui-monospace,Menlo,Consolas,monospace; user-select:none}
.aln-cell{flex:0 0 auto; width:20px; height:24px; display:flex; align-items:center; justify-content:center;
font-family:ui-monospace,Menlo,Consolas,monospace; font-size:13px; border-radius:4px; margin-right:2px;
background:#f2f2ef; color:#555}
.aln-cell.same{background:#e2483d; color:#fff; font-weight:700}
.aln-cell.diff{background:#eee; color:#888}
.aln-cell.gap{background:transparent; color:#ccc}
.aln-cell.lyr{background:transparent; color:#5a3f78; font-family:var(--sans); font-size:9.5px;
height:15px; overflow:hidden; padding:0 1px}
.aln-grid.haslyr .aln-cell{width:30px}
.aln-legend{margin-top:6px; font-size:11px; color:var(--faint)}
.aln-legend .sw{display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:middle; margin:0 3px 0 8px}
.aln-warn{font-size:11px; color:#9a6a1f; margin-top:5px}
.aln-grid.editable .aln-cell.drag{cursor:grab}
.aln-grid.editable .aln-cell.drag:active{cursor:grabbing}
.aln-cell.dragging{outline:2px solid var(--edit); outline-offset:-2px}
/* only the gaps this note can legally reach light up, so the rule that it
may not cross another note is visible rather than merely enforced */
.aln-cell.drop.ok{background:#dff0e4; outline:1px dashed #7ab98a; outline-offset:-1px}
.aln-edited{font-size:11px; font-weight:400; color:var(--warn); margin-left:10px}
/* aligned-pitch roll, collapsed by default */
.roll2-det{margin-top:14px; border:1px solid var(--line); border-radius:10px; background:#fff}
.roll2-det>summary{cursor:pointer; padding:9px 12px; font-size:12.5px; font-weight:600; list-style:none}
.roll2-det>summary::-webkit-details-marker{display:none}
.roll2-det>summary::before{content:"\25B8 "; color:var(--faint)}
.roll2-det[open]>summary::before{content:"\25BE "}
.roll2-det>summary span{font-weight:400; color:var(--faint); font-size:11px; margin-left:6px}
.roll2-body{padding:0 12px 12px}
.roll2-listen{display:flex; gap:6px; margin-bottom:8px}
.roll2-svg-wrap{overflow-x:auto}
</style>
</head>
<body>
<header>
<h1>PMI Calculator</h1>
<p>load both excerpts, correct each transcription, then align them and read the PMI</p>
<p class="disclaimer">Research use only. PMI measures melodic similarity, not infringement.
No liability is accepted for any consequence of using this tool or relying on its output.</p>
</header>
<label class="slot" id="dualSlot">
<span class="tag">Full audio &rarr; backend</span>
<span class="fname" id="dualName">drop both full mixes here, or click to pick two files</span>
<span class="hint">the first file becomes excerpt A and the second B; you can also drop one at a time</span>
<input type="file" accept="audio/*" id="dualFile" multiple>
</label>
<div class="restore-bar" id="restoreBar" hidden>
<span id="restoreMsg"></span>
<button class="btn sm" id="btnRestore">Restore</button>
<button class="btn sm" id="btnDiscard">Discard</button>
</div>
<div class="excerpts">
<section class="excerpt" data-key="A"></section>
<section class="excerpt" data-key="B"></section>
</div>
<div class="pair-bar">
<button class="btn dl lg" id="btnTranscribePair">Transcribe both excerpts &rarr;</button>
<span class="seg-status" id="pairStatus"></span>
</div>
<div class="cand-pop" id="candPop" hidden></div>
<!-- ============ PMI WORKBENCH ============ -->
<div class="pmi-wrap">
<div class="pmi-head">
<b>PMI</b>
<span class="note-txt">Both excerpts above stay loaded. Correct each transcription and set its key,
then compute — editing either one and computing again just updates the result.</span>
</div>
<div class="pmi-row">
<span class="pmi-slot" id="pmiStateA">A: empty</span>
<span class="pmi-slot" id="pmiStateB">B: empty</span>
<button class="btn primary lg" id="btnPMI" disabled>Compute PMI</button>
</div>
<div class="pmi-result" id="pmiResult" hidden></div>
<div id="pdfBar" hidden><button class="btn dl" id="btnPdf">&#10515; Download report (PDF)</button></div>
</div>
<script>
/* ============================================================
0. BACKEND INTEGRATION
Front-end POSTs the (cropped) audio -> backend (source separation +
transcription) -> returns { midi, vocal, chroma }.
============================================================ */
const BACKEND_URL = "/transcribe"; // same-origin: FastAPI serves this page AND the endpoint
const PMI_URL = "/pmi", ANALYZE_URL = "/analyze";
const GRADIO_CDN = "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
function base64ToArrayBuffer(b64){
const bin = atob(b64), len = bin.length, bytes = new Uint8Array(len);
for(let i=0;i<len;i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
// ZeroGPU: the transcription endpoint must be called through @gradio/client
// so the HF iframe auth headers reach ZeroGPU (a bare fetch() would hit the
// strict IP-based rate limit). analyze/pmi remain plain fetch (CPU routes).
let _gradioClient = null, _gradioMod = null;
async function gradioMod(){
if(!_gradioMod) _gradioMod = await import(GRADIO_CDN);
return _gradioMod;
}
async function gradioClient(){
if(_gradioClient) return _gradioClient;
const mod = await gradioMod();
_gradioClient = await mod.Client.connect(window.location.origin);
return _gradioClient;
}
/* ============================================================
SHARED AUDIO GRAPH
One AudioContext for the whole page. Each excerpt gets its own
audio/MIDI gain + pan pair; only one excerpt plays at a time.
============================================================ */
let ac = null, master = null, gPmi = null;
function ensureAC(){
if(ac) return;
ac = new (window.AudioContext || window.webkitAudioContext)();
master = ac.createDynamicsCompressor(); master.connect(ac.destination);
gPmi = ac.createGain(); gPmi.gain.value = 0.85; gPmi.connect(master);
}
function tone(freq, tS, tE, dest, type, vibrato, peak, sink){
const o = ac.createOscillator(), g = ac.createGain();
o.type = type; o.frequency.value = freq;
if(vibrato){
const lfo = ac.createOscillator(), la = ac.createGain();
lfo.frequency.value = 5.5; la.gain.value = freq*0.013;
lfo.connect(la).connect(o.frequency);
lfo.start(tS); lfo.stop(tE+0.05); if(sink) sink.push(lfo);
}
g.gain.setValueAtTime(0, tS); g.gain.linearRampToValueAtTime(peak, tS+0.012);
g.gain.setValueAtTime(peak, Math.max(tS+0.013, tE-0.05)); g.gain.linearRampToValueAtTime(0, tE);
o.connect(g).connect(dest); o.start(tS); o.stop(tE+0.05); if(sink) sink.push(o);
}
function midiToFreq(m){ return 440*Math.pow(2,(m-69)/12); }
/* ============================================================
MIDI PARSER (read) + WRITER (export)
============================================================ */
function parseMidi(buf){
const dv = new DataView(buf); let p = 0;
const u8=()=>dv.getUint8(p++), u16=()=>{const v=dv.getUint16(p);p+=2;return v;}, u32=()=>{const v=dv.getUint32(p);p+=4;return v;};
const str=n=>{let s="";for(let i=0;i<n;i++)s+=String.fromCharCode(u8());return s;};
const vlq=()=>{let v=0,c;do{c=u8();v=(v<<7)|(c&0x7f);}while(c&0x80);return v;};
if(str(4)!=="MThd") throw new Error("not a MIDI file");
u32(); u16(); const ntrks=u16(); const division=u16();
if(division & 0x8000) throw new Error("SMPTE timing unsupported");
const ppq = division & 0x7fff; let tempo = 500000; const raw = []; const lyr = [];
for(let t=0;t<ntrks;t++){
if(str(4)!=="MTrk") break;
const len=u32(), end=p+len; let tick=0, running=0; const active={};
const close=(pitch,tk)=>{ const a=active[pitch]; if(a!=null){ raw.push({pitch,s:a.tick,e:tk,vel:a.vel}); delete active[pitch]; } };
while(p<end){
tick+=vlq();
let status=dv.getUint8(p);
if(status & 0x80){ p++; if(status<0xF0) running=status; } else status=running;
const type=status & 0xF0;
if(status===0xFF){ const mt=u8(),l=vlq();
if(mt===0x51&&l===3){tempo=(u8()<<16)|(u8()<<8)|u8();}
else if(mt===0x05){ lyr.push({tick, text:decodeURIComponent(escape(str(l)))}); }
else p+=l; }
else if(status===0xF0||status===0xF7){ p+=vlq(); }
else if(type===0x90){ const pitch=u8(),vel=u8(); vel>0?active[pitch]={tick,vel}:close(pitch,tick); }
else if(type===0x80){ const pitch=u8();u8();close(pitch,tick); }
else if(type===0xC0||type===0xD0){ p+=1; } else { p+=2; }
}
p=end;
}
const spt=(tempo/1e6)/ppq;
const notes = raw.map(n=>({pitch:n.pitch,start:n.s*spt,dur:Math.max((n.e-n.s)*spt,0.04),conf:n.vel/127}))
.sort((a,b)=>a.start-b.start);
// carried on the array so every existing caller keeps working unchanged
notes.lyrics = lyr.map(l=>({t:l.tick*spt, text:l.text})).sort((a,b)=>a.t-b.t);
return notes;
}
// lyrics: [{t, text}] in the SAME timebase as the notes (segment-relative).
// MIDI has a lyric meta-event, so annotations travel inside the file the user
// already exports -- one file to keep, and they can never drift out of sync.
function notesToMidi(ns, ppq=480, bpm=120, lyrics=null){
const out=[], app=(...b)=>{for(const x of b) out.push(x&255);};
const u16=n=>app((n>>8),n), u32=n=>app((n>>24),(n>>16),(n>>8),n);
const trk=[], tapp=(...b)=>{for(const x of b) trk.push(x&255);};
const tvlq=n=>{ const b=[n&0x7f]; n>>=7; while(n>0){ b.unshift((n&0x7f)|0x80); n>>=7;} tapp(...b); };
const secPerTick=(60/bpm)/ppq;
tvlq(0); tapp(0xff,0x51,0x03); const us=Math.round(60000000/bpm); tapp((us>>16),(us>>8),us);
const evs=[];
ns.forEach(n=>{
const on=Math.max(0,Math.round(n.start/secPerTick));
const off=Math.max(on+1,Math.round((n.start+n.dur)/secPerTick));
const vel=Math.max(1,Math.min(127,Math.round((n.conf==null?1:n.conf)*127)));
evs.push({t:on,type:0x90,p:n.pitch,v:vel}); evs.push({t:off,type:0x80,p:n.pitch,v:0});
});
(lyrics||[]).forEach(l=>{
const txt = String(l.text==null?"":l.text);
if(!txt) return;
evs.push({t:Math.max(0,Math.round(l.t/secPerTick)), type:0xFF, text:txt});
});
// lyric events sort ahead of note events at the same tick (0xFF is written as
// a negative key) so a word lands with the note it labels, not after it
evs.sort((a,b)=> a.t-b.t || (a.type===0xFF?-1:0)-(b.type===0xFF?-1:0) || a.type-b.type);
let last=0;
evs.forEach(e=>{
tvlq(e.t-last); last=e.t;
if(e.type===0xFF){
const bytes=[]; for(const ch of unescape(encodeURIComponent(e.text))) bytes.push(ch.charCodeAt(0)&255);
tapp(0xff,0x05); tvlq(bytes.length); tapp(...bytes);
}else{
tapp(e.type, e.p&127, e.v&127);
}
});
tvlq(0); tapp(0xff,0x2f,0x00);
app(0x4d,0x54,0x68,0x64); u32(6); u16(0); u16(1); u16(ppq);
app(0x4d,0x54,0x72,0x6b); u32(trk.length); for(const x of trk) out.push(x);
return new Uint8Array(out);
}
/* ============================================================
SHARED HELPERS
============================================================ */
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const BLACK_PC = [1,3,6,8,10];
const CSSROOT = getComputedStyle(document.documentElement);
const col = k => CSSROOT.getPropertyValue(k).trim();
let nid = 1;
function withIds(arr){ return arr.map(n=>Object.assign({id:nid++, edited:false, added:false}, n)); }
function clampPitch(p){ return Math.max(0,Math.min(127,Math.round(p))); }
function pName(p){ return NOTE_NAMES[((p%12)+12)%12] + (Math.floor(p/12)-1); }
function roundRectCtx(c,x0,y0,w,h,r){
r = Math.min(r, h/2, w/2);
c.beginPath();
c.moveTo(x0+r,y0); c.arcTo(x0+w,y0,x0+w,y0+h,r); c.arcTo(x0+w,y0+h,x0,y0+h,r);
c.arcTo(x0,y0+h,x0,y0,r); c.arcTo(x0,y0,x0+w,y0,r); c.closePath();
}
// vocals is the validated path; PMI thresholds were calibrated on vocals.
const INST_NOTE = { vocals:"", guitar:"Experimental", piano:"Experimental" };
/* ---- note-completion tuning constants (shared, read-only) ---------------- */
const TIE_MARGIN = 0.15; // top-2 closer than this => chroma is ambiguous
const PRIOR_W = 0.15; // tiebreak weight; small by design
const E_SILENT = 0.05; // energy below this (rel. to track max) => no signal
const E_WEAK = 0.15; // ...and below this => weak signal, say so
const PEAK_FLAT = 0.12; // (top1-top2)/top1 below this => no clear winner
const SIM_K = 3; // notes of context each side for the repeat search
const SIM_MIN = 0.55; // context agreement needed to report a repeat
const SIM_MIN_CTX= 4; // ...over at least this many context positions
const SIM_NEAR = 0.4; // partial credit when a context note is 1 semitone off
const SIM_OFF = 12; // transposition range searched, in semitones
/* ============================================================
PANEL TEMPLATE
Both excerpts are the same editor, built twice. Everything is
addressed by class and scoped to the panel root, so nothing is
shared by accident.
============================================================ */
function panelHTML(K){ return `
<div class="ex-head">
<span class="ex-badge">${K}</span>
<b>Excerpt ${K}</b>
<span class="ex-sub js-sub">no audio loaded</span>
<span class="reuse">
<span class="reuse-lbl">reuse a saved excerpt:</span>
<label class="mid-in" title="Load a .mid you saved earlier. Brings back the notes and any lyric markers, with no transcription needed.">
&#8681; MIDI <em>notes</em>
<input type="file" accept=".mid,.midi" class="js-midfile" hidden>
</label>
<label class="mid-in" title="Load a stem you saved earlier, to hear the separated audio again without re-running the GPU.">
&#8681; Stem <em>audio</em>
<input type="file" accept="audio/*" class="js-stemfile" hidden>
</label>
</span>
</div>
<div class="seg-wrap js-segwrap" hidden>
<div class="seg-head">
<b>Select a segment</b>
<span class="note-txt">Drag the handles to choose a portion (a chorus or phrase, ~10&ndash;30 s),
preview it. Both excerpts are transcribed in one go, so set the segment in each. Shorter segments process much faster.</span>
</div>
<div class="seg-canvas-wrap js-segcanvaswrap">
<canvas class="segWave js-segwave"></canvas>
<div class="seg-sel js-segsel"></div>
<div class="seg-handle js-segl"></div>
<div class="seg-handle js-segr"></div>
<div class="seg-playhead js-segph" hidden></div>
</div>
<div class="hscroll js-segscroll"><div class="js-segspacer"></div></div>
<div class="zoomrow">
<button class="btn sm js-segzout" title="Zoom out">&minus;</button>
<button class="btn sm js-segzin" title="Zoom in">+</button>
<span class="zlabel js-segzlabel">&ndash;</span>
<button class="btn sm js-segfit">fit selection</button>
<button class="btn sm js-segall">whole track</button>
<span class="note-txt">scroll to zoom, Shift + scroll to pan</span>
</div>
<div class="seg-hint">Right-click the waveform to add a lyric marker at that point.
Right-click an existing marker to rename it, or clear the text to delete it.</div>
<div class="seg-row">
<span class="seg-time js-segtimes">0.00 &ndash; 0.00 s (0.00 s)</span>
<label class="inst-pick">isolate
<select class="js-inst">
<option value="vocals" selected>vocals</option>
<option value="guitar">guitar</option>
<option value="piano">piano</option>
</select>
</label>
<button class="btn sm js-segpreview">&#9654; preview segment</button>
<button class="btn sm js-segfull">&#9654; whole track</button>
</div>
</div>
<div class="stage js-stage" hidden>
<div class="transport">
<button class="play js-play">&#9654; Play</button>
<label class="vol">audio &#9664;<input type="range" class="js-volaudio" min="0" max="100" step="1" value="85"></label>
<label class="vol">&#9654; MIDI<input type="range" class="js-volmidi" min="0" max="100" step="1" value="80"></label>
</div>
<div class="edittools">
<button class="btn js-undo" disabled>&#8630; Undo</button>
<button class="btn js-redo" disabled>&#8631; Redo</button>
<span class="sep"></span>
<button class="btn js-merge" disabled>Merge</button>
<button class="btn js-split" disabled>Split</button>
<button class="btn js-delete" disabled>Delete</button>
<span class="sep"></span>
<button class="btn js-lyrics">&#65291; Markers</button>
<button class="btn js-lrcimport" hidden>Import LRC</button>
<input type="file" class="js-lrcfile" accept=".lrc,.txt" hidden>
<button class="btn dl js-download">&#10515; MIDI</button>
<button class="btn dl js-dlstem" title="Save the separated vocal. Reload it later with the MIDI to restore the full session without using the GPU.">&#10515; Stem</button>
<span class="hint2">
<b>Notes</b> &middot; double-click empty space to insert a note &middot;
right-click a note for the pitches the audio supports at that position &middot;
drag to transpose &middot; drag either edge to adjust onset or duration &middot;
&uarr;/&darr; to transpose by a semitone (Shift for an octave) &middot; Ctrl+Z to undo.<br>
<b>Lyrics</b> &middot; double-click a note to type its word or syllable, then press &#10003; (or Enter) to confirm &middot;
the word appears by the note, Enter jumps to the next note, Esc cancels &middot;
these are the lyrics carried through to the alignment below.<br>
<b>View</b> &middot; scroll to zoom, Shift + scroll to pan.<br>
<b>Waveform markers</b> (optional, above) &middot; drag to reposition &middot;
Shift-drag to shift the whole track &middot; right-click to delete.
</span>
</div>
<div class="roll-wrap">
<canvas class="roll js-roll"></canvas>
<span class="lyric-box js-lyricbox">
<input class="js-lyricin" spellcheck="false" autocomplete="off"
placeholder="word / syllable" aria-label="Lyric for this note">
<button class="lyric-ok js-lyricok" title="Confirm (Enter)">&#10003;</button>
<button class="lyric-x js-lyricx" title="Cancel (Esc)">&times;</button>
</span>
<div class="lyric-tip js-lyrictip"></div>
</div>
<div class="hscroll js-rollscroll"><div class="js-rollspacer"></div></div>
<div class="zoomrow">
<button class="btn sm js-rollzout" title="Zoom out">&minus;</button>
<button class="btn sm js-rollzin" title="Zoom in">+</button>
<span class="zlabel js-rollzlabel">&ndash;</span>
<button class="btn sm js-rollall">fit all</button>
<span class="note-txt">scroll to zoom, Shift + scroll to pan</span>
</div>
<div class="roll-legend">
<span class="sw" style="background:var(--note)"></span>transcribed
<span class="sw" style="background:var(--edit)"></span>edited
<span class="sw" style="background:var(--infer)"></span>completed by you
</div>
<div class="readout">
<span class="chip"><span class="dot" style="background:var(--note)"></span>notes <b class="js-cnotes">0</b></span>
<span class="chip"><span class="dot" style="background:var(--edit)"></span>edited <b class="js-cedit">0</b></span>
</div>
<div class="keyrow js-keyrow">
<label>key
<select class="keysel js-tonic"></select>
<select class="keysel js-mode"><option value="major">major</option><option value="minor">minor</option></select>
</label>
<button class="btn sm js-listen" title="Play the two candidate keys (transposed to C) so you can pick the one with fewer accidentals">&#9654; compare keys</button>
<button class="btn sm js-reanalyse" title="Re-run key estimation on the current, corrected notes">re-estimate</button>
<span class="keycand js-keycand"></span>
</div>
</div>
`; }
/* ============================================================
PANEL FACTORY
Every excerpt owns its own notes, audio, chroma, lyrics, undo
stack, playhead and segment selection. Nothing lives at module
scope that a second excerpt could overwrite.
============================================================ */
const PANELS = {};
function sharedStatus(){ return document.getElementById("pairStatus"); }
/* ---- autosave ----
Nothing persists on the server, so a snapshot of both panels goes to
localStorage. Audio is excluded: one stem is ~4.7 MB base64 against a ~5 MB
budget. Notes, markers, bounds and key fit in well under 100 KB, and MIDI
export and PMI both run off notes alone. */
const SAVE_KEY = "pmi-editor-autosave-v1";
let saveTimer = 0;
function writeSnapshot(){
try{
const A = PANELS.A, B = PANELS.B;
const a = A && A.snapshot(), b = B && B.snapshot();
if(!a && !b){ localStorage.removeItem(SAVE_KEY); return; }
localStorage.setItem(SAVE_KEY, JSON.stringify({v:1, savedAt:Date.now(), a, b}));
}catch(err){
// Best-effort only: a full or disabled localStorage must never break editing.
}
}
// Debounced: edits arrive in bursts (dragging a note fires continuously) and
// serialising on every one of them would stutter the UI.
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(writeSnapshot, 800);
}
function readSnapshot(){
try{
const raw = localStorage.getItem(SAVE_KEY);
if(!raw) return null;
const d = JSON.parse(raw);
return (d && d.v === 1) ? d : null;
}catch(err){ return null; }
}
function clearSnapshot(){
try{ localStorage.removeItem(SAVE_KEY); }catch(err){}
}
function resetPanel(key){
const root = document.querySelector(`.excerpt[data-key="${key}"]`);
if(root) createPanel(key, root);
}
let sharedTimerId = 0;
function startSharedTimer(estLo, estHi){
const status = sharedStatus();
const tStart = Date.now();
const frames = ["\u280b","\u2819","\u2839","\u2838","\u283c","\u2834","\u2826","\u2827","\u2807","\u280f"];
let k = 0;
const est = ` (both excerpts together usually take ~${estLo}\u2013${estHi}s on GPU)`;
const tick = ()=>{
const el = (Date.now()-tStart)/1000;
const sp = frames[k++ % frames.length];
status.innerHTML = `<span style="font-family:monospace">${sp}</span> `
+ `Separating &amp; transcribing both excerpts\u2026 <b>${el.toFixed(0)}s elapsed</b>${est}`;
};
tick(); sharedTimerId = setInterval(tick, 120);
}
function stopSharedTimer(){
if(sharedTimerId){ clearInterval(sharedTimerId); sharedTimerId=0; }
}
let activePanel = null; // which excerpt the keyboard talks to
const ZOOM_STEP = 1.1; // one wheel notch / button press; gentle on purpose
let activeZone = 'seg'; // 'seg' | 'roll': what Space controls
function createPanel(KEY, root){
root.innerHTML = panelHTML(KEY);
const $ = sel => root.querySelector(sel);
/* ---- state ---- */
let notes = [];
let audioBuffer = null;
let chromaData = null; // {u8, en, nFrames, sr, hop} | null
let lyrics = []; // [{t, text}] in WHOLE-SONG seconds
let lyricsOn = false;
let lyricHit = [], lyricDrag = null, segLyricHit = [];
let lyricClusterHit = []; // [{x,y,w,h,words}] merged-label boxes for hover
let selectedIds = new Set();
let undoStack = [], redoStack = [];
let ph = 0, playing = false, t0 = 0, startOffset = 0, rafId = 0;
let srcAudio = null, voices = [];
let gAudio = null, gMidi = null;
let geo = {padL:58,padT:14,plotW:1,plotH:1,span:1,vs:0,vd:1,lo:0,hi:1,rowH:10,gap:2,bh:6};
// Both timelines are virtual: the canvas renders only the visible window,
// since the full width would exceed the maximum canvas size.
let rollZoom = 1, rollVS = 0;
let segZoom = 1, segVS = 0;
let segBuffer = null, segFileName = "audio", loadedName = null, segStart = 0, segEnd = 20;
let lastDoneMsg = "";
// Which notes were counted as identical in the last PMI run, by note index.
// Set from the alignment so the grid's red squares and the roll agree.
let matchFlags = null;
let reattachedName = ""; // set when audio was re-attached to restored notes
let restoredName = ""; // filename the restored notes came from
// segStart is live and follows the handles. txStart is the segment offset the
// CURRENT notes were transcribed from, frozen at extraction. Markers are stored
// in whole-song time, so they must be rebased against txStart, not segStart --
// otherwise nudging the handles after a transcription slides every marker
// sideways relative to notes that did not move.
let txStart = 0;
let segSrc = null, segRAF = 0;
let segPlayT = null; // preview playhead time (s), null = none placed yet
let segPlaying = false; // is preview audio actually sounding right now
let analysis = null; // last /analyze result
const cv = $(".js-roll"), ctx = cv.getContext("2d");
/* ---- per-panel audio nodes (created on first use) ---- */
function ensureNodes(){
ensureAC();
if(gAudio) return;
gAudio = ac.createGain(); gMidi = ac.createGain();
const panA = ac.createStereoPanner(), panM = ac.createStereoPanner();
panA.pan.value = -0.55; panM.pan.value = 0.55;
gAudio.connect(panA).connect(master); gMidi.connect(panM).connect(master);
applyVolumes();
}
function applyVolumes(){
if(!gAudio) return;
gAudio.gain.value = (+$(".js-volaudio").value)/100;
gMidi.gain.value = (+$(".js-volmidi").value)/100;
}
/* ---- lyric track (visual reference; never touches PMI) ---- */
function lyricAt(px,py){
for(let i=0;i<lyricHit.length;i++){
const b=lyricHit[i]; if(!b) continue;
if(px>=b.x && px<=b.x+b.w && py>=b.y && py<=b.y+b.h) return i;
}
return -1;
}
function clusterAt(px,py){
for(const c of lyricClusterHit){
if(px>=c.x && px<=c.x+c.w && py>=c.y && py<=c.y+c.h) return c;
}
return null;
}
function parseLRC(text){
const out = [];
const reTime = /\[(\d+):(\d+)(?:[.:](\d+))?\]/g;
text.split(/\r?\n/).forEach(line=>{
reTime.lastIndex = 0;
const stamps = []; let m;
while((m = reTime.exec(line)) !== null){
const mm = parseInt(m[1],10), ss = parseInt(m[2],10);
const fr = m[3] ? parseInt(m[3],10) / Math.pow(10, m[3].length) : 0;
stamps.push(mm*60 + ss + fr);
}
if(!stamps.length) return;
const words = line.replace(reTime, "").trim();
if(!words) return;
stamps.forEach(t=> out.push({t, text: words}));
});
out.sort((a,b)=> a.t - b.t);
return out;
}
function anchorLyricsTo(index, newT){
if(index < 0 || index >= lyrics.length) return;
const delta = newT - lyrics[index].t;
lyrics.forEach(l=> l.t += delta);
lyrics.sort((a,b)=> a.t - b.t);
scheduleSave();
}
function splitLyric(index){
if(index < 0 || index >= lyrics.length) return;
const l = lyrics[index];
const hasSpace = /\s/.test(l.text.trim());
const toks = hasSpace ? l.text.trim().split(/\s+/) : Array.from(l.text.trim());
if(toks.length <= 1) return;
const next = lyrics[index+1];
const span = next ? Math.max(0.2, next.t - l.t) : Math.max(0.6, toks.length*0.3);
const step = span / toks.length;
const repl = toks.map((tok,k)=> ({t: l.t + k*step, text: tok}));
lyrics.splice(index, 1, ...repl);
lyrics.sort((a,b)=> a.t - b.t);
scheduleSave();
}
function addLyric(t, text){
lyrics.push({t: Math.max(0,t), text: text || "\u2022"});
lyrics.sort((a,b)=> a.t - b.t);
scheduleSave();
}
function deleteLyric(i){ if(i>=0 && i<lyrics.length){ lyrics.splice(i,1); scheduleSave(); } }
function toggleLyrics(){ lyricsOn = !lyricsOn; syncLyricsUI(); drawWave(); draw(); }
function syncLyricsUI(){
const b = $(".js-lyrics"), imp = $(".js-lrcimport");
b.textContent = lyricsOn ? "\u2212 Markers" : "\uFF0B Markers";
b.classList.toggle("on", lyricsOn);
imp.hidden = !lyricsOn;
}
function currentInstrument(){ return $(".js-inst").value; }
function syncInstrumentUI(){
const v = currentInstrument();
const st = sharedStatus();
if(INST_NOTE[v] !== undefined && !st.dataset.busy) st.textContent = INST_NOTE[v];
}
/* ============================================================
PIANO ROLL
============================================================ */
function byId(id){ return notes.find(n=>n.id===id); }
function spanSec(){
const nEnd = notes.length ? Math.max(...notes.map(n=>n.start+n.dur)) : 0;
return Math.max(nEnd, audioBuffer?audioBuffer.duration:0, 1);
}
function rollViewDur(){ return spanSec()/rollZoom; }
function rollClampVS(){
const span=spanSec(), vd=rollViewDur();
rollVS = Math.max(0, Math.min(rollVS, Math.max(0, span - vd)));
}
const timeToX = t => geo.padL + ((t-geo.vs)/geo.vd)*geo.plotW;
const xToTime = px => geo.vs + ((px-geo.padL)/geo.plotW)*geo.vd;
const yToPitch = py => clampPitch(geo.hi - Math.floor((py-geo.padT)/geo.rowH));
function syncRollScroll(){
const wrap=cv.parentElement, W=wrap.clientWidth;
$(".js-rollspacer").style.width = Math.round(W*rollZoom) + "px";
const sc=$(".js-rollscroll");
const want = (rollVS/Math.max(spanSec(),1e-6))*W*rollZoom;
if(Math.abs(sc.scrollLeft-want) > 1) sc.scrollLeft = want;
$(".js-rollzlabel").textContent = fmtWin(rollViewDur());
}
function setRollZoom(z, anchorT){
const span=spanSec();
// never let the window shrink below ~0.2 s; past that the grid is noise
const zMax = Math.max(1, span/0.2);
z = Math.max(1, Math.min(z, zMax));
// Keep the anchor at the SAME position on screen instead of recentring on
// it: pulling the pointed-at time into the middle of the view on every
// wheel notch is what made zooming feel like it was fighting back.
const before = rollViewDur();
const a = (anchorT==null) ? (rollVS + before/2) : anchorT;
const frac = before > 0 ? (a - rollVS) / before : 0.5;
rollZoom = z;
rollVS = a - frac * rollViewDur();
rollClampVS(); syncRollScroll(); draw();
}
// the useful number is how much time is on screen, not an abstract factor
function fmtWin(d){ return d>=60 ? (d/60).toFixed(1)+" min" : d.toFixed(d<2?2:1)+" s"; }
function draw(){
const dpr = window.devicePixelRatio||1, w = cv.clientWidth, h = cv.clientHeight;
cv.width = w*dpr; cv.height = h*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,w,h);
if(!notes.length){
geo.span = spanSec(); geo.vs = 0; geo.vd = geo.span;
ctx.fillStyle = col("--faint");
ctx.font = '12px ' + col("--sans");
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(audioBuffer ? "No notes yet, double-click to add one"
: "Load audio above and choose a segment in both excerpts", w/2, h/2);
ctx.textAlign = "start";
return;
}
const lo = Math.min(...notes.map(n=>n.pitch))-1, hi = Math.max(...notes.map(n=>n.pitch))+1;
const span = spanSec();
rollClampVS();
const vs = rollVS, vd = rollViewDur();
const padL=58,padR=14,padT=14,padB=20, plotW=w-padL-padR, plotH=h-padT-padB;
const rows=hi-lo+1, rowH=plotH/rows, gap=rowH*0.18, bh=Math.max(rowH-2*gap,3);
geo = {padL,padT,plotW,plotH,span,vs,vd,lo,hi,rowH,gap,bh};
const x = timeToX, y = pch => padT + (hi-pch)*rowH;
ctx.font = '10px ' + col("--sans");
/* pitch rows */
for(let pch=lo;pch<=hi;pch++){
const ry=y(pch), isBlack=BLACK_PC.includes(((pch%12)+12)%12);
if(isBlack){ ctx.fillStyle="rgba(0,0,0,.028)"; ctx.fillRect(padL,ry,plotW,rowH); }
ctx.strokeStyle=col("--grid"); ctx.beginPath(); ctx.moveTo(padL,ry+rowH); ctx.lineTo(w-padR,ry+rowH); ctx.stroke();
}
/* time grid: pick a step that keeps the number of lines readable at any zoom */
const STEPS=[0.05,0.1,0.25,0.5,1,2,5,10,15,30,60,120];
let gs=STEPS[STEPS.length-1];
for(const st of STEPS){ if(vd/st <= 20){ gs=st; break; } }
ctx.textBaseline="top"; ctx.textAlign="center";
for(let t=Math.ceil(vs/gs)*gs; t<=vs+vd+1e-9; t+=gs){
const gx=x(t);
if(gx < padL-1 || gx > w-padR+1) continue;
ctx.strokeStyle=col("--grid"); ctx.beginPath(); ctx.moveTo(gx,padT); ctx.lineTo(gx,h-padB); ctx.stroke();
ctx.fillStyle=col("--faint");
ctx.fillText(gs<1 ? t.toFixed(2)+"s" : t.toFixed(gs<1?2:0)+"s", gx, h-padB+3);
}
ctx.textAlign="start";
/* left piano keyboard */
for(let pch=lo;pch<=hi;pch++){
const ry=y(pch), isBlack=BLACK_PC.includes(((pch%12)+12)%12);
ctx.fillStyle="#FFFFFF"; ctx.fillRect(0,ry,padL,rowH);
ctx.strokeStyle=col("--line"); ctx.beginPath(); ctx.moveTo(0,ry); ctx.lineTo(padL,ry); ctx.stroke();
if(isBlack){ ctx.fillStyle=col("--key-black"); ctx.fillRect(0,ry,padL*0.6,rowH); }
if(((pch%12)+12)%12===0){ ctx.fillStyle=col("--muted"); ctx.textAlign="right"; ctx.textBaseline="middle";
ctx.fillText("C"+(Math.floor(pch/12)-1), padL-6, ry+rowH/2); ctx.textAlign="start"; ctx.textBaseline="top"; }
}
ctx.strokeStyle=col("--line"); ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,h-padB); ctx.stroke();
/* everything time-based is clipped to the plot, so nothing bleeds over the keys */
ctx.save();
ctx.beginPath(); ctx.rect(padL,padT,plotW,plotH); ctx.clip();
for(let ni=0; ni<notes.length; ni++){
const n = notes[ni];
if(n.start+n.dur < vs || n.start > vs+vd) continue; // outside the window
const nx=x(n.start), nw=Math.max((n.dur/vd)*plotW-1,2), ny=y(n.pitch)+gap;
// matchFlags is only ever set while the report image is being taken, so
// the red appears in the PDF and never on the editing surface
ctx.fillStyle = (matchFlags && matchFlags[ni]) ? "#e2483d"
: n.inferred ? col("--infer")
: n.edited ? col("--edit") : col("--note");
roundRectCtx(ctx,nx,ny,nw,bh,2); ctx.fill();
if(selectedIds.has(n.id)){ ctx.strokeStyle=col("--sel"); ctx.lineWidth=2; roundRectCtx(ctx,nx-1.5,ny-1.5,nw+3,bh+3,3); ctx.stroke(); }
}
/* playhead */
if(ph >= vs && ph <= vs+vd){
const phx=x(ph);
ctx.strokeStyle=col("--play"); ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(phx,padT); ctx.lineTo(phx,h-padB); ctx.stroke();
}
/* markers: teal, own band at the top; per-note lyrics are purple and stay below it */
const MARK_TOP = padT + 1, MARK_BAND = (lyricsOn && lyrics.length) ? 18 : 0;
lyricHit = [];
if(lyricsOn && lyrics.length){
ctx.save();
ctx.font='11px '+col("--sans"); ctx.textBaseline='top';
lyrics.forEach((l,i)=>{
const rt = l.t - txStart;
if(rt < vs - 0.001 || rt > vs+vd) return;
const lx = x(rt);
ctx.strokeStyle='rgba(20,140,150,0.45)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(lx,padT); ctx.lineTo(lx,h-padB); ctx.stroke();
const tw = ctx.measureText(l.text).width;
ctx.fillStyle='rgba(20,140,150,0.16)';
roundRectCtx(ctx,lx+1,MARK_TOP,tw+8,15,3); ctx.fill();
ctx.fillStyle='#0d6b73';
ctx.fillText(l.text, lx+5, MARK_TOP+2);
lyricHit[i]={x:lx+1,y:MARK_TOP,w:tw+8,h:15};
});
ctx.restore();
}
/* per-note lyric labels, drawn against the note itself, above where there
is room and below otherwise. When consecutive labelled notes are too
close for their words to sit side by side, they merge into one counter
chip (e.g. "\u22ef3"): zoom in and, once there is room, each word shows in
full again. A merged chip reveals its words on hover. The note being
edited is skipped because the live box already shows its word. */
lyricClusterHit = [];
if(notes.some(n=>n.lyric)){
ctx.font='10px '+col("--sans"); ctx.textBaseline='alphabetic'; ctx.textAlign='left';
const LBL_H=13, LBL_PADX=3, GAPMIN=2;
const labelled = notes.slice().sort((a,b)=>a.start-b.start)
.filter(n=> n.lyric && n.id!==lyricEditId && n.start+n.dur >= vs && n.start <= vs+vd);
// group into clusters: a note joins the current cluster if its word would
// overlap the previous word's box at this zoom.
const clusters=[]; let cur=null;
labelled.forEach(n=>{
const nx=x(n.start);
const w=ctx.measureText(n.lyric).width + LBL_PADX*2;
if(cur && nx < cur.right + GAPMIN){
cur.notes.push(n); cur.right = Math.max(cur.right, nx + w);
} else {
cur={ notes:[n], left:nx, right:nx + w }; clusters.push(cur);
}
});
clusters.forEach(cl=>{
const first=cl.notes[0];
const nx=x(first.start), ny=y(first.pitch)+gap;
const merged = cl.notes.length>1;
const text = merged ? ("\u22ef"+cl.notes.length) : first.lyric;
const tw=ctx.measureText(text).width, boxW=tw+LBL_PADX*2;
// place above the FIRST note if it has headroom, else below it
const above = ny - gap - 2 >= padT + MARK_BAND + LBL_H;
const boxY = above ? Math.max(ny - 2 - LBL_H, padT + MARK_BAND)
: Math.min(ny + bh + 2, h - padB - LBL_H);
ctx.fillStyle = merged ? 'rgba(138,99,168,0.22)' : 'rgba(138,99,168,0.12)';
roundRectCtx(ctx, nx, boxY, boxW, LBL_H, 3); ctx.fill();
ctx.fillStyle='#5a3f78';
ctx.fillText(text, nx+LBL_PADX, boxY+LBL_H-3.5);
if(merged){
lyricClusterHit.push({ x:nx, y:boxY, w:boxW, h:LBL_H,
words: cl.notes.map(n=>n.lyric) });
}
});
}
ctx.restore();
syncRollScroll();
}
/* ============================================================
PLAYBACK (one excerpt at a time)
============================================================ */
function stopSources(){
voices.forEach(v=>{try{v.stop()}catch(e){}}); voices=[];
if(srcAudio){try{srcAudio.stop()}catch(e){} srcAudio=null;}
}
function startPlayback(){
stopEveryPanel(api);
ensureNodes(); if(ac.state==="suspended") ac.resume();
// Seeking re-enters here; without this each call leaks its previous frame
// loop and stopPlayback can only cancel the newest one.
cancelAnimationFrame(rafId);
stopSources(); applyVolumes();
startOffset=Math.max(0,Math.min(ph,spanSec()-0.05)); t0=ac.currentTime+0.08;
for(const n of notes){
const ne=n.start+n.dur; if(ne<=startOffset) continue;
const tS=t0+Math.max(0,n.start-startOffset), tE=t0+(ne-startOffset), f=midiToFreq(n.pitch);
if(!audioBuffer) tone(f,tS,tE,gAudio,"sawtooth",true,0.6,voices);
tone(f,tS,tE,gMidi,"triangle",false,0.7,voices);
}
if(audioBuffer && startOffset < audioBuffer.duration){
// Merging can push the last note past the end of the stem, and Web Audio
// plays nothing at all when the offset exceeds the buffer.
srcAudio=ac.createBufferSource(); srcAudio.buffer=audioBuffer;
srcAudio.connect(gAudio); srcAudio.start(t0, Math.min(startOffset, audioBuffer.duration-0.01)); }
playing=true; $(".js-play").innerHTML="&#9632; Stop"; loop();
}
function stopPlayback(reset){
stopSources(); cancelAnimationFrame(rafId); playing=false;
$(".js-play").innerHTML="&#9654; Play"; if(reset) ph=0; draw();
}
function loop(){
ph=startOffset+Math.max(0,ac.currentTime-t0);
if(ph>=spanSec()){ stopPlayback(true); return; }
// when zoomed in, follow the playhead instead of letting it run off screen
if(rollZoom>1){
const vd=rollViewDur();
if(ph < rollVS || ph > rollVS + vd*0.97){ rollVS = ph - vd*0.15; rollClampVS(); }
}
draw(); rafId=requestAnimationFrame(loop);
}
/* ============================================================
UNDO / REDO
============================================================ */
// undo/redo only ever needs the notes; kept separate from the autosave
// snapshot, which is a different shape entirely
function undoSnapshot(){
return JSON.stringify({
notes: notes.map(n=>({id:n.id,pitch:n.pitch,start:n.start,dur:n.dur,conf:n.conf,
edited:n.edited,added:n.added,inferred:n.inferred,lyric:n.lyric})),
lyrics: lyrics.map(l=>({t:l.t, text:l.text})) // markers are editable too
});
}
function undoRestore(str){
const d = JSON.parse(str);
notes = d.notes || [];
lyrics = (d.lyrics || []).map(l=>({t:l.t, text:l.text}));
}
function pushUndo(){ undoStack.push(undoSnapshot()); if(undoStack.length>80) undoStack.shift(); redoStack.length=0; }
function commitUndo(snap){ undoStack.push(snap); if(undoStack.length>80) undoStack.shift(); redoStack.length=0; }
function resetUndo(){ undoStack=[]; redoStack=[]; }
function undo(){ if(!undoStack.length) return; redoStack.push(undoSnapshot()); undoRestore(undoStack.pop()); selectedIds=new Set(); afterEdit(); }
function redo(){ if(!redoStack.length) return; undoStack.push(undoSnapshot()); undoRestore(redoStack.pop()); selectedIds=new Set(); afterEdit(); }
/* ============================================================
EDITING
============================================================ */
const EDGE_PX=6;
function getNoteAt(px,py){
for(let i=notes.length-1;i>=0;i--){
const n=notes[i], x0=timeToX(n.start), x1=timeToX(n.start+n.dur);
const rowTop=geo.padT+(geo.hi-n.pitch)*geo.rowH, y0=rowTop+geo.gap, y1=y0+geo.bh;
if(px>=x0-2 && px<=x1+2 && py>=y0-2 && py<=y1+2){
const w = x1-x0;
const grab = Math.min(EDGE_PX, Math.max(3, w*0.4));
const mid = (x0+x1)/2;
const edgeL = w>4 && px<=x0+grab && px<mid;
const edge = w>4 && px>=x1-grab && px>=mid;
return {note:n, edge, edgeL};
}
}
return null;
}
function markEdited(n){ n.edited=true; n.conf=1; }
function selSize(){ return selectedIds.size; }
function updateToolbar(){
$(".js-undo").disabled = undoStack.length===0;
$(".js-redo").disabled = redoStack.length===0;
$(".js-merge").disabled = selSize()<2;
$(".js-split").disabled = selSize()!==1;
$(".js-delete").disabled= selSize()<1;
// nothing to save until a transcription has produced a stem
$(".js-dlstem").disabled = !audioBuffer;
}
function mergeSelected(){
const sel=notes.filter(n=>selectedIds.has(n.id)).sort((a,b)=>a.start-b.start);
if(sel.length<2) return; pushUndo();
const start=sel[0].start, end=Math.max(...sel.map(n=>n.start+n.dur));
const pitch=sel.slice().sort((a,b)=>b.dur-a.dur)[0].pitch;
notes=notes.filter(n=>!selectedIds.has(n.id));
const m={id:nid++,pitch,start,dur:end-start,conf:1,edited:true,added:true};
notes.push(m); notes.sort((a,b)=>a.start-b.start);
selectedIds=new Set([m.id]); afterEdit();
}
function splitSelected(){
const sel=notes.filter(n=>selectedIds.has(n.id)); if(sel.length!==1) return;
const n=sel[0], gap=Math.min(0.08,n.dur*0.25), half=(n.dur-gap)/2; if(half<0.03) return; pushUndo();
notes=notes.filter(x=>x.id!==n.id);
const a={id:nid++,pitch:n.pitch,start:n.start,dur:half,conf:1,edited:true,added:true};
const b={id:nid++,pitch:n.pitch,start:n.start+half+gap,dur:half,conf:1,edited:true,added:true};
notes.push(a,b); notes.sort((x,y)=>x.start-y.start); selectedIds=new Set([b.id]); afterEdit();
}
function deleteSelected(){ if(!selSize()) return; pushUndo(); notes=notes.filter(n=>!selectedIds.has(n.id)); selectedIds=new Set(); afterEdit(); }
function addNote(start,pitch,inferred){
pushUndo();
const dur=Math.min(0.4,Math.max(0.15,geo.span*0.04));
const n={id:nid++,pitch:clampPitch(pitch),start:Math.max(0,start),dur,conf:1,
edited:true,added:true,inferred:!!inferred};
notes.push(n); notes.sort((a,b)=>a.start-b.start); selectedIds=new Set([n.id]); afterEdit();
}
function afterEdit(){ refreshReadout(); updateToolbar(); draw(); markStale(); scheduleSave(); }
let dragActive=false, dragMode="move", dragOrig=null, resizeId=null, resizeDur0=0,
resizeEnd0=0, resizeStart0=0,
downP0=0, downT0=0, downX=0, downY=0, didDrag=false, pendingSeek=null, beforeSnap=null;
cv.addEventListener("pointerdown",e=>{
cv.setPointerCapture(e.pointerId);
focusPanel(); activeZone='roll';
const r=cv.getBoundingClientRect(), px=e.clientX-r.left, py=e.clientY-r.top;
downX=px; downY=py; didDrag=false;
const li = lyricsOn ? lyricAt(px,py) : -1;
if(li >= 0){ lyricDrag={i:li, mode:e.shiftKey?"anchor":"one"}; dragActive=false; return; }
const hit=getNoteAt(px,py);
if(hit){
pendingSeek=null; const n=hit.note;
if(e.shiftKey){ selectedIds.has(n.id)?selectedIds.delete(n.id):selectedIds.add(n.id); }
else if(!selectedIds.has(n.id)){ selectedIds=new Set([n.id]); }
beforeSnap=undoSnapshot(); dragActive=true;
if(hit.edge){ dragMode="resize"; resizeId=n.id; resizeDur0=n.dur; }
else if(hit.edgeL){ dragMode="resizeL"; resizeId=n.id; resizeDur0=n.dur;
resizeEnd0=n.start+n.dur; resizeStart0=n.start; }
else { dragMode="move"; downP0=yToPitch(py); downT0=xToTime(px);
dragOrig=notes.filter(m=>selectedIds.has(m.id)).map(m=>({id:m.id,pitch:m.pitch,start:m.start,added:m.added})); }
updateToolbar(); draw();
} else { pendingSeek={t:xToTime(px)}; }
});
cv.addEventListener("pointermove",e=>{
const r=cv.getBoundingClientRect(), px=e.clientX-r.left, py=e.clientY-r.top;
if(lyricDrag){
if(e.buttons===0){ lyricDrag=null; return; }
const newT = xToTime(px) + txStart;
if(lyricDrag.mode === "anchor"){ anchorLyricsTo(lyricDrag.i, newT); }
else {
lyrics[lyricDrag.i].t = Math.max(0, newT);
const moved = lyrics[lyricDrag.i];
lyrics.sort((a,b)=> a.t - b.t);
lyricDrag.i = lyrics.indexOf(moved);
}
draw(); drawWave(); return;
}
if(!dragActive && !pendingSeek){
// a merged lyric chip under the cursor -> reveal its words
const cl = clusterAt(px,py);
const tip = $(".js-lyrictip");
if(cl){
tip.textContent = cl.words.join(" \u00b7 ");
tip.classList.add("on");
const wrap=cv.parentElement, tw=tip.offsetWidth||120;
tip.style.left = Math.max(2, Math.min(cl.x, wrap.clientWidth - tw - 2)) + "px";
tip.style.top = Math.max(2, cl.y - (tip.offsetHeight||22) - 3) + "px";
cv.style.cursor = "help";
return;
} else if(tip.classList.contains("on")){ tip.classList.remove("on"); }
if(lyricsOn && lyricAt(px,py) >= 0){ cv.style.cursor="grab"; return; }
const h=getNoteAt(px,py);
cv.style.cursor = !h ? "crosshair" : ((h.edge||h.edgeL) ? "ew-resize" : (h.note.added ? "move" : "ns-resize"));
return;
}
if(e.buttons===0){ endDrag(); return; }
if(Math.abs(px-downX)>4 || Math.abs(py-downY)>4) didDrag=true;
if(!dragActive || !didDrag) return;
if(dragMode==="resize"){
const n=byId(resizeId); if(n) n.dur=Math.max(0.05, xToTime(px)-n.start); draw();
} else if(dragMode==="resizeL"){
const n=byId(resizeId);
if(n){ const newStart=Math.min(Math.max(0, xToTime(px)), resizeEnd0-0.05);
n.start=newStart; n.dur=resizeEnd0-newStart; }
draw();
} else {
const dp=yToPitch(py)-downP0, dt=xToTime(px)-downT0;
dragOrig.forEach(o=>{ const n=byId(o.id); if(!n) return;
n.pitch=clampPitch(o.pitch+dp);
if(o.added) n.start=Math.max(0,o.start+dt); });
draw();
}
});
cv.addEventListener("pointerup",endDrag);
cv.addEventListener("pointercancel",endDrag);
cv.addEventListener("pointerleave",()=>{ const t=$(".js-lyrictip"); if(t) t.classList.remove("on"); });
function endDrag(){
if(lyricDrag){ lyricDrag=null; markStale(); return; }
if(dragActive){
let changed=false;
if(dragMode==="resize"){ const n=byId(resizeId); if(n && Math.abs(n.dur-resizeDur0)>1e-6){ changed=true; commitUndo(beforeSnap); markEdited(n); } }
else if(dragMode==="resizeL"){ const n=byId(resizeId); if(n && Math.abs(n.start-resizeStart0)>1e-6){ changed=true; commitUndo(beforeSnap); markEdited(n); notes.sort((a,b)=>a.start-b.start); } }
else { changed=dragOrig.some(o=>{ const n=byId(o.id); return n && (n.pitch!==o.pitch || n.start!==o.start); });
if(changed){ commitUndo(beforeSnap); dragOrig.forEach(o=>{ const n=byId(o.id); if(n && (n.pitch!==o.pitch || n.start!==o.start)) markEdited(n); }); notes.sort((a,b)=>a.start-b.start); } }
if(changed) afterEdit();
if(!didDrag){ ph=xToTime(downX); playing?startPlayback():draw(); }
dragActive=false; dragOrig=null; resizeId=null; beforeSnap=null;
} else if(pendingSeek && !didDrag){ selectedIds=new Set(); ph=pendingSeek.t; updateToolbar(); playing?startPlayback():draw(); }
pendingSeek=null;
}
/* ============================================================
NOTE-COMPLETION HINTS
============================================================ */
function analysisSpan(n){
const i = notes.findIndex(x=>x.id===n.id);
let lo = 0, hi = Infinity;
if(i > 0) lo = notes[i-1].start + notes[i-1].dur;
if(i >= 0 && i < notes.length-1) hi = notes[i+1].start;
let a = Math.max(n.start, lo);
let b = Math.min(n.start + n.dur, hi);
if(!(b - a > 0.02)){
a = Math.max(lo, Math.min(n.start, hi - 0.03));
b = Math.min(hi, a + 0.05);
}
return [a, b];
}
function _frames(a, b){
const fps = chromaData.sr / chromaData.hop;
let f0 = Math.floor(a*fps), f1 = Math.ceil(b*fps) - 1;
if(f1 < f0) f1 = f0;
return [f0, f1];
}
function chromaOver(a, b){
if(!chromaData || !chromaData.nFrames) return null;
const [f0,f1] = _frames(a,b);
const acc = new Float64Array(12); let wsum = 0;
for(let k=f0; k<=f1; k++){
if(k<0 || k>=chromaData.nFrames) continue;
const w = chromaData.en ? (chromaData.en[k]/255) + 0.02 : 1;
for(let pc=0; pc<12; pc++) acc[pc] += chromaData.u8[k*12+pc] * w;
wsum += w;
}
if(!(wsum>0)) return null;
for(let pc=0; pc<12; pc++) acc[pc] /= wsum;
return acc;
}
function energyOver(a, b){
if(!chromaData || !chromaData.en) return null;
const [f0,f1] = _frames(a,b);
let sum = 0, cnt = 0;
for(let k=f0; k<=f1; k++){
if(k<0 || k>=chromaData.en.length) continue;
sum += chromaData.en[k]; cnt++;
}
return cnt ? (sum/cnt)/255 : null;
}
function pcPrior(exceptId){
const h = new Float64Array(12);
notes.forEach(n=>{ if(n.id===exceptId) return;
h[((Math.round(n.pitch)%12)+12)%12] += n.dur; });
const m = Math.max(...h);
if(m>0) for(let i=0;i<12;i++) h[i] /= m;
return h;
}
function nearestOctave(pc, ref){
let p = Math.round(ref/12)*12 + pc;
while(p-ref > 6) p -= 12;
while(ref-p > 6) p += 12;
return clampPitch(p);
}
function _simMatch(a, b){
const d = Math.abs(a-b);
return d===0 ? 1 : d===1 ? SIM_NEAR : 0;
}
function selfSimSuggestion(n){
const idx = notes.findIndex(x=>x.id===n.id);
if(idx < 0) return null;
const P = notes.map(x=>x.pitch), N = P.length;
let best = null;
for(let j=0; j<N; j++){
if(Math.abs(j-idx) <= SIM_K) continue;
for(let off=-SIM_OFF; off<=SIM_OFF; off++){
let hit = 0, tot = 0;
for(let k=1; k<=SIM_K; k++){
if(idx-k>=0 && j-k>=0){ tot++; hit += _simMatch(P[idx-k]+off, P[j-k]); }
if(idx+k< N && j+k< N){ tot++; hit += _simMatch(P[idx+k]+off, P[j+k]); }
}
if(tot < SIM_MIN_CTX) continue;
const sc = hit/tot;
if(sc < SIM_MIN) continue;
if(!best || sc > best.score ||
(sc === best.score && Math.abs(off) < Math.abs(best.off)))
best = {score:sc, off, at:notes[j].start, pitch:clampPitch(P[j]-off)};
}
}
return best;
}
function noteCandidates(n){
const [a,b] = analysisSpan(n);
const ch = chromaOver(a, b); if(!ch) return null;
const mx = Math.max(...ch); if(!(mx>0)) return null;
const prior = pcPrior(n.id);
let c = [];
for(let pc=0; pc<12; pc++) c.push({pc, s:ch[pc]/mx, pr:prior[pc]});
c.sort((x,y)=> y.s - x.s);
const peak = c[0].s>0 ? (c[0].s - c[1].s)/c[0].s : 0;
if(c.length>1 && (c[0].s - c[1].s) < TIE_MARGIN){
c.sort((x,y)=> (y.s + PRIOR_W*y.pr) - (x.s + PRIOR_W*x.pr));
}
const e = energyOver(a, b);
return {
energy: e, peak: peak,
verdict: (e!=null && e < E_SILENT) ? "silent"
: (peak < PEAK_FLAT) ? "flat"
: (e!=null && e < E_WEAK) ? "weak" : "clear",
list: c.slice(0,3).map(x=>({ pitch: nearestOctave(x.pc, n.pitch),
score: x.s, leans: x.pr > 0.15 }))
};
}
function showCandidates(px, py, n){
const pop = document.getElementById("candPop");
const r0 = noteCandidates(n);
const sim = selfSimSuggestion(n);
let html = "";
if(!r0){
html = `<div class="hd">no audio analysis for this take</div>`;
} else {
const V = {
clear : ['ok', `audio at ${n.start.toFixed(2)}s: clear`],
weak : ['warn',`audio at ${n.start.toFixed(2)}s: weak signal, treat with care`],
flat : ['warn',`audio at ${n.start.toFixed(2)}s: no clear pitch, don't trust this`],
silent: ['bad', `audio at ${n.start.toFixed(2)}s: near silence, there may be no note here`]
}[r0.verdict];
html += `<div class="hd v-${V[0]}">Excerpt ${KEY} \u00b7 ${V[1]}</div>`;
if(r0.verdict === "silent"){
html += `<div class="hd">chroma suppressed, nothing to measure</div>`;
} else {
html += r0.list.map(c=>
`<div class="cand" data-pitch="${c.pitch}">`+
`<b>${pName(c.pitch)}</b>`+
`<span class="bar" style="width:${Math.max(2,Math.round(c.score*54))}px"></span>`+
`<span class="why">${Math.round(c.score*100)}% chroma`+
`${c.leans?" \u00b7 frequent here":""}`+
`${c.pitch===clampPitch(n.pitch)?" \u00b7 current":""}</span></div>`).join("");
}
}
if(sim){
html += `<div class="rule"></div>`+
`<div class="cand" data-pitch="${sim.pitch}">`+
`<b>${pName(sim.pitch)}</b>`+
`<span class="why">\u27f3 same phrase at ${sim.at.toFixed(1)}s `+
`(${Math.round(sim.score*100)}% context match)</span></div>`;
}
html += `<div class="rule"></div><div class="hd">suggestions only, you decide</div>`;
pop.innerHTML = html;
pop.dataset.owner = KEY;
pop.dataset.note = String(n.id);
const r = cv.getBoundingClientRect();
pop.hidden = false;
const pw = pop.offsetWidth || 250, phh = pop.offsetHeight || 120;
let L = r.left + window.scrollX + px + 10, T = r.top + window.scrollY + py + 10;
if(L + pw > window.scrollX + document.documentElement.clientWidth) L -= pw + 20;
if(T + phh > window.scrollY + document.documentElement.clientHeight) T -= phh + 20;
pop.style.left = L + "px";
pop.style.top = T + "px";
}
/* ------------------------------------------------------------------
Per-note lyrics. The word lives on the note, so the alignment row
below needs no guessing: one note, one cell, one word. Typing a
lyric does NOT mark the note as edited, because "edited" means a
human verified the PITCH and that distinction matters in a
forensic reading of the transcription.
------------------------------------------------------------------ */
let lyricEditId = null;
// if a waveform marker sits on this note, offer its text as a starting point
function markerTextFor(n){
for(const l of lyrics){
const rt = l.t - txStart;
if(rt >= n.start - 0.02 && rt < n.start + n.dur) return l.text;
}
return "";
}
function closeLyricEditor(commit){
const el=$(".js-lyricin"), box=$(".js-lyricbox");
if(lyricEditId==null){ box.classList.remove("on"); return; }
const n=byId(lyricEditId), id=lyricEditId;
lyricEditId=null; box.classList.remove("on");
if(commit && n){
const v=el.value.trim();
if((n.lyric||"") !== v){ pushUndo(); n.lyric = v || undefined; afterEdit(); }
}
return id;
}
function openLyricEditor(id){
const n=byId(id); if(!n) return;
// bring the note into view first, so the box is never off screen
if(rollZoom>1){
const vd=rollViewDur();
if(n.start < geo.vs || n.start > geo.vs+vd*0.9){ rollVS=n.start-vd*0.25; rollClampVS(); draw(); }
}
const el=$(".js-lyricin"), box=$(".js-lyricbox"), wrap=cv.parentElement;
el.value = n.lyric || markerTextFor(n);
lyricEditId=id;
selectedIds=new Set([id]); draw();
// draw() has run, so geo is current: place the box just under the note,
// clamped to the visible area both ways (the box is ~150px wide, 26 tall).
box.classList.add("on");
const bw=box.offsetWidth||150, bh2=box.offsetHeight||26;
const nx=timeToX(n.start), ny=geo.padT+(geo.hi-n.pitch)*geo.rowH+geo.rowH+2;
box.style.left = Math.max(2, Math.min(nx, wrap.clientWidth - bw - 2)) + "px";
box.style.top = Math.max(2, Math.min(ny, wrap.clientHeight - bh2 - 2)) + "px";
el.focus(); el.select();
}
function nextNoteAfter(id){
const ordered=notes.slice().sort((a,b)=>a.start-b.start);
const i=ordered.findIndex(n=>n.id===id);
return (i>=0 && i<ordered.length-1) ? ordered[i+1].id : null;
}
// A pointerdown on either button must not let the input's blur fire first
// and commit/close before the button's own handler runs.
let suppressBlur=false;
[".js-lyricok",".js-lyricx"].forEach(sel=>{
$(sel).addEventListener("pointerdown", ()=>{ suppressBlur=true; });
});
$(".js-lyricin").addEventListener("keydown", e=>{
e.stopPropagation(); // never reaches the global shortcuts
if(e.key==="Enter"){
e.preventDefault();
const id=closeLyricEditor(true);
const nxt=e.shiftKey?null:nextNoteAfter(id);
if(nxt!=null) openLyricEditor(nxt); else draw();
} else if(e.key==="Escape"){
e.preventDefault(); closeLyricEditor(false); draw();
} else if(e.key==="Tab"){
e.preventDefault();
const id=closeLyricEditor(true);
const nxt=nextNoteAfter(id);
if(nxt!=null) openLyricEditor(nxt); else draw();
}
});
$(".js-lyricin").addEventListener("blur", ()=>{
if(suppressBlur){ suppressBlur=false; return; }
if(lyricEditId!=null){ closeLyricEditor(true); draw(); }
});
$(".js-lyricok").addEventListener("click", ()=>{
const id=closeLyricEditor(true);
const nxt=nextNoteAfter(id);
if(nxt!=null) openLyricEditor(nxt); else draw();
});
$(".js-lyricx").addEventListener("click", ()=>{ closeLyricEditor(false); draw(); });
function retuneTo(id, pitch){
const n = byId(id); if(!n) return;
if(clampPitch(n.pitch) === clampPitch(pitch)) return;
pushUndo();
n.pitch = clampPitch(pitch);
markEdited(n);
selectedIds = new Set([n.id]);
afterEdit();
}
cv.addEventListener("dblclick",e=>{
const r=cv.getBoundingClientRect(), px=e.clientX-r.left, py=e.clientY-r.top;
if(lyricsOn){ const li=lyricAt(px,py); if(li>=0){ splitLyric(li); draw(); drawWave(); return; } }
const hit=getNoteAt(px,py);
if(hit){ openLyricEditor(hit.note.id); return; } // a note carries its own word
if(!notes.length && !audioBuffer) return; // nothing loaded: don't invent notes
addNote(xToTime(px), yToPitch(py), true);
});
cv.addEventListener("contextmenu",e=>{
const r=cv.getBoundingClientRect(), px=e.clientX-r.left, py=e.clientY-r.top;
if(lyricsOn){ const li=lyricAt(px,py); if(li>=0){ e.preventDefault(); deleteLyric(li); draw(); drawWave(); return; } }
const hit=getNoteAt(px,py);
if(!hit) return;
e.preventDefault();
focusPanel();
showCandidates(px, py, hit.note);
});
/* ============================================================
SEGMENT SELECTOR
============================================================ */
function segDur(){ return segBuffer ? segBuffer.duration : 1; }
function segViewDur(){ return segDur()/segZoom; }
function segClampVS(){ segVS = Math.max(0, Math.min(segVS, Math.max(0, segDur()-segViewDur()))); }
function segW(){ return $(".js-segcanvaswrap").clientWidth; }
function segT2X(t){ return (t - segVS)/segViewDur()*segW(); }
function segX2T(px){ return segVS + (px/segW())*segViewDur(); }
function syncSegScroll(){
if(!segBuffer) return;
const W=segW();
$(".js-segspacer").style.width = Math.round(W*segZoom)+"px";
const sc=$(".js-segscroll");
const want=(segVS/segDur())*W*segZoom;
if(Math.abs(sc.scrollLeft-want) > 1) sc.scrollLeft = want;
$(".js-segzlabel").textContent = fmtWin(segViewDur());
}
function setSegZoom(z, anchorT){
if(!segBuffer) return;
const zMax = Math.max(1, segDur()/0.2); // stop at ~0.2 s across the view
z = Math.max(1, Math.min(z, zMax));
// Anchor stays put on screen rather than being recentred -- see setRollZoom.
const before = segViewDur();
const a = (anchorT==null) ? (segVS + before/2) : anchorT;
const frac = before > 0 ? (a - segVS) / before : 0.5;
segZoom = z;
segVS = a - frac * segViewDur();
segClampVS(); syncSegScroll(); drawWave(); updateSegUI(); drawSegPlayhead();
}
function fitSelection(){
if(!segBuffer) return;
const selDur = Math.max(0.2, segEnd-segStart);
setSegZoom(segDur()/(selDur*1.25), (segStart+segEnd)/2);
}
function drawWave(){
if(!segBuffer) return;
const c = $(".js-segwave"), wrap = $(".js-segcanvaswrap");
const W=wrap.clientWidth, H=wrap.clientHeight, dpr=window.devicePixelRatio||1;
c.width=W*dpr; c.height=H*dpr; const x=c.getContext("2d"); x.setTransform(dpr,0,0,dpr,0,0);
x.clearRect(0,0,W,H);
segClampVS();
const vs=segVS, vd=segViewDur(), sr=segBuffer.sampleRate;
const ch=segBuffer.getChannelData(0), N=ch.length, mid=H/2;
const spp=(vd/W)*sr; // samples behind one pixel column
const stride=Math.max(1, Math.floor(spp/512)); // cap the work per column
x.strokeStyle="#c9c9c3"; x.beginPath();
for(let i=0;i<W;i++){
const s0=Math.max(0, Math.floor((vs+(i/W)*vd)*sr));
const s1=Math.min(N, Math.max(s0+1, Math.floor(s0+spp)));
let mn=1, mx=-1;
for(let j=s0;j<s1;j+=stride){ const v=ch[j]||0; if(v<mn)mn=v; if(v>mx)mx=v; }
if(mn>mx){ mn=0; mx=0; }
x.moveTo(i, mid+mn*mid*0.9); x.lineTo(i, mid+mx*mid*0.9);
}
x.stroke();
/* second ticks, so a zoomed view still says where it is */
const STEPS=[0.05,0.1,0.25,0.5,1,2,5,10,15,30,60,120];
let gs=STEPS[STEPS.length-1];
for(const st of STEPS){ if(vd/st <= 12){ gs=st; break; } }
x.font='9px '+col("--sans"); x.textBaseline='bottom'; x.textAlign='left';
for(let t=Math.ceil(vs/gs)*gs; t<=vs+vd+1e-9; t+=gs){
const tx=segT2X(t);
if(tx<0 || tx>W) continue;
x.strokeStyle='rgba(0,0,0,.07)'; x.beginPath(); x.moveTo(tx,H-10); x.lineTo(tx,H); x.stroke();
x.fillStyle=col("--faint"); x.fillText(gs<1? t.toFixed(2) : t.toFixed(0), tx+2, H-1);
}
/* markers, in whole-song time on this same clock */
segLyricHit = [];
if(lyrics.length){
x.font='10px '+col("--sans"); x.textBaseline='top';
lyrics.forEach((l,i)=>{
const lx = segT2X(l.t);
if(lx < -30 || lx > W+30) return;
const inSeg = (l.t >= segStart && l.t <= segEnd);
x.strokeStyle = inSeg ? 'rgba(20,140,150,0.85)' : 'rgba(20,140,150,0.3)';
x.lineWidth = 1;
x.beginPath(); x.moveTo(lx,0); x.lineTo(lx,H); x.stroke();
const tw = x.measureText(l.text).width;
x.fillStyle = inSeg ? 'rgba(13,107,115,0.9)' : 'rgba(13,107,115,0.35)';
roundRectCtx(x, lx+1, 1, tw+6, 13, 3); x.fill();
x.fillStyle = '#fff';
x.fillText(l.text, lx+4, 2);
segLyricHit[i] = {x:lx+1, y:1, w:tw+6, h:13};
});
}
syncSegScroll();
}
function updateSegUI(){
if(!segBuffer) return;
const W=segW();
const xL=segT2X(segStart), xR=segT2X(segEnd);
const sel=$(".js-segsel");
sel.style.left=xL+"px"; sel.style.width=Math.max(0,xR-xL)+"px";
$(".js-segl").style.left=xL+"px";
$(".js-segr").style.left=xR+"px";
// a handle scrolled out of view must not look grabbable at the edge
$(".js-segl").style.visibility = (xL>=-6 && xL<=W+6) ? "visible" : "hidden";
$(".js-segr").style.visibility = (xR>=-6 && xR<=W+6) ? "visible" : "hidden";
$(".js-segtimes").textContent=
`${segStart.toFixed(2)} \u2013 ${segEnd.toFixed(2)} s (${(segEnd-segStart).toFixed(2)} s)`;
}
function segLyricAt(e){
const wrap=$(".js-segcanvaswrap"), r=wrap.getBoundingClientRect();
const px=e.clientX-r.left, py=e.clientY-r.top;
for(let i=0;i<segLyricHit.length;i++){
const b=segLyricHit[i]; if(!b) continue;
if(px>=b.x && px<=b.x+b.w && py>=b.y && py<=b.y+b.h) return i;
}
return -1;
}
(function segMarkers(){
const wrap=$(".js-segcanvaswrap");
wrap.addEventListener("contextmenu", e=>{
if(!segBuffer) return;
e.preventDefault(); focusPanel();
const i = segLyricAt(e);
if(i >= 0){
const t = prompt("Marker text (clear to delete):", lyrics[i].text);
if(t === null) return;
if(!t.trim()) deleteLyric(i); else lyrics[i].text = t.trim();
} else {
const r = wrap.getBoundingClientRect();
const t = Math.max(0, Math.min(segBuffer.duration,
(e.clientX-r.left)/r.width*segBuffer.duration));
const txt = prompt("Marker text at " + t.toFixed(2) + "s:", "");
if(txt === null || !txt.trim()) return;
addLyric(t, txt.trim());
lyricsOn = true; syncLyricsUI();
}
drawWave(); draw(); markStale();
});
})();
(function segDrag(){
const wrap=$(".js-segcanvaswrap");
let mode=null, markIdx=-1;
const xToT=(clientX)=>{ const r=wrap.getBoundingClientRect();
return Math.max(0, Math.min(segBuffer.duration, segX2T(clientX-r.left))); };
// px tolerance for grabbing an edge or the playhead, in TIME units
const grabT=()=> (segViewDur()/segW())*7;
function down(e){
if(!segBuffer) return;
if(e.button !== 0) return;
focusPanel(); activeZone='seg';
const mi = segLyricAt(e);
if(mi >= 0){ mode="mark"; markIdx=mi; e.preventDefault(); return; }
const t=xToT(e.clientX), tol=grabT();
const onL = e.target.classList.contains("js-segl") || Math.abs(t-segStart)<=tol;
const onR = e.target.classList.contains("js-segr") || Math.abs(t-segEnd)<=tol;
// an edge grab wins; the playhead is next; a bare click inside the
// selection seeks and plays from there.
if(onL && (!onR || Math.abs(t-segStart)<=Math.abs(t-segEnd))) mode="L";
else if(onR) mode="R";
else if(segPlayT!=null && Math.abs(t-segPlayT)<=tol) mode="scrub";
else if(t>=segStart-tol && t<=segEnd+tol){ mode="scrub"; seekSegTo(t); }
else mode = (Math.abs(t-segStart)<Math.abs(t-segEnd)) ? "L" : "R";
if(mode==="L"||mode==="R") move(e);
e.preventDefault();
}
function move(e){
if(!mode||!segBuffer) return;
const t=xToT(e.clientX);
if(mode==="mark"){
if(markIdx<0 || markIdx>=lyrics.length) return;
const moved = lyrics[markIdx];
moved.t = Math.max(0, Math.min(segBuffer.duration, t));
lyrics.sort((a,b)=> a.t - b.t);
markIdx = lyrics.indexOf(moved);
drawWave(); draw();
return;
}
if(mode==="scrub"){ seekSegTo(t); return; }
if(mode==="L") segStart=Math.min(t, segEnd-0.2);
else segEnd=Math.max(t, segStart+0.2);
segStart=Math.max(0,segStart); segEnd=Math.min(segBuffer.duration,segEnd);
// keep the playhead inside the selection it belongs to
if(segPlayT!=null){ segPlayT=Math.max(segStart,Math.min(segEnd,segPlayT)); drawSegPlayhead(); }
updateSegUI(); drawWave();
}
function up(e){
// a click that placed the playhead (no real drag) starts playback from it
if(mode==="scrub" && segPlayT!=null && !segPlaying) playRange(segPlayT, segEnd);
mode=null; markIdx=-1;
}
wrap.addEventListener("mousedown",down);
window.addEventListener("mousemove",move);
window.addEventListener("mouseup",up);
})();
// draw the preview playhead wherever it currently sits, in or out of view
function drawSegPlayhead(){
const head=$(".js-segph");
if(segPlayT==null){ head.hidden=true; return; }
const hx=segT2X(segPlayT);
head.hidden = (hx < 0 || hx > segW());
head.style.left=hx+"px";
}
function stopSegPreview(keepHead){
if(segSrc){try{segSrc.stop()}catch(e){} segSrc=null;}
cancelAnimationFrame(segRAF);
segPlaying=false;
if(!keepHead){ segPlayT=null; $(".js-segph").hidden=true; }
else drawSegPlayhead();
}
// Play from `a` to `b`, and leave a scrubbable playhead behind when it stops.
// `endStop` marks the natural end (drop the head) versus a manual stop (keep).
function playRange(a,b){
stopEveryPanel(null);
ensureAC(); if(ac.state==="suspended") ac.resume();
stopSegPreview(true);
segSrc=ac.createBufferSource(); segSrc.buffer=segBuffer; segSrc.connect(ac.destination);
const tt0=ac.currentTime+0.05; segSrc.start(tt0, a, Math.max(0.05,b-a));
segPlaying=true;
const head=$(".js-segph"); head.hidden=false;
(function tick(){
const now=ac.currentTime-tt0+a;
if(now>=b){ segPlayT=b; segPlaying=false; stopSegPreview(false); return; }
segPlayT=now; drawSegPlayhead();
segRAF=requestAnimationFrame(tick);
})();
}
function seekSegTo(t){
segPlayT = Math.max(0, Math.min(segBuffer.duration, t));
if(segPlaying) stopSegPreview(true); // moving the head interrupts playback
drawSegPlayhead();
}
// Mono 16-bit WAV from any AudioBuffer range. Shared by the segment crop that
// goes to the backend and by the stem download, so both write the same format.
function bufferToWav(buffer, from, to){
const sr=buffer.sampleRate, a=Math.floor(from*sr), b=Math.floor(to*sr), n=Math.max(0,b-a);
const ch0=buffer.getChannelData(0);
const ch1=buffer.numberOfChannels>1?buffer.getChannelData(1):null;
const buf=new ArrayBuffer(44+n*2), view=new DataView(buf);
const wr=(o,s)=>{ for(let i=0;i<s.length;i++) view.setUint8(o+i,s.charCodeAt(i)); };
wr(0,"RIFF"); view.setUint32(4,36+n*2,true); wr(8,"WAVE"); wr(12,"fmt ");
view.setUint32(16,16,true); view.setUint16(20,1,true); view.setUint16(22,1,true);
view.setUint32(24,sr,true); view.setUint32(28,sr*2,true); view.setUint16(32,2,true);
view.setUint16(34,16,true); wr(36,"data"); view.setUint32(40,n*2,true);
let o=44;
for(let i=0;i<n;i++){ let s=ch0[a+i]||0; if(ch1) s=(s+(ch1[a+i]||0))*0.5;
s=Math.max(-1,Math.min(1,s)); view.setInt16(o, s<0?s*0x8000:s*0x7FFF, true); o+=2; }
return new Blob([buf],{type:"audio/wav"});
}
function cropToWav(){ return bufferToWav(segBuffer, segStart, segEnd); }
/* ---- extraction helpers, driven by the shared pair coordinator ---- */
function pairReady(){ return !!segBuffer && (segEnd - segStart) > 0.05; }
// Returns the cropped file, or null if the user backed out.
function pairPrepare(){
if(!pairReady()) return null;
const hasEdits = notes.some(n=>n.edited||n.added);
if(hasEdits){
const ok = confirm(`Transcribing will replace excerpt ${KEY}'s transcription, `
+ "including your manual edits. Continue?");
if(!ok){ sharedStatus().textContent="Cancelled, your current transcription is kept."; return null; }
}
// Markers outside the chosen segment have nothing left to annotate once the
// excerpt is cut, so drop them. The survivors keep their whole-song times.
const kept = lyrics.filter(l => l.t >= segStart && l.t <= segEnd);
if(kept.length !== lyrics.length) lyrics = kept;
$(".js-inst").disabled = true;
const wav = cropToWav();
const fname = segFileName.replace(/\.[^.]+$/,"")
+ `_seg_${segStart.toFixed(1)}-${segEnd.toFixed(1)}.wav`;
return { file: new File([wav], fname, {type:"audio/wav"}),
instrument: currentInstrument() };
}
async function pairApply(data){
notes = withIds(parseMidi(base64ToArrayBuffer(data.midi)));
resetUndo(); selectedIds = new Set();
ensureNodes();
audioBuffer = await ac.decodeAudioData(base64ToArrayBuffer(data.vocal));
chromaData = data.chroma ? {
u8: new Uint8Array(base64ToArrayBuffer(data.chroma.data)),
en: data.chroma.energy ? new Uint8Array(base64ToArrayBuffer(data.chroma.energy)) : null,
nFrames: data.chroma.n_frames, sr: data.chroma.sr, hop: data.chroma.hop
} : null;
ph = 0; stopPlayback(true); update();
txStart = segStart; // these notes belong to THIS crop
rollZoom = 1; rollVS = 0; // a new transcription starts unzoomed
// the shared timer (owned by the coordinator) keeps running until the
// OTHER excerpt also finishes; this panel just records its own message
lastDoneMsg =
`Done: transcribed ${segStart.toFixed(1)}\u2013${segEnd.toFixed(1)}s `
+ `(${(segEnd-segStart).toFixed(1)}s). `
+ `Not right? Drag the handles to adjust and transcribe again.`;
$(".js-stage").hidden = false;
refreshReadout(); drawWave(); draw();
await analyseKey(); // key estimate is ready before you look for it
}
/* ---- stem save/reload: loaded into audioBuffer only, never segBuffer ---- */
function downloadStem(){
if(!audioBuffer) return;
const blob = bufferToWav(audioBuffer, 0, audioBuffer.duration);
const base = (loadedName || "excerpt").replace(/\.[^.]+$/, "");
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${base}_${KEY}_stem.wav`;
a.click(); URL.revokeObjectURL(a.href);
}
async function loadStem(f){
ensureAC();
try{ audioBuffer = await ac.decodeAudioData(await f.arrayBuffer()); }
catch{ $(".js-sub").textContent = "\u26a0 could not decode " + f.name; return false; }
ph = 0; stopPlayback(true);
$(".js-stage").hidden = false;
refreshReadout(); draw();
$(".js-sub").textContent = `${loadedName || f.name} \u00b7 ${notes.length} notes `
+ `\u00b7 stem loaded (${audioBuffer.duration.toFixed(1)}s)`;
return true;
}
/* ---- import a .mid, skipping audio and transcription ---- */
async function importMidi(f){
let parsed;
try{ parsed = parseMidi(await f.arrayBuffer()); }
catch(err){ $(".js-sub").textContent = "\u26a0 could not read " + f.name; return false; }
if(!parsed.length){ $(".js-sub").textContent = "\u26a0 no notes in " + f.name; return false; }
// Audio and notes are separate things: importing MIDI replaces where the
// notes came from, never the loaded audio. Only ask before discarding a
// transcription that already exists, since that is the part with work in it.
if(notes.length){
const ok = confirm(`Replace excerpt ${KEY}'s ${notes.length} notes with `
+ `${f.name}?` + (segBuffer ? " The loaded audio is kept." : ""));
if(!ok) return false;
}
const keptAudio = !!segBuffer;
notes = withIds(parsed);
// a file exported from here carries its markers; anything else simply has none
const carried = parsed.lyrics || [];
chromaData = null; // hints belong to the old transcription
if(!keptAudio){
// nothing to align against, so the notes define their own timeline
audioBuffer = null;
loadedName = f.name; segFileName = f.name; restoredName = "";
txStart = 0; segStart = 0;
segEnd = Math.max(...notes.map(n=>n.start+n.dur), 1);
}
else{
// An imported .mid starts at 0, like a fresh transcription of this segment,
// so it is read against segStart -- otherwise notes and markers shift apart.
txStart = segStart;
}
// The segment bounds, waveform and preview are otherwise untouched.
// Markers come back only now, because txStart is only settled above and the
// file stores them segment-relative while the editor keeps whole-song time.
lyrics = carried.map(l=>({t: l.t + txStart, text: l.text}));
lyricsOn = lyrics.length > 0;
resetUndo(); selectedIds = new Set();
rollZoom = 1; rollVS = 0;
analysis = null;
$(".js-segwrap").hidden = !keptAudio;
$(".js-stage").hidden = false;
syncLyricsUI(); refreshReadout(); updateToolbar(); drawWave(); draw();
$(".js-sub").textContent = `${f.name} \u00b7 ${notes.length} notes imported`
+ (lyrics.length ? `, ${lyrics.length} lyric markers` : "")
+ (keptAudio ? " (audio kept)" : " (no audio)");
await analyseKey(); // key estimate runs on notes, no audio needed
refreshPmiState(); scheduleSave();
return true;
}
/* ---- autosave snapshot: notes and markers only; audio is far too large ---- */
function snapshot(){
if(!notes.length && !lyrics.length) return null;
return {
notes: notes.map(n=>({pitch:n.pitch, start:n.start, dur:n.dur, conf:n.conf,
edited:n.edited, added:n.added, inferred:n.inferred})),
lyrics: lyrics.map(l=>({t:l.t, text:l.text})),
lyricsOn, segStart, segEnd, txStart,
fileName: loadedName || segFileName || "",
instrument: currentInstrument(),
analysis, tonic: $(".js-tonic").value, mode: $(".js-mode").value
};
}
function restore(d){
if(!d) return;
notes = withIds((d.notes||[]).map(n=>({...n})));
lyrics = (d.lyrics||[]).map(l=>({t:l.t, text:l.text}));
lyricsOn = !!d.lyricsOn;
segStart = d.segStart ?? 0;
segEnd = d.segEnd ?? 20;
txStart = d.txStart ?? segStart;
analysis = d.analysis || null;
if(d.instrument) $(".js-inst").value = d.instrument;
if(d.tonic) $(".js-tonic").value = d.tonic;
if(d.mode) $(".js-mode").value = d.mode;
resetUndo(); selectedIds = new Set();
rollZoom = 1; rollVS = 0;
// No audio: the roll and toolbars work, playback and the audio-based
// completion hints stay unavailable until a file is loaded again.
audioBuffer = null; chromaData = null;
restoredName = d.fileName || "";
$(".js-stage").hidden = false;
// The segment panel stays hidden until audio exists; say so where the file
// status normally lives, so the missing piece is visible per excerpt.
$(".js-sub").textContent = (restoredName ? restoredName + " \u00b7 " : "")
+ `${notes.length} notes restored \u2014 audio not loaded`;
if(analysis) renderKey(analysis);
syncLyricsUI(); refreshReadout(); updateToolbar(); draw();
}
function pairFail(msg){ lastDoneMsg = ""; }
function pairEnd(){ $(".js-inst").disabled = false; }
function pairNote(msg){ sharedStatus().textContent = msg; }
/* ============================================================
KEY ESTIMATION (per excerpt)
============================================================ */
function midiBytes(withLyrics){
// Markers are stored in whole-song time; the notes are segment-relative, so
// rebase them onto the same clock before they go into the file.
const ly = withLyrics ? lyrics.map(l=>({t: l.t - txStart, text: l.text}))
.filter(l=>l.t >= -0.5) : null;
return notesToMidi(notes, 480, 120, ly);
}
// Shared by analyseKey and by restore(), so a recovered session shows its key
// estimate without having to re-run the analysis request.
function renderKey(a){
if(!a || !a.key) return;
const cEl = $(".js-keycand");
$(".js-tonic").value = a.key.tonic;
$(".js-mode").value = a.key.mode;
const btns=(a.candidates||[]).map(c=>
`<button class="btn sm js-pick" data-tonic="${c.tonic}" data-mode="${c.mode}">`
+ `${c.name} <b>(${c.n_out_of_key} acc.)</b></button>`).join(" ");
cEl.innerHTML=`auto: <b>${a.key.name}</b> &nbsp; pick: ${btns} `
+ `&nbsp;<span class="note-txt">(or use \u201ccompare keys\u201d to hear each)</span>`;
}
async function analyseKey(){
if(!notes.length) return;
const cEl = $(".js-keycand");
cEl.textContent = "estimating key\u2026";
try{
const fd=new FormData();
fd.append("midi", new Blob([midiBytes()],{type:"audio/midi"}), "song.mid");
const res=await fetch(ANALYZE_URL,{method:"POST",body:fd});
const a=await res.json();
if(!res.ok||a.error) throw new Error(a.error||("HTTP "+res.status));
analysis=a;
renderKey(a);
}catch(err){
cEl.textContent="key analysis failed: "+err.message;
}
refreshPmiState();
}
function playPCs(pcs){
stopEveryPanel(null);
ensureAC(); if(ac.state==="suspended") ac.resume();
voices.forEach(v=>{try{v.stop()}catch(e){}}); voices=[];
const base=60, step=0.42, t=ac.currentTime+0.06;
pcs.forEach((pc,i)=>{ tone(midiToFreq(base+pc), t+i*step, t+i*step+step*0.9, gPmi, "triangle", false, 0.5, voices); });
}
async function compareKeys(){
if(!analysis || !analysis.candidates || !analysis.candidates.length) return;
const cands=analysis.candidates, btn=$(".js-listen");
btn.disabled=true; const old=btn.innerHTML;
for(let i=0;i<cands.length;i++){
btn.textContent=`\u266a ${cands[i].name} (${cands[i].n_out_of_key} acc.)`;
playPCs(cands[i].transposed);
await new Promise(r=>setTimeout(r,(cands[i].transposed.length*0.42+0.5)*1000));
if(i<cands.length-1) await new Promise(r=>setTimeout(r,400));
}
btn.disabled=false; btn.innerHTML=old;
}
function chosenKey(){
return { tonic: $(".js-tonic").value, mode: $(".js-mode").value };
}
/* ---- lyrics for the alignment grid -------------------------------------
One note, one column, one word. Nothing is inferred, so there is nothing
to mis-place. Display only; never reaches the PMI. --------------------- */
function lyricColumns(){
const ordered = notes.slice().sort((a,b)=>a.start-b.start);
const cells = ordered.map(n => n.lyric || "");
return { cells, filled: cells.filter(Boolean).length };
}
/* ============================================================
WIRING
============================================================ */
function refreshReadout(){
$(".js-cnotes").textContent=notes.length;
$(".js-cedit").textContent=notes.filter(n=>n.edited).length;
$(".js-sub").textContent = loadedName
? loadedName + (notes.length ? ` \u00b7 ${notes.length} notes` : " \u00b7 not transcribed yet")
: "no audio loaded";
}
function update(){ refreshReadout(); updateToolbar(); draw(); refreshPmiState(); }
function focusPanel(){
activePanel = api;
document.querySelectorAll(".excerpt").forEach(el=>el.classList.toggle("active", el===root));
}
root.addEventListener("pointerdown", focusPanel, true);
// fill tonic dropdown
NOTE_NAMES.forEach((nm,i)=>{
const o=document.createElement("option"); o.value=i; o.textContent=nm; $(".js-tonic").appendChild(o);
});
$(".js-play").onclick = ()=>{ playing?stopPlayback(false):startPlayback(); };
[".js-volaudio",".js-volmidi"].forEach(s=>$(s).addEventListener("input",applyVolumes));
const wire=(sel,fn)=>{ const b=$(sel); b.onclick=()=>{ fn(); b.blur(); }; };
wire(".js-undo",undo); wire(".js-redo",redo);
wire(".js-merge",mergeSelected); wire(".js-split",splitSelected); wire(".js-delete",deleteSelected);
wire(".js-dlstem", downloadStem);
wire(".js-download",()=>{
const blob=new Blob([midiBytes(true)], {type:"audio/midi"});
const url=URL.createObjectURL(blob), a=document.createElement("a");
a.href=url; a.download=`excerpt_${KEY}.mid`; a.click();
setTimeout(()=>URL.revokeObjectURL(url), 1000);
});
wire(".js-lyrics",toggleLyrics);
wire(".js-lrcimport",()=>$(".js-lrcfile").click());
wire(".js-listen",compareKeys);
wire(".js-reanalyse",analyseKey);
$(".js-lrcfile").addEventListener("change",e=>{
const f=e.target.files[0]; e.target.value=""; if(!f) return;
const rd=new FileReader();
rd.onload=()=>{ lyrics=parseLRC(String(rd.result||"")); lyricsOn=true; syncLyricsUI(); drawWave(); draw(); markStale(); };
rd.readAsText(f);
});
$(".js-keycand").addEventListener("click",e=>{
const b=e.target.closest(".js-pick"); if(!b) return;
$(".js-tonic").value=b.dataset.tonic;
$(".js-mode").value=b.dataset.mode;
markStale();
});
[".js-tonic",".js-mode"].forEach(s=>$(s).addEventListener("change",markStale));
$(".js-inst").addEventListener("change",syncInstrumentUI);
$(".js-segpreview").addEventListener("click",()=>playRange(segStart,segEnd));
$(".js-segfull").addEventListener("click",()=>{ if(segBuffer) playRange(0,segBuffer.duration); });
$(".js-segcanvaswrap").addEventListener("pointerdown",()=>{ activeZone='seg'; },true);
/* ---- zoom + pan: waveform ---- */
$(".js-segzin").addEventListener("click",()=>setSegZoom(segZoom*ZOOM_STEP));
$(".js-segzout").addEventListener("click",()=>setSegZoom(segZoom/ZOOM_STEP));
$(".js-segfit").addEventListener("click",fitSelection);
$(".js-segall").addEventListener("click",()=>setSegZoom(1));
$(".js-segscroll").addEventListener("scroll",()=>{
if(!segBuffer) return;
const W=segW(); if(!W) return;
segVS=($(".js-segscroll").scrollLeft/(W*segZoom))*segDur();
segClampVS(); drawWave(); updateSegUI();
});
// Plain wheel zooms. Ctrl+wheel is claimed by browser page zoom on several
// setups, which is why the modifier version looked like it did nothing.
$(".js-segcanvaswrap").addEventListener("wheel",e=>{
if(!segBuffer) return;
const horiz = Math.abs(e.deltaX) > Math.abs(e.deltaY);
const d = horiz ? e.deltaX : e.deltaY;
if(!d) return;
e.preventDefault();
if(e.shiftKey || horiz){
segVS += (d>0?1:-1)*segViewDur()*0.12;
segClampVS(); syncSegScroll(); drawWave(); updateSegUI(); drawSegPlayhead();
} else {
const r=e.currentTarget.getBoundingClientRect();
setSegZoom(segZoom*(d<0?ZOOM_STEP:1/ZOOM_STEP), segX2T(e.clientX-r.left));
}
},{passive:false});
/* ---- zoom + pan: piano roll ---- */
$(".js-rollzin").addEventListener("click",()=>setRollZoom(rollZoom*ZOOM_STEP));
$(".js-rollzout").addEventListener("click",()=>setRollZoom(rollZoom/ZOOM_STEP));
$(".js-rollall").addEventListener("click",()=>setRollZoom(1));
$(".js-rollscroll").addEventListener("scroll",()=>{
const W=cv.parentElement.clientWidth; if(!W) return;
rollVS=($(".js-rollscroll").scrollLeft/(W*rollZoom))*spanSec();
rollClampVS(); draw();
});
cv.addEventListener("wheel",e=>{
if(!notes.length) return;
const horiz = Math.abs(e.deltaX) > Math.abs(e.deltaY);
const d = horiz ? e.deltaX : e.deltaY;
if(!d) return;
e.preventDefault();
if(e.shiftKey || horiz){
rollVS += (d>0?1:-1)*rollViewDur()*0.12;
rollClampVS(); draw();
} else {
const r=cv.getBoundingClientRect();
setRollZoom(rollZoom*(d<0?ZOOM_STEP:1/ZOOM_STEP), xToTime(e.clientX-r.left));
}
},{passive:false});
/* file slot: click + drag-and-drop */
/* Loading is driven by the shared uploader above both excerpts, so that one
drop can fill A and B at once. The panel just exposes how to take a file. */
async function loadFile(f){
ensureAC();
const sub = $(".js-sub");
sub.textContent = f.name + " (decoding\u2026)";
let buf;
try{ buf = await ac.decodeAudioData(await f.arrayBuffer()); }
catch{ sub.textContent = "\u26a0 could not decode " + f.name; return false; }
// Re-attaching audio to restored notes must not reset the bounds, or the
// notes would describe a stretch nobody selected.
const reattaching = notes.length > 0 && !segBuffer;
segBuffer = buf;
segFileName = f.name; loadedName = f.name;
if(reattaching){
// Clamp in case a different (shorter) file was supplied; the bounds are
// otherwise left exactly as they were recovered.
segEnd = Math.min(segEnd, segBuffer.duration);
segStart = Math.max(0, Math.min(segStart, segEnd - 0.05));
$(".js-segwrap").hidden = false;
// markers restored from an autosave only become visible once the waveform
// they sit on exists, so redraw them along with it
syncLyricsUI(); drawWave(); updateSegUI(); refreshReadout();
reattachedName = f.name;
return true;
}
refreshReadout();
segStart=0; segEnd=Math.min(segBuffer.duration,20);
segZoom=1; segVS=0; rollZoom=1; rollVS=0;
$(".js-segwrap").hidden=false;
if(!BACKEND_URL){ audioBuffer=segBuffer; txStart=0; ph=0; $(".js-stage").hidden=false; stopPlayback(true); update(); }
drawWave(); updateSegUI();
return true;
}
$(".js-stemfile").addEventListener("change", e=>{
const f = e.target.files && e.target.files[0];
e.target.value = "";
if(f) loadStem(f);
});
$(".js-midfile").addEventListener("change", e=>{
const f = e.target.files && e.target.files[0];
e.target.value = ""; // copy the File out first: see the uploader
if(f) importMidi(f);
});
syncInstrumentUI(); syncLyricsUI(); update();
/* ---- public surface used by the PMI section and global handlers ---- */
const api = {
key: KEY,
hasNotes: ()=> notes.length>0,
rollImage(){ const c = $(".js-roll");
return { url: c.toDataURL("image/png"), w: c.width, h: c.height }; },
fileName: ()=> loadedName || segFileName || "",
keyName(){
const k = chosenKey();
if(k.tonic === "" || k.tonic == null) return "";
return `${NOTE_NAMES[((+k.tonic)%12+12)%12]} ${k.mode}`;
},
segmentText(){ return segBuffer ? `${segStart.toFixed(2)}\u2013${segEnd.toFixed(2)} s` : "\u2014"; },
// Used only while the report image is taken: the editor itself never
// colours matched notes, so what is on screen stays a plain transcription
// and the red means one thing in one place -- the report.
rollImageWithMatches(flags){
// Zoom out for the capture as well: the roll only draws what is inside
// the visible window, so a zoomed-in editor would otherwise put a partial
// excerpt into the report without saying so. Both are restored after.
const z = rollZoom, v = rollVS;
matchFlags = flags; rollZoom = 1; rollVS = 0; draw();
const c = $(".js-roll"), img = { url: c.toDataURL("image/png"), w: c.width, h: c.height };
matchFlags = null; rollZoom = z; rollVS = v; draw();
return img;
},
pairReady, pairPrepare, pairApply, pairFail, pairEnd, pairNote,
doneMsg: ()=> lastDoneMsg,
snapshot, restore, loadFile,
hasAudio: ()=> !!segBuffer,
needsAudio: ()=> notes.length > 0 && !segBuffer,
restoredName: ()=> restoredName,
reattachedName: ()=> reattachedName,
segDuration: ()=> segEnd - segStart,
noteCount: ()=> notes.length,
midiBytes,
chosenKey,
lyricColumns,
analysis: ()=> analysis,
stopAll(){ if(playing) stopPlayback(false); stopSegPreview(); stopSources(); },
onResize(){ if(segBuffer){ syncSegScroll(); drawWave(); updateSegUI(); drawSegPlayhead(); } syncRollScroll(); draw(); },
keyDown(e){
const mod=e.metaKey||e.ctrlKey;
if(mod && e.key.toLowerCase()==="z"){ e.preventDefault(); e.shiftKey?redo():undo(); return true; }
if(mod && e.key.toLowerCase()==="y"){ e.preventDefault(); redo(); return true; }
if(e.code==="Space"){
e.preventDefault();
const segVisible = !$(".js-segwrap").hidden;
if(activeZone==='seg' && segVisible){
if(segPlaying) stopSegPreview(true);
else playRange(segPlayT!=null ? segPlayT : segStart, segEnd);
}
else { if(segSrc) stopSegPreview(); playing ? stopPlayback(false) : startPlayback(); }
return true;
}
if((e.key==="Delete"||e.key==="Backspace") && selSize()){ e.preventDefault(); deleteSelected(); return true; }
if((e.key==="ArrowUp"||e.key==="ArrowDown") && selSize()){
e.preventDefault(); pushUndo();
const d=(e.key==="ArrowUp"?1:-1)*(e.shiftKey?12:1);
notes.forEach(n=>{ if(selectedIds.has(n.id)){ n.pitch=clampPitch(n.pitch+d); markEdited(n); } });
afterEdit(); return true;
}
return false;
},
retuneTo
};
PANELS[KEY]=api;
return api;
}
/* ============================================================
GLOBAL COORDINATION
============================================================ */
/* Both excerpts are transcribed in ONE backend call. ZeroGPU limits the daily
number of runs as well as the seconds, and a run is counted per GPU entry
whatever its length -- so two separate extractions spent two runs to answer
the one question PMI asks. Requiring both excerpts up front makes the pair
the only path, which halves the run count and pays the fixed GPU overhead
(fork, CUDA attach, weight streaming) once instead of twice. */
let pairBusy = false;
// The shared button clears instead of transcribing once both excerpts hold
// notes, however they arrived -- transcribed, imported or restored.
function bothHaveNotes(){
const A = PANELS.A, B = PANELS.B;
return !!(A && B && A.hasNotes() && B.hasNotes());
}
function syncPairButton(){
const btn = document.getElementById("btnTranscribePair");
if(!btn || pairBusy) return;
const done = bothHaveNotes();
btn.textContent = done ? "Clear & start over" : "Transcribe both excerpts \u2192";
btn.classList.toggle("danger", done);
btn.classList.toggle("dl", !done);
btn.classList.toggle("lg", !done);
}
async function transcribeBoth(){
const btn = document.getElementById("btnTranscribePair");
if(bothHaveNotes()){
if(!confirm("Clear both excerpts and start over?")) return;
resetPanel("A"); resetPanel("B");
clearSnapshot();
document.getElementById("dualName").textContent =
"drop both full mixes here, or click to pick two files";
sharedStatus().textContent = "";
syncPairButton();
return;
}
const A = PANELS.A, B = PANELS.B;
if(!A || !B || pairBusy) return;
// Both excerpts must be loaded and have a segment: there is no cheaper
// single-excerpt path to fall back to, so say plainly what is missing.
// Checked before the button locks, so a missing excerpt never leaves it stuck.
const missing = [A,B].filter(p=>!p.pairReady()).map(p=>p.key);
if(missing.length){
sharedStatus().textContent = missing.length === 2
? "Load both excerpts and choose a segment in each, then transcribe."
: `Excerpt ${missing[0]} still needs an audio file and a segment.`;
return;
}
btn.disabled = true;
const pa = A.pairPrepare();
if(!pa){ btn.disabled = false; return; } // user cancelled on A
const pb = B.pairPrepare();
if(!pb){ A.pairEnd(); btn.disabled = false;
sharedStatus().textContent = "Cancelled."; return; } // undo A's lock
pairBusy = true;
// ONE timer for the whole call, sized on both clips together -- estimating
// per panel and starting two independent intervals against the same status
// line made the display flicker between A's and B's numbers every 120 ms.
const durA = A.segDuration(), durB = B.segDuration();
startSharedTimer(Math.round((durA+durB)*0.25+10), Math.round((durA+durB)*0.45+18));
try{
const client = await gradioClient();
const mod = await gradioMod();
const result = await client.predict("/transcribe_pair", {
audio_a: mod.handle_file(pa.file), audio_b: mod.handle_file(pb.file),
instrument_a: pa.instrument, instrument_b: pb.instrument });
const data = result.data[0];
await A.pairApply(data.a);
await B.pairApply(data.b);
stopSharedTimer();
sharedStatus().textContent = `A: ${A.doneMsg()} | B: ${B.doneMsg()}`;
writeSnapshot(); // a fresh transcription is worth persisting at once
}catch(err){
stopSharedTimer();
sharedStatus().textContent = "\u26a0 " + err.message;
}finally{
pairBusy = false; A.pairEnd(); B.pairEnd(); btn.disabled = false;
}
}
/* ---- PDF report: jsPDF keeps the text selectable; only the rolls are images ---- */
// jsdelivr first: it already serves the Gradio client to this page, so it is the
// one CDN known to be reachable here. The others are fallbacks in case a network
// blocks it. A pinned version keeps the API stable.
const JSPDF_CDNS = [
"https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js",
"https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
];
let _jspdf = null;
function loadScript(src){
return new Promise((res, rej)=>{
const t = document.createElement("script");
t.src = src; t.onload = res;
t.onerror = ()=>rej(new Error("blocked: " + src));
document.head.appendChild(t);
});
}
async function loadJsPDF(){
if(_jspdf) return _jspdf;
const errs = [];
for(const url of JSPDF_CDNS){
if(window.jspdf && window.jspdf.jsPDF) break;
try{ await loadScript(url); }catch(e){ errs.push(e.message); }
}
if(!(window.jspdf && window.jspdf.jsPDF)) return null; // caller falls back
_jspdf = window.jspdf.jsPDF;
return _jspdf;
}
// Library-free fallback: build the same report as an HTML document and let the
// browser print it. Works with no network at all.
function reportViaPrint(){
const A = PANELS.A, B = PANELS.B;
const { id, pmi } = pmiFromAlignment(alnEdit.A, alnEdit.B, alnEdit.denom);
const edited = (alnEdit.A !== alnEdit.baseA) || (alnEdit.B !== alnEdit.baseB);
const grid = renderAlignmentGrid(alnEdit.A, alnEdit.B,
A.lyricColumns(), B.lyricColumns(), false);
const mf = matchFlagsFrom(alnEdit.A, alnEdit.B);
const esc = t => String(t==null?"":t).replace(/[&<>]/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
const styles = Array.from(document.querySelectorAll("style")).map(n=>n.textContent).join("\n");
const row = (k,p) => `<tr><td><b>${k}</b></td><td>${esc(p.fileName()||"\u2014")}</td>`
+ `<td>${esc(p.segmentText())}</td><td>${esc(p.keyName()||"\u2014")}</td>`
+ `<td>${p.noteCount()}</td></tr>`;
const w = window.open("", "_blank");
if(!w){ alert("The PDF library could not be loaded and the report window was blocked. "
+ "Allow pop-ups for this page and try again."); return; }
w.document.write(`<!doctype html><meta charset="utf-8"><title>PMI report</title>
<style>${styles}</style>
<style>body{max-width:960px;margin:0 auto;padding:24px;background:#fff}
h1{font-size:20px;margin:0 0 2px} .meta{color:#666;font-size:12px;margin-bottom:16px}
table.hdr{border-collapse:collapse;font-size:12px;margin-bottom:16px;width:100%}
table.hdr td{border-bottom:1px solid #eee;padding:5px 8px}
.ex img{width:100%;border:1px solid #e6e6e6;border-radius:6px;display:block;margin-bottom:12px}
.score{font-size:26px;font-weight:700;margin:18px 0 4px}
@page{margin:14mm} @media print{.noprint{display:none}}</style>
<h1>Percent Melodic Identity \u2014 comparison report</h1>
<div class="meta">Generated ${new Date().toLocaleString()}</div>
<table class="hdr"><tr><td></td><td><b>Source</b></td><td><b>Segment</b></td><td><b>Key</b></td><td><b>Notes</b></td></tr>${row("A",A)}${row("B",B)}</table>
<div class="ex"><h3>Excerpt A</h3><img src="${mf ? A.rollImageWithMatches(mf.A).url : A.rollImage().url}"></div>
<div class="ex"><h3>Excerpt B</h3><img src="${mf ? B.rollImageWithMatches(mf.B).url : B.rollImage().url}"></div>
<div class="score">PMI = ${pmi.toFixed(1)}%</div>
<div style="font-size:12px;color:#555;margin-bottom:10px">${id} matching positions${edited?" \u00b7 alignment adjusted by hand":""}</div>
${grid}
<p style="margin-top:20px;font-size:11px;color:#888">Notes shown in red are counted as identical after transposition to a common tonic. Lyrics are reference annotations and form no part of the calculation.</p>
<p class="noprint" style="font-size:12px;color:#666">Use your browser's Print dialogue and choose \u201cSave as PDF\u201d.</p>`);
w.document.close(); w.focus();
setTimeout(()=>w.print(), 400);
}
async function downloadReport(){
const A = PANELS.A, B = PANELS.B;
if(!A || !B || !alnEdit) return;
const btn = document.getElementById("btnPdf");
const label = btn.textContent;
btn.disabled = true; btn.textContent = "Building PDF\u2026";
try{
const JsPDF = await loadJsPDF();
// No library (offline or CDN blocked): fall back to a print-ready window.
if(!JsPDF){ reportViaPrint(); return; }
const doc = new JsPDF("p", "mm", "a4");
const M = 14, W = 210 - M*2, PH = 297;
let y = M;
const need = h => { if(y + h > PH - M){ doc.addPage(); y = M; } };
doc.setFont("helvetica", "bold"); doc.setFontSize(15);
doc.text("Percent Melodic Identity \u2014 comparison report", M, y+4); y += 9;
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(110);
doc.text(`Generated ${new Date().toLocaleString()}`, M, y); y += 7;
doc.setTextColor(0);
doc.setFontSize(9);
const cols = [M, M+12, M+92, M+126, M+160];
const hdr = ["", "Source", "Segment", "Key", "Notes"];
doc.setFont("helvetica","bold");
hdr.forEach((t,i)=>doc.text(t, cols[i], y));
doc.setFont("helvetica","normal"); y += 1.5;
doc.setDrawColor(220); doc.line(M, y, M+W, y); y += 4.5;
[["A",A],["B",B]].forEach(([k,p])=>{
const cells = [k, p.fileName()||"\u2014", p.segmentText(), p.keyName()||"\u2014", String(p.noteCount())];
cells.forEach((t,i)=>doc.text(doc.splitTextToSize(String(t), i===1?76:32)[0], cols[i], y));
y += 5;
});
y += 4;
const mf = matchFlagsFrom(alnEdit.A, alnEdit.B);
for(const [k,p] of [["A",A],["B",B]]){
const img = p.rollImageWithMatches(k === "A" ? mf.A : mf.B);
const h = Math.min(70, (img.h / img.w) * W);
need(h + 8);
doc.setFont("helvetica","bold"); doc.setFontSize(10);
doc.text(`Excerpt ${k}`, M, y); y += 2.5;
doc.addImage(img.url, "PNG", M, y, W, h);
doc.setDrawColor(225); doc.rect(M, y, W, h);
y += h + 6;
}
const { id, pmi } = pmiFromAlignment(alnEdit.A, alnEdit.B, alnEdit.denom);
const edited = (alnEdit.A !== alnEdit.baseA) || (alnEdit.B !== alnEdit.baseB);
need(16);
doc.setFont("helvetica","bold"); doc.setFontSize(19);
doc.text(`PMI = ${pmi.toFixed(1)}%`, M, y+5);
if(edited){
doc.setFont("helvetica","normal"); doc.setFontSize(8.5); doc.setTextColor(150,70,45);
doc.text("alignment adjusted by hand", M+52, y+5); doc.setTextColor(0);
}
y += 10;
doc.setFont("helvetica","normal"); doc.setFontSize(8.5); doc.setTextColor(90);
doc.text(`${id} matching positions \u00b7 A: ${A.noteCount()} notes \u00b7 B: ${B.noteCount()} notes`
+ ` \u00b7 transposed to a common tonic`, M, y);
doc.setTextColor(0); y += 7;
const lyA = A.lyricColumns(), lyB = B.lyricColumns();
// one word per note, indexed by column: gaps get nothing, and the k-th
// note takes the k-th word, exactly as the on-screen grid does
const wordsFor = (aln, m) => {
const out = []; let k = 0;
for(let i=0;i<aln.length;i++){
if(aln[i] === "-"){ out.push(""); continue; }
out.push(m && m.filled ? (m.cells[k] || "") : "");
k++;
}
return out;
};
const wA = wordsFor(alnEdit.A, lyA), wB = wordsFor(alnEdit.B, lyB);
const CW = 8.2, per = Math.floor(W / CW), CH = 6.4;
for(let off=0; off<alnEdit.A.length; off+=per){
const n = Math.min(per, alnEdit.A.length - off);
need(CH*2 + 12);
[["A", alnEdit.A, alnEdit.B, wA], ["B", alnEdit.B, alnEdit.A, wB]].forEach(([lab,row,other,words])=>{
doc.setFontSize(7); doc.setTextColor(130);
doc.text(lab, M-4.5, y+4.4);
for(let j=0;j<n;j++){
const i = off+j, ch = row[i], x = M + j*CW;
const match = ch !== "-" && ch === other[i];
if(ch === "-"){ doc.setDrawColor(225); doc.rect(x, y, CW-1, CH); }
else{
if(match) doc.setFillColor(226,72,61); else doc.setFillColor(238,238,238);
doc.rect(x, y, CW-1, CH, "F");
doc.setTextColor(match?255:60); doc.setFontSize(8.5);
doc.text(ch, x + (CW-1)/2, y+4.4, {align:"center"});
}
const wd = words[i];
if(wd){ doc.setTextColor(120); doc.setFontSize(5.6);
doc.text(doc.splitTextToSize(wd, CW+3)[0], x + (CW-1)/2, y+CH+3, {align:"center"}); }
}
doc.setTextColor(0);
y += CH + 5;
});
y += 3;
}
need(14);
doc.setFontSize(7.5); doc.setTextColor(120);
doc.text(doc.splitTextToSize(
"Notes shown in red are those counted as identical after transposition to a common tonic. "
+ "Lyrics, where present, are reference annotations and form no part of the calculation.", W), M, y+3);
const stamp = new Date().toISOString().slice(0,10);
doc.save(`PMI_report_${stamp}.pdf`);
}catch(err){
alert("Could not build the PDF: " + err.message);
}finally{
btn.disabled = false; btn.textContent = label;
}
}
document.getElementById("btnPdf").addEventListener("click", downloadReport);
document.getElementById("btnTranscribePair").addEventListener("click", transcribeBoth);
// Nothing survives a reload, so warn first. The browser supplies its own
// wording; setting returnValue is only what triggers the prompt.
window.addEventListener("beforeunload", e=>{
const A = PANELS.A, B = PANELS.B;
const hasWork = (A && A.hasNotes()) || (B && B.hasNotes());
if(!hasWork) return;
// Flush synchronously: the debounced save may still be pending, and anything
// edited in the last moment would otherwise not make it into the snapshot.
clearTimeout(saveTimer);
writeSnapshot();
e.preventDefault();
e.returnValue = "";
});
function stopEveryPanel(except){
Object.values(PANELS).forEach(p=>{ if(p!==except) p.stopAll(); });
}
function markStale(){
const box=document.getElementById("pmiResult");
if(box && !box.hidden) box.classList.add("stale");
refreshPmiState();
}
function refreshPmiState(){
const A=PANELS.A, B=PANELS.B;
if(!A||!B) return;
const set=(id,p)=>{
const el=document.getElementById(id);
const n=p.noteCount();
el.textContent = `${p.key}: ${n? n+" notes" : "empty"}`;
el.classList.toggle("filled", n>0);
};
set("pmiStateA",A); set("pmiStateB",B);
document.getElementById("btnPMI").disabled = !(A.hasNotes() && B.hasNotes());
syncPairButton(); // already tracks whether both sides hold notes
}
/* ---- candidate popup: one popup, routed back to its owning excerpt ---- */
(function candPopWiring(){
const pop=document.getElementById("candPop");
pop.addEventListener("click", e=>{
const c=e.target.closest(".cand"); if(!c) return;
const p=PANELS[pop.dataset.owner]; if(!p) return;
pop.hidden=true;
p.retuneTo(+pop.dataset.note, +c.dataset.pitch);
});
document.addEventListener("pointerdown", e=>{
if(!pop.hidden && !pop.contains(e.target)) pop.hidden=true;
}, true);
document.addEventListener("keydown", e=>{ if(e.key==="Escape") pop.hidden=true; });
})();
/* ============================================================
PMI: reads both excerpts in place. No capture step.
============================================================ */
const PMI_LETTERS=['C','d','D','e','E','F','g','G','a','A','b','B'];
const LETTER2PC={}; PMI_LETTERS.forEach((l,i)=>LETTER2PC[l]=i);
let lastAln={A:"",B:""};
function esc(t){ return String(t).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function countSymbols(s){ let n=0; for(const ch of s) if(ch!=="-") n++; return n; }
// Savage-style aligned grid: equal-width cells, identical scale degrees in red.
// Each melody can carry a row of its own lyrics underneath. The words come from
// the notes themselves (one note, one column, one word), so nothing is inferred
// and nothing can land in the wrong place. Display only; never enters the PMI.
function renderAlignmentGrid(alnA, alnB, lyA, lyB, editable){
const fits = (m, aln) => !!m && Array.isArray(m.cells) && m.cells.length === countSymbols(aln);
const okA = fits(lyA, alnA), okB = fits(lyB, alnB);
const useLy = (okA && lyA.cells.some(Boolean)) || (okB && lyB.cells.some(Boolean));
const cell=(ch,other,row,i)=>{
const d = editable ? ` data-row="${row}" data-i="${i}"` : "";
if(ch==="-") return `<div class="aln-cell gap${editable?" drop":""}"${d}>\u00b7</div>`;
const same = (other!=="-" && ch===other);
return `<div class="aln-cell ${same?"same":"diff"}${editable?" drag":""}"${d}>${ch}</div>`;
};
const lyRow=(aln, m, ok)=>{
let h=`<div class="arow lyr"><span class="albl"></span>`, k=0;
for(let i=0;i<aln.length;i++){
if(aln[i]==="-"){ h+=`<div class="aln-cell lyr"></div>`; continue; }
const w = ok ? (m.cells[k]||"") : "";
k++;
h+=`<div class="aln-cell lyr" title="${esc(w)}">${esc(w)}</div>`;
}
return h+`</div>`;
};
let rA=`<div class="arow"><span class="albl">A</span>`;
let rB=`<div class="arow"><span class="albl">B</span>`;
for(let i=0;i<alnA.length;i++){ rA+=cell(alnA[i],alnB[i],"A",i); rB+=cell(alnB[i],alnA[i],"B",i); }
rA+="</div>"; rB+="</div>";
let html=`<div class="aln-grid${useLy?" haslyr":""}${editable?" editable":""}" id="alnGrid">`;
html += rA;
if(useLy) html += lyRow(alnA, lyA, okA);
html += rB;
if(useLy) html += lyRow(alnB, lyB, okB);
html += `<div class="aln-legend"><span class="sw" style="background:#e2483d"></span>identical scale degree (counted in PMI)`
+ `<span class="sw" style="background:#eee"></span>different`
+ `<span class="sw" style="background:transparent;border:1px solid #ddd"></span>gap`
+ (editable ? `<br>Drag a note into a gap to realign it, or right-click a column to add or remove a gap. The score updates as you go.` : ``)
+ (useLy ? `<br>Lyrics are typed onto the notes in the editor above. Reference only, never part of the PMI.` : ``)
+ `</div>`;
// If the aligned sequence holds a different number of notes than the editor,
// the columns and the words are no longer the same list and any row drawn
// would be off by an unknown amount. Say so rather than draw it.
const warn = [];
[["A", lyA, okA], ["B", lyB, okB]].forEach(([k, m, ok])=>{
if(!m || !m.filled) return;
if(!ok) warn.push(`Excerpt ${k}: lyrics not shown. The aligned sequence has a different note count than the editor, so the words cannot be placed reliably.`);
});
if(warn.length) html += `<div class="aln-warn">${warn.map(esc).join("<br>")}</div>`;
return html + `</div>`;
}
function renderPianoRoll(alnA, alnB){
lastAln={A:alnA,B:alnB};
const n=alnA.length, colW=16, rowH=11, padL=20, padT=8, rows=12;
const W=padL+n*colW+8, H=padT*2+rows*rowH+8;
const y=pc=>padT+(rows-1-pc)*rowH;
let svg=`<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">`;
[0,2,4,5,7,9,11].forEach(pc=>{ svg+=`<line x1="${padL}" y1="${y(pc)+rowH/2}" x2="${W-8}" y2="${y(pc)+rowH/2}" stroke="#f0f0ed"/>`; });
const drawRow=(s,other)=>{
for(let i=0;i<s.length;i++){
const ch=s[i]; if(ch==="-") continue;
const pc=LETTER2PC[ch]; if(pc===undefined) continue;
const same=(other[i]!=="-" && s[i]===other[i]);
const x=padL+i*colW;
svg+=`<rect x="${x+1}" y="${y(pc)}" width="${colW-2}" height="${rowH-2}" rx="2" fill="${same?'#e2483d':'#c7c7c1'}"/>`;
}
};
drawRow(alnA,alnB); drawRow(alnB,alnA);
svg+=`</svg>`;
return `<div class="roll2-svg-wrap">${svg}</div>`;
}
/* ---- manual alignment correction ----
Notes may slide into a gap, and gaps may be added or removed, but nothing
may cross a note: the note sequence and the alignment length are therefore
invariant, so no edit can produce a malformed alignment. PMI is recomputed
locally, the note counts being unchanged. */
let alnEdit = null; // {A, B, baseA, baseB, nA, nB, denom} while a result is shown
// The k-th non-gap symbol of a row is that melody's k-th note, so a match in
// the alignment can be traced back to the note it came from. This is what puts
// the same red on the roll as in the grid: one judgement, shown twice.
function matchFlagsFrom(a, b){
const fa = [], fb = [];
let ka = 0, kb = 0;
for(let i=0;i<a.length;i++){
const hit = a[i] !== "-" && a[i] === b[i];
if(a[i] !== "-") fa[ka++] = hit;
if(b[i] !== "-") fb[kb++] = hit;
}
return {A: fa, B: fb};
}
function pmiFromAlignment(a, b, denom){
let id = 0;
for(let i=0;i<a.length;i++) if(a[i]!=="-" && a[i]===b[i]) id++;
return { id, pmi: denom>0 ? 100*id/denom : 0 };
}
function slideNote(row, from, to){
const r = row.split("");
if(r[from]==="-" || r[to]!=="-") return null;
const lo = Math.min(from,to), hi = Math.max(from,to);
for(let i=lo+1;i<hi;i++) if(r[i]!=="-") return null; // would cross a note
r[to] = r[from]; r[from] = "-";
return r.join("");
}
// Inserting a gap pushes that row right and pads the other, so both stay the
// same length and note order is untouched. Deleting is the inverse.
function insertGap(aln, row, i){
const out = {A:aln.A, B:aln.B}, other = row === "A" ? "B" : "A";
out[row] = out[row].slice(0,i) + "-" + out[row].slice(i);
out[other] = out[other] + "-";
return pruneEmptyColumns(out);
}
function removeGap(aln, row, i){
if(aln[row][i] !== "-") return null;
const out = {A:aln.A, B:aln.B}, other = row === "A" ? "B" : "A";
out[row] = out[row].slice(0,i) + out[row].slice(i+1);
// shorten the other row too if it ends in padding, otherwise re-pad this one
if(out[other].endsWith("-")) out[other] = out[other].slice(0,-1);
else out[row] = out[row] + "-";
return pruneEmptyColumns(out);
}
// A column of two gaps carries no information and only widens the display.
function pruneEmptyColumns(aln){
let A = "", B = "";
for(let i=0;i<aln.A.length;i++){
if(aln.A[i] === "-" && aln.B[i] === "-") continue;
A += aln.A[i]; B += aln.B[i];
}
return {A: A || aln.A, B: B || aln.B};
}
function repaintAlignment(){
if(!alnEdit) return;
const host = document.getElementById("alnGrid");
if(!host) return;
const A = PANELS.A, B = PANELS.B;
host.outerHTML = renderAlignmentGrid(alnEdit.A, alnEdit.B,
A.lyricColumns(), B.lyricColumns(), true);
const { id, pmi } = pmiFromAlignment(alnEdit.A, alnEdit.B, alnEdit.denom);
const scoreEl = document.querySelector("#pmiResult .pmi-score");
const edited = (alnEdit.A !== alnEdit.baseA) || (alnEdit.B !== alnEdit.baseB);
if(scoreEl) scoreEl.innerHTML = `PMI = ${pmi.toFixed(1)}%`
+ (edited ? ` <span class="aln-edited">alignment adjusted by hand \u00b7 `
+ `<button class="btn sm" id="alnReset">reset</button></span>` : "");
const info = document.querySelector("#pmiResult .pmi-keys");
if(info) info.innerHTML = info.innerHTML.replace(/^\d+ matching positions/,
`${id} matching positions`);
}
function bindAlignmentDrag(){
const grid = document.getElementById("alnGrid");
if(!grid || !alnEdit) return;
let from = null;
grid.addEventListener("pointerdown", e=>{
const c = e.target.closest(".aln-cell.drag");
if(!c) return;
e.preventDefault();
from = { row: c.dataset.row, i: +c.dataset.i };
c.classList.add("dragging");
// mark every gap this note could legally reach, so the limits are visible
const row = alnEdit[from.row];
grid.querySelectorAll(`.aln-cell.drop[data-row="${from.row}"]`).forEach(g=>{
if(slideNote(row, from.i, +g.dataset.i) !== null) g.classList.add("ok");
});
});
// right-click: make or remove a gap at this column, for cases where there is
// nothing to slide into
grid.addEventListener("contextmenu", e=>{
const c = e.target.closest(".aln-cell[data-row]");
if(!c || !alnEdit) return;
e.preventDefault();
const row = c.dataset.row, i = +c.dataset.i;
const next = (alnEdit[row][i] === "-")
? removeGap({A:alnEdit.A, B:alnEdit.B}, row, i)
: insertGap({A:alnEdit.A, B:alnEdit.B}, row, i);
if(!next) return;
alnEdit.A = next.A; alnEdit.B = next.B;
repaintAlignment(); bindAlignmentDrag();
});
grid.addEventListener("pointerup", e=>{
if(!from) return;
const c = e.target.closest(".aln-cell");
const target = (c && c.dataset.row === from.row) ? +c.dataset.i : null;
grid.querySelectorAll(".dragging,.ok").forEach(el=>el.classList.remove("dragging","ok"));
if(target !== null){
const moved = slideNote(alnEdit[from.row], from.i, target);
if(moved){ alnEdit[from.row] = moved; repaintAlignment(); bindAlignmentDrag(); }
}
from = null;
});
}
document.addEventListener("click", e=>{
if(e.target && e.target.id === "alnReset" && alnEdit){
alnEdit.A = alnEdit.baseA; alnEdit.B = alnEdit.baseB;
repaintAlignment(); bindAlignmentDrag();
}
});
let pmiVoices=[];
function playAlignedRows(which){
stopEveryPanel(null);
ensureAC(); if(ac.state==="suspended") ac.resume();
pmiVoices.forEach(v=>{try{v.stop()}catch(e){}}); pmiVoices=[];
const step=0.34, base=60, t0=ac.currentTime+0.06;
const rows = which==="both" ? ["A","B"] : [which];
rows.forEach(r=>{
const s=lastAln[r]||"";
for(let i=0;i<s.length;i++){
const ch=s[i]; if(ch==="-") continue;
const pc=LETTER2PC[ch]; if(pc===undefined) continue;
const tS=t0+i*step, tE=tS+step*0.85;
tone(midiToFreq(base+pc), tS, tE, gPmi, "triangle", false, which==="both"?0.4:0.6, pmiVoices);
}
});
}
async function computePMI(){
const A=PANELS.A, B=PANELS.B;
const btn=document.getElementById("btnPMI"), box=document.getElementById("pmiResult");
if(!(A.hasNotes() && B.hasNotes())) return;
btn.disabled=true; btn.textContent="Computing\u2026";
box.hidden=false; box.classList.remove("stale");
box.innerHTML="<span class='note-txt'>aligning\u2026</span>";
try{
const ka=A.chosenKey(), kb=B.chosenKey();
const fd=new FormData();
fd.append("midi_a", new Blob([A.midiBytes()],{type:"audio/midi"}), "songA.mid");
fd.append("midi_b", new Blob([B.midiBytes()],{type:"audio/midi"}), "songB.mid");
fd.append("tonic_a", ka.tonic); fd.append("mode_a", ka.mode);
fd.append("tonic_b", kb.tonic); fd.append("mode_b", kb.mode);
const res=await fetch(PMI_URL,{method:"POST",body:fd});
const d=await res.json();
if(!res.ok||d.error) throw new Error(d.error||("HTTP "+res.status));
let html =
`<div class="pmi-score">PMI = ${d.PMI.toFixed(1)}%</div>`
+ `<div class="pmi-keys">${d.ID} matching positions \u00b7 `
+ `A: ${d.keyA}${d.forcedA?" (set)":""}, ${d.n_notes_A} notes (${d.lenA} sym) \u00b7 `
+ `B: ${d.keyB}${d.forcedB?" (set)":""}, ${d.n_notes_B} notes (${d.lenB} sym) \u00b7 `
+ `transposed to C, ${d.encoding}</div>`
+ renderAlignmentGrid(d.alnA, d.alnB, A.lyricColumns(), B.lyricColumns(), true)
+ `<details class="roll2-det"><summary>Aligned pitches<span>optional view \u2014 the grid above carries the same matches</span></summary>`
+ `<div class="roll2-body">`
+ `<div class="roll2-listen">`
+ `<button class="btn sm" data-play="A">\u25b6 A</button>`
+ `<button class="btn sm" data-play="B">\u25b6 B</button>`
+ `<button class="btn sm" data-play="both">\u25b6 both</button>`
+ `</div>`
+ renderPianoRoll(d.alnA, d.alnB)
+ `</div></details>`;
if(d.soft_unify_applicable && d.soft_unify){
const su=d.soft_unify;
html += `<div class="pmi-keys" style="margin-top:14px">`
+ `<b>Soft-unified</b> (offered because absolute-pitch similarity = ${d.abs_similarity.toFixed(1)}% \u2265 ${d.soft_unify_threshold}%). `
+ `Both forced to <b>${su.shared_key}</b> (from ${su.shared_key_source}, higher key-confidence): `
+ `<b>PMI = ${su.PMI.toFixed(1)}%</b> (${su.ID} matches). `
+ `<i>Reported separately, not a substitute for the main PMI.</i></div>`
+ renderAlignmentGrid(su.alnA, su.alnB, null, null);
} else {
html += `<div class="note-txt" style="margin-top:10px">absolute-pitch similarity = ${d.abs_similarity.toFixed(1)}% (&lt; ${d.soft_unify_threshold}%, soft key-unification not offered)</div>`;
}
box.innerHTML=html;
document.getElementById("pdfBar").hidden = false;
// denominator is the mean note count, fixed for this pair: sliding a note
// changes where matches fall, never how many notes each melody has
alnEdit = { A:d.alnA, B:d.alnB, baseA:d.alnA, baseB:d.alnB,
denom:(d.n_notes_A + d.n_notes_B)/2 };
bindAlignmentDrag();
}catch(err){
alnEdit = null;
box.innerHTML=`<span style="color:#c46">PMI failed: ${err.message}</span>`;
}finally{
btn.disabled=false; btn.textContent="Compute PMI";
refreshPmiState();
}
}
document.getElementById("btnPMI").addEventListener("click",computePMI);
document.getElementById("pmiResult").addEventListener("click",e=>{
const b=e.target.closest("[data-play]"); if(!b) return;
playAlignedRows(b.dataset.play);
});
/* ============================================================
BOOT
============================================================ */
document.querySelectorAll(".excerpt").forEach(el=>createPanel(el.dataset.key, el));
/* ---- shared uploader: one drop fills both excerpts ----
Two files go to A and B in the order given. A single file fills whichever
excerpt is still empty (A first), so dropping them one at a time also works
and re-dropping replaces A rather than silently doing nothing. */
(function dualUpload(){
const slot = document.getElementById("dualSlot");
const input = document.getElementById("dualFile");
const label = document.getElementById("dualName");
async function take(files){
const list = Array.from(files || []).filter(f=>f && f.size);
if(!list.length) return;
const A = PANELS.A, B = PANELS.B;
if(!A || !B) return;
// Excerpts awaiting audio are filled first, or a single file would land on A
// and leave the restored excerpt unplayable.
const waiting = [A,B].filter(p=>p.needsAudio());
let targets;
if(waiting.length){
targets = waiting.slice(0, list.length).map((p,i)=>[p, list[i]]);
// any spare files go to whichever excerpt is still empty
let k = targets.length;
for(const p of [A,B]){
if(k >= list.length) break;
if(targets.some(t=>t[0]===p) || p.hasAudio()) continue;
targets.push([p, list[k++]]);
}
}else if(list.length >= 2){
targets = [[A, list[0]], [B, list[1]]];
if(list.length > 2) label.textContent =
`Only the first two files are used (${list[0].name}, ${list[1].name}).`;
}else{
// one file: fill the first excerpt without audio, else replace A
const empty = !A.hasAudio() ? A : (!B.hasAudio() ? B : A);
targets = [[empty, list[0]]];
}
label.textContent = "decoding\u2026";
const done = [], warn = [];
for(const [panel, f] of targets){
const wanted = panel.restoredName();
const wasWaiting = panel.needsAudio();
const ok = await panel.loadFile(f);
done.push(`${panel.key}: ${ok ? f.name : "failed"}`);
// Identity can't be verified, only the name compared -- so this warns
// rather than blocks: the recovered segment bounds are only meaningful
// against the same recording.
if(ok && wasWaiting && wanted && wanted !== f.name)
warn.push(`${panel.key} was transcribed from "${wanted}"`);
}
label.textContent = done.join(" \u00b7 ")
+ (warn.length ? ` \u2014 \u26a0 ${warn.join("; ")}` : " \u2014 drop again to replace");
refreshPmiState();
}
input.addEventListener("change", e=>{
// Copy out of the FileList BEFORE resetting value: input.files is live, so
// clearing the input empties the very list we are about to read. (The old
// single-file code took files[0] first, which is a File object and survives
// the reset -- the same pattern silently breaks for a whole list.)
const fs = Array.from(e.target.files || []);
e.target.value = "";
take(fs);
});
["dragenter","dragover"].forEach(ev=>slot.addEventListener(ev,e=>{
e.preventDefault(); slot.classList.add("drag"); }));
["dragleave","drop"].forEach(ev=>slot.addEventListener(ev,e=>{
e.preventDefault(); slot.classList.remove("drag"); }));
slot.addEventListener("drop", e=>{ take(e.dataTransfer.files); });
})();
/* ---- offer any autosaved session back ---- */
(function offerRestore(){
const snap = readSnapshot();
if(!snap) return;
const bar = document.getElementById("restoreBar");
const when = new Date(snap.savedAt);
const parts = [];
if(snap.a) parts.push(`A${snap.a.fileName ? " ("+snap.a.fileName+")" : ""}`);
if(snap.b) parts.push(`B${snap.b.fileName ? " ("+snap.b.fileName+")" : ""}`);
document.getElementById("restoreMsg").innerHTML =
`Unsaved work from <b>${when.toLocaleString()}</b>: ${parts.join(", ")}. `
+ `Notes and lyric markers can be recovered; the audio files need loading again `
+ `(they are too large to store in the browser).`;
bar.hidden = false;
document.getElementById("btnRestore").addEventListener("click", ()=>{
if(snap.a && PANELS.A) PANELS.A.restore(snap.a);
if(snap.b && PANELS.B) PANELS.B.restore(snap.b);
bar.hidden = true;
refreshPmiState();
document.getElementById("dualName").textContent =
"drop the same audio files here to reconnect them to the restored notes";
sharedStatus().textContent =
"Restored. MIDI export and Compute PMI work now; drop the audio files in the "
+ "box above to hear them and to re-transcribe.";
});
document.getElementById("btnDiscard").addEventListener("click", ()=>{
if(!confirm("Discard the autosaved work permanently?")) return;
clearSnapshot(); bar.hidden = true;
});
})();
activePanel = PANELS.A;
document.querySelector('.excerpt[data-key="A"]').classList.add("active");
refreshPmiState();
window.addEventListener("resize",()=>{ Object.values(PANELS).forEach(p=>p.onResize()); });
window.addEventListener("keydown",e=>{
const t=e.target;
if(t && (t.tagName==="INPUT" || t.tagName==="SELECT" || t.tagName==="TEXTAREA")) return;
if(activePanel) activePanel.keyDown(e);
});
</script>
</body>
</html>