diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 8a3ccd0..1fbdef6 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -9643,6 +9643,44 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
// ponytail: chi khoi phuc khi project cu co curve; chua hop nhat migration
}, [notes]);
+ // CC lane grid: vẽ cột beat/bar GIỐNG HỆT piano roll (cùng pixelsPerBeat +
+ // snapBeats + timeSigNum) → không lệch khi zoom. snapBeats copy nguyên từ
+ // notes canvas để hai lane trùng khít.
+ const paintCCLaneGrid = (ctx, h) => {
+ let snapBeats = 0.25;
+ if (snapValue === '1') snapBeats = 1.0;
+ else if (snapValue === '1/2') snapBeats = 0.5;
+ else if (snapValue === '1/4') snapBeats = 0.25;
+ else if (snapValue === '1/8') snapBeats = 0.125;
+ else if (snapValue === '1/16') snapBeats = 0.0625;
+ else if (snapValue === '4') snapBeats = 4.0;
+ else if (snapValue === '1/32') snapBeats = 0.03125;
+ for (let beat = 0; beat <= viewBeats; beat += snapBeats) {
+ const x = beat * pixelsPerBeat;
+ if (x > viewWidth) 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();
+ }
+ };
+ // Nhan 0/+-: CC 0=min (velocity, expression...) ghi "0" o day; CC luong cuc
+ // (pan) ghi "+" dinh, "0" giua, "-" day.
+ const paintCCLaneZeroLabel = (ctx, h, bipolar) => {
+ ctx.fillStyle = '#8b8b95';
+ ctx.font = '10px sans-serif';
+ if (bipolar) {
+ ctx.fillText('+', 5, 12);
+ ctx.fillText('0', 5, h / 2 + 3);
+ ctx.fillText('-', 5, h - 5);
+ } else {
+ ctx.fillText('0', 5, h - 5);
+ }
+ };
+
// Ve CC11 lane (graph thread). Tach rieng de drag ve preview truc tiep
// (truoc day setNotes tung mousemove + doc notes tu closure stale -> node
// cu/moi xen ke -> flicker). previewNoteId != null: chi ve note do voi
@@ -9658,12 +9696,15 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, viewWidth, h);
- ctx.strokeStyle = '#252525';
+ paintCCLaneGrid(ctx, h);
+ // CC11 0=min → ghi "0" o day (khong ve duong giua nua).
+ ctx.strokeStyle = '#2f2f38';
ctx.lineWidth = 1;
ctx.beginPath();
- ctx.moveTo(0, h / 2);
- ctx.lineTo(viewWidth, h / 2);
+ ctx.moveTo(0, h - 10);
+ ctx.lineTo(viewWidth, h - 10);
ctx.stroke();
+ paintCCLaneZeroLabel(ctx, h, false);
const yOf = v => h - ((v / 127) * (h - 20) + 10);
if (notes.length === 0) return;
let minS = Infinity, maxE = -Infinity;
@@ -9719,13 +9760,27 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, viewWidth, h);
+ paintCCLaneGrid(ctx, h);
- ctx.strokeStyle = '#252525';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(0, h / 2);
- ctx.lineTo(viewWidth, h / 2);
- ctx.stroke();
+ if (ccMode === 'pan') {
+ // Luong cuc: 0 o giua (+ dinh / - day) — giu nguyen nhu cu.
+ ctx.strokeStyle = '#252525';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, h / 2);
+ ctx.lineTo(viewWidth, h / 2);
+ ctx.stroke();
+ paintCCLaneZeroLabel(ctx, h, true);
+ } else {
+ // 0=min: ve duong day tai gia tri 0 + nhan "0".
+ ctx.strokeStyle = '#2f2f38';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, h - 10);
+ ctx.lineTo(viewWidth, h - 10);
+ ctx.stroke();
+ paintCCLaneZeroLabel(ctx, h, false);
+ }
if (ccMode === 'cc11') {
// CC11 expression: graph thread cho note dang chon; khi dang drag ve
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index 6ea0f80..84963f8 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -745,12 +745,20 @@ const[showCC,setShowCC]=React.useState(true);const[ccHeight,setCcHeight]=React.u
// sinh lai per-note expression_curve (engine doc per-note) bang cach sample
// global tai vung tung note.
const[cc11GlobalCurve,setCc11GlobalCurve]=React.useState([]);React.useEffect(()=>{if(cc11GlobalCurve&&cc11GlobalCurve.length)return;const n=notes.find(x=>Array.isArray(x.expression_curve)&&x.expression_curve.length);if(n)setCc11GlobalCurve(n.expression_curve.slice());// ponytail: chi khoi phuc khi project cu co curve; chua hop nhat migration
-},[notes]);// Ve CC11 lane (graph thread). Tach rieng de drag ve preview truc tiep
+},[notes]);// CC lane grid: vẽ cột beat/bar GIỐNG HỆT piano roll (cùng pixelsPerBeat +
+// snapBeats + timeSigNum) → không lệch khi zoom. snapBeats copy nguyên từ
+// notes canvas để hai lane trùng khít.
+const paintCCLaneGrid=(ctx,h)=>{let snapBeats=0.25;if(snapValue==='1')snapBeats=1.0;else if(snapValue==='1/2')snapBeats=0.5;else if(snapValue==='1/4')snapBeats=0.25;else if(snapValue==='1/8')snapBeats=0.125;else if(snapValue==='1/16')snapBeats=0.0625;else if(snapValue==='4')snapBeats=4.0;else if(snapValue==='1/32')snapBeats=0.03125;for(let beat=0;beat<=viewBeats;beat+=snapBeats){const x=beat*pixelsPerBeat;if(x>viewWidth)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();}};// Nhan 0/+-: CC 0=min (velocity, expression...) ghi "0" o day; CC luong cuc
+// (pan) ghi "+" dinh, "0" giua, "-" day.
+const paintCCLaneZeroLabel=(ctx,h,bipolar)=>{ctx.fillStyle='#8b8b95';ctx.font='10px sans-serif';if(bipolar){ctx.fillText('+',5,12);ctx.fillText('0',5,h/2+3);ctx.fillText('-',5,h-5);}else{ctx.fillText('0',5,h-5);}};// Ve CC11 lane (graph thread). Tach rieng de drag ve preview truc tiep
// (truoc day setNotes tung mousemove + doc notes tu closure stale -> node
// cu/moi xen ke -> flicker). previewNoteId != null: chi ve note do voi
// previewCurve thay cho expression_curve da commit.
-const paintCC11Lane=previewCurve=>{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.setTransform(dpr,0,0,dpr,0,0);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();const yOf=v=>h-(v/127*(h-20)+10);if(notes.length===0)return;let minS=Infinity,maxE=-Infinity;notes.forEach(n=>{minS=Math.min(minS,n.start_beat);maxE=Math.max(maxE,n.start_beat+(n.duration_beats||1));});const x0=(renderBeatOffset+minS)*pixelsPerBeat;const x1=(renderBeatOffset+maxE)*pixelsPerBeat;// Dashed lien tuc tu note dau den note cuoi (default line)
-ctx.strokeStyle='#2f6b57';ctx.lineWidth=1.5;ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(x0,yOf(127));ctx.lineTo(x1,yOf(127));ctx.stroke();ctx.setLineDash([]);const curve=(previewCurve||cc11GlobalCurve||[]).slice().sort((a,b)=>a.position_ratio-b.position_ratio);if(!curve.length)return;ctx.strokeStyle='#34d399';ctx.lineWidth=2;ctx.beginPath();if(curve.length===1){ctx.moveTo(x0,yOf(curve[0].value));ctx.lineTo(x1,yOf(curve[0].value));}else{curve.forEach((p,i)=>{const x=x0+p.position_ratio*(x1-x0);const y=yOf(p.value);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);});}ctx.stroke();curve.forEach(p=>{const x=x0+p.position_ratio*(x1-x0);ctx.fillStyle='#a7f3d0';ctx.beginPath();ctx.arc(x,yOf(p.value),4,0,2*Math.PI);ctx.fill();});};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();if(ccMode==='cc11'){// CC11 expression: graph thread cho note dang chon; khi dang drag ve
+const paintCC11Lane=previewCurve=>{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.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle='#161616';ctx.fillRect(0,0,viewWidth,h);paintCCLaneGrid(ctx,h);// CC11 0=min → ghi "0" o day (khong ve duong giua nua).
+ctx.strokeStyle='#2f2f38';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h-10);ctx.lineTo(viewWidth,h-10);ctx.stroke();paintCCLaneZeroLabel(ctx,h,false);const yOf=v=>h-(v/127*(h-20)+10);if(notes.length===0)return;let minS=Infinity,maxE=-Infinity;notes.forEach(n=>{minS=Math.min(minS,n.start_beat);maxE=Math.max(maxE,n.start_beat+(n.duration_beats||1));});const x0=(renderBeatOffset+minS)*pixelsPerBeat;const x1=(renderBeatOffset+maxE)*pixelsPerBeat;// Dashed lien tuc tu note dau den note cuoi (default line)
+ctx.strokeStyle='#2f6b57';ctx.lineWidth=1.5;ctx.setLineDash([4,4]);ctx.beginPath();ctx.moveTo(x0,yOf(127));ctx.lineTo(x1,yOf(127));ctx.stroke();ctx.setLineDash([]);const curve=(previewCurve||cc11GlobalCurve||[]).slice().sort((a,b)=>a.position_ratio-b.position_ratio);if(!curve.length)return;ctx.strokeStyle='#34d399';ctx.lineWidth=2;ctx.beginPath();if(curve.length===1){ctx.moveTo(x0,yOf(curve[0].value));ctx.lineTo(x1,yOf(curve[0].value));}else{curve.forEach((p,i)=>{const x=x0+p.position_ratio*(x1-x0);const y=yOf(p.value);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);});}ctx.stroke();curve.forEach(p=>{const x=x0+p.position_ratio*(x1-x0);ctx.fillStyle='#a7f3d0';ctx.beginPath();ctx.arc(x,yOf(p.value),4,0,2*Math.PI);ctx.fill();});};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);paintCCLaneGrid(ctx,h);if(ccMode==='pan'){// Luong cuc: 0 o giua (+ dinh / - day) — giu nguyen nhu cu.
+ctx.strokeStyle='#252525';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h/2);ctx.lineTo(viewWidth,h/2);ctx.stroke();paintCCLaneZeroLabel(ctx,h,true);}else{// 0=min: ve duong day tai gia tri 0 + nhan "0".
+ctx.strokeStyle='#2f2f38';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,h-10);ctx.lineTo(viewWidth,h-10);ctx.stroke();paintCCLaneZeroLabel(ctx,h,false);}if(ccMode==='cc11'){// CC11 expression: graph thread cho note dang chon; khi dang drag ve
// preview tu ccDragRef (khong phu thuoc state da commit).
const drag=ccDragRef.current;if(drag&&drag.mode==='cc11'&&drag.previewCurve){paintCC11Lane(drag.previewCurve);}else{paintCC11Lane();}return;}else{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,ccHeight,showCC]);// Follow playhead: khi PLAY — playhead luôn ở GIỮA view, notes trôi sang
// trái (scroll theo st.currentTime); khi STOP — scroll về đầu (playhead ở
diff --git a/app/templates/index.html b/app/templates/index.html
index 031aa28..b261919 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -51,7 +51,7 @@
-
+