Add visual melody editor for classical pieces

New editor screen lets users fix incorrect melodies without editing code:
- Canvas-based timeline with 5 lane rows (Red/Yellow/Blue/Green/Kick)
- Click to add notes, click existing notes to select, drag to move
- Delete/Backspace to remove selected note
- Arrow keys to nudge timing and lane
- Beat grid snapping (1/4 beat resolution)
- BPM adjustment
- Zoom slider for timeline scale
- Preview with orchestral background + melody clicks
- Save to localStorage (auto-loaded when playing the piece)
- Copy JSON to clipboard for sharing corrections
- Reset button to restore original melody
- Edit button appears in menu only for classical pieces

Edits are loaded in buildClassicalNotes() so saved corrections
are used during gameplay automatically.

https://claude.ai/code/session_01TFVkxq4PYhkSFGYHEi4uqY
This commit is contained in:
Claude
2026-04-01 16:18:09 +00:00
parent 6baec25e6b
commit 6e2538412a
+338 -2
View File
@@ -131,6 +131,7 @@ canvas{display:block;}
<div class="sticky-start">
<button class="smbtn" onclick="startRemap()">🎮 Remap Pads</button>
<button class="smbtn" id="edit-btn" onclick="openEditor()" style="display:none;">✏️ Edit Melody</button>
<button class="bigbtn" onclick="startGame()">▶ Start</button>
</div>
</div>
@@ -179,6 +180,25 @@ canvas{display:block;}
</div>
</div>
<!-- EDITOR -->
<div class="screen" id="sed" style="padding:0;">
<div id="ed-toolbar" style="display:flex;align-items:center;gap:8px;padding:6px 12px;background:#13131f;border-bottom:1px solid #333;flex-wrap:wrap;">
<button class="smbtn" onclick="closeEditor()">← Back</button>
<span id="ed-title" style="font-weight:bold;color:#ccc;margin-right:8px;">Editing: —</span>
<label style="color:#aaa;font-size:12px;">BPM: <input type="number" id="ed-bpm" value="120" min="30" max="300" style="width:55px;background:#1a1a2e;color:#fff;border:1px solid #444;padding:2px 4px;border-radius:3px;" onchange="edUpdateBpm()"></label>
<label style="color:#aaa;font-size:12px;">Zoom: <input type="range" id="ed-zoom" min="20" max="200" value="80" style="width:80px;" oninput="edDraw()"></label>
<button class="smbtn" onclick="edPreview()" id="ed-pv-btn">▶ Preview</button>
<button class="smbtn" onclick="edSave()" style="background:#38a169;">💾 Save</button>
<button class="smbtn" onclick="edExport()">📋 Copy JSON</button>
<button class="smbtn red" onclick="edReset()">↺ Reset</button>
<span id="ed-status" style="color:#888;font-size:11px;margin-left:auto;"></span>
</div>
<div id="ed-help" style="padding:4px 12px;background:#0d0d18;color:#666;font-size:11px;border-bottom:1px solid #222;">
Click = add note | Click note = select | Drag = move | Delete/Backspace = remove selected | Scroll = pan timeline
</div>
<canvas id="ed-canvas" style="display:block;width:100%;cursor:crosshair;"></canvas>
</div>
<script>
// ── LANE DEFINITIONS ──────────────────────────────────
const LANES=[
@@ -3086,12 +3106,15 @@ const WORLD_PIECES=(function(){
var SYNTH_GENRES={"Prime":PRIME_PIECES,"Singalong":SINGALONG_PIECES,"Christmas":CHRISTMAS_PIECES,"Folk":FOLK_PIECES,"Spirituals":SPIRITUAL_PIECES,"World":WORLD_PIECES,"Classical":CLASSICAL_PIECES,"Video Games":VIDEOGAME_PIECES,"Chiptune Originals":CHIPTUNE_PIECES};
function buildClassicalNotes(piece){
return piece.melody.filter(function(m){return activePads[m.l];})
// Check for saved melody edits
var saved=edLoadSaved(piece.n);
var melody=saved?saved.melody:piece.melody;
return melody.filter(function(m){return activePads[m.l];})
.map(function(m){
var freq=261.63;
for(var i=0;i<piece.bg.length;i++){
var b=piece.bg[i];
if(Math.abs(b.t-m.t)<0.01&&b.i!=='bass'&&b.i!=='timpani'){freq=b.f;break;}
if(Math.abs(b.t-m.t)<0.05&&b.i!=='bass'&&b.i!=='timpani'){freq=b.f;break;}
}
return{time:m.t/gameSpeed,lane:m.l,freq:freq,hit:false,missed:false};
})
@@ -3250,6 +3273,7 @@ function buildMenu(){
pl.appendChild(row);
});
updateHitModeVisibility();
updateEditBtn();
}
buildMenu();loadSettings();
function addPlayer(){players.push({name:"Player "+(players.length+1)});buildMenu();}
@@ -3327,6 +3351,318 @@ function renderScores(){
}
refreshMiniLB();
// ── MELODY EDITOR ─────────────────────────────────────
var edPiece=null,edMelody=[],edBpm=120,edSelected=-1,edScrollX=0,edDragging=false,edDragNote=-1,edDragOffX=0,edDragOffY=0,edPreviewing=false;
var ED_LANE_H=50,ED_KICK_H=40,ED_HEAD=0,ED_NOTE_W=14,ED_NOTE_H=16;
var ED_COLORS=["#e53e3e","#d69e2e","#3182ce","#38a169","#805ad5"];
var ED_NAMES=["Red","Yellow","Blue","Green","Kick"];
function getEdCanvas(){return document.getElementById("ed-canvas");}
function getEdCtx(){return getEdCanvas().getContext("2d");}
function edPxPerSec(){return parseInt(document.getElementById("ed-zoom").value);}
function edTimeToX(t){return t*edPxPerSec()-edScrollX;}
function edXToTime(x){return(x+edScrollX)/edPxPerSec();}
function edYToLane(y){
if(y<ED_HEAD)return-1;
var laneY=y-ED_HEAD;
var padH=ED_LANE_H;
for(var i=0;i<4;i++){if(laneY<padH*(i+1))return i;}
return 4; // kick
}
function edLaneToY(l){
if(l<4)return ED_HEAD+l*ED_LANE_H+ED_LANE_H/2;
return ED_HEAD+4*ED_LANE_H+ED_KICK_H/2;
}
function openEditor(){
var pieces=SYNTH_GENRES[curGenre];
if(!pieces||!pieces[curIdx])return;
edPiece=pieces[curIdx];
if(!edPiece.isClassical)return;
// Load saved edits or use original
var saved=edLoadSaved(edPiece.n);
if(saved){
edMelody=saved.melody.map(function(m){return{t:m.t,l:m.l};});
edBpm=saved.bpm||edPiece.bpm;
}else{
edMelody=edPiece.melody.map(function(m){return{t:m.t,l:m.l};});
edBpm=edPiece.bpm;
}
edSelected=-1;edScrollX=0;
document.getElementById("ed-title").textContent="Editing: "+edPiece.n;
document.getElementById("ed-bpm").value=edBpm;
show("sed");
setTimeout(edResize,50);
}
function closeEditor(){stopPreview();edPreviewing=false;document.getElementById("ed-pv-btn").textContent="▶ Preview";show("sm");}
function edResize(){
var cv=getEdCanvas();
var rect=cv.parentElement.getBoundingClientRect();
var toolH=document.getElementById("ed-toolbar").offsetHeight+document.getElementById("ed-help").offsetHeight;
cv.width=rect.width;
cv.height=window.innerHeight-toolH-cv.getBoundingClientRect().top+window.scrollY;
ED_LANE_H=Math.floor((cv.height-ED_KICK_H)/4);
edDraw();
}
window.addEventListener("resize",function(){if(document.getElementById("sed").classList.contains("active"))edResize();});
function edDraw(){
var cv=getEdCanvas(),ctx=getEdCtx(),W=cv.width,H=cv.height;
ctx.clearRect(0,0,W,H);
var pps=edPxPerSec();
// Draw lane backgrounds
for(var i=0;i<4;i++){
ctx.fillStyle=i%2===0?"#0e0e1a":"#111122";
ctx.fillRect(0,ED_HEAD+i*ED_LANE_H,W,ED_LANE_H);
// Lane label
ctx.fillStyle=ED_COLORS[i];ctx.globalAlpha=0.3;
ctx.font="bold 12px sans-serif";
ctx.fillText(ED_NAMES[i],4,ED_HEAD+i*ED_LANE_H+15);
ctx.globalAlpha=1;
}
// Kick lane
ctx.fillStyle="#0a0a14";
ctx.fillRect(0,ED_HEAD+4*ED_LANE_H,W,ED_KICK_H);
ctx.fillStyle=ED_COLORS[4];ctx.globalAlpha=0.3;
ctx.font="bold 12px sans-serif";
ctx.fillText("Kick",4,ED_HEAD+4*ED_LANE_H+15);
ctx.globalAlpha=1;
// Grid lines (every beat and every second)
var beatSec=60/edBpm;
var startBeat=Math.floor(edXToTime(0)/beatSec);
var endBeat=Math.ceil(edXToTime(W)/beatSec);
for(var b=startBeat;b<=endBeat;b++){
var bx=edTimeToX(b*beatSec);
if(bx<0||bx>W)continue;
ctx.strokeStyle=b%4===0?"#444":"#222";
ctx.lineWidth=b%4===0?1.5:0.5;
ctx.beginPath();ctx.moveTo(bx,ED_HEAD);ctx.lineTo(bx,H);ctx.stroke();
if(b%4===0){
ctx.fillStyle="#555";ctx.font="10px sans-serif";
ctx.fillText((b*beatSec).toFixed(1)+"s",bx+2,ED_HEAD+H-4);
}
}
// Second markers
var startSec=Math.floor(edXToTime(0));
var endSec=Math.ceil(edXToTime(W));
for(var s=startSec;s<=endSec;s++){
var sx=edTimeToX(s);
if(sx<0||sx>W)continue;
ctx.strokeStyle="#333";ctx.lineWidth=0.5;
ctx.beginPath();ctx.moveTo(sx,0);ctx.lineTo(sx,ED_HEAD);ctx.stroke();
}
// Lane separator lines
for(i=1;i<5;i++){
var ly=ED_HEAD+i*ED_LANE_H;
ctx.strokeStyle="#333";ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(0,ly);ctx.lineTo(W,ly);ctx.stroke();
}
// Draw notes
edMelody.forEach(function(m,idx){
var nx=edTimeToX(m.t)-ED_NOTE_W/2;
var ny=edLaneToY(m.l)-ED_NOTE_H/2;
if(nx+ED_NOTE_W<0||nx>W)return;
ctx.fillStyle=ED_COLORS[m.l]||"#888";
ctx.globalAlpha=idx===edSelected?1:0.8;
ctx.fillRect(nx,ny,ED_NOTE_W,ED_NOTE_H);
if(idx===edSelected){
ctx.strokeStyle="#fff";ctx.lineWidth=2;
ctx.strokeRect(nx-1,ny-1,ED_NOTE_W+2,ED_NOTE_H+2);
}
ctx.globalAlpha=1;
});
// Duration marker
if(edPiece){
var dx=edTimeToX(edPiece.duration);
if(dx>0&&dx<W){
ctx.strokeStyle="#e53e3e";ctx.lineWidth=2;ctx.setLineDash([4,4]);
ctx.beginPath();ctx.moveTo(dx,0);ctx.lineTo(dx,H);ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle="#e53e3e";ctx.font="10px sans-serif";
ctx.fillText("END",dx+3,12);
}
}
// Status
document.getElementById("ed-status").textContent=edMelody.length+" notes | "+
(edSelected>=0?"Selected #"+edSelected+" (t="+edMelody[edSelected].t.toFixed(3)+"s, "+ED_NAMES[edMelody[edSelected].l]+")":"Click to add, click note to select");
}
function edHitTest(x,y){
for(var i=edMelody.length-1;i>=0;i--){
var m=edMelody[i];
var nx=edTimeToX(m.t)-ED_NOTE_W/2;
var ny=edLaneToY(m.l)-ED_NOTE_H/2;
if(x>=nx&&x<=nx+ED_NOTE_W&&y>=ny&&y<=ny+ED_NOTE_H)return i;
}
return-1;
}
// Mouse handlers
function edMouseDown(e){
var cv=getEdCanvas(),rect=cv.getBoundingClientRect();
var mx=e.clientX-rect.left,my=e.clientY-rect.top;
var hit=edHitTest(mx,my);
if(hit>=0){
edSelected=hit;
edDragging=true;edDragNote=hit;
edDragOffX=mx-edTimeToX(edMelody[hit].t);
edDragOffY=my-edLaneToY(edMelody[hit].l);
edDraw();
}else{
// Add new note
var t=edXToTime(mx);
var l=edYToLane(my);
if(l>=0&&l<=4&&t>=0){
// Snap to nearest beat subdivision (1/4 beat)
var snap=60/edBpm/4;
t=Math.round(t/snap)*snap;
edMelody.push({t:t,l:l});
edMelody.sort(function(a,b){return a.t-b.t;});
edSelected=edMelody.findIndex(function(m){return m.t===t&&m.l===l;});
edDraw();
}
}
}
function edMouseMove(e){
if(!edDragging||edDragNote<0)return;
var cv=getEdCanvas(),rect=cv.getBoundingClientRect();
var mx=e.clientX-rect.left,my=e.clientY-rect.top;
var t=edXToTime(mx-edDragOffX);
var l=edYToLane(my);
var snap=60/edBpm/4;
t=Math.max(0,Math.round(t/snap)*snap);
if(l>=0&&l<=4){
edMelody[edDragNote].t=t;
edMelody[edDragNote].l=l;
edSelected=edDragNote;
edDraw();
}
}
function edMouseUp(){
if(edDragging){
edDragging=false;edDragNote=-1;
edMelody.sort(function(a,b){return a.t-b.t;});
// Re-find selected after sort
edDraw();
}
}
function edWheel(e){
e.preventDefault();
edScrollX+=e.deltaX||e.deltaY;
if(edScrollX<0)edScrollX=0;
edDraw();
}
function edKeyDown(e){
if(!document.getElementById("sed").classList.contains("active"))return;
if((e.key==="Delete"||e.key==="Backspace")&&edSelected>=0){
edMelody.splice(edSelected,1);
edSelected=-1;
edDraw();
e.preventDefault();
}
if(e.key==="ArrowLeft"&&edSelected>=0){
var snap=60/edBpm/4;
edMelody[edSelected].t=Math.max(0,edMelody[edSelected].t-snap);
edMelody.sort(function(a,b){return a.t-b.t;});
edDraw();e.preventDefault();
}
if(e.key==="ArrowRight"&&edSelected>=0){
var snap2=60/edBpm/4;
edMelody[edSelected].t+=snap2;
edMelody.sort(function(a,b){return a.t-b.t;});
edDraw();e.preventDefault();
}
if(e.key==="ArrowUp"&&edSelected>=0){
edMelody[edSelected].l=Math.max(0,edMelody[edSelected].l-1);
edDraw();e.preventDefault();
}
if(e.key==="ArrowDown"&&edSelected>=0){
edMelody[edSelected].l=Math.min(4,edMelody[edSelected].l+1);
edDraw();e.preventDefault();
}
}
// Setup editor canvas events
(function(){
var cv=document.getElementById("ed-canvas");
cv.addEventListener("mousedown",edMouseDown);
cv.addEventListener("mousemove",edMouseMove);
cv.addEventListener("mouseup",edMouseUp);
cv.addEventListener("mouseleave",edMouseUp);
cv.addEventListener("wheel",edWheel,{passive:false});
document.addEventListener("keydown",edKeyDown);
})();
function edUpdateBpm(){edBpm=parseInt(document.getElementById("ed-bpm").value)||120;edDraw();}
function edPreview(){
if(edPreviewing){stopPreview();edPreviewing=false;document.getElementById("ed-pv-btn").textContent="▶ Preview";return;}
// Play the piece with current edits
var ac=getAc(true);ac.resume();
var piece=edPiece;
for(var i=0;i<piece.bg.length;i++){
var n=piece.bg[i];
playSynth(ac,n.f,ac.currentTime+0.1+n.t,n.d,n.i,n.v*1.2,previewNodes);
}
// Also play a click/drum sound on each melody note so user hears the hits
edMelody.forEach(function(m){
var freq=[329,392,440,349,220][m.l]||329;
playSynth(ac,freq,ac.currentTime+0.1+m.t,0.1,'woodwind',0.5,previewNodes);
});
edPreviewing=true;
document.getElementById("ed-pv-btn").textContent="⏹ Stop";
// Auto-stop after duration
var dur=piece.duration||90;
setTimeout(function(){if(edPreviewing){edPreviewing=false;document.getElementById("ed-pv-btn").textContent="▶ Preview";}},dur*1000+500);
}
// Save/load edits to localStorage
var ED_STORAGE_KEY="drgame_edits";
function edGetAllSaved(){try{return JSON.parse(localStorage.getItem(ED_STORAGE_KEY))||{};}catch(e){return{};}}
function edLoadSaved(name){var all=edGetAllSaved();return all[name]||null;}
function edSave(){
var all=edGetAllSaved();
all[edPiece.n]={melody:edMelody.map(function(m){return{t:m.t,l:m.l};}),bpm:edBpm};
try{localStorage.setItem(ED_STORAGE_KEY,JSON.stringify(all));}catch(e){}
document.getElementById("ed-status").textContent="Saved! ("+edMelody.length+" notes)";
}
function edReset(){
if(!confirm("Reset to original melody? This discards your edits."))return;
var all=edGetAllSaved();delete all[edPiece.n];
try{localStorage.setItem(ED_STORAGE_KEY,JSON.stringify(all));}catch(e){}
edMelody=edPiece.melody.map(function(m){return{t:m.t,l:m.l};});
edBpm=edPiece.bpm;
document.getElementById("ed-bpm").value=edBpm;
edSelected=-1;edDraw();
}
function edExport(){
var data=JSON.stringify({name:edPiece.n,bpm:edBpm,melody:edMelody},null,2);
navigator.clipboard.writeText(data).then(function(){
document.getElementById("ed-status").textContent="Copied to clipboard!";
}).catch(function(){
// Fallback: show in prompt
prompt("Copy this JSON:",data);
});
}
// Show/hide edit button based on whether current genre has classical pieces
function updateEditBtn(){
var pieces=SYNTH_GENRES[curGenre];
var btn=document.getElementById("edit-btn");
if(pieces&&pieces[curIdx]&&pieces[curIdx].isClassical){
btn.style.display="";
}else{
btn.style.display="none";
}
}
// ── GAME START ─────────────────────────────────────────
function startGame(){
stopPreview();