luna0805 commited on
Commit
386d2de
·
verified ·
1 Parent(s): 59f75f2

Update transcription-editor.html

Browse files
Files changed (1) hide show
  1. 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-input{position:absolute; z-index:20; font-family:var(--sans); font-size:11px; padding:2px 5px;
94
- border:1px solid var(--sel); border-radius:4px; background:#fff; color:#5a3f78; width:104px; outline:none}
 
 
 
 
 
 
 
 
 
 
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
  &uarr;/&darr; to transpose by a semitone (Shift for an octave) &middot; Ctrl+Z to undo.<br>
454
- <b>Lyrics</b> &middot; double-click a note to type its word or syllable &middot;
455
- Enter moves to the next note, Esc closes. These are what appear under the alignment below.<br>
 
456
  <b>View</b> &middot; scroll to zoom, Shift + scroll to pan.<br>
457
  <b>Waveform markers</b> (optional, above) &middot; drag to reposition &middot;
458
  Shift-drag to shift the whole track &middot; 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
- <input class="lyric-input js-lyricin" hidden spellcheck="false" autocomplete="off">
 
 
 
 
 
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 anyLyric = notes.some(n=>n.lyric);
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+stripH); ctx.clip();
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 strip: one word per note, skipped where two would collide */
780
- if(anyLyric){
781
- ctx.font='10px '+col("--sans"); ctx.textBaseline='top'; ctx.textAlign='left';
782
- const sy = h - padB + 2;
783
- let lastRight = -Infinity;
 
 
 
 
784
  notes.slice().sort((a,b)=>a.start-b.start).forEach(n=>{
785
  if(!n.lyric) return;
786
- if(n.start < vs || n.start > vs+vd) return;
787
- const lx = x(n.start);
788
- if(lx < lastRight + 3) return;
789
- ctx.strokeStyle='rgba(138,99,168,0.35)'; ctx.lineWidth=1;
790
- ctx.beginPath(); ctx.moveTo(lx, padT); ctx.lineTo(lx, sy); ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  ctx.fillStyle='#5a3f78';
792
- ctx.fillText(n.lyric, lx+2, sy);
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){ el.hidden=true; return; }
1159
  const n=byId(lyricEditId), id=lyricEditId;
1160
- lyricEditId=null; el.hidden=true;
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 field is never off screen
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", ()=>{ if(lyricEditId!=null) closeLyricEditor(true); });
 
 
 
 
 
 
 
 
 
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 &middot;
462
  drag to transpose &middot; drag either edge to adjust onset or duration &middot;
463
  &uarr;/&darr; to transpose by a semitone (Shift for an octave) &middot; Ctrl+Z to undo.<br>
464
+ <b>Lyrics</b> &middot; double-click a note to type its word or syllable, then press &#10003; (or Enter) to confirm &middot;
465
+ the word appears by the note, Enter jumps to the next note, Esc cancels &middot;
466
+ these are the lyrics carried through to the alignment below.<br>
467
  <b>View</b> &middot; scroll to zoom, Shift + scroll to pan.<br>
468
  <b>Waveform markers</b> (optional, above) &middot; drag to reposition &middot;
469
  Shift-drag to shift the whole track &middot; 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)">&#10003;</button>
479
+ <button class="lyric-x js-lyricx" title="Cancel (Esc)">&times;</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;