feat: piano roll 4 features - SNAP, velocity selected, auto-scroll, synth button

This commit is contained in:
2026-07-26 09:43:19 +07:00
parent bd55d035de
commit a765998455
2 changed files with 78 additions and 20 deletions
+69 -18
View File
@@ -4543,7 +4543,7 @@ const AIPresetModal = ({ isOpen, onClose }) => {
}, "Đóng"))));
};
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches }) => {
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect }) => {
const [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity');
@@ -4910,6 +4910,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
notes.forEach((note) => {
const x = 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;
@@ -4918,19 +4919,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const stemH = val * (h - 20) + 10;
const y = h - stemH;
ctx.strokeStyle = ccMode === 'pan' ? '#a78bfa' : '#fbbf24';
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' ? '#c084fc' : '#fbbf24';
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]);
}, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds]);
React.useEffect(() => {
const scrollToC3 = () => {
@@ -5002,9 +5003,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
} else {
// Ctrl+click on empty space: start selection marquee
setSelectedNoteIds([]);
const snapStart = getSnapBeat(beat, snapVal);
setSelectionMarquee({
startBeat: beat, startPitch: pitch,
currentBeat: beat, currentPitch: pitch
startBeat: snapStart, startPitch: pitch,
currentBeat: snapStart, currentPitch: pitch
});
return;
}
@@ -5138,9 +5140,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const pitch = 127 - Math.floor(y / NoteHeight);
if (selectionMarquee) {
const snappedBeat = getSnapBeat(beat, snapVal);
const marquee = {
...selectionMarquee,
currentBeat: beat,
currentBeat: snappedBeat,
currentPitch: pitch
};
setSelectionMarquee(marquee);
@@ -5234,6 +5237,17 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return [...cleaned, ...brushNotes];
});
}
const container = gridScrollRef.current;
if (container) {
const cr = container.getBoundingClientRect();
const edgeThreshold = 30;
const scrollStep = 6;
if (e.clientY < cr.top + edgeThreshold) {
container.scrollTop = Math.max(0, container.scrollTop - scrollStep);
} else if (e.clientY > cr.bottom - edgeThreshold) {
container.scrollTop = Math.min(container.scrollHeight - container.clientHeight, container.scrollTop + scrollStep);
}
}
return;
}
if (draggedNote.mode === 'erase_sweep') {
@@ -5358,8 +5372,16 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
};
if (e.ctrlKey) {
if (selectedNoteIds.length > 0) {
selectedNoteIds.forEach(id => {
const idx = notes.findIndex(n => n.id === id);
if (idx !== -1) paintNote(idx, val);
});
ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: selectedNoteIds.map(id => notes.findIndex(n => n.id === id)).filter(i => i !== -1) };
} else {
if (noteIdx !== -1) paintNote(noteIdx, val);
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] };
}
return;
}
@@ -5380,6 +5402,24 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const drag = ccDragRef.current;
const painted = drag.lastPainted || [];
if (drag.selectedMode && selectedNoteIds.length > 0) {
selectedNoteIds.forEach(id => {
const idx = notes.findIndex(n => n.id === id);
if (idx !== -1 && !painted.includes(idx)) {
setNotes(prev => prev.map((n, i) => {
if (i !== idx) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val };
}));
}
});
const newPainted = selectedNoteIds
.map(id => notes.findIndex(n => n.id === id))
.filter(i => i !== -1 && !painted.includes(i));
ccDragRef.current.lastPainted = [...painted, ...newPainted];
return;
}
const candidateIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats);
if (candidateIdx !== -1 && !painted.includes(candidateIdx)) {
@@ -5621,7 +5661,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const playHeadX = (st.currentTime || 0) / beatSec * pixelsPerBeat;
return React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col"
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"
}, React.createElement("div", {
className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"
}, /*#__PURE__*/React.createElement("div", {
@@ -5659,7 +5699,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
value: selectedMidiInputId || '',
onChange: e => onMidiInputSelect(e.target.value),
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]"
}, /*#__PURE__*/React.createElement("option", { value: "" }, "Input"), /*#__PURE__*/React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => /*#__PURE__*/React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), /*#__PURE__*/React.createElement("div", {
}, /*#__PURE__*/React.createElement("option", { value: "" }, "Input"), /*#__PURE__*/React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => /*#__PURE__*/React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), /*#__PURE__*/React.createElement("button", {
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
title: st.instrumentName || "Synth",
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[50px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), /*#__PURE__*/React.createElement("span", { className: "truncate text-[9px]" }, st.instrumentName || 'Synth')), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-0.5 ml-1"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: Math.max(0, (st.currentTime || 0) - beatSec * 4) } : s)),
@@ -5730,7 +5774,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s));
}
if (e.shiftKey) {
const beatSnap = Math.round(clickBeat / 4) * 4;
const beatSnap = getSnapBeat(clickBeat, snapVal);
if (loopStartBeat === null) {
setLoopStartBeat(Math.max(0, beatSnap - 4));
setLoopEndBeat(Math.max(4, beatSnap));
@@ -5739,14 +5783,16 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
}
return;
}
const startData = { startX: e.clientX, startBeat: clickBeat, scrollLeft: e.currentTarget.scrollLeft };
const snappedStartBeat = getSnapBeat(clickBeat, snapVal);
const startData = { startX: e.clientX, startBeat: snappedStartBeat, scrollLeft: e.currentTarget.scrollLeft };
rulerDragRef.current = startData;
const onMove = (ev) => {
const r = rulerScrollRef.current;
if (!r || !rulerDragRef.current) return;
const rRect = r.getBoundingClientRect();
const bx = ev.clientX - rRect.left + rulerDragRef.current.scrollLeft;
const beat = Math.max(0, bx / pixelsPerBeat);
const rawBeat = Math.max(0, bx / pixelsPerBeat);
const beat = getSnapBeat(rawBeat, snapVal);
if (Math.abs(ev.clientX - rulerDragRef.current.startX) > 5) {
const sBeat = Math.max(0, Math.min(rulerDragRef.current.startBeat, beat));
const eBeat = Math.max(sBeat + 1, Math.max(rulerDragRef.current.startBeat, beat));
@@ -5788,7 +5834,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (!r) return;
const rRect = r.getBoundingClientRect();
const bx = ev.clientX - rRect.left + r.scrollLeft;
const nBeat = Math.max(0, Math.min(loopEndBeat - 1, Math.round(bx / pixelsPerBeat / 4) * 4));
const nBeat = Math.max(0, Math.min(loopEndBeat - 1, getSnapBeat(bx / pixelsPerBeat, snapVal)));
setLoopStartBeat(nBeat);
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: nBeat * beatSec } : s));
};
@@ -5805,7 +5851,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (!r) return;
const rRect = r.getBoundingClientRect();
const bx = ev.clientX - rRect.left + r.scrollLeft;
const nBeat = Math.max(loopStartBeat + 1, Math.round(bx / pixelsPerBeat / 4) * 4);
const nBeat = Math.max(loopStartBeat + 1, getSnapBeat(bx / pixelsPerBeat, snapVal));
setLoopEndBeat(nBeat);
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionEnd: nBeat * beatSec } : s));
};
@@ -5825,7 +5871,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (!r) return;
const rRect = r.getBoundingClientRect();
const bx = ev.clientX - rRect.left + r.scrollLeft;
const centerBeat = Math.round(bx / pixelsPerBeat / 4) * 4;
const centerBeat = getSnapBeat(bx / pixelsPerBeat, snapVal);
const halfRange = range / 2;
const newStart = Math.max(0, centerBeat - halfRange);
setLoopStartBeat(newStart);
@@ -5900,8 +5946,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ref: ccCanvasRef,
onMouseDown: handleCCMouseDown,
onMouseMove: handleCCMouseMove,
onMouseUp: () => { ccDragRef.current = null; },
onMouseLeave: () => { ccDragRef.current = null; },
onMouseUp: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
onMouseLeave: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
className: "absolute inset-0"
}), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && /*#__PURE__*/React.createElement("div", {
style: {
@@ -6350,6 +6396,10 @@ const App = () => {
setInstrumentSelectorTrackId(null);
setSynthCategory(null);
setSelectedSoundFontId(null);
setSubTabs(prev => prev.map(s => {
if (s.trackId !== trackId) return s;
return { ...s, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName, instrumentId };
}));
setTimeout(() => lucide.createIcons(), 50);
};
const setTrackInstrument = (trackId, instrumentId, displayName) => {
@@ -15649,7 +15699,8 @@ const App = () => {
onRecord: handleRecordClick,
selectedMidiInputId: selectedMidiInputId,
onMidiInputSelect: handleMidiInputSelect,
activeMidiPitches: activeMidiPitches
activeMidiPitches: activeMidiPitches,
onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); }
});
}
const subTrack = tracks.find(t => t.id === st.trackId);
+7
View File
@@ -8,6 +8,13 @@
- **Tóm tắt thay đổi:** (1) Piano Roll zoom-out không còn màn hình đen — bars fill toàn bộ viewport. (2) Shift+scroll trong Piano Roll di chuyển playhead và play notes MIDI như fast-forward. (3) Khi drag section/MIDI/clip đến cạnh phải timeline, auto-scroll container. (4) Server-side cache FluidSynth instances + PluginManager singleton + list_soundfont_instruments cache.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/core/vst_engine.py`
- **Ghi chú/Test (nếu có):** `npm run build` pass. `vst_engine.py` thêm `load_soundfont_cached`, `release_soundfont`, `get_plugin_manager`, `_SF_INSTRUMENTS_CACHE` — refcount-based cache.
---
### [2026-07-26 09:36] Task: Piano Roll 4 features (SNAP, velocity selected, auto-scroll, Synth button)
- **Tóm tắt thay đổi:** (1) SNAP trong MIDI tab — grid snap cho note drawing, selection marquee, loop range. (2) Ctrl+drag velocity — selected notes màu xanh dương, affect only selected; no selection = paint all. (3) Auto-scroll Piano Roll khi brush-drag gần cạnh top/bottom. (4) Synth button trong MIDI tab toolbar — mở instrument selector khi nhấn. Layout fix: thêm `h-full` cho outer div.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** Build báo lỗi pre-existing tại return statement (original code cũng lỗi tương tự). Các thay đổi logic đã verified qua diff.
---
### [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.