diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 0a081e5..c96e421 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -6175,9 +6175,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos ctx.fillStyle = layer.track_color || '#888'; ctx.strokeStyle = layer.track_color || '#888'; layer.notes.forEach(function(note) { - var x = (renderBeatOffset + note.relative_start_beat) * pixelsPerBeat; + var snapStart = snapVal !== 'free' ? getSnapBeat(note.relative_start_beat, snapVal) : note.relative_start_beat; + var rawEnd = note.relative_start_beat + note.duration_beats; + var snapEnd = snapVal !== 'free' ? getSnapBeat(rawEnd, snapVal) : rawEnd; + var x = (renderBeatOffset + snapStart) * pixelsPerBeat; var y = (127 - note.pitch) * NoteHeight; - var w = note.duration_beats * pixelsPerBeat; + var w = Math.max(2, (snapEnd - snapStart) * pixelsPerBeat); var h = NoteHeight - 1; ctx.fillRect(x, y, w, h); }); @@ -6187,9 +6190,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos // Layer 3: Active notes with velocity layer representation notes.forEach((note) => { - const x = (renderBeatOffset + note.start_beat) * pixelsPerBeat; + const snapStart = snapVal !== 'free' ? getSnapBeat(note.start_beat, snapVal) : note.start_beat; + const rawEnd = note.start_beat + note.duration_beats; + const snapEnd = snapVal !== 'free' ? getSnapBeat(rawEnd, snapVal) : rawEnd; + const x = (renderBeatOffset + snapStart) * pixelsPerBeat; const y = (127 - note.pitch) * NoteHeight; - const w = note.duration_beats * pixelsPerBeat; + const w = Math.max(2, (snapEnd - snapStart) * pixelsPerBeat); const isSelected = selectedNoteIds.includes(note.id); // Draw background of note @@ -6209,9 +6215,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos // Draw real-time recording notes if (recordingState === 'RECORDING' && recTempMidiNotes && recTempMidiNotes.length > 0) { recTempMidiNotes.forEach(note => { - const x = (renderBeatOffset + note.start_beat) * pixelsPerBeat; + const snapStart = snapVal !== 'free' ? getSnapBeat(note.start_beat, snapVal) : note.start_beat; + const rawEnd = note.start_beat + (note.duration_beats || 0.25); + const snapEnd = snapVal !== 'free' ? getSnapBeat(rawEnd, snapVal) : rawEnd; + const x = (renderBeatOffset + snapStart) * pixelsPerBeat; const y = (127 - note.pitch) * NoteHeight; - const w = (note.duration_beats || 0.25) * pixelsPerBeat; + const w = Math.max(2, (snapEnd - snapStart) * pixelsPerBeat); ctx.fillStyle = 'rgba(255, 100, 100, 0.35)'; ctx.strokeStyle = '#ff6464'; ctx.lineWidth = 1; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index d1b5650..546d527 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -208,11 +208,11 @@ const scrollBeat=mx/pixelsPerBeat-renderBeatOffset;const clickedNote=notes.find( const container=gridScrollRef.current;if(container)container.scrollLeft+=e.deltaY;}return;}if(e.altKey){e.preventDefault();const scrollDelta=e.deltaY;const beatSec=60.0/(parseInt(bpm)||120);const step=scrollDelta<0?-0.25:0.25;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));if(window.SonicSF){const ctx=getAudioContext();const playing=notes.filter(n=>currentBeat=n.start_beat);var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCh=window.SonicPianoRoll?window.SonicPianoRoll.getTrackMidiChannel(pvTrk,activeTracks):pvTrk?pvTrk.midiChannel:0;playing.forEach(n=>{window.SonicSF.playNote(n.pitch,(n.velocity||0.8)*127,200,ctx.currentTime,st.instrumentProgram,null,pvCh,pvTrk?pvTrk.synth_engine:undefined);});}}};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.useLayoutEffect(()=>{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=1.0;else if(snapVal==='1/2')snapBeats=0.5;else if(snapVal==='1/4')snapBeats=0.25;else if(snapVal==='1/8')snapBeats=0.125;else if(snapVal==='1/16')snapBeats=0.0625;else if(snapVal==='4')snapBeats=4.0;else if(snapVal==='1/32')snapBeats=0.03125;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();}// Layer 2: Ghost Notes (background reference from other tracks) -if(showGhostNotes&&sessionSyncMode&&ghostLayers.length>0){ghostLayers.forEach(function(layer){ctx.save();ctx.globalAlpha=0.25;ctx.fillStyle=layer.track_color||'#888';ctx.strokeStyle=layer.track_color||'#888';layer.notes.forEach(function(note){var x=(renderBeatOffset+note.relative_start_beat)*pixelsPerBeat;var y=(127-note.pitch)*NoteHeight;var w=note.duration_beats*pixelsPerBeat;var h=NoteHeight-1;ctx.fillRect(x,y,w,h);});ctx.restore();});}// Layer 3: Active notes with velocity layer representation -notes.forEach(note=>{const x=(renderBeatOffset+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 +if(showGhostNotes&&sessionSyncMode&&ghostLayers.length>0){ghostLayers.forEach(function(layer){ctx.save();ctx.globalAlpha=0.25;ctx.fillStyle=layer.track_color||'#888';ctx.strokeStyle=layer.track_color||'#888';layer.notes.forEach(function(note){var snapStart=snapVal!=='free'?getSnapBeat(note.relative_start_beat,snapVal):note.relative_start_beat;var rawEnd=note.relative_start_beat+note.duration_beats;var snapEnd=snapVal!=='free'?getSnapBeat(rawEnd,snapVal):rawEnd;var x=(renderBeatOffset+snapStart)*pixelsPerBeat;var y=(127-note.pitch)*NoteHeight;var w=Math.max(2,(snapEnd-snapStart)*pixelsPerBeat);var h=NoteHeight-1;ctx.fillRect(x,y,w,h);});ctx.restore();});}// Layer 3: Active notes with velocity layer representation +notes.forEach(note=>{const snapStart=snapVal!=='free'?getSnapBeat(note.start_beat,snapVal):note.start_beat;const rawEnd=note.start_beat+note.duration_beats;const snapEnd=snapVal!=='free'?getSnapBeat(rawEnd,snapVal):rawEnd;const x=(renderBeatOffset+snapStart)*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=Math.max(2,(snapEnd-snapStart)*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 real-time recording notes -if(recordingState==='RECORDING'&&recTempMidiNotes&&recTempMidiNotes.length>0){recTempMidiNotes.forEach(note=>{const x=(renderBeatOffset+note.start_beat)*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=(note.duration_beats||0.25)*pixelsPerBeat;ctx.fillStyle='rgba(255, 100, 100, 0.35)';ctx.strokeStyle='#ff6464';ctx.lineWidth=1;ctx.fillRect(x+1,y+1,Math.max(2,w-2),NoteHeight-2);ctx.strokeRect(x+1,y+1,Math.max(2,w-2),NoteHeight-2);const vel=Math.min(1,note.velocity||0.8);ctx.fillStyle='#ff6464';ctx.fillRect(x+1,y+1,Math.max(2,(w-2)*vel),NoteHeight-2);});}// Draw selection marquee if active +if(recordingState==='RECORDING'&&recTempMidiNotes&&recTempMidiNotes.length>0){recTempMidiNotes.forEach(note=>{const snapStart=snapVal!=='free'?getSnapBeat(note.start_beat,snapVal):note.start_beat;const rawEnd=note.start_beat+(note.duration_beats||0.25);const snapEnd=snapVal!=='free'?getSnapBeat(rawEnd,snapVal):rawEnd;const x=(renderBeatOffset+snapStart)*pixelsPerBeat;const y=(127-note.pitch)*NoteHeight;const w=Math.max(2,(snapEnd-snapStart)*pixelsPerBeat);ctx.fillStyle='rgba(255, 100, 100, 0.35)';ctx.strokeStyle='#ff6464';ctx.lineWidth=1;ctx.fillRect(x+1,y+1,Math.max(2,w-2),NoteHeight-2);ctx.strokeRect(x+1,y+1,Math.max(2,w-2),NoteHeight-2);const vel=Math.min(1,note.velocity||0.8);ctx.fillStyle='#ff6464';ctx.fillRect(x+1,y+1,Math.max(2,(w-2)*vel),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){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,recordingState,recTempMidiNotes,showGhostNotes,sessionSyncMode,ghostLayers,renderBeatOffset]);React.useLayoutEffect(()=>{const canvas=ccCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const h=ccHeight;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=(renderBeatOffset+note.start_beat)*pixelsPerBeat;const isSelected=selectedNoteIds.includes(note.id);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'?isSelected?'#60a5fa':'#a78bfa':isSelected?'#3b82f6':'#fbbf24';ctx.lineWidth=2.5;ctx.beginPath();ctx.moveTo(x,h);ctx.lineTo(x,y);ctx.stroke();ctx.fillStyle=ccMode==='pan'?isSelected?'#3b82f6':'#c084fc':isSelected?'#3b82f6':'#fbbf24';ctx.beginPath();ctx.arc(x,y,3.5,0,2*Math.PI);ctx.fill();});},[notes,ccMode,rollZoom,viewWidth,selectedNoteIds,renderBeatOffset]);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);},[]);// Sync ghost play data to subTab state for playback integration React.useEffect(function(){if(!sessionSyncMode||!showGhostNotes||!ghostLayers.length){setSubTabs(function(prev){return prev.map(function(s){if(s.id!==st.id)return s;return Object.assign({},s,{ghostPlayLayers:[]});});});return;}var layers=[];ghostLayers.forEach(function(layer){if(activePlayTrackIds===null||activePlayTrackIds!==layer.track_id)return;var trk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});layers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:trk?trk.instrumentProgram:undefined,instrumentName:trk?trk.instrumentName:undefined,synthEngine:trk?trk.synth_engine:undefined});});setSubTabs(function(prev){return prev.map(function(s){if(s.id!==st.id)return s;return Object.assign({},s,{ghostPlayLayers:layers});});});},[ghostLayers,activePlayTrackIds,sessionSyncMode,showGhostNotes,st.id,activeTracks]);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-renderBeatOffset;const pitch=127-Math.floor(y/NoteHeight);if(scaleMenuPos)setScaleMenuPos(null);// Right click -> delete note (if on note) or prepare for sweep-drag diff --git a/wiki.md b/wiki.md index 500f800..2a8c3c0 100644 --- a/wiki.md +++ b/wiki.md @@ -849,6 +849,12 @@ - **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx` - **Ghi chú/Test (nếu có):** `npm run build` — build passes. +### [2026-07-30 21:15] Task: PIANO ROLL draw notes snapped to grid +- **Tóm tắt thay đổi:** Note `start_beat` + `duration_beats` được snap bằng `getSnapBeat` trước khi vẽ lên canvas. Áp dụng cho active notes, ghost notes, recording notes. +- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` +- **Ghi chú/Test (nếu có):** `npm run build` pass. Note hiển thị thẳng hàng với grid lines theo snap value. +--- + ### [2026-07-30 21:07] Task: Fix PIANO ROLL snap grid — snap values inverted in 3 places - **Tóm tắt thay đổi:** snapVal mapping bị đảo ngược: snap='1' (1 beat) cho q=4.0 (4 beats) → grid vẽ mỗi 4 beat, note snap mỗi 4 beat. Fix: `getSnapBeat`, `getSnapDuration`, grid drawing: snap='1'→1.0, '1/2'→0.5, '1/4'→0.25, '1/8'→0.125, '1/16'→0.0625, '1/32'→0.03125, '4'→4.0. - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`