feat: piano roll 4 features - SNAP, velocity selected, auto-scroll, synth button
This commit is contained in:
+71
-20
@@ -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 (noteIdx !== -1) paintNote(noteIdx, val);
|
||||
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] };
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user