Spaces:
Running on Zero
Running on Zero
| <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 } | |
| 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 → 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 →</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">⤓ 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."> | |
| ⇩ 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."> | |
| ⇩ 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–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">−</button> | |
| <button class="btn sm js-segzin" title="Zoom in">+</button> | |
| <span class="zlabel js-segzlabel">–</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 – 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">▶ preview segment</button> | |
| <button class="btn sm js-segfull">▶ whole track</button> | |
| </div> | |
| </div> | |
| <div class="stage js-stage" hidden> | |
| <div class="transport"> | |
| <button class="play js-play">▶ Play</button> | |
| <label class="vol">audio ◀<input type="range" class="js-volaudio" min="0" max="100" step="1" value="85"></label> | |
| <label class="vol">▶ 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>↶ Undo</button> | |
| <button class="btn js-redo" disabled>↷ 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">+ 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">⤓ 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.">⤓ Stem</button> | |
| <span class="hint2"> | |
| <b>Notes</b> · double-click empty space to insert a note · | |
| right-click a note for the pitches the audio supports at that position · | |
| drag to transpose · drag either edge to adjust onset or duration · | |
| ↑/↓ to transpose by a semitone (Shift for an octave) · Ctrl+Z to undo.<br> | |
| <b>Lyrics</b> · double-click a note to type its word or syllable, then press ✓ (or Enter) to confirm · | |
| the word appears by the note, Enter jumps to the next note, Esc cancels · | |
| these are the lyrics carried through to the alignment below.<br> | |
| <b>View</b> · scroll to zoom, Shift + scroll to pan.<br> | |
| <b>Waveform markers</b> (optional, above) · drag to reposition · | |
| Shift-drag to shift the whole track · 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)">✓</button> | |
| <button class="lyric-x js-lyricx" title="Cancel (Esc)">×</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">−</button> | |
| <button class="btn sm js-rollzin" title="Zoom in">+</button> | |
| <span class="zlabel js-rollzlabel">–</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">▶ 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 & 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="■ Stop"; loop(); | |
| } | |
| function stopPlayback(reset){ | |
| stopSources(); cancelAnimationFrame(rafId); playing=false; | |
| $(".js-play").innerHTML="▶ 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> pick: ${btns} ` | |
| + ` <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=>({"&":"&","<":"<",">":">"}[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=>({'&':'&','<':'<','>':'>','"':'"'}[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)}% (< ${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> |