From aa63a310fcfaf60cb07e5d93ec7ae7edaf8fcbf1 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 25 Jul 2026 09:18:41 +0700 Subject: [PATCH] fix: brush velocity isolation from CC lane + AGENTs.md compliance --- app/static/js/app.jsx | 165 +++++++++++++++++++++++++++++++++--------- wiki.md | 35 +++++++++ 2 files changed, 165 insertions(+), 35 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index b3f14a6..7fba6a1 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -438,7 +438,9 @@ const WaveformLane = ({ // Grid lines based on Snap value ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; ctx.lineWidth = 1; - const PADDING_LEFT = 2; // seconds of empty space on left edge + const beatDuration = (60.0 / (parseFloat(bpm) || 120)); + const barDuration = beatDuration * 4; + const PADDING_LEFT = beatDuration; // 1-beat margin on left const tStart = scrollLeftVal / zoom - PADDING_LEFT; const tEnd = (scrollLeftVal + drawWidth) / zoom; let gridSpacing = 1.0; @@ -1215,7 +1217,7 @@ const TempoTrackLane = ({ ctx.fillRect(0, 0, drawWidth, height); const beatDuration = 60 / bpm; const barDuration = beatDuration * 4; - const PADDING_LEFT = 2; // seconds of empty space on left edge + const PADDING_LEFT = beatDuration; // 1-beat margin on left const tStart = scrollLeftVal / zoom - PADDING_LEFT; const tEnd = (scrollLeftVal + drawWidth) / zoom; const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; @@ -4440,6 +4442,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot const viewBeats = Math.ceil(viewWidth / pixelsPerBeat) + 4; const [notes, setNotes] = React.useState(st.notes || []); + const brushVelocityRef = React.useRef(0.8); const [selectedNoteIds, setSelectedNoteIds] = React.useState([]); // Undo/redo stacks @@ -4683,7 +4686,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const h = 80; + const h = ccHeight; canvas.width = viewWidth * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); @@ -4899,7 +4902,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot pitch: snapPitchToScale(pitch, selectedScale), start_beat: start, duration_beats: initialDur, - velocity: 0.8, + velocity: brushVelocityRef.current, pan: 0.0 }; setNotes(prev => [...prev, newNote]); @@ -4999,7 +5002,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot pitch: p, start_beat: draggedNote.initialBeat + i * perNoteDur, duration_beats: Math.max(0.125, perNoteDur * 0.9), - velocity: 0.8, + velocity: brushVelocityRef.current, pan: 0.0 })); const newBrushIds = brushNotes.map(bn => bn.id); @@ -5244,6 +5247,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot const [scaleMenuPos, setScaleMenuPos] = React.useState(null); const [aiPrompt, setAiPrompt] = React.useState(''); const [aiLoading, setAiLoading] = React.useState(false); + const [showCC, setShowCC] = React.useState(true); + const [ccHeight, setCcHeight] = React.useState(80); const snapPitchToScale = (pitch, scale) => { if (!scale) return pitch; @@ -5398,7 +5403,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot key: mode, onClick: () => setCcMode(mode), className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}` - }, mode)))), /*#__PURE__*/React.createElement("div", { + }, mode)))), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowCC(!showCC), + className: `px-2 py-1 rounded text-xs ${showCC ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}` + }, ccMode === 'pan' ? 'Pan' : 'Vel'), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 flex-1 max-w-[300px] ml-2" }, /*#__PURE__*/React.createElement("input", { type: "text", @@ -5475,9 +5483,21 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot onMouseLeave: handleGridMouseUp, onContextMenu: handleContextMenu, className: "absolute inset-0 cursor-crosshair" - })))), /*#__PURE__*/React.createElement("div", { - className: "h-20 bg-[#161616] border-t border-zinc-900 flex shrink-0" + })))), showCC && /*#__PURE__*/React.createElement("div", { + style: { height: `${ccHeight}px` }, + className: "bg-[#161616] border-t border-zinc-900 flex shrink-0 relative" }, /*#__PURE__*/React.createElement("div", { + onMouseDown: e => { + e.preventDefault(); + const startY = e.clientY; + const startH = ccHeight; + const onMove = ev => { setCcHeight(Math.max(40, startH + ev.clientY - startY)); }; + const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + className: "absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30" + }), /*#__PURE__*/React.createElement("div", { className: "w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold" }, ccMode.toUpperCase()), /*#__PURE__*/React.createElement("div", { ref: ccWrapperRef, @@ -5494,7 +5514,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot onMouseMove: handleCCMouseMove, onMouseUp: () => { ccDragRef.current = null; }, onMouseLeave: () => { ccDragRef.current = null; }, - className: "absolute inset-0 cursor-ns-resize" + className: "absolute inset-0" })))), scaleMenuPos && renderScaleContextMenu()); }; @@ -6073,11 +6093,14 @@ const App = () => { recordingStateRef.current = recordingState; const lastTempCompileTimeRef = useRef(0); const nextMetronomeBeatRef = useRef(0); - const [showExportPanel, setShowExportPanel] = useState(true); + const [showExportPanel, setShowExportPanel] = useState(false); const [showAIPanel, setShowAIPanel] = useState(true); - const [showSelectionPanel, setShowSelectionPanel] = useState(true); - const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(true); - const [showMediaExplorer, setShowMediaExplorer] = useState(true); + const [showSelectionPanel, setShowSelectionPanel] = useState(false); + const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(false); + const [showMediaExplorer, setShowMediaExplorer] = useState(false); + const [scrollBufferExtra, setScrollBufferExtra] = useState(0); + const scrollBufferExtraRef = useRef(0); + scrollBufferExtraRef.current = scrollBufferExtra; const [showFxRack, setShowFxRack] = useState(false); const [showMidiEvents, setShowMidiEvents] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(320); @@ -8498,8 +8521,9 @@ const App = () => { // ── Computed Values ── const maxDuration = useMemo(() => { const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; + const cur = activeTracks; let max = 10; - tracks.forEach(t => { + cur.forEach(t => { const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ id: 'default', buffer: t.buffer, @@ -8524,8 +8548,9 @@ const App = () => { if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') { max = Math.max(max, currentTime + 60); } + max += secPerBar * 6 + scrollBufferExtra; return max; - }, [tracks, recordingState, currentTime]); + }, [activeTracks, recordingState, currentTime, bpm, scrollBufferExtra]); const maxDurationRef = useRef(maxDuration); maxDurationRef.current = maxDuration; const minZoom = useMemo(() => { @@ -10005,7 +10030,8 @@ const App = () => { const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { - const track = tracks.find(t => t.id === trackId); + const curTracks = activeTracksRef.current || activeTracks; + const track = curTracks.find(t => t.id === trackId); if (!track) return; const existingClips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default_' + track.id, @@ -10019,7 +10045,7 @@ const App = () => { if (isDuplicate) { const cloneId = nextClipId(); const clone = { ...clip, id: cloneId, startTime: clip.startTime || 0, name: clip.name + ' (Copy)' }; - setTracks(prev => prev.map(t => { + updateActiveTracks(prev => prev.map(t => { if (t.id === trackId) { const newClips = [...existingClips, clone]; return { ...t, clips: newClips, buffer: newClips[0].buffer, startTime: newClips[0].startTime, name: newClips[0].name }; @@ -10033,7 +10059,7 @@ const App = () => { return; } if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => { + updateActiveTracks(prev => prev.map(t => { if (t.id === trackId) { return { ...t, clips: existingClips }; } @@ -10049,7 +10075,8 @@ const App = () => { const stretchedClipRef = useRef(null); stretchedClipRef.current = stretchedClip; const handleClipStretchStart = (trackId, clipId, clickTime) => { - const track = tracks.find(t => t.id === trackId); + const curTracks = activeTracksRef.current || activeTracks; + const track = curTracks.find(t => t.id === trackId); if (!track) return; const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default_' + track.id, @@ -10062,7 +10089,7 @@ const App = () => { if (!clip || !clip.buffer) return; const beforeSnap = captureTrackSnapshot(trackId); if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => t.id === trackId ? { + updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips } : t)); @@ -10158,10 +10185,19 @@ const App = () => { const wrapper = timelineWrapperRef.current; if (!wrapper) return; const wr = wrapper.getBoundingClientRect(); - const margin = 60; + const margin = 80; if (clientX > wr.right - margin) { - const speed = 15; + const depth = (clientX - (wr.right - margin)) / margin; + const speed = Math.round(5 + depth * depth * 40); wrapper.scrollLeft += speed; + if (wrapper.scrollLeft > wrapper.scrollWidth - wrapper.clientWidth - 100) { + const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; + setScrollBufferExtra(prev => prev + secPerBar * 4); + } + } else if (clientX < wr.left + margin) { + const depth = ((wr.left + margin) - clientX) / margin; + const speed = Math.round(5 + depth * depth * 40); + wrapper.scrollLeft = Math.max(0, wrapper.scrollLeft - speed); } }; @@ -10176,10 +10212,14 @@ const App = () => { const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = mouseX / zoom; - const rawStart = Math.max(0, time - drag.clickOffset); - const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); + const beatSec = (60.0 / (parseInt(bpmRef.current) || 120)); + const rawStart = Math.max(beatSec, time - drag.clickOffset); + const secPerBar = beatSec * 4; + const marginBar = maxDurationRef.current - secPerBar; + const clampedStart = Math.min(rawStart, marginBar); + const newStart = snapTime(clampedStart, snapValueRef.current, bpmRef.current); const targetTrackId = hoveredTrackIdRef.current || drag.trackId; - setTracks(prev => prev.map(t => { + updateActiveTracks(prev => prev.map(t => { // Clear the clip from its previous track if it moved to a new track if (t.id === drag.trackId && drag.trackId !== targetTrackId) { const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId); @@ -10252,15 +10292,19 @@ const App = () => { const handleMouseMove = e => { const stretch = stretchedClipRef.current; if (!stretch) return; + autoScrollTimeline(e.clientX); const wrapper = timelineWrapperRef.current; if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = mouseX / zoom; - const newDuration = Math.max(0.1, time - stretch.startTime); + const secPerBar = (60.0 / (parseInt(bpmRef.current) || 120)) * 4; + const marginBar = maxDurationRef.current - secPerBar; + const maxEnd = Math.min(time, marginBar); + const newDuration = Math.max(0.1, maxEnd - stretch.startTime); const speedRatio = stretch.originalDuration / newDuration; - setTracks(prev => prev.map(t => { + updateActiveTracks(prev => prev.map(t => { if (t.id === stretch.trackId) { const updatedClips = (t.clips || []).map(c => { if (c.id === stretch.clipId) { @@ -10327,9 +10371,11 @@ const App = () => { const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = mouseX / zoom; - const newStart = Math.max(0, time - drag.clickOffset); + const beatSec = (60.0 / (parseInt(bpm) || 120)); + const secondsPerBar = beatSec * 4; + const marginBar = maxDurationRef.current - secondsPerBar; + const newStart = Math.max(beatSec, Math.min(time - drag.clickOffset, marginBar)); const targetTrackId = hoveredTrackIdRef.current || drag.trackId; - const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; updateActiveTracks(prev => { let movedItem = null; for (let track of prev) { @@ -10398,7 +10444,8 @@ const App = () => { if (idx === -1) return t; const item = items[idx]; if (resize.side === 'left') { - const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1); + const beatSec = (60.0 / (parseInt(bpm) || 120)); + const newStart = Math.max(beatSec, Math.min(time, resize.originalStart + resize.originalDuration - 0.1)); const end = resize.originalStart + resize.originalDuration; const newDuration = end - newStart; if (newDuration < 0.1) return t; @@ -10406,7 +10453,10 @@ const App = () => { ? { ...item, start: newStart, duration: newDuration } : { ...item, startTime: newStart, duration: newDuration }; } else { - const newDuration = Math.max(0.1, time - resize.originalStart); + const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; + const marginBar = maxDurationRef.current - secondsPerBar; + const clampedTime = Math.min(time, marginBar); + const newDuration = Math.max(0.1, clampedTime - resize.originalStart); items[idx] = { ...item, duration: newDuration }; } return resize.itemType === 'section' @@ -10950,7 +11000,8 @@ const App = () => { // ── Insert Section at Playhead ── const insertSectionAtPlayhead = () => { - const track = tracks.find(t => t.id === selectedTrackId); + const curTracks = activeTracks; + const track = curTracks.find(t => t.id === selectedTrackId); if (!track) { showToast('Chọn track trước', 'warning'); return; } const section = { id: `sec_${Date.now()}`, @@ -10959,7 +11010,7 @@ const App = () => { duration: 4, color: track.color || '#06b6d4' }; - setTracks(prev => prev.map(t => t.id === selectedTrackId ? { + updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? { ...t, sections: [...(t.sections || []), section] } : t)); @@ -11970,7 +12021,7 @@ const App = () => { if (!prompt) { showToast('Vui lòng nhập nội dung prompt.', 'warning'); return; } if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.currentSelectedTrackId = selectedTrackId; - window.DAWCommandDispatcher.currentTracks = tracks; + window.DAWCommandDispatcher.currentTracks = activeTracks; window.DAWCommandDispatcher.lastCutSourceTrackId = null; window.DAWCommandDispatcher.lastCutNewTrackId = null; } @@ -11994,7 +12045,7 @@ const App = () => { const model = provider.model_name || provider.model || 'deepseek-chat'; setAiActionLog(prev => [...prev, { type: 'info', text: ` Provider: ${provider.name || 'default'} | Model: ${model} | URL: ${baseUrl.slice(0, 40)}`, time: Date.now() }]); const dawContext = window.AIGateway.buildAIPromptContext({ - tracks, bpm, selectedTrackId, currentTime, selLeft, selRight + tracks: activeTracks, bpm, selectedTrackId, currentTime, selLeft, selRight }); let matchedInstruction = ''; @@ -15659,6 +15710,50 @@ const App = () => { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+M")), /*#__PURE__*/React.createElement("div", { className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("div", { + className: "px-3 py-0.5 text-[10px] text-zinc-500 font-bold uppercase tracking-wider" + }, "Insert"), /*#__PURE__*/React.createElement("button", { + onClick: () => { insertSectionAtPlayhead(); closeContextMenu(); }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "folder-plus", + className: "w-3.5 h-3.5 text-amber-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Insert Section")), /*#__PURE__*/React.createElement("button", { + onClick: () => { insertMidiItemAtPlayhead(); closeContextMenu(); }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "music", + className: "w-3.5 h-3.5 text-purple-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Insert MIDI Item")), /*#__PURE__*/React.createElement("button", { + onClick: () => { insertSoundClipAtCursor(); closeContextMenu(); }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-audio", + className: "w-3.5 h-3.5 text-emerald-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Insert Sound Clip")), /*#__PURE__*/React.createElement("button", { + onClick: () => { insertTrackBelow(); closeContextMenu(); }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus-square", + className: "w-3.5 h-3.5 text-cyan-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Insert Track")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" }), /*#__PURE__*/React.createElement("button", { onClick: contextMenuCopy, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" diff --git a/wiki.md b/wiki.md index f218213..c0e1f05 100644 --- a/wiki.md +++ b/wiki.md @@ -18,3 +18,38 @@ - **Tóm tắt thay đổi:** (1) CC lane velocity stem vẽ tại start note thay vì center. (2) Shift+scroll chỉ play note khi playhead vượt qua start_beat. (3) Thêm `viewWidth`/`viewBeats` vào dependency array grid canvas + CC canvas để auto-redraw khi resize/zoom. - **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. + +### [2026-07-25 08:31] Task: Fix AI Copilot tab scope + hide bottom panels + context menu Insert + autoscroll +- **Tóm tắt thay đổi:** (1) AI Copilot context dùng `activeTracks` thay `tracks` — chỉ tác động tab đang active. (2) Bottom panels mặc định ẩn. (3) Right-click track timeline có 4 mục Insert. (4) Thêm auto-scroll vào stretched clip resize. Sửa `insertSectionAtPlayhead` dùng `updateActiveTracks`. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass. + +### [2026-07-25 08:36] Task: Fix auto-scroll + maxDuration tab isolation + 1-bar margin +- **Tóm tắt thay đổi:** (1) `maxDuration` dùng `activeTracks` + 4-bar buffer. (2) Cách ly MAIN vs SECTION-TAB. (3) Clip/section/MIDI drag/stretch/resize clamp 1-bar from right. (4) Clip drag + stretched clip dùng `updateActiveTracks`. (5) Stretched clip handler thêm auto-scroll. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass. + +### [2026-07-25 08:53] Task: Add timeline left/right margin + dynamic buffer expansion +- **Tóm tắt thay đổi:** (1) PADDING_LEFT thay đổi từ 2s thành 2-bar margin ở cả WaveformLane và TempoTrackLane. (2) Thêm state `scrollBufferExtra` + auto-expand 4 bars khi auto-scroll gần đến rìa timeline. (3) `maxDuration` thêm `scrollBufferExtra` + 6 bars static buffer. (4) Clip drag/stretch dùng `activeTracks` + `updateActiveTracks` thay `tracks`/`setTracks`. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass. + +### [2026-07-25 09:04] Task: Fix left margin 1-beat, items always visible, bar/timeline sync +- **Tóm tắt thay đổi:** (1) PADDING_LEFT từ 2-bar xuống 1-beat ở cả WaveformLane và TempoTrackLane. (2) Drag clamp left ≥ 1 beat, right ≤ maxDuration - 1 bar. (3) Bar numbers + grid vẽ lại đồng bộ với scroll+zoom. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass. + +### [2026-07-25 09:08] Task: Fix Piano Roll CC lane always visible + resizable height +- **Tóm tắt thay đổi:** (1) `showCC` default `true` — CC lane luôn hiển thị. (2) Thêm `ccHeight` state + resize handle (drag top edge) — cho phép thay đổi độ cao CC lane. (3) Canvas dùng `ccHeight` thay hardcoded 80px. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass. + +### [2026-07-25 09:18] Task: Fix brush velocity isolation from CC lane +- **Tóm tắt thay đổi:** Thêm `brushVelocityRef` riêng biệt (0.8) — brush không còn bị ảnh hưởng bởi velocity paint từ CC lane. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass. + +### [2026-07-25 07:25] Task: Fix auto-scroll + maxDuration tab isolation + 1-bar margin +- **Tóm tắt thay đổi:** (1) `maxDuration` dùng `activeTracks` + 4-bar buffer. (2) Cách ly MAIN vs SECTION-TAB. (3) Clip/section/MIDI drag/stretch/resize clamp 1-bar from right. (4) Clip drag + stretched clip dùng `updateActiveTracks`. (5) Stretched clip handler thêm auto-scroll. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.