Spaces:
Running on Zero
Running on Zero
Update transcription-editor.html
Browse files- transcription-editor.html +81 -33
transcription-editor.html
CHANGED
|
@@ -90,8 +90,18 @@
|
|
| 90 |
|
| 91 |
.roll-wrap{position:relative; height:290px; background:var(--panel); border:1px solid var(--line);
|
| 92 |
border-radius:6px; overflow:hidden}
|
| 93 |
-
.lyric-
|
| 94 |
-
border:1px solid var(--sel); border-radius:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
canvas.roll{display:block; width:100%; height:100%; cursor:crosshair; touch-action:none}
|
| 96 |
.readout{display:flex; gap:7px; flex-wrap:wrap; font-size:12px; color:var(--muted); align-items:center}
|
| 97 |
.chip{display:inline-flex; align-items:center; gap:6px; background:#F7F7F5; border:1px solid var(--line);
|
|
@@ -451,8 +461,9 @@ function panelHTML(K){ return `
|
|
| 451 |
right-click a note for the pitches the audio supports at that position ·
|
| 452 |
drag to transpose · drag either edge to adjust onset or duration ·
|
| 453 |
↑/↓ to transpose by a semitone (Shift for an octave) · Ctrl+Z to undo.<br>
|
| 454 |
-
<b>Lyrics</b> · double-click a note to type its word or syllable ·
|
| 455 |
-
|
|
|
|
| 456 |
<b>View</b> · scroll to zoom, Shift + scroll to pan.<br>
|
| 457 |
<b>Waveform markers</b> (optional, above) · drag to reposition ·
|
| 458 |
Shift-drag to shift the whole track · right-click to delete.
|
|
@@ -461,7 +472,12 @@ function panelHTML(K){ return `
|
|
| 461 |
|
| 462 |
<div class="roll-wrap">
|
| 463 |
<canvas class="roll js-roll"></canvas>
|
| 464 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
</div>
|
| 466 |
<div class="hscroll js-rollscroll"><div class="js-rollspacer"></div></div>
|
| 467 |
<div class="zoomrow">
|
|
@@ -695,9 +711,7 @@ function createPanel(KEY, root){
|
|
| 695 |
const span = spanSec();
|
| 696 |
rollClampVS();
|
| 697 |
const vs = rollVS, vd = rollViewDur();
|
| 698 |
-
const
|
| 699 |
-
const stripH = anyLyric ? 15 : 0;
|
| 700 |
-
const padL=58,padR=14,padT=14,padB=20+stripH, plotW=w-padL-padR, plotH=h-padT-padB;
|
| 701 |
const rows=hi-lo+1, rowH=plotH/rows, gap=rowH*0.18, bh=Math.max(rowH-2*gap,3);
|
| 702 |
geo = {padL,padT,plotW,plotH,span,vs,vd,lo,hi,rowH,gap,bh};
|
| 703 |
const x = timeToX, y = pch => padT + (hi-pch)*rowH;
|
|
@@ -736,7 +750,7 @@ function createPanel(KEY, root){
|
|
| 736 |
|
| 737 |
/* everything time-based is clipped to the plot, so nothing bleeds over the keys */
|
| 738 |
ctx.save();
|
| 739 |
-
ctx.beginPath(); ctx.rect(padL,padT,plotW,plotH
|
| 740 |
|
| 741 |
const thr=(+$(".js-thr").value)/100;
|
| 742 |
for(const n of notes){
|
|
@@ -776,21 +790,37 @@ function createPanel(KEY, root){
|
|
| 776 |
});
|
| 777 |
ctx.restore();
|
| 778 |
}
|
| 779 |
-
/* lyric
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
notes.slice().sort((a,b)=>a.start-b.start).forEach(n=>{
|
| 785 |
if(!n.lyric) return;
|
| 786 |
-
if(n.
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
ctx.
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
ctx.fillStyle='#5a3f78';
|
| 792 |
-
ctx.fillText(n.lyric,
|
| 793 |
-
lastRight = lx + ctx.measureText(n.lyric).width + 2;
|
| 794 |
});
|
| 795 |
}
|
| 796 |
ctx.restore();
|
|
@@ -1154,10 +1184,10 @@ function createPanel(KEY, root){
|
|
| 1154 |
return "";
|
| 1155 |
}
|
| 1156 |
function closeLyricEditor(commit){
|
| 1157 |
-
const el=$(".js-lyricin");
|
| 1158 |
-
if(lyricEditId==null){
|
| 1159 |
const n=byId(lyricEditId), id=lyricEditId;
|
| 1160 |
-
lyricEditId=null;
|
| 1161 |
if(commit && n){
|
| 1162 |
const v=el.value.trim();
|
| 1163 |
if((n.lyric||"") !== v){ pushUndo(); n.lyric = v || undefined; afterEdit(); }
|
|
@@ -1166,19 +1196,22 @@ function createPanel(KEY, root){
|
|
| 1166 |
}
|
| 1167 |
function openLyricEditor(id){
|
| 1168 |
const n=byId(id); if(!n) return;
|
| 1169 |
-
// bring the note into view first, so the
|
| 1170 |
if(rollZoom>1){
|
| 1171 |
const vd=rollViewDur();
|
| 1172 |
if(n.start < geo.vs || n.start > geo.vs+vd*0.9){ rollVS=n.start-vd*0.25; rollClampVS(); draw(); }
|
| 1173 |
}
|
| 1174 |
-
const el=$(".js-lyricin"), wrap=cv.parentElement;
|
| 1175 |
-
const nx=timeToX(n.start), ny=geo.padT+(geo.hi-n.pitch)*geo.rowH+geo.rowH;
|
| 1176 |
el.value = n.lyric || markerTextFor(n);
|
| 1177 |
-
el.hidden=false;
|
| 1178 |
-
el.style.left = Math.max(2, Math.min(nx, wrap.clientWidth-110)) + "px";
|
| 1179 |
-
el.style.top = Math.max(2, Math.min(ny, wrap.clientHeight-24)) + "px";
|
| 1180 |
lyricEditId=id;
|
| 1181 |
selectedIds=new Set([id]); draw();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
el.focus(); el.select();
|
| 1183 |
}
|
| 1184 |
function nextNoteAfter(id){
|
|
@@ -1186,23 +1219,38 @@ function createPanel(KEY, root){
|
|
| 1186 |
const i=ordered.findIndex(n=>n.id===id);
|
| 1187 |
return (i>=0 && i<ordered.length-1) ? ordered[i+1].id : null;
|
| 1188 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1189 |
$(".js-lyricin").addEventListener("keydown", e=>{
|
| 1190 |
e.stopPropagation(); // never reaches the global shortcuts
|
| 1191 |
if(e.key==="Enter"){
|
| 1192 |
e.preventDefault();
|
| 1193 |
const id=closeLyricEditor(true);
|
| 1194 |
const nxt=e.shiftKey?null:nextNoteAfter(id);
|
| 1195 |
-
if(nxt!=null) openLyricEditor(nxt);
|
| 1196 |
} else if(e.key==="Escape"){
|
| 1197 |
e.preventDefault(); closeLyricEditor(false); draw();
|
| 1198 |
} else if(e.key==="Tab"){
|
| 1199 |
e.preventDefault();
|
| 1200 |
const id=closeLyricEditor(true);
|
| 1201 |
const nxt=nextNoteAfter(id);
|
| 1202 |
-
if(nxt!=null) openLyricEditor(nxt);
|
| 1203 |
}
|
| 1204 |
});
|
| 1205 |
-
$(".js-lyricin").addEventListener("blur", ()=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1206 |
|
| 1207 |
function retuneTo(id, pitch){
|
| 1208 |
const n = byId(id); if(!n) return;
|
|
|
|
| 90 |
|
| 91 |
.roll-wrap{position:relative; height:290px; background:var(--panel); border:1px solid var(--line);
|
| 92 |
border-radius:6px; overflow:hidden}
|
| 93 |
+
.lyric-box{position:absolute; z-index:20; display:none; align-items:center; gap:3px; padding:3px;
|
| 94 |
+
background:#fff; border:1px solid var(--sel); border-radius:6px; box-shadow:0 3px 12px rgba(0,0,0,.14)}
|
| 95 |
+
.lyric-box.on{display:flex}
|
| 96 |
+
.lyric-box input{font-family:var(--sans); font-size:11px; padding:2px 5px; border:1px solid var(--line);
|
| 97 |
+
border-radius:4px; background:#fff; color:#5a3f78; width:96px; outline:none}
|
| 98 |
+
.lyric-box input:focus{border-color:var(--sel)}
|
| 99 |
+
.lyric-box .lyric-ok{font-family:inherit; font-size:12px; line-height:1; color:#fff; background:var(--edit);
|
| 100 |
+
border:none; border-radius:4px; padding:4px 7px; cursor:pointer}
|
| 101 |
+
.lyric-box .lyric-ok:hover{background:#264249}
|
| 102 |
+
.lyric-box .lyric-x{font-family:inherit; font-size:13px; line-height:1; color:var(--muted); background:none;
|
| 103 |
+
border:none; padding:2px 4px; cursor:pointer}
|
| 104 |
+
.lyric-box .lyric-x:hover{color:var(--warn)}
|
| 105 |
canvas.roll{display:block; width:100%; height:100%; cursor:crosshair; touch-action:none}
|
| 106 |
.readout{display:flex; gap:7px; flex-wrap:wrap; font-size:12px; color:var(--muted); align-items:center}
|
| 107 |
.chip{display:inline-flex; align-items:center; gap:6px; background:#F7F7F5; border:1px solid var(--line);
|
|
|
|
| 461 |
right-click a note for the pitches the audio supports at that position ·
|
| 462 |
drag to transpose · drag either edge to adjust onset or duration ·
|
| 463 |
↑/↓ to transpose by a semitone (Shift for an octave) · Ctrl+Z to undo.<br>
|
| 464 |
+
<b>Lyrics</b> · double-click a note to type its word or syllable, then press ✓ (or Enter) to confirm ·
|
| 465 |
+
the word appears by the note, Enter jumps to the next note, Esc cancels ·
|
| 466 |
+
these are the lyrics carried through to the alignment below.<br>
|
| 467 |
<b>View</b> · scroll to zoom, Shift + scroll to pan.<br>
|
| 468 |
<b>Waveform markers</b> (optional, above) · drag to reposition ·
|
| 469 |
Shift-drag to shift the whole track · right-click to delete.
|
|
|
|
| 472 |
|
| 473 |
<div class="roll-wrap">
|
| 474 |
<canvas class="roll js-roll"></canvas>
|
| 475 |
+
<span class="lyric-box js-lyricbox">
|
| 476 |
+
<input class="js-lyricin" spellcheck="false" autocomplete="off"
|
| 477 |
+
placeholder="word / syllable" aria-label="Lyric for this note">
|
| 478 |
+
<button class="lyric-ok js-lyricok" title="Confirm (Enter)">✓</button>
|
| 479 |
+
<button class="lyric-x js-lyricx" title="Cancel (Esc)">×</button>
|
| 480 |
+
</span>
|
| 481 |
</div>
|
| 482 |
<div class="hscroll js-rollscroll"><div class="js-rollspacer"></div></div>
|
| 483 |
<div class="zoomrow">
|
|
|
|
| 711 |
const span = spanSec();
|
| 712 |
rollClampVS();
|
| 713 |
const vs = rollVS, vd = rollViewDur();
|
| 714 |
+
const padL=58,padR=14,padT=14,padB=20, plotW=w-padL-padR, plotH=h-padT-padB;
|
|
|
|
|
|
|
| 715 |
const rows=hi-lo+1, rowH=plotH/rows, gap=rowH*0.18, bh=Math.max(rowH-2*gap,3);
|
| 716 |
geo = {padL,padT,plotW,plotH,span,vs,vd,lo,hi,rowH,gap,bh};
|
| 717 |
const x = timeToX, y = pch => padT + (hi-pch)*rowH;
|
|
|
|
| 750 |
|
| 751 |
/* everything time-based is clipped to the plot, so nothing bleeds over the keys */
|
| 752 |
ctx.save();
|
| 753 |
+
ctx.beginPath(); ctx.rect(padL,padT,plotW,plotH); ctx.clip();
|
| 754 |
|
| 755 |
const thr=(+$(".js-thr").value)/100;
|
| 756 |
for(const n of notes){
|
|
|
|
| 790 |
});
|
| 791 |
ctx.restore();
|
| 792 |
}
|
| 793 |
+
/* per-note lyric labels, drawn against the note itself. Preference is
|
| 794 |
+
above the note; if there is no room there (note sits near the top) the
|
| 795 |
+
label drops below it. Collisions within the same band are skipped so
|
| 796 |
+
words never overprint, and the label of the note being edited is held
|
| 797 |
+
back because the live text box already shows it. */
|
| 798 |
+
if(notes.some(n=>n.lyric)){
|
| 799 |
+
ctx.font='10px '+col("--sans"); ctx.textBaseline='alphabetic'; ctx.textAlign='left';
|
| 800 |
+
const LBL_H=13, LBL_PADX=3;
|
| 801 |
+
let lastAboveRight=-Infinity, lastBelowRight=-Infinity;
|
| 802 |
notes.slice().sort((a,b)=>a.start-b.start).forEach(n=>{
|
| 803 |
if(!n.lyric) return;
|
| 804 |
+
if(n.id===lyricEditId) return;
|
| 805 |
+
if(n.start+n.dur < vs || n.start > vs+vd) return;
|
| 806 |
+
const nx=x(n.start), ny=y(n.pitch)+gap;
|
| 807 |
+
const tw=ctx.measureText(n.lyric).width, boxW=tw+LBL_PADX*2;
|
| 808 |
+
const above = ny - gap - 2 >= padT + LBL_H; // room over the note?
|
| 809 |
+
let boxY;
|
| 810 |
+
if(above){
|
| 811 |
+
if(nx < lastAboveRight + 2) return;
|
| 812 |
+
boxY = ny - 2 - LBL_H;
|
| 813 |
+
lastAboveRight = nx + boxW;
|
| 814 |
+
} else {
|
| 815 |
+
const noteBottom = ny + bh;
|
| 816 |
+
if(nx < lastBelowRight + 2) return;
|
| 817 |
+
boxY = Math.min(noteBottom + 2, h - padB - LBL_H);
|
| 818 |
+
lastBelowRight = nx + boxW;
|
| 819 |
+
}
|
| 820 |
+
ctx.fillStyle='rgba(138,99,168,0.12)';
|
| 821 |
+
roundRectCtx(ctx, nx, boxY, boxW, LBL_H, 3); ctx.fill();
|
| 822 |
ctx.fillStyle='#5a3f78';
|
| 823 |
+
ctx.fillText(n.lyric, nx+LBL_PADX, boxY+LBL_H-3.5);
|
|
|
|
| 824 |
});
|
| 825 |
}
|
| 826 |
ctx.restore();
|
|
|
|
| 1184 |
return "";
|
| 1185 |
}
|
| 1186 |
function closeLyricEditor(commit){
|
| 1187 |
+
const el=$(".js-lyricin"), box=$(".js-lyricbox");
|
| 1188 |
+
if(lyricEditId==null){ box.classList.remove("on"); return; }
|
| 1189 |
const n=byId(lyricEditId), id=lyricEditId;
|
| 1190 |
+
lyricEditId=null; box.classList.remove("on");
|
| 1191 |
if(commit && n){
|
| 1192 |
const v=el.value.trim();
|
| 1193 |
if((n.lyric||"") !== v){ pushUndo(); n.lyric = v || undefined; afterEdit(); }
|
|
|
|
| 1196 |
}
|
| 1197 |
function openLyricEditor(id){
|
| 1198 |
const n=byId(id); if(!n) return;
|
| 1199 |
+
// bring the note into view first, so the box is never off screen
|
| 1200 |
if(rollZoom>1){
|
| 1201 |
const vd=rollViewDur();
|
| 1202 |
if(n.start < geo.vs || n.start > geo.vs+vd*0.9){ rollVS=n.start-vd*0.25; rollClampVS(); draw(); }
|
| 1203 |
}
|
| 1204 |
+
const el=$(".js-lyricin"), box=$(".js-lyricbox"), wrap=cv.parentElement;
|
|
|
|
| 1205 |
el.value = n.lyric || markerTextFor(n);
|
|
|
|
|
|
|
|
|
|
| 1206 |
lyricEditId=id;
|
| 1207 |
selectedIds=new Set([id]); draw();
|
| 1208 |
+
// draw() has run, so geo is current: place the box just under the note,
|
| 1209 |
+
// clamped to the visible area both ways (the box is ~150px wide, 26 tall).
|
| 1210 |
+
box.classList.add("on");
|
| 1211 |
+
const bw=box.offsetWidth||150, bh2=box.offsetHeight||26;
|
| 1212 |
+
const nx=timeToX(n.start), ny=geo.padT+(geo.hi-n.pitch)*geo.rowH+geo.rowH+2;
|
| 1213 |
+
box.style.left = Math.max(2, Math.min(nx, wrap.clientWidth - bw - 2)) + "px";
|
| 1214 |
+
box.style.top = Math.max(2, Math.min(ny, wrap.clientHeight - bh2 - 2)) + "px";
|
| 1215 |
el.focus(); el.select();
|
| 1216 |
}
|
| 1217 |
function nextNoteAfter(id){
|
|
|
|
| 1219 |
const i=ordered.findIndex(n=>n.id===id);
|
| 1220 |
return (i>=0 && i<ordered.length-1) ? ordered[i+1].id : null;
|
| 1221 |
}
|
| 1222 |
+
// A pointerdown on either button must not let the input's blur fire first
|
| 1223 |
+
// and commit/close before the button's own handler runs.
|
| 1224 |
+
let suppressBlur=false;
|
| 1225 |
+
[".js-lyricok",".js-lyricx"].forEach(sel=>{
|
| 1226 |
+
$(sel).addEventListener("pointerdown", ()=>{ suppressBlur=true; });
|
| 1227 |
+
});
|
| 1228 |
$(".js-lyricin").addEventListener("keydown", e=>{
|
| 1229 |
e.stopPropagation(); // never reaches the global shortcuts
|
| 1230 |
if(e.key==="Enter"){
|
| 1231 |
e.preventDefault();
|
| 1232 |
const id=closeLyricEditor(true);
|
| 1233 |
const nxt=e.shiftKey?null:nextNoteAfter(id);
|
| 1234 |
+
if(nxt!=null) openLyricEditor(nxt); else draw();
|
| 1235 |
} else if(e.key==="Escape"){
|
| 1236 |
e.preventDefault(); closeLyricEditor(false); draw();
|
| 1237 |
} else if(e.key==="Tab"){
|
| 1238 |
e.preventDefault();
|
| 1239 |
const id=closeLyricEditor(true);
|
| 1240 |
const nxt=nextNoteAfter(id);
|
| 1241 |
+
if(nxt!=null) openLyricEditor(nxt); else draw();
|
| 1242 |
}
|
| 1243 |
});
|
| 1244 |
+
$(".js-lyricin").addEventListener("blur", ()=>{
|
| 1245 |
+
if(suppressBlur){ suppressBlur=false; return; }
|
| 1246 |
+
if(lyricEditId!=null){ closeLyricEditor(true); draw(); }
|
| 1247 |
+
});
|
| 1248 |
+
$(".js-lyricok").addEventListener("click", ()=>{
|
| 1249 |
+
const id=closeLyricEditor(true);
|
| 1250 |
+
const nxt=nextNoteAfter(id);
|
| 1251 |
+
if(nxt!=null) openLyricEditor(nxt); else draw();
|
| 1252 |
+
});
|
| 1253 |
+
$(".js-lyricx").addEventListener("click", ()=>{ closeLyricEditor(false); draw(); });
|
| 1254 |
|
| 1255 |
function retuneTo(id, pitch){
|
| 1256 |
const n = byId(id); if(!n) return;
|