diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 6fbddcd..b3f14a6 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -4551,11 +4551,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot const newBeat = Math.max(0, Math.min(maxBeats, currentBeat + step)); const newTime = newBeat * beatSec; setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: newTime } : s)); - // Play notes at the new beat position + // Play notes only when playhead crosses a note start if (window.SonicSF) { const ctx = getAudioContext(); const playing = notes.filter(n => - newBeat >= n.start_beat && newBeat < n.start_beat + n.duration_beats + currentBeat < n.start_beat && newBeat >= n.start_beat ); playing.forEach(n => { window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null); @@ -4676,7 +4676,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot ctx.stroke(); } } - }, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm]); + }, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats]); React.useEffect(() => { const canvas = ccCanvasRef.current; @@ -4699,7 +4699,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot ctx.stroke(); notes.forEach((note) => { - const x = note.start_beat * pixelsPerBeat + (note.duration_beats * pixelsPerBeat) / 2; + const x = note.start_beat * pixelsPerBeat; let val = note.velocity !== undefined ? note.velocity : 0.8; if (ccMode === 'pan') { val = (note.pan !== undefined ? note.pan : 0.0) * 0.5 + 0.5; @@ -4720,7 +4720,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill(); }); - }, [notes, ccMode, rollZoom]); + }, [notes, ccMode, rollZoom, viewWidth]); React.useEffect(() => { const scrollToC3 = () => { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index a4320a4..aa20be8 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -135,15 +135,15 @@ const drawWidth=totalBeats*pixelsPerBeat;const[rollViewWidth,setRollViewWidth]=R const undoStackRef=React.useRef([]);const redoStackRef=React.useRef([]);const notesBeforeDragRef=React.useRef(null);const pushToUndo=React.useCallback(prevNotes=>{undoStackRef.current.push(JSON.parse(JSON.stringify(prevNotes)));redoStackRef.current=[];if(undoStackRef.current.length>50)undoStackRef.current.shift();},[]);const handleUndo=React.useCallback(()=>{const prev=undoStackRef.current.pop();if(!prev)return;redoStackRef.current.push(JSON.parse(JSON.stringify(notes)));setNotes(prev);setSelectedNoteIds([]);},[notes]);const handleRedo=React.useCallback(()=>{const next=redoStackRef.current.pop();if(!next)return;undoStackRef.current.push(JSON.parse(JSON.stringify(notes)));setNotes(next);setSelectedNoteIds([]);},[notes]);React.useEffect(()=>{const handler=e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'&&!e.shiftKey){e.preventDefault();handleUndo();}else if((e.ctrlKey||e.metaKey)&&(e.key==='y'||e.key==='z'&&e.shiftKey)){e.preventDefault();handleRedo();}else if(e.key==='Delete'||e.key==='Backspace'){if(selectedNoteIds.length>0&&e.target.tagName!=='INPUT'&&e.target.tagName!=='TEXTAREA'){e.preventDefault();pushToUndo(notes);setNotes(prev=>prev.filter(n=>!selectedNoteIds.includes(n.id)));setSelectedNoteIds([]);showToast(`Đã xóa ${selectedNoteIds.length} nốt!`,'info');}}};window.addEventListener('keydown',handler);return()=>window.removeEventListener('keydown',handler);},[handleUndo,handleRedo,notes,selectedNoteIds]);React.useEffect(()=>{onUpdateNotes(st.id,notes);},[notes]);const getSnapBeat=(beat,mode)=>{let q=0.25;if(mode==='free')return beat;if(mode==='1')q=4.0;else if(mode==='1/2')q=2.0;else if(mode==='1/4')q=1.0;else if(mode==='1/8')q=0.5;else if(mode==='1/16')q=0.25;else if(mode==='1/32')q=0.125;return Math.round(beat/q)*q;};const getSnapDuration=mode=>{if(mode==='free')return 0.25;if(mode==='1')return 4.0;if(mode==='1/2')return 2.0;if(mode==='1/4')return 1.0;if(mode==='1/8')return 0.5;if(mode==='1/16')return 0.25;if(mode==='1/32')return 0.125;return 0.25;};// Local Zoom Wheel Event handler to block browser page zoom React.useEffect(()=>{const handleWheelRaw=e=>{if(e.ctrlKey){e.preventDefault();const zoomFactor=e.deltaY<0?1.15:0.85;setRollZoom(prev=>Math.max(15,Math.min(250,prev*zoomFactor)));}};const container=gridScrollRef.current;if(container){container.addEventListener('wheel',handleWheelRaw,{passive:false});}return()=>{if(container){container.removeEventListener('wheel',handleWheelRaw);}};},[]);// Shift + Scroll event listener: fast‑forward playhead + play notes React.useEffect(()=>{const handleCanvasWheel=e=>{if(e.shiftKey){e.preventDefault();const scrollDelta=e.deltaY;const beatSec=60.0/(parseInt(bpm)||120);const step=scrollDelta<0?-0.25:0.25;// 1/4 beat per notch -const currentBeat=(st.currentTime||0)/beatSec;const maxBeats=totalBeats;const newBeat=Math.max(0,Math.min(maxBeats,currentBeat+step));const newTime=newBeat*beatSec;setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:newTime}:s));// Play notes at the new beat position -if(window.SonicSF){const ctx=getAudioContext();const playing=notes.filter(n=>newBeat>=n.start_beat&&newBeat{window.SonicSF.playNote(n.pitch,(n.velocity||0.8)*127,200,ctx.currentTime,st.instrumentProgram,null);});}}};const canvas=canvasRef.current;if(canvas){canvas.addEventListener('wheel',handleCanvasWheel,{passive:false});}return()=>{if(canvas){canvas.removeEventListener('wheel',handleCanvasWheel);}};},[notes,st.currentTime,pixelsPerBeat,st.id,totalBeats,bpm]);React.useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=128*NoteHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);// Draw background rows +const currentBeat=(st.currentTime||0)/beatSec;const maxBeats=totalBeats;const newBeat=Math.max(0,Math.min(maxBeats,currentBeat+step));const newTime=newBeat*beatSec;setSubTabs(prev=>prev.map(s=>s.id===st.id?{...s,currentTime:newTime}:s));// Play notes only when playhead crosses a note start +if(window.SonicSF){const ctx=getAudioContext();const playing=notes.filter(n=>currentBeat=n.start_beat);playing.forEach(n=>{window.SonicSF.playNote(n.pitch,(n.velocity||0.8)*127,200,ctx.currentTime,st.instrumentProgram,null);});}}};const canvas=canvasRef.current;if(canvas){canvas.addEventListener('wheel',handleCanvasWheel,{passive:false});}return()=>{if(canvas){canvas.removeEventListener('wheel',handleCanvasWheel);}};},[notes,st.currentTime,pixelsPerBeat,st.id,totalBeats,bpm]);React.useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=128*NoteHeight;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);// Draw background rows for(let pitch=0;pitch<128;pitch++){const y=(127-pitch)*NoteHeight;const isBlack=[1,3,6,8,10].includes(pitch%12);ctx.fillStyle=isBlack?'#1a1a1e':'#25252a';ctx.fillRect(0,y,viewWidth,NoteHeight);ctx.strokeStyle='#2d2d35';ctx.lineWidth=0.5;ctx.beginPath();ctx.moveTo(0,y+NoteHeight);ctx.lineTo(viewWidth,y+NoteHeight);ctx.stroke();}// Draw snap lines let snapBeats=0.25;if(snapVal==='1')snapBeats=4.0;else if(snapVal==='1/2')snapBeats=2.0;else if(snapVal==='1/4')snapBeats=1.0;else if(snapVal==='1/8')snapBeats=0.5;else if(snapVal==='1/16')snapBeats=0.25;else if(snapVal==='1/32')snapBeats=0.125;for(let beat=0;beat<=viewBeats;beat+=snapBeats){const x=beat*pixelsPerBeat;if(x>drawWidth)break;const isBar=beat%timeSigNum===0;ctx.strokeStyle=isBar?'#444450':'#2d2d35';ctx.lineWidth=isBar?1.2:0.6;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke();}// Draw notes with velocity layer representation notes.forEach(note=>{const x=note.start_beat*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=note.duration_beats*pixelsPerBeat;const isSelected=selectedNoteIds.includes(note.id);// Draw background of note ctx.fillStyle=isSelected?'rgba(59, 130, 246, 0.4)':'rgba(234, 179, 8, 0.25)';ctx.strokeStyle=isSelected?'#3b82f6':'#ca8a04';ctx.lineWidth=isSelected?1.5:1;ctx.fillRect(x+1,y+1,w-2,NoteHeight-2);ctx.strokeRect(x+1,y+1,w-2,NoteHeight-2);// Draw velocity layer (solid yellow/blue bar inside, proportional to velocity) const vel=note.velocity!==undefined?note.velocity:0.8;const velW=Math.max(2,(w-2)*vel);ctx.fillStyle=isSelected?'#3b82f6':'#eab308';ctx.fillRect(x+1,y+1,velW,NoteHeight-2);});// Draw selection marquee if active if(selectionMarquee){const minBeat=Math.min(selectionMarquee.startBeat,selectionMarquee.currentBeat);const maxBeat=Math.max(selectionMarquee.startBeat,selectionMarquee.currentBeat);const minPitch=Math.min(selectionMarquee.startPitch,selectionMarquee.currentPitch);const maxPitch=Math.max(selectionMarquee.startPitch,selectionMarquee.currentPitch);const mx=minBeat*pixelsPerBeat;const my=(127-maxPitch)*NoteHeight;const mw=(maxBeat-minBeat)*pixelsPerBeat;const mh=(maxPitch-minPitch+1)*NoteHeight;ctx.fillStyle='rgba(59, 130, 246, 0.15)';ctx.strokeStyle='#3b82f6';ctx.lineWidth=1;ctx.setLineDash([4,4]);ctx.fillRect(mx,my,mw,mh);ctx.strokeRect(mx,my,mw,mh);ctx.setLineDash([]);}// Draw playhead -if(st.currentTime!==undefined&&st.currentTime!==null&&st.currentTime>=0){const phBeat=st.currentTime/(60.0/(parseInt(bpm)||120));const phX=phBeat*pixelsPerBeat;if(phX>=0&&phX<=viewWidth){ctx.strokeStyle='#f59e0b';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(phX,0);ctx.lineTo(phX,(128-PITCH_START)*NoteHeight);ctx.stroke();}}},[notes,snapVal,rollZoom,selectedNoteIds,selectionMarquee,st.currentTime,bpm]);React.useEffect(()=>{const canvas=ccCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=80;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);ctx.fillStyle='#161616';ctx.fillRect(0,0,viewWidth,h);ctx.strokeStyle='#252525';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(viewWidth,h/2);ctx.stroke();notes.forEach(note=>{const x=note.start_beat*pixelsPerBeat+note.duration_beats*pixelsPerBeat/2;let val=note.velocity!==undefined?note.velocity:0.8;if(ccMode==='pan'){val=(note.pan!==undefined?note.pan:0.0)*0.5+0.5;}const stemH=val*(h-20)+10;const y=h-stemH;ctx.strokeStyle=ccMode==='pan'?'#a78bfa':'#fbbf24';ctx.lineWidth=2.5;ctx.beginPath();ctx.moveTo(x,h);ctx.lineTo(x,y);ctx.stroke();ctx.fillStyle=ccMode==='pan'?'#c084fc':'#fbbf24';ctx.beginPath();ctx.arc(x,y,3.5,0,2*Math.PI);ctx.fill();});},[notes,ccMode,rollZoom]);React.useEffect(()=>{const scrollToC3=()=>{if(gridScrollRef.current){const ch=gridScrollRef.current.clientHeight||400;gridScrollRef.current.scrollTop=Math.max(0,(127-48)*NoteHeight+NoteHeight-ch);}};scrollToC3();const timer=setTimeout(scrollToC3,100);return()=>clearTimeout(timer);},[]);const handleGridMouseDown=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat;const pitch=127-Math.floor(y/NoteHeight);if(scaleMenuPos)setScaleMenuPos(null);// Right click -> Quick delete note or start erase sweep +if(st.currentTime!==undefined&&st.currentTime!==null&&st.currentTime>=0){const phBeat=st.currentTime/(60.0/(parseInt(bpm)||120));const phX=phBeat*pixelsPerBeat;if(phX>=0&&phX<=viewWidth){ctx.strokeStyle='#f59e0b';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(phX,0);ctx.lineTo(phX,(128-PITCH_START)*NoteHeight);ctx.stroke();}}},[notes,snapVal,rollZoom,selectedNoteIds,selectionMarquee,st.currentTime,bpm,viewWidth,viewBeats]);React.useEffect(()=>{const canvas=ccCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=80;canvas.width=viewWidth*dpr;canvas.height=h*dpr;ctx.scale(dpr,dpr);ctx.fillStyle='#161616';ctx.fillRect(0,0,viewWidth,h);ctx.strokeStyle='#252525';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(viewWidth,h/2);ctx.stroke();notes.forEach(note=>{const x=note.start_beat*pixelsPerBeat;let val=note.velocity!==undefined?note.velocity:0.8;if(ccMode==='pan'){val=(note.pan!==undefined?note.pan:0.0)*0.5+0.5;}const stemH=val*(h-20)+10;const y=h-stemH;ctx.strokeStyle=ccMode==='pan'?'#a78bfa':'#fbbf24';ctx.lineWidth=2.5;ctx.beginPath();ctx.moveTo(x,h);ctx.lineTo(x,y);ctx.stroke();ctx.fillStyle=ccMode==='pan'?'#c084fc':'#fbbf24';ctx.beginPath();ctx.arc(x,y,3.5,0,2*Math.PI);ctx.fill();});},[notes,ccMode,rollZoom,viewWidth]);React.useEffect(()=>{const scrollToC3=()=>{if(gridScrollRef.current){const ch=gridScrollRef.current.clientHeight||400;gridScrollRef.current.scrollTop=Math.max(0,(127-48)*NoteHeight+NoteHeight-ch);}};scrollToC3();const timer=setTimeout(scrollToC3,100);return()=>clearTimeout(timer);},[]);const handleGridMouseDown=e=>{const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const beat=x/pixelsPerBeat;const pitch=127-Math.floor(y/NoteHeight);if(scaleMenuPos)setScaleMenuPos(null);// Right click -> Quick delete note or start erase sweep if(e.button===2){e.preventDefault();const clickedNote=notes.find(n=>{return pitch===n.pitch&&beat>=n.start_beat&&beatprev.filter(n=>n.id!==clickedNote.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==clickedNote.id));showToast('Đã xóa nốt!','info');return;}// Start erase sweep (right-click on empty space only) notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));setDraggedNote({mode:'erase_sweep',visitedPitches:[pitch]});return;}if(e.button!==0)return;// Only handle left click // Check if clicking on an existing note