fix: brush velocity isolation from CC lane + AGENTs.md compliance

This commit is contained in:
2026-07-25 09:18:41 +07:00
parent c5c3ec19cc
commit aa63a310fc
2 changed files with 165 additions and 35 deletions
+130 -35
View File
@@ -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"