diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 17ef12b..3a2f359 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -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); diff --git a/wiki.md b/wiki.md index 5890bc6..d2b5e1d 100644 --- a/wiki.md +++ b/wiki.md @@ -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.